diff --git a/.env.example b/.env.example index e83350aee..c727c7af5 100644 --- a/.env.example +++ b/.env.example @@ -1,16 +1,14 @@ # Integration tests environment example # Copy this file to .env and configure to run integration tests locally. # -# ## Database Configuration## # Set DB_CONNECTION to the database you want to test against. # Tests in tests/Integration/Database will run against this connection. -# -# ## Redis Configuration ## -# Integration tests auto-skip if Redis is unavailable on default host/port. -# Set REDIS_HOST to run tests against a specific Redis instance. -# If REDIS_HOST is set explicitly, tests will fail (not skip) if Redis is unavailable. -# Database +# SQLite +# DB_CONNECTION=sqlite +# DB_DATABASE=/tmp/testing.sqlite + +# MySQL # DB_CONNECTION=mysql # DB_HOST=127.0.0.1 # DB_PORT=3306 @@ -18,24 +16,35 @@ # DB_USERNAME=root # DB_PASSWORD=password +# MariaDB +# DB_CONNECTION=mariadb +# DB_HOST=127.0.0.1 +# DB_PORT=3307 +# DB_DATABASE=testing +# DB_USERNAME=root +# DB_PASSWORD=password + +# Postgres +# DB_CONNECTION=pgsql +# DB_HOST=127.0.0.1 +# DB_PORT=5432 +# DB_DATABASE=testing +# DB_USERNAME=postgres +# DB_PASSWORD=password + # Redis # REDIS_HOST=127.0.0.1 # REDIS_PORT=6379 -# REDIS_AUTH= +# REDIS_AUTH=password # REDIS_DB=8 -# Integration Tests -# Copy this file to .env and configure to run integration tests locally. -# Tests are skipped by default. Set the RUN_*_INTEGRATION_TESTS vars to enable. # Meilisearch Integration Tests -RUN_MEILISEARCH_INTEGRATION_TESTS=false -MEILISEARCH_HOST=127.0.0.1 -MEILISEARCH_PORT=7700 -MEILISEARCH_KEY=secret +# MEILISEARCH_HOST=127.0.0.1 +# MEILISEARCH_PORT=7700 +# MEILISEARCH_KEY=secret # Typesense Integration Tests -RUN_TYPESENSE_INTEGRATION_TESTS=false -TYPESENSE_HOST=127.0.0.1 -TYPESENSE_PORT=8108 -TYPESENSE_API_KEY=secret -TYPESENSE_PROTOCOL=http +# TYPESENSE_HOST=127.0.0.1 +# TYPESENSE_PORT=8108 +# TYPESENSE_API_KEY=secret +# TYPESENSE_PROTOCOL=http diff --git a/.github/workflows/databases.yml b/.github/workflows/databases.yml new file mode 100644 index 000000000..9f604b4b1 --- /dev/null +++ b/.github/workflows/databases.yml @@ -0,0 +1,340 @@ +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.1.4-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@v5 + 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.1.4-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@v5 + 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.1.4-php8.4 + + strategy: + fail-fast: true + + name: MariaDB 10 + + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Cache Composer dependencies + uses: actions/cache@v5 + 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 + + mariadb_11: + runs-on: ubuntu-latest + timeout-minutes: 5 + + services: + mariadb: + image: mariadb:11 + 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.1.4-php8.4 + + strategy: + fail-fast: true + + name: MariaDB 11 + + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Cache Composer dependencies + uses: actions/cache@v5 + 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.1.4-php8.4 + + strategy: + fail-fast: true + + name: PostgreSQL 17 + + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Cache Composer dependencies + uses: actions/cache@v5 + 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.1.4-php8.4 + + strategy: + fail-fast: true + + name: PostgreSQL 18 + + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Cache Composer dependencies + uses: actions/cache@v5 + 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.1.4-php8.4 + + strategy: + fail-fast: true + + name: SQLite + + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Cache Composer dependencies + uses: actions/cache@v5 + 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 + DB_DATABASE: /tmp/testing.sqlite + run: | + touch /tmp/testing.sqlite + vendor/bin/phpunit tests/Integration/Database diff --git a/.github/workflows/engine.yml b/.github/workflows/engine.yml new file mode 100644 index 000000000..b1e94dd34 --- /dev/null +++ b/.github/workflows/engine.yml @@ -0,0 +1,45 @@ +name: engine + +on: + push: + pull_request: + +jobs: + engine: + runs-on: ubuntu-latest + timeout-minutes: 5 + + container: + image: phpswoole/swoole:6.1.4-php8.4 + + strategy: + fail-fast: true + + name: Engine Integration Tests + + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Cache Composer dependencies + uses: actions/cache@v5 + 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: Start test servers + run: | + php src/engine/examples/http_server.php & + php src/engine/examples/tcp_packet_server.php & + php src/engine/examples/websocket_server.php & + php src/engine/examples/http_server_v2.php & + sleep 3 + + - name: Execute integration tests + env: + ENGINE_TEST_SERVER_HOST: 127.0.0.1 + run: vendor/bin/phpunit tests/Integration/Engine tests/Integration/Guzzle diff --git a/.github/workflows/redis.yml b/.github/workflows/redis.yml index 913bfbb28..c170cb00a 100644 --- a/.github/workflows/redis.yml +++ b/.github/workflows/redis.yml @@ -20,6 +20,7 @@ jobs: --health-timeout 5s --health-retries 5 + # Swoole 6.1+ required for phpredis 6.3.0+ (HSETEX support for "any" tag mode) container: image: phpswoole/swoole:6.1.4-php8.4 @@ -30,10 +31,10 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Cache Composer dependencies - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: /root/.composer/cache key: composer-8.4-${{ hashFiles('composer.lock') }} @@ -49,7 +50,7 @@ jobs: REDIS_DB: 8 run: | vendor/bin/phpunit tests/Integration/Cache/Redis - vendor/bin/phpunit tests/Redis/Integration + vendor/bin/phpunit tests/Integration/Redis valkey_9: runs-on: ubuntu-latest @@ -66,6 +67,7 @@ jobs: --health-timeout 5s --health-retries 5 + # Swoole 6.1+ required for phpredis 6.3.0+ (HSETEX support for "any" tag mode) container: image: phpswoole/swoole:6.1.4-php8.4 @@ -76,10 +78,10 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Cache Composer dependencies - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: /root/.composer/cache key: composer-8.4-${{ hashFiles('composer.lock') }} @@ -95,4 +97,4 @@ jobs: REDIS_DB: 8 run: | vendor/bin/phpunit tests/Integration/Cache/Redis - vendor/bin/phpunit tests/Redis/Integration + vendor/bin/phpunit tests/Integration/Redis diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 95d838872..3350c6362 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,7 +16,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Remove optional "v" prefix id: version diff --git a/.github/workflows/scout.yml b/.github/workflows/scout.yml new file mode 100644 index 000000000..07018238d --- /dev/null +++ b/.github/workflows/scout.yml @@ -0,0 +1,98 @@ +name: scout + +on: + push: + pull_request: + +jobs: + meilisearch: + runs-on: ubuntu-latest + timeout-minutes: 5 + + services: + meilisearch: + image: getmeili/meilisearch:latest + env: + MEILI_MASTER_KEY: secret + MEILI_NO_ANALYTICS: true + ports: + - 7700:7700 + options: >- + --health-cmd "curl -f http://localhost:7700/health" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + container: + image: phpswoole/swoole:6.1.4-php8.4 + + strategy: + fail-fast: true + + name: Meilisearch + + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Cache Composer dependencies + uses: actions/cache@v5 + 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: + RUN_MEILISEARCH_INTEGRATION_TESTS: true + MEILISEARCH_HOST: meilisearch + MEILISEARCH_PORT: 7700 + MEILISEARCH_KEY: secret + run: vendor/bin/phpunit tests/Integration/Scout/Meilisearch + + typesense: + runs-on: ubuntu-latest + timeout-minutes: 5 + + services: + typesense: + image: typesense/typesense:27.1 + env: + TYPESENSE_API_KEY: secret + TYPESENSE_DATA_DIR: /tmp + ports: + - 8108:8108 + + container: + image: phpswoole/swoole:6.1.4-php8.4 + + strategy: + fail-fast: true + + name: Typesense + + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Cache Composer dependencies + uses: actions/cache@v5 + 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: + RUN_TYPESENSE_INTEGRATION_TESTS: true + TYPESENSE_HOST: typesense + TYPESENSE_PORT: 8108 + TYPESENSE_API_KEY: secret + TYPESENSE_PROTOCOL: http + run: vendor/bin/phpunit tests/Integration/Scout/Typesense diff --git a/.github/workflows/split.yml b/.github/workflows/split.yml index 1c4ded64d..cba26e395 100644 --- a/.github/workflows/split.yml +++ b/.github/workflows/split.yml @@ -16,7 +16,7 @@ jobs: SSH_PRIVATE_KEY: ${{ secrets.SPLIT_PRIVATE_KEY }} steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: 0 diff --git a/.github/workflows/static-analysis.yml b/.github/workflows/static-analysis.yml index 14c221ce1..dc25509a9 100644 --- a/.github/workflows/static-analysis.yml +++ b/.github/workflows/static-analysis.yml @@ -5,35 +5,36 @@ on: pull_request: jobs: - linux_tests: + types: runs-on: ubuntu-latest - if: "!contains(github.event.head_commit.message, '[skip ci]') && !contains(github.event.head_commit.message, '[ci skip]')" strategy: fail-fast: true matrix: include: - - php: "8.2" - swoole: "5.1.6" - - php: "8.3" - swoole: "5.1.6" - - php: "8.4" - swoole: "6.0.2" + - name: Source Code + config: phpstan.neon.dist + - name: Types + config: phpstan.types.neon.dist - name: PHP ${{ matrix.php }} (swoole-${{ matrix.swoole }}) + name: ${{ matrix.name }} container: - image: phpswoole/swoole:${{ matrix.swoole }}-php${{ matrix.php }} + image: phpswoole/swoole:6.1.4-php8.4 steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 + + - name: Cache Composer dependencies + uses: actions/cache@v5 + 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 + run: COMPOSER_MEMORY_LIMIT=-1 composer install --prefer-dist --no-interaction --no-progress - - name: Execute static analysis - run: | - vendor/bin/phpstan --configuration="phpstan.neon.dist" --memory-limit=-1 - vendor/bin/phpstan --configuration="phpstan.types.neon.dist" --memory-limit=-1 + - name: Execute type checking + run: vendor/bin/phpstan --configuration="${{ matrix.config }}" --memory-limit=-1 --no-progress diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 690cea7d8..545f4f0e2 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -13,12 +13,8 @@ jobs: fail-fast: true matrix: include: - - php: "8.2" - swoole: "5.1.6" - - php: "8.3" - swoole: "5.1.6" - php: "8.4" - swoole: "6.0.2" + swoole: "6.1.4" name: PHP ${{ matrix.php }} (swoole-${{ matrix.swoole }}) @@ -27,10 +23,15 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 + + - name: Install PHP extensions + run: | + apt-get update && apt-get install -y libgmp-dev libicu-dev + docker-php-ext-install gmp intl - name: Cache Composer dependencies - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: /root/.composer/cache key: composer-${{ matrix.php }}-${{ hashFiles('composer.lock') }} @@ -43,80 +44,4 @@ jobs: - name: Execute tests run: | PHP_CS_FIXER_IGNORE_ENV=1 vendor/bin/php-cs-fixer fix --dry-run --diff - vendor/bin/phpunit -c phpunit.xml.dist --exclude-group integration - - meilisearch_integration_tests: - runs-on: ubuntu-latest - if: "!contains(github.event.head_commit.message, '[skip ci]') && !contains(github.event.head_commit.message, '[ci skip]')" - - name: Integration (Meilisearch) - - services: - meilisearch: - image: getmeili/meilisearch:latest - env: - MEILI_MASTER_KEY: secret - MEILI_NO_ANALYTICS: true - ports: - - 7700:7700 - options: >- - --health-cmd "curl -f http://localhost:7700/health" - --health-interval 10s - --health-timeout 5s - --health-retries 5 - - container: - image: phpswoole/swoole:6.0.2-php8.4 - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Install dependencies - run: | - COMPOSER_MEMORY_LIMIT=-1 composer install --prefer-dist -n -o - - - name: Execute Meilisearch integration tests - env: - RUN_MEILISEARCH_INTEGRATION_TESTS: true - MEILISEARCH_HOST: meilisearch - MEILISEARCH_PORT: 7700 - MEILISEARCH_KEY: secret - run: | - vendor/bin/phpunit -c phpunit.xml.dist --group meilisearch-integration - - typesense_integration_tests: - runs-on: ubuntu-latest - if: "!contains(github.event.head_commit.message, '[skip ci]') && !contains(github.event.head_commit.message, '[ci skip]')" - - name: Integration (Typesense) - - services: - typesense: - image: typesense/typesense:27.1 - env: - TYPESENSE_API_KEY: secret - TYPESENSE_DATA_DIR: /tmp - ports: - - 8108:8108 - - container: - image: phpswoole/swoole:6.0.2-php8.4 - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Install dependencies - run: | - COMPOSER_MEMORY_LIMIT=-1 composer install --prefer-dist -n -o - - - name: Execute Typesense integration tests - env: - RUN_TYPESENSE_INTEGRATION_TESTS: true - TYPESENSE_HOST: typesense - TYPESENSE_PORT: 8108 - TYPESENSE_API_KEY: secret - TYPESENSE_PROTOCOL: http - run: | - vendor/bin/phpunit -c phpunit.xml.dist --group typesense-integration + vendor/bin/phpunit -c phpunit.xml.dist diff --git a/.gitignore b/.gitignore index 2b2880b06..60cf6711f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,15 +1,15 @@ .idea -/.env /.phpunit.cache -/.tmp /vendor composer.lock /phpunit.xml .phpunit.result.cache -.env -!tests/Foundation/fixtures/hyperf1/composer.lock +!tests/Foundation/fixtures/project1/composer.lock +!tests/Support/fixtures/composer/composer.lock tests/Http/fixtures .env +!tests/Support/envs/**/.env +/testing docs/node_modules/ docs/.vuepress/dist/ docs/.vuepress/.cache diff --git a/bin/test-servers.sh b/bin/test-servers.sh new file mode 100755 index 000000000..70fc424e9 --- /dev/null +++ b/bin/test-servers.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash + +# Start all engine test servers for integration testing. +# +# Usage: +# ./bin/test-servers.sh +# +# Servers: +# - HTTP server on port 19501 +# - TCP packet server on port 19502 +# - WebSocket server on port 19503 +# - HTTP v2 server on port 19505 +# +# Press Ctrl+C to stop all servers. + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(dirname "$SCRIPT_DIR")" + +# Track child PIDs for cleanup +PIDS=() + +cleanup() { + echo "" + echo "Stopping test servers..." + for pid in "${PIDS[@]}"; do + kill "$pid" 2>/dev/null || true + done + exit 0 +} + +trap cleanup EXIT + +echo "Starting engine test servers..." + +php "$PROJECT_DIR/src/engine/examples/http_server.php" & +PIDS+=($!) +echo " HTTP server started on port 19501 (PID: $!)" + +php "$PROJECT_DIR/src/engine/examples/tcp_packet_server.php" & +PIDS+=($!) +echo " TCP packet server started on port 19502 (PID: $!)" + +php "$PROJECT_DIR/src/engine/examples/websocket_server.php" & +PIDS+=($!) +echo " WebSocket server started on port 19503 (PID: $!)" + +php "$PROJECT_DIR/src/engine/examples/http_server_v2.php" & +PIDS+=($!) +echo " HTTP v2 server started on port 19505 (PID: $!)" + +echo "" +echo "All servers running. Press Ctrl+C to stop." +echo "" + +# Wait for all background processes +wait diff --git a/composer.json b/composer.json index f4bff62d7..253ef8b8a 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/", @@ -34,7 +35,10 @@ "Hypervel\\Config\\": "src/config/src/", "Hypervel\\Console\\": "src/console/src/", "Hypervel\\Container\\": "src/container/src/", + "Hypervel\\Context\\": "src/context/src/", + "Hypervel\\Coordinator\\": "src/coordinator/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/", @@ -42,6 +46,7 @@ "Hypervel\\Event\\": "src/event/src/", "Hypervel\\Filesystem\\": "src/filesystem/src/", "Hypervel\\Foundation\\": "src/foundation/src/", + "Hypervel\\Guzzle\\": "src/guzzle/src/", "Hypervel\\Hashing\\": "src/hashing/src/", "Hypervel\\Horizon\\": "src/horizon/src/", "Hypervel\\Http\\": "src/http/src/", @@ -52,6 +57,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/", @@ -61,33 +68,48 @@ "Hypervel\\Scout\\": "src/scout/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\\Testing\\": "src/testing/src/", "Hypervel\\Translation\\": "src/translation/src/", "Hypervel\\Validation\\": "src/validation/src/", "Hypervel\\Permission\\": "src/permission/src/", - "Hypervel\\Sentry\\": "src/sentry/src/" + "Hypervel\\Sentry\\": "src/sentry/src/", + "Hypervel\\Engine\\": "src/engine/src/" }, "files": [ "src/auth/src/Functions.php", "src/bus/src/Functions.php", "src/cache/src/Functions.php", "src/config/src/Functions.php", + "src/coordinator/src/Functions.php", "src/coroutine/src/Functions.php", + "src/engine/src/Functions.php", "src/event/src/Functions.php", "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/testbench/src/functions.php", "src/translation/src/Functions.php", - "src/core/src/helpers.php" + "src/context/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/" }, @@ -96,7 +118,7 @@ ] }, "require": { - "php": ">=8.2", + "php": ">=8.4", "ext-hash": "*", "ext-json": "*", "ext-mbstring": "*", @@ -104,6 +126,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", @@ -113,13 +136,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", @@ -138,9 +160,11 @@ "sentry/sentry": "^4.15", "symfony/error-handler": "^6.3", "symfony/mailer": "^6.2", + "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", @@ -148,46 +172,61 @@ "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/context": "self.version", + "hypervel/contracts": "self.version", + "hypervel/coordinator": "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", + "hypervel/engine": "self.version", "hypervel/event": "self.version", "hypervel/filesystem": "self.version", "hypervel/foundation": "self.version", + "hypervel/guzzle": "self.version", "hypervel/hashing": "self.version", "hypervel/horizon": "self.version", "hypervel/http": "self.version", "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/sanctum": "self.version", "hypervel/scout": "self.version", "hypervel/session": "self.version", "hypervel/socialite": "self.version", "hypervel/support": "self.version", "hypervel/telescope": "self.version", "hypervel/testbench": "self.version", + "hypervel/testing": "self.version", "hypervel/translation": "self.version", "hypervel/validation": "self.version", "hypervel/permission": "self.version", "hypervel/sentry": "self.version" }, "suggest": { - "hyperf/redis": "Required to use redis driver. (^3.1).", + "ext-intl": "Required to use number formatting and spellout features.", + "hypervel/redis": "Required to use redis driver. (^0.4).", "hypervel/session": "Required to use session guard. (^3.1).", "friendsofhyperf/tinker": "Required to use the tinker console command (^3.1).", "league/flysystem-read-only": "Required to use read-only disks (^3.3)", @@ -206,7 +245,6 @@ "filp/whoops": "^2.15", "friendsofphp/php-cs-fixer": "^3.57.2", "hyperf/devtool": "~3.1.0", - "hyperf/redis": "~3.1.0", "hyperf/testing": "~3.1.0", "hyperf/view-engine": "~3.1.0", "hypervel/facade-documenter": "dev-main", @@ -244,11 +282,13 @@ "Hypervel\\Bus\\ConfigProvider", "Hypervel\\Cache\\ConfigProvider", "Hypervel\\Cookie\\ConfigProvider", + "Hypervel\\Database\\ConfigProvider", "Hypervel\\Config\\ConfigProvider", "Hypervel\\Console\\ConfigProvider", "Hypervel\\Devtool\\ConfigProvider", "Hypervel\\Dispatcher\\ConfigProvider", "Hypervel\\Encryption\\ConfigProvider", + "Hypervel\\Engine\\ConfigProvider", "Hypervel\\Event\\ConfigProvider", "Hypervel\\Filesystem\\ConfigProvider", "Hypervel\\Foundation\\ConfigProvider", @@ -278,9 +318,20 @@ ] }, "branch-alias": { - "dev-main": "0.3-dev" + "dev-main": "0.4-dev" } }, + "scripts": { + "test": "phpunit", + "analyse": "phpstan analyse", + "lint": "php-cs-fixer fix --diff --dry-run", + "lint:fix": "php-cs-fixer fix", + "check": [ + "@test", + "@analyse", + "@lint" + ] + }, "minimum-stability": "dev", "prefer-stable": true } diff --git a/docs/ai/general.md b/docs/ai/general.md new file mode 100644 index 000000000..29ce44046 --- /dev/null +++ b/docs/ai/general.md @@ -0,0 +1,10 @@ +# General Code Style + +## Coroutine Context Keys + +Format: `__package_name.segment.segment` + +- `__` prefix on all framework keys — reserves the namespace so user keys never collide +- First segment = owning package in `snake_case` (kebab-case dirs become `snake_case`: `nested-set` → `nested_set`) +- `snake_case` throughout, never `camelCase` +- Store as `protected const` when used in multiple places within a class diff --git a/docs/ai/porting.md b/docs/ai/porting.md new file mode 100644 index 000000000..b84a26ecb --- /dev/null +++ b/docs/ai/porting.md @@ -0,0 +1,603 @@ +# Porting Guide + +## Background + +Hypervel is a Laravel-style Swoole framework originally built on top of Hyperf. We are decoupling from Hyperf and making Hypervel as close to 1:1 with Laravel as possible. This involves porting packages from both Hyperf (Swoole/coroutine infrastructure) and Laravel (application-level features). + +When porting, we keep packages as close to 1:1 with the originals as possible so merging upstream changes is easy later. The exceptions are: +- Modernising PHP types (PHP 8.4+ features, strict types) +- Adding Laravel-style title docblocks to methods (not classes — see rules below) +- For ported Laravel packages: making them coroutine-safe and adding Swoole performance enhancements (e.g., static property caching) +- General performance improvements — but stop and explain the opportunity first for approval + +## Directory Reference + +**Working directory for ALL operations (porting, commits, tests, phpstan, etc.):** + +`/home/binaryfire/workspace/monorepo/contrib/hypervel/components/` + +This is the Hypervel repo. Always `cd` into it before doing anything. + +Source references (read-only, for copying from): + +| Path | Description | +|------|-------------| +| `/home/binaryfire/workspace/monorepo/examples/laravel/framework/` | Laravel source reference | +| `/home/binaryfire/workspace/monorepo/examples/hyperf/hyperf/` | Hyperf source reference | + +## Porting Packages + +### Workflow + +#### 1. Package skeleton + +If the Hypervel version of the package doesn't exist yet, create the skeleton using an existing package as a template: +- **Porting a Hyperf package:** Use the `pool` package as reference +- **Porting a Laravel package:** Use the `cache` package as reference + +Read the reference package's `composer.json`, `LICENSE.md`, and `README.md`. Then read the components repo's root `composer.json` and add the new package following existing patterns. + +#### 2. Audit existing Hypervel package (if it exists) + +Read all files in the existing Hypervel package and categorise them: +- **Empty extensions** (class just extends Hyperf, no overrides/additions/properties): Delete these — they'll be replaced by ported versions +- **Custom classes** (don't extend Hyperf): Keep as-is +- **Extended classes with additions** (extend Hyperf + add overrides, methods, properties): Keep — the Hyperf parent's code must be merged into these + +#### 3. Create the todo list + +Check the source package (Hyperf or Laravel) to see what classes exist. Create a comprehensive todo list with a separate entry for each file to port. Each entry must clearly state the strategy: +- **Copy and update** — new file, no existing Hypervel equivalent +- **Merge** — existing Hypervel file with additions that must be preserved + +#### 4. Work through files one at a time, alphabetically + +**For newly copied files (copy and update):** +1. Copy the file using `cp` (never read → write) +2. Read the ENTIRE copied file (if small enough for one read) to understand context +3. Update namespaces, modernise types, add method docblocks, etc. + +**For merged files:** +1. Read BOTH the Hyperf/Laravel file AND the existing Hypervel file +2. Carefully merge the source file into the Hypervel file, preserving all Hypervel additions +3. Update namespaces, modernise types, add method docblocks, etc. + +**For large files that can't be read in one go:** +Work through in chunks from top to bottom — read a chunk, update, read next chunk, update. Do NOT try to search for patterns and update scattered bits. + +#### 5. Update consumers + +Search **both `src/` and `tests/`** for any `use` statements or references to the old namespace (e.g., `Hyperf\Coordinator\`) and update them to the new Hypervel namespace. Verify zero remaining references before proceeding. + +#### 6. Run phpstan + +After porting is complete, run phpstan on the newly ported package and fix errors. Investigate each error properly — don't reach for ignores without thinking it through. + +#### 7. Run full phpunit + +Run the full test suite (`./vendor/bin/phpunit`). Investigate all failures thoroughly — don't assume a failure is caused by the porting without confirming. For straightforward fixes (e.g., a missed namespace update), fix and continue. For anything more complex (behavioural changes, test logic issues, unclear root causes), stop and explain the cause along with your recommended fix for approval. + +### Rules + +- **Never use bulk modification tools** — no `sed`, `replace_all`, scripted loops, etc. All edits must be manual and targeted. +- **One file at a time** — never work on multiple files simultaneously. +- **Never use Write to overwrite files** — always use Edit for targeted updates. +- **Always use `cp` to copy files and `mv` to move/rename** — never read → write → delete. +- **No class docblocks unless warranted** — only add a class docblock if something unusual or complex needs explanation. Method docblocks (title only, Laravel-style) are always added. +- **Preserve existing comments** — do not remove them. Translate non-English comments to English and improve grammar when appropriate. +- **Stop on anything unusual** — missing dependencies, logic needing special consideration, things that don't make sense for Hypervel, etc. Explain the situation and your recommended solution. Do not proceed without approval. +- **Never skip or stub things out** — no removing code, no commenting out with "TODO once X is ported" placeholders. If such a situation arises, stop and explain with your recommendation. +- **Mark temporary compatibility paths with `@TODO:`** — when you add a real transitional fallback/shim during porting, add an inline `@TODO:` with the removal condition. Do not use `@TODO` to avoid implementing behavior now. +- **Stop on any source code bug** — if phpstan or tests expose a source bug (typing, logic, behavior), investigate, explain root cause, and provide a recommended fix for approval. +- **Use unions over `mixed` when types are known** — `mixed` is only for truly unconstrained values or cases that cannot be safely narrowed after control-flow analysis. +- **Type decisions must be evidence-based** — check corresponding Laravel/Hyperf signatures and docblocks as reference, then trace real control flow in method bodies and callers/callees. +- **Modernize types only in touched code** — do not refactor unrelated files unless required by confirmed control flow or a failing test. +- **Review worker-lifetime state explicitly** — whenever a change introduces or modifies static properties/caches/singletons, STOP and report the Swoole persistence impact (memory growth, cross-request behavior) with a recommendation: keep as-is for performance parity, or adapt for worker safety. +- **Flag cache opportunities with recommendation** — if a ported path repeatedly computes expensive stable metadata and worker-lifetime static caching would be a clear win, STOP and recommend it (what to cache, expected benefit, and safety constraints). + +## Porting Tests + +### Test Porting Workflow + +Follow the same cp-then-edit process as source files. This workflow applies to both Hyperf and Laravel test porting. + +#### 1. Audit source tests + +List all test files in the source package's `tests/` directory. For Laravel packages, also check `tests/Integration/{PackageName}/` — that's where Laravel puts its integration tests for each package. Note what each file covers. + +#### 2. Audit existing Hypervel tests (if any) + +Read all files in the existing Hypervel test directory for this package. Categorise them: +- **Custom tests** (Hypervel-specific, no Hyperf/Laravel equivalent): Keep as-is +- **Ported tests** (already ported from source): Keep — new source tests must be merged in + +#### 3. Create the todo list + +One entry per test file. Note the strategy: +- **Copy and update** — no existing Hypervel test for this +- **Merge** — Hypervel already has a test file with custom tests that must be preserved alongside the ported source tests +- **Integration** — needs external service, goes in `tests/Integration/{PackageName}/` +- **Blocked** — depends on unported code. STOP and explain what's blocked and why. Prefer adapting the test to work with the current codebase over commenting it out. Only comment out individual test methods (not whole files) as a last resort, with user approval. + +#### 4. Port test files one at a time + +**For newly copied files (copy and update):** +1. Copy the file using `cp` to the correct location +2. Read the ENTIRE copied file to understand context +3. Update namespaces, base class, imports, types, docblocks, etc. + +**For merged files:** +1. Read BOTH the source file AND the existing Hypervel file +2. Merge source tests into the Hypervel file, preserving all Hypervel-specific tests +3. Update namespaces, types, docblocks, etc. + +**For stub/helper files:** Copy `Stub/` directory files the same way. + +#### 5. Run tests after each file + +Use this exact cadence for each test class: +1. Port the test class. +2. Run that test class immediately (`./vendor/bin/phpunit --no-progress path/to/TestClass.php`). +3. Fix all straightforward failures. +4. If any failure exposes a source code bug (typing, logic, behavior), STOP and report root cause + recommended fix for approval. +5. Once green, commit that test class (and any approved source fixes) before moving to the next class. + +#### 6. Run full phpunit + +After all test files are ported, run the full test suite. Same rules as the source porting workflow — straightforward fixes go ahead, anything complex gets stopped and explained. + +### General Rules + +These apply to all test porting, regardless of whether the source is Hyperf or Laravel. + +#### Base Classes + +**Never extend `PHPUnit\Framework\TestCase` directly.** Always use one of these: + +| Class | Use When | +|-------|----------| +| `Hypervel\Tests\TestCase` | Unit tests, mocks only, no container needed | +| `Hypervel\Testbench\TestCase` | Integration tests, needs container (facades, config, DB, etc.) | + +Always call `parent::setUp()` in your setUp method. + +#### Coroutine Support + +Code that uses `Context` for state (like `DatabaseTransactionsManager`) requires tests to run in coroutines. Without this, Context state persists across tests since they share the non-coroutine context. + +**Add the `RunTestsInCoroutine` trait** to individual test classes that need it: +```php +use Hypervel\Foundation\Testing\Concerns\RunTestsInCoroutine; + +class MyTest extends TestCase +{ + use RunTestsInCoroutine; +} +``` + +Each test runs in a fresh coroutine. Context is automatically destroyed when the coroutine ends — no manual cleanup needed. + +**Optional hooks** (define if needed): +- `setUpInCoroutine()` — runs inside the coroutine before the test +- `tearDownInCoroutine()` — runs inside the coroutine after the test + +**Disabling coroutines:** If a base class uses `RunTestsInCoroutine` but a specific test class needs to run outside coroutine context, set `protected bool $enableCoroutine = false;` on that class. This is only relevant when the trait is present (via the class itself or a parent). + +#### Per-Package Base Test Cases + +Do **not** create per-package abstract test case classes (e.g., `EngineTestCase`, `CoroutineTestCase`) just for coroutine support. Use `Hypervel\Tests\TestCase` + trait directly. + +A per-package base class is only justified when there is shared setUp logic beyond just coroutines — e.g., shared container mock setup, shared helpers, or shared test fixtures that multiple test classes in the package need. + +#### Mockery + +**Always import as `m`:** Use `use Mockery as m;` and call `m::mock()`, `m::spy()`, etc. Never use the full `Mockery::` prefix. + +**Never add `Mockery::close()` to tearDown.** It's handled globally by `AfterEachTestExtension` for all tests. + +#### Docblocks and Types + +- Add `declare(strict_types=1);` at the top of every file +- Add `@internal` and `@coversNothing` docblock to every test class +- Do **not** add `: void` return type to test methods — existing tests don't use them, stay consistent + +#### phpstan + +The `tests/` directory is excluded from phpstan. Do not run phpstan on tests. + +#### Handling Failing Tests + +For tests that fail after conversion: + +1. **Easy fixes** (namespace typos, missing return types, etc.) — fix and continue +2. **Non-trivial failures** — STOP and investigate: + - Identify the root cause (missing feature, source bug, architectural difference) + - Explain what's missing and what adding it would involve + - Report findings and wait for instructions + +**You do not decide what tests to skip or remove.** Only the user makes that call after reviewing your investigation. + +#### Commenting Out Tests + +**Commenting out tests should be extremely rare.** Before proposing to comment out a test, first investigate whether small adaptations can make it work with the current Hypervel codebase (it can be updated again later when more things are ported). + +When commenting out is genuinely unavoidable (e.g., depends on a completely unported subsystem), **STOP and explain** what's blocked and why, and wait for approval. Never silently comment out tests. + +When approved, comment out **individual test methods** (not whole files) with a `@TODO` explaining what needs to happen: + +```php +// @TODO Enable once {package} is ported - depends on {SpecificClass} which doesn't exist yet +// public function testSomething(): void +// { +// ... +// } +``` + +This keeps the test visible for future searchability (`@TODO` grep) rather than requiring diffs against the source to discover what's missing. + +#### Removed Tests + +Removing tests is **incredibly rare** and should almost never happen. Always **STOP and explain** why you believe a test should be removed, and wait for approval. + +When the user approves removing a test, replace it with a comment **in the same position**: + +```php +// REMOVED: testMethodName - Reason for removal +``` + +This preserves the test's location so future diffs against Hyperf/Laravel show intentional removals rather than tests that look like they need porting. + +### Porting Hyperf Tests + +#### Directory Structure + +Ported Hyperf tests live in `tests/{PackageName}/` (PascalCase). There is no subdirectory — these are the primary tests for the package. + +#### Namespace Changes + +- `HyperfTest\{Package}` → `Hypervel\Tests\{Package}` +- All `Hyperf\` source imports → `Hypervel\` + +#### Boilerplate Removal + +- Remove the Hyperf license header block (`@link`, `@document`, `@contact`, `@license`) +- Remove PHPUnit attributes: `#[CoversNothing]`, `#[CoversClass(…)]`, `#[Group(…)]` — use PHPDoc annotations only + +#### Container Mocking + +Hyperf tests use `Psr\Container\ContainerInterface`. Change to `Hypervel\Contracts\Container\Container`: + +```php +// Hyperf +use Psr\Container\ContainerInterface; +$container = Mockery::mock(ContainerInterface::class); + +// Hypervel +use Hypervel\Contracts\Container\Container as ContainerContract; +$container = m::mock(ContainerContract::class); +``` + +#### Error Handler Mocking + +Hyperf tests use `StdoutLoggerInterface` + `FormatterInterface` for error reporting in coroutines. Hypervel uses `ExceptionHandler`: + +```php +// Hyperf +$container->shouldReceive('has')->withAnyArgs()->andReturnTrue(); +$container->shouldReceive('get')->with(StdoutLoggerInterface::class)->andReturn($logger); +$logger->shouldReceive('warning')->with('unit')->twice(); +$container->shouldReceive('get')->with(FormatterInterface::class)->andReturn($formatter); +$formatter->shouldReceive('format')->with($exception)->twice()->andReturn('unit'); + +// Hypervel +use Hypervel\Contracts\Debug\ExceptionHandler as ExceptionHandlerContract; +$container->shouldReceive('has')->with(ExceptionHandlerContract::class)->andReturnTrue(); +$container->shouldReceive('get')->with(ExceptionHandlerContract::class) + ->andReturn($handler = m::mock(ExceptionHandlerContract::class)); +$handler->shouldReceive('report')->with($exception)->twice(); +``` + +#### NonCoroutine Tests + +Hyperf uses `#[Group('NonCoroutine')]` on individual test methods to mark tests that must run outside a coroutine. In Hypervel, extract those methods to a separate test class. If the base class uses `RunTestsInCoroutine`, set `protected bool $enableCoroutine = false;` on the new class. + +#### Hyperf Quick Checklist + +1. Update namespace from `HyperfTest\{Package}` to `Hypervel\Tests\{Package}` +2. Add `declare(strict_types=1);` +3. Change `Hyperf\` imports to `Hypervel\` +4. Remove Hyperf license header and PHPUnit attributes +5. Extend `Hypervel\Tests\TestCase` (not `PHPUnit\Framework\TestCase`) +6. Add `use RunTestsInCoroutine;` if tests need coroutine context +7. Add `@internal` and `@coversNothing` docblock +8. Do **not** add `: void` return types to test methods +9. Change container mock to `Hypervel\Contracts\Container\Container` +10. Change error handler mock to `Hypervel\Contracts\Debug\ExceptionHandler` +11. Extract `#[Group('NonCoroutine')]` methods to separate class +12. Ensure `parent::setUp()` is called +13. Run tests and fix any remaining type errors + +### Porting 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 + +**Also check Laravel's `tests/Integration/{PackageName}/` directory** — that's where Laravel puts integration tests for each package. Those must be ported too, following the same workflow, to our `tests/Integration/{PackageName}/Laravel/` directory (or directly to `tests/Integration/{PackageName}/` if there's no need to separate from Hypervel-specific integration tests). + +#### Namespace Changes + +- Change `Illuminate\` to `Hypervel\` +- **Preserve Laravel's namespace structure** — just swap prefix and add `\Laravel`: + - `Illuminate\Tests\Integration\Database` → `Hypervel\Tests\Integration\Database\Laravel` + - `Illuminate\Tests\Integration\Database\EloquentFooTest` → `Hypervel\Tests\Integration\Database\Laravel\EloquentFooTest` + +If Laravel's namespace includes the test class name, keep it. Stripping it causes "Cannot redeclare class" errors. + +#### 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. + +#### When Tests Expose Source Code Type Errors + +If a Laravel test fails with a type error, the source code type may be wrong — not the test. Types should be **correct**, not just strict. A narrow type that doesn't cover all valid cases is incorrect. + +**How to identify:** +- Test returns/passes a type that the source code should accept but doesn't +- The type is a parent class of what's currently declared (e.g., `Support\Collection` vs `Eloquent\Collection`) + +**How to fix:** +1. Identify all valid types the method can accept/return +2. Use the common base type that covers all cases without being unnecessarily loose +3. Fix the source code, not the test + +**Example:** A method returns `Eloquent\Collection` normally, but an `afterQuery` callback can return `Support\Collection`. Since `Eloquent\Collection` extends `Support\Collection`, the correct return type is `Support\Collection` — it covers both cases precisely. + +**Wrong approach:** Removing types, using `mixed`, or modifying tests to avoid the type check. These hide the real issue. + +#### 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. + +#### Helper Class Namespacing + +Laravel tests define helper classes (models, stubs) with generic names like `User`, `Post`, `Comment`. When multiple test files use the same namespace and define classes with the same name, PHP throws "Cannot redeclare class" errors. + +**Use test-specific namespaces** (matching Laravel's pattern): + +```php +// WRONG - shared namespace causes conflicts +namespace Hypervel\Tests\Integration\Database\Laravel; + +class EloquentDeleteTest extends DatabaseTestCase { ... } +class Comment extends Model {} // Conflicts with Comment in other files! + +// CORRECT - test-specific namespace isolates classes +namespace Hypervel\Tests\Integration\Database\Laravel\EloquentDeleteTest; + +class EloquentDeleteTest extends DatabaseTestCase { ... } +class Comment extends Model {} // No conflict - different namespace +``` + +The namespace includes the test class name as the final segment. This means: +- Each test file has its own namespace +- Helper classes can use simple names (`Comment`, `Post`, `User`) +- No `$table` properties needed (Eloquent derives `comments` from `Comment`) +- No explicit foreign keys needed (Eloquent derives `user_id` from `User`) + +PHPUnit loads test files directly (not via autoloading), so the namespace doesn't need to match the directory structure. + +#### Unsupported Features + +Tests for these features should be **removed** (not commented out) without asking — they will never be supported: + +- **Databases:** SQL Server, MongoDB, DynamoDB — Hypervel only supports MySQL, MariaDB, PostgreSQL, and SQLite +- **Cache drivers:** Memcached, DynamoDB, MongoDB +- **Dynamic connections:** `DB::build()`, `DB::connectUsing()` — incompatible with Swoole connection pooling + +This list is exhaustive. Any other missing functionality is "not yet ported" and requires investigation and reporting. + +#### Temporary Workarounds (Until illuminate/events Is Ported) + +Hypervel currently uses Hyperf's event system, which has some differences from Laravel's. These workarounds apply until `illuminate/events` is ported. Once ported, search for `@TODO.*illuminate/events` to find tests that need updating. + +**Pattern A: `Event::fake()` + `assertDispatched()` — Works as-is** + +Hypervel's `EventFake` supports `assertDispatched()`, `assertDispatchedTimes()`, etc. No changes needed: + +```php +Event::fake(); +// ... test code ... +Event::assertDispatched(ModelsPruned::class, 2); +``` + +**Pattern B: Mockery mock of Dispatcher — Convert to Event::fake()** + +Laravel tests that mock the Dispatcher directly (e.g., `app('events')->shouldReceive('dispatch')->times(2)`) should be converted to use `Event::fake()` + `assertDispatched()`: + +```php +// Laravel original using Mockery +app('events')->shouldReceive('dispatch')->times(2)->with(m::type(ModelsPruned::class)); +$count = (new MassPrunableTestModel())->pruneAll(); + +// Hypervel - convert to Event::fake() +Event::fake(); +$count = (new MassPrunableTestModel())->pruneAll(); +Event::assertDispatched(ModelsPruned::class, 2); +``` + +**Pattern C: Wildcard listeners — Spread vs array payload** + +Hypervel spreads wildcard listener payload as separate arguments; Laravel passes them as an array. Create a working version and comment out the original: + +```php +/** + * @TODO Replace with testOriginalName once illuminate/events is ported. + * Hypervel's event dispatcher spreads wildcard listener payload instead of passing array. + */ +public function testWorkingVersion() +{ + // Hypervel version: receives spread arguments ($event, $model) + User::getEventDispatcher()->listen('eloquent.retrieved:*', function ($event, $model) { + if ($model instanceof Login) { + // ... + } + }); +} + +// @TODO Restore this test once illuminate/events package is ported (wildcard listeners receive array payload) +// public function testOriginalName() +// { +// // Laravel version: receives array ($event, $models) +// User::getEventDispatcher()->listen('eloquent.retrieved:*', function ($event, $models) { +// foreach ($models as $model) { +// // ... +// } +// }); +// } +``` + +#### Laravel Quick Checklist + +1. Update namespace to `Hypervel\Tests\{Package}\Laravel` +2. Add `declare(strict_types=1);` +3. Change `Illuminate\` imports to `Hypervel\` +4. Add `@internal` and `@coversNothing` docblock to test classes +5. Extend correct base TestCase (`Hypervel\Tests\TestCase` or `Hypervel\Testbench\TestCase`) +6. Ensure `parent::setUp()` is called +7. Do **not** add `: void` return types to test methods +8. Add type declarations to model properties +9. Fix mock types (PDO, QueryBuilder, Grammar, etc.) +10. Add `->andReturnSelf()` to chained method mocks +11. Use test-specific namespace if file defines helper classes — avoids "Cannot redeclare class" errors when multiple test files define classes with the same name (e.g., `...Laravel\EloquentDeleteTest`) +12. Remove tests for unsupported features (SQL Server/MongoDB/DynamoDB databases, Memcached/DynamoDB/MongoDB cache, dynamic connections) +13. Run tests and fix any remaining type errors + +### Integration Tests + +This applies to tests ported from **both** Hyperf and Laravel. + +#### Definition + +Tests that require external services (databases, Redis, HTTP servers, search engines) that can't run in every environment go in `tests/Integration/{PackageName}/`. The exception is tests that call freely-available external APIs (e.g., the Guzzle tests hitting the public Pokemon API) — those can stay in regular `tests/` since they work everywhere. + +#### Skip Traits + +Each external service has a corresponding trait that auto-skips tests when the service isn't reachable: + +| Trait | Service | Key Env Vars | +|-------|---------|-------------| +| `InteractsWithRedis` | Redis/Valkey | `REDIS_HOST`, `REDIS_PORT` | +| `InteractsWithServer` | Engine test servers (HTTP, TCP, WebSocket, HTTP/2) | `ENGINE_TEST_SERVER_HOST` | + +These traits follow a consistent pattern: try to connect, skip with defaults if unavailable, fail if explicit config is set but unreachable (misconfiguration). When porting integration tests for a new service type, create a new trait following this same pattern. + +#### phpunit.xml.dist + +`tests/Integration/` is **not** excluded from `phpunit.xml.dist`. The skip traits handle graceful skipping when services aren't available. When services are available (CI or local with `.env`), the tests run normally. + +#### GH Workflows + +Each integration group has its own workflow file in `.github/workflows/`: + +| Workflow | Runs | Directory | +|----------|------|-----------| +| `engine.yml` | HTTP test servers | `tests/Integration/Engine`, `tests/Integration/Guzzle` | +| `databases.yml` | MySQL, MariaDB, PostgreSQL, SQLite | `tests/Integration/Database` | +| `redis.yml` | Redis, Valkey | `tests/Integration/Cache/Redis`, `tests/Redis/Integration` | +| `scout.yml` | Meilisearch, Typesense | `tests/Integration/Scout/*` | + +When porting integration tests that need a new service, either add them to an existing workflow or create a new one. The workflow must spin up the service container and set the appropriate env vars. + +#### Environment Files + +Add env vars for new integration tests to **both**: +- **`.env.example`** — commented out, as reference for what's available +- **`.env`** — with sensible local defaults so developers can uncomment and run locally + +See the existing entries for database, Redis, Meilisearch, and Typesense as examples. diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 8b0b442bf..0fd0d1d90 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -20,7 +20,6 @@ parameters: - %currentWorkingDirectory%/src/*/class_map/* - %currentWorkingDirectory%/src/foundation/src/helpers.php - %currentWorkingDirectory%/src/foundation/src/Testing/Concerns/* - - %currentWorkingDirectory%/src/foundation/src/Testing/Constraints/* - %currentWorkingDirectory%/src/foundation/src/Http/WebsocketKernel.php - %currentWorkingDirectory%/src/log/src/Adapter/* - %currentWorkingDirectory%/src/support/src/Js.php @@ -28,6 +27,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,26 +119,26 @@ 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#' - - '#Trait Hypervel\\Scout\\Searchable is used zero times and is not analysed#' - - 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#' + + # Conditionable::when() callback - PHPDoc requires 2 params but callbacks often only need 1 + - identifier: argument.type + message: '#Parameter \#2 \$callback of method .*::when\(\) expects#' + + # Conditionable trait - dynamic behavior with HigherOrderWhenProxy can't be fully typed + - identifier: return.type + path: src/conditionable/* + - identifier: arguments.count + path: src/conditionable/* + - identifier: nullCoalesce.expr + path: src/conditionable/* diff --git a/phpunit.xml.dist b/phpunit.xml.dist index e327f01ea..0df9281e9 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -18,6 +18,9 @@ ./tests/Horizon + + + diff --git a/src/api-client/composer.json b/src/api-client/composer.json index b3920b964..500ffebd2 100644 --- a/src/api-client/composer.json +++ b/src/api-client/composer.json @@ -21,9 +21,9 @@ } ], "require": { - "php": "^8.2", - "hypervel/support": "^0.3", - "hypervel/http-client": "^0.3" + "php": "^8.4", + "hypervel/support": "^0.4", + "hypervel/http-client": "^0.4" }, "autoload": { "psr-4": { @@ -32,7 +32,7 @@ }, "extra": { "branch-alias": { - "dev-main": "0.3-dev" + "dev-main": "0.4-dev" } }, "config": { diff --git a/src/api-client/src/ApiRequest.php b/src/api-client/src/ApiRequest.php index 44f92a0aa..aa9c69ddf 100644 --- a/src/api-client/src/ApiRequest.php +++ b/src/api-client/src/ApiRequest.php @@ -5,7 +5,7 @@ namespace Hypervel\ApiClient; use GuzzleHttp\Psr7\Uri; -use Hyperf\Engine\Http\Stream; +use Hypervel\Engine\Http\Stream; use Hypervel\HttpClient\Request as HttpClientRequest; use Psr\Http\Message\RequestInterface; diff --git a/src/api-client/src/ApiResource.php b/src/api-client/src/ApiResource.php index f726897c0..e47642650 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 Hypervel\Support\Traits\ForwardsCalls; 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..34c7e8c3f 100644 --- a/src/auth/composer.json +++ b/src/auth/composer.json @@ -20,20 +20,19 @@ } ], "require": { - "php": "^8.2", + "php": "^8.4", "nesbot/carbon": "^2.72.6", - "hyperf/context": "~3.1.0", - "hyperf/stringable": "~3.1.0", - "hyperf/macroable": "~3.1.0", + "hypervel/context": "^0.4", + "hypervel/macroable": "^0.4", "hyperf/contract": "~3.1.0", "hyperf/config": "~3.1.0", - "hyperf/database": "~3.1.0", "hyperf/http-server": "~3.1.0", - "hypervel/hashing": "^0.3", - "hypervel/jwt": "^0.3" + "hypervel/database": "^0.4", + "hypervel/hashing": "^0.4", + "hypervel/jwt": "^0.4" }, "suggest": { - "hypervel/session": "Required to use session guard. (~0.1.0)" + "hypervel/session": "Required to use session guard. (^0.4)" }, "autoload": { "psr-4": { @@ -48,7 +47,7 @@ "config": "Hypervel\\Auth\\ConfigProvider" }, "branch-alias": { - "dev-main": "0.3-dev" + "dev-main": "0.4-dev" } }, "config": { diff --git a/src/auth/src/Access/Authorizable.php b/src/auth/src/Access/Authorizable.php index 5977bd1bf..c41bc03ac 100644 --- a/src/auth/src/Access/Authorizable.php +++ b/src/auth/src/Access/Authorizable.php @@ -4,8 +4,8 @@ namespace Hypervel\Auth\Access; -use Hyperf\Context\ApplicationContext; -use Hypervel\Auth\Contracts\Gate; +use Hypervel\Context\ApplicationContext; +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..7e2d3f3bf 100644 --- a/src/auth/src/Access/AuthorizesRequests.php +++ b/src/auth/src/Access/AuthorizesRequests.php @@ -4,9 +4,9 @@ namespace Hypervel\Auth\Access; -use Hyperf\Context\ApplicationContext; -use Hypervel\Auth\Contracts\Authenticatable; -use Hypervel\Auth\Contracts\Gate; +use Hypervel\Context\ApplicationContext; +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..2e21cc118 100644 --- a/src/auth/src/AuthManager.php +++ b/src/auth/src/AuthManager.php @@ -5,17 +5,17 @@ namespace Hypervel\Auth; use Closure; -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\Config\Repository; +use Hypervel\Context\Context; +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; @@ -43,12 +43,12 @@ class AuthManager implements AuthFactoryContract /** * The auth configuration. */ - protected ConfigInterface $config; + protected Repository $config; public function __construct( protected ContainerInterface $app ) { - $this->config = $this->app->get(ConfigInterface::class); + $this->config = $this->app->get('config'); $this->userResolver = function ($guard = null) { return $this->guard($guard)->user(); }; 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..3a3c96cf7 100644 --- a/src/auth/src/Functions.php +++ b/src/auth/src/Functions.php @@ -4,9 +4,9 @@ namespace Hypervel\Auth; -use Hyperf\Context\ApplicationContext; -use Hypervel\Auth\Contracts\Factory as AuthFactoryContract; -use Hypervel\Auth\Contracts\Guard; +use Hypervel\Context\ApplicationContext; +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..f70de1a16 100644 --- a/src/auth/src/Guards/JwtGuard.php +++ b/src/auth/src/Guards/JwtGuard.php @@ -5,15 +5,15 @@ namespace Hypervel\Auth\Guards; use Carbon\Carbon; -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\Context\Context; +use Hypervel\Context\RequestContext; +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..1e0ef033c 100644 --- a/src/auth/src/Guards/RequestGuard.php +++ b/src/auth/src/Guards/RequestGuard.php @@ -4,13 +4,13 @@ namespace Hypervel\Auth\Guards; -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\Context\ApplicationContext; +use Hypervel\Context\Context; +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..589085be8 100644 --- a/src/auth/src/Guards/SessionGuard.php +++ b/src/auth/src/Guards/SessionGuard.php @@ -4,12 +4,12 @@ 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\Context\Context; +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/composer.json b/src/broadcasting/composer.json index bb3baa55d..01e24191f 100644 --- a/src/broadcasting/composer.json +++ b/src/broadcasting/composer.json @@ -26,22 +26,22 @@ } }, "require": { - "php": "^8.2", + "php": "^8.4", "hyperf/contract": "~3.1.0", "hyperf/http-server": "~3.1.0", - "hyperf/pool": "~3.1.0", - "hypervel/auth": "^0.3", - "hypervel/bus": "^0.3", - "hypervel/cache": "^0.3", - "hypervel/foundation": "^0.3", - "hypervel/queue": "^0.3", - "hypervel/router": "^0.3", - "hypervel/support": "^0.3", - "hypervel/object-pool": "^0.3" + "hypervel/pool": "^0.4", + "hypervel/auth": "^0.4", + "hypervel/bus": "^0.4", + "hypervel/cache": "^0.4", + "hypervel/foundation": "^0.4", + "hypervel/queue": "^0.4", + "hypervel/router": "^0.4", + "hypervel/support": "^0.4", + "hypervel/object-pool": "^0.4" }, "suggest": { "ext-hash": "Required to use the Ably and Pusher broadcast drivers.", - "hyperf/redis": "Required to use the Redis broadcast driver (~3.1.0).", + "hypervel/redis": "Required to use the Redis broadcast driver (^0.4).", "ably/ably-php": "Required to use the Ably broadcast driver (^1.0).", "pusher/pusher-php-server": "Required to use the Pusher broadcast driver (^6.0|^7.0)." }, @@ -53,7 +53,7 @@ "config": "Hypervel\\Broadcasting\\ConfigProvider" }, "branch-alias": { - "dev-main": "0.3-dev" + "dev-main": "0.4-dev" } } } 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..c2ef7510e 100644 --- a/src/broadcasting/src/BroadcastManager.php +++ b/src/broadcasting/src/BroadcastManager.php @@ -7,26 +7,25 @@ use Ably\AblyRest; use Closure; use GuzzleHttp\Client as GuzzleClient; -use Hyperf\Contract\ConfigInterface; use Hyperf\HttpServer\Contract\RequestInterface; use Hyperf\HttpServer\Router\DispatcherFactory as RouterDispatcherFactory; -use Hyperf\Redis\RedisFactory; use Hypervel\Broadcasting\Broadcasters\AblyBroadcaster; use Hypervel\Broadcasting\Broadcasters\LogBroadcaster; 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 Hypervel\Redis\RedisFactory; use InvalidArgumentException; use Psr\Container\ContainerInterface; use Psr\EventDispatcher\EventDispatcherInterface; @@ -80,7 +79,7 @@ public function routes(array $attributes = []): void ]; } - $kernels = $this->app->get(ConfigInterface::class) + $kernels = $this->app->get('config') ->get('server.kernels', []); foreach (array_keys($kernels) as $kernel) { $this->app->get(RouterDispatcherFactory::class) @@ -358,7 +357,7 @@ protected function createRedisDriver(array $config): Broadcaster $this->app, $this->app->get(RedisFactory::class), $config['connection'] ?? 'default', - $this->app->get(ConfigInterface::class)->get('database.redis.options.prefix', ''), + $this->app->get('config')->get('database.redis.options.prefix', ''), ); } @@ -384,7 +383,7 @@ protected function createNullDriver(array $config): Broadcaster protected function getConfig(string $name): ?array { if ($name !== 'null') { - return $this->app->get(ConfigInterface::class)->get("broadcasting.connections.{$name}"); + return $this->app->get('config')->get("broadcasting.connections.{$name}"); } return ['driver' => 'null']; @@ -395,7 +394,7 @@ protected function getConfig(string $name): ?array */ public function getDefaultDriver(): string { - return $this->app->get(ConfigInterface::class)->get('broadcasting.default'); + return $this->app->get('config')->get('broadcasting.default'); } /** @@ -403,7 +402,7 @@ public function getDefaultDriver(): string */ public function setDefaultDriver(string $name): void { - $this->app->get(ConfigInterface::class)->set('broadcasting.default', $name); + $this->app->get('config')->set('broadcasting.default', $name); } /** 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..1165d45c8 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\Redis\RedisFactory; +use Hypervel\Support\Arr; use Psr\Container\ContainerInterface; use RedisException; @@ -97,8 +97,9 @@ public function broadcast(array $channels, string $event, array $payload = []): try { $connection->eval( $this->broadcastMultipleChannelsScript(), - [$payload, ...$this->formatChannels($channels)], 0, + $payload, + ...$this->formatChannels($channels), ); } catch (ConnectionException|RedisException $e) { throw new BroadcastException( 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..b502623af 100644 --- a/src/broadcasting/src/UniqueBroadcastEvent.php +++ b/src/broadcasting/src/UniqueBroadcastEvent.php @@ -4,9 +4,9 @@ namespace Hypervel\Broadcasting; -use Hyperf\Context\ApplicationContext; -use Hypervel\Cache\Contracts\Factory as Cache; -use Hypervel\Queue\Contracts\ShouldBeUnique; +use Hypervel\Context\ApplicationContext; +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..bb2dee959 100644 --- a/src/bus/composer.json +++ b/src/bus/composer.json @@ -21,17 +21,17 @@ } ], "require": { - "php": "^8.2", - "hyperf/context": "~3.1.0", + "php": "^8.4", + "hypervel/context": "^0.4", "hyperf/contract": "~3.1.0", - "hyperf/collection": "~3.1.0", - "hyperf/conditionable": "~3.1.0", - "hyperf/coroutine": "~3.1.0", + "hypervel/collections": "^0.4", + "hypervel/conditionable": "^0.4", + "hypervel/coroutine": "^0.4", "hyperf/support": "~3.1.0", "nesbot/carbon": "^2.72.6", "laravel/serializable-closure": "^1.3", - "hypervel/support": "^0.3", - "hypervel/cache": "^0.3" + "hypervel/support": "^0.4", + "hypervel/cache": "^0.4" }, "autoload": { @@ -47,7 +47,7 @@ "config": "Hypervel\\Bus\\ConfigProvider" }, "branch-alias": { - "dev-main": "0.3-dev" + "dev-main": "0.4-dev" } }, "suggest": { diff --git a/src/bus/src/Batch.php b/src/bus/src/Batch.php index 83858bed5..fdf34d188 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\Context\ApplicationContext; +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..e66bc38fe 100644 --- a/src/bus/src/Batchable.php +++ b/src/bus/src/Batchable.php @@ -5,9 +5,9 @@ namespace Hypervel\Bus; use Carbon\CarbonImmutable; -use Hyperf\Context\ApplicationContext; -use Hyperf\Stringable\Str; -use Hypervel\Bus\Contracts\BatchRepository; +use Hypervel\Context\ApplicationContext; +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..13699b2a8 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\Context\ApplicationContext; +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..6f548f344 100644 --- a/src/bus/src/DatabaseBatchRepositoryFactory.php +++ b/src/bus/src/DatabaseBatchRepositoryFactory.php @@ -4,15 +4,14 @@ namespace Hypervel\Bus; -use Hyperf\Contract\ConfigInterface; -use Hyperf\Database\ConnectionResolverInterface; +use Hypervel\Database\ConnectionResolverInterface; use Psr\Container\ContainerInterface; class DatabaseBatchRepositoryFactory { public function __invoke(ContainerInterface $container): DatabaseBatchRepository { - $config = $container->get(ConfigInterface::class); + $config = $container->get('config'); return new DatabaseBatchRepository( $container->get(BatchFactory::class), diff --git a/src/bus/src/Dispatchable.php b/src/bus/src/Dispatchable.php index 1394b0cf3..48f0bbbbd 100644 --- a/src/bus/src/Dispatchable.php +++ b/src/bus/src/Dispatchable.php @@ -5,9 +5,9 @@ namespace Hypervel\Bus; use Closure; -use Hyperf\Context\ApplicationContext; -use Hyperf\Support\Fluent; -use Hypervel\Bus\Contracts\Dispatcher; +use Hypervel\Context\ApplicationContext; +use Hypervel\Contracts\Bus\Dispatcher; +use Hypervel\Support\Fluent; use function Hyperf\Support\value; diff --git a/src/bus/src/Dispatcher.php b/src/bus/src/Dispatcher.php index ad67d754c..47f2200cf 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\Coroutine\Coroutine; 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/DispatchesJobs.php b/src/bus/src/DispatchesJobs.php index cfcd99fe0..ff9898010 100644 --- a/src/bus/src/DispatchesJobs.php +++ b/src/bus/src/DispatchesJobs.php @@ -5,7 +5,7 @@ namespace Hypervel\Bus; use Closure; -use Hyperf\Context\ApplicationContext; +use Hypervel\Context\ApplicationContext; use Hypervel\Queue\CallQueuedClosure; trait DispatchesJobs diff --git a/src/bus/src/Functions.php b/src/bus/src/Functions.php index 2d5ff7d93..4aabd5cd2 100644 --- a/src/bus/src/Functions.php +++ b/src/bus/src/Functions.php @@ -5,8 +5,8 @@ namespace Hypervel\Bus; use Closure; -use Hyperf\Context\ApplicationContext; -use Hypervel\Bus\Contracts\Dispatcher; +use Hypervel\Context\ApplicationContext; +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..31ab2ab17 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\Coroutine\Coroutine; +use Hypervel\Support\Arr; +use Hypervel\Support\Collection; +use Hypervel\Support\Traits\Conditionable; 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..a758af44c 100644 --- a/src/bus/src/PendingChain.php +++ b/src/bus/src/PendingChain.php @@ -7,10 +7,10 @@ use Closure; use DateInterval; use DateTimeInterface; -use Hyperf\Conditionable\Conditionable; -use Hyperf\Context\ApplicationContext; -use Hypervel\Bus\Contracts\Dispatcher; +use Hypervel\Context\ApplicationContext; +use Hypervel\Contracts\Bus\Dispatcher; use Hypervel\Queue\CallQueuedClosure; +use Hypervel\Support\Traits\Conditionable; use Laravel\SerializableClosure\SerializableClosure; use UnitEnum; diff --git a/src/bus/src/PendingDispatch.php b/src/bus/src/PendingDispatch.php index 6649cd1b7..1043b2b2d 100644 --- a/src/bus/src/PendingDispatch.php +++ b/src/bus/src/PendingDispatch.php @@ -6,10 +6,10 @@ 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\Context\ApplicationContext; +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/composer.json b/src/cache/composer.json index 5a1db87c2..6666850ef 100644 --- a/src/cache/composer.json +++ b/src/cache/composer.json @@ -14,7 +14,11 @@ { "name": "Albert Chen", "email": "albert@hypervel.org" - } + }, + { + "name": "Raj Siva-Rajah", + "homepage": "https://github.com/binaryfire" + } ], "support": { "issues": "https://github.com/hypervel/components/issues", @@ -29,15 +33,15 @@ ] }, "require": { - "php": "^8.2", + "php": "^8.4", "hyperf/config": "~3.1.0", "hyperf/support": "~3.1.0", "laravel/serializable-closure": "^1.3", "psr/simple-cache": "^3.0" }, "suggest": { - "hyperf/redis": "Required to use redis driver. (~3.1.0)", - "hypervel/cache": "Required to use file lock. (~0.1.0)" + "hypervel/redis": "Required to use redis driver. (^0.4).", + "hypervel/cache": "Required to use file lock. (^0.4)" }, "config": { "sort-packages": true @@ -47,7 +51,7 @@ "config": "Hypervel\\Cache\\ConfigProvider" }, "branch-alias": { - "dev-main": "0.3-dev" + "dev-main": "0.4-dev" } } } diff --git a/src/cache/publish/cache.php b/src/cache/publish/cache.php index 18833e2c0..158a7afd5 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..27ea3cc4d 100644 --- a/src/cache/src/ArrayStore.php +++ b/src/cache/src/ArrayStore.php @@ -4,8 +4,8 @@ namespace Hypervel\Cache; -use Hyperf\Support\Traits\InteractsWithTime; -use Hypervel\Cache\Contracts\LockProvider; +use Hypervel\Contracts\Cache\LockProvider; +use Hypervel\Support\InteractsWithTime; 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 a31ff35c0..1330aa47c 100644 --- a/src/cache/src/CacheManager.php +++ b/src/cache/src/CacheManager.php @@ -5,21 +5,19 @@ namespace Hypervel\Cache; 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 CacheRepository; +use Hypervel\Contracts\Cache\Store; +use Hypervel\Redis\RedisFactory; 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 @@ -53,7 +51,7 @@ public function __call(string $method, array $parameters): mixed /** * Get a cache store instance by name, wrapped in a repository. */ - public function store(?string $name = null): RepositoryContract + public function store(?string $name = null): CacheRepository { $name = $name ?: $this->getDefaultDriver(); @@ -63,7 +61,7 @@ public function store(?string $name = null): RepositoryContract /** * Get a cache driver instance. */ - public function driver(?string $driver = null): RepositoryContract + public function driver(?string $driver = null): CacheRepository { return $this->store($driver); } @@ -97,7 +95,7 @@ public function refreshEventDispatcher(): void */ public function getDefaultDriver(): string { - return $this->app->get(ConfigInterface::class) + return $this->app->get('config') ->get('cache.default', 'file'); } @@ -106,7 +104,7 @@ public function getDefaultDriver(): string */ public function setDefaultDriver(string $name): void { - $this->app->get(ConfigInterface::class) + $this->app->get('config') ->set('cache.default', $name); } @@ -159,7 +157,7 @@ public function setApplication(ContainerInterface $app): static /** * Attempt to get the store from the local cache. */ - protected function getStore(string $name): RepositoryContract + protected function getStore(string $name): CacheRepository { return $this->stores[$name] ?? $this->resolve($name); } @@ -169,7 +167,7 @@ protected function getStore(string $name): RepositoryContract * * @throws InvalidArgumentException */ - protected function resolve(string $name): RepositoryContract + protected function resolve(string $name): CacheRepository { $config = $this->getConfig($name); @@ -193,7 +191,7 @@ protected function resolve(string $name): RepositoryContract /** * Call a custom driver creator. */ - protected function callCustomCreator(array $config): RepositoryContract + protected function callCustomCreator(array $config): CacheRepository { return $this->customCreators[$config['driver']]($this->app, $config); } @@ -285,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, @@ -319,7 +317,7 @@ protected function setEventDispatcher(Repository $repository): void */ protected function getPrefix(array $config): string { - return $config['prefix'] ?? $this->app->get(ConfigInterface::class)->get('cache.prefix'); + return $config['prefix'] ?? $this->app->get('config')->get('cache.prefix'); } /** @@ -328,7 +326,7 @@ protected function getPrefix(array $config): string protected function getConfig(string $name): ?array { if ($name !== 'null') { - return $this->app->get(ConfigInterface::class)->get("cache.stores.{$name}"); + return $this->app->get('config')->get("cache.stores.{$name}"); } return ['driver' => 'null']; diff --git a/src/cache/src/ConfigProvider.php b/src/cache/src/ConfigProvider.php index 7907b02d3..8269c4fec 100644 --- a/src/cache/src/ConfigProvider.php +++ b/src/cache/src/ConfigProvider.php @@ -6,13 +6,13 @@ 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\Cache\Redis\Console\BenchmarkCommand; use Hypervel\Cache\Redis\Console\DoctorCommand; use Hypervel\Cache\Redis\Console\PruneStaleTagsCommand; +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..07e75839a 100644 --- a/src/cache/src/Console/ClearCommand.php +++ b/src/cache/src/Console/ClearCommand.php @@ -5,9 +5,9 @@ namespace Hypervel\Cache\Console; 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\Filesystem\Filesystem; 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..9a022e4c3 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 Hypervel\Support\InteractsWithTime; use Throwable; class DatabaseStore implements Store, LockProvider diff --git a/src/cache/src/FileStore.php b/src/cache/src/FileStore.php index 4d2cf1192..b039e0aa3 100644 --- a/src/cache/src/FileStore.php +++ b/src/cache/src/FileStore.php @@ -5,12 +5,12 @@ namespace Hypervel\Cache; 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\Filesystem; use Hypervel\Filesystem\LockableFile; +use Hypervel\Support\InteractsWithTime; class FileStore implements Store, LockProvider { diff --git a/src/cache/src/Functions.php b/src/cache/src/Functions.php index d18af2f6c..2bae93320 100644 --- a/src/cache/src/Functions.php +++ b/src/cache/src/Functions.php @@ -4,8 +4,8 @@ namespace Hypervel\Cache; -use Hyperf\Context\ApplicationContext; -use Hypervel\Cache\Exceptions\InvalidArgumentException; +use Hypervel\Context\ApplicationContext; +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..895a6ac04 100644 --- a/src/cache/src/Listeners/BaseListener.php +++ b/src/cache/src/Listeners/BaseListener.php @@ -4,9 +4,8 @@ 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 @@ -17,7 +16,7 @@ public function __construct(protected ContainerInterface $container) protected function swooleStores(): Collection { - $config = $this->container->get(ConfigInterface::class)->get('cache.stores'); + $config = $this->container->get('config')->get('cache.stores'); return collect($config)->where('driver', 'swoole'); } diff --git a/src/cache/src/Lock.php b/src/cache/src/Lock.php index 55b5da9e0..1a0c0a0e3 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\InteractsWithTime; +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..75c726533 100644 --- a/src/cache/src/RateLimiter.php +++ b/src/cache/src/RateLimiter.php @@ -5,8 +5,8 @@ namespace Hypervel\Cache; use Closure; -use Hyperf\Support\Traits\InteractsWithTime; -use Hypervel\Cache\Contracts\Factory as Cache; +use Hypervel\Contracts\Cache\Factory as Cache; +use Hypervel\Support\InteractsWithTime; use UnitEnum; use function Hypervel\Support\enum_value; diff --git a/src/cache/src/Redis/AllTagSet.php b/src/cache/src/Redis/AllTagSet.php index 6b125ac4b..b8f210593 100644 --- a/src/cache/src/Redis/AllTagSet.php +++ b/src/cache/src/Redis/AllTagSet.php @@ -4,10 +4,10 @@ namespace Hypervel\Cache\Redis; -use Hyperf\Collection\LazyCollection; -use Hypervel\Cache\Contracts\Store; use Hypervel\Cache\RedisStore; use Hypervel\Cache\TagSet; +use Hypervel\Contracts\Cache\Store; +use Hypervel\Support\LazyCollection; class AllTagSet extends TagSet { diff --git a/src/cache/src/Redis/AllTaggedCache.php b/src/cache/src/Redis/AllTaggedCache.php index 52d1e908b..61d233d88 100644 --- a/src/cache/src/Redis/AllTaggedCache.php +++ b/src/cache/src/Redis/AllTaggedCache.php @@ -7,7 +7,6 @@ use Closure; use DateInterval; use DateTimeInterface; -use Hypervel\Cache\Contracts\Store; use Hypervel\Cache\Events\CacheFlushed; use Hypervel\Cache\Events\CacheFlushing; use Hypervel\Cache\Events\CacheHit; @@ -15,6 +14,7 @@ use Hypervel\Cache\Events\KeyWritten; use Hypervel\Cache\RedisStore; use Hypervel\Cache\TaggedCache; +use Hypervel\Contracts\Cache\Store; use UnitEnum; use function Hypervel\Support\enum_value; diff --git a/src/cache/src/Redis/AnyTagSet.php b/src/cache/src/Redis/AnyTagSet.php index a1554cf72..8e2cdc0e1 100644 --- a/src/cache/src/Redis/AnyTagSet.php +++ b/src/cache/src/Redis/AnyTagSet.php @@ -5,9 +5,9 @@ namespace Hypervel\Cache\Redis; use Generator; -use Hypervel\Cache\Contracts\Store; use Hypervel\Cache\RedisStore; use Hypervel\Cache\TagSet; +use Hypervel\Contracts\Cache\Store; /** * Any-mode tag set for Redis 8.0+ enhanced tagging. diff --git a/src/cache/src/Redis/AnyTaggedCache.php b/src/cache/src/Redis/AnyTaggedCache.php index ef078123b..73c0e780f 100644 --- a/src/cache/src/Redis/AnyTaggedCache.php +++ b/src/cache/src/Redis/AnyTaggedCache.php @@ -9,7 +9,6 @@ use DateInterval; use DateTimeInterface; use Generator; -use Hypervel\Cache\Contracts\Store; use Hypervel\Cache\Events\CacheFlushed; use Hypervel\Cache\Events\CacheFlushing; use Hypervel\Cache\Events\CacheHit; @@ -17,6 +16,7 @@ use Hypervel\Cache\Events\KeyWritten; use Hypervel\Cache\RedisStore; use Hypervel\Cache\TaggedCache; +use Hypervel\Contracts\Cache\Store; use UnitEnum; use function Hypervel\Support\enum_value; diff --git a/src/cache/src/Redis/Console/Benchmark/BenchmarkContext.php b/src/cache/src/Redis/Console/Benchmark/BenchmarkContext.php index 8397c0344..04cea9e8c 100644 --- a/src/cache/src/Redis/Console/Benchmark/BenchmarkContext.php +++ b/src/cache/src/Redis/Console/Benchmark/BenchmarkContext.php @@ -6,11 +6,11 @@ use Exception; use Hyperf\Command\Command; -use Hypervel\Cache\Contracts\Factory as CacheContract; use Hypervel\Cache\Redis\Exceptions\BenchmarkMemoryException; use Hypervel\Cache\Redis\TagMode; use Hypervel\Cache\RedisStore; use Hypervel\Cache\Repository; +use Hypervel\Contracts\Cache\Factory as CacheContract; use Hypervel\Redis\RedisConnection; use Hypervel\Support\SystemInfo; use RuntimeException; @@ -60,7 +60,7 @@ public function __construct( * * @return Repository */ - public function getStore(): \Hypervel\Cache\Contracts\Repository + public function getStore(): \Hypervel\Contracts\Cache\Repository { /** @var Repository */ return $this->cacheManager->store($this->storeName); diff --git a/src/cache/src/Redis/Console/BenchmarkCommand.php b/src/cache/src/Redis/Console/BenchmarkCommand.php index d9a3436fe..eb724ae1b 100644 --- a/src/cache/src/Redis/Console/BenchmarkCommand.php +++ b/src/cache/src/Redis/Console/BenchmarkCommand.php @@ -7,8 +7,6 @@ use Exception; use Hyperf\Command\Command; use Hyperf\Command\Concerns\Prohibitable; -use Hyperf\Contract\ConfigInterface; -use Hypervel\Cache\Contracts\Factory as CacheContract; use Hypervel\Cache\Redis\Console\Benchmark\BenchmarkContext; use Hypervel\Cache\Redis\Console\Benchmark\ResultsFormatter; use Hypervel\Cache\Redis\Console\Benchmark\ScenarioResult; @@ -25,6 +23,7 @@ use Hypervel\Cache\Redis\Support\MonitoringDetector; use Hypervel\Cache\Redis\TagMode; use Hypervel\Cache\RedisStore; +use Hypervel\Contracts\Cache\Factory as CacheContract; use Hypervel\Redis\RedisConnection; use Hypervel\Support\SystemInfo; use Hypervel\Support\Traits\HasLaravelStyleCommand; @@ -240,7 +239,7 @@ protected function setup(): bool */ protected function checkMonitoringTools(): bool { - $config = $this->app->get(ConfigInterface::class); + $config = $this->app->get('config'); $monitoringTools = (new MonitoringDetector($config))->detect(); if (! empty($monitoringTools) && ! $this->option('force')) { @@ -280,7 +279,7 @@ protected function confirmSafeToRun(): bool return true; } - $config = $this->app->get(ConfigInterface::class); + $config = $this->app->get('config'); $env = $config->get('app.env', 'production'); $scale = $this->option('scale'); @@ -551,7 +550,7 @@ protected function checkMemoryRequirements(string $scale): void */ protected function displayMemoryError(BenchmarkMemoryException $e): void { - $config = $this->app->get(ConfigInterface::class); + $config = $this->app->get('config'); $this->newLine(); $this->error('Benchmark aborted due to memory constraints.'); diff --git a/src/cache/src/Redis/Console/Concerns/DetectsRedisStore.php b/src/cache/src/Redis/Console/Concerns/DetectsRedisStore.php index 7035764c6..59f7a99cd 100644 --- a/src/cache/src/Redis/Console/Concerns/DetectsRedisStore.php +++ b/src/cache/src/Redis/Console/Concerns/DetectsRedisStore.php @@ -4,8 +4,6 @@ namespace Hypervel\Cache\Redis\Console\Concerns; -use Hyperf\Contract\ConfigInterface; - /** * Provides store detection functionality for commands. */ @@ -16,7 +14,7 @@ trait DetectsRedisStore */ protected function detectRedisStore(): ?string { - $config = $this->app->get(ConfigInterface::class); + $config = $this->app->get('config'); $stores = $config->get('cache.stores', []); foreach ($stores as $name => $storeConfig) { diff --git a/src/cache/src/Redis/Console/Doctor/Checks/ConcurrencyCheck.php b/src/cache/src/Redis/Console/Doctor/Checks/ConcurrencyCheck.php index 4f74ce15b..2f590d835 100644 --- a/src/cache/src/Redis/Console/Doctor/Checks/ConcurrencyCheck.php +++ b/src/cache/src/Redis/Console/Doctor/Checks/ConcurrencyCheck.php @@ -4,10 +4,10 @@ namespace Hypervel\Cache\Redis\Console\Doctor\Checks; -use Hyperf\Stringable\Str; use Hypervel\Cache\Redis\Console\Doctor\CheckResult; use Hypervel\Cache\Redis\Console\Doctor\DoctorContext; use Hypervel\Coroutine\Coroutine; +use Hypervel\Support\Str; use Symfony\Component\Console\Output\OutputInterface; use Throwable; diff --git a/src/cache/src/Redis/Console/Doctor/Checks/ExpirationCheck.php b/src/cache/src/Redis/Console/Doctor/Checks/ExpirationCheck.php index ccc02d65e..16e86958d 100644 --- a/src/cache/src/Redis/Console/Doctor/Checks/ExpirationCheck.php +++ b/src/cache/src/Redis/Console/Doctor/Checks/ExpirationCheck.php @@ -4,9 +4,9 @@ namespace Hypervel\Cache\Redis\Console\Doctor\Checks; -use Hyperf\Stringable\Str; use Hypervel\Cache\Redis\Console\Doctor\CheckResult; use Hypervel\Cache\Redis\Console\Doctor\DoctorContext; +use Hypervel\Support\Str; use Symfony\Component\Console\Output\OutputInterface; /** diff --git a/src/cache/src/Redis/Console/DoctorCommand.php b/src/cache/src/Redis/Console/DoctorCommand.php index d9cc9d715..fe1b5463a 100644 --- a/src/cache/src/Redis/Console/DoctorCommand.php +++ b/src/cache/src/Redis/Console/DoctorCommand.php @@ -7,8 +7,6 @@ use Exception; use Hyperf\Command\Command; use Hyperf\Command\Concerns\Prohibitable; -use Hyperf\Contract\ConfigInterface; -use Hypervel\Cache\Contracts\Factory as CacheContract; use Hypervel\Cache\Redis\Console\Concerns\DetectsRedisStore; use Hypervel\Cache\Redis\Console\Doctor\CheckResult; use Hypervel\Cache\Redis\Console\Doctor\Checks\AddOperationsCheck; @@ -37,6 +35,7 @@ use Hypervel\Cache\Redis\Console\Doctor\Checks\TaggedRememberCheck; use Hypervel\Cache\Redis\Console\Doctor\DoctorContext; use Hypervel\Cache\RedisStore; +use Hypervel\Contracts\Cache\Factory as CacheContract; use Hypervel\Redis\RedisConnection; use Hypervel\Support\Traits\HasLaravelStyleCommand; use Symfony\Component\Console\Input\InputOption; @@ -310,7 +309,7 @@ protected function displaySystemInformation(): void $this->line(' Framework: Hypervel'); // Cache Store - $config = $this->app->get(ConfigInterface::class); + $config = $this->app->get('config'); $defaultStore = $config->get('cache.default', 'file'); $this->line(" Default Cache Store: {$defaultStore}"); diff --git a/src/cache/src/Redis/Console/PruneStaleTagsCommand.php b/src/cache/src/Redis/Console/PruneStaleTagsCommand.php index c7ec674e8..d53532e99 100644 --- a/src/cache/src/Redis/Console/PruneStaleTagsCommand.php +++ b/src/cache/src/Redis/Console/PruneStaleTagsCommand.php @@ -5,8 +5,8 @@ namespace Hypervel\Cache\Redis\Console; use Hyperf\Command\Command; -use Hypervel\Cache\Contracts\Factory as CacheContract; use Hypervel\Cache\RedisStore; +use Hypervel\Contracts\Cache\Factory as CacheContract; use Hypervel\Support\Traits\HasLaravelStyleCommand; use Symfony\Component\Console\Input\InputArgument; diff --git a/src/cache/src/Redis/Operations/AllTag/GetEntries.php b/src/cache/src/Redis/Operations/AllTag/GetEntries.php index f3f09e82b..6323af1d5 100644 --- a/src/cache/src/Redis/Operations/AllTag/GetEntries.php +++ b/src/cache/src/Redis/Operations/AllTag/GetEntries.php @@ -4,9 +4,9 @@ namespace Hypervel\Cache\Redis\Operations\AllTag; -use Hyperf\Collection\LazyCollection; use Hypervel\Cache\Redis\Support\StoreContext; use Hypervel\Redis\RedisConnection; +use Hypervel\Support\LazyCollection; /** * Retrieves all cache key entries from all tag sorted sets. diff --git a/src/cache/src/Redis/Support/MonitoringDetector.php b/src/cache/src/Redis/Support/MonitoringDetector.php index 478d8e2c2..3af9161b1 100644 --- a/src/cache/src/Redis/Support/MonitoringDetector.php +++ b/src/cache/src/Redis/Support/MonitoringDetector.php @@ -4,7 +4,7 @@ namespace Hypervel\Cache\Redis\Support; -use Hyperf\Contract\ConfigInterface; +use Hypervel\Config\Repository; use Hypervel\Telescope\Telescope; /** @@ -16,7 +16,7 @@ class MonitoringDetector { public function __construct( - private readonly ConfigInterface $config, + private readonly Repository $config, ) { } diff --git a/src/cache/src/RedisLock.php b/src/cache/src/RedisLock.php index d304b3093..0c5033bc0 100644 --- a/src/cache/src/RedisLock.php +++ b/src/cache/src/RedisLock.php @@ -4,8 +4,8 @@ namespace Hypervel\Cache; -use Hyperf\Redis\Redis; -use Hypervel\Cache\Contracts\RefreshableLock; +use Hypervel\Contracts\Cache\RefreshableLock; +use Hypervel\Redis\Redis; use InvalidArgumentException; class RedisLock extends Lock implements RefreshableLock @@ -42,7 +42,12 @@ public function acquire(): bool */ public function release(): bool { - return (bool) $this->redis->eval(LuaScripts::releaseLock(), [$this->name, $this->owner], 1); + return (bool) $this->redis->eval( + LuaScripts::releaseLock(), + 1, + $this->name, + $this->owner, + ); } /** @@ -81,7 +86,13 @@ public function refresh(?int $seconds = null): bool ); } - return (bool) $this->redis->eval(LuaScripts::refreshLock(), [$this->name, $this->owner, $seconds], 1); + return (bool) $this->redis->eval( + LuaScripts::refreshLock(), + 1, + $this->name, + $this->owner, + $seconds, + ); } /** diff --git a/src/cache/src/RedisStore.php b/src/cache/src/RedisStore.php index 665e05446..b9dd83292 100644 --- a/src/cache/src/RedisStore.php +++ b/src/cache/src/RedisStore.php @@ -5,10 +5,6 @@ namespace Hypervel\Cache; use Closure; -use Hyperf\Redis\Pool\PoolFactory; -use Hyperf\Redis\RedisFactory; -use Hyperf\Redis\RedisProxy; -use Hypervel\Cache\Contracts\LockProvider; use Hypervel\Cache\Redis\AllTaggedCache; use Hypervel\Cache\Redis\AllTagSet; use Hypervel\Cache\Redis\AnyTaggedCache; @@ -31,6 +27,10 @@ use Hypervel\Cache\Redis\Support\Serialization; use Hypervel\Cache\Redis\Support\StoreContext; use Hypervel\Cache\Redis\TagMode; +use Hypervel\Contracts\Cache\LockProvider; +use Hypervel\Redis\Pool\PoolFactory; +use Hypervel\Redis\RedisFactory; +use Hypervel\Redis\RedisProxy; class RedisStore extends TaggableStore implements LockProvider { diff --git a/src/cache/src/Repository.php b/src/cache/src/Repository.php index 5268ffee8..e88312822 100644 --- a/src/cache/src/Repository.php +++ b/src/cache/src/Repository.php @@ -10,10 +10,6 @@ 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 +24,17 @@ 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\InteractsWithTime; +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/SwooleTableManager.php b/src/cache/src/SwooleTableManager.php index 58dce43e5..278f1511a 100644 --- a/src/cache/src/SwooleTableManager.php +++ b/src/cache/src/SwooleTableManager.php @@ -4,7 +4,6 @@ namespace Hypervel\Cache; -use Hyperf\Contract\ConfigInterface; use InvalidArgumentException; use Psr\Container\ContainerInterface; use Swoole\Table; @@ -55,7 +54,7 @@ protected function resolve(string $name): Table protected function getConfig(string $name): ?array { if ($name !== 'null') { - return $this->app->get(ConfigInterface::class)->get("cache.swoole_tables.{$name}"); + return $this->app->get('config')->get("cache.swoole_tables.{$name}"); } return null; diff --git a/src/cache/src/TagSet.php b/src/cache/src/TagSet.php index f7d02520d..10930d83a 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..f124082fe --- /dev/null +++ b/src/collections/composer.json @@ -0,0 +1,53 @@ +{ + "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" + }, + { + "name": "Raj Siva-Rajah", + "homepage": "https://github.com/binaryfire" + } + ], + "support": { + "issues": "https://github.com/hypervel/components/issues", + "source": "https://github.com/hypervel/components" + }, + "autoload": { + "psr-4": { + "Hypervel\\Support\\": "src/" + }, + "files": [ + "src/Functions.php", + "src/helpers.php" + ] + }, + "require": { + "php": "^8.4", + "hypervel/conditionable": "^0.4", + "hypervel/macroable": "^0.4", + "hypervel/contracts": "^0.4" + }, + "suggest": { + "hypervel/http": "Required to convert collections to API resources (^0.4)." + }, + "config": { + "sort-packages": true + }, + "extra": { + "branch-alias": { + "dev-main": "0.4-dev" + } + } +} diff --git a/src/collections/src/Arr.php b/src/collections/src/Arr.php new file mode 100644 index 000000000..97405d285 --- /dev/null +++ b/src/collections/src/Arr.php @@ -0,0 +1,1102 @@ +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|null $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|null $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('.', (string) $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(mixed $array, string|int|float|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('.', (string) $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(mixed $array, array|string|int|float|null $keys): bool + { + $keys = (array) $keys; + + if (! $array || $keys === []) { + return false; + } + + foreach ($keys as $key) { + $subKeyArray = $array; + + if (static::exists($array, $key)) { + continue; + } + + foreach (explode('.', (string) $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(mixed $array, array|string|int|float|null $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(mixed $array, array|string|int|float|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|int|float|null $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|int|float|null $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|string|null $number = null, bool $preserveKeys = false): mixed + { + if (is_string($number)) { + if (filter_var($number, FILTER_VALIDATE_INT) === false) { + throw new InvalidArgumentException("The requested number [{$number}] must be an integer."); + } + + $number = (int) $number; + } + + $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|float|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 + { + $collection = new Collection($array); + + if (is_null($callback)) { + return $collection->sort()->all(); + } + + return $collection->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 + { + $collection = new Collection($array); + + if (is_null($callback)) { + return $collection->sortDesc()->all(); + } + + return $collection->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..9e14cead5 --- /dev/null +++ b/src/collections/src/Collection.php @@ -0,0 +1,1887 @@ + + * @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 + { + return $this->hasSole($callback); + } + + /** + * 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 + { + return $this->hasMany($callback); + } + + /** + * 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 + * @param null|array|Closure|int|string $key + * @return static + */ + public function pluck(Closure|string|int|array|null $value, Closure|string|int|array|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|string $number + * @return ($number is null ? TValue : static) + * + * @throws InvalidArgumentException + */ + public function random(callable|int|string|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(); + } + + /** + * Determine if the collection contains a single item, optionally matching the given criteria. + * + * @param null|(callable(TValue, TKey): bool)|string $key + */ + public function hasSole(callable|string|null $key = null, mixed $operator = null, mixed $value = null): bool + { + $filter = func_num_args() > 1 + ? $this->operatorForWhere(...func_get_args()) + : $key; + + return $this + ->unless($filter == null) + ->filter($filter) + ->count() === 1; + } + + /** + * 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((string) $values[0], (string) $values[1]); + } else { + $result = strcasecmp((string) $values[0], (string) $values[1]); + } + } else { + $result = match ($options) { + SORT_NUMERIC => (int) $values[0] <=> (int) $values[1], + SORT_STRING => strcmp((string) $values[0], (string) $values[1]), + SORT_NATURAL => strnatcmp((string) $values[0], (string) $values[1]), + SORT_LOCALE_STRING => strcoll((string) $values[0], (string) $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. + */ + public function splice(int $offset, ?int $length = null, mixed $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..8a93c591f --- /dev/null +++ b/src/collections/src/Enumerable.php @@ -0,0 +1,1117 @@ + + * @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. + */ + public static function unwrap(mixed $value): mixed; + + /** + * 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(mixed $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(mixed $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(mixed $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(callable|string|null $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(callable|string|null $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(callable|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|null $value, ?string $glue = null): string; + + /** + * Intersect the collection with the given items. + * + * @param Arrayable|iterable $items + */ + public function intersect(mixed $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(mixed $items, callable $callback): static; + + /** + * Intersect the collection with the given items with additional index check. + * + * @param Arrayable|iterable $items + */ + public function intersectAssoc(mixed $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(mixed $items, callable $callback): static; + + /** + * Intersect the collection with the given items by key. + * + * @param Arrayable|iterable $items + */ + public function intersectByKeys(mixed $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; + + /** + * Determine if the collection contains a single item, optionally matching the given criteria. + * + * @param null|(callable(TValue, TKey): bool)|string $key + */ + public function hasSole(callable|string|null $key = null, mixed $operator = null, mixed $value = null): bool; + + /** + * Determine if the collection contains multiple items, optionally matching the given criteria. + * + * @param null|(callable(TValue, TKey): bool)|string $key + */ + public function hasMany(callable|string|null $key = null, mixed $operator = null, mixed $value = null): 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. + * + * No return type: Eloquent\Collection::collapse() returns base collection, + * which would violate `: static` when called on Eloquent\Collection. + * + * @template TFlatMapKey of array-key + * @template TFlatMapValue + * + * @param callable(TValue, TKey): (array|Collection) $callback + * @return static + */ + public function flatMap(callable $callback); + + /** + * 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(mixed $items): static; + + /** + * Recursively merge the collection with the given items. + * + * @template TMergeRecursiveValue + * + * @param Arrayable|iterable $items + * @return static + */ + public function mergeRecursive(mixed $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(mixed $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(mixed $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(callable|int|string|null $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(mixed $items): static; + + /** + * Recursively replace the collection items with the given items. + * + * @param Arrayable|iterable $items + */ + public function replaceRecursive(mixed $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(Closure|string|int|array|null $value, Closure|string|int|array|null $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; + } + } + }); + } + + /** + * 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|float $number, ?callable $callback = null): static + { + if ($number < 1) { + return new static(); + } + + $collection = new static(function () use ($number) { + if (is_infinite($number)) { + for ($i = 1;; ++$i) { + yield $i; + } + } + + for ($i = 1; $i <= $number; ++$i) { + yield $i; + } + }); + + return $collection + ->unless($callback == null) + ->map($callback); + } + + /** + * 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(mixed $items): static + { + return $this->passthru(__FUNCTION__, func_get_args()); + } + + #[Override] + public function diffUsing(mixed $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(mixed $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 null|(callable(TValue, TKey): mixed)|string $value + */ + public function implode(callable|string|null $value, ?string $glue = null): string + { + return $this->collect()->implode(...func_get_args()); + } + + #[Override] + public function intersect(mixed $items): static + { + return $this->passthru(__FUNCTION__, func_get_args()); + } + + #[Override] + public function intersectUsing(mixed $items, callable $callback): static + { + return $this->passthru(__FUNCTION__, func_get_args()); + } + + #[Override] + public function intersectAssoc(mixed $items): static + { + return $this->passthru(__FUNCTION__, func_get_args()); + } + + #[Override] + public function intersectAssocUsing(mixed $items, callable $callback): static + { + return $this->passthru(__FUNCTION__, func_get_args()); + } + + #[Override] + public function intersectByKeys(mixed $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(?callable $callback = null): bool + { + return $this->hasSole($callback); + } + + /** + * Determine if the collection contains multiple items. + */ + public function containsManyItems(): bool + { + return $this->hasMany(); + } + + /** + * 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 null|array|Closure|int|string $value + * @return static + */ + public function pluck(Closure|string|int|array|null $value, Closure|string|int|array|null $key = null) + { + return new static(function () use ($value, $key) { + [$value, $key] = $this->explodePluckParameters($value, $key); + + foreach ($this as $item) { + $itemValue = $value instanceof Closure + ? $value($item) + : data_get($item, $value); + + if (is_null($key)) { + yield $itemValue; + } else { + $itemKey = $key instanceof Closure + ? $key($item) + : 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(mixed $items): static + { + // @phpstan-ignore return.type (passthru loses generic type info) + return $this->passthru(__FUNCTION__, func_get_args()); + } + + #[Override] + public function mergeRecursive(mixed $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 instanceof IteratorAggregate) { + $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(mixed $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 null|array|Enumerable|string $keys + */ + public function only(mixed $keys): static + { + if ($keys instanceof Enumerable) { + $keys = $keys->all(); + } elseif (! is_null($keys)) { + $keys = is_array($keys) ? $keys : func_get_args(); + } + + return new static(function () use ($keys) { + if (is_null($keys)) { + yield from $this; + + return; + } + + $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 null|array|Enumerable|string $keys + */ + public function select(mixed $keys): static + { + if ($keys instanceof Enumerable) { + $keys = $keys->all(); + } elseif (! is_null($keys)) { + $keys = is_array($keys) ? $keys : func_get_args(); + } + + return new static(function () use ($keys) { + if (is_null($keys)) { + yield from $this; + + return; + } + + 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(callable|int|string|null $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(mixed $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(mixed $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(); + } + + /** + * Determine if the collection contains a single item or a single item matching the given criteria. + * + * @param null|(callable(TValue, TKey): bool)|string $key + */ + public function hasSole(callable|string|null $key = null, mixed $operator = null, mixed $value = null): bool + { + $filter = func_num_args() > 1 + ? $this->operatorForWhere(...func_get_args()) + : $key; + + return $this + ->unless($filter == null) + ->filter($filter) + ->take(2) + ->count() === 1; + } + + /** + * 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". + */ + protected function explodePluckParameters(Closure|string|int|array|null $value, Closure|string|int|array|null $key): array + { + $value = is_string($value) ? explode('.', $value) : $value; + + $key = is_null($key) || is_array($key) || is_int($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..a4325315d --- /dev/null +++ b/src/collections/src/Traits/EnumeratesValues.php @@ -0,0 +1,1094 @@ + $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 $hasMany + * @property-read HigherOrderCollectionProxy $hasSole + * @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', + 'hasMany', + 'hasSole', + '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|TUnwrapValue $value + * @return (array|TUnwrapValue) + */ + public static function unwrap(mixed $value): mixed + { + 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(callable|string $key, mixed $operator = null, mixed $value = null): mixed + { + return $this->first($this->operatorForWhere(...func_get_args())); + } + + /** + * Determine if the collection contains multiple items, optionally matching the given criteria. + * + * @param null|(callable(TValue, TKey): bool)|string $key + */ + public function hasMany(callable|string|null $key = null, mixed $operator = null, mixed $value = null): bool + { + $filter = func_num_args() > 1 + ? $this->operatorForWhere(...func_get_args()) + : $key; + + return $this + ->unless($filter == null) + ->filter($filter) + ->take(2) + ->count() === 2; + } + + /** + * 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. + * + * No return type: Eloquent\Collection::collapse() returns base collection, + * which would violate `: static` when called on Eloquent\Collection. + * + * @template TFlatMapKey of array-key + * @template TFlatMapValue + * + * @param callable(TValue, TKey): (array|Collection) $callback + * @return static + */ + public function flatMap(callable $callback) + { + 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|null $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(callable|string|null $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|null $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..5132961c7 --- /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.4" + }, + "config": { + "sort-packages": true + }, + "extra": { + "branch-alias": { + "dev-main": "0.4-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..5cd74f6cc 100644 --- a/src/config/composer.json +++ b/src/config/composer.json @@ -29,9 +29,9 @@ ] }, "require": { - "php": "^8.2", + "php": "^8.4", "hyperf/config": "~3.1.0", - "hyperf/macroable": "~3.1.0" + "hypervel/macroable": "^0.4" }, "config": { "sort-packages": true @@ -41,7 +41,7 @@ "config": "Hypervel\\Config\\ConfigProvider" }, "branch-alias": { - "dev-main": "0.3-dev" + "dev-main": "0.4-dev" } } } \ No newline at end of file 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/ConfigProvider.php b/src/config/src/ConfigProvider.php index 949ab64af..4bb0f78fb 100644 --- a/src/config/src/ConfigProvider.php +++ b/src/config/src/ConfigProvider.php @@ -5,6 +5,7 @@ namespace Hypervel\Config; use Hyperf\Contract\ConfigInterface; +use Hypervel\Contracts\Config\Repository; class ConfigProvider { @@ -13,6 +14,7 @@ public function __invoke(): array return [ 'dependencies' => [ ConfigInterface::class => ConfigFactory::class, + Repository::class => ConfigFactory::class, ], ]; } diff --git a/src/config/src/Functions.php b/src/config/src/Functions.php index 9fe456f69..ad162efa7 100644 --- a/src/config/src/Functions.php +++ b/src/config/src/Functions.php @@ -4,8 +4,7 @@ namespace Hypervel\Config; -use Hyperf\Context\ApplicationContext; -use Hypervel\Config\Contracts\Repository as ConfigContract; +use Hypervel\Context\ApplicationContext; /** * Get / set the specified configuration value. @@ -13,11 +12,11 @@ * 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 { - $config = ApplicationContext::getContainer()->get(ConfigContract::class); + $config = ApplicationContext::getContainer()->get('config'); if (is_null($key)) { return $config; diff --git a/src/config/src/ProviderConfig.php b/src/config/src/ProviderConfig.php index 0bf9c29c6..b747dc8e5 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\Composer; 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/composer.json b/src/console/composer.json index c3e363e89..300df7812 100644 --- a/src/console/composer.json +++ b/src/console/composer.json @@ -25,13 +25,13 @@ } }, "require": { - "php": "^8.2", + "php": "^8.4", "guzzlehttp/guzzle": "^7.8.2", "hyperf/command": "~3.1.0", - "hypervel/cache": "^0.3", - "hypervel/coroutine": "^0.3", - "hypervel/foundation": "^0.3", - "hypervel/queue": "^0.3", + "hypervel/cache": "^0.4", + "hypervel/coroutine": "^0.4", + "hypervel/foundation": "^0.4", + "hypervel/queue": "^0.4", "dragonmantank/cron-expression": "^3.3.2", "symfony/console": "^5.4|^6.4|^7.0", "friendsofhyperf/command-signals": "~3.1.0" @@ -48,7 +48,7 @@ "config": "Hypervel\\Console\\ConfigProvider" }, "branch-alias": { - "dev-main": "0.3-dev" + "dev-main": "0.4-dev" } } } \ No newline at end of file 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..725c9ad92 100644 --- a/src/console/src/ClosureCommand.php +++ b/src/console/src/ClosureCommand.php @@ -6,10 +6,10 @@ use BadMethodCallException; 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 Hypervel\Support\Traits\ForwardsCalls; 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..79f742860 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\Filesystem\Filesystem; +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/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/composer.json b/src/container/composer.json index 6c1d57d46..473e2ab00 100644 --- a/src/container/composer.json +++ b/src/container/composer.json @@ -20,8 +20,8 @@ } ], "require": { - "php": "^8.2", - "hyperf/context": "~3.1.0", + "php": "^8.4", + "hypervel/context": "^0.4", "hyperf/di": "~3.1.0" }, "autoload": { @@ -31,7 +31,7 @@ }, "extra": { "branch-alias": { - "dev-main": "0.3-dev" + "dev-main": "0.4-dev" } }, "config": { 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..d2930d8a0 100644 --- a/src/container/src/Container.php +++ b/src/container/src/Container.php @@ -6,10 +6,10 @@ use ArrayAccess; use Closure; -use Hyperf\Context\ApplicationContext; use Hyperf\Di\Container as HyperfContainer; use Hyperf\Di\Definition\DefinitionSource; -use Hypervel\Container\Contracts\Container as ContainerContract; +use Hypervel\Context\ApplicationContext; +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. */ @@ -330,7 +341,11 @@ public function instance(string $abstract, mixed $instance): mixed { $this->removeAbstractAlias($abstract); - unset($this->aliases[$abstract]); + $isBound = $this->bound($abstract); + + unset($this->aliases[$abstract], $this->resolvedEntries[$abstract]); + + // Clear any cached resolved entry so the new instance is used // We'll check to determine if this type has been bound before, and if it has // we will fire the rebound callbacks registered with the container and it @@ -339,12 +354,14 @@ public function instance(string $abstract, mixed $instance): mixed $instance = fn () => $instance; } - if ($this->bound($abstract)) { + // Define the new instance BEFORE firing rebound callbacks, + // so callbacks receive the new instance, not the old one + $this->define($abstract, $instance); + + if ($isBound) { $this->rebound($abstract); } - $this->define($abstract, $instance); - return $instance; } @@ -711,7 +728,10 @@ public function offsetGet(mixed $offset): mixed public function offsetSet(mixed $offset, mixed $value): void { - $this->define($offset, $value); + $this->bind( + abstract: $offset, + concrete: $value instanceof Closure ? $value : fn () => $value + ); } public function offsetUnset(mixed $offset): void diff --git a/src/context/LICENSE.md b/src/context/LICENSE.md new file mode 100644 index 000000000..385442433 --- /dev/null +++ b/src/context/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. diff --git a/src/context/README.md b/src/context/README.md new file mode 100644 index 000000000..0ced38474 --- /dev/null +++ b/src/context/README.md @@ -0,0 +1,4 @@ +Context for Hypervel +=== + +[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/hypervel/context) diff --git a/src/context/composer.json b/src/context/composer.json new file mode 100644 index 000000000..a4b743717 --- /dev/null +++ b/src/context/composer.json @@ -0,0 +1,44 @@ +{ + "name": "hypervel/context", + "description": "A coroutine context library for Hypervel.", + "license": "MIT", + "keywords": [ + "php", + "hyperf", + "context", + "coroutine", + "swoole", + "hypervel" + ], + "support": { + "issues": "https://github.com/hypervel/components/issues", + "source": "https://github.com/hypervel/components" + }, + "authors": [ + { + "name": "Albert Chen", + "email": "albert@hypervel.org" + } + ], + "require": { + "php": "^8.4", + "hypervel/engine": "^0.4" + }, + "autoload": { + "psr-4": { + "Hypervel\\Context\\": "src/" + }, + "files": [ + "src/helpers.php" + ] + }, + "extra": { + "branch-alias": { + "dev-main": "0.4-dev" + } + }, + "config": { + "sort-packages": true + }, + "minimum-stability": "dev" +} diff --git a/src/context/src/ApplicationContext.php b/src/context/src/ApplicationContext.php new file mode 100644 index 000000000..df01bb366 --- /dev/null +++ b/src/context/src/ApplicationContext.php @@ -0,0 +1,43 @@ + */ + // protected static array $nonCoContext = []; + + /** + * @param TKey $id + * @param TValue $value + * @return TValue + */ + public static function set(UnitEnum|string $id, mixed $value, ?int $coroutineId = null): mixed + { + $id = enum_value($id); + + if (Coroutine::id() > 0) { + Coroutine::getContextFor($coroutineId)[$id] = $value; + } else { + static::$nonCoContext[$id] = $value; + } + + return $value; + } + + /** + * @param TKey $id + * @return TValue + */ + public static function get(UnitEnum|string $id, mixed $default = null, ?int $coroutineId = null): mixed + { + $id = enum_value($id); + + if (Coroutine::id() > 0) { + return Coroutine::getContextFor($coroutineId)[$id] ?? $default; + } + + return static::$nonCoContext[$id] ?? $default; + } + + /** + * @param TKey $id + */ + public static function has(UnitEnum|string $id, ?int $coroutineId = null): bool + { + $id = enum_value($id); + + if (Coroutine::id() > 0) { + return isset(Coroutine::getContextFor($coroutineId)[$id]); + } + + return isset(static::$nonCoContext[$id]); + } + + /** + * Release the context when you are not in coroutine environment. + * + * @param TKey $id + */ + public static function destroy(UnitEnum|string $id, ?int $coroutineId = null): void + { + $id = enum_value($id); + + if (Coroutine::id() > 0) { + unset(Coroutine::getContextFor($coroutineId)[$id]); + } + + unset(static::$nonCoContext[$id]); + } + + /** + * Copy the context from a coroutine to current coroutine. + * This method will delete the origin values in current coroutine. + */ + public static function copy(int $fromCoroutineId, array $keys = []): void + { + $from = Coroutine::getContextFor($fromCoroutineId); + + if ($from === null) { + return; + } + + $current = Coroutine::getContextFor(); + + if ($keys) { + $map = array_intersect_key($from->getArrayCopy(), array_flip($keys)); + } else { + $map = $from->getArrayCopy(); + } + + $current->exchangeArray($map); + } + + /** + * Retrieve the value and override it by closure. + * + * @param TKey $id + * @param (Closure(TValue):TValue) $closure + */ + public static function override(UnitEnum|string $id, Closure $closure, ?int $coroutineId = null): mixed + { + $value = null; + + if (self::has($id, $coroutineId)) { + $value = self::get($id, null, $coroutineId); + } + + $value = $closure($value); + + self::set($id, $value, $coroutineId); + + return $value; + } + + /** + * Retrieve the value and store it if not exists. + * + * @param TKey $id + * @param TValue $value + * @return TValue + */ + public static function getOrSet(UnitEnum|string $id, mixed $value, ?int $coroutineId = null): mixed + { + if (! self::has($id, $coroutineId)) { + return self::set($id, value($value), $coroutineId); + } + + return self::get($id, null, $coroutineId); + } + + /** + * Set multiple key-value pairs in the context. + */ + public static function setMany(array $values, ?int $coroutineId = null): void + { + foreach ($values as $key => $value) { + static::set($key, $value, $coroutineId); + } + } + + /** + * Copy context data from non-coroutine context to the specified coroutine context. + */ + public static function copyFromNonCoroutine(array $keys = [], ?int $coroutineId = null): void + { + if (is_null($context = Coroutine::getContextFor($coroutineId))) { + return; + } + + if ($keys) { + $map = array_intersect_key(static::$nonCoContext, array_flip($keys)); + } else { + $map = static::$nonCoContext; + } + + $context->exchangeArray($map); + } + + /** + * Copy context data from the specified coroutine context to non-coroutine context. + */ + public static function copyToNonCoroutine(array $keys = [], ?int $coroutineId = null): void + { + if (is_null($context = Coroutine::getContextFor($coroutineId))) { + return; + } + + if ($keys) { + foreach ($keys as $key) { + if (isset($context[$key])) { + static::$nonCoContext[$key] = $context[$key]; + } + } + } else { + foreach ($context as $key => $value) { + static::$nonCoContext[$key] = $value; + } + } + } + + /** + * Get a value from non-coroutine context only. + * + * Unlike get() which reads from coroutine context when inside a coroutine, + * this always reads from non-coroutine storage regardless of current context. + * + * @param TKey $id + * @param TValue $default + * @return TValue + */ + public static function getFromNonCoroutine(UnitEnum|string $id, mixed $default = null): mixed + { + $id = enum_value($id); + + return static::$nonCoContext[$id] ?? $default; + } + + /** + * Clear specific keys from non-coroutine context only. + * + * Unlike destroy() which clears from both contexts, this only affects + * non-coroutine storage. Useful for clearing stale data before copying. + */ + public static function clearFromNonCoroutine(array $keys): void + { + foreach ($keys as $key) { + unset(static::$nonCoContext[$key]); + } + } + + /** + * Destroy all context data for the specified coroutine, preserving only the depth key. + */ + public static function destroyAll(?int $coroutineId = null): void + { + $coroutineId = $coroutineId ?: Coroutine::id(); + + // Clear non-coroutine context in non-coroutine environment. + if ($coroutineId < 0) { + static::$nonCoContext = []; + return; + } + + if (! $context = Coroutine::getContextFor($coroutineId)) { + return; + } + + $contextKeys = []; + foreach ($context as $key => $_) { + if ($key === static::DEPTH_KEY) { + continue; + } + $contextKeys[] = $key; + } + + foreach ($contextKeys as $key) { + static::destroy($key, $coroutineId); + } + } + + /** + * @return null|array|ArrayObject + */ + public static function getContainer(?int $coroutineId = null): array|ArrayObject|null + { + if (Coroutine::id() > 0) { + return Coroutine::getContextFor($coroutineId); + } + + return static::$nonCoContext; + } +} diff --git a/src/core/src/Context/ParentContext.php b/src/context/src/ParentContext.php similarity index 95% rename from src/core/src/Context/ParentContext.php rename to src/context/src/ParentContext.php index 019d05118..747afc9a7 100644 --- a/src/core/src/Context/ParentContext.php +++ b/src/context/src/ParentContext.php @@ -4,6 +4,7 @@ namespace Hypervel\Context; +use ArrayObject; use Closure; use Hypervel\Coroutine\Coroutine; @@ -63,7 +64,7 @@ public static function getOrSet(string $id, mixed $value): mixed return Context::getOrSet($id, $value); } - public static function getContainer() + public static function getContainer(): array|ArrayObject|null { if (Coroutine::inCoroutine()) { return Context::getContainer(Coroutine::parentId()); diff --git a/src/context/src/RequestContext.php b/src/context/src/RequestContext.php new file mode 100644 index 000000000..60ca43585 --- /dev/null +++ b/src/context/src/RequestContext.php @@ -0,0 +1,36 @@ +getTargetObject(); + + return $target->{$name}(...$arguments); + } + + public function __get(string $name): mixed + { + $target = $this->getTargetObject(); + + return $target->{$name}; + } + + public function __set(string $name, mixed $value): void + { + $target = $this->getTargetObject(); + $target->{$name} = $value; + } + + protected function getTargetObject(): mixed + { + if (! isset($this->proxyKey)) { + throw new RuntimeException(sprintf('Missing $proxyKey property in %s.', $this::class)); + } + + return Context::get($this->proxyKey); + } +} diff --git a/src/core/src/helpers.php b/src/context/src/helpers.php similarity index 100% rename from src/core/src/helpers.php rename to src/context/src/helpers.php 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..1cc60fc6f --- /dev/null +++ b/src/contracts/composer.json @@ -0,0 +1,43 @@ +{ + "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" + }, + { + "name": "Raj Siva-Rajah", + "homepage": "https://github.com/binaryfire" + } + ], + "support": { + "issues": "https://github.com/hypervel/components/issues", + "source": "https://github.com/hypervel/components" + }, + "autoload": { + "psr-4": { + "Hypervel\\Contracts\\": "src/" + } + }, + "require": { + "php": "^8.4" + }, + "config": { + "sort-packages": true + }, + "extra": { + "branch-alias": { + "dev-main": "0.4-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/ComparesCastableAttributes.php b/src/contracts/src/Database/Eloquent/ComparesCastableAttributes.php new file mode 100644 index 000000000..584ea995a --- /dev/null +++ b/src/contracts/src/Database/Eloquent/ComparesCastableAttributes.php @@ -0,0 +1,15 @@ + $attributes + */ + public function increment(Model $model, string $key, mixed $value, array $attributes): mixed; + + /** + * Decrement the attribute. + * + * @param array $attributes + */ + public function decrement(Model $model, string $key, mixed $value, array $attributes): mixed; +} diff --git a/src/contracts/src/Database/Eloquent/SerializesCastableAttributes.php b/src/contracts/src/Database/Eloquent/SerializesCastableAttributes.php new file mode 100644 index 000000000..dfc85e771 --- /dev/null +++ b/src/contracts/src/Database/Eloquent/SerializesCastableAttributes.php @@ -0,0 +1,17 @@ + $attributes + */ + public function serialize(Model $model, string $key, mixed $value, array $attributes): mixed; +} 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 @@ + returns all coroutine IDs + */ + public static function list(): iterable; +} diff --git a/src/contracts/src/Engine/DefaultOptionInterface.php b/src/contracts/src/Engine/DefaultOptionInterface.php new file mode 100644 index 000000000..24cf2cff8 --- /dev/null +++ b/src/contracts/src/Engine/DefaultOptionInterface.php @@ -0,0 +1,13 @@ + + */ + 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/contracts/src/Pool/ConnectionInterface.php b/src/contracts/src/Pool/ConnectionInterface.php new file mode 100644 index 000000000..abb4cf1ba --- /dev/null +++ b/src/contracts/src/Pool/ConnectionInterface.php @@ -0,0 +1,33 @@ + + */ + 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 @@ +channel = new Channel(1); + } + + /** + * Yield the current coroutine for a given timeout, unless the coordinator is woken up from outside. + * + * @return bool Returns true if the coordinator has been woken up + */ + public function yield(float|int $timeout = -1): bool + { + $this->channel->pop((float) $timeout); + return $this->channel->isClosing(); + } + + /** + * Determine if the coordinator is closing. + */ + public function isClosing(): bool + { + return $this->channel->isClosing(); + } + + /** + * Wake up all coroutines yielding for this coordinator. + */ + public function resume(): void + { + $this->channel->close(); + } +} diff --git a/src/coordinator/src/CoordinatorManager.php b/src/coordinator/src/CoordinatorManager.php new file mode 100644 index 000000000..b4af8df8e --- /dev/null +++ b/src/coordinator/src/CoordinatorManager.php @@ -0,0 +1,43 @@ + + */ + private static array $container = []; + + /** + * Initialize a coordinator with the given identifier. + */ + public static function initialize(string $identifier): void + { + self::$container[$identifier] = new Coordinator(); + } + + /** + * Get a coordinator by its identifier, creating one if it doesn't exist. + */ + public static function until(string $identifier): Coordinator + { + if (! isset(self::$container[$identifier])) { + self::$container[$identifier] = new Coordinator(); + } + + return self::$container[$identifier]; + } + + /** + * Remove the coordinator by the identifier to prevent memory leaks. + */ + public static function clear(string $identifier): void + { + unset(self::$container[$identifier]); + } +} diff --git a/src/coordinator/src/Functions.php b/src/coordinator/src/Functions.php new file mode 100644 index 000000000..eca17b403 --- /dev/null +++ b/src/coordinator/src/Functions.php @@ -0,0 +1,32 @@ +yield($timeout)`. + */ +function block(float $timeout = -1, string $identifier = Constants::WORKER_EXIT): bool +{ + return CoordinatorManager::until($identifier)->yield($timeout); +} + +/** + * Resume the coroutine that is blocked by the specified identifier. + * Alias of `CoordinatorManager::until($identifier)->resume()`. + */ +function resume(string $identifier = Constants::WORKER_EXIT): void +{ + CoordinatorManager::until($identifier)->resume(); +} + +/** + * Clear the coroutine that is blocked by the specified identifier. + * Alias of `CoordinatorManager::clear($identifier)`. + */ +function clear(string $identifier = Constants::WORKER_EXIT): void +{ + CoordinatorManager::clear($identifier); +} diff --git a/src/coordinator/src/Timer.php b/src/coordinator/src/Timer.php new file mode 100644 index 000000000..bbbd2f5ec --- /dev/null +++ b/src/coordinator/src/Timer.php @@ -0,0 +1,131 @@ +id; + $this->closures[$id] = true; + go(function () use ($timeout, $closure, $identifier, $id) { + try { + ++Timer::$count; + $isClosing = match (true) { + $timeout > 0 => CoordinatorManager::until($identifier)->yield($timeout), // Run after $timeout seconds. + $timeout === 0.0 => CoordinatorManager::until($identifier)->isClosing(), // Run immediately. + default => CoordinatorManager::until($identifier)->yield(), // Run until $identifier resume. + }; + if (isset($this->closures[$id])) { + $closure($isClosing); + } + } finally { + unset($this->closures[$id]); + --Timer::$count; + } + }); + return $id; + } + + /** + * Execute a callback repeatedly at a given interval until stopped or the identifier is resumed. + */ + public function tick(float $timeout, callable $closure, string $identifier = Constants::WORKER_EXIT): int + { + $id = ++$this->id; + $this->closures[$id] = true; + go(function () use ($timeout, $closure, $identifier, $id) { + try { + $round = 0; + ++Timer::$count; + while (true) { + $isClosing = CoordinatorManager::until($identifier)->yield(max($timeout, 0.000001)); + if (! isset($this->closures[$id])) { + break; + } + + $result = null; + + try { + $result = $closure($isClosing); + } catch (Throwable $exception) { + $this->logger?->error((string) $exception); + } + + if ($result === self::STOP || $isClosing) { + break; + } + + ++$round; + ++Timer::$round; + } + } finally { + unset($this->closures[$id]); + Timer::$round -= $round; + --Timer::$count; + } + }); + return $id; + } + + /** + * Execute a callback when the identifier is resumed. + */ + public function until(callable $closure, string $identifier = Constants::WORKER_EXIT): int + { + return $this->after(-1, $closure, $identifier); + } + + /** + * Clear a registered timer callback by its ID. + */ + public function clear(int $id): void + { + unset($this->closures[$id]); + } + + /** + * Clear all registered timer callbacks. + */ + public function clearAll(): void + { + $this->closures = []; + } + + /** + * Get the current timer statistics. + * + * @return array{num: int, round: int} + */ + public static function stats(): array + { + return [ + 'num' => Timer::$count, + 'round' => Timer::$round, + ]; + } +} diff --git a/src/core/class_map/Command/Concerns/Confirmable.php b/src/core/class_map/Command/Concerns/Confirmable.php index 968a0772e..4681a4111 100644 --- a/src/core/class_map/Command/Concerns/Confirmable.php +++ b/src/core/class_map/Command/Concerns/Confirmable.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/core/class_map/Hyperf/Coroutine/Coroutine.php b/src/core/class_map/Hyperf/Coroutine/Coroutine.php index 733498211..1edfc9b0b 100644 --- a/src/core/class_map/Hyperf/Coroutine/Coroutine.php +++ b/src/core/class_map/Hyperf/Coroutine/Coroutine.php @@ -4,13 +4,13 @@ namespace Hyperf\Coroutine; -use Hyperf\Context\ApplicationContext; -use Hyperf\Context\Context; use Hyperf\Contract\StdoutLoggerInterface; use Hyperf\Engine\Coroutine as Co; use Hyperf\Engine\Exception\CoroutineDestroyedException; use Hyperf\Engine\Exception\RunningInNonCoroutineException; use Hyperf\ExceptionHandler\Formatter\FormatterInterface; +use Hypervel\Context\ApplicationContext; +use Hypervel\Context\Context; use Throwable; class Coroutine diff --git a/src/core/composer.json b/src/core/composer.json index 9c0355a6c..681e0c564 100644 --- a/src/core/composer.json +++ b/src/core/composer.json @@ -23,19 +23,13 @@ "autoload": { "psr-4": { "Hypervel\\": "src/" - }, - "files": [ - "src/helpers.php" - ] + } }, "require": { - "php": "^8.2", - "hyperf/database": "~3.1.0", + "php": "^8.4", "hyperf/http-message": "~3.1.0", - "hyperf/context": "~3.1.0" - }, - "require-dev": { - "fakerphp/faker": "^2.0" + "hypervel/context": "^0.4", + "symfony/polyfill-php85": "^1.33" }, "config": { "sort-packages": true @@ -45,7 +39,7 @@ "config": "Hypervel\\ConfigProvider" }, "branch-alias": { - "dev-main": "0.3-dev" + "dev-main": "0.4-dev" } } } \ No newline at end of file diff --git a/src/core/src/ConfigProvider.php b/src/core/src/ConfigProvider.php index ef6e22c82..bf9ab2e37 100644 --- a/src/core/src/ConfigProvider.php +++ b/src/core/src/ConfigProvider.php @@ -6,21 +6,7 @@ use Hyperf\Command\Concerns\Confirmable; use Hyperf\Coroutine\Coroutine; -use Hyperf\Database\Commands\Migrations\BaseCommand as MigrationBaseCommand; -use Hyperf\Database\Commands\Migrations\FreshCommand; -use Hyperf\Database\Commands\Migrations\InstallCommand; -use Hyperf\Database\Commands\Migrations\MigrateCommand; -use Hyperf\Database\Commands\Migrations\RefreshCommand; -use Hyperf\Database\Commands\Migrations\ResetCommand; -use Hyperf\Database\Commands\Migrations\RollbackCommand; -use Hyperf\Database\Commands\Migrations\StatusCommand; -use Hyperf\Database\Migrations\MigrationCreator as HyperfMigrationCreator; -use Hyperf\Database\Model\Factory as HyperfDatabaseFactory; use Hyperf\ViewEngine\Compiler\CompilerInterface; -use Hypervel\Database\Console\SeedCommand; -use Hypervel\Database\Eloquent\Factories\LegacyFactoryInvoker as DatabaseFactoryInvoker; -use Hypervel\Database\Migrations\MigrationCreator; -use Hypervel\Database\TransactionListener; use Hypervel\View\CompilerFactory; class ConfigProvider @@ -29,27 +15,11 @@ public function __invoke(): array { return [ 'dependencies' => [ - 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 deleted file mode 100644 index 45e51f8fd..000000000 --- a/src/core/src/Context/ApplicationContext.php +++ /dev/null @@ -1,21 +0,0 @@ - $value) { - static::set($key, $value, $coroutineId); - } - } - - /** - * Copy context data from non-coroutine context to the specified coroutine context. - */ - public static function copyFromNonCoroutine(array $keys = [], ?int $coroutineId = null): void - { - if (is_null($context = Coroutine::getContextFor($coroutineId))) { - return; - } - - if ($keys) { - $map = array_intersect_key(static::$nonCoContext, array_flip($keys)); - } else { - $map = static::$nonCoContext; - } - - $context->exchangeArray($map); - } - - /** - * Destroy all context data for the specified coroutine, preserving only the depth key. - */ - public static function destroyAll(?int $coroutineId = null): void - { - $coroutineId = $coroutineId ?: Coroutine::id(); - - // Clear non-coroutine context in non-coroutine environment. - if ($coroutineId < 0) { - static::$nonCoContext = []; - return; - } - - if (! $context = Coroutine::getContextFor($coroutineId)) { - return; - } - - $contextKeys = []; - foreach ($context as $key => $_) { - if ($key === static::DEPTH_KEY) { - continue; - } - $contextKeys[] = $key; - } - - foreach ($contextKeys as $key) { - static::destroy($key, $coroutineId); - } - } -} diff --git a/src/core/src/Context/RequestContext.php b/src/core/src/Context/RequestContext.php deleted file mode 100644 index 5a3b7319c..000000000 --- a/src/core/src/Context/RequestContext.php +++ /dev/null @@ -1,11 +0,0 @@ -> $collectionClass - */ - public function __construct( - public string $collectionClass, - ) { - } -} 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/HasCollection.php b/src/core/src/Database/Eloquent/Concerns/HasCollection.php deleted file mode 100644 index 421d7d89d..000000000 --- a/src/core/src/Database/Eloquent/Concerns/HasCollection.php +++ /dev/null @@ -1,61 +0,0 @@ -> - */ - protected static array $resolvedCollectionClasses = []; - - /** - * Create a new Eloquent Collection instance. - * - * @param array $models - * @return \Hypervel\Database\Eloquent\Collection - * @phpstan-ignore generics.notSubtype (static in Pivot/MorphPivot context satisfies Model constraint at runtime) - */ - public function newCollection(array $models = []): Collection - { - static::$resolvedCollectionClasses[static::class] ??= ($this->resolveCollectionFromAttribute() ?? static::$collectionClass); - - return new static::$resolvedCollectionClasses[static::class]($models); // @phpstan-ignore argument.type - } - - /** - * Resolve the collection class name from the CollectedBy attribute. - * - * @return null|class-string - */ - protected function resolveCollectionFromAttribute(): ?string - { - $reflectionClass = new ReflectionClass(static::class); - - $attributes = $reflectionClass->getAttributes(CollectedBy::class); - - if (! isset($attributes[0])) { - return null; - } - - // @phpstan-ignore return.type (attribute stores generic Model type, but we know it's compatible with static) - return $attributes[0]->newInstance()->collectionClass; - } -} 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 ae37a9d83..000000000 --- a/src/core/src/Database/Eloquent/Model.php +++ /dev/null @@ -1,324 +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 HasCollection; - use HasGlobalScopes; - use HasLocalScopes; - use HasObservers; - use HasRelations; - use HasRelationships; - use HasTimestamps; - use TransformsToResource; - - /** - * The default collection class for this model. - * - * Override this property to use a custom collection class. Alternatively, - * use the #[CollectedBy] attribute for a more declarative approach. - * - * @var class-string> - */ - protected static string $collectionClass = Collection::class; - - /** - * The resolved builder class names by model. - * - * @var array, class-string>|false> - */ - protected static array $resolvedBuilderClasses = []; - - 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; - } - - 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 80f1702b9..000000000 --- a/src/core/src/Database/Eloquent/Relations/MorphPivot.php +++ /dev/null @@ -1,86 +0,0 @@ - - */ - protected static string $collectionClass = Collection::class; - - /** - * 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; - } - - /** - * 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 7607b9253..000000000 --- a/src/core/src/Database/Eloquent/Relations/Pivot.php +++ /dev/null @@ -1,80 +0,0 @@ - - */ - protected static string $collectionClass = Collection::class; - - /** - * 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; - } - - /** - * 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/CompilerFactory.php b/src/core/src/View/CompilerFactory.php index ab13af113..8606f4dfe 100644 --- a/src/core/src/View/CompilerFactory.php +++ b/src/core/src/View/CompilerFactory.php @@ -4,8 +4,8 @@ namespace Hypervel\View; -use Hyperf\Support\Filesystem\Filesystem; use Hyperf\ViewEngine\Blade; +use Hypervel\Filesystem\Filesystem; use Hypervel\View\Compilers\BladeCompiler; use Psr\Container\ContainerInterface; 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..e00fdddd2 100644 --- a/src/core/src/View/Middleware/ValidationExceptionHandle.php +++ b/src/core/src/View/Middleware/ValidationExceptionHandle.php @@ -6,10 +6,10 @@ use Hyperf\Contract\MessageBag as MessageBagContract; use Hyperf\Contract\MessageProvider; -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\Support\MessageBag; use Hypervel\Validation\ValidationException; use Psr\Container\ContainerInterface; use Psr\Http\Message\ResponseInterface; diff --git a/src/coroutine/composer.json b/src/coroutine/composer.json index 862551323..18311309d 100644 --- a/src/coroutine/composer.json +++ b/src/coroutine/composer.json @@ -29,7 +29,7 @@ ] }, "require": { - "php": "^8.2", + "php": "^8.4", "hyperf/coroutine": "~3.1.0" }, "config": { @@ -37,7 +37,7 @@ }, "extra": { "branch-alias": { - "dev-main": "0.3-dev" + "dev-main": "0.4-dev" } } } \ No newline at end of file diff --git a/src/coroutine/src/Barrier.php b/src/coroutine/src/Barrier.php index e31f0443a..781342544 100644 --- a/src/coroutine/src/Barrier.php +++ b/src/coroutine/src/Barrier.php @@ -4,8 +4,8 @@ namespace Hypervel\Coroutine; -use Hyperf\Coroutine\Barrier as BaseBarrier; +use Hypervel\Engine\Barrier as EngineBarrier; -class Barrier extends BaseBarrier +class Barrier extends EngineBarrier { } diff --git a/src/coroutine/src/Channel.php b/src/coroutine/src/Channel.php deleted file mode 100644 index edd431cdc..000000000 --- a/src/coroutine/src/Channel.php +++ /dev/null @@ -1,11 +0,0 @@ -initInstance(); + } + + /** + * Execute a closure with the pooled instance. + */ + public function call(Closure $closure): mixed + { + $release = true; + $channel = $this->channel; + try { + $instance = $channel->pop($this->waitTimeout); + if ($instance === false) { + if ($channel->isClosing()) { + throw new ChannelClosedException('The channel was closed.'); + } + + if ($channel->isTimeout()) { + throw new WaitTimeoutException('The instance pop from channel timeout.'); + } + } + + $result = $closure($instance); + } catch (ChannelClosedException|WaitTimeoutException $exception) { + $release = false; + throw $exception; + } finally { + $release && $channel->push($instance ?? null); + } + + return $result; + } + + /** + * Initialize or reinitialize the pooled instance. + */ + public function initInstance(): void + { + $this->channel?->close(); + $this->channel = new Channel(1); + $this->channel->push($this->closure->__invoke()); + } } diff --git a/src/coroutine/src/Channel/Manager.php b/src/coroutine/src/Channel/Manager.php index 5961b11e9..1d7dfc0e5 100644 --- a/src/coroutine/src/Channel/Manager.php +++ b/src/coroutine/src/Channel/Manager.php @@ -4,8 +4,74 @@ namespace Hypervel\Coroutine\Channel; -use Hyperf\Coroutine\Channel\Manager as BaseManager; +use Hypervel\Engine\Channel; -class Manager extends BaseManager +class Manager { + /** + * @var array + */ + protected array $channels = []; + + public function __construct( + protected int $size = 1, + ) { + } + + /** + * Get a channel by ID, optionally initializing it if it doesn't exist. + */ + public function get(int $id, bool $initialize = false): ?Channel + { + if (isset($this->channels[$id])) { + return $this->channels[$id]; + } + + if ($initialize) { + return $this->channels[$id] = $this->make($this->size); + } + + return null; + } + + /** + * Create a new channel with the given capacity. + */ + public function make(int $limit): Channel + { + return new Channel($limit); + } + + /** + * Close and remove a channel by ID. + */ + public function close(int $id): void + { + if ($channel = $this->channels[$id] ?? null) { + $channel->close(); + } + + unset($this->channels[$id]); + } + + /** + * Get all managed channels. + * + * @return array + */ + public function getChannels(): array + { + return $this->channels; + } + + /** + * Close and remove all managed channels. + */ + public function flush(): void + { + $channels = $this->getChannels(); + foreach ($channels as $id => $channel) { + $this->close($id); + } + } } diff --git a/src/coroutine/src/Channel/Pool.php b/src/coroutine/src/Channel/Pool.php index 38304d32a..800f424b5 100644 --- a/src/coroutine/src/Channel/Pool.php +++ b/src/coroutine/src/Channel/Pool.php @@ -4,8 +4,40 @@ namespace Hypervel\Coroutine\Channel; -use Hyperf\Coroutine\Channel\Pool as BasePool; +use Hypervel\Engine\Channel; +use SplQueue; -class Pool extends BasePool +/** + * A singleton pool for reusing Channel instances. + * + * @extends SplQueue + */ +class Pool extends SplQueue { + protected static ?Pool $instance = null; + + /** + * Get the singleton instance. + */ + public static function getInstance(): self + { + return static::$instance ??= new self(); + } + + /** + * Get a channel from the pool, or create a new one if empty. + */ + public function get(): Channel + { + return $this->isEmpty() ? new Channel(1) : $this->pop(); + } + + /** + * Release a channel back to the pool. + */ + public function release(Channel $channel): void + { + $channel->errCode = 0; + $this->push($channel); + } } diff --git a/src/coroutine/src/Concurrent.php b/src/coroutine/src/Concurrent.php index 8ad3395dd..ffe839584 100644 --- a/src/coroutine/src/Concurrent.php +++ b/src/coroutine/src/Concurrent.php @@ -4,15 +4,84 @@ namespace Hypervel\Coroutine; -use Hyperf\Context\ApplicationContext; -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\Context\ApplicationContext; +use Hypervel\Contracts\Debug\ExceptionHandler as ExceptionHandlerContract; +use Hypervel\Coroutine\Exception\InvalidArgumentException; +use Hypervel\Engine\Channel; use Throwable; -class Concurrent extends BaseConcurrent +/** + * @method bool isFull() + * @method bool isEmpty() + */ +class Concurrent { + protected Channel $channel; + + public function __construct( + protected int $limit, + ) { + $this->channel = new Channel($limit); + } + + /** + * Proxy isFull() and isEmpty() to the channel. + * + * @return mixed + * @throws InvalidArgumentException When method is not supported + */ + public function __call(string $name, array $arguments) + { + if (in_array($name, ['isFull', 'isEmpty'])) { + return $this->channel->{$name}(...$arguments); + } + + throw new InvalidArgumentException(sprintf('The method %s is not supported.', $name)); + } + + /** + * Get the concurrency limit. + */ + public function getLimit(): int + { + return $this->limit; + } + + /** + * Get the current number of running coroutines. + */ + public function length(): int + { + return $this->channel->getLength(); + } + + /** + * Get the current number of running coroutines. + */ + public function getLength(): int + { + return $this->channel->getLength(); + } + + /** + * Get the current number of running coroutines. + */ + public function getRunningCoroutineCount(): int + { + return $this->getLength(); + } + + /** + * Get the underlying channel. + */ + public function getChannel(): Channel + { + return $this->channel; + } + + /** + * Create a new coroutine with concurrency limiting. + */ public function create(callable $callable): void { $this->channel->push(true); @@ -28,6 +97,9 @@ public function create(callable $callable): void }); } + /** + * Report an exception through the exception handler. + */ protected function reportException(Throwable $throwable): void { if (! ApplicationContext::hasContainer()) { @@ -39,13 +111,6 @@ protected function reportException(Throwable $throwable): void if ($container->has(ExceptionHandlerContract::class)) { $container->get(ExceptionHandlerContract::class) ->report($throwable); - return; - } - - if ($container->has(StdoutLoggerInterface::class) && $container->has(FormatterInterface::class)) { - $logger = $container->get(StdoutLoggerInterface::class); - $formatter = $container->get(FormatterInterface::class); - $logger->error($formatter->format($throwable)); } } } diff --git a/src/coroutine/src/Coroutine.php b/src/coroutine/src/Coroutine.php index c869349ca..119cafd58 100644 --- a/src/coroutine/src/Coroutine.php +++ b/src/coroutine/src/Coroutine.php @@ -4,22 +4,186 @@ namespace Hypervel\Coroutine; -use Hyperf\Context\ApplicationContext; -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\Context\ApplicationContext; +use Hypervel\Context\Context; +use Hypervel\Contracts\Debug\ExceptionHandler as ExceptionHandlerContract; +use Hypervel\Engine\Coroutine as Co; +use Hypervel\Engine\Exception\CoroutineDestroyedException; +use Hypervel\Engine\Exception\RunningInNonCoroutineException; use Throwable; -class Coroutine extends BaseCoroutine +class Coroutine { protected static bool $enableReportException = true; + /** + * @var array + */ + protected static array $afterCreatedCallbacks = []; + + /** + * Returns the current coroutine ID. + * + * Returns -1 when running in non-coroutine context. + */ + public static function id(): int + { + return Co::id(); + } + + /** + * Register a callback to be called after a coroutine is created. + */ + public static function afterCreated(callable $callback): void + { + static::$afterCreatedCallbacks[] = $callback; + } + + /** + * Flush after created callbacks. + */ + public static function flushAfterCreated(): void + { + static::$afterCreatedCallbacks = []; + } + + /** + * Register a callback to be executed when the coroutine exits. + */ + public static function defer(callable $callable): void + { + Co::defer(static function () use ($callable) { + try { + $callable(); + } catch (Throwable $throwable) { + static::printLog($throwable); + } + }); + } + + /** + * Sleep for the given number of seconds. + */ + public static function sleep(float $seconds): void + { + usleep(intval($seconds * 1000 * 1000)); + } + + /** + * Returns the parent coroutine ID. + * + * Returns 0 when running in the top level coroutine. + * + * @throws RunningInNonCoroutineException When running in non-coroutine context + * @throws CoroutineDestroyedException When the coroutine has been destroyed + */ + public static function parentId(?int $coroutineId = null): int + { + return Co::pid($coroutineId); + } + + /** + * Alias of Coroutine::parentId(). + * + * @throws RunningInNonCoroutineException When running in non-coroutine context + * @throws CoroutineDestroyedException When the coroutine has been destroyed + */ + public static function pid(?int $coroutineId = null): int + { + return Co::pid($coroutineId); + } + + /** + * Create a new coroutine. + * + * @return int The coroutine ID, or -1 if creation failed + */ + public static function create(callable $callable): int + { + $coroutine = Co::create(static function () use ($callable) { + try { + // Execute afterCreated callbacks. + foreach (static::$afterCreatedCallbacks as $callback) { + try { + $callback(); + } catch (Throwable $throwable) { + static::printLog($throwable); + } + } + $callable(); + } catch (Throwable $throwable) { + static::printLog($throwable); + } + }); + + try { + return $coroutine->getId(); + } catch (Throwable) { + return -1; + } + } + + /** + * Create a coroutine with a copy of the parent coroutine context. + * + * @param array $keys Context keys to copy (empty = all keys) + */ + public static function fork(callable $callable, array $keys = []): int + { + $cid = static::id(); + $callable = static function () use ($callable, $cid, $keys) { + Context::copy($cid, $keys); + $callable(); + }; + + return static::create($callable); + } + + /** + * Determine if currently running in a coroutine. + */ + public static function inCoroutine(): bool + { + return Co::id() > 0; + } + + /** + * Get coroutine statistics. + */ + public static function stats(): array + { + return Co::stats(); + } + + /** + * Determine if a coroutine with the given ID exists. + */ + public static function exists(int $id): bool + { + return Co::exists($id); + } + + /** + * Get a list of all coroutine IDs. + * + * @return iterable + */ + public static function list(): iterable + { + return Co::list(); + } + + /** + * Enable or disable exception reporting in coroutines. + */ public static function enableReportException(bool $enableReportException): void { static::$enableReportException = $enableReportException; } + /** + * Report an exception through the exception handler. + */ protected static function printLog(Throwable $throwable): void { if (! ApplicationContext::hasContainer() || ! static::$enableReportException) { @@ -31,18 +195,6 @@ protected static function printLog(Throwable $throwable): void if ($container->has(ExceptionHandlerContract::class)) { $container->get(ExceptionHandlerContract::class) ->report($throwable); - - return; - } - - if ($container->has(StdoutLoggerInterface::class)) { - $logger = $container->get(StdoutLoggerInterface::class); - if ($container->has(FormatterInterface::class)) { - $formatter = $container->get(FormatterInterface::class); - $logger->warning($formatter->format($throwable)); - } else { - $logger->warning((string) $throwable); - } } } } diff --git a/src/coroutine/src/Exception/ChannelClosedException.php b/src/coroutine/src/Exception/ChannelClosedException.php new file mode 100644 index 000000000..f7184e7c3 --- /dev/null +++ b/src/coroutine/src/Exception/ChannelClosedException.php @@ -0,0 +1,11 @@ +throwable; + } +} diff --git a/src/coroutine/src/Exception/InvalidArgumentException.php b/src/coroutine/src/Exception/InvalidArgumentException.php new file mode 100644 index 000000000..95bb79568 --- /dev/null +++ b/src/coroutine/src/Exception/InvalidArgumentException.php @@ -0,0 +1,9 @@ + + */ + protected array $throwables = []; + + /** + * Get the successful results from parallel execution. + */ + public function getResults(): array + { + return $this->results; + } + + /** + * Set the successful results from parallel execution. + */ + public function setResults(array $results): void + { + $this->results = $results; + } + + /** + * Get the throwables from failed parallel tasks. + * + * @return array + */ + public function getThrowables(): array + { + return $this->throwables; + } + + /** + * Set the throwables from failed parallel tasks. + * + * @param array $throwables + * @return array + */ + public function setThrowables(array $throwables): array + { + return $this->throwables = $throwables; + } +} diff --git a/src/coroutine/src/Exception/TimeoutException.php b/src/coroutine/src/Exception/TimeoutException.php new file mode 100644 index 000000000..897f8dcdc --- /dev/null +++ b/src/coroutine/src/Exception/TimeoutException.php @@ -0,0 +1,11 @@ + + */ + protected static array $channels = []; + + /** + * Acquire a lock for the given key. + * + * Returns true if this is the first lock acquisition (owner), + * or false if waiting on an existing lock. + */ + public static function lock(string $key): bool + { + if (! isset(static::$channels[$key])) { + static::$channels[$key] = new Channel(1); + return true; + } + + $channel = static::$channels[$key]; + $channel->pop(-1); + return false; + } + + /** + * Release the lock for the given key. + */ + public static function unlock(string $key): void + { + if (isset(static::$channels[$key])) { + $channel = static::$channels[$key]; + static::$channels[$key] = null; + $channel->close(); + } + } } diff --git a/src/coroutine/src/Mutex.php b/src/coroutine/src/Mutex.php index d8b44daab..b832bab01 100644 --- a/src/coroutine/src/Mutex.php +++ b/src/coroutine/src/Mutex.php @@ -4,8 +4,65 @@ namespace Hypervel\Coroutine; -use Hyperf\Coroutine\Mutex as BaseMutex; +use Hypervel\Engine\Channel; -class Mutex extends BaseMutex +class Mutex { + /** + * @var array + */ + protected static array $channels = []; + + /** + * Acquire a mutex lock for the given key. + * + * @param float $timeout Timeout in seconds (-1 for unlimited) + * @return bool True if lock acquired, false if timeout or channel closing + */ + public static function lock(string $key, float $timeout = -1): bool + { + if (! isset(static::$channels[$key])) { + static::$channels[$key] = new Channel(1); + } + + $channel = static::$channels[$key]; + $channel->push(1, $timeout); + if ($channel->isTimeout() || $channel->isClosing()) { + return false; + } + + return true; + } + + /** + * Release a mutex lock for the given key. + * + * @param float $timeout Timeout in seconds + * @return bool True if unlocked successfully, false if timeout (unlock called more than once) + */ + public static function unlock(string $key, float $timeout = 5): bool + { + if (isset(static::$channels[$key])) { + $channel = static::$channels[$key]; + $channel->pop($timeout); + if ($channel->isTimeout()) { + // unlock more than once + return false; + } + } + + return true; + } + + /** + * Clear and close the mutex channel for the given key. + */ + public static function clear(string $key): void + { + if (isset(static::$channels[$key])) { + $channel = static::$channels[$key]; + static::$channels[$key] = null; + $channel->close(); + } + } } diff --git a/src/coroutine/src/Parallel.php b/src/coroutine/src/Parallel.php index bc3078547..0d3e17be8 100644 --- a/src/coroutine/src/Parallel.php +++ b/src/coroutine/src/Parallel.php @@ -4,8 +4,120 @@ namespace Hypervel\Coroutine; -use Hyperf\Coroutine\Parallel as BaseParallel; +use Hypervel\Coroutine\Exception\ParallelExecutionException; +use Hypervel\Engine\Channel; +use Throwable; -class Parallel extends BaseParallel +use function sprintf; + +class Parallel { + /** + * @var array + */ + protected array $callbacks = []; + + protected ?Channel $concurrentChannel = null; + + protected array $results = []; + + /** + * @var array + */ + protected array $throwables = []; + + /** + * Create a new parallel executor. + * + * @param int $concurrent Maximum concurrent coroutines (0 = unlimited) + */ + public function __construct(int $concurrent = 0) + { + if ($concurrent > 0) { + $this->concurrentChannel = new Channel($concurrent); + } + } + + /** + * Add a callback to be executed in parallel. + */ + public function add(callable $callable, int|string|null $key = null): void + { + if (is_null($key)) { + $this->callbacks[] = $callable; + } else { + $this->callbacks[$key] = $callable; + } + } + + /** + * Execute all callbacks in parallel and wait for completion. + * + * @param bool $throw Whether to throw on errors + * @return array The results keyed by callback key + * @throws ParallelExecutionException When $throw is true and errors occurred + */ + public function wait(bool $throw = true): array + { + $wg = new WaitGroup(); + $wg->add(count($this->callbacks)); + foreach ($this->callbacks as $key => $callback) { + $this->concurrentChannel && $this->concurrentChannel->push(true); + $this->results[$key] = null; + Coroutine::create(function () use ($callback, $key, $wg) { + try { + $this->results[$key] = $callback(); + } catch (Throwable $throwable) { + $this->throwables[$key] = $throwable; + unset($this->results[$key]); + } finally { + $this->concurrentChannel && $this->concurrentChannel->pop(); + $wg->done(); + } + }); + } + $wg->wait(); + if ($throw && ($throwableCount = count($this->throwables)) > 0) { + $message = 'Detecting ' . $throwableCount . ' throwable occurred during parallel execution:' . PHP_EOL . $this->formatThrowables($this->throwables); + $executionException = new ParallelExecutionException($message); + $executionException->setResults($this->results); + $executionException->setThrowables($this->throwables); + $this->results = []; + $this->throwables = []; + throw $executionException; + } + return $this->results; + } + + /** + * Get the number of registered callbacks. + */ + public function count(): int + { + return count($this->callbacks); + } + + /** + * Clear all callbacks, results, and throwables. + */ + public function clear(): void + { + $this->callbacks = []; + $this->results = []; + $this->throwables = []; + } + + /** + * Format throwables into a nice list. + * + * @param array $throwables + */ + private function formatThrowables(array $throwables): string + { + $output = ''; + foreach ($throwables as $key => $value) { + $output .= sprintf('(%s) %s: %s' . PHP_EOL . '%s' . PHP_EOL, $key, get_class($value), $value->getMessage(), $value->getTraceAsString()); + } + return $output; + } } diff --git a/src/coroutine/src/WaitConcurrent.php b/src/coroutine/src/WaitConcurrent.php index e811b8f2f..5625efaca 100644 --- a/src/coroutine/src/WaitConcurrent.php +++ b/src/coroutine/src/WaitConcurrent.php @@ -4,8 +4,47 @@ namespace Hypervel\Coroutine; -use Hyperf\Coroutine\WaitConcurrent as BaseWaitConcurrent; - -class WaitConcurrent extends BaseWaitConcurrent +/** + * @method bool isFull() + * @method bool isEmpty() + */ +class WaitConcurrent extends Concurrent { + protected WaitGroup $wg; + + public function __construct( + protected int $limit, + ) { + parent::__construct($limit); + $this->wg = new WaitGroup(); + } + + /** + * Create a new coroutine with concurrency limiting and wait tracking. + */ + public function create(callable $callable): void + { + $this->wg->add(); + + $callable = function () use ($callable) { + try { + $callable(); + } finally { + $this->wg->done(); + } + }; + + parent::create($callable); + } + + /** + * Wait for all coroutines to complete. + * + * @param float $timeout Timeout in seconds (-1 for unlimited) + * @return bool True if all completed, false if timed out + */ + public function wait(float $timeout = -1): bool + { + return $this->wg->wait($timeout); + } } diff --git a/src/coroutine/src/WaitGroup.php b/src/coroutine/src/WaitGroup.php index fdcac0f4f..94fca4871 100644 --- a/src/coroutine/src/WaitGroup.php +++ b/src/coroutine/src/WaitGroup.php @@ -4,8 +4,92 @@ namespace Hypervel\Coroutine; -use Hyperf\Coroutine\WaitGroup as BaseWaitGroup; +use BadMethodCallException; +use Hypervel\Engine\Channel; +use InvalidArgumentException; -class WaitGroup extends BaseWaitGroup +/** + * Go-style WaitGroup for waiting on multiple coroutines. + * + * Based on swoole/library implementation. + */ +class WaitGroup { + protected Channel $chan; + + protected int $count = 0; + + protected bool $waiting = false; + + public function __construct(int $delta = 0) + { + $this->chan = new Channel(1); + if ($delta > 0) { + $this->add($delta); + } + } + + /** + * Add to the counter (call before starting coroutines). + * + * @throws BadMethodCallException When called concurrently with wait + * @throws InvalidArgumentException When delta would make counter negative + */ + public function add(int $delta = 1): void + { + if ($this->waiting) { + throw new BadMethodCallException('WaitGroup misuse: add called concurrently with wait'); + } + $count = $this->count + $delta; + if ($count < 0) { + throw new InvalidArgumentException('WaitGroup misuse: negative counter'); + } + $this->count = $count; + } + + /** + * Decrement the counter (call when a coroutine completes). + * + * @throws BadMethodCallException When counter would go negative + */ + public function done(): void + { + $count = $this->count - 1; + if ($count < 0) { + throw new BadMethodCallException('WaitGroup misuse: negative counter'); + } + $this->count = $count; + if ($count === 0 && $this->waiting) { + $this->chan->push(true); + } + } + + /** + * Block until the counter reaches zero. + * + * @param float $timeout Timeout in seconds (-1 for unlimited) + * @return bool True if completed, false if timed out + * @throws BadMethodCallException When wait is called before previous wait returned + */ + public function wait(float $timeout = -1): bool + { + if ($this->waiting) { + throw new BadMethodCallException('WaitGroup misuse: reused before previous wait has returned'); + } + if ($this->count > 0) { + $this->waiting = true; + $done = $this->chan->pop($timeout); + $this->waiting = false; + return $done; + } + return true; + } + + /** + * Get the current counter value. + */ + public function count(): int + { + return $this->count; + } } diff --git a/src/coroutine/src/Waiter.php b/src/coroutine/src/Waiter.php index 2d0cf2ce7..bc38dc928 100644 --- a/src/coroutine/src/Waiter.php +++ b/src/coroutine/src/Waiter.php @@ -4,8 +4,57 @@ namespace Hypervel\Coroutine; -use Hyperf\Coroutine\Waiter as BaseWaiter; +use Closure; +use Hypervel\Coroutine\Exception\ExceptionThrower; +use Hypervel\Coroutine\Exception\WaitTimeoutException; +use Hypervel\Engine\Channel; +use Throwable; -class Waiter extends BaseWaiter +class Waiter { + protected float $pushTimeout = 10.0; + + protected float $popTimeout = 10.0; + + public function __construct(float $timeout = 10.0) + { + $this->popTimeout = $timeout; + } + + /** + * Execute a closure in a coroutine and wait for the result. + * + * @template TReturn + * @param Closure():TReturn $closure + * @param null|float $timeout Timeout in seconds (null uses default) + * @return TReturn + * @throws WaitTimeoutException When the wait times out + */ + public function wait(Closure $closure, ?float $timeout = null): mixed + { + if ($timeout === null) { + $timeout = $this->popTimeout; + } + + $channel = new Channel(1); + Coroutine::create(function () use ($channel, $closure) { + try { + $result = $closure(); + } catch (Throwable $exception) { + $result = new ExceptionThrower($exception); + } finally { + $channel->push($result ?? null, $this->pushTimeout); + } + }); + + $result = $channel->pop($timeout); + if ($result === false && $channel->isTimeout()) { + throw new WaitTimeoutException(sprintf('Channel wait failed, reason: Timed out for %s s', $timeout)); + } + if ($result instanceof ExceptionThrower) { + throw $result->getThrowable(); + } + + return $result; + } } 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..0fe4118cd --- /dev/null +++ b/src/database/composer.json @@ -0,0 +1,51 @@ +{ + "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" + }, + { + "name": "Raj Siva-Rajah", + "homepage": "https://github.com/binaryfire" + } + ], + "support": { + "issues": "https://github.com/hypervel/components/issues", + "source": "https://github.com/hypervel/components" + }, + "autoload": { + "psr-4": { + "Hypervel\\Database\\": "src/" + } + }, + "require": { + "php": "^8.4", + "hypervel/pool": "^0.4" + }, + "require-dev": { + "fakerphp/faker": "^2.0" + }, + "config": { + "sort-packages": true + }, + "extra": { + "hyperf": { + "config": "Hypervel\\Database\\ConfigProvider" + }, + "branch-alias": { + "dev-main": "0.4-dev" + } + } +} diff --git a/src/database/src/Capsule/Manager.php b/src/database/src/Capsule/Manager.php new file mode 100644 index 000000000..dc8069a4d --- /dev/null +++ b/src/database/src/Capsule/Manager.php @@ -0,0 +1,194 @@ +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 Builder|Closure|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..83824408b --- /dev/null +++ b/src/database/src/ConfigProvider.php @@ -0,0 +1,51 @@ + [ + ConnectionResolverInterface::class => ConnectionResolver::class, + MigrationRepositoryInterface::class => DatabaseMigrationRepositoryFactory::class, + ], + 'listeners' => [ + RegisterConnectionResolverListener::class, + UnsetContextInTaskWorkerListener::class, + ], + 'commands' => [ + FreshCommand::class, + InstallCommand::class, + MakeMigrationCommand::class, + MigrateCommand::class, + RefreshCommand::class, + ResetCommand::class, + RollbackCommand::class, + SeedCommand::class, + ShowModelCommand::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 $fetchUsing = []): array + { + return $this->run($query, $bindings, function ($query, $bindings) use ($useReadPdo, $fetchUsing) { + 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(...$fetchUsing); + }); + } + + /** + * 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, array $fetchUsing = []): 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(...$fetchUsing)) { + 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 ? (int) $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->getName(), + $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[] = [ + 'threshold' => $threshold, + 'handler' => $handler, + 'has_run' => false, + ]; + + // Register a single persistent listener that iterates over all handlers. + // This prevents listener accumulation when connections are reset for pooling. + // Only set the flag if a dispatcher is present; otherwise the listener + // won't actually be registered and we should try again next time. + if (! $this->queryDurationListenerRegistered && $this->events) { + $this->listen(function ($event) { + foreach ($this->queryDurationHandlers as $key => $config) { + if (! $config['has_run'] && $this->totalQueryDuration() > $config['threshold']) { + $config['handler']($this, $event); + $this->queryDurationHandlers[$key]['has_run'] = true; + } + } + }); + $this->queryDurationListenerRegistered = 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. + * + * Any open transactions are rolled back before disconnecting to ensure + * the connection is returned to the pool in a clean state. + */ + public function disconnect(): void + { + // Roll back any open transactions before releasing the PDO. + // This prevents dirty state from leaking to the next pool user. + if ($this->transactions > 0) { + $this->transactions = 0; + + if ($this->pdo?->inTransaction()) { + $this->pdo->rollBack(); + } + } + + $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(mixed $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 (is_array($value)) { + throw new RuntimeException('The database connection does not support escaping arrays.'); + } + 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 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->getName(), + '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; + } + + /** + * Retrieve the latest read / write type used. + * + * @return null|'read'|'write' + */ + protected function latestReadWriteTypeUsed(): ?string + { + return $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..65591362f --- /dev/null +++ b/src/database/src/ConnectionInterface.php @@ -0,0 +1,178 @@ +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); + } + + /** + * Create an in-memory SQLite connection using a shared PDO. + * + * Used by connection pooling for in-memory SQLite where all pool slots + * must share the same PDO instance to see the same data. Without this, + * each pooled connection would get its own empty in-memory database. + * + * Returns Connection (not SQLiteConnection) to respect custom resolvers + * that may return a different Connection subclass. + */ + public function makeSqliteFromSharedPdo(PDO $pdo, array $config, ?string $name = null): Connection + { + $config = $this->parseConfig($config, $name); + + // Use write config if read/write is configured, matching normal factory behavior + $connectionConfig = isset($config['read']) + ? $this->getWriteConfig($config) + : $config; + + // Go through createConnection() to respect custom resolvers + return $this->createConnection( + 'sqlite', + $pdo, + $connectionConfig['database'], + $connectionConfig['prefix'], + $connectionConfig + ); + } + + /** + * 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..1e151b87d --- /dev/null +++ b/src/database/src/Console/Migrations/FreshCommand.php @@ -0,0 +1,105 @@ +isProhibited() || ! $this->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..9439608ed --- /dev/null +++ b/src/database/src/Console/Migrations/RefreshCommand.php @@ -0,0 +1,128 @@ +isProhibited() || ! $this->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..1b41d6540 --- /dev/null +++ b/src/database/src/Console/Migrations/ResetCommand.php @@ -0,0 +1,58 @@ +isProhibited() || ! $this->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..a9d61da7d --- /dev/null +++ b/src/database/src/Console/Migrations/RollbackCommand.php @@ -0,0 +1,55 @@ +isProhibited() || ! $this->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 @@ +input->getOption('database'); return $database - ?: $this->app->get(ConfigInterface::class) + ?: $this->app->get('config') ->get('database.default'); } diff --git a/src/database/src/Console/Seeds/WithoutModelEvents.php b/src/database/src/Console/Seeds/WithoutModelEvents.php new file mode 100644 index 000000000..91ff5cb4b --- /dev/null +++ b/src/database/src/Console/Seeds/WithoutModelEvents.php @@ -0,0 +1,18 @@ + Model::withoutEvents($callback); + } +} diff --git a/src/database/src/Console/ShowModelCommand.php b/src/database/src/Console/ShowModelCommand.php new file mode 100644 index 000000000..c6610e3dd --- /dev/null +++ b/src/database/src/Console/ShowModelCommand.php @@ -0,0 +1,158 @@ +modelInspector->inspect( + $this->argument('model'), + $this->option('database') + ); + } catch (ContainerExceptionInterface $e) { + $this->components->error($e->getMessage()); + + return self::FAILURE; + } + + $this->display($info); + + return self::SUCCESS; + } + + /** + * Render the model information. + */ + protected function display(array $modelData): void + { + $this->option('json') + ? $this->displayJson($modelData) + : $this->displayCli($modelData); + } + + /** + * Render the model information as JSON. + */ + protected function displayJson(array $modelData): void + { + $this->output->writeln( + (new Collection($modelData))->toJson() + ); + } + + /** + * Render the model information for the CLI. + */ + protected function displayCli(array $modelData): void + { + $this->newLine(); + + $this->components->twoColumnDetail('' . $modelData['class'] . ''); + $this->components->twoColumnDetail('Database', $modelData['database']); + $this->components->twoColumnDetail('Table', $modelData['table']); + + if ($policy = $modelData['policy'] ?? false) { + $this->components->twoColumnDetail('Policy', $policy); + } + + $this->newLine(); + + $this->components->twoColumnDetail( + 'Attributes', + 'type / cast', + ); + + foreach ($modelData['attributes'] as $attribute) { + $first = trim(sprintf( + '%s %s', + $attribute['name'], + (new Collection(['increments', 'unique', 'nullable', 'fillable', 'hidden', 'appended'])) + ->filter(fn ($property) => $attribute[$property]) + ->map(fn ($property) => sprintf('%s', $property)) + ->implode(', ') + )); + + $second = (new Collection([ + $attribute['type'], + $attribute['cast'] ? '' . $attribute['cast'] . '' : null, + ]))->filter()->implode(' / '); + + $this->components->twoColumnDetail($first, $second); + + if ($attribute['default'] !== null) { + $this->components->bulletList( + [sprintf('default: %s', $attribute['default'])], + OutputInterface::VERBOSITY_VERBOSE + ); + } + } + + $this->newLine(); + + $this->components->twoColumnDetail('Relations'); + + foreach ($modelData['relations'] as $relation) { + $this->components->twoColumnDetail( + sprintf('%s %s', $relation['name'], $relation['type']), + $relation['related'] + ); + } + + $this->newLine(); + + $this->components->twoColumnDetail('Events'); + + if (count($modelData['events']) > 0) { + foreach ($modelData['events'] as $event) { + $this->components->twoColumnDetail( + sprintf('%s', $event['event']), + sprintf('%s', $event['class']), + ); + } + } + + $this->newLine(); + + $this->components->twoColumnDetail('Observers'); + + if (count($modelData['observers']) > 0) { + foreach ($modelData['observers'] as $observer) { + $this->components->twoColumnDetail( + sprintf('%s', $observer['event']), + implode(', ', $observer['observer']) + ); + } + } + + $this->newLine(); + } +} 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..8672ec580 --- /dev/null +++ b/src/database/src/DatabaseManager.php @@ -0,0 +1,459 @@ + + */ + 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->getName())->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 + { + if (! isset($this->connections[$name])) { + $this->connections[$name] = $this->configure( + $this->makeConnection($name) + ); + + $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/database.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/database.php instead.' + ); + } + + /** + * 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): Connection + { + // Set the event dispatcher if available. + if ($this->app->bound('events')) { + $connection->setEventDispatcher($this->app['events']); + } + + if ($this->app->bound('db.transactions')) { + $connection->setTransactionManager($this->app->get('db.transactions')); + } + + // Set a reconnector callback 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) + ); + } + + /** + * 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); + + // Clear cached connection for SimpleConnectionResolver (non-pooled mode) + unset($this->connections[$name]); + + // Clear resolver-level caching (e.g., DatabaseConnectionResolver's static cache) + $resolver = $this->app->get(ConnectionResolverInterface::class); + if ($resolver instanceof FlushableConnectionResolver) { + $resolver->flush($name); + } + + // 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 + { + $fresh = $this->configure( + $this->makeConnection($name) + ); + + 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..f97dad789 --- /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. + */ + protected QueryBuilder $query; + + /** + * The model being queried. + * + * @var TModel + */ + protected ?Model $model = null; + + /** + * The attributes that should be added to new models created by this builder. + */ + public array $pendingAttributes = []; + + /** + * The relationships that should be eager loaded. + */ + protected array $eagerLoad = []; + + /** + * All of the globally registered builder macros. + */ + protected static array $macros = []; + + /** + * All of the locally registered builder macros. + */ + protected array $localMacros = []; + + /** + * A replacement for the typical delete function. + */ + protected ?Closure $onDelete = null; + + /** + * The properties that should be returned from query builder. + * + * @var list + */ + protected array $propertyPassthru = [ + 'from', + ]; + + /** + * The methods that should be returned from query builder. + * + * @var list + */ + protected array $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. + */ + protected array $scopes = []; + + /** + * Removed global scopes. + */ + protected array $removedScopes = []; + + /** + * The callbacks that should be invoked after retrieving data from the database. + * + * @var list + */ + protected array $afterQueryCallbacks = []; + + /** + * The callbacks that should be invoked on clone. + * + * @var list + */ + protected array $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 = []): Model + { + return $this->newModelInstance($attributes); + } + + /** + * Register a new global scope. + */ + public function withGlobalScope(string $identifier, Closure|Scope $scope): static + { + $this->scopes[$identifier] = $scope; + + if (method_exists($scope, 'extend')) { + $scope->extend($this); + } + + return $this; + } + + /** + * Remove a registered global scope. + */ + public function withoutGlobalScope(Scope|string $scope): static + { + if (! is_string($scope)) { + $scope = get_class($scope); + } + + unset($this->scopes[$scope]); + + $this->removedScopes[] = $scope; + + return $this; + } + + /** + * Remove all or passed registered global scopes. + */ + public function withoutGlobalScopes(?array $scopes = null): static + { + 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. + */ + public function withoutGlobalScopesExcept(array $scopes = []): static + { + $this->withoutGlobalScopes( + array_diff(array_keys($this->scopes), $scopes) + ); + + return $this; + } + + /** + * Get an array of global scopes that were removed from the query. + */ + public function removedScopes(): array + { + return $this->removedScopes; + } + + /** + * Add a where clause on the primary key to the query. + */ + public function whereKey(mixed $id): static + { + 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. + */ + public function whereKeyNot(mixed $id): static + { + 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. + */ + public function except(mixed $models): static + { + 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)|Expression|string $column + */ + public function where(array|Closure|Expression|string $column, mixed $operator = null, mixed $value = null, string $boolean = 'and'): static + { + 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)|Expression|string $column + * @return null|TModel + */ + public function firstWhere(array|Closure|Expression|string $column, mixed $operator = null, mixed $value = null, string $boolean = 'and'): ?Model + { + return $this->where(...func_get_args())->first(); + } + + /** + * Add an "or where" clause to the query. + * + * @param array|(Closure(static): mixed)|Expression|string $column + */ + public function orWhere(array|Closure|Expression|string $column, mixed $operator = null, mixed $value = null): static + { + [$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)|Expression|string $column + */ + public function whereNot(array|Closure|Expression|string $column, mixed $operator = null, mixed $value = null, string $boolean = 'and'): static + { + return $this->where($column, $operator, $value, $boolean . ' not'); + } + + /** + * Add an "or where not" clause to the query. + * + * @param array|(Closure(static): mixed)|Expression|string $column + */ + public function orWhereNot(array|Closure|Expression|string $column, mixed $operator = null, mixed $value = null): static + { + return $this->whereNot($column, $operator, $value, 'or'); + } + + /** + * Add an "order by" clause for a timestamp to the query. + */ + public function latest(Expression|string|null $column = null): static + { + 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. + */ + public function oldest(Expression|string|null $column = null): static + { + 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 Collection + */ + public function hydrate(array $items): Collection + { + $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 + */ + public function fillAndInsert(array $values): bool + { + 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 + */ + public function fillAndInsertOrIgnore(array $values): int + { + 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 + */ + public function fillAndInsertGetId(array $values): int + { + 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): array + { + 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. + * + * @return Collection + */ + public function fromQuery(string $query, array $bindings = []): Collection + { + return $this->hydrate( + $this->query->getConnection()->select($query, $bindings) + ); + } + + /** + * Find a model by its primary key. + * + * @return ($id is (array|Arrayable) ? Collection : null|TModel) + */ + public function find(mixed $id, array|string $columns = ['*']): Model|Collection|null + { + 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. + * + * @return TModel + * + * @throws ModelNotFoundException + * @throws \Hypervel\Database\MultipleRecordsFoundException + */ + public function findSole(mixed $id, array|string $columns = ['*']): Model + { + return $this->whereKey($id)->sole($columns); + } + + /** + * Find multiple models by their primary keys. + * + * @return Collection + */ + public function findMany(Arrayable|array $ids, array|string $columns = ['*']): Collection + { + $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. + * + * @return ($id is (array|Arrayable) ? Collection : TModel) + * + * @throws ModelNotFoundException + */ + public function findOrFail(mixed $id, array|string $columns = ['*']): Model|Collection + { + $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. + * + * @return ($id is (array|Arrayable) ? Collection : TModel) + */ + public function findOrNew(mixed $id, array|string $columns = ['*']): Model|Collection + { + 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 (Closure(): TValue)|list|string $columns + * @param null|(Closure(): TValue) $callback + * @return ( + * $id is (Arrayable|array) + * ? Collection + * : TModel|TValue + * ) + */ + public function findOr(mixed $id, Closure|array|string $columns = ['*'], ?Closure $callback = null): mixed + { + 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 = []): Model + { + 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 = []): Model + { + 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 = []): Model + { + 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 = []): Model + { + 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. + * + * @return TModel + */ + public function incrementOrCreate(array $attributes, string $column = 'count', float|int $default = 1, float|int $step = 1, array $extra = []): Model + { + return tap($this->firstOrCreate($attributes, [$column => $default]), function ($instance) use ($column, $step, $extra) { + if (! $instance->wasRecentlyCreated) { + $instance->increment($column, $step, $extra); // @phpstan-ignore method.protected (handled by __call) + } + }); + } + + /** + * Execute the query and get the first result or throw an exception. + * + * @return TModel + * + * @throws ModelNotFoundException + */ + public function firstOrFail(array|string $columns = ['*']): Model + { + 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(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(); + } + + /** + * Execute the query and get the first result if it's the sole matching record. + * + * @return TModel + * + * @throws ModelNotFoundException + * @throws \Hypervel\Database\MultipleRecordsFoundException + */ + public function sole(array|string $columns = ['*']): Model + { + 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. + */ + public function value(Expression|string $column): mixed + { + if ($result = $this->first([$column])) { + $column = $column instanceof Expression ? $column->getValue($this->getGrammar()) : $column; + + return $result->{Str::afterLast($column, '.')}; + } + + return null; + } + + /** + * Get a single column's value from the first result of a query if it's the sole matching record. + * + * @throws ModelNotFoundException + * @throws \Hypervel\Database\MultipleRecordsFoundException + */ + public function soleValue(Expression|string $column): mixed + { + $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. + * + * @throws ModelNotFoundException + */ + public function valueOrFail(Expression|string $column): mixed + { + $column = $column instanceof Expression ? $column->getValue($this->getGrammar()) : $column; + + return $this->firstOrFail([$column])->{Str::afterLast($column, '.')}; + } + + /** + * Execute the query as a "select" statement. + * + * @phpstan-return Collection + */ + public function get(array|string $columns = ['*']): BaseCollection + { + $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. + * + * @return array + */ + public function getModels(array|string $columns = ['*']): array + { + 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): array + { + 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. + */ + protected function eagerLoadRelation(array $models, string $name, Closure $constraints): array + { + // 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. + * + * @return Relation + */ + public function getRelation(string $name): Relation + { + // 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. + */ + protected function relationsNestedUnder(string $relation): array + { + $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. + */ + protected function isNestedUnder(string $relation, string $name): bool + { + return str_contains($name, '.') && str_starts_with($name, $relation . '.'); + } + + /** + * 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(BaseCollection $result): BaseCollection + { + 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(): LazyCollection + { + 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(): void + { + 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. + * + * @return BaseCollection + */ + public function pluck(Expression|string $column, ?string $key = null): BaseCollection + { + $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. + * + * @throws InvalidArgumentException + */ + public function paginate(Closure|int|null $perPage = null, array|string $columns = ['*'], string $pageName = 'page', ?int $page = null, Closure|int|null $total = null): LengthAwarePaginator + { + $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. + */ + public function simplePaginate(?int $perPage = null, array|string $columns = ['*'], string $pageName = 'page', ?int $page = null): Paginator + { + $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. + */ + public function cursorPaginate(?int $perPage = null, array|string $columns = ['*'], string $cursorName = 'cursor', Cursor|string|null $cursor = null): CursorPaginatorContract + { + $perPage = $perPage ?: $this->model->getPerPage(); + + return $this->paginateUsingCursor($perPage, $columns, $cursorName, $cursor); + } + + /** + * Ensure the proper order by required for cursor pagination. + */ + protected function ensureOrderForCursorPagination(bool $shouldReverse = false): BaseCollection + { + 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 = []): Model + { + 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 = []): Model + { + 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): Model + { + 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 = []): Model + { + return Model::withoutEvents(fn () => $this->forceCreate($attributes)); + } + + /** + * Update records in the database. + */ + public function update(array $values): int + { + return $this->toBase()->update($this->addUpdatedAtColumn($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 (! 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. + */ + public function touch(?string $column = null): false|int + { + $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. + */ + public function increment(Expression|string $column, mixed $amount = 1, array $extra = []): int + { + return $this->toBase()->increment( + $column, + $amount, + $this->addUpdatedAtColumn($extra) + ); + } + + /** + * Decrement a column's value by a given amount. + */ + public function decrement(Expression|string $column, mixed $amount = 1, array $extra = []): int + { + return $this->toBase()->decrement( + $column, + $amount, + $this->addUpdatedAtColumn($extra) + ); + } + + /** + * Add the "updated at" column to an array of values. + */ + protected function addUpdatedAtColumn(array $values): array + { + 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. + */ + protected function addUniqueIdsToUpsertValues(array $values): array + { + 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. + */ + protected function addTimestampsToUpsertValues(array $values): array + { + 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. + */ + protected function addUpdatedAtToUpsertColumns(array $update): array + { + 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. + */ + public function delete(): mixed + { + 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. + */ + public function forceDelete(): mixed + { + return $this->query->delete(); + } + + /** + * Register a replacement for the default delete function. + */ + public function onDelete(Closure $callback): void + { + $this->onDelete = $callback; + } + + /** + * Determine if the given model has a scope. + */ + public function hasNamedScope(string $scope): bool + { + 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. + */ + public function scopes(array|string $scopes): mixed + { + $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. + */ + public function applyScopes(): static + { + 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. + */ + protected function callScope(callable $scope, array $parameters = []): mixed + { + 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. + */ + protected function callNamedScope(string $scope, array $parameters = []): mixed + { + 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. + */ + protected function addNewWheresWithinGroup(QueryBuilder $query, int $originalWhereCount): void + { + // 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. + */ + protected function groupWhereSliceForScope(QueryBuilder $query, array $whereSlice): void + { + $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. + */ + protected function createNestedWhere(array $whereSlice, string $boolean = 'and'): array + { + $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(Relation<*,*,*>): mixed)|string|null $callback + */ + public function with(array|string $relations, Closure|string|null $callback = null): static + { + 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. + */ + public function without(mixed $relations): static + { + $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 + */ + public function withOnly(array|string $relations): static + { + $this->eagerLoad = []; + + return $this->with($relations); + } + + /** + * Create a new instance of the model being queried. + * + * @return TModel + */ + public function newModelInstance(array $attributes = []): Model + { + $attributes = array_merge($this->pendingAttributes, $attributes); + + return $this->model->newInstance($attributes)->setConnection( + $this->query->getConnection()->getName() + ); + } + + /** + * Parse a list of relations into individuals. + */ + protected function parseWithRelations(array $relations): array + { + 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. + */ + protected function prepareNestedWithRelationships(array $relations, string $prefix = ''): array + { + $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. + */ + protected function combineConstraints(array $constraints): Closure + { + return function ($builder) use ($constraints) { + foreach ($constraints as $constraint) { + $builder = $constraint($builder) ?? $builder; + } + + return $builder; + }; + } + + /** + * Parse the attribute select constraints from the name. + */ + protected function parseNameAndAttributeSelectionConstraint(string $name): array + { + return str_contains($name, ':') + ? $this->createSelectWithConstraint($name) + : [$name, static function () { + }]; + } + + /** + * Create a constraint to select the given columns for the relation. + */ + protected function createSelectWithConstraint(string $name): array + { + 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. + */ + protected function addNestedWiths(string $name, array $results): array + { + $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. + */ + public function withAttributes(Expression|array|string $attributes, mixed $value = null, bool $asConditions = true): static + { + 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. + */ + public function withCasts(array $casts): static + { + $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. + */ + protected function getUnionBuilders(): BaseCollection + { + return isset($this->query->unions) + ? (new BaseCollection($this->query->unions))->pluck('query') + : new BaseCollection(); + } + + /** + * Get the underlying query builder instance. + */ + public function getQuery(): QueryBuilder + { + return $this->query; + } + + /** + * Set the underlying query builder instance. + */ + public function setQuery(QueryBuilder $query): static + { + $this->query = $query; + + return $this; + } + + /** + * Get a base query builder instance. + */ + public function toBase(): QueryBuilder + { + return $this->applyScopes()->getQuery(); + } + + /** + * Get the relationships being eagerly loaded. + */ + public function getEagerLoads(): array + { + return $this->eagerLoad; + } + + /** + * Set the relationships being eagerly loaded. + */ + public function setEagerLoads(array $eagerLoad): static + { + $this->eagerLoad = $eagerLoad; + + return $this; + } + + /** + * Indicate that the given relationships should not be eagerly loaded. + */ + public function withoutEagerLoad(array $relations): static + { + $relations = array_diff(array_keys($this->model->getRelations()), $relations); + + return $this->with($relations); + } + + /** + * Flush the relationships being eagerly loaded. + */ + public function withoutEagerLoads(): static + { + return $this->setEagerLoads([]); + } + + /** + * Get the "limit" value from the query or null if it's not set. + */ + public function getLimit(): ?int + { + return $this->query->getLimit(); + } + + /** + * Get the "offset" value from the query or null if it's not set. + */ + public function getOffset(): ?int + { + return $this->query->getOffset(); + } + + /** + * Get the default key name of the table. + */ + protected function defaultKeyName(): string + { + return $this->getModel()->getKeyName(); + } + + /** + * Get the model instance being queried. + * + * @return TModel + */ + public function getModel(): Model + { + 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): static + { + $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. + */ + public function qualifyColumn(Expression|string $column): string + { + $column = $column instanceof Expression ? $column->getValue($this->getGrammar()) : $column; + + return $this->model->qualifyColumn($column); + } + + /** + * Qualify the given columns with the model's table. + */ + public function qualifyColumns(Expression|array $columns): array + { + return $this->model->qualifyColumns($columns); + } + + /** + * Get the given macro by name. + */ + public function getMacro(string $name): ?Closure + { + return Arr::get($this->localMacros, $name); + } + + /** + * Checks if a macro is registered. + */ + public function hasMacro(string $name): bool + { + return isset($this->localMacros[$name]); + } + + /** + * Get the given global macro by name. + */ + public static function getGlobalMacro(string $name): ?Closure + { + return Arr::get(static::$macros, $name); + } + + /** + * Checks if a global macro is registered. + */ + public static function hasGlobalMacro(string $name): bool + { + return isset(static::$macros[$name]); + } + + /** + * Dynamically access builder proxies. + * + * @throws Exception + */ + public function __get(string $key): mixed + { + 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. + */ + public function __call(string $method, array $parameters): mixed + { + if ($method === 'macro') { + $this->localMacros[$parameters[0]] = $parameters[1]; + + return null; + } + + 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. + * + * @throws BadMethodCallException + */ + public static function __callStatic(string $method, array $parameters): mixed + { + if ($method === 'macro') { + static::$macros[$parameters[0]] = $parameters[1]; + + return null; + } + + if ($method === 'mixin') { + static::registerMixin($parameters[0], $parameters[1] ?? true); + + return null; + } + + 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. + */ + public function clone(): static + { + return clone $this; + } + + /** + * Register a closure to be invoked on a clone. + */ + public function onClone(Closure $callback): static + { + $this->onCloneCallbacks[] = $callback; + + return $this; + } + + /** + * Force a clone of the underlying query builder when cloning. + */ + public function __clone(): void + { + $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|int|array|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..be7a40112 --- /dev/null +++ b/src/database/src/Eloquent/Concerns/HasAttributes.php @@ -0,0 +1,2237 @@ + + */ + 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); + } + + /** + * Remove all appended properties from the model. + */ + public function withoutAppends(): static + { + return $this->setAppends([]); + } + + /** + * 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..5460e2054 --- /dev/null +++ b/src/database/src/Eloquent/Concerns/HasRelationships.php @@ -0,0 +1,1047 @@ + $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|int $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|int $class): string + { + return Arr::get(Relation::morphMap() ?: [], $class, (string) $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|HasOneOrMany $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..04e479570 --- /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..f80164221 --- /dev/null +++ b/src/database/src/Eloquent/Concerns/QueriesRelationships.php @@ -0,0 +1,1022 @@ +|string $relation + * @param null|(Closure(\Hypervel\Database\Eloquent\Builder): mixed) $callback + * + * @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 + */ + 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 + */ + 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 + */ + 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 + */ + 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 + */ + 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 + */ + 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 + */ + 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 + */ + 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 + */ + 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 + */ + 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 + */ + 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 + */ + 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 + */ + 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 + */ + 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 + */ + 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 + */ + 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 + */ + 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 + */ + 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 + */ + 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 + */ + 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 + */ + 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 + */ + 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 + */ + 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 + */ + 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 + */ + 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 + */ + 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 + */ + 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 + */ + 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 + */ + 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 + */ + 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 + * + * @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. + * + * @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 + * + * @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. + * + * @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. + */ + 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. + */ + 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. + */ + 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. + */ + 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. + */ + 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. + */ + 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. + */ + 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 + */ + 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 + */ + 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. + */ + 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.ignore_on_touch'; + + /** + * 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.events_disabled'; + + /** + * 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 + */ + protected function increment(string $column, mixed $amount = 1, array $extra = []): int + { + return $this->incrementOrDecrement($column, $amount, $extra, 'increment'); + } + + /** + * Decrement a column's value by a given amount. + * + * @param array $extra + */ + protected function decrement(string $column, mixed $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, mixed $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): self + { + 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..9e31b074a --- /dev/null +++ b/src/database/src/Eloquent/Relations/BelongsToMany.php @@ -0,0 +1,1496 @@ +> + * + * @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 = []; + + $isAssociative = Arr::isAssoc($results->all()); + + foreach ($results as $key => $result) { + $value = $this->getDictionaryKey($result->{$this->accessor}->{$this->foreignPivotKey}); + + if ($isAssociative) { + $dictionary[$value][$key] = $result; + } else { + $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 = ['*']): BaseCollection + { + // 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..8ad214eb9 --- /dev/null +++ b/src/database/src/Eloquent/Relations/Concerns/InteractsWithPivotTable.php @@ -0,0 +1,612 @@ + [], '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|null $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|null $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|null $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): Model + { + $attributes = array_merge(array_column($this->pivotValues, 'value', 'column'), $attributes); + + $pivot = $this->related->newPivot( + $this->parent, + $attributes, + $this->table, + $exists, + $this->using + ); + + /* @phpstan-ignore method.notFound (AsPivot trait provides setPivotKeys/setRelatedModel) */ + return $pivot + ->setPivotKeys($this->foreignPivotKey, $this->relatedPivotKey) + ->setRelatedModel($this->related); + } + + /** + * Create a new existing pivot model instance. + */ + public function newExistingPivot(array $attributes = []): Model + { + 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..495381c14 --- /dev/null +++ b/src/database/src/Eloquent/Relations/HasOneOrMany.php @@ -0,0 +1,569 @@ + + */ +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(); + + $dictionary = []; + + $isAssociative = Arr::isAssoc($results->all()); + + foreach ($results as $key => $item) { + $pairKey = $this->getDictionaryKey($item->{$foreign}); + + if ($isAssociative) { + $dictionary[$pairKey][$key] = $item; + } else { + $dictionary[$pairKey][] = $item; + } + } + + return $dictionary; + } + + /** + * 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..dde86bf42 --- /dev/null +++ b/src/database/src/Eloquent/Relations/HasOneOrManyThrough.php @@ -0,0 +1,775 @@ + + */ +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 = []; + + $isAssociative = Arr::isAssoc($results->all()); + + // 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 $key => $result) { + if ($isAssociative) { + $dictionary[$result->laravel_through_key][$key] = $result; // @phpstan-ignore property.notFound + } else { + $dictionary[$result->laravel_through_key][] = $result; // @phpstan-ignore property.notFound + } + } + + 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 = ['*']): BaseCollection + { + $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..4aa49218d --- /dev/null +++ b/src/database/src/Eloquent/Relations/MorphTo.php @@ -0,0 +1,429 @@ + + */ +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 + { + $isAssociative = Arr::isAssoc($models->all()); + + foreach ($models as $key => $model) { + if ($model->{$this->morphType}) { + $morphTypeKey = $this->getDictionaryKey($model->{$this->morphType}); + $foreignKeyKey = $this->getDictionaryKey($model->{$this->foreignKey}); + + if ($isAssociative) { + $this->dictionary[$morphTypeKey][$foreignKeyKey][$key] = $model; + } else { + $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..95a17aa1d --- /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): Model + { + $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..a27a33e5f --- /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\Support\Collection + */ + public function get(array $columns = ['*']): BaseCollection + { + 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|int|float + { + 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/UnsetContextInTaskWorkerListener.php b/src/database/src/Listeners/UnsetContextInTaskWorkerListener.php new file mode 100644 index 000000000..2e20fee1c --- /dev/null +++ b/src/database/src/Listeners/UnsetContextInTaskWorkerListener.php @@ -0,0 +1,50 @@ +server->taskworker) { + return; + } + + $connectionResolver = $this->container->get(ConnectionResolverInterface::class); + $connections = (array) $this->config->get('database.connections', []); + + foreach (array_keys($connections) 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..26cb20798 --- /dev/null +++ b/src/database/src/Migrations/DatabaseMigrationRepositoryFactory.php @@ -0,0 +1,27 @@ +get('config'); + + $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, + ) { + } + + /** + * 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. + * + * Fetches the dispatcher from the container each time to ensure Event::fake() + * and other runtime swaps are respected (the Migrator may be constructed + * before fakes are set up). + */ + public function fireMigrationEvent(MigrationEventContract $event): void + { + $container = ApplicationContext::getContainer(); + + if ($container->has(Dispatcher::class)) { + $container->get(Dispatcher::class)->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..014b3f2c1 --- /dev/null +++ b/src/database/src/Pool/DbPool.php @@ -0,0 +1,128 @@ +get('config'); + $key = sprintf('database.connections.%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); + + // For in-memory SQLite, pre-create a shared PDO so all pool slots + // see the same database. This must happen after parent::__construct. + if ($this->isInMemorySqlite()) { + $this->sharedInMemorySqlitePdo = $this->createSharedInMemorySqlitePdo(); + } + } + + /** + * Get the pool name. + */ + public function getName(): string + { + return $this->name; + } + + /** + * Get the shared PDO for in-memory SQLite, or null for other drivers/configurations. + */ + public function getSharedInMemorySqlitePdo(): ?PDO + { + return $this->sharedInMemorySqlitePdo; + } + + /** + * Create a new pooled connection. + */ + protected function createConnection(): ConnectionInterface + { + return new PooledConnection($this->container, $this, $this->config); + } + + /** + * Create the shared PDO for in-memory SQLite via the factory. + * + * Uses the normal factory pipeline to get all config parsing, driver + * extensions, and connection setup. We then extract the PDO and let + * the Connection object be garbage collected. + */ + protected function createSharedInMemorySqlitePdo(): PDO + { + $factory = $this->container->get(ConnectionFactory::class); + $connection = $factory->make($this->config, $this->name); + + return $connection->getPdo(); + } + + /** + * Check if this pool is for an in-memory SQLite database. + */ + protected function isInMemorySqlite(): bool + { + if (($this->config['driver'] ?? '') !== 'sqlite') { + return false; + } + + $database = $this->config['database'] ?? ''; + + return $database === ':memory:' + || str_contains($database, '?mode=memory') + || str_contains($database, '&mode=memory'); + } + + /** + * Flush all connections and clear the shared in-memory SQLite PDO. + */ + public function flushAll(): void + { + parent::flushAll(); + $this->sharedInMemorySqlitePdo = null; + } +} 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..cf062bf2b --- /dev/null +++ b/src/database/src/Pool/PooledConnection.php @@ -0,0 +1,262 @@ +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(); + + $sharedPdo = $this->pool->getSharedInMemorySqlitePdo(); + + if ($sharedPdo !== null) { + // In-memory SQLite: use shared PDO so all pool slots see same data + $this->connection = $this->factory->makeSqliteFromSharedPdo( + $sharedPdo, + $this->config, + $this->config['name'] ?? null + ); + } else { + // Normal path: factory creates fresh connection with new PDO + $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('db.transactions')) { + $this->connection->setTransactionManager($this->container->get('db.transactions')); + } + + // 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) { + // Only disconnect if NOT using shared in-memory SQLite PDO. + // Shared PDO is owned by the pool, not individual connections. + if ($this->pool->getSharedInMemorySqlitePdo() === null) { + $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 + { + $sharedPdo = $this->pool->getSharedInMemorySqlitePdo(); + + if ($sharedPdo !== null) { + // For shared in-memory SQLite, rebind to the same PDO. + // Creating a fresh PDO would give us a new empty database. + $connection->setPdo($sharedPdo); + $connection->setReadPdo($sharedPdo); + } else { + // Normal refresh path for other drivers + $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.'); + } + + // Dispatch connection established event (fetching from container to respect fakes) + if ($this->container->has(Dispatcher::class)) { + $this->container->get(Dispatcher::class)->dispatch( + new ConnectionEstablished($connection) + ); + } + } +} 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..86a855a67 --- /dev/null +++ b/src/database/src/Query/Builder.php @@ -0,0 +1,3982 @@ + */ + 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|null $from = null; + + /** + * 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; + + /** + * The PDO fetch mode arguments for the query. + */ + public array $fetchUsing = []; + + /** + * 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 an expression to the select clause. + */ + public function selectExpression(ExpressionContract $expression, string $as): static + { + return $this->selectRaw( + '(' . $expression->getValue($this->grammar) . ') as ' . $this->grammar->wrap($as) + ); + } + + /** + * 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(Expression|string $expression, mixed $bindings = []): static + { + $this->from = $expression instanceof Expression + ? $expression + : 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, mixed $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, mixed $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|self|EloquentBuilder|Relation|ExpressionContract|array|string $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. + * + * @param \Hypervel\Database\Query\Builder|\Hypervel\Database\Eloquent\Builder<*>|\Hypervel\Contracts\Database\Query\Expression|string $column + */ + public function whereBetweenColumns(self|EloquentBuilder|ExpressionContract|string $column, array $values, string $boolean = 'and', bool $not = false): static + { + $type = 'betweenColumns'; + + if ($this->isQueryable($column)) { + [$sub, $bindings] = $this->createSub($column); + + return $this->addBinding($bindings, 'where') + ->whereBetweenColumns(new Expression('(' . $sub . ')'), $values, $boolean, $not); + } + + $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(array|ExpressionContract|string $column): static + { + return $this->whereNotNull($column, 'or'); + } + + /** + * Add a "where date" statement to the query. + */ + public function whereDate(ExpressionContract|string $column, mixed $operator, mixed $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, mixed $operator, mixed $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, mixed $operator, mixed $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, mixed $operator, mixed $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, mixed $operator, mixed $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, mixed $operator, mixed $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, mixed $operator, mixed $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, mixed $operator, mixed $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, mixed $operator, mixed $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, mixed $operator, mixed $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 + { + $query = $this->newQuery(); + + if (! is_null($this->from)) { + $query->from($this->from); + } + + return $query; + } + + /** + * 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, mixed $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, (int) $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 (is_null($value) || $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(Collection $result): Collection + { + 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 = ['*']): object|array|null + { + 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, + $this->fetchUsing + ); + } + + /** + * 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, + $this->fetchUsing + ); + }))->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, + $this->fetchUsing + ); + + // 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|string + { + $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, mixed $amount = 1, array $extra = []): int + { + if (! is_numeric($amount)) { + throw new InvalidArgumentException('Non-numeric value passed to increment method.'); + } + + 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) { + // @phpstan-ignore function.alreadyNarrowedType (runtime validation for user input) + if (! is_numeric($amount)) { + throw new InvalidArgumentException("Non-numeric value passed as increment amount for column: '{$column}'."); + } + // @phpstan-ignore function.alreadyNarrowedType (runtime validation - user could pass indexed array) + if (! is_string($column)) { + throw new InvalidArgumentException('Non-associative array passed to incrementEach method.'); + } + + $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, mixed $amount = 1, array $extra = []): int + { + if (! is_numeric($amount)) { + throw new InvalidArgumentException('Non-numeric value passed to decrement method.'); + } + + 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; + } + + /** + * Set the PDO fetch mode arguments for the query. + */ + public function fetchUsing(mixed ...$fetchUsing): static + { + $this->fetchUsing = $fetchUsing; + + 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} = is_array($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..e3cf2afec --- /dev/null +++ b/src/database/src/Query/Grammars/Grammar.php @@ -0,0 +1,1253 @@ +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); + } + + $joinWord = ($join->type === 'straight_join' && $this->supportsStraightJoins()) ? '' : ' join'; + + return trim("{$join->type}{$joinWord} {$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.'); + } + + /** + * Determine if the grammar supports straight joins. + * + * @throws RuntimeException + */ + protected function supportsStraightJoins(): bool + { + throw new RuntimeException('This database engine does not support straight 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..2abe63b70 --- /dev/null +++ b/src/database/src/Query/Grammars/MySqlGrammar.php @@ -0,0 +1,473 @@ +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. + * + * @throws InvalidArgumentException + */ + protected function compileIndexHint(Builder $query, IndexHint $indexHint): string + { + $index = $indexHint->index; + + $indexes = array_map('trim', explode(',', $index)); + + foreach ($indexes as $i) { + if (! preg_match('/^[a-zA-Z0-9_$]+$/', $i)) { + throw new InvalidArgumentException('Index name contains invalid characters.'); + } + } + + return match ($indexHint->type) { + 'hint' => "use index ({$index})", + 'force' => "force index ({$index})", + default => "ignore index ({$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. + * + * @throws InvalidArgumentException + */ + public function compileRandom(string|int $seed): string + { + if ($seed === '') { + return 'RAND()'; + } + + if (! is_numeric($seed)) { + throw new InvalidArgumentException('The seed value must be numeric.'); + } + + return 'RAND(' . (int) $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"); + } + + /** + * Determine if the grammar supports straight joins. + */ + protected function supportsStraightJoins(): bool + { + return 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 delete query that uses joins. + * + * Adds ORDER BY and LIMIT if present, for platforms that allow them (e.g., PlanetScale). + * Standard MySQL does not support ORDER BY or LIMIT with joined deletes and will throw a syntax error. + */ + protected function compileDeleteWithJoins(Builder $query, string $table, string $where): string + { + $sql = parent::compileDeleteWithJoins($query, $table, $where); + + 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..82d69272b --- /dev/null +++ b/src/database/src/Query/Grammars/SQLiteGrammar.php @@ -0,0 +1,387 @@ +', '<=', '>=', '<>', '!=', + '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. + * + * @throws InvalidArgumentException + */ + protected function compileIndexHint(Builder $query, IndexHint $indexHint): string + { + if ($indexHint->type !== 'force') { + return ''; + } + + $index = $indexHint->index; + + if (! preg_match('/^[a-zA-Z0-9_$]+$/', $index)) { + throw new InvalidArgumentException('Index name contains invalid characters.'); + } + + return "indexed by {$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..cc2468021 --- /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|null $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..0fce03f28 --- /dev/null +++ b/src/database/src/Schema/BlueprintState.php @@ -0,0 +1,226 @@ +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(): Fluent|IndexDefinition|null + { + 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': + $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()->toString()); + } + + /** + * 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..fc624bd7a --- /dev/null +++ b/src/database/src/Schema/Grammars/MySqlGrammar.php @@ -0,0 +1,1163 @@ +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 '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'; + } + + /** + * 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..ba917be4c --- /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 @@ +input->getOption('event'); - $listener = $this->input->getOption('listener'); + $eventFilter = $this->input->getOption('event'); + $listenerFilter = $this->input->getOption('listener'); /** @var EventDispatcherContract $dispatcher */ $dispatcher = $this->container->get(EventDispatcherInterface::class); - $this->show($this->handleData($dispatcher, $event, $listener), $this->output); + + $this->show($this->handleData($dispatcher, $eventFilter, $listenerFilter), $this->output); } - protected function configure() + protected function configure(): void { $this->setDescription("List the application's events and listeners.") ->addOption('event', 'e', InputOption::VALUE_OPTIONAL, 'Filter the events by event name.') ->addOption('listener', 'l', InputOption::VALUE_OPTIONAL, 'Filter the events by listener name.'); } - protected function handleData(EventDispatcherInterface $dispatcher, ?string $filterEvent, ?string $filterListener): array + /** + * Process raw listeners into display format. + */ + protected function handleData(EventDispatcherInterface $dispatcher, ?string $eventFilter, ?string $listenerFilter): array { $data = []; + if (! $dispatcher instanceof EventDispatcherContract) { return $data; } - foreach ($dispatcher->getRawListeners() as $event => $listeners) { - if (! is_array($listeners)) { + foreach ($dispatcher->getRawListeners() as $event => $rawListeners) { + if (! is_array($rawListeners)) { continue; } - if ($filterEvent && ! str_contains($event, $filterEvent)) { + if ($eventFilter && ! str_contains($event, $eventFilter)) { continue; } - $listeners = array_filter($listeners, function ($listener) use ($filterListener) { - if (! $listener instanceof ListenerData) { - return false; + $formattedListeners = []; + + foreach ($rawListeners as $listener) { + $formatted = $this->formatListener($listener); + + if ($listenerFilter && ! str_contains($formatted, $listenerFilter)) { + continue; } - return ! $filterListener || str_contains($listener->listener, $filterListener); - }); + $formattedListeners[] = $formatted; + } - $listeners = array_map(function ($listener) { - $listener = $listener->listener; - if (is_array($listener) && count($listener) === 2) { - [$object, $method] = $listener; - $listenerClassName = is_string($object) - ? $object - : get_class($object); + if (! empty($formattedListeners)) { + $data[$event] = [ + 'events' => $event, + 'listeners' => $formattedListeners, + ]; + } + } - return implode('::', [$listenerClassName, $method]); - } + return $data; + } - if (is_string($listener)) { - return $listener; - } + /** + * Format a raw listener for display. + */ + protected function formatListener(mixed $listener): string + { + if (is_string($listener)) { + return $listener; + } - if ($listener instanceof Closure) { - return 'Closure'; - } + if ($listener instanceof Closure) { + return 'Closure'; + } - return 'Unknown listener'; - }, $listeners); + if (is_array($listener) && count($listener) === 2) { + [$object, $method] = $listener; + $className = is_string($object) ? $object : get_class($object); - $data[$event]['events'] = $event; - $data[$event]['listeners'] = array_merge($data[$event]['listeners'] ?? [], $listeners); + return $className . '::' . $method; } - return $data; + return 'Unknown listener'; } protected function show(array $data, OutputInterface $output): void { + if (empty($data)) { + $output->writeln('No events registered.'); + + return; + } + $rows = []; - foreach ($data as $route) { - $route['listeners'] = implode(PHP_EOL, (array) $route['listeners']); - $rows[] = $route; + foreach ($data as $row) { + $row['listeners'] = implode(PHP_EOL, (array) $row['listeners']); + $rows[] = $row; $rows[] = new TableSeparator(); } - $rows = array_slice($rows, 0, count($rows) - 1); + + // Remove trailing separator + array_pop($rows); + $table = new Table($output); $table->setHeaders(['Events', 'Listeners'])->setRows($rows); $table->render(); diff --git a/src/devtool/src/Commands/WatchCommand.php b/src/devtool/src/Commands/WatchCommand.php index 98406f6b6..96a900b72 100644 --- a/src/devtool/src/Commands/WatchCommand.php +++ b/src/devtool/src/Commands/WatchCommand.php @@ -6,7 +6,6 @@ use Hyperf\Command\Command as HyperfCommand; use Hyperf\Command\Concerns\NullDisableEventDispatcher; -use Hyperf\Contract\ConfigInterface; use Hyperf\Watcher\Option; use Hyperf\Watcher\Watcher; use Psr\Container\ContainerInterface; @@ -35,7 +34,7 @@ public function handle() return; } - $options = $this->container->get(ConfigInterface::class)->get('watcher', []); + $options = $this->container->get('config')->get('watcher', []); if (empty($options) && file_exists($defaultConfigPath = BASE_PATH . '/vendor/hyperf/watcher/publish/watcher.php') ) { diff --git a/src/devtool/src/Generator/BatchesTableCommand.php b/src/devtool/src/Generator/BatchesTableCommand.php index 00e7d587e..78f3a92ba 100644 --- a/src/devtool/src/Generator/BatchesTableCommand.php +++ b/src/devtool/src/Generator/BatchesTableCommand.php @@ -5,9 +5,8 @@ namespace Hypervel\Devtool\Generator; use Carbon\Carbon; -use Hyperf\Context\ApplicationContext; -use Hyperf\Contract\ConfigInterface; use Hyperf\Devtool\Generator\GeneratorCommand; +use Hypervel\Context\ApplicationContext; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; @@ -86,7 +85,7 @@ protected function getOptions(): array }); return array_merge(array_values($options), [ - ['path', 'p', InputOption::VALUE_OPTIONAL, 'The path of the sessions table migration.'], + ['path', 'p', InputOption::VALUE_OPTIONAL, 'The path of the batches table migration.'], ]); } @@ -101,7 +100,7 @@ protected function getDefaultNamespace(): string protected function migrationTableName(): string { return ApplicationContext::getContainer() - ->get(ConfigInterface::class) + ->get('config') ->get('queue.batching.table', 'job_batches'); } } diff --git a/src/devtool/src/Generator/CacheLocksTableCommand.php b/src/devtool/src/Generator/CacheLocksTableCommand.php index a094576ff..d5c515dcf 100644 --- a/src/devtool/src/Generator/CacheLocksTableCommand.php +++ b/src/devtool/src/Generator/CacheLocksTableCommand.php @@ -5,9 +5,8 @@ namespace Hypervel\Devtool\Generator; use Carbon\Carbon; -use Hyperf\Context\ApplicationContext; -use Hyperf\Contract\ConfigInterface; use Hyperf\Devtool\Generator\GeneratorCommand; +use Hypervel\Context\ApplicationContext; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; @@ -100,7 +99,7 @@ protected function getDefaultNamespace(): string protected function migrationTableName(): string { return ApplicationContext::getContainer() - ->get(ConfigInterface::class) + ->get('config') ->get('cache.stores.database.lock_table', 'cache_locks'); } } diff --git a/src/devtool/src/Generator/CacheTableCommand.php b/src/devtool/src/Generator/CacheTableCommand.php index fbdb2219a..6fed95691 100644 --- a/src/devtool/src/Generator/CacheTableCommand.php +++ b/src/devtool/src/Generator/CacheTableCommand.php @@ -5,9 +5,8 @@ namespace Hypervel\Devtool\Generator; use Carbon\Carbon; -use Hyperf\Context\ApplicationContext; -use Hyperf\Contract\ConfigInterface; use Hyperf\Devtool\Generator\GeneratorCommand; +use Hypervel\Context\ApplicationContext; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; @@ -100,7 +99,7 @@ protected function getDefaultNamespace(): string protected function migrationTableName(): string { return ApplicationContext::getContainer() - ->get(ConfigInterface::class) + ->get('config') ->get('cache.stores.database.table', 'cache'); } } diff --git a/src/devtool/src/Generator/ConsoleCommand.php b/src/devtool/src/Generator/ConsoleCommand.php index 6b10c79dd..993c85ed4 100644 --- a/src/devtool/src/Generator/ConsoleCommand.php +++ b/src/devtool/src/Generator/ConsoleCommand.php @@ -5,7 +5,7 @@ namespace Hypervel\Devtool\Generator; use Hyperf\Devtool\Generator\GeneratorCommand; -use Hyperf\Stringable\Str; +use Hypervel\Support\Str; use Symfony\Component\Console\Input\InputOption; class ConsoleCommand extends GeneratorCommand diff --git a/src/devtool/src/Generator/ControllerCommand.php b/src/devtool/src/Generator/ControllerCommand.php index 26ce83c67..fd3a3da67 100644 --- a/src/devtool/src/Generator/ControllerCommand.php +++ b/src/devtool/src/Generator/ControllerCommand.php @@ -5,7 +5,7 @@ namespace Hypervel\Devtool\Generator; use Hyperf\Devtool\Generator\GeneratorCommand; -use Hyperf\Stringable\Str; +use Hypervel\Support\Str; use Symfony\Component\Console\Input\ArrayInput; use Symfony\Component\Console\Input\InputOption; diff --git a/src/devtool/src/Generator/MailCommand.php b/src/devtool/src/Generator/MailCommand.php index 7fff6cfcc..670bfc7e6 100644 --- a/src/devtool/src/Generator/MailCommand.php +++ b/src/devtool/src/Generator/MailCommand.php @@ -4,9 +4,9 @@ namespace Hypervel\Devtool\Generator; -use Hyperf\Collection\Collection; use Hyperf\Devtool\Generator\GeneratorCommand; -use Hyperf\Stringable\Str; +use Hypervel\Support\Collection; +use Hypervel\Support\Str; use Symfony\Component\Console\Input\InputOption; class MailCommand extends GeneratorCommand diff --git a/src/devtool/src/Generator/ModelCommand.php b/src/devtool/src/Generator/ModelCommand.php index e76b30f77..f48c89484 100644 --- a/src/devtool/src/Generator/ModelCommand.php +++ b/src/devtool/src/Generator/ModelCommand.php @@ -5,7 +5,7 @@ namespace Hypervel\Devtool\Generator; use Hyperf\Devtool\Generator\GeneratorCommand; -use Hyperf\Stringable\Str; +use Hypervel\Support\Str; use Symfony\Component\Console\Input\ArrayInput; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; diff --git a/src/devtool/src/Generator/ObserverCommand.php b/src/devtool/src/Generator/ObserverCommand.php index ecc6476e6..806ede0e0 100644 --- a/src/devtool/src/Generator/ObserverCommand.php +++ b/src/devtool/src/Generator/ObserverCommand.php @@ -5,7 +5,7 @@ namespace Hypervel\Devtool\Generator; use Hyperf\Devtool\Generator\GeneratorCommand; -use Hyperf\Stringable\Str; +use Hypervel\Support\Str; use Symfony\Component\Console\Input\InputOption; class ObserverCommand extends GeneratorCommand diff --git a/src/devtool/src/Generator/PolicyCommand.php b/src/devtool/src/Generator/PolicyCommand.php index 85c22cce6..df9ac2001 100644 --- a/src/devtool/src/Generator/PolicyCommand.php +++ b/src/devtool/src/Generator/PolicyCommand.php @@ -5,7 +5,7 @@ namespace Hypervel\Devtool\Generator; use Hyperf\Devtool\Generator\GeneratorCommand; -use Hyperf\Stringable\Str; +use Hypervel\Support\Str; use LogicException; use Symfony\Component\Console\Input\InputOption; diff --git a/src/devtool/src/Generator/QueueFailedTableCommand.php b/src/devtool/src/Generator/QueueFailedTableCommand.php index faa65b7ad..38b8b1916 100644 --- a/src/devtool/src/Generator/QueueFailedTableCommand.php +++ b/src/devtool/src/Generator/QueueFailedTableCommand.php @@ -5,9 +5,8 @@ namespace Hypervel\Devtool\Generator; use Carbon\Carbon; -use Hyperf\Context\ApplicationContext; -use Hyperf\Contract\ConfigInterface; use Hyperf\Devtool\Generator\GeneratorCommand; +use Hypervel\Context\ApplicationContext; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; @@ -86,7 +85,7 @@ protected function getOptions(): array }); return array_merge(array_values($options), [ - ['path', 'p', InputOption::VALUE_OPTIONAL, 'The path of the sessions table migration.'], + ['path', 'p', InputOption::VALUE_OPTIONAL, 'The path of the failed jobs table migration.'], ]); } @@ -101,7 +100,7 @@ protected function getDefaultNamespace(): string protected function migrationTableName(): string { return ApplicationContext::getContainer() - ->get(ConfigInterface::class) + ->get('config') ->get('queue.failed.table', 'failed_jobs'); } } diff --git a/src/devtool/src/Generator/QueueTableCommand.php b/src/devtool/src/Generator/QueueTableCommand.php index 389b9b025..4aa9f6532 100644 --- a/src/devtool/src/Generator/QueueTableCommand.php +++ b/src/devtool/src/Generator/QueueTableCommand.php @@ -5,9 +5,8 @@ namespace Hypervel\Devtool\Generator; use Carbon\Carbon; -use Hyperf\Context\ApplicationContext; -use Hyperf\Contract\ConfigInterface; use Hyperf\Devtool\Generator\GeneratorCommand; +use Hypervel\Context\ApplicationContext; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; @@ -86,7 +85,7 @@ protected function getOptions(): array }); return array_merge(array_values($options), [ - ['path', 'p', InputOption::VALUE_OPTIONAL, 'The path of the sessions table migration.'], + ['path', 'p', InputOption::VALUE_OPTIONAL, 'The path of the queue jobs table migration.'], ]); } @@ -101,7 +100,7 @@ protected function getDefaultNamespace(): string protected function migrationTableName(): string { return ApplicationContext::getContainer() - ->get(ConfigInterface::class) + ->get('config') ->get('queue.connections.database.table', 'jobs'); } } diff --git a/src/devtool/src/Generator/stubs/batches-table.stub b/src/devtool/src/Generator/stubs/batches-table.stub index 19df50f0a..76906886a 100644 --- a/src/devtool/src/Generator/stubs/batches-table.stub +++ b/src/devtool/src/Generator/stubs/batches-table.stub @@ -2,7 +2,7 @@ declare(strict_types=1); -use Hyperf\Database\Schema\Blueprint; +use Hypervel\Database\Schema\Blueprint; use Hypervel\Database\Migrations\Migration; use Hypervel\Support\Facades\Schema; diff --git a/src/devtool/src/Generator/stubs/cache-locks-table.stub b/src/devtool/src/Generator/stubs/cache-locks-table.stub index 14904bbc5..9bcacd038 100644 --- a/src/devtool/src/Generator/stubs/cache-locks-table.stub +++ b/src/devtool/src/Generator/stubs/cache-locks-table.stub @@ -2,7 +2,7 @@ declare(strict_types=1); -use Hyperf\Database\Schema\Blueprint; +use Hypervel\Database\Schema\Blueprint; use Hypervel\Database\Migrations\Migration; use Hypervel\Support\Facades\Schema; diff --git a/src/devtool/src/Generator/stubs/cache-table.stub b/src/devtool/src/Generator/stubs/cache-table.stub index fd52b56ac..74f1cd9a6 100644 --- a/src/devtool/src/Generator/stubs/cache-table.stub +++ b/src/devtool/src/Generator/stubs/cache-table.stub @@ -2,7 +2,7 @@ declare(strict_types=1); -use Hyperf\Database\Schema\Blueprint; +use Hypervel\Database\Schema\Blueprint; use Hypervel\Database\Migrations\Migration; use Hypervel\Support\Facades\Schema; diff --git a/src/devtool/src/Generator/stubs/failed-jobs-table.stub b/src/devtool/src/Generator/stubs/failed-jobs-table.stub index a50a68286..1e1a59f1c 100644 --- a/src/devtool/src/Generator/stubs/failed-jobs-table.stub +++ b/src/devtool/src/Generator/stubs/failed-jobs-table.stub @@ -2,7 +2,7 @@ declare(strict_types=1); -use Hyperf\Database\Schema\Blueprint; +use Hypervel\Database\Schema\Blueprint; use Hypervel\Database\Migrations\Migration; use Hypervel\Support\Facades\Schema; diff --git a/src/devtool/src/Generator/stubs/job.queued.stub b/src/devtool/src/Generator/stubs/job.queued.stub index 326b5acc2..b87b76dea 100644 --- a/src/devtool/src/Generator/stubs/job.queued.stub +++ b/src/devtool/src/Generator/stubs/job.queued.stub @@ -4,7 +4,7 @@ declare(strict_types=1); namespace %NAMESPACE%; -use Hypervel\Queue\Contracts\ShouldQueue; +use Hypervel\Contracts\Queue\ShouldQueue; use Hypervel\Queue\Queueable; class %CLASS% implements ShouldQueue diff --git a/src/devtool/src/Generator/stubs/jobs-table.stub b/src/devtool/src/Generator/stubs/jobs-table.stub index 839c0dd64..91622f8a4 100644 --- a/src/devtool/src/Generator/stubs/jobs-table.stub +++ b/src/devtool/src/Generator/stubs/jobs-table.stub @@ -2,7 +2,7 @@ declare(strict_types=1); -use Hyperf\Database\Schema\Blueprint; +use Hypervel\Database\Schema\Blueprint; use Hypervel\Database\Migrations\Migration; use Hypervel\Support\Facades\Schema; diff --git a/src/devtool/src/Generator/stubs/listener.stub b/src/devtool/src/Generator/stubs/listener.stub index c1f857447..70af6247d 100644 --- a/src/devtool/src/Generator/stubs/listener.stub +++ b/src/devtool/src/Generator/stubs/listener.stub @@ -4,7 +4,7 @@ declare(strict_types=1); namespace %NAMESPACE%; -use Hypervel\Queue\Contracts\ShouldQueue; +use Hypervel\Contracts\Queue\ShouldQueue; class %CLASS% { diff --git a/src/devtool/src/Generator/stubs/mail.stub b/src/devtool/src/Generator/stubs/mail.stub index 8a6a5400e..753cb4ae0 100644 --- a/src/devtool/src/Generator/stubs/mail.stub +++ b/src/devtool/src/Generator/stubs/mail.stub @@ -8,7 +8,7 @@ use Hypervel\Bus\Queueable; use Hypervel\Mail\Mailable; use Hypervel\Mail\Mailables\Content; use Hypervel\Mail\Mailables\Envelope; -use Hypervel\Queue\Contracts\ShouldQueue; +use Hypervel\Contracts\Queue\ShouldQueue; use Hypervel\Queue\SerializesModels; class %CLASS% extends Mailable diff --git a/src/devtool/src/Generator/stubs/markdown-mail.stub b/src/devtool/src/Generator/stubs/markdown-mail.stub index 0c0b491db..d45dbcdec 100644 --- a/src/devtool/src/Generator/stubs/markdown-mail.stub +++ b/src/devtool/src/Generator/stubs/markdown-mail.stub @@ -8,7 +8,7 @@ use Hypervel\Bus\Queueable; use Hypervel\Mail\Mailable; use Hypervel\Mail\Mailables\Content; use Hypervel\Mail\Mailables\Envelope; -use Hypervel\Queue\Contracts\ShouldQueue; +use Hypervel\Contracts\Queue\ShouldQueue; use Hypervel\Queue\SerializesModels; class %CLASS% extends Mailable diff --git a/src/devtool/src/Generator/stubs/markdown-notification.stub b/src/devtool/src/Generator/stubs/markdown-notification.stub index 3fc6fb5bc..92265ff04 100644 --- a/src/devtool/src/Generator/stubs/markdown-notification.stub +++ b/src/devtool/src/Generator/stubs/markdown-notification.stub @@ -7,7 +7,7 @@ namespace %NAMESPACE%; use Hypervel\Bus\Queueable; use Hypervel\Notifications\Messages\MailMessage; use Hypervel\Notifications\Notification; -use Hypervel\Queue\Contracts\ShouldQueue; +use Hypervel\Contracts\Queue\ShouldQueue; class %CLASS% extends Notification { diff --git a/src/devtool/src/Generator/stubs/notification.stub b/src/devtool/src/Generator/stubs/notification.stub index 4369556c5..03052ed92 100644 --- a/src/devtool/src/Generator/stubs/notification.stub +++ b/src/devtool/src/Generator/stubs/notification.stub @@ -7,7 +7,7 @@ namespace %NAMESPACE%; use Hypervel\Bus\Queueable; use Hypervel\Notifications\Messages\MailMessage; use Hypervel\Notifications\Notification; -use Hypervel\Queue\Contracts\ShouldQueue; +use Hypervel\Contracts\Queue\ShouldQueue; class %CLASS% extends Notification { diff --git a/src/devtool/src/Generator/stubs/notifications-table.stub b/src/devtool/src/Generator/stubs/notifications-table.stub index f54002fca..3cdb40bf6 100644 --- a/src/devtool/src/Generator/stubs/notifications-table.stub +++ b/src/devtool/src/Generator/stubs/notifications-table.stub @@ -2,7 +2,7 @@ declare(strict_types=1); -use Hyperf\Database\Schema\Blueprint; +use Hypervel\Database\Schema\Blueprint; use Hypervel\Database\Migrations\Migration; use Hypervel\Support\Facades\Schema; diff --git a/src/devtool/src/Generator/stubs/rule.stub b/src/devtool/src/Generator/stubs/rule.stub index da4271399..d46813846 100644 --- a/src/devtool/src/Generator/stubs/rule.stub +++ b/src/devtool/src/Generator/stubs/rule.stub @@ -5,7 +5,7 @@ declare(strict_types=1); namespace %NAMESPACE%; use Closure; -use Hypervel\Validation\Contracts\ValidationRule; +use Hypervel\Contracts\Validation\ValidationRule; class %CLASS% implements ValidationRule { diff --git a/src/devtool/src/Generator/stubs/sessions-table.stub b/src/devtool/src/Generator/stubs/sessions-table.stub index 0dd1310ab..5faf42c99 100644 --- a/src/devtool/src/Generator/stubs/sessions-table.stub +++ b/src/devtool/src/Generator/stubs/sessions-table.stub @@ -2,7 +2,7 @@ declare(strict_types=1); -use Hyperf\Database\Schema\Blueprint; +use Hypervel\Database\Schema\Blueprint; use Hypervel\Database\Migrations\Migration; use Hypervel\Support\Facades\Schema; diff --git a/src/devtool/src/Generator/stubs/view-mail.stub b/src/devtool/src/Generator/stubs/view-mail.stub index 545c94c3d..cb9db2ed8 100644 --- a/src/devtool/src/Generator/stubs/view-mail.stub +++ b/src/devtool/src/Generator/stubs/view-mail.stub @@ -8,7 +8,7 @@ use Hypervel\Bus\Queueable; use Hypervel\Mail\Mailable; use Hypervel\Mail\Mailables\Content; use Hypervel\Mail\Mailables\Envelope; -use Hypervel\Queue\Contracts\ShouldQueue; +use Hypervel\Contracts\Queue\ShouldQueue; use Hypervel\Queue\SerializesModels; class %CLASS% extends Mailable diff --git a/src/dispatcher/class_map/HttpRequestHandler.php b/src/dispatcher/class_map/HttpRequestHandler.php index d214c3b74..fab66800d 100644 --- a/src/dispatcher/class_map/HttpRequestHandler.php +++ b/src/dispatcher/class_map/HttpRequestHandler.php @@ -4,7 +4,7 @@ namespace Hyperf\Dispatcher; -use Hyperf\Context\Context; +use Hypervel\Context\Context; use Hypervel\Dispatcher\Pipeline; use Psr\Container\ContainerInterface; use Psr\Http\Message\ResponseInterface; @@ -22,7 +22,7 @@ public function __construct( public function handle(ServerRequestInterface $request): ResponseInterface { - Context::set('request.middleware', $this->middlewares); + Context::set('__request.middleware', $this->middlewares); return $this->container ->get(Pipeline::class) diff --git a/src/dispatcher/composer.json b/src/dispatcher/composer.json index a3dcb3e8c..4449b3c13 100644 --- a/src/dispatcher/composer.json +++ b/src/dispatcher/composer.json @@ -21,8 +21,8 @@ } ], "require": { - "php": "^8.2", - "hyperf/context": "~3.1.0", + "php": "^8.4", + "hypervel/context": "^0.4", "hyperf/dispatcher": "~3.1.0", "hyperf/pipeline": "~3.1.0" }, @@ -36,7 +36,7 @@ "config": "Hypervel\\Dispatcher\\ConfigProvider" }, "branch-alias": { - "dev-main": "0.3-dev" + "dev-main": "0.4-dev" } }, "config": { diff --git a/src/dispatcher/src/AdaptedRequestHandler.php b/src/dispatcher/src/AdaptedRequestHandler.php index ff01570af..36a0d8a24 100644 --- a/src/dispatcher/src/AdaptedRequestHandler.php +++ b/src/dispatcher/src/AdaptedRequestHandler.php @@ -5,7 +5,7 @@ namespace Hypervel\Dispatcher; use Closure; -use Hyperf\Context\Context; +use Hypervel\Context\Context; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\RequestHandlerInterface; diff --git a/src/encryption/composer.json b/src/encryption/composer.json index 17d405142..8c92c1afd 100644 --- a/src/encryption/composer.json +++ b/src/encryption/composer.json @@ -26,13 +26,11 @@ } }, "require": { - "php": "^8.2", + "php": "^8.4", "ext-hash": "*", "ext-mbstring": "*", "ext-openssl": "*", - "hyperf/config": "~3.1.0", - "hyperf/tappable": "~3.1.0", - "hyperf/stringable": "~3.1.0" + "hyperf/config": "~3.1.0" }, "config": { "sort-packages": true @@ -42,7 +40,7 @@ "config": "Hypervel\\Encryption\\ConfigProvider" }, "branch-alias": { - "dev-main": "0.3-dev" + "dev-main": "0.4-dev" } } } \ No newline at end of file diff --git a/src/encryption/src/Commands/KeyGenerateCommand.php b/src/encryption/src/Commands/KeyGenerateCommand.php index 168b2f526..09cb057b8 100644 --- a/src/encryption/src/Commands/KeyGenerateCommand.php +++ b/src/encryption/src/Commands/KeyGenerateCommand.php @@ -6,7 +6,7 @@ use Hyperf\Command\Command as HyperfCommand; use Hyperf\Command\Concerns\Confirmable as ConfirmableTrait; -use Hyperf\Contract\ConfigInterface; +use Hypervel\Config\Repository; use Hypervel\Encryption\Encrypter; use Psr\Container\ContainerInterface; use Symfony\Component\Console\Input\InputOption; @@ -17,7 +17,7 @@ class KeyGenerateCommand extends HyperfCommand public function __construct( protected ContainerInterface $container, - protected ConfigInterface $config + protected Repository $config ) { parent::__construct('key:generate'); } diff --git a/src/encryption/src/ConfigProvider.php b/src/encryption/src/ConfigProvider.php index bd6227141..ffa40d3b0 100644 --- a/src/encryption/src/ConfigProvider.php +++ b/src/encryption/src/ConfigProvider.php @@ -4,8 +4,8 @@ namespace Hypervel\Encryption; +use Hypervel\Contracts\Encryption\Encrypter; use Hypervel\Encryption\Commands\KeyGenerateCommand; -use Hypervel\Encryption\Contracts\Encrypter; class ConfigProvider { diff --git a/src/encryption/src/Encrypter.php b/src/encryption/src/Encrypter.php index 87943704f..a072f4d56 100644 --- a/src/encryption/src/Encrypter.php +++ b/src/encryption/src/Encrypter.php @@ -4,10 +4,10 @@ namespace Hypervel\Encryption; -use Hypervel\Encryption\Contracts\Encrypter as EncrypterContract; -use Hypervel\Encryption\Contracts\StringEncrypter; -use Hypervel\Encryption\Exceptions\DecryptException; -use Hypervel\Encryption\Exceptions\EncryptException; +use Hypervel\Contracts\Encryption\DecryptException; +use Hypervel\Contracts\Encryption\Encrypter as EncrypterContract; +use Hypervel\Contracts\Encryption\EncryptException; +use Hypervel\Contracts\Encryption\StringEncrypter; use RuntimeException; class Encrypter implements EncrypterContract, StringEncrypter @@ -81,7 +81,7 @@ public static function generateKey(string $cipher): string /** * Encrypt the given value. * - * @throws \Hypervel\Encryption\Exceptions\EncryptException + * @throws \Hypervel\Contracts\Encryption\EncryptException */ public function encrypt(mixed $value, bool $serialize = true): string { @@ -119,7 +119,7 @@ public function encrypt(mixed $value, bool $serialize = true): string /** * Encrypt a string without serialization. * - * @throws \Hypervel\Encryption\Exceptions\EncryptException + * @throws \Hypervel\Contracts\Encryption\EncryptException */ public function encryptString(string $value): string { @@ -129,7 +129,7 @@ public function encryptString(string $value): string /** * Decrypt the given value. * - * @throws \Hypervel\Encryption\Exceptions\DecryptException + * @throws \Hypervel\Contracts\Encryption\DecryptException */ public function decrypt(string $payload, bool $unserialize = true): mixed { @@ -180,7 +180,7 @@ public function decrypt(string $payload, bool $unserialize = true): mixed /** * Decrypt the given string without unserialization. * - * @throws \Hypervel\Encryption\Exceptions\DecryptException + * @throws \Hypervel\Contracts\Encryption\DecryptException */ public function decryptString(string $payload): string { @@ -198,7 +198,7 @@ protected function hash(string $iv, mixed $value, string $key): string /** * Get the JSON array from the given payload. * - * @throws \Hypervel\Encryption\Exceptions\DecryptException + * @throws \Hypervel\Contracts\Encryption\DecryptException */ protected function getJsonPayload(string $payload): array { diff --git a/src/encryption/src/EncryptionFactory.php b/src/encryption/src/EncryptionFactory.php index cce9bf48d..f36929f9d 100644 --- a/src/encryption/src/EncryptionFactory.php +++ b/src/encryption/src/EncryptionFactory.php @@ -4,19 +4,16 @@ namespace Hypervel\Encryption; -use Hyperf\Contract\ConfigInterface; -use Hyperf\Stringable\Str; use Hypervel\Encryption\Exceptions\MissingAppKeyException; +use Hypervel\Support\Str; use Laravel\SerializableClosure\SerializableClosure; use Psr\Container\ContainerInterface; -use function Hyperf\Tappable\tap; - class EncryptionFactory { public function __invoke(ContainerInterface $container): Encrypter { - $config = $container->get(ConfigInterface::class); + $config = $container->get('config'); // Fallback to the encryption config if key is not set in app config. $config = ($config->has('app.cipher') && $config->has('app.key')) ? $config->get('app') diff --git a/src/engine/LICENSE.md b/src/engine/LICENSE.md new file mode 100644 index 000000000..385442433 --- /dev/null +++ b/src/engine/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. diff --git a/src/engine/README.md b/src/engine/README.md new file mode 100644 index 000000000..5183ddad0 --- /dev/null +++ b/src/engine/README.md @@ -0,0 +1,4 @@ +Engine for Hypervel +=== + +[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/hypervel/engine) diff --git a/src/engine/composer.json b/src/engine/composer.json new file mode 100644 index 000000000..fb60068d8 --- /dev/null +++ b/src/engine/composer.json @@ -0,0 +1,60 @@ +{ + "name": "hypervel/engine", + "description": "Coroutine engine for Hypervel powered by Swoole.", + "license": "MIT", + "keywords": [ + "php", + "hyperf", + "hypervel", + "engine", + "swoole", + "coroutine" + ], + "support": { + "issues": "https://github.com/hypervel/components/issues", + "source": "https://github.com/hypervel/components" + }, + "authors": [ + { + "name": "Albert Chen", + "email": "albert@hypervel.org" + }, + { + "name": "Raj Siva-Rajah", + "homepage": "https://github.com/binaryfire" + } + ], + "require": { + "php": "^8.4", + "hypervel/contracts": "^0.4" + }, + "autoload": { + "psr-4": { + "Hypervel\\Engine\\": "src/" + }, + "files": [ + "src/Functions.php" + ] + }, + "suggest": { + "ext-sockets": "*", + "ext-swoole": ">=5.0", + "psr/http-message": "Required to use WebSocket Frame.", + "hyperf/http-message": "Required to use ResponseEmitter." + }, + "conflict": { + "ext-swoole": "<5.0" + }, + "extra": { + "hyperf": { + "config": "Hypervel\\Engine\\ConfigProvider" + }, + "branch-alias": { + "dev-main": "0.4-dev" + } + }, + "config": { + "sort-packages": true + }, + "minimum-stability": "dev" +} diff --git a/src/engine/examples/http_server.php b/src/engine/examples/http_server.php new file mode 100644 index 000000000..edd866687 --- /dev/null +++ b/src/engine/examples/http_server.php @@ -0,0 +1,55 @@ + SWOOLE_HOOK_ALL, +]); + +$callback = function () { + $server = new Server('0.0.0.0', 19501); + $server->handle('/', function (Request $request, Response $response) { + $response->setHeader('Server', 'Hyperf'); + switch ($request->server['request_uri']) { + case '/': + $response->end('Hello World.'); + break; + case '/header': + $response->header('X-ID', [uniqid(), $id = uniqid()]); + $response->end($id); + break; + case '/cookies': + $response->setCookie('X-Server-Id', $id = uniqid()); + $response->setCookie('X-Server-Name', 'Hyperf'); + $response->end($id); + break; + case '/timeout': + $time = $request->get['time'] ?? 1; + sleep((int) $time); + $response->end(); + break; + default: + $response->setStatusCode(404); + $response->end(); + break; + } + }); + $server->start(); +}; + +run($callback); diff --git a/src/engine/examples/http_server_v2.php b/src/engine/examples/http_server_v2.php new file mode 100644 index 000000000..1fa7f37a1 --- /dev/null +++ b/src/engine/examples/http_server_v2.php @@ -0,0 +1,64 @@ + SWOOLE_HOOK_ALL, +]); + +$callback = function () { + $server = new Server('0.0.0.0', 19505); + $server->handle('/', function (Request $request, Response $response) { + $path = $request->server['request_uri']; + + match ($path) { + '/set-cookies' => (function () use ($request, $response) { + // Get cookies sent by client + $cookies = $request->cookie ?? []; + + // Parse JSON body for cookies to set + $body = $request->rawContent(); + $json = $body ? json_decode($body, true) : []; + + // Set cookies from JSON body + if (! empty($json['id'])) { + $response->setCookie('id', $json['id']); + } + if (! empty($json['id2'])) { + $response->setCookie('id2', $json['id2']); + } + + // Return received cookies as JSON + $response->setHeader('Content-Type', 'application/json'); + $response->end(json_encode($cookies)); + })(), + default => (function () use ($request, $response) { + $body = $request->rawContent(); + $ret = 'Hello World.'; + if ($body) { + $ret = 'Received: ' . $body; + } + $response->end($ret); + })() + }; + }); + $server->start(); +}; + +run($callback); diff --git a/src/engine/examples/tcp_packet_server.php b/src/engine/examples/tcp_packet_server.php new file mode 100644 index 000000000..4e62a3d6b --- /dev/null +++ b/src/engine/examples/tcp_packet_server.php @@ -0,0 +1,53 @@ + SWOOLE_HOOK_ALL, +]); + +function p(string $data): string +{ + return pack('N', strlen($data)) . $data; +} + +run(function () { + $server = new Server('0.0.0.0', 19502); + $server->set([ + 'open_length_check' => true, + 'package_max_length' => 1024 * 1024 * 2, + 'package_length_type' => 'N', + 'package_length_offset' => 0, + 'package_body_offset' => 4, + ]); + $server->handle(function (Connection $connection) { + $socket = $connection->exportSocket(); + while (true) { + $body = $socket->recvPacket(); + if (empty($body)) { + break; + } + $body = substr($body, 4); + if ($body === 'ping') { + $socket->sendAll(p('pong')); + } else { + $socket->sendAll(p('recv:' . $body)); + } + } + }); + $server->start(); +}); diff --git a/src/engine/examples/websocket_server.php b/src/engine/examples/websocket_server.php new file mode 100644 index 000000000..532873ad5 --- /dev/null +++ b/src/engine/examples/websocket_server.php @@ -0,0 +1,42 @@ + SWOOLE_HOOK_ALL, +]); + +run(function () { + $server = new Server('0.0.0.0', 19503, false); + $server->set(['open_websocket_ping_frame' => true]); + + $server->handle('/', function (Request $request, Response $connection) { + $socket = new WebSocket($connection, $request); + $socket->on(WebSocket::ON_CLOSE, static function (Response $connection, int $fd) { + var_dump('closed: ' . $fd); + $connection->close(); + }); + $socket->on(WebSocket::ON_MESSAGE, static function (Response $connection, Frame $frame) { + $connection->push('received: ' . $frame->data); + }); + $socket->start(); + }); + $server->start(); +}); diff --git a/src/engine/src/Barrier.php b/src/engine/src/Barrier.php new file mode 100644 index 000000000..92441c8bb --- /dev/null +++ b/src/engine/src/Barrier.php @@ -0,0 +1,27 @@ + + */ +class Channel extends \Swoole\Coroutine\Channel implements ChannelInterface +{ + protected bool $closed = false; + + /** + * Push data into the channel. + * + * @param TValue $data + * @param float $timeout Timeout in seconds (-1 for unlimited) + */ + public function push(mixed $data, float $timeout = -1): bool + { + return parent::push($data, $timeout); + } + + /** + * Pop data from the channel. + * + * @param float $timeout Timeout in seconds (-1 for unlimited) + * @return false|TValue Returns false when pop fails + */ + public function pop(float $timeout = -1): mixed + { + return parent::pop($timeout); + } + + /** + * Get the channel capacity. + */ + public function getCapacity(): int + { + return $this->capacity; + } + + /** + * Get the current length of the channel. + */ + public function getLength(): int + { + return $this->length(); + } + + /** + * Determine if the channel is available. + */ + public function isAvailable(): bool + { + return ! $this->isClosing(); + } + + /** + * Close the channel. + */ + public function close(): bool + { + $this->closed = true; + return parent::close(); + } + + /** + * Determine if the channel has producers waiting. + * + * @throws RuntimeException not supported in Swoole + */ + public function hasProducers(): bool + { + throw new RuntimeException('Not supported.'); + } + + /** + * Determine if the channel has consumers waiting. + * + * @throws RuntimeException not supported in Swoole + */ + public function hasConsumers(): bool + { + throw new RuntimeException('Not supported.'); + } + + /** + * Determine if the channel is readable. + * + * @throws RuntimeException not supported in Swoole + */ + public function isReadable(): bool + { + throw new RuntimeException('Not supported.'); + } + + /** + * Determine if the channel is writable. + * + * @throws RuntimeException not supported in Swoole + */ + public function isWritable(): bool + { + throw new RuntimeException('Not supported.'); + } + + /** + * Determine if the channel is closing or closed. + */ + public function isClosing(): bool + { + return $this->closed || $this->errCode === SWOOLE_CHANNEL_CLOSED; + } + + /** + * Determine if the last operation timed out. + */ + public function isTimeout(): bool + { + return ! $this->closed && $this->errCode === SWOOLE_CHANNEL_TIMEOUT; + } +} diff --git a/src/engine/src/ConfigProvider.php b/src/engine/src/ConfigProvider.php new file mode 100644 index 000000000..a8415977b --- /dev/null +++ b/src/engine/src/ConfigProvider.php @@ -0,0 +1,26 @@ + [ + SocketFactoryInterface::class => SocketFactory::class, + ServerFactoryInterface::class => ServerFactory::class, + ClientFactoryInterface::class => ClientFactory::class, + ], + ]; + } +} diff --git a/src/engine/src/Constant.php b/src/engine/src/Constant.php new file mode 100644 index 000000000..b00efa9b7 --- /dev/null +++ b/src/engine/src/Constant.php @@ -0,0 +1,21 @@ +callable = $callable; + } + + /** + * Create and execute a new coroutine. + */ + public static function create(callable $callable, ...$data): static + { + $coroutine = new static($callable); + $coroutine->execute(...$data); + return $coroutine; + } + + /** + * Execute the coroutine. + */ + public function execute(...$data): static + { + $this->id = SwooleCo::create($this->callable, ...$data); + return $this; + } + + /** + * Get the coroutine ID. + */ + public function getId(): int + { + if (is_null($this->id)) { + throw new RuntimeException('Coroutine was not be executed.'); + } + return $this->id; + } + + /** + * Get the current coroutine ID. + */ + public static function id(): int + { + return SwooleCo::getCid(); + } + + /** + * Get the parent coroutine ID. + */ + public static function pid(?int $id = null): int + { + if ($id) { + $cid = SwooleCo::getPcid($id); + if ($cid === false) { + throw new CoroutineDestroyedException(sprintf('Coroutine #%d has been destroyed.', $id)); + } + } else { + $cid = SwooleCo::getPcid(); + } + if ($cid === false) { + throw new RunningInNonCoroutineException('Non-Coroutine environment don\'t has parent coroutine id.'); + } + return max(0, $cid); + } + + /** + * Set the coroutine configuration. + */ + public static function set(array $config): void + { + SwooleCo::set($config); + } + + /** + * Get the coroutine context. + */ + public static function getContextFor(?int $id = null): ?ArrayObject + { + if ($id === null) { + return SwooleCo::getContext(); + } + + return SwooleCo::getContext($id); + } + + /** + * Register a callback to be executed when the coroutine ends. + */ + public static function defer(callable $callable): void + { + SwooleCo::defer($callable); + } + + /** + * Yield the current coroutine. + * + * @param mixed $data only supported in Swow + * @return bool + */ + public static function yield(mixed $data = null): mixed + { + return SwooleCo::yield(); + } + + /** + * Resume a coroutine by ID. + * + * @param mixed $data only supported in Swow + * @return bool + */ + public static function resumeById(int $id, mixed ...$data): mixed + { + return SwooleCo::resume($id); + } + + /** + * Get the coroutine statistics. + */ + public static function stats(): array + { + return SwooleCo::stats(); + } + + /** + * Determine if a coroutine exists. + */ + public static function exists(?int $id = null): bool + { + return SwooleCo::exists($id); + } + + /** + * Get all coroutine IDs. + * + * @return iterable + */ + public static function list(): iterable + { + foreach (SwooleCo::list() as $cid) { + yield $cid; + } + } +} diff --git a/src/engine/src/DefaultOption.php b/src/engine/src/DefaultOption.php new file mode 100644 index 000000000..f65f5a878 --- /dev/null +++ b/src/engine/src/DefaultOption.php @@ -0,0 +1,23 @@ +getFin()) { + $flags |= SWOOLE_WEBSOCKET_FLAG_FIN; + } + if ($frame->getRSV1()) { + $flags |= SWOOLE_WEBSOCKET_FLAG_RSV1; + } + if ($frame->getRSV2()) { + $flags |= SWOOLE_WEBSOCKET_FLAG_RSV2; + } + if ($frame->getRSV3()) { + $flags |= SWOOLE_WEBSOCKET_FLAG_RSV3; + } + if ($frame->getMask()) { + $flags |= SWOOLE_WEBSOCKET_FLAG_MASK; + } + + return $flags; +} diff --git a/src/engine/src/Http/Client.php b/src/engine/src/Http/Client.php new file mode 100644 index 000000000..5c4732bcb --- /dev/null +++ b/src/engine/src/Http/Client.php @@ -0,0 +1,81 @@ + $headers + */ + public function request(string $method = 'GET', string $path = '/', array $headers = [], string $contents = '', string $version = '1.1'): RawResponse + { + $this->setMethod($method); + $this->setData($contents); + $this->setHeaders($this->encodeHeaders($headers)); + $this->execute($path); + if ($this->errCode !== 0) { + throw new HttpClientException($this->errMsg, $this->errCode); + } + return new RawResponse( + $this->statusCode, + $this->decodeHeaders($this->headers ?? []), + $this->body, + $version + ); + } + + /** + * Decode headers from Swoole format to standard format. + * + * @param array $headers + * @return array + */ + private function decodeHeaders(array $headers): array + { + $result = []; + foreach ($headers as $name => $header) { + // The key of header is lower case. + if (is_array($header)) { + $result[$name] = $header; + } else { + $result[$name][] = $header; + } + } + if ($this->set_cookie_headers) { + $result['set-cookie'] = $this->set_cookie_headers; + } + return $result; + } + + /** + * Encode headers for Swoole (does not support two-dimensional arrays). + * + * @param array $headers + * @return array + */ + private function encodeHeaders(array $headers): array + { + $result = []; + foreach ($headers as $name => $value) { + $result[$name] = is_array($value) ? implode(',', $value) : $value; + } + + return $result; + } +} diff --git a/src/engine/src/Http/EventStream.php b/src/engine/src/Http/EventStream.php new file mode 100644 index 000000000..8b8f6ee42 --- /dev/null +++ b/src/engine/src/Http/EventStream.php @@ -0,0 +1,44 @@ +connection->getSocket(); + $socket->header('Content-Type', 'text/event-stream; charset=utf-8'); + $socket->header('Transfer-Encoding', 'chunked'); + $socket->header('Cache-Control', 'no-cache'); + foreach ($response?->getHeaders() ?? [] as $name => $values) { + $socket->header($name, implode(', ', $values)); + } + } + + /** + * Write data to the event stream. + */ + public function write(string $data): self + { + $this->connection->write($data); + return $this; + } + + /** + * End the event stream. + */ + public function end(): void + { + $this->connection->end(); + } +} diff --git a/src/engine/src/Http/FdGetter.php b/src/engine/src/Http/FdGetter.php new file mode 100644 index 000000000..1f33b950d --- /dev/null +++ b/src/engine/src/Http/FdGetter.php @@ -0,0 +1,18 @@ +fd; + } +} diff --git a/src/engine/src/Http/Http.php b/src/engine/src/Http/Http.php new file mode 100644 index 000000000..0a1583e8a --- /dev/null +++ b/src/engine/src/Http/Http.php @@ -0,0 +1,54 @@ + $values) { + foreach ((array) $values as $value) { + $headerString .= sprintf("%s: %s\r\n", $key, $value); + } + } + + return sprintf( + "%s %s HTTP/%s\r\n%s\r\n%s", + $method, + $path, + $protocolVersion, + $headerString, + $body + ); + } + + /** + * Pack an HTTP response into a string. + */ + public static function packResponse(int $statusCode, string $reasonPhrase = '', array $headers = [], string|Stringable $body = '', string $protocolVersion = HttpContract::DEFAULT_PROTOCOL_VERSION): string + { + $headerString = ''; + foreach ($headers as $key => $values) { + foreach ((array) $values as $value) { + $headerString .= sprintf("%s: %s\r\n", $key, $value); + } + } + return sprintf( + "HTTP/%s %s %s\r\n%s\r\n%s", + $protocolVersion, + $statusCode, + $reasonPhrase, + $headerString, + $body + ); + } +} diff --git a/src/engine/src/Http/RawResponse.php b/src/engine/src/Http/RawResponse.php new file mode 100644 index 000000000..a30ac7045 --- /dev/null +++ b/src/engine/src/Http/RawResponse.php @@ -0,0 +1,55 @@ +statusCode; + } + + /** + * Get the response headers. + */ + public function getHeaders(): array + { + return $this->headers; + } + + /** + * Get the response body. + */ + public function getBody(): string + { + return $this->body; + } + + /** + * Get the HTTP protocol version. + */ + public function getVersion(): string + { + return $this->version; + } +} diff --git a/src/engine/src/Http/Server.php b/src/engine/src/Http/Server.php new file mode 100644 index 000000000..41ba8e55e --- /dev/null +++ b/src/engine/src/Http/Server.php @@ -0,0 +1,84 @@ +host = $name; + $this->port = $port; + + $this->server = new HttpServer($name, $port, reuse_port: true); + return $this; + } + + /** + * Set the request handler. + */ + public function handle(callable $callable): static + { + $this->handler = $callable; + return $this; + } + + /** + * Start the server. + */ + public function start(): void + { + $this->server->handle('/', function ($request, $response) { + Coroutine::create(function () use ($request, $response) { + try { + $handler = $this->handler; + + $handler(Request::loadFromSwooleRequest($request), $response); + } catch (Throwable $exception) { + $this->logger->critical((string) $exception); + } + }); + }); + + $this->server->start(); + } + + /** + * Close the server. + */ + public function close(): bool + { + $this->server->shutdown(); + + return true; + } +} diff --git a/src/engine/src/Http/ServerFactory.php b/src/engine/src/Http/ServerFactory.php new file mode 100644 index 000000000..5e3b91c46 --- /dev/null +++ b/src/engine/src/Http/ServerFactory.php @@ -0,0 +1,29 @@ +logger); + + return $server->bind($name, $port); + } +} diff --git a/src/engine/src/Http/Stream.php b/src/engine/src/Http/Stream.php new file mode 100755 index 000000000..cb17ca983 --- /dev/null +++ b/src/engine/src/Http/Stream.php @@ -0,0 +1,229 @@ +size = strlen($this->contents); + $this->writable = true; + } + + /** + * Reads all data from the stream into a string, from the beginning to end. + * This method MUST attempt to seek to the beginning of the stream before + * reading data and read the stream until the end is reached. + * Warning: This could attempt to load a large amount of data into memory. + * This method MUST NOT raise an exception in order to conform with PHP's + * string casting operations. + * + * @see http://php.net/manual/en/language.oop5.magic.php#object.tostring + */ + public function __toString(): string + { + try { + return $this->getContents(); + } catch (Throwable) { + return ''; + } + } + + /** + * Closes the stream and any underlying resources. + */ + public function close(): void + { + $this->detach(); + } + + /** + * Separates any underlying resources from the stream. + * After the stream has been detached, the stream is in an unusable state. + * + * @return null|resource Underlying PHP stream, if any + */ + public function detach() + { + $this->contents = ''; + $this->size = 0; + $this->writable = false; + + return null; + } + + /** + * Get the size of the stream if known. + * + * @return null|int returns the size in bytes if known, or null if unknown + */ + public function getSize(): ?int + { + if (! $this->size) { + $this->size = strlen($this->getContents()); + } + return $this->size; + } + + /** + * Returns the current position of the file read/write pointer. + * + * @return int Position of the file pointer + * @throws RuntimeException on error + */ + public function tell(): int + { + throw new RuntimeException('Cannot determine the position of a SwooleStream'); + } + + /** + * Returns true if the stream is at the end of the stream. + */ + public function eof(): bool + { + return $this->getSize() === 0; + } + + /** + * Returns whether or not the stream is seekable. + */ + public function isSeekable(): bool + { + return false; + } + + /** + * Seek to a position in the stream. + * + * @see http://www.php.net/manual/en/function.fseek.php + * @param int $offset Stream offset + * @param int $whence Specifies how the cursor position will be calculated + * based on the seek offset. Valid values are identical to the built-in + * PHP $whence values for `fseek()`. SEEK_SET: Set position equal to + * offset bytes SEEK_CUR: Set position to current location plus offset + * SEEK_END: Set position to end-of-stream plus offset. + * @throws RuntimeException on failure + */ + public function seek($offset, $whence = SEEK_SET): void + { + throw new RuntimeException('Cannot seek a SwooleStream'); + } + + /** + * Seek to the beginning of the stream. + * If the stream is not seekable, this method will raise an exception; + * otherwise, it will perform a seek(0). + * + * @throws RuntimeException on failure + * @see http://www.php.net/manual/en/function.fseek.php + * @see seek() + */ + public function rewind(): void + { + $this->seek(0); + } + + /** + * Returns whether or not the stream is writable. + */ + public function isWritable(): bool + { + return $this->writable; + } + + /** + * Write data to the stream. + * + * @param string $string the string that is to be written + * @return int returns the number of bytes written to the stream + * @throws RuntimeException on failure + */ + public function write($string): int + { + if (! $this->writable) { + throw new RuntimeException('Cannot write to a non-writable stream'); + } + + $size = strlen($string); + + $this->contents .= $string; + $this->size += $size; + + return $size; + } + + /** + * Returns whether or not the stream is readable. + */ + public function isReadable(): bool + { + return true; + } + + /** + * Read data from the stream. + * + * @param int $length Read up to $length bytes from the object and return + * them. Fewer than $length bytes may be returned if underlying stream + * call returns fewer bytes. + * @return string returns the data read from the stream, or an empty string + * if no bytes are available + * @throws RuntimeException if an error occurs + */ + public function read($length): string + { + if ($length >= $this->getSize()) { + $result = $this->contents; + $this->contents = ''; + $this->size = 0; + } else { + $result = substr($this->contents, 0, $length); + $this->contents = substr($this->contents, $length); + $this->size = $this->getSize() - $length; + } + + return $result; + } + + /** + * Returns the remaining contents in a string. + * + * @throws RuntimeException if unable to read or an error occurs while + * reading + */ + public function getContents(): string + { + return $this->contents; + } + + /** + * Get stream metadata as an associative array or retrieve a specific key. + * The keys returned are identical to the keys returned from PHP's + * stream_get_meta_data() function. + * + * @see http://php.net/manual/en/function.stream-get-meta-data.php + * @param string $key specific metadata to retrieve + * @return null|array|mixed Returns an associative array if no key is + * provided. Returns a specific key value if a key is provided and the + * value is found, or null if the key is not found. + */ + public function getMetadata($key = null) + { + throw new BadMethodCallException('Not implemented'); + } +} diff --git a/src/engine/src/Http/V2/Client.php b/src/engine/src/Http/V2/Client.php new file mode 100644 index 000000000..86b01eee4 --- /dev/null +++ b/src/engine/src/Http/V2/Client.php @@ -0,0 +1,126 @@ +client = new HTTP2Client($host, $port, $ssl); + + if ($settings) { + $this->client->set($settings); + } + + $this->client->connect(); + } + + /** + * Set the client settings. + */ + public function set(array $settings): bool + { + return $this->client->set($settings); + } + + /** + * Send an HTTP/2 request. + */ + public function send(RequestInterface $request): int + { + $res = $this->client->send($this->transformRequest($request)); + if ($res === false) { + throw new HttpClientException($this->client->errMsg, $this->client->errCode); + } + + return $res; + } + + /** + * Receive an HTTP/2 response. + */ + public function recv(float $timeout = 0): ResponseInterface + { + $response = $this->client->recv($timeout); + if ($response === false) { + throw new HttpClientException($this->client->errMsg, $this->client->errCode); + } + + return $this->transformResponse($response); + } + + /** + * Write data to a stream. + */ + public function write(int $streamId, mixed $data, bool $end = false): bool + { + return $this->client->write($streamId, $data, $end); + } + + /** + * Send a ping frame. + */ + public function ping(): bool + { + return $this->client->ping(); + } + + /** + * Close the connection. + */ + public function close(): bool + { + return $this->client->close(); + } + + /** + * Determine if the client is connected. + */ + public function isConnected(): bool + { + return $this->client->connected; + } + + /** + * Transform a Swoole response to a response interface. + */ + private function transformResponse(SwResponse $response): ResponseInterface + { + return new Response( + $response->streamId, + $response->statusCode, + $response->headers ?? [], + $response->data, + ); + } + + /** + * Transform a request interface to a Swoole request. + */ + private function transformRequest(RequestInterface $request): SwRequest + { + $req = new SwRequest(); + $req->method = $request->getMethod(); + $req->path = $request->getPath(); + $req->headers = $request->getHeaders(); + $req->data = $request->getBody(); + $req->pipeline = $request->isPipeline(); + $req->usePipelineRead = $request->isPipeline(); // @phpstan-ignore property.notFound (exists in Swoole 5.1.0+) + return $req; + } +} diff --git a/src/engine/src/Http/V2/ClientFactory.php b/src/engine/src/Http/V2/ClientFactory.php new file mode 100644 index 000000000..46dcf63fe --- /dev/null +++ b/src/engine/src/Http/V2/ClientFactory.php @@ -0,0 +1,19 @@ +path; + } + + /** + * Set the request path. + */ + public function setPath(string $path): void + { + $this->path = $path; + } + + /** + * Get the request method. + */ + public function getMethod(): string + { + return $this->method; + } + + /** + * Set the request method. + */ + public function setMethod(string $method): void + { + $this->method = $method; + } + + /** + * Get the request body. + */ + public function getBody(): string + { + return $this->body; + } + + /** + * Set the request body. + */ + public function setBody(string $body): void + { + $this->body = $body; + } + + /** + * Get the request headers. + */ + public function getHeaders(): array + { + return $this->headers; + } + + /** + * Set the request headers. + */ + public function setHeaders(array $headers): void + { + $this->headers = $headers; + } + + /** + * Determine if this is a pipeline request. + */ + public function isPipeline(): bool + { + return $this->pipeline; + } + + /** + * Set whether this is a pipeline request. + */ + public function setPipeline(bool $pipeline): void + { + $this->pipeline = $pipeline; + } +} diff --git a/src/engine/src/Http/V2/Response.php b/src/engine/src/Http/V2/Response.php new file mode 100644 index 000000000..dc53b826f --- /dev/null +++ b/src/engine/src/Http/V2/Response.php @@ -0,0 +1,53 @@ +streamId; + } + + /** + * Get the HTTP status code. + */ + public function getStatusCode(): int + { + return $this->statusCode; + } + + /** + * Get the response headers. + */ + public function getHeaders(): array + { + return $this->headers; + } + + /** + * Get the response body. + */ + public function getBody(): ?string + { + return $this->body; + } +} diff --git a/src/engine/src/Http/WritableConnection.php b/src/engine/src/Http/WritableConnection.php new file mode 100644 index 000000000..ed8c44fb3 --- /dev/null +++ b/src/engine/src/Http/WritableConnection.php @@ -0,0 +1,44 @@ +response->write($data); + } + + /** + * Get the underlying socket. + * + * @return Response + */ + public function getSocket(): mixed + { + return $this->response; + } + + /** + * End the connection. + */ + public function end(): ?bool + { + return $this->response->end(); + } +} diff --git a/src/engine/src/ResponseEmitter.php b/src/engine/src/ResponseEmitter.php new file mode 100644 index 000000000..00d50e7b6 --- /dev/null +++ b/src/engine/src/ResponseEmitter.php @@ -0,0 +1,98 @@ +header['Upgrade'] ?? '') === 'websocket') { + return; + } + $this->buildSwooleResponse($connection, $response); + $content = $response->getBody(); + if ($content instanceof FileInterface) { + $connection->sendfile($content->getFilename()); + return; + } + + if ($withContent) { + $connection->end((string) $content); + } else { + $connection->end(); + } + } catch (Throwable $exception) { + $this->logger?->critical((string) $exception); + } + } + + /** + * Build the Swoole response from a PSR-7 response. + */ + protected function buildSwooleResponse(Response $swooleResponse, ResponseInterface $response): void + { + // Headers + foreach ($response->getHeaders() as $key => $value) { + $swooleResponse->header($key, $value); + } + + if ($response instanceof HyperfResponse) { + // Cookies + foreach ((array) $response->getCookies() as $domain => $paths) { + foreach ($paths ?? [] as $path => $item) { + foreach ($item ?? [] as $name => $cookie) { + if ($cookie instanceof Cookie) { + $value = $cookie->isRaw() ? $cookie->getValue() : rawurlencode($cookie->getValue()); + $swooleResponse->rawcookie($cookie->getName(), $value, $cookie->getExpiresTime(), $cookie->getPath(), $cookie->getDomain(), $cookie->isSecure(), $cookie->isHttpOnly(), (string) $cookie->getSameSite()); + } + } + } + } + + // Trailers + foreach ($response->getTrailers() as $key => $value) { + $swooleResponse->trailer($key, $value); + } + } + + // Status code + $swooleResponse->status($response->getStatusCode(), $response->getReasonPhrase()); + } + + /** + * Determine if all methods exist on an object. + */ + protected function isMethodsExists(object $object, array $methods): bool + { + foreach ($methods as $method) { + if (! method_exists($object, $method)) { + return false; + } + } + return true; + } +} diff --git a/src/engine/src/SafeSocket.php b/src/engine/src/SafeSocket.php new file mode 100644 index 000000000..0e8b23939 --- /dev/null +++ b/src/engine/src/SafeSocket.php @@ -0,0 +1,162 @@ +channel = new Channel($capacity); + } + + /** + * Set the socket option. + */ + public function setSocketOption(SocketOptionInterface $option): void + { + $this->option = $option; + } + + /** + * Get the socket option. + */ + public function getSocketOption(): ?SocketOptionInterface + { + return $this->option; + } + + /** + * Send all data to the socket. + * + * @throws SocketTimeoutException when send data timeout + * @throws SocketClosedException when the client is closed + */ + public function sendAll(string $data, float $timeout = 0): false|int + { + $this->loop(); + + $res = $this->channel->push([$data, $timeout], $timeout); + if ($res === false) { + if ($this->channel->isClosing()) { + $this->throw && throw new SocketClosedException('The channel is closed.'); + } + if ($this->channel->isTimeout()) { + $this->throw && throw new SocketTimeoutException('The channel is full.'); + } + + return false; + } + return strlen($data); + } + + /** + * Receive all data from the socket. + * + * @throws SocketTimeoutException when send data timeout + * @throws SocketClosedException when the client is closed + */ + public function recvAll(int $length = 65536, float $timeout = 0): false|string + { + $res = $this->socket->recvAll($length, $timeout); + if (! $res) { + if ($this->socket->errCode === SOCKET_ETIMEDOUT) { + $this->throw && throw new SocketTimeoutException('Recv timeout'); + } + + $this->throw && throw new SocketClosedException('The socket is closed.'); + } + + return $res; + } + + /** + * Receive a packet from the socket. + * + * @throws SocketTimeoutException when send data timeout + * @throws SocketClosedException when the client is closed + */ + public function recvPacket(float $timeout = 0): false|string + { + $res = $this->socket->recvPacket($timeout); + if (! $res) { + if ($this->socket->errCode === SOCKET_ETIMEDOUT) { + $this->throw && throw new SocketTimeoutException('Recv timeout'); + } + + $this->throw && throw new SocketClosedException('The socket is closed.'); + } + + return $res; + } + + /** + * Close the socket. + */ + public function close(): bool + { + $this->channel->close(); + + return $this->socket->close(); + } + + /** + * Set the logger. + */ + public function setLogger(?LoggerInterface $logger): static + { + $this->logger = $logger; + return $this; + } + + /** + * Start the send loop. + */ + protected function loop(): void + { + if ($this->loop) { + return; + } + + $this->loop = true; + + Coroutine::create(function () { + try { + while (true) { + $data = $this->channel->pop(-1); + if ($this->channel->isClosing()) { + return; + } + + [$data, $timeout] = $data; + + $this->socket->sendAll($data, $timeout); + } + } catch (Throwable $exception) { + $this->logger?->critical((string) $exception); + } + }); + } +} diff --git a/src/engine/src/Signal.php b/src/engine/src/Signal.php new file mode 100644 index 000000000..4e21524fe --- /dev/null +++ b/src/engine/src/Signal.php @@ -0,0 +1,19 @@ +option = $option; + } + + /** + * Get the socket option. + */ + public function getSocketOption(): ?SocketOptionInterface + { + return $this->option; + } +} diff --git a/src/engine/src/Socket/SocketFactory.php b/src/engine/src/Socket/SocketFactory.php new file mode 100644 index 000000000..4d54db9e4 --- /dev/null +++ b/src/engine/src/Socket/SocketFactory.php @@ -0,0 +1,40 @@ +setSocketOption($option); + + if ($protocol = $option->getProtocol()) { + $socket->setProtocol($protocol); + } + + if ($option->getTimeout() === null) { + $res = $socket->connect($option->getHost(), $option->getPort()); + } else { + $res = $socket->connect($option->getHost(), $option->getPort(), $option->getTimeout()); + } + + if (! $res) { + throw new SocketConnectException($socket->errMsg, $socket->errCode); + } + + return $socket; + } +} diff --git a/src/engine/src/Socket/SocketOption.php b/src/engine/src/Socket/SocketOption.php new file mode 100644 index 000000000..ca04e0482 --- /dev/null +++ b/src/engine/src/Socket/SocketOption.php @@ -0,0 +1,53 @@ +host; + } + + /** + * Get the port. + */ + public function getPort(): int + { + return $this->port; + } + + /** + * Get the connection timeout in seconds. + */ + public function getTimeout(): ?float + { + return $this->timeout; + } + + /** + * Get the protocol configuration. + */ + public function getProtocol(): array + { + return $this->protocol; + } +} diff --git a/src/engine/src/WebSocket/Frame.php b/src/engine/src/WebSocket/Frame.php new file mode 100644 index 000000000..cc2488714 --- /dev/null +++ b/src/engine/src/WebSocket/Frame.php @@ -0,0 +1,206 @@ +setPayloadData($payloadData); + } + + public function __toString() + { + return $this->toString(); + } + + public function getOpcode(): int + { + return $this->opcode; + } + + public function setOpcode(int $opcode): static + { + $this->opcode = $opcode; + return $this; + } + + public function withOpcode(int $opcode): static + { + return (clone $this)->setOpcode($opcode); + } + + public function getFin(): bool + { + return $this->fin; + } + + public function setFin(bool $fin): static + { + $this->fin = $fin; + return $this; + } + + public function withFin(bool $fin): static + { + return (clone $this)->setFin($fin); + } + + public function getRSV1(): bool + { + return $this->rsv1; + } + + public function setRSV1(bool $rsv1): static + { + $this->rsv1 = $rsv1; + return $this; + } + + public function withRSV1(bool $rsv1): static + { + return (clone $this)->setRSV1($rsv1); + } + + public function getRSV2(): bool + { + return $this->rsv2; + } + + public function setRSV2(bool $rsv2): static + { + $this->rsv2 = $rsv2; + return $this; + } + + public function withRSV2(bool $rsv2): static + { + return (clone $this)->setRSV2($rsv2); + } + + public function getRSV3(): bool + { + return $this->rsv3; + } + + public function setRSV3(bool $rsv3): static + { + $this->rsv3 = $rsv3; + return $this; + } + + public function withRSV3(bool $rsv3): static + { + return (clone $this)->setRSV3($rsv3); + } + + public function getPayloadLength(): int + { + return $this->payloadData->getSize() ?? 0; + } + + public function setPayloadLength(int $payloadLength): static + { + $this->payloadLength = $payloadLength; + return $this; + } + + public function withPayloadLength(int $payloadLength): static + { + return (clone $this)->setPayloadLength($payloadLength); + } + + public function getMask(): bool + { + return ! empty($this->maskingKey); + } + + public function getMaskingKey(): string + { + return $this->maskingKey; + } + + public function setMaskingKey(string $maskingKey): static + { + $this->maskingKey = $maskingKey; + return $this; + } + + public function withMaskingKey(string $maskingKey): static + { + return (clone $this)->setMaskingKey($maskingKey); + } + + public function getPayloadData(): StreamInterface + { + return $this->payloadData; + } + + public function setPayloadData(mixed $payloadData): static + { + $this->payloadData = new Stream((string) $payloadData); + return $this; + } + + public function withPayloadData(mixed $payloadData): static + { + return (clone $this)->setPayloadData($payloadData); + } + + public function toString(bool $withoutPayloadData = false): string + { + return SwooleFrame::pack( + (string) $this->getPayloadData(), + $this->getOpcode(), + swoole_get_flags_from_frame($this) + ); + } + + public static function from(mixed $frame): static + { + if (! $frame instanceof SwooleFrame) { + throw new InvalidArgumentException('The frame is invalid.'); + } + + return new static( + (bool) ($frame->flags & SWOOLE_WEBSOCKET_FLAG_FIN), + (bool) ($frame->flags & SWOOLE_WEBSOCKET_FLAG_RSV1), + (bool) ($frame->flags & SWOOLE_WEBSOCKET_FLAG_RSV2), + (bool) ($frame->flags & SWOOLE_WEBSOCKET_FLAG_RSV3), + $frame->opcode, + strlen($frame->data), + $frame->flags & SWOOLE_WEBSOCKET_FLAG_MASK ? '258E' : '', + $frame->data + ); + } +} diff --git a/src/engine/src/WebSocket/Opcode.php b/src/engine/src/WebSocket/Opcode.php new file mode 100644 index 000000000..49185e3aa --- /dev/null +++ b/src/engine/src/WebSocket/Opcode.php @@ -0,0 +1,20 @@ +getPayloadData(); + $flags = swoole_get_flags_from_frame($frame); + + if ($this->connection instanceof SwooleResponse) { + $this->connection->push($data, $frame->getOpcode(), $flags); + return true; + } + + if ($this->connection instanceof Server) { + $this->connection->push($this->fd, $data, $frame->getOpcode(), $flags); + return true; + } + + throw new InvalidArgumentException('The websocket connection is invalid.'); + } + + /** + * Initialize the file descriptor from a frame or request. + */ + public function init(mixed $frame): static + { + switch (true) { + case is_int($frame): + $this->fd = $frame; + break; + case $frame instanceof Request || $frame instanceof SwooleFrame: + $this->fd = $frame->fd; + break; + } + + return $this; + } + + /** + * Get the file descriptor. + */ + public function getFd(): int + { + return $this->fd; + } + + /** + * Close the WebSocket connection. + */ + public function close(): bool + { + if ($this->connection instanceof SwooleResponse) { + return $this->connection->close(); + } + + if ($this->connection instanceof Server) { + return $this->connection->disconnect($this->fd); + } + + return false; + } +} diff --git a/src/engine/src/WebSocket/WebSocket.php b/src/engine/src/WebSocket/WebSocket.php new file mode 100644 index 000000000..bb152ff90 --- /dev/null +++ b/src/engine/src/WebSocket/WebSocket.php @@ -0,0 +1,84 @@ + + */ + protected array $events = []; + + /** + * Create a new WebSocket instance. + * + * @phpstan-ignore constructor.unusedParameter (request kept for API consistency) + */ + public function __construct(Response $connection, Request $request, protected ?LoggerInterface $logger = null) + { + $this->connection = $connection; + $this->connection->upgrade(); + } + + /** + * Register an event handler. + */ + public function on(string $event, callable $callback): void + { + $this->events[$event] = $callback; + } + + /** + * Start the WebSocket message loop. + */ + public function start(): void + { + while (true) { + /** @var false|string|SwFrame $frame */ + $frame = $this->connection->recv(-1); // @phpstan-ignore arguments.count (recv accepts timeout parameter) + if ($frame === false) { + $this->logger?->warning( + sprintf( + '%s:(%s) %s', + 'Websocket recv failed:', + swoole_last_error(), + swoole_strerror(swoole_last_error(), 9) + ) + ); + } + + if ($frame === false || $frame instanceof CloseFrame || $frame === '') { + if ($callback = $this->events[static::ON_CLOSE] ?? null) { + $callback($this->connection, $this->connection->fd); + } + break; + } + + switch ($frame->opcode) { + case Opcode::PING: + $this->connection->push('', Opcode::PONG); + break; + case Opcode::PONG: + break; + default: + if ($callback = $this->events[static::ON_MESSAGE] ?? null) { + $callback($this->connection, $frame); + } + } + } + + $this->connection = null; + $this->events = []; + } +} diff --git a/src/event/composer.json b/src/event/composer.json index 2703e2f00..84b42536d 100644 --- a/src/event/composer.json +++ b/src/event/composer.json @@ -30,12 +30,11 @@ ] }, "require": { - "php": "^8.2", + "php": "^8.4", "hyperf/event": "~3.1.0", - "hyperf/collection": "~3.1.0", - "hyperf/context": "~3.1.0", - "hyperf/stringable": "~3.1.0", - "hypervel/bus": "~0.1", + "hypervel/collections": "^0.4", + "hypervel/context": "^0.4", + "hypervel/bus": "^0.4", "laravel/serializable-closure": "^1.3" }, "suggest": { @@ -49,7 +48,7 @@ "config": "Hypervel\\Event\\ConfigProvider" }, "branch-alias": { - "dev-main": "0.3-dev" + "dev-main": "0.4-dev" } } } diff --git a/src/event/illuminate/CallQueuedListener.php b/src/event/illuminate/CallQueuedListener.php index 53346030c..d73c1f6fe 100644 --- a/src/event/illuminate/CallQueuedListener.php +++ b/src/event/illuminate/CallQueuedListener.php @@ -6,10 +6,10 @@ use DateInterval; use DateTimeInterface; -use Hyperf\Context\ApplicationContext; use Hypervel\Bus\Queueable; -use Hypervel\Queue\Contracts\Job; -use Hypervel\Queue\Contracts\ShouldQueue; +use Hypervel\Context\ApplicationContext; +use Hypervel\Contracts\Queue\Job; +use Hypervel\Contracts\Queue\ShouldQueue; use Hypervel\Queue\InteractsWithQueue; use Throwable; diff --git a/src/event/src/CallQueuedListener.php b/src/event/src/CallQueuedListener.php index 8b842ff20..fab1f6815 100644 --- a/src/event/src/CallQueuedListener.php +++ b/src/event/src/CallQueuedListener.php @@ -6,10 +6,10 @@ use DateInterval; use DateTimeInterface; -use Hyperf\Context\ApplicationContext; use Hypervel\Bus\Queueable; -use Hypervel\Queue\Contracts\Job; -use Hypervel\Queue\Contracts\ShouldQueue; +use Hypervel\Context\ApplicationContext; +use Hypervel\Contracts\Queue\Job; +use Hypervel\Contracts\Queue\ShouldQueue; use Hypervel\Queue\InteractsWithQueue; use Throwable; diff --git a/src/event/src/Contracts/ListenerProvider.php b/src/event/src/Contracts/ListenerProvider.php index 451a948fe..6ab78915e 100644 --- a/src/event/src/Contracts/ListenerProvider.php +++ b/src/event/src/Contracts/ListenerProvider.php @@ -16,7 +16,7 @@ public function getListenersForEvent(object|string $event): iterable; /** * Register an event listener with the listener provider. */ - public function on(string $event, array|callable|string $listener, int $priority): void; + public function on(string $event, array|callable|string $listener): void; /** * Get all of the listeners for a given event name. diff --git a/src/event/src/EventDispatcher.php b/src/event/src/EventDispatcher.php index d6be8df42..71ca81e45 100644 --- a/src/event/src/EventDispatcher.php +++ b/src/event/src/EventDispatcher.php @@ -6,21 +6,21 @@ use Closure; use Exception; -use Hyperf\Collection\Arr; -use Hyperf\Context\ApplicationContext; -use Hyperf\Context\Context; -use Hyperf\Stringable\Str; -use Hypervel\Broadcasting\Contracts\Factory as BroadcastFactory; -use Hypervel\Broadcasting\Contracts\ShouldBroadcast; -use Hypervel\Database\TransactionManager; -use Hypervel\Event\Contracts\Dispatcher as EventDispatcherContract; +use Hypervel\Context\ApplicationContext; +use Hypervel\Context\Context; +use Hypervel\Contracts\Broadcasting\Factory as BroadcastFactory; +use Hypervel\Contracts\Broadcasting\ShouldBroadcast; +use Hypervel\Contracts\Event\Dispatcher as EventDispatcherContract; +use Hypervel\Contracts\Event\ShouldDispatchAfterCommit; +use Hypervel\Contracts\Event\ShouldHandleEventsAfterCommit; +use Hypervel\Contracts\Queue\Factory as QueueFactoryContract; +use Hypervel\Contracts\Queue\ShouldBeEncrypted; +use Hypervel\Contracts\Queue\ShouldQueue; +use Hypervel\Contracts\Queue\ShouldQueueAfterCommit; +use Hypervel\Database\DatabaseTransactionsManager; use Hypervel\Event\Contracts\ListenerProvider as ListenerProviderContract; -use Hypervel\Event\Contracts\ShouldDispatchAfterCommit; -use Hypervel\Event\Contracts\ShouldHandleEventsAfterCommit; -use Hypervel\Queue\Contracts\Factory as QueueFactoryContract; -use Hypervel\Queue\Contracts\ShouldBeEncrypted; -use Hypervel\Queue\Contracts\ShouldQueue; -use Hypervel\Queue\Contracts\ShouldQueueAfterCommit; +use Hypervel\Support\Arr; +use Hypervel\Support\Str; use Hypervel\Support\Traits\ReflectsClosures; use Illuminate\Events\CallQueuedListener; use Psr\Container\ContainerInterface; @@ -61,7 +61,7 @@ public function __construct( /** * Fire an event and call the listeners. */ - public function dispatch(object|string $event, mixed $payload = [], bool $halt = false): object|string + public function dispatch(object|string $event, mixed $payload = [], bool $halt = false): mixed { if ($this->shouldDeferEvent($event)) { Context::override('__event.deferred_events', function (?array $events) use ($event, $payload, $halt) { @@ -113,16 +113,15 @@ protected function dump(mixed $listener, object|string $event): void } /** - * Register an event listener with the listener provider. + * Register an event listener with the dispatcher. */ public function listen( array|Closure|QueuedClosure|string $events, - array|Closure|int|QueuedClosure|string|null $listener = null, - int $priority = ListenerData::DEFAULT_PRIORITY + array|Closure|QueuedClosure|string|null $listener = null ): void { if ($events instanceof Closure) { foreach ((array) $this->firstClosureParameterTypes($events) as $event) { - $this->listeners->on($event, $events, is_int($listener) ? $listener : $priority); + $this->listeners->on($event, $events); } return; @@ -130,7 +129,7 @@ public function listen( if ($events instanceof QueuedClosure) { foreach ((array) $this->firstClosureParameterTypes($events->closure) as $event) { - $this->listeners->on($event, $events->resolve(), is_int($listener) ? $listener : $priority); + $this->listeners->on($event, $events->resolve()); } return; @@ -141,14 +140,14 @@ public function listen( } foreach ((array) $events as $event) { - $this->listeners->on($event, $listener, $priority); + $this->listeners->on($event, $listener); } } /** * 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,18 +155,36 @@ 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); } + // For object events, the event object itself is the payload (Laravel behavior) + // The original $payload is ignored for object events + if (is_object($event)) { + $payload = [$event]; + } else { + // Wrap payload in array like Laravel does + $payload = Arr::wrap($payload); + } + + // Wildcard listeners need the event name (string), not the event object + $eventName = is_object($event) ? get_class($event) : $event; + foreach ($this->getListeners($event) as $listener) { - $response = $listener($event, $payload); + $response = $listener($eventName, $payload); $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; } } @@ -218,8 +235,11 @@ protected function prepareListeners(object|string $eventName): array { $listeners = []; - foreach ($this->listeners->getListenersForEvent($eventName) as $listener) { - $listeners[] = $this->makeListener($listener); + foreach ($this->listeners->getListenersForEvent($eventName) as $listenerData) { + $listeners[] = $this->makeListener( + $listenerData['listener'], + $listenerData['isWildcard'] + ); } return $listeners; @@ -228,34 +248,34 @@ protected function prepareListeners(object|string $eventName): array /** * Create a callable for a class based listener. */ - protected function makeListener(array|Closure|string $listener): Closure + protected function makeListener(array|Closure|string $listener, bool $wildcard = false): Closure { if (is_string($listener) || (is_array($listener) && ((isset($listener[0]) && is_string($listener[0])) || is_callable($listener)))) { - return $this->createClassListener($listener); + return $this->createClassListener($listener, $wildcard); } - return function ($event, $payload) use ($listener) { - if (is_array($payload)) { + return function ($event, $payload) use ($listener, $wildcard) { + if ($wildcard) { return $listener($event, ...array_values($payload)); } - return $listener($event, $payload); + return $listener(...array_values($payload)); }; } /** * Create a class based listener. */ - protected function createClassListener(array|string $listener): Closure + protected function createClassListener(array|string $listener, bool $wildcard = false): Closure { - return function (object|string $event, mixed $payload) use ($listener) { + return function (object|string $event, mixed $payload) use ($listener, $wildcard) { $callable = $this->createClassCallable($listener); - if (is_array($payload)) { + if ($wildcard) { return $callable($event, ...array_values($payload)); } - return $callable($event, $payload); + return $callable(...array_values($payload)); }; } @@ -378,8 +398,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); } @@ -463,17 +487,17 @@ protected function queueHandler(object|string $class, string $method, array $arg [$listener, $job] = $this->createListenerAndJob($class, $method, $arguments); $connection = $this->resolveQueue()->connection(method_exists($listener, 'viaConnection') - ? (isset($arguments[1]) ? $listener->viaConnection($arguments[1]) : $listener->viaConnection()) + ? (isset($arguments[0]) ? $listener->viaConnection($arguments[0]) : $listener->viaConnection()) : $listener->connection ?? null); $queue = method_exists($listener, 'viaQueue') - ? (isset($arguments[1]) ? $listener->viaQueue($arguments[1]) : $listener->viaQueue()) + ? (isset($arguments[0]) ? $listener->viaQueue($arguments[0]) : $listener->viaQueue()) : $listener->queue ?? null; $queue = is_null($queue) ? null : enum_value($queue); $delay = method_exists($listener, 'withDelay') - ? (isset($arguments[1]) ? $listener->withDelay($arguments[1]) : $listener->withDelay()) + ? (isset($arguments[0]) ? $listener->withDelay($arguments[0]) : $listener->withDelay()) : $listener->delay ?? null; is_null($delay) @@ -517,7 +541,6 @@ protected function propagateListenerOptions(mixed $listener, CallQueuedListener $job->failOnTimeout = $listener->failOnTimeout ?? false; $job->tries = $listener->tries ?? null; - unset($data[0]); $job->through(array_merge( method_exists($listener, 'middleware') ? $listener->middleware(...$data) : [], $listener->middleware ?? [] diff --git a/src/event/src/EventDispatcherFactory.php b/src/event/src/EventDispatcherFactory.php index 066addb46..71a1ddc14 100644 --- a/src/event/src/EventDispatcherFactory.php +++ b/src/event/src/EventDispatcherFactory.php @@ -5,7 +5,7 @@ namespace Hypervel\Event; use Hyperf\Contract\StdoutLoggerInterface; -use Hypervel\Queue\Contracts\Factory as QueueFactoryContract; +use Hypervel\Contracts\Queue\Factory as QueueFactoryContract; use Psr\Container\ContainerInterface; use Psr\EventDispatcher\ListenerProviderInterface; @@ -19,6 +19,12 @@ public function __invoke(ContainerInterface $container) $dispatcher->setQueueResolver(fn () => $container->get(QueueFactoryContract::class)); + $dispatcher->setTransactionManagerResolver( + fn () => $container->has('db.transactions') + ? $container->get('db.transactions') + : null + ); + return $dispatcher; } } diff --git a/src/event/src/Functions.php b/src/event/src/Functions.php index 511911767..65486a366 100644 --- a/src/event/src/Functions.php +++ b/src/event/src/Functions.php @@ -5,7 +5,7 @@ namespace Hypervel\Event; use Closure; -use Hyperf\Context\ApplicationContext; +use Hypervel\Context\ApplicationContext; use Psr\EventDispatcher\EventDispatcherInterface; /** diff --git a/src/event/src/ListenerData.php b/src/event/src/ListenerData.php deleted file mode 100644 index fa88e36fc..000000000 --- a/src/event/src/ListenerData.php +++ /dev/null @@ -1,22 +0,0 @@ -event = $event; - $this->listener = $listener; - $this->priority = $priority; - } -} diff --git a/src/event/src/ListenerProvider.php b/src/event/src/ListenerProvider.php index 7379e30c2..66e18ceaf 100644 --- a/src/event/src/ListenerProvider.php +++ b/src/event/src/ListenerProvider.php @@ -4,12 +4,9 @@ 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 { @@ -21,56 +18,49 @@ class ListenerProvider implements ListenerProviderContract /** * Get all of the listeners for a given event name. + * + * @return iterable */ public function getListenersForEvent(object|string $event): iterable { $eventName = is_string($event) ? $event : get_class($event); - $listeners = []; - if (! is_null($cache = $this->listenersCache[$eventName] ?? null)) { - $listeners = $cache; - } else { - $listeners = $this->getListenersUsingCondition( - $this->listeners, - fn ($_, $key) => is_string($event) ? $event === $key : $event instanceof $key - ); - - $wildcards = $this->getListenersUsingCondition( - $this->wildcards, - fn ($_, $key) => Str::is($key, $eventName) - ); - - $listeners = $listeners->merge($wildcards)->toArray(); - $this->listenersCache[$eventName] = $listeners; + if (isset($this->listenersCache[$eventName])) { + return $this->listenersCache[$eventName]; } - $queue = new SplPriorityQueue(); + $listeners = $this->getListenersUsingCondition( + $this->listeners, + fn ($_, $key) => is_string($event) ? $event === $key : $event instanceof $key, + isWildcard: false + ); - foreach ($listeners as $index => $listener) { - $queue->insert($listener, $index * -1); - } + $wildcards = $this->getListenersUsingCondition( + $this->wildcards, + fn ($_, $key) => Str::is($key, $eventName), + isWildcard: true + ); - return $queue; + $result = $listeners->merge($wildcards)->values()->all(); + $this->listenersCache[$eventName] = $result; + + return $result; } /** * Register an event listener with the listener provider. */ - public function on( - string $event, - array|callable|string $listener, - int $priority = ListenerData::DEFAULT_PRIORITY - ): void { + public function on(string $event, array|callable|string $listener): void + { $this->listenersCache = []; - $listenerData = new ListenerData($event, $listener, $priority); if ($this->isWildcardEvent($event)) { - $this->wildcards[$event][] = $listenerData; + $this->wildcards[$event][] = $listener; return; } - $this->listeners[$event][] = $listenerData; + $this->listeners[$event][] = $listener; } /** @@ -123,24 +113,15 @@ public function hasWildcard(string $event): bool /** * Get listeners using condition. + * + * @return Collection */ - protected function getListenersUsingCondition(array $listeners, callable $filter): Collection + protected function getListenersUsingCondition(array $listeners, callable $filter, bool $isWildcard = false): Collection { return collect($listeners) ->filter($filter) ->flatten(1) - ->map(function ($listener, $index) { - return [ - 'listener' => $listener->listener, - 'priority' => $listener->priority, - 'index' => $index, - ]; - }) - ->sortBy([ - ['priority', 'desc'], - ['index', 'asc'], - ]) - ->pluck('listener'); + ->map(fn ($listener) => ['listener' => $listener, 'isWildcard' => $isWildcard]); } /** diff --git a/src/event/src/ListenerProviderFactory.php b/src/event/src/ListenerProviderFactory.php index aa7d0b2a9..e3b8ce1c1 100644 --- a/src/event/src/ListenerProviderFactory.php +++ b/src/event/src/ListenerProviderFactory.php @@ -4,58 +4,72 @@ namespace Hypervel\Event; -use Hyperf\Contract\ConfigInterface; use Hyperf\Di\Annotation\AnnotationCollector; use Hyperf\Event\Annotation\Listener; use Hyperf\Event\Contract\ListenerInterface; -use Hyperf\Event\ListenerData; use Psr\Container\ContainerInterface; +/** + * Factory for creating and configuring the ListenerProvider. + * + * Registers listeners from two sources: + * 1. Config-based: Classes listed in the 'listeners' config array + * 2. Annotation-based: Classes with #[Listener] attribute + * + * Both sources support Hyperf's ListenerInterface pattern where listeners + * declare which events they handle via listen() and process them via process(). + */ class ListenerProviderFactory { public function __invoke(ContainerInterface $container): ListenerProvider { $listenerProvider = new ListenerProvider(); - // Register config listeners. $this->registerConfig($listenerProvider, $container); - - // Register annotation listeners. $this->registerAnnotations($listenerProvider, $container); return $listenerProvider; } + /** + * Register listeners from the 'listeners' config array. + */ protected function registerConfig(ListenerProvider $provider, ContainerInterface $container): void { - $config = $container->get(ConfigInterface::class); - foreach ($config->get('listeners', []) as $listener => $priority) { - if (is_int($listener)) { - $listener = $priority; - $priority = ListenerData::DEFAULT_PRIORITY; - } + $config = $container->get('config'); + + foreach ($config->get('listeners', []) as $key => $value) { + // Support both indexed array and associative (legacy priority) format + $listener = is_int($key) ? $value : $key; + if (is_string($listener)) { - $this->register($provider, $container, $listener, $priority); + $this->register($provider, $container, $listener); } } } + /** + * Register listeners with #[Listener] annotation. + */ protected function registerAnnotations(ListenerProvider $provider, ContainerInterface $container): void { foreach (AnnotationCollector::list() as $className => $values) { - /** @var Listener $annotation */ - if ($annotation = $values['_c'][Listener::class] ?? null) { - $this->register($provider, $container, $className, $annotation->priority); + if (isset($values['_c'][Listener::class])) { + $this->register($provider, $container, $className); } } } - protected function register(ListenerProvider $provider, ContainerInterface $container, string $listener, int $priority = ListenerData::DEFAULT_PRIORITY): void + /** + * Register a listener class implementing ListenerInterface. + */ + protected function register(ListenerProvider $provider, ContainerInterface $container, string $listener): void { $instance = $container->get($listener); + if ($instance instanceof ListenerInterface) { foreach ($instance->listen() as $event) { - $provider->on($event, [$instance, 'process'], $priority); + $provider->on($event, [$instance, 'process']); } } } diff --git a/src/event/src/NullDispatcher.php b/src/event/src/NullDispatcher.php new file mode 100644 index 000000000..2591eea8d --- /dev/null +++ b/src/event/src/NullDispatcher.php @@ -0,0 +1,127 @@ +dispatcher->listen($events, $listener); + } + + /** + * 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..c578bedb1 100644 --- a/src/filesystem/composer.json +++ b/src/filesystem/composer.json @@ -29,12 +29,12 @@ ] }, "require": { - "php": "^8.2", - "hyperf/collection": "~3.1.0", - "hyperf/macroable": "~3.1.0", - "hyperf/support": "~3.1.0", - "hyperf/conditionable": "~3.1.0", - "hypervel/object-pool": "^0.3" + "php": "^8.4", + "hypervel/collections": "^0.4", + "hypervel/macroable": "^0.4", + "hypervel/support": "^0.4", + "hypervel/conditionable": "^0.4", + "hypervel/object-pool": "^0.4" }, "suggest": { "ext-fileinfo": "Required to use the Filesystem class.", @@ -57,7 +57,7 @@ "config": "Hypervel\\Filesystem\\ConfigProvider" }, "branch-alias": { - "dev-main": "0.3-dev" + "dev-main": "0.4-dev" } } } \ No newline at end of file diff --git a/src/filesystem/src/AwsS3V3Adapter.php b/src/filesystem/src/AwsS3V3Adapter.php index 6c773ecfc..0377ee2f1 100644 --- a/src/filesystem/src/AwsS3V3Adapter.php +++ b/src/filesystem/src/AwsS3V3Adapter.php @@ -6,7 +6,7 @@ use Aws\S3\S3Client; use DateTimeInterface; -use Hyperf\Conditionable\Conditionable; +use Hypervel\Support\Traits\Conditionable; use League\Flysystem\AwsS3V3\AwsS3V3Adapter as S3Adapter; use League\Flysystem\FilesystemOperator; use RuntimeException; 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..f93db6b03 100644 --- a/src/filesystem/src/Filesystem.php +++ b/src/filesystem/src/Filesystem.php @@ -4,10 +4,114 @@ namespace Hypervel\Filesystem; -use Hyperf\Support\Filesystem\Filesystem as HyperfFilesystem; +use ErrorException; +use FilesystemIterator; +use Hypervel\Contracts\Filesystem\FileNotFoundException; +use Hypervel\Coroutine\Coroutine; +use Hypervel\Coroutine\Locker; +use Hypervel\Support\Traits\Macroable; +use Symfony\Component\Finder\Finder; +use Symfony\Component\Finder\SplFileInfo; -class Filesystem extends HyperfFilesystem +class Filesystem { + use Macroable; + + /** + * Determine if a file or directory exists. + */ + public function exists(string $path): bool + { + return file_exists($path); + } + + /** + * Get the contents of a file. + * + * @throws FileNotFoundException + */ + public function get(string $path, bool $lock = false): string + { + if ($this->isFile($path)) { + return $lock ? $this->sharedGet($path) : file_get_contents($path); + } + + throw new FileNotFoundException("File does not exist at path {$path}"); + } + + /** + * Get contents of a file with shared access. + */ + public function sharedGet(string $path): string + { + return $this->atomic($path, function ($path) { + $contents = ''; + $handle = fopen($path, 'rb'); + if ($handle) { + $wouldBlock = false; + flock($handle, LOCK_SH | LOCK_NB, $wouldBlock); + while ($wouldBlock) { + usleep(1000); + flock($handle, LOCK_SH | LOCK_NB, $wouldBlock); + } + try { + clearstatcache(true, $path); + $contents = fread($handle, $this->size($path) ?: 1); + } finally { + flock($handle, LOCK_UN); + fclose($handle); + } + } + return $contents; + }); + } + + /** + * Get the returned value of a file. + * + * @throws FileNotFoundException + */ + public function getRequire(string $path) + { + if ($this->isFile($path)) { + return require $path; + } + + throw new FileNotFoundException("File does not exist at path {$path}"); + } + + /** + * Clears file status cache. + */ + public function clearStatCache(string $path): void + { + clearstatcache(true, $path); + } + + /** + * Get the MD5 hash of the file at the given path. + */ + public function hash(string $path): string + { + return md5_file($path); + } + + /** + * Require the given file once. + */ + public function requireOnce(string $file): void + { + require_once $file; + } + + /** + * Determine if a file or directory is missing. + */ + public function missing(string $path): bool + { + return ! $this->exists($path); + } + /** * Ensure a directory exists. */ @@ -17,4 +121,443 @@ public function ensureDirectoryExists(string $path, int $mode = 0755, bool $recu $this->makeDirectory($path, $mode, $recursive); } } + + /** + * Write the contents of a file. + * + * @param resource|string $contents + * @return bool|int + */ + public function put(string $path, $contents, bool $lock = false) + { + if ($lock) { + return $this->atomic($path, function ($path) use ($contents) { + $handle = fopen($path, 'w+'); + if ($handle) { + $wouldBlock = false; + flock($handle, LOCK_EX | LOCK_NB, $wouldBlock); + while ($wouldBlock) { + usleep(1000); + flock($handle, LOCK_EX | LOCK_NB, $wouldBlock); + } + try { + fwrite($handle, $contents); + } finally { + flock($handle, LOCK_UN); + fclose($handle); + } + } + }); + } + return file_put_contents($path, $contents); + } + + /** + * Write the contents of a file, replacing it atomically if it already exists. + */ + public function replace(string $path, string $content) + { + // If the path already exists and is a symlink, get the real path... + clearstatcache(true, $path); + + $path = realpath($path) ?: $path; + + $tempPath = tempnam(dirname($path), basename($path)); + + // Fix permissions of tempPath because `tempnam()` creates it with permissions set to 0600... + chmod($tempPath, 0777 - umask()); + + file_put_contents($tempPath, $content); + + rename($tempPath, $path); + } + + /** + * Prepend to a file. + */ + public function prepend(string $path, string $data): int + { + if ($this->exists($path)) { + return $this->put($path, $data . $this->get($path)); + } + + return $this->put($path, $data); + } + + /** + * Append to a file. + */ + public function append(string $path, string $data): int + { + return file_put_contents($path, $data, FILE_APPEND); + } + + /** + * Get or set UNIX mode of a file or directory. + */ + public function chmod(string $path, ?int $mode = null) + { + if ($mode) { + return chmod($path, $mode); + } + + return substr(sprintf('%o', fileperms($path)), -4); + } + + /** + * Delete the file at a given path. + * + * @param array|string $paths + */ + public function delete($paths): bool + { + $paths = is_array($paths) ? $paths : func_get_args(); + + $success = true; + + foreach ($paths as $path) { + try { + if (! @unlink($path)) { + $success = false; + } + } catch (ErrorException) { + $success = false; + } + } + + return $success; + } + + /** + * Move a file to a new location. + */ + public function move(string $path, string $target): bool + { + return rename($path, $target); + } + + /** + * Copy a file to a new location. + */ + public function copy(string $path, string $target): bool + { + return copy($path, $target); + } + + /** + * Create a hard link to the target file or directory. + */ + public function link(string $target, string $link): bool + { + if (! $this->windowsOs()) { + return symlink($target, $link); + } + + $mode = $this->isDirectory($target) ? 'J' : 'H'; + + exec("mklink /{$mode} \"{$link}\" \"{$target}\""); + return true; + } + + /** + * Extract the file name from a file path. + */ + public function name(string $path): string + { + return pathinfo($path, PATHINFO_FILENAME); + } + + /** + * Extract the trailing name component from a file path. + */ + public function basename(string $path): string + { + return pathinfo($path, PATHINFO_BASENAME); + } + + /** + * Extract the parent directory from a file path. + */ + public function dirname(string $path): string + { + return pathinfo($path, PATHINFO_DIRNAME); + } + + /** + * Extract the file extension from a file path. + */ + public function extension(string $path): string + { + return pathinfo($path, PATHINFO_EXTENSION); + } + + /** + * Get the file type of a given file. + */ + public function type(string $path): string + { + return filetype($path); + } + + /** + * Get the mime-type of a given file. + * + * @return false|string + */ + public function mimeType(string $path) + { + return finfo_file(finfo_open(FILEINFO_MIME_TYPE), $path); + } + + /** + * Get the file size of a given file. + */ + public function size(string $path): int + { + return filesize($path); + } + + /** + * Get the file's last modification time. + */ + public function lastModified(string $path): int + { + return filemtime($path); + } + + /** + * Determine if the given path is a directory. + */ + public function isDirectory(string $directory): bool + { + return is_dir($directory); + } + + /** + * Determine if the given path is readable. + */ + public function isReadable(string $path): bool + { + return is_readable($path); + } + + /** + * Determine if the given path is writable. + */ + public function isWritable(string $path): bool + { + return is_writable($path); + } + + /** + * Determine if the given path is a file. + */ + public function isFile(string $file): bool + { + return is_file($file); + } + + /** + * Find path names matching a given pattern. + */ + public function glob(string $pattern, int $flags = 0): array + { + return glob($pattern, $flags); + } + + /** + * Get an array of all files in a directory. + * + * @return SplFileInfo[] + */ + public function files(string $directory, bool $hidden = false): array + { + return iterator_to_array( + Finder::create()->files()->ignoreDotFiles(! $hidden)->in($directory)->depth(0)->sortByName(), + false + ); + } + + /** + * Get all of the files from the given directory (recursive). + * @return SplFileInfo[] + */ + public function allFiles(string $directory, bool $hidden = false): array + { + return iterator_to_array( + Finder::create()->files()->ignoreDotFiles(! $hidden)->in($directory)->sortByName(), + false + ); + } + + /** + * Get all of the directories within a given directory. + */ + public function directories(string $directory): array + { + $directories = []; + + foreach (Finder::create()->in($directory)->directories()->depth(0)->sortByName() as $dir) { + $directories[] = $dir->getPathname(); + } + + return $directories; + } + + /** + * Create a directory. + */ + public function makeDirectory(string $path, int $mode = 0755, bool $recursive = false, bool $force = false): bool + { + if ($force) { + return @mkdir($path, $mode, $recursive); + } + + return mkdir($path, $mode, $recursive); + } + + /** + * Move a directory. + */ + public function moveDirectory(string $from, string $to, bool $overwrite = false): bool + { + if ($overwrite && $this->isDirectory($to) && ! $this->deleteDirectory($to)) { + return false; + } + + return @rename($from, $to) === true; + } + + /** + * Copy a directory from one location to another. + */ + public function copyDirectory(string $directory, string $destination, ?int $options = null): bool + { + if (! $this->isDirectory($directory)) { + return false; + } + + $options = $options ?: FilesystemIterator::SKIP_DOTS; + + // If the destination directory does not actually exist, we will go ahead and + // create it recursively, which just gets the destination prepared to copy + // the files over. Once we make the directory we'll proceed the copying. + if (! $this->isDirectory($destination)) { + $this->makeDirectory($destination, 0777, true); + } + + $items = new FilesystemIterator($directory, $options); + + foreach ($items as $item) { + // As we spin through items, we will check to see if the current file is actually + // a directory or a file. When it is actually a directory we will need to call + // back into this function recursively to keep copying these nested folders. + $target = $destination . DIRECTORY_SEPARATOR . $item->getBasename(); + + if ($item->isDir()) { + $path = $item->getPathname(); + + if (! $this->copyDirectory($path, $target, $options)) { + return false; + } + } + + // If the current items is just a regular file, we will just copy this to the new + // location and keep looping. If for some reason the copy fails we'll bail out + // and return false, so the developer is aware that the copy process failed. + else { + if (! $this->copy($item->getPathname(), $target)) { + return false; + } + } + } + + return true; + } + + /** + * Recursively delete a directory. + * + * The directory itself may be optionally preserved. + */ + public function deleteDirectory(string $directory, bool $preserve = false): bool + { + if (! $this->isDirectory($directory)) { + return false; + } + + $items = new FilesystemIterator($directory); + + foreach ($items as $item) { + // If the item is a directory, we can just recurse into the function and + // delete that sub-directory otherwise we'll just delete the file and + // keep iterating through each file until the directory is cleaned. + if ($item->isDir() && ! $item->isLink()) { + $this->deleteDirectory($item->getPathname()); + } + + // If the item is just a file, we can go ahead and delete it since we're + // just looping through and waxing all of the files in this directory + // and calling directories recursively, so we delete the real path. + else { + $this->delete($item->getPathname()); + } + } + + if (! $preserve) { + @rmdir($directory); + } + + return true; + } + + /** + * Remove all of the directories within a given directory. + */ + public function deleteDirectories(string $directory): bool + { + $allDirectories = $this->directories($directory); + + if (! empty($allDirectories)) { + foreach ($allDirectories as $directoryName) { + $this->deleteDirectory($directoryName); + } + + return true; + } + + return false; + } + + /** + * Empty the specified directory of all files and folders. + */ + public function cleanDirectory(string $directory): bool + { + return $this->deleteDirectory($directory, true); + } + + /** + * Detect whether it's Windows. + */ + public function windowsOs(): bool + { + return stripos(PHP_OS, 'win') === 0; + } + + protected function atomic($path, $callback) + { + if (Coroutine::inCoroutine()) { + try { + while (! Locker::lock($path)) { + usleep(1000); + } + return $callback($path); + } finally { + Locker::unlock($path); + } + } else { + return $callback($path); + } + } } diff --git a/src/filesystem/src/FilesystemAdapter.php b/src/filesystem/src/FilesystemAdapter.php index 51ca8d38d..42f75ec76 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\Context\ApplicationContext; +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\Conditionable; +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..880e676e3 100644 --- a/src/filesystem/src/FilesystemManager.php +++ b/src/filesystem/src/FilesystemManager.php @@ -7,13 +7,12 @@ 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; @@ -419,7 +418,7 @@ public function set(string $name, mixed $disk): static */ protected function getConfig(string $name): array { - return $this->app->get(ConfigInterface::class) + return $this->app->get('config') ->get("filesystems.disks.{$name}", []); } @@ -428,7 +427,7 @@ protected function getConfig(string $name): array */ public function getDefaultDriver(): string { - return $this->app->get(ConfigInterface::class) + return $this->app->get('config') ->get('filesystems.default'); } @@ -437,7 +436,7 @@ public function getDefaultDriver(): string */ public function getDefaultCloudDriver(): string { - return $this->app->get(ConfigInterface::class) + return $this->app->get('config') ->get('filesystems.cloud', 's3'); } 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/LocalFilesystemAdapter.php b/src/filesystem/src/LocalFilesystemAdapter.php index 377076197..f2968c674 100644 --- a/src/filesystem/src/LocalFilesystemAdapter.php +++ b/src/filesystem/src/LocalFilesystemAdapter.php @@ -6,7 +6,7 @@ use Closure; use DateTimeInterface; -use Hyperf\Conditionable\Conditionable; +use Hypervel\Support\Traits\Conditionable; use RuntimeException; class LocalFilesystemAdapter extends FilesystemAdapter diff --git a/src/filesystem/src/LockableFile.php b/src/filesystem/src/LockableFile.php index d05c3854c..b6f22cf21 100644 --- a/src/filesystem/src/LockableFile.php +++ b/src/filesystem/src/LockableFile.php @@ -6,9 +6,9 @@ use Closure; use Exception; -use Hyperf\Coroutine\Coroutine; -use Hyperf\Coroutine\Locker; -use Hypervel\Filesystem\Exceptions\LockTimeoutException; +use Hypervel\Contracts\Filesystem\LockTimeoutException; +use Hypervel\Coroutine\Coroutine; +use Hypervel\Coroutine\Locker; class LockableFile { diff --git a/src/foundation/composer.json b/src/foundation/composer.json index 5e4c23490..bd9c2e7bb 100644 --- a/src/foundation/composer.json +++ b/src/foundation/composer.json @@ -20,27 +20,25 @@ } ], "require": { - "php": "^8.2", + "php": "^8.4", "nesbot/carbon": "^2.72.6", - "hypervel/core": "^0.3", - "hypervel/filesystem": "^0.3", - "hypervel/support": "^0.3", - "hypervel/http": "^0.3", - "hypervel/validation": "^0.3", + "hypervel/core": "^0.4", + "hypervel/database": "^0.4", + "hypervel/filesystem": "^0.4", + "hypervel/support": "^0.4", + "hypervel/http": "^0.4", + "hypervel/validation": "^0.4", "hyperf/config": "~3.1.0", "hyperf/di": "~3.1.0", "hyperf/support": "~3.1.0", "hyperf/command": "~3.1.0", - "hyperf/collection": "~3.1.0", - "hyperf/context": "~3.1.0", + "hypervel/collections": "^0.4", + "hypervel/context": "^0.4", "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", + "hypervel/engine": "^0.4", "hyperf/framework": "~3.1.0", "friendsofhyperf/pretty-console": "~3.1.0", "friendsofhyperf/command-signals": "~3.1.0", @@ -67,7 +65,7 @@ ] }, "branch-alias": { - "dev-main": "0.3-dev" + "dev-main": "0.4-dev" } }, "config": { diff --git a/src/foundation/src/Application.php b/src/foundation/src/Application.php index 60fc18feb..c03799f6b 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 @@ -31,7 +30,7 @@ class Application extends Container implements ApplicationContract * * @var string */ - public const VERSION = '0.3.18'; + public const VERSION = '0.4'; /** * The base path for the Hypervel installation. @@ -550,47 +549,48 @@ 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'], - \Hyperf\Contract\ConfigInterface::class => [ + \Hypervel\Contracts\Console\Kernel::class => ['artisan'], + \Hypervel\Contracts\Config\Repository::class => [ 'config', - \Hypervel\Config\Contracts\Repository::class, + \Hypervel\Config\Repository::class, + \Hyperf\Contract\ConfigInterface::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,64 +598,66 @@ 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', \Hypervel\JWT\JWTManager::class, ], - \Hyperf\Redis\Redis::class => ['redis'], + \Hypervel\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'], + \Hypervel\Database\DatabaseTransactionsManager::class => ['db.transactions'], + \Hypervel\Database\Migrations\Migrator::class => ['migrator'], ] as $key => $aliases) { foreach ($aliases as $alias) { $this->alias($key, $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..723b4c2b7 100644 --- a/src/foundation/src/Bootstrap/RegisterFacades.php +++ b/src/foundation/src/Bootstrap/RegisterFacades.php @@ -4,9 +4,8 @@ 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; @@ -27,7 +26,7 @@ public function bootstrap(ApplicationContract $app): void // do nothing } - $configAliases = $app->get(ConfigInterface::class) + $configAliases = $app->get('config') ->get('app.aliases', []); $aliases = array_merge($composerAliases, $configAliases); diff --git a/src/foundation/src/Bootstrap/RegisterProviders.php b/src/foundation/src/Bootstrap/RegisterProviders.php index 4e938b64d..6048f0f1d 100644 --- a/src/foundation/src/Bootstrap/RegisterProviders.php +++ b/src/foundation/src/Bootstrap/RegisterProviders.php @@ -4,10 +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\Foundation\Providers\FoundationServiceProvider; +use Hypervel\Support\Arr; use Hypervel\Support\Composer; use Throwable; @@ -37,7 +36,7 @@ public function bootstrap(ApplicationContract $app): void $providers = array_unique( array_merge( $providers, - $app->get(ConfigInterface::class)->get('app.providers', []) + $app->get('config')->get('app.providers', []) ) ); diff --git a/src/foundation/src/ClassLoader.php b/src/foundation/src/ClassLoader.php index 467ce633d..5a2a1c028 100644 --- a/src/foundation/src/ClassLoader.php +++ b/src/foundation/src/ClassLoader.php @@ -7,10 +7,10 @@ use Hyperf\Di\LazyLoader\LazyLoader; use Hyperf\Di\ScanHandler\PcntlScanHandler; use Hyperf\Di\ScanHandler\ScanHandlerInterface; -use Hyperf\Support\DotenvManager; use Hypervel\Container\ScanConfig; use Hypervel\Container\Scanner as AnnotationScanner; use Hypervel\Support\Composer; +use Hypervel\Support\DotenvManager; class ClassLoader { diff --git a/src/foundation/src/Console/Commands/AboutCommand.php b/src/foundation/src/Console/Commands/AboutCommand.php index 108e17bba..55a0611a4 100644 --- a/src/foundation/src/Console/Commands/AboutCommand.php +++ b/src/foundation/src/Console/Commands/AboutCommand.php @@ -5,7 +5,7 @@ namespace Hypervel\Foundation\Console\Commands; use Closure; -use Hyperf\Contract\ConfigInterface; +use Hypervel\Config\Repository; use Hypervel\Console\Command; use Hypervel\Support\Collection; use Hypervel\Support\Composer; @@ -20,7 +20,7 @@ class AboutCommand extends Command protected string $description = 'Display basic information about your application'; public function __construct( - protected ConfigInterface $config, + protected Repository $config, protected Composer $composer, ) { parent::__construct(); diff --git a/src/foundation/src/Console/Commands/ConfigShowCommand.php b/src/foundation/src/Console/Commands/ConfigShowCommand.php index cd38ae41e..25f045d52 100644 --- a/src/foundation/src/Console/Commands/ConfigShowCommand.php +++ b/src/foundation/src/Console/Commands/ConfigShowCommand.php @@ -4,7 +4,7 @@ namespace Hypervel\Foundation\Console\Commands; -use Hyperf\Contract\ConfigInterface; +use Hypervel\Config\Repository; use Hypervel\Console\Command; use Hypervel\Support\Arr; @@ -15,7 +15,7 @@ class ConfigShowCommand extends Command protected string $description = 'Display all of the values for a given configuration file or key'; public function __construct( - protected ConfigInterface $config + protected Repository $config ) { parent::__construct(); } diff --git a/src/foundation/src/Console/Commands/ServerReloadCommand.php b/src/foundation/src/Console/Commands/ServerReloadCommand.php index 9a068b224..68778239c 100644 --- a/src/foundation/src/Console/Commands/ServerReloadCommand.php +++ b/src/foundation/src/Console/Commands/ServerReloadCommand.php @@ -4,10 +4,10 @@ namespace Hypervel\Foundation\Console\Commands; -use Hyperf\Contract\ConfigInterface; -use Hyperf\Support\Filesystem\FileNotFoundException; -use Hyperf\Support\Filesystem\Filesystem; +use Hypervel\Config\Repository; use Hypervel\Console\Command; +use Hypervel\Contracts\Filesystem\FileNotFoundException; +use Hypervel\Filesystem\Filesystem; use Psr\Container\ContainerInterface; use Throwable; @@ -19,7 +19,7 @@ class ServerReloadCommand extends Command public function __construct( protected ContainerInterface $container, - protected ConfigInterface $config, + protected Repository $config, protected Filesystem $filesystem ) { parent::__construct(); diff --git a/src/foundation/src/Console/Commands/VendorPublishCommand.php b/src/foundation/src/Console/Commands/VendorPublishCommand.php index c2c53814c..cc0435dc0 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\Filesystem\Filesystem; +use Hypervel\Support\Arr; +use Hypervel\Support\Collection; +use Hypervel\Support\Composer; 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..56579fee5 100644 --- a/src/foundation/src/Console/Kernel.php +++ b/src/foundation/src/Console/Kernel.php @@ -6,29 +6,25 @@ 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; @@ -157,7 +153,7 @@ protected function collectCommands(): array // Load commands from Hyperf config for compatibility. $configReflections = array_map(function (string $class) { return ReflectionManager::reflectClass($class); - }, $this->app->get(ConfigInterface::class)->get('commands', [])); + }, $this->app->get('config')->get('commands', [])); // Load commands that defined by annotation. $annotationReflections = []; diff --git a/src/foundation/src/Exceptions/Handler.php b/src/foundation/src/Exceptions/Handler.php index 2a3e8ef46..8dde360eb 100644 --- a/src/foundation/src/Exceptions/Handler.php +++ b/src/foundation/src/Exceptions/Handler.php @@ -6,35 +6,35 @@ 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; use Hyperf\HttpMessage\Upload\UploadedFile; -use Hyperf\Support\MessageBag; 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\Context\Context; +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\MessageBag; use Hypervel\Support\Reflector; use Hypervel\Support\Traits\ReflectsClosures; use Hypervel\Validation\ValidationException; 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..271265d06 100644 --- a/src/foundation/src/Exceptions/WhoopsErrorRenderer.php +++ b/src/foundation/src/Exceptions/WhoopsErrorRenderer.php @@ -4,10 +4,10 @@ namespace Hypervel\Foundation\Exceptions; -use Hyperf\Context\RequestContext; use Hypervel\Context\ApplicationContext; -use Hypervel\Foundation\Exceptions\Contracts\ExceptionRenderer; -use Hypervel\Session\Contracts\Session as SessionContract; +use Hypervel\Context\RequestContext; +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..8dd62b14d 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\Context\Context; +use Hypervel\Context\ResponseContext; +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..260ca021c 100644 --- a/src/foundation/src/Http/Kernel.php +++ b/src/foundation/src/Http/Kernel.php @@ -4,9 +4,6 @@ namespace Hypervel\Foundation\Http; -use Hyperf\Context\RequestContext; -use Hyperf\Coordinator\Constants; -use Hyperf\Coordinator\CoordinatorManager; use Hyperf\HttpMessage\Server\Request; use Hyperf\HttpMessage\Server\Response; use Hyperf\HttpMessage\Upload\UploadedFile as HyperfUploadedFile; @@ -14,16 +11,19 @@ use Hyperf\HttpServer\Event\RequestReceived; 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\Context\RequestContext; +use Hypervel\Contracts\Debug\ExceptionHandler as ExceptionHandlerContract; +use Hypervel\Coordinator\Constants; +use Hypervel\Coordinator\CoordinatorManager; use Hypervel\Foundation\Exceptions\Handler as ExceptionHandler; use Hypervel\Foundation\Http\Contracts\MiddlewareContract; use Hypervel\Foundation\Http\Traits\HasMiddleware; use Hypervel\Http\UploadedFile; +use Hypervel\Support\SafeCaller; use Psr\Http\Message\ResponseInterface; use Throwable; -use function Hyperf\Coroutine\defer; +use function Hypervel\Coroutine\defer; class Kernel extends HyperfServer implements MiddlewareContract { @@ -154,7 +154,7 @@ protected function dispatchRequestHandledEvents(Request $request, ResponseInterf )); } - protected function getResponseForException(Throwable $throwable): Response + protected function getResponseForException(Throwable $throwable): ResponseInterface { return $this->container->get(SafeCaller::class)->call(function () use ($throwable) { return $this->exceptionHandlerDispatcher->dispatch($throwable, $this->exceptionHandlers); 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/TransformsRequest.php b/src/foundation/src/Http/Middleware/TransformsRequest.php index 236a5bc87..fde0469f4 100644 --- a/src/foundation/src/Http/Middleware/TransformsRequest.php +++ b/src/foundation/src/Http/Middleware/TransformsRequest.php @@ -4,7 +4,7 @@ namespace Hypervel\Foundation\Http\Middleware; -use Hyperf\Context\Context; +use Hypervel\Context\Context; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\MiddlewareInterface; diff --git a/src/foundation/src/Http/Middleware/VerifyCsrfToken.php b/src/foundation/src/Http/Middleware/VerifyCsrfToken.php index 6fb2e13db..0d0b5571a 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\Config\Repository; +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; @@ -46,7 +46,7 @@ class VerifyCsrfToken implements MiddlewareInterface */ public function __construct( protected ContainerInterface $app, - protected ConfigInterface $config, + protected Repository $config, protected Request $request ) { } 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..4501aa72e 100644 --- a/src/foundation/src/Http/WebsocketKernel.php +++ b/src/foundation/src/Http/WebsocketKernel.php @@ -4,24 +4,24 @@ namespace Hypervel\Foundation\Http; -use Hyperf\Context\Context; -use Hyperf\Coordinator\Constants; -use Hyperf\Coordinator\CoordinatorManager; -use Hyperf\Engine\Constant; -use Hyperf\Engine\WebSocket\WebSocket; use Hyperf\HttpMessage\Base\Response; use Hyperf\HttpMessage\Server\Response as Psr7Response; -use Hyperf\Support\SafeCaller; use Hyperf\WebSocketServer\Collector\FdCollector; use Hyperf\WebSocketServer\Context as WsContext; use Hyperf\WebSocketServer\CoreMiddleware; 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\Context\Context; +use Hypervel\Contracts\Debug\ExceptionHandler as ExceptionHandlerContract; +use Hypervel\Coordinator\Constants; +use Hypervel\Coordinator\CoordinatorManager; +use Hypervel\Engine\Constant; +use Hypervel\Engine\WebSocket\WebSocket; use Hypervel\Foundation\Exceptions\Handler as ExceptionHandler; use Hypervel\Foundation\Http\Contracts\MiddlewareContract; use Hypervel\Foundation\Http\Traits\HasMiddleware; +use Hypervel\Support\SafeCaller; use Psr\Http\Message\ResponseInterface; use Swoole\Http\Request; use Swoole\Http\Response as SwooleResponse; diff --git a/src/foundation/src/Listeners/ReloadDotenvAndConfig.php b/src/foundation/src/Listeners/ReloadDotenvAndConfig.php index 60cd7e3c9..4791a1cfa 100644 --- a/src/foundation/src/Listeners/ReloadDotenvAndConfig.php +++ b/src/foundation/src/Listeners/ReloadDotenvAndConfig.php @@ -4,11 +4,11 @@ namespace Hypervel\Foundation\Listeners; -use Hyperf\Contract\ConfigInterface; use Hyperf\Event\Contract\ListenerInterface; use Hyperf\Framework\Event\BeforeWorkerStart; -use Hyperf\Support\DotenvManager; -use Hypervel\Foundation\Contracts\Application as ApplicationContract; +use Hypervel\Config\Repository; +use Hypervel\Contracts\Foundation\Application as ApplicationContract; +use Hypervel\Support\DotenvManager; class ReloadDotenvAndConfig implements ListenerInterface { @@ -20,7 +20,7 @@ public function __construct(protected ApplicationContract $container) { $this->setConfigCallback(); - $container->afterResolving(ConfigInterface::class, function (ConfigInterface $config) { + $container->afterResolving('config', function (Repository $config) { if (static::$stopCallback) { return; } @@ -48,7 +48,7 @@ public function process(object $event): void protected function reloadConfig(): void { - $this->container->unbind(ConfigInterface::class); + $this->container->unbind('config'); } protected function reloadDotenv(): void @@ -63,9 +63,9 @@ protected function reloadDotenv(): void protected function setConfigCallback(): void { - $this->container->get(ConfigInterface::class) + $this->container->get('config') ->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..4256afc2d 100644 --- a/src/foundation/src/Listeners/SetProcessTitle.php +++ b/src/foundation/src/Listeners/SetProcessTitle.php @@ -4,15 +4,14 @@ namespace Hypervel\Foundation\Listeners; -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 { public function __construct(ApplicationContract $container) { - $this->name = $container->get(ConfigInterface::class) + $this->name = $container->get('config') ->get('app.name'); } } 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..95073fc09 100644 --- a/src/foundation/src/Providers/FoundationServiceProvider.php +++ b/src/foundation/src/Providers/FoundationServiceProvider.php @@ -5,22 +5,22 @@ namespace Hypervel\Foundation\Providers; 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\Config\Repository; +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; @@ -32,13 +32,13 @@ class FoundationServiceProvider extends ServiceProvider { - protected ConfigInterface $config; + protected Repository $config; protected ConsoleOutputInterface $output; public function __construct(protected ApplicationContract $app) { - $this->config = $app->get(ConfigInterface::class); + $this->config = $app->get('config'); $this->output = new ConsoleOutput(); if ($app->hasDebugModeEnabled()) { @@ -111,11 +111,6 @@ protected function overrideHyperfConfigs(): void 'app_name' => $this->config->get('app.name'), 'app_env' => $this->config->get('app.env'), StdoutLoggerInterface::class . '.log_level' => $this->config->get('app.stdout_log_level'), - 'databases' => $connections = $this->config->get('database.connections'), - 'databases.migrations' => $migration = $this->config->get('database.migrations', 'migrations'), - 'databases.default' => $connections[$this->config->get('database.default')] ?? [], - 'databases.default.migrations' => $migration, - 'redis' => $this->getRedisConfig(), ]; foreach ($configs as $key => $value) { @@ -127,19 +122,6 @@ protected function overrideHyperfConfigs(): void $this->config->set('middlewares', $this->getMiddlewareConfig()); } - protected function getRedisConfig(): array - { - $redisConfig = $this->config->get('database.redis', []); - $redisOptions = $redisConfig['options'] ?? []; - unset($redisConfig['options']); - - return array_map(function (array $config) use ($redisOptions) { - return array_merge($config, [ - 'options' => $redisOptions, - ]); - }, $redisConfig); - } - protected function getMiddlewareConfig(): array { if ($middleware = $this->config->get('middlewares', [])) { diff --git a/src/foundation/src/Signal/WorkerStopHandler.php b/src/foundation/src/Signal/WorkerStopHandler.php index eb7055463..aaae8c5fb 100644 --- a/src/foundation/src/Signal/WorkerStopHandler.php +++ b/src/foundation/src/Signal/WorkerStopHandler.php @@ -4,8 +4,8 @@ namespace Hypervel\Foundation\Signal; -use Hyperf\Engine\Coroutine; use Hyperf\Signal\Handler\WorkerStopHandler as HyperfWorkerStopHandler; +use Hypervel\Engine\Coroutine; class WorkerStopHandler extends HyperfWorkerStopHandler { diff --git a/src/foundation/src/Testing/Attributes/WithMigration.php b/src/foundation/src/Testing/Attributes/WithMigration.php deleted file mode 100644 index 31dbb3a03..000000000 --- a/src/foundation/src/Testing/Attributes/WithMigration.php +++ /dev/null @@ -1,44 +0,0 @@ - - */ - 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/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..ea79b5d74 100644 --- a/src/foundation/src/Testing/Concerns/InteractsWithConsole.php +++ b/src/foundation/src/Testing/Concerns/InteractsWithConsole.php @@ -4,8 +4,8 @@ namespace Hypervel\Foundation\Testing\Concerns; -use Hypervel\Foundation\Console\Contracts\Kernel as KernelContract; -use Hypervel\Foundation\Testing\PendingCommand; +use Hypervel\Contracts\Console\Kernel as KernelContract; +use Hypervel\Testing\PendingCommand; trait InteractsWithConsole { @@ -78,6 +78,9 @@ public function command(string $command, array $parameters = []): int|PendingCom /** * Disable mocking the console output. + * + * When using this with traits like DatabaseMigrations, call this in setUp() + * BEFORE parent::setUp() to ensure mock output is never bound. */ protected function withoutMockingConsoleOutput(): static { diff --git a/src/foundation/src/Testing/Concerns/InteractsWithContainer.php b/src/foundation/src/Testing/Concerns/InteractsWithContainer.php index 50a05aeed..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; diff --git a/src/foundation/src/Testing/Concerns/InteractsWithDatabase.php b/src/foundation/src/Testing/Concerns/InteractsWithDatabase.php index 1396b606a..008b7b124 100644 --- a/src/foundation/src/Testing/Concerns/InteractsWithDatabase.php +++ b/src/foundation/src/Testing/Concerns/InteractsWithDatabase.php @@ -4,16 +4,16 @@ 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\Foundation\Testing\Constraints\CountInDatabase; -use Hypervel\Foundation\Testing\Constraints\HasInDatabase; -use Hypervel\Foundation\Testing\Constraints\NotSoftDeletedInDatabase; -use Hypervel\Foundation\Testing\Constraints\SoftDeletedInDatabase; +use Hypervel\Contracts\Support\Jsonable; +use Hypervel\Database\Eloquent\Model; +use Hypervel\Database\Eloquent\SoftDeletes; +use Hypervel\Database\Events\QueryExecuted; +use Hypervel\Support\Arr; use Hypervel\Support\Facades\DB; +use Hypervel\Testing\Constraints\CountInDatabase; +use Hypervel\Testing\Constraints\HasInDatabase; +use Hypervel\Testing\Constraints\NotSoftDeletedInDatabase; +use Hypervel\Testing\Constraints\SoftDeletedInDatabase; use PHPUnit\Framework\Constraint\LogicalNot as ReverseConstraint; trait InteractsWithDatabase @@ -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/InteractsWithMeilisearch.php b/src/foundation/src/Testing/Concerns/InteractsWithMeilisearch.php new file mode 100644 index 000000000..d0fdb244c --- /dev/null +++ b/src/foundation/src/Testing/Concerns/InteractsWithMeilisearch.php @@ -0,0 +1,212 @@ +markTestSkipped( + 'Meilisearch connection failed with defaults. Set MEILISEARCH_HOST & MEILISEARCH_PORT to enable ' . static::class + ); + } + + $host = env('MEILISEARCH_HOST', '127.0.0.1'); + $port = env('MEILISEARCH_PORT', '7700'); + + $this->initializeMeilisearchClient(); + + try { + $this->meilisearch->health(); + // getIndexes() requires auth - use it to verify credentials + $this->meilisearch->getIndexes(); + $this->cleanupMeilisearchIndexes(); + } catch (Throwable $e) { + if ($host === '127.0.0.1' && $port === '7700' && env('MEILISEARCH_HOST') === null) { + static::$meilisearchConnectionFailed = true; + $this->markTestSkipped( + 'Meilisearch connection failed with defaults. Set MEILISEARCH_HOST & MEILISEARCH_PORT to enable ' . static::class + ); + } + // Explicit config exists but failed - rethrow so test fails (misconfiguration) + throw $e; + } + } + + /** + * Tear down Meilisearch (auto-called via beforeApplicationDestroyed). + */ + protected function tearDownInteractsWithMeilisearch(): void + { + if (static::$meilisearchConnectionFailed || $this->meilisearch === null) { + return; + } + + try { + $this->cleanupMeilisearchIndexes(); + } catch (Throwable) { + // Ignore cleanup errors + } + + $this->meilisearch = null; + } + + /** + * Configure Meilisearch for testing. + * + * Call from defineEnvironment() to set up Scout config. + */ + protected function configureMeilisearchForTesting(Repository $config): void + { + $this->computeMeilisearchTestPrefix(); + + $config->set('scout.driver', 'meilisearch'); + $config->set('scout.prefix', $this->meilisearchTestPrefix); + $config->set('scout.meilisearch.host', $this->getMeilisearchHost()); + $config->set('scout.meilisearch.key', env('MEILISEARCH_KEY', '')); + } + + /** + * Initialize the Meilisearch client. + */ + protected function initializeMeilisearchClient(): void + { + $this->meilisearch = new MeilisearchClient( + $this->getMeilisearchHost(), + env('MEILISEARCH_KEY', '') + ); + } + + /** + * Compute the test prefix for parallel-safe index names. + */ + protected function computeMeilisearchTestPrefix(): void + { + $base = 'test_'; + $token = env('TEST_TOKEN', ''); + + $this->meilisearchTestPrefix = $token !== '' ? "{$base}{$token}_" : $base; + } + + /** + * Get the Meilisearch host URL. + * + * Builds URL from MEILISEARCH_HOST and MEILISEARCH_PORT env vars. + */ + protected function getMeilisearchHost(): string + { + $host = env('MEILISEARCH_HOST', '127.0.0.1'); + $port = env('MEILISEARCH_PORT', '7700'); + + return "http://{$host}:{$port}"; + } + + /** + * Check if MEILISEARCH_HOST was explicitly set. + */ + protected function hasExplicitMeilisearchConfig(): bool + { + return env('MEILISEARCH_HOST') !== null; + } + + /** + * Get a prefixed index name. + */ + protected function meilisearchIndex(string $name): string + { + return $this->meilisearchTestPrefix . $name; + } + + /** + * Clean up all test indexes matching the test prefix. + */ + protected function cleanupMeilisearchIndexes(): void + { + if ($this->meilisearch === null) { + return; + } + + try { + $indexes = $this->meilisearch->getIndexes(); + + foreach ($indexes->getResults() as $index) { + if (str_starts_with($index->getUid(), $this->meilisearchTestPrefix)) { + $this->meilisearch->deleteIndex($index->getUid()); + } + } + } catch (Throwable) { + // Ignore errors during cleanup + } + } + + /** + * Wait for all pending Meilisearch tasks to complete. + */ + protected function waitForMeilisearchTasks(int $timeoutMs = 5000): void + { + if ($this->meilisearch === null) { + return; + } + + try { + $tasks = $this->meilisearch->getTasks(); + foreach ($tasks->getResults() as $task) { + if (in_array($task['status'], ['enqueued', 'processing'], true)) { + $this->meilisearch->waitForTask($task['uid'], $timeoutMs); + } + } + } catch (Throwable) { + // Ignore timeout errors + } + } +} diff --git a/src/foundation/src/Testing/Concerns/InteractsWithRedis.php b/src/foundation/src/Testing/Concerns/InteractsWithRedis.php index fa5bbbf18..705e7be50 100644 --- a/src/foundation/src/Testing/Concerns/InteractsWithRedis.php +++ b/src/foundation/src/Testing/Concerns/InteractsWithRedis.php @@ -4,7 +4,7 @@ namespace Hypervel\Foundation\Testing\Concerns; -use Hyperf\Contract\ConfigInterface; +use Hypervel\Config\Repository; use Hypervel\Support\Facades\Redis; use Throwable; @@ -98,7 +98,7 @@ protected function tearDownInteractsWithRedis(): void * * Call from defineEnvironment() to set up Redis config. */ - protected function configureRedisForTesting(ConfigInterface $config): void + protected function configureRedisForTesting(Repository $config): void { $this->computeRedisTestPrefix(); @@ -120,11 +120,7 @@ protected function configureRedisForTesting(ConfigInterface $config): void ], ]; - // Set both locations - database.redis.* (source) and redis.* (runtime) - // FoundationServiceProvider copies database.redis.* to redis.* at boot, - // but tests run AFTER boot, so we must set redis.* directly $config->set('database.redis.default', $connectionConfig); - $config->set('redis.default', $connectionConfig); } /** @@ -218,10 +214,10 @@ protected function createRedisConnectionWithPrefix(string $optPrefix): string { $connectionName = 'test_opt_' . ($optPrefix === '' ? 'none' : md5($optPrefix)); - $config = $this->app->get(ConfigInterface::class); + $config = $this->app->get('config'); // Check if already exists - if ($config->get("redis.{$connectionName}") !== null) { + if ($config->get("database.redis.{$connectionName}") !== null) { return $connectionName; } @@ -243,7 +239,10 @@ protected function createRedisConnectionWithPrefix(string $optPrefix): string ], ]; - $config->set("redis.{$connectionName}", $connectionConfig); + $config->set("database.redis.{$connectionName}", $connectionConfig); + + // RedisFactory snapshots configured pools in __construct, so reset it after adding runtime test pools. + $this->app->forgetInstance(\Hypervel\Redis\RedisFactory::class); return $connectionName; } diff --git a/src/foundation/src/Testing/Concerns/InteractsWithServer.php b/src/foundation/src/Testing/Concerns/InteractsWithServer.php new file mode 100644 index 000000000..cbdc0f2e2 --- /dev/null +++ b/src/foundation/src/Testing/Concerns/InteractsWithServer.php @@ -0,0 +1,115 @@ +serverHost = env('ENGINE_TEST_SERVER_HOST', '127.0.0.1'); + + if (static::$serverConnectionFailed) { + $this->markTestSkipped( + 'Server connection failed with defaults. Set ENGINE_TEST_SERVER_HOST to enable ' . static::class + ); + } + + if (! $this->canConnectToServer()) { + if ($this->isUsingDefaultServerConfig()) { + static::$serverConnectionFailed = true; + $this->markTestSkipped( + 'Server connection failed with defaults. Set ENGINE_TEST_SERVER_HOST to enable ' . static::class + ); + } + // Explicit config exists but failed - throw so test fails (misconfiguration) + $this->fail(sprintf( + 'Cannot connect to server at %s:%d. Check your ENGINE_TEST_SERVER_HOST configuration.', + $this->serverHost, + $this->serverPort + )); + } + } + + /** + * Check if we can connect to the server. + */ + protected function canConnectToServer(): bool + { + try { + $socket = @fsockopen($this->serverHost, $this->serverPort, $errno, $errstr, 1); + if ($socket === false) { + return false; + } + fclose($socket); + return true; + } catch (Throwable) { + return false; + } + } + + /** + * Check if using default server configuration. + */ + protected function isUsingDefaultServerConfig(): bool + { + return env('ENGINE_TEST_SERVER_HOST') === null; + } + + /** + * Get the server host. + */ + protected function getServerHost(): string + { + return $this->serverHost; + } + + /** + * Get the server port. + */ + protected function getServerPort(): int + { + return $this->serverPort; + } +} 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/InteractsWithTime.php b/src/foundation/src/Testing/Concerns/InteractsWithTime.php index f0b1ad21d..0256fbb86 100644 --- a/src/foundation/src/Testing/Concerns/InteractsWithTime.php +++ b/src/foundation/src/Testing/Concerns/InteractsWithTime.php @@ -19,7 +19,9 @@ trait InteractsWithTime */ public function freezeTime($callback = null) { - return $this->travelTo(Carbon::now(), $callback); + $result = $this->travelTo($now = Carbon::now(), $callback); + + return $callback === null ? $now : $result; } /** @@ -30,7 +32,9 @@ public function freezeTime($callback = null) */ public function freezeSecond($callback = null) { - return $this->travelTo(Carbon::now()->startOfSecond(), $callback); + $result = $this->travelTo($now = Carbon::now()->startOfSecond(), $callback); + + return $callback === null ? $now : $result; } /** diff --git a/src/foundation/src/Testing/Concerns/InteractsWithTypesense.php b/src/foundation/src/Testing/Concerns/InteractsWithTypesense.php new file mode 100644 index 000000000..5788e19f9 --- /dev/null +++ b/src/foundation/src/Testing/Concerns/InteractsWithTypesense.php @@ -0,0 +1,192 @@ +markTestSkipped( + 'Typesense connection failed with defaults. Set TYPESENSE_HOST & TYPESENSE_PORT to enable ' . static::class + ); + } + + $host = env('TYPESENSE_HOST', '127.0.0.1'); + $port = env('TYPESENSE_PORT', '8108'); + + try { + $this->initializeTypesenseClient(); + $this->typesense->health->retrieve(); + $this->cleanupTypesenseCollections(); + } catch (Throwable $e) { + if ($host === '127.0.0.1' && $port === '8108' && env('TYPESENSE_HOST') === null) { + static::$typesenseConnectionFailed = true; + $this->markTestSkipped( + 'Typesense connection failed with defaults. Set TYPESENSE_HOST & TYPESENSE_PORT to enable ' . static::class + ); + } + // Explicit config exists but failed - rethrow so test fails (misconfiguration) + throw $e; + } + } + + /** + * Tear down Typesense (auto-called via beforeApplicationDestroyed). + */ + protected function tearDownInteractsWithTypesense(): void + { + if (static::$typesenseConnectionFailed || $this->typesense === null) { + return; + } + + try { + $this->cleanupTypesenseCollections(); + } catch (Throwable) { + // Ignore cleanup errors + } + + $this->typesense = null; + } + + /** + * Configure Typesense for testing. + * + * Call from defineEnvironment() to set up Scout config. + */ + protected function configureTypesenseForTesting(Repository $config): void + { + $this->computeTypesenseTestPrefix(); + + $config->set('scout.driver', 'typesense'); + $config->set('scout.prefix', $this->typesenseTestPrefix); + $config->set('scout.typesense.client-settings', $this->getTypesenseClientSettings()); + } + + /** + * Initialize the Typesense client. + */ + protected function initializeTypesenseClient(): void + { + $this->typesense = new TypesenseClient($this->getTypesenseClientSettings()); + } + + /** + * Get Typesense client settings. + * + * @return array + */ + protected function getTypesenseClientSettings(): array + { + return [ + 'api_key' => env('TYPESENSE_API_KEY', ''), + 'nodes' => [ + [ + 'host' => env('TYPESENSE_HOST', '127.0.0.1'), + 'port' => (string) env('TYPESENSE_PORT', '8108'), + 'protocol' => env('TYPESENSE_PROTOCOL', 'http'), + ], + ], + 'connection_timeout_seconds' => 2, + ]; + } + + /** + * Compute the test prefix for parallel-safe collection names. + */ + protected function computeTypesenseTestPrefix(): void + { + $base = 'test_'; + $token = env('TEST_TOKEN', ''); + + $this->typesenseTestPrefix = $token !== '' ? "{$base}{$token}_" : $base; + } + + /** + * Check if TYPESENSE_HOST was explicitly set. + */ + protected function hasExplicitTypesenseConfig(): bool + { + return env('TYPESENSE_HOST') !== null; + } + + /** + * Get a prefixed collection name. + */ + protected function typesenseCollection(string $name): string + { + return $this->typesenseTestPrefix . $name; + } + + /** + * Clean up all test collections matching the test prefix. + */ + protected function cleanupTypesenseCollections(): void + { + if ($this->typesense === null) { + return; + } + + try { + $collections = $this->typesense->collections->retrieve(); + + foreach ($collections as $collection) { + if (str_starts_with($collection['name'], $this->typesenseTestPrefix)) { + $this->typesense->collections[$collection['name']]->delete(); + } + } + } catch (Throwable) { + // Ignore errors during cleanup + } + } +} diff --git a/src/foundation/src/Testing/Concerns/MakesHttpRequests.php b/src/foundation/src/Testing/Concerns/MakesHttpRequests.php index aa7fc4169..dde1516ce 100644 --- a/src/foundation/src/Testing/Concerns/MakesHttpRequests.php +++ b/src/foundation/src/Testing/Concerns/MakesHttpRequests.php @@ -5,8 +5,8 @@ namespace Hypervel\Foundation\Testing\Concerns; use Hypervel\Foundation\Testing\Http\TestClient; -use Hypervel\Foundation\Testing\Http\TestResponse; use Hypervel\Foundation\Testing\Stubs\FakeMiddleware; +use Hypervel\Testing\TestResponse; trait MakesHttpRequests { 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/Concerns/RunTestsInCoroutine.php b/src/foundation/src/Testing/Concerns/RunTestsInCoroutine.php index e6d256528..fbdec26cc 100644 --- a/src/foundation/src/Testing/Concerns/RunTestsInCoroutine.php +++ b/src/foundation/src/Testing/Concerns/RunTestsInCoroutine.php @@ -4,9 +4,10 @@ namespace Hypervel\Foundation\Testing\Concerns; -use Hyperf\Coordinator\Constants; -use Hyperf\Coordinator\CoordinatorManager; use Hypervel\Context\Context; +use Hypervel\Coordinator\Constants; +use Hypervel\Coordinator\CoordinatorManager; +use Hypervel\Support\Collection; use Swoole\Coroutine; use Swoole\Timer; use Throwable; @@ -33,6 +34,9 @@ final protected function runTestsInCoroutine(...$arguments) /* @phpstan-ignore-next-line */ run(function () use (&$testResult, &$exception, $arguments) { + // Clear stale transaction context from previous tests before copying + $this->clearNonCoroutineTransactionContext(); + if ($this->copyNonCoroutineContext) { Context::copyFromNonCoroutine(); } @@ -44,6 +48,7 @@ final protected function runTestsInCoroutine(...$arguments) $exception = $e; } finally { $this->invokeTearDownInCoroutine(); + $this->cleanupTestContext(); Timer::clearAll(); CoordinatorManager::until(Constants::WORKER_EXIT)->resume(); } @@ -68,6 +73,14 @@ final protected function runTest(): mixed protected function invokeSetupInCoroutine(): void { + // Call trait-specific coroutine setup methods (e.g., setUpDatabaseTransactionsInCoroutine) + foreach (class_uses_recursive(static::class) as $trait) { + $method = 'setUp' . class_basename($trait) . 'InCoroutine'; + if (method_exists($this, $method)) { + $this->{$method}(); + } + } + if (method_exists($this, 'setUpInCoroutine')) { call_user_func([$this, 'setUpInCoroutine']); } @@ -78,5 +91,54 @@ protected function invokeTearDownInCoroutine(): void if (method_exists($this, 'tearDownInCoroutine')) { call_user_func([$this, 'tearDownInCoroutine']); } + + // Call trait-specific coroutine teardown methods (e.g., tearDownDatabaseTransactionsInCoroutine) + foreach (class_uses_recursive(static::class) as $trait) { + $method = 'tearDown' . class_basename($trait) . 'InCoroutine'; + if (method_exists($this, $method)) { + $this->{$method}(); + } + } + } + + /** + * Clear transaction context from non-coroutine storage before test starts. + * + * RefreshDatabase starts its wrapper transaction in setUp() (outside coroutine), + * storing it in nonCoContext. We must preserve this data for copying into the + * coroutine. Only clear if there are no pending transactions (meaning any data + * is stale from a previous test that didn't clean up properly). + */ + protected function clearNonCoroutineTransactionContext(): void + { + $pending = Context::getFromNonCoroutine('__db.transactions.pending'); + + if ($pending instanceof Collection && $pending->isNotEmpty()) { + return; + } + + Context::clearFromNonCoroutine([ + '__db.transactions.committed', + '__db.transactions.pending', + '__db.transactions.current', + ]); + } + + /** + * Clean up Context keys that cause test pollution. + * + * Only destroys specific keys known to leak between tests. Does not use + * Context::destroyAll() because that would destroy data needed by defer + * callbacks (e.g., Redis connections waiting to be released). + */ + protected function cleanupTestContext(): void + { + // Transaction manager state + Context::destroy('__db.transactions.committed'); + Context::destroy('__db.transactions.pending'); + Context::destroy('__db.transactions.current'); + + // Model guard state + Context::destroy('__database.model.unguarded'); } } diff --git a/src/foundation/src/Testing/Coroutine/Waiter.php b/src/foundation/src/Testing/Coroutine/Waiter.php index 92878f312..dc9a90743 100644 --- a/src/foundation/src/Testing/Coroutine/Waiter.php +++ b/src/foundation/src/Testing/Coroutine/Waiter.php @@ -5,17 +5,17 @@ namespace Hypervel\Foundation\Testing\Coroutine; use Closure; -use Hyperf\Context\Context; -use Hyperf\Coroutine\Coroutine; -use Hyperf\Coroutine\Exception\ExceptionThrower; -use Hyperf\Coroutine\Exception\WaitTimeoutException; -use Hyperf\Coroutine\Waiter as HyperfWaiter; -use Hyperf\Engine\Channel; +use Hypervel\Context\Context; +use Hypervel\Coroutine\Coroutine; +use Hypervel\Coroutine\Exception\ExceptionThrower; +use Hypervel\Coroutine\Exception\WaitTimeoutException; +use Hypervel\Coroutine\Waiter as BaseWaiter; +use Hypervel\Engine\Channel; use Throwable; -class Waiter extends HyperfWaiter +class Waiter extends BaseWaiter { - public function wait(Closure $closure, ?float $timeout = null) + public function wait(Closure $closure, ?float $timeout = null): mixed { if ($timeout === null) { $timeout = $this->popTimeout; diff --git a/src/foundation/src/Testing/DatabaseConnectionResolver.php b/src/foundation/src/Testing/DatabaseConnectionResolver.php index 925916e35..db230dfb0 100644 --- a/src/foundation/src/Testing/DatabaseConnectionResolver.php +++ b/src/foundation/src/Testing/DatabaseConnectionResolver.php @@ -4,29 +4,126 @@ namespace Hypervel\Foundation\Testing; -use Hyperf\Contract\ConfigInterface; -use Hyperf\Database\ConnectionInterface; -use Hyperf\DbConnection\ConnectionResolver; +use Hypervel\Context\ApplicationContext; +use Hypervel\Contracts\Container\Container; +use Hypervel\Contracts\Event\Dispatcher; +use Hypervel\Database\Connection; +use Hypervel\Database\ConnectionInterface; +use Hypervel\Database\ConnectionResolver; +use Hypervel\Database\FlushableConnectionResolver; +use Psr\EventDispatcher\EventDispatcherInterface; +use UnitEnum; -class DatabaseConnectionResolver extends ConnectionResolver +use function Hypervel\Support\enum_value; + +/** + * Database connection resolver for the testing environment. + * + * Caches connections statically to prevent pool exhaustion (since the testing + * environment doesn't use defer() to release connections back to the pool). + * Call resetCachedConnections() at the start of each test to ensure clean + * state without the test pollution that static caching would otherwise cause. + */ +class DatabaseConnectionResolver extends ConnectionResolver implements FlushableConnectionResolver { /** * Connections for testing environment. + * + * @var array */ protected static array $connections = []; /** - * Get a database connection instance. + * The object ID of the container when connections were cached. + * Used to detect when a new test's container differs from previous. + */ + protected static ?int $containerId = null; + + /** + * Whether the dispatcher rebinding hook has been registered. + */ + protected static bool $rebindingRegistered = false; + + /** + * Reset all cached connections to clean state. + * + * Called after Application is created to prevent test pollution (query logs, + * event listeners, transaction state, etc.) from leaking between tests. + * + * When the container changes (new test with fresh Application), cached + * connections are flushed since they hold references to the old container's + * services. A rebinding hook is registered so Event::fake() automatically + * updates cached connections with the new dispatcher. */ - public function connection(?string $name = null): ConnectionInterface + public static function resetCachedConnections(): void { - if (is_null($name)) { - $name = $this->getDefaultConnection(); + $container = ApplicationContext::getContainer(); + $currentContainerId = spl_object_id($container); + + // If container changed, flush all cached connections since they hold + // stale references to the old container's dispatcher and other services + if (static::$containerId !== $currentContainerId) { + static::$containerId = $currentContainerId; + static::$connections = []; + static::$rebindingRegistered = false; + } + + // Reset per-request state on remaining connections + foreach (static::$connections as $connection) { + if ($connection instanceof Connection) { + $connection->resetForPool(); + } } + // Register rebinding hook so Event::fake() updates cached connections + static::registerDispatcherRebinding($container); + } + + /** + * Register a rebinding hook for the event dispatcher. + * + * When Event::fake() swaps the dispatcher, this callback updates all + * cached connections to use the new (fake) dispatcher. + */ + protected static function registerDispatcherRebinding(Container $container): void + { + if (static::$rebindingRegistered) { + return; + } + + // Register for the PSR interface that Event facade uses + $container->rebinding(EventDispatcherInterface::class, function ($app, $dispatcher) { + foreach (static::$connections as $connection) { + if ($connection instanceof Connection && $dispatcher instanceof Dispatcher) { + $connection->setEventDispatcher($dispatcher); + } + } + }); + + static::$rebindingRegistered = true; + } + + /** + * Flush a cached connection. + * + * Clears the static cache so the next connection() call creates a fresh + * connection with current configuration. + */ + public function flush(string $name): void + { + unset(static::$connections[$name]); + } + + /** + * Get a database connection instance. + */ + public function connection(UnitEnum|string|null $name = null): ConnectionInterface + { + $name = enum_value($name) ?: $this->getDefaultConnection(); + // If the pool is enabled, we should use the default connection resolver. $poolEnabled = $this->container - ->get(ConfigInterface::class) + ->get('config') ->get("database.connections.{$name}.pool.testing_enabled", false); if ($poolEnabled) { return parent::connection($name); diff --git a/src/foundation/src/Testing/DatabaseMigrations.php b/src/foundation/src/Testing/DatabaseMigrations.php index 45f73fc44..cefe2f32f 100644 --- a/src/foundation/src/Testing/DatabaseMigrations.php +++ b/src/foundation/src/Testing/DatabaseMigrations.php @@ -4,6 +4,7 @@ namespace Hypervel\Foundation\Testing; +use Hypervel\Database\Eloquent\Model; use Hypervel\Foundation\Testing\Traits\CanConfigureMigrationCommands; trait DatabaseMigrations @@ -15,12 +16,47 @@ trait DatabaseMigrations */ public function runDatabaseMigrations(): void { + $this->beforeRefreshingDatabase(); + $this->command('migrate:fresh', $this->migrateFreshUsing()); + $this->afterRefreshingDatabase(); + + $this->refreshModelBootedStates(); + $this->beforeApplicationDestroyed(function () { $this->command('migrate:rollback'); RefreshDatabaseState::$migrated = false; }); } + + /** + * Refresh the model booted states. + * + * Clears the static booted model tracking so that models re-register + * their event listeners with the current event dispatcher. This is + * necessary when Event::fake() creates a new EventFake, otherwise + * model event callbacks point to the old dispatcher. + */ + protected function refreshModelBootedStates(): void + { + Model::clearBootedModels(); + } + + /** + * 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..36f0b18e7 100644 --- a/src/foundation/src/Testing/DatabaseTransactions.php +++ b/src/foundation/src/Testing/DatabaseTransactions.php @@ -4,44 +4,112 @@ namespace Hypervel\Foundation\Testing; -use Hyperf\Database\Connection as DatabaseConnection; -use Hyperf\DbConnection\Db; +use Hypervel\Database\Connection as DatabaseConnection; +use Hypervel\Database\DatabaseManager; +use Hypervel\Foundation\Testing\Concerns\RunTestsInCoroutine; trait DatabaseTransactions { /** * Handle database transactions on the specified connections. + * + * For tests using RunTestsInCoroutine, this method does nothing - the actual + * transaction work is done in setUpDatabaseTransactionsInCoroutine() to keep + * all transaction state in the same coroutine. + * + * For non-coroutine tests, this starts the transaction immediately and + * registers a rollback callback. */ public function beginDatabaseTransaction(): void { - $database = $this->app->get(Db::class); + // If using RunTestsInCoroutine, defer to coroutine-aware methods + if (in_array(RunTestsInCoroutine::class, class_uses_recursive(static::class), true)) { + return; + } - foreach ($this->connectionsToTransact() as $name) { + // Non-coroutine path: start transaction and register rollback callback + $this->beginDatabaseTransactionWork(); + + $this->beforeApplicationDestroyed(function () { + $this->rollbackDatabaseTransactionWork(); + }); + } + + /** + * Start database transaction in the test coroutine. + * + * Called by RunTestsInCoroutine before the test runs. Keeps all transaction + * state in the same coroutine, avoiding Context handoff issues. + */ + protected function setUpDatabaseTransactionsInCoroutine(): void + { + $this->beginDatabaseTransactionWork(); + } + + /** + * Rollback database transaction in the test coroutine. + * + * Called by RunTestsInCoroutine after the test runs. + */ + protected function tearDownDatabaseTransactionsInCoroutine(): void + { + $this->rollbackDatabaseTransactionWork(); + } + + /** + * Start transactions on all connections. + */ + protected function beginDatabaseTransactionWork(): void + { + $database = $this->app->get(DatabaseManager::class); + $connections = $this->connectionsToTransact(); + + // Create a testing-aware transaction manager that properly handles afterCommit callbacks + $this->app->instance( + 'db.transactions', + $transactionsManager = new DatabaseTransactionsManager($connections) + ); + + foreach ($connections as $name) { $connection = $database->connection($name); + + // Set the testing transaction manager on the connection + $connection->setTransactionManager($transactionsManager); + $dispatcher = $connection->getEventDispatcher(); $connection->unsetEventDispatcher(); $connection->beginTransaction(); - $connection->setEventDispatcher($dispatcher); + + if ($dispatcher !== null) { + $connection->setEventDispatcher($dispatcher); + } } + } - $this->beforeApplicationDestroyed(function () use ($database) { - foreach ($this->connectionsToTransact() as $name) { - $connection = $database->connection($name); - $dispatcher = $connection->getEventDispatcher(); + /** + * Rollback transactions on all connections. + */ + protected function rollbackDatabaseTransactionWork(): void + { + $database = $this->app->get(DatabaseManager::class); - $connection->unsetEventDispatcher(); + foreach ($this->connectionsToTransact() as $name) { + $connection = $database->connection($name); + $dispatcher = $connection->getEventDispatcher(); - if ($connection instanceof DatabaseConnection) { - $connection->resetRecordsModified(); - } + $connection->unsetEventDispatcher(); - $connection->rollBack(); + if ($connection instanceof DatabaseConnection) { + $connection->forgetRecordModificationState(); + } + + $connection->rollBack(); + + if ($dispatcher !== null) { $connection->setEventDispatcher($dispatcher); - // this will trigger a database refresh warning - // $connection->disconnect(); } - }); + } } /** diff --git a/src/foundation/src/Testing/DatabaseTransactionsManager.php b/src/foundation/src/Testing/DatabaseTransactionsManager.php new file mode 100644 index 000000000..daf94f0e8 --- /dev/null +++ b/src/foundation/src/Testing/DatabaseTransactionsManager.php @@ -0,0 +1,78 @@ + + */ + protected array $connectionsTransacting; + + /** + * Create a new database transaction manager instance. + * + * @param array $connectionsTransacting + */ + public function __construct(array $connectionsTransacting) + { + $this->connectionsTransacting = $connectionsTransacting; + } + + /** + * Register a transaction callback. + * + * If there are no applicable transactions (only the RefreshDatabase wrapper), + * the callback executes immediately. Otherwise, it's queued for after commit. + */ + public function addCallback(callable $callback): void + { + // If there are no transactions, we'll run the callbacks right away. Also, we'll run it + // right away when we're in test mode and we only have the wrapping transaction. For + // every other case, we'll queue up the callback to run after the commit happens. + if ($this->callbackApplicableTransactions()->count() === 0) { + $callback(); + return; + } + + $this->callbackApplicableTransactions()->last()->addCallback($callback); + } + + /** + * Get the transactions that are applicable to callbacks. + * + * Skips the RefreshDatabase wrapper transaction(s) so callbacks are only + * associated with transactions created within the test itself. + * + * @return Collection + */ + public function callbackApplicableTransactions(): Collection + { + return $this->getPendingTransactions()->skip(count($this->connectionsTransacting))->values(); + } + + /** + * Determine if after commit callbacks should be executed for the given transaction level. + * + * Returns true at level 1 (the RefreshDatabase wrapper level) instead of level 0, + * since the wrapper transaction is never committed during tests. + */ + public function afterCommitCallbacksShouldBeExecuted(int $level): bool + { + return $level === 1; + } +} diff --git a/src/foundation/src/Testing/Dispatcher/HttpDispatcher.php b/src/foundation/src/Testing/Dispatcher/HttpDispatcher.php index 01ed94b6e..7222785d5 100644 --- a/src/foundation/src/Testing/Dispatcher/HttpDispatcher.php +++ b/src/foundation/src/Testing/Dispatcher/HttpDispatcher.php @@ -4,8 +4,8 @@ namespace Hypervel\Foundation\Testing\Dispatcher; -use Hyperf\Context\ApplicationContext; use Hyperf\Dispatcher\HttpDispatcher as HyperfHttpDispatcher; +use Hypervel\Context\ApplicationContext; use Psr\Http\Message\ResponseInterface; class HttpDispatcher extends HyperfHttpDispatcher diff --git a/src/foundation/src/Testing/Http/TestClient.php b/src/foundation/src/Testing/Http/TestClient.php index 8ad780ab3..d110c05bc 100644 --- a/src/foundation/src/Testing/Http/TestClient.php +++ b/src/foundation/src/Testing/Http/TestClient.php @@ -4,9 +4,6 @@ namespace Hypervel\Foundation\Testing\Http; -use Hyperf\Collection\Arr; -use Hyperf\Context\Context; -use Hyperf\Contract\ConfigInterface; use Hyperf\Dispatcher\HttpDispatcher; use Hyperf\ExceptionHandler\ExceptionHandlerDispatcher; use Hyperf\HttpMessage\Server\Request as Psr7Request; @@ -15,10 +12,12 @@ use Hyperf\HttpServer\Event\RequestHandled; use Hyperf\HttpServer\Event\RequestReceived; use Hyperf\HttpServer\ResponseEmitter; -use Hyperf\Support\Filesystem\Filesystem; use Hyperf\Testing\HttpMessage\Upload\UploadedFile; +use Hypervel\Context\Context; +use Hypervel\Filesystem\Filesystem; 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 +25,6 @@ use Psr\Http\Message\UploadedFileInterface; use Throwable; -use function Hyperf\Collection\data_get; - class TestClient extends HttpKernel { protected bool $enableEvents = false; @@ -42,7 +39,7 @@ class TestClient extends HttpKernel public function __construct(ContainerInterface $container, $server = 'http') { - $this->enableEvents = $container->get(ConfigInterface::class) + $this->enableEvents = $container->get('config') ->get("server.servers.{$server}.options.enable_request_lifecycle", false); if ($this->enableEvents) { $this->event = $container->get(EventDispatcherInterface::class); @@ -253,7 +250,7 @@ protected function execute(ServerRequestInterface $psr7Request): ResponseInterfa protected function loadKernelMiddleware(string $server): void { - $kernelClass = $this->container->get(ConfigInterface::class) + $kernelClass = $this->container->get('config') ->get("server.kernels.{$server}"); if (! $kernelClass || ! class_exists($kernelClass)) { return; @@ -275,8 +272,8 @@ protected function persistToContext(ServerRequestInterface $request, ResponseInt protected function initBaseUri(string $server): void { - if ($this->container->has(ConfigInterface::class)) { - $config = $this->container->get(ConfigInterface::class); + if ($this->container->has('config')) { + $config = $this->container->get('config'); $servers = $config->get('server.servers', []); foreach ($servers as $item) { if ($item['name'] == $server) { diff --git a/src/foundation/src/Testing/RefreshDatabase.php b/src/foundation/src/Testing/RefreshDatabase.php index 415fbd94a..a79c3707f 100644 --- a/src/foundation/src/Testing/RefreshDatabase.php +++ b/src/foundation/src/Testing/RefreshDatabase.php @@ -4,10 +4,10 @@ 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\Concerns\RunTestsInCoroutine; use Hypervel\Foundation\Testing\Traits\CanConfigureMigrationCommands; use Psr\EventDispatcher\EventDispatcherInterface; @@ -17,20 +17,30 @@ trait RefreshDatabase /** * Define hooks to migrate the database before and after each test. + * + * For tests using RunTestsInCoroutine, the transaction and post-refresh + * hooks are deferred to setUpRefreshDatabaseInCoroutine() to keep all + * transaction state in the same coroutine. */ public function refreshDatabase(): void { $this->beforeRefreshingDatabase(); + // Restore in-memory database BEFORE migrations for all tests. + // This ensures the correct ordering: restore cached PDO → run migrations → begin transaction. + // For in-memory SQLite, this avoids overwriting a freshly migrated schema later. if ($this->usingInMemoryDatabase()) { $this->restoreInMemoryDatabase(); } $this->refreshTestDatabase(); - $this->afterRefreshingDatabase(); - - $this->refreshModelBootedStates(); + // For coroutine tests, these run in setUpRefreshDatabaseInCoroutine() + // to maintain correct ordering: transaction → afterRefreshing → test + if (! in_array(RunTestsInCoroutine::class, class_uses_recursive(static::class), true)) { + $this->afterRefreshingDatabase(); + $this->refreshModelBootedStates(); + } } /** @@ -38,7 +48,7 @@ public function refreshDatabase(): void */ protected function refreshModelBootedStates(): void { - Booted::$container = []; + Model::clearBootedModels(); } /** @@ -46,7 +56,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])) { @@ -62,13 +72,17 @@ protected function restoreInMemoryDatabase(): void */ protected function usingInMemoryDatabase(): bool { - $config = $this->app->get(ConfigInterface::class); + $config = $this->app->get('config'); return $config->get("database.connections.{$this->getRefreshConnection()}.database") === ':memory:'; } /** * Refresh a conventional test database. + * + * Runs migrations if needed. For non-coroutine tests, also starts the + * wrapper transaction. For coroutine tests, transaction handling is + * deferred to setUpRefreshDatabaseInCoroutine(). */ protected function refreshTestDatabase(): void { @@ -92,19 +106,63 @@ protected function refreshTestDatabase(): void $this->mockConsoleOutput = $shouldMockOutput; } - $this->beginDatabaseTransaction(); + // For coroutine tests, transaction handling happens in setUpRefreshDatabaseInCoroutine() + if (! in_array(RunTestsInCoroutine::class, class_uses_recursive(static::class), true)) { + $this->beginDatabaseTransactionWork(); + + $this->beforeApplicationDestroyed(function () { + $this->rollbackDatabaseTransactionWork(); + }); + } } /** - * Begin a database transaction on the testing database. + * Start database transaction in the test coroutine. + * + * Called by RunTestsInCoroutine before the test runs. Maintains correct + * ordering: transaction starts, then afterRefreshingDatabase runs, then + * test executes. This keeps all transaction state in the same coroutine. + * + * Note: restoreInMemoryDatabase() runs earlier in refreshDatabase() before + * migrations, which is the correct ordering for in-memory SQLite. */ - public function beginDatabaseTransaction(): void + protected function setUpRefreshDatabaseInCoroutine(): void { - $database = $this->app->get(Db::class); + $this->beginDatabaseTransactionWork(); + $this->afterRefreshingDatabase(); + $this->refreshModelBootedStates(); + } - foreach ($this->connectionsToTransact() as $name) { + /** + * Rollback database transaction in the test coroutine. + * + * Called by RunTestsInCoroutine after the test runs. + */ + protected function tearDownRefreshDatabaseInCoroutine(): void + { + $this->rollbackDatabaseTransactionWork(); + } + + /** + * Start transactions on all connections. + */ + protected function beginDatabaseTransactionWork(): void + { + $database = $this->app->get(DatabaseManager::class); + $connections = $this->connectionsToTransact(); + + // Create a testing-aware transaction manager that properly handles afterCommit callbacks + $this->app->instance( + 'db.transactions', + $transactionsManager = new DatabaseTransactionsManager($connections) + ); + + foreach ($connections as $name) { $connection = $database->connection($name); + // Set the testing transaction manager on the connection + $connection->setTransactionManager($transactionsManager); + if ($this->usingInMemoryDatabase()) { RefreshDatabaseState::$inMemoryConnections[$name] ??= $connection->getPdo(); } @@ -113,30 +171,40 @@ public function beginDatabaseTransaction(): void $connection->unsetEventDispatcher(); $connection->beginTransaction(); - $connection->setEventDispatcher($dispatcher); + + if ($dispatcher) { + $connection->setEventDispatcher($dispatcher); + } } + } + + /** + * Rollback transactions on all connections. + */ + protected function rollbackDatabaseTransactionWork(): void + { + $database = $this->app->get(DatabaseManager::class); - $this->beforeApplicationDestroyed(function () use ($database) { - foreach ($this->connectionsToTransact() as $name) { - $connection = $database->connection($name); - $dispatcher = $connection->getEventDispatcher(); + foreach ($this->connectionsToTransact() as $name) { + $connection = $database->connection($name); + $dispatcher = $connection->getEventDispatcher(); - $connection->unsetEventDispatcher(); + $connection->unsetEventDispatcher(); - if (! $connection->getPdo()->inTransaction()) { - RefreshDatabaseState::$migrated = false; - } + if (! $connection->getPdo()->inTransaction()) { + RefreshDatabaseState::$migrated = false; + } + + if ($connection instanceof DatabaseConnection) { + $connection->forgetRecordModificationState(); + } - if ($connection instanceof DatabaseConnection) { - $connection->resetRecordsModified(); - } + $connection->rollBack(); - $connection->rollBack(); + if ($dispatcher) { $connection->setEventDispatcher($dispatcher); - // this will trigger a database refresh warning - // $connection->disconnect(); } - }); + } } /** @@ -144,7 +212,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(); @@ -181,7 +249,7 @@ protected function afterRefreshingDatabase(): void protected function getRefreshConnection(): string { return $this->app - ->get(ConfigInterface::class) + ->get('config') ->get('database.default'); } } diff --git a/src/foundation/src/Testing/TestCase.php b/src/foundation/src/Testing/TestCase.php index 8aee00de1..270b75d7d 100644 --- a/src/foundation/src/Testing/TestCase.php +++ b/src/foundation/src/Testing/TestCase.php @@ -6,7 +6,10 @@ use Carbon\Carbon; use Carbon\CarbonImmutable; -use Hyperf\Coroutine\Coroutine; +use Faker\Generator as FakerGenerator; +use Hypervel\Context\Context; +use Hypervel\Coroutine\Coroutine; +use Hypervel\Database\Eloquent\Model; use Hypervel\Foundation\Testing\Concerns\InteractsWithAuthentication; use Hypervel\Foundation\Testing\Concerns\InteractsWithConsole; use Hypervel\Foundation\Testing\Concerns\InteractsWithContainer; @@ -16,10 +19,9 @@ 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; +use function Hypervel\Coroutine\run; abstract class TestCase extends \PHPUnit\Framework\TestCase { @@ -61,9 +63,21 @@ protected function setUp(): void $this->refreshApplication(); } - $this->runInCoroutine( - fn () => $this->setUpTraits() - ); + // Reset after Application exists so container-change detection works correctly + // and rebinding hooks are registered on the current container. + DatabaseConnectionResolver::resetCachedConnections(); + + $this->setUpFaker(); + + $this->runInCoroutine(function () { + $this->setUpTraits(); + + // Preserve transaction manager context for the test coroutine. + // RefreshDatabase stores transaction state in Context, but setUpTraits runs + // in a temporary coroutine. Copy to non-coroutine context so the test + // coroutine (which copies from nonCoContext) can access it. + $this->preserveTransactionContext(); + }); foreach ($this->afterApplicationCreatedCallbacks as $callback) { $callback(); @@ -114,6 +128,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 ($app) => \Faker\Factory::create($app->make('config')->get('app.faker_locale', 'en_US')) + ); + } + } + protected function tearDown(): void { if ($this->app) { @@ -132,14 +159,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(); } @@ -148,6 +167,13 @@ protected function tearDown(): void CarbonImmutable::setTestNow(); } + // Reset Model strict mode flags to prevent test pollution. + // These are process-global in Swoole, so tests that enable strict + // modes must not leak that state to subsequent tests. + Model::preventSilentlyDiscardingAttributes(false); + Model::preventLazyLoading(false); + Model::preventAccessingMissingAttributes(false); + $this->setUpHasRun = false; } @@ -187,8 +213,28 @@ protected function callBeforeApplicationDestroyedCallbacks() } } + /** + * Preserve transaction manager context for the test coroutine. + * + * RefreshDatabase and DatabaseTransactions store transaction state in Context. + * Since setUpTraits runs in a temporary coroutine (separate from the test method's + * coroutine), we must copy this state to non-coroutine context. The test coroutine + * will then copy from non-coroutine context via copyFromNonCoroutine(). + */ + protected function preserveTransactionContext(): void + { + Context::copyToNonCoroutine([ + '__db.transactions.committed', + '__db.transactions.pending', + '__db.transactions.current', + ]); + } + /** * 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 { diff --git a/src/foundation/src/Testing/Traits/CanConfigureMigrationCommands.php b/src/foundation/src/Testing/Traits/CanConfigureMigrationCommands.php index 3d7561f86..8f16e3d7e 100644 --- a/src/foundation/src/Testing/Traits/CanConfigureMigrationCommands.php +++ b/src/foundation/src/Testing/Traits/CanConfigureMigrationCommands.php @@ -4,8 +4,6 @@ namespace Hypervel\Foundation\Testing\Traits; -use Hyperf\Contract\ConfigInterface; - trait CanConfigureMigrationCommands { /** @@ -15,7 +13,7 @@ protected function migrateFreshUsing(): array { $seeder = $this->seeder(); $connection = $this->app - ->get(ConfigInterface::class) + ->get('config') ->get('database.default'); return array_merge( diff --git a/src/foundation/src/helpers.php b/src/foundation/src/helpers.php index da526a6d4..e71790b24 100644 --- a/src/foundation/src/helpers.php +++ b/src/foundation/src/helpers.php @@ -3,36 +3,36 @@ declare(strict_types=1); 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\Context\ApplicationContext; +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\Config\Repository : ($key is string ? mixed : null)) */ function config(mixed $key = null, mixed $default = null): mixed { diff --git a/src/guzzle/LICENSE.md b/src/guzzle/LICENSE.md new file mode 100644 index 000000000..385442433 --- /dev/null +++ b/src/guzzle/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. diff --git a/src/guzzle/README.md b/src/guzzle/README.md new file mode 100644 index 000000000..2aee856cd --- /dev/null +++ b/src/guzzle/README.md @@ -0,0 +1,4 @@ +Guzzle for Hypervel +=== + +[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/hypervel/guzzle) diff --git a/src/guzzle/composer.json b/src/guzzle/composer.json new file mode 100644 index 000000000..d88737c95 --- /dev/null +++ b/src/guzzle/composer.json @@ -0,0 +1,49 @@ +{ + "name": "hypervel/guzzle", + "type": "library", + "description": "Swoole coroutine handler for Guzzle HTTP client.", + "license": "MIT", + "keywords": [ + "php", + "guzzle", + "swoole", + "coroutine", + "hypervel" + ], + "authors": [ + { + "name": "Albert Chen", + "email": "albert@hypervel.org" + }, + { + "name": "Raj Siva-Rajah", + "homepage": "https://github.com/binaryfire" + } + ], + "support": { + "issues": "https://github.com/hypervel/components/issues", + "source": "https://github.com/hypervel/components" + }, + "autoload": { + "psr-4": { + "Hypervel\\Guzzle\\": "src/" + } + }, + "require": { + "php": "^8.4", + "guzzlehttp/guzzle": "^7.0", + "hypervel/context": "^0.4", + "hypervel/coroutine": "^0.4", + "hypervel/engine": "^0.4", + "hypervel/pool": "^0.4", + "psr/http-message": "^1.0|^2.0" + }, + "config": { + "sort-packages": true + }, + "extra": { + "branch-alias": { + "dev-main": "0.4-dev" + } + } +} diff --git a/src/guzzle/src/ClientFactory.php b/src/guzzle/src/ClientFactory.php new file mode 100644 index 000000000..bf022a9e2 --- /dev/null +++ b/src/guzzle/src/ClientFactory.php @@ -0,0 +1,55 @@ +runInSwoole = extension_loaded('swoole'); + if (defined('SWOOLE_HOOK_NATIVE_CURL')) { + $this->nativeCurlHook = SWOOLE_HOOK_NATIVE_CURL; + } + } + + /** + * Create a new Guzzle client instance. + */ + public function create(array $options = []): Client + { + $stack = null; + + if ( + $this->runInSwoole + && Coroutine::inCoroutine() + && (Runtime::getHookFlags() & $this->nativeCurlHook) == 0 + ) { + $stack = HandlerStack::create(new CoroutineHandler()); + } + + $config = array_replace(['handler' => $stack], $options); + + return $this->container->make(Client::class, ['config' => $config]); + } +} diff --git a/src/guzzle/src/ConfigProvider.php b/src/guzzle/src/ConfigProvider.php new file mode 100644 index 000000000..3591502a3 --- /dev/null +++ b/src/guzzle/src/ConfigProvider.php @@ -0,0 +1,16 @@ + 80, + 'https' => 443, + ]; + + /** + * Handle the HTTP request. + */ + public function __invoke(RequestInterface $request, array $options): PromiseInterface + { + $uri = $request->getUri(); + $host = $uri->getHost(); + $port = $this->getPort($uri); + $ssl = $uri->getScheme() === 'https'; + $path = $uri->getPath(); + $query = $uri->getQuery(); + + if (empty($path)) { + $path = '/'; + } + if ($query !== '') { + $path .= '?' . $query; + } + + $client = $this->makeClient($host, $port, $ssl); + + // Init Headers + $headers = $this->initHeaders($request, $options); + // Init Settings + $settings = $this->getSettings($request, $options); + if (! empty($settings)) { + $client->set($settings); + } + + $ms = microtime(true); + + try { + $raw = $client->request($request->getMethod(), $path, $headers, (string) $request->getBody()); + } catch (Exception $exception) { + $message = sprintf('Failed to connecting to %s port %s, %s', $host, $port, $exception->getMessage()); + $exception = new ConnectException($message, $request, null, [ + 'errCode' => $exception->getCode(), + ]); + return Create::rejectionFor($exception); + } + + $response = $this->getResponse($raw, $request, $options, microtime(true) - $ms); + + return new FulfilledPromise($response); + } + + /** + * Create a new HTTP client instance. + */ + protected function makeClient(string $host, int $port, bool $ssl): Client + { + return new Client($host, $port, $ssl); + } + + /** + * Initialize the request headers. + */ + protected function initHeaders(RequestInterface $request, array $options): array + { + $headers = $request->getHeaders(); + $userInfo = $request->getUri()->getUserInfo(); + if ($userInfo) { + $headers['Authorization'] = sprintf('Basic %s', base64_encode($userInfo)); + } + + return $this->rewriteHeaders($headers); + } + + /** + * Rewrite headers to be compatible with Swoole's HTTP client. + */ + protected function rewriteHeaders(array $headers): array + { + // Content-Length can cause 400 errors in some cases. + // Expect header is not supported by Swoole's coroutine HTTP client. + unset($headers['Content-Length'], $headers['Expect']); + + return $headers; + } + + /** + * Get the Swoole client settings from request options. + */ + protected function getSettings(RequestInterface $request, array $options): array + { + $settings = []; + if (isset($options['delay']) && $options['delay'] > 0) { + usleep(intval($options['delay'] * 1000)); + } + + // SSL certificate verification + if (isset($options['verify'])) { + $settings['ssl_verify_peer'] = false; + if ($options['verify'] !== false) { + $settings['ssl_verify_peer'] = true; + $settings['ssl_allow_self_signed'] = true; + $settings['ssl_host_name'] = $request->getUri()->getHost(); + if (is_string($options['verify'])) { + // Throw an error if the file/folder/link path is not valid or doesn't exist. + if (! file_exists($options['verify'])) { + throw new InvalidArgumentException("SSL CA bundle not found: {$options['verify']}"); + } + // If it's a directory or a link to a directory use CURLOPT_CAPATH. + // If not, it's probably a file, or a link to a file, so use CURLOPT_CAINFO. + if (is_dir($options['verify']) + || (is_link($options['verify']) && is_dir(readlink($options['verify'])))) { + $settings['ssl_capath'] = $options['verify']; + } else { + $settings['ssl_cafile'] = $options['verify']; + } + } + } + } + + // Timeout + if (isset($options['timeout']) && $options['timeout'] > 0) { + $settings['timeout'] = $options['timeout']; + } + + // Proxy + if (! empty($options['proxy'])) { + $uri = null; + if (is_array($options['proxy'])) { + $scheme = $request->getUri()->getScheme(); + if (isset($options['proxy'][$scheme])) { + $host = $request->getUri()->getHost(); + if (! isset($options['proxy']['no']) || ! GuzzleHttp\Utils::isHostInNoProxy($host, $options['proxy']['no'])) { + $uri = new Uri($options['proxy'][$scheme]); + } + } + } else { + $uri = new Uri($options['proxy']); + } + + if ($uri) { + $settings['http_proxy_host'] = $uri->getHost(); + $settings['http_proxy_port'] = $this->getPort($uri); + if ($uri->getUserInfo()) { + [$user, $password] = explode(':', $uri->getUserInfo()); + $settings['http_proxy_user'] = $user; + if (! empty($password)) { + $settings['http_proxy_password'] = $password; + } + } + } + } + + // SSL KEY + isset($options['ssl_key']) && $settings['ssl_key_file'] = $options['ssl_key']; + isset($options['cert']) && $settings['ssl_cert_file'] = $options['cert']; + + // Swoole Setting + if (isset($options['swoole']) && is_array($options['swoole'])) { + $settings = array_replace($settings, $options['swoole']); + } + + return $settings; + } + + /** + * Create a PSR-7 response from the raw response. + */ + protected function getResponse(RawResponse $raw, RequestInterface $request, array $options, float $transferTime): Psr7\Response + { + $body = $raw->body; + $sink = $options['sink'] ?? null; + if (isset($sink) && (is_string($sink) || is_resource($sink))) { + $body = $this->createSink($body, $sink); + } + + $response = new Psr7\Response( + $raw->statusCode, + $raw->headers, + $body + ); + + if ($callback = $options[RequestOptions::ON_STATS] ?? null) { + $stats = new TransferStats( + $request, + $response, + $transferTime, + $raw->statusCode, + [] + ); + + $callback($stats); + } + + return $response; + } + + /** + * Create a stream from the response body. + */ + protected function createStream(string $body): StreamInterface + { + return Utils::streamFor($body); + } + + /** + * Write the response body to a sink. + * + * @param resource|string $stream + * @return resource + */ + protected function createSink(string $body, $stream) + { + if (is_string($stream)) { + $stream = fopen($stream, 'w+'); + } + if ($body !== '') { + fwrite($stream, $body); + } + + return $stream; + } + + /** + * Get the port from the URI. + * + * @throws InvalidArgumentException + */ + protected function getPort(UriInterface $uri): int + { + if ($port = $uri->getPort()) { + return $port; + } + if (isset(self::$defaultPorts[$uri->getScheme()])) { + return self::$defaultPorts[$uri->getScheme()]; + } + throw new InvalidArgumentException("Unsupported scheme from the URI {$uri->__toString()}"); + } +} diff --git a/src/guzzle/src/HandlerStackFactory.php b/src/guzzle/src/HandlerStackFactory.php new file mode 100644 index 000000000..b6074e3fa --- /dev/null +++ b/src/guzzle/src/HandlerStackFactory.php @@ -0,0 +1,95 @@ + 1, + 'max_connections' => 30, + 'wait_timeout' => 3.0, + 'max_idle_time' => 60, + ]; + + /** + * The default middlewares. + */ + protected array $middlewares = [ + 'retry' => [RetryMiddleware::class, [1, 10]], + ]; + + /** + * Whether to use the pool handler. + */ + protected bool $usePoolHandler = false; + + /** + * Create a new handler stack factory instance. + */ + public function __construct() + { + if (class_exists(ApplicationContext::class)) { + $this->usePoolHandler = class_exists(PoolFactory::class) && ApplicationContext::getContainer() instanceof Container; + } + } + + /** + * Create a new handler stack. + */ + public function create(array $option = [], array $middlewares = []): HandlerStack + { + $handler = null; + $option = array_merge($this->option, $option); + $middlewares = array_merge($this->middlewares, $middlewares); + + if (Coroutine::inCoroutine()) { + $handler = $this->getHandler($option); + } + + $stack = HandlerStack::create($handler); + + foreach ($middlewares as $key => $middleware) { + if (is_array($middleware)) { + [$class, $arguments] = $middleware; + $middleware = new $class(...$arguments); + } + + if ($middleware instanceof MiddlewareInterface) { + $stack->push($middleware->getMiddleware(), $key); + } + } + + return $stack; + } + + /** + * Get the appropriate handler based on the environment. + */ + protected function getHandler(array $option): CoroutineHandler + { + if ($this->usePoolHandler) { + return app(PoolHandler::class, [ + 'option' => $option, + ]); + } + + if (class_exists(ApplicationContext::class)) { + return app(CoroutineHandler::class); + } + + return new CoroutineHandler(); + } +} diff --git a/src/guzzle/src/MiddlewareInterface.php b/src/guzzle/src/MiddlewareInterface.php new file mode 100644 index 000000000..4531723af --- /dev/null +++ b/src/guzzle/src/MiddlewareInterface.php @@ -0,0 +1,13 @@ +getUri(); + $host = $uri->getHost(); + $port = $uri->getPort(); + $ssl = $uri->getScheme() === 'https'; + $path = $uri->getPath(); + $query = $uri->getQuery(); + + if (empty($port)) { + $port = $ssl ? 443 : 80; + } + if (empty($path)) { + $path = '/'; + } + if ($query !== '') { + $path .= '?' . $query; + } + + $pool = $this->factory->get( + $this->getPoolName($uri), + fn () => $this->makeClient($host, $port, $ssl), + $this->option + ); + + $connection = $pool->get(); + $response = null; + try { + /** @var Client $client */ + $client = $connection->getConnection(); + if (! $this->isCookiePersistent) { + $client->setCookies([]); + } + + $headers = $this->initHeaders($request, $options); + $settings = $this->getSettings($request, $options); + if (! empty($settings)) { + $client->set($settings); + } + + $ms = microtime(true); + + try { + $raw = $client->request($request->getMethod(), $path, $headers, (string) $request->getBody()); + } catch (Exception $exception) { + $connection->close(); + $exception = new ConnectException($exception->getMessage(), $request, null, [ + 'errCode' => $exception->getCode(), + ]); + return Create::rejectionFor($exception); + } + + $response = $this->getResponse($raw, $request, $options, microtime(true) - $ms); + } finally { + $connection->release(); + } + + return new FulfilledPromise($response); + } + + /** + * Get the pool name for the given URI. + */ + protected function getPoolName(UriInterface $uri): string + { + return sprintf('guzzle.handler.%s.%d.%s', $uri->getHost(), $uri->getPort(), $uri->getScheme()); + } +} diff --git a/src/guzzle/src/RetryMiddleware.php b/src/guzzle/src/RetryMiddleware.php new file mode 100644 index 000000000..2794d0a67 --- /dev/null +++ b/src/guzzle/src/RetryMiddleware.php @@ -0,0 +1,44 @@ +isOk($response) && $retries < $this->retries) { + return true; + } + return false; + }, function () { + return $this->delay; + }); + } + + /** + * Check if the response status is successful. + */ + protected function isOk(?ResponseInterface $response): bool + { + return $response && $response->getStatusCode() >= 200 && $response->getStatusCode() < 300; + } +} diff --git a/src/hashing/composer.json b/src/hashing/composer.json index ac47f04da..f7e6473fd 100644 --- a/src/hashing/composer.json +++ b/src/hashing/composer.json @@ -26,9 +26,9 @@ } }, "require": { - "php": "^8.2", + "php": "^8.4", "hyperf/config": "~3.1.0", - "hyperf/context": "~3.1.0", + "hypervel/context": "^0.4", "hyperf/support": "~3.1.0" }, "config": { @@ -39,7 +39,7 @@ "config": "Hypervel\\Hashing\\ConfigProvider" }, "branch-alias": { - "dev-main": "0.3-dev" + "dev-main": "0.4-dev" } } } \ No newline at end of file diff --git a/src/hashing/src/Argon2IdHasher.php b/src/hashing/src/Argon2IdHasher.php index 913a55495..77b5b3f78 100644 --- a/src/hashing/src/Argon2IdHasher.php +++ b/src/hashing/src/Argon2IdHasher.php @@ -33,4 +33,12 @@ protected function algorithm(): int|string { return PASSWORD_ARGON2ID; } + + /** + * Verify the hashed value's algorithm. + */ + protected function isUsingCorrectAlgorithm(string $hashedValue): bool + { + return $this->info($hashedValue)['algoName'] === 'argon2id'; + } } diff --git a/src/hashing/src/ArgonHasher.php b/src/hashing/src/ArgonHasher.php index 78dc033d1..ccb7cdd15 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 @@ -94,6 +94,50 @@ public function needsRehash(string $hashedValue, array $options = []): bool ]); } + /** + * Verifies that the configuration is less than or equal to what is configured. + * + * @internal + */ + public function verifyConfiguration(string $hashedValue): bool + { + return $this->isUsingCorrectAlgorithm($hashedValue) && $this->isUsingValidOptions($hashedValue); + } + + /** + * Verify the hashed value's algorithm. + */ + protected function isUsingCorrectAlgorithm(string $hashedValue): bool + { + return $this->info($hashedValue)['algoName'] === 'argon2i'; + } + + /** + * Verify the hashed value's options. + */ + protected function isUsingValidOptions(string $hashedValue): bool + { + ['options' => $options] = $this->info($hashedValue); + + if ( + ! is_int($options['memory_cost'] ?? null) + || ! is_int($options['time_cost'] ?? null) + || ! is_int($options['threads'] ?? null) + ) { + return false; + } + + if ( + $options['memory_cost'] > $this->memory + || $options['time_cost'] > $this->time + || $options['threads'] > $this->threads + ) { + return false; + } + + return true; + } + /** * Set the default password memory factor. * diff --git a/src/hashing/src/BcryptHasher.php b/src/hashing/src/BcryptHasher.php index ef746d5f9..1699bcd80 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 @@ -70,6 +70,42 @@ public function needsRehash(string $hashedValue, array $options = []): bool ]); } + /** + * Verifies that the configuration is less than or equal to what is configured. + * + * @internal + */ + public function verifyConfiguration(string $hashedValue): bool + { + return $this->isUsingCorrectAlgorithm($hashedValue) && $this->isUsingValidOptions($hashedValue); + } + + /** + * Verify the hashed value's algorithm. + */ + protected function isUsingCorrectAlgorithm(string $hashedValue): bool + { + return $this->info($hashedValue)['algoName'] === 'bcrypt'; + } + + /** + * Verify the hashed value's options. + */ + protected function isUsingValidOptions(string $hashedValue): bool + { + ['options' => $options] = $this->info($hashedValue); + + if (! is_int($options['cost'] ?? null)) { + return false; + } + + if ($options['cost'] > $this->rounds) { + return false; + } + + return true; + } + /** * Set the default password work factor. * 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..b72e2a670 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 { @@ -76,6 +76,22 @@ public function isHashed(string $value): bool return password_get_info($value)['algo'] !== null; } + /** + * Verifies that the configuration is less than or equal to what is configured. + * + * @internal + */ + public function verifyConfiguration(string $hashedValue): bool + { + $driver = $this->driver(); + + if (method_exists($driver, 'verifyConfiguration')) { + return $driver->verifyConfiguration($hashedValue); + } + + return true; + } + /** * Get the default driver name. */ diff --git a/src/horizon/composer.json b/src/horizon/composer.json index 5cea2cf1b..11c771e28 100644 --- a/src/horizon/composer.json +++ b/src/horizon/composer.json @@ -19,15 +19,15 @@ } ], "require": { - "php": "^8.2", - "hyperf/context": "~3.1.0", + "php": "^8.4", + "hypervel/context": "^0.4", "nesbot/carbon": "^2.72.6", - "hypervel/support": "^0.3", - "hypervel/cache": "^0.3", - "hypervel/core": "^0.3", - "hypervel/console": "^0.3", - "hypervel/event": "^0.3", - "hypervel/queue": "^0.3" + "hypervel/support": "^0.4", + "hypervel/cache": "^0.4", + "hypervel/core": "^0.4", + "hypervel/console": "^0.4", + "hypervel/event": "^0.4", + "hypervel/queue": "^0.4" }, "autoload": { "psr-4": { @@ -36,7 +36,7 @@ }, "extra": { "branch-alias": { - "dev-main": "0.3-dev" + "dev-main": "0.4-dev" }, "hypervel": { "providers": [ 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..e4a5a12d2 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\InteractsWithTime; use Hypervel\Support\Str; -use Hypervel\Support\Traits\InteractsWithTime; class TerminateCommand extends Command { diff --git a/src/horizon/src/HorizonServiceProvider.php b/src/horizon/src/HorizonServiceProvider.php index 93b2f2ae2..df0d04c87 100644 --- a/src/horizon/src/HorizonServiceProvider.php +++ b/src/horizon/src/HorizonServiceProvider.php @@ -4,10 +4,10 @@ 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\Redis\RedisFactory; use Hypervel\Support\Facades\Route; use Hypervel\Support\ServiceProvider; 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/Lock.php b/src/horizon/src/Lock.php index 9bab0ce03..0f3ab7067 100644 --- a/src/horizon/src/Lock.php +++ b/src/horizon/src/Lock.php @@ -5,8 +5,8 @@ namespace Hypervel\Horizon; use Closure; -use Hyperf\Redis\RedisFactory; -use Hyperf\Redis\RedisProxy; +use Hypervel\Redis\RedisFactory; +use Hypervel\Redis\RedisProxy; class Lock { @@ -47,7 +47,9 @@ public function exists(string $key): bool */ public function get(string $key, int $seconds = 60): bool { - if ($result = $this->connection()->setNx($key, 1)) { + $result = $this->connection()->setNx($key, '1') === 1; + + if ($result) { $this->connection()->expire($key, $seconds); } 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 6d644e846..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 int|string $process */ $processes = $processes->merge($this->exec->run('pgrep -P ' . (string) $process)); } diff --git a/src/horizon/src/RedisHorizonCommandQueue.php b/src/horizon/src/RedisHorizonCommandQueue.php index 0bddeec95..8e339accb 100644 --- a/src/horizon/src/RedisHorizonCommandQueue.php +++ b/src/horizon/src/RedisHorizonCommandQueue.php @@ -4,9 +4,9 @@ namespace Hypervel\Horizon; -use Hyperf\Redis\RedisFactory; -use Hyperf\Redis\RedisProxy; use Hypervel\Horizon\Contracts\HorizonCommandQueue; +use Hypervel\Redis\RedisFactory; +use Hypervel\Redis\RedisProxy; class RedisHorizonCommandQueue implements HorizonCommandQueue { 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/RedisJobRepository.php b/src/horizon/src/Repositories/RedisJobRepository.php index b9bafb382..49c5559bf 100644 --- a/src/horizon/src/Repositories/RedisJobRepository.php +++ b/src/horizon/src/Repositories/RedisJobRepository.php @@ -5,11 +5,11 @@ namespace Hypervel\Horizon\Repositories; use Carbon\CarbonImmutable; -use Hyperf\Redis\RedisFactory; -use Hyperf\Redis\RedisProxy; use Hypervel\Horizon\Contracts\JobRepository; use Hypervel\Horizon\JobPayload; use Hypervel\Horizon\LuaScripts; +use Hypervel\Redis\RedisFactory; +use Hypervel\Redis\RedisProxy; use Hypervel\Support\Arr; use Hypervel\Support\Collection; use stdClass; @@ -509,7 +509,7 @@ public function findFailed(string $id): ?stdClass $this->keys ); - $job = is_array($attributes) && $attributes[$this->keys[0]] ? (object) $attributes : null; + $job = $attributes[$this->keys[0]] ? (object) $attributes : null; if ($job && $job->status !== 'failed') { return null; @@ -604,13 +604,11 @@ public function purge(string $queue): int { return $this->connection()->eval( LuaScripts::purge(), - [ - 'recent_jobs', - 'pending_jobs', - config('horizon.prefix'), - $queue, - ], 2, + 'recent_jobs', + 'pending_jobs', + config('horizon.prefix'), + $queue, ); } diff --git a/src/horizon/src/Repositories/RedisMasterSupervisorRepository.php b/src/horizon/src/Repositories/RedisMasterSupervisorRepository.php index dfd7709c4..b298ec537 100644 --- a/src/horizon/src/Repositories/RedisMasterSupervisorRepository.php +++ b/src/horizon/src/Repositories/RedisMasterSupervisorRepository.php @@ -5,11 +5,11 @@ namespace Hypervel\Horizon\Repositories; use Carbon\CarbonImmutable; -use Hyperf\Redis\RedisFactory; -use Hyperf\Redis\RedisProxy; use Hypervel\Horizon\Contracts\MasterSupervisorRepository; use Hypervel\Horizon\Contracts\SupervisorRepository; use Hypervel\Horizon\MasterSupervisor; +use Hypervel\Redis\RedisFactory; +use Hypervel\Redis\RedisProxy; use Hypervel\Support\Arr; use stdClass; diff --git a/src/horizon/src/Repositories/RedisMetricsRepository.php b/src/horizon/src/Repositories/RedisMetricsRepository.php index ee64179de..328668ca0 100644 --- a/src/horizon/src/Repositories/RedisMetricsRepository.php +++ b/src/horizon/src/Repositories/RedisMetricsRepository.php @@ -5,12 +5,12 @@ namespace Hypervel\Horizon\Repositories; use Carbon\CarbonImmutable; -use Hyperf\Redis\RedisFactory; -use Hyperf\Redis\RedisProxy; use Hypervel\Horizon\Contracts\MetricsRepository; use Hypervel\Horizon\Lock; use Hypervel\Horizon\LuaScripts; use Hypervel\Horizon\WaitTimeCalculator; +use Hypervel\Redis\RedisFactory; +use Hypervel\Redis\RedisProxy; use Hypervel\Support\Str; class RedisMetricsRepository implements MetricsRepository @@ -144,12 +144,10 @@ public function incrementJob(string $job, ?float $runtime): void { $this->connection()->eval( LuaScripts::updateMetrics(), - [ - 'job:' . $job, - 'measured_jobs', - str_replace(',', '.', (string) $runtime), - ], 2, + 'job:' . $job, + 'measured_jobs', + str_replace(',', '.', (string) $runtime), ); } @@ -160,12 +158,10 @@ public function incrementQueue(string $queue, ?float $runtime): void { $this->connection()->eval( LuaScripts::updateMetrics(), - [ - 'queue:' . $queue, - 'measured_queues', - str_replace(',', '.', (string) $runtime), - ], 2, + 'queue:' . $queue, + 'measured_queues', + str_replace(',', '.', (string) $runtime), ); } diff --git a/src/horizon/src/Repositories/RedisProcessRepository.php b/src/horizon/src/Repositories/RedisProcessRepository.php index eec31fd53..aa4745e52 100644 --- a/src/horizon/src/Repositories/RedisProcessRepository.php +++ b/src/horizon/src/Repositories/RedisProcessRepository.php @@ -5,9 +5,9 @@ namespace Hypervel\Horizon\Repositories; use Carbon\CarbonImmutable; -use Hyperf\Redis\RedisFactory; -use Hyperf\Redis\RedisProxy; use Hypervel\Horizon\Contracts\ProcessRepository; +use Hypervel\Redis\RedisFactory; +use Hypervel\Redis\RedisProxy; class RedisProcessRepository implements ProcessRepository { diff --git a/src/horizon/src/Repositories/RedisSupervisorRepository.php b/src/horizon/src/Repositories/RedisSupervisorRepository.php index ed1f0b004..a979169ce 100644 --- a/src/horizon/src/Repositories/RedisSupervisorRepository.php +++ b/src/horizon/src/Repositories/RedisSupervisorRepository.php @@ -5,10 +5,10 @@ namespace Hypervel\Horizon\Repositories; use Carbon\CarbonImmutable; -use Hyperf\Redis\RedisFactory; -use Hyperf\Redis\RedisProxy; use Hypervel\Horizon\Contracts\SupervisorRepository; use Hypervel\Horizon\Supervisor; +use Hypervel\Redis\RedisFactory; +use Hypervel\Redis\RedisProxy; use Hypervel\Support\Arr; use stdClass; diff --git a/src/horizon/src/Repositories/RedisTagRepository.php b/src/horizon/src/Repositories/RedisTagRepository.php index 2554d8a5a..ea1d17a48 100644 --- a/src/horizon/src/Repositories/RedisTagRepository.php +++ b/src/horizon/src/Repositories/RedisTagRepository.php @@ -4,9 +4,9 @@ namespace Hypervel\Horizon\Repositories; -use Hyperf\Redis\RedisFactory; -use Hyperf\Redis\RedisProxy; use Hypervel\Horizon\Contracts\TagRepository; +use Hypervel\Redis\RedisFactory; +use Hypervel\Redis\RedisProxy; class RedisTagRepository implements TagRepository { 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..bda359ff8 100644 --- a/src/http-client/composer.json +++ b/src/http-client/composer.json @@ -26,18 +26,18 @@ } }, "require": { - "php": "^8.2", - "hyperf/macroable": "~3.1.0", + "php": "^8.4", + "hypervel/macroable": "^0.4", "guzzlehttp/guzzle": "^7.8.2", "guzzlehttp/uri-template": "^1.0", - "hypervel/support": "^0.3" + "hypervel/support": "^0.4" }, "config": { "sort-packages": true }, "extra": { "branch-alias": { - "dev-main": "0.3-dev" + "dev-main": "0.4-dev" } } } diff --git a/src/http-client/src/Factory.php b/src/http-client/src/Factory.php index f6e9186e8..8ee050c08 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 */ @@ -70,6 +68,11 @@ class Factory */ protected bool $preventStrayRequests = false; + /** + * The URL patterns that are allowed as stray requests. + */ + protected array $allowedStrayRequestUrls = []; + protected string $poolProxyClass = ClientPoolProxy::class; /** @@ -239,7 +242,10 @@ public function fakeSequence(string $url = '*'): ResponseSequence public function stubUrl(string $url, array|callable|int|PromiseInterface|Response|string $callback): static { return $this->fake(function ($request, $options) use ($url, $callback) { - if (! Str::is(Str::start($url, '*'), $request->url())) { + $pattern = Str::start($url, '*'); + $requestUrl = $request->url(); + + if (! Str::is($pattern, $requestUrl) && ! Str::is($pattern, Str::finish($requestUrl, '/'))) { return; } @@ -280,9 +286,16 @@ public function preventingStrayRequests(): bool /** * Indicate that an exception should not be thrown if any request is not faked. */ - public function allowStrayRequests(): static + public function allowStrayRequests(?array $only = null): static { - return $this->preventStrayRequests(false); + if (is_null($only)) { + $this->preventStrayRequests(false); + $this->allowedStrayRequestUrls = []; + } else { + $this->allowedStrayRequestUrls = array_values($only); + } + + return $this; } /** @@ -404,7 +417,10 @@ public function recorded(?callable $callback = null): Collection public function createPendingRequest(): PendingRequest { return tap($this->newPendingRequest(), function (PendingRequest $request) { - $request->stub($this->stubCallbacks)->preventStrayRequests($this->preventStrayRequests); + $request + ->stub($this->stubCallbacks) + ->preventStrayRequests($this->preventStrayRequests) + ->allowStrayRequests($this->allowedStrayRequestUrls); }); } diff --git a/src/http-client/src/PendingRequest.php b/src/http-client/src/PendingRequest.php index 1c06f3c97..993290715 100644 --- a/src/http-client/src/PendingRequest.php +++ b/src/http-client/src/PendingRequest.php @@ -16,16 +16,16 @@ use GuzzleHttp\Promise\PromiseInterface; 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\Conditionable; +use Hypervel\Support\Traits\Macroable; use JsonSerializable; use OutOfBoundsException; use Psr\Http\Message\RequestInterface; @@ -139,6 +139,11 @@ class PendingRequest */ protected bool $preventStrayRequests = false; + /** + * The URL patterns that are allowed as stray requests. + */ + protected array $allowedStrayRequestUrls = []; + /** * The middleware callables added by users that will handle requests. */ @@ -625,7 +630,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 +1013,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, }; } @@ -1122,7 +1129,7 @@ public function buildStubHandler(): Closure ->first(); if (is_null($response)) { - if ($this->preventStrayRequests) { + if (! $this->isAllowedRequestUrl((string) $request->getUri())) { throw new RuntimeException( 'Attempted request to [' . (string) $request->getUri() . '] without a matching fake.' ); @@ -1229,6 +1236,34 @@ public function preventStrayRequests(bool $prevent = true): static return $this; } + /** + * Allow stray, unfaked requests for specific URL patterns. + */ + public function allowStrayRequests(array $only): static + { + $this->allowedStrayRequestUrls = array_values($only); + + return $this; + } + + /** + * Determine if the given URL is allowed as a stray request. + */ + public function isAllowedRequestUrl(string $url): bool + { + if (! $this->preventStrayRequests) { + return true; + } + + foreach ($this->allowedStrayRequestUrls as $pattern) { + if (Str::is($pattern, $url)) { + return true; + } + } + + return false; + } + /** * Toggle asynchronicity in requests. */ 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..809ddee34 100644 --- a/src/http/composer.json +++ b/src/http/composer.json @@ -26,12 +26,11 @@ } }, "require": { - "php": "^8.2", + "php": "^8.4", "hyperf/http-server": "~3.1.0", - "hyperf/collection": "~3.1.0", - "hyperf/stringable": "~3.1.0", - "hyperf/codec": "~3.1.0", - "hyperf/context": "~3.1.0", + "hypervel/collections": "^0.4", + "hypervel/support": "^0.4", + "hypervel/context": "^0.4", "hyperf/contract": "~3.1.0", "hyperf/resource": "~3.1.0", "nesbot/carbon": "^2.72.6" @@ -48,7 +47,7 @@ "config": "Hypervel\\Http\\ConfigProvider" }, "branch-alias": { - "dev-main": "0.3-dev" + "dev-main": "0.4-dev" } } } \ No newline at end of file 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..11dde5b9f 100644 --- a/src/http/src/CoreMiddleware.php +++ b/src/http/src/CoreMiddleware.php @@ -5,11 +5,6 @@ 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; @@ -19,10 +14,14 @@ use Hyperf\View\RenderInterface; use Hyperf\ViewEngine\Contract\Renderable; use Hyperf\ViewEngine\Contract\ViewInterface; +use Hypervel\Context\RequestContext; 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; @@ -54,7 +53,7 @@ protected function transferToResponse($response, ServerRequestInterface $request { if ($response instanceof Renderable) { if ($response instanceof ViewInterface) { - if ($this->container->get(ConfigInterface::class)->get('view.event.enable', false)) { + if ($this->container->get('config')->get('view.event.enable', false)) { $this->container->get(EventDispatcherInterface::class) ->dispatch(new ViewRendered($response)); } @@ -73,7 +72,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 +85,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/JsonResponse.php b/src/http/src/JsonResponse.php new file mode 100644 index 000000000..d3866f4ae --- /dev/null +++ b/src/http/src/JsonResponse.php @@ -0,0 +1,262 @@ +response = $response; + $this->original = $originalData; + $this->encodingOptions = $encodingOptions; + } + + /** + * Get the underlying PSR-7 response. + */ + public function toPsr7(): ResponseInterface + { + return $this->response; + } + + /** + * Get the JSON decoded data from the response. + */ + public function getData(bool $assoc = false, int $depth = 512): mixed + { + return json_decode((string) $this->response->getBody(), $assoc, $depth); + } + + /** + * Set the data to be sent as JSON. + * + * @throws InvalidArgumentException + */ + public function setData(mixed $data = []): static + { + $this->original = $data; + + $json = match (true) { + $data instanceof Jsonable => $data->toJson($this->encodingOptions), + $data instanceof JsonSerializable => json_encode($data->jsonSerialize(), $this->encodingOptions | JSON_THROW_ON_ERROR), + $data instanceof Arrayable => json_encode($data->toArray(), $this->encodingOptions | JSON_THROW_ON_ERROR), + default => json_encode($data, $this->encodingOptions | JSON_THROW_ON_ERROR), + }; + + $body = $this->response->getBody(); + if ($body->isSeekable()) { + $body->rewind(); + } + + $newBody = new \Hyperf\HttpMessage\Stream\SwooleStream($json); + $this->response = $this->response->withBody($newBody); + + return $this; + } + + /** + * Get the JSON encoding options. + */ + public function getEncodingOptions(): int + { + return $this->encodingOptions; + } + + /** + * Set the JSON encoding options. + */ + public function setEncodingOptions(int $options): static + { + $this->encodingOptions = $options; + + if ($this->original !== null) { + $this->setData($this->original); + } + + return $this; + } + + /** + * Set a header on the response. + */ + public function header(string $key, string|array $values, bool $replace = true): static + { + $this->response = $replace + ? $this->response->withHeader($key, $values) + : $this->response->withAddedHeader($key, $values); + + return $this; + } + + /** + * Add multiple headers to the response. + */ + public function withHeaders(array $headers): static + { + foreach ($headers as $key => $value) { + $this->response = $this->response->withHeader($key, $value); + } + + return $this; + } + + /** + * Set the response status code. + */ + public function setStatusCode(int $code, string $text = ''): static + { + $this->response = $this->response->withStatus($code, $text); + + return $this; + } + + /** + * Get the status code. + */ + public function status(): int + { + return $this->response->getStatusCode(); + } + + /** + * Get the content of the response. + */ + public function content(): string + { + return (string) $this->response->getBody(); + } + + // ========================================================================= + // PSR-7 ResponseInterface Implementation (delegates to wrapped response) + // ========================================================================= + + public function getProtocolVersion(): string + { + return $this->response->getProtocolVersion(); + } + + public function withProtocolVersion(string $version): static + { + $clone = clone $this; + $clone->response = $this->response->withProtocolVersion($version); + return $clone; + } + + public function getHeaders(): array + { + return $this->response->getHeaders(); + } + + public function hasHeader(string $name): bool + { + return $this->response->hasHeader($name); + } + + public function getHeader(string $name): array + { + return $this->response->getHeader($name); + } + + public function getHeaderLine(string $name): string + { + return $this->response->getHeaderLine($name); + } + + public function withHeader(string $name, $value): static + { + $clone = clone $this; + $clone->response = $this->response->withHeader($name, $value); + return $clone; + } + + public function withAddedHeader(string $name, $value): static + { + $clone = clone $this; + $clone->response = $this->response->withAddedHeader($name, $value); + return $clone; + } + + public function withoutHeader(string $name): static + { + $clone = clone $this; + $clone->response = $this->response->withoutHeader($name); + return $clone; + } + + public function getBody(): StreamInterface + { + return $this->response->getBody(); + } + + public function withBody(StreamInterface $body): static + { + $clone = clone $this; + $clone->response = $this->response->withBody($body); + return $clone; + } + + public function getStatusCode(): int + { + return $this->response->getStatusCode(); + } + + public function withStatus(int $code, string $reasonPhrase = ''): static + { + $clone = clone $this; + $clone->response = $this->response->withStatus($code, $reasonPhrase); + return $clone; + } + + public function getReasonPhrase(): string + { + return $this->response->getReasonPhrase(); + } + + /** + * Dynamically pass method calls to the underlying response. + */ + public function __call(string $method, array $parameters): mixed + { + return $this->forwardCallTo($this->response, $method, $parameters); + } +} diff --git a/src/http/src/Middleware/HandleCors.php b/src/http/src/Middleware/HandleCors.php index bc4e49047..dee74cdcb 100644 --- a/src/http/src/Middleware/HandleCors.php +++ b/src/http/src/Middleware/HandleCors.php @@ -4,9 +4,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; @@ -26,7 +25,7 @@ public function __construct( protected RequestContract $request, protected Cors $cors, ) { - $this->config = $container->get(ConfigInterface::class)->get('cors', []); + $this->config = $container->get('config')->get('cors', []); } public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface diff --git a/src/http/src/Request.php b/src/http/src/Request.php index 3da99ee8d..4623d6129 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\ApplicationContext; +use Hypervel\Context\Context; 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/CollectsResources.php b/src/http/src/Resources/CollectsResources.php new file mode 100644 index 000000000..f3f39d098 --- /dev/null +++ b/src/http/src/Resources/CollectsResources.php @@ -0,0 +1,98 @@ +collects(); + + $this->collection = $collects && ! $resource->first() instanceof $collects + ? $resource->mapInto($collects) + : $resource->toBase(); + + return ($resource instanceof AbstractPaginator || $resource instanceof AbstractCursorPaginator) + ? $resource->setCollection($this->collection) + : $this->collection; + } + + /** + * Get the resource that this resource collects. + * + * @return null|class-string + */ + protected function collects(): ?string + { + $collects = null; + + if ($this->collects) { + $collects = $this->collects; + } elseif (str_ends_with(class_basename($this), 'Collection') + && (class_exists($class = Str::replaceLast('Collection', '', get_class($this))) + || class_exists($class = Str::replaceLast('Collection', 'Resource', get_class($this))))) { + $collects = $class; + } + + if (! $collects || is_a($collects, JsonResource::class, true)) { + return $collects; + } + + throw new LogicException('Resource collections must collect instances of ' . JsonResource::class . '.'); + } + + /** + * Get the JSON serialization options that should be applied to the resource response. + * + * @throws ReflectionException + */ + public function jsonOptions(): int + { + $collects = $this->collects(); + + if (! $collects) { + return 0; + } + + return (new ReflectionClass($collects)) + ->newInstanceWithoutConstructor() + ->jsonOptions(); + } + + /** + * Get an iterator for the resource collection. + * + * @return ArrayIterator + */ + public function getIterator(): Traversable + { + return $this->collection->getIterator(); + } +} diff --git a/src/http/src/Resources/Concerns/CollectsResources.php b/src/http/src/Resources/Concerns/CollectsResources.php deleted file mode 100644 index de0a35627..000000000 --- a/src/http/src/Resources/Concerns/CollectsResources.php +++ /dev/null @@ -1,35 +0,0 @@ -collects(); - - $this->collection = $collects && ! $resource->first() instanceof $collects - ? $resource->mapInto($collects) - : $resource->toBase(); - - return $this->isPaginatorResource($resource) - ? $resource->setCollection($this->collection) - : $this->collection; - } -} diff --git a/src/http/src/Resources/ConditionallyLoadsAttributes.php b/src/http/src/Resources/ConditionallyLoadsAttributes.php new file mode 100644 index 000000000..2e9764b38 --- /dev/null +++ b/src/http/src/Resources/ConditionallyLoadsAttributes.php @@ -0,0 +1,422 @@ + $value) { + ++$index; + + if (is_array($value)) { + $data[$key] = $this->filter($value); + + continue; + } + + if (is_numeric($key) && $value instanceof MergeValue) { + return $this->mergeData( + $data, + $index, + $this->filter($value->data), + array_values($value->data) === $value->data + ); + } + + if ($value instanceof self && is_null($value->resource)) { + $data[$key] = null; + } + } + + return $this->removeMissingValues($data); + } + + /** + * Merge the given data in at the given index. + */ + protected function mergeData(array $data, int $index, array $merge, bool $numericKeys): array + { + if ($numericKeys) { + return $this->removeMissingValues(array_merge( + array_merge(array_slice($data, 0, $index, true), $merge), + $this->filter(array_values(array_slice($data, $index + 1, null, true))) + )); + } + + return $this->removeMissingValues(array_slice($data, 0, $index, true) + + $merge + + $this->filter(array_slice($data, $index + 1, null, true))); + } + + /** + * Remove the missing values from the filtered data. + */ + protected function removeMissingValues(array $data): array + { + $numericKeys = true; + + foreach ($data as $key => $value) { + if (($value instanceof PotentiallyMissing && $value->isMissing()) + || ($value instanceof self + && $value->resource instanceof PotentiallyMissing + && $value->isMissing())) { /* @phpstan-ignore method.notFound (delegated via __call) */ + unset($data[$key]); + } else { + $numericKeys = $numericKeys && is_numeric($key); + } + } + + if (property_exists($this, 'preserveKeys') && $this->preserveKeys === true) { + return $data; + } + + return $numericKeys ? array_values($data) : $data; + } + + /** + * Retrieve a value if the given "condition" is truthy. + * + * @param bool $condition + * @param mixed $value + * @param mixed $default + * @return MissingValue|mixed + */ + protected function when($condition, $value, $default = new MissingValue()) + { + if ($condition) { + return value($value); + } + + return func_num_args() === 3 ? value($default) : $default; + } + + /** + * Retrieve a value if the given "condition" is falsy. + * + * @param bool $condition + * @param mixed $value + * @param mixed $default + * @return MissingValue|mixed + */ + public function unless($condition, $value, $default = new MissingValue()) + { + $arguments = func_num_args() === 2 ? [$value] : [$value, $default]; + + return $this->when(! $condition, ...$arguments); + } + + /** + * Merge a value into the array. + * + * @param mixed $value + * @return MergeValue|mixed + */ + protected function merge($value) + { + return $this->mergeWhen(true, $value); + } + + /** + * Merge a value if the given condition is truthy. + * + * @param bool $condition + * @param mixed $value + * @param mixed $default + * @return MergeValue|mixed + */ + protected function mergeWhen($condition, $value, $default = new MissingValue()) + { + if ($condition) { + return new MergeValue(value($value)); + } + + return func_num_args() === 3 ? new MergeValue(value($default)) : $default; + } + + /** + * Merge a value unless the given condition is truthy. + * + * @param bool $condition + * @param mixed $value + * @param mixed $default + * @return MergeValue|mixed + */ + protected function mergeUnless($condition, $value, $default = new MissingValue()) + { + $arguments = func_num_args() === 2 ? [$value] : [$value, $default]; + + return $this->mergeWhen(! $condition, ...$arguments); + } + + /** + * Merge the given attributes. + */ + protected function attributes(array $attributes): MergeValue + { + return new MergeValue( + Arr::only($this->resource->toArray(), $attributes) + ); + } + + /** + * Retrieve an attribute if it exists on the resource. + * + * @param string $attribute + * @param mixed $value + * @param mixed $default + * @return MissingValue|mixed + */ + public function whenHas($attribute, $value = null, $default = new MissingValue()) + { + if (! array_key_exists($attribute, $this->resource->getAttributes())) { + return value($default); + } + + return func_num_args() === 1 + ? $this->resource->{$attribute} + : value($value, $this->resource->{$attribute}); + } + + /** + * Retrieve a model attribute if it is null. + * + * @param mixed $value + * @param mixed $default + * @return MissingValue|mixed + */ + protected function whenNull($value, $default = new MissingValue()) + { + $arguments = func_num_args() == 1 ? [$value] : [$value, $default]; + + return $this->when(is_null($value), ...$arguments); + } + + /** + * Retrieve a model attribute if it is not null. + * + * @param mixed $value + * @param mixed $default + * @return MissingValue|mixed + */ + protected function whenNotNull($value, $default = new MissingValue()) + { + $arguments = func_num_args() == 1 ? [$value] : [$value, $default]; + + return $this->when(! is_null($value), ...$arguments); + } + + /** + * Retrieve an accessor when it has been appended. + * + * @param string $attribute + * @param mixed $value + * @param mixed $default + * @return MissingValue|mixed + */ + protected function whenAppended($attribute, $value = null, $default = new MissingValue()) + { + if ($this->resource->hasAppended($attribute)) { + return func_num_args() >= 2 ? value($value) : $this->resource->{$attribute}; + } + + return func_num_args() === 3 ? value($default) : $default; + } + + /** + * Retrieve a relationship if it has been loaded. + * + * @param string $relationship + * @param mixed $value + * @param mixed $default + * @return MissingValue|mixed + */ + protected function whenLoaded($relationship, $value = null, $default = new MissingValue()) + { + if (! $this->resource->relationLoaded($relationship)) { + return value($default); + } + + $loadedValue = $this->resource->{$relationship}; + + if (func_num_args() === 1) { + return $loadedValue; + } + + if ($loadedValue === null) { + return; + } + + if ($value === null) { + $value = value(...); + } + + return value($value, $loadedValue); + } + + /** + * Retrieve a relationship count if it exists. + * + * @param string $relationship + * @param mixed $value + * @param mixed $default + * @return MissingValue|mixed + */ + public function whenCounted($relationship, $value = null, $default = new MissingValue()) + { + $attribute = (new Stringable($relationship))->snake()->finish('_count')->value(); + + if (! array_key_exists($attribute, $this->resource->getAttributes())) { + return value($default); + } + + if (func_num_args() === 1) { + return $this->resource->{$attribute}; + } + + if ($this->resource->{$attribute} === null) { + return; + } + + if ($value === null) { + $value = value(...); + } + + return value($value, $this->resource->{$attribute}); + } + + /** + * Retrieve a relationship aggregated value if it exists. + * + * @param string $relationship + * @param string $column + * @param string $aggregate + * @param mixed $value + * @param mixed $default + * @return MissingValue|mixed + */ + public function whenAggregated($relationship, $column, $aggregate, $value = null, $default = new MissingValue()) + { + $attribute = (new Stringable($relationship))->snake()->append('_')->append($aggregate)->append('_')->finish($column)->value(); + + if (! array_key_exists($attribute, $this->resource->getAttributes())) { + return value($default); + } + + if (func_num_args() === 3) { + return $this->resource->{$attribute}; + } + + if ($this->resource->{$attribute} === null) { + return; + } + + if ($value === null) { + $value = value(...); + } + + return value($value, $this->resource->{$attribute}); + } + + /** + * Retrieve a relationship existence check if it exists. + * + * @param string $relationship + * @param mixed $value + * @param mixed $default + * @return MissingValue|mixed + */ + public function whenExistsLoaded($relationship, $value = null, $default = new MissingValue()) + { + $attribute = (new Stringable($relationship))->snake()->finish('_exists')->value(); + + if (! array_key_exists($attribute, $this->resource->getAttributes())) { + return value($default); + } + + if (func_num_args() === 1) { + return $this->resource->{$attribute}; + } + + if ($this->resource->{$attribute} === null) { + return; + } + + return value($value, $this->resource->{$attribute}); + } + + /** + * Execute a callback if the given pivot table has been loaded. + * + * @param string $table + * @param mixed $value + * @param mixed $default + * @return MissingValue|mixed + */ + protected function whenPivotLoaded($table, $value, $default = new MissingValue()) + { + return $this->whenPivotLoadedAs('pivot', ...func_get_args()); + } + + /** + * Execute a callback if the given pivot table with a custom accessor has been loaded. + * + * @param string $accessor + * @param string $table + * @param mixed $value + * @param mixed $default + * @return MissingValue|mixed + */ + protected function whenPivotLoadedAs($accessor, $table, $value, $default = new MissingValue()) + { + return $this->when( + $this->hasPivotLoadedAs($accessor, $table), + $value, + $default, + ); + } + + /** + * Determine if the resource has the specified pivot table loaded. + */ + protected function hasPivotLoaded(string $table): bool + { + return $this->hasPivotLoadedAs('pivot', $table); + } + + /** + * Determine if the resource has the specified pivot table loaded with a custom accessor. + */ + protected function hasPivotLoadedAs(string $accessor, string $table): bool + { + return isset($this->resource->{$accessor}) + && ($this->resource->{$accessor} instanceof $table + || $this->resource->{$accessor}->getTable() === $table); + } + + /** + * Transform the given value if it is present. + * + * @param mixed $value + * @param mixed $default + * @return mixed + */ + protected function transform($value, callable $callback, $default = new MissingValue()) + { + return transform( + $value, + $callback, + $default + ); + } +} diff --git a/src/http/src/Resources/DelegatesToResource.php b/src/http/src/Resources/DelegatesToResource.php new file mode 100644 index 000000000..a21b642a5 --- /dev/null +++ b/src/http/src/Resources/DelegatesToResource.php @@ -0,0 +1,129 @@ +resource->getRouteKey(); + } + + /** + * Get the route key for the resource. + */ + public function getRouteKeyName(): string + { + return $this->resource->getRouteKeyName(); + } + + /** + * Retrieve the model for a bound value. + * + * @throws Exception + */ + public function resolveRouteBinding(mixed $value, ?string $field = null) + { + throw new Exception('Resources may not be implicitly resolved from route bindings.'); + } + + /** + * Retrieve the model for a bound value. + * + * @throws Exception + */ + public function resolveChildRouteBinding(string $childType, mixed $value, ?string $field = null) + { + throw new Exception('Resources may not be implicitly resolved from child route bindings.'); + } + + /** + * Determine if the given attribute exists. + * + * @param mixed $offset + */ + public function offsetExists($offset): bool + { + return isset($this->resource[$offset]); + } + + /** + * Get the value for a given offset. + * + * @param mixed $offset + */ + public function offsetGet($offset): mixed + { + return $this->resource[$offset]; + } + + /** + * Set the value for a given offset. + * + * @param mixed $offset + * @param mixed $value + */ + public function offsetSet($offset, $value): void + { + $this->resource[$offset] = $value; + } + + /** + * Unset the value for a given offset. + * + * @param mixed $offset + */ + public function offsetUnset($offset): void + { + unset($this->resource[$offset]); + } + + /** + * Determine if an attribute exists on the resource. + */ + public function __isset(string $key): bool + { + return isset($this->resource->{$key}); + } + + /** + * Unset an attribute on the resource. + */ + public function __unset(string $key): void + { + unset($this->resource->{$key}); + } + + /** + * Dynamically get properties from the underlying resource. + */ + public function __get(string $key): mixed + { + return $this->resource->{$key}; + } + + /** + * Dynamically pass method calls to the underlying resource. + */ + public function __call(string $method, array $parameters): mixed + { + if (static::hasMacro($method)) { + return $this->macroCall($method, $parameters); + } + + return $this->forwardCallTo($this->resource, $method, $parameters); + } +} diff --git a/src/http/src/Resources/Json/AnonymousResourceCollection.php b/src/http/src/Resources/Json/AnonymousResourceCollection.php index dff962552..7bc520a4c 100644 --- a/src/http/src/Resources/Json/AnonymousResourceCollection.php +++ b/src/http/src/Resources/Json/AnonymousResourceCollection.php @@ -4,19 +4,20 @@ namespace Hypervel\Http\Resources\Json; -/** - * Anonymous resource collection for wrapping arbitrary collections. - * - * This class extends ResourceCollection to ensure proper type hierarchy - * within Hypervel's resource system. - */ class AnonymousResourceCollection extends ResourceCollection { + /** + * The name of the resource being collected. + */ + public ?string $collects = null; + + /** + * Indicates if the collection keys should be preserved. + */ + public bool $preserveKeys = false; + /** * Create a new anonymous resource collection. - * - * @param mixed $resource the resource being collected - * @param string $collects the name of the resource being collected */ public function __construct(mixed $resource, string $collects) { @@ -24,4 +25,14 @@ public function __construct(mixed $resource, string $collects) parent::__construct($resource); } + + /** + * Indicate that the collection keys should be preserved. + */ + public function preserveKeys(bool $value = true): static + { + $this->preserveKeys = $value; + + return $this; + } } diff --git a/src/http/src/Resources/Json/JsonResource.php b/src/http/src/Resources/Json/JsonResource.php index 2ad5e5ed6..0cb42725f 100644 --- a/src/http/src/Resources/Json/JsonResource.php +++ b/src/http/src/Resources/Json/JsonResource.php @@ -4,20 +4,255 @@ namespace Hypervel\Http\Resources\Json; -use Hyperf\Resource\Json\JsonResource as BaseJsonResource; -use Hypervel\Router\Contracts\UrlRoutable; +use ArrayAccess; +use Hypervel\Container\Container; +use Hypervel\Contracts\Router\UrlRoutable; +use Hypervel\Contracts\Support\Arrayable; +use Hypervel\Contracts\Support\Responsable; +use Hypervel\Database\Eloquent\JsonEncodingException; +use Hypervel\Http\JsonResponse; +use Hypervel\Http\Request; +use Hypervel\Http\Resources\ConditionallyLoadsAttributes; +use Hypervel\Http\Resources\DelegatesToResource; +use JsonException; +use JsonSerializable; -use function Hyperf\Tappable\tap; - -class JsonResource extends BaseJsonResource implements UrlRoutable +class JsonResource implements ArrayAccess, JsonSerializable, Responsable, UrlRoutable { + use ConditionallyLoadsAttributes; + use DelegatesToResource; + + /** + * The resource instance. + */ + public mixed $resource; + + /** + * The additional data that should be added to the top-level resource array. + */ + public array $with = []; + /** - * Create new anonymous resource collection. + * The additional meta data that should be added to the resource response. + * + * Added during response construction by the developer. + */ + public array $additional = []; + + /** + * The "data" wrapper that should be applied. + */ + public static ?string $wrap = 'data'; + + /** + * Whether to force wrapping even if the $wrap key exists in underlying resource data. + */ + public static bool $forceWrapping = false; + + /** + * Create a new resource instance. + */ + public function __construct(mixed $resource) + { + $this->resource = $resource; + } + + /** + * Create a new resource instance. + */ + public static function make(mixed ...$parameters): static + { + return new static(...$parameters); + } + + /** + * Create a new anonymous resource collection. */ public static function collection(mixed $resource): AnonymousResourceCollection { - return tap(new AnonymousResourceCollection($resource, static::class), function ($collection) { - $collection->preserveKeys = (new static([]))->preserveKeys; + return tap(static::newCollection($resource), function ($collection) { + if (property_exists(static::class, 'preserveKeys')) { + /* @phpstan-ignore property.notFound (checked by property_exists above) */ + $collection->preserveKeys = (new static([]))->preserveKeys === true; + } }); } + + /** + * Create a new resource collection instance. + */ + protected static function newCollection(mixed $resource): AnonymousResourceCollection + { + return new AnonymousResourceCollection($resource, static::class); + } + + /** + * Resolve the resource to an array. + */ + public function resolve(?Request $request = null): array + { + $data = $this->resolveResourceData( + $request ?: $this->resolveRequestFromContainer() + ); + + if ($data instanceof Arrayable) { + $data = $data->toArray(); + } elseif ($data instanceof JsonSerializable) { + $data = $data->jsonSerialize(); + } + + return $this->filter((array) $data); + } + + /** + * Transform the resource into an array. + */ + public function toAttributes(Request $request): array|Arrayable|JsonSerializable + { + if (property_exists($this, 'attributes')) { + return $this->attributes; + } + + return $this->toArray($request); + } + + /** + * Resolve the resource data to an array. + */ + public function resolveResourceData(Request $request): array|Arrayable|JsonSerializable + { + return $this->toAttributes($request); + } + + /** + * Transform the resource into an array. + */ + public function toArray(Request $request): array|Arrayable|JsonSerializable + { + if (is_null($this->resource)) { + return []; + } + + return is_array($this->resource) + ? $this->resource + : $this->resource->toArray(); + } + + /** + * Convert the resource to JSON. + * + * @throws JsonEncodingException + */ + public function toJson(int $options = 0): string + { + try { + $json = json_encode($this->jsonSerialize(), $options | JSON_THROW_ON_ERROR); + } catch (JsonException $e) { + throw JsonEncodingException::forResource($this, $e->getMessage()); + } + + return $json; + } + + /** + * Convert the resource to pretty print formatted JSON. + * + * @throws JsonEncodingException + */ + public function toPrettyJson(int $options = 0): string + { + return $this->toJson(JSON_PRETTY_PRINT | $options); + } + + /** + * Get any additional data that should be returned with the resource array. + */ + public function with(Request $request): array + { + return $this->with; + } + + /** + * Add additional meta data to the resource response. + */ + public function additional(array $data): static + { + $this->additional = $data; + + return $this; + } + + /** + * Get the JSON serialization options that should be applied to the resource response. + */ + public function jsonOptions(): int + { + return 0; + } + + /** + * Customize the response for a request. + */ + public function withResponse(Request $request, JsonResponse $response): void + { + } + + /** + * Resolve the HTTP request instance from container. + */ + protected function resolveRequestFromContainer(): Request + { + return Container::getInstance()->make('request'); + } + + /** + * Set the string that should wrap the outer-most resource array. + */ + public static function wrap(?string $value): void + { + static::$wrap = $value; + } + + /** + * Disable wrapping of the outer-most resource array. + */ + public static function withoutWrapping(): void + { + static::$wrap = null; + } + + /** + * Transform the resource into an HTTP response. + */ + public function response(?Request $request = null): JsonResponse + { + return $this->toResponse( + $request ?: $this->resolveRequestFromContainer() + ); + } + + /** + * Create an HTTP response that represents the object. + */ + public function toResponse(Request $request): JsonResponse + { + return (new ResourceResponse($this))->toResponse($request); + } + + /** + * Prepare the resource for JSON serialization. + */ + public function jsonSerialize(): array + { + return $this->resolve($this->resolveRequestFromContainer()); + } + + /** + * Flush the resource's global state. + */ + public static function flushState(): void + { + static::$wrap = 'data'; + static::$forceWrapping = false; + } } diff --git a/src/http/src/Resources/Json/PaginatedResourceResponse.php b/src/http/src/Resources/Json/PaginatedResourceResponse.php new file mode 100644 index 000000000..d4da4cc8c --- /dev/null +++ b/src/http/src/Resources/Json/PaginatedResourceResponse.php @@ -0,0 +1,94 @@ +json( + $this->wrap( + $this->resource->resolve($request), + array_merge_recursive( + $this->paginationInformation($request), + $this->resource->with($request), + $this->resource->additional + ) + ), + $this->calculateStatus(), + [], + $this->resource->jsonOptions() + ), function ($response) use ($request) { + $response->original = $this->resource->resource->map(function ($item) { + if (is_array($item)) { + return Arr::get($item, 'resource'); + } + if (is_object($item)) { + return $item->resource ?? null; + } + + return null; + }); + + $this->resource->withResponse($request, $response); + }); + } + + /** + * Add the pagination information to the response. + */ + protected function paginationInformation(Request $request): array + { + $paginated = $this->resource->resource->toArray(); + + $default = [ + 'links' => $this->paginationLinks($paginated), + 'meta' => $this->meta($paginated), + ]; + + if (method_exists($this->resource, 'paginationInformation') + || $this->resource->hasMacro('paginationInformation')) { + return $this->resource->paginationInformation($request, $paginated, $default); + } + + return $default; + } + + /** + * Get the pagination links for the response. + */ + protected function paginationLinks(array $paginated): array + { + return [ + 'first' => $paginated['first_page_url'] ?? null, + 'last' => $paginated['last_page_url'] ?? null, + 'prev' => $paginated['prev_page_url'] ?? null, + 'next' => $paginated['next_page_url'] ?? null, + ]; + } + + /** + * Gather the meta data for the response. + */ + protected function meta(array $paginated): array + { + return Arr::except($paginated, [ + 'data', + 'first_page_url', + 'last_page_url', + 'prev_page_url', + 'next_page_url', + ]); + } +} diff --git a/src/http/src/Resources/Json/ResourceCollection.php b/src/http/src/Resources/Json/ResourceCollection.php index c0845fc7b..42ed63fc7 100644 --- a/src/http/src/Resources/Json/ResourceCollection.php +++ b/src/http/src/Resources/Json/ResourceCollection.php @@ -4,10 +4,116 @@ namespace Hypervel\Http\Resources\Json; -use Hyperf\Resource\Json\ResourceCollection as BaseResourceCollection; -use Hypervel\Http\Resources\Concerns\CollectsResources; +use Countable; +use Hypervel\Http\JsonResponse; +use Hypervel\Http\Request; +use Hypervel\Http\Resources\CollectsResources; +use Hypervel\Pagination\AbstractCursorPaginator; +use Hypervel\Pagination\AbstractPaginator; +use IteratorAggregate; +use Override; -class ResourceCollection extends BaseResourceCollection +class ResourceCollection extends JsonResource implements Countable, IteratorAggregate { use CollectsResources; + + /** + * The resource that this resource collects. + */ + public ?string $collects = null; + + /** + * The mapped collection instance. + */ + public mixed $collection = null; + + /** + * Indicates if all existing request query parameters should be added to pagination links. + */ + protected bool $preserveAllQueryParameters = false; + + /** + * The query parameters that should be added to the pagination links. + */ + protected ?array $queryParameters = null; + + /** + * Create a new resource instance. + */ + public function __construct(mixed $resource) + { + parent::__construct($resource); + + $this->resource = $this->collectResource($resource); + } + + /** + * Indicate that all current query parameters should be appended to pagination links. + */ + public function preserveQuery(): static + { + $this->preserveAllQueryParameters = true; + + return $this; + } + + /** + * Specify the query string parameters that should be present on pagination links. + */ + public function withQuery(array $query): static + { + $this->preserveAllQueryParameters = false; + + $this->queryParameters = $query; + + return $this; + } + + /** + * Return the count of items in the resource collection. + */ + public function count(): int + { + return $this->collection->count(); + } + + /** + * Transform the resource into a JSON array. + */ + #[Override] + public function toArray(Request $request): array + { + if ($this->collection->first() instanceof JsonResource) { + return $this->collection->map->resolve($request)->all(); + } + + return $this->collection->map->toArray($request)->all(); + } + + /** + * Create an HTTP response that represents the object. + */ + #[Override] + public function toResponse(Request $request): JsonResponse + { + if ($this->resource instanceof AbstractPaginator || $this->resource instanceof AbstractCursorPaginator) { + return $this->preparePaginatedResponse($request); + } + + return parent::toResponse($request); + } + + /** + * Create a paginate-aware HTTP response. + */ + protected function preparePaginatedResponse(Request $request): JsonResponse + { + if ($this->preserveAllQueryParameters) { + $this->resource->appends($request->query()); + } elseif (! is_null($this->queryParameters)) { + $this->resource->appends($this->queryParameters); + } + + return (new PaginatedResourceResponse($this))->toResponse($request); + } } diff --git a/src/http/src/Resources/Json/ResourceResponse.php b/src/http/src/Resources/Json/ResourceResponse.php new file mode 100644 index 000000000..98ac2853a --- /dev/null +++ b/src/http/src/Resources/Json/ResourceResponse.php @@ -0,0 +1,105 @@ +resource = $resource; + } + + /** + * Create an HTTP response that represents the object. + */ + public function toResponse(Request $request): JsonResponse + { + return tap(response()->json( + $this->wrap( + $this->resource->resolve($request), + $this->resource->with($request), + $this->resource->additional + ), + $this->calculateStatus(), + [], + $this->resource->jsonOptions() + ), function ($response) use ($request) { + $response->original = $this->resource->resource; + + $this->resource->withResponse($request, $response); + }); + } + + /** + * Wrap the given data if necessary. + */ + protected function wrap(Collection|array $data, array $with = [], array $additional = []): array + { + if ($data instanceof Collection) { + $data = $data->all(); + } + + if ($this->haveDefaultWrapperAndDataIsUnwrapped($data)) { + $data = [$this->wrapper() => $data]; + } elseif ($this->haveAdditionalInformationAndDataIsUnwrapped($data, $with, $additional)) { + $data = [($this->wrapper() ?? 'data') => $data]; + } + + return array_merge_recursive($data, $with, $additional); + } + + /** + * Determine if we have a default wrapper and the given data is unwrapped. + */ + protected function haveDefaultWrapperAndDataIsUnwrapped(array $data): bool + { + if ($this->resource instanceof JsonResource && $this->resource::$forceWrapping) { + return $this->wrapper() !== null; + } + + return $this->wrapper() && ! array_key_exists($this->wrapper(), $data); + } + + /** + * Determine if "with" data has been added and our data is unwrapped. + */ + protected function haveAdditionalInformationAndDataIsUnwrapped(array $data, array $with, array $additional): bool + { + return (! empty($with) || ! empty($additional)) + && (! $this->wrapper() + || ! array_key_exists($this->wrapper(), $data)); + } + + /** + * Get the default data wrapper for the resource. + */ + protected function wrapper(): ?string + { + return $this->resource::$wrap; + } + + /** + * Calculate the appropriate status code for the response. + */ + protected function calculateStatus(): int + { + return $this->resource->resource instanceof Model + && $this->resource->resource->wasRecentlyCreated ? 201 : 200; + } +} diff --git a/src/http/src/Resources/MergeValue.php b/src/http/src/Resources/MergeValue.php new file mode 100644 index 000000000..1014e9ff4 --- /dev/null +++ b/src/http/src/Resources/MergeValue.php @@ -0,0 +1,30 @@ +data = $data->all(); + } elseif ($data instanceof JsonSerializable) { + $this->data = $data->jsonSerialize(); + } else { + $this->data = $data; + } + } +} diff --git a/src/http/src/Resources/MissingValue.php b/src/http/src/Resources/MissingValue.php new file mode 100644 index 000000000..ee5a86ec4 --- /dev/null +++ b/src/http/src/Resources/MissingValue.php @@ -0,0 +1,16 @@ + $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')) { @@ -217,14 +221,14 @@ public function view(string $view, array $data = [], int $status = 200, array $h * * @param array|Arrayable|Jsonable $data */ - public function json($data, int $status = 200, array $headers = []): ResponseInterface + public function json($data, int $status = 200, array $headers = [], int $encodingOptions = 0): JsonResponse { $response = parent::json($data); foreach ($headers as $name => $value) { $response = $response->withHeader($name, $value); } - return $response->withStatus($status); + return new JsonResponse($response->withStatus($status), $data, $encodingOptions); } /** diff --git a/src/http/src/UploadedFile.php b/src/http/src/UploadedFile.php index bc855f651..32076f287 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\Context\ApplicationContext; 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..3d1fcbb79 100644 --- a/src/jwt/composer.json +++ b/src/jwt/composer.json @@ -20,14 +20,13 @@ } ], "require": { - "php": "^8.2", + "php": "^8.4", "nesbot/carbon": "^2.72.6", - "hyperf/collection": "~3.1.0", + "hypervel/collections": "^0.4", "lcobucci/jwt": "^5.0", "psr/simple-cache": "^3.0", - "hyperf/stringable": "~3.1.0", "ramsey/uuid": "^4.7", - "hypervel/cache": "^0.3" + "hypervel/cache": "^0.4" }, "autoload": { "psr-4": { @@ -39,7 +38,7 @@ "config": "Hypervel\\JWT\\ConfigProvider" }, "branch-alias": { - "dev-main": "0.3-dev" + "dev-main": "0.4-dev" } }, "config": { diff --git a/src/jwt/src/BlacklistFactory.php b/src/jwt/src/BlacklistFactory.php index 271d39307..003f0e688 100644 --- a/src/jwt/src/BlacklistFactory.php +++ b/src/jwt/src/BlacklistFactory.php @@ -4,8 +4,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; @@ -14,7 +13,7 @@ class BlacklistFactory { public function __invoke(ContainerInterface $container): BlacklistContract { - $config = $container->get(ConfigInterface::class); + $config = $container->get('config'); $storageClass = $config->get('jwt.providers.storage'); $storage = match ($storageClass) { 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..e43f02887 100644 --- a/src/log/composer.json +++ b/src/log/composer.json @@ -26,13 +26,12 @@ } }, "require": { - "php": "^8.2", + "php": "^8.4", "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/support": "^0.3" + "hypervel/collections": "^0.4", + "hypervel/support": "^0.4" }, "config": { "sort-packages": true @@ -42,7 +41,7 @@ "config": "Hypervel\\Log\\ConfigProvider" }, "branch-alias": { - "dev-main": "0.3-dev" + "dev-main": "0.4-dev" } } } \ No newline at end of file diff --git a/src/log/src/Adapter/HyperfLogFactory.php b/src/log/src/Adapter/HyperfLogFactory.php index d0945d156..9c536f28c 100644 --- a/src/log/src/Adapter/HyperfLogFactory.php +++ b/src/log/src/Adapter/HyperfLogFactory.php @@ -4,7 +4,6 @@ namespace Hypervel\Log\Adapter; -use Hyperf\Contract\ConfigInterface; use Psr\Container\ContainerInterface; class HyperfLogFactory @@ -13,7 +12,7 @@ public function __invoke(ContainerInterface $container): LogFactoryAdapter { return new LogFactoryAdapter( $container, - $container->get(ConfigInterface::class) + $container->get('config') ); } } diff --git a/src/log/src/LogManager.php b/src/log/src/LogManager.php index 80edd62c1..36b217ba8 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\Config\Repository; +use Hypervel\Context\Context; +use Hypervel\Support\Collection; use Hypervel\Support\Environment; +use Hypervel\Support\Str; use InvalidArgumentException; use Monolog\Formatter\LineFormatter; use Monolog\Handler\ErrorLogHandler; @@ -40,7 +40,7 @@ class LogManager implements LoggerInterface /** * The config for log. */ - protected ConfigInterface $config; + protected Repository $config; /** * The array of resolved channels. @@ -63,7 +63,7 @@ class LogManager implements LoggerInterface public function __construct( protected ContainerInterface $app ) { - $this->config = $this->app->get(ConfigInterface::class); + $this->config = $this->app->get('config'); } /** diff --git a/src/log/src/Logger.php b/src/log/src/Logger.php index f800edee0..5f452a214 100755 --- a/src/log/src/Logger.php +++ b/src/log/src/Logger.php @@ -5,9 +5,9 @@ namespace Hypervel\Log; use Closure; -use Hyperf\Context\Context; -use Hyperf\Contract\Arrayable; -use Hyperf\Contract\Jsonable; +use Hypervel\Context\Context; +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..a6c475808 --- /dev/null +++ b/src/macroable/composer.json @@ -0,0 +1,41 @@ +{ + "name": "hypervel/macroable", + "type": "library", + "description": "The Hypervel Macroable package.", + "license": "MIT", + "keywords": [ + "php", + "macroable", + "hypervel" + ], + "authors": [ + { + "name": "Albert Chen", + "email": "albert@hypervel.org" + }, + { + "name": "Raj Siva-Rajah", + "homepage": "https://github.com/binaryfire" + } + ], + "support": { + "issues": "https://github.com/hypervel/components/issues", + "source": "https://github.com/hypervel/components" + }, + "autoload": { + "psr-4": { + "Hypervel\\Support\\": "src/" + } + }, + "require": { + "php": "^8.4" + }, + "config": { + "sort-packages": true + }, + "extra": { + "branch-alias": { + "dev-main": "0.4-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..2c8c940b0 100644 --- a/src/mail/composer.json +++ b/src/mail/composer.json @@ -26,15 +26,14 @@ } }, "require": { - "php": "^8.2", - "hyperf/collection": "~3.1.0", - "hyperf/conditionable": "~3.1.0", - "hyperf/stringable": "~3.1.0", - "hyperf/macroable": "~3.1.0", + "php": "^8.4", + "hypervel/collections": "^0.4", + "hypervel/conditionable": "^0.4", + "hypervel/macroable": "^0.4", "hyperf/di": "~3.1.0", - "hypervel/support": "^0.3", - "hypervel/filesystem": "^0.3", - "hypervel/object-pool": "^0.3", + "hypervel/support": "^0.4", + "hypervel/filesystem": "^0.4", + "hypervel/object-pool": "^0.4", "league/commonmark": "^2.2", "psr/log": "^1.0|^2.0|^3.0", "symfony/mailer": "^6.2", @@ -48,7 +47,7 @@ "config": "Hypervel\\Mail\\ConfigProvider" }, "branch-alias": { - "dev-main": "0.3-dev" + "dev-main": "0.4-dev" } }, "suggest": { diff --git a/src/mail/src/Attachment.php b/src/mail/src/Attachment.php index 6f5331c75..b1bbdc079 100644 --- a/src/mail/src/Attachment.php +++ b/src/mail/src/Attachment.php @@ -5,11 +5,11 @@ namespace Hypervel\Mail; use Closure; -use Hyperf\Context\ApplicationContext; -use Hyperf\Macroable\Macroable; -use Hypervel\Filesystem\Contracts\Factory as FilesystemFactory; -use Hypervel\Filesystem\Contracts\Filesystem; +use Hypervel\Context\ApplicationContext; +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..94b74ff58 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\Config\Repository; +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 { @@ -48,7 +48,7 @@ class MailManager implements FactoryContract /** * The config instance. */ - protected ConfigInterface $config; + protected Repository $config; /** * The array of resolved mailers. @@ -78,7 +78,7 @@ class MailManager implements FactoryContract public function __construct( protected ContainerInterface $app ) { - $this->config = $app->get(ConfigInterface::class); + $this->config = $app->get('config'); } /** @@ -408,7 +408,7 @@ protected function createLogTransport(array $config): LogTransport if ($logger instanceof LogManager) { $logger = $logger->channel( - $config['channel'] ?? $this->app->get(ConfigInterface::class)->get('mail.log_channel') + $config['channel'] ?? $this->app->get('config')->get('mail.log_channel') ); } diff --git a/src/mail/src/Mailable.php b/src/mail/src/Mailable.php index e635c64b4..8b3e91bd3 100644 --- a/src/mail/src/Mailable.php +++ b/src/mail/src/Mailable.php @@ -8,26 +8,25 @@ 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\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\Context\ApplicationContext; +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\Support\Collection; use Hypervel\Support\HtmlString; +use Hypervel\Support\Str; +use Hypervel\Support\Traits\Conditionable; +use Hypervel\Support\Traits\ForwardsCalls; use Hypervel\Support\Traits\Localizable; -use Hypervel\Translation\Contracts\HasLocalePreference; +use Hypervel\Support\Traits\Macroable; +use Hypervel\Testing\Constraints\SeeInOrder; use PHPUnit\Framework\Assert as PHPUnit; use ReflectionClass; use ReflectionException; @@ -36,7 +35,6 @@ use Symfony\Component\Mailer\Header\TagHeader; use Symfony\Component\Mime\Address; -use function Hyperf\Support\call; use function Hyperf\Support\make; class Mailable implements MailableContract, Renderable @@ -353,7 +351,7 @@ protected function markdownRenderer(): Markdown { return tap(make(Markdown::class), function ($markdown) { $markdown->theme( - $this->theme ?: ApplicationContext::getContainer()->get(ConfigInterface::class)->get( + $this->theme ?: ApplicationContext::getContainer()->get('config')->get( 'mail.markdown.theme', 'default' ) @@ -1340,10 +1338,7 @@ protected function renderForAssertions(): array protected function prepareMailableForDelivery(): void { if (method_exists($this, 'build')) { - $container = ApplicationContext::getContainer(); - method_exists($container, 'call') - ? $container->call([$this, 'build']) // @phpstan-ignore-line - : call([$this, 'build']); + ApplicationContext::getContainer()->call([$this, 'build']); } $this->ensureHeadersAreHydrated(); diff --git a/src/mail/src/Mailables/Content.php b/src/mail/src/Mailables/Content.php index cdfafe531..5d3632125 100644 --- a/src/mail/src/Mailables/Content.php +++ b/src/mail/src/Mailables/Content.php @@ -4,7 +4,7 @@ namespace Hypervel\Mail\Mailables; -use Hyperf\Conditionable\Conditionable; +use Hypervel\Support\Traits\Conditionable; class Content { diff --git a/src/mail/src/Mailables/Envelope.php b/src/mail/src/Mailables/Envelope.php index 8ce4bc5ac..26f489845 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; +use Hypervel\Support\Traits\Conditionable; class Envelope { diff --git a/src/mail/src/Mailables/Headers.php b/src/mail/src/Mailables/Headers.php index 08f0b5f5a..7d01c0290 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; +use Hypervel\Support\Traits\Conditionable; 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/MarkdownFactory.php b/src/mail/src/MarkdownFactory.php index c466148d5..6b3bcc6bf 100644 --- a/src/mail/src/MarkdownFactory.php +++ b/src/mail/src/MarkdownFactory.php @@ -4,14 +4,14 @@ namespace Hypervel\Mail; -use Hyperf\Contract\ConfigInterface; use Hyperf\ViewEngine\Contract\FactoryInterface; +use Hypervel\Config\Repository; class MarkdownFactory { public function __construct( protected FactoryInterface $factory, - protected ConfigInterface $config, + protected Repository $config, ) { } diff --git a/src/mail/src/Message.php b/src/mail/src/Message.php index a3b922b22..5db8b7802 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 Hypervel\Support\Traits\ForwardsCalls; 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..c94bf096f 100644 --- a/src/mail/src/PendingMail.php +++ b/src/mail/src/PendingMail.php @@ -6,11 +6,9 @@ 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; +use Hypervel\Support\Traits\Conditionable; 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..9507f889e 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 Hypervel\Support\Traits\ForwardsCalls; use Symfony\Component\Mailer\SentMessage as SymfonySentMessage; /** diff --git a/src/mail/src/TextMessage.php b/src/mail/src/TextMessage.php index 748bf1e6b..79113544b 100644 --- a/src/mail/src/TextMessage.php +++ b/src/mail/src/TextMessage.php @@ -4,8 +4,8 @@ namespace Hypervel\Mail; -use Hyperf\Support\Traits\ForwardsCalls; -use Hypervel\Mail\Contracts\Attachable; +use Hypervel\Contracts\Mail\Attachable; +use Hypervel\Support\Traits\ForwardsCalls; /** * @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..774332902 100644 --- a/src/nested-set/composer.json +++ b/src/nested-set/composer.json @@ -26,17 +26,17 @@ } }, "require": { - "php": "^8.2", - "hyperf/database": "~3.1.0", - "hypervel/core": "^0.3", - "hypervel/support": "^0.3" + "php": "^8.4", + "hypervel/core": "^0.4", + "hypervel/database": "^0.4", + "hypervel/support": "^0.4" }, "config": { "sort-packages": true }, "extra": { "branch-alias": { - "dev-main": "0.3-dev" + "dev-main": "0.4-dev" } } } \ No newline at end of file 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..dec3f799d 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); } @@ -761,12 +761,14 @@ public function rebuildTree(array $data, bool $delete = false, int|Model|null $r $this->withTrashed(); } - $existing = $this + /** @var \Hypervel\Database\Eloquent\Collection $result */ + $result = $this ->when($root, function (self $query) use ($root) { return $query->whereDescendantOf($root); }) - ->get() - ->getDictionary(); + ->get(); + + $existing = $result->getDictionary(); $dictionary = []; $parentId = $root ? $root->getKey() : null; @@ -833,7 +835,7 @@ protected function buildRebuildDictionary( unset($existing[$key]); } - $model->fill(Arr::except($itemData, 'children'))->save(); + $model->fill(Arr::except($itemData, ['children', $keyName]))->save(); $dictionary[$parentId][] = $model; 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..1b0fea913 100644 --- a/src/nested-set/src/NodeContext.php +++ b/src/nested-set/src/NodeContext.php @@ -5,22 +5,22 @@ namespace Hypervel\NestedSet; use DateTimeInterface; -use Hyperf\Database\Model\Model; use Hypervel\Context\Context; +use Hypervel\Database\Eloquent\Model; class NodeContext { public static function keepDeletedAt(Model $model): void { Context::set( - 'nestedset.deleted_at.' . get_class($model), + '__nested_set.deleted_at.' . get_class($model), $model->{$model->getDeletedAtColumn()} // @phpstan-ignore-line ); } public static function restoreDeletedAt(Model $model): DateTimeInterface|int|string { - $deletedAt = Context::get('nestedset.deleted_at.' . get_class($model)); + $deletedAt = Context::get('__nested_set.deleted_at.' . get_class($model)); if (! is_null($deletedAt)) { /* @phpstan-ignore-next-line */ @@ -32,11 +32,11 @@ public static function restoreDeletedAt(Model $model): DateTimeInterface|int|str public static function hasPerformed(Model $model): bool { - return Context::get('nestedset.has_performed.' . get_class($model), false); + return Context::get('__nested_set.has_performed.' . get_class($model), false); } public static function setHasPerformed(Model $model, bool $performed = true): void { - Context::set('nestedset.has_performed.' . get_class($model), $performed); + Context::set('__nested_set.has_performed.' . get_class($model), $performed); } } diff --git a/src/notifications/composer.json b/src/notifications/composer.json index a7564ad46..5da167065 100644 --- a/src/notifications/composer.json +++ b/src/notifications/composer.json @@ -26,19 +26,18 @@ } }, "require": { - "php": "^8.2", + "php": "^8.4", "hyperf/config": "~3.1.0", - "hyperf/stringable": "~3.1.0", "hyperf/contract": "~3.1.0", - "hyperf/collection": "~3.1.0", - "hyperf/context": "~3.1.0", - "hyperf/database": "~3.1.0", + "hypervel/collections": "^0.4", + "hypervel/context": "^0.4", "hyperf/di": "~3.1.0", - "hypervel/broadcasting": "^0.3", - "hypervel/support": "^0.3", - "hypervel/mail": "^0.3", - "hypervel/queue": "^0.3", - "hypervel/object-pool": "^0.3" + "hypervel/database": "^0.4", + "hypervel/broadcasting": "^0.4", + "hypervel/support": "^0.4", + "hypervel/mail": "^0.4", + "hypervel/queue": "^0.4", + "hypervel/object-pool": "^0.4" }, "config": { "sort-packages": true @@ -53,7 +52,7 @@ ] }, "branch-alias": { - "dev-main": "0.3-dev" + "dev-main": "0.4-dev" } } } \ No newline at end of file diff --git a/src/notifications/src/AnonymousNotifiable.php b/src/notifications/src/AnonymousNotifiable.php index 150dd1458..297f2dec4 100644 --- a/src/notifications/src/AnonymousNotifiable.php +++ b/src/notifications/src/AnonymousNotifiable.php @@ -4,8 +4,8 @@ namespace Hypervel\Notifications; -use Hyperf\Context\ApplicationContext; -use Hypervel\Notifications\Contracts\Dispatcher; +use Hypervel\Context\ApplicationContext; +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..145d470c0 100644 --- a/src/notifications/src/ChannelManager.php +++ b/src/notifications/src/ChannelManager.php @@ -5,17 +5,17 @@ namespace Hypervel\Notifications; use Closure; -use Hyperf\Context\Context; -use Hyperf\Stringable\Str; -use Hypervel\Bus\Contracts\Dispatcher as BusDispatcherContract; +use Hypervel\Context\Context; +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; @@ -194,7 +194,7 @@ public function getPoolConfig(string $driver): array */ public function getDefaultDriver(): string { - return Context::get('__notifications.defaultChannel', $this->defaultChannel); + return Context::get('__notifications.default_channel', $this->defaultChannel); } /** @@ -210,7 +210,7 @@ public function deliversVia(): string */ public function deliverVia(string $channel): void { - Context::set('__notifications.defaultChannel', $channel); + Context::set('__notifications.default_channel', $channel); } /** @@ -218,7 +218,7 @@ public function deliverVia(string $channel): void */ public function locale(string $locale): static { - Context::set('__notifications.defaultLocale', $locale); + Context::set('__notifications.default_locale', $locale); return $this; } @@ -228,6 +228,6 @@ public function locale(string $locale): static */ public function getLocale(): ?string { - return Context::get('__notifications.defaultLocale', $this->locale); + return Context::get('__notifications.default_locale', $this->locale); } } 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..ffd6141d6 100644 --- a/src/notifications/src/Channels/MailChannel.php +++ b/src/notifications/src/Channels/MailChannel.php @@ -5,18 +5,17 @@ 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\Context\ApplicationContext; +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; @@ -114,7 +113,7 @@ protected function buildMarkdownText(MailMessage $message): Closure protected function markdownRenderer(MailMessage $message): Markdown { $config = ApplicationContext::getContainer() - ->get(ConfigInterface::class); + ->get('config'); $theme = $message->theme ?? $config->get('mail.markdown.theme', 'default'); 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/SlackWebApiChannel.php b/src/notifications/src/Channels/SlackWebApiChannel.php index 83f96d0d9..9c848fd47 100644 --- a/src/notifications/src/Channels/SlackWebApiChannel.php +++ b/src/notifications/src/Channels/SlackWebApiChannel.php @@ -5,7 +5,7 @@ namespace Hypervel\Notifications\Channels; use GuzzleHttp\Client as HttpClient; -use Hyperf\Contract\ConfigInterface; +use Hypervel\Config\Repository; use Hypervel\Notifications\Notification; use Hypervel\Notifications\Slack\SlackMessage; use Hypervel\Notifications\Slack\SlackRoute; @@ -22,7 +22,7 @@ class SlackWebApiChannel */ public function __construct( protected HttpClient $client, - protected ConfigInterface $config + protected Repository $config ) { } 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..2e4698434 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\Context\ApplicationContext; +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; +use Hypervel\Support\Traits\Conditionable; 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/Messages/SlackAttachment.php b/src/notifications/src/Messages/SlackAttachment.php index bdbb5b7b8..52f42d6a5 100644 --- a/src/notifications/src/Messages/SlackAttachment.php +++ b/src/notifications/src/Messages/SlackAttachment.php @@ -7,7 +7,7 @@ use Closure; use DateInterval; use DateTimeInterface; -use Hyperf\Support\Traits\InteractsWithTime; +use Hypervel\Support\InteractsWithTime; class SlackAttachment { 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..2dee6cfb7 100644 --- a/src/notifications/src/RoutesNotifications.php +++ b/src/notifications/src/RoutesNotifications.php @@ -4,9 +4,9 @@ namespace Hypervel\Notifications; -use Hyperf\Context\ApplicationContext; -use Hyperf\Stringable\Str; -use Hypervel\Notifications\Contracts\Dispatcher; +use Hypervel\Context\ApplicationContext; +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 + { + $rendered = $this->render(); + + return $rendered instanceof Stringable + ? (string) $rendered + : $rendered->toHtml(); + } + + /** + * 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 + { + $rendered = $this->render(); + + return $rendered instanceof Stringable + ? (string) $rendered + : $rendered->toHtml(); + } +} diff --git a/src/pagination/src/AbstractPaginator.php b/src/pagination/src/AbstractPaginator.php new file mode 100644 index 000000000..b6c5a7d85 --- /dev/null +++ b/src/pagination/src/AbstractPaginator.php @@ -0,0 +1,734 @@ + + */ +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 + { + $rendered = $this->render(); + + return $rendered instanceof Stringable + ? (string) $rendered + : $rendered->toHtml(); + } + + /** + * 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 + { + $rendered = $this->render(); + $renderedString = $rendered instanceof Stringable + ? (string) $rendered + : $rendered->toHtml(); + + return $this->escapeWhenCastingToString + ? e($renderedString) + : $renderedString; + } + + /** + * 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..e840ef4a9 --- /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/composer.json b/src/permission/composer.json index cddfc135f..ed22a3b3b 100644 --- a/src/permission/composer.json +++ b/src/permission/composer.json @@ -26,19 +26,19 @@ } }, "require": { - "php": "^8.2", - "hypervel/auth": "^0.3", - "hypervel/cache": "^0.3", - "hypervel/console": "^0.3", - "hypervel/core": "^0.3", - "hypervel/support": "^0.3" + "php": "^8.4", + "hypervel/auth": "^0.4", + "hypervel/cache": "^0.4", + "hypervel/console": "^0.4", + "hypervel/core": "^0.4", + "hypervel/support": "^0.4" }, "config": { "sort-packages": true }, "extra": { "branch-alias": { - "dev-main": "0.3-dev" + "dev-main": "0.4-dev" }, "hyperf": { "config": "Hypervel\\Permission\\ConfigProvider" 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..a6612aff9 100644 --- a/src/permission/src/PermissionManager.php +++ b/src/permission/src/PermissionManager.php @@ -4,9 +4,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; @@ -87,7 +86,7 @@ protected function initializeCache(): void protected function getConfig(string $name): mixed { - return $this->app->get(ConfigInterface::class)->get("permission.{$name}"); + return $this->app->get('config')->get("permission.{$name}"); } public function getCache(): Repository 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..b93d13f10 --- /dev/null +++ b/src/pool/composer.json @@ -0,0 +1,47 @@ +{ + "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" + }, + { + "name": "Raj Siva-Rajah", + "homepage": "https://github.com/binaryfire" + } + ], + "support": { + "issues": "https://github.com/hypervel/components/issues", + "source": "https://github.com/hypervel/components" + }, + "autoload": { + "psr-4": { + "Hypervel\\Pool\\": "src/" + } + }, + "require": { + "php": "^8.4", + "hyperf/contract": "~3.1.0", + "hypervel/coroutine": "^0.4", + "hypervel/engine": "^0.4", + "psr/container": "^1.0|^2.0" + }, + "config": { + "sort-packages": true + }, + "extra": { + "branch-alias": { + "dev-main": "0.4-dev" + } + } +} diff --git a/src/pool/src/Channel.php b/src/pool/src/Channel.php new file mode 100644 index 000000000..22b11d11d --- /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..4990def73 --- /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..4042686eb --- /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..110e30105 --- /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..7ca2ec048 --- /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..3f1eadf7f --- /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..64ba0e9ea --- /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..8ab6e4aec --- /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..8fe1aaa66 100644 --- a/src/process/composer.json +++ b/src/process/composer.json @@ -20,11 +20,10 @@ } ], "require": { - "php": "^8.2", - "hypervel/support": "^0.3", - "hyperf/collection": "^3.1", - "hyperf/macroable": "^3.1", - "hyperf/tappable": "^3.1", + "php": "^8.4", + "hypervel/support": "^0.4", + "hypervel/collections": "^0.4", + "hypervel/macroable": "^0.4", "symfony/process": "^7.0" }, "autoload": { @@ -34,7 +33,7 @@ }, "extra": { "branch-alias": { - "dev-main": "0.3-dev" + "dev-main": "0.4-dev" } }, "config": { 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..82c21f570 100644 --- a/src/process/src/PendingProcess.php +++ b/src/process/src/PendingProcess.php @@ -5,12 +5,12 @@ 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 Hypervel\Support\Traits\Conditionable; use LogicException; use RuntimeException; use Symfony\Component\Process\Exception\ProcessTimedOutException as SymfonyTimeoutException; @@ -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..ca01463b2 100644 --- a/src/prompts/composer.json +++ b/src/prompts/composer.json @@ -19,19 +19,19 @@ ] }, "require": { - "php": "^8.2", + "php": "^8.4", "ext-mbstring": "*", "composer-runtime-api": "^2.2", "symfony/console": "^6.2|^7.0", "nunomaduro/termwind": "^2.0" }, "require-dev": { - "hyperf/collection": "~3.1.0", + "hypervel/collections": "^0.4", "mockery/mockery": "^1.5" }, "extra": { "branch-alias": { - "dev-main": "0.3-dev" + "dev-main": "0.4-dev" } }, "prefer-stable": true, 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/Spinner.php b/src/prompts/src/Spinner.php index 72697bdbf..7538a8d10 100644 --- a/src/prompts/src/Spinner.php +++ b/src/prompts/src/Spinner.php @@ -5,7 +5,7 @@ namespace Hypervel\Prompts; use Closure; -use Hyperf\Coroutine\Coroutine; +use Hypervel\Coroutine\Coroutine; use RuntimeException; class Spinner 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..82cf20e5b 100644 --- a/src/queue/composer.json +++ b/src/queue/composer.json @@ -20,20 +20,19 @@ } ], "require": { - "php": "^8.2", - "hyperf/coroutine": "~3.1.0", - "hyperf/coordinator": "~3.1.0", + "php": "^8.4", + "hypervel/coroutine": "^0.4", + "hypervel/coordinator": "^0.4", "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.4", + "hypervel/database": "^0.4", "laravel/serializable-closure": "^1.2.2", "ramsey/uuid": "^4.7", "symfony/process": "^7.0", - "hypervel/core": "^0.3", - "hypervel/support": "^0.3", - "hypervel/encryption": "^0.3" + "hypervel/core": "^0.4", + "hypervel/support": "^0.4", + "hypervel/encryption": "^0.4" }, "autoload": { "psr-4": { @@ -45,7 +44,7 @@ "config": "Hypervel\\Queue\\ConfigProvider" }, "branch-alias": { - "dev-main": "0.3-dev" + "dev-main": "0.4-dev" } }, "suggest": { 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..b97628598 100644 --- a/src/queue/src/Connectors/RedisConnector.php +++ b/src/queue/src/Connectors/RedisConnector.php @@ -4,9 +4,9 @@ namespace Hypervel\Queue\Connectors; -use Hyperf\Redis\RedisFactory; -use Hypervel\Queue\Contracts\Queue; +use Hypervel\Contracts\Queue\Queue; use Hypervel\Queue\RedisQueue; +use Hypervel\Redis\RedisFactory; 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..e322c14df 100644 --- a/src/queue/src/Console/ClearCommand.php +++ b/src/queue/src/Console/ClearCommand.php @@ -5,11 +5,10 @@ namespace Hypervel\Queue\Console; 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; @@ -40,7 +39,7 @@ public function handle(): ?int } $connection = $this->argument('connection') - ?: $this->app->get(ConfigInterface::class)->get('queue.default'); + ?: $this->app->get('config')->get('queue.default'); // We need to get the right queue for the connection which is set in the queue // configuration file for the application. We will pull it based on the set @@ -67,7 +66,7 @@ public function handle(): ?int */ protected function getQueue(string $connection): string { - return $this->option('queue') ?: $this->app->get(ConfigInterface::class)->get( + return $this->option('queue') ?: $this->app->get('config')->get( "queue.connections.{$connection}.queue", 'default' ); 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..75084e1fa 100644 --- a/src/queue/src/Console/ListenCommand.php +++ b/src/queue/src/Console/ListenCommand.php @@ -5,10 +5,10 @@ namespace Hypervel\Queue\Console; use Hyperf\Command\Command; -use Hyperf\Contract\ConfigInterface; -use Hyperf\Stringable\Str; +use Hypervel\Config\Repository; use Hypervel\Queue\Listener; use Hypervel\Queue\ListenerOptions; +use Hypervel\Support\Str; use Hypervel\Support\Traits\HasLaravelStyleCommand; class ListenCommand extends Command @@ -40,7 +40,7 @@ class ListenCommand extends Command * Create a new queue listen command. */ public function __construct( - protected ConfigInterface $config, + protected Repository $config, protected Listener $listener ) { parent::__construct(); diff --git a/src/queue/src/Console/MonitorCommand.php b/src/queue/src/Console/MonitorCommand.php index 7ca6f1cf3..69b3fe486 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\Config\Repository; +use Hypervel\Contracts\Queue\Factory; use Hypervel\Queue\Events\QueueBusy; +use Hypervel\Support\Collection; use Hypervel\Support\Traits\HasLaravelStyleCommand; use Psr\EventDispatcher\EventDispatcherInterface; @@ -39,7 +39,7 @@ class MonitorCommand extends Command public function __construct( protected Factory $manager, protected EventDispatcherInterface $events, - protected ConfigInterface $config, + protected Repository $config, ) { parent::__construct(); } @@ -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..9971277cb 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\InteractsWithTime; use Hypervel\Support\Traits\HasLaravelStyleCommand; -use Hypervel\Support\Traits\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..cd9714c0c 100644 --- a/src/queue/src/Console/WorkCommand.php +++ b/src/queue/src/Console/WorkCommand.php @@ -5,10 +5,9 @@ namespace Hypervel\Queue\Console; 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\Config\Repository; +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\InteractsWithTime; +use Hypervel\Support\Str; use Hypervel\Support\Traits\HasLaravelStyleCommand; -use Hypervel\Support\Traits\InteractsWithTime; use Psr\Container\ContainerInterface; use Psr\EventDispatcher\EventDispatcherInterface; use Symfony\Component\Console\Terminal; @@ -75,7 +75,7 @@ class WorkCommand extends Command */ public function __construct( protected ContainerInterface $container, - protected ConfigInterface $config, + protected Repository $config, protected Worker $worker, protected CacheFactory $cache ) { diff --git a/src/queue/src/CoroutineQueue.php b/src/queue/src/CoroutineQueue.php index 963705c4d..59fa8fb9c 100644 --- a/src/queue/src/CoroutineQueue.php +++ b/src/queue/src/CoroutineQueue.php @@ -5,7 +5,6 @@ namespace Hypervel\Queue; use Hypervel\Coroutine\Coroutine; -use Hypervel\Database\TransactionManager; use Throwable; class CoroutineQueue extends SyncQueue @@ -24,9 +23,9 @@ public function push(object|string $job, mixed $data = '', ?string $queue = null { if ( $this->shouldDispatchAfterCommit($job) - && $this->container->has(TransactionManager::class) + && $this->container->has('db.transactions') ) { - return $this->container->get(TransactionManager::class) + return $this->container->get('db.transactions') ->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..820133e3d 100644 --- a/src/queue/src/DeferQueue.php +++ b/src/queue/src/DeferQueue.php @@ -4,8 +4,7 @@ namespace Hypervel\Queue; -use Hyperf\Engine\Coroutine; -use Hypervel\Database\TransactionManager; +use Hypervel\Engine\Coroutine; use Throwable; class DeferQueue extends SyncQueue @@ -23,9 +22,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('db.transactions') ) { - return $this->container->get(TransactionManager::class) + return $this->container->get('db.transactions') ->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..d405bf79d 100644 --- a/src/queue/src/Failed/FailedJobProviderFactory.php +++ b/src/queue/src/Failed/FailedJobProviderFactory.php @@ -4,16 +4,15 @@ 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 { public function __invoke(ContainerInterface $container) { - $config = $container->get(ConfigInterface::class) + $config = $container->get('config') ->get('queue.failed', []); if (array_key_exists('driver', $config) 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..b5bc46956 100644 --- a/src/queue/src/InteractsWithQueue.php +++ b/src/queue/src/InteractsWithQueue.php @@ -6,10 +6,10 @@ 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 Hypervel\Support\InteractsWithTime; use PHPUnit\Framework\Assert as PHPUnit; use RuntimeException; use Throwable; diff --git a/src/queue/src/Jobs/DatabaseJobRecord.php b/src/queue/src/Jobs/DatabaseJobRecord.php index 282266835..706df6245 100644 --- a/src/queue/src/Jobs/DatabaseJobRecord.php +++ b/src/queue/src/Jobs/DatabaseJobRecord.php @@ -4,7 +4,7 @@ namespace Hypervel\Queue\Jobs; -use Hyperf\Support\Traits\InteractsWithTime; +use Hypervel\Support\InteractsWithTime; use stdClass; class DatabaseJobRecord 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..bd5486b74 100644 --- a/src/queue/src/Jobs/Job.php +++ b/src/queue/src/Jobs/Job.php @@ -4,13 +4,13 @@ namespace Hypervel\Queue\Jobs; -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; +use Hypervel\Support\InteractsWithTime; use Psr\Container\ContainerInterface; use Psr\EventDispatcher\EventDispatcherInterface; use Throwable; 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..cf0a103cb 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\Context\ApplicationContext; +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..e93886735 100644 --- a/src/queue/src/Middleware/RateLimitedWithRedis.php +++ b/src/queue/src/Middleware/RateLimitedWithRedis.php @@ -4,13 +4,10 @@ namespace Hypervel\Queue\Middleware; -use Hyperf\Context\ApplicationContext; -use Hyperf\Contract\ConfigInterface; -use Hyperf\Redis\RedisFactory; +use Hypervel\Context\ApplicationContext; use Hypervel\Redis\Limiters\DurationLimiter; -use Hypervel\Support\Traits\InteractsWithTime; - -use function Hyperf\Tappable\tap; +use Hypervel\Redis\RedisFactory; +use Hypervel\Support\InteractsWithTime; class RateLimitedWithRedis extends RateLimited { @@ -82,7 +79,7 @@ protected function getTimeUntilNextRetry(string $key): int protected function getConnectionName(): string { return ApplicationContext::getContainer() - ->get(ConfigInterface::class) + ->get('config') ->get('queue.connections.redis.connection', 'default'); } diff --git a/src/queue/src/Middleware/ThrottlesExceptionsWithRedis.php b/src/queue/src/Middleware/ThrottlesExceptionsWithRedis.php index dc3577ed7..26b0c981c 100644 --- a/src/queue/src/Middleware/ThrottlesExceptionsWithRedis.php +++ b/src/queue/src/Middleware/ThrottlesExceptionsWithRedis.php @@ -4,11 +4,10 @@ namespace Hypervel\Queue\Middleware; -use Hyperf\Context\ApplicationContext; -use Hyperf\Contract\ConfigInterface; -use Hyperf\Redis\RedisFactory; +use Hypervel\Context\ApplicationContext; use Hypervel\Redis\Limiters\DurationLimiter; -use Hypervel\Support\Traits\InteractsWithTime; +use Hypervel\Redis\RedisFactory; +use Hypervel\Support\InteractsWithTime; use Throwable; class ThrottlesExceptionsWithRedis extends ThrottlesExceptions @@ -69,7 +68,7 @@ public function handle(mixed $job, callable $next): mixed protected function getConnectionName(): string { return ApplicationContext::getContainer() - ->get(ConfigInterface::class) + ->get('config') ->get('queue.connections.redis.connection', 'default'); } } diff --git a/src/queue/src/Middleware/WithoutOverlapping.php b/src/queue/src/Middleware/WithoutOverlapping.php index 849958ad1..5aae91abd 100644 --- a/src/queue/src/Middleware/WithoutOverlapping.php +++ b/src/queue/src/Middleware/WithoutOverlapping.php @@ -6,9 +6,9 @@ use DateInterval; use DateTimeInterface; -use Hyperf\Context\ApplicationContext; -use Hypervel\Cache\Contracts\Factory as CacheFactory; -use Hypervel\Support\Traits\InteractsWithTime; +use Hypervel\Context\ApplicationContext; +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..ad0263fd2 100644 --- a/src/queue/src/Queue.php +++ b/src/queue/src/Queue.php @@ -7,22 +7,19 @@ 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\Queue\Events\JobQueued; use Hypervel\Queue\Events\JobQueueing; use Hypervel\Queue\Exceptions\InvalidPayloadException; +use Hypervel\Support\Arr; +use Hypervel\Support\Collection; +use Hypervel\Support\InteractsWithTime; +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 +276,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('db.transactions') ) { - return $this->container->get(TransactionManager::class) + return $this->container->get('db.transactions') ->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..bea9b527e 100644 --- a/src/queue/src/QueueManager.php +++ b/src/queue/src/QueueManager.php @@ -5,9 +5,11 @@ namespace Hypervel\Queue; use Closure; -use Hyperf\Contract\ConfigInterface; -use Hyperf\Database\ConnectionResolverInterface; -use Hyperf\Redis\RedisFactory; +use Hypervel\Config\Repository; +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 +21,13 @@ 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 Hypervel\Redis\RedisFactory; 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 { @@ -36,7 +36,7 @@ class QueueManager implements FactoryContract, MonitorContract /** * The config instance. */ - protected ConfigInterface $config; + protected Repository $config; /** * The array of resolved queue connections. @@ -64,7 +64,7 @@ class QueueManager implements FactoryContract, MonitorContract public function __construct( protected ContainerInterface $app ) { - $this->config = $app->get(ConfigInterface::class); + $this->config = $app->get('config'); $this->registerConnectors(); } 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..5f76f2016 100644 --- a/src/queue/src/RedisQueue.php +++ b/src/queue/src/RedisQueue.php @@ -6,13 +6,13 @@ use DateInterval; 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\Redis\RedisFactory; +use Hypervel\Redis\RedisProxy; +use Hypervel\Support\Str; class RedisQueue extends Queue implements QueueContract, ClearableQueue { @@ -53,12 +53,10 @@ public function size(?string $queue = null): int return $this->getConnection()->eval( LuaScripts::size(), - [ - $queue, - $queue . ':delayed', - $queue . ':reserved', - ], 3, + $queue, + $queue . ':delayed', + $queue . ':reserved', ); } @@ -145,12 +143,10 @@ public function pushRaw(string $payload, ?string $queue = null, array $options = { $this->getConnection()->eval( LuaScripts::push(), - [ - $this->getQueue($queue), - $this->getQueue($queue) . ':notify', - $payload, - ], 2, + $this->getQueue($queue), + $this->getQueue($queue) . ':notify', + $payload, ); return json_decode($payload, true)['id'] ?? null; @@ -246,14 +242,12 @@ public function migrateExpiredJobs(string $from, string $to): array { return $this->getConnection()->eval( LuaScripts::migrateExpiredJobs(), - [ - $from, - $to, - $to . ':notify', - $this->currentTime(), - $this->migrationBatchSize, - ], 3, + $from, + $to, + $to . ':notify', + $this->currentTime(), + $this->migrationBatchSize, ); } @@ -264,13 +258,11 @@ protected function retrieveNextJob(string $queue, bool $block = true): array { $nextJob = $this->getConnection()->eval( LuaScripts::pop(), - [ - $queue, - $queue . ':reserved', - $queue . ':notify', - $this->availableAt($this->retryAfter), - ], 3, + $queue, + $queue . ':reserved', + $queue . ':notify', + $this->availableAt($this->retryAfter), ); if (empty($nextJob)) { @@ -305,13 +297,11 @@ public function deleteAndRelease(string $queue, RedisJob $job, DateInterval|Date $this->getConnection()->eval( LuaScripts::release(), - [ - $queue . ':delayed', - $queue . ':reserved', - $job->getReservedJob(), - $this->availableAt($delay), - ], 2, + $queue . ':delayed', + $queue . ':reserved', + $job->getReservedJob(), + $this->availableAt($delay), ); } @@ -325,13 +315,11 @@ public function clear(string $queue): int return $this->getConnection() ->eval( LuaScripts::clear(), - [ - $queue, - $queue . ':delayed', - $queue . ':reserved', - $queue . ':notify', - ], 4, + $queue, + $queue . ':delayed', + $queue . ':reserved', + $queue . ':notify', ); } diff --git a/src/queue/src/SerializesAndRestoresModelIdentifiers.php b/src/queue/src/SerializesAndRestoresModelIdentifiers.php index b4f029e18..4831030be 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 { @@ -108,7 +108,7 @@ public function restoreModel(ModelIdentifier $value): Model /** * Get the query for model restoration. */ - protected function getQueryForModelRestoration(Model $model, array|int $ids): Builder + protected function getQueryForModelRestoration(Model $model, array|int|string $ids): Builder { return $model->newQueryForRestoration($ids); } 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..a6cad9106 100644 --- a/src/queue/src/SyncQueue.php +++ b/src/queue/src/SyncQueue.php @@ -6,10 +6,9 @@ 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\Queue\Events\JobExceptionOccurred; use Hypervel\Queue\Events\JobProcessed; use Hypervel\Queue\Events\JobProcessing; @@ -75,9 +74,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('db.transactions') ) { - return $this->container->get(TransactionManager::class) + return $this->container->get('db.transactions') ->addCallback( fn () => $this->executeJob($job, $data, $queue) ); diff --git a/src/queue/src/Worker.php b/src/queue/src/Worker.php index 2d3f93f56..80592ea52 100644 --- a/src/queue/src/Worker.php +++ b/src/queue/src/Worker.php @@ -4,16 +4,16 @@ namespace Hypervel\Queue; -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\Coordinator\Timer; +use Hypervel\Coroutine\Concurrent; 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, @@ -202,6 +202,11 @@ public function daemon(string $connectionName, string $queue, WorkerOptions $opt ); if (! is_null($status)) { + // Ensure in-flight job coroutines finish before daemon() reports completion. + while (! $concurrent->isEmpty()) { + usleep(1000); + } + return $this->stop($status, $options); } } @@ -312,7 +317,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..450f2a580 100644 --- a/src/redis/composer.json +++ b/src/redis/composer.json @@ -14,7 +14,11 @@ { "name": "Albert Chen", "email": "albert@hypervel.org" - } + }, + { + "name": "Raj Siva-Rajah", + "homepage": "https://github.com/binaryfire" + } ], "support": { "issues": "https://github.com/hypervel/components/issues", @@ -26,9 +30,12 @@ } }, "require": { - "php": "^8.2", - "hyperf/redis": "~3.1.0", - "hypervel/support": "^0.3" + "php": "^8.4", + "ext-redis": "*", + "hyperf/contract": "~3.1.0", + "hypervel/contracts": "^0.4", + "hypervel/pool": "^0.4", + "hypervel/support": "^0.4" }, "config": { "sort-packages": true @@ -38,7 +45,7 @@ "config": "Hypervel\\Redis\\ConfigProvider" }, "branch-alias": { - "dev-main": "0.3-dev" + "dev-main": "0.4-dev" } } -} \ No newline at end of file +} diff --git a/src/redis/src/ConfigProvider.php b/src/redis/src/ConfigProvider.php index c1b64395b..dd988247b 100644 --- a/src/redis/src/ConfigProvider.php +++ b/src/redis/src/ConfigProvider.php @@ -4,15 +4,16 @@ namespace Hypervel\Redis; -use Hyperf\Redis\Pool\RedisPool as HyperfRedisPool; - class ConfigProvider { + /** + * Get the Redis package configuration. + */ public function __invoke(): array { return [ 'dependencies' => [ - HyperfRedisPool::class => RedisPool::class, + \Redis::class => Redis::class, ], ]; } diff --git a/src/redis/src/Events/CommandExecuted.php b/src/redis/src/Events/CommandExecuted.php new file mode 100644 index 000000000..7a3025a0f --- /dev/null +++ b/src/redis/src/Events/CommandExecuted.php @@ -0,0 +1,49 @@ +parameters)->map(function ($parameter) { + if (is_array($parameter)) { + return collect($parameter)->map(function ($value, $key) { + if (is_array($value)) { + return sprintf('%s %s', $key, json_encode($value)); + } + + return is_int($key) ? $value : sprintf('%s %s', $key, $value); + })->implode(' '); + } + + return $parameter; + })->implode(' '); + + return sprintf('%s %s', $this->command, $parameters); + } +} diff --git a/src/redis/src/Exceptions/InvalidRedisConnectionException.php b/src/redis/src/Exceptions/InvalidRedisConnectionException.php new file mode 100644 index 000000000..9d70c2a23 --- /dev/null +++ b/src/redis/src/Exceptions/InvalidRedisConnectionException.php @@ -0,0 +1,11 @@ +getConnection()->eval( $this->luaScript(), - [ - $this->name, - microtime(true), - time(), - $this->decay, - $this->maxLocks, - ], - 1 + 1, + $this->name, + microtime(true), + time(), + $this->decay, + $this->maxLocks, ); $this->decaysAt = $results[1]; @@ -92,14 +90,12 @@ public function tooManyAttempts(): bool { [$this->decaysAt, $this->remaining] = $this->getConnection()->eval( $this->tooManyAttemptsLuaScript(), - [ - $this->name, - microtime(true), - time(), - $this->decay, - $this->maxLocks, - ], - 1 + 1, + $this->name, + microtime(true), + time(), + $this->decay, + $this->maxLocks, ); return $this->remaining <= 0; diff --git a/src/redis/src/Pool/PoolFactory.php b/src/redis/src/Pool/PoolFactory.php new file mode 100644 index 000000000..cc082ade5 --- /dev/null +++ b/src/redis/src/Pool/PoolFactory.php @@ -0,0 +1,42 @@ +pools as $pool) { + $pool->flushAll(); + } + } + + /** + * Get or create a pool for the given connection name. + */ + public function getPool(string $name): RedisPool + { + if (isset($this->pools[$name])) { + return $this->pools[$name]; + } + + return $this->pools[$name] = $this->container->make(RedisPool::class, ['name' => $name]); + } +} diff --git a/src/redis/src/Pool/RedisPool.php b/src/redis/src/Pool/RedisPool.php new file mode 100644 index 000000000..5cb06369c --- /dev/null +++ b/src/redis/src/Pool/RedisPool.php @@ -0,0 +1,58 @@ +get(RedisConfig::class); + $this->config = $configService->connectionConfig($this->name); + $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; + } + + /** + * Get the Redis connection configuration. + */ + public function getConfig(): array + { + return $this->config; + } + + /** + * Create a new pooled Redis connection. + */ + protected function createConnection(): ConnectionInterface + { + return new RedisConnection($this->container, $this, $this->config); + } +} diff --git a/src/redis/src/Redis.php b/src/redis/src/Redis.php index a36c94039..d480876cf 100644 --- a/src/redis/src/Redis.php +++ b/src/redis/src/Redis.php @@ -4,12 +4,15 @@ namespace Hypervel\Redis; -use Hyperf\Redis\Event\CommandExecuted; -use Hyperf\Redis\Exception\InvalidRedisConnectionException; -use Hyperf\Redis\Pool\PoolFactory; use Hypervel\Context\ApplicationContext; use Hypervel\Context\Context; +use Hypervel\Redis\Events\CommandExecuted; +use Hypervel\Redis\Exceptions\InvalidRedisConnectionException; +use Hypervel\Redis\Pool\PoolFactory; +use Hypervel\Redis\Subscriber\Subscriber; use Hypervel\Redis\Traits\MultiExec; +use Hypervel\Redis\Traits\ScanCaller; +use Hypervel\Support\Arr; use Throwable; use UnitEnum; @@ -20,6 +23,7 @@ */ class Redis { + use ScanCaller; use MultiExec; protected string $poolName = 'default'; @@ -31,6 +35,10 @@ public function __construct( public function __call($name, $arguments) { + if (in_array($name, ['subscribe', 'psubscribe'], true)) { + return $this->handleSubscribe($name, $arguments); + } + $hasContextConnection = Context::has($this->getContextKey()); $connection = $this->getConnection($hasContextConnection); @@ -62,8 +70,8 @@ public function __call($name, $arguments) // Connection is already in context, don't release } elseif ($exception === null && $this->shouldUseSameConnection($name)) { // On success with same-connection command: store in context for reuse - if ($name === 'select' && $db = $arguments[0]) { - $connection->setDatabase((int) $db); + if ($name === 'select' && array_key_exists(0, $arguments)) { + $connection->setDatabase((int) $arguments[0]); } Context::set($this->getContextKey(), $connection); defer(function () { @@ -96,6 +104,37 @@ protected function releaseContextConnection(): void } } + /** + * Handle subscribe/psubscribe using the coroutine-native subscriber. + * + * Creates a dedicated socket connection (not from the pool) and bridges + * the channel-based subscriber to the Laravel-style callback API. + */ + protected function handleSubscribe(string $name, array $arguments): void + { + $channels = Arr::wrap($arguments[0]); + $callback = $arguments[1]; + + $subscriber = $this->subscriber(); + + try { + if ($name === 'subscribe') { + $subscriber->subscribe(...$channels); + } else { + $subscriber->psubscribe(...$channels); + } + + $channel = $subscriber->channel(); + while ($message = $channel->pop()) { + $callback($message->payload, $message->channel); + } + } finally { + if (! $subscriber->closed) { + $subscriber->close(); + } + } + } + /** * Define the commands that need same connection to execute. * When these commands executed, the connection will storage to coroutine context. @@ -106,7 +145,7 @@ protected function shouldUseSameConnection(string $methodName): bool 'multi', 'pipeline', 'select', - ]); + ], true); } /** @@ -168,6 +207,31 @@ public function withConnection(callable $callback, bool $transform = true): mixe } } + /** + * Create a coroutine-native Redis subscriber. + * + * Returns a Subscriber with its own dedicated socket connection (not from + * the pool). Use for the channel-based pub/sub API: + * + * $sub = Redis::subscriber(); + * $sub->subscribe('channel'); + * while ($message = $sub->channel()->pop()) { ... } + * $sub->close(); + */ + public function subscriber(): Subscriber + { + $config = $this->factory->getPool($this->poolName)->getConfig(); + $options = $config['options'] ?? []; + + return new Subscriber( + host: $config['host'], + port: (int) $config['port'], + password: (string) ($config['auth'] ?? ''), + timeout: (float) ($config['timeout'] ?? 5.0), + prefix: (string) ($options['prefix'] ?? ''), + ); + } + /** * Get a Redis connection by name. */ diff --git a/src/redis/src/RedisConfig.php b/src/redis/src/RedisConfig.php new file mode 100644 index 000000000..0ccb8eddf --- /dev/null +++ b/src/redis/src/RedisConfig.php @@ -0,0 +1,137 @@ + + */ + public function connectionNames(): array + { + $redisConfig = $this->all(); + $names = []; + + foreach ($redisConfig as $name => $connectionConfig) { + if (in_array($name, ['client', 'options', 'clusters'], true)) { + continue; + } + + $this->validateConnectionConfig($name, $connectionConfig); + + $names[] = $name; + } + + return $names; + } + + /** + * Get a single Redis connection config with merged options. + * + * @return array + */ + public function connectionConfig(string $name): array + { + $redisConfig = $this->all(); + $connectionConfig = $redisConfig[$name] ?? null; + $this->validateConnectionConfig($name, $connectionConfig); + + $sharedOptions = $redisConfig['options'] ?? []; + if (! is_array($sharedOptions)) { + throw new InvalidArgumentException('The redis options config must be an array.'); + } + + $connectionOptions = $connectionConfig['options'] ?? []; + if (! is_array($connectionOptions)) { + throw new InvalidArgumentException(sprintf('The redis connection [%s] options must be an array.', $name)); + } + + $connectionConfig['options'] = array_replace($sharedOptions, $connectionOptions); + + return $connectionConfig; + } + + /** + * Get all redis config. + * + * @return array + */ + private function all(): array + { + $redisConfig = $this->config->get('database.redis'); + if (! is_array($redisConfig)) { + throw new InvalidArgumentException('The redis config must be an array.'); + } + + return $redisConfig; + } + + /** + * Validate a redis connection config entry. + */ + private function validateConnectionConfig(string $name, mixed $connectionConfig): void + { + if (! is_array($connectionConfig)) { + throw new InvalidArgumentException(sprintf('The redis connection [%s] must be an array.', $name)); + } + + $clusterConfig = $connectionConfig['cluster'] ?? []; + if (! is_array($clusterConfig)) { + throw new InvalidArgumentException(sprintf('The redis connection [%s] cluster config must be an array.', $name)); + } + + $sentinelConfig = $connectionConfig['sentinel'] ?? []; + if (! is_array($sentinelConfig)) { + throw new InvalidArgumentException(sprintf('The redis connection [%s] sentinel config must be an array.', $name)); + } + + $clusterEnabled = (bool) ($clusterConfig['enable'] ?? false); + $sentinelEnabled = (bool) ($sentinelConfig['enable'] ?? false); + + if ($clusterEnabled && $sentinelEnabled) { + throw new InvalidArgumentException(sprintf('The redis connection [%s] cannot enable both cluster and sentinel.', $name)); + } + + if ($clusterEnabled) { + $seeds = $clusterConfig['seeds'] ?? null; + if (! is_array($seeds) || $seeds === []) { + throw new InvalidArgumentException(sprintf('The redis connection [%s] cluster seeds must be a non-empty array.', $name)); + } + + return; + } + + if ($sentinelEnabled) { + $nodes = $sentinelConfig['nodes'] ?? null; + $masterName = $sentinelConfig['master_name'] ?? null; + + if (! is_array($nodes) || $nodes === []) { + throw new InvalidArgumentException(sprintf('The redis connection [%s] sentinel nodes must be a non-empty array.', $name)); + } + + if (! is_string($masterName) || $masterName === '') { + throw new InvalidArgumentException(sprintf('The redis connection [%s] sentinel master name must be configured.', $name)); + } + + return; + } + + if (! array_key_exists('host', $connectionConfig) || ! array_key_exists('port', $connectionConfig)) { + throw new InvalidArgumentException(sprintf('The redis connection [%s] must define host and port.', $name)); + } + } +} diff --git a/src/redis/src/RedisConnection.php b/src/redis/src/RedisConnection.php index 503687e50..bd93df905 100644 --- a/src/redis/src/RedisConnection.php +++ b/src/redis/src/RedisConnection.php @@ -5,14 +5,23 @@ namespace Hypervel\Redis; use Generator; -use Hyperf\Redis\RedisConnection as HyperfRedisConnection; +use Hyperf\Contract\StdoutLoggerInterface; +use Hypervel\Contracts\Pool\PoolInterface; +use Hypervel\Pool\Connection as BaseConnection; +use Hypervel\Pool\Exception\ConnectionException; +use Hypervel\Redis\Exceptions\InvalidRedisConnectionException; +use Hypervel\Redis\Exceptions\InvalidRedisOptionException; use Hypervel\Redis\Exceptions\LuaScriptException; use Hypervel\Redis\Operations\FlushByPattern; use Hypervel\Redis\Operations\SafeScan; use Hypervel\Support\Arr; use Hypervel\Support\Collection; +use Psr\Container\ContainerInterface; +use Psr\EventDispatcher\EventDispatcherInterface; +use Psr\Log\LogLevel; use Redis; use RedisCluster; +use RedisException; use Throwable; /** @@ -22,14 +31,21 @@ * @method bool set(string $key, mixed $value, mixed $expireResolution = null, mixed $expireTTL = null, mixed $flag = null) Set the value of a key * @method array mget(array $keys) Get the values of multiple keys * @method int setnx(string $key, string $value) Set key if not exists + * @method int setNx(string $key, string $value) Set key if not exists * @method array hmget(string $key, mixed ...$fields) Get hash field values * @method bool hmset(string $key, mixed ...$dictionary) Set hash field values * @method int hsetnx(string $hash, string $key, string $value) Set hash field if not exists + * @method mixed hget(string $key, string $member) Get hash field value + * @method false|int hset(string $key, mixed ...$fields_and_vals) Set hash field values * @method false|int lrem(string $key, int $count, mixed $value) Remove list elements + * @method false|int llen(string $key) Get list length * @method null|array blpop(mixed ...$arguments) Blocking left pop from list * @method null|array brpop(mixed ...$arguments) Blocking right pop from list * @method mixed spop(string $key, int $count = 1) Remove and return random set member + * @method false|int sRem(string $key, mixed $value, mixed ...$other_values) Remove members from set * @method int zadd(string $key, mixed ...$dictionary) Add members to sorted set + * @method false|int zcard(string $key) Get sorted set cardinality + * @method false|int zcount(string $key, int|string $start, int|string $end) Count sorted set members by score range * @method array zrangebyscore(string $key, mixed $min, mixed $max, array $options = []) Get sorted set members by score range * @method array zrevrangebyscore(string $key, mixed $min, mixed $max, array $options = []) Get sorted set members by score range (reverse) * @method int zinterstore(string $output, array $keys, array $options = []) Intersect sorted sets @@ -38,6 +54,7 @@ * @method mixed evalsha(string $script, int $numkeys, mixed ...$arguments) Evaluate Lua script by SHA1 * @method mixed flushdb(mixed ...$arguments) Flush database * @method mixed executeRaw(array $parameters) Execute raw Redis command + * @method mixed pipeline(callable|null $callback = null) Execute commands in a pipeline * @method array smembers(string $key) Get all set members * @method false|int hdel(string $key, string ...$fields) Delete hash fields * @method false|int zrem(string $key, string ...$members) Remove sorted set members @@ -305,40 +322,371 @@ * @method false|int|Redis zintercard(array $keys, int $limit = -1) * @method array|false|Redis zunion(array $keys, array|null $weights = null, array|null $options = null) */ -class RedisConnection extends HyperfRedisConnection +class RedisConnection extends BaseConnection { + protected Redis|RedisCluster|null $connection = null; + + protected ?EventDispatcherInterface $eventDispatcher = null; + + protected array $config = [ + 'timeout' => 0.0, + 'reserved' => null, + 'retry_interval' => 0, + 'read_timeout' => 0.0, + 'cluster' => [ + 'enable' => false, + 'name' => null, + 'seeds' => [], + 'read_timeout' => 0.0, + 'persistent' => false, + 'context' => [], + ], + 'sentinel' => [ + 'enable' => false, + 'master_name' => '', + 'nodes' => [], + 'persistent' => '', + 'read_timeout' => 0, + ], + 'options' => [], + 'context' => [], + 'event' => [ + 'enable' => false, + ], + ]; + + /** + * Current redis database. + */ + protected ?int $database = null; + /** * Determine if the connection calls should be transformed to Laravel style. */ protected bool $shouldTransform = false; + /** + * Create a new Redis connection instance. + * + * @param array $config + */ + public function __construct(ContainerInterface $container, PoolInterface $pool, array $config) + { + parent::__construct($container, $pool); + $this->config = array_replace_recursive($this->config, $config); + + $this->reconnect(); + } + public function __call($name, $arguments) { try { - if (in_array($name, ['subscribe', 'psubscribe'])) { - return $this->callSubscribe($name, $arguments); + return $this->executeCommand($name, $arguments); + } catch (RedisException $exception) { + return $this->retry($name, $arguments, $exception); + } + } + + /** + * Execute a Redis command, applying transforms when enabled. + * + * @param array $arguments + */ + private function executeCommand(string $name, array $arguments): mixed + { + if (in_array($name, ['subscribe', 'psubscribe'], true)) { + return $this->callSubscribe($name, $arguments); + } + + if ($this->shouldTransform && ! $this->isQueueingMode()) { + $method = 'call' . ucfirst($name); + if (method_exists($this, $method)) { + return $this->{$method}(...$arguments); } + } - if ($this->shouldTransform) { - $method = 'call' . ucfirst($name); - if (method_exists($this, $method)) { - return $this->{$method}(...$arguments); - } + return $this->connection->{$name}(...$arguments); + } + + /** + * Get the active connection. + */ + public function getActiveConnection(): static + { + if ($this->check()) { + return $this; + } + + if (! $this->reconnect()) { + throw new ConnectionException('Connection reconnect failed.'); + } + + return $this; + } + + /** + * Get the event dispatcher instance. + */ + public function getEventDispatcher(): ?EventDispatcherInterface + { + return $this->eventDispatcher; + } + + /** + * Reconnect to Redis. + * + * @throws RedisException + * @throws ConnectionException + */ + public function reconnect(): bool + { + $auth = $this->config['auth'] ?? null; + $db = (int) ($this->config['db'] ?? 0); + $cluster = $this->config['cluster']['enable'] ?? false; + $sentinel = $this->config['sentinel']['enable'] ?? false; + + $redis = match (true) { + $cluster => $this->createRedisCluster(), + $sentinel => $this->createRedisSentinel(), + default => $this->createRedis($this->config), + }; + + $options = $this->config['options'] ?? []; + + foreach ($options as $name => $value) { + if (is_string($name)) { + $name = match (strtolower($name)) { + 'serializer' => Redis::OPT_SERIALIZER, + 'prefix' => Redis::OPT_PREFIX, + 'read_timeout' => Redis::OPT_READ_TIMEOUT, + 'scan' => Redis::OPT_SCAN, + 'failover' => defined(Redis::class . '::OPT_SLAVE_FAILOVER') ? Redis::OPT_SLAVE_FAILOVER : 5, + 'keepalive' => Redis::OPT_TCP_KEEPALIVE, + 'compression' => Redis::OPT_COMPRESSION, + 'reply_literal' => Redis::OPT_REPLY_LITERAL, + 'compression_level' => Redis::OPT_COMPRESSION_LEVEL, + default => throw new InvalidRedisOptionException(sprintf('The redis option key `%s` is invalid.', $name)), + }; } - return $this->connection->{$name}(...$arguments); - } catch (Throwable $exception) { - $result = $this->retry($name, $arguments, $exception); + $redis->setOption($name, $value); } - return $result; + if ($redis instanceof Redis && isset($auth) && $auth !== '') { + $redis->auth($auth); + } + + $database = $this->database ?? $db; + if ($database > 0) { + $redis->select($database); + } + + $this->connection = $redis; + $this->lastUseTime = microtime(true); + + if (($this->config['event']['enable'] ?? false) && $this->container->has(EventDispatcherInterface::class)) { + $this->eventDispatcher = $this->container->get(EventDispatcherInterface::class); + } + + return true; } + /** + * Close the current connection. + */ + public function close(): bool + { + $this->connection = null; + + return true; + } + + /** + * Release the connection back to pool. + */ public function release(): void { $this->shouldTransform = false; - parent::release(); + try { + $defaultDb = (int) ($this->config['db'] ?? 0); + if ($this->database !== null && $this->database !== $defaultDb) { + $this->select($defaultDb); + $this->database = null; + } + + parent::release(); + } catch (Throwable $exception) { + $this->log('Release connection failed, caused by ' . $exception, LogLevel::CRITICAL); + } + } + + /** + * Set current redis database. + */ + public function setDatabase(?int $database): void + { + $this->database = $database; + } + + /** + * Create a redis cluster connection. + */ + protected function createRedisCluster(): RedisCluster + { + try { + $parameters = []; + $parameters[] = $this->config['cluster']['name'] ?? null; + $parameters[] = $this->config['cluster']['seeds'] ?? []; + $parameters[] = $this->config['timeout'] ?? 0.0; + $parameters[] = $this->config['cluster']['read_timeout'] ?? 0.0; + $parameters[] = $this->config['cluster']['persistent'] ?? false; + $parameters[] = $this->config['auth'] ?? null; + if (! empty($this->config['cluster']['context'])) { + $parameters[] = $this->config['cluster']['context']; + } + + $redis = new RedisCluster(...$parameters); + } catch (Throwable $exception) { + throw new ConnectionException('Connection reconnect failed ' . $exception->getMessage()); + } + + return $redis; + } + + /** + * Retry a redis command after reconnecting. + * + * @param array $arguments + */ + protected function retry(string $name, array $arguments, RedisException $exception): mixed + { + $this->log('Redis::__call failed, because ' . $exception->getMessage()); + + try { + $this->reconnect(); + + return $this->executeCommand($name, $arguments); + } catch (Throwable $exception) { + $this->lastUseTime = 0.0; + throw $exception; + } + } + + /** + * Determine if the underlying Redis client is in pipeline/multi mode. + */ + protected function isQueueingMode(): bool + { + return $this->connection instanceof Redis && $this->connection->getMode() !== Redis::ATOMIC; + } + + /** + * Create a redis sentinel connection. + * + * @throws ConnectionException + */ + protected function createRedisSentinel(): Redis + { + try { + $nodes = $this->config['sentinel']['nodes'] ?? []; + $timeout = $this->config['timeout'] ?? 0; + $persistent = $this->config['sentinel']['persistent'] ?? null; + $retryInterval = $this->config['retry_interval'] ?? 0; + $readTimeout = $this->config['sentinel']['read_timeout'] ?? 0; + $masterName = $this->config['sentinel']['master_name'] ?? ''; + $auth = $this->config['sentinel']['auth'] ?? null; + + shuffle($nodes); + + $host = null; + $port = null; + foreach ($nodes as $node) { + try { + $resolved = parse_url($node); + if (! isset($resolved['host'], $resolved['port'])) { + $this->log(sprintf('The redis sentinel node [%s] is invalid.', $node), LogLevel::ERROR); + continue; + } + + $options = [ + 'host' => $resolved['host'], + 'port' => (int) $resolved['port'], + 'connectTimeout' => $timeout, + 'persistent' => $persistent, + 'retryInterval' => $retryInterval, + 'readTimeout' => $readTimeout, + ...($auth ? ['auth' => $auth] : []), + ]; + + $sentinel = $this->container->get(RedisSentinelFactory::class)->create($options); + $masterInfo = $sentinel->getMasterAddrByName($masterName); + if (is_array($masterInfo) && count($masterInfo) >= 2) { + [$host, $port] = $masterInfo; + break; + } + } catch (Throwable $exception) { + $this->log('Redis sentinel connection failed, caused by ' . $exception->getMessage()); + continue; + } + } + + if ($host === null && $port === null) { + throw new InvalidRedisConnectionException('Connect sentinel redis server failed.'); + } + + $redis = $this->createRedis([ + 'host' => $host, + 'port' => $port, + 'timeout' => $timeout, + 'retry_interval' => $retryInterval, + 'read_timeout' => $readTimeout, + ]); + } catch (Throwable $exception) { + throw new ConnectionException('Connection reconnect failed ' . $exception->getMessage()); + } + + return $redis; + } + + /** + * Create a redis connection. + * + * @param array $config + * @throws ConnectionException + * @throws RedisException + */ + protected function createRedis(array $config): Redis + { + $parameters = [ + $config['host'], + (int) $config['port'], + $config['timeout'] ?? 0.0, + $config['reserved'] ?? null, + $config['retry_interval'] ?? 0, + $config['read_timeout'] ?? 0.0, + ]; + + if (! empty($config['context'])) { + $parameters[] = $config['context']; + } + + $redis = new Redis(); + if (! $redis->connect(...$parameters)) { + throw new ConnectionException('Connection reconnect failed.'); + } + + return $redis; + } + + /** + * Log a redis connection message. + */ + protected function log(string $message, string $level = LogLevel::WARNING): void + { + if ($this->container->has(StdoutLoggerInterface::class) && $logger = $this->container->get(StdoutLoggerInterface::class)) { + $logger->log($level, $message); + } } /** @@ -466,13 +814,18 @@ protected function callBrpop(mixed ...$arguments): ?array } /** - * Removes and returns a random element from the set value at key. + * Removes and returns random elements from the set value at key. * - * @return false|mixed + * When called without count, returns a single element (string|false). + * When called with count, returns an array of elements. */ - protected function callSpop(string $key, ?int $count = 1): mixed + protected function callSpop(string $key, ?int $count = null): mixed { - return $this->connection->sPop($key, $count); + if ($count !== null) { + return $this->connection->sPop($key, $count); + } + + return $this->connection->sPop($key); } /** @@ -579,7 +932,7 @@ protected function getScanOptions(array $arguments): array public function scan(&$cursor, ...$arguments): mixed { if (! $this->shouldTransform) { - return parent::scan($cursor, ...$arguments); + return $this->__call('scan', array_merge([&$cursor], $arguments)); } $options = $this->getScanOptions($arguments); @@ -607,7 +960,7 @@ public function scan(&$cursor, ...$arguments): mixed public function zscan($key, &$cursor, ...$arguments): mixed { if (! $this->shouldTransform) { - return parent::zScan($key, $cursor, ...$arguments); + return $this->__call('zScan', array_merge([$key, &$cursor], $arguments)); } $options = $this->getScanOptions($arguments); @@ -636,7 +989,7 @@ public function zscan($key, &$cursor, ...$arguments): mixed public function hscan($key, &$cursor, ...$arguments): mixed { if (! $this->shouldTransform) { - return parent::hScan($key, $cursor, ...$arguments); + return $this->__call('hScan', array_merge([$key, &$cursor], $arguments)); } $options = $this->getScanOptions($arguments); @@ -665,7 +1018,7 @@ public function hscan($key, &$cursor, ...$arguments): mixed public function sscan($key, &$cursor, ...$arguments): mixed { if (! $this->shouldTransform) { - return parent::sScan($key, $cursor, ...$arguments); + return $this->__call('sScan', array_merge([$key, &$cursor], $arguments)); } $options = $this->getScanOptions($arguments); @@ -724,6 +1077,17 @@ protected function callExecuteRaw(array $parameters): mixed return $this->connection->rawCommand(...$parameters); } + /** + * Execute a subscribe or psubscribe command. + * + * WARNING: phpredis subscribe/psubscribe blocks the calling coroutine for + * the lifetime of the subscription. This holds a connection pool slot until + * the subscription ends. Always run in a dedicated coroutine and be mindful + * of pool size when using multiple subscribers. + * + * @TODO Explore non-blocking alternatives such as Swoole\Coroutine\Redis + * or a channel-based subscriber that doesn't hold a pool connection. + */ protected function callSubscribe(string $name, array $arguments): mixed { $timeout = $this->connection->getOption(Redis::OPT_READ_TIMEOUT); @@ -742,6 +1106,13 @@ protected function callSubscribe(string $name, array $arguments): mixed } } + /** + * Build the arguments for a subscribe or psubscribe call. + * + * Wraps the user callback to reorder phpredis's callback arguments + * from ($redis, $channel, $message) to Laravel's ($message, $channel). + * For psubscribe, phpredis sends ($redis, $pattern, $channel, $message). + */ protected function getSubscribeArguments(string $name, array $arguments): array { $channels = Arr::wrap($arguments[0]); @@ -756,7 +1127,7 @@ protected function getSubscribeArguments(string $name, array $arguments): array return [ $channels, - $callback = fn ($redis, $pattern, $channel, $message) => $callback($message, $channel), + fn ($redis, $pattern, $channel, $message) => $callback($message, $channel), ]; } diff --git a/src/redis/src/RedisFactory.php b/src/redis/src/RedisFactory.php index d9b7e7e7f..2a9d6a27a 100644 --- a/src/redis/src/RedisFactory.php +++ b/src/redis/src/RedisFactory.php @@ -4,10 +4,8 @@ namespace Hypervel\Redis; -use Hyperf\Contract\ConfigInterface; -use Hyperf\Redis\Exception\InvalidRedisProxyException; - -use function Hyperf\Support\make; +use Hypervel\Contracts\Container\Container as ContainerContract; +use Hypervel\Redis\Exceptions\InvalidRedisProxyException; class RedisFactory { @@ -16,15 +14,19 @@ class RedisFactory */ protected array $proxies = []; - public function __construct(ConfigInterface $config) + /** + * Create a new Redis factory instance. + */ + public function __construct(RedisConfig $config, ContainerContract $container) { - $redisConfig = $config->get('redis'); - - foreach ($redisConfig as $poolName => $item) { - $this->proxies[$poolName] = make(RedisProxy::class, ['pool' => $poolName]); + foreach ($config->connectionNames() as $poolName) { + $this->proxies[$poolName] = $container->make(RedisProxy::class, ['pool' => $poolName]); } } + /** + * Get a Redis proxy by pool name. + */ public function get(string $poolName): RedisProxy { $proxy = $this->proxies[$poolName] ?? null; diff --git a/src/redis/src/RedisPool.php b/src/redis/src/RedisPool.php deleted file mode 100644 index ea8c803f4..000000000 --- a/src/redis/src/RedisPool.php +++ /dev/null @@ -1,16 +0,0 @@ -container, $this, $this->config); - } -} diff --git a/src/redis/src/RedisProxy.php b/src/redis/src/RedisProxy.php index 3e70d0c6b..9bbf532b9 100644 --- a/src/redis/src/RedisProxy.php +++ b/src/redis/src/RedisProxy.php @@ -4,10 +4,13 @@ namespace Hypervel\Redis; -use Hyperf\Redis\Pool\PoolFactory; +use Hypervel\Redis\Pool\PoolFactory; class RedisProxy extends Redis { + /** + * Create a new Redis proxy instance. + */ public function __construct(PoolFactory $factory, string $pool) { parent::__construct($factory); diff --git a/src/redis/src/RedisSentinelFactory.php b/src/redis/src/RedisSentinelFactory.php new file mode 100644 index 000000000..47b929090 --- /dev/null +++ b/src/redis/src/RedisSentinelFactory.php @@ -0,0 +1,43 @@ +isOlderThan6 = version_compare(phpversion('redis'), '6.0.0', '<'); + } + + /** + * Create a redis sentinel client instance. + * + * @param array $options + */ + public function create(array $options = []): RedisSentinel + { + if ($this->isOlderThan6) { + return new RedisSentinel( + $options['host'], + (int) $options['port'], + (float) $options['connectTimeout'], + $options['persistent'], + (int) $options['retryInterval'], + (float) $options['readTimeout'], + ...(isset($options['auth']) ? [$options['auth']] : []), + ); + } + + // https://github.com/phpredis/phpredis/blob/develop/sentinel.md#examples-for-version-60-or-later + return new RedisSentinel($options); /* @phpstan-ignore-line */ + } +} diff --git a/src/redis/src/Subscriber/CommandBuilder.php b/src/redis/src/Subscriber/CommandBuilder.php new file mode 100644 index 000000000..b387ab31e --- /dev/null +++ b/src/redis/src/Subscriber/CommandBuilder.php @@ -0,0 +1,38 @@ + '$-1' . Constants::CRLF, + is_int($args) => ':' . $args . Constants::CRLF, + is_string($args) => '$' . strlen($args) . Constants::CRLF . $args . Constants::CRLF, + is_array($args) => (function (array $args) { + $result = '*' . count($args) . Constants::CRLF; + foreach ($args as $arg) { + $result .= static::build($arg); + } + return $result; + })($args), + }; + } +} diff --git a/src/redis/src/Subscriber/CommandInvoker.php b/src/redis/src/Subscriber/CommandInvoker.php new file mode 100644 index 000000000..c5e7934f1 --- /dev/null +++ b/src/redis/src/Subscriber/CommandInvoker.php @@ -0,0 +1,184 @@ +resultChannel = new Channel(); + $this->pingChannel = new Channel(); + $this->messageChannel = new Channel(100); + $this->timer = new Timer(); + $this->loop(); + $this->watchForShutdown(); + } + + public function invoke(int|string|array|null $command, int $number): array + { + try { + $this->connection->send(CommandBuilder::build($command)); + } catch (Throwable $e) { + $this->interrupt(); + throw $e; + } + + $result = []; + + for ($i = 0; $i < $number; ++$i) { + $result[] = $this->resultChannel->pop(); + } + + return $result; + } + + public function channel(): Channel + { + return $this->messageChannel; + } + + public function interrupt(): bool + { + $this->connection->close(); + $this->resultChannel->close(); + $this->messageChannel->close(); + + return true; + } + + public function ping(float $timeout = 1): string|bool + { + $this->connection->send(CommandBuilder::build('ping')); + return $this->pingChannel->pop($timeout); + } + + /** + * @throws SocketException + */ + protected function receive(Connection $connection): void + { + /** @var null|array $buffer */ + $buffer = null; + + while (true) { + $line = $connection->recv(); + + if ($line === false || $line === '') { + $this->interrupt(); + break; + } + + $line = substr($line, 0, -strlen(Constants::CRLF)); + + if ($line === '+OK') { + $this->resultChannel->push($line); + continue; + } + + if ($line === '*3') { + if (! empty($buffer)) { + $this->resultChannel->push($buffer); + $buffer = null; + } + $buffer[] = $line; + continue; + } + + $buffer[] = $line; + $type = $buffer[2] ?? false; + + if ($type === 'subscribe' && count($buffer) === 6) { + $this->resultChannel->push($buffer); + $buffer = null; + continue; + } + + if ($type === 'unsubscribe' && count($buffer) === 6) { + $this->resultChannel->push($buffer); + $buffer = null; + continue; + } + + if ($type === 'message' && count($buffer) === 7) { + $message = new Message(channel: $buffer[4], payload: $buffer[6]); + $timerID = $this->timer->after(30.0, function () use ($message) { + $this->logger?->error(sprintf('Message channel (%s) is 30 seconds full, disconnected', $message->channel)); + $this->interrupt(); + }); + $this->messageChannel->push($message); + $this->timer->clear($timerID); + $buffer = null; + continue; + } + + if ($type === 'psubscribe' && count($buffer) === 6) { + $this->resultChannel->push($buffer); + $buffer = null; + continue; + } + + if ($type === 'punsubscribe' && count($buffer) === 6) { + $this->resultChannel->push($buffer); + $buffer = null; + continue; + } + + if ($type === 'pmessage' && count($buffer) === 9) { + $message = new Message(pattern: $buffer[4], channel: $buffer[6], payload: $buffer[8]); + $timerID = $this->timer->after(30.0, function () use ($message) { + $this->logger?->error(sprintf('Message channel (%s) is 30 seconds full, disconnected', $message->channel)); + $this->interrupt(); + }); + $this->messageChannel->push($message); + $this->timer->clear($timerID); + $buffer = null; + continue; + } + + if ($type === 'pong' && count($buffer) === 5) { + $this->pingChannel->push('pong'); + $buffer = null; + continue; + } + } + } + + /** + * Watch for worker shutdown and interrupt the connection. + * + * Without this, the receive loop's socket recv blocks indefinitely + * and Swoole's coroutine scheduler cannot detect the deadlock (active + * I/O keeps the event loop alive). This provides a deterministic + * shutdown path that doesn't depend on coroutine scheduling order. + */ + protected function watchForShutdown(): void + { + $this->timer->until(function () { + $this->interrupt(); + }); + } + + protected function loop(): void + { + Coroutine::create(function () { + $this->receive($this->connection); + }); + } +} diff --git a/src/redis/src/Subscriber/Connection.php b/src/redis/src/Subscriber/Connection.php new file mode 100644 index 000000000..2710578c5 --- /dev/null +++ b/src/redis/src/Subscriber/Connection.php @@ -0,0 +1,69 @@ + true, + 'package_eof' => Constants::EOF, + ]); + $factory ??= new SocketFactory(); + $this->socket = $factory->make($options); + } + + public function send(string $data): bool + { + $len = strlen($data); + $size = $this->socket->sendAll($data); + + if ($size === false) { + throw new SocketException('Failed to send data to the socket.'); + } + + if ($len !== $size) { + throw new SocketException('The sending data is incomplete, it may be that the socket has been closed by the peer.'); + } + + return true; + } + + /** + * @param float $timeout the timeout parameter is used to set the timeout rules for the recv method + * @see https://wiki.swoole.com/en/#/coroutine_client/init?id=timeout-rules + * -1: indicates no timeout + * 0: indicates no change in timeout + * > 0: represents setting a timeout timer for the corresponding number of seconds, with a maximum precision of 1 millisecond, which is a floating-point number; 0.5 represents 500 milliseconds + */ + public function recv(float $timeout = -1): string|bool + { + return $this->socket->recvPacket($timeout); + } + + public function close(): void + { + if (! $this->closed && ! $this->socket->close()) { + throw new SocketException('Failed to close the socket.'); + } + + $this->closed = true; + } +} diff --git a/src/redis/src/Subscriber/Constants.php b/src/redis/src/Subscriber/Constants.php new file mode 100644 index 000000000..c9ebd7a3d --- /dev/null +++ b/src/redis/src/Subscriber/Constants.php @@ -0,0 +1,12 @@ +connect(); + } + + /** + * @throws SocketException + * @throws Throwable + * @throws SubscribeException + */ + public function subscribe(string ...$channels): void + { + $channels = array_map(fn ($channel) => $this->prefix . $channel, $channels); + $result = $this->commandInvoker->invoke(['subscribe', ...$channels], count($channels)); + + foreach ($result as $value) { + if ($value === false) { + $this->commandInvoker->interrupt(); + throw new SubscribeException('Subscribe failed'); + } + } + } + + /** + * @throws SocketException + * @throws Throwable + * @throws UnsubscribeException + */ + public function unsubscribe(string ...$channels): void + { + $channels = array_map(fn ($channel) => $this->prefix . $channel, $channels); + $result = $this->commandInvoker->invoke(['unsubscribe', ...$channels], count($channels)); + + foreach ($result as $value) { + if ($value === false) { + $this->commandInvoker->interrupt(); + throw new UnsubscribeException('Unsubscribe failed'); + } + } + } + + /** + * @throws SocketException + * @throws Throwable + * @throws SubscribeException + */ + public function psubscribe(string ...$channels): void + { + $channels = array_map(fn ($channel) => $this->prefix . $channel, $channels); + $result = $this->commandInvoker->invoke(['psubscribe', ...$channels], count($channels)); + + foreach ($result as $value) { + if ($value === false) { + $this->commandInvoker->interrupt(); + throw new SubscribeException('Psubscribe failed'); + } + } + } + + /** + * @throws SocketException + * @throws Throwable + * @throws UnsubscribeException + */ + public function punsubscribe(string ...$channels): void + { + $channels = array_map(fn ($channel) => $this->prefix . $channel, $channels); + $result = $this->commandInvoker->invoke(['punsubscribe', ...$channels], count($channels)); + + foreach ($result as $value) { + if ($value === false) { + $this->commandInvoker->interrupt(); + throw new UnsubscribeException('Punsubscribe failed'); + } + } + } + + public function channel(): Channel + { + return $this->commandInvoker->channel(); + } + + /** + * @throws SocketException + */ + public function close(): void + { + $this->closed = true; + $this->commandInvoker->interrupt(); + } + + /** + * @throws SocketException + */ + public function ping(float $timeout = 1): string|bool + { + return $this->commandInvoker->ping($timeout); + } + + /** + * @throws SocketException + */ + protected function connect(): void + { + $connection = new Connection($this->host, $this->port, $this->timeout); + $this->commandInvoker = new CommandInvoker($connection, $this->logger); + + if ($this->password !== '') { + $this->commandInvoker->invoke(['auth', $this->password], 1); + } + } +} diff --git a/src/redis/src/Traits/MultiExec.php b/src/redis/src/Traits/MultiExec.php index 2ba816cb4..9e467add9 100644 --- a/src/redis/src/Traits/MultiExec.php +++ b/src/redis/src/Traits/MultiExec.php @@ -9,11 +9,8 @@ use Redis; use RedisCluster; -use function Hyperf\Tappable\tap; - /** * Coroutine multi-exec trait. - * @see Hyperf\Redis\Traits\MultiExec */ trait MultiExec { diff --git a/src/redis/src/Traits/ScanCaller.php b/src/redis/src/Traits/ScanCaller.php new file mode 100644 index 000000000..5d564985f --- /dev/null +++ b/src/redis/src/Traits/ScanCaller.php @@ -0,0 +1,47 @@ +__call('scan', array_merge([&$cursor], $arguments)); + } + + /** + * Scan hash fields. + * @param mixed $key + * @param mixed $cursor + */ + public function hScan($key, &$cursor, ...$arguments) + { + return $this->__call('hScan', array_merge([$key, &$cursor], $arguments)); + } + + /** + * Scan sorted set members. + * @param mixed $key + * @param mixed $cursor + */ + public function zScan($key, &$cursor, ...$arguments) + { + return $this->__call('zScan', array_merge([$key, &$cursor], $arguments)); + } + + /** + * Scan set members. + * @param mixed $key + * @param mixed $cursor + */ + public function sScan($key, &$cursor, ...$arguments) + { + return $this->__call('sScan', array_merge([$key, &$cursor], $arguments)); + } +} 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..aca170696 --- /dev/null +++ b/src/reflection/composer.json @@ -0,0 +1,45 @@ +{ + "name": "hypervel/reflection", + "type": "library", + "description": "The Hypervel Reflection package.", + "license": "MIT", + "keywords": [ + "php", + "reflection", + "hypervel" + ], + "authors": [ + { + "name": "Albert Chen", + "email": "albert@hypervel.org" + }, + { + "name": "Raj Siva-Rajah", + "homepage": "https://github.com/binaryfire" + } + ], + "support": { + "issues": "https://github.com/hypervel/components/issues", + "source": "https://github.com/hypervel/components" + }, + "autoload": { + "psr-4": { + "Hypervel\\Support\\": "src/" + }, + "files": [ + "src/helpers.php" + ] + }, + "require": { + "php": "^8.4", + "hypervel/support": "self.version" + }, + "config": { + "sort-packages": true + }, + "extra": { + "branch-alias": { + "dev-main": "0.4-dev" + } + } +} diff --git a/src/reflection/src/ClassInvoker.php b/src/reflection/src/ClassInvoker.php new file mode 100644 index 000000000..4a613553d --- /dev/null +++ b/src/reflection/src/ClassInvoker.php @@ -0,0 +1,41 @@ +reflection = new ReflectionClass($instance); + } + + /** + * Get a property value from the wrapped instance. + */ + public function __get(string $name): mixed + { + $property = $this->reflection->getProperty($name); + + return $property->getValue($this->instance); + } + + /** + * Call a method on the wrapped instance. + */ + public function __call(string $name, array $arguments): mixed + { + $method = $this->reflection->getMethod($name); + + return $method->invokeArgs($this->instance, $arguments); + } +} 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..9cf3e8e51 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..0e51c5927 --- /dev/null +++ b/src/reflection/src/helpers.php @@ -0,0 +1,95 @@ +|(Closure(TValue): mixed) $class + * @param (Closure(TValue): mixed)|int $callback + * @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 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/composer.json b/src/router/composer.json index 2960be0f0..355654894 100644 --- a/src/router/composer.json +++ b/src/router/composer.json @@ -29,8 +29,8 @@ ] }, "require": { - "php": "^8.2", - "hyperf/context": "~3.1.0", + "php": "^8.4", + "hypervel/context": "^0.4", "hyperf/http-server": "~3.1.0", "nikic/fast-route": "^1.3.0" }, @@ -48,7 +48,7 @@ "config": "Hypervel\\Router\\ConfigProvider" }, "branch-alias": { - "dev-main": "0.3-dev" + "dev-main": "0.4-dev" } } } 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..e43ce6d76 100644 --- a/src/router/src/Functions.php +++ b/src/router/src/Functions.php @@ -4,8 +4,8 @@ namespace Hypervel\Router; -use Hyperf\Context\ApplicationContext; -use Hypervel\Router\Contracts\UrlGenerator as UrlGeneratorContract; +use Hypervel\Context\ApplicationContext; +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..7748ac796 100644 --- a/src/router/src/Middleware/ThrottleRequests.php +++ b/src/router/src/Middleware/ThrottleRequests.php @@ -5,15 +5,15 @@ 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 Hypervel\Support\InteractsWithTime; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\MiddlewareInterface; 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..106bfae12 100644 --- a/src/router/src/Router.php +++ b/src/router/src/Router.php @@ -5,12 +5,12 @@ namespace Hypervel\Router; 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\Context\ApplicationContext; +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..4bf10fd2c 100644 --- a/src/router/src/UrlGenerator.php +++ b/src/router/src/UrlGenerator.php @@ -9,20 +9,19 @@ use Closure; use DateInterval; use DateTimeInterface; -use Hyperf\Collection\Arr; -use Hyperf\Context\Context; -use Hyperf\Context\RequestContext; -use Hyperf\Contract\ConfigInterface; use Hyperf\Contract\ContainerInterface; use Hyperf\Contract\SessionInterface; 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\Context\Context; +use Hypervel\Context\RequestContext; +use Hypervel\Contracts\Router\UrlGenerator as UrlGeneratorContract; +use Hypervel\Contracts\Router\UrlRoutable; +use Hypervel\Support\Arr; +use Hypervel\Support\InteractsWithTime; +use Hypervel\Support\Str; +use Hypervel\Support\Traits\Macroable; use InvalidArgumentException; class UrlGenerator implements UrlGeneratorContract @@ -480,7 +479,7 @@ protected function getSignedKey(): string return $this->signedKey; } - return $this->container->get(ConfigInterface::class) + return $this->container->get('config') ->get('app.key'); } @@ -514,6 +513,6 @@ protected function getRequestUri(): Uri return $this->container->get(RequestInterface::class)->getUri(); } - return new Uri($this->container->get(ConfigInterface::class)->get('app.url')); + return new Uri($this->container->get('config')->get('app.url')); } } diff --git a/src/sanctum/composer.json b/src/sanctum/composer.json index e2d6f5ced..0a2dd4c07 100644 --- a/src/sanctum/composer.json +++ b/src/sanctum/composer.json @@ -16,15 +16,19 @@ { "name": "Albert Chen", "email": "albert@hypervel.org" + }, + { + "name": "Raj Siva-Rajah", + "homepage": "https://github.com/binaryfire" } ], "require": { - "php": "^8.2", - "hyperf/database": "~3.1.0", + "php": "^8.4", + "hypervel/database": "^0.4", "hyperf/http-server": "~3.1.0", - "hypervel/auth": "^0.3", - "hypervel/console": "^0.3", - "hypervel/support": "^0.3", + "hypervel/auth": "^0.4", + "hypervel/console": "^0.4", + "hypervel/support": "^0.4", "nesbot/carbon": "^2.72.6" }, "autoload": { @@ -34,7 +38,7 @@ }, "extra": { "branch-alias": { - "dev-main": "0.3-dev" + "dev-main": "0.4-dev" }, "hypervel": { "providers": [ 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..77fbea5c4 100644 --- a/src/sanctum/src/SanctumGuard.php +++ b/src/sanctum/src/SanctumGuard.php @@ -4,18 +4,18 @@ namespace Hypervel\Sanctum; -use Hyperf\Context\ApplicationContext; -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\Context\ApplicationContext; +use Hypervel\Context\Context; +use Hypervel\Context\RequestContext; +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/sanctum/src/SanctumServiceProvider.php b/src/sanctum/src/SanctumServiceProvider.php index 5352d663b..2bf2371a7 100644 --- a/src/sanctum/src/SanctumServiceProvider.php +++ b/src/sanctum/src/SanctumServiceProvider.php @@ -4,7 +4,6 @@ namespace Hypervel\Sanctum; -use Hyperf\Contract\ConfigInterface; use Hyperf\HttpServer\Contract\RequestInterface; use Hypervel\Auth\AuthManager; use Hypervel\Sanctum\Console\Commands\PruneExpired; @@ -57,7 +56,7 @@ protected function registerSanctumGuard(): void } // Get expiration from sanctum config - $expiration = $this->app->get(ConfigInterface::class)->get('sanctum.expiration'); + $expiration = $this->app->get('config')->get('sanctum.expiration'); return new SanctumGuard( name: $name, diff --git a/src/scout/composer.json b/src/scout/composer.json index 6278898cb..1185242d7 100644 --- a/src/scout/composer.json +++ b/src/scout/composer.json @@ -17,6 +17,10 @@ { "name": "Albert Chen", "email": "albert@hypervel.org" + }, + { + "name": "Raj Siva-Rajah", + "homepage": "https://github.com/binaryfire" } ], "support": { @@ -29,14 +33,14 @@ } }, "require": { - "php": "^8.2", - "hypervel/config": "^0.3", - "hypervel/console": "^0.3", - "hypervel/core": "^0.3", - "hypervel/coroutine": "^0.3", - "hypervel/event": "^0.3", - "hypervel/queue": "^0.3", - "hypervel/support": "^0.3" + "php": "^8.4", + "hypervel/config": "^0.4", + "hypervel/console": "^0.4", + "hypervel/core": "^0.4", + "hypervel/coroutine": "^0.4", + "hypervel/event": "^0.4", + "hypervel/queue": "^0.4", + "hypervel/support": "^0.4" }, "suggest": { "meilisearch/meilisearch-php": "Required for Meilisearch driver (^1.0)", @@ -47,7 +51,7 @@ }, "extra": { "branch-alias": { - "dev-main": "0.3-dev" + "dev-main": "0.4-dev" }, "hypervel": { "providers": [ diff --git a/src/scout/config/scout.php b/src/scout/config/scout.php index 12bbcce41..c4fa21571 100644 --- a/src/scout/config/scout.php +++ b/src/scout/config/scout.php @@ -57,7 +57,7 @@ 'enabled' => env('SCOUT_QUEUE', false), 'connection' => env('SCOUT_QUEUE_CONNECTION'), 'queue' => env('SCOUT_QUEUE_NAME'), - 'after_commit' => env('SCOUT_AFTER_COMMIT', false), + 'after_commit' => env('SCOUT_QUEUE_AFTER_COMMIT', false), ], /* @@ -142,18 +142,27 @@ 'typesense' => [ 'client-settings' => [ - 'api_key' => env('TYPESENSE_API_KEY', ''), + 'api_key' => env('TYPESENSE_API_KEY', 'xyz'), 'nodes' => [ [ 'host' => env('TYPESENSE_HOST', 'localhost'), 'port' => env('TYPESENSE_PORT', '8108'), + 'path' => env('TYPESENSE_PATH', ''), 'protocol' => env('TYPESENSE_PROTOCOL', 'http'), ], ], - 'connection_timeout_seconds' => 2, + 'nearest_node' => [ + 'host' => env('TYPESENSE_HOST', 'localhost'), + 'port' => env('TYPESENSE_PORT', '8108'), + 'path' => env('TYPESENSE_PATH', ''), + 'protocol' => env('TYPESENSE_PROTOCOL', 'http'), + ], + 'connection_timeout_seconds' => env('TYPESENSE_CONNECTION_TIMEOUT_SECONDS', 2), + 'healthcheck_interval_seconds' => env('TYPESENSE_HEALTHCHECK_INTERVAL_SECONDS', 30), + 'num_retries' => env('TYPESENSE_NUM_RETRIES', 3), + 'retry_interval_seconds' => env('TYPESENSE_RETRY_INTERVAL_SECONDS', 1), ], - 'max_total_results' => env('TYPESENSE_MAX_TOTAL_RESULTS', 1000), - 'import_action' => 'upsert', + // 'max_total_results' => env('TYPESENSE_MAX_TOTAL_RESULTS', 1000), 'model-settings' => [ // Per-model settings can be defined here: // App\Models\User::class => [ @@ -170,5 +179,6 @@ // ], // ], ], + 'import_action' => env('TYPESENSE_IMPORT_ACTION', 'upsert'), ], ]; diff --git a/src/scout/src/Builder.php b/src/scout/src/Builder.php index e6724f7eb..0be527fae 100644 --- a/src/scout/src/Builder.php +++ b/src/scout/src/Builder.php @@ -5,14 +5,14 @@ namespace Hypervel\Scout; use Closure; -use Hyperf\Contract\Arrayable; -use Hyperf\Contract\LengthAwarePaginatorInterface; -use Hyperf\Contract\PaginatorInterface; -use Hyperf\Database\Connection; -use Hyperf\Paginator\LengthAwarePaginator; -use Hyperf\Paginator\Paginator; +use Hypervel\Contracts\Pagination\LengthAwarePaginator as LengthAwarePaginatorContract; +use Hypervel\Contracts\Pagination\Paginator as PaginatorContract; +use Hypervel\Contracts\Support\Arrayable; +use Hypervel\Database\Connection; use Hypervel\Database\Eloquent\Collection as EloquentCollection; use Hypervel\Database\Eloquent\Model; +use Hypervel\Pagination\LengthAwarePaginator; +use Hypervel\Pagination\Paginator; use Hypervel\Scout\Contracts\PaginatesEloquentModels; use Hypervel\Scout\Contracts\PaginatesEloquentModelsUsingDatabase; use Hypervel\Scout\Contracts\SearchableInterface; @@ -22,8 +22,6 @@ use Hypervel\Support\Traits\Macroable; use Hypervel\Support\Traits\Tappable; -use function Hyperf\Tappable\tap; - /** * Fluent search query builder for searchable models. * @@ -361,7 +359,7 @@ public function simplePaginate( ?int $perPage = null, string $pageName = 'page', ?int $page = null - ): PaginatorInterface { + ): PaginatorContract { $engine = $this->engine(); $page = $page ?? Paginator::resolveCurrentPage($pageName); @@ -384,7 +382,7 @@ public function simplePaginate( )->all(); $results = $this->model->newCollection($mappedModels); - return (new Paginator($results, $perPage, $page, [ + return (new Paginator($results, $perPage, $page, [/* @phpstan-ignore-line */ 'path' => Paginator::resolveCurrentPath(), 'pageName' => $pageName, ]))->hasMorePagesWhen( @@ -399,7 +397,7 @@ public function paginate( ?int $perPage = null, string $pageName = 'page', ?int $page = null - ): LengthAwarePaginatorInterface { + ): LengthAwarePaginatorContract { $engine = $this->engine(); $page = $page ?? Paginator::resolveCurrentPage($pageName); @@ -423,7 +421,7 @@ public function paginate( $results = $this->model->newCollection($mappedModels); return (new LengthAwarePaginator( - $results, + $results, /* @phpstan-ignore-line */ $this->getTotalCount($rawResults), $perPage, $page, diff --git a/src/scout/src/Console/DeleteIndexCommand.php b/src/scout/src/Console/DeleteIndexCommand.php index 76d99518f..8091827a5 100644 --- a/src/scout/src/Console/DeleteIndexCommand.php +++ b/src/scout/src/Console/DeleteIndexCommand.php @@ -4,7 +4,7 @@ namespace Hypervel\Scout\Console; -use Hyperf\Contract\ConfigInterface; +use Hypervel\Config\Repository; use Hypervel\Console\Command; use Hypervel\Scout\EngineManager; use Hypervel\Support\Str; @@ -28,7 +28,7 @@ class DeleteIndexCommand extends Command /** * Execute the console command. */ - public function handle(EngineManager $manager, ConfigInterface $config): int + public function handle(EngineManager $manager, Repository $config): int { $name = $this->indexName((string) $this->argument('name'), $config); @@ -42,7 +42,7 @@ public function handle(EngineManager $manager, ConfigInterface $config): int /** * Get the fully-qualified index name for the given index. */ - protected function indexName(string $name, ConfigInterface $config): string + protected function indexName(string $name, Repository $config): string { if (class_exists($name)) { return (new $name())->indexableAs(); diff --git a/src/scout/src/Console/ImportCommand.php b/src/scout/src/Console/ImportCommand.php index b64469cb6..741b0df98 100644 --- a/src/scout/src/Console/ImportCommand.php +++ b/src/scout/src/Console/ImportCommand.php @@ -5,7 +5,7 @@ namespace Hypervel\Scout\Console; use Hypervel\Console\Command; -use Hypervel\Event\Contracts\Dispatcher; +use Hypervel\Contracts\Event\Dispatcher; use Hypervel\Scout\Events\ModelsImported; use Hypervel\Scout\Exceptions\ScoutException; diff --git a/src/scout/src/Console/IndexCommand.php b/src/scout/src/Console/IndexCommand.php index 696c49cd3..e50cae222 100644 --- a/src/scout/src/Console/IndexCommand.php +++ b/src/scout/src/Console/IndexCommand.php @@ -4,7 +4,7 @@ namespace Hypervel\Scout\Console; -use Hyperf\Contract\ConfigInterface; +use Hypervel\Config\Repository; use Hypervel\Console\Command; use Hypervel\Database\Eloquent\SoftDeletes; use Hypervel\Scout\Contracts\UpdatesIndexSettings; @@ -32,7 +32,7 @@ class IndexCommand extends Command /** * Execute the console command. */ - public function handle(EngineManager $manager, ConfigInterface $config): int + public function handle(EngineManager $manager, Repository $config): int { $engine = $manager->engine(); @@ -91,7 +91,7 @@ protected function createIndex(Engine $engine, string $name, array $options): vo /** * Get the fully-qualified index name for the given index. */ - protected function indexName(string $name, ConfigInterface $config): string + protected function indexName(string $name, Repository $config): string { if (class_exists($name)) { return (new $name())->indexableAs(); diff --git a/src/scout/src/Console/SyncIndexSettingsCommand.php b/src/scout/src/Console/SyncIndexSettingsCommand.php index e55186db2..c419e0841 100644 --- a/src/scout/src/Console/SyncIndexSettingsCommand.php +++ b/src/scout/src/Console/SyncIndexSettingsCommand.php @@ -4,7 +4,7 @@ namespace Hypervel\Scout\Console; -use Hyperf\Contract\ConfigInterface; +use Hypervel\Config\Repository; use Hypervel\Console\Command; use Hypervel\Database\Eloquent\SoftDeletes; use Hypervel\Scout\Contracts\UpdatesIndexSettings; @@ -30,7 +30,7 @@ class SyncIndexSettingsCommand extends Command /** * Execute the console command. */ - public function handle(EngineManager $manager, ConfigInterface $config): int + public function handle(EngineManager $manager, Repository $config): int { $driver = $this->option('driver') ?: $config->get('scout.driver'); @@ -79,7 +79,7 @@ public function handle(EngineManager $manager, ConfigInterface $config): int /** * Get the fully-qualified index name for the given index. */ - protected function indexName(string $name, ConfigInterface $config): string + protected function indexName(string $name, Repository $config): string { if (class_exists($name)) { return (new $name())->indexableAs(); diff --git a/src/scout/src/Contracts/PaginatesEloquentModels.php b/src/scout/src/Contracts/PaginatesEloquentModels.php index 979e74685..7eaa8c1e1 100644 --- a/src/scout/src/Contracts/PaginatesEloquentModels.php +++ b/src/scout/src/Contracts/PaginatesEloquentModels.php @@ -4,8 +4,8 @@ namespace Hypervel\Scout\Contracts; -use Hyperf\Contract\LengthAwarePaginatorInterface; -use Hyperf\Contract\PaginatorInterface; +use Hypervel\Contracts\Pagination\LengthAwarePaginator; +use Hypervel\Contracts\Pagination\Paginator; use Hypervel\Scout\Builder; /** @@ -20,10 +20,10 @@ interface PaginatesEloquentModels /** * Paginate the given search on the engine. */ - public function paginate(Builder $builder, int $perPage, int $page): LengthAwarePaginatorInterface; + public function paginate(Builder $builder, int $perPage, int $page): LengthAwarePaginator; /** * Paginate the given search on the engine using simple pagination. */ - public function simplePaginate(Builder $builder, int $perPage, int $page): PaginatorInterface; + public function simplePaginate(Builder $builder, int $perPage, int $page): Paginator; } diff --git a/src/scout/src/Contracts/PaginatesEloquentModelsUsingDatabase.php b/src/scout/src/Contracts/PaginatesEloquentModelsUsingDatabase.php index 82c0359dc..da6f57a5b 100644 --- a/src/scout/src/Contracts/PaginatesEloquentModelsUsingDatabase.php +++ b/src/scout/src/Contracts/PaginatesEloquentModelsUsingDatabase.php @@ -4,8 +4,8 @@ namespace Hypervel\Scout\Contracts; -use Hyperf\Contract\LengthAwarePaginatorInterface; -use Hyperf\Contract\PaginatorInterface; +use Hypervel\Contracts\Pagination\LengthAwarePaginator; +use Hypervel\Contracts\Pagination\Paginator; use Hypervel\Scout\Builder; /** @@ -25,7 +25,7 @@ public function paginateUsingDatabase( int $perPage, string $pageName, int $page - ): LengthAwarePaginatorInterface; + ): LengthAwarePaginator; /** * Paginate the given search on the engine using simple database pagination. @@ -35,5 +35,5 @@ public function simplePaginateUsingDatabase( int $perPage, string $pageName, int $page - ): PaginatorInterface; + ): Paginator; } diff --git a/src/scout/src/EngineManager.php b/src/scout/src/EngineManager.php index bc86501a6..28d109885 100644 --- a/src/scout/src/EngineManager.php +++ b/src/scout/src/EngineManager.php @@ -5,7 +5,6 @@ namespace Hypervel\Scout; use Closure; -use Hyperf\Contract\ConfigInterface; use Hypervel\Scout\Engines\CollectionEngine; use Hypervel\Scout\Engines\DatabaseEngine; use Hypervel\Scout\Engines\MeilisearchEngine; @@ -217,6 +216,6 @@ public function getDefaultDriver(): string */ protected function getConfig(string $key, mixed $default = null): mixed { - return $this->container->get(ConfigInterface::class)->get("scout.{$key}", $default); + return $this->container->get('config')->get("scout.{$key}", $default); } } diff --git a/src/scout/src/Engines/CollectionEngine.php b/src/scout/src/Engines/CollectionEngine.php index 671fda043..7571fb6ed 100644 --- a/src/scout/src/Engines/CollectionEngine.php +++ b/src/scout/src/Engines/CollectionEngine.php @@ -4,7 +4,6 @@ namespace Hypervel\Scout\Engines; -use Hyperf\Contract\ConfigInterface; use Hypervel\Context\ApplicationContext; use Hypervel\Database\Eloquent\Builder as EloquentBuilder; use Hypervel\Database\Eloquent\Collection as EloquentCollection; @@ -303,7 +302,7 @@ public function deleteIndex(string $name): mixed protected function getScoutConfig(string $key, mixed $default = null): mixed { return ApplicationContext::getContainer() - ->get(ConfigInterface::class) + ->get('config') ->get("scout.{$key}", $default); } } diff --git a/src/scout/src/Engines/DatabaseEngine.php b/src/scout/src/Engines/DatabaseEngine.php index 30755a0b9..624a8c27b 100644 --- a/src/scout/src/Engines/DatabaseEngine.php +++ b/src/scout/src/Engines/DatabaseEngine.php @@ -4,10 +4,9 @@ namespace Hypervel\Scout\Engines; -use Hyperf\Contract\ConfigInterface; -use Hyperf\Contract\LengthAwarePaginatorInterface; -use Hyperf\Contract\PaginatorInterface; use Hypervel\Context\ApplicationContext; +use Hypervel\Contracts\Pagination\LengthAwarePaginator as LengthAwarePaginatorContract; +use Hypervel\Contracts\Pagination\Paginator as PaginatorContract; use Hypervel\Database\Eloquent\Builder as EloquentBuilder; use Hypervel\Database\Eloquent\Collection as EloquentCollection; use Hypervel\Database\Eloquent\Model; @@ -72,7 +71,7 @@ public function paginateUsingDatabase( int $perPage, string $pageName, int $page - ): LengthAwarePaginatorInterface { + ): LengthAwarePaginatorContract { return $this->buildSearchQuery($builder) ->when(count($builder->orders) > 0, function (EloquentBuilder $query) use ($builder): void { foreach ($builder->orders as $order) { @@ -99,7 +98,7 @@ public function simplePaginateUsingDatabase( int $perPage, string $pageName, int $page - ): PaginatorInterface { + ): PaginatorContract { return $this->buildSearchQuery($builder) ->when(count($builder->orders) > 0, function (EloquentBuilder $query) use ($builder): void { foreach ($builder->orders as $order) { @@ -506,7 +505,7 @@ public function deleteIndex(string $name): mixed protected function getConfig(string $key, mixed $default = null): mixed { return ApplicationContext::getContainer() - ->get(ConfigInterface::class) + ->get('config') ->get("scout.{$key}", $default); } } diff --git a/src/scout/src/Engines/MeilisearchEngine.php b/src/scout/src/Engines/MeilisearchEngine.php index 7216ef27c..6be81337f 100644 --- a/src/scout/src/Engines/MeilisearchEngine.php +++ b/src/scout/src/Engines/MeilisearchEngine.php @@ -172,7 +172,7 @@ protected function filters(Builder $builder): string return is_numeric($value) ? sprintf('%s=%s', $key, $value) - : sprintf('%s="%s"', $key, $value); + : sprintf('%s="%s"', $key, addcslashes((string) $value, '"\\')); }); $whereInOperators = [ @@ -271,7 +271,7 @@ public function map(Builder $builder, mixed $results, Model $model): EloquentCol /** @var EloquentCollection $scoutModels */ $scoutModels = $model->getScoutModelsByIds($builder, $objectIds); - return $scoutModels + $mapped = $scoutModels ->filter(fn ($m) => in_array($m->getScoutKey(), $objectIds)) ->map(function ($m) use ($results, $objectIdPositions) { /** @var Model&SearchableInterface $m */ @@ -287,6 +287,8 @@ public function map(Builder $builder, mixed $results, Model $model): EloquentCol }) ->sortBy(fn ($m) => $objectIdPositions[$m->getScoutKey()]) ->values(); + + return $model->newCollection($mapped->all()); } /** diff --git a/src/scout/src/Engines/TypesenseEngine.php b/src/scout/src/Engines/TypesenseEngine.php index 6495e3cb3..5279c1236 100644 --- a/src/scout/src/Engines/TypesenseEngine.php +++ b/src/scout/src/Engines/TypesenseEngine.php @@ -4,7 +4,6 @@ namespace Hypervel\Scout\Engines; -use Hyperf\Contract\ConfigInterface; use Hypervel\Context\ApplicationContext; use Hypervel\Database\Eloquent\Collection as EloquentCollection; use Hypervel\Database\Eloquent\Model; @@ -639,7 +638,7 @@ public function getTypesenseClient(): Typesense protected function getConfig(string $key, mixed $default = null): mixed { return ApplicationContext::getContainer() - ->get(ConfigInterface::class) + ->get('config') ->get("scout.{$key}", $default); } diff --git a/src/scout/src/Jobs/MakeSearchable.php b/src/scout/src/Jobs/MakeSearchable.php index 8afeb896d..863693686 100644 --- a/src/scout/src/Jobs/MakeSearchable.php +++ b/src/scout/src/Jobs/MakeSearchable.php @@ -4,9 +4,9 @@ namespace Hypervel\Scout\Jobs; +use Hypervel\Contracts\Queue\ShouldQueue; use Hypervel\Database\Eloquent\Collection; use Hypervel\Database\Eloquent\Model; -use Hypervel\Queue\Contracts\ShouldQueue; use Hypervel\Queue\Queueable; use Hypervel\Scout\Contracts\SearchableInterface; diff --git a/src/scout/src/Jobs/RemoveFromSearch.php b/src/scout/src/Jobs/RemoveFromSearch.php index 30520701d..e54903f31 100644 --- a/src/scout/src/Jobs/RemoveFromSearch.php +++ b/src/scout/src/Jobs/RemoveFromSearch.php @@ -4,9 +4,9 @@ namespace Hypervel\Scout\Jobs; +use Hypervel\Contracts\Queue\ShouldQueue; use Hypervel\Database\Eloquent\Collection; use Hypervel\Database\Eloquent\Model; -use Hypervel\Queue\Contracts\ShouldQueue; use Hypervel\Queue\Queueable; use Hypervel\Scout\Contracts\SearchableInterface; diff --git a/src/scout/src/Jobs/RemoveableScoutCollection.php b/src/scout/src/Jobs/RemoveableScoutCollection.php index 98ae4afbc..25643eeec 100644 --- a/src/scout/src/Jobs/RemoveableScoutCollection.php +++ b/src/scout/src/Jobs/RemoveableScoutCollection.php @@ -32,7 +32,7 @@ public function getQueueableIds(): array return []; } - /** @var Model $first */ + /** @var Model&SearchableInterface $first */ $first = $this->first(); if (in_array(Searchable::class, class_uses_recursive($first))) { diff --git a/src/scout/src/ModelObserver.php b/src/scout/src/ModelObserver.php new file mode 100644 index 000000000..3cda3db16 --- /dev/null +++ b/src/scout/src/ModelObserver.php @@ -0,0 +1,190 @@ +afterCommit = Config::boolean('scout.after_commit', false); + $this->usingSoftDeletes = Config::boolean('scout.soft_delete', false); + } + + /** + * Enable syncing for the given class. + * + * Uses Context for coroutine-safe state management. + * + * @param class-string $class + */ + public static function enableSyncingFor(string $class): void + { + Context::set(self::SYNCING_DISABLED_CONTEXT_KEY_PREFIX . $class, false); + } + + /** + * Disable syncing for the given class. + * + * Uses Context for coroutine-safe state management. + * + * @param class-string $class + */ + public static function disableSyncingFor(string $class): void + { + Context::set(self::SYNCING_DISABLED_CONTEXT_KEY_PREFIX . $class, true); + } + + /** + * Determine if syncing is disabled for the given class or model. + * + * Uses Context for coroutine-safe state management. + * + * @param class-string|object $class + */ + public static function syncingDisabledFor(object|string $class): bool + { + $class = is_object($class) ? get_class($class) : $class; + + return (bool) Context::get(self::SYNCING_DISABLED_CONTEXT_KEY_PREFIX . $class, false); + } + + /** + * Handle the saved event for the model. + * + * @param Model&SearchableInterface $model + */ + public function saved(Model $model): void + { + if (static::syncingDisabledFor($model)) { + return; + } + + /* @phpstan-ignore method.notFound (provided by Searchable trait) */ + if (! $this->forceSaving && ! $model->searchIndexShouldBeUpdated()) { + return; + } + + if (! $model->shouldBeSearchable()) { + /* @phpstan-ignore method.notFound (provided by Searchable trait) */ + if ($model->wasSearchableBeforeUpdate()) { + $model->unsearchable(); + } + + return; + } + + $model->searchable(); + } + + /** + * Handle the deleted event for the model. + * + * @param Model&SearchableInterface $model + */ + public function deleted(Model $model): void + { + if (static::syncingDisabledFor($model)) { + return; + } + + /* @phpstan-ignore method.notFound (provided by Searchable trait) */ + if (! $model->wasSearchableBeforeDelete()) { + return; + } + + if ($this->usingSoftDeletes && $this->usesSoftDelete($model)) { + $this->whileForcingUpdate(function () use ($model): void { + $this->saved($model); + }); + } else { + $model->unsearchable(); + } + } + + /** + * Handle the force deleted event for the model. + * + * @param Model&SearchableInterface $model + */ + public function forceDeleted(Model $model): void + { + if (static::syncingDisabledFor($model)) { + return; + } + + $model->unsearchable(); + } + + /** + * Handle the restored event for the model. + * + * @param Model&SearchableInterface $model + */ + public function restored(Model $model): void + { + $this->whileForcingUpdate(function () use ($model): void { + $this->saved($model); + }); + } + + /** + * Execute the given callback while forcing updates. + */ + protected function whileForcingUpdate(Closure $callback): mixed + { + $this->forceSaving = true; + + try { + return $callback(); + } finally { + $this->forceSaving = false; + } + } + + /** + * Determine if the given model uses soft deletes. + */ + protected function usesSoftDelete(Model $model): bool + { + return in_array(SoftDeletes::class, class_uses_recursive($model)); + } +} diff --git a/src/scout/src/ScoutServiceProvider.php b/src/scout/src/ScoutServiceProvider.php index c5f3fc2a6..d52cedfa0 100644 --- a/src/scout/src/ScoutServiceProvider.php +++ b/src/scout/src/ScoutServiceProvider.php @@ -4,7 +4,6 @@ namespace Hypervel\Scout; -use Hyperf\Contract\ConfigInterface; use Hypervel\Scout\Console\DeleteAllIndexesCommand; use Hypervel\Scout\Console\DeleteIndexCommand; use Hypervel\Scout\Console\FlushCommand; @@ -30,7 +29,7 @@ public function register(): void $this->app->bind(EngineManager::class, EngineManager::class); $this->app->bind(MeilisearchClient::class, function () { - $config = $this->app->get(ConfigInterface::class); + $config = $this->app->get('config'); return new MeilisearchClient( $config->get('scout.meilisearch.host', 'http://localhost:7700'), @@ -39,7 +38,7 @@ public function register(): void }); $this->app->bind(TypesenseClient::class, function () { - $config = $this->app->get(ConfigInterface::class); + $config = $this->app->get('config'); return new TypesenseClient( $config->get('scout.typesense.client-settings', []) diff --git a/src/scout/src/Searchable.php b/src/scout/src/Searchable.php index dbcc89073..c76857458 100644 --- a/src/scout/src/Searchable.php +++ b/src/scout/src/Searchable.php @@ -5,9 +5,7 @@ namespace Hypervel\Scout; use Closure; -use Hyperf\Contract\ConfigInterface; use Hypervel\Context\ApplicationContext; -use Hypervel\Context\Context; use Hypervel\Coroutine\Coroutine; use Hypervel\Coroutine\WaitConcurrent; use Hypervel\Database\Eloquent\Builder as EloquentBuilder; @@ -41,68 +39,9 @@ public static function bootSearchable(): void { static::addGlobalScope(new SearchableScope()); - (new static())->registerSearchableMacros(); - - static::registerCallback('saved', function ($model): void { - if (! static::isSearchSyncingEnabled()) { - return; - } - - if (! $model->searchIndexShouldBeUpdated()) { - return; - } - - if (! $model->shouldBeSearchable()) { - if ($model->wasSearchableBeforeUpdate()) { - $model->unsearchable(); - } - return; - } - - $model->searchable(); - }); - - static::registerCallback('deleted', function ($model): void { - if (! static::isSearchSyncingEnabled()) { - return; - } - - if (! $model->wasSearchableBeforeDelete()) { - return; - } + static::observe(new ModelObserver()); - if (static::usesSoftDelete() && static::getScoutConfig('soft_delete', false)) { - $model->searchable(); - } else { - $model->unsearchable(); - } - }); - - static::registerCallback('forceDeleted', function ($model): void { - if (! static::isSearchSyncingEnabled()) { - return; - } - - $model->unsearchable(); - }); - - static::registerCallback('restored', function ($model): void { - if (! static::isSearchSyncingEnabled()) { - return; - } - - // Note: restored is a "forced update" - we don't check searchIndexShouldBeUpdated() - // because restored models should always be re-indexed - - if (! $model->shouldBeSearchable()) { - if ($model->wasSearchableBeforeUpdate()) { - $model->unsearchable(); - } - return; - } - - $model->searchable(); - }); + (new static())->registerSearchableMacros(); } /** @@ -388,7 +327,7 @@ public function queryScoutModelsByIds(Builder $builder, array $ids): EloquentBui */ public static function enableSearchSyncing(): void { - Context::set('__scout.syncing_disabled.' . static::class, false); + ModelObserver::enableSyncingFor(static::class); } /** @@ -396,7 +335,7 @@ public static function enableSearchSyncing(): void */ public static function disableSearchSyncing(): void { - Context::set('__scout.syncing_disabled.' . static::class, true); + ModelObserver::disableSyncingFor(static::class); } /** @@ -404,7 +343,7 @@ public static function disableSearchSyncing(): void */ public static function isSearchSyncingEnabled(): bool { - return ! Context::get('__scout.syncing_disabled.' . static::class, false); + return ! ModelObserver::syncingDisabledFor(static::class); } /** @@ -572,7 +511,7 @@ protected static function usesSoftDelete(): bool protected static function getScoutConfig(string $key, mixed $default = null): mixed { return ApplicationContext::getContainer() - ->get(ConfigInterface::class) + ->get('config') ->get("scout.{$key}", $default); } } diff --git a/src/scout/src/SearchableScope.php b/src/scout/src/SearchableScope.php index 436e1dec1..450369e26 100644 --- a/src/scout/src/SearchableScope.php +++ b/src/scout/src/SearchableScope.php @@ -4,17 +4,15 @@ namespace Hypervel\Scout; -use Hyperf\Contract\ConfigInterface; -use Hyperf\Database\Model\Builder as HyperfBuilder; -use Hyperf\Database\Model\Model as HyperfModel; -use Hyperf\Database\Model\Scope; use Hypervel\Context\ApplicationContext; use Hypervel\Database\Eloquent\Builder as EloquentBuilder; use Hypervel\Database\Eloquent\Collection as EloquentCollection; use Hypervel\Database\Eloquent\Model; +use Hypervel\Database\Eloquent\Scope; use Hypervel\Scout\Contracts\SearchableInterface; use Hypervel\Scout\Events\ModelsFlushed; use Hypervel\Scout\Events\ModelsImported; +use Hypervel\Support\Collection; use Psr\EventDispatcher\EventDispatcherInterface; /** @@ -25,7 +23,7 @@ class SearchableScope implements Scope /** * Apply the scope to a given Eloquent query builder. */ - public function apply(HyperfBuilder $builder, HyperfModel $model): void + public function apply(EloquentBuilder $builder, Model $model): void { // This scope doesn't modify queries, only extends the builder } @@ -41,11 +39,11 @@ public function extend(EloquentBuilder $builder): void $scoutKeyName = $model->getScoutKeyName(); $chunkSize = $chunk ?? static::getScoutConfig('chunk.searchable', 500); - $builder->chunkById($chunkSize, function (EloquentCollection $models) { - /* @phpstan-ignore-next-line method.notFound, argument.type */ + $builder->chunkById($chunkSize, function (Collection $models) { + /** @var EloquentCollection $models */ + /* @phpstan-ignore method.notFound (searchable() added via Searchable trait) */ $models->filter(fn ($m) => $m->shouldBeSearchable())->searchable(); - /* @phpstan-ignore-next-line argument.type */ static::dispatchEvent(new ModelsImported($models)); }, $builder->qualifyColumn($scoutKeyName), $scoutKeyName); }); @@ -56,11 +54,11 @@ public function extend(EloquentBuilder $builder): void $scoutKeyName = $model->getScoutKeyName(); $chunkSize = $chunk ?? static::getScoutConfig('chunk.unsearchable', 500); - $builder->chunkById($chunkSize, function (EloquentCollection $models) { - /* @phpstan-ignore-next-line method.notFound */ + $builder->chunkById($chunkSize, function (Collection $models) { + /** @var EloquentCollection $models */ + /* @phpstan-ignore method.notFound (unsearchable() added via Searchable trait) */ $models->unsearchable(); - /* @phpstan-ignore-next-line argument.type */ static::dispatchEvent(new ModelsFlushed($models)); }, $builder->qualifyColumn($scoutKeyName), $scoutKeyName); }); @@ -72,7 +70,7 @@ public function extend(EloquentBuilder $builder): void protected static function getScoutConfig(string $key, mixed $default = null): mixed { return ApplicationContext::getContainer() - ->get(ConfigInterface::class) + ->get('config') ->get("scout.{$key}", $default); } diff --git a/src/sentry/composer.json b/src/sentry/composer.json index 105ec0227..a248a7f2e 100644 --- a/src/sentry/composer.json +++ b/src/sentry/composer.json @@ -25,12 +25,12 @@ } }, "require": { - "php": "^8.2", - "hypervel/cache": "^0.3", - "hypervel/console": "^0.3", - "hypervel/core": "^0.3", - "hypervel/object-pool": "^0.3", - "hypervel/support": "^0.3", + "php": "^8.4", + "hypervel/cache": "^0.4", + "hypervel/console": "^0.4", + "hypervel/core": "^0.4", + "hypervel/object-pool": "^0.4", + "hypervel/support": "^0.4", "sentry/sentry": "^4.15.0" }, "config": { @@ -38,7 +38,7 @@ }, "extra": { "branch-alias": { - "dev-main": "0.3-dev" + "dev-main": "0.4-dev" }, "hypervel": { "providers": [ diff --git a/src/sentry/src/Aspects/CoroutineAspect.php b/src/sentry/src/Aspects/CoroutineAspect.php index e4c6346c4..44040b2f9 100644 --- a/src/sentry/src/Aspects/CoroutineAspect.php +++ b/src/sentry/src/Aspects/CoroutineAspect.php @@ -6,8 +6,8 @@ use Hyperf\Di\Aop\AbstractAspect; use Hyperf\Di\Aop\ProceedingJoinPoint; -use Hyperf\Engine\Coroutine; use Hypervel\Coroutine\Coroutine as HypervelCoroutine; +use Hypervel\Engine\Coroutine; use Hypervel\Sentry\Switcher; use Sentry\SentrySdk; use Throwable; @@ -15,7 +15,7 @@ class CoroutineAspect extends AbstractAspect { public array $classes = [ - 'Hyperf\Coroutine\Coroutine::create', + 'Hypervel\Coroutine\Coroutine::create', ]; protected array $keys = [ diff --git a/src/sentry/src/Factory/ClientBuilderFactory.php b/src/sentry/src/Factory/ClientBuilderFactory.php index 4f8e8e603..7cb6208d1 100644 --- a/src/sentry/src/Factory/ClientBuilderFactory.php +++ b/src/sentry/src/Factory/ClientBuilderFactory.php @@ -4,8 +4,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 +17,6 @@ use Sentry\Integration as SdkIntegration; use function Hyperf\Support\make; -use function Hyperf\Tappable\tap; -use function Hypervel\Support\env; class ClientBuilderFactory { @@ -36,7 +33,7 @@ class ClientBuilderFactory public function __invoke(Application $container) { - $userConfig = $container->get(ConfigInterface::class)->get('sentry', []); + $userConfig = $container->get('config')->get('sentry', []); $userConfig['enable_tracing'] ??= true; foreach (static::SPECIFIC_OPTIONS as $specificOptionName) { @@ -87,7 +84,7 @@ function (ClientBuilder $clientBuilder) use ($container) { protected function resolveIntegrations(Application $container, ClientBuilder $clientBuilder): void { $options = $clientBuilder->getOptions(); - $userConfig = (array) $container->get(ConfigInterface::class)->get('sentry', []); + $userConfig = (array) $container->get('config')->get('sentry', []); /** @var array|callable $userIntegrationOption */ $userIntegrationOption = $userConfig['integrations'] ?? []; 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..31e4cff1b 100644 --- a/src/sentry/src/Features/CacheFeature.php +++ b/src/sentry/src/Features/CacheFeature.php @@ -5,7 +5,6 @@ namespace Hypervel\Sentry\Features; use Exception; -use Hyperf\Contract\ConfigInterface; use Hypervel\Cache\Events\CacheEvent; use Hypervel\Cache\Events\CacheHit; use Hypervel\Cache\Events\CacheMissed; @@ -18,12 +17,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; @@ -45,7 +44,7 @@ public function isApplicable(): bool public function onBoot(): void { - $config = $this->container->get(ConfigInterface::class); + $config = $this->container->get('config'); $stores = array_keys($config->get('cache.stores', [])); foreach ($stores as $store) { $config->set("cache.stores.{$store}.events", true); 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/Feature.php b/src/sentry/src/Features/Feature.php index 47c7b29d2..055d23850 100644 --- a/src/sentry/src/Features/Feature.php +++ b/src/sentry/src/Features/Feature.php @@ -4,7 +4,6 @@ namespace Hypervel\Sentry\Features; -use Hyperf\Contract\ConfigInterface; use Hypervel\Foundation\Application; use Hypervel\Sentry\Switcher; use Sentry\SentrySdk; @@ -79,7 +78,7 @@ protected function container(): Application */ protected function getUserConfig(): array { - $config = $this->container->get(ConfigInterface::class)->get('sentry', []); + $config = $this->container->get('config')->get('sentry', []); return empty($config) ? [] : $config; } 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..616b218f8 100644 --- a/src/sentry/src/Features/RedisFeature.php +++ b/src/sentry/src/Features/RedisFeature.php @@ -5,13 +5,13 @@ namespace Hypervel\Sentry\Features; use Exception; -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\Redis\Events\CommandExecuted; +use Hypervel\Redis\Pool\PoolFactory; +use Hypervel\Redis\RedisConfig; use Hypervel\Sentry\Traits\ResolvesEventOrigin; -use Hypervel\Session\Contracts\Session; use Hypervel\Support\Str; use Sentry\SentrySdk; use Sentry\Tracing\SpanContext; @@ -27,10 +27,13 @@ public function isApplicable(): bool public function onBoot(): void { - $config = $this->container->get(ConfigInterface::class); - if ($config->has('database.connections.redis.event')) { - $config->set('database.connections.redis.event', true); + $config = $this->container->get('config'); + $redisConfig = $this->container->get(RedisConfig::class); + + foreach ($redisConfig->connectionNames() as $connection) { + $config->set("database.redis.{$connection}.event.enable", true); } + $dispatcher = $this->container->get(Dispatcher::class); $dispatcher->listen(CommandExecuted::class, [$this, 'handleRedisCommands']); } @@ -45,7 +48,8 @@ public function handleRedisCommands(CommandExecuted $event): void } $pool = $this->container->get(PoolFactory::class)->getPool($event->connectionName); - $config = $this->container->get(ConfigInterface::class)->get('redis.' . $event->connectionName, []); + $redisConfig = $this->container->get(RedisConfig::class); + $config = $redisConfig->connectionConfig($event->connectionName); $keyForDescription = ''; 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/sentry/src/Hub.php b/src/sentry/src/Hub.php index bb9783b03..56b976d8d 100644 --- a/src/sentry/src/Hub.php +++ b/src/sentry/src/Hub.php @@ -30,11 +30,11 @@ class Hub implements HubInterface { - public const CONTEXT_STACK_KEY = 'sentry.stack'; + public const CONTEXT_STACK_KEY = '__sentry.stack'; - public const CONTEXT_LAST_EVENT_ID_KEY = 'sentry.last_event_id'; + public const CONTEXT_LAST_EVENT_ID_KEY = '__sentry.last_event_id'; - public const CONTEXT_REQUEST_COROUTINE_ID_KEY = 'sentry.coroutine_id'; + public const CONTEXT_REQUEST_COROUTINE_ID_KEY = '__sentry.coroutine_id'; public function __construct(protected ?ClientInterface $client = null, protected ?Scope $scope = null) { diff --git a/src/sentry/src/SentryServiceProvider.php b/src/sentry/src/SentryServiceProvider.php index 2e62b81f8..82be6f5ee 100644 --- a/src/sentry/src/SentryServiceProvider.php +++ b/src/sentry/src/SentryServiceProvider.php @@ -4,7 +4,6 @@ namespace Hypervel\Sentry; -use Hyperf\Contract\ConfigInterface; use Hypervel\Context\Context; use Hypervel\Coroutine\Coroutine; use Hypervel\Sentry\Aspects\CoroutineAspect; @@ -21,6 +20,7 @@ use Sentry\ClientBuilder; use Sentry\ClientInterface; use Sentry\HttpClient\HttpClientInterface; +use Sentry\SentrySdk; use Sentry\State\HubInterface; use Throwable; @@ -28,6 +28,9 @@ class SentryServiceProvider extends ServiceProvider { public function boot(): void { + // Keep Sentry's global singleton hub aligned with the current application container. + SentrySdk::setCurrentHub($this->app->get(HubInterface::class)); + $this->bootFeatures(); $this->registerPublishing(); $this->registerCommands(); @@ -67,7 +70,7 @@ public function register(): void new Pool( $builder->getOptions(), $this->app, - $this->app->get(ConfigInterface::class)->get('pools.sentry', []) + $this->app->get('config')->get('pools.sentry', []) ) ); @@ -109,7 +112,7 @@ protected function registerCommands(): void protected function registerFeatures(): void { - $features = $this->app->get(ConfigInterface::class)->get('sentry.features', []); + $features = $this->app->get('config')->get('sentry.features', []); foreach ($features as $feature) { $this->app->bind($feature, $feature); } @@ -128,7 +131,7 @@ protected function registerFeatures(): void protected function bootFeatures(): void { - $features = $this->app->get(ConfigInterface::class)->get('sentry.features', []); + $features = $this->app->get('config')->get('sentry.features', []); foreach ($features as $feature) { try { /** @var Feature $featureInstance */ diff --git a/src/sentry/src/Switcher.php b/src/sentry/src/Switcher.php index 11c14202e..e34145374 100644 --- a/src/sentry/src/Switcher.php +++ b/src/sentry/src/Switcher.php @@ -4,11 +4,11 @@ namespace Hypervel\Sentry; -use Hyperf\Contract\ConfigInterface; +use Hypervel\Config\Repository; class Switcher { - public function __construct(protected ConfigInterface $config) + public function __construct(protected Repository $config) { } diff --git a/src/sentry/src/Transport/HttpPoolTransport.php b/src/sentry/src/Transport/HttpPoolTransport.php index 54a0f1448..8ae3fa261 100644 --- a/src/sentry/src/Transport/HttpPoolTransport.php +++ b/src/sentry/src/Transport/HttpPoolTransport.php @@ -23,7 +23,7 @@ public function send(Event $event): Result /** @var HttpTransport $transport */ $transport = $this->pool->get(); - Context::set('sentry.transport', $transport); + Context::set('__sentry.transport', $transport); try { return $transport->send($event); @@ -36,7 +36,7 @@ public function send(Event $event): Result public function close(?int $timeout = null): Result { - if ($transport = Context::get('sentry.transport')) { + if ($transport = Context::get('__sentry.transport')) { $this->pool->release($transport); } diff --git a/src/session/composer.json b/src/session/composer.json index b624812cd..71cd688d8 100644 --- a/src/session/composer.json +++ b/src/session/composer.json @@ -29,18 +29,16 @@ ] }, "require": { - "php": "^8.2", + "php": "^8.4", "ext-session": "*", - "hyperf/context": "~3.1.0", - "hyperf/collection": "~3.1.0", + "hypervel/context": "^0.4", + "hypervel/collections": "^0.4", "hyperf/support": "~3.1.0", - "hyperf/stringable": "~3.1.0", - "hyperf/macroable": "~3.1.0", - "hyperf/tappable": "~3.1.0", + "hypervel/macroable": "^0.4", "hyperf/view-engine": "~3.1.0", - "hypervel/cache": "^0.3", - "hypervel/cookie": "^0.3", - "hypervel/support": "^0.3" + "hypervel/cache": "^0.4", + "hypervel/cookie": "^0.4", + "hypervel/support": "^0.4" }, "config": { "sort-packages": true @@ -50,7 +48,7 @@ "config": "Hypervel\\Session\\ConfigProvider" }, "branch-alias": { - "dev-main": "0.3-dev" + "dev-main": "0.4-dev" } } } \ No newline at end of file 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..5c4e28f82 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\Context\Context; +use Hypervel\Context\RequestContext; +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; @@ -255,7 +253,7 @@ public function setContainer(ContainerInterface $container): static */ public function setExists(bool $value): static { - Context::set('_session.database.exists', $value); + Context::set('__session.database.exists', $value); return $this; } @@ -265,6 +263,6 @@ public function setExists(bool $value): static */ public function getExists(): bool { - return Context::get('_session.database.exists', false); + return Context::get('__session.database.exists', false); } } 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/FileSessionHandler.php b/src/session/src/FileSessionHandler.php index f2b3fa14a..354574d6c 100644 --- a/src/session/src/FileSessionHandler.php +++ b/src/session/src/FileSessionHandler.php @@ -5,7 +5,7 @@ namespace Hypervel\Session; use Carbon\Carbon; -use Hyperf\Support\Filesystem\Filesystem; +use Hypervel\Filesystem\Filesystem; use SessionHandlerInterface; use Symfony\Component\Finder\Finder; 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..15bf50102 100644 --- a/src/session/src/Middleware/StartSession.php +++ b/src/session/src/Middleware/StartSession.php @@ -6,13 +6,13 @@ use Carbon\Carbon; use DateTimeInterface; -use Hyperf\Context\Context; use Hyperf\Contract\SessionInterface; use Hyperf\HttpServer\Request; use Hyperf\HttpServer\Router\Dispatched; -use Hypervel\Cache\Contracts\Factory as CacheFactoryContract; +use Hypervel\Context\Context; +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..d9011fa67 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\Filesystem\Filesystem; use Hypervel\Support\Manager; use SessionHandlerInterface; diff --git a/src/session/src/Store.php b/src/session/src/Store.php index 13c668ed4..f8274ab9f 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\Context\Context; +use Hypervel\Contracts\Session\Session; +use Hypervel\Support\Arr; +use Hypervel\Support\MessageBag; use Hypervel\Support\Str; +use Hypervel\Support\Traits\Macroable; use SessionHandlerInterface; use stdClass; use UnitEnum; @@ -47,7 +47,7 @@ public function start(): bool $this->regenerateToken(); } - return Context::set('_session.store.started', true); + return Context::set('__session.store.started', true); } /** @@ -55,7 +55,7 @@ public function start(): bool */ protected function getAttributes(): array { - return Context::get('_session.store.attributes', []); + return Context::get('__session.store.attributes', []); } /** @@ -63,7 +63,7 @@ protected function getAttributes(): array */ protected function setAttributes(array $attributes): void { - Context::set('_session.store.attributes', $attributes); + Context::set('__session.store.attributes', $attributes); } /** @@ -72,8 +72,8 @@ protected function setAttributes(array $attributes): void protected function replaceAttributes(array $attributes): void { Context::set( - '_session.store.attributes', - array_replace(Context::get('_session.store.attributes', []), $attributes) + '__session.store.attributes', + array_replace(Context::get('__session.store.attributes', []), $attributes) ); } @@ -148,7 +148,7 @@ public function save(): void $this->serialization === 'json' ? json_encode($this->getAttributes()) : serialize($this->getAttributes()) )); - Context::set('_session.store.started', false); + Context::set('__session.store.started', false); } /** @@ -511,7 +511,7 @@ public function migrate(bool $destroy = false): bool */ public function isStarted(): bool { - return Context::get('_session.store.started', false); + return Context::get('__session.store.started', false); } /** @@ -543,7 +543,7 @@ public function id(): ?string */ public function getId(): ?string { - return Context::get('_session.store.id', null); + return Context::get('__session.store.id', null); } /** @@ -552,7 +552,7 @@ public function getId(): ?string public function setId(?string $id): void { Context::set( - '_session.store.id', + '__session.store.id', $this->isValidId($id) ? $id : $this->generateSessionId() ); } 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/composer.json b/src/socialite/composer.json index 20bdb1aab..dad565b1d 100644 --- a/src/socialite/composer.json +++ b/src/socialite/composer.json @@ -26,12 +26,12 @@ } }, "require": { - "php": "^8.2", + "php": "^8.4", "ext-json": "*", "firebase/php-jwt": "^6.4", "guzzlehttp/guzzle": "^6.0|^7.0", - "hypervel/http": "^0.3", - "hypervel/support": "^0.3", + "hypervel/http": "^0.4", + "hypervel/support": "^0.4", "league/oauth1-client": "^1.11", "phpseclib/phpseclib": "^3.0" }, @@ -43,7 +43,7 @@ "config": "Hypervel\\Socialite\\ConfigProvider" }, "branch-alias": { - "dev-main": "0.3-dev" + "dev-main": "0.4-dev" } } } \ No newline at end of file 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..9137f197b 100644 --- a/src/support/composer.json +++ b/src/support/composer.json @@ -17,17 +17,25 @@ { "name": "Albert Chen", "email": "albert@hypervel.org" + }, + { + "name": "Raj Siva-Rajah", + "homepage": "https://github.com/binaryfire" } ], "require": { - "php": "^8.2", - "hyperf/context": "~3.1.0", + "php": "^8.4", + "hypervel/context": "^0.4", "hyperf/support": "~3.1.0", - "hyperf/stringable": "~3.1.0", - "hyperf/tappable": "~3.1.0", - "hyperf/collection": "~3.1.0", + "hypervel/collections": "^0.4", + "hypervel/conditionable": "^0.4", + "hypervel/macroable": "^0.4", + "hypervel/reflection": "^0.4", + "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": { @@ -40,11 +48,14 @@ }, "extra": { "branch-alias": { - "dev-main": "0.3-dev" + "dev-main": "0.4-dev" } }, + "suggest": { + "ext-intl": "Required to use number formatting and spellout features." + }, "config": { "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/Carbon.php b/src/support/src/Carbon.php index 98c41d85f..48daf7465 100644 --- a/src/support/src/Carbon.php +++ b/src/support/src/Carbon.php @@ -6,8 +6,10 @@ use Carbon\Carbon as BaseCarbon; use Carbon\CarbonImmutable as BaseCarbonImmutable; -use Hyperf\Conditionable\Conditionable; +use Hypervel\Support\Traits\Conditionable; use Hypervel\Support\Traits\Dumpable; +use Ramsey\Uuid\Uuid; +use Symfony\Component\Uid\Ulid; class Carbon extends BaseCarbon { @@ -19,4 +21,54 @@ public static function setTestNow(mixed $testNow = null): void BaseCarbon::setTestNow($testNow); BaseCarbonImmutable::setTestNow($testNow); } + + /** + * Create a Carbon instance from a given ordered UUID or ULID. + */ + public static function createFromId(Uuid|Ulid|string $id): static + { + if (is_string($id)) { + $id = Ulid::isValid($id) ? Ulid::fromString($id) : Uuid::fromString($id); + } + + return static::createFromInterface($id->getDateTime()); + } + + /** + * Get the current date / time plus a given amount of time. + */ + public function plus( + int $years = 0, + int $months = 0, + int $weeks = 0, + int $days = 0, + int $hours = 0, + int $minutes = 0, + int $seconds = 0, + int $microseconds = 0 + ): static { + return $this->add(" + {$years} years {$months} months {$weeks} weeks {$days} days + {$hours} hours {$minutes} minutes {$seconds} seconds {$microseconds} microseconds + "); + } + + /** + * Get the current date / time minus a given amount of time. + */ + public function minus( + int $years = 0, + int $months = 0, + int $weeks = 0, + int $days = 0, + int $hours = 0, + int $minutes = 0, + int $seconds = 0, + int $microseconds = 0 + ): static { + return $this->sub(" + {$years} years {$months} months {$weeks} weeks {$days} days + {$hours} hours {$minutes} minutes {$seconds} seconds {$microseconds} microseconds + "); + } } 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 @@ -load(); + } + + /** + * Reload environment variables from the given paths. + */ + public static function reload(array $paths, bool $force = false): void + { + if (static::$cachedValues === null) { + static::load($paths); + + return; + } + + foreach (static::$cachedValues as $deletedEntry => $value) { + static::getAdapter()->delete($deletedEntry); + } + + static::$cachedValues = static::getDotenv($paths, $force)->load(); + } + + /** + * Reset all static state, allowing load() to run again. + * + * Removes any previously loaded env vars from putenv before clearing + * the internal tracking, so immutable repositories don't see stale values. + */ + public static function reset(): void + { + if (static::$cachedValues !== null) { + foreach (static::$cachedValues as $name => $value) { + static::getAdapter()->delete($name); + } + } + + static::$cachedValues = null; + static::$dotenv = null; + static::$adapter = null; + } + + /** + * Get or create the Dotenv instance. + */ + protected static function getDotenv(array $paths, bool $force = false): Dotenv + { + if (static::$dotenv !== null && ! $force) { + return static::$dotenv; + } + + return static::$dotenv = Dotenv::create( + RepositoryBuilder::createWithNoAdapters() + ->addAdapter(static::getAdapter($force)) + ->immutable() + ->make(), + $paths + ); + } + + /** + * Get or create the environment adapter. + */ + protected static function getAdapter(bool $force = false): AdapterInterface + { + if (static::$adapter !== null && ! $force) { + return static::$adapter; + } + + return static::$adapter = PutenvAdapter::create()->get(); + } +} diff --git a/src/support/src/Env.php b/src/support/src/Env.php new file mode 100644 index 000000000..41c15a339 --- /dev/null +++ b/src/support/src/Env.php @@ -0,0 +1,276 @@ + + */ + 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..a5c7b53ac 100644 --- a/src/support/src/Facades/Config.php +++ b/src/support/src/Facades/Config.php @@ -4,8 +4,6 @@ namespace Hypervel\Support\Facades; -use Hypervel\Config\Contracts\Repository as ConfigContract; - /** * @method static bool has(string $key) * @method static mixed get(array|string $key, mixed $default = null) @@ -30,6 +28,6 @@ class Config extends Facade { protected static function getFacadeAccessor() { - return ConfigContract::class; + return 'config'; } } 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/Exceptions.php b/src/support/src/Facades/Exceptions.php new file mode 100644 index 000000000..4815c2448 --- /dev/null +++ b/src/support/src/Facades/Exceptions.php @@ -0,0 +1,64 @@ +>|class-string $exceptions + */ + public static function fake(array|string $exceptions = []): ExceptionHandlerFake + { + $exceptionHandler = static::isFake() + ? static::getFacadeRoot()->handler() + : static::getFacadeRoot(); + + return tap(new ExceptionHandlerFake($exceptionHandler, Arr::wrap($exceptions)), function ($fake) { + static::swap($fake); + }); + } + + protected static function getFacadeAccessor(): string + { + return ExceptionHandler::class; + } +} diff --git a/src/support/src/Facades/Facade.php b/src/support/src/Facades/Facade.php index db95e6b97..100565fb5 100644 --- a/src/support/src/Facades/Facade.php +++ b/src/support/src/Facades/Facade.php @@ -130,9 +130,12 @@ protected static function getMockableClass(): ?string */ public static function swap(mixed $instance) { - static::$resolvedInstance[static::getFacadeAccessor()] = $instance; + $accessor = static::getFacadeAccessor(); + static::$resolvedInstance[$accessor] = $instance; - ApplicationContext::getContainer()->instance(static::getFacadeAccessor(), $instance); + if (ApplicationContext::hasContainer()) { + ApplicationContext::getContainer()->instance($accessor, $instance); + } } /** @@ -227,6 +230,7 @@ public static function defaultAliases(): Collection 'DB' => DB::class, 'Environment' => Environment::class, 'Event' => Event::class, + 'Exceptions' => Exceptions::class, 'File' => File::class, 'Gate' => Gate::class, 'Hash' => Hash::class, 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..0ef2070ac 100644 --- a/src/support/src/Facades/Storage.php +++ b/src/support/src/Facades/Storage.php @@ -4,8 +4,7 @@ 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 +12,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,12 +125,12 @@ 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 = []) { $disk = enum_value($disk) ?: ApplicationContext::getContainer() - ->get(ConfigInterface::class) + ->get('config') ->get('filesystems.default'); $root = storage_path('framework/testing/disks/' . $disk); @@ -150,12 +149,12 @@ 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 = []) { $disk = enum_value($disk) ?: ApplicationContext::getContainer() - ->get(ConfigInterface::class) + ->get('config') ->get('filesystems.default'); static::set($disk, $fake = static::createLocalDriver(array_merge($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..66715f318 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 Hypervel\Contracts\Support\Arrayable; +use Hypervel\Contracts\Support\Jsonable; +use Hypervel\Support\Traits\Conditionable; +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 array|object $attributes + */ + public function __construct(array|object $attributes = []) + { + $this->fill($attributes); + } + + /** + * Create a new fluent instance. + * + * @param array|object $attributes + */ + public static function make(array|object $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 array|object $attributes + */ + public function fill(array|object $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 @@ -html = $html; + $this->html = $html ?? ''; } /** diff --git a/src/support/src/Traits/InteractsWithTime.php b/src/support/src/InteractsWithTime.php similarity index 96% rename from src/support/src/Traits/InteractsWithTime.php rename to src/support/src/InteractsWithTime.php index db2549ece..fa172c8f5 100644 --- a/src/support/src/Traits/InteractsWithTime.php +++ b/src/support/src/InteractsWithTime.php @@ -2,12 +2,11 @@ declare(strict_types=1); -namespace Hypervel\Support\Traits; +namespace Hypervel\Support; use Carbon\CarbonInterval; use DateInterval; use DateTimeInterface; -use Hypervel\Support\Carbon; trait InteractsWithTime { diff --git a/src/support/src/Js.php b/src/support/src/Js.php index 7a068aef8..617ddbea6 100644 --- a/src/support/src/Js.php +++ b/src/support/src/Js.php @@ -4,10 +4,9 @@ namespace Hypervel\Support; -use Hyperf\Contract\Arrayable; -use Hyperf\Contract\Jsonable; -use Hyperf\Stringable\Str; -use Hyperf\ViewEngine\Contract\Htmlable; +use Hypervel\Contracts\Support\Arrayable; +use Hypervel\Contracts\Support\Htmlable; +use Hypervel\Contracts\Support\Jsonable; use JsonException; use JsonSerializable; use Stringable; @@ -25,7 +24,12 @@ class Js implements Htmlable, Stringable * * @var int */ - protected const REQUIRED_FLAGS = JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | JSON_HEX_QUOT | JSON_THROW_ON_ERROR; + protected const REQUIRED_FLAGS = JSON_HEX_TAG + | JSON_HEX_APOS + | JSON_HEX_AMP + | JSON_HEX_QUOT + | JSON_UNESCAPED_UNICODE + | JSON_THROW_ON_ERROR; /** * Create a new class instance. @@ -58,6 +62,13 @@ protected function convertDataToJavaScriptExpression(mixed $data, int $flags = 0 return $data->toHtml(); } + if ($data instanceof Htmlable + && ! $data instanceof Arrayable + && ! $data instanceof Jsonable + && ! $data instanceof JsonSerializable) { + $data = $data->toHtml(); + } + if ($data instanceof UnitEnum) { $data = enum_value($data); } @@ -79,7 +90,7 @@ protected function convertDataToJavaScriptExpression(mixed $data, int $flags = 0 public static function encode(mixed $data, int $flags = 0, int $depth = 512): string { if ($data instanceof Jsonable) { - return (string) $data; + return $data->toJson($flags | static::REQUIRED_FLAGS); } if ($data instanceof Arrayable && ! ($data instanceof JsonSerializable)) { diff --git a/src/support/src/Json.php b/src/support/src/Json.php new file mode 100644 index 000000000..030b94ae6 --- /dev/null +++ b/src/support/src/Json.php @@ -0,0 +1,40 @@ +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..d324420a3 100644 --- a/src/support/src/Manager.php +++ b/src/support/src/Manager.php @@ -5,8 +5,7 @@ namespace Hypervel\Support; use Closure; -use Hyperf\Contract\ConfigInterface; -use Hyperf\Stringable\Str; +use Hypervel\Config\Repository; use InvalidArgumentException; use Psr\Container\ContainerInterface; @@ -15,7 +14,7 @@ abstract class Manager /** * The configuration repository instance. */ - protected ConfigInterface $config; + protected Repository $config; /** * The registered custom driver creators. @@ -33,7 +32,7 @@ abstract class Manager public function __construct( protected ContainerInterface $container ) { - $this->config = $container->get(ConfigInterface::class); + $this->config = $container->get('config'); } /** 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..ac3600f1b 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; @@ -30,7 +30,7 @@ public static function format(float|int $number, ?int $precision = null, ?int $m { static::ensureIntlExtensionIsInstalled(); - $formatter = new NumberFormatter($locale ?? static::$locale, NumberFormatter::DECIMAL); + $formatter = new NumberFormatter($locale ?? static::defaultLocale(), NumberFormatter::DECIMAL); if (! is_null($maxPrecision)) { $formatter->setAttribute(NumberFormatter::MAX_FRACTION_DIGITS, $maxPrecision); @@ -41,6 +41,34 @@ public static function format(float|int $number, ?int $precision = null, ?int $m return $formatter->format($number); } + /** + * Parse the given string according to the specified format type. + */ + public static function parse(string $string, ?int $type = NumberFormatter::TYPE_DOUBLE, ?string $locale = null): int|float|false + { + static::ensureIntlExtensionIsInstalled(); + + $formatter = new NumberFormatter($locale ?? static::defaultLocale(), NumberFormatter::DECIMAL); + + return $formatter->parse($string, $type); + } + + /** + * Parse a string into an integer according to the specified locale. + */ + public static function parseInt(string $string, ?string $locale = null): int|false + { + return self::parse($string, NumberFormatter::TYPE_INT32, $locale); + } + + /** + * Parse a string into a float according to the specified locale. + */ + public static function parseFloat(string $string, ?string $locale = null): float|false + { + return self::parse($string, NumberFormatter::TYPE_DOUBLE, $locale); + } + /** * Spell out the given number in the given locale. */ @@ -56,7 +84,21 @@ public static function spell(float|int $number, ?string $locale = null, ?int $af return static::format($number, locale: $locale); } - $formatter = new NumberFormatter($locale ?? static::$locale, NumberFormatter::SPELLOUT); + $formatter = new NumberFormatter($locale ?? static::defaultLocale(), NumberFormatter::SPELLOUT); + + return $formatter->format($number); + } + + /** + * Spell out the given number in the given locale in ordinal form. + */ + public static function spellOrdinal(float|int $number, ?string $locale = null): string + { + static::ensureIntlExtensionIsInstalled(); + + $formatter = new NumberFormatter($locale ?? static::defaultLocale(), NumberFormatter::SPELLOUT); + + $formatter->setTextAttribute(NumberFormatter::DEFAULT_RULESET, '%spellout-ordinal'); return $formatter->format($number); } @@ -68,7 +110,7 @@ public static function ordinal(float|int $number, ?string $locale = null): strin { static::ensureIntlExtensionIsInstalled(); - $formatter = new NumberFormatter($locale ?? static::$locale, NumberFormatter::ORDINAL); + $formatter = new NumberFormatter($locale ?? static::defaultLocale(), NumberFormatter::ORDINAL); return $formatter->format($number); } @@ -80,7 +122,7 @@ public static function percentage(float|int $number, int $precision = 0, ?int $m { static::ensureIntlExtensionIsInstalled(); - $formatter = new NumberFormatter($locale ?? static::$locale, NumberFormatter::PERCENT); + $formatter = new NumberFormatter($locale ?? static::defaultLocale(), NumberFormatter::PERCENT); if (! is_null($maxPrecision)) { $formatter->setAttribute(NumberFormatter::MAX_FRACTION_DIGITS, $maxPrecision); @@ -94,13 +136,17 @@ public static function percentage(float|int $number, int $precision = 0, ?int $m /** * Convert the given number to its currency equivalent. */ - public static function currency(float|int $number, string $in = '', ?string $locale = null): false|string + public static function currency(float|int $number, string $in = '', ?string $locale = null, ?int $precision = null): false|string { static::ensureIntlExtensionIsInstalled(); - $formatter = new NumberFormatter($locale ?? static::$locale, NumberFormatter::CURRENCY); + $formatter = new NumberFormatter($locale ?? static::defaultLocale(), NumberFormatter::CURRENCY); + + if (! is_null($precision)) { + $formatter->setAttribute(NumberFormatter::FRACTION_DIGITS, $precision); + } - return $formatter->formatCurrency($number, ! empty($in) ? $in : static::$currency); + return $formatter->formatCurrency($number, ! empty($in) ? $in : static::defaultCurrency()); } /** @@ -169,11 +215,11 @@ protected static function summarize(float|int $number, int $precision = 0, ?int return sprintf('%s' . end($units), static::summarize($number / 1e15, $precision, $maxPrecision, $units)); } - $numberExponent = floor(log10($number)); + $numberExponent = (int) floor(log10($number)); $displayExponent = $numberExponent - ($numberExponent % 3); - $number /= pow(10, $displayExponent); + $number /= 10 ** $displayExponent; - return trim(sprintf('%s%s', static::format($number, $precision, $maxPrecision), $units[(string) $displayExponent] ?? '')); + return trim(sprintf('%s%s', static::format($number, $precision, $maxPrecision), $units[$displayExponent] ?? '')); } /** @@ -187,18 +233,18 @@ public static function clamp(float|int $number, float|int $min, float|int $max): /** * Split the given number into pairs of min/max values. */ - public static function pairs(float|int $to, float|int $by, float|int $offset = 1): array + public static function pairs(float|int $to, float|int $by, float|int $start = 0, float|int $offset = 1): array { $output = []; - for ($lower = 0; $lower < $to; $lower += $by) { - $upper = $lower + $by; + for ($lower = $start; $lower < $to; $lower += $by) { + $upper = $lower + $by - $offset; if ($upper > $to) { $upper = $to; } - $output[] = [$lower + $offset, $upper]; + $output[] = [$lower, $upper]; } return $output; @@ -217,11 +263,15 @@ 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); - return tap($callback(), fn () => static::useLocale($previousLocale)); + try { + return $callback(); + } finally { + static::useLocale($previousLocale); + } } /** @@ -229,11 +279,15 @@ 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); - return tap($callback(), fn () => static::useCurrency($previousCurrency)); + try { + return $callback(); + } finally { + static::useCurrency($previousCurrency); + } } /** @@ -249,7 +303,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/Onceable.php b/src/support/src/Onceable.php index d270d8e93..db0dd369f 100644 --- a/src/support/src/Onceable.php +++ b/src/support/src/Onceable.php @@ -5,7 +5,7 @@ namespace Hypervel\Support; use Closure; -use Hypervel\Support\Contracts\HasOnceHash; +use Hypervel\Contracts\Support\HasOnceHash; use Laravel\SerializableClosure\Support\ReflectionClosure; class Onceable diff --git a/src/support/src/Optional.php b/src/support/src/Optional.php new file mode 100644 index 000000000..f737affce --- /dev/null +++ b/src/support/src/Optional.php @@ -0,0 +1,108 @@ +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 (is_object($this->value)) { + return isset($this->value->{$name}); + } + + if (is_array($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/Pipeline.php b/src/support/src/Pipeline.php index 32aa9686b..2e906707c 100644 --- a/src/support/src/Pipeline.php +++ b/src/support/src/Pipeline.php @@ -4,9 +4,9 @@ namespace Hypervel\Support; -use Hyperf\Conditionable\Conditionable; use Hyperf\Pipeline\Pipeline as BasePipeline; use Hypervel\Context\ApplicationContext; +use Hypervel\Support\Traits\Conditionable; class Pipeline extends BasePipeline { 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/Reflector.php b/src/support/src/Reflector.php index 66310db93..6f9007895 100644 --- a/src/support/src/Reflector.php +++ b/src/support/src/Reflector.php @@ -4,7 +4,9 @@ namespace Hypervel\Support; +use ReflectionAttribute; use ReflectionClass; +use ReflectionEnum; use ReflectionMethod; use ReflectionNamedType; use ReflectionParameter; @@ -55,11 +57,49 @@ public static function isCallable(mixed $var, bool $syntaxOnly = false): bool } /** - * Get the class name of the given parameter's type, if possible. + * Get the specified class attribute, optionally following an inheritance chain. + * + * @template TAttribute of object + * + * @param class-string|object $objectOrClass + * @param class-string $attribute + * @return null|TAttribute + */ + public static function getClassAttribute(mixed $objectOrClass, string $attribute, bool $ascend = false): ?object + { + return static::getClassAttributes($objectOrClass, $attribute, $ascend)->flatten()->first(); + } + + /** + * Get the specified class attribute(s), optionally following an inheritance chain. + * + * @template TTarget of object + * @template TAttribute of object * - * @param ReflectionParameter $parameter + * @param class-string|TTarget $objectOrClass + * @param class-string $attribute + * @return Collection, Collection>|Collection + */ + public static function getClassAttributes(mixed $objectOrClass, string $attribute, bool $includeParents = false): Collection + { + $reflectionClass = new ReflectionClass($objectOrClass); + + $attributes = []; + + do { + $attributes[$reflectionClass->name] = new Collection(array_map( + fn (ReflectionAttribute $reflectionAttribute): object => $reflectionAttribute->newInstance(), + $reflectionClass->getAttributes($attribute) + )); + } while ($includeParents && false !== $reflectionClass = $reflectionClass->getParentClass()); + + return $includeParents ? new Collection($attributes) : array_first($attributes); + } + + /** + * Get the class name of the given parameter's type, if possible. */ - public static function getParameterClassName($parameter): ?string + public static function getParameterClassName(ReflectionParameter $parameter): ?string { $type = $parameter->getType(); @@ -72,10 +112,8 @@ public static function getParameterClassName($parameter): ?string /** * Get the class names of the given parameter's type, including union types. - * - * @param ReflectionParameter $parameter */ - public static function getParameterClassNames($parameter): array + public static function getParameterClassNames(ReflectionParameter $parameter): array { $type = $parameter->getType(); @@ -98,11 +136,8 @@ public static function getParameterClassNames($parameter): array /** * Get the given type's class name. - * - * @param ReflectionParameter $parameter - * @param ReflectionNamedType $type */ - protected static function getTypeName($parameter, $type): ?string + protected static function getTypeName(ReflectionParameter $parameter, ReflectionNamedType $type): ?string { $name = $type->getName(); @@ -121,11 +156,8 @@ protected static function getTypeName($parameter, $type): ?string /** * Determine if the parameter's type is a subclass of the given type. - * - * @param ReflectionParameter $parameter - * @param string $className */ - public static function isParameterSubclassOf($parameter, $className): bool + public static function isParameterSubclassOf(ReflectionParameter $parameter, string $className): bool { $paramClassName = static::getParameterClassName($parameter); @@ -133,4 +165,25 @@ public static function isParameterSubclassOf($parameter, $className): bool && (class_exists($paramClassName) || interface_exists($paramClassName)) && (new ReflectionClass($paramClassName))->isSubclassOf($className); } + + /** + * Determine if the parameter's type is a Backed Enum with a string backing type. + */ + public static function isParameterBackedEnumWithStringBackingType(ReflectionParameter $parameter): bool + { + if (! $parameter->getType() instanceof ReflectionNamedType) { + return false; + } + + $backedEnumClass = $parameter->getType()->getName(); + + if (enum_exists($backedEnumClass)) { + $reflectionBackedEnum = new ReflectionEnum($backedEnumClass); + + return $reflectionBackedEnum->isBacked() + && $reflectionBackedEnum->getBackingType()->getName() === 'string'; + } + + return false; + } } diff --git a/src/support/src/SafeCaller.php b/src/support/src/SafeCaller.php new file mode 100644 index 000000000..3fddfbdd8 --- /dev/null +++ b/src/support/src/SafeCaller.php @@ -0,0 +1,40 @@ +container->has(ExceptionHandlerContract::class)) { + $this->container->get(ExceptionHandlerContract::class)->report($exception); + } + } + + return value($default); + } +} diff --git a/src/support/src/ServiceProvider.php b/src/support/src/ServiceProvider.php index ae3659e55..920d83f10 100644 --- a/src/support/src/ServiceProvider.php +++ b/src/support/src/ServiceProvider.php @@ -5,12 +5,11 @@ namespace Hypervel\Support; 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; @@ -96,7 +95,7 @@ public function callBootedCallbacks(): void */ protected function mergeConfigFrom(string $path, string $key): void { - $config = $this->app->get(ConfigInterface::class); + $config = $this->app->get('config'); $config->set($key, array_merge( require $path, $config->get($key, []) @@ -118,7 +117,7 @@ protected function loadRoutesFrom(string $path): void protected function loadViewsFrom(array|string $path, string $namespace): void { $this->callAfterResolving(ViewFactoryContract::class, function ($view) use ($path, $namespace) { - $viewPath = $this->app->get(ConfigInterface::class) + $viewPath = $this->app->get('config') ->get('view.config.view_path', null); if (is_dir($appPath = $viewPath . '/vendor/' . $namespace)) { diff --git a/src/support/src/Sleep.php b/src/support/src/Sleep.php index 322aa3878..905a1b50e 100644 --- a/src/support/src/Sleep.php +++ b/src/support/src/Sleep.php @@ -7,14 +7,11 @@ use Carbon\CarbonInterval; use Closure; use DateInterval; -use Hyperf\Collection\Collection; -use Hyperf\Macroable\Macroable; +use DateTimeInterface; +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; @@ -87,7 +84,7 @@ public static function for(DateInterval|float|int $duration): static /** * Sleep until the given timestamp. */ - public static function until(DateInterval|float|int|string $timestamp): static + public static function until(DateTimeInterface|float|int|string $timestamp): static { if (is_numeric($timestamp)) { $timestamp = Carbon::createFromTimestamp($timestamp, date_default_timezone_get()); @@ -358,29 +355,38 @@ public static function assertSleptTimes(int $expected): void */ public static function assertSequence(array $sequence): void { - static::assertSleptTimes(count($sequence)); - - (new Collection($sequence)) - ->zip(static::$sequence) - ->eachSpread(function (?Sleep $expected, CarbonInterval $actual) { - if ($expected === null) { - return; + try { + static::assertSleptTimes(count($sequence)); + + (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; + } + + PHPUnit::assertTrue( + $expected->shouldNotSleep()->duration->equalTo($actual), + vsprintf('Expected sleep duration of [%s] but actually slept for [%s].', [ + $expected->duration->cascade()->forHumans([ + 'options' => 0, + 'minimumUnit' => 'microsecond', + ]), + $actual->cascade()->forHumans([ + 'options' => 0, + 'minimumUnit' => 'microsecond', + ]), + ]) + ); + }); + } finally { + foreach ($sequence as $expected) { + if ($expected instanceof self) { + $expected->shouldNotSleep(); } - - PHPUnit::assertTrue( - $expected->shouldNotSleep()->duration->equalTo($actual), - vsprintf('Expected sleep duration of [%s] but actually slept for [%s].', [ - $expected->duration->cascade()->forHumans([ - 'options' => 0, - 'minimumUnit' => 'microsecond', - ]), - $actual->cascade()->forHumans([ - 'options' => 0, - 'minimumUnit' => 'microsecond', - ]), - ]) - ); - }); + } + } } /** diff --git a/src/support/src/Str.php b/src/support/src/Str.php index a4f3a7145..a41bf1129 100644 --- a/src/support/src/Str.php +++ b/src/support/src/Str.php @@ -4,53 +4,423 @@ 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 LogicException; +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 Stringable as BaseStringable; +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(mixed $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|int|float|bool|BaseStringable|null $search): string + { + $search = (string) $search; + + 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|int|float|bool|BaseStringable|null $search): string { - if ($value instanceof BackedEnum) { - return (string) $value->value; + $search = (string) $search; + + 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|int|float|bool|BaseStringable|null $value, string $language = 'en'): string + { + return ASCII::to_ascii((string) $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|int|float|bool|BaseStringable|null $search): string + { + $search = (string) $search; + + 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|int|float|bool|BaseStringable|null $search): string + { + $search = (string) $search; + + 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|int|float|bool|BaseStringable|null $from, string|int|float|bool|BaseStringable|null $to): string + { + $from = (string) $from; + $to = (string) $to; + + 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|int|float|bool|BaseStringable|null $from, string|int|float|bool|BaseStringable|null $to): string + { + $from = (string) $from; + $to = (string) $to; + + 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, mixed $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 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|int|float|bool|BaseStringable|null $haystack, string|int|float|bool|BaseStringable|iterable|null $needles): bool + { + if ($haystack === null) { + return false; + } + + $haystack = (string) $haystack; + + if (! is_iterable($needles)) { + $needles = (array) $needles; + } + + foreach ($needles as $needle) { + $needle = (string) $needle; + + if ($needle !== '' && str_ends_with($haystack, $needle)) { + return true; + } } - return (string) $value; + return false; + } + + /** + * Determine if a given string doesn't end with a given substring. + * + * @param iterable|string $needles + */ + public static function doesntEndWith(string|int|float|bool|BaseStringable|null $haystack, string|int|float|bool|BaseStringable|iterable|null $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|int|float|bool|BaseStringable|null $text, string|int|float|bool|BaseStringable|null $phrase = '', array $options = []): ?string + { + $text = (string) $text; + $phrase = (string) $phrase; + + $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 finish(string $value, string $cap): string + { + $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 fromAll(array $values): array + public static function unwrap(string $value, string $before, ?string $after = null): string { - return array_map(self::from(...), $values); + 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|int|float|bool|BaseStringable|iterable|null $pattern, string|int|float|bool|BaseStringable|null $value, bool $ignoreCase = false): bool { $value = (string) $value; @@ -87,13 +457,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|int|float|bool|BaseStringable|null $value): bool + { + return ASCII::is_ascii((string) $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 +552,1218 @@ 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|BaseStringable $character, int $index, ?int $length = null, string $encoding = 'UTF-8'): string + { + $character = (string) $character; + + 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|array $value): string|array + { + 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|int|float|bool|BaseStringable|null $search, string $replace, string $subject): string + { + $search = (string) $search; + + 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|int|float|bool|BaseStringable|null $search, string $replace, string $subject): string + { + $search = (string) $search; + + 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|int|float|bool|BaseStringable|null $search, string $replace, string $subject): string + { + $search = (string) $search; + + 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|int|float|bool|BaseStringable|null $title, string $separator = '-', ?string $language = 'en', array $dictionary = ['@' => 'at']): string + { + $title = (string) $title; + + $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|int|float|bool|BaseStringable|null $haystack, string|int|float|bool|BaseStringable|iterable|null $needles): bool + { + if ($haystack === null) { + return false; + } + + $haystack = (string) $haystack; + + if (! is_iterable($needles)) { + $needles = [$needles]; + } + + foreach ($needles as $needle) { + $needle = (string) $needle; + + if ($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|int|float|bool|BaseStringable|null $haystack, string|int|float|bool|BaseStringable|iterable|null $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; + } + + /** + * Remove all strings from the casing caches. + */ + public static function flushCache(): void + { + throw new LogicException('Str::flushCache() is not implemented in Hypervel because Str casing caches are intentionally not used. Use StrCache for persistent casing caching.'); + } + + /** + * Return all factory functions to their default state. + */ + public static function resetFactoryState(): void + { + static::createRandomStringsNormally(); + static::createUlidsNormally(); + static::createUuidsNormally(); + } } 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..b12f6faa5 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(mixed $value = '') + { + $this->value = (string) $value; + } + + /** + * Return the remainder of a string after the first occurrence of a given value. + */ + public function after(string|int|float|bool|BaseStringable|null $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|int|float|bool|BaseStringable|null $search): static + { + return new static(Str::afterLast($this->value, $search)); + } + + /** + * Append the given values to the string. + */ + public function append(string|int|float|bool|BaseStringable|null ...$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(mixed $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|int|float|bool|BaseStringable|null $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|int|float|bool|BaseStringable|null $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|int|float|bool|BaseStringable|iterable|null $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|int|float|bool|BaseStringable|iterable|null $needles): bool + { + return Str::doesntEndWith($this->value, $needles); + } + + /** + * Determine if the string is an exact match with the given value. + */ + public function exactly(mixed $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|int|float|bool|BaseStringable|iterable|null $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|int|float|bool|BaseStringable|null $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|int|float|bool|BaseStringable|null $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|int|float|bool|BaseStringable|iterable|null $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|int|float|bool|BaseStringable|iterable|null $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|int|float|bool|BaseStringable|iterable|null $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|int|float|bool|BaseStringable|iterable|null $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|int|float|bool|BaseStringable|iterable|null $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|int|float|bool|BaseStringable|iterable|null $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|int|float|bool|BaseStringable|iterable|null $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..be16ebaf2 100644 --- a/src/support/src/Testing/Fakes/EventFake.php +++ b/src/support/src/Testing/Fakes/EventFake.php @@ -5,16 +5,17 @@ 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\QueuedClosure; +use Hypervel\Support\Arr; +use Hypervel\Support\Collection; +use Hypervel\Support\Str; +use Hypervel\Support\Traits\ForwardsCalls; 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 +23,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 +43,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,9 +188,10 @@ 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 */ + public function listen( + array|Closure|QueuedClosure|string $events, + array|Closure|QueuedClosure|string|null $listener = null + ): void { $this->dispatcher->listen($events, $listener); } @@ -198,14 +200,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 +239,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 +252,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/ExceptionHandlerFake.php b/src/support/src/Testing/Fakes/ExceptionHandlerFake.php new file mode 100644 index 000000000..f979ff418 --- /dev/null +++ b/src/support/src/Testing/Fakes/ExceptionHandlerFake.php @@ -0,0 +1,241 @@ + + */ + protected array $reported = []; + + /** + * If the fake should throw exceptions when they are reported. + */ + protected bool $throwOnReport = false; + + /** + * Create a new exception handler fake. + * + * @param list> $exceptions + */ + public function __construct( + protected ExceptionHandler $handler, + protected array $exceptions = [], + ) { + } + + /** + * Get the underlying handler implementation. + */ + public function handler(): ExceptionHandler + { + return $this->handler; + } + + /** + * Assert if an exception of the given type has been reported. + * + * @param class-string|(Closure(Throwable): bool) $exception + */ + public function assertReported(Closure|string $exception): void + { + $message = sprintf( + 'The expected [%s] exception was not reported.', + is_string($exception) ? $exception : $this->firstClosureParameterType($exception) + ); + + if (is_string($exception)) { + PHPUnit::assertTrue( + in_array($exception, array_map(get_class(...), $this->reported), true), + $message, + ); + + return; + } + + PHPUnit::assertTrue( + (new Collection($this->reported))->contains( + fn (Throwable $e) => $this->firstClosureParameterType($exception) === get_class($e) + && $exception($e) === true, + ), + $message, + ); + } + + /** + * Assert the number of exceptions that have been reported. + */ + public function assertReportedCount(int $count): void + { + $total = count($this->reported); + + PHPUnit::assertSame( + $count, + $total, + "The total number of exceptions reported was {$total} instead of {$count}." + ); + } + + /** + * Assert if an exception of the given type has not been reported. + * + * @param class-string|(Closure(Throwable): bool) $exception + */ + public function assertNotReported(Closure|string $exception): void + { + try { + $this->assertReported($exception); + } catch (ExpectationFailedException) { + return; + } + + throw new ExpectationFailedException(sprintf( + 'The expected [%s] exception was reported.', + is_string($exception) ? $exception : $this->firstClosureParameterType($exception) + )); + } + + /** + * Assert nothing has been reported. + */ + public function assertNothingReported(): void + { + PHPUnit::assertEmpty( + $this->reported, + sprintf( + 'The following exceptions were reported: %s.', + implode(', ', array_map(get_class(...), $this->reported)), + ), + ); + } + + /** + * Report or log an exception. + */ + public function report(Throwable $e): void + { + if (! $this->isFakedException($e)) { + $this->handler->report($e); + + return; + } + + if (! $this->shouldReport($e)) { + return; + } + + $this->reported[] = $e; + + if ($this->throwOnReport) { + throw $e; + } + } + + /** + * Determine if the given exception is faked. + */ + protected function isFakedException(Throwable $e): bool + { + return count($this->exceptions) === 0 || in_array(get_class($e), $this->exceptions, true); + } + + /** + * Determine if the exception should be reported. + */ + public function shouldReport(Throwable $e): bool + { + return $this->handler->shouldReport($e); + } + + /** + * Render an exception into an HTTP response. + */ + public function render(Request $request, Throwable $e): ResponseInterface + { + return $this->handler->render($request, $e); + } + + /** + * Register a callback to be called after an HTTP error response is rendered. + */ + public function afterResponse(callable $callback): void + { + $this->handler->afterResponse($callback); + } + + /** + * Throw exceptions when they are reported. + */ + public function throwOnReport(): static + { + $this->throwOnReport = true; + + return $this; + } + + /** + * Throw the first reported exception. + * + * @throws Throwable + */ + public function throwFirstReported(): static + { + foreach ($this->reported as $e) { + throw $e; + } + + return $this; + } + + /** + * Get the exceptions that have been reported. + * + * @return list + */ + public function reported(): array + { + return $this->reported; + } + + /** + * Set the "original" handler that should be used by the fake. + */ + public function setHandler(ExceptionHandler $handler): static + { + $this->handler = $handler; + + return $this; + } + + /** + * Handle dynamic method calls to the handler. + * + * @param array $parameters + */ + public function __call(string $method, array $parameters): mixed + { + return $this->forwardCallTo($this->handler, $method, $parameters); + } +} diff --git a/src/support/src/Testing/Fakes/MailFake.php b/src/support/src/Testing/Fakes/MailFake.php index 19052e1b9..9275b5e23 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\ForwardsCalls; 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/InteractsWithData.php b/src/support/src/Traits/InteractsWithData.php index 08a146856..443a18481 100644 --- a/src/support/src/Traits/InteractsWithData.php +++ b/src/support/src/Traits/InteractsWithData.php @@ -4,11 +4,13 @@ namespace Hypervel\Support\Traits; +use BackedEnum; use Hypervel\Support\Arr; use Hypervel\Support\Carbon; use Hypervel\Support\Collection; use Hypervel\Support\Facades\Date; use Hypervel\Support\Str; +use ReflectionEnum; use stdClass; use Stringable; use UnitEnum; @@ -264,7 +266,13 @@ public function enum(string $key, string $enumClass, mixed $default = null): mix return value($default); } - return $enumClass::tryFrom($this->data($key)) ?: value($default); + $value = $this->normalizeEnumValue($enumClass, $this->data($key)); + + if ($value === null) { + return value($default); + } + + return $enumClass::tryFrom($value) ?: value($default); } /** @@ -282,6 +290,8 @@ public function enums(string $key, string $enumClass): array } return $this->collect($key) + ->map(fn ($value) => $this->normalizeEnumValue($enumClass, $value)) + ->filter(fn ($value) => $value !== null) ->map(fn ($value) => $enumClass::tryFrom($value)) ->filter() ->all(); @@ -294,7 +304,63 @@ public function enums(string $key, string $enumClass): array */ protected function isBackedEnum(string $enumClass): bool { - return enum_exists($enumClass) && method_exists($enumClass, 'tryFrom'); + return enum_exists($enumClass) && is_subclass_of($enumClass, BackedEnum::class); + } + + /** + * Normalize enum input to a strict backed value. + */ + protected function normalizeEnumValue(string $enumClass, mixed $value): int|string|null + { + $backingType = $this->enumBackingType($enumClass); + + if ($backingType === 'int') { + if (is_int($value)) { + return $value; + } + + if (is_float($value) || is_bool($value)) { + return (int) $value; + } + + if (is_string($value)) { + $trimmed = trim($value); + + if ($trimmed === '' || ! is_numeric($trimmed)) { + return null; + } + + return (int) $trimmed; + } + + return null; + } + + if ($backingType === 'string') { + if (is_string($value)) { + return $value; + } + + if (is_int($value) || is_float($value) || is_bool($value) || $value instanceof Stringable) { + return (string) $value; + } + } + + return null; + } + + /** + * Resolve and cache the enum backing type for repeated lookups. + * + * @param class-string $enumClass + * @return null|'int'|'string' + */ + protected function enumBackingType(string $enumClass): ?string + { + /** @var array, null|'int'|'string'> $cache */ + static $cache = []; + + return $cache[$enumClass] ??= (new ReflectionEnum($enumClass))->getBackingType()?->getName(); } /** 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 5651de293..da08f6896 100644 --- a/src/support/src/helpers.php +++ b/src/support/src/helpers.php @@ -2,82 +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); - } -} + $start = 9999; -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); - } -} + foreach ($array as $key => $value) { + if (is_numeric($key)) { + ++$start; -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(); - } - - if ($value instanceof Htmlable) { - return $value->toHtml(); - } - - 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; @@ -91,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; @@ -221,6 +223,27 @@ 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. @@ -244,10 +267,42 @@ function once(callable $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(mixed $value = null, ?callable $callback = null): mixed + function optional($value = null, ?callable $callback = null) { - return \Hyperf\Support\optional($value, $callback); + 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 preg_replace_array($pattern, array $replacements, $subject): string + { + return preg_replace_callback($pattern, function () use (&$replacements) { + foreach ($replacements as $value) { + return array_shift($replacements); + } + }, $subject); } } @@ -255,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; @@ -269,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); @@ -307,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) ?: []; @@ -326,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); @@ -341,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 - */ - function throw_unless($condition, $exception, ...$parameters) - { - 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 + * @param TValue $value + * @param null|(callable(TValue): (TReturn)) $callback + * @return ($callback is null ? TValue : TReturn) */ - function when($expr, $value = null, $default = null) + function with($value, ?callable $callback = 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..adc0f84d1 100644 --- a/src/telescope/composer.json +++ b/src/telescope/composer.json @@ -20,13 +20,11 @@ } ], "require": { - "php": "^8.2", - "hyperf/context": "~3.1.0", + "php": "^8.4", + "hypervel/context": "^0.4", "hyperf/support": "~3.1.0", - "hyperf/stringable": "~3.1.0", - "hyperf/tappable": "~3.1.0", - "hyperf/collection": "~3.1.0", - "hypervel/core": "^0.3" + "hypervel/collections": "^0.4", + "hypervel/core": "^0.4" }, "autoload": { "psr-4": { @@ -35,7 +33,7 @@ }, "extra": { "branch-alias": { - "dev-main": "0.3-dev" + "dev-main": "0.4-dev" }, "hyperf": { "config": "Hypervel\\Telescope\\ConfigProvider" 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..30cb9bfaa 100644 --- a/src/telescope/src/AuthorizesRequests.php +++ b/src/telescope/src/AuthorizesRequests.php @@ -5,8 +5,8 @@ namespace Hypervel\Telescope; use Closure; -use Hyperf\Context\ApplicationContext; -use Hypervel\Http\Contracts\RequestContract; +use Hypervel\Context\ApplicationContext; +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..bc1a78702 100644 --- a/src/telescope/src/IncomingEntry.php +++ b/src/telescope/src/IncomingEntry.php @@ -5,9 +5,9 @@ namespace Hypervel\Telescope; use DateTimeInterface; -use Hyperf\Context\ApplicationContext; -use Hyperf\Stringable\Str; -use Hypervel\Auth\Contracts\Authenticatable; +use Hypervel\Context\ApplicationContext; +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..0ab9cb204 100644 --- a/src/telescope/src/ListensForStorageOpportunities.php +++ b/src/telescope/src/ListensForStorageOpportunities.php @@ -7,9 +7,9 @@ use Closure; use Hyperf\Command\Event\AfterExecute as AfterExecuteCommand; use Hyperf\Command\Event\BeforeHandle as BeforeHandleCommand; -use Hyperf\Context\Context; use Hyperf\HttpServer\Event\RequestReceived; -use Hypervel\Http\Contracts\RequestContract; +use Hypervel\Context\Context; +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..4b400b787 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\Context\Context; +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; @@ -246,7 +246,7 @@ protected function updateTags(EntryUpdate $entry): void */ public function getMonitorTags(): ?array { - return Context::get('telescope.monitored_tags', null); + return Context::get('__telescope.monitored_tags', null); } /** @@ -254,7 +254,7 @@ public function getMonitorTags(): ?array */ public function setMonitorTags(?array $tags): void { - Context::set('telescope.monitored_tags', $tags); + Context::set('__telescope.monitored_tags', $tags); } /** 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..6edd2f4a3 100644 --- a/src/telescope/src/Telescope.php +++ b/src/telescope/src/Telescope.php @@ -6,24 +6,24 @@ use Closure; use Exception; -use Hyperf\Collection\Arr; -use Hyperf\Collection\Collection; -use Hyperf\Context\ApplicationContext; -use Hyperf\Stringable\Str; +use Hypervel\Context\ApplicationContext; 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; use Psr\Container\ContainerInterface; use Throwable; -use function Hyperf\Coroutine\defer; use function Hypervel\Cache\cache; use function Hypervel\Config\config; +use function Hypervel\Coroutine\defer; use function Hypervel\Event\event; class Telescope 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..782c75503 100644 --- a/src/telescope/src/Watchers/CacheWatcher.php +++ b/src/telescope/src/Watchers/CacheWatcher.php @@ -4,12 +4,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; @@ -45,7 +44,7 @@ public function register(ContainerInterface $app): void */ public static function enableCacheEvents(ContainerInterface $app): void { - $config = $app->get(ConfigInterface::class); + $config = $app->get('config'); foreach (array_keys($config->get('cache.stores', [])) as $store) { $config->set("cache.stores.{$store}.events", true); } 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..e7acaf7b4 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; @@ -57,10 +57,17 @@ public function recordEvent(object|string $event, ...$payload): void */ protected function extractPayload(object|string $event, array $payload): array { + // For object events: the event object itself contains the payload properties + // Wildcard listeners receive (eventName, eventObject) so check payload[0] if (is_object($event) && empty($payload)) { return ExtractProperties::from($event); } + // For wildcard listeners with object events, the event object is in payload[0] + if (is_string($event) && count($payload) === 1 && is_object($payload[0])) { + return ExtractProperties::from($payload[0]); + } + return Collection::make($payload)->map(function ($value) { return is_object($value) ? [ 'class' => get_class($value), @@ -115,15 +122,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..558b04ce3 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\Context\Context; +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..10f4ed921 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\Redis\Events\CommandExecuted; +use Hypervel\Redis\Redis; +use Hypervel\Redis\RedisConfig; +use Hypervel\Support\Collection; use Hypervel\Telescope\IncomingEntry; use Hypervel\Telescope\Telescope; use Psr\Container\ContainerInterface; @@ -39,9 +39,10 @@ public function register(ContainerInterface $app): void */ public static function enableRedisEvents(ContainerInterface $app): void { - $config = $app->get(ConfigInterface::class); - foreach (array_keys($config->get('redis', [])) as $connection) { - $config->set("redis.{$connection}.event.enable", true); + $config = $app->get('config'); + $redisConfig = $app->get(RedisConfig::class); + foreach ($redisConfig->connectionNames() as $connection) { + $config->set("database.redis.{$connection}.event.enable", true); } static::$eventsEnabled = true; diff --git a/src/telescope/src/Watchers/RequestWatcher.php b/src/telescope/src/Watchers/RequestWatcher.php index 79bec2c25..e6be23fa8 100644 --- a/src/telescope/src/Watchers/RequestWatcher.php +++ b/src/telescope/src/Watchers/RequestWatcher.php @@ -4,16 +4,15 @@ 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\Context\Context; +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; @@ -61,7 +60,7 @@ public function register(ContainerInterface $app): void protected function enableRequestEvents(ContainerInterface $app): void { - $config = $app->get(ConfigInterface::class); + $config = $app->get('config'); $servers = $config->get('server.servers', []); foreach ($servers as &$server) { @@ -101,7 +100,7 @@ public function recordRequest(RequestHandled $event): void 'uri' => str_replace($this->request->root(), '', $this->request->fullUrl()) ?: '/', 'method' => $this->request->method(), 'controller_action' => $dispatched->handler ? $dispatched->handler->callback : '', - 'middleware' => Context::get('request.middleware', []), + 'middleware' => Context::get('__request.middleware', []), 'headers' => $this->headers($this->request->getHeaders()), 'payload' => $this->payload($this->input()), 'session' => $this->payload($this->sessionVariables()), 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..c9c98babf 100644 --- a/src/telescope/src/Watchers/ViewWatcher.php +++ b/src/telescope/src/Watchers/ViewWatcher.php @@ -4,10 +4,9 @@ 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; @@ -24,7 +23,7 @@ class ViewWatcher extends Watcher */ public function register(ContainerInterface $app): void { - $app->get(ConfigInterface::class) + $app->get('config') ->set('view.event.enable', true); $app->get(EventDispatcherInterface::class) diff --git a/src/testbench/bin/testbench b/src/testbench/bin/testbench new file mode 100755 index 000000000..33f66b6dd --- /dev/null +++ b/src/testbench/bin/testbench @@ -0,0 +1,70 @@ +#!/usr/bin/env php + components/vendor + __DIR__ . '/../../vendor/autoload.php', // installed: testbench/bin -> testbench/vendor +]; + +foreach (array_filter($autoloadPaths) as $autoloadPath) { + if (is_file($autoloadPath)) { + require $autoloadPath; + break; + } +} + +// Determine working path - must contain testbench.yaml +// Order of preference: +// 1. TESTBENCH_WORKING_PATH env if it contains testbench.yaml +// 2. The testbench package directory (src/testbench relative to this binary) +// 3. Current working directory +$envPath = getenv('TESTBENCH_WORKING_PATH'); +$testbenchPackageDir = dirname(__DIR__); // bin/testbench -> testbench/ + +$workingPath = match (true) { + is_string($envPath) && is_file($envPath . '/testbench.yaml') => $envPath, + is_file($testbenchPackageDir . '/testbench.yaml') => $testbenchPackageDir, + default => getcwd(), +}; + +define('TESTBENCH_WORKING_PATH', $workingPath); + +// Bootstrap the testbench environment (sets BASE_PATH, SWOOLE_HOOK_FLAGS, etc.) +Hypervel\Testbench\Bootstrapper::bootstrap(); + +use Hypervel\Context\ApplicationContext; +use Hypervel\Contracts\Console\Kernel as KernelContract; +use Hypervel\Contracts\Debug\ExceptionHandler as ExceptionHandlerContract; +use Hypervel\Foundation\Application; +use Hypervel\Foundation\Console\Kernel as ConsoleKernel; +use Symfony\Component\Console\Input\ArgvInput; +use Symfony\Component\Console\Output\ConsoleOutput; +use Workbench\App\Exceptions\ExceptionHandler; + +// Create application +$app = new Application(); +$app->bind(KernelContract::class, ConsoleKernel::class); +$app->bind(ExceptionHandlerContract::class, ExceptionHandler::class); + +ApplicationContext::setContainer($app); + +// Get console kernel and run +$kernel = $app->make(KernelContract::class); + +$status = $kernel->handle( + new ArgvInput(), + new ConsoleOutput() +); + +exit($status); diff --git a/src/testbench/composer.json b/src/testbench/composer.json index 3bcc442e7..01db54bd4 100644 --- a/src/testbench/composer.json +++ b/src/testbench/composer.json @@ -20,14 +20,17 @@ } ], "require": { - "php": "^8.2", - "hypervel/framework": "^0.3", + "php": "^8.4", + "hypervel/framework": "*", "mockery/mockery": "^1.6.10", "phpunit/phpunit": "^10.0.7", "symfony/yaml": "^7.3", "vlucas/phpdotenv": "^5.6.1" }, "autoload": { + "files": [ + "src/functions.php" + ], "psr-4": { "Hypervel\\Testbench\\": "src/", "Workbench\\App\\": "workbench/app/" @@ -35,13 +38,14 @@ }, "extra": { "branch-alias": { - "dev-main": "0.3-dev" + "dev-main": "0.4-dev" } }, "config": { "sort-packages": true }, "bin": [ + "bin/testbench", "bin/testbench-sync" ], "scripts": { diff --git a/src/testbench/migrations/0001_01_01_000000_testbench_create_users_table.php b/src/testbench/migrations/0001_01_01_000000_testbench_create_users_table.php new file mode 100644 index 000000000..5b69b8e90 --- /dev/null +++ b/src/testbench/migrations/0001_01_01_000000_testbench_create_users_table.php @@ -0,0 +1,50 @@ +id(); + $table->string('name'); + $table->string('email')->unique(); + $table->timestamp('email_verified_at')->nullable(); + $table->string('password'); + $table->rememberToken(); + $table->timestamps(); + }); + + Schema::create('password_reset_tokens', function (Blueprint $table) { + $table->string('email')->primary(); + $table->string('token'); + $table->timestamp('created_at')->nullable(); + }); + + Schema::create('sessions', function (Blueprint $table) { + $table->string('id')->primary(); + $table->foreignId('user_id')->nullable()->index(); + $table->string('ip_address', 45)->nullable(); + $table->text('user_agent')->nullable(); + $table->longText('payload'); + $table->integer('last_activity')->index(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('users'); + Schema::dropIfExists('password_reset_tokens'); + Schema::dropIfExists('sessions'); + } +}; diff --git a/src/testbench/migrations/0001_01_01_000001_testbench_create_cache_table.php b/src/testbench/migrations/0001_01_01_000001_testbench_create_cache_table.php new file mode 100644 index 000000000..9ef37d3bf --- /dev/null +++ b/src/testbench/migrations/0001_01_01_000001_testbench_create_cache_table.php @@ -0,0 +1,36 @@ +string('key')->primary(); + $table->mediumText('value'); + $table->integer('expiration')->index(); + }); + + Schema::create('cache_locks', function (Blueprint $table) { + $table->string('key')->primary(); + $table->string('owner'); + $table->integer('expiration')->index(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('cache'); + Schema::dropIfExists('cache_locks'); + } +}; diff --git a/src/testbench/migrations/0001_01_01_000002_testbench_create_jobs_table.php b/src/testbench/migrations/0001_01_01_000002_testbench_create_jobs_table.php new file mode 100644 index 000000000..cf1b50d4e --- /dev/null +++ b/src/testbench/migrations/0001_01_01_000002_testbench_create_jobs_table.php @@ -0,0 +1,58 @@ +id(); + $table->string('queue')->index(); + $table->longText('payload'); + $table->unsignedTinyInteger('attempts'); + $table->unsignedInteger('reserved_at')->nullable(); + $table->unsignedInteger('available_at'); + $table->unsignedInteger('created_at'); + }); + + Schema::create('job_batches', function (Blueprint $table) { + $table->string('id')->primary(); + $table->string('name'); + $table->integer('total_jobs'); + $table->integer('pending_jobs'); + $table->integer('failed_jobs'); + $table->longText('failed_job_ids'); + $table->mediumText('options')->nullable(); + $table->integer('cancelled_at')->nullable(); + $table->integer('created_at'); + $table->integer('finished_at')->nullable(); + }); + + Schema::create('failed_jobs', function (Blueprint $table) { + $table->id(); + $table->string('uuid')->unique(); + $table->text('connection'); + $table->text('queue'); + $table->longText('payload'); + $table->longText('exception'); + $table->timestamp('failed_at')->useCurrent(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('jobs'); + Schema::dropIfExists('job_batches'); + Schema::dropIfExists('failed_jobs'); + } +}; diff --git a/src/testbench/migrations/notifications/0001_01_02_000000_testbench_create_notifications_table.php b/src/testbench/migrations/notifications/0001_01_02_000000_testbench_create_notifications_table.php new file mode 100644 index 000000000..b51564dd9 --- /dev/null +++ b/src/testbench/migrations/notifications/0001_01_02_000000_testbench_create_notifications_table.php @@ -0,0 +1,32 @@ +uuid('id')->primary(); + $table->string('type'); + $table->morphs('notifiable'); + $table->text('data'); + $table->timestamp('read_at')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('notifications'); + } +}; diff --git a/src/foundation/src/Testing/Attributes/Define.php b/src/testbench/src/Attributes/Define.php similarity index 81% rename from src/foundation/src/Testing/Attributes/Define.php rename to src/testbench/src/Attributes/Define.php index 1d55570b2..2e6dcab76 100644 --- a/src/foundation/src/Testing/Attributes/Define.php +++ b/src/testbench/src/Attributes/Define.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace Hypervel\Foundation\Testing\Attributes; +namespace Hypervel\Testbench\Attributes; use Attribute; -use Hypervel\Foundation\Testing\Contracts\Attributes\Resolvable; -use Hypervel\Foundation\Testing\Contracts\Attributes\TestingFeature; +use Hypervel\Testbench\Contracts\Attributes\Resolvable; +use Hypervel\Testbench\Contracts\Attributes\TestingFeature; /** * Meta-attribute that resolves to actual attribute classes based on group. diff --git a/src/foundation/src/Testing/Attributes/DefineDatabase.php b/src/testbench/src/Attributes/DefineDatabase.php similarity index 80% rename from src/foundation/src/Testing/Attributes/DefineDatabase.php rename to src/testbench/src/Attributes/DefineDatabase.php index d8feec7e5..bd5147c67 100644 --- a/src/foundation/src/Testing/Attributes/DefineDatabase.php +++ b/src/testbench/src/Attributes/DefineDatabase.php @@ -2,14 +2,14 @@ declare(strict_types=1); -namespace Hypervel\Foundation\Testing\Attributes; +namespace Hypervel\Testbench\Attributes; use Attribute; use Closure; -use Hypervel\Foundation\Contracts\Application as ApplicationContract; -use Hypervel\Foundation\Testing\Contracts\Attributes\Actionable; -use Hypervel\Foundation\Testing\Contracts\Attributes\AfterEach; -use Hypervel\Foundation\Testing\Contracts\Attributes\BeforeEach; +use Hypervel\Contracts\Foundation\Application as ApplicationContract; +use Hypervel\Testbench\Contracts\Attributes\Actionable; +use Hypervel\Testbench\Contracts\Attributes\AfterEach; +use Hypervel\Testbench\Contracts\Attributes\BeforeEach; /** * Calls a test method for database setup with deferred execution support. diff --git a/src/foundation/src/Testing/Attributes/DefineEnvironment.php b/src/testbench/src/Attributes/DefineEnvironment.php similarity index 78% rename from src/foundation/src/Testing/Attributes/DefineEnvironment.php rename to src/testbench/src/Attributes/DefineEnvironment.php index 1ca822be4..b8c121a6a 100644 --- a/src/foundation/src/Testing/Attributes/DefineEnvironment.php +++ b/src/testbench/src/Attributes/DefineEnvironment.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Hypervel\Foundation\Testing\Attributes; +namespace Hypervel\Testbench\Attributes; use Attribute; use Closure; -use Hypervel\Foundation\Contracts\Application as ApplicationContract; -use Hypervel\Foundation\Testing\Contracts\Attributes\Actionable; +use Hypervel\Contracts\Foundation\Application as ApplicationContract; +use Hypervel\Testbench\Contracts\Attributes\Actionable; /** * Calls a test method with the application instance for environment setup. diff --git a/src/foundation/src/Testing/Attributes/DefineRoute.php b/src/testbench/src/Attributes/DefineRoute.php similarity index 79% rename from src/foundation/src/Testing/Attributes/DefineRoute.php rename to src/testbench/src/Attributes/DefineRoute.php index fd238b0d7..22b370304 100644 --- a/src/foundation/src/Testing/Attributes/DefineRoute.php +++ b/src/testbench/src/Attributes/DefineRoute.php @@ -2,13 +2,13 @@ declare(strict_types=1); -namespace Hypervel\Foundation\Testing\Attributes; +namespace Hypervel\Testbench\Attributes; use Attribute; use Closure; -use Hypervel\Foundation\Contracts\Application as ApplicationContract; -use Hypervel\Foundation\Testing\Contracts\Attributes\Actionable; +use Hypervel\Contracts\Foundation\Application as ApplicationContract; use Hypervel\Router\Router; +use Hypervel\Testbench\Contracts\Attributes\Actionable; /** * Calls a test method with the router instance for route definition. diff --git a/src/testbench/src/Attributes/RequiresDatabase.php b/src/testbench/src/Attributes/RequiresDatabase.php new file mode 100644 index 000000000..7151b36b4 --- /dev/null +++ b/src/testbench/src/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/testbench/src/Attributes/RequiresEnv.php similarity index 82% rename from src/foundation/src/Testing/Attributes/RequiresEnv.php rename to src/testbench/src/Attributes/RequiresEnv.php index 603a43044..b575c5ac5 100644 --- a/src/foundation/src/Testing/Attributes/RequiresEnv.php +++ b/src/testbench/src/Attributes/RequiresEnv.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Hypervel\Foundation\Testing\Attributes; +namespace Hypervel\Testbench\Attributes; use Attribute; use Closure; -use Hypervel\Foundation\Contracts\Application as ApplicationContract; -use Hypervel\Foundation\Testing\Contracts\Attributes\Actionable; +use Hypervel\Contracts\Foundation\Application as ApplicationContract; +use Hypervel\Testbench\Contracts\Attributes\Actionable; /** * Skips the test if the required environment variable is missing. diff --git a/src/foundation/src/Testing/Attributes/ResetRefreshDatabaseState.php b/src/testbench/src/Attributes/ResetRefreshDatabaseState.php similarity index 82% rename from src/foundation/src/Testing/Attributes/ResetRefreshDatabaseState.php rename to src/testbench/src/Attributes/ResetRefreshDatabaseState.php index 957370091..19c741f26 100644 --- a/src/foundation/src/Testing/Attributes/ResetRefreshDatabaseState.php +++ b/src/testbench/src/Attributes/ResetRefreshDatabaseState.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Hypervel\Foundation\Testing\Attributes; +namespace Hypervel\Testbench\Attributes; use Attribute; -use Hypervel\Foundation\Testing\Contracts\Attributes\AfterAll; -use Hypervel\Foundation\Testing\Contracts\Attributes\BeforeAll; use Hypervel\Foundation\Testing\RefreshDatabaseState; +use Hypervel\Testbench\Contracts\Attributes\AfterAll; +use Hypervel\Testbench\Contracts\Attributes\BeforeAll; /** * Resets the database state before and after all tests in a class. diff --git a/src/foundation/src/Testing/Attributes/WithConfig.php b/src/testbench/src/Attributes/WithConfig.php similarity index 75% rename from src/foundation/src/Testing/Attributes/WithConfig.php rename to src/testbench/src/Attributes/WithConfig.php index d4572496d..867bfe196 100644 --- a/src/foundation/src/Testing/Attributes/WithConfig.php +++ b/src/testbench/src/Attributes/WithConfig.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace Hypervel\Foundation\Testing\Attributes; +namespace Hypervel\Testbench\Attributes; use Attribute; -use Hypervel\Foundation\Contracts\Application as ApplicationContract; -use Hypervel\Foundation\Testing\Contracts\Attributes\Invokable; +use Hypervel\Contracts\Foundation\Application as ApplicationContract; +use Hypervel\Testbench\Contracts\Attributes\Invokable; /** * Sets a config value directly. diff --git a/src/testbench/src/Attributes/WithMigration.php b/src/testbench/src/Attributes/WithMigration.php new file mode 100644 index 000000000..19f9cfcb5 --- /dev/null +++ b/src/testbench/src/Attributes/WithMigration.php @@ -0,0 +1,55 @@ + + */ + public readonly array $types; + + /** + * @param string ...$types Migration types or paths to load + */ + public function __construct(string ...$types) + { + $this->types = (new Collection(count($types) > 0 ? $types : ['laravel'])) + ->transform(static fn (string $type): string => in_array($type, ['cache', 'queue', 'session']) ? 'laravel' : $type) + ->unique() + ->values() + ->all(); + } + + /** + * Handle the attribute. + */ + public function __invoke(ApplicationContract $app): mixed + { + /** @var array $paths */ + $paths = (new Collection($this->types)) + ->transform(static fn (string $type): string => default_migration_path($type !== 'laravel' ? $type : null)) + ->all(); + + load_migration_paths($app, $paths); + + return null; + } +} 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 index f9853e891..2522c38d8 100644 --- a/src/testbench/src/Concerns/CreatesApplication.php +++ b/src/testbench/src/Concerns/CreatesApplication.php @@ -4,7 +4,7 @@ namespace Hypervel\Testbench\Concerns; -use Hypervel\Foundation\Contracts\Application as ApplicationContract; +use Hypervel\Contracts\Foundation\Application as ApplicationContract; /** * Provides hooks for registering package service providers and aliases. diff --git a/src/testbench/src/Concerns/HandlesAssertions.php b/src/testbench/src/Concerns/HandlesAssertions.php new file mode 100644 index 000000000..b45a6401e --- /dev/null +++ b/src/testbench/src/Concerns/HandlesAssertions.php @@ -0,0 +1,41 @@ +markTestSkipped($message); + } + } + + /** + * Mark the test as skipped when condition is equivalent to true. + * + * @param null|bool|(Closure($this): bool) $condition + * @param mixed $condition + */ + protected function markTestSkippedWhen($condition, string $message): void + { + /* @phpstan-ignore argument.type */ + if (value($condition)) { + $this->markTestSkipped($message); + } + } +} diff --git a/src/foundation/src/Testing/Concerns/HandlesAttributes.php b/src/testbench/src/Concerns/HandlesAttributes.php similarity index 81% rename from src/foundation/src/Testing/Concerns/HandlesAttributes.php rename to src/testbench/src/Concerns/HandlesAttributes.php index eb9a05fbd..7bf3e442e 100644 --- a/src/foundation/src/Testing/Concerns/HandlesAttributes.php +++ b/src/testbench/src/Concerns/HandlesAttributes.php @@ -2,13 +2,13 @@ declare(strict_types=1); -namespace Hypervel\Foundation\Testing\Concerns; +namespace Hypervel\Testbench\Concerns; -use Hypervel\Foundation\Contracts\Application as ApplicationContract; -use Hypervel\Foundation\Testing\Contracts\Attributes\Actionable; -use Hypervel\Foundation\Testing\Contracts\Attributes\Invokable; -use Hypervel\Foundation\Testing\Features\FeaturesCollection; +use Hypervel\Contracts\Foundation\Application as ApplicationContract; use Hypervel\Support\Collection; +use Hypervel\Testbench\Contracts\Attributes\Actionable; +use Hypervel\Testbench\Contracts\Attributes\Invokable; +use Hypervel\Testbench\Features\FeaturesCollection; /** * Handles parsing and executing test method attributes. @@ -28,7 +28,7 @@ trait HandlesAttributes protected function parseTestMethodAttributes(ApplicationContract $app, string $attribute): FeaturesCollection { $attributes = $this->resolvePhpUnitAttributes() - ->filter(static fn ($attributes, string $key) => $key === $attribute && ! empty($attributes)) + ->filter(static fn ($attributes, string $key) => $key === $attribute && $attributes->isNotEmpty()) ->flatten() ->map(function ($instance) use ($app) { if ($instance instanceof Invokable) { diff --git a/src/testbench/src/Concerns/HandlesDatabases.php b/src/testbench/src/Concerns/HandlesDatabases.php index ed6d918e0..5d094c3e0 100644 --- a/src/testbench/src/Concerns/HandlesDatabases.php +++ b/src/testbench/src/Concerns/HandlesDatabases.php @@ -4,11 +4,45 @@ namespace Hypervel\Testbench\Concerns; +use Hypervel\Testbench\Attributes\RequiresDatabase; +use Hypervel\Testbench\Attributes\WithConfig; +use Hypervel\Testbench\Attributes\WithMigration; +use Hypervel\Testbench\Contracts\Attributes\Actionable; +use Hypervel\Testbench\Contracts\Attributes\Invokable; + /** * Provides hooks for defining database migrations and seeders. + * + * @property null|\Hypervel\Contracts\Foundation\Application $app */ trait HandlesDatabases { + /** + * Determine if using in-memory SQLite database connection. + */ + protected function usesSqliteInMemoryDatabaseConnection(?string $connection = null): bool + { + if ($this->app === null) { + return false; + } + + /** @var \Hypervel\Config\Repository $config */ + $config = $this->app->make('config'); + + $connection ??= $config->get('database.default'); + + /** @var null|array{driver: string, database: string} $database */ + $database = $config->get("database.connections.{$connection}"); + + if ($database === null || $database['driver'] !== 'sqlite') { + return false; + } + + return $database['database'] === ':memory:' + || str_contains($database['database'], '?mode=memory') + || str_contains($database['database'], '&mode=memory'); + } + /** * Define database migrations. */ @@ -43,9 +77,39 @@ protected function defineDatabaseMigrationsAfterDatabaseRefreshed(): void /** * Setup database requirements. + * + * Processes RequiresDatabase first (to skip early if wrong driver), + * then WithConfig and WithMigration attributes before running migrations, + * then executes the callback (which typically runs migrations), + * and finally runs seeders. */ protected function setUpDatabaseRequirements(callable $callback): void { + // Process RequiresDatabase FIRST - skip test early if wrong driver + // This must happen before any driver-specific schema operations + $this->resolvePhpUnitAttributes() + ->filter(static fn ($attrs, string $key) => $key === RequiresDatabase::class) + ->flatten() + ->filter(static fn ($instance) => $instance instanceof Actionable) + ->each(fn ($instance) => $instance->handle( + $this->app, + fn ($method, $parameters) => $this->{$method}(...$parameters) + )); + + // Process WithConfig attributes BEFORE database connections are established + $this->resolvePhpUnitAttributes() + ->filter(static fn ($attrs, string $key) => $key === WithConfig::class) + ->flatten() + ->filter(static fn ($instance) => $instance instanceof Invokable) + ->each(fn ($instance) => $instance($this->app)); + + // Process WithMigration attributes BEFORE migrations run + $this->resolvePhpUnitAttributes() + ->filter(static fn ($attrs, string $key) => $key === WithMigration::class) + ->flatten() + ->filter(static fn ($instance) => $instance instanceof Invokable) + ->each(fn ($instance) => $instance($this->app)); + $this->defineDatabaseMigrations(); $this->beforeApplicationDestroyed(fn () => $this->destroyDatabaseMigrations()); diff --git a/src/testbench/src/Concerns/HandlesRoutes.php b/src/testbench/src/Concerns/HandlesRoutes.php index 3cea8604f..79fa34a0d 100644 --- a/src/testbench/src/Concerns/HandlesRoutes.php +++ b/src/testbench/src/Concerns/HandlesRoutes.php @@ -4,7 +4,7 @@ namespace Hypervel\Testbench\Concerns; -use Hypervel\Foundation\Contracts\Application as ApplicationContract; +use Hypervel\Contracts\Foundation\Application as ApplicationContract; use Hypervel\Router\Router; use ReflectionMethod; diff --git a/src/testbench/src/Concerns/InteractsWithPublishedFiles.php b/src/testbench/src/Concerns/InteractsWithPublishedFiles.php new file mode 100644 index 000000000..3ec87d619 --- /dev/null +++ b/src/testbench/src/Concerns/InteractsWithPublishedFiles.php @@ -0,0 +1,263 @@ + + */ + protected ?array $cachedExistingMigrationsFiles = null; + + /** + * Setup Interacts with Published Files environment. + * + * @internal + */ + protected function setUpInteractsWithPublishedFiles(): void + { + $this->cacheExistingMigrationsFiles(); + + $this->cleanUpPublishedFiles(); + $this->cleanUpPublishedMigrationFiles(); + } + + /** + * Teardown Interacts with Published Files environment. + * + * @internal + */ + protected function tearDownInteractsWithPublishedFiles(): void + { + if ($this->interactsWithPublishedFilesTeardownRegistered === false) { + $this->cleanUpPublishedFiles(); + $this->cleanUpPublishedMigrationFiles(); + } + + $this->interactsWithPublishedFilesTeardownRegistered = true; + } + + /** + * Cache existing migration files. + * + * @internal + */ + protected function cacheExistingMigrationsFiles(): void + { + $this->cachedExistingMigrationsFiles ??= (new Collection( + $this->app['files']->files($this->app->databasePath('migrations')) + ))->filter(static fn ($file) => str_ends_with($file, '.php')) + ->all(); + } + + /** + * Assert file contains the given strings. + * + * @param array $contains + */ + protected function assertFileContains(array $contains, string $file, string $message = ''): void + { + $this->assertFilenameExists($file); + + $haystack = $this->app['files']->get( + $this->app->basePath($file) + ); + + foreach ($contains as $needle) { + $this->assertStringContainsString($needle, $haystack, $message); + } + } + + /** + * Assert file does not contain the given strings. + * + * @param array $contains + */ + protected function assertFileDoesNotContains(array $contains, string $file, string $message = ''): void + { + $this->assertFilenameExists($file); + + $haystack = $this->app['files']->get( + $this->app->basePath($file) + ); + + foreach ($contains as $needle) { + $this->assertStringNotContainsString($needle, $haystack, $message); + } + } + + /** + * Assert file does not contain the given strings. + * + * @param array $contains + */ + protected function assertFileNotContains(array $contains, string $file, string $message = ''): void + { + $this->assertFileDoesNotContains($contains, $file, $message); + } + + /** + * Assert migration file contains the given strings. + * + * @param array $contains + */ + protected function assertMigrationFileContains(array $contains, string $file, string $message = '', ?string $directory = null): void + { + $migrationFile = $this->findFirstPublishedMigrationFile($file, $directory); + + $this->assertTrue(! is_null($migrationFile), "Assert migration file {$file} does exist"); + + $haystack = $this->app['files']->get($migrationFile); + + foreach ($contains as $needle) { + $this->assertStringContainsString($needle, $haystack, $message); + } + } + + /** + * Assert migration file does not contain the given strings. + * + * @param array $contains + */ + protected function assertMigrationFileDoesNotContains(array $contains, string $file, string $message = '', ?string $directory = null): void + { + $migrationFile = $this->findFirstPublishedMigrationFile($file, $directory); + + $this->assertTrue(! is_null($migrationFile), "Assert migration file {$file} does exist"); + + $haystack = $this->app['files']->get($migrationFile); + + foreach ($contains as $needle) { + $this->assertStringNotContainsString($needle, $haystack, $message); + } + } + + /** + * Assert migration file does not contain the given strings. + * + * @param array $contains + */ + protected function assertMigrationFileNotContains(array $contains, string $file, string $message = '', ?string $directory = null): void + { + $this->assertMigrationFileDoesNotContains($contains, $file, $message, $directory); + } + + /** + * Assert filename exists. + */ + protected function assertFilenameExists(string $file): void + { + $appFile = $this->app->basePath($file); + + $this->assertTrue($this->app['files']->exists($appFile), "Assert file {$file} does exist"); + } + + /** + * Assert filename does not exist. + */ + protected function assertFilenameDoesNotExists(string $file): void + { + $appFile = $this->app->basePath($file); + + $this->assertTrue(! $this->app['files']->exists($appFile), "Assert file {$file} doesn't exist"); + } + + /** + * Assert filename does not exist. + */ + protected function assertFilenameNotExists(string $file): void + { + $this->assertFilenameDoesNotExists($file); + } + + /** + * Assert migration filename exists. + */ + protected function assertMigrationFileExists(string $file, ?string $directory = null): void + { + $migrationFile = $this->findFirstPublishedMigrationFile($file, $directory); + + $this->assertTrue(! is_null($migrationFile), "Assert migration file {$file} does exist"); + } + + /** + * Assert migration filename does not exist. + */ + protected function assertMigrationFileDoesNotExists(string $file, ?string $directory = null): void + { + $migrationFile = $this->findFirstPublishedMigrationFile($file, $directory); + + $this->assertTrue(is_null($migrationFile), "Assert migration file {$file} doesn't exist"); + } + + /** + * Assert migration filename does not exist. + */ + protected function assertMigrationFileNotExists(string $file, ?string $directory = null): void + { + $this->assertMigrationFileDoesNotExists($file, $directory); + } + + /** + * Removes generated files. + * + * @internal + */ + protected function cleanUpPublishedFiles(): void + { + $this->app['files']->delete( + (new Collection($this->files ?? [])) + ->transform(fn ($file) => $this->app->basePath($file)) + ->map(fn ($file) => str_contains($file, '*') ? [...$this->app['files']->glob($file)] : $file) + ->flatten() + ->filter(fn ($file) => $this->app['files']->exists($file)) + ->reject(static fn ($file) => str_ends_with($file, '.gitkeep') || str_ends_with($file, '.gitignore')) + ->all() + ); + } + + /** + * Find the first published migration file matching the filename. + */ + protected function findFirstPublishedMigrationFile(string $filename, ?string $directory = null): ?string + { + $migrationPath = ! is_null($directory) + ? $this->app->basePath($directory) + : $this->app->databasePath('migrations'); + + return $this->app['files']->glob(join_paths($migrationPath, "*{$filename}"))[0] ?? null; + } + + /** + * Removes generated migration files. + * + * @internal + */ + protected function cleanUpPublishedMigrationFiles(): void + { + $this->app['files']->delete( + (new Collection($this->app['files']->files($this->app->databasePath('migrations')))) + ->reject(fn ($file) => in_array($file, $this->cachedExistingMigrationsFiles)) + ->filter(static fn ($file) => str_ends_with($file, '.php')) + ->all() + ); + } +} diff --git a/src/foundation/src/Testing/Concerns/InteractsWithTestCase.php b/src/testbench/src/Concerns/InteractsWithTestCase.php similarity index 89% rename from src/foundation/src/Testing/Concerns/InteractsWithTestCase.php rename to src/testbench/src/Concerns/InteractsWithTestCase.php index b9def808a..5ba67fcbb 100644 --- a/src/foundation/src/Testing/Concerns/InteractsWithTestCase.php +++ b/src/testbench/src/Concerns/InteractsWithTestCase.php @@ -2,24 +2,25 @@ declare(strict_types=1); -namespace Hypervel\Foundation\Testing\Concerns; +namespace Hypervel\Testbench\Concerns; use Attribute; use Closure; -use Hypervel\Foundation\Testing\AttributeParser; -use Hypervel\Foundation\Testing\Contracts\Attributes\Actionable; -use Hypervel\Foundation\Testing\Contracts\Attributes\AfterAll; -use Hypervel\Foundation\Testing\Contracts\Attributes\AfterEach; -use Hypervel\Foundation\Testing\Contracts\Attributes\BeforeAll; -use Hypervel\Foundation\Testing\Contracts\Attributes\BeforeEach; -use Hypervel\Foundation\Testing\Contracts\Attributes\Invokable; -use Hypervel\Foundation\Testing\Contracts\Attributes\Resolvable; use Hypervel\Support\Collection; +use Hypervel\Testbench\Attributes\DefineEnvironment; +use Hypervel\Testbench\Contracts\Attributes\Actionable; +use Hypervel\Testbench\Contracts\Attributes\AfterAll; +use Hypervel\Testbench\Contracts\Attributes\AfterEach; +use Hypervel\Testbench\Contracts\Attributes\BeforeAll; +use Hypervel\Testbench\Contracts\Attributes\BeforeEach; +use Hypervel\Testbench\Contracts\Attributes\Invokable; +use Hypervel\Testbench\Contracts\Attributes\Resolvable; +use Hypervel\Testbench\PHPUnit\AttributeParser; /** * Provides test case lifecycle and attribute caching functionality. * - * @property null|\Hypervel\Foundation\Contracts\Application $app + * @property null|\Hypervel\Contracts\Foundation\Application $app */ trait InteractsWithTestCase { @@ -181,11 +182,13 @@ protected function setUpTheTestEnvironmentUsingTestCase(): void ->filter(static fn ($instance) => $instance instanceof Invokable) ->each(fn ($instance) => $instance($this->app)); - // Execute Actionable attributes (like DefineEnvironment, DefineRoute, DefineDatabase) + // Execute Actionable attributes (like DefineRoute, DefineDatabase) + // DefineEnvironment is excluded - it runs earlier in defineEnvironment() + // before providers boot, so database config can be set before connections pool. // 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) + ->filter(static fn ($instance) => $instance instanceof Actionable && ! $instance instanceof DefineEnvironment) ->each(function ($instance): void { $result = $instance->handle( $this->app, diff --git a/src/testbench/src/ConfigProviderRegister.php b/src/testbench/src/ConfigProviderRegister.php index 94cb4f3a3..981cb8580 100644 --- a/src/testbench/src/ConfigProviderRegister.php +++ b/src/testbench/src/ConfigProviderRegister.php @@ -4,17 +4,16 @@ 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, - \Hyperf\Engine\ConfigProvider::class, + \Hypervel\Engine\ConfigProvider::class, \Hyperf\Event\ConfigProvider::class, \Hyperf\ExceptionHandler\ConfigProvider::class, \Hyperf\Framework\ConfigProvider::class, @@ -22,14 +21,12 @@ 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/foundation/src/Testing/Contracts/Attributes/Actionable.php b/src/testbench/src/Contracts/Attributes/Actionable.php similarity index 74% rename from src/foundation/src/Testing/Contracts/Attributes/Actionable.php rename to src/testbench/src/Contracts/Attributes/Actionable.php index 3f22166fa..b3f1e80c6 100644 --- a/src/foundation/src/Testing/Contracts/Attributes/Actionable.php +++ b/src/testbench/src/Contracts/Attributes/Actionable.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace Hypervel\Foundation\Testing\Contracts\Attributes; +namespace Hypervel\Testbench\Contracts\Attributes; use Closure; -use Hypervel\Foundation\Contracts\Application as ApplicationContract; +use Hypervel\Contracts\Foundation\Application as ApplicationContract; /** * Interface for attributes that handle actions via a callback. diff --git a/src/foundation/src/Testing/Contracts/Attributes/AfterAll.php b/src/testbench/src/Contracts/Attributes/AfterAll.php similarity index 79% rename from src/foundation/src/Testing/Contracts/Attributes/AfterAll.php rename to src/testbench/src/Contracts/Attributes/AfterAll.php index f82834655..66fa964a4 100644 --- a/src/foundation/src/Testing/Contracts/Attributes/AfterAll.php +++ b/src/testbench/src/Contracts/Attributes/AfterAll.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Hypervel\Foundation\Testing\Contracts\Attributes; +namespace Hypervel\Testbench\Contracts\Attributes; /** * Interface for attributes that run after all tests in a class. diff --git a/src/foundation/src/Testing/Contracts/Attributes/AfterEach.php b/src/testbench/src/Contracts/Attributes/AfterEach.php similarity index 67% rename from src/foundation/src/Testing/Contracts/Attributes/AfterEach.php rename to src/testbench/src/Contracts/Attributes/AfterEach.php index 6f5a8ed48..c9fb283b1 100644 --- a/src/foundation/src/Testing/Contracts/Attributes/AfterEach.php +++ b/src/testbench/src/Contracts/Attributes/AfterEach.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Hypervel\Foundation\Testing\Contracts\Attributes; +namespace Hypervel\Testbench\Contracts\Attributes; -use Hypervel\Foundation\Contracts\Application as ApplicationContract; +use Hypervel\Contracts\Foundation\Application as ApplicationContract; /** * Interface for attributes that run after each test. diff --git a/src/foundation/src/Testing/Contracts/Attributes/BeforeAll.php b/src/testbench/src/Contracts/Attributes/BeforeAll.php similarity index 79% rename from src/foundation/src/Testing/Contracts/Attributes/BeforeAll.php rename to src/testbench/src/Contracts/Attributes/BeforeAll.php index 61cb82cd6..12b60433e 100644 --- a/src/foundation/src/Testing/Contracts/Attributes/BeforeAll.php +++ b/src/testbench/src/Contracts/Attributes/BeforeAll.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Hypervel\Foundation\Testing\Contracts\Attributes; +namespace Hypervel\Testbench\Contracts\Attributes; /** * Interface for attributes that run before all tests in a class. diff --git a/src/foundation/src/Testing/Contracts/Attributes/BeforeEach.php b/src/testbench/src/Contracts/Attributes/BeforeEach.php similarity index 67% rename from src/foundation/src/Testing/Contracts/Attributes/BeforeEach.php rename to src/testbench/src/Contracts/Attributes/BeforeEach.php index 1cd60b573..fc4e74853 100644 --- a/src/foundation/src/Testing/Contracts/Attributes/BeforeEach.php +++ b/src/testbench/src/Contracts/Attributes/BeforeEach.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Hypervel\Foundation\Testing\Contracts\Attributes; +namespace Hypervel\Testbench\Contracts\Attributes; -use Hypervel\Foundation\Contracts\Application as ApplicationContract; +use Hypervel\Contracts\Foundation\Application as ApplicationContract; /** * Interface for attributes that run before each test. diff --git a/src/foundation/src/Testing/Contracts/Attributes/Invokable.php b/src/testbench/src/Contracts/Attributes/Invokable.php similarity index 67% rename from src/foundation/src/Testing/Contracts/Attributes/Invokable.php rename to src/testbench/src/Contracts/Attributes/Invokable.php index 591a897ee..2045c7f09 100644 --- a/src/foundation/src/Testing/Contracts/Attributes/Invokable.php +++ b/src/testbench/src/Contracts/Attributes/Invokable.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Hypervel\Foundation\Testing\Contracts\Attributes; +namespace Hypervel\Testbench\Contracts\Attributes; -use Hypervel\Foundation\Contracts\Application as ApplicationContract; +use Hypervel\Contracts\Foundation\Application as ApplicationContract; /** * Interface for attributes that are directly invokable. diff --git a/src/foundation/src/Testing/Contracts/Attributes/Resolvable.php b/src/testbench/src/Contracts/Attributes/Resolvable.php similarity index 79% rename from src/foundation/src/Testing/Contracts/Attributes/Resolvable.php rename to src/testbench/src/Contracts/Attributes/Resolvable.php index 8d786938a..d37ad8ea1 100644 --- a/src/foundation/src/Testing/Contracts/Attributes/Resolvable.php +++ b/src/testbench/src/Contracts/Attributes/Resolvable.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Hypervel\Foundation\Testing\Contracts\Attributes; +namespace Hypervel\Testbench\Contracts\Attributes; /** * Interface for meta-attributes that resolve to actual attribute classes. diff --git a/src/foundation/src/Testing/Contracts/Attributes/TestingFeature.php b/src/testbench/src/Contracts/Attributes/TestingFeature.php similarity index 67% rename from src/foundation/src/Testing/Contracts/Attributes/TestingFeature.php rename to src/testbench/src/Contracts/Attributes/TestingFeature.php index 4d06be9a2..dee3d4372 100644 --- a/src/foundation/src/Testing/Contracts/Attributes/TestingFeature.php +++ b/src/testbench/src/Contracts/Attributes/TestingFeature.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Hypervel\Foundation\Testing\Contracts\Attributes; +namespace Hypervel\Testbench\Contracts\Attributes; /** * Marker interface for testing feature attributes. diff --git a/src/testbench/src/Factories/UserFactory.php b/src/testbench/src/Factories/UserFactory.php new file mode 100644 index 000000000..f9277cf7e --- /dev/null +++ b/src/testbench/src/Factories/UserFactory.php @@ -0,0 +1,61 @@ + + * + * @property null|class-string<\Hypervel\Database\Eloquent\Model|TModel> $model + */ +class UserFactory extends Factory +{ + /** + * The current password being used by the factory. + */ + protected static ?string $password = null; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'name' => fake()->name(), + 'email' => fake()->unique()->safeEmail(), + 'email_verified_at' => now(), + 'password' => static::$password ??= Hash::make('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, + ]); + } + + /** + * Get the name of the model that is generated by the factory. + * + * @return class-string<\Hypervel\Database\Eloquent\Model|TModel> + */ + public function modelName(): string + { + return $this->model ?? config('auth.providers.users.model') ?? User::class; + } +} diff --git a/src/foundation/src/Testing/Features/FeaturesCollection.php b/src/testbench/src/Features/FeaturesCollection.php similarity index 90% rename from src/foundation/src/Testing/Features/FeaturesCollection.php rename to src/testbench/src/Features/FeaturesCollection.php index 175620be0..d865aa34b 100644 --- a/src/foundation/src/Testing/Features/FeaturesCollection.php +++ b/src/testbench/src/Features/FeaturesCollection.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Hypervel\Foundation\Testing\Features; +namespace Hypervel\Testbench\Features; use Closure; use Hypervel\Support\Collection; diff --git a/src/testbench/src/Foundation/Process/ProcessDecorator.php b/src/testbench/src/Foundation/Process/ProcessDecorator.php new file mode 100644 index 000000000..d599b21f9 --- /dev/null +++ b/src/testbench/src/Foundation/Process/ProcessDecorator.php @@ -0,0 +1,49 @@ +|Closure|string $command The original command + */ + public function __construct( + protected Process $process, + protected Closure|array|string $command, + ) { + } + + /** + * Handle dynamic calls to the process instance. + * + * @return $this|ProcessResult + */ + public function __call(string $method, array $parameters): mixed + { + $response = $this->forwardDecoratedCallTo($this->process, $method, $parameters); + + if ($response instanceof self && $this->process->isTerminated()) { + return new ProcessResult($this->process, $this->command); + } + + return $response; + } +} diff --git a/src/testbench/src/Foundation/Process/ProcessResult.php b/src/testbench/src/Foundation/Process/ProcessResult.php new file mode 100644 index 000000000..fdcfcda29 --- /dev/null +++ b/src/testbench/src/Foundation/Process/ProcessResult.php @@ -0,0 +1,61 @@ + + */ + protected array $passthru = [ + 'getCommandLine', + 'getErrorOutput', + 'getExitCode', + 'getOutput', + 'isSuccessful', + ]; + + /** + * Create a new process result instance. + * + * @param Process $process The underlying Symfony process + * @param array|Closure|string $command The original command + */ + public function __construct( + Process $process, + protected Closure|array|string $command, + ) { + parent::__construct($process); + } + + /** + * Handle dynamic calls to the process instance. + * + * @throws BadMethodCallException + */ + public function __call(string $method, array $parameters): mixed + { + if (! in_array($method, $this->passthru)) { + self::throwBadMethodCallException($method); + } + + return $this->forwardDecoratedCallTo($this->process, $method, $parameters); + } +} diff --git a/src/testbench/src/Foundation/Process/RemoteCommand.php b/src/testbench/src/Foundation/Process/RemoteCommand.php new file mode 100644 index 000000000..ef1733ad3 --- /dev/null +++ b/src/testbench/src/Foundation/Process/RemoteCommand.php @@ -0,0 +1,74 @@ +|string $env Environment variables or APP_ENV value + * @param null|bool $tty Whether to enable TTY mode + */ + public function __construct( + public string $workingPath, + public array|string $env = [], + public ?bool $tty = null, + ) { + } + + /** + * Execute the command. + * + * @param string $commander The testbench binary path + * @param array|Closure|string $command The command(s) to run + */ + public function handle(string $commander, Closure|array|string $command): ProcessDecorator + { + $env = is_string($this->env) ? ['APP_ENV' => $this->env] : $this->env; + + $env['TESTBENCH_PACKAGE_REMOTE'] = '(true)'; + + // Closure commands require SerializableClosure - not implemented yet + if ($command instanceof Closure) { + throw new RuntimeException( + 'Closure commands are not yet supported by remote(). Use string commands instead.' + ); + } + + $commands = Arr::wrap($command); + + $process = Process::fromShellCommandline( + command: Arr::join([ + ProcessUtils::escapeArgument(php_binary()), + ProcessUtils::escapeArgument($commander), + ...$commands, + ], ' '), + cwd: $this->workingPath, + env: array_merge(defined_environment_variables(), $env) + ); + + if (is_bool($this->tty)) { + $process->setTty($this->tty); + } + + return new ProcessDecorator($process, $command); + } +} diff --git a/src/foundation/src/Testing/AttributeParser.php b/src/testbench/src/PHPUnit/AttributeParser.php similarity index 94% rename from src/foundation/src/Testing/AttributeParser.php rename to src/testbench/src/PHPUnit/AttributeParser.php index 5664bc305..abb63a8a1 100644 --- a/src/foundation/src/Testing/AttributeParser.php +++ b/src/testbench/src/PHPUnit/AttributeParser.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace Hypervel\Foundation\Testing; +namespace Hypervel\Testbench\PHPUnit; -use Hypervel\Foundation\Testing\Contracts\Attributes\Resolvable; -use Hypervel\Foundation\Testing\Contracts\Attributes\TestingFeature; +use Hypervel\Testbench\Contracts\Attributes\Resolvable; +use Hypervel\Testbench\Contracts\Attributes\TestingFeature; use PHPUnit\Framework\TestCase; use ReflectionAttribute; use ReflectionClass; diff --git a/src/testbench/src/TestCase.php b/src/testbench/src/TestCase.php index df85d6213..4c90186a0 100644 --- a/src/testbench/src/TestCase.php +++ b/src/testbench/src/TestCase.php @@ -4,30 +4,47 @@ namespace Hypervel\Testbench; -use Hyperf\Context\ApplicationContext; -use Hyperf\Coordinator\Constants; -use Hyperf\Coordinator\CoordinatorManager; +use Hypervel\Context\ApplicationContext; +use Hypervel\Contracts\Console\Kernel as KernelContract; +use Hypervel\Contracts\Debug\ExceptionHandler as ExceptionHandlerContract; +use Hypervel\Contracts\Foundation\Application as ApplicationContract; +use Hypervel\Coordinator\Constants; +use Hypervel\Coordinator\CoordinatorManager; 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\DatabaseMigrations; +use Hypervel\Foundation\Testing\DatabaseTransactions; +use Hypervel\Foundation\Testing\RefreshDatabase; use Hypervel\Foundation\Testing\TestCase as BaseTestCase; +use Hypervel\Foundation\Testing\WithoutEvents; +use Hypervel\Foundation\Testing\WithoutMiddleware; use Hypervel\Queue\Queue; +use Hypervel\Testbench\Attributes\DefineEnvironment; +use Hypervel\Testbench\Concerns\HandlesAttributes; +use Hypervel\Testbench\Concerns\InteractsWithTestCase; +use Hypervel\Testbench\Contracts\Attributes\Actionable; use Swoole\Timer; use Workbench\App\Exceptions\ExceptionHandler; /** * Base test case for package testing with testbench features. * + * Methods below are provided by traits that child test classes may use. + * The setUpTraits() method checks for trait usage before calling these. + * + * @method void refreshDatabase() + * @method void runDatabaseMigrations() + * @method void beginDatabaseTransaction() + * @method void disableMiddlewareForAllTests() + * @method void disableEventsForAllTests() + * * @internal * @coversNothing */ class TestCase extends BaseTestCase { use Concerns\CreatesApplication; + use Concerns\HandlesAssertions; use Concerns\HandlesDatabases; use Concerns\HandlesRoutes; use HandlesAttributes; @@ -59,13 +76,79 @@ protected function setUp(): void /** * Define environment setup. + * + * DefineEnvironment attributes are processed here (before providers boot) + * so they can set database config and other settings that must be configured + * before connections are established. In Swoole, once connections are pooled, + * you cannot change the underlying config. */ protected function defineEnvironment(ApplicationContract $app): void { + // Process DefineEnvironment attributes at the same time as the method override + // This must happen BEFORE providers boot so database config can be set + $this->resolvePhpUnitAttributes() + ->filter(static fn ($attrs, string $key) => $key === DefineEnvironment::class) + ->flatten() + ->filter(static fn ($instance) => $instance instanceof Actionable) + ->each(fn ($instance) => $instance->handle( + $app, + fn ($method, $parameters) => $this->{$method}(...$parameters) + )); + $this->registerPackageProviders($app); $this->registerPackageAliases($app); } + /** + * Boot the testing helper traits. + * + * Overrides Foundation's setUpTraits to wrap database operations + * in setUpDatabaseRequirements(), ensuring WithMigration attributes + * are processed before migrations run. + * + * @return array + */ + protected function setUpTraits(): array + { + $uses = array_flip(class_uses_recursive(static::class)); + + // Wrap database-related trait setup in setUpDatabaseRequirements + // so WithMigration attributes are processed BEFORE migrations run + $this->setUpDatabaseRequirements(function () use ($uses): void { + if (isset($uses[RefreshDatabase::class])) { + $this->refreshDatabase(); + } + + if (isset($uses[DatabaseMigrations::class])) { + $this->runDatabaseMigrations(); + } + }); + + if (isset($uses[DatabaseTransactions::class])) { + $this->beginDatabaseTransaction(); + } + + if (isset($uses[WithoutMiddleware::class])) { + $this->disableMiddlewareForAllTests(); + } + + if (isset($uses[WithoutEvents::class])) { + $this->disableEventsForAllTests(); + } + + foreach ($uses as $trait) { + if (method_exists($this, $method = 'setUp' . class_basename($trait))) { + $this->{$method}(); + } + + if (method_exists($this, $method = 'tearDown' . class_basename($trait))) { + $this->beforeApplicationDestroyed(fn () => $this->{$method}()); + } + } + + return $uses; + } + protected function createApplication(): ApplicationContract { $app = new Application(); diff --git a/src/testbench/src/functions.php b/src/testbench/src/functions.php new file mode 100644 index 000000000..fb4a3dae3 --- /dev/null +++ b/src/testbench/src/functions.php @@ -0,0 +1,183 @@ +afterResolving($name, $callback); + + if ($app->resolved($name)) { + value($callback, $app->get($name), $app); + } +} + +/** + * Load migration paths. + * + * Registers the given paths with the migrator so they're included when running migrations. + * + * @param array|string $paths + */ +function load_migration_paths(ApplicationContract $app, array|string $paths): void +{ + after_resolving($app, Migrator::class, static function (Migrator $migrator) use ($paths): void { + foreach (Arr::wrap($paths) as $path) { + $migrator->path($path); + } + }); +} + +/** + * Get the path to the default skeleton application. + * + * Returns the path to the workbench app used for testing. + * + * @param array|string $path + */ +function default_skeleton_path(array|string $path = ''): string|false +{ + return realpath( + join_paths(dirname(__DIR__), 'workbench', ...Arr::wrap(func_num_args() > 1 ? func_get_args() : $path)) + ); +} + +/** + * Get the migration path by type. + * + * Returns the path to framework test migrations in the testbench package. + * These are separate from the workbench app's migrations (which use database_path()). + * + * @throws InvalidArgumentException + */ +function default_migration_path(?string $type = null): string +{ + // Migrations live at testbench/migrations/, parallel to testbench/workbench/ + // This mirrors Laravel's testbench-core/laravel/migrations/ structure + $basePath = dirname(__DIR__) . '/migrations'; + + $path = realpath( + is_null($type) + ? $basePath + : join_paths($basePath, $type) + ); + + if ($path === false) { + throw new InvalidArgumentException( + sprintf('Unable to resolve migration path for type [%s]', $type ?? 'laravel') + ); + } + + return $path; +} + +/** + * Join the given paths together. + */ +function join_paths(?string $basePath, string ...$paths): string +{ + foreach ($paths as $index => $path) { + if (empty($path)) { + unset($paths[$index]); + } else { + $paths[$index] = DIRECTORY_SEPARATOR . ltrim($path, DIRECTORY_SEPARATOR); + } + } + + return $basePath . implode('', $paths); +} + +/** + * Get the path to the package folder. + * + * @param array|string ...$path + */ +function package_path(array|string $path = ''): string +{ + $workingPath = defined('TESTBENCH_WORKING_PATH') + ? TESTBENCH_WORKING_PATH + : getcwd(); + + $path = join_paths(null, ...Arr::wrap(func_num_args() > 1 ? func_get_args() : $path)); + + return join_paths(rtrim($workingPath, DIRECTORY_SEPARATOR), ltrim($path, DIRECTORY_SEPARATOR)); +} + +/** + * Get defined environment variables to pass to subprocess. + * + * Filters out non-scalar values (arrays, objects) since environment + * variables must be strings. This prevents "Array to string conversion" + * errors when tests pollute $_SERVER with array values. + * + * @return array + */ +function defined_environment_variables(): array +{ + return (new Collection(array_merge($_SERVER, $_ENV))) + ->keys() + ->mapWithKeys(static fn (string $key) => [$key => $_ENV[$key] ?? $_SERVER[$key] ?? null]) + ->filter(static fn ($value) => $value === null || is_scalar($value)) + ->when( + ! defined('TESTBENCH_WORKING_PATH'), + static fn (Collection $env) => $env->put('TESTBENCH_WORKING_PATH', package_path()) + )->all(); +} + +/** + * Determine the PHP binary. + */ +function php_binary(bool $escape = false): string +{ + $phpBinary = support_php_binary(); + + return $escape ? ProcessUtils::escapeArgument($phpBinary) : $phpBinary; +} + +/** + * Run remote action using Testbench CLI. + * + * Spawns a subprocess to run a console command, useful for testing scenarios + * that require process isolation (e.g., queue workers with job timeouts). + * + * @param array|Closure|string $command The command to run + * @param array|string $env Environment variables or APP_ENV value + * @param null|bool $tty Whether to enable TTY mode + */ +function remote(Closure|array|string $command, array|string $env = [], ?bool $tty = null): ProcessDecorator +{ + $remote = new RemoteCommand(package_path(), $env, $tty); + + // Look for testbench binary in order of preference: + // 1. vendor/bin/testbench (installed as dependency) + // 2. src/testbench/bin/testbench (monorepo structure) + // 3. Fall back to 'testbench' in PATH + $vendorBinary = package_path('vendor', 'bin', 'testbench'); + $srcBinary = package_path('src', 'testbench', 'bin', 'testbench'); + + $commander = match (true) { + is_file($vendorBinary) => $vendorBinary, + is_file($srcBinary) => $srcBinary, + default => 'testbench', + }; + + return $remote->handle($commander, $command); +} diff --git a/src/testbench/testbench.yaml b/src/testbench/testbench.yaml index 415866b03..d0c4a4ee6 100644 --- a/src/testbench/testbench.yaml +++ b/src/testbench/testbench.yaml @@ -12,8 +12,10 @@ workbench: purge: files: + # Do not purge composer.lock or composer.json: + # provider discovery reads Composer metadata during bootstrap, and remote() + # subprocess cleanup must not delete files required by the parent test process. - .env - - composer.lock directories: - runtime - public/vendor diff --git a/src/testbench/workbench/app/Models/User.php b/src/testbench/workbench/app/Models/User.php deleted file mode 100644 index 3c928ac32..000000000 --- a/src/testbench/workbench/app/Models/User.php +++ /dev/null @@ -1,20 +0,0 @@ - env('APP_DEBUG', true), + /* + |-------------------------------------------------------------------------- + | Encryption Key + |-------------------------------------------------------------------------- + | + | This key is used by the encryption service and should be set + | to a random, 32 character string to ensure that all encrypted values + | are secure. You should do this prior to deploying the application. + | + */ + + 'cipher' => 'AES-256-CBC', + + 'key' => env('APP_KEY', 'AckfSECXIvnK5r28GVIWUAxmbBSjTsmF'), + + 'previous_keys' => [ + ...array_filter( + explode(',', env('APP_PREVIOUS_KEYS', '')) + ), + ], + /* |-------------------------------------------------------------------------- | Cacheable Flag for Annotations Scanning diff --git a/src/testbench/workbench/config/database.php b/src/testbench/workbench/config/database.php index b8ab87bd5..ba99f2c0d 100644 --- a/src/testbench/workbench/config/database.php +++ b/src/testbench/workbench/config/database.php @@ -5,28 +5,14 @@ use Hypervel\Support\Str; return [ - /* - |-------------------------------------------------------------------------- - | Default Database Connection Name - |-------------------------------------------------------------------------- - | - | Here you may specify which of the database connections below you wish - | to use as your default connection for database operations. This is - | the connection which will be utilized unless another connection - | is explicitly specified when you execute a query / statement. - | - */ - 'default' => env('DB_CONNECTION', 'sqlite'), - 'connections' => [ 'sqlite' => [ 'driver' => 'sqlite', - 'database' => ':memory:', + 'database' => env('DB_DATABASE', ':memory:'), 'prefix' => '', 'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true), ], - 'mysql' => [ 'driver' => 'mysql', 'host' => env('DB_HOST', '127.0.0.1'), @@ -34,38 +20,44 @@ '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' => '', + 'prefix_indexes' => true, + 'search_path' => 'public', + 'sslmode' => 'prefer', + ], ], - /* - |-------------------------------------------------------------------------- - | Migration Repository Table - |-------------------------------------------------------------------------- - | - | This table keeps track of all the migrations that have already run for - | your application. Using this information, we can determine which of - | the migrations on disk haven't actually been run on the database. - | - */ - 'migrations' => 'migrations', - /* - |-------------------------------------------------------------------------- - | Redis Databases - |-------------------------------------------------------------------------- - | - | Redis is an open source, fast, and advanced key-value store that also - | provides a richer body of commands than a typical key-value system - | such as Memcached. You may define your connection settings here. - | - */ - 'redis' => [ 'options' => [ 'prefix' => env('REDIS_PREFIX', Str::slug(env('APP_NAME', 'hypervel'), '_') . '_database_'), diff --git a/src/testbench/workbench/config/queue.php b/src/testbench/workbench/config/queue.php index 4e5190d58..173c0664b 100644 --- a/src/testbench/workbench/config/queue.php +++ b/src/testbench/workbench/config/queue.php @@ -8,6 +8,15 @@ 'sync' => [ 'driver' => 'sync', ], + + 'database' => [ + 'driver' => 'database', + 'connection' => env('DB_QUEUE_CONNECTION'), + 'table' => env('DB_QUEUE_TABLE', 'jobs'), + 'queue' => env('DB_QUEUE', 'default'), + 'retry_after' => (int) env('DB_QUEUE_RETRY_AFTER', 90), + 'after_commit' => false, + ], ], 'batching' => [ 'database' => env('DB_CONNECTION', 'sqlite'), diff --git a/src/testbench/workbench/database/factories/.gitkeep b/src/testbench/workbench/database/factories/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/src/testbench/workbench/database/factories/UserFactory.php b/src/testbench/workbench/database/factories/UserFactory.php deleted file mode 100644 index f000034ce..000000000 --- a/src/testbench/workbench/database/factories/UserFactory.php +++ /dev/null @@ -1,17 +0,0 @@ -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 - ]; -}); diff --git a/src/testbench/workbench/database/migrations/.gitkeep b/src/testbench/workbench/database/migrations/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/src/testbench/workbench/database/seeders/.gitkeep b/src/testbench/workbench/database/seeders/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/src/testing/LICENSE.md b/src/testing/LICENSE.md new file mode 100644 index 000000000..09cec3ed7 --- /dev/null +++ b/src/testing/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/testing/README.md b/src/testing/README.md new file mode 100644 index 000000000..dc387b634 --- /dev/null +++ b/src/testing/README.md @@ -0,0 +1,4 @@ +Testing for Hypervel +=== + +[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/hypervel/testing) diff --git a/src/testing/composer.json b/src/testing/composer.json new file mode 100644 index 000000000..9eb660f23 --- /dev/null +++ b/src/testing/composer.json @@ -0,0 +1,43 @@ +{ + "name": "hypervel/testing", + "type": "library", + "description": "The testing package for Hypervel.", + "license": "MIT", + "keywords": [ + "php", + "hyperf", + "testing", + "swoole", + "hypervel" + ], + "authors": [ + { + "name": "Albert Chen", + "email": "albert@hypervel.org" + }, + { + "name": "Raj Siva-Rajah", + "homepage": "https://github.com/binaryfire" + } + ], + "support": { + "issues": "https://github.com/hypervel/components/issues", + "source": "https://github.com/hypervel/components" + }, + "autoload": { + "psr-4": { + "Hypervel\\Testing\\": "src/" + } + }, + "require": { + "php": "^8.4" + }, + "config": { + "sort-packages": true + }, + "extra": { + "branch-alias": { + "dev-main": "0.4-dev" + } + } +} diff --git a/src/testing/src/Assert.php b/src/testing/src/Assert.php new file mode 100644 index 000000000..821834814 --- /dev/null +++ b/src/testing/src/Assert.php @@ -0,0 +1,29 @@ +strict = $strict; + $this->subset = $subset; + } + + /** + * Evaluates the constraint for parameter $other. + * + * If $returnResult is set to false (the default), an exception is thrown + * in case of a failure. null is returned otherwise. + * + * If $returnResult is true, the result of the evaluation is returned as + * a boolean value instead: true in case of success, false in case of a + * failure. + */ + public function evaluate(mixed $other, string $description = '', bool $returnResult = false): ?bool + { + // type cast $other & $this->subset as an array to allow + // support in standard array functions. + $other = $this->toArray($other); + $this->subset = $this->toArray($this->subset); + + $patched = array_replace_recursive($other, $this->subset); + + if ($this->strict) { + $result = $other === $patched; + } else { + $result = $other == $patched; + } + + if ($returnResult) { + return $result; + } + + if (! $result) { + $f = new ComparisonFailure( + $patched, + $other, + var_export($patched, true), + var_export($other, true) + ); + + $this->fail($other, $description, $f); + } + + return null; + } + + /** + * Returns a string representation of the constraint. + */ + public function toString(): string + { + return 'has the subset ' . (new Exporter())->export($this->subset); + } + + /** + * Returns the description of the failure. + * + * The beginning of failure messages is "Failed asserting that" in most + * cases. This method should return the second part of that sentence. + */ + protected function failureDescription(mixed $other): string + { + return 'an array ' . $this->toString(); + } + + /** + * Convert an iterable to an array. + */ + protected function toArray(iterable $other): array + { + if (is_array($other)) { + return $other; + } + + if ($other instanceof ArrayObject) { + return $other->getArrayCopy(); + } + + // iterable that isn't array or ArrayObject must be Traversable + return iterator_to_array($other); + } +} diff --git a/src/foundation/src/Testing/Constraints/CountInDatabase.php b/src/testing/src/Constraints/CountInDatabase.php similarity index 94% rename from src/foundation/src/Testing/Constraints/CountInDatabase.php rename to src/testing/src/Constraints/CountInDatabase.php index c283cf3e8..1e689f083 100644 --- a/src/foundation/src/Testing/Constraints/CountInDatabase.php +++ b/src/testing/src/Constraints/CountInDatabase.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Hypervel\Foundation\Testing\Constraints; +namespace Hypervel\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/testing/src/Constraints/HasInDatabase.php similarity index 88% rename from src/foundation/src/Testing/Constraints/HasInDatabase.php rename to src/testing/src/Constraints/HasInDatabase.php index ed8a42445..0e23e6b44 100644 --- a/src/foundation/src/Testing/Constraints/HasInDatabase.php +++ b/src/testing/src/Constraints/HasInDatabase.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace Hypervel\Foundation\Testing\Constraints; +namespace Hypervel\Testing\Constraints; -use Hyperf\Database\Query\Expression; -use Hyperf\DbConnection\Connection; +use Hypervel\Contracts\Database\Query\Expression; +use Hypervel\Database\Connection; use PHPUnit\Framework\Constraint\Constraint; class HasInDatabase extends Constraint @@ -93,11 +93,9 @@ protected function getAdditionalInfo($table) public function toString($options = 0): string { foreach ($this->data as $key => $data) { - $output[$key] = $data instanceof Expression ? (string) $data : $data; + $output[$key] = $data instanceof Expression ? $data->getValue($this->database->getQueryGrammar()) : $data; } - // since phpunit 10 it will pass options in boolean - // we need to cast it to int return json_encode($output ?? [], (int) $options); } } diff --git a/src/foundation/src/Testing/Constraints/NotSoftDeletedInDatabase.php b/src/testing/src/Constraints/NotSoftDeletedInDatabase.php similarity index 95% rename from src/foundation/src/Testing/Constraints/NotSoftDeletedInDatabase.php rename to src/testing/src/Constraints/NotSoftDeletedInDatabase.php index 57e7e4931..3c34b2c3d 100644 --- a/src/foundation/src/Testing/Constraints/NotSoftDeletedInDatabase.php +++ b/src/testing/src/Constraints/NotSoftDeletedInDatabase.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Hypervel\Foundation\Testing\Constraints; +namespace Hypervel\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/SeeInOrder.php b/src/testing/src/Constraints/SeeInOrder.php similarity index 97% rename from src/foundation/src/Testing/Constraints/SeeInOrder.php rename to src/testing/src/Constraints/SeeInOrder.php index 5a85a8415..0bb6c1c4a 100644 --- a/src/foundation/src/Testing/Constraints/SeeInOrder.php +++ b/src/testing/src/Constraints/SeeInOrder.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Hypervel\Foundation\Testing\Constraints; +namespace Hypervel\Testing\Constraints; use PHPUnit\Framework\Constraint\Constraint; use ReflectionClass; diff --git a/src/foundation/src/Testing/Constraints/SoftDeletedInDatabase.php b/src/testing/src/Constraints/SoftDeletedInDatabase.php similarity index 95% rename from src/foundation/src/Testing/Constraints/SoftDeletedInDatabase.php rename to src/testing/src/Constraints/SoftDeletedInDatabase.php index 69b1bd0b3..47073c86c 100644 --- a/src/foundation/src/Testing/Constraints/SoftDeletedInDatabase.php +++ b/src/testing/src/Constraints/SoftDeletedInDatabase.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Hypervel\Foundation\Testing\Constraints; +namespace Hypervel\Testing\Constraints; -use Hyperf\DbConnection\Connection; +use Hypervel\Database\Connection; use PHPUnit\Framework\Constraint\Constraint; class SoftDeletedInDatabase extends Constraint diff --git a/src/testing/src/Exceptions/InvalidArgumentException.php b/src/testing/src/Exceptions/InvalidArgumentException.php new file mode 100644 index 000000000..d1ca47a4c --- /dev/null +++ b/src/testing/src/Exceptions/InvalidArgumentException.php @@ -0,0 +1,34 @@ +get(ConfigInterface::class); + $config = $container->get('config'); // When registering the translator component, we'll need to set the default // locale as well as the fallback locale. So, we'll grab the application diff --git a/src/validation/composer.json b/src/validation/composer.json index 0c4e772fa..07b494ac6 100644 --- a/src/validation/composer.json +++ b/src/validation/composer.json @@ -26,13 +26,13 @@ } }, "require": { - "php": "^8.2", + "php": "^8.4", "ext-filter": "*", "ext-mbstring": "*", "brick/math": "^0.11|^0.12", "egulias/email-validator": "^3.2.5|^4.0", - "hypervel/support": "^0.3", - "hypervel/translation": "^0.3" + "hypervel/support": "^0.4", + "hypervel/translation": "^0.4" }, "config": { "sort-packages": true @@ -42,7 +42,7 @@ "config": "Hypervel\\Validation\\ConfigProvider" }, "branch-alias": { - "dev-main": "0.3-dev" + "dev-main": "0.4-dev" } }, "suggest": { 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 49a3b3641..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; @@ -459,8 +459,8 @@ public function validateDoesntContain(string $attribute, mixed $value, mixed $pa */ 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)); @@ -962,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 2235499ae..07d1b1350 100644 --- a/src/validation/src/Rule.php +++ b/src/validation/src/Rule.php @@ -5,11 +5,11 @@ 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; 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 index f736aa794..e50362e68 100644 --- a/src/validation/src/Rules/Contains.php +++ b/src/validation/src/Rules/Contains.php @@ -5,7 +5,7 @@ namespace Hypervel\Validation\Rules; use BackedEnum; -use Hyperf\Contract\Arrayable; +use Hypervel\Contracts\Support\Arrayable; use Stringable; use UnitEnum; 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 index 3a9357751..74ed48fa3 100644 --- a/src/validation/src/Rules/DoesntContain.php +++ b/src/validation/src/Rules/DoesntContain.php @@ -5,7 +5,7 @@ namespace Hypervel\Validation\Rules; use BackedEnum; -use Hyperf\Contract\Arrayable; +use Hypervel\Contracts\Support\Arrayable; use Stringable; use UnitEnum; 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('get')->with(Gate::class)->andReturn($gate); diff --git a/tests/Auth/Access/AuthorizesRequestsTest.php b/tests/Auth/Access/AuthorizesRequestsTest.php index c7a682634..773095be1 100644 --- a/tests/Auth/Access/AuthorizesRequestsTest.php +++ b/tests/Auth/Access/AuthorizesRequestsTest.php @@ -4,14 +4,14 @@ namespace Hypervel\Tests\Auth\Access; -use Hyperf\Context\ApplicationContext; -use Hyperf\Contract\ContainerInterface; -use Hyperf\Database\Model\Model; use Hypervel\Auth\Access\Response; -use Hypervel\Auth\Contracts\Gate; +use Hypervel\Context\ApplicationContext; +use Hypervel\Contracts\Auth\Access\Gate; +use Hypervel\Contracts\Container\Container; +use Hypervel\Database\Eloquent\Model; use Hypervel\Tests\Auth\Stub\AuthorizesRequestsStub; use Hypervel\Tests\TestCase; -use Mockery; +use Mockery as m; use Mockery\MockInterface; /** @@ -22,7 +22,7 @@ class AuthorizesRequestsTest extends TestCase { public function testAuthorize() { - $response = Mockery::mock(Response::class); + $response = m::mock(Response::class); $gate = $this->mockGate(); @@ -34,7 +34,7 @@ public function testAuthorize() public function testAuthorizeMayBeGuessedPassingModelInstance() { $model = new class extends Model {}; - $response = Mockery::mock(Response::class); + $response = m::mock(Response::class); $gate = $this->mockGate(); @@ -46,7 +46,7 @@ public function testAuthorizeMayBeGuessedPassingModelInstance() public function testAuthorizeMayBeGuessedPassingClassName() { $class = Model::class; - $response = Mockery::mock(Response::class); + $response = m::mock(Response::class); $gate = $this->mockGate(); @@ -58,7 +58,7 @@ public function testAuthorizeMayBeGuessedPassingClassName() public function testAuthorizeMayBeGuessedAndNormalized() { $model = new class extends Model {}; - $response = Mockery::mock(Response::class); + $response = m::mock(Response::class); $gate = $this->mockGate(); @@ -77,10 +77,10 @@ public function store($model) */ private function mockGate(): Gate { - $gate = Mockery::mock(Gate::class); + $gate = m::mock(Gate::class); - /** @var ContainerInterface|MockInterface */ - $container = Mockery::mock(ContainerInterface::class); + /** @var Container|MockInterface */ + $container = m::mock(Container::class); $container->shouldReceive('get')->with(Gate::class)->andReturn($gate); diff --git a/tests/Auth/Access/GateTest.php b/tests/Auth/Access/GateTest.php index ac125b259..841184d10 100644 --- a/tests/Auth/Access/GateTest.php +++ b/tests/Auth/Access/GateTest.php @@ -9,7 +9,7 @@ use Hypervel\Auth\Access\AuthorizationException; use Hypervel\Auth\Access\Gate; use Hypervel\Auth\Access\Response; -use Hypervel\Auth\Contracts\Authenticatable; +use Hypervel\Contracts\Auth\Authenticatable; use Hypervel\Tests\Auth\Stub\AccessGateTestAuthenticatable; use Hypervel\Tests\Auth\Stub\AccessGateTestBeforeCallback; use Hypervel\Tests\Auth\Stub\AccessGateTestClass; diff --git a/tests/Auth/AuthDatabaseUserProviderTest.php b/tests/Auth/AuthDatabaseUserProviderTest.php index f7c50700d..306b78722 100644 --- a/tests/Auth/AuthDatabaseUserProviderTest.php +++ b/tests/Auth/AuthDatabaseUserProviderTest.php @@ -4,12 +4,12 @@ namespace Hypervel\Tests\Auth; -use Hyperf\Database\ConnectionInterface; -use Hyperf\Database\Query\Builder; -use Hypervel\Auth\Contracts\Authenticatable; use Hypervel\Auth\GenericUser; use Hypervel\Auth\Providers\DatabaseUserProvider; -use Hypervel\Hashing\Contracts\Hasher; +use Hypervel\Contracts\Auth\Authenticatable; +use Hypervel\Contracts\Hashing\Hasher; +use Hypervel\Database\ConnectionInterface; +use Hypervel\Database\Query\Builder; use Hypervel\Tests\TestCase; use Mockery as m; @@ -22,7 +22,7 @@ class AuthDatabaseUserProviderTest extends TestCase public function testRetrieveByIDReturnsUserWhenUserIsFound() { $builder = m::mock(Builder::class); - $builder->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..3edb69cb4 100644 --- a/tests/Auth/AuthMangerTest.php +++ b/tests/Auth/AuthMangerTest.php @@ -4,24 +4,23 @@ namespace Hypervel\Tests\Auth; -use Hyperf\Config\Config; -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\Config\Repository; +use Hypervel\Container\Container; use Hypervel\Context\ApplicationContext; +use Hypervel\Context\Context; +use Hypervel\Contracts\Auth\Authenticatable; +use Hypervel\Contracts\Auth\Guard; +use Hypervel\Contracts\Auth\UserProvider; +use Hypervel\Contracts\Hashing\Hasher as HashContract; +use Hypervel\Coroutine\Coroutine; +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; @@ -36,7 +35,7 @@ class AuthMangerTest extends TestCase public function testGetDefaultDriverFromConfig() { $manager = new AuthManager($container = $this->getContainer()); - $container->get(ConfigInterface::class) + $container->get('config') ->set('auth.defaults.guard', 'foo'); $this->assertSame('foo', $manager->getDefaultDriver()); @@ -60,7 +59,7 @@ public function testGetDefaultDriverFromContext() public function testExtendDriver() { $manager = new AuthManager($container = $this->getContainer()); - $container->get(ConfigInterface::class) + $container->get('config') ->set('auth.guards.foo', ['driver' => 'bar']); $guard = m::mock(Guard::class); @@ -74,7 +73,7 @@ public function testExtendDriver() public function testGetDefaultUserProvider() { $manager = new AuthManager($container = $this->getContainer()); - $container->get(ConfigInterface::class) + $container->get('config') ->set('auth.defaults.provider', 'foo'); $this->assertSame('foo', $manager->getDefaultUserProvider()); @@ -91,7 +90,7 @@ public function testCreateDatabaseUserProvider() { $manager = new AuthManager($container = $this->getContainer()); - $container->get(ConfigInterface::class) + $container->get('config') ->set('auth.providers.foo', [ 'driver' => 'database', 'connection' => 'foo', @@ -117,7 +116,7 @@ public function testCreateCustomUserProvider() { $manager = new AuthManager($container = $this->getContainer()); - $container->get(ConfigInterface::class) + $container->get('config') ->set('auth.providers.foo', [ 'driver' => 'bar', ]); @@ -150,15 +149,15 @@ public function testViaRequest() ApplicationContext::setContainer($container); - $container->get(ConfigInterface::class) + $container->get('config') ->set('auth.providers.foo', [ 'driver' => 'foo', ]); - $container->get(ConfigInterface::class) + $container->get('config') ->set('auth.guards.foo', [ 'driver' => 'custom', ]); - $container->get(ConfigInterface::class) + $container->get('config') ->set('auth.defaults.provider', 'foo'); $provider = m::mock(UserProvider::class); @@ -173,13 +172,13 @@ public function testViaRequest() protected function getContainer(array $authConfig = []) { - $config = new Config([ + $config = new Repository([ 'auth' => $authConfig, ]); return new Container( new DefinitionSource([ - ConfigInterface::class => fn () => $config, + 'config' => fn () => $config, ]) ); } 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..8207f7685 100644 --- a/tests/Broadcasting/BroadcastManagerTest.php +++ b/tests/Broadcasting/BroadcastManagerTest.php @@ -4,25 +4,26 @@ namespace Hypervel\Tests\Broadcasting; -use Hyperf\Contract\ConfigInterface; use Hyperf\HttpServer\Router\DispatcherFactory as RouterDispatcherFactory; 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\Config\Repository; 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\Config\Repository as ConfigContract; +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; @@ -47,7 +48,7 @@ protected function setUp(): void $this->container = new Application( new DefinitionSource([ BusDispatcherContract::class => fn () => m::mock(QueueingDispatcher::class), - ConfigInterface::class => fn () => m::mock(ConfigInterface::class), + ConfigContract::class => fn () => m::mock(Repository::class), QueueFactoryContract::class => fn () => m::mock(QueueFactoryContract::class), BroadcastingFactoryContract::class => fn ($container) => new BroadcastManager($container), ]), @@ -61,8 +62,6 @@ protected function tearDown(): void { parent::tearDown(); - m::close(); - Facade::clearResolvedInstances(); } @@ -114,7 +113,7 @@ public function testThrowExceptionWhenUnknownStoreIsUsed() $config->shouldReceive('get')->with('broadcasting.connections.alien_connection')->andReturn(null); $app = m::mock(ContainerInterface::class); - $app->shouldReceive('get')->with(ConfigInterface::class)->andReturn($config); + $app->shouldReceive('get')->with('config')->andReturn($config); $broadcastManager = new BroadcastManager($app); @@ -138,14 +137,14 @@ public function testRoutesExcludesCsrfMiddleware(): void ->with('http') ->andReturn($router); - $config = m::mock(ConfigInterface::class); + $config = m::mock(Repository::class); $config->shouldReceive('get') ->with('server.kernels', []) ->andReturn(['http' => []]); $app = m::mock(ContainerInterface::class); $app->shouldReceive('has')->with(Kernel::class)->andReturn(true); - $app->shouldReceive('get')->with(ConfigInterface::class)->andReturn($config); + $app->shouldReceive('get')->with('config')->andReturn($config); $app->shouldReceive('get')->with(RouterDispatcherFactory::class)->andReturn($routerFactory); $broadcastManager = new BroadcastManager($app); diff --git a/tests/Broadcasting/BroadcasterTest.php b/tests/Broadcasting/BroadcasterTest.php index 9f31f70b1..9a60adae0 100644 --- a/tests/Broadcasting/BroadcasterTest.php +++ b/tests/Broadcasting/BroadcasterTest.php @@ -5,15 +5,14 @@ namespace Hypervel\Tests\Broadcasting; 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\Context\RequestContext; +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..3d987f6b7 100644 --- a/tests/Broadcasting/RedisBroadcasterTest.php +++ b/tests/Broadcasting/RedisBroadcasterTest.php @@ -5,10 +5,10 @@ namespace Hypervel\Tests\Broadcasting; use Hyperf\HttpServer\Request; -use Hyperf\Redis\RedisFactory; use Hypervel\Auth\AuthManager; use Hypervel\Broadcasting\Broadcasters\RedisBroadcaster; use Hypervel\HttpMessage\Exceptions\AccessDeniedHttpException; +use Hypervel\Redis\RedisFactory; use Hypervel\Support\Facades\Facade; use Hypervel\Tests\Foundation\Concerns\HasMockedApplication; use Mockery as m; @@ -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..3484898ee 100644 --- a/tests/Bus/BusBatchableTest.php +++ b/tests/Bus/BusBatchableTest.php @@ -4,12 +4,12 @@ namespace Hypervel\Tests\Bus; -use Hyperf\Context\ApplicationContext; -use Hyperf\Di\Container; use Hyperf\Di\Definition\DefinitionSource; use Hypervel\Bus\Batch; use Hypervel\Bus\Batchable; -use Hypervel\Bus\Contracts\BatchRepository; +use Hypervel\Container\Container; +use Hypervel\Context\ApplicationContext; +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..27254af0a 100644 --- a/tests/Cache/CacheFileStoreTest.php +++ b/tests/Cache/CacheFileStoreTest.php @@ -6,9 +6,9 @@ use Carbon\Carbon; use Exception; -use Hyperf\Support\Filesystem\FileNotFoundException; -use Hyperf\Support\Filesystem\Filesystem; use Hypervel\Cache\FileStore; +use Hypervel\Contracts\Filesystem\FileNotFoundException; +use Hypervel\Filesystem\Filesystem; use Hypervel\Tests\TestCase; use Mockery as m; @@ -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 928ad4063..49b429775 100644 --- a/tests/Cache/CacheManagerTest.php +++ b/tests/Cache/CacheManagerTest.php @@ -4,17 +4,16 @@ namespace Hypervel\Tests\Cache; -use Hyperf\Config\Config; -use Hyperf\Contract\ConfigInterface; -use Hyperf\Redis\Pool\PoolFactory; -use Hyperf\Redis\Pool\RedisPool; -use Hyperf\Redis\RedisFactory; use Hypervel\Cache\CacheManager; -use Hypervel\Cache\Contracts\Repository; use Hypervel\Cache\NullStore; use Hypervel\Cache\Redis\TagMode; use Hypervel\Cache\RedisStore; +use Hypervel\Config\Repository as ConfigRepository; +use Hypervel\Contracts\Cache\Repository as CacheRepository; +use Hypervel\Redis\Pool\PoolFactory; +use Hypervel\Redis\Pool\RedisPool; use Hypervel\Redis\RedisConnection; +use Hypervel\Redis\RedisFactory; use Hypervel\Tests\TestCase; use InvalidArgumentException; use Mockery as m; @@ -43,7 +42,7 @@ public function testCustomDriverClosureBoundObjectIsCacheManager() $app = $this->getApp($userConfig); $cacheManager = new CacheManager($app); - $repository = m::mock(Repository::class); + $repository = m::mock(CacheRepository::class); $cacheManager->extend('foo', fn () => $repository); $this->assertEquals($repository, $cacheManager->store('foo')); } @@ -63,8 +62,8 @@ public function testCustomDriverOverridesInternalDrivers() $app = $this->getApp($userConfig); $cacheManager = new CacheManager($app); - /** @var MockInterface|Repository */ - $repository = m::mock(Repository::class); + /** @var CacheRepository|MockInterface */ + $repository = m::mock(CacheRepository::class); $repository->shouldReceive('get')->with('foo')->andReturn('bar'); $cacheManager->extend('array', fn () => $repository); @@ -165,7 +164,7 @@ public function testItSetsDefaultDriverChangesGlobalConfig() $cacheManager->setDefaultDriver('><((((@>'); - $this->assertEquals('><((((@>', $app->get(ConfigInterface::class)->get('cache.default')); + $this->assertEquals('><((((@>', $app->get('config')->get('cache.default')); } public function testItPurgesMemoizedStoreObjects() @@ -220,7 +219,7 @@ public function testForgetDriver() $cacheManager->shouldReceive('resolve') ->withArgs(['array']) ->times(4) - ->andReturn(m::mock(Repository::class)); + ->andReturn(m::mock(CacheRepository::class)); $cacheManager->shouldReceive('getDefaultDriver') ->once() @@ -253,8 +252,8 @@ public function testForgetDriverForgets() $cacheManager = new CacheManager($app); $cacheManager->extend('forget', function () use (&$count) { - /** @var MockInterface|Repository */ - $repository = m::mock(Repository::class); + /** @var CacheRepository|MockInterface */ + $repository = m::mock(CacheRepository::class); if ($count++ === 0) { $repository->shouldReceive('forever')->with('foo', 'bar')->once(); @@ -396,7 +395,7 @@ protected function getApp(array $userConfig) { /** @var ContainerInterface|MockInterface */ $app = m::mock(ContainerInterface::class); - $app->shouldReceive('get')->with(ConfigInterface::class)->andReturn(new Config($userConfig)); + $app->shouldReceive('get')->with('config')->andReturn(new ConfigRepository($userConfig)); return $app; } 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..d492f8b3e 100644 --- a/tests/Cache/CacheRedisLockTest.php +++ b/tests/Cache/CacheRedisLockTest.php @@ -4,9 +4,9 @@ namespace Hypervel\Tests\Cache; -use Hyperf\Redis\Redis; -use Hypervel\Cache\Contracts\RefreshableLock; use Hypervel\Cache\RedisLock; +use Hypervel\Contracts\Cache\RefreshableLock; +use Hypervel\Redis\Redis; use Hypervel\Tests\TestCase; use InvalidArgumentException; use Mockery as m; @@ -54,7 +54,7 @@ public function testLockCanBeReleased() $redis->shouldReceive('eval') ->once() - ->with(m::type('string'), ['foo', $lock->owner()], 1) + ->with(m::type('string'), 1, 'foo', $lock->owner()) ->andReturn(1); $this->assertTrue($lock->release()); @@ -78,7 +78,7 @@ public function testRefreshExtendsLockTtl() $redis->shouldReceive('eval') ->once() - ->with(m::type('string'), ['foo', $lock->owner(), 10], 1) + ->with(m::type('string'), 1, 'foo', $lock->owner(), 10) ->andReturn(1); $this->assertTrue($lock->refresh()); @@ -90,7 +90,7 @@ public function testRefreshWithCustomTtl() $redis->shouldReceive('eval') ->once() - ->with(m::type('string'), ['foo', $lock->owner(), 30], 1) + ->with(m::type('string'), 1, 'foo', $lock->owner(), 30) ->andReturn(1); $this->assertTrue($lock->refresh(30)); @@ -102,7 +102,7 @@ public function testRefreshReturnsFalseWhenNotOwned() $redis->shouldReceive('eval') ->once() - ->with(m::type('string'), ['foo', $lock->owner(), 10], 1) + ->with(m::type('string'), 1, 'foo', $lock->owner(), 10) ->andReturn(0); $this->assertFalse($lock->refresh()); 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..63c8bbe04 100644 --- a/tests/Cache/CacheRepositoryTest.php +++ b/tests/Cache/CacheRepositoryTest.php @@ -10,14 +10,14 @@ use DateInterval; use DateTime; 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\Filesystem\Filesystem; 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/Cache/Redis/Console/DoctorCommandTest.php b/tests/Cache/Redis/Console/DoctorCommandTest.php index ab0cb4b79..f8ea1aef8 100644 --- a/tests/Cache/Redis/Console/DoctorCommandTest.php +++ b/tests/Cache/Redis/Console/DoctorCommandTest.php @@ -4,15 +4,16 @@ namespace Hypervel\Tests\Cache\Redis\Console; -use Hyperf\Contract\ConfigInterface; use Hypervel\Cache\CacheManager; -use Hypervel\Cache\Contracts\Factory as CacheContract; -use Hypervel\Cache\Contracts\Repository; -use Hypervel\Cache\Contracts\Store; use Hypervel\Cache\Redis\Console\DoctorCommand; use Hypervel\Cache\Redis\Support\StoreContext; use Hypervel\Cache\Redis\TagMode; use Hypervel\Cache\RedisStore; +use Hypervel\Config\Repository as ConfigRepository; +use Hypervel\Contracts\Cache\Factory as CacheContract; +use Hypervel\Contracts\Cache\Repository; +use Hypervel\Contracts\Cache\Store; +use Hypervel\Contracts\Config\Repository as ConfigContract; use Hypervel\Redis\RedisConnection; use Hypervel\Testbench\TestCase; use Mockery as m; @@ -55,7 +56,7 @@ public function testDoctorFailsForNonRedisStore(): void public function testDoctorDetectsRedisStoreFromConfig(): void { // Set up config with a redis store - $config = m::mock(ConfigInterface::class); + $config = m::mock(ConfigRepository::class); $config->shouldReceive('get') ->with('cache.stores', []) ->andReturn([ @@ -69,7 +70,7 @@ public function testDoctorDetectsRedisStoreFromConfig(): void ->with('cache.stores.redis.connection', 'default') ->andReturn('default'); - $this->app->set(ConfigInterface::class, $config); + $this->app->set(ConfigContract::class, $config); // Mock Redis store $context = m::mock(StoreContext::class); @@ -117,7 +118,7 @@ public function testDoctorUsesSpecifiedStore(): void return; } - $config = m::mock(ConfigInterface::class); + $config = m::mock(ConfigRepository::class); $config->shouldReceive('get') ->with('cache.default', 'file') ->andReturn('file'); @@ -125,7 +126,7 @@ public function testDoctorUsesSpecifiedStore(): void ->with('cache.stores.custom-redis.connection', 'default') ->andReturn('custom'); - $this->app->set(ConfigInterface::class, $config); + $this->app->set(ConfigContract::class, $config); // Mock Redis store $context = m::mock(StoreContext::class); @@ -163,7 +164,7 @@ public function testDoctorUsesSpecifiedStore(): void public function testDoctorDisplaysTagMode(): void { - $config = m::mock(ConfigInterface::class); + $config = m::mock(ConfigRepository::class); $config->shouldReceive('get') ->with('cache.default', 'file') ->andReturn('redis'); @@ -171,7 +172,7 @@ public function testDoctorDisplaysTagMode(): void ->with('cache.stores.redis.connection', 'default') ->andReturn('default'); - $this->app->set(ConfigInterface::class, $config); + $this->app->set(ConfigContract::class, $config); // Mock Redis store with 'all' mode $context = m::mock(StoreContext::class); @@ -209,7 +210,7 @@ public function testDoctorDisplaysTagMode(): void public function testDoctorFailsWhenNoRedisStoreDetected(): void { // Set up config with NO redis stores - $config = m::mock(ConfigInterface::class); + $config = m::mock(ConfigRepository::class); $config->shouldReceive('get') ->with('cache.stores', []) ->andReturn([ @@ -220,7 +221,7 @@ public function testDoctorFailsWhenNoRedisStoreDetected(): void ->with('cache.default', 'file') ->andReturn('file'); - $this->app->set(ConfigInterface::class, $config); + $this->app->set(ConfigContract::class, $config); $command = new DoctorCommand(); $output = new BufferedOutput(); @@ -233,7 +234,7 @@ public function testDoctorFailsWhenNoRedisStoreDetected(): void public function testDoctorDisplaysSystemInformation(): void { - $config = m::mock(ConfigInterface::class); + $config = m::mock(ConfigRepository::class); $config->shouldReceive('get') ->with('cache.stores', []) ->andReturn([ @@ -246,7 +247,7 @@ public function testDoctorDisplaysSystemInformation(): void ->with('cache.stores.redis.connection', 'default') ->andReturn('default'); - $this->app->set(ConfigInterface::class, $config); + $this->app->set(ConfigContract::class, $config); $context = m::mock(StoreContext::class); $context->shouldReceive('withConnection') diff --git a/tests/Cache/Redis/Console/PruneStaleTagsCommandTest.php b/tests/Cache/Redis/Console/PruneStaleTagsCommandTest.php index 2fb021678..e93569d0c 100644 --- a/tests/Cache/Redis/Console/PruneStaleTagsCommandTest.php +++ b/tests/Cache/Redis/Console/PruneStaleTagsCommandTest.php @@ -5,9 +5,6 @@ namespace Hypervel\Tests\Cache\Redis\Console; use Hypervel\Cache\CacheManager; -use Hypervel\Cache\Contracts\Factory as CacheContract; -use Hypervel\Cache\Contracts\Repository; -use Hypervel\Cache\Contracts\Store; use Hypervel\Cache\Redis\Console\PruneStaleTagsCommand; use Hypervel\Cache\Redis\Operations\AllTag\Prune as IntersectionPrune; use Hypervel\Cache\Redis\Operations\AllTagOperations; @@ -15,6 +12,9 @@ use Hypervel\Cache\Redis\Operations\AnyTagOperations; use Hypervel\Cache\Redis\TagMode; use Hypervel\Cache\RedisStore; +use Hypervel\Contracts\Cache\Factory as CacheContract; +use Hypervel\Contracts\Cache\Repository; +use Hypervel\Contracts\Cache\Store; use Hypervel\Testbench\TestCase; use Mockery as m; use Symfony\Component\Console\Input\ArrayInput; diff --git a/tests/Cache/Redis/Operations/AllTag/FlushTest.php b/tests/Cache/Redis/Operations/AllTag/FlushTest.php index d6dfed336..9dc76b9f6 100644 --- a/tests/Cache/Redis/Operations/AllTag/FlushTest.php +++ b/tests/Cache/Redis/Operations/AllTag/FlushTest.php @@ -4,9 +4,9 @@ namespace Hypervel\Tests\Cache\Redis\Operations\AllTag; -use Hyperf\Collection\LazyCollection; use Hypervel\Cache\Redis\Operations\AllTag\Flush; use Hypervel\Cache\Redis\Operations\AllTag\GetEntries; +use Hypervel\Support\LazyCollection; use Hypervel\Tests\Cache\Redis\RedisCacheTestCase; use Mockery as m; diff --git a/tests/Cache/Redis/Operations/AllTag/GetEntriesTest.php b/tests/Cache/Redis/Operations/AllTag/GetEntriesTest.php index 7b8bcc130..c8b7e629e 100644 --- a/tests/Cache/Redis/Operations/AllTag/GetEntriesTest.php +++ b/tests/Cache/Redis/Operations/AllTag/GetEntriesTest.php @@ -4,8 +4,8 @@ namespace Hypervel\Tests\Cache\Redis\Operations\AllTag; -use Hyperf\Collection\LazyCollection; use Hypervel\Cache\Redis\Operations\AllTag\GetEntries; +use Hypervel\Support\LazyCollection; use Hypervel\Tests\Cache\Redis\RedisCacheTestCase; use Mockery as m; diff --git a/tests/Cache/Redis/RedisCacheTestCase.php b/tests/Cache/Redis/RedisCacheTestCase.php index c6f8d08ec..ac2cde141 100644 --- a/tests/Cache/Redis/RedisCacheTestCase.php +++ b/tests/Cache/Redis/RedisCacheTestCase.php @@ -5,12 +5,11 @@ namespace Hypervel\Tests\Cache\Redis; use Carbon\Carbon; -use Hyperf\Redis\Pool\PoolFactory; -use Hyperf\Redis\Pool\RedisPool; -use Hyperf\Redis\RedisFactory as HyperfRedisFactory; use Hypervel\Cache\RedisStore; +use Hypervel\Redis\Pool\PoolFactory; +use Hypervel\Redis\Pool\RedisPool; use Hypervel\Redis\RedisConnection; -use Hypervel\Redis\RedisFactory as HypervelRedisFactory; +use Hypervel\Redis\RedisFactory; use Hypervel\Redis\RedisProxy; use Hypervel\Testbench\TestCase; use Hypervel\Tests\Redis\Stub\FakeRedisClient; @@ -189,12 +188,12 @@ protected function registerRedisFactoryMock( $redisProxy->shouldReceive('withConnection') ->andReturnUsing(fn (callable $callback) => $callback($connection)); - $redisFactory = m::mock(HypervelRedisFactory::class); + $redisFactory = m::mock(RedisFactory::class); $redisFactory->shouldReceive('get') ->with($connectionName) ->andReturn($redisProxy); - $this->instance(HypervelRedisFactory::class, $redisFactory); + $this->instance(RedisFactory::class, $redisFactory); } /** @@ -215,7 +214,7 @@ protected function createStore( $this->registerRedisFactoryMock($connection, $connectionName); $store = new RedisStore( - m::mock(HyperfRedisFactory::class), + m::mock(RedisFactory::class), $prefix, $connectionName, $this->createPoolFactory($connection, $connectionName) @@ -259,7 +258,7 @@ protected function createClusterStore( $this->registerRedisFactoryMock($connection, $connectionName); $store = new RedisStore( - m::mock(HyperfRedisFactory::class), + m::mock(RedisFactory::class), $prefix, $connectionName, $this->createPoolFactory($connection, $connectionName) @@ -300,7 +299,7 @@ protected function createStoreWithFakeClient( $this->registerRedisFactoryMock($connection, $connectionName); $store = new RedisStore( - m::mock(HyperfRedisFactory::class), + m::mock(RedisFactory::class), $prefix, $connectionName, $this->createPoolFactory($connection, $connectionName) diff --git a/tests/Cache/Redis/RedisStoreTest.php b/tests/Cache/Redis/RedisStoreTest.php index d0b22ed27..e1a54eb61 100644 --- a/tests/Cache/Redis/RedisStoreTest.php +++ b/tests/Cache/Redis/RedisStoreTest.php @@ -4,11 +4,11 @@ namespace Hypervel\Tests\Cache\Redis; -use Hyperf\Redis\RedisFactory; -use Hyperf\Redis\RedisProxy; use Hypervel\Cache\Redis\TagMode; use Hypervel\Cache\RedisLock; use Hypervel\Cache\RedisStore; +use Hypervel\Redis\RedisFactory; +use Hypervel\Redis\RedisProxy; use Mockery as m; /** diff --git a/tests/Cache/RedisLockTest.php b/tests/Cache/RedisLockTest.php index a55401857..cb1d31fb8 100644 --- a/tests/Cache/RedisLockTest.php +++ b/tests/Cache/RedisLockTest.php @@ -4,8 +4,8 @@ namespace Hypervel\Tests\Cache; -use Hyperf\Redis\Redis; use Hypervel\Cache\RedisLock; +use Hypervel\Redis\Redis; use Hypervel\Tests\TestCase; use Mockery as m; use RuntimeException; @@ -16,13 +16,6 @@ */ class RedisLockTest extends TestCase { - protected function tearDown(): void - { - m::close(); - - parent::tearDown(); - } - public function testAcquireWithExpirationUsesSETWithNXAndEX(): void { $redis = m::mock(Redis::class); @@ -80,7 +73,7 @@ public function testReleaseUsesLuaScriptToAtomicallyCheckOwnership(): void $redis = m::mock(Redis::class); $redis->shouldReceive('eval') ->once() - ->with(m::type('string'), ['lock:foo', 'owner123'], 1) + ->with(m::type('string'), 1, 'lock:foo', 'owner123') ->andReturn(1); $lock = new RedisLock($redis, 'lock:foo', 60, 'owner123'); @@ -93,7 +86,7 @@ public function testReleaseReturnsFalseWhenNotOwner(): void $redis = m::mock(Redis::class); $redis->shouldReceive('eval') ->once() - ->with(m::type('string'), ['lock:foo', 'owner123'], 1) + ->with(m::type('string'), 1, 'lock:foo', 'owner123') ->andReturn(0); $lock = new RedisLock($redis, 'lock:foo', 60, 'owner123'); 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..3713910b6 100644 --- a/tests/Console/Scheduling/EventTest.php +++ b/tests/Console/Scheduling/EventTest.php @@ -5,14 +5,14 @@ namespace Hypervel\Tests\Console\Scheduling; 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\Context\ApplicationContext; +use Hypervel\Context\Context; +use Hypervel\Contracts\Console\Kernel as KernelContract; +use Hypervel\Contracts\Container\Container; +use Hypervel\Filesystem\Filesystem; +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/Container/ContainerTest.php b/tests/Container/ContainerTest.php index d05b1cf86..3bcec44bd 100644 --- a/tests/Container/ContainerTest.php +++ b/tests/Container/ContainerTest.php @@ -10,7 +10,7 @@ use Hypervel\Container\Container; use Hypervel\Container\DefinitionSource; use Hypervel\Tests\TestCase; -use Mockery; +use Mockery as m; use stdClass; /** @@ -24,7 +24,7 @@ protected function tearDown(): void parent::tearDown(); Container::setInstance( - Mockery::mock(Container::class) + m::mock(Container::class) ); } @@ -37,7 +37,7 @@ public function testContainerSingleton() $this->assertSame($container, Container::getInstance()); Container::setInstance( - Mockery::mock(Container::class) + m::mock(Container::class) ); $container2 = Container::getInstance(); @@ -179,6 +179,10 @@ public function testArrayAccess() // test offsetSet when it's not instanceof Closure $container = $this->getContainer(); $container['something'] = 'text'; + $this->assertTrue(isset($container['something'])); + $this->assertNotEmpty($container['something']); + $this->assertSame('text', $container['something']); + unset($container['something']); $this->assertFalse(isset($container['something'])); } diff --git a/tests/Context/ApplicationContextTest.php b/tests/Context/ApplicationContextTest.php new file mode 100644 index 000000000..5e0450f36 --- /dev/null +++ b/tests/Context/ApplicationContextTest.php @@ -0,0 +1,24 @@ +assertSame($container, ApplicationContext::getContainer()); + } +} diff --git a/tests/Context/ContextCoroutineTest.php b/tests/Context/ContextCoroutineTest.php new file mode 100644 index 000000000..6a07d2620 --- /dev/null +++ b/tests/Context/ContextCoroutineTest.php @@ -0,0 +1,158 @@ +assertSame($uid, Context::get('test.store.id')); + }, + ]); + } + + public function testCopyAfterSet() + { + Context::set('test.store.id', $uid = uniqid()); + $id = Coroutine::id(); + parallel([ + function () use ($id, $uid) { + Context::set('test.store.name', 'Hyperf'); + Context::copy($id, ['test.store.id']); + $this->assertSame($uid, Context::get('test.store.id')); + + // Context::copy will delete origin values. + $this->assertNull(Context::get('test.store.name')); + }, + ]); + } + + public function testContextChangeAfterCopy() + { + $obj = new stdClass(); + $obj->id = $uid = uniqid(); + + Context::set('test.store.id', $obj); + Context::set('test.store.useless.id', 1); + $id = Coroutine::id(); + $tid = uniqid(); + parallel([ + function () use ($id, $uid, $tid) { + Context::copy($id, ['test.store.id']); + $obj = Context::get('test.store.id'); + $this->assertSame($uid, $obj->id); + $obj->id = $tid; + $this->assertFalse(Context::has('test.store.useless.id')); + }, + ]); + + $this->assertSame($tid, Context::get('test.store.id')->id); + } + + public function testContextFromNull() + { + $res = Context::get('id', $default = 'Hello World!', -1); + $this->assertSame($default, $res); + + $res = Context::get('id', null, -1); + $this->assertSame(null, $res); + + $this->assertFalse(Context::has('id', -1)); + + Context::copy(-1); + + parallel([ + function () { + Context::set('id', $id = uniqid()); + Context::copy(-1, ['id']); + $this->assertSame($id, Context::get('id')); + }, + ]); + } + + public function testResponseContextWithCoroutineId() + { + $response = m::mock(ResponsePlusInterface::class); + $chan = new Channel(1); + $close = new Channel(1); + go(function () use ($chan, $response, $close) { + ResponseContext::set($response); + $this->assertSame($response, ResponseContext::get()); + $chan->push(Coroutine::id()); + $close->pop(1); + }); + + $id = $chan->pop(5); + $this->assertSame($response, ResponseContext::get($id)); + $close->push(true); + } + + public function testRequestContextWithCoroutineId() + { + $request = m::mock(ServerRequestPlusInterface::class); + RequestContext::set($request); + $id = Coroutine::id(); + (new Waiter())->wait(function () use ($id, $request) { + $this->assertSame($request, RequestContext::get($id)); + }); + } + + public function testContextOverrideWithCoroutineId() + { + $id = Coroutine::id(); + $value = uniqid(); + Context::override('override.id.coroutine_id', fn () => $value); + (new Waiter())->wait(function () use ($id, $value) { + Context::override( + 'override.id.coroutine_id', + function ($v) use ($value) { + $this->assertSame($v, $value); + return '123'; + }, + $id + ); + }); + + $this->assertSame('123', Context::get('override.id.coroutine_id')); + } + + public function testContextGetOrSetWithCoroutineId() + { + $id = Coroutine::id(); + $value = uniqid(); + Context::getOrSet('get_or_set.id.coroutine_id', fn () => $value); + (new Waiter())->wait(function () use ($id, $value) { + $res = Context::getOrSet('get_or_set.id.coroutine_id', fn () => '123', $id); + $this->assertSame($res, $value); + }); + } +} diff --git a/tests/Core/ContextEnumTest.php b/tests/Context/ContextEnumTest.php similarity index 83% rename from tests/Core/ContextEnumTest.php rename to tests/Context/ContextEnumTest.php index c55ae126e..347e30b53 100644 --- a/tests/Core/ContextEnumTest.php +++ b/tests/Context/ContextEnumTest.php @@ -2,11 +2,10 @@ declare(strict_types=1); -namespace Hypervel\Tests\Core; +namespace Hypervel\Tests\Context; use Hypervel\Context\Context; -use PHPUnit\Framework\TestCase; -use TypeError; +use Hypervel\Tests\TestCase; enum ContextKeyBackedEnum: string { @@ -46,28 +45,28 @@ protected function tearDown(): void parent::tearDown(); } - public function testSetAndGetWithBackedEnum(): void + public function testSetAndGetWithBackedEnum() { Context::set(ContextKeyBackedEnum::CurrentUser, 'user-123'); $this->assertSame('user-123', Context::get(ContextKeyBackedEnum::CurrentUser)); } - public function testSetAndGetWithUnitEnum(): void + public function testSetAndGetWithUnitEnum() { Context::set(ContextKeyUnitEnum::Locale, 'en-US'); $this->assertSame('en-US', Context::get(ContextKeyUnitEnum::Locale)); } - public function testSetWithIntBackedEnumThrowsTypeError(): void + public function testSetAndGetWithIntBackedEnum() { - // Int-backed enum causes TypeError because parent::set() expects string key - $this->expectException(TypeError::class); Context::set(ContextKeyIntBackedEnum::UserId, 'user-123'); + + $this->assertSame('user-123', Context::get(ContextKeyIntBackedEnum::UserId)); } - public function testHasWithBackedEnum(): void + public function testHasWithBackedEnum() { $this->assertFalse(Context::has(ContextKeyBackedEnum::CurrentUser)); @@ -76,7 +75,7 @@ public function testHasWithBackedEnum(): void $this->assertTrue(Context::has(ContextKeyBackedEnum::CurrentUser)); } - public function testHasWithUnitEnum(): void + public function testHasWithUnitEnum() { $this->assertFalse(Context::has(ContextKeyUnitEnum::Locale)); @@ -85,7 +84,7 @@ public function testHasWithUnitEnum(): void $this->assertTrue(Context::has(ContextKeyUnitEnum::Locale)); } - public function testDestroyWithBackedEnum(): void + public function testDestroyWithBackedEnum() { Context::set(ContextKeyBackedEnum::CurrentUser, 'user-123'); $this->assertTrue(Context::has(ContextKeyBackedEnum::CurrentUser)); @@ -95,7 +94,7 @@ public function testDestroyWithBackedEnum(): void $this->assertFalse(Context::has(ContextKeyBackedEnum::CurrentUser)); } - public function testDestroyWithUnitEnum(): void + public function testDestroyWithUnitEnum() { Context::set(ContextKeyUnitEnum::Locale, 'en-US'); $this->assertTrue(Context::has(ContextKeyUnitEnum::Locale)); @@ -105,7 +104,7 @@ public function testDestroyWithUnitEnum(): void $this->assertFalse(Context::has(ContextKeyUnitEnum::Locale)); } - public function testOverrideWithBackedEnum(): void + public function testOverrideWithBackedEnum() { Context::set(ContextKeyBackedEnum::CurrentUser, 'user-123'); @@ -115,7 +114,7 @@ public function testOverrideWithBackedEnum(): void $this->assertSame('user-123-modified', Context::get(ContextKeyBackedEnum::CurrentUser)); } - public function testOverrideWithUnitEnum(): void + public function testOverrideWithUnitEnum() { Context::set(ContextKeyUnitEnum::Locale, 'en'); @@ -125,7 +124,7 @@ public function testOverrideWithUnitEnum(): void $this->assertSame('en-US', Context::get(ContextKeyUnitEnum::Locale)); } - public function testGetOrSetWithBackedEnum(): void + public function testGetOrSetWithBackedEnum() { // First call should set and return the value $result = Context::getOrSet(ContextKeyBackedEnum::RequestId, 'req-001'); @@ -136,7 +135,7 @@ public function testGetOrSetWithBackedEnum(): void $this->assertSame('req-001', $result); } - public function testGetOrSetWithUnitEnum(): void + public function testGetOrSetWithUnitEnum() { $result = Context::getOrSet(ContextKeyUnitEnum::Theme, 'dark'); $this->assertSame('dark', $result); @@ -145,7 +144,7 @@ public function testGetOrSetWithUnitEnum(): void $this->assertSame('dark', $result); } - public function testGetOrSetWithClosure(): void + public function testGetOrSetWithClosure() { $callCount = 0; $callback = function () use (&$callCount) { @@ -163,7 +162,7 @@ public function testGetOrSetWithClosure(): void $this->assertSame(1, $callCount); } - public function testSetManyWithEnumKeys(): void + public function testSetManyWithEnumKeys() { Context::setMany([ ContextKeyBackedEnum::CurrentUser->value => 'user-123', @@ -174,7 +173,7 @@ public function testSetManyWithEnumKeys(): void $this->assertSame('en-US', Context::get(ContextKeyUnitEnum::Locale)); } - public function testBackedEnumAndStringInteroperability(): void + public function testBackedEnumAndStringInteroperability() { // Set with enum Context::set(ContextKeyBackedEnum::CurrentUser, 'user-123'); @@ -189,7 +188,7 @@ public function testBackedEnumAndStringInteroperability(): void $this->assertSame('req-456', Context::get(ContextKeyBackedEnum::RequestId)); } - public function testUnitEnumAndStringInteroperability(): void + public function testUnitEnumAndStringInteroperability() { // Set with enum Context::set(ContextKeyUnitEnum::Locale, 'en-US'); @@ -204,21 +203,21 @@ public function testUnitEnumAndStringInteroperability(): void $this->assertSame('dark', Context::get(ContextKeyUnitEnum::Theme)); } - public function testGetWithDefaultAndBackedEnum(): void + public function testGetWithDefaultAndBackedEnum() { $result = Context::get(ContextKeyBackedEnum::CurrentUser, 'default-user'); $this->assertSame('default-user', $result); } - public function testGetWithDefaultAndUnitEnum(): void + public function testGetWithDefaultAndUnitEnum() { $result = Context::get(ContextKeyUnitEnum::Locale, 'en'); $this->assertSame('en', $result); } - public function testMultipleEnumKeysCanCoexist(): void + public function testMultipleEnumKeysCanCoexist() { Context::set(ContextKeyBackedEnum::CurrentUser, 'user-123'); Context::set(ContextKeyBackedEnum::RequestId, 'req-456'); diff --git a/tests/Context/ContextTest.php b/tests/Context/ContextTest.php new file mode 100644 index 000000000..cd5eb3be6 --- /dev/null +++ b/tests/Context/ContextTest.php @@ -0,0 +1,146 @@ + 'value1', + 'key2' => 'value2', + 'key3' => 'value3', + ]; + + Context::setMany($values); + + foreach ($values as $key => $expectedValue) { + $this->assertTrue(Context::has($key)); + $this->assertEquals($expectedValue, Context::get($key)); + } + } + + /** + * @covers ::copyFromNonCoroutine + */ + public function testCopyFromNonCoroutineWithSpecificKeys() + { + Context::set('foo', 'foo'); + Context::set('bar', 'bar'); + + run(function () { + Coroutine::create(function () { + Context::copyFromNonCoroutine(); + $this->assertSame('foo', Context::get('foo')); + $this->assertSame('bar', Context::get('bar')); + }); + }); + } + + /** + * @covers ::destroyAll + */ + public function testDestroyAll() + { + Context::set('key1', 'value1'); + Context::set('key2', 'value2'); + + $this->assertTrue(Context::has('key1')); + $this->assertTrue(Context::has('key2')); + + Context::destroyAll(); + + $this->assertFalse(Context::has('key1')); + $this->assertFalse(Context::has('key2')); + } + + public function testOverride() + { + Context::set('override.id', 1); + + $this->assertSame(2, Context::override('override.id', function ($id) { + return $id + 1; + })); + + $this->assertSame(2, Context::get('override.id')); + } + + public function testGetOrSet() + { + Context::set('test.store.id', null); + $this->assertSame(1, Context::getOrSet('test.store.id', function () { + return 1; + })); + $this->assertSame(1, Context::getOrSet('test.store.id', function () { + return 2; + })); + + Context::set('test.store.id', null); + $this->assertSame(1, Context::getOrSet('test.store.id', 1)); + } + + public function testContextDestroy() + { + Context::set($id = uniqid(), $value = uniqid()); + + $this->assertSame($value, Context::get($id)); + Context::destroy($id); + $this->assertNull(Context::get($id)); + } + + public function testRequestContext() + { + $request = m::mock(ServerRequestPlusInterface::class); + RequestContext::set($request); + $this->assertSame($request, RequestContext::get()); + + Context::set(ServerRequestInterface::class, $req = m::mock(ServerRequestPlusInterface::class)); + $this->assertNotSame($request, RequestContext::get()); + $this->assertSame($req, RequestContext::get()); + $this->assertSame($req, Context::get(ServerRequestInterface::class)); + } + + public function testResponseContext() + { + $response = m::mock(ResponsePlusInterface::class); + ResponseContext::set($response); + $this->assertSame($response, ResponseContext::get()); + + Context::set(ResponseInterface::class, $req = m::mock(ResponsePlusInterface::class)); + $this->assertNotSame($response, ResponseContext::get()); + $this->assertSame($req, ResponseContext::get()); + $this->assertSame($req, Context::get(ResponseInterface::class)); + } +} diff --git a/tests/Context/Traits/CoroutineProxyTest.php b/tests/Context/Traits/CoroutineProxyTest.php new file mode 100644 index 000000000..aa90cf3ec --- /dev/null +++ b/tests/Context/Traits/CoroutineProxyTest.php @@ -0,0 +1,57 @@ +assertSame('bar', $foo->callBar()); + $this->assertSame('bar', $foo->bar); + $foo->bar = 'foo'; + $this->assertSame('foo', $foo->bar); + } + + public function testCoroutineProxyException() + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Missing $proxyKey property in Hypervel\Tests\Context\Traits\Foo2.'); + $foo = new Foo2(); + $foo->callBar(); + } +} + +class Bar +{ + public $bar = 'bar'; + + public function callBar() + { + return 'bar'; + } +} + +class Foo +{ + use CoroutineProxy; + + protected $proxyKey = 'bar'; +} + +class Foo2 +{ + use CoroutineProxy; +} diff --git a/tests/Cookie/CookieManagerTest.php b/tests/Cookie/CookieManagerTest.php index 07abcff93..611cdc44f 100644 --- a/tests/Cookie/CookieManagerTest.php +++ b/tests/Cookie/CookieManagerTest.php @@ -4,8 +4,8 @@ namespace Hypervel\Tests\Cookie; -use Hyperf\Context\RequestContext; use Hyperf\HttpServer\Contract\RequestInterface; +use Hypervel\Context\RequestContext; use Hypervel\Cookie\Cookie; use Hypervel\Cookie\CookieManager; use Hypervel\Tests\TestCase; 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/Coordinator/CoordinatorTest.php b/tests/Coordinator/CoordinatorTest.php new file mode 100644 index 000000000..4d07379ae --- /dev/null +++ b/tests/Coordinator/CoordinatorTest.php @@ -0,0 +1,55 @@ +yield(0.001); + $this->assertFalse($aborted); + } + + public function testYieldMicroSeconds() + { + $coord = new Coordinator(); + $aborted = $coord->yield(0.000001); + $this->assertFalse($aborted); + } + + public function testYieldResume() + { + $coord = new Coordinator(); + $wg = new WaitGroup(); + $wg->add(); + go(function () use ($coord, $wg) { + $aborted = $coord->yield(10); + $this->assertTrue($aborted); + $wg->done(); + }); + $wg->add(); + go(function () use ($coord, $wg) { + $aborted = $coord->yield(10); + $this->assertTrue($aborted); + $wg->done(); + }); + $coord->resume(); + $wg->wait(); + } +} diff --git a/tests/Coordinator/FunctionTest.php b/tests/Coordinator/FunctionTest.php new file mode 100644 index 000000000..bbda4781a --- /dev/null +++ b/tests/Coordinator/FunctionTest.php @@ -0,0 +1,65 @@ +assertFalse($aborted); + } + + public function testBlockMicroSeconds() + { + $aborted = block(0.000001); + $this->assertFalse($aborted); + } + + public function testResume() + { + $identifier = uniqid(); + $wg = new WaitGroup(); + $wg->add(); + go(function () use ($wg, $identifier) { + $aborted = block(10, $identifier); + $this->assertTrue($aborted); + $wg->done(); + }); + $wg->add(); + go(function () use ($wg, $identifier) { + $aborted = block(10, $identifier); + $this->assertTrue($aborted); + $wg->done(); + }); + resume($identifier); + $wg->wait(); + } +} diff --git a/tests/Coordinator/TimerTest.php b/tests/Coordinator/TimerTest.php new file mode 100644 index 000000000..5eb5dbf83 --- /dev/null +++ b/tests/Coordinator/TimerTest.php @@ -0,0 +1,149 @@ +wait(function () { + $id = 0; + $timer = new Timer(); + $identifier = uniqid(); + $timer->after(0.001, function ($isClosing) use (&$id) { + ++$id; + $this->assertFalse($isClosing); + }, $identifier); + + $this->assertSame(0, $id); + usleep(10000); + $this->assertSame(1, $id); + }); + } + + public function testAfterWhenClosing() + { + $this->wait(function () { + $id = 0; + $timer = new Timer(); + $identifier = uniqid(); + $timer->after(0.001, function ($isClosing) use (&$id) { + ++$id; + $this->assertTrue($isClosing); + }, $identifier); + + $this->assertSame(0, $id); + CoordinatorManager::until($identifier)->resume(); + $this->assertSame(1, $id); + }); + } + + public function testAfterWhenClear() + { + $this->wait(function () { + $id = 0; + $timer = new Timer(); + $identifier = uniqid(); + $ret = $timer->after(0.001, function () use (&$id) { + ++$id; + }, $identifier); + $timer->clear($ret); + CoordinatorManager::until($identifier)->resume(); + $this->assertSame(0, $id); + }); + } + + public function testTick() + { + $this->wait(function () { + $id = 0; + $timer = new Timer(); + $identifier = uniqid(); + $timer->tick(0.001, function () use (&$id) { + ++$id; + }, $identifier); + usleep(10000); + CoordinatorManager::until($identifier)->resume(); + $this->assertGreaterThanOrEqual(1, $id); + }); + } + + public function testTickWhenReturnStop() + { + $this->wait(function () { + $id = 0; + $timer = new Timer(); + $identifier = uniqid(); + $timer->tick(0.001, function () use (&$id) { + ++$id; + if ($id >= 10) { + return Timer::STOP; + } + }, $identifier); + usleep(20000); + $this->assertSame(10, $id); + }); + } + + public function testClearDontExistsClosure() + { + $timer = new Timer(); + + $timer->clear(999); + + $this->assertTrue(true); + } + + public function testUntil() + { + $this->wait(function () { + $id = 0; + $timer = new Timer(); + $identifier = uniqid(); + $timer->until(function () use (&$id) { + ++$id; + }, $identifier); + + $this->assertSame(0, $id); + CoordinatorManager::until($identifier)->resume(); + $this->assertSame(1, $id); + }); + } + + public function testUntilWhenClear() + { + $this->wait(function () { + $id = 0; + $timer = new Timer(); + $identifier = uniqid(); + $ret = $timer->until(function () use (&$id) { + ++$id; + }, $identifier); + $timer->clear($ret); + $this->assertSame(0, $id); + CoordinatorManager::until($identifier)->resume(); + $this->assertSame(0, $id); + }); + } + + private function wait(Closure $closure): void + { + $waiter = new Waiter(); + $waiter->wait($closure); + } +} diff --git a/tests/Core/ContextTest.php b/tests/Core/ContextTest.php deleted file mode 100644 index 693136cc9..000000000 --- a/tests/Core/ContextTest.php +++ /dev/null @@ -1,81 +0,0 @@ - 'value1', - 'key2' => 'value2', - 'key3' => 'value3', - ]; - - Context::setMany($values); - - foreach ($values as $key => $expectedValue) { - $this->assertTrue(Context::has($key)); - $this->assertEquals($expectedValue, Context::get($key)); - } - } - - /** - * @covers ::copyFromNonCoroutine - */ - public function testCopyFromNonCoroutineWithSpecificKeys(): void - { - Context::set('foo', 'foo'); - Context::set('bar', 'bar'); - - run(function () { - Coroutine::create(function () { - Context::copyFromNonCoroutine(); - $this->assertSame('foo', Context::get('foo')); - $this->assertSame('bar', Context::get('bar')); - }); - }); - } - - /** - * @covers ::destroyAll - */ - public function testDestroyAll(): void - { - Context::set('key1', 'value1'); - Context::set('key2', 'value2'); - - $this->assertTrue(Context::has('key1')); - $this->assertTrue(Context::has('key2')); - - Context::destroyAll(); - - $this->assertFalse(Context::has('key1')); - $this->assertFalse(Context::has('key2')); - } -} 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/HasCollectionTest.php b/tests/Core/Database/Eloquent/Concerns/HasCollectionTest.php deleted file mode 100644 index 37fb8e6cc..000000000 --- a/tests/Core/Database/Eloquent/Concerns/HasCollectionTest.php +++ /dev/null @@ -1,275 +0,0 @@ -newCollection([]); - - $this->assertInstanceOf(Collection::class, $collection); - $this->assertNotInstanceOf(CustomTestCollection::class, $collection); - } - - public function testNewCollectionReturnsCustomCollectionWhenAttributePresent(): void - { - $model = new HasCollectionTestModelWithAttribute(); - - $collection = $model->newCollection([]); - - $this->assertInstanceOf(CustomTestCollection::class, $collection); - } - - public function testNewCollectionPassesModelsToCollection(): void - { - $model1 = new HasCollectionTestModel(); - $model2 = new HasCollectionTestModel(); - - $collection = $model1->newCollection([$model1, $model2]); - - $this->assertCount(2, $collection); - $this->assertSame($model1, $collection[0]); - $this->assertSame($model2, $collection[1]); - } - - public function testNewCollectionCachesResolvedCollectionClass(): void - { - $model1 = new HasCollectionTestModelWithAttribute(); - $model2 = new HasCollectionTestModelWithAttribute(); - - // First call should resolve and cache - $collection1 = $model1->newCollection([]); - - // Second call should use cache - $collection2 = $model2->newCollection([]); - - // Both should be CustomTestCollection - $this->assertInstanceOf(CustomTestCollection::class, $collection1); - $this->assertInstanceOf(CustomTestCollection::class, $collection2); - } - - public function testResolveCollectionFromAttributeReturnsNullWhenNoAttribute(): void - { - $model = new HasCollectionTestModel(); - - $result = $model->testResolveCollectionFromAttribute(); - - $this->assertNull($result); - } - - public function testResolveCollectionFromAttributeReturnsCollectionClassWhenAttributePresent(): void - { - $model = new HasCollectionTestModelWithAttribute(); - - $result = $model->testResolveCollectionFromAttribute(); - - $this->assertSame(CustomTestCollection::class, $result); - } - - public function testDifferentModelsUseDifferentCaches(): void - { - $modelWithoutAttribute = new HasCollectionTestModel(); - $modelWithAttribute = new HasCollectionTestModelWithAttribute(); - - $collection1 = $modelWithoutAttribute->newCollection([]); - $collection2 = $modelWithAttribute->newCollection([]); - - $this->assertInstanceOf(Collection::class, $collection1); - $this->assertNotInstanceOf(CustomTestCollection::class, $collection1); - $this->assertInstanceOf(CustomTestCollection::class, $collection2); - } - - public function testChildModelWithoutAttributeUsesDefaultCollection(): void - { - $model = new HasCollectionTestChildModel(); - - $collection = $model->newCollection([]); - - // PHP attributes are not inherited - child needs its own attribute - $this->assertInstanceOf(Collection::class, $collection); - $this->assertNotInstanceOf(CustomTestCollection::class, $collection); - } - - public function testChildModelWithOwnAttributeUsesOwnCollection(): void - { - $model = new HasCollectionTestChildModelWithOwnAttribute(); - - $collection = $model->newCollection([]); - - $this->assertInstanceOf(AnotherCustomTestCollection::class, $collection); - } - - public function testNewCollectionUsesCollectionClassPropertyWhenNoAttribute(): void - { - $model = new HasCollectionTestModelWithProperty(); - - $collection = $model->newCollection([]); - - $this->assertInstanceOf(PropertyTestCollection::class, $collection); - } - - public function testAttributeTakesPrecedenceOverCollectionClassProperty(): void - { - $model = new HasCollectionTestModelWithAttributeAndProperty(); - - $collection = $model->newCollection([]); - - // Attribute should win over property - $this->assertInstanceOf(CustomTestCollection::class, $collection); - $this->assertNotInstanceOf(PropertyTestCollection::class, $collection); - } -} - -// Test fixtures - -class HasCollectionTestModel extends Model -{ - protected ?string $table = 'test_models'; - - /** - * Expose protected method for testing. - */ - public function testResolveCollectionFromAttribute(): ?string - { - return $this->resolveCollectionFromAttribute(); - } - - /** - * Clear the static cache for testing. - */ - public static function clearResolvedCollectionClasses(): void - { - static::$resolvedCollectionClasses = []; - } -} - -#[CollectedBy(CustomTestCollection::class)] -class HasCollectionTestModelWithAttribute extends Model -{ - protected ?string $table = 'test_models'; - - /** - * Expose protected method for testing. - */ - public function testResolveCollectionFromAttribute(): ?string - { - return $this->resolveCollectionFromAttribute(); - } - - /** - * Clear the static cache for testing. - */ - public static function clearResolvedCollectionClasses(): void - { - static::$resolvedCollectionClasses = []; - } -} - -class HasCollectionTestChildModel extends HasCollectionTestModelWithAttribute -{ - /** - * Clear the static cache for testing. - */ - public static function clearResolvedCollectionClasses(): void - { - static::$resolvedCollectionClasses = []; - } -} - -#[CollectedBy(AnotherCustomTestCollection::class)] -class HasCollectionTestChildModelWithOwnAttribute extends HasCollectionTestModelWithAttribute -{ - /** - * Clear the static cache for testing. - */ - public static function clearResolvedCollectionClasses(): void - { - static::$resolvedCollectionClasses = []; - } -} - -/** - * @template TKey of array-key - * @template TModel of Model - * @extends Collection - */ -class CustomTestCollection extends Collection -{ -} - -/** - * @template TKey of array-key - * @template TModel of Model - * @extends Collection - */ -class AnotherCustomTestCollection extends Collection -{ -} - -class HasCollectionTestModelWithProperty extends Model -{ - protected ?string $table = 'test_models'; - - protected static string $collectionClass = PropertyTestCollection::class; - - /** - * Clear the static cache for testing. - */ - public static function clearResolvedCollectionClasses(): void - { - static::$resolvedCollectionClasses = []; - } -} - -#[CollectedBy(CustomTestCollection::class)] -class HasCollectionTestModelWithAttributeAndProperty extends Model -{ - protected ?string $table = 'test_models'; - - // Property should be ignored when attribute is present - protected static string $collectionClass = PropertyTestCollection::class; - - /** - * Clear the static cache for testing. - */ - public static function clearResolvedCollectionClasses(): void - { - static::$resolvedCollectionClasses = []; - } -} - -/** - * @template TKey of array-key - * @template TModel of Model - * @extends Collection - */ -class PropertyTestCollection extends Collection -{ -} 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/PivotCollectionTest.php b/tests/Core/Database/Eloquent/Relations/PivotCollectionTest.php deleted file mode 100644 index 7a9fb0f8c..000000000 --- a/tests/Core/Database/Eloquent/Relations/PivotCollectionTest.php +++ /dev/null @@ -1,256 +0,0 @@ -newCollection([]); - - $this->assertInstanceOf(Collection::class, $collection); - } - - public function testPivotNewCollectionReturnsCustomCollectionWhenAttributePresent(): void - { - $pivot = new PivotCollectionTestPivotWithAttribute(); - - $collection = $pivot->newCollection([]); - - $this->assertInstanceOf(CustomPivotCollection::class, $collection); - } - - public function testPivotNewCollectionUsesCollectionClassPropertyWhenNoAttribute(): void - { - $pivot = new PivotCollectionTestPivotWithProperty(); - - $collection = $pivot->newCollection([]); - - $this->assertInstanceOf(PropertyPivotCollection::class, $collection); - } - - public function testPivotAttributeTakesPrecedenceOverCollectionClassProperty(): void - { - $pivot = new PivotCollectionTestPivotWithAttributeAndProperty(); - - $collection = $pivot->newCollection([]); - - // Attribute should win over property - $this->assertInstanceOf(CustomPivotCollection::class, $collection); - $this->assertNotInstanceOf(PropertyPivotCollection::class, $collection); - } - - public function testPivotNewCollectionPassesModelsToCollection(): void - { - $pivot1 = new PivotCollectionTestPivot(); - $pivot2 = new PivotCollectionTestPivot(); - - $collection = $pivot1->newCollection([$pivot1, $pivot2]); - - $this->assertCount(2, $collection); - $this->assertSame($pivot1, $collection[0]); - $this->assertSame($pivot2, $collection[1]); - } - - // ========================================================================= - // MorphPivot Tests - // ========================================================================= - - public function testMorphPivotNewCollectionReturnsHypervelCollectionByDefault(): void - { - $pivot = new PivotCollectionTestMorphPivot(); - - $collection = $pivot->newCollection([]); - - $this->assertInstanceOf(Collection::class, $collection); - } - - public function testMorphPivotNewCollectionReturnsCustomCollectionWhenAttributePresent(): void - { - $pivot = new PivotCollectionTestMorphPivotWithAttribute(); - - $collection = $pivot->newCollection([]); - - $this->assertInstanceOf(CustomMorphPivotCollection::class, $collection); - } - - public function testMorphPivotNewCollectionUsesCollectionClassPropertyWhenNoAttribute(): void - { - $pivot = new PivotCollectionTestMorphPivotWithProperty(); - - $collection = $pivot->newCollection([]); - - $this->assertInstanceOf(PropertyMorphPivotCollection::class, $collection); - } - - public function testMorphPivotAttributeTakesPrecedenceOverCollectionClassProperty(): void - { - $pivot = new PivotCollectionTestMorphPivotWithAttributeAndProperty(); - - $collection = $pivot->newCollection([]); - - // Attribute should win over property - $this->assertInstanceOf(CustomMorphPivotCollection::class, $collection); - $this->assertNotInstanceOf(PropertyMorphPivotCollection::class, $collection); - } -} - -// ========================================================================= -// Pivot Test Fixtures -// ========================================================================= - -class PivotCollectionTestPivot extends Pivot -{ - public static function clearResolvedCollectionClasses(): void - { - static::$resolvedCollectionClasses = []; - } -} - -#[CollectedBy(CustomPivotCollection::class)] -class PivotCollectionTestPivotWithAttribute extends Pivot -{ - public static function clearResolvedCollectionClasses(): void - { - static::$resolvedCollectionClasses = []; - } -} - -class PivotCollectionTestPivotWithProperty extends Pivot -{ - protected static string $collectionClass = PropertyPivotCollection::class; - - public static function clearResolvedCollectionClasses(): void - { - static::$resolvedCollectionClasses = []; - } -} - -#[CollectedBy(CustomPivotCollection::class)] -class PivotCollectionTestPivotWithAttributeAndProperty extends Pivot -{ - protected static string $collectionClass = PropertyPivotCollection::class; - - public static function clearResolvedCollectionClasses(): void - { - static::$resolvedCollectionClasses = []; - } -} - -// ========================================================================= -// MorphPivot Test Fixtures -// ========================================================================= - -class PivotCollectionTestMorphPivot extends MorphPivot -{ - public static function clearResolvedCollectionClasses(): void - { - static::$resolvedCollectionClasses = []; - } -} - -#[CollectedBy(CustomMorphPivotCollection::class)] -class PivotCollectionTestMorphPivotWithAttribute extends MorphPivot -{ - public static function clearResolvedCollectionClasses(): void - { - static::$resolvedCollectionClasses = []; - } -} - -class PivotCollectionTestMorphPivotWithProperty extends MorphPivot -{ - protected static string $collectionClass = PropertyMorphPivotCollection::class; - - public static function clearResolvedCollectionClasses(): void - { - static::$resolvedCollectionClasses = []; - } -} - -#[CollectedBy(CustomMorphPivotCollection::class)] -class PivotCollectionTestMorphPivotWithAttributeAndProperty extends MorphPivot -{ - protected static string $collectionClass = PropertyMorphPivotCollection::class; - - public static function clearResolvedCollectionClasses(): void - { - static::$resolvedCollectionClasses = []; - } -} - -// ========================================================================= -// Custom Collection Classes -// ========================================================================= - -/** - * @template TKey of array-key - * @template TModel - * @extends Collection - */ -class CustomPivotCollection extends Collection -{ -} - -/** - * @template TKey of array-key - * @template TModel - * @extends Collection - */ -class PropertyPivotCollection extends Collection -{ -} - -/** - * @template TKey of array-key - * @template TModel - * @extends Collection - */ -class CustomMorphPivotCollection extends Collection -{ -} - -/** - * @template TKey of array-key - * @template TModel - * @extends Collection - */ -class PropertyMorphPivotCollection extends Collection -{ -} 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..60b331841 100644 --- a/tests/Core/EloquentBroadcastingTest.php +++ b/tests/Core/EloquentBroadcastingTest.php @@ -5,21 +5,21 @@ 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; -use Mockery; +use Mockery as m; /** * @internal @@ -217,13 +217,13 @@ public function testBroadcastPayloadCanBeDefined() private function assertHandldedBroadcastableEvent(BroadcastableModelEventOccurred $event, Closure $closure) { - $broadcaster = Mockery::mock(Broadcaster::class); + $broadcaster = m::mock(Broadcaster::class); $broadcaster->shouldReceive('broadcast')->once() ->withArgs(function (array $channels, string $eventName, array $payload) use ($closure) { return $closure($channels, $eventName, $payload); }); - $manager = Mockery::mock(BroadcastingFactory::class); + $manager = m::mock(BroadcastingFactory::class); $manager->shouldReceive('connection')->once()->with(null)->andReturn($broadcaster); (new BroadcastEvent($event))->handle($manager); diff --git a/tests/Core/ModelListenerTest.php b/tests/Core/ModelListenerTest.php deleted file mode 100644 index 8210aab36..000000000 --- a/tests/Core/ModelListenerTest.php +++ /dev/null @@ -1,102 +0,0 @@ -expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Unable to find model class: model'); - - $this->getModelListener() - ->register('model', 'event', fn () => true); - } - - public function testRegisterWithInvalidEvent() - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Event [event] is not a valid Eloquent event.'); - - $this->getModelListener() - ->register(new ModelUser(), 'event', fn () => true); - } - - public function testRegister() - { - $dispatcher = m::mock(EventDispatcherInterface::class); - $dispatcher->shouldReceive('listen') - ->once() - ->with(Created::class, m::type('callable')); - - $manager = $this->getModelListener($dispatcher); - $manager->register($user = new ModelUser(), 'created', $callback = fn () => true); - - $this->assertSame( - [$callback], - $manager->getCallbacks($user, 'created') - ); - - $this->assertSame( - ['created' => [$callback]], - $manager->getCallbacks($user) - ); - } - - public function testClear() - { - $dispatcher = m::mock(EventDispatcherInterface::class); - $dispatcher->shouldReceive('listen') - ->once() - ->with(Created::class, m::type('callable')); - - $manager = $this->getModelListener($dispatcher); - $manager->register($user = new ModelUser(), 'created', fn () => true); - - $manager->clear($user); - - $this->assertSame([], $manager->getCallbacks(new ModelUser())); - } - - public function testHandleEvents() - { - $dispatcher = m::mock(EventDispatcherInterface::class); - $dispatcher->shouldReceive('listen') - ->once() - ->with(Created::class, m::type('callable')); - - $callbackUser = null; - $manager = $this->getModelListener($dispatcher); - $manager->register($user = new ModelUser(), 'created', function ($user) use (&$callbackUser) { - $callbackUser = $user; - }); - $manager->handleEvent(new Created($user)); - - $this->assertSame($user, $callbackUser); - } - - protected function getModelListener(?EventDispatcherInterface $dispatcher = null): ModelListener - { - return new ModelListener( - $dispatcher ?? m::mock(EventDispatcherInterface::class) - ); - } -} - -class ModelUser extends Model -{ -} 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/Coroutine/BarrierTest.php b/tests/Coroutine/BarrierTest.php new file mode 100644 index 000000000..e113f2517 --- /dev/null +++ b/tests/Coroutine/BarrierTest.php @@ -0,0 +1,35 @@ +assertSame($N, $count); + } +} diff --git a/tests/Coroutine/Channel/CallerTest.php b/tests/Coroutine/Channel/CallerTest.php new file mode 100644 index 000000000..59091c72e --- /dev/null +++ b/tests/Coroutine/Channel/CallerTest.php @@ -0,0 +1,81 @@ +call(static function ($instance) { + return 1; + }); + + $this->assertSame(1, $id); + + $id = $caller->call(static function ($instance) { + return 2; + }); + + $this->assertSame(2, $id); + } + + public function testCaller() + { + $obj = new stdClass(); + $obj->id = uniqid(); + $caller = new Caller(static function () use ($obj) { + return $obj; + }); + + $id = $caller->call(static function ($instance) { + return $instance->id; + }); + + $this->assertSame($obj->id, $id); + + $caller->call(function ($instance) use ($obj) { + $this->assertSame($instance, $obj); + }); + } + + public function testCallerPopTimeout() + { + $obj = new stdClass(); + $obj->id = uniqid(); + $caller = new Caller(static function () use ($obj) { + return $obj; + }, 0.001); + + go(static function () use ($caller) { + $caller->call(static function ($instance) { + usleep(10 * 1000); + }); + }); + + $this->expectException(WaitTimeoutException::class); + + $caller->call(static function ($instance) { + return 1; + }); + } +} diff --git a/tests/Coroutine/Channel/ChannelManagerTest.php b/tests/Coroutine/Channel/ChannelManagerTest.php new file mode 100644 index 000000000..2f85a6b37 --- /dev/null +++ b/tests/Coroutine/Channel/ChannelManagerTest.php @@ -0,0 +1,52 @@ +get(1, true); + $this->assertInstanceOf(Channel::class, $chan); + $chan = $manager->get(1); + $this->assertInstanceOf(Channel::class, $chan); + go(function () use ($chan) { + usleep(10 * 1000); + $chan->push('Hello World.'); + }); + + $this->assertSame('Hello World.', $chan->pop()); + $manager->close(1); + $this->assertTrue($chan->isClosing()); + $this->assertNull($manager->get(1)); + } + + public function testChannelFlush() + { + $manager = new ChannelManager(); + $manager->get(1, true); + $manager->get(2, true); + $manager->get(4, true); + $manager->get(5, true); + + $this->assertSame(4, count($manager->getChannels())); + $manager->flush(); + $this->assertSame(0, count($manager->getChannels())); + } +} diff --git a/tests/Coroutine/ConcurrentTest.php b/tests/Coroutine/ConcurrentTest.php new file mode 100644 index 000000000..35621dae1 --- /dev/null +++ b/tests/Coroutine/ConcurrentTest.php @@ -0,0 +1,87 @@ +getContainer(); + } + + public function testConcurrent() + { + $concurrent = new Concurrent($limit = 10); + $this->assertSame($limit, $concurrent->getLimit()); + $this->assertTrue($concurrent->isEmpty()); + $this->assertFalse($concurrent->isFull()); + + $count = 0; + for ($i = 0; $i < 15; ++$i) { + $concurrent->create(function () use (&$count) { + Coroutine::sleep(0.1); + ++$count; + }); + } + + $this->assertTrue($concurrent->isFull()); + $this->assertSame(5, $count); + $this->assertSame($limit, $concurrent->getRunningCoroutineCount()); + $this->assertSame($limit, $concurrent->getLength()); + $this->assertSame($limit, $concurrent->length()); + + while (! $concurrent->isEmpty()) { + Coroutine::sleep(0.1); + } + + $this->assertSame(15, $count); + } + + public function testException() + { + $con = new Concurrent(10); + $count = 0; + + for ($i = 0; $i < 15; ++$i) { + $con->create(function () use (&$count) { + Coroutine::sleep(0.1); + ++$count; + throw new Exception('ddd'); + }); + } + + $this->assertSame(5, $count); + $this->assertSame(10, $con->getRunningCoroutineCount()); + + while (! $con->isEmpty()) { + Coroutine::sleep(0.1); + } + $this->assertSame(15, $count); + } + + protected function getContainer(): void + { + $container = m::mock(ContainerContract::class); + $container->shouldReceive('has')->andReturn(false); + + ApplicationContext::setContainer($container); + } +} diff --git a/tests/Coroutine/CoroutineNonCoroutineContextTest.php b/tests/Coroutine/CoroutineNonCoroutineContextTest.php new file mode 100644 index 000000000..c0546c23b --- /dev/null +++ b/tests/Coroutine/CoroutineNonCoroutineContextTest.php @@ -0,0 +1,43 @@ +assertSame(0, Coroutine::parentId()); + }); + } + + public function testRun() + { + $asserts = [ + SWOOLE_HOOK_ALL, + SWOOLE_HOOK_SLEEP, + SWOOLE_HOOK_CURL, + ]; + + foreach ($asserts as $flags) { + run(function () use ($flags) { + $this->assertTrue(Coroutine::inCoroutine()); + $this->assertSame($flags, Runtime::getHookFlags()); + }, $flags); + } + } +} diff --git a/tests/Coroutine/CoroutineTest.php b/tests/Coroutine/CoroutineTest.php new file mode 100644 index 000000000..90219a2af --- /dev/null +++ b/tests/Coroutine/CoroutineTest.php @@ -0,0 +1,174 @@ +assertSame($pid, Coroutine::parentId()); + $pid = Coroutine::id(); + $id = Coroutine::create(function () use ($pid) { + $this->assertSame($pid, Coroutine::parentId(Coroutine::id())); + usleep(1000); + }); + Coroutine::create(function () use ($pid) { + $this->assertSame($pid, Coroutine::parentId()); + }); + $this->assertSame($pid, Coroutine::parentId($id)); + }); + } + + public function testCoroutineParentIdHasBeenDestroyed() + { + $id = Coroutine::create(function () { + }); + + try { + Coroutine::parentId($id); + $this->assertTrue(false); + } catch (Throwable $exception) { + $this->assertInstanceOf(CoroutineDestroyedException::class, $exception); + } + } + + public function testCoroutineAndDeferWithException() + { + $container = m::mock(ContainerContract::class); + ApplicationContext::setContainer($container); + + $exception = new Exception(); + $container->shouldReceive('has')->with(ExceptionHandlerContract::class)->andReturnTrue(); + $container->shouldReceive('get')->with(ExceptionHandlerContract::class) + ->andReturn($handler = m::mock(ExceptionHandlerContract::class)); + $handler->shouldReceive('report')->with($exception)->twice(); + + $chan = new Channel(1); + go(static function () use ($chan, $exception) { + defer(static function () use ($chan, $exception) { + try { + throw $exception; + } finally { + $chan->push(1); + } + }); + + throw $exception; + }); + + $this->assertTrue(true); + } + + public function testAfterCreatedCallbacksAreExecuted() + { + $executed = false; + + Coroutine::afterCreated(function () use (&$executed) { + $executed = true; + }); + + Coroutine::create(function () { + // The afterCreated callback should have run before this + }); + + $this->assertTrue($executed); + + // Clean up + Coroutine::flushAfterCreated(); + } + + public function testAfterCreatedCallbacksExecuteInOrder() + { + $order = []; + + Coroutine::afterCreated(function () use (&$order) { + $order[] = 1; + }); + + Coroutine::afterCreated(function () use (&$order) { + $order[] = 2; + }); + + Coroutine::create(function () use (&$order) { + $order[] = 3; + }); + + $this->assertSame([1, 2, 3], $order); + + // Clean up + Coroutine::flushAfterCreated(); + } + + public function testFlushAfterCreatedClearsCallbacks() + { + $count = 0; + + Coroutine::afterCreated(function () use (&$count) { + ++$count; + }); + + Coroutine::create(function () {}); + $this->assertSame(1, $count); + + Coroutine::flushAfterCreated(); + + Coroutine::create(function () {}); + $this->assertSame(1, $count); // Should still be 1, callback was flushed + } + + public function testAfterCreatedCallbackExceptionDoesNotStopOthers() + { + $container = m::mock(ContainerContract::class); + ApplicationContext::setContainer($container); + $container->shouldReceive('has')->with(ExceptionHandlerContract::class)->andReturnTrue(); + $container->shouldReceive('get')->with(ExceptionHandlerContract::class) + ->andReturn($handler = m::mock(ExceptionHandlerContract::class)); + $handler->shouldReceive('report')->once(); + + $secondCallbackRan = false; + $mainCallableRan = false; + + Coroutine::afterCreated(function () { + throw new Exception('First callback fails'); + }); + + Coroutine::afterCreated(function () use (&$secondCallbackRan) { + $secondCallbackRan = true; + }); + + Coroutine::create(function () use (&$mainCallableRan) { + $mainCallableRan = true; + }); + + $this->assertTrue($secondCallbackRan); + $this->assertTrue($mainCallableRan); + + // Clean up + Coroutine::flushAfterCreated(); + } +} diff --git a/tests/Coroutine/FunctionTest.php b/tests/Coroutine/FunctionTest.php new file mode 100644 index 000000000..1d8967157 --- /dev/null +++ b/tests/Coroutine/FunctionTest.php @@ -0,0 +1,63 @@ +assertTrue(is_int($id)); + $this->assertSame('Hypervel', $uniqid); + } + + public function testDefer() + { + $channel = new Channel(10); + parallel([function () use ($channel) { + defer(function () use ($channel) { + $channel->push(0); + }); + defer(function () use ($channel) { + $channel->push(1); + defer(function () use ($channel) { + $channel->push(2); + }); + defer(function () use ($channel) { + $channel->push(3); + }); + }); + defer(function () use ($channel) { + $channel->push(4); + }); + $channel->push(5); + }]); + + $this->assertSame(5, $channel->pop(0.001)); + $this->assertSame(4, $channel->pop(0.001)); + $this->assertSame(1, $channel->pop(0.001)); + $this->assertSame(3, $channel->pop(0.001)); + $this->assertSame(2, $channel->pop(0.001)); + $this->assertSame(0, $channel->pop(0.001)); + } +} diff --git a/tests/Coroutine/LockerTest.php b/tests/Coroutine/LockerTest.php new file mode 100644 index 000000000..319ff7b94 --- /dev/null +++ b/tests/Coroutine/LockerTest.php @@ -0,0 +1,53 @@ +push(1); + usleep(10000); + $chan->push(2); + Locker::unlock('foo'); + }); + + go(function () use ($chan) { + Locker::lock('foo'); + $chan->push(3); + usleep(10000); + $chan->push(4); + }); + + go(function () use ($chan) { + Locker::lock('foo'); + $chan->push(5); + $chan->push(6); + }); + + $ret = []; + while ($res = $chan->pop(1)) { + $ret[] = $res; + } + + $this->assertSame([1, 2, 3, 5, 6, 4], $ret); + } +} diff --git a/tests/Coroutine/MutexTest.php b/tests/Coroutine/MutexTest.php new file mode 100644 index 000000000..1b7760fc8 --- /dev/null +++ b/tests/Coroutine/MutexTest.php @@ -0,0 +1,53 @@ +push($value); + } finally { + Mutex::unlock('test'); + } + } + }; + + $wg = new WaitGroup(5); + foreach (['h', 'e', 'l', 'l', 'o'] as $value) { + go(function () use ($func, $value, $wg) { + $func($value); + $wg->done(); + }); + } + + $res = ''; + $wg->wait(1); + for ($i = 0; $i < 5; ++$i) { + $res .= $chan->pop(1); + } + + $this->assertSame('hello', $res); + } +} diff --git a/tests/Coroutine/ParallelTest.php b/tests/Coroutine/ParallelTest.php new file mode 100644 index 000000000..ac2368f93 --- /dev/null +++ b/tests/Coroutine/ParallelTest.php @@ -0,0 +1,281 @@ +add(function () { + return Coroutine::id(); + }); + } + $result = $parallel->wait(); + $id = $result[0]; + $this->assertSame([$id, $id + 1, $id + 2], $result); + + // Array + $parallel = new Parallel(); + for ($i = 0; $i < 3; ++$i) { + $parallel->add([$this, 'returnCoId']); + } + $result = $parallel->wait(); + $id = $result[0]; + $this->assertSame([$id, $id + 1, $id + 2], $result); + } + + public function testParallelConcurrent() + { + $parallel = new Parallel(); + $num = 0; + $callback = function () use (&$num) { + ++$num; + Coroutine::sleep(0.01); + return $num; + }; + for ($i = 0; $i < 4; ++$i) { + $parallel->add($callback); + } + $res = $parallel->wait(); + $this->assertSame([4, 4, 4, 4], array_values($res)); + + $parallel = new Parallel(2); + $num = 0; + $callback = function () use (&$num) { + ++$num; + Coroutine::sleep(0.01); + return $num; + }; + for ($i = 0; $i < 4; ++$i) { + $parallel->add($callback); + } + $res = $parallel->wait(); + sort($res); + $this->assertSame([2, 3, 4, 4], array_values($res)); + + $num = 10; + $callbacks = []; + for ($i = 0; $i < 4; ++$i) { + $callbacks[] = function () use (&$num) { + ++$num; + Coroutine::sleep(0.01); + return $num; + }; + } + $res = parallel($callbacks, 2); + sort($res); + $this->assertSame([12, 13, 14, 14], array_values($res)); + } + + public function testParallelCallbackCount() + { + $parallel = new Parallel(); + $callback = function () { + return 1; + }; + for ($i = 0; $i < 4; ++$i) { + $parallel->add($callback); + } + $res = $parallel->wait(); + $this->assertEquals(count($res), 4); + + for ($i = 0; $i < 4; ++$i) { + $parallel->add($callback); + } + $res = $parallel->wait(); + $this->assertEquals(count($res), 8); + } + + public function testParallelClear() + { + $parallel = new Parallel(); + $callback = function () { + return 1; + }; + for ($i = 0; $i < 4; ++$i) { + $parallel->add($callback); + } + $res = $parallel->wait(); + $parallel->clear(); + $this->assertEquals(count($res), 4); + + for ($i = 0; $i < 4; ++$i) { + $parallel->add($callback); + } + $res = $parallel->wait(); + $parallel->clear(); + $this->assertEquals(count($res), 4); + } + + public function testParallelKeys() + { + $parallel = new Parallel(); + $callback = function () { + return 1; + }; + for ($i = 0; $i < 4; ++$i) { + $parallel->add($callback); + } + $res = $parallel->wait(); + $parallel->clear(); + $this->assertSame([1, 1, 1, 1], $res); + + for ($i = 0; $i < 4; ++$i) { + $parallel->add($callback, 'id_' . $i); + } + $res = $parallel->wait(); + $parallel->clear(); + $this->assertSame(['id_0' => 1, 'id_1' => 1, 'id_2' => 1, 'id_3' => 1], $res); + + for ($i = 0; $i < 4; ++$i) { + $parallel->add($callback, $i - 1); + } + $res = $parallel->wait(); + $parallel->clear(); + $this->assertSame([-1 => 1, 0 => 1, 1 => 1, 2 => 1], $res); + + $parallel->add($callback, 1); + $res = $parallel->wait(); + $parallel->clear(); + $this->assertSame([1 => 1], $res); + } + + public function testParallelThrows() + { + $parallel = new Parallel(); + $err = function () { + Coroutine::sleep(0.001); + throw new RuntimeException('something bad happened'); + }; + $ok = function () { + Coroutine::sleep(0.001); + return 1; + }; + $parallel->add($err); + for ($i = 0; $i < 4; ++$i) { + $parallel->add($ok); + } + $this->expectException(ParallelExecutionException::class); + $res = $parallel->wait(); + } + + public function testParallelResultsAndThrows() + { + $parallel = new Parallel(); + + $err = function () { + Coroutine::sleep(0.001); + throw new RuntimeException('something bad happened'); + }; + $parallel->add($err); + + $ids = [1 => uniqid(), 2 => uniqid(), 3 => uniqid(), 4 => uniqid()]; + foreach ($ids as $id) { + $parallel->add(function () use ($id) { + Coroutine::sleep(0.001); + return $id; + }); + } + + try { + $parallel->wait(); + throw new RuntimeException(); + } catch (ParallelExecutionException $exception) { + foreach (['Detecting', 'RuntimeException', '#0'] as $keyword) { + $this->assertTrue(str_contains($exception->getMessage(), $keyword)); + } + + $result = $exception->getResults(); + $this->assertEquals($ids, $result); + + $throwables = $exception->getThrowables(); + $this->assertTrue(count($throwables) === 1); + $this->assertSame('something bad happened', $throwables[0]->getMessage()); + } + } + + public function testParallelCount() + { + $parallel = new Parallel(); + $id = 0; + $parallel->add(static function () use (&$id) { + ++$id; + }); + $parallel->add(static function () use (&$id) { + ++$id; + }); + $this->assertSame(2, $parallel->count()); + $parallel->wait(); + $this->assertSame(2, $parallel->count()); + $this->assertSame(2, $id); + $parallel->wait(); + $this->assertSame(2, $parallel->count()); + $this->assertSame(4, $id); + } + + public function testTheResultSort() + { + $res = parallel(['a' => function () { + usleep(1000); + return 1; + }, 'b' => function () { + return 2; + }]); + + $this->assertSame(['a' => 1, 'b' => 2], $res); + + $res = parallel(['a' => function () { + usleep(1000); + return 1; + }, 'b' => function () { + }]); + + $this->assertSame(['a' => 1, 'b' => null], $res); + } + + public function testThrowExceptionInParallel() + { + try { + parallel([ + static function () { + throw new Exception(); + }, + ]); + } catch (ParallelExecutionException $exception) { + /** @var Throwable $exception */ + $exception = $exception->getThrowables()[0]; + $traces = $exception->getTrace(); + ob_start(); + var_dump($traces); + $content = ob_get_clean(); + $this->assertStringNotContainsString('*RECURSION*', $content); + } + } + + public function returnCoId(): int + { + return Coroutine::id(); + } +} diff --git a/tests/Coroutine/WaitGroupTest.php b/tests/Coroutine/WaitGroupTest.php new file mode 100644 index 000000000..3573295d1 --- /dev/null +++ b/tests/Coroutine/WaitGroupTest.php @@ -0,0 +1,50 @@ +add(2); + $result = []; + $i = 2; + while ($i--) { + Coroutine::create(function () use ($wg, &$result) { + Coroutine::sleep(0.001); + $result[] = true; + $wg->done(); + }); + } + $wg->wait(1); + $this->assertTrue(count($result) === 2); + + $wg->add(); + $wg->add(); + $result = []; + $i = 2; + while ($i--) { + Coroutine::create(function () use ($wg, &$result) { + Coroutine::sleep(0.001); + $result[] = true; + $wg->done(); + }); + } + $wg->wait(1); + $this->assertTrue(count($result) === 2); + } +} diff --git a/tests/Coroutine/WaiterTest.php b/tests/Coroutine/WaiterTest.php new file mode 100644 index 000000000..5d5877433 --- /dev/null +++ b/tests/Coroutine/WaiterTest.php @@ -0,0 +1,112 @@ +shouldReceive('get')->with(Waiter::class)->andReturn(new Waiter()); + } + + public function testWait() + { + $id = uniqid(); + $result = wait(function () use ($id) { + return $id; + }); + + $this->assertSame($id, $result); + + $id = rand(0, 9999); + $result = wait(function () use ($id) { + return $id + 1; + }); + + $this->assertSame($id + 1, $result); + } + + public function testWaitNone() + { + $callback = function () { + }; + $result = wait($callback); + $this->assertSame($result, $callback()); + $this->assertSame(null, $result); + + $callback = function () { + return null; + }; + $result = wait($callback); + $this->assertSame($result, $callback()); + $this->assertSame(null, $result); + } + + public function testWaitException() + { + $message = uniqid(); + $callback = function () use ($message) { + throw new RuntimeException($message); + }; + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage($message); + wait($callback); + } + + public function testWaitReturnException() + { + $message = uniqid(); + $callback = function () use ($message) { + return new RuntimeException($message); + }; + + $result = wait($callback); + $this->assertInstanceOf(RuntimeException::class, $result); + $this->assertSame($message, $result->getMessage()); + } + + public function testPushTimeout() + { + $channel = new Channel(1); + $this->assertSame(true, $channel->push(1, 1)); + $this->assertSame(false, $channel->push(1, 1)); + } + + public function testTimeout() + { + $callback = function () { + Coroutine::sleep(0.5); + return true; + }; + + $this->expectException(WaitTimeoutException::class); + $this->expectExceptionMessage('Channel wait failed, reason: Timed out for 0.001 s'); + wait($callback, 0.001); + } +} diff --git a/tests/Database/ConnectionTest.php b/tests/Database/ConnectionTest.php new file mode 100644 index 000000000..79d28f6c7 --- /dev/null +++ b/tests/Database/ConnectionTest.php @@ -0,0 +1,225 @@ +get('config')->set(StdoutLoggerInterface::class . '.log_level', []); + } + + /** + * Test that disconnect() rolls back any open transaction. + * + * In Swoole's connection pooling environment, connections are reused across + * requests/coroutines. If a connection is disconnected (or purged) while a + * transaction is open, the transaction must be rolled back to prevent the + * dirty state from leaking to the next user of the pooled connection. + */ + public function testDisconnectRollsBackOpenTransaction(): void + { + $connection = DB::connection(); + + // Start a transaction + $connection->beginTransaction(); + $this->assertSame(1, $connection->transactionLevel()); + + // Disconnect should roll back the transaction + $connection->disconnect(); + + // After disconnect, transaction level should be reset + // (the Connection wrapper's state should be clean) + $this->assertSame(0, $connection->transactionLevel()); + } + + /** + * Test that disconnect() rolls back nested transactions (savepoints). + */ + public function testDisconnectRollsBackNestedTransactions(): void + { + $connection = DB::connection(); + + // Start nested transactions + $connection->beginTransaction(); + $connection->beginTransaction(); + $connection->beginTransaction(); + $this->assertSame(3, $connection->transactionLevel()); + + // Disconnect should roll back all transaction levels + $connection->disconnect(); + + $this->assertSame(0, $connection->transactionLevel()); + } + + /** + * Test that disconnect() works correctly when no transaction is open. + */ + public function testDisconnectWithNoTransactionDoesNotError(): void + { + $connection = DB::connection(); + + $this->assertSame(0, $connection->transactionLevel()); + + // Should not throw any error + $connection->disconnect(); + + $this->assertSame(0, $connection->transactionLevel()); + } + + /** + * Test that purge() cleans up transactions via disconnect(). + */ + public function testPurgeRollsBackOpenTransaction(): void + { + $connection = DB::connection(); + $connectionName = $connection->getName(); + + // Start a transaction + $connection->beginTransaction(); + $this->assertSame(1, $connection->transactionLevel()); + + // Purge should clean up the transaction + DB::purge($connectionName); + + // Get a fresh connection - it should have no transaction + $freshConnection = DB::connection($connectionName); + $this->assertSame(0, $freshConnection->transactionLevel()); + } + + /** + * Test that multiple whenQueryingForLongerThan handlers work correctly. + * + * Uses a single persistent listener internally, but all handlers should fire. + */ + public function testMultipleQueryDurationHandlersAllFire(): void + { + $connection = DB::connection(); + $connection->resetTotalQueryDuration(); + + $fired = ['handler1' => false, 'handler2' => false, 'handler3' => false]; + + // Register multiple handlers with very low thresholds + $connection->whenQueryingForLongerThan(0, function () use (&$fired) { + $fired['handler1'] = true; + }); + $connection->whenQueryingForLongerThan(0, function () use (&$fired) { + $fired['handler2'] = true; + }); + $connection->whenQueryingForLongerThan(0, function () use (&$fired) { + $fired['handler3'] = true; + }); + + // Execute a query to trigger the handlers + $connection->select('SELECT 1'); + + $this->assertTrue($fired['handler1'], 'Handler 1 should have fired'); + $this->assertTrue($fired['handler2'], 'Handler 2 should have fired'); + $this->assertTrue($fired['handler3'], 'Handler 3 should have fired'); + } + + /** + * Test that handlers respect their individual thresholds. + */ + public function testQueryDurationHandlersRespectThresholds(): void + { + $connection = DB::connection(); + $connection->resetTotalQueryDuration(); + + $fired = ['low' => false, 'high' => false]; + + // Low threshold - should fire + $connection->whenQueryingForLongerThan(0, function () use (&$fired) { + $fired['low'] = true; + }); + + // Very high threshold - should not fire + $connection->whenQueryingForLongerThan(999999999, function () use (&$fired) { + $fired['high'] = true; + }); + + $connection->select('SELECT 1'); + + $this->assertTrue($fired['low'], 'Low threshold handler should have fired'); + $this->assertFalse($fired['high'], 'High threshold handler should not have fired'); + } + + /** + * Test that resetForPool clears handlers but new handlers still work. + */ + public function testHandlersWorkAfterResetForPool(): void + { + $connection = DB::connection(); + $connection->resetTotalQueryDuration(); + + $oldHandlerFired = false; + $newHandlerFired = false; + + // Register a handler + $connection->whenQueryingForLongerThan(0, function () use (&$oldHandlerFired) { + $oldHandlerFired = true; + }); + + // Reset the connection (simulating return to pool) + $connection->resetForPool(); + $connection->resetTotalQueryDuration(); + + // Register a new handler after reset + $connection->whenQueryingForLongerThan(0, function () use (&$newHandlerFired) { + $newHandlerFired = true; + }); + + // Execute a query + $connection->select('SELECT 1'); + + $this->assertFalse($oldHandlerFired, 'Old handler should not fire after resetForPool'); + $this->assertTrue($newHandlerFired, 'New handler should fire after resetForPool'); + } + + /** + * Test that handlers only fire once until allowQueryDurationHandlersToRunAgain is called. + */ + public function testHandlersOnlyFireOnceUntilReset(): void + { + $connection = DB::connection(); + $connection->resetTotalQueryDuration(); + + $fireCount = 0; + + $connection->whenQueryingForLongerThan(0, function () use (&$fireCount) { + ++$fireCount; + }); + + // First query - should fire + $connection->select('SELECT 1'); + $this->assertSame(1, $fireCount); + + // Second query - should NOT fire again (already ran) + $connection->select('SELECT 1'); + $this->assertSame(1, $fireCount); + + // Allow handlers to run again + $connection->allowQueryDurationHandlersToRunAgain(); + + // Third query - should fire again + $connection->select('SELECT 1'); + $this->assertSame(2, $fireCount); + } +} 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..041981e27 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.events_disabled'); + 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.events_disabled'; - // 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 c542a8652..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,37 +14,17 @@ 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 * @coversNothing */ -class FactoryTest extends TestCase +class DatabaseEloquentFactoryTest extends TestCase { use RefreshDatabase; @@ -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/Database/EventDispatcherFreshnessTest.php b/tests/Database/EventDispatcherFreshnessTest.php new file mode 100644 index 000000000..d6eee27c8 --- /dev/null +++ b/tests/Database/EventDispatcherFreshnessTest.php @@ -0,0 +1,75 @@ +select('SELECT 1'); + + // Disconnect to force reconnection on next query + $connection->disconnect(); + + // Swap to fake dispatcher AFTER connection is cached + Event::fake([ConnectionEstablished::class]); + + // Trigger reconnection - this should dispatch through the fake + $connection->select('SELECT 1'); + + Event::assertDispatched(ConnectionEstablished::class); + } + + /** + * Test that TransactionBeginning events go through the current dispatcher. + * + * Connection::fireConnectionEvent() dispatches via $this->events. When + * Event::fake() swaps the dispatcher, a rebinding callback updates the + * cached connection's dispatcher to use the fake. + */ + public function testTransactionBeginningUsesCurrentDispatcher(): void + { + // Ensure a connection exists and is cached + $connection = DB::connection(); + $connection->select('SELECT 1'); + + // Swap to fake dispatcher AFTER connection is cached + Event::fake([TransactionBeginning::class]); + + // Start a transaction - this should dispatch through the fake + $connection->beginTransaction(); + $connection->rollBack(); + + Event::assertDispatched(TransactionBeginning::class); + } +} 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..45246a50a --- /dev/null +++ b/tests/Database/Laravel/DatabaseAbstractSchemaGrammarTest.php @@ -0,0 +1,35 @@ +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..7124c1b85 --- /dev/null +++ b/tests/Database/Laravel/DatabaseConcernsBuildsQueriesTraitTest.php @@ -0,0 +1,26 @@ +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..d7f34a52c --- /dev/null +++ b/tests/Database/Laravel/DatabaseConcernsHasAttributesTest.php @@ -0,0 +1,126 @@ +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..387927204 --- /dev/null +++ b/tests/Database/Laravel/DatabaseConcernsPreventsCircularRecursionTest.php @@ -0,0 +1,277 @@ +assertEquals(0, RecursiveMethodStub::$globalStack); + $this->assertEquals(0, $instance->instanceStack); + + $this->assertEquals(0, $instance->callStack()); + $this->assertEquals(1, RecursiveMethodStub::$globalStack); + $this->assertEquals(1, $instance->instanceStack); + + $this->assertEquals(1, $instance->callStack()); + $this->assertEquals(2, RecursiveMethodStub::$globalStack); + $this->assertEquals(2, $instance->instanceStack); + } + + public function testRecursiveDefaultCallbackIsCalledOnlyOnRecursion() + { + $instance = new RecursiveMethodStub(); + + $this->assertEquals(0, RecursiveMethodStub::$globalStack); + $this->assertEquals(0, $instance->instanceStack); + $this->assertEquals(0, $instance->defaultStack); + + $this->assertEquals(['instance' => 1, 'default' => 0], $instance->callCallableDefaultStack()); + $this->assertEquals(1, RecursiveMethodStub::$globalStack); + $this->assertEquals(1, $instance->instanceStack); + $this->assertEquals(1, $instance->defaultStack); + + $this->assertEquals(['instance' => 2, 'default' => 1], $instance->callCallableDefaultStack()); + $this->assertEquals(2, RecursiveMethodStub::$globalStack); + $this->assertEquals(2, $instance->instanceStack); + $this->assertEquals(2, $instance->defaultStack); + } + + public function testRecursiveDefaultCallbackIsCalledOnlyOncePerCallStack() + { + $instance = new RecursiveMethodStub(); + + $this->assertEquals(0, RecursiveMethodStub::$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, RecursiveMethodStub::$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, RecursiveMethodStub::$globalStack); + $this->assertEquals(2, $instance->instanceStack); + $this->assertEquals(2, $instance->defaultStack); + } + + public function testRecursiveCallsAreLimitedToIndividualInstances() + { + $instance = new RecursiveMethodStub(); + $other = $instance->other; + + $this->assertEquals(0, RecursiveMethodStub::$globalStack); + $this->assertEquals(0, $instance->instanceStack); + $this->assertEquals(0, $other->instanceStack); + + $instance->callStack(); + $this->assertEquals(1, RecursiveMethodStub::$globalStack); + $this->assertEquals(1, $instance->instanceStack); + $this->assertEquals(0, $other->instanceStack); + + $instance->callStack(); + $this->assertEquals(2, RecursiveMethodStub::$globalStack); + $this->assertEquals(2, $instance->instanceStack); + $this->assertEquals(0, $other->instanceStack); + + $other->callStack(); + $this->assertEquals(3, RecursiveMethodStub::$globalStack); + $this->assertEquals(2, $instance->instanceStack); + $this->assertEquals(1, $other->instanceStack); + + $other->callStack(); + $this->assertEquals(4, RecursiveMethodStub::$globalStack); + $this->assertEquals(2, $instance->instanceStack); + $this->assertEquals(2, $other->instanceStack); + } + + public function testRecursiveCallsToCircularReferenceCallsOtherInstanceOnce() + { + $instance = new RecursiveMethodStub(); + $other = $instance->other; + + $this->assertEquals(0, RecursiveMethodStub::$globalStack); + $this->assertEquals(0, $instance->instanceStack); + $this->assertEquals(0, $other->instanceStack); + + $instance->callOtherStack(); + $this->assertEquals(2, RecursiveMethodStub::$globalStack); + $this->assertEquals(1, $instance->instanceStack); + $this->assertEquals(1, $other->instanceStack); + + $instance->callOtherStack(); + $this->assertEquals(4, RecursiveMethodStub::$globalStack); + $this->assertEquals(2, $instance->instanceStack); + $this->assertEquals(2, $other->instanceStack); + + $other->callOtherStack(); + $this->assertEquals(6, RecursiveMethodStub::$globalStack); + $this->assertEquals(3, $other->instanceStack); + $this->assertEquals(3, $instance->instanceStack); + + $other->callOtherStack(); + $this->assertEquals(8, RecursiveMethodStub::$globalStack); + $this->assertEquals(4, $other->instanceStack); + $this->assertEquals(4, $instance->instanceStack); + } + + public function testRecursiveCallsToCircularLinkedListCallsEachInstanceOnce() + { + $instance = new RecursiveMethodStub(); + $second = $instance->other; + $third = new RecursiveMethodStub($second); + $instance->other = $third; + + $this->assertEquals(0, RecursiveMethodStub::$globalStack); + $this->assertEquals(0, $instance->instanceStack); + $this->assertEquals(0, $second->instanceStack); + $this->assertEquals(0, $third->instanceStack); + + $instance->callOtherStack(); + $this->assertEquals(3, RecursiveMethodStub::$globalStack); + $this->assertEquals(1, $instance->instanceStack); + $this->assertEquals(1, $second->instanceStack); + $this->assertEquals(1, $third->instanceStack); + + $second->callOtherStack(); + $this->assertEquals(6, RecursiveMethodStub::$globalStack); + $this->assertEquals(2, $instance->instanceStack); + $this->assertEquals(2, $second->instanceStack); + $this->assertEquals(2, $third->instanceStack); + + $third->callOtherStack(); + $this->assertEquals(9, RecursiveMethodStub::$globalStack); + $this->assertEquals(3, $instance->instanceStack); + $this->assertEquals(3, $second->instanceStack); + $this->assertEquals(3, $third->instanceStack); + } + + public function testMockedModelCallToWithoutRecursionMethodWorks(): void + { + $mock = m::mock(ModelStub::class)->makePartial(); + + // Model toArray method implementation + $toArray = $mock->withoutRecursion( + fn () => array_merge($mock->attributesToArray(), $mock->relationsToArray()), + fn () => $mock->attributesToArray(), + ); + $this->assertEquals([], $toArray); + } +} + +class RecursiveMethodStub +{ + use PreventsCircularRecursion; + + public function __construct( + public ?RecursiveMethodStub $other = null, + ) { + $this->other ??= new RecursiveMethodStub($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 ModelStub extends Model +{ +} diff --git a/tests/Database/Laravel/DatabaseConnectionFactoryTest.php b/tests/Database/Laravel/DatabaseConnectionFactoryTest.php new file mode 100755 index 000000000..44621523d --- /dev/null +++ b/tests/Database/Laravel/DatabaseConnectionFactoryTest.php @@ -0,0 +1,185 @@ +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..904662f39 --- /dev/null +++ b/tests/Database/Laravel/DatabaseConnectionTest.php @@ -0,0 +1,780 @@ +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(PDOStub::class)->onlyMethods(['prepare'])->getMock(); + $writePdo = $this->getMockBuilder(PDOStub::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(PDOStub::class)->onlyMethods(['prepare'])->getMock(); + $writePdo = $this->getMockBuilder(PDOStub::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(PDOStub::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(PDOStub::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(PDOStub::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(PDOStub::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(PDOStub::class); + $connection->setPdo($pdo); + }); + $connection->disconnect(); + $connection->beginTransaction(); + $this->assertEquals(1, $connection->transactionLevel()); + } + + public function testBeginTransactionMethodNeverRetriesIfWithinTransaction() + { + $pdo = $this->createMock(PDOStub::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(PDOStub::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(PDOStub::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(PDOStub::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(PDOStub::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(PDOStub::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(PDOStub::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(PDOStub::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(PDOStub::class)->onlyMethods(['beginTransaction', 'commit', 'rollBack'])->getMock(); + $mock = $this->getMockConnection([], $pdo); + $pdo->expects($this->exactly(3))->method('commit')->will($this->throwException(new PDOExceptionStub('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(PDOStub::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(PDOStub::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(PDOStub::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(PDOStub::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(PDOStub::class) + ->onlyMethods(['prepare']) + ->getMock(); + $writePdo->expects($this->never())->method('prepare'); + + // Create read PDO mock that throws an exception + $readPdo = $this->getMockBuilder(PDOStub::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(PDOStub::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(PDOStub::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(PDOStub::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 PDOStub()); + $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 PDOStub(); + $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 PDOStub extends PDO +{ + public function __construct() + { + } +} + +class PDOExceptionStub 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 null|string $message + * @param null|string $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..a003ad4a2 --- /dev/null +++ b/tests/Database/Laravel/DatabaseConnectorTest.php @@ -0,0 +1,296 @@ +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..1d686c06f --- /dev/null +++ b/tests/Database/Laravel/DatabaseEloquentAsBinaryCastTest.php @@ -0,0 +1,132 @@ +getProperty('customCodecs'); + $property->setValue(null, []); + + parent::tearDown(); + } + + public function testCastThrowsWhenFormatMissing() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The binary codec format is required.'); + + $model = new TestModel(); + $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 TestModel(); + $model->setRawAttributes(['invalid_format' => 'value']); + $model->invalid_format; + } + + public function testGetDecodesUuidFromBinary() + { + $uuid = '550e8400-e29b-41d4-a716-446655440000'; + $model = new TestModel(); + $model->setRawAttributes(['uuid' => Uuid::fromString($uuid)->getBytes()]); + + $this->assertSame($uuid, $model->uuid); + } + + public function testSetEncodesUuidToBinary() + { + $uuid = '550e8400-e29b-41d4-a716-446655440000'; + $model = new TestModel(); + $model->uuid = $uuid; + + $this->assertSame(Uuid::fromString($uuid)->getBytes(), $model->getAttributes()['uuid']); + } + + public function testGetDecodesUlidFromBinary() + { + $ulid = '01ARZ3NDEKTSV4RRFFQ69G5FAV'; + $model = new TestModel(); + $model->setRawAttributes(['ulid' => Ulid::fromString($ulid)->toBinary()]); + + $this->assertSame($ulid, $model->ulid); + } + + public function testSetEncodesUlidToBinary() + { + $ulid = '01ARZ3NDEKTSV4RRFFQ69G5FAV'; + $model = new TestModel(); + $model->ulid = $ulid; + + $this->assertSame(Ulid::fromString($ulid)->toBinary(), $model->getAttributes()['ulid']); + } + + public function testGetReturnsNullForNullValue() + { + $model = new TestModel(); + $model->setRawAttributes(['uuid' => null]); + + $this->assertNull($model->uuid); + } + + public function testSetEncodesNullToNull() + { + $model = new TestModel(); + $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 TestModel 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..66b4268fb --- /dev/null +++ b/tests/Database/Laravel/DatabaseEloquentBelongsToManyAggregateTest.php @@ -0,0 +1,206 @@ +addConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + ]); + + $db->bootEloquent(); + $db->setAsGlobal(); + + $this->createSchema(); + } + + public function testWithSumDifferentTables() + { + $this->seedData(); + + $order = Order::query() + ->withSum('products as total_products', 'order_product.quantity') + ->first(); + + $this->assertEquals(12, $order->total_products); + } + + public function testWithSumSameTable() + { + $this->seedData(); + + $order = Transaction::query() + ->withSum('allocatedTo as total_allocated', 'allocations.amount') + ->first(); + + $this->assertEquals(1200, $order->total_allocated); + } + + public function testWithSumExpression() + { + $this->seedData(); + + $order = Transaction::query() + ->withSum('allocatedTo as total_allocated', new Expression('allocations.amount * 2')) + ->first(); + + $this->assertEquals(2400, $order->total_allocated); + } + + /** + * Setup the database schema. + */ + 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. + */ + protected function tearDown(): void + { + $this->schema()->drop('orders'); + $this->schema()->drop('products'); + + parent::tearDown(); + } + + /** + * Helpers... + */ + protected function seedData() + { + $order = Order::create(['id' => 1]); + + Product::query()->insert([ + ['id' => 1], + ['id' => 2], + ['id' => 3], + ]); + + $order->products()->sync([ + 1 => ['quantity' => 3], + 2 => ['quantity' => 4], + 3 => ['quantity' => 5], + ]); + + $transaction = Transaction::create(['id' => 1, 'value' => 1200]); + + Transaction::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 Order extends Eloquent +{ + protected ?string $table = 'orders'; + + protected array $fillable = ['id']; + + public bool $timestamps = false; + + public function products() + { + return $this + ->belongsToMany(Product::class, 'order_product', 'order_id', 'product_id') + ->withPivot('quantity'); + } +} + +class Product extends Eloquent +{ + protected ?string $table = 'products'; + + protected array $fillable = ['id']; + + public bool $timestamps = false; +} + +class Transaction extends Eloquent +{ + protected ?string $table = 'transactions'; + + protected array $fillable = ['id', 'value']; + + public bool $timestamps = false; + + public function allocatedTo() + { + return $this + ->belongsToMany(Transaction::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..0d5564e05 --- /dev/null +++ b/tests/Database/Laravel/DatabaseEloquentBelongsToManyChunkByIdTest.php @@ -0,0 +1,160 @@ +addConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + ]); + + $db->bootEloquent(); + $db->setAsGlobal(); + + $this->createSchema(); + } + + /** + * Setup the database schema. + */ + 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 = User::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 = User::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. + */ + protected function tearDown(): void + { + $this->schema()->drop('users'); + $this->schema()->drop('articles'); + $this->schema()->drop('article_user'); + + parent::tearDown(); + } + + /** + * Helpers... + */ + protected function seedData() + { + $user = User::create(['id' => 1, 'email' => 'taylorotwell@gmail.com']); + Article::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 User extends Eloquent +{ + protected ?string $table = 'users'; + + protected array $fillable = ['id', 'email']; + + public bool $timestamps = false; + + public function articles() + { + return $this->belongsToMany(Article::class, 'article_user', 'user_id', 'article_id'); + } +} + +class Article 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..6277f6dbb --- /dev/null +++ b/tests/Database/Laravel/DatabaseEloquentBelongsToManyCreateOrFirstTest.php @@ -0,0 +1,516 @@ +id = 123; + $this->mockConnectionForModels( + [$source, new RelatedModel()], + '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 SourceModel(); + $source->id = 123; + $this->mockConnectionForModels( + [$source, new RelatedModel()], + '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 SourceModel(); + $source->id = 123; + $source->exists = true; + $this->mockConnectionForModels( + [$source, new RelatedModel()], + '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 SourceModel(); + $source->id = 123; + $source->exists = true; + $this->mockConnectionForModels( + [$source, new RelatedModel()], + '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 SourceModel(); + $source->id = 123; + $source->exists = true; + $this->mockConnectionForModels( + [$source, new RelatedModel()], + '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 SourceModel { + 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 RelatedModel([ + '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 RelatedModel()], + '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 SourceModel { + 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 RelatedModel([ + '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 RelatedModel()], + '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 SourceModel { + 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 RelatedModel([ + '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 RelatedModel()], + '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 RelatedModel extends Model +{ + protected ?string $table = 'related_table'; + + protected array $guarded = []; +} + +/** + * @property int $id + */ +class SourceModel extends Model +{ + protected ?string $table = 'source_table'; + + protected array $guarded = []; + + public function related(): BelongsToMany + { + return $this->belongsToMany( + RelatedModel::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..e18c6c944 --- /dev/null +++ b/tests/Database/Laravel/DatabaseEloquentBelongsToManyEachByIdTest.php @@ -0,0 +1,144 @@ +addConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + ]); + + $db->bootEloquent(); + $db->setAsGlobal(); + + $this->createSchema(); + } + + /** + * Setup the database schema. + */ + 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 = User::query()->first(); + $i = 0; + + $user->articles()->eachById(function (Article $model) use (&$i) { + ++$i; + $this->assertEquals($i, $model->id); + }); + + $this->assertSame(3, $i); + } + + /** + * Tear down the database schema. + */ + protected function tearDown(): void + { + $this->schema()->drop('users'); + $this->schema()->drop('articles'); + $this->schema()->drop('article_user'); + + parent::tearDown(); + } + + /** + * Helpers... + */ + protected function seedData() + { + $user = User::create(['id' => 1, 'email' => 'taylorotwell@gmail.com']); + Article::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 User extends Eloquent +{ + protected ?string $table = 'users'; + + protected array $fillable = ['id', 'email']; + + public bool $timestamps = false; + + public function articles() + { + return $this->belongsToMany(Article::class, 'article_user', 'user_id', 'article_id'); + } +} + +class Article 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..7b0c6c93c --- /dev/null +++ b/tests/Database/Laravel/DatabaseEloquentBelongsToManyExpressionTest.php @@ -0,0 +1,188 @@ +addConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + ]); + + $db->bootEloquent(); + $db->setAsGlobal(); + + $this->createSchema(); + } + + public function testAmbiguousColumnsExpression(): void + { + $this->seedData(); + + $tags = Post::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 = Post::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 = Post::query()->firstOrFail(); + Tag::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 = Post::query()->firstOrFail(); + Tag::addGlobalScope( + 'default', + static fn () => throw new Exception('Default global scope.') + ); + + $this->assertNotEmpty($post->tags()->withoutGlobalScopes()->get()); + } + + /** + * Setup the database schema. + */ + 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. + */ + protected function tearDown(): void + { + $this->schema()->drop('posts'); + $this->schema()->drop('tags'); + $this->schema()->drop('taggables'); + + parent::tearDown(); + } + + /** + * Helpers... + */ + protected function seedData(): void + { + $p1 = Post::query()->create(); + $p2 = Post::query()->create(); + $t1 = Tag::query()->create(); + $t2 = Tag::query()->create(); + $t3 = Tag::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 Post extends Eloquent +{ + protected ?string $table = 'posts'; + + protected array $fillable = ['id']; + + public bool $timestamps = false; + + public function tags(): MorphToMany + { + return $this->morphToMany( + Tag::class, + 'taggable', + 'taggables', + 'taggable_id', + 'tag_id', + 'id', + 'id', + ); + } +} + +class Tag 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..2edb08cc6 --- /dev/null +++ b/tests/Database/Laravel/DatabaseEloquentBelongsToManyLazyByIdTest.php @@ -0,0 +1,145 @@ +addConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + ]); + + $db->bootEloquent(); + $db->setAsGlobal(); + + $this->createSchema(); + } + + /** + * Setup the database schema. + */ + 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 = User::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. + */ + protected function tearDown(): void + { + $this->schema()->drop('users'); + $this->schema()->drop('articles'); + $this->schema()->drop('article_user'); + + parent::tearDown(); + } + + /** + * Helpers... + */ + protected function seedData() + { + $user = User::create(['id' => 1, 'email' => 'taylorotwell@gmail.com']); + Article::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 User extends Eloquent +{ + protected ?string $table = 'users'; + + protected array $fillable = ['id', 'email']; + + public bool $timestamps = false; + + public function articles() + { + return $this->belongsToMany(Article::class, 'article_user', 'user_id', 'article_id'); + } +} + +class Article 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..0b8997104 --- /dev/null +++ b/tests/Database/Laravel/DatabaseEloquentBelongsToManySyncReturnValueTypeTest.php @@ -0,0 +1,165 @@ +addConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + ]); + + $db->bootEloquent(); + $db->setAsGlobal(); + + $this->createSchema(); + } + + /** + * Setup the database schema. + */ + 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. + */ + protected function tearDown(): void + { + $this->schema()->drop('users'); + $this->schema()->drop('articles'); + $this->schema()->drop('article_user'); + + parent::tearDown(); + } + + /** + * Helpers... + */ + protected function seedData() + { + User::create(['id' => 1, 'email' => 'taylorotwell@gmail.com']); + Article::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 = User::query()->first(); + $articleIDs = Article::all()->pluck('id')->toArray(); + + $changes = $user->articles()->sync($articleIDs); + + collect($changes['attached'])->map(function ($id) { + $this->assertSame(gettype($id), (new Article())->getKeyType()); + }); + + $user->articles->each(function (Article $article) { + $this->assertSame('0', (string) $article->pivot->visible); + }); + } + + public function testSyncWithPivotDefaultsReturnValueType() + { + $this->seedData(); + + $user = User::query()->first(); + $articleIDs = Article::all()->pluck('id')->toArray(); + + $changes = $user->articles()->syncWithPivotValues($articleIDs, ['visible' => true]); + + collect($changes['attached'])->each(function ($id) { + $this->assertSame(gettype($id), (new Article())->getKeyType()); + }); + + $user->articles->each(function (Article $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 User extends Eloquent +{ + protected ?string $table = 'users'; + + protected array $fillable = ['id', 'email']; + + public bool $timestamps = false; + + public function articles() + { + return $this->belongsToMany(Article::class, 'article_user', 'user_id', 'article_id')->withPivot('visible'); + } +} + +class Article 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..b6916054c --- /dev/null +++ b/tests/Database/Laravel/DatabaseEloquentBelongsToManySyncTouchesParentTest.php @@ -0,0 +1,185 @@ +addConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + ]); + + $db->bootEloquent(); + $db->setAsGlobal(); + + $this->createSchema(); + } + + /** + * Setup the database schema. + */ + 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. + */ + protected function tearDown(): void + { + $this->schema()->drop('users'); + $this->schema()->drop('articles'); + $this->schema()->drop('article_user'); + + parent::tearDown(); + } + + /** + * Helpers... + */ + protected function seedData() + { + User::create(['id' => 1, 'email' => 'taylorotwell@gmail.com']); + User::create(['id' => 2, 'email' => 'anonymous@gmail.com']); + User::create(['id' => 3, 'email' => 'anoni-mous@gmail.com']); + } + + public function testSyncWithDetachedValuesShouldTouch() + { + $this->seedData(); + + Carbon::setTestNow('2021-07-19 10:13:14'); + $article = Article::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 = User::find(1); + $this->assertNotSame('2021-07-20 19:13:14', $user1->updated_at->format('Y-m-d H:i:s')); + $user2 = User::find(2); + $this->assertNotSame('2021-07-20 19:13:14', $user2->updated_at->format('Y-m-d H:i:s')); + $user3 = User::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 Article 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(Article::class, 'article_user', 'article_id', 'user_id') + ->using(ArticleUser::class) + ->withTimestamps(); + } +} + +class ArticleUser extends EloquentPivot +{ + protected ?string $table = 'article_user'; + + protected array $fillable = ['article_id', 'user_id']; + + protected array $touches = ['article']; + + public function article() + { + return $this->belongsTo(Article::class, 'article_id', 'id'); + } + + public function user() + { + return $this->belongsTo(User::class, 'user_id', 'id'); + } +} + +class User 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(Article::class, 'article_user', 'user_id', 'article_id') + ->using(ArticleUser::class) + ->withTimestamps(); + } +} diff --git a/tests/Database/Laravel/DatabaseEloquentBelongsToManyWithAttributesPendingTest.php b/tests/Database/Laravel/DatabaseEloquentBelongsToManyWithAttributesPendingTest.php new file mode 100644 index 000000000..03743a435 --- /dev/null +++ b/tests/Database/Laravel/DatabaseEloquentBelongsToManyWithAttributesPendingTest.php @@ -0,0 +1,266 @@ +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. + */ + 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. + * + * @param mixed $connection + * @return \Illuminate\Database\Connection + */ + protected function connection($connection = 'default') + { + return Model::getConnectionResolver()->connection($connection); + } + + /** + * Get a schema builder instance. + * + * @param mixed $connection + * @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..f1722576d --- /dev/null +++ b/tests/Database/Laravel/DatabaseEloquentBelongsToManyWithAttributesTest.php @@ -0,0 +1,273 @@ +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. + */ + 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. + * + * @param mixed $connection + * @return \Illuminate\Database\Connection + */ + protected function connection($connection = 'default') + { + return Model::getConnectionResolver()->connection($connection); + } + + /** + * Get a schema builder instance. + * + * @param mixed $connection + * @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..2d93ec510 --- /dev/null +++ b/tests/Database/Laravel/DatabaseEloquentBelongsToManyWithCastedAttributesTest.php @@ -0,0 +1,90 @@ +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(QueryBuilder::class, ['getGrammar' => m::mock(Grammar::class, ['isExpression' => false])]) + ); + + return new BelongsToMany( + $builder, + new ModelStub(), + 'relation', + 'foreign_key', + 'id', + 'parent_key', + 'related_key' + ); + } +} + +class ModelStub 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..32f609a86 --- /dev/null +++ b/tests/Database/Laravel/DatabaseEloquentBelongsToManyWithDefaultAttributesTest.php @@ -0,0 +1,65 @@ +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(QueryBuilder::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..e80dac672 --- /dev/null +++ b/tests/Database/Laravel/DatabaseEloquentBelongsToManyWithoutTouchingTest.php @@ -0,0 +1,77 @@ +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(BelongsToManyWithoutTouchingUser::class); + + $parent->shouldReceive('getAttribute')->with('id')->andReturn(1); + $builder->shouldReceive('getModel')->andReturn($related); + $builder->shouldReceive('where'); + $builder->shouldReceive('getQuery')->andReturn( + m::mock(QueryBuilder::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 BelongsToManyWithoutTouchingUser extends Model +{ + protected ?string $table = 'users'; + + protected array $fillable = ['id', 'email']; + + public function articles(): BelongsToMany + { + return $this->belongsToMany(BelongsToManyWithoutTouchingArticle::class, 'article_user', 'user_id', 'article_id'); + } +} + +class BelongsToManyWithoutTouchingArticle extends Model +{ + protected ?string $table = 'articles'; + + protected array $fillable = ['id', 'title']; + + protected array $touches = ['user']; + + public function users(): BelongsToMany + { + return $this->belongsToMany(BelongsToManyWithoutTouchingUser::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..f2c347fa9 --- /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 ModelStub(), new ModelStub(), new AnotherModelStub()]; + $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 ModelStub(), new ModelStubWithZeroId()]; + $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 ModelStub(), new ModelStubWithBackedEnumCast()]; + $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 ModelStub(); + $model1->foreign_key = 1; + $model2 = new ModelStub(); + $model2->foreign_key = 2; + $model3 = new ModelStub(); + $model3->foreign_key = new class { + public function __toString() + { + return '3'; + } + }; + $model4 = new ModelStub(); + $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 MissingModelStub(), new MissingModelStub()]; + $relation->addEagerConstraints($models); + } + + public function testDefaultEagerConstraintsWhenIncrementingAndNonIntKeyType() + { + $relation = $this->getRelation(null, 'string'); + $relation->getQuery()->shouldReceive('whereIn')->once()->with('relation.id', m::mustBe([])); + $models = [new MissingModelStub(), new MissingModelStub()]; + $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 MissingModelStub(), new MissingModelStub()]; + $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 ModelStub(); + + 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. + * @param null|mixed $parent + */ + protected function getRelationWithPartialMock($parent = null) + { + $this->builder = m::mock(Builder::class); + $this->builder->shouldReceive('where')->with('relation.id', '=', 'foreign.value'); + $this->related = m::mock(ModelStub::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 ModelStub(); + + return new BelongsTo($this->builder, $parent, 'foreign_key', 'id', 'relation'); + } +} + +class ModelStub extends Model +{ + public $foreign_key = 'foreign.value'; +} + +class AnotherModelStub extends Model +{ + public $foreign_key = 'foreign.value.two'; +} + +class ModelStubWithZeroId extends Model +{ + public $foreign_key = 0; +} + +class MissingModelStub extends Model +{ + public $foreign_key; +} + +class ModelStubWithBackedEnumCast 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..38540c2f2 --- /dev/null +++ b/tests/Database/Laravel/DatabaseEloquentBuilderCreateOrFirstTest.php @@ -0,0 +1,513 @@ +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 TestModel(); + $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 TestModel(); + $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 TestModel(); + $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 TestModel(); + $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 TestModel(); + $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 TestModel(); + $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 TestModel(); + $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 TestModel(); + $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 TestModel(); + $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 TestModel(); + $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 TestModel(); + $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 TestModel 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..7451ac682 --- /dev/null +++ b/tests/Database/Laravel/DatabaseEloquentBuilderTest.php @@ -0,0 +1,3157 @@ +getMockQueryBuilder()]); + $model = $this->getMockModel(); + $builder->setModel($model); + $model->shouldReceive('getKeyType')->once()->andReturn('int'); + $builder->getQuery()->shouldReceive('where')->once()->with('foo_table.foo', '=', 'bar'); + $expectedModel = m::mock(Model::class); + $builder->shouldReceive('first')->with(['column'])->andReturn($expectedModel); + + $result = $builder->find('bar', ['column']); + $this->assertSame($expectedModel, $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'); + $expectedModel = m::mock(Model::class); + $builder->shouldReceive('sole')->with(['column'])->andReturn($expectedModel); + + $result = $builder->findSole('bar', ['column']); + $this->assertSame($expectedModel, $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']); + $expectedCollection = new Collection(['baz']); + $builder->shouldReceive('get')->with(['column'])->andReturn($expectedCollection); + + $result = $builder->findMany(['one', 'two'], ['column']); + $this->assertEquals($expectedCollection, $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'); + $expectedModel = m::mock(Model::class); + $model->shouldReceive('findOrNew')->once()->andReturn($expectedModel); + + $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($expectedModel); + + $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(); + // findOr with multiple IDs always returns Collection (even empty) - callback is NOT triggered + // because find() only returns null for single non-existent ID, not for arrays + $builder->shouldReceive('get')->andReturn(new Collection())->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]); + + // When no models found, still returns empty Collection (not callback result) + $result = $builder->findOr([1, 2, 3], fn () => 'callback result'); + $this->assertInstanceOf(Collection::class, $result); + $this->assertCount(0, $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(); + // findOr with multiple IDs always returns Collection (even empty) - callback is NOT triggered + $builder->shouldReceive('get')->andReturn(new Collection())->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]); + + // When no models found, still returns empty Collection (not callback result) + $result = $builder->findOr(new Collection([1, 2, 3]), fn () => 'callback result'); + $this->assertInstanceOf(Collection::class, $result); + $this->assertCount(0, $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); + $expectedCollection = new Collection(['baz']); + $builder->shouldReceive('get')->with(['column'])->andReturn($expectedCollection); + + $result = $builder->find([1, 2], ['column']); + $this->assertSame($expectedCollection, $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); + $expectedCollection = new Collection(['baz']); + $builder->shouldReceive('get')->with(['column'])->andReturn($expectedCollection); + + $result = $builder->find($ids, ['column']); + $this->assertSame($expectedCollection, $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 StubStringPrimaryKey()); + + $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 StubStringPrimaryKey()); + + $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 = m::mock(Model::class)->makePartial(); + $mockModel->forceFill(['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(PluckStub::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(PluckStub::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(PluckStub::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(PluckStub::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(PluckDatesStub::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(PluckDatesStub::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(PluckStub::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(PluckStub::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(PluckStub::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(PluckStub::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(PluckDatesStub::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(PluckDatesStub::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(Relation::class); + $relation->shouldReceive('addEagerConstraints')->once()->with(['models']); + $relation->shouldReceive('initRelation')->once()->with(['models'], 'orders')->andReturn(['models']); + $eagerResults = new Collection(['results']); + $relation->shouldReceive('getEager')->once()->andReturn($eagerResults); + $relation->shouldReceive('match')->once()->with(['models'], $eagerResults, '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 ModelSelfRelatedStub(); + $this->mockConnectionForModel($model, 'SQLite'); + + $models = [ + new ModelSelfRelatedStub(), + new ModelSelfRelatedStub(), + ]; + $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()); + $relation = m::mock(Relation::class); + $builder->getModel()->shouldReceive('newInstance->orders')->once()->andReturn($relation); + $relationQuery = m::mock(Builder::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()); + $relation = m::mock(Relation::class); + $groupsRelation = m::mock(Relation::class); + $builder->getModel()->shouldReceive('newInstance->orders')->once()->andReturn($relation); + $builder->getModel()->shouldReceive('newInstance->ordersGroups')->once()->andReturn($groupsRelation); + + $relationQuery = m::mock(Builder::class); + $relation->shouldReceive('getQuery')->andReturn($relationQuery); + + $groupRelationQuery = m::mock(Builder::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 ScopeStub()); + $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 DynamicScopeStub()); + $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 DynamicScopeStub()); + $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 NestedStub(); + $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 NestedStub(); + $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 NestedStub(); + $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 Stub(); + $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 Stub(); + $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 HigherOrderWhereScopeStub(); + $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 HigherOrderWhereScopeStub(); + $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 HigherOrderWhereScopeStub(); + $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 HigherOrderWhereScopeStub(); + $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 HigherOrderWhereScopeStub(); + $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 HigherOrderWhereScopeStub(); + $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 WhereBelongsToStub([ + 'id' => 1, + 'parent_id' => 2, + ]); + + $parent = new WhereBelongsToStub([ + 'id' => 2, + 'parent_id' => 1, + ]); + + $builder = $this->getBuilder(); + $builder->shouldReceive('from')->with('where_belongs_to_stubs'); + $builder->setModel($related); + $builder->getQuery()->shouldReceive('whereIn')->once()->with('where_belongs_to_stubs.parent_id', [2], 'and'); + + $result = $builder->whereBelongsTo($parent); + $this->assertEquals($result, $builder); + + $builder = $this->getBuilder(); + $builder->shouldReceive('from')->with('where_belongs_to_stubs'); + $builder->setModel($related); + $builder->getQuery()->shouldReceive('whereIn')->once()->with('where_belongs_to_stubs.parent_id', [2], 'and'); + + $result = $builder->whereBelongsTo($parent, 'parent'); + $this->assertEquals($result, $builder); + + $parents = new Collection([new WhereBelongsToStub([ + 'id' => 2, + 'parent_id' => 1, + ]), new WhereBelongsToStub([ + 'id' => 3, + 'parent_id' => 1, + ])]); + + $builder = $this->getBuilder(); + $builder->shouldReceive('from')->with('where_belongs_to_stubs'); + $builder->setModel($related); + $builder->getQuery()->shouldReceive('whereIn')->once()->with('where_belongs_to_stubs.parent_id', [2, 3], 'and'); + + $result = $builder->whereBelongsTo($parents); + $this->assertEquals($result, $builder); + + $builder = $this->getBuilder(); + $builder->shouldReceive('from')->with('where_belongs_to_stubs'); + $builder->setModel($related); + $builder->getQuery()->shouldReceive('whereIn')->once()->with('where_belongs_to_stubs.parent_id', [2, 3], 'and'); + + $result = $builder->whereBelongsTo($parents, 'parent'); + $this->assertEquals($result, $builder); + } + + public function testWhereAttachedTo() + { + $related = new ModelFarRelatedStub(); + $related->id = 49; + $related->name = 'test'; + + $builder = ModelParentStub::whereAttachedTo($related, 'roles'); + + $this->assertSame('select * from "model_parent_stubs" where exists (select * from "model_far_related_stubs" inner join "user_role" on "model_far_related_stubs"."id" = "user_role"."related_id" where "model_parent_stubs"."id" = "user_role"."self_id" and "model_far_related_stubs"."id" in (49))', $builder->toSql()); + } + + public function testWhereAttachedToCollection() + { + $model1 = new ModelParentStub(); + $model1->id = 3; + $model1->name = 'test3'; + + $model2 = new ModelParentStub(); + $model2->id = 4; + $model2->name = 'test4'; + + $builder = ModelFarRelatedStub::whereAttachedTo(new Collection([$model1, $model2]), 'roles'); + + $this->assertSame('select * from "model_far_related_stubs" where exists (select * from "model_parent_stubs" inner join "user_role" on "model_parent_stubs"."id" = "user_role"."self_id" where "model_far_related_stubs"."id" = "user_role"."related_id" and "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 ModelParentStub(); + + $builder = $model->withCount('foo'); + + $this->assertSame('select "model_parent_stubs".*, (select count(*) from "model_close_related_stubs" where "model_parent_stubs"."foo_id" = "model_close_related_stubs"."id") as "foo_count" from "model_parent_stubs"', $builder->toSql()); + } + + public function testWithCountAndSelect() + { + $model = new ModelParentStub(); + + $builder = $model->select('id')->withCount('foo'); + + $this->assertSame('select "id", (select count(*) from "model_close_related_stubs" where "model_parent_stubs"."foo_id" = "model_close_related_stubs"."id") as "foo_count" from "model_parent_stubs"', $builder->toSql()); + } + + public function testWithCountSecondRelationWithClosure() + { + $model = new ModelParentStub(); + + $builder = $model->withCount(['address', 'foo' => function ($query) { + $query->where('active', false); + }]); + + $this->assertSame('select "model_parent_stubs".*, (select count(*) from "model_close_related_stubs" where "model_parent_stubs"."foo_id" = "model_close_related_stubs"."id") as "address_count", (select count(*) from "model_close_related_stubs" where "model_parent_stubs"."foo_id" = "model_close_related_stubs"."id" and "active" = ?) as "foo_count" from "model_parent_stubs"', $builder->toSql()); + } + + public function testWithCountAndMergedWheres() + { + $model = new ModelParentStub(); + + $builder = $model->select('id')->withCount(['activeFoo' => function ($q) { + $q->where('bam', '>', 'qux'); + }]); + + $this->assertSame('select "id", (select count(*) from "model_close_related_stubs" where "model_parent_stubs"."foo_id" = "model_close_related_stubs"."id" and "bam" > ? and "active" = ?) as "active_foo_count" from "model_parent_stubs"', $builder->toSql()); + $this->assertEquals(['qux', true], $builder->getBindings()); + } + + public function testWithCountAndGlobalScope() + { + $model = new ModelParentStub(); + ModelCloseRelatedStub::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 + ModelCloseRelatedStub::addGlobalScope('withCount', function ($query) { + }); + + $this->assertSame('select "id", (select count(*) from "model_close_related_stubs" where "model_parent_stubs"."foo_id" = "model_close_related_stubs"."id") as "foo_count" from "model_parent_stubs"', $builder->toSql()); + } + + public function testWithMin() + { + $model = new ModelParentStub(); + + $builder = $model->withMin('foo', 'price'); + + $this->assertSame('select "model_parent_stubs".*, (select min("model_close_related_stubs"."price") from "model_close_related_stubs" where "model_parent_stubs"."foo_id" = "model_close_related_stubs"."id") as "foo_min_price" from "model_parent_stubs"', $builder->toSql()); + } + + public function testWithMinExpression() + { + $model = new ModelParentStub(); + + $builder = $model->withMin('foo', new Expression('price - discount')); + + $this->assertSame('select "model_parent_stubs".*, (select min(price - discount) from "model_close_related_stubs" where "model_parent_stubs"."foo_id" = "model_close_related_stubs"."id") as "foo_min_price_discount" from "model_parent_stubs"', $builder->toSql()); + } + + public function testWithMinOnBelongsToMany() + { + $model = new ModelParentStub(); + + $builder = $model->withMin('roles', 'id'); + + $this->assertSame('select "model_parent_stubs".*, (select min("model_far_related_stubs"."id") from "model_far_related_stubs" inner join "user_role" on "model_far_related_stubs"."id" = "user_role"."related_id" where "model_parent_stubs"."id" = "user_role"."self_id") as "roles_min_id" from "model_parent_stubs"', $builder->toSql()); + } + + public function testWithMinOnSelfRelated() + { + $model = new ModelSelfRelatedStub(); + + $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 ModelParentStub(); + + $builder = $model->withMax('foo', 'price'); + + $this->assertSame('select "model_parent_stubs".*, (select max("model_close_related_stubs"."price") from "model_close_related_stubs" where "model_parent_stubs"."foo_id" = "model_close_related_stubs"."id") as "foo_max_price" from "model_parent_stubs"', $builder->toSql()); + } + + public function testWithMaxExpression() + { + $model = new ModelParentStub(); + + $builder = $model->withMax('foo', new Expression('price - discount')); + + $this->assertSame('select "model_parent_stubs".*, (select max(price - discount) from "model_close_related_stubs" where "model_parent_stubs"."foo_id" = "model_close_related_stubs"."id") as "foo_max_price_discount" from "model_parent_stubs"', $builder->toSql()); + } + + public function testWithAvg() + { + $model = new ModelParentStub(); + + $builder = $model->withAvg('foo', 'price'); + + $this->assertSame('select "model_parent_stubs".*, (select avg("model_close_related_stubs"."price") from "model_close_related_stubs" where "model_parent_stubs"."foo_id" = "model_close_related_stubs"."id") as "foo_avg_price" from "model_parent_stubs"', $builder->toSql()); + } + + public function testWitAvgExpression() + { + $model = new ModelParentStub(); + + $builder = $model->withAvg('foo', new Expression('price - discount')); + + $this->assertSame('select "model_parent_stubs".*, (select avg(price - discount) from "model_close_related_stubs" where "model_parent_stubs"."foo_id" = "model_close_related_stubs"."id") as "foo_avg_price_discount" from "model_parent_stubs"', $builder->toSql()); + } + + public function testWithCountAndConstraintsAndHaving() + { + $model = new ModelParentStub(); + + $builder = $model->where('bar', 'baz'); + $builder->withCount(['foo' => function ($q) { + $q->where('bam', '>', 'qux'); + }])->having('foo_count', '>=', 1); + + $this->assertSame('select "model_parent_stubs".*, (select count(*) from "model_close_related_stubs" where "model_parent_stubs"."foo_id" = "model_close_related_stubs"."id" and "bam" > ?) as "foo_count" from "model_parent_stubs" where "bar" = ? having "foo_count" >= ?', $builder->toSql()); + $this->assertEquals(['qux', 'baz', 1], $builder->getBindings()); + } + + public function testWithCountAndRename() + { + $model = new ModelParentStub(); + + $builder = $model->withCount('foo as foo_bar'); + + $this->assertSame('select "model_parent_stubs".*, (select count(*) from "model_close_related_stubs" where "model_parent_stubs"."foo_id" = "model_close_related_stubs"."id") as "foo_bar" from "model_parent_stubs"', $builder->toSql()); + } + + public function testWithCountMultipleAndPartialRename() + { + $model = new ModelParentStub(); + + $builder = $model->withCount(['foo as foo_bar', 'foo']); + + $this->assertSame('select "model_parent_stubs".*, (select count(*) from "model_close_related_stubs" where "model_parent_stubs"."foo_id" = "model_close_related_stubs"."id") as "foo_bar", (select count(*) from "model_close_related_stubs" where "model_parent_stubs"."foo_id" = "model_close_related_stubs"."id") as "foo_count" from "model_parent_stubs"', $builder->toSql()); + } + + public function testWithAggregateAlias() + { + $model = new ModelParentStub(); + + $builder = $model->withAggregate('foo', new Expression('TIMESTAMPDIFF(SECOND, `created_at`, `updated_at`)'), 'sum'); + + $this->assertSame( + 'select "model_parent_stubs".*, (select sum(TIMESTAMPDIFF(SECOND, `created_at`, `updated_at`)) from "model_close_related_stubs" where "model_parent_stubs"."foo_id" = "model_close_related_stubs"."id") as "foo_sum_timestampdiffsecond_created_at_updated_at" from "model_parent_stubs"', + $builder->toSql() + ); + } + + public function testWithAggregateAndSelfRelationConstrain() + { + Stub::resolveRelationUsing('children', function ($model) { + return $model->hasMany(Stub::class, 'parent_id', 'id')->where('enum_value', new stdClass()); + }); + + $model = new Stub(); + $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 ModelParentStub(); + + $builder = $model->withExists('foo'); + + $this->assertSame('select "model_parent_stubs".*, exists(select * from "model_close_related_stubs" where "model_parent_stubs"."foo_id" = "model_close_related_stubs"."id") as "foo_exists" from "model_parent_stubs"', $builder->toSql()); + } + + public function testWithExistsAndSelect() + { + $model = new ModelParentStub(); + + $builder = $model->select('id')->withExists('foo'); + + $this->assertSame('select "id", exists(select * from "model_close_related_stubs" where "model_parent_stubs"."foo_id" = "model_close_related_stubs"."id") as "foo_exists" from "model_parent_stubs"', $builder->toSql()); + } + + public function testWithExistsAndMergedWheres() + { + $model = new ModelParentStub(); + + $builder = $model->select('id')->withExists(['activeFoo' => function ($q) { + $q->where('bam', '>', 'qux'); + }]); + + $this->assertSame('select "id", exists(select * from "model_close_related_stubs" where "model_parent_stubs"."foo_id" = "model_close_related_stubs"."id" and "bam" > ? and "active" = ?) as "active_foo_exists" from "model_parent_stubs"', $builder->toSql()); + $this->assertEquals(['qux', true], $builder->getBindings()); + } + + public function testWithExistsAndGlobalScope() + { + $model = new ModelParentStub(); + ModelCloseRelatedStub::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 + ModelCloseRelatedStub::addGlobalScope('withExists', function ($query) { + }); + + $this->assertSame('select "id", exists(select * from "model_close_related_stubs" where "model_parent_stubs"."foo_id" = "model_close_related_stubs"."id") as "foo_exists" from "model_parent_stubs"', $builder->toSql()); + } + + public function testWithExistsOnBelongsToMany() + { + $model = new ModelParentStub(); + + $builder = $model->withExists('roles'); + + $this->assertSame('select "model_parent_stubs".*, exists(select * from "model_far_related_stubs" inner join "user_role" on "model_far_related_stubs"."id" = "user_role"."related_id" where "model_parent_stubs"."id" = "user_role"."self_id") as "roles_exists" from "model_parent_stubs"', $builder->toSql()); + } + + public function testWithExistsOnSelfRelated() + { + $model = new ModelSelfRelatedStub(); + + $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 ModelParentStub(); + + $builder = $model->withExists('foo as foo_bar'); + + $this->assertSame('select "model_parent_stubs".*, exists(select * from "model_close_related_stubs" where "model_parent_stubs"."foo_id" = "model_close_related_stubs"."id") as "foo_bar" from "model_parent_stubs"', $builder->toSql()); + } + + public function testWithExistsMultipleAndPartialRename() + { + $model = new ModelParentStub(); + + $builder = $model->withExists(['foo as foo_bar', 'foo']); + + $this->assertSame('select "model_parent_stubs".*, exists(select * from "model_close_related_stubs" where "model_parent_stubs"."foo_id" = "model_close_related_stubs"."id") as "foo_bar", exists(select * from "model_close_related_stubs" where "model_parent_stubs"."foo_id" = "model_close_related_stubs"."id") as "foo_exists" from "model_parent_stubs"', $builder->toSql()); + } + + public function testHasWithConstraintsAndHavingInSubquery() + { + $model = new ModelParentStub(); + + $builder = $model->where('bar', 'baz'); + $builder->whereHas('foo', function ($q) { + $q->having('bam', '>', 'qux'); + })->where('quux', 'quuux'); + + $this->assertSame('select * from "model_parent_stubs" where "bar" = ? and exists (select * from "model_close_related_stubs" where "model_parent_stubs"."foo_id" = "model_close_related_stubs"."id" having "bam" > ?) and "quux" = ?', $builder->toSql()); + $this->assertEquals(['baz', 'qux', 'quuux'], $builder->getBindings()); + } + + public function testHasWithConstraintsWithOrWhereAndHavingInSubquery() + { + $model = new ModelParentStub(); + + $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 "model_parent_stubs" where "name" = ? and exists (select * from "model_close_related_stubs" where "model_parent_stubs"."foo_id" = "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() + { + ModelParentStub::resolveRelationUsing('addressAsExpression', function ($model) { + return $model->address()->fromSub(ModelCloseRelatedStub::query(), 'model_close_related_stubs'); + }); + + $model = new ModelParentStub(); + + $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 "model_parent_stubs" where "name" = ? and exists (select * from "model_close_related_stubs" where "model_parent_stubs"."foo_id" = "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 ModelParentStub(); + $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 "model_parent_stubs" where "bar" = ? and exists (select * from "model_close_related_stubs" inner join "quuuux" on "quuuuux" = ? where "model_parent_stubs"."foo_id" = "model_close_related_stubs"."id" having "bam" > ?) and "quux" = ?', $builder->toSql()); + $this->assertEquals(['baz', 'quuuuuux', 'qux', 'quuux'], $builder->getBindings()); + } + + public function testHasWithConstraintsAndHavingInSubqueryWithCount() + { + $model = new ModelParentStub(); + + $builder = $model->where('bar', 'baz'); + $builder->whereHas('foo', function ($q) { + $q->having('bam', '>', 'qux'); + }, '>=', 2)->where('quux', 'quuux'); + + $this->assertSame('select * from "model_parent_stubs" where "bar" = ? and (select count(*) from "model_close_related_stubs" where "model_parent_stubs"."foo_id" = "model_close_related_stubs"."id" having "bam" > ?) >= 2 and "quux" = ?', $builder->toSql()); + $this->assertEquals(['baz', 'qux', 'quuux'], $builder->getBindings()); + } + + public function testWithCountAndConstraintsWithBindingInSelectSub() + { + $model = new ModelParentStub(); + + $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 "model_parent_stubs".*, (select count(*) from "model_close_related_stubs" where "model_parent_stubs"."foo_id" = "model_close_related_stubs"."id") as "foo_count" from "model_parent_stubs"', $builder->toSql()); + $this->assertSame([], $builder->getBindings()); + } + + public function testWithExistsAndConstraintsWithBindingInSelectSub() + { + $model = new ModelParentStub(); + + $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 "model_parent_stubs".*, exists(select * from "model_close_related_stubs" where "model_parent_stubs"."foo_id" = "model_close_related_stubs"."id") as "foo_exists" from "model_parent_stubs"', $builder->toSql()); + $this->assertSame([], $builder->getBindings()); + } + + public function testHasNestedWithConstraints() + { + $model = new ModelParentStub(); + + $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 ModelParentStub(); + + $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 ModelParentStub(); + $connection = $this->mockConnectionForModel($model, ''); + + $morphToKey = $model->morph()->getMorphType(); + + $connection->shouldReceive('select')->once()->andReturn([ + [$morphToKey => ModelFarRelatedStub::class], + [$morphToKey => ModelOtherFarRelatedStub::class], + ]); + + $builder = $model->orWhereHasMorph('morph', [ModelFarRelatedStub::class], function ($q) { + $q->has('baz'); + })->orWhereHasMorph('morph', [ModelOtherFarRelatedStub::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 ModelParentStub(); + $connection = $this->mockConnectionForModel($model, ''); + + $morphToKey = $model->morph()->getMorphType(); + + $connection->shouldReceive('select')->once()->andReturn([ + [$morphToKey => ModelFarRelatedStub::class], + [$morphToKey => ModelOtherFarRelatedStub::class], + ]); + + $builder = $model->orWhereHasMorph('morph', [ModelFarRelatedStub::class], function ($q) { + $q->has('baz.bam'); + })->orWhereHasMorph('morph', [ModelOtherFarRelatedStub::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 ModelParentStub(); + + $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 ModelSelfRelatedStub(); + + $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 ModelSelfRelatedStub(); + + $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 ModelParentStub(); + + $builder = $model->doesntHave('foo'); + + $this->assertSame('select * from "model_parent_stubs" where not exists (select * from "model_close_related_stubs" where "model_parent_stubs"."foo_id" = "model_close_related_stubs"."id")', $builder->toSql()); + } + + public function testDoesntHaveNested() + { + $model = new ModelParentStub(); + + $builder = $model->doesntHave('foo.bar'); + + $this->assertSame('select * from "model_parent_stubs" where not exists (select * from "model_close_related_stubs" where "model_parent_stubs"."foo_id" = "model_close_related_stubs"."id" and exists (select * from "model_far_related_stubs" where "model_close_related_stubs"."id" = "model_far_related_stubs"."model_close_related_stub_id"))', $builder->toSql()); + } + + public function testOrDoesntHave() + { + $model = new ModelParentStub(); + + $builder = $model->where('bar', 'baz')->orDoesntHave('foo'); + + $this->assertSame('select * from "model_parent_stubs" where "bar" = ? or not exists (select * from "model_close_related_stubs" where "model_parent_stubs"."foo_id" = "model_close_related_stubs"."id")', $builder->toSql()); + $this->assertEquals(['baz'], $builder->getBindings()); + } + + public function testWhereDoesntHave() + { + $model = new ModelParentStub(); + + $builder = $model->whereDoesntHave('foo', function ($query) { + $query->where('bar', 'baz'); + }); + + $this->assertSame('select * from "model_parent_stubs" where not exists (select * from "model_close_related_stubs" where "model_parent_stubs"."foo_id" = "model_close_related_stubs"."id" and "bar" = ?)', $builder->toSql()); + $this->assertEquals(['baz'], $builder->getBindings()); + } + + public function testOrWhereDoesntHave() + { + $model = new ModelParentStub(); + + $builder = $model->where('bar', 'baz')->orWhereDoesntHave('foo', function ($query) { + $query->where('qux', 'quux'); + }); + + $this->assertSame('select * from "model_parent_stubs" where "bar" = ? or not exists (select * from "model_close_related_stubs" where "model_parent_stubs"."foo_id" = "model_close_related_stubs"."id" and "qux" = ?)', $builder->toSql()); + $this->assertEquals(['baz', 'quux'], $builder->getBindings()); + } + + public function testWhereMorphedTo() + { + $model = new ModelParentStub(); + $this->mockConnectionForModel($model, ''); + + $relatedModel = new ModelCloseRelatedStub(); + $relatedModel->id = 1; + + $builder = $model->whereMorphedTo('morph', $relatedModel); + + $this->assertSame('select * from "model_parent_stubs" where (("model_parent_stubs"."morph_type" = ? and "model_parent_stubs"."morph_id" in (?)))', $builder->toSql()); + $this->assertEquals([$relatedModel->getMorphClass(), $relatedModel->getKey()], $builder->getBindings()); + } + + public function testWhereMorphedToCollection() + { + $model = new ModelParentStub(); + $this->mockConnectionForModel($model, ''); + + $firstRelatedModel = new ModelCloseRelatedStub(); + $firstRelatedModel->id = 1; + + $secondRelatedModel = new ModelCloseRelatedStub(); + $secondRelatedModel->id = 2; + + $builder = $model->whereMorphedTo('morph', new Collection([$firstRelatedModel, $secondRelatedModel])); + + $this->assertSame('select * from "model_parent_stubs" where (("model_parent_stubs"."morph_type" = ? and "model_parent_stubs"."morph_id" in (?, ?)))', $builder->toSql()); + $this->assertEquals([$firstRelatedModel->getMorphClass(), $firstRelatedModel->getKey(), $secondRelatedModel->getKey()], $builder->getBindings()); + } + + public function testWhereMorphedToCollectionWithDifferentModels() + { + $model = new ModelParentStub(); + $this->mockConnectionForModel($model, ''); + + $firstRelatedModel = new ModelCloseRelatedStub(); + $firstRelatedModel->id = 1; + + $secondRelatedModel = new ModelFarRelatedStub(); + $secondRelatedModel->id = 2; + + $thirdRelatedModel = new ModelCloseRelatedStub(); + $thirdRelatedModel->id = 3; + + $builder = $model->whereMorphedTo('morph', [$firstRelatedModel, $secondRelatedModel, $thirdRelatedModel]); + + $this->assertSame('select * from "model_parent_stubs" where (("model_parent_stubs"."morph_type" = ? and "model_parent_stubs"."morph_id" in (?, ?)) or ("model_parent_stubs"."morph_type" = ? and "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 ModelParentStub(); + $this->mockConnectionForModel($model, ''); + + $builder = $model->whereMorphedTo('morph', null); + $this->assertSame('select * from "model_parent_stubs" where "model_parent_stubs"."morph_type" is null', $builder->toSql()); + } + + public function testWhereNotMorphedTo() + { + $model = new ModelParentStub(); + $this->mockConnectionForModel($model, ''); + + $relatedModel = new ModelCloseRelatedStub(); + $relatedModel->id = 1; + + $builder = $model->whereNotMorphedTo('morph', $relatedModel); + + $this->assertSame('select * from "model_parent_stubs" where not (("model_parent_stubs"."morph_type" <=> ? and "model_parent_stubs"."morph_id" in (?)))', $builder->toSql()); + $this->assertEquals([$relatedModel->getMorphClass(), $relatedModel->getKey()], $builder->getBindings()); + } + + public function testWhereNotMorphedToCollection() + { + $model = new ModelParentStub(); + $this->mockConnectionForModel($model, ''); + + $firstRelatedModel = new ModelCloseRelatedStub(); + $firstRelatedModel->id = 1; + + $secondRelatedModel = new ModelCloseRelatedStub(); + $secondRelatedModel->id = 2; + + $builder = $model->whereNotMorphedTo('morph', new Collection([$firstRelatedModel, $secondRelatedModel])); + + $this->assertSame('select * from "model_parent_stubs" where not (("model_parent_stubs"."morph_type" <=> ? and "model_parent_stubs"."morph_id" in (?, ?)))', $builder->toSql()); + $this->assertEquals([$firstRelatedModel->getMorphClass(), $firstRelatedModel->getKey(), $secondRelatedModel->getKey()], $builder->getBindings()); + } + + public function testWhereNotMorphedToCollectionWithDifferentModels() + { + $model = new ModelParentStub(); + $this->mockConnectionForModel($model, ''); + + $firstRelatedModel = new ModelCloseRelatedStub(); + $firstRelatedModel->id = 1; + + $secondRelatedModel = new ModelFarRelatedStub(); + $secondRelatedModel->id = 2; + + $thirdRelatedModel = new ModelCloseRelatedStub(); + $thirdRelatedModel->id = 3; + + $builder = $model->whereNotMorphedTo('morph', [$firstRelatedModel, $secondRelatedModel, $thirdRelatedModel]); + + $this->assertSame('select * from "model_parent_stubs" where not (("model_parent_stubs"."morph_type" <=> ? and "model_parent_stubs"."morph_id" in (?, ?)) or ("model_parent_stubs"."morph_type" <=> ? and "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 ModelParentStub(); + $this->mockConnectionForModel($model, ''); + + $relatedModel = new ModelCloseRelatedStub(); + $relatedModel->id = 1; + + $builder = $model->where('bar', 'baz')->orWhereMorphedTo('morph', $relatedModel); + + $this->assertSame('select * from "model_parent_stubs" where "bar" = ? or (("model_parent_stubs"."morph_type" = ? and "model_parent_stubs"."morph_id" in (?)))', $builder->toSql()); + $this->assertEquals(['baz', $relatedModel->getMorphClass(), $relatedModel->getKey()], $builder->getBindings()); + } + + public function testOrWhereMorphedToCollection() + { + $model = new ModelParentStub(); + $this->mockConnectionForModel($model, ''); + + $firstRelatedModel = new ModelCloseRelatedStub(); + $firstRelatedModel->id = 1; + + $secondRelatedModel = new ModelCloseRelatedStub(); + $secondRelatedModel->id = 2; + + $builder = $model->where('bar', 'baz')->orWhereMorphedTo('morph', new Collection([$firstRelatedModel, $secondRelatedModel])); + + $this->assertSame('select * from "model_parent_stubs" where "bar" = ? or (("model_parent_stubs"."morph_type" = ? and "model_parent_stubs"."morph_id" in (?, ?)))', $builder->toSql()); + $this->assertEquals(['baz', $firstRelatedModel->getMorphClass(), $firstRelatedModel->getKey(), $secondRelatedModel->getKey()], $builder->getBindings()); + } + + public function testOrWhereMorphedToCollectionWithDifferentModels() + { + $model = new ModelParentStub(); + $this->mockConnectionForModel($model, ''); + + $firstRelatedModel = new ModelCloseRelatedStub(); + $firstRelatedModel->id = 1; + + $secondRelatedModel = new ModelFarRelatedStub(); + $secondRelatedModel->id = 2; + + $thirdRelatedModel = new ModelCloseRelatedStub(); + $thirdRelatedModel->id = 3; + + $builder = $model->where('bar', 'baz')->orWhereMorphedTo('morph', [$firstRelatedModel, $secondRelatedModel, $thirdRelatedModel]); + + $this->assertSame('select * from "model_parent_stubs" where "bar" = ? or (("model_parent_stubs"."morph_type" = ? and "model_parent_stubs"."morph_id" in (?, ?)) or ("model_parent_stubs"."morph_type" = ? and "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 ModelParentStub(); + $this->mockConnectionForModel($model, ''); + + $builder = $model->where('bar', 'baz')->orWhereMorphedTo('morph', null); + + $this->assertSame('select * from "model_parent_stubs" where "bar" = ? or "model_parent_stubs"."morph_type" is null', $builder->toSql()); + $this->assertEquals(['baz'], $builder->getBindings()); + } + + public function testOrWhereNotMorphedTo() + { + $model = new ModelParentStub(); + $this->mockConnectionForModel($model, ''); + + $relatedModel = new ModelCloseRelatedStub(); + $relatedModel->id = 1; + + $builder = $model->where('bar', 'baz')->orWhereNotMorphedTo('morph', $relatedModel); + + $this->assertSame('select * from "model_parent_stubs" where "bar" = ? or not (("model_parent_stubs"."morph_type" <=> ? and "model_parent_stubs"."morph_id" in (?)))', $builder->toSql()); + $this->assertEquals(['baz', $relatedModel->getMorphClass(), $relatedModel->getKey()], $builder->getBindings()); + } + + public function testOrWhereNotMorphedToCollection() + { + $model = new ModelParentStub(); + $this->mockConnectionForModel($model, ''); + + $firstRelatedModel = new ModelCloseRelatedStub(); + $firstRelatedModel->id = 1; + + $secondRelatedModel = new ModelCloseRelatedStub(); + $secondRelatedModel->id = 2; + + $builder = $model->where('bar', 'baz')->orWhereNotMorphedTo('morph', new Collection([$firstRelatedModel, $secondRelatedModel])); + + $this->assertSame('select * from "model_parent_stubs" where "bar" = ? or not (("model_parent_stubs"."morph_type" <=> ? and "model_parent_stubs"."morph_id" in (?, ?)))', $builder->toSql()); + $this->assertEquals(['baz', $firstRelatedModel->getMorphClass(), $firstRelatedModel->getKey(), $secondRelatedModel->getKey()], $builder->getBindings()); + } + + public function testOrWhereNotMorphedToCollectionWithDifferentModels() + { + $model = new ModelParentStub(); + $this->mockConnectionForModel($model, ''); + + $firstRelatedModel = new ModelCloseRelatedStub(); + $firstRelatedModel->id = 1; + + $secondRelatedModel = new ModelFarRelatedStub(); + $secondRelatedModel->id = 2; + + $thirdRelatedModel = new ModelCloseRelatedStub(); + $thirdRelatedModel->id = 3; + + $builder = $model->where('bar', 'baz')->orWhereNotMorphedTo('morph', [$firstRelatedModel, $secondRelatedModel, $thirdRelatedModel]); + + $this->assertSame('select * from "model_parent_stubs" where "bar" = ? or not (("model_parent_stubs"."morph_type" <=> ? and "model_parent_stubs"."morph_id" in (?, ?)) or ("model_parent_stubs"."morph_type" <=> ? and "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 ModelParentStub(); + $this->mockConnectionForModel($model, ''); + + $builder = $model->whereMorphedTo('morph', ModelCloseRelatedStub::class); + + $this->assertSame('select * from "model_parent_stubs" where "model_parent_stubs"."morph_type" = ?', $builder->toSql()); + $this->assertEquals([ModelCloseRelatedStub::class], $builder->getBindings()); + } + + public function testWhereNotMorphedToClass() + { + $model = new ModelParentStub(); + $this->mockConnectionForModel($model, ''); + + $builder = $model->whereNotMorphedTo('morph', ModelCloseRelatedStub::class); + + $this->assertSame('select * from "model_parent_stubs" where not "model_parent_stubs"."morph_type" <=> ?', $builder->toSql()); + $this->assertEquals([ModelCloseRelatedStub::class], $builder->getBindings()); + } + + public function testOrWhereMorphedToClass() + { + $model = new ModelParentStub(); + $this->mockConnectionForModel($model, ''); + + $builder = $model->where('bar', 'baz')->orWhereMorphedTo('morph', ModelCloseRelatedStub::class); + + $this->assertSame('select * from "model_parent_stubs" where "bar" = ? or "model_parent_stubs"."morph_type" = ?', $builder->toSql()); + $this->assertEquals(['baz', ModelCloseRelatedStub::class], $builder->getBindings()); + } + + public function testOrWhereNotMorphedToClass() + { + $model = new ModelParentStub(); + $this->mockConnectionForModel($model, ''); + + $builder = $model->where('bar', 'baz')->orWhereNotMorphedTo('morph', ModelCloseRelatedStub::class); + + $this->assertSame('select * from "model_parent_stubs" where "bar" = ? or not "model_parent_stubs"."morph_type" <=> ?', $builder->toSql()); + $this->assertEquals(['baz', ModelCloseRelatedStub::class], $builder->getBindings()); + } + + public function testWhereNotMorphedToWithSQLite() + { + $model = new ModelParentStub(); + $this->mockConnectionForModel($model, 'SQLite'); + + $relatedModel = new ModelCloseRelatedStub(); + $relatedModel->id = 1; + + $builder = $model->whereNotMorphedTo('morph', $relatedModel); + + $this->assertStringNotContainsString('<=>', $builder->toSql()); + $this->assertSame('select * from "model_parent_stubs" where not (("model_parent_stubs"."morph_type" IS ? and "model_parent_stubs"."morph_id" in (?)))', $builder->toSql()); + $this->assertEquals([$relatedModel->getMorphClass(), $relatedModel->getKey()], $builder->getBindings()); + } + + public function testWhereNotMorphedToClassWithSQLite() + { + $model = new ModelParentStub(); + $this->mockConnectionForModel($model, 'SQLite'); + + $builder = $model->whereNotMorphedTo('morph', ModelCloseRelatedStub::class); + + $this->assertStringNotContainsString('<=>', $builder->toSql()); + $this->assertSame('select * from "model_parent_stubs" where not "model_parent_stubs"."morph_type" IS ?', $builder->toSql()); + $this->assertEquals([ModelCloseRelatedStub::class], $builder->getBindings()); + } + + public function testWhereMorphedToAlias() + { + $model = new ModelParentStub(); + $this->mockConnectionForModel($model, ''); + + Relation::morphMap([ + 'alias' => ModelCloseRelatedStub::class, + ]); + + $builder = $model->whereMorphedTo('morph', ModelCloseRelatedStub::class); + + $this->assertSame('select * from "model_parent_stubs" where "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 StubStringPrimaryKey(); + $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 StubStringPrimaryKey(); + $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 StubStringPrimaryKey(); + $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 StubStringPrimaryKey(); + $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 StubStringPrimaryKey(); + $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 StubStringPrimaryKey(); + $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 StubStringPrimaryKey(); + $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 StubStringPrimaryKey(); + $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 StubStringPrimaryKey(); + $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 NestedStub(); + $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 Stub(); + $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 Stub(); + $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 Stub(); + $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 StubWithoutTimestamp(); + $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 Stub(); + $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 Stub(); + $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 StubStringPrimaryKey(); + $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 StubStringPrimaryKey(); + $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 StubStringPrimaryKey(); + $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 StubWithoutTimestamp(); + $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 Stub()); + $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 Stub extends Model +{ + protected ?string $table = 'table'; +} + +class ScopeStub extends Model +{ + public function scopeApproved($query) + { + $query->where('foo', 'bar'); + } +} + +class DynamicScopeStub extends Model +{ + public function scopeDynamic($query, $foo = 'foo', $bar = 'bar') + { + $query->where($foo, $bar); + } +} + +class HigherOrderWhereScopeStub 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 NestedStub extends Model +{ + use SoftDeletes; + + protected ?string $table = 'table'; + + public function scopeEmpty($query) + { + return $query; + } +} + +class PluckStub 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 PluckDatesStub 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 ModelParentStub extends Model +{ + public function foo() + { + return $this->belongsTo(ModelCloseRelatedStub::class); + } + + public function address() + { + return $this->belongsTo(ModelCloseRelatedStub::class, 'foo_id'); + } + + public function activeFoo() + { + return $this->belongsTo(ModelCloseRelatedStub::class, 'foo_id')->where('active', true); + } + + public function roles() + { + return $this->belongsToMany( + ModelFarRelatedStub::class, + 'user_role', + 'self_id', + 'related_id' + ); + } + + public function morph() + { + return $this->morphTo(); + } +} + +class ModelCloseRelatedStub extends Model +{ + public function bar() + { + return $this->hasMany(ModelFarRelatedStub::class); + } + + public function baz() + { + return $this->hasMany(ModelFarRelatedStub::class); + } + + public function bam() + { + return $this->hasMany(ModelOtherFarRelatedStub::class); + } +} + +class ModelFarRelatedStub extends Model +{ + public function roles() + { + return $this->belongsToMany( + ModelParentStub::class, + 'user_role', + 'related_id', + 'self_id', + ); + } + + public function baz() + { + return $this->belongsTo(ModelCloseRelatedStub::class); + } +} + +class ModelOtherFarRelatedStub extends Model +{ + public function roles() + { + return $this->belongsToMany( + ModelParentStub::class, + 'user_role', + 'related_id', + 'self_id', + ); + } + + public function baz() + { + return $this->belongsTo(ModelCloseRelatedStub::class); + } +} + +class ModelSelfRelatedStub 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(ModelFarRelatedStub::class, 'foreign_key', 'id', 'bar'); + } +} + +class StubWithoutTimestamp extends Model +{ + public const UPDATED_AT = null; + + protected ?string $table = 'table'; +} + +class StubStringPrimaryKey extends Model +{ + public bool $incrementing = false; + + protected ?string $table = 'foo_table'; + + protected string $keyType = 'string'; +} + +class WhereBelongsToStub extends Model +{ + protected array $fillable = [ + 'id', + 'parent_id', + ]; + + public function whereBelongsToStub() + { + 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..6cde4f5e2 --- /dev/null +++ b/tests/Database/Laravel/DatabaseEloquentCollectionQueueableTest.php @@ -0,0 +1,70 @@ +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..fc85a6f58 --- /dev/null +++ b/tests/Database/Laravel/DatabaseEloquentCollectionTest.php @@ -0,0 +1,912 @@ +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 CollectionModel())->forceFill(['id' => 1]); + $model2 = (new CollectionModel())->forceFill(['id' => 2]); + $model3 = (new CollectionModel())->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 CollectionModel())->forceFill(['id' => 1]); + $model2 = (new CollectionModel())->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\DatabaseEloquentCollectionTest\CollectionModel] 3'); + + $c->findOrFail([1, 2, 3]); + } + + public function testFindOrFailThrowsExceptionWithMessageWhenOtherModelsArePresent() + { + $model = (new CollectionModel())->forceFill(['id' => 1]); + + $c = new Collection([$model]); + + $this->expectException(ModelNotFoundException::class); + $this->expectExceptionMessage('No query results for model [Hypervel\Tests\Database\Laravel\DatabaseEloquentCollectionTest\CollectionModel] 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 CollectionModel(); + $two = new CollectionModel(); + $three = new CollectionModel(); + $four = new CollectionModel(); + $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 CollectionModel(); + $two = new CollectionModel(); + $three = new CollectionModel(); + $four = new CollectionModel(); + $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 CollectionModel()]); + $c = $c->makeHidden(['visible']); + + $this->assertEquals(['hidden', 'visible'], $c[0]->getHidden()); + } + + public function testMakeVisibleRemovesHiddenFromEntireCollection() + { + $c = new Collection([new CollectionModel()]); + $c = $c->makeVisible(['hidden']); + + $this->assertEquals([], $c[0]->getHidden()); + } + + public function testMergeHiddenAddsHiddenOnEntireCollection() + { + $c = new Collection([new CollectionModel()]); + $c = $c->mergeHidden(['merged']); + + $this->assertEquals(['hidden', 'merged'], $c[0]->getHidden()); + } + + public function testMergeVisibleRemovesHiddenFromEntireCollection() + { + $c = new Collection([new CollectionModel()]); + $c = $c->mergeVisible(['merged']); + + $this->assertEquals(['visible', 'merged'], $c[0]->getVisible()); + } + + public function testSetVisibleReplacesVisibleOnEntireCollection() + { + $c = new Collection([new CollectionModel()]); + $c = $c->setVisible(['hidden']); + + $this->assertEquals(['hidden'], $c[0]->getVisible()); + } + + public function testSetHiddenReplacesHiddenOnEntireCollection() + { + $c = new Collection([new CollectionModel()]); + $c = $c->setHidden(['visible']); + + $this->assertEquals(['visible'], $c[0]->getHidden()); + } + + public function testAppendsAddsTestOnEntireCollection() + { + $c = new Collection([new CollectionModel()]); + $c = $c->makeVisible('test'); + $c = $c->append('test'); + + $this->assertEquals(['test' => 'test'], $c[0]->toArray()); + } + + public function testSetAppendsSetsAppendedPropertiesOnEntireCollection() + { + $c = new Collection([new AppendingUser()]); + $c->setAppends(['other_appended_field']); + + $this->assertEquals( + [['other_appended_field' => 'bye']], + $c->toArray() + ); + } + + public function testWithoutAppendsRemovesAppendsOnEntireCollection() + { + $this->seedData(); + $c = AppendingUser::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 CollectionModel()]); + $c = $c->makeVisible('hidden'); + + $this->assertEquals([], $c[0]->getHidden()); + $this->assertEquals(['visible', 'hidden'], $c[0]->getVisible()); + } + + public function testMultiply() + { + $a = new CollectionModel(); + $b = new CollectionModel(); + + $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 CollectionModel(), new CollectionModel()]); + $this->assertEquals(CollectionModel::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 CollectionModel(), (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 = User::with('articles')->first(); + $user->articles->loadExists('comments'); + $commentsExists = $user->articles->pluck('comments_exists')->toArray(); + + $this->assertContainsOnly('bool', $commentsExists); + } + + public function testWithNonScalarKey() + { + $fooKey = new TestKey('foo'); + $foo = m::mock(Model::class); + $foo->shouldReceive('getKey')->andReturn($fooKey); + + $barKey = new TestKey('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 CollectionModel())->forceFill(['id' => 1, 'name' => 'John', 'country' => 'US']); + $model2 = (new CollectionModel())->forceFill(['id' => 2, 'name' => 'Jane', 'country' => 'NL']); + $model3 = (new CollectionModel())->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 (CollectionModel $model) => "{$model->name} ({$model->country})")->all()); + } + + /** + * Helpers... + */ + protected function seedData() + { + $user = User::create(['id' => 1, 'email' => 'taylorotwell@gmail.com']); + + Article::query()->insert([ + ['user_id' => 1, 'title' => 'Another title'], + ['user_id' => 1, 'title' => 'Another title'], + ['user_id' => 1, 'title' => 'Another title'], + ]); + + Comment::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 CollectionModel extends Model +{ + protected array $visible = ['visible']; + + protected array $hidden = ['hidden']; + + public function getTestAttribute(): string + { + return 'test'; + } +} + +class User extends Model +{ + protected ?string $table = 'users'; + + protected array $guarded = []; + + public bool $timestamps = false; + + public function articles() + { + return $this->hasMany(Article::class, 'user_id'); + } +} + +class Article extends Model +{ + protected ?string $table = 'articles'; + + protected array $guarded = []; + + public bool $timestamps = false; + + public function comments() + { + return $this->hasMany(Comment::class, 'article_id'); + } +} + +class Comment extends Model +{ + protected ?string $table = 'comments'; + + protected array $guarded = []; + + public bool $timestamps = false; +} + +class TestKey +{ + public function __construct(private readonly string $key) + { + } + + public function __toString(): string + { + return $this->key; + } +} + +class AppendingUser 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(Article::class, 'user_id'); + } +} diff --git a/tests/Database/Laravel/DatabaseEloquentDynamicRelationsTest.php b/tests/Database/Laravel/DatabaseEloquentDynamicRelationsTest.php new file mode 100644 index 000000000..ea02171f4 --- /dev/null +++ b/tests/Database/Laravel/DatabaseEloquentDynamicRelationsTest.php @@ -0,0 +1,139 @@ + 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..be73e527d --- /dev/null +++ b/tests/Database/Laravel/DatabaseEloquentFactoryTest.php @@ -0,0 +1,1289 @@ +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. + */ + 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. + */ + protected function tearDown(): void + { + $this->schema()->drop('users'); + + Container::setInstance(null); + + parent::tearDown(); + } + + public function testBasicModelCanBeCreated() + { + $user = UserFactory::new()->create(); + $this->assertInstanceOf(Eloquent::class, $user); + + $user = UserFactory::new()->createOne(); + $this->assertInstanceOf(Eloquent::class, $user); + + $user = UserFactory::new()->create(['name' => 'Taylor Otwell']); + $this->assertInstanceOf(Eloquent::class, $user); + $this->assertSame('Taylor Otwell', $user->name); + + $user = UserFactory::new()->set('name', 'Taylor Otwell')->create(); + $this->assertInstanceOf(Eloquent::class, $user); + $this->assertSame('Taylor Otwell', $user->name); + + $users = UserFactory::new()->createMany([ + ['name' => 'Taylor Otwell'], + ['name' => 'Jeffrey Way'], + ]); + $this->assertInstanceOf(Collection::class, $users); + $this->assertCount(2, $users); + + $users = UserFactory::new()->createMany(2); + $this->assertInstanceOf(Collection::class, $users); + $this->assertCount(2, $users); + $this->assertInstanceOf(User::class, $users->first()); + + $users = UserFactory::times(2)->createMany(); + $this->assertInstanceOf(Collection::class, $users); + $this->assertCount(2, $users); + $this->assertInstanceOf(User::class, $users->first()); + + $users = UserFactory::times(2)->createMany(); + $this->assertInstanceOf(Collection::class, $users); + $this->assertCount(2, $users); + $this->assertInstanceOf(User::class, $users->first()); + + $users = UserFactory::times(3)->createMany([ + ['name' => 'Taylor Otwell'], + ['name' => 'Jeffrey Way'], + ]); + $this->assertInstanceOf(Collection::class, $users); + $this->assertCount(2, $users); + $this->assertInstanceOf(User::class, $users->first()); + + $users = UserFactory::new()->createMany(); + $this->assertInstanceOf(Collection::class, $users); + $this->assertCount(1, $users); + $this->assertInstanceOf(User::class, $users->first()); + + $users = UserFactory::times(10)->create(); + $this->assertCount(10, $users); + } + + public function testExpandedClosureAttributesAreResolvedAndPassedToClosures() + { + $user = UserFactory::new()->create([ + 'name' => function () { + return 'taylor'; + }, + 'options' => function ($attributes) { + return $attributes['name'] . '-options'; + }, + ]); + + $this->assertSame('taylor-options', $user->options); + } + + public function testExpandedClosureAttributeReturningAFactoryIsResolved() + { + $post = PostFactory::new()->create([ + 'title' => 'post', + 'user_id' => fn ($attributes) => UserFactory::new([ + 'options' => $attributes['title'] . '-options', + ]), + ]); + + $this->assertEquals('post-options', $post->user->options); + } + + public function testMakeCreatesUnpersistedModelInstance() + { + $user = UserFactory::new()->makeOne(); + $this->assertInstanceOf(Eloquent::class, $user); + + $user = UserFactory::new()->make(['name' => 'Taylor Otwell']); + + $this->assertInstanceOf(Eloquent::class, $user); + $this->assertSame('Taylor Otwell', $user->name); + $this->assertCount(0, User::all()); + } + + public function testBasicModelAttributesCanBeCreated() + { + $user = UserFactory::new()->raw(); + $this->assertIsArray($user); + + $user = UserFactory::new()->raw(['name' => 'Taylor Otwell']); + $this->assertIsArray($user); + $this->assertSame('Taylor Otwell', $user['name']); + } + + public function testExpandedModelAttributesCanBeCreated() + { + $post = PostFactory::new()->raw(); + $this->assertIsArray($post); + + $post = PostFactory::new()->raw(['title' => 'Test Title']); + $this->assertIsArray($post); + $this->assertIsInt($post['user_id']); + $this->assertSame('Test Title', $post['title']); + } + + public function testLazyModelAttributesCanBeCreated() + { + $userFunction = UserFactory::new()->lazy(); + $this->assertIsCallable($userFunction); + $this->assertInstanceOf(Eloquent::class, $userFunction()); + + $userFunction = UserFactory::new()->lazy(['name' => 'Taylor Otwell']); + $this->assertIsCallable($userFunction); + + $user = $userFunction(); + $this->assertInstanceOf(Eloquent::class, $user); + $this->assertSame('Taylor Otwell', $user->name); + } + + public function testMultipleModelAttributesCanBeCreated() + { + $posts = PostFactory::times(10)->raw(); + $this->assertIsArray($posts); + + $this->assertCount(10, $posts); + } + + public function testAfterCreatingAndMakingCallbacksAreCalled() + { + $user = UserFactory::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 testHasManyRelationship() + { + $users = UserFactory::times(10) + ->has( + PostFactory::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, User::all()); + $this->assertCount(30, Post::all()); + $this->assertCount(3, User::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 testBelongsToRelationship() + { + $posts = PostFactory::times(3) + ->for(UserFactory::new(['name' => 'Taylor Otwell']), 'user') + ->create(); + + $this->assertCount(3, $posts->filter(function ($post) { + return $post->user->name === 'Taylor Otwell'; + })); + + $this->assertCount(1, User::all()); + $this->assertCount(3, Post::all()); + } + + public function testBelongsToRelationshipWithExistingModelInstance() + { + $user = UserFactory::new(['name' => 'Taylor Otwell'])->create(); + $posts = PostFactory::times(3) + ->for($user, 'user') + ->create(); + + $this->assertCount(3, $posts->filter(function ($post) use ($user) { + return $post->user->is($user); + })); + + $this->assertCount(1, User::all()); + $this->assertCount(3, Post::all()); + } + + public function testBelongsToRelationshipWithExistingModelInstanceWithRelationshipNameImpliedFromModel() + { + $user = UserFactory::new(['name' => 'Taylor Otwell'])->create(); + $posts = PostFactory::times(3) + ->for($user) + ->create(); + + $this->assertCount(3, $posts->filter(function ($post) use ($user) { + return $post->factoryTestUser->is($user); + })); + + $this->assertCount(1, User::all()); + $this->assertCount(3, Post::all()); + } + + public function testMorphToRelationship() + { + $posts = CommentFactory::times(3) + ->for(PostFactory::new(['title' => 'Test Title']), 'commentable') + ->create(); + + $this->assertSame('Test Title', Post::first()->title); + $this->assertCount(3, Post::first()->comments); + + $this->assertCount(1, Post::all()); + $this->assertCount(3, Comment::all()); + } + + public function testMorphToRelationshipWithExistingModelInstance() + { + $post = PostFactory::new(['title' => 'Test Title'])->create(); + $posts = CommentFactory::times(3) + ->for($post, 'commentable') + ->create(); + + $this->assertSame('Test Title', Post::first()->title); + $this->assertCount(3, Post::first()->comments); + + $this->assertCount(1, Post::all()); + $this->assertCount(3, Comment::all()); + } + + public function testBelongsToManyRelationship() + { + $users = UserFactory::times(3) + ->hasAttached( + RoleFactory::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, Role::all()); + + $user = User::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 testBelongsToManyRelationshipRelatedModelsSetOnInstanceWhenTouchingOwner() + { + $user = UserFactory::new()->create(); + $role = RoleFactory::new()->hasAttached($user, [], 'users')->create(); + + $this->assertCount(1, $role->users); + } + + public function testRelationCanBeLoadedBeforeModelIsCreated() + { + $user = UserFactory::new(['name' => 'Taylor Otwell'])->createOne(); + + $post = PostFactory::new() + ->for($user, 'user') + ->afterMaking(function (Post $post) { + $post->load('user'); + }) + ->createOne(); + + $this->assertTrue($post->relationLoaded('user')); + $this->assertTrue($post->user->is($user)); + + $this->assertCount(1, User::all()); + $this->assertCount(1, Post::all()); + } + + public function testBelongsToManyRelationshipWithExistingModelInstances() + { + $roles = RoleFactory::times(3) + ->afterCreating(function ($role) { + $_SERVER['__test.role.creating-role'] = $role; + }) + ->create(); + UserFactory::times(3) + ->hasAttached($roles, ['admin' => 'Y'], 'roles') + ->create(); + + $this->assertCount(3, Role::all()); + + $user = User::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 testBelongsToManyRelationshipWithExistingModelInstancesUsingArray() + { + $roles = RoleFactory::times(3) + ->afterCreating(function ($role) { + $_SERVER['__test.role.creating-role'] = $role; + }) + ->create(); + UserFactory::times(3) + ->hasAttached($roles->toArray(), ['admin' => 'Y'], 'roles') + ->create(); + + $this->assertCount(3, Role::all()); + + $user = User::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 testBelongsToManyRelationshipWithExistingModelInstancesWithRelationshipNameImpliedFromModel() + { + $roles = RoleFactory::times(3) + ->afterCreating(function ($role) { + $_SERVER['__test.role.creating-role'] = $role; + }) + ->create(); + UserFactory::times(3) + ->hasAttached($roles, ['admin' => 'Y']) + ->create(); + + $this->assertCount(3, Role::all()); + + $user = User::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 testSequences() + { + $users = UserFactory::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 = UserFactory::new() + ->hasAttached( + RoleFactory::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 = UserFactory::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 testCountedSequence() + { + $factory = UserFactory::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 testSequenceWithHasManyRelationship() + { + $users = UserFactory::times(2) + ->sequence( + ['name' => 'Abigail Otwell'], + ['name' => 'Taylor Otwell'], + ) + ->has( + PostFactory::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, User::all()); + $this->assertCount(6, Post::all()); + $this->assertCount(3, User::latest()->first()->posts); + $this->assertEquals( + Post::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 testCrossJoinSequences() + { + $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 = UserFactory::times(4) + ->state( + new CrossJoinSequence( + [['first_name' => 'Thomas'], ['first_name' => 'Agent']], + [['last_name' => 'Anderson'], ['last_name' => 'Smith']], + ), + ) + ->make(); + + $assert($usersByClass); + + $usersByMethod = UserFactory::times(4) + ->crossJoinSequence( + [['first_name' => 'Thomas'], ['first_name' => 'Agent']], + [['last_name' => 'Anderson'], ['last_name' => 'Smith']], + ) + ->make(); + + $assert($usersByMethod); + } + + public function testResolveNestedModelFactories() + { + 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 testResolveNestedModelNameFromFactory() + { + 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 testResolveNonAppNestedModelFactories() + { + 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 testModelHasFactory() + { + Factory::guessFactoryNamesUsing(function ($model) { + return $model . 'Factory'; + }); + + $this->assertInstanceOf(UserFactory::class, User::factory()); + } + + public function testDynamicHasAndForMethods() + { + Factory::guessFactoryNamesUsing(function ($model) { + return $model . 'Factory'; + }); + + $user = UserFactory::new()->hasPosts(3)->create(); + + $this->assertCount(3, $user->posts); + + $post = PostFactory::new() + ->forAuthor(['name' => 'Taylor Otwell']) + ->hasComments(2) + ->create(); + + $this->assertInstanceOf(User::class, $post->author); + $this->assertSame('Taylor Otwell', $post->author->name); + $this->assertCount(2, $post->comments); + } + + public function testCanBeMacroable() + { + $factory = UserFactory::new(); + $factory->macro('getFoo', function () { + return 'Hello World'; + }); + + $this->assertSame('Hello World', $factory->getFoo()); + } + + public function testFactoryCanConditionallyExecuteCode() + { + UserFactory::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 testDynamicTrashedStateForSoftdeletesModels() + { + $now = Carbon::create(2020, 6, 7, 8, 9); + Carbon::setTestNow($now); + $post = PostFactory::new()->trashed()->create(); + + $this->assertTrue($post->deleted_at->equalTo($now->subDay())); + + $deleted_at = Carbon::create(2020, 1, 2, 3, 4, 5); + $post = PostFactory::new()->trashed($deleted_at)->create(); + + $this->assertTrue($deleted_at->equalTo($post->deleted_at)); + + Carbon::setTestNow(); + } + + public function testDynamicTrashedStateRespectsExistingState() + { + $now = Carbon::create(2020, 6, 7, 8, 9); + Carbon::setTestNow($now); + $comment = CommentFactory::new()->trashed()->create(); + + $this->assertTrue($comment->deleted_at->equalTo($now->subWeek())); + + Carbon::setTestNow(); + } + + public function testDynamicTrashedStateThrowsExceptionWhenNotASoftdeletesModel() + { + $this->expectException(BadMethodCallException::class); + UserFactory::new()->trashed()->create(); + } + + public function testModelInstancesCanBeUsedInPlaceOfNestedFactories() + { + Factory::guessFactoryNamesUsing(function ($model) { + return $model . 'Factory'; + }); + + $user = UserFactory::new()->create(); + $post = PostFactory::new() + ->recycle($user) + ->hasComments(2) + ->create(); + + $this->assertSame(1, User::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 testForMethodRecyclesModels() + { + Factory::guessFactoryNamesUsing(function ($model) { + return $model . 'Factory'; + }); + + $user = UserFactory::new()->create(); + $post = PostFactory::new() + ->recycle($user) + ->for(UserFactory::new()) + ->create(); + + $this->assertSame(1, User::count()); + } + + public function testHasMethodDoesNotReassignTheParent() + { + Factory::guessFactoryNamesUsing(function ($model) { + return $model . 'Factory'; + }); + + $post = PostFactory::new()->create(); + $user = UserFactory::new() + ->recycle($post) + // The recycled post already belongs to a user, so it shouldn't be recycled here. + ->has(PostFactory::new(), 'posts') + ->create(); + + $this->assertSame(2, Post::count()); + } + + public function testMultipleModelsCanBeProvidedToRecycle() + { + Factory::guessFactoryNamesUsing(function ($model) { + return $model . 'Factory'; + }); + + $users = UserFactory::new()->count(3)->create(); + + $posts = PostFactory::new() + ->recycle($users) + ->for(UserFactory::new()) + ->has(CommentFactory::new()->count(5), 'comments') + ->count(2) + ->create(); + + $this->assertSame(3, User::count()); + } + + public function testRecycledModelsCanBeCombinedWithMultipleCalls() + { + Factory::guessFactoryNamesUsing(function ($model) { + return $model . 'Factory'; + }); + + $users = UserFactory::new() + ->count(2) + ->create(); + $posts = PostFactory::new() + ->recycle($users) + ->count(2) + ->create(); + $additionalUser = UserFactory::new() + ->create(); + $additionalPost = PostFactory::new() + ->recycle($additionalUser) + ->create(); + + $this->assertSame(3, User::count()); + $this->assertSame(3, Post::count()); + + $comments = CommentFactory::new() + ->recycle($users) + ->recycle($posts) + ->recycle([$additionalUser, $additionalPost]) + ->count(5) + ->create(); + + $this->assertSame(3, User::count()); + $this->assertSame(3, Post::count()); + } + + public function testNoModelsCanBeProvidedToRecycle() + { + Factory::guessFactoryNamesUsing(function ($model) { + return $model . 'Factory'; + }); + + $posts = PostFactory::new() + ->recycle([]) + ->count(2) + ->create(); + + $this->assertSame(2, Post::count()); + $this->assertSame(2, User::count()); + } + + public function testCanDisableRelationships() + { + $post = PostFactory::new() + ->withoutParents() + ->make(); + + $this->assertNull($post->user_id); + } + + public function testCanDisableRelationshipsExplicitlyByModelName() + { + $comment = CommentFactory::new() + ->withoutParents([User::class]) + ->make(); + + $this->assertNull($comment->user_id); + $this->assertNotNull($comment->commentable->id); + } + + public function testCanDisableRelationshipsExplicitlyByAttributeName() + { + $comment = CommentFactory::new() + ->withoutParents(['user_id']) + ->make(); + + $this->assertNull($comment->user_id); + $this->assertNotNull($comment->commentable->id); + } + + public function testCanDisableRelationshipsExplicitlyByBothAttributeNameAndModelName() + { + $comment = CommentFactory::new() + ->withoutParents(['user_id', Post::class]) + ->make(); + + $this->assertNull($comment->user_id); + $this->assertNull($comment->commentable->id); + } + + public function testCanDefaultToWithoutParents() + { + PostFactory::dontExpandRelationshipsByDefault(); + + $post = PostFactory::new()->make(); + $this->assertNull($post->user_id); + + PostFactory::expandRelationshipsByDefault(); + $postWithParents = PostFactory::new()->create(); + $this->assertNotNull($postWithParents->user_id); + } + + public function testFactoryModelNamesCorrect() + { + $this->assertEquals(UseFactoryAttribute::factory()->modelName(), UseFactoryAttribute::class); + $this->assertEquals(GuessModel::factory()->modelName(), GuessModel::class); + } + + public function testFactoryGlobalModelResolver() + { + Factory::guessModelNamesUsing(function ($factory) { + return __NAMESPACE__ . '\\' . Str::replaceLast('Factory', '', class_basename($factory::class)); + }); + + $this->assertEquals(GuessModel::factory()->modelName(), GuessModel::class); + $this->assertEquals(UseFactoryAttribute::factory()->modelName(), UseFactoryAttribute::class); + + $this->assertEquals(UseFactoryAttributeFactory::new()->modelName(), UseFactoryAttribute::class); + $this->assertEquals(GuessModelFactory::new()->modelName(), GuessModel::class); + } + + public function testFactoryModelHasManyRelationshipHasPendingAttributes() + { + User::factory()->has(new PostFactory(), 'postsWithFooBarBazAsTitle')->create(); + + $this->assertEquals('foo bar baz', Post::first()->title); + } + + public function testFactoryModelHasManyRelationshipHasPendingAttributesOverride() + { + User::factory()->has((new PostFactory())->state(['title' => 'other title']), 'postsWithFooBarBazAsTitle')->create(); + + $this->assertEquals('other title', Post::first()->title); + } + + public function testFactoryModelHasOneRelationshipHasPendingAttributes() + { + User::factory()->has(new PostFactory(), 'postWithFooBarBazAsTitle')->create(); + + $this->assertEquals('foo bar baz', Post::first()->title); + } + + public function testFactoryModelHasOneRelationshipHasPendingAttributesOverride() + { + User::factory()->has((new PostFactory())->state(['title' => 'other title']), 'postWithFooBarBazAsTitle')->create(); + + $this->assertEquals('other title', Post::first()->title); + } + + public function testFactoryModelBelongsToManyRelationshipHasPendingAttributes() + { + User::factory()->has(new RoleFactory(), 'rolesWithFooBarBazAsName')->create(); + + $this->assertEquals('foo bar baz', Role::first()->name); + } + + public function testFactoryModelBelongsToManyRelationshipHasPendingAttributesOverride() + { + User::factory()->has((new RoleFactory())->state(['name' => 'other name']), 'rolesWithFooBarBazAsName')->create(); + + $this->assertEquals('other name', Role::first()->name); + } + + public function testFactoryModelMorphManyRelationshipHasPendingAttributes() + { + (new PostFactory())->has(new CommentFactory(), 'commentsWithFooBarBazAsBody')->create(); + + $this->assertEquals('foo bar baz', Comment::first()->body); + } + + public function testFactoryModelMorphManyRelationshipHasPendingAttributesOverride() + { + (new PostFactory())->has((new CommentFactory())->state(['body' => 'other body']), 'commentsWithFooBarBazAsBody')->create(); + + $this->assertEquals('other body', Comment::first()->body); + } + + public function testFactoryCanInsert() + { + (new PostFactory()) + ->count(5) + ->recycle([ + (new UserFactory())->create(['name' => Name::Taylor]), + (new UserFactory())->create(['name' => Name::Shad, 'created_at' => now()]), + ]) + ->state(['title' => 'hello']) + ->insert(); + $this->assertCount(5, $posts = Post::query()->where('title', 'hello')->get()); + $this->assertEquals(strtoupper($posts[0]->user->name), $posts[0]->upper_case_name); + $this->assertEquals( + 2, + ($users = User::query()->get())->count() + ); + $this->assertCount(1, $users->where('name', 'totwell')); + $this->assertCount(1, $users->where('name', 'shaedrich')); + } + + public function testFactoryCanInsertWithHidden() + { + (new UserFactory())->forEachSequence(['name' => Name::Taylor, 'options' => 'abc'])->insert(); + $user = DB::table('users')->sole(); + $this->assertEquals('abc', $user->options); + $userModel = User::query()->sole(); + $this->assertEquals('abc', $userModel->options); + } + + public function testFactoryCanInsertWithArrayCasts() + { + (new UserWithArrayFactory())->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 UserFactory extends Factory +{ + protected ?string $model = User::class; + + public function definition(): array + { + return [ + 'name' => $this->faker->name(), + 'options' => null, + ]; + } +} + +class User 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(Post::class, 'user_id'); + } + + public function postsWithFooBarBazAsTitle() + { + return $this->hasMany(Post::class, 'user_id')->withAttributes(['title' => 'foo bar baz']); + } + + public function postWithFooBarBazAsTitle() + { + return $this->hasOne(Post::class, 'user_id')->withAttributes(['title' => 'foo bar baz']); + } + + public function roles() + { + return $this->belongsToMany(Role::class, 'role_user', 'user_id', 'role_id')->withPivot('admin'); + } + + public function rolesWithFooBarBazAsName() + { + return $this->belongsToMany(Role::class, 'role_user', 'user_id', 'role_id')->withPivot('admin')->withAttributes(['name' => 'foo bar baz']); + } + + public function factoryTestRoles() + { + return $this->belongsToMany(Role::class, 'role_user', 'user_id', 'role_id')->withPivot('admin'); + } +} + +class PostFactory extends Factory +{ + protected ?string $model = Post::class; + + public function definition(): array + { + return [ + 'user_id' => UserFactory::new(), + 'title' => $this->faker->name(), + ]; + } +} + +class Post 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(User::class, 'user_id'); + } + + public function factoryTestUser() + { + return $this->belongsTo(User::class, 'user_id'); + } + + public function author() + { + return $this->belongsTo(User::class, 'user_id'); + } + + public function comments() + { + return $this->morphMany(Comment::class, 'commentable'); + } + + public function commentsWithFooBarBazAsBody() + { + return $this->morphMany(Comment::class, 'commentable')->withAttributes(['body' => 'foo bar baz']); + } +} + +class CommentFactory extends Factory +{ + protected ?string $model = Comment::class; + + public function definition(): array + { + return [ + 'commentable_id' => PostFactory::new(), + 'commentable_type' => Post::class, + 'user_id' => fn () => UserFactory::new(), + 'body' => $this->faker->name(), + ]; + } + + public function trashed() + { + return $this->state([ + 'deleted_at' => Carbon::now()->subWeek(), + ]); + } +} + +class Comment extends Eloquent +{ + use SoftDeletes; + + protected ?string $table = 'comments'; + + public function commentable() + { + return $this->morphTo(); + } +} + +class RoleFactory extends Factory +{ + protected ?string $model = Role::class; + + public function definition(): array + { + return [ + 'name' => $this->faker->name(), + ]; + } +} + +class Role extends Eloquent +{ + protected ?string $table = 'roles'; + + protected array $touches = ['users']; + + public function users() + { + return $this->belongsToMany(User::class, 'role_user', 'role_id', 'user_id')->withPivot('admin'); + } +} + +class GuessModelFactory extends Factory +{ + protected static function appNamespace(): string + { + return __NAMESPACE__ . '\\'; + } + + public function definition(): array + { + return [ + 'name' => $this->faker->name(), + ]; + } +} + +class GuessModel extends Eloquent +{ + use HasFactory; + + protected static $factory = GuessModelFactory::class; +} + +class UseFactoryAttributeFactory extends Factory +{ + public function definition(): array + { + return [ + 'name' => $this->faker->name(), + ]; + } +} + +#[UseFactory(UseFactoryAttributeFactory::class)] +class UseFactoryAttribute extends Eloquent +{ + use HasFactory; +} + +class UserWithArray extends Eloquent +{ + protected ?string $table = 'users'; + + protected function casts(): array + { + return ['options' => 'array']; + } +} + +class UserWithArrayFactory extends Factory +{ + protected ?string $model = UserWithArray::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..9fe00caae --- /dev/null +++ b/tests/Database/Laravel/DatabaseEloquentGlobalScopesTest.php @@ -0,0 +1,295 @@ +addConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + ])->bootEloquent(); + } + + protected function tearDown(): void + { + Model::unsetConnectionResolver(); + + parent::tearDown(); + } + + public function testGlobalScopeIsApplied() + { + $model = new GlobalScopesModel(); + $query = $model->newQuery(); + $this->assertSame('select * from "table" where "active" = ?', $query->toSql()); + $this->assertEquals([1], $query->getBindings()); + } + + public function testGlobalScopeCanBeRemoved() + { + $model = new GlobalScopesModel(); + $query = $model->newQuery()->withoutGlobalScope(ActiveScope::class); + $this->assertSame('select * from "table"', $query->toSql()); + $this->assertEquals([], $query->getBindings()); + } + + public function testClassNameGlobalScopeIsApplied() + { + $model = new ClassNameGlobalScopesModel(); + $query = $model->newQuery(); + $this->assertSame('select * from "table" where "active" = ?', $query->toSql()); + $this->assertEquals([1], $query->getBindings()); + } + + public function testGlobalScopeInAttributeIsApplied() + { + $model = new GlobalScopeInAttributeModel(); + $query = $model->newQuery(); + $this->assertSame('select * from "table" where "active" = ?', $query->toSql()); + $this->assertEquals([1], $query->getBindings()); + } + + public function testGlobalScopeInInheritedAttributeIsApplied() + { + $model = new GlobalScopeInInheritedAttributeModel(); + $query = $model->newQuery(); + $this->assertSame('select * from "table" where "active" = ?', $query->toSql()); + $this->assertEquals([1], $query->getBindings()); + } + + public function testClosureGlobalScopeIsApplied() + { + $model = new ClosureGlobalScopesModel(); + $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 GlobalScopesArrayModel(); + $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 ClosureGlobalScopesModel(); + $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 ClosureGlobalScopesModel(); + $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 ClosureGlobalScopesModel(); + $query = $model->newQuery()->withoutGlobalScopes(); + $this->assertSame('select * from "table"', $query->toSql()); + $this->assertEquals([], $query->getBindings()); + + $query = ClosureGlobalScopesModel::withoutGlobalScopes(); + $this->assertSame('select * from "table"', $query->toSql()); + $this->assertEquals([], $query->getBindings()); + } + + public function testAllGlobalScopesCanBeRemovedExceptSpecified() + { + $model = new ClosureGlobalScopesModel(); + $query = $model->newQuery()->withoutGlobalScopesExcept(['active_scope']); + $this->assertSame('select * from "table" where "active" = ?', $query->toSql()); + $this->assertEquals([1], $query->getBindings()); + + $query = ClosureGlobalScopesModel::withoutGlobalScopesExcept(['active_scope']); + $this->assertSame('select * from "table" where "active" = ?', $query->toSql()); + $this->assertEquals([1], $query->getBindings()); + } + + public function testGlobalScopesWithOrWhereConditionsAreNested() + { + $model = new ClosureGlobalScopesWithOrModel(); + + $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 = ClosureGlobalScopesModel::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 = ClosureGlobalScopesModel::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 = GlobalScopesWithRelationModel::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 ClosureGlobalScopesModel 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 GlobalScopesWithRelationModel extends ClosureGlobalScopesModel +{ + protected ?string $table = 'table2'; + + public function related() + { + return $this->hasMany(GlobalScopesModel::class, 'related_id')->where('foo', 'bar'); + } +} + +class ClosureGlobalScopesWithOrModel extends ClosureGlobalScopesModel +{ + 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 GlobalScopesModel extends Model +{ + protected ?string $table = 'table'; + + public static function boot(): void + { + static::addGlobalScope(new ActiveScope()); + + parent::boot(); + } +} + +class ClassNameGlobalScopesModel extends Model +{ + protected ?string $table = 'table'; + + public static function boot(): void + { + static::addGlobalScope(ActiveScope::class); + + parent::boot(); + } +} + +class GlobalScopesArrayModel 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 GlobalScopeInAttributeModel 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 GlobalScopeInInheritedAttributeTrait +{ +} + +class GlobalScopeInInheritedAttributeModel extends Model +{ + use GlobalScopeInInheritedAttributeTrait; + + protected ?string $table = 'table'; +} diff --git a/tests/Database/Laravel/DatabaseEloquentHasManyCreateOrFirstTest.php b/tests/Database/Laravel/DatabaseEloquentHasManyCreateOrFirstTest.php new file mode 100755 index 000000000..f5cebf3a7 --- /dev/null +++ b/tests/Database/Laravel/DatabaseEloquentHasManyCreateOrFirstTest.php @@ -0,0 +1,378 @@ +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 ParentModel(); + $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 ParentModel(); + $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 ParentModel(); + $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 ParentModel(); + $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 ParentModel(); + $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 ParentModel(); + $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 ParentModel(); + $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 ParentModel extends Model +{ + protected ?string $table = 'parent_table'; + + protected array $guarded = []; + + public function children(): HasMany + { + return $this->hasMany(ChildModel::class, 'parent_id'); + } +} + +/** + * @property int $id + * @property int $parent_id + */ +class ChildModel 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..052cac62f --- /dev/null +++ b/tests/Database/Laravel/DatabaseEloquentHasManyTest.php @@ -0,0 +1,492 @@ +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 + RelatedStub::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(RelatedStub::$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 ModelStub(); + $model1->id = 1; + $model2 = new ModelStub(); + $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 ModelStub(); + $model1->id = 1; + $model2 = new ModelStub(); + $model2->id = 2; + $relation->addEagerConstraints([$model1, $model2]); + } + + public function testModelsAreProperlyMatchedToParents() + { + $relation = $this->getRelation(); + + $result1 = new ModelStub(); + $result1->foreign_key = 1; + $result2 = new ModelStub(); + $result2->foreign_key = 2; + $result3 = new ModelStub(); + $result3->foreign_key = 2; + + $model1 = new ModelStub(); + $model1->id = 1; + $model2 = new ModelStub(); + $model2->id = 2; + $model3 = new ModelStub(); + $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 RelatedStub(); + $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 ModelStub 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 RelatedStub 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..2a21091f1 --- /dev/null +++ b/tests/Database/Laravel/DatabaseEloquentHasManyThroughCreateOrFirstTest.php @@ -0,0 +1,453 @@ +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 ParentModel(); + $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 ParentModel(); + $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 ParentModel(); + $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 ParentModel(); + $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 ParentModel(); + $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 ParentModel(); + $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 ParentModel(); + $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 ChildModel extends Model +{ + protected ?string $table = 'child'; + + protected array $guarded = []; +} + +/** + * @property int $id + * @property int $parent_id + */ +class PivotModel extends Model +{ + protected ?string $table = 'pivot'; + + protected array $guarded = []; +} + +/** + * @property int $id + */ +class ParentModel extends Model +{ + protected ?string $table = 'parent'; + + protected array $guarded = []; + + public function children(): HasManyThrough + { + return $this->hasManyThrough( + ChildModel::class, + PivotModel::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..1190ba675 --- /dev/null +++ b/tests/Database/Laravel/DatabaseEloquentHasManyThroughIntegrationTest.php @@ -0,0 +1,762 @@ +addConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + ]); + + $db->bootEloquent(); + $db->setAsGlobal(); + + $this->createSchema(); + } + + /** + * Setup the database schema. + */ + 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. + */ + protected function tearDown(): void + { + $this->schema()->drop('users'); + $this->schema()->drop('posts'); + $this->schema()->drop('countries'); + + parent::tearDown(); + } + + public function testItLoadsAHasManyThroughRelationWithCustomKeys() + { + $this->seedData(); + $posts = Country::first()->posts; + + $this->assertSame('A title', $posts[0]->title); + $this->assertCount(2, $posts); + } + + public function testItLoadsADefaultHasManyThroughRelation() + { + $this->migrateDefault(); + $this->seedDefaultData(); + + $posts = DefaultCountry::first()->posts; + $this->assertSame('A title', $posts[0]->title); + $this->assertCount(2, $posts); + + $this->resetDefault(); + } + + public function testItLoadsARelationWithCustomIntermediateAndLocalKey() + { + $this->seedData(); + $posts = IntermediateCountry::first()->posts; + + $this->assertSame('A title', $posts[0]->title); + $this->assertCount(2, $posts); + } + + public function testEagerLoadingARelationWithCustomIntermediateAndLocalKey() + { + $this->seedData(); + $posts = IntermediateCountry::with('posts')->first()->posts; + + $this->assertSame('A title', $posts[0]->title); + $this->assertCount(2, $posts); + } + + public function testWhereHasOnARelationWithCustomIntermediateAndLocalKey() + { + $this->seedData(); + $country = IntermediateCountry::whereHas('posts', function ($query) { + $query->where('title', 'A title'); + })->get(); + + $this->assertCount(1, $country); + } + + public function testWithWhereHasOnARelationWithCustomIntermediateAndLocalKey() + { + $this->seedData(); + $country = IntermediateCountry::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() + { + Country::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 = Country::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() + { + Country::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 = Country::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\DatabaseEloquentHasManyThroughIntegrationTest\Post].'); + + Country::create(['id' => 1, 'name' => 'United States of America', 'shortname' => 'us']) + ->users()->create(['id' => 1, 'email' => 'taylorotwell@gmail.com', 'country_short' => 'us']); + + Country::first()->posts()->firstOrFail(); + } + + public function testFindOrFailThrowsAnException() + { + $this->expectException(ModelNotFoundException::class); + $this->expectExceptionMessage('No query results for model [Hypervel\Tests\Database\Laravel\DatabaseEloquentHasManyThroughIntegrationTest\Post] 1'); + + Country::create(['id' => 1, 'name' => 'United States of America', 'shortname' => 'us']) + ->users()->create(['id' => 1, 'email' => 'taylorotwell@gmail.com', 'country_short' => 'us']); + + Country::first()->posts()->findOrFail(1); + } + + public function testFindOrFailWithManyThrowsAnException() + { + $this->expectException(ModelNotFoundException::class); + $this->expectExceptionMessage('No query results for model [Hypervel\Tests\Database\Laravel\DatabaseEloquentHasManyThroughIntegrationTest\Post] 1, 2'); + + Country::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']); + + Country::first()->posts()->findOrFail([1, 2]); + } + + public function testFindOrFailWithManyUsingCollectionThrowsAnException() + { + $this->expectException(ModelNotFoundException::class); + $this->expectExceptionMessage('No query results for model [Hypervel\Tests\Database\Laravel\DatabaseEloquentHasManyThroughIntegrationTest\Post] 1, 2'); + + Country::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']); + + Country::first()->posts()->findOrFail(new Collection([1, 2])); + } + + public function testFindOrMethod() + { + Country::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 = Country::first()->posts()->findOr(1, fn () => 'callback result'); + $this->assertInstanceOf(Post::class, $result); + $this->assertSame(1, $result->id); + $this->assertSame('A title', $result->title); + + $result = Country::first()->posts()->findOr(1, ['posts.id'], fn () => 'callback result'); + $this->assertInstanceOf(Post::class, $result); + $this->assertSame(1, $result->id); + $this->assertNull($result->title); + + $result = Country::first()->posts()->findOr(2, fn () => 'callback result'); + $this->assertSame('callback result', $result); + } + + public function testFindOrMethodWithMany() + { + Country::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 = Country::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 = Country::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 = Country::first()->posts()->findOr([1, 2, 3], fn () => 'callback result'); + $this->assertSame('callback result', $result); + } + + public function testFindOrMethodWithManyUsingCollection() + { + Country::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 = Country::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 = Country::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 = Country::first()->posts()->findOr(new Collection([1, 2, 3]), fn () => 'callback result'); + $this->assertSame('callback result', $result); + } + + public function testFirstRetrievesFirstRecord() + { + $this->seedData(); + $post = Country::first()->posts()->first(); + + $this->assertNotNull($post); + $this->assertSame('A title', $post->title); + } + + public function testAllColumnsAreRetrievedByDefault() + { + $this->seedData(); + $post = Country::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 = Country::first()->posts()->first(['title', 'body']); + + $this->assertEquals([ + 'title', + 'body', + 'laravel_through_key', + ], array_keys($post->getAttributes())); + } + + public function testChunkReturnsCorrectModels() + { + $this->seedData(); + $this->seedDataExtended(); + $country = Country::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 = Country::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 = Country::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 = Country::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 = Country::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 = Country::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 = Country::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(); + SoftDeletesUser::first()->delete(); + + $posts = SoftDeletesCountry::first()->posts; + + $this->assertSame('A title', $posts[0]->title); + $this->assertCount(2, $posts); + } + + public function testEagerLoadingLoadsRelatedModelsCorrectly() + { + $this->seedData(); + $country = SoftDeletesCountry::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() + { + Country::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 = Country::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() + { + DefaultCountry::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('default_country_id'); + $table->timestamps(); + }); + + $this->schema()->create('posts_default', function ($table) { + $table->increments('id'); + $table->integer('default_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 User extends Eloquent +{ + protected ?string $table = 'users'; + + protected array $guarded = []; + + public function posts() + { + return $this->hasMany(Post::class, 'user_id'); + } +} + +/** + * Eloquent Models... + */ +class Post extends Eloquent +{ + protected ?string $table = 'posts'; + + protected array $guarded = []; + + public function owner() + { + return $this->belongsTo(User::class, 'user_id'); + } +} + +class Country extends Eloquent +{ + protected ?string $table = 'countries'; + + protected array $guarded = []; + + public function posts() + { + return $this->hasManyThrough(Post::class, User::class, 'country_id', 'user_id'); + } + + public function users() + { + return $this->hasMany(User::class, 'country_id'); + } +} + +/** + * Eloquent Models... + */ +class DefaultUser extends Eloquent +{ + protected ?string $table = 'users_default'; + + protected array $guarded = []; + + public function posts() + { + return $this->hasMany(DefaultPost::class); + } +} + +/** + * Eloquent Models... + */ +class DefaultPost extends Eloquent +{ + protected ?string $table = 'posts_default'; + + protected array $guarded = []; + + public function owner() + { + return $this->belongsTo(DefaultUser::class); + } +} + +class DefaultCountry extends Eloquent +{ + protected ?string $table = 'countries_default'; + + protected array $guarded = []; + + public function posts() + { + return $this->hasManyThrough(DefaultPost::class, DefaultUser::class); + } + + public function users() + { + return $this->hasMany(DefaultUser::class); + } +} + +class IntermediateCountry extends Eloquent +{ + protected ?string $table = 'countries'; + + protected array $guarded = []; + + public function posts() + { + return $this->hasManyThrough(Post::class, User::class, 'country_short', 'email', 'shortname', 'email'); + } + + public function users() + { + return $this->hasMany(User::class, 'country_id'); + } +} + +class SoftDeletesUser extends Eloquent +{ + use SoftDeletes; + + protected ?string $table = 'users'; + + protected array $guarded = []; + + public function posts() + { + return $this->hasMany(SoftDeletesPost::class, 'user_id'); + } +} + +/** + * Eloquent Models... + */ +class SoftDeletesPost extends Eloquent +{ + protected ?string $table = 'posts'; + + protected array $guarded = []; + + public function owner() + { + return $this->belongsTo(SoftDeletesUser::class, 'user_id'); + } +} + +class SoftDeletesCountry extends Eloquent +{ + protected ?string $table = 'countries'; + + protected array $guarded = []; + + public function posts() + { + return $this->hasManyThrough(SoftDeletesPost::class, User::class, 'country_id', 'user_id'); + } + + public function users() + { + return $this->hasMany(SoftDeletesUser::class, 'country_id'); + } +} diff --git a/tests/Database/Laravel/DatabaseEloquentHasOneOfManyTest.php b/tests/Database/Laravel/DatabaseEloquentHasOneOfManyTest.php new file mode 100755 index 000000000..9f05e529c --- /dev/null +++ b/tests/Database/Laravel/DatabaseEloquentHasOneOfManyTest.php @@ -0,0 +1,720 @@ +addConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + ]); + + $db->bootEloquent(); + $db->setAsGlobal(); + + $this->createSchema(); + } + + /** + * Setup the database schema. + */ + 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. + */ + 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 = User::make(); + $this->assertSame('latest_login', $user->latest_login()->getRelationName()); + } + + public function testItGuessesRelationNameAndAddsOfManyWhenTableNameIsRelationName() + { + $model = TestModel::make(); + $this->assertSame('logins_of_many', $model->logins()->getRelationName()); + } + + public function testRelationNameCanBeSet() + { + $user = User::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 = User::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 = User::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() + { + Login::addGlobalScope('test', function ($query) { + $query->orderBy('id'); + }); + + $user = User::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()); + + Login::addGlobalScope('test', function ($query) { + }); + } + + public function testGlobalScopeIsNotAppliedWhenRelationIsDefinedWithoutGlobalScopeWithComplexQuery() + { + Price::addGlobalScope('test', function ($query) { + $query->orderBy('id'); + }); + + $user = User::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()); + + Price::addGlobalScope('test', function ($query) { + }); + } + + public function testQualifyingSubSelectColumn() + { + $user = User::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 = User::make(); + $user->latest_login_with_invalid_aggregate(); + } + + public function testItGetsCorrectResults() + { + $user = User::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 = User::create(); + $user->logins()->create(); + + $result = $user->latest_login()->getResults(); + $this->assertNotNull($result); + $this->assertFalse(isset($result->id_aggregate)); + } + + public function testItGetsCorrectResultsUsingShortcutMethod() + { + $user = User::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 = User::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 = User::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 = User::create(); + $previousLogin = $user->logins()->create(); + $user->logins()->create(); + + $result = $user->latest_login()->whereKey($previousLogin->getKey())->getResults(); + $this->assertNull($result); + } + + public function testItEagerLoadsCorrectModels() + { + $user = User::create(); + $user->logins()->create(); + $latestLogin = $user->logins()->create(); + + $user = User::with('latest_login')->first(); + + $this->assertTrue($user->relationLoaded('latest_login')); + $this->assertSame($latestLogin->id, $user->latest_login->id); + } + + public function testItJoinsOtherTableInSubQuery() + { + $user = User::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 = User::create(); + $previousLogin = $user->logins()->create(); + $latestLogin = $user->logins()->create(); + + $found = User::whereHas('latest_login', function ($query) use ($latestLogin) { + $query->where('logins.id', $latestLogin->id); + })->exists(); + $this->assertTrue($found); + + $found = User::whereHas('latest_login', function ($query) use ($previousLogin) { + $query->where('logins.id', $previousLogin->id); + })->exists(); + $this->assertFalse($found); + } + + public function testWithHasNested() + { + $user = User::create(); + $previousLogin = $user->logins()->create(); + $latestLogin = $user->logins()->create(); + + $found = User::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 = User::withWhereHas('latest_login', function ($query) use ($previousLogin) { + $query->where('logins.id', $previousLogin->id); + })->exists(); + + $this->assertFalse($found); + } + + public function testHasCount() + { + $user = User::create(); + $user->logins()->create(); + $user->logins()->create(); + + $user = User::withCount('latest_login')->first(); + $this->assertEquals(1, $user->latest_login_count); + } + + public function testExists() + { + $user = User::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 = User::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 = User::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 = User::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 = User::create(); + $user->logins()->create(); + $user->logins()->create(); + + $this->assertSame(1, $user->latest_login()->count()); + } + + public function testAggregate() + { + $user = User::create(); + $firstLogin = $user->logins()->create(); + $user->logins()->create(); + + $user = User::first(); + $this->assertSame($firstLogin->id, $user->first_login->id); + } + + public function testJoinConstraints() + { + $user = User::create(); + $user->states()->create([ + 'type' => 'foo', + 'state' => 'draft', + ]); + $currentForState = $user->states()->create([ + 'type' => 'foo', + 'state' => 'active', + ]); + $user->states()->create([ + 'type' => 'bar', + 'state' => 'baz', + ]); + + $user = User::first(); + $this->assertSame($currentForState->id, $user->foo_state->id); + } + + public function testMultipleAggregates() + { + $user = User::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 = User::first(); + $this->assertSame($price->id, $user->price->id); + } + + public function testEagerLoadingWithMultipleAggregates() + { + $user1 = User::create(); + $user2 = User::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 = User::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 = User::create(); + + $user = User::withExists('latest_login')->first(); + $this->assertFalse($user->latest_login_exists); + + $user->logins()->create(); + $user = User::withExists('latest_login')->first(); + $this->assertTrue($user->latest_login_exists); + } + + public function testWithExistsWithConstraintsInJoinSubSelect() + { + $user = User::create(); + + $user = User::withExists('foo_state')->first(); + + $this->assertFalse($user->foo_state_exists); + + $user->states()->create([ + 'type' => 'foo', + 'state' => 'bar', + ]); + $user = User::withExists('foo_state')->first(); + $this->assertTrue($user->foo_state_exists); + } + + public function testWithSoftDeletes() + { + $user = User::create(); + $user->logins()->create(); + $user->latest_login_with_soft_deletes; + $this->assertNotNull($user->latest_login_with_soft_deletes); + } + + public function testWithConstraintNotInAggregate() + { + $user = User::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 = User::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 User extends Eloquent +{ + protected ?string $table = 'users'; + + protected array $guarded = []; + + public bool $timestamps = false; + + public function logins() + { + return $this->hasMany(Login::class, 'user_id'); + } + + public function latest_login() + { + return $this->hasOne(Login::class, 'user_id')->ofMany(); + } + + public function latest_login_with_soft_deletes() + { + return $this->hasOne(LoginWithSoftDeletes::class, 'user_id')->ofMany(); + } + + public function latest_login_with_shortcut() + { + return $this->hasOne(Login::class, 'user_id')->latestOfMany(); + } + + public function latest_login_with_invalid_aggregate() + { + return $this->hasOne(Login::class, 'user_id')->ofMany('id', 'count'); + } + + public function latest_login_without_global_scope() + { + return $this->hasOne(Login::class, 'user_id')->withoutGlobalScopes()->latestOfMany(); + } + + public function first_login() + { + return $this->hasOne(Login::class, 'user_id')->ofMany('id', 'min'); + } + + public function latest_login_with_foo_state() + { + return $this->hasOne(Login::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(State::class, 'user_id'); + } + + public function foo_state() + { + return $this->hasOne(State::class, 'user_id')->ofMany( + [], // should automatically add 'id' => 'max' + function ($q) { + $q->where('type', 'foo'); + } + ); + } + + public function last_updated_foo_state() + { + return $this->hasOne(State::class, 'user_id')->ofMany([ + 'updated_at' => 'max', + 'id' => 'max', + ], function ($q) { + $q->where('type', 'foo'); + }); + } + + public function prices() + { + return $this->hasMany(Price::class, 'user_id'); + } + + public function price() + { + return $this->hasOne(Price::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(Price::class, 'user_id')->ofMany(['published_at' => 'MAX']); + } + + public function price_with_shortcut() + { + return $this->hasOne(Price::class, 'user_id')->latestOfMany(['published_at', 'id']); + } + + public function price_without_global_scope() + { + return $this->hasOne(Price::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(State::class, 'user_id')->ofMany([ + 'updated_at' => 'max', + 'created_at' => 'max', + ]); + } +} + +class TestModel extends Eloquent +{ + public function logins() + { + return $this->hasOne(Login::class)->ofMany(); + } +} + +class Login extends Eloquent +{ + protected ?string $table = 'logins'; + + protected array $guarded = []; + + public bool $timestamps = false; +} + +class LoginWithSoftDeletes extends Eloquent +{ + use SoftDeletes; + + protected ?string $table = 'logins'; + + protected array $guarded = []; + + public bool $timestamps = false; +} + +class State extends Eloquent +{ + protected ?string $table = 'states'; + + protected array $guarded = []; + + public bool $timestamps = true; + + protected array $fillable = ['type', 'state', 'updated_at']; +} + +class Price 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..472319373 --- /dev/null +++ b/tests/Database/Laravel/DatabaseEloquentHasOneOrManyWithAttributesPendingTest.php @@ -0,0 +1,315 @@ +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 array $guarded = []; + + protected array $casts = [ + 'is_admin' => 'boolean', + ]; +} diff --git a/tests/Database/Laravel/DatabaseEloquentHasOneOrManyWithAttributesTest.php b/tests/Database/Laravel/DatabaseEloquentHasOneOrManyWithAttributesTest.php new file mode 100755 index 000000000..d6cb023cc --- /dev/null +++ b/tests/Database/Laravel/DatabaseEloquentHasOneOrManyWithAttributesTest.php @@ -0,0 +1,304 @@ +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 array $guarded = []; + + protected array $casts = [ + 'is_admin' => 'boolean', + ]; +} diff --git a/tests/Database/Laravel/DatabaseEloquentHasOneTest.php b/tests/Database/Laravel/DatabaseEloquentHasOneTest.php new file mode 100755 index 000000000..2b8052f4d --- /dev/null +++ b/tests/Database/Laravel/DatabaseEloquentHasOneTest.php @@ -0,0 +1,341 @@ +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 ModelStub(); + $model1->id = 1; + $model2 = new ModelStub(); + $model2->id = 2; + $relation->addEagerConstraints([$model1, $model2]); + } + + public function testModelsAreProperlyMatchedToParents() + { + $relation = $this->getRelation(); + + $result1 = new ModelStub(); + $result1->foreign_key = 1; + $result2 = new ModelStub(); + $result2->foreign_key = 2; + $result3 = new ModelStub(); + $result3->foreign_key = new class { + public function __toString() + { + return '4'; + } + }; + + $model1 = new ModelStub(); + $model1->id = 1; + $model2 = new ModelStub(); + $model2->id = 2; + $model3 = new ModelStub(); + $model3->id = 3; + $model4 = new ModelStub(); + $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 ModelStub 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..1bc7f0659 --- /dev/null +++ b/tests/Database/Laravel/DatabaseEloquentHasOneThroughIntegrationTest.php @@ -0,0 +1,535 @@ +addConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + ]); + + $db->bootEloquent(); + $db->setAsGlobal(); + + $this->createSchema(); + } + + /** + * Setup the database schema. + */ + 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. + */ + protected function tearDown(): void + { + $this->schema()->drop('users'); + $this->schema()->drop('contracts'); + $this->schema()->drop('positions'); + + parent::tearDown(); + } + + public function testItLoadsAHasOneThroughRelationWithCustomKeys() + { + $this->seedData(); + $contract = Position::first()->contract; + + $this->assertSame('A title', $contract->title); + } + + public function testItLoadsADefaultHasOneThroughRelation() + { + $this->migrateDefault(); + $this->seedDefaultData(); + + $contract = DefaultPosition::first()->contract; + $this->assertSame('A title', $contract->title); + $this->assertArrayNotHasKey('email', $contract->getAttributes()); + + $this->resetDefault(); + } + + public function testItLoadsARelationWithCustomIntermediateAndLocalKey() + { + $this->seedData(); + $contract = IntermediatePosition::first()->contract; + + $this->assertSame('A title', $contract->title); + } + + public function testEagerLoadingARelationWithCustomIntermediateAndLocalKey() + { + $this->seedData(); + $contract = IntermediatePosition::with('contract')->first()->contract; + + $this->assertSame('A title', $contract->title); + } + + public function testWhereHasOnARelationWithCustomIntermediateAndLocalKey() + { + $this->seedData(); + $position = IntermediatePosition::whereHas('contract', function ($query) { + $query->where('title', 'A title'); + })->get(); + + $this->assertCount(1, $position); + } + + public function testWithWhereHasOnARelationWithCustomIntermediateAndLocalKey() + { + $this->seedData(); + $position = IntermediatePosition::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\DatabaseEloquentHasOneThroughIntegrationTest\Contract].'); + + Position::create(['id' => 1, 'name' => 'President', 'shortname' => 'ps']) + ->user()->create(['id' => 1, 'email' => 'taylorotwell@gmail.com', 'position_short' => 'ps']); + + Position::first()->contract()->firstOrFail(); + } + + public function testFindOrFailThrowsAnException() + { + $this->expectException(ModelNotFoundException::class); + + Position::create(['id' => 1, 'name' => 'President', 'shortname' => 'ps']) + ->user()->create(['id' => 1, 'email' => 'taylorotwell@gmail.com', 'position_short' => 'ps']); + + Position::first()->contract()->findOrFail(1); + } + + public function testFirstRetrievesFirstRecord() + { + $this->seedData(); + $contract = Position::first()->contract()->first(); + + $this->assertNotNull($contract); + $this->assertSame('A title', $contract->title); + } + + public function testAllColumnsAreRetrievedByDefault() + { + $this->seedData(); + $contract = Position::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 = Position::first()->contract()->first(['title', 'body']); + + $this->assertEquals([ + 'title', + 'body', + 'laravel_through_key', + ], array_keys($contract->getAttributes())); + } + + public function testChunkReturnsCorrectModels() + { + $this->seedData(); + $this->seedDataExtended(); + $position = Position::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 = Position::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 = Position::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 = Position::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(); + SoftDeletesUser::first()->delete(); + + $contract = SoftDeletesPosition::first()->contract; + + $this->assertSame('A title', $contract->title); + } + + public function testEagerLoadingLoadsRelatedModelsCorrectly() + { + $this->seedData(); + $position = SoftDeletesPosition::with('contract')->first(); + + $this->assertSame('ps', $position->shortname); + $this->assertSame('A title', $position->contract->title); + } + + /** + * Helpers... + */ + protected function seedData() + { + Position::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 = Position::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() + { + DefaultPosition::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('default_position_id')->unique()->nullable(); + $table->timestamps(); + }); + + $this->schema()->create('contracts_default', function ($table) { + $table->increments('id'); + $table->integer('default_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 User extends Eloquent +{ + protected ?string $table = 'users'; + + protected array $guarded = []; + + public function contract() + { + return $this->hasOne(Contract::class, 'user_id'); + } +} + +/** + * Eloquent Models... + */ +class Contract extends Eloquent +{ + protected ?string $table = 'contracts'; + + protected array $guarded = []; + + public function owner() + { + return $this->belongsTo(User::class, 'user_id'); + } +} + +class Position extends Eloquent +{ + protected ?string $table = 'positions'; + + protected array $guarded = []; + + public function contract() + { + return $this->hasOneThrough(Contract::class, User::class, 'position_id', 'user_id'); + } + + public function user() + { + return $this->hasOne(User::class, 'position_id'); + } +} + +/** + * Eloquent Models... + */ +class DefaultUser extends Eloquent +{ + protected ?string $table = 'users_default'; + + protected array $guarded = []; + + public function contract() + { + return $this->hasOne(DefaultContract::class); + } +} + +/** + * Eloquent Models... + */ +class DefaultContract extends Eloquent +{ + protected ?string $table = 'contracts_default'; + + protected array $guarded = []; + + public function owner() + { + return $this->belongsTo(DefaultUser::class); + } +} + +class DefaultPosition extends Eloquent +{ + protected ?string $table = 'positions_default'; + + protected array $guarded = []; + + public function contract() + { + return $this->hasOneThrough(DefaultContract::class, DefaultUser::class); + } + + public function user() + { + return $this->hasOne(DefaultUser::class); + } +} + +class IntermediatePosition extends Eloquent +{ + protected ?string $table = 'positions'; + + protected array $guarded = []; + + public function contract() + { + return $this->hasOneThrough(Contract::class, User::class, 'position_short', 'email', 'shortname', 'email'); + } + + public function user() + { + return $this->hasOne(User::class, 'position_id'); + } +} + +class SoftDeletesUser extends Eloquent +{ + use SoftDeletes; + + protected ?string $table = 'users'; + + protected array $guarded = []; + + public function contract() + { + return $this->hasOne(SoftDeletesContract::class, 'user_id'); + } +} + +/** + * Eloquent Models... + */ +class SoftDeletesContract extends Eloquent +{ + protected ?string $table = 'contracts'; + + protected array $guarded = []; + + public function owner() + { + return $this->belongsTo(SoftDeletesUser::class, 'user_id'); + } +} + +class SoftDeletesPosition extends Eloquent +{ + protected ?string $table = 'positions'; + + protected array $guarded = []; + + public function contract() + { + return $this->hasOneThrough(SoftDeletesContract::class, User::class, 'position_id', 'user_id'); + } + + public function user() + { + return $this->hasOne(SoftDeletesUser::class, 'position_id'); + } +} diff --git a/tests/Database/Laravel/DatabaseEloquentHasOneThroughOfManyTest.php b/tests/Database/Laravel/DatabaseEloquentHasOneThroughOfManyTest.php new file mode 100755 index 000000000..293b0318d --- /dev/null +++ b/tests/Database/Laravel/DatabaseEloquentHasOneThroughOfManyTest.php @@ -0,0 +1,785 @@ +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 = User::make(); + $this->assertSame('latest_login', $user->latest_login()->getRelationName()); + } + + public function testItGuessesRelationNameAndAddsOfManyWhenTableNameIsRelationName(): void + { + $model = TestModel::make(); + $this->assertSame('logins_of_many', $model->logins()->getRelationName()); + } + + public function testRelationNameCanBeSet(): void + { + $user = User::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 = User::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 = User::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 = User::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 + { + Login::addGlobalScope('test', function ($query) { + $query->orderBy($query->qualifyColumn('id')); + }); + + $user = User::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()); + + Login::addGlobalScope('test', function ($query) { + }); + } + + public function testGlobalScopeIsNotAppliedWhenRelationIsDefinedWithoutGlobalScopeWithComplexQuery(): void + { + Price::addGlobalScope('test', function ($query) { + $query->orderBy($query->qualifyColumn('id')); + }); + + $user = User::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()); + + Price::addGlobalScope('test', function ($query) { + }); + } + + public function testQualifyingSubSelectColumn(): void + { + $user = User::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 = User::make(); + $user->latest_login_with_invalid_aggregate(); + } + + public function testItGetsCorrectResults(): void + { + $user = User::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 = User::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 = User::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 = User::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 = User::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 = User::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 = User::factory()->hasIntermediates(2)->create(); + $user->intermediates->last()->logins()->create(); + $latestLogin = $user->intermediates->first()->logins()->create(); + + $user = User::with('latest_login')->first(); + + $this->assertTrue($user->relationLoaded('latest_login')); + $this->assertSame($latestLogin->id, $user->latest_login->id); + } + + public function testItJoinsOtherTableInSubQuery(): void + { + $user = User::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 = User::factory()->hasIntermediates(2)->create(); + $previousLogin = $user->intermediates->first()->logins()->create(); + $latestLogin = $user->intermediates->last()->logins()->create(); + + $found = User::whereHas('latest_login', function ($query) use ($latestLogin) { + $query->where('logins.id', $latestLogin->id); + })->exists(); + $this->assertTrue($found); + + $found = User::whereHas('latest_login', function ($query) use ($previousLogin) { + $query->where('logins.id', $previousLogin->id); + })->exists(); + $this->assertFalse($found); + } + + public function testWithHasNested(): void + { + $user = User::factory()->hasIntermediates(2)->create(); + $previousLogin = $user->intermediates->first()->logins()->create(); + $latestLogin = $user->intermediates->last()->logins()->create(); + + $found = User::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 = User::withWhereHas('latest_login', function ($query) use ($previousLogin) { + $query->where('logins.id', $previousLogin->id); + })->exists(); + + $this->assertFalse($found); + } + + public function testHasCount(): void + { + $user = User::factory()->hasIntermediates(2)->create(); + $user->intermediates->last()->logins()->create(); + $user->intermediates->first()->logins()->create(); + + $user = User::withCount('latest_login')->first(); + $this->assertEquals(1, $user->latest_login_count); + } + + public function testExists(): void + { + $user = User::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 = User::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 = User::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 = User::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 = User::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 = User::factory()->hasIntermediates(2)->create(); + $firstLogin = $user->intermediates->first()->logins()->create(); + $user->intermediates->last()->logins()->create(); + + $user = User::first(); + $this->assertSame($firstLogin->id, $user->first_login->id); + } + + public function testJoinConstraints(): void + { + $user = User::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 = User::first(); + $this->assertSame($currentForState->id, $user->foo_state->id); + } + + public function testMultipleAggregates(): void + { + $user = User::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 = User::first(); + $this->assertSame($price->id, $user->price->id); + } + + public function testEagerLoadingWithMultipleAggregates(): void + { + $user1 = User::factory()->hasIntermediates(2)->create(); + $user2 = User::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 = User::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 = User::factory()->hasIntermediates(1)->create(); + + $user = User::withExists('latest_login')->first(); + $this->assertFalse($user->latest_login_exists); + + $user->intermediates->first()->logins()->create(); + $user = User::withExists('latest_login')->first(); + $this->assertTrue($user->latest_login_exists); + } + + public function testWithExistsWithConstraintsInJoinSubSelect(): void + { + $user = User::factory()->hasIntermediates(1)->create(); + $user = User::withExists('foo_state')->first(); + + $this->assertFalse($user->foo_state_exists); + + $user->intermediates->first()->states()->create([ + 'type' => 'foo', + 'state' => 'bar', + ]); + $user = User::withExists('foo_state')->first(); + $this->assertTrue($user->foo_state_exists); + } + + public function testWithSoftDeletes(): void + { + $user = User::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 = User::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 = User::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 User extends Eloquent +{ + use HasFactory; + + protected ?string $table = 'users'; + + protected array $guarded = []; + + public bool $timestamps = false; + + protected static string $factory = UserFactory::class; + + public function intermediates(): HasMany + { + return $this->hasMany(Intermediate::class, 'user_id'); + } + + public function logins(): HasManyThrough + { + return $this->through('intermediates')->has('logins'); + } + + public function latest_login(): HasOneThrough + { + return $this->hasOneThrough( + Login::class, + Intermediate::class, + 'user_id', + 'intermediate_id' + )->ofMany(); + } + + public function latest_login_with_soft_deletes(): HasOneThrough + { + return $this->hasOneThrough( + LoginWithSoftDeletes::class, + Intermediate::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 Intermediate extends Eloquent +{ + use HasFactory; + + protected ?string $table = 'intermediates'; + + protected array $guarded = []; + + public bool $timestamps = false; + + protected static string $factory = IntermediateFactory::class; + + public function logins(): HasMany + { + return $this->hasMany(Login::class, 'intermediate_id'); + } + + public function states(): HasMany + { + return $this->hasMany(State::class, 'intermediate_id'); + } + + public function prices(): HasMany + { + return $this->hasMany(Price::class, 'intermediate_id'); + } +} + +class TestModel extends Eloquent +{ + public function logins(): HasOneThrough + { + return $this->hasOneThrough( + Login::class, + Intermediate::class, + 'user_id', + 'intermediate_id', + )->ofMany(); + } +} + +class Login extends Eloquent +{ + protected ?string $table = 'logins'; + + protected array $guarded = []; + + public bool $timestamps = false; +} + +class LoginWithSoftDeletes extends Eloquent +{ + use SoftDeletes; + + protected ?string $table = 'logins'; + + protected array $guarded = []; + + public bool $timestamps = false; +} + +class State extends Eloquent +{ + protected ?string $table = 'states'; + + protected array $guarded = []; + + public bool $timestamps = true; + + protected array $fillable = ['type', 'state', 'updated_at']; +} + +class Price 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 UserFactory extends Factory +{ + protected ?string $model = User::class; + + public function definition(): array + { + return []; + } +} + +class IntermediateFactory extends Factory +{ + protected ?string $model = Intermediate::class; + + public function definition(): array + { + return ['user_id' => User::factory()]; + } +} diff --git a/tests/Database/Laravel/DatabaseEloquentIntegrationTest.php b/tests/Database/Laravel/DatabaseEloquentIntegrationTest.php new file mode 100644 index 000000000..f963fde0c --- /dev/null +++ b/tests/Database/Laravel/DatabaseEloquentIntegrationTest.php @@ -0,0 +1,3140 @@ +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('achievement_user', function ($table) { + $table->integer('achievement_id'); + $table->integer('user_id'); + }); + } + + $this->schema($connection)->create('non_incrementing_users', function ($table) { + $table->string('name')->nullable(); + }); + } + + /** + * Tear down the database schema. + */ + 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() + { + User::insert([['id' => 1, 'email' => 'taylorotwell@gmail.com'], ['id' => 2, 'email' => 'abigailotwell@gmail.com']]); + + $this->assertEquals(2, User::count()); + + $this->assertFalse(User::where('email', 'taylorotwell@gmail.com')->doesntExist()); + $this->assertTrue(User::where('email', 'mohamed@laravel.com')->doesntExist()); + + $model = User::where('email', 'taylorotwell@gmail.com')->first(); + $this->assertSame('taylorotwell@gmail.com', $model->email); + $this->assertTrue(isset($model->email)); + $this->assertTrue(isset($model->friends)); + + $model = User::find(1); + $this->assertInstanceOf(User::class, $model); + $this->assertEquals(1, $model->id); + + $model = User::find(2); + $this->assertInstanceOf(User::class, $model); + $this->assertEquals(2, $model->id); + + $missing = User::find(3); + $this->assertNull($missing); + + $collection = User::find([]); + $this->assertInstanceOf(Collection::class, $collection); + $this->assertCount(0, $collection); + + $collection = User::find([1, 2, 3]); + $this->assertInstanceOf(Collection::class, $collection); + $this->assertCount(2, $collection); + + $models = User::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() + { + User::insert([['id' => 1, 'email' => 'taylorotwell@gmail.com'], ['id' => 2, 'email' => 'abigailotwell@gmail.com']]); + + $models = User::oldest('id')->get(); + + $this->assertCount(2, $models); + $this->assertInstanceOf(Collection::class, $models); + $this->assertInstanceOf(User::class, $models[0]); + $this->assertInstanceOf(User::class, $models[1]); + $this->assertSame('taylorotwell@gmail.com', $models[0]->email); + $this->assertSame('abigailotwell@gmail.com', $models[1]->email); + } + + public function testPaginatedModelCollectionRetrieval() + { + User::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 = User::oldest('id')->paginate(2); + + $this->assertCount(2, $models); + $this->assertInstanceOf(LengthAwarePaginator::class, $models); + $this->assertInstanceOf(User::class, $models[0]); + $this->assertInstanceOf(User::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 = User::oldest('id')->paginate(2); + + $this->assertCount(1, $models); + $this->assertInstanceOf(LengthAwarePaginator::class, $models); + $this->assertInstanceOf(User::class, $models[0]); + $this->assertSame('foo@gmail.com', $models[0]->email); + } + + public function testPaginatedModelCollectionRetrievalUsingCallablePerPage() + { + User::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 = User::oldest('id')->paginate(function ($total) { + return $total <= 3 ? 3 : 2; + }); + + $this->assertCount(3, $models); + $this->assertInstanceOf(LengthAwarePaginator::class, $models); + $this->assertInstanceOf(User::class, $models[0]); + $this->assertInstanceOf(User::class, $models[1]); + $this->assertInstanceOf(User::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 = User::oldest('id')->paginate(function ($total) { + return $total <= 3 ? 3 : 2; + }); + + $this->assertCount(0, $models); + $this->assertInstanceOf(LengthAwarePaginator::class, $models); + + User::create(['id' => 4, 'email' => 'bar@gmail.com']); + + Paginator::currentPageResolver(function () { + return 1; + }); + $models = User::oldest('id')->paginate(function ($total) { + return $total <= 3 ? 3 : 2; + }); + + $this->assertCount(2, $models); + $this->assertInstanceOf(LengthAwarePaginator::class, $models); + $this->assertInstanceOf(User::class, $models[0]); + $this->assertInstanceOf(User::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 = User::oldest('id')->paginate(function ($total) { + return $total <= 3 ? 3 : 2; + }); + + $this->assertCount(2, $models); + $this->assertInstanceOf(LengthAwarePaginator::class, $models); + $this->assertInstanceOf(User::class, $models[0]); + $this->assertInstanceOf(User::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 = User::oldest('id')->paginate(2); + + $this->assertCount(0, $models); + $this->assertInstanceOf(LengthAwarePaginator::class, $models); + + Paginator::currentPageResolver(function () { + return 2; + }); + $models = User::oldest('id')->paginate(2); + + $this->assertCount(0, $models); + } + + public function testPaginatedModelCollectionRetrievalWhenNoElementsAndDefaultPerPage() + { + $models = User::oldest('id')->paginate(); + + $this->assertCount(0, $models); + $this->assertInstanceOf(LengthAwarePaginator::class, $models); + } + + public function testCountForPaginationWithGrouping() + { + User::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 = User::groupBy('email')->getQuery(); + + $this->assertEquals(3, $query->getCountForPagination()); + } + + public function testCountForPaginationWithGroupingAndSubSelects() + { + User::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 = User::find(1); + + $user1->friends()->create(['id' => 5, 'email' => 'friend@gmail.com']); + + $query = User::select([ + 'id', + 'friends_count' => User::whereColumn('friend_id', 'user_id')->count(), + ])->groupBy('email')->getQuery(); + + $this->assertEquals(4, $query->getCountForPagination()); + } + + public function testCursorPaginatedModelCollectionRetrieval() + { + User::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 = User::oldest('id')->cursorPaginate(2); + + $this->assertCount(2, $models); + $this->assertInstanceOf(CursorPaginator::class, $models); + $this->assertInstanceOf(User::class, $models[0]); + $this->assertInstanceOf(User::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 = User::oldest('id')->cursorPaginate(2); + + $this->assertCount(1, $models); + $this->assertInstanceOf(CursorPaginator::class, $models); + $this->assertInstanceOf(User::class, $models[0]); + $this->assertSame('foo@gmail.com', $models[0]->email); + $this->assertFalse($models->hasMorePages()); + $this->assertTrue($models->hasPages()); + } + + public function testPreviousCursorPaginatedModelCollectionRetrieval() + { + User::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 = User::oldest('id')->cursorPaginate(2); + + $this->assertCount(2, $models); + $this->assertInstanceOf(CursorPaginator::class, $models); + $this->assertInstanceOf(User::class, $models[0]); + $this->assertInstanceOf(User::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 = User::oldest('id')->cursorPaginate(2); + + $this->assertCount(0, $models); + $this->assertInstanceOf(CursorPaginator::class, $models); + + Paginator::currentPageResolver(function () { + return new Cursor(['id' => 1]); + }); + $models = User::oldest('id')->cursorPaginate(2); + + $this->assertCount(0, $models); + } + + public function testCursorPaginatedModelCollectionRetrievalWhenNoElementsAndDefaultPerPage() + { + $models = User::oldest('id')->cursorPaginate(); + + $this->assertCount(0, $models); + $this->assertInstanceOf(CursorPaginator::class, $models); + } + + public function testFirstOrNew() + { + $user1 = User::firstOrNew( + ['name' => 'Dries Vints'], + ['name' => 'Nuno Maduro'] + ); + + $this->assertSame('Nuno Maduro', $user1->name); + } + + public function testFirstOrCreate() + { + $user1 = User::firstOrCreate(['email' => 'taylorotwell@gmail.com']); + + $this->assertSame('taylorotwell@gmail.com', $user1->email); + $this->assertNull($user1->name); + + $user2 = User::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 = User::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 = User::firstOrCreate( + ['name' => 'Dries Vints'], + ['name' => 'Nuno Maduro', 'email' => 'nuno@laravel.com'] + ); + + $this->assertSame('Nuno Maduro', $user4->name); + } + + public function testCreateOrFirst() + { + $user1 = UniqueUser::createOrFirst(['email' => 'taylorotwell@gmail.com']); + + $this->assertSame('taylorotwell@gmail.com', $user1->email); + $this->assertNull($user1->name); + + $user2 = UniqueUser::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 = UniqueUser::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 = UniqueUser::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. + UniqueUser::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. + UniqueUser::createOrFirst( + ['email' => 'taylorotwell+bar@gmail.com'], + [ + 'screen_name' => '@taylorotwell', + ] + ); + } + + public function testCreateOrFirstWithinTransaction() + { + $user1 = UniqueUser::create(['email' => 'taylorotwell@gmail.com']); + + DB::transaction(function () use ($user1) { + $user2 = UniqueUser::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 = User::create(['email' => 'taylorotwell@gmail.com']); + + $user2 = User::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 = User::updateOrCreate( + ['email' => 'themsaid@gmail.com'], + ['name' => 'Mohamed Said'] + ); + + $this->assertSame('Mohamed Said', $user3->name); + $this->assertEquals(2, User::count()); + } + + public function testUpdateOrCreateOnDifferentConnection() + { + User::create(['email' => 'taylorotwell@gmail.com']); + + User::on('second_connection')->updateOrCreate( + ['email' => 'taylorotwell@gmail.com'], + ['name' => 'Taylor Otwell'] + ); + + User::on('second_connection')->updateOrCreate( + ['email' => 'themsaid@gmail.com'], + ['name' => 'Mohamed Said'] + ); + + $this->assertEquals(1, User::count()); + $this->assertEquals(2, User::on('second_connection')->count()); + } + + public function testCheckAndCreateMethodsOnMultiConnections() + { + User::create(['id' => 1, 'email' => 'taylorotwell@gmail.com']); + User::on('second_connection')->find( + User::on('second_connection')->insert(['id' => 2, 'email' => 'themsaid@gmail.com']) + ); + + $user1 = User::on('second_connection')->findOrNew(1); + $user2 = User::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 = User::on('second_connection')->firstOrNew(['email' => 'taylorotwell@gmail.com']); + $user2 = User::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, User::on('second_connection')->count()); + $user1 = User::on('second_connection')->firstOrCreate(['email' => 'taylorotwell@gmail.com']); + $user2 = User::on('second_connection')->firstOrCreate(['email' => 'themsaid@gmail.com']); + $this->assertSame('second_connection', $user1->getConnectionName()); + $this->assertSame('second_connection', $user2->getConnectionName()); + $this->assertEquals(2, User::on('second_connection')->count()); + } + + public function testCreatingModelWithEmptyAttributes() + { + $model = NonIncrementing::create([]); + + $this->assertFalse($model->exists); + $this->assertFalse($model->wasRecentlyCreated); + } + + public function testChunk() + { + User::insert([ + ['name' => 'First', 'email' => 'first@example.com'], + ['name' => 'Second', 'email' => 'second@example.com'], + ['name' => 'Third', 'email' => 'third@example.com'], + ]); + + $chunks = 0; + + User::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() + { + User::insert([ + ['name' => 'First', 'email' => 'first@example.com'], + ['name' => 'Second', 'email' => 'second@example.com'], + ['name' => 'Third', 'email' => 'third@example.com'], + ]); + + $chunks = 0; + + User::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() + { + User::insert([ + ['name' => 'First', 'email' => 'first@example.com'], + ['name' => 'Second', 'email' => 'second@example.com'], + ['name' => 'Third', 'email' => 'third@example.com'], + ]); + + $chunks = 0; + + User::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() + { + User::insert([ + ['name' => 'First', 'email' => 'first@example.com'], + ['name' => 'Second', 'email' => 'second@example.com'], + ['name' => 'Third', 'email' => 'third@example.com'], + ]); + + $chunks = 0; + + User::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() + { + User::insert([ + ['name' => 'First', 'email' => 'first@example.com'], + ['name' => 'Second', 'email' => 'second@example.com'], + ['name' => 'Third', 'email' => 'third@example.com'], + ]); + + $chunks = 0; + + User::query()->orderBy('id', 'asc')->offset(3)->chunk(2, function () use (&$chunks) { + ++$chunks; + }); + + $this->assertEquals(0, $chunks); + } + + public function testChunksWithLimitsAndOffsets() + { + User::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; + + User::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() + { + User::insert([ + ['name' => 'First', 'email' => 'first@example.com'], + ['name' => 'Second', 'email' => 'second@example.com'], + ['name' => 'Third', 'email' => 'third@example.com'], + ]); + + $chunks = 0; + + User::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() + { + User::insert([ + ['name' => 'First', 'email' => 'first@example.com'], + ['name' => 'Second', 'email' => 'second@example.com'], + ['name' => 'Third', 'email' => 'third@example.com'], + ]); + + $chunks = 0; + + User::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() + { + User::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; + + User::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() + { + NonIncrementingSecond::insert([ + ['name' => ' First'], + ['name' => ' Second'], + ['name' => ' Third'], + ]); + + $i = 0; + NonIncrementingSecond::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() + { + NonIncrementingSecond::insert([ + ['name' => ' First'], + ['name' => ' Second'], + ['name' => ' Third'], + ]); + + $users = []; + NonIncrementingSecond::query()->eachById( + function (NonIncrementingSecond $user, $i) use (&$users) { + $users[] = [$user->name, $i]; + }, + 2, + 'name' + ); + $this->assertSame([[' First', 0], [' Second', 1], [' Third', 2]], $users); + } + + public function testPluck() + { + User::insert([ + ['id' => 1, 'email' => 'taylorotwell@gmail.com'], + ['id' => 2, 'email' => 'abigailotwell@gmail.com'], + ]); + + $simple = User::oldest('id')->pluck('users.email')->all(); + $keyed = User::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 = User::create(['id' => 1, 'email' => 'taylorotwell@gmail.com']); + $user2 = User::create(['id' => 2, 'email' => 'abigailotwell@gmail.com']); + + $user2->posts()->create(['id' => 1, 'name' => 'First post']); + $user1->posts()->create(['id' => 2, 'name' => 'Second post']); + + $query = User::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() + { + UserWithSpaceInColumnName::insert([ + ['id' => 1, 'email address' => 'taylorotwell@gmail.com'], + ['id' => 2, 'email address' => 'abigailotwell@gmail.com'], + ]); + + $simple = UserWithSpaceInColumnName::oldest('id')->pluck('users_with_space_in_column_name.email address')->all(); + $keyed = UserWithSpaceInColumnName::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() + { + User::insert([ + ['id' => 1, 'email' => 'taylorotwell@gmail.com'], + ['id' => 2, 'email' => 'abigailotwell@gmail.com'], + ]); + + $single = User::findOrFail(1); + $multiple = User::findOrFail([1, 2]); + + $this->assertInstanceOf(User::class, $single); + $this->assertSame('taylorotwell@gmail.com', $single->email); + $this->assertInstanceOf(Collection::class, $multiple); + $this->assertInstanceOf(User::class, $multiple[0]); + $this->assertInstanceOf(User::class, $multiple[1]); + } + + public function testFindOrFailWithSingleIdThrowsModelNotFoundException() + { + $this->expectException(ModelNotFoundException::class); + $this->expectExceptionMessage('No query results for model [Hypervel\Tests\Database\Laravel\DatabaseEloquentIntegrationTest\User] 1'); + $this->expectExceptionObject( + (new ModelNotFoundException())->setModel(User::class, [1]), + ); + + User::findOrFail(1); + } + + public function testFindOrFailWithMultipleIdsThrowsModelNotFoundException() + { + $this->expectException(ModelNotFoundException::class); + $this->expectExceptionMessage('No query results for model [Hypervel\Tests\Database\Laravel\DatabaseEloquentIntegrationTest\User] 2, 3'); + $this->expectExceptionObject( + (new ModelNotFoundException())->setModel(User::class, [2, 3]), + ); + + User::create(['id' => 1, 'email' => 'taylorotwell@gmail.com']); + User::findOrFail([1, 2, 3]); + } + + public function testFindOrFailWithMultipleIdsUsingCollectionThrowsModelNotFoundException() + { + $this->expectException(ModelNotFoundException::class); + $this->expectExceptionMessage('No query results for model [Hypervel\Tests\Database\Laravel\DatabaseEloquentIntegrationTest\User] 2, 3'); + $this->expectExceptionObject( + (new ModelNotFoundException())->setModel(User::class, [2, 3]), + ); + + User::create(['id' => 1, 'email' => 'taylorotwell@gmail.com']); + User::findOrFail(new Collection([1, 1, 2, 3])); + } + + public function testOneToOneRelationship() + { + $user = User::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(User::class, $user); + $this->assertInstanceOf(Post::class, $post); + $this->assertSame('taylorotwell@gmail.com', $user->email); + $this->assertSame('First Post', $post->name); + } + + public function testIssetLoadsInRelationshipIfItIsntLoadedAlready() + { + $user = User::create(['email' => 'taylorotwell@gmail.com']); + $user->post()->create(['name' => 'First Post']); + + $this->assertTrue(isset($user->post->name)); + } + + public function testOneToManyRelationship() + { + $user = User::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(Post::class, $posts[0]); + $this->assertInstanceOf(Post::class, $posts[1]); + $this->assertInstanceOf(Post::class, $post2); + $this->assertSame('Second Post', $post2->name); + $this->assertInstanceOf(User::class, $post2->user); + $this->assertSame('taylorotwell@gmail.com', $post2->user->email); + } + + public function testBasicModelHydration() + { + $user = new User(['email' => 'taylorotwell@gmail.com']); + $user->setConnection('second_connection'); + $user->save(); + + $user = new User(['email' => 'abigailotwell@gmail.com']); + $user->setConnection('second_connection'); + $user->save(); + + $models = User::on('second_connection')->fromQuery('SELECT * FROM users WHERE email = ?', ['abigailotwell@gmail.com']); + + $this->assertInstanceOf(Collection::class, $models); + $this->assertInstanceOf(User::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 = User::create(['email' => 'taylorotwell@gmail.com']); + $post1 = $user1->post()->firstOrNew(['name' => 'First Post'], ['name' => 'New Post']); + + $this->assertSame('New Post', $post1->name); + + $user2 = User::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 = User::create(['email' => 'taylorotwell@gmail.com']); + $post1 = $user1->post()->firstOrCreate(['name' => 'First Post'], ['name' => 'New Post']); + + $this->assertSame('New Post', $post1->name); + + $user2 = User::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 = User::create(['email' => 'taylorotwell@gmail.com']); + $user->friends()->create(['email' => 'abigailotwell@gmail.com']); + + $this->assertTrue(isset($user->friends[0]->id)); + + $results = User::has('friends')->get(); + + $this->assertCount(1, $results); + $this->assertSame('taylorotwell@gmail.com', $results->first()->email); + } + + public function testWhereHasOnSelfReferencingBelongsToManyRelationship() + { + $user = User::create(['email' => 'taylorotwell@gmail.com']); + $user->friends()->create(['email' => 'abigailotwell@gmail.com']); + + $results = User::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 = User::create(['email' => 'taylorotwell@gmail.com']); + $user->friends()->create(['email' => 'abigailotwell@gmail.com']); + + $results = User::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 = User::create(['email' => 'taylorotwell@gmail.com']); + $friend = $user->friends()->create(['email' => 'abigailotwell@gmail.com']); + $friend->friends()->create(['email' => 'foo@gmail.com']); + + $results = User::has('friends.friends')->get(); + + $this->assertCount(1, $results); + $this->assertSame('taylorotwell@gmail.com', $results->first()->email); + } + + public function testWhereHasOnNestedSelfReferencingBelongsToManyRelationship() + { + $user = User::create(['email' => 'taylorotwell@gmail.com']); + $friend = $user->friends()->create(['email' => 'abigailotwell@gmail.com']); + $friend->friends()->create(['email' => 'foo@gmail.com']); + + $results = User::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 = User::create(['email' => 'taylorotwell@gmail.com']); + $friend = $user->friends()->create(['email' => 'abigailotwell@gmail.com']); + $friend->friends()->create(['email' => 'foo@gmail.com']); + + $results = User::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 = User::create(['email' => 'taylorotwell@gmail.com']); + $user->friends()->create(['email' => 'abigailotwell@gmail.com']); + + $results = User::has('friendsOne')->get(); + + $this->assertCount(1, $results); + $this->assertSame('taylorotwell@gmail.com', $results->first()->email); + } + + public function testHasOnNestedSelfReferencingBelongsToManyRelationshipWithWherePivot() + { + $user = User::create(['email' => 'taylorotwell@gmail.com']); + $friend = $user->friends()->create(['email' => 'abigailotwell@gmail.com']); + $friend->friends()->create(['email' => 'foo@gmail.com']); + + $results = User::has('friendsOne.friendsTwo')->get(); + + $this->assertCount(1, $results); + $this->assertSame('taylorotwell@gmail.com', $results->first()->email); + } + + public function testHasOnSelfReferencingBelongsToRelationship() + { + $parentPost = Post::create(['name' => 'Parent Post', 'user_id' => 1]); + Post::create(['name' => 'Child Post', 'parent_id' => $parentPost->id, 'user_id' => 2]); + + $results = Post::has('parentPost')->get(); + + $this->assertCount(1, $results); + $this->assertSame('Child Post', $results->first()->name); + } + + public function testAggregatedValuesOfDatetimeField() + { + User::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', User::max('created_at')); + $this->assertSame('2016-08-01 12:00:00', User::min('created_at')); + } + + public function testWhereHasOnSelfReferencingBelongsToRelationship() + { + $parentPost = Post::create(['name' => 'Parent Post', 'user_id' => 1]); + Post::create(['name' => 'Child Post', 'parent_id' => $parentPost->id, 'user_id' => 2]); + + $results = Post::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 = Post::create(['name' => 'Parent Post', 'user_id' => 1]); + Post::create(['name' => 'Child Post', 'parent_id' => $parentPost->id, 'user_id' => 2]); + + $results = Post::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 = Post::create(['name' => 'Grandparent Post', 'user_id' => 1]); + $parentPost = Post::create(['name' => 'Parent Post', 'parent_id' => $grandParentPost->id, 'user_id' => 2]); + Post::create(['name' => 'Child Post', 'parent_id' => $parentPost->id, 'user_id' => 3]); + + $results = Post::has('parentPost.parentPost')->get(); + + $this->assertCount(1, $results); + $this->assertSame('Child Post', $results->first()->name); + } + + public function testWhereHasOnNestedSelfReferencingBelongsToRelationship() + { + $grandParentPost = Post::create(['name' => 'Grandparent Post', 'user_id' => 1]); + $parentPost = Post::create(['name' => 'Parent Post', 'parent_id' => $grandParentPost->id, 'user_id' => 2]); + Post::create(['name' => 'Child Post', 'parent_id' => $parentPost->id, 'user_id' => 3]); + + $results = Post::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 = Post::create(['name' => 'Grandparent Post', 'user_id' => 1]); + $parentPost = Post::create(['name' => 'Parent Post', 'parent_id' => $grandParentPost->id, 'user_id' => 2]); + Post::create(['name' => 'Child Post', 'parent_id' => $parentPost->id, 'user_id' => 3]); + + $results = Post::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 = Post::create(['name' => 'Parent Post', 'user_id' => 1]); + Post::create(['name' => 'Child Post', 'parent_id' => $parentPost->id, 'user_id' => 2]); + + $results = Post::has('childPosts')->get(); + + $this->assertCount(1, $results); + $this->assertSame('Parent Post', $results->first()->name); + } + + public function testWhereHasOnSelfReferencingHasManyRelationship() + { + $parentPost = Post::create(['name' => 'Parent Post', 'user_id' => 1]); + Post::create(['name' => 'Child Post', 'parent_id' => $parentPost->id, 'user_id' => 2]); + + $results = Post::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 = Post::create(['name' => 'Parent Post', 'user_id' => 1]); + Post::create(['name' => 'Child Post', 'parent_id' => $parentPost->id, 'user_id' => 2]); + + $results = Post::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 = Post::create(['name' => 'Grandparent Post', 'user_id' => 1]); + $parentPost = Post::create(['name' => 'Parent Post', 'parent_id' => $grandParentPost->id, 'user_id' => 2]); + Post::create(['name' => 'Child Post', 'parent_id' => $parentPost->id, 'user_id' => 3]); + + $results = Post::has('childPosts.childPosts')->get(); + + $this->assertCount(1, $results); + $this->assertSame('Grandparent Post', $results->first()->name); + } + + public function testWhereHasOnNestedSelfReferencingHasManyRelationship() + { + $grandParentPost = Post::create(['name' => 'Grandparent Post', 'user_id' => 1]); + $parentPost = Post::create(['name' => 'Parent Post', 'parent_id' => $grandParentPost->id, 'user_id' => 2]); + Post::create(['name' => 'Child Post', 'parent_id' => $parentPost->id, 'user_id' => 3]); + + $results = Post::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 = Post::create(['name' => 'Grandparent Post', 'user_id' => 1]); + $parentPost = Post::create(['name' => 'Parent Post', 'parent_id' => $grandParentPost->id, 'user_id' => 2]); + Post::create(['name' => 'Child Post', 'parent_id' => $parentPost->id, 'user_id' => 3]); + + $results = Post::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 = User::create(['id' => 1, 'email' => 'taylorotwell@gmail.com']); + + $user->posts()->create(['name' => 'Post 2']) + ->photos()->create(['name' => 'photo.jpg']); + + $query = User::has('postWithPhotos'); + + $bindingsCount = count($query->getBindings()); + $questionMarksCount = substr_count($query->toSql(), '?'); + + $this->assertEquals($questionMarksCount, $bindingsCount); + } + + public function testHasOnMorphToRelationship() + { + $post = Post::create(['name' => 'Morph Post', 'user_id' => 1]); + (new Photo())->imageable()->associate($post)->fill(['name' => 'Morph Photo'])->save(); + + $photos = Photo::has('imageable')->get(); + + $this->assertEquals(1, $photos->count()); + } + + public function testBelongsToManyRelationshipModelsAreProperlyHydratedWithSoleQuery() + { + $user = UserWithCustomFriendPivot::create(['email' => 'taylorotwell@gmail.com']); + $user->friends()->create(['email' => 'abigailotwell@gmail.com']); + + $user->friends()->get()->each(function ($friend) { + $this->assertInstanceOf(FriendPivot::class, $friend->pivot); + }); + + $soleFriend = $user->friends()->where('email', 'abigailotwell@gmail.com')->sole(); + + $this->assertInstanceOf(FriendPivot::class, $soleFriend->pivot); + } + + public function testBelongsToManyRelationshipMissingModelExceptionWithSoleQueryWorks() + { + $this->expectException(ModelNotFoundException::class); + $user = UserWithCustomFriendPivot::create(['email' => 'taylorotwell@gmail.com']); + $user->friends()->where('email', 'abigailotwell@gmail.com')->sole(); + } + + public function testBelongsToManyRelationshipModelsAreProperlyHydratedOverChunkedRequest() + { + $user = User::create(['email' => 'taylorotwell@gmail.com']); + $friend = $user->friends()->create(['email' => 'abigailotwell@gmail.com']); + + User::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 = User::create(['email' => 'taylorotwell@gmail.com']); + $friend = $user->friends()->create(['email' => 'abigailotwell@gmail.com']); + + User::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 = User::create(['email' => 'taylorotwell@gmail.com']); + $friend = $user->friends()->create(['email' => 'abigailotwell@gmail.com']); + + foreach (User::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() + { + User::insert([ + ['email' => 'user1@gmail.com'], + ['email' => 'user2@gmail.com'], + ['email' => 'user3@gmail.com'], + ]); + + [$user1, $user2, $user3] = User::get(); + + Achievement::fillAndInsert([['status' => 3], [], []]); + [$achievement1, $achievement2, $achievement3] = Achievement::get(); + + $user1->achievements()->attach([$achievement1]); + $user2->achievements()->attach([$achievement1, $achievement3]); + $user3->achievements()->attach([$achievement2, $achievement3]); + + $achievedAchievement1 = User::whereAttachedTo($achievement1)->get(); + + $this->assertSame(2, $achievedAchievement1->count()); + $this->assertTrue($achievedAchievement1->contains($user1)); + $this->assertTrue($achievedAchievement1->contains($user2)); + + $achievedByUser1or2 = Achievement::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 = User::create(['email' => 'taylorotwell@gmail.com']); + $user->posts()->create(['name' => 'First Post']); + $user = User::with('posts')->where('email', 'taylorotwell@gmail.com')->first(); + + $this->assertSame('First Post', $user->posts->first()->name); + + $post = Post::with('user')->where('name', 'First Post')->get(); + $this->assertSame('taylorotwell@gmail.com', $post->first()->user->email); + } + + public function testBasicNestedSelfReferencingHasManyEagerLoading() + { + $user = User::create(['email' => 'taylorotwell@gmail.com']); + $post = $user->posts()->create(['name' => 'First Post']); + $post->childPosts()->create(['name' => 'Child Post', 'user_id' => $user->id]); + + $user = User::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 = Post::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 = User::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(Photo::class, $user->photos[0]); + $this->assertInstanceOf(Collection::class, $post->photos); + $this->assertInstanceOf(Photo::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 = Photo::orderBy('name')->get(); + + $this->assertInstanceOf(Collection::class, $photos); + $this->assertCount(4, $photos); + $this->assertInstanceOf(User::class, $photos[0]->imageable); + $this->assertInstanceOf(Post::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' => User::class, + 'post' => Post::class, + ]); + + $user = User::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(Photo::class, $user->photos[0]); + $this->assertInstanceOf(Collection::class, $post->photos); + $this->assertInstanceOf(Photo::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' => User::class, + 'post' => Post::class, + ]); + + $user = User::create(['email' => 'taylorotwell@gmail.com']); + $user->photos()->create(['name' => 'Avatar 1']); + + $photo = Photo::first(); + $this->assertSame('user', $photo->imageable_type); + $this->assertInstanceOf(User::class, $photo->imageable); + } + + public function testMorphMapIsMergedByDefault() + { + $map1 = [ + 'user' => User::class, + ]; + $map2 = [ + 'post' => Post::class, + ]; + + Relation::morphMap($map1); + Relation::morphMap($map2); + + $this->assertEquals(array_merge($map1, $map2), Relation::morphMap()); + } + + public function testMorphMapOverwritesCurrentMap() + { + $map1 = [ + 'user' => User::class, + ]; + $map2 = [ + 'post' => Post::class, + ]; + + Relation::morphMap($map1, false); + $this->assertEquals($map1, Relation::morphMap()); + Relation::morphMap($map2, false); + $this->assertEquals($map2, Relation::morphMap()); + } + + public function testEmptyMorphToRelationship() + { + $photo = new Photo(); + + $this->assertNull($photo->imageable); + } + + public function testSaveOrFail() + { + $date = '1970-01-01'; + $post = new Post([ + 'user_id' => 1, 'name' => 'Post', 'created_at' => $date, 'updated_at' => $date, + ]); + + $this->assertTrue($post->saveOrFail()); + $this->assertEquals(1, Post::count()); + } + + public function testSavingJSONFields() + { + $model = WithJSON::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'; + Post::create([ + 'id' => 1, 'user_id' => 1, 'name' => 'Post', 'created_at' => $date, 'updated_at' => $date, + ]); + + $post = new Post([ + 'id' => 1, 'user_id' => 1, 'name' => 'Post', 'created_at' => $date, 'updated_at' => $date, + ]); + + $post->saveOrFail(); + } + + public function testMultiInsertsWithDifferentValues() + { + $date = '1970-01-01'; + $result = Post::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, Post::count()); + } + + public function testMultiInsertsWithSameValues() + { + $date = '1970-01-01'; + $result = Post::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, Post::count()); + } + + public function testNestedTransactions() + { + $user = User::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 = User::first(); + $this->assertSame('taylor@laravel.com', $user->email); + }); + } + + public function testNestedTransactionsUsingSaveOrFailWillSucceed() + { + $user = User::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 = User::first(); + $this->assertSame('otwell@laravel.com', $user->email); + $this->assertEquals(1, $user->id); + }); + } + + public function testNestedTransactionsUsingSaveOrFailWillFails() + { + $user = User::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 = User::first(); + $this->assertSame('taylor@laravel.com', $user->email); + $this->assertEquals(1, $user->id); + }); + } + + public function testToArrayIncludesDefaultFormattedTimestamps() + { + $model = new User(); + + $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 UserWithCustomDateSerialization(); + + $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() + { + User::create(['email' => 'taylorotwell@gmail.com']); + + $user = User::first(); + $this->assertIsInt($user->id); + } + + public function testDefaultIncrementingPrimaryKeyIntegerCastCanBeOverwritten() + { + UserWithStringCastId::create(['email' => 'taylorotwell@gmail.com']); + + $user = UserWithStringCastId::first(); + $this->assertIsString($user->id); + } + + public function testRelationsArePreloadedInGlobalScope() + { + $user = UserWithGlobalScope::create(['email' => 'taylorotwell@gmail.com']); + $user->posts()->create(['name' => 'My Post']); + + $result = UserWithGlobalScope::first(); + + $this->assertCount(1, $result->getRelations()); + } + + public function testModelIgnoredByGlobalScopeCanBeRefreshed() + { + $user = UserWithOmittingGlobalScope::create(['id' => 1, 'email' => 'taylorotwell@gmail.com']); + + $this->assertNotNull($user->fresh()); + } + + public function testGlobalScopeCanBeRemovedByOtherGlobalScope() + { + $user = UserWithGlobalScopeRemovingOtherScope::create(['id' => 1, 'email' => 'taylorotwell@gmail.com']); + $user->delete(); + + $this->assertNotNull(UserWithGlobalScopeRemovingOtherScope::find($user->id)); + } + + public function testForPageBeforeIdCorrectlyPaginates() + { + User::insert([ + ['id' => 1, 'email' => 'taylorotwell@gmail.com'], + ['id' => 2, 'email' => 'abigailotwell@gmail.com'], + ]); + + $results = User::forPageBeforeId(15, 2); + $this->assertInstanceOf(Builder::class, $results); + $this->assertEquals(1, $results->first()->id); + + $results = User::orderBy('id', 'desc')->forPageBeforeId(15, 2); + $this->assertInstanceOf(Builder::class, $results); + $this->assertEquals(1, $results->first()->id); + } + + public function testForPageAfterIdCorrectlyPaginates() + { + User::insert([ + ['id' => 1, 'email' => 'taylorotwell@gmail.com'], + ['id' => 2, 'email' => 'abigailotwell@gmail.com'], + ]); + + $results = User::forPageAfterId(15, 1); + $this->assertInstanceOf(Builder::class, $results); + $this->assertEquals(2, $results->first()->id); + + $results = User::orderBy('id', 'desc')->forPageAfterId(15, 1); + $this->assertInstanceOf(Builder::class, $results); + $this->assertEquals(2, $results->first()->id); + } + + public function testMorphToRelationsAcrossDatabaseConnections() + { + $item = null; + + Item::create(['id' => 1]); + Order::create(['id' => 1, 'item_type' => Item::class, 'item_id' => 1]); + try { + $item = Order::first()->item; + } catch (Exception) { + // ignore the exception + } + + $this->assertInstanceOf(Item::class, $item); + } + + public function testEagerLoadedMorphToRelationsOnAnotherDatabaseConnection() + { + Post::create(['id' => 1, 'name' => 'Default Connection Post', 'user_id' => 1]); + Photo::create(['id' => 1, 'imageable_type' => Post::class, 'imageable_id' => 1, 'name' => 'Photo']); + + Post::on('second_connection') + ->create(['id' => 1, 'name' => 'Second Connection Post', 'user_id' => 1]); + Photo::on('second_connection') + ->create(['id' => 1, 'imageable_type' => Post::class, 'imageable_id' => 1, 'name' => 'Photo']); + + $defaultConnectionPost = Photo::with('imageable')->first()->imageable; + $secondConnectionPost = Photo::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 = UserWithCustomFriendPivot::create(['id' => 1, 'name' => 'John Doe', 'email' => 'johndoe@example.com']); + $jane = UserWithCustomFriendPivot::create(['id' => 2, 'name' => 'Jane Doe', 'email' => 'janedoe@example.com']); + $jack = UserWithCustomFriendPivot::create(['id' => 3, 'name' => 'Jack Doe', 'email' => 'jackdoe@example.com']); + $jule = UserWithCustomFriendPivot::create(['id' => 4, 'name' => 'Jule Doe', 'email' => 'juledoe@example.com']); + + FriendLevel::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 = UserWithCustomFriendPivot::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 = User::create(['id' => 1, 'email' => 'taylorotwell@gmail.com']); + $retrieved = User::find(1); + + $this->assertTrue($saved->is($retrieved)); + } + + public function testFreshMethodOnModel() + { + $now = Carbon::now()->startOfSecond(); + $nowSerialized = $now->toJSON(); + $nowWithFractionsSerialized = $now->toJSON(); + Carbon::setTestNow($now); + + $storedUser1 = User::create([ + 'id' => 1, + 'email' => 'taylorotwell@gmail.com', + 'birthday' => $now, + ]); + $storedUser1->newQuery()->update([ + 'email' => 'dev@mathieutu.ovh', + 'name' => 'Mathieu TUDISCO', + ]); + $freshStoredUser1 = $storedUser1->fresh(); + + $storedUser2 = User::create([ + 'id' => 2, + 'email' => 'taylorotwell@gmail.com', + 'birthday' => $now, + ]); + $storedUser2->newQuery()->update(['email' => 'dev@mathieutu.ovh']); + $freshStoredUser2 = $storedUser2->fresh(); + + $notStoredUser = new User([ + '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(User::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(User::class, $storedUser2); + + $this->assertEquals([ + 'id' => 3, + 'email' => 'taylorotwell@gmail.com', + 'birthday' => $nowWithFractionsSerialized, + ], $notStoredUser->toArray()); + $this->assertNull($freshNotStoredUser); + } + + public function testFreshMethodOnCollection() + { + User::insert([['id' => 1, 'email' => 'taylorotwell@gmail.com'], ['id' => 2, 'email' => 'taylorotwell@gmail.com']]); + + $users = User::all() + ->add(new User(['id' => 3, 'email' => 'taylorotwell@gmail.com'])); + + User::find(1)->update(['name' => 'Mathieu TUDISCO']); + User::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 User(); + $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 User(); + $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 User(); + $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 User(); + $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 User(); + $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 User(); + $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 = TouchingUser::create(['id' => 1, 'email' => 'taylorotwell@gmail.com']); + $post = TouchingPost::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 = TouchingUser::create(['id' => 1, 'email' => 'taylorotwell@gmail.com']); + $post = TouchingPost::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)); + + TouchingComment::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 = TouchingUser::create(['id' => 1, 'email' => 'taylorotwell@gmail.com']); + $post = TouchingPost::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 = TouchingUser::create(['id' => 1, 'email' => 'taylorotwell@gmail.com']); + $post = TouchingPost::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 = TouchingUser::create(['id' => 1, 'email' => 'taylorotwell@gmail.com']); + $post = TouchingPost::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)); + + TouchingUser::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 = TouchingUser::create(['id' => 1, 'email' => 'taylorotwell@gmail.com']); + $post = TouchingPost::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)); + + TouchingUser::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 = TouchingUser::create(['id' => 1, 'email' => 'taylorotwell@gmail.com']); + $post = TouchingPost::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 = TouchingUser::create(['id' => 1, 'email' => 'taylorotwell@gmail.com']); + $post = TouchingPost::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)); + + TouchingUser::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 = TouchingUser::create(['id' => 1, 'email' => 'taylorotwell@gmail.com']); + $post = TouchingPost::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)); + + TouchingUser::withoutTouching(function () { + TouchingComment::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 = TouchingUser::create(['id' => 1, 'email' => 'taylorotwell@gmail.com']); + $post = TouchingPost::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)); + + TouchingPost::withoutTouching(function () { + TouchingComment::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 = TouchingUser::create(['id' => 1, 'email' => 'taylorotwell@gmail.com']); + $post = TouchingPost::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)); + + TouchingUser::withoutTouching(function () { + TouchingPost::withoutTouching(function () { + TouchingComment::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 = TouchingUser::create(['id' => 1, 'email' => 'taylorotwell@gmail.com']); + $post = TouchingPost::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([TouchingUser::class, TouchingPost::class], function () { + TouchingComment::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(IgnoringTouchUser::isIgnoringTouch()); + + Model::withoutTouching(function () { + $this->assertTrue(Model::isIgnoringTouch()); + $this->assertTrue(IgnoringTouchUser::isIgnoringTouch()); + }); + + $this->assertFalse(IgnoringTouchUser::isIgnoringTouch()); + $this->assertFalse(Model::isIgnoringTouch()); + } + + public function testChildModelsAreIgnored() + { + $this->assertFalse(Model::isIgnoringTouch()); + $this->assertFalse(IgnoringTouchUser::isIgnoringTouch()); + $this->assertFalse(IgnoringTouchPost::isIgnoringTouch()); + + IgnoringTouchUser::withoutTouching(function () { + $this->assertFalse(Model::isIgnoringTouch()); + $this->assertFalse(IgnoringTouchPost::isIgnoringTouch()); + $this->assertTrue(IgnoringTouchUser::isIgnoringTouch()); + }); + + $this->assertFalse(IgnoringTouchPost::isIgnoringTouch()); + $this->assertFalse(IgnoringTouchUser::isIgnoringTouch()); + $this->assertFalse(Model::isIgnoringTouch()); + } + + public function testPivotsCanBeRefreshed() + { + FriendLevel::create(['id' => 1, 'level' => 'acquaintance']); + FriendLevel::create(['id' => 2, 'level' => 'friend']); + + $user = User::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 = Post::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' => Post::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 = TouchingCategory::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(); + + TouchingCategory::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 = TouchingCategory::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(User::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 = User::get()); + + $users->take(2)->each(function (User $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(WithJSON::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 = WithJSON::get()); + + $testsWithJson->each(function (WithJSON $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... + * @param mixed $connection + */ + + /** + * Get a database connection instance. + * + * @return \Illuminate\Database\Connection + */ + protected function connection($connection = 'default') + { + return Eloquent::getConnectionResolver()->connection($connection); + } + + /** + * Get a schema builder instance. + * + * @param mixed $connection + * @return \Illuminate\Database\Schema\Builder + */ + protected function schema($connection = 'default') + { + return $this->connection($connection)->getSchemaBuilder(); + } +} + +/** + * Eloquent Models... + */ +class User 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(Post::class, 'user_id'); + } + + public function post() + { + return $this->hasOne(Post::class, 'user_id'); + } + + public function photos() + { + return $this->morphMany(Photo::class, 'imageable'); + } + + public function postWithPhotos() + { + return $this->post()->join('photo', function ($join) { + $join->on('photo.imageable_id', 'post.id'); + $join->where('photo.imageable_type', 'Post'); + }); + } + + public function achievements() + { + return $this->belongsToMany(Achievement::class); + } +} + +class UserWithCustomFriendPivot extends User +{ + public function friends() + { + return $this->belongsToMany(User::class, 'friends', 'user_id', 'friend_id') + ->using(FriendPivot::class)->withPivot('user_id', 'friend_id', 'friend_level_id'); + } +} + +class UserWithSpaceInColumnName extends User +{ + protected ?string $table = 'users_with_space_in_column_name'; +} + +class NonIncrementing extends Eloquent +{ + protected ?string $table = 'non_incrementing_users'; + + protected array $guarded = []; + + public bool $incrementing = false; + + public bool $timestamps = false; +} + +class NonIncrementingSecond extends NonIncrementing +{ + protected UnitEnum|string|null $connection = 'second_connection'; +} + +class UserWithGlobalScope extends User +{ + public static function boot(): void + { + parent::boot(); + + static::addGlobalScope(function ($builder) { + $builder->with('posts'); + }); + } +} + +class UserWithOmittingGlobalScope extends User +{ + public static function boot(): void + { + parent::boot(); + + static::addGlobalScope(function ($builder) { + $builder->where('email', '!=', 'taylorotwell@gmail.com'); + }); + } +} + +class UserWithGlobalScopeRemovingOtherScope 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 UniqueUser extends Eloquent +{ + protected ?string $table = 'unique_users'; + + protected array $casts = ['birthday' => 'datetime']; + + protected array $guarded = []; +} + +class Post extends Eloquent +{ + protected ?string $table = 'posts'; + + protected array $guarded = []; + + public function user() + { + return $this->belongsTo(User::class, 'user_id'); + } + + public function photos() + { + return $this->morphMany(Photo::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(Tag::class, 'taggable', Taggable::class, null, 'tag_id')->withPivot('taxonomy'); + } +} + +class Taggable extends MorphPivot +{ + protected ?string $table = 'taggables'; +} + +class Tag extends Eloquent +{ + protected ?string $table = 'tags'; + + protected array $guarded = []; +} + +class FriendLevel extends Eloquent +{ + protected ?string $table = 'friend_levels'; + + protected array $guarded = []; +} + +class Photo extends Eloquent +{ + protected ?string $table = 'photos'; + + protected array $guarded = []; + + public function imageable() + { + return $this->morphTo(); + } +} + +class UserWithStringCastId extends User +{ + protected array $casts = [ + 'id' => 'string', + ]; +} + +class UserWithCustomDateSerialization extends User +{ + protected function serializeDate(DateTimeInterface $date): string + { + return $date->format('d-m-y'); + } +} + +class Order extends Eloquent +{ + protected array $guarded = []; + + protected ?string $table = 'test_orders'; + + protected array $with = ['item']; + + public function item() + { + return $this->morphTo(); + } +} + +class Item extends Eloquent +{ + protected array $guarded = []; + + protected ?string $table = 'test_items'; + + protected UnitEnum|string|null $connection = 'second_connection'; +} + +class WithJSON extends Eloquent +{ + protected array $guarded = []; + + protected ?string $table = 'with_json'; + + public bool $timestamps = false; + + protected array $casts = [ + 'json' => 'array', + ]; +} + +class FriendPivot extends Pivot +{ + protected ?string $table = 'friends'; + + protected array $guarded = []; + + public bool $timestamps = false; + + public function user() + { + return $this->belongsTo(User::class); + } + + public function friend() + { + return $this->belongsTo(User::class); + } + + public function level() + { + return $this->belongsTo(FriendLevel::class, 'friend_level_id'); + } +} + +class TouchingUser extends Eloquent +{ + protected ?string $table = 'users'; + + protected array $guarded = []; +} + +class TouchingPost extends Eloquent +{ + protected ?string $table = 'posts'; + + protected array $guarded = []; + + protected array $touches = [ + 'user', + ]; + + public function user() + { + return $this->belongsTo(TouchingUser::class, 'user_id'); + } +} + +class TouchingComment extends Eloquent +{ + protected ?string $table = 'comments'; + + protected array $guarded = []; + + protected array $touches = [ + 'post', + ]; + + public function post() + { + return $this->belongsTo(TouchingPost::class, 'post_id'); + } +} + +class TouchingCategory extends Eloquent +{ + protected ?string $table = 'categories'; + + protected array $guarded = []; + + protected array $touches = [ + 'parent', + 'children', + ]; + + public function parent() + { + return $this->belongsTo(TouchingCategory::class, 'parent_id'); + } + + public function children() + { + return $this->hasMany(TouchingCategory::class, 'parent_id')->chaperone(); + } +} + +class Achievement extends Eloquent +{ + public bool $timestamps = false; + + protected ?string $table = 'achievements'; + + protected array $guarded = []; + + protected array $attributes = ['status' => null]; + + public function users() + { + return $this->belongsToMany(User::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'; +} + +/** + * Used for isIgnoringTouch() / withoutTouching() tests. + */ +class IgnoringTouchUser extends Eloquent +{ + protected array $guarded = []; +} + +class IgnoringTouchPost 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..52b0f0ddf --- /dev/null +++ b/tests/Database/Laravel/DatabaseEloquentIntegrationWithTablePrefixTest.php @@ -0,0 +1,179 @@ +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. + */ + 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() + { + User::create(['email' => 'taylorotwell@gmail.com']); + User::create(['email' => 'abigailotwell@gmail.com']); + + $models = User::fromQuery('SELECT * FROM prefix_users WHERE email = ?', ['abigailotwell@gmail.com']); + + $this->assertInstanceOf(Collection::class, $models); + $this->assertInstanceOf(User::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... + * @param mixed $connection + */ + + /** + * Get a database connection instance. + * + * @return \Illuminate\Database\Connection + */ + protected function connection($connection = 'default') + { + return Eloquent::getConnectionResolver()->connection($connection); + } + + /** + * Get a schema builder instance. + * + * @param mixed $connection + * @return \Illuminate\Database\Schema\Builder + */ + protected function schema($connection = 'default') + { + return $this->connection($connection)->getSchemaBuilder(); + } +} + +class User 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..1923570ed --- /dev/null +++ b/tests/Database/Laravel/DatabaseEloquentInverseRelationHasManyTest.php @@ -0,0 +1,319 @@ +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. + */ + 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... + * @param mixed $connection + */ + + /** + * Get a database connection instance. + * + * @return \Illuminate\Database\Connection + */ + protected function connection($connection = 'default') + { + return Eloquent::getConnectionResolver()->connection($connection); + } + + /** + * Get a schema builder instance. + * + * @param mixed $connection + * @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..275d9a2dc --- /dev/null +++ b/tests/Database/Laravel/DatabaseEloquentInverseRelationHasOneTest.php @@ -0,0 +1,255 @@ +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. + */ + protected function tearDown(): void + { + $this->schema()->drop('test_parent'); + $this->schema()->drop('test_child'); + + parent::tearDown(); + } + + public function testHasOneInverseRelationIsProperlySetToParentWhenLazyLoaded() + { + HasOneRelationInverseChildModel::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() + { + HasOneRelationInverseChildModel::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 = HasOneRelationInverseChildModel::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 = HasOneRelationInverseChildModel::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 = HasOneRelationInverseChildModel::factory()->create(); + + $this->assertTrue($parent->isNot($child->parent)); + + $parent->child()->save($child); + + $this->assertTrue($parent->is($child->parent)); + $this->assertSame($parent, $child->parent); + } + + /** + * Helpers... + * @param mixed $connection + */ + + /** + * Get a database connection instance. + * + * @return \Illuminate\Database\Connection + */ + protected function connection($connection = 'default') + { + return Eloquent::getConnectionResolver()->connection($connection); + } + + /** + * Get a schema builder instance. + * + * @param mixed $connection + * @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(HasOneRelationInverseChildModel::class, 'parent_id')->inverse('parent'); + } +} + +class HasOneInverseParentModelFactory extends Factory +{ + protected ?string $model = HasOneInverseParentModel::class; + + public function definition(): array + { + return []; + } +} + +class HasOneRelationInverseChildModel extends Model +{ + use HasFactory; + + protected ?string $table = 'test_child'; + + protected array $fillable = ['id', 'parent_id']; + + protected static function newFactory(): HasOneRelationInverseChildModelFactory + { + return new HasOneRelationInverseChildModelFactory(); + } + + public function parent(): BelongsTo + { + return $this->belongsTo(HasOneInverseParentModel::class, 'parent_id'); + } +} + +class HasOneRelationInverseChildModelFactory extends Factory +{ + protected ?string $model = HasOneRelationInverseChildModel::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..4623c9776 --- /dev/null +++ b/tests/Database/Laravel/DatabaseEloquentInverseRelationMorphManyTest.php @@ -0,0 +1,386 @@ +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. + */ + 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... + * @param mixed $connection + */ + + /** + * Get a database connection instance. + * + * @return \Illuminate\Database\Connection + */ + protected function connection($connection = 'default') + { + return Eloquent::getConnectionResolver()->connection($connection); + } + + /** + * Get a schema builder instance. + * + * @param mixed $connection + * @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..372be8a3b --- /dev/null +++ b/tests/Database/Laravel/DatabaseEloquentInverseRelationMorphOneTest.php @@ -0,0 +1,286 @@ +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. + */ + 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... + * @param mixed $connection + */ + + /** + * Get a database connection instance. + * + * @return \Illuminate\Database\Connection + */ + protected function connection($connection = 'default') + { + return Eloquent::getConnectionResolver()->connection($connection); + } + + /** + * Get a schema builder instance. + * + * @param mixed $connection + * @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..8ed526683 --- /dev/null +++ b/tests/Database/Laravel/DatabaseEloquentInverseRelationTest.php @@ -0,0 +1,402 @@ +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 InverseRelationChildModel()); + + $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 static function guessedParentRelationsDataProvider() + { + yield ['hasInverseRelationParentStub']; + yield ['parentStub']; + yield ['owner']; + } + + 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(), + ]); + } +} + +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 InverseRelationChildModel (originally from DatabaseEloquentInverseRelationHasOneTest). + */ +class InverseRelationChildModel 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..ad044c4f5 --- /dev/null +++ b/tests/Database/Laravel/DatabaseEloquentIrregularPluralTest.php @@ -0,0 +1,166 @@ +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..de5fea61a --- /dev/null +++ b/tests/Database/Laravel/DatabaseEloquentLocalScopesTest.php @@ -0,0 +1,111 @@ +addConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + ])->bootEloquent(); + } + + protected function tearDown(): void + { + Model::unsetConnectionResolver(); + + parent::tearDown(); + } + + public function testCanCheckExistenceOfLocalScope() + { + $model = new ScopedModel(); + + $this->assertTrue($model->hasNamedScope('active')); + $this->assertTrue($model->hasNamedScope('type')); + + $this->assertFalse($model->hasNamedScope('nonExistentLocalScope')); + } + + public function testLocalScopeIsApplied() + { + $model = new ScopedModel(); + $query = $model->newQuery()->active(); + + $this->assertSame('select * from "table" where "active" = ?', $query->toSql()); + $this->assertEquals([true], $query->getBindings()); + } + + public function testDynamicLocalScopeIsApplied() + { + $model = new ScopedModel(); + $query = $model->newQuery()->type('foo'); + + $this->assertSame('select * from "table" where "type" = ?', $query->toSql()); + $this->assertEquals(['foo'], $query->getBindings()); + } + + public function testLocalScopesCanChained() + { + $model = new ScopedModel(); + $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 ScopedModel(); + $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 ScopedModel(); + $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 ScopedModel 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..5d35b2b7e --- /dev/null +++ b/tests/Database/Laravel/DatabaseEloquentModelTest.php @@ -0,0 +1,4480 @@ +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 DateModelStub(); + $model->setAttribute(0, 'value'); + + $this->assertEquals([0 => 'value'], $model->getAttributes()); + } + + public function testDirtyAttributes() + { + $model = new ModelStub(['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 CastingStub(); + $model->intAttribute = null; + $model->syncOriginal(); + $this->assertFalse($model->isDirty('intAttribute')); + $model->forceFill(['intAttribute' => 0]); + $this->assertTrue($model->isDirty('intAttribute')); + } + + public function testFloatAndNullComparisonWhenDirty() + { + $model = new CastingStub(); + $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 CastingStub(); + $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 CastingStub(); + $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 CastingStub(); + $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 CastingStub(); + $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 CastingStub(); + $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 CastingStub(); + $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 CastingStub(); + $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 CastingStub(); + $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 CastingStub(); + $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 CastingStub(); + $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 CastingStub(); + $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 CastingStub(); + $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 CastingStub(); + $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 CastingStub(); + $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 EnumCastingStub(); + $this->assertTrue($model->hasCast('enumAttribute', StringStatus::class)); + } + + public function testCleanAttributes() + { + $model = new ModelStub(['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 ModelStub(['castedFloat' => 8 - 6.4]); + $model->syncOriginal(); + $model->castedFloat = 1.6; + $this->assertTrue($model->originalIsEquivalent('castedFloat')); + + // test is not equivalent + $model = new ModelStub(['castedFloat' => 5.6]); + $model->syncOriginal(); + $model->castedFloat = 5.5; + $this->assertFalse($model->originalIsEquivalent('castedFloat')); + } + + public function testCalculatedAttributes() + { + $model = new ModelStub(); + $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 ModelStub(['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 ModelStub(); + $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 ModelStub(); + $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 ModelStub(); + $instance = $model->newInstance(['name' => 'taylor']); + $this->assertInstanceOf(ModelStub::class, $instance); + $this->assertSame('taylor', $instance->name); + } + + public function testNewInstanceReturnsNewInstanceWithTableSet() + { + $model = new ModelStub(); + $model->setTable('test'); + $newInstance = $model->newInstance(); + + $this->assertSame('test', $newInstance->getTable()); + } + + public function testNewInstanceReturnsNewInstanceWithMergedCasts() + { + $model = new ModelStub(); + $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 = SaveStub::create(['name' => 'taylor']); + $this->assertTrue($_SERVER['__eloquent.saved']); + $this->assertSame('taylor', $model->name); + } + + public function testMakeMethodDoesNotSaveNewModel() + { + $_SERVER['__eloquent.saved'] = false; + $model = SaveStub::make(['name' => 'taylor']); + $this->assertFalse($_SERVER['__eloquent.saved']); + $this->assertSame('taylor', $model->name); + } + + public function testForceCreateMethodSavesNewModelWithGuardedAttributes() + { + $_SERVER['__eloquent.saved'] = false; + $model = SaveStub::forceCreate(['id' => 21]); + $this->assertTrue($_SERVER['__eloquent.saved']); + $this->assertEquals(21, $model->id); + } + + public function testFindMethodUseWritePdo() + { + FindWithWritePdoStub::onWriteConnection()->find(1); + } + + public function testDestroyMethodCallsQueryBuilderCorrectly() + { + DestroyStub::destroy(1, 2, 3); + } + + public function testDestroyMethodCallsQueryBuilderCorrectlyWithCollection() + { + DestroyStub::destroy(new BaseCollection([1, 2, 3])); + } + + public function testDestroyMethodCallsQueryBuilderCorrectlyWithEloquentCollection() + { + DestroyStub::destroy(new Collection([ + new DestroyStub(['id' => 1]), + new DestroyStub(['id' => 2]), + new DestroyStub(['id' => 3]), + ])); + } + + public function testDestroyMethodCallsQueryBuilderCorrectlyWithMultipleArgs() + { + DestroyStub::destroy(1, 2, 3); + } + + public function testDestroyMethodCallsQueryBuilderCorrectlyWithEmptyIds() + { + $count = EmptyDestroyStub::destroy([]); + $this->assertSame(0, $count); + } + + public function testWithMethodCallsQueryBuilderCorrectly() + { + $result = WithStub::with('foo', 'bar'); + $this->assertInstanceOf(Builder::class, $result); + } + + public function testWithoutMethodRemovesEagerLoadedRelationshipCorrectly() + { + $model = new WithoutRelationStub(); + $this->addMockConnection($model); + $instance = $model->newInstance()->newQuery()->without('foo'); + $this->assertEmpty($instance->getEagerLoads()); + } + + public function testWithOnlyMethodLoadsRelationshipCorrectly() + { + $model = new WithoutRelationStub(); + $this->addMockConnection($model); + $instance = $model->newInstance()->newQuery()->withOnly('taylor'); + $this->assertNotNull($instance->getEagerLoads()['taylor']); + $this->assertArrayNotHasKey('foo', $instance->getEagerLoads()); + } + + public function testEagerLoadingWithColumns() + { + $model = new WithoutRelationStub(); + $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 WithWhereHasStub(); + $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 WithWhereHasStub(); + $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 = WithStub::with(['foo', 'bar']); + $this->assertInstanceOf(Builder::class, $result); + } + + public function testUpdateProcess() + { + $model = $this->getMockBuilder(ModelStub::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(ModelStub::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(ModelStub::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(ModelStub::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(EventObjectStub::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(SavingEventStub::class))->andReturn(false); + $model->exists = true; + + $this->assertFalse($model->save()); + } + + public function testUpdateProcessWithoutTimestamps() + { + $model = $this->getMockBuilder(EventObjectStub::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(ModelStub::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(DateModelStub::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(DateModelStub::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 DateModelStub(); + 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 DateModelStub(); + 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 DateModelStub(); + $model->created_at = '2013-05-22 00:00:00'; + $this->assertInstanceOf(Carbon::class, $model->created_at); + + $model = new DateModelStub(); + $model->created_at = $this->currentTime(); + $this->assertInstanceOf(Carbon::class, $model->created_at); + + $model = new DateModelStub(); + $model->created_at = 0; + $this->assertInstanceOf(Carbon::class, $model->created_at); + + $model = new DateModelStub(); + $model->created_at = '2012-01-01'; + $this->assertInstanceOf(Carbon::class, $model->created_at); + } + + public function testFromDateTime() + { + $model = new ModelStub(); + + $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(DateModelStub::class)->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(ModelStub::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(ModelStub::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(ModelStub::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(ModelStub::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(ModelStub::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(ModelStub::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(ModelStub::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(ModelStub::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(ModelStub::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(ModelStub::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(ModelStub::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 RecursiveRelationshipsStub(['id' => 1, 'parent_id' => null]); + $lastId = $parent->id; + $parent->setRelation('self', $parent); + + $children = new Collection(); + for ($count = 0; $count < 2; ++$count) { + $child = new RecursiveRelationshipsStub(['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); + ModelStub::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 ModelStub(); + $builder = $model->newQuery(); + $this->assertInstanceOf(Builder::class, $builder); + } + + public function testGetAndSetTableOperations() + { + $model = new ModelStub(); + $this->assertSame('stub', $model->getTable()); + $model->setTable('foo'); + $this->assertSame('foo', $model->getTable()); + } + + public function testGetKeyReturnsValueOfPrimaryKey() + { + $model = new ModelStub(); + $model->id = 1; + $this->assertEquals(1, $model->getKey()); + $this->assertSame('id', $model->getKeyName()); + } + + public function testConnectionManagement() + { + ModelStub::setConnectionResolver($resolver = m::mock(ConnectionResolverInterface::class)); + $model = m::mock(ModelStub::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) + { + ModelStub::setConnectionResolver($resolver = m::mock(ConnectionResolverInterface::class)); + $model = new ModelStub(); + + $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 ModelStub(); + $model->name = 'foo'; + $model->age = null; + $model->password = 'password1'; + $model->setHidden(['password']); + $model->setRelation('names', new BaseCollection([ + new ModelStub(['bar' => 'baz']), new ModelStub(['bam' => 'boom']), + ])); + $model->setRelation('partner', new ModelStub(['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 RecursiveRelationshipsStub(['id' => 1, 'parent_id' => null]); + $lastId = $parent->id; + $parent->setRelation('self', $parent); + + $children = new Collection(); + for ($count = 0; $count < 2; ++$count) { + $child = new RecursiveRelationshipsStub(['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 RecursiveRelationshipsStub(['id' => 1, 'parent_id' => null]); + $lastId = $parent->id; + $parent->setRelation('self', $parent); + + $children = new Collection(); + for ($count = 0; $count < 2; ++$count) { + $child = new RecursiveRelationshipsStub(['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 ModelStub(); + $model->setVisible(['name']); + $model->name = 'Taylor'; + $model->age = 26; + $array = $model->toArray(); + + $this->assertEquals(['name' => 'Taylor'], $array); + } + + public function testHiddenCanAlsoExcludeRelationships() + { + $model = new ModelStub(); + $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 ModelStub(); + + $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 ModelStub(); + $model->setRelation('namesList', new BaseCollection([ + new ModelStub(['bar' => 'baz']), new ModelStub(['bam' => 'boom']), + ])); + $array = $model->toArray(); + + $this->assertSame('baz', $array['names_list'][0]['bar']); + $this->assertSame('boom', $array['names_list'][1]['bam']); + + $model = new CamelStub(); + $model->setRelation('namesList', new BaseCollection([ + new ModelStub(['bar' => 'baz']), new ModelStub(['bam' => 'boom']), + ])); + $array = $model->toArray(); + + $this->assertSame('baz', $array['namesList'][0]['bar']); + $this->assertSame('boom', $array['namesList'][1]['bam']); + } + + public function testToArrayUsesMutators() + { + $model = new ModelStub(); + $model->list_items = [1, 2, 3]; + $array = $model->toArray(); + + $this->assertEquals([1, 2, 3], $array['list_items']); + } + + public function testHidden() + { + $model = new ModelStub(['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 HiddenStub(); + + $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 ModelStub(['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 VisibleStub(); + + $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 DynamicHiddenStub(['name' => 'foo', 'age' => 'bar', 'id' => 'baz']); + $array = $model->toArray(); + $this->assertArrayHasKey('name', $array); + $this->assertArrayNotHasKey('age', $array); + } + + public function testWithHidden() + { + $model = new ModelStub(['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 ModelStub(['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 DynamicVisibleStub(['name' => 'foo', 'age' => 'bar', 'id' => 'baz']); + $array = $model->toArray(); + $this->assertArrayHasKey('name', $array); + $this->assertArrayNotHasKey('age', $array); + } + + public function testMakeVisibleIf() + { + $model = new ModelStub(['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 ModelStub(['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 ModelStub(); + $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 ModelStub(); + + $this->assertSame('stub.column', $model->qualifyColumn('column')); + } + + public function testForceFillMethodFillsGuardedAttributes() + { + $model = (new SaveStub())->forceFill(['id' => 21]); + $this->assertEquals(21, $model->id); + } + + public function testFillingJSONAttributes() + { + $model = new ModelStub(); + $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 ModelStub(['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 ModelStub(); + ModelStub::unguard(); + $model->guard(['*']); + $model->fill(['name' => 'foo', 'age' => 'bar']); + $this->assertSame('foo', $model->name); + $this->assertSame('bar', $model->age); + ModelStub::unguard(false); + } + + public function testUnderscorePropertiesAreNotFilled() + { + $model = new ModelStub(); + $model->fill(['_method' => 'PUT']); + $this->assertEquals([], $model->getAttributes()); + } + + public function testGuarded() + { + $model = new ModelStub(); + + ModelStub::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 ModelStub(); + $model->guard(['name', 'age']); + $model->fill(['Foo' => 'bar']); + $this->assertFalse(isset($model->Foo)); + + $handledMassAssignmentExceptions = 0; + + Model::preventSilentlyDiscardingAttributes(); + + $this->expectException(MassAssignmentException::class); + $model = new ModelStub(); + $model->guard(['name', 'age']); + $model->fill(['Foo' => 'bar']); + + Model::preventSilentlyDiscardingAttributes(false); + } + + public function testGuardedWithFillableConfig(): void + { + $model = new ModelStub(); + $model::unguard(); + + ModelStub::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() + { + ModelStub::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 ModelStub(); + $model->guard(['name', 'age']); + $model->fill(['Foo' => 'bar']); + + $this->assertInstanceOf(ModelStub::class, $callbackModel); + $this->assertEquals(['Foo'], $callbackKeys); + + Model::preventSilentlyDiscardingAttributes(false); + Model::handleDiscardedAttributeViolationUsing(null); + } + + public function testFillableOverridesGuarded() + { + Model::preventSilentlyDiscardingAttributes(false); + + $model = new ModelStub(); + $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 ModelStub(); + $model->guard(['*']); + $model->fill(['name' => 'foo', 'age' => 'bar', 'votes' => 'baz']); + } + + public function testUnguardedRunsCallbackWhileBeingUnguarded() + { + $model = Model::unguarded(function () { + return (new ModelStub())->guard(['*'])->fill(['name' => 'Taylor']); + }); + $this->assertSame('Taylor', $model->name); + $this->assertFalse(Model::isUnguarded()); + } + + public function testUnguardedCallDoesNotChangeUnguardedState() + { + Model::unguard(); + $model = Model::unguarded(function () { + return (new ModelStub())->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 ModelStub(); + $this->addMockConnection($model); + $relation = $model->hasOne(SaveStub::class); + $this->assertSame('save_stub.model_stub_id', $relation->getQualifiedForeignKeyName()); + + $model = new ModelStub(); + $this->addMockConnection($model); + $relation = $model->hasOne(SaveStub::class, 'foo'); + $this->assertSame('save_stub.foo', $relation->getQualifiedForeignKeyName()); + $this->assertSame($model, $relation->getParent()); + $this->assertInstanceOf(SaveStub::class, $relation->getQuery()->getModel()); + } + + public function testMorphOneCreatesProperRelation() + { + $model = new ModelStub(); + $this->addMockConnection($model); + $relation = $model->morphOne(SaveStub::class, 'morph'); + $this->assertSame('save_stub.morph_id', $relation->getQualifiedForeignKeyName()); + $this->assertSame('save_stub.morph_type', $relation->getQualifiedMorphType()); + $this->assertEquals(ModelStub::class, $relation->getMorphClass()); + } + + public function testCorrectMorphClassIsReturned() + { + Relation::morphMap(['alias' => 'AnotherModel']); + $model = new ModelStub(); + + try { + $this->assertEquals(ModelStub::class, $model->getMorphClass()); + } finally { + Relation::morphMap([], false); + } + } + + public function testHasManyCreatesProperRelation() + { + $model = new ModelStub(); + $this->addMockConnection($model); + $relation = $model->hasMany(SaveStub::class); + $this->assertSame('save_stub.model_stub_id', $relation->getQualifiedForeignKeyName()); + + $model = new ModelStub(); + $this->addMockConnection($model); + $relation = $model->hasMany(SaveStub::class, 'foo'); + + $this->assertSame('save_stub.foo', $relation->getQualifiedForeignKeyName()); + $this->assertSame($model, $relation->getParent()); + $this->assertInstanceOf(SaveStub::class, $relation->getQuery()->getModel()); + } + + public function testMorphManyCreatesProperRelation() + { + $model = new ModelStub(); + $this->addMockConnection($model); + $relation = $model->morphMany(SaveStub::class, 'morph'); + $this->assertSame('save_stub.morph_id', $relation->getQualifiedForeignKeyName()); + $this->assertSame('save_stub.morph_type', $relation->getQualifiedMorphType()); + $this->assertEquals(ModelStub::class, $relation->getMorphClass()); + } + + public function testBelongsToCreatesProperRelation() + { + $model = new ModelStub(); + $this->addMockConnection($model); + $relation = $model->belongsToStub(); + $this->assertSame('belongs_to_stub_id', $relation->getForeignKeyName()); + $this->assertSame($model, $relation->getParent()); + $this->assertInstanceOf(SaveStub::class, $relation->getQuery()->getModel()); + + $model = new ModelStub(); + $this->addMockConnection($model); + $relation = $model->belongsToExplicitKeyStub(); + $this->assertSame('foo', $relation->getForeignKeyName()); + } + + public function testMorphToCreatesProperRelation() + { + $model = new ModelStub(); + $this->addMockConnection($model); + + // $this->morphTo(); + $model->setAttribute('morph_to_stub_type', SaveStub::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(SaveStub::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 ModelStub(); + $this->addMockConnection($model); + + $relation = $model->belongsToMany(SaveStub::class); + $this->assertSame('model_stub_save_stub.model_stub_id', $relation->getQualifiedForeignPivotKeyName()); + $this->assertSame('model_stub_save_stub.save_stub_id', $relation->getQualifiedRelatedPivotKeyName()); + $this->assertSame($model, $relation->getParent()); + $this->assertInstanceOf(SaveStub::class, $relation->getQuery()->getModel()); + $this->assertEquals(__FUNCTION__, $relation->getRelationName()); + + $model = new ModelStub(); + $this->addMockConnection($model); + $relation = $model->belongsToMany(SaveStub::class, 'table', 'foreign', 'other'); + $this->assertSame('table.foreign', $relation->getQualifiedForeignPivotKeyName()); + $this->assertSame('table.other', $relation->getQualifiedRelatedPivotKeyName()); + $this->assertSame($model, $relation->getParent()); + $this->assertInstanceOf(SaveStub::class, $relation->getQuery()->getModel()); + } + + public function testRelationsWithVariedConnections() + { + // Has one + $model = new ModelStub(); + $model->setConnection('non_default'); + $this->addMockConnection($model); + $relation = $model->hasOne(NoConnectionModelStub::class); + $this->assertSame('non_default', $relation->getRelated()->getConnectionName()); + + $model = new ModelStub(); + $model->setConnection('non_default'); + $this->addMockConnection($model); + $relation = $model->hasOne(DifferentConnectionModelStub::class); + $this->assertSame('different_connection', $relation->getRelated()->getConnectionName()); + + // Morph One + $model = new ModelStub(); + $model->setConnection('non_default'); + $this->addMockConnection($model); + $relation = $model->morphOne(NoConnectionModelStub::class, 'type'); + $this->assertSame('non_default', $relation->getRelated()->getConnectionName()); + + $model = new ModelStub(); + $model->setConnection('non_default'); + $this->addMockConnection($model); + $relation = $model->morphOne(DifferentConnectionModelStub::class, 'type'); + $this->assertSame('different_connection', $relation->getRelated()->getConnectionName()); + + // Belongs to + $model = new ModelStub(); + $model->setConnection('non_default'); + $this->addMockConnection($model); + $relation = $model->belongsTo(NoConnectionModelStub::class); + $this->assertSame('non_default', $relation->getRelated()->getConnectionName()); + + $model = new ModelStub(); + $model->setConnection('non_default'); + $this->addMockConnection($model); + $relation = $model->belongsTo(DifferentConnectionModelStub::class); + $this->assertSame('different_connection', $relation->getRelated()->getConnectionName()); + + // has many + $model = new ModelStub(); + $model->setConnection('non_default'); + $this->addMockConnection($model); + $relation = $model->hasMany(NoConnectionModelStub::class); + $this->assertSame('non_default', $relation->getRelated()->getConnectionName()); + + $model = new ModelStub(); + $model->setConnection('non_default'); + $this->addMockConnection($model); + $relation = $model->hasMany(DifferentConnectionModelStub::class); + $this->assertSame('different_connection', $relation->getRelated()->getConnectionName()); + + // has many through + $model = new ModelStub(); + $model->setConnection('non_default'); + $this->addMockConnection($model); + $relation = $model->hasManyThrough(NoConnectionModelStub::class, SaveStub::class); + $this->assertSame('non_default', $relation->getRelated()->getConnectionName()); + + $model = new ModelStub(); + $model->setConnection('non_default'); + $this->addMockConnection($model); + $relation = $model->hasManyThrough(DifferentConnectionModelStub::class, SaveStub::class); + $this->assertSame('different_connection', $relation->getRelated()->getConnectionName()); + + // belongs to many + $model = new ModelStub(); + $model->setConnection('non_default'); + $this->addMockConnection($model); + $relation = $model->belongsToMany(NoConnectionModelStub::class); + $this->assertSame('non_default', $relation->getRelated()->getConnectionName()); + + $model = new ModelStub(); + $model->setConnection('non_default'); + $this->addMockConnection($model); + $relation = $model->belongsToMany(DifferentConnectionModelStub::class); + $this->assertSame('different_connection', $relation->getRelated()->getConnectionName()); + } + + public function testModelsAssumeTheirName() + { + require_once __DIR__ . '/stubs/EloquentModelNamespacedStub.php'; + + $model = new ModelWithoutTableStub(); + $this->assertSame('model_without_table_stubs', $model->getTable()); + + $namespacedModel = new EloquentModelNamespacedStub(); + $this->assertSame('eloquent_model_namespaced_stubs', $namespacedModel->getTable()); + } + + public function testTheMutatorCacheIsPopulated() + { + $class = new ModelStub(); + + $expectedAttributes = [ + 'list_items', + 'password', + 'appendable', + ]; + + $this->assertEquals($expectedAttributes, $class->getMutatedAttributes()); + } + + public function testRouteKeyIsPrimaryKey() + { + $model = new NonIncrementingStub(); + $model->id = 'foo'; + $this->assertSame('foo', $model->getRouteKey()); + } + + public function testRouteNameIsPrimaryKeyName() + { + $model = new ModelStub(); + $this->assertSame('id', $model->getRouteKeyName()); + } + + public function testCloneModelMakesAFreshCopyOfTheModel() + { + $class = new ModelStub(); + $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 PrimaryUuidModelStub(); + $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 NonPrimaryUuidModelStub(); + $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 PrimaryUlidModelStub(); + $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 NonPrimaryUlidModelStub(); + $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() + { + ModelStub::setEventDispatcher($events = m::mock(Dispatcher::class)); + $events->shouldReceive('listen')->once()->with('eloquent.creating: Hypervel\Tests\Database\Laravel\DatabaseEloquentModelTest\ModelStub', TestObserverStub::class . '@creating'); + $events->shouldReceive('listen')->once()->with('eloquent.saved: Hypervel\Tests\Database\Laravel\DatabaseEloquentModelTest\ModelStub', TestObserverStub::class . '@saved'); + $events->shouldReceive('forget'); + ModelStub::observe(new TestObserverStub()); + ModelStub::flushEventListeners(); + } + + public function testModelObserversCanBeAttachedToModelsWithString() + { + ModelStub::setEventDispatcher($events = m::mock(Dispatcher::class)); + $events->shouldReceive('listen')->once()->with('eloquent.creating: Hypervel\Tests\Database\Laravel\DatabaseEloquentModelTest\ModelStub', TestObserverStub::class . '@creating'); + $events->shouldReceive('listen')->once()->with('eloquent.saved: Hypervel\Tests\Database\Laravel\DatabaseEloquentModelTest\ModelStub', TestObserverStub::class . '@saved'); + $events->shouldReceive('forget'); + ModelStub::observe(TestObserverStub::class); + ModelStub::flushEventListeners(); + } + + public function testModelObserversCanBeAttachedToModelsThroughAnArray() + { + ModelStub::setEventDispatcher($events = m::mock(Dispatcher::class)); + $events->shouldReceive('listen')->once()->with('eloquent.creating: Hypervel\Tests\Database\Laravel\DatabaseEloquentModelTest\ModelStub', TestObserverStub::class . '@creating'); + $events->shouldReceive('listen')->once()->with('eloquent.saved: Hypervel\Tests\Database\Laravel\DatabaseEloquentModelTest\ModelStub', TestObserverStub::class . '@saved'); + $events->shouldReceive('forget'); + ModelStub::observe([TestObserverStub::class]); + ModelStub::flushEventListeners(); + } + + public function testModelObserversCanBeAttachedToModelsWithStringUsingAttribute() + { + ModelWithObserveAttributeStub::setEventDispatcher($events = m::mock(Dispatcher::class)); + $events->shouldReceive('dispatch'); + $events->shouldReceive('listen')->once()->with('eloquent.creating: Hypervel\Tests\Database\Laravel\DatabaseEloquentModelTest\ModelWithObserveAttributeStub', TestObserverStub::class . '@creating'); + $events->shouldReceive('listen')->once()->with('eloquent.saved: Hypervel\Tests\Database\Laravel\DatabaseEloquentModelTest\ModelWithObserveAttributeStub', TestObserverStub::class . '@saved'); + $events->shouldReceive('forget'); + ModelWithObserveAttributeStub::flushEventListeners(); + } + + public function testModelObserversCanBeAttachedToModelsThroughAnArrayUsingAttribute() + { + ModelWithObserveAttributeUsingArrayStub::setEventDispatcher($events = m::mock(Dispatcher::class)); + $events->shouldReceive('dispatch'); + $events->shouldReceive('listen')->once()->with('eloquent.creating: Hypervel\Tests\Database\Laravel\DatabaseEloquentModelTest\ModelWithObserveAttributeUsingArrayStub', TestObserverStub::class . '@creating'); + $events->shouldReceive('listen')->once()->with('eloquent.saved: Hypervel\Tests\Database\Laravel\DatabaseEloquentModelTest\ModelWithObserveAttributeUsingArrayStub', TestObserverStub::class . '@saved'); + $events->shouldReceive('forget'); + ModelWithObserveAttributeUsingArrayStub::flushEventListeners(); + } + + public function testModelObserversCanBeAttachedToModelsThroughAttributesOnParentClasses() + { + ModelWithObserveAttributeGrandchildStub::setEventDispatcher($events = m::mock(Dispatcher::class)); + $events->shouldReceive('dispatch'); + $events->shouldReceive('listen')->once()->with('eloquent.creating: Hypervel\Tests\Database\Laravel\DatabaseEloquentModelTest\ModelWithObserveAttributeGrandchildStub', TestObserverStub::class . '@creating'); + $events->shouldReceive('listen')->once()->with('eloquent.saved: Hypervel\Tests\Database\Laravel\DatabaseEloquentModelTest\ModelWithObserveAttributeGrandchildStub', TestObserverStub::class . '@saved'); + $events->shouldReceive('listen')->once()->with('eloquent.creating: Hypervel\Tests\Database\Laravel\DatabaseEloquentModelTest\ModelWithObserveAttributeGrandchildStub', TestAnotherObserverStub::class . '@creating'); + $events->shouldReceive('listen')->once()->with('eloquent.saved: Hypervel\Tests\Database\Laravel\DatabaseEloquentModelTest\ModelWithObserveAttributeGrandchildStub', TestAnotherObserverStub::class . '@saved'); + $events->shouldReceive('listen')->once()->with('eloquent.creating: Hypervel\Tests\Database\Laravel\DatabaseEloquentModelTest\ModelWithObserveAttributeGrandchildStub', TestThirdObserverStub::class . '@creating'); + $events->shouldReceive('listen')->once()->with('eloquent.saved: Hypervel\Tests\Database\Laravel\DatabaseEloquentModelTest\ModelWithObserveAttributeGrandchildStub', TestThirdObserverStub::class . '@saved'); + $events->shouldReceive('forget'); + ModelWithObserveAttributeGrandchildStub::flushEventListeners(); + } + + public function testThrowExceptionOnAttachingNotExistsModelObserverWithString() + { + $this->expectException(InvalidArgumentException::class); + ModelStub::observe(NotExistClass::class); + } + + public function testThrowExceptionOnAttachingNotExistsModelObserversThroughAnArray() + { + $this->expectException(InvalidArgumentException::class); + ModelStub::observe([NotExistClass::class]); + } + + public function testModelObserversCanBeAttachedToModelsThroughCallingObserveMethodOnlyOnce() + { + ModelStub::setEventDispatcher($events = m::mock(Dispatcher::class)); + $events->shouldReceive('listen')->once()->with('eloquent.creating: Hypervel\Tests\Database\Laravel\DatabaseEloquentModelTest\ModelStub', TestObserverStub::class . '@creating'); + $events->shouldReceive('listen')->once()->with('eloquent.saved: Hypervel\Tests\Database\Laravel\DatabaseEloquentModelTest\ModelStub', TestObserverStub::class . '@saved'); + + $events->shouldReceive('listen')->once()->with('eloquent.creating: Hypervel\Tests\Database\Laravel\DatabaseEloquentModelTest\ModelStub', TestAnotherObserverStub::class . '@creating'); + $events->shouldReceive('listen')->once()->with('eloquent.saved: Hypervel\Tests\Database\Laravel\DatabaseEloquentModelTest\ModelStub', TestAnotherObserverStub::class . '@saved'); + + $events->shouldReceive('forget'); + + ModelStub::observe([ + TestObserverStub::class, + TestAnotherObserverStub::class, + ]); + + ModelStub::flushEventListeners(); + } + + public function testWithoutEventDispatcher() + { + SaveStub::setEventDispatcher($events = m::mock(Dispatcher::class)); + $events->shouldReceive('listen')->once()->with('eloquent.creating: Hypervel\Tests\Database\Laravel\DatabaseEloquentModelTest\SaveStub', TestObserverStub::class . '@creating'); + $events->shouldReceive('listen')->once()->with('eloquent.saved: Hypervel\Tests\Database\Laravel\DatabaseEloquentModelTest\SaveStub', TestObserverStub::class . '@saved'); + $events->shouldNotReceive('until'); + $events->shouldNotReceive('dispatch'); + $events->shouldReceive('forget'); + SaveStub::observe(TestObserverStub::class); + + $model = SaveStub::withoutEvents(function () { + $model = new SaveStub(); + $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\DatabaseEloquentModelTest\SaveStub', $model); + $events->shouldReceive('dispatch')->once()->with('eloquent.saved: Hypervel\Tests\Database\Laravel\DatabaseEloquentModelTest\SaveStub', $model); + + $model->last_name = 'Otwell'; + $model->save(); + + SaveStub::flushEventListeners(); + } + + public function testSetObservableEvents() + { + $class = new ModelStub(); + $class->setObservableEvents(['foo']); + + $this->assertContains('foo', $class->getObservableEvents()); + } + + public function testAddObservableEvent() + { + $class = new ModelStub(); + $class->addObservableEvents('foo'); + + $this->assertContains('foo', $class->getObservableEvents()); + } + + public function testAddMultipleObserveableEvents() + { + $class = new ModelStub(); + $class->addObservableEvents('foo', 'bar'); + + $this->assertContains('foo', $class->getObservableEvents()); + $this->assertContains('bar', $class->getObservableEvents()); + } + + public function testRemoveObservableEvent() + { + $class = new ModelStub(); + $class->setObservableEvents(['foo', 'bar']); + $class->removeObservableEvents('bar'); + + $this->assertNotContains('bar', $class->getObservableEvents()); + } + + public function testRemoveMultipleObservableEvents() + { + $class = new ModelStub(); + $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\DatabaseEloquentModelTest\ModelStub::incorrectRelationStub must return a relationship instance.'); + + $model = new ModelStub(); + $model->incorrectRelationStub; + } + + public function testModelIsBootedOnUnserialize() + { + $model = new BootingTestStub(); + $this->assertTrue(BootingTestStub::isBooted()); + $model->foo = 'bar'; + $string = serialize($model); + $model = null; + BootingTestStub::unboot(); + $this->assertFalse(BootingTestStub::isBooted()); + unserialize($string); + $this->assertTrue(BootingTestStub::isBooted()); + } + + public function testCallbacksCanBeRunAfterBootingHasFinished() + { + $this->assertFalse(BootingCallbackTestStub::$bootHasFinished); + + $model = new BootingCallbackTestStub(); + + $this->assertTrue($model::$bootHasFinished); + + BootingCallbackTestStub::unboot(); + } + + public function testBootedCallbacksAreSeparatedByClass() + { + $this->assertFalse(BootingCallbackTestStub::$bootHasFinished); + + $model = new BootingCallbackTestStub(); + + $this->assertTrue($model::$bootHasFinished); + + $this->assertFalse(ChildBootingCallbackTestStub::$bootHasFinished); + + $model = new ChildBootingCallbackTestStub(); + + $this->assertTrue($model::$bootHasFinished); + + BootingCallbackTestStub::unboot(); + ChildBootingCallbackTestStub::unboot(); + } + + public function testModelsTraitIsInitialized() + { + $model = new ModelStubWithTrait(); + $this->assertTrue($model->fooBarIsInitialized); + } + + public function testAppendingOfAttributes() + { + $model = new AppendsStub(); + + $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 AppendsStub(); + + $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 testWithoutAppendsRemovesAppends() + { + $model = new AppendsStub(); + + $this->assertEquals(['is_admin', 'camelCased', 'StudlyCased'], $model->getAppends()); + + $model->withoutAppends(); + + $this->assertEmpty($model->getAppends()); + } + + public function testGetMutatedAttributes() + { + $model = new GetMutatorsStub(); + + $this->assertEquals(['first_name', 'middle_name', 'last_name'], $model->getMutatedAttributes()); + + GetMutatorsStub::resetMutatorCache(); + + GetMutatorsStub::$snakeAttributes = false; + $this->assertEquals(['firstName', 'middleName', 'lastName'], $model->getMutatedAttributes()); + } + + public function testReplicateCreatesANewModelInstanceWithSameAttributeValues() + { + $model = new ModelStub(); + $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 ModelStub(); + + $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 ModelStub(); + $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(ModelStub::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(ModelStub::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(ModelStub::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(ModelStub::class . '[partner]'); + $this->addMockConnection($model); + $model->shouldReceive('partner')->once()->andReturn($relation); + $model->setTouchedRelations(['partner']); + + $mockPartnerModel = m::mock(ModelStub::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(ModelStub::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 CastingStub(); + $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 CastingStub(); + $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 CastingStub(); + $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\DatabaseEloquentModelTest\CastingStub] to JSON: Malformed UTF-8 characters, possibly incorrectly encoded.'); + + $model = new CastingStub(); + $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\DatabaseEloquentModelTest\CastingStub] to JSON: Malformed UTF-8 characters, possibly incorrectly encoded.'); + + $model = new CastingStub(); + $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\DatabaseEloquentModelTest\CastingStub] to JSON: Malformed UTF-8 characters, possibly incorrectly encoded.'); + + $model = new CastingStub(); + $model->jsonAttributeWithUnicode = ['foo' => "b\xF8r"]; + + $model->getAttributes(); + } + + public function testJsonCastingRespectsUnicodeOption() + { + $data = ['こんにちは' => '世界']; + $model = new CastingStub(); + $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 CastingStub(); + + $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 CastingStub(); + + $model->asEnumArrayObjectAttribute = ['draft', 'pending']; + $this->assertInstanceOf(ArrayObject::class, $model->asEnumArrayObjectAttribute); + } + + public function testMergeCastsMergesCasts() + { + $model = new CastingStub(); + + $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 CastingStub(); + + $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 CastingStub(); + $model->asToObjectCast = TestValueObject::make([ + 'myPropertyA' => 'A', + 'myPropertyB' => 'B', + ]); + unset($model->asToObjectCast); + $this->assertArrayNotHasKey('asToObjectCast', $model->getAttributes()); + } + + public function testUpdatingNonExistentModelFails() + { + $model = new ModelStub(); + $this->assertFalse($model->update()); + } + + public function testIssetBehavesCorrectlyWithAttributesAndRelationships() + { + $model = new ModelStub(); + $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(ModelStub::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(ModelStub::class . '[delete]'); + $model->delete = 123; + $this->assertEquals(123, $model->delete); + } + + public function testIntKeyTypePreserved() + { + $model = $this->getMockBuilder(ModelStub::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(KeyTypeModelStub::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 ModelStub(); + $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 ModelStub(); + $this->addMockConnection($model); + + $this->assertInstanceOf(Builder::class, $model->scopes('published')); + $this->assertSame(['published'], $model->scopesCalled); + } + + public function testIsWithNull() + { + $firstInstance = new ModelStub(['id' => 1]); + $secondInstance = null; + + $this->assertFalse($firstInstance->is($secondInstance)); + } + + public function testIsWithTheSameModelInstance() + { + $firstInstance = new ModelStub(['id' => 1]); + $secondInstance = new ModelStub(['id' => 1]); + $result = $firstInstance->is($secondInstance); + $this->assertTrue($result); + } + + public function testIsWithAnotherModelInstance() + { + $firstInstance = new ModelStub(['id' => 1]); + $secondInstance = new ModelStub(['id' => 2]); + $result = $firstInstance->is($secondInstance); + $this->assertFalse($result); + } + + public function testIsWithAnotherTable() + { + $firstInstance = new ModelStub(['id' => 1]); + $secondInstance = new ModelStub(['id' => 1]); + $secondInstance->setTable('foo'); + $result = $firstInstance->is($secondInstance); + $this->assertFalse($result); + } + + public function testIsWithAnotherConnection() + { + $firstInstance = new ModelStub(['id' => 1]); + $secondInstance = new ModelStub(['id' => 1]); + $secondInstance->setConnection('foo'); + $result = $firstInstance->is($secondInstance); + $this->assertFalse($result); + } + + public function testWithoutTouchingCallback() + { + new ModelStub(['id' => 1]); + + $called = false; + + ModelStub::withoutTouching(function () use (&$called) { + $called = true; + }); + + $this->assertTrue($called); + } + + public function testWithoutTouchingOnCallback() + { + new ModelStub(['id' => 1]); + + $called = false; + + Model::withoutTouchingOn([ModelStub::class], function () use (&$called) { + $called = true; + }); + + $this->assertTrue($called); + } + + public function testThrowsWhenAccessingMissingAttributes() + { + $originalMode = Model::preventsAccessingMissingAttributes(); + Model::preventAccessingMissingAttributes(); + + try { + $model = new ModelStub(['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 ModelWithPrimitiveCasts(['id' => 1]); + $model->exists = true; + + $exceptionCount = 0; + $primitiveCasts = ModelWithPrimitiveCasts::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 ModelStub(['id' => 1]); + $model->exists = true; + + $this->assertEquals(1, $model->id); + + $model->this_attribute_does_not_exist; + + $this->assertInstanceOf(ModelStub::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 ModelStub(['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 ModelStub(['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 ModelStub(['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 ModelStub(['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(ModelWithUpdatedAtNull::class) + ); + } + + public function testNotTouchingModelWithoutTimestamps() + { + $this->assertTrue( + Model::isIgnoringTouch(ModelWithoutTimestamps::class) + ); + } + + public function testGetOriginalCastsAttributes() + { + $model = new CastingStub(); + $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 CastingStub(); + $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 CastingStub(); + $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 CastingStub(); + $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 CastingStub(); + + $this->assertEquals('int', $model->getCasts()['castStringableObject']); + } + + public function testMergeingStringableObjectCastUSesStringRepresentation() + { + $stringable = new StringableCastBuilder(); + $stringable->cast = 'test'; + + $model = (new CastingStub())->mergeCasts([ + 'something' => $stringable, + ]); + + $this->assertEquals('test', $model->getCasts()['something']); + } + + public function testUsingPlainObjectAsCastThrowsException() + { + $model = new CastingStub(); + + $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 ModelStub([ + '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 ModelWithPrimitiveCasts(); + + $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 ModelStub([ + '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 ModelStub(['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 ModelStub(['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 ModelWithMutators(); + $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 ModelWithMutators(); + $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 ModelWithCollectedByAttribute(); + $collection = $model->newCollection([$model]); + + $this->assertInstanceOf(CustomEloquentCollection::class, $collection); + } + + public function testUseFactoryAttribute() + { + $model = new ModelWithUseFactoryAttribute(); + $instance = ModelWithUseFactoryAttribute::factory()->make(['name' => 'test name']); + $factory = ModelWithUseFactoryAttribute::factory(); + $this->assertInstanceOf(ModelWithUseFactoryAttribute::class, $instance); + $this->assertInstanceOf(ModelWithUseFactoryAttributeFactory::class, $model::factory()); + $this->assertInstanceOf(ModelWithUseFactoryAttributeFactory::class, $model::newFactory()); + $this->assertEquals(ModelWithUseFactoryAttribute::class, $factory->modelName()); + $this->assertEquals('test name', $instance->name); // Small smoke test to ensure the factory is working + } + + public function testUseCustomBuilderWithUseEloquentBuilderAttribute() + { + $model = new ModelWithUseEloquentBuilderAttributeStub(); + + $query = $this->createMock(BaseBuilder::class); + $eloquentBuilder = $model->newEloquentBuilder($query); + + $this->assertInstanceOf(CustomBuilder::class, $eloquentBuilder); + } + + public function testDefaultBuilderIsUsedWhenUseEloquentBuilderAttributeIsNotPresent() + { + $model = new ModelWithoutUseEloquentBuilderAttributeStub(); + + $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 ModelWithUseEloquentBuilderAttributeStub extends Model +{ +} + +class ModelWithoutUseEloquentBuilderAttributeStub extends Model +{ +} + +class TestObserverStub +{ + public function creating() + { + } + + public function saved() + { + } +} + +class TestAnotherObserverStub +{ + public function creating() + { + } + + public function saved() + { + } +} + +class TestThirdObserverStub +{ + public function creating() + { + } + + public function saved() + { + } +} + +class ModelStub 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(SaveStub::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(SaveStub::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 ModelStubWithTrait extends ModelStub +{ + use FooBarTrait; +} + +class CamelStub extends ModelStub +{ + public static bool $snakeAttributes = false; +} + +class DateModelStub extends ModelStub +{ + public function getDates(): array + { + return ['created_at', 'updated_at']; + } +} + +class SaveStub 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 KeyTypeModelStub extends ModelStub +{ + protected string $keyType = 'string'; +} + +class FindWithWritePdoStub extends Model +{ + public function newQuery(): Builder + { + $mock = m::mock(Builder::class); + $mock->shouldReceive('useWritePdo')->once()->andReturnSelf(); + $mock->shouldReceive('find')->once()->with(1)->andReturn(m::mock(Model::class)); + + return $mock; + } +} + +class DestroyStub 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); + $model = m::mock(Model::class); + $model->shouldReceive('delete')->once(); + $mock->shouldReceive('get')->once()->andReturn(new Collection([$model])); + + return $mock; + } +} + +class EmptyDestroyStub extends Model +{ + public function newQuery(): Builder + { + $mock = m::mock(Builder::class); + $mock->shouldReceive('whereIn')->never(); + + return $mock; + } +} + +class WithStub extends Model +{ + public function newQuery(): Builder + { + $mock = m::mock(Builder::class); + $mock->shouldReceive('with')->once()->with(['foo', 'bar'])->andReturnSelf(); + + return $mock; + } +} + +class WithWhereHasStub extends Model +{ + public function foo() + { + return $this->hasMany(ModelStub::class); + } +} + +class WithoutRelationStub extends Model +{ + public array $with = ['foo']; + + protected array $guarded = []; + + public function getEagerLoads() + { + return $this->eagerLoads; + } +} + +class ModelWithoutTableStub extends Model +{ +} + +class BootingTestStub extends Model +{ + public static function unboot() + { + unset(static::$booted[static::class], static::$bootedCallbacks[static::class]); + } + + public static function isBooted() + { + return array_key_exists(static::class, static::$booted); + } +} + +class AppendsStub 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 GetMutatorsStub 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 CastingStub 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 EnumCastingStub extends Model +{ + protected array $casts = ['enumAttribute' => StringStatus::class]; +} + +class DynamicHiddenStub extends Model +{ + protected ?string $table = 'stub'; + + protected array $guarded = []; + + public function getHidden(): array + { + return ['age', 'id']; + } +} + +class VisibleStub extends Model +{ + protected ?string $table = 'stub'; + + protected array $visible = ['foo']; +} + +class HiddenStub extends Model +{ + protected ?string $table = 'stub'; + + protected array $hidden = ['foo']; +} + +class DynamicVisibleStub extends Model +{ + protected ?string $table = 'stub'; + + protected array $guarded = []; + + public function getVisible(): array + { + return ['name', 'id']; + } +} + +class NonIncrementingStub extends Model +{ + protected ?string $table = 'stub'; + + protected array $guarded = []; + + public bool $incrementing = false; +} + +class NoConnectionModelStub extends ModelStub +{ +} + +class DifferentConnectionModelStub extends ModelStub +{ + public UnitEnum|string|null $connection = 'different_connection'; +} + +class PrimaryUuidModelStub extends ModelStub +{ + use HasUuids; + + public bool $incrementing = false; + + protected string $keyType = 'string'; + + public function getKeyName(): string + { + return 'uuid'; + } +} + +class NonPrimaryUuidModelStub extends ModelStub +{ + use HasUuids; + + public function getKeyName(): string + { + return 'id'; + } + + public function uniqueIds(): array + { + return ['uuid']; + } +} + +class PrimaryUlidModelStub extends ModelStub +{ + use HasUlids; + + public bool $incrementing = false; + + protected string $keyType = 'string'; + + public function getKeyName(): string + { + return 'ulid'; + } +} + +class NonPrimaryUlidModelStub extends ModelStub +{ + use HasUlids; + + public function getKeyName(): string + { + return 'id'; + } + + public function uniqueIds(): array + { + return ['ulid']; + } +} + +#[ObservedBy(TestObserverStub::class)] +class ModelWithObserveAttributeStub extends ModelStub +{ +} + +#[ObservedBy([TestObserverStub::class])] +class ModelWithObserveAttributeUsingArrayStub extends ModelStub +{ +} + +#[ObservedBy([TestObserverStub::class])] +class ModelWithObserveAttributeGrandparentStub extends ModelStub +{ +} + +#[ObservedBy([TestAnotherObserverStub::class])] +class ModelWithObserveAttributeParentStub extends ModelWithObserveAttributeGrandparentStub +{ +} + +#[ObservedBy([TestThirdObserverStub::class])] +class ModelWithObserveAttributeGrandchildStub extends ModelWithObserveAttributeParentStub +{ +} + +class SavingEventStub +{ +} + +class EventObjectStub extends Model +{ + protected array $dispatchesEvents = [ + 'saving' => SavingEventStub::class, + ]; +} + +class ModelWithoutTimestamps extends Model +{ + protected ?string $table = 'stub'; + + public bool $timestamps = false; +} + +class ModelWithUpdatedAtNull extends Model +{ + protected ?string $table = 'stub'; + + public 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 ModelWithPrimitiveCasts 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 RecursiveRelationshipsStub 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 ModelWithMutators 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 ModelWithCollectedByAttribute extends Model +{ +} + +class CustomEloquentCollection extends Collection +{ +} + +class ModelWithUseFactoryAttributeFactory extends Factory +{ + public function definition(): array + { + return []; + } +} + +#[UseFactory(ModelWithUseFactoryAttributeFactory::class)] +class ModelWithUseFactoryAttribute extends Model +{ + use HasFactory; +} + +trait TraitBootingCallbackTestStub +{ + public static function bootTraitBootingCallbackTestStub() + { + static::whenBooted(fn () => static::$bootHasFinished = true); + } +} + +class BootingCallbackTestStub extends Model +{ + use TraitBootingCallbackTestStub; + + public static bool $bootHasFinished = false; + + public static function unboot() + { + unset(static::$booted[static::class], static::$bootedCallbacks[static::class]); + + static::$bootHasFinished = false; + } +} + +class ChildBootingCallbackTestStub extends BootingCallbackTestStub +{ + 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..eddfe34c9 --- /dev/null +++ b/tests/Database/Laravel/DatabaseEloquentMorphOneOfManyTest.php @@ -0,0 +1,263 @@ +addConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + ]); + + $db->bootEloquent(); + $db->setAsGlobal(); + + $this->createSchema(); + } + + /** + * Setup the database schema. + */ + public function createSchema(): void + { + $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. + */ + protected function tearDown(): void + { + $this->schema()->drop('products'); + $this->schema()->drop('states'); + + parent::tearDown(); + } + + public function testEagerLoadingAppliesConstraintsToInnerJoinSubQuery() + { + $product = Product::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 = Product::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 = Product::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 = Product::create(); + $state = $product->states()->forceCreate([ + 'state' => 'active', + ]); + + $this->assertNotNull($state); + $this->assertSame(Product::class, $product->current_state->stateful_type); + } + + public function testExists() + { + $product = Product::create(); + $previousState = $product->states()->create([ + 'state' => 'draft', + ]); + $currentState = $product->states()->create([ + 'state' => 'active', + ]); + + $exists = Product::whereHas('current_state', function ($q) use ($previousState) { + $q->whereKey($previousState->getKey()); + })->exists(); + $this->assertFalse($exists); + + $exists = Product::whereHas('current_state', function ($q) use ($currentState) { + $q->whereKey($currentState->getKey()); + })->exists(); + $this->assertTrue($exists); + } + + public function testWithWhereHas() + { + $product = Product::create(); + $previousState = $product->states()->create([ + 'state' => 'draft', + ]); + $currentState = $product->states()->create([ + 'state' => 'active', + ]); + + $exists = Product::withWhereHas('current_state', function ($q) use ($previousState) { + $q->whereKey($previousState->getKey()); + })->exists(); + $this->assertFalse($exists); + + $exists = Product::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 = Product::create(); + $currentState = $product->states()->create([ + 'state' => 'active', + ]); + + $exists = Product::withWhereRelation('current_state', 'state', 'active')->exists(); + $this->assertTrue($exists); + + $exists = Product::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 = Product::create(); + + $product = Product::withExists('current_state')->first(); + $this->assertFalse($product->current_state_exists); + + $product->states()->create([ + 'state' => 'draft', + ]); + $product = Product::withExists('current_state')->first(); + $this->assertTrue($product->current_state_exists); + } + + public function testWithExistsWithConstraintsInJoinSubSelect() + { + $product = Product::create(); + + $product = Product::withExists('current_foo_state')->first(); + $this->assertFalse($product->current_foo_state_exists); + + $product->states()->create([ + 'state' => 'draft', + 'type' => 'foo', + ]); + $product = Product::withExists('current_foo_state')->first(); + $this->assertTrue($product->current_foo_state_exists); + } + + /** + * Get a database connection instance. + */ + protected function connection(): \Hypervel\Database\Connection + { + return Eloquent::getConnectionResolver()->connection(); + } + + /** + * Get a schema builder instance. + */ + protected function schema(): \Hypervel\Database\Schema\Builder + { + return $this->connection()->getSchemaBuilder(); + } +} + +/** + * Eloquent Models... + */ +class Product extends Eloquent +{ + protected ?string $table = 'products'; + + protected array $guarded = []; + + public bool $timestamps = false; + + public function states() + { + return $this->morphMany(State::class, 'stateful'); + } + + public function current_state() + { + return $this->morphOne(State::class, 'stateful')->ofMany(); + } + + public function current_foo_state() + { + return $this->morphOne(State::class, 'stateful')->ofMany( + ['id' => 'max'], + function ($q) { + $q->where('type', 'foo'); + } + ); + } +} + +class State extends Eloquent +{ + protected ?string $table = 'states'; + + protected array $guarded = []; + + public bool $timestamps = false; + + protected array $fillable = ['state', 'type']; +} diff --git a/tests/Database/Laravel/DatabaseEloquentMorphTest.php b/tests/Database/Laravel/DatabaseEloquentMorphTest.php new file mode 100755 index 000000000..888e3b513 --- /dev/null +++ b/tests/Database/Laravel/DatabaseEloquentMorphTest.php @@ -0,0 +1,543 @@ +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 ResetModelStub(); + $model1->id = 1; + $model2 = new ResetModelStub(); + $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 ResetModelStub(); + $model1->id = 1; + $model2 = new ResetModelStub(); + $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'] + )->andReturn(1); + + $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'] + )->andReturn(2); + + $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 ResetModelStub extends Model +{ +} diff --git a/tests/Database/Laravel/DatabaseEloquentMorphToManyTest.php b/tests/Database/Laravel/DatabaseEloquentMorphToManyTest.php new file mode 100644 index 000000000..e5ed7654f --- /dev/null +++ b/tests/Database/Laravel/DatabaseEloquentMorphToManyTest.php @@ -0,0 +1,147 @@ +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 ModelStub(); + $model1->id = 1; + $model2 = new ModelStub(); + $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(QueryBuilder::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(QueryBuilder::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(3); + $relation->getQuery()->getQuery()->shouldReceive('newQuery')->once()->andReturn($query); + $relation->expects($this->once())->method('touchIfTouching'); + + $this->assertSame(3, $relation->detach([1, 2, 3])); + } + + public function testDetachMethodClearsAllPivotRecordsWhenNoIDsAreGiven(): void + { + $relation = $this->getMockBuilder(MorphToMany::class)->onlyMethods(['touchIfTouching'])->setConstructorArgs($this->getRelationArguments())->getMock(); + $query = m::mock(QueryBuilder::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(1); + $relation->getQuery()->getQuery()->shouldReceive('newQuery')->once()->andReturn($query); + $relation->expects($this->once())->method('touchIfTouching'); + + $this->assertSame(1, $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(); + $queryBuilder = m::mock(QueryBuilder::class); + $queryBuilder->shouldReceive('getGrammar')->andReturn($grammar); + $builder->shouldReceive('getQuery')->andReturn($queryBuilder); + + return [$builder, $parent, 'taggable', 'taggables', 'taggable_id', 'tag_id', 'id', 'id', 'relation_name', false]; + } +} + +class ModelStub extends Model +{ + protected array $guarded = []; +} diff --git a/tests/Database/Laravel/DatabaseEloquentMorphToTest.php b/tests/Database/Laravel/DatabaseEloquentMorphToTest.php new file mode 100644 index 000000000..fce154c3a --- /dev/null +++ b/tests/Database/Laravel/DatabaseEloquentMorphToTest.php @@ -0,0 +1,432 @@ +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([]); + $connection->shouldReceive('getPostProcessor')->andReturn($processor = m::mock(Processor::class)); + $connection->shouldReceive('query')->andReturnUsing(function () use ($connection, $grammar, $processor) { + return new QueryBuilder($connection, $grammar, $processor); + }); + } + + public function testLookupDictionaryIsProperlyConstructedForEnums() + { + $relation = $this->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() + { + $this->addMockConnection(new ModelStub()); + + $relation = $this->getRelation()->withDefault(); + + $this->builder->shouldReceive('first')->once()->andReturnNull(); + + $newModel = new ModelStub(); + + $this->assertEquals($newModel, $relation->getResults()); + } + + public function testMorphToWithDynamicDefault() + { + $this->addMockConnection(new ModelStub()); + + $relation = $this->getRelation()->withDefault(function ($newModel) { + $newModel->username = 'taylor'; + }); + + $this->builder->shouldReceive('first')->once()->andReturnNull(); + + $newModel = new ModelStub(); + $newModel->username = 'taylor'; + + $result = $relation->getResults(); + + $this->assertEquals($newModel, $result); + + $this->assertSame('taylor', $result->username); + } + + public function testMorphToWithArrayDefault() + { + $this->addMockConnection(new ModelStub()); + + $relation = $this->getRelation()->withDefault(['username' => 'taylor']); + + $this->builder->shouldReceive('first')->once()->andReturnNull(); + + $newModel = new ModelStub(); + $newModel->username = 'taylor'; + + $result = $relation->getResults(); + + $this->assertEquals($newModel, $result); + + $this->assertSame('taylor', $result->username); + } + + public function testMorphToWithZeroMorphType() + { + $parent = $this->getMockBuilder(ModelStub::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(ModelStub::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() + { + $this->addMockConnection(new RelatedStub()); + + $parent = new ModelStub(); + $parent->relation_type = RelatedStub::class; + + $relation = $parent->relation()->withDefault(); + + $newModel = new RelatedStub(); + + $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 ModelStub(); + + return m::mock(MorphTo::class . '[createModelByType]', [$this->builder, $parent, 'foreign_key', 'id', 'morph_type', 'relation']); + } +} + +class ModelStub extends Model +{ + public string $foreign_key = 'foreign.value'; + + protected ?string $table = 'model_stubs'; + + public function relation() + { + return $this->morphTo(); + } +} + +class RelatedStub extends Model +{ + protected ?string $table = 'related_stubs'; +} diff --git a/tests/Database/Laravel/DatabaseEloquentPivotTest.php b/tests/Database/Laravel/DatabaseEloquentPivotTest.php new file mode 100755 index 000000000..8882861b3 --- /dev/null +++ b/tests/Database/Laravel/DatabaseEloquentPivotTest.php @@ -0,0 +1,228 @@ +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 = MutatorStub::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 = JsonCastStub::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 = MutatorStub::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 = DateStub::fromAttributes($parent, ['foo' => 'bar', 'created_at' => 'foo'], 'table'); + $this->assertTrue($pivot->timestamps); + + $pivot = DateStub::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(Builder::class); + $query->shouldReceive('where')->once()->with(['foreign' => 'foreign.value', 'other' => 'other.value'])->andReturn($query); + $query->shouldReceive('delete')->once()->andReturn(1); + $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(); + + $parentModel = m::mock(Model::class); + $original->pivotParent = $parentModel; + $original->setRelation('bar', 'baz'); + + $this->assertSame('baz', $original->getRelation('bar')); + + $pivot = $original->withoutRelations(); + + $this->assertInstanceOf(Pivot::class, $pivot); + $this->assertNotSame($pivot, $original); + $this->assertSame($parentModel, $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 DateStub extends Pivot +{ + public function getDates(): array + { + return []; + } +} + +class MutatorStub extends Pivot +{ + private $mutatorCalled = false; + + public function setFooAttribute($value) + { + $this->mutatorCalled = true; + + return $value; + } + + public function getMutatorCalled() + { + return $this->mutatorCalled; + } +} + +class JsonCastStub extends Pivot +{ + protected array $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..d73d83982 --- /dev/null +++ b/tests/Database/Laravel/DatabaseEloquentPolymorphicIntegrationTest.php @@ -0,0 +1,300 @@ +addConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + ]); + + $db->bootEloquent(); + $db->setAsGlobal(); + + $this->createSchema(); + } + + /** + * Setup the database schema. + */ + public function createSchema(): void + { + $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. + */ + protected function tearDown(): void + { + $this->schema()->drop('users'); + $this->schema()->drop('posts'); + $this->schema()->drop('comments'); + $this->schema()->drop('likes'); + + parent::tearDown(); + } + + public function testItLoadsRelationshipsAutomatically() + { + $this->seedData(); + + $like = LikeWithSingleWith::first(); + + $this->assertTrue($like->relationLoaded('likeable')); + $this->assertEquals(Comment::first(), $like->likeable); + } + + public function testItLoadsChainedRelationshipsAutomatically() + { + $this->seedData(); + + $like = LikeWithSingleWith::first(); + + $this->assertTrue($like->likeable->relationLoaded('commentable')); + $this->assertEquals(Post::first(), $like->likeable->commentable); + } + + public function testItLoadsNestedRelationshipsAutomatically() + { + $this->seedData(); + + $like = LikeWithNestedWith::first(); + + $this->assertTrue($like->relationLoaded('likeable')); + $this->assertTrue($like->likeable->relationLoaded('owner')); + + $this->assertEquals(User::first(), $like->likeable->owner); + } + + public function testItLoadsNestedRelationshipsOnDemand() + { + $this->seedData(); + + $like = Like::with('likeable.owner')->first(); + + $this->assertTrue($like->relationLoaded('likeable')); + $this->assertTrue($like->likeable->relationLoaded('owner')); + + $this->assertEquals(User::first(), $like->likeable->owner); + } + + public function testItLoadsNestedMorphRelationshipsOnDemand() + { + $this->seedData(); + + Post::first()->likes()->create([]); + + $likes = Like::with('likeable.owner')->get()->loadMorph('likeable', [ + Comment::class => ['commentable'], + Post::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(); + + Post::first()->likes()->create([]); + Comment::first()->likes()->create([]); + + $likes = Like::with('likeable.owner')->get()->loadMorphCount('likeable', [ + Comment::class => ['likes'], + Post::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(): void + { + $taylor = User::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. + */ + protected function connection(): \Hypervel\Database\Connection + { + return Eloquent::getConnectionResolver()->connection(); + } + + /** + * Get a schema builder instance. + */ + protected function schema(): \Hypervel\Database\Schema\Builder + { + return $this->connection()->getSchemaBuilder(); + } +} + +/** + * Eloquent Models... + */ +class User extends Eloquent +{ + protected ?string $table = 'users'; + + protected array $guarded = []; + + public function posts() + { + return $this->hasMany(Post::class, 'user_id'); + } +} + +class Post extends Eloquent +{ + protected ?string $table = 'posts'; + + protected array $guarded = []; + + public function comments() + { + return $this->morphMany(Comment::class, 'commentable'); + } + + public function owner() + { + return $this->belongsTo(User::class, 'user_id'); + } + + public function likes() + { + return $this->morphMany(Like::class, 'likeable'); + } +} + +class Comment extends Eloquent +{ + protected ?string $table = 'comments'; + + protected array $guarded = []; + + protected array $with = ['commentable']; + + public function owner() + { + return $this->belongsTo(User::class, 'user_id'); + } + + public function commentable() + { + return $this->morphTo(); + } + + public function likes() + { + return $this->morphMany(Like::class, 'likeable'); + } +} + +class Like extends Eloquent +{ + protected ?string $table = 'likes'; + + protected array $guarded = []; + + public function likeable() + { + return $this->morphTo(); + } +} + +class LikeWithSingleWith extends Eloquent +{ + protected ?string $table = 'likes'; + + protected array $guarded = []; + + protected array $with = ['likeable']; + + public function likeable() + { + return $this->morphTo(); + } +} + +class LikeWithNestedWith extends Eloquent +{ + protected ?string $table = 'likes'; + + protected array $guarded = []; + + protected array $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..5a4abfcf1 --- /dev/null +++ b/tests/Database/Laravel/DatabaseEloquentPolymorphicRelationsIntegrationTest.php @@ -0,0 +1,192 @@ +addConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + ]); + + $db->bootEloquent(); + $db->setAsGlobal(); + + $this->createSchema(); + } + + protected function createSchema(): void + { + $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('tag_id'); + $table->integer('taggable_id'); + $table->string('taggable_type'); + }); + } + + /** + * Tear down the database schema. + */ + 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 = Post::create(); + $image = Image::create(); + $tag = Tag::create(); + $tag2 = Tag::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 = Post::create(); + $tag = Tag::create(); + $post->tags()->attach($tag->id); + + $post = Post::with('tags')->whereId(1)->first(); + $tag = Tag::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 = Post::create(); + $tag1 = Tag::create(); + $tag2 = Tag::create(); + $tag3 = Tag::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(Tag::class, $tags->first()); + $count += $tags->count(); + ++$iterations; + }); + + $this->assertEquals(2, $iterations); + $this->assertEquals(3, $count); + } + + /** + * Get a database connection instance. + */ + protected function connection(string $connection = 'default'): \Hypervel\Database\Connection + { + return Eloquent::getConnectionResolver()->connection($connection); + } + + /** + * Get a schema builder instance. + */ + protected function schema(string $connection = 'default'): \Hypervel\Database\Schema\Builder + { + return $this->connection($connection)->getSchemaBuilder(); + } +} + +/** + * Eloquent Models... + */ +class Post extends Eloquent +{ + protected ?string $table = 'posts'; + + protected array $guarded = []; + + public function tags() + { + return $this->morphToMany(Tag::class, 'taggable'); + } +} + +class Image extends Eloquent +{ + protected ?string $table = 'images'; + + protected array $guarded = []; + + public function tags() + { + return $this->morphToMany(Tag::class, 'taggable'); + } +} + +class Tag extends Eloquent +{ + protected ?string $table = 'tags'; + + protected array $guarded = []; + + public function posts() + { + return $this->morphedByMany(Post::class, 'taggable'); + } + + public function images() + { + return $this->morphedByMany(Image::class, 'taggable'); + } +} diff --git a/tests/Database/Laravel/DatabaseEloquentRelationTest.php b/tests/Database/Laravel/DatabaseEloquentRelationTest.php new file mode 100755 index 000000000..cdc9b243c --- /dev/null +++ b/tests/Database/Laravel/DatabaseEloquentRelationTest.php @@ -0,0 +1,384 @@ +setRelation('test', $relation); + $parent->setRelation('foo', 'bar'); + $this->assertArrayNotHasKey('foo', $parent->toArray()); + } + + public function testUnsetExistingRelation() + { + $parent = new ResetModelStub(); + $relation = new ResetModelStub(); + $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(NoTouchingModelStub::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])->andReturn(1); + + $relation->touch(); + } + + public function testCanDisableParentTouchingForAllModels() + { + /** @var \Illuminate\Tests\Database\NoTouchingModelStub $related */ + $related = m::mock(NoTouchingModelStub::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(NoTouchingModelStub::class)->makePartial(); + $related->shouldReceive('getUpdatedAtColumn')->never(); + $related->shouldReceive('freshTimestampString')->never(); + + $anotherRelated = m::mock(NoTouchingAnotherModelStub::class)->makePartial(); + + $this->assertFalse($related::isIgnoringTouch()); + $this->assertFalse($anotherRelated::isIgnoringTouch()); + + NoTouchingModelStub::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])->andReturn(1); + + $anotherRelation->touch(); + }); + + $this->assertFalse($related::isIgnoringTouch()); + $this->assertFalse($anotherRelated::isIgnoringTouch()); + } + + public function testParentModelIsNotTouchedWhenChildModelIsIgnored() + { + $related = m::mock(NoTouchingModelStub::class)->makePartial(); + $related->shouldReceive('getUpdatedAtColumn')->never(); + $related->shouldReceive('freshTimestampString')->never(); + + $relatedChild = m::mock(NoTouchingChildModelStub::class)->makePartial(); + $relatedChild->shouldReceive('getUpdatedAtColumn')->never(); + $relatedChild->shouldReceive('freshTimestampString')->never(); + + $this->assertFalse($related::isIgnoringTouch()); + $this->assertFalse($relatedChild::isIgnoringTouch()); + + NoTouchingModelStub::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(NoTouchingModelStub::class)->makePartial(); + $related->shouldReceive('getUpdatedAtColumn')->never(); + $related->shouldReceive('freshTimestampString')->never(); + + $relatedChild = m::mock(NoTouchingChildModelStub::class)->makePartial(); + $relatedChild->shouldReceive('getUpdatedAtColumn')->never(); + $relatedChild->shouldReceive('freshTimestampString')->never(); + + $this->assertFalse($related::isIgnoringTouch()); + $this->assertFalse($relatedChild::isIgnoringTouch()); + + try { + NoTouchingModelStub::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([ResetModelStub::class]); + + $this->assertEquals([ + 'reset' => ResetModelStub::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 NoTouchingModelStub(); + + $original->setRelation('foo', 'baz'); + + $this->assertSame('baz', $original->getRelation('foo')); + + $model = $original->withoutRelations(); + + $this->assertInstanceOf(NoTouchingModelStub::class, $model); + $this->assertTrue($original->relationLoaded('foo')); + $this->assertFalse($model->relationLoaded('foo')); + + $model = $original->unsetRelations(); + + $this->assertInstanceOf(NoTouchingModelStub::class, $model); + $this->assertFalse($original->relationLoaded('foo')); + $this->assertFalse($model->relationLoaded('foo')); + } + + public function testMacroable() + { + Relation::macro('foo', function () { + return 'foo'; + }); + + $model = new ResetModelStub(); + $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([]); + $connection->shouldReceive('getPostProcessor')->andReturn($processor = m::mock(Processor::class)); + $connection->shouldReceive('query')->andReturnUsing(function () use ($connection, $grammar, $processor) { + return new QueryBuilder($connection, $grammar, $processor); + }); + + $relation = new RelationStub($model->newQuery(), $model); + + $result = $relation->foo(); + $this->assertSame('foo', $result); + } + + public function testIsRelationIgnoresAttribute() + { + $model = new RelationAndAttributeModelStub(); + + $this->assertTrue($model->isRelation('parent')); + $this->assertFalse($model->isRelation('field')); + } +} + +class ResetModelStub extends Model +{ + protected ?string $table = 'reset'; + + // Override method call which would normally go through __call() + + public function getQuery() + { + return $this->newQuery()->getQuery(); + } +} + +class RelationStub extends Relation +{ + public function addConstraints(): void + { + } + + public function addEagerConstraints(array $models): void + { + } + + public function initRelation(array $models, string $relation): array + { + return []; + } + + public function match(array $models, Collection $results, string $relation): array + { + return []; + } + + public function getResults(): mixed + { + return null; + } +} + +class NoTouchingModelStub extends Model +{ + protected ?string $table = 'table'; + + protected array $attributes = [ + 'id' => 1, + ]; +} + +class NoTouchingChildModelStub extends NoTouchingModelStub +{ +} + +class NoTouchingAnotherModelStub extends Model +{ + protected ?string $table = 'another_table'; + + protected array $attributes = [ + 'id' => 2, + ]; +} + +class RelationAndAttributeModelStub extends Model +{ + protected ?string $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..7d9944f51 --- /dev/null +++ b/tests/Database/Laravel/DatabaseEloquentRelationshipsTest.php @@ -0,0 +1,574 @@ +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 RelationshipsCustomPost(); + + $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 RelationshipsPost(); + $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(): string + { + 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(): string + { + 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(): string + { + 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(): string + { + 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 MockedConnectionModel extends Model +{ + 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')->andReturn(false); + $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 FakeRelationship extends MockedConnectionModel +{ +} + +class RelationshipsPost extends MockedConnectionModel +{ + 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 RelationshipsCustomPost extends RelationshipsPost +{ + protected function newBelongsTo(Builder $query, Model $child, string $foreignKey, string $ownerKey, string $relation): BelongsTo + { + return new CustomBelongsTo($query, $child, $foreignKey, $ownerKey, $relation); + } + + protected function newHasMany(Builder $query, Model $parent, string $foreignKey, string $localKey): HasMany + { + return new CustomHasMany($query, $parent, $foreignKey, $localKey); + } + + protected function newHasOne(Builder $query, Model $parent, string $foreignKey, string $localKey): HasOne + { + return new CustomHasOne($query, $parent, $foreignKey, $localKey); + } + + protected function newMorphOne(Builder $query, Model $parent, string $type, string $id, string $localKey): MorphOne + { + return new CustomMorphOne($query, $parent, $type, $id, $localKey); + } + + protected function newMorphMany(Builder $query, Model $parent, string $type, string $id, string $localKey): MorphMany + { + return new CustomMorphMany($query, $parent, $type, $id, $localKey); + } + + protected function newBelongsToMany( + Builder $query, + Model $parent, + string $table, + string $foreignPivotKey, + string $relatedPivotKey, + string $parentKey, + string $relatedKey, + ?string $relationName = null + ): BelongsToMany { + return new CustomBelongsToMany($query, $parent, $table, $foreignPivotKey, $relatedPivotKey, $parentKey, $relatedKey, $relationName); + } + + protected function newHasManyThrough( + Builder $query, + Model $farParent, + Model $throughParent, + string $firstKey, + string $secondKey, + string $localKey, + string $secondLocalKey + ): HasManyThrough { + return new CustomHasManyThrough($query, $farParent, $throughParent, $firstKey, $secondKey, $localKey, $secondLocalKey); + } + + protected function newHasOneThrough( + Builder $query, + Model $farParent, + Model $throughParent, + string $firstKey, + string $secondKey, + string $localKey, + string $secondLocalKey + ): HasOneThrough { + return new CustomHasOneThrough($query, $farParent, $throughParent, $firstKey, $secondKey, $localKey, $secondLocalKey); + } + + 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 CustomMorphToMany( + $query, + $parent, + $name, + $table, + $foreignPivotKey, + $relatedPivotKey, + $parentKey, + $relatedKey, + $relationName, + $inverse + ); + } + + protected function newMorphTo(Builder $query, Model $parent, string $foreignKey, ?string $ownerKey, string $type, string $relation): MorphTo + { + 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 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..8cd82ca2b --- /dev/null +++ b/tests/Database/Laravel/DatabaseEloquentResourceCollectionTest.php @@ -0,0 +1,82 @@ +toResourceCollection(EloquentResourceCollectionTestResource::class); + + $this->assertInstanceOf(JsonResource::class, $resource); + } + + public function testItThrowsExceptionWhenResourceCannotBeFound() + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Failed to find resource class for model [Hypervel\Tests\Database\Laravel\Fixtures\Models\EloquentResourceCollectionTestModel].'); + + $collection = new Collection([ + new EloquentResourceCollectionTestModel(), + ]); + $collection->toResourceCollection(); + } + + public function testItCanGuessResourceWhenNotProvided() + { + $collection = new Collection([ + new EloquentResourceCollectionTestModel(), + ]); + + class_alias(EloquentResourceCollectionTestResource::class, 'Hypervel\Tests\Database\Laravel\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..8b2b29f65 --- /dev/null +++ b/tests/Database/Laravel/DatabaseEloquentResourceModelTest.php @@ -0,0 +1,80 @@ +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 [Hypervel\Tests\Database\Laravel\Fixtures\Models\EloquentResourceTestResourceModel].'); + + $model = new EloquentResourceTestResourceModel(); + $model->toResource(); + } + + public function testItCanGuessResourceWhenNotProvided() + { + $model = new EloquentResourceTestResourceModelWithGuessableResource(); + + class_alias(EloquentResourceTestJsonResource::class, 'Hypervel\Tests\Database\Laravel\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, 'Hypervel\Tests\Database\Laravel\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([ + 'Hypervel\Tests\Database\Laravel\Fixtures\Http\Resources\EloquentResourceTestResourceModelResource', + 'Hypervel\Tests\Database\Laravel\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..3e2542e40 --- /dev/null +++ b/tests/Database/Laravel/DatabaseEloquentSoftDeletesIntegrationTest.php @@ -0,0 +1,1161 @@ +addConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + ]); + + $db->bootEloquent(); + $db->setAsGlobal(); + + $this->createSchema(); + } + + /** + * Setup the database schema. + */ + 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. + */ + 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 = User::all(); + + $this->assertCount(1, $users); + $this->assertEquals(2, $users->first()->id); + $this->assertNull(User::find(1)); + } + + public function testSoftDeletesAreNotRetrievedFromBaseQuery() + { + $this->createUsers(); + + $query = User::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 = User::query(); + $query->chunk(2, function ($user) use (&$count) { + $count += count($user); + }); + $this->assertEquals(1, $count); + + $query = User::query(); + $this->assertCount(1, $query->pluck('email')->all()); + + Paginator::currentPageResolver(function () { + return 1; + }); + + CursorPaginator::currentCursorResolver(function () { + return null; + }); + + $query = User::query(); + $this->assertCount(1, $query->paginate(2)->all()); + + $query = User::query(); + $this->assertCount(1, $query->simplePaginate(2)->all()); + + $query = User::query(); + $this->assertCount(1, $query->cursorPaginate(2)->all()); + + $this->assertEquals(0, User::where('email', 'taylorotwell@gmail.com')->increment('id')); + $this->assertEquals(0, User::where('email', 'taylorotwell@gmail.com')->decrement('id')); + } + + public function testWithTrashedReturnsAllRecords() + { + $this->createUsers(); + + $this->assertCount(2, User::withTrashed()->get()); + $this->assertInstanceOf(Eloquent::class, User::withTrashed()->find(1)); + } + + public function testWithTrashedAcceptsAnArgument() + { + $this->createUsers(); + + $this->assertCount(1, User::withTrashed(false)->get()); + $this->assertCount(2, User::withTrashed(true)->get()); + } + + public function testDeleteSetsDeletedColumn() + { + $this->createUsers(); + + $this->assertInstanceOf(Carbon::class, User::withTrashed()->find(1)->deleted_at); + $this->assertNull(User::find(2)->deleted_at); + } + + public function testForceDeleteActuallyDeletesRecords() + { + $this->createUsers(); + User::find(2)->forceDelete(); + + $users = User::withTrashed()->get(); + + $this->assertCount(1, $users); + $this->assertEquals(1, $users->first()->id); + } + + public function testForceDeleteUpdateExistsProperty() + { + $this->createUsers(); + $user = User::find(2); + + $this->assertTrue($user->exists); + + $user->forceDelete(); + + $this->assertFalse($user->exists); + } + + public function testForceDeleteDoesntUpdateExistsPropertyIfFailed() + { + $user = new class extends User { + public bool $exists = true; + + public function newModelQuery(): \Hypervel\Database\Eloquent\Builder + { + $mock = m::mock(\Hypervel\Database\Eloquent\Builder::class); + $mock->shouldReceive('where')->andReturnSelf(); + $mock->shouldReceive('forceDelete')->andThrow(new Exception()); + + return $mock; + } + }; + + $this->assertTrue($user->exists); + + try { + $user->forceDelete(); + } catch (Exception) { + } + + $this->assertTrue($user->exists); + } + + public function testForceDestroyFullyDeletesRecord() + { + $this->createUsers(); + $deleted = User::forceDestroy(2); + + $this->assertSame(1, $deleted); + + $users = User::withTrashed()->get(); + + $this->assertCount(1, $users); + $this->assertEquals(1, $users->first()->id); + $this->assertNull(User::find(2)); + } + + public function testForceDestroyDeletesAlreadyDeletedRecord() + { + $this->createUsers(); + $deleted = User::forceDestroy(1); + + $this->assertSame(1, $deleted); + + $users = User::withTrashed()->get(); + + $this->assertCount(1, $users); + $this->assertEquals(2, $users->first()->id); + $this->assertNull(User::find(1)); + } + + public function testForceDestroyDeletesMultipleRecords() + { + $this->createUsers(); + $deleted = User::forceDestroy([1, 2]); + + $this->assertSame(2, $deleted); + + $this->assertTrue(User::withTrashed()->get()->isEmpty()); + } + + public function testForceDestroyDeletesRecordsFromCollection() + { + $this->createUsers(); + $deleted = User::forceDestroy(collect([1, 2])); + + $this->assertSame(2, $deleted); + + $this->assertTrue(User::withTrashed()->get()->isEmpty()); + } + + public function testForceDestroyDeletesRecordsFromEloquentCollection() + { + $this->createUsers(); + $deleted = User::forceDestroy(User::all()); + + $this->assertSame(1, $deleted); + + $users = User::withTrashed()->get(); + + $this->assertCount(1, $users); + $this->assertEquals(1, $users->first()->id); + $this->assertNull(User::find(2)); + } + + public function testRestoreRestoresRecords() + { + $this->createUsers(); + $taylor = User::withTrashed()->find(1); + + $this->assertTrue($taylor->trashed()); + + $taylor->restore(); + + $users = User::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 = User::onlyTrashed()->get(); + + $this->assertCount(1, $users); + $this->assertEquals(1, $users->first()->id); + } + + public function testOnlyWithoutTrashedOnlyReturnsTrashedRecords() + { + $this->createUsers(); + + $users = User::withoutTrashed()->get(); + + $this->assertCount(1, $users); + $this->assertEquals(2, $users->first()->id); + + $users = User::withTrashed()->withoutTrashed()->get(); + + $this->assertCount(1, $users); + $this->assertEquals(2, $users->first()->id); + } + + public function testFirstOrNew() + { + $this->createUsers(); + + $result = User::firstOrNew(['email' => 'taylorotwell@gmail.com']); + $this->assertNull($result->id); + + $result = User::withTrashed()->firstOrNew(['email' => 'taylorotwell@gmail.com']); + $this->assertEquals(1, $result->id); + } + + public function testFindOrNew() + { + $this->createUsers(); + + $result = User::findOrNew(1); + $this->assertNull($result->id); + + $result = User::withTrashed()->findOrNew(1); + $this->assertEquals(1, $result->id); + } + + public function testFirstOrCreate() + { + $this->createUsers(); + + $result = User::withTrashed()->firstOrCreate(['email' => 'taylorotwell@gmail.com']); + $this->assertSame('taylorotwell@gmail.com', $result->email); + $this->assertCount(1, User::all()); + + $result = User::firstOrCreate(['email' => 'foo@bar.com']); + $this->assertSame('foo@bar.com', $result->email); + $this->assertCount(2, User::all()); + $this->assertCount(3, User::withTrashed()->get()); + } + + public function testCreateOrFirst() + { + $this->createUsers(); + + $result = User::withTrashed()->createOrFirst(['email' => 'taylorotwell@gmail.com']); + $this->assertSame('taylorotwell@gmail.com', $result->email); + $this->assertCount(1, User::all()); + + $result = User::createOrFirst(['email' => 'foo@bar.com']); + $this->assertSame('foo@bar.com', $result->email); + $this->assertCount(2, User::all()); + $this->assertCount(3, User::withTrashed()->get()); + } + + /** + * @throws Exception + */ + public function testUpdateModelAfterSoftDeleting() + { + Carbon::setTestNow($now = Carbon::now()); + $this->createUsers(); + + /** @var User $userModel */ + $userModel = User::find(2); + $userModel->delete(); + $this->assertEquals($now->toDateTimeString(), $userModel->getOriginal('deleted_at')); + $this->assertNull(User::find(2)); + $this->assertEquals($userModel, User::withTrashed()->find(2)); + } + + /** + * @throws Exception + */ + public function testRestoreAfterSoftDelete() + { + $this->createUsers(); + + /** @var User $userModel */ + $userModel = User::find(2); + $userModel->delete(); + $userModel->restore(); + + $this->assertEquals($userModel->id, User::find(2)->id); + } + + /** + * @throws Exception + */ + public function testSoftDeleteAfterRestoring() + { + $this->createUsers(); + + /** @var User $userModel */ + $userModel = User::withTrashed()->find(1); + $userModel->restore(); + $this->assertEquals($userModel->deleted_at, User::find(1)->deleted_at); + $this->assertEquals($userModel->getOriginal('deleted_at'), User::find(1)->deleted_at); + $userModel->delete(); + $this->assertNull(User::find(1)); + $this->assertEquals($userModel->deleted_at, User::withTrashed()->find(1)->deleted_at); + $this->assertEquals($userModel->getOriginal('deleted_at'), User::withTrashed()->find(1)->deleted_at); + } + + public function testModifyingBeforeSoftDeletingAndRestoring() + { + $this->createUsers(); + + /** @var User $userModel */ + $userModel = User::find(2); + $userModel->email = 'foo@bar.com'; + $userModel->delete(); + $userModel->restore(); + + $this->assertEquals($userModel->id, User::find(2)->id); + $this->assertSame('foo@bar.com', User::find(2)->email); + } + + public function testUpdateOrCreate() + { + $this->createUsers(); + + $result = User::updateOrCreate(['email' => 'foo@bar.com'], ['email' => 'bar@baz.com']); + $this->assertSame('bar@baz.com', $result->email); + $this->assertCount(2, User::all()); + + $result = User::withTrashed()->updateOrCreate(['email' => 'taylorotwell@gmail.com'], ['email' => 'foo@bar.com']); + $this->assertSame('foo@bar.com', $result->email); + $this->assertCount(2, User::all()); + $this->assertCount(3, User::withTrashed()->get()); + } + + public function testHasOneRelationshipCanBeSoftDeleted() + { + $this->createUsers(); + + $abigail = User::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 = User::where('email', 'abigailotwell@gmail.com')->first(); + $group = Group::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 = User::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 = User::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 = User::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 = User::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 = User::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 User())->newQuery(); + $this->assertSame('select * from "users" where "users"."deleted_at" is null', $query->toSql()); + } + + public function testSecondLevelRelationshipCanBeSoftDeleted() + { + $this->createUsers(); + + $abigail = User::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 = User::where('email', 'abigailotwell@gmail.com')->first(); + $post = $abigail->posts()->create(['title' => 'First Title']); + + $users = User::where('email', 'taylorotwell@gmail.com')->has('posts')->get(); + $this->assertCount(0, $users); + + $users = User::where('email', 'abigailotwell@gmail.com')->has('posts')->get(); + $this->assertCount(1, $users); + + $users = User::where('email', 'doesnt@exist.com')->orHas('posts')->get(); + $this->assertCount(1, $users); + + $users = User::whereHas('posts', function ($query) { + $query->where('title', 'First Title'); + })->get(); + $this->assertCount(1, $users); + + $users = User::whereHas('posts', function ($query) { + $query->where('title', 'Another Title'); + })->get(); + $this->assertCount(0, $users); + + $users = User::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 = User::has('posts')->get(); + $this->assertCount(0, $users); + } + + public function testWhereHasWithNestedDeletedRelationshipAndOnlyTrashedCondition() + { + $this->createUsers(); + + $abigail = User::where('email', 'abigailotwell@gmail.com')->first(); + $post = $abigail->posts()->create(['title' => 'First Title']); + $post->delete(); + + $users = User::has('posts')->get(); + $this->assertCount(0, $users); + + $users = User::whereHas('posts', function ($q) { + $q->onlyTrashed(); + })->get(); + $this->assertCount(1, $users); + + $users = User::whereHas('posts', function ($q) { + $q->withTrashed(); + })->get(); + $this->assertCount(1, $users); + } + + public function testWhereHasWithNestedDeletedRelationship() + { + $this->createUsers(); + + $abigail = User::where('email', 'abigailotwell@gmail.com')->first(); + $post = $abigail->posts()->create(['title' => 'First Title']); + $comment = $post->comments()->create(['body' => 'Comment Body']); + $comment->delete(); + + $users = User::has('posts.comments')->get(); + $this->assertCount(0, $users); + + $users = User::doesntHave('posts.comments')->get(); + $this->assertCount(1, $users); + } + + public function testWhereDoesntHaveWithNestedDeletedRelationship() + { + $this->createUsers(); + + $users = User::doesntHave('posts.comments')->get(); + $this->assertCount(1, $users); + } + + public function testWhereHasWithNestedDeletedRelationshipAndWithTrashedCondition() + { + $this->createUsers(); + + $abigail = UserWithTrashedPosts::where('email', 'abigailotwell@gmail.com')->first(); + $post = $abigail->posts()->create(['title' => 'First Title']); + $post->delete(); + + $users = UserWithTrashedPosts::has('posts')->get(); + $this->assertCount(1, $users); + } + + public function testWithCountWithNestedDeletedRelationshipAndOnlyTrashedCondition() + { + $this->createUsers(); + + $abigail = User::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 = User::withCount('posts')->orderBy('postsCount', 'desc')->first(); + $this->assertEquals(2, $user->posts_count); + + $user = User::withCount(['posts' => function ($q) { + $q->onlyTrashed(); + }])->orderBy('postsCount', 'desc')->first(); + $this->assertEquals(1, $user->posts_count); + + $user = User::withCount(['posts' => function ($q) { + $q->withTrashed(); + }])->orderBy('postsCount', 'desc')->first(); + $this->assertEquals(3, $user->posts_count); + + $user = User::withCount(['posts' => function ($q) { + $q->withTrashed()->where('title', 'First Title'); + }])->orderBy('postsCount', 'desc')->first(); + $this->assertEquals(1, $user->posts_count); + + $user = User::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 = User::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 = User::where('email', 'abigailotwell@gmail.com')->first(); + $post1 = $abigail->posts()->create(['title' => 'First Title']); + $post1->comments()->create([ + 'body' => 'Comment Body', + 'owner_type' => User::class, + 'owner_id' => $abigail->id, + ]); + + $abigail->delete(); + + $comment = CommentWithTrashed::with(['owner' => function ($q) { + $q->withoutGlobalScope(SoftDeletingScope::class); + }])->first(); + + $this->assertEquals($abigail->email, $comment->owner->email); + + $comment = CommentWithTrashed::with(['owner' => function ($q) { + $q->withTrashed(); + }])->first(); + + $this->assertEquals($abigail->email, $comment->owner->email); + + $comment = CommentWithoutSoftDelete::with(['owner' => function ($q) { + $q->withTrashed(); + }])->first(); + + $this->assertEquals($abigail->email, $comment->owner->email); + } + + public function testMorphToWithBadMethodCall() + { + $this->expectException(BadMethodCallException::class); + + $this->createUsers(); + + $abigail = User::where('email', 'abigailotwell@gmail.com')->first(); + $post1 = $abigail->posts()->create(['title' => 'First Title']); + + $post1->comments()->create([ + 'body' => 'Comment Body', + 'owner_type' => User::class, + 'owner_id' => $abigail->id, + ]); + + CommentWithoutSoftDelete::with(['owner' => function ($q) { + $q->thisMethodDoesNotExist(); + }])->first(); + } + + public function testMorphToWithConstraints() + { + $this->createUsers(); + + $abigail = User::where('email', 'abigailotwell@gmail.com')->first(); + $post1 = $abigail->posts()->create(['title' => 'First Title']); + $post1->comments()->create([ + 'body' => 'Comment Body', + 'owner_type' => User::class, + 'owner_id' => $abigail->id, + ]); + + $comment = CommentWithTrashed::with(['owner' => function ($q) { + $q->where('email', 'taylorotwell@gmail.com'); + }])->first(); + + $this->assertNull($comment->owner); + } + + public function testMorphToWithoutConstraints() + { + $this->createUsers(); + + $abigail = User::where('email', 'abigailotwell@gmail.com')->first(); + $post1 = $abigail->posts()->create(['title' => 'First Title']); + $post1->comments()->create([ + 'body' => 'Comment Body', + 'owner_type' => User::class, + 'owner_id' => $abigail->id, + ]); + + $comment = CommentWithTrashed::with('owner')->first(); + + $this->assertEquals($abigail->email, $comment->owner->email); + + $abigail->delete(); + $comment = CommentWithTrashed::with('owner')->first(); + + $this->assertNull($comment->owner); + } + + public function testMorphToNonSoftDeletingModel() + { + $taylor = UserWithoutSoftDelete::create(['id' => 1, 'email' => 'taylorotwell@gmail.com']); + $post1 = $taylor->posts()->create(['title' => 'First Title']); + $post1->comments()->create([ + 'body' => 'Comment Body', + 'owner_type' => UserWithoutSoftDelete::class, + 'owner_id' => $taylor->id, + ]); + + $comment = CommentWithTrashed::with('owner')->first(); + + $this->assertEquals($taylor->email, $comment->owner->email); + + $taylor->delete(); + $comment = CommentWithTrashed::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, User::whereHas('self_referencing')->count()); + } + + /** + * Helpers... + * + * @return User[] + */ + protected function createUsers() + { + $taylor = User::create(['id' => 1, 'email' => 'taylorotwell@gmail.com', 'user_id' => 2]); + $abigail = User::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 UserWithoutSoftDelete extends Eloquent +{ + protected ?string $table = 'users'; + + protected array $guarded = []; + + public function posts() + { + return $this->hasMany(Post::class, 'user_id'); + } +} + +/** + * Eloquent Models... + */ +class User extends Eloquent +{ + use SoftDeletes; + + protected ?string $table = 'users'; + + protected array $guarded = []; + + public function self_referencing() + { + return $this->hasMany(User::class, 'user_id')->onlyTrashed(); + } + + public function posts() + { + return $this->hasMany(Post::class, 'user_id'); + } + + public function address() + { + return $this->hasOne(Address::class, 'user_id'); + } + + public function group() + { + return $this->belongsTo(Group::class, 'group_id'); + } +} + +class UserWithTrashedPosts extends Eloquent +{ + use SoftDeletes; + + protected ?string $table = 'users'; + + protected array $guarded = []; + + public function posts() + { + return $this->hasMany(Post::class, 'user_id')->withTrashed(); + } +} + +/** + * Eloquent Models... + */ +class Post extends Eloquent +{ + use SoftDeletes; + + protected ?string $table = 'posts'; + + protected array $guarded = []; + + public function comments() + { + return $this->hasMany(Comment::class, 'post_id'); + } +} + +/** + * Eloquent Models... + */ +class CommentWithoutSoftDelete extends Eloquent +{ + protected ?string $table = 'comments'; + + protected array $guarded = []; + + public function owner() + { + return $this->morphTo(); + } +} + +/** + * Eloquent Models... + */ +class Comment extends Eloquent +{ + use SoftDeletes; + + protected ?string $table = 'comments'; + + protected array $guarded = []; + + public function owner() + { + return $this->morphTo(); + } +} + +class CommentWithTrashed extends Eloquent +{ + use SoftDeletes; + + protected ?string $table = 'comments'; + + protected array $guarded = []; + + public function owner() + { + return $this->morphTo(); + } +} + +/** + * Eloquent Models... + */ +class Address extends Eloquent +{ + use SoftDeletes; + + protected ?string $table = 'addresses'; + + protected array $guarded = []; +} + +/** + * Eloquent Models... + */ +class Group extends Eloquent +{ + use SoftDeletes; + + protected ?string $table = 'groups'; + + protected array $guarded = []; + + public function users() + { + $this->hasMany(User::class); + } +} diff --git a/tests/Database/Laravel/DatabaseEloquentStrictMorphsTest.php b/tests/Database/Laravel/DatabaseEloquentStrictMorphsTest.php new file mode 100644 index 000000000..15a6e260f --- /dev/null +++ b/tests/Database/Laravel/DatabaseEloquentStrictMorphsTest.php @@ -0,0 +1,92 @@ +expectException(ClassMorphViolationException::class); + + $model = new ModelStub(); + + $model->getMorphClass(); + } + + public function testStrictModeDoesNotThrowExceptionWhenMorphMap() + { + $model = new ModelStub(); + + Relation::morphMap([ + 'test' => ModelStub::class, + ]); + + $morphName = $model->getMorphClass(); + $this->assertSame('test', $morphName); + } + + public function testMapsCanBeEnforcedInOneMethod() + { + $model = new ModelStub(); + + Relation::requireMorphMap(false); + + Relation::enforceMorphMap([ + 'test' => ModelStub::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 PivotStub(); + + $pivotModel->getMorphClass(); + } + + protected function tearDown(): void + { + Relation::morphMap([], false); + Relation::requireMorphMap(false); + + parent::tearDown(); + } +} + +class ModelStub extends Model +{ +} + +class PivotStub extends Pivot +{ +} diff --git a/tests/Database/Laravel/DatabaseEloquentTimestampsTest.php b/tests/Database/Laravel/DatabaseEloquentTimestampsTest.php new file mode 100644 index 000000000..c9837e9d7 --- /dev/null +++ b/tests/Database/Laravel/DatabaseEloquentTimestampsTest.php @@ -0,0 +1,338 @@ +addConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + ]); + + $db->bootEloquent(); + $db->setAsGlobal(); + + $this->createSchema(); + } + + /** + * Setup the database schema. + */ + 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. + */ + 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 ?string $table = 'users'; + + protected array $guarded = []; +} + +class UserWithCreated extends Eloquent +{ + public const UPDATED_AT = null; + + protected ?string $table = 'users_created_at'; + + protected array $guarded = []; + + protected ?string $dateFormat = 'U'; +} + +class UserWithUpdated extends Eloquent +{ + public const CREATED_AT = null; + + protected ?string $table = 'users_updated_at'; + + protected array $guarded = []; + + protected ?string $dateFormat = 'U'; +} diff --git a/tests/Database/Laravel/DatabaseEloquentWithAttributesPendingTest.php b/tests/Database/Laravel/DatabaseEloquentWithAttributesPendingTest.php new file mode 100644 index 000000000..3e0ab33fa --- /dev/null +++ b/tests/Database/Laravel/DatabaseEloquentWithAttributesPendingTest.php @@ -0,0 +1,163 @@ +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 array $guarded = []; + + protected array $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..c68d36651 --- /dev/null +++ b/tests/Database/Laravel/DatabaseEloquentWithAttributesTest.php @@ -0,0 +1,168 @@ +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 array $guarded = []; + + protected array $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..a81b6f760 --- /dev/null +++ b/tests/Database/Laravel/DatabaseEloquentWithCastsTest.php @@ -0,0 +1,139 @@ +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 \Hypervel\Database\Connection + */ + 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 Time extends Eloquent +{ + protected array $guarded = []; + + protected array $casts = [ + 'time' => 'datetime', + ]; +} + +class UniqueTime extends Eloquent +{ + protected array $guarded = []; + + protected array $casts = [ + 'time' => 'datetime', + ]; +} diff --git a/tests/Database/Laravel/DatabaseIntegrationTest.php b/tests/Database/Laravel/DatabaseIntegrationTest.php new file mode 100644 index 000000000..bae809bd3 --- /dev/null +++ b/tests/Database/Laravel/DatabaseIntegrationTest.php @@ -0,0 +1,62 @@ +listeners = []; + + $dispatcher = m::mock(Dispatcher::class); + $dispatcher->shouldReceive('listen')->andReturnUsing(function ($event, $callback) { + $this->listeners[$event] = $callback; + }); + $dispatcher->shouldReceive('dispatch')->andReturnUsing(function ($event) { + $eventClass = get_class($event); + if (isset($this->listeners[$eventClass])) { + ($this->listeners[$eventClass])($event); + } + }); + + $db = new DB(); + $db->addConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + ]); + $db->setAsGlobal(); + $db->setEventDispatcher($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..583061fa3 --- /dev/null +++ b/tests/Database/Laravel/DatabaseMariaDbBuilderTest.php @@ -0,0 +1,49 @@ +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..8c69fec01 --- /dev/null +++ b/tests/Database/Laravel/DatabaseMariaDbProcessorTest.php @@ -0,0 +1,38 @@ + '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..53c186d85 --- /dev/null +++ b/tests/Database/Laravel/DatabaseMariaDbQueryGrammarTest.php @@ -0,0 +1,31 @@ +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..cfbda79e5 --- /dev/null +++ b/tests/Database/Laravel/DatabaseMariaDbSchemaBuilderTest.php @@ -0,0 +1,50 @@ +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..6d224db13 --- /dev/null +++ b/tests/Database/Laravel/DatabaseMariaDbSchemaGrammarTest.php @@ -0,0 +1,1586 @@ +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..cd55e680d --- /dev/null +++ b/tests/Database/Laravel/DatabaseMariaDbSchemaStateTest.php @@ -0,0 +1,94 @@ +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..398a1acca --- /dev/null +++ b/tests/Database/Laravel/DatabaseMigrationCreatorTest.php @@ -0,0 +1,112 @@ +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/DatabaseMigrationRepositoryTest.php b/tests/Database/Laravel/DatabaseMigrationRepositoryTest.php new file mode 100755 index 000000000..17a5dea07 --- /dev/null +++ b/tests/Database/Laravel/DatabaseMigrationRepositoryTest.php @@ -0,0 +1,123 @@ +getRepository(); + $query = m::mock(QueryBuilder::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(QueryBuilder::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(QueryBuilder::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(QueryBuilder::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(QueryBuilder::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(SchemaBuilder::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/DatabaseMySQLSchemaBuilderTest.php b/tests/Database/Laravel/DatabaseMySQLSchemaBuilderTest.php new file mode 100755 index 000000000..c0c906312 --- /dev/null +++ b/tests/Database/Laravel/DatabaseMySQLSchemaBuilderTest.php @@ -0,0 +1,50 @@ +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..1a8acac0c --- /dev/null +++ b/tests/Database/Laravel/DatabaseMySqlBuilderTest.php @@ -0,0 +1,49 @@ +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..785bc7881 --- /dev/null +++ b/tests/Database/Laravel/DatabaseMySqlProcessorTest.php @@ -0,0 +1,38 @@ + '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..ef0847c22 --- /dev/null +++ b/tests/Database/Laravel/DatabaseMySqlQueryGrammarTest.php @@ -0,0 +1,31 @@ +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..1c796d485 --- /dev/null +++ b/tests/Database/Laravel/DatabaseMySqlSchemaGrammarTest.php @@ -0,0 +1,1757 @@ +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..5e60aa07d --- /dev/null +++ b/tests/Database/Laravel/DatabaseMySqlSchemaStateTest.php @@ -0,0 +1,140 @@ +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 = null; + $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..c64e66611 --- /dev/null +++ b/tests/Database/Laravel/DatabasePostgresBuilderTest.php @@ -0,0 +1,301 @@ +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..7387cc358 --- /dev/null +++ b/tests/Database/Laravel/DatabasePostgresProcessorTest.php @@ -0,0 +1,42 @@ + '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..37a9f2c91 --- /dev/null +++ b/tests/Database/Laravel/DatabasePostgresQueryGrammarTest.php @@ -0,0 +1,76 @@ +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..708fa95eb --- /dev/null +++ b/tests/Database/Laravel/DatabasePostgresSchemaBuilderTest.php @@ -0,0 +1,49 @@ +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..ccb92f047 --- /dev/null +++ b/tests/Database/Laravel/DatabasePostgresSchemaGrammarTest.php @@ -0,0 +1,1397 @@ +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]); + } + + /** @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; + } + + #[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); + } + + 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..db155f4a9 --- /dev/null +++ b/tests/Database/Laravel/DatabaseProcessorTest.php @@ -0,0 +1,45 @@ +createMock(PDOStub::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 PDOStub 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..41a4cfa1d --- /dev/null +++ b/tests/Database/Laravel/DatabaseQueryBuilderTest.php @@ -0,0 +1,6730 @@ + '/'); + Paginator::currentPageResolver(fn () => 1); + CursorPaginator::currentCursorResolver(fn () => null); + + parent::tearDown(); + } + + public function testBasicSelect() + { + $builder = $this->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); + return []; + }); + $builder->getConnection()->shouldReceive('select')->once()->andReturnUsing(function ($sql) { + $this->assertSame('select "foo", "bar" from "users"', $sql); + return []; + }); + $builder->getConnection()->shouldReceive('select')->once()->andReturnUsing(function ($sql) { + $this->assertSame('select "baz" from "users"', $sql); + return []; + }); + + $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 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 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 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 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 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 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 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 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->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(); + } + + 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 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(\Hypervel\Database\Grammar $grammar): string|int|float + { + 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(TypeError::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(TypeError::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(TypeError::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(TypeError::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 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(TypeError::class); + $builder = $this->getBuilder(); + $builder->from('users')->leftJoinLateral(['foo'], 'sub'); + } + + 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 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(TypeError::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 testInsertOrIgnoreUsingMethod() + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('does not support'); + $builder = $this->getBuilder(); + $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(TypeError::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(TypeError::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(TypeError::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([]); + } + + 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); + } + + 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); + } + + 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 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 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); + } + + 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')->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')->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')->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 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 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->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()); + } + + 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 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(TypeError::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 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 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(new Collection($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(\Hypervel\Database\Grammar $grammar): string|int|float + { + 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 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 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 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 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 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(TypeError::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(TypeError::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 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 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 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 \Illuminate\Database\Query\Builder|\Mockery\MockInterface + */ + 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..9de1255d4 --- /dev/null +++ b/tests/Database/Laravel/DatabaseQueryExceptionTest.php @@ -0,0 +1,171 @@ +getMockConnection(); + + $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->getMockConnection(); + + $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_replica', 'SELECT * FROM users', [], $pdoException, [ + 'driver' => 'mysql', + 'name' => 'mysql_replica', + '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_replica', $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_replica', '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_replica', + 'host' => '192.168.1.10', + 'port' => '3306', + 'database' => 'laravel_db', + 'unix_socket' => null, + ]; + $exception = new QueryException('mysql_replica', '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 getMockConnection() + { + $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..66a7801e7 --- /dev/null +++ b/tests/Database/Laravel/DatabaseQueryGrammarTest.php @@ -0,0 +1,82 @@ +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..34f463076 --- /dev/null +++ b/tests/Database/Laravel/DatabaseSQLiteBuilderTest.php @@ -0,0 +1,77 @@ +shouldReceive('getSchemaGrammar')->once()->andReturn(new SQLiteGrammar($connection)); + + $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()->andReturn(new SQLiteGrammar($connection)); + + $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..804999286 --- /dev/null +++ b/tests/Database/Laravel/DatabaseSQLiteProcessorTest.php @@ -0,0 +1,42 @@ + '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..486a9354a --- /dev/null +++ b/tests/Database/Laravel/DatabaseSQLiteQueryGrammarTest.php @@ -0,0 +1,31 @@ +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..01fdb5496 --- /dev/null +++ b/tests/Database/Laravel/DatabaseSQLiteSchemaGrammarTest.php @@ -0,0 +1,1170 @@ +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..ed010038a --- /dev/null +++ b/tests/Database/Laravel/DatabaseSchemaBlueprintTest.php @@ -0,0 +1,694 @@ +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')); + } + + 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')); + } + + 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')); + } + + 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')); + } + + 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')); + } + + 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')); + } + + 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(\Hypervel\Foundation\Auth\User::class); + })->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(\Hypervel\Foundation\Auth\User::class)->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(\Hypervel\Foundation\Auth\User::class); + })->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(\Hypervel\Foundation\Auth\User::class); + })->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')); + } + + 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')); + } + + 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')); + } + + 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 = 'Hypervel\Database\Schema\Grammars\\' . $grammar . 'Grammar'; + $builderClass = 'Hypervel\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..51fed7192 --- /dev/null +++ b/tests/Database/Laravel/DatabaseSchemaBuilderIntegrationTest.php @@ -0,0 +1,106 @@ +db = new DB(); + + $this->db->addConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + ]); + + $this->db->setAsGlobal(); + } + + 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(): \Hypervel\Database\Schema\Builder + { + 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..c70e73434 --- /dev/null +++ b/tests/Database/Laravel/DatabaseSchemaBuilderTest.php @@ -0,0 +1,88 @@ +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(Grammar::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(Grammar::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..98acdbc06 --- /dev/null +++ b/tests/Database/Laravel/DatabaseSeederTest.php @@ -0,0 +1,91 @@ +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..f16a47940 --- /dev/null +++ b/tests/Database/Laravel/DatabaseSoftDeletingScopeTest.php @@ -0,0 +1,161 @@ +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(Model::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..ad27089fb --- /dev/null +++ b/tests/Database/Laravel/DatabaseSoftDeletingTest.php @@ -0,0 +1,73 @@ +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 array $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 array $casts = ['deleted_at' => 'string']; + }; + + $this->assertSame('2018-12-29 13:59:39', $model->deleted_at); + } +} + +class SoftDeletingModel extends Model +{ + use SoftDeletes; + + protected array $guarded = []; + + protected ?string $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..d04d8ec37 --- /dev/null +++ b/tests/Database/Laravel/DatabaseSoftDeletingTraitTest.php @@ -0,0 +1,129 @@ +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(Stub::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(Stub::class); + $model->makePartial(); + $model->shouldReceive('fireModelEvent')->with('restoring')->andReturn(false); + $model->shouldReceive('save')->never(); + + $this->assertFalse($model->restore()); + } +} + +class Stub +{ + 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(): bool + { + return true; + } + + 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..c24d0ab4f --- /dev/null +++ b/tests/Database/Laravel/DatabaseSqliteSchemaStateTest.php @@ -0,0 +1,62 @@ + '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); + $factoryCalledWith = null; + $processFactory = function (...$args) use ($process, &$factoryCalledWith) { + $factoryCalledWith = $args; + return $process; + }; + + $schemaState = new SqliteSchemaState($connection, null, $processFactory); + $schemaState->load('database/schema/sqlite-schema.dump'); + + $this->assertSame('sqlite3 "${:LARAVEL_LOAD_DATABASE}" < "${:LARAVEL_LOAD_PATH}"', $factoryCalledWith[0]); + + $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..5e7660fa1 --- /dev/null +++ b/tests/Database/Laravel/DatabaseTransactionsManagerTest.php @@ -0,0 +1,345 @@ +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..582de6abc --- /dev/null +++ b/tests/Database/Laravel/DatabaseTransactionsTest.php @@ -0,0 +1,261 @@ +addConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + ]); + + $db->addConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + ], 'second_connection'); + + $db->setAsGlobal(); + + $this->createSchema(); + } + + protected function createSchema(): void + { + 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. + */ + 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. + */ + protected function schema(string $connection = 'default'): \Hypervel\Database\Schema\Builder + { + return $this->connection($connection)->getSchemaBuilder(); + } + + public function connection(string $name = 'default'): \Hypervel\Database\Connection + { + return DB::connection($name); + } +} diff --git a/tests/Database/Laravel/EloquentHasOneOrManyDeprecationTest.php b/tests/Database/Laravel/EloquentHasOneOrManyDeprecationTest.php new file mode 100644 index 000000000..b4606b6b9 --- /dev/null +++ b/tests/Database/Laravel/EloquentHasOneOrManyDeprecationTest.php @@ -0,0 +1,101 @@ +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..0d07a716a --- /dev/null +++ b/tests/Database/Laravel/EloquentModelCustomCastingTest.php @@ -0,0 +1,515 @@ +addConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + ]); + + $db->bootEloquent(); + $db->setAsGlobal(); + + $this->createSchema(); + } + + /** + * Setup the database schema. + */ + 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. + */ + protected function tearDown(): void + { + $this->schema()->drop('casting_table'); + $this->schema()->drop('members'); + $this->schema()->drop('documents'); + $this->schema()->drop('people'); + + 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 null|string + */ + 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 null|string $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. + */ + public function serialize(Model $model, string $key, mixed $value, array $attributes): mixed + { + 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 null|string + */ + 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 null|string $value + * @param array $attributes + * @return string + */ + public function set($model, $key, $value, $attributes) + { + return $value ?? ''; + } +} + +/** + * Eloquent Models... + */ +class CustomCasts extends Eloquent +{ + protected ?string $table = 'casting_table'; + + protected array $guarded = []; + + protected array $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 bool $timestamps = false; + + protected array $casts = [ + 'amount' => Euro::class, + ]; +} + +class Document extends Model +{ + public bool $timestamps = false; + + protected array $casts = [ + 'document' => StructuredDocumentCaster::class, + ]; +} + +class Person extends Model +{ + protected array $guarded = ['id']; + + public bool $timestamps = false; + + protected array $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 $model, string $key, mixed $firstValue, mixed $secondValue): bool + { + return json_decode($firstValue) == json_decode($secondValue); + } +} + +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..109d609db --- /dev/null +++ b/tests/Database/Laravel/Fixtures/Models/EloquentModelUsingNonIncrementedInt.php @@ -0,0 +1,24 @@ + */ + 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..8a4ea16a8 --- /dev/null +++ b/tests/Database/Laravel/Fixtures/Models/User.php @@ -0,0 +1,15 @@ +=', 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 @@ + 'sqlite']); + $connection->setEventDispatcher($this->app->make(Dispatcher::class)); + $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:'), '', '', ['name' => 'sqlite']); + $connection->setEventDispatcher($this->app->make(Dispatcher::class)); + $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:'), '', '', ['name' => 'sqlite']); + $connection->setEventDispatcher($this->app->make(Dispatcher::class)); + $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:'), '', '', ['name' => 'sqlite']); + $connection->setEventDispatcher($this->app->make(Dispatcher::class)); + $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:'), '', '', ['name' => 'sqlite']); + $connection->setEventDispatcher($this->app->make(Dispatcher::class)); + $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($this->app->make(Dispatcher::class)); + $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:'), '', '', ['name' => 'sqlite']); + $connection->setEventDispatcher($this->app->make(Dispatcher::class)); + $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:'), '', '', ['name' => 'sqlite']); + $connection->setEventDispatcher($this->app->make(Dispatcher::class)); + $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:'), '', '', ['name' => 'sqlite']); + $connection->setEventDispatcher($this->app->make(Dispatcher::class)); + + $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:'), '', '', ['name' => 'sqlite']); + $connection->setEventDispatcher($this->app->make(Dispatcher::class)); + $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:'), '', '', ['name' => 'sqlite']); + $connection->setEventDispatcher($this->app->make(Dispatcher::class)); + $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/TableGuesserTest.php b/tests/Database/Laravel/TableGuesserTest.php new file mode 100644 index 000000000..fab6c6188 --- /dev/null +++ b/tests/Database/Laravel/TableGuesserTest.php @@ -0,0 +1,61 @@ +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/Todo/DatabaseMigrationInstallCommandTest.php b/tests/Database/Laravel/Todo/DatabaseMigrationInstallCommandTest.php new file mode 100755 index 000000000..8bc1b77f0 --- /dev/null +++ b/tests/Database/Laravel/Todo/DatabaseMigrationInstallCommandTest.php @@ -0,0 +1,54 @@ +markTestSkipped('Requires illuminate/console package to be ported first.'); + } + + public function testFireCallsRepositoryToInstall() + { + $command = new InstallCommand($repo = m::mock(MigrationRepositoryInterface::class)); + $command->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/Todo/DatabaseMigrationMakeCommandTest.php b/tests/Database/Laravel/Todo/DatabaseMigrationMakeCommandTest.php new file mode 100755 index 000000000..149d5a364 --- /dev/null +++ b/tests/Database/Laravel/Todo/DatabaseMigrationMakeCommandTest.php @@ -0,0 +1,129 @@ +markTestSkipped('Requires illuminate/console package to be ported first.'); + } + + public function testBasicCreateDumpsAutoload() + { + $command = new MigrateMakeCommand( + $creator = m::mock(MigrationCreator::class), + $composer = m::mock(Composer::class) + ); + $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 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/Todo/DatabaseMigrationMigrateCommandTest.php b/tests/Database/Laravel/Todo/DatabaseMigrationMigrateCommandTest.php new file mode 100755 index 000000000..f5bf13701 --- /dev/null +++ b/tests/Database/Laravel/Todo/DatabaseMigrationMigrateCommandTest.php @@ -0,0 +1,177 @@ +markTestSkipped('Requires illuminate/console package to be ported first.'); + } + + public function testBasicMigrationsCallMigratorWithProperArguments() + { + $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('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()); + } +} + +// TODO: Uncomment once illuminate/console package is ported +// 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/Todo/DatabaseMigrationRefreshCommandTest.php b/tests/Database/Laravel/Todo/DatabaseMigrationRefreshCommandTest.php new file mode 100755 index 000000000..61b06eca2 --- /dev/null +++ b/tests/Database/Laravel/Todo/DatabaseMigrationRefreshCommandTest.php @@ -0,0 +1,149 @@ +markTestSkipped('Requires illuminate/console package to be ported first.'); + } + + protected function tearDown(): void + { + RefreshCommand::prohibit(false); + + parent::tearDown(); + } + + public function testRefreshCommandCallsCommandsWithProperArguments() + { + $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); + + $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 ''; + } +} + +// TODO: Uncomment once illuminate/console package is ported +// 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/Todo/DatabaseMigrationResetCommandTest.php b/tests/Database/Laravel/Todo/DatabaseMigrationResetCommandTest.php new file mode 100755 index 000000000..a937741c0 --- /dev/null +++ b/tests/Database/Laravel/Todo/DatabaseMigrationResetCommandTest.php @@ -0,0 +1,108 @@ +markTestSkipped('Requires illuminate/console package to be ported first.'); + } + + protected function tearDown(): void + { + ResetCommand::prohibit(false); + + parent::tearDown(); + } + + public function testResetCommandCallsMigratorWithProperArguments() + { + $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(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()); + } +} + +// TODO: Uncomment once illuminate/console package is ported +// 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/Todo/DatabaseMigrationRollbackCommandTest.php b/tests/Database/Laravel/Todo/DatabaseMigrationRollbackCommandTest.php new file mode 100755 index 000000000..2ce92a6f1 --- /dev/null +++ b/tests/Database/Laravel/Todo/DatabaseMigrationRollbackCommandTest.php @@ -0,0 +1,113 @@ +markTestSkipped('Requires illuminate/console package to be ported first.'); + } + + public function testRollbackCommandCallsMigratorWithProperArguments() + { + $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' => 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()); + } +} + +// TODO: Uncomment once illuminate/console package is ported +// 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/Todo/DatabaseMigratorIntegrationTest.php b/tests/Database/Laravel/Todo/DatabaseMigratorIntegrationTest.php new file mode 100644 index 000000000..1b55bf124 --- /dev/null +++ b/tests/Database/Laravel/Todo/DatabaseMigratorIntegrationTest.php @@ -0,0 +1,310 @@ +markTestSkipped('Requires illuminate/console package to be ported first.'); + + $this->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/Todo/PruneCommandTest.php b/tests/Database/Laravel/Todo/PruneCommandTest.php new file mode 100644 index 000000000..c1a23ca01 --- /dev/null +++ b/tests/Database/Laravel/Todo/PruneCommandTest.php @@ -0,0 +1,285 @@ +markTestSkipped('Requires illuminate/console package to be ported first.'); + + Application::setInstance($container = new Application(__DIR__ . '/Pruning')); + + Closure::bind( + fn () => $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/Todo/SeedCommandTest.php b/tests/Database/Laravel/Todo/SeedCommandTest.php new file mode 100644 index 000000000..6e20effeb --- /dev/null +++ b/tests/Database/Laravel/Todo/SeedCommandTest.php @@ -0,0 +1,169 @@ +markTestSkipped('Requires illuminate/console package to be ported first.'); + } + + public function testHandle() + { + $input = new ArrayInput(['--force' => 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(); + } +} + +// TODO: Uncomment once illuminate/console package is ported +// class UserWithoutModelEventsSeeder extends Seeder +// { +// use WithoutModelEvents; +// +// public function run() +// { +// Assert::assertInstanceOf(NullDispatcher::class, Model::getEventDispatcher()); +// } +// } 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..439515371 --- /dev/null +++ b/tests/Database/Laravel/migrations/connection_configured/2022_02_21_000000_create_failed_jobs_table.php @@ -0,0 +1,39 @@ +id(); + $table->text('connection'); + $table->text('queue'); + $table->longText('payload'); + $table->longText('exception'); + $table->timestamp('failed_at')->useCurrent(); + }); + } + + /** + * Reverse the migrations. + */ + 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..b206d3c31 --- /dev/null +++ b/tests/Database/Laravel/migrations/connection_configured/2022_02_21_120000_create_jobs_table.php @@ -0,0 +1,33 @@ +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. + */ + 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/src/testbench/workbench/database/migrations/2023_08_03_000000_create_users_table.php b/tests/Database/Laravel/migrations/one/2016_01_01_000000_create_users_table.php similarity index 68% rename from src/testbench/workbench/database/migrations/2023_08_03_000000_create_users_table.php rename to tests/Database/Laravel/migrations/one/2016_01_01_000000_create_users_table.php index 358effaf8..db421c62d 100644 --- a/src/testbench/workbench/database/migrations/2023_08_03_000000_create_users_table.php +++ b/tests/Database/Laravel/migrations/one/2016_01_01_000000_create_users_table.php @@ -2,22 +2,23 @@ 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 { +class CreateUsersTable extends Migration +{ /** * Run the migrations. */ - public function up(): void + public function up() { Schema::create('users', function (Blueprint $table) { - $table->id(); + $table->increments('id'); $table->string('name'); $table->string('email')->unique(); - $table->timestamp('email_verified_at')->nullable(); $table->string('password'); + $table->rememberToken(); $table->timestamps(); }); } @@ -25,8 +26,8 @@ public function up(): void /** * Reverse the migrations. */ - public function down(): 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..659f5d4e5 --- /dev/null +++ b/tests/Database/Laravel/migrations/one/2016_01_01_100000_create_password_resets_table.php @@ -0,0 +1,30 @@ +string('email')->index(); + $table->string('token')->index(); + $table->timestamp('created_at'); + }); + } + + /** + * Reverse the migrations. + */ + 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..71c478a45 --- /dev/null +++ b/tests/Database/Laravel/migrations/should_run/2016_01_01_200000_create_flights_table.php @@ -0,0 +1,34 @@ +increments('id'); + $table->string('name'); + }); + } + + /** + * Reverse the migrations. + */ + 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..3a5434bfb --- /dev/null +++ b/tests/Database/Laravel/migrations/two/2016_01_01_200000_create_flights_table.php @@ -0,0 +1,29 @@ +increments('id'); + $table->string('name'); + }); + } + + /** + * Reverse the migrations. + */ + 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..dcc87da6d --- /dev/null +++ b/tests/Database/Laravel/stubs/EloquentModelNamespacedStub.php @@ -0,0 +1,11 @@ + 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/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/Dispatcher/AdaptedRequestHandlerTest.php b/tests/Dispatcher/AdaptedRequestHandlerTest.php index 176358559..e0d58405d 100644 --- a/tests/Dispatcher/AdaptedRequestHandlerTest.php +++ b/tests/Dispatcher/AdaptedRequestHandlerTest.php @@ -4,7 +4,7 @@ namespace Hypervel\Tests\Dispatcher; -use Hyperf\Context\Context; +use Hypervel\Context\Context; use Hypervel\Dispatcher\AdaptedRequestHandler; use Hypervel\Tests\TestCase; use Mockery as m; 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/Engine/BarrierTest.php b/tests/Engine/BarrierTest.php new file mode 100644 index 000000000..bf4475c79 --- /dev/null +++ b/tests/Engine/BarrierTest.php @@ -0,0 +1,35 @@ +assertSame($N, $count); + } +} diff --git a/tests/Engine/ChannelTest.php b/tests/Engine/ChannelTest.php new file mode 100644 index 000000000..90f7aba90 --- /dev/null +++ b/tests/Engine/ChannelTest.php @@ -0,0 +1,174 @@ +push($value); + } + + $actual[] = $channel->pop(); + $actual[] = $channel->pop(); + $actual[] = $channel->pop(); + + $this->assertSame($result, $actual); + } + + public function testChannelInCoroutine() + { + $id = uniqid(); + /** @var ChannelInterface $channel */ + $channel = new Channel(1); + Coroutine::create(function () use ($channel, $id) { + usleep(2000); + $channel->push($id); + }); + $t = microtime(true); + $this->assertSame($id, $channel->pop()); + $this->assertTrue((microtime(true) - $t) > 0.001); + } + + public function testChannelClose() + { + /** @var ChannelInterface $channel */ + $channel = new Channel(); + $this->assertFalse($channel->isClosing()); + Coroutine::create(function () use ($channel) { + usleep(1000); + $channel->close(); + }); + $this->assertFalse($channel->pop()); + $this->assertTrue($channel->isClosing()); + + $channel = new Channel(1); + Coroutine::create(function () use ($channel) { + $channel->close(); + }); + $this->assertTrue($channel->isClosing()); + } + + public function testChannelCloseAgain() + { + /** @var ChannelInterface $channel */ + $channel = new Channel(1); + $channel->close(); + $channel->close(); + + $this->assertTrue($channel->isClosing()); + $this->assertFalse($channel->isAvailable()); + } + + public function testPushClosedChannel() + { + /** @var ChannelInterface $channel */ + $channel = new Channel(10); + $channel->push(111); + $channel->close(); + $this->assertFalse($channel->isEmpty()); + $channel->push(123); + $this->assertTrue($channel->isClosing()); + $this->assertSame(111, $channel->pop()); + $this->assertSame(false, $channel->pop()); + } + + public function testChannelIsAvailable() + { + /** @var ChannelInterface $channel */ + $channel = new Channel(1); + $this->assertTrue($channel->isAvailable()); + $channel->close(); + $channel->pop(); + $this->assertFalse($channel->isAvailable()); + } + + public function testChannelTimeout() + { + /** @var ChannelInterface $channel */ + $channel = new Channel(1); + $channel->pop(0.001); + $this->assertTrue($channel->isTimeout()); + + $channel->push(true); + $channel->pop(0.001); + $this->assertFalse($channel->isTimeout()); + } + + public function testChannelPushTimeout() + { + /** @var ChannelInterface $channel */ + $channel = new Channel(1); + $this->assertSame(true, $channel->push(1, 1)); + $this->assertSame(false, $channel->push(1, 1)); + $this->assertTrue($channel->isTimeout()); + + $channel = new Channel(1); + $this->assertSame(true, $channel->push(1, 1.0)); + $this->assertSame(false, $channel->push(1, 1.0)); + $this->assertTrue($channel->isTimeout()); + } + + public function testChannelIsClosing() + { + /** @var ChannelInterface $channel */ + $channel = new Channel(1); + $channel->push(true); + $this->assertFalse($channel->isClosing()); + $this->assertFalse($channel->isTimeout()); + $this->assertTrue($channel->isAvailable()); + $channel->pop(); + $this->assertFalse($channel->isClosing()); + $this->assertFalse($channel->isTimeout()); + $this->assertTrue($channel->isAvailable()); + $channel->pop(0.001); + $this->assertFalse($channel->isClosing()); + $this->assertTrue($channel->isTimeout()); + $this->assertTrue($channel->isAvailable()); + $this->assertTrue($channel->close()); + $this->assertTrue($channel->isClosing()); + $this->assertFalse($channel->isTimeout()); + $this->assertFalse($channel->isAvailable()); + $channel->pop(); + $this->assertTrue($channel->isClosing()); + $this->assertFalse($channel->isTimeout()); + $this->assertFalse($channel->isAvailable()); + $channel->pop(0.001); + $this->assertTrue($channel->isClosing()); + $this->assertFalse($channel->isTimeout()); + $this->assertFalse($channel->isAvailable()); + } + + public function testSplId() + { + $obj = new stdClass(); + $chan = new Channel(1); + $chan->push($obj); + + $this->assertSame(spl_object_id($obj), spl_object_id($assert = $chan->pop())); + $this->assertSame(spl_object_hash($obj), spl_object_hash($assert)); + } +} diff --git a/tests/Engine/ConstantTest.php b/tests/Engine/ConstantTest.php new file mode 100644 index 000000000..ae5457369 --- /dev/null +++ b/tests/Engine/ConstantTest.php @@ -0,0 +1,31 @@ +assertSame('Swoole', Constant::ENGINE); + } + + public function testIsCoroutineServer() + { + $this->assertTrue(Constant::isCoroutineServer(new HttpServer('127.0.0.1'))); + $this->assertTrue(Constant::isCoroutineServer(new Server('127.0.0.1'))); + } +} diff --git a/tests/Engine/CoroutineTest.php b/tests/Engine/CoroutineTest.php new file mode 100644 index 000000000..daf69983f --- /dev/null +++ b/tests/Engine/CoroutineTest.php @@ -0,0 +1,181 @@ +assertTrue(true); + }); + + $coroutine->execute(); + + $this->assertInstanceOf(CoroutineInterface::class, $coroutine); + $this->assertIsInt($coroutine->getId()); + } + + public function testCoroutineCreateStatic() + { + $coroutine = Coroutine::create(function () { + $this->assertTrue(true); + }); + + $this->assertInstanceOf(CoroutineInterface::class, $coroutine); + $this->assertIsInt($coroutine->getId()); + } + + public function testCoroutineContext() + { + $id = uniqid(); + $coroutine = Coroutine::create(function () use ($id) { + $this->assertInstanceOf(ArrayObject::class, Coroutine::getContextFor()); + $this->assertFalse(isset(Coroutine::getContextFor()['name'])); + $this->assertSame(null, Coroutine::getContextFor()['name'] ?? null); + Coroutine::getContextFor()['name'] = $id; + $this->assertSame($id, Coroutine::getContextFor()['name']); + usleep(1000); + }); + + $this->assertSame($id, Coroutine::getContextFor($coroutine->getId())['name']); + + usleep(1000); + $this->assertNull(Coroutine::getContextFor($coroutine->getId())); + } + + public function testCoroutineId() + { + $this->assertIsInt($id = Coroutine::id()); + $this->assertGreaterThan(0, $id); + } + + public function testCoroutinePid() + { + $pid = Coroutine::id(); + Coroutine::create(function () use ($pid) { + $this->assertSame($pid, Coroutine::pid()); + $pid = Coroutine::id(); + $co = Coroutine::create(function () use ($pid) { + $this->assertSame($pid, Coroutine::pid(Coroutine::id())); + usleep(1000); + }); + Coroutine::create(function () use ($pid) { + $this->assertSame($pid, Coroutine::pid()); + }); + $this->assertSame($pid, Coroutine::pid($co->getId())); + }); + } + + public function testCoroutinePidHasBeenDestroyed() + { + $co = Coroutine::create(function () { + }); + + try { + Coroutine::pid($co->getId()); + $this->assertTrue(false); + } catch (Throwable $exception) { + $this->assertInstanceOf(CoroutineDestroyedException::class, $exception); + } + } + + public function testCoroutineInTopCoroutine() + { + $this->assertSame(0, Coroutine::pid()); + } + + public function testCoroutineDefer() + { + $channel = new Channel(2); + Coroutine::create(function () use ($channel) { + Coroutine::defer(function () use ($channel) { + $channel->push(2); + }); + + $channel->push(1); + }); + + $this->assertSame(1, $channel->pop()); + $this->assertSame(2, $channel->pop()); + } + + public function testTheOrderForCoroutineDefer() + { + $channel = new Channel(3); + Coroutine::create(function () use ($channel) { + Coroutine::defer(function () use ($channel) { + $channel->push(2); + }); + Coroutine::defer(function () use ($channel) { + $channel->push(3); + }); + + $channel->push(1); + }); + + $this->assertSame(1, $channel->pop()); + $this->assertSame(3, $channel->pop()); + $this->assertSame(2, $channel->pop()); + } + + public function testCoroutineResumeById() + { + $channel = new Channel(10); + Coroutine::create(function () use ($channel) { + $channel->push(1); + $co = Coroutine::create(function () use ($channel) { + $channel->push(2); + Coroutine::yield(); + $channel->push(3); + }); + $channel->push(4); + $res = Coroutine::resumeById($co->getId()); + $channel->push(5); + }); + + $this->assertSame(1, $channel->pop()); + $this->assertSame(2, $channel->pop()); + $this->assertSame(4, $channel->pop()); + $this->assertSame(3, $channel->pop()); + $this->assertSame(5, $channel->pop()); + } + + public function testCoroutineList() + { + $list = Coroutine::list(); + $this->assertIsIterable($list); + $this->assertContains(Coroutine::id(), $list); + } + + public function testCoroutineListCount() + { + Coroutine::create(function () { + sleep(1); + }); + Coroutine::create(function () { + sleep(1); + }); + Coroutine::create(function () { + sleep(1); + }); + $this->assertEquals(4, iterator_count(Coroutine::list())); + } +} diff --git a/tests/Engine/ExtensionTest.php b/tests/Engine/ExtensionTest.php new file mode 100644 index 000000000..e41e18c09 --- /dev/null +++ b/tests/Engine/ExtensionTest.php @@ -0,0 +1,23 @@ +assertTrue(Extension::isLoaded()); + } +} diff --git a/tests/Engine/HttpTest.php b/tests/Engine/HttpTest.php new file mode 100644 index 000000000..a80ab0fe2 --- /dev/null +++ b/tests/Engine/HttpTest.php @@ -0,0 +1,32 @@ + 'application/json'], 'Hello World'); + + $this->assertSame("GET / HTTP/1.1\r\nContent-Type: application/json\r\n\r\nHello World", $data); + } + + public function testHttpPackResponse() + { + $data = Http::packResponse(200, 'OK', ['Content-Type' => 'application/json'], 'Hello World'); + + $this->assertSame("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\r\nHello World", $data); + } +} diff --git a/tests/Engine/SignalTest.php b/tests/Engine/SignalTest.php new file mode 100644 index 000000000..f8df81178 --- /dev/null +++ b/tests/Engine/SignalTest.php @@ -0,0 +1,32 @@ +assertFalse($res); + + go(static function () { + sleep(1); + posix_kill(getmypid(), SIGUSR1); + }); + + $res = Signal::wait(SIGUSR1, 2); + $this->assertTrue($res); + } +} diff --git a/tests/Engine/SocketTest.php b/tests/Engine/SocketTest.php new file mode 100644 index 000000000..43feab927 --- /dev/null +++ b/tests/Engine/SocketTest.php @@ -0,0 +1,246 @@ +make(new Socket\SocketOption('127.0.0.1', 33333)); + } catch (SocketConnectException $exception) { + $this->assertSame(SOCKET_ECONNREFUSED, $exception->getCode()); + $this->assertSame('Connection refused', $exception->getMessage()); + } + + try { + (new Socket\SocketFactory())->make(new Socket\SocketOption('192.0.0.1', 9501, 1)); + } catch (SocketConnectException $exception) { + $this->assertSame(SOCKET_ETIMEDOUT, $exception->getCode()); + $this->assertStringContainsString('timed out', $exception->getMessage()); + } + } + + public function testSafeSocketSendAndRecvPacket() + { + $server = new Server('0.0.0.0', 9506); + $p = function (string $data): string { + return pack('N', strlen($data)) . $data; + }; + go(function () use ($server, $p) { + $server->set([ + 'open_length_check' => true, + 'package_max_length' => 1024 * 1024 * 2, + 'package_length_type' => 'N', + 'package_length_offset' => 0, + 'package_body_offset' => 4, + ]); + $server->handle(function (Server\Connection $connection) use ($p) { + $socket = new SafeSocket($connection->exportSocket(), 65535); + while (true) { + try { + $body = $socket->recvPacket(); + if (empty($body)) { + $socket->close(); + break; + } + go(function () use ($socket, $body, $p) { + $body = substr($body, 4); + if ($body === 'ping') { + $socket->sendAll($p('pong')); + } else { + $socket->sendAll($p($body)); + } + }); + } catch (Throwable $exception) { + $socket->close(); + $this->assertInstanceOf(SocketClosedException::class, $exception); + break; + } + } + }); + $server->start(); + }); + + sleep(1); + + $socket = (new Socket\SocketFactory())->make(new Socket\SocketOption('127.0.0.1', 9506, protocol: [ + 'open_length_check' => true, + 'package_max_length' => 1024 * 1024 * 2, + 'package_length_type' => 'N', + 'package_length_offset' => 0, + 'package_body_offset' => 4, + ])); + + for ($i = 0; $i < 200; ++$i) { + $res = $socket->sendAll($p(str_repeat('s', 10240)), 1); + } + + for ($i = 0; $i < 200; ++$i) { + $socket->recvPacket(1); + } + + $server->shutdown(); + } + + public function testSafeSocketBroken() + { + $server = new Server('0.0.0.0', 9506); + $p = function (string $data): string { + return pack('N', strlen($data)) . $data; + }; + go(function () use ($server, $p) { + $server->set([ + 'open_length_check' => true, + 'package_max_length' => 1024 * 1024 * 2, + 'package_length_type' => 'N', + 'package_length_offset' => 0, + 'package_body_offset' => 4, + ]); + $server->handle(function (Server\Connection $connection) use ($p) { + $socket = new SafeSocket($connection->exportSocket(), 65535); + while (true) { + try { + $body = $socket->recvPacket(); + if (empty($body)) { + $socket->close(); + break; + } + go(function () use ($socket, $body, $p) { + $body = substr($body, 4); + if ($body === 'ping') { + $socket->sendAll($p('pong')); + } else { + $socket->sendAll($p($body)); + } + }); + } catch (Throwable $exception) { + $socket->close(); + $this->assertInstanceOf(SocketClosedException::class, $exception); + break; + } + } + }); + $server->start(); + }); + + sleep(1); + + $socket = (new Socket\SocketFactory())->make(new Socket\SocketOption('127.0.0.1', 9506, protocol: [ + 'open_length_check' => true, + 'package_max_length' => 1024 * 1024 * 2, + 'package_length_type' => 'N', + 'package_length_offset' => 0, + 'package_body_offset' => 4, + ])); + + $socket->sendAll($p(str_repeat('s', 10240)), 1); + $socket->recvPacket(1); + $socket->sendAll($p(str_repeat('s', 10240)), 1); + $socket->recvPacket(1); + + $socket->close(); + + sleep(1); + + $server->shutdown(); + } + + public function testSafeSocketBrokenDontThrow() + { + $server = new Server('0.0.0.0', 9506); + $p = function (string $data): string { + return pack('N', strlen($data)) . $data; + }; + go(function () use ($server, $p) { + $server->set([ + 'open_length_check' => true, + 'package_max_length' => 1024 * 1024 * 2, + 'package_length_type' => 'N', + 'package_length_offset' => 0, + 'package_body_offset' => 4, + ]); + $server->handle(function (Server\Connection $connection) use ($p) { + $socket = new SafeSocket($connection->exportSocket(), 65535, false); + while (true) { + $body = $socket->recvPacket(); + if (empty($body)) { + $socket->close(); + break; + } + go(function () use ($socket, $body, $p) { + $body = substr($body, 4); + if ($body === 'ping') { + $socket->sendAll($p('pong')); + } else { + $socket->sendAll($p($body)); + } + }); + } + $this->assertTrue(true); + }); + $server->start(); + }); + + sleep(1); + + $socket = (new Socket\SocketFactory())->make(new Socket\SocketOption('127.0.0.1', 9506, protocol: [ + 'open_length_check' => true, + 'package_max_length' => 1024 * 1024 * 2, + 'package_length_type' => 'N', + 'package_length_offset' => 0, + 'package_body_offset' => 4, + ])); + + $socket->sendAll($p(str_repeat('s', 10240)), 1); + $socket->recvPacket(1); + $socket->sendAll($p(str_repeat('s', 10240)), 1); + $socket->recvPacket(1); + + $socket->close(); + + sleep(1); + + $server->shutdown(); + } + + public function testSocketGetOption() + { + $server = new Server('0.0.0.0', 9506); + + sleep(1); + + $socket = (new Socket\SocketFactory())->make($option = new Socket\SocketOption('127.0.0.1', 9506, protocol: [ + 'open_length_check' => true, + 'package_max_length' => 1024 * 1024 * 2, + 'package_length_type' => 'N', + 'package_length_offset' => 0, + 'package_body_offset' => 4, + ])); + + $this->assertSame($option, $socket->getSocketOption()); + + $socket->close(); + + sleep(1); + + $server->shutdown(); + } +} diff --git a/tests/Engine/WebSocketTest.php b/tests/Engine/WebSocketTest.php new file mode 100644 index 000000000..0a15f5656 --- /dev/null +++ b/tests/Engine/WebSocketTest.php @@ -0,0 +1,48 @@ +assertIsString($string = (string) $frame); + + $sf = new SwooleFrame(); + $sf->data = 'Hello World.'; + $frame = Frame::from($sf); + $this->assertSame($string, (string) $frame); + } + + public function testResponseGetFd() + { + $response = new Response(new stdClass()); + + $response->init(123); + $this->assertSame(123, $response->getFd()); + + $sf = new SwooleFrame(); + $sf->fd = 1234; + $response->init($sf); + $this->assertSame(1234, $response->getFd()); + } +} 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..954fa4056 100644 --- a/tests/Event/EventsDispatcherTest.php +++ b/tests/Event/EventsDispatcherTest.php @@ -6,12 +6,12 @@ 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; -use Mockery; +use Mockery as m; use Mockery\MockInterface; use Psr\Container\ContainerInterface; @@ -30,7 +30,7 @@ protected function setUp(): void { parent::setUp(); - $this->container = Mockery::mock(ContainerInterface::class); + $this->container = m::mock(ContainerInterface::class); } public function testBasicEventExecution() @@ -38,7 +38,7 @@ public function testBasicEventExecution() unset($_SERVER['__event.test']); $d = $this->getEventDispatcher(); - $d->listen('foo', function ($event, $foo) { + $d->listen('foo', function ($foo) { $_SERVER['__event.test'] = $foo; }); @@ -46,7 +46,7 @@ public function testBasicEventExecution() $this->assertSame('bar', $_SERVER['__event.test']); // we can still add listeners after the event has fired - $d->listen('foo', function ($event, $foo) { + $d->listen('foo', function ($foo) { $_SERVER['__event.test'] .= $foo; }); @@ -58,7 +58,7 @@ public function testDeferEventExecution() { unset($_SERVER['__event.test']); $d = $this->getEventDispatcher(); - $d->listen('foo', function ($event, $foo) { + $d->listen('foo', function ($foo) { $_SERVER['__event.test'] = $foo; }); @@ -77,10 +77,10 @@ public function testDeferMultipleEvents() { $_SERVER['__event.test'] = []; $d = $this->getEventDispatcher(); - $d->listen('foo', function ($event, $value) { + $d->listen('foo', function ($value) { $_SERVER['__event.test'][] = $value; }); - $d->listen('bar', function ($event, $value) { + $d->listen('bar', function ($value) { $_SERVER['__event.test'][] = $value; }); $d->defer(function () use ($d) { @@ -96,7 +96,7 @@ public function testDeferNestedEvents() { $_SERVER['__event.test'] = []; $d = $this->getEventDispatcher(); - $d->listen('foo', function ($event, $foo) { + $d->listen('foo', function ($foo) { $_SERVER['__event.test'][] = $foo; }); @@ -120,11 +120,11 @@ public function testDeferSpecificEvents() $_SERVER['__event.test'] = []; $d = $this->getEventDispatcher(); - $d->listen('foo', function ($event, $foo) { + $d->listen('foo', function ($foo) { $_SERVER['__event.test'][] = $foo; }); - $d->listen('bar', function ($event, $bar) { + $d->listen('bar', function ($bar) { $_SERVER['__event.test'][] = $bar; }); @@ -143,11 +143,11 @@ public function testDeferSpecificNestedEvents() $_SERVER['__event.test'] = []; $d = $this->getEventDispatcher(); - $d->listen('foo', function ($event, $foo) { + $d->listen('foo', function ($foo) { $_SERVER['__event.test'][] = $foo; }); - $d->listen('bar', function ($event, $bar) { + $d->listen('bar', function ($bar) { $_SERVER['__event.test'][] = $bar; }); @@ -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() @@ -226,10 +229,10 @@ public function testReturningFalseStopsPropagation() unset($_SERVER['__event.test']); $d = $this->getEventDispatcher(); - $d->listen('foo', function ($event, $foo) { + $d->listen('foo', function ($foo) { return $foo; }); - $d->listen('foo', function ($event, $foo) { + $d->listen('foo', function ($foo) { $_SERVER['__event.test'] = $foo; return false; @@ -306,17 +309,17 @@ public function testQueuedEventsAreFired() unset($_SERVER['__event.test']); $d = $this->getEventDispatcher(); - $d->listen('update', function ($event, $name) { + $d->listen('update', function ($name) { $_SERVER['__event.test'] = $name; }); $d->push('update', ['name' => 'taylor']); - $d->listen('update', function ($event, $name) { + $d->listen('update', function ($name) { $_SERVER['__event.test'] .= '_' . $name; }); $this->assertFalse(isset($_SERVER['__event.test'])); $d->flush('update'); - $d->listen('update', function ($event, $name) { + $d->listen('update', function ($name) { $_SERVER['__event.test'] .= $name; }); $this->assertSame('taylor_taylor', $_SERVER['__event.test']); @@ -328,7 +331,7 @@ public function testQueuedEventsCanBeForgotten() $d = $this->getEventDispatcher(); $d->push('update', ['name' => 'taylor']); - $d->listen('update', function ($event, $name) { + $d->listen('update', function ($name) { $_SERVER['__event.test'] = $name; }); @@ -344,7 +347,7 @@ public function testMultiplePushedEventsWillGetFlushed() $d = $this->getEventDispatcher(); $d->push('update', ['name' => 'taylor ']); $d->push('update', ['name' => 'otwell']); - $d->listen('update', function ($event, $name) { + $d->listen('update', function ($name) { $_SERVER['__event.test'] .= $name; }); @@ -358,7 +361,7 @@ public function testPushMethodCanAcceptObjectAsPayload() $d = $this->getEventDispatcher(); $d->push(ExampleEvent::class, $e = new ExampleEvent()); - $d->listen(ExampleEvent::class, function ($event, $payload) { + $d->listen(ExampleEvent::class, function ($payload) { $_SERVER['__event.test'] = $payload; }); @@ -806,18 +809,18 @@ public function testGetRawListeners() // Get raw listeners $rawListeners = $d->getRawListeners(); - // Assert that the raw listeners are as expected + // Assert that the raw listeners are as expected (now returns raw strings, not ListenerData) $this->assertArrayHasKey('event1', $rawListeners); $this->assertArrayHasKey('event2', $rawListeners); $this->assertArrayHasKey('event3', $rawListeners); - $this->assertSame('Listener1', $rawListeners['event1'][0]->listener); - $this->assertSame('Listener2', $rawListeners['event2'][0]->listener); - $this->assertSame('Listener3', $rawListeners['event3'][0]->listener); + $this->assertSame('Listener1', $rawListeners['event1'][0]); + $this->assertSame('Listener2', $rawListeners['event2'][0]); + $this->assertSame('Listener3', $rawListeners['event3'][0]); } public function testDispatchWithAfterCommit() { - $transactionResolver = Mockery::mock(TransactionManager::class); + $transactionResolver = m::mock(DatabaseTransactionsManager::class); $transactionResolver ->shouldReceive('addCallback') ->once(); @@ -826,7 +829,7 @@ public function testDispatchWithAfterCommit() $d->setTransactionManagerResolver(fn () => $transactionResolver); $listenerTriggered = false; - $d->listen(AfterCommitEvent::class, function ($event, $foo) use (&$listenerTriggered) { + $d->listen(AfterCommitEvent::class, function ($foo) use (&$listenerTriggered) { $listenerTriggered = true; }); @@ -870,7 +873,7 @@ public function __construct() $_SERVER['__event.test'][] = '__construct'; } - public function __invoke($event, $payload) + public function __invoke($payload) { $_SERVER['__event.test'][] = '__invoke_' . $payload; @@ -896,14 +899,14 @@ class AfterCommitEvent implements ShouldDispatchAfterCommit class TestEventListener { - public function onFooEvent($event, $foo, $bar) + public function onFooEvent($foo, $bar) { $_SERVER['__event.test'] = $foo; return 'baz'; } - public function handle($event, $foo, $bar) + public function handle($foo, $bar) { $_SERVER['__event.test'] = $bar; diff --git a/tests/Event/Hyperf/EventDispatcherTest.php b/tests/Event/Hyperf/EventDispatcherTest.php index 392412d96..b9fb4e4ce 100644 --- a/tests/Event/Hyperf/EventDispatcherTest.php +++ b/tests/Event/Hyperf/EventDispatcherTest.php @@ -4,10 +4,9 @@ namespace Hypervel\Tests\Event\Hyperf; -use Hyperf\Config\Config; -use Hyperf\Contract\ConfigInterface; use Hyperf\Contract\StdoutLoggerInterface; use Hyperf\Framework\Logger\StdoutLogger; +use Hypervel\Config\Repository; use Hypervel\Event\Contracts\ListenerProvider as ListenerProviderContract; use Hypervel\Event\EventDispatcher; use Hypervel\Event\EventDispatcherFactory; @@ -17,8 +16,8 @@ use Hypervel\Tests\Event\Hyperf\Listener\AlphaListener; use Hypervel\Tests\Event\Hyperf\Listener\BetaListener; use Hypervel\Tests\Event\Hyperf\Listener\PriorityListener; -use Mockery; use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration; +use Mockery as m; use PHPUnit\Framework\TestCase; use Psr\Container\ContainerInterface; use Psr\EventDispatcher\EventDispatcherInterface; @@ -35,14 +34,14 @@ class EventDispatcherTest extends TestCase public function testInvokeDispatcher() { - $listeners = Mockery::mock(ListenerProviderContract::class); + $listeners = m::mock(ListenerProviderContract::class); $this->assertInstanceOf(EventDispatcherInterface::class, new EventDispatcher($listeners)); } public function testInvokeDispatcherWithStdoutLogger() { - $listeners = Mockery::mock(ListenerProviderContract::class); - $logger = Mockery::mock(StdoutLoggerInterface::class); + $listeners = m::mock(ListenerProviderContract::class); + $logger = m::mock(StdoutLoggerInterface::class); $this->assertInstanceOf(EventDispatcherInterface::class, $instance = new EventDispatcher($listeners, $logger)); $reflectionClass = new ReflectionClass($instance); $loggerProperty = $reflectionClass->getProperty('logger'); @@ -51,9 +50,9 @@ public function testInvokeDispatcherWithStdoutLogger() public function testInvokeDispatcherByFactory() { - $container = Mockery::mock(ContainerInterface::class); - $container->shouldReceive('get')->with(ConfigInterface::class)->andReturn(new Config([])); - $config = $container->get(ConfigInterface::class); + $container = m::mock(ContainerInterface::class); + $container->shouldReceive('get')->with('config')->andReturn(new Repository([])); + $config = $container->get('config'); $container->shouldReceive('get')->with(PsrListenerProviderInterface::class)->andReturn(new ListenerProvider()); $container->shouldReceive('get')->with(StdoutLoggerInterface::class)->andReturn(new StdoutLogger($config)); $this->assertInstanceOf(EventDispatcherInterface::class, $instance = (new EventDispatcherFactory())($container)); @@ -75,7 +74,7 @@ public function testStoppable() public function testLoggerDump() { - $logger = Mockery::mock(StdoutLoggerInterface::class); + $logger = m::mock(StdoutLoggerInterface::class); $logger->shouldReceive('debug')->once(); $listenerProvider = new ListenerProvider(); $listenerProvider->on(Alpha::class, [new AlphaListener(), 'process']); @@ -83,20 +82,21 @@ public function testLoggerDump() $dispatcher->dispatch(new Alpha()); } - public function testListenersWithPriority() + public function testListenersCalledInRegistrationOrder(): void { + // Listeners are called in registration order (Laravel-style, no priority) PriorityEvent::$result = []; $listenerProvider = new ListenerProvider(); - $listenerProvider->on(PriorityEvent::class, [new PriorityListener(1), 'process'], 1); - $listenerProvider->on(PriorityEvent::class, [new PriorityListener(2), 'process'], 3); - $listenerProvider->on(PriorityEvent::class, [new PriorityListener(3), 'process'], 2); - $listenerProvider->on(PriorityEvent::class, [new PriorityListener(4), 'process'], 0); - $listenerProvider->on(PriorityEvent::class, [new PriorityListener(5), 'process'], 99); - $listenerProvider->on(PriorityEvent::class, [new PriorityListener(6), 'process'], -99); + $listenerProvider->on(PriorityEvent::class, [new PriorityListener(1), 'process']); + $listenerProvider->on(PriorityEvent::class, [new PriorityListener(2), 'process']); + $listenerProvider->on(PriorityEvent::class, [new PriorityListener(3), 'process']); + $listenerProvider->on(PriorityEvent::class, [new PriorityListener(4), 'process']); + $listenerProvider->on(PriorityEvent::class, [new PriorityListener(5), 'process']); + $listenerProvider->on(PriorityEvent::class, [new PriorityListener(6), 'process']); $dispatcher = new EventDispatcher($listenerProvider); $dispatcher->dispatch(new PriorityEvent()); - $this->assertSame([5, 2, 3, 1, 4, 6], PriorityEvent::$result); + $this->assertSame([1, 2, 3, 4, 5, 6], PriorityEvent::$result); } } diff --git a/tests/Event/Hyperf/ListenerProviderTest.php b/tests/Event/Hyperf/ListenerProviderTest.php index 15d800c7e..22d5b12ef 100644 --- a/tests/Event/Hyperf/ListenerProviderTest.php +++ b/tests/Event/Hyperf/ListenerProviderTest.php @@ -16,19 +16,21 @@ */ class ListenerProviderTest extends TestCase { - public function testListenNotExistEvent() + public function testListenNotExistEvent(): void { $provider = new ListenerProvider(); $provider->on(Alpha::class, [new AlphaListener(), 'process']); $provider->on('NotExistEvent', [new AlphaListener(), 'process']); - $it = $provider->getListenersForEvent(new Alpha()); - [$class, $method] = $it->current(); + $listeners = $provider->getListenersForEvent(new Alpha()); + $this->assertCount(1, $listeners); + $listenerData = $listeners[0]; + [$class, $method] = $listenerData['listener']; $this->assertInstanceOf(AlphaListener::class, $class); $this->assertSame('process', $method); - $this->assertNull($it->next()); + $this->assertFalse($listenerData['isWildcard']); - $it = $provider->getListenersForEvent(new Beta()); - $this->assertNull($it->current()); + $betaListeners = $provider->getListenersForEvent(new Beta()); + $this->assertEmpty($betaListeners); } } diff --git a/tests/Event/Hyperf/ListenerTest.php b/tests/Event/Hyperf/ListenerTest.php index 0e8040e71..ac187c720 100644 --- a/tests/Event/Hyperf/ListenerTest.php +++ b/tests/Event/Hyperf/ListenerTest.php @@ -4,11 +4,8 @@ namespace Hypervel\Tests\Event\Hyperf; -use Hyperf\Config\Config; -use Hyperf\Contract\ConfigInterface; use Hyperf\Event\Annotation\Listener as ListenerAnnotation; -use Hyperf\Event\ListenerData; -use Hyperf\Stdlib\SplPriorityQueue; +use Hypervel\Config\Repository; use Hypervel\Event\EventDispatcher; use Hypervel\Event\ListenerProvider; use Hypervel\Event\ListenerProviderFactory; @@ -17,8 +14,8 @@ use Hypervel\Tests\Event\Hyperf\Listener\AlphaListener; use Hypervel\Tests\Event\Hyperf\Listener\BetaListener; use Hypervel\Tests\TestCase; -use Mockery; use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration; +use Mockery as m; use Psr\Container\ContainerInterface; use Psr\EventDispatcher\ListenerProviderInterface; @@ -37,7 +34,7 @@ public function testInvokeListenerProvider() $this->assertTrue(is_array($listenerProvider->listeners)); } - public function testInvokeListenerProviderWithListeners() + public function testInvokeListenerProviderWithListeners(): void { $listenerProvider = new ListenerProvider(); $this->assertInstanceOf(ListenerProviderInterface::class, $listenerProvider); @@ -46,7 +43,8 @@ public function testInvokeListenerProviderWithListeners() $listenerProvider->on(Beta::class, [new BetaListener(), 'process']); $this->assertTrue(is_array($listenerProvider->listeners)); $this->assertSame(2, count($listenerProvider->listeners)); - $this->assertInstanceOf(SplPriorityQueue::class, $listenerProvider->getListenersForEvent(new Alpha())); + // getListenersForEvent now returns an array (Laravel-style) + $this->assertIsArray($listenerProvider->getListenersForEvent(new Alpha())); } public function testListenerProcess() @@ -62,8 +60,8 @@ public function testListenerProcess() public function testListenerInvokeByFactory() { - $container = Mockery::mock(ContainerInterface::class); - $container->shouldReceive('get')->once()->with(ConfigInterface::class)->andReturn(new Config([])); + $container = m::mock(ContainerInterface::class); + $container->shouldReceive('get')->once()->with('config')->andReturn(new Repository([])); $container->shouldReceive('get') ->once() ->with(ListenerProviderInterface::class) @@ -74,8 +72,8 @@ public function testListenerInvokeByFactory() public function testListenerInvokeByFactoryWithConfig() { - $container = Mockery::mock(ContainerInterface::class); - $container->shouldReceive('get')->once()->with(ConfigInterface::class)->andReturn(new Config([ + $container = m::mock(ContainerInterface::class); + $container->shouldReceive('get')->once()->with('config')->andReturn(new Repository([ 'listeners' => [ AlphaListener::class, BetaListener::class, @@ -110,8 +108,8 @@ public function testListenerInvokeByFactoryWithAnnotationConfig() $listenerAnnotation->collectClass(AlphaListener::class, ListenerAnnotation::class); $listenerAnnotation->collectClass(BetaListener::class, ListenerAnnotation::class); - $container = Mockery::mock(ContainerInterface::class); - $container->shouldReceive('get')->once()->with(ConfigInterface::class)->andReturn(new Config([])); + $container = m::mock(ContainerInterface::class); + $container->shouldReceive('get')->once()->with('config')->andReturn(new Repository([])); $container->shouldReceive('get') ->with(AlphaListener::class) ->andReturn($alphaListener = new AlphaListener()); @@ -136,12 +134,11 @@ public function testListenerInvokeByFactoryWithAnnotationConfig() $this->assertSame(2, $betaListener->value); } - public function testListenerAnnotationWithPriority() + public function testListenerAnnotationExists(): void { + // Hyperf's Listener annotation still exists (for compatibility) + // but priority is ignored in Hypervel's Laravel-style event system $listenerAnnotation = new ListenerAnnotation(); - $this->assertSame(ListenerData::DEFAULT_PRIORITY, $listenerAnnotation->priority); - - $listenerAnnotation = new ListenerAnnotation(2); - $this->assertSame(2, $listenerAnnotation->priority); + $this->assertInstanceOf(ListenerAnnotation::class, $listenerAnnotation); } } diff --git a/tests/Event/QueuedEventsTest.php b/tests/Event/QueuedEventsTest.php index 24d1bdd5f..b017a778a 100644 --- a/tests/Event/QueuedEventsTest.php +++ b/tests/Event/QueuedEventsTest.php @@ -4,18 +4,18 @@ namespace Hypervel\Tests\Event; -use Hyperf\Config\Config; -use Hyperf\Context\ApplicationContext; -use Hyperf\Contract\ConfigInterface; use Hyperf\Contract\StdoutLoggerInterface; use Hyperf\Di\Definition\DefinitionSource; -use Hypervel\Bus\Contracts\Dispatcher; +use Hypervel\Config\Repository; use Hypervel\Container\Container; +use Hypervel\Context\ApplicationContext; +use Hypervel\Contracts\Bus\Dispatcher; +use Hypervel\Contracts\Config\Repository as ConfigContract; +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; @@ -344,9 +344,11 @@ public function testQueueAcceptsStringBackedEnumViaMethod(): void private function getContainer(): Container { + $config = new Repository([]); $container = new Container( new DefinitionSource([ - ConfigInterface::class => fn () => new Config([]), + 'config' => fn () => $config, + ConfigContract::class => fn () => $config, ]) ); diff --git a/tests/Filesystem/FilesystemAdapterTest.php b/tests/Filesystem/FilesystemAdapterTest.php index e75c2afc0..5d41f6b1c 100644 --- a/tests/Filesystem/FilesystemAdapterTest.php +++ b/tests/Filesystem/FilesystemAdapterTest.php @@ -6,14 +6,15 @@ use Carbon\Carbon; use GuzzleHttp\Psr7\Stream; -use Hyperf\Context\ApplicationContext; -use Hyperf\Context\Context; -use Hyperf\Coroutine\Coroutine; use Hyperf\HttpMessage\Upload\UploadedFile; +use Hypervel\Context\ApplicationContext; +use Hypervel\Context\Context; +use Hypervel\Contracts\Container\Container; +use Hypervel\Contracts\Http\Request as RequestContract; +use Hypervel\Contracts\Http\Response as ResponseContract; +use Hypervel\Coroutine\Coroutine; 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 +60,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); } @@ -644,7 +644,7 @@ public function testGetChecksum() protected function mockResponse(string $content): void { - $container = m::mock(ContainerInterface::class); + $container = m::mock(Container::class); $container->shouldReceive('get') ->with(ResponseContract::class) ->andReturn(new Response()); diff --git a/tests/Filesystem/FilesystemManagerTest.php b/tests/Filesystem/FilesystemManagerTest.php index 5402f47f4..3865a65e1 100644 --- a/tests/Filesystem/FilesystemManagerTest.php +++ b/tests/Filesystem/FilesystemManagerTest.php @@ -4,13 +4,11 @@ namespace Hypervel\Tests\Filesystem; -use Hyperf\Config\Config; -use Hyperf\Context\ApplicationContext; -use Hyperf\Contract\ConfigInterface; -use Hyperf\Contract\ContainerInterface; -use Hyperf\Di\Container; use Hyperf\Di\Definition\DefinitionSource; -use Hypervel\Filesystem\Contracts\Filesystem; +use Hypervel\Config\Repository; +use Hypervel\Container\Container; +use Hypervel\Context\ApplicationContext; +use Hypervel\Contracts\Filesystem\Filesystem; use Hypervel\Filesystem\FilesystemManager; use Hypervel\Filesystem\FilesystemPoolProxy; use Hypervel\ObjectPool\Contracts\Factory as PoolFactory; @@ -18,6 +16,7 @@ use InvalidArgumentException; use PHPUnit\Framework\Attributes\RequiresOperatingSystem; use PHPUnit\Framework\TestCase; +use Psr\Container\ContainerInterface; use TypeError; enum FilesystemTestStringBackedDisk: string @@ -278,11 +277,11 @@ public function testDriveAcceptsStringBackedEnum(): void protected function getContainer(array $config = []): ContainerInterface { - $config = new Config(['filesystems' => $config]); + $config = new Repository(['filesystems' => $config]); return new Container( new DefinitionSource([ - ConfigInterface::class => fn () => $config, + 'config' => fn () => $config, PoolFactory::class => PoolManager::class, ]) ); diff --git a/tests/Foundation/Bootstrap/RegisterFacadesTest.php b/tests/Foundation/Bootstrap/RegisterFacadesTest.php index d1db6236c..df8ebc0f8 100644 --- a/tests/Foundation/Bootstrap/RegisterFacadesTest.php +++ b/tests/Foundation/Bootstrap/RegisterFacadesTest.php @@ -4,7 +4,8 @@ namespace Hypervel\Tests\Foundation\Bootstrap; -use Hyperf\Contract\ConfigInterface; +use Hypervel\Config\Repository; +use Hypervel\Contracts\Config\Repository as ConfigContract; use Hypervel\Foundation\Bootstrap\RegisterFacades; use Hypervel\Support\Composer; use Hypervel\Tests\Foundation\Concerns\HasMockedApplication; @@ -21,7 +22,7 @@ class RegisterFacadesTest extends TestCase public function testRegisterAliases() { - $config = m::mock(ConfigInterface::class); + $config = m::mock(Repository::class); $config->shouldReceive('get') ->with('app.aliases', []) ->once() @@ -30,7 +31,7 @@ public function testRegisterAliases() ]); $app = $this->getApplication([ - ConfigInterface::class => fn () => $config, + ConfigContract::class => fn () => $config, ]); $bootstrapper = $this->createPartialMock( @@ -45,7 +46,7 @@ public function testRegisterAliases() 'TestAlias' => 'TestClass', ]); - Composer::setBasePath(dirname(__DIR__) . '/fixtures/hyperf1'); + Composer::setBasePath(dirname(__DIR__) . '/fixtures/project1'); $bootstrapper->bootstrap($app); } diff --git a/tests/Foundation/Bootstrap/RegisterProvidersTest.php b/tests/Foundation/Bootstrap/RegisterProvidersTest.php index 4f3eba57c..12630084e 100644 --- a/tests/Foundation/Bootstrap/RegisterProvidersTest.php +++ b/tests/Foundation/Bootstrap/RegisterProvidersTest.php @@ -4,7 +4,8 @@ namespace Hypervel\Tests\Foundation\Bootstrap; -use Hyperf\Contract\ConfigInterface; +use Hypervel\Config\Repository; +use Hypervel\Contracts\Config\Repository as ConfigContract; use Hypervel\Foundation\Bootstrap\RegisterProviders; use Hypervel\Support\Composer; use Hypervel\Support\ServiceProvider; @@ -29,7 +30,7 @@ public function tearDown(): void public function testRegisterProviders() { - $config = m::mock(ConfigInterface::class); + $config = m::mock(Repository::class); $config->shouldReceive('get') ->with('app.providers', []) ->once() @@ -38,10 +39,10 @@ public function testRegisterProviders() ]); $app = $this->getApplication([ - ConfigInterface::class => fn () => $config, + ConfigContract::class => fn () => $config, ]); - Composer::setBasePath(dirname(__DIR__) . '/fixtures/hyperf1'); + Composer::setBasePath(dirname(__DIR__) . '/fixtures/project1'); (new RegisterProviders())->bootstrap($app); diff --git a/tests/Foundation/Console/CliDumperTest.php b/tests/Foundation/Console/CliDumperTest.php index 2abedd449..ca296e77e 100644 --- a/tests/Foundation/Console/CliDumperTest.php +++ b/tests/Foundation/Console/CliDumperTest.php @@ -4,8 +4,8 @@ namespace Hypervel\Tests\Foundation\Console; -use Hyperf\Contract\ConfigInterface; use Hypervel\Config\Repository; +use Hypervel\Contracts\Config\Repository as ConfigContract; use Hypervel\Foundation\Console\CliDumper; use Hypervel\Tests\Foundation\Concerns\HasMockedApplication; use Hypervel\Tests\TestCase; @@ -34,7 +34,7 @@ protected function setUp(): void $this->config = $this->getConfig(); $this->container = $this->getApplication([ - ConfigInterface::class => fn () => $this->config, + ConfigContract::class => fn () => $this->config, ]); CliDumper::resolveDumpSourceUsing(function () { diff --git a/tests/Foundation/FoundationApplicationTest.php b/tests/Foundation/FoundationApplicationTest.php index b0d74a77b..4836ffada 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; @@ -194,11 +194,11 @@ public function testAfterBootstrappingAddsClosure() public function testGetNamespace() { - $app1 = $this->getApplication([], realpath(__DIR__ . '/fixtures/hyperf1')); - $app2 = $this->getApplication([], realpath(__DIR__ . '/fixtures/hyperf2')); + $app1 = $this->getApplication([], realpath(__DIR__ . '/fixtures/project1')); + $app2 = $this->getApplication([], realpath(__DIR__ . '/fixtures/project2')); - $this->assertSame('Hyperf\One\\', $app1->getNamespace()); - $this->assertSame('Hyperf\Two\\', $app2->getNamespace()); + $this->assertSame('App\One\\', $app1->getNamespace()); + $this->assertSame('App\Two\\', $app2->getNamespace()); } public function testMacroable() diff --git a/tests/Foundation/FoundationExceptionHandlerTest.php b/tests/Foundation/FoundationExceptionHandlerTest.php index 60f8a3203..a98b1f4db 100644 --- a/tests/Foundation/FoundationExceptionHandlerTest.php +++ b/tests/Foundation/FoundationExceptionHandlerTest.php @@ -5,12 +5,7 @@ namespace Hypervel\Tests\Foundation; use Exception; -use Hyperf\Context\Context; -use Hyperf\Context\RequestContext; -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 +16,19 @@ use Hyperf\ViewEngine\ViewErrorBag; use Hypervel\Config\Repository; use Hypervel\Context\ApplicationContext; +use Hypervel\Context\Context; +use Hypervel\Context\RequestContext; +use Hypervel\Context\ResponseContext; +use Hypervel\Contracts\Config\Repository as ConfigContract; +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; @@ -70,7 +70,7 @@ protected function setUp(): void $this->config = $this->getConfig(); $this->request = m::mock(Request::class); $this->container = $this->getApplication([ - ConfigInterface::class => fn () => $this->config, + ConfigContract::class => fn () => $this->config, FactoryInterface::class => fn () => new stdClass(), Request::class => fn () => $this->request, ServerRequestInterface::class => fn () => m::mock(ServerRequestInterface::class), @@ -91,6 +91,7 @@ public function tearDown(): void parent::tearDown(); Context::destroy('__request.root.uri'); + Context::destroy(ServerRequestInterface::class); } public function testHandlerReportsExceptionAsContext() diff --git a/tests/Foundation/Http/CustomCastingTest.php b/tests/Foundation/Http/CustomCastingTest.php index eff716c13..14313aa7b 100644 --- a/tests/Foundation/Http/CustomCastingTest.php +++ b/tests/Foundation/Http/CustomCastingTest.php @@ -7,8 +7,7 @@ use ArrayObject; use Carbon\Carbon; use Carbon\CarbonInterface; -use Hyperf\Collection\Collection; -use Hyperf\Context\Context; +use Hypervel\Context\Context; use Hypervel\Foundation\Http\Casts\AsDataObjectArray; use Hypervel\Foundation\Http\Casts\AsDataObjectCollection; use Hypervel\Foundation\Http\Casts\AsEnumArrayObject; @@ -16,10 +15,11 @@ 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; -use Mockery; +use Mockery as m; use Psr\Http\Message\ServerRequestInterface; use Swow\Psr7\Message\ServerRequestPlusInterface; @@ -36,7 +36,7 @@ class CustomCastingTest extends TestCase */ public function testEnumCasting() { - $psrRequest = Mockery::mock(ServerRequestPlusInterface::class); + $psrRequest = m::mock(ServerRequestPlusInterface::class); $psrRequest->shouldReceive('getParsedBody')->andReturn([ 'status' => 'active', ]); @@ -56,7 +56,7 @@ public function testEnumCasting() */ public function testEnumCastingAll() { - $psrRequest = Mockery::mock(ServerRequestPlusInterface::class); + $psrRequest = m::mock(ServerRequestPlusInterface::class); $psrRequest->shouldReceive('getParsedBody')->andReturn([ 'status' => 'active', 'name' => 'Test', @@ -82,7 +82,7 @@ public function testEnumCastingAll() */ public function testCustomClassCasting() { - $psrRequest = Mockery::mock(ServerRequestPlusInterface::class); + $psrRequest = m::mock(ServerRequestPlusInterface::class); $psrRequest->shouldReceive('getParsedBody')->andReturn([ 'price' => '1000', ]); @@ -103,7 +103,7 @@ public function testCustomClassCasting() */ public function testNullValueHandling() { - $psrRequest = Mockery::mock(ServerRequestPlusInterface::class); + $psrRequest = m::mock(ServerRequestPlusInterface::class); $psrRequest->shouldReceive('getParsedBody')->andReturn([ 'status' => null, ]); @@ -122,7 +122,7 @@ public function testNullValueHandling() */ public function testNonExistentField() { - $psrRequest = Mockery::mock(ServerRequestPlusInterface::class); + $psrRequest = m::mock(ServerRequestPlusInterface::class); $psrRequest->shouldReceive('getParsedBody')->andReturn([ 'status' => 'active', 'name' => 'Test', @@ -142,7 +142,7 @@ public function testNonExistentField() */ public function testAsEnumArrayObjectCasting() { - $psrRequest = Mockery::mock(ServerRequestPlusInterface::class); + $psrRequest = m::mock(ServerRequestPlusInterface::class); $psrRequest->shouldReceive('getParsedBody')->andReturn([ 'statuses' => ['active', 'inactive'], ]); @@ -164,7 +164,7 @@ public function testAsEnumArrayObjectCasting() */ public function testAsEnumCollectionCasting() { - $psrRequest = Mockery::mock(ServerRequestPlusInterface::class); + $psrRequest = m::mock(ServerRequestPlusInterface::class); $psrRequest->shouldReceive('getParsedBody')->andReturn([ 'statuses' => ['active', 'inactive', 'pending'], ]); @@ -187,7 +187,7 @@ public function testAsEnumCollectionCasting() */ public function testCastedWithoutValidation() { - $psrRequest = Mockery::mock(ServerRequestPlusInterface::class); + $psrRequest = m::mock(ServerRequestPlusInterface::class); $psrRequest->shouldReceive('getParsedBody')->andReturn([ 'status' => 'active', 'extra_field' => 'extra_value', @@ -214,7 +214,7 @@ public function testCastedWithoutValidation() */ public function testPrimitiveIntCasting() { - $psrRequest = Mockery::mock(ServerRequestPlusInterface::class); + $psrRequest = m::mock(ServerRequestPlusInterface::class); $psrRequest->shouldReceive('getParsedBody')->andReturn([ 'age' => '25', ]); @@ -234,7 +234,7 @@ public function testPrimitiveIntCasting() */ public function testPrimitiveFloatCasting() { - $psrRequest = Mockery::mock(ServerRequestPlusInterface::class); + $psrRequest = m::mock(ServerRequestPlusInterface::class); $psrRequest->shouldReceive('getParsedBody')->andReturn([ 'price' => '19.99', ]); @@ -254,7 +254,7 @@ public function testPrimitiveFloatCasting() */ public function testPrimitiveBoolCasting() { - $psrRequest = Mockery::mock(ServerRequestPlusInterface::class); + $psrRequest = m::mock(ServerRequestPlusInterface::class); $psrRequest->shouldReceive('getParsedBody')->andReturn([ 'is_active' => '1', ]); @@ -274,7 +274,7 @@ public function testPrimitiveBoolCasting() */ public function testPrimitiveArrayCasting() { - $psrRequest = Mockery::mock(ServerRequestPlusInterface::class); + $psrRequest = m::mock(ServerRequestPlusInterface::class); $psrRequest->shouldReceive('getParsedBody')->andReturn([ 'tags' => '["tag1","tag2"]', ]); @@ -294,7 +294,7 @@ public function testPrimitiveArrayCasting() */ public function testPrimitiveCollectionCasting() { - $psrRequest = Mockery::mock(ServerRequestPlusInterface::class); + $psrRequest = m::mock(ServerRequestPlusInterface::class); $psrRequest->shouldReceive('getParsedBody')->andReturn([ 'items' => json_encode(['item1', 'item2']), ]); @@ -314,7 +314,7 @@ public function testPrimitiveCollectionCasting() */ public function testPrimitiveDatetimeCasting() { - $psrRequest = Mockery::mock(ServerRequestPlusInterface::class); + $psrRequest = m::mock(ServerRequestPlusInterface::class); $psrRequest->shouldReceive('getParsedBody')->andReturn([ 'created_at' => 1705315800, // 2024-01-15 10:50:00 UTC 'published_date' => '2024-01-15', @@ -348,7 +348,7 @@ public function testPrimitiveDatetimeCasting() */ public function testDataObjectCasting() { - $psrRequest = Mockery::mock(ServerRequestPlusInterface::class); + $psrRequest = m::mock(ServerRequestPlusInterface::class); $psrRequest->shouldReceive('getParsedBody')->andReturn([ 'contact' => ['name' => 'Jane', 'email' => 'jane@example.com'], ]); @@ -369,7 +369,7 @@ public function testDataObjectCasting() */ public function testAsArrayObjectCasting() { - $psrRequest = Mockery::mock(ServerRequestPlusInterface::class); + $psrRequest = m::mock(ServerRequestPlusInterface::class); $psrRequest->shouldReceive('getParsedBody')->andReturn([ 'contacts' => [ ['name' => 'John', 'email' => 'john@example.com'], @@ -395,7 +395,7 @@ public function testAsArrayObjectCasting() */ public function testAsCollectionCasting() { - $psrRequest = Mockery::mock(ServerRequestPlusInterface::class); + $psrRequest = m::mock(ServerRequestPlusInterface::class); $psrRequest->shouldReceive('getParsedBody')->andReturn([ 'products' => [ ['sku' => 'ABC123', 'name' => 'Product A', 'price' => 100], diff --git a/tests/Foundation/Http/HtmlDumperTest.php b/tests/Foundation/Http/HtmlDumperTest.php index 8aaa289be..4b82c0d0d 100644 --- a/tests/Foundation/Http/HtmlDumperTest.php +++ b/tests/Foundation/Http/HtmlDumperTest.php @@ -4,9 +4,9 @@ namespace Hypervel\Tests\Foundation\Http; -use Hyperf\Contract\ConfigInterface; use Hypervel\Config\Repository; use Hypervel\Context\ApplicationContext; +use Hypervel\Contracts\Config\Repository as ConfigContract; use Hypervel\Foundation\Http\HtmlDumper; use Hypervel\Tests\Foundation\Concerns\HasMockedApplication; use Hypervel\Tests\TestCase; @@ -34,7 +34,7 @@ protected function setUp(): void $this->config = $this->getConfig(); $this->container = $this->getApplication([ - ConfigInterface::class => fn () => $this->config, + ConfigContract::class => fn () => $this->config, ]); HtmlDumper::resolveDumpSourceUsing(function () { 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 index 5b1d9369e..c7a101b60 100644 --- a/tests/Foundation/Testing/Attributes/AttributesTest.php +++ b/tests/Foundation/Testing/Attributes/AttributesTest.php @@ -6,22 +6,22 @@ use Attribute; use Closure; -use Hypervel\Foundation\Testing\Attributes\Define; -use Hypervel\Foundation\Testing\Attributes\DefineDatabase; -use Hypervel\Foundation\Testing\Attributes\DefineEnvironment; -use Hypervel\Foundation\Testing\Attributes\DefineRoute; -use Hypervel\Foundation\Testing\Attributes\RequiresEnv; -use Hypervel\Foundation\Testing\Attributes\ResetRefreshDatabaseState; -use Hypervel\Foundation\Testing\Attributes\WithConfig; -use Hypervel\Foundation\Testing\Attributes\WithMigration; -use Hypervel\Foundation\Testing\Contracts\Attributes\Actionable; -use Hypervel\Foundation\Testing\Contracts\Attributes\AfterAll; -use Hypervel\Foundation\Testing\Contracts\Attributes\AfterEach; -use Hypervel\Foundation\Testing\Contracts\Attributes\BeforeAll; -use Hypervel\Foundation\Testing\Contracts\Attributes\BeforeEach; -use Hypervel\Foundation\Testing\Contracts\Attributes\Invokable; -use Hypervel\Foundation\Testing\Contracts\Attributes\Resolvable; use Hypervel\Foundation\Testing\RefreshDatabaseState; +use Hypervel\Testbench\Attributes\Define; +use Hypervel\Testbench\Attributes\DefineDatabase; +use Hypervel\Testbench\Attributes\DefineEnvironment; +use Hypervel\Testbench\Attributes\DefineRoute; +use Hypervel\Testbench\Attributes\RequiresEnv; +use Hypervel\Testbench\Attributes\ResetRefreshDatabaseState; +use Hypervel\Testbench\Attributes\WithConfig; +use Hypervel\Testbench\Attributes\WithMigration; +use Hypervel\Testbench\Contracts\Attributes\Actionable; +use Hypervel\Testbench\Contracts\Attributes\AfterAll; +use Hypervel\Testbench\Contracts\Attributes\AfterEach; +use Hypervel\Testbench\Contracts\Attributes\BeforeAll; +use Hypervel\Testbench\Contracts\Attributes\BeforeEach; +use Hypervel\Testbench\Contracts\Attributes\Invokable; +use Hypervel\Testbench\Contracts\Attributes\Resolvable; use Hypervel\Testbench\TestCase; use ReflectionClass; @@ -150,17 +150,51 @@ public function testResetRefreshDatabaseStateResetsState(): void public function testWithMigrationImplementsInvokable(): void { - $attribute = new WithMigration('/path/to/migrations'); + $attribute = new WithMigration('laravel'); $this->assertInstanceOf(Invokable::class, $attribute); - $this->assertSame(['/path/to/migrations'], $attribute->paths); + $this->assertSame(['laravel'], $attribute->types); + } + + public function testWithMigrationDefaultsToLaravel(): void + { + $attribute = new WithMigration(); + + $this->assertSame(['laravel'], $attribute->types); + } + + public function testWithMigrationAliasesMapToLaravel(): void + { + // cache, queue, session all map to 'laravel' + $cacheAttr = new WithMigration('cache'); + $queueAttr = new WithMigration('queue'); + $sessionAttr = new WithMigration('session'); + + $this->assertSame(['laravel'], $cacheAttr->types); + $this->assertSame(['laravel'], $queueAttr->types); + $this->assertSame(['laravel'], $sessionAttr->types); + } + + public function testWithMigrationDeduplicatesTypes(): void + { + // Multiple aliases that map to 'laravel' should dedupe + $attribute = new WithMigration('cache', 'queue', 'session', 'laravel'); + + $this->assertSame(['laravel'], $attribute->types); + } + + public function testWithMigrationPreservesLiteralPaths(): void + { + $attribute = new WithMigration('/path/to/migrations'); + + $this->assertSame(['/path/to/migrations'], $attribute->types); } - public function testWithMigrationMultiplePaths(): void + public function testWithMigrationMixedTypesAndPaths(): void { - $attribute = new WithMigration('/path/one', '/path/two'); + $attribute = new WithMigration('cache', '/custom/path'); - $this->assertSame(['/path/one', '/path/two'], $attribute->paths); + $this->assertSame(['laravel', '/custom/path'], $attribute->types); } public function testRequiresEnvImplementsActionable(): void diff --git a/tests/Foundation/Testing/Attributes/RequiresDatabaseTest.php b/tests/Foundation/Testing/Attributes/RequiresDatabaseTest.php new file mode 100644 index 000000000..e323e8f86 --- /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: null); + + $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: null); + + $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/BootTraitsTest.php b/tests/Foundation/Testing/BootTraitsTest.php index f35aacd82..e3bf77518 100644 --- a/tests/Foundation/Testing/BootTraitsTest.php +++ b/tests/Foundation/Testing/BootTraitsTest.php @@ -39,6 +39,16 @@ public function testSetUpTraits() class TestCaseWithTrait extends TestbenchTestCase { use TestTrait; + + /** + * Dummy test method required for setUpTraits() to work. + * + * PHPUnit TestCase expects the named test method to exist, and + * AttributeParser reflects on it to check for database attributes. + */ + public function foo(): void + { + } } trait TestTrait diff --git a/tests/Foundation/Testing/Concerns/AttributeInheritanceTest.php b/tests/Foundation/Testing/Concerns/AttributeInheritanceTest.php index 0e90c411a..6ab285f9a 100644 --- a/tests/Foundation/Testing/Concerns/AttributeInheritanceTest.php +++ b/tests/Foundation/Testing/Concerns/AttributeInheritanceTest.php @@ -4,8 +4,8 @@ namespace Hypervel\Tests\Foundation\Testing\Concerns; -use Hypervel\Foundation\Testing\AttributeParser; -use Hypervel\Foundation\Testing\Attributes\WithConfig; +use Hypervel\Testbench\Attributes\WithConfig; +use Hypervel\Testbench\PHPUnit\AttributeParser; use Hypervel\Testbench\TestCase; /** diff --git a/tests/Foundation/Testing/Concerns/DeferredAttributeExecutionTest.php b/tests/Foundation/Testing/Concerns/DeferredAttributeExecutionTest.php index 24742d730..c1407ad51 100644 --- a/tests/Foundation/Testing/Concerns/DeferredAttributeExecutionTest.php +++ b/tests/Foundation/Testing/Concerns/DeferredAttributeExecutionTest.php @@ -4,7 +4,7 @@ namespace Hypervel\Tests\Foundation\Testing\Concerns; -use Hypervel\Foundation\Testing\Attributes\DefineDatabase; +use Hypervel\Testbench\Attributes\DefineDatabase; use Hypervel\Testbench\TestCase; /** diff --git a/tests/Foundation/Testing/Concerns/DefineEnvironmentTest.php b/tests/Foundation/Testing/Concerns/DefineEnvironmentTest.php index d8fc7549f..f0292bfd2 100644 --- a/tests/Foundation/Testing/Concerns/DefineEnvironmentTest.php +++ b/tests/Foundation/Testing/Concerns/DefineEnvironmentTest.php @@ -4,7 +4,7 @@ namespace Hypervel\Tests\Foundation\Testing\Concerns; -use Hypervel\Foundation\Contracts\Application as ApplicationContract; +use Hypervel\Contracts\Foundation\Application as ApplicationContract; use Hypervel\Testbench\TestCase; /** diff --git a/tests/Foundation/Testing/Concerns/HandlesAttributesTest.php b/tests/Foundation/Testing/Concerns/HandlesAttributesTest.php index a13d21588..26f392a7f 100644 --- a/tests/Foundation/Testing/Concerns/HandlesAttributesTest.php +++ b/tests/Foundation/Testing/Concerns/HandlesAttributesTest.php @@ -4,9 +4,9 @@ namespace Hypervel\Tests\Foundation\Testing\Concerns; -use Hypervel\Foundation\Testing\Attributes\DefineEnvironment; -use Hypervel\Foundation\Testing\Attributes\WithConfig; -use Hypervel\Foundation\Testing\Features\FeaturesCollection; +use Hypervel\Testbench\Attributes\DefineEnvironment; +use Hypervel\Testbench\Attributes\WithConfig; +use Hypervel\Testbench\Features\FeaturesCollection; use Hypervel\Testbench\TestCase; /** diff --git a/tests/Foundation/Testing/Concerns/InteractsWithAuthenticationTest.php b/tests/Foundation/Testing/Concerns/InteractsWithAuthenticationTest.php index d741015ac..a398a3c89 100644 --- a/tests/Foundation/Testing/Concerns/InteractsWithAuthenticationTest.php +++ b/tests/Foundation/Testing/Concerns/InteractsWithAuthenticationTest.php @@ -4,14 +4,13 @@ namespace Hypervel\Tests\Foundation\Testing\Concerns; -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\Context\Context; +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; +use Mockery as m; /** * @internal @@ -30,14 +29,14 @@ public function tearDown(): void public function testAssertAsGuest() { - $guard = Mockery::mock(Guard::class); + $guard = m::mock(Guard::class); $guard->shouldReceive('check') ->twice() ->andReturn(false); $this->app->get(AuthFactoryContract::class) ->extend('foo', fn () => $guard); - $this->app->get(ConfigInterface::class) + $this->app->get('config') ->set('auth.guards.foo', [ 'driver' => 'foo', 'provider' => 'users', @@ -51,13 +50,13 @@ public function testAssertAsGuest() public function testAssertActingAs() { - $guard = Mockery::mock(Guard::class); + $guard = m::mock(Guard::class); $guard->shouldReceive('check') ->once() ->andReturn(true); $guard->shouldReceive('setUser') ->once() - ->andReturn($user = Mockery::mock(UserContract::class)); + ->andReturn($user = m::mock(UserContract::class)); $guard->shouldReceive('user') ->once() ->andReturn($user); @@ -67,7 +66,7 @@ public function testAssertActingAs() $this->app->get(AuthFactoryContract::class) ->extend('foo', fn () => $guard); - $this->app->get(ConfigInterface::class) + $this->app->get('config') ->set('auth.guards.foo', [ 'driver' => 'foo', 'provider' => 'users', diff --git a/tests/Foundation/Testing/Concerns/InteractsWithDatabaseTest.php b/tests/Foundation/Testing/Concerns/InteractsWithDatabaseTest.php index 6d91fe0f8..eb7817f55 100644 --- a/tests/Foundation/Testing/Concerns/InteractsWithDatabaseTest.php +++ b/tests/Foundation/Testing/Concerns/InteractsWithDatabaseTest.php @@ -4,14 +4,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 +20,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); } @@ -71,14 +77,19 @@ public function testAssertModelMissing() public function testFactoryUsesConfiguredFakerLocale() { $locale = 'fr_FR'; - $this->app->get(ConfigInterface::class) + $this->app->get('config') ->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 +99,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..10a6f3586 100644 --- a/tests/Foundation/Testing/Concerns/InteractsWithSessionTest.php +++ b/tests/Foundation/Testing/Concerns/InteractsWithSessionTest.php @@ -4,8 +4,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; /** @@ -30,7 +29,7 @@ public function testWithSessionWorksWithCacheDriver(): void { // Use cache driver which has strict type hints on sessionId // This tests that startSession() properly initializes a session ID - $this->app->get(ConfigInterface::class)->set('session.driver', 'redis'); + $this->app->get('config')->set('session.driver', 'redis'); $this->withSession(['cache_test' => 'value']); diff --git a/tests/Foundation/Testing/Concerns/InteractsWithTestCaseTest.php b/tests/Foundation/Testing/Concerns/InteractsWithTestCaseTest.php index 460862c41..c51061049 100644 --- a/tests/Foundation/Testing/Concerns/InteractsWithTestCaseTest.php +++ b/tests/Foundation/Testing/Concerns/InteractsWithTestCaseTest.php @@ -5,13 +5,13 @@ namespace Hypervel\Tests\Foundation\Testing\Concerns; use Attribute; -use Hypervel\Foundation\Testing\AttributeParser; -use Hypervel\Foundation\Testing\Attributes\Define; -use Hypervel\Foundation\Testing\Attributes\DefineEnvironment; -use Hypervel\Foundation\Testing\Attributes\WithConfig; -use Hypervel\Foundation\Testing\Concerns\HandlesAttributes; -use Hypervel\Foundation\Testing\Concerns\InteractsWithTestCase; use Hypervel\Support\Collection; +use Hypervel\Testbench\Attributes\Define; +use Hypervel\Testbench\Attributes\DefineEnvironment; +use Hypervel\Testbench\Attributes\WithConfig; +use Hypervel\Testbench\Concerns\HandlesAttributes; +use Hypervel\Testbench\Concerns\InteractsWithTestCase; +use Hypervel\Testbench\PHPUnit\AttributeParser; use Hypervel\Testbench\TestCase; /** diff --git a/tests/Foundation/Testing/Concerns/MakesHttpRequestsTest.php b/tests/Foundation/Testing/Concerns/MakesHttpRequestsTest.php index 520caee22..94f2a726d 100644 --- a/tests/Foundation/Testing/Concerns/MakesHttpRequestsTest.php +++ b/tests/Foundation/Testing/Concerns/MakesHttpRequestsTest.php @@ -5,16 +5,16 @@ namespace Hypervel\Tests\Foundation\Testing\Concerns; use Hyperf\HttpMessage\Base\Response; -use Hyperf\Support\MessageBag; use Hyperf\ViewEngine\ViewErrorBag; use Hypervel\Foundation\Testing\Concerns\RunTestsInCoroutine; use Hypervel\Foundation\Testing\Http\ServerResponse; -use Hypervel\Foundation\Testing\Http\TestResponse; use Hypervel\Foundation\Testing\Stubs\FakeMiddleware; use Hypervel\Router\RouteFileCollector; use Hypervel\Session\ArraySessionHandler; use Hypervel\Session\Store; +use Hypervel\Support\MessageBag; use Hypervel\Testbench\TestCase; +use Hypervel\Testing\TestResponse; use PHPUnit\Framework\AssertionFailedError; /** diff --git a/tests/Foundation/Testing/DatabaseMigrationsTest.php b/tests/Foundation/Testing/DatabaseMigrationsTest.php index 1346c0451..706435ada 100644 --- a/tests/Foundation/Testing/DatabaseMigrationsTest.php +++ b/tests/Foundation/Testing/DatabaseMigrationsTest.php @@ -4,9 +4,9 @@ namespace Hypervel\Tests\Foundation\Testing; -use Hyperf\Config\Config; -use Hyperf\Contract\ConfigInterface; -use Hypervel\Foundation\Console\Contracts\Kernel as KernelContract; +use Hypervel\Config\Repository; +use Hypervel\Contracts\Config\Repository as ConfigContract; +use Hypervel\Contracts\Console\Kernel as KernelContract; use Hypervel\Foundation\Testing\Concerns\InteractsWithConsole; use Hypervel\Foundation\Testing\DatabaseMigrations; use Hypervel\Testbench\TestCase; @@ -64,7 +64,7 @@ public function testRefreshTestDatabaseDefault() ->with('migrate:rollback', []) ->andReturn(0); $this->app = $this->getApplication([ - ConfigInterface::class => fn () => $this->getConfig(), + ConfigContract::class => fn () => $this->getConfig(), KernelContract::class => fn () => $kernel, ]); @@ -88,7 +88,7 @@ public function testRefreshTestDatabaseWithDropViewsOption() ->with('migrate:rollback', []) ->andReturn(0); $this->app = $this->getApplication([ - ConfigInterface::class => fn () => $this->getConfig(), + ConfigContract::class => fn () => $this->getConfig(), KernelContract::class => fn () => $kernel, ]); @@ -112,7 +112,7 @@ public function testRefreshTestDatabaseWithSeedOption() ->with('migrate:rollback', []) ->andReturn(0); $this->app = $this->getApplication([ - ConfigInterface::class => fn () => $this->getConfig(), + ConfigContract::class => fn () => $this->getConfig(), KernelContract::class => fn () => $kernel, ]); @@ -136,16 +136,16 @@ public function testRefreshTestDatabaseWithSeederOption() ->with('migrate:rollback', []) ->andReturn(0); $this->app = $this->getApplication([ - ConfigInterface::class => fn () => $this->getConfig(), + ConfigContract::class => fn () => $this->getConfig(), KernelContract::class => fn () => $kernel, ]); $this->runDatabaseMigrations(); } - protected function getConfig(array $config = []): Config + protected function getConfig(array $config = []): Repository { - return new Config(array_merge([ + return new Repository(array_merge([ 'database' => [ 'default' => 'default', ], diff --git a/tests/Foundation/Testing/RefreshDatabaseTest.php b/tests/Foundation/Testing/RefreshDatabaseTest.php index e4bfb4c0e..5df70f489 100644 --- a/tests/Foundation/Testing/RefreshDatabaseTest.php +++ b/tests/Foundation/Testing/RefreshDatabaseTest.php @@ -4,11 +4,11 @@ namespace Hypervel\Tests\Foundation\Testing; -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\Config\Repository; +use Hypervel\Contracts\Config\Repository as ConfigContract; +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; @@ -64,9 +64,9 @@ public function testRefreshTestDatabaseDefault() ])->andReturn(0); $this->app = $this->getApplication([ - ConfigInterface::class => fn () => $this->getConfig(), + ConfigContract::class => fn () => $this->getConfig(), KernelContract::class => fn () => $kernel, - Db::class => fn () => $this->getMockedDatabase(), + DatabaseManager::class => fn () => $this->getMockedDatabase(), ]); $this->refreshTestDatabase(); @@ -85,9 +85,9 @@ public function testRefreshTestDatabaseWithDropViewsOption() '--seed' => false, ])->andReturn(0); $this->app = $this->getApplication([ - ConfigInterface::class => fn () => $this->getConfig(), + ConfigContract::class => fn () => $this->getConfig(), KernelContract::class => fn () => $kernel, - Db::class => fn () => $this->getMockedDatabase(), + DatabaseManager::class => fn () => $this->getMockedDatabase(), ]); $this->refreshTestDatabase(); @@ -106,9 +106,9 @@ public function testRefreshTestDatabaseWithSeedOption() '--seed' => true, ])->andReturn(0); $this->app = $this->getApplication([ - ConfigInterface::class => fn () => $this->getConfig(), + ConfigContract::class => fn () => $this->getConfig(), KernelContract::class => fn () => $kernel, - Db::class => fn () => $this->getMockedDatabase(), + DatabaseManager::class => fn () => $this->getMockedDatabase(), ]); $this->refreshTestDatabase(); @@ -127,24 +127,24 @@ public function testRefreshTestDatabaseWithSeederOption() '--seeder' => 'seeder', ])->andReturn(0); $this->app = $this->getApplication([ - ConfigInterface::class => fn () => $this->getConfig(), + ConfigContract::class => fn () => $this->getConfig(), KernelContract::class => fn () => $kernel, - Db::class => fn () => $this->getMockedDatabase(), + DatabaseManager::class => fn () => $this->getMockedDatabase(), ]); $this->refreshTestDatabase(); } - protected function getConfig(array $config = []): Config + protected function getConfig(array $config = []): Repository { - return new Config(array_merge([ + return new Repository(array_merge([ 'database' => [ 'default' => 'default', ], ], $config)); } - protected function getMockedDatabase(): Db + protected function getMockedDatabase(): DatabaseManager { $connection = m::mock(ConnectionInterface::class); $connection->shouldReceive('getEventDispatcher') @@ -159,6 +159,8 @@ protected function getMockedDatabase(): Db $connection->shouldReceive('setEventDispatcher') ->twice() ->with($eventDispatcher); + $connection->shouldReceive('setTransactionManager') + ->once(); $pdo = m::mock(PDO::class); $pdo->shouldReceive('inTransaction') @@ -167,7 +169,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/Testing/Traits/CanConfigureMigrationCommandsTest.php b/tests/Foundation/Testing/Traits/CanConfigureMigrationCommandsTest.php index 3429d9bd0..5d1931e5f 100644 --- a/tests/Foundation/Testing/Traits/CanConfigureMigrationCommandsTest.php +++ b/tests/Foundation/Testing/Traits/CanConfigureMigrationCommandsTest.php @@ -4,8 +4,8 @@ namespace Hypervel\Tests\Foundation\Testing\Traits; -use Hyperf\Config\Config; -use Hyperf\Contract\ConfigInterface; +use Hypervel\Config\Repository; +use Hypervel\Contracts\Config\Repository as ConfigContract; use Hypervel\Foundation\Testing\Traits\CanConfigureMigrationCommands; use Hypervel\Tests\Foundation\Concerns\HasMockedApplication; use PHPUnit\Framework\TestCase; @@ -70,9 +70,9 @@ public function testMigrateFreshUsingWithPropertySets(): void $this->assertEquals($expected, $migrateFreshUsingReflection->invoke($this->traitObject)); } - protected function getConfig(array $config = []): Config + protected function getConfig(array $config = []): Repository { - return new Config(array_merge([ + return new Repository(array_merge([ 'database' => [ 'default' => 'default', ], @@ -92,13 +92,13 @@ class CanConfigureMigrationCommandsTestMockClass public function __construct() { $this->app = $this->getApplication([ - ConfigInterface::class => fn () => $this->getConfig(), + ConfigContract::class => fn () => $this->getConfig(), ]); } - protected function getConfig(array $config = []): Config + protected function getConfig(array $config = []): Repository { - return new Config(array_merge([ + return new Repository(array_merge([ 'database' => [ 'default' => 'default', ], diff --git a/tests/Foundation/fixtures/hyperf1/composer.json b/tests/Foundation/fixtures/project1/composer.json similarity index 88% rename from tests/Foundation/fixtures/hyperf1/composer.json rename to tests/Foundation/fixtures/project1/composer.json index df7ba951a..b7d744bf1 100644 --- a/tests/Foundation/fixtures/hyperf1/composer.json +++ b/tests/Foundation/fixtures/project1/composer.json @@ -1,7 +1,7 @@ { "autoload": { "psr-4": { - "Hyperf\\One\\": "app/" + "App\\One\\": "app/" } }, "extra": { diff --git a/tests/Foundation/fixtures/hyperf1/composer.lock b/tests/Foundation/fixtures/project1/composer.lock similarity index 100% rename from tests/Foundation/fixtures/hyperf1/composer.lock rename to tests/Foundation/fixtures/project1/composer.lock diff --git a/tests/Foundation/fixtures/hyperf2/composer.json b/tests/Foundation/fixtures/project2/composer.json similarity index 60% rename from tests/Foundation/fixtures/hyperf2/composer.json rename to tests/Foundation/fixtures/project2/composer.json index a1476b9c0..ccc952912 100644 --- a/tests/Foundation/fixtures/hyperf2/composer.json +++ b/tests/Foundation/fixtures/project2/composer.json @@ -1,7 +1,7 @@ { "autoload": { "psr-4": { - "Hyperf\\Two\\": "app/" + "App\\Two\\": "app/" } } } \ No newline at end of file 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/Guzzle/CoroutineHandlerTest.php b/tests/Guzzle/CoroutineHandlerTest.php new file mode 100644 index 000000000..b191afeda --- /dev/null +++ b/tests/Guzzle/CoroutineHandlerTest.php @@ -0,0 +1,421 @@ + 0.001, 'connect_timeout' => 0.001])->wait(); + $this->fail('Expected ConnectException was not thrown'); + } catch (Exception $exception) { + $this->assertInstanceOf(ConnectException::class, $exception); + // Message format: "Failed to connecting to {host} port {port}, {underlying error}" + $this->assertStringStartsWith('Failed to connecting to localhost port 123', $exception->getMessage()); + } + } + + /** + * Test that the handler returns promises for async requests. + */ + public function testReusesHandles() + { + $handler = new CoroutineHandler(); + $request = new Request('GET', 'https://pokeapi.co/api/v2/pokemon/'); + $result1 = $handler($request, []); + $request = new Request('GET', 'https://pokeapi.co/api/v2/pokemon/'); + $result2 = $handler($request, []); + + $this->assertInstanceOf(PromiseInterface::class, $result1); + $this->assertInstanceOf(PromiseInterface::class, $result2); + } + + /** + * Test that the delay option causes the handler to sleep. + */ + public function testDoesSleep() + { + $handler = new CoroutineHandlerStub(); + $request = new Request('GET', 'https://pokeapi.co/api/v2/pokemon/'); + $response = $handler($request, ['delay' => 1, 'timeout' => 5])->wait(); + + $json = json_decode((string) $response->getBody(), true); + + $this->assertSame(5, $json['setting']['timeout']); + } + + /** + * Test that connection errors include handler context with error code. + */ + public function testCreatesErrorsWithContext() + { + $handler = new CoroutineHandler(); + $request = new Request('GET', 'http://localhost:123'); + $called = false; + $promise = $handler($request, ['timeout' => 0.001]) + ->otherwise(function (ConnectException $exception) use (&$called) { + $called = true; + $this->assertArrayHasKey('errCode', $exception->getHandlerContext()); + }); + $promise->wait(); + $this->assertTrue($called); + } + + /** + * Test that the handler works correctly with a Guzzle client. + */ + public function testGuzzleClient() + { + $client = new Client([ + 'base_uri' => 'http://127.0.0.1:8080', + 'handler' => HandlerStack::create(new CoroutineHandlerStub()), + ]); + + $response = (string) $client->get('/echo', [ + 'timeout' => 10, + 'headers' => [ + 'X-TOKEN' => md5('1234'), + ], + 'json' => [ + 'id' => 1, + ], + ])->getBody(); + + $result = json_decode($response, true); + + $this->assertSame('127.0.0.1', $result['host']); + $this->assertSame(8080, $result['port']); + $this->assertSame(false, $result['ssl']); + $this->assertSame([md5('1234')], $result['headers']['X-TOKEN']); + + // Test with real external API + $client = new Client([ + 'base_uri' => 'https://pokeapi.co', + 'timeout' => 5, + 'handler' => HandlerStack::create(new CoroutineHandler()), + ]); + + $response = (string) $client->get('/api/v2/pokemon')->getBody(); + + $this->assertNotEmpty($response); + } + + /** + * Test that Swoole-specific settings are passed through. + */ + public function testSwooleSetting() + { + $client = new Client([ + 'base_uri' => 'http://127.0.0.1:8080', + 'handler' => HandlerStack::create(new CoroutineHandlerStub()), + 'timeout' => 5, + 'swoole' => [ + 'timeout' => 10, + 'socket_buffer_size' => 1024 * 1024 * 2, + ], + ]); + + $data = json_decode((string) $client->get('/')->getBody(), true); + + $this->assertSame(10, $data['setting']['timeout']); + $this->assertSame(1024 * 1024 * 2, $data['setting']['socket_buffer_size']); + } + + /** + * Test that proxy settings are correctly configured. + */ + public function testProxy() + { + $client = new Client([ + 'base_uri' => 'http://127.0.0.1:8080', + 'handler' => HandlerStack::create(new CoroutineHandlerStub()), + 'proxy' => 'http://user:pass@127.0.0.1:8081', + ]); + + $json = json_decode((string) $client->get('/')->getBody(), true); + + $setting = $json['setting']; + + $this->assertSame('127.0.0.1', $setting['http_proxy_host']); + $this->assertSame(8081, $setting['http_proxy_port']); + $this->assertSame('user', $setting['http_proxy_user']); + $this->assertSame('pass', $setting['http_proxy_password']); + } + + /** + * Test that proxy array selects HTTP proxy for HTTP scheme. + */ + public function testProxyArrayHttpScheme() + { + $client = new Client([ + 'base_uri' => 'http://127.0.0.1:8080', + 'handler' => HandlerStack::create(new CoroutineHandlerStub()), + 'proxy' => [ + 'http' => 'http://127.0.0.1:12333', + 'https' => 'http://127.0.0.1:12334', + 'no' => ['.cn'], + ], + ]); + + $json = json_decode((string) $client->get('/')->getBody(), true); + + $setting = $json['setting']; + + $this->assertSame('127.0.0.1', $setting['http_proxy_host']); + $this->assertSame(12333, $setting['http_proxy_port']); + $this->assertArrayNotHasKey('http_proxy_user', $setting); + $this->assertArrayNotHasKey('http_proxy_password', $setting); + } + + /** + * Test that proxy array selects HTTPS proxy for HTTPS scheme. + */ + public function testProxyArrayHttpsScheme() + { + $client = new Client([ + 'base_uri' => 'https://www.baidu.com', + 'handler' => HandlerStack::create(new CoroutineHandlerStub()), + 'proxy' => [ + 'http' => 'http://127.0.0.1:12333', + 'https' => 'http://127.0.0.1:12334', + 'no' => ['.cn'], + ], + ]); + + $json = json_decode((string) $client->get('/')->getBody(), true); + + $setting = $json['setting']; + + $this->assertSame('127.0.0.1', $setting['http_proxy_host']); + $this->assertSame(12334, $setting['http_proxy_port']); + $this->assertArrayNotHasKey('http_proxy_user', $setting); + $this->assertArrayNotHasKey('http_proxy_password', $setting); + } + + /** + * Test that proxy is skipped when host matches no-proxy list. + */ + public function testProxyArrayHostInNoproxy() + { + $client = new Client([ + 'base_uri' => 'https://www.baidu.cn', + 'handler' => HandlerStack::create(new CoroutineHandlerStub()), + 'proxy' => [ + 'http' => 'http://127.0.0.1:12333', + 'https' => 'http://127.0.0.1:12334', + 'no' => ['.cn'], + ], + ]); + + $json = json_decode((string) $client->get('/')->getBody(), true); + + $setting = $json['setting']; + + $this->assertArrayNotHasKey('http_proxy_host', $setting); + $this->assertArrayNotHasKey('http_proxy_port', $setting); + } + + /** + * Test that SSL key and certificate options are passed through. + */ + public function testSslKeyAndCert() + { + $client = new Client([ + 'base_uri' => 'http://127.0.0.1:8080', + 'handler' => HandlerStack::create(new CoroutineHandlerStub()), + 'timeout' => 5, + 'cert' => 'apiclient_cert.pem', + 'ssl_key' => 'apiclient_key.pem', + ]); + + $data = json_decode((string) $client->get('/')->getBody(), true); + + $this->assertSame('apiclient_cert.pem', $data['setting']['ssl_cert_file']); + $this->assertSame('apiclient_key.pem', $data['setting']['ssl_key_file']); + + $client = new Client([ + 'base_uri' => 'http://127.0.0.1:8080', + 'handler' => HandlerStack::create(new CoroutineHandlerStub()), + 'timeout' => 5, + ]); + + $data = json_decode((string) $client->get('/')->getBody(), true); + + $this->assertArrayNotHasKey('ssl_cert_file', $data['setting']); + $this->assertArrayNotHasKey('ssl_key_file', $data['setting']); + } + + /** + * Test that user info in URI is converted to Basic auth header. + */ + public function testUserInfo() + { + $url = 'https://username:password@127.0.0.1:8080'; + $handler = new CoroutineHandlerStub(); + $request = new Request('GET', $url . '/echo'); + + $response = $handler($request, ['timeout' => 5])->wait(); + $content = (string) $response->getBody(); + $json = json_decode($content, true); + + $this->assertSame('Basic ' . base64_encode('username:password'), $json['headers']['Authorization']); + } + + /** + * Test that ON_STATS callback is called with transfer stats. + */ + public function testRequestOptionOnStats() + { + $url = 'http://127.0.0.1:9501'; + $handler = new CoroutineHandlerStub(); + $request = new Request('GET', $url . '/echo'); + + $called = false; + $handler($request, [RequestOptions::ON_STATS => function (TransferStats $stats) use (&$called) { + $called = true; + $this->assertIsFloat($stats->getTransferTime()); + }])->wait(); + $this->assertTrue($called); + } + + /** + * Test that ON_STATS callback works when configured on client. + */ + public function testRequestOptionOnStatsInClient() + { + $called = false; + $url = 'http://127.0.0.1:9501'; + $client = new Client([ + 'handler' => new CoroutineHandlerStub(), + 'base_uri' => $url, + RequestOptions::ON_STATS => function (TransferStats $stats) use (&$called) { + $called = true; + $this->assertIsFloat($stats->getTransferTime()); + }, + ]); + $client->get('/'); + $this->assertTrue($called); + } + + /** + * Test that response body can be written to a file sink. + */ + public function testSink() + { + $dir = sys_get_temp_dir() . '/hypervel-guzzle-test/'; + @mkdir($dir, 0755, true); + + $handler = new CoroutineHandlerStub(); + $stream = $handler->createSink($body = uniqid(), $sink = $dir . uniqid()); + $this->assertSame($body, file_get_contents($sink)); + $this->assertSame('', stream_get_contents($stream)); + + $stream = $handler->createSink($body = uniqid(), $sink); + $this->assertSame($body, file_get_contents($sink)); + $this->assertSame('', stream_get_contents($stream)); + fseek($stream, 0); + $this->assertSame($body, stream_get_contents($stream)); + } + + /** + * Test that response body can be written to a resource sink. + */ + public function testResourceSink() + { + $dir = sys_get_temp_dir() . '/hypervel-guzzle-test/'; + @mkdir($dir, 0755, true); + $sink = fopen($file = $dir . uniqid(), 'w+'); + $handler = new CoroutineHandlerStub(); + $stream = $handler->createSink($body1 = uniqid(), $sink); + $this->assertSame('', stream_get_contents($stream)); + $stream = $handler->createSink($body2 = uniqid(), $sink); + $this->assertSame('', stream_get_contents($stream)); + $this->assertSame($body1 . $body2, file_get_contents($file)); + fseek($sink, 0); + $this->assertSame($body1 . $body2, stream_get_contents($stream)); + } + + /** + * Test that Expect and Content-Length headers are removed by default. + * + * These headers can cause issues with Swoole's coroutine HTTP client. + */ + public function testExpect100Continue() + { + $url = 'http://127.0.0.1:9501'; + $client = new Client([ + 'handler' => HandlerStack::create(new CoroutineHandlerStub()), + 'base_uri' => $url, + ]); + $response = $client->post('/', [ + RequestOptions::JSON => [ + 'data' => str_repeat(uniqid(), 100000), + ], + ]); + + $data = json_decode((string) $response->getBody(), true); + $this->assertArrayNotHasKey('Content-Length', $data['headers']); + $this->assertArrayNotHasKey('Expect', $data['headers']); + + $stub = m::mock(CoroutineHandlerStub::class . '[rewriteHeaders]'); + $stub->shouldReceive('rewriteHeaders')->withAnyArgs()->andReturnUsing(function ($headers) { + return $headers; + }); + + $client = new Client([ + 'handler' => HandlerStack::create($stub), + 'base_uri' => $url, + ]); + $response = $client->post('/', [ + RequestOptions::JSON => [ + 'data' => str_repeat(uniqid(), 100000), + ], + ]); + + $data = json_decode((string) $response->getBody(), true); + $this->assertArrayHasKey('Content-Length', $data['headers']); + $this->assertArrayHasKey('Expect', $data['headers']); + } + + /** + * Create a new CoroutineHandler instance. + */ + protected function getHandler(array $options = []): CoroutineHandler + { + return new CoroutineHandler(); + } +} diff --git a/tests/Guzzle/HandlerStackFactoryTest.php b/tests/Guzzle/HandlerStackFactoryTest.php new file mode 100644 index 000000000..85f4579fc --- /dev/null +++ b/tests/Guzzle/HandlerStackFactoryTest.php @@ -0,0 +1,197 @@ +shouldReceive('has')->with(CoroutineHandler::class)->andReturnFalse(); + $container->shouldReceive('make')->with(CoroutineHandler::class, m::any())->andReturn(new CoroutineHandler()); + ApplicationContext::setContainer($container); + + $factory = new HandlerStackFactory(); + $stack = $factory->create(); + $this->assertInstanceOf(HandlerStack::class, $stack); + $this->assertTrue($stack->hasHandler()); + + $reflection = new ReflectionClass($stack); + + $handler = $reflection->getProperty('handler'); + $this->assertInstanceOf(CoroutineHandler::class, $handler->getValue($stack)); + + $property = $reflection->getProperty('stack'); + foreach ($property->getValue($stack) as $stackItem) { + $this->assertTrue(in_array($stackItem[1], ['http_errors', 'allow_redirects', 'cookies', 'prepare_body', 'retry'])); + } + } + + /** + * Test that factory uses container to make CoroutineHandler when available. + */ + public function testMakeCoroutineHandler() + { + $container = m::mock(Container::class); + ApplicationContext::setContainer($container); + $container->shouldReceive('has')->with(CoroutineHandler::class)->andReturnFalse(); + $container->shouldReceive('make')->with(CoroutineHandler::class, m::any())->andReturn(new CoroutineHandler()); + + $factory = new HandlerStackFactoryStub(); + $stack = $factory->create(); + $this->assertInstanceOf(HandlerStack::class, $stack); + $this->assertTrue($stack->hasHandler()); + + $reflection = new ReflectionClass($stack); + + $handler = $reflection->getProperty('handler'); + $this->assertInstanceOf(CoroutineHandler::class, $handler->getValue($stack)); + + $property = $reflection->getProperty('stack'); + foreach ($property->getValue($stack) as $stackItem) { + $this->assertTrue(in_array($stackItem[1], ['http_errors', 'allow_redirects', 'cookies', 'prepare_body', 'retry'])); + } + } + + /** + * Test that factory creates a PoolHandler when pool factory is available. + */ + public function testCreatePoolHandler() + { + $this->setContainer(); + + $factory = new HandlerStackFactory(); + $stack = $factory->create(); + $this->assertTrue($stack->hasHandler()); + $this->assertInstanceOf(HandlerStack::class, $stack); + + $reflection = new ReflectionClass($stack); + + $handler = $reflection->getProperty('handler'); + $this->assertInstanceOf(PoolHandler::class, $handler->getValue($stack)); + + $property = $reflection->getProperty('stack'); + $items = array_column($property->getValue($stack), 1); + + $this->assertSame(['http_errors', 'allow_redirects', 'cookies', 'prepare_body', 'retry'], $items); + } + + /** + * Test that pool options are passed through to the handler. + */ + public function testPoolHandlerOption() + { + $this->setContainer(); + + $factory = new HandlerStackFactory(); + $stack = $factory->create(['max_connections' => 50]); + + $stackReflection = new ReflectionClass($stack); + $handler = $stackReflection->getProperty('handler'); + $handler = $handler->getValue($stack); + + $handlerReflection = new ReflectionClass($handler); + $option = $handlerReflection->getProperty('option'); + + $this->assertSame(50, $option->getValue($handler)['max_connections']); + } + + /** + * Test that custom middleware can be added to the handler stack. + */ + public function testPoolHandlerMiddleware() + { + $this->setContainer(); + + $factory = new HandlerStackFactory(); + $stack = $factory->create([], ['retry_again' => [RetryMiddleware::class, [1, 10]]]); + + $reflection = new ReflectionClass($stack); + $property = $reflection->getProperty('stack'); + $items = array_column($property->getValue($stack), 1); + $this->assertSame(['http_errors', 'allow_redirects', 'cookies', 'prepare_body', 'retry', 'retry_again'], $items); + } + + /** + * Test that retry middleware retries failed requests. + */ + public function testRetryMiddleware() + { + $this->setContainer(); + + $factory = new HandlerStackFactory(); + $stack = $factory->create([], ['retry_again' => [RetryMiddleware::class, [1, 10]]]); + $stack->setHandler($stub = new CoroutineHandlerStub(201)); + + $client = new Client([ + 'handler' => $stack, + 'base_uri' => 'http://127.0.0.1:9501', + ]); + + $response = $client->get('/'); + $this->assertSame(201, $response->getStatusCode()); + $this->assertSame(1, $stub->count); + + $stack = $factory->create([], ['retry' => [RetryMiddleware::class, [1, 10]]]); + $stack->setHandler($stub = new CoroutineHandlerStub(400)); + $client = new Client([ + 'handler' => $stack, + 'base_uri' => 'http://127.0.0.1:9501', + ]); + + $this->expectExceptionCode(400); + $this->expectException(ClientException::class); + $this->expectExceptionMessageMatches('/400 Bad Request/'); + + try { + $client->get('/'); + } catch (Throwable $exception) { + $this->assertSame(2, $stub->count); + throw $exception; + } + } + + /** + * Set up a mock container with pool factory for testing. + */ + protected function setContainer() + { + $container = m::mock(Container::class); + $factory = new PoolFactory($container); + $container->shouldReceive('make')->with(PoolHandler::class, m::any())->andReturnUsing(function ($class, $args) use ($factory) { + return new PoolHandler($factory, $args['option']); + }); + + ApplicationContext::setContainer($container); + } +} diff --git a/tests/Guzzle/Stub/CoroutineHandlerStub.php b/tests/Guzzle/Stub/CoroutineHandlerStub.php new file mode 100644 index 000000000..a9fce5505 --- /dev/null +++ b/tests/Guzzle/Stub/CoroutineHandlerStub.php @@ -0,0 +1,71 @@ +statusCode = $statusCode; + } + + /** + * Expose createSink for testing. + * + * @param resource|string $sink + * @return resource + */ + public function createSink(string $body, $sink) + { + return parent::createSink($body, $sink); + } + + /** + * Expose rewriteHeaders for testing. + */ + public function rewriteHeaders(array $headers): array + { + return parent::rewriteHeaders($headers); + } + + /** + * Create a mock client that captures request details. + */ + protected function makeClient(string $host, int $port, bool $ssl): Client + { + $client = m::mock(Client::class . '[request]', [$host, $port, $ssl]); + $client->shouldReceive('request')->withAnyArgs()->andReturnUsing(function ($method, $path, $headers, $body) use ($host, $port, $ssl, $client) { + ++$this->count; + $body = json_encode([ + 'host' => $host, + 'port' => $port, + 'ssl' => $ssl, + 'method' => $method, + 'headers' => $headers, + 'setting' => $client->setting, + 'uri' => $path, + 'body' => $body, + ]); + return new RawResponse($this->statusCode, [], $body, '1.1'); + }); + return $client; + } +} diff --git a/tests/Guzzle/Stub/HandlerStackFactoryStub.php b/tests/Guzzle/Stub/HandlerStackFactoryStub.php new file mode 100644 index 000000000..3b3cef813 --- /dev/null +++ b/tests/Guzzle/Stub/HandlerStackFactoryStub.php @@ -0,0 +1,21 @@ +usePoolHandler = false; + } +} diff --git a/tests/Hashing/HasherTest.php b/tests/Hashing/HasherTest.php index d5bbf8cb0..7144c39bb 100644 --- a/tests/Hashing/HasherTest.php +++ b/tests/Hashing/HasherTest.php @@ -4,15 +4,14 @@ namespace Hypervel\Tests\Hashing; -use Hyperf\Config\Config; -use Hyperf\Contract\ConfigInterface; use Hyperf\Contract\ContainerInterface; +use Hypervel\Config\Repository as ConfigRepository; use Hypervel\Hashing\Argon2IdHasher; use Hypervel\Hashing\ArgonHasher; use Hypervel\Hashing\BcryptHasher; use Hypervel\Hashing\HashManager; use Hypervel\Tests\TestCase; -use Mockery; +use Mockery as m; use RuntimeException; /** @@ -129,10 +128,10 @@ public function testIsHashedWithNonHashedValue() protected function getContainer() { - $container = Mockery::mock(ContainerInterface::class); + $container = m::mock(ContainerInterface::class); $container->shouldReceive('get') - ->with(ConfigInterface::class) - ->andReturn($config = new Config([ + ->with('config') + ->andReturn($config = new ConfigRepository([ 'hashing' => [ 'driver' => 'bcrypt', 'bcrypt' => [ diff --git a/tests/Horizon/Controller/DashboardStatsControllerTest.php b/tests/Horizon/Controller/DashboardStatsControllerTest.php index 27d5f9803..8681d485c 100644 --- a/tests/Horizon/Controller/DashboardStatsControllerTest.php +++ b/tests/Horizon/Controller/DashboardStatsControllerTest.php @@ -10,7 +10,7 @@ use Hypervel\Horizon\Contracts\SupervisorRepository; use Hypervel\Horizon\WaitTimeCalculator; use Hypervel\Tests\Horizon\ControllerTestCase; -use Mockery; +use Mockery as m; /** * @internal @@ -21,7 +21,7 @@ class DashboardStatsControllerTest extends ControllerTestCase public function testAllStatsAreCorrectlyReturned() { // Setup supervisor data... - $supervisors = Mockery::mock(SupervisorRepository::class); + $supervisors = m::mock(SupervisorRepository::class); $supervisors->shouldReceive('all')->andReturn([ (object) [ 'processes' => [ @@ -38,19 +38,19 @@ public function testAllStatsAreCorrectlyReturned() $this->app->instance(SupervisorRepository::class, $supervisors); // Setup metrics data... - $metrics = Mockery::mock(MetricsRepository::class); + $metrics = m::mock(MetricsRepository::class); $metrics->shouldReceive('jobsProcessedPerMinute')->andReturn(1); $metrics->shouldReceive('queueWithMaximumRuntime')->andReturn('default'); $metrics->shouldReceive('queueWithMaximumThroughput')->andReturn('default'); $this->app->instance(MetricsRepository::class, $metrics); - $jobs = Mockery::mock(JobRepository::class); + $jobs = m::mock(JobRepository::class); $jobs->shouldReceive('countRecentlyFailed')->andReturn(1); $jobs->shouldReceive('countRecent')->andReturn(1); $this->app->instance(JobRepository::class, $jobs); // Setup wait time data... - $wait = Mockery::mock(WaitTimeCalculator::class); + $wait = m::mock(WaitTimeCalculator::class); $wait->shouldReceive('calculate')->andReturn([ 'first' => 20, 'second' => 10, @@ -81,7 +81,7 @@ public function testAllStatsAreCorrectlyReturned() public function testPausedStatusIsReflectedIfAllMasterSupervisorsArePaused() { - $masters = Mockery::mock(MasterSupervisorRepository::class); + $masters = m::mock(MasterSupervisorRepository::class); $masters->shouldReceive('all')->andReturn([ (object) [ 'status' => 'paused', @@ -102,7 +102,7 @@ public function testPausedStatusIsReflectedIfAllMasterSupervisorsArePaused() public function testPausedStatusIsntReflectedIfNotAllMasterSupervisorsArePaused() { - $masters = Mockery::mock(MasterSupervisorRepository::class); + $masters = m::mock(MasterSupervisorRepository::class); $masters->shouldReceive('all')->andReturn([ (object) [ 'status' => 'running', diff --git a/tests/Horizon/Controller/MonitoringControllerTest.php b/tests/Horizon/Controller/MonitoringControllerTest.php index 00bbc2f45..715eca6f6 100644 --- a/tests/Horizon/Controller/MonitoringControllerTest.php +++ b/tests/Horizon/Controller/MonitoringControllerTest.php @@ -8,7 +8,7 @@ use Hypervel\Horizon\Contracts\TagRepository; use Hypervel\Horizon\JobPayload; use Hypervel\Tests\Horizon\ControllerTestCase; -use Mockery; +use Mockery as m; /** * @internal @@ -18,7 +18,7 @@ class MonitoringControllerTest extends ControllerTestCase { public function testMonitoredTagsAndJobCountsAreReturned() { - $tags = Mockery::mock(TagRepository::class); + $tags = m::mock(TagRepository::class); $tags->shouldReceive('monitoring')->andReturn(['first', 'second']); $tags->shouldReceive('count')->with('first')->andReturn(1); diff --git a/tests/Horizon/Feature/AuthTest.php b/tests/Horizon/Feature/AuthTest.php index 4fd9fd830..cfaaab64c 100644 --- a/tests/Horizon/Feature/AuthTest.php +++ b/tests/Horizon/Feature/AuthTest.php @@ -9,7 +9,7 @@ use Hypervel\Horizon\Http\Middleware\Authenticate; use Hypervel\Http\Response; use Hypervel\Tests\Horizon\IntegrationTestCase; -use Mockery; +use Mockery as m; use Psr\Http\Message\ServerRequestInterface; /** @@ -24,10 +24,10 @@ public function testAuthenticationCallbackWorks() return $request->getAttribute('user') === 'foo'; }); - $fooRequestMock = Mockery::mock(ServerRequestInterface::class); + $fooRequestMock = m::mock(ServerRequestInterface::class); $fooRequestMock->shouldReceive('getAttribute')->with('user')->andReturn('foo'); - $barRequestMock = Mockery::mock(ServerRequestInterface::class); + $barRequestMock = m::mock(ServerRequestInterface::class); $barRequestMock->shouldReceive('getAttribute')->with('user')->andReturn('bar'); $this->assertTrue(Horizon::check($fooRequestMock)); @@ -41,7 +41,7 @@ public function testAuthenticationMiddlewareCanPass() }); $middleware = new Authenticate(); - $requestMock = Mockery::mock(ServerRequestInterface::class); + $requestMock = m::mock(ServerRequestInterface::class); $response = new Response(); $responseFromMiddleware = $middleware->handle( @@ -61,7 +61,7 @@ public function testAuthenticationMiddlewareThrowsOnFailure() }); $middleware = new Authenticate(); - $requestMock = Mockery::mock(ServerRequestInterface::class); + $requestMock = m::mock(ServerRequestInterface::class); $response = new Response(); $middleware->handle( diff --git a/tests/Horizon/Feature/AutoScalerTest.php b/tests/Horizon/Feature/AutoScalerTest.php index 6612f3fea..500fc0aaa 100644 --- a/tests/Horizon/Feature/AutoScalerTest.php +++ b/tests/Horizon/Feature/AutoScalerTest.php @@ -4,15 +4,15 @@ 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; +use Mockery as m; /** * @internal @@ -135,7 +135,7 @@ public function testScalerWillNotScalePastMaxProcessThresholdUnderHighLoad() public function testScalerWillNotScaleBelowMinimumWorkerThreshold() { - $external = Mockery::mock(SystemProcessCounter::class); + $external = m::mock(SystemProcessCounter::class); $external->shouldReceive('get')->with('name')->andReturn(5); $this->app->instance(SystemProcessCounter::class, $external); @@ -162,8 +162,8 @@ public function testScalerWillNotScaleBelowMinimumWorkerThreshold() protected function with_scaling_scenario($maxProcesses, array $pools, array $extraOptions = []) { // Mock dependencies... - $queueFactory = Mockery::mock(QueueFactory::class); - $metrics = Mockery::mock(MetricsRepository::class); + $queueFactory = m::mock(QueueFactory::class); + $metrics = m::mock(MetricsRepository::class); // Create scaler... $scaler = new AutoScaler($queueFactory, $metrics); @@ -183,7 +183,7 @@ protected function with_scaling_scenario($maxProcesses, array $pools, array $ext }); // Set stats per pool... - $queue = Mockery::mock(RedisQueue::class); + $queue = m::mock(RedisQueue::class); collect($pools)->each(function ($pool, $name) use ($queue, $metrics) { $queue->shouldReceive('readyNow')->with($name)->andReturn($pool['size']); $metrics->shouldReceive('runtimeForQueue')->with($name)->andReturn($pool['runtime']); @@ -232,7 +232,7 @@ public function testScalerConsidersMaxShiftAndAttemptsToGetCloserToProperBalance public function testScalerDoesNotPermitGoingToZeroProcessesDespiteExceedingMaxProcesses() { - $external = Mockery::mock(SystemProcessCounter::class); + $external = m::mock(SystemProcessCounter::class); $external->shouldReceive('get')->with('name')->andReturn(5); $this->app->instance(SystemProcessCounter::class, $external); 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/MasterSupervisorTest.php b/tests/Horizon/Feature/MasterSupervisorTest.php index d55779a02..4714c0ab8 100644 --- a/tests/Horizon/Feature/MasterSupervisorTest.php +++ b/tests/Horizon/Feature/MasterSupervisorTest.php @@ -17,7 +17,7 @@ use Hypervel\Tests\Horizon\Feature\Fixtures\EternalSupervisor; use Hypervel\Tests\Horizon\Feature\Fixtures\SupervisorProcessWithFakeRestart; use Hypervel\Tests\Horizon\IntegrationTestCase; -use Mockery; +use Mockery as m; use Symfony\Component\Process\Process; /** @@ -43,7 +43,7 @@ public function testNamesCanBeCustomized() public function testMasterProcessMarksCleanExitsAsDeadAndRemovesThem() { - $process = Mockery::mock(Process::class); + $process = m::mock(Process::class); $master = new MasterSupervisor(); $master->working = true; $master->supervisors[] = $supervisorProcess = new SupervisorProcess( @@ -63,7 +63,7 @@ public function testMasterProcessMarksCleanExitsAsDeadAndRemovesThem() public function testMasterProcessMarksDuplicatesAsDeadAndRemovesThem() { - $process = Mockery::mock(Process::class); + $process = m::mock(Process::class); $master = new MasterSupervisor(); $master->working = true; $master->supervisors[] = $supervisorProcess = new SupervisorProcess( @@ -83,7 +83,7 @@ public function testMasterProcessMarksDuplicatesAsDeadAndRemovesThem() public function testMasterProcessRestartsUnexpectedExits() { - $process = Mockery::mock(Process::class); + $process = m::mock(Process::class); $master = new MasterSupervisor(); $master->working = true; $master->supervisors[] = $supervisorProcess = new SupervisorProcessWithFakeRestart( @@ -114,7 +114,7 @@ public function testMasterProcessRestartsUnexpectedExits() public function testMasterProcessRestartsProcessesThatNeverStarted() { - $process = Mockery::mock(Process::class); + $process = m::mock(Process::class); $master = new MasterSupervisor(); $master->working = true; $master->supervisors[] = $supervisorProcess = new SupervisorProcessWithFakeRestart( @@ -133,7 +133,7 @@ public function testMasterProcessRestartsProcessesThatNeverStarted() public function testMasterProcessStartsUnstartedProcessesWhenUnpaused() { - $process = Mockery::mock(Process::class); + $process = m::mock(Process::class); $master = new MasterSupervisor(); $master->supervisors[] = $supervisorProcess = new SupervisorProcessWithFakeRestart( $this->supervisorOptions(), @@ -175,7 +175,7 @@ public function testMasterProcessLoopProcessesPendingCommands() public function testMasterProcessInformationIsPersisted() { - $process = Mockery::mock(Process::class); + $process = m::mock(Process::class); $master = new MasterSupervisor(); $master->working = true; $master->supervisors[] = new SupervisorProcess($this->supervisorOptions(), $process); diff --git a/tests/Horizon/Feature/MetricsTest.php b/tests/Horizon/Feature/MetricsTest.php index 09904d421..06a02a48a 100644 --- a/tests/Horizon/Feature/MetricsTest.php +++ b/tests/Horizon/Feature/MetricsTest.php @@ -9,7 +9,7 @@ use Hypervel\Horizon\Stopwatch; use Hypervel\Support\Facades\Queue; use Hypervel\Tests\Horizon\IntegrationTestCase; -use Mockery; +use Mockery as m; /** * @internal @@ -63,7 +63,7 @@ public function testThroughputIsStoredPerQueue() public function testAverageRuntimeIsStoredPerJobClassInMilliseconds() { - $stopwatch = Mockery::mock(Stopwatch::class); + $stopwatch = m::mock(Stopwatch::class); $stopwatch->shouldReceive('start'); $stopwatch->shouldReceive('forget'); $stopwatch->shouldReceive('check')->andReturn(1, 2); @@ -80,7 +80,7 @@ public function testAverageRuntimeIsStoredPerJobClassInMilliseconds() public function testAverageRuntimeIsStoredPerQueueInMilliseconds() { - $stopwatch = Mockery::mock(Stopwatch::class); + $stopwatch = m::mock(Stopwatch::class); $stopwatch->shouldReceive('start'); $stopwatch->shouldReceive('forget'); $stopwatch->shouldReceive('check')->andReturn(1, 2); @@ -111,7 +111,7 @@ public function testListOfAllJobsWithMetricInformationIsMaintained() public function testSnapshotOfMetricsPerformanceCanBeStored() { - $stopwatch = Mockery::mock(Stopwatch::class); + $stopwatch = m::mock(Stopwatch::class); $stopwatch->shouldReceive('start'); $stopwatch->shouldReceive('forget'); $stopwatch->shouldReceive('check')->andReturn(1, 2, 3); @@ -170,7 +170,7 @@ public function testSnapshotOfMetricsPerformanceCanBeStored() public function testJobsProcessedPerMinuteSinceLastSnapshotIsCalculable() { - $stopwatch = Mockery::mock(Stopwatch::class); + $stopwatch = m::mock(Stopwatch::class); $stopwatch->shouldReceive('start'); $stopwatch->shouldReceive('forget'); $stopwatch->shouldReceive('check')->andReturn(1); @@ -207,7 +207,7 @@ public function testJobsProcessedPerMinuteSinceLastSnapshotIsCalculable() public function testOnlyPast24SnapshotsAreRetained() { - $stopwatch = Mockery::mock(Stopwatch::class); + $stopwatch = m::mock(Stopwatch::class); $stopwatch->shouldReceive('start'); $stopwatch->shouldReceive('forget'); $stopwatch->shouldReceive('check')->andReturn(1); diff --git a/tests/Horizon/Feature/MonitorMasterSupervisorMemoryTest.php b/tests/Horizon/Feature/MonitorMasterSupervisorMemoryTest.php index 6c1083529..f4b94c03b 100644 --- a/tests/Horizon/Feature/MonitorMasterSupervisorMemoryTest.php +++ b/tests/Horizon/Feature/MonitorMasterSupervisorMemoryTest.php @@ -9,7 +9,7 @@ use Hypervel\Horizon\MasterSupervisor; use Hypervel\Support\Environment; use Hypervel\Tests\Horizon\IntegrationTestCase; -use Mockery; +use Mockery as m; /** * @internal @@ -21,7 +21,7 @@ protected function setUp(): void { parent::setUp(); - $environment = Mockery::mock(Environment::class); + $environment = m::mock(Environment::class); $environment->shouldReceive('isTesting')->andReturn(false); $this->app->instance(Environment::class, $environment); } @@ -30,7 +30,7 @@ public function testSupervisorIsTerminatedWhenUsingTooMuchMemory() { $monitor = new MonitorMasterSupervisorMemory(); - $master = Mockery::mock(MasterSupervisor::class); + $master = m::mock(MasterSupervisor::class); $master->shouldReceive('memoryUsage')->andReturn(192); $master->shouldReceive('output')->once()->with('error', 'Memory limit exceeded: Using 192/64MB. Consider increasing horizon.memory_limit.'); @@ -43,7 +43,7 @@ public function testSupervisorIsNotTerminatedWhenUsingLowMemory() { $monitor = new MonitorMasterSupervisorMemory(); - $master = Mockery::mock(MasterSupervisor::class); + $master = m::mock(MasterSupervisor::class); $master->shouldReceive('memoryUsage')->andReturn(16); $master->shouldReceive('terminate')->never(); diff --git a/tests/Horizon/Feature/MonitorSupervisorMemoryTest.php b/tests/Horizon/Feature/MonitorSupervisorMemoryTest.php index 3d1907a82..2d7d1c7f3 100644 --- a/tests/Horizon/Feature/MonitorSupervisorMemoryTest.php +++ b/tests/Horizon/Feature/MonitorSupervisorMemoryTest.php @@ -10,7 +10,7 @@ use Hypervel\Horizon\SupervisorOptions; use Hypervel\Support\Environment; use Hypervel\Tests\Horizon\IntegrationTestCase; -use Mockery; +use Mockery as m; /** * @internal @@ -22,7 +22,7 @@ protected function setUp(): void { parent::setUp(); - $environment = Mockery::mock(Environment::class); + $environment = m::mock(Environment::class); $environment->shouldReceive('isTesting')->andReturn(false); $this->app->instance(Environment::class, $environment); } @@ -31,7 +31,7 @@ public function testSupervisorIsTerminatedWhenUsingTooMuchMemory() { $monitor = new MonitorSupervisorMemory(); - $supervisor = Mockery::mock(Supervisor::class); + $supervisor = m::mock(Supervisor::class); $supervisor->options = new SupervisorOptions('redis', 'default'); $supervisor->shouldReceive('memoryUsage')->andReturn(192); @@ -44,7 +44,7 @@ public function testSupervisorIsNotTerminatedWhenUsingLowMemory() { $monitor = new MonitorSupervisorMemory(); - $supervisor = Mockery::mock(Supervisor::class); + $supervisor = m::mock(Supervisor::class); $supervisor->options = new SupervisorOptions('redis', 'default'); $supervisor->shouldReceive('memoryUsage')->andReturn(64); diff --git a/tests/Horizon/Feature/MonitorWaitTimesTest.php b/tests/Horizon/Feature/MonitorWaitTimesTest.php index 330378adb..eb9ea01cf 100644 --- a/tests/Horizon/Feature/MonitorWaitTimesTest.php +++ b/tests/Horizon/Feature/MonitorWaitTimesTest.php @@ -11,7 +11,7 @@ use Hypervel\Horizon\WaitTimeCalculator; use Hypervel\Support\Facades\Event; use Hypervel\Tests\Horizon\IntegrationTestCase; -use Mockery; +use Mockery as m; /** * @internal @@ -23,7 +23,7 @@ public function testQueuesWithLongWaitsAreFound() { Event::fake(); - $calc = Mockery::mock(WaitTimeCalculator::class); + $calc = m::mock(WaitTimeCalculator::class); $calc->shouldReceive('calculate')->andReturn([ 'redis:test-queue' => 10, 'redis:test-queue-2' => 80, @@ -45,7 +45,7 @@ public function testQueueIgnoresLongWaits() Event::fake(); - $calc = Mockery::mock(WaitTimeCalculator::class); + $calc = m::mock(WaitTimeCalculator::class); $calc->expects('calculate')->andReturn([ 'redis:ignore-queue' => 10, ]); @@ -62,11 +62,11 @@ public function testMonitorWaitTimesSkipsWhenLockIsNotAcquired() { Event::fake(); - $calc = Mockery::mock(WaitTimeCalculator::class); + $calc = m::mock(WaitTimeCalculator::class); $calc->expects('calculate')->never(); $this->app->instance(WaitTimeCalculator::class, $calc); - $metrics = Mockery::mock(MetricsRepository::class); + $metrics = m::mock(MetricsRepository::class); $metrics->shouldReceive('acquireWaitTimeMonitorLock')->once()->andReturnFalse(); $this->app->instance(MetricsRepository::class, $metrics); @@ -81,11 +81,11 @@ public function testMonitorWaitTimesSkipsWhenNotDueToMonitor() { Event::fake(); - $calc = Mockery::mock(WaitTimeCalculator::class); + $calc = m::mock(WaitTimeCalculator::class); $calc->expects('calculate')->never(); $this->app->instance(WaitTimeCalculator::class, $calc); - $metrics = Mockery::mock(MetricsRepository::class); + $metrics = m::mock(MetricsRepository::class); $metrics->shouldReceive('acquireWaitTimeMonitorLock')->never(); $this->app->instance(MetricsRepository::class, $metrics); @@ -103,13 +103,13 @@ public function testMonitorWaitTimesSkipsWhenNotDueToMonitorAndExecutesAfter2Min Event::fake(); - $calc = Mockery::mock(WaitTimeCalculator::class); + $calc = m::mock(WaitTimeCalculator::class); $calc->expects('calculate')->once()->andReturn([ 'redis:default' => 70, ]); $this->app->instance(WaitTimeCalculator::class, $calc); - $metrics = Mockery::mock(MetricsRepository::class); + $metrics = m::mock(MetricsRepository::class); $metrics->shouldReceive('acquireWaitTimeMonitorLock')->once()->andReturnTrue(); $this->app->instance(MetricsRepository::class, $metrics); @@ -133,13 +133,13 @@ public function testMonitorWaitTimesExecutesOnceWhenCalledTwice() Event::fake(); - $calc = Mockery::mock(WaitTimeCalculator::class); + $calc = m::mock(WaitTimeCalculator::class); $calc->expects('calculate')->once()->andReturn([ 'redis:default' => 70, ]); $this->app->instance(WaitTimeCalculator::class, $calc); - $metrics = Mockery::mock(MetricsRepository::class); + $metrics = m::mock(MetricsRepository::class); $metrics->shouldReceive('acquireWaitTimeMonitorLock')->once()->andReturnTrue(); $this->app->instance(MetricsRepository::class, $metrics); diff --git a/tests/Horizon/Feature/ProcessInspectorTest.php b/tests/Horizon/Feature/ProcessInspectorTest.php index f67621b32..db7c16c92 100644 --- a/tests/Horizon/Feature/ProcessInspectorTest.php +++ b/tests/Horizon/Feature/ProcessInspectorTest.php @@ -9,7 +9,7 @@ use Hypervel\Horizon\Exec; use Hypervel\Horizon\ProcessInspector; use Hypervel\Tests\Horizon\IntegrationTestCase; -use Mockery; +use Mockery as m; /** * @internal @@ -19,14 +19,14 @@ class ProcessInspectorTest extends IntegrationTestCase { public function testFindsOrphanedProcessIds() { - $exec = Mockery::mock(Exec::class); + $exec = m::mock(Exec::class); $exec->shouldReceive('run')->with('pgrep -f [h]orizon')->andReturn([1, 2, 3, 4, 5, 6]); $exec->shouldReceive('run')->with('pgrep -f horizon:purge')->andReturn([]); $exec->shouldReceive('run')->with('pgrep -P 2')->andReturn([4]); $exec->shouldReceive('run')->with('pgrep -P 3')->andReturn([5]); $this->app->instance(Exec::class, $exec); - $supervisors = Mockery::mock(SupervisorRepository::class); + $supervisors = m::mock(SupervisorRepository::class); $supervisors->shouldReceive('all')->andReturn([ [ 'pid' => 2, @@ -37,7 +37,7 @@ public function testFindsOrphanedProcessIds() ]); $this->app->instance(SupervisorRepository::class, $supervisors); - $masters = Mockery::mock(MasterSupervisorRepository::class); + $masters = m::mock(MasterSupervisorRepository::class); $masters->shouldReceive('all')->andReturn([ [ 'pid' => 6, diff --git a/tests/Horizon/Feature/RedisPayloadTest.php b/tests/Horizon/Feature/RedisPayloadTest.php index 35678bf12..bb1724828 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; @@ -25,7 +25,7 @@ use Hypervel\Tests\Horizon\Feature\Fixtures\SilencedMailable; use Hypervel\Tests\Horizon\IntegrationTestCase; use Illuminate\Events\CallQueuedListener; -use Mockery; +use Mockery as m; use StdClass; /** @@ -44,7 +44,7 @@ public function testTypeIsCorrectlyDetermined() $JobPayload->prepare(new CallQueuedListener('stdClass', 'method', [new StdClass()])); $this->assertSame('event', $JobPayload->decoded['type']); - $JobPayload->prepare(new SendQueuedMailable(Mockery::mock(Mailable::class))); + $JobPayload->prepare(new SendQueuedMailable(m::mock(Mailable::class))); $this->assertSame('mail', $JobPayload->decoded['type']); $JobPayload->prepare(new SendQueuedNotifications([], new StdClass(), ['mail'])); @@ -172,7 +172,7 @@ public function testItDeterminesIfJobIsSilencedCorrectlyForMailable() { $JobPayload = new JobPayload(json_encode(['id' => 1])); - $mailableMock = Mockery::mock(SilencedMailable::class); + $mailableMock = m::mock(SilencedMailable::class); config(['horizon.silenced' => [get_class($mailableMock)]]); $JobPayload->prepare(new SendQueuedMailable($mailableMock)); $this->assertTrue($JobPayload->isSilenced()); 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..fcfae62c0 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; @@ -22,7 +22,7 @@ use Hypervel\Support\Facades\Queue; use Hypervel\Support\Facades\Redis; use Hypervel\Tests\Horizon\IntegrationTestCase; -use Mockery; +use Mockery as m; /** * @internal @@ -176,7 +176,7 @@ public function testSupervisorMonitorsWorkerProcesses() public function testExceptionsAreCaughtAndHandledDuringLoop() { - $exceptions = Mockery::mock(ExceptionHandler::class); + $exceptions = m::mock(ExceptionHandler::class); $exceptions->shouldReceive('report')->once(); $this->app->instance(ExceptionHandler::class, $exceptions); @@ -422,7 +422,7 @@ public function testAutoScalerIsCalledOnLoopWhenAutoScaling() $this->supervisor = $supervisor = new Supervisor($options); // Mock the scaler... - $autoScaler = Mockery::mock(AutoScaler::class); + $autoScaler = m::mock(AutoScaler::class); $autoScaler->shouldReceive('scale')->once()->with($supervisor); $this->app->bind(AutoScaler::class, fn () => $autoScaler); diff --git a/tests/Horizon/Feature/TrimMonitoredJobsTest.php b/tests/Horizon/Feature/TrimMonitoredJobsTest.php index bcfade825..2c34ed5bc 100644 --- a/tests/Horizon/Feature/TrimMonitoredJobsTest.php +++ b/tests/Horizon/Feature/TrimMonitoredJobsTest.php @@ -10,7 +10,7 @@ use Hypervel\Horizon\Listeners\TrimMonitoredJobs; use Hypervel\Horizon\MasterSupervisor; use Hypervel\Tests\Horizon\IntegrationTestCase; -use Mockery; +use Mockery as m; /** * @internal @@ -22,19 +22,19 @@ public function testTrimmerHasACooldownPeriod() { $trim = new TrimMonitoredJobs(); - $repository = Mockery::mock(JobRepository::class); + $repository = m::mock(JobRepository::class); $repository->shouldReceive('trimMonitoredJobs')->twice(); $this->app->instance(JobRepository::class, $repository); // Should not be called first time since date is initialized... - $trim->handle(new MasterSupervisorLooped(Mockery::mock(MasterSupervisor::class))); + $trim->handle(new MasterSupervisorLooped(m::mock(MasterSupervisor::class))); CarbonImmutable::setTestNow(CarbonImmutable::now()->addMinutes(1600)); // Should only be called twice... - $trim->handle(new MasterSupervisorLooped(Mockery::mock(MasterSupervisor::class))); - $trim->handle(new MasterSupervisorLooped(Mockery::mock(MasterSupervisor::class))); - $trim->handle(new MasterSupervisorLooped(Mockery::mock(MasterSupervisor::class))); + $trim->handle(new MasterSupervisorLooped(m::mock(MasterSupervisor::class))); + $trim->handle(new MasterSupervisorLooped(m::mock(MasterSupervisor::class))); + $trim->handle(new MasterSupervisorLooped(m::mock(MasterSupervisor::class))); CarbonImmutable::setTestNow(); } diff --git a/tests/Horizon/Feature/TrimRecentJobsTest.php b/tests/Horizon/Feature/TrimRecentJobsTest.php index 7ef099954..073ce97cf 100644 --- a/tests/Horizon/Feature/TrimRecentJobsTest.php +++ b/tests/Horizon/Feature/TrimRecentJobsTest.php @@ -10,7 +10,7 @@ use Hypervel\Horizon\Listeners\TrimRecentJobs; use Hypervel\Horizon\MasterSupervisor; use Hypervel\Tests\Horizon\IntegrationTestCase; -use Mockery; +use Mockery as m; /** * @internal @@ -22,19 +22,19 @@ public function testTrimmerHasACooldownPeriod() { $trim = new TrimRecentJobs(); - $repository = Mockery::mock(JobRepository::class); + $repository = m::mock(JobRepository::class); $repository->shouldReceive('trimRecentJobs')->twice(); $this->app->instance(JobRepository::class, $repository); // Should not be called first time since date is initialized... - $trim->handle(new MasterSupervisorLooped(Mockery::mock(MasterSupervisor::class))); + $trim->handle(new MasterSupervisorLooped(m::mock(MasterSupervisor::class))); CarbonImmutable::setTestNow(CarbonImmutable::now()->addMinutes(30)); // Should only be called twice... - $trim->handle(new MasterSupervisorLooped(Mockery::mock(MasterSupervisor::class))); - $trim->handle(new MasterSupervisorLooped(Mockery::mock(MasterSupervisor::class))); - $trim->handle(new MasterSupervisorLooped(Mockery::mock(MasterSupervisor::class))); + $trim->handle(new MasterSupervisorLooped(m::mock(MasterSupervisor::class))); + $trim->handle(new MasterSupervisorLooped(m::mock(MasterSupervisor::class))); + $trim->handle(new MasterSupervisorLooped(m::mock(MasterSupervisor::class))); CarbonImmutable::setTestNow(); } diff --git a/tests/Horizon/Feature/WaitTimeCalculatorTest.php b/tests/Horizon/Feature/WaitTimeCalculatorTest.php index d6370df4a..cde006e9b 100644 --- a/tests/Horizon/Feature/WaitTimeCalculatorTest.php +++ b/tests/Horizon/Feature/WaitTimeCalculatorTest.php @@ -4,13 +4,13 @@ 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; +use Mockery as m; /** * @internal @@ -159,10 +159,10 @@ public function testTotalProcessesCanBeZero() protected function with_scenario(array $supervisorSettings, array $queues) { - $queue = Mockery::mock(Queue::class); - $queueFactory = Mockery::mock(QueueFactory::class); - $supervisors = Mockery::mock(SupervisorRepository::class); - $metrics = Mockery::mock(MetricsRepository::class); + $queue = m::mock(Queue::class); + $queueFactory = m::mock(QueueFactory::class); + $supervisors = m::mock(SupervisorRepository::class); + $metrics = m::mock(MetricsRepository::class); $supervisors->shouldReceive('all')->andReturn($supervisorSettings); $queueFactory->shouldReceive('connection')->andReturn($queue); diff --git a/tests/Horizon/IntegrationTestCase.php b/tests/Horizon/IntegrationTestCase.php index b638cd71c..99ef16ae5 100644 --- a/tests/Horizon/IntegrationTestCase.php +++ b/tests/Horizon/IntegrationTestCase.php @@ -5,9 +5,7 @@ namespace Hypervel\Tests\Horizon; use Closure; -use Hyperf\Contract\ConfigInterface; -use Hyperf\Redis\Pool\PoolFactory; -use Hypervel\Foundation\Application; +use Hypervel\Contracts\Foundation\Application as ApplicationContract; use Hypervel\Foundation\Testing\Concerns\RunTestsInCoroutine; use Hypervel\Horizon\Contracts\JobRepository; use Hypervel\Horizon\Contracts\TagRepository; @@ -18,6 +16,7 @@ use Hypervel\Horizon\WorkerCommandString; use Hypervel\Queue\Worker; use Hypervel\Queue\WorkerOptions; +use Hypervel\Redis\Pool\PoolFactory; use Hypervel\Support\Facades\Redis; use Hypervel\Testbench\TestCase; @@ -47,7 +46,7 @@ protected function setUp(): void protected function tearDown(): void { - $config = $this->app->get(ConfigInterface::class); + $config = $this->app->get('config'); $config->set('queue', $this->originalQueueConfig); $poolFactory = $this->app->get(PoolFactory::class); @@ -72,7 +71,7 @@ public function setUpInCoroutine() protected function loadServiceProviders(): void { - $config = $this->app->get(ConfigInterface::class); + $config = $this->app->get('config'); $config->set('horizon.middleware', [Authenticate::class]); $config->set('horizon.prefix', static::HORIZON_PREFIX); @@ -176,7 +175,7 @@ protected function workerOptions(): WorkerOptions /** * Get the service providers for the package. */ - protected function getPackageProviders(Application $app): array + protected function getPackageProviders(ApplicationContract $app): array { return ['Hypervel\Horizon\HorizonServiceProvider']; } @@ -184,7 +183,7 @@ protected function getPackageProviders(Application $app): array /** * Configure the environment. */ - protected function getEnvironmentSetUp(Application $app): void + protected function getEnvironmentSetUp(ApplicationContract $app): void { $app['config']->set('queue.default', 'redis'); } 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..0db4f9e9c 100644 --- a/tests/Horizon/worker.php +++ b/tests/Horizon/worker.php @@ -4,15 +4,14 @@ require __DIR__ . '/../../vendor/autoload.php'; -use Hyperf\Context\ApplicationContext; use Hyperf\Contract\ApplicationInterface; -use Hyperf\Contract\ConfigInterface; -use Hyperf\Coordinator\Constants; -use Hyperf\Coordinator\CoordinatorManager; +use Hypervel\Context\ApplicationContext; +use Hypervel\Contracts\Console\Kernel as KernelContract; +use Hypervel\Contracts\Debug\ExceptionHandler as ExceptionHandlerContract; +use Hypervel\Coordinator\Constants; +use Hypervel\Coordinator\CoordinatorManager; 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; @@ -20,7 +19,7 @@ use Hypervel\Tests\Horizon\IntegrationTestCase; use Workbench\App\Exceptions\ExceptionHandler; -use function Hyperf\Coroutine\run; +use function Hypervel\Coroutine\run; Bootstrapper::bootstrap(); @@ -31,7 +30,7 @@ ApplicationContext::setContainer($app); $app->get(ApplicationInterface::class); -$config = $app->get(ConfigInterface::class); +$config = $app->get('config'); $config->set('horizon.prefix', IntegrationTestCase::HORIZON_PREFIX); $config->set('queue', [ 'default' => 'redis', diff --git a/tests/Http/Middleware/HandleCorsTest.php b/tests/Http/Middleware/HandleCorsTest.php index 7bbc42376..b97c54a01 100644 --- a/tests/Http/Middleware/HandleCorsTest.php +++ b/tests/Http/Middleware/HandleCorsTest.php @@ -4,7 +4,6 @@ namespace Hypervel\Tests\Http\Middleware; -use Hyperf\Contract\ConfigInterface; use Hypervel\Foundation\Testing\Concerns\RunTestsInCoroutine; use Hypervel\Http\Middleware\HandleCors; use Hypervel\Http\Request; @@ -23,7 +22,7 @@ public function setUp(): void { parent::setUp(); - $config = $this->app->get(ConfigInterface::class); + $config = $this->app->get('config'); $config->set('cors', [ 'paths' => ['api/*'], @@ -65,7 +64,7 @@ public function testOptionsAllowOriginAllowed() public function testAllowAllOrigins() { - $this->app->get(ConfigInterface::class)->set('cors.allowed_origins', ['*']); + $this->app->get('config')->set('cors.allowed_origins', ['*']); $crawler = $this->options('api/ping', [], [ 'Origin' => 'http://laravel.com', @@ -78,7 +77,7 @@ public function testAllowAllOrigins() public function testAllowAllOriginsWildcard() { - $this->app->get(ConfigInterface::class)->set('cors.allowed_origins', ['*.laravel.com']); + $this->app->get('config')->set('cors.allowed_origins', ['*.laravel.com']); $crawler = $this->options('api/ping', [], [ 'Origin' => 'http://test.laravel.com', @@ -91,7 +90,7 @@ public function testAllowAllOriginsWildcard() public function testOriginsWildcardIncludesNestedSubdomains() { - $this->app->get(ConfigInterface::class)->set('cors.allowed_origins', ['*.laravel.com']); + $this->app->get('config')->set('cors.allowed_origins', ['*.laravel.com']); $crawler = $this->options('api/ping', [], [ 'Origin' => 'http://api.service.test.laravel.com', @@ -104,7 +103,7 @@ public function testOriginsWildcardIncludesNestedSubdomains() public function testAllowAllOriginsWildcardNoMatch() { - $this->app->get(ConfigInterface::class)->set('cors.allowed_origins', ['*.laravel.com']); + $this->app->get('config')->set('cors.allowed_origins', ['*.laravel.com']); $crawler = $this->options('api/ping', [], [ 'Origin' => 'http://test.symfony.com', @@ -172,7 +171,7 @@ public function testAllowHeaderAllowedOptions() public function testAllowHeaderAllowedWildcardOptions() { - $this->app->get(ConfigInterface::class)->set('cors.allowed_headers', ['*']); + $this->app->get('config')->set('cors.allowed_headers', ['*']); $crawler = $this->options('api/ping', [], [ 'Origin' => 'http://localhost', @@ -209,7 +208,7 @@ public function testAllowHeaderAllowed() public function testAllowHeaderAllowedWildcard() { - $this->app->get(ConfigInterface::class)->set('cors.allowed_headers', ['*']); + $this->app->get('config')->set('cors.allowed_headers', ['*']); $crawler = $this->post('web/ping', [], [ 'Origin' => 'http://localhost', diff --git a/tests/Http/RequestTest.php b/tests/Http/RequestTest.php index c11382601..aa892e840 100644 --- a/tests/Http/RequestTest.php +++ b/tests/Http/RequestTest.php @@ -5,24 +5,24 @@ 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\Context\ApplicationContext; +use Hypervel\Context\Context; +use Hypervel\Contracts\Container\Container; +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 Mockery as m; use PHPUnit\Framework\TestCase; -use Psr\Container\ContainerInterface; use Psr\Http\Message\ServerRequestInterface; use RuntimeException; use Swow\Psr7\Message\ServerRequestPlusInterface; @@ -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'); @@ -44,7 +43,7 @@ protected function tearDown(): void public function testAllFiles() { - $psrRequest = Mockery::mock(ServerRequestPlusInterface::class); + $psrRequest = m::mock(ServerRequestPlusInterface::class); $psrRequest->shouldReceive('getUploadedFiles')->andReturn([ 'file' => new UploadedFile('/tmp/tmp_name', 32, 0), ]); @@ -56,7 +55,7 @@ public function testAllFiles() public function testAnyFilled() { - $psrRequest = Mockery::mock(ServerRequestPlusInterface::class); + $psrRequest = m::mock(ServerRequestPlusInterface::class); $psrRequest->shouldReceive('getParsedBody')->andReturn(['name' => 'John', 'email' => '']); $psrRequest->shouldReceive('getQueryParams')->andReturn([]); Context::set(ServerRequestInterface::class, $psrRequest); @@ -68,7 +67,7 @@ public function testAnyFilled() public function testAll() { - $psrRequest = Mockery::mock(ServerRequestPlusInterface::class); + $psrRequest = m::mock(ServerRequestPlusInterface::class); $psrRequest->shouldReceive('getParsedBody')->andReturn(['name' => 'John', 'email' => '']); $psrRequest->shouldReceive('getQueryParams')->andReturn(['foo' => 'bar']); $psrRequest->shouldReceive('getUploadedFiles')->andReturn([ @@ -98,7 +97,7 @@ public function testAll() public function testBoolean() { - $psrRequest = Mockery::mock(ServerRequestPlusInterface::class); + $psrRequest = m::mock(ServerRequestPlusInterface::class); $psrRequest->shouldReceive('getParsedBody')->andReturn(['active' => '1', 'inactive' => '0']); $psrRequest->shouldReceive('getQueryParams')->andReturn([]); Context::set(ServerRequestInterface::class, $psrRequest); @@ -111,7 +110,7 @@ public function testBoolean() public function testCollect() { - $psrRequest = Mockery::mock(ServerRequestPlusInterface::class); + $psrRequest = m::mock(ServerRequestPlusInterface::class); $psrRequest->shouldReceive('getParsedBody')->andReturn(['name' => 'John', 'age' => 30]); $psrRequest->shouldReceive('getQueryParams')->andReturn([]); $psrRequest->shouldReceive('getUploadedFiles')->andReturn([]); @@ -128,7 +127,7 @@ public function testCollect() public function testDate() { - $psrRequest = Mockery::mock(ServerRequestPlusInterface::class); + $psrRequest = m::mock(ServerRequestPlusInterface::class); $psrRequest->shouldReceive('getParsedBody')->andReturn(['created_at' => '2023-05-15']); $psrRequest->shouldReceive('getQueryParams')->andReturn([]); Context::set(ServerRequestInterface::class, $psrRequest); @@ -144,7 +143,7 @@ public function testDate() public function testEnum() { - $psrRequest = Mockery::mock(ServerRequestPlusInterface::class); + $psrRequest = m::mock(ServerRequestPlusInterface::class); $psrRequest->shouldReceive('getParsedBody')->andReturn(['status' => 'active']); $psrRequest->shouldReceive('getQueryParams')->andReturn([]); Context::set(ServerRequestInterface::class, $psrRequest); @@ -157,7 +156,7 @@ public function testEnum() public function testExcept() { - $psrRequest = Mockery::mock(ServerRequestPlusInterface::class); + $psrRequest = m::mock(ServerRequestPlusInterface::class); $psrRequest->shouldReceive('getParsedBody')->andReturn(['name' => 'John', 'age' => 30, 'email' => 'john@example.com']); $psrRequest->shouldReceive('getQueryParams')->andReturn([]); $psrRequest->shouldReceive('getUploadedFiles')->andReturn([]); @@ -170,7 +169,7 @@ public function testExcept() public function testExists() { - $psrRequest = Mockery::mock(ServerRequestPlusInterface::class); + $psrRequest = m::mock(ServerRequestPlusInterface::class); $psrRequest->shouldReceive('getParsedBody')->andReturn(['name' => 'John']); $psrRequest->shouldReceive('getQueryParams')->andReturn([]); Context::set(ServerRequestInterface::class, $psrRequest); @@ -182,7 +181,7 @@ public function testExists() public function testFilled() { - $psrRequest = Mockery::mock(ServerRequestPlusInterface::class); + $psrRequest = m::mock(ServerRequestPlusInterface::class); $psrRequest->shouldReceive('getParsedBody')->andReturn(['name' => 'John', 'email' => '']); $psrRequest->shouldReceive('getQueryParams')->andReturn([]); Context::set(ServerRequestInterface::class, $psrRequest); @@ -195,7 +194,7 @@ public function testFilled() public function testFloat() { - $psrRequest = Mockery::mock(ServerRequestPlusInterface::class); + $psrRequest = m::mock(ServerRequestPlusInterface::class); $psrRequest->shouldReceive('getParsedBody')->andReturn(['price' => '10.5']); $psrRequest->shouldReceive('getQueryParams')->andReturn([]); Context::set(ServerRequestInterface::class, $psrRequest); @@ -207,7 +206,7 @@ public function testFloat() public function testString() { - $psrRequest = Mockery::mock(ServerRequestPlusInterface::class); + $psrRequest = m::mock(ServerRequestPlusInterface::class); $psrRequest->shouldReceive('getParsedBody')->andReturn(['name' => 'John']); $psrRequest->shouldReceive('getQueryParams')->andReturn([]); Context::set(ServerRequestInterface::class, $psrRequest); @@ -220,7 +219,7 @@ public function testString() public function testHasAny() { - $psrRequest = Mockery::mock(ServerRequestPlusInterface::class); + $psrRequest = m::mock(ServerRequestPlusInterface::class); $psrRequest->shouldReceive('getParsedBody')->andReturn(['name' => 'John', 'age' => 30]); $psrRequest->shouldReceive('getQueryParams')->andReturn([]); $psrRequest->shouldReceive('getUploadedFiles')->andReturn([]); @@ -233,7 +232,7 @@ public function testHasAny() public function testGetHost() { - $psrRequest = Mockery::mock(ServerRequestPlusInterface::class); + $psrRequest = m::mock(ServerRequestPlusInterface::class); $psrRequest->shouldReceive('hasHeader')->with('HOST')->andReturn(true); $psrRequest->shouldReceive('getHeaderLine')->with('HOST')->andReturn('example.com:8080'); Context::set(ServerRequestInterface::class, $psrRequest); @@ -244,7 +243,7 @@ public function testGetHost() public function testGetHttpHost() { - $psrRequest = Mockery::mock(ServerRequestPlusInterface::class); + $psrRequest = m::mock(ServerRequestPlusInterface::class); $psrRequest->shouldReceive('hasHeader')->with('HOST')->andReturn(true); $psrRequest->shouldReceive('getHeaderLine')->with('HOST')->andReturn('example.com:8080'); Context::set(ServerRequestInterface::class, $psrRequest); @@ -255,7 +254,7 @@ public function testGetHttpHost() public function testGetPort() { - $psrRequest = Mockery::mock(ServerRequestPlusInterface::class); + $psrRequest = m::mock(ServerRequestPlusInterface::class); $psrRequest->shouldReceive('hasHeader')->with('HOST')->andReturn(true); $psrRequest->shouldReceive('getHeaderLine')->with('HOST')->andReturn('example.com:8080'); Context::set(ServerRequestInterface::class, $psrRequest); @@ -266,7 +265,7 @@ public function testGetPort() public function testGetSchemeWithHttp() { - $psrRequest = Mockery::mock(ServerRequestPlusInterface::class); + $psrRequest = m::mock(ServerRequestPlusInterface::class); $psrRequest->shouldReceive('getUri')->andReturn( new HyperfUri('http://localhost/path') ); @@ -278,7 +277,7 @@ public function testGetSchemeWithHttp() public function testGetSchemeWithHttps() { - $psrRequest = Mockery::mock(ServerRequestPlusInterface::class); + $psrRequest = m::mock(ServerRequestPlusInterface::class); $psrRequest->shouldReceive('getUri')->andReturn( new HyperfUri('https://localhost/path') ); @@ -290,7 +289,7 @@ public function testGetSchemeWithHttps() public function testIsSecureWithHttp() { - $psrRequest = Mockery::mock(ServerRequestPlusInterface::class); + $psrRequest = m::mock(ServerRequestPlusInterface::class); $psrRequest->shouldReceive('getUri')->andReturn( new HyperfUri('http://localhost/path') ); @@ -302,7 +301,7 @@ public function testIsSecureWithHttp() public function testIsSecureWithHttps() { - $psrRequest = Mockery::mock(ServerRequestPlusInterface::class); + $psrRequest = m::mock(ServerRequestPlusInterface::class); $psrRequest->shouldReceive('getUri')->andReturn( new HyperfUri('https://localhost/path') ); @@ -314,7 +313,7 @@ public function testIsSecureWithHttps() public function testInteger() { - $psrRequest = Mockery::mock(ServerRequestPlusInterface::class); + $psrRequest = m::mock(ServerRequestPlusInterface::class); $psrRequest->shouldReceive('getParsedBody')->andReturn(['age' => '30']); $psrRequest->shouldReceive('getQueryParams')->andReturn([]); Context::set(ServerRequestInterface::class, $psrRequest); @@ -326,7 +325,7 @@ public function testInteger() public function testIsEmptyString() { - $psrRequest = Mockery::mock(ServerRequestPlusInterface::class); + $psrRequest = m::mock(ServerRequestPlusInterface::class); $psrRequest->shouldReceive('getParsedBody')->andReturn(['name' => '', 'age' => '30']); $psrRequest->shouldReceive('getQueryParams')->andReturn([]); Context::set(ServerRequestInterface::class, $psrRequest); @@ -338,7 +337,7 @@ public function testIsEmptyString() public function testIsJson() { - $psrRequest = Mockery::mock(ServerRequestPlusInterface::class); + $psrRequest = m::mock(ServerRequestPlusInterface::class); $psrRequest->shouldReceive('hasHeader')->with('CONTENT_TYPE')->andReturn(true); $psrRequest->shouldReceive('getHeaderLine')->with('CONTENT_TYPE')->andReturn('application/json'); Context::set(ServerRequestInterface::class, $psrRequest); @@ -349,7 +348,7 @@ public function testIsJson() public function testIsNotFilled() { - $psrRequest = Mockery::mock(ServerRequestPlusInterface::class); + $psrRequest = m::mock(ServerRequestPlusInterface::class); $psrRequest->shouldReceive('getParsedBody')->andReturn(['name' => '', 'age' => '30']); $psrRequest->shouldReceive('getQueryParams')->andReturn([]); Context::set(ServerRequestInterface::class, $psrRequest); @@ -361,7 +360,7 @@ public function testIsNotFilled() public function testKeys() { - $psrRequest = Mockery::mock(ServerRequestPlusInterface::class); + $psrRequest = m::mock(ServerRequestPlusInterface::class); $psrRequest->shouldReceive('getParsedBody')->andReturn(['name' => 'John', 'age' => 30]); $psrRequest->shouldReceive('getQueryParams')->andReturn([]); $psrRequest->shouldReceive('getUploadedFiles')->andReturn([]); @@ -373,7 +372,7 @@ public function testKeys() public function testMerge() { - $psrRequest = Mockery::mock(ServerRequestPlusInterface::class); + $psrRequest = m::mock(ServerRequestPlusInterface::class); $psrRequest->shouldReceive('getUploadedFiles')->andReturn([]); Context::set(ServerRequestInterface::class, $psrRequest); @@ -386,7 +385,7 @@ public function testMerge() public function testReplace() { - $psrRequest = Mockery::mock(ServerRequestPlusInterface::class); + $psrRequest = m::mock(ServerRequestPlusInterface::class); $psrRequest->shouldReceive('getUploadedFiles')->andReturn([]); Context::set(ServerRequestInterface::class, $psrRequest); @@ -399,7 +398,7 @@ public function testReplace() public function testMergeIfMissing() { - $psrRequest = Mockery::mock(ServerRequestPlusInterface::class); + $psrRequest = m::mock(ServerRequestPlusInterface::class); $psrRequest->shouldReceive('getParsedBody')->andReturn(['name' => 'John']); $psrRequest->shouldReceive('getQueryParams')->andReturn([]); $psrRequest->shouldReceive('getUploadedFiles')->andReturn([]); @@ -412,7 +411,7 @@ public function testMergeIfMissing() public function testMissing() { - $psrRequest = Mockery::mock(ServerRequestPlusInterface::class); + $psrRequest = m::mock(ServerRequestPlusInterface::class); $psrRequest->shouldReceive('getParsedBody')->andReturn(['name' => 'John']); $psrRequest->shouldReceive('getQueryParams')->andReturn([]); Context::set(ServerRequestInterface::class, $psrRequest); @@ -424,7 +423,7 @@ public function testMissing() public function testOnly() { - $psrRequest = Mockery::mock(ServerRequestPlusInterface::class); + $psrRequest = m::mock(ServerRequestPlusInterface::class); $psrRequest->shouldReceive('getParsedBody')->andReturn(['name' => 'John', 'age' => 30, 'email' => 'john@example.com']); $psrRequest->shouldReceive('getQueryParams')->andReturn([]); $psrRequest->shouldReceive('getUploadedFiles')->andReturn([]); @@ -437,7 +436,7 @@ public function testOnly() public function testSchemeAndHttpHost() { - $psrRequest = Mockery::mock(ServerRequestPlusInterface::class); + $psrRequest = m::mock(ServerRequestPlusInterface::class); $psrRequest->shouldReceive('hasHeader')->with('HOST')->andReturn(true); $psrRequest->shouldReceive('getHeaderLine')->with('HOST')->andReturn('example.com:8080'); $psrRequest->shouldReceive('getUri')->andReturn( @@ -451,7 +450,7 @@ public function testSchemeAndHttpHost() public function testExpectsJson() { - $psrRequest = Mockery::mock(ServerRequestPlusInterface::class); + $psrRequest = m::mock(ServerRequestPlusInterface::class); $psrRequest->shouldReceive('hasHeader')->with('X-Requested-With')->andReturn(true); $psrRequest->shouldReceive('hasHeader')->with('X-PJAX')->andReturn(false); $psrRequest->shouldReceive('hasHeader')->with('Accept')->andReturn(false); @@ -465,7 +464,7 @@ public function testExpectsJson() public function testWantsJson() { - $psrRequest = Mockery::mock(ServerRequestPlusInterface::class); + $psrRequest = m::mock(ServerRequestPlusInterface::class); $psrRequest->shouldReceive('hasHeader')->with('Accept')->andReturn(true); $psrRequest->shouldReceive('getHeaderLine')->with('Accept')->andReturn('application/json'); Context::set(ServerRequestInterface::class, $psrRequest); @@ -476,7 +475,7 @@ public function testWantsJson() public function testAccepts() { - $psrRequest = Mockery::mock(ServerRequestPlusInterface::class); + $psrRequest = m::mock(ServerRequestPlusInterface::class); $psrRequest->shouldReceive('hasHeader')->with('Accept')->andReturn(true); $psrRequest->shouldReceive('getHeaderLine')->with('Accept')->andReturn('application/json'); Context::set(ServerRequestInterface::class, $psrRequest); @@ -487,7 +486,7 @@ public function testAccepts() public function testPrefers() { - $psrRequest = Mockery::mock(ServerRequestPlusInterface::class); + $psrRequest = m::mock(ServerRequestPlusInterface::class); $psrRequest->shouldReceive('hasHeader')->with('Accept')->andReturn(true); $psrRequest->shouldReceive('getHeaderLine')->with('Accept')->andReturn('application/json,text/html'); Context::set(ServerRequestInterface::class, $psrRequest); @@ -498,7 +497,7 @@ public function testPrefers() public function testAcceptsAnyContentType() { - $psrRequest = Mockery::mock(ServerRequestPlusInterface::class); + $psrRequest = m::mock(ServerRequestPlusInterface::class); $psrRequest->shouldReceive('hasHeader')->with('Accept')->andReturn(true); $psrRequest->shouldReceive('getHeaderLine')->with('Accept')->andReturn('*/*'); Context::set(ServerRequestInterface::class, $psrRequest); @@ -509,7 +508,7 @@ public function testAcceptsAnyContentType() public function testAcceptsJson() { - $psrRequest = Mockery::mock(ServerRequestPlusInterface::class); + $psrRequest = m::mock(ServerRequestPlusInterface::class); $psrRequest->shouldReceive('hasHeader')->with('Accept')->andReturn(true); $psrRequest->shouldReceive('getHeaderLine')->with('Accept')->andReturn('application/json'); Context::set(ServerRequestInterface::class, $psrRequest); @@ -520,7 +519,7 @@ public function testAcceptsJson() public function testAcceptsHtml() { - $psrRequest = Mockery::mock(ServerRequestPlusInterface::class); + $psrRequest = m::mock(ServerRequestPlusInterface::class); $psrRequest->shouldReceive('hasHeader')->with('Accept')->andReturn(true); $psrRequest->shouldReceive('getHeaderLine')->with('Accept')->andReturn('text/html'); Context::set(ServerRequestInterface::class, $psrRequest); @@ -531,7 +530,7 @@ public function testAcceptsHtml() public function testWhenFilled() { - $psrRequest = Mockery::mock(ServerRequestPlusInterface::class); + $psrRequest = m::mock(ServerRequestPlusInterface::class); $psrRequest->shouldReceive('getQueryParams')->andReturn([]); $psrRequest->shouldReceive('getParsedBody')->andReturn(['key' => 'value']); $psrRequest->shouldReceive('getUploadedFiles')->andReturn([]); @@ -553,7 +552,7 @@ public function testWhenFilled() public function testWhenHas() { - $psrRequest = Mockery::mock(ServerRequestPlusInterface::class); + $psrRequest = m::mock(ServerRequestPlusInterface::class); $psrRequest->shouldReceive('getQueryParams')->andReturn([]); $psrRequest->shouldReceive('getParsedBody')->andReturn(['key' => 'value']); $psrRequest->shouldReceive('getUploadedFiles')->andReturn([]); @@ -575,7 +574,7 @@ public function testWhenHas() public function testGetClientIp() { - $psrRequest = Mockery::mock(ServerRequestPlusInterface::class); + $psrRequest = m::mock(ServerRequestPlusInterface::class); $psrRequest->shouldReceive('getHeaderLine')->with('x-real-ip')->andReturn('127.0.0.1'); Context::set(ServerRequestInterface::class, $psrRequest); $request = new Request(); @@ -585,7 +584,7 @@ public function testGetClientIp() public function testIp() { - $psrRequest = Mockery::mock(ServerRequestPlusInterface::class); + $psrRequest = m::mock(ServerRequestPlusInterface::class); $psrRequest->shouldReceive('getHeaderLine')->with('x-real-ip')->andReturn('127.0.0.1'); Context::set(ServerRequestInterface::class, $psrRequest); $request = new Request(); @@ -595,7 +594,7 @@ public function testIp() public function testFullUrlWithQuery() { - $psrRequest = Mockery::mock(ServerRequestPlusInterface::class); + $psrRequest = m::mock(ServerRequestPlusInterface::class); $psrRequest->shouldReceive('getQueryParams')->andReturn(['key' => 'value']); $psrRequest->shouldReceive('getServerParams')->andReturn([]); $psrRequest->shouldReceive('getUri')->andReturn( @@ -610,7 +609,7 @@ public function testFullUrlWithQuery() public function testFullUrlWithoutQuery() { - $psrRequest = Mockery::mock(ServerRequestPlusInterface::class); + $psrRequest = m::mock(ServerRequestPlusInterface::class); $psrRequest->shouldReceive('getQueryParams')->andReturn(['key' => 'value', 'foo' => 'bar']); $psrRequest->shouldReceive('getServerParams')->andReturn([]); $psrRequest->shouldReceive('getUri')->andReturn( @@ -624,7 +623,7 @@ public function testFullUrlWithoutQuery() public function testRoot() { - $psrRequest = Mockery::mock(ServerRequestPlusInterface::class); + $psrRequest = m::mock(ServerRequestPlusInterface::class); $psrRequest->shouldReceive('hasHeader')->with('HOST')->andReturn(true); $psrRequest->shouldReceive('getHeaderLine')->with('HOST')->andReturn('example.com:8080'); $psrRequest->shouldReceive('getUri')->andReturn( @@ -638,7 +637,7 @@ public function testRoot() public function testMethod() { - $psrRequest = Mockery::mock(ServerRequestPlusInterface::class); + $psrRequest = m::mock(ServerRequestPlusInterface::class); $psrRequest->shouldReceive('getMethod')->andReturn('GET'); Context::set(ServerRequestInterface::class, $psrRequest); $request = new Request(); @@ -648,7 +647,7 @@ public function testMethod() public function testUri() { - $psrRequest = Mockery::mock(ServerRequestPlusInterface::class); + $psrRequest = m::mock(ServerRequestPlusInterface::class); $psrRequest->shouldReceive('getQueryParams')->andReturn(['key' => 'value']); $psrRequest->shouldReceive('getServerParams')->andReturn([]); $psrRequest->shouldReceive('getUri')->andReturn( @@ -664,7 +663,7 @@ public function testUri() public function testBearerToken() { - $psrRequest = Mockery::mock(ServerRequestPlusInterface::class); + $psrRequest = m::mock(ServerRequestPlusInterface::class); $psrRequest->shouldReceive('hasHeader')->with('Authorization')->andReturn(true); $psrRequest->shouldReceive('getHeaderLine')->with('Authorization')->andReturn('Bearer token'); Context::set(ServerRequestInterface::class, $psrRequest); @@ -675,7 +674,7 @@ public function testBearerToken() public function testGetAcceptableContentTypes() { - $psrRequest = Mockery::mock(ServerRequestPlusInterface::class); + $psrRequest = m::mock(ServerRequestPlusInterface::class); $psrRequest->shouldReceive('hasHeader')->with('Accept')->andReturn(true); $psrRequest->shouldReceive('getHeaderLine')->with('Accept')->andReturn('application/json,text/html'); Context::set(ServerRequestInterface::class, $psrRequest); @@ -686,7 +685,7 @@ public function testGetAcceptableContentTypes() public function testGetMimeType() { - $psrRequest = Mockery::mock(ServerRequestPlusInterface::class); + $psrRequest = m::mock(ServerRequestPlusInterface::class); Context::set(ServerRequestInterface::class, $psrRequest); $request = new Request(); @@ -695,7 +694,7 @@ public function testGetMimeType() public function testGetMimeTypes() { - $psrRequest = Mockery::mock(ServerRequestPlusInterface::class); + $psrRequest = m::mock(ServerRequestPlusInterface::class); Context::set(ServerRequestInterface::class, $psrRequest); $request = new Request(); @@ -704,7 +703,7 @@ public function testGetMimeTypes() public function testIsXmlHttpRequest() { - $psrRequest = Mockery::mock(ServerRequestPlusInterface::class); + $psrRequest = m::mock(ServerRequestPlusInterface::class); $psrRequest->shouldReceive('hasHeader')->with('X-Requested-With')->andReturn(true); $psrRequest->shouldReceive('getHeaderLine')->with('X-Requested-With')->andReturn('XMLHttpRequest'); Context::set(ServerRequestInterface::class, $psrRequest); @@ -715,7 +714,7 @@ public function testIsXmlHttpRequest() public function testAjax() { - $psrRequest = Mockery::mock(ServerRequestPlusInterface::class); + $psrRequest = m::mock(ServerRequestPlusInterface::class); $psrRequest->shouldReceive('hasHeader')->with('X-Requested-With')->andReturn(true); $psrRequest->shouldReceive('getHeaderLine')->with('X-Requested-With')->andReturn('XMLHttpRequest'); Context::set(ServerRequestInterface::class, $psrRequest); @@ -726,7 +725,7 @@ public function testAjax() public function testPrefetch() { - $psrRequest = Mockery::mock(ServerRequestPlusInterface::class); + $psrRequest = m::mock(ServerRequestPlusInterface::class); $psrRequest->shouldReceive('hasHeader')->with('X-MOZ')->andReturn(true); $psrRequest->shouldReceive('getHeaderLine')->with('X-MOZ')->andReturn('prefetch'); @@ -738,7 +737,7 @@ public function testPrefetch() public function testPjax() { - $psrRequest = Mockery::mock(ServerRequestPlusInterface::class); + $psrRequest = m::mock(ServerRequestPlusInterface::class); $psrRequest->shouldReceive('hasHeader')->with('X-PJAX')->andReturn(true); $psrRequest->shouldReceive('getHeaderLine')->with('X-PJAX')->andReturn('true'); Context::set(ServerRequestInterface::class, $psrRequest); @@ -749,13 +748,13 @@ public function testPjax() public function testHasSession() { - $container = Mockery::mock(ContainerInterface::class); + $container = m::mock(Container::class); $container->shouldReceive('has') ->with(SessionContract::class) ->andReturn(true); ApplicationContext::setContainer($container); - $psrRequest = Mockery::mock(ServerRequestPlusInterface::class); + $psrRequest = m::mock(ServerRequestPlusInterface::class); Context::set(ServerRequestInterface::class, $psrRequest); $request = new Request(); @@ -764,13 +763,13 @@ public function testHasSession() public function testSession() { - $container = Mockery::mock(ContainerInterface::class); + $container = m::mock(Container::class); $container->shouldReceive('get') ->with(SessionContract::class) - ->andReturn($session = Mockery::mock(SessionContract::class)); + ->andReturn($session = m::mock(SessionContract::class)); ApplicationContext::setContainer($container); - $psrRequest = Mockery::mock(ServerRequestPlusInterface::class); + $psrRequest = m::mock(ServerRequestPlusInterface::class); Context::set(ServerRequestInterface::class, $psrRequest); $request = new Request(); @@ -779,7 +778,7 @@ public function testSession() public function testGetPsr7Request() { - $psrRequest = Mockery::mock(ServerRequestPlusInterface::class); + $psrRequest = m::mock(ServerRequestPlusInterface::class); Context::set(ServerRequestInterface::class, $psrRequest); $request = new Request(); @@ -788,14 +787,14 @@ public function testGetPsr7Request() public function testValidate() { - $psrRequest = Mockery::mock(ServerRequestPlusInterface::class); + $psrRequest = m::mock(ServerRequestPlusInterface::class); $psrRequest->shouldReceive('getQueryParams')->andReturn([]); $psrRequest->shouldReceive('getParsedBody')->andReturn(['name' => 'John Doe']); $psrRequest->shouldReceive('getUploadedFiles')->andReturn([]); Context::set(ServerRequestInterface::class, $psrRequest); $request = new Request(); - $validatorFactory = Mockery::mock(ValidatorFactoryContract::class); + $validatorFactory = m::mock(ValidatorFactoryContract::class); $validatorFactory->shouldReceive('validate') ->once() ->with( @@ -806,7 +805,7 @@ public function testValidate() ) ->andReturn(['name' => 'John Doe']); - $container = Mockery::mock(ContainerInterface::class); + $container = m::mock(Container::class); $container->shouldReceive('get') ->with(ValidatorFactoryContract::class) ->andReturn($validatorFactory); @@ -833,13 +832,13 @@ public function testHasValidSignature() { $request = new Request(); - $urlGenerator = Mockery::mock(UrlGeneratorContract::class); + $urlGenerator = m::mock(UrlGeneratorContract::class); $urlGenerator->shouldReceive('hasValidSignature') ->once() ->with($request, true) ->andReturn(true); - $container = Mockery::mock(ContainerInterface::class); + $container = m::mock(Container::class); $container->shouldReceive('get') ->with(UrlGeneratorContract::class) ->once() @@ -853,13 +852,13 @@ public function testHasValidRelativeSignature() { $request = new Request(); - $urlGenerator = Mockery::mock(UrlGeneratorContract::class); + $urlGenerator = m::mock(UrlGeneratorContract::class); $urlGenerator->shouldReceive('hasValidSignature') ->once() ->with($request, false) ->andReturn(true); - $container = Mockery::mock(ContainerInterface::class); + $container = m::mock(Container::class); $container->shouldReceive('get') ->with(UrlGeneratorContract::class) ->once() @@ -873,13 +872,13 @@ public function testHasValidSignatureWhileIgnoring() { $request = new Request(); - $urlGenerator = Mockery::mock(UrlGeneratorContract::class); + $urlGenerator = m::mock(UrlGeneratorContract::class); $urlGenerator->shouldReceive('hasValidSignature') ->once() ->with($request, true, []) ->andReturn(true); - $container = Mockery::mock(ContainerInterface::class); + $container = m::mock(Container::class); $container->shouldReceive('get') ->with(UrlGeneratorContract::class) ->once() @@ -893,13 +892,13 @@ public function testHasValidRelativeSignatureWhileIgnoring() { $request = new Request(); - $urlGenerator = Mockery::mock(UrlGeneratorContract::class); + $urlGenerator = m::mock(UrlGeneratorContract::class); $urlGenerator->shouldReceive('hasValidSignature') ->once() ->with($request, false, []) ->andReturn(true); - $container = Mockery::mock(ContainerInterface::class); + $container = m::mock(Container::class); $container->shouldReceive('get') ->with(UrlGeneratorContract::class) ->once() @@ -924,7 +923,7 @@ public function testGetDispatchedRoute() $handler = new RouteHandler('TestController@index', '/test', ['as' => 'test.index']); $dispatched = new DispatchedRoute([1, $handler, ['id' => '123']]); - $psrRequest = Mockery::mock(ServerRequestPlusInterface::class); + $psrRequest = m::mock(ServerRequestPlusInterface::class); $psrRequest->shouldReceive('getAttribute') ->with(Dispatched::class) ->andReturn($dispatched); @@ -939,7 +938,7 @@ public function testGetDispatchedRoute() public function testSegment() { - $psrRequest = Mockery::mock(ServerRequestPlusInterface::class); + $psrRequest = m::mock(ServerRequestPlusInterface::class); $psrRequest->shouldReceive('getServerParams') ->andReturn(['request_uri' => '/users/123/posts/456']); @@ -956,7 +955,7 @@ public function testSegment() public function testSegmentWithRootPath() { - $psrRequest = Mockery::mock(ServerRequestPlusInterface::class); + $psrRequest = m::mock(ServerRequestPlusInterface::class); $psrRequest->shouldReceive('getServerParams') ->andReturn(['request_uri' => '/']); @@ -969,7 +968,7 @@ public function testSegmentWithRootPath() public function testSegments() { - $psrRequest = Mockery::mock(ServerRequestPlusInterface::class); + $psrRequest = m::mock(ServerRequestPlusInterface::class); $psrRequest->shouldReceive('getServerParams')->andReturn(['request_uri' => '/api/v1/users/123']); Context::set(ServerRequestInterface::class, $psrRequest); @@ -981,7 +980,7 @@ public function testSegments() public function testSegmentsWithRootPath() { - $psrRequest = Mockery::mock(ServerRequestPlusInterface::class); + $psrRequest = m::mock(ServerRequestPlusInterface::class); $psrRequest->shouldReceive('getServerParams')->andReturn(['request_uri' => '/']); Context::set(ServerRequestInterface::class, $psrRequest); @@ -993,7 +992,7 @@ public function testSegmentsWithRootPath() public function testSegmentsWithSingleSegment() { - $psrRequest = Mockery::mock(ServerRequestPlusInterface::class); + $psrRequest = m::mock(ServerRequestPlusInterface::class); $psrRequest->shouldReceive('getServerParams')->andReturn(['request_uri' => '/home']); Context::set(ServerRequestInterface::class, $psrRequest); @@ -1008,7 +1007,7 @@ public function testRouteIs() $handler = new RouteHandler('TestController@index', '/test', ['as' => 'user.profile']); $dispatched = new DispatchedRoute([1, $handler, []]); - $psrRequest = Mockery::mock(ServerRequestPlusInterface::class); + $psrRequest = m::mock(ServerRequestPlusInterface::class); $psrRequest->shouldReceive('getAttribute') ->with(Dispatched::class) ->andReturn($dispatched); @@ -1032,7 +1031,7 @@ public function testRouteIsWithNoRouteName() $handler = new RouteHandler('TestController@index', '/test', []); $dispatched = new DispatchedRoute([1, $handler, []]); - $psrRequest = Mockery::mock(ServerRequestPlusInterface::class); + $psrRequest = m::mock(ServerRequestPlusInterface::class); $psrRequest->shouldReceive('getAttribute') ->with(Dispatched::class) ->andReturn($dispatched); @@ -1046,7 +1045,7 @@ public function testRouteIsWithNoRouteName() public function testFullUrlIs() { - $psrRequest = Mockery::mock(ServerRequestPlusInterface::class); + $psrRequest = m::mock(ServerRequestPlusInterface::class); $psrRequest->shouldReceive('getQueryParams')->andReturn(['key' => 'value']); $psrRequest->shouldReceive('getServerParams')->andReturn(['query_string' => 'key=value', 'request_uri' => '/api/users?key=value']); $psrRequest->shouldReceive('getUri')->andReturn(new HyperfUri('http://localhost/api/users')); @@ -1067,7 +1066,7 @@ public function testFullUrlIs() public function testFullUrlIsWithoutQuery() { - $psrRequest = Mockery::mock(ServerRequestPlusInterface::class); + $psrRequest = m::mock(ServerRequestPlusInterface::class); $psrRequest->shouldReceive('getQueryParams')->andReturn([]); $psrRequest->shouldReceive('getServerParams')->andReturn(['query_string' => '', 'request_uri' => '/api/users']); $psrRequest->shouldReceive('getUri')->andReturn(new HyperfUri('http://localhost/api/users')); diff --git a/tests/Http/Resource/JsonResourceTest.php b/tests/Http/Resource/JsonResourceTest.php index 9e71ce8d4..e6244bedb 100644 --- a/tests/Http/Resource/JsonResourceTest.php +++ b/tests/Http/Resource/JsonResourceTest.php @@ -4,8 +4,10 @@ namespace Hypervel\Tests\Http\Resource; +use Hypervel\Http\Request; use Hypervel\Http\Resources\Json\AnonymousResourceCollection; use Hypervel\Http\Resources\Json\JsonResource; +use Mockery as m; use PHPUnit\Framework\TestCase; /** @@ -24,8 +26,9 @@ public function toArray() }; $collection = JsonResource::collection([$resource]); + $request = m::mock(Request::class); $this->assertInstanceOf(AnonymousResourceCollection::class, $collection); - $this->assertSame([['foo' => 'bar']], $collection->toArray()); + $this->assertSame([['foo' => 'bar']], $collection->toArray($request)); } } diff --git a/tests/Http/Resource/ResourceCollectionTest.php b/tests/Http/Resource/ResourceCollectionTest.php index 360950c52..107ed7286 100644 --- a/tests/Http/Resource/ResourceCollectionTest.php +++ b/tests/Http/Resource/ResourceCollectionTest.php @@ -4,7 +4,9 @@ namespace Hypervel\Tests\Http\Resource; +use Hypervel\Http\Request; use Hypervel\Http\Resources\Json\ResourceCollection; +use Mockery as m; use PHPUnit\Framework\TestCase; /** @@ -28,14 +30,15 @@ public function toArray() } }; - $collection = (new ResourceCollection([$resourceA, $resourceB])); + $collection = new ResourceCollection([$resourceA, $resourceB]); + $request = m::mock(Request::class); $this->assertSame( [ ['foo' => 'bar'], ['hello' => 'world'], ], - $collection->toArray() + $collection->toArray($request) ); } } diff --git a/tests/Http/ResponseTest.php b/tests/Http/ResponseTest.php index db29af6a3..228549b34 100644 --- a/tests/Http/ResponseTest.php +++ b/tests/Http/ResponseTest.php @@ -4,24 +4,23 @@ namespace Hypervel\Tests\Http; -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\Context\ApplicationContext; +use Hypervel\Context\Context; +use Hypervel\Contracts\Container\Container; +use Hypervel\Contracts\Support\Arrayable; +use Hypervel\Contracts\Support\Jsonable; +use Hypervel\Filesystem\Filesystem; use Hypervel\Http\Exceptions\FileNotFoundException; use Hypervel\Http\Response; use Hypervel\HttpMessage\Exceptions\RangeNotSatisfiableHttpException; -use Mockery; +use Mockery as m; use PHPUnit\Framework\TestCase; -use Psr\Container\ContainerInterface; 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); @@ -41,7 +39,7 @@ protected function tearDown(): void public function testMake() { - $container = Mockery::mock(ContainerInterface::class); + $container = m::mock(Container::class); ApplicationContext::setContainer($container); $psrResponse = new \Hyperf\HttpMessage\Base\Response(); @@ -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"}'; } @@ -88,7 +86,7 @@ public function __toString(): string public function testNoContent() { - $container = Mockery::mock(ContainerInterface::class); + $container = m::mock(Container::class); ApplicationContext::setContainer($container); $psrResponse = new \Hyperf\HttpMessage\Base\Response(); @@ -108,10 +106,10 @@ public function testView() $psrResponse = new \Hyperf\HttpMessage\Base\Response(); Context::set(ResponseInterface::class, $psrResponse); - $container = Mockery::mock(ContainerInterface::class); + $container = m::mock(Container::class); ApplicationContext::setContainer($container); - $renderer = Mockery::mock(RenderInterface::class); + $renderer = m::mock(RenderInterface::class); $renderer->shouldReceive('render')->with('test-view', ['data' => 'value'])->andReturn( (new HyperfResponse())->withAddedHeader('content-type', 'text/html')->withBody(new SwooleStream('

Test

')) ); @@ -138,13 +136,13 @@ public function testGetPsr7Response() public function testFileWithFileNotFoundException() { - $filesystem = Mockery::mock(Filesystem::class); + $filesystem = m::mock(Filesystem::class); $filesystem->shouldReceive('isFile') ->with('file_path') ->once() ->andReturn(false); - $container = Mockery::mock(ContainerInterface::class); + $container = m::mock(Container::class); $container->shouldReceive('get') ->with(Filesystem::class) ->once() @@ -161,7 +159,7 @@ public function testFileWithFileNotFoundException() public function testFile() { - $filesystem = Mockery::mock(Filesystem::class); + $filesystem = m::mock(Filesystem::class); $filesystem->shouldReceive('isFile') ->with($filePath = 'file_path') ->once() @@ -171,7 +169,7 @@ public function testFile() ->once() ->andReturn($fileContent = 'file_content'); - $container = Mockery::mock(ContainerInterface::class); + $container = m::mock(Container::class); $container->shouldReceive('get') ->with(Filesystem::class) ->once() @@ -189,7 +187,7 @@ public function testFile() public function testStream() { - $psrResponse = Mockery::mock(\Hyperf\HttpMessage\Server\Response::class)->makePartial(); + $psrResponse = m::mock(\Hyperf\HttpMessage\Server\Response::class)->makePartial(); $psrResponse->shouldReceive('write') ->with($content = 'Streaming content') ->once() @@ -212,7 +210,7 @@ public function testStream() public function testStreamWithStringResult() { - $psrResponse = Mockery::mock(\Hyperf\HttpMessage\Server\Response::class)->makePartial(); + $psrResponse = m::mock(\Hyperf\HttpMessage\Server\Response::class)->makePartial(); $psrResponse->shouldReceive('write') ->with($content = 'Streaming content') ->once() @@ -234,7 +232,7 @@ public function testStreamWithStringResult() public function testStreamWithNonChunkable() { - $psrResponse = Mockery::mock(ResponsePlusInterface::class); + $psrResponse = m::mock(ResponsePlusInterface::class); Context::set(ResponseInterface::class, $psrResponse); $this->expectException(RuntimeException::class); @@ -246,7 +244,7 @@ public function testStreamWithNonChunkable() public function testStreamDownload() { - $psrResponse = Mockery::mock(\Hyperf\HttpMessage\Server\Response::class)->makePartial(); + $psrResponse = m::mock(\Hyperf\HttpMessage\Server\Response::class)->makePartial(); $psrResponse->shouldReceive('write') ->with($content = 'File content') ->once() @@ -273,7 +271,7 @@ public function testStreamDownload() public function testStreamDownloadWithRangeHeader() { - $psrResponse = Mockery::mock(\Hyperf\HttpMessage\Server\Response::class)->makePartial(); + $psrResponse = m::mock(\Hyperf\HttpMessage\Server\Response::class)->makePartial(); $psrResponse->shouldReceive('write') ->with($content = 'File content') ->once() @@ -309,7 +307,7 @@ public function testStreamDownloadWithRangeHeader() public function testStreamDownloadWithRangeHeaderAndWithoutContentLength() { - $psrResponse = Mockery::mock(\Hyperf\HttpMessage\Server\Response::class)->makePartial(); + $psrResponse = m::mock(\Hyperf\HttpMessage\Server\Response::class)->makePartial(); $psrResponse->shouldReceive('write') ->with($content = 'File content') ->once() @@ -344,7 +342,7 @@ public function testStreamDownloadWithRangeHeaderAndWithoutContentLength() public function testStreamDownloadWithInvalidRangeHeader() { - $psrResponse = Mockery::mock(\Hyperf\HttpMessage\Server\Response::class)->makePartial(); + $psrResponse = m::mock(\Hyperf\HttpMessage\Server\Response::class)->makePartial(); $psrResponse->shouldNotReceive('write'); Context::set(ResponseInterface::class, $psrResponse); @@ -367,7 +365,7 @@ public function testStreamDownloadWithInvalidRangeHeader() protected function mockRequest(array $headers = [], string $method = 'GET'): ServerRequestPlusInterface { - $request = Mockery::mock(ServerRequestPlusInterface::class); + $request = m::mock(ServerRequestPlusInterface::class); $request->shouldReceive('getMethod')->andReturn($method); foreach ($headers as $key => $value) { diff --git a/tests/Http/UploadedFileTest.php b/tests/Http/UploadedFileTest.php index f9cbaa6ff..1e7fa26b6 100644 --- a/tests/Http/UploadedFileTest.php +++ b/tests/Http/UploadedFileTest.php @@ -4,9 +4,9 @@ namespace Hypervel\Tests\Http; -use Hyperf\Context\ApplicationContext; -use Hyperf\Di\Container; use Hyperf\Di\Definition\DefinitionSource; +use Hypervel\Container\Container; +use Hypervel\Context\ApplicationContext; use Hypervel\Http\Exceptions\CannotWriteFileException; use Hypervel\Http\Exceptions\ExtensionFileException; use Hypervel\Http\Exceptions\FileException; diff --git a/tests/HttpClient/HttpClientTest.php b/tests/HttpClient/HttpClientTest.php index 9849bef4d..189d5d2a8 100644 --- a/tests/HttpClient/HttpClientTest.php +++ b/tests/HttpClient/HttpClientTest.php @@ -11,13 +11,10 @@ use GuzzleHttp\Psr7\Response as Psr7Response; use GuzzleHttp\Psr7\Utils; 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\Config\Repository as ConfigRepository; +use Hypervel\Context\ApplicationContext; +use Hypervel\Contracts\Support\Arrayable; use Hypervel\Http\Response as HttpResponse; use Hypervel\HttpClient\ConnectionException; use Hypervel\HttpClient\Events\RequestSending; @@ -35,6 +32,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 +68,8 @@ protected function setUp(): void protected function tearDown(): void { - m::close(); + Sleep::fake(false); + parent::tearDown(); } @@ -3237,11 +3237,11 @@ public function testGetEmptyConfigWhenConfigNotSet() protected function getContainer(array $config = []): ContainerInterface { - $config = new Config(['http_client' => $config]); + $config = new ConfigRepository(['http_client' => $config]); - return new \Hyperf\Di\Container( + return new \Hypervel\Container\Container( new \Hyperf\Di\Definition\DefinitionSource([ - ConfigInterface::class => fn () => $config, + 'config' => fn () => $config, PoolFactory::class => PoolManager::class, ]) ); diff --git a/tests/Integration/Cache/Redis/ClusterFallbackIntegrationTest.php b/tests/Integration/Cache/Redis/ClusterFallbackIntegrationTest.php index abd3344fb..234be1102 100644 --- a/tests/Integration/Cache/Redis/ClusterFallbackIntegrationTest.php +++ b/tests/Integration/Cache/Redis/ClusterFallbackIntegrationTest.php @@ -64,7 +64,7 @@ protected function setUp(): void parent::setUp(); // Create cluster-mode store using the same factory as the real store - $factory = $this->app->get(\Hyperf\Redis\RedisFactory::class); + $factory = $this->app->get(\Hypervel\Redis\RedisFactory::class); $realStore = Cache::store('redis')->getStore(); $this->clusterStore = new ClusterModeRedisStore( diff --git a/tests/Integration/Cache/Redis/ConcurrencyIntegrationTest.php b/tests/Integration/Cache/Redis/ConcurrencyIntegrationTest.php index 4a540018a..34978a30a 100644 --- a/tests/Integration/Cache/Redis/ConcurrencyIntegrationTest.php +++ b/tests/Integration/Cache/Redis/ConcurrencyIntegrationTest.php @@ -4,8 +4,8 @@ namespace Hypervel\Tests\Integration\Cache\Redis; -use Hyperf\Coroutine\Parallel; use Hypervel\Cache\Redis\TagMode; +use Hypervel\Coroutine\Parallel; use Hypervel\Support\Facades\Cache; /** diff --git a/tests/Integration/Cache/Redis/PrefixHandlingIntegrationTest.php b/tests/Integration/Cache/Redis/PrefixHandlingIntegrationTest.php index a0ddcbbc7..5aa927251 100644 --- a/tests/Integration/Cache/Redis/PrefixHandlingIntegrationTest.php +++ b/tests/Integration/Cache/Redis/PrefixHandlingIntegrationTest.php @@ -4,11 +4,11 @@ namespace Hypervel\Tests\Integration\Cache\Redis; -use Hyperf\Redis\RedisFactory; use Hypervel\Cache\Redis\AnyTaggedCache; use Hypervel\Cache\Redis\AnyTagSet; use Hypervel\Cache\Redis\TagMode; use Hypervel\Cache\RedisStore; +use Hypervel\Redis\RedisFactory; /** * Integration tests for prefix handling with different configurations. diff --git a/tests/Integration/Cache/Redis/RedisCacheIntegrationTestCase.php b/tests/Integration/Cache/Redis/RedisCacheIntegrationTestCase.php index 2c33891f9..73d1c8925 100644 --- a/tests/Integration/Cache/Redis/RedisCacheIntegrationTestCase.php +++ b/tests/Integration/Cache/Redis/RedisCacheIntegrationTestCase.php @@ -4,11 +4,10 @@ namespace Hypervel\Tests\Integration\Cache\Redis; -use Hyperf\Contract\ConfigInterface; -use Hypervel\Cache\Contracts\Repository; use Hypervel\Cache\Redis\TagMode; use Hypervel\Cache\RedisStore; -use Hypervel\Foundation\Contracts\Application as ApplicationContract; +use Hypervel\Contracts\Cache\Repository; +use Hypervel\Contracts\Foundation\Application as ApplicationContract; use Hypervel\Foundation\Testing\Concerns\InteractsWithRedis; use Hypervel\Foundation\Testing\Concerns\RunTestsInCoroutine; use Hypervel\Support\Facades\Cache; @@ -39,7 +38,7 @@ abstract class RedisCacheIntegrationTestCase extends TestCase protected function defineEnvironment(ApplicationContract $app): void { - $config = $app->get(ConfigInterface::class); + $config = $app->get('config'); // Configure Redis (prefix comes from REDIS_PREFIX env var set by bootstrap) $this->configureRedisForTesting($config); diff --git a/tests/Integration/Database/ConnectionCoroutineSafetyTest.php b/tests/Integration/Database/ConnectionCoroutineSafetyTest.php new file mode 100644 index 000000000..1bf598946 --- /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..812c4eace --- /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/Laravel/AfterQueryTest.php b/tests/Integration/Database/Laravel/AfterQueryTest.php new file mode 100644 index 000000000..6973e15d7 --- /dev/null +++ b/tests/Integration/Database/Laravel/AfterQueryTest.php @@ -0,0 +1,400 @@ +increments('id'); + $table->integer('team_id')->nullable(); + }); + + Schema::create('teams', function (Blueprint $table) { + $table->increments('id'); + $table->integer('owner_id'); + }); + + Schema::create('posts', function (Blueprint $table) { + $table->increments('id'); + }); + + Schema::create('users_posts', function (Blueprint $table) { + $table->increments('id'); + $table->integer('user_id'); + $table->integer('post_id'); + $table->timestamps(); + }); + } + + public function testAfterQueryOnEloquentBuilder() + { + AfterQueryUser::create(); + AfterQueryUser::create(); + + $afterQueryIds = collect(); + + $users = AfterQueryUser::query() + ->afterQuery(function (Collection $users) use ($afterQueryIds) { + $afterQueryIds->push(...$users->pluck('id')->all()); + + foreach ($users as $user) { + $this->assertInstanceOf(AfterQueryUser::class, $user); + } + }) + ->get(); + + $this->assertCount(2, $users); + $this->assertEqualsCanonicalizing($afterQueryIds->toArray(), $users->pluck('id')->toArray()); + } + + public function testAfterQueryOnBaseBuilder() + { + AfterQueryUser::create(); + AfterQueryUser::create(); + + $afterQueryIds = collect(); + + $users = AfterQueryUser::query() + ->toBase() + ->afterQuery(function (Collection $users) use ($afterQueryIds) { + $afterQueryIds->push(...$users->pluck('id')->all()); + + foreach ($users as $user) { + $this->assertNotInstanceOf(AfterQueryUser::class, $user); + } + }) + ->get(); + + $this->assertCount(2, $users); + $this->assertEqualsCanonicalizing($afterQueryIds->toArray(), $users->pluck('id')->toArray()); + } + + public function testAfterQueryOnEloquentCursor() + { + AfterQueryUser::create(); + AfterQueryUser::create(); + + $afterQueryIds = collect(); + + $users = AfterQueryUser::query() + ->afterQuery(function (Collection $users) use ($afterQueryIds) { + $afterQueryIds->push(...$users->pluck('id')->all()); + + foreach ($users as $user) { + $this->assertInstanceOf(AfterQueryUser::class, $user); + } + }) + ->cursor(); + + $this->assertCount(2, $users); + $this->assertEqualsCanonicalizing($afterQueryIds->toArray(), $users->pluck('id')->toArray()); + } + + public function testAfterQueryOnBaseBuilderCursor() + { + AfterQueryUser::create(); + AfterQueryUser::create(); + + $afterQueryIds = collect(); + + $users = AfterQueryUser::query() + ->toBase() + ->afterQuery(function (Collection $users) use ($afterQueryIds) { + $afterQueryIds->push(...$users->pluck('id')->all()); + + foreach ($users as $user) { + $this->assertNotInstanceOf(AfterQueryUser::class, $user); + } + }) + ->cursor(); + + $this->assertCount(2, $users); + $this->assertEqualsCanonicalizing($afterQueryIds->toArray(), $users->pluck('id')->toArray()); + } + + public function testAfterQueryOnEloquentPluck() + { + AfterQueryUser::create(); + AfterQueryUser::create(); + + $afterQueryIds = collect(); + + $userIds = AfterQueryUser::query() + ->afterQuery(function (Collection $userIds) use ($afterQueryIds) { + $afterQueryIds->push(...$userIds->all()); + + foreach ($userIds as $userId) { + $this->assertIsInt($userId); + } + }) + ->pluck('id'); + + $this->assertCount(2, $userIds); + $this->assertEqualsCanonicalizing($afterQueryIds->toArray(), $userIds->toArray()); + } + + public function testAfterQueryOnBaseBuilderPluck() + { + AfterQueryUser::create(); + AfterQueryUser::create(); + + $afterQueryIds = collect(); + + $userIds = AfterQueryUser::query() + ->toBase() + ->afterQuery(function (Collection $userIds) use ($afterQueryIds) { + $afterQueryIds->push(...$userIds->all()); + + foreach ($userIds as $userId) { + $this->assertIsInt((int) $userId); + } + }) + ->pluck('id'); + + $this->assertCount(2, $userIds); + $this->assertEqualsCanonicalizing($afterQueryIds->toArray(), $userIds->toArray()); + } + + public function testAfterQueryHookOnBelongsToManyRelationship() + { + $user = AfterQueryUser::create(); + $firstPost = AfterQueryPost::create(); + $secondPost = AfterQueryPost::create(); + + $user->posts()->attach($firstPost); + $user->posts()->attach($secondPost); + + $afterQueryIds = collect(); + + $posts = $user->posts() + ->afterQuery(function (Collection $posts) use ($afterQueryIds) { + $afterQueryIds->push(...$posts->pluck('id')->all()); + + foreach ($posts as $post) { + $this->assertInstanceOf(AfterQueryPost::class, $post); + } + }) + ->get(); + + $this->assertCount(2, $posts); + $this->assertEqualsCanonicalizing($afterQueryIds->toArray(), $posts->pluck('id')->toArray()); + } + + public function testAfterQueryHookOnHasManyThroughRelationship() + { + $user = AfterQueryUser::create(); + $team = AfterQueryTeam::create(['owner_id' => $user->id]); + + AfterQueryUser::create(['team_id' => $team->id]); + AfterQueryUser::create(['team_id' => $team->id]); + + $afterQueryIds = collect(); + + $teamMates = $user->teamMates() + ->afterQuery(function (Collection $teamMates) use ($afterQueryIds) { + $afterQueryIds->push(...$teamMates->pluck('id')->all()); + + foreach ($teamMates as $teamMate) { + $this->assertInstanceOf(AfterQueryUser::class, $teamMate); + } + }) + ->get(); + + $this->assertCount(2, $teamMates); + $this->assertEqualsCanonicalizing($afterQueryIds->toArray(), $teamMates->pluck('id')->toArray()); + } + + public function testAfterQueryOnEloquentBuilderCanAlterReturnedResult() + { + $firstUser = AfterQueryUser::create(); + $secondUser = AfterQueryUser::create(); + + $users = AfterQueryUser::query() + ->afterQuery(function () { + return collect(['foo', 'bar']); + }) + ->get(); + + $this->assertEquals(collect(['foo', 'bar']), $users); + + $users = AfterQueryUser::query() + ->afterQuery(function () { + return collect(['foo', 'bar']); + }) + ->pluck('id'); + + $this->assertEquals(collect(['foo', 'bar']), $users); + + $users = AfterQueryUser::query() + ->afterQuery(function ($users) use ($firstUser) { + return $users->first()->is($firstUser) ? collect(['foo', 'bar']) : collect(['bar', 'foo']); + }) + ->cursor(); + + $this->assertEquals(collect(['foo', 'bar']), $users->collect()); + + $users = AfterQueryUser::query() + ->afterQuery(function ($users) use ($firstUser) { + return $users->where('id', '!=', $firstUser->id); + }) + ->cursor(); + + $this->assertEquals([$secondUser->id], $users->collect()->pluck('id')->all()); + + $firstPost = AfterQueryPost::create(); + $secondPost = AfterQueryPost::create(); + + $firstUser->posts()->attach($firstPost); + $firstUser->posts()->attach($secondPost); + + $posts = $firstUser->posts() + ->afterQuery(function () { + return collect(['foo', 'bar']); + }) + ->get(); + + $this->assertEquals(collect(['foo', 'bar']), $posts); + + $user = AfterQueryUser::create(); + $team = AfterQueryTeam::create(['owner_id' => $user->id]); + + AfterQueryUser::create(['team_id' => $team->id]); + AfterQueryUser::create(['team_id' => $team->id]); + + $teamMates = $user->teamMates() + ->afterQuery(function () { + return collect(['foo', 'bar']); + }) + ->get(); + + $this->assertEquals(collect(['foo', 'bar']), $teamMates); + } + + public function testAfterQueryOnBaseBuilderCanAlterReturnedResult() + { + $firstUser = AfterQueryUser::create(); + $secondUser = AfterQueryUser::create(); + + $users = AfterQueryUser::query() + ->toBase() + ->afterQuery(function () { + return collect(['foo', 'bar']); + }) + ->get(); + + $this->assertEquals(collect(['foo', 'bar']), $users); + + $users = AfterQueryUser::query() + ->toBase() + ->afterQuery(function () { + return collect(['foo', 'bar']); + }) + ->pluck('id'); + + $this->assertEquals(collect(['foo', 'bar']), $users); + + $users = AfterQueryUser::query() + ->toBase() + ->afterQuery(function ($users) use ($firstUser) { + return ((int) $users->first()->id) === $firstUser->id ? collect(['foo', 'bar']) : collect(['bar', 'foo']); + }) + ->cursor(); + + $this->assertEquals(collect(['foo', 'bar']), $users->collect()); + + $users = AfterQueryUser::query() + ->toBase() + ->afterQuery(function ($users) use ($firstUser) { + return $users->where('id', '!=', $firstUser->id); + }) + ->cursor(); + + $this->assertEquals([$secondUser->id], $users->collect()->pluck('id')->all()); + + $firstPost = AfterQueryPost::create(); + $secondPost = AfterQueryPost::create(); + + $firstUser->posts()->attach($firstPost); + $firstUser->posts()->attach($secondPost); + + $posts = $firstUser->posts() + ->toBase() + ->afterQuery(function () { + return collect(['foo', 'bar']); + }) + ->get(); + + $this->assertEquals(collect(['foo', 'bar']), $posts); + + $user = AfterQueryUser::create(); + $team = AfterQueryTeam::create(['owner_id' => $user->id]); + + AfterQueryUser::create(['team_id' => $team->id]); + AfterQueryUser::create(['team_id' => $team->id]); + + $teamMates = $user->teamMates() + ->toBase() + ->afterQuery(function () { + return collect(['foo', 'bar']); + }) + ->get(); + + $this->assertEquals(collect(['foo', 'bar']), $teamMates); + } +} + +class AfterQueryUser extends Model +{ + protected ?string $table = 'users'; + + protected array $guarded = []; + + public bool $timestamps = false; + + public function teamMates() + { + return $this->hasManyThrough(self::class, AfterQueryTeam::class, 'owner_id', 'team_id'); + } + + public function posts() + { + return $this->belongsToMany(AfterQueryPost::class, 'users_posts', 'user_id', 'post_id')->withTimestamps(); + } +} + +class AfterQueryTeam extends Model +{ + protected ?string $table = 'teams'; + + protected array $guarded = []; + + public bool $timestamps = false; + + public function members() + { + return $this->hasMany(AfterQueryUser::class, 'team_id'); + } +} + +class AfterQueryPost extends Model +{ + protected ?string $table = 'posts'; + + protected array $guarded = []; + + public bool $timestamps = false; +} diff --git a/tests/Integration/Database/Laravel/ConnectionThreadsCountTest.php b/tests/Integration/Database/Laravel/ConnectionThreadsCountTest.php new file mode 100644 index 000000000..c13d7ef23 --- /dev/null +++ b/tests/Integration/Database/Laravel/ConnectionThreadsCountTest.php @@ -0,0 +1,26 @@ +threadCount(); + + if ($this->driver === 'sqlite') { + $this->assertNull($count, 'SQLite does not support connection count'); + } else { + $this->assertGreaterThanOrEqual(1, $count); + } + } +} diff --git a/tests/Integration/Database/Laravel/DatabaseConnectionsTest.php b/tests/Integration/Database/Laravel/DatabaseConnectionsTest.php new file mode 100644 index 000000000..bf3f2f9c4 --- /dev/null +++ b/tests/Integration/Database/Laravel/DatabaseConnectionsTest.php @@ -0,0 +1,204 @@ +set('database.connections.sqlite', [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + ]); + + // Configure a read/write split connection for tests + $app['config']->set('database.connections.sqlite_readwrite', [ + 'driver' => 'sqlite', + 'read' => [ + 'database' => static::$readPath, + ], + 'write' => [ + 'database' => static::$writePath, + ], + ]); + } + + // REMOVED: testBuildDatabaseConnection - Dynamic connections incompatible with Swoole connection pooling + + // REMOVED: testEstablishDatabaseConnection - Dynamic connections incompatible with Swoole connection pooling + + // REMOVED: testThrowExceptionIfConnectionAlreadyExists - Dynamic connections incompatible with Swoole connection pooling + + // REMOVED: testOverrideExistingConnection - Dynamic connections incompatible with Swoole connection pooling + + // REMOVED: testEstablishingAConnectionWillDispatchAnEvent - Uses connectUsing() which is incompatible with Swoole connection pooling + + public function testTablePrefix(): void + { + DB::setTablePrefix('prefix_'); + $this->assertSame('prefix_', DB::getTablePrefix()); + + DB::withoutTablePrefix(function ($connection) { + $this->assertSame('', $connection->getTablePrefix()); + }); + + $this->assertSame('prefix_', DB::getTablePrefix()); + + DB::setTablePrefix(''); + $this->assertSame('', DB::getTablePrefix()); + } + + // REMOVED: testDynamicConnectionDoesntFailOnReconnect - Dynamic connections incompatible with Swoole connection pooling + + // REMOVED: testDynamicConnectionWithNoNameDoesntFailOnReconnect - Dynamic connections incompatible with Swoole connection pooling + + public function testReadWriteTypeIsProvidedInQueryExecutedEventAndQueryLog(): void + { + $connection = DB::connection('sqlite_readwrite'); + + $events = collect(); + $connection->listen($events->push(...)); + $connection->enableQueryLog(); + + $connection->statement('select 1'); + $this->assertSame('write', $events->shift()->readWriteType); + + $connection->select('select 1'); + $this->assertSame('read', $events->shift()->readWriteType); + + $connection->statement('select 1'); + $this->assertSame('write', $events->shift()->readWriteType); + + $connection->select('select 1'); + $this->assertSame('read', $events->shift()->readWriteType); + + $this->assertEmpty($events); + $this->assertSame([ + ['query' => 'select 1', 'readWriteType' => 'write'], + ['query' => 'select 1', 'readWriteType' => 'read'], + ['query' => 'select 1', 'readWriteType' => 'write'], + ['query' => 'select 1', 'readWriteType' => 'read'], + ], Arr::select($connection->getQueryLog(), [ + 'query', 'readWriteType', + ])); + } + + public function testConnectionsWithoutReadWriteConfigurationAlwaysShowAsWrite(): void + { + // Default sqlite connection has no read/write splitting + $connection = DB::connection('sqlite'); + + $events = collect(); + $connection->listen($events->push(...)); + + $connection->statement('select 1'); + $this->assertSame('write', $events->shift()->readWriteType); + + $connection->select('select 1'); + $this->assertSame('write', $events->shift()->readWriteType); + + $connection->statement('select 1'); + $this->assertSame('write', $events->shift()->readWriteType); + + $connection->select('select 1'); + $this->assertSame('write', $events->shift()->readWriteType); + } + + public function testQueryExceptionsProvideReadWriteType(): void + { + try { + DB::connection('sqlite_readwrite')->select('xxxx', useReadPdo: true); + $this->fail(); + } catch (QueryException $exception) { + $this->assertSame('read', $exception->readWriteType); + } + + try { + DB::connection('sqlite_readwrite')->select('xxxx', useReadPdo: false); + $this->fail(); + } catch (QueryException $exception) { + $this->assertSame('write', $exception->readWriteType); + } + } + + public function testQueryInEventListenerCannotInterfereWithReadWriteType(): void + { + $connection = DB::connection('sqlite_readwrite'); + + $events = collect(); + $connection->listen($events->push(...)); + $connection->enableQueryLog(); + + $connection->listen(function ($query) use ($connection) { + if ($query->sql === 'select 1') { + $connection->select('select 2'); + } + }); + + $connection->statement('select 1'); + $this->assertSame('write', $events->shift()->readWriteType); + $this->assertSame('read', $events->shift()->readWriteType); + + $connection->select('select 1'); + $this->assertSame('read', $events->shift()->readWriteType); + $this->assertSame('read', $events->shift()->readWriteType); + + $connection->statement('select 1'); + $this->assertSame('write', $events->shift()->readWriteType); + $this->assertSame('read', $events->shift()->readWriteType); + + $connection->select('select 1'); + $this->assertSame('read', $events->shift()->readWriteType); + $this->assertSame('read', $events->shift()->readWriteType); + + $this->assertSame([ + ['query' => 'select 2', 'readWriteType' => 'read'], + ['query' => 'select 1', 'readWriteType' => 'write'], + ['query' => 'select 2', 'readWriteType' => 'read'], + ['query' => 'select 1', 'readWriteType' => 'read'], + ['query' => 'select 2', 'readWriteType' => 'read'], + ['query' => 'select 1', 'readWriteType' => 'write'], + ['query' => 'select 2', 'readWriteType' => 'read'], + ['query' => 'select 1', 'readWriteType' => 'read'], + ], Arr::select($connection->getQueryLog(), [ + 'query', 'readWriteType', + ])); + } +} diff --git a/tests/Integration/Database/Laravel/DatabaseCustomCastsTest.php b/tests/Integration/Database/Laravel/DatabaseCustomCastsTest.php new file mode 100644 index 000000000..c23a240b3 --- /dev/null +++ b/tests/Integration/Database/Laravel/DatabaseCustomCastsTest.php @@ -0,0 +1,262 @@ +increments('id'); + $table->text('array_object'); + $table->json('array_object_json'); + $table->text('collection'); + $table->string('stringable'); + $table->string('password'); + $table->timestamps(); + }); + + Schema::create('test_eloquent_model_with_custom_casts_nullables', function (Blueprint $table) { + $table->increments('id'); + $table->text('array_object')->nullable(); + $table->json('array_object_json')->nullable(); + $table->text('collection')->nullable(); + $table->string('stringable')->nullable(); + $table->timestamps(); + }); + } + + public function testCustomCasting() + { + $model = new TestEloquentModelWithCustomCasts(); + + $model->array_object = ['name' => 'Taylor']; + $model->array_object_json = ['name' => 'Taylor']; + $model->collection = collect(['name' => 'Taylor']); + $model->stringable = new Stringable('Taylor'); + $model->password = Hash::make('secret'); + + $model->save(); + + $model = $model->fresh(); + + $this->assertEquals(['name' => 'Taylor'], $model->array_object->toArray()); + $this->assertEquals(['name' => 'Taylor'], $model->array_object_json->toArray()); + $this->assertEquals(['name' => 'Taylor'], $model->collection->toArray()); + $this->assertSame('Taylor', (string) $model->stringable); + $this->assertTrue(Hash::check('secret', $model->password)); + + $model->array_object['age'] = 34; + $model->array_object['meta']['title'] = 'Developer'; + + $model->array_object_json['age'] = 34; + $model->array_object_json['meta']['title'] = 'Developer'; + + $model->save(); + + $model = $model->fresh(); + + $this->assertEquals( + [ + 'name' => 'Taylor', + 'age' => 34, + 'meta' => ['title' => 'Developer'], + ], + $model->array_object->toArray() + ); + + $this->assertEquals( + [ + 'name' => 'Taylor', + 'age' => 34, + 'meta' => ['title' => 'Developer'], + ], + $model->array_object_json->toArray() + ); + } + + public function testCustomCastingUsingCreate() + { + $model = TestEloquentModelWithCustomCasts::create([ + 'array_object' => ['name' => 'Taylor'], + 'array_object_json' => ['name' => 'Taylor'], + 'collection' => collect(['name' => 'Taylor']), + 'stringable' => new Stringable('Taylor'), + 'password' => Hash::make('secret'), + ]); + + $model->save(); + + $model = $model->fresh(); + + $this->assertEquals(['name' => 'Taylor'], $model->array_object->toArray()); + $this->assertEquals(['name' => 'Taylor'], $model->array_object_json->toArray()); + $this->assertEquals(['name' => 'Taylor'], $model->collection->toArray()); + $this->assertSame('Taylor', (string) $model->stringable); + $this->assertTrue(Hash::check('secret', $model->password)); + } + + public function testCustomCastingNullableValues() + { + $model = new TestEloquentModelWithCustomCastsNullable(); + + $model->array_object = null; + $model->array_object_json = null; + $model->collection = collect(); + $model->stringable = null; + + $model->save(); + + $model = $model->fresh(); + + $this->assertEmpty($model->array_object); + $this->assertEmpty($model->array_object_json); + $this->assertEmpty($model->collection); + $this->assertSame('', (string) $model->stringable); + + $model->array_object = ['name' => 'John']; + $model->array_object['name'] = 'Taylor'; + $model->array_object['meta']['title'] = 'Developer'; + + $model->array_object_json = ['name' => 'John']; + $model->array_object_json['name'] = 'Taylor'; + $model->array_object_json['meta']['title'] = 'Developer'; + + $model->save(); + + $model = $model->fresh(); + + $this->assertEquals( + [ + 'name' => 'Taylor', + 'meta' => ['title' => 'Developer'], + ], + $model->array_object->toArray() + ); + + $this->assertEquals( + [ + 'name' => 'Taylor', + 'meta' => ['title' => 'Developer'], + ], + $model->array_object_json->toArray() + ); + } + + public function testAsCollectionWithMapInto() + { + $model = new TestEloquentModelWithCustomCasts(); + $model->mergeCasts([ + 'collection' => AsCollection::of(Fluent::class), + ]); + + $model->setRawAttributes([ + 'collection' => json_encode([['foo' => 'bar']]), + ]); + + $this->assertInstanceOf(Fluent::class, $model->collection->first()); + $this->assertSame('bar', $model->collection->first()->foo); + } + + public function testAsCustomCollectionWithMapInto() + { + $model = new TestEloquentModelWithCustomCasts(); + $model->mergeCasts([ + 'collection' => AsCollection::using(CustomCollection::class, Fluent::class), + ]); + + $model->setRawAttributes([ + 'collection' => json_encode([['foo' => 'bar']]), + ]); + + $this->assertInstanceOf(CustomCollection::class, $model->collection); + $this->assertInstanceOf(Fluent::class, $model->collection->first()); + $this->assertSame('bar', $model->collection->first()->foo); + } + + public function testAsCollectionWithMapCallback(): void + { + $model = new TestEloquentModelWithCustomCasts(); + $model->mergeCasts([ + 'collection' => AsCollection::of([FluentWithCallback::class, 'make']), + ]); + + $model->setRawAttributes([ + 'collection' => json_encode([['foo' => 'bar']]), + ]); + + $this->assertInstanceOf(FluentWithCallback::class, $model->collection->first()); + $this->assertSame('bar', $model->collection->first()->foo); + } + + public function testAsCustomCollectionWithMapCallback(): void + { + $model = new TestEloquentModelWithCustomCasts(); + $model->mergeCasts([ + 'collection' => AsCollection::using(CustomCollection::class, [FluentWithCallback::class, 'make']), + ]); + + $model->setRawAttributes([ + 'collection' => json_encode([['foo' => 'bar']]), + ]); + + $this->assertInstanceOf(CustomCollection::class, $model->collection); + $this->assertInstanceOf(FluentWithCallback::class, $model->collection->first()); + $this->assertSame('bar', $model->collection->first()->foo); + } +} + +class TestEloquentModelWithCustomCasts extends Model +{ + protected array $guarded = []; + + protected array $casts = [ + 'array_object' => AsArrayObject::class, + 'array_object_json' => AsArrayObject::class, + 'collection' => AsCollection::class, + 'stringable' => AsStringable::class, + 'password' => 'hashed', + ]; +} + +class TestEloquentModelWithCustomCastsNullable extends Model +{ + protected array $guarded = []; + + protected array $casts = [ + 'array_object' => AsArrayObject::class, + 'array_object_json' => AsArrayObject::class, + 'collection' => AsCollection::class, + 'stringable' => AsStringable::class, + ]; +} + +class FluentWithCallback extends Fluent +{ + public static function make(array|object $attributes = []): static + { + return new static($attributes); + } +} + +class CustomCollection extends Collection +{ +} diff --git a/tests/Integration/Database/Laravel/DatabaseEloquentBroadcastingTest.php b/tests/Integration/Database/Laravel/DatabaseEloquentBroadcastingTest.php new file mode 100644 index 000000000..a2fcf4613 --- /dev/null +++ b/tests/Integration/Database/Laravel/DatabaseEloquentBroadcastingTest.php @@ -0,0 +1,277 @@ +increments('id'); + $table->string('name'); + $table->softDeletes(); + $table->timestamps(); + }); + } + + public function testBasicBroadcasting() + { + Event::fake([BroadcastableModelEventOccurred::class]); + + $model = new TestEloquentBroadcastUser(); + $model->name = 'Taylor'; + $model->save(); + + Event::assertDispatched(function (BroadcastableModelEventOccurred $event) { + return $event->model instanceof TestEloquentBroadcastUser + && count($event->broadcastOn()) === 1 + && $event->model->name === 'Taylor' + && $event->broadcastOn()[0]->name == "private-Hypervel.Tests.Integration.Database.Laravel.TestEloquentBroadcastUser.{$event->model->id}"; + }); + } + + public function testChannelRouteFormatting() + { + $model = new TestEloquentBroadcastUser(); + + $this->assertSame('Hypervel.Tests.Integration.Database.Laravel.TestEloquentBroadcastUser.{testEloquentBroadcastUser}', $model->broadcastChannelRoute()); + } + + public function testBroadcastingOnModelTrashing() + { + Event::fake([BroadcastableModelEventOccurred::class]); + + $model = new SoftDeletableTestEloquentBroadcastUser(); + $model->name = 'Bean'; + $model->saveQuietly(); + + $model->delete(); + + Event::assertDispatched(function (BroadcastableModelEventOccurred $event) { + return $event->model instanceof SoftDeletableTestEloquentBroadcastUser + && $event->event() == 'trashed' + && count($event->broadcastOn()) === 1 + && $event->model->name === 'Bean' + && $event->broadcastOn()[0]->name == "private-Hypervel.Tests.Integration.Database.Laravel.SoftDeletableTestEloquentBroadcastUser.{$event->model->id}"; + }); + } + + public function testBroadcastingForSpecificEventsOnly() + { + Event::fake([BroadcastableModelEventOccurred::class]); + + $model = new TestEloquentBroadcastUserOnSpecificEventsOnly(); + $model->name = 'James'; + $model->save(); + + Event::assertDispatched(function (BroadcastableModelEventOccurred $event) { + return $event->model instanceof TestEloquentBroadcastUserOnSpecificEventsOnly + && $event->event() == 'created' + && count($event->broadcastOn()) === 1 + && $event->model->name === 'James' + && $event->broadcastOn()[0]->name == "private-Hypervel.Tests.Integration.Database.Laravel.TestEloquentBroadcastUserOnSpecificEventsOnly.{$event->model->id}"; + }); + + $model->name = 'Graham'; + $model->save(); + + Event::assertNotDispatched(function (BroadcastableModelEventOccurred $event) { + return $event->model instanceof TestEloquentBroadcastUserOnSpecificEventsOnly + && $event->model->name === 'Graham' + && $event->event() == 'updated'; + }); + } + + public function testBroadcastNameDefault() + { + Event::fake([BroadcastableModelEventOccurred::class]); + + $model = new TestEloquentBroadcastUser(); + $model->name = 'Mohamed'; + $model->save(); + + Event::assertDispatched(function (BroadcastableModelEventOccurred $event) { + return $event->model instanceof TestEloquentBroadcastUser + && $event->model->name === 'Mohamed' + && $event->broadcastAs() === 'TestEloquentBroadcastUserCreated' + && $this->assertHandldedBroadcastableEvent($event, function (array $channels, string $eventName, array $payload) { + return $eventName === 'TestEloquentBroadcastUserCreated'; + }); + }); + } + + public function testBroadcastNameCanBeDefined() + { + Event::fake([BroadcastableModelEventOccurred::class]); + + $model = new TestEloquentBroadcastUserWithSpecificBroadcastName(); + $model->name = 'Nuno'; + $model->save(); + + Event::assertDispatched(function (BroadcastableModelEventOccurred $event) { + return $event->model instanceof TestEloquentBroadcastUserWithSpecificBroadcastName + && $event->model->name === 'Nuno' + && $event->broadcastAs() === 'foo' + && $this->assertHandldedBroadcastableEvent($event, function (array $channels, string $eventName, array $payload) { + return $eventName === 'foo'; + }); + }); + + $model->name = 'Dries'; + $model->save(); + + Event::assertDispatched(function (BroadcastableModelEventOccurred $event) { + return $event->model instanceof TestEloquentBroadcastUserWithSpecificBroadcastName + && $event->model->name === 'Dries' + && $event->broadcastAs() === 'TestEloquentBroadcastUserWithSpecificBroadcastNameUpdated' + && $this->assertHandldedBroadcastableEvent($event, function (array $channels, string $eventName, array $payload) { + return $eventName === 'TestEloquentBroadcastUserWithSpecificBroadcastNameUpdated'; + }); + }); + } + + public function testBroadcastPayloadDefault() + { + Event::fake([BroadcastableModelEventOccurred::class]); + + $model = new TestEloquentBroadcastUser(); + $model->name = 'Nuno'; + $model->save(); + + Event::assertDispatched(function (BroadcastableModelEventOccurred $event) { + return $event->model instanceof TestEloquentBroadcastUser + && $event->model->name === 'Nuno' + && is_null($event->broadcastWith()) + && $this->assertHandldedBroadcastableEvent($event, function (array $channels, string $eventName, array $payload) { + return Arr::has($payload, ['model', 'connection', 'queue', 'socket']); + }); + }); + } + + public function testBroadcastPayloadCanBeDefined() + { + Event::fake([BroadcastableModelEventOccurred::class]); + + $model = new TestEloquentBroadcastUserWithSpecificBroadcastPayload(); + $model->name = 'Dries'; + $model->save(); + + Event::assertDispatched(function (BroadcastableModelEventOccurred $event) { + return $event->model instanceof TestEloquentBroadcastUserWithSpecificBroadcastPayload + && $event->model->name === 'Dries' + && $event->broadcastWith() === ['foo' => 'bar'] + && $this->assertHandldedBroadcastableEvent($event, function (array $channels, string $eventName, array $payload) { + return Arr::has($payload, ['foo', 'socket']); + }); + }); + + $model->name = 'Graham'; + $model->save(); + + Event::assertDispatched(function (BroadcastableModelEventOccurred $event) { + return $event->model instanceof TestEloquentBroadcastUserWithSpecificBroadcastPayload + && $event->model->name === 'Graham' + && is_null($event->broadcastWith()) + && $this->assertHandldedBroadcastableEvent($event, function (array $channels, string $eventName, array $payload) { + return Arr::has($payload, ['model', 'connection', 'queue', 'socket']); + }); + }); + } + + private function assertHandldedBroadcastableEvent(BroadcastableModelEventOccurred $event, Closure $closure) + { + $broadcaster = m::mock(Broadcaster::class); + $broadcaster->shouldReceive('broadcast')->once() + ->withArgs(function (array $channels, string $eventName, array $payload) use ($closure) { + return $closure($channels, $eventName, $payload); + }); + + $manager = m::mock(BroadcastingFactory::class); + $manager->shouldReceive('connection')->once()->with(null)->andReturn($broadcaster); + + (new BroadcastEvent($event))->handle($manager); + + return true; + } +} + +class TestEloquentBroadcastUser extends Model +{ + use BroadcastsEvents; + + protected ?string $table = 'test_eloquent_broadcasting_users'; +} + +class SoftDeletableTestEloquentBroadcastUser extends Model +{ + use BroadcastsEvents; + use SoftDeletes; + + protected ?string $table = 'test_eloquent_broadcasting_users'; +} + +class TestEloquentBroadcastUserOnSpecificEventsOnly extends Model +{ + use BroadcastsEvents; + + protected ?string $table = 'test_eloquent_broadcasting_users'; + + public function broadcastOn($event) + { + switch ($event) { + case 'created': + return [$this]; + } + } +} + +class TestEloquentBroadcastUserWithSpecificBroadcastName extends Model +{ + use BroadcastsEvents; + + protected ?string $table = 'test_eloquent_broadcasting_users'; + + public function broadcastAs($event) + { + switch ($event) { + case 'created': + return 'foo'; + } + } +} + +class TestEloquentBroadcastUserWithSpecificBroadcastPayload extends Model +{ + use BroadcastsEvents; + + protected ?string $table = 'test_eloquent_broadcasting_users'; + + public function broadcastWith($event) + { + switch ($event) { + case 'created': + return ['foo' => 'bar']; + } + } +} diff --git a/tests/Integration/Database/Laravel/DatabaseEloquentModelAttributeCastingTest.php b/tests/Integration/Database/Laravel/DatabaseEloquentModelAttributeCastingTest.php new file mode 100644 index 000000000..279ffbee4 --- /dev/null +++ b/tests/Integration/Database/Laravel/DatabaseEloquentModelAttributeCastingTest.php @@ -0,0 +1,529 @@ +increments('id'); + $table->timestamps(); + }); + } + + public function testBasicCustomCasting() + { + $model = new TestEloquentModelWithAttributeCast(); + $model->uppercase = 'taylor'; + + $this->assertSame('TAYLOR', $model->uppercase); + $this->assertSame('TAYLOR', $model->getAttributes()['uppercase']); + $this->assertSame('TAYLOR', $model->toArray()['uppercase']); + + $unserializedModel = unserialize(serialize($model)); + + $this->assertSame('TAYLOR', $unserializedModel->uppercase); + $this->assertSame('TAYLOR', $unserializedModel->getAttributes()['uppercase']); + $this->assertSame('TAYLOR', $unserializedModel->toArray()['uppercase']); + + $model->syncOriginal(); + $model->uppercase = 'dries'; + $this->assertSame('TAYLOR', $model->getOriginal('uppercase')); + + $model = new TestEloquentModelWithAttributeCast(); + $model->uppercase = 'taylor'; + $model->syncOriginal(); + $model->uppercase = 'dries'; + $model->getOriginal(); + + $this->assertSame('DRIES', $model->uppercase); + + $model = $model->setAttribute('uppercase', 'james'); + + $this->assertInstanceOf(TestEloquentModelWithAttributeCast::class, $model); + + $model = new TestEloquentModelWithAttributeCast(); + + $model->address = $address = new AttributeCastAddress('110 Kingsbrook St.', 'My Childhood House'); + $address->lineOne = '117 Spencer St.'; + $this->assertSame('117 Spencer St.', $model->getAttributes()['address_line_one']); + + $model = new TestEloquentModelWithAttributeCast(); + + $model->setRawAttributes([ + 'address_line_one' => '110 Kingsbrook St.', + 'address_line_two' => 'My Childhood House', + ]); + + $this->assertSame('110 Kingsbrook St.', $model->address->lineOne); + $this->assertSame('My Childhood House', $model->address->lineTwo); + + $this->assertSame('110 Kingsbrook St.', $model->toArray()['address_line_one']); + $this->assertSame('My Childhood House', $model->toArray()['address_line_two']); + + $model->address->lineOne = '117 Spencer St.'; + + $this->assertFalse(isset($model->toArray()['address'])); + $this->assertSame('117 Spencer St.', $model->toArray()['address_line_one']); + $this->assertSame('My Childhood House', $model->toArray()['address_line_two']); + + $this->assertSame('117 Spencer St.', json_decode($model->toJson(), true)['address_line_one']); + $this->assertSame('My Childhood House', json_decode($model->toJson(), true)['address_line_two']); + + $model->address = null; + + $this->assertNull($model->toArray()['address_line_one']); + $this->assertNull($model->toArray()['address_line_two']); + + $model->options = ['foo' => 'bar']; + $this->assertEquals(['foo' => 'bar'], $model->options); + $this->assertEquals(['foo' => 'bar'], $model->options); + $model->options = ['foo' => 'bar']; + $model->options = ['foo' => 'bar']; + $this->assertEquals(['foo' => 'bar'], $model->options); + $this->assertEquals(['foo' => 'bar'], $model->options); + + $this->assertSame(json_encode(['foo' => 'bar']), $model->getAttributes()['options']); + + $model = new TestEloquentModelWithAttributeCast(['options' => []]); + $model->syncOriginal(); + $model->options = ['foo' => 'bar']; + $this->assertTrue($model->isDirty('options')); + + $model = new TestEloquentModelWithAttributeCast(); + $model->birthday_at = now(); + $this->assertIsString($model->toArray()['birthday_at']); + } + + public function testGetOriginalWithCastValueObjects() + { + $model = new TestEloquentModelWithAttributeCast([ + 'address' => new AttributeCastAddress('110 Kingsbrook St.', 'My Childhood House'), + ]); + + $model->syncOriginal(); + + $model->address = new AttributeCastAddress('117 Spencer St.', 'Another house.'); + + $this->assertSame('117 Spencer St.', $model->address->lineOne); + $this->assertSame('110 Kingsbrook St.', $model->getOriginal('address')->lineOne); + $this->assertSame('117 Spencer St.', $model->address->lineOne); + + $model = new TestEloquentModelWithAttributeCast([ + 'address' => new AttributeCastAddress('110 Kingsbrook St.', 'My Childhood House'), + ]); + + $model->syncOriginal(); + + $model->address = new AttributeCastAddress('117 Spencer St.', 'Another house.'); + + $this->assertSame('117 Spencer St.', $model->address->lineOne); + $this->assertSame('110 Kingsbrook St.', $model->getOriginal()['address_line_one']); + $this->assertSame('117 Spencer St.', $model->address->lineOne); + $this->assertSame('110 Kingsbrook St.', $model->getOriginal()['address_line_one']); + + $model = new TestEloquentModelWithAttributeCast([ + 'address' => new AttributeCastAddress('110 Kingsbrook St.', 'My Childhood House'), + ]); + + $model->syncOriginal(); + + $model->address = null; + + $this->assertNull($model->address); + $this->assertInstanceOf(AttributeCastAddress::class, $model->getOriginal('address')); + $this->assertNull($model->address); + } + + public function testOneWayCasting() + { + $model = new TestEloquentModelWithAttributeCast(); + + $this->assertNull($model->password); + + $model->password = 'secret'; + + $this->assertEquals(hash('sha256', 'secret'), $model->password); + $this->assertEquals(hash('sha256', 'secret'), $model->getAttributes()['password']); + $this->assertEquals(hash('sha256', 'secret'), $model->getAttributes()['password']); + $this->assertEquals(hash('sha256', 'secret'), $model->password); + + $model->password = 'secret2'; + + $this->assertEquals(hash('sha256', 'secret2'), $model->password); + $this->assertEquals(hash('sha256', 'secret2'), $model->getAttributes()['password']); + $this->assertEquals(hash('sha256', 'secret2'), $model->getAttributes()['password']); + $this->assertEquals(hash('sha256', 'secret2'), $model->password); + } + + public function testSettingRawAttributesClearsTheCastCache() + { + $model = new TestEloquentModelWithAttributeCast(); + + $model->setRawAttributes([ + 'address_line_one' => '110 Kingsbrook St.', + 'address_line_two' => 'My Childhood House', + ]); + + $this->assertSame('110 Kingsbrook St.', $model->address->lineOne); + + $model->setRawAttributes([ + 'address_line_one' => '117 Spencer St.', + 'address_line_two' => 'My Childhood House', + ]); + + $this->assertSame('117 Spencer St.', $model->address->lineOne); + } + + public function testCastsThatOnlyHaveGetterDoNotPersistAnythingToModelOnSave() + { + $model = new TestEloquentModelWithAttributeCast(); + + $model->virtual; + + $model->getAttributes(); + + $this->assertEmpty($model->getDirty()); + } + + public function testCastsThatOnlyHaveGetterThatReturnsPrimitivesAreNotCached() + { + $model = new TestEloquentModelWithAttributeCast(); + + $previous = null; + + foreach (range(0, 10) as $ignored) { + $this->assertNotSame($previous, $previous = $model->virtualString); + } + } + + public function testAttributesCanCacheStrings() + { + $model = new TestEloquentModelWithAttributeCast(); + + $previous = $model->virtual_string_cached; + + $this->assertIsString($previous); + + $this->assertSame($previous, $model->virtual_string_cached); + } + + public function testAttributesCanCacheBooleans() + { + $model = new TestEloquentModelWithAttributeCast(); + + $first = $model->virtual_boolean_cached; + + $this->assertIsBool($first); + + foreach (range(0, 10) as $ignored) { + $this->assertSame($first, $model->virtual_boolean_cached); + } + } + + public function testAttributesCanCacheNull() + { + $model = new TestEloquentModelWithAttributeCast(); + + $this->assertSame(0, $model->virtualNullCalls); + + $first = $model->virtual_null_cached; + + $this->assertNull($first); + + $this->assertSame(1, $model->virtualNullCalls); + + foreach (range(0, 10) as $ignored) { + $this->assertSame($first, $model->virtual_null_cached); + } + + $this->assertSame(1, $model->virtualNullCalls); + } + + public function testAttributesByDefaultDontCacheBooleans() + { + $model = new TestEloquentModelWithAttributeCast(); + + $first = $model->virtual_boolean; + + $this->assertIsBool($first); + + foreach (range(0, 50) as $ignored) { + $current = $model->virtual_boolean; + + $this->assertIsBool($current); + + if ($first !== $current) { + return; + } + } + + $this->fail('"virtual_boolean" seems to be cached.'); + } + + public function testCastsThatOnlyHaveGetterThatReturnsObjectAreCached() + { + $model = new TestEloquentModelWithAttributeCast(); + + $previous = $model->virtualObject; + + foreach (range(0, 10) as $ignored) { + $this->assertSame($previous, $previous = $model->virtualObject); + } + } + + public function testCastsThatOnlyHaveGetterThatReturnsDateTimeAreCached() + { + $model = new TestEloquentModelWithAttributeCast(); + + $previous = $model->virtualDateTime; + + foreach (range(0, 10) as $ignored) { + $this->assertSame($previous, $previous = $model->virtualDateTime); + } + } + + public function testCastsThatOnlyHaveGetterThatReturnsObjectAreNotCached() + { + $model = new TestEloquentModelWithAttributeCast(); + + $previous = $model->virtualObjectWithoutCaching; + + foreach (range(0, 10) as $ignored) { + $this->assertNotSame($previous, $previous = $model->virtualObjectWithoutCaching); + } + } + + public function testCastsThatOnlyHaveGetterThatReturnsDateTimeAreNotCached() + { + $model = new TestEloquentModelWithAttributeCast(); + + $previous = $model->virtualDateTimeWithoutCaching; + + foreach (range(0, 10) as $ignored) { + $this->assertNotSame($previous, $previous = $model->virtualDateTimeWithoutCaching); + } + } + + public function testCastsThatOnlyHaveGetterThatReturnsObjectAreNotCachedFluent() + { + $model = new TestEloquentModelWithAttributeCast(); + + $previous = $model->virtualObjectWithoutCachingFluent; + + foreach (range(0, 10) as $ignored) { + $this->assertNotSame($previous, $previous = $model->virtualObjectWithoutCachingFluent); + } + } + + public function testCastsThatOnlyHaveGetterThatReturnsDateTimeAreNotCachedFluent() + { + $model = new TestEloquentModelWithAttributeCast(); + + $previous = $model->virtualDateTimeWithoutCachingFluent; + + foreach (range(0, 10) as $ignored) { + $this->assertNotSame($previous, $previous = $model->virtualDateTimeWithoutCachingFluent); + } + } +} + +class TestEloquentModelWithAttributeCast extends Model +{ + protected array $guarded = []; + + public function uppercase(): Attribute + { + return Attribute::make( + function ($value) { + return strtoupper($value); + }, + function ($value) { + return strtoupper($value); + } + ); + } + + public function address(): Attribute + { + return new Attribute( + function ($value, $attributes) { + if (is_null($attributes['address_line_one'])) { + return; + } + + return new AttributeCastAddress($attributes['address_line_one'], $attributes['address_line_two']); + }, + function ($value) { + if (is_null($value)) { + return [ + 'address_line_one' => null, + 'address_line_two' => null, + ]; + } + + return ['address_line_one' => $value->lineOne, 'address_line_two' => $value->lineTwo]; + } + ); + } + + public function options(): Attribute + { + return new Attribute( + function ($value) { + return json_decode($value, true); + }, + function ($value) { + return json_encode($value); + } + ); + } + + public function birthdayAt(): Attribute + { + return new Attribute( + function ($value) { + return Carbon::parse($value); + }, + function ($value) { + return $value->format('Y-m-d'); + } + ); + } + + public function password(): Attribute + { + return new Attribute(null, function ($value) { + return hash('sha256', $value); + }); + } + + public function virtual(): Attribute + { + return new Attribute( + function () { + return collect(); + } + ); + } + + public function virtualString(): Attribute + { + return new Attribute( + function () { + return Str::random(10); + } + ); + } + + public function virtualStringCached(): Attribute + { + return Attribute::get(function () { + return Str::random(10); + })->shouldCache(); + } + + public function virtualBooleanCached(): Attribute + { + return Attribute::get(function () { + return (bool) mt_rand(0, 1); + })->shouldCache(); + } + + public function virtualBoolean(): Attribute + { + return Attribute::get(function () { + return (bool) mt_rand(0, 1); + }); + } + + public $virtualNullCalls = 0; + + public function virtualNullCached(): Attribute + { + return Attribute::get(function () { + ++$this->virtualNullCalls; + + return null; + })->shouldCache(); + } + + public function virtualObject(): Attribute + { + return new Attribute( + function () { + return new AttributeCastAddress(Str::random(10), Str::random(10)); + } + ); + } + + public function virtualDateTime(): Attribute + { + return new Attribute( + function () { + return Date::now()->addSeconds(mt_rand(0, 10000)); + } + ); + } + + public function virtualObjectWithoutCachingFluent(): Attribute + { + return (new Attribute( + function () { + return new AttributeCastAddress(Str::random(10), Str::random(10)); + } + ))->withoutObjectCaching(); + } + + public function virtualDateTimeWithoutCachingFluent(): Attribute + { + return (new Attribute( + function () { + return Date::now()->addSeconds(mt_rand(0, 10000)); + } + ))->withoutObjectCaching(); + } + + public function virtualObjectWithoutCaching(): Attribute + { + return Attribute::get(function () { + return new AttributeCastAddress(Str::random(10), Str::random(10)); + })->withoutObjectCaching(); + } + + public function virtualDateTimeWithoutCaching(): Attribute + { + return Attribute::get(function () { + return Date::now()->addSeconds(mt_rand(0, 10000)); + })->withoutObjectCaching(); + } +} + +class AttributeCastAddress +{ + public $lineOne; + + public $lineTwo; + + public function __construct($lineOne, $lineTwo) + { + $this->lineOne = $lineOne; + $this->lineTwo = $lineTwo; + } +} diff --git a/tests/Integration/Database/Laravel/DatabaseEloquentModelCustomCastingTest.php b/tests/Integration/Database/Laravel/DatabaseEloquentModelCustomCastingTest.php new file mode 100644 index 000000000..e8405d35c --- /dev/null +++ b/tests/Integration/Database/Laravel/DatabaseEloquentModelCustomCastingTest.php @@ -0,0 +1,615 @@ +increments('id'); + $table->timestamps(); + $table->decimal('price'); + }); + } + + public function testBasicCustomCasting() + { + $model = new TestEloquentModelWithCustomCast(); + $model->uppercase = 'taylor'; + + $this->assertSame('TAYLOR', $model->uppercase); + $this->assertSame('TAYLOR', $model->getAttributes()['uppercase']); + $this->assertSame('TAYLOR', $model->toArray()['uppercase']); + + $unserializedModel = unserialize(serialize($model)); + + $this->assertSame('TAYLOR', $unserializedModel->uppercase); + $this->assertSame('TAYLOR', $unserializedModel->getAttributes()['uppercase']); + $this->assertSame('TAYLOR', $unserializedModel->toArray()['uppercase']); + + $model->syncOriginal(); + $model->uppercase = 'dries'; + $this->assertSame('TAYLOR', $model->getOriginal('uppercase')); + + $model = new TestEloquentModelWithCustomCast(); + $model->uppercase = 'taylor'; + $model->syncOriginal(); + $model->uppercase = 'dries'; + $model->getOriginal(); + + $this->assertSame('DRIES', $model->uppercase); + + $model = new TestEloquentModelWithCustomCast(); + + $model->address = $address = new Address('110 Kingsbrook St.', 'My Childhood House'); + $address->lineOne = '117 Spencer St.'; + $this->assertSame('117 Spencer St.', $model->getAttributes()['address_line_one']); + + $model = new TestEloquentModelWithCustomCast(); + + $model->setRawAttributes([ + 'address_line_one' => '110 Kingsbrook St.', + 'address_line_two' => 'My Childhood House', + ]); + + $this->assertSame('110 Kingsbrook St.', $model->address->lineOne); + $this->assertSame('My Childhood House', $model->address->lineTwo); + + $this->assertSame('110 Kingsbrook St.', $model->toArray()['address_line_one']); + $this->assertSame('My Childhood House', $model->toArray()['address_line_two']); + + $model->address->lineOne = '117 Spencer St.'; + + $this->assertFalse(isset($model->toArray()['address'])); + $this->assertSame('117 Spencer St.', $model->toArray()['address_line_one']); + $this->assertSame('My Childhood House', $model->toArray()['address_line_two']); + + $this->assertSame('117 Spencer St.', json_decode($model->toJson(), true)['address_line_one']); + $this->assertSame('My Childhood House', json_decode($model->toJson(), true)['address_line_two']); + + $model->address = null; + + $this->assertNull($model->toArray()['address_line_one']); + $this->assertNull($model->toArray()['address_line_two']); + + $model->options = ['foo' => 'bar']; + $this->assertEquals(['foo' => 'bar'], $model->options); + $this->assertEquals(['foo' => 'bar'], $model->options); + $model->options = ['foo' => 'bar']; + $model->options = ['foo' => 'bar']; + $this->assertEquals(['foo' => 'bar'], $model->options); + $this->assertEquals(['foo' => 'bar'], $model->options); + + $this->assertSame(json_encode(['foo' => 'bar']), $model->getAttributes()['options']); + + $model = new TestEloquentModelWithCustomCast(['options' => []]); + $model->syncOriginal(); + $model->options = ['foo' => 'bar']; + $this->assertTrue($model->isDirty('options')); + + $model = new TestEloquentModelWithCustomCast(); + $model->birthday_at = now(); + $this->assertIsString($model->toArray()['birthday_at']); + + $model = new TestEloquentModelWithCustomCast(); + $now = now()->toImmutable(); + $model->anniversary_on_with_object_caching = $now; + $model->anniversary_on_without_object_caching = $now; + $this->assertSame($now, $model->anniversary_on_with_object_caching); + $this->assertSame('UTC', $model->anniversary_on_with_object_caching->format('e')); + $this->assertNotSame($now, $model->anniversary_on_without_object_caching); + $this->assertNotSame('UTC', $model->anniversary_on_without_object_caching->format('e')); + } + + public function testGetOriginalWithCastValueObjects() + { + $model = new TestEloquentModelWithCustomCast([ + 'address' => new Address('110 Kingsbrook St.', 'My Childhood House'), + ]); + + $model->syncOriginal(); + + $model->address = new Address('117 Spencer St.', 'Another house.'); + + $this->assertSame('117 Spencer St.', $model->address->lineOne); + $this->assertSame('110 Kingsbrook St.', $model->getOriginal('address')->lineOne); + $this->assertSame('117 Spencer St.', $model->address->lineOne); + + $model = new TestEloquentModelWithCustomCast([ + 'address' => new Address('110 Kingsbrook St.', 'My Childhood House'), + ]); + + $model->syncOriginal(); + + $model->address = new Address('117 Spencer St.', 'Another house.'); + + $this->assertSame('117 Spencer St.', $model->address->lineOne); + $this->assertSame('110 Kingsbrook St.', $model->getOriginal()['address_line_one']); + $this->assertSame('117 Spencer St.', $model->address->lineOne); + $this->assertSame('110 Kingsbrook St.', $model->getOriginal()['address_line_one']); + + $model = new TestEloquentModelWithCustomCast([ + 'address' => new Address('110 Kingsbrook St.', 'My Childhood House'), + ]); + + $model->syncOriginal(); + + $model->address = null; + + $this->assertNull($model->address); + $this->assertInstanceOf(Address::class, $model->getOriginal('address')); + $this->assertNull($model->address); + } + + public function testDeviableCasts() + { + $model = new TestEloquentModelWithCustomCast(); + $model->price = '123.456'; + $model->save(); + + $model->increment('price', '530.865'); + + $this->assertSame((new Decimal('654.321'))->getValue(), $model->price->getValue()); + + $model->decrement('price', '333.333'); + + $this->assertSame((new Decimal('320.988'))->getValue(), $model->price->getValue()); + + $model->increment('price', new Decimal('100.001')); + + $this->assertSame((new Decimal('420.989'))->getValue(), $model->price->getValue()); + + $model->decrement('price', new Decimal('200.002')); + + $this->assertSame((new Decimal('220.987'))->getValue(), $model->price->getValue()); + } + + public function testSerializableCasts() + { + $model = new TestEloquentModelWithCustomCast(); + $model->price = '123.456'; + + $expectedValue = (new Decimal('123.456'))->getValue(); + + $this->assertSame($expectedValue, $model->price->getValue()); + $this->assertSame('123.456', $model->getAttributes()['price']); + $this->assertSame('123.456', $model->toArray()['price']); + + $unserializedModel = unserialize(serialize($model)); + + $this->assertSame($expectedValue, $unserializedModel->price->getValue()); + $this->assertSame('123.456', $unserializedModel->getAttributes()['price']); + $this->assertSame('123.456', $unserializedModel->toArray()['price']); + } + + public function testOneWayCasting() + { + // CastsInboundAttributes is used for casting that is unidirectional... only use case I can think of is one-way hashing... + $model = new TestEloquentModelWithCustomCast(); + + $model->password = 'secret'; + + $this->assertEquals(hash('sha256', 'secret'), $model->password); + $this->assertEquals(hash('sha256', 'secret'), $model->getAttributes()['password']); + $this->assertEquals(hash('sha256', 'secret'), $model->getAttributes()['password']); + $this->assertEquals(hash('sha256', 'secret'), $model->password); + + $model->password = 'secret2'; + + $this->assertEquals(hash('sha256', 'secret2'), $model->password); + $this->assertEquals(hash('sha256', 'secret2'), $model->getAttributes()['password']); + $this->assertEquals(hash('sha256', 'secret2'), $model->getAttributes()['password']); + $this->assertEquals(hash('sha256', 'secret2'), $model->password); + } + + public function testSettingRawAttributesClearsTheCastCache() + { + $model = new TestEloquentModelWithCustomCast(); + + $model->setRawAttributes([ + 'address_line_one' => '110 Kingsbrook St.', + 'address_line_two' => 'My Childhood House', + ]); + + $this->assertSame('110 Kingsbrook St.', $model->address->lineOne); + + $model->setRawAttributes([ + 'address_line_one' => '117 Spencer St.', + 'address_line_two' => 'My Childhood House', + ]); + + $this->assertSame('117 Spencer St.', $model->address->lineOne); + } + + public function testSettingAttributesUsingArrowClearsTheCastCache() + { + $model = new TestEloquentModelWithCustomCast(); + $model->typed_settings = ['foo' => true]; + + $this->assertTrue($model->typed_settings->foo); + + $model->setAttribute('typed_settings->foo', false); + + $this->assertFalse($model->typed_settings->foo); + } + + public function testWithCastableInterface() + { + $model = new TestEloquentModelWithCustomCast(); + + $model->setRawAttributes([ + 'value_object_with_caster' => serialize(new ValueObject('hello')), + ]); + + $this->assertInstanceOf(ValueObject::class, $model->value_object_with_caster); + $this->assertSame(serialize(new ValueObject('hello')), $model->toArray()['value_object_with_caster']); + + $model->setRawAttributes([ + 'value_object_caster_with_argument' => null, + ]); + + $this->assertSame('argument', $model->value_object_caster_with_argument); + + $model->setRawAttributes([ + 'value_object_caster_with_caster_instance' => serialize(new ValueObject('hello')), + ]); + + $this->assertInstanceOf(ValueObject::class, $model->value_object_caster_with_caster_instance); + } + + public function testGetFromUndefinedCast() + { + $this->expectException(InvalidCastException::class); + + $model = new TestEloquentModelWithCustomCast(); + $model->undefined_cast_column; + } + + public function testSetToUndefinedCast() + { + $this->expectException(InvalidCastException::class); + + $model = new TestEloquentModelWithCustomCast(); + $this->assertTrue($model->hasCast('undefined_cast_column')); + + $model->undefined_cast_column = 'Glāžšķūņu rūķīši'; + } +} + +class TestEloquentModelWithCustomCast extends Model +{ + protected array $guarded = []; + + protected array $casts = [ + 'address' => AddressCaster::class, + 'price' => DecimalCaster::class, + 'password' => HashCaster::class, + 'other_password' => HashCaster::class . ':md5', + 'uppercase' => UppercaseCaster::class, + 'options' => JsonCaster::class, + 'typed_settings' => JsonSettingsCaster::class, + 'value_object_with_caster' => ValueObject::class, + 'value_object_caster_with_argument' => ValueObject::class . ':argument', + 'value_object_caster_with_caster_instance' => ValueObjectWithCasterInstance::class, + 'undefined_cast_column' => UndefinedCast::class, + 'birthday_at' => DateObjectCaster::class, + 'anniversary_on_with_object_caching' => DateTimezoneCasterWithObjectCaching::class . ':America/New_York', + 'anniversary_on_without_object_caching' => DateTimezoneCasterWithoutObjectCaching::class . ':America/New_York', + ]; +} + +class HashCaster implements CastsInboundAttributes +{ + public function __construct(protected string $algorithm = 'sha256') + { + } + + public function set(Model $model, string $key, mixed $value, array $attributes): array + { + return [$key => hash($this->algorithm, $value)]; + } +} + +class UppercaseCaster implements CastsAttributes +{ + public function get(Model $model, string $key, mixed $value, array $attributes): string + { + return strtoupper($value); + } + + public function set(Model $model, string $key, mixed $value, array $attributes): array + { + return [$key => strtoupper($value)]; + } +} + +class AddressCaster implements CastsAttributes +{ + public function get(Model $model, string $key, mixed $value, array $attributes): ?Address + { + if (is_null($attributes['address_line_one'])) { + return null; + } + + return new Address($attributes['address_line_one'], $attributes['address_line_two']); + } + + public function set(Model $model, string $key, mixed $value, array $attributes): array + { + if (is_null($value)) { + return [ + 'address_line_one' => null, + 'address_line_two' => null, + ]; + } + + return ['address_line_one' => $value->lineOne, 'address_line_two' => $value->lineTwo]; + } +} + +class JsonCaster implements CastsAttributes +{ + public function get(Model $model, string $key, mixed $value, array $attributes): ?array + { + return json_decode($value, true); + } + + public function set(Model $model, string $key, mixed $value, array $attributes): string + { + return json_encode($value); + } +} + +class JsonSettingsCaster implements CastsAttributes +{ + public function get(Model $model, string $key, mixed $value, array $attributes): ?Settings + { + if ($value === null) { + return null; + } + + if ($value instanceof Settings) { + return $value; + } + + $payload = json_decode($value, true, JSON_THROW_ON_ERROR); + + return Settings::from($payload); + } + + public function set(Model $model, string $key, mixed $value, array $attributes): ?string + { + if ($value === null) { + return null; + } + + if (is_array($value)) { + $value = Settings::from($value); + } + + if (! $value instanceof Settings) { + throw new Exception("Attribute `{$key}` with JsonSettingsCaster should be a Settings object"); + } + + return $value->toJson(); + } +} + +class DecimalCaster implements CastsAttributes, DeviatesCastableAttributes, SerializesCastableAttributes +{ + public function get(Model $model, string $key, mixed $value, array $attributes): Decimal + { + return new Decimal($value); + } + + public function set(Model $model, string $key, mixed $value, array $attributes): string + { + return (string) $value; + } + + public function increment(Model $model, string $key, mixed $value, array $attributes): mixed + { + return new Decimal($attributes[$key] + ($value instanceof Decimal ? (string) $value : $value)); + } + + public function decrement(Model $model, string $key, mixed $value, array $attributes): mixed + { + return new Decimal($attributes[$key] - ($value instanceof Decimal ? (string) $value : $value)); + } + + public function serialize(Model $model, string $key, mixed $value, array $attributes): mixed + { + return (string) $value; + } +} + +class ValueObjectCaster implements CastsAttributes +{ + public function __construct(private mixed $argument = null) + { + } + + public function get(Model $model, string $key, mixed $value, array $attributes): mixed + { + if ($this->argument) { + return $this->argument; + } + + return unserialize($value); + } + + public function set(Model $model, string $key, mixed $value, array $attributes): string + { + return serialize($value); + } +} + +class ValueObject implements Castable +{ + public string $name; + + public function __construct(string $name) + { + $this->name = $name; + } + + public static function castUsing(array $arguments): CastsAttributes + { + return new class(...$arguments) implements CastsAttributes, SerializesCastableAttributes { + public function __construct(private mixed $argument = null) + { + } + + public function get(Model $model, string $key, mixed $value, array $attributes): mixed + { + if ($this->argument) { + return $this->argument; + } + + return unserialize($value); + } + + public function set(Model $model, string $key, mixed $value, array $attributes): string + { + return serialize($value); + } + + public function serialize(Model $model, string $key, mixed $value, array $attributes): mixed + { + return serialize($value); + } + }; + } +} + +class ValueObjectWithCasterInstance extends ValueObject +{ + public static function castUsing(array $arguments): CastsAttributes + { + return new ValueObjectCaster(); + } +} + +class Address +{ + public $lineOne; + + public $lineTwo; + + public function __construct($lineOne, $lineTwo) + { + $this->lineOne = $lineOne; + $this->lineTwo = $lineTwo; + } +} + +class Settings +{ + public ?bool $foo; + + public ?bool $bar; + + public function __construct(?bool $foo, ?bool $bar) + { + $this->foo = $foo; + $this->bar = $bar; + } + + public static function from(array $data): Settings + { + return new self( + $data['foo'] ?? null, + $data['bar'] ?? null, + ); + } + + public function toJson($options = 0): string + { + return json_encode(['foo' => $this->foo, 'bar' => $this->bar], $options); + } +} + +final class Decimal +{ + private int $value; + + private int $scale; + + public function __construct(string|int|float $value) + { + $stringValue = (string) $value; + $parts = explode('.', $stringValue); + + $this->scale = strlen($parts[1]); + $this->value = (int) str_replace('.', '', $stringValue); + } + + public function getValue(): int + { + return $this->value; + } + + public function __toString(): string + { + return substr_replace((string) $this->value, '.', -$this->scale, 0); + } +} + +class DateObjectCaster implements CastsAttributes +{ + public function __construct(private mixed $argument = null) + { + } + + public function get(Model $model, string $key, mixed $value, array $attributes): Carbon + { + return Carbon::parse($value); + } + + public function set(Model $model, string $key, mixed $value, array $attributes): string + { + return $value->format('Y-m-d'); + } +} + +class DateTimezoneCasterWithObjectCaching implements CastsAttributes +{ + public function __construct(private string $timezone = 'UTC') + { + } + + public function get(Model $model, string $key, mixed $value, array $attributes): Carbon + { + return Carbon::parse($value, $this->timezone); + } + + public function set(Model $model, string $key, mixed $value, array $attributes): string + { + return $value->timezone($this->timezone)->format('Y-m-d'); + } +} + +class DateTimezoneCasterWithoutObjectCaching extends DateTimezoneCasterWithObjectCaching +{ + public bool $withoutObjectCaching = true; +} diff --git a/tests/Integration/Database/Laravel/DatabaseTransactionsTest.php b/tests/Integration/Database/Laravel/DatabaseTransactionsTest.php new file mode 100644 index 000000000..d7a080c78 --- /dev/null +++ b/tests/Integration/Database/Laravel/DatabaseTransactionsTest.php @@ -0,0 +1,155 @@ +set([ + 'database.connections.second_connection' => [ + 'driver' => 'sqlite', + 'database' => ':memory:', + ], + ]); + } + + public function testTransactionCallbacks() + { + [$firstObject, $secondObject, $thirdObject] = [ + new TestObjectForTransactions(), + new TestObjectForTransactions(), + new TestObjectForTransactions(), + ]; + + DB::transaction(function () use ($secondObject, $firstObject) { + DB::afterCommit(fn () => $firstObject->handle()); + + DB::transaction(function () use ($secondObject) { + DB::afterCommit(fn () => $secondObject->handle()); + }); + }); + + $this->assertTrue($firstObject->ran); + $this->assertTrue($secondObject->ran); + $this->assertEquals(1, $firstObject->runs); + $this->assertEquals(1, $secondObject->runs); + $this->assertFalse($thirdObject->ran); + } + + public function testTransactionCallbacksDoNotInterfereWithOneAnother() + { + [$firstObject, $secondObject, $thirdObject] = [ + new TestObjectForTransactions(), + new TestObjectForTransactions(), + new TestObjectForTransactions(), + ]; + + // The problem here is that we're initiating a base transaction, and then two nested transactions. + // Although these two nested transactions are not the same, they share the same level (2). + // Since they are not the same, the latter one failing should not affect the first one. + DB::transaction(function () use ($thirdObject, $secondObject, $firstObject) { // Adds a transaction @ level 1 + DB::transaction(function () use ($firstObject) { // Adds a transaction @ level 2 + DB::afterCommit(fn () => $firstObject->handle()); // Adds a callback to be executed after transaction level 2 is committed + }); + + DB::afterCommit(fn () => $secondObject->handle()); // Adds a callback to be executed after transaction 1 @ lvl 1 + + try { + DB::transaction(function () use ($thirdObject) { // Adds a transaction 3 @ level 2 + DB::afterCommit(fn () => $thirdObject->handle()); + throw new Exception(); // This should only affect callback 3, not 1, even though both share the same transaction level. + }); + } catch (Exception) { + } + }); + + $this->assertTrue($firstObject->ran); + $this->assertTrue($secondObject->ran); + $this->assertEquals(1, $firstObject->runs); + $this->assertEquals(1, $secondObject->runs); + $this->assertFalse($thirdObject->ran); + } + + public function testTransactionsDoNotAffectDifferentConnections() + { + [$firstObject, $secondObject, $thirdObject] = [ + new TestObjectForTransactions(), + new TestObjectForTransactions(), + new TestObjectForTransactions(), + ]; + + DB::transaction(function () use ($secondObject, $firstObject, $thirdObject) { + DB::transaction(function () use ($secondObject) { + DB::afterCommit(fn () => $secondObject->handle()); + }); + + DB::afterCommit(fn () => $firstObject->handle()); + + try { + DB::connection('second_connection')->transaction(function () use ($thirdObject) { + DB::afterCommit(fn () => $thirdObject->handle()); + + throw new Exception(); + }); + } catch (Exception) { + } + }); + + $this->assertTrue($firstObject->ran); + $this->assertTrue($secondObject->ran); + $this->assertFalse($thirdObject->ran); + } + + public function testAfterRollbackCallbacksAreExecuted() + { + $afterCommitRan = false; + $afterRollbackRan = false; + + try { + DB::transaction(function () use (&$afterCommitRan, &$afterRollbackRan) { + DB::afterCommit(function () use (&$afterCommitRan) { + $afterCommitRan = true; + }); + + DB::afterRollBack(function () use (&$afterRollbackRan) { + $afterRollbackRan = true; + }); + + throw new RuntimeException('rollback'); + }); + } catch (RuntimeException) { + // Ignore the expected rollback exception. + } + + $this->assertFalse($afterCommitRan); + $this->assertTrue($afterRollbackRan); + } +} + +class TestObjectForTransactions +{ + public bool $ran = false; + + public int $runs = 0; + + public function handle(): void + { + $this->ran = true; + ++$this->runs; + } +} diff --git a/tests/Integration/Database/Laravel/EloquentAggregateTest.php b/tests/Integration/Database/Laravel/EloquentAggregateTest.php new file mode 100644 index 000000000..3d3310005 --- /dev/null +++ b/tests/Integration/Database/Laravel/EloquentAggregateTest.php @@ -0,0 +1,106 @@ +increments('id'); + $table->integer('c'); + $table->string('name'); + $table->integer('balance')->nullable(); + }); + } + + public function testMinMax() + { + UserAggregateTest::create(['c' => 1, 'name' => 'test-name1', 'balance' => -1]); + UserAggregateTest::create(['c' => 2, 'name' => 'test-name2', 'balance' => -1]); + UserAggregateTest::create(['c' => 3, 'name' => 'test-name3', 'balance' => 0]); + UserAggregateTest::create(['c' => 4, 'name' => 'test-name4', 'balance' => +1]); + UserAggregateTest::create(['c' => 5, 'name' => 'test-name5', 'balance' => +2]); + UserAggregateTest::create(['c' => 6, 'name' => 'test-name5', 'balance' => null]); + + $this->assertEquals(-1, UserAggregateTest::query()->min('balance')); + $this->assertNull(UserAggregateTest::query()->where('name', 'no-name')->min('balance')); + $this->assertEquals(1, UserAggregateTest::query()->where('c', '>', 3)->min('balance')); + + $this->assertEquals(2, UserAggregateTest::query()->max('balance')); + $this->assertNull(UserAggregateTest::query()->where('name', 'no-name')->max('balance')); + $this->assertEquals(0, UserAggregateTest::query()->where('c', '<', 4)->max('balance')); + } + + public function testAvg() + { + UserAggregateTest::create(['c' => 1, 'name' => 'test-name1', 'balance' => -10]); + UserAggregateTest::create(['c' => 2, 'name' => 'test-name2', 'balance' => -10]); + UserAggregateTest::create(['c' => 3, 'name' => 'test-name3', 'balance' => 0]); + UserAggregateTest::create(['c' => 4, 'name' => 'test-name4', 'balance' => +10]); + UserAggregateTest::create(['c' => 5, 'name' => 'test-name5', 'balance' => +20]); + UserAggregateTest::create(['c' => 6, 'name' => 'test-name5', 'balance' => null]); + + $this->assertEquals(2, UserAggregateTest::query()->avg('balance')); + $this->assertNull(UserAggregateTest::query()->where('name', 'no-name')->avg('balance')); + $this->assertEquals(15, UserAggregateTest::query()->where('c', '>', 3)->avg('balance')); + + $this->assertEquals(2, UserAggregateTest::query()->average('balance')); + $this->assertNull(UserAggregateTest::query()->where('name', 'no-name')->average('balance')); + $this->assertEquals(-10, UserAggregateTest::query()->where('c', '<', 3)->average('balance')); + } + + public function testSum() + { + UserAggregateTest::create(['c' => 1, 'name' => 'name-1', 'balance' => -11]); + UserAggregateTest::create(['c' => 2, 'name' => 'name-2', 'balance' => -10]); + UserAggregateTest::create(['c' => 3, 'name' => 'name-3', 'balance' => 0]); + UserAggregateTest::create(['c' => 4, 'name' => 'name-4', 'balance' => +12]); + UserAggregateTest::create(['c' => 5, 'name' => 'name-5', 'balance' => null]); + + $this->assertEquals(-9, UserAggregateTest::query()->sum('balance')); + $result = UserAggregateTest::query()->where('name', 'no-name')->sum('balance'); + $this->assertNotNull($result); + $this->assertEquals(0, $result); + $this->assertEquals(2, UserAggregateTest::query()->where('c', '>', 1)->sum('balance')); + } + + public function testNumericAggregate() + { + UserAggregateTest::create(['c' => 1, 'name' => 'name-1', 'balance' => 40]); + UserAggregateTest::create(['c' => 2, 'name' => 'name-2', 'balance' => -40]); + UserAggregateTest::create(['c' => 3, 'name' => 'name-3', 'balance' => 0]); + UserAggregateTest::create(['c' => 4, 'name' => 'name-4', 'balance' => 20]); + UserAggregateTest::create(['c' => 5, 'name' => 'name-5', 'balance' => null]); + + $this->assertEquals(20, UserAggregateTest::query()->numericAggregate('sum', ['balance'])); + // When calculating the average, rows with NULL values are excluded + $this->assertEquals(5, UserAggregateTest::query()->numericAggregate('avg', ['balance'])); + $this->assertEquals(40, UserAggregateTest::query()->numericAggregate('max', ['balance'])); + $this->assertEquals(-40, UserAggregateTest::query()->numericAggregate('min', ['balance'])); + } +} + +/** + * @internal + * @coversNothing + */ +class UserAggregateTest extends Model +{ + protected ?string $table = 'users'; + + protected array $fillable = ['name', 'c', 'balance']; + + public bool $timestamps = false; +} diff --git a/tests/Integration/Database/Laravel/EloquentBelongsToManyTest.php b/tests/Integration/Database/Laravel/EloquentBelongsToManyTest.php new file mode 100644 index 000000000..06b0612cb --- /dev/null +++ b/tests/Integration/Database/Laravel/EloquentBelongsToManyTest.php @@ -0,0 +1,1694 @@ +increments('id'); + $table->string('uuid'); + $table->string('name'); + $table->timestamps(); + }); + + Schema::create('posts', function (Blueprint $table) { + $table->increments('id'); + $table->string('uuid'); + $table->string('title'); + $table->timestamps(); + }); + + Schema::create('tags', function (Blueprint $table) { + $table->increments('id'); + $table->string('name'); + $table->string('type')->nullable(); + $table->timestamps(); + }); + + Schema::create('unique_tags', function (Blueprint $table) { + $table->increments('id'); + $table->string('name')->unique(); + $table->string('type')->nullable(); + $table->timestamps(); + }); + + Schema::create('users_posts', function (Blueprint $table) { + $table->increments('id'); + $table->string('user_uuid'); + $table->string('post_uuid'); + $table->tinyInteger('is_draft')->default(1); + $table->timestamps(); + }); + + Schema::create('posts_tags', function (Blueprint $table) { + $table->integer('post_id'); + $table->integer('tag_id')->default(0); + $table->string('tag_name')->default('')->nullable(); + $table->string('flag')->default('')->nullable(); + $table->string('isActive')->default('')->nullable(); + $table->timestamps(); + }); + + Schema::create('posts_unique_tags', function (Blueprint $table) { + $table->integer('post_id'); + $table->integer('tag_id')->default(0); + $table->string('tag_name')->default('')->nullable(); + $table->string('flag')->default('')->nullable(); + $table->timestamps(); + }); + } + + public function testBasicCreateAndRetrieve() + { + Carbon::setTestNow('2017-10-10 10:10:10'); + + $post = Post::create(['title' => Str::random()]); + + $tag = Tag::create(['name' => Str::random()]); + $tag2 = Tag::create(['name' => Str::random()]); + $tag3 = Tag::create(['name' => Str::random()]); + + $post->tags()->sync([ + $tag->id => ['flag' => 'taylor'], + $tag2->id => ['flag' => ''], + $tag3->id => ['flag' => 'exclude'], + ]); + + // Tags with flag = exclude should be excluded + $this->assertCount(2, $post->tags); + $this->assertInstanceOf(Collection::class, $post->tags); + $this->assertEquals($tag->name, $post->tags[0]->name); + $this->assertEquals($tag2->name, $post->tags[1]->name); + + // Testing on the pivot model + $this->assertInstanceOf(Pivot::class, $post->tags[0]->pivot); + $this->assertEquals($post->id, $post->tags[0]->pivot->post_id); + $this->assertSame('post_id', $post->tags[0]->pivot->getForeignKey()); + $this->assertSame('tag_id', $post->tags[0]->pivot->getOtherKey()); + $this->assertSame('posts_tags', $post->tags[0]->pivot->getTable()); + $this->assertEquals( + [ + 'post_id' => '1', 'tag_id' => '1', 'flag' => 'taylor', + 'created_at' => '2017-10-10T10:10:10.000000Z', 'updated_at' => '2017-10-10T10:10:10.000000Z', + ], + $post->tags[0]->pivot->toArray() + ); + } + + public function testRefreshOnOtherModelWorks() + { + $post = Post::create(['title' => Str::random()]); + $tag = Tag::create(['name' => $tagName = Str::random()]); + + $post->tags()->sync([ + $tag->id, + ]); + + $post->load('tags'); + + $loadedTag = $post->tags()->first(); + + $tag->update(['name' => 'newName']); + + $this->assertEquals($tagName, $loadedTag->name); + + $this->assertEquals($tagName, $post->tags[0]->name); + + $loadedTag->refresh(); + + $this->assertSame('newName', $loadedTag->name); + + $post->refresh(); + + $this->assertSame('newName', $post->tags[0]->name); + } + + public function testCustomPivotClass() + { + Carbon::setTestNow('2017-10-10 10:10:10'); + + $post = Post::create(['title' => Str::random()]); + + $tag = TagWithCustomPivot::create(['name' => Str::random()]); + + $post->tagsWithCustomPivot()->attach($tag->id); + + $this->assertInstanceOf(PostTagPivot::class, $post->tagsWithCustomPivot[0]->pivot); + $this->assertSame('1507630210', $post->tagsWithCustomPivot[0]->pivot->created_at); + + $this->assertInstanceOf(PostTagPivot::class, $post->tagsWithCustomPivotClass[0]->pivot); + $this->assertSame('posts_tags', $post->tagsWithCustomPivotClass()->getTable()); + + $this->assertEquals([ + 'post_id' => '1', + 'tag_id' => '1', + ], $post->tagsWithCustomAccessor[0]->tag->toArray()); + + $pivot = $post->tagsWithCustomPivot[0]->pivot; + $pivot->tag_id = 2; + $pivot->save(); + + $this->assertEquals(1, PostTagPivot::count()); + $this->assertEquals(1, PostTagPivot::first()->post_id); + $this->assertEquals(2, PostTagPivot::first()->tag_id); + } + + public function testCustomPivotClassUsingSync() + { + Carbon::setTestNow('2017-10-10 10:10:10'); + + $post = Post::create(['title' => Str::random()]); + + $tag = TagWithCustomPivot::create(['name' => Str::random()]); + + $results = $post->tagsWithCustomPivot()->sync([ + $tag->id => ['flag' => 1], + ]); + + $this->assertNotEmpty($results['attached']); + + $results = $post->tagsWithCustomPivot()->sync([ + $tag->id => ['flag' => 1], + ]); + + $this->assertEmpty($results['updated']); + + $results = $post->tagsWithCustomPivot()->sync([]); + + $this->assertNotEmpty($results['detached']); + } + + public function testCustomPivotClassUsingUpdateExistingPivot() + { + Carbon::setTestNow('2017-10-10 10:10:10'); + + $post = Post::create(['title' => Str::random()]); + $tag = TagWithCustomPivot::create(['name' => Str::random()]); + + DB::table('posts_tags')->insert([ + ['post_id' => $post->id, 'tag_id' => $tag->id, 'flag' => 'empty'], + ]); + + // Test on actually existing pivot + $this->assertEquals( + 1, + $post->tagsWithCustomExtraPivot()->updateExistingPivot($tag->id, ['flag' => 'exclude']) + ); + foreach ($post->tagsWithCustomExtraPivot as $tag) { + $this->assertSame('exclude', $tag->pivot->flag); + } + + // Test on non-existent pivot + $this->assertEquals( + 0, + $post->tagsWithCustomExtraPivot()->updateExistingPivot(0, ['flag' => 'exclude']) + ); + } + + public function testCustomPivotClassUpdatesTimestamps() + { + Carbon::setTestNow('2017-10-10 10:10:10'); + + $post = Post::create(['title' => Str::random()]); + $tag = TagWithCustomPivot::create(['name' => Str::random()]); + + DB::table('posts_tags')->insert([ + [ + 'post_id' => $post->id, 'tag_id' => $tag->id, 'flag' => 'empty', + 'created_at' => '2017-10-10 10:10:10', + 'updated_at' => '2017-10-10 10:10:10', + ], + ]); + + Carbon::setTestNow('2017-10-10 10:10:20'); // +10 seconds + + $this->assertEquals( + 1, + $post->tagsWithCustomExtraPivot()->updateExistingPivot($tag->id, ['flag' => 'exclude']) + ); + foreach ($post->tagsWithCustomExtraPivot as $tag) { + $this->assertSame('exclude', $tag->pivot->flag); + + if ($this->driver === 'sqlsrv') { + $this->assertSame('2017-10-10 10:10:10.000', $tag->pivot->getAttributes()['created_at']); + $this->assertSame('2017-10-10 10:10:20.000', $tag->pivot->getAttributes()['updated_at']); // +10 seconds + } else { + $this->assertSame('2017-10-10 10:10:10', $tag->pivot->getAttributes()['created_at']); + $this->assertSame('2017-10-10 10:10:20', $tag->pivot->getAttributes()['updated_at']); // +10 seconds + } + } + } + + public function testAttachMethod() + { + $post = Post::create(['title' => Str::random()]); + + $tag = Tag::create(['name' => Str::random()]); + $tag2 = Tag::create(['name' => Str::random()]); + $tag3 = Tag::create(['name' => Str::random()]); + $tag4 = Tag::create(['name' => Str::random()]); + $tag5 = Tag::create(['name' => Str::random()]); + $tag6 = Tag::create(['name' => Str::random()]); + $tag7 = Tag::create(['name' => Str::random()]); + $tag8 = Tag::create(['name' => Str::random()]); + + $post->tags()->attach($tag->id); + $this->assertEquals($tag->name, $post->tags[0]->name); + $this->assertNotNull($post->tags[0]->pivot->created_at); + + $post->tags()->attach($tag2->id, ['flag' => 'taylor']); + $post->load('tags'); + $this->assertEquals($tag2->name, $post->tags[1]->name); + $this->assertSame('taylor', $post->tags[1]->pivot->flag); + + $post->tags()->attach([$tag3->id, $tag4->id]); + $post->load('tags'); + $this->assertEquals($tag3->name, $post->tags[2]->name); + $this->assertEquals($tag4->name, $post->tags[3]->name); + + $post->tags()->attach([$tag5->id => ['flag' => 'mohamed'], $tag6->id => ['flag' => 'adam']]); + $post->load('tags'); + $this->assertEquals($tag5->name, $post->tags[4]->name); + $this->assertSame('mohamed', $post->tags[4]->pivot->flag); + $this->assertEquals($tag6->name, $post->tags[5]->name); + $this->assertSame('adam', $post->tags[5]->pivot->flag); + + $post->tags()->attach(new Collection([$tag7, $tag8])); + $post->load('tags'); + $this->assertEquals($tag7->name, $post->tags[6]->name); + $this->assertEquals($tag8->name, $post->tags[7]->name); + } + + public function testDetachMethod() + { + $post = Post::create(['title' => Str::random()]); + + $tag = Tag::create(['name' => Str::random()]); + $tag2 = Tag::create(['name' => Str::random()]); + $tag3 = Tag::create(['name' => Str::random()]); + $tag4 = Tag::create(['name' => Str::random()]); + $tag5 = Tag::create(['name' => Str::random()]); + Tag::create(['name' => Str::random()]); + Tag::create(['name' => Str::random()]); + + $post->tags()->attach(Tag::all()); + + $this->assertEquals(Tag::pluck('name'), $post->tags->pluck('name')); + + $post->tags()->detach($tag->id); + $post->load('tags'); + $this->assertEquals( + Tag::whereNotIn('id', [$tag->id])->pluck('name'), + $post->tags->pluck('name') + ); + + $post->tags()->detach([$tag2->id, $tag3->id]); + $post->load('tags'); + $this->assertEquals( + Tag::whereNotIn('id', [$tag->id, $tag2->id, $tag3->id])->pluck('name'), + $post->tags->pluck('name') + ); + + $post->tags()->detach(new Collection([$tag4, $tag5])); + $post->load('tags'); + $this->assertEquals( + Tag::whereNotIn('id', [$tag->id, $tag2->id, $tag3->id, $tag4->id, $tag5->id])->pluck('name'), + $post->tags->pluck('name') + ); + + $this->assertCount(2, $post->tags); + $post->tags()->detach(); + $post->load('tags'); + $this->assertCount(0, $post->tags); + } + + public function testDetachMethodWithCustomPivot() + { + $post = Post::create(['title' => Str::random()]); + + $tag = Tag::create(['name' => Str::random()]); + $tag2 = Tag::create(['name' => Str::random()]); + $tag3 = Tag::create(['name' => Str::random()]); + $tag4 = Tag::create(['name' => Str::random()]); + $tag5 = Tag::create(['name' => Str::random()]); + Tag::create(['name' => Str::random()]); + Tag::create(['name' => Str::random()]); + + $post->tagsWithCustomPivot()->attach(Tag::all()); + + $this->assertEquals(Tag::pluck('name'), $post->tags->pluck('name')); + + $post->tagsWithCustomPivot()->detach($tag->id); + $post->load('tagsWithCustomPivot'); + $this->assertEquals( + Tag::whereNotIn('id', [$tag->id])->pluck('name'), + $post->tagsWithCustomPivot->pluck('name') + ); + + $post->tagsWithCustomPivot()->detach([$tag2->id, $tag3->id]); + $post->load('tagsWithCustomPivot'); + $this->assertEquals( + Tag::whereNotIn('id', [$tag->id, $tag2->id, $tag3->id])->pluck('name'), + $post->tagsWithCustomPivot->pluck('name') + ); + + $post->tagsWithCustomPivot()->detach(new Collection([$tag4, $tag5])); + $post->load('tagsWithCustomPivot'); + $this->assertEquals( + Tag::whereNotIn('id', [$tag->id, $tag2->id, $tag3->id, $tag4->id, $tag5->id])->pluck('name'), + $post->tagsWithCustomPivot->pluck('name') + ); + + $this->assertCount(2, $post->tagsWithCustomPivot); + $post->tagsWithCustomPivot()->detach(); + $post->load('tagsWithCustomPivot'); + $this->assertCount(0, $post->tagsWithCustomPivot); + } + + public function testFirstMethod() + { + $post = Post::create(['title' => Str::random()]); + + $tag = Tag::create(['name' => Str::random()]); + + $post->tags()->attach(Tag::all()); + + $this->assertEquals($tag->name, $post->tags()->first()->name); + } + + public function testFirstOrFailMethod() + { + $this->expectException(ModelNotFoundException::class); + + $post = Post::create(['title' => Str::random()]); + + $post->tags()->firstOrFail(['id']); + } + + public function testFindMethod() + { + $post = Post::create(['title' => Str::random()]); + + $tag = Tag::create(['name' => Str::random()]); + $tag2 = Tag::create(['name' => Str::random()]); + + $post->tags()->attach(Tag::all()); + + $this->assertEquals($tag2->name, $post->tags()->find($tag2->id)->name); + $this->assertCount(0, $post->tags()->findMany([])); + $this->assertCount(2, $post->tags()->findMany([$tag->id, $tag2->id])); + $this->assertCount(0, $post->tags()->findMany(new Collection())); + $this->assertCount(2, $post->tags()->findMany(new Collection([$tag->id, $tag2->id]))); + } + + public function testFindMethodStringyKey() + { + Schema::create('post_string_key', function (Blueprint $table) { + $table->string('id', 1)->primary(); + $table->string('title', 10); + }); + + Schema::create('tag_string_key', function (Blueprint $table) { + $table->string('id', 1)->primary(); + $table->string('title', 10); + }); + + Schema::create('post_tag_string_key', function (Blueprint $table) { + $table->id(); + $table->string('post_id', 1); + $table->string('tag_id', 1); + }); + + $post = PostStringPrimaryKey::query()->create([ + 'id' => 'a', + 'title' => Str::random(10), + ]); + + $tag = TagStringPrimaryKey::query()->create([ + 'id' => 'b', + 'title' => Str::random(10), + ]); + + $tag2 = TagStringPrimaryKey::query()->create([ + 'id' => 'c', + 'title' => Str::random(10), + ]); + + $post->tags()->attach(TagStringPrimaryKey::all()); + + $this->assertEquals($tag2->name, $post->tags()->find($tag2->id)->name); + $this->assertCount(0, $post->tags()->findMany([])); + $this->assertCount(2, $post->tags()->findMany([$tag->id, $tag2->id])); + $this->assertCount(0, $post->tags()->findMany(new Collection())); + $this->assertCount(2, $post->tags()->findMany(new Collection([$tag->id, $tag2->id]))); + } + + public function testFindSoleMethod() + { + $post = Post::create(['title' => Str::random()]); + + $tag = Tag::create(['name' => Str::random()]); + + $post->tags()->attach($tag); + + $this->assertEquals($tag->id, $post->tags()->findSole($tag->id)->id); + + $this->assertEquals($tag->id, $post->tags()->findSole($tag)->id); + + // Test with no records + $post->tags()->detach($tag); + + try { + $post->tags()->findSole($tag); + $this->fail('Expected RecordsNotFoundException was not thrown.'); + } catch (RecordsNotFoundException $e) { + $this->assertTrue(true); + } + } + + public function testFindOrFailMethod() + { + $this->expectException(ModelNotFoundException::class); + $this->expectExceptionMessage('No query results for model [Hypervel\Tests\Integration\Database\Laravel\EloquentBelongsToManyTest\Tag] 10'); + + $post = Post::create(['title' => Str::random()]); + + Tag::create(['name' => Str::random()]); + + $post->tags()->attach(Tag::all()); + + $post->tags()->findOrFail(10); + } + + public function testFindOrFailMethodWithMany() + { + $this->expectException(ModelNotFoundException::class); + $this->expectExceptionMessage('No query results for model [Hypervel\Tests\Integration\Database\Laravel\EloquentBelongsToManyTest\Tag] 10, 11'); + + $post = Post::create(['title' => Str::random()]); + + Tag::create(['name' => Str::random()]); + + $post->tags()->attach(Tag::all()); + + $post->tags()->findOrFail([10, 11]); + } + + public function testFindOrFailMethodWithManyUsingCollection() + { + $this->expectException(ModelNotFoundException::class); + $this->expectExceptionMessage('No query results for model [Hypervel\Tests\Integration\Database\Laravel\EloquentBelongsToManyTest\Tag] 10, 11'); + + $post = Post::create(['title' => Str::random()]); + + Tag::create(['name' => Str::random()]); + + $post->tags()->attach(Tag::all()); + + $post->tags()->findOrFail(new Collection([10, 11])); + } + + public function testFindOrNewMethod() + { + $post = Post::create(['title' => Str::random()]); + + $tag = Tag::create(['name' => Str::random()]); + + $post->tags()->attach(Tag::all()); + + $this->assertEquals($tag->id, $post->tags()->findOrNew($tag->id)->id); + + $this->assertNull($post->tags()->findOrNew(666)->id); + $this->assertInstanceOf(Tag::class, $post->tags()->findOrNew(666)); + } + + public function testFindOrMethod() + { + $post = Post::create(['title' => Str::random()]); + $post->tags()->create(['name' => Str::random()]); + + $result = $post->tags()->findOr(1, fn () => 'callback result'); + $this->assertInstanceOf(Tag::class, $result); + $this->assertSame(1, $result->id); + $this->assertNotNull($result->name); + + $result = $post->tags()->findOr(1, ['id'], fn () => 'callback result'); + $this->assertInstanceOf(Tag::class, $result); + $this->assertSame(1, $result->id); + $this->assertNull($result->name); + + $result = $post->tags()->findOr(2, fn () => 'callback result'); + $this->assertSame('callback result', $result); + } + + public function testFindOrMethodWithMany() + { + $post = Post::create(['title' => Str::random()]); + $post->tags()->createMany([ + ['name' => Str::random()], + ['name' => Str::random()], + ]); + + $result = $post->tags()->findOr([1, 2], fn () => 'callback result'); + $this->assertInstanceOf(Collection::class, $result); + $this->assertSame(1, $result[0]->id); + $this->assertSame(2, $result[1]->id); + $this->assertNotNull($result[0]->name); + $this->assertNotNull($result[1]->name); + + $result = $post->tags()->findOr([1, 2], ['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]->name); + $this->assertNull($result[1]->name); + + $result = $post->tags()->findOr([1, 2, 3], fn () => 'callback result'); + $this->assertSame('callback result', $result); + } + + public function testFindOrMethodWithManyUsingCollection() + { + $post = Post::create(['title' => Str::random()]); + $post->tags()->createMany([ + ['name' => Str::random()], + ['name' => Str::random()], + ]); + + $result = $post->tags()->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->assertNotNull($result[0]->name); + $this->assertNotNull($result[1]->name); + + $result = $post->tags()->findOr(new Collection([1, 2]), ['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]->name); + $this->assertNull($result[1]->name); + + $result = $post->tags()->findOr(new Collection([1, 2, 3]), fn () => 'callback result'); + $this->assertSame('callback result', $result); + } + + public function testFirstOrNewMethod() + { + $post = Post::create(['title' => Str::random()]); + + $tag = Tag::create(['name' => Str::random()]); + + $post->tags()->attach(Tag::all()); + + $this->assertEquals($tag->id, $post->tags()->firstOrNew(['id' => $tag->id])->id); + + $this->assertNull($post->tags()->firstOrNew(['id' => 666])->id); + $this->assertInstanceOf(Tag::class, $post->tags()->firstOrNew(['id' => 666])); + } + + // public function testFirstOrNewUnrelatedExisting() + // { + // $post = Post::create(['title' => Str::random()]); + + // $name = Str::random(); + // $tag = Tag::create(['name' => $name]); + + // $postTag = $post->tags()->firstOrNew(['name' => $name]); + // $this->assertTrue($postTag->exists); + // $this->assertTrue($postTag->is($tag)); + // $this->assertTrue($tag->is($post->tags()->first())); + // } + + public function testFirstOrCreateMethod() + { + $post = Post::create(['title' => Str::random()]); + + $tag = Tag::create(['name' => Str::random()]); + + $post->tags()->attach(Tag::all()); + + $this->assertEquals($tag->id, $post->tags()->firstOrCreate(['name' => $tag->name])->id); + + $new = $post->tags()->firstOrCreate(['name' => 'wavez']); + $this->assertSame('wavez', $new->name); + $this->assertNotNull($new->id); + } + + public function testFirstOrCreateUnrelatedExisting() + { + $post = Post::create(['title' => Str::random()]); + + $name = Str::random(); + $tag = Tag::create(['name' => $name]); + + $postTag = $post->tags()->firstOrCreate(['name' => $name]); + $this->assertTrue($postTag->exists); + $this->assertTrue($postTag->is($tag)); + $this->assertTrue($tag->is($post->tags()->first())); + } + + public function testCreateOrFirst() + { + $post = Post::create(['title' => Str::random()]); + + $tag = UniqueTag::create(['name' => Str::random()]); + + $post->tagsUnique()->attach(UniqueTag::all()); + + $this->assertEquals($tag->id, $post->tagsUnique()->createOrFirst(['name' => $tag->name])->id); + + $new = $post->tagsUnique()->createOrFirst(['name' => 'wavez']); + $this->assertSame('wavez', $new->name); + $this->assertNotNull($new->id); + } + + public function testCreateOrFirstUnrelatedExisting() + { + $post = Post::create(['title' => Str::random()]); + + $name = Str::random(); + $tag = UniqueTag::create(['name' => $name]); + + $postTag = $post->tagsUnique()->createOrFirst(['name' => $name]); + $this->assertTrue($postTag->exists); + $this->assertTrue($postTag->is($tag)); + $this->assertTrue($tag->is($post->tagsUnique()->first())); + } + + public function testCreateOrFirstWithinTransaction() + { + $post = Post::create(['title' => Str::random()]); + + $tag = UniqueTag::create(['name' => Str::random()]); + + $post->tagsUnique()->attach(UniqueTag::all()); + + DB::transaction(function () use ($tag, $post) { + $this->assertEquals($tag->id, $post->tagsUnique()->createOrFirst(['name' => $tag->name])->id); + }); + } + + public function testFirstOrNewMethodWithValues() + { + $post = Post::create(['title' => Str::random()]); + $tag = Tag::create(['name' => Str::random()]); + $post->tags()->attach(Tag::all()); + + $existing = $post->tags()->firstOrNew( + ['name' => $tag->name], + ['type' => 'featured'] + ); + + $this->assertEquals($tag->id, $existing->id); + $this->assertNotEquals('foo', $existing->name); + + $new = $post->tags()->firstOrNew( + ['name' => 'foo'], + ['type' => 'featured'] + ); + + $this->assertSame('foo', $new->name); + $this->assertSame('featured', $new->type); + + $new = $post->tags()->firstOrNew( + ['name' => 'foo'], + ['name' => 'bar'] + ); + + $this->assertSame('bar', $new->name); + } + + public function testFirstOrCreateMethodWithValues() + { + $post = Post::create(['title' => Str::random()]); + $tag = Tag::create(['name' => Str::random()]); + $post->tags()->attach(Tag::all()); + + $existing = $post->tags()->firstOrCreate( + ['name' => $tag->name], + ['type' => 'featured'] + ); + + $this->assertEquals($tag->id, $existing->id); + $this->assertNotEquals('foo', $existing->name); + + $new = $post->tags()->firstOrCreate( + ['name' => 'foo'], + ['type' => 'featured'] + ); + + $this->assertSame('foo', $new->name); + $this->assertSame('featured', $new->type); + $this->assertNotNull($new->id); + + $new = $post->tags()->firstOrCreate( + ['name' => 'qux'], + ['name' => 'bar'] + ); + + $this->assertSame('bar', $new->name); + $this->assertNotNull($new->id); + } + + public function testUpdateOrCreateMethod() + { + $post = Post::create(['title' => Str::random()]); + + $tag = Tag::create(['name' => Str::random()]); + + $post->tags()->attach(Tag::all()); + + $post->tags()->updateOrCreate(['id' => $tag->id], ['name' => 'wavez']); + $this->assertSame('wavez', $tag->fresh()->name); + + $post->tags()->updateOrCreate(['id' => 666], ['name' => 'dives']); + $this->assertNotNull($post->tags()->whereName('dives')->first()); + } + + public function testUpdateOrCreateUnrelatedExisting() + { + $post = Post::create(['title' => Str::random()]); + + $tag = Tag::create(['name' => 'foo']); + + $postTag = $post->tags()->updateOrCreate(['name' => 'foo'], ['name' => 'wavez']); + $this->assertTrue($postTag->exists); + $this->assertTrue($postTag->is($tag)); + $this->assertSame('wavez', $tag->fresh()->name); + $this->assertSame('wavez', $postTag->name); + $this->assertTrue($tag->is($post->tags()->first())); + } + + public function testUpdateOrCreateMethodCreate() + { + $post = Post::create(['title' => Str::random()]); + + $post->tags()->updateOrCreate(['name' => 'wavez'], ['type' => 'featured']); + + $tag = $post->tags()->whereType('featured')->first(); + + $this->assertNotNull($tag); + $this->assertSame('wavez', $tag->name); + } + + public function testSyncMethod() + { + $post = Post::create(['title' => Str::random()]); + + $tag = Tag::create(['name' => Str::random()]); + $tag2 = Tag::create(['name' => Str::random()]); + $tag3 = Tag::create(['name' => Str::random()]); + $tag4 = Tag::create(['name' => Str::random()]); + + $post->tags()->sync([$tag->id, $tag2->id]); + + $this->assertEquals( + Tag::whereIn('id', [$tag->id, $tag2->id])->pluck('name'), + $post->load('tags')->tags->pluck('name') + ); + + $output = $post->tags()->sync([$tag->id, $tag3->id, $tag4->id]); + + $this->assertEquals( + Tag::whereIn('id', [$tag->id, $tag3->id, $tag4->id])->pluck('name'), + $post->load('tags')->tags->pluck('name') + ); + + $this->assertEquals([ + 'attached' => [$tag3->id, $tag4->id], + 'detached' => [1 => $tag2->id], + 'updated' => [], + ], $output); + + $post->tags()->sync([]); + $this->assertEmpty($post->load('tags')->tags); + + $post->tags()->sync([ + $tag->id => ['flag' => 'taylor'], + $tag2->id => ['flag' => 'mohamed'], + ]); + $post->load('tags'); + $this->assertEquals($tag->name, $post->tags[0]->name); + $this->assertSame('taylor', $post->tags[0]->pivot->flag); + $this->assertEquals($tag2->name, $post->tags[1]->name); + $this->assertSame('mohamed', $post->tags[1]->pivot->flag); + } + + public function testSyncMethodWithModels() + { + $post = Post::create(['title' => Str::random()]); + + $tag = Tag::create(['name' => Str::random()]); + $tag2 = Tag::create(['name' => Str::random()]); + $tag3 = Tag::create(['name' => Str::random()]); + $tag4 = Tag::create(['name' => Str::random()]); + + // Test syncing with an array of models + $post->tags()->sync([$tag, $tag2]); + + $this->assertEquals( + Tag::whereIn('id', [$tag->id, $tag2->id])->pluck('name'), + $post->load('tags')->tags->pluck('name') + ); + + // Test syncing with a BaseCollection of models + $tagCollection = collect([$tag3, $tag4]); + + $post->tags()->sync($tagCollection); + + $this->assertEquals( + Tag::whereIn('id', [$tag3->id, $tag4->id])->pluck('name'), + $post->load('tags')->tags->pluck('name') + ); + + // Test syncing with EloquentCollection of models + $tagCollection = Tag::limit(2)->get(); + + $post->tags()->sync($tagCollection); + + $this->assertEquals( + Tag::whereIn('id', [$tag->id, $tag2->id])->pluck('name'), + $post->load('tags')->tags->pluck('name') + ); + } + + public function testSyncWithoutDetachingMethod() + { + $post = Post::create(['title' => Str::random()]); + + $tag = Tag::create(['name' => Str::random()]); + $tag2 = Tag::create(['name' => Str::random()]); + + $post->tags()->sync([$tag->id]); + + $this->assertEquals( + Tag::whereIn('id', [$tag->id])->pluck('name'), + $post->load('tags')->tags->pluck('name') + ); + + $post->tags()->syncWithoutDetaching([$tag2->id]); + + $this->assertEquals( + Tag::whereIn('id', [$tag->id, $tag2->id])->pluck('name'), + $post->load('tags')->tags->pluck('name') + ); + } + + public function testSyncMethodWithEmptyValueDoesNotQueryWhenDetachingDisabled() + { + $post = Post::create(['title' => Str::random()]); + + DB::enableQueryLog(); + + foreach ([collect(), [], null] as $value) { + $result = $post->tags()->sync($value, false); + + $this->assertEquals([ + 'attached' => [], + 'detached' => [], + 'updated' => [], + ], $result); + } + + $this->assertEmpty(DB::getQueryLog()); + + DB::disableQueryLog(); + } + + public function testToggleMethod() + { + $post = Post::create(['title' => Str::random()]); + + $tag = Tag::create(['name' => Str::random()]); + $tag2 = Tag::create(['name' => Str::random()]); + + $post->tags()->toggle([$tag->id]); + + $this->assertEquals( + Tag::whereIn('id', [$tag->id])->pluck('name'), + $post->load('tags')->tags->pluck('name') + ); + + $post->tags()->toggle([$tag2->id, $tag->id]); + + $this->assertEquals( + Tag::whereIn('id', [$tag2->id])->pluck('name'), + $post->load('tags')->tags->pluck('name') + ); + + $post->tags()->toggle([$tag2->id, $tag->id => ['flag' => 'taylor']]); + $post->load('tags'); + $this->assertEquals( + Tag::whereIn('id', [$tag->id])->pluck('name'), + $post->tags->pluck('name') + ); + $this->assertSame('taylor', $post->tags[0]->pivot->flag); + } + + public function testTouchingParent() + { + $post = Post::create(['title' => Str::random()]); + + $tag = TouchingTag::create(['name' => Str::random()]); + + $post->touchingTags()->attach([$tag->id]); + + $this->assertNotSame('2017-10-10 10:10:10', $post->fresh()->updated_at->toDateTimeString()); + + Carbon::setTestNow('2017-10-10 10:10:10'); + + $tag->update(['name' => $tag->name]); + $this->assertNotSame('2017-10-10 10:10:10', $post->fresh()->updated_at->toDateTimeString()); + + $tag->update(['name' => Str::random()]); + $this->assertSame('2017-10-10 10:10:10', $post->fresh()->updated_at->toDateTimeString()); + } + + public function testTouchingRelatedModelsOnSync() + { + $tag = TouchingTag::create(['name' => Str::random()]); + + $post = Post::create(['title' => Str::random()]); + + $this->assertNotSame('2017-10-10 10:10:10', $post->fresh()->updated_at->toDateTimeString()); + $this->assertNotSame('2017-10-10 10:10:10', $tag->fresh()->updated_at->toDateTimeString()); + + Carbon::setTestNow('2017-10-10 10:10:10'); + + $tag->posts()->sync([$post->id]); + + $this->assertSame('2017-10-10 10:10:10', $post->fresh()->updated_at->toDateTimeString()); + $this->assertSame('2017-10-10 10:10:10', $tag->fresh()->updated_at->toDateTimeString()); + } + + public function testNoTouchingHappensIfNotConfigured() + { + $tag = Tag::create(['name' => Str::random()]); + + $post = Post::create(['title' => Str::random()]); + + $this->assertNotSame('2017-10-10 10:10:10', $post->fresh()->updated_at->toDateTimeString()); + $this->assertNotSame('2017-10-10 10:10:10', $tag->fresh()->updated_at->toDateTimeString()); + + Carbon::setTestNow('2017-10-10 10:10:10'); + + $tag->posts()->sync([$post->id]); + + $this->assertNotSame('2017-10-10 10:10:10', $post->fresh()->updated_at->toDateTimeString()); + $this->assertNotSame('2017-10-10 10:10:10', $tag->fresh()->updated_at->toDateTimeString()); + } + + public function testCanRetrieveRelatedIds() + { + $post = Post::create(['title' => Str::random()]); + + DB::table('tags')->insert([ + ['name' => 'excluded'], + ['name' => Str::random()], + ]); + + DB::table('posts_tags')->insert([ + ['post_id' => $post->id, 'tag_id' => 1, 'flag' => ''], + ['post_id' => $post->id, 'tag_id' => 2, 'flag' => 'exclude'], + ['post_id' => $post->id, 'tag_id' => 3, 'flag' => ''], + ]); + + $this->assertEquals([1, 3], $post->tags()->allRelatedIds()->toArray()); + } + + public function testCanTouchRelatedModels() + { + $post = Post::create(['title' => Str::random()]); + + DB::table('tags')->insert([ + ['name' => Str::random()], + ['name' => Str::random()], + ]); + + DB::table('posts_tags')->insert([ + ['post_id' => $post->id, 'tag_id' => 1, 'flag' => ''], + ['post_id' => $post->id, 'tag_id' => 2, 'flag' => 'exclude'], + ['post_id' => $post->id, 'tag_id' => 3, 'flag' => ''], + ]); + + Carbon::setTestNow('2017-10-10 10:10:10'); + + $post->tags()->touch(); + + foreach ($post->tags()->pluck('tags.updated_at') as $date) { + $this->assertSame('2017-10-10 10:10:10', $date->toDateTimeString()); + } + + $this->assertNotSame('2017-10-10 10:10:10', Tag::find(2)->updated_at?->toDateTimeString()); + } + + public function testWherePivotOnString() + { + $tag = Tag::create(['name' => Str::random()])->fresh(); + $post = Post::create(['title' => Str::random()]); + + DB::table('posts_tags')->insert([ + ['post_id' => $post->id, 'tag_id' => $tag->id, 'flag' => 'foo'], + ]); + + $relationTag = $post->tags()->wherePivot('flag', 'foo')->first(); + $this->assertEquals($relationTag->getAttributes(), $tag->getAttributes()); + + $relationTag = $post->tags()->wherePivot('flag', '=', 'foo')->first(); + $this->assertEquals($relationTag->getAttributes(), $tag->getAttributes()); + } + + public function testFirstWhere() + { + $tag = Tag::create(['name' => 'foo'])->fresh(); + $post = Post::create(['title' => Str::random()]); + + DB::table('posts_tags')->insert([ + ['post_id' => $post->id, 'tag_id' => $tag->id, 'flag' => 'foo'], + ]); + + $relationTag = $post->tags()->firstWhere('name', 'foo'); + $this->assertEquals($relationTag->getAttributes(), $tag->getAttributes()); + + $relationTag = $post->tags()->firstWhere('name', '=', 'foo'); + $this->assertEquals($relationTag->getAttributes(), $tag->getAttributes()); + } + + public function testWherePivotOnBoolean() + { + $tag = Tag::create(['name' => Str::random()])->fresh(); + $post = Post::create(['title' => Str::random()]); + + DB::table('posts_tags')->insert([ + ['post_id' => $post->id, 'tag_id' => $tag->id, 'flag' => true], + ]); + + $relationTag = $post->tags()->wherePivot('flag', true)->first(); + $this->assertEquals($relationTag->getAttributes(), $tag->getAttributes()); + + $relationTag = $post->tags()->wherePivot('flag', '=', true)->first(); + $this->assertEquals($relationTag->getAttributes(), $tag->getAttributes()); + } + + public function testOrWherePivotOnBoolean() + { + $tag = Tag::create(['name' => Str::random()])->fresh(); + $post = Post::create(['title' => Str::random()]); + + DB::table('posts_tags')->insert([ + ['post_id' => $post->id, 'tag_id' => $tag->id, 'flag' => true, 'isActive' => false], + ]); + + $relationTag = $post->tags()->wherePivot('isActive', false)->orWherePivot('flag', true)->first(); + $this->assertEquals($relationTag->getAttributes(), $tag->getAttributes()); + } + + public function testWherePivotNotBetween() + { + $tag = Tag::create(['name' => Str::random()])->fresh(); + $post = Post::create(['title' => Str::random()]); + + DB::table('posts_tags')->insert([ + ['post_id' => $post->id, 'tag_id' => $tag->id, 'flag' => true, 'isActive' => false], + ]); + + $relationTag = $post->tags() + ->wherePivotNotBetween('isActive', ['true', 'false']) + ->orWherePivotNotBetween('flag', ['true', 'false']) + ->first(); + + $this->assertEquals($relationTag->getAttributes(), $tag->getAttributes()); + } + + public function testWherePivotInMethod() + { + $tag = Tag::create(['name' => Str::random()])->fresh(); + $post = Post::create(['title' => Str::random()]); + + DB::table('posts_tags')->insert([ + ['post_id' => $post->id, 'tag_id' => $tag->id, 'flag' => 'foo'], + ]); + + $relationTag = $post->tags()->wherePivotIn('flag', ['foo'])->first(); + $this->assertEquals($relationTag->getAttributes(), $tag->getAttributes()); + } + + public function testOrWherePivotInMethod() + { + $tag1 = Tag::create(['name' => Str::random()]); + $tag2 = Tag::create(['name' => Str::random()]); + $tag3 = Tag::create(['name' => Str::random()]); + $post = Post::create(['title' => Str::random()]); + + DB::table('posts_tags')->insert([ + ['post_id' => $post->id, 'tag_id' => $tag1->id, 'flag' => 'foo'], + ]); + DB::table('posts_tags')->insert([ + ['post_id' => $post->id, 'tag_id' => $tag2->id, 'flag' => 'bar'], + ]); + DB::table('posts_tags')->insert([ + ['post_id' => $post->id, 'tag_id' => $tag3->id, 'flag' => 'baz'], + ]); + + $relationTags = $post->tags()->wherePivotIn('flag', ['foo'])->orWherePivotIn('flag', ['baz'])->get(); + $this->assertEquals($relationTags->pluck('id')->toArray(), [$tag1->id, $tag3->id]); + } + + public function testWherePivotNotInMethod() + { + $tag1 = Tag::create(['name' => Str::random()]); + $tag2 = Tag::create(['name' => Str::random()])->fresh(); + $post = Post::create(['title' => Str::random()]); + + DB::table('posts_tags')->insert([ + ['post_id' => $post->id, 'tag_id' => $tag1->id, 'flag' => 'foo'], + ]); + DB::table('posts_tags')->insert([ + ['post_id' => $post->id, 'tag_id' => $tag2->id, 'flag' => 'bar'], + ]); + + $relationTag = $post->tags()->wherePivotNotIn('flag', ['foo'])->first(); + $this->assertEquals($relationTag->getAttributes(), $tag2->getAttributes()); + } + + public function testOrWherePivotNotInMethod() + { + $tag1 = Tag::create(['name' => Str::random()]); + $tag2 = Tag::create(['name' => Str::random()]); + $tag3 = Tag::create(['name' => Str::random()]); + $post = Post::create(['title' => Str::random()]); + + DB::table('posts_tags')->insert([ + ['post_id' => $post->id, 'tag_id' => $tag1->id, 'flag' => 'foo'], + ]); + DB::table('posts_tags')->insert([ + ['post_id' => $post->id, 'tag_id' => $tag2->id, 'flag' => 'bar'], + ]); + DB::table('posts_tags')->insert([ + ['post_id' => $post->id, 'tag_id' => $tag3->id, 'flag' => 'baz'], + ]); + + $relationTags = $post->tags()->wherePivotIn('flag', ['foo'])->orWherePivotNotIn('flag', ['baz'])->get(); + $this->assertEquals($relationTags->pluck('id')->toArray(), [$tag1->id, $tag2->id]); + } + + public function testWherePivotNullMethod() + { + $tag1 = Tag::create(['name' => Str::random()]); + $tag2 = Tag::create(['name' => Str::random()])->fresh(); + $post = Post::create(['title' => Str::random()]); + + DB::table('posts_tags')->insert([ + ['post_id' => $post->id, 'tag_id' => $tag1->id, 'flag' => 'foo'], + ]); + DB::table('posts_tags')->insert([ + ['post_id' => $post->id, 'tag_id' => $tag2->id, 'flag' => null], + ]); + + $relationTag = $post->tagsWithExtraPivot()->wherePivotNull('flag')->first(); + $this->assertEquals($relationTag->getAttributes(), $tag2->getAttributes()); + } + + public function testWherePivotNotNullMethod() + { + $tag1 = Tag::create(['name' => Str::random()])->fresh(); + $tag2 = Tag::create(['name' => Str::random()]); + $post = Post::create(['title' => Str::random()]); + + DB::table('posts_tags')->insert([ + ['post_id' => $post->id, 'tag_id' => $tag1->id, 'flag' => 'foo', 'isActive' => true], + ]); + DB::table('posts_tags')->insert([ + ['post_id' => $post->id, 'tag_id' => $tag2->id, 'flag' => null, 'isActive' => false], + ]); + + $relationTag = $post->tagsWithExtraPivot()->wherePivotNotNull('flag')->orWherePivotNotNull('isActive')->first(); + $this->assertEquals($relationTag->getAttributes(), $tag1->getAttributes()); + } + + public function testCanUpdateExistingPivot() + { + $tag = Tag::create(['name' => Str::random()]); + $post = Post::create(['title' => Str::random()]); + + DB::table('posts_tags')->insert([ + ['post_id' => $post->id, 'tag_id' => $tag->id, 'flag' => 'empty'], + ]); + + $post->tagsWithExtraPivot()->updateExistingPivot($tag->id, ['flag' => 'exclude']); + + foreach ($post->tagsWithExtraPivot as $tag) { + $this->assertSame('exclude', $tag->pivot->flag); + } + } + + public function testCanUpdateExistingPivotUsingArrayableOfIds() + { + $tags = new Collection([ + $tag1 = Tag::create(['name' => Str::random()]), + $tag2 = Tag::create(['name' => Str::random()]), + ]); + $post = Post::create(['title' => Str::random()]); + + DB::table('posts_tags')->insert([ + ['post_id' => $post->id, 'tag_id' => $tag1->id, 'flag' => 'empty'], + ['post_id' => $post->id, 'tag_id' => $tag2->id, 'flag' => 'empty'], + ]); + + $post->tagsWithExtraPivot()->updateExistingPivot($tags, ['flag' => 'exclude']); + + foreach ($post->tagsWithExtraPivot as $tag) { + $this->assertSame('exclude', $tag->pivot->flag); + } + } + + public function testCanUpdateExistingPivotUsingModel() + { + $tag = Tag::create(['name' => Str::random()]); + $post = Post::create(['title' => Str::random()]); + + DB::table('posts_tags')->insert([ + ['post_id' => $post->id, 'tag_id' => $tag->id, 'flag' => 'empty'], + ]); + + $post->tagsWithExtraPivot()->updateExistingPivot($tag, ['flag' => 'exclude']); + + foreach ($post->tagsWithExtraPivot as $tag) { + $this->assertSame('exclude', $tag->pivot->flag); + } + } + + public function testCustomRelatedKey() + { + $post = Post::create(['title' => Str::random()]); + + $tag = $post->tagsWithCustomRelatedKey()->create(['name' => Str::random()]); + $this->assertEquals($tag->name, $post->tagsWithCustomRelatedKey()->first()->pivot->tag_name); + + $post->tagsWithCustomRelatedKey()->detach($tag); + + $post->tagsWithCustomRelatedKey()->attach($tag); + $this->assertEquals($tag->name, $post->tagsWithCustomRelatedKey()->first()->pivot->tag_name); + + $post->tagsWithCustomRelatedKey()->detach(new Collection([$tag])); + + $post->tagsWithCustomRelatedKey()->attach(new Collection([$tag])); + $this->assertEquals($tag->name, $post->tagsWithCustomRelatedKey()->first()->pivot->tag_name); + + $post->tagsWithCustomRelatedKey()->updateExistingPivot($tag, ['flag' => 'exclude']); + $this->assertSame('exclude', $post->tagsWithCustomRelatedKey()->first()->pivot->flag); + } + + public function testGlobalScopeColumns() + { + $tag = Tag::create(['name' => Str::random()]); + $post = Post::create(['title' => Str::random()]); + + DB::table('posts_tags')->insert([ + ['post_id' => $post->id, 'tag_id' => $tag->id, 'flag' => 'empty'], + ]); + + $tags = $post->tagsWithGlobalScope; + + $this->assertEquals(['id' => 1], $tags[0]->getAttributes()); + } + + public function testPivotDoesntHavePrimaryKey() + { + $user = User::create(['name' => Str::random()]); + $post1 = Post::create(['title' => Str::random()]); + $post2 = Post::create(['title' => Str::random()]); + + $user->postsWithCustomPivot()->sync([$post1->uuid]); + $this->assertEquals($user->uuid, $user->postsWithCustomPivot()->first()->pivot->user_uuid); + $this->assertEquals($post1->uuid, $user->postsWithCustomPivot()->first()->pivot->post_uuid); + $this->assertEquals(1, $user->postsWithCustomPivot()->first()->pivot->is_draft); + + $user->postsWithCustomPivot()->sync([$post2->uuid]); + $this->assertEquals($user->uuid, $user->postsWithCustomPivot()->first()->pivot->user_uuid); + $this->assertEquals($post2->uuid, $user->postsWithCustomPivot()->first()->pivot->post_uuid); + $this->assertEquals(1, $user->postsWithCustomPivot()->first()->pivot->is_draft); + + $user->postsWithCustomPivot()->updateExistingPivot($post2->uuid, ['is_draft' => 0]); + $this->assertEquals(0, $user->postsWithCustomPivot()->first()->pivot->is_draft); + } + + public function testOrderByPivotMethod() + { + $tag1 = Tag::create(['name' => Str::random()]); + $tag2 = Tag::create(['name' => Str::random()])->fresh(); + $tag3 = Tag::create(['name' => Str::random()])->fresh(); + $tag4 = Tag::create(['name' => Str::random()]); + $post = Post::create(['title' => Str::random()]); + + DB::table('posts_tags')->insert([ + ['post_id' => $post->id, 'tag_id' => $tag1->id, 'flag' => 'foo3'], + ['post_id' => $post->id, 'tag_id' => $tag2->id, 'flag' => 'foo1'], + ['post_id' => $post->id, 'tag_id' => $tag3->id, 'flag' => 'foo4'], + ['post_id' => $post->id, 'tag_id' => $tag4->id, 'flag' => 'foo2'], + ]); + + $relationTag1 = $post->tagsWithCustomExtraPivot()->orderByPivot('flag', 'asc')->first(); + $this->assertEquals($relationTag1->getAttributes(), $tag2->getAttributes()); + + $relationTag2 = $post->tagsWithCustomExtraPivot()->orderByPivot('flag', 'desc')->first(); + $this->assertEquals($relationTag2->getAttributes(), $tag3->getAttributes()); + } + + public function testFirstOrMethod() + { + $user1 = User::create(['name' => Str::random()]); + $user2 = User::create(['name' => Str::random()]); + $user3 = User::create(['name' => Str::random()]); + $post1 = Post::create(['title' => Str::random()]); + $post2 = Post::create(['title' => Str::random()]); + $post3 = Post::create(['title' => Str::random()]); + + $user1->posts()->sync([$post1->uuid, $post2->uuid]); + $user2->posts()->sync([$post1->uuid, $post2->uuid]); + + $this->assertEquals( + $post1->id, + $user2->posts()->firstOr(function () { + return Post::create(['title' => Str::random()]); + })->id + ); + + $this->assertEquals( + $post3->id, + $user3->posts()->firstOr(function () use ($post3) { + return $post3; + })->id + ); + } + + public function testUpdateOrCreateQueryBuilderIsolation() + { + $user = User::create(['name' => Str::random()]); + $post = Post::create(['title' => Str::random()]); + + $user->postsWithCustomPivot()->attach($post); + + $instance = $user->postsWithCustomPivot()->updateOrCreate( + ['uuid' => $post->uuid], + ['title' => Str::random()], + ); + + $this->assertArrayNotHasKey( + 'user_uuid', + $instance->toArray(), + ); + } + + public function testFirstOrCreateQueryBuilderIsolation() + { + $user = User::create(['name' => Str::random()]); + $post = Post::create(['title' => Str::random()]); + + $user->postsWithCustomPivot()->attach($post); + + $instance = $user->postsWithCustomPivot()->firstOrCreate( + ['uuid' => $post->uuid], + ['title' => Str::random()], + ); + + $this->assertArrayNotHasKey( + 'user_uuid', + $instance->toArray(), + ); + } +} + +class User extends Model +{ + protected ?string $table = 'users'; + + public bool $timestamps = true; + + protected array $guarded = []; + + protected static function boot(): void + { + parent::boot(); + + static::creating(function ($model) { + $model->setAttribute('uuid', Str::random()); + }); + } + + public function posts() + { + return $this->belongsToMany(Post::class, 'users_posts', 'user_uuid', 'post_uuid', 'uuid', 'uuid') + ->withPivot('is_draft') + ->withTimestamps(); + } + + public function postsWithCustomPivot() + { + return $this->belongsToMany(Post::class, 'users_posts', 'user_uuid', 'post_uuid', 'uuid', 'uuid') + ->using(UserPostPivot::class) + ->withPivot('is_draft') + ->withTimestamps(); + } +} + +class PostStringPrimaryKey extends Model +{ + public bool $incrementing = false; + + public bool $timestamps = false; + + protected ?string $table = 'post_string_key'; + + protected string $keyType = 'string'; + + protected array $fillable = ['title', 'id']; + + public function tags() + { + return $this->belongsToMany(TagStringPrimaryKey::class, 'post_tag_string_key', 'post_id', 'tag_id'); + } +} + +class TagStringPrimaryKey extends Model +{ + public bool $incrementing = false; + + public bool $timestamps = false; + + protected ?string $table = 'tag_string_key'; + + protected string $keyType = 'string'; + + protected array $fillable = ['title', 'id']; + + public function posts() + { + return $this->belongsToMany(PostStringPrimaryKey::class, 'post_tag_string_key', 'tag_id', 'post_id'); + } +} + +class Post extends Model +{ + protected ?string $table = 'posts'; + + public bool $timestamps = true; + + protected array $guarded = []; + + protected array $touches = ['touchingTags']; + + protected static function boot(): void + { + parent::boot(); + + static::creating(function ($model) { + $model->setAttribute('uuid', Str::random()); + }); + } + + public function users() + { + return $this->belongsToMany(User::class, 'users_posts', 'post_uuid', 'user_uuid', 'uuid', 'uuid') + ->withPivot('is_draft') + ->withTimestamps(); + } + + public function tags() + { + return $this->belongsToMany(Tag::class, 'posts_tags', 'post_id', 'tag_id') + ->withPivot('flag') + ->withTimestamps() + ->wherePivot('flag', '<>', 'exclude'); + } + + public function tagsUnique() + { + return $this->belongsToMany(UniqueTag::class, 'posts_unique_tags', 'post_id', 'tag_id') + ->withPivot('flag') + ->withTimestamps() + ->wherePivot('flag', '<>', 'exclude'); + } + + public function tagsWithExtraPivot() + { + return $this->belongsToMany(Tag::class, 'posts_tags', 'post_id', 'tag_id') + ->withPivot('flag'); + } + + public function touchingTags() + { + return $this->belongsToMany(TouchingTag::class, 'posts_tags', 'post_id', 'tag_id') + ->withTimestamps(); + } + + public function tagsWithCustomPivot() + { + return $this->belongsToMany(TagWithCustomPivot::class, 'posts_tags', 'post_id', 'tag_id') + ->using(PostTagPivot::class) + ->withTimestamps(); + } + + public function tagsWithCustomExtraPivot() + { + return $this->belongsToMany(TagWithCustomPivot::class, 'posts_tags', 'post_id', 'tag_id') + ->using(PostTagPivot::class) + ->withTimestamps() + ->withPivot('flag'); + } + + public function tagsWithCustomPivotClass() + { + return $this->belongsToMany(TagWithCustomPivot::class, PostTagPivot::class, 'post_id', 'tag_id'); + } + + public function tagsWithCustomAccessor() + { + return $this->belongsToMany(TagWithCustomPivot::class, 'posts_tags', 'post_id', 'tag_id') + ->using(PostTagPivot::class) + ->as('tag'); + } + + public function tagsWithCustomRelatedKey() + { + return $this->belongsToMany(Tag::class, 'posts_tags', 'post_id', 'tag_name', 'id', 'name') + ->withPivot('flag'); + } + + public function tagsWithGlobalScope() + { + return $this->belongsToMany(TagWithGlobalScope::class, 'posts_tags', 'post_id', 'tag_id'); + } +} + +class Tag extends Model +{ + protected ?string $table = 'tags'; + + public bool $timestamps = true; + + protected array $fillable = ['name', 'type']; + + public function posts() + { + return $this->belongsToMany(Post::class, 'posts_tags', 'tag_id', 'post_id'); + } +} + +class UniqueTag extends Model +{ + protected ?string $table = 'unique_tags'; + + public bool $timestamps = true; + + protected array $fillable = ['name', 'type']; + + public function posts() + { + return $this->belongsToMany(Post::class, 'posts_unique_tags', 'tag_id', 'post_id'); + } +} + +class TouchingTag extends Model +{ + protected ?string $table = 'tags'; + + public bool $timestamps = true; + + protected array $guarded = []; + + protected array $touches = ['posts']; + + public function posts() + { + return $this->belongsToMany(Post::class, 'posts_tags', 'tag_id', 'post_id'); + } +} + +class TagWithCustomPivot extends Model +{ + protected ?string $table = 'tags'; + + public bool $timestamps = true; + + protected array $guarded = []; + + public function posts() + { + return $this->belongsToMany(Post::class, 'posts_tags', 'tag_id', 'post_id'); + } +} + +class UserPostPivot extends Pivot +{ + protected ?string $table = 'users_posts'; +} + +class PostTagPivot extends Pivot +{ + protected ?string $table = 'posts_tags'; + + public function getCreatedAtAttribute(mixed $value): string + { + return Carbon::parse($value)->format('U'); + } +} + +class TagWithGlobalScope extends Model +{ + protected ?string $table = 'tags'; + + public bool $timestamps = true; + + protected array $guarded = []; + + public static function boot(): void + { + parent::boot(); + + static::addGlobalScope(function ($query) { + $query->select('tags.id'); + }); + } +} diff --git a/tests/Integration/Database/Laravel/EloquentBelongsToTest.php b/tests/Integration/Database/Laravel/EloquentBelongsToTest.php new file mode 100644 index 000000000..63eda3cd1 --- /dev/null +++ b/tests/Integration/Database/Laravel/EloquentBelongsToTest.php @@ -0,0 +1,153 @@ +increments('id'); + $table->string('slug')->nullable(); + $table->unsignedInteger('parent_id')->nullable(); + $table->string('parent_slug')->nullable(); + }); + + $user = User::create(['slug' => Str::random()]); + User::create(['parent_id' => $user->id, 'parent_slug' => $user->slug]); + } + + public function testHasSelf() + { + $users = User::has('parent')->get(); + + $this->assertCount(1, $users); + } + + public function testHasSelfCustomOwnerKey() + { + $users = User::has('parentBySlug')->get(); + + $this->assertCount(1, $users); + } + + public function testAssociateWithModel() + { + $parent = User::doesntHave('parent')->first(); + $child = User::has('parent')->first(); + + $parent->parent()->associate($child); + + $this->assertEquals($child->id, $parent->parent_id); + $this->assertEquals($child->id, $parent->parent->id); + } + + public function testAssociateWithId() + { + $parent = User::doesntHave('parent')->first(); + $child = User::has('parent')->first(); + + $parent->parent()->associate($child->id); + + $this->assertEquals($child->id, $parent->parent_id); + $this->assertEquals($child->id, $parent->parent->id); + } + + public function testAssociateWithIdUnsetsLoadedRelation() + { + $child = User::has('parent')->with('parent')->first(); + + // Overwrite the (loaded) parent relation + $child->parent()->associate($child->id); + + $this->assertEquals($child->id, $child->parent_id); + $this->assertFalse($child->relationLoaded('parent')); + } + + public function testParentIsNotNull() + { + $child = User::has('parent')->first(); + $parent = null; + + $this->assertFalse($child->parent()->is($parent)); + $this->assertTrue($child->parent()->isNot($parent)); + } + + public function testParentIsModel() + { + $child = User::has('parent')->first(); + $parent = User::doesntHave('parent')->first(); + + $this->assertTrue($child->parent()->is($parent)); + $this->assertFalse($child->parent()->isNot($parent)); + } + + public function testParentIsNotAnotherModel() + { + $child = User::has('parent')->first(); + $parent = new User(); + $parent->id = 3; + + $this->assertFalse($child->parent()->is($parent)); + $this->assertTrue($child->parent()->isNot($parent)); + } + + public function testNullParentIsNotModel() + { + $child = User::has('parent')->first(); + $child->parent()->dissociate(); + $parent = User::doesntHave('parent')->first(); + + $this->assertFalse($child->parent()->is($parent)); + $this->assertTrue($child->parent()->isNot($parent)); + } + + public function testParentIsNotModelWithAnotherTable() + { + $child = User::has('parent')->first(); + $parent = User::doesntHave('parent')->first(); + $parent->setTable('foo'); + + $this->assertFalse($child->parent()->is($parent)); + $this->assertTrue($child->parent()->isNot($parent)); + } + + public function testParentIsNotModelWithAnotherConnection() + { + $child = User::has('parent')->first(); + $parent = User::doesntHave('parent')->first(); + $parent->setConnection('foo'); + + $this->assertFalse($child->parent()->is($parent)); + $this->assertTrue($child->parent()->isNot($parent)); + } +} + +class User extends Model +{ + public bool $timestamps = false; + + protected array $guarded = []; + + public function parent() + { + return $this->belongsTo(self::class, 'parent_id'); + } + + public function parentBySlug() + { + return $this->belongsTo(self::class, 'parent_slug', 'slug'); + } +} diff --git a/tests/Integration/Database/Laravel/EloquentCollectionFreshTest.php b/tests/Integration/Database/Laravel/EloquentCollectionFreshTest.php new file mode 100644 index 000000000..66e0c5e66 --- /dev/null +++ b/tests/Integration/Database/Laravel/EloquentCollectionFreshTest.php @@ -0,0 +1,44 @@ +increments('id'); + $table->string('email'); + $table->timestamps(); + }); + } + + public function testEloquentCollectionFresh() + { + User::insert([ + ['email' => 'laravel@framework.com'], + ['email' => 'laravel@laravel.com'], + ]); + + $collection = User::all(); + + $collection->first()->delete(); + + $freshCollection = $collection->fresh(); + + $this->assertCount(1, $freshCollection); + $this->assertInstanceOf(EloquentCollection::class, $freshCollection); + } +} diff --git a/tests/Integration/Database/Laravel/EloquentCollectionLoadCountTest.php b/tests/Integration/Database/Laravel/EloquentCollectionLoadCountTest.php new file mode 100644 index 000000000..ba15d6748 --- /dev/null +++ b/tests/Integration/Database/Laravel/EloquentCollectionLoadCountTest.php @@ -0,0 +1,144 @@ +increments('id'); + $table->unsignedInteger('some_default_value'); + $table->softDeletes(); + }); + + Schema::create('comments', function (Blueprint $table) { + $table->increments('id'); + $table->unsignedInteger('post_id'); + }); + + Schema::create('likes', function (Blueprint $table) { + $table->increments('id'); + $table->unsignedInteger('post_id'); + }); + + $post = Post::create(); + $post->comments()->saveMany([new Comment(), new Comment()]); + + $post->likes()->save(new Like()); + + Post::create(); + } + + public function testLoadCount() + { + $posts = Post::all(); + + DB::enableQueryLog(); + + $posts->loadCount('comments'); + + $this->assertCount(1, DB::getQueryLog()); + $this->assertSame('2', (string) $posts[0]->comments_count); + $this->assertSame('0', (string) $posts[1]->comments_count); + $this->assertSame('2', (string) $posts[0]->getOriginal('comments_count')); + } + + public function testLoadCountWithSameModels() + { + $posts = Post::all()->push(Post::first()); + + DB::enableQueryLog(); + + $posts->loadCount('comments'); + + $this->assertCount(1, DB::getQueryLog()); + $this->assertSame('2', (string) $posts[0]->comments_count); + $this->assertSame('0', (string) $posts[1]->comments_count); + $this->assertSame('2', (string) $posts[2]->comments_count); + } + + public function testLoadCountOnDeletedModels() + { + $posts = Post::all()->each->delete(); + + DB::enableQueryLog(); + + $posts->loadCount('comments'); + + $this->assertCount(1, DB::getQueryLog()); + $this->assertSame('2', (string) $posts[0]->comments_count); + $this->assertSame('0', (string) $posts[1]->comments_count); + } + + public function testLoadCountWithArrayOfRelations() + { + $posts = Post::all(); + + DB::enableQueryLog(); + + $posts->loadCount(['comments', 'likes']); + + $this->assertCount(1, DB::getQueryLog()); + $this->assertSame('2', (string) $posts[0]->comments_count); + $this->assertSame('1', (string) $posts[0]->likes_count); + $this->assertSame('0', (string) $posts[1]->comments_count); + $this->assertSame('0', (string) $posts[1]->likes_count); + } + + public function testLoadCountDoesNotOverrideAttributesWithDefaultValue() + { + $post = Post::first(); + $post->some_default_value = 200; + + Collection::make([$post])->loadCount('comments'); + + $this->assertSame(200, $post->some_default_value); + $this->assertSame('2', (string) $post->comments_count); + } +} + +class Post extends Model +{ + use SoftDeletes; + + protected array $attributes = [ + 'some_default_value' => 100, + ]; + + public bool $timestamps = false; + + public function comments() + { + return $this->hasMany(Comment::class); + } + + public function likes() + { + return $this->hasMany(Like::class); + } +} + +class Comment extends Model +{ + public bool $timestamps = false; +} + +class Like extends Model +{ + public bool $timestamps = false; +} diff --git a/tests/Integration/Database/Laravel/EloquentCollectionLoadMissingTest.php b/tests/Integration/Database/Laravel/EloquentCollectionLoadMissingTest.php new file mode 100644 index 000000000..4091098cd --- /dev/null +++ b/tests/Integration/Database/Laravel/EloquentCollectionLoadMissingTest.php @@ -0,0 +1,337 @@ +increments('id'); + }); + + Schema::create('posts', function (Blueprint $table) { + $table->increments('id'); + $table->unsignedInteger('user_id'); + }); + + Schema::create('comments', function (Blueprint $table) { + $table->increments('id'); + $table->unsignedInteger('parent_id')->nullable(); + $table->unsignedInteger('post_id'); + }); + + Schema::create('revisions', function (Blueprint $table) { + $table->increments('id'); + $table->unsignedInteger('comment_id'); + }); + + Schema::create('post_relations', function (Blueprint $table) { + $table->increments('id'); + $table->unsignedInteger('post_id'); + }); + + Schema::create('post_sub_relations', function (Blueprint $table) { + $table->increments('id'); + $table->unsignedInteger('post_relation_id'); + }); + + Schema::create('post_sub_sub_relations', function (Blueprint $table) { + $table->increments('id'); + $table->unsignedInteger('post_sub_relation_id'); + }); + + User::create(); + + Post::insert([ + ['user_id' => 1], + ['user_id' => 1], + ]); + + Comment::insert([ + ['parent_id' => null, 'post_id' => 1], + ['parent_id' => 1, 'post_id' => 1], + ['parent_id' => 2, 'post_id' => 1], + ]); + + Revision::create(['comment_id' => 1]); + + PostRelation::create(['post_id' => 2]); + PostSubRelation::create(['post_relation_id' => 1]); + PostSubSubRelation::create(['post_sub_relation_id' => 1]); + } + + public function testLoadMissing() + { + $posts = Post::with('comments', 'user')->get(); + + DB::enableQueryLog(); + + $posts->loadMissing('comments.parent.revisions:revisions.comment_id', 'user:id'); + + $this->assertCount(2, DB::getQueryLog()); + $this->assertTrue($posts[0]->comments[0]->relationLoaded('parent')); + $this->assertTrue($posts[0]->comments[1]->parent->relationLoaded('revisions')); + $this->assertArrayNotHasKey('id', $posts[0]->comments[1]->parent->revisions[0]->getAttributes()); + } + + public function testLoadMissingWithClosure() + { + $posts = Post::with('comments')->get(); + + DB::enableQueryLog(); + + $posts->loadMissing(['comments.parent' => function ($query) { + $query->select('id'); + }]); + + $this->assertCount(1, DB::getQueryLog()); + $this->assertTrue($posts[0]->comments[0]->relationLoaded('parent')); + $this->assertArrayNotHasKey('post_id', $posts[0]->comments[1]->parent->getAttributes()); + } + + public function testLoadMissingWithDuplicateRelationName() + { + $posts = Post::with('comments')->get(); + + DB::enableQueryLog(); + + $posts->loadMissing('comments.parent.parent'); + + $this->assertCount(2, DB::getQueryLog()); + $this->assertTrue($posts[0]->comments[0]->relationLoaded('parent')); + $this->assertTrue($posts[0]->comments[1]->parent->relationLoaded('parent')); + } + + public function testLoadMissingWithoutInitialLoad() + { + $user = User::first(); + $user->loadMissing('posts.postRelation.postSubRelations.postSubSubRelations'); + + $this->assertEquals(2, $user->posts->count()); + $this->assertNull($user->posts[0]->postRelation); + $this->assertInstanceOf(PostRelation::class, $user->posts[1]->postRelation); + $this->assertEquals(1, $user->posts[1]->postRelation->postSubRelations->count()); + $this->assertInstanceOf(PostSubRelation::class, $user->posts[1]->postRelation->postSubRelations[0]); + $this->assertEquals(1, $user->posts[1]->postRelation->postSubRelations[0]->postSubSubRelations->count()); + $this->assertInstanceOf(PostSubSubRelation::class, $user->posts[1]->postRelation->postSubRelations[0]->postSubSubRelations[0]); + } + + public function testLoadMissingWithNestedArraySyntax() + { + $posts = Post::with('user')->get(); + + DB::enableQueryLog(); + + $posts->loadMissing([ + 'comments' => ['parent'], + 'user', + ]); + + $this->assertCount(2, DB::getQueryLog()); + $this->assertTrue($posts[0]->comments[0]->relationLoaded('parent')); + $this->assertTrue($posts[0]->relationLoaded('user')); + } + + public function testLoadMissingWithMultipleDotNotationRelations() + { + $posts = Post::with('comments')->get(); + + DB::enableQueryLog(); + + $posts->loadMissing([ + 'comments.parent', + 'user.posts', + ]); + + $this->assertCount(3, DB::getQueryLog()); + $this->assertTrue($posts[0]->comments[0]->relationLoaded('parent')); + $this->assertTrue($posts[0]->relationLoaded('user')); + $this->assertTrue($posts[0]->user->relationLoaded('posts')); + } + + public function testLoadMissingWithNestedArrayWithColon() + { + $posts = Post::with('comments')->get(); + + DB::enableQueryLog(); + + $posts->loadMissing(['comments' => ['parent:id']]); + + $this->assertCount(1, DB::getQueryLog()); + $this->assertTrue($posts[0]->comments[0]->relationLoaded('parent')); + $this->assertArrayNotHasKey('post_id', $posts[0]->comments[1]->parent->getAttributes()); + } + + public function testLoadMissingWithNestedArray() + { + $posts = Post::with('comments')->get(); + + DB::enableQueryLog(); + + $posts->loadMissing(['comments' => ['parent']]); + + $this->assertCount(1, DB::getQueryLog()); + $this->assertTrue($posts[0]->comments[0]->relationLoaded('parent')); + } + + public function testLoadMissingWithNestedArrayWithClosure() + { + $posts = Post::with('comments')->get(); + + DB::enableQueryLog(); + + $posts->loadMissing(['comments' => ['parent' => function ($query) { + $query->select('id'); + }]]); + + $this->assertCount(1, DB::getQueryLog()); + $this->assertTrue($posts[0]->comments[0]->relationLoaded('parent')); + $this->assertArrayNotHasKey('post_id', $posts[0]->comments[1]->parent->getAttributes()); + } + + public function testLoadMissingWithMultipleNestedArrays() + { + $users = User::get(); + $users->loadMissing([ + 'posts' => [ + 'postRelation' => [ + 'postSubRelations' => [ + 'postSubSubRelations', + ], + ], + ], + ]); + + $user = $users->first(); + $this->assertEquals(2, $user->posts->count()); + $this->assertNull($user->posts[0]->postRelation); + $this->assertInstanceOf(PostRelation::class, $user->posts[1]->postRelation); + $this->assertEquals(1, $user->posts[1]->postRelation->postSubRelations->count()); + $this->assertInstanceOf(PostSubRelation::class, $user->posts[1]->postRelation->postSubRelations[0]); + $this->assertEquals(1, $user->posts[1]->postRelation->postSubRelations[0]->postSubSubRelations->count()); + $this->assertInstanceOf(PostSubSubRelation::class, $user->posts[1]->postRelation->postSubRelations[0]->postSubSubRelations[0]); + } + + public function testLoadMissingWithMultipleNestedArraysCombinedWithDotNotation() + { + $users = User::get(); + $users->loadMissing([ + 'posts' => [ + 'postRelation' => [ + 'postSubRelations.postSubSubRelations', + ], + ], + ]); + + $user = $users->first(); + $this->assertEquals(2, $user->posts->count()); + $this->assertNull($user->posts[0]->postRelation); + $this->assertInstanceOf(PostRelation::class, $user->posts[1]->postRelation); + $this->assertEquals(1, $user->posts[1]->postRelation->postSubRelations->count()); + $this->assertInstanceOf(PostSubRelation::class, $user->posts[1]->postRelation->postSubRelations[0]); + $this->assertEquals(1, $user->posts[1]->postRelation->postSubRelations[0]->postSubSubRelations->count()); + $this->assertInstanceOf(PostSubSubRelation::class, $user->posts[1]->postRelation->postSubRelations[0]->postSubSubRelations[0]); + } +} + +class Comment extends Model +{ + public bool $timestamps = false; + + protected array $guarded = []; + + public function parent() + { + return $this->belongsTo(self::class); + } + + public function revisions() + { + return $this->hasMany(Revision::class); + } +} + +class Post extends Model +{ + public bool $timestamps = false; + + protected array $guarded = []; + + public function comments() + { + return $this->hasMany(Comment::class); + } + + public function user() + { + return $this->belongsTo(User::class); + } + + public function postRelation() + { + return $this->hasOne(PostRelation::class); + } +} + +class PostRelation extends Model +{ + public bool $timestamps = false; + + protected array $guarded = []; + + public function postSubRelations() + { + return $this->hasMany(PostSubRelation::class); + } +} + +class PostSubRelation extends Model +{ + public bool $timestamps = false; + + protected array $guarded = []; + + public function postSubSubRelations() + { + return $this->hasMany(PostSubSubRelation::class); + } +} + +class PostSubSubRelation extends Model +{ + public bool $timestamps = false; + + protected array $guarded = []; +} + +class Revision extends Model +{ + public bool $timestamps = false; + + protected array $guarded = []; +} + +class User extends Model +{ + public bool $timestamps = false; + + protected array $guarded = []; + + public function posts() + { + return $this->hasMany(Post::class); + } +} diff --git a/tests/Integration/Database/Laravel/EloquentCursorPaginateTest.php b/tests/Integration/Database/Laravel/EloquentCursorPaginateTest.php new file mode 100644 index 000000000..7ac0c691f --- /dev/null +++ b/tests/Integration/Database/Laravel/EloquentCursorPaginateTest.php @@ -0,0 +1,317 @@ +increments('id'); + $table->string('title')->nullable(); + $table->unsignedInteger('user_id')->nullable(); + $table->timestamps(); + }); + + Schema::create('test_users', function ($table) { + $table->increments('id'); + $table->string('name')->nullable(); + $table->timestamps(); + }); + } + + public function testCursorPaginationOnTopOfColumns() + { + for ($i = 1; $i <= 16; ++$i) { + $posts[] = [ + 'title' => 'Title ' . $i, + ]; + } + TestPost::fillAndInsert($posts); + + $this->assertCount(15, TestPost::cursorPaginate(15, ['id', 'title'])); + } + + public function testPaginationWithUnion() + { + TestPost::fillAndInsert([ + ['title' => 'Hello world', 'user_id' => 1], + ['title' => 'Goodbye world', 'user_id' => 2], + ['title' => 'Howdy', 'user_id' => 3], + ['title' => '4th', 'user_id' => 4], + ]); + + $table1 = TestPost::query()->whereIn('user_id', [1, 2]); + $table2 = TestPost::query()->whereIn('user_id', [3, 4]); + + $result = $table1->unionAll($table2) + ->orderBy('user_id', 'desc') + ->cursorPaginate(1); + + $this->assertSame(['user_id'], $result->getOptions()['parameters']); + } + + public function testPaginationWithDistinct() + { + for ($i = 1; $i <= 3; ++$i) { + $posts[] = ['title' => 'Hello world']; + $posts[] = ['title' => 'Goodbye world']; + } + TestPost::fillAndInsert($posts); + + $query = TestPost::query()->distinct(); + + $this->assertEquals(6, $query->get()->count()); + $this->assertEquals(6, $query->count()); + $this->assertCount(6, $query->cursorPaginate()->items()); + } + + public function testPaginationWithWhereClause() + { + for ($i = 1; $i <= 3; ++$i) { + $posts[] = ['title' => 'Hello world', 'user_id' => null]; + $posts[] = ['title' => 'Goodbye world', 'user_id' => 2]; + } + TestPost::fillAndInsert($posts); + + $query = TestPost::query()->whereNull('user_id'); + + $this->assertEquals(3, $query->get()->count()); + $this->assertEquals(3, $query->count()); + $this->assertCount(3, $query->cursorPaginate()->items()); + } + + public function testPaginationWithHasClause() + { + TestUser::fillAndInsert([[], [], []]); + + for ($i = 1; $i <= 3; ++$i) { + $posts[] = ['title' => 'Hello world', 'user_id' => null]; + $posts[] = ['title' => 'Goodbye world', 'user_id' => 2]; + $posts[] = ['title' => 'Howdy', 'user_id' => 3]; + } + TestPost::fillAndInsert($posts); + + $query = TestUser::query()->has('posts'); + + $this->assertEquals(2, $query->get()->count()); + $this->assertEquals(2, $query->count()); + $this->assertCount(2, $query->cursorPaginate()->items()); + } + + public function testPaginationWithWhereHasClause() + { + TestUser::fillAndInsert([[], [], []]); + for ($i = 1; $i <= 3; ++$i) { + $posts[] = ['title' => 'Hello world', 'user_id' => null]; + $posts[] = ['title' => 'Goodbye world', 'user_id' => 2]; + $posts[] = ['title' => 'Howdy', 'user_id' => 3]; + } + TestPost::fillAndInsert($posts); + + $query = TestUser::query()->whereHas('posts', function ($query) { + $query->where('title', 'Howdy'); + }); + + $this->assertEquals(1, $query->get()->count()); + $this->assertEquals(1, $query->count()); + $this->assertCount(1, $query->cursorPaginate()->items()); + } + + public function testPaginationWithWhereExistsClause() + { + TestUser::fillAndInsert([[], [], []]); + for ($i = 1; $i <= 3; ++$i) { + $posts[] = ['title' => 'Hello world', 'user_id' => null]; + $posts[] = ['title' => 'Goodbye world', 'user_id' => 2]; + $posts[] = ['title' => 'Howdy', 'user_id' => 3]; + } + TestPost::fillAndInsert($posts); + + $query = TestUser::query()->whereExists(function ($query) { + $query->select(DB::raw(1)) + ->from('test_posts') + ->whereColumn('test_posts.user_id', 'test_users.id'); + }); + + $this->assertEquals(2, $query->get()->count()); + $this->assertEquals(2, $query->count()); + $this->assertCount(2, $query->cursorPaginate()->items()); + } + + public function testPaginationWithMultipleWhereClauses() + { + TestUser::fillAndInsert([[], [], [], []]); + for ($i = 1; $i <= 4; ++$i) { + $posts[] = ['title' => 'Hello world', 'user_id' => null]; + $posts[] = ['title' => 'Goodbye world', 'user_id' => 2]; + $posts[] = ['title' => 'Howdy', 'user_id' => 3]; + $posts[] = ['title' => 'Howdy', 'user_id' => 4]; + } + TestPost::fillAndInsert($posts); + + $query = TestUser::query()->whereExists(function ($query) { + $query->select(DB::raw(1)) + ->from('test_posts') + ->whereColumn('test_posts.user_id', 'test_users.id'); + })->whereHas('posts', function ($query) { + $query->where('title', 'Howdy'); + })->where('id', '<', 5)->orderBy('id'); + + $clonedQuery = $query->clone(); + $anotherQuery = $query->clone(); + + $this->assertEquals(2, $query->get()->count()); + $this->assertEquals(2, $query->count()); + $this->assertCount(2, $query->cursorPaginate()->items()); + $this->assertCount(1, $clonedQuery->cursorPaginate(1)->items()); + $this->assertCount( + 1, + $anotherQuery->cursorPaginate(5, ['*'], 'cursor', new Cursor(['id' => 3])) + ->items() + ); + } + + public function testPaginationWithMultipleUnionAndMultipleWhereClauses() + { + TestPost::fillAndInsert([ + ['title' => 'Post A', 'user_id' => 100], + ['title' => 'Post B', 'user_id' => 101], + ]); + + $table1 = TestPost::select(['id', 'title', 'user_id'])->where('user_id', 100); + $table2 = TestPost::select(['id', 'title', 'user_id'])->where('user_id', 101); + $table3 = TestPost::select(['id', 'title', 'user_id'])->where('user_id', 101); + + $columns = ['id']; + $cursorName = 'cursor-name'; + $cursor = new Cursor(['id' => 1]); + + $result = $table1->toBase() + ->union($table2->toBase()) + ->union($table3->toBase()) + ->orderBy('id', 'asc') + ->cursorPaginate(1, $columns, $cursorName, $cursor); + + $this->assertSame(['id'], $result->getOptions()['parameters']); + + $postB = $table2->where('id', '>', 1)->first(); + $this->assertEquals('Post B', $postB->title, 'Expect `Post B` is the result of the second query'); + + $this->assertCount(1, $result->items(), 'Expect cursor paginated query should have 1 result'); + $this->assertEquals('Post B', current($result->items())->title, 'Expect the paginated query would return `Post B`'); + } + + public function testPaginationWithMultipleAliases() + { + TestUser::fillAndInsert([ + ['name' => 'A (user)'], + ['name' => 'C (user)'], + ]); + + TestPost::fillAndInsert([['title' => 'B (post)'], ['title' => 'D (post)']]); + + $table1 = TestPost::select(['title as alias']); + $table2 = TestUser::select(['name as alias']); + + $columns = ['alias']; + $cursorName = 'cursor-name'; + $cursor = new Cursor(['alias' => 'A (user)']); + + $result = $table1->toBase() + ->union($table2->toBase()) + ->orderBy('alias', 'asc') + ->cursorPaginate(1, $columns, $cursorName, $cursor); + + $this->assertSame(['alias'], $result->getOptions()['parameters']); + + $this->assertCount(1, $result->items(), 'Expect cursor paginated query should have 1 result'); + $this->assertEquals('B (post)', current($result->items())->alias, 'Expect the paginated query would return `B (post)`'); + } + + public function testPaginationWithAliasedOrderBy() + { + TestUser::fillAndInsert([[], [], [], [], [], []]); + + $query = TestUser::query()->select('id as user_id')->orderBy('user_id'); + $clonedQuery = $query->clone(); + $anotherQuery = $query->clone(); + + $this->assertEquals(6, $query->get()->count()); + $this->assertEquals(6, $query->count()); + $this->assertCount(6, $query->cursorPaginate()->items()); + $this->assertCount(3, $clonedQuery->cursorPaginate(3)->items()); + $this->assertCount( + 4, + $anotherQuery->cursorPaginate(10, ['*'], 'cursor', new Cursor(['user_id' => 2])) + ->items() + ); + } + + public function testPaginationWithDistinctColumnsAndSelect() + { + for ($i = 1; $i <= 3; ++$i) { + $posts[] = ['title' => 'Hello world']; + $posts[] = ['title' => 'Goodbye world']; + } + TestPost::fillAndInsert($posts); + + $query = TestPost::query()->orderBy('title')->distinct('title')->select('title'); + + $this->assertEquals(2, $query->get()->count()); + $this->assertEquals(2, $query->count()); + $this->assertCount(2, $query->cursorPaginate()->items()); + } + + public function testPaginationWithDistinctColumnsAndSelectAndJoin() + { + TestUser::fillAndInsert([[], [], [], [], []]); + $users = TestUser::query()->get(); + for ($i = 1; $i <= 5; ++$i) { + $user = $users[$i - 1]; + + for ($j = 1; $j <= 10; ++$j) { + $posts[] = [ + 'title' => 'Title ' . $i, + 'user_id' => $user->id, + ]; + } + } + TestPost::fillAndInsert($posts); + + $query = TestUser::query()->join('test_posts', 'test_posts.user_id', '=', 'test_users.id') + ->distinct('test_users.id')->select('test_users.*'); + + $this->assertEquals(5, $query->get()->count()); + $this->assertEquals(5, $query->count()); + $this->assertCount(5, $query->cursorPaginate()->items()); + } +} + +class TestPost extends Model +{ + protected array $guarded = []; +} + +class TestUser extends Model +{ + protected array $guarded = []; + + public function posts() + { + return $this->hasMany(TestPost::class, 'user_id'); + } +} diff --git a/tests/Integration/Database/Laravel/EloquentCustomPivotCastTest.php b/tests/Integration/Database/Laravel/EloquentCustomPivotCastTest.php new file mode 100644 index 000000000..ae96a8e73 --- /dev/null +++ b/tests/Integration/Database/Laravel/EloquentCustomPivotCastTest.php @@ -0,0 +1,195 @@ +increments('id'); + $table->string('email'); + }); + + Schema::create('projects', function (Blueprint $table) { + $table->increments('id'); + $table->string('name'); + }); + + Schema::create('project_users', function (Blueprint $table) { + $table->integer('user_id'); + $table->integer('project_id'); + $table->text('permissions'); + }); + } + + public function testCastsAreRespectedOnAttach() + { + $user = CustomPivotCastTestUser::forceCreate([ + 'email' => 'taylor@laravel.com', + ]); + + $project = CustomPivotCastTestProject::forceCreate([ + 'name' => 'Test Project', + ]); + + $project->collaborators()->attach($user, ['permissions' => ['foo' => 'bar']]); + $project = $project->fresh(); + + $this->assertEquals(['foo' => 'bar'], $project->collaborators[0]->pivot->permissions); + } + + public function testCastsAreRespectedOnAttachArray() + { + $user = CustomPivotCastTestUser::forceCreate([ + 'email' => 'taylor@laravel.com', + ]); + + $user2 = CustomPivotCastTestUser::forceCreate([ + 'email' => 'mohamed@laravel.com', + ]); + + $project = CustomPivotCastTestProject::forceCreate([ + 'name' => 'Test Project', + ]); + + $project->collaborators()->attach([ + $user->id => ['permissions' => ['foo' => 'bar']], + $user2->id => ['permissions' => ['baz' => 'bar']], + ]); + $project = $project->fresh(); + + $this->assertEquals(['foo' => 'bar'], $project->collaborators[0]->pivot->permissions); + $this->assertEquals(['baz' => 'bar'], $project->collaborators[1]->pivot->permissions); + } + + public function testCastsAreRespectedOnSync() + { + $user = CustomPivotCastTestUser::forceCreate([ + 'email' => 'taylor@laravel.com', + ]); + + $project = CustomPivotCastTestProject::forceCreate([ + 'name' => 'Test Project', + ]); + + $project->collaborators()->sync([$user->id => ['permissions' => ['foo' => 'bar']]]); + $project = $project->fresh(); + + $this->assertEquals(['foo' => 'bar'], $project->collaborators[0]->pivot->permissions); + } + + public function testCastsAreRespectedOnSyncArray() + { + $user = CustomPivotCastTestUser::forceCreate([ + 'email' => 'taylor@laravel.com', + ]); + + $user2 = CustomPivotCastTestUser::forceCreate([ + 'email' => 'mohamed@laravel.com', + ]); + + $project = CustomPivotCastTestProject::forceCreate([ + 'name' => 'Test Project', + ]); + + $project->collaborators()->sync([ + $user->id => ['permissions' => ['foo' => 'bar']], + $user2->id => ['permissions' => ['baz' => 'bar']], + ]); + $project = $project->fresh(); + + $this->assertEquals(['foo' => 'bar'], $project->collaborators[0]->pivot->permissions); + $this->assertEquals(['baz' => 'bar'], $project->collaborators[1]->pivot->permissions); + } + + public function testCastsAreRespectedOnSyncArrayWhileUpdatingExisting() + { + $user = CustomPivotCastTestUser::forceCreate([ + 'email' => 'taylor@laravel.com', + ]); + + $user2 = CustomPivotCastTestUser::forceCreate([ + 'email' => 'mohamed@laravel.com', + ]); + + $project = CustomPivotCastTestProject::forceCreate([ + 'name' => 'Test Project', + ]); + + $project->collaborators()->attach([ + $user->id => ['permissions' => ['foo' => 'bar']], + $user2->id => ['permissions' => ['baz' => 'bar']], + ]); + + $project->collaborators()->sync([ + $user->id => ['permissions' => ['foo1' => 'bar1']], + $user2->id => ['permissions' => ['baz2' => 'bar2']], + ]); + + $project = $project->fresh(); + + $this->assertEquals(['foo1' => 'bar1'], $project->collaborators[0]->pivot->permissions); + $this->assertEquals(['baz2' => 'bar2'], $project->collaborators[1]->pivot->permissions); + } + + public function testDefaultAttributesAreRespectedAndCastsAreRespected() + { + $project = CustomPivotCastTestProject::forceCreate([ + 'name' => 'Test Project', + ]); + + $pivot = $project->collaborators()->newPivot(); + + $this->assertEquals(['permissions' => ['create', 'update']], $pivot->toArray()); + } +} + +class CustomPivotCastTestUser extends Model +{ + protected ?string $table = 'users'; + + public bool $timestamps = false; +} + +class CustomPivotCastTestProject extends Model +{ + protected ?string $table = 'projects'; + + public bool $timestamps = false; + + public function collaborators() + { + return $this->belongsToMany( + CustomPivotCastTestUser::class, + 'project_users', + 'project_id', + 'user_id' + )->using(CustomPivotCastTestCollaborator::class)->withPivot('permissions'); + } +} + +class CustomPivotCastTestCollaborator extends Pivot +{ + public bool $timestamps = false; + + protected array $attributes = [ + 'permissions' => '["create", "update"]', + ]; + + protected array $casts = [ + 'permissions' => 'json', + ]; +} diff --git a/tests/Integration/Database/Laravel/EloquentDeleteTest.php b/tests/Integration/Database/Laravel/EloquentDeleteTest.php new file mode 100644 index 000000000..bda1adc77 --- /dev/null +++ b/tests/Integration/Database/Laravel/EloquentDeleteTest.php @@ -0,0 +1,252 @@ +driver !== 'mariadb') { + return false; + } + + $version = DB::scalar('SELECT VERSION()'); + + return version_compare($version, '11.0', '>='); + } + + protected function afterRefreshingDatabase(): void + { + Schema::create('posts', function (Blueprint $table) { + $table->increments('id'); + $table->string('title')->nullable(); + $table->timestamps(); + }); + + Schema::create('comments', function (Blueprint $table) { + $table->increments('id'); + $table->string('body')->nullable(); + $table->integer('post_id'); + $table->timestamps(); + }); + + Schema::create('roles', function (Blueprint $table) { + $table->increments('id'); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function testDeleteUseLimitWithoutJoins(): void + { + $totalPosts = 10; + $deleteLimit = 1; + + for ($i = 0; $i < $totalPosts; ++$i) { + Post::query()->create(); + } + + // Test simple delete with limit (no join) + Post::query()->latest('id')->limit($deleteLimit)->delete(); + + $this->assertEquals($totalPosts - $deleteLimit, Post::query()->count()); + } + + public function testDeleteUseLimitWithJoins(): void + { + // MySQL does not support DELETE with JOIN + ORDER BY + LIMIT + // MariaDB 10.x does not support it, but MariaDB 11+ does + if ($this->driver === 'mysql') { + $this->markTestSkipped('MySQL does not support LIMIT on DELETE statements with JOIN clauses.'); + } + + if ($this->driver === 'mariadb' && ! $this->mariaDbSupportsDeleteJoinLimit()) { + $this->markTestSkipped('MariaDB < 11.0 does not support LIMIT on DELETE statements with JOIN clauses.'); + } + + $totalPosts = 10; + $deleteLimit = 1; + $whereThreshold = 8; + + for ($i = 0; $i < $totalPosts; ++$i) { + Comment::query()->create([ + 'post_id' => Post::query()->create()->id, + ]); + } + + // Test delete with join and limit + Post::query() + ->join('comments', 'comments.post_id', '=', 'posts.id') + ->where('posts.id', '>', $whereThreshold) + ->orderBy('posts.id') + ->limit($deleteLimit) + ->delete(); + + $this->assertEquals($totalPosts - $deleteLimit, Post::query()->count()); + } + + public function testDeleteWithLimitAndJoinThrowsExceptionOnMySql(): void + { + if (! in_array($this->driver, ['mysql', 'mariadb'])) { + $this->markTestSkipped('This test only applies to MySQL/MariaDB.'); + } + + // MariaDB 11+ supports DELETE with JOIN + ORDER BY + LIMIT, so no exception is thrown + if ($this->mariaDbSupportsDeleteJoinLimit()) { + $this->markTestSkipped('MariaDB 11+ supports LIMIT on DELETE statements with JOIN clauses.'); + } + + $this->expectException(QueryException::class); + + for ($i = 0; $i < 10; ++$i) { + Comment::query()->create([ + 'post_id' => Post::query()->create()->id, + ]); + } + + Post::query() + ->join('comments', 'comments.post_id', '=', 'posts.id') + ->where('posts.id', '>', 5) + ->orderBy('posts.id') + ->limit(1) + ->delete(); + } + + public function testForceDeletedEventIsFired() + { + $role = Role::create([]); + $this->assertInstanceOf(Role::class, $role); + Role::observe(new RoleObserver()); + + $role->delete(); + $this->assertNull(RoleObserver::$model); + + $role->forceDelete(); + + $this->assertEquals($role->id, RoleObserver::$model->id); + } + + public function testForceDeletingEventIsFired() + { + $role = Role::create([]); + $this->assertInstanceOf(Role::class, $role); + Role::observe(new RoleObserver()); + + $role->forceDelete(); + + $this->assertEquals($role->id, RoleObserver::$model->id); + } + + public function testDeleteQuietly() + { + $_SERVER['(-_-)'] = '\(^_^)/'; + Post::deleting(fn () => $_SERVER['(-_-)'] = null); + Post::deleted(fn () => $_SERVER['(-_-)'] = null); + $post = Post::query()->create([]); + $result = $post->deleteQuietly(); + + $this->assertEquals('\(^_^)/', $_SERVER['(-_-)']); + $this->assertTrue($result); + $this->assertFalse($post->exists); + + // For a soft-deleted model: + Role::deleting(fn () => $_SERVER['(-_-)'] = null); + Role::deleted(fn () => $_SERVER['(-_-)'] = null); + Role::softDeleted(fn () => $_SERVER['(-_-)'] = null); + $role = Role::create([]); + $result = $role->deleteQuietly(); + $this->assertTrue($result); + $this->assertEquals('\(^_^)/', $_SERVER['(-_-)']); + + unset($_SERVER['(-_-)']); + } + + public function testDestroy() + { + Schema::create('my_posts', function (Blueprint $table) { + $table->increments('my_id'); + $table->timestamps(); + }); + + PostStringyKey::unguard(); + PostStringyKey::query()->create([]); + PostStringyKey::query()->create([]); + + PostStringyKey::query()->getConnection()->enableQueryLog(); + PostStringyKey::retrieved(fn ($model) => $_SERVER['destroy']['retrieved'][] = $model->my_id); + PostStringyKey::deleting(fn ($model) => $_SERVER['destroy']['deleting'][] = $model->my_id); + PostStringyKey::deleted(fn ($model) => $_SERVER['destroy']['deleted'][] = $model->my_id); + + $_SERVER['destroy'] = []; + PostStringyKey::destroy(1, 2, 3, 4); + + $this->assertEquals([1, 2], $_SERVER['destroy']['retrieved']); + $this->assertEquals([1, 2], $_SERVER['destroy']['deleting']); + $this->assertEquals([1, 2], $_SERVER['destroy']['deleted']); + + $logs = PostStringyKey::query()->getConnection()->getQueryLog(); + + $this->assertEquals(0, PostStringyKey::query()->count()); + + $this->assertStringStartsWith('select * from "my_posts" where "my_id" in (', str_replace(['`', '[', ']'], '"', $logs[0]['query'])); + + $this->assertStringStartsWith('delete from "my_posts" where "my_id" = ', str_replace(['`', '[', ']'], '"', $logs[1]['query'])); + $this->assertEquals([1], $logs[1]['bindings']); + + $this->assertStringStartsWith('delete from "my_posts" where "my_id" = ', str_replace(['`', '[', ']'], '"', $logs[2]['query'])); + $this->assertEquals([2], $logs[2]['bindings']); + + // Total of 3 queries. + $this->assertCount(3, $logs); + + PostStringyKey::reguard(); + unset($_SERVER['destroy']); + Schema::drop('my_posts'); + } +} + +class Comment extends Model +{ + protected ?string $table = 'comments'; + + protected array $fillable = ['post_id']; +} + +class Role extends Model +{ + use SoftDeletes; + + protected ?string $table = 'roles'; + + protected array $guarded = []; +} + +class RoleObserver +{ + public static ?Model $model = null; + + public function forceDeleted(Model $model): void + { + static::$model = $model; + } +} diff --git a/tests/Integration/Database/Laravel/EloquentEagerLoadingLimitTest.php b/tests/Integration/Database/Laravel/EloquentEagerLoadingLimitTest.php new file mode 100644 index 000000000..a362eec74 --- /dev/null +++ b/tests/Integration/Database/Laravel/EloquentEagerLoadingLimitTest.php @@ -0,0 +1,189 @@ +id(); + }); + + Schema::create('posts', function (Blueprint $table) { + $table->id(); + $table->unsignedBigInteger('user_id'); + $table->timestamps(); + }); + + Schema::create('comments', function (Blueprint $table) { + $table->id(); + $table->unsignedBigInteger('post_id'); + $table->timestamps(); + }); + + Schema::create('roles', function (Blueprint $table) { + $table->id(); + $table->timestamps(); + }); + + Schema::create('role_user', function (Blueprint $table) { + $table->unsignedBigInteger('role_id'); + $table->unsignedBigInteger('user_id'); + }); + + User::create(); + User::create(); + + Post::create(['user_id' => 1, 'created_at' => new Carbon('2024-01-01 00:00:01')]); + Post::create(['user_id' => 1, 'created_at' => new Carbon('2024-01-01 00:00:02')]); + Post::create(['user_id' => 1, 'created_at' => new Carbon('2024-01-01 00:00:03')]); + Post::create(['user_id' => 2, 'created_at' => new Carbon('2024-01-01 00:00:04')]); + Post::create(['user_id' => 2, 'created_at' => new Carbon('2024-01-01 00:00:05')]); + Post::create(['user_id' => 2, 'created_at' => new Carbon('2024-01-01 00:00:06')]); + + Comment::create(['post_id' => 1, 'created_at' => new Carbon('2024-01-01 00:00:01')]); + Comment::create(['post_id' => 2, 'created_at' => new Carbon('2024-01-01 00:00:02')]); + Comment::create(['post_id' => 3, 'created_at' => new Carbon('2024-01-01 00:00:03')]); + Comment::create(['post_id' => 4, 'created_at' => new Carbon('2024-01-01 00:00:04')]); + Comment::create(['post_id' => 5, 'created_at' => new Carbon('2024-01-01 00:00:05')]); + Comment::create(['post_id' => 6, 'created_at' => new Carbon('2024-01-01 00:00:06')]); + + Role::create(['created_at' => new Carbon('2024-01-01 00:00:01')]); + Role::create(['created_at' => new Carbon('2024-01-01 00:00:02')]); + Role::create(['created_at' => new Carbon('2024-01-01 00:00:03')]); + Role::create(['created_at' => new Carbon('2024-01-01 00:00:04')]); + Role::create(['created_at' => new Carbon('2024-01-01 00:00:05')]); + Role::create(['created_at' => new Carbon('2024-01-01 00:00:06')]); + + DB::table('role_user')->insert([ + ['role_id' => 1, 'user_id' => 1], + ['role_id' => 2, 'user_id' => 1], + ['role_id' => 3, 'user_id' => 1], + ['role_id' => 4, 'user_id' => 2], + ['role_id' => 5, 'user_id' => 2], + ['role_id' => 6, 'user_id' => 2], + ]); + } + + public function testBelongsToMany(): void + { + $users = User::with(['roles' => fn ($query) => $query->latest()->limit(2)]) + ->orderBy('id') + ->get(); + + $this->assertEquals([3, 2], $users[0]->roles->pluck('id')->all()); + $this->assertEquals([6, 5], $users[1]->roles->pluck('id')->all()); + $this->assertArrayNotHasKey('laravel_row', $users[0]->roles[0]); + $this->assertArrayNotHasKey('@laravel_group := `user_id`', $users[0]->roles[0]); + } + + public function testBelongsToManyWithOffset(): void + { + $users = User::with(['roles' => fn ($query) => $query->latest()->limit(2)->offset(1)]) + ->orderBy('id') + ->get(); + + $this->assertEquals([2, 1], $users[0]->roles->pluck('id')->all()); + $this->assertEquals([5, 4], $users[1]->roles->pluck('id')->all()); + } + + public function testHasMany(): void + { + $users = User::with(['posts' => fn ($query) => $query->latest()->limit(2)]) + ->orderBy('id') + ->get(); + + $this->assertEquals([3, 2], $users[0]->posts->pluck('id')->all()); + $this->assertEquals([6, 5], $users[1]->posts->pluck('id')->all()); + $this->assertArrayNotHasKey('laravel_row', $users[0]->posts[0]); + $this->assertArrayNotHasKey('@laravel_group := `user_id`', $users[0]->posts[0]); + } + + public function testHasManyWithOffset(): void + { + $users = User::with(['posts' => fn ($query) => $query->latest()->limit(2)->offset(1)]) + ->orderBy('id') + ->get(); + + $this->assertEquals([2, 1], $users[0]->posts->pluck('id')->all()); + $this->assertEquals([5, 4], $users[1]->posts->pluck('id')->all()); + } + + public function testHasManyThrough(): void + { + $users = User::with(['comments' => fn ($query) => $query->latest('comments.created_at')->limit(2)]) + ->orderBy('id') + ->get(); + + $this->assertEquals([3, 2], $users[0]->comments->pluck('id')->all()); + $this->assertEquals([6, 5], $users[1]->comments->pluck('id')->all()); + $this->assertArrayNotHasKey('laravel_row', $users[0]->comments[0]); + $this->assertArrayNotHasKey('@laravel_group := `user_id`', $users[0]->comments[0]); + } + + public function testHasManyThroughWithOffset(): void + { + $users = User::with(['comments' => fn ($query) => $query->latest('comments.created_at')->limit(2)->offset(1)]) + ->orderBy('id') + ->get(); + + $this->assertEquals([2, 1], $users[0]->comments->pluck('id')->all()); + $this->assertEquals([5, 4], $users[1]->comments->pluck('id')->all()); + } +} + +class Comment extends Model +{ + public bool $timestamps = false; + + protected array $guarded = []; +} + +class Post extends Model +{ + protected array $guarded = []; +} + +class Role extends Model +{ + protected array $guarded = []; +} + +class User extends Model +{ + public bool $timestamps = false; + + protected array $guarded = []; + + public function comments(): HasManyThrough + { + return $this->hasManyThrough(Comment::class, Post::class); + } + + public function posts(): HasMany + { + return $this->hasMany(Post::class); + } + + public function roles(): BelongsToMany + { + return $this->belongsToMany(Role::class); + } +} diff --git a/tests/Integration/Database/Laravel/EloquentHasManyTest.php b/tests/Integration/Database/Laravel/EloquentHasManyTest.php new file mode 100644 index 000000000..f7fe942b1 --- /dev/null +++ b/tests/Integration/Database/Laravel/EloquentHasManyTest.php @@ -0,0 +1,161 @@ +id(); + }); + + Schema::create('eloquent_has_many_test_posts', function ($table) { + $table->id(); + $table->foreignId('eloquent_has_many_test_user_id'); + $table->string('title')->unique(); + $table->timestamps(); + }); + + Schema::create('eloquent_has_many_test_logins', function ($table) { + $table->id(); + $table->foreignId('eloquent_has_many_test_user_id'); + $table->timestamp('login_time'); + }); + } + + public function testCanGetHasOneFromHasManyRelationship() + { + $user = EloquentHasManyTestUser::create(); + + $user->logins()->create(['login_time' => now()]); + + $this->assertInstanceOf(HasOne::class, $user->logins()->one()); + } + + public function testHasOneRelationshipFromHasMany() + { + $user = EloquentHasManyTestUser::create(); + + EloquentHasManyTestLogin::create([ + 'eloquent_has_many_test_user_id' => $user->id, + 'login_time' => '2020-09-29', + ]); + $latestLogin = EloquentHasManyTestLogin::create([ + 'eloquent_has_many_test_user_id' => $user->id, + 'login_time' => '2023-03-14', + ]); + $oldestLogin = EloquentHasManyTestLogin::create([ + 'eloquent_has_many_test_user_id' => $user->id, + 'login_time' => '2010-01-01', + ]); + + $this->assertEquals($oldestLogin->id, $user->oldestLogin->id); + $this->assertEquals($latestLogin->id, $user->latestLogin->id); + } + + public function testFirstOrCreate() + { + $user = EloquentHasManyTestUser::create(); + + $post1 = $user->posts()->create(['title' => Str::random()]); + $post2 = $user->posts()->firstOrCreate(['title' => $post1->title]); + + $this->assertTrue($post1->is($post2)); + $this->assertCount(1, $user->posts()->get()); + } + + public function testFirstOrCreateWithinTransaction() + { + $user = EloquentHasManyTestUser::create(); + + $post1 = $user->posts()->create(['title' => Str::random()]); + + DB::transaction(function () use ($user, $post1) { + $post2 = $user->posts()->firstOrCreate(['title' => $post1->title]); + + $this->assertTrue($post1->is($post2)); + }); + + $this->assertCount(1, $user->posts()->get()); + } + + public function testCreateOrFirst() + { + $user = EloquentHasManyTestUser::create(); + + $post1 = $user->posts()->createOrFirst(['title' => Str::random()]); + $post2 = $user->posts()->createOrFirst(['title' => $post1->title]); + + $this->assertTrue($post1->is($post2)); + $this->assertCount(1, $user->posts()->get()); + } + + public function testCreateOrFirstWithinTransaction() + { + $user = EloquentHasManyTestUser::create(); + + $post1 = $user->posts()->create(['title' => Str::random()]); + + DB::transaction(function () use ($user, $post1) { + $post2 = $user->posts()->createOrFirst(['title' => $post1->title]); + + $this->assertTrue($post1->is($post2)); + }); + + $this->assertCount(1, $user->posts()->get()); + } +} + +class EloquentHasManyTestUser extends Model +{ + protected array $guarded = []; + + public bool $timestamps = false; + + public function logins(): HasMany + { + return $this->hasMany(EloquentHasManyTestLogin::class); + } + + public function latestLogin(): HasOne + { + return $this->logins()->one()->latestOfMany('login_time'); + } + + public function oldestLogin(): HasOne + { + return $this->logins()->one()->oldestOfMany('login_time'); + } + + public function posts(): HasMany + { + return $this->hasMany(EloquentHasManyTestPost::class); + } +} + +class EloquentHasManyTestLogin extends Model +{ + protected array $guarded = []; + + public bool $timestamps = false; +} + +class EloquentHasManyTestPost extends Model +{ + protected array $guarded = []; +} diff --git a/tests/Integration/Database/Laravel/EloquentHasManyThroughTest.php b/tests/Integration/Database/Laravel/EloquentHasManyThroughTest.php new file mode 100644 index 000000000..19ded8d31 --- /dev/null +++ b/tests/Integration/Database/Laravel/EloquentHasManyThroughTest.php @@ -0,0 +1,538 @@ +increments('id'); + $table->string('slug')->nullable(); + $table->integer('team_id')->nullable(); + $table->string('name'); + }); + + Schema::create('teams', function (Blueprint $table) { + $table->increments('id'); + $table->integer('owner_id')->nullable(); + $table->string('owner_slug')->nullable(); + }); + + Schema::create('categories', function (Blueprint $table) { + $table->increments('id'); + $table->integer('parent_id')->nullable(); + $table->softDeletes(); + }); + + Schema::create('products', function (Blueprint $table) { + $table->increments('id'); + $table->integer('category_id'); + }); + + Schema::create('articles', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id'); + $table->string('title')->unique(); + $table->timestamps(); + }); + } + + public function testBasicCreateAndRetrieve() + { + $user = User::create(['name' => Str::random()]); + + $team1 = Team::create(['owner_id' => $user->id]); + $team2 = Team::create(['owner_id' => $user->id]); + + $mate1 = User::create(['name' => 'John', 'team_id' => $team1->id]); + $mate2 = User::create(['name' => 'Jack', 'team_id' => $team2->id, 'slug' => null]); + + User::create(['name' => Str::random()]); + + $this->assertEquals([$mate1->id, $mate2->id], $user->teamMates->pluck('id')->toArray()); + $this->assertEquals([$mate1->id, $mate2->id], $user->teamMatesWithPendingRelation->pluck('id')->toArray()); + $this->assertEquals([$user->id], User::has('teamMates')->pluck('id')->toArray()); + $this->assertEquals([$user->id], User::has('teamMatesWithPendingRelation')->pluck('id')->toArray()); + + $result = $user->teamMates()->first(); + $this->assertEquals( + $mate1->refresh()->getAttributes() + ['laravel_through_key' => '1'], + $result->getAttributes() + ); + + $result = $user->teamMatesWithPendingRelation()->first(); + $this->assertEquals( + $mate1->refresh()->getAttributes() + ['laravel_through_key' => '1'], + $result->getAttributes() + ); + + $result = $user->teamMates()->firstWhere('name', 'Jack'); + $this->assertEquals( + $mate2->refresh()->getAttributes() + ['laravel_through_key' => '1'], + $result->getAttributes() + ); + + $result = $user->teamMatesWithPendingRelation()->firstWhere('name', 'Jack'); + $this->assertEquals( + $mate2->refresh()->getAttributes() + ['laravel_through_key' => '1'], + $result->getAttributes() + ); + } + + public function testGlobalScopeColumns() + { + $user = User::create(['name' => Str::random()]); + + $team1 = Team::create(['owner_id' => $user->id]); + + User::create(['name' => Str::random(), 'team_id' => $team1->id]); + + $teamMates = $user->teamMatesWithGlobalScope; + $this->assertEquals(['id' => 2, 'laravel_through_key' => 1], $teamMates[0]->getAttributes()); + + $teamMates = $user->teamMatesWithGlobalScopeWithPendingRelation; + $this->assertEquals(['id' => 2, 'laravel_through_key' => 1], $teamMates[0]->getAttributes()); + } + + public function testHasSelf() + { + $user = User::create(['name' => Str::random()]); + + $team = Team::create(['owner_id' => $user->id]); + + User::create(['name' => Str::random(), 'team_id' => $team->id]); + + $users = User::has('teamMates')->get(); + $this->assertCount(1, $users); + + $users = User::has('teamMatesWithPendingRelation')->get(); + $this->assertCount(1, $users); + } + + public function testHasSelfCustomOwnerKey() + { + $user = User::create(['slug' => Str::random(), 'name' => Str::random()]); + + $team = Team::create(['owner_slug' => $user->slug]); + + User::create(['name' => Str::random(), 'team_id' => $team->id]); + + $users = User::has('teamMatesBySlug')->get(); + $this->assertCount(1, $users); + + $users = User::has('teamMatesBySlugWithPendingRelationship')->get(); + $this->assertCount(1, $users); + } + + public function testHasSameParentAndThroughParentTable() + { + Category::create(); + Category::create(); + Category::create(['parent_id' => 1]); + Category::create(['parent_id' => 2])->delete(); + + Product::create(['category_id' => 3]); + Product::create(['category_id' => 4]); + + $categories = Category::has('subProducts')->get(); + + $this->assertEquals([1], $categories->pluck('id')->all()); + } + + public function testFirstOrNewOnMissingRecord() + { + $taylor = User::create(['name' => 'Taylor', 'slug' => 'taylor']); + $team = Team::create(['owner_id' => $taylor->id]); + + $user1 = $taylor->teamMates()->firstOrNew( + ['slug' => 'tony'], + ['name' => 'Tony', 'team_id' => $team->id], + ); + + $this->assertFalse($user1->exists); + $this->assertEquals($team->id, $user1->team_id); + $this->assertSame('tony', $user1->slug); + $this->assertSame('Tony', $user1->name); + } + + public function testFirstOrNewWhenRecordExists() + { + $taylor = User::create(['name' => 'Taylor', 'slug' => 'taylor']); + $team = Team::create(['owner_id' => $taylor->id]); + $existingTony = $team->members()->create(['name' => 'Tony Messias', 'slug' => 'tony']); + + $newTony = $taylor->teamMates()->firstOrNew( + ['slug' => 'tony'], + ['name' => 'Tony', 'team_id' => $team->id], + ); + + $this->assertTrue($newTony->exists); + $this->assertEquals($team->id, $newTony->team_id); + $this->assertSame('tony', $newTony->slug); + $this->assertSame('Tony Messias', $newTony->name); + + $this->assertTrue($existingTony->is($newTony)); + $this->assertSame('tony', $existingTony->refresh()->slug); + $this->assertSame('Tony Messias', $existingTony->name); + } + + public function testFirstOrCreateWhenModelDoesntExist() + { + $owner = User::create(['name' => 'Taylor']); + Team::create(['owner_id' => $owner->id]); + + $mate = $owner->teamMates()->firstOrCreate(['slug' => 'adam'], ['name' => 'Adam']); + + $this->assertTrue($mate->wasRecentlyCreated); + $this->assertNull($mate->team_id); + $this->assertEquals('Adam', $mate->name); + $this->assertEquals('adam', $mate->slug); + } + + public function testFirstOrCreateWhenModelExists() + { + $owner = User::create(['name' => 'Taylor']); + $team = Team::create(['owner_id' => $owner->id]); + + $team->members()->create(['slug' => 'adam', 'name' => 'Adam Wathan']); + + $mate = $owner->teamMates()->firstOrCreate(['slug' => 'adam'], ['name' => 'Adam']); + + $this->assertFalse($mate->wasRecentlyCreated); + $this->assertNotNull($mate->team_id); + $this->assertTrue($team->is($mate->team)); + $this->assertEquals('Adam Wathan', $mate->name); + $this->assertEquals('adam', $mate->slug); + } + + public function testFirstOrCreateRegressionIssue() + { + $team1 = Team::create(); + $team2 = Team::create(); + + $jane = $team2->members()->create(['name' => 'Jane', 'slug' => 'jane']); + $john = $team1->members()->create(['name' => 'John', 'slug' => 'john']); + + $taylor = User::create(['name' => 'Taylor']); + $team1->update(['owner_id' => $taylor->id]); + + $newJohn = $taylor->teamMates()->firstOrCreate( + ['slug' => 'john'], + ['name' => 'John Doe'], + ); + + $this->assertFalse($newJohn->wasRecentlyCreated); + $this->assertTrue($john->is($newJohn)); + $this->assertEquals('john', $newJohn->refresh()->slug); + $this->assertEquals('John', $newJohn->name); + + $this->assertSame('john', $john->refresh()->slug); + $this->assertSame('John', $john->name); + $this->assertSame('jane', $jane->refresh()->slug); + $this->assertSame('Jane', $jane->name); + } + + public function testCreateOrFirstWhenRecordDoesntExist() + { + $team = Team::create(); + $tony = $team->members()->create(['name' => 'Tony']); + + $article = $team->articles()->createOrFirst( + ['title' => 'Laravel Forever'], + ['user_id' => $tony->id], + ); + + $this->assertTrue($article->wasRecentlyCreated); + $this->assertEquals('Laravel Forever', $article->title); + $this->assertTrue($tony->is($article->user)); + } + + public function testCreateOrFirstWhenRecordExists() + { + $team = Team::create(); + $taylor = $team->members()->create(['name' => 'Taylor']); + $tony = $team->members()->create(['name' => 'Tony']); + + $existingArticle = $taylor->articles()->create([ + 'title' => 'Laravel Forever', + ]); + + $newArticle = $team->articles()->createOrFirst( + ['title' => 'Laravel Forever'], + ['user_id' => $tony->id], + ); + + $this->assertFalse($newArticle->wasRecentlyCreated); + $this->assertEquals('Laravel Forever', $newArticle->title); + $this->assertTrue($taylor->is($newArticle->user)); + $this->assertTrue($existingArticle->is($newArticle)); + } + + public function testCreateOrFirstWhenRecordExistsInTransaction() + { + $team = Team::create(); + $taylor = $team->members()->create(['name' => 'Taylor']); + $tony = $team->members()->create(['name' => 'Tony']); + + $existingArticle = $taylor->articles()->create([ + 'title' => 'Laravel Forever', + ]); + + $newArticle = DB::transaction(fn () => $team->articles()->createOrFirst( + ['title' => 'Laravel Forever'], + ['user_id' => $tony->id], + )); + + $this->assertFalse($newArticle->wasRecentlyCreated); + $this->assertEquals('Laravel Forever', $newArticle->title); + $this->assertTrue($taylor->is($newArticle->user)); + $this->assertTrue($existingArticle->is($newArticle)); + } + + public function testCreateOrFirstRegressionIssue() + { + $team1 = Team::create(); + + $taylor = $team1->members()->create(['name' => 'Taylor']); + $tony = $team1->members()->create(['name' => 'Tony']); + + $existingTonyArticle = $tony->articles()->create(['title' => 'The New createOrFirst Method']); + $existingTaylorArticle = $taylor->articles()->create(['title' => 'Laravel Forever']); + + $newArticle = $team1->articles()->createOrFirst( + ['title' => 'Laravel Forever'], + ['user_id' => $tony->id], + ); + + $this->assertFalse($newArticle->wasRecentlyCreated); + $this->assertTrue($existingTaylorArticle->is($newArticle)); + $this->assertEquals('Laravel Forever', $newArticle->refresh()->title); + $this->assertTrue($taylor->is($newArticle->user)); + + $this->assertSame('Laravel Forever', $existingTaylorArticle->refresh()->title); + $this->assertSame('The New createOrFirst Method', $existingTonyArticle->refresh()->title); + $this->assertTrue($tony->is($existingTonyArticle->user)); + } + + public function testUpdateOrCreateAffectingWrongModelsRegression() + { + // On Laravel 10.21.0, a bug was introduced that would update the wrong model when using `updateOrCreate()`, + // because the UPDATE statement would target a model based on the ID from the parent instead of the actual + // conditions that the `updateOrCreate()` targeted. This test replicates the case that causes this bug. + + $team1 = Team::create(); + $team2 = Team::create(); + + // Jane's ID should be the same as the $team1's ID for the bug to occur. + $jane = User::create(['name' => 'Jane', 'slug' => 'jane-slug', 'team_id' => $team2->id]); + $john = User::create(['name' => 'John', 'slug' => 'john-slug', 'team_id' => $team1->id]); + + $taylor = User::create(['name' => 'Taylor']); + $team1->update(['owner_id' => $taylor->id]); + + $this->assertSame(2, $john->id); + $this->assertSame(1, $jane->id); + + $this->assertSame(2, $john->refresh()->id); + $this->assertSame(1, $jane->refresh()->id); + + $this->assertSame('john-slug', $john->slug); + $this->assertSame('jane-slug', $jane->slug); + + $this->assertSame('john-slug', $john->refresh()->slug); + $this->assertSame('jane-slug', $jane->refresh()->slug); + + // The `updateOrCreate` method would first try to find a matching attached record with a query like: + // `->where($attributes)->first()`, which should return `John` of ID 1 in our case. However, it'd + // return the incorrect ID of 2, which caused it to update Jane's record instead of John's. + + $taylor->teamMates()->updateOrCreate([ + 'name' => 'John', + ], [ + 'slug' => 'john-doe', + ]); + + // Expect $john's slug to be updated to john-doe instead of john-slug. + $this->assertSame('john-doe', $john->fresh()->slug); + // $jane should not be updated, because it belongs to a different user altogether. + $this->assertSame('jane-slug', $jane->fresh()->slug); + } + + public function testCanReplicateModelLoadedThroughHasManyThrough() + { + $team = Team::create(); + $user = User::create(['team_id' => $team->id, 'name' => 'John']); + Article::create(['user_id' => $user->id, 'title' => 'John\'s new has-many-through-article']); + + $article = $team->articles()->first(); + + $this->assertInstanceOf(Article::class, $article); + + $newArticle = $article->replicate(); + $newArticle->title .= ' v2'; + $newArticle->save(); + } + + public function testOne(): void + { + $team = Team::create(); + + $user = User::create(['team_id' => $team->id, 'name' => Str::random()]); + + Article::create(['user_id' => $user->id, 'title' => Str::random(), 'created_at' => now()->subDay()]); + $latestArticle = Article::create(['user_id' => $user->id, 'title' => Str::random(), 'created_at' => now()]); + + $this->assertEquals($latestArticle->id, $team->latestArticle->id); + } +} + +class User extends Model +{ + protected ?string $table = 'users'; + + public bool $timestamps = false; + + protected array $guarded = []; + + public function teamMates() + { + return $this->hasManyThrough(self::class, Team::class, 'owner_id', 'team_id'); + } + + public function teamMatesWithPendingRelation() + { + return $this->through($this->ownedTeams()) + ->has(fn (Team $team) => $team->members()); + } + + public function teamMatesBySlug() + { + return $this->hasManyThrough(self::class, Team::class, 'owner_slug', 'team_id', 'slug'); + } + + public function teamMatesBySlugWithPendingRelationship() + { + return $this->through($this->hasMany(Team::class, 'owner_slug', 'slug')) + ->has(fn ($team) => $team->hasMany(User::class, 'team_id')); + } + + public function teamMatesWithGlobalScope() + { + return $this->hasManyThrough(UserWithGlobalScope::class, Team::class, 'owner_id', 'team_id'); + } + + public function teamMatesWithGlobalScopeWithPendingRelation() + { + return $this->through($this->ownedTeams()) + ->has(fn (Team $team) => $team->membersWithGlobalScope()); + } + + public function ownedTeams() + { + return $this->hasMany(Team::class, 'owner_id'); + } + + public function team() + { + return $this->belongsTo(Team::class); + } + + public function articles() + { + return $this->hasMany(Article::class); + } +} + +class UserWithGlobalScope extends Model +{ + protected ?string $table = 'users'; + + public bool $timestamps = false; + + protected array $guarded = []; + + public static function boot(): void + { + parent::boot(); + + static::addGlobalScope(function ($query) { + $query->select('users.id'); + }); + } +} + +class Team extends Model +{ + protected ?string $table = 'teams'; + + public bool $timestamps = false; + + protected array $guarded = []; + + public function members() + { + return $this->hasMany(User::class, 'team_id'); + } + + public function membersWithGlobalScope() + { + return $this->hasMany(UserWithGlobalScope::class, 'team_id'); + } + + public function articles() + { + return $this->hasManyThrough(Article::class, User::class); + } + + public function latestArticle(): HasOneThrough + { + return $this->articles()->one()->latest(); + } +} + +class Category extends Model +{ + use SoftDeletes; + + public bool $timestamps = false; + + protected array $guarded = []; + + public function subProducts() + { + return $this->hasManyThrough(Product::class, self::class, 'parent_id'); + } +} + +class Product extends Model +{ + public bool $timestamps = false; + + protected array $guarded = []; +} + +class Article extends Model +{ + protected array $guarded = []; + + public function user() + { + return $this->belongsTo(User::class); + } +} diff --git a/tests/Integration/Database/Laravel/EloquentHasOneIsTest.php b/tests/Integration/Database/Laravel/EloquentHasOneIsTest.php new file mode 100644 index 000000000..9b9003932 --- /dev/null +++ b/tests/Integration/Database/Laravel/EloquentHasOneIsTest.php @@ -0,0 +1,104 @@ +increments('id'); + $table->timestamps(); + }); + + Schema::create('attachments', function (Blueprint $table) { + $table->increments('id'); + $table->unsignedInteger('post_id')->nullable(); + }); + + $post = Post::create(); + $post->attachment()->create(); + } + + public function testChildIsNotNull() + { + $parent = Post::first(); + $child = null; + + $this->assertFalse($parent->attachment()->is($child)); + $this->assertTrue($parent->attachment()->isNot($child)); + } + + public function testChildIsModel() + { + $parent = Post::first(); + $child = Attachment::first(); + + $this->assertTrue($parent->attachment()->is($child)); + $this->assertFalse($parent->attachment()->isNot($child)); + } + + public function testChildIsNotAnotherModel() + { + $parent = Post::first(); + $child = new Attachment(); + $child->id = 2; + + $this->assertFalse($parent->attachment()->is($child)); + $this->assertTrue($parent->attachment()->isNot($child)); + } + + public function testNullChildIsNotModel() + { + $parent = Post::first(); + $child = Attachment::first(); + $child->post_id = null; + + $this->assertFalse($parent->attachment()->is($child)); + $this->assertTrue($parent->attachment()->isNot($child)); + } + + public function testChildIsNotModelWithAnotherTable() + { + $parent = Post::first(); + $child = Attachment::first(); + $child->setTable('foo'); + + $this->assertFalse($parent->attachment()->is($child)); + $this->assertTrue($parent->attachment()->isNot($child)); + } + + public function testChildIsNotModelWithAnotherConnection() + { + $parent = Post::first(); + $child = Attachment::first(); + $child->setConnection('foo'); + + $this->assertFalse($parent->attachment()->is($child)); + $this->assertTrue($parent->attachment()->isNot($child)); + } +} + +class Attachment extends Model +{ + public bool $timestamps = false; +} + +class Post extends Model +{ + public function attachment() + { + return $this->hasOne(Attachment::class); + } +} diff --git a/tests/Integration/Database/Laravel/EloquentHasOneOfManyTest.php b/tests/Integration/Database/Laravel/EloquentHasOneOfManyTest.php new file mode 100644 index 000000000..6d1a1c11a --- /dev/null +++ b/tests/Integration/Database/Laravel/EloquentHasOneOfManyTest.php @@ -0,0 +1,169 @@ +id(); + }); + + Schema::create('logins', function ($table) { + $table->id(); + $table->foreignId('user_id'); + }); + + Schema::create('states', function ($table) { + $table->increments('id'); + $table->string('state'); + $table->string('type'); + $table->foreignId('user_id'); + $table->timestamps(); + }); + } + + /** + * @TODO Replace with testItOnlyEagerLoadsRequiredModelsOriginal once illuminate/events package is ported. + * Hypervel's event dispatcher spreads wildcard listener payload instead of passing array. + */ + public function testItOnlyEagerLoadsRequiredModels() + { + $this->retrievedLogins = 0; + User::getEventDispatcher()->listen('eloquent.retrieved:*', function ($event, $model) { + if ($model instanceof Login) { + ++$this->retrievedLogins; + } + }); + + $user = User::create(); + $user->latest_login()->create(); + $user->latest_login()->create(); + $user = User::create(); + $user->latest_login()->create(); + $user->latest_login()->create(); + + User::with('latest_login')->get(); + + $this->assertSame(2, $this->retrievedLogins); + } + + // @TODO Restore this test once illuminate/events package is ported (wildcard listeners receive array payload) + // public function testItOnlyEagerLoadsRequiredModelsOriginal() + // { + // $this->retrievedLogins = 0; + // User::getEventDispatcher()->listen('eloquent.retrieved:*', function ($event, $models) { + // foreach ($models as $model) { + // if (get_class($model) == Login::class) { + // ++$this->retrievedLogins; + // } + // } + // }); + // + // $user = User::create(); + // $user->latest_login()->create(); + // $user->latest_login()->create(); + // $user = User::create(); + // $user->latest_login()->create(); + // $user->latest_login()->create(); + // + // User::with('latest_login')->get(); + // + // $this->assertSame(2, $this->retrievedLogins); + // } + + public function testItGetsCorrectResultUsingAtLeastTwoAggregatesDistinctFromId() + { + $user = User::create(); + + $latestState = $user->states()->create([ + 'state' => 'state', + 'type' => 'type', + 'created_at' => '2023-01-01', + 'updated_at' => '2023-01-03', + ]); + + $oldestState = $user->states()->create([ + 'state' => 'state', + 'type' => 'type', + 'created_at' => '2023-01-01', + 'updated_at' => '2023-01-02', + ]); + + $this->assertSame($user->oldest_updated_state->id, $oldestState->id); + $this->assertSame($user->oldest_updated_oldest_created_state->id, $oldestState->id); + + $users = User::with('latest_updated_state', 'latest_updated_latest_created_state')->get(); + + $this->assertSame($users[0]->latest_updated_state->id, $latestState->id); + $this->assertSame($users[0]->latest_updated_latest_created_state->id, $latestState->id); + } +} + +class User extends Model +{ + protected array $guarded = []; + + public bool $timestamps = false; + + public function latest_login() + { + return $this->hasOne(Login::class)->ofMany(); + } + + public function states() + { + return $this->hasMany(State::class); + } + + public function latest_updated_state() + { + return $this->hasOne(State::class, 'user_id')->ofMany('updated_at', 'max'); + } + + public function oldest_updated_state() + { + return $this->hasOne(State::class, 'user_id')->ofMany('updated_at', 'min'); + } + + public function latest_updated_latest_created_state() + { + return $this->hasOne(State::class, 'user_id')->ofMany([ + 'updated_at' => 'max', + 'created_at' => 'max', + ]); + } + + public function oldest_updated_oldest_created_state() + { + return $this->hasOne(State::class, 'user_id')->ofMany([ + 'updated_at' => 'min', + 'created_at' => 'min', + ]); + } +} + +class Login extends Model +{ + protected array $guarded = []; + + public bool $timestamps = false; +} + +class State extends Model +{ + protected array $guarded = []; +} diff --git a/tests/Integration/Database/Laravel/EloquentLazyEagerLoadingTest.php b/tests/Integration/Database/Laravel/EloquentLazyEagerLoadingTest.php new file mode 100644 index 000000000..adc1de3ab --- /dev/null +++ b/tests/Integration/Database/Laravel/EloquentLazyEagerLoadingTest.php @@ -0,0 +1,104 @@ +increments('id'); + }); + + Schema::create('two', function (Blueprint $table) { + $table->increments('id'); + $table->integer('one_id'); + }); + + Schema::create('three', function (Blueprint $table) { + $table->increments('id'); + $table->integer('one_id'); + }); + } + + public function testItBasic() + { + $one = Model1::create(); + $one->twos()->create(); + $one->threes()->create(); + + $model = Model1::find($one->id); + + $this->assertTrue($model->relationLoaded('twos')); + $this->assertFalse($model->relationLoaded('threes')); + + DB::enableQueryLog(); + + $model->load('threes'); + + $this->assertCount(1, DB::getQueryLog()); + + $this->assertTrue($model->relationLoaded('threes')); + } +} + +class Model1 extends Model +{ + protected ?string $table = 'one'; + + public bool $timestamps = false; + + protected array $guarded = []; + + protected array $with = ['twos']; + + public function twos() + { + return $this->hasMany(Model2::class, 'one_id'); + } + + public function threes() + { + return $this->hasMany(Model3::class, 'one_id'); + } +} + +class Model2 extends Model +{ + protected ?string $table = 'two'; + + public bool $timestamps = false; + + protected array $guarded = []; + + public function one() + { + return $this->belongsTo(Model1::class, 'one_id'); + } +} + +class Model3 extends Model +{ + protected ?string $table = 'three'; + + public bool $timestamps = false; + + protected array $guarded = []; + + public function one() + { + return $this->belongsTo(Model1::class, 'one_id'); + } +} diff --git a/tests/Integration/Database/Laravel/EloquentMassPrunableTest.php b/tests/Integration/Database/Laravel/EloquentMassPrunableTest.php new file mode 100644 index 000000000..d82e1f24d --- /dev/null +++ b/tests/Integration/Database/Laravel/EloquentMassPrunableTest.php @@ -0,0 +1,112 @@ +each(function ($table) { + Schema::create($table, function (Blueprint $table) { + $table->increments('id'); + $table->string('name')->nullable(); + $table->softDeletes(); + $table->boolean('pruned')->default(false); + $table->timestamps(); + }); + }); + } + + public function testPrunableMethodMustBeImplemented() + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage( + 'Please implement', + ); + + MassPrunableTestModelMissingPrunableMethod::create()->pruneAll(); + } + + public function testPrunesRecords() + { + Event::fake(); + + collect(range(1, 5000))->map(function ($id) { + return ['name' => 'foo']; + })->chunk(200)->each(function ($chunk) { + MassPrunableTestModel::insert($chunk->all()); + }); + + $count = (new MassPrunableTestModel())->pruneAll(); + + $this->assertEquals(1500, $count); + $this->assertEquals(3500, MassPrunableTestModel::count()); + + Event::assertDispatched(ModelsPruned::class, 2); + } + + public function testPrunesSoftDeletedRecords() + { + Event::fake(); + + collect(range(1, 5000))->map(function ($id) { + return ['deleted_at' => now()]; + })->chunk(200)->each(function ($chunk) { + MassPrunableSoftDeleteTestModel::insert($chunk->all()); + }); + + $count = (new MassPrunableSoftDeleteTestModel())->pruneAll(); + + $this->assertEquals(3000, $count); + $this->assertEquals(0, MassPrunableSoftDeleteTestModel::count()); + $this->assertEquals(2000, MassPrunableSoftDeleteTestModel::withTrashed()->count()); + + Event::assertDispatched(ModelsPruned::class, 3); + } +} + +class MassPrunableTestModel extends Model +{ + use MassPrunable; + + public function prunable() + { + return $this->where('id', '<=', 1500); + } +} + +class MassPrunableSoftDeleteTestModel extends Model +{ + use MassPrunable; + use SoftDeletes; + + public function prunable() + { + return $this->where('id', '<=', 3000); + } +} + +class MassPrunableTestModelMissingPrunableMethod extends Model +{ + use MassPrunable; +} diff --git a/tests/Integration/Database/Laravel/EloquentModelCustomEventsTest.php b/tests/Integration/Database/Laravel/EloquentModelCustomEventsTest.php new file mode 100644 index 000000000..c7f6e0584 --- /dev/null +++ b/tests/Integration/Database/Laravel/EloquentModelCustomEventsTest.php @@ -0,0 +1,121 @@ +increments('id'); + }); + + Schema::create('eloquent_model_stub_with_custom_event_from_traits', function (Blueprint $table) { + $table->boolean('custom_attribute'); + $table->boolean('observer_attribute'); + }); + } + + public function testFlushListenersClearsCustomEvents() + { + $_SERVER['fired_event'] = false; + + TestModel1::flushEventListeners(); + + TestModel1::create(); + + $this->assertFalse($_SERVER['fired_event']); + } + + public function testCustomEventListenersAreFired() + { + $_SERVER['fired_event'] = false; + + TestModel1::create(); + + $this->assertTrue($_SERVER['fired_event']); + } + + public function testAddObservableEventFromTrait() + { + $model = new EloquentModelStubWithCustomEventFromTrait(); + + $this->assertNull($model->custom_attribute); + $this->assertNull($model->observer_attribute); + + $model->completeCustomAction(); + + $this->assertTrue($model->custom_attribute); + $this->assertTrue($model->observer_attribute); + } +} + +class TestModel1 extends Model +{ + public array $dispatchesEvents = ['created' => CustomEvent::class]; + + public ?string $table = 'test_model1'; + + public bool $timestamps = false; + + protected array $guarded = []; +} + +class CustomEvent +{ +} + +trait CustomEventTrait +{ + public function completeCustomAction() + { + $this->custom_attribute = true; + + $this->fireModelEvent('customEvent'); + } + + public function initializeCustomEventTrait() + { + $this->addObservableEvents([ + 'customEvent', + ]); + } +} + +class CustomObserver +{ + public function customEvent(EloquentModelStubWithCustomEventFromTrait $model) + { + $model->observer_attribute = true; + } +} + +#[ObservedBy(CustomObserver::class)] +class EloquentModelStubWithCustomEventFromTrait extends Model +{ + use CustomEventTrait; + + public bool $timestamps = false; +} diff --git a/tests/Integration/Database/Laravel/EloquentModelDateCastingTest.php b/tests/Integration/Database/Laravel/EloquentModelDateCastingTest.php new file mode 100644 index 000000000..bbb138751 --- /dev/null +++ b/tests/Integration/Database/Laravel/EloquentModelDateCastingTest.php @@ -0,0 +1,188 @@ +increments('id'); + $table->date('date_field')->nullable(); + $table->datetime('datetime_field')->nullable(); + $table->date('immutable_date_field')->nullable(); + $table->datetime('immutable_datetime_field')->nullable(); + }); + + Schema::create('test_model2', function (Blueprint $table) { + $table->increments('id'); + $table->date('date_field')->nullable(); + $table->datetime('datetime_field')->nullable(); + $table->date('immutable_date_field')->nullable(); + $table->datetime('immutable_datetime_field')->nullable(); + $table->timestamp('created_at')->nullable(); + }); + } + + public function testDatesAreCustomCastable() + { + $user = TestModel1::create([ + 'date_field' => '2019-10-01', + 'datetime_field' => '2019-10-01 10:15:20', + ]); + + $this->assertSame('2019-10', $user->toArray()['date_field']); + $this->assertSame('2019-10 10:15', $user->toArray()['datetime_field']); + $this->assertInstanceOf(Carbon::class, $user->date_field); + $this->assertInstanceOf(Carbon::class, $user->datetime_field); + } + + public function testDatesFormattedAttributeBindings() + { + $bindings = []; + + $this->app->make('db')->listen(static function ($query) use (&$bindings) { + $bindings = $query->bindings; + }); + + TestModel1::create([ + 'date_field' => '2019-10-01', + 'datetime_field' => '2019-10-01 10:15:20', + 'immutable_date_field' => '2019-10-01', + 'immutable_datetime_field' => '2019-10-01 10:15', + ]); + + $this->assertSame(['2019-10-01', '2019-10-01 10:15:20', '2019-10-01', '2019-10-01 10:15'], $bindings); + } + + public function testDatesFormattedArrayAndJson() + { + $user = TestModel1::create([ + 'date_field' => '2019-10-01', + 'datetime_field' => '2019-10-01 10:15:20', + 'immutable_date_field' => '2019-10-01', + 'immutable_datetime_field' => '2019-10-01 10:15', + ]); + + $expected = [ + 'date_field' => '2019-10', + 'datetime_field' => '2019-10 10:15', + 'immutable_date_field' => '2019-10', + 'immutable_datetime_field' => '2019-10 10:15', + 'id' => 1, + ]; + + $this->assertSame($expected, $user->toArray()); + $this->assertSame(json_encode($expected), $user->toJson()); + } + + public function testCustomDateCastsAreComparedAsDatesForCarbonInstances() + { + $user = TestModel1::create([ + 'date_field' => '2019-10-01', + 'datetime_field' => '2019-10-01 10:15:20', + 'immutable_date_field' => '2019-10-01', + 'immutable_datetime_field' => '2019-10-01 10:15:20', + ]); + + $user->date_field = new Carbon('2019-10-01'); + $user->datetime_field = new Carbon('2019-10-01 10:15:20'); + $user->immutable_date_field = new CarbonImmutable('2019-10-01'); + $user->immutable_datetime_field = new CarbonImmutable('2019-10-01 10:15:20'); + + $this->assertArrayNotHasKey('date_field', $user->getDirty()); + $this->assertArrayNotHasKey('datetime_field', $user->getDirty()); + $this->assertArrayNotHasKey('immutable_date_field', $user->getDirty()); + $this->assertArrayNotHasKey('immutable_datetime_field', $user->getDirty()); + } + + public function testCustomDateCastsAreComparedAsDatesForStringValues() + { + $user = TestModel1::create([ + 'date_field' => '2019-10-01', + 'datetime_field' => '2019-10-01 10:15:20', + 'immutable_date_field' => '2019-10-01', + 'immutable_datetime_field' => '2019-10-01 10:15:20', + ]); + + $user->date_field = '2019-10-01'; + $user->datetime_field = '2019-10-01 10:15:20'; + $user->immutable_date_field = '2019-10-01'; + $user->immutable_datetime_field = '2019-10-01 10:15:20'; + + $this->assertArrayNotHasKey('date_field', $user->getDirty()); + $this->assertArrayNotHasKey('datetime_field', $user->getDirty()); + $this->assertArrayNotHasKey('immutable_date_field', $user->getDirty()); + $this->assertArrayNotHasKey('immutable_datetime_field', $user->getDirty()); + } + + public function testDatesCanBeSerializedToArray() + { + $this->freezeSecond(function ($now) { + $user = TestModel2::create([ + 'date_field' => '2019-10-01', + 'datetime_field' => '2019-10-01 10:15:20', + 'immutable_date_field' => '2019-10-01', + 'immutable_datetime_field' => '2019-10-01 10:15:20', + ]); + + $this->assertSame(['created_at', null], $user->getDates()); + + $user->refresh(); + + $this->assertSame([ + 'id' => $user->getKey(), + 'date_field' => '2019-10', + 'datetime_field' => '2019-10 10:15', + 'immutable_date_field' => '2019-10', + 'immutable_datetime_field' => '2019-10 10:15', + 'created_at' => $now->toISOString(), + ], $user->attributesToArray()); + }); + } +} + +class TestModel1 extends Model +{ + public ?string $table = 'test_model1'; + + public bool $timestamps = false; + + protected array $guarded = []; + + public array $casts = [ + 'date_field' => 'date:Y-m', + 'datetime_field' => 'datetime:Y-m H:i', + 'immutable_date_field' => 'date:Y-m', + 'immutable_datetime_field' => 'datetime:Y-m H:i', + ]; +} + +class TestModel2 extends Model +{ + public ?string $table = 'test_model2'; + + public const UPDATED_AT = null; + + protected array $guarded = []; + + public array $casts = [ + 'date_field' => 'date:Y-m', + 'datetime_field' => 'datetime:Y-m H:i', + 'immutable_date_field' => 'date:Y-m', + 'immutable_datetime_field' => 'datetime:Y-m H:i', + ]; +} diff --git a/tests/Integration/Database/Laravel/EloquentModelDecimalCastingTest.php b/tests/Integration/Database/Laravel/EloquentModelDecimalCastingTest.php new file mode 100644 index 000000000..59d6a75b1 --- /dev/null +++ b/tests/Integration/Database/Laravel/EloquentModelDecimalCastingTest.php @@ -0,0 +1,231 @@ +increments('id'); + $table->decimal('decimal_field_2', 8, 2)->nullable(); + $table->decimal('decimal_field_4', 8, 4)->nullable(); + }); + } + + public function testItHandlesExponent() + { + $model = new class extends Model { + public bool $timestamps = false; + + protected array $casts = [ + 'amount' => 'decimal:20', + ]; + }; + + $model->amount = 0.123456789e3; + $this->assertSame('123.45678900000000000000', $model->amount); + + $model->amount = '0.123456789e3'; + $this->assertSame('123.45678900000000000000', $model->amount); + } + + public function testItHandlesIntegersWithUnderscores() + { + $model = new class extends Model { + public bool $timestamps = false; + + protected array $casts = [ + 'amount' => 'decimal:2', + ]; + }; + + $model->amount = 1_234.5; + $this->assertSame('1234.50', $model->amount); + } + + public function testItWrapsThrownExceptions() + { + $model = new class extends Model { + public bool $timestamps = false; + + protected array $casts = [ + 'amount' => 'decimal:20', + ]; + }; + $model->amount = 'foo'; + + try { + $model->amount; + $this->fail(); + } catch (MathException $e) { + $this->assertSame('Unable to cast value to a decimal.', $e->getMessage()); + $this->assertInstanceOf(NumberFormatException::class, $e->getPrevious()); + $this->assertSame('The given value "foo" does not represent a valid number.', $e->getPrevious()->getMessage()); + } + } + + public function testItHandlesMissingIntegers() + { + $model = new class extends Model { + public bool $timestamps = false; + + protected array $casts = [ + 'amount' => 'decimal:2', + ]; + }; + + $model->amount = .8; + $this->assertSame('0.80', $model->amount); + + $model->amount = '.8'; + $this->assertSame('0.80', $model->amount); + } + + public function testItHandlesLargeNumbers() + { + $model = new class extends Model { + public bool $timestamps = false; + + protected array $casts = [ + 'amount' => 'decimal:20', + ]; + }; + + $model->amount = '0.89898989898989898989'; + $this->assertSame('0.89898989898989898989', $model->amount); + + $model->amount = '89898989898989898989'; + $this->assertSame('89898989898989898989.00000000000000000000', $model->amount); + } + + public function testItRounds() + { + $model = new class extends Model { + public bool $timestamps = false; + + protected array $casts = [ + 'amount' => 'decimal:2', + ]; + }; + + $model->amount = '0.8989898989'; + $this->assertSame('0.90', $model->amount); + } + + public function testItTrimsLongValues() + { + $model = new class extends Model { + public bool $timestamps = false; + + protected array $casts = [ + 'amount' => 'decimal:20', + ]; + }; + + $model->amount = '0.89898989898989898989898989898989898989898989'; + $this->assertSame('0.89898989898989898990', $model->amount); + } + + public function testItDoesntRoundNumbers() + { + $model = new class extends Model { + public bool $timestamps = false; + + protected array $casts = [ + 'amount' => 'decimal:1', + ]; + }; + + $model->amount = '0.99'; + $this->assertSame('1.0', $model->amount); + } + + public function testDecimalsAreCastable() + { + $user = TestModel1::create([ + 'decimal_field_2' => '12', + 'decimal_field_4' => '1234', + ]); + + $this->assertSame('12.00', $user->toArray()['decimal_field_2']); + $this->assertSame('1234.0000', $user->toArray()['decimal_field_4']); + + $user->decimal_field_2 = 12; + $user->decimal_field_4 = '1234'; + + $this->assertSame('12.00', $user->toArray()['decimal_field_2']); + $this->assertSame('1234.0000', $user->toArray()['decimal_field_4']); + + $this->assertFalse($user->isDirty()); + + $user->decimal_field_4 = '1234.1234'; + $this->assertTrue($user->isDirty()); + } + + public function testRoundingDirection() + { + $model = new class extends Model { + protected array $casts = [ + 'amount' => 'decimal:2', + ]; + }; + + $model->amount = '0.999'; + $this->assertSame('1.00', $model->amount); + + $model->amount = '-0.999'; + $this->assertSame('-1.00', $model->amount); + + $model->amount = '0.554'; + $this->assertSame('0.55', $model->amount); + + $model->amount = '-0.554'; + $this->assertSame('-0.55', $model->amount); + + $model->amount = '0.555'; + $this->assertSame('0.56', $model->amount); + + $model->amount = '-0.555'; + $this->assertSame('-0.56', $model->amount); + + $model->amount = '0.005'; + $this->assertSame('0.01', $model->amount); + + $model->amount = '-0.005'; + $this->assertSame('-0.01', $model->amount); + + $model->amount = '0.8989898989'; + $this->assertSame('0.90', $model->amount); + + $model->amount = '-0.8989898989'; + $this->assertSame('-0.90', $model->amount); + } +} + +class TestModel1 extends Model +{ + public ?string $table = 'test_model1'; + + public bool $timestamps = false; + + protected array $guarded = []; + + public array $casts = [ + 'decimal_field_2' => 'decimal:2', + 'decimal_field_4' => 'decimal:4', + ]; +} diff --git a/tests/Integration/Database/Laravel/EloquentModelEncryptedCastingTest.php b/tests/Integration/Database/Laravel/EloquentModelEncryptedCastingTest.php new file mode 100644 index 000000000..aaff4901f --- /dev/null +++ b/tests/Integration/Database/Laravel/EloquentModelEncryptedCastingTest.php @@ -0,0 +1,402 @@ +encrypter = $this->mock(Encrypter::class); + Crypt::swap($this->encrypter); + + Model::$encrypter = null; + } + + protected function afterRefreshingDatabase(): void + { + Schema::create('encrypted_casts', function (Blueprint $table) { + $table->increments('id'); + $table->string('secret', 1000)->nullable(); + $table->text('secret_array')->nullable(); + $table->text('secret_json')->nullable(); + $table->text('secret_object')->nullable(); + $table->text('secret_collection')->nullable(); + }); + } + + public function testStringsAreCastable() + { + $this->encrypter->expects('encrypt') + ->with('this is a secret string', false) + ->andReturn('encrypted-secret-string'); + $this->encrypter->expects('decrypt') + ->with('encrypted-secret-string', false) + ->andReturn('this is a secret string'); + + /** @var EncryptedCast $subject */ + $subject = EncryptedCast::create([ + 'secret' => 'this is a secret string', + ]); + + $this->assertSame('this is a secret string', $subject->secret); + $this->assertDatabaseHas('encrypted_casts', [ + 'id' => $subject->id, + 'secret' => 'encrypted-secret-string', + ]); + } + + public function testArraysAreCastable() + { + $this->encrypter->expects('encrypt') + ->with('{"key1":"value1"}', false) + ->andReturn('encrypted-secret-array-string'); + $this->encrypter->expects('decrypt') + ->with('encrypted-secret-array-string', false) + ->andReturn('{"key1":"value1"}'); + + /** @var EncryptedCast $subject */ + $subject = EncryptedCast::create([ + 'secret_array' => ['key1' => 'value1'], + ]); + + $this->assertSame(['key1' => 'value1'], $subject->secret_array); + $this->assertDatabaseHas('encrypted_casts', [ + 'id' => $subject->id, + 'secret_array' => 'encrypted-secret-array-string', + ]); + } + + public function testJsonIsCastable() + { + $this->encrypter->expects('encrypt') + ->with('{"key1":"value1"}', false) + ->andReturn('encrypted-secret-json-string'); + $this->encrypter->expects('decrypt') + ->with('encrypted-secret-json-string', false) + ->andReturn('{"key1":"value1"}'); + + /** @var EncryptedCast $subject */ + $subject = EncryptedCast::create([ + 'secret_json' => ['key1' => 'value1'], + ]); + + $this->assertSame(['key1' => 'value1'], $subject->secret_json); + $this->assertDatabaseHas('encrypted_casts', [ + 'id' => $subject->id, + 'secret_json' => 'encrypted-secret-json-string', + ]); + } + + public function testJsonAttributeIsCastable() + { + $this->encrypter->expects('encrypt') + ->with('{"key1":"value1"}', false) + ->andReturn('encrypted-secret-json-string'); + $this->encrypter->expects('decrypt') + ->with('encrypted-secret-json-string', false) + ->andReturn('{"key1":"value1"}'); + $this->encrypter->expects('encrypt') + ->with('{"key1":"value1","key2":"value2"}', false) + ->andReturn('encrypted-secret-json-string2'); + $this->encrypter->expects('decrypt') + ->with('encrypted-secret-json-string2', false) + ->andReturn('{"key1":"value1","key2":"value2"}'); + + $subject = new EncryptedCast([ + 'secret_json' => ['key1' => 'value1'], + ]); + $subject->fill([ + 'secret_json->key2' => 'value2', + ]); + $subject->save(); + + $this->assertSame(['key1' => 'value1', 'key2' => 'value2'], $subject->secret_json); + $this->assertDatabaseHas('encrypted_casts', [ + 'id' => $subject->id, + 'secret_json' => 'encrypted-secret-json-string2', + ]); + } + + public function testObjectIsCastable() + { + $object = new stdClass(); + $object->key1 = 'value1'; + + $this->encrypter->expects('encrypt') + ->with('{"key1":"value1"}', false) + ->andReturn('encrypted-secret-object-string'); + $this->encrypter->expects('decrypt') + ->twice() + ->with('encrypted-secret-object-string', false) + ->andReturn('{"key1":"value1"}'); + + /** @var EncryptedCast $object */ + $object = EncryptedCast::create([ + 'secret_object' => $object, + ]); + + $this->assertInstanceOf(stdClass::class, $object->secret_object); + $this->assertSame('value1', $object->secret_object->key1); + $this->assertDatabaseHas('encrypted_casts', [ + 'id' => $object->id, + 'secret_object' => 'encrypted-secret-object-string', + ]); + } + + public function testCollectionIsCastable() + { + $this->encrypter->expects('encrypt') + ->with('{"key1":"value1"}', false) + ->andReturn('encrypted-secret-collection-string'); + $this->encrypter->expects('decrypt') + ->twice() + ->with('encrypted-secret-collection-string', false) + ->andReturn('{"key1":"value1"}'); + + /** @var EncryptedCast $subject */ + $subject = EncryptedCast::create([ + 'secret_collection' => new Collection(['key1' => 'value1']), + ]); + + $this->assertInstanceOf(Collection::class, $subject->secret_collection); + $this->assertSame('value1', $subject->secret_collection->get('key1')); + $this->assertDatabaseHas('encrypted_casts', [ + 'id' => $subject->id, + 'secret_collection' => 'encrypted-secret-collection-string', + ]); + } + + public function testAsEncryptedCollection() + { + $this->encrypter->expects('encryptString') + ->twice() + ->with('{"key1":"value1"}') + ->andReturn('encrypted-secret-collection-string-1'); + $this->encrypter->expects('encryptString') + ->times(10) + ->with('{"key1":"value1","key2":"value2"}') + ->andReturn('encrypted-secret-collection-string-2'); + $this->encrypter->expects('decryptString') + ->once() + ->with('encrypted-secret-collection-string-2') + ->andReturn('{"key1":"value1","key2":"value2"}'); + + $subject = new EncryptedCast(); + + $subject->mergeCasts(['secret_collection' => AsEncryptedCollection::class]); + + $subject->secret_collection = new Collection(['key1' => 'value1']); + $subject->secret_collection->put('key2', 'value2'); + + $subject->save(); + + $this->assertInstanceOf(Collection::class, $subject->secret_collection); + $this->assertSame('value1', $subject->secret_collection->get('key1')); + $this->assertSame('value2', $subject->secret_collection->get('key2')); + $this->assertDatabaseHas('encrypted_casts', [ + 'id' => $subject->id, + 'secret_collection' => 'encrypted-secret-collection-string-2', + ]); + + $subject = $subject->fresh(); + + $this->assertInstanceOf(Collection::class, $subject->secret_collection); + $this->assertSame('value1', $subject->secret_collection->get('key1')); + $this->assertSame('value2', $subject->secret_collection->get('key2')); + + $subject->secret_collection = null; + $subject->save(); + + $this->assertNull($subject->secret_collection); + $this->assertDatabaseHas('encrypted_casts', [ + 'id' => $subject->id, + 'secret_collection' => null, + ]); + + $this->assertNull($subject->fresh()->secret_collection); + } + + public function testAsEncryptedCollectionMap() + { + $this->encrypter->expects('encryptString') + ->twice() + ->with('[{"key1":"value1"}]') + ->andReturn('encrypted-secret-collection-string-1'); + $this->encrypter->expects('encryptString') + ->times(12) + ->with('[{"key1":"value1"},{"key2":"value2"}]') + ->andReturn('encrypted-secret-collection-string-2'); + $this->encrypter->expects('decryptString') + ->once() + ->with('encrypted-secret-collection-string-2') + ->andReturn('[{"key1":"value1"},{"key2":"value2"}]'); + + $subject = new EncryptedCast(); + + $subject->mergeCasts(['secret_collection' => AsEncryptedCollection::of(Fluent::class)]); + + $subject->secret_collection = new Collection([new Fluent(['key1' => 'value1'])]); + $subject->secret_collection->push(new Fluent(['key2' => 'value2'])); + + $subject->save(); + + $this->assertInstanceOf(Collection::class, $subject->secret_collection); + $this->assertInstanceOf(Fluent::class, $subject->secret_collection->first()); + $this->assertSame('value1', $subject->secret_collection->get(0)->key1); + $this->assertSame('value2', $subject->secret_collection->get(1)->key2); + $this->assertDatabaseHas('encrypted_casts', [ + 'id' => $subject->id, + 'secret_collection' => 'encrypted-secret-collection-string-2', + ]); + + $subject = $subject->fresh(); + + $this->assertInstanceOf(Collection::class, $subject->secret_collection); + $this->assertInstanceOf(Fluent::class, $subject->secret_collection->first()); + $this->assertSame('value1', $subject->secret_collection->get(0)->key1); + $this->assertSame('value2', $subject->secret_collection->get(1)->key2); + + $subject->secret_collection = null; + $subject->save(); + + $this->assertNull($subject->secret_collection); + $this->assertDatabaseHas('encrypted_casts', [ + 'id' => $subject->id, + 'secret_collection' => null, + ]); + + $this->assertNull($subject->fresh()->secret_collection); + } + + public function testAsEncryptedArrayObject() + { + $this->encrypter->expects('encryptString') + ->once() + ->with('{"key1":"value1"}') + ->andReturn('encrypted-secret-array-string-1'); + $this->encrypter->expects('decryptString') + ->once() + ->with('encrypted-secret-array-string-1') + ->andReturn('{"key1":"value1"}'); + $this->encrypter->expects('encryptString') + ->times(10) + ->with('{"key1":"value1","key2":"value2"}') + ->andReturn('encrypted-secret-array-string-2'); + $this->encrypter->expects('decryptString') + ->once() + ->with('encrypted-secret-array-string-2') + ->andReturn('{"key1":"value1","key2":"value2"}'); + + $subject = new EncryptedCast(); + + $subject->mergeCasts(['secret_array' => AsEncryptedArrayObject::class]); + + $subject->secret_array = ['key1' => 'value1']; + $subject->secret_array['key2'] = 'value2'; + + $subject->save(); + + $this->assertInstanceOf(ArrayObject::class, $subject->secret_array); + $this->assertSame('value1', $subject->secret_array['key1']); + $this->assertSame('value2', $subject->secret_array['key2']); + $this->assertDatabaseHas('encrypted_casts', [ + 'id' => $subject->id, + 'secret_array' => 'encrypted-secret-array-string-2', + ]); + + $subject = $subject->fresh(); + + $this->assertInstanceOf(ArrayObject::class, $subject->secret_array); + $this->assertSame('value1', $subject->secret_array['key1']); + $this->assertSame('value2', $subject->secret_array['key2']); + + $subject->secret_array = null; + $subject->save(); + + $this->assertNull($subject->secret_array); + $this->assertDatabaseHas('encrypted_casts', [ + 'id' => $subject->id, + 'secret_array' => null, + ]); + + $this->assertNull($subject->fresh()->secret_array); + } + + public function testCustomEncrypterCanBeSpecified() + { + $customEncrypter = $this->mock(Encrypter::class); + + $this->assertNull(Model::$encrypter); + + Model::encryptUsing($customEncrypter); + + $this->assertSame($customEncrypter, Model::$encrypter); + + $this->encrypter->expects('encrypt') + ->never(); + $this->encrypter->expects('decrypt') + ->never(); + $customEncrypter->expects('encrypt') + ->with('this is a secret string', false) + ->andReturn('encrypted-secret-string'); + $customEncrypter->expects('decrypt') + ->with('encrypted-secret-string', false) + ->andReturn('this is a secret string'); + + /** @var EncryptedCast $subject */ + $subject = EncryptedCast::create([ + 'secret' => 'this is a secret string', + ]); + + $this->assertSame('this is a secret string', $subject->secret); + $this->assertDatabaseHas('encrypted_casts', [ + 'id' => $subject->id, + 'secret' => 'encrypted-secret-string', + ]); + } +} + +/** + * @property $secret + * @property $secret_array + * @property $secret_json + * @property $secret_object + * @property $secret_collection + */ +class EncryptedCast extends Model +{ + public bool $timestamps = false; + + protected array $guarded = []; + + public array $casts = [ + 'secret' => 'encrypted', + 'secret_array' => 'encrypted:array', + 'secret_json' => 'encrypted:json', + 'secret_object' => 'encrypted:object', + 'secret_collection' => 'encrypted:collection', + ]; +} diff --git a/tests/Integration/Database/Laravel/EloquentModelEncryptedDirtyTest.php b/tests/Integration/Database/Laravel/EloquentModelEncryptedDirtyTest.php new file mode 100644 index 000000000..79f8ca54d --- /dev/null +++ b/tests/Integration/Database/Laravel/EloquentModelEncryptedDirtyTest.php @@ -0,0 +1,98 @@ + str_repeat('a', 32)]); + Model::$encrypter = null; + + $model = new EncryptedDirtyAttributeCast([ + 'secret' => 'some-secret', + 'secret_array_object' => [1, 2, 3], + ]); + + $model->syncOriginal(); + + $this->assertFalse($model->isDirty('secret')); + $this->assertFalse($model->isDirty('secret_array_object')); + + $model->secret = 'some-secret'; + $model->secret_array_object = [1, 2, 3]; + + // Encrypted attributes should always be considered dirty if updated in any way because of rotatable encryption keys... + $this->assertFalse($model->isDirty('secret')); + $this->assertFalse($model->isDirty('secret_array_object')); + + $model->secret = 'some-other-secret'; + $model->secret_array_object = [4, 5, 6]; + + // Encrypted attributes should always be considered dirty if updated in any way because of rotatable encryption keys... + $this->assertTrue($model->isDirty('secret')); + $this->assertTrue($model->isDirty('secret_array_object')); + } + + public function testDirtyAttributeBehaviorWithPreviousKeys() + { + config(['app.key' => str_repeat('a', 32)]); + config(['app.previous_keys' => [str_repeat('b', 32)]]); + Model::$encrypter = null; + + $model = new EncryptedDirtyAttributeCast([ + 'secret' => 'some-secret', + 'secret_array_object' => [1, 2, 3], + ]); + + $model->syncOriginal(); + + $this->assertFalse($model->isDirty('secret')); + $this->assertFalse($model->isDirty('secret_array_object')); + + $model->secret = 'some-secret'; + $model->secret_array_object = [1, 2, 3]; + + // Encrypted attributes should always be considered dirty if updated in any way because of rotatable encryption keys... + $this->assertTrue($model->isDirty('secret')); + $this->assertTrue($model->isDirty('secret_array_object')); + + $model->secret = 'some-other-secret'; + $model->secret_array_object = [4, 5, 6]; + + // Encrypted attributes should always be considered dirty if updated in any way because of rotatable encryption keys... + $this->assertTrue($model->isDirty('secret')); + $this->assertTrue($model->isDirty('secret_array_object')); + } +} + +/** + * @property $secret + * @property $secret_array + * @property $secret_json + * @property $secret_object + * @property $secret_collection + */ +class EncryptedDirtyAttributeCast extends Model +{ + protected array $guarded = []; + + public array $casts = [ + 'secret' => 'encrypted', + 'secret_array' => 'encrypted:array', + 'secret_json' => 'encrypted:json', + 'secret_object' => 'encrypted:object', + 'secret_collection' => 'encrypted:collection', + 'secret_array_object' => AsEncryptedArrayObject::class, + ]; +} diff --git a/tests/Integration/Database/Laravel/EloquentModelEnumCastingTest.php b/tests/Integration/Database/Laravel/EloquentModelEnumCastingTest.php new file mode 100644 index 000000000..a5534514e --- /dev/null +++ b/tests/Integration/Database/Laravel/EloquentModelEnumCastingTest.php @@ -0,0 +1,365 @@ +increments('id'); + $table->string('string_status', 100)->nullable(); + $table->json('string_status_collection')->nullable(); + $table->json('string_status_array')->nullable(); + $table->integer('integer_status')->nullable(); + $table->json('integer_status_collection')->nullable(); + $table->json('integer_status_array')->nullable(); + $table->string('arrayable_status')->nullable(); + }); + + Schema::create('unique_enum_casts', function (Blueprint $table) { + $table->increments('id'); + $table->string('string_status', 100)->unique(); + }); + } + + public function testEnumsAreCastable() + { + DB::table('enum_casts')->insert([ + 'string_status' => 'pending', + 'string_status_collection' => json_encode(['pending', 'done']), + 'string_status_array' => json_encode(['pending', 'done']), + 'integer_status' => 1, + 'integer_status_collection' => json_encode([1, 2]), + 'integer_status_array' => json_encode([1, 2]), + 'arrayable_status' => 'pending', + ]); + + $model = EloquentModelEnumCastingTestModel::first(); + + $this->assertEquals(StringStatus::pending, $model->string_status); + $this->assertEquals([StringStatus::pending, StringStatus::done], $model->string_status_collection->all()); + $this->assertEquals([StringStatus::pending, StringStatus::done], $model->string_status_array->toArray()); + $this->assertEquals(IntegerStatus::pending, $model->integer_status); + $this->assertEquals([IntegerStatus::pending, IntegerStatus::done], $model->integer_status_collection->all()); + $this->assertEquals([IntegerStatus::pending, IntegerStatus::done], $model->integer_status_array->toArray()); + $this->assertEquals(ArrayableStatus::pending, $model->arrayable_status); + } + + public function testEnumsReturnNullWhenNull() + { + DB::table('enum_casts')->insert([ + 'string_status' => null, + 'string_status_collection' => null, + 'string_status_array' => null, + 'integer_status' => null, + 'integer_status_collection' => null, + 'integer_status_array' => null, + 'arrayable_status' => null, + ]); + + $model = EloquentModelEnumCastingTestModel::first(); + + $this->assertEquals(null, $model->string_status); + $this->assertEquals(null, $model->string_status_collection); + $this->assertEquals(null, $model->string_status_array); + $this->assertEquals(null, $model->integer_status); + $this->assertEquals(null, $model->integer_status_collection); + $this->assertEquals(null, $model->integer_status_array); + $this->assertEquals(null, $model->arrayable_status); + } + + public function testEnumsAreCastableToArray() + { + $model = new EloquentModelEnumCastingTestModel([ + 'string_status' => StringStatus::pending, + 'string_status_collection' => [StringStatus::pending, StringStatus::done], + 'string_status_array' => [StringStatus::pending, StringStatus::done], + 'integer_status' => IntegerStatus::pending, + 'integer_status_collection' => [IntegerStatus::pending, IntegerStatus::done], + 'integer_status_array' => [IntegerStatus::pending, IntegerStatus::done], + 'arrayable_status' => ArrayableStatus::pending, + ]); + + $this->assertEquals([ + 'string_status' => 'pending', + 'string_status_collection' => ['pending', 'done'], + 'string_status_array' => ['pending', 'done'], + 'integer_status' => 1, + 'integer_status_collection' => [1, 2], + 'integer_status_array' => [1, 2], + 'arrayable_status' => [ + 'name' => 'pending', + 'value' => 'pending', + 'description' => 'pending status description', + ], + ], $model->toArray()); + } + + public function testEnumsAreCastableToArrayWhenNull() + { + $model = new EloquentModelEnumCastingTestModel([ + 'string_status' => null, + 'string_status_collection' => null, + 'string_status_array' => null, + 'integer_status' => null, + 'integer_status_collection' => null, + 'integer_status_array' => null, + 'arrayable_status' => null, + ]); + + $this->assertEquals([ + 'string_status' => null, + 'string_status_collection' => null, + 'string_status_array' => null, + 'integer_status' => null, + 'integer_status_collection' => null, + 'integer_status_array' => null, + 'arrayable_status' => null, + ], $model->toArray()); + } + + public function testEnumsAreConvertedOnSave() + { + $model = new EloquentModelEnumCastingTestModel([ + 'string_status' => StringStatus::pending, + 'string_status_collection' => [StringStatus::pending, StringStatus::done], + 'string_status_array' => [StringStatus::pending, StringStatus::done], + 'integer_status' => IntegerStatus::pending, + 'integer_status_collection' => [IntegerStatus::pending, IntegerStatus::done], + 'integer_status_array' => [IntegerStatus::pending, IntegerStatus::done], + 'arrayable_status' => ArrayableStatus::pending, + ]); + + $model->save(); + + $this->assertEquals([ + 'id' => $model->id, + 'string_status' => 'pending', + 'string_status_collection' => json_encode(['pending', 'done']), + 'string_status_array' => json_encode(['pending', 'done']), + 'integer_status' => 1, + 'integer_status_collection' => json_encode([1, 2]), + 'integer_status_array' => json_encode([1, 2]), + 'arrayable_status' => 'pending', + ], collect(DB::table('enum_casts')->where('id', $model->id)->first())->map(function ($value) { + return is_string($value) ? str_replace(', ', ',', $value) : $value; + })->all()); + } + + public function testEnumsAreNotConvertedOnSaveWhenAlreadyCorrect() + { + $model = new EloquentModelEnumCastingTestModel([ + 'string_status' => 'pending', + 'string_status_collection' => ['pending', 'done'], + 'string_status_array' => ['pending', 'done'], + 'integer_status' => 1, + 'integer_status_collection' => [1, 2], + 'integer_status_array' => [1, 2], + 'arrayable_status' => 'pending', + ]); + + $model->save(); + + $this->assertEquals([ + 'id' => $model->id, + 'string_status' => 'pending', + 'string_status_collection' => json_encode(['pending', 'done']), + 'string_status_array' => json_encode(['pending', 'done']), + 'integer_status' => 1, + 'integer_status_collection' => json_encode([1, 2]), + 'integer_status_array' => json_encode([1, 2]), + 'arrayable_status' => 'pending', + ], collect(DB::table('enum_casts')->where('id', $model->id)->first())->map(function ($value) { + return is_string($value) ? str_replace(', ', ',', $value) : $value; + })->all()); + } + + public function testEnumsAcceptNullOnSave() + { + $model = new EloquentModelEnumCastingTestModel([ + 'string_status' => null, + 'string_status_collection' => null, + 'string_status_array' => null, + 'integer_status' => null, + 'integer_status_collection' => null, + 'integer_status_array' => null, + 'arrayable_status' => null, + ]); + + $model->save(); + + $this->assertEquals((object) [ + 'id' => $model->id, + 'string_status' => null, + 'string_status_collection' => null, + 'string_status_array' => null, + 'integer_status' => null, + 'integer_status_collection' => null, + 'integer_status_array' => null, + 'arrayable_status' => null, + ], DB::table('enum_casts')->where('id', $model->id)->first()); + } + + public function testEnumsAcceptBackedValueOnSave() + { + $model = new EloquentModelEnumCastingTestModel([ + 'string_status' => 'pending', + 'integer_status' => 1, + 'arrayable_status' => 'pending', + ]); + + $model->save(); + + $model = EloquentModelEnumCastingTestModel::first(); + + $this->assertEquals(StringStatus::pending, $model->string_status); + $this->assertEquals(IntegerStatus::pending, $model->integer_status); + $this->assertEquals(ArrayableStatus::pending, $model->arrayable_status); + } + + public function testFirstOrNew() + { + DB::table('enum_casts')->insert([ + 'string_status' => 'pending', + 'integer_status' => 1, + 'arrayable_status' => 'pending', + ]); + + $model = EloquentModelEnumCastingTestModel::firstOrNew([ + 'string_status' => StringStatus::pending, + ]); + + $model2 = EloquentModelEnumCastingTestModel::firstOrNew([ + 'string_status' => StringStatus::done, + ]); + + $this->assertTrue($model->exists); + $this->assertFalse($model2->exists); + + $model2->save(); + + $this->assertEquals(StringStatus::done, $model2->string_status); + } + + public function testFirstOrCreate() + { + DB::table('enum_casts')->insert([ + 'string_status' => 'pending', + 'integer_status' => 1, + ]); + + $model = EloquentModelEnumCastingTestModel::firstOrCreate([ + 'string_status' => StringStatus::pending, + ]); + + $model2 = EloquentModelEnumCastingTestModel::firstOrCreate([ + 'string_status' => StringStatus::done, + ]); + + $this->assertEquals(StringStatus::pending, $model->string_status); + $this->assertEquals(StringStatus::done, $model2->string_status); + } + + public function testAttributeCastToAnEnumCanNotBeSetToAnotherEnum(): void + { + $model = new EloquentModelEnumCastingTestModel(); + + $this->expectException(ValueError::class); + $this->expectExceptionMessage( + sprintf('Value [%s] is not of the expected enum type [%s].', var_export(ArrayableStatus::pending, true), StringStatus::class) + ); + + $model->string_status = ArrayableStatus::pending; + } + + public function testAttributeCastToAnEnumCanNotBeSetToAValueNotDefinedOnTheEnum(): void + { + $model = new EloquentModelEnumCastingTestModel(); + + $this->expectException(ValueError::class); + $this->expectExceptionMessage( + sprintf('"unexpected_value" is not a valid backing value for enum %s', StringStatus::class) + ); + + $model->string_status = 'unexpected_value'; + } + + public function testAnAttributeWithoutACastCanBeSetToAnEnum(): void + { + $model = new EloquentModelEnumCastingTestModel(); + + $model->non_enum_status = StringStatus::pending; + + $this->assertEquals(StringStatus::pending, $model->non_enum_status); + } + + public function testCreateOrFirst() + { + $model1 = EloquentModelEnumCastingUniqueTestModel::createOrFirst([ + 'string_status' => StringStatus::pending, + ]); + + $model2 = EloquentModelEnumCastingUniqueTestModel::createOrFirst([ + 'string_status' => StringStatus::pending, + ]); + + $model3 = EloquentModelEnumCastingUniqueTestModel::createOrFirst([ + 'string_status' => StringStatus::done, + ]); + + $this->assertEquals(StringStatus::pending, $model1->string_status); + $this->assertEquals(StringStatus::pending, $model2->string_status); + $this->assertTrue($model1->is($model2)); + $this->assertEquals(StringStatus::done, $model3->string_status); + } +} + +class EloquentModelEnumCastingTestModel extends Model +{ + public bool $timestamps = false; + + protected array $guarded = []; + + protected ?string $table = 'enum_casts'; + + public array $casts = [ + 'string_status' => StringStatus::class, + 'string_status_collection' => AsEnumCollection::class . ':' . StringStatus::class, + 'string_status_array' => AsEnumArrayObject::class . ':' . StringStatus::class, + 'integer_status' => IntegerStatus::class, + 'integer_status_collection' => AsEnumCollection::class . ':' . IntegerStatus::class, + 'integer_status_array' => AsEnumArrayObject::class . ':' . IntegerStatus::class, + 'arrayable_status' => ArrayableStatus::class, + ]; +} + +class EloquentModelEnumCastingUniqueTestModel extends Model +{ + public bool $timestamps = false; + + protected array $guarded = []; + + protected ?string $table = 'unique_enum_casts'; + + public array $casts = [ + 'string_status' => StringStatus::class, + ]; +} diff --git a/tests/Integration/Database/Laravel/EloquentModelHashedCastingTest.php b/tests/Integration/Database/Laravel/EloquentModelHashedCastingTest.php new file mode 100644 index 000000000..9b2c7f27f --- /dev/null +++ b/tests/Integration/Database/Laravel/EloquentModelHashedCastingTest.php @@ -0,0 +1,328 @@ +increments('id'); + $table->string('password')->nullable(); + }); + } + + public function testHashedWithBcrypt() + { + Config::set('hashing.driver', 'bcrypt'); + Config::set('hashing.bcrypt.rounds', 13); + + $subject = HashedCast::create([ + 'password' => 'password', + ]); + + $this->assertTrue(password_verify('password', $subject->password)); + $this->assertSame('2y', password_get_info($subject->password)['algo']); + $this->assertSame(13, password_get_info($subject->password)['options']['cost']); + $this->assertDatabaseHas('hashed_casts', [ + 'id' => $subject->id, + 'password' => $subject->password, + ]); + } + + public function testNotHashedIfAlreadyHashedWithBcrypt() + { + Config::set('hashing.driver', 'bcrypt'); + Config::set('hashing.bcrypt.rounds', 13); + + $subject = HashedCast::create([ + // "password"; 13 rounds; bcrypt; + 'password' => '$2y$13$Hdxlvi7OZqK3/fKVNypJs.vJqQcmOo3HnnT6w7fec9FRTRYxAhuCO', + ]); + + $this->assertSame('$2y$13$Hdxlvi7OZqK3/fKVNypJs.vJqQcmOo3HnnT6w7fec9FRTRYxAhuCO', $subject->password); + $this->assertDatabaseHas('hashed_casts', [ + 'id' => $subject->id, + 'password' => '$2y$13$Hdxlvi7OZqK3/fKVNypJs.vJqQcmOo3HnnT6w7fec9FRTRYxAhuCO', + ]); + } + + public function testNotHashedIfNullWithBrcypt() + { + Config::set('hashing.driver', 'bcrypt'); + Config::set('hashing.bcrypt.rounds', 13); + + $subject = HashedCast::create([ + 'password' => null, + ]); + + $this->assertNull($subject->password); + $this->assertDatabaseHas('hashed_casts', [ + 'id' => $subject->id, + 'password' => null, + ]); + } + + public function testPassingHashWithHigherCostThrowsExceptionWithBcrypt() + { + Config::set('hashing.driver', 'bcrypt'); + Config::set('hashing.bcrypt.rounds', 10); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage("Could not verify the hashed value's configuration."); + + $subject = HashedCast::create([ + // "password"; 13 rounds; bcrypt; + 'password' => '$2y$13$Hdxlvi7OZqK3/fKVNypJs.vJqQcmOo3HnnT6w7fec9FRTRYxAhuCO', + ]); + } + + public function testPassingHashWithLowerCostDoesNotThrowExceptionWithBcrypt() + { + Config::set('hashing.driver', 'bcrypt'); + Config::set('hashing.bcrypt.rounds', 13); + + $subject = HashedCast::create([ + // "password"; 7 rounds; bcrypt; + 'password' => '$2y$07$Ivc2VnUOUFtfdbXFc/Ysu.PgiwAHkDmbZQNR1OpIjKCxTxEfWLP5y', + ]); + + $this->assertSame('$2y$07$Ivc2VnUOUFtfdbXFc/Ysu.PgiwAHkDmbZQNR1OpIjKCxTxEfWLP5y', $subject->password); + $this->assertDatabaseHas('hashed_casts', [ + 'id' => $subject->id, + 'password' => '$2y$07$Ivc2VnUOUFtfdbXFc/Ysu.PgiwAHkDmbZQNR1OpIjKCxTxEfWLP5y', + ]); + } + + public function testPassingDifferentHashAlgorithmThrowsExceptionWithBcrypt() + { + Config::set('hashing.driver', 'bcrypt'); + Config::set('hashing.bcrypt.rounds', 13); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage("Could not verify the hashed value's configuration."); + + $subject = HashedCast::create([ + // "password"; argon2id; + 'password' => '$argon2i$v=19$m=1024,t=2,p=2$OENON0I5bXo2WDQyQnM2bg$3ma8cKHITsmAjyIYKDLdSvtkMCiEz/s6qWnLAf+Ehek', + ]); + } + + public function testHashedWithArgon() + { + Config::set('hashing.driver', 'argon'); + Config::set('hashing.argon.memory', 1234); + Config::set('hashing.argon.threads', 2); + Config::set('hashing.argon.time', 7); + + $subject = HashedCast::create([ + 'password' => 'password', + ]); + + $this->assertTrue(password_verify('password', $subject->password)); + $this->assertSame('argon2i', password_get_info($subject->password)['algo']); + $this->assertSame(1234, password_get_info($subject->password)['options']['memory_cost']); + $this->assertSame(2, password_get_info($subject->password)['options']['threads']); + $this->assertSame(7, password_get_info($subject->password)['options']['time_cost']); + $this->assertDatabaseHas('hashed_casts', [ + 'id' => $subject->id, + 'password' => $subject->password, + ]); + } + + public function testNotHashedIfAlreadyHashedWithArgon() + { + Config::set('hashing.driver', 'argon'); + Config::set('hashing.argon.memory', 1234); + Config::set('hashing.argon.threads', 2); + Config::set('hashing.argon.time', 7); + + $subject = HashedCast::create([ + // "password"; 1234 memory; 2 threads; 7 time; argon2i; + 'password' => '$argon2i$v=19$m=1234,t=7,p=2$Lm9vSkJuU3M1SllaaTNwZA$5izrDfbWtpkSBH9EczQ8U1yjSOvAkhE4AuYrbBHwi5k', + ]); + + $this->assertSame('$argon2i$v=19$m=1234,t=7,p=2$Lm9vSkJuU3M1SllaaTNwZA$5izrDfbWtpkSBH9EczQ8U1yjSOvAkhE4AuYrbBHwi5k', $subject->password); + $this->assertDatabaseHas('hashed_casts', [ + 'id' => $subject->id, + 'password' => '$argon2i$v=19$m=1234,t=7,p=2$Lm9vSkJuU3M1SllaaTNwZA$5izrDfbWtpkSBH9EczQ8U1yjSOvAkhE4AuYrbBHwi5k', + ]); + } + + public function testNotHashedIfNullWithArgon() + { + Config::set('hashing.driver', 'argon'); + Config::set('hashing.argon.memory', 1234); + Config::set('hashing.argon.threads', 2); + Config::set('hashing.argon.time', 7); + + $subject = HashedCast::create([ + 'password' => null, + ]); + + $this->assertNull($subject->password); + $this->assertDatabaseHas('hashed_casts', [ + 'id' => $subject->id, + 'password' => null, + ]); + } + + public function testPassingHashWithHigherMemoryThrowsExceptionWithArgon() + { + Config::set('hashing.driver', 'argon'); + Config::set('hashing.argon.memory', 1234); + Config::set('hashing.argon.threads', 2); + Config::set('hashing.argon.time', 7); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage("Could not verify the hashed value's configuration."); + + $subject = HashedCast::create([ + // "password"; 2345 memory; 2 threads; 7 time; argon2i; + 'password' => '$argon2i$v=19$m=2345,t=7,p=2$MWVVZnpiZHl5RkcveHovcA$QECQzuQ2aAKvUpD25cTUJaAyPFxlOIsCRu+5nbDsU3k', + ]); + } + + public function testPassingHashWithHigherTimeThrowsExceptionWithArgon() + { + Config::set('hashing.driver', 'argon'); + Config::set('hashing.argon.memory', 1234); + Config::set('hashing.argon.threads', 2); + Config::set('hashing.argon.time', 7); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage("Could not verify the hashed value's configuration."); + + $subject = HashedCast::create([ + // "password"; 1234 memory; 2 threads; 8 time; argon2i; + 'password' => '$argon2i$v=19$m=1234,t=8,p=2$LmszcGVHd0t6b3JweUxqTQ$sdY25X0Qe86fezr1cEjYQxAHI2SdN67yVs5x0ovffag', + ]); + } + + public function testPassingHashWithHigherThreadsThrowsExceptionWithArgon() + { + Config::set('hashing.driver', 'argon'); + Config::set('hashing.argon.memory', 1234); + Config::set('hashing.argon.threads', 2); + Config::set('hashing.argon.time', 7); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage("Could not verify the hashed value's configuration."); + + $subject = HashedCast::create([ + // "password"; 1234 memory; 3 threads; 7 time; argon2i; + 'password' => '$argon2i$v=19$m=1234,t=7,p=3$OFludXF6bzFpRmdpSHdwSA$J1P4dCGJde6mYe2RZEOFWaztBbDWfxQAM09ZQRMjsw8', + ]); + } + + public function testPassingHashWithLowerMemoryThrowsExceptionWithArgon() + { + Config::set('hashing.driver', 'argon'); + Config::set('hashing.argon.memory', 3456); + Config::set('hashing.argon.threads', 2); + Config::set('hashing.argon.time', 7); + + $subject = HashedCast::create([ + // "password"; 2345 memory; 2 threads; 7 time; argon2i; + 'password' => '$argon2i$v=19$m=2345,t=7,p=2$MWVVZnpiZHl5RkcveHovcA$QECQzuQ2aAKvUpD25cTUJaAyPFxlOIsCRu+5nbDsU3k', + ]); + + $this->assertSame('$argon2i$v=19$m=2345,t=7,p=2$MWVVZnpiZHl5RkcveHovcA$QECQzuQ2aAKvUpD25cTUJaAyPFxlOIsCRu+5nbDsU3k', $subject->password); + $this->assertDatabaseHas('hashed_casts', [ + 'id' => $subject->id, + 'password' => '$argon2i$v=19$m=2345,t=7,p=2$MWVVZnpiZHl5RkcveHovcA$QECQzuQ2aAKvUpD25cTUJaAyPFxlOIsCRu+5nbDsU3k', + ]); + } + + public function testPassingHashWithLowerTimeThrowsExceptionWithArgon() + { + Config::set('hashing.driver', 'argon'); + Config::set('hashing.argon.memory', 2345); + Config::set('hashing.argon.threads', 2); + Config::set('hashing.argon.time', 8); + + $subject = HashedCast::create([ + // "password"; 2345 memory; 2 threads; 7 time; argon2i; + 'password' => '$argon2i$v=19$m=2345,t=7,p=2$MWVVZnpiZHl5RkcveHovcA$QECQzuQ2aAKvUpD25cTUJaAyPFxlOIsCRu+5nbDsU3k', + ]); + + $this->assertSame('$argon2i$v=19$m=2345,t=7,p=2$MWVVZnpiZHl5RkcveHovcA$QECQzuQ2aAKvUpD25cTUJaAyPFxlOIsCRu+5nbDsU3k', $subject->password); + $this->assertDatabaseHas('hashed_casts', [ + 'id' => $subject->id, + 'password' => '$argon2i$v=19$m=2345,t=7,p=2$MWVVZnpiZHl5RkcveHovcA$QECQzuQ2aAKvUpD25cTUJaAyPFxlOIsCRu+5nbDsU3k', + ]); + } + + public function testPassingHashWithLowerThreadsThrowsExceptionWithArgon() + { + Config::set('hashing.driver', 'argon'); + Config::set('hashing.argon.memory', 2345); + Config::set('hashing.argon.threads', 3); + Config::set('hashing.argon.time', 7); + + $subject = HashedCast::create([ + // "password"; 2345 memory; 2 threads; 7 time; argon2i; + 'password' => '$argon2i$v=19$m=2345,t=7,p=2$MWVVZnpiZHl5RkcveHovcA$QECQzuQ2aAKvUpD25cTUJaAyPFxlOIsCRu+5nbDsU3k', + ]); + + $this->assertSame('$argon2i$v=19$m=2345,t=7,p=2$MWVVZnpiZHl5RkcveHovcA$QECQzuQ2aAKvUpD25cTUJaAyPFxlOIsCRu+5nbDsU3k', $subject->password); + $this->assertDatabaseHas('hashed_casts', [ + 'id' => $subject->id, + 'password' => '$argon2i$v=19$m=2345,t=7,p=2$MWVVZnpiZHl5RkcveHovcA$QECQzuQ2aAKvUpD25cTUJaAyPFxlOIsCRu+5nbDsU3k', + ]); + } + + public function testPassingDifferentHashAlgorithmThrowsExceptionWithArgonAndBcrypt() + { + Config::set('hashing.driver', 'argon'); + Config::set('hashing.bcrypt.rounds', 13); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage("Could not verify the hashed value's configuration."); + + $subject = HashedCast::create([ + // "password"; bcrypt; + 'password' => '$2y$13$Hdxlvi7OZqK3/fKVNypJs.vJqQcmOo3HnnT6w7fec9FRTRYxAhuCO', + ]); + } + + public function testPassingDifferentHashAlgorithmThrowsExceptionWithArgon2idAndBcrypt() + { + Config::set('hashing.driver', 'argon2id'); + Config::set('hashing.argon.memory', 2345); + Config::set('hashing.argon.threads', 2); + Config::set('hashing.argon.time', 7); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage("Could not verify the hashed value's configuration."); + + $subject = HashedCast::create([ + // "password"; 2345 memory; 2 threads; 7 time; argon2i; + 'password' => '$argon2i$v=19$m=2345,t=7,p=2$MWVVZnpiZHl5RkcveHovcA$QECQzuQ2aAKvUpD25cTUJaAyPFxlOIsCRu+5nbDsU3k', + ]); + } +} + +class HashedCast extends Model +{ + public bool $timestamps = false; + + protected array $guarded = []; + + public array $casts = [ + 'password' => 'hashed', + ]; +} diff --git a/tests/Integration/Database/Laravel/EloquentModelImmutableDateCastingTest.php b/tests/Integration/Database/Laravel/EloquentModelImmutableDateCastingTest.php new file mode 100644 index 000000000..57c5bb24d --- /dev/null +++ b/tests/Integration/Database/Laravel/EloquentModelImmutableDateCastingTest.php @@ -0,0 +1,81 @@ +increments('id'); + $table->date('date_field')->nullable(); + $table->datetime('datetime_field')->nullable(); + }); + } + + public function testDatesAreImmutableCastable() + { + $model = TestModelImmutable::create([ + 'date_field' => '2019-10-01', + 'datetime_field' => '2019-10-01 10:15:20', + ]); + + $this->assertSame('2019-10-01T00:00:00.000000Z', $model->toArray()['date_field']); + $this->assertSame('2019-10-01T10:15:20.000000Z', $model->toArray()['datetime_field']); + $this->assertInstanceOf(CarbonImmutable::class, $model->date_field); + $this->assertInstanceOf(CarbonImmutable::class, $model->datetime_field); + } + + public function testDatesAreImmutableAndCustomCastable() + { + $model = TestModelCustomImmutable::create([ + 'date_field' => '2019-10-01', + 'datetime_field' => '2019-10-01 10:15:20', + ]); + + $this->assertSame('2019-10', $model->toArray()['date_field']); + $this->assertSame('2019-10 10:15', $model->toArray()['datetime_field']); + $this->assertInstanceOf(CarbonImmutable::class, $model->date_field); + $this->assertInstanceOf(CarbonImmutable::class, $model->datetime_field); + } +} + +class TestModelImmutable extends Model +{ + public ?string $table = 'test_model_immutable'; + + public bool $timestamps = false; + + protected array $guarded = []; + + public array $casts = [ + 'date_field' => 'immutable_date', + 'datetime_field' => 'immutable_datetime', + ]; +} + +class TestModelCustomImmutable extends Model +{ + public ?string $table = 'test_model_immutable'; + + public bool $timestamps = false; + + protected array $guarded = []; + + public array $casts = [ + 'date_field' => 'immutable_date:Y-m', + 'datetime_field' => 'immutable_datetime:Y-m H:i', + ]; +} diff --git a/tests/Integration/Database/Laravel/EloquentModelJsonCastingTest.php b/tests/Integration/Database/Laravel/EloquentModelJsonCastingTest.php new file mode 100644 index 000000000..6c8c42768 --- /dev/null +++ b/tests/Integration/Database/Laravel/EloquentModelJsonCastingTest.php @@ -0,0 +1,102 @@ +increments('id'); + $table->json('basic_string_as_json_field')->nullable(); + $table->json('json_string_as_json_field')->nullable(); + $table->json('array_as_json_field')->nullable(); + $table->json('object_as_json_field')->nullable(); + $table->json('collection_as_json_field')->nullable(); + }); + } + + public function testStringsAreCastable() + { + /** @var JsonCast $object */ + $object = JsonCast::create([ + 'basic_string_as_json_field' => 'this is a string', + 'json_string_as_json_field' => '{"key1":"value1"}', + ]); + + $this->assertSame('this is a string', $object->basic_string_as_json_field); + $this->assertSame('{"key1":"value1"}', $object->json_string_as_json_field); + } + + public function testArraysAreCastable() + { + /** @var JsonCast $object */ + $object = JsonCast::create([ + 'array_as_json_field' => ['key1' => 'value1'], + ]); + + $this->assertEquals(['key1' => 'value1'], $object->array_as_json_field); + } + + public function testObjectsAreCastable() + { + $object = new stdClass(); + $object->key1 = 'value1'; + + /** @var JsonCast $user */ + $user = JsonCast::create([ + 'object_as_json_field' => $object, + ]); + + $this->assertInstanceOf(stdClass::class, $user->object_as_json_field); + $this->assertSame('value1', $user->object_as_json_field->key1); + } + + public function testCollectionsAreCastable() + { + /** @var JsonCast $user */ + $user = JsonCast::create([ + 'collection_as_json_field' => new Collection(['key1' => 'value1']), + ]); + + $this->assertInstanceOf(Collection::class, $user->collection_as_json_field); + $this->assertSame('value1', $user->collection_as_json_field->get('key1')); + } +} + +/** + * @property $basic_string_as_json_field + * @property $json_string_as_json_field + * @property $array_as_json_field + * @property $object_as_json_field + * @property $collection_as_json_field + */ +class JsonCast extends Model +{ + public ?string $table = 'json_casts'; + + public bool $timestamps = false; + + protected array $guarded = []; + + public array $casts = [ + 'basic_string_as_json_field' => 'json', + 'json_string_as_json_field' => 'json', + 'array_as_json_field' => 'array', + 'object_as_json_field' => 'object', + 'collection_as_json_field' => 'collection', + ]; +} diff --git a/tests/Integration/Database/Laravel/EloquentModelLoadCountTest.php b/tests/Integration/Database/Laravel/EloquentModelLoadCountTest.php new file mode 100644 index 000000000..2a8f81746 --- /dev/null +++ b/tests/Integration/Database/Laravel/EloquentModelLoadCountTest.php @@ -0,0 +1,155 @@ +increments('id'); + }); + + Schema::create('related1s', function (Blueprint $table) { + $table->increments('id'); + $table->unsignedInteger('base_model_id'); + }); + + Schema::create('related2s', function (Blueprint $table) { + $table->increments('id'); + $table->unsignedInteger('base_model_id'); + }); + + Schema::create('deleted_related', function (Blueprint $table) { + $table->increments('id'); + $table->unsignedInteger('base_model_id'); + $table->softDeletes(); + }); + + BaseModel::create(); + + Related1::create(['base_model_id' => 1]); + Related1::create(['base_model_id' => 1]); + Related2::create(['base_model_id' => 1]); + DeletedRelated::create(['base_model_id' => 1]); + } + + public function testLoadCountSingleRelation() + { + $model = BaseModel::first(); + + DB::enableQueryLog(); + + $model->loadCount('related1'); + + $this->assertCount(1, DB::getQueryLog()); + $this->assertEquals(2, $model->related1_count); + } + + public function testLoadCountMultipleRelations() + { + $model = BaseModel::first(); + + DB::enableQueryLog(); + + $model->loadCount(['related1', 'related2']); + + $this->assertCount(1, DB::getQueryLog()); + $this->assertEquals(2, $model->related1_count); + $this->assertEquals(1, $model->related2_count); + } + + public function testLoadCountDeletedRelations() + { + $model = BaseModel::first(); + + $this->assertNull($model->deletedrelated_count); + + $model->loadCount('deletedrelated'); + + $this->assertEquals(1, $model->deletedrelated_count); + + DeletedRelated::first()->delete(); + + $model = BaseModel::first(); + + $this->assertNull($model->deletedrelated_count); + + $model->loadCount('deletedrelated'); + + $this->assertEquals(0, $model->deletedrelated_count); + } +} + +class BaseModel extends Model +{ + public bool $timestamps = false; + + protected array $guarded = []; + + public function related1() + { + return $this->hasMany(Related1::class); + } + + public function related2() + { + return $this->hasMany(Related2::class); + } + + public function deletedrelated() + { + return $this->hasMany(DeletedRelated::class); + } +} + +class Related1 extends Model +{ + public bool $timestamps = false; + + protected array $fillable = ['base_model_id']; + + public function parent() + { + return $this->belongsTo(BaseModel::class); + } +} + +class Related2 extends Model +{ + public bool $timestamps = false; + + protected array $fillable = ['base_model_id']; + + public function parent() + { + return $this->belongsTo(BaseModel::class); + } +} + +class DeletedRelated extends Model +{ + use SoftDeletes; + + public bool $timestamps = false; + + protected array $fillable = ['base_model_id']; + + public function parent() + { + return $this->belongsTo(BaseModel::class); + } +} diff --git a/tests/Integration/Database/Laravel/EloquentModelLoadMaxTest.php b/tests/Integration/Database/Laravel/EloquentModelLoadMaxTest.php new file mode 100644 index 000000000..60a37b437 --- /dev/null +++ b/tests/Integration/Database/Laravel/EloquentModelLoadMaxTest.php @@ -0,0 +1,110 @@ +increments('id'); + }); + + Schema::create('related1s', function (Blueprint $table) { + $table->increments('id'); + $table->unsignedInteger('base_model_id'); + $table->integer('number'); + }); + + Schema::create('related2s', function (Blueprint $table) { + $table->increments('id'); + $table->unsignedInteger('base_model_id'); + $table->integer('number'); + }); + + BaseModel::create(); + + Related1::create(['base_model_id' => 1, 'number' => 10]); + Related1::create(['base_model_id' => 1, 'number' => 11]); + Related2::create(['base_model_id' => 1, 'number' => 12]); + Related2::create(['base_model_id' => 1, 'number' => 13]); + } + + public function testLoadMaxSingleRelation() + { + $model = BaseModel::first(); + + DB::enableQueryLog(); + + $model->loadMax('related1', 'number'); + + $this->assertCount(1, DB::getQueryLog()); + $this->assertEquals(11, $model->related1_max_number); + } + + public function testLoadMaxMultipleRelations() + { + $model = BaseModel::first(); + + DB::enableQueryLog(); + + $model->loadMax(['related1', 'related2'], 'number'); + + $this->assertCount(1, DB::getQueryLog()); + $this->assertEquals(11, $model->related1_max_number); + $this->assertEquals(13, $model->related2_max_number); + } +} + +class BaseModel extends Model +{ + public bool $timestamps = false; + + protected array $guarded = []; + + public function related1() + { + return $this->hasMany(Related1::class); + } + + public function related2() + { + return $this->hasMany(Related2::class); + } +} + +class Related1 extends Model +{ + public bool $timestamps = false; + + protected array $fillable = ['base_model_id', 'number']; + + public function parent() + { + return $this->belongsTo(BaseModel::class); + } +} + +class Related2 extends Model +{ + public bool $timestamps = false; + + protected array $fillable = ['base_model_id', 'number']; + + public function parent() + { + return $this->belongsTo(BaseModel::class); + } +} diff --git a/tests/Integration/Database/Laravel/EloquentModelLoadMinTest.php b/tests/Integration/Database/Laravel/EloquentModelLoadMinTest.php new file mode 100644 index 000000000..55a85a51f --- /dev/null +++ b/tests/Integration/Database/Laravel/EloquentModelLoadMinTest.php @@ -0,0 +1,110 @@ +increments('id'); + }); + + Schema::create('related1s', function (Blueprint $table) { + $table->increments('id'); + $table->unsignedInteger('base_model_id'); + $table->integer('number'); + }); + + Schema::create('related2s', function (Blueprint $table) { + $table->increments('id'); + $table->unsignedInteger('base_model_id'); + $table->integer('number'); + }); + + BaseModel::create(); + + Related1::create(['base_model_id' => 1, 'number' => 10]); + Related1::create(['base_model_id' => 1, 'number' => 11]); + Related2::create(['base_model_id' => 1, 'number' => 12]); + Related2::create(['base_model_id' => 1, 'number' => 13]); + } + + public function testLoadMinSingleRelation() + { + $model = BaseModel::first(); + + DB::enableQueryLog(); + + $model->loadMin('related1', 'number'); + + $this->assertCount(1, DB::getQueryLog()); + $this->assertEquals(10, $model->related1_min_number); + } + + public function testLoadMinMultipleRelations() + { + $model = BaseModel::first(); + + DB::enableQueryLog(); + + $model->loadMin(['related1', 'related2'], 'number'); + + $this->assertCount(1, DB::getQueryLog()); + $this->assertEquals(10, $model->related1_min_number); + $this->assertEquals(12, $model->related2_min_number); + } +} + +class BaseModel extends Model +{ + public bool $timestamps = false; + + protected array $guarded = []; + + public function related1() + { + return $this->hasMany(Related1::class); + } + + public function related2() + { + return $this->hasMany(Related2::class); + } +} + +class Related1 extends Model +{ + public bool $timestamps = false; + + protected array $fillable = ['base_model_id', 'number']; + + public function parent() + { + return $this->belongsTo(BaseModel::class); + } +} + +class Related2 extends Model +{ + public bool $timestamps = false; + + protected array $fillable = ['base_model_id', 'number']; + + public function parent() + { + return $this->belongsTo(BaseModel::class); + } +} diff --git a/tests/Integration/Database/Laravel/EloquentModelLoadMissingTest.php b/tests/Integration/Database/Laravel/EloquentModelLoadMissingTest.php new file mode 100644 index 000000000..dee9d61ef --- /dev/null +++ b/tests/Integration/Database/Laravel/EloquentModelLoadMissingTest.php @@ -0,0 +1,130 @@ +increments('id'); + $table->string('name'); + }); + + Schema::create('comment_mentions_users', function (Blueprint $table) { + $table->unsignedInteger('comment_id'); + $table->unsignedInteger('user_id'); + }); + + Schema::create('posts', function (Blueprint $table) { + $table->increments('id'); + $table->unsignedInteger('first_comment_id')->nullable(); + $table->string('content')->nullable(); + }); + + Schema::create('comments', function (Blueprint $table) { + $table->increments('id'); + $table->unsignedInteger('parent_id')->nullable(); + $table->unsignedInteger('post_id'); + $table->string('content')->nullable(); + }); + + Post::create(); + + Comment::create(['parent_id' => null, 'post_id' => 1, 'content' => 'Hello ']); + Comment::create(['parent_id' => 1, 'post_id' => 1]); + + User::create(['name' => 'Taylor']); + User::create(['name' => 'Otwell']); + + Comment::first()->mentionsUsers()->attach([1, 2]); + + Post::first()->update(['first_comment_id' => 1]); + } + + public function testLoadMissing() + { + $post = Post::with('comments')->first(); + + DB::enableQueryLog(); + + $post->loadMissing('comments.parent'); + + $this->assertCount(1, DB::getQueryLog()); + $this->assertTrue($post->comments[0]->relationLoaded('parent')); + } + + public function testLoadMissingNoUnnecessaryAttributeMutatorAccess() + { + $posts = Post::all(); + + DB::enableQueryLog(); + + $posts->loadMissing('firstComment.parent'); + + $this->assertCount(1, DB::getQueryLog()); + } +} + +class Comment extends Model +{ + public bool $timestamps = false; + + protected array $guarded = []; + + public function parent() + { + return $this->belongsTo(self::class); + } + + public function mentionsUsers() + { + return $this->belongsToMany(User::class, 'comment_mentions_users'); + } + + public function content(): Attribute + { + return new Attribute(function (?string $value) { + return preg_replace_callback('//', function ($matches) { + return '@' . $this->mentionsUsers->find($matches[1])?->name; + }, $value); + }); + } +} + +class Post extends Model +{ + public bool $timestamps = false; + + protected array $guarded = []; + + public function comments() + { + return $this->hasMany(Comment::class); + } + + public function firstComment() + { + return $this->belongsTo(Comment::class, 'first_comment_id'); + } +} + +class User extends Model +{ + public bool $timestamps = false; + + protected array $guarded = []; +} diff --git a/tests/Integration/Database/Laravel/EloquentModelLoadSumTest.php b/tests/Integration/Database/Laravel/EloquentModelLoadSumTest.php new file mode 100644 index 000000000..493c5ff11 --- /dev/null +++ b/tests/Integration/Database/Laravel/EloquentModelLoadSumTest.php @@ -0,0 +1,109 @@ +increments('id'); + }); + + Schema::create('related1s', function (Blueprint $table) { + $table->increments('id'); + $table->unsignedInteger('base_model_id'); + $table->integer('number'); + }); + + Schema::create('related2s', function (Blueprint $table) { + $table->increments('id'); + $table->unsignedInteger('base_model_id'); + $table->integer('number'); + }); + + BaseModel::create(); + + Related1::create(['base_model_id' => 1, 'number' => 10]); + Related1::create(['base_model_id' => 1, 'number' => 11]); + Related2::create(['base_model_id' => 1, 'number' => 12]); + } + + public function testLoadSumSingleRelation() + { + $model = BaseModel::first(); + + DB::enableQueryLog(); + + $model->loadSum('related1', 'number'); + + $this->assertCount(1, DB::getQueryLog()); + $this->assertEquals(21, $model->related1_sum_number); + } + + public function testLoadSumMultipleRelations() + { + $model = BaseModel::first(); + + DB::enableQueryLog(); + + $model->loadSum(['related1', 'related2'], 'number'); + + $this->assertCount(1, DB::getQueryLog()); + $this->assertEquals(21, $model->related1_sum_number); + $this->assertEquals(12, $model->related2_sum_number); + } +} + +class BaseModel extends Model +{ + public bool $timestamps = false; + + protected array $guarded = []; + + public function related1() + { + return $this->hasMany(Related1::class); + } + + public function related2() + { + return $this->hasMany(Related2::class); + } +} + +class Related1 extends Model +{ + public bool $timestamps = false; + + protected array $fillable = ['base_model_id', 'number']; + + public function parent() + { + return $this->belongsTo(BaseModel::class); + } +} + +class Related2 extends Model +{ + public bool $timestamps = false; + + protected array $fillable = ['base_model_id', 'number']; + + public function parent() + { + return $this->belongsTo(BaseModel::class); + } +} diff --git a/tests/Integration/Database/Laravel/EloquentModelRefreshTest.php b/tests/Integration/Database/Laravel/EloquentModelRefreshTest.php new file mode 100644 index 000000000..c1096df5d --- /dev/null +++ b/tests/Integration/Database/Laravel/EloquentModelRefreshTest.php @@ -0,0 +1,129 @@ +increments('id'); + $table->string('title'); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function testItRefreshesModelExcludedByGlobalScope() + { + $post = Post::create(['title' => 'mohamed']); + + $post->refresh(); + } + + public function testItRefreshesASoftDeletedModel() + { + $post = Post::create(['title' => 'said']); + + Post::find($post->id)->delete(); + + $this->assertFalse($post->trashed()); + + $post->refresh(); + + $this->assertTrue($post->trashed()); + } + + public function testItSyncsOriginalOnRefresh() + { + $post = Post::create(['title' => 'pat']); + + Post::find($post->id)->update(['title' => 'patrick']); + + $post->refresh(); + + $this->assertEmpty($post->getDirty()); + + $this->assertSame('patrick', $post->getOriginal('title')); + } + + public function testItDoesNotSyncPreviousOnRefresh() + { + $post = Post::create(['title' => 'pat']); + + Post::find($post->id)->update(['title' => 'patrick']); + + $post->refresh(); + + $this->assertEmpty($post->getDirty()); + $this->assertEmpty($post->getPrevious()); + } + + public function testAsPivot() + { + Schema::create('post_posts', function (Blueprint $table) { + $table->increments('id'); + $table->bigInteger('foreign_id'); + $table->bigInteger('related_id'); + }); + + $post = AsPivotPost::create(['title' => 'parent']); + $child = AsPivotPost::create(['title' => 'child']); + + $post->children()->attach($child->getKey()); + + $this->assertEquals(1, $post->children->count()); + + $post->children->first()->refresh(); + } +} + +class Post extends Model +{ + use SoftDeletes; + + public ?string $table = 'posts'; + + public bool $timestamps = true; + + protected array $guarded = []; + + protected static function boot(): void + { + parent::boot(); + + static::addGlobalScope('age', function ($query) { + $query->where('title', '!=', 'mohamed'); + }); + } +} + +class AsPivotPost extends Post +{ + public function children() + { + return $this + ->belongsToMany(static::class, (new AsPivotPostPivot())->getTable(), 'foreign_id', 'related_id') + ->using(AsPivotPostPivot::class); + } +} + +class AsPivotPostPivot extends Model +{ + use AsPivot; + + protected ?string $table = 'post_posts'; +} diff --git a/tests/Integration/Database/Laravel/EloquentModelRelationAutoloadTest.php b/tests/Integration/Database/Laravel/EloquentModelRelationAutoloadTest.php new file mode 100644 index 000000000..76feb725d --- /dev/null +++ b/tests/Integration/Database/Laravel/EloquentModelRelationAutoloadTest.php @@ -0,0 +1,368 @@ +increments('id'); + $table->string('name')->nullable(); + $table->string('status')->nullable(); + $table->unsignedInteger('post_id')->nullable(); + }); + + Schema::create('posts', function (Blueprint $table) { + $table->increments('id'); + }); + + Schema::create('videos', function (Blueprint $table) { + $table->increments('id'); + }); + + Schema::create('comments', function (Blueprint $table) { + $table->increments('id'); + $table->unsignedInteger('parent_id')->nullable(); + $table->morphs('commentable'); + }); + + Schema::create('likes', function (Blueprint $table) { + $table->increments('id'); + $table->morphs('likeable'); + }); + } + + public function testRelationAutoloadForCollection() + { + $post1 = Post::create(); + $comment1 = $post1->comments()->create(['parent_id' => null]); + $comment2 = $post1->comments()->create(['parent_id' => $comment1->id]); + $comment2->likes()->create(); + $comment2->likes()->create(); + + $post2 = Post::create(); + $comment3 = $post2->comments()->create(['parent_id' => null]); + $comment3->likes()->create(); + + $posts = Post::get(); + + DB::enableQueryLog(); + + $likes = []; + + $posts->withRelationshipAutoloading(); + + foreach ($posts as $post) { + foreach ($post->comments as $comment) { + $likes = array_merge($likes, $comment->likes->all()); + } + } + + $this->assertCount(2, DB::getQueryLog()); + $this->assertCount(3, $likes); + $this->assertTrue($posts[0]->comments[0]->relationLoaded('likes')); + + DB::disableQueryLog(); + } + + public function testRelationAutoloadForSingleModel() + { + $post = Post::create(); + $comment1 = $post->comments()->create(['parent_id' => null]); + $comment2 = $post->comments()->create(['parent_id' => $comment1->id]); + $comment2->likes()->create(); + $comment2->likes()->create(); + + DB::enableQueryLog(); + + $likes = []; + + $post->withRelationshipAutoloading(); + + foreach ($post->comments as $comment) { + $likes = array_merge($likes, $comment->likes->all()); + } + + $this->assertCount(2, DB::getQueryLog()); + $this->assertCount(2, $likes); + $this->assertTrue($post->comments[0]->relationLoaded('likes')); + + DB::disableQueryLog(); + } + + public function testRelationAutoloadWithSerialization() + { + Model::automaticallyEagerLoadRelationships(); + + $post = Post::create(); + $comment1 = $post->comments()->create(['parent_id' => null]); + $comment2 = $post->comments()->create(['parent_id' => $comment1->id]); + $comment2->likes()->create(); + + DB::enableQueryLog(); + + $likes = []; + + $post = serialize($post); + $post = unserialize($post); + + foreach ($post->comments as $comment) { + $likes = array_merge($likes, $comment->likes->all()); + } + + $this->assertCount(2, DB::getQueryLog()); + + Model::automaticallyEagerLoadRelationships(false); + + DB::disableQueryLog(); + } + + public function testRelationAutoloadWithCircularRelations() + { + $post = Post::create(); + $comment1 = $post->comments()->create(['parent_id' => null]); + $comment2 = $post->comments()->create(['parent_id' => $comment1->id]); + $post->likes()->create(); + + DB::enableQueryLog(); + + $post->withRelationshipAutoloading(); + $comment = $post->comments->first(); + $comment->setRelation('post', $post); + + $this->assertCount(1, $post->likes); + + $this->assertCount(2, DB::getQueryLog()); + + DB::disableQueryLog(); + } + + public function testRelationAutoloadWithChaperoneRelations() + { + Model::automaticallyEagerLoadRelationships(); + + $post = Post::create(); + $comment1 = $post->comments()->create(['parent_id' => null]); + $comment2 = $post->comments()->create(['parent_id' => $comment1->id]); + $post->likes()->create(); + + DB::enableQueryLog(); + + $post->load('commentsWithChaperone'); + + $this->assertCount(1, $post->likes); + + $this->assertCount(2, DB::getQueryLog()); + + Model::automaticallyEagerLoadRelationships(false); + + DB::disableQueryLog(); + } + + public function testRelationAutoloadVariousNestedMorphRelations() + { + tap(Post::create(), function ($post) { + $post->likes()->create(); + $post->comments()->create(); + tap($post->comments()->create(), function ($comment) { + $comment->likes()->create(); + $comment->likes()->create(); + }); + }); + + tap(Post::create(), function ($post) { + $post->likes()->create(); + tap($post->comments()->create(), function ($comment) { + $comment->likes()->create(); + }); + }); + + tap(Video::create(), function ($video) { + tap($video->comments()->create(), function ($comment) { + $comment->likes()->create(); + }); + }); + + tap(Video::create(), function ($video) { + tap($video->comments()->create(), function ($comment) { + $comment->likes()->create(); + }); + }); + + $likes = Like::get(); + + DB::enableQueryLog(); + + $videos = []; + $videoLike = null; + + $likes->withRelationshipAutoloading(); + + foreach ($likes as $like) { + $likeable = $like->likeable; + + if (($likeable instanceof Comment) && ($likeable->commentable instanceof Video)) { + $videos[] = $likeable->commentable; + $videoLike = $like; + } + } + + $this->assertCount(4, DB::getQueryLog()); + $this->assertCount(2, $videos); + $this->assertTrue($videoLike->relationLoaded('likeable')); + $this->assertTrue($videoLike->likeable->relationLoaded('commentable')); + + DB::disableQueryLog(); + } + + public function testRelationAutoloadWorksOnFactoryMake() + { + Model::automaticallyEagerLoadRelationships(); + + DB::enableQueryLog(); + + $tags = Tag::factory()->times(3)->make(); + + $post = Post::create(); + + $post->tags()->saveMany($tags); + + $this->assertCount(7, DB::getQueryLog()); + + Model::automaticallyEagerLoadRelationships(false); + + DB::disableQueryLog(); + } +} + +class TagFactory extends Factory +{ + protected ?string $model = Tag::class; + + public function definition(): array + { + return []; + } +} + +class Tag extends Model +{ + use HasFactory; + + public bool $timestamps = false; + + protected array $guarded = []; + + protected static function booted(): void + { + static::creating(function ($model) { + if ($model->post->shouldApplyStatus()) { + $model->status = 'Todo'; + } + }); + } + + protected static function newFactory(): TagFactory + { + return TagFactory::new(); + } + + public function post() + { + return $this->belongsTo(Post::class); + } +} + +class Comment extends Model +{ + public bool $timestamps = false; + + protected array $guarded = []; + + public function parent() + { + return $this->belongsTo(self::class); + } + + public function likes() + { + return $this->morphMany(Like::class, 'likeable'); + } + + public function commentable() + { + return $this->morphTo(); + } +} + +class Post extends Model +{ + public bool $timestamps = false; + + public function shouldApplyStatus(): bool + { + return false; + } + + public function comments() + { + return $this->morphMany(Comment::class, 'commentable'); + } + + public function commentsWithChaperone() + { + return $this->morphMany(Comment::class, 'commentable')->chaperone(); + } + + public function likes() + { + return $this->morphMany(Like::class, 'likeable'); + } + + public function tags() + { + return $this->hasMany(Tag::class); + } +} + +class Video extends Model +{ + public bool $timestamps = false; + + public function comments() + { + return $this->morphMany(Comment::class, 'commentable'); + } + + public function likes() + { + return $this->morphMany(Like::class, 'likeable'); + } +} + +class Like extends Model +{ + public bool $timestamps = false; + + protected array $guarded = []; + + public function likeable() + { + return $this->morphTo(); + } +} diff --git a/tests/Integration/Database/Laravel/EloquentModelScopeTest.php b/tests/Integration/Database/Laravel/EloquentModelScopeTest.php new file mode 100644 index 000000000..f58d5a546 --- /dev/null +++ b/tests/Integration/Database/Laravel/EloquentModelScopeTest.php @@ -0,0 +1,52 @@ +assertTrue($model->hasNamedScope('exists')); + } + + public function testModelDoesNotHaveScope() + { + $model = new TestScopeModel1(); + + $this->assertFalse($model->hasNamedScope('doesNotExist')); + } + + public function testModelHasAttributedScope() + { + $model = new TestScopeModel1(); + + $this->assertTrue($model->hasNamedScope('existsAsWell')); + } +} + +class TestScopeModel1 extends Model +{ + public function scopeExists(Builder $builder): Builder + { + return $builder; + } + + #[Scope] + protected function existsAsWell(Builder $builder): Builder + { + return $builder; + } +} diff --git a/tests/Integration/Database/Laravel/EloquentModelStringCastingTest.php b/tests/Integration/Database/Laravel/EloquentModelStringCastingTest.php new file mode 100644 index 000000000..aea194a99 --- /dev/null +++ b/tests/Integration/Database/Laravel/EloquentModelStringCastingTest.php @@ -0,0 +1,86 @@ +increments('id'); + $table->string('array_attributes'); + $table->string('json_attributes'); + $table->string('object_attributes'); + $table->timestamps(); + }); + } + + /** + * Tests... + */ + public function testSavingCastedAttributesToDatabase() + { + /** @var StringCasts $model */ + $model = StringCasts::create([ + 'array_attributes' => ['key1' => 'value1'], + 'json_attributes' => ['json_key' => 'json_value'], + 'object_attributes' => ['json_key' => 'json_value'], + ]); + $this->assertSame(['key1' => 'value1'], $model->getOriginal('array_attributes')); + $this->assertSame(['key1' => 'value1'], $model->getAttribute('array_attributes')); + + $this->assertSame(['json_key' => 'json_value'], $model->getOriginal('json_attributes')); + $this->assertSame(['json_key' => 'json_value'], $model->getAttribute('json_attributes')); + + $stdClass = new stdClass(); + $stdClass->json_key = 'json_value'; + $this->assertEquals($stdClass, $model->getOriginal('object_attributes')); + $this->assertEquals($stdClass, $model->getAttribute('object_attributes')); + } + + public function testSavingCastedEmptyAttributesToDatabase() + { + /** @var StringCasts $model */ + $model = StringCasts::create([ + 'array_attributes' => [], + 'json_attributes' => [], + 'object_attributes' => [], + ]); + $this->assertSame([], $model->getOriginal('array_attributes')); + $this->assertSame([], $model->getAttribute('array_attributes')); + + $this->assertSame([], $model->getOriginal('json_attributes')); + $this->assertSame([], $model->getAttribute('json_attributes')); + + $this->assertSame([], $model->getOriginal('object_attributes')); + $this->assertSame([], $model->getAttribute('object_attributes')); + } +} + +/** + * Eloquent Models... + */ +class StringCasts extends Eloquent +{ + protected ?string $table = 'casting_table'; + + protected array $guarded = []; + + protected array $casts = [ + 'array_attributes' => 'array', + 'json_attributes' => 'json', + 'object_attributes' => 'object', + ]; +} diff --git a/tests/Integration/Database/Laravel/EloquentModelTest.php b/tests/Integration/Database/Laravel/EloquentModelTest.php new file mode 100644 index 000000000..dd5e85b3c --- /dev/null +++ b/tests/Integration/Database/Laravel/EloquentModelTest.php @@ -0,0 +1,166 @@ +increments('id'); + $table->timestamp('nullable_date')->nullable(); + }); + + Schema::create('test_model2', function (Blueprint $table) { + $table->increments('id'); + $table->string('name'); + $table->string('title'); + }); + } + + public function testUserCanUpdateNullableDate() + { + $user = TestModel1::create([ + 'nullable_date' => null, + ]); + + $user->fill([ + 'nullable_date' => $now = Carbon::now(), + ]); + $this->assertTrue($user->isDirty('nullable_date')); + + $user->save(); + $this->assertEquals($now->toDateString(), $user->nullable_date->toDateString()); + } + + public function testAttributeChanges() + { + $user = TestModel2::create([ + 'name' => $originalName = Str::random(), 'title' => Str::random(), + ]); + + $this->assertEmpty($user->getDirty()); + $this->assertEmpty($user->getChanges()); + $this->assertEmpty($user->getPrevious()); + $this->assertFalse($user->isDirty()); + $this->assertFalse($user->wasChanged()); + + $user->name = $overrideName = Str::random(); + + $this->assertEquals(['name' => $overrideName], $user->getDirty()); + $this->assertEmpty($user->getChanges()); + $this->assertEmpty($user->getPrevious()); + $this->assertTrue($user->isDirty()); + $this->assertFalse($user->wasChanged()); + + $user->save(); + + $this->assertEmpty($user->getDirty()); + $this->assertEquals(['name' => $overrideName], $user->getChanges()); + $this->assertEquals(['name' => $originalName], $user->getPrevious()); + $this->assertTrue($user->wasChanged()); + $this->assertTrue($user->wasChanged('name')); + } + + public function testDiscardChanges() + { + $user = TestModel2::create([ + 'name' => $originalName = Str::random(), 'title' => Str::random(), + ]); + + $this->assertEmpty($user->getDirty()); + $this->assertEmpty($user->getChanges()); + $this->assertEmpty($user->getPrevious()); + $this->assertFalse($user->isDirty()); + $this->assertFalse($user->wasChanged()); + + $user->name = $overrideName = Str::random(); + + $this->assertEquals(['name' => $overrideName], $user->getDirty()); + $this->assertEmpty($user->getChanges()); + $this->assertEmpty($user->getPrevious()); + $this->assertTrue($user->isDirty()); + $this->assertFalse($user->wasChanged()); + $this->assertSame($originalName, $user->getOriginal('name')); + $this->assertSame($overrideName, $user->getAttribute('name')); + + $user->discardChanges(); + + $this->assertEmpty($user->getDirty()); + $this->assertEmpty($user->getChanges()); + $this->assertEmpty($user->getPrevious()); + $this->assertSame($originalName, $user->getOriginal('name')); + $this->assertSame($originalName, $user->getAttribute('name')); + + $user->save(); + $this->assertFalse($user->wasChanged()); + $this->assertEmpty($user->getChanges()); + $this->assertEmpty($user->getPrevious()); + } + + public function testInsertRecordWithReservedWordFieldName() + { + Schema::create('actions', function (Blueprint $table) { + $table->id(); + $table->string('label'); + $table->timestamp('start'); + $table->timestamp('end')->nullable(); + $table->boolean('analyze'); + }); + + $model = new class extends Model { + protected ?string $table = 'actions'; + + protected array $guarded = ['id']; + + public bool $timestamps = false; + }; + + $model->newInstance()->create([ + 'label' => 'test', + 'start' => '2023-01-01 00:00:00', + 'end' => '2024-01-01 00:00:00', + 'analyze' => true, + ]); + + $this->assertDatabaseHas('actions', [ + 'label' => 'test', + 'start' => '2023-01-01 00:00:00', + 'end' => '2024-01-01 00:00:00', + 'analyze' => true, + ]); + } +} + +class TestModel1 extends Model +{ + public ?string $table = 'test_model1'; + + public bool $timestamps = false; + + protected array $guarded = []; + + protected array $casts = ['nullable_date' => 'datetime']; +} + +class TestModel2 extends Model +{ + public ?string $table = 'test_model2'; + + public bool $timestamps = false; + + protected array $guarded = []; +} diff --git a/tests/Integration/Database/Laravel/EloquentModelWithoutEventsTest.php b/tests/Integration/Database/Laravel/EloquentModelWithoutEventsTest.php new file mode 100644 index 000000000..bcd90c992 --- /dev/null +++ b/tests/Integration/Database/Laravel/EloquentModelWithoutEventsTest.php @@ -0,0 +1,56 @@ +increments('id'); + $table->text('project')->nullable(); + }); + } + + public function testWithoutEventsRegistersBootedListenersForLater() + { + $model = AutoFilledModel::withoutEvents(function () { + return AutoFilledModel::create(); + }); + + $this->assertNull($model->project); + + $model->save(); + + $this->assertSame('Laravel', $model->project); + } +} + +class AutoFilledModel extends Model +{ + public ?string $table = 'auto_filled_models'; + + public bool $timestamps = false; + + protected array $guarded = []; + + public static function boot(): void + { + parent::boot(); + + static::saving(function ($model) { + $model->project = 'Laravel'; + }); + } +} diff --git a/tests/Integration/Database/Laravel/EloquentMorphConstrainTest.php b/tests/Integration/Database/Laravel/EloquentMorphConstrainTest.php new file mode 100644 index 000000000..aba001f8e --- /dev/null +++ b/tests/Integration/Database/Laravel/EloquentMorphConstrainTest.php @@ -0,0 +1,98 @@ +increments('id'); + $table->boolean('post_visible'); + }); + + Schema::create('videos', function (Blueprint $table) { + $table->increments('id'); + $table->boolean('video_visible'); + }); + + Schema::create('comments', function (Blueprint $table) { + $table->increments('id'); + $table->string('commentable_type'); + $table->integer('commentable_id'); + }); + + $post1 = Post::create(['post_visible' => true]); + (new Comment())->commentable()->associate($post1)->save(); + + $post2 = Post::create(['post_visible' => false]); + (new Comment())->commentable()->associate($post2)->save(); + + $video1 = Video::create(['video_visible' => true]); + (new Comment())->commentable()->associate($video1)->save(); + + $video2 = Video::create(['video_visible' => false]); + (new Comment())->commentable()->associate($video2)->save(); + } + + public function testMorphConstraints() + { + $comments = Comment::query() + ->with(['commentable' => function (MorphTo $morphTo) { + $morphTo->constrain([ + Post::class => function ($query) { + $query->where('post_visible', true); + }, + Video::class => function ($query) { + $query->where('video_visible', true); + }, + ]); + }]) + ->get(); + + $this->assertTrue($comments[0]->commentable->post_visible); + $this->assertNull($comments[1]->commentable); + $this->assertTrue($comments[2]->commentable->video_visible); + $this->assertNull($comments[3]->commentable); + } +} + +class Comment extends Model +{ + public bool $timestamps = false; + + public function commentable(): MorphTo + { + return $this->morphTo(); + } +} + +class Post extends Model +{ + public bool $timestamps = false; + + protected array $fillable = ['post_visible']; + + protected array $casts = ['post_visible' => 'boolean']; +} + +class Video extends Model +{ + public bool $timestamps = false; + + protected array $fillable = ['video_visible']; + + protected array $casts = ['video_visible' => 'boolean']; +} diff --git a/tests/Integration/Database/Laravel/EloquentMorphCountEagerLoadingTest.php b/tests/Integration/Database/Laravel/EloquentMorphCountEagerLoadingTest.php new file mode 100644 index 000000000..06e8dede2 --- /dev/null +++ b/tests/Integration/Database/Laravel/EloquentMorphCountEagerLoadingTest.php @@ -0,0 +1,134 @@ +increments('id'); + $table->unsignedInteger('post_id'); + }); + + Schema::create('views', function (Blueprint $table) { + $table->increments('id'); + $table->unsignedInteger('video_id'); + }); + + Schema::create('posts', function (Blueprint $table) { + $table->increments('id'); + }); + + Schema::create('videos', function (Blueprint $table) { + $table->increments('id'); + }); + + Schema::create('comments', function (Blueprint $table) { + $table->increments('id'); + $table->string('commentable_type'); + $table->integer('commentable_id'); + }); + + $post = Post::create(); + $video = Video::create(); + + tap((new Like())->post()->associate($post))->save(); + tap((new Like())->post()->associate($post))->save(); + + tap((new View())->video()->associate($video))->save(); + + (new Comment())->commentable()->associate($post)->save(); + (new Comment())->commentable()->associate($video)->save(); + } + + public function testWithMorphCountLoading() + { + $comments = Comment::query() + ->with(['commentable' => function (MorphTo $morphTo) { + $morphTo->morphWithCount([Post::class => ['likes']]); + }]) + ->get(); + + $this->assertTrue($comments[0]->relationLoaded('commentable')); + $this->assertEquals(2, $comments[0]->commentable->likes_count); + $this->assertTrue($comments[1]->relationLoaded('commentable')); + $this->assertNull($comments[1]->commentable->views_count); + } + + public function testWithMorphCountLoadingWithSingleRelation() + { + $comments = Comment::query() + ->with(['commentable' => function (MorphTo $morphTo) { + $morphTo->morphWithCount([Post::class => 'likes']); + }]) + ->get(); + + $this->assertTrue($comments[0]->relationLoaded('commentable')); + $this->assertEquals(2, $comments[0]->commentable->likes_count); + } +} + +class Comment extends Model +{ + public bool $timestamps = false; + + public function commentable(): MorphTo + { + return $this->morphTo(); + } +} + +class Post extends Model +{ + public bool $timestamps = false; + + public function likes(): HasMany + { + return $this->hasMany(Like::class); + } +} + +class Video extends Model +{ + public bool $timestamps = false; + + public function views(): HasMany + { + return $this->hasMany(View::class); + } +} + +class Like extends Model +{ + public bool $timestamps = false; + + public function post(): BelongsTo + { + return $this->belongsTo(Post::class); + } +} + +class View extends Model +{ + public bool $timestamps = false; + + public function video(): BelongsTo + { + return $this->belongsTo(Video::class); + } +} diff --git a/tests/Integration/Database/Laravel/EloquentMorphCountLazyEagerLoadingTest.php b/tests/Integration/Database/Laravel/EloquentMorphCountLazyEagerLoadingTest.php new file mode 100644 index 000000000..9f0c32f2c --- /dev/null +++ b/tests/Integration/Database/Laravel/EloquentMorphCountLazyEagerLoadingTest.php @@ -0,0 +1,87 @@ +increments('id'); + $table->unsignedInteger('post_id'); + }); + + Schema::create('posts', function (Blueprint $table) { + $table->increments('id'); + }); + + Schema::create('comments', function (Blueprint $table) { + $table->increments('id'); + $table->string('commentable_type'); + $table->integer('commentable_id'); + }); + + $post = Post::create(); + + tap((new Like())->post()->associate($post))->save(); + tap((new Like())->post()->associate($post))->save(); + + (new Comment())->commentable()->associate($post)->save(); + } + + public function testLazyEagerLoading() + { + $comment = Comment::first(); + + $comment->loadMorphCount('commentable', [ + Post::class => ['likes'], + ]); + + $this->assertTrue($comment->relationLoaded('commentable')); + $this->assertEquals(2, $comment->commentable->likes_count); + } +} + +class Comment extends Model +{ + public bool $timestamps = false; + + public function commentable(): MorphTo + { + return $this->morphTo(); + } +} + +class Post extends Model +{ + public bool $timestamps = false; + + public function likes(): HasMany + { + return $this->hasMany(Like::class); + } +} + +class Like extends Model +{ + public bool $timestamps = false; + + public function post(): BelongsTo + { + return $this->belongsTo(Post::class); + } +} diff --git a/tests/Integration/Database/Laravel/EloquentMorphEagerLoadingTest.php b/tests/Integration/Database/Laravel/EloquentMorphEagerLoadingTest.php new file mode 100644 index 000000000..9e3c40c51 --- /dev/null +++ b/tests/Integration/Database/Laravel/EloquentMorphEagerLoadingTest.php @@ -0,0 +1,166 @@ +increments('id'); + $table->softDeletes(); + }); + + Schema::create('posts', function (Blueprint $table) { + $table->increments('post_id'); + $table->unsignedInteger('user_id'); + }); + + Schema::create('videos', function (Blueprint $table) { + $table->increments('video_id'); + }); + + Schema::create('actions', function (Blueprint $table) { + $table->increments('id'); + $table->string('target_type'); + $table->integer('target_id'); + }); + + Schema::create('comments', function (Blueprint $table) { + $table->increments('id'); + $table->string('commentable_type'); + $table->integer('commentable_id'); + }); + + $user = User::create(); + $user2 = User::forceCreate(['deleted_at' => now()]); + + $post = tap((new Post())->user()->associate($user))->save(); + + $video = Video::create(); + + (new Comment())->commentable()->associate($post)->save(); + (new Comment())->commentable()->associate($video)->save(); + + (new Action())->target()->associate($video)->save(); + (new Action())->target()->associate($user2)->save(); + } + + public function testWithMorphLoading() + { + $comments = Comment::query() + ->with(['commentable' => function (MorphTo $morphTo) { + $morphTo->morphWith([Post::class => ['user']]); + }]) + ->get(); + + $this->assertCount(2, $comments); + + $this->assertTrue($comments[0]->relationLoaded('commentable')); + $this->assertInstanceOf(Post::class, $comments[0]->getRelation('commentable')); + $this->assertTrue($comments[0]->commentable->relationLoaded('user')); + $this->assertTrue($comments[1]->relationLoaded('commentable')); + $this->assertInstanceOf(Video::class, $comments[1]->getRelation('commentable')); + } + + public function testWithMorphLoadingWithSingleRelation() + { + $comments = Comment::query() + ->with(['commentable' => function (MorphTo $morphTo) { + $morphTo->morphWith([Post::class => 'user']); + }]) + ->get(); + + $this->assertTrue($comments[0]->relationLoaded('commentable')); + $this->assertTrue($comments[0]->commentable->relationLoaded('user')); + } + + public function testMorphLoadingMixedWithTrashedRelations() + { + $action = Action::query() + ->with('target') + ->get(); + + $this->assertCount(2, $action); + + $this->assertTrue($action[0]->relationLoaded('target')); + $this->assertInstanceOf(Video::class, $action[0]->getRelation('target')); + $this->assertTrue($action[1]->relationLoaded('target')); + $this->assertInstanceOf(User::class, $action[1]->getRelation('target')); + } + + public function testMorphWithTrashedRelationLazyLoading() + { + $deletedUser = User::forceCreate(['deleted_at' => now()]); + + $action = new Action(); + $action->target()->associate($deletedUser)->save(); + + // model is already set via associate and not retrieved from the database + $this->assertInstanceOf(User::class, $action->target); + + $action->unsetRelation('target'); + + $this->assertInstanceOf(User::class, $action->target); + } +} + +class Action extends Model +{ + public bool $timestamps = false; + + public function target(): MorphTo + { + return $this->morphTo()->withTrashed(); + } +} + +class Comment extends Model +{ + public bool $timestamps = false; + + public function commentable(): MorphTo + { + return $this->morphTo(); + } +} + +class Post extends Model +{ + public bool $timestamps = false; + + protected string $primaryKey = 'post_id'; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} + +class User extends Model +{ + use SoftDeletes; + + public bool $timestamps = false; +} + +class Video extends Model +{ + public bool $timestamps = false; + + protected string $primaryKey = 'video_id'; +} diff --git a/tests/Integration/Database/Laravel/EloquentMorphLazyEagerLoadingTest.php b/tests/Integration/Database/Laravel/EloquentMorphLazyEagerLoadingTest.php new file mode 100644 index 000000000..ea0a27326 --- /dev/null +++ b/tests/Integration/Database/Laravel/EloquentMorphLazyEagerLoadingTest.php @@ -0,0 +1,82 @@ +increments('id'); + }); + + Schema::create('posts', function (Blueprint $table) { + $table->increments('post_id'); + $table->unsignedInteger('user_id'); + }); + + Schema::create('comments', function (Blueprint $table) { + $table->increments('id'); + $table->string('commentable_type'); + $table->integer('commentable_id'); + }); + + $user = User::create(); + + $post = tap((new Post())->user()->associate($user))->save(); + + (new Comment())->commentable()->associate($post)->save(); + } + + public function testLazyEagerLoading() + { + $comment = Comment::first(); + + $comment->loadMorph('commentable', [ + Post::class => ['user'], + ]); + + $this->assertTrue($comment->relationLoaded('commentable')); + $this->assertTrue($comment->commentable->relationLoaded('user')); + } +} + +class Comment extends Model +{ + public bool $timestamps = false; + + public function commentable(): MorphTo + { + return $this->morphTo(); + } +} + +class Post extends Model +{ + public bool $timestamps = false; + + protected string $primaryKey = 'post_id'; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} + +class User extends Model +{ + public bool $timestamps = false; +} diff --git a/tests/Integration/Database/Laravel/EloquentMorphManyTest.php b/tests/Integration/Database/Laravel/EloquentMorphManyTest.php new file mode 100644 index 000000000..10b658c24 --- /dev/null +++ b/tests/Integration/Database/Laravel/EloquentMorphManyTest.php @@ -0,0 +1,125 @@ +increments('id'); + $table->string('title'); + $table->timestamps(); + }); + + Schema::create('comments', function (Blueprint $table) { + $table->increments('id'); + $table->string('name'); + $table->integer('commentable_id'); + $table->string('commentable_type'); + $table->timestamps(); + }); + } + + public function testUpdateModelWithDefaultWithCount() + { + $post = Post::create(['title' => Str::random()]); + + $post->update(['title' => 'new name']); + + $this->assertSame('new name', $post->title); + } + + public function testSelfReferencingExistenceQuery() + { + $post = Post::create(['title' => 'foo']); + + $comment = tap((new Comment(['name' => 'foo']))->commentable()->associate($post))->save(); + + (new Comment(['name' => 'bar']))->commentable()->associate($comment)->save(); + + $comments = Comment::has('replies')->get(); + + $this->assertEquals([1], $comments->pluck('id')->all()); + } + + public function testCanMorphOne() + { + $post = Post::create(['title' => 'Your favorite book by C.S. Lewis']); + + Carbon::setTestNow('1990-02-02 12:00:00'); + $oldestComment = tap((new Comment(['name' => 'The Allegory Of Love']))->commentable()->associate($post))->save(); + + Carbon::setTestNow('2000-07-02 09:00:00'); + tap((new Comment(['name' => 'The Screwtape Letters']))->commentable()->associate($post))->save(); + + Carbon::setTestNow('2022-01-01 00:00:00'); + $latestComment = tap((new Comment(['name' => 'The Silver Chair']))->commentable()->associate($post))->save(); + + $this->assertInstanceOf(MorphOne::class, $post->comments()->one()); + + $this->assertEquals($latestComment->id, $post->latestComment->id); + $this->assertEquals($oldestComment->id, $post->oldestComment->id); + } +} + +class Post extends Model +{ + public ?string $table = 'posts'; + + public bool $timestamps = true; + + protected array $guarded = []; + + protected array $withCount = ['comments']; + + public function comments(): MorphMany + { + return $this->morphMany(Comment::class, 'commentable'); + } + + public function latestComment(): MorphOne + { + return $this->comments()->one()->latestOfMany(); + } + + public function oldestComment(): MorphOne + { + return $this->comments()->one()->oldestOfMany(); + } +} + +class Comment extends Model +{ + public ?string $table = 'comments'; + + public bool $timestamps = true; + + protected array $guarded = []; + + public function commentable(): MorphTo + { + return $this->morphTo(); + } + + public function replies(): MorphMany + { + return $this->morphMany(self::class, 'commentable'); + } +} diff --git a/tests/Integration/Database/Laravel/EloquentMorphOneIsTest.php b/tests/Integration/Database/Laravel/EloquentMorphOneIsTest.php new file mode 100644 index 000000000..f7b2adfb8 --- /dev/null +++ b/tests/Integration/Database/Laravel/EloquentMorphOneIsTest.php @@ -0,0 +1,107 @@ +increments('id'); + $table->timestamps(); + }); + + Schema::create('attachments', function (Blueprint $table) { + $table->increments('id'); + $table->string('attachable_type')->nullable(); + $table->integer('attachable_id')->nullable(); + }); + + $post = Post::create(); + $post->attachment()->create(); + } + + public function testChildIsNotNull() + { + $parent = Post::first(); + $child = null; + + $this->assertFalse($parent->attachment()->is($child)); + $this->assertTrue($parent->attachment()->isNot($child)); + } + + public function testChildIsModel() + { + $parent = Post::first(); + $child = Attachment::first(); + + $this->assertTrue($parent->attachment()->is($child)); + $this->assertFalse($parent->attachment()->isNot($child)); + } + + public function testChildIsNotAnotherModel() + { + $parent = Post::first(); + $child = new Attachment(); + $child->id = 2; + + $this->assertFalse($parent->attachment()->is($child)); + $this->assertTrue($parent->attachment()->isNot($child)); + } + + public function testNullChildIsNotModel() + { + $parent = Post::first(); + $child = Attachment::first(); + $child->attachable_type = null; + $child->attachable_id = null; + + $this->assertFalse($parent->attachment()->is($child)); + $this->assertTrue($parent->attachment()->isNot($child)); + } + + public function testChildIsNotModelWithAnotherTable() + { + $parent = Post::first(); + $child = Attachment::first(); + $child->setTable('foo'); + + $this->assertFalse($parent->attachment()->is($child)); + $this->assertTrue($parent->attachment()->isNot($child)); + } + + public function testChildIsNotModelWithAnotherConnection() + { + $parent = Post::first(); + $child = Attachment::first(); + $child->setConnection('foo'); + + $this->assertFalse($parent->attachment()->is($child)); + $this->assertTrue($parent->attachment()->isNot($child)); + } +} + +class Attachment extends Model +{ + public bool $timestamps = false; +} + +class Post extends Model +{ + public function attachment(): MorphOne + { + return $this->morphOne(Attachment::class, 'attachable'); + } +} diff --git a/tests/Integration/Database/Laravel/EloquentMorphToGlobalScopesTest.php b/tests/Integration/Database/Laravel/EloquentMorphToGlobalScopesTest.php new file mode 100644 index 000000000..76331ee2a --- /dev/null +++ b/tests/Integration/Database/Laravel/EloquentMorphToGlobalScopesTest.php @@ -0,0 +1,93 @@ +increments('id'); + $table->softDeletes(); + }); + + Schema::create('comments', function (Blueprint $table) { + $table->increments('id'); + $table->string('commentable_type'); + $table->integer('commentable_id'); + }); + + $post = Post::create(); + (new Comment())->commentable()->associate($post)->save(); + + $post = tap(Post::create())->delete(); + (new Comment())->commentable()->associate($post)->save(); + } + + public function testWithGlobalScopes() + { + $comments = Comment::with('commentable')->get(); + + $this->assertNotNull($comments[0]->commentable); + $this->assertNull($comments[1]->commentable); + } + + public function testWithoutGlobalScope() + { + $comments = Comment::with(['commentable' => function ($query) { + $query->withoutGlobalScopes([SoftDeletingScope::class]); + }])->get(); + + $this->assertNotNull($comments[0]->commentable); + $this->assertNotNull($comments[1]->commentable); + } + + public function testWithoutGlobalScopes() + { + $comments = Comment::with(['commentable' => function ($query) { + $query->withoutGlobalScopes(); + }])->get(); + + $this->assertNotNull($comments[0]->commentable); + $this->assertNotNull($comments[1]->commentable); + } + + public function testLazyLoading() + { + $comment = Comment::latest('id')->first(); + $post = $comment->commentable()->withoutGlobalScopes()->first(); + + $this->assertNotNull($post); + } +} + +class Comment extends Model +{ + public bool $timestamps = false; + + public function commentable(): MorphTo + { + return $this->morphTo(); + } +} + +class Post extends Model +{ + use SoftDeletes; + + public bool $timestamps = false; +} diff --git a/tests/Integration/Database/Laravel/EloquentMorphToIsTest.php b/tests/Integration/Database/Laravel/EloquentMorphToIsTest.php new file mode 100644 index 000000000..277afdace --- /dev/null +++ b/tests/Integration/Database/Laravel/EloquentMorphToIsTest.php @@ -0,0 +1,107 @@ +increments('id'); + $table->timestamps(); + }); + + Schema::create('comments', function (Blueprint $table) { + $table->increments('id'); + $table->string('commentable_type'); + $table->integer('commentable_id'); + }); + + $post = Post::create(); + (new Comment())->commentable()->associate($post)->save(); + } + + public function testParentIsNotNull() + { + $child = Comment::first(); + $parent = null; + + $this->assertFalse($child->commentable()->is($parent)); + $this->assertTrue($child->commentable()->isNot($parent)); + } + + public function testParentIsModel() + { + $child = Comment::first(); + $parent = Post::first(); + + $this->assertTrue($child->commentable()->is($parent)); + $this->assertFalse($child->commentable()->isNot($parent)); + } + + public function testParentIsNotAnotherModel() + { + $child = Comment::first(); + $parent = new Post(); + $parent->id = 2; + + $this->assertFalse($child->commentable()->is($parent)); + $this->assertTrue($child->commentable()->isNot($parent)); + } + + public function testNullParentIsNotModel() + { + $child = Comment::first(); + $child->commentable()->dissociate(); + $parent = Post::first(); + + $this->assertFalse($child->commentable()->is($parent)); + $this->assertTrue($child->commentable()->isNot($parent)); + } + + public function testParentIsNotModelWithAnotherTable() + { + $child = Comment::first(); + $parent = Post::first(); + $parent->setTable('foo'); + + $this->assertFalse($child->commentable()->is($parent)); + $this->assertTrue($child->commentable()->isNot($parent)); + } + + public function testParentIsNotModelWithAnotherConnection() + { + $child = Comment::first(); + $parent = Post::first(); + $parent->setConnection('foo'); + + $this->assertFalse($child->commentable()->is($parent)); + $this->assertTrue($child->commentable()->isNot($parent)); + } +} + +class Comment extends Model +{ + public bool $timestamps = false; + + public function commentable(): MorphTo + { + return $this->morphTo(); + } +} + +class Post extends Model +{ +} diff --git a/tests/Integration/Database/Laravel/EloquentMorphToLazyEagerLoadingTest.php b/tests/Integration/Database/Laravel/EloquentMorphToLazyEagerLoadingTest.php new file mode 100644 index 000000000..0b15fc9e5 --- /dev/null +++ b/tests/Integration/Database/Laravel/EloquentMorphToLazyEagerLoadingTest.php @@ -0,0 +1,101 @@ +increments('id'); + }); + + Schema::create('posts', function (Blueprint $table) { + $table->increments('post_id'); + $table->unsignedInteger('user_id'); + }); + + Schema::create('videos', function (Blueprint $table) { + $table->increments('video_id'); + }); + + Schema::create('comments', function (Blueprint $table) { + $table->increments('id'); + $table->string('commentable_type'); + $table->integer('commentable_id'); + }); + + $user = User::create(); + + $post = tap((new Post())->user()->associate($user))->save(); + + $video = Video::create(); + + (new Comment())->commentable()->associate($post)->save(); + (new Comment())->commentable()->associate($video)->save(); + } + + public function testLazyEagerLoading() + { + $comments = Comment::all(); + + DB::enableQueryLog(); + + $comments->load('commentable'); + + $this->assertCount(3, DB::getQueryLog()); + $this->assertTrue($comments[0]->relationLoaded('commentable')); + $this->assertTrue($comments[0]->commentable->relationLoaded('user')); + $this->assertTrue($comments[1]->relationLoaded('commentable')); + } +} + +class Comment extends Model +{ + public bool $timestamps = false; + + public function commentable(): MorphTo + { + return $this->morphTo(); + } +} + +class Post extends Model +{ + public bool $timestamps = false; + + protected string $primaryKey = 'post_id'; + + protected array $with = ['user']; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} + +class User extends Model +{ + public bool $timestamps = false; +} + +class Video extends Model +{ + public bool $timestamps = false; + + protected string $primaryKey = 'video_id'; +} diff --git a/tests/Integration/Database/Laravel/EloquentMorphToSelectTest.php b/tests/Integration/Database/Laravel/EloquentMorphToSelectTest.php new file mode 100644 index 000000000..3c4b4fcff --- /dev/null +++ b/tests/Integration/Database/Laravel/EloquentMorphToSelectTest.php @@ -0,0 +1,93 @@ +increments('id'); + $table->timestamps(); + }); + + Schema::create('comments', function (Blueprint $table) { + $table->increments('id'); + $table->string('commentable_type'); + $table->integer('commentable_id'); + }); + + $post = Post::create(); + (new Comment())->commentable()->associate($post)->save(); + } + + public function testSelect() + { + $comments = Comment::with('commentable:id')->get(); + + $this->assertEquals(['id' => 1], $comments[0]->commentable->getAttributes()); + } + + public function testSelectRaw() + { + $comments = Comment::with(['commentable' => function ($query) { + $query->selectRaw('id'); + }])->get(); + + $this->assertEquals(['id' => 1], $comments[0]->commentable->getAttributes()); + } + + public function testSelectSub() + { + $comments = Comment::with(['commentable' => function ($query) { + $query->selectSub(function ($query) { + $query->select('id'); + }, 'id'); + }])->get(); + + $this->assertEquals(['id' => 1], $comments[0]->commentable->getAttributes()); + } + + public function testAddSelect() + { + $comments = Comment::with(['commentable' => function ($query) { + $query->addSelect('id'); + }])->get(); + + $this->assertEquals(['id' => 1], $comments[0]->commentable->getAttributes()); + } + + public function testLazyLoading() + { + $comment = Comment::first(); + $post = $comment->commentable()->select('id')->first(); + + $this->assertEquals(['id' => 1], $post->getAttributes()); + } +} + +class Comment extends Model +{ + public bool $timestamps = false; + + public function commentable(): MorphTo + { + return $this->morphTo(); + } +} + +class Post extends Model +{ +} diff --git a/tests/Integration/Database/Laravel/EloquentMorphToTouchesTest.php b/tests/Integration/Database/Laravel/EloquentMorphToTouchesTest.php new file mode 100644 index 000000000..917d24842 --- /dev/null +++ b/tests/Integration/Database/Laravel/EloquentMorphToTouchesTest.php @@ -0,0 +1,70 @@ +increments('id'); + $table->timestamps(); + }); + + Schema::create('comments', function (Blueprint $table) { + $table->increments('id'); + $table->nullableMorphs('commentable'); + }); + + Post::create(); + } + + public function testNotNull() + { + $comment = (new Comment())->commentable()->associate(Post::first()); + + DB::enableQueryLog(); + + $comment->save(); + + $this->assertCount(2, DB::getQueryLog()); + } + + public function testNull() + { + DB::enableQueryLog(); + + Comment::create(); + + $this->assertCount(1, DB::getQueryLog()); + } +} + +class Comment extends Model +{ + public bool $timestamps = false; + + protected array $touches = ['commentable']; + + public function commentable(): MorphTo + { + return $this->morphTo(null, null, null, 'id'); + } +} + +class Post extends Model +{ +} diff --git a/tests/Integration/Database/Laravel/EloquentMultiDimensionalArrayEagerLoadingTest.php b/tests/Integration/Database/Laravel/EloquentMultiDimensionalArrayEagerLoadingTest.php new file mode 100644 index 000000000..e4bf798f4 --- /dev/null +++ b/tests/Integration/Database/Laravel/EloquentMultiDimensionalArrayEagerLoadingTest.php @@ -0,0 +1,280 @@ +increments('id'); + }); + + Schema::create('avatars', function (Blueprint $table) { + $table->increments('id'); + $table->unsignedInteger('user_id'); + }); + + Schema::create('posts', function (Blueprint $table) { + $table->increments('id'); + $table->string('title'); + $table->string('content'); + $table->unsignedInteger('user_id'); + }); + + Schema::create('images', function (Blueprint $table) { + $table->increments('id'); + $table->unsignedInteger('post_id'); + }); + + Schema::create('comments', function (Blueprint $table) { + $table->increments('id'); + $table->string('title'); + $table->string('content'); + $table->unsignedInteger('post_id'); + }); + + Schema::create('tags', function (Blueprint $table) { + $table->increments('id'); + $table->unsignedInteger('comment_id'); + }); + + $user = User::create(); + $user->avatar()->create(); + $posts = $user->posts()->createMany([ + [ + 'title' => '1. post title', + 'content' => '1. post content', + ], + [ + 'title' => '2. post title', + 'content' => '2. post content', + ], + ]); + $posts->map->image()->each->create(); + $comments = $posts->map->comments()->map->create([ + 'title' => 'comment title', + 'content' => 'comment content', + ]); + $comments->map->tags()->each->create(); + $comments->map->tags()->each->create(); + $comments->map->tags()->each->create(); + } + + public function testItCanEagerLoad() + { + DB::enableQueryLog(); + + $users = User::query() + ->with([ + 'avatar', + 'posts' => [ + 'comments' => [ + 'tags', + ], + 'image', + ], + ])->get(); + + $this->assertCount(6, DB::getQueryLog()); + $this->assertCount(1, $users); + $this->assertTrue($users[0]->relationLoaded('avatar')); + $this->assertNotNull($users[0]->avatar); + $this->assertTrue($users[0]->relationLoaded('posts')); + $this->assertCount(2, $users[0]->posts); + $this->assertTrue($users[0]->posts[0]->isNot($users[0]->posts[1])); + $this->assertTrue($users[0]->posts->every->relationLoaded('image')); + $this->assertCount(2, $users[0]->posts->map->image); + $this->assertTrue($users[0]->posts[0]->image->isNot($users[0]->posts[1]->image)); + $this->assertTrue($users[0]->posts->every->relationLoaded('comments')); + $this->assertCount(2, $users[0]->posts->flatMap->comments); + $this->assertTrue($users[0]->posts[0]->comments[0]->isNot($users[0]->posts[1]->comments[0])); + $this->assertTrue($users[0]->posts->flatMap->comments->every->relationLoaded('tags')); + $this->assertCount(6, $users[0]->posts->flatMap->comments->flatMap->tags); + } + + public function testItAppliesConstraintsViaClosuresAndCanContinueEagerLoading() + { + DB::enableQueryLog(); + + $users = User::query() + ->with([ + 'posts' => fn ($query) => $query->withCount('comments')->with([ + 'comments' => [ + 'tags', + ], + ]), + ]) + ->get(); + + $this->assertCount(4, DB::getQueryLog()); + $this->assertCount(1, $users); + $this->assertTrue($users[0]->relationLoaded('posts')); + $this->assertCount(2, $users[0]->posts); + $users[0]->posts->every(fn ($post) => $this->assertEquals(1, $post->comments_count)); + $this->assertTrue($users[0]->posts->every->relationLoaded('comments')); + $this->assertCount(2, $users[0]->posts->flatMap->comments); + $this->assertTrue($users[0]->posts->flatMap->comments->every->relationLoaded('tags')); + } + + public function testItCanSpecifyAttributesToSelectInKeys() + { + DB::enableQueryLog(); + + $users = User::query() + ->with([ + 'posts:id,title,user_id' => [ + 'comments:id,content,post_id' => [ + 'tags', + ], + ], + ]) + ->get(); + + $this->assertCount(4, DB::getQueryLog()); + $this->assertCount(1, $users); + $this->assertTrue($users[0]->relationLoaded('posts')); + $this->assertCount(2, $users[0]->posts); + $users[0]->posts->every(fn ($post) => $this->assertSame(['id', 'title', 'user_id'], array_keys($post->getAttributes()))); + $this->assertTrue($users[0]->posts->every->relationLoaded('comments')); + $this->assertCount(2, $users[0]->posts->flatMap->comments); + $users[0]->posts->flatMap->comments->every(fn ($post) => $this->assertSame(['id', 'content', 'post_id'], array_keys($post->getAttributes()))); + $this->assertTrue($users[0]->posts->flatMap->comments->every->relationLoaded('tags')); + $this->assertCount(6, $users[0]->posts->flatMap->comments->flatMap->tags); + } + + public function testItMixesWithDotNotation() + { + DB::enableQueryLog(); + + $users = User::query() + ->with([ + 'posts' => [ + 'comments', + ], + 'posts.image', + ]) + ->get(); + + $this->assertCount(4, DB::getQueryLog()); + $this->assertCount(1, $users); + $this->assertTrue($users[0]->relationLoaded('posts')); + $this->assertCount(2, $users[0]->posts); + $this->assertTrue($users[0]->posts->every->relationLoaded('comments')); + $this->assertCount(2, $users[0]->posts->flatMap->comments); + $this->assertTrue($users[0]->posts->every->relationLoaded('image')); + $this->assertCount(2, $users[0]->posts->map->image); + } + + public function testItMixesConstraintsFromDotNotation() + { + DB::enableQueryLog(); + + $users = User::query() + ->with([ + 'posts.comments' => fn ($query) => $query->with('tags'), + 'posts:id,title,user_id' => [ + 'comments' => fn ($query) => $query->withCount('tags'), + ], + ]) + ->get(); + + $this->assertCount(4, DB::getQueryLog()); + $this->assertCount(1, $users); + $this->assertTrue($users[0]->relationLoaded('posts')); + $this->assertCount(2, $users[0]->posts); + $users[0]->posts->every(fn ($post) => $this->assertNull($post->content)); + $this->assertTrue($users[0]->posts->every->relationLoaded('comments')); + $this->assertCount(2, $users[0]->posts->flatMap->comments); + $users[0]->posts->flatMap->comments->every(fn ($comment) => $this->assertEquals(3, $comment->tags_count)); + $this->assertTrue($users[0]->posts->flatMap->comments->every->relationLoaded('tags')); + $this->assertCount(6, $users[0]->posts->flatMap->comments->flatMap->tags); + } +} + +class User extends Model +{ + public bool $timestamps = false; + + protected array $guarded = []; + + public function posts(): HasMany + { + return $this->hasMany(Post::class); + } + + public function avatar(): HasOne + { + return $this->hasOne(Avatar::class); + } +} + +class Post extends Model +{ + public bool $timestamps = false; + + protected array $guarded = []; + + public function comments(): HasMany + { + return $this->hasMany(Comment::class); + } + + public function image(): HasOne + { + return $this->hasOne(Image::class); + } +} + +class Image extends Model +{ + public bool $timestamps = false; + + protected array $guarded = []; +} + +class Comment extends Model +{ + public bool $timestamps = false; + + protected array $guarded = []; + + public function tags(): HasMany + { + return $this->hasMany(Tag::class); + } +} + +class Tag extends Model +{ + public bool $timestamps = false; + + protected array $guarded = []; +} + +class Avatar extends Model +{ + public bool $timestamps = false; + + protected array $guarded = []; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/tests/Integration/Database/Laravel/EloquentNamedScopeAttributeTest.php b/tests/Integration/Database/Laravel/EloquentNamedScopeAttributeTest.php new file mode 100644 index 000000000..2519749f9 --- /dev/null +++ b/tests/Integration/Database/Laravel/EloquentNamedScopeAttributeTest.php @@ -0,0 +1,54 @@ +markTestSkippedUnless( + $this->usesSqliteInMemoryDatabaseConnection(), + 'Requires in-memory database connection', + ); + } + + #[DataProvider('scopeDataProvider')] + public function testItCanQueryNamedScopedFromTheQueryBuilder(string $methodName): void + { + $query = NamedScopeUser::query()->{$methodName}(true); + + $this->assertSame($this->query, $query->toRawSql()); + } + + #[DataProvider('scopeDataProvider')] + public function testItCanQueryNamedScopedFromStaticQuery(string $methodName): void + { + $query = NamedScopeUser::{$methodName}(true); + + $this->assertSame($this->query, $query->toRawSql()); + } + + public static function scopeDataProvider(): array + { + return [ + 'scope with return' => ['verified'], + 'scope without return' => ['verifiedWithoutReturn'], + ]; + } +} diff --git a/tests/Integration/Database/Laravel/EloquentPaginateTest.php b/tests/Integration/Database/Laravel/EloquentPaginateTest.php new file mode 100644 index 000000000..4e49cfc73 --- /dev/null +++ b/tests/Integration/Database/Laravel/EloquentPaginateTest.php @@ -0,0 +1,116 @@ +increments('id'); + $table->string('title')->nullable(); + $table->unsignedInteger('user_id')->nullable(); + $table->timestamps(); + }); + + Schema::create('users', function ($table) { + $table->increments('id'); + $table->timestamps(); + }); + } + + public function testPaginationOnTopOfColumns() + { + for ($i = 1; $i <= 50; ++$i) { + Post::create([ + 'title' => 'Title ' . $i, + ]); + } + + $this->assertCount(15, Post::paginate(15, ['id', 'title'])); + } + + public function testPaginationWithDistinct() + { + for ($i = 1; $i <= 3; ++$i) { + Post::create(['title' => 'Hello world']); + Post::create(['title' => 'Goodbye world']); + } + + $query = Post::query()->distinct(); + + $this->assertEquals(6, $query->get()->count()); + $this->assertEquals(6, $query->count()); + $this->assertEquals(6, $query->paginate()->total()); + } + + public function testPaginationWithDistinctAndSelect() + { + // This is the 'broken' behaviour, but this test is added to show backwards compatibility. + for ($i = 1; $i <= 3; ++$i) { + Post::create(['title' => 'Hello world']); + Post::create(['title' => 'Goodbye world']); + } + + $query = Post::query()->distinct()->select('title'); + + $this->assertEquals(2, $query->get()->count()); + $this->assertEquals(6, $query->count()); + $this->assertEquals(6, $query->paginate()->total()); + } + + public function testPaginationWithDistinctColumnsAndSelect() + { + for ($i = 1; $i <= 3; ++$i) { + Post::create(['title' => 'Hello world']); + Post::create(['title' => 'Goodbye world']); + } + + $query = Post::query()->distinct('title')->select('title'); + + $this->assertEquals(2, $query->get()->count()); + $this->assertEquals(2, $query->count()); + $this->assertEquals(2, $query->paginate()->total()); + } + + public function testPaginationWithDistinctColumnsAndSelectAndJoin() + { + for ($i = 1; $i <= 5; ++$i) { + $user = User::create(); + for ($j = 1; $j <= 10; ++$j) { + Post::create([ + 'title' => 'Title ' . $i, + 'user_id' => $user->id, + ]); + } + } + + $query = User::query()->join('posts', 'posts.user_id', '=', 'users.id') + ->distinct('users.id')->select('users.*'); + + $this->assertEquals(5, $query->get()->count()); + $this->assertEquals(5, $query->count()); + $this->assertEquals(5, $query->paginate()->total()); + } +} + +class Post extends Model +{ + protected array $guarded = []; +} + +class User extends Model +{ + protected array $guarded = []; +} diff --git a/tests/Integration/Database/Laravel/EloquentPivotEventsTest.php b/tests/Integration/Database/Laravel/EloquentPivotEventsTest.php new file mode 100644 index 000000000..52ea9e30d --- /dev/null +++ b/tests/Integration/Database/Laravel/EloquentPivotEventsTest.php @@ -0,0 +1,384 @@ +increments('id'); + $table->string('email'); + $table->timestamps(); + }); + + Schema::create('projects', function (Blueprint $table) { + $table->increments('id'); + $table->string('name'); + $table->timestamps(); + }); + + Schema::create('equipments', function (Blueprint $table) { + $table->increments('id'); + $table->string('name'); + $table->timestamps(); + }); + + Schema::create('equipmentables', function (Blueprint $table) { + $table->increments('id'); + $table->morphs('equipmentable'); + $table->foreignId('equipment_id'); + }); + + Schema::create('project_users', function (Blueprint $table) { + $table->integer('user_id'); + $table->integer('project_id'); + $table->text('permissions')->nullable(); + $table->string('role')->nullable(); + }); + } + + public function testPivotWillTriggerEventsToBeFired() + { + $user = PivotEventsTestUser::forceCreate(['email' => 'taylor@laravel.com']); + $user2 = PivotEventsTestUser::forceCreate(['email' => 'ralph@ralphschindler.com']); + $project = PivotEventsTestProject::forceCreate(['name' => 'Test Project']); + + $project->collaborators()->attach($user); + $this->assertEquals(['saving', 'creating', 'created', 'saved'], PivotEventsTestCollaborator::$eventsCalled); + + PivotEventsTestCollaborator::$eventsCalled = []; + $project->collaborators()->sync([$user2->id]); + $this->assertEquals(['deleting', 'deleted', 'saving', 'creating', 'created', 'saved'], PivotEventsTestCollaborator::$eventsCalled); + + PivotEventsTestCollaborator::$eventsCalled = []; + $project->collaborators()->sync([$user->id => ['role' => 'owner'], $user2->id => ['role' => 'contributor']]); + $this->assertEquals(['saving', 'creating', 'created', 'saved', 'saving', 'updating', 'updated', 'saved'], PivotEventsTestCollaborator::$eventsCalled); + + PivotEventsTestCollaborator::$eventsCalled = []; + $project->collaborators()->detach($user); + $this->assertEquals(['deleting', 'deleted'], PivotEventsTestCollaborator::$eventsCalled); + } + + public function testPivotWithPivotValueWillTriggerEventsToBeFired() + { + $user = PivotEventsTestUser::forceCreate(['email' => 'taylor@laravel.com']); + $user2 = PivotEventsTestUser::forceCreate(['email' => 'ralph@ralphschindler.com']); + $project = PivotEventsTestProject::forceCreate(['name' => 'Test Project']); + + $project->managers()->attach($user); + $this->assertEquals(['saving', 'creating', 'created', 'saved'], PivotEventsTestCollaborator::$eventsCalled); + $project->managers()->attach($user2); + + PivotEventsTestCollaborator::$eventsCalled = []; + $project->managers()->updateExistingPivot($user->id, ['permissions' => ['foo', 'bar']]); + $this->assertEquals(['saving', 'updating', 'updated', 'saved'], PivotEventsTestCollaborator::$eventsCalled); + $project->managers()->detach($user2); + + PivotEventsTestCollaborator::$eventsCalled = []; + $project->managers()->sync([$user2->id]); + $this->assertEquals(['deleting', 'deleted', 'saving', 'creating', 'created', 'saved'], PivotEventsTestCollaborator::$eventsCalled); + + PivotEventsTestCollaborator::$eventsCalled = []; + $project->managers()->sync([$user->id => ['permissions' => ['foo']], $user2->id => ['permissions' => ['bar']]]); + $this->assertEquals(['saving', 'creating', 'created', 'saved', 'saving', 'updating', 'updated', 'saved'], PivotEventsTestCollaborator::$eventsCalled); + + PivotEventsTestCollaborator::$eventsCalled = []; + $project->managers()->detach($user); + $this->assertEquals(['deleting', 'deleted'], PivotEventsTestCollaborator::$eventsCalled); + } + + public function testPivotWithPivotCriteriaTriggerEventsToBeFiredOnCreateUpdateNoneOnDetach() + { + $user = PivotEventsTestUser::forceCreate(['email' => 'taylor@laravel.com']); + $user2 = PivotEventsTestUser::forceCreate(['email' => 'ralph@ralphschindler.com']); + $project = PivotEventsTestProject::forceCreate(['name' => 'Test Project']); + + $project->contributors()->sync([$user->id, $user2->id]); + $this->assertEquals(['saving', 'creating', 'created', 'saved', 'saving', 'creating', 'created', 'saved'], PivotEventsTestCollaborator::$eventsCalled); + + PivotEventsTestCollaborator::$eventsCalled = []; + $project->contributors()->detach($user->id); + $this->assertEquals([], PivotEventsTestCollaborator::$eventsCalled); + } + + public function testCustomPivotUpdateEventHasExistingAttributes() + { + $_SERVER['pivot_attributes'] = false; + + $user = PivotEventsTestUser::forceCreate([ + 'email' => 'taylor@laravel.com', + ]); + + $project = PivotEventsTestProject::forceCreate([ + 'name' => 'Test Project', + ]); + + $project->collaborators()->attach($user, ['permissions' => ['foo', 'bar']]); + + $project->collaborators()->updateExistingPivot($user->id, ['role' => 'Lead Developer']); + + $this->assertEquals( + [ + 'user_id' => '1', + 'project_id' => '1', + 'permissions' => '["foo","bar"]', + 'role' => 'Lead Developer', + ], + $_SERVER['pivot_attributes'] + ); + } + + public function testCustomPivotUpdateEventHasDirtyCorrect() + { + $_SERVER['pivot_dirty_attributes'] = false; + + $user = PivotEventsTestUser::forceCreate([ + 'email' => 'taylor@laravel.com', + ]); + + $project = PivotEventsTestProject::forceCreate([ + 'name' => 'Test Project', + ]); + + $project->collaborators()->attach($user, ['permissions' => ['foo', 'bar'], 'role' => 'Developer']); + + $project->collaborators()->updateExistingPivot($user->id, ['role' => 'Lead Developer']); + + $this->assertSame(['role' => 'Lead Developer'], $_SERVER['pivot_dirty_attributes']); + } + + public function testCustomMorphPivotClassDetachAttributes() + { + $project = PivotEventsTestProject::forceCreate([ + 'name' => 'Test Project', + ]); + + PivotEventsTestModelEquipment::deleting(function ($model) use ($project) { + $this->assertInstanceOf(PivotEventsTestProject::class, $model->equipmentable); + $this->assertEquals($project->id, $model->equipmentable->id); + }); + + $equipment = PivotEventsTestEquipment::forceCreate([ + 'name' => 'important-equipment', + ]); + + $project->equipments()->save($equipment); + $equipment->projects()->sync([]); + + $this->assertEquals( + [PivotEventsTestProject::class, PivotEventsTestProject::class, PivotEventsTestProject::class, PivotEventsTestProject::class, PivotEventsTestProject::class, PivotEventsTestProject::class], + PivotEventsTestModelEquipment::$eventsMorphClasses + ); + + $this->assertEquals( + ['equipmentable_type', 'equipmentable_type', 'equipmentable_type', 'equipmentable_type', 'equipmentable_type', 'equipmentable_type'], + PivotEventsTestModelEquipment::$eventsMorphTypes + ); + } +} + +class PivotEventsTestUser extends Model +{ + public ?string $table = 'users'; +} + +class PivotEventsTestEquipment extends Model +{ + public ?string $table = 'equipments'; + + public function getForeignKey(): string + { + return 'equipment_id'; + } + + public function projects(): MorphToMany + { + return $this->morphedByMany(PivotEventsTestProject::class, 'equipmentable')->using(PivotEventsTestModelEquipment::class); + } +} + +class PivotEventsTestProject extends Model +{ + public ?string $table = 'projects'; + + public function collaborators(): BelongsToMany + { + return $this->belongsToMany( + PivotEventsTestUser::class, + 'project_users', + 'project_id', + 'user_id' + )->using(PivotEventsTestCollaborator::class); + } + + public function contributors(): BelongsToMany + { + return $this->belongsToMany(PivotEventsTestUser::class, 'project_users', 'project_id', 'user_id') + ->using(PivotEventsTestCollaborator::class) + ->wherePivot('role', 'contributor'); + } + + public function managers(): BelongsToMany + { + return $this->belongsToMany(PivotEventsTestUser::class, 'project_users', 'project_id', 'user_id') + ->using(PivotEventsTestCollaborator::class) + ->withPivotValue('role', 'manager'); + } + + public function equipments(): MorphToMany + { + return $this->morphToMany(PivotEventsTestEquipment::class, 'equipmentable')->using(PivotEventsTestModelEquipment::class); + } +} + +class PivotEventsTestModelEquipment extends MorphPivot +{ + public ?string $table = 'equipmentables'; + + public static array $eventsMorphClasses = []; + + public static array $eventsMorphTypes = []; + + public static function boot(): void + { + parent::boot(); + + static::creating(function ($model) { + static::$eventsMorphClasses[] = $model->morphClass; + static::$eventsMorphTypes[] = $model->morphType; + }); + + static::created(function ($model) { + static::$eventsMorphClasses[] = $model->morphClass; + static::$eventsMorphTypes[] = $model->morphType; + }); + + static::updating(function ($model) { + static::$eventsMorphClasses[] = $model->morphClass; + static::$eventsMorphTypes[] = $model->morphType; + }); + + static::updated(function ($model) { + static::$eventsMorphClasses[] = $model->morphClass; + static::$eventsMorphTypes[] = $model->morphType; + }); + + static::saving(function ($model) { + static::$eventsMorphClasses[] = $model->morphClass; + static::$eventsMorphTypes[] = $model->morphType; + }); + + static::saved(function ($model) { + static::$eventsMorphClasses[] = $model->morphClass; + static::$eventsMorphTypes[] = $model->morphType; + }); + + static::deleting(function ($model) { + static::$eventsMorphClasses[] = $model->morphClass; + static::$eventsMorphTypes[] = $model->morphType; + }); + + static::deleted(function ($model) { + static::$eventsMorphClasses[] = $model->morphClass; + static::$eventsMorphTypes[] = $model->morphType; + }); + } + + public function equipment(): BelongsTo + { + return $this->belongsTo(PivotEventsTestEquipment::class); + } + + public function equipmentable(): MorphTo + { + return $this->morphTo(); + } +} + +class PivotEventsTestCollaborator extends Pivot +{ + public ?string $table = 'project_users'; + + public bool $timestamps = false; + + protected array $casts = [ + 'permissions' => 'json', + ]; + + public static array $eventsCalled = []; + + public static function boot(): void + { + parent::boot(); + + static::creating(function ($model) { + static::$eventsCalled[] = 'creating'; + }); + + static::created(function ($model) { + static::$eventsCalled[] = 'created'; + }); + + static::updating(function ($model) { + static::$eventsCalled[] = 'updating'; + }); + + static::updated(function ($model) { + $_SERVER['pivot_attributes'] = $model->getAttributes(); + $_SERVER['pivot_dirty_attributes'] = $model->getDirty(); + static::$eventsCalled[] = 'updated'; + }); + + static::saving(function ($model) { + static::$eventsCalled[] = 'saving'; + }); + + static::saved(function ($model) { + static::$eventsCalled[] = 'saved'; + }); + + static::deleting(function ($model) { + static::$eventsCalled[] = 'deleting'; + }); + + static::deleted(function ($model) { + static::$eventsCalled[] = 'deleted'; + }); + } +} diff --git a/tests/Integration/Database/Laravel/EloquentPivotSerializationTest.php b/tests/Integration/Database/Laravel/EloquentPivotSerializationTest.php new file mode 100644 index 000000000..a396b990d --- /dev/null +++ b/tests/Integration/Database/Laravel/EloquentPivotSerializationTest.php @@ -0,0 +1,206 @@ +increments('id'); + $table->string('email'); + $table->timestamps(); + }); + + Schema::create('projects', function (Blueprint $table) { + $table->increments('id'); + $table->string('name'); + $table->timestamps(); + }); + + Schema::create('project_users', function (Blueprint $table) { + $table->integer('user_id'); + $table->integer('project_id'); + }); + + Schema::create('tags', function (Blueprint $table) { + $table->increments('id'); + $table->string('name'); + $table->timestamps(); + }); + + Schema::create('taggables', function (Blueprint $table) { + $table->integer('tag_id'); + $table->integer('taggable_id'); + $table->string('taggable_type'); + }); + } + + public function testPivotCanBeSerializedAndRestored() + { + $user = PivotSerializationTestUser::forceCreate(['email' => 'taylor@laravel.com']); + $project = PivotSerializationTestProject::forceCreate(['name' => 'Test Project']); + $project->collaborators()->attach($user); + + $project = $project->fresh(); + + $class = new PivotSerializationTestClass($project->collaborators->first()->pivot); + $class = unserialize(serialize($class)); + + $this->assertEquals($project->collaborators->first()->pivot->user_id, $class->pivot->user_id); + $this->assertEquals($project->collaborators->first()->pivot->project_id, $class->pivot->project_id); + + $class->pivot->save(); + } + + public function testMorphPivotCanBeSerializedAndRestored() + { + $project = PivotSerializationTestProject::forceCreate(['name' => 'Test Project']); + $tag = PivotSerializationTestTag::forceCreate(['name' => 'Test Tag']); + $project->tags()->attach($tag); + + $project = $project->fresh(); + + $class = new PivotSerializationTestClass($project->tags->first()->pivot); + $class = unserialize(serialize($class)); + + $this->assertEquals($project->tags->first()->pivot->tag_id, $class->pivot->tag_id); + $this->assertEquals($project->tags->first()->pivot->taggable_id, $class->pivot->taggable_id); + $this->assertEquals($project->tags->first()->pivot->taggable_type, $class->pivot->taggable_type); + + $class->pivot->save(); + } + + public function testCollectionOfPivotsCanBeSerializedAndRestored() + { + $user = PivotSerializationTestUser::forceCreate(['email' => 'taylor@laravel.com']); + $user2 = PivotSerializationTestUser::forceCreate(['email' => 'mohamed@laravel.com']); + $project = PivotSerializationTestProject::forceCreate(['name' => 'Test Project']); + + $project->collaborators()->attach($user); + $project->collaborators()->attach($user2); + + $project = $project->fresh(); + + $class = new PivotSerializationTestCollectionClass(DatabaseCollection::make($project->collaborators->map->pivot)); + $class = unserialize(serialize($class)); + + $this->assertEquals($project->collaborators[0]->pivot->user_id, $class->pivots[0]->user_id); + $this->assertEquals($project->collaborators[1]->pivot->project_id, $class->pivots[1]->project_id); + } + + public function testCollectionOfMorphPivotsCanBeSerializedAndRestored() + { + $tag = PivotSerializationTestTag::forceCreate(['name' => 'Test Tag 1']); + $tag2 = PivotSerializationTestTag::forceCreate(['name' => 'Test Tag 2']); + $project = PivotSerializationTestProject::forceCreate(['name' => 'Test Project']); + + $project->tags()->attach($tag); + $project->tags()->attach($tag2); + + $project = $project->fresh(); + + $class = new PivotSerializationTestCollectionClass(DatabaseCollection::make($project->tags->map->pivot)); + $class = unserialize(serialize($class)); + + $this->assertEquals($project->tags[0]->pivot->tag_id, $class->pivots[0]->tag_id); + $this->assertEquals($project->tags[0]->pivot->taggable_id, $class->pivots[0]->taggable_id); + $this->assertEquals($project->tags[0]->pivot->taggable_type, $class->pivots[0]->taggable_type); + + $this->assertEquals($project->tags[1]->pivot->tag_id, $class->pivots[1]->tag_id); + $this->assertEquals($project->tags[1]->pivot->taggable_id, $class->pivots[1]->taggable_id); + $this->assertEquals($project->tags[1]->pivot->taggable_type, $class->pivots[1]->taggable_type); + } +} + +class PivotSerializationTestClass +{ + use SerializesModels; + + public $pivot; + + public function __construct($pivot) + { + $this->pivot = $pivot; + } +} + +class PivotSerializationTestCollectionClass +{ + use SerializesModels; + + public $pivots; + + public function __construct($pivots) + { + $this->pivots = $pivots; + } +} + +class PivotSerializationTestUser extends Model +{ + public ?string $table = 'users'; +} + +class PivotSerializationTestProject extends Model +{ + public ?string $table = 'projects'; + + public function collaborators(): BelongsToMany + { + return $this->belongsToMany( + PivotSerializationTestUser::class, + 'project_users', + 'project_id', + 'user_id' + )->using(PivotSerializationTestCollaborator::class); + } + + public function tags(): MorphToMany + { + return $this->morphToMany(PivotSerializationTestTag::class, 'taggable', 'taggables', 'taggable_id', 'tag_id') + ->using(PivotSerializationTestTagAttachment::class); + } +} + +class PivotSerializationTestTag extends Model +{ + public ?string $table = 'tags'; + + public function projects(): MorphToMany + { + return $this->morphedByMany(PivotSerializationTestProject::class, 'taggable', 'taggables', 'tag_id', 'taggable_id') + ->using(PivotSerializationTestTagAttachment::class); + } +} + +class PivotSerializationTestCollaborator extends Pivot +{ + public ?string $table = 'project_users'; + + public bool $timestamps = false; +} + +class PivotSerializationTestTagAttachment extends MorphPivot +{ + public ?string $table = 'taggables'; + + public bool $timestamps = false; +} diff --git a/tests/Integration/Database/Laravel/EloquentPivotTest.php b/tests/Integration/Database/Laravel/EloquentPivotTest.php new file mode 100644 index 000000000..2ef58fe5c --- /dev/null +++ b/tests/Integration/Database/Laravel/EloquentPivotTest.php @@ -0,0 +1,172 @@ +increments('id'); + $table->string('email'); + $table->timestamps(); + }); + + Schema::create('projects', function (Blueprint $table) { + $table->increments('id'); + $table->string('name'); + $table->timestamps(); + }); + + Schema::create('collaborators', function (Blueprint $table) { + $table->integer('user_id'); + $table->integer('project_id'); + $table->text('permissions')->nullable(); + }); + + Schema::create('contributors', function (Blueprint $table) { + $table->id(); + $table->integer('user_id'); + $table->integer('project_id'); + $table->text('permissions')->nullable(); + }); + + Schema::create('subscriptions', function (Blueprint $table) { + $table->integer('user_id'); + $table->integer('project_id'); + $table->string('status'); + }); + } + + public function testPivotConvenientHelperReturnExpectedResult() + { + $user = PivotTestUser::forceCreate(['email' => 'taylor@laravel.com']); + $user2 = PivotTestUser::forceCreate(['email' => 'ralph@ralphschindler.com']); + $project = PivotTestProject::forceCreate(['name' => 'Test Project']); + + $project->contributors()->attach($user); + $project->collaborators()->attach($user2); + + tap($project->contributors->first()->pivot, function ($pivot) { + $this->assertEquals(1, $pivot->getKey()); + $this->assertEquals(1, $pivot->getQueueableId()); + $this->assertSame('user_id', $pivot->getRelatedKey()); + $this->assertSame('project_id', $pivot->getForeignKey()); + }); + + tap($project->collaborators->first()->pivot, function ($pivot) { + $this->assertNull($pivot->getKey()); + $this->assertSame('project_id:1:user_id:2', $pivot->getQueueableId()); + $this->assertSame('user_id', $pivot->getRelatedKey()); + $this->assertSame('project_id', $pivot->getForeignKey()); + }); + } + + public function testPivotValuesCanBeSetFromRelationDefinition() + { + $user = PivotTestUser::forceCreate(['email' => 'taylor@laravel.com']); + $active = PivotTestProject::forceCreate(['name' => 'Active Project']); + $inactive = PivotTestProject::forceCreate(['name' => 'Inactive Project']); + + $this->assertSame('active', $user->activeSubscriptions()->newPivot()->status); + $this->assertSame('inactive', $user->inactiveSubscriptions()->newPivot()->status); + + $user->activeSubscriptions()->attach($active); + $user->inactiveSubscriptions()->attach($inactive); + + $this->assertSame('active', $user->activeSubscriptions->first()->pivot->status); + $this->assertSame('inactive', $user->inactiveSubscriptions->first()->pivot->status); + } +} + +class PivotTestUser extends Model +{ + public ?string $table = 'users'; + + public function activeSubscriptions(): BelongsToMany + { + return $this->belongsToMany(PivotTestProject::class, 'subscriptions', 'user_id', 'project_id') + ->withPivotValue('status', 'active') + ->withPivot('status') + ->using(PivotTestSubscription::class); + } + + public function inactiveSubscriptions(): BelongsToMany + { + return $this->belongsToMany(PivotTestProject::class, 'subscriptions', 'user_id', 'project_id') + ->withPivotValue('status', 'inactive') + ->withPivot('status') + ->using(PivotTestSubscription::class); + } +} + +class PivotTestProject extends Model +{ + public ?string $table = 'projects'; + + public function collaborators(): BelongsToMany + { + return $this->belongsToMany( + PivotTestUser::class, + 'collaborators', + 'project_id', + 'user_id' + )->withPivot('permissions') + ->using(PivotTestCollaborator::class); + } + + public function contributors(): BelongsToMany + { + return $this->belongsToMany(PivotTestUser::class, 'contributors', 'project_id', 'user_id') + ->withPivot('id', 'permissions') + ->using(PivotTestContributor::class); + } +} + +class PivotTestCollaborator extends Pivot +{ + public ?string $table = 'collaborators'; + + public bool $timestamps = false; + + protected array $casts = [ + 'permissions' => 'json', + ]; +} + +class PivotTestContributor extends Pivot +{ + public ?string $table = 'contributors'; + + public bool $timestamps = false; + + public bool $incrementing = true; + + protected array $casts = [ + 'permissions' => 'json', + ]; +} + +class PivotTestSubscription extends Pivot +{ + public ?string $table = 'subscriptions'; + + public bool $timestamps = false; + + protected array $attributes = [ + 'status' => 'active', + ]; +} diff --git a/tests/Integration/Database/Laravel/EloquentPivotWithoutTimestampTest.fixtures.php b/tests/Integration/Database/Laravel/EloquentPivotWithoutTimestampTest.fixtures.php new file mode 100644 index 000000000..fd4827766 --- /dev/null +++ b/tests/Integration/Database/Laravel/EloquentPivotWithoutTimestampTest.fixtures.php @@ -0,0 +1,79 @@ +belongsToMany(Role::class) + ->withPivot('notes') + ->using(UserRole::class) + ->withTimestamps(updatedAt: false); + } +} + +#[UseFactory(RoleFactory::class)] +class Role extends Model +{ + use HasFactory; + + public function users(): BelongsToMany + { + return $this->belongsToMany(User::class) + ->withPivot('notes') + ->using(UserRole::class); + } +} + +class RoleFactory extends Factory +{ + public function definition(): array + { + return [ + 'name' => fake()->name(), + ]; + } +} + +class UserRole extends Pivot +{ + protected ?string $table = 'role_user'; + + public function getUpdatedAtColumn(): ?string + { + return null; + } +} + +function migrate() +{ + Schema::create('roles', function (Blueprint $table) { + $table->id(); + $table->string('name'); + $table->timestamps(); + }); + + Schema::create('role_user', function (Blueprint $table) { + $table->foreignId('user_id'); + $table->foreignId('role_id'); + $table->text('notes'); + $table->timestamp('created_at')->nullable(); + }); +} diff --git a/tests/Integration/Database/Laravel/EloquentPivotWithoutTimestampTest.php b/tests/Integration/Database/Laravel/EloquentPivotWithoutTimestampTest.php new file mode 100644 index 000000000..22edc4405 --- /dev/null +++ b/tests/Integration/Database/Laravel/EloquentPivotWithoutTimestampTest.php @@ -0,0 +1,44 @@ +freezeSecond(); + + $user = App\User::factory()->create(); + $role = App\Role::factory()->create(); + + $user->roles()->attach($role->getKey(), ['notes' => 'Laravel']); + + $this->assertDatabaseHas('role_user', [ + 'user_id' => $user->getKey(), + 'role_id' => $role->getKey(), + 'notes' => 'Laravel', + 'created_at' => $now, + ]); + } +} diff --git a/tests/Integration/Database/Laravel/EloquentPrunableTest.php b/tests/Integration/Database/Laravel/EloquentPrunableTest.php new file mode 100644 index 000000000..c7aa1eda1 --- /dev/null +++ b/tests/Integration/Database/Laravel/EloquentPrunableTest.php @@ -0,0 +1,191 @@ +each(function ($table) { + Schema::create($table, function (Blueprint $table) { + $table->increments('id'); + $table->string('name')->nullable(); + $table->softDeletes(); + $table->boolean('pruned')->default(false); + $table->timestamps(); + }); + }); + } + + public function testPrunableMethodMustBeImplemented() + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage( + 'Please implement', + ); + + PrunableTestModelMissingPrunableMethod::create()->pruneAll(); + } + + public function testPrunesRecords() + { + Event::fake(); + + collect(range(1, 5000))->map(function ($id) { + return ['name' => 'foo']; + })->chunk(200)->each(function ($chunk) { + PrunableTestModel::insert($chunk->all()); + }); + + $count = (new PrunableTestModel())->pruneAll(); + + $this->assertEquals(1500, $count); + $this->assertEquals(3500, PrunableTestModel::count()); + + Event::assertDispatched(ModelsPruned::class, 2); + } + + public function testPrunesSoftDeletedRecords() + { + Event::fake(); + + collect(range(1, 5000))->map(function ($id) { + return ['deleted_at' => now()]; + })->chunk(200)->each(function ($chunk) { + PrunableSoftDeleteTestModel::insert($chunk->all()); + }); + + $count = (new PrunableSoftDeleteTestModel())->pruneAll(); + + $this->assertEquals(3000, $count); + $this->assertEquals(0, PrunableSoftDeleteTestModel::count()); + $this->assertEquals(2000, PrunableSoftDeleteTestModel::withTrashed()->count()); + + Event::assertDispatched(ModelsPruned::class, 3); + } + + public function testPruneWithCustomPruneMethod() + { + Event::fake(); + + collect(range(1, 5000))->map(function ($id) { + return ['name' => 'foo']; + })->chunk(200)->each(function ($chunk) { + PrunableWithCustomPruneMethodTestModel::insert($chunk->all()); + }); + + $count = (new PrunableWithCustomPruneMethodTestModel())->pruneAll(); + + $this->assertEquals(1000, $count); + $this->assertTrue((bool) PrunableWithCustomPruneMethodTestModel::first()->pruned); + $this->assertFalse((bool) PrunableWithCustomPruneMethodTestModel::orderBy('id', 'desc')->first()->pruned); + $this->assertEquals(5000, PrunableWithCustomPruneMethodTestModel::count()); + + Event::assertDispatched(ModelsPruned::class, 1); + } + + public function testPruneWithExceptionAtOneOfModels() + { + Event::fake(); + Exceptions::fake(); + + collect(range(1, 5000))->map(function ($id) { + return ['name' => 'foo']; + })->chunk(200)->each(function ($chunk) { + PrunableWithException::insert($chunk->all()); + }); + + $count = (new PrunableWithException())->pruneAll(); + + $this->assertEquals(999, $count); + + Event::assertDispatched(ModelsPruned::class, 1); + Event::assertDispatched(fn (ModelsPruned $event) => $event->count === 999); + Exceptions::assertReportedCount(1); + Exceptions::assertReported(fn (Exception $exception) => $exception->getMessage() === 'foo bar'); + } +} + +class PrunableTestModel extends Model +{ + use Prunable; + + public function prunable() + { + return $this->where('id', '<=', 1500); + } +} + +class PrunableSoftDeleteTestModel extends Model +{ + use Prunable; + use SoftDeletes; + + public function prunable() + { + return $this->where('id', '<=', 3000); + } +} + +class PrunableWithCustomPruneMethodTestModel extends Model +{ + use Prunable; + + public function prunable() + { + return $this->where('id', '<=', 1000); + } + + public function prune() + { + $this->forceFill([ + 'pruned' => true, + ])->save(); + } +} + +class PrunableWithException extends Model +{ + use Prunable; + + public function prunable() + { + return $this->where('id', '<=', 1000); + } + + public function prune() + { + if ($this->id === 500) { + throw new Exception('foo bar'); + } + } +} + +class PrunableTestModelMissingPrunableMethod extends Model +{ + use Prunable; +} diff --git a/tests/Integration/Database/Laravel/EloquentPushTest.php b/tests/Integration/Database/Laravel/EloquentPushTest.php new file mode 100644 index 000000000..91a0f7de8 --- /dev/null +++ b/tests/Integration/Database/Laravel/EloquentPushTest.php @@ -0,0 +1,99 @@ +increments('id'); + $table->string('name'); + }); + + Schema::create('posts', function (Blueprint $table) { + $table->increments('id'); + $table->string('title'); + $table->unsignedInteger('user_id'); + }); + + Schema::create('comments', function (Blueprint $table) { + $table->increments('id'); + $table->string('comment'); + $table->unsignedInteger('post_id'); + }); + } + + public function testPushMethodSavesTheRelationshipsRecursively() + { + $user = new UserX(); + $user->name = 'Test'; + $user->save(); + $user->posts()->create(['title' => 'Test title']); + + $post = PostX::firstOrFail(); + $post->comments()->create(['comment' => 'Test comment']); + + $user = $user->fresh(); + $user->name = 'Test 1'; + $user->posts[0]->title = 'Test title 1'; + $user->posts[0]->comments[0]->comment = 'Test comment 1'; + $user->push(); + + $this->assertSame(1, UserX::count()); + $this->assertSame('Test 1', UserX::firstOrFail()->name); + $this->assertSame(1, PostX::count()); + $this->assertSame('Test title 1', PostX::firstOrFail()->title); + $this->assertSame(1, CommentX::count()); + $this->assertSame('Test comment 1', CommentX::firstOrFail()->comment); + } +} + +class UserX extends Model +{ + public bool $timestamps = false; + + protected array $guarded = []; + + protected ?string $table = 'users'; + + public function posts(): HasMany + { + return $this->hasMany(PostX::class, 'user_id'); + } +} + +class PostX extends Model +{ + public bool $timestamps = false; + + protected array $guarded = []; + + protected ?string $table = 'posts'; + + public function comments(): HasMany + { + return $this->hasMany(CommentX::class, 'post_id'); + } +} + +class CommentX extends Model +{ + public bool $timestamps = false; + + protected array $guarded = []; + + protected ?string $table = 'comments'; +} diff --git a/tests/Integration/Database/Laravel/EloquentStrictLoadingTest.php b/tests/Integration/Database/Laravel/EloquentStrictLoadingTest.php new file mode 100644 index 000000000..3e647958a --- /dev/null +++ b/tests/Integration/Database/Laravel/EloquentStrictLoadingTest.php @@ -0,0 +1,256 @@ +increments('id'); + $table->integer('number')->default(1); + }); + + Schema::create('test_model2', function (Blueprint $table) { + $table->increments('id'); + $table->foreignId('model_1_id'); + }); + + Schema::create('test_model3', function (Blueprint $table) { + $table->increments('id'); + $table->foreignId('model_2_id'); + }); + } + + public function testStrictModeThrowsAnExceptionOnLazyLoading() + { + $this->expectException(LazyLoadingViolationException::class); + $this->expectExceptionMessage('Attempted to lazy load'); + + EloquentStrictLoadingTestModel1::create(); + EloquentStrictLoadingTestModel1::create(); + + $models = EloquentStrictLoadingTestModel1::get(); + + $models[0]->modelTwos; + } + + public function testStrictModeDoesntThrowAnExceptionOnLazyLoadingWithSingleModel() + { + EloquentStrictLoadingTestModel1::create(); + + $models = EloquentStrictLoadingTestModel1::get(); + + $this->assertInstanceOf(Collection::class, $models); + } + + public function testStrictModeDoesntThrowAnExceptionOnAttributes() + { + EloquentStrictLoadingTestModel1::create(); + + $models = EloquentStrictLoadingTestModel1::get(['id']); + + $this->assertNull($models[0]->number); + } + + public function testStrictModeDoesntThrowAnExceptionOnEagerLoading() + { + $this->app['config']->set('database.connections.testing.zxc', false); + + EloquentStrictLoadingTestModel1::create(); + EloquentStrictLoadingTestModel1::create(); + + $models = EloquentStrictLoadingTestModel1::with('modelTwos')->get(); + + $this->assertInstanceOf(Collection::class, $models[0]->modelTwos); + } + + public function testStrictModeDoesntThrowAnExceptionOnLazyEagerLoading() + { + EloquentStrictLoadingTestModel1::create(); + EloquentStrictLoadingTestModel1::create(); + + $models = EloquentStrictLoadingTestModel1::get(); + + $models->load('modelTwos'); + + $this->assertInstanceOf(Collection::class, $models[0]->modelTwos); + } + + public function testStrictModeDoesntThrowAnExceptionOnSingleModelLoading() + { + $model = EloquentStrictLoadingTestModel1::create(); + + $model = EloquentStrictLoadingTestModel1::find($model->id); + + $this->assertInstanceOf(Collection::class, $model->modelTwos); + } + + public function testStrictModeThrowsAnExceptionOnLazyLoadingInRelations() + { + $this->expectException(LazyLoadingViolationException::class); + $this->expectExceptionMessage('Attempted to lazy load'); + + $model1 = EloquentStrictLoadingTestModel1::create(); + EloquentStrictLoadingTestModel2::create(['model_1_id' => $model1->id]); + EloquentStrictLoadingTestModel2::create(['model_1_id' => $model1->id]); + + $models = EloquentStrictLoadingTestModel1::with('modelTwos')->get(); + + $models[0]->modelTwos[0]->modelThrees; + } + + public function testStrictModeWithCustomCallbackOnLazyLoading() + { + Event::fake(); + + Model::handleLazyLoadingViolationUsing(function ($model, $key) { + event(new ViolatedLazyLoadingEvent($model, $key)); + }); + + EloquentStrictLoadingTestModel1::create(); + EloquentStrictLoadingTestModel1::create(); + + $models = EloquentStrictLoadingTestModel1::get(); + + $models[0]->modelTwos; + + Event::assertDispatched(ViolatedLazyLoadingEvent::class); + } + + public function testStrictModeWithOverriddenHandlerOnLazyLoading() + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Violated'); + + EloquentStrictLoadingTestModel1WithCustomHandler::create(); + EloquentStrictLoadingTestModel1WithCustomHandler::create(); + + $models = EloquentStrictLoadingTestModel1WithCustomHandler::get(); + + $models[0]->modelTwos; + } + + public function testStrictModeDoesntThrowAnExceptionOnManuallyMadeModel() + { + $model1 = EloquentStrictLoadingTestModel1WithLocalPreventsLazyLoading::make(); + $model2 = EloquentStrictLoadingTestModel2::make(); + $model1->modelTwos->push($model2); + + $this->assertInstanceOf(Collection::class, $model1->modelTwos); + } + + public function testStrictModeDoesntThrowAnExceptionOnRecentlyCreatedModel() + { + $model1 = EloquentStrictLoadingTestModel1WithLocalPreventsLazyLoading::create(); + $this->assertInstanceOf(Collection::class, $model1->modelTwos); + } +} + +class EloquentStrictLoadingTestModel1 extends Model +{ + protected ?string $table = 'test_model1'; + + public bool $timestamps = false; + + protected array $guarded = []; + + public function modelTwos(): HasMany + { + return $this->hasMany(EloquentStrictLoadingTestModel2::class, 'model_1_id'); + } +} + +class EloquentStrictLoadingTestModel1WithCustomHandler extends Model +{ + protected ?string $table = 'test_model1'; + + public bool $timestamps = false; + + protected array $guarded = []; + + public function modelTwos(): HasMany + { + return $this->hasMany(EloquentStrictLoadingTestModel2::class, 'model_1_id'); + } + + protected function handleLazyLoadingViolation(string $key): mixed + { + throw new RuntimeException("Violated {$key}"); + } +} + +class EloquentStrictLoadingTestModel1WithLocalPreventsLazyLoading extends Model +{ + protected ?string $table = 'test_model1'; + + public bool $timestamps = false; + + public bool $preventsLazyLoading = true; + + protected array $guarded = []; + + public function modelTwos(): HasMany + { + return $this->hasMany(EloquentStrictLoadingTestModel2::class, 'model_1_id'); + } +} + +class EloquentStrictLoadingTestModel2 extends Model +{ + protected ?string $table = 'test_model2'; + + public bool $timestamps = false; + + protected array $guarded = []; + + public function modelThrees(): HasMany + { + return $this->hasMany(EloquentStrictLoadingTestModel3::class, 'model_2_id'); + } +} + +class EloquentStrictLoadingTestModel3 extends Model +{ + protected ?string $table = 'test_model3'; + + public bool $timestamps = false; + + protected array $guarded = []; +} + +class ViolatedLazyLoadingEvent +{ + public Model $model; + + public string $key; + + public function __construct(Model $model, string $key) + { + $this->model = $model; + $this->key = $key; + } +} diff --git a/tests/Integration/Database/Laravel/EloquentThroughTest.php b/tests/Integration/Database/Laravel/EloquentThroughTest.php new file mode 100644 index 000000000..4ba4727eb --- /dev/null +++ b/tests/Integration/Database/Laravel/EloquentThroughTest.php @@ -0,0 +1,132 @@ +increments('id'); + $table->boolean('public'); + }); + + Schema::create('other_commentables', function (Blueprint $table) { + $table->increments('id'); + }); + + Schema::create('comments', function (Blueprint $table) { + $table->increments('id'); + $table->string('commentable_type'); + $table->integer('commentable_id'); + }); + + Schema::create('likes', function (Blueprint $table) { + $table->increments('id'); + $table->unsignedInteger('comment_id'); + }); + + $post = tap(new Post(['public' => true]))->save(); + $comment = tap((new Comment())->commentable()->associate($post))->save(); + (new Like())->comment()->associate($comment)->save(); + (new Like())->comment()->associate($comment)->save(); + + $otherCommentable = tap(new OtherCommentable())->save(); + $comment2 = tap((new Comment())->commentable()->associate($otherCommentable))->save(); + (new Like())->comment()->associate($comment2)->save(); + } + + public function test() + { + /** @var Post $post */ + $post = Post::first(); + $this->assertEquals(2, $post->commentLikes()->count()); + } +} + +class Comment extends Model +{ + public bool $timestamps = false; + + public function commentable(): MorphTo + { + return $this->morphTo(); + } + + public function likes(): HasMany + { + return $this->hasMany(Like::class); + } +} + +class Post extends Model +{ + public bool $timestamps = false; + + protected array $guarded = []; + + protected array $withCount = ['comments']; + + public function comments(): MorphMany + { + return $this->morphMany(Comment::class, 'commentable'); + } + + public function commentLikes(): HasManyThrough + { + return $this->through($this->comments())->has('likes'); + } + + public function texts(): HasMany + { + return $this->hasMany(Text::class); + } +} + +class OtherCommentable extends Model +{ + public bool $timestamps = false; + + public function comments(): MorphMany + { + return $this->morphMany(Comment::class, 'commentable'); + } +} + +class Text extends Model +{ + public bool $timestamps = false; + + protected array $guarded = []; + + public function post(): BelongsTo + { + return $this->belongsTo(Post::class); + } +} + +class Like extends Model +{ + public bool $timestamps = false; + + public function comment(): BelongsTo + { + return $this->belongsTo(Comment::class); + } +} diff --git a/tests/Integration/Database/Laravel/EloquentTouchParentWithGlobalScopeTest.php b/tests/Integration/Database/Laravel/EloquentTouchParentWithGlobalScopeTest.php new file mode 100644 index 000000000..02199a3c4 --- /dev/null +++ b/tests/Integration/Database/Laravel/EloquentTouchParentWithGlobalScopeTest.php @@ -0,0 +1,87 @@ +increments('id'); + $table->string('title'); + $table->timestamps(); + }); + + Schema::create('comments', function (Blueprint $table) { + $table->increments('id'); + $table->integer('post_id'); + $table->string('title'); + $table->timestamps(); + }); + } + + public function testBasicCreateAndRetrieve() + { + $post = Post::create(['title' => Str::random(), 'updated_at' => '2016-10-10 10:10:10']); + + $this->assertSame('2016-10-10', $post->fresh()->updated_at->toDateString()); + + $post->comments()->create(['title' => Str::random()]); + + $this->assertNotSame('2016-10-10', $post->fresh()->updated_at->toDateString()); + } +} + +class Post extends Model +{ + protected ?string $table = 'posts'; + + public bool $timestamps = true; + + protected array $guarded = []; + + public function comments(): HasMany + { + return $this->hasMany(Comment::class, 'post_id'); + } + + public static function boot(): void + { + parent::boot(); + + static::addGlobalScope('age', function (Builder $builder) { + $builder->join('comments', 'comments.post_id', '=', 'posts.id'); + }); + } +} + +class Comment extends Model +{ + protected ?string $table = 'comments'; + + public bool $timestamps = true; + + protected array $guarded = []; + + protected array $touches = ['post']; + + public function post(): BelongsTo + { + return $this->belongsTo(Post::class, 'post_id'); + } +} diff --git a/tests/Integration/Database/Laravel/EloquentTransactionWithAfterCommitTest.php b/tests/Integration/Database/Laravel/EloquentTransactionWithAfterCommitTest.php new file mode 100644 index 000000000..5ec43e8b7 --- /dev/null +++ b/tests/Integration/Database/Laravel/EloquentTransactionWithAfterCommitTest.php @@ -0,0 +1,21 @@ +createTransactionTestTables(); + } +} diff --git a/tests/Integration/Database/Laravel/EloquentTransactionWithAfterCommitTests.php b/tests/Integration/Database/Laravel/EloquentTransactionWithAfterCommitTests.php new file mode 100644 index 000000000..2d4f6748b --- /dev/null +++ b/tests/Integration/Database/Laravel/EloquentTransactionWithAfterCommitTests.php @@ -0,0 +1,245 @@ +id(); + $table->string('name'); + $table->string('email')->unique(); + $table->timestamp('email_verified_at')->nullable(); + $table->string('password'); + $table->string('remember_token', 100)->nullable(); + $table->timestamps(); + }); + } + + if (! Schema::hasTable('password_reset_tokens')) { + Schema::create('password_reset_tokens', function (Blueprint $table) { + $table->string('email')->primary(); + $table->string('token'); + $table->timestamp('created_at')->nullable(); + }); + } + } + + public function testObserverIsCalledOnTestsWithAfterCommit() + { + User::observe($observer = EloquentTransactionWithAfterCommitTestsUserObserver::resetting()); + + $user1 = User::unguarded(fn () => User::create(UserFactory::new()->raw())); + + $this->assertTrue($user1->exists); + $this->assertEquals(1, $observer::$calledTimes, 'Failed to assert the observer was called once.'); + } + + public function testObserverCalledWithAfterCommitWhenInsideTransaction() + { + User::observe($observer = EloquentTransactionWithAfterCommitTestsUserObserver::resetting()); + + $user1 = DB::transaction(fn () => User::unguarded(fn () => User::create(UserFactory::new()->raw()))); + + $this->assertTrue($user1->exists); + $this->assertEquals(1, $observer::$calledTimes, 'Failed to assert the observer was called once.'); + } + + public function testObserverCalledWithAfterCommitWhenInsideTransactionWithDispatchSync() + { + User::observe($observer = EloquentTransactionWithAfterCommitTestsUserObserverUsingDispatchSync::resetting()); + + $user1 = DB::transaction(fn () => User::unguarded(fn () => User::create(UserFactory::new()->raw()))); + + $this->assertTrue($user1->exists); + $this->assertEquals(1, $observer::$calledTimes, 'Failed to assert the observer was called once.'); + + $this->assertDatabaseHas('password_reset_tokens', [ + 'email' => $user1->email, + 'token' => sha1($user1->email), + ]); + } + + public function testObserverIsCalledOnTestsWithAfterCommitWhenUsingSavepoint() + { + User::observe($observer = EloquentTransactionWithAfterCommitTestsUserObserver::resetting()); + + $user1 = User::unguarded(fn () => User::createOrFirst(UserFactory::new()->raw())); + + $this->assertTrue($user1->exists); + $this->assertEquals(1, $observer::$calledTimes, 'Failed to assert the observer was called once.'); + } + + public function testObserverIsCalledOnTestsWithAfterCommitWhenUsingSavepointAndInsideTransaction() + { + User::observe($observer = EloquentTransactionWithAfterCommitTestsUserObserver::resetting()); + + $user1 = DB::transaction(fn () => User::unguarded(fn () => User::createOrFirst(UserFactory::new()->raw()))); + + $this->assertTrue($user1->exists); + $this->assertEquals(1, $observer::$calledTimes, 'Failed to assert the observer was called once.'); + } + + public function testObserverIsCalledEvenWhenDeeplyNestingTransactions() + { + User::observe($observer = EloquentTransactionWithAfterCommitTestsUserObserver::resetting()); + + $user1 = DB::transaction(function () use ($observer) { + return tap(DB::transaction(function () use ($observer) { + return tap(DB::transaction(function () use ($observer) { + return tap(User::unguarded(fn () => User::createOrFirst(UserFactory::new()->raw())), function () use ($observer) { + $this->assertEquals(0, $observer::$calledTimes, 'Should not have been called'); + }); + }), function () use ($observer) { + $this->assertEquals(0, $observer::$calledTimes, 'Should not have been called'); + }); + }), function () use ($observer) { + $this->assertEquals(0, $observer::$calledTimes, 'Should not have been called'); + }); + }); + + $this->assertTrue($user1->exists); + $this->assertEquals(1, $observer::$calledTimes, 'Failed to assert the observer was called once.'); + } + + public function testTransactionCallbackExceptions() + { + [$firstObject, $secondObject] = [ + new EloquentTransactionWithAfterCommitTestsTestObjectForTransactions(), + new EloquentTransactionWithAfterCommitTestsTestObjectForTransactions(), + ]; + + $rootTransactionLevel = DB::transactionLevel(); + + // After commit callbacks may fail with an exception. When they do, the rest of the callbacks are not + // executed. It's important that the transaction would already be committed by that point, so the + // transaction level should be modified before executing any callbacks. Also, exceptions in the + // callbacks should not affect the connection's transaction level. + $this->expectException(RuntimeException::class); + + try { + DB::transaction(function () use ($rootTransactionLevel, $firstObject, $secondObject) { + DB::transaction(function () use ($rootTransactionLevel, $firstObject) { + $this->assertSame($rootTransactionLevel + 2, DB::transactionLevel()); + + DB::afterCommit(function () use ($rootTransactionLevel, $firstObject) { + $this->assertSame($rootTransactionLevel, DB::transactionLevel()); + + $firstObject->handle(); + }); + }); + + $this->assertSame($rootTransactionLevel + 1, DB::transactionLevel()); + + DB::afterCommit(fn () => throw new RuntimeException()); + DB::afterCommit(fn () => $secondObject->handle()); + }); + } finally { + $this->assertSame($rootTransactionLevel, DB::transactionLevel()); + $this->assertTrue($firstObject->ran); + $this->assertFalse($secondObject->ran); + $this->assertEquals(1, $firstObject->runs); + } + } +} + +class EloquentTransactionWithAfterCommitTestsUserObserver +{ + public static int $calledTimes = 0; + + public bool $afterCommit = true; + + public static function resetting(): static + { + static::$calledTimes = 0; + + return new static(); + } + + public function created(User $user): void + { + ++static::$calledTimes; + } +} + +class EloquentTransactionWithAfterCommitTestsUserObserverUsingDispatchSync extends EloquentTransactionWithAfterCommitTestsUserObserver +{ + public function created(User $user): void + { + dispatch_sync(new EloquentTransactionWithAfterCommitTestsJob($user->email)); + + parent::created($user); + } +} + +class EloquentTransactionWithAfterCommitTestsJob implements ShouldQueue +{ + use Dispatchable; + use InteractsWithQueue; + use Queueable; + + public function __construct( + public string $email + ) { + } + + public function handle(): void + { + DB::transaction(function () { + DB::table('password_reset_tokens')->insert([ + ['email' => $this->email, 'token' => sha1($this->email), 'created_at' => now()], + ]); + }); + } +} + +class EloquentTransactionWithAfterCommitTestsTestObjectForTransactions +{ + public bool $ran = false; + + public int $runs = 0; + + public function handle(): void + { + $this->ran = true; + ++$this->runs; + } +} diff --git a/tests/Integration/Database/Laravel/EloquentTransactionWithAfterCommitUsingDatabaseMigrationsTest.php b/tests/Integration/Database/Laravel/EloquentTransactionWithAfterCommitUsingDatabaseMigrationsTest.php new file mode 100644 index 000000000..378b76c03 --- /dev/null +++ b/tests/Integration/Database/Laravel/EloquentTransactionWithAfterCommitUsingDatabaseMigrationsTest.php @@ -0,0 +1,23 @@ +createTransactionTestTables(); + } +} diff --git a/tests/Integration/Database/Laravel/EloquentTransactionWithAfterCommitUsingDatabaseTransactionsTest.php b/tests/Integration/Database/Laravel/EloquentTransactionWithAfterCommitUsingDatabaseTransactionsTest.php new file mode 100644 index 000000000..b3d7ac2fa --- /dev/null +++ b/tests/Integration/Database/Laravel/EloquentTransactionWithAfterCommitUsingDatabaseTransactionsTest.php @@ -0,0 +1,57 @@ +usesSqliteInMemoryDatabaseConnection()) { + $this->markTestSkipped('Test cannot be used with in-memory SQLite connection.'); + } + + return parent::setUpTraits(); + } + + protected function setUp(): void + { + $this->beforeApplicationDestroyed(function () { + foreach (array_keys($this->app['db']->getConnections()) as $name) { + $this->app['db']->purge($name); + } + }); + + parent::setUp(); + + $this->createTransactionTestTables(); + } + + protected function defineEnvironment($app): void + { + $connection = $app->make('config')->get('database.default'); + + $this->driver = $app['config']->get("database.connections.{$connection}.driver"); + } +} diff --git a/tests/Integration/Database/Laravel/EloquentTransactionWithAfterCommitUsingRefreshDatabaseOnMultipleConnectionsTest.php b/tests/Integration/Database/Laravel/EloquentTransactionWithAfterCommitUsingRefreshDatabaseOnMultipleConnectionsTest.php new file mode 100644 index 000000000..fc8ca9761 --- /dev/null +++ b/tests/Integration/Database/Laravel/EloquentTransactionWithAfterCommitUsingRefreshDatabaseOnMultipleConnectionsTest.php @@ -0,0 +1,60 @@ + 'sqlite', 'database' => ':memory:', 'foreign_key_constraints' => false])] +class EloquentTransactionWithAfterCommitUsingRefreshDatabaseOnMultipleConnectionsTest extends EloquentTransactionWithAfterCommitUsingRefreshDatabaseTest +{ + protected function connectionsToTransact(): array + { + return [null, 'second']; + } + + protected function afterRefreshingDatabase(): void + { + parent::afterRefreshingDatabase(); + $this->artisan('migrate', ['--database' => 'second']); + } + + public function testAfterCommitCallbacksAreCalledCorrectlyWhenNoAppTransaction(): void + { + $called = false; + + DB::afterCommit(function () use (&$called) { + $called = true; + }); + + $this->assertTrue($called); + } + + public function testAfterCommitCallbacksAreCalledWithWrappingTransactionsCorrectly(): void + { + $calls = []; + + DB::transaction(function () use (&$calls) { + DB::afterCommit(function () use (&$calls) { + $calls[] = 'first transaction callback'; + }); + + DB::connection('second')->transaction(function () use (&$calls) { + DB::connection('second')->afterCommit(function () use (&$calls) { + $calls[] = 'second transaction callback'; + }); + }); + }); + + $this->assertEquals([ + 'second transaction callback', + 'first transaction callback', + ], $calls); + } +} diff --git a/tests/Integration/Database/Laravel/EloquentTransactionWithAfterCommitUsingRefreshDatabaseTest.php b/tests/Integration/Database/Laravel/EloquentTransactionWithAfterCommitUsingRefreshDatabaseTest.php new file mode 100644 index 000000000..f62101023 --- /dev/null +++ b/tests/Integration/Database/Laravel/EloquentTransactionWithAfterCommitUsingRefreshDatabaseTest.php @@ -0,0 +1,51 @@ +beforeApplicationDestroyed(function () { + $database = $this->app->get(DatabaseManager::class); + foreach (array_keys($database->getConnections()) as $name) { + $database->purge($name); + } + }); + + parent::setUp(); + } + + protected function afterRefreshingDatabase(): void + { + $this->createTransactionTestTables(); + } + + 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"); + } +} diff --git a/tests/Integration/Database/Laravel/EloquentUniqueStringPrimaryKeysTest.php b/tests/Integration/Database/Laravel/EloquentUniqueStringPrimaryKeysTest.php new file mode 100644 index 000000000..4c3962b95 --- /dev/null +++ b/tests/Integration/Database/Laravel/EloquentUniqueStringPrimaryKeysTest.php @@ -0,0 +1,209 @@ +uuid('id')->primary(); + $table->uuid('foo'); + $table->uuid('bar'); + $table->timestamps(); + }); + + Schema::create('foo', function (Blueprint $table) { + $table->uuid('id')->primary(); + $table->string('email')->unique(); + $table->string('name'); + $table->timestamps(); + }); + + Schema::create('posts', function (Blueprint $table) { + $table->ulid('id')->primary(); + $table->ulid('foo'); + $table->ulid('bar'); + $table->timestamps(); + }); + + Schema::create('songs', function (Blueprint $table) { + $table->id(); + $table->uuid('foo'); + $table->uuid('bar'); + $table->timestamps(); + }); + + Schema::create('pictures', function (Blueprint $table) { + $table->uuid('uuid')->primary(); + $table->timestamps(); + }); + } + + public function testModelWithUuidPrimaryKeyCanBeCreated() + { + $user = ModelWithUuidPrimaryKey::create(); + + $this->assertTrue(Str::isUuid($user->id)); + $this->assertTrue(Str::isUuid($user->foo)); + $this->assertTrue(Str::isUuid($user->bar)); + } + + public function testModelWithUlidPrimaryKeyCanBeCreated() + { + $user = ModelWithUlidPrimaryKey::create(); + + $this->assertTrue(Str::isUlid($user->id)); + $this->assertTrue(Str::isUlid($user->foo)); + $this->assertTrue(Str::isUlid($user->bar)); + } + + public function testModelWithoutUuidPrimaryKeyCanBeCreated() + { + $user = ModelWithoutUuidPrimaryKey::create(); + + $this->assertTrue(is_int($user->id)); + $this->assertTrue(Str::isUuid($user->foo)); + $this->assertTrue(Str::isUuid($user->bar)); + } + + public function testModelWithCustomUuidPrimaryKeyNameCanBeCreated() + { + $user = ModelWithCustomUuidPrimaryKeyName::create(); + + $this->assertTrue(Str::isUuid($user->uuid)); + } + + public function testModelWithUuidPrimaryKeyCanBeCreatedQuietly() + { + $user = new ModelWithUuidPrimaryKey(); + + $user->saveQuietly(); + + $this->assertTrue(Str::isUuid($user->id)); + $this->assertTrue(Str::isUuid($user->foo)); + $this->assertTrue(Str::isUuid($user->bar)); + } + + public function testModelWithUlidPrimaryKeyCanBeCreatedQuietly() + { + $user = new ModelWithUlidPrimaryKey(); + + $user->saveQuietly(); + + $this->assertTrue(Str::isUlid($user->id)); + $this->assertTrue(Str::isUlid($user->foo)); + $this->assertTrue(Str::isUlid($user->bar)); + } + + public function testModelWithoutUuidPrimaryKeyCanBeCreatedQuietly() + { + $user = new ModelWithoutUuidPrimaryKey(); + + $user->saveQuietly(); + + $this->assertTrue(is_int($user->id)); + $this->assertTrue(Str::isUuid($user->foo)); + $this->assertTrue(Str::isUuid($user->bar)); + } + + public function testModelWithCustomUuidPrimaryKeyNameCanBeCreatedQuietly() + { + $user = new ModelWithCustomUuidPrimaryKeyName(); + + $user->saveQuietly(); + + $this->assertTrue(Str::isUuid($user->uuid)); + } + + public function testUpsertWithUuidPrimaryKey() + { + ModelUpsertWithUuidPrimaryKey::create(['email' => 'foo', 'name' => 'bar']); + ModelUpsertWithUuidPrimaryKey::create(['name' => 'bar1', 'email' => 'foo2']); + + ModelUpsertWithUuidPrimaryKey::upsert([['email' => 'foo3', 'name' => 'bar'], ['name' => 'bar2', 'email' => 'foo2']], ['email']); + + $this->assertEquals(3, ModelUpsertWithUuidPrimaryKey::count()); + } +} + +class ModelWithUuidPrimaryKey extends Eloquent +{ + use HasUuids; + + protected ?string $table = 'users'; + + protected array $guarded = []; + + public function uniqueIds(): array + { + return [$this->getKeyName(), 'foo', 'bar']; + } +} + +class ModelUpsertWithUuidPrimaryKey extends Eloquent +{ + use HasUuids; + + protected ?string $table = 'foo'; + + protected array $guarded = []; + + public function uniqueIds(): array + { + return [$this->getKeyName()]; + } +} + +class ModelWithUlidPrimaryKey extends Eloquent +{ + use HasUlids; + + protected ?string $table = 'posts'; + + protected array $guarded = []; + + public function uniqueIds(): array + { + return [$this->getKeyName(), 'foo', 'bar']; + } +} + +class ModelWithoutUuidPrimaryKey extends Eloquent +{ + use HasUuids; + + protected ?string $table = 'songs'; + + protected array $guarded = []; + + public function uniqueIds(): array + { + return ['foo', 'bar']; + } +} + +class ModelWithCustomUuidPrimaryKeyName extends Eloquent +{ + use HasUuids; + + protected ?string $table = 'pictures'; + + protected array $guarded = []; + + protected string $primaryKey = 'uuid'; +} diff --git a/tests/Integration/Database/Laravel/EloquentUpdateTest.php b/tests/Integration/Database/Laravel/EloquentUpdateTest.php new file mode 100644 index 000000000..f932501b1 --- /dev/null +++ b/tests/Integration/Database/Laravel/EloquentUpdateTest.php @@ -0,0 +1,219 @@ +increments('id'); + $table->string('name')->nullable(); + $table->string('title')->nullable(); + }); + + Schema::create('test_model2', function (Blueprint $table) { + $table->increments('id'); + $table->string('name'); + $table->string('job')->nullable(); + $table->softDeletes(); + $table->timestamps(); + }); + + Schema::create('test_model3', function (Blueprint $table) { + $table->increments('id'); + $table->unsignedInteger('counter'); + $table->softDeletes(); + $table->timestamps(); + }); + } + + public function testBasicUpdate() + { + TestUpdateModel1::create([ + 'name' => Str::random(), + 'title' => 'Ms.', + ]); + + TestUpdateModel1::where('title', 'Ms.')->delete(); + + $this->assertCount(0, TestUpdateModel1::all()); + } + + public function testUpdateWithLimitsAndOrders() + { + if ($this->driver === 'sqlsrv') { + $this->markTestSkipped('The limit keyword is not supported on MSSQL.'); + } + + for ($i = 1; $i <= 10; ++$i) { + TestUpdateModel1::create(); + } + + TestUpdateModel1::latest('id')->limit(3)->update(['title' => 'Dr.']); + + $this->assertSame('Dr.', TestUpdateModel1::find(8)->title); + $this->assertNotSame('Dr.', TestUpdateModel1::find(7)->title); + } + + public function testUpdatedAtWithJoins() + { + TestUpdateModel1::create([ + 'name' => 'Abdul', + 'title' => 'Mr.', + ]); + + TestUpdateModel2::create([ + 'name' => Str::random(), + ]); + + TestUpdateModel2::join('test_model1', function ($join) { + $join->on('test_model1.id', '=', 'test_model2.id') + ->where('test_model1.title', '=', 'Mr.'); + })->update(['test_model2.name' => 'Abdul', 'job' => 'Engineer']); + + $record = TestUpdateModel2::find(1); + + $this->assertSame('Engineer: Abdul', $record->job . ': ' . $record->name); + } + + public function testSoftDeleteWithJoins() + { + TestUpdateModel1::create([ + 'name' => Str::random(), + 'title' => 'Mr.', + ]); + + TestUpdateModel2::create([ + 'name' => Str::random(), + ]); + + TestUpdateModel2::join('test_model1', function ($join) { + $join->on('test_model1.id', '=', 'test_model2.id') + ->where('test_model1.title', '=', 'Mr.'); + })->delete(); + + $this->assertCount(0, TestUpdateModel2::all()); + } + + public function testIncrement() + { + TestUpdateModel3::create([ + 'counter' => 0, + ]); + + TestUpdateModel3::create([ + 'counter' => 0, + ])->delete(); + + TestUpdateModel3::increment('counter'); + + $models = TestUpdateModel3::withoutGlobalScopes()->orderBy('id')->get(); + $this->assertEquals(1, $models[0]->counter); + $this->assertEquals(0, $models[1]->counter); + } + + public function testIncrementOrDecrementIgnoresGlobalScopes() + { + /** @var TestUpdateModel3 $deletedModel */ + $deletedModel = tap(TestUpdateModel3::create([ + 'counter' => 0, + ]), fn ($model) => $model->delete()); + + $deletedModel->increment('counter'); + + $this->assertEquals(1, $deletedModel->counter); + + $deletedModel->fresh(); + $this->assertEquals(1, $deletedModel->counter); + + $deletedModel->decrement('counter'); + $this->assertEquals(0, $deletedModel->fresh()->counter); + } + + public function testUpdateSyncsPrevious() + { + $model = TestUpdateModel1::create([ + 'name' => Str::random(), + 'title' => 'Ms.', + ]); + + $model->update(['title' => 'Dr.']); + + $this->assertSame('Dr.', $model->title); + $this->assertSame('Dr.', $model->getOriginal('title')); + $this->assertSame(['title' => 'Dr.'], $model->getChanges()); + $this->assertSame(['title' => 'Ms.'], $model->getPrevious()); + } + + public function testSaveSyncsPrevious() + { + $model = TestUpdateModel1::create([ + 'name' => Str::random(), + 'title' => 'Ms.', + ]); + + $model->title = 'Dr.'; + $model->save(); + + $this->assertSame('Dr.', $model->title); + $this->assertSame('Dr.', $model->getOriginal('title')); + $this->assertSame(['title' => 'Dr.'], $model->getChanges()); + $this->assertSame(['title' => 'Ms.'], $model->getPrevious()); + } + + public function testIncrementSyncsPrevious() + { + $model = TestUpdateModel3::create([ + 'counter' => 0, + ]); + + $model->increment('counter'); + + $this->assertEquals(1, $model->counter); + $this->assertSame(['counter' => 1], $model->getChanges()); + $this->assertSame(['counter' => 0], $model->getPrevious()); + } +} + +class TestUpdateModel1 extends Model +{ + public ?string $table = 'test_model1'; + + public bool $timestamps = false; + + protected array $guarded = []; +} + +class TestUpdateModel2 extends Model +{ + use SoftDeletes; + + public ?string $table = 'test_model2'; + + protected array $fillable = ['name']; +} + +class TestUpdateModel3 extends Model +{ + use SoftDeletes; + + public ?string $table = 'test_model3'; + + protected array $fillable = ['counter']; + + protected array $casts = ['deleted_at' => 'datetime']; +} diff --git a/tests/Integration/Database/Laravel/EloquentWhereHasMorphTest.php b/tests/Integration/Database/Laravel/EloquentWhereHasMorphTest.php new file mode 100644 index 000000000..9153f3ad6 --- /dev/null +++ b/tests/Integration/Database/Laravel/EloquentWhereHasMorphTest.php @@ -0,0 +1,341 @@ +increments('id'); + $table->string('title'); + $table->softDeletes(); + }); + + Schema::create('videos', function (Blueprint $table) { + $table->increments('id'); + $table->string('title'); + }); + + Schema::create('comments', function (Blueprint $table) { + $table->increments('id'); + $table->string('title'); + $table->nullableMorphs('commentable'); + $table->softDeletes(); + }); + + $models = []; + + $models[] = Post::create(['title' => 'foo']); + $models[] = Post::create(['title' => 'bar']); + $models[] = Post::create(['title' => 'baz']); + end($models)->delete(); + + $models[] = Video::create(['title' => 'foo']); + $models[] = Video::create(['title' => 'bar']); + $models[] = Video::create(['title' => 'baz']); + $models[] = null; // deleted + $models[] = null; // deleted + + foreach ($models as $model) { + $comment = new Comment(); + $comment->commentable()->associate($model); + $comment->title = 'foo'; + $comment->save(); + } + } + + public function testWhereHasMorph() + { + $comments = Comment::whereHasMorph('commentable', [Post::class, Video::class], function (Builder $query) { + $query->where('title', 'foo'); + })->orderBy('id')->get(); + + $this->assertEquals([1, 4], $comments->pluck('id')->all()); + } + + public function testWhereHasMorphWithMorphMap() + { + Relation::morphMap(['posts' => Post::class]); + + Comment::where('commentable_type', Post::class)->update(['commentable_type' => 'posts']); + + try { + $comments = Comment::whereHasMorph('commentable', [Post::class, Video::class], function (Builder $query) { + $query->where('title', 'foo'); + })->orderBy('id')->get(); + + $this->assertEquals([1, 4], $comments->pluck('id')->all()); + } finally { + Relation::morphMap([], false); + } + } + + public function testWhereHasMorphWithWildcard() + { + // Test newModelQuery() without global scopes. + Comment::where('commentable_type', Video::class)->delete(); + + $comments = Comment::withTrashed() + ->whereHasMorph('commentable', '*', function (Builder $query) { + $query->where('title', 'foo'); + })->orderBy('id')->get(); + + $this->assertEquals([1, 4], $comments->pluck('id')->all()); + } + + public function testWhereHasMorphWithWildcardAndMorphMap() + { + Relation::morphMap(['posts' => Post::class]); + + Comment::where('commentable_type', Post::class)->update(['commentable_type' => 'posts']); + + try { + $comments = Comment::whereHasMorph('commentable', '*', function (Builder $query) { + $query->where('title', 'foo'); + })->orderBy('id')->get(); + + $this->assertEquals([1, 4], $comments->pluck('id')->all()); + } finally { + Relation::morphMap([], false); + } + } + + public function testWhereHasMorphWithWildcardAndOnlyNullMorphTypes() + { + Comment::whereNotNull('commentable_type')->forceDelete(); + + $comments = Comment::query() + ->whereHasMorph('commentable', '*', function (Builder $query) { + $query->where('title', 'foo'); + }) + ->orderBy('id')->get(); + + $this->assertEmpty($comments->pluck('id')->all()); + } + + public function testWhereHasMorphWithRelationConstraint() + { + $comments = Comment::whereHasMorph('commentableWithConstraint', Video::class, function (Builder $query) { + $query->where('title', 'like', 'ba%'); + })->orderBy('id')->get(); + + $this->assertEquals([5], $comments->pluck('id')->all()); + } + + public function testWhereHasMorphWitDifferentConstraints() + { + $comments = Comment::whereHasMorph('commentable', [Post::class, Video::class], function (Builder $query, $type) { + if ($type === Post::class) { + $query->where('title', 'foo'); + } + + if ($type === Video::class) { + $query->where('title', 'bar'); + } + })->orderBy('id')->get(); + + $this->assertEquals([1, 5], $comments->pluck('id')->all()); + } + + public function testWhereHasMorphWithOwnerKey() + { + Schema::table('posts', function (Blueprint $table) { + $table->string('slug')->nullable(); + }); + + Schema::table('comments', function (Blueprint $table) { + $table->dropIndex('comments_commentable_type_commentable_id_index'); + }); + + Schema::table('comments', function (Blueprint $table) { + $table->string('commentable_id')->nullable()->change(); + }); + + Post::where('id', 1)->update(['slug' => 'foo']); + + Comment::where('id', 1)->update(['commentable_id' => 'foo']); + + $comments = Comment::whereHasMorph('commentableWithOwnerKey', Post::class, function (Builder $query) { + $query->where('title', 'foo'); + })->orderBy('id')->get(); + + $this->assertEquals([1], $comments->pluck('id')->all()); + } + + public function testHasMorph() + { + $comments = Comment::hasMorph('commentable', Post::class)->orderBy('id')->get(); + + $this->assertEquals([1, 2], $comments->pluck('id')->all()); + } + + public function testOrHasMorph() + { + $comments = Comment::where('id', 1)->orHasMorph('commentable', Video::class)->orderBy('id')->get(); + + $this->assertEquals([1, 4, 5, 6], $comments->pluck('id')->all()); + } + + public function testDoesntHaveMorph() + { + $comments = Comment::doesntHaveMorph('commentable', Post::class)->orderBy('id')->get(); + + $this->assertEquals([3], $comments->pluck('id')->all()); + } + + public function testOrDoesntHaveMorph() + { + $comments = Comment::where('id', 1)->orDoesntHaveMorph('commentable', Post::class)->orderBy('id')->get(); + + $this->assertEquals([1, 3], $comments->pluck('id')->all()); + } + + public function testOrWhereHasMorph() + { + $comments = Comment::where('id', 1) + ->orWhereHasMorph('commentable', Video::class, function (Builder $query) { + $query->where('title', 'foo'); + })->orderBy('id')->get(); + + $this->assertEquals([1, 4], $comments->pluck('id')->all()); + } + + public function testOrWhereHasMorphWithWildcardAndOnlyNullMorphTypes() + { + Comment::whereNotNull('commentable_type')->forceDelete(); + + $comments = Comment::where('id', 7) + ->orWhereHasMorph('commentable', '*', function (Builder $query) { + $query->where('title', 'foo'); + })->orderBy('id')->get(); + + $this->assertEquals([7], $comments->pluck('id')->all()); + } + + public function testWhereDoesntHaveMorph() + { + $comments = Comment::whereDoesntHaveMorph('commentable', Post::class, function (Builder $query) { + $query->where('title', 'foo'); + })->orderBy('id')->get(); + + $this->assertEquals([2, 3], $comments->pluck('id')->all()); + } + + public function testWhereDoesntHaveMorphWithWildcardAndOnlyNullMorphTypes() + { + Comment::whereNotNull('commentable_type')->forceDelete(); + + $comments = Comment::whereDoesntHaveMorph('commentable', [], function (Builder $query) { + $query->where('title', 'foo'); + })->orderBy('id')->get(); + + $this->assertEquals([7, 8], $comments->pluck('id')->all()); + } + + public function testOrWhereDoesntHaveMorph() + { + $comments = Comment::where('id', 1) + ->orWhereDoesntHaveMorph('commentable', Post::class, function (Builder $query) { + $query->where('title', 'foo'); + })->orderBy('id')->get(); + + $this->assertEquals([1, 2, 3], $comments->pluck('id')->all()); + } + + public function testModelScopesAreAccessible() + { + $comments = Comment::whereHasMorph('commentable', [Post::class, Video::class], function (Builder $query) { + $query->someSharedModelScope(); + })->orderBy('id')->get(); + + $this->assertEquals([1, 4], $comments->pluck('id')->all()); + } + + public function testWhereDoesntHaveMorphWithNullableMorph() + { + $comments = Comment::whereDoesntHaveMorph('commentable', '*')->orderBy('id')->get(); + + $this->assertEquals([3, 7, 8], $comments->pluck('id')->all()); + } + + public function testWhereDoesntHaveMorphWithNullableMorphAndAdditionalWhereIsLogicallyGrouped() + { + $commentsWhereFirst = Comment::whereNot('title', 'foo') + ->whereDoesntHaveMorph('commentable', '*') + ->orderBy('id') + ->get(); + + $commentsWhereLast = Comment::whereDoesntHaveMorph('commentable', '*') + ->whereNot('title', 'foo') + ->orderBy('id') + ->get(); + + $this->assertCount(0, $commentsWhereFirst); + $this->assertCount(0, $commentsWhereLast); + } +} + +class Comment extends Model +{ + use SoftDeletes; + + public bool $timestamps = false; + + protected array $guarded = []; + + public function commentable() + { + return $this->morphTo(); + } + + public function commentableWithConstraint() + { + return $this->morphTo('commentable')->where('title', 'bar'); + } + + public function commentableWithOwnerKey() + { + return $this->morphTo('commentable', null, null, 'slug'); + } +} + +class Post extends Model +{ + use SoftDeletes; + + public bool $timestamps = false; + + protected array $guarded = []; + + public function scopeSomeSharedModelScope($query) + { + $query->where('title', '=', 'foo'); + } +} + +class Video extends Model +{ + public bool $timestamps = false; + + protected array $guarded = []; + + public function scopeSomeSharedModelScope($query) + { + $query->where('title', '=', 'foo'); + } +} diff --git a/tests/Integration/Database/Laravel/EloquentWhereHasTest.php b/tests/Integration/Database/Laravel/EloquentWhereHasTest.php new file mode 100644 index 000000000..0c6f0d832 --- /dev/null +++ b/tests/Integration/Database/Laravel/EloquentWhereHasTest.php @@ -0,0 +1,313 @@ +increments('id'); + }); + + Schema::create('posts', function (Blueprint $table) { + $table->increments('id'); + $table->unsignedInteger('user_id'); + $table->boolean('public'); + }); + + Schema::create('texts', function (Blueprint $table) { + $table->increments('id'); + $table->unsignedInteger('post_id'); + $table->text('content'); + }); + + Schema::create('comments', function (Blueprint $table) { + $table->increments('id'); + $table->string('commentable_type'); + $table->integer('commentable_id'); + }); + + $user = User::create(); + $post = tap((new Post(['public' => true]))->user()->associate($user))->save(); + (new Comment())->commentable()->associate($post)->save(); + (new Text(['content' => 'test']))->post()->associate($post)->save(); + + $user = User::create(); + $post = tap((new Post(['public' => false]))->user()->associate($user))->save(); + (new Comment())->commentable()->associate($post)->save(); + (new Text(['content' => 'test2']))->post()->associate($post)->save(); + } + + /** + * Check that the 'whereRelation' callback function works. + */ + #[DataProvider('dataProviderWhereRelationCallback')] + public function testWhereRelationCallback($callbackEloquent, $callbackQuery) + { + $userWhereRelation = User::whereRelation('posts', $callbackEloquent); + $userWhereHas = User::whereHas('posts', $callbackEloquent); + $query = DB::table('users')->whereExists($callbackQuery); + + $this->assertEquals($userWhereRelation->getQuery()->toSql(), $query->toSql()); + $this->assertEquals($userWhereRelation->getQuery()->toSql(), $userWhereHas->toSql()); + $this->assertEquals($userWhereHas->getQuery()->toSql(), $query->toSql()); + + $this->assertEquals($userWhereRelation->first()->id, $query->first()->id); + $this->assertEquals($userWhereRelation->first()->id, $userWhereHas->first()->id); + $this->assertEquals($userWhereHas->first()->id, $query->first()->id); + } + + /** + * Check that the 'orWhereRelation' callback function works. + */ + #[DataProvider('dataProviderWhereRelationCallback')] + public function testOrWhereRelationCallback($callbackEloquent, $callbackQuery) + { + $userOrWhereRelation = User::orWhereRelation('posts', $callbackEloquent); + $userOrWhereHas = User::orWhereHas('posts', $callbackEloquent); + $query = DB::table('users')->orWhereExists($callbackQuery); + + $this->assertEquals($userOrWhereRelation->getQuery()->toSql(), $query->toSql()); + $this->assertEquals($userOrWhereRelation->getQuery()->toSql(), $userOrWhereHas->toSql()); + $this->assertEquals($userOrWhereHas->getQuery()->toSql(), $query->toSql()); + + $this->assertEquals($userOrWhereRelation->first()->id, $query->first()->id); + $this->assertEquals($userOrWhereRelation->first()->id, $userOrWhereHas->first()->id); + $this->assertEquals($userOrWhereHas->first()->id, $query->first()->id); + } + + /** + * Check that the 'whereDoesntHaveRelation' callback function works. + */ + #[DataProvider('dataProviderWhereRelationCallback')] + public function testWhereDoesntRelationCallback($callbackEloquent, $callbackQuery) + { + $userWhereDoesntRelation = User::whereDoesntHaveRelation('posts', $callbackEloquent); + $userWhereHas = User::whereDoesntHave('posts', $callbackEloquent); + $query = DB::table('users')->whereNotExists($callbackQuery); + + $this->assertEquals($userWhereDoesntRelation->getQuery()->toSql(), $query->toSql()); + $this->assertEquals($userWhereDoesntRelation->getQuery()->toSql(), $userWhereHas->toSql()); + $this->assertEquals($userWhereHas->getQuery()->toSql(), $query->toSql()); + + $this->assertEquals($userWhereDoesntRelation->first()->id, $query->first()->id); + $this->assertEquals($userWhereDoesntRelation->first()->id, $userWhereHas->first()->id); + $this->assertEquals($userWhereHas->first()->id, $query->first()->id); + } + + /** + * Check that the 'orWhereDoesntRelation' callback function works. + */ + #[DataProvider('dataProviderWhereRelationCallback')] + public function testOrWhereDoesntRelationCallback($callbackEloquent, $callbackQuery) + { + $userOrWhereDoesntRelation = User::orWhereDoesntHaveRelation('posts', $callbackEloquent); + $userOrWhereHas = User::orWhereDoesntHave('posts', $callbackEloquent); + $query = DB::table('users')->orWhereNotExists($callbackQuery); + + $this->assertEquals($userOrWhereDoesntRelation->getQuery()->toSql(), $query->toSql()); + $this->assertEquals($userOrWhereDoesntRelation->getQuery()->toSql(), $userOrWhereHas->toSql()); + $this->assertEquals($userOrWhereHas->getQuery()->toSql(), $query->toSql()); + + $this->assertEquals($userOrWhereDoesntRelation->first()->id, $query->first()->id); + $this->assertEquals($userOrWhereDoesntRelation->first()->id, $userOrWhereHas->first()->id); + $this->assertEquals($userOrWhereHas->first()->id, $query->first()->id); + } + + public static function dataProviderWhereRelationCallback() + { + $callbackArray = function ($value) { + $callbackEloquent = function (EloquentBuilder $builder) use ($value) { + $builder->selectRaw('id')->where('public', $value); + }; + + $callbackQuery = function (QueryBuilder $builder) use ($value) { + $hasMany = app()->make(User::class)->posts(); + + $builder->from('posts')->addSelect(['*'])->whereColumn( + $hasMany->getQualifiedParentKeyName(), + '=', + $hasMany->getQualifiedForeignKeyName() + ); + + $builder->selectRaw('id')->where('public', $value); + }; + + return [$callbackEloquent, $callbackQuery]; + }; + + return [ + 'Find user with post.public = true' => $callbackArray(true), + 'Find user with post.public = false' => $callbackArray(false), + ]; + } + + public function testWhereRelation() + { + $users = User::whereRelation('posts', 'public', true)->get(); + + $this->assertEquals([1], $users->pluck('id')->all()); + } + + public function testOrWhereRelation() + { + $users = User::whereRelation('posts', 'public', true)->orWhereRelation('posts', 'public', false)->get(); + + $this->assertEquals([1, 2], $users->pluck('id')->all()); + } + + public function testNestedWhereRelation() + { + $texts = User::whereRelation('posts.texts', 'content', 'test')->get(); + + $this->assertEquals([1], $texts->pluck('id')->all()); + } + + public function testNestedOrWhereRelation() + { + $texts = User::whereRelation('posts.texts', 'content', 'test')->orWhereRelation('posts.texts', 'content', 'test2')->get(); + + $this->assertEquals([1, 2], $texts->pluck('id')->all()); + } + + public function testWhereMorphRelation() + { + $comments = Comment::whereMorphRelation('commentable', '*', 'public', true)->get(); + + $this->assertEquals([1], $comments->pluck('id')->all()); + } + + public function testOrWhereMorphRelation() + { + $comments = Comment::whereMorphRelation('commentable', '*', 'public', true) + ->orWhereMorphRelation('commentable', '*', 'public', false) + ->get(); + + $this->assertEquals([1, 2], $comments->pluck('id')->all()); + } + + public function testWhereDoesntHaveRelation() + { + $users = User::whereDoesntHaveRelation('posts', 'public', true)->get(); + + $this->assertEquals([2], $users->pluck('id')->all()); + } + + public function testOrWhereDoesntHaveRelation() + { + $users = User::whereDoesntHaveRelation('posts', 'public', true)->orWhereDoesntHaveRelation('posts', 'public', false)->get(); + + $this->assertEquals([1, 2], $users->pluck('id')->all()); + } + + public function testNestedWhereDoesntHaveRelation() + { + $texts = User::whereDoesntHaveRelation('posts.texts', 'content', 'test')->get(); + + $this->assertEquals([2], $texts->pluck('id')->all()); + } + + public function testNestedOrWhereDoesntHaveRelation() + { + $texts = User::whereDoesntHaveRelation('posts.texts', 'content', 'test')->orWhereDoesntHaveRelation('posts.texts', 'content', 'test2')->get(); + + $this->assertEquals([1, 2], $texts->pluck('id')->all()); + } + + public function testWhereMorphDoesntHaveRelation() + { + $comments = Comment::whereMorphDoesntHaveRelation('commentable', '*', 'public', true)->get(); + + $this->assertEquals([2], $comments->pluck('id')->all()); + } + + public function testOrWhereMorphDoesntHaveRelation() + { + $comments = Comment::whereMorphDoesntHaveRelation('commentable', '*', 'public', true) + ->orWhereMorphDoesntHaveRelation('commentable', '*', 'public', false) + ->get(); + + $this->assertEquals([1, 2], $comments->pluck('id')->all()); + } + + public function testWithCount() + { + $users = User::whereHas('posts', function ($query) { + $query->where('public', true); + })->get(); + + $this->assertEquals([1], $users->pluck('id')->all()); + } +} + +class Comment extends Model +{ + public bool $timestamps = false; + + public function commentable() + { + return $this->morphTo(); + } +} + +class Post extends Model +{ + public bool $timestamps = false; + + protected array $guarded = []; + + protected array $withCount = ['comments']; + + public function comments() + { + return $this->morphMany(Comment::class, 'commentable'); + } + + public function texts() + { + return $this->hasMany(Text::class); + } + + public function user() + { + return $this->belongsTo(User::class); + } +} + +class Text extends Model +{ + public bool $timestamps = false; + + protected array $guarded = []; + + public function post() + { + return $this->belongsTo(Post::class); + } +} + +class User extends Model +{ + public bool $timestamps = false; + + public function posts() + { + return $this->hasMany(Post::class); + } +} diff --git a/tests/Integration/Database/Laravel/EloquentWhereTest.php b/tests/Integration/Database/Laravel/EloquentWhereTest.php new file mode 100644 index 000000000..5f0e8a351 --- /dev/null +++ b/tests/Integration/Database/Laravel/EloquentWhereTest.php @@ -0,0 +1,352 @@ +increments('id'); + $table->string('name'); + $table->string('email'); + $table->string('address'); + }); + } + + public function testWhereAndWhereOrBehavior() + { + /** @var \Hypervel\Tests\Integration\Database\Laravel\UserWhereTest $firstUser */ + $firstUser = UserWhereTest::create([ + 'name' => 'test-name', + 'email' => 'test-email', + 'address' => 'test-address', + ]); + + /** @var \Hypervel\Tests\Integration\Database\Laravel\UserWhereTest $secondUser */ + $secondUser = UserWhereTest::create([ + 'name' => 'test-name1', + 'email' => 'test-email1', + 'address' => 'test-address1', + ]); + + $this->assertTrue($firstUser->is(UserWhereTest::where('name', '=', $firstUser->name)->first())); + $this->assertTrue($firstUser->is(UserWhereTest::where('name', $firstUser->name)->first())); + $this->assertTrue($firstUser->is(UserWhereTest::where('name', $firstUser->name)->where('email', $firstUser->email)->first())); + $this->assertNull(UserWhereTest::where('name', $firstUser->name)->where('email', $secondUser->email)->first()); + $this->assertTrue($secondUser->is(UserWhereTest::where('name', 'wrong-name')->orWhere('email', $secondUser->email)->first())); + $this->assertTrue($firstUser->is(UserWhereTest::where(['name' => 'test-name', 'email' => 'test-email'])->first())); + $this->assertNull(UserWhereTest::where(['name' => 'test-name', 'email' => 'test-email1'])->first()); + $this->assertTrue( + $secondUser->is( + UserWhereTest::where(['name' => 'wrong-name', 'email' => 'test-email1'], null, null, 'or')->first() + ) + ); + + $this->assertSame( + 1, + UserWhereTest::where(['name' => 'test-name', 'email' => 'test-email1']) + ->orWhere(['name' => 'test-name1', 'address' => 'wrong-address'])->count() + ); + + $this->assertTrue( + $secondUser->is( + UserWhereTest::where(['name' => 'test-name', 'email' => 'test-email1']) + ->orWhere(['name' => 'test-name1', 'address' => 'wrong-address']) + ->first() + ) + ); + } + + public function testWhereNot() + { + /** @var \Hypervel\Tests\Integration\Database\Laravel\UserWhereTest $firstUser */ + $firstUser = UserWhereTest::create([ + 'name' => 'test-name', + 'email' => 'test-email', + 'address' => 'test-address', + ]); + + /** @var \Hypervel\Tests\Integration\Database\Laravel\UserWhereTest $secondUser */ + $secondUser = UserWhereTest::create([ + 'name' => 'test-name1', + 'email' => 'test-email1', + 'address' => 'test-address1', + ]); + + $this->assertTrue($secondUser->is(UserWhereTest::whereNot(function ($query) use ($firstUser) { + $query->where('name', '=', $firstUser->name); + })->first())); + $this->assertTrue($firstUser->is(UserWhereTest::where('name', $firstUser->name)->whereNot(function ($query) use ($secondUser) { + $query->where('email', $secondUser->email); + })->first())); + $this->assertTrue($secondUser->is(UserWhereTest::where('name', 'wrong-name')->orWhereNot(function ($query) use ($firstUser) { + $query->where('email', $firstUser->email); + })->first())); + } + + public function testWhereIn() + { + /** @var \Hypervel\Tests\Integration\Database\Laravel\UserWhereTest $user1 */ + $user1 = UserWhereTest::create([ + 'name' => 'test-name1', + 'email' => 'test-email1', + 'address' => 'test-address1', + ]); + + /** @var \Hypervel\Tests\Integration\Database\Laravel\UserWhereTest $user2 */ + $user2 = UserWhereTest::create([ + 'name' => 'test-name2', + 'email' => 'test-email2', + 'address' => 'test-address2', + ]); + + /** @var \Hypervel\Tests\Integration\Database\Laravel\UserWhereTest $user3 */ + $user3 = UserWhereTest::create([ + 'name' => 'test-name2', + 'email' => 'test-email3', + 'address' => 'test-address3', + ]); + + $this->assertTrue($user2->is(UserWhereTest::whereIn('id', [2])->first())); + + $users = UserWhereTest::query()->whereIn('id', [1, 2, 22])->get(); + + $this->assertTrue($user1->is($users[0])); + $this->assertTrue($user2->is($users[1])); + $this->assertCount(2, $users); + + $users = UserWhereTest::query()->whereIn('email', ['test-email1', 'test-email2'])->get(); + + $this->assertTrue($user1->is($users[0])); + $this->assertTrue($user2->is($users[1])); + $this->assertCount(2, $users); + + $users = UserWhereTest::query() + ->whereIn('id', [1]) + ->orWhereIn('email', ['test-email1', 'test-email2']) + ->get(); + + $this->assertTrue($user1->is($users[0])); + $this->assertTrue($user2->is($users[1])); + $this->assertCount(2, $users); + } + + public function testWhereInCanAcceptQueryable() + { + $user1 = UserWhereTest::create([ + 'name' => 'test-name1', + 'email' => 'test-email1', + 'address' => 'test-address1', + ]); + + $user2 = UserWhereTest::create([ + 'name' => 'test-name2', + 'email' => 'test-email2', + 'address' => 'test-address2', + ]); + + $user3 = UserWhereTest::create([ + 'name' => 'test-name2', + 'email' => 'test-email3', + 'address' => 'test-address3', + ]); + + $query = UserWhereTest::query()->select('name')->where('id', '>', 1); + + $users = UserWhereTest::query()->whereIn('name', $query)->get(); + + $this->assertTrue($user2->is($users[0])); + $this->assertTrue($user3->is($users[1])); + $this->assertCount(2, $users); + + $users = UserWhereTest::query()->whereIn('name', function (Builder $query) { + $query->select('name')->where('id', '>', 1); + })->get(); + + $this->assertTrue($user2->is($users[0])); + $this->assertTrue($user3->is($users[1])); + $this->assertCount(2, $users); + + $query = DB::table('users')->select('name')->where('id', '=', 1); + + $users = UserWhereTest::query()->whereNotIn('name', $query)->get(); + + $this->assertTrue($user2->is($users[0])); + $this->assertTrue($user3->is($users[1])); + $this->assertCount(2, $users); + } + + public function testWhereIntegerInRaw() + { + /** @var \Hypervel\Tests\Integration\Database\Laravel\UserWhereTest $user1 */ + $user1 = UserWhereTest::create([ + 'name' => 'test-name1', + 'email' => 'test-email1', + 'address' => 'test-address1', + ]); + + /** @var \Hypervel\Tests\Integration\Database\Laravel\UserWhereTest $user2 */ + $user2 = UserWhereTest::create([ + 'name' => 'test-name2', + 'email' => 'test-email2', + 'address' => 'test-address2', + ]); + + /** @var \Hypervel\Tests\Integration\Database\Laravel\UserWhereTest $user3 */ + $user3 = UserWhereTest::create([ + 'name' => 'test-name2', + 'email' => 'test-email3', + 'address' => 'test-address3', + ]); + + $users = UserWhereTest::query()->whereIntegerInRaw('id', [1, 2, 5])->get(); + $this->assertTrue($user1->is($users[0])); + $this->assertTrue($user2->is($users[1])); + $this->assertCount(2, $users); + + $users = UserWhereTest::query()->whereIntegerNotInRaw('id', [2])->get(); + $this->assertTrue($user1->is($users[0])); + $this->assertTrue($user3->is($users[1])); + $this->assertCount(2, $users); + + $users = UserWhereTest::query()->whereIntegerInRaw('id', ['1', '2'])->get(); + $this->assertTrue($user1->is($users[0])); + $this->assertTrue($user2->is($users[1])); + $this->assertCount(2, $users); + } + + public function testFirstWhere() + { + /** @var \Hypervel\Tests\Integration\Database\Laravel\UserWhereTest $firstUser */ + $firstUser = UserWhereTest::create([ + 'name' => 'test-name', + 'email' => 'test-email', + 'address' => 'test-address', + ]); + + /** @var \Hypervel\Tests\Integration\Database\Laravel\UserWhereTest $secondUser */ + $secondUser = UserWhereTest::create([ + 'name' => 'test-name1', + 'email' => 'test-email1', + 'address' => 'test-address1', + ]); + + $this->assertTrue($firstUser->is(UserWhereTest::firstWhere('name', '=', $firstUser->name))); + $this->assertTrue($firstUser->is(UserWhereTest::firstWhere('name', $firstUser->name))); + $this->assertTrue($firstUser->is(UserWhereTest::where('name', $firstUser->name)->firstWhere('email', $firstUser->email))); + $this->assertNull(UserWhereTest::where('name', $firstUser->name)->firstWhere('email', $secondUser->email)); + $this->assertTrue($firstUser->is(UserWhereTest::firstWhere(['name' => 'test-name', 'email' => 'test-email']))); + $this->assertNull(UserWhereTest::firstWhere(['name' => 'test-name', 'email' => 'test-email1'])); + $this->assertTrue( + $secondUser->is( + UserWhereTest::firstWhere(['name' => 'wrong-name', 'email' => 'test-email1'], null, null, 'or') + ) + ); + } + + public function testSole() + { + $expected = UserWhereTest::create([ + 'name' => 'test-name', + 'email' => 'test-email', + 'address' => 'test-address', + ]); + + $this->assertTrue($expected->is(UserWhereTest::where('name', 'test-name')->sole())); + } + + public function testSoleFailsForMultipleRecords() + { + UserWhereTest::create([ + 'name' => 'test-name', + 'email' => 'test-email', + 'address' => 'test-address', + ]); + + UserWhereTest::create([ + 'name' => 'test-name', + 'email' => 'other-email', + 'address' => 'other-address', + ]); + + $this->expectExceptionObject(new MultipleRecordsFoundException(2)); + + UserWhereTest::where('name', 'test-name')->sole(); + } + + public function testSoleFailsIfNoRecords() + { + try { + UserWhereTest::where('name', 'test-name')->sole(); + } catch (ModelNotFoundException $exception) { + } + + $this->assertSame(UserWhereTest::class, $exception->getModel()); + } + + public function testSoleValue() + { + $expected = UserWhereTest::create([ + 'name' => 'test-name', + 'email' => 'test-email', + 'address' => 'test-address', + ]); + + $this->assertEquals('test-name', UserWhereTest::where('name', 'test-name')->soleValue('name')); + } + + public function testChunkMap() + { + UserWhereTest::create([ + 'name' => 'first-name', + 'email' => 'first-email', + 'address' => 'first-address', + ]); + + UserWhereTest::create([ + 'name' => 'second-name', + 'email' => 'second-email', + 'address' => 'second-address', + ]); + + DB::enableQueryLog(); + + $results = UserWhereTest::orderBy('id')->chunkMap(function ($user) { + return $user->name; + }, 1); + + $this->assertCount(2, $results); + $this->assertSame('first-name', $results[0]); + $this->assertSame('second-name', $results[1]); + $this->assertCount(3, DB::getQueryLog()); + } +} + +/** + * @internal + * @coversNothing + */ +class UserWhereTest extends Model +{ + protected ?string $table = 'users'; + + protected array $guarded = []; + + public bool $timestamps = false; +} diff --git a/tests/Integration/Database/Laravel/EloquentWithCountTest.php b/tests/Integration/Database/Laravel/EloquentWithCountTest.php new file mode 100644 index 000000000..732d32316 --- /dev/null +++ b/tests/Integration/Database/Laravel/EloquentWithCountTest.php @@ -0,0 +1,164 @@ +increments('id'); + }); + + Schema::create('two', function (Blueprint $table) { + $table->increments('id'); + $table->integer('one_id'); + }); + + Schema::create('three', function (Blueprint $table) { + $table->increments('id'); + $table->integer('two_id'); + }); + + Schema::create('four', function (Blueprint $table) { + $table->increments('id'); + $table->integer('one_id'); + }); + } + + public function testItBasic() + { + $one = Model1::create(); + $two = $one->twos()->Create(); + $two->threes()->Create(); + + $results = Model1::withCount([ + 'twos' => function ($query) { + $query->where('id', '>=', 1); + }, + ]); + + $this->assertEquals([ + ['id' => 1, 'twos_count' => 1], + ], $results->get()->toArray()); + } + + public function testGlobalScopes() + { + $one = Model1::create(); + $one->fours()->create(); + + $result = Model1::withCount('fours')->first(); + $this->assertEquals(0, $result->fours_count); + + $result = Model1::withCount('allFours')->first(); + $this->assertEquals(1, $result->all_fours_count); + } + + public function testSortingScopes() + { + $one = Model1::create(); + $one->twos()->create(); + + $query = Model1::withCount('twos')->getQuery(); + + $this->assertNull($query->orders); + $this->assertSame([], $query->getRawBindings()['order']); + } +} + +class Model1 extends Model +{ + public ?string $table = 'one'; + + public bool $timestamps = false; + + protected array $guarded = []; + + public function twos() + { + return $this->hasMany(Model2::class, 'one_id'); + } + + public function fours() + { + return $this->hasMany(Model4::class, 'one_id'); + } + + public function allFours() + { + return $this->fours()->withoutGlobalScopes(); + } +} + +class Model2 extends Model +{ + public ?string $table = 'two'; + + public bool $timestamps = false; + + protected array $guarded = []; + + protected array $withCount = ['threes']; + + protected static function boot(): void + { + parent::boot(); + + static::addGlobalScope('app', function ($builder) { + $builder->latest(); + }); + } + + public function threes() + { + return $this->hasMany(Model3::class, 'two_id'); + } +} + +class Model3 extends Model +{ + public ?string $table = 'three'; + + public bool $timestamps = false; + + protected array $guarded = []; + + protected static function boot(): void + { + parent::boot(); + + static::addGlobalScope('app', function ($builder) { + $builder->where('id', '>', 0); + }); + } +} + +class Model4 extends Model +{ + public ?string $table = 'four'; + + public bool $timestamps = false; + + protected array $guarded = []; + + protected static function boot(): void + { + parent::boot(); + + static::addGlobalScope('app', function ($builder) { + $builder->where('id', '>', 1); + }); + } +} diff --git a/tests/Integration/Database/Laravel/Enums.php b/tests/Integration/Database/Laravel/Enums.php new file mode 100644 index 000000000..910ac3b10 --- /dev/null +++ b/tests/Integration/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/Integration/Database/Laravel/EventConnectionEstablishedTest.php b/tests/Integration/Database/Laravel/EventConnectionEstablishedTest.php new file mode 100644 index 000000000..885586f3b --- /dev/null +++ b/tests/Integration/Database/Laravel/EventConnectionEstablishedTest.php @@ -0,0 +1,54 @@ +get('config'); + $config->set(StdoutLoggerInterface::class . '.log_level', []); + } + + /** + * Test that ConnectionEstablished fires when a connection is re-established. + * + * Note: Laravel's version of this test uses migrate:fresh to trigger reconnection + * (because Laravel's db:wipe disconnects after dropping tables). In Hypervel with + * Swoole connection pooling, db:wipe does NOT disconnect - the pooled connection + * remains valid after dropping tables. So we explicitly disconnect and query to + * trigger the reconnection path. + */ + public function testConnectionEstablishedEventFiringOnReconnect(): void + { + // Get a connection and disconnect it to simulate a dropped connection + $connection = DB::connection(); + $connection->disconnect(); + + // Fake the event after disconnect (before reconnection happens) + Event::fake([ConnectionEstablished::class]); + Event::assertNotDispatched(ConnectionEstablished::class); + + // Run a query - this triggers the reconnector which re-establishes the connection + $connection->select('SELECT 1'); + + // Assert the event was dispatched during reconnection + Event::assertDispatched(ConnectionEstablished::class); + } +} diff --git a/tests/Integration/Database/Laravel/Fixtures/NamedScopeUser.php b/tests/Integration/Database/Laravel/Fixtures/NamedScopeUser.php new file mode 100644 index 000000000..38a9f4fad --- /dev/null +++ b/tests/Integration/Database/Laravel/Fixtures/NamedScopeUser.php @@ -0,0 +1,46 @@ + 'datetime', + 'password' => 'hashed', + ]; + } + + #[Scope] + protected function verified(Builder $builder, bool $email = true) + { + return $builder->when( + $email === true, + fn ($query) => $query->whereNotNull('email_verified_at'), + fn ($query) => $query->whereNull('email_verified_at'), + ); + } + + #[Scope] + protected function verifiedWithoutReturn(Builder $builder, bool $email = true) + { + $this->verified($builder, $email); + } + + public function scopeVerifiedUser(Builder $builder, bool $email = true) + { + return $builder->when( + $email === true, + fn ($query) => $query->whereNotNull('email_verified_at'), + fn ($query) => $query->whereNull('email_verified_at'), + ); + } +} diff --git a/tests/Integration/Database/Laravel/Fixtures/Post.php b/tests/Integration/Database/Laravel/Fixtures/Post.php new file mode 100644 index 000000000..435da825e --- /dev/null +++ b/tests/Integration/Database/Laravel/Fixtures/Post.php @@ -0,0 +1,12 @@ +id(); + $table->string('name')->nullable(); + $table->string('email')->unique(); + $table->timestamps(); + }); + } + } + + protected function destroyDatabaseMigrations(): void + { + Schema::drop('database_eloquent_mariadb_integration_users'); + } + + public function testCreateOrFirst() + { + $user1 = DatabaseEloquentMariaDbIntegrationUser::createOrFirst(['email' => 'taylorotwell@gmail.com']); + + $this->assertSame('taylorotwell@gmail.com', $user1->email); + $this->assertNull($user1->name); + + $user2 = DatabaseEloquentMariaDbIntegrationUser::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 = DatabaseEloquentMariaDbIntegrationUser::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 = DatabaseEloquentMariaDbIntegrationUser::createOrFirst( + ['name' => 'Dries Vints'], + ['name' => 'Nuno Maduro', 'email' => 'nuno@laravel.com'] + ); + + $this->assertSame('Nuno Maduro', $user4->name); + } + + public function testCreateOrFirstWithinTransaction() + { + $user1 = DatabaseEloquentMariaDbIntegrationUser::createOrFirst(['email' => 'taylor@laravel.com']); + + DB::transaction(function () use ($user1) { + $user2 = DatabaseEloquentMariaDbIntegrationUser::createOrFirst( + ['email' => 'taylor@laravel.com'], + ['name' => 'Taylor Otwell'] + ); + + $this->assertEquals($user1->id, $user2->id); + $this->assertSame('taylor@laravel.com', $user2->email); + $this->assertNull($user2->name); + }); + } +} + +class DatabaseEloquentMariaDbIntegrationUser extends Model +{ + protected ?string $table = 'database_eloquent_mariadb_integration_users'; + + protected array $guarded = []; +} diff --git a/tests/Integration/Database/Laravel/MariaDb/DatabaseEmulatePreparesMariaDbConnectionTest.php b/tests/Integration/Database/Laravel/MariaDb/DatabaseEmulatePreparesMariaDbConnectionTest.php new file mode 100755 index 000000000..7b51ae117 --- /dev/null +++ b/tests/Integration/Database/Laravel/MariaDb/DatabaseEmulatePreparesMariaDbConnectionTest.php @@ -0,0 +1,27 @@ +set('database.connections.mariadb.options', [ + PDO::ATTR_EMULATE_PREPARES => true, + ]); + } +} diff --git a/tests/Integration/Database/Laravel/MariaDb/DatabaseMariaDbConnectionTest.php b/tests/Integration/Database/Laravel/MariaDb/DatabaseMariaDbConnectionTest.php new file mode 100644 index 000000000..0346c53de --- /dev/null +++ b/tests/Integration/Database/Laravel/MariaDb/DatabaseMariaDbConnectionTest.php @@ -0,0 +1,156 @@ +json(self::JSON_COL)->nullable(); + $table->float(self::FLOAT_COL)->nullable(); + }); + } + } + + protected function destroyDatabaseMigrations(): void + { + Schema::drop(self::TABLE); + } + + #[DataProvider('floatComparisonsDataProvider')] + public function testJsonFloatComparison($value, $operator, $shouldMatch) + { + DB::table(self::TABLE)->insert([self::JSON_COL => '{"rank":' . self::FLOAT_VAL . '}']); + + $this->assertSame( + $shouldMatch, + DB::table(self::TABLE)->where(self::JSON_COL . '->rank', $operator, $value)->exists(), + self::JSON_COL . '->rank should ' . ($shouldMatch ? '' : 'not ') . "be {$operator} {$value}" + ); + } + + public static function floatComparisonsDataProvider() + { + return [ + [0.2, '=', true], + [0.2, '>', false], + [0.2, '<', false], + [0.1, '=', false], + [0.1, '<', false], + [0.1, '>', true], + [0.3, '=', false], + [0.3, '<', true], + [0.3, '>', false], + ]; + } + + public function testFloatValueStoredCorrectly() + { + DB::table(self::TABLE)->insert([self::FLOAT_COL => self::FLOAT_VAL]); + + $this->assertEquals(self::FLOAT_VAL, DB::table(self::TABLE)->value(self::FLOAT_COL)); + } + + #[DataProvider('jsonWhereNullDataProvider')] + public function testJsonWhereNull($expected, $key, array $value = ['value' => 123]) + { + DB::table(self::TABLE)->insert([self::JSON_COL => json_encode($value)]); + + $this->assertSame($expected, DB::table(self::TABLE)->whereNull(self::JSON_COL . '->' . $key)->exists()); + } + + #[DataProvider('jsonWhereNullDataProvider')] + public function testJsonWhereNotNull($expected, $key, array $value = ['value' => 123]) + { + DB::table(self::TABLE)->insert([self::JSON_COL => json_encode($value)]); + + $this->assertSame(! $expected, DB::table(self::TABLE)->whereNotNull(self::JSON_COL . '->' . $key)->exists()); + } + + public static function jsonWhereNullDataProvider() + { + return [ + 'key not exists' => [true, 'invalid'], + 'key exists and null' => [true, 'value', ['value' => null]], + 'key exists and "null"' => [false, 'value', ['value' => 'null']], + 'key exists and not null' => [false, 'value', ['value' => false]], + 'nested key not exists' => [true, 'nested->invalid'], + 'nested key exists and null' => [true, 'nested->value', ['nested' => ['value' => null]]], + 'nested key exists and "null"' => [false, 'nested->value', ['nested' => ['value' => 'null']]], + 'nested key exists and not null' => [false, 'nested->value', ['nested' => ['value' => false]]], + 'array index not exists' => [false, '[0]', [1 => 'invalid']], + 'array index exists and null' => [true, '[0]', [null]], + 'array index exists and "null"' => [false, '[0]', ['null']], + 'array index exists and not null' => [false, '[0]', [false]], + 'nested array index not exists' => [false, 'nested[0]', ['nested' => [1 => 'nested->invalid']]], + 'nested array index exists and null' => [true, 'nested->value[1]', ['nested' => ['value' => [0, null]]]], + 'nested array index exists and "null"' => [false, 'nested->value[1]', ['nested' => ['value' => [0, 'null']]]], + 'nested array index exists and not null' => [false, 'nested->value[1]', ['nested' => ['value' => [0, false]]]], + ]; + } + + public function testJsonPathUpdate() + { + DB::table(self::TABLE)->insert([ + [self::JSON_COL => '{"foo":["bar"]}'], + [self::JSON_COL => '{"foo":["baz"]}'], + ]); + $updatedCount = DB::table(self::TABLE)->where(self::JSON_COL . '->foo[0]', 'baz')->update([ + self::JSON_COL . '->foo[0]' => 'updated', + ]); + $this->assertSame(1, $updatedCount); + } + + #[DataProvider('jsonContainsKeyDataProvider')] + public function testWhereJsonContainsKey($count, $column) + { + DB::table(self::TABLE)->insert([ + ['json_col' => '{"foo":{"bar":["baz"]}}'], + ['json_col' => '{"foo":{"bar":false}}'], + ['json_col' => '{"foo":{}}'], + ['json_col' => '{"foo":[{"bar":"bar"},{"baz":"baz"}]}'], + ['json_col' => '{"bar":null}'], + ]); + + $this->assertSame($count, DB::table(self::TABLE)->whereJsonContainsKey($column)->count()); + } + + public static function jsonContainsKeyDataProvider() + { + return [ + 'string key' => [4, 'json_col->foo'], + 'nested key exists' => [2, 'json_col->foo->bar'], + 'string key missing' => [0, 'json_col->none'], + 'integer key with arrow ' => [0, 'json_col->foo->bar->0'], + 'integer key with braces' => [2, 'json_col->foo->bar[0]'], + 'integer key missing' => [0, 'json_col->foo->bar[1]'], + 'mixed keys' => [1, 'json_col->foo[1]->baz'], + 'null value' => [1, 'json_col->bar'], + ]; + } +} diff --git a/tests/Integration/Database/Laravel/MariaDb/DatabaseMariaDbSchemaBuilderAlterTableWithEnumTest.php b/tests/Integration/Database/Laravel/MariaDb/DatabaseMariaDbSchemaBuilderAlterTableWithEnumTest.php new file mode 100644 index 000000000..2165307a5 --- /dev/null +++ b/tests/Integration/Database/Laravel/MariaDb/DatabaseMariaDbSchemaBuilderAlterTableWithEnumTest.php @@ -0,0 +1,74 @@ +integer('id'); + $table->string('name'); + $table->string('age'); + $table->enum('color', ['red', 'blue']); + }); + } + + protected function destroyDatabaseMigrations(): void + { + Schema::drop('users'); + } + + public function testRenameColumnOnTableWithEnum() + { + Schema::table('users', function (Blueprint $table) { + $table->renameColumn('name', 'username'); + }); + + $this->assertTrue(Schema::hasColumn('users', 'username')); + } + + public function testChangeColumnOnTableWithEnum() + { + Schema::table('users', function (Blueprint $table) { + $table->unsignedInteger('age')->change(); + }); + + $this->assertSame('int', Schema::getColumnType('users', 'age')); + } + + public function testGetTablesAndColumnListing() + { + $tables = Schema::getTables(); + + $this->assertCount(2, $tables); + $this->assertEquals(['migrations', 'users'], array_column($tables, 'name')); + + $columns = Schema::getColumnListing('users'); + + foreach (['id', 'name', 'age', 'color'] as $column) { + $this->assertContains($column, $columns); + } + + Schema::create('posts', function (Blueprint $table) { + $table->integer('id'); + $table->string('title'); + }); + $tables = Schema::getTables(); + $this->assertCount(3, $tables); + Schema::drop('posts'); + } +} diff --git a/tests/Integration/Database/Laravel/MariaDb/DatabaseMariaDbSchemaBuilderTest.php b/tests/Integration/Database/Laravel/MariaDb/DatabaseMariaDbSchemaBuilderTest.php new file mode 100644 index 000000000..e67cf0a01 --- /dev/null +++ b/tests/Integration/Database/Laravel/MariaDb/DatabaseMariaDbSchemaBuilderTest.php @@ -0,0 +1,38 @@ +id(); + $table->comment('This is a comment'); + }); + + $tableInfo = DB::table('information_schema.tables') + ->where('table_schema', $this->app['config']->get('database.connections.mariadb.database')) + ->where('table_name', 'users') + ->select('table_comment as table_comment') + ->first(); + + $this->assertEquals('This is a comment', $tableInfo->table_comment); + + Schema::drop('users'); + } +} diff --git a/tests/Integration/Database/Laravel/MariaDb/EloquentCastTest.php b/tests/Integration/Database/Laravel/MariaDb/EloquentCastTest.php new file mode 100644 index 000000000..5f3e0b7d6 --- /dev/null +++ b/tests/Integration/Database/Laravel/MariaDb/EloquentCastTest.php @@ -0,0 +1,243 @@ +increments('id'); + $table->string('email')->unique(); + $table->integer('created_at'); + $table->integer('updated_at'); + }); + + Schema::create('users_nullable_timestamps', function ($table) { + $table->increments('id'); + $table->string('email')->unique(); + $table->timestamp('created_at')->nullable(); + $table->timestamp('updated_at')->nullable(); + }); + } + + protected function destroyDatabaseMigrations(): void + { + Schema::drop('users'); + } + + public function testItCastTimestampsCreatedByTheBuilderWhenTimeHasNotPassed() + { + Carbon::setTestNow(now()); + $createdAt = now()->timestamp; + + $castUser = UserWithIntTimestampsViaCasts::create([ + 'email' => fake()->unique()->email, + ]); + $attributeUser = UserWithIntTimestampsViaAttribute::create([ + 'email' => fake()->unique()->email, + ]); + $mutatorUser = UserWithIntTimestampsViaMutator::create([ + 'email' => fake()->unique()->email, + ]); + + $this->assertSame($createdAt, $castUser->created_at->timestamp); + $this->assertSame($createdAt, $castUser->updated_at->timestamp); + $this->assertSame($createdAt, $attributeUser->created_at->timestamp); + $this->assertSame($createdAt, $attributeUser->updated_at->timestamp); + $this->assertSame($createdAt, $mutatorUser->created_at->timestamp); + $this->assertSame($createdAt, $mutatorUser->updated_at->timestamp); + + $castUser->update([ + 'email' => fake()->unique()->email, + ]); + $attributeUser->update([ + 'email' => fake()->unique()->email, + ]); + $mutatorUser->update([ + 'email' => fake()->unique()->email, + ]); + + $this->assertSame($createdAt, $castUser->created_at->timestamp); + $this->assertSame($createdAt, $castUser->updated_at->timestamp); + $this->assertSame($createdAt, $castUser->fresh()->updated_at->timestamp); + $this->assertSame($createdAt, $attributeUser->created_at->timestamp); + $this->assertSame($createdAt, $attributeUser->updated_at->timestamp); + $this->assertSame($createdAt, $attributeUser->fresh()->updated_at->timestamp); + $this->assertSame($createdAt, $mutatorUser->created_at->timestamp); + $this->assertSame($createdAt, $mutatorUser->updated_at->timestamp); + $this->assertSame($createdAt, $mutatorUser->fresh()->updated_at->timestamp); + } + + public function testItCastTimestampsCreatedByTheBuilderWhenTimeHasPassed() + { + Carbon::setTestNow(now()); + $createdAt = now()->timestamp; + + $castUser = UserWithIntTimestampsViaCasts::create([ + 'email' => fake()->unique()->email, + ]); + $attributeUser = UserWithIntTimestampsViaAttribute::create([ + 'email' => fake()->unique()->email, + ]); + $mutatorUser = UserWithIntTimestampsViaMutator::create([ + 'email' => fake()->unique()->email, + ]); + + $this->assertSame($createdAt, $castUser->created_at->timestamp); + $this->assertSame($createdAt, $castUser->updated_at->timestamp); + $this->assertSame($createdAt, $attributeUser->created_at->timestamp); + $this->assertSame($createdAt, $attributeUser->updated_at->timestamp); + $this->assertSame($createdAt, $mutatorUser->created_at->timestamp); + $this->assertSame($createdAt, $mutatorUser->updated_at->timestamp); + + Carbon::setTestNow(now()->addSecond()); + $updatedAt = now()->timestamp; + + $castUser->update([ + 'email' => fake()->unique()->email, + ]); + $attributeUser->update([ + 'email' => fake()->unique()->email, + ]); + $mutatorUser->update([ + 'email' => fake()->unique()->email, + ]); + + $this->assertSame($createdAt, $castUser->created_at->timestamp); + $this->assertSame($updatedAt, $castUser->updated_at->timestamp); + $this->assertSame($updatedAt, $castUser->fresh()->updated_at->timestamp); + $this->assertSame($createdAt, $attributeUser->created_at->timestamp); + $this->assertSame($updatedAt, $attributeUser->updated_at->timestamp); + $this->assertSame($updatedAt, $attributeUser->fresh()->updated_at->timestamp); + $this->assertSame($createdAt, $mutatorUser->created_at->timestamp); + $this->assertSame($updatedAt, $mutatorUser->updated_at->timestamp); + $this->assertSame($updatedAt, $mutatorUser->fresh()->updated_at->timestamp); + } + + public function testItCastTimestampsUpdatedByAMutator() + { + Carbon::setTestNow(now()); + + $mutatorUser = UserWithUpdatedAtViaMutator::create([ + 'email' => fake()->unique()->email, + ]); + + $this->assertNull($mutatorUser->updated_at); + + Carbon::setTestNow(now()->addSecond()); + $updatedAt = now()->timestamp; + + $mutatorUser->update([ + 'email' => fake()->unique()->email, + ]); + + $this->assertSame($updatedAt, $mutatorUser->updated_at->timestamp); + $this->assertSame($updatedAt, $mutatorUser->fresh()->updated_at->timestamp); + } +} + +class UserWithIntTimestampsViaCasts extends Model +{ + protected ?string $table = 'users'; + + protected array $fillable = ['email']; + + protected array $casts = [ + 'created_at' => UnixTimeStampToCarbon::class, + 'updated_at' => UnixTimeStampToCarbon::class, + ]; +} + +class UnixTimeStampToCarbon implements CastsAttributes +{ + public function get($model, string $key, $value, array $attributes) + { + return Carbon::parse($value); + } + + public function set($model, string $key, $value, array $attributes) + { + return Carbon::parse($value)->timestamp; + } +} + +class UserWithIntTimestampsViaAttribute extends Model +{ + protected ?string $table = 'users'; + + protected array $fillable = ['email']; + + protected function updatedAt(): Attribute + { + return Attribute::make( + get: fn ($value) => Carbon::parse($value), + set: fn ($value) => Carbon::parse($value)->timestamp, + ); + } + + protected function createdAt(): Attribute + { + return Attribute::make( + get: fn ($value) => Carbon::parse($value), + set: fn ($value) => Carbon::parse($value)->timestamp, + ); + } +} + +class UserWithIntTimestampsViaMutator extends Model +{ + protected ?string $table = 'users'; + + protected array $fillable = ['email']; + + protected function getUpdatedAtAttribute($value) + { + return Carbon::parse($value); + } + + protected function setUpdatedAtAttribute($value) + { + $this->attributes['updated_at'] = Carbon::parse($value)->timestamp; + } + + protected function getCreatedAtAttribute($value) + { + return Carbon::parse($value); + } + + protected function setCreatedAtAttribute($value) + { + $this->attributes['created_at'] = Carbon::parse($value)->timestamp; + } +} + +class UserWithUpdatedAtViaMutator extends Model +{ + protected ?string $table = 'users_nullable_timestamps'; + + protected array $fillable = ['email', 'updated_at']; + + public function setUpdatedAtAttribute($value) + { + if (! $this->id) { + return; + } + + $this->attributes['updated_at'] = $value; + } +} diff --git a/tests/Integration/Database/Laravel/MariaDb/EscapeTest.php b/tests/Integration/Database/Laravel/MariaDb/EscapeTest.php new file mode 100644 index 000000000..378b3cb4c --- /dev/null +++ b/tests/Integration/Database/Laravel/MariaDb/EscapeTest.php @@ -0,0 +1,77 @@ +assertSame('42', $this->app['db']->escape(42)); + $this->assertSame('-6', $this->app['db']->escape(-6)); + } + + public function testEscapeFloat() + { + $this->assertSame('3.14159', $this->app['db']->escape(3.14159)); + $this->assertSame('-3.14159', $this->app['db']->escape(-3.14159)); + } + + public function testEscapeBool() + { + $this->assertSame('1', $this->app['db']->escape(true)); + $this->assertSame('0', $this->app['db']->escape(false)); + } + + public function testEscapeNull() + { + $this->assertSame('null', $this->app['db']->escape(null)); + $this->assertSame('null', $this->app['db']->escape(null, true)); + } + + public function testEscapeBinary() + { + $this->assertSame("x'dead00beef'", $this->app['db']->escape(hex2bin('dead00beef'), true)); + } + + public function testEscapeString() + { + $this->assertSame("'2147483647'", $this->app['db']->escape('2147483647')); + $this->assertSame("'true'", $this->app['db']->escape('true')); + $this->assertSame("'false'", $this->app['db']->escape('false')); + $this->assertSame("'null'", $this->app['db']->escape('null')); + $this->assertSame("'Hello\\'World'", $this->app['db']->escape("Hello'World")); + } + + public function testEscapeStringInvalidUtf8() + { + $this->expectException(RuntimeException::class); + + $this->app['db']->escape("I am hiding an invalid \x80 utf-8 continuation byte"); + } + + public function testEscapeStringNullByte() + { + $this->expectException(RuntimeException::class); + + $this->app['db']->escape("I am hiding a \00 byte"); + } + + public function testEscapeArray() + { + $this->expectException(RuntimeException::class); + + $this->app['db']->escape(['a', 'b']); + } +} diff --git a/tests/Integration/Database/Laravel/MariaDb/FulltextTest.php b/tests/Integration/Database/Laravel/MariaDb/FulltextTest.php new file mode 100644 index 000000000..6c7e40484 --- /dev/null +++ b/tests/Integration/Database/Laravel/MariaDb/FulltextTest.php @@ -0,0 +1,73 @@ +id('id'); + $table->string('title', 200); + $table->text('body'); + $table->fulltext(['title', 'body']); + }); + } + + protected function destroyDatabaseMigrations(): void + { + Schema::drop('articles'); + } + + protected function setUpInCoroutine(): void + { + DB::table('articles')->insert([ + ['title' => 'MariaDB Tutorial', 'body' => 'DBMS stands for DataBase ...'], + ['title' => 'How To Use MariaDB Well', 'body' => 'After you went through a ...'], + ['title' => 'Optimizing MariaDB', 'body' => 'In this tutorial, we show ...'], + ['title' => '1001 MariaDB Tricks', 'body' => '1. Never run mariadbd as root. 2. ...'], + ['title' => 'MariaDB vs. YourSQL', 'body' => 'In the following database comparison ...'], + ['title' => 'MariaDB Security', 'body' => 'When configured properly, MariaDB ...'], + ]); + } + + /** @link https://mariadb.com/kb/en/full-text-index-overview/#in-natural-language-mode */ + public function testWhereFulltext() + { + $articles = DB::table('articles')->whereFullText(['title', 'body'], 'database')->get(); + + $this->assertCount(2, $articles); + $this->assertSame('MariaDB Tutorial', $articles[0]->title); + $this->assertSame('MariaDB vs. YourSQL', $articles[1]->title); + } + + /** @link https://mariadb.com/kb/en/full-text-index-overview/#in-boolean-mode */ + public function testWhereFulltextWithBooleanMode() + { + $articles = DB::table('articles')->whereFullText(['title', 'body'], '+MariaDB -YourSQL', ['mode' => 'boolean'])->get(); + + $this->assertCount(5, $articles); + } + + /** @link https://mariadb.com/kb/en/full-text-index-overview/#with-query-expansion */ + public function testWhereFulltextWithExpandedQuery() + { + $articles = DB::table('articles')->whereFullText(['title', 'body'], 'database', ['expanded' => true])->get(); + + $this->assertCount(6, $articles); + } +} diff --git a/tests/Integration/Database/Laravel/MariaDb/JsonLikeTest.php b/tests/Integration/Database/Laravel/MariaDb/JsonLikeTest.php new file mode 100644 index 000000000..8e0a613cd --- /dev/null +++ b/tests/Integration/Database/Laravel/MariaDb/JsonLikeTest.php @@ -0,0 +1,68 @@ +id(); + $table->json('data'); + }); + } + + protected function destroyDatabaseMigrations(): void + { + Schema::dropIfExists('tasks'); + } + + public function testJsonLikeWithEmoji() + { + // Test that LIKE queries work correctly with emojis in JSON fields + // This verifies that json_value() handles emojis correctly (unlike json_unquote) + DB::table('tasks')->insert([ + ['data' => '{"status":"Building started 🔨"}'], + ['data' => '{"status":"Tests passed ✅"}'], + ['data' => '{"status":"Deployment complete 🌎"}'], + ]); + + // Search for records containing the hammer emoji + $buildCount = DB::table('tasks') + ->where('data->status', 'like', '%🔨%') + ->count(); + $this->assertSame(1, $buildCount, 'Should find 1 record with hammer emoji'); + + // Search for records containing "Tests" with emoji + $testsCount = DB::table('tasks') + ->where('data->status', 'like', '%Tests%') + ->count(); + $this->assertSame(1, $testsCount, 'Should find 1 record with "Tests"'); + + // Search for records containing rocket emoji + $deployCount = DB::table('tasks') + ->where('data->status', 'like', '%🌎%') + ->count(); + $this->assertSame(1, $deployCount, 'Should find 1 record with globe emoji'); + + // Verify we can find text before emoji + $completeCount = DB::table('tasks') + ->where('data->status', 'like', '%complete%') + ->count(); + $this->assertSame(1, $completeCount, 'Should find 1 record with "complete" before emoji'); + } +} diff --git a/tests/Integration/Database/Laravel/MariaDb/MariaDbTestCase.php b/tests/Integration/Database/Laravel/MariaDb/MariaDbTestCase.php new file mode 100644 index 000000000..76cb13ef5 --- /dev/null +++ b/tests/Integration/Database/Laravel/MariaDb/MariaDbTestCase.php @@ -0,0 +1,17 @@ +app['config']->get('database.default') !== 'testing') { + $this->artisan('db:wipe', ['--drop-views' => true]); + } + + $options = [ + '--path' => realpath(__DIR__ . '/stubs/'), + '--realpath' => true, + ]; + + $this->artisan('migrate', $options); + + $this->beforeApplicationDestroyed(function () use ($options) { + $this->artisan('migrate:rollback', $options); + }); + } + + public function testRealpathMigrationHasProperlyExecuted() + { + $this->assertTrue(Schema::hasTable('members')); + } + + public function testMigrationsHasTheMigratedTable() + { + $this->assertDatabaseHas('migrations', [ + 'id' => 1, + 'migration' => '2014_10_12_000000_create_members_table', + 'batch' => 1, + ]); + } +} diff --git a/tests/Integration/Database/Laravel/MigrationServiceProviderTest.php b/tests/Integration/Database/Laravel/MigrationServiceProviderTest.php new file mode 100644 index 000000000..e630cb2e1 --- /dev/null +++ b/tests/Integration/Database/Laravel/MigrationServiceProviderTest.php @@ -0,0 +1,30 @@ +app->get('migrator'); + $fromClass = $this->app->get(Migrator::class); + + $this->assertSame($fromString, $fromClass); + } +} diff --git a/tests/Integration/Database/Laravel/MigratorEventsTest.php b/tests/Integration/Database/Laravel/MigratorEventsTest.php new file mode 100644 index 000000000..0b9652b04 --- /dev/null +++ b/tests/Integration/Database/Laravel/MigratorEventsTest.php @@ -0,0 +1,162 @@ + realpath(__DIR__ . '/stubs/'), + '--realpath' => true, + ]; + } + + public function testMigrationEventsAreFired() + { + Event::fake(); + + $this->artisan('migrate', $this->migrateOptions()); + $this->artisan('migrate:rollback', $this->migrateOptions()); + + Event::assertDispatched(MigrationsStarted::class, 2); + Event::assertDispatched(MigrationsEnded::class, 2); + Event::assertDispatched(MigrationStarted::class, 2); + Event::assertDispatched(MigrationEnded::class, 2); + Event::assertDispatched(MigrationSkipped::class, 1); + } + + public function testMigrationEventsContainTheOptionsAndPretendFalse() + { + Event::fake(); + + $this->artisan('migrate', $this->migrateOptions()); + $this->artisan('migrate:rollback', $this->migrateOptions()); + + Event::assertDispatched(MigrationsStarted::class, function ($event) { + return $event->method === 'up' + && is_array($event->options) + && isset($event->options['pretend']) + && $event->options['pretend'] === false; + }); + Event::assertDispatched(MigrationsStarted::class, function ($event) { + return $event->method === 'down' + && is_array($event->options) + && isset($event->options['pretend']) + && $event->options['pretend'] === false; + }); + Event::assertDispatched(MigrationsEnded::class, function ($event) { + return $event->method === 'up' + && is_array($event->options) + && isset($event->options['pretend']) + && $event->options['pretend'] === false; + }); + Event::assertDispatched(MigrationsEnded::class, function ($event) { + return $event->method === 'down' + && is_array($event->options) + && isset($event->options['pretend']) + && $event->options['pretend'] === false; + }); + } + + public function testMigrationEventsContainTheOptionsAndPretendTrue() + { + Event::fake(); + + $this->artisan('migrate', $this->migrateOptions() + ['--pretend' => true]); + $this->artisan('migrate:rollback', $this->migrateOptions()); // doesn't support pretend + + Event::assertDispatched(MigrationsStarted::class, function ($event) { + return $event->method === 'up' + && is_array($event->options) + && isset($event->options['pretend']) + && $event->options['pretend'] === true; + }); + + Event::assertDispatched(MigrationsEnded::class, function ($event) { + return $event->method === 'up' + && is_array($event->options) + && isset($event->options['pretend']) + && $event->options['pretend'] === true; + }); + } + + public function testMigrationEventsContainTheMigrationAndMethod() + { + Event::fake(); + + $this->artisan('migrate', $this->migrateOptions()); + $this->artisan('migrate:rollback', $this->migrateOptions()); + + Event::assertDispatched(MigrationsStarted::class, function ($event) { + return $event->method === 'up'; + }); + Event::assertDispatched(MigrationsStarted::class, function ($event) { + return $event->method === 'down'; + }); + Event::assertDispatched(MigrationsEnded::class, function ($event) { + return $event->method === 'up'; + }); + Event::assertDispatched(MigrationsEnded::class, function ($event) { + return $event->method === 'down'; + }); + + Event::assertDispatched(MigrationStarted::class, function ($event) { + return $event->method === 'up' && $event->migration instanceof Migration; + }); + Event::assertDispatched(MigrationStarted::class, function ($event) { + return $event->method === 'down' && $event->migration instanceof Migration; + }); + Event::assertDispatched(MigrationEnded::class, function ($event) { + return $event->method === 'up' && $event->migration instanceof Migration; + }); + Event::assertDispatched(MigrationEnded::class, function ($event) { + return $event->method === 'down' && $event->migration instanceof Migration; + }); + } + + public function testTheNoMigrationEventIsFiredWhenNothingToMigrate() + { + Event::fake(); + + $this->artisan('migrate'); + $this->artisan('migrate:rollback'); + + Event::assertDispatched(NoPendingMigrations::class, function ($event) { + return $event->method === 'up'; + }); + Event::assertDispatched(NoPendingMigrations::class, function ($event) { + return $event->method === 'down'; + }); + } + + public function testMigrationSkippedEventIsFired() + { + Event::fake(); + + $this->artisan('migrate', [ + '--path' => realpath(__DIR__ . '/stubs/2014_10_13_000000_skipped_migration.php'), + '--realpath' => true, + ]); + + Event::assertDispatched(MigrationSkipped::class, function ($event) { + return $event->migrationName === '2014_10_13_000000_skipped_migration'; + }); + } +} diff --git a/tests/Integration/Database/Laravel/ModelInspectorTest.php b/tests/Integration/Database/Laravel/ModelInspectorTest.php new file mode 100644 index 000000000..a4bacccff --- /dev/null +++ b/tests/Integration/Database/Laravel/ModelInspectorTest.php @@ -0,0 +1,249 @@ +withoutMockingConsoleOutput(); + + parent::setUp(); + } + + protected function afterRefreshingDatabase(): void + { + Schema::create('parent_test_models', function (Blueprint $table) { + $table->id(); + }); + Schema::create('model_info_extractor_test_model', function (Blueprint $table) { + $table->increments('id'); + $table->uuid(); + $table->string('name'); + $table->boolean('a_bool'); + $table->foreignId('parent_test_model_id')->constrained(); + $table->timestamp('nullable_date')->nullable(); + $table->timestamps(); + }); + } + + public function testExtractsModelData() + { + $extractor = new ModelInspector($this->app); + $modelInfo = $extractor->inspect(ModelInspectorTestModel::class); + $this->assertModelInfo($modelInfo); + $this->assertSame(ModelInspectorTestModelEloquentCollection::class, $modelInfo['collection']); + $this->assertSame(ModelInspectorTestModelBuilder::class, $modelInfo['builder']); + $this->assertSame(ModelInspectorTestModelResource::class, $modelInfo['resource']); + } + + public function testCommandReturnsJson(): void + { + $this->artisan('model:show', ['model' => ModelInspectorTestModel::class, '--json' => true]); + $output = Artisan::output(); + $this->assertJson($output); + $modelInfo = json_decode($output, true); + $this->assertModelInfo($modelInfo); + } + + private function assertModelInfo(ModelInfo|array $modelInfo) + { + $this->assertEquals(ModelInspectorTestModel::class, $modelInfo['class']); + $this->assertEquals(Schema::getConnection()->getConfig()['name'], $modelInfo['database']); + $this->assertEquals('model_info_extractor_test_model', $modelInfo['table']); + $this->assertNull($modelInfo['policy']); + $this->assertCount(8, $modelInfo['attributes']); + + $this->assertAttributes([ + 'name' => 'id', + 'increments' => true, + 'nullable' => false, + 'default' => null, + 'unique' => true, + 'fillable' => true, + 'hidden' => false, + 'appended' => null, + 'cast' => null, + ], $modelInfo['attributes'][0]); + + $this->assertAttributes([ + 'name' => 'uuid', + 'increments' => false, + 'nullable' => false, + 'default' => null, + 'unique' => false, + 'fillable' => true, + 'hidden' => false, + 'appended' => null, + 'cast' => null, + ], $modelInfo['attributes'][1]); + + $this->assertAttributes([ + 'name' => 'name', + 'increments' => false, + 'nullable' => false, + 'default' => null, + 'unique' => false, + 'fillable' => false, + 'hidden' => false, + 'appended' => null, + 'cast' => null, + ], $modelInfo['attributes'][2]); + + $this->assertAttributes([ + 'name' => 'a_bool', + 'increments' => false, + 'nullable' => false, + 'default' => null, + 'unique' => false, + 'fillable' => true, + 'hidden' => false, + 'appended' => null, + 'cast' => 'bool', + ], $modelInfo['attributes'][3]); + + $this->assertAttributes([ + 'name' => 'parent_test_model_id', + 'increments' => false, + 'nullable' => false, + 'default' => null, + 'unique' => false, + 'fillable' => true, + 'hidden' => false, + 'appended' => null, + 'cast' => null, + ], $modelInfo['attributes'][4]); + + $this->assertAttributes([ + 'name' => 'nullable_date', + 'increments' => false, + 'nullable' => true, + 'default' => null, + 'unique' => false, + 'fillable' => true, + 'hidden' => false, + 'appended' => null, + 'cast' => 'datetime', + ], $modelInfo['attributes'][5]); + + $this->assertAttributes([ + 'name' => 'created_at', + 'increments' => false, + 'nullable' => true, + 'default' => null, + 'unique' => false, + 'fillable' => true, + 'hidden' => false, + 'appended' => null, + 'cast' => 'datetime', + ], $modelInfo['attributes'][6]); + + $this->assertAttributes([ + 'name' => 'updated_at', + 'increments' => false, + 'nullable' => true, + 'default' => null, + 'unique' => false, + 'fillable' => true, + 'hidden' => false, + 'appended' => null, + 'cast' => 'datetime', + ], $modelInfo['attributes'][7]); + + $this->assertCount(1, $modelInfo['relations']); + $this->assertEqualsCanonicalizing([ + 'name' => 'parentModel', + 'type' => 'BelongsTo', + 'related' => 'Hypervel\Tests\Integration\Database\Laravel\ParentTestModel', + ], $modelInfo['relations'][0]); + + $this->assertEmpty($modelInfo['events']); + $this->assertCount(1, $modelInfo['observers']); + $this->assertEquals('created', $modelInfo['observers'][0]['event']); + $this->assertCount(1, $modelInfo['observers'][0]['observer']); + $this->assertEquals('Hypervel\Tests\Integration\Database\Laravel\ModelInspectorTestModelObserver@created', $modelInfo['observers'][0]['observer'][0]); + $this->assertEquals(ModelInspectorTestModelEloquentCollection::class, $modelInfo['collection']); + $this->assertEquals(ModelInspectorTestModelBuilder::class, $modelInfo['builder']); + } + + private function assertAttributes($expectedAttributes, $actualAttributes) + { + foreach (['name', 'increments', 'nullable', 'unique', 'fillable', 'hidden', 'appended', 'cast'] as $key) { + $this->assertEquals($expectedAttributes[$key], $actualAttributes[$key]); + } + // We ignore type because it varies from DB to DB + $this->assertArrayHasKey('type', $actualAttributes); + $this->assertArrayHasKey('default', $actualAttributes); + } +} + +#[ObservedBy(ModelInspectorTestModelObserver::class)] +#[CollectedBy(ModelInspectorTestModelEloquentCollection::class)] +#[UseResource(ModelInspectorTestModelResource::class)] +class ModelInspectorTestModel extends Model +{ + use HasUuids; + + protected static string $builder = ModelInspectorTestModelBuilder::class; + + public ?string $table = 'model_info_extractor_test_model'; + + protected array $guarded = ['name']; + + protected array $casts = ['nullable_date' => 'datetime', 'a_bool' => 'bool']; + + public function parentModel(): BelongsTo + { + return $this->belongsTo(ParentTestModel::class); + } +} + +class ParentTestModel extends Model +{ + public ?string $table = 'parent_test_models'; + + public bool $timestamps = false; +} + +class ModelInspectorTestModelObserver +{ + public function created() + { + } +} + +class ModelInspectorTestModelEloquentCollection extends Collection +{ +} + +class ModelInspectorTestModelBuilder extends Builder +{ +} + +class ModelInspectorTestModelResource extends JsonResource +{ +} diff --git a/tests/Integration/Database/Laravel/MySql/DatabaseEloquentMySqlIntegrationTest.php b/tests/Integration/Database/Laravel/MySql/DatabaseEloquentMySqlIntegrationTest.php new file mode 100644 index 000000000..bcd964da9 --- /dev/null +++ b/tests/Integration/Database/Laravel/MySql/DatabaseEloquentMySqlIntegrationTest.php @@ -0,0 +1,90 @@ +id(); + $table->string('name')->nullable(); + $table->string('email')->unique(); + $table->timestamps(); + }); + } + } + + protected function destroyDatabaseMigrations(): void + { + Schema::drop('database_eloquent_mysql_integration_users'); + } + + public function testCreateOrFirst() + { + $user1 = DatabaseEloquentMySqlIntegrationUser::createOrFirst(['email' => 'taylorotwell@gmail.com']); + + $this->assertSame('taylorotwell@gmail.com', $user1->email); + $this->assertNull($user1->name); + + $user2 = DatabaseEloquentMySqlIntegrationUser::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 = DatabaseEloquentMySqlIntegrationUser::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 = DatabaseEloquentMySqlIntegrationUser::createOrFirst( + ['name' => 'Dries Vints'], + ['name' => 'Nuno Maduro', 'email' => 'nuno@laravel.com'] + ); + + $this->assertSame('Nuno Maduro', $user4->name); + } + + public function testCreateOrFirstWithinTransaction() + { + $user1 = DatabaseEloquentMySqlIntegrationUser::createOrFirst(['email' => 'taylor@laravel.com']); + + DB::transaction(function () use ($user1) { + $user2 = DatabaseEloquentMySqlIntegrationUser::createOrFirst( + ['email' => 'taylor@laravel.com'], + ['name' => 'Taylor Otwell'] + ); + + $this->assertEquals($user1->id, $user2->id); + $this->assertSame('taylor@laravel.com', $user2->email); + $this->assertNull($user2->name); + }); + } +} + +class DatabaseEloquentMySqlIntegrationUser extends Model +{ + protected ?string $table = 'database_eloquent_mysql_integration_users'; + + protected array $guarded = []; +} diff --git a/tests/Integration/Database/Laravel/MySql/DatabaseEmulatePreparesMySqlConnectionTest.php b/tests/Integration/Database/Laravel/MySql/DatabaseEmulatePreparesMySqlConnectionTest.php new file mode 100755 index 000000000..efdaa175e --- /dev/null +++ b/tests/Integration/Database/Laravel/MySql/DatabaseEmulatePreparesMySqlConnectionTest.php @@ -0,0 +1,27 @@ +set('database.connections.mysql.options', [ + PDO::ATTR_EMULATE_PREPARES => true, + ]); + } +} diff --git a/tests/Integration/Database/Laravel/MySql/DatabaseMySqlConnectionTest.php b/tests/Integration/Database/Laravel/MySql/DatabaseMySqlConnectionTest.php new file mode 100644 index 000000000..703857a47 --- /dev/null +++ b/tests/Integration/Database/Laravel/MySql/DatabaseMySqlConnectionTest.php @@ -0,0 +1,180 @@ +json(self::JSON_COL)->nullable(); + $table->float(self::FLOAT_COL)->nullable(); + }); + } + } + + protected function destroyDatabaseMigrations(): void + { + Schema::drop(self::TABLE); + } + + #[DataProvider('floatComparisonsDataProvider')] + public function testJsonFloatComparison($value, $operator, $shouldMatch) + { + DB::table(self::TABLE)->insert([self::JSON_COL => '{"rank":' . self::FLOAT_VAL . '}']); + + $this->assertSame( + $shouldMatch, + DB::table(self::TABLE)->where(self::JSON_COL . '->rank', $operator, $value)->exists(), + self::JSON_COL . '->rank should ' . ($shouldMatch ? '' : 'not ') . "be {$operator} {$value}" + ); + } + + public static function floatComparisonsDataProvider() + { + return [ + [0.2, '=', true], + [0.2, '>', false], + [0.2, '<', false], + [0.1, '=', false], + [0.1, '<', false], + [0.1, '>', true], + [0.3, '=', false], + [0.3, '<', true], + [0.3, '>', false], + ]; + } + + public function testFloatValueStoredCorrectly() + { + DB::table(self::TABLE)->insert([self::FLOAT_COL => self::FLOAT_VAL]); + + $this->assertEquals(self::FLOAT_VAL, DB::table(self::TABLE)->value(self::FLOAT_COL)); + } + + #[DataProvider('jsonWhereNullDataProvider')] + public function testJsonWhereNull($expected, $key, array $value = ['value' => 123]) + { + DB::table(self::TABLE)->insert([self::JSON_COL => json_encode($value)]); + + $this->assertSame($expected, DB::table(self::TABLE)->whereNull(self::JSON_COL . '->' . $key)->exists()); + } + + #[DataProvider('jsonWhereNullDataProvider')] + public function testJsonWhereNotNull($expected, $key, array $value = ['value' => 123]) + { + DB::table(self::TABLE)->insert([self::JSON_COL => json_encode($value)]); + + $this->assertSame(! $expected, DB::table(self::TABLE)->whereNotNull(self::JSON_COL . '->' . $key)->exists()); + } + + public static function jsonWhereNullDataProvider() + { + return [ + 'key not exists' => [true, 'invalid'], + 'key exists and null' => [true, 'value', ['value' => null]], + 'key exists and "null"' => [false, 'value', ['value' => 'null']], + 'key exists and not null' => [false, 'value', ['value' => false]], + 'nested key not exists' => [true, 'nested->invalid'], + 'nested key exists and null' => [true, 'nested->value', ['nested' => ['value' => null]]], + 'nested key exists and "null"' => [false, 'nested->value', ['nested' => ['value' => 'null']]], + 'nested key exists and not null' => [false, 'nested->value', ['nested' => ['value' => false]]], + 'array index not exists' => [false, '[0]', [1 => 'invalid']], + 'array index exists and null' => [true, '[0]', [null]], + 'array index exists and "null"' => [false, '[0]', ['null']], + 'array index exists and not null' => [false, '[0]', [false]], + 'nested array index not exists' => [false, 'nested[0]', ['nested' => [1 => 'nested->invalid']]], + 'nested array index exists and null' => [true, 'nested->value[1]', ['nested' => ['value' => [0, null]]]], + 'nested array index exists and "null"' => [false, 'nested->value[1]', ['nested' => ['value' => [0, 'null']]]], + 'nested array index exists and not null' => [false, 'nested->value[1]', ['nested' => ['value' => [0, false]]]], + ]; + } + + public function testJsonPathUpdate() + { + DB::table(self::TABLE)->insert([ + [self::JSON_COL => '{"foo":["bar"]}'], + [self::JSON_COL => '{"foo":["baz"]}'], + ]); + $updatedCount = DB::table(self::TABLE)->where(self::JSON_COL . '->foo[0]', 'baz')->update([ + self::JSON_COL . '->foo[0]' => 'updated', + ]); + $this->assertSame(1, $updatedCount); + } + + #[DataProvider('jsonContainsKeyDataProvider')] + public function testWhereJsonContainsKey($count, $column) + { + DB::table(self::TABLE)->insert([ + ['json_col' => '{"foo":{"bar":["baz"]}}'], + ['json_col' => '{"foo":{"bar":false}}'], + ['json_col' => '{"foo":{}}'], + ['json_col' => '{"foo":[{"bar":"bar"},{"baz":"baz"}]}'], + ['json_col' => '{"bar":null}'], + ]); + + $this->assertSame($count, DB::table(self::TABLE)->whereJsonContainsKey($column)->count()); + } + + public static function jsonContainsKeyDataProvider() + { + return [ + 'string key' => [4, 'json_col->foo'], + 'nested key exists' => [2, 'json_col->foo->bar'], + 'string key missing' => [0, 'json_col->none'], + 'integer key with arrow ' => [0, 'json_col->foo->bar->0'], + 'integer key with braces' => [2, 'json_col->foo->bar[0]'], + 'integer key missing' => [0, 'json_col->foo->bar[1]'], + 'mixed keys' => [1, 'json_col->foo[1]->baz'], + 'null value' => [1, 'json_col->bar'], + ]; + } + + public function testLastInsertIdIsPreserved() + { + if (! Schema::hasTable('auto_id_table')) { + Schema::create('auto_id_table', function (Blueprint $table) { + $table->id(); + }); + } + + try { + static $callbackExecuted = false; + DB::listen(function (QueryExecuted $event) use (&$callbackExecuted) { + DB::getPdo()->query('SELECT 1'); + $callbackExecuted = true; + }); + + $id = DB::table('auto_id_table')->insertGetId([]); + $this->assertTrue($callbackExecuted, 'The query listener was not executed.'); + $this->assertEquals(1, $id); + } finally { + Schema::drop('auto_id_table'); + } + } +} diff --git a/tests/Integration/Database/Laravel/MySql/DatabaseMySqlSchemaBuilderAlterTableWithEnumTest.php b/tests/Integration/Database/Laravel/MySql/DatabaseMySqlSchemaBuilderAlterTableWithEnumTest.php new file mode 100644 index 000000000..c032d1c7c --- /dev/null +++ b/tests/Integration/Database/Laravel/MySql/DatabaseMySqlSchemaBuilderAlterTableWithEnumTest.php @@ -0,0 +1,74 @@ +integer('id'); + $table->string('name'); + $table->string('age'); + $table->enum('color', ['red', 'blue']); + }); + } + + protected function destroyDatabaseMigrations(): void + { + Schema::drop('users'); + } + + public function testRenameColumnOnTableWithEnum() + { + Schema::table('users', function (Blueprint $table) { + $table->renameColumn('name', 'username'); + }); + + $this->assertTrue(Schema::hasColumn('users', 'username')); + } + + public function testChangeColumnOnTableWithEnum() + { + Schema::table('users', function (Blueprint $table) { + $table->unsignedInteger('age')->change(); + }); + + $this->assertSame('int', Schema::getColumnType('users', 'age')); + } + + public function testGetTablesAndColumnListing() + { + $tables = Schema::getTables(); + + $this->assertCount(2, $tables); + $this->assertEquals(['migrations', 'users'], array_column($tables, 'name')); + + $columns = Schema::getColumnListing('users'); + + foreach (['id', 'name', 'age', 'color'] as $column) { + $this->assertContains($column, $columns); + } + + Schema::create('posts', function (Blueprint $table) { + $table->integer('id'); + $table->string('title'); + }); + $tables = Schema::getTables(); + $this->assertCount(3, $tables); + Schema::drop('posts'); + } +} diff --git a/tests/Integration/Database/Laravel/MySql/DatabaseMySqlSchemaBuilderTest.php b/tests/Integration/Database/Laravel/MySql/DatabaseMySqlSchemaBuilderTest.php new file mode 100644 index 000000000..e1ca884e3 --- /dev/null +++ b/tests/Integration/Database/Laravel/MySql/DatabaseMySqlSchemaBuilderTest.php @@ -0,0 +1,53 @@ +id(); + $table->comment('This is a comment'); + }); + + $tableInfo = DB::table('information_schema.tables') + ->where('table_schema', $this->app['config']->get('database.connections.mysql.database')) + ->where('table_name', 'users') + ->select('table_comment as table_comment') + ->first(); + + $this->assertEquals('This is a comment', $tableInfo->table_comment); + + Schema::drop('users'); + } + + #[RequiresDatabase('mysql', '>=8.0.13')] + public function testGetRawIndex() + { + Schema::create('table', function (Blueprint $table) { + $table->id(); + $table->timestamps(); + $table->rawIndex('(year(created_at))', 'table_raw_index'); + }); + + $indexes = Schema::getIndexes('table'); + + $this->assertSame([], collect($indexes)->firstWhere('name', 'table_raw_index')['columns']); + } +} diff --git a/tests/Integration/Database/Laravel/MySql/EloquentCastTest.php b/tests/Integration/Database/Laravel/MySql/EloquentCastTest.php new file mode 100644 index 000000000..e0d394249 --- /dev/null +++ b/tests/Integration/Database/Laravel/MySql/EloquentCastTest.php @@ -0,0 +1,243 @@ +increments('id'); + $table->string('email')->unique(); + $table->integer('created_at'); + $table->integer('updated_at'); + }); + + Schema::create('users_nullable_timestamps', function ($table) { + $table->increments('id'); + $table->string('email')->unique(); + $table->timestamp('created_at')->nullable(); + $table->timestamp('updated_at')->nullable(); + }); + } + + protected function destroyDatabaseMigrations(): void + { + Schema::drop('users'); + } + + public function testItCastTimestampsCreatedByTheBuilderWhenTimeHasNotPassed() + { + Carbon::setTestNow(now()); + $createdAt = now()->timestamp; + + $castUser = UserWithIntTimestampsViaCasts::create([ + 'email' => fake()->unique()->email, + ]); + $attributeUser = UserWithIntTimestampsViaAttribute::create([ + 'email' => fake()->unique()->email, + ]); + $mutatorUser = UserWithIntTimestampsViaMutator::create([ + 'email' => fake()->unique()->email, + ]); + + $this->assertSame($createdAt, $castUser->created_at->timestamp); + $this->assertSame($createdAt, $castUser->updated_at->timestamp); + $this->assertSame($createdAt, $attributeUser->created_at->timestamp); + $this->assertSame($createdAt, $attributeUser->updated_at->timestamp); + $this->assertSame($createdAt, $mutatorUser->created_at->timestamp); + $this->assertSame($createdAt, $mutatorUser->updated_at->timestamp); + + $castUser->update([ + 'email' => fake()->unique()->email, + ]); + $attributeUser->update([ + 'email' => fake()->unique()->email, + ]); + $mutatorUser->update([ + 'email' => fake()->unique()->email, + ]); + + $this->assertSame($createdAt, $castUser->created_at->timestamp); + $this->assertSame($createdAt, $castUser->updated_at->timestamp); + $this->assertSame($createdAt, $castUser->fresh()->updated_at->timestamp); + $this->assertSame($createdAt, $attributeUser->created_at->timestamp); + $this->assertSame($createdAt, $attributeUser->updated_at->timestamp); + $this->assertSame($createdAt, $attributeUser->fresh()->updated_at->timestamp); + $this->assertSame($createdAt, $mutatorUser->created_at->timestamp); + $this->assertSame($createdAt, $mutatorUser->updated_at->timestamp); + $this->assertSame($createdAt, $mutatorUser->fresh()->updated_at->timestamp); + } + + public function testItCastTimestampsCreatedByTheBuilderWhenTimeHasPassed() + { + Carbon::setTestNow(now()); + $createdAt = now()->timestamp; + + $castUser = UserWithIntTimestampsViaCasts::create([ + 'email' => fake()->unique()->email, + ]); + $attributeUser = UserWithIntTimestampsViaAttribute::create([ + 'email' => fake()->unique()->email, + ]); + $mutatorUser = UserWithIntTimestampsViaMutator::create([ + 'email' => fake()->unique()->email, + ]); + + $this->assertSame($createdAt, $castUser->created_at->timestamp); + $this->assertSame($createdAt, $castUser->updated_at->timestamp); + $this->assertSame($createdAt, $attributeUser->created_at->timestamp); + $this->assertSame($createdAt, $attributeUser->updated_at->timestamp); + $this->assertSame($createdAt, $mutatorUser->created_at->timestamp); + $this->assertSame($createdAt, $mutatorUser->updated_at->timestamp); + + Carbon::setTestNow(now()->addSecond()); + $updatedAt = now()->timestamp; + + $castUser->update([ + 'email' => fake()->unique()->email, + ]); + $attributeUser->update([ + 'email' => fake()->unique()->email, + ]); + $mutatorUser->update([ + 'email' => fake()->unique()->email, + ]); + + $this->assertSame($createdAt, $castUser->created_at->timestamp); + $this->assertSame($updatedAt, $castUser->updated_at->timestamp); + $this->assertSame($updatedAt, $castUser->fresh()->updated_at->timestamp); + $this->assertSame($createdAt, $attributeUser->created_at->timestamp); + $this->assertSame($updatedAt, $attributeUser->updated_at->timestamp); + $this->assertSame($updatedAt, $attributeUser->fresh()->updated_at->timestamp); + $this->assertSame($createdAt, $mutatorUser->created_at->timestamp); + $this->assertSame($updatedAt, $mutatorUser->updated_at->timestamp); + $this->assertSame($updatedAt, $mutatorUser->fresh()->updated_at->timestamp); + } + + public function testItCastTimestampsUpdatedByAMutator() + { + Carbon::setTestNow(now()); + + $mutatorUser = UserWithUpdatedAtViaMutator::create([ + 'email' => fake()->unique()->email, + ]); + + $this->assertNull($mutatorUser->updated_at); + + Carbon::setTestNow(now()->addSecond()); + $updatedAt = now()->timestamp; + + $mutatorUser->update([ + 'email' => fake()->unique()->email, + ]); + + $this->assertSame($updatedAt, $mutatorUser->updated_at->timestamp); + $this->assertSame($updatedAt, $mutatorUser->fresh()->updated_at->timestamp); + } +} + +class UserWithIntTimestampsViaCasts extends Model +{ + protected ?string $table = 'users'; + + protected array $fillable = ['email']; + + protected array $casts = [ + 'created_at' => UnixTimeStampToCarbon::class, + 'updated_at' => UnixTimeStampToCarbon::class, + ]; +} + +class UnixTimeStampToCarbon implements CastsAttributes +{ + public function get($model, string $key, $value, array $attributes) + { + return Carbon::parse($value); + } + + public function set($model, string $key, $value, array $attributes) + { + return Carbon::parse($value)->timestamp; + } +} + +class UserWithIntTimestampsViaAttribute extends Model +{ + protected ?string $table = 'users'; + + protected array $fillable = ['email']; + + protected function updatedAt(): Attribute + { + return Attribute::make( + get: fn ($value) => Carbon::parse($value), + set: fn ($value) => Carbon::parse($value)->timestamp, + ); + } + + protected function createdAt(): Attribute + { + return Attribute::make( + get: fn ($value) => Carbon::parse($value), + set: fn ($value) => Carbon::parse($value)->timestamp, + ); + } +} + +class UserWithIntTimestampsViaMutator extends Model +{ + protected ?string $table = 'users'; + + protected array $fillable = ['email']; + + protected function getUpdatedAtAttribute($value) + { + return Carbon::parse($value); + } + + protected function setUpdatedAtAttribute($value) + { + $this->attributes['updated_at'] = Carbon::parse($value)->timestamp; + } + + protected function getCreatedAtAttribute($value) + { + return Carbon::parse($value); + } + + protected function setCreatedAtAttribute($value) + { + $this->attributes['created_at'] = Carbon::parse($value)->timestamp; + } +} + +class UserWithUpdatedAtViaMutator extends Model +{ + protected ?string $table = 'users_nullable_timestamps'; + + protected array $fillable = ['email', 'updated_at']; + + public function setUpdatedAtAttribute($value) + { + if (! $this->id) { + return; + } + + $this->attributes['updated_at'] = $value; + } +} diff --git a/tests/Integration/Database/Laravel/MySql/EscapeTest.php b/tests/Integration/Database/Laravel/MySql/EscapeTest.php new file mode 100644 index 000000000..2b931dc52 --- /dev/null +++ b/tests/Integration/Database/Laravel/MySql/EscapeTest.php @@ -0,0 +1,77 @@ +assertSame('42', $this->app['db']->escape(42)); + $this->assertSame('-6', $this->app['db']->escape(-6)); + } + + public function testEscapeFloat() + { + $this->assertSame('3.14159', $this->app['db']->escape(3.14159)); + $this->assertSame('-3.14159', $this->app['db']->escape(-3.14159)); + } + + public function testEscapeBool() + { + $this->assertSame('1', $this->app['db']->escape(true)); + $this->assertSame('0', $this->app['db']->escape(false)); + } + + public function testEscapeNull() + { + $this->assertSame('null', $this->app['db']->escape(null)); + $this->assertSame('null', $this->app['db']->escape(null, true)); + } + + public function testEscapeBinary() + { + $this->assertSame("x'dead00beef'", $this->app['db']->escape(hex2bin('dead00beef'), true)); + } + + public function testEscapeString() + { + $this->assertSame("'2147483647'", $this->app['db']->escape('2147483647')); + $this->assertSame("'true'", $this->app['db']->escape('true')); + $this->assertSame("'false'", $this->app['db']->escape('false')); + $this->assertSame("'null'", $this->app['db']->escape('null')); + $this->assertSame("'Hello\\'World'", $this->app['db']->escape("Hello'World")); + } + + public function testEscapeStringInvalidUtf8() + { + $this->expectException(RuntimeException::class); + + $this->app['db']->escape("I am hiding an invalid \x80 utf-8 continuation byte"); + } + + public function testEscapeStringNullByte() + { + $this->expectException(RuntimeException::class); + + $this->app['db']->escape("I am hiding a \00 byte"); + } + + public function testEscapeArray() + { + $this->expectException(RuntimeException::class); + + $this->app['db']->escape(['a', 'b']); + } +} diff --git a/tests/Integration/Database/Laravel/MySql/FulltextTest.php b/tests/Integration/Database/Laravel/MySql/FulltextTest.php new file mode 100644 index 000000000..b508da533 --- /dev/null +++ b/tests/Integration/Database/Laravel/MySql/FulltextTest.php @@ -0,0 +1,70 @@ +id('id'); + $table->string('title', 200); + $table->text('body'); + $table->fulltext(['title', 'body']); + }); + + DB::table('articles')->insert([ + ['title' => 'MySQL Tutorial', 'body' => 'DBMS stands for DataBase ...'], + ['title' => 'How To Use MySQL Well', 'body' => 'After you went through a ...'], + ['title' => 'Optimizing MySQL', 'body' => 'In this tutorial, we show ...'], + ['title' => '1001 MySQL Tricks', 'body' => '1. Never run mysqld as root. 2. ...'], + ['title' => 'MySQL vs. YourSQL', 'body' => 'In the following database comparison ...'], + ['title' => 'MySQL Security', 'body' => 'When configured properly, MySQL ...'], + ]); + } + + protected function destroyDatabaseMigrations(): void + { + Schema::drop('articles'); + } + + /** @link https://dev.mysql.com/doc/refman/8.0/en/fulltext-natural-language.html */ + public function testWhereFulltext() + { + $articles = DB::table('articles')->whereFullText(['title', 'body'], 'database')->get(); + + $this->assertCount(2, $articles); + $this->assertSame('MySQL Tutorial', $articles[0]->title); + $this->assertSame('MySQL vs. YourSQL', $articles[1]->title); + } + + /** @link https://dev.mysql.com/doc/refman/8.0/en/fulltext-boolean.html */ + public function testWhereFulltextWithBooleanMode() + { + $articles = DB::table('articles')->whereFullText(['title', 'body'], '+MySQL -YourSQL', ['mode' => 'boolean'])->get(); + + $this->assertCount(5, $articles); + } + + /** @link https://dev.mysql.com/doc/refman/8.0/en/fulltext-query-expansion.html */ + public function testWhereFulltextWithExpandedQuery() + { + $articles = DB::table('articles')->whereFullText(['title', 'body'], 'database', ['expanded' => true])->get(); + + $this->assertCount(6, $articles); + } +} diff --git a/tests/Integration/Database/Laravel/MySql/JoinLateralTest.php b/tests/Integration/Database/Laravel/MySql/JoinLateralTest.php new file mode 100644 index 000000000..f1f1aae19 --- /dev/null +++ b/tests/Integration/Database/Laravel/MySql/JoinLateralTest.php @@ -0,0 +1,118 @@ +checkMySqlVersion(); + + Schema::create('users', function (Blueprint $table) { + $table->id('id'); + $table->string('name'); + }); + + Schema::create('posts', function (Blueprint $table) { + $table->id('id'); + $table->string('title'); + $table->integer('rating'); + $table->unsignedBigInteger('user_id'); + }); + + DB::table('users')->insert([ + ['name' => Str::random()], + ['name' => Str::random()], + ]); + + DB::table('posts')->insert([ + ['title' => Str::random(), 'rating' => 1, 'user_id' => 1], + ['title' => Str::random(), 'rating' => 3, 'user_id' => 1], + ['title' => Str::random(), 'rating' => 7, 'user_id' => 1], + ]); + } + + protected function destroyDatabaseMigrations(): void + { + Schema::drop('posts'); + Schema::drop('users'); + } + + protected function checkMySqlVersion(): void + { + $mySqlVersion = DB::select('select version()')[0]->{'version()'} ?? ''; + + if (str_contains($mySqlVersion, 'Maria')) { + $this->markTestSkipped('Lateral joins are not supported on MariaDB' . __CLASS__); + } elseif ((float) $mySqlVersion < '8.0.14') { + $this->markTestSkipped('Lateral joins are not supported on MySQL < 8.0.14' . __CLASS__); + } + } + + public function testJoinLateral() + { + $subquery = DB::table('posts') + ->select('title as best_post_title', 'rating as best_post_rating') + ->whereColumn('user_id', 'users.id') + ->orderBy('rating', 'desc') + ->limit(2); + + $userWithPosts = DB::table('users') + ->where('id', 1) + ->joinLateral($subquery, 'best_post') + ->get(); + + $this->assertCount(2, $userWithPosts); + $this->assertEquals(7, $userWithPosts[0]->best_post_rating); + $this->assertEquals(3, $userWithPosts[1]->best_post_rating); + + $userWithoutPosts = DB::table('users') + ->where('id', 2) + ->joinLateral($subquery, 'best_post') + ->get(); + + $this->assertCount(0, $userWithoutPosts); + } + + public function testLeftJoinLateral() + { + $subquery = DB::table('posts') + ->select('title as best_post_title', 'rating as best_post_rating') + ->whereColumn('user_id', 'users.id') + ->orderBy('rating', 'desc') + ->limit(2); + + $userWithPosts = DB::table('users') + ->where('id', 1) + ->leftJoinLateral($subquery, 'best_post') + ->get(); + + $this->assertCount(2, $userWithPosts); + $this->assertEquals(7, $userWithPosts[0]->best_post_rating); + $this->assertEquals(3, $userWithPosts[1]->best_post_rating); + + $userWithoutPosts = DB::table('users') + ->where('id', 2) + ->leftJoinLateral($subquery, 'best_post') + ->get(); + + $this->assertCount(1, $userWithoutPosts); + $this->assertNull($userWithoutPosts[0]->best_post_title); + $this->assertNull($userWithoutPosts[0]->best_post_rating); + } +} diff --git a/tests/Integration/Database/Laravel/MySql/MySqlTestCase.php b/tests/Integration/Database/Laravel/MySql/MySqlTestCase.php new file mode 100644 index 000000000..0ac8b0681 --- /dev/null +++ b/tests/Integration/Database/Laravel/MySql/MySqlTestCase.php @@ -0,0 +1,17 @@ +id(); + $table->string('name')->nullable(); + $table->string('email')->unique(); + $table->timestamps(); + }); + } + } + + protected function destroyDatabaseMigrations(): void + { + Schema::drop('database_eloquent_postgres_integration_users'); + } + + public function testCreateOrFirst() + { + $user1 = DatabaseEloquentPostgresIntegrationUser::createOrFirst(['email' => 'taylorotwell@gmail.com']); + + $this->assertSame('taylorotwell@gmail.com', $user1->email); + $this->assertNull($user1->name); + + $user2 = DatabaseEloquentPostgresIntegrationUser::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 = DatabaseEloquentPostgresIntegrationUser::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 = DatabaseEloquentPostgresIntegrationUser::createOrFirst( + ['name' => 'Dries Vints'], + ['name' => 'Nuno Maduro', 'email' => 'nuno@laravel.com'] + ); + + $this->assertSame('Nuno Maduro', $user4->name); + } + + public function testCreateOrFirstWithinTransaction() + { + $user1 = DatabaseEloquentPostgresIntegrationUser::create(['email' => 'taylor@laravel.com']); + + DB::transaction(function () use ($user1) { + $user2 = DatabaseEloquentPostgresIntegrationUser::createOrFirst( + ['email' => 'taylor@laravel.com'], + ['name' => 'Taylor Otwell'] + ); + + $this->assertEquals($user1->id, $user2->id); + $this->assertSame('taylor@laravel.com', $user2->email); + $this->assertNull($user2->name); + }); + } +} + +class DatabaseEloquentPostgresIntegrationUser extends Model +{ + protected ?string $table = 'database_eloquent_postgres_integration_users'; + + protected array $guarded = []; +} diff --git a/tests/Integration/Database/Laravel/Postgres/DatabasePostgresConnectionTest.php b/tests/Integration/Database/Laravel/Postgres/DatabasePostgresConnectionTest.php new file mode 100644 index 000000000..c4a6cf380 --- /dev/null +++ b/tests/Integration/Database/Laravel/Postgres/DatabasePostgresConnectionTest.php @@ -0,0 +1,124 @@ +json('json_col')->nullable(); + }); + } + } + + protected function destroyDatabaseMigrations(): void + { + Schema::drop('json_table'); + } + + #[DataProvider('jsonWhereNullDataProvider')] + public function testJsonWhereNull($expected, $key, array $value = ['value' => 123]) + { + DB::table('json_table')->insert(['json_col' => json_encode($value)]); + + $this->assertSame($expected, DB::table('json_table')->whereNull("json_col->{$key}")->exists()); + } + + #[DataProvider('jsonWhereNullDataProvider')] + public function testJsonWhereNotNull($expected, $key, array $value = ['value' => 123]) + { + DB::table('json_table')->insert(['json_col' => json_encode($value)]); + + $this->assertSame(! $expected, DB::table('json_table')->whereNotNull("json_col->{$key}")->exists()); + } + + public static function jsonWhereNullDataProvider() + { + return [ + 'key not exists' => [true, 'invalid'], + 'key exists and null' => [true, 'value', ['value' => null]], + 'key exists and "null"' => [false, 'value', ['value' => 'null']], + 'key exists and not null' => [false, 'value', ['value' => false]], + 'nested key not exists' => [true, 'nested->invalid'], + 'nested key exists and null' => [true, 'nested->value', ['nested' => ['value' => null]]], + 'nested key exists and "null"' => [false, 'nested->value', ['nested' => ['value' => 'null']]], + 'nested key exists and not null' => [false, 'nested->value', ['nested' => ['value' => false]]], + 'array index not exists' => [true, '[0]', [1 => 'invalid']], + 'array index exists and null' => [true, '[0]', [null]], + 'array index exists and "null"' => [false, '[0]', ['null']], + 'array index exists and not null' => [false, '[0]', [false]], + 'multiple array index not exists' => [true, '[0][0]', [1 => [1 => 'invalid']]], + 'multiple array index exists and null' => [true, '[0][0]', [[null]]], + 'multiple array index exists and "null"' => [false, '[0][0]', [['null']]], + 'multiple array index exists and not null' => [false, '[0][0]', [[false]]], + 'nested array index not exists' => [true, 'nested[0]', ['nested' => [1 => 'nested->invalid']]], + 'nested array index exists and null' => [true, 'nested->value[1]', ['nested' => ['value' => [0, null]]]], + 'nested array index exists and "null"' => [false, 'nested->value[1]', ['nested' => ['value' => [0, 'null']]]], + 'nested array index exists and not null' => [false, 'nested->value[1]', ['nested' => ['value' => [0, false]]]], + ]; + } + + public function testJsonPathUpdate() + { + DB::table('json_table')->insert([ + ['json_col' => '{"foo":["bar"]}'], + ['json_col' => '{"foo":["baz"]}'], + ['json_col' => '{"foo":[["array"]]}'], + ]); + + $updatedCount = DB::table('json_table')->where('json_col->foo[0]', 'baz')->update([ + 'json_col->foo[0]' => 'updated', + ]); + $this->assertSame(1, $updatedCount); + + $updatedCount = DB::table('json_table')->where('json_col->foo[0][0]', 'array')->update([ + 'json_col->foo[0][0]' => 'updated', + ]); + $this->assertSame(1, $updatedCount); + } + + #[DataProvider('jsonContainsKeyDataProvider')] + public function testWhereJsonContainsKey($count, $column) + { + DB::table('json_table')->insert([ + ['json_col' => '{"foo":{"bar":["baz"]}}'], + ['json_col' => '{"foo":{"bar":false}}'], + ['json_col' => '{"foo":{}}'], + ['json_col' => '{"foo":[{"bar":"bar"},{"baz":"baz"}]}'], + ['json_col' => '{"bar":null}'], + ]); + + $this->assertSame($count, DB::table('json_table')->whereJsonContainsKey($column)->count()); + } + + public static function jsonContainsKeyDataProvider() + { + return [ + 'string key' => [4, 'json_col->foo'], + 'nested key exists' => [2, 'json_col->foo->bar'], + 'string key missing' => [0, 'json_col->none'], + 'integer key with arrow ' => [1, 'json_col->foo->bar->0'], + 'integer key with braces' => [1, 'json_col->foo->bar[0]'], + 'integer key missing' => [0, 'json_col->foo->bar[1]'], + 'mixed keys' => [1, 'json_col->foo[1]->baz'], + 'null value' => [1, 'json_col->bar'], + ]; + } +} diff --git a/tests/Integration/Database/Laravel/Postgres/EscapeTest.php b/tests/Integration/Database/Laravel/Postgres/EscapeTest.php new file mode 100644 index 000000000..921d154f8 --- /dev/null +++ b/tests/Integration/Database/Laravel/Postgres/EscapeTest.php @@ -0,0 +1,77 @@ +assertSame('42', $this->app['db']->escape(42)); + $this->assertSame('-6', $this->app['db']->escape(-6)); + } + + public function testEscapeFloat() + { + $this->assertSame('3.14159', $this->app['db']->escape(3.14159)); + $this->assertSame('-3.14159', $this->app['db']->escape(-3.14159)); + } + + public function testEscapeBool() + { + $this->assertSame('true', $this->app['db']->escape(true)); + $this->assertSame('false', $this->app['db']->escape(false)); + } + + public function testEscapeNull() + { + $this->assertSame('null', $this->app['db']->escape(null)); + $this->assertSame('null', $this->app['db']->escape(null, true)); + } + + public function testEscapeBinary() + { + $this->assertSame("'\\xdead00beef'::bytea", $this->app['db']->escape(hex2bin('dead00beef'), true)); + } + + public function testEscapeString() + { + $this->assertSame("'2147483647'", $this->app['db']->escape('2147483647')); + $this->assertSame("'true'", $this->app['db']->escape('true')); + $this->assertSame("'false'", $this->app['db']->escape('false')); + $this->assertSame("'null'", $this->app['db']->escape('null')); + $this->assertSame("'Hello''World'", $this->app['db']->escape("Hello'World")); + } + + public function testEscapeStringInvalidUtf8() + { + $this->expectException(RuntimeException::class); + + $this->app['db']->escape("I am hiding an invalid \x80 utf-8 continuation byte"); + } + + public function testEscapeStringNullByte() + { + $this->expectException(RuntimeException::class); + + $this->app['db']->escape("I am hiding a \00 byte"); + } + + public function testEscapeArray() + { + $this->expectException(RuntimeException::class); + + $this->app['db']->escape(['a', 'b']); + } +} diff --git a/tests/Integration/Database/Laravel/Postgres/FulltextTest.php b/tests/Integration/Database/Laravel/Postgres/FulltextTest.php new file mode 100644 index 000000000..f068a42ab --- /dev/null +++ b/tests/Integration/Database/Laravel/Postgres/FulltextTest.php @@ -0,0 +1,76 @@ +id('id'); + $table->string('title', 200); + $table->text('body'); + $table->fulltext(['title', 'body']); + }); + + DB::table('articles')->insert([ + ['title' => 'PostgreSQL Tutorial', 'body' => 'DBMS stands for DataBase ...'], + ['title' => 'How To Use PostgreSQL Well', 'body' => 'After you went through a ...'], + ['title' => 'Optimizing PostgreSQL', 'body' => 'In this tutorial, we show ...'], + ['title' => '1001 PostgreSQL Tricks', 'body' => '1. Never run mysqld as root. 2. ...'], + ['title' => 'PostgreSQL vs. YourSQL', 'body' => 'In the following database comparison ...'], + ['title' => 'PostgreSQL Security', 'body' => 'When configured properly, PostgreSQL ...'], + ]); + } + + protected function destroyDatabaseMigrations(): void + { + Schema::drop('articles'); + } + + public function testWhereFulltext() + { + $articles = DB::table('articles')->whereFullText(['title', 'body'], 'database')->orderBy('id')->get(); + + $this->assertCount(2, $articles); + $this->assertSame('PostgreSQL Tutorial', $articles[0]->title); + $this->assertSame('PostgreSQL vs. YourSQL', $articles[1]->title); + } + + #[RequiresDatabase('pgsql', '>=11.0')] + public function testWhereFulltextWithWebsearch() + { + $articles = DB::table('articles')->whereFullText(['title', 'body'], '+PostgreSQL -YourSQL', ['mode' => 'websearch'])->get(); + + $this->assertCount(5, $articles); + } + + public function testWhereFulltextWithPlain() + { + $articles = DB::table('articles')->whereFullText(['title', 'body'], 'PostgreSQL tutorial', ['mode' => 'plain'])->get(); + + $this->assertCount(2, $articles); + } + + public function testWhereFulltextWithPhrase() + { + $articles = DB::table('articles')->whereFullText(['title', 'body'], 'PostgreSQL tutorial', ['mode' => 'phrase'])->get(); + + $this->assertCount(1, $articles); + } +} diff --git a/tests/Integration/Database/Laravel/Postgres/JoinLateralTest.php b/tests/Integration/Database/Laravel/Postgres/JoinLateralTest.php new file mode 100644 index 000000000..b5aa8330a --- /dev/null +++ b/tests/Integration/Database/Laravel/Postgres/JoinLateralTest.php @@ -0,0 +1,105 @@ +id('id'); + $table->string('name'); + }); + + Schema::create('posts', function (Blueprint $table) { + $table->id('id'); + $table->string('title'); + $table->integer('rating'); + $table->unsignedBigInteger('user_id'); + }); + + DB::table('users')->insert([ + ['name' => Str::random()], + ['name' => Str::random()], + ]); + + DB::table('posts')->insert([ + ['title' => Str::random(), 'rating' => 1, 'user_id' => 1], + ['title' => Str::random(), 'rating' => 3, 'user_id' => 1], + ['title' => Str::random(), 'rating' => 7, 'user_id' => 1], + ]); + } + + protected function destroyDatabaseMigrations(): void + { + Schema::drop('posts'); + Schema::drop('users'); + } + + public function testJoinLateral() + { + $subquery = DB::table('posts') + ->select('title as best_post_title', 'rating as best_post_rating') + ->whereColumn('user_id', 'users.id') + ->orderBy('rating', 'desc') + ->limit(2); + + $userWithPosts = DB::table('users') + ->where('id', 1) + ->joinLateral($subquery, 'best_post') + ->get(); + + $this->assertCount(2, $userWithPosts); + $this->assertEquals(7, $userWithPosts[0]->best_post_rating); + $this->assertEquals(3, $userWithPosts[1]->best_post_rating); + + $userWithoutPosts = DB::table('users') + ->where('id', 2) + ->joinLateral($subquery, 'best_post') + ->get(); + + $this->assertCount(0, $userWithoutPosts); + } + + public function testLeftJoinLateral() + { + $subquery = DB::table('posts') + ->select('title as best_post_title', 'rating as best_post_rating') + ->whereColumn('user_id', 'users.id') + ->orderBy('rating', 'desc') + ->limit(2); + + $userWithPosts = DB::table('users') + ->where('id', 1) + ->leftJoinLateral($subquery, 'best_post') + ->get(); + + $this->assertCount(2, $userWithPosts); + $this->assertEquals(7, $userWithPosts[0]->best_post_rating); + $this->assertEquals(3, $userWithPosts[1]->best_post_rating); + + $userWithoutPosts = DB::table('users') + ->where('id', 2) + ->leftJoinLateral($subquery, 'best_post') + ->get(); + + $this->assertCount(1, $userWithoutPosts); + $this->assertNull($userWithoutPosts[0]->best_post_title); + $this->assertNull($userWithoutPosts[0]->best_post_rating); + } +} diff --git a/tests/Integration/Database/Laravel/Postgres/PostgresSchemaBuilderTest.php b/tests/Integration/Database/Laravel/Postgres/PostgresSchemaBuilderTest.php new file mode 100644 index 000000000..37875dc11 --- /dev/null +++ b/tests/Integration/Database/Laravel/Postgres/PostgresSchemaBuilderTest.php @@ -0,0 +1,263 @@ +set('database.connections.pgsql.search_path', 'public,private'); + } + + /** + * Configure pgsql_dont_drop_all connection with dont_drop for all schemas. + */ + protected function usePgsqlDontDropAll(Application $app): void + { + $baseConfig = $app['config']->get('database.connections.pgsql'); + $app['config']->set('database.connections.pgsql_dont_drop_all', array_merge($baseConfig, [ + 'search_path' => 'public,private', + 'dont_drop' => ['spatial_ref_sys', 'table'], + ])); + } + + /** + * Configure pgsql_dont_drop_one connection with dont_drop for one schema only. + */ + protected function usePgsqlDontDropOne(Application $app): void + { + $baseConfig = $app['config']->get('database.connections.pgsql'); + $app['config']->set('database.connections.pgsql_dont_drop_one', array_merge($baseConfig, [ + 'search_path' => 'public,private', + 'dont_drop' => ['spatial_ref_sys', 'private.table'], + ])); + } + + protected function defineDatabaseMigrations(): void + { + parent::defineDatabaseMigrations(); + + DB::statement('create schema if not exists private'); + } + + protected function destroyDatabaseMigrations(): void + { + DB::statement('drop table if exists public.table'); + DB::statement('drop table if exists private.table'); + + DB::statement('drop view if exists public.foo'); + DB::statement('drop view if exists private.foo'); + + DB::statement('drop schema private'); + + parent::destroyDatabaseMigrations(); + } + + public function testDropAllTablesOnAllSchemas() + { + Schema::create('public.table', function (Blueprint $table) { + $table->increments('id'); + }); + Schema::create('private.table', function (Blueprint $table) { + $table->increments('id'); + }); + + Schema::dropAllTables(); + + $this->artisan('migrate:install'); + + $this->assertFalse(Schema::hasTable('public.table')); + $this->assertFalse(Schema::hasTable('private.table')); + } + + #[DefineEnvironment('usePgsqlDontDropAll')] + public function testDropAllTablesUsesDontDropConfigOnAllSchemas(): void + { + $schema = Schema::connection('pgsql_dont_drop_all'); + + $schema->create('public.table', function (Blueprint $table) { + $table->increments('id'); + }); + $schema->create('private.table', function (Blueprint $table) { + $table->increments('id'); + }); + + $schema->dropAllTables(); + + $this->artisan('migrate:install', ['--database' => 'pgsql_dont_drop_all']); + + $this->assertTrue($schema->hasTable('public.table')); + $this->assertTrue($schema->hasTable('private.table')); + } + + #[DefineEnvironment('usePgsqlDontDropOne')] + public function testDropAllTablesUsesDontDropConfigOnOneSchema(): void + { + $schema = Schema::connection('pgsql_dont_drop_one'); + + $schema->create('public.table', function (Blueprint $table) { + $table->increments('id'); + }); + $schema->create('private.table', function (Blueprint $table) { + $table->increments('id'); + }); + + $schema->dropAllTables(); + + $this->artisan('migrate:install', ['--database' => 'pgsql_dont_drop_one']); + + $this->assertFalse($schema->hasTable('public.table')); + $this->assertTrue($schema->hasTable('private.table')); + } + + public function testDropAllViewsOnAllSchemas() + { + DB::statement('create view public.foo (id) as select 1'); + DB::statement('create view private.foo (id) as select 1'); + + $this->assertTrue(Schema::hasView('public.foo')); + $this->assertTrue(Schema::hasView('private.foo')); + + Schema::dropAllViews(); + + $this->assertFalse(Schema::hasView('public.foo')); + $this->assertFalse(Schema::hasView('private.foo')); + } + + public function testAddTableCommentOnNewTable() + { + Schema::create('public.posts', function (Blueprint $table) { + $table->comment('This is a comment'); + }); + + $this->assertEquals('This is a comment', DB::selectOne("select obj_description('public.posts'::regclass, 'pg_class')")->obj_description); + } + + public function testAddTableCommentOnExistingTable() + { + Schema::create('public.posts', function (Blueprint $table) { + $table->id(); + $table->comment('This is a comment'); + }); + + Schema::table('public.posts', function (Blueprint $table) { + $table->comment('This is a new comment'); + }); + + $this->assertEquals('This is a new comment', DB::selectOne("select obj_description('public.posts'::regclass, 'pg_class')")->obj_description); + } + + public function testGetTables() + { + Schema::create('public.table', function (Blueprint $table) { + $table->string('name'); + }); + + Schema::create('private.table', function (Blueprint $table) { + $table->integer('votes'); + }); + + $tables = Schema::getTables(); + + $this->assertNotEmpty(array_filter($tables, function ($table) { + return $table['name'] === 'table' && $table['schema'] === 'public'; + })); + $this->assertNotEmpty(array_filter($tables, function ($table) { + return $table['name'] === 'table' && $table['schema'] === 'private'; + })); + } + + public function testGetViews() + { + DB::statement('create view public.foo (id) as select 1'); + DB::statement('create view private.foo (id) as select 1'); + + $views = Schema::getViews(); + + $this->assertNotEmpty(array_filter($views, function ($view) { + return $view['name'] === 'foo' && $view['schema'] === 'public'; + })); + $this->assertNotEmpty(array_filter($views, function ($view) { + return $view['name'] === 'foo' && $view['schema'] === 'private'; + })); + } + + #[RequiresDatabase('pgsql', '>=11.0')] + public function testDropPartitionedTables() + { + DB::statement('create table groups (id bigserial, tenant_id bigint, name varchar, primary key (id, tenant_id)) partition by hash (tenant_id)'); + DB::statement('create table groups_1 partition of groups for values with (modulus 2, remainder 0)'); + DB::statement('create table groups_2 partition of groups for values with (modulus 2, remainder 1)'); + + $tables = array_column(Schema::getTables(), 'name'); + + $this->assertContains('groups', $tables); + $this->assertContains('groups_1', $tables); + $this->assertContains('groups_2', $tables); + + Schema::dropAllTables(); + + $this->artisan('migrate:install'); + + $tables = array_column(Schema::getTables(), 'name'); + + $this->assertNotContains('groups', $tables); + $this->assertNotContains('groups_1', $tables); + $this->assertNotContains('groups_2', $tables); + } + + public function testGetRawIndex() + { + Schema::create('public.table', function (Blueprint $table) { + $table->id(); + $table->timestamps(); + $table->rawIndex("DATE_TRUNC('year'::text,created_at)", 'table_raw_index'); + }); + + $indexes = Schema::getIndexes('public.table'); + + $this->assertSame([], collect($indexes)->firstWhere('name', 'table_raw_index')['columns']); + } + + public function testCreateIndexesOnline() + { + Schema::create('public.table', function (Blueprint $table) { + $table->id(); + $table->timestamps(); + $table->string('title', 200); + $table->text('body'); + + $table->unique('title')->online(); + $table->index(['created_at'])->online(); + $table->fullText(['body'])->online(); + $table->rawIndex("DATE_TRUNC('year'::text,created_at)", 'table_raw_index')->online(); + }); + + $indexes = Schema::getIndexes('public.table'); + $indexNames = collect($indexes)->pluck('name'); + + $this->assertContains('public_table_title_unique', $indexNames); + $this->assertContains('public_table_created_at_index', $indexNames); + $this->assertContains('public_table_body_fulltext', $indexNames); + $this->assertContains('table_raw_index', $indexNames); + } +} diff --git a/tests/Integration/Database/Laravel/Postgres/PostgresTestCase.php b/tests/Integration/Database/Laravel/Postgres/PostgresTestCase.php new file mode 100644 index 000000000..61ea188ac --- /dev/null +++ b/tests/Integration/Database/Laravel/Postgres/PostgresTestCase.php @@ -0,0 +1,17 @@ +increments('id'); + $table->string('title'); + $table->text('content'); + $table->timestamp('created_at'); + }); + + DB::table('posts')->insert([ + ['title' => 'Foo Post', 'content' => 'Lorem Ipsum.', 'created_at' => new Carbon('2017-11-12 13:14:15')], + ['title' => 'Bar Post', 'content' => 'Lorem Ipsum.', 'created_at' => new Carbon('2018-01-02 03:04:05')], + ]); + } + + public function testIncrement() + { + Schema::create('accounting', function (Blueprint $table) { + $table->increments('id'); + $table->float('wallet_1'); + $table->float('wallet_2'); + $table->integer('user_id'); + $table->string('name', 20); + }); + + DB::table('accounting')->insert([ + [ + 'wallet_1' => 100, + 'wallet_2' => 200, + 'user_id' => 1, + 'name' => 'Taylor', + ], + [ + 'wallet_1' => 15, + 'wallet_2' => 300, + 'user_id' => 2, + 'name' => 'Otwell', + ], + ]); + $connection = DB::table('accounting')->getConnection(); + $connection->enableQueryLog(); + + DB::table('accounting')->where('user_id', 2)->incrementEach([ + 'wallet_1' => 10, + 'wallet_2' => -20, + ], ['name' => 'foo']); + + $queryLogs = $connection->getQueryLog(); + $this->assertCount(1, $queryLogs); + + $rows = DB::table('accounting')->get(); + + $this->assertCount(2, $rows); + // other rows are not affected. + $this->assertEquals([ + 'id' => 1, + 'wallet_1' => 100, + 'wallet_2' => 200, + 'user_id' => 1, + 'name' => 'Taylor', + ], (array) $rows[0]); + + $this->assertEquals([ + 'id' => 2, + 'wallet_1' => 15 + 10, + 'wallet_2' => 300 - 20, + 'user_id' => 2, + 'name' => 'foo', + ], (array) $rows[1]); + + // without the second argument. + $affectedRowsCount = DB::table('accounting')->where('user_id', 2)->incrementEach([ + 'wallet_1' => 20, + 'wallet_2' => 20, + ]); + + $this->assertEquals(1, $affectedRowsCount); + + $rows = DB::table('accounting')->get(); + + $this->assertEquals([ + 'id' => 2, + 'wallet_1' => 15 + (10 + 20), + 'wallet_2' => 300 + (-20 + 20), + 'user_id' => 2, + 'name' => 'foo', + ], (array) $rows[1]); + + // Test Can affect multiple rows at once. + $affectedRowsCount = DB::table('accounting')->incrementEach([ + 'wallet_1' => 31.5, + 'wallet_2' => '-32.5', + ]); + + $this->assertEquals(2, $affectedRowsCount); + + $rows = DB::table('accounting')->get(); + $this->assertEquals([ + 'id' => 1, + 'wallet_1' => 100 + 31.5, + 'wallet_2' => 200 - 32.5, + 'user_id' => 1, + 'name' => 'Taylor', + ], (array) $rows[0]); + + $this->assertEquals([ + 'id' => 2, + 'wallet_1' => 15 + (10 + 20 + 31.5), + 'wallet_2' => 300 + (-20 + 20 - 32.5), + 'user_id' => 2, + 'name' => 'foo', + ], (array) $rows[1]); + + // In case of a conflict, the second argument wins and sets a fixed value: + $affectedRowsCount = DB::table('accounting')->incrementEach([ + 'wallet_1' => 3000, + ], ['wallet_1' => 1.5]); + + $this->assertEquals(2, $affectedRowsCount); + + $rows = DB::table('accounting')->get(); + + $this->assertEquals(1.5, $rows[0]->wallet_1); + $this->assertEquals(1.5, $rows[1]->wallet_1); + + Schema::drop('accounting'); + } + + public function testSole() + { + $expected = ['id' => '1', 'title' => 'Foo Post']; + + $this->assertEquals($expected, (array) DB::table('posts')->where('title', 'Foo Post')->select('id', 'title')->sole()); + } + + public function testSoleWithParameters() + { + $expected = ['id' => '1']; + + $this->assertEquals($expected, (array) DB::table('posts')->where('title', 'Foo Post')->sole('id')); + $this->assertEquals($expected, (array) DB::table('posts')->where('title', 'Foo Post')->sole(['id'])); + + $expected = ['id' => '1', 'title' => 'Foo Post']; + $this->assertEquals($expected, (array) DB::table('posts')->where('title', 'Foo Post')->sole(['id', 'title'])); + } + + public function testSoleFailsForMultipleRecords() + { + DB::table('posts')->insert([ + ['title' => 'Foo Post', 'content' => 'Lorem Ipsum.', 'created_at' => new Carbon('2017-11-12 13:14:15')], + ]); + + $this->expectExceptionObject(new MultipleRecordsFoundException(2)); + + DB::table('posts')->where('title', 'Foo Post')->sole(); + } + + public function testSoleFailsIfNoRecords() + { + $this->expectException(RecordsNotFoundException::class); + + DB::table('posts')->where('title', 'Baz Post')->sole(); + } + + public function testSelect() + { + $expected = ['id' => '1', 'title' => 'Foo Post']; + + $this->assertEquals($expected, (array) DB::table('posts')->select('id', 'title')->first()); + $this->assertEquals($expected, (array) DB::table('posts')->select(['id', 'title'])->first()); + + $this->assertCount(4, (array) DB::table('posts')->select()->first()); + } + + public function testSelectReplacesExistingSelects() + { + $this->assertEquals( + ['id' => '1', 'title' => 'Foo Post'], + (array) DB::table('posts')->select('content')->select(['id', 'title'])->first() + ); + } + + public function testSelectWithSubQuery() + { + $this->assertEquals( + ['id' => '1', 'title' => 'Foo Post', 'foo' => 'Lorem Ipsum.'], + (array) DB::table('posts')->select(['id', 'title', 'foo' => function ($query) { + $query->select('content'); + }])->first() + ); + } + + public function testAddSelect() + { + $expected = ['id' => '1', 'title' => 'Foo Post', 'content' => 'Lorem Ipsum.']; + + $this->assertEquals($expected, (array) DB::table('posts')->select('id')->addSelect('title', 'content')->first()); + $this->assertEquals($expected, (array) DB::table('posts')->select('id')->addSelect(['title', 'content'])->first()); + $this->assertEquals($expected, (array) DB::table('posts')->addSelect(['id', 'title', 'content'])->first()); + + $this->assertCount(4, (array) DB::table('posts')->addSelect([])->first()); + $this->assertEquals(['id' => '1'], (array) DB::table('posts')->select('id')->addSelect([])->first()); + } + + public function testAddSelectWithSubQuery() + { + $this->assertEquals( + ['id' => '1', 'title' => 'Foo Post', 'foo' => 'Lorem Ipsum.'], + (array) DB::table('posts')->addSelect(['id', 'title', 'foo' => function ($query) { + $query->select('content'); + }])->first() + ); + } + + public function testFromWithAlias() + { + $this->assertCount(2, DB::table('posts', 'alias')->select('alias.*')->get()); + } + + public function testFromWithSubQuery() + { + $this->assertSame( + 'Fake Post', + DB::table(function ($query) { + $query->selectRaw("'Fake Post' as title"); + }, 'posts')->first()->title + ); + } + + public function testWhereValueSubQuery() + { + $subQuery = function ($query) { + $query->selectRaw("'Sub query value'"); + }; + + $this->assertTrue(DB::table('posts')->where($subQuery, 'Sub query value')->exists()); + $this->assertFalse(DB::table('posts')->where($subQuery, 'Does not match')->exists()); + $this->assertTrue(DB::table('posts')->where($subQuery, '!=', 'Does not match')->exists()); + } + + public function testWhereValueSubQueryBuilder() + { + $subQuery = DB::table('posts')->selectRaw("'Sub query value'")->limit(1); + + $this->assertTrue(DB::table('posts')->where($subQuery, 'Sub query value')->exists()); + $this->assertFalse(DB::table('posts')->where($subQuery, 'Does not match')->exists()); + $this->assertTrue(DB::table('posts')->where($subQuery, '!=', 'Does not match')->exists()); + + $this->assertTrue(DB::table('posts')->where(DB::raw('\'Sub query value\''), $subQuery)->exists()); + $this->assertFalse(DB::table('posts')->where(DB::raw('\'Does not match\''), $subQuery)->exists()); + $this->assertTrue(DB::table('posts')->where(DB::raw('\'Does not match\''), '!=', $subQuery)->exists()); + } + + public function testWhereNot() + { + $results = DB::table('posts')->whereNot(function ($query) { + $query->where('title', 'Foo Post'); + })->get(); + + $this->assertCount(1, $results); + $this->assertSame('Bar Post', $results[0]->title); + } + + public function testWhereNotInputStringParameter() + { + $results = DB::table('posts')->whereNot('title', 'Foo Post')->get(); + + $this->assertCount(1, $results); + $this->assertSame('Bar Post', $results[0]->title); + + DB::table('posts')->insert([ + ['title' => 'Baz Post', 'content' => 'Lorem Ipsum.', 'created_at' => new Carbon('2017-11-12 13:14:15')], + ]); + + $results = DB::table('posts')->whereNot('title', 'Foo Post')->whereNot('title', 'Bar Post')->get(); + $this->assertSame('Baz Post', $results[0]->title); + } + + public function testOrWhereNot() + { + $results = DB::table('posts')->where('id', 1)->orWhereNot(function ($query) { + $query->where('title', 'Foo Post'); + })->get(); + + $this->assertCount(2, $results); + } + + public function testWhereDate() + { + $this->assertSame(1, DB::table('posts')->whereDate('created_at', '2018-01-02')->count()); + $this->assertSame(1, DB::table('posts')->whereDate('created_at', new Carbon('2018-01-02'))->count()); + } + + #[DefineEnvironment('defineEnvironmentWouldThrowsPDOException')] + public function testWhereDateWithInvalidOperator() + { + $sql = DB::table('posts')->whereDate('created_at', '? OR 1=1', '2018-01-02'); + + PHPUnit::assertArraySubset([ + [ + 'column' => 'created_at', + 'type' => 'Date', + 'value' => '? OR 1=1', + 'boolean' => 'and', + ], + ], $sql->wheres); + + $this->assertSame(0, $sql->count()); + } + + public function testOrWhereDate() + { + $this->assertSame(2, DB::table('posts')->where('id', 1)->orWhereDate('created_at', '2018-01-02')->count()); + $this->assertSame(2, DB::table('posts')->where('id', 1)->orWhereDate('created_at', new Carbon('2018-01-02'))->count()); + } + + #[DefineEnvironment('defineEnvironmentWouldThrowsPDOException')] + public function testOrWhereDateWithInvalidOperator() + { + $sql = DB::table('posts')->where('id', 1)->orWhereDate('created_at', '? OR 1=1', '2018-01-02'); + + PHPUnit::assertArraySubset([ + [ + 'column' => 'id', + 'type' => 'Basic', + 'value' => 1, + 'boolean' => 'and', + ], + [ + 'column' => 'created_at', + 'type' => 'Date', + 'value' => '? OR 1=1', + 'boolean' => 'or', + ], + ], $sql->wheres); + + $this->assertSame(1, $sql->count()); + } + + public function testWhereDay() + { + $this->assertSame(1, DB::table('posts')->whereDay('created_at', '02')->count()); + $this->assertSame(1, DB::table('posts')->whereDay('created_at', 2)->count()); + $this->assertSame(1, DB::table('posts')->whereDay('created_at', new Carbon('2018-01-02'))->count()); + } + + public function testWhereDayWithInvalidOperator() + { + $sql = DB::table('posts')->whereDay('created_at', '? OR 1=1', '02'); + + PHPUnit::assertArraySubset([ + [ + 'column' => 'created_at', + 'type' => 'Day', + 'value' => '00', + 'boolean' => 'and', + ], + ], $sql->wheres); + + $this->assertSame(0, $sql->count()); + } + + public function testOrWhereDay() + { + $this->assertSame(2, DB::table('posts')->where('id', 1)->orWhereDay('created_at', '02')->count()); + $this->assertSame(2, DB::table('posts')->where('id', 1)->orWhereDay('created_at', 2)->count()); + $this->assertSame(2, DB::table('posts')->where('id', 1)->orWhereDay('created_at', new Carbon('2018-01-02'))->count()); + } + + public function testOrWhereDayWithInvalidOperator() + { + $sql = DB::table('posts')->where('id', 1)->orWhereDay('created_at', '? OR 1=1', '02'); + + PHPUnit::assertArraySubset([ + [ + 'column' => 'id', + 'type' => 'Basic', + 'value' => 1, + 'boolean' => 'and', + ], + [ + 'column' => 'created_at', + 'type' => 'Day', + 'value' => '00', + 'boolean' => 'or', + ], + ], $sql->wheres); + + $this->assertSame(1, $sql->count()); + } + + public function testWhereMonth() + { + $this->assertSame(1, DB::table('posts')->whereMonth('created_at', '01')->count()); + $this->assertSame(1, DB::table('posts')->whereMonth('created_at', 1)->count()); + $this->assertSame(1, DB::table('posts')->whereMonth('created_at', new Carbon('2018-01-02'))->count()); + } + + public function testWhereMonthWithInvalidOperator() + { + $sql = DB::table('posts')->whereMonth('created_at', '? OR 1=1', '01'); + + PHPUnit::assertArraySubset([ + [ + 'column' => 'created_at', + 'type' => 'Month', + 'value' => '00', + 'boolean' => 'and', + ], + ], $sql->wheres); + + $this->assertSame(0, $sql->count()); + } + + public function testOrWhereMonth() + { + $this->assertSame(2, DB::table('posts')->where('id', 1)->orWhereMonth('created_at', '01')->count()); + $this->assertSame(2, DB::table('posts')->where('id', 1)->orWhereMonth('created_at', 1)->count()); + $this->assertSame(2, DB::table('posts')->where('id', 1)->orWhereMonth('created_at', new Carbon('2018-01-02'))->count()); + } + + public function testOrWhereMonthWithInvalidOperator() + { + $sql = DB::table('posts')->where('id', 1)->orWhereMonth('created_at', '? OR 1=1', '01'); + + PHPUnit::assertArraySubset([ + [ + 'column' => 'id', + 'type' => 'Basic', + 'value' => 1, + 'boolean' => 'and', + ], + [ + 'column' => 'created_at', + 'type' => 'Month', + 'value' => '00', + 'boolean' => 'or', + ], + ], $sql->wheres); + + $this->assertSame(1, $sql->count()); + } + + public function testWhereYear() + { + $this->assertSame(1, DB::table('posts')->whereYear('created_at', '2018')->count()); + $this->assertSame(1, DB::table('posts')->whereYear('created_at', 2018)->count()); + $this->assertSame(1, DB::table('posts')->whereYear('created_at', new Carbon('2018-01-02'))->count()); + } + + #[DefineEnvironment('defineEnvironmentWouldThrowsPDOException')] + public function testWhereYearWithInvalidOperator() + { + $sql = DB::table('posts')->whereYear('created_at', '? OR 1=1', '2018'); + + PHPUnit::assertArraySubset([ + [ + 'column' => 'created_at', + 'type' => 'Year', + 'value' => '? OR 1=1', + 'boolean' => 'and', + ], + ], $sql->wheres); + + $this->assertSame(0, $sql->count()); + } + + public function testOrWhereYear() + { + $this->assertSame(2, DB::table('posts')->where('id', 1)->orWhereYear('created_at', '2018')->count()); + $this->assertSame(2, DB::table('posts')->where('id', 1)->orWhereYear('created_at', 2018)->count()); + $this->assertSame(2, DB::table('posts')->where('id', 1)->orWhereYear('created_at', new Carbon('2018-01-02'))->count()); + } + + #[DefineEnvironment('defineEnvironmentWouldThrowsPDOException')] + public function testOrWhereYearWithInvalidOperator() + { + $sql = DB::table('posts')->where('id', 1)->orWhereYear('created_at', '? OR 1=1', '2018'); + + PHPUnit::assertArraySubset([ + [ + 'column' => 'id', + 'type' => 'Basic', + 'value' => 1, + 'boolean' => 'and', + ], + [ + 'column' => 'created_at', + 'type' => 'Year', + 'value' => '? OR 1=1', + 'boolean' => 'or', + ], + ], $sql->wheres); + + $this->assertSame(1, $sql->count()); + } + + public function testWhereTime() + { + $this->assertSame(1, DB::table('posts')->whereTime('created_at', '03:04:05')->count()); + $this->assertSame(1, DB::table('posts')->whereTime('created_at', new Carbon('2018-01-02 03:04:05'))->count()); + } + + #[DefineEnvironment('defineEnvironmentWouldThrowsPDOException')] + public function testWhereTimeWithInvalidOperator() + { + $sql = DB::table('posts')->whereTime('created_at', '? OR 1=1', '03:04:05'); + + PHPUnit::assertArraySubset([ + [ + 'column' => 'created_at', + 'type' => 'Time', + 'value' => '? OR 1=1', + 'boolean' => 'and', + ], + ], $sql->wheres); + + $this->assertSame(0, $sql->count()); + } + + public function testOrWhereTime() + { + $this->assertSame(2, DB::table('posts')->where('id', 1)->orWhereTime('created_at', '03:04:05')->count()); + $this->assertSame(2, DB::table('posts')->where('id', 1)->orWhereTime('created_at', new Carbon('2018-01-02 03:04:05'))->count()); + } + + #[DefineEnvironment('defineEnvironmentWouldThrowsPDOException')] + public function testOrWhereTimeWithInvalidOperator() + { + $sql = DB::table('posts')->where('id', 1)->orWhereTime('created_at', '? OR 1=1', '03:04:05'); + + PHPUnit::assertArraySubset([ + [ + 'column' => 'id', + 'type' => 'Basic', + 'value' => 1, + 'boolean' => 'and', + ], + [ + 'column' => 'created_at', + 'type' => 'Time', + 'value' => '? OR 1=1', + 'boolean' => 'or', + ], + ], $sql->wheres); + + $this->assertSame(1, $sql->count()); + } + + public function testWhereNested() + { + $results = DB::table('posts')->where('content', 'Lorem Ipsum.')->whereNested(function ($query) { + $query->where('title', 'Foo Post') + ->orWhere('title', 'Bar Post'); + })->count(); + $this->assertSame(2, $results); + } + + public function testPaginateWithSpecificColumns() + { + $result = DB::table('posts')->paginate(5, ['title', 'content']); + + $this->assertInstanceOf(LengthAwarePaginator::class, $result); + $this->assertEquals($result->items(), [ + (object) ['title' => 'Foo Post', 'content' => 'Lorem Ipsum.'], + (object) ['title' => 'Bar Post', 'content' => 'Lorem Ipsum.'], + ]); + } + + public function testChunkMap() + { + DB::enableQueryLog(); + + $results = DB::table('posts')->orderBy('id')->chunkMap(function ($post) { + return $post->title; + }, 1); + + $this->assertCount(2, $results); + $this->assertSame('Foo Post', $results[0]); + $this->assertSame('Bar Post', $results[1]); + $this->assertCount(3, DB::getQueryLog()); + } + + public function testPluck() + { + // Test SELECT override, since pluck will take the first column. + $this->assertSame([ + 'Foo Post', + 'Bar Post', + ], DB::table('posts')->select(['content', 'id', 'title'])->pluck('title')->toArray()); + + // Test without SELECT override. + $this->assertSame([ + 'Foo Post', + 'Bar Post', + ], DB::table('posts')->pluck('title')->toArray()); + + // Test specific key. + $this->assertSame([ + 1 => 'Foo Post', + 2 => 'Bar Post', + ], DB::table('posts')->pluck('title', 'id')->toArray()); + + $results = DB::table('posts')->pluck('title', 'created_at'); + + // Test timestamps (truncates RDBMS differences). + $this->assertSame([ + '2017-11-12 13:14:15', + '2018-01-02 03:04:05', + ], $results->keys()->map(fn ($v) => substr($v, 0, 19))->toArray()); + $this->assertSame([ + 'Foo Post', + 'Bar Post', + ], $results->values()->toArray()); + + // Test duplicate keys (a match will override a previous match). + $this->assertSame([ + 'Lorem Ipsum.' => 'Bar Post', + ], DB::table('posts')->pluck('title', 'content')->toArray()); + + // Test custom select query before calling pluck. + $result = DB::table('posts') + ->selectSub(DB::table('posts')->selectRaw('COUNT(*)'), 'total_posts_count') + ->pluck('total_posts_count') + ->toArray(); + // Cast for database compatibility. + $this->assertSame(2, (int) $result[0]); + $this->assertSame(2, (int) $result[1]); + } + + public function testFetchUsing() + { + // Fetch column as a list. + $this->assertSame([ + 'Foo Post', + 'Bar Post', + ], DB::table('posts')->select(['title'])->fetchUsing(PDO::FETCH_COLUMN)->get()->toArray()); + + // Fetch the second column as a list (zero-indexed). + $this->assertSame([ + 'Lorem Ipsum.', + 'Lorem Ipsum.', + ], DB::table('posts')->select(['title', 'content'])->fetchUsing(PDO::FETCH_COLUMN, 1)->get()->toArray()); + + // Fetch two columns as key value pairs. + $this->assertSame([ + 1 => 'Foo Post', + 2 => 'Bar Post', + ], DB::table('posts')->select(['id', 'title'])->fetchUsing(PDO::FETCH_KEY_PAIR)->get()->toArray()); + + // Fetch data as associative array with custom key. + $result = DB::table('posts')->select(['id', 'title'])->fetchUsing(PDO::FETCH_UNIQUE)->get()->toArray(); + // Note: results are keyed by their post id here. + $this->assertSame('Foo Post', $result[1]->title); + $this->assertSame('Bar Post', $result[2]->title); + + // Use a cursor. + $this->assertSame([ + 'Foo Post', + 'Bar Post', + ], DB::table('posts')->select(['title'])->fetchUsing(PDO::FETCH_COLUMN)->cursor()->collect()->toArray()); + + // Test the default 'object' fetch mode. + $result = DB::table('posts')->select(['title'])->fetchUsing(PDO::FETCH_OBJ)->get()->toArray(); + $result2 = DB::table('posts')->select(['title'])->fetchUsing()->get()->toArray(); + $this->assertSame('Foo Post', $result[0]->title); + $this->assertSame('Bar Post', $result[1]->title); + $this->assertSame('Foo Post', $result2[0]->title); + $this->assertSame('Bar Post', $result2[1]->title); + } + + protected function defineEnvironmentWouldThrowsPDOException($app): void + { + $this->afterApplicationCreated(function () { + if (in_array($this->driver, ['pgsql', 'sqlsrv'])) { + $this->expectException(PDOException::class); + } + }); + } +} diff --git a/tests/Integration/Database/Laravel/QueryBuilderUpdateTest.php b/tests/Integration/Database/Laravel/QueryBuilderUpdateTest.php new file mode 100644 index 000000000..ba480ffee --- /dev/null +++ b/tests/Integration/Database/Laravel/QueryBuilderUpdateTest.php @@ -0,0 +1,107 @@ +increments('id'); + $table->string('name')->nullable(); + $table->string('title')->nullable(); + $table->string('status')->nullable(); + $table->integer('credits')->nullable(); + $table->json('payload')->nullable(); + }); + + Schema::create('example_credits', function (Blueprint $table) { + $table->increments('id'); + $table->unsignedBigInteger('example_id'); + $table->integer('credits'); + }); + } + + #[DataProvider('jsonValuesDataProvider')] + #[RequiresDatabase(['sqlite', 'mysql', 'mariadb'])] + public function testBasicUpdateForJson($column, $given, $expected) + { + DB::table('example')->insert([ + ['name' => 'Taylor Otwell', 'title' => 'Mr.'], + ]); + + DB::table('example')->update([ + $column => $given, + ]); + + $this->assertDatabaseHas('example', [ + 'name' => 'Taylor Otwell', + 'title' => 'Mr.', + $column => $column === 'payload' ? $this->castAsJson($expected) : $expected, + ]); + } + + public static function jsonValuesDataProvider() + { + yield ['payload', ['Laravel', 'Founder'], ['Laravel', 'Founder']]; + yield ['payload', collect(['Laravel', 'Founder']), ['Laravel', 'Founder']]; + yield ['status', StringStatus::draft, 'draft']; + } + + #[RequiresDatabase(['sqlite', 'mysql', 'mariadb'])] + public function testSubqueryUpdate() + { + DB::table('example')->insert([ + ['name' => 'Taylor Otwell', 'title' => 'Mr.'], + ['name' => 'Tim MacDonald', 'title' => 'Mr.'], + ]); + + DB::table('example_credits')->insert([ + ['example_id' => 1, 'credits' => 10], + ['example_id' => 1, 'credits' => 20], + ]); + + $this->assertDatabaseHas('example', [ + 'name' => 'Taylor Otwell', + 'title' => 'Mr.', + 'credits' => null, + ]); + + $this->assertDatabaseHas('example', [ + 'name' => 'Tim MacDonald', + 'title' => 'Mr.', + 'credits' => null, + ]); + + DB::table('example')->update([ + 'credits' => DB::table('example_credits')->selectRaw('sum(credits)')->whereColumn('example_credits.example_id', 'example.id'), + ]); + + $this->assertDatabaseHas('example', [ + 'name' => 'Taylor Otwell', + 'title' => 'Mr.', + 'credits' => 30, + ]); + + $this->assertDatabaseHas('example', [ + 'name' => 'Tim MacDonald', + 'title' => 'Mr.', + 'credits' => null, + ]); + } +} diff --git a/tests/Integration/Database/Laravel/QueryBuilderWhereLikeTest.php b/tests/Integration/Database/Laravel/QueryBuilderWhereLikeTest.php new file mode 100644 index 000000000..51da1ceba --- /dev/null +++ b/tests/Integration/Database/Laravel/QueryBuilderWhereLikeTest.php @@ -0,0 +1,115 @@ +id('id'); + $table->string('name', 200); + $table->text('email'); + }); + } + + protected function destroyDatabaseMigrations(): void + { + Schema::drop('users'); + } + + protected function setUpInCoroutine(): void + { + DB::table('users')->insert([ + ['name' => 'John Doe', 'email' => 'John.Doe@example.com'], + ['name' => 'Jane Doe', 'email' => 'janedoe@example.com'], + ['name' => 'Dale doe', 'email' => 'Dale.Doe@example.com'], + ['name' => 'Earl Smith', 'email' => 'Earl.Smith@example.com'], + ['name' => 'tim smith', 'email' => 'tim.smith@example.com'], + ]); + } + + public function testWhereLike() + { + $users = DB::table('users')->whereLike('email', 'john.doe@example.com')->get(); + $this->assertCount(1, $users); + $this->assertSame('John.Doe@example.com', $users[0]->email); + + $this->assertSame(4, DB::table('users')->whereNotLike('email', 'john.doe@example.com')->count()); + } + + public function testWhereLikeWithPercentWildcard() + { + $this->assertSame(5, DB::table('users')->whereLike('email', '%@example.com')->count()); + $this->assertSame(2, DB::table('users')->whereNotLike('email', '%Doe%')->count()); + + $users = DB::table('users')->whereLike('email', 'john%')->get(); + $this->assertCount(1, $users); + $this->assertSame('John.Doe@example.com', $users[0]->email); + } + + public function testWhereLikeWithUnderscoreWildcard() + { + $users = DB::table('users')->whereLike('email', '_a_e_%@example.com')->get(); + $this->assertCount(2, $users); + $this->assertSame('janedoe@example.com', $users[0]->email); + $this->assertSame('Dale.Doe@example.com', $users[1]->email); + } + + public function testWhereLikeCaseSensitive() + { + if ($this->driver === 'sqlsrv') { + $this->markTestSkipped('The case-sensitive whereLike clause is not supported on MSSQL.'); + } + + $users = DB::table('users')->whereLike('email', 'john.doe@example.com', true)->get(); + $this->assertCount(0, $users); + + $users = DB::table('users')->whereLike('email', 'tim.smith@example.com', true)->get(); + $this->assertCount(1, $users); + $this->assertSame('tim.smith@example.com', $users[0]->email); + $this->assertSame(5, DB::table('users')->whereNotLike('email', 'john.doe@example.com', true)->count()); + } + + public function testWhereLikeWithPercentWildcardCaseSensitive() + { + if ($this->driver === 'sqlsrv') { + $this->markTestSkipped('The case-sensitive whereLike clause is not supported on MSSQL.'); + } + + $this->assertSame(2, DB::table('users')->whereLike('email', '%Doe@example.com', true)->count()); + $this->assertSame(4, DB::table('users')->whereNotLike('email', '%smith%', true)->count()); + + $users = DB::table('users')->whereLike('email', '%Doe@example.com', true)->get(); + $this->assertCount(2, $users); + $this->assertSame('John.Doe@example.com', $users[0]->email); + $this->assertSame('Dale.Doe@example.com', $users[1]->email); + } + + public function testWhereLikeWithUnderscoreWildcardCaseSensitive() + { + if ($this->driver === 'sqlsrv') { + $this->markTestSkipped('The case-sensitive whereLike clause is not supported on MSSQL.'); + } + + $users = DB::table('users')->whereLike('email', 'j__edoe@example.com', true)->get(); + $this->assertCount(1, $users); + $this->assertSame('janedoe@example.com', $users[0]->email); + + $users = DB::table('users')->whereNotLike('email', '%_oe@example.com', true)->get(); + $this->assertCount(2, $users); + $this->assertSame('Earl.Smith@example.com', $users[0]->email); + $this->assertSame('tim.smith@example.com', $users[1]->email); + } +} diff --git a/tests/Integration/Database/Laravel/QueryingWithEnumsTest.php b/tests/Integration/Database/Laravel/QueryingWithEnumsTest.php new file mode 100644 index 000000000..ac7148637 --- /dev/null +++ b/tests/Integration/Database/Laravel/QueryingWithEnumsTest.php @@ -0,0 +1,67 @@ +increments('id'); + $table->string('string_status', 100)->nullable(); + $table->integer('integer_status')->nullable(); + $table->string('non_backed_status', 100)->nullable(); + }); + } + + public function testCanQueryWithEnums() + { + DB::table('enum_casts')->insert([ + 'string_status' => 'pending', + 'integer_status' => 1, + 'non_backed_status' => 'pending', + ]); + + $record = DB::table('enum_casts')->where('string_status', StringStatus::pending)->first(); + $record2 = DB::table('enum_casts')->where('integer_status', IntegerStatus::pending)->first(); + $record3 = DB::table('enum_casts')->whereIn('integer_status', [IntegerStatus::pending])->first(); + $record4 = DB::table('enum_casts')->where('non_backed_status', NonBackedStatus::pending)->first(); + + $this->assertNotNull($record); + $this->assertNotNull($record2); + $this->assertNotNull($record3); + $this->assertNotNull($record4); + $this->assertSame('pending', $record->string_status); + $this->assertEquals(1, $record2->integer_status); + $this->assertSame('pending', $record4->non_backed_status); + } + + public function testCanInsertWithEnums() + { + DB::table('enum_casts')->insert([ + 'string_status' => StringStatus::pending, + 'integer_status' => IntegerStatus::pending, + 'non_backed_status' => NonBackedStatus::pending, + ]); + + $record = DB::table('enum_casts')->where('string_status', StringStatus::pending)->first(); + + $this->assertNotNull($record); + $this->assertSame('pending', $record->string_status); + $this->assertEquals(1, $record->integer_status); + $this->assertSame('pending', $record->non_backed_status); + } +} diff --git a/tests/Integration/Database/Laravel/Queue/BatchableTransactionTest.php b/tests/Integration/Database/Laravel/Queue/BatchableTransactionTest.php new file mode 100644 index 000000000..ae4622198 --- /dev/null +++ b/tests/Integration/Database/Laravel/Queue/BatchableTransactionTest.php @@ -0,0 +1,69 @@ +usesSqliteInMemoryDatabaseConnection()) { + $this->markTestSkipped('Test does not support using :memory: database connection'); + } + } + + public function testItCanHandleTimeoutJob() + { + Bus::batch([new Fixtures\TimeOutJobWithTransaction()]) + ->allowFailures() + ->dispatch(); + + $this->assertSame(1, DB::table('jobs')->count()); + $this->assertSame(0, DB::table('failed_jobs')->count()); + $this->assertSame(1, DB::table('job_batches')->count()); + + try { + remote('queue:work --stop-when-empty', [ + 'DB_CONNECTION' => config('database.default'), + 'QUEUE_CONNECTION' => config('queue.default'), + ])->run(); + } catch (Throwable $e) { + $this->assertInstanceOf(ProcessSignaledException::class, $e); + $this->assertSame('The process has been signaled with signal "9".', $e->getMessage()); + } + + $this->assertSame(0, DB::table('jobs')->count()); + $this->assertSame(1, DB::table('failed_jobs')->count()); + + $this->assertDatabaseHas('job_batches', [ + 'total_jobs' => 1, + 'pending_jobs' => 1, + 'failed_jobs' => 1, + 'failed_job_ids' => json_encode(DB::table('failed_jobs')->pluck('uuid')->all()), + ]); + } +} diff --git a/tests/Integration/Database/Laravel/Queue/Fixtures/TimeOutJobWithNestedTransactions.php b/tests/Integration/Database/Laravel/Queue/Fixtures/TimeOutJobWithNestedTransactions.php new file mode 100644 index 000000000..639b58a30 --- /dev/null +++ b/tests/Integration/Database/Laravel/Queue/Fixtures/TimeOutJobWithNestedTransactions.php @@ -0,0 +1,29 @@ + sleep(20)); + }); + } +} diff --git a/tests/Integration/Database/Laravel/Queue/Fixtures/TimeOutJobWithTransaction.php b/tests/Integration/Database/Laravel/Queue/Fixtures/TimeOutJobWithTransaction.php new file mode 100644 index 000000000..d33e136da --- /dev/null +++ b/tests/Integration/Database/Laravel/Queue/Fixtures/TimeOutJobWithTransaction.php @@ -0,0 +1,27 @@ + sleep(20)); + } +} diff --git a/tests/Integration/Database/Laravel/Queue/Fixtures/TimeOutNonBatchableJobWithNestedTransactions.php b/tests/Integration/Database/Laravel/Queue/Fixtures/TimeOutNonBatchableJobWithNestedTransactions.php new file mode 100644 index 000000000..0d978f830 --- /dev/null +++ b/tests/Integration/Database/Laravel/Queue/Fixtures/TimeOutNonBatchableJobWithNestedTransactions.php @@ -0,0 +1,27 @@ + sleep(20)); + }); + } +} diff --git a/tests/Integration/Database/Laravel/Queue/Fixtures/TimeOutNonBatchableJobWithTransaction.php b/tests/Integration/Database/Laravel/Queue/Fixtures/TimeOutNonBatchableJobWithTransaction.php new file mode 100644 index 000000000..750d3577a --- /dev/null +++ b/tests/Integration/Database/Laravel/Queue/Fixtures/TimeOutNonBatchableJobWithTransaction.php @@ -0,0 +1,25 @@ + sleep(20)); + } +} diff --git a/tests/Integration/Database/Laravel/Queue/QueueTransactionTest.php b/tests/Integration/Database/Laravel/Queue/QueueTransactionTest.php new file mode 100644 index 000000000..31420f34c --- /dev/null +++ b/tests/Integration/Database/Laravel/Queue/QueueTransactionTest.php @@ -0,0 +1,70 @@ +usesSqliteInMemoryDatabaseConnection()) { + $this->markTestSkipped('Test does not support using :memory: database connection'); + } + } + + #[DataProvider('timeoutJobs')] + public function testItCanHandleTimeoutJob($job) + { + dispatch($job); + + $this->assertSame(1, DB::table('jobs')->count()); + $this->assertSame(0, DB::table('failed_jobs')->count()); + + try { + remote('queue:work --stop-when-empty', [ + 'DB_CONNECTION' => config('database.default'), + 'QUEUE_CONNECTION' => config('queue.default'), + ])->run(); + } catch (Throwable $e) { + $this->assertInstanceOf(ProcessSignaledException::class, $e); + $this->assertSame('The process has been signaled with signal "9".', $e->getMessage()); + } + + $this->assertSame(0, DB::table('jobs')->count()); + $this->assertSame(1, DB::table('failed_jobs')->count()); + } + + public static function timeoutJobs(): array + { + return [ + [new Fixtures\TimeOutJobWithTransaction()], + [new Fixtures\TimeOutJobWithNestedTransactions()], + [new Fixtures\TimeOutNonBatchableJobWithTransaction()], + [new Fixtures\TimeOutNonBatchableJobWithNestedTransactions()], + ]; + } +} diff --git a/tests/Integration/Database/Laravel/RefreshCommandTest.php b/tests/Integration/Database/Laravel/RefreshCommandTest.php new file mode 100644 index 000000000..e80e89097 --- /dev/null +++ b/tests/Integration/Database/Laravel/RefreshCommandTest.php @@ -0,0 +1,54 @@ +app->setBasePath(__DIR__); + + $options = [ + '--path' => 'stubs/', + ]; + + $this->migrateRefreshWith($options); + } + + public function testRefreshWithRealpath() + { + $options = [ + '--path' => realpath(__DIR__ . '/stubs/'), + '--realpath' => true, + ]; + + $this->migrateRefreshWith($options); + } + + private function migrateRefreshWith(array $options) + { + if ($this->app['config']->get('database.default') !== 'testing') { + $this->artisan('db:wipe', ['--drop-views' => true]); + } + + $this->beforeApplicationDestroyed(function () use ($options) { + $this->artisan('migrate:rollback', $options); + }); + + $this->artisan('migrate:refresh', $options); + DB::table('members')->insert(['name' => 'foo', 'email' => 'foo@bar', 'password' => 'secret']); + $this->assertEquals(1, DB::table('members')->count()); + + $this->artisan('migrate:refresh', $options); + $this->assertEquals(0, DB::table('members')->count()); + } +} diff --git a/tests/Integration/Database/Laravel/SchemaBuilderSchemaNameTest.php b/tests/Integration/Database/Laravel/SchemaBuilderSchemaNameTest.php new file mode 100644 index 000000000..b6c24fe27 --- /dev/null +++ b/tests/Integration/Database/Laravel/SchemaBuilderSchemaNameTest.php @@ -0,0 +1,617 @@ +usesSqliteInMemoryDatabaseConnection()) { + $this->markTestSkipped('Test cannot be run using :memory: database connection, SQLite test file is here: \Illuminate\Tests\Integration\Database\Sqlite\SchemaBuilderSchemaNameTest'); + } + } + + protected function defineDatabaseMigrations(): void + { + if (in_array($this->driver, ['mariadb', 'mysql'])) { + Schema::createDatabase('my_schema'); + } elseif ($this->driver === 'sqlite') { + DB::connection('without-prefix')->statement("attach database ':memory:' as my_schema"); + DB::connection('with-prefix')->statement("attach database ':memory:' as my_schema"); + } elseif ($this->driver === 'pgsql') { + DB::statement('create schema if not exists my_schema'); + } elseif ($this->driver === 'sqlsrv') { + DB::statement("if schema_id('my_schema') is null begin exec('create schema my_schema') end"); + } + } + + protected function destroyDatabaseMigrations(): void + { + if (in_array($this->driver, ['mariadb', 'mysql'])) { + Schema::dropDatabaseIfExists('my_schema'); + } elseif ($this->driver === 'sqlite') { + DB::connection('without-prefix')->statement('detach database my_schema'); + DB::connection('with-prefix')->statement('detach database my_schema'); + } elseif ($this->driver === 'pgsql') { + DB::statement('drop schema if exists my_schema cascade'); + } elseif ($this->driver === 'sqlsrv') { + // DB::statement("if schema_id('my_schema') is not null begin exec('drop schema my_schema') end"); + } + } + + protected function defineEnvironment($app): void + { + parent::defineEnvironment($app); + + $connection = $app['config']->get('database.default'); + + $app['config']->set("database.connections.{$connection}.prefix_indexes", true); + $app['config']->set('database.connections.pgsql.search_path', 'public,my_schema'); + $app['config']->set('database.connections.without-prefix', $app['config']->get('database.connections.' . $connection)); + $app['config']->set('database.connections.with-prefix', $app['config']->get('database.connections.without-prefix')); + $app['config']->set('database.connections.with-prefix.prefix', 'example_'); + } + + #[DataProvider('connectionProvider')] + public function testSchemas($connection) + { + $schema = Schema::connection($connection); + + $schemas = $schema->getSchemas(); + $schemaNames = array_column($schemas, 'name'); + $currentSchema = $schema->getCurrentSchemaName(); + + $this->assertSame($currentSchema, collect($schemas)->firstWhere('default')['name']); + + // Check that expected schemas are present (other system schemas may also exist) + $expectedSchemas = match ($this->driver) { + 'mysql', 'mariadb' => [$currentSchema, 'my_schema'], + 'pgsql' => ['public', 'my_schema'], + 'sqlite' => ['main', 'my_schema'], + 'sqlsrv' => ['dbo', 'guest', 'my_schema'], + }; + foreach ($expectedSchemas as $expectedSchema) { + $this->assertContains($expectedSchema, $schemaNames); + } + } + + #[DataProvider('connectionProvider')] + public function testCreate($connection) + { + $schema = Schema::connection($connection); + + $schema->create('my_schema.table', function (Blueprint $table) { + $table->id(); + }); + + $this->assertTrue($schema->hasTable('my_schema.table')); + $this->assertFalse($schema->hasTable('table')); + + $currentSchema = $schema->getCurrentSchemaName(); + $tableName = $connection === 'with-prefix' ? 'example_table' : 'table'; + + // Use assertContains rather than exact equality - testbench may create additional tables + $tables = $schema->getTableListing([$currentSchema, 'my_schema']); + $this->assertContains($currentSchema . '.migrations', $tables); + $this->assertContains('my_schema.' . $tableName, $tables); + } + + #[DataProvider('connectionProvider')] + public function testRename($connection) + { + $schema = Schema::connection($connection); + + $schema->create('my_schema.table', function (Blueprint $table) { + $table->id(); + }); + $schema->create('table', function (Blueprint $table) { + $table->id(); + }); + + $this->assertTrue($schema->hasTable('my_schema.table')); + $this->assertFalse($schema->hasTable('my_schema.new_table')); + $this->assertTrue($schema->hasTable('table')); + $this->assertFalse($schema->hasTable('my_table')); + + if (in_array($this->driver, ['mariadb', 'mysql'])) { + $schema->rename('my_schema.table', 'my_schema.new_table'); + } else { + $schema->rename('my_schema.table', 'new_table'); + } + $schema->rename('table', 'my_table'); + + $this->assertTrue($schema->hasTable('my_schema.new_table')); + $this->assertFalse($schema->hasTable('my_schema.table')); + $this->assertTrue($schema->hasTable('my_table')); + $this->assertFalse($schema->hasTable('table')); + } + + #[DataProvider('connectionProvider')] + public function testDrop($connection) + { + $schema = Schema::connection($connection); + + $schema->create('my_schema.table', function (Blueprint $table) { + $table->id(); + }); + $schema->create('table', function (Blueprint $table) { + $table->id(); + }); + + $this->assertTrue($schema->hasTable('my_schema.table')); + $this->assertTrue($schema->hasTable('table')); + + $currentSchema = $schema->getCurrentSchemaName(); + $tableName = $connection === 'with-prefix' ? 'example_table' : 'table'; + + // Use assertContains rather than exact equality - testbench may create additional tables + $tables = $schema->getTableListing([$currentSchema, 'my_schema']); + $this->assertContains($currentSchema . '.migrations', $tables); + $this->assertContains($currentSchema . '.' . $tableName, $tables); + $this->assertContains('my_schema.' . $tableName, $tables); + + $schema->drop('my_schema.table'); + + $this->assertFalse($schema->hasTable('my_schema.table')); + $this->assertTrue($schema->hasTable('table')); + + $tables = $schema->getTableListing([$currentSchema, 'my_schema']); + $this->assertContains($currentSchema . '.migrations', $tables); + $this->assertContains($currentSchema . '.' . $tableName, $tables); + $this->assertNotContains('my_schema.' . $tableName, $tables); + } + + #[DataProvider('connectionProvider')] + public function testDropIfExists($connection) + { + $schema = Schema::connection($connection); + + $schema->create('my_schema.table', function (Blueprint $table) { + $table->id(); + }); + $schema->create('table', function (Blueprint $table) { + $table->id(); + }); + + $this->assertTrue($schema->hasTable('my_schema.table')); + $this->assertTrue($schema->hasTable('table')); + + $schema->dropIfExists('my_schema.table'); + $schema->dropIfExists('my_schema.fake_table'); + $schema->dropIfExists('fake_schema.table'); + + $this->assertFalse($schema->hasTable('my_schema.table')); + $this->assertTrue($schema->hasTable('table')); + } + + #[DataProvider('connectionProvider')] + public function testAddColumns($connection) + { + $schema = Schema::connection($connection); + + $schema->create('my_schema.table', function (Blueprint $table) { + $table->id(); + $table->string('title')->default('default schema title'); + }); + $schema->create('my_table', function (Blueprint $table) { + $table->id(); + $table->string('name')->default('default name'); + }); + + $this->assertEquals(['id', 'title'], $schema->getColumnListing('my_schema.table')); + $this->assertEquals(['id', 'name'], $schema->getColumnListing('my_table')); + + $schema->table('my_schema.table', function (Blueprint $table) { + $table->string('name')->default('default schema name'); + $table->integer('count'); + }); + $schema->table('my_table', function (Blueprint $table) { + $table->integer('count'); + $table->string('title')->default('default title'); + }); + + $this->assertEquals(['id', 'title', 'name', 'count'], $schema->getColumnListing('my_schema.table')); + $this->assertEquals(['id', 'name', 'count', 'title'], $schema->getColumnListing('my_table')); + $this->assertStringContainsString('default schema name', collect($schema->getColumns('my_schema.table'))->firstWhere('name', 'name')['default']); + $this->assertStringContainsString('default schema title', collect($schema->getColumns('my_schema.table'))->firstWhere('name', 'title')['default']); + $this->assertStringContainsString('default name', collect($schema->getColumns('my_table'))->firstWhere('name', 'name')['default']); + $this->assertStringContainsString('default title', collect($schema->getColumns('my_table'))->firstWhere('name', 'title')['default']); + } + + #[DataProvider('connectionProvider')] + public function testRenameColumns($connection) + { + $schema = Schema::connection($connection); + + $schema->create('my_schema.table', function (Blueprint $table) { + $table->id(); + $table->string('title')->default('default schema title'); + }); + $schema->create('table', function (Blueprint $table) { + $table->id(); + $table->string('name')->default('default name'); + }); + + $this->assertTrue($schema->hasColumn('my_schema.table', 'title')); + $this->assertTrue($schema->hasColumn('table', 'name')); + + $schema->table('my_schema.table', function (Blueprint $table) { + $table->renameColumn('title', 'new_title'); + }); + $schema->table('table', function (Blueprint $table) { + $table->renameColumn('name', 'new_name'); + }); + + $this->assertFalse($schema->hasColumn('my_schema.table', 'title')); + $this->assertTrue($schema->hasColumn('my_schema.table', 'new_title')); + $this->assertFalse($schema->hasColumn('table', 'name')); + $this->assertTrue($schema->hasColumn('table', 'new_name')); + $this->assertStringContainsString('default schema title', collect($schema->getColumns('my_schema.table'))->firstWhere('name', 'new_title')['default']); + $this->assertStringContainsString('default name', collect($schema->getColumns('table'))->firstWhere('name', 'new_name')['default']); + } + + #[DataProvider('connectionProvider')] + public function testModifyColumns($connection) + { + $schema = Schema::connection($connection); + + $schema->create('my_schema.table', function (Blueprint $table) { + $table->id(); + $table->string('name'); + $table->integer('count'); + }); + $schema->create('my_table', function (Blueprint $table) { + $table->id(); + $table->string('title'); + $table->integer('count'); + }); + + $schema->table('my_schema.table', function (Blueprint $table) { + $table->string('name')->default('default schema name')->change(); + $table->bigInteger('count')->change(); + }); + $schema->table('my_table', function (Blueprint $table) { + $table->string('title')->default('default title')->change(); + $table->bigInteger('count')->change(); + }); + + $this->assertStringContainsString('default schema name', collect($schema->getColumns('my_schema.table'))->firstWhere('name', 'name')['default']); + $this->assertStringContainsString('default title', collect($schema->getColumns('my_table'))->firstWhere('name', 'title')['default']); + $this->assertEquals($this->driver === 'sqlsrv' ? 'nvarchar' : 'varchar', $schema->getColumnType('my_schema.table', 'name')); + $this->assertEquals($this->driver === 'sqlsrv' ? 'nvarchar' : 'varchar', $schema->getColumnType('my_table', 'title')); + $this->assertEquals(match ($this->driver) { + 'pgsql' => 'int8', + 'sqlite' => 'integer', + default => 'bigint', + }, $schema->getColumnType('my_schema.table', 'count')); + $this->assertEquals(match ($this->driver) { + 'pgsql' => 'int8', + 'sqlite' => 'integer', + default => 'bigint', + }, $schema->getColumnType('my_table', 'count')); + } + + #[DataProvider('connectionProvider')] + public function testDropColumns($connection) + { + $schema = Schema::connection($connection); + + $schema->create('my_schema.table', function (Blueprint $table) { + $table->id(); + $table->string('name')->default('default schema name'); + $table->integer('count')->default(20); + $table->string('title')->default('default schema title'); + }); + $schema->create('table', function (Blueprint $table) { + $table->id(); + $table->string('name')->default('default name'); + $table->integer('count')->default(10); + $table->string('title')->default('default title'); + }); + + $this->assertTrue($schema->hasColumns('my_schema.table', ['id', 'name', 'count', 'title'])); + $this->assertTrue($schema->hasColumns('table', ['id', 'name', 'count', 'title'])); + + $schema->dropColumns('my_schema.table', ['name', 'count']); + $schema->dropColumns('table', ['name', 'title']); + + $this->assertTrue($schema->hasColumns('my_schema.table', ['id', 'title'])); + $this->assertFalse($schema->hasColumn('my_schema.table', 'name')); + $this->assertFalse($schema->hasColumn('my_schema.table', 'count')); + $this->assertTrue($schema->hasColumns('table', ['id', 'count'])); + $this->assertFalse($schema->hasColumn('table', 'name')); + $this->assertFalse($schema->hasColumn('table', 'title')); + $this->assertStringContainsString('default schema title', collect($schema->getColumns('my_schema.table'))->firstWhere('name', 'title')['default']); + $this->assertStringContainsString('10', collect($schema->getColumns('table'))->firstWhere('name', 'count')['default']); + } + + #[DataProvider('connectionProvider')] + public function testIndexes($connection) + { + $schema = Schema::connection($connection); + + $schema->create('my_schema.table', function (Blueprint $table) { + $table->string('code')->primary(); + $table->string('email')->unique(); + $table->integer('name')->index(); + $table->integer('title')->index(); + }); + $schema->create('my_table', function (Blueprint $table) { + $table->string('code')->primary(); + $table->string('email')->unique(); + $table->integer('name')->index(); + $table->integer('title')->index(); + }); + + $this->assertTrue($schema->hasIndex('my_schema.table', ['code'], 'primary')); + $this->assertTrue($schema->hasIndex('my_schema.table', ['email'], 'unique')); + $this->assertTrue($schema->hasIndex('my_schema.table', ['name'])); + $this->assertTrue($schema->hasIndex('my_table', ['code'], 'primary')); + $this->assertTrue($schema->hasIndex('my_table', ['email'], 'unique')); + $this->assertTrue($schema->hasIndex('my_table', ['name'])); + + $schemaIndexName = $connection === 'with-prefix' ? 'my_schema_example_table_title_index' : 'my_schema_table_title_index'; + $indexName = $connection === 'with-prefix' ? 'example_my_table_title_index' : 'my_table_title_index'; + + $schema->table('my_schema.table', function (Blueprint $table) use ($schemaIndexName) { + $table->renameIndex($schemaIndexName, 'schema_new_index_name'); + }); + $schema->table('my_table', function (Blueprint $table) use ($indexName) { + $table->renameIndex($indexName, 'new_index_name'); + }); + + $this->assertTrue($schema->hasIndex('my_schema.table', 'schema_new_index_name')); + $this->assertFalse($schema->hasIndex('my_schema.table', $schemaIndexName)); + $this->assertTrue($schema->hasIndex('my_table', 'new_index_name')); + $this->assertFalse($schema->hasIndex('my_table', $indexName)); + + $schema->table('my_schema.table', function (Blueprint $table) { + $table->dropPrimary(['code']); + $table->dropUnique(['email']); + $table->dropIndex(['name']); + $table->dropIndex('schema_new_index_name'); + }); + $schema->table('my_table', function (Blueprint $table) { + $table->dropPrimary(['code']); + $table->dropUnique(['email']); + $table->dropIndex(['name']); + $table->dropIndex('new_index_name'); + }); + + $this->assertEmpty($schema->getIndexListing('my_schema.table')); + $this->assertEmpty($schema->getIndexListing('my_table')); + } + + #[DataProvider('connectionProvider')] + #[RequiresDatabase(['mariadb', 'mysql', 'pgsql', 'sqlsrv'])] + public function testForeignKeys($connection) + { + $schema = Schema::connection($connection); + $currentSchema = $schema->getCurrentSchemaName(); + + $schema->create('my_tables', function (Blueprint $table) { + $table->id(); + }); + $schema->create('my_schema.table', function (Blueprint $table) use ($currentSchema) { + $table->id(); + $table->foreignId('my_table_id') + ->constrained(table: in_array($this->driver, ['mariadb', 'mysql']) ? "{$currentSchema}.my_tables" : null); + }); + $schema->create('table', function (Blueprint $table) { + $table->unsignedBigInteger('table_id'); + $table->foreign('table_id')->references('id')->on('my_schema.table'); + }); + + $schemaTableName = $connection === 'with-prefix' ? 'example_table' : 'table'; + $tableName = $connection === 'with-prefix' ? 'example_my_tables' : 'my_tables'; + $defaultSchemaName = match ($this->driver) { + 'pgsql' => 'public', + 'sqlsrv' => 'dbo', + default => $currentSchema, + }; + + $this->assertTrue(collect($schema->getForeignKeys('my_schema.table'))->contains( + fn ($foreign) => $foreign['columns'] === ['my_table_id'] + && $foreign['foreign_table'] === $tableName && $foreign['foreign_schema'] === $defaultSchemaName + && $foreign['foreign_columns'] === ['id'] + )); + + $this->assertTrue(collect($schema->getForeignKeys('table'))->contains( + fn ($foreign) => $foreign['columns'] === ['table_id'] + && $foreign['foreign_table'] === $schemaTableName && $foreign['foreign_schema'] === 'my_schema' + && $foreign['foreign_columns'] === ['id'] + )); + + $schema->table('my_schema.table', function (Blueprint $table) { + $table->dropForeign(['my_table_id']); + }); + $schema->table('table', function (Blueprint $table) { + $table->dropForeign(['table_id']); + }); + + $this->assertEmpty($schema->getForeignKeys('my_schema.table')); + $this->assertEmpty($schema->getForeignKeys('table')); + } + + #[DataProvider('connectionProvider')] + #[RequiresDatabase('sqlite')] + public function testForeignKeysOnSameSchema($connection) + { + $schema = Schema::connection($connection); + + $schema->create('my_schema.my_tables', function (Blueprint $table) { + $table->id(); + }); + $schema->create('my_schema.table', function (Blueprint $table) { + $table->id(); + $table->foreignId('my_table_id')->constrained(); + }); + $schema->create('my_schema.second_table', function (Blueprint $table) { + $table->unsignedBigInteger('table_id'); + $table->foreign('table_id')->references('id')->on('table'); + }); + + $myTableName = $connection === 'with-prefix' ? 'example_my_tables' : 'my_tables'; + $tableName = $connection === 'with-prefix' ? 'example_table' : 'table'; + + $this->assertTrue(collect($schema->getForeignKeys('my_schema.table'))->contains( + fn ($foreign) => $foreign['columns'] === ['my_table_id'] + && $foreign['foreign_table'] === $myTableName && $foreign['foreign_schema'] === 'my_schema' + && $foreign['foreign_columns'] === ['id'] + )); + + $this->assertTrue(collect($schema->getForeignKeys('my_schema.second_table'))->contains( + fn ($foreign) => $foreign['columns'] === ['table_id'] + && $foreign['foreign_table'] === $tableName && $foreign['foreign_schema'] === 'my_schema' + && $foreign['foreign_columns'] === ['id'] + )); + + $schema->table('my_schema.table', function (Blueprint $table) { + $table->dropForeign(['my_table_id']); + }); + + $this->assertEmpty($schema->getForeignKeys('my_schema.table')); + } + + #[DataProvider('connectionProvider')] + public function testHasView($connection) + { + $db = DB::connection($connection); + $schema = $db->getSchemaBuilder(); + + $db->statement('create view ' . $db->getSchemaGrammar()->wrapTable('my_schema.view') . ' (name) as select 1'); + $db->statement('create view ' . $db->getSchemaGrammar()->wrapTable('my_view') . ' (name) as select 1'); + + $this->assertTrue($schema->hasView('my_schema.view')); + $this->assertTrue($schema->hasView('my_view')); + $this->assertTrue($schema->hasColumn('my_schema.view', 'name')); + $this->assertTrue($schema->hasColumn('my_view', 'name')); + + $currentSchema = $schema->getCurrentSchemaName(); + $viewName = $connection === 'with-prefix' ? 'example_view' : 'view'; + $myViewName = $connection === 'with-prefix' ? 'example_my_view' : 'my_view'; + + $this->assertEqualsCanonicalizing( + [$currentSchema . '.' . $myViewName, 'my_schema.' . $viewName], + array_column($schema->getViews([$currentSchema, 'my_schema']), 'schema_qualified_name') + ); + + $db->statement('drop view ' . $db->getSchemaGrammar()->wrapTable('my_schema.view')); + $db->statement('drop view ' . $db->getSchemaGrammar()->wrapTable('my_view')); + + $this->assertFalse($schema->hasView('my_schema.view')); + $this->assertFalse($schema->hasView('my_view')); + + $this->assertEmpty($schema->getViews([$currentSchema, 'my_schema'])); + } + + #[DataProvider('connectionProvider')] + #[RequiresDatabase(['mariadb', 'mysql', 'pgsql'])] + public function testComment($connection) + { + $schema = Schema::connection($connection); + $currentSchema = $schema->getCurrentSchemaName(); + + $schema->create('my_schema.table', function (Blueprint $table) { + $table->comment('comment on schema table'); + $table->string('name')->comment('comment on schema column'); + }); + $schema->create('table', function (Blueprint $table) { + $table->comment('comment on table'); + $table->string('name')->comment('comment on column'); + }); + + $tables = collect($schema->getTables()); + $tableName = $connection === 'with-prefix' ? 'example_table' : 'table'; + $defaultSchema = $this->driver === 'pgsql' ? 'public' : $currentSchema; + + $this->assertEquals( + 'comment on schema table', + $tables->first(fn ($table) => $table['name'] === $tableName && $table['schema'] === 'my_schema')['comment'] + ); + $this->assertEquals( + 'comment on table', + $tables->first(fn ($table) => $table['name'] === $tableName && $table['schema'] === $defaultSchema)['comment'] + ); + $this->assertEquals( + 'comment on schema column', + collect($schema->getColumns('my_schema.table'))->firstWhere('name', 'name')['comment'] + ); + $this->assertEquals( + 'comment on column', + collect($schema->getColumns('table'))->firstWhere('name', 'name')['comment'] + ); + } + + #[DataProvider('connectionProvider')] + #[RequiresDatabase(['mariadb', 'mysql', 'pgsql'])] + public function testAutoIncrementStartingValue($connection) + { + $this->expectNotToPerformAssertions(); + + $schema = Schema::connection($connection); + + $schema->create('my_schema.table', function (Blueprint $table) { + $table->increments('code')->from(25); + }); + $schema->create('table', function (Blueprint $table) { + $table->increments('code')->from(15); + }); + } + + #[DataProvider('connectionProvider')] + #[RequiresDatabase('sqlsrv')] + public function testHasTable($connection) + { + $db = DB::connection($connection); + $schema = $db->getSchemaBuilder(); + + try { + $db->statement("create login my_user with password = 'Passw0rd'"); + $db->statement('create user my_user for login my_user'); + } catch (\Illuminate\Database\QueryException) { + } + + $db->statement('grant create table to my_user'); + $db->statement('grant alter on SCHEMA::my_schema to my_user'); + $db->statement("alter user my_user with default_schema = my_schema execute as user='my_user'"); + + config([ + 'database.connections.' . $connection . '.username' => 'my_user', + 'database.connections.' . $connection . '.password' => 'Passw0rd', + ]); + + $this->assertEquals('my_schema', $schema->getCurrentSchemaName()); + + $schema->create('table', function (Blueprint $table) { + $table->id(); + }); + + $this->assertTrue($schema->hasTable('table')); + $this->assertTrue($schema->hasTable('my_schema.table')); + $this->assertFalse($schema->hasTable('dbo.table')); + } + + public static function connectionProvider(): array + { + return [ + 'without prefix' => ['without-prefix'], + 'with prefix' => ['with-prefix'], + ]; + } +} diff --git a/tests/Integration/Database/Laravel/SchemaBuilderTest.php b/tests/Integration/Database/Laravel/SchemaBuilderTest.php new file mode 100644 index 000000000..eb8280b77 --- /dev/null +++ b/tests/Integration/Database/Laravel/SchemaBuilderTest.php @@ -0,0 +1,827 @@ +expectNotToPerformAssertions(); + + Schema::create('table', function (Blueprint $table) { + $table->increments('id'); + }); + + Schema::dropAllTables(); + + $this->artisan('migrate:install'); + + Schema::create('table', function (Blueprint $table) { + $table->increments('id'); + }); + } + + public function testDropAllViews() + { + $this->expectNotToPerformAssertions(); + + DB::statement('create view foo (id) as select 1'); + + Schema::dropAllViews(); + + DB::statement('create view foo (id) as select 1'); + } + + #[RequiresDatabase('sqlite')] + public function testChangeToTinyInteger() + { + Schema::create('test', function (Blueprint $table) { + $table->string('test_column'); + }); + + $blueprint = new Blueprint($this->getConnection(), 'test', function (Blueprint $table) { + $table->tinyInteger('test_column')->change(); + }); + + $blueprint->build(); + + $this->assertSame('integer', Schema::getColumnType('test', 'test_column')); + } + + #[RequiresDatabase(['mysql', 'mariadb'])] + public function testChangeToTextColumn() + { + Schema::create('test', function (Blueprint $table) { + $table->integer('test_column'); + }); + + foreach (['tinyText', 'text', 'mediumText', 'longText'] as $type) { + $blueprint = new Blueprint($this->getConnection(), 'test', function ($table) use ($type) { + $table->{$type}('test_column')->change(); + }); + + $uppercase = strtolower($type); + + $expected = ["alter table `test` modify `test_column` {$uppercase} not null"]; + + $this->assertEquals($expected, $blueprint->toSql()); + } + } + + #[RequiresDatabase(['mysql', 'mariadb'])] + public function testChangeTextColumnToTextColumn() + { + Schema::create('test', static function (Blueprint $table) { + $table->text('test_column'); + }); + + foreach (['tinyText', 'mediumText', 'longText'] as $type) { + $blueprint = new Blueprint($this->getConnection(), 'test', function ($table) use ($type) { + $table->{$type}('test_column')->change(); + }); + + $lowercase = strtolower($type); + + $expected = ["alter table `test` modify `test_column` {$lowercase} not null"]; + + $this->assertEquals($expected, $blueprint->toSql()); + } + } + + #[RequiresDatabase(['mysql', 'mariadb'])] + public function testModifyNullableColumn() + { + Schema::create('test', static function (Blueprint $table) { + $table->string('not_null_column_to_not_null'); + $table->string('not_null_column_to_nullable'); + $table->string('nullable_column_to_nullable')->nullable(); + $table->string('nullable_column_to_not_null')->nullable(); + }); + + $blueprint = new Blueprint($this->getConnection(), 'test', function ($table) { + $table->text('not_null_column_to_not_null')->change(); + $table->text('not_null_column_to_nullable')->nullable()->change(); + $table->text('nullable_column_to_nullable')->nullable()->change(); + $table->text('nullable_column_to_not_null')->change(); + }); + + $expected = [ + 'alter table `test` modify `not_null_column_to_not_null` text not null', + 'alter table `test` modify `not_null_column_to_nullable` text null', + 'alter table `test` modify `nullable_column_to_nullable` text null', + 'alter table `test` modify `nullable_column_to_not_null` text not null', + ]; + + $this->assertEquals($expected, $blueprint->toSql()); + } + + public function testChangeNullableColumn() + { + Schema::create('test', function (Blueprint $table) { + $table->string('not_null_column_to_not_null'); + $table->string('not_null_column_to_nullable'); + $table->string('nullable_column_to_nullable')->nullable(); + $table->string('nullable_column_to_not_null')->nullable(); + }); + + $columns = collect(Schema::getColumns('test')); + + $this->assertFalse($columns->firstWhere('name', 'not_null_column_to_not_null')['nullable']); + $this->assertFalse($columns->firstWhere('name', 'not_null_column_to_nullable')['nullable']); + $this->assertTrue($columns->firstWhere('name', 'nullable_column_to_nullable')['nullable']); + $this->assertTrue($columns->firstWhere('name', 'nullable_column_to_not_null')['nullable']); + + Schema::table('test', function (Blueprint $table) { + $table->text('not_null_column_to_not_null')->change(); + $table->text('not_null_column_to_nullable')->nullable()->change(); + $table->text('nullable_column_to_nullable')->nullable()->change(); + $table->text('nullable_column_to_not_null')->change(); + }); + + $columns = collect(Schema::getColumns('test')); + + $this->assertFalse($columns->firstWhere('name', 'not_null_column_to_not_null')['nullable']); + $this->assertTrue($columns->firstWhere('name', 'not_null_column_to_nullable')['nullable']); + $this->assertTrue($columns->firstWhere('name', 'nullable_column_to_nullable')['nullable']); + $this->assertFalse($columns->firstWhere('name', 'nullable_column_to_not_null')['nullable']); + } + + public function testRenameColumnWithDefault() + { + Schema::create('test', static function (Blueprint $table) { + $table->timestamp('foo')->useCurrent(); + $table->string('bar')->default('value'); + }); + + $columns = Schema::getColumns('test'); + $defaultFoo = collect($columns)->firstWhere('name', 'foo')['default']; + $defaultBar = collect($columns)->firstWhere('name', 'bar')['default']; + + Schema::table('test', static function (Blueprint $table) { + $table->renameColumn('foo', 'new_foo'); + $table->renameColumn('bar', 'new_bar'); + }); + + $this->assertEquals(collect(Schema::getColumns('test'))->firstWhere('name', 'new_foo')['default'], $defaultFoo); + $this->assertEquals(collect(Schema::getColumns('test'))->firstWhere('name', 'new_bar')['default'], $defaultBar); + } + + #[RequiresDatabase('sqlite')] + public function testModifyColumnWithZeroDefaultOnSqlite() + { + Schema::create('test', static function (Blueprint $table) { + $table->integer('column_default_zero')->default(new Expression('0')); + $table->integer('column_to_change'); + }); + + Schema::table('test', function (Blueprint $table) { + $table->smallInteger('column_to_change')->default(new Expression('0'))->change(); + }); + + $columns = collect(Schema::getColumns('test')); + + $this->assertSame('0', $columns->firstWhere('name', 'column_default_zero')['default']); + $this->assertSame('0', $columns->firstWhere('name', 'column_to_change')['default']); + } + + public function testCompoundPrimaryWithAutoIncrement() + { + if ($this->driver === 'sqlite') { + $this->markTestSkipped('Compound primary key with an auto increment column is not supported on SQLite.'); + } + + Schema::create('test', function (Blueprint $table) { + $table->id(); + $table->uuid(); + + $table->primary(['id', 'uuid']); + }); + + $this->assertTrue(collect(Schema::getColumns('test'))->firstWhere('name', 'id')['auto_increment']); + $this->assertTrue(Schema::hasIndex('test', ['id', 'uuid'], 'primary')); + } + + public function testModifyingAutoIncrementColumn() + { + if ($this->driver === 'sqlsrv') { + $this->markTestSkipped('Changing a primary column is not supported on SQL Server.'); + } + + Schema::create('test', function (Blueprint $table) { + $table->increments('id'); + }); + + $this->assertTrue(collect(Schema::getColumns('test'))->firstWhere('name', 'id')['auto_increment']); + $this->assertTrue(Schema::hasIndex('test', ['id'], 'primary')); + + Schema::table('test', function (Blueprint $table) { + $table->bigIncrements('id')->change(); + }); + + $this->assertTrue(collect(Schema::getColumns('test'))->firstWhere('name', 'id')['auto_increment']); + $this->assertTrue(Schema::hasIndex('test', ['id'], 'primary')); + } + + public function testModifyingColumnToAutoIncrementColumn() + { + if (in_array($this->driver, ['pgsql', 'sqlsrv'])) { + $this->markTestSkipped('Changing a column to auto increment is not supported on PostgreSQL and SQL Server.'); + } + + Schema::create('test', function (Blueprint $table) { + $table->unsignedBigInteger('id'); + }); + + $this->assertFalse(collect(Schema::getColumns('test'))->firstWhere('name', 'id')['auto_increment']); + $this->assertFalse(Schema::hasIndex('test', ['id'], 'primary')); + + Schema::table('test', function (Blueprint $table) { + $table->bigIncrements('id')->primary()->change(); + }); + + $this->assertTrue(collect(Schema::getColumns('test'))->firstWhere('name', 'id')['auto_increment']); + $this->assertTrue(Schema::hasIndex('test', ['id'], 'primary')); + } + + public function testAddingAutoIncrementColumn() + { + if ($this->driver === 'sqlite') { + $this->markTestSkipped('Adding a primary column is not supported on SQLite.'); + } + + Schema::create('test', function (Blueprint $table) { + $table->string('name'); + }); + + Schema::table('test', function (Blueprint $table) { + $table->bigIncrements('id'); + }); + + $this->assertTrue(collect(Schema::getColumns('test'))->firstWhere('name', 'id')['auto_increment']); + $this->assertTrue(Schema::hasIndex('test', ['id'], 'primary')); + } + + public function testGetTables() + { + Schema::create('foo', function (Blueprint $table) { + $table->comment('This is a comment'); + $table->increments('id'); + }); + + Schema::create('bar', function (Blueprint $table) { + $table->string('name'); + }); + + Schema::create('baz', function (Blueprint $table) { + $table->integer('votes'); + }); + + $tables = Schema::getTables(); + + $this->assertEmpty(array_diff(['foo', 'bar', 'baz'], array_column($tables, 'name'))); + + if (in_array($this->driver, ['mysql', 'mariadb', 'pgsql'])) { + $this->assertNotEmpty(array_filter($tables, function ($table) { + return $table['name'] === 'foo' && $table['comment'] === 'This is a comment'; + })); + } + } + + public function testHasView() + { + DB::statement('create view foo (id) as select 1'); + + $this->assertTrue(Schema::hasView('foo')); + } + + public function testGetViews() + { + DB::statement('create view foo (id) as select 1'); + DB::statement('create view bar (name) as select 1'); + DB::statement('create view baz (votes) as select 1'); + + $views = Schema::getViews(); + + $this->assertEmpty(array_diff(['foo', 'bar', 'baz'], array_column($views, 'name'))); + } + + #[RequiresDatabase('pgsql')] + public function testGetAndDropTypes() + { + DB::statement('create type pseudo_foo'); + DB::statement('create type comp_foo as (f1 int, f2 text)'); + DB::statement("create type enum_foo as enum ('new', 'open', 'closed')"); + DB::statement('create type range_foo as range (subtype = float8)'); + DB::statement('create domain domain_foo as text'); + DB::statement('create type base_foo'); + DB::statement("create function foo_in(cstring) returns base_foo language internal immutable strict parallel safe as 'int2in'"); + DB::statement("create function foo_out(base_foo) returns cstring language internal immutable strict parallel safe as 'int2out'"); + DB::statement('create type base_foo (input = foo_in, output = foo_out)'); + + $types = Schema::getTypes(); + + if (version_compare($this->getConnection()->getServerVersion(), '14.0', '<')) { + $this->assertCount(10, $types); + } else { + $this->assertCount(13, $types); + } + + $this->assertTrue(collect($types)->contains(fn ($type) => $type['name'] === 'pseudo_foo' && $type['type'] === 'pseudo' && ! $type['implicit'])); + $this->assertTrue(collect($types)->contains(fn ($type) => $type['name'] === 'comp_foo' && $type['type'] === 'composite' && ! $type['implicit'])); + $this->assertTrue(collect($types)->contains(fn ($type) => $type['name'] === 'enum_foo' && $type['type'] === 'enum' && ! $type['implicit'])); + $this->assertTrue(collect($types)->contains(fn ($type) => $type['name'] === 'range_foo' && $type['type'] === 'range' && ! $type['implicit'])); + $this->assertTrue(collect($types)->contains(fn ($type) => $type['name'] === 'domain_foo' && $type['type'] === 'domain' && ! $type['implicit'])); + $this->assertTrue(collect($types)->contains(fn ($type) => $type['name'] === 'base_foo' && $type['type'] === 'base' && ! $type['implicit'])); + + Schema::dropAllTypes(); + $types = Schema::getTypes(); + + $this->assertEmpty($types); + } + + public function testGetColumns() + { + Schema::create('foo', function (Blueprint $table) { + $table->id(); + $table->string('bar')->nullable(); + $table->string('baz')->default('test'); + }); + + $columns = Schema::getColumns('foo'); + + $this->assertCount(3, $columns); + $this->assertTrue(collect($columns)->contains( + fn ($column) => $column['name'] === 'id' && $column['auto_increment'] && ! $column['nullable'] + )); + $this->assertTrue(collect($columns)->contains( + fn ($column) => $column['name'] === 'bar' && $column['nullable'] + )); + $this->assertTrue(collect($columns)->contains( + fn ($column) => $column['name'] === 'baz' && ! $column['nullable'] && str_contains($column['default'], 'test') + )); + } + + public function testGetColumnsOnView() + { + DB::statement('create view foo (bar) as select 1'); + + $columns = Schema::getColumns('foo'); + + $this->assertCount(1, $columns); + $this->assertTrue($columns[0]['name'] === 'bar'); + } + + public function testGetIndexes() + { + Schema::create('foo', function (Blueprint $table) { + $table->string('bar')->index('my_index'); + }); + + $indexes = Schema::getIndexes('foo'); + + $this->assertCount(1, $indexes); + $this->assertTrue( + $indexes[0]['name'] === 'my_index' + && $indexes[0]['columns'] === ['bar'] + && ! $indexes[0]['unique'] + && ! $indexes[0]['primary'] + ); + $this->assertTrue(Schema::hasIndex('foo', 'my_index')); + $this->assertTrue(Schema::hasIndex('foo', ['bar'])); + $this->assertFalse(Schema::hasIndex('foo', 'my_index', 'primary')); + $this->assertFalse(Schema::hasIndex('foo', ['bar'], 'unique')); + } + + public function testGetUniqueIndexes() + { + Schema::create('foo', function (Blueprint $table) { + $table->id(); + $table->string('bar'); + $table->integer('baz'); + + $table->unique(['baz', 'bar']); + }); + + $indexes = Schema::getIndexes('foo'); + + $this->assertCount(2, $indexes); + $this->assertTrue(collect($indexes)->contains( + fn ($index) => $index['columns'] === ['id'] && $index['primary'] + )); + $this->assertTrue(collect($indexes)->contains( + fn ($index) => $index['name'] === 'foo_baz_bar_unique' && $index['columns'] === ['baz', 'bar'] && $index['unique'] + )); + $this->assertTrue(Schema::hasIndex('foo', 'foo_baz_bar_unique')); + $this->assertTrue(Schema::hasIndex('foo', 'foo_baz_bar_unique', 'unique')); + $this->assertTrue(Schema::hasIndex('foo', ['baz', 'bar'])); + $this->assertTrue(Schema::hasIndex('foo', ['baz', 'bar'], 'unique')); + $this->assertFalse(Schema::hasIndex('foo', ['baz', 'bar'], 'primary')); + } + + public function testGetIndexesWithCompositeKeys() + { + Schema::create('foo', function (Blueprint $table) { + $table->unsignedBigInteger('key'); + $table->string('bar')->unique(); + $table->integer('baz'); + + $table->primary(['baz', 'key']); + }); + + $indexes = Schema::getIndexes('foo'); + + $this->assertCount(2, $indexes); + $this->assertTrue(collect($indexes)->contains( + fn ($index) => $index['columns'] === ['baz', 'key'] && $index['primary'] + )); + $this->assertTrue(collect($indexes)->contains( + fn ($index) => $index['name'] === 'foo_bar_unique' && $index['columns'] === ['bar'] && $index['unique'] + )); + } + + #[RequiresDatabase(['mysql', 'mariadb', 'pgsql'])] + public function testGetFullTextIndexes() + { + Schema::create('articles', function (Blueprint $table) { + $table->id(); + $table->string('title', 200); + $table->text('body'); + + $table->fulltext(['body', 'title']); + }); + + $indexes = Schema::getIndexes('articles'); + + $this->assertCount(2, $indexes); + $this->assertTrue(collect($indexes)->contains(fn ($index) => $index['columns'] === ['id'] && $index['primary'])); + $this->assertTrue(collect($indexes)->contains('name', 'articles_body_title_fulltext')); + } + + public function testHasIndexOrder() + { + Schema::create('foo', function (Blueprint $table) { + $table->integer('bar'); + $table->integer('baz'); + $table->integer('qux'); + + $table->unique(['bar', 'baz']); + $table->index(['baz', 'bar']); + $table->index(['baz', 'qux']); + }); + + $this->assertTrue(Schema::hasIndex('foo', ['bar', 'baz'])); + $this->assertTrue(Schema::hasIndex('foo', ['bar', 'baz'], 'unique')); + $this->assertTrue(Schema::hasIndex('foo', ['baz', 'bar'])); + $this->assertFalse(Schema::hasIndex('foo', ['baz', 'bar'], 'unique')); + $this->assertTrue(Schema::hasIndex('foo', ['baz', 'qux'])); + $this->assertFalse(Schema::hasIndex('foo', ['qux', 'baz'])); + } + + public function testGetForeignKeys() + { + Schema::create('users', function (Blueprint $table) { + $table->id(); + }); + + Schema::create('posts', function (Blueprint $table) { + $table->foreignId('user_id')->nullable()->constrained()->cascadeOnUpdate()->nullOnDelete(); + }); + + $foreignKeys = Schema::getForeignKeys('posts'); + + $this->assertCount(1, $foreignKeys); + $this->assertTrue(collect($foreignKeys)->contains( + fn ($foreign) => $foreign['columns'] === ['user_id'] + && $foreign['foreign_table'] === 'users' && $foreign['foreign_columns'] === ['id'] + && $foreign['on_update'] === 'cascade' && $foreign['on_delete'] === 'set null' + )); + } + + public function testGetCompoundForeignKeys() + { + Schema::create('parent', function (Blueprint $table) { + $table->id(); + $table->integer('a'); + $table->integer('b'); + + $table->unique(['b', 'a']); + }); + + Schema::create('child', function (Blueprint $table) { + $table->integer('c'); + $table->integer('d'); + + $table->foreign(['d', 'c'], 'test_fk')->references(['b', 'a'])->on('parent'); + }); + + $foreignKeys = Schema::getForeignKeys('child'); + + $this->assertCount(1, $foreignKeys); + $this->assertTrue(collect($foreignKeys)->contains( + fn ($foreign) => $foreign['columns'] === ['d', 'c'] + && $foreign['foreign_table'] === 'parent' + && $foreign['foreign_columns'] === ['b', 'a'] + )); + } + + public function testAlteringTableWithForeignKeyConstraintsEnabled() + { + Schema::enableForeignKeyConstraints(); + + Schema::create('parents', function (Blueprint $table) { + $table->id(); + $table->text('name'); + }); + + Schema::create('children', function (Blueprint $table) { + $table->foreignId('parent_id')->constrained(); + }); + + $id = DB::table('parents')->insertGetId(['name' => 'foo']); + DB::table('children')->insert(['parent_id' => $id]); + + Schema::table('parents', function (Blueprint $table) { + $table->string('name')->change(); + }); + + $foreignKeys = Schema::getForeignKeys('children'); + + $this->assertCount(1, $foreignKeys); + $this->assertTrue(collect($foreignKeys)->contains( + fn ($foreign) => $foreign['columns'] === ['parent_id'] + && $foreign['foreign_table'] === 'parents' && $foreign['foreign_columns'] === ['id'] + )); + } + + #[RequiresDatabase('mariadb')] + public function testSystemVersionedTables() + { + DB::statement('create table `test` (`foo` int) WITH system versioning;'); + + $this->assertTrue(Schema::hasTable('test')); + + Schema::dropAllTables(); + + $this->artisan('migrate:install'); + + DB::statement('create table `test` (`foo` int) WITH system versioning;'); + } + + #[RequiresDatabase('sqlite')] + public function testAddingStoredColumnOnSqlite() + { + Schema::create('test', function (Blueprint $table) { + $table->integer('price'); + }); + + Schema::table('test', function (Blueprint $table) { + $table->integer('virtual_column')->virtualAs('"price" - 5'); + $table->integer('stored_column')->storedAs('"price" - 5'); + }); + + $this->assertTrue(Schema::hasColumns('test', ['virtual_column', 'stored_column'])); + } + + #[RequiresDatabase('sqlite')] + public function testModifyingStoredColumnOnSqlite() + { + Schema::create('test', function (Blueprint $table) { + $table->integer('price'); + $table->integer('virtual_price')->virtualAs('price - 2'); + $table->integer('stored_price')->storedAs('price - 4'); + $table->integer('virtual_price_changed')->virtualAs('price - 6'); + $table->integer('stored_price_changed')->storedAs('price - 8'); + }); + + DB::table('test')->insert(['price' => 100]); + + Schema::table('test', function (Blueprint $table) { + $table->integer('virtual_price_changed')->virtualAs('price - 5')->change(); + $table->integer('stored_price_changed')->storedAs('price - 7')->change(); + }); + + $this->assertEquals( + ['price' => 100, 'virtual_price' => 98, 'stored_price' => 96, 'virtual_price_changed' => 95, 'stored_price_changed' => 93], + (array) DB::table('test')->first() + ); + + $columns = Schema::getColumns('test'); + + $this->assertTrue(collect($columns)->contains( + fn ($column) => $column['name'] === 'virtual_price' && $column['generation']['type'] === 'virtual' + && $column['generation']['expression'] === 'price - 2' + )); + $this->assertTrue(collect($columns)->contains( + fn ($column) => $column['name'] === 'stored_price' && $column['generation']['type'] === 'stored' + && $column['generation']['expression'] === 'price - 4' + )); + $this->assertTrue(collect($columns)->contains( + fn ($column) => $column['name'] === 'virtual_price_changed' && $column['generation']['type'] === 'virtual' + && $column['generation']['expression'] === 'price - 5' + )); + $this->assertTrue(collect($columns)->contains( + fn ($column) => $column['name'] === 'stored_price_changed' && $column['generation']['type'] === 'stored' + && $column['generation']['expression'] === 'price - 7' + )); + } + + #[RequiresDatabase('pgsql', '>=18')] + public function testGettingGeneratedColumns() + { + Schema::create('test', function (Blueprint $table) { + $table->integer('price'); + + if ($this->driver === 'sqlsrv') { + $table->computed('virtual_price', 'price - 5'); + $table->computed('stored_price', 'price - 10')->persisted(); + } else { + $table->integer('virtual_price')->virtualAs('price - 5'); + $table->integer('stored_price')->storedAs('price - 10'); + } + }); + + $columns = Schema::getColumns('test'); + + $this->assertTrue(collect($columns)->contains( + fn ($column) => $column['name'] === 'price' && is_null($column['generation']) + )); + $this->assertTrue(collect($columns)->contains( + fn ($column) => $column['name'] === 'virtual_price' + && $column['generation']['type'] === 'virtual' + && match ($this->driver) { + 'mysql' => $column['generation']['expression'] === '(`price` - 5)', + 'mariadb' => $column['generation']['expression'] === '`price` - 5', + 'sqlsrv' => $column['generation']['expression'] === '([price]-(5))', + 'pgsql' => $column['generation']['expression'] === '(price - 5)', + default => $column['generation']['expression'] === 'price - 5', + } + )); + $this->assertTrue(collect($columns)->contains( + fn ($column) => $column['name'] === 'stored_price' + && $column['generation']['type'] === 'stored' + && match ($this->driver) { + 'mysql' => $column['generation']['expression'] === '(`price` - 10)', + 'mariadb' => $column['generation']['expression'] === '`price` - 10', + 'sqlsrv' => $column['generation']['expression'] === '([price]-(10))', + 'pgsql' => $column['generation']['expression'] === '(price - 10)', + default => $column['generation']['expression'] === 'price - 10', + } + )); + } + + #[RequiresDatabase('sqlite')] + public function testAddForeignKeysOnSqlite() + { + Schema::create('users', function (Blueprint $table) { + $table->id(); + $table->string('name'); + }); + + Schema::create('posts', function (Blueprint $table) { + $table->string('title')->unique(); + }); + + Schema::table('posts', function (Blueprint $table) { + $table->foreignId('user_id')->nullable()->index()->constrained(); + $table->string('user_name'); + $table->foreign('user_name')->references('name')->on('users'); + }); + + $foreignKeys = Schema::getForeignKeys('posts'); + $this->assertCount(2, $foreignKeys); + $this->assertTrue(collect($foreignKeys)->contains(fn ($foreign) => $foreign['columns'] === ['user_id'] && $foreign['foreign_table'] === 'users' && $foreign['foreign_columns'] === ['id'])); + $this->assertTrue(collect($foreignKeys)->contains(fn ($foreign) => $foreign['columns'] === ['user_name'] && $foreign['foreign_table'] === 'users' && $foreign['foreign_columns'] === ['name'])); + $this->assertTrue(Schema::hasColumns('posts', ['title', 'user_id', 'user_name'])); + $this->assertTrue(Schema::hasIndex('posts', ['user_id'])); + $this->assertTrue(Schema::hasIndex('posts', ['title'], 'unique')); + } + + #[RequiresDatabase('sqlite')] + public function testDropForeignKeysOnSqlite() + { + Schema::create('users', function (Blueprint $table) { + $table->id(); + $table->string('name'); + }); + + Schema::create('posts', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->nullable()->index()->constrained(); + $table->string('user_name')->unique(); + $table->foreign('user_name')->references('name')->on('users'); + }); + + $foreignKeys = Schema::getForeignKeys('posts'); + $this->assertCount(2, $foreignKeys); + $this->assertTrue(collect($foreignKeys)->contains(fn ($foreign) => $foreign['columns'] === ['user_id'] && $foreign['foreign_table'] === 'users' && $foreign['foreign_columns'] === ['id'])); + $this->assertTrue(collect($foreignKeys)->contains(fn ($foreign) => $foreign['columns'] === ['user_name'] && $foreign['foreign_table'] === 'users' && $foreign['foreign_columns'] === ['name'])); + $this->assertTrue(Schema::hasIndex('posts', ['id'], 'primary')); + + Schema::table('posts', function (Blueprint $table) { + $table->string('title')->unique(); + $table->dropIndex(['user_id']); + $table->dropForeign(['user_id']); + $table->dropColumn('user_id'); + }); + + $foreignKeys = Schema::getForeignKeys('posts'); + $this->assertCount(1, $foreignKeys); + $this->assertTrue(collect($foreignKeys)->contains(fn ($foreign) => $foreign['columns'] === ['user_name'] && $foreign['foreign_table'] === 'users' && $foreign['foreign_columns'] === ['name'])); + $this->assertTrue(Schema::hasColumns('posts', ['user_name', 'title'])); + $this->assertTrue(Schema::hasIndex('posts', ['id'], 'primary')); + $this->assertTrue(Schema::hasIndex('posts', ['title'], 'unique')); + $this->assertTrue(Schema::hasIndex('posts', ['user_name'], 'unique')); + $this->assertFalse(Schema::hasColumn('posts', 'user_id')); + $this->assertFalse(Schema::hasIndex('posts', ['user_id'])); + } + + #[RequiresDatabase('sqlite')] + public function testAddAndDropPrimaryOnSqlite() + { + Schema::create('users', function (Blueprint $table) { + $table->id(); + $table->string('name'); + }); + + Schema::create('posts', function (Blueprint $table) { + $table->foreignId('user_id')->nullable()->index()->constrained(); + $table->string('user_name')->unique(); + $table->foreign('user_name')->references('name')->on('users'); + }); + + Schema::table('posts', function (Blueprint $table) { + $table->string('title')->primary(); + $table->dropIndex(['user_id']); + $table->dropForeign(['user_id']); + $table->dropColumn('user_id'); + }); + + $foreignKeys = Schema::getForeignKeys('posts'); + $this->assertCount(1, $foreignKeys); + $this->assertTrue(collect($foreignKeys)->contains(fn ($foreign) => $foreign['columns'] === ['user_name'] && $foreign['foreign_table'] === 'users' && $foreign['foreign_columns'] === ['name'])); + $this->assertTrue(Schema::hasColumns('posts', ['user_name', 'title'])); + $this->assertTrue(Schema::hasIndex('posts', ['title'], 'primary')); + $this->assertTrue(Schema::hasIndex('posts', ['user_name'], 'unique')); + $this->assertFalse(Schema::hasColumn('posts', 'user_id')); + $this->assertFalse(Schema::hasIndex('posts', ['user_id'])); + + Schema::table('posts', function (Blueprint $table) { + $table->dropPrimary(); + $table->integer('votes'); + }); + + $foreignKeys = Schema::getForeignKeys('posts'); + $this->assertCount(1, $foreignKeys); + $this->assertTrue(collect($foreignKeys)->contains(fn ($foreign) => $foreign['columns'] === ['user_name'] && $foreign['foreign_table'] === 'users' && $foreign['foreign_columns'] === ['name'])); + $this->assertTrue(Schema::hasColumns('posts', ['user_name', 'title', 'votes'])); + $this->assertFalse(Schema::hasIndex('posts', ['title'], 'primary')); + $this->assertTrue(Schema::hasIndex('posts', ['user_name'], 'unique')); + } + + public function testAddingMacros() + { + Schema::macro('foo', fn () => 'foo'); + + $this->assertEquals('foo', Schema::foo()); + + Schema::macro('hasForeignKeyForColumn', function (string $column, string $table, string $foreignTable) { + return collect(Schema::getForeignKeys($table)) + ->contains(function (array $foreignKey) use ($column, $foreignTable) { + return collect($foreignKey['columns'])->contains($column) + && $foreignKey['foreign_table'] == $foreignTable; + }); + }); + + Schema::create('questions', function (Blueprint $table) { + $table->id(); + $table->string('body'); + }); + + Schema::create('answers', function (Blueprint $table) { + $table->id(); + $table->string('body'); + $table->foreignId('question_id')->constrained(); + }); + + $this->assertTrue(Schema::hasForeignKeyForColumn('question_id', 'answers', 'questions')); + $this->assertFalse(Schema::hasForeignKeyForColumn('body', 'answers', 'questions')); + } +} diff --git a/tests/Integration/Database/Laravel/Sqlite/ConnectorTest.php b/tests/Integration/Database/Laravel/Sqlite/ConnectorTest.php new file mode 100644 index 000000000..9a0566e0a --- /dev/null +++ b/tests/Integration/Database/Laravel/Sqlite/ConnectorTest.php @@ -0,0 +1,122 @@ +get('config')->set('database.connections.custom_sqlite', [ + 'driver' => 'sqlite', + 'database' => database_path('custom.sqlite'), + 'foreign_key_constraints' => true, + 'busy_timeout' => 12345, + 'journal_mode' => 'wal', + 'synchronous' => 'normal', + 'pragmas' => [ + 'query_only' => true, + ], + ]); + } + + /** + * Configure writable_sqlite connection for testing dynamic pragma modification. + */ + protected function useWritableSqliteConnection(Application $app): void + { + $app->get('config')->set('database.connections.writable_sqlite', [ + 'driver' => 'sqlite', + 'database' => database_path('custom.sqlite'), + 'foreign_key_constraints' => true, + 'busy_timeout' => 12345, + 'journal_mode' => 'wal', + 'synchronous' => 'normal', + ]); + } + + protected function defineDatabaseMigrations(): void + { + // Create the custom.sqlite database file for tests that need it + if (file_exists(database_path('custom.sqlite'))) { + return; + } + + Schema::createDatabase(database_path('custom.sqlite')); + } + + protected function destroyDatabaseMigrations(): void + { + Schema::dropDatabaseIfExists(database_path('custom.sqlite')); + } + + /** + * Test default pragma values for SQLite connection. + * + * The default config has foreign_key_constraints => true, so foreign_keys is 1. + * journal_mode differs based on database type: 'memory' for :memory:, 'delete' for file-based. + */ + public function testDefaultPragmaValues(): void + { + // Default config has foreign_key_constraints => true + $this->assertSame(1, Schema::pragma('foreign_keys')); + $this->assertSame(60000, Schema::pragma('busy_timeout')); + + $expectedJournalMode = $this->usesSqliteInMemoryDatabaseConnection() ? 'memory' : 'delete'; + $this->assertSame($expectedJournalMode, Schema::pragma('journal_mode')); + + $this->assertSame(2, Schema::pragma('synchronous')); + } + + /** + * Test custom pragma configuration via connection config. + */ + #[DefineEnvironment('useCustomSqliteConnection')] + public function testCustomPragmaConfiguration(): void + { + $schema = Schema::connection('custom_sqlite'); + + $this->assertSame(1, $schema->pragma('foreign_keys')); + $this->assertSame(12345, $schema->pragma('busy_timeout')); + $this->assertSame('wal', $schema->pragma('journal_mode')); + $this->assertSame(1, $schema->pragma('synchronous')); + $this->assertSame(1, $schema->pragma('query_only')); + } + + /** + * Test dynamic pragma modification at runtime. + */ + #[DefineEnvironment('useWritableSqliteConnection')] + public function testDynamicPragmaModification(): void + { + $schema = Schema::connection('writable_sqlite'); + + // Verify initial values from config + $this->assertSame(1, $schema->pragma('foreign_keys')); + $this->assertSame(12345, $schema->pragma('busy_timeout')); + + // Modify pragmas dynamically + $schema->pragma('foreign_keys', 0); + $schema->pragma('busy_timeout', 54321); + $schema->pragma('journal_mode', 'delete'); + $schema->pragma('synchronous', 0); + + // Verify changes + $this->assertSame(0, $schema->pragma('foreign_keys')); + $this->assertSame(54321, $schema->pragma('busy_timeout')); + $this->assertSame('delete', $schema->pragma('journal_mode')); + $this->assertSame(0, $schema->pragma('synchronous')); + } +} diff --git a/tests/Integration/Database/Laravel/Sqlite/Console/MigrateFreshCommandWithJournalModeWalTest.php b/tests/Integration/Database/Laravel/Sqlite/Console/MigrateFreshCommandWithJournalModeWalTest.php new file mode 100644 index 000000000..8f61ee2af --- /dev/null +++ b/tests/Integration/Database/Laravel/Sqlite/Console/MigrateFreshCommandWithJournalModeWalTest.php @@ -0,0 +1,68 @@ +get('config')->set('database.connections.sqlite.journal_mode', 'wal'); + } + + #[Override] + protected function setUp(): void + { + // WAL journal mode doesn't work with :memory: databases + if ($this->isConfiguredForInMemoryDatabase()) { + parent::setUp(); + $this->markTestSkipped('WAL journal mode requires a file-based database, not :memory:'); + } + + // Delete any existing database file to start fresh, then create an + // empty file. The connector will set WAL mode via the journal_mode + // config when the connection is established. + $databasePath = $this->getConfiguredDatabasePath(); + $this->deleteSqliteDatabaseFile($databasePath); + touch($databasePath); + + $this->beforeApplicationDestroyed(fn () => $this->deleteSqliteDatabaseFile()); + + parent::setUp(); + } + + public function testMigrateFreshWorksWithWalJournalMode(): void + { + // DatabaseMigrations trait already ran migrate:fresh in setUp. + // Verify it succeeded and WAL mode is active. + $this->assertTrue(Schema::hasTable('users')); + $this->assertSame('wal', DB::scalar('pragma journal_mode')); + } +} diff --git a/tests/Integration/Database/Laravel/Sqlite/DatabaseSchemaBlueprintTest.php b/tests/Integration/Database/Laravel/Sqlite/DatabaseSchemaBlueprintTest.php new file mode 100644 index 000000000..587f7c37e --- /dev/null +++ b/tests/Integration/Database/Laravel/Sqlite/DatabaseSchemaBlueprintTest.php @@ -0,0 +1,470 @@ +set('database.connections.sqlite.foreign_key_constraints', false); + } + + protected function setUpInCoroutine(): void + { + // Purge and reconnect to apply the foreign_key_constraints config + DB::purge(); + Schema::dropAllTables(); + $this->artisan('migrate:install'); + } + + public function testRenamingAndChangingColumnsWork() + { + DB::connection()->getSchemaBuilder()->create('users', function ($table) { + $table->string('name'); + $table->string('age'); + }); + + $blueprint = $this->getBlueprint('SQLite', 'users', function ($table) { + $table->renameColumn('name', 'first_name'); + $table->integer('age')->change(); + }); + + $queries = $blueprint->toSql(); + + $expected = [ + '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"', + ]; + + $this->assertEquals($expected, $queries); + } + + public function testRenamingColumnsWorks() + { + $schema = DB::connection()->getSchemaBuilder(); + + $schema->create('test', function (Blueprint $table) { + $table->string('foo'); + $table->string('baz'); + }); + + $schema->table('test', function (Blueprint $table) { + $table->renameColumn('foo', 'bar'); + $table->renameColumn('baz', 'qux'); + }); + + $this->assertFalse($schema->hasColumn('test', 'foo')); + $this->assertFalse($schema->hasColumn('test', 'baz')); + $this->assertTrue($schema->hasColumns('test', ['bar', 'qux'])); + } + + public function testNativeColumnModifyingOnPostgreSql() + { + $blueprint = $this->getBlueprint('Postgres', 'users', function ($table) { + $table->integer('code')->autoIncrement()->from(10)->comment('my comment')->change(); + }); + + $this->assertEquals([ + 'alter table "users" ' + . 'alter column "code" type integer, ' + . 'alter column "code" set not null', + 'alter sequence users_code_seq restart with 10', + 'comment on column "users"."code" is \'my comment\'', + ], $blueprint->toSql()); + + $blueprint = $this->getBlueprint('Postgres', 'users', function ($table) { + $table->char('name', 40)->nullable()->default('easy')->collation('unicode')->change(); + }); + + $this->assertEquals([ + 'alter table "users" ' + . 'alter column "name" type char(40) collate "unicode", ' + . 'alter column "name" drop not null, ' + . 'alter column "name" set default \'easy\', ' + . 'alter column "name" drop identity if exists', + 'comment on column "users"."name" is NULL', + ], $blueprint->toSql()); + + $blueprint = $this->getBlueprint('Postgres', 'users', function ($table) { + $table->integer('foo')->generatedAs('expression')->always()->change(); + }); + + $this->assertEquals([ + 'alter table "users" ' + . 'alter column "foo" type integer, ' + . 'alter column "foo" set not null, ' + . 'alter column "foo" drop default, ' + . 'alter column "foo" drop identity if exists, ' + . 'alter column "foo" add generated always as identity (expression)', + 'comment on column "users"."foo" is NULL', + ], $blueprint->toSql()); + + $blueprint = $this->getBlueprint('Postgres', 'users', function ($table) { + $table->geometry('foo', 'point', 1234)->change(); + }); + + $this->assertEquals([ + 'alter table "users" ' + . 'alter column "foo" type geometry(point,1234), ' + . 'alter column "foo" set not null, ' + . 'alter column "foo" drop default, ' + . 'alter column "foo" drop identity if exists', + 'comment on column "users"."foo" is NULL', + ], $blueprint->toSql()); + + $blueprint = $this->getBlueprint('Postgres', 'users', function ($table) { + $table->timestamp('added_at', 2)->useCurrent()->storedAs(null)->change(); + }); + + $this->assertEquals([ + 'alter table "users" ' + . 'alter column "added_at" type timestamp(2) without time zone, ' + . 'alter column "added_at" set not null, ' + . 'alter column "added_at" set default CURRENT_TIMESTAMP, ' + . 'alter column "added_at" drop expression if exists, ' + . 'alter column "added_at" drop identity if exists', + 'comment on column "users"."added_at" is NULL', + ], $blueprint->toSql()); + } + + public function testChangingColumnWithCollationWorks() + { + DB::connection()->getSchemaBuilder()->create('users', function ($table) { + $table->string('age'); + }); + + $blueprint = $this->getBlueprint('SQLite', 'users', function ($table) { + $table->integer('age')->collation('RTRIM')->change(); + }); + + $blueprint2 = $this->getBlueprint('SQLite', 'users', function ($table) { + $table->integer('age')->collation('NOCASE')->change(); + }); + + $queries = $blueprint->toSql(); + + $expected = [ + 'create table "__temp__users" ("age" integer not null collate \'RTRIM\')', + 'insert into "__temp__users" ("age") select "age" from "users"', + 'drop table "users"', + 'alter table "__temp__users" rename to "users"', + ]; + + $this->assertEquals($expected, $queries); + + $queries = $blueprint2->toSql(); + + $expected = [ + 'create table "__temp__users" ("age" integer not null collate \'NOCASE\')', + 'insert into "__temp__users" ("age") select "age" from "users"', + 'drop table "users"', + 'alter table "__temp__users" rename to "users"', + ]; + + $this->assertEquals($expected, $queries); + } + + public function testChangingCharColumnsWork() + { + DB::connection()->getSchemaBuilder()->create('users', function ($table) { + $table->string('name'); + }); + + $getSql = function ($grammar) { + return $this->getBlueprint($grammar, 'users', function ($table) { + $table->text('changed_col')->change(); + })->toSql(); + }; + + $expected = [ + 'create table "__temp__users" ("name" varchar not null)', + 'insert into "__temp__users" ("name") select "name" from "users"', + 'drop table "users"', + 'alter table "__temp__users" rename to "users"', + ]; + + $this->assertEquals($expected, $getSql('SQLite')); + } + + public function testChangingPrimaryAutoincrementColumnsToNonAutoincrementColumnsWork() + { + DB::connection()->getSchemaBuilder()->create('users', function ($table) { + $table->increments('id'); + }); + + $getSql = function ($grammar) { + return $this->getBlueprint($grammar, 'users', function ($table) { + $table->binary('id')->change(); + })->toSql(); + }; + + $expected = [ + 'create table "__temp__users" ("id" blob not null, primary key ("id"))', + 'insert into "__temp__users" ("id") select "id" from "users"', + 'drop table "users"', + 'alter table "__temp__users" rename to "users"', + ]; + + $this->assertEquals($expected, $getSql('SQLite')); + } + + public function testChangingDoubleColumnsWork() + { + DB::connection()->getSchemaBuilder()->create('products', function ($table) { + $table->integer('price'); + }); + + $getSql = function ($grammar) { + return $this->getBlueprint($grammar, 'products', function ($table) { + $table->double('price')->change(); + })->toSql(); + }; + + $expected = [ + 'create table "__temp__products" ("price" double not null)', + 'insert into "__temp__products" ("price") select "price" from "products"', + 'drop table "products"', + 'alter table "__temp__products" rename to "products"', + ]; + + $this->assertEquals($expected, $getSql('SQLite')); + } + + public function testChangingColumnsWithDefaultWorks() + { + DB::connection()->getSchemaBuilder()->create('products', function ($table) { + $table->integer('changed_col'); + $table->timestamp('timestamp_col')->useCurrent(); + $table->integer('integer_col')->default(123); + $table->string('string_col')->default('value'); + }); + + $getSql = function ($grammar) { + return $this->getBlueprint($grammar, 'products', function ($table) { + $table->text('changed_col')->change(); + })->toSql(); + }; + + $expected = [ + 'create table "__temp__products" ("changed_col" text not null, "timestamp_col" datetime not null default (CURRENT_TIMESTAMP), "integer_col" integer not null default (\'123\'), "string_col" varchar not null default (\'value\'))', + 'insert into "__temp__products" ("changed_col", "timestamp_col", "integer_col", "string_col") select "changed_col", "timestamp_col", "integer_col", "string_col" from "products"', + 'drop table "products"', + 'alter table "__temp__products" rename to "products"', + ]; + + $this->assertEquals($expected, $getSql('SQLite')); + } + + public function testRenameIndexWorks() + { + DB::connection()->getSchemaBuilder()->create('users', function ($table) { + $table->string('name'); + $table->string('age'); + }); + DB::connection()->getSchemaBuilder()->table('users', function ($table) { + $table->index(['name'], 'index1'); + }); + + $getSql = function ($grammar) { + return $this->getBlueprint($grammar, 'users', function ($table) { + $table->renameIndex('index1', 'index2'); + })->toSql(); + }; + + $expected = [ + 'drop index "index1"', + 'create index "index2" on "users" ("name")', + ]; + + $this->assertEquals($expected, $getSql('SQLite')); + + $expected = [ + 'alter table `users` rename index `index1` to `index2`', + ]; + + $this->assertEquals($expected, $getSql('MySql')); + + $expected = [ + 'alter index "index1" rename to "index2"', + ]; + + $this->assertEquals($expected, $getSql('Postgres')); + } + + public function testAddUniqueIndexWithoutNameWorks() + { + DB::connection()->getSchemaBuilder()->create('users', function ($table) { + $table->string('name')->nullable(); + }); + + $getSql = function ($grammar) { + return $this->getBlueprint($grammar, 'users', function ($table) { + $table->string('name')->nullable()->unique()->change(); + })->toSql(); + }; + + $expected = [ + 'alter table `users` modify `name` varchar(255) null', + 'alter table `users` add unique `users_name_unique`(`name`)', + ]; + + $this->assertEquals($expected, $getSql('MySql')); + + $expected = [ + 'alter table "users" alter column "name" type varchar(255), alter column "name" drop not null, alter column "name" drop default, alter column "name" drop identity if exists', + 'alter table "users" add constraint "users_name_unique" unique ("name")', + 'comment on column "users"."name" is NULL', + ]; + + $this->assertEquals($expected, $getSql('Postgres')); + + $expected = [ + 'create table "__temp__users" ("name" varchar)', + 'insert into "__temp__users" ("name") select "name" from "users"', + 'drop table "users"', + 'alter table "__temp__users" rename to "users"', + 'create unique index "users_name_unique" on "users" ("name")', + ]; + + $this->assertEquals($expected, $getSql('SQLite')); + } + + public function testAddUniqueIndexWithNameWorks() + { + DB::connection()->getSchemaBuilder()->create('users', function ($table) { + $table->string('name')->nullable(); + }); + + $getSql = function ($grammar) { + return $this->getBlueprint($grammar, 'users', function ($table) { + $table->unsignedInteger('name')->nullable()->unique('index1')->change(); + })->toSql(); + }; + + $expected = [ + 'alter table `users` modify `name` int unsigned null', + 'alter table `users` add unique `index1`(`name`)', + ]; + + $this->assertEquals($expected, $getSql('MySql')); + + $expected = [ + 'alter table "users" alter column "name" type integer, alter column "name" drop not null, alter column "name" drop default, alter column "name" drop identity if exists', + 'alter table "users" add constraint "index1" unique ("name")', + 'comment on column "users"."name" is NULL', + ]; + + $this->assertEquals($expected, $getSql('Postgres')); + + $expected = [ + 'create table "__temp__users" ("name" integer)', + 'insert into "__temp__users" ("name") select "name" from "users"', + 'drop table "users"', + 'alter table "__temp__users" rename to "users"', + 'create unique index "index1" on "users" ("name")', + ]; + + $this->assertEquals($expected, $getSql('SQLite')); + } + + public function testAddColumnNamedCreateWorks() + { + Schema::create('users', function (Blueprint $table) { + $table->string('name'); + }); + + Schema::table('users', function (Blueprint $table) { + $table->string('create')->nullable(); + }); + + $this->assertTrue(Schema::hasColumn('users', 'create')); + } + + public function testDropIndexOnColumnChangeWorks() + { + DB::connection()->getSchemaBuilder()->create('users', function ($table) { + $table->string('name')->nullable(); + }); + + $getSql = function ($grammar) { + return $this->getBlueprint($grammar, 'users', function ($table) { + $table->string('name')->nullable()->unique(false)->change(); + })->toSql(); + }; + + $this->assertContains( + 'alter table `users` drop index `users_name_unique`', + $getSql('MySql'), + ); + + $this->assertContains( + 'alter table "users" drop constraint "users_name_unique"', + $getSql('Postgres'), + ); + + $this->assertContains( + 'drop index "users_name_unique"', + $getSql('SQLite'), + ); + } + + public function testItDoesNotSetPrecisionHigherThanSupportedWhenRenamingTimestamps() + { + Schema::create('users', function (Blueprint $table) { + $table->timestamp('created_at'); + }); + + try { + // this would only fail in mysql, postgres and sql server + Schema::table('users', function (Blueprint $table) { + $table->renameColumn('created_at', 'new_created_at'); + }); + + $this->addToAssertionCount(1); // it did not throw + } catch (Exception $e) { + // Expecting something similar to: + // Illuminate\Database\QueryException + // SQLSTATE[42000]: Syntax error or access violation: 1426 Too big precision 10 specified for 'my_timestamp'. Maximum is 6.... + $this->fail('test_it_does_not_set_precision_higher_than_supported_when_renaming_timestamps has failed. Error: ' . $e->getMessage()); + } + } + + public function testItEnsuresDroppingForeignKeyIsAvailable() + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('This database driver does not support dropping foreign keys by name.'); + + Schema::table('users', function (Blueprint $table) { + $table->dropForeign('something'); + }); + } + + protected function getBlueprint( + string $grammar, + string $table, + Closure $callback, + ): Blueprint { + $grammarClass = 'Hypervel\Database\Schema\Grammars\\' . $grammar . 'Grammar'; + + $connection = DB::connection(); + $connection->setSchemaGrammar(new $grammarClass($connection)); + + return new Blueprint($connection, $table, $callback); + } +} diff --git a/tests/Integration/Database/Laravel/Sqlite/DatabaseSchemaBuilderTest.php b/tests/Integration/Database/Laravel/Sqlite/DatabaseSchemaBuilderTest.php new file mode 100644 index 000000000..5b626bd50 --- /dev/null +++ b/tests/Integration/Database/Laravel/Sqlite/DatabaseSchemaBuilderTest.php @@ -0,0 +1,161 @@ +artisan('migrate:install'); + Schema::connection('sqlite-with-prefix')->dropAllTables(); + $this->artisan('migrate:install', ['--database' => 'sqlite-with-prefix']); + Schema::connection('sqlite-with-indexed-prefix')->dropAllTables(); + $this->artisan('migrate:install', ['--database' => 'sqlite-with-indexed-prefix']); + } + + protected function defineEnvironment($app): void + { + $app['config']->set([ + 'database.connections.sqlite-with-prefix' => [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => 'example_', + 'prefix_indexes' => false, + ], + 'database.connections.sqlite-with-indexed-prefix' => [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => 'example_', + 'prefix_indexes' => true, + ], + ]); + } + + public function testDropAllTablesWorksWithForeignKeys() + { + Schema::create('table1', function (Blueprint $table) { + $table->integer('id'); + $table->string('name'); + }); + + Schema::create('table2', function (Blueprint $table) { + $table->integer('id'); + $table->string('user_id'); + $table->foreign('user_id')->references('id')->on('table1'); + }); + + $this->assertTrue(Schema::hasTable('table1')); + $this->assertTrue(Schema::hasTable('table2')); + + Schema::dropAllTables(); + + $this->assertFalse(Schema::hasTable('table1')); + $this->assertFalse(Schema::hasTable('table2')); + + // Restore migrations table for teardown's migrate:rollback + $this->artisan('migrate:install'); + } + + public function testHasColumnAndIndexWithPrefixIndexDisabled() + { + $connection = DB::connection('sqlite-with-prefix'); + + Schema::connection('sqlite-with-prefix')->create('table1', function (Blueprint $table) { + $table->integer('id'); + $table->string('name')->index(); + }); + + $indexes = array_column($connection->getSchemaBuilder()->getIndexes('table1'), 'name'); + + $this->assertContains('table1_name_index', $indexes, 'name'); + } + + public function testHasColumnAndIndexWithPrefixIndexEnabled() + { + $connection = DB::connection('sqlite-with-indexed-prefix'); + + Schema::connection('sqlite-with-indexed-prefix')->create('table1', function (Blueprint $table) { + $table->integer('id'); + $table->string('name')->index(); + }); + + $indexes = array_column($connection->getSchemaBuilder()->getIndexes('table1'), 'name'); + + $this->assertContains('example_table1_name_index', $indexes); + } + + public function testAlterTableAddForeignKeyWithPrefix() + { + $schema = Schema::connection('sqlite-with-prefix'); + + $schema->create('table1', function (Blueprint $table) { + $table->id(); + }); + + $schema->create('table2', function (Blueprint $table) { + $table->id(); + $table->foreignId('author_id')->constrained('table1'); + }); + + $schema->table('table2', function (Blueprint $table) { + $table->foreignId('moderator_id')->constrained('table1'); + }); + + $foreignKeys = collect($schema->getForeignKeys('table2')); + + $this->assertTrue( + $foreignKeys->contains( + fn ($fk) => $fk['foreign_table'] === 'example_table1' + && $fk['foreign_columns'] === ['id'] + && $fk['columns'] === ['author_id'] + ) + ); + + $this->assertTrue( + $foreignKeys->contains( + fn ($fk) => $fk['foreign_table'] === 'example_table1' + && $fk['foreign_columns'] === ['id'] + && $fk['columns'] === ['moderator_id'] + ) + ); + } + + public function testAlterTableAddForeignKeyWithExpressionDefault() + { + Schema::create('items', function (Blueprint $table) { + $table->id(); + $table->json('flags')->default(new Expression('(JSON_ARRAY())')); + }); + + Schema::table('items', function (Blueprint $table) { + $table->foreignId('item_id')->nullable()->constrained('items'); + }); + + $this->assertTrue(collect(Schema::getForeignKeys('items'))->contains( + fn ($fk) => $fk['foreign_table'] === 'items' + && $fk['foreign_columns'] === ['id'] + && $fk['columns'] === ['item_id'] + )); + + $columns = Schema::getColumns('items'); + + $this->assertTrue(collect($columns)->contains( + fn ($column) => $column['name'] === 'flags' && $column['default'] === 'JSON_ARRAY()' + )); + + $this->assertTrue(collect($columns)->contains(fn ($column) => $column['name'] === 'item_id' && $column['nullable'])); + } +} diff --git a/tests/Integration/Database/Laravel/Sqlite/DatabaseSqliteConnectionTest.php b/tests/Integration/Database/Laravel/Sqlite/DatabaseSqliteConnectionTest.php new file mode 100644 index 000000000..14d665cc5 --- /dev/null +++ b/tests/Integration/Database/Laravel/Sqlite/DatabaseSqliteConnectionTest.php @@ -0,0 +1,72 @@ +set('database.default', 'conn1'); + + $app['config']->set('database.connections.conn1', [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + ]); + } + + protected function afterRefreshingDatabase(): void + { + if (! Schema::hasTable('json_table')) { + Schema::create('json_table', function (Blueprint $table) { + $table->json('json_col')->nullable(); + }); + } + } + + protected function destroyDatabaseMigrations(): void + { + Schema::drop('json_table'); + } + + #[DataProvider('jsonContainsKeyDataProvider')] + public function testWhereJsonContainsKey($count, $column) + { + DB::table('json_table')->insert([ + ['json_col' => '{"foo":{"bar":["baz"]}}'], + ['json_col' => '{"foo":{"bar":false}}'], + ['json_col' => '{"foo":{}}'], + ['json_col' => '{"foo":[{"bar":"bar"},{"baz":"baz"}]}'], + ['json_col' => '{"bar":null}'], + ]); + + $this->assertSame($count, DB::table('json_table')->whereJsonContainsKey($column)->count()); + } + + public static function jsonContainsKeyDataProvider() + { + return [ + 'string key' => [4, 'json_col->foo'], + 'nested key exists' => [2, 'json_col->foo->bar'], + 'string key missing' => [0, 'json_col->none'], + 'integer key with arrow ' => [0, 'json_col->foo->bar->0'], + 'integer key with braces' => [1, 'json_col->foo->bar[0]'], + 'integer key missing' => [0, 'json_col->foo->bar[1]'], + 'mixed keys' => [1, 'json_col->foo[1]->baz'], + 'null value' => [1, 'json_col->bar'], + ]; + } +} diff --git a/tests/Integration/Database/Laravel/Sqlite/DatabaseSqliteSchemaBuilderTest.php b/tests/Integration/Database/Laravel/Sqlite/DatabaseSqliteSchemaBuilderTest.php new file mode 100644 index 000000000..f5839ef46 --- /dev/null +++ b/tests/Integration/Database/Laravel/Sqlite/DatabaseSqliteSchemaBuilderTest.php @@ -0,0 +1,99 @@ +set('database.default', 'conn1'); + + $app['config']->set('database.connections.conn1', [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + ]); + } + + protected function afterRefreshingDatabase(): void + { + Schema::create('users', function (Blueprint $table) { + $table->integer('id'); + $table->string('name'); + $table->string('age'); + $table->enum('color', ['red', 'blue']); + }); + } + + protected function destroyDatabaseMigrations(): void + { + Schema::drop('users'); + } + + public function testGetTablesAndColumnListing() + { + $tables = Schema::getTables(); + + $this->assertCount(2, $tables); + $this->assertEquals(['migrations', 'users'], array_column($tables, 'name')); + + $columns = Schema::getColumnListing('users'); + + foreach (['id', 'name', 'age', 'color'] as $column) { + $this->assertContains($column, $columns); + } + + Schema::create('posts', function (Blueprint $table) { + $table->integer('id'); + $table->string('title'); + }); + $tables = Schema::getTables(); + $this->assertCount(3, $tables); + Schema::drop('posts'); + } + + public function testGetViews() + { + DB::connection('conn1')->statement(<<<'SQL' +CREATE VIEW users_view +AS +SELECT name,age from users; +SQL); + + $tableView = Schema::getViews(); + + $this->assertCount(1, $tableView); + $this->assertEquals('users_view', $tableView[0]['name']); + + DB::connection('conn1')->statement(<<<'SQL' +DROP VIEW IF EXISTS users_view; +SQL); + + $this->assertEmpty(Schema::getViews()); + } + + public function testGetRawIndex() + { + Schema::create('table', function (Blueprint $table) { + $table->id(); + $table->timestamps(); + $table->rawIndex('(strftime("%Y", created_at))', 'table_raw_index'); + }); + + $indexes = Schema::getIndexes('table'); + + $this->assertSame([], collect($indexes)->firstWhere('name', 'table_raw_index')['columns']); + } +} diff --git a/tests/Integration/Database/Laravel/Sqlite/EloquentModelConnectionsTest.php b/tests/Integration/Database/Laravel/Sqlite/EloquentModelConnectionsTest.php new file mode 100644 index 000000000..2df0ac53d --- /dev/null +++ b/tests/Integration/Database/Laravel/Sqlite/EloquentModelConnectionsTest.php @@ -0,0 +1,156 @@ +set('database.default', 'conn1'); + + $app['config']->set('database.connections.conn1', [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + ]); + + $app['config']->set('database.connections.conn2', [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + ]); + } + + protected function defineDatabaseMigrations(): void + { + // Clean up any existing tables from previous tests + Schema::dropIfExists('child'); + Schema::dropIfExists('parent'); + Schema::connection('conn2')->dropIfExists('child'); + Schema::connection('conn2')->dropIfExists('parent'); + + Schema::create('parent', function (Blueprint $table) { + $table->increments('id'); + $table->string('name'); + }); + + Schema::create('child', function (Blueprint $table) { + $table->increments('id'); + $table->string('name'); + $table->integer('parent_id'); + }); + + Schema::connection('conn2')->create('parent', function (Blueprint $table) { + $table->increments('id'); + $table->string('name'); + }); + + Schema::connection('conn2')->create('child', function (Blueprint $table) { + $table->increments('id'); + $table->string('name'); + $table->integer('parent_id'); + }); + } + + public function testChildObeysParentConnection() + { + $parent1 = ParentModel::create(['name' => Str::random()]); + $parent1->children()->create(['name' => 'childOnConn1']); + $parents1 = ParentModel::with('children')->get(); + $this->assertSame('childOnConn1', ChildModel::on('conn1')->first()->name); + $this->assertSame('childOnConn1', $parent1->children()->first()->name); + $this->assertSame('childOnConn1', $parents1[0]->children[0]->name); + + $parent2 = ParentModel::on('conn2')->create(['name' => Str::random()]); + $parent2->children()->create(['name' => 'childOnConn2']); + $parents2 = ParentModel::on('conn2')->with('children')->get(); + $this->assertSame('childOnConn2', ChildModel::on('conn2')->first()->name); + $this->assertSame('childOnConn2', $parent2->children()->first()->name); + $this->assertSame('childOnConn2', $parents2[0]->children[0]->name); + } + + public function testChildUsesItsOwnConnectionIfSet() + { + $parent1 = ParentModel::create(['name' => Str::random()]); + $parent1->childrenDefaultConn2()->create(['name' => 'childAlwaysOnConn2']); + $parents1 = ParentModel::with('childrenDefaultConn2')->get(); + $this->assertSame('childAlwaysOnConn2', ChildModelDefaultConn2::first()->name); + $this->assertSame('childAlwaysOnConn2', $parent1->childrenDefaultConn2()->first()->name); + $this->assertSame('childAlwaysOnConn2', $parents1[0]->childrenDefaultConn2[0]->name); + $this->assertSame('childAlwaysOnConn2', $parents1[0]->childrenDefaultConn2[0]->name); + } + + public function testChildUsesItsOwnConnectionIfSetEvenIfParentExplicitConnection() + { + $parent1 = ParentModel::on('conn1')->create(['name' => Str::random()]); + $parent1->childrenDefaultConn2()->create(['name' => 'childAlwaysOnConn2']); + $parents1 = ParentModel::on('conn1')->with('childrenDefaultConn2')->get(); + $this->assertSame('childAlwaysOnConn2', ChildModelDefaultConn2::first()->name); + $this->assertSame('childAlwaysOnConn2', $parent1->childrenDefaultConn2()->first()->name); + $this->assertSame('childAlwaysOnConn2', $parents1[0]->childrenDefaultConn2[0]->name); + } +} + +class ParentModel extends Model +{ + protected ?string $table = 'parent'; + + public bool $timestamps = false; + + protected array $guarded = []; + + public function children(): HasMany + { + return $this->hasMany(ChildModel::class, 'parent_id'); + } + + public function childrenDefaultConn2(): HasMany + { + return $this->hasMany(ChildModelDefaultConn2::class, 'parent_id'); + } +} + +class ChildModel extends Model +{ + protected ?string $table = 'child'; + + public bool $timestamps = false; + + protected array $guarded = []; + + public function parent(): BelongsTo + { + return $this->belongsTo(ParentModel::class, 'parent_id'); + } +} + +class ChildModelDefaultConn2 extends Model +{ + protected UnitEnum|string|null $connection = 'conn2'; + + protected ?string $table = 'child'; + + public bool $timestamps = false; + + protected array $guarded = []; + + public function parent(): BelongsTo + { + return $this->belongsTo(ParentModel::class, 'parent_id'); + } +} diff --git a/tests/Integration/Database/Laravel/Sqlite/EscapeTest.php b/tests/Integration/Database/Laravel/Sqlite/EscapeTest.php new file mode 100644 index 000000000..410c7be7b --- /dev/null +++ b/tests/Integration/Database/Laravel/Sqlite/EscapeTest.php @@ -0,0 +1,86 @@ +set('database.default', 'conn1'); + + $app['config']->set('database.connections.conn1', [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + ]); + } + + public function testEscapeInt() + { + $this->assertSame('42', $this->app['db']->escape(42)); + $this->assertSame('-6', $this->app['db']->escape(-6)); + } + + public function testEscapeFloat() + { + $this->assertSame('3.14159', $this->app['db']->escape(3.14159)); + $this->assertSame('-3.14159', $this->app['db']->escape(-3.14159)); + } + + public function testEscapeBool() + { + $this->assertSame('1', $this->app['db']->escape(true)); + $this->assertSame('0', $this->app['db']->escape(false)); + } + + public function testEscapeNull() + { + $this->assertSame('null', $this->app['db']->escape(null)); + $this->assertSame('null', $this->app['db']->escape(null, true)); + } + + public function testEscapeBinary() + { + $this->assertSame("x'dead00beef'", $this->app['db']->escape(hex2bin('dead00beef'), true)); + } + + public function testEscapeString() + { + $this->assertSame("'2147483647'", $this->app['db']->escape('2147483647')); + $this->assertSame("'true'", $this->app['db']->escape('true')); + $this->assertSame("'false'", $this->app['db']->escape('false')); + $this->assertSame("'null'", $this->app['db']->escape('null')); + $this->assertSame("'Hello''World'", $this->app['db']->escape("Hello'World")); + } + + public function testEscapeStringInvalidUtf8() + { + $this->expectException(RuntimeException::class); + + $this->app['db']->escape("I am hiding an invalid \x80 utf-8 continuation byte"); + } + + public function testEscapeStringNullByte() + { + $this->expectException(RuntimeException::class); + + $this->app['db']->escape("I am hiding a \00 byte"); + } + + public function testEscapeArray() + { + $this->expectException(RuntimeException::class); + + $this->app['db']->escape(['a', 'b']); + } +} diff --git a/tests/Integration/Database/Laravel/Sqlite/SchemaBuilderSchemaNameTest.php b/tests/Integration/Database/Laravel/Sqlite/SchemaBuilderSchemaNameTest.php new file mode 100644 index 000000000..31a05f800 --- /dev/null +++ b/tests/Integration/Database/Laravel/Sqlite/SchemaBuilderSchemaNameTest.php @@ -0,0 +1,17 @@ +mustRun(); + + // Restore migrations table for parent's migrate:rollback + remote('migrate:install')->mustRun(); + + parent::tearDown(); + } + + #[RequiresOperatingSystem('Linux|Darwin')] + public function testSchemaDumpOnSqlite() + { + if (! is_executable('/usr/bin/sqlite3') && ! shell_exec('which sqlite3')) { + $this->markTestSkipped('sqlite3 CLI tool is not available'); + } + + if ($this->usesSqliteInMemoryDatabaseConnection()) { + $this->markTestSkipped('Test cannot be run using :in-memory: database connection'); + } + + $connection = DB::connection(); + $connection->getSchemaBuilder()->createDatabase($connection->getConfig('database')); + + $connection->statement('CREATE TABLE IF NOT EXISTS migrations (id integer primary key autoincrement not null, migration varchar not null, batch integer not null);'); + $connection->statement('CREATE TABLE users (id integer primary key autoincrement not null, email varchar not null, name varchar not null);'); + $connection->statement('INSERT INTO users (email, name) VALUES ("taylor@laravel.com", "Taylor Otwell");'); + + $this->assertTrue($connection->table('sqlite_sequence')->exists()); + + $this->app['files']->ensureDirectoryExists(database_path('schema')); + + $connection->getSchemaState()->dump($connection, database_path('schema/sqlite-schema.sql')); + + $this->assertFileContains([ + 'CREATE TABLE migrations', + 'CREATE TABLE users', + ], 'database/schema/sqlite-schema.sql'); + $this->assertFileNotContains([ + 'sqlite_sequence', + ], 'database/schema/sqlite-schema.sql'); + } +} diff --git a/tests/Integration/Database/Laravel/Sqlite/SqliteTestCase.php b/tests/Integration/Database/Laravel/Sqlite/SqliteTestCase.php new file mode 100644 index 000000000..357558d1c --- /dev/null +++ b/tests/Integration/Database/Laravel/Sqlite/SqliteTestCase.php @@ -0,0 +1,91 @@ +ensureSqliteDatabaseFileExists(); + + parent::setUp(); + } + + /** + * Check if configured for in-memory SQLite database. + * + * Uses env() so it can be called before the app boots. + */ + protected function isConfiguredForInMemoryDatabase(): bool + { + $path = env('DB_DATABASE', ':memory:'); + + return $path === ':memory:' + || str_contains($path, '?mode=memory') + || str_contains($path, '&mode=memory'); + } + + /** + * Get the configured SQLite database path from env. + * + * Uses env() so it can be called before the app boots. + */ + protected function getConfiguredDatabasePath(): string + { + return env('DB_DATABASE', ':memory:'); + } + + /** + * Ensure the SQLite database file exists before connecting. + * + * The SQLite connector requires the file to exist (it won't auto-create). + * This runs before parent::setUp() which establishes the connection. + */ + protected function ensureSqliteDatabaseFileExists(): void + { + if ($this->isConfiguredForInMemoryDatabase()) { + return; + } + + $path = $this->getConfiguredDatabasePath(); + + if (! file_exists($path)) { + touch($path); + } + } + + /** + * Delete the SQLite database file and its WAL companion files. + * + * Use this when a test needs a completely fresh database file (not just + * fresh tables). The file will be recreated by ensureSqliteDatabaseFileExists() + * in the next test's setUp. + */ + protected function deleteSqliteDatabaseFile(?string $path = null): void + { + $path ??= $this->app->get('config')->get('database.connections.sqlite.database'); + + if ($path === ':memory:' || str_contains($path, 'mode=memory')) { + return; + } + + (new Filesystem())->delete([ + $path, + $path . '-wal', + $path . '-shm', + ]); + } +} diff --git a/tests/Integration/Database/Laravel/TimestampTypeTest.php b/tests/Integration/Database/Laravel/TimestampTypeTest.php new file mode 100644 index 000000000..d0ce05d99 --- /dev/null +++ b/tests/Integration/Database/Laravel/TimestampTypeTest.php @@ -0,0 +1,75 @@ +addColumn('datetime', 'datetime_to_timestamp'); + }); + + Schema::table('test', function (Blueprint $table) { + $table->timestamp('datetime_to_timestamp')->nullable()->change(); + }); + + $this->assertTrue(Schema::hasColumn('test', 'datetime_to_timestamp')); + // Only MySQL, MariaDB, and PostgreSQL actually have a timestamp type + $this->assertSame( + match ($this->driver) { + 'mysql', 'mariadb', 'pgsql' => 'timestamp', + default => 'datetime', + }, + Schema::getColumnType('test', 'datetime_to_timestamp') + ); + } + + public function testChangeTimestampColumnToDatetimeColumn() + { + Schema::create('test', function (Blueprint $table) { + $table->addColumn('timestamp', 'timestamp_to_datetime'); + }); + + Schema::table('test', function (Blueprint $table) { + $table->dateTime('timestamp_to_datetime')->nullable()->change(); + }); + + $this->assertTrue(Schema::hasColumn('test', 'timestamp_to_datetime')); + // Postgres only has a timestamp type + $this->assertSame( + match ($this->driver) { + 'pgsql' => 'timestamp', + default => 'datetime', + }, + Schema::getColumnType('test', 'timestamp_to_datetime') + ); + } + + #[RequiresDatabase(['mysql', 'mariadb'])] + public function testChangeStringColumnToTimestampColumn() + { + Schema::create('test', function (Blueprint $table) { + $table->string('string_to_timestamp'); + }); + + $blueprint = new Blueprint($this->getConnection(), 'test', function ($table) { + $table->timestamp('string_to_timestamp')->nullable()->change(); + }); + + $expected = ['alter table `test` modify `string_to_timestamp` timestamp null']; + + $this->assertEquals($expected, $blueprint->toSql()); + } +} diff --git a/tests/Integration/Database/Laravel/Todo/DatabaseCacheStoreTest.php b/tests/Integration/Database/Laravel/Todo/DatabaseCacheStoreTest.php new file mode 100644 index 000000000..54540b006 --- /dev/null +++ b/tests/Integration/Database/Laravel/Todo/DatabaseCacheStoreTest.php @@ -0,0 +1,296 @@ +markTestSkipped('Port after cache package is fully ported (missing forgetIfExpired, getConnection methods).'); + } + + public function testValueCanStoreNewCache(): void + { + $store = $this->getStore(); + + $store->put('foo', 'bar', 60); + + $this->assertSame('bar', $store->get('foo')); + } + + public function testPutOperationShouldNotStoreExpired(): void + { + $store = $this->getStore(); + + $store->put('foo', 'bar', 0); + + $this->assertDatabaseMissing($this->getCacheTableName(), ['key' => $this->withCachePrefix('foo')]); + } + + public function testValueCanUpdateExistCache(): void + { + $store = $this->getStore(); + + $store->put('foo', 'bar', 60); + $store->put('foo', 'new-bar', 60); + + $this->assertSame('new-bar', $store->get('foo')); + } + + public function testValueCanUpdateExistCacheInTransaction(): void + { + $store = $this->getStore(); + + $store->put('foo', 'bar', 60); + + DB::beginTransaction(); + $store->put('foo', 'new-bar', 60); + DB::commit(); + + $this->assertSame('new-bar', $store->get('foo')); + } + + public function testAddOperationShouldNotStoreExpired(): void + { + $store = $this->getStore(); + + $result = $store->add('foo', 'bar', 0); + + $this->assertFalse($result); + $this->assertDatabaseMissing($this->getCacheTableName(), ['key' => $this->withCachePrefix('foo')]); + } + + public function testAddOperationCanStoreNewCache(): void + { + $store = $this->getStore(); + + $result = $store->add('foo', 'bar', 60); + + $this->assertTrue($result); + $this->assertSame('bar', $store->get('foo')); + } + + public function testAddOperationShouldNotUpdateExistCache() + { + $store = $this->getStore(); + + $store->add('foo', 'bar', 60); + $result = $store->add('foo', 'new-bar', 60); + + $this->assertFalse($result); + $this->assertSame('bar', $store->get('foo')); + } + + public function testAddOperationShouldNotUpdateExistCacheInTransaction() + { + $store = $this->getStore(); + + $store->add('foo', 'bar', 60); + + DB::beginTransaction(); + $result = $store->add('foo', 'new-bar', 60); + DB::commit(); + + $this->assertFalse($result); + $this->assertSame('bar', $store->get('foo')); + } + + public function testAddOperationCanUpdateIfCacheExpired() + { + $store = $this->getStore(); + + $this->insertToCacheTable('foo', 'bar', 0); + $result = $store->add('foo', 'new-bar', 60); + + $this->assertTrue($result); + $this->assertSame('new-bar', $store->get('foo')); + } + + public function testAddOperationCanUpdateIfCacheExpiredInTransaction() + { + $store = $this->getStore(); + + $this->insertToCacheTable('foo', 'bar', 0); + + DB::beginTransaction(); + $result = $store->add('foo', 'new-bar', 60); + DB::commit(); + + $this->assertTrue($result); + $this->assertSame('new-bar', $store->get('foo')); + } + + public function testGetOperationReturnNullIfExpired() + { + $store = $this->getStore(); + + $this->insertToCacheTable('foo', 'bar', 0); + + $result = $store->get('foo'); + + $this->assertNull($result); + } + + public function testGetOperationCanDeleteExpired() + { + $store = $this->getStore(); + + $this->insertToCacheTable('foo', 'bar', 0); + + $store->get('foo'); + + $this->assertDatabaseMissing($this->getCacheTableName(), ['key' => $this->withCachePrefix('foo')]); + } + + public function testForgetIfExpiredOperationCanDeleteExpired() + { + $store = $this->getStore(); + + $this->insertToCacheTable('foo', 'bar', 0); + + $store->forgetIfExpired('foo'); + + $this->assertDatabaseMissing($this->getCacheTableName(), ['key' => $this->withCachePrefix('foo')]); + } + + public function testForgetIfExpiredOperationShouldNotDeleteUnExpired() + { + $store = $this->getStore(); + + $store->put('foo', 'bar', 60); + + $store->forgetIfExpired('foo'); + + $this->assertDatabaseHas($this->getCacheTableName(), ['key' => $this->withCachePrefix('foo')]); + } + + public function testMany() + { + $this->insertToCacheTable('first', 'a', 60); + $this->insertToCacheTable('second', 'b', 60); + + $store = $this->getStore(); + + $this->assertEquals([ + 'first' => 'a', + 'second' => 'b', + 'third' => null, + ], $store->get(['first', 'second', 'third'])); + + $this->assertEquals([ + 'first' => 'a', + 'second' => 'b', + 'third' => null, + ], $store->many(['first', 'second', 'third'])); + } + + public function testManyWithExpiredKeys() + { + $this->insertToCacheTable('first', 'a', 0); + $this->insertToCacheTable('second', 'b', 60); + + $this->assertEquals([ + 'first' => null, + 'second' => 'b', + 'third' => null, + ], $this->getStore()->many(['first', 'second', 'third'])); + + $this->assertDatabaseMissing($this->getCacheTableName(), ['key' => $this->withCachePrefix('first')]); + } + + public function testManyAsAssociativeArray() + { + $this->insertToCacheTable('first', 'cached', 60); + + $result = $this->getStore()->many([ + 'first' => 'aa', + 'second' => 'bb', + 'third', + ]); + + $this->assertEquals([ + 'first' => 'cached', + 'second' => 'bb', + 'third' => null, + ], $result); + } + + public function testPutMany() + { + $store = $this->getStore(); + + $store->putMany($data = [ + 'first' => 'a', + 'second' => 'b', + ], 60); + + $this->assertEquals($data, $store->many(['first', 'second'])); + $this->assertDatabaseHas($this->getCacheTableName(), [ + 'key' => $this->withCachePrefix('first'), + 'value' => serialize('a'), + ]); + $this->assertDatabaseHas($this->getCacheTableName(), [ + 'key' => $this->withCachePrefix('second'), + 'value' => serialize('b'), + ]); + } + + public function testResolvingSQLiteConnectionDoesNotThrowExceptions() + { + $originalConfiguration = config('database'); + + app('config')->set('database.default', 'sqlite'); + app('config')->set('database.connections.sqlite.database', __DIR__ . '/non-existing-file'); + + $store = $this->getStore(); + $this->assertInstanceOf(SQLiteConnection::class, $store->getConnection()); + + app('config')->set('database', $originalConfiguration); + } + + /** + * @return \Hypervel\Cache\DatabaseStore + */ + protected function getStore() + { + return Cache::store('database'); + } + + protected function getCacheTableName() + { + return config('cache.stores.database.table'); + } + + protected function withCachePrefix(string $key) + { + return config('cache.prefix') . $key; + } + + protected function insertToCacheTable(string $key, $value, $ttl = 60) + { + DB::table($this->getCacheTableName()) + ->insert( + [ + 'key' => $this->withCachePrefix($key), + 'value' => serialize($value), + 'expiration' => Carbon::now()->addSeconds($ttl)->getTimestamp(), + ] + ); + } +} diff --git a/tests/Integration/Database/Laravel/Todo/DatabaseLockTest.php b/tests/Integration/Database/Laravel/Todo/DatabaseLockTest.php new file mode 100644 index 000000000..cf94c3e6e --- /dev/null +++ b/tests/Integration/Database/Laravel/Todo/DatabaseLockTest.php @@ -0,0 +1,124 @@ +markTestSkipped('Port after cache package is fully ported (missing isOwnedBy, isOwnedByCurrentProcess, getConnectionName methods on Lock/DatabaseLock).'); + } + + public function testLockCanHaveASeparateConnection() + { + $this->app['config']->set('cache.stores.database.lock_connection', 'test'); + $this->app['config']->set('database.connections.test', $this->app['config']->get('database.connections.mysql')); + + $this->assertSame('test', Cache::driver('database')->lock('foo')->getConnectionName()); + } + + public function testLockCanBeAcquired() + { + $lock = Cache::driver('database')->lock('foo'); + $this->assertTrue($lock->get()); + + $otherLock = Cache::driver('database')->lock('foo'); + $this->assertFalse($otherLock->get()); + + $lock->release(); + + $otherLock = Cache::driver('database')->lock('foo'); + $this->assertTrue($otherLock->get()); + + $otherLock->release(); + } + + public function testLockCanBeForceReleased() + { + $lock = Cache::driver('database')->lock('foo'); + $this->assertTrue($lock->get()); + + $otherLock = Cache::driver('database')->lock('foo'); + $otherLock->forceRelease(); + $this->assertTrue($otherLock->get()); + + $otherLock->release(); + } + + public function testExpiredLockCanBeRetrieved() + { + $lock = Cache::driver('database')->lock('foo'); + $this->assertTrue($lock->get()); + DB::table('cache_locks')->update(['expiration' => now()->subDays(1)->getTimestamp()]); + + $otherLock = Cache::driver('database')->lock('foo'); + $this->assertTrue($otherLock->get()); + + $otherLock->release(); + } + + public function testOtherOwnerDoesNotOwnLockAfterRestore() + { + $firstLock = Cache::store('database')->lock('foo'); + $this->assertTrue($firstLock->isOwnedBy(null)); + $this->assertTrue($firstLock->get()); + $this->assertTrue($firstLock->isOwnedBy($firstLock->owner())); + + $secondLock = Cache::store('database')->restoreLock('foo', 'other_owner'); + $this->assertTrue($secondLock->isOwnedBy($firstLock->owner())); + $this->assertFalse($secondLock->isOwnedByCurrentProcess()); + } + + #[TestWith(['Deadlock found when trying to get lock', 1213, true])] + #[TestWith(['Table does not exist', 1146, false])] + public function testIgnoresConcurrencyException(string $message, int $code, bool $hasConcurrenyError) + { + $connection = m::mock(Connection::class); + $insertBuilder = m::mock(Builder::class); + $deleteBuilder = m::mock(Builder::class); + + $insertBuilder->shouldReceive('insert')->once()->andReturn(true); + + $deleteBuilder->shouldReceive('where')->with('expiration', '<=', m::any())->once()->andReturnSelf(); + $deleteBuilder->shouldReceive('delete')->once()->andThrow( + new QueryException( + 'mysql', + 'delete from cache_locks where expiration <= ?', + [], + new PDOException($message, $code) + ) + ); + + $connection->shouldReceive('table')->with('cache_locks')->andReturn($insertBuilder, $deleteBuilder); + + $lock = new DatabaseLock($connection, 'cache_locks', 'foo', 0, lottery: [1, 1]); + + if ($hasConcurrenyError) { + $this->assertTrue($lock->acquire()); + } else { + $this->expectException(QueryException::class); + $this->assertFalse($lock->acquire()); + } + } +} diff --git a/tests/Integration/Database/Laravel/stubs/2014_10_12_000000_create_members_table.php b/tests/Integration/Database/Laravel/stubs/2014_10_12_000000_create_members_table.php new file mode 100644 index 000000000..890a4db42 --- /dev/null +++ b/tests/Integration/Database/Laravel/stubs/2014_10_12_000000_create_members_table.php @@ -0,0 +1,33 @@ +increments('id'); + $table->string('name'); + $table->string('email')->unique(); + $table->string('password'); + $table->rememberToken(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down() + { + Schema::drop('members'); + } +} diff --git a/tests/Integration/Database/Laravel/stubs/2014_10_13_000000_skipped_migration.php b/tests/Integration/Database/Laravel/stubs/2014_10_13_000000_skipped_migration.php new file mode 100644 index 000000000..a4b39ffa2 --- /dev/null +++ b/tests/Integration/Database/Laravel/stubs/2014_10_13_000000_skipped_migration.php @@ -0,0 +1,32 @@ +id(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('skipped_table'); + } +}; 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..3b07490fb --- /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/InMemorySqliteSharedPdoTest.php b/tests/Integration/Database/Sqlite/InMemorySqliteSharedPdoTest.php new file mode 100644 index 000000000..a55edbb1c --- /dev/null +++ b/tests/Integration/Database/Sqlite/InMemorySqliteSharedPdoTest.php @@ -0,0 +1,487 @@ +configureInMemoryDatabase(); + + // Suppress expected log output from reconnect tests + $config = $this->app->get('config'); + $config->set(StdoutLoggerInterface::class . '.log_level', []); + } + + protected function configureInMemoryDatabase(): void + { + $config = $this->app->get('config'); + + $this->app->set('db.connector.sqlite', new SQLiteConnector()); + + $connectionConfig = [ + 'driver' => 'sqlite', + 'database' => ':memory:', + '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('database.connections.memory_test', $connectionConfig); + } + + protected function getPoolFactory(): PoolFactory + { + return $this->app->get(PoolFactory::class); + } + + // ========================================================================= + // DbPool::isInMemorySqlite() detection tests + // ========================================================================= + + /** + * @dataProvider inMemoryDatabaseProvider + */ + public function testIsInMemorySqliteDetection(string $database, bool $expected): void + { + $config = $this->app->get('config'); + + $connectionConfig = [ + 'driver' => 'sqlite', + 'database' => $database, + 'prefix' => '', + 'pool' => [ + 'min_connections' => 1, + 'max_connections' => 2, + ], + ]; + + $configKey = 'in_memory_test_' . md5($database); + $config->set("database.connections.{$configKey}", $connectionConfig); + + $factory = $this->getPoolFactory(); + $pool = $factory->getPool($configKey); + + // Use reflection to test the protected method + $method = new ReflectionMethod(DbPool::class, 'isInMemorySqlite'); + + $this->assertSame($expected, $method->invoke($pool)); + + // Cleanup + $factory->flushPool($configKey); + } + + 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 testNonSqliteDriverIsNotInMemorySqlite(): void + { + $config = $this->app->get('config'); + + $connectionConfig = [ + 'driver' => 'mysql', + 'host' => 'localhost', + 'database' => ':memory:', // Even with :memory: database name + 'prefix' => '', + 'pool' => [ + 'min_connections' => 1, + 'max_connections' => 2, + ], + ]; + + $config->set('database.connections.mysql_memory_test', $connectionConfig); + + $factory = $this->getPoolFactory(); + $pool = $factory->getPool('mysql_memory_test'); + + $method = new ReflectionMethod(DbPool::class, 'isInMemorySqlite'); + + $this->assertFalse($method->invoke($pool)); + + $factory->flushPool('mysql_memory_test'); + } + + // ========================================================================= + // Shared PDO tests + // ========================================================================= + + public function testInMemorySqlitePoolHasSharedPdo(): void + { + $factory = $this->getPoolFactory(); + $pool = $factory->getPool('memory_test'); + + $sharedPdo = $pool->getSharedInMemorySqlitePdo(); + + $this->assertInstanceOf(PDO::class, $sharedPdo); + } + + public function testFileSqlitePoolDoesNotHaveSharedPdo(): void + { + $config = $this->app->get('config'); + + $tempFile = sys_get_temp_dir() . '/test_no_shared_pdo.db'; + @touch($tempFile); + + try { + $connectionConfig = [ + 'driver' => 'sqlite', + 'database' => $tempFile, + 'prefix' => '', + 'pool' => [ + 'min_connections' => 1, + 'max_connections' => 2, + ], + ]; + + $config->set('database.connections.file_sqlite_test', $connectionConfig); + + $factory = $this->getPoolFactory(); + $pool = $factory->getPool('file_sqlite_test'); + + $this->assertNull($pool->getSharedInMemorySqlitePdo()); + + $factory->flushPool('file_sqlite_test'); + } finally { + @unlink($tempFile); + } + } + + public function testAllPoolSlotsShareSamePdoForInMemorySqlite(): void + { + $factory = $this->getPoolFactory(); + $pool = $factory->getPool('memory_test'); + + run(function () use ($pool) { + // Get multiple pooled connections + $pooled1 = $pool->get(); + $pooled2 = $pool->get(); + + $connection1 = $pooled1->getConnection(); + $connection2 = $pooled2->getConnection(); + + // Both connections should have the same underlying PDO + $pdo1 = $connection1->getPdo(); + $pdo2 = $connection2->getPdo(); + + $this->assertSame($pdo1, $pdo2, 'All pool slots should share the same PDO for in-memory SQLite'); + + $pooled1->release(); + $pooled2->release(); + }); + } + + public function testSharedPdoMaintainsDataAcrossPoolSlots(): void + { + $factory = $this->getPoolFactory(); + $pool = $factory->getPool('memory_test'); + + run(function () use ($pool) { + // Create table and insert data using first connection + $pooled1 = $pool->get(); + $connection1 = $pooled1->getConnection(); + + $connection1->statement('CREATE TABLE IF NOT EXISTS shared_test (id INTEGER PRIMARY KEY, name TEXT)'); + $connection1->statement("INSERT INTO shared_test (name) VALUES ('test_value')"); + + $pooled1->release(); + + // Verify data is visible from second connection + $pooled2 = $pool->get(); + $connection2 = $pooled2->getConnection(); + + $result = $connection2->selectOne('SELECT name FROM shared_test WHERE id = 1'); + + $this->assertNotNull($result); + $this->assertEquals('test_value', $result->name); + + $pooled2->release(); + }); + } + + public function testFlushAllClearsSharedPdo(): void + { + $factory = $this->getPoolFactory(); + $pool = $factory->getPool('memory_test'); + + // Verify shared PDO exists + $this->assertInstanceOf(PDO::class, $pool->getSharedInMemorySqlitePdo()); + + // Flush the pool + $pool->flushAll(); + + // Shared PDO should be cleared + $this->assertNull($pool->getSharedInMemorySqlitePdo()); + } + + // ========================================================================= + // ConnectionFactory::makeSqliteFromSharedPdo() tests + // ========================================================================= + + public function testMakeSqliteFromSharedPdoCreatesConnectionWithProvidedPdo(): void + { + $factory = $this->app->get(ConnectionFactory::class); + + // Create a PDO manually + $pdo = new PDO('sqlite::memory:'); + + $config = [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => 'test_', + ]; + + $connection = $factory->makeSqliteFromSharedPdo($pdo, $config, 'test_connection'); + + $this->assertInstanceOf(Connection::class, $connection); + $this->assertSame($pdo, $connection->getPdo()); + $this->assertEquals('test_', $connection->getTablePrefix()); + $this->assertEquals('test_connection', $connection->getName()); + } + + public function testMakeSqliteFromSharedPdoUsesWriteConfigWhenReadWritePresent(): void + { + $factory = $this->app->get(ConnectionFactory::class); + + $pdo = new PDO('sqlite::memory:'); + + $config = [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + 'read' => [ + 'prefix' => 'read_', + ], + 'write' => [ + 'prefix' => 'write_', + ], + ]; + + $connection = $factory->makeSqliteFromSharedPdo($pdo, $config, 'rw_test'); + + // Should use write config's prefix + $this->assertEquals('write_', $connection->getTablePrefix()); + } + + // ========================================================================= + // PooledConnection behavior with shared PDO + // ========================================================================= + + public function testPooledConnectionCloseDoesNotDisconnectSharedPdo(): void + { + $factory = $this->getPoolFactory(); + $pool = $factory->getPool('memory_test'); + + run(function () use ($pool) { + $sharedPdo = $pool->getSharedInMemorySqlitePdo(); + + // Create table using the shared PDO directly + $sharedPdo->exec('CREATE TABLE IF NOT EXISTS close_test (id INTEGER PRIMARY KEY)'); + $sharedPdo->exec('INSERT INTO close_test (id) VALUES (1)'); + + // Get a pooled connection + $pooled = $pool->get(); + $connection = $pooled->getConnection(); + + // Verify we can see the data + $result = $connection->selectOne('SELECT id FROM close_test WHERE id = 1'); + $this->assertNotNull($result); + + // Close the pooled connection (should NOT disconnect the shared PDO) + $pooled->close(); + + // The shared PDO should still be functional + // Get another pooled connection and verify data still exists + $pooled2 = $pool->get(); + $connection2 = $pooled2->getConnection(); + + $result2 = $connection2->selectOne('SELECT id FROM close_test WHERE id = 1'); + $this->assertNotNull($result2, 'Data should still exist because shared PDO was not disconnected'); + + $pooled2->release(); + }); + } + + public function testPooledConnectionRefreshRebindsToSharedPdo(): void + { + $factory = $this->getPoolFactory(); + $pool = $factory->getPool('memory_test'); + + run(function () use ($pool) { + $sharedPdo = $pool->getSharedInMemorySqlitePdo(); + + // Create table and data + $sharedPdo->exec('CREATE TABLE IF NOT EXISTS refresh_test (id INTEGER PRIMARY KEY, value TEXT)'); + $sharedPdo->exec("INSERT INTO refresh_test (id, value) VALUES (1, 'original')"); + + $pooled = $pool->get(); + $connection = $pooled->getConnection(); + + // Trigger a refresh via the reconnector + // The refresh() method should rebind to the same shared PDO, not create a fresh one + $connection->reconnect(); + + // After refresh, we should still see the same data (same PDO) + $result = $connection->selectOne('SELECT value FROM refresh_test WHERE id = 1'); + $this->assertNotNull($result); + $this->assertEquals('original', $result->value); + + // Verify PDO is still the shared one + $this->assertSame($sharedPdo, $connection->getPdo()); + + $pooled->release(); + }); + } + + public function testReconnectUsesSharedPdoForInMemorySqlite(): void + { + $factory = $this->getPoolFactory(); + $pool = $factory->getPool('memory_test'); + + run(function () use ($pool) { + $sharedPdo = $pool->getSharedInMemorySqlitePdo(); + + $pooled = $pool->get(); + $connection = $pooled->getConnection(); + + // Connection should be using the shared PDO + $this->assertSame($sharedPdo, $connection->getPdo()); + + $pooled->release(); + }); + } + + // ========================================================================= + // Capsule isolation tests - verifies Capsule does NOT use shared PDO + // ========================================================================= + + public function testCapsuleConnectionsAreIsolatedFromPooledConnections(): void + { + // First, create data via pooled connection + $factory = $this->getPoolFactory(); + $pool = $factory->getPool('memory_test'); + + run(function () use ($pool) { + $pooled = $pool->get(); + $connection = $pooled->getConnection(); + + $connection->statement('CREATE TABLE IF NOT EXISTS capsule_isolation_test (id INTEGER PRIMARY KEY, source TEXT)'); + $connection->statement("INSERT INTO capsule_isolation_test (source) VALUES ('pooled')"); + + $pooled->release(); + }); + + // Now create a Capsule instance - it should have its own isolated database + $capsule = new \Hypervel\Database\Capsule\Manager(); + $capsule->addConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + ]); + + $capsuleConnection = $capsule->getConnection(); + + // Capsule should NOT see the data from pooled connection (different PDO) + // This query should fail because the table doesn't exist in Capsule's database + $tables = $capsuleConnection->select("SELECT name FROM sqlite_master WHERE type='table' AND name='capsule_isolation_test'"); + + $this->assertEmpty($tables, 'Capsule should have its own isolated in-memory database, not sharing with pool'); + } + + public function testMultipleCapsuleInstancesAreIsolatedFromEachOther(): void + { + // Create first Capsule and add data + $capsule1 = new \Hypervel\Database\Capsule\Manager(); + $capsule1->addConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + ]); + + $connection1 = $capsule1->getConnection(); + $connection1->statement('CREATE TABLE test_table (id INTEGER PRIMARY KEY, value TEXT)'); + $connection1->statement("INSERT INTO test_table (value) VALUES ('capsule1_data')"); + + // Create second Capsule - should be completely isolated + $capsule2 = new \Hypervel\Database\Capsule\Manager(); + $capsule2->addConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + ]); + + $connection2 = $capsule2->getConnection(); + + // Capsule2 should NOT see the table from Capsule1 + $tables = $connection2->select("SELECT name FROM sqlite_master WHERE type='table' AND name='test_table'"); + + $this->assertEmpty($tables, 'Each Capsule instance should have its own isolated in-memory database'); + + // Verify Capsule1 still has its data + $result = $connection1->selectOne('SELECT value FROM test_table WHERE id = 1'); + $this->assertEquals('capsule1_data', $result->value); + } + + public function testCapsuleConnectionsGetFreshPdoEachTime(): void + { + $capsule1 = new \Hypervel\Database\Capsule\Manager(); + $capsule1->addConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + ]); + + $capsule2 = new \Hypervel\Database\Capsule\Manager(); + $capsule2->addConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + ]); + + $pdo1 = $capsule1->getConnection()->getPdo(); + $pdo2 = $capsule2->getConnection()->getPdo(); + + // Each Capsule should have a different PDO instance + $this->assertNotSame($pdo1, $pdo2, 'Each Capsule instance should have its own PDO'); + } +} diff --git a/tests/Integration/Database/Sqlite/PoolConnectionManagementTest.php b/tests/Integration/Database/Sqlite/PoolConnectionManagementTest.php new file mode 100644 index 000000000..e01580d69 --- /dev/null +++ b/tests/Integration/Database/Sqlite/PoolConnectionManagementTest.php @@ -0,0 +1,484 @@ +configureDatabase(); + $this->createTestTable(); + + // Suppress expected error logs from transaction rollback tests + $config = $this->app->get('config'); + $config->set('Hyperf\Contract\StdoutLoggerInterface.log_level', []); + } + + protected function configureDatabase(): void + { + $config = $this->app->get('config'); + + $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('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..e0418f075 --- /dev/null +++ b/tests/Integration/Database/Sqlite/SQLiteFilePoolingTest.php @@ -0,0 +1,390 @@ +configureDatabase(); + $this->createTestTable(); + } + + protected function configureDatabase(): void + { + $config = $this->app->get('config'); + + $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('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..b709a35e9 --- /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/Integration/Engine/ClientTest.php b/tests/Integration/Engine/ClientTest.php new file mode 100644 index 000000000..455a4966c --- /dev/null +++ b/tests/Integration/Engine/ClientTest.php @@ -0,0 +1,143 @@ +getServerHost(), $this->getServerPort()); + $response = $client->request('GET', '/'); + $this->assertSame(200, $response->statusCode); + $this->assertSame(['Hyperf'], $response->headers['server']); + $this->assertSame('Hello World.', $response->body); + } + + public function testClientSocketConnectionRefused() + { + try { + // Use a port that definitely has no server running + $client = new Client('127.0.0.1', 29501); + $client->request('GET', '/timeout?time=1'); + $this->fail('Expected HttpClientException to be thrown'); + } catch (Throwable $exception) { + $this->assertInstanceOf(HttpClientException::class, $exception); + $this->assertSame(SOCKET_ECONNREFUSED, $exception->getCode()); + $this->assertSame('Connection refused', $exception->getMessage()); + } + } + + public function testClientJsonRequest() + { + $client = new Client($this->getServerHost(), $this->getServerPort()); + $response = $client->request( + 'POST', + '/', + ['Content-Type' => 'application/json charset=UTF-8'], + json_encode(['name' => 'Hyperf'], JSON_UNESCAPED_UNICODE) + ); + $this->assertSame(200, $response->statusCode); + $this->assertSame(['Hyperf'], $response->headers['server']); + $this->assertSame('Hello World.', $response->body); + } + + public function testClientSocketConnectionTimeout() + { + try { + $client = new Client($this->getServerHost(), $this->getServerPort()); + $client->set(['timeout' => 0.1]); + $client->request('GET', '/timeout?time=1'); + $this->fail('Expected HttpClientException to be thrown'); + } catch (Throwable $exception) { + $this->assertInstanceOf(HttpClientException::class, $exception); + $this->assertSame(SOCKET_ETIMEDOUT, $exception->getCode()); + $this->assertStringContainsString('timed out', $exception->getMessage()); + } + } + + public function testClientCookies() + { + $client = new Client($this->getServerHost(), $this->getServerPort()); + $response = $client->request('GET', '/cookies'); + $this->assertSame(200, $response->statusCode); + $this->assertSame(['Hyperf'], $response->headers['server']); + $this->assertSame([ + 'X-Server-Id=' . $response->body, + 'X-Server-Name=Hyperf', + ], $response->headers['set-cookie']); + } + + public function testGuzzleClientWithCookies() + { + $client = new GuzzleHttp\Client([ + 'base_uri' => sprintf('http://%s:%d/', $this->getServerHost(), $this->getServerPort()), + 'handler' => GuzzleHttp\HandlerStack::create(new CoroutineHandler()), + 'cookies' => true, + ]); + + $response = $client->get('cookies'); + + $cookies = $client->getConfig('cookies'); + + $this->assertSame((string) $response->getBody(), $cookies->toArray()[0]['Value']); + $this->assertSame('Hyperf', $cookies->toArray()[1]['Value']); + } + + public function testServerHeaders() + { + $client = new Client($this->getServerHost(), $this->getServerPort()); + $response = $client->request('GET', '/header'); + if (SWOOLE_VERSION_ID >= 60000) { + $this->assertSame($response->body, $response->headers['x-id'][1]); + } else { + // Co Client won't support getting multi response headers. + $this->assertSame($response->body, implode(',', $response->headers['x-id'])); + } + + $client = new GuzzleHttp\Client([ + 'base_uri' => sprintf('http://%s:%d/', $this->getServerHost(), $this->getServerPort()), + 'handler' => GuzzleHttp\HandlerStack::create(new CoroutineHandler()), + ]); + + $response = $client->get('/header'); + if (SWOOLE_VERSION_ID >= 60000) { + $this->assertSame((string) $response->getBody(), $response->getHeader('x-id')[1]); + } else { + // Co Client Won't support to get multi response headers. + $this->assertSame((string) $response->getBody(), $response->getHeaderLine('x-id')); + } + + // When Swoole version > 4.5, The native curl support to get multi response headers. + if (SWOOLE_VERSION_ID >= 40600) { + $client = new GuzzleHttp\Client([ + 'base_uri' => sprintf('http://%s:%d/', $this->getServerHost(), $this->getServerPort()), + ]); + $response = $client->get('/header'); + $this->assertSame(2, count($response->getHeader('x-id'))); + $this->assertSame((string) $response->getBody(), $response->getHeader('x-id')[1]); + } + } + + public function testClientNotFound() + { + $client = new Client($this->getServerHost(), $this->getServerPort()); + $response = $client->request('GET', '/not_found'); + $this->assertSame(404, $response->statusCode); + } +} diff --git a/tests/Integration/Engine/EngineIntegrationTestCase.php b/tests/Integration/Engine/EngineIntegrationTestCase.php new file mode 100644 index 000000000..a93f4ea83 --- /dev/null +++ b/tests/Integration/Engine/EngineIntegrationTestCase.php @@ -0,0 +1,27 @@ +setUpInteractsWithServer(); + } +} diff --git a/tests/Integration/Engine/Http2ClientTest.php b/tests/Integration/Engine/Http2ClientTest.php new file mode 100644 index 000000000..6c964092e --- /dev/null +++ b/tests/Integration/Engine/Http2ClientTest.php @@ -0,0 +1,42 @@ +getServerHost(), $this->getServerPort()); + $client->send(new Request('/')); + $response = $client->recv(1); + $this->assertSame('Hello World.', $response->getBody()); + + $client->send(new Request('/header')); + $response = $client->recv(1); + $id = $response->getHeaders()['x-id']; + $this->assertSame($id, $response->getBody()); + + $client->send(new Request('/not-found')); + $response = $client->recv(1); + $this->assertSame(404, $response->getStatusCode()); + + $this->assertTrue($client->isConnected()); + + $client->close(); + + $this->assertFalse($client->isConnected()); + } +} diff --git a/tests/Integration/Engine/HttpServerTest.php b/tests/Integration/Engine/HttpServerTest.php new file mode 100644 index 000000000..6328e341d --- /dev/null +++ b/tests/Integration/Engine/HttpServerTest.php @@ -0,0 +1,69 @@ +getServerHost(), $this->getServerPort()); + $response = $client->request('GET', '/'); + $this->assertSame(200, $response->statusCode); + $this->assertSame('Hello World.', $response->body); + } + + public function testHttpServerReceived() + { + $client = new Client($this->getServerHost(), $this->getServerPort()); + $response = $client->request('POST', '/', contents: 'Hyperf'); + $this->assertSame(200, $response->statusCode); + $this->assertSame('Received: Hyperf', $response->body); + } + + public function testHttpServerCookies() + { + $client = new Client($this->getServerHost(), $this->getServerPort()); + + $client->setCookies(['key' => 'value']); + + $response = $client->request('POST', '/set-cookies', ['user_id' => uniqid()], Json::encode(['id' => $id = uniqid()])); + $this->assertSame(200, $response->statusCode); + $this->assertSame(1, count($response->getHeaders()['set-cookie'])); + $this->assertStringStartsWith('id=' . $id, $response->getHeaders()['set-cookie'][0]); + $json = Json::decode((string) $response->getBody()); + $this->assertSame(['key' => 'value'], $json); + + $response = $client->request('POST', '/set-cookies', [], Json::encode(['id2' => $id2 = uniqid()])); + $this->assertSame(200, $response->statusCode); + $this->assertSame(1, count($response->getHeaders()['set-cookie'])); + $this->assertStringStartsWith('id2=' . $id2, $response->getHeaders()['set-cookie'][0]); + $json = Json::decode((string) $response->getBody()); + $this->assertSame(['key' => 'value', 'id' => $id], $json); + + $client->setCookies([]); + $response = $client->request('POST', '/set-cookies', [], Json::encode(['id2' => $id2 = uniqid()])); + $this->assertSame(200, $response->statusCode); + $this->assertSame(1, count($response->getHeaders()['set-cookie'])); + $this->assertStringStartsWith('id2=' . $id2, $response->getHeaders()['set-cookie'][0]); + $json = Json::decode((string) $response->getBody()); + $this->assertSame([], $json); + } +} diff --git a/tests/Integration/Engine/SocketTest.php b/tests/Integration/Engine/SocketTest.php new file mode 100644 index 000000000..a654abf95 --- /dev/null +++ b/tests/Integration/Engine/SocketTest.php @@ -0,0 +1,83 @@ +setProtocol([ + 'open_length_check' => true, + 'package_max_length' => 1024 * 1024 * 2, + 'package_length_type' => 'N', + 'package_length_offset' => 0, + 'package_body_offset' => 4, + ]); + $socket->connect($this->getServerHost(), $this->getServerPort()); + $socket->sendAll(pack('N', 4) . 'ping'); + $this->assertSame('pong', substr($socket->recvPacket(), 4)); + + $id = uniqid(); + $socket->sendAll(pack('N', strlen($id)) . $id); + $this->assertSame('recv:' . $id, substr($socket->recvPacket(), 4)); + } + + public function testSocketRecvPacketFromTcpServerViaFactory() + { + $socket = (new Socket\SocketFactory())->make(new Socket\SocketOption( + $this->getServerHost(), + $this->getServerPort(), + protocol: [ + 'open_length_check' => true, + 'package_max_length' => 1024 * 1024 * 2, + 'package_length_type' => 'N', + 'package_length_offset' => 0, + 'package_body_offset' => 4, + ] + )); + $socket->sendAll(pack('N', 4) . 'ping'); + $this->assertSame('pong', substr($socket->recvPacket(), 4)); + + $id = uniqid(); + $socket->sendAll(pack('N', strlen($id)) . $id); + $this->assertSame('recv:' . $id, substr($socket->recvPacket(), 4)); + } + + public function testSocketRecvAllFromTcpServer() + { + $socket = new Socket(AF_INET, SOCK_STREAM, 0); + $socket->connect($this->getServerHost(), $this->getServerPort()); + $socket->sendAll(pack('N', 4) . 'ping'); + $res = $socket->recvAll(4); + $this->assertSame(4, unpack('Nlen', $res)['len']); + $res = $socket->recvAll(4); + $this->assertSame('pong', $res); + + $id = str_repeat(uniqid(), rand(1, 10)); + $socket->sendAll(pack('N', $len = strlen($id)) . $id); + $res = $socket->recvAll(4); + $len += 5; + $this->assertSame($len, unpack('Nlen', $res)['len']); + $res = $socket->recvAll($len); + $this->assertSame('recv:' . $id, $res); + } +} diff --git a/tests/Integration/Engine/WebSocketTest.php b/tests/Integration/Engine/WebSocketTest.php new file mode 100644 index 000000000..c4d4e74fe --- /dev/null +++ b/tests/Integration/Engine/WebSocketTest.php @@ -0,0 +1,45 @@ +getServerHost(), $this->getServerPort(), false); + $client->set(['open_websocket_pong_frame' => true]); + $client->upgrade('/'); + + $client->push('Hello World!', Opcode::TEXT); + $ret = $client->recv(1); + $this->assertInstanceOf(SwooleFrame::class, $ret); + $this->assertSame('received: Hello World!', $ret->data); + $this->assertSame(Opcode::TEXT, $ret->opcode); + + if (SWOOLE_VERSION_ID > 60102) { + $client->push('', Opcode::PING); + $ret = $client->recv(1); + $this->assertInstanceOf(SwooleFrame::class, $ret); + $this->assertSame(Opcode::PONG, $ret->opcode); + } + } +} diff --git a/tests/Integration/Guzzle/GuzzleIntegrationTestCase.php b/tests/Integration/Guzzle/GuzzleIntegrationTestCase.php new file mode 100644 index 000000000..e10a75875 --- /dev/null +++ b/tests/Integration/Guzzle/GuzzleIntegrationTestCase.php @@ -0,0 +1,27 @@ +setUpInteractsWithServer(); + } +} diff --git a/tests/Integration/Guzzle/PoolHandlerTest.php b/tests/Integration/Guzzle/PoolHandlerTest.php new file mode 100644 index 000000000..8591b151e --- /dev/null +++ b/tests/Integration/Guzzle/PoolHandlerTest.php @@ -0,0 +1,106 @@ +get(); + + $this->assertSame(2, $this->id); + } + + /** + * Test that the pool handler reuses connections. + * + * Makes two requests and verifies the handler only creates one client, + * proving that connections are being pooled and reused. + */ + public function testPoolHandler() + { + $container = $this->getContainer(); + $client = new Client([ + 'handler' => $handler = new PoolHandlerStub($container->get(PoolFactory::class), []), + 'base_uri' => sprintf('http://%s:%d', $this->getServerHost(), $this->getServerPort()), + ]); + + $response = $client->get('/'); + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('Hello World.', (string) $response->getBody()); + $this->assertSame(1, $handler->count); + + // Second request should reuse the pooled connection + $client->get('/'); + $this->assertSame(1, $handler->count); + } + + protected function get(): void + { + try { + $this->id = 1; + return; + } finally { + $this->id = 2; + } + } + + /** + * Create a mock container with pool dependencies. + */ + protected function getContainer(): Container + { + $container = m::mock(Container::class); + $container->shouldReceive('make')->with(PoolOption::class, m::andAnyOtherArgs())->andReturnUsing(function ($_, $args) { + return new PoolOption(...array_values($args)); + }); + $container->shouldReceive('make')->with(Pool::class, m::andAnyOtherArgs())->andReturnUsing(function ($_, $args) use ($container) { + return new Pool($container, $args['callback'], $args['option']); + }); + $container->shouldReceive('get')->with(PoolFactory::class)->andReturnUsing(function () use ($container) { + return new PoolFactory($container); + }); + $container->shouldReceive('make')->with(Channel::class, m::any())->andReturnUsing(function ($_, $args) { + return new Channel($args['size']); + }); + $container->shouldReceive('make')->with(Connection::class, m::any())->andReturnUsing(function ($_, $args) use ($container) { + return new Connection($container, $args['pool'], $args['callback']); + }); + $container->shouldReceive('has')->with(StdoutLoggerInterface::class)->andReturnFalse(); + $container->shouldReceive('has')->with(EventDispatcherInterface::class)->andReturnFalse(); + + ApplicationContext::setContainer($container); + + return $container; + } +} diff --git a/tests/Integration/Guzzle/Stub/PoolHandlerStub.php b/tests/Integration/Guzzle/Stub/PoolHandlerStub.php new file mode 100644 index 000000000..755823a0d --- /dev/null +++ b/tests/Integration/Guzzle/Stub/PoolHandlerStub.php @@ -0,0 +1,20 @@ +count; + + return parent::makeClient($host, $port, $ssl); + } +} diff --git a/tests/Redis/Integration/EvalWithShaCacheIntegrationTest.php b/tests/Integration/Redis/EvalWithShaCacheIntegrationTest.php similarity index 96% rename from tests/Redis/Integration/EvalWithShaCacheIntegrationTest.php rename to tests/Integration/Redis/EvalWithShaCacheIntegrationTest.php index d2ff750a8..587739b5e 100644 --- a/tests/Redis/Integration/EvalWithShaCacheIntegrationTest.php +++ b/tests/Integration/Redis/EvalWithShaCacheIntegrationTest.php @@ -2,10 +2,9 @@ declare(strict_types=1); -namespace Hypervel\Tests\Redis\Integration; +namespace Hypervel\Tests\Integration\Redis; -use Hyperf\Contract\ConfigInterface; -use Hypervel\Foundation\Contracts\Application as ApplicationContract; +use Hypervel\Contracts\Foundation\Application as ApplicationContract; use Hypervel\Foundation\Testing\Concerns\InteractsWithRedis; use Hypervel\Foundation\Testing\Concerns\RunTestsInCoroutine; use Hypervel\Redis\Exceptions\LuaScriptException; @@ -33,7 +32,7 @@ class EvalWithShaCacheIntegrationTest extends TestCase protected function defineEnvironment(ApplicationContract $app): void { - $config = $app->get(ConfigInterface::class); + $config = $app->get('config'); $this->configureRedisForTesting($config); } diff --git a/tests/Integration/Redis/RedisConnectionIntegrationTest.php b/tests/Integration/Redis/RedisConnectionIntegrationTest.php new file mode 100644 index 000000000..8bc0084e5 --- /dev/null +++ b/tests/Integration/Redis/RedisConnectionIntegrationTest.php @@ -0,0 +1,82 @@ +get('config'); + $this->configureRedisForTesting($config); + } + + protected function setUp(): void + { + parent::setUp(); + + $this->isOlderThan6 = version_compare((string) phpversion('redis'), '6.0.0', '<'); + } + + public function testPhpRedisConnectSignatureAndConnection(): void + { + $redis = new Redis(); + $reflection = new ReflectionClass($redis); + $parameters = $reflection->getMethod('connect')->getParameters(); + + $this->assertSame('host', $parameters[0]->getName()); + $this->assertSame('port', $parameters[1]->getName()); + $this->assertSame('timeout', $parameters[2]->getName()); + + if ($this->isOlderThan6) { + $this->assertSame('retry_interval', $parameters[3]->getName()); + } else { + $this->assertSame('persistent_id', $parameters[3]->getName()); + } + + $connected = $redis->connect( + env('REDIS_HOST', '127.0.0.1'), + (int) env('REDIS_PORT', 6379), + 0.0, + null, + 0, + 0, + ); + + $this->assertTrue($connected); + + $auth = env('REDIS_AUTH', null); + if (is_string($auth) && $auth !== '') { + $this->assertTrue($redis->auth($auth)); + } + + $this->assertTrue( + $redis->select((int) env('REDIS_DB', $this->redisTestDatabase)) + ); + + $ping = $redis->ping(); + $this->assertTrue($ping === true || str_contains((string) $ping, 'PONG')); + + $redis->close(); + } +} diff --git a/tests/Integration/Redis/RedisProxyIntegrationTest.php b/tests/Integration/Redis/RedisProxyIntegrationTest.php new file mode 100644 index 000000000..eba202fa4 --- /dev/null +++ b/tests/Integration/Redis/RedisProxyIntegrationTest.php @@ -0,0 +1,607 @@ +get('config'); + $this->configureRedisForTesting($config); + } + + public function testRedisOptionPrefix(): void + { + $prefixedName = $this->createRedisConnectionWithPrefix('test:'); + $plainName = $this->createRedisConnectionWithPrefix(''); + + $prefixed = Redis::connection($prefixedName); + $plain = Redis::connection($plainName); + + $prefixed->flushdb(); + $prefixed->set('test', 'yyy'); + + $this->assertSame('yyy', $prefixed->get('test')); + $this->assertSame('yyy', $plain->get('test:test')); + } + + public function testRedisOptionSerializer(): void + { + $serializedName = $this->createRedisConnectionWithOptions( + name: 'test_serializer', + options: [ + 'prefix' => '', + 'serializer' => PhpRedis::SERIALIZER_PHP, + ], + ); + $plainName = $this->createRedisConnectionWithOptions( + name: 'test_plain', + options: ['prefix' => ''], + ); + + $serialized = Redis::connection($serializedName); + $plain = Redis::connection($plainName); + + $serialized->flushdb(); + $serialized->set('test', 'yyy'); + + $this->assertSame('yyy', $serialized->get('test')); + $this->assertSame('s:3:"yyy";', $plain->get('test')); + } + + public function testHyperLogLog(): void + { + $redis = Redis::connection($this->createRedisConnectionWithPrefix('')); + $redis->flushdb(); + + $result = $redis->pfAdd('test:hyperloglog', ['123', 'fff']); + $this->assertSame(1, $result); + $result = $redis->pfAdd('test:hyperloglog', ['123']); + $this->assertSame(0, $result); + + $this->assertSame(2, $redis->pfCount('test:hyperloglog')); + $redis->pfAdd('test:hyperloglog2', [1234]); + $redis->pfMerge('test:hyperloglog2', ['test:hyperloglog']); + $this->assertSame(3, $redis->pfCount('test:hyperloglog2')); + $this->assertFalse($redis->pfAdd('test:hyperloglog3', [])); + } + + public function testZSetAddAnd(): void + { + $redis = Redis::connection($this->createRedisConnectionWithPrefix('')); + $redis->flushdb(); + + $key = 'test:zset:add:remove'; + + $redis->zAdd($key, microtime(true) * 1000 + 100, 'test'); + usleep(1_000); + + $result = $redis->zRangeByScore($key, '0', (string) (microtime(true) * 1000)); + $this->assertEmpty($result); + } + + public function testPipelineReturnsNativeRedisInstanceAndExecutesCallback(): void + { + $redis = Redis::connection($this->createRedisConnectionWithPrefix('')); + $redis->flushdb(); + + $pipeline = $redis->pipeline(); + $this->assertInstanceOf(PhpRedis::class, $pipeline); + + $key = 'pipeline:' . uniqid(); + $results = $redis->pipeline(function (PhpRedis $pipe) use ($key) { + $pipe->incr($key); + $pipe->incr($key); + $pipe->incr($key); + }); + + $this->assertSame([1, 2, 3], $results); + $this->assertSame('3', $redis->get($key)); + } + + public function testTransactionReturnsNativeRedisInstanceAndExecutesCallback(): void + { + $redis = Redis::connection($this->createRedisConnectionWithPrefix('')); + $redis->flushdb(); + + $transaction = $redis->transaction(); + $this->assertInstanceOf(PhpRedis::class, $transaction); + + $key = 'transaction:' . uniqid(); + $results = $redis->transaction(function (PhpRedis $tx) use ($key) { + $tx->incr($key); + $tx->incr($key); + $tx->incr($key); + }); + + $this->assertSame([1, 2, 3], $results); + $this->assertSame('3', $redis->get($key)); + } + + public function testScanReturnsCursorAndKeysTuple(): void + { + $redis = Redis::connection($this->createRedisConnectionWithPrefix('')); + $redis->flushdb(); + + $expected = ['scan:1', 'scan:2', 'scan:3', 'scan:4']; + foreach ($expected as $value) { + $redis->set($value, '1'); + } + + $cursor = null; + $collected = []; + while (($chunk = $redis->scan($cursor, 'scan:*', 2)) !== false) { + [$cursor, $keys] = $chunk; + $collected = array_merge($collected, $keys); + } + + $collected = array_values(array_unique($collected)); + sort($collected); + + $this->assertSame($expected, $collected); + $this->assertSame(0, $cursor); + } + + public function testHscanReturnsCursorAndFieldMapTuple(): void + { + $redis = Redis::connection($this->createRedisConnectionWithPrefix('')); + $redis->flushdb(); + + $expected = ['scan:1', 'scan:2', 'scan:3', 'scan:4']; + foreach ($expected as $value) { + $redis->hSet('scaner', $value, '1'); + } + + $cursor = null; + $fields = []; + while (($chunk = $redis->hscan('scaner', $cursor, 'scan:*', 2)) !== false) { + [$cursor, $map] = $chunk; + $fields = array_merge($fields, array_keys($map)); + } + + $fields = array_values(array_unique($fields)); + sort($fields); + + $this->assertSame($expected, $fields); + $this->assertSame(0, $cursor); + } + + public function testSscanReturnsCursorAndMembersTuple(): void + { + $redis = Redis::connection($this->createRedisConnectionWithPrefix('')); + $redis->flushdb(); + + $expected = ['member:1', 'member:2', 'member:3', 'member:4']; + foreach ($expected as $member) { + $redis->sAdd('scanset', $member); + } + + $cursor = null; + $collected = []; + while (($chunk = $redis->sscan('scanset', $cursor, 'member:*', 2)) !== false) { + [$cursor, $members] = $chunk; + $collected = array_merge($collected, $members); + } + + $collected = array_values(array_unique($collected)); + sort($collected); + + $this->assertSame($expected, $collected); + $this->assertSame(0, $cursor); + } + + public function testZscanReturnsCursorAndScoreMapTuple(): void + { + $redis = Redis::connection($this->createRedisConnectionWithPrefix('')); + $redis->flushdb(); + + $members = ['zmem:1' => 1.0, 'zmem:2' => 2.0, 'zmem:3' => 3.0, 'zmem:4' => 4.0]; + foreach ($members as $member => $score) { + $redis->zadd('scanzset', $score, $member); + } + + $cursor = null; + $collected = []; + while (($chunk = $redis->zscan('scanzset', $cursor, 'zmem:*', 2)) !== false) { + [$cursor, $map] = $chunk; + foreach ($map as $member => $score) { + $collected[$member] = $score; + } + } + + ksort($collected); + + $this->assertSame($members, $collected); + $this->assertSame(0, $cursor); + } + + public function testRedisPipelineConcurrentExecs(): void + { + $redis = Redis::connection($this->createRedisConnectionWithPrefix('')); + $redis->flushdb(); + + $redis->rPush('pipeline:list', 'A'); + $redis->rPush('pipeline:list', 'B'); + $redis->rPush('pipeline:list', 'C'); + $redis->rPush('pipeline:list', 'D'); + $redis->rPush('pipeline:list', 'E'); + + $first = new Channel(1); + $second = new Channel(1); + + go(static function () use ($redis, $first) { + $redis->pipeline(); + usleep(2_000); + $redis->lRange('pipeline:list', 0, 1); + $redis->lTrim('pipeline:list', 2, -1); + usleep(1_000); + $first->push($redis->exec()); + }); + + go(static function () use ($redis, $second) { + $redis->pipeline(); + usleep(1_000); + $redis->lRange('pipeline:list', 0, 1); + $redis->lTrim('pipeline:list', 2, -1); + usleep(20_000); + $second->push($redis->exec()); + }); + + $this->assertSame([['A', 'B'], true], $first->pop()); + $this->assertSame([['C', 'D'], true], $second->pop()); + } + + public function testPipelineCallbackAndSelect(): void + { + $redis = Redis::connection($this->createRedisConnectionWithPrefix('')); + $redis->flushdb(); + + $redis->select(1); + $redis->set('concurrent_pipeline_test_callback_and_select_value', $id = uniqid(), 'EX', 600); + + $key = 'concurrent_pipeline_test_callback_and_select'; + $results = $redis->pipeline(function (PhpRedis $pipe) use ($key) { + $pipe->set($key, "value_{$key}"); + $pipe->incr("{$key}_counter"); + $pipe->get($key); + $pipe->get("{$key}_counter"); + }); + + $this->assertCount(4, $results); + $this->assertSame($id, $redis->get('concurrent_pipeline_test_callback_and_select_value')); + } + + public function testPipelineCallbackAndPipeline(): void + { + $redis = Redis::connection($this->createRedisConnectionWithPrefix('')); + $redis->flushdb(); + + $openPipeline = $redis->pipeline(); + // This uses integer expiry while a pipeline is open to assert queue-mode bypasses transformed callSet(). + $redis->set('concurrent_pipeline_test_callback_and_select_value', $id = uniqid(), 600); + + $key = 'concurrent_pipeline_test_callback_and_select'; + $callbackResults = $redis->pipeline(function (PhpRedis $pipe) use ($key) { + $pipe->set($key, "value_{$key}"); + $pipe->incr("{$key}_counter"); + $pipe->get($key); + $pipe->get("{$key}_counter"); + }); + + go(static function () use ($redis) { + $redis->select(1); + $redis->set('xxx', 'x'); + $redis->set('xxx', 'x'); + $redis->set('xxx', 'x'); + }); + + $openPipeline->set('xxxxxx', 'x'); + $openPipeline->set('xxxxxx', 'x'); + $openPipeline->set('xxxxxx', 'x'); + $openPipeline->set('xxxxxx', 'x'); + + $this->assertInstanceOf(PhpRedis::class, $openPipeline); + // The pre-callback set() is queued on the open pipeline connection, so callback exec includes 5 queued results. + $this->assertCount(5, $callbackResults); + $this->assertSame($id, $redis->get('concurrent_pipeline_test_callback_and_select_value')); + } + + public function testSelectIsolationAcrossCoroutines(): void + { + $redis = Redis::connection($this->createRedisConnectionWithPrefix('')); + $redis->flushdb(); + + $uniqueKey = 'select_isolation_' . uniqid(); + + $channelA = new Channel(1); + $channelB = new Channel(1); + + // Coroutine A: select db 1, set a key + go(static function () use ($redis, $uniqueKey, $channelA) { + $redis->select(1); + $redis->set($uniqueKey, 'from_db1'); + $channelA->push($redis->get($uniqueKey)); + }); + + // Coroutine B: stays on default db 0, should NOT see the key + go(static function () use ($redis, $uniqueKey, $channelB) { + // Small delay to let coroutine A execute first + usleep(5_000); + $channelB->push($redis->get($uniqueKey)); + }); + + // Coroutine A should see its key on db 1 + $this->assertSame('from_db1', $channelA->pop()); + + // Coroutine B should NOT see the key (it's on db 0) + $this->assertNull($channelB->pop()); + + // Clean up db 1 + $redis->select(1); + $redis->del($uniqueKey); + } + + public function testPipelineCallbackRunsCommands(): void + { + $redis = Redis::connection($this->createRedisConnectionWithPrefix('')); + $redis->flushdb(); + + $key = 'pipeline:' . uniqid(); + + $results = $redis->pipeline(function (PhpRedis $pipeline) use ($key) { + $pipeline->incr($key); + $pipeline->incr($key); + $pipeline->incr($key); + }); + + $this->assertSame([1, 2, 3], $results); + $this->assertSame('3', $redis->get($key)); + } + + public function testTransactionCallbackRunsCommands(): void + { + $redis = Redis::connection($this->createRedisConnectionWithPrefix('')); + $redis->flushdb(); + + $key = 'transaction:' . uniqid(); + + $results = $redis->transaction(function (PhpRedis $transaction) use ($key) { + $transaction->incr($key); + $transaction->incr($key); + $transaction->incr($key); + }); + + $this->assertSame([1, 2, 3], $results); + $this->assertSame('3', $redis->get($key)); + } + + public function testWithConnectionTransformFalseSupportsPipelineCallbacks(): void + { + $redis = Redis::connection($this->createRedisConnectionWithPrefix('')); + $redis->flushdb(); + + $key = 'pipeline:transform_off:' . uniqid(); + $results = $redis->withConnection(function (RedisConnection $connection) use ($key) { + $connection->pipeline(); + $connection->set($key, 'value', 600); + $connection->get($key); + + return $connection->exec(); + }, transform: false); + + $this->assertIsArray($results); + $this->assertCount(2, $results); + $this->assertSame('value', $redis->get($key)); + } + + public function testWithConnectionTransformFalseSupportsTransactionCallbacks(): void + { + $redis = Redis::connection($this->createRedisConnectionWithPrefix('')); + $redis->flushdb(); + + $key = 'transaction:transform_off:' . uniqid(); + $results = $redis->withConnection(function (RedisConnection $connection) use ($key) { + $connection->multi(); + $connection->set($key, '0', 600); + $connection->incr($key); + + return $connection->exec(); + }, transform: false); + + $this->assertIsArray($results); + $this->assertCount(2, $results); + $this->assertSame('1', $redis->get($key)); + } + + public function testConcurrentPipelineCallbacksWithLimitedConnectionPool(): void + { + $redis = Redis::connection($this->createRedisConnectionWithOptions( + name: 'test_concurrent_pipeline_callbacks', + options: ['prefix' => ''], + maxConnections: 3, + )); + $redis->flushdb(); + + $concurrentOperations = 20; + $channels = []; + + for ($i = 0; $i < $concurrentOperations; ++$i) { + $channels[$i] = new Channel(1); + } + + for ($i = 0; $i < $concurrentOperations; ++$i) { + go(function () use ($redis, $channels, $i) { + try { + $key = "concurrent_pipeline_test_{$i}"; + + $results = $redis->pipeline(function (PhpRedis $pipe) use ($key) { + $pipe->set($key, "value_{$key}"); + $pipe->incr("{$key}_counter"); + $pipe->get($key); + $pipe->get("{$key}_counter"); + }); + + sleep(1); + + $this->assertCount(4, $results); + $this->assertTrue($results[0]); + $this->assertSame(1, $results[1]); + $this->assertSame("value_{$key}", $results[2]); + $this->assertSame('1', $results[3]); + + $channels[$i]->push(['success' => true, 'operation' => 'pipeline']); + } catch (Throwable $exception) { + $channels[$i]->push(['success' => false, 'error' => $exception->getMessage()]); + } + }); + } + + $successCount = 0; + for ($i = 0; $i < $concurrentOperations; ++$i) { + $result = $channels[$i]->pop(10.0); + $this->assertNotFalse($result, "Operation {$i} timed out - possible connection pool exhaustion"); + + if ($result['success']) { + ++$successCount; + } else { + $this->fail("Concurrent operation {$i} failed: " . $result['error']); + } + } + + $this->assertSame( + $concurrentOperations, + $successCount, + "All {$concurrentOperations} concurrent pipeline operations should succeed with only 3 max connections", + ); + + for ($i = 0; $i < $concurrentOperations; ++$i) { + $redis->del("concurrent_pipeline_test_{$i}"); + $redis->del("concurrent_pipeline_test_{$i}_counter"); + } + } + + public function testConcurrentTransactionCallbacksWithLimitedConnectionPool(): void + { + $redis = Redis::connection($this->createRedisConnectionWithOptions( + name: 'test_concurrent_transaction_callbacks', + options: ['prefix' => ''], + maxConnections: 3, + )); + $redis->flushdb(); + + $concurrentOperations = 20; + $channels = []; + + for ($i = 0; $i < $concurrentOperations; ++$i) { + $channels[$i] = new Channel(1); + } + + for ($i = 0; $i < $concurrentOperations; ++$i) { + go(function () use ($redis, $channels, $i) { + try { + $key = "concurrent_transaction_test_{$i}"; + + $results = $redis->transaction(function (PhpRedis $transaction) use ($key) { + $transaction->set($key, "tx_value_{$key}"); + $transaction->incr("{$key}_counter"); + $transaction->get($key); + }); + + sleep(1); + + $this->assertCount(3, $results); + $this->assertTrue($results[0]); + $this->assertSame(1, $results[1]); + $this->assertSame("tx_value_{$key}", $results[2]); + + $channels[$i]->push(['success' => true, 'operation' => 'transaction']); + } catch (Throwable $exception) { + $channels[$i]->push(['success' => false, 'error' => $exception->getMessage()]); + } + }); + } + + $successCount = 0; + for ($i = 0; $i < $concurrentOperations; ++$i) { + $result = $channels[$i]->pop(10.0); + $this->assertNotFalse($result, "Transaction operation {$i} timed out - possible connection pool exhaustion"); + + if ($result['success']) { + ++$successCount; + } else { + $this->fail("Concurrent transaction {$i} failed: " . $result['error']); + } + } + + $this->assertSame( + $concurrentOperations, + $successCount, + "All {$concurrentOperations} concurrent transaction operations should succeed with only 3 max connections", + ); + + for ($i = 0; $i < $concurrentOperations; ++$i) { + $redis->del("concurrent_transaction_test_{$i}"); + $redis->del("concurrent_transaction_test_{$i}_counter"); + } + } + + /** + * Create a Redis connection with custom options for integration assertions. + * + * @param array $options + */ + private function createRedisConnectionWithOptions(string $name, array $options, int $maxConnections = 10): string + { + $config = $this->app->get('config'); + + if ($config->get("database.redis.{$name}") !== null) { + return $name; + } + + $config->set("database.redis.{$name}", [ + 'host' => env('REDIS_HOST', '127.0.0.1'), + 'auth' => env('REDIS_AUTH', null) ?: null, + 'port' => (int) env('REDIS_PORT', 6379), + 'db' => (int) env('REDIS_DB', $this->redisTestDatabase), + 'pool' => [ + 'min_connections' => 1, + 'max_connections' => $maxConnections, + 'connect_timeout' => 10.0, + 'wait_timeout' => 3.0, + 'heartbeat' => -1, + 'max_idle_time' => 60.0, + ], + 'options' => $options, + ]); + + // RedisFactory snapshots pools at construction, so reset after adding runtime test connections. + $this->app->forgetInstance(RedisFactory::class); + + return $name; + } +} diff --git a/tests/Integration/Redis/RedisSubscribeIntegrationTest.php b/tests/Integration/Redis/RedisSubscribeIntegrationTest.php new file mode 100644 index 000000000..8648c932b --- /dev/null +++ b/tests/Integration/Redis/RedisSubscribeIntegrationTest.php @@ -0,0 +1,207 @@ +get('config'); + $this->configureRedisForTesting($config); + $this->connectionName = $this->createRedisConnectionWithPrefix(''); + } + + public function testSubscribeExitsCleanlyWithNoMessages() + { + $channelName = 'test_redis_noop_' . uniqid(); + $subscribed = new Channel(1); + + go(function () use ($channelName, $subscribed) { + Redis::connection($this->connectionName)->subscribe([$channelName], function () use ($subscribed) { + $subscribed->push(true); + }); + }); + + // Wait for the subscriber to be established, then let the test end. + // The WORKER_EXIT shutdown watcher should cleanly interrupt the + // subscriber — without it, the orphaned socket recv coroutine + // would block Swoole's run() indefinitely. + usleep(100_000); + + // No message published — subscriber received nothing. + // If we reach this assertion, the test didn't hang. + $this->assertTrue(true); + } + + public function testSubscribeReceivesMessageViaCallback() + { + $channelName = 'test_redis_sub_' . uniqid(); + $resultChannel = new Channel(1); + + go(function () use ($channelName, $resultChannel) { + Redis::connection($this->connectionName)->subscribe([$channelName], function ($message, $channel) use ($resultChannel) { + $resultChannel->push(['message' => $message, 'channel' => $channel]); + }); + }); + + usleep(100_000); + + $this->publishViaRawClient($channelName, 'hello_world'); + + $result = $resultChannel->pop(5.0); + $this->assertNotFalse($result, 'Subscribe timed out waiting for message'); + $this->assertSame('hello_world', $result['message']); + $this->assertSame($channelName, $result['channel']); + } + + public function testPsubscribeReceivesMessageViaCallback() + { + $pattern = 'test_redis_psub_' . uniqid() . ':*'; + $publishChannel = str_replace('*', 'specific', $pattern); + $resultChannel = new Channel(1); + + go(function () use ($pattern, $resultChannel) { + Redis::connection($this->connectionName)->psubscribe([$pattern], function ($message, $channel) use ($resultChannel) { + $resultChannel->push(['message' => $message, 'channel' => $channel]); + }); + }); + + usleep(100_000); + + $this->publishViaRawClient($publishChannel, 'pattern_data'); + + $result = $resultChannel->pop(5.0); + $this->assertNotFalse($result, 'Psubscribe timed out waiting for message'); + $this->assertSame('pattern_data', $result['message']); + $this->assertSame($publishChannel, $result['channel']); + } + + public function testSubscribeAcceptsStringChannel() + { + $channelName = 'test_redis_string_' . uniqid(); + $resultChannel = new Channel(1); + + go(function () use ($channelName, $resultChannel) { + Redis::connection($this->connectionName)->subscribe($channelName, function ($message, $channel) use ($resultChannel) { + $resultChannel->push(['message' => $message, 'channel' => $channel]); + }); + }); + + usleep(100_000); + + $this->publishViaRawClient($channelName, 'string_arg'); + + $result = $resultChannel->pop(5.0); + $this->assertNotFalse($result, 'String channel subscribe timed out'); + $this->assertSame('string_arg', $result['message']); + } + + public function testSubscriberReturnsChannelBasedApi() + { + $channelName = 'test_redis_subscriber_' . uniqid(); + $subscriber = Redis::connection($this->connectionName)->subscriber(); + + $subscriber->subscribe($channelName); + + go(function () use ($channelName) { + usleep(50_000); + $this->publishViaRawClient($channelName, 'channel_api'); + }); + + $message = $subscriber->channel()->pop(5.0); + + $this->assertInstanceOf(Message::class, $message); + $this->assertSame($channelName, $message->channel); + $this->assertSame('channel_api', $message->payload); + + $subscriber->close(); + } + + public function testSubscriberWithPrefix() + { + $prefix = 'myprefix:'; + $connectionName = $this->createRedisConnectionWithPrefix($prefix); + $channelName = 'test_redis_prefixed_' . uniqid(); + $subscriber = Redis::connection($connectionName)->subscriber(); + + $subscriber->subscribe($channelName); + + go(function () use ($channelName, $prefix) { + usleep(50_000); + // Publish to the full prefixed channel name + $this->publishViaRawClient($prefix . $channelName, 'prefixed_data'); + }); + + $message = $subscriber->channel()->pop(5.0); + + $this->assertInstanceOf(Message::class, $message); + $this->assertSame($prefix . $channelName, $message->channel); + $this->assertSame('prefixed_data', $message->payload); + + $subscriber->close(); + } + + /** + * Publish a message using a raw phpredis client (separate connection). + */ + private function publishViaRawClient(string $channel, string $message): void + { + $client = new \Redis(); + $client->connect( + env('REDIS_HOST', '127.0.0.1'), + (int) env('REDIS_PORT', 6379) + ); + + $auth = env('REDIS_AUTH'); + if ($auth) { + $client->auth($auth); + } + + $client->publish($channel, $message); + $client->close(); + } +} diff --git a/tests/Integration/Redis/SafeScanIntegrationTest.php b/tests/Integration/Redis/SafeScanIntegrationTest.php new file mode 100644 index 000000000..ebbb80bf5 --- /dev/null +++ b/tests/Integration/Redis/SafeScanIntegrationTest.php @@ -0,0 +1,206 @@ +get('config'); + $this->configureRedisForTesting($config); + } + + public function testSafeScanYieldsKeysWithoutPrefix() + { + $prefix = 'safescan_test:'; + $connectionName = $this->createRedisConnectionWithPrefix($prefix); + $redis = Redis::connection($connectionName); + $redis->flushdb(); + + // Create keys via the prefixed connection + $redis->set('key1', 'val1'); + $redis->set('key2', 'val2'); + $redis->set('key3', 'val3'); + + // safeScan should yield keys WITHOUT the prefix + $keys = $redis->withConnection(function (RedisConnection $connection) { + return iterator_to_array($connection->safeScan('key*')); + }, transform: false); + + sort($keys); + + $this->assertSame(['key1', 'key2', 'key3'], $keys); + + // Verify these keys work with get() (which auto-adds prefix) + $this->assertSame('val1', $redis->get('key1')); + } + + public function testSafeScanWithoutPrefix() + { + $connectionName = $this->createRedisConnectionWithPrefix(''); + $redis = Redis::connection($connectionName); + $redis->flushdb(); + + $redis->set('noprefix:1', 'a'); + $redis->set('noprefix:2', 'b'); + $redis->set('other:1', 'c'); + + $keys = $redis->withConnection(function (RedisConnection $connection) { + return iterator_to_array($connection->safeScan('noprefix:*')); + }, transform: false); + + sort($keys); + + $this->assertSame(['noprefix:1', 'noprefix:2'], $keys); + } + + public function testSafeScanMatchesPatternOnly() + { + $prefix = 'pattern_test:'; + $connectionName = $this->createRedisConnectionWithPrefix($prefix); + $redis = Redis::connection($connectionName); + $redis->flushdb(); + + $redis->set('cache:user:1', 'u1'); + $redis->set('cache:user:2', 'u2'); + $redis->set('cache:post:1', 'p1'); + $redis->set('session:1', 's1'); + + $keys = $redis->withConnection(function (RedisConnection $connection) { + return iterator_to_array($connection->safeScan('cache:user:*')); + }, transform: false); + + sort($keys); + + $this->assertSame(['cache:user:1', 'cache:user:2'], $keys); + } + + public function testFlushByPatternDeletesMatchingKeys() + { + $connectionName = $this->createRedisConnectionWithPrefix(''); + $redis = Redis::connection($connectionName); + $redis->flushdb(); + + // Create keys: some match pattern, some don't + $redis->set('flush:match:1', 'a'); + $redis->set('flush:match:2', 'b'); + $redis->set('flush:match:3', 'c'); + $redis->set('flush:keep:1', 'x'); + $redis->set('flush:keep:2', 'y'); + + $deleted = $redis->withConnection(function (RedisConnection $connection) { + return $connection->flushByPattern('flush:match:*'); + }, transform: false); + + $this->assertSame(3, $deleted); + + // Matching keys should be gone + $this->assertNull($redis->get('flush:match:1')); + $this->assertNull($redis->get('flush:match:2')); + $this->assertNull($redis->get('flush:match:3')); + + // Non-matching keys should remain + $this->assertSame('x', $redis->get('flush:keep:1')); + $this->assertSame('y', $redis->get('flush:keep:2')); + } + + public function testFlushByPatternReturnsDeletedCount() + { + $connectionName = $this->createRedisConnectionWithPrefix(''); + $redis = Redis::connection($connectionName); + $redis->flushdb(); + + for ($i = 0; $i < 15; ++$i) { + $redis->set("count:key:{$i}", "val{$i}"); + } + + $deleted = $redis->withConnection(function (RedisConnection $connection) { + return $connection->flushByPattern('count:key:*'); + }, transform: false); + + $this->assertSame(15, $deleted); + } + + public function testFlushByPatternReturnsZeroWhenNoKeysMatch() + { + $connectionName = $this->createRedisConnectionWithPrefix(''); + $redis = Redis::connection($connectionName); + $redis->flushdb(); + + $deleted = $redis->withConnection(function (RedisConnection $connection) { + return $connection->flushByPattern('nonexistent:*'); + }, transform: false); + + $this->assertSame(0, $deleted); + } + + public function testFlushByPatternWithPrefixHandlesDoublePrefix() + { + $prefix = 'flushprefix:'; + $connectionName = $this->createRedisConnectionWithPrefix($prefix); + $redis = Redis::connection($connectionName); + $redis->flushdb(); + + // Create keys via prefixed connection (stored as "flushprefix:cache:1" in Redis) + $redis->set('cache:1', 'a'); + $redis->set('cache:2', 'b'); + $redis->set('other:1', 'c'); + + // flushByPattern should handle OPT_PREFIX correctly — no double prefix + $deleted = $redis->withConnection(function (RedisConnection $connection) { + return $connection->flushByPattern('cache:*'); + }, transform: false); + + $this->assertSame(2, $deleted); + + // Verify matching keys are gone + $this->assertNull($redis->get('cache:1')); + $this->assertNull($redis->get('cache:2')); + + // Verify non-matching key remains + $this->assertSame('c', $redis->get('other:1')); + } + + public function testFlushByPatternViaRedisFacade() + { + $connectionName = $this->createRedisConnectionWithPrefix(''); + $redis = Redis::connection($connectionName); + $redis->flushdb(); + + $redis->set('facade:flush:1', 'a'); + $redis->set('facade:flush:2', 'b'); + $redis->set('facade:keep:1', 'c'); + + // Redis::flushByPattern() handles connection lifecycle automatically + $deleted = $redis->flushByPattern('facade:flush:*'); + + $this->assertSame(2, $deleted); + $this->assertNull($redis->get('facade:flush:1')); + $this->assertSame('c', $redis->get('facade:keep:1')); + } +} diff --git a/tests/Integration/Redis/Subscriber/SubscriberIntegrationTest.php b/tests/Integration/Redis/Subscriber/SubscriberIntegrationTest.php new file mode 100644 index 000000000..0dfc7b1d6 --- /dev/null +++ b/tests/Integration/Redis/Subscriber/SubscriberIntegrationTest.php @@ -0,0 +1,265 @@ +get('config'); + $this->configureRedisForTesting($config); + } + + public function testSubscribeReceivesMessage() + { + $channelName = 'test_sub_' . uniqid(); + $subscriber = $this->createTestSubscriber(); + + $subscriber->subscribe($channelName); + + go(function () use ($channelName) { + usleep(50_000); + $this->publishViaRawClient($channelName, 'hello'); + }); + + $message = $subscriber->channel()->pop(5.0); + + $this->assertNotFalse($message, 'Timed out waiting for message'); + $this->assertSame($channelName, $message->channel); + $this->assertSame('hello', $message->payload); + $this->assertNull($message->pattern); + + $subscriber->close(); + } + + public function testSubscribeToMultipleChannels() + { + $channel1 = 'test_multi_a_' . uniqid(); + $channel2 = 'test_multi_b_' . uniqid(); + $subscriber = $this->createTestSubscriber(); + + $subscriber->subscribe($channel1, $channel2); + + go(function () use ($channel1, $channel2) { + usleep(50_000); + $this->publishViaRawClient($channel1, 'msg1'); + $this->publishViaRawClient($channel2, 'msg2'); + }); + + $message1 = $subscriber->channel()->pop(5.0); + $this->assertNotFalse($message1, 'Timed out waiting for message 1'); + + $message2 = $subscriber->channel()->pop(5.0); + $this->assertNotFalse($message2, 'Timed out waiting for message 2'); + + $channels = [$message1->channel, $message2->channel]; + $payloads = [$message1->payload, $message2->payload]; + + $this->assertContains($channel1, $channels); + $this->assertContains($channel2, $channels); + $this->assertContains('msg1', $payloads); + $this->assertContains('msg2', $payloads); + + $subscriber->close(); + } + + public function testUnsubscribeStopsReceivingFromChannel() + { + $channel1 = 'test_unsub_keep_' . uniqid(); + $channel2 = 'test_unsub_drop_' . uniqid(); + $subscriber = $this->createTestSubscriber(); + + $subscriber->subscribe($channel1, $channel2); + $subscriber->unsubscribe($channel2); + + go(function () use ($channel1, $channel2) { + usleep(50_000); + // Publish to unsubscribed channel first — should be ignored + $this->publishViaRawClient($channel2, 'dropped'); + // Then publish to subscribed channel + $this->publishViaRawClient($channel1, 'kept'); + }); + + $message = $subscriber->channel()->pop(5.0); + $this->assertNotFalse($message, 'Timed out waiting for message'); + $this->assertSame($channel1, $message->channel); + $this->assertSame('kept', $message->payload); + + $subscriber->close(); + } + + public function testPsubscribeReceivesMatchingMessage() + { + $pattern = 'test_psub_' . uniqid() . ':*'; + $publishChannel = str_replace('*', 'specific', $pattern); + $subscriber = $this->createTestSubscriber(); + + $subscriber->psubscribe($pattern); + + go(function () use ($publishChannel) { + usleep(50_000); + $this->publishViaRawClient($publishChannel, 'pattern_data'); + }); + + $message = $subscriber->channel()->pop(5.0); + + $this->assertNotFalse($message, 'Timed out waiting for pmessage'); + $this->assertSame($publishChannel, $message->channel); + $this->assertSame('pattern_data', $message->payload); + $this->assertSame($pattern, $message->pattern); + + $subscriber->close(); + } + + public function testPunsubscribeStopsReceivingFromPattern() + { + $pattern1 = 'test_punsub_keep_' . uniqid() . ':*'; + $pattern2 = 'test_punsub_drop_' . uniqid() . ':*'; + $channel1 = str_replace('*', 'event', $pattern1); + $channel2 = str_replace('*', 'event', $pattern2); + $subscriber = $this->createTestSubscriber(); + + $subscriber->psubscribe($pattern1, $pattern2); + $subscriber->punsubscribe($pattern2); + + go(function () use ($channel1, $channel2) { + usleep(50_000); + $this->publishViaRawClient($channel2, 'dropped'); + $this->publishViaRawClient($channel1, 'kept'); + }); + + $message = $subscriber->channel()->pop(5.0); + $this->assertNotFalse($message, 'Timed out waiting for pmessage'); + $this->assertSame($channel1, $message->channel); + $this->assertSame('kept', $message->payload); + $this->assertSame($pattern1, $message->pattern); + + $subscriber->close(); + } + + public function testSubscribeWithPrefixPrependsToChannels() + { + $rawChannel = 'test_prefix_' . uniqid(); + $prefix = 'myapp:'; + $subscriber = $this->createTestSubscriber(prefix: $prefix); + + $subscriber->subscribe($rawChannel); + + go(function () use ($rawChannel, $prefix) { + usleep(50_000); + // Publish to the full prefixed channel name + $this->publishViaRawClient($prefix . $rawChannel, 'prefixed'); + }); + + $message = $subscriber->channel()->pop(5.0); + + $this->assertNotFalse($message, 'Timed out waiting for prefixed message'); + $this->assertSame($prefix . $rawChannel, $message->channel); + $this->assertSame('prefixed', $message->payload); + + $subscriber->close(); + } + + public function testPingReturnsPongWhileSubscribed() + { + $channelName = 'test_ping_' . uniqid(); + $subscriber = $this->createTestSubscriber(); + + // Must subscribe first — Redis only responds to PING with a multi-bulk + // pong in subscribe mode. In normal mode it sends +PONG which the + // RESP parser doesn't handle. + $subscriber->subscribe($channelName); + + $result = $subscriber->ping(5.0); + + $this->assertSame('pong', $result); + + $subscriber->close(); + } + + public function testCloseStopsReceivingMessages() + { + $channelName = 'test_close_' . uniqid(); + $subscriber = $this->createTestSubscriber(); + + $subscriber->subscribe($channelName); + + $this->assertFalse($subscriber->closed); + + $subscriber->close(); + + $this->assertTrue($subscriber->closed); + $this->assertFalse($subscriber->channel()->pop(0.1)); + } + + /** + * Create a Subscriber connected to the test Redis server. + */ + private function createTestSubscriber(string $prefix = ''): Subscriber + { + return new Subscriber( + host: env('REDIS_HOST', '127.0.0.1'), + port: (int) env('REDIS_PORT', 6379), + password: (string) (env('REDIS_AUTH', '') ?: ''), + timeout: 5.0, + prefix: $prefix, + ); + } + + /** + * Publish a message using a raw phpredis client (separate connection). + */ + private function publishViaRawClient(string $channel, string $message): void + { + $client = new Redis(); + $client->connect( + env('REDIS_HOST', '127.0.0.1'), + (int) env('REDIS_PORT', 6379) + ); + + $auth = env('REDIS_AUTH'); + if ($auth) { + $client->auth($auth); + } + + $client->publish($channel, $message); + $client->close(); + } +} diff --git a/tests/Integration/Redis/TransformIntegrationTest.php b/tests/Integration/Redis/TransformIntegrationTest.php new file mode 100644 index 000000000..490f9a52c --- /dev/null +++ b/tests/Integration/Redis/TransformIntegrationTest.php @@ -0,0 +1,413 @@ +get('config'); + $this->configureRedisForTesting($config); + } + + public function testGetReturnsNullForMissingKey() + { + $redis = Redis::connection($this->createRedisConnectionWithPrefix('')); + $redis->flushdb(); + + // With transform enabled (default), get() returns null instead of false + $result = $redis->get('nonexistent_key'); + + $this->assertNull($result); + } + + public function testGetReturnsValueForExistingKey() + { + $redis = Redis::connection($this->createRedisConnectionWithPrefix('')); + $redis->flushdb(); + + $redis->set('existing', 'hello'); + + $result = $redis->get('existing'); + + $this->assertSame('hello', $result); + } + + public function testSetWithExpiryAndFlag() + { + $redis = Redis::connection($this->createRedisConnectionWithPrefix('')); + $redis->flushdb(); + + // Laravel-style: set(key, value, 'EX', seconds, 'NX') + // Transform converts to phpredis: set(key, value, ['NX', 'EX' => seconds]) + $result = $redis->set('transform_set', 'value', 'EX', 600, 'NX'); + + $this->assertTrue($result); + $this->assertSame('value', $redis->get('transform_set')); + + // TTL should be set + $ttl = $redis->ttl('transform_set'); + $this->assertGreaterThan(0, $ttl); + $this->assertLessThanOrEqual(600, $ttl); + } + + public function testSetWithExpiryNxFailsWhenKeyExists() + { + $redis = Redis::connection($this->createRedisConnectionWithPrefix('')); + $redis->flushdb(); + + $redis->set('transform_set_nx', 'first'); + + // NX flag should prevent overwriting + $result = $redis->set('transform_set_nx', 'second', 'EX', 600, 'NX'); + + $this->assertFalse($result); + $this->assertSame('first', $redis->get('transform_set_nx')); + } + + public function testSetnxReturnsIntNotBool() + { + $redis = Redis::connection($this->createRedisConnectionWithPrefix('')); + $redis->flushdb(); + + // Transform converts bool to int: true → 1 + $result = $redis->setnx('setnx_key', 'value'); + + $this->assertSame(1, $result); + + // Second call should return 0 (key already exists) + $result = $redis->setnx('setnx_key', 'other'); + + $this->assertSame(0, $result); + } + + public function testMgetTransformsFalseToNull() + { + $redis = Redis::connection($this->createRedisConnectionWithPrefix('')); + $redis->flushdb(); + + $redis->set('mget1', 'value1'); + $redis->set('mget3', 'value3'); + + // mget2 doesn't exist — transform converts false to null + $result = $redis->mget(['mget1', 'mget2', 'mget3']); + + $this->assertSame(['value1', null, 'value3'], $result); + } + + public function testEvalReordersArguments() + { + $redis = Redis::connection($this->createRedisConnectionWithPrefix('')); + $redis->flushdb(); + + $redis->set('eval_key', 'eval_value'); + + // Laravel-style: eval(script, numKeys, key1, ...) + // Transform reorders to phpredis: eval(script, [key1, ...], numKeys) + $result = $redis->eval('return redis.call("GET", KEYS[1])', 1, 'eval_key'); + + $this->assertSame('eval_value', $result); + } + + public function testEvalWithMultipleKeysAndArgs() + { + $redis = Redis::connection($this->createRedisConnectionWithPrefix('')); + $redis->flushdb(); + + // Set two keys, then use eval with 2 KEYS + 1 ARGV + $redis->set('eval_k1', 'v1'); + $redis->set('eval_k2', 'v2'); + + $result = $redis->eval( + 'return {redis.call("GET", KEYS[1]), redis.call("GET", KEYS[2]), ARGV[1]}', + 2, + 'eval_k1', + 'eval_k2', + 'extra_arg' + ); + + $this->assertSame(['v1', 'v2', 'extra_arg'], $result); + } + + public function testHmsetWithArrayForm() + { + $redis = Redis::connection($this->createRedisConnectionWithPrefix('')); + $redis->flushdb(); + + // Array form: hmset(key, ['field1' => 'value1', 'field2' => 'value2']) + $result = $redis->hmset('hash', ['field1' => 'val1', 'field2' => 'val2']); + + $this->assertTrue($result); + $this->assertSame('val1', $redis->hget('hash', 'field1')); + $this->assertSame('val2', $redis->hget('hash', 'field2')); + } + + public function testHmsetWithAlternatingKeyValuePairs() + { + $redis = Redis::connection($this->createRedisConnectionWithPrefix('')); + $redis->flushdb(); + + // Alternating form: hmset(key, field1, value1, field2, value2) + // Transform converts to: hmset(key, ['field1' => 'value1', 'field2' => 'value2']) + $result = $redis->hmset('hash_alt', 'f1', 'v1', 'f2', 'v2'); + + $this->assertTrue($result); + $this->assertSame('v1', $redis->hget('hash_alt', 'f1')); + $this->assertSame('v2', $redis->hget('hash_alt', 'f2')); + } + + public function testHmgetReturnsIndexedValues() + { + $redis = Redis::connection($this->createRedisConnectionWithPrefix('')); + $redis->flushdb(); + + $redis->hset('hmget_hash', 'field1', 'val1'); + $redis->hset('hmget_hash', 'field2', 'val2'); + + // Transform strips keys, returns just the values as indexed array + $result = $redis->hmget('hmget_hash', ['field1', 'field2']); + + $this->assertSame(['val1', 'val2'], $result); + } + + public function testHmgetWithMultipleStringArgs() + { + $redis = Redis::connection($this->createRedisConnectionWithPrefix('')); + $redis->flushdb(); + + $redis->hset('hmget_hash2', 'a', '1'); + $redis->hset('hmget_hash2', 'b', '2'); + + // Multiple string args form: hmget(key, field1, field2) + $result = $redis->hmget('hmget_hash2', 'a', 'b'); + + $this->assertSame(['1', '2'], $result); + } + + public function testHsetnxReturnsInt() + { + $redis = Redis::connection($this->createRedisConnectionWithPrefix('')); + $redis->flushdb(); + + // Transform converts bool to int: true → 1 + $result = $redis->hsetnx('hsetnx_hash', 'field', 'value'); + + $this->assertSame(1, $result); + + // Second call returns 0 (field already exists) + $result = $redis->hsetnx('hsetnx_hash', 'field', 'other'); + + $this->assertSame(0, $result); + } + + public function testLremSwapsArguments() + { + $redis = Redis::connection($this->createRedisConnectionWithPrefix('')); + $redis->flushdb(); + + $redis->rpush('lrem_list', 'a'); + $redis->rpush('lrem_list', 'b'); + $redis->rpush('lrem_list', 'a'); + $redis->rpush('lrem_list', 'c'); + $redis->rpush('lrem_list', 'a'); + + // Laravel-style: lrem(key, count, value) + // Transform reorders to phpredis: lRem(key, value, count) + $removed = $redis->lrem('lrem_list', 2, 'a'); + + $this->assertSame(2, $removed); + + // Should have one 'a' remaining (removed from head) + $remaining = $redis->lrange('lrem_list', 0, -1); + $this->assertSame(['b', 'c', 'a'], $remaining); + } + + public function testSpopWithoutCountReturnsSingleElement() + { + $redis = Redis::connection($this->createRedisConnectionWithPrefix('')); + $redis->flushdb(); + + $redis->sadd('spop_set', 'member1'); + + // Without count: returns a single string (not array) + $result = $redis->spop('spop_set'); + + $this->assertSame('member1', $result); + } + + public function testSpopWithCountReturnsArray() + { + $redis = Redis::connection($this->createRedisConnectionWithPrefix('')); + $redis->flushdb(); + + $redis->sadd('spop_set2', 'a', 'b', 'c'); + + // With count: returns an array + $result = $redis->spop('spop_set2', 2); + + $this->assertIsArray($result); + $this->assertCount(2, $result); + } + + public function testSpopReturnsEmptySetAsFalse() + { + $redis = Redis::connection($this->createRedisConnectionWithPrefix('')); + $redis->flushdb(); + + // spop on non-existent set returns false + $result = $redis->spop('empty_set'); + + $this->assertFalse($result); + } + + public function testBlpopReturnsNullOnTimeout() + { + $redis = Redis::connection($this->createRedisConnectionWithPrefix('')); + $redis->flushdb(); + + // blpop with 1 second timeout on empty list returns null (not empty array) + $result = $redis->blpop('empty_list', 1); + + $this->assertNull($result); + } + + public function testBrpopReturnsNullOnTimeout() + { + $redis = Redis::connection($this->createRedisConnectionWithPrefix('')); + $redis->flushdb(); + + // brpop with 1 second timeout on empty list returns null (not empty array) + $result = $redis->brpop('empty_list', 1); + + $this->assertNull($result); + } + + public function testBlpopReturnsArrayOnSuccess() + { + $redis = Redis::connection($this->createRedisConnectionWithPrefix('')); + $redis->flushdb(); + + $redis->rpush('blpop_list', 'item1'); + + $result = $redis->blpop('blpop_list', 1); + + $this->assertSame(['blpop_list', 'item1'], $result); + } + + public function testZaddWithOptionsAndScoreMemberPairs() + { + $redis = Redis::connection($this->createRedisConnectionWithPrefix('')); + $redis->flushdb(); + + // Laravel-style: zadd(key, 'NX', score, member, score, member) + // Transform parses options and reorders for phpredis + $result = $redis->zadd('zset', 'NX', 1.0, 'member1', 2.0, 'member2'); + + $this->assertSame(2, $result); + $this->assertSame(1.0, $redis->zscore('zset', 'member1')); + $this->assertSame(2.0, $redis->zscore('zset', 'member2')); + } + + public function testZaddWithArrayForm() + { + $redis = Redis::connection($this->createRedisConnectionWithPrefix('')); + $redis->flushdb(); + + // Array form: zadd(key, ['member' => score]) + $result = $redis->zadd('zset2', ['mem1' => 1.0, 'mem2' => 2.0]); + + $this->assertSame(2, $result); + $this->assertSame(1.0, $redis->zscore('zset2', 'mem1')); + } + + public function testZrangebyscoreConvertsLimitOption() + { + $redis = Redis::connection($this->createRedisConnectionWithPrefix('')); + $redis->flushdb(); + + $redis->zadd('zrange', ['a' => 1, 'b' => 2, 'c' => 3, 'd' => 4, 'e' => 5]); + + // Laravel-style limit with offset/count keys + // Transform converts to indexed array [offset, count] + $result = $redis->zrangebyscore('zrange', '1', '5', [ + 'limit' => ['offset' => 1, 'count' => 2], + ]); + + $this->assertSame(['b', 'c'], $result); + } + + public function testZrevrangebyscoreConvertsLimitOption() + { + $redis = Redis::connection($this->createRedisConnectionWithPrefix('')); + $redis->flushdb(); + + $redis->zadd('zrevrange', ['a' => 1, 'b' => 2, 'c' => 3, 'd' => 4, 'e' => 5]); + + $result = $redis->zrevrangebyscore('zrevrange', '5', '1', [ + 'limit' => ['offset' => 1, 'count' => 2], + ]); + + $this->assertSame(['d', 'c'], $result); + } + + public function testFlushdbAsync() + { + $redis = Redis::connection($this->createRedisConnectionWithPrefix('')); + + $redis->set('async_flush_key', 'value'); + + // Transform converts 'ASYNC' string to flushdb(true) + $result = $redis->flushdb('ASYNC'); + + $this->assertTrue($result); + } + + public function testExecuteRawDelegatesToRawCommand() + { + $redis = Redis::connection($this->createRedisConnectionWithPrefix('')); + $redis->flushdb(); + + $redis->set('raw_key', 'raw_value'); + + // Transform: executeRaw(['GET', 'raw_key']) → rawCommand('GET', 'raw_key') + $result = $redis->executeRaw(['GET', 'raw_key']); + + $this->assertSame('raw_value', $result); + } + + public function testEvalshaLoadsAndExecutesScript() + { + $redis = Redis::connection($this->createRedisConnectionWithPrefix('')); + $redis->flushdb(); + + $redis->set('evalsha_key', 'evalsha_value'); + + // Transform: evalsha(script, numKeys, key) → script('load', script) + evalSha(sha, [key], numKeys) + $result = $redis->evalsha('return redis.call("GET", KEYS[1])', 1, 'evalsha_key'); + + $this->assertSame('evalsha_value', $result); + } +} diff --git a/tests/Scout/Integration/Meilisearch/MeilisearchCommandsIntegrationTest.php b/tests/Integration/Scout/Meilisearch/MeilisearchCommandsIntegrationTest.php similarity index 96% rename from tests/Scout/Integration/Meilisearch/MeilisearchCommandsIntegrationTest.php rename to tests/Integration/Scout/Meilisearch/MeilisearchCommandsIntegrationTest.php index f7bf6e6df..1d37514d5 100644 --- a/tests/Scout/Integration/Meilisearch/MeilisearchCommandsIntegrationTest.php +++ b/tests/Integration/Scout/Meilisearch/MeilisearchCommandsIntegrationTest.php @@ -2,16 +2,13 @@ declare(strict_types=1); -namespace Hypervel\Tests\Scout\Integration\Meilisearch; +namespace Hypervel\Tests\Integration\Scout\Meilisearch; use Hypervel\Tests\Scout\Models\SearchableModel; /** * Integration tests for Scout console commands with Meilisearch. * - * @group integration - * @group meilisearch-integration - * * @internal * @coversNothing */ diff --git a/tests/Scout/Integration/Meilisearch/MeilisearchConnectionTest.php b/tests/Integration/Scout/Meilisearch/MeilisearchConnectionTest.php similarity index 94% rename from tests/Scout/Integration/Meilisearch/MeilisearchConnectionTest.php rename to tests/Integration/Scout/Meilisearch/MeilisearchConnectionTest.php index 326206511..dc10f7b72 100644 --- a/tests/Scout/Integration/Meilisearch/MeilisearchConnectionTest.php +++ b/tests/Integration/Scout/Meilisearch/MeilisearchConnectionTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Hypervel\Tests\Scout\Integration\Meilisearch; +namespace Hypervel\Tests\Integration\Scout\Meilisearch; use Hypervel\Foundation\Testing\Concerns\RunTestsInCoroutine; use Hypervel\Tests\Support\MeilisearchIntegrationTestCase; @@ -10,9 +10,6 @@ /** * Basic connectivity test for Meilisearch. * - * @group integration - * @group meilisearch-integration - * * @internal * @coversNothing */ diff --git a/tests/Scout/Integration/Meilisearch/MeilisearchEngineIntegrationTest.php b/tests/Integration/Scout/Meilisearch/MeilisearchEngineIntegrationTest.php similarity index 98% rename from tests/Scout/Integration/Meilisearch/MeilisearchEngineIntegrationTest.php rename to tests/Integration/Scout/Meilisearch/MeilisearchEngineIntegrationTest.php index 9bc864722..7ac14ba1f 100644 --- a/tests/Scout/Integration/Meilisearch/MeilisearchEngineIntegrationTest.php +++ b/tests/Integration/Scout/Meilisearch/MeilisearchEngineIntegrationTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Hypervel\Tests\Scout\Integration\Meilisearch; +namespace Hypervel\Tests\Integration\Scout\Meilisearch; use Hypervel\Database\Eloquent\Collection as EloquentCollection; use Hypervel\Tests\Scout\Models\SearchableModel; @@ -10,9 +10,6 @@ /** * Integration tests for MeilisearchEngine core operations. * - * @group integration - * @group meilisearch-integration - * * @internal * @coversNothing */ diff --git a/tests/Scout/Integration/Meilisearch/MeilisearchFilteringIntegrationTest.php b/tests/Integration/Scout/Meilisearch/MeilisearchFilteringIntegrationTest.php similarity index 98% rename from tests/Scout/Integration/Meilisearch/MeilisearchFilteringIntegrationTest.php rename to tests/Integration/Scout/Meilisearch/MeilisearchFilteringIntegrationTest.php index e38e2e982..675dceb87 100644 --- a/tests/Scout/Integration/Meilisearch/MeilisearchFilteringIntegrationTest.php +++ b/tests/Integration/Scout/Meilisearch/MeilisearchFilteringIntegrationTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Hypervel\Tests\Scout\Integration\Meilisearch; +namespace Hypervel\Tests\Integration\Scout\Meilisearch; use Hypervel\Database\Eloquent\Collection as EloquentCollection; use Hypervel\Tests\Scout\Models\SearchableModel; @@ -10,9 +10,6 @@ /** * Integration tests for Meilisearch filtering operations. * - * @group integration - * @group meilisearch-integration - * * @internal * @coversNothing */ diff --git a/tests/Scout/Integration/Meilisearch/MeilisearchIndexSettingsIntegrationTest.php b/tests/Integration/Scout/Meilisearch/MeilisearchIndexSettingsIntegrationTest.php similarity index 87% rename from tests/Scout/Integration/Meilisearch/MeilisearchIndexSettingsIntegrationTest.php rename to tests/Integration/Scout/Meilisearch/MeilisearchIndexSettingsIntegrationTest.php index 90d31ee93..a58dbdd94 100644 --- a/tests/Scout/Integration/Meilisearch/MeilisearchIndexSettingsIntegrationTest.php +++ b/tests/Integration/Scout/Meilisearch/MeilisearchIndexSettingsIntegrationTest.php @@ -2,17 +2,13 @@ declare(strict_types=1); -namespace Hypervel\Tests\Scout\Integration\Meilisearch; +namespace Hypervel\Tests\Integration\Scout\Meilisearch; -use Hyperf\Contract\ConfigInterface; use Hypervel\Tests\Scout\Models\SearchableModel; /** * Integration tests for Meilisearch index settings configuration. * - * @group integration - * @group meilisearch-integration - * * @internal * @coversNothing */ @@ -27,7 +23,7 @@ public function testSyncIndexSettingsCommandAppliesConfigSettings(): void $this->meilisearch->waitForTask($task['taskUid']); // Configure index settings via Scout config - $this->app->get(ConfigInterface::class)->set('scout.meilisearch.index-settings', [ + $this->app->get('config')->set('scout.meilisearch.index-settings', [ SearchableModel::class => [ 'filterableAttributes' => ['title', 'body'], 'sortableAttributes' => ['id', 'title'], @@ -62,7 +58,7 @@ public function testSyncIndexSettingsCommandWithPlainIndexName(): void $this->meilisearch->waitForTask($task['taskUid']); // Configure index settings using plain index name (with prefix) - $this->app->get(ConfigInterface::class)->set('scout.meilisearch.index-settings', [ + $this->app->get('config')->set('scout.meilisearch.index-settings', [ $indexName => [ 'filterableAttributes' => ['status'], 'sortableAttributes' => ['created_at'], @@ -86,7 +82,7 @@ public function testSyncIndexSettingsCommandWithPlainIndexName(): void public function testSyncIndexSettingsCommandReportsNoSettingsWhenEmpty(): void { // Ensure no index settings are configured - $this->app->get(ConfigInterface::class)->set('scout.meilisearch.index-settings', []); + $this->app->get('config')->set('scout.meilisearch.index-settings', []); // Run the sync command $this->artisan('scout:sync-index-settings') diff --git a/tests/Scout/Integration/Meilisearch/MeilisearchScoutIntegrationTestCase.php b/tests/Integration/Scout/Meilisearch/MeilisearchScoutIntegrationTestCase.php similarity index 93% rename from tests/Scout/Integration/Meilisearch/MeilisearchScoutIntegrationTestCase.php rename to tests/Integration/Scout/Meilisearch/MeilisearchScoutIntegrationTestCase.php index 6dd610810..e8df56c8e 100644 --- a/tests/Scout/Integration/Meilisearch/MeilisearchScoutIntegrationTestCase.php +++ b/tests/Integration/Scout/Meilisearch/MeilisearchScoutIntegrationTestCase.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Hypervel\Tests\Scout\Integration\Meilisearch; +namespace Hypervel\Tests\Integration\Scout\Meilisearch; use Hypervel\Foundation\Testing\Concerns\RunTestsInCoroutine; use Hypervel\Foundation\Testing\RefreshDatabase; @@ -23,9 +23,6 @@ * Extends the generic Meilisearch test case with Scout-specific setup: * database migrations, Scout commands, and engine initialization. * - * @group integration - * @group meilisearch-integration - * * @internal * @coversNothing */ @@ -83,7 +80,7 @@ protected function migrateFreshUsing(): array '--database' => $this->getRefreshConnection(), '--realpath' => true, '--path' => [ - dirname(__DIR__, 2) . '/migrations', + dirname(__DIR__, 3) . '/Scout/migrations', ], ]; } diff --git a/tests/Scout/Integration/Meilisearch/MeilisearchSoftDeleteIntegrationTest.php b/tests/Integration/Scout/Meilisearch/MeilisearchSoftDeleteIntegrationTest.php similarity index 95% rename from tests/Scout/Integration/Meilisearch/MeilisearchSoftDeleteIntegrationTest.php rename to tests/Integration/Scout/Meilisearch/MeilisearchSoftDeleteIntegrationTest.php index b1d62b233..07d9b4ae1 100644 --- a/tests/Scout/Integration/Meilisearch/MeilisearchSoftDeleteIntegrationTest.php +++ b/tests/Integration/Scout/Meilisearch/MeilisearchSoftDeleteIntegrationTest.php @@ -2,18 +2,14 @@ declare(strict_types=1); -namespace Hypervel\Tests\Scout\Integration\Meilisearch; +namespace Hypervel\Tests\Integration\Scout\Meilisearch; -use Hyperf\Contract\ConfigInterface; use Hypervel\Database\Eloquent\Collection as EloquentCollection; use Hypervel\Tests\Scout\Models\SoftDeleteSearchableModel; /** * Integration tests for Scout soft delete behavior with Meilisearch. * - * @group integration - * @group meilisearch-integration - * * @internal * @coversNothing */ @@ -24,7 +20,7 @@ protected function setUp(): void parent::setUp(); // Enable soft delete support in Scout - $this->app->get(ConfigInterface::class)->set('scout.soft_delete', true); + $this->app->get('config')->set('scout.soft_delete', true); } protected function setUpInCoroutine(): void diff --git a/tests/Scout/Integration/Meilisearch/MeilisearchSortingIntegrationTest.php b/tests/Integration/Scout/Meilisearch/MeilisearchSortingIntegrationTest.php similarity index 97% rename from tests/Scout/Integration/Meilisearch/MeilisearchSortingIntegrationTest.php rename to tests/Integration/Scout/Meilisearch/MeilisearchSortingIntegrationTest.php index fc51c1ff0..e940facd8 100644 --- a/tests/Scout/Integration/Meilisearch/MeilisearchSortingIntegrationTest.php +++ b/tests/Integration/Scout/Meilisearch/MeilisearchSortingIntegrationTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Hypervel\Tests\Scout\Integration\Meilisearch; +namespace Hypervel\Tests\Integration\Scout\Meilisearch; use Hypervel\Database\Eloquent\Collection as EloquentCollection; use Hypervel\Tests\Scout\Models\SearchableModel; @@ -10,9 +10,6 @@ /** * Integration tests for Meilisearch sorting operations. * - * @group integration - * @group meilisearch-integration - * * @internal * @coversNothing */ diff --git a/tests/Scout/Integration/Typesense/TypesenseCommandsIntegrationTest.php b/tests/Integration/Scout/Typesense/TypesenseCommandsIntegrationTest.php similarity index 94% rename from tests/Scout/Integration/Typesense/TypesenseCommandsIntegrationTest.php rename to tests/Integration/Scout/Typesense/TypesenseCommandsIntegrationTest.php index 7531ab361..bcc4a833a 100644 --- a/tests/Scout/Integration/Typesense/TypesenseCommandsIntegrationTest.php +++ b/tests/Integration/Scout/Typesense/TypesenseCommandsIntegrationTest.php @@ -2,16 +2,13 @@ declare(strict_types=1); -namespace Hypervel\Tests\Scout\Integration\Typesense; +namespace Hypervel\Tests\Integration\Scout\Typesense; use Hypervel\Tests\Scout\Models\TypesenseSearchableModel; /** * Integration tests for Scout console commands with Typesense. * - * @group integration - * @group typesense-integration - * * @internal * @coversNothing */ diff --git a/tests/Scout/Integration/Typesense/TypesenseConfigIntegrationTest.php b/tests/Integration/Scout/Typesense/TypesenseConfigIntegrationTest.php similarity index 89% rename from tests/Scout/Integration/Typesense/TypesenseConfigIntegrationTest.php rename to tests/Integration/Scout/Typesense/TypesenseConfigIntegrationTest.php index 1e5d36971..570f31b4d 100644 --- a/tests/Scout/Integration/Typesense/TypesenseConfigIntegrationTest.php +++ b/tests/Integration/Scout/Typesense/TypesenseConfigIntegrationTest.php @@ -2,9 +2,8 @@ declare(strict_types=1); -namespace Hypervel\Tests\Scout\Integration\Typesense; +namespace Hypervel\Tests\Integration\Scout\Typesense; -use Hyperf\Contract\ConfigInterface; use Hypervel\Database\Eloquent\Collection as EloquentCollection; use Hypervel\Scout\EngineManager; use Hypervel\Tests\Scout\Models\ConfigBasedTypesenseModel; @@ -13,9 +12,6 @@ /** * Integration tests for Typesense configuration options. * - * @group integration - * @group typesense-integration - * * @internal * @coversNothing */ @@ -26,7 +22,7 @@ public function testModelSettingsCollectionSchemaFromConfig(): void $modelClass = ConfigBasedTypesenseModel::class; // Configure collection schema via config - $this->app->get(ConfigInterface::class)->set("scout.typesense.model-settings.{$modelClass}", [ + $this->app->get('config')->set("scout.typesense.model-settings.{$modelClass}", [ 'collection-schema' => [ 'fields' => [ ['name' => 'id', 'type' => 'string'], @@ -60,7 +56,7 @@ public function testModelSettingsSearchParametersFromConfig(): void $modelClass = ConfigBasedTypesenseModel::class; // Configure with specific query_by that only searches title - $this->app->get(ConfigInterface::class)->set("scout.typesense.model-settings.{$modelClass}", [ + $this->app->get('config')->set("scout.typesense.model-settings.{$modelClass}", [ 'collection-schema' => [ 'fields' => [ ['name' => 'id', 'type' => 'string'], @@ -95,7 +91,7 @@ public function testMaxTotalResultsConfigLimitsPagination(): void $modelClass = ConfigBasedTypesenseModel::class; // Configure collection schema - $this->app->get(ConfigInterface::class)->set("scout.typesense.model-settings.{$modelClass}", [ + $this->app->get('config')->set("scout.typesense.model-settings.{$modelClass}", [ 'collection-schema' => [ 'fields' => [ ['name' => 'id', 'type' => 'string'], @@ -109,7 +105,7 @@ public function testMaxTotalResultsConfigLimitsPagination(): void ]); // Set max_total_results to a low value - $this->app->get(ConfigInterface::class)->set('scout.typesense.max_total_results', 3); + $this->app->get('config')->set('scout.typesense.max_total_results', 3); // Clear cached engines to pick up new config $this->app->get(EngineManager::class)->forgetEngines(); @@ -147,7 +143,7 @@ public function testImportActionConfigIsUsed(): void $modelClass = ConfigBasedTypesenseModel::class; // Configure collection schema - $this->app->get(ConfigInterface::class)->set("scout.typesense.model-settings.{$modelClass}", [ + $this->app->get('config')->set("scout.typesense.model-settings.{$modelClass}", [ 'collection-schema' => [ 'fields' => [ ['name' => 'id', 'type' => 'string'], @@ -161,7 +157,7 @@ public function testImportActionConfigIsUsed(): void ]); // Set import_action to 'upsert' (default) - allows insert and update - $this->app->get(ConfigInterface::class)->set('scout.typesense.import_action', 'upsert'); + $this->app->get('config')->set('scout.typesense.import_action', 'upsert'); // Clear cached engines $this->app->get(EngineManager::class)->forgetEngines(); diff --git a/tests/Scout/Integration/Typesense/TypesenseConnectionTest.php b/tests/Integration/Scout/Typesense/TypesenseConnectionTest.php similarity index 95% rename from tests/Scout/Integration/Typesense/TypesenseConnectionTest.php rename to tests/Integration/Scout/Typesense/TypesenseConnectionTest.php index 82d088f98..8df7fb5d6 100644 --- a/tests/Scout/Integration/Typesense/TypesenseConnectionTest.php +++ b/tests/Integration/Scout/Typesense/TypesenseConnectionTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Hypervel\Tests\Scout\Integration\Typesense; +namespace Hypervel\Tests\Integration\Scout\Typesense; use Hypervel\Foundation\Testing\Concerns\RunTestsInCoroutine; use Hypervel\Tests\Support\TypesenseIntegrationTestCase; @@ -10,9 +10,6 @@ /** * Basic connectivity test for Typesense. * - * @group integration - * @group typesense-integration - * * @internal * @coversNothing */ diff --git a/tests/Scout/Integration/Typesense/TypesenseEngineIntegrationTest.php b/tests/Integration/Scout/Typesense/TypesenseEngineIntegrationTest.php similarity index 98% rename from tests/Scout/Integration/Typesense/TypesenseEngineIntegrationTest.php rename to tests/Integration/Scout/Typesense/TypesenseEngineIntegrationTest.php index 016b916b8..fee3924c4 100644 --- a/tests/Scout/Integration/Typesense/TypesenseEngineIntegrationTest.php +++ b/tests/Integration/Scout/Typesense/TypesenseEngineIntegrationTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Hypervel\Tests\Scout\Integration\Typesense; +namespace Hypervel\Tests\Integration\Scout\Typesense; use Hypervel\Database\Eloquent\Collection as EloquentCollection; use Hypervel\Tests\Scout\Models\TypesenseSearchableModel; @@ -10,9 +10,6 @@ /** * Integration tests for TypesenseEngine core operations. * - * @group integration - * @group typesense-integration - * * @internal * @coversNothing */ diff --git a/tests/Scout/Integration/Typesense/TypesenseFilteringIntegrationTest.php b/tests/Integration/Scout/Typesense/TypesenseFilteringIntegrationTest.php similarity index 97% rename from tests/Scout/Integration/Typesense/TypesenseFilteringIntegrationTest.php rename to tests/Integration/Scout/Typesense/TypesenseFilteringIntegrationTest.php index cc613f69c..7a959d1ad 100644 --- a/tests/Scout/Integration/Typesense/TypesenseFilteringIntegrationTest.php +++ b/tests/Integration/Scout/Typesense/TypesenseFilteringIntegrationTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Hypervel\Tests\Scout\Integration\Typesense; +namespace Hypervel\Tests\Integration\Scout\Typesense; use Hypervel\Database\Eloquent\Collection as EloquentCollection; use Hypervel\Tests\Scout\Models\TypesenseSearchableModel; @@ -10,9 +10,6 @@ /** * Integration tests for Typesense filtering operations. * - * @group integration - * @group typesense-integration - * * @internal * @coversNothing */ diff --git a/tests/Scout/Integration/Typesense/TypesenseScoutIntegrationTestCase.php b/tests/Integration/Scout/Typesense/TypesenseScoutIntegrationTestCase.php similarity index 93% rename from tests/Scout/Integration/Typesense/TypesenseScoutIntegrationTestCase.php rename to tests/Integration/Scout/Typesense/TypesenseScoutIntegrationTestCase.php index 8896c0825..1f552506e 100644 --- a/tests/Scout/Integration/Typesense/TypesenseScoutIntegrationTestCase.php +++ b/tests/Integration/Scout/Typesense/TypesenseScoutIntegrationTestCase.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Hypervel\Tests\Scout\Integration\Typesense; +namespace Hypervel\Tests\Integration\Scout\Typesense; use Hypervel\Foundation\Testing\Concerns\RunTestsInCoroutine; use Hypervel\Foundation\Testing\RefreshDatabase; @@ -22,9 +22,6 @@ * Extends the generic Typesense test case with Scout-specific setup: * database migrations, Scout commands, and engine initialization. * - * @group integration - * @group typesense-integration - * * @internal * @coversNothing */ @@ -81,7 +78,7 @@ protected function migrateFreshUsing(): array '--database' => $this->getRefreshConnection(), '--realpath' => true, '--path' => [ - dirname(__DIR__, 2) . '/migrations', + dirname(__DIR__, 3) . '/Scout/migrations', ], ]; } diff --git a/tests/Scout/Integration/Typesense/TypesenseSoftDeleteIntegrationTest.php b/tests/Integration/Scout/Typesense/TypesenseSoftDeleteIntegrationTest.php similarity index 94% rename from tests/Scout/Integration/Typesense/TypesenseSoftDeleteIntegrationTest.php rename to tests/Integration/Scout/Typesense/TypesenseSoftDeleteIntegrationTest.php index dda676687..f7b7dd27a 100644 --- a/tests/Scout/Integration/Typesense/TypesenseSoftDeleteIntegrationTest.php +++ b/tests/Integration/Scout/Typesense/TypesenseSoftDeleteIntegrationTest.php @@ -2,18 +2,14 @@ declare(strict_types=1); -namespace Hypervel\Tests\Scout\Integration\Typesense; +namespace Hypervel\Tests\Integration\Scout\Typesense; -use Hyperf\Contract\ConfigInterface; use Hypervel\Database\Eloquent\Collection as EloquentCollection; use Hypervel\Tests\Scout\Models\TypesenseSoftDeleteSearchableModel; /** * Integration tests for Scout soft delete behavior with Typesense. * - * @group integration - * @group typesense-integration - * * @internal * @coversNothing */ @@ -24,7 +20,7 @@ protected function setUp(): void parent::setUp(); // Enable soft delete support in Scout - $this->app->get(ConfigInterface::class)->set('scout.soft_delete', true); + $this->app->get('config')->set('scout.soft_delete', true); } public function testDefaultSearchExcludesSoftDeletedModels(): void diff --git a/tests/Scout/Integration/Typesense/TypesenseSortingIntegrationTest.php b/tests/Integration/Scout/Typesense/TypesenseSortingIntegrationTest.php similarity index 96% rename from tests/Scout/Integration/Typesense/TypesenseSortingIntegrationTest.php rename to tests/Integration/Scout/Typesense/TypesenseSortingIntegrationTest.php index 16559492f..bddf02ea8 100644 --- a/tests/Scout/Integration/Typesense/TypesenseSortingIntegrationTest.php +++ b/tests/Integration/Scout/Typesense/TypesenseSortingIntegrationTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Hypervel\Tests\Scout\Integration\Typesense; +namespace Hypervel\Tests\Integration\Scout\Typesense; use Hypervel\Database\Eloquent\Collection as EloquentCollection; use Hypervel\Tests\Scout\Models\TypesenseSearchableModel; @@ -10,9 +10,6 @@ /** * Integration tests for Typesense sorting operations. * - * @group integration - * @group typesense-integration - * * @internal * @coversNothing */ diff --git a/tests/JWT/BlacklistTest.php b/tests/JWT/BlacklistTest.php index eac869851..d4d9b351e 100644 --- a/tests/JWT/BlacklistTest.php +++ b/tests/JWT/BlacklistTest.php @@ -9,7 +9,7 @@ use Hypervel\JWT\Contracts\StorageContract; use Hypervel\JWT\Exceptions\TokenInvalidException; use Hypervel\Tests\TestCase; -use Mockery; +use Mockery as m; use Mockery\MockInterface; use PHPUnit\Framework\Attributes\DataProvider; @@ -33,7 +33,7 @@ protected function setUp(): void Carbon::setTestNow('2000-01-01T00:00:00.000000Z'); $this->testNowTimestamp = Carbon::now()->timestamp; - $this->storage = Mockery::mock(StorageContract::class); + $this->storage = m::mock(StorageContract::class); $this->blacklist = new Blacklist($this->storage); } diff --git a/tests/JWT/JWTManagerTest.php b/tests/JWT/JWTManagerTest.php index 481f565f9..e8cb60d8c 100644 --- a/tests/JWT/JWTManagerTest.php +++ b/tests/JWT/JWTManagerTest.php @@ -5,8 +5,8 @@ namespace Hypervel\Tests\JWT; use Carbon\Carbon; -use Hyperf\Contract\ConfigInterface; use Hyperf\Contract\ContainerInterface; +use Hypervel\Config\Repository; use Hypervel\JWT\Contracts\BlacklistContract; use Hypervel\JWT\Exceptions\JWTException; use Hypervel\JWT\Exceptions\TokenBlacklistedException; @@ -14,7 +14,7 @@ use Hypervel\JWT\Providers\Lcobucci; use Hypervel\Tests\JWT\Stub\ValidationStub; use Hypervel\Tests\TestCase; -use Mockery; +use Mockery as m; use Mockery\MockInterface; use Ramsey\Uuid\Uuid; use Ramsey\Uuid\UuidFactoryInterface; @@ -32,9 +32,9 @@ class JWTManagerTest extends TestCase private ContainerInterface $container; /** - * @var ConfigInterface|MockInterface + * @var MockInterface|Repository */ - private ConfigInterface $config; + private Repository $config; /** * @var Lcobucci|MockInterface @@ -229,24 +229,24 @@ private function setTestNow() private function mockContainer() { - $this->container = Mockery::mock(ContainerInterface::class); + $this->container = m::mock(ContainerInterface::class); } private function mockConfig() { - $this->config = Mockery::mock(ConfigInterface::class); + $this->config = m::mock(Repository::class); - $this->container->shouldReceive('get')->with(ConfigInterface::class)->andReturn($this->config); + $this->container->shouldReceive('get')->with('config')->andReturn($this->config); } private function mockProvider() { - $this->provider = Mockery::mock(Lcobucci::class); + $this->provider = m::mock(Lcobucci::class); } private function mockBlacklist() { - $this->blacklist = Mockery::mock(BlacklistContract::class); + $this->blacklist = m::mock(BlacklistContract::class); $this->container->shouldReceive('get')->with(BlacklistContract::class)->andReturn($this->blacklist); } @@ -269,13 +269,13 @@ private function mockUuid(string $value) } /** @var MockInterface|UuidFactoryInterface */ - $factory = Mockery::mock(UuidFactoryInterface::class); + $factory = m::mock(UuidFactoryInterface::class); // Ignore Serializable interface deprecation warnings in PHP 8.1+ /** @var MockInterface|UuidInterface */ $uuid = $this->runInSpecifyErrorReportingLevel( E_ALL & ~E_DEPRECATED & ~E_USER_DEPRECATED, - fn () => Mockery::mock(UuidInterface::class) + fn () => m::mock(UuidInterface::class) ); $uuid->shouldReceive('__toString')->andReturn($value); diff --git a/tests/JWT/Storage/PsrCacheTest.php b/tests/JWT/Storage/PsrCacheTest.php index 1457215aa..73ba4ee50 100644 --- a/tests/JWT/Storage/PsrCacheTest.php +++ b/tests/JWT/Storage/PsrCacheTest.php @@ -6,7 +6,7 @@ use Hypervel\JWT\Storage\PsrCache; use Hypervel\Tests\TestCase; -use Mockery; +use Mockery as m; use Mockery\MockInterface; use Psr\SimpleCache\CacheInterface; @@ -25,7 +25,7 @@ class PsrCacheTest extends TestCase protected function setUp(): void { - $this->cache = Mockery::mock(CacheInterface::class); + $this->cache = m::mock(CacheInterface::class); $this->storage = new PsrCache($this->cache); } diff --git a/tests/JWT/Storage/TaggedCacheTest.php b/tests/JWT/Storage/TaggedCacheTest.php index 7e407a416..9c388c8fa 100644 --- a/tests/JWT/Storage/TaggedCacheTest.php +++ b/tests/JWT/Storage/TaggedCacheTest.php @@ -4,10 +4,10 @@ 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; +use Mockery as m; use Mockery\MockInterface; /** @@ -26,7 +26,7 @@ class TaggedCacheTest extends TestCase protected function setUp(): void { /** @var CacheRepository|MockInterface */ - $cache = Mockery::mock(CacheRepository::class); + $cache = m::mock(CacheRepository::class); $this->cache = $cache; $this->storage = new TaggedCache($this->cache); diff --git a/tests/Log/LogLoggerTest.php b/tests/Log/LogLoggerTest.php index 4c9950162..801d9cdb6 100644 --- a/tests/Log/LogLoggerTest.php +++ b/tests/Log/LogLoggerTest.php @@ -4,7 +4,7 @@ namespace Hypervel\Tests\Log; -use Hyperf\Context\Context; +use Hypervel\Context\Context; use Hypervel\Log\Events\MessageLogged; use Hypervel\Log\Logger; use Mockery as m; @@ -20,7 +20,6 @@ class LogLoggerTest extends TestCase { protected function tearDown(): void { - m::close(); Context::destroy('__logger.context'); } diff --git a/tests/Log/LogManagerTest.php b/tests/Log/LogManagerTest.php index 128d203ad..cd54e0643 100644 --- a/tests/Log/LogManagerTest.php +++ b/tests/Log/LogManagerTest.php @@ -4,11 +4,10 @@ namespace Hypervel\Tests\Log; -use Hyperf\Config\Config; -use Hyperf\Context\Context; -use Hyperf\Contract\ConfigInterface; use Hyperf\Di\Container; use Hyperf\Di\Definition\DefinitionSource; +use Hypervel\Config\Repository as ConfigRepository; +use Hypervel\Context\Context; use Hypervel\Log\Logger; use Hypervel\Log\LogManager; use Hypervel\Support\Environment; @@ -57,7 +56,7 @@ public function testLogManagerCachesLoggerInstances() public function testLogManagerGetDefaultDriver() { $manager = new LogManager($container = $this->getContainer(), new DispatcherStub()); - $container->get(ConfigInterface::class) + $container->get('config') ->set('logging.default', 'single'); $this->assertEmpty($manager->getChannels()); @@ -70,7 +69,7 @@ public function testLogManagerGetDefaultDriver() public function testStackChannel() { $manager = new LogManager($container = $this->getContainer(), new DispatcherStub()); - $config = $container->get(ConfigInterface::class); + $config = $container->get('config'); $config->set('logging.channels.stack', [ 'driver' => 'stack', @@ -116,7 +115,7 @@ public function testStackChannel() public function testLogManagerCreatesConfiguredMonologHandler() { $manager = new LogManager($container = $this->getContainer(), new DispatcherStub()); - $config = $container->get(ConfigInterface::class); + $config = $container->get('config'); $config->set('logging.channels.nonbubblingstream', [ 'driver' => 'monolog', 'name' => 'foobar', @@ -163,7 +162,7 @@ public function testLogManagerCreatesConfiguredMonologHandler() public function testLogManagerCreatesMonologHandlerWithConfiguredFormatter() { $manager = new LogManager($container = $this->getContainer(), new DispatcherStub()); - $config = $container->get(ConfigInterface::class); + $config = $container->get('config'); $config->set('logging.channels.newrelic', [ 'driver' => 'monolog', 'name' => 'nr', @@ -203,7 +202,7 @@ public function testLogManagerCreatesMonologHandlerWithConfiguredFormatter() public function testLogManagerCreatesMonologHandlerWithProperFormatter() { $manager = new LogManager($container = $this->getContainer(), new DispatcherStub()); - $config = $container->get(ConfigInterface::class); + $config = $container->get('config'); $config->set('logging.channels.null', [ 'driver' => 'monolog', 'handler' => NullHandler::class, @@ -230,7 +229,7 @@ public function testLogManagerCreatesMonologHandlerWithProperFormatter() public function testLogManagerCreatesMonologHandlerWithProcessors() { $manager = new LogManager($container = $this->getContainer(), new DispatcherStub()); - $config = $container->get(ConfigInterface::class); + $config = $container->get('config'); $config->set('logging.channels.memory', [ 'driver' => 'monolog', 'name' => 'memory', @@ -268,7 +267,7 @@ protected function createEmergencyLogger(): LoggerInterface }; $container->get(Environment::class)->set('testing'); - $config = $container->get(ConfigInterface::class); + $config = $container->get('config'); $config->set('logging.default', null); $config->set('logging.channels.null', [ 'driver' => 'monolog', @@ -294,7 +293,7 @@ protected function createEmergencyLogger(): LoggerInterface public function testLogManagerCreateSingleDriverWithConfiguredFormatter() { $manager = new LogManager($container = $this->getContainer(), new DispatcherStub()); - $config = $container->get(ConfigInterface::class); + $config = $container->get('config'); $config->set('logging.channels.defaultsingle', [ 'driver' => 'single', 'name' => 'ds', @@ -338,7 +337,7 @@ public function testLogManagerCreateSingleDriverWithConfiguredFormatter() public function testLogManagerCreateDailyDriverWithConfiguredFormatter() { $manager = new LogManager($container = $this->getContainer(), new DispatcherStub()); - $config = $container->get(ConfigInterface::class); + $config = $container->get('config'); $config->set('logging.channels.defaultdaily', [ 'driver' => 'daily', 'name' => 'dd', @@ -382,7 +381,7 @@ public function testLogManagerCreateDailyDriverWithConfiguredFormatter() public function testLogManagerCreateSyslogDriverWithConfiguredFormatter() { $manager = new LogManager($container = $this->getContainer(), new DispatcherStub()); - $config = $container->get(ConfigInterface::class); + $config = $container->get('config'); $config->set('logging.channels.defaultsyslog', [ 'driver' => 'syslog', 'name' => 'ds', @@ -456,7 +455,7 @@ public function testLogManagerCanBuildOnDemandChannel() public function testLogManagerCanUseOnDemandChannelInOnDemandStack() { $manager = new LogManager($container = $this->getContainer(), new DispatcherStub()); - $container->get(ConfigInterface::class) + $container->get('config') ->set('logging.channels.test', [ 'driver' => 'single', 'path' => $path = __DIR__ . '/logs/custom.log', @@ -492,7 +491,7 @@ public function __invoke() public function testWrappingHandlerInFingersCrossedWhenActionLevelIsUsed() { $manager = new LogManager($container = $this->getContainer(), new DispatcherStub()); - $container->get(ConfigInterface::class) + $container->get('config') ->set('logging.channels.fingerscrossed', [ 'driver' => 'monolog', 'handler' => StreamHandler::class, @@ -535,7 +534,7 @@ public function testWrappingHandlerInFingersCrossedWhenActionLevelIsUsed() public function testFingersCrossedHandlerStopsRecordBufferingAfterFirstFlushByDefault() { $manager = new LogManager($container = $this->getContainer(), new DispatcherStub()); - $container->get(ConfigInterface::class) + $container->get('config') ->set('logging.channels.fingerscrossed', [ 'driver' => 'monolog', 'handler' => StreamHandler::class, @@ -562,7 +561,7 @@ public function testFingersCrossedHandlerStopsRecordBufferingAfterFirstFlushByDe public function testFingersCrossedHandlerCanBeConfiguredToResumeBufferingAfterFlushing() { $manager = new LogManager($container = $this->getContainer(), new DispatcherStub()); - $container->get(ConfigInterface::class) + $container->get('config') ->set('logging.channels.fingerscrossed', [ 'driver' => 'monolog', 'handler' => StreamHandler::class, @@ -590,7 +589,7 @@ public function testFingersCrossedHandlerCanBeConfiguredToResumeBufferingAfterFl public function testItSharesContextWithAlreadyResolvedChannels() { $manager = new LogManager($container = $this->getContainer(), new DispatcherStub()); - $config = $container->get(ConfigInterface::class); + $config = $container->get('config'); $config->set('logging.default', null); $config->set('logging.channels.null', [ 'driver' => 'monolog', @@ -613,7 +612,7 @@ public function testItSharesContextWithAlreadyResolvedChannels() public function testItSharesContextWithFreshlyResolvedChannels() { $manager = new LogManager($container = $this->getContainer(), new DispatcherStub()); - $config = $container->get(ConfigInterface::class); + $config = $container->get('config'); $config->set('logging.default', null); $config->set('logging.channels.null', [ 'driver' => 'monolog', @@ -646,7 +645,7 @@ public function testContextCanBePubliclyAccessedByOtherLoggingSystems() public function testItSharesContextWithStacksWhenTheyAreResolved() { $manager = new LogManager($container = $this->getContainer(), new DispatcherStub()); - $config = $container->get(ConfigInterface::class); + $config = $container->get('config'); $config->set('logging.default', null); $config->set('logging.channels.null', [ 'driver' => 'monolog', @@ -670,7 +669,7 @@ public function testItSharesContextWithStacksWhenTheyAreResolved() public function testItMergesSharedContextRatherThanReplacing() { $manager = new LogManager($container = $this->getContainer(), new DispatcherStub()); - $config = $container->get(ConfigInterface::class); + $config = $container->get('config'); $config->set('logging.default', null); $config->set('logging.channels.null', [ 'driver' => 'monolog', @@ -719,7 +718,7 @@ public function testFlushSharedContext() public function testLogManagerCreateCustomFormatterWithTap() { $manager = new LogManager($container = $this->getContainer(), new DispatcherStub()); - $container->get(ConfigInterface::class) + $container->get('config') ->set('logging.channels.custom', [ 'driver' => 'single', 'tap' => [CustomizeFormatter::class], @@ -742,7 +741,7 @@ public function testLogManagerCreateCustomFormatterWithTap() protected function getContainer(array $logConfig = []) { - $config = new Config([ + $config = new ConfigRepository([ 'logging' => array_merge([ 'channels' => [ 'single' => [ @@ -754,7 +753,7 @@ protected function getContainer(array $logConfig = []) ]); return new Container( new DefinitionSource([ - ConfigInterface::class => fn () => $config, + 'config' => fn () => $config, EventDispatcherInterface::class => fn () => new DispatcherStub(), ]) ); 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..6d2ee4b65 100644 --- a/tests/Mail/MailFailoverTransportTest.php +++ b/tests/Mail/MailFailoverTransportTest.php @@ -4,18 +4,10 @@ namespace Hypervel\Tests\Mail; -use Hyperf\Config\Config; -use Hyperf\Context\ApplicationContext; -use Hyperf\Contract\ConfigInterface; -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\Mail\MailManager; -use Mockery; -use PHPUnit\Framework\TestCase; -use Psr\Container\ContainerInterface; -use Psr\EventDispatcher\EventDispatcherInterface; +use Hyperf\ViewEngine\Contract\FactoryInterface as ViewFactory; +use Hypervel\Contracts\Mail\Factory as FactoryContract; +use Hypervel\Testbench\TestCase; +use Mockery as m; use Symfony\Component\Mailer\Transport\FailoverTransport; /** @@ -24,9 +16,15 @@ */ class MailFailoverTransportTest extends TestCase { + protected function setUp(): void + { + parent::setUp(); + $this->app->set(ViewFactory::class, m::mock(ViewFactory::class)); + } + public function testGetFailoverTransportWithConfiguredTransports() { - $container = $this->getContainer([ + $this->app->get('config')->set('mail', [ 'default' => 'failover', 'mailers' => [ 'failover' => [ @@ -48,7 +46,7 @@ public function testGetFailoverTransportWithConfiguredTransports() ], ]); - $transport = $container->get(FactoryContract::class) + $transport = $this->app->get(FactoryContract::class) ->removePoolable('failover') ->getSymfonyTransport(); $this->assertInstanceOf(FailoverTransport::class, $transport); @@ -56,31 +54,15 @@ public function testGetFailoverTransportWithConfiguredTransports() public function testGetFailoverTransportWithLaravel6StyleMailConfiguration() { - $container = $this->getContainer([ + $this->app->get('config')->set('mail', [ 'driver' => 'failover', 'mailers' => ['sendmail', 'array'], 'sendmail' => '/usr/sbin/sendmail -bs', ]); - $transport = $container->get(FactoryContract::class) + $transport = $this->app->get(FactoryContract::class) ->removePoolable('failover') ->getSymfonyTransport(); $this->assertInstanceOf(FailoverTransport::class, $transport); } - - protected function getContainer(array $config = []): ContainerInterface - { - $container = new Container( - new DefinitionSource([ - ConfigInterface::class => fn () => new Config(['mail' => $config]), - FactoryContract::class => MailManager::class, - ViewInterface::class => fn () => Mockery::mock(ViewInterface::class), - EventDispatcherInterface::class => fn () => Mockery::mock(EventDispatcherInterface::class), - ]) - ); - - ApplicationContext::setContainer($container); - - return $container; - } } diff --git a/tests/Mail/MailLogTransportTest.php b/tests/Mail/MailLogTransportTest.php index 97fd97713..b7a1c248a 100644 --- a/tests/Mail/MailLogTransportTest.php +++ b/tests/Mail/MailLogTransportTest.php @@ -4,21 +4,13 @@ namespace Hypervel\Tests\Mail; -use Hyperf\Config\Config; -use Hyperf\Context\ApplicationContext; -use Hyperf\Contract\ConfigInterface; -use Hyperf\Di\Container; -use Hyperf\Di\Definition\DefinitionSource; -use Hyperf\ViewEngine\Contract\FactoryInterface as ViewInterface; +use Hyperf\ViewEngine\Contract\FactoryInterface as ViewFactory; +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; -use Mockery; -use PHPUnit\Framework\TestCase; -use Psr\Container\ContainerInterface; -use Psr\EventDispatcher\EventDispatcherInterface; +use Hypervel\Testbench\TestCase; +use Mockery as m; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; use Stringable; @@ -30,24 +22,28 @@ */ class MailLogTransportTest extends TestCase { + protected function setUp(): void + { + parent::setUp(); + $this->app->set(ViewFactory::class, m::mock(ViewFactory::class)); + } + public function testGetLogTransportWithConfiguredChannel() { - $container = $this->getContainer([ - 'mail' => [ - 'driver' => 'log', - 'log_channel' => 'mail', - ], - 'logging' => [ - 'channels' => [ - 'mail' => [ - 'driver' => 'single', - 'path' => 'mail.log', - ], + $this->app->get('config')->set('mail', [ + 'driver' => 'log', + 'log_channel' => 'mail', + ]); + $this->app->get('config')->set('logging', [ + 'channels' => [ + 'mail' => [ + 'driver' => 'single', + 'path' => 'mail.log', ], ], ]); - $transport = $container->get(FactoryContract::class) + $transport = $this->app->get(FactoryContract::class) ->removePoolable('log') ->getSymfonyTransport(); $this->assertInstanceOf(LogTransport::class, $transport); @@ -108,19 +104,16 @@ public function testItOnlyDecodesQuotedPrintablePartsOfTheMessageBeforeLogging() public function testGetLogTransportWithPsrLogger() { - $container = $this->getContainer([ - 'mail' => [ - 'driver' => 'log', - ], + $this->app->get('config')->set('mail', [ + 'driver' => 'log', ]); - /** @var Container $container */ - $container->set(LoggerInterface::class, new NullLogger()); + $this->app->set(LoggerInterface::class, new NullLogger()); - $transportLogger = $container->get(FactoryContract::class)->getSymfonyTransport()->logger(); + $transportLogger = $this->app->get(FactoryContract::class)->getSymfonyTransport()->logger(); $this->assertEquals( - $container->get(LoggerInterface::class), + $this->app->get(LoggerInterface::class), $transportLogger ); } @@ -142,21 +135,4 @@ public function log($level, string|Stringable $message, array $context = []): vo return $logger->loggedValue; } - - protected function getContainer(array $config = []): ContainerInterface - { - $container = new Container( - new DefinitionSource([ - ConfigInterface::class => fn () => new Config($config), - FactoryContract::class => MailManager::class, - ViewInterface::class => fn () => Mockery::mock(ViewInterface::class), - EventDispatcherInterface::class => fn () => Mockery::mock(EventDispatcherInterface::class), - LoggerInterface::class => fn () => Mockery::mock(LoggerInterface::class), - ]) - ); - - ApplicationContext::setContainer($container); - - return $container; - } } diff --git a/tests/Mail/MailMailableTest.php b/tests/Mail/MailMailableTest.php index 1f9adced0..4b48a12f2 100644 --- a/tests/Mail/MailMailableTest.php +++ b/tests/Mail/MailMailableTest.php @@ -4,26 +4,21 @@ namespace Hypervel\Tests\Mail; -use Hyperf\Context\ApplicationContext; -use Hyperf\Contract\ConfigInterface; -use Hyperf\Di\Container; -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; use Hypervel\Mail\Mailer; use Hypervel\Mail\MailManager; use Hypervel\Mail\Transport\ArrayTransport; +use Hypervel\Testbench\TestCase; use Mockery as m; use PHPUnit\Framework\AssertionFailedError; -use PHPUnit\Framework\TestCase; -use Psr\EventDispatcher\EventDispatcherInterface; /** * @internal @@ -31,11 +26,6 @@ */ class MailMailableTest extends TestCase { - protected function tearDown(): void - { - m::close(); - } - public function testMailableSetsRecipientsCorrectly() { $this->mockContainer(); @@ -1046,6 +1036,8 @@ public function build() public function testAssertHasAttachmentFromStorage() { + $this->mockContainer(); + $mailable = new class extends Mailable { public function build() { @@ -1149,23 +1141,15 @@ public function build() $mailable->assertHasSubject('test subject'); } - protected function mockContainer() + protected function mockContainer(): void { $mailer = m::mock(MailerContract::class); $mailer->shouldReceive('render') ->andReturn(''); - $container = new Container( - new DefinitionSource([ - ConfigInterface::class => fn () => m::mock(ConfigInterface::class), - FactoryContract::class => MailManager::class, - ViewFactory::class => ViewFactory::class, - EventDispatcherInterface::class => fn () => m::mock(EventDispatcherInterface::class), - MailerContract::class => fn () => $mailer, - ]) - ); - - ApplicationContext::setContainer($container); + $this->app->bind(FactoryContract::class, MailManager::class); + $this->app->set(ViewFactory::class, m::mock(ViewFactory::class)); + $this->app->set(MailerContract::class, $mailer); } protected function mockView() diff --git a/tests/Mail/MailMailerTest.php b/tests/Mail/MailMailerTest.php index 13d5d0d2b..2e37c50ac 100644 --- a/tests/Mail/MailMailerTest.php +++ b/tests/Mail/MailMailerTest.php @@ -4,12 +4,8 @@ namespace Hypervel\Tests\Mail; -use Hyperf\Contract\ConfigInterface; -use Hyperf\Di\Container; -use Hyperf\Di\Definition\DefinitionSource; use Hyperf\ViewEngine\Contract\FactoryInterface as ViewFactory; use Hyperf\ViewEngine\Contract\ViewInterface; -use Hypervel\Context\ApplicationContext; use Hypervel\Mail\Events\MessageSending; use Hypervel\Mail\Events\MessageSent; use Hypervel\Mail\Mailable; @@ -17,8 +13,8 @@ use Hypervel\Mail\Message; use Hypervel\Mail\Transport\ArrayTransport; use Hypervel\Support\HtmlString; +use Hypervel\Testbench\TestCase; use Mockery as m; -use PHPUnit\Framework\TestCase; use Psr\EventDispatcher\EventDispatcherInterface; /** @@ -27,18 +23,10 @@ */ class MailMailerTest extends TestCase { - protected ?Container $app = null; - - protected function setUp(): void - { - $this->app = $this->mockContainer(); - } - protected function tearDown(): void { unset($_SERVER['__mailer.test']); - - m::close(); + parent::tearDown(); } public function testMailerSendSendsMessageWithProperViewContent() @@ -328,19 +316,9 @@ public function testMacroable() ); } - protected function mockContainer(): Container + protected function mockContainer(): void { - $container = new Container( - new DefinitionSource([ - ConfigInterface::class => fn () => m::mock(ConfigInterface::class), - ViewFactory::class => ViewFactory::class, - EventDispatcherInterface::class => fn () => m::mock(EventDispatcherInterface::class), - ]) - ); - - ApplicationContext::setContainer($container); - - return $container; + $this->app->set(ViewFactory::class, m::mock(ViewFactory::class)); } protected function mockView() diff --git a/tests/Mail/MailManagerTest.php b/tests/Mail/MailManagerTest.php index 207e71355..ab9d55c9c 100644 --- a/tests/Mail/MailManagerTest.php +++ b/tests/Mail/MailManagerTest.php @@ -4,20 +4,12 @@ namespace Hypervel\Tests\Mail; -use Hyperf\Config\Config; -use Hyperf\Context\ApplicationContext; -use Hyperf\Contract\ConfigInterface; -use Hyperf\Di\Container; -use Hyperf\Di\Definition\DefinitionSource; use Hyperf\ViewEngine\Contract\FactoryInterface as ViewFactory; use Hypervel\Mail\MailManager; use Hypervel\Mail\TransportPoolProxy; -use Hypervel\ObjectPool\Contracts\Factory as PoolFactory; -use Hypervel\ObjectPool\PoolManager; +use Hypervel\Testbench\TestCase; use InvalidArgumentException; -use Mockery; -use PHPUnit\Framework\TestCase; -use Psr\EventDispatcher\EventDispatcherInterface; +use Mockery as m; use Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport; /** @@ -26,14 +18,19 @@ */ class MailManagerTest extends TestCase { + protected function setUp(): void + { + parent::setUp(); + $this->app->set(ViewFactory::class, m::mock(ViewFactory::class)); + } + /** * @dataProvider emptyTransportConfigDataProvider * @param mixed $transport */ public function testEmptyTransportConfig($transport) { - $container = $this->getContainer(); - $container->get(ConfigInterface::class) + $this->app->get('config') ->set('mail.mailers.custom_smtp', [ 'transport' => $transport, 'host' => null, @@ -47,7 +44,7 @@ public function testEmptyTransportConfig($transport) $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage("Unsupported mail transport [{$transport}]"); - (new MailManager($container)) + (new MailManager($this->app)) ->mailer('custom_smtp'); } @@ -62,13 +59,12 @@ public static function emptyTransportConfigDataProvider() public function testMailUrlConfig() { - $container = $this->getContainer(); - $container->get(ConfigInterface::class) + $this->app->get('config') ->set('mail.mailers.smtp_url', [ 'url' => 'smtp://usr:pwd@127.0.0.2:5876', ]); - $transport = (new MailManager($container)) + $transport = (new MailManager($this->app)) ->removePoolable('smtp') ->mailer('smtp_url') ->getSymfonyTransport(); // @phpstan-ignore-line @@ -82,32 +78,15 @@ public function testMailUrlConfig() public function testPoolableMailUrlConfig() { - $container = $this->getContainer(); - $container->get(ConfigInterface::class) + $this->app->get('config') ->set('mail.mailers.smtp_url', [ 'url' => 'smtp://usr:pwd@127.0.0.2:5876', ]); - $transport = (new MailManager($container)) + $transport = (new MailManager($this->app)) ->mailer('smtp_url') ->getSymfonyTransport(); // @phpstan-ignore-line $this->assertInstanceOf(TransportPoolProxy::class, $transport); } - - protected function getContainer(): Container - { - $container = new Container( - new DefinitionSource([ - ConfigInterface::class => fn () => new Config([]), - ViewFactory::class => fn () => Mockery::mock(ViewFactory::class), - EventDispatcherInterface::class => fn () => Mockery::mock(EventDispatcherInterface::class), - PoolFactory::class => PoolManager::class, - ]) - ); - - ApplicationContext::setContainer($container); - - return $container; - } } 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..556d7fc1f 100644 --- a/tests/Mail/MailSesTransportTest.php +++ b/tests/Mail/MailSesTransportTest.php @@ -7,19 +7,11 @@ use Aws\Command; use Aws\Exception\AwsException; use Aws\Ses\SesClient; -use Hyperf\Config\Config; -use Hyperf\Context\ApplicationContext; -use Hyperf\Contract\ConfigInterface; -use Hyperf\Di\Container; -use Hyperf\Di\Definition\DefinitionSource; use Hyperf\ViewEngine\Contract\FactoryInterface as ViewFactory; use Hypervel\Mail\MailManager; use Hypervel\Mail\Transport\SesTransport; -use Hypervel\ObjectPool\Contracts\Factory as PoolFactory; -use Hypervel\ObjectPool\PoolManager; +use Hypervel\Testbench\TestCase; use Mockery as m; -use PHPUnit\Framework\TestCase; -use Psr\EventDispatcher\EventDispatcherInterface; use Symfony\Component\Mailer\Exception\TransportException; use Symfony\Component\Mailer\Header\MetadataHeader; use Symfony\Component\Mime\Address; @@ -31,23 +23,21 @@ */ class MailSesTransportTest extends TestCase { - protected function tearDown(): void + protected function setUp(): void { - m::close(); - - parent::tearDown(); + parent::setUp(); + $this->app->set(ViewFactory::class, m::mock(ViewFactory::class)); } public function testGetTransport() { - $container = $this->mockContainer(); - $container->get(ConfigInterface::class)->set('services.ses', [ + $this->app->get('config')->set('services.ses', [ 'key' => 'foo', 'secret' => 'bar', 'region' => 'us-east-1', ]); - $manager = new MailManager($container); + $manager = new MailManager($this->app); /** @var \Hypervel\Mail\Transport\SesTransport $transport */ $transport = $manager->createSymfonyTransport(['transport' => 'ses']); @@ -107,8 +97,7 @@ public function testSendError() public function testSesLocalConfiguration() { - $container = $this->mockContainer(); - $container->get(ConfigInterface::class)->set('mail', [ + $this->app->get('config')->set('mail', [ 'mailers' => [ 'ses' => [ 'transport' => 'ses', @@ -122,13 +111,13 @@ public function testSesLocalConfiguration() ], ], ]); - $container->get(ConfigInterface::class)->set('services', [ + $this->app->get('config')->set('services', [ 'ses' => [ 'region' => 'us-east-1', ], ]); - $manager = new MailManager($container); + $manager = new MailManager($this->app); /** @var \Hypervel\Mail\Mailer $mailer */ $mailer = $manager->mailer('ses'); @@ -145,20 +134,4 @@ public function testSesLocalConfiguration() ], ], $transport->getOptions()); } - - protected function mockContainer(): Container - { - $container = new Container( - new DefinitionSource([ - ConfigInterface::class => fn () => new Config([]), - ViewFactory::class => fn () => m::mock(ViewFactory::class), - EventDispatcherInterface::class => fn () => m::mock(EventDispatcherInterface::class), - PoolFactory::class => PoolManager::class, - ]) - ); - - ApplicationContext::setContainer($container); - - return $container; - } } diff --git a/tests/Mail/MailSesV2TransportTest.php b/tests/Mail/MailSesV2TransportTest.php index 1f3eaddcc..96f5b1a75 100644 --- a/tests/Mail/MailSesV2TransportTest.php +++ b/tests/Mail/MailSesV2TransportTest.php @@ -7,17 +7,11 @@ use Aws\Command; use Aws\Exception\AwsException; use Aws\SesV2\SesV2Client; -use Hyperf\Config\Config; -use Hyperf\Context\ApplicationContext; -use Hyperf\Contract\ConfigInterface; -use Hyperf\Di\Container; -use Hyperf\Di\Definition\DefinitionSource; use Hyperf\ViewEngine\Contract\FactoryInterface as ViewFactory; use Hypervel\Mail\MailManager; use Hypervel\Mail\Transport\SesV2Transport; +use Hypervel\Testbench\TestCase; use Mockery as m; -use PHPUnit\Framework\TestCase; -use Psr\EventDispatcher\EventDispatcherInterface; use Symfony\Component\Mailer\Exception\TransportException; use Symfony\Component\Mailer\Header\MetadataHeader; use Symfony\Component\Mime\Address; @@ -29,23 +23,21 @@ */ class MailSesV2TransportTest extends TestCase { - protected function tearDown(): void + protected function setUp(): void { - m::close(); - - parent::tearDown(); + parent::setUp(); + $this->app->set(ViewFactory::class, m::mock(ViewFactory::class)); } public function testGetTransport() { - $container = $this->mockContainer(); - $container->get(ConfigInterface::class)->set('services.ses', [ + $this->app->get('config')->set('services.ses', [ 'key' => 'foo', 'secret' => 'bar', 'region' => 'us-east-1', ]); - $manager = new MailManager($container); + $manager = new MailManager($this->app); /** @var \Hypervel\Mail\Transport\SesV2Transport $transport */ $transport = $manager->createSymfonyTransport(['transport' => 'ses-v2']); @@ -105,8 +97,7 @@ public function testSendError() public function testSesV2LocalConfiguration() { - $container = $this->mockContainer(); - $container->get(ConfigInterface::class)->set('mail', [ + $this->app->get('config')->set('mail', [ 'mailers' => [ 'ses' => [ 'transport' => 'ses-v2', @@ -120,13 +111,13 @@ public function testSesV2LocalConfiguration() ], ], ]); - $container->get(ConfigInterface::class)->set('services', [ + $this->app->get('config')->set('services', [ 'ses' => [ 'region' => 'us-east-1', ], ]); - $manager = new MailManager($container); + $manager = new MailManager($this->app); /** @var \Hypervel\Mail\Mailer $mailer */ $mailer = $manager->mailer('ses'); @@ -143,19 +134,4 @@ public function testSesV2LocalConfiguration() ], ], $transport->getOptions()); } - - protected function mockContainer(): Container - { - $container = new Container( - new DefinitionSource([ - ConfigInterface::class => fn () => new Config([]), - ViewFactory::class => fn () => m::mock(ViewFactory::class), - EventDispatcherInterface::class => fn () => m::mock(EventDispatcherInterface::class), - ]) - ); - - ApplicationContext::setContainer($container); - - return $container; - } } diff --git a/tests/Mail/MailableQueuedTest.php b/tests/Mail/MailableQueuedTest.php index def875a02..47754261f 100644 --- a/tests/Mail/MailableQueuedTest.php +++ b/tests/Mail/MailableQueuedTest.php @@ -4,23 +4,18 @@ namespace Hypervel\Tests\Mail; -use Hyperf\Config\Config; -use Hyperf\Context\ApplicationContext; -use Hyperf\Contract\ConfigInterface; -use Hyperf\Di\Container; -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 Hypervel\Testbench\TestCase; use Mockery as m; -use PHPUnit\Framework\TestCase; use Symfony\Component\Mailer\Transport\TransportInterface; /** @@ -29,14 +24,15 @@ */ class MailableQueuedTest extends TestCase { - protected function tearDown(): void + protected function setUp(): void { - m::close(); + parent::setUp(); + $this->app->set(MailableContract::class, m::mock(MailableContract::class)); } public function testQueuedMailableSent() { - $queueFake = new QueueFake($this->getContainer()); + $queueFake = new QueueFake($this->app); $mailer = $this->getMockBuilder(Mailer::class) ->setConstructorArgs($this->getMocks()) ->onlyMethods(['createMessage', 'to']) @@ -50,7 +46,7 @@ public function testQueuedMailableSent() public function testQueuedMailableWithAttachmentSent() { - $queueFake = new QueueFake($this->getContainer()); + $queueFake = new QueueFake($this->app); $mailer = $this->getMockBuilder(Mailer::class) ->setConstructorArgs($this->getMocks()) ->onlyMethods(['createMessage']) @@ -67,16 +63,31 @@ public function testQueuedMailableWithAttachmentSent() $queueFake->assertPushedOn(null, SendQueuedMailable::class); } + public function testQueuedMailableReceivesMailableInstance() + { + $queueFake = new QueueFake($this->app); + $mailer = $this->getMockBuilder(Mailer::class) + ->setConstructorArgs($this->getMocks()) + ->onlyMethods(['createMessage', 'to']) + ->getMock(); + $mailer->setQueue($queueFake); + $mailable = new MailableQueueableStub(); + $mailer->send($mailable); + + $queueFake->assertPushed(SendQueuedMailable::class, function (SendQueuedMailable $job) use ($mailable) { + return $job->mailable === $mailable; + }); + } + public function testQueuedMailableWithAttachmentFromDiskSent() { - $app = $this->getContainer(); $this->getMockBuilder(Filesystem::class) ->getMock(); $filesystemFactory = $this->getMockBuilder(FilesystemManager::class) - ->setConstructorArgs([$app]) + ->setConstructorArgs([$this->app]) ->getMock(); - $app->set('filesystem', $filesystemFactory); - $queueFake = new QueueFake($app); + $this->app->set('filesystem', $filesystemFactory); + $queueFake = new QueueFake($this->app); $mailer = $this->getMockBuilder(Mailer::class) ->setConstructorArgs($this->getMocks()) ->onlyMethods(['createMessage']) @@ -100,20 +111,6 @@ protected function getMocks() { return ['smtp', m::mock(Factory::class), m::mock(TransportInterface::class)]; } - - protected function getContainer(array $config = []): Container - { - $container = new Container( - new DefinitionSource([ - ConfigInterface::class => fn () => new Config($config), - MailableContract::class => fn () => m::mock(MailableContract::class), - ]) - ); - - ApplicationContext::setContainer($container); - - return $container; - } } class MailableQueueableStub extends Mailable implements ShouldQueue 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..c58f573e4 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; @@ -24,6 +24,8 @@ class NodeTest extends TestCase { use RefreshDatabase; + protected bool $migrateRefresh = true; + protected function migrateFreshUsing(): array { return [ @@ -42,6 +44,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 @@ -420,8 +427,8 @@ public function testFailsToSaveNodeUntilParentIsSaved(): void { $this->expectException(BadMethodCallException::class); - $node = new Category(['title' => 'Node']); - $parent = new Category(['title' => 'Parent']); + $node = new Category(['name' => 'Node']); + $parent = new Category(['name' => 'Parent']); $node->appendTo($parent)->save(); } @@ -474,7 +481,7 @@ public function testToTreeBuildsWithDefaultOrder(): void public function testToTreeBuildsWithCustomOrder(): void { $tree = Category::whereBetween('_lft', [8, 17]) - ->orderBy('title') + ->orderBy('name') ->get() ->toTree(); @@ -994,7 +1001,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..5c0a4e315 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; @@ -19,6 +19,8 @@ class ScopedNodeTest extends TestCase { use RefreshDatabase; + protected bool $migrateRefresh = true; + protected function migrateFreshUsing(): array { return [ @@ -37,6 +39,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..e01df8fa1 100644 --- a/tests/Notifications/NotificationChannelManagerTest.php +++ b/tests/Notifications/NotificationChannelManagerTest.php @@ -4,13 +4,13 @@ namespace Hypervel\Tests\Notifications; -use Hyperf\Config\Config; -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\Config\Repository as ConfigRepository; +use Hypervel\Container\Container; 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 +21,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 +31,6 @@ */ class NotificationChannelManagerTest extends TestCase { - protected function tearDown(): void - { - m::close(); - } - public function testGetDefaultChannel() { $container = $this->getContainer(); @@ -132,7 +126,7 @@ protected function getContainer(): Container { $container = new Container( new DefinitionSource([ - ConfigInterface::class => fn () => new Config([]), + 'config' => fn () => new ConfigRepository([]), BusDispatcherContract::class => fn () => m::mock(BusDispatcherContract::class), EventDispatcherInterface::class => fn () => m::mock(EventDispatcherInterface::class), PoolFactory::class => PoolManager::class, 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..f61cb7b15 100644 --- a/tests/Notifications/NotificationRoutesNotificationsTest.php +++ b/tests/Notifications/NotificationRoutesNotificationsTest.php @@ -4,11 +4,11 @@ namespace Hypervel\Tests\Notifications; -use Hyperf\Context\ApplicationContext; -use Hyperf\Di\Container; use Hyperf\Di\Definition\DefinitionSource; +use Hypervel\Container\Container; +use Hypervel\Context\ApplicationContext; +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..53eb84b62 100644 --- a/tests/Notifications/SlackMessageTest.php +++ b/tests/Notifications/SlackMessageTest.php @@ -7,7 +7,7 @@ use Closure; use GuzzleHttp\Client as HttpClient; use GuzzleHttp\Psr7\Response; -use Hyperf\Config\Config; +use Hypervel\Config\Repository; use Hypervel\Notifications\Channels\SlackWebApiChannel; use Hypervel\Notifications\Notifiable; use Hypervel\Notifications\Notification; @@ -18,7 +18,7 @@ use Hypervel\Notifications\Slack\SlackMessage; use Hypervel\Notifications\Slack\SlackRoute; use LogicException; -use Mockery; +use Mockery as m; use PHPUnit\Framework\TestCase; use RuntimeException; @@ -34,7 +34,7 @@ class SlackMessageTest extends TestCase protected ?HttpClient $client = null; - protected ?Config $config = null; + protected ?Repository $config = null; public function setUp(): void { @@ -46,8 +46,6 @@ public function tearDown(): void $this->slackChannel = null; $this->client = null; $this->config = null; - - Mockery::close(); } public function testExceptionWhenNoTextOrBlock(): void @@ -718,8 +716,8 @@ public function testExceptionWithoutToken(): void protected function getSlackChannel(): SlackWebApiChannel { return new SlackWebApiChannel( - $this->client = Mockery::mock(HttpClient::class), - $this->config = new Config([]) + $this->client = m::mock(HttpClient::class), + $this->config = new Repository([]) ); } diff --git a/tests/ObjectPool/ObjectPoolTest.php b/tests/ObjectPool/ObjectPoolTest.php index 0bf956ff6..2d2a47ccf 100644 --- a/tests/ObjectPool/ObjectPoolTest.php +++ b/tests/ObjectPool/ObjectPoolTest.php @@ -4,12 +4,12 @@ namespace Hypervel\Tests\ObjectPool; -use Hyperf\Context\ApplicationContext; -use Hyperf\Coroutine\Coroutine; +use Hypervel\Context\ApplicationContext; +use Hypervel\Coroutine\Coroutine; use Hypervel\Foundation\Testing\Concerns\RunTestsInCoroutine; use Hypervel\Tests\ObjectPool\Stub\FooPool; use Hypervel\Tests\TestCase; -use Mockery; +use Mockery as m; use Psr\Container\ContainerInterface; use RuntimeException; use stdClass; @@ -129,7 +129,7 @@ public function testGetStats() protected function getContainer() { - $container = Mockery::mock(ContainerInterface::class); + $container = m::mock(ContainerInterface::class); ApplicationContext::setContainer($container); return $container; diff --git a/tests/ObjectPool/ObjectRecyclerTest.php b/tests/ObjectPool/ObjectRecyclerTest.php index ab59599ed..a492841e7 100644 --- a/tests/ObjectPool/ObjectRecyclerTest.php +++ b/tests/ObjectPool/ObjectRecyclerTest.php @@ -5,12 +5,12 @@ namespace Hypervel\Tests\ObjectPool; use Carbon\Carbon; -use Hyperf\Coordinator\Timer; +use Hypervel\Coordinator\Timer; use Hypervel\ObjectPool\Contracts\Factory as PoolFactory; use Hypervel\ObjectPool\Contracts\ObjectPool; use Hypervel\ObjectPool\ObjectRecycler; use Hypervel\Tests\TestCase; -use Mockery; +use Mockery as m; /** * @internal @@ -20,14 +20,14 @@ class ObjectRecyclerTest extends TestCase { public function testStart() { - $timer = Mockery::mock(Timer::class); + $timer = m::mock(Timer::class); $timer->shouldReceive('tick') ->once() - ->with($interval = 1.0, Mockery::type('Closure')) + ->with($interval = 1.0, m::type('Closure')) ->andReturn($timerId = 99); $recycler = new ObjectRecycler( - Mockery::mock(PoolFactory::class), + m::mock(PoolFactory::class), $interval ); $recycler->setTimer($timer); @@ -38,14 +38,14 @@ public function testStart() public function testStop() { - $timer = Mockery::mock(Timer::class); + $timer = m::mock(Timer::class); $timer->shouldReceive('tick') ->once() - ->with($interval = 1.0, Mockery::type('Closure')) + ->with($interval = 1.0, m::type('Closure')) ->andReturn($timerId = 99); $recycler = new ObjectRecycler( - Mockery::mock(PoolFactory::class), + m::mock(PoolFactory::class), $interval ); $recycler->setTimer($timer); @@ -64,12 +64,12 @@ public function testGetLastRecycledAt() { Carbon::setTestNow('2025-04-01 00:00:00'); - $pool = Mockery::mock(ObjectPool::class); + $pool = m::mock(ObjectPool::class); $pool->shouldReceive('getLastRecycledAt') ->once() ->andReturn($lastRecycledAt = Carbon::now()); - $manager = Mockery::mock(PoolFactory::class); + $manager = m::mock(PoolFactory::class); $manager->shouldReceive('get') ->once() ->with('foo') diff --git a/tests/ObjectPool/PoolManagerTest.php b/tests/ObjectPool/PoolManagerTest.php index c341804bf..fe6c26aaa 100644 --- a/tests/ObjectPool/PoolManagerTest.php +++ b/tests/ObjectPool/PoolManagerTest.php @@ -4,9 +4,9 @@ namespace Hypervel\Tests\ObjectPool; -use Hyperf\Context\ApplicationContext; -use Hyperf\Di\Container; use Hyperf\Di\Definition\DefinitionSource; +use Hypervel\Container\Container; +use Hypervel\Context\ApplicationContext; use Hypervel\Foundation\Testing\Concerns\RunTestsInCoroutine; use Hypervel\ObjectPool\Contracts\Factory as PoolFactory; use Hypervel\ObjectPool\ObjectPool; diff --git a/tests/ObjectPool/PoolProxyTest.php b/tests/ObjectPool/PoolProxyTest.php index 73a2d8b7d..34ac1c1d6 100644 --- a/tests/ObjectPool/PoolProxyTest.php +++ b/tests/ObjectPool/PoolProxyTest.php @@ -5,13 +5,13 @@ namespace Hypervel\Tests\ObjectPool; use Closure; -use Hyperf\Context\ApplicationContext; +use Hypervel\Context\ApplicationContext; +use Hypervel\Contracts\Container\Container as ContainerContract; use Hypervel\ObjectPool\Contracts\Factory as PoolFactory; use Hypervel\ObjectPool\ObjectPool; use Hypervel\ObjectPool\PoolProxy; use Mockery as m; use PHPUnit\Framework\TestCase; -use Psr\Container\ContainerInterface; /** * @internal @@ -34,7 +34,7 @@ public function testCallPoolProxy() ->once() ->andReturn($pool); - $container = m::mock(ContainerInterface::class); + $container = m::mock(ContainerContract::class); $container->shouldReceive('get') ->with(PoolFactory::class) ->once() diff --git a/tests/ObjectPool/SimpleObjectPoolTest.php b/tests/ObjectPool/SimpleObjectPoolTest.php index 4f52c782c..70158f5e0 100644 --- a/tests/ObjectPool/SimpleObjectPoolTest.php +++ b/tests/ObjectPool/SimpleObjectPoolTest.php @@ -4,10 +4,10 @@ namespace Hypervel\Tests\ObjectPool; -use Hyperf\Context\ApplicationContext; +use Hypervel\Context\ApplicationContext; use Hypervel\ObjectPool\SimpleObjectPool; use Hypervel\Tests\TestCase; -use Mockery; +use Mockery as m; use Psr\Container\ContainerInterface; use stdClass; @@ -28,7 +28,7 @@ public function testCreateObject() protected function getContainer() { - $container = Mockery::mock(ContainerInterface::class); + $container = m::mock(ContainerInterface::class); ApplicationContext::setContainer($container); return $container; diff --git a/tests/ObjectPool/TimeStrategyTest.php b/tests/ObjectPool/TimeStrategyTest.php index e3ec2d09e..dfbb93219 100644 --- a/tests/ObjectPool/TimeStrategyTest.php +++ b/tests/ObjectPool/TimeStrategyTest.php @@ -9,7 +9,7 @@ use Hypervel\ObjectPool\Contracts\Recycler; use Hypervel\ObjectPool\Strategies\TimeStrategy; use Hypervel\Tests\TestCase; -use Mockery; +use Mockery as m; use Psr\Container\ContainerInterface; /** @@ -31,7 +31,7 @@ public function testShouldNotRecycle() { Carbon::setTestNow('2025-04-01 00:00:00'); - $pool = Mockery::mock(ObjectPool::class); + $pool = m::mock(ObjectPool::class); $pool->shouldReceive('getLastRecycledAt') ->once() ->andReturn(Carbon::now()->subSeconds(3)); @@ -47,7 +47,7 @@ public function testShouldRecycle() { Carbon::setTestNow('2025-04-01 00:00:00'); - $pool = Mockery::mock(ObjectPool::class); + $pool = m::mock(ObjectPool::class); $pool->shouldReceive('getLastRecycledAt') ->once() ->andReturn(Carbon::now()->subSeconds(30)); @@ -63,7 +63,7 @@ public function testRecycle() { Carbon::setTestNow('2025-04-01 00:00:00'); - $pool = Mockery::mock(ObjectPool::class); + $pool = m::mock(ObjectPool::class); $pool->shouldReceive('getOption->getRecycleRatio') ->once() ->andReturn(0.5); @@ -87,12 +87,12 @@ public function testRecycle() protected function mockContainerWithInterval(float $interval): ContainerInterface { - $recycler = Mockery::mock(Recycler::class); + $recycler = m::mock(Recycler::class); $recycler->shouldReceive('getInterval') ->once() ->andReturn($interval); - $container = Mockery::mock(ContainerInterface::class); + $container = m::mock(ContainerInterface::class); $container->shouldReceive('get') ->with(Recycler::class) ->andReturn($recycler); diff --git a/tests/Pagination/PaginationResolverTest.php b/tests/Pagination/PaginationResolverTest.php new file mode 100644 index 000000000..5538e409a --- /dev/null +++ b/tests/Pagination/PaginationResolverTest.php @@ -0,0 +1,238 @@ +setUpMockRequest(['page' => '3']); + + PaginationState::resolveUsing($this->app); + + $this->assertSame(3, Paginator::resolveCurrentPage()); + } + + public function testCurrentPageResolverReturnsOneWhenNoRequest(): void + { + // No request in Context + Context::destroy(ServerRequestInterface::class); + + PaginationState::resolveUsing($this->app); + + $this->assertSame(1, Paginator::resolveCurrentPage()); + } + + public function testCurrentPageResolverReturnsOneForInvalidPage(): void + { + $this->setUpMockRequest(['page' => 'invalid']); + + PaginationState::resolveUsing($this->app); + + $this->assertSame(1, Paginator::resolveCurrentPage()); + } + + public function testCurrentPageResolverReturnsOneForNegativePage(): void + { + $this->setUpMockRequest(['page' => '-5']); + + PaginationState::resolveUsing($this->app); + + $this->assertSame(1, Paginator::resolveCurrentPage()); + } + + public function testCurrentCursorResolverReadsFromRequest(): void + { + $cursor = new Cursor(['id' => 10], true); + $this->setUpMockRequest(['cursor' => $cursor->encode()]); + + PaginationState::resolveUsing($this->app); + + $resolved = CursorPaginator::resolveCurrentCursor(); + + $this->assertInstanceOf(Cursor::class, $resolved); + $this->assertSame(10, $resolved->parameter('id')); + $this->assertTrue($resolved->pointsToNextItems()); + } + + public function testCurrentCursorResolverReturnsNullWhenNoRequest(): void + { + Context::destroy(ServerRequestInterface::class); + + PaginationState::resolveUsing($this->app); + + $this->assertNull(CursorPaginator::resolveCurrentCursor()); + } + + public function testCurrentCursorResolverReturnsNullForInvalidCursor(): void + { + $this->setUpMockRequest(['cursor' => 'not-valid-base64!@#']); + + PaginationState::resolveUsing($this->app); + + $this->assertNull(CursorPaginator::resolveCurrentCursor()); + } + + public function testCurrentPathResolverReadsFromRequest(): void + { + $this->setUpMockRequest([], 'https://example.com/users'); + + PaginationState::resolveUsing($this->app); + + $this->assertSame('https://example.com/users', Paginator::resolveCurrentPath()); + } + + public function testCurrentPathResolverReturnsSlashWhenNoRequest(): void + { + Context::destroy(ServerRequestInterface::class); + + PaginationState::resolveUsing($this->app); + + $this->assertSame('/', Paginator::resolveCurrentPath()); + } + + public function testQueryStringResolverReadsFromRequest(): void + { + $this->setUpMockRequest(['foo' => 'bar', 'baz' => 'qux']); + + PaginationState::resolveUsing($this->app); + + $this->assertSame(['foo' => 'bar', 'baz' => 'qux'], Paginator::resolveQueryString()); + } + + public function testQueryStringResolverReturnsEmptyArrayWhenNoRequest(): void + { + Context::destroy(ServerRequestInterface::class); + + PaginationState::resolveUsing($this->app); + + $this->assertSame([], Paginator::resolveQueryString()); + } + + public function testCoroutineIsolation(): void + { + PaginationState::resolveUsing($this->app); + + $channel = new Channel(2); + + // Coroutine 1: page 5 + go(function () use ($channel) { + $this->setUpMockRequest(['page' => '5']); + $channel->push(['coroutine' => 1, 'page' => Paginator::resolveCurrentPage()]); + }); + + // Coroutine 2: page 10 + go(function () use ($channel) { + $this->setUpMockRequest(['page' => '10']); + $channel->push(['coroutine' => 2, 'page' => Paginator::resolveCurrentPage()]); + }); + + $results = []; + $results[] = $channel->pop(1.0); + $results[] = $channel->pop(1.0); + + // Sort by coroutine number for consistent assertion + usort($results, fn ($a, $b) => $a['coroutine'] <=> $b['coroutine']); + + $this->assertSame(5, $results[0]['page']); + $this->assertSame(10, $results[1]['page']); + } + + public function testCursorCoroutineIsolation(): void + { + PaginationState::resolveUsing($this->app); + + $cursor1 = new Cursor(['id' => 100], true); + $cursor2 = new Cursor(['id' => 200], false); + + $channel = new Channel(2); + + go(function () use ($channel, $cursor1) { + $this->setUpMockRequest(['cursor' => $cursor1->encode()]); + $resolved = CursorPaginator::resolveCurrentCursor(); + $channel->push([ + 'coroutine' => 1, + 'id' => $resolved->parameter('id'), + 'pointsToNext' => $resolved->pointsToNextItems(), + ]); + }); + + go(function () use ($channel, $cursor2) { + $this->setUpMockRequest(['cursor' => $cursor2->encode()]); + $resolved = CursorPaginator::resolveCurrentCursor(); + $channel->push([ + 'coroutine' => 2, + 'id' => $resolved->parameter('id'), + 'pointsToNext' => $resolved->pointsToNextItems(), + ]); + }); + + $results = []; + $results[] = $channel->pop(1.0); + $results[] = $channel->pop(1.0); + + usort($results, fn ($a, $b) => $a['coroutine'] <=> $b['coroutine']); + + $this->assertSame(100, $results[0]['id']); + $this->assertTrue($results[0]['pointsToNext']); + $this->assertSame(200, $results[1]['id']); + $this->assertFalse($results[1]['pointsToNext']); + } + + /** + * Set up a mock request in Context with the given query parameters. + */ + protected function setUpMockRequest(array $queryParams = [], string $url = 'https://example.com'): void + { + $psrRequest = m::mock(ServerRequestPlusInterface::class); + $psrRequest->shouldReceive('getQueryParams')->andReturn($queryParams); + $psrRequest->shouldReceive('getParsedBody')->andReturn([]); + + Context::set(ServerRequestInterface::class, $psrRequest); + + // Create a Request instance that the resolvers will use + $request = m::mock(Request::class)->makePartial(); + $request->shouldReceive('input')->andReturnUsing(function ($key, $default = null) use ($queryParams) { + return $queryParams[$key] ?? $default; + }); + $request->shouldReceive('query')->andReturn($queryParams); + $request->shouldReceive('url')->andReturn($url); + + $this->app->instance('request', $request); + } +} diff --git a/tests/Permission/HasPermissionTest.php b/tests/Permission/HasPermissionTest.php index 88e895bbc..d57109794 100644 --- a/tests/Permission/HasPermissionTest.php +++ b/tests/Permission/HasPermissionTest.php @@ -41,25 +41,21 @@ protected function setUp(): void $this->viewPermission = Permission::create([ 'name' => 'view', 'guard_name' => 'web', - 'is_forbidden' => false, ]); $this->editPermission = Permission::create([ 'name' => 'edit', 'guard_name' => 'web', - 'is_forbidden' => false, ]); $this->managePermission = Permission::create([ 'name' => 'manage', 'guard_name' => 'web', - 'is_forbidden' => false, ]); $this->deletePermission = Permission::create([ 'name' => 'delete', 'guard_name' => 'web', - 'is_forbidden' => false, ]); // Create test role with permissions diff --git a/tests/Permission/Middlewares/PermissionMiddlewareTest.php b/tests/Permission/Middlewares/PermissionMiddlewareTest.php index 644386b17..ddac9397a 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(); } @@ -109,7 +108,6 @@ public function testProcessSucceedsWhenUserHasPermission(): void Permission::create([ 'name' => 'view', 'guard_name' => 'web', - 'is_forbidden' => false, ]); $user->givePermissionTo('view'); @@ -132,7 +130,6 @@ public function testProcessWithMultiplePermissionsSucceedsWhenUserHasAny(): void Permission::create([ 'name' => 'view', 'guard_name' => 'web', - 'is_forbidden' => false, ]); $user->givePermissionTo('view'); 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/PermissionManagerTest.php b/tests/Permission/PermissionManagerTest.php index 24017971d..380a4c635 100644 --- a/tests/Permission/PermissionManagerTest.php +++ b/tests/Permission/PermissionManagerTest.php @@ -43,13 +43,11 @@ protected function setUp(): void $this->viewPermission = Permission::create([ 'name' => 'view', 'guard_name' => 'web', - 'is_forbidden' => false, ]); $this->editPermission = Permission::create([ 'name' => 'edit', 'guard_name' => 'web', - 'is_forbidden' => false, ]); // Create test roles diff --git a/tests/Permission/PermissionTestCase.php b/tests/Permission/PermissionTestCase.php index 915569d64..5f2fbc51a 100644 --- a/tests/Permission/PermissionTestCase.php +++ b/tests/Permission/PermissionTestCase.php @@ -4,8 +4,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; @@ -25,7 +24,7 @@ protected function setUp(): void { parent::setUp(); - $this->app->get(ConfigInterface::class) + $this->app->get('config') ->set('cache', [ 'default' => env('CACHE_DRIVER', 'array'), 'stores' => [ @@ -36,7 +35,7 @@ protected function setUp(): void 'prefix' => env('CACHE_PREFIX', 'hypervel_cache'), ]); - $this->app->get(ConfigInterface::class) + $this->app->get('config') ->set('permission', [ 'models' => [ 'role' => \Hypervel\Permission\Models\Role::class, 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/Pool/ConnectionTest.php b/tests/Pool/ConnectionTest.php new file mode 100644 index 000000000..35faaf52e --- /dev/null +++ b/tests/Pool/ConnectionTest.php @@ -0,0 +1,71 @@ +shouldReceive('warning')->withAnyArgs()->once()->andReturnTrue(); + $container->shouldReceive('has')->with(StdoutLoggerInterface::class)->once()->andReturnTrue(); + $container->shouldReceive('get')->with(StdoutLoggerInterface::class)->once()->andReturn($logger); + $container->shouldReceive('has')->with(EventDispatcherInterface::class)->andReturnFalse(); + + $connection = new ActiveConnectionStub($container, m::mock(Pool::class)); + $this->assertEquals($connection, $connection->getConnection()); + } + + public function testReleaseConnectionEvent() + { + $assert = 0; + $container = m::mock(ContainerContract::class); + $container->shouldReceive('has')->with(StdoutLoggerInterface::class)->once()->andReturnFalse(); + $container->shouldReceive('has')->with(EventDispatcherInterface::class)->andReturnTrue(); + $container->shouldReceive('get')->with(EventDispatcherInterface::class)->andReturn($dispatcher = m::mock(EventDispatcherInterface::class)); + $dispatcher->shouldReceive('dispatch')->once()->with(ReleaseConnection::class)->andReturnUsing(function (ReleaseConnection $event) use (&$assert) { + $assert = $event->connection->getLastReleaseTime(); + }); + + $connection = new ActiveConnectionStub($container, $pool = m::mock(Pool::class)); + $pool->shouldReceive('release')->withAnyArgs()->andReturnNull(); + $pool->shouldReceive('getOption')->andReturn(new PoolOption(events: [ReleaseConnection::class])); + + $connection->release(); + $this->assertTrue($assert > 0); + } + + public function testDontHaveEvents() + { + $container = m::mock(ContainerContract::class); + $container->shouldReceive('has')->with(StdoutLoggerInterface::class)->once()->andReturnFalse(); + $container->shouldReceive('has')->with(EventDispatcherInterface::class)->andReturnTrue(); + $container->shouldReceive('get')->with(EventDispatcherInterface::class)->andReturn($dispatcher = m::mock(EventDispatcherInterface::class)); + $dispatcher->shouldReceive('dispatch')->never()->with(ReleaseConnection::class)->andReturnNull(); + + $connection = new ActiveConnectionStub($container, $pool = m::mock(Pool::class)); + $pool->shouldReceive('release')->withAnyArgs()->andReturnNull(); + $pool->shouldReceive('getOption')->andReturn(new PoolOption(events: [])); + + $connection->release(); + + $this->assertTrue(true); + } +} diff --git a/tests/Pool/FrequencyTest.php b/tests/Pool/FrequencyTest.php new file mode 100644 index 000000000..f63d8dded --- /dev/null +++ b/tests/Pool/FrequencyTest.php @@ -0,0 +1,90 @@ +setBeginTime($now - 4); + $frequency->setHits([ + $now => 1, + $now - 1 => 10, + $now - 2 => 10, + $now - 3 => 10, + $now - 4 => 10, + ]); + + $num = $frequency->frequency(); + $this->assertSame(41 / 5, $num); + + $frequency->hit(); + $num = $frequency->frequency(); + $this->assertSame(42 / 5, $num); + } + + public function testConstantFrequency() + { + $pool = m::mock(Pool::class); + $channel = new Channel(100); + $pool->shouldReceive('flushOne')->andReturnUsing(function () use ($channel) { + $channel->push(m::mock(ConnectionInterface::class)); + }); + + $stub = new ConstantFrequencyStub($pool); + Coroutine::sleep(0.005); + $stub->clear(); + $this->assertGreaterThan(0, $channel->length()); + } + + public function testFrequencyHitOneSecondAfter() + { + $frequency = new FrequencyStub(); + $now = time(); + + $frequency->setBeginTime($now - 4); + $frequency->setHits([ + $now => 1, + $now - 1 => 10, + $now - 2 => 10, + $now - 4 => 10, + ]); + $num = $frequency->frequency(); + $this->assertSame(31 / 5, $num); + $frequency->hit(); + $num = $frequency->frequency(); + $this->assertSame(32 / 5, $num); + + $frequency->setHits([ + $now => 1, + $now - 1 => 10, + $now - 2 => 10, + $now - 3 => 10, + ]); + $num = $frequency->frequency(); + $this->assertSame(31 / 5, $num); + $frequency->hit(); + $num = $frequency->frequency(); + $this->assertSame(32 / 5, $num); + } +} diff --git a/tests/Pool/HeartbeatConnectionTest.php b/tests/Pool/HeartbeatConnectionTest.php new file mode 100644 index 000000000..8c58befd1 --- /dev/null +++ b/tests/Pool/HeartbeatConnectionTest.php @@ -0,0 +1,129 @@ +getContainer(); + $pool = $container->get(HeartbeatPoolStub::class); + $connection = $pool->get(); + + $this->assertInstanceOf(KeepaliveConnectionStub::class, $connection); + $this->assertSame(1, $pool->getCurrentConnections()); + $this->assertSame(0, $pool->getConnectionsInChannel()); + + $connection = $pool->get(); + $this->assertSame(2, $pool->getCurrentConnections()); + $this->assertSame(0, $pool->getConnectionsInChannel()); + + $connection->release(); + $this->assertSame(1, $pool->getConnectionsInChannel()); + + $connection = $pool->get(); + $this->assertSame(0, $pool->getConnectionsInChannel()); + $this->assertSame(2, $pool->getCurrentConnections()); + } + + public function testConnectionCall() + { + $container = $this->getContainer(); + $pool = $container->get(HeartbeatPoolStub::class); + /** @var KeepaliveConnectionStub $connection */ + $connection = $pool->get(); + $connection->setActiveConnection($conn = new class { + public function send(string $data) + { + return str_repeat($data, 2); + } + }); + $str = uniqid(); + $result = $connection->call(function ($connection) use ($str) { + return $connection->send($str); + }); + + $this->assertSame($result, str_repeat($str, 2)); + } + + public function testConnectionHeartbeat() + { + $container = $this->getContainer(); + $pool = $container->get(HeartbeatPoolStub::class); + /** @var KeepaliveConnectionStub $connection */ + $connection = $pool->get(); + $connection->reconnect(); + $timer = $connection->timer; + $this->assertSame(1, count((new ClassInvoker($timer))->closures)); + $this->assertTrue($connection->check()); + $connection->close(); + $this->assertSame(0, count((new ClassInvoker($timer))->closures)); + $this->assertFalse($connection->check()); + $this->assertSame('close protocol', Context::get('test.pool.heartbeat_connection')['close']); + } + + public function testConnectionDestruct() + { + $container = $this->getContainer(); + $pool = $container->get(HeartbeatPoolStub::class); + /** @var KeepaliveConnectionStub $connection */ + $connection = $pool->get(); + $connection->reconnect(); + $connection->release(); + + $connection = $pool->get(); + $connection->reconnect(); + $connection->release(); + + $pool->flush(); + + $this->assertSame('close protocol', Context::get('test.pool.heartbeat_connection')['close']); + } + + protected function getContainer() + { + $container = m::mock(ContainerContract::class); + ApplicationContext::setContainer($container); + + $container->shouldReceive('get')->with(HeartbeatPoolStub::class)->andReturnUsing(function () use ($container) { + return new HeartbeatPoolStub($container, []); + }); + + return $container; + } +} diff --git a/tests/Pool/PoolTest.php b/tests/Pool/PoolTest.php new file mode 100644 index 000000000..66d63d1d7 --- /dev/null +++ b/tests/Pool/PoolTest.php @@ -0,0 +1,150 @@ +getContainer(); + $container->shouldReceive('has')->with(StdoutLoggerInterface::class)->andReturn(true); + $container->shouldReceive('get')->with(StdoutLoggerInterface::class)->andReturn((function () { + $logger = m::mock(StdoutLoggerInterface::class); + $logger->shouldReceive('error')->withAnyArgs()->times(4)->andReturn(true); + return $logger; + })()); + $pool = new FooPool($container, []); + + $conns = []; + for ($i = 0; $i < 5; ++$i) { + $conns[] = $pool->get(); + } + + foreach ($conns as $conn) { + $pool->release($conn); + } + + $pool->flush(); + $this->assertSame(1, $pool->getConnectionsInChannel()); + $this->assertSame(1, $pool->getCurrentConnections()); + } + + public function testPoolFlushOne() + { + $container = $this->getContainer(); + $container->shouldReceive('has')->with(StdoutLoggerInterface::class)->andReturn(true); + $container->shouldReceive('get')->with(StdoutLoggerInterface::class)->andReturn((function () { + $logger = m::mock(StdoutLoggerInterface::class); + $logger->shouldReceive('error')->withAnyArgs()->times(3)->andReturn(true); + return $logger; + })()); + $pool = new FooPool($container, []); + + $conns = []; + $checks = [false, false, true, true, true]; + for ($i = 0; $i < 5; ++$i) { + $conn = $pool->get(); + $conn->shouldReceive('check')->andReturn(array_shift($checks)); + $conns[] = $conn; + } + + foreach ($conns as $conn) { + $pool->release($conn); + } + + $pool->flushOne(); + $this->assertSame(4, $pool->getConnectionsInChannel()); + $this->assertSame(4, $pool->getCurrentConnections()); + $pool->flushOne(true); + $this->assertSame(3, $pool->getConnectionsInChannel()); + $this->assertSame(3, $pool->getCurrentConnections()); + $pool->flushOne(true); + $this->assertSame(2, $pool->getConnectionsInChannel()); + $this->assertSame(2, $pool->getCurrentConnections()); + $pool->flushOne(); + $this->assertSame(2, $pool->getConnectionsInChannel()); + $this->assertSame(2, $pool->getCurrentConnections()); + } + + public function testPoolFlushAll() + { + $container = $this->getContainer(); + $container->shouldReceive('has')->with(StdoutLoggerInterface::class)->andReturn(true); + $container->shouldReceive('get')->with(StdoutLoggerInterface::class)->andReturn((function () { + $logger = m::mock(StdoutLoggerInterface::class); + $logger->shouldReceive('error')->withAnyArgs()->times(5)->andReturn(true); + return $logger; + })()); + $pool = new FooPool($container, []); + + $conns = []; + for ($i = 0; $i < 5; ++$i) { + $conns[] = $pool->get(); + } + + foreach ($conns as $conn) { + $pool->release($conn); + } + + $this->assertSame(5, $pool->getConnectionsInChannel()); + $this->assertSame(5, $pool->getCurrentConnections()); + + $pool->flushAll(); + + $this->assertSame(0, $pool->getConnectionsInChannel()); + $this->assertSame(0, $pool->getCurrentConnections()); + } + + public function testFrequenctHitFailed() + { + $container = $this->getContainer(); + $container->shouldReceive('has')->andReturnTrue(); + $logger = m::mock(StdoutLoggerInterface::class); + $logger->shouldReceive('error')->with(m::any())->once()->andReturnUsing(function ($args) { + $this->assertStringContainsString('Hit Failed', $args); + }); + $container->shouldReceive('get')->with(StdoutLoggerInterface::class)->andReturn($logger); + + $pool = new class($container, []) extends Pool { + public function __construct(ContainerContract $container, array $config = []) + { + parent::__construct($container, $config); + + $this->frequency = m::mock(FrequencyInterface::class); + $this->frequency->shouldReceive('hit')->andThrow(new RuntimeException('Hit Failed')); + } + + protected function createConnection(): ConnectionInterface + { + return m::mock(ConnectionInterface::class); + } + }; + + $this->assertInstanceOf(ConnectionInterface::class, $pool->get()); + } + + protected function getContainer() + { + $container = m::mock(ContainerContract::class); + ApplicationContext::setContainer($container); + + return $container; + } +} diff --git a/tests/Pool/Stub/ActiveConnectionStub.php b/tests/Pool/Stub/ActiveConnectionStub.php new file mode 100644 index 000000000..b7f7d6e63 --- /dev/null +++ b/tests/Pool/Stub/ActiveConnectionStub.php @@ -0,0 +1,33 @@ +count === 0) { + ++$this->count; + throw new Exception(); + } + + return $this; + } + + public function reconnect(): bool + { + return true; + } + + public function close(): bool + { + return true; + } +} diff --git a/tests/Pool/Stub/ConstantFrequencyStub.php b/tests/Pool/Stub/ConstantFrequencyStub.php new file mode 100644 index 000000000..949149071 --- /dev/null +++ b/tests/Pool/Stub/ConstantFrequencyStub.php @@ -0,0 +1,12 @@ +beginTime = $time; + } + + public function setHits(array $hits): void + { + $this->hits = $hits; + } + + public function getHits(): array + { + return $this->hits; + } +} diff --git a/tests/Pool/Stub/HeartbeatPoolStub.php b/tests/Pool/Stub/HeartbeatPoolStub.php new file mode 100644 index 000000000..5d9173c5d --- /dev/null +++ b/tests/Pool/Stub/HeartbeatPoolStub.php @@ -0,0 +1,16 @@ +container, $this); + } +} diff --git a/tests/Pool/Stub/KeepaliveConnectionStub.php b/tests/Pool/Stub/KeepaliveConnectionStub.php new file mode 100644 index 000000000..9a0876d3a --- /dev/null +++ b/tests/Pool/Stub/KeepaliveConnectionStub.php @@ -0,0 +1,40 @@ +activeConnection = $connection; + } + + protected function getActiveConnection(): mixed + { + return $this->activeConnection; + } + + protected function sendClose(mixed $connection): void + { + $data = Context::get('test.pool.heartbeat_connection', []); + $data['close'] = 'close protocol'; + Context::set('test.pool.heartbeat_connection', $data); + } + + protected function heartbeat(): void + { + $data = Context::get('test.pool.heartbeat_connection', []); + $data['heartbeat'] = 'heartbeat protocol'; + Context::set('test.pool.heartbeat_connection', $data); + } +} 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..575ba734f 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; @@ -17,7 +17,7 @@ use PHPUnit\Framework\TestCase; use Psr\EventDispatcher\EventDispatcherInterface; -use function Hyperf\Coroutine\run; +use function Hypervel\Coroutine\run; /** * @internal @@ -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('db.transactions', $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('db.transactions', $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..a2ad00435 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; @@ -17,7 +17,7 @@ use PHPUnit\Framework\TestCase; use Psr\EventDispatcher\EventDispatcherInterface; -use function Hyperf\Coroutine\run; +use function Hypervel\Coroutine\run; /** * @internal @@ -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('db.transactions', $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('db.transactions', $transactionManager); $defer->setContainer($container); run(fn () => $defer->push(new DeferQueueAfterCommitInterfaceJob())); diff --git a/tests/Queue/QueueDelayTest.php b/tests/Queue/QueueDelayTest.php index 885a0eaf5..154c41c73 100644 --- a/tests/Queue/QueueDelayTest.php +++ b/tests/Queue/QueueDelayTest.php @@ -4,14 +4,14 @@ namespace Hypervel\Tests\Queue; -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 Mockery; +use Hypervel\Container\Container; +use Hypervel\Context\ApplicationContext; +use Hypervel\Contracts\Bus\Dispatcher; +use Hypervel\Contracts\Queue\ShouldQueue; +use Mockery as m; use PHPUnit\Framework\TestCase; /** @@ -53,7 +53,7 @@ public function testPendingDispatchWithoutDelay() protected function mockContainer(): void { - $event = Mockery::mock(Dispatcher::class); + $event = m::mock(Dispatcher::class); $event->shouldReceive('dispatch'); $container = new Container( new DefinitionSource([ 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..59bdb7f99 100644 --- a/tests/Queue/QueueManagerTest.php +++ b/tests/Queue/QueueManagerTest.php @@ -4,16 +4,15 @@ namespace Hypervel\Tests\Queue; -use Hyperf\Config\Config; -use Hyperf\Context\ApplicationContext; -use Hyperf\Contract\ConfigInterface; -use Hyperf\Di\Container; use Hyperf\Di\Definition\DefinitionSource; -use Hypervel\Encryption\Contracts\Encrypter; +use Hypervel\Config\Repository as ConfigRepository; +use Hypervel\Container\Container; +use Hypervel\Context\ApplicationContext; +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,15 +24,10 @@ */ class QueueManagerTest extends TestCase { - protected function tearDown(): void - { - m::close(); - } - public function testDefaultConnectionCanBeResolved() { $container = $this->getContainer(); - $config = $container->get(ConfigInterface::class); + $config = $container->get('config'); $config->set('queue.default', 'sync'); $config->set('queue.connections.sync', ['driver' => 'sync']); @@ -54,7 +48,7 @@ public function testDefaultConnectionCanBeResolved() public function testOtherConnectionCanBeResolved() { $container = $this->getContainer(); - $config = $container->get(ConfigInterface::class); + $config = $container->get('config'); $config->set('queue.default', 'sync'); $config->set('queue.connections.foo', ['driver' => 'bar']); @@ -75,7 +69,7 @@ public function testOtherConnectionCanBeResolved() public function testNullConnectionCanBeResolved() { $container = $this->getContainer(); - $config = $container->get(ConfigInterface::class); + $config = $container->get('config'); $config->set('queue.default', 'null'); $manager = new QueueManager($container); @@ -95,7 +89,7 @@ public function testNullConnectionCanBeResolved() public function testAddPoolableConnector() { $container = $this->getContainer(); - $config = $container->get(ConfigInterface::class); + $config = $container->get('config'); $config->set('queue.default', 'sync'); $config->set('queue.connections.foo', ['driver' => 'bar']); @@ -113,7 +107,7 @@ protected function getContainer(): Container { $container = new Container( new DefinitionSource([ - ConfigInterface::class => fn () => new Config([]), + 'config' => fn () => new ConfigRepository([]), Encrypter::class => fn () => m::mock(Encrypter::class), PoolFactory::class => PoolManager::class, ]) 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..c0c2d8515 100644 --- a/tests/Queue/QueueRedisQueueTest.php +++ b/tests/Queue/QueueRedisQueueTest.php @@ -5,13 +5,13 @@ namespace Hypervel\Tests\Queue; 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\Redis\RedisFactory; +use Hypervel\Redis\RedisProxy; 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()); } @@ -42,7 +40,7 @@ public function testPushProperlyPushesJobOntoRedis() $queue->setContainer($container = m::spy(Container::class)); $queue->setConnectionName('default'); $redisProxy = m::mock(RedisProxy::class); - $redisProxy->shouldReceive('eval')->once()->with(LuaScripts::push(), ['queues:default', 'queues:default:notify', json_encode(['uuid' => $uuid, 'displayName' => 'foo', 'job' => 'foo', 'maxTries' => null, 'maxExceptions' => null, 'failOnTimeout' => false, 'backoff' => null, 'timeout' => null, 'data' => ['data'], 'id' => 'foo', 'attempts' => 0])], 2); + $redisProxy->shouldReceive('eval')->once()->with(LuaScripts::push(), 2, 'queues:default', 'queues:default:notify', json_encode(['uuid' => $uuid, 'displayName' => 'foo', 'job' => 'foo', 'maxTries' => null, 'maxExceptions' => null, 'failOnTimeout' => false, 'backoff' => null, 'timeout' => null, 'data' => ['data'], 'id' => 'foo', 'attempts' => 0])); $redis->shouldReceive('get')->once()->andReturn($redisProxy); $id = $queue->push('foo', ['data']); @@ -59,7 +57,7 @@ public function testPushProperlyPushesJobOntoRedisWithCustomPayloadHook() $queue->setContainer($container = m::spy(Container::class)); $queue->setConnectionName('default'); $redisProxy = m::mock(RedisProxy::class); - $redisProxy->shouldReceive('eval')->once()->with(LuaScripts::push(), ['queues:default', 'queues:default:notify', json_encode(['uuid' => $uuid, 'displayName' => 'foo', 'job' => 'foo', 'maxTries' => null, 'maxExceptions' => null, 'failOnTimeout' => false, 'backoff' => null, 'timeout' => null, 'data' => ['data'], 'custom' => 'taylor', 'id' => 'foo', 'attempts' => 0])], 2); + $redisProxy->shouldReceive('eval')->once()->with(LuaScripts::push(), 2, 'queues:default', 'queues:default:notify', json_encode(['uuid' => $uuid, 'displayName' => 'foo', 'job' => 'foo', 'maxTries' => null, 'maxExceptions' => null, 'failOnTimeout' => false, 'backoff' => null, 'timeout' => null, 'data' => ['data'], 'custom' => 'taylor', 'id' => 'foo', 'attempts' => 0])); $redis->shouldReceive('get')->once()->andReturn($redisProxy); Queue::createPayloadUsing(function ($connection, $queue, $payload) { @@ -82,7 +80,7 @@ public function testPushProperlyPushesJobOntoRedisWithTwoCustomPayloadHook() $queue->setContainer($container = m::spy(Container::class)); $queue->setConnectionName('default'); $redisProxy = m::mock(RedisProxy::class); - $redisProxy->shouldReceive('eval')->once()->with(LuaScripts::push(), ['queues:default', 'queues:default:notify', json_encode(['uuid' => $uuid, 'displayName' => 'foo', 'job' => 'foo', 'maxTries' => null, 'maxExceptions' => null, 'failOnTimeout' => false, 'backoff' => null, 'timeout' => null, 'data' => ['data'], 'custom' => 'taylor', 'bar' => 'foo', 'id' => 'foo', 'attempts' => 0])], 2); + $redisProxy->shouldReceive('eval')->once()->with(LuaScripts::push(), 2, 'queues:default', 'queues:default:notify', json_encode(['uuid' => $uuid, 'displayName' => 'foo', 'job' => 'foo', 'maxTries' => null, 'maxExceptions' => null, 'failOnTimeout' => false, 'backoff' => null, 'timeout' => null, 'data' => ['data'], 'custom' => 'taylor', 'bar' => 'foo', 'id' => 'foo', 'attempts' => 0])); $redis->shouldReceive('get')->once()->andReturn($redisProxy); Queue::createPayloadUsing(function ($connection, $queue, $payload) { 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..0b9e45516 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('db.transactions', $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('db.transactions', $transactionManager); $sync->setContainer($container); $sync->push(new SyncQueueAfterCommitInterfaceJob()); diff --git a/tests/Queue/QueueWorkerTest.php b/tests/Queue/QueueWorkerTest.php index 6fd85c062..d3f784a70 100644 --- a/tests/Queue/QueueWorkerTest.php +++ b/tests/Queue/QueueWorkerTest.php @@ -7,14 +7,16 @@ use DateInterval; use DateTimeInterface; use Exception; -use Hyperf\Context\ApplicationContext; -use Hyperf\Di\Container; use Hyperf\Di\Definition\DefinitionSource; -use Hypervel\Foundation\Exceptions\Contracts\ExceptionHandler as ExceptionHandlerContract; +use Hypervel\Container\Container; +use Hypervel\Context\ApplicationContext; +use Hypervel\Contracts\Container\Container as ContainerContract; +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; @@ -27,8 +29,6 @@ use Hypervel\Support\Carbon; use Mockery as m; use PHPUnit\Framework\TestCase; -use Psr\Container\ContainerInterface; -use Psr\EventDispatcher\EventDispatcherInterface; use RuntimeException; use Throwable; @@ -40,19 +40,19 @@ class QueueWorkerTest extends TestCase { use RunTestsInCoroutine; - protected EventDispatcherInterface $events; + protected EventDispatcher $events; protected ExceptionHandlerContract $exceptionHandler; - protected ContainerInterface $container; + protected ContainerContract $container; 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..dc90ebfa3 100644 --- a/tests/Queue/RateLimitedTest.php +++ b/tests/Queue/RateLimitedTest.php @@ -4,12 +4,12 @@ namespace Hypervel\Tests\Queue; -use Hyperf\Context\ApplicationContext; -use Hyperf\Di\Container; use Hyperf\Di\Definition\DefinitionSource; use Hypervel\Cache\RateLimiter; +use Hypervel\Container\Container; +use Hypervel\Context\ApplicationContext; use Hypervel\Queue\Middleware\RateLimited; -use Mockery; +use Mockery as m; use Mockery\MockInterface; use PHPUnit\Framework\TestCase; use TypeError; @@ -35,12 +35,6 @@ enum RateLimitedTestUnitEnum */ class RateLimitedTest extends TestCase { - protected function tearDown(): void - { - parent::tearDown(); - Mockery::close(); - } - public function testConstructorAcceptsString(): void { $this->mockRateLimiter(); @@ -96,7 +90,7 @@ public function testDontReleaseSetsShouldReleaseToFalse(): void */ protected function mockRateLimiter(): RateLimiter&MockInterface { - $limiter = Mockery::mock(RateLimiter::class); + $limiter = m::mock(RateLimiter::class); $container = new Container( new DefinitionSource([ 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/DurationLimiterTest.php b/tests/Redis/DurationLimiterTest.php index 406894c24..1ca5a0cb5 100644 --- a/tests/Redis/DurationLimiterTest.php +++ b/tests/Redis/DurationLimiterTest.php @@ -4,10 +4,10 @@ namespace Hypervel\Tests\Redis; -use Hyperf\Redis\RedisFactory; -use Hyperf\Redis\RedisProxy; use Hypervel\Redis\Limiters\DurationLimiter; use Hypervel\Redis\Limiters\LimiterTimeoutException; +use Hypervel\Redis\RedisFactory; +use Hypervel\Redis\RedisProxy; use Hypervel\Tests\TestCase; use Mockery as m; @@ -38,6 +38,30 @@ public function testAcquireSucceedsWhenBelowLimit(): void $this->assertSame(4, $limiter->remaining); } + public function testAcquireUsesTransformedEvalSignature(): void + { + $redis = $this->mockRedis(); + $redis->shouldReceive('eval') + ->once() + ->withArgs(function (string $script, int $numberOfKeys, string $name, float $microtime, int $timestamp, int $decay, int $maxLocks): bool { + $this->assertNotSame('', $script); + $this->assertSame(1, $numberOfKeys); + $this->assertSame('test-key', $name); + $this->assertGreaterThan(0.0, $microtime); + $this->assertGreaterThan(0, $timestamp); + $this->assertSame(60, $decay); + $this->assertSame(5, $maxLocks); + + return true; + }) + ->andReturn([1, time() + 60, 4]); + + $factory = $this->createFactory($redis); + $limiter = new DurationLimiter($factory, 'default', 'test-key', 5, 60); + + $this->assertTrue($limiter->acquire()); + } + public function testAcquireFailsWhenAtLimit(): void { $redis = $this->mockRedis(); @@ -103,6 +127,31 @@ public function testTooManyAttemptsReturnsFalseWhenHasRemaining(): void $this->assertSame(3, $limiter->remaining); } + public function testTooManyAttemptsUsesTransformedEvalSignature(): void + { + $redis = $this->mockRedis(); + $redis->shouldReceive('eval') + ->once() + ->withArgs(function (string $script, int $numberOfKeys, string $name, float $microtime, int $timestamp, int $decay, int $maxLocks): bool { + $this->assertNotSame('', $script); + $this->assertSame(1, $numberOfKeys); + $this->assertSame('test-key', $name); + $this->assertGreaterThan(0.0, $microtime); + $this->assertGreaterThan(0, $timestamp); + $this->assertSame(60, $decay); + $this->assertSame(5, $maxLocks); + + return true; + }) + ->andReturn([time() + 60, 2]); + + $factory = $this->createFactory($redis); + $limiter = new DurationLimiter($factory, 'default', 'test-key', 5, 60); + + $this->assertFalse($limiter->tooManyAttempts()); + $this->assertSame(2, $limiter->remaining); + } + public function testClearDeletesKey(): void { $redis = $this->mockRedis(); diff --git a/tests/Redis/Events/CommandExecutedTest.php b/tests/Redis/Events/CommandExecutedTest.php new file mode 100644 index 000000000..4f1d512a0 --- /dev/null +++ b/tests/Redis/Events/CommandExecutedTest.php @@ -0,0 +1,126 @@ +assertSame($command, $event->command); + $this->assertSame($parameters, $event->parameters); + $this->assertSame($time, $event->time); + $this->assertSame($connection, $event->connection); + $this->assertSame($connectionName, $event->connectionName); + $this->assertSame($result, $event->result); + $this->assertSame($throwable, $event->throwable); + } + + public function testFormatCommandWithSimpleParameters() + { + $command = 'GET'; + $parameters = ['key1']; + $connection = m::mock(RedisConnection::class); + $event = new CommandExecuted( + $command, + $parameters, + 0.1, + $connection, + 'default', + 'value1', + null + ); + + $this->assertSame('GET key1', $event->getFormatCommand()); + } + + public function testFormatCommandWithArrayParameters() + { + $command = 'HMSET'; + $parameters = ['hash1', ['field1' => 'value1', 'field2' => 'value2']]; + $connection = m::mock(RedisConnection::class); + $event = new CommandExecuted( + $command, + $parameters, + 0.1, + $connection, + 'default', + true, + null + ); + + $this->assertSame('HMSET hash1 field1 value1 field2 value2', $event->getFormatCommand()); + } + + public function testFormatCommandWithNestedArrayParameters() + { + $command = 'COMPLEX'; + $parameters = [ + 'key1', + [ + 'field1' => ['subfield1' => 'value1'], + 'field2' => 'value2', + ], + ]; + $connection = m::mock(RedisConnection::class); + $event = new CommandExecuted( + $command, + $parameters, + 0.1, + $connection, + 'default', + true, + null + ); + + $this->assertSame('COMPLEX key1 field1 {"subfield1":"value1"} field2 value2', $event->getFormatCommand()); + } + + public function testWithThrowable() + { + $command = 'GET'; + $parameters = ['key1']; + $connection = m::mock(RedisConnection::class); + $throwable = new Exception('Test exception'); + $event = new CommandExecuted( + $command, + $parameters, + 0.1, + $connection, + 'default', + null, + $throwable + ); + + $this->assertSame($throwable, $event->throwable); + } +} diff --git a/tests/Redis/MultiExecTest.php b/tests/Redis/MultiExecTest.php index 6614407fd..26ea6ffb4 100644 --- a/tests/Redis/MultiExecTest.php +++ b/tests/Redis/MultiExecTest.php @@ -4,10 +4,10 @@ namespace Hypervel\Tests\Redis; -use Hyperf\Redis\Pool\PoolFactory; -use Hyperf\Redis\Pool\RedisPool; use Hypervel\Context\Context; use Hypervel\Foundation\Testing\Concerns\RunTestsInCoroutine; +use Hypervel\Redis\Pool\PoolFactory; +use Hypervel\Redis\Pool\RedisPool; use Hypervel\Redis\Redis; use Hypervel\Redis\RedisConnection; use Hypervel\Tests\TestCase; @@ -160,6 +160,79 @@ public function testPipelineWithCallbackReleasesOnException(): void }); } + public function testTransactionWithCallbackDoesNotReleaseExistingContextConnection(): void + { + $multiInstance = m::mock(PhpRedis::class); + $multiInstance->shouldReceive('exec')->once()->andReturn([]); + + $phpRedis = m::mock(PhpRedis::class); + $phpRedis->shouldReceive('multi')->once()->andReturn($multiInstance); + + $connection = $this->createMockConnection($phpRedis); + // Set up existing connection in context BEFORE the transaction call + Context::set('redis.connection.default', $connection); + + // Connection is NOT released during the test (it already existed in context), + // but allow release() call for test cleanup + $connection->shouldReceive('release')->zeroOrMoreTimes(); + + $redis = $this->createRedis($connection); + + $redis->transaction(function ($tx) { + // empty callback + }); + } + + public function testTransactionWithCallbackReleasesOnException(): void + { + $multiInstance = m::mock(PhpRedis::class); + $multiInstance->shouldReceive('exec')->once()->andThrow(new RuntimeException('Transaction failed')); + + $phpRedis = m::mock(PhpRedis::class); + $phpRedis->shouldReceive('multi')->once()->andReturn($multiInstance); + + $connection = $this->createMockConnection($phpRedis); + // Connection should still be released even on exception + $connection->shouldReceive('release')->once(); + + $redis = $this->createRedis($connection); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Transaction failed'); + + $redis->transaction(function ($tx) { + // callback runs, but exec will throw + }); + } + + public function testShouldTransformIsResetWhenConnectionReleasedAfterCallback(): void + { + $execResults = ['OK']; + + $pipelineInstance = m::mock(PhpRedis::class); + $pipelineInstance->shouldReceive('set')->once()->andReturnSelf(); + $pipelineInstance->shouldReceive('exec')->once()->andReturn($execResults); + + $phpRedis = m::mock(PhpRedis::class); + $phpRedis->shouldReceive('pipeline')->once()->andReturn($pipelineInstance); + + $connection = $this->createMockConnection($phpRedis); + + // Verify shouldTransform is called with true when getting the connection, + // and that release() is called (which resets shouldTransform to false internally) + $connection->shouldReceive('release')->once(); + + $redis = $this->createRedis($connection); + + $redis->pipeline(function ($pipe) { + $pipe->set('key', 'value'); + }); + + // After pipeline callback completes, connection was released. + // The connection should no longer be in context. + $this->assertNull(Context::get('redis.connection.default')); + } + /** * Create a mock RedisConnection. */ diff --git a/tests/Redis/Operations/FlushByPatternTest.php b/tests/Redis/Operations/FlushByPatternTest.php index 8324caa84..41524f261 100644 --- a/tests/Redis/Operations/FlushByPatternTest.php +++ b/tests/Redis/Operations/FlushByPatternTest.php @@ -19,12 +19,6 @@ */ class FlushByPatternTest extends TestCase { - protected function tearDown(): void - { - m::close(); - parent::tearDown(); - } - /** * Create a RedisConnection wrapping a FakeRedisClient for testing. * diff --git a/tests/Redis/PoolFactoryTest.php b/tests/Redis/PoolFactoryTest.php new file mode 100644 index 000000000..e3f91a1d9 --- /dev/null +++ b/tests/Redis/PoolFactoryTest.php @@ -0,0 +1,132 @@ +mockContainerWithPools(); + + $factory = new PoolFactory($container); + + $pool1 = $factory->getPool('default'); + $pool2 = $factory->getPool('default'); + + $this->assertSame($pool1, $pool2); + } + + public function testGetPoolReturnsDifferentInstancesForDifferentNames() + { + $container = $this->mockContainerWithPools(); + + $factory = new PoolFactory($container); + + $pool1 = $factory->getPool('default'); + $pool2 = $factory->getPool('cache'); + + $this->assertNotSame($pool1, $pool2); + } + + public function testFlushAll() + { + $container = $this->mockContainerWithPools(); + + $factory = new PoolFactory($container); + + $pool1 = $factory->getPool('default'); + $pool2 = $factory->getPool('cache'); + + $connection1 = $pool1->get(); + $connection2 = $pool1->get(); + $connection3 = $pool2->get(); + + $pool1->release($connection1); + $pool1->release($connection2); + $pool2->release($connection3); + + $this->assertSame(2, $pool1->getConnectionsInChannel()); + $this->assertSame(1, $pool2->getConnectionsInChannel()); + + $factory->flushAll(); + + $this->assertSame(0, $pool1->getConnectionsInChannel()); + $this->assertSame(0, $pool2->getConnectionsInChannel()); + } + + private function mockContainerWithPools(): m\MockInterface|ContainerContract + { + $connectionConfig = [ + 'host' => 'localhost', + 'port' => 6379, + 'db' => 0, + 'pool' => [ + 'min_connections' => 1, + 'max_connections' => 10, + 'connect_timeout' => 10.0, + 'wait_timeout' => 3.0, + 'heartbeat' => -1, + 'max_idle_time' => 60.0, + ], + ]; + + $redisConfig = m::mock(RedisConfig::class); + $redisConfig->shouldReceive('connectionConfig')->andReturn($connectionConfig); + + $container = m::mock(ContainerContract::class); + $container->shouldReceive('get')->with(RedisConfig::class)->andReturn($redisConfig); + $container->shouldReceive('has')->andReturn(false); + $container->shouldReceive('make')->with(RedisPool::class, m::any())->andReturnUsing( + fn ($class, $args) => new PoolFactoryTestPool($container, $args['name']) + ); + + return $container; + } +} + +/** + * @internal + */ +class PoolFactoryTestPool extends RedisPool +{ + protected function createConnection(): ConnectionInterface + { + return new PoolFactoryTestConnection($this->container, $this); + } +} + +/** + * @internal + */ +class PoolFactoryTestConnection extends Connection +{ + public function close(): bool + { + return true; + } + + public function reconnect(): bool + { + return true; + } + + public function getActiveConnection(): static + { + return $this; + } +} diff --git a/tests/Redis/RedisConfigTest.php b/tests/Redis/RedisConfigTest.php new file mode 100644 index 000000000..611eca6e1 --- /dev/null +++ b/tests/Redis/RedisConfigTest.php @@ -0,0 +1,225 @@ + 'phpredis', + 'options' => ['prefix' => 'global:'], + 'clusters' => ['cache' => []], + 'default' => ['host' => '127.0.0.1', 'port' => 6379, 'db' => 0], + 'cache' => ['host' => '127.0.0.1', 'port' => 6379, 'db' => 1], + ]; + + $config = m::mock(Repository::class); + $config->shouldReceive('get')->with('database.redis')->andReturn($redisConfig); + + $this->assertSame(['default', 'cache'], (new RedisConfig($config))->connectionNames()); + } + + public function testConnectionNamesThrowsForNonArrayConnectionEntry(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The redis connection [default] must be an array.'); + + $config = m::mock(Repository::class); + $config->shouldReceive('get')->with('database.redis')->andReturn([ + 'default' => 'tcp://127.0.0.1:6379', + ]); + + (new RedisConfig($config))->connectionNames(); + } + + public function testConnectionNamesThrowsWhenHostPortMissingForDirectConnection(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The redis connection [custom] must define host and port.'); + + $config = m::mock(Repository::class); + $config->shouldReceive('get')->with('database.redis')->andReturn([ + 'custom' => ['foo' => 'bar'], + ]); + + (new RedisConfig($config))->connectionNames(); + } + + public function testConnectionConfigMergesSharedAndConnectionOptions(): void + { + $redisConfig = [ + 'options' => ['prefix' => 'global:', 'serializer' => 1], + 'default' => [ + 'host' => '127.0.0.1', + 'port' => 6379, + 'db' => 0, + 'options' => ['prefix' => 'default:'], + ], + ]; + + $config = m::mock(Repository::class); + $config->shouldReceive('get')->with('database.redis')->andReturn($redisConfig); + + $connectionConfig = (new RedisConfig($config))->connectionConfig('default'); + + $this->assertSame( + ['prefix' => 'default:', 'serializer' => 1], + $connectionConfig['options'], + ); + } + + public function testConnectionConfigThrowsForMissingConnection(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The redis connection [default] must be an array.'); + + $config = m::mock(Repository::class); + $config->shouldReceive('get')->with('database.redis')->andReturn([]); + + (new RedisConfig($config))->connectionConfig('default'); + } + + public function testConnectionConfigThrowsForInvalidConnectionOptions(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The redis connection [default] options must be an array.'); + + $config = m::mock(Repository::class); + $config->shouldReceive('get')->with('database.redis')->andReturn([ + 'options' => ['prefix' => 'global:'], + 'default' => [ + 'host' => '127.0.0.1', + 'port' => 6379, + 'db' => 0, + 'options' => 'invalid', + ], + ]); + + (new RedisConfig($config))->connectionConfig('default'); + } + + public function testConnectionNamesAcceptsClusterConnectionWithoutHostAndPort(): void + { + $config = m::mock(Repository::class); + $config->shouldReceive('get')->with('database.redis')->andReturn([ + 'clustered' => [ + 'db' => 0, + 'cluster' => [ + 'enable' => true, + 'seeds' => ['127.0.0.1:7000', '127.0.0.1:7001'], + ], + ], + ]); + + $this->assertSame(['clustered'], (new RedisConfig($config))->connectionNames()); + } + + public function testConnectionNamesThrowsWhenClusterEnabledWithoutSeeds(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The redis connection [clustered] cluster seeds must be a non-empty array.'); + + $config = m::mock(Repository::class); + $config->shouldReceive('get')->with('database.redis')->andReturn([ + 'clustered' => [ + 'cluster' => [ + 'enable' => true, + 'seeds' => [], + ], + ], + ]); + + (new RedisConfig($config))->connectionNames(); + } + + public function testConnectionNamesAcceptsSentinelConnectionWithoutHostAndPort(): void + { + $config = m::mock(Repository::class); + $config->shouldReceive('get')->with('database.redis')->andReturn([ + 'sentinel' => [ + 'db' => 0, + 'sentinel' => [ + 'enable' => true, + 'nodes' => ['tcp://127.0.0.1:26379'], + 'master_name' => 'mymaster', + ], + ], + ]); + + $this->assertSame(['sentinel'], (new RedisConfig($config))->connectionNames()); + } + + public function testConnectionNamesThrowsWhenSentinelEnabledWithoutNodes(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The redis connection [sentinel] sentinel nodes must be a non-empty array.'); + + $config = m::mock(Repository::class); + $config->shouldReceive('get')->with('database.redis')->andReturn([ + 'sentinel' => [ + 'sentinel' => [ + 'enable' => true, + 'nodes' => [], + 'master_name' => 'mymaster', + ], + ], + ]); + + (new RedisConfig($config))->connectionNames(); + } + + public function testConnectionNamesThrowsWhenSentinelEnabledWithoutMasterName(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The redis connection [sentinel] sentinel master name must be configured.'); + + $config = m::mock(Repository::class); + $config->shouldReceive('get')->with('database.redis')->andReturn([ + 'sentinel' => [ + 'sentinel' => [ + 'enable' => true, + 'nodes' => ['tcp://127.0.0.1:26379'], + 'master_name' => '', + ], + ], + ]); + + (new RedisConfig($config))->connectionNames(); + } + + public function testConnectionNamesThrowsWhenClusterAndSentinelBothEnabled(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The redis connection [mixed] cannot enable both cluster and sentinel.'); + + $config = m::mock(Repository::class); + $config->shouldReceive('get')->with('database.redis')->andReturn([ + 'mixed' => [ + 'cluster' => [ + 'enable' => true, + 'seeds' => ['127.0.0.1:7000'], + ], + 'sentinel' => [ + 'enable' => true, + 'nodes' => ['tcp://127.0.0.1:26379'], + 'master_name' => 'mymaster', + ], + ], + ]); + + (new RedisConfig($config))->connectionNames(); + } +} diff --git a/tests/Redis/RedisConnectionTest.php b/tests/Redis/RedisConnectionTest.php index 277b4e690..d035bc002 100644 --- a/tests/Redis/RedisConnectionTest.php +++ b/tests/Redis/RedisConnectionTest.php @@ -4,18 +4,24 @@ namespace Hypervel\Tests\Redis; -use Hyperf\Contract\PoolInterface; +use Hyperf\Contract\StdoutLoggerInterface; use Hyperf\Di\Container; use Hyperf\Di\Definition\DefinitionSource; use Hyperf\Pool\PoolOption; +use Hypervel\Contracts\Pool\PoolInterface; +use Hypervel\Pool\Exception\ConnectionException; use Hypervel\Redis\Exceptions\LuaScriptException; use Hypervel\Redis\RedisConnection; use Hypervel\Tests\Redis\Stubs\RedisConnectionStub; use Hypervel\Tests\TestCase; -use Mockery; +use Mockery as m; use Psr\Container\ContainerInterface; +use Psr\Log\LogLevel; use Redis; use RedisCluster; +use RedisException; +use RuntimeException; +use TypeError; /** * @internal @@ -47,6 +53,320 @@ public function testRelease(): void $this->assertFalse($connection->getShouldTransform()); } + public function testReleaseResetsDatabaseToConfiguredDefault(): void + { + $pool = $this->getMockedPool(); + $pool->shouldReceive('release')->once(); + + $redis = m::mock(Redis::class); + $redis->shouldReceive('select')->once()->with(1)->andReturn(true); + $redis->shouldReceive('select')->once()->with(1)->andReturn(true); + + $connection = new class($this->getContainer(), $pool, ['host' => '127.0.0.1', 'port' => 6379, 'db' => 1], $redis) extends RedisConnection { + public function __construct( + ContainerInterface $container, + PoolInterface $pool, + array $config, + private Redis $fakeRedis + ) { + parent::__construct($container, $pool, $config); + } + + protected function createRedis(array $config): Redis + { + return $this->fakeRedis; + } + }; + + $connection->setDatabase(2); + $connection->release(); + } + + public function testReleaseDefaultsToDatabaseZeroWhenDbConfigIsMissing(): void + { + $pool = $this->getMockedPool(); + $pool->shouldReceive('release')->once(); + + $redis = m::mock(Redis::class); + $redis->shouldReceive('select')->once()->with(0)->andReturn(true); + + $connection = new class($this->getContainer(), $pool, ['host' => '127.0.0.1', 'port' => 6379], $redis) extends RedisConnection { + public function __construct( + ContainerInterface $container, + PoolInterface $pool, + array $config, + private Redis $fakeRedis + ) { + parent::__construct($container, $pool, $config); + } + + protected function createRedis(array $config): Redis + { + return $this->fakeRedis; + } + }; + + $connection->setDatabase(5); + $connection->release(); + } + + public function testReconnectUsesCurrentDatabaseWhenSet(): void + { + $pool = $this->getMockedPool(); + $redis = m::mock(Redis::class); + $redis->shouldReceive('select')->once()->with(2)->andReturn(true); + + $connection = new class($this->getContainer(), $pool, ['host' => '127.0.0.1', 'port' => 6379, 'db' => 0], $redis) extends RedisConnection { + public function __construct( + ContainerInterface $container, + PoolInterface $pool, + array $config, + private Redis $fakeRedis + ) { + parent::__construct($container, $pool, $config); + } + + protected function createRedis(array $config): Redis + { + return $this->fakeRedis; + } + }; + + $connection->setDatabase(2); + $connection->reconnect(); + } + + public function testConnectionConfigMergesDefaults(): void + { + $connection = new RedisConnectionStub( + $this->getContainer(), + $this->getMockedPool(), + [ + 'host' => 'redis', + 'port' => 16379, + 'auth' => 'redis', + 'db' => 0, + 'retry_interval' => 5, + 'read_timeout' => 3.0, + 'context' => [ + 'stream' => ['cafile' => 'foo-cafile', 'verify_peer' => true], + ], + 'cluster' => [ + 'enable' => false, + 'name' => null, + 'seeds' => ['127.0.0.1:6379'], + 'context' => [ + 'stream' => ['cafile' => 'foo-cafile', 'verify_peer' => true], + ], + ], + 'pool' => [ + 'min_connections' => 1, + 'max_connections' => 30, + 'connect_timeout' => 10.0, + 'wait_timeout' => 3.0, + 'heartbeat' => -1, + 'max_idle_time' => 1, + ], + ], + ); + + $this->assertSame( + [ + 'timeout' => 0.0, + 'reserved' => null, + 'retry_interval' => 5, + 'read_timeout' => 3.0, + 'cluster' => [ + 'enable' => false, + 'name' => null, + 'seeds' => ['127.0.0.1:6379'], + 'read_timeout' => 0.0, + 'persistent' => false, + 'context' => [ + 'stream' => ['cafile' => 'foo-cafile', 'verify_peer' => true], + ], + ], + 'sentinel' => [ + 'enable' => false, + 'master_name' => '', + 'nodes' => [], + 'persistent' => '', + 'read_timeout' => 0, + ], + 'options' => [], + 'context' => [ + 'stream' => ['cafile' => 'foo-cafile', 'verify_peer' => true], + ], + 'event' => [ + 'enable' => false, + ], + 'host' => 'redis', + 'port' => 16379, + 'auth' => 'redis', + 'db' => 0, + 'pool' => [ + 'min_connections' => 1, + 'max_connections' => 30, + 'connect_timeout' => 10.0, + 'wait_timeout' => 3.0, + 'heartbeat' => -1, + 'max_idle_time' => 1, + ], + ], + $connection->getConfigForTest(), + ); + } + + public function testClusterReconnectFailureThrowsConnectionException(): void + { + if (version_compare((string) phpversion('redis'), '6.0.0', '<')) { + $this->markTestSkipped('Cluster constructor typing differs on redis extension < 6.'); + } + + $this->expectException(ConnectionException::class); + $this->expectExceptionMessage('Connection reconnect failed'); + + new class($this->getContainer(), $this->getMockedPool(), ['cluster' => ['enable' => true, 'name' => 'mycluster', 'seeds' => [], 'read_timeout' => 1.0, 'persistent' => false], 'timeout' => 1.0]) extends RedisConnection { + protected function createRedis(array $config): Redis + { + throw new RuntimeException('createRedis should not be called for cluster config.'); + } + }; + } + + public function testQueueingModeBypassesTransformedSet(): void + { + $connection = $this->mockRedisConnection(transform: true); + $redis = m::mock(Redis::class); + + $redis->shouldReceive('getMode')->once()->andReturn(Redis::MULTI); + $redis->shouldReceive('set')->once()->with('key', 'value', 600)->andReturnSelf(); + + $connection->setActiveConnection($redis); + + $result = $connection->__call('set', ['key', 'value', 600]); + + $this->assertSame($redis, $result); + } + + public function testPipelineModeBypassesTransformedSet(): void + { + $connection = $this->mockRedisConnection(transform: true); + $redis = m::mock(Redis::class); + + $redis->shouldReceive('getMode')->once()->andReturn(Redis::PIPELINE); + $redis->shouldReceive('set')->once()->with('key', 'value', 600)->andReturnSelf(); + + $connection->setActiveConnection($redis); + + $result = $connection->__call('set', ['key', 'value', 600]); + + $this->assertSame($redis, $result); + } + + public function testTransformDisabledSetUsesNativeSignatureWithoutInspectingMode(): void + { + $connection = $this->mockRedisConnection(transform: false); + $redis = m::mock(Redis::class); + + $redis->shouldReceive('getMode')->never(); + $redis->shouldReceive('set')->once()->with('key', 'value', 600)->andReturn(true); + + $connection->setActiveConnection($redis); + + $result = $connection->__call('set', ['key', 'value', 600]); + + $this->assertTrue($result); + } + + public function testTypeErrorsAreNotRetried(): void + { + $connection = $this->mockRedisConnection(transform: true); + $redis = m::mock(Redis::class); + + $redis->shouldReceive('getMode')->once()->andReturn(Redis::ATOMIC); + $connection->setActiveConnection($redis); + + $this->expectException(TypeError::class); + + $connection->__call('set', ['key', 'value', 600]); + } + + public function testRedisExceptionIsRetried(): void + { + $pool = $this->getMockedPool(); + $redis = m::mock(Redis::class); + + $redis->shouldReceive('get') + ->once() + ->with('foo') + ->andThrow(new RedisException('network')); + $redis->shouldReceive('get') + ->once() + ->with('foo') + ->andReturn('bar'); + + $connection = new class($this->getContainer(), $pool, ['host' => '127.0.0.1', 'port' => 6379], $redis) extends RedisConnection { + public function __construct( + ContainerInterface $container, + PoolInterface $pool, + array $config, + private Redis $fakeRedis + ) { + parent::__construct($container, $pool, $config); + } + + protected function createRedis(array $config): Redis + { + return $this->fakeRedis; + } + }; + + $connection->shouldTransform(false); + + $result = $connection->__call('get', ['foo']); + + $this->assertSame('bar', $result); + } + + public function testLogWritesToStdoutLogger(): void + { + $pool = $this->getMockedPool(); + $redis = m::mock(Redis::class); + $logger = m::mock(StdoutLoggerInterface::class); + $logger->shouldReceive('log') + ->once() + ->with(LogLevel::ERROR, 'unit'); + + $container = m::mock(ContainerInterface::class); + $container->shouldReceive('has')->with(\Psr\EventDispatcher\EventDispatcherInterface::class)->andReturn(false); + $container->shouldReceive('has')->with(StdoutLoggerInterface::class)->andReturn(true); + $container->shouldReceive('get')->with(StdoutLoggerInterface::class)->andReturn($logger); + + $connection = new class($container, $pool, ['host' => '127.0.0.1', 'port' => 6379], $redis) extends RedisConnection { + public function __construct( + ContainerInterface $container, + PoolInterface $pool, + array $config, + private Redis $fakeRedis + ) { + parent::__construct($container, $pool, $config); + } + + protected function createRedis(array $config): Redis + { + return $this->fakeRedis; + } + + public function callLog(string $message, string $level): void + { + $this->log($message, $level); + } + }; + + $connection->callLog('unit', LogLevel::ERROR); + } + public function testCallGet(): void { $connection = $this->mockRedisConnection(transform: true); @@ -697,7 +1017,7 @@ public function testIsClusterReturnsTrueForRedisCluster(): void $connection = $this->mockRedisConnection(); // Set a RedisCluster mock as the active connection - $clusterMock = Mockery::mock(RedisCluster::class)->shouldIgnoreMissing(); + $clusterMock = m::mock(RedisCluster::class)->shouldIgnoreMissing(); $connection->setActiveConnection($clusterMock); $this->assertTrue($connection->isCluster()); @@ -878,6 +1198,655 @@ public function testEvalWithShaCacheClearsLastErrorBeforeEvalSha(): void $this->assertEquals('ok', $result); } + public function testRetryAppliesGetTransform(): void + { + $pool = $this->getMockedPool(); + $redis = m::mock(Redis::class); + + // First get() throws RedisException, triggering retry + $redis->shouldReceive('getMode')->andReturn(Redis::ATOMIC); + $redis->shouldReceive('get') + ->once() + ->with('missing') + ->andThrow(new RedisException('connection lost')); + + // After reconnect, get() returns false (key not found) + $redis->shouldReceive('get') + ->once() + ->with('missing') + ->andReturn(false); + + $connection = new class($this->getContainer(), $pool, ['host' => '127.0.0.1', 'port' => 6379], $redis) extends RedisConnection { + public function __construct( + ContainerInterface $container, + PoolInterface $pool, + array $config, + private Redis $fakeRedis + ) { + parent::__construct($container, $pool, $config); + } + + protected function createRedis(array $config): Redis + { + return $this->fakeRedis; + } + }; + + $connection->shouldTransform(true); + + // With transform enabled, retry should return null (not false) + $result = $connection->__call('get', ['missing']); + + $this->assertNull($result); + } + + public function testRetryAppliesSetnxTransform(): void + { + $pool = $this->getMockedPool(); + $redis = m::mock(Redis::class); + + $redis->shouldReceive('getMode')->andReturn(Redis::ATOMIC); + + // First setNx() throws RedisException, triggering retry + $redis->shouldReceive('setNx') + ->once() + ->with('key', 'value') + ->andThrow(new RedisException('connection lost')); + + // After reconnect, setNx() returns true (phpredis bool) + // Transform should cast to int (1) + $redis->shouldReceive('setNx') + ->once() + ->with('key', 'value') + ->andReturn(true); + + $connection = new class($this->getContainer(), $pool, ['host' => '127.0.0.1', 'port' => 6379], $redis) extends RedisConnection { + public function __construct( + ContainerInterface $container, + PoolInterface $pool, + array $config, + private Redis $fakeRedis + ) { + parent::__construct($container, $pool, $config); + } + + protected function createRedis(array $config): Redis + { + return $this->fakeRedis; + } + }; + + $connection->shouldTransform(true); + + // Laravel setnx returns int (1), not bool (true) + $result = $connection->__call('setnx', ['key', 'value']); + + $this->assertSame(1, $result); + } + + public function testSpopWithoutCountReturnsSingleElement(): void + { + $connection = $this->mockRedisConnection(transform: true); + + // Without count, phpredis sPop returns a single string + $connection->getConnection() + ->shouldReceive('sPop') + ->once() + ->with('myset') + ->andReturn('member1'); + + $result = $connection->__call('spop', ['myset']); + + $this->assertSame('member1', $result); + } + + public function testSpopWithCountReturnsArray(): void + { + $connection = $this->mockRedisConnection(transform: true); + + // With count, phpredis sPop returns an array + $connection->getConnection() + ->shouldReceive('sPop') + ->once() + ->with('myset', 3) + ->andReturn(['member1', 'member2', 'member3']); + + $result = $connection->__call('spop', ['myset', 3]); + + $this->assertSame(['member1', 'member2', 'member3'], $result); + } + + public function testSpopWithoutCountReturnsFalseForEmptySet(): void + { + $connection = $this->mockRedisConnection(transform: true); + + $connection->getConnection() + ->shouldReceive('sPop') + ->once() + ->with('emptyset') + ->andReturn(false); + + $result = $connection->__call('spop', ['emptyset']); + + $this->assertFalse($result); + } + + public function testEvalReordersArguments() + { + // Can't mock eval() on phpredis — Mockery's proxy falls through to the + // C extension which tries a real connection. Instead, override callEval + // to capture the arguments it receives after __call dispatches to it. + $captured = []; + $connection = new class($this->getContainer(), $this->getMockedPool(), [], $captured) extends RedisConnection { + public function __construct( + ContainerInterface $container, + PoolInterface $pool, + array $config, + private array &$captured, + ) { + parent::__construct($container, $pool, $config); + } + + protected function createRedis(array $config): Redis + { + return m::mock(Redis::class)->shouldIgnoreMissing(); + } + + protected function callEval(string $script, int $numberOfKeys, mixed ...$arguments): mixed + { + $this->captured = [ + 'script' => $script, + 'numberOfKeys' => $numberOfKeys, + 'arguments' => $arguments, + ]; + + return 'captured'; + } + }; + + $connection->shouldTransform(true); + + // User calls: eval('return KEYS[1]', 1, 'mykey') + $result = $connection->__call('eval', ['return KEYS[1]', 1, 'mykey']); + + $this->assertSame('captured', $result); + $this->assertSame('return KEYS[1]', $captured['script']); + $this->assertSame(1, $captured['numberOfKeys']); + $this->assertSame(['mykey'], $captured['arguments']); + } + + public function testEvalReordersMultipleArguments() + { + $captured = []; + $connection = new class($this->getContainer(), $this->getMockedPool(), [], $captured) extends RedisConnection { + public function __construct( + ContainerInterface $container, + PoolInterface $pool, + array $config, + private array &$captured, + ) { + parent::__construct($container, $pool, $config); + } + + protected function createRedis(array $config): Redis + { + return m::mock(Redis::class)->shouldIgnoreMissing(); + } + + protected function callEval(string $script, int $numberOfKeys, mixed ...$arguments): mixed + { + $this->captured = [ + 'script' => $script, + 'numberOfKeys' => $numberOfKeys, + 'arguments' => $arguments, + ]; + + return 'captured'; + } + }; + + $connection->shouldTransform(true); + + // User calls: eval('return {KEYS[1], ARGV[1]}', 1, 'mykey', 'myarg') + $result = $connection->__call('eval', ['return {KEYS[1], ARGV[1]}', 1, 'mykey', 'myarg']); + + $this->assertSame('captured', $result); + $this->assertSame('return {KEYS[1], ARGV[1]}', $captured['script']); + $this->assertSame(1, $captured['numberOfKeys']); + $this->assertSame(['mykey', 'myarg'], $captured['arguments']); + } + + public function testEvalWithNoKeysOrArguments() + { + $captured = []; + $connection = new class($this->getContainer(), $this->getMockedPool(), [], $captured) extends RedisConnection { + public function __construct( + ContainerInterface $container, + PoolInterface $pool, + array $config, + private array &$captured, + ) { + parent::__construct($container, $pool, $config); + } + + protected function createRedis(array $config): Redis + { + return m::mock(Redis::class)->shouldIgnoreMissing(); + } + + protected function callEval(string $script, int $numberOfKeys, mixed ...$arguments): mixed + { + $this->captured = [ + 'script' => $script, + 'numberOfKeys' => $numberOfKeys, + 'arguments' => $arguments, + ]; + + return 'captured'; + } + }; + + $connection->shouldTransform(true); + + // User calls: eval('return 42', 0) + $result = $connection->__call('eval', ['return 42', 0]); + + $this->assertSame('captured', $result); + $this->assertSame('return 42', $captured['script']); + $this->assertSame(0, $captured['numberOfKeys']); + $this->assertSame([], $captured['arguments']); + } + + public function testSubscribeWrapsCallbackArgumentOrder() + { + $connection = $this->mockRedisConnection(); + $redis = m::mock(Redis::class); + + $redis->shouldReceive('getOption') + ->with(Redis::OPT_READ_TIMEOUT) + ->once() + ->andReturn(30.0); + $redis->shouldReceive('setOption') + ->with(Redis::OPT_READ_TIMEOUT, -1) + ->once(); + $redis->shouldReceive('setOption') + ->with(Redis::OPT_READ_TIMEOUT, 30.0) + ->once(); + + // Capture the wrapped callback that subscribe receives + $capturedCallback = null; + $redis->shouldReceive('subscribe') + ->once() + ->with(['channel1'], m::on(function ($callback) use (&$capturedCallback) { + $capturedCallback = $callback; + + return true; + })) + ->andReturnTrue(); + + $connection->setActiveConnection($redis); + + // User provides callback expecting ($message, $channel) + $receivedArgs = []; + $userCallback = function ($message, $channel) use (&$receivedArgs) { + $receivedArgs = ['message' => $message, 'channel' => $channel]; + }; + + $connection->__call('subscribe', [['channel1'], $userCallback]); + + // Simulate phpredis calling the wrapped callback with ($redis, $channel, $message) + $this->assertNotNull($capturedCallback); + $capturedCallback($redis, 'channel1', 'hello'); + + $this->assertSame('hello', $receivedArgs['message']); + $this->assertSame('channel1', $receivedArgs['channel']); + } + + public function testPsubscribeWrapsCallbackArgumentOrder() + { + $connection = $this->mockRedisConnection(); + $redis = m::mock(Redis::class); + + $redis->shouldReceive('getOption') + ->with(Redis::OPT_READ_TIMEOUT) + ->once() + ->andReturn(30.0); + $redis->shouldReceive('setOption') + ->with(Redis::OPT_READ_TIMEOUT, -1) + ->once(); + $redis->shouldReceive('setOption') + ->with(Redis::OPT_READ_TIMEOUT, 30.0) + ->once(); + + $capturedCallback = null; + $redis->shouldReceive('psubscribe') + ->once() + ->with(['channel:*'], m::on(function ($callback) use (&$capturedCallback) { + $capturedCallback = $callback; + + return true; + })) + ->andReturnTrue(); + + $connection->setActiveConnection($redis); + + $receivedArgs = []; + $userCallback = function ($message, $channel) use (&$receivedArgs) { + $receivedArgs = ['message' => $message, 'channel' => $channel]; + }; + + $connection->__call('psubscribe', [['channel:*'], $userCallback]); + + // Simulate phpredis calling the wrapped callback with ($redis, $pattern, $channel, $message) + $this->assertNotNull($capturedCallback); + $capturedCallback($redis, 'channel:*', 'channel:1', 'world'); + + $this->assertSame('world', $receivedArgs['message']); + $this->assertSame('channel:1', $receivedArgs['channel']); + } + + public function testSubscribeRestoresReadTimeoutOnException() + { + $connection = $this->mockRedisConnection(); + $redis = m::mock(Redis::class); + + $redis->shouldReceive('getOption') + ->with(Redis::OPT_READ_TIMEOUT) + ->once() + ->andReturn(60.0); + $redis->shouldReceive('setOption') + ->with(Redis::OPT_READ_TIMEOUT, -1) + ->once(); + // Should restore even when subscribe throws + $redis->shouldReceive('setOption') + ->with(Redis::OPT_READ_TIMEOUT, 60.0) + ->once(); + + $redis->shouldReceive('subscribe') + ->once() + ->andThrow(new RedisException('Subscribe failed')); + + $connection->setActiveConnection($redis); + + $this->expectException(RedisException::class); + $this->expectExceptionMessage('Subscribe failed'); + + $connection->__call('subscribe', [['channel1'], function () {}]); + } + + public function testSubscribeWrapsStringChannelAsArray() + { + $connection = $this->mockRedisConnection(); + $redis = m::mock(Redis::class); + + $redis->shouldReceive('getOption')->andReturn(0); + $redis->shouldReceive('setOption')->twice(); + + // Verify that a string channel gets wrapped in an array + $redis->shouldReceive('subscribe') + ->once() + ->with(['single-channel'], m::type('Closure')) + ->andReturnTrue(); + + $connection->setActiveConnection($redis); + + $connection->__call('subscribe', ['single-channel', function () {}]); + } + + public function testReconnectSetsSerializerOption() + { + $pool = $this->getMockedPool(); + $redis = m::mock(Redis::class); + $redis->shouldReceive('setOption') + ->once() + ->with(Redis::OPT_SERIALIZER, Redis::SERIALIZER_PHP); + + new class($this->getContainer(), $pool, ['host' => '127.0.0.1', 'port' => 6379, 'options' => ['serializer' => Redis::SERIALIZER_PHP]], $redis) extends RedisConnection { + public function __construct( + ContainerInterface $container, + PoolInterface $pool, + array $config, + private Redis $fakeRedis, + ) { + parent::__construct($container, $pool, $config); + } + + protected function createRedis(array $config): Redis + { + return $this->fakeRedis; + } + }; + } + + public function testReconnectSetsPrefixOption() + { + $pool = $this->getMockedPool(); + $redis = m::mock(Redis::class); + $redis->shouldReceive('setOption') + ->once() + ->with(Redis::OPT_PREFIX, 'myapp:'); + + new class($this->getContainer(), $pool, ['host' => '127.0.0.1', 'port' => 6379, 'options' => ['prefix' => 'myapp:']], $redis) extends RedisConnection { + public function __construct( + ContainerInterface $container, + PoolInterface $pool, + array $config, + private Redis $fakeRedis, + ) { + parent::__construct($container, $pool, $config); + } + + protected function createRedis(array $config): Redis + { + return $this->fakeRedis; + } + }; + } + + public function testReconnectThrowsOnUnknownOption() + { + $pool = $this->getMockedPool(); + $redis = m::mock(Redis::class); + + $this->expectException(\Hypervel\Redis\Exceptions\InvalidRedisOptionException::class); + $this->expectExceptionMessage('The redis option key `bogus` is invalid.'); + + new class($this->getContainer(), $pool, ['host' => '127.0.0.1', 'port' => 6379, 'options' => ['bogus' => 'value']], $redis) extends RedisConnection { + public function __construct( + ContainerInterface $container, + PoolInterface $pool, + array $config, + private Redis $fakeRedis, + ) { + parent::__construct($container, $pool, $config); + } + + protected function createRedis(array $config): Redis + { + return $this->fakeRedis; + } + }; + } + + public function testReconnectSetsNumericOptions() + { + $pool = $this->getMockedPool(); + $redis = m::mock(Redis::class); + + // Numeric keys bypass the match statement and pass directly + $redis->shouldReceive('setOption') + ->once() + ->with(Redis::OPT_SERIALIZER, Redis::SERIALIZER_JSON); + + new class($this->getContainer(), $pool, ['host' => '127.0.0.1', 'port' => 6379, 'options' => [Redis::OPT_SERIALIZER => Redis::SERIALIZER_JSON]], $redis) extends RedisConnection { + public function __construct( + ContainerInterface $container, + PoolInterface $pool, + array $config, + private Redis $fakeRedis, + ) { + parent::__construct($container, $pool, $config); + } + + protected function createRedis(array $config): Redis + { + return $this->fakeRedis; + } + }; + } + + public function testReconnectAuthenticatesWhenAuthConfigured() + { + $pool = $this->getMockedPool(); + $redis = m::mock(Redis::class); + $redis->shouldReceive('auth') + ->once() + ->with('secret'); + + new class($this->getContainer(), $pool, ['host' => '127.0.0.1', 'port' => 6379, 'auth' => 'secret'], $redis) extends RedisConnection { + public function __construct( + ContainerInterface $container, + PoolInterface $pool, + array $config, + private Redis $fakeRedis, + ) { + parent::__construct($container, $pool, $config); + } + + protected function createRedis(array $config): Redis + { + return $this->fakeRedis; + } + }; + } + + public function testReconnectDoesNotAuthenticateWhenAuthEmpty() + { + $pool = $this->getMockedPool(); + $redis = m::mock(Redis::class); + $redis->shouldNotReceive('auth'); + + new class($this->getContainer(), $pool, ['host' => '127.0.0.1', 'port' => 6379, 'auth' => ''], $redis) extends RedisConnection { + public function __construct( + ContainerInterface $container, + PoolInterface $pool, + array $config, + private Redis $fakeRedis, + ) { + parent::__construct($container, $pool, $config); + } + + protected function createRedis(array $config): Redis + { + return $this->fakeRedis; + } + }; + } + + public function testCloseNullsConnection() + { + $connection = $this->mockRedisConnection(); + + // getActiveConnection() lazily creates the connection + $connection->getActiveConnection(); + $this->assertNotNull($connection->client()); + + $result = $connection->close(); + + $this->assertTrue($result); + $this->assertNull($connection->client()); + } + + public function testIsQueueingModeReturnsFalseForRedisCluster() + { + $connection = $this->mockRedisConnection(transform: true); + + // RedisCluster doesn't have getMode() in the isQueueingMode check — + // the method returns false when connection is not Redis instance. + // This means transforms still fire for cluster connections during pipeline. + $clusterMock = m::mock(RedisCluster::class); + $clusterMock->shouldReceive('setNx') + ->once() + ->with('key', 'value') + ->andReturn(true); + + $connection->setActiveConnection($clusterMock); + + // With transform enabled on a cluster connection, callSetnx should fire + // (isQueueingMode returns false for non-Redis) + $result = $connection->__call('setnx', ['key', 'value']); + + $this->assertSame(1, $result); + } + + public function testRetryFailureZeroesLastUseTime() + { + $pool = $this->getMockedPool(); + $redis = m::mock(Redis::class); + + $redis->shouldReceive('get') + ->once() + ->andThrow(new RedisException('first failure')); + + $connection = new class($this->getContainer(), $pool, ['host' => '127.0.0.1', 'port' => 6379], $redis) extends RedisConnection { + private bool $constructed = false; + + public function __construct( + ContainerInterface $container, + PoolInterface $pool, + array $config, + private Redis $fakeRedis, + ) { + parent::__construct($container, $pool, $config); + $this->constructed = true; + } + + protected function createRedis(array $config): Redis + { + if ($this->constructed) { + // Retry's reconnect fails + throw new RedisException('reconnect failed'); + } + + // Initial construction succeeds + return $this->fakeRedis; + } + + public function getLastUseTime(): float + { + return $this->lastUseTime; + } + }; + + // lastUseTime should be non-zero after initial construction + $this->assertGreaterThan(0.0, $connection->getLastUseTime()); + + try { + $connection->__call('get', ['foo']); + $this->fail('Expected RedisException'); + } catch (RedisException $exception) { + $this->assertSame('reconnect failed', $exception->getMessage()); + } + + $this->assertSame(0.0, $connection->getLastUseTime()); + } + + public function testScanWithArrayOptions() + { + $connection = $this->mockRedisConnection(transform: true); + $cursor = 0; + + $connection->getConnection() + ->shouldReceive('scan') + ->with(0, 'prefix:*', 20) + ->once() + ->andReturn(['key1', 'key2']); + + $result = $connection->scan($cursor, ['match' => 'prefix:*', 'count' => 20]); + + $this->assertEquals([0, ['key1', 'key2']], $result); + } + protected function mockRedisConnection(?ContainerInterface $container = null, ?PoolInterface $pool = null, array $options = [], bool $transform = false): RedisConnection { $connection = new RedisConnectionStub( @@ -895,9 +1864,9 @@ protected function mockRedisConnection(?ContainerInterface $container = null, ?P protected function getMockedPool(): PoolInterface { - $pool = Mockery::mock(PoolInterface::class); + $pool = m::mock(PoolInterface::class); $pool->shouldReceive('getOption') - ->andReturn(Mockery::mock(PoolOption::class)); + ->andReturn(m::mock(PoolOption::class)); return $pool; } diff --git a/tests/Redis/RedisFactoryTest.php b/tests/Redis/RedisFactoryTest.php index 39b1974ad..e94f6d6e0 100644 --- a/tests/Redis/RedisFactoryTest.php +++ b/tests/Redis/RedisFactoryTest.php @@ -4,8 +4,10 @@ namespace Hypervel\Tests\Redis; -use Hyperf\Contract\ConfigInterface; -use Hyperf\Redis\Exception\InvalidRedisProxyException; +use Hypervel\Config\Repository; +use Hypervel\Contracts\Container\Container as ContainerContract; +use Hypervel\Redis\Exceptions\InvalidRedisProxyException; +use Hypervel\Redis\RedisConfig; use Hypervel\Redis\RedisFactory; use Hypervel\Redis\RedisProxy; use Hypervel\Tests\TestCase; @@ -83,10 +85,12 @@ public function testGetReturnsSameProxyInstanceOnMultipleCalls(): void private function createFactoryWithProxies(array $proxies): RedisFactory { // Create factory with empty config (no pools created) - $config = m::mock(ConfigInterface::class); - $config->shouldReceive('get')->with('redis')->andReturn([]); + $config = m::mock(Repository::class); + $config->shouldReceive('get')->with('database.redis')->andReturn([]); + $redisConfig = new RedisConfig($config); + $container = m::mock(ContainerContract::class); - $factory = new RedisFactory($config); + $factory = new RedisFactory($redisConfig, $container); // Inject proxies via reflection $reflection = new ReflectionClass($factory); diff --git a/tests/Redis/RedisPoolTest.php b/tests/Redis/RedisPoolTest.php new file mode 100644 index 000000000..21a769e04 --- /dev/null +++ b/tests/Redis/RedisPoolTest.php @@ -0,0 +1,167 @@ + 'redis', + 'port' => 16379, + 'db' => 0, + 'pool' => [ + 'min_connections' => 1, + 'max_connections' => 30, + 'connect_timeout' => 10.0, + 'wait_timeout' => 3.0, + 'heartbeat' => -1, + 'max_idle_time' => 1, + ], + ]; + + $container = $this->mockContainerWithRedisConfig($connectionConfig); + $pool = new RedisPool($container, 'default'); + + $this->assertSame($connectionConfig, $pool->getConfig()); + } + + public function testLowFrequencyFlushClosesIdleConnections(): void + { + TestPoolConnection::reset(); + + $connectionConfig = [ + 'host' => 'redis', + 'port' => 16379, + 'db' => 0, + 'pool' => [ + 'min_connections' => 1, + 'max_connections' => 30, + 'connect_timeout' => 10.0, + 'wait_timeout' => 3.0, + 'heartbeat' => -1, + 'max_idle_time' => 1, + ], + ]; + + $container = $this->mockContainerWithRedisConfig($connectionConfig); + $container->shouldReceive('has')->andReturn(false); + + $pool = new TestRedisPool($container, 'default'); + + $connection1 = $pool->get(); + $connection2 = $pool->get(); + $connection3 = $pool->get(); + + $this->assertSame(3, $pool->getCurrentConnections()); + + $connection1->release(); + $connection2->release(); + $connection3->release(); + + $this->assertSame(3, $pool->getCurrentConnections()); + + $pool->setFrequencyForTest(new AlwaysLowFrequency()); + $connection = $pool->get(); + + $this->assertSame(1, $pool->getCurrentConnections()); + $this->assertSame(2, TestPoolConnection::$closeCount); + + $connection->release(); + + $this->assertSame(1, $pool->getCurrentConnections()); + $this->assertSame(1, $pool->getConnectionsInChannel()); + } + + /** + * @param array $connectionConfig + */ + private function mockContainerWithRedisConfig(array $connectionConfig): m\MockInterface|ContainerInterface + { + $redisConfig = m::mock(RedisConfig::class); + $redisConfig->shouldReceive('connectionConfig')->once()->with('default')->andReturn($connectionConfig); + + $container = m::mock(ContainerInterface::class); + $container->shouldReceive('get')->with(RedisConfig::class)->once()->andReturn($redisConfig); + + return $container; + } +} + +class TestRedisPool extends RedisPool +{ + public function setFrequencyForTest(FrequencyInterface|LowFrequencyInterface $frequency): void + { + $this->frequency = $frequency; + } + + protected function createConnection(): ConnectionInterface + { + return new TestPoolConnection($this->container, $this); + } +} + +class TestPoolConnection extends Connection +{ + public static int $closeCount = 0; + + public static function reset(): void + { + self::$closeCount = 0; + } + + public function close(): bool + { + ++self::$closeCount; + + return true; + } + + public function reconnect(): bool + { + return true; + } + + public function getActiveConnection(): static + { + return $this; + } +} + +class AlwaysLowFrequency implements FrequencyInterface, LowFrequencyInterface +{ + public function __construct(?\Hypervel\Pool\Pool $pool = null) + { + } + + public function hit(int $number = 1): bool + { + return true; + } + + public function frequency(): float + { + return 0.0; + } + + public function isLowFrequency(): bool + { + return true; + } +} diff --git a/tests/Redis/RedisProxyTest.php b/tests/Redis/RedisProxyTest.php index c8f617fd7..b24c75955 100644 --- a/tests/Redis/RedisProxyTest.php +++ b/tests/Redis/RedisProxyTest.php @@ -4,10 +4,10 @@ namespace Hypervel\Tests\Redis; -use Hyperf\Redis\Pool\PoolFactory; -use Hyperf\Redis\Pool\RedisPool; use Hypervel\Context\Context; use Hypervel\Foundation\Testing\Concerns\RunTestsInCoroutine; +use Hypervel\Redis\Pool\PoolFactory; +use Hypervel\Redis\Pool\RedisPool; use Hypervel\Redis\RedisConnection; use Hypervel\Redis\RedisProxy; use Hypervel\Tests\TestCase; diff --git a/tests/Redis/RedisTest.php b/tests/Redis/RedisTest.php index cf437f7f8..2f401800c 100644 --- a/tests/Redis/RedisTest.php +++ b/tests/Redis/RedisTest.php @@ -6,20 +6,29 @@ use Exception; use Hyperf\Pool\PoolOption; -use Hyperf\Redis\Event\CommandExecuted; -use Hyperf\Redis\Pool\PoolFactory; -use Hyperf\Redis\Pool\RedisPool; +use Hypervel\Context\ApplicationContext; use Hypervel\Context\Context; +use Hypervel\Engine\Channel; use Hypervel\Foundation\Testing\Concerns\RunTestsInCoroutine; +use Hypervel\Redis\Events\CommandExecuted; +use Hypervel\Redis\Pool\PoolFactory; +use Hypervel\Redis\Pool\RedisPool; use Hypervel\Redis\Redis; use Hypervel\Redis\RedisConnection; +use Hypervel\Redis\RedisFactory; +use Hypervel\Redis\RedisProxy; use Hypervel\Tests\TestCase; use Mockery as m; use Psr\EventDispatcher\EventDispatcherInterface; use Redis as PhpRedis; +use RedisCluster; +use RedisSentinel; +use ReflectionClass; use RuntimeException; use Throwable; +use function Hypervel\Coroutine\go; + /** * Tests for the Redis class - the main public API. * @@ -33,6 +42,15 @@ class RedisTest extends TestCase { use RunTestsInCoroutine; + protected bool $isOlderThan6 = false; + + protected function setUp(): void + { + parent::setUp(); + + $this->isOlderThan6 = version_compare((string) phpversion('redis'), '6.0.0', '<'); + } + protected function tearDown(): void { parent::tearDown(); @@ -103,6 +121,98 @@ public function testConnectionIsStoredInContextForSelect(): void $this->assertTrue(Context::has('redis.connection.default')); } + public function testConnectionIsStoredInContextForSelectZeroDatabase(): void + { + $connection = $this->mockConnection(); + $connection->shouldReceive('select')->once()->with(0)->andReturn(true); + $connection->shouldReceive('setDatabase')->once()->with(0); + $connection->shouldReceive('release')->once(); + + $redis = $this->createRedis($connection); + + $result = $redis->select(0); + + $this->assertTrue($result); + $this->assertTrue(Context::has('redis.connection.default')); + } + + public function testSelectPinnedConnectionDoesNotLeakAcrossCoroutines(): void + { + $setConnection = $this->mockConnection(); + $setConnection->shouldReceive('set')->once()->with('xxxx', 'yyyy')->andReturn('db:0 name:set argument:xxxx,yyyy'); + $setConnection->shouldReceive('release')->once(); + + $selectedConnection = $this->mockConnection(); + $selectedConnection->shouldReceive('select')->once()->with(2)->andReturn(true); + $selectedConnection->shouldReceive('setDatabase')->once()->with(2); + $selectedConnection->shouldReceive('get')->once()->with('xxxx')->andReturn('db:2 name:get argument:xxxx'); + $selectedConnection->shouldReceive('release')->once(); + + $otherCoroutineConnection = $this->mockConnection(); + $otherCoroutineConnection->shouldReceive('get')->once()->with('xxxx')->andReturn('db:0 name:get argument:xxxx'); + $otherCoroutineConnection->shouldReceive('release')->once(); + + $pool = m::mock(RedisPool::class); + $pool->shouldReceive('get')->times(3)->andReturn( + $setConnection, + $selectedConnection, + $otherCoroutineConnection, + ); + $pool->shouldReceive('getOption')->andReturn(m::mock(PoolOption::class)); + + $poolFactory = m::mock(PoolFactory::class); + $poolFactory->shouldReceive('getPool')->with('default')->andReturn($pool); + + $redis = new Redis($poolFactory); + + $this->assertSame('db:0 name:set argument:xxxx,yyyy', $redis->set('xxxx', 'yyyy')); + $this->assertTrue($redis->select(2)); + $this->assertSame('db:2 name:get argument:xxxx', $redis->get('xxxx')); + + $channel = new Channel(1); + go(static function () use ($redis, $channel) { + $channel->push($redis->get('xxxx')); + }); + + $this->assertSame('db:0 name:get argument:xxxx', $channel->pop()); + } + + public function testPinnedConnectionInOneCoroutineIsNotReusedInAnotherCoroutine(): void + { + $pipeline = m::mock(PhpRedis::class); + + $pinnedConnection = $this->mockConnection(); + $pinnedConnection->shouldReceive('multi')->once()->andReturn($pipeline); + $pinnedConnection->shouldReceive('set')->once()->with('id', '123')->andReturnSelf(); + $pinnedConnection->shouldReceive('exec')->once()->andReturn([]); + $pinnedConnection->shouldReceive('release')->once(); + + $otherCoroutineConnection = $this->mockConnection(); + $otherCoroutineConnection->shouldReceive('get')->once()->with('id')->andReturn('from-other-connection'); + $otherCoroutineConnection->shouldReceive('release')->once(); + + $pool = m::mock(RedisPool::class); + $pool->shouldReceive('get')->times(2)->andReturn($pinnedConnection, $otherCoroutineConnection); + $pool->shouldReceive('getOption')->andReturn(m::mock(PoolOption::class)); + + $poolFactory = m::mock(PoolFactory::class); + $poolFactory->shouldReceive('getPool')->with('default')->andReturn($pool); + + $redis = new Redis($poolFactory); + + $redis->multi(); + $redis->set('id', '123'); + + $channel = new Channel(1); + go(static function () use ($redis, $channel) { + $channel->push($redis->get('id')); + }); + + $this->assertSame('from-other-connection', $channel->pop()); + + $this->assertSame([], $redis->exec()); + } + public function testExistingContextConnectionIsReused(): void { $connection = $this->mockConnection(); @@ -140,6 +250,24 @@ public function testExceptionIsPropagated(): void $redis->get('key'); } + public function testReleasesConnectionWhenUnderlyingGetConnectionFails(): void + { + $connection = m::mock(RedisConnection::class); + $connection->shouldReceive('shouldTransform')->andReturnSelf(); + $connection->shouldReceive('getEventDispatcher')->andReturnNull(); + $connection->shouldReceive('getConnection') + ->once() + ->andThrow(new RuntimeException('Get connection failed.')); + $connection->shouldReceive('release')->once(); + + $redis = $this->createRedis($connection); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Get connection failed.'); + + $redis->set('xxxx', 'yyyy'); + } + public function testExceptionWithContextConnectionDoesNotReleaseConnection(): void { $expectedException = new Exception('Redis error'); @@ -401,6 +529,117 @@ public function testWithConnectionAllowsMultipleOperationsOnSameConnection(): vo $this->assertSame('NOSCRIPT No matching script', $result); } + public function testRedisClusterConstructorSignature(): void + { + $reflection = new ReflectionClass(RedisCluster::class); + $method = $reflection->getMethod('__construct'); + $names = [ + ['name', 'string'], + ['seeds', 'array'], + ['timeout', ['int', 'float']], + ['read_timeout', ['int', 'float']], + ['persistent', 'bool'], + ['auth', 'mixed'], + ['context', 'array'], + ]; + + foreach ($method->getParameters() as $parameter) { + [$name, $type] = array_shift($names); + $this->assertSame($name, $parameter->getName()); + + if ($parameter->getName() === 'seeds') { + $this->assertSame('array', $parameter->getType()?->getName()); + continue; + } + + if ($this->isOlderThan6) { + $this->assertNull($parameter->getType()); + continue; + } + + if (is_array($type)) { + foreach ($parameter->getType()?->getTypes() ?? [] as $namedType) { + $this->assertTrue(in_array($namedType->getName(), $type, true)); + } + + continue; + } + + $this->assertSame($type, $parameter->getType()?->getName()); + } + } + + public function testRedisSentinelConstructorSignature(): void + { + $reflection = new ReflectionClass(RedisSentinel::class); + $method = $reflection->getMethod('__construct'); + $count = count($method->getParameters()); + + if (! $this->isOlderThan6) { + $this->assertSame(1, $count); + $this->assertSame('options', $method->getParameters()[0]->getName()); + + return; + } + + if ($count === 6) { + $this->markTestIncomplete('RedisSentinel does not support auth in this extension variant.'); + } + + $this->assertSame(7, $count); + } + + public function testShuffleNodesMaintainsNodeCount(): void + { + $nodes = ['127.0.0.1:6379', '127.0.0.1:6378', '127.0.0.1:6377']; + + shuffle($nodes); + + $this->assertIsArray($nodes); + $this->assertSame(3, count($nodes)); + } + + public function testConnectionReturnsNamedProxy() + { + $proxy = m::mock(RedisProxy::class); + + $factory = m::mock(RedisFactory::class); + $factory->shouldReceive('get') + ->once() + ->with('cache') + ->andReturn($proxy); + + $container = m::mock(\Hypervel\Contracts\Container\Container::class); + $container->shouldReceive('get') + ->with(RedisFactory::class) + ->once() + ->andReturn($factory); + + ApplicationContext::setContainer($container); + + $redis = $this->createRedis($this->mockConnection()); + + $result = $redis->connection('cache'); + + $this->assertSame($proxy, $result); + } + + public function testFlushByPatternDelegatestoConnection() + { + $connection = $this->mockConnection(); + $connection->shouldReceive('flushByPattern') + ->once() + ->with('cache:*') + ->andReturn(42); + $connection->shouldReceive('release')->once(); + + $redis = $this->createRedis($connection); + + $result = $redis->flushByPattern('cache:*'); + + $this->assertSame(42, $result); + } + /** * Create a mock RedisConnection with standard expectations. */ diff --git a/tests/Redis/Stubs/RedisConnectionStub.php b/tests/Redis/Stubs/RedisConnectionStub.php index 14d0e981a..c62613ae8 100644 --- a/tests/Redis/Stubs/RedisConnectionStub.php +++ b/tests/Redis/Stubs/RedisConnectionStub.php @@ -4,13 +4,13 @@ namespace Hypervel\Tests\Redis\Stubs; -use Hyperf\Contract\PoolInterface; +use Hypervel\Contracts\Pool\PoolInterface; use Hypervel\Redis\RedisConnection; -use Mockery; +use Mockery as m; use Psr\Container\ContainerInterface; use Redis; use RedisCluster; -use Throwable; +use RedisException; class RedisConnectionStub extends RedisConnection { @@ -41,18 +41,20 @@ public function check(): bool return true; } - public function getActiveConnection(): Redis|RedisCluster + public function getActiveConnection(): static { if ($this->connection !== null) { - return $this->connection; + return $this; } // Use shouldIgnoreMissing() to prevent falling through to real Redis // methods when expectations don't match (which causes "Redis server went away") $connection = $this->redisConnection - ?? Mockery::mock(Redis::class)->shouldIgnoreMissing(); + ?? m::mock(Redis::class)->shouldIgnoreMissing(); - return $this->connection = $connection; + $this->connection = $connection; + + return $this; } public function setActiveConnection(Redis|RedisCluster $connection): static @@ -68,10 +70,22 @@ public function setActiveConnection(Redis|RedisCluster $connection): static */ public function getConnection(): Redis|RedisCluster { - return $this->getActiveConnection(); + $this->getActiveConnection(); + + return $this->connection; + } + + /** + * Get the merged connection configuration. + * + * @return array + */ + public function getConfigForTest(): array + { + return $this->config; } - protected function retry($name, $arguments, Throwable $exception) + protected function retry(string $name, array $arguments, RedisException $exception): mixed { throw $exception; } diff --git a/tests/Redis/Subscriber/CommandBuilderTest.php b/tests/Redis/Subscriber/CommandBuilderTest.php new file mode 100644 index 000000000..9cee4f5e3 --- /dev/null +++ b/tests/Redis/Subscriber/CommandBuilderTest.php @@ -0,0 +1,87 @@ +assertSame("\$-1\r\n", CommandBuilder::build(null)); + } + + public function testBuildInteger() + { + $this->assertSame(":1\r\n", CommandBuilder::build(1)); + } + + public function testBuildString() + { + $this->assertSame("\$3\r\nfoo\r\n", CommandBuilder::build('foo')); + } + + public function testBuildSimpleArray() + { + $this->assertSame( + "*2\r\n\$3\r\nfoo\r\n\$3\r\nbar\r\n", + CommandBuilder::build(['foo', 'bar']) + ); + } + + public function testBuildNestedArray() + { + $this->assertSame( + "*4\r\n:1\r\n*2\r\n:2\r\n\$1\r\n4\r\n:2\r\n\$3\r\nbar\r\n", + CommandBuilder::build([1, [2, '4'], 2, 'bar']) + ); + } + + public function testBuildPing() + { + $this->assertSame("PING\r\n", CommandBuilder::build('ping')); + } + + public function testBuildEmptyString() + { + $this->assertSame("\$0\r\n\r\n", CommandBuilder::build('')); + } + + public function testBuildEmptyArray() + { + $this->assertSame("*0\r\n", CommandBuilder::build([])); + } + + public function testBuildZeroInteger() + { + $this->assertSame(":0\r\n", CommandBuilder::build(0)); + } + + public function testBuildNegativeInteger() + { + $this->assertSame(":-5\r\n", CommandBuilder::build(-5)); + } + + public function testBuildSubscribeCommand() + { + $this->assertSame( + "*2\r\n\$9\r\nsubscribe\r\n\$10\r\nmy-channel\r\n", + CommandBuilder::build(['subscribe', 'my-channel']) + ); + } + + public function testBuildAuthCommand() + { + $this->assertSame( + "*2\r\n\$4\r\nauth\r\n\$8\r\npassword\r\n", + CommandBuilder::build(['auth', 'password']) + ); + } +} diff --git a/tests/Redis/Subscriber/CommandInvokerTest.php b/tests/Redis/Subscriber/CommandInvokerTest.php new file mode 100644 index 000000000..461306699 --- /dev/null +++ b/tests/Redis/Subscriber/CommandInvokerTest.php @@ -0,0 +1,288 @@ +createMockConnection($responses); + $connection->shouldReceive('send')->once(); + $connection->shouldReceive('close')->atLeast()->once(); + + $invoker = new CommandInvoker($connection); + $result = $invoker->invoke(['subscribe', 'foo'], 1); + + $this->assertCount(1, $result); + $this->assertIsArray($result[0]); + } + + public function testInvokeInterruptsAndRethrowsOnSendFailure() + { + $connection = $this->createMockConnection([false]); + $connection->shouldReceive('send') + ->once() + ->andThrow(new SocketException('Connection lost')); + $connection->shouldReceive('close')->atLeast()->once(); + + $invoker = new CommandInvoker($connection); + + try { + $invoker->invoke(['subscribe', 'foo'], 1); + $this->fail('Expected SocketException was not thrown'); + } catch (SocketException $e) { + $this->assertSame('Connection lost', $e->getMessage()); + } + + // interrupt() should have closed the message channel + $this->assertFalse($invoker->channel()->pop(0.01)); + } + + public function testChannelReturnsMessageChannel() + { + $connection = $this->createMockConnection([false]); + $connection->shouldReceive('close')->atLeast()->once(); + + $invoker = new CommandInvoker($connection); + $channel = $invoker->channel(); + + $this->assertInstanceOf(\Hypervel\Engine\Channel::class, $channel); + } + + public function testInterruptClosesAllChannels() + { + $connection = $this->createMockConnection([false]); + $connection->shouldReceive('close')->atLeast()->once(); + + $invoker = new CommandInvoker($connection); + + // Give the background coroutine time to start and exit + usleep(10_000); + + $result = $invoker->interrupt(); + $this->assertTrue($result); + + // Channel should be closed — pop returns false + $this->assertFalse($invoker->channel()->pop(0.01)); + } + + public function testShutdownWatcherInterruptsOnWorkerExit() + { + // Create a connection that blocks on recv() indefinitely (simulating + // a real socket waiting for messages). The shutdown watcher should + // interrupt it when WORKER_EXIT is resumed. + $connection = m::mock(Connection::class); + $connection->shouldReceive('recv') + ->andReturnUsing(function () { + // Block long enough that only the shutdown watcher can unblock us + usleep(5_000_000); + return false; + }); + $connection->shouldReceive('close')->atLeast()->once(); + + $invoker = new CommandInvoker($connection); + + // Resume WORKER_EXIT — this should trigger the shutdown watcher + // which calls interrupt(), closing the connection and channels. + CoordinatorManager::until(Constants::WORKER_EXIT)->resume(); + + // Give the shutdown watcher coroutine time to fire + usleep(50_000); + + // Message channel should be closed by interrupt() + $this->assertFalse($invoker->channel()->pop(0.01)); + } + + public function testReceiveRoutesMessageToMessageChannel() + { + // Simulate: subscribe confirmation, then a message, then disconnect + $responses = [ + // Subscribe confirmation (*3 array) + "*3\r\n", + "\$9\r\n", + "subscribe\r\n", + "\$3\r\n", + "foo\r\n", + ":1\r\n", + // Message (*3 array with 'message' type — 7 lines total) + "*3\r\n", + "\$7\r\n", + "message\r\n", + "\$3\r\n", + "foo\r\n", + "\$5\r\n", + "hello\r\n", + // Disconnect + false, + ]; + + $connection = $this->createMockConnection($responses); + $connection->shouldReceive('send')->once(); + $connection->shouldReceive('close')->atLeast()->once(); + + $invoker = new CommandInvoker($connection); + + // Send subscribe to consume the confirmation + $invoker->invoke(['subscribe', 'foo'], 1); + + // Pop the message from the message channel + $message = $invoker->channel()->pop(1.0); + + $this->assertInstanceOf(Message::class, $message); + $this->assertSame('foo', $message->channel); + $this->assertSame('hello', $message->payload); + $this->assertNull($message->pattern); + } + + public function testReceiveRoutesPmessageToMessageChannel() + { + // Simulate: psubscribe confirmation, then a pmessage, then disconnect + $responses = [ + // Psubscribe confirmation (*3 array) + "*3\r\n", + "\$10\r\n", + "psubscribe\r\n", + "\$5\r\n", + "foo.*\r\n", + ":1\r\n", + // Pmessage (*4 array with 'pmessage' type — 9 lines total) + "*4\r\n", + "\$8\r\n", + "pmessage\r\n", + "\$5\r\n", + "foo.*\r\n", + "\$7\r\n", + "foo.bar\r\n", + "\$4\r\n", + "data\r\n", + // Disconnect + false, + ]; + + $connection = $this->createMockConnection($responses); + $connection->shouldReceive('send')->once(); + $connection->shouldReceive('close')->atLeast()->once(); + + $invoker = new CommandInvoker($connection); + + // Send psubscribe to consume the confirmation + $invoker->invoke(['psubscribe', 'foo.*'], 1); + + // Pop the pmessage from the message channel + $message = $invoker->channel()->pop(1.0); + + $this->assertInstanceOf(Message::class, $message); + $this->assertSame('foo.bar', $message->channel); + $this->assertSame('data', $message->payload); + $this->assertSame('foo.*', $message->pattern); + } + + public function testReceiveRoutesPongToPingChannel() + { + // Simulate: pong response, then disconnect + $responses = [ + // Pong (*1 array — 5 lines: *1, $4, pong, $0, empty) + // Actually looking at the code: type = buffer[2], pong check is count==5 + // So it needs: *-something, $4, pong, $0, (empty) + "*1\r\n", + "\$4\r\n", + "pong\r\n", + "\$0\r\n", + "\r\n", + // Disconnect + false, + ]; + + $connection = $this->createMockConnection($responses); + $connection->shouldReceive('send')->once(); + $connection->shouldReceive('close')->atLeast()->once(); + + $invoker = new CommandInvoker($connection); + + // Send ping — the result should come from the ping channel + $result = $invoker->ping(1.0); + + $this->assertSame('pong', $result); + } + + public function testReceiveDisconnectsOnEmptyLine() + { + $connection = $this->createMockConnection(['']); + $connection->shouldReceive('close')->atLeast()->once(); + + $invoker = new CommandInvoker($connection); + + // Give the background coroutine time to process + usleep(10_000); + + // Message channel should be closed + $this->assertFalse($invoker->channel()->pop(0.01)); + } + + /** + * Create a mock Connection that returns the given responses from recv(). + * + * Uses andReturnUsing with usleep before the final false response so the + * background coroutine yields, giving the test coroutine time to pop + * messages from the channel before interrupt() closes it. + * + * @param array $responses + */ + private function createMockConnection(array $responses): Connection + { + $connection = m::mock(Connection::class); + $connection->shouldReceive('recv') + ->andReturnUsing(function () use (&$responses) { + $response = array_shift($responses); + if ($response === false || $response === null) { + // Yield before disconnecting so the test coroutine + // can pop any buffered messages from the channel. + usleep(50_000); + return false; + } + return $response; + }); + + return $connection; + } +} diff --git a/tests/Redis/Subscriber/ConnectionTest.php b/tests/Redis/Subscriber/ConnectionTest.php new file mode 100644 index 000000000..9de42347b --- /dev/null +++ b/tests/Redis/Subscriber/ConnectionTest.php @@ -0,0 +1,110 @@ +shouldReceive('sendAll')->with('hello')->once()->andReturn(5); + + $connection = $this->createConnection($socket); + + $this->assertTrue($connection->send('hello')); + } + + public function testSendThrowsWhenSendAllReturnsFalse() + { + $socket = m::mock(SocketInterface::class); + $socket->shouldReceive('sendAll')->with('data')->once()->andReturn(false); + + $connection = $this->createConnection($socket); + + $this->expectException(SocketException::class); + $this->expectExceptionMessage('Failed to send data to the socket.'); + + $connection->send('data'); + } + + public function testSendThrowsWhenSendIncomplete() + { + $socket = m::mock(SocketInterface::class); + // Data is 5 bytes but only 3 were sent + $socket->shouldReceive('sendAll')->with('hello')->once()->andReturn(3); + + $connection = $this->createConnection($socket); + + $this->expectException(SocketException::class); + $this->expectExceptionMessage('The sending data is incomplete'); + + $connection->send('hello'); + } + + public function testRecvDelegatesToSocket() + { + $socket = m::mock(SocketInterface::class); + $socket->shouldReceive('recvPacket')->with(-1.0)->once()->andReturn("*3\r\n"); + + $connection = $this->createConnection($socket); + + $this->assertSame("*3\r\n", $connection->recv()); + } + + public function testRecvPassesTimeout() + { + $socket = m::mock(SocketInterface::class); + $socket->shouldReceive('recvPacket')->with(5.0)->once()->andReturn(false); + + $connection = $this->createConnection($socket); + + $this->assertFalse($connection->recv(5.0)); + } + + public function testCloseSucceeds() + { + $socket = m::mock(SocketInterface::class); + $socket->shouldReceive('close')->once()->andReturn(true); + + $connection = $this->createConnection($socket); + + $connection->close(); + + // Second close should not call socket->close() again + $connection->close(); + } + + public function testCloseThrowsWhenSocketCloseFails() + { + $socket = m::mock(SocketInterface::class); + $socket->shouldReceive('close')->once()->andReturn(false); + + $connection = $this->createConnection($socket); + + $this->expectException(SocketException::class); + $this->expectExceptionMessage('Failed to close the socket.'); + + $connection->close(); + } + + private function createConnection(SocketInterface $socket): Connection + { + $factory = m::mock(SocketFactoryInterface::class); + $factory->shouldReceive('make')->once()->andReturn($socket); + + return new Connection('127.0.0.1', 6379, 5.0, $factory); + } +} diff --git a/tests/Redis/Subscriber/MessageTest.php b/tests/Redis/Subscriber/MessageTest.php new file mode 100644 index 000000000..046b74acf --- /dev/null +++ b/tests/Redis/Subscriber/MessageTest.php @@ -0,0 +1,45 @@ +assertSame('my-channel', $message->channel); + $this->assertSame('hello', $message->payload); + $this->assertNull($message->pattern); + } + + public function testConstructWithPattern() + { + $message = new Message(channel: 'events.user.created', payload: 'data', pattern: 'events.*'); + + $this->assertSame('events.user.created', $message->channel); + $this->assertSame('data', $message->payload); + $this->assertSame('events.*', $message->pattern); + } + + public function testPropertiesAreReadonly() + { + $message = new Message(channel: 'ch', payload: 'msg'); + + $reflection = new ReflectionClass($message); + + $this->assertTrue($reflection->getProperty('channel')->isReadOnly()); + $this->assertTrue($reflection->getProperty('payload')->isReadOnly()); + $this->assertTrue($reflection->getProperty('pattern')->isReadOnly()); + } +} diff --git a/tests/Redis/Subscriber/SubscriberTest.php b/tests/Redis/Subscriber/SubscriberTest.php new file mode 100644 index 000000000..8a228cfab --- /dev/null +++ b/tests/Redis/Subscriber/SubscriberTest.php @@ -0,0 +1,240 @@ +shouldReceive('invoke') + ->once() + ->with(['subscribe', 'foo', 'bar'], 2) + ->andReturn([['subscribe'], ['subscribe']]); + + $subscriber = $this->createSubscriber($invoker); + $subscriber->subscribe('foo', 'bar'); + } + + public function testSubscribePrependsPrefix() + { + $invoker = m::mock(CommandInvoker::class); + $invoker->shouldReceive('invoke') + ->once() + ->with(['subscribe', 'app:foo', 'app:bar'], 2) + ->andReturn([['subscribe'], ['subscribe']]); + + $subscriber = $this->createSubscriber($invoker, prefix: 'app:'); + $subscriber->subscribe('foo', 'bar'); + } + + public function testSubscribeThrowsOnFailure() + { + $invoker = m::mock(CommandInvoker::class); + $invoker->shouldReceive('invoke') + ->once() + ->with(['subscribe', 'foo'], 1) + ->andReturn([false]); + $invoker->shouldReceive('interrupt')->once(); + + $subscriber = $this->createSubscriber($invoker); + + $this->expectException(SubscribeException::class); + $this->expectExceptionMessage('Subscribe failed'); + + $subscriber->subscribe('foo'); + } + + public function testUnsubscribeDelegatesToCommandInvoker() + { + $invoker = m::mock(CommandInvoker::class); + $invoker->shouldReceive('invoke') + ->once() + ->with(['unsubscribe', 'foo'], 1) + ->andReturn([['unsubscribe']]); + + $subscriber = $this->createSubscriber($invoker); + $subscriber->unsubscribe('foo'); + } + + public function testUnsubscribePrependsPrefix() + { + $invoker = m::mock(CommandInvoker::class); + $invoker->shouldReceive('invoke') + ->once() + ->with(['unsubscribe', 'app:foo'], 1) + ->andReturn([['unsubscribe']]); + + $subscriber = $this->createSubscriber($invoker, prefix: 'app:'); + $subscriber->unsubscribe('foo'); + } + + public function testUnsubscribeThrowsOnFailure() + { + $invoker = m::mock(CommandInvoker::class); + $invoker->shouldReceive('invoke') + ->once() + ->with(['unsubscribe', 'foo'], 1) + ->andReturn([false]); + $invoker->shouldReceive('interrupt')->once(); + + $subscriber = $this->createSubscriber($invoker); + + $this->expectException(UnsubscribeException::class); + $this->expectExceptionMessage('Unsubscribe failed'); + + $subscriber->unsubscribe('foo'); + } + + public function testPsubscribeDelegatesToCommandInvoker() + { + $invoker = m::mock(CommandInvoker::class); + $invoker->shouldReceive('invoke') + ->once() + ->with(['psubscribe', 'foo.*', 'bar.*'], 2) + ->andReturn([['psubscribe'], ['psubscribe']]); + + $subscriber = $this->createSubscriber($invoker); + $subscriber->psubscribe('foo.*', 'bar.*'); + } + + public function testPsubscribePrependsPrefix() + { + $invoker = m::mock(CommandInvoker::class); + $invoker->shouldReceive('invoke') + ->once() + ->with(['psubscribe', 'app:events.*'], 1) + ->andReturn([['psubscribe']]); + + $subscriber = $this->createSubscriber($invoker, prefix: 'app:'); + $subscriber->psubscribe('events.*'); + } + + public function testPsubscribeThrowsOnFailure() + { + $invoker = m::mock(CommandInvoker::class); + $invoker->shouldReceive('invoke') + ->once() + ->with(['psubscribe', 'foo.*'], 1) + ->andReturn([false]); + $invoker->shouldReceive('interrupt')->once(); + + $subscriber = $this->createSubscriber($invoker); + + $this->expectException(SubscribeException::class); + $this->expectExceptionMessage('Psubscribe failed'); + + $subscriber->psubscribe('foo.*'); + } + + public function testPunsubscribeDelegatesToCommandInvoker() + { + $invoker = m::mock(CommandInvoker::class); + $invoker->shouldReceive('invoke') + ->once() + ->with(['punsubscribe', 'foo.*'], 1) + ->andReturn([['punsubscribe']]); + + $subscriber = $this->createSubscriber($invoker); + $subscriber->punsubscribe('foo.*'); + } + + public function testPunsubscribePrependsPrefix() + { + $invoker = m::mock(CommandInvoker::class); + $invoker->shouldReceive('invoke') + ->once() + ->with(['punsubscribe', 'app:foo.*'], 1) + ->andReturn([['punsubscribe']]); + + $subscriber = $this->createSubscriber($invoker, prefix: 'app:'); + $subscriber->punsubscribe('foo.*'); + } + + public function testPunsubscribeThrowsOnFailure() + { + $invoker = m::mock(CommandInvoker::class); + $invoker->shouldReceive('invoke') + ->once() + ->with(['punsubscribe', 'foo.*'], 1) + ->andReturn([false]); + $invoker->shouldReceive('interrupt')->once(); + + $subscriber = $this->createSubscriber($invoker); + + $this->expectException(UnsubscribeException::class); + $this->expectExceptionMessage('Punsubscribe failed'); + + $subscriber->punsubscribe('foo.*'); + } + + public function testChannelDelegatesToCommandInvoker() + { + $channel = new Channel(1); + $invoker = m::mock(CommandInvoker::class); + $invoker->shouldReceive('channel')->once()->andReturn($channel); + + $subscriber = $this->createSubscriber($invoker); + + $this->assertSame($channel, $subscriber->channel()); + } + + public function testCloseSetsClosedAndInterrupts() + { + $invoker = m::mock(CommandInvoker::class); + $invoker->shouldReceive('interrupt')->once()->andReturn(true); + + $subscriber = $this->createSubscriber($invoker); + + $this->assertFalse($subscriber->closed); + + $subscriber->close(); + + $this->assertTrue($subscriber->closed); + } + + public function testPingDelegatesToCommandInvoker() + { + $invoker = m::mock(CommandInvoker::class); + $invoker->shouldReceive('ping')->once()->with(2.5)->andReturn('pong'); + + $subscriber = $this->createSubscriber($invoker); + + $this->assertSame('pong', $subscriber->ping(2.5)); + } + + /** + * Create a Subscriber with a mock CommandInvoker, bypassing the real connection. + */ + private function createSubscriber(CommandInvoker $invoker, string $prefix = ''): Subscriber + { + $reflection = new ReflectionClass(Subscriber::class); + $subscriber = $reflection->newInstanceWithoutConstructor(); + + $subscriber->host = '127.0.0.1'; + $subscriber->port = 6379; + $subscriber->password = ''; + $subscriber->timeout = 5.0; + $subscriber->prefix = $prefix; + $subscriber->closed = false; + + $reflection->getProperty('commandInvoker')->setValue($subscriber, $invoker); + + return $subscriber; + } +} diff --git a/tests/Router/DispatcherFactoryTest.php b/tests/Router/DispatcherFactoryTest.php index a81a61e83..c1a0ddc03 100644 --- a/tests/Router/DispatcherFactoryTest.php +++ b/tests/Router/DispatcherFactoryTest.php @@ -4,16 +4,16 @@ namespace Hypervel\Tests\Router; -use Hyperf\Context\ApplicationContext; -use Hyperf\Di\Container; use Hyperf\Di\Definition\DefinitionSource; use Hyperf\HttpServer\Router\RouteCollector as HyperfRouteCollector; +use Hypervel\Container\Container; +use Hypervel\Context\ApplicationContext; use Hypervel\Router\DispatcherFactory; use Hypervel\Router\RouteCollector; use Hypervel\Router\RouteFileCollector; use Hypervel\Router\Router; use Hypervel\Tests\TestCase; -use Mockery; +use Mockery as m; use Mockery\MockInterface; /** @@ -38,7 +38,7 @@ public function testGetRouter() } /** @var MockInterface|RouteCollector */ - $routeCollector = Mockery::mock(RouteCollector::class); + $routeCollector = m::mock(RouteCollector::class); $getContainer = $this->getContainer([ HyperfRouteCollector::class => fn () => $routeCollector, @@ -57,7 +57,7 @@ public function testInitConfigRoute() } /** @var MockInterface|RouteCollector */ - $routeCollector = Mockery::mock(RouteCollector::class); + $routeCollector = m::mock(RouteCollector::class); $routeCollector->shouldReceive('get')->with('/foo', 'Handler::Foo')->once(); $routeCollector->shouldReceive('get')->with('/bar', 'Handler::Bar')->once(); @@ -90,7 +90,7 @@ public function testRoutesAddedAfterConstructionAreLoaded() } /** @var MockInterface|RouteCollector */ - $routeCollector = Mockery::mock(RouteCollector::class); + $routeCollector = m::mock(RouteCollector::class); // Initial route from foo.php $routeCollector->shouldReceive('get')->with('/foo', 'Handler::Foo')->once(); diff --git a/tests/Router/FunctionsTest.php b/tests/Router/FunctionsTest.php index 49844d6b4..8afe61d3f 100644 --- a/tests/Router/FunctionsTest.php +++ b/tests/Router/FunctionsTest.php @@ -4,11 +4,11 @@ namespace Hypervel\Tests\Router; -use Hyperf\Context\ApplicationContext; -use Hyperf\Contract\ContainerInterface; -use Hypervel\Router\Contracts\UrlGenerator as UrlGeneratorContract; +use Hypervel\Context\ApplicationContext; +use Hypervel\Contracts\Container\Container as ContainerContract; +use Hypervel\Contracts\Router\UrlGenerator as UrlGeneratorContract; use Hypervel\Tests\TestCase; -use Mockery; +use Mockery as m; use Mockery\MockInterface; use function Hypervel\Router\route; @@ -64,9 +64,9 @@ public function testSecureUrl() */ private function mockUrlGenerator(): UrlGeneratorContract { - /** @var ContainerInterface|MockInterface */ - $container = Mockery::mock(ContainerInterface::class); - $urlGenerator = Mockery::mock(UrlGeneratorContract::class); + /** @var ContainerContract|MockInterface */ + $container = m::mock(ContainerContract::class); + $urlGenerator = m::mock(UrlGeneratorContract::class); $container->shouldReceive('get') ->with(UrlGeneratorContract::class) 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/Router/UrlGeneratorTest.php b/tests/Router/UrlGeneratorTest.php index ac19122fe..39c756cf6 100644 --- a/tests/Router/UrlGeneratorTest.php +++ b/tests/Router/UrlGeneratorTest.php @@ -4,24 +4,23 @@ namespace Hypervel\Tests\Router; -use Hyperf\Config\Config; -use Hyperf\Context\ApplicationContext; -use Hyperf\Context\Context; -use Hyperf\Context\RequestContext; -use Hyperf\Contract\ConfigInterface; use Hyperf\Contract\ContainerInterface; use Hyperf\Contract\SessionInterface; use Hyperf\HttpMessage\Server\Request as ServerRequest; use Hyperf\HttpServer\Contract\RequestInterface; use Hyperf\HttpServer\Request; use Hyperf\HttpServer\Router\DispatcherFactory as HyperfDispatcherFactory; +use Hypervel\Config\Repository as ConfigRepository; +use Hypervel\Context\ApplicationContext; +use Hypervel\Context\Context; +use Hypervel\Context\RequestContext; use Hypervel\Router\DispatcherFactory; use Hypervel\Router\RouteCollector; use Hypervel\Router\UrlGenerator; use Hypervel\Tests\Router\Stub\UrlRoutableStub; use Hypervel\Tests\TestCase; use InvalidArgumentException; -use Mockery; +use Mockery as m; use Mockery\MockInterface; use Psr\Http\Message\ServerRequestInterface; use ReflectionMethod; @@ -62,12 +61,12 @@ public function testRoute() $this->mockRouter(); - $config = Mockery::mock(ConfigInterface::class); + $config = m::mock(ConfigRepository::class); $config->shouldReceive('get') ->with('app.url') ->andReturn('http://example.com'); $this->container->shouldReceive('get') - ->with(ConfigInterface::class) + ->with('config') ->andReturn($config); $this->router @@ -252,7 +251,7 @@ public function testNoRequestContext() { $urlGenerator = new UrlGenerator($this->container); - $this->container->shouldReceive('get')->with(ConfigInterface::class)->andReturn(new Config([ + $this->container->shouldReceive('get')->with('config')->andReturn(new ConfigRepository([ 'app' => [ 'url' => 'http://localhost', ], @@ -324,13 +323,13 @@ public function testPreviousPath() ) ); - // Mock ConfigInterface for app.url - $mockConfig = Mockery::mock(ConfigInterface::class); + // Mock config Repository for app.url + $mockConfig = m::mock(ConfigRepository::class); $mockConfig->shouldReceive('get') ->with('app.url') ->andReturn('http://example.com'); $this->container->shouldReceive('get') - ->with(ConfigInterface::class) + ->with('config') ->andReturn($mockConfig); // Test with referer header @@ -641,7 +640,7 @@ public function testUseOriginClearsCache() private function mockContainer() { /** @var ContainerInterface|MockInterface */ - $container = Mockery::mock(ContainerInterface::class); + $container = m::mock(ContainerInterface::class); $container->shouldReceive('get') ->with(RequestInterface::class) @@ -655,10 +654,10 @@ private function mockContainer() private function mockRouter(?RouteCollector $router = null) { /** @var DispatcherFactory|MockInterface */ - $factory = Mockery::mock(DispatcherFactory::class); + $factory = m::mock(DispatcherFactory::class); /** @var MockInterface|RouteCollector */ - $router = $router ?: Mockery::mock(RouteCollector::class); + $router = $router ?: m::mock(RouteCollector::class); $this->container ->shouldReceive('get') diff --git a/tests/Sanctum/ActingAsTest.php b/tests/Sanctum/ActingAsTest.php index 7496323c2..a831c2b28 100644 --- a/tests/Sanctum/ActingAsTest.php +++ b/tests/Sanctum/ActingAsTest.php @@ -4,9 +4,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; @@ -26,7 +25,7 @@ protected function setUp(): void $this->app->register(SanctumServiceProvider::class); // Configure auth guards - $this->app->get(ConfigInterface::class) + $this->app->get('config') ->set([ 'auth.guards.sanctum' => [ 'driver' => 'sanctum', diff --git a/tests/Sanctum/AuthenticateRequestsTest.php b/tests/Sanctum/AuthenticateRequestsTest.php index dc67361a5..160294d0e 100644 --- a/tests/Sanctum/AuthenticateRequestsTest.php +++ b/tests/Sanctum/AuthenticateRequestsTest.php @@ -5,7 +5,7 @@ namespace Hypervel\Tests\Sanctum; use Hypervel\Context\Context; -use Hypervel\Foundation\Contracts\Application as ApplicationContract; +use Hypervel\Contracts\Foundation\Application as ApplicationContract; use Hypervel\Foundation\Testing\Concerns\RunTestsInCoroutine; use Hypervel\Foundation\Testing\RefreshDatabase; use Hypervel\Router\Router; @@ -61,29 +61,6 @@ protected function defineEnvironment(ApplicationContract $app): void ]); } - protected function defineRoutes(Router $router): void - { - $router->get('/sanctum/api/user', function () { - $user = auth('sanctum')->user(); - - if (! $user) { - abort(401); - } - - return response()->json(['email' => $user->email]); - }); - - $router->get('/sanctum/web/user', function () { - $user = auth('sanctum')->user(); - - if (! $user) { - abort(401); - } - - return response()->json(['email' => $user->email]); - }); - } - protected function tearDown(): void { parent::tearDown(); @@ -118,6 +95,29 @@ protected function createUsersTable(): void }); } + protected function defineRoutes(Router $router): void + { + $router->get('/sanctum/api/user', function () { + $user = auth('sanctum')->user(); + + if (! $user) { + abort(401); + } + + return response()->json(['email' => $user->email]); + }); + + $router->get('/sanctum/web/user', function () { + $user = auth('sanctum')->user(); + + if (! $user) { + abort(401); + } + + return response()->json(['email' => $user->email]); + }); + } + public function testCanAuthorizeValidUserUsingAuthorizationHeader(): void { // Create a user in the database diff --git a/tests/Sanctum/CheckAbilitiesTest.php b/tests/Sanctum/CheckAbilitiesTest.php index ac85abfb2..ddf65e918 100644 --- a/tests/Sanctum/CheckAbilitiesTest.php +++ b/tests/Sanctum/CheckAbilitiesTest.php @@ -4,10 +4,10 @@ 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 Mockery as m; use PHPUnit\Framework\TestCase; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; @@ -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() @@ -62,13 +55,13 @@ public function getAuthPassword(): string } }; - $request = Mockery::mock(ServerRequestInterface::class); - $response = Mockery::mock(ResponseInterface::class); + $request = m::mock(ServerRequestInterface::class); + $response = m::mock(ResponseInterface::class); - $guard = Mockery::mock(Guard::class); + $guard = m::mock(Guard::class); $guard->shouldReceive('user')->andReturn($user); - $authFactory = Mockery::mock(AuthFactory::class); + $authFactory = m::mock(AuthFactory::class); $authFactory->shouldReceive('guard')->andReturn($guard); $middleware = new CheckAbilities($authFactory); @@ -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() @@ -118,12 +111,12 @@ public function getAuthPassword(): string } }; - $request = Mockery::mock(ServerRequestInterface::class); + $request = m::mock(ServerRequestInterface::class); - $guard = Mockery::mock(Guard::class); + $guard = m::mock(Guard::class); $guard->shouldReceive('user')->andReturn($user); - $authFactory = Mockery::mock(AuthFactory::class); + $authFactory = m::mock(AuthFactory::class); $authFactory->shouldReceive('guard')->andReturn($guard); $middleware = new CheckAbilities($authFactory); @@ -137,12 +130,12 @@ public function testExceptionIsThrownIfNoAuthenticatedUser(): void { $this->expectException(\Hypervel\Auth\AuthenticationException::class); - $request = Mockery::mock(ServerRequestInterface::class); + $request = m::mock(ServerRequestInterface::class); - $guard = Mockery::mock(Guard::class); + $guard = m::mock(Guard::class); $guard->shouldReceive('user')->once()->andReturn(null); - $authFactory = Mockery::mock(AuthFactory::class); + $authFactory = m::mock(AuthFactory::class); $authFactory->shouldReceive('guard')->andReturn($guard); $middleware = new CheckAbilities($authFactory); @@ -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; @@ -183,12 +176,12 @@ public function getAuthPassword(): string } }; - $request = Mockery::mock(ServerRequestInterface::class); + $request = m::mock(ServerRequestInterface::class); - $guard = Mockery::mock(Guard::class); + $guard = m::mock(Guard::class); $guard->shouldReceive('user')->andReturn($user); - $authFactory = Mockery::mock(AuthFactory::class); + $authFactory = m::mock(AuthFactory::class); $authFactory->shouldReceive('guard')->andReturn($guard); $middleware = new CheckAbilities($authFactory); diff --git a/tests/Sanctum/CheckForAnyAbilityTest.php b/tests/Sanctum/CheckForAnyAbilityTest.php index fb66374ac..2a13963ae 100644 --- a/tests/Sanctum/CheckForAnyAbilityTest.php +++ b/tests/Sanctum/CheckForAnyAbilityTest.php @@ -4,10 +4,10 @@ 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 Mockery as m; use PHPUnit\Framework\TestCase; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; @@ -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() @@ -66,13 +59,13 @@ public function getAuthPassword(): string } }; - $request = Mockery::mock(ServerRequestInterface::class); - $response = Mockery::mock(ResponseInterface::class); + $request = m::mock(ServerRequestInterface::class); + $response = m::mock(ResponseInterface::class); - $guard = Mockery::mock(Guard::class); + $guard = m::mock(Guard::class); $guard->shouldReceive('user')->andReturn($user); - $authFactory = Mockery::mock(AuthFactory::class); + $authFactory = m::mock(AuthFactory::class); $authFactory->shouldReceive('guard')->andReturn($guard); $middleware = new CheckForAnyAbility($authFactory); @@ -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() @@ -122,12 +115,12 @@ public function getAuthPassword(): string } }; - $request = Mockery::mock(ServerRequestInterface::class); + $request = m::mock(ServerRequestInterface::class); - $guard = Mockery::mock(Guard::class); + $guard = m::mock(Guard::class); $guard->shouldReceive('user')->andReturn($user); - $authFactory = Mockery::mock(AuthFactory::class); + $authFactory = m::mock(AuthFactory::class); $authFactory->shouldReceive('guard')->andReturn($guard); $middleware = new CheckForAnyAbility($authFactory); @@ -141,12 +134,12 @@ public function testExceptionIsThrownIfNoAuthenticatedUser(): void { $this->expectException(\Hypervel\Auth\AuthenticationException::class); - $request = Mockery::mock(ServerRequestInterface::class); + $request = m::mock(ServerRequestInterface::class); - $guard = Mockery::mock(Guard::class); + $guard = m::mock(Guard::class); $guard->shouldReceive('user')->once()->andReturn(null); - $authFactory = Mockery::mock(AuthFactory::class); + $authFactory = m::mock(AuthFactory::class); $authFactory->shouldReceive('guard')->andReturn($guard); $middleware = new CheckForAnyAbility($authFactory); @@ -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; @@ -187,12 +180,12 @@ public function getAuthPassword(): string } }; - $request = Mockery::mock(ServerRequestInterface::class); + $request = m::mock(ServerRequestInterface::class); - $guard = Mockery::mock(Guard::class); + $guard = m::mock(Guard::class); $guard->shouldReceive('user')->andReturn($user); - $authFactory = Mockery::mock(AuthFactory::class); + $authFactory = m::mock(AuthFactory::class); $authFactory->shouldReceive('guard')->andReturn($guard); $middleware = new CheckForAnyAbility($authFactory); diff --git a/tests/Sanctum/CurrentApplicationUrlWithPortTest.php b/tests/Sanctum/CurrentApplicationUrlWithPortTest.php index 030bc0e86..f78447e68 100644 --- a/tests/Sanctum/CurrentApplicationUrlWithPortTest.php +++ b/tests/Sanctum/CurrentApplicationUrlWithPortTest.php @@ -4,7 +4,6 @@ namespace Hypervel\Tests\Sanctum; -use Hyperf\Contract\ConfigInterface; use Hypervel\Sanctum\Sanctum; use Hypervel\Testbench\TestCase; @@ -16,7 +15,7 @@ class CurrentApplicationUrlWithPortTest extends TestCase { public function testCurrentApplicationUrlWithPort(): void { - $this->app->get(ConfigInterface::class)->set('app.url', 'https://www.example.com:8080'); + $this->app->get('config')->set('app.url', 'https://www.example.com:8080'); $result = Sanctum::currentApplicationUrlWithPort(); @@ -25,7 +24,7 @@ public function testCurrentApplicationUrlWithPort(): void public function testCurrentApplicationUrlWithoutPort(): void { - $this->app->get(ConfigInterface::class)->set('app.url', 'https://www.example.com'); + $this->app->get('config')->set('app.url', 'https://www.example.com'); $result = Sanctum::currentApplicationUrlWithPort(); @@ -34,7 +33,7 @@ public function testCurrentApplicationUrlWithoutPort(): void public function testCurrentApplicationUrlWhenNotSet(): void { - $this->app->get(ConfigInterface::class)->set('app.url', null); + $this->app->get('config')->set('app.url', null); $result = Sanctum::currentApplicationUrlWithPort(); diff --git a/tests/Sanctum/EnsureFrontendRequestsAreStatefulTest.php b/tests/Sanctum/EnsureFrontendRequestsAreStatefulTest.php index 19dfcab4b..2b0f3c3b6 100644 --- a/tests/Sanctum/EnsureFrontendRequestsAreStatefulTest.php +++ b/tests/Sanctum/EnsureFrontendRequestsAreStatefulTest.php @@ -4,11 +4,10 @@ namespace Hypervel\Tests\Sanctum; -use Hyperf\Contract\ConfigInterface; use Hyperf\HttpServer\Contract\RequestInterface; use Hypervel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful; use Hypervel\Testbench\TestCase; -use Mockery; +use Mockery as m; /** * @internal @@ -20,12 +19,12 @@ protected function setUp(): void { parent::setUp(); - $this->app->get(ConfigInterface::class)->set('sanctum.stateful', ['test.com', '*.test.com']); + $this->app->get('config')->set('sanctum.stateful', ['test.com', '*.test.com']); } public function testRequestFromFrontendIsIdentified(): void { - $request = Mockery::mock(RequestInterface::class); + $request = m::mock(RequestInterface::class); $request->shouldReceive('header') ->with('referer') ->andReturn('https://test.com'); @@ -38,7 +37,7 @@ public function testRequestFromFrontendIsIdentified(): void public function testRequestNotFromFrontend(): void { - $request = Mockery::mock(RequestInterface::class); + $request = m::mock(RequestInterface::class); $request->shouldReceive('header') ->with('referer') ->andReturn('https://wrong.com'); @@ -51,7 +50,7 @@ public function testRequestNotFromFrontend(): void public function testOriginFallback(): void { - $request = Mockery::mock(RequestInterface::class); + $request = m::mock(RequestInterface::class); $request->shouldReceive('header') ->with('referer') ->andReturn(null); @@ -64,7 +63,7 @@ public function testOriginFallback(): void public function testWildcardDomainMatching(): void { - $request = Mockery::mock(RequestInterface::class); + $request = m::mock(RequestInterface::class); $request->shouldReceive('header') ->with('referer') ->andReturn('https://subdomain.test.com'); @@ -77,7 +76,7 @@ public function testWildcardDomainMatching(): void public function testRequestsWithoutRefererOrOrigin(): void { - $request = Mockery::mock(RequestInterface::class); + $request = m::mock(RequestInterface::class); $request->shouldReceive('header') ->with('referer') ->andReturn(null); @@ -99,7 +98,7 @@ public function testStatefulDomainsReturnsConfiguredDomains(): void public function testStatefulDomainsCanBeOverridden(): void { - $request = Mockery::mock(RequestInterface::class); + $request = m::mock(RequestInterface::class); $request->shouldReceive('header') ->with('referer') ->andReturn('https://custom.example.com'); diff --git a/tests/Sanctum/FrontendRequestsAreStatefulTest.php b/tests/Sanctum/FrontendRequestsAreStatefulTest.php index 9cd1c295e..6951324ce 100644 --- a/tests/Sanctum/FrontendRequestsAreStatefulTest.php +++ b/tests/Sanctum/FrontendRequestsAreStatefulTest.php @@ -4,8 +4,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 +15,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,11 +28,22 @@ 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(); - $this->app->get(ConfigInterface::class)->set([ + $this->app->get('config')->set([ 'auth.guards.sanctum.driver' => 'sanctum', 'auth.guards.sanctum.provider' => 'users', 'auth.providers.users.model' => User::class, @@ -144,9 +153,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..d4a48f079 100644 --- a/tests/Sanctum/GuardTest.php +++ b/tests/Sanctum/GuardTest.php @@ -4,9 +4,8 @@ namespace Hypervel\Tests\Sanctum; -use Hyperf\Context\Context; -use Hyperf\Contract\ConfigInterface; use Hypervel\Auth\AuthManager; +use Hypervel\Context\Context; use Hypervel\Foundation\Testing\Concerns\RunTestsInCoroutine; use Hypervel\Foundation\Testing\RefreshDatabase; use Hypervel\Sanctum\Events\TokenAuthenticated; @@ -16,7 +15,7 @@ use Hypervel\Support\Facades\Route; use Hypervel\Testbench\TestCase; use Hypervel\Tests\Sanctum\Stub\TestUser; -use Mockery; +use Mockery as m; use Psr\EventDispatcher\EventDispatcherInterface; /** @@ -36,7 +35,7 @@ protected function setUp(): void $this->app->register(SanctumServiceProvider::class); - $this->app->get(ConfigInterface::class) + $this->app->get('config') ->set([ 'auth.guards.sanctum' => [ 'driver' => 'sanctum', @@ -48,7 +47,6 @@ protected function setUp(): void ], 'auth.providers.users.model' => TestUser::class, 'auth.providers.users.driver' => 'eloquent', - 'database.default' => 'testing', 'sanctum.guard' => ['web'], ]); @@ -63,7 +61,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; } @@ -283,7 +280,7 @@ public function testTokenAuthenticationDispatchesEvent(): void $realDispatcher = $this->app->get(EventDispatcherInterface::class); // Create a partial mock that delegates to the real dispatcher - $events = Mockery::mock($realDispatcher); + $events = m::mock($realDispatcher); $events->makePartial(); // This makes it a partial mock // Only spy on dispatch calls, don't change behavior diff --git a/tests/Sanctum/PruneExpiredTest.php b/tests/Sanctum/PruneExpiredTest.php index ab0a855c3..d19242646 100644 --- a/tests/Sanctum/PruneExpiredTest.php +++ b/tests/Sanctum/PruneExpiredTest.php @@ -4,7 +4,6 @@ namespace Hypervel\Tests\Sanctum; -use Hyperf\Contract\ConfigInterface; use Hypervel\Foundation\Testing\RefreshDatabase; use Hypervel\Sanctum\PersonalAccessToken; use Hypervel\Support\Carbon; @@ -32,7 +31,7 @@ protected function migrateFreshUsing(): array public function testCanDeleteExpiredTokensWithIntegerExpiration(): void { - $this->app->get(ConfigInterface::class) + $this->app->get('config') ->set(['sanctum.expiration' => 60]); // Create tokens with different expiration times @@ -67,7 +66,7 @@ public function testCanDeleteExpiredTokensWithIntegerExpiration(): void $model = PersonalAccessToken::class; $model::where('expires_at', '<', now()->subHours($hours))->delete(); - $expiration = $this->app->get(ConfigInterface::class) + $expiration = $this->app->get('config') ->get('sanctum.expiration'); if ($expiration) { $model::where('created_at', '<', now()->subMinutes($expiration + ($hours * 60)))->delete(); @@ -80,7 +79,7 @@ public function testCanDeleteExpiredTokensWithIntegerExpiration(): void public function testCantDeleteExpiredTokensWithNullExpiration(): void { - $this->app->get(ConfigInterface::class) + $this->app->get('config') ->set(['sanctum.expiration' => null]); PersonalAccessToken::forceCreate([ @@ -99,7 +98,7 @@ public function testCantDeleteExpiredTokensWithNullExpiration(): void $model::where('expires_at', '<', now()->subHours($hours))->delete(); // With null expiration, no config-based deletion happens - $expiration = $this->app->get(ConfigInterface::class) + $expiration = $this->app->get('config') ->get('sanctum.expiration'); $this->assertNull($expiration); @@ -108,7 +107,7 @@ public function testCantDeleteExpiredTokensWithNullExpiration(): void public function testCanDeleteExpiredTokensWithExpiresAtExpiration(): void { - $this->app->get(ConfigInterface::class) + $this->app->get('config') ->set(['sanctum.expiration' => 60]); PersonalAccessToken::forceCreate([ @@ -142,7 +141,7 @@ public function testCanDeleteExpiredTokensWithExpiresAtExpiration(): void $model = PersonalAccessToken::class; $model::where('expires_at', '<', now()->subHours($hours))->delete(); - $expiration = $this->app->get(ConfigInterface::class) + $expiration = $this->app->get('config') ->get('sanctum.expiration'); if ($expiration) { $model::where('created_at', '<', now()->subMinutes($expiration + ($hours * 60)))->delete(); diff --git a/tests/Sanctum/SimpleGuardTest.php b/tests/Sanctum/SimpleGuardTest.php index cd96026b3..f0fb58328 100644 --- a/tests/Sanctum/SimpleGuardTest.php +++ b/tests/Sanctum/SimpleGuardTest.php @@ -4,7 +4,6 @@ namespace Hypervel\Tests\Sanctum; -use Hyperf\Contract\ConfigInterface; use Hypervel\Foundation\Testing\Concerns\RunTestsInCoroutine; use Hypervel\Foundation\Testing\RefreshDatabase; use Hypervel\Sanctum\SanctumServiceProvider; @@ -29,7 +28,7 @@ protected function setUp(): void $this->app->register(SanctumServiceProvider::class); - $this->app->get(ConfigInterface::class) + $this->app->get('config') ->set([ 'auth.guards.sanctum' => [ 'driver' => 'sanctum', @@ -37,7 +36,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/Scout/Feature/DatabaseEngineTest.php b/tests/Scout/Feature/DatabaseEngineTest.php index 2e25c828d..27c47ab5e 100644 --- a/tests/Scout/Feature/DatabaseEngineTest.php +++ b/tests/Scout/Feature/DatabaseEngineTest.php @@ -4,7 +4,6 @@ namespace Hypervel\Tests\Scout\Feature; -use Hyperf\Contract\ConfigInterface; use Hypervel\Scout\Engines\DatabaseEngine; use Hypervel\Tests\Scout\Models\PrefixSearchableModel; use Hypervel\Tests\Scout\Models\SearchableModel; @@ -21,7 +20,7 @@ protected function setUp(): void parent::setUp(); // Set driver to database for these tests - $this->app->get(ConfigInterface::class)->set('scout.driver', 'database'); + $this->app->get('config')->set('scout.driver', 'database'); } public function testSearchReturnsMatchingModels(): void diff --git a/tests/Scout/Feature/SearchableModelTest.php b/tests/Scout/Feature/SearchableModelTest.php index 8c927cb3d..5622e42e0 100644 --- a/tests/Scout/Feature/SearchableModelTest.php +++ b/tests/Scout/Feature/SearchableModelTest.php @@ -33,7 +33,7 @@ public function testSearchableAsReturnsTableName() public function testSearchableAsReturnsTableNameWithPrefix() { // Set a prefix in the config - $this->app->get(\Hyperf\Contract\ConfigInterface::class) + $this->app->get('config') ->set('scout.prefix', 'test_'); $model = new SearchableModel(); @@ -163,7 +163,7 @@ public function testModelCanBeSearched() public function testSoftDeletedModelsAreExcludedByDefault() { // Set soft delete config - $this->app->get(\Hyperf\Contract\ConfigInterface::class) + $this->app->get('config') ->set('scout.soft_delete', true); $model = SoftDeletableSearchableModel::create([ @@ -183,7 +183,7 @@ public function testSoftDeletedModelsAreExcludedByDefault() public function testSoftDeletedModelsCanBeIncludedWithWithTrashed() { // Set soft delete config - $this->app->get(\Hyperf\Contract\ConfigInterface::class) + $this->app->get('config') ->set('scout.soft_delete', true); $model = SoftDeletableSearchableModel::create([ diff --git a/tests/Scout/Feature/SearchableScopeTest.php b/tests/Scout/Feature/SearchableScopeTest.php index ef41969bd..2469f890f 100644 --- a/tests/Scout/Feature/SearchableScopeTest.php +++ b/tests/Scout/Feature/SearchableScopeTest.php @@ -4,7 +4,6 @@ namespace Hypervel\Tests\Scout\Feature; -use Hyperf\Contract\ConfigInterface; use Hypervel\Scout\Events\ModelsFlushed; use Hypervel\Scout\Events\ModelsImported; use Hypervel\Support\Facades\Event; @@ -25,7 +24,7 @@ protected function setUp(): void parent::setUp(); // Use collection driver to avoid external service calls - $this->app->get(ConfigInterface::class)->set('scout.driver', 'collection'); + $this->app->get('config')->set('scout.driver', 'collection'); } public function testSearchableMacroDispatchesModelsImportedEvent(): void diff --git a/tests/Scout/ScoutTestCase.php b/tests/Scout/ScoutTestCase.php index bb1a8ad14..392624622 100644 --- a/tests/Scout/ScoutTestCase.php +++ b/tests/Scout/ScoutTestCase.php @@ -4,8 +4,6 @@ namespace Hypervel\Tests\Scout; -use Hyperf\Contract\ConfigInterface; -use Hypervel\Foundation\Contracts\Application as ApplicationContract; use Hypervel\Foundation\Testing\Concerns\RunTestsInCoroutine; use Hypervel\Foundation\Testing\RefreshDatabase; use Hypervel\Scout\ScoutServiceProvider; @@ -24,15 +22,13 @@ class ScoutTestCase extends TestCase protected bool $migrateRefresh = true; - protected ?ApplicationContract $app = null; - protected function setUp(): void { parent::setUp(); $this->app->register(ScoutServiceProvider::class); - $this->app->get(ConfigInterface::class) + $this->app->get('config') ->set('scout', [ 'driver' => 'collection', 'prefix' => '', diff --git a/tests/Scout/Unit/BuilderTest.php b/tests/Scout/Unit/BuilderTest.php index 1f3e4d111..ad1e5be89 100644 --- a/tests/Scout/Unit/BuilderTest.php +++ b/tests/Scout/Unit/BuilderTest.php @@ -4,10 +4,10 @@ namespace Hypervel\Tests\Scout\Unit; -use Hyperf\Paginator\LengthAwarePaginator; -use Hyperf\Paginator\Paginator; use Hypervel\Database\Eloquent\Collection as EloquentCollection; use Hypervel\Database\Eloquent\Model; +use Hypervel\Pagination\LengthAwarePaginator; +use Hypervel\Pagination\Paginator; use Hypervel\Scout\Builder; use Hypervel\Scout\Contracts\PaginatesEloquentModels; use Hypervel\Scout\Contracts\PaginatesEloquentModelsUsingDatabase; diff --git a/tests/Scout/Unit/ConfigTest.php b/tests/Scout/Unit/ConfigTest.php index 330333765..a8620c4d6 100644 --- a/tests/Scout/Unit/ConfigTest.php +++ b/tests/Scout/Unit/ConfigTest.php @@ -4,7 +4,6 @@ namespace Hypervel\Tests\Scout\Unit; -use Hyperf\Contract\ConfigInterface; use Hypervel\Coroutine\WaitConcurrent; use Hypervel\Database\Eloquent\Collection; use Hypervel\Scout\Events\ModelsFlushed; @@ -37,7 +36,7 @@ public function testCommandConcurrencyConfigIsUsed(): void $this->resetScoutRunner(); // Set a specific concurrency value - $this->app->get(ConfigInterface::class)->set('scout.command_concurrency', 25); + $this->app->get('config')->set('scout.command_concurrency', 25); // Define SCOUT_COMMAND to trigger the command path if (! defined('SCOUT_COMMAND')) { @@ -70,7 +69,7 @@ public function testCommandConcurrencyConfigIsUsed(): void public function testChunkSearchableConfigAffectsImportEvents(): void { // Set a small chunk size to verify multiple events are fired - $this->app->get(ConfigInterface::class)->set('scout.chunk.searchable', 2); + $this->app->get('config')->set('scout.chunk.searchable', 2); // Create 5 models for ($i = 1; $i <= 5; ++$i) { @@ -95,7 +94,7 @@ public function testChunkSearchableConfigAffectsImportEvents(): void public function testChunkUnsearchableConfigAffectsFlushEvents(): void { // Set a small chunk size - $this->app->get(ConfigInterface::class)->set('scout.chunk.unsearchable', 2); + $this->app->get('config')->set('scout.chunk.unsearchable', 2); // Create 5 models for ($i = 1; $i <= 5; ++$i) { diff --git a/tests/Scout/Unit/Console/ImportCommandTest.php b/tests/Scout/Unit/Console/ImportCommandTest.php index e73504468..d95555173 100644 --- a/tests/Scout/Unit/Console/ImportCommandTest.php +++ b/tests/Scout/Unit/Console/ImportCommandTest.php @@ -4,7 +4,7 @@ namespace Hypervel\Tests\Scout\Unit\Console; -use Hypervel\Event\Contracts\Dispatcher; +use Hypervel\Contracts\Event\Dispatcher; use Hypervel\Scout\Console\ImportCommand; use Hypervel\Scout\Exceptions\ScoutException; use Hypervel\Tests\TestCase; diff --git a/tests/Scout/Unit/Console/SyncIndexSettingsCommandTest.php b/tests/Scout/Unit/Console/SyncIndexSettingsCommandTest.php index f62fef6dc..ebd8ecae5 100644 --- a/tests/Scout/Unit/Console/SyncIndexSettingsCommandTest.php +++ b/tests/Scout/Unit/Console/SyncIndexSettingsCommandTest.php @@ -4,7 +4,7 @@ namespace Hypervel\Tests\Scout\Unit\Console; -use Hyperf\Contract\ConfigInterface; +use Hypervel\Config\Repository; use Hypervel\Scout\Console\SyncIndexSettingsCommand; use Hypervel\Scout\Contracts\UpdatesIndexSettings; use Hypervel\Scout\Engine; @@ -30,7 +30,7 @@ public function testFailsWhenEngineDoesNotSupportUpdatingIndexSettings(): void ->once() ->andReturn($engine); - $config = m::mock(ConfigInterface::class); + $config = m::mock(Repository::class); $config->shouldReceive('get') ->with('scout.driver') ->andReturn('collection'); @@ -58,7 +58,7 @@ public function testSucceedsWithInfoMessageWhenNoIndexSettingsConfigured(): void ->once() ->andReturn($engine); - $config = m::mock(ConfigInterface::class); + $config = m::mock(Repository::class); $config->shouldReceive('get') ->with('scout.driver') ->andReturn('meilisearch'); @@ -92,7 +92,7 @@ public function testSyncsIndexSettingsSuccessfully(): void ->once() ->andReturn($engine); - $config = m::mock(ConfigInterface::class); + $config = m::mock(Repository::class); $config->shouldReceive('get') ->with('scout.driver') ->andReturn('meilisearch'); @@ -128,7 +128,7 @@ public function testUsesDriverOptionWhenProvided(): void ->once() ->andReturn($engine); - $config = m::mock(ConfigInterface::class); + $config = m::mock(Repository::class); // Note: scout.driver should NOT be called when driver option is provided $config->shouldReceive('get') ->with('scout.typesense.index-settings', []) @@ -154,7 +154,7 @@ public function testIndexNameResolutionPrependsPrefix(): void $method = new ReflectionMethod(SyncIndexSettingsCommand::class, 'indexName'); $method->setAccessible(true); - $config = m::mock(ConfigInterface::class); + $config = m::mock(Repository::class); $config->shouldReceive('get') ->with('scout.prefix', '') ->andReturn('prod_'); @@ -171,7 +171,7 @@ public function testIndexNameResolutionDoesNotDuplicatePrefix(): void $method = new ReflectionMethod(SyncIndexSettingsCommand::class, 'indexName'); $method->setAccessible(true); - $config = m::mock(ConfigInterface::class); + $config = m::mock(Repository::class); $config->shouldReceive('get') ->with('scout.prefix', '') ->andReturn('prod_'); diff --git a/tests/Scout/Unit/EngineManagerTest.php b/tests/Scout/Unit/EngineManagerTest.php index d3845bdd9..11aa072ff 100644 --- a/tests/Scout/Unit/EngineManagerTest.php +++ b/tests/Scout/Unit/EngineManagerTest.php @@ -4,7 +4,7 @@ namespace Hypervel\Tests\Scout\Unit; -use Hyperf\Contract\ConfigInterface; +use Hypervel\Config\Repository; use Hypervel\Scout\Engine; use Hypervel\Scout\EngineManager; use Hypervel\Scout\Engines\CollectionEngine; @@ -278,7 +278,7 @@ protected function createMockContainer(array $config): m\MockInterface&Container { $container = m::mock(ContainerInterface::class); - $configService = m::mock(ConfigInterface::class); + $configService = m::mock(Repository::class); $configService->shouldReceive('get') ->with('scout.driver', m::any()) ->andReturn($config['driver'] ?? null); @@ -287,7 +287,7 @@ protected function createMockContainer(array $config): m\MockInterface&Container ->andReturn($config['soft_delete'] ?? false); $container->shouldReceive('get') - ->with(ConfigInterface::class) + ->with('config') ->andReturn($configService); return $container; @@ -297,7 +297,7 @@ protected function createMockContainerWithTypesense(array $config): m\MockInterf { $container = m::mock(ContainerInterface::class); - $configService = m::mock(ConfigInterface::class); + $configService = m::mock(Repository::class); $configService->shouldReceive('get') ->with('scout.driver', m::any()) ->andReturn($config['driver'] ?? null); @@ -309,7 +309,7 @@ protected function createMockContainerWithTypesense(array $config): m\MockInterf ->andReturn($config['max_total_results'] ?? 1000); $container->shouldReceive('get') - ->with(ConfigInterface::class) + ->with('config') ->andReturn($configService); return $container; diff --git a/tests/Scout/Unit/Engines/MeilisearchEngineTest.php b/tests/Scout/Unit/Engines/MeilisearchEngineTest.php index ead5d268d..7a7a1ddc6 100644 --- a/tests/Scout/Unit/Engines/MeilisearchEngineTest.php +++ b/tests/Scout/Unit/Engines/MeilisearchEngineTest.php @@ -260,6 +260,8 @@ public function testMapCorrectlyMapsResultsToModels() $model = m::mock(Model::class . ', ' . SearchableInterface::class); $model->shouldReceive('getScoutKeyName')->andReturn('id'); $model->shouldReceive('getScoutModelsByIds')->andReturn(new EloquentCollection([$searchableModel])); + $model->shouldReceive('newCollection') + ->andReturnUsing(fn ($models) => new EloquentCollection($models)); $builder = m::mock(Builder::class); @@ -306,6 +308,8 @@ public function testMapRespectsOrder() $model = m::mock(Model::class . ', ' . SearchableInterface::class); $model->shouldReceive('getScoutKeyName')->andReturn('id'); $model->shouldReceive('getScoutModelsByIds')->andReturn($models); + $model->shouldReceive('newCollection') + ->andReturnUsing(fn ($models) => new EloquentCollection($models)); $builder = m::mock(Builder::class); diff --git a/tests/Scout/Unit/Engines/TypesenseEngineTest.php b/tests/Scout/Unit/Engines/TypesenseEngineTest.php index a7dd246cf..9d4d858ea 100644 --- a/tests/Scout/Unit/Engines/TypesenseEngineTest.php +++ b/tests/Scout/Unit/Engines/TypesenseEngineTest.php @@ -11,7 +11,7 @@ use Hypervel\Scout\Engines\TypesenseEngine; use Hypervel\Scout\Exceptions\NotSupportedException; use Hypervel\Tests\TestCase; -use Mockery; +use Mockery as m; use Mockery\MockInterface; use ReflectionMethod; use Typesense\Client as TypesenseClient; @@ -27,15 +27,9 @@ */ class TypesenseEngineTest extends TestCase { - protected function tearDown(): void - { - Mockery::close(); - parent::tearDown(); - } - protected function createEngine(?MockInterface $client = null): TypesenseEngine { - $client = $client ?? Mockery::mock(TypesenseClient::class); + $client = $client ?? m::mock(TypesenseClient::class); return new TypesenseEngine($client, 1000); } @@ -45,17 +39,17 @@ protected function createEngine(?MockInterface $client = null): TypesenseEngine */ protected function createPartialEngine(?MockInterface $client = null): MockInterface&TypesenseEngine { - $client = $client ?? Mockery::mock(TypesenseClient::class); + $client = $client ?? m::mock(TypesenseClient::class); /** @var MockInterface&TypesenseEngine */ - return Mockery::mock(TypesenseEngine::class, [$client, 1000]) + return m::mock(TypesenseEngine::class, [$client, 1000]) ->shouldAllowMockingProtectedMethods() ->makePartial(); } protected function createSearchableModelMock(): MockInterface { - return Mockery::mock(Model::class . ', ' . SearchableInterface::class); + return m::mock(Model::class . ', ' . SearchableInterface::class); } protected function invokeMethod(object $object, string $methodName, array $parameters = []): mixed @@ -69,7 +63,7 @@ public function testFiltersMethod(): void { $engine = $this->createEngine(); - $builder = Mockery::mock(Builder::class); + $builder = m::mock(Builder::class); $builder->wheres = [ 'status' => 'active', 'age' => 25, @@ -193,7 +187,7 @@ public function testCreateIndexThrowsNotSupportedException(): void public function testUpdateWithEmptyCollectionDoesNothing(): void { - $client = Mockery::mock(TypesenseClient::class); + $client = m::mock(TypesenseClient::class); $client->shouldNotReceive('getCollections'); $engine = $this->createEngine($client); @@ -209,17 +203,17 @@ public function testDeleteRemovesDocumentsFromIndex(): void $model->shouldReceive('getScoutKey')->andReturn(123); // Mock the Document object that's returned by array access on Documents - $document = Mockery::mock(Document::class); + $document = m::mock(Document::class); $document->shouldReceive('retrieve')->once()->andReturn([]); $document->shouldReceive('delete')->once()->andReturn([]); // Documents already implements ArrayAccess - $documents = Mockery::mock(Documents::class); + $documents = m::mock(Documents::class); $documents->shouldReceive('offsetGet') ->with('123') ->andReturn($document); - $collection = Mockery::mock(TypesenseCollection::class); + $collection = m::mock(TypesenseCollection::class); $collection->shouldReceive('getDocuments')->andReturn($documents); $engine = $this->createPartialEngine(); @@ -233,7 +227,7 @@ public function testDeleteRemovesDocumentsFromIndex(): void public function testDeleteWithEmptyCollectionDoesNothing(): void { - $client = Mockery::mock(TypesenseClient::class); + $client = m::mock(TypesenseClient::class); $client->shouldNotReceive('getCollections'); $engine = $this->createEngine($client); @@ -249,16 +243,16 @@ public function testDeleteDocumentReturnsEmptyArrayWhenDocumentNotFound(): void $model->shouldReceive('getScoutKey')->andReturn(123); // Mock the Document object to throw ObjectNotFound on retrieve - $document = Mockery::mock(Document::class); + $document = m::mock(Document::class); $document->shouldReceive('retrieve')->once()->andThrow(new ObjectNotFound('Document not found')); $document->shouldNotReceive('delete'); - $documents = Mockery::mock(Documents::class); + $documents = m::mock(Documents::class); $documents->shouldReceive('offsetGet') ->with('123') ->andReturn($document); - $collection = Mockery::mock(TypesenseCollection::class); + $collection = m::mock(TypesenseCollection::class); $collection->shouldReceive('getDocuments')->andReturn($documents); $engine = $this->createPartialEngine(); @@ -279,15 +273,15 @@ public function testDeleteDocumentThrowsOnNonNotFoundErrors(): void $model->shouldReceive('getScoutKey')->andReturn(123); // Mock the Document object to throw TypesenseClientError (network/auth error) - $document = Mockery::mock(Document::class); + $document = m::mock(Document::class); $document->shouldReceive('retrieve')->once()->andThrow(new TypesenseClientError('Connection failed')); - $documents = Mockery::mock(Documents::class); + $documents = m::mock(Documents::class); $documents->shouldReceive('offsetGet') ->with('123') ->andReturn($document); - $collection = Mockery::mock(TypesenseCollection::class); + $collection = m::mock(TypesenseCollection::class); $collection->shouldReceive('getDocuments')->andReturn($documents); $engine = $this->createPartialEngine(); @@ -306,7 +300,7 @@ public function testFlushDeletesCollection(): void { $model = $this->createSearchableModelMock(); - $collection = Mockery::mock(TypesenseCollection::class); + $collection = m::mock(TypesenseCollection::class); $collection->shouldReceive('delete')->once(); $engine = $this->createPartialEngine(); @@ -320,7 +314,7 @@ public function testFlushDeletesCollection(): void public function testDeleteIndexCallsTypesenseDelete(): void { - $collection = Mockery::mock(TypesenseCollection::class); + $collection = m::mock(TypesenseCollection::class); $collection->shouldReceive('delete') ->once() ->andReturn(['name' => 'test_index']); @@ -341,7 +335,7 @@ public function __get($name) } }; - $client = Mockery::mock(TypesenseClient::class); + $client = m::mock(TypesenseClient::class); $client->shouldReceive('getCollections')->andReturn($collections); $engine = $this->createEngine($client); @@ -353,7 +347,7 @@ public function __get($name) public function testGetTypesenseClientReturnsClient(): void { - $client = Mockery::mock(TypesenseClient::class); + $client = m::mock(TypesenseClient::class); $engine = $this->createEngine($client); $this->assertSame($client, $engine->getTypesenseClient()); @@ -366,7 +360,7 @@ public function testMapReturnsEmptyCollectionWhenNoResults(): void $model = $this->createSearchableModelMock(); $model->shouldReceive('newCollection')->andReturn(new EloquentCollection()); - $builder = Mockery::mock(Builder::class); + $builder = m::mock(Builder::class); $results = ['found' => 0, 'hits' => []]; $mapped = $engine->map($builder, $results, $model); @@ -381,7 +375,7 @@ public function testLazyMapReturnsLazyCollectionWhenNoResults(): void $model = $this->createSearchableModelMock(); $model->shouldReceive('newCollection')->andReturn(new EloquentCollection()); - $builder = Mockery::mock(Builder::class); + $builder = m::mock(Builder::class); $results = ['found' => 0, 'hits' => []]; $lazyMapped = $engine->lazyMap($builder, $results, $model); @@ -395,7 +389,7 @@ public function testBuildSearchParametersIncludesBasicParameters(): void $model = $this->createSearchableModelMock(); - $builder = Mockery::mock(Builder::class); + $builder = m::mock(Builder::class); $builder->model = $model; $builder->query = 'search term'; $builder->wheres = []; @@ -421,7 +415,7 @@ public function testBuildSearchParametersIncludesFilters(): void $model = $this->createSearchableModelMock(); - $builder = Mockery::mock(Builder::class); + $builder = m::mock(Builder::class); $builder->model = $model; $builder->query = 'test'; $builder->wheres = ['status' => 'active']; @@ -443,7 +437,7 @@ public function testBuildSearchParametersMergesBuilderOptions(): void $model = $this->createSearchableModelMock(); - $builder = Mockery::mock(Builder::class); + $builder = m::mock(Builder::class); $builder->model = $model; $builder->query = 'test'; $builder->wheres = []; @@ -467,7 +461,7 @@ public function testBuildSearchParametersIncludesSortBy(): void $model = $this->createSearchableModelMock(); - $builder = Mockery::mock(Builder::class); + $builder = m::mock(Builder::class); $builder->model = $model; $builder->query = 'test'; $builder->wheres = []; @@ -490,7 +484,7 @@ public function testBuildSearchParametersAppendsToExistingSortBy(): void $model = $this->createSearchableModelMock(); - $builder = Mockery::mock(Builder::class); + $builder = m::mock(Builder::class); $builder->model = $model; $builder->query = 'test'; $builder->wheres = []; @@ -514,7 +508,7 @@ public function testBuildSearchParametersWithDifferentPageAndPerPage(): void $model = $this->createSearchableModelMock(); - $builder = Mockery::mock(Builder::class); + $builder = m::mock(Builder::class); $builder->model = $model; $builder->query = 'query'; $builder->wheres = []; @@ -535,7 +529,7 @@ public function testBuildSearchParametersWithEmptyQuery(): void $model = $this->createSearchableModelMock(); - $builder = Mockery::mock(Builder::class); + $builder = m::mock(Builder::class); $builder->model = $model; $builder->query = ''; $builder->wheres = []; @@ -555,10 +549,10 @@ public function testBuildSearchParametersWithEmptyQuery(): void */ protected function createPartialEngineWithConfig(?MockInterface $client = null): MockInterface&TypesenseEngine { - $client = $client ?? Mockery::mock(TypesenseClient::class); + $client = $client ?? m::mock(TypesenseClient::class); /** @var MockInterface&TypesenseEngine */ - $engine = Mockery::mock(TypesenseEngine::class, [$client, 1000]) + $engine = m::mock(TypesenseEngine::class, [$client, 1000]) ->shouldAllowMockingProtectedMethods() ->makePartial(); diff --git a/tests/Scout/Unit/Jobs/RemoveFromSearchTest.php b/tests/Scout/Unit/Jobs/RemoveFromSearchTest.php index b97ac8c9b..b48274b53 100644 --- a/tests/Scout/Unit/Jobs/RemoveFromSearchTest.php +++ b/tests/Scout/Unit/Jobs/RemoveFromSearchTest.php @@ -9,7 +9,7 @@ use Hypervel\Scout\Jobs\RemoveFromSearch; use Hypervel\Tests\Scout\Models\SearchableModel; use Hypervel\Tests\Scout\ScoutTestCase; -use Mockery; +use Mockery as m; /** * Tests for RemoveFromSearch job. @@ -19,12 +19,6 @@ */ class RemoveFromSearchTest extends ScoutTestCase { - protected function tearDown(): void - { - Mockery::close(); - parent::tearDown(); - } - public function testHandleCallsEngineDelete(): void { $model1 = new SearchableModel(['title' => 'First', 'body' => 'Content']); @@ -35,10 +29,10 @@ public function testHandleCallsEngineDelete(): void $collection = new Collection([$model1, $model2]); - $engine = Mockery::mock(\Hypervel\Scout\Engine::class); + $engine = m::mock(\Hypervel\Scout\Engine::class); $engine->shouldReceive('delete') ->once() - ->with(Mockery::on(function ($models) { + ->with(m::on(function ($models) { return $models instanceof RemoveableScoutCollection && $models->count() === 2; })); @@ -62,7 +56,7 @@ public function testHandleDoesNothingForEmptyCollection(): void { $collection = new Collection([]); - $engine = Mockery::mock(\Hypervel\Scout\Engine::class); + $engine = m::mock(\Hypervel\Scout\Engine::class); $engine->shouldNotReceive('delete'); $this->app->instance(\Hypervel\Scout\EngineManager::class, new class($engine) { diff --git a/tests/Scout/Unit/MakeSearchableUsingTest.php b/tests/Scout/Unit/MakeSearchableUsingTest.php index 84fa91939..ad97ae3f5 100644 --- a/tests/Scout/Unit/MakeSearchableUsingTest.php +++ b/tests/Scout/Unit/MakeSearchableUsingTest.php @@ -9,7 +9,7 @@ use Hypervel\Tests\Scout\Models\FilteringSearchableModel; use Hypervel\Tests\Scout\Models\SearchableModel; use Hypervel\Tests\Scout\ScoutTestCase; -use Mockery; +use Mockery as m; /** * Tests for makeSearchableUsing() behavior. @@ -19,12 +19,6 @@ */ class MakeSearchableUsingTest extends ScoutTestCase { - protected function tearDown(): void - { - Mockery::close(); - parent::tearDown(); - } - public function testSyncMakeSearchablePassesFilteredCollectionToEngine(): void { // Create models - one published, one draft @@ -37,10 +31,10 @@ public function testSyncMakeSearchablePassesFilteredCollectionToEngine(): void $collection = new Collection([$published, $draft]); // Mock the engine to verify what gets passed to update() - $engine = Mockery::mock(\Hypervel\Scout\Engine::class); + $engine = m::mock(\Hypervel\Scout\Engine::class); $engine->shouldReceive('update') ->once() - ->with(Mockery::on(function ($models) { + ->with(m::on(function ($models) { // Should only contain the published model, not the draft return $models->count() === 1 && $models->first()->id === 1 @@ -74,7 +68,7 @@ public function testSyncMakeSearchableHandlesEmptyFilteredCollection(): void $collection = new Collection([$draft1, $draft2]); // Mock the engine - update should NOT be called - $engine = Mockery::mock(\Hypervel\Scout\Engine::class); + $engine = m::mock(\Hypervel\Scout\Engine::class); $engine->shouldNotReceive('update'); $this->app->instance(\Hypervel\Scout\EngineManager::class, new class($engine) { @@ -107,10 +101,10 @@ public function testMakeSearchableJobPassesFilteredCollectionToEngine(): void $collection = new Collection([$published, $draft]); // Mock the engine to verify what gets passed to update() - $engine = Mockery::mock(\Hypervel\Scout\Engine::class); + $engine = m::mock(\Hypervel\Scout\Engine::class); $engine->shouldReceive('update') ->once() - ->with(Mockery::on(function ($models) { + ->with(m::on(function ($models) { // Should only contain the published model, not the draft return $models->count() === 1 && $models->first()->id === 1 @@ -144,7 +138,7 @@ public function testMakeSearchableJobHandlesEmptyFilteredCollection(): void $collection = new Collection([$draft1, $draft2]); // Mock the engine - update should NOT be called - $engine = Mockery::mock(\Hypervel\Scout\Engine::class); + $engine = m::mock(\Hypervel\Scout\Engine::class); $engine->shouldNotReceive('update'); $this->app->instance(\Hypervel\Scout\EngineManager::class, new class($engine) { @@ -179,10 +173,10 @@ public function testMakeSearchableUsingDefaultBehaviorPassesThroughUnchanged(): $collection = new Collection([$model1, $model2]); // Mock the engine to verify all models are passed - $engine = Mockery::mock(\Hypervel\Scout\Engine::class); + $engine = m::mock(\Hypervel\Scout\Engine::class); $engine->shouldReceive('update') ->once() - ->with(Mockery::on(function ($models) { + ->with(m::on(function ($models) { return $models->count() === 2; })); diff --git a/tests/Scout/Unit/QueueDispatchTest.php b/tests/Scout/Unit/QueueDispatchTest.php index d220aa9b3..48782c9d1 100644 --- a/tests/Scout/Unit/QueueDispatchTest.php +++ b/tests/Scout/Unit/QueueDispatchTest.php @@ -4,7 +4,6 @@ namespace Hypervel\Tests\Scout\Unit; -use Hyperf\Contract\ConfigInterface; use Hypervel\Database\Eloquent\Collection; use Hypervel\Scout\Jobs\MakeSearchable; use Hypervel\Scout\Jobs\RemoveFromSearch; @@ -29,8 +28,8 @@ protected function tearDown(): void public function testQueueMakeSearchableDispatchesJobWhenQueueEnabled(): void { - $this->app->get(ConfigInterface::class)->set('scout.queue.enabled', true); - $this->app->get(ConfigInterface::class)->set('scout.queue.after_commit', false); + $this->app->get('config')->set('scout.queue.enabled', true); + $this->app->get('config')->set('scout.queue.after_commit', false); Bus::fake([MakeSearchable::class]); @@ -47,8 +46,8 @@ public function testQueueMakeSearchableDispatchesJobWhenQueueEnabled(): void public function testQueueMakeSearchableDispatchesWithAfterCommitWhenEnabled(): void { - $this->app->get(ConfigInterface::class)->set('scout.queue.enabled', true); - $this->app->get(ConfigInterface::class)->set('scout.queue.after_commit', true); + $this->app->get('config')->set('scout.queue.enabled', true); + $this->app->get('config')->set('scout.queue.after_commit', true); Bus::fake([MakeSearchable::class]); @@ -64,8 +63,8 @@ public function testQueueMakeSearchableDispatchesWithAfterCommitWhenEnabled(): v public function testQueueRemoveFromSearchDispatchesJobWhenQueueEnabled(): void { - $this->app->get(ConfigInterface::class)->set('scout.queue.enabled', true); - $this->app->get(ConfigInterface::class)->set('scout.queue.after_commit', false); + $this->app->get('config')->set('scout.queue.enabled', true); + $this->app->get('config')->set('scout.queue.after_commit', false); Bus::fake([RemoveFromSearch::class]); @@ -82,8 +81,8 @@ public function testQueueRemoveFromSearchDispatchesJobWhenQueueEnabled(): void public function testQueueRemoveFromSearchDispatchesWithAfterCommitWhenEnabled(): void { - $this->app->get(ConfigInterface::class)->set('scout.queue.enabled', true); - $this->app->get(ConfigInterface::class)->set('scout.queue.after_commit', true); + $this->app->get('config')->set('scout.queue.enabled', true); + $this->app->get('config')->set('scout.queue.after_commit', true); Bus::fake([RemoveFromSearch::class]); @@ -99,7 +98,7 @@ public function testQueueRemoveFromSearchDispatchesWithAfterCommitWhenEnabled(): public function testQueueMakeSearchableDoesNotDispatchJobWhenQueueDisabled(): void { - $this->app->get(ConfigInterface::class)->set('scout.queue.enabled', false); + $this->app->get('config')->set('scout.queue.enabled', false); Bus::fake([MakeSearchable::class]); @@ -115,7 +114,7 @@ public function testQueueMakeSearchableDoesNotDispatchJobWhenQueueDisabled(): vo public function testQueueRemoveFromSearchDoesNotDispatchJobWhenQueueDisabled(): void { - $this->app->get(ConfigInterface::class)->set('scout.queue.enabled', false); + $this->app->get('config')->set('scout.queue.enabled', false); Bus::fake([RemoveFromSearch::class]); @@ -130,7 +129,7 @@ public function testQueueRemoveFromSearchDoesNotDispatchJobWhenQueueDisabled(): public function testEmptyCollectionDoesNotDispatchMakeSearchableJob(): void { - $this->app->get(ConfigInterface::class)->set('scout.queue.enabled', true); + $this->app->get('config')->set('scout.queue.enabled', true); Bus::fake([MakeSearchable::class]); @@ -142,7 +141,7 @@ public function testEmptyCollectionDoesNotDispatchMakeSearchableJob(): void public function testEmptyCollectionDoesNotDispatchRemoveFromSearchJob(): void { - $this->app->get(ConfigInterface::class)->set('scout.queue.enabled', true); + $this->app->get('config')->set('scout.queue.enabled', true); Bus::fake([RemoveFromSearch::class]); @@ -154,7 +153,7 @@ public function testEmptyCollectionDoesNotDispatchRemoveFromSearchJob(): void public function testQueueMakeSearchableDispatchesCustomJobClass(): void { - $this->app->get(ConfigInterface::class)->set('scout.queue.enabled', true); + $this->app->get('config')->set('scout.queue.enabled', true); Scout::makeSearchableUsing(TestCustomMakeSearchable::class); @@ -170,7 +169,7 @@ public function testQueueMakeSearchableDispatchesCustomJobClass(): void public function testQueueRemoveFromSearchDispatchesCustomJobClass(): void { - $this->app->get(ConfigInterface::class)->set('scout.queue.enabled', true); + $this->app->get('config')->set('scout.queue.enabled', true); Scout::removeFromSearchUsing(TestCustomRemoveFromSearch::class); diff --git a/tests/Scout/Unit/ScoutTest.php b/tests/Scout/Unit/ScoutTest.php index 50cca11b3..b197f699e 100644 --- a/tests/Scout/Unit/ScoutTest.php +++ b/tests/Scout/Unit/ScoutTest.php @@ -23,7 +23,6 @@ class ScoutTest extends ScoutTestCase protected function tearDown(): void { Scout::resetJobClasses(); - m::close(); parent::tearDown(); } diff --git a/tests/Scout/migrations/2025_01_01_000000_create_searchable_models_table.php b/tests/Scout/migrations/2025_01_01_000000_create_searchable_models_table.php index fcd96f71d..e54af7107 100644 --- a/tests/Scout/migrations/2025_01_01_000000_create_searchable_models_table.php +++ b/tests/Scout/migrations/2025_01_01_000000_create_searchable_models_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/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 982898318..e3a2a5dc1 100644 --- a/tests/Sentry/Features/LogFeatureTest.php +++ b/tests/Sentry/Features/LogFeatureTest.php @@ -4,8 +4,8 @@ namespace Hypervel\Tests\Sentry\Features; -use Hyperf\Contract\ConfigInterface; -use Hypervel\Foundation\Contracts\Application as ApplicationContract; +use Hypervel\Config\Repository; +use Hypervel\Contracts\Foundation\Application as ApplicationContract; use Hypervel\Foundation\Testing\Concerns\RunTestsInCoroutine; use Hypervel\Sentry\Features\LogFeature; use Hypervel\Support\Facades\Log; @@ -31,7 +31,7 @@ protected function defineEnvironment(ApplicationContract $app): void { parent::defineEnvironment($app); - tap($app->get(ConfigInterface::class), static function (ConfigInterface $config) { + tap($app->get('config'), static function (Repository $config) { $config->set('logging.channels.sentry', [ 'driver' => 'sentry', ]); diff --git a/tests/Sentry/Features/NotificationsFeatureTest.php b/tests/Sentry/Features/NotificationsFeatureTest.php index e40507b0c..8d93a170a 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; @@ -35,7 +35,7 @@ class NotificationsFeatureTest extends SentryTestCase protected function defineEnvironment(ApplicationContract $app): void { parent::defineEnvironment($app); - $this->app->set(ViewFactory::class, m::mock(ViewFactory::class)); + $this->app->set(ViewFactory::class, m::mock(ViewFactory::class)->shouldIgnoreMissing()); } public function testSpanIsRecorded(): void diff --git a/tests/Sentry/Features/QueueFeatureTest.php b/tests/Sentry/Features/QueueFeatureTest.php index 3fb941338..10f9cbbe9 100644 --- a/tests/Sentry/Features/QueueFeatureTest.php +++ b/tests/Sentry/Features/QueueFeatureTest.php @@ -5,9 +5,8 @@ namespace Hypervel\Tests\Sentry\Features; 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; @@ -110,7 +109,7 @@ public function testQueueJobsWithBreadcrumbSetInBetweenKeepsNonJobBreadcrumbsOnC public function testQueueJobCreatesTransactionByDefault(): void { - $this->app->get(ConfigInterface::class)->set('sentry.traces_sample_rate', 1.0); + $this->app->get('config')->set('sentry.traces_sample_rate', 1.0); dispatch(new QueueEventsTestJob()); $transaction = $this->getLastSentryEvent(); @@ -130,8 +129,8 @@ public function testQueueJobCreatesTransactionByDefault(): void */ public function testQueueJobDoesntCreateTransaction(): void { - $this->app->get(ConfigInterface::class)->set('sentry.traces_sample_rate', 1.0); - $this->app->get(ConfigInterface::class)->set('sentry.tracing.queue_job_transactions', false); + $this->app->get('config')->set('sentry.traces_sample_rate', 1.0); + $this->app->get('config')->set('sentry.tracing.queue_job_transactions', false); dispatch(new QueueEventsTestJob()); $transaction = $this->getLastSentryEvent(); diff --git a/tests/Sentry/Features/RedisFeatureTest.php b/tests/Sentry/Features/RedisFeatureTest.php index 5f1d16b31..84474f5e2 100644 --- a/tests/Sentry/Features/RedisFeatureTest.php +++ b/tests/Sentry/Features/RedisFeatureTest.php @@ -4,18 +4,19 @@ namespace Hypervel\Tests\Sentry\Features; -use Hyperf\Contract\ConfigInterface; -use Hyperf\Contract\PoolOptionInterface; -use Hyperf\Redis\Event\CommandExecuted; -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\Contracts\Pool\PoolOptionInterface; use Hypervel\Foundation\Testing\Concerns\RunTestsInCoroutine; +use Hypervel\Redis\Events\CommandExecuted; +use Hypervel\Redis\Pool\PoolFactory; +use Hypervel\Redis\Pool\RedisPool; +use Hypervel\Redis\RedisConnection; use Hypervel\Sentry\Features\RedisFeature; use Hypervel\Session\SessionManager; use Hypervel\Tests\Sentry\SentryTestCase; -use Mockery; +use Mockery as m; +use Sentry\SentrySdk; +use Sentry\State\HubInterface; /** * @internal @@ -210,30 +211,57 @@ public function testRedisCommandWithDifferentConfiguration(): void $this->assertEquals(10.0, $spanData['duration']); } + public function testRedisFeatureWorksAfterReplacingStaleGlobalHub(): void + { + $staleHub = m::mock(HubInterface::class); + SentrySdk::setCurrentHub($staleHub); + + $this->refreshApplication(); + $this->setupMocks(); + + $transaction = $this->startTransaction(); + + $dispatcher = $this->app->get(Dispatcher::class); + $connection = $this->createRedisConnection('default'); + $event = new CommandExecuted('GET', ['test-key'], 0.005, $connection, 'default', 'value', null); + + $dispatcher->dispatch($event); + + $this->assertNotSame($staleHub, SentrySdk::getCurrentHub()); + $this->assertSame($this->app->get(HubInterface::class), SentrySdk::getCurrentHub()); + + $spans = $transaction->getSpanRecorder()->getSpans(); + $this->assertCount(2, $spans); + } + private function setupMocks(string $connectionName = 'default', int $database = 0): void { // Mock PoolFactory - $poolOption = Mockery::mock(PoolOptionInterface::class); + $poolOption = m::mock(PoolOptionInterface::class); $poolOption->shouldReceive('getMaxConnections')->andReturn(10); $poolOption->shouldReceive('getMaxIdleTime')->andReturn(60.0); - $pool = Mockery::mock(RedisPool::class); + $pool = m::mock(RedisPool::class); $pool->shouldReceive('getOption')->andReturn($poolOption); $pool->shouldReceive('getConnectionsInChannel')->andReturn(5); $pool->shouldReceive('getCurrentConnections')->andReturn(2); - $poolFactory = Mockery::mock(PoolFactory::class); + $poolFactory = m::mock(PoolFactory::class); $poolFactory->shouldReceive('getPool')->with($connectionName)->andReturn($pool); $this->app->instance(PoolFactory::class, $poolFactory); // Mock Redis config - $config = $this->app->get(ConfigInterface::class); - $config->set("redis.{$connectionName}", ['db' => $database]); + $config = $this->app->get('config'); + $config->set("database.redis.{$connectionName}", [ + 'host' => '127.0.0.1', + 'port' => 6379, + 'db' => $database, + ]); } private function createRedisConnection(string $name): RedisConnection { - return Mockery::mock(RedisConnection::class); + return m::mock(RedisConnection::class); } } diff --git a/tests/Sentry/SentryTestCase.php b/tests/Sentry/SentryTestCase.php index 2be11b924..7d43f05fb 100644 --- a/tests/Sentry/SentryTestCase.php +++ b/tests/Sentry/SentryTestCase.php @@ -4,8 +4,8 @@ namespace Hypervel\Tests\Sentry; -use Hyperf\Contract\ConfigInterface; -use Hypervel\Foundation\Contracts\Application as ApplicationContract; +use Hypervel\Config\Repository; +use Hypervel\Contracts\Foundation\Application as ApplicationContract; use Hypervel\Sentry\SentryServiceProvider; use Hypervel\Testbench\ConfigProviderRegister; use ReflectionException; @@ -41,7 +41,7 @@ protected function defineEnvironment(ApplicationContract $app): void self::$lastSentryEvents = []; $this->setupGlobalEventProcessor(); - $app->get(ConfigInterface::class) + $app->get('config') ->set('cache', [ 'default' => env('CACHE_DRIVER', 'array'), 'stores' => [ @@ -52,7 +52,7 @@ protected function defineEnvironment(ApplicationContract $app): void 'prefix' => env('CACHE_PREFIX', 'hypervel_cache'), ]); - tap($app->get(ConfigInterface::class), function (ConfigInterface $config) { + tap($app->get('config'), function (Repository $config) { $config->set('sentry.before_send', static function (Event $event, ?EventHint $hint) { self::$lastSentryEvents[] = [$event, $hint]; 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/SessionStoreBackedEnumTest.php b/tests/Session/SessionStoreBackedEnumTest.php index 1b60768f0..6c4370395 100644 --- a/tests/Session/SessionStoreBackedEnumTest.php +++ b/tests/Session/SessionStoreBackedEnumTest.php @@ -4,7 +4,7 @@ namespace Hypervel\Tests\Session; -use Hyperf\Context\Context; +use Hypervel\Context\Context; use Hypervel\Session\Store; use Hypervel\Tests\TestCase; use Mockery as m; @@ -40,9 +40,9 @@ class SessionStoreBackedEnumTest extends TestCase { protected function tearDown(): void { - Context::destroy('_session.store.started'); - Context::destroy('_session.store.id'); - Context::destroy('_session.store.attributes'); + Context::destroy('__session.store.started'); + Context::destroy('__session.store.id'); + Context::destroy('__session.store.attributes'); parent::tearDown(); } diff --git a/tests/Session/SessionStoreTest.php b/tests/Session/SessionStoreTest.php index d07872a3d..49c7e2be0 100644 --- a/tests/Session/SessionStoreTest.php +++ b/tests/Session/SessionStoreTest.php @@ -4,11 +4,11 @@ namespace Hypervel\Tests\Session; -use Hyperf\Context\Context; -use Hyperf\Stringable\Str; -use Hyperf\Support\MessageBag; use Hyperf\ViewEngine\ViewErrorBag; +use Hypervel\Context\Context; use Hypervel\Session\Store; +use Hypervel\Support\MessageBag; +use Hypervel\Support\Str; use Hypervel\Tests\TestCase; use Mockery as m; use SessionHandlerInterface; @@ -21,9 +21,9 @@ class SessionStoreTest extends TestCase { protected function tearDown(): void { - Context::destroy('_session.store.started'); - Context::destroy('_session.store.id'); - Context::destroy('_session.store.attributes'); + Context::destroy('__session.store.started'); + Context::destroy('__session.store.id'); + Context::destroy('__session.store.attributes'); parent::tearDown(); } 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/Socialite/SocialiteManagerTest.php b/tests/Socialite/SocialiteManagerTest.php index fca8caf8d..f0e9e90f0 100644 --- a/tests/Socialite/SocialiteManagerTest.php +++ b/tests/Socialite/SocialiteManagerTest.php @@ -4,7 +4,6 @@ namespace Hypervel\Tests\Socialite; -use Hyperf\Contract\ConfigInterface; use Hypervel\Context\Context; use Hypervel\Socialite\Exceptions\DriverMissingConfigurationException; use Hypervel\Socialite\SocialiteManager; @@ -28,7 +27,7 @@ public function setUp(): void { parent::setUp(); - $this->app->get(ConfigInterface::class) + $this->app->get('config') ->set('services.github', [ 'client_id' => 'github-client-id', 'client_secret' => 'github-client-secret', @@ -48,7 +47,7 @@ public function testItCanInstantiateTheGithubDriver() public function testItCanInstantiateTheGithubDriverWithScopesFromConfigArray() { $factory = $this->app->get(SocialiteManager::class); - $this->app->get(ConfigInterface::class) + $this->app->get('config') ->set('services.github', [ 'client_id' => 'github-client-id', 'client_secret' => 'github-client-secret', @@ -69,7 +68,7 @@ public function testItCanInstantiateTheGithubDriverWithScopesWithoutArrayFromCon public function testItCanInstantiateTheGithubDriverWithScopesFromConfigArrayMergedByProgrammaticScopesUsingScopesMethod() { $factory = $this->app->get(SocialiteManager::class); - $this->app->get(ConfigInterface::class) + $this->app->get('config') ->set('services.github', [ 'client_id' => 'github-client-id', 'client_secret' => 'github-client-secret', @@ -83,7 +82,7 @@ public function testItCanInstantiateTheGithubDriverWithScopesFromConfigArrayMerg public function testItCanInstantiateTheGithubDriverWithScopesFromConfigArrayOverwrittenByProgrammaticScopesUsingSetScopesMethod() { $factory = $this->app->get(SocialiteManager::class); - $this->app->get(ConfigInterface::class) + $this->app->get('config') ->set('services.github', [ 'client_id' => 'github-client-id', 'client_secret' => 'github-client-secret', @@ -101,7 +100,7 @@ public function testItThrowsExceptionWhenClientSecretIsMissing() $factory = $this->app->get(SocialiteManager::class); - $this->app->get(ConfigInterface::class) + $this->app->get('config') ->set('services.github', [ 'client_id' => 'github-client-id', 'redirect' => 'http://your-callback-url', @@ -117,7 +116,7 @@ public function testItThrowsExceptionWhenClientIdIsMissing() $factory = $this->app->get(SocialiteManager::class); - $this->app->get(ConfigInterface::class) + $this->app->get('config') ->set('services.github', [ 'client_secret' => 'github-client-secret', 'redirect' => 'http://your-callback-url', @@ -133,7 +132,7 @@ public function testItThrowsExceptionWhenRedirectIsMissing() $factory = $this->app->get(SocialiteManager::class); - $this->app->get(ConfigInterface::class) + $this->app->get('config') ->set('services.github', [ 'client_id' => 'github-client-id', 'client_secret' => 'github-client-secret', @@ -149,7 +148,7 @@ public function testItThrowsExceptionWhenConfigurationIsCompletelyMissing() $factory = $this->app->get(SocialiteManager::class); - $this->app->get(ConfigInterface::class) + $this->app->get('config') ->set('services.github', null); $factory->driver('github'); diff --git a/tests/Support/Common.php b/tests/Support/Common.php new file mode 100644 index 000000000..c8e030cb9 --- /dev/null +++ b/tests/Support/Common.php @@ -0,0 +1,64 @@ + 'bar']; + } +} + +class TestJsonableObject implements Jsonable +{ + public function toJson($options = 0): string + { + return '{"foo":"bar"}'; + } +} + +class TestJsonSerializeObject implements JsonSerializable +{ + public function jsonSerialize(): array + { + return ['foo' => 'bar']; + } +} + +class TestJsonSerializeWithScalarValueObject implements JsonSerializable +{ + public function jsonSerialize(): string + { + return 'foo'; + } +} + +class TestTraversableAndJsonSerializableObject implements IteratorAggregate, JsonSerializable +{ + public array $items; + + public function __construct(array $items = []) + { + $this->items = $items; + } + + public function getIterator(): Traversable + { + return new ArrayIterator($this->items); + } + + public function jsonSerialize(): array + { + return json_decode(json_encode($this->items), true); + } +} diff --git a/tests/Support/ComposerTest.php b/tests/Support/ComposerTest.php new file mode 100644 index 000000000..b6cff8c33 --- /dev/null +++ b/tests/Support/ComposerTest.php @@ -0,0 +1,75 @@ +assertInstanceOf(ClassLoader::class, $loader); + } + + public function testSetAndGetLoader() + { + $original = Composer::getLoader(); + $custom = new ClassLoader(); + + Composer::setLoader($custom); + + $this->assertSame($custom, Composer::getLoader()); + + Composer::setLoader($original); + } + + public function testGetLockContent() + { + $content = Composer::getLockContent(); + $this->assertNotEmpty($content); + $this->assertNotNull($content->offsetGet('packages')); + } + + public function testGetMergedExtraWithoutKeyReturnsAll() + { + $extra = Composer::getMergedExtra(); + $this->assertIsArray($extra); + $this->assertNotEmpty($extra); + } + + public function testGetVersions() + { + $versions = Composer::getVersions(); + $this->assertIsArray($versions); + } + + public function testGetScripts() + { + $scripts = Composer::getScripts(); + $this->assertIsArray($scripts); + } +} diff --git a/tests/Support/Concerns/CountsEnumerations.php b/tests/Support/Concerns/CountsEnumerations.php new file mode 100644 index 000000000..10d14a938 --- /dev/null +++ b/tests/Support/Concerns/CountsEnumerations.php @@ -0,0 +1,99 @@ +push($i); + + yield $i; + } + }; + + return [$generatorFunction, $recorder]; + } + + protected function assertDoesNotEnumerate(callable $executor): void + { + $this->assertEnumerates(0, $executor); + } + + protected function assertDoesNotEnumerateCollection( + LazyCollection $collection, + callable $executor + ): void { + $this->assertEnumeratesCollection($collection, 0, $executor); + } + + protected function assertEnumerates(int $count, callable $executor): void + { + $this->assertEnumeratesCollection( + LazyCollection::times(100), + $count, + $executor + ); + } + + protected function assertEnumeratesCollection( + LazyCollection $collection, + int $count, + callable $executor + ): void { + $enumerated = 0; + + $data = $this->countEnumerations($collection, $enumerated); + + $executor($data); + + $this->assertEnumerations($count, $enumerated); + } + + protected function assertEnumeratesOnce(callable $executor): void + { + $this->assertEnumeratesCollectionOnce(LazyCollection::times(10), $executor); + } + + protected function assertEnumeratesCollectionOnce( + LazyCollection $collection, + callable $executor + ): void { + $enumerated = 0; + $count = $collection->count(); + $collection = $this->countEnumerations($collection, $enumerated); + + $executor($collection); + + $this->assertEquals( + $count, + $enumerated, + $count > $enumerated ? 'Failed to enumerate in full.' : 'Enumerated more than once.' + ); + } + + protected function assertEnumerations(int $expected, int $actual): void + { + $this->assertEquals( + $expected, + $actual, + "Failed asserting that {$actual} items that were enumerated matches expected {$expected}." + ); + } + + protected function countEnumerations(LazyCollection $collection, int &$count): LazyCollection + { + return $collection->tapEach(function () use (&$count) { + ++$count; + }); + } +} diff --git a/tests/Support/ConfigurationUrlParserTest.php b/tests/Support/ConfigurationUrlParserTest.php new file mode 100644 index 000000000..d4dbaf166 --- /dev/null +++ b/tests/Support/ConfigurationUrlParserTest.php @@ -0,0 +1,452 @@ +assertEquals($expectedOutput, (new ConfigurationUrlParser())->parseConfiguration($config)); + } + + public static function databaseUrls() + { + return [ + 'simple URL' => [ + 'mysql://foo:bar@localhost/baz', + [ + 'driver' => 'mysql', + 'username' => 'foo', + 'password' => 'bar', + 'host' => 'localhost', + 'database' => 'baz', + ], + ], + 'simple URL with port' => [ + 'mysql://foo:bar@localhost:134/baz', + [ + 'driver' => 'mysql', + 'username' => 'foo', + 'password' => 'bar', + 'host' => 'localhost', + 'port' => 134, + 'database' => 'baz', + ], + ], + 'sqlite relative URL with host' => [ + 'sqlite://localhost/foo/database.sqlite', + [ + 'database' => 'foo/database.sqlite', + 'driver' => 'sqlite', + 'host' => 'localhost', + ], + ], + 'sqlite absolute URL with host' => [ + 'sqlite://localhost//tmp/database.sqlite', + [ + 'database' => '/tmp/database.sqlite', + 'driver' => 'sqlite', + 'host' => 'localhost', + ], + ], + 'sqlite relative URL without host' => [ + 'sqlite:///foo/database.sqlite', + [ + 'database' => 'foo/database.sqlite', + 'driver' => 'sqlite', + ], + ], + 'sqlite absolute URL without host' => [ + 'sqlite:////tmp/database.sqlite', + [ + 'database' => '/tmp/database.sqlite', + 'driver' => 'sqlite', + ], + ], + 'sqlite memory' => [ + 'sqlite:///:memory:', + [ + 'database' => ':memory:', + 'driver' => 'sqlite', + ], + ], + 'params parsed from URL override individual params' => [ + [ + 'url' => 'mysql://foo:bar@localhost/baz', + 'password' => 'lulz', + 'driver' => 'sqlite', + ], + [ + 'username' => 'foo', + 'password' => 'bar', + 'host' => 'localhost', + 'database' => 'baz', + 'driver' => 'mysql', + ], + ], + 'params not parsed from URL but individual params are preserved' => [ + [ + 'url' => 'mysql://foo:bar@localhost/baz', + 'port' => 134, + ], + [ + 'username' => 'foo', + 'password' => 'bar', + 'host' => 'localhost', + 'port' => 134, + 'database' => 'baz', + 'driver' => 'mysql', + ], + ], + 'query params from URL are used as extra params' => [ + 'mysql://foo:bar@localhost/database?charset=UTF-8', + [ + 'driver' => 'mysql', + 'database' => 'database', + 'host' => 'localhost', + 'username' => 'foo', + 'password' => 'bar', + 'charset' => 'UTF-8', + ], + ], + 'simple URL with driver set apart' => [ + [ + 'url' => '//foo:bar@localhost/baz', + 'driver' => 'sqlsrv', + ], + [ + 'username' => 'foo', + 'password' => 'bar', + 'host' => 'localhost', + 'database' => 'baz', + 'driver' => 'sqlsrv', + ], + ], + 'simple URL with percent encoding' => [ + 'mysql://foo%3A:bar%2F@localhost/baz+baz%40', + [ + 'username' => 'foo:', + 'password' => 'bar/', + 'host' => 'localhost', + 'database' => 'baz+baz@', + 'driver' => 'mysql', + ], + ], + 'simple URL with percent sign in password' => [ + 'mysql://foo:bar%25bar@localhost/baz', + [ + 'username' => 'foo', + 'password' => 'bar%bar', + 'host' => 'localhost', + 'database' => 'baz', + 'driver' => 'mysql', + ], + ], + 'simple URL with percent encoding in query' => [ + 'mysql://foo:bar%25bar@localhost/baz?timezone=%2B00%3A00', + [ + 'username' => 'foo', + 'password' => 'bar%bar', + 'host' => 'localhost', + 'database' => 'baz', + 'driver' => 'mysql', + 'timezone' => '+00:00', + ], + ], + 'URL with mssql alias driver' => [ + 'mssql://null', + [ + 'driver' => 'sqlsrv', + ], + ], + 'URL with sqlsrv alias driver' => [ + 'sqlsrv://null', + [ + 'driver' => 'sqlsrv', + ], + ], + 'URL with mysql alias driver' => [ + 'mysql://null', + [ + 'driver' => 'mysql', + ], + ], + 'URL with mysql2 alias driver' => [ + 'mysql2://null', + [ + 'driver' => 'mysql', + ], + ], + 'URL with postgres alias driver' => [ + 'postgres://null', + [ + 'driver' => 'pgsql', + ], + ], + 'URL with postgresql alias driver' => [ + 'postgresql://null', + [ + 'driver' => 'pgsql', + ], + ], + 'URL with pgsql alias driver' => [ + 'pgsql://null', + [ + 'driver' => 'pgsql', + ], + ], + 'URL with sqlite alias driver' => [ + 'sqlite://null', + [ + 'driver' => 'sqlite', + ], + ], + 'URL with sqlite3 alias driver' => [ + 'sqlite3://null', + [ + 'driver' => 'sqlite', + ], + ], + + 'URL with unknown driver' => [ + 'foo://null', + [ + 'driver' => 'foo', + ], + ], + 'Sqlite with foreign_key_constraints' => [ + 'sqlite:////absolute/path/to/database.sqlite?foreign_key_constraints=true', + [ + 'driver' => 'sqlite', + 'database' => '/absolute/path/to/database.sqlite', + 'foreign_key_constraints' => true, + ], + ], + 'Sqlite with busy_timeout' => [ + 'sqlite:////absolute/path/to/database.sqlite?busy_timeout=5000', + [ + 'driver' => 'sqlite', + 'database' => '/absolute/path/to/database.sqlite', + 'busy_timeout' => 5000, + ], + ], + 'Sqlite with journal_mode' => [ + 'sqlite:////absolute/path/to/database.sqlite?journal_mode=WAL', + [ + 'driver' => 'sqlite', + 'database' => '/absolute/path/to/database.sqlite', + 'journal_mode' => 'WAL', + ], + ], + 'Sqlite with synchronous' => [ + 'sqlite:////absolute/path/to/database.sqlite?synchronous=NORMAL', + [ + 'driver' => 'sqlite', + 'database' => '/absolute/path/to/database.sqlite', + 'synchronous' => 'NORMAL', + ], + ], + + 'Most complex example with read and write subarrays all in string' => [ + 'mysql://root:@null/database?read[host][]=192.168.1.1&write[host][]=196.168.1.2&sticky=true&charset=utf8mb4&collation=utf8mb4_unicode_ci&prefix=', + [ + 'read' => [ + 'host' => ['192.168.1.1'], + ], + 'write' => [ + 'host' => ['196.168.1.2'], + ], + 'sticky' => true, + 'driver' => 'mysql', + 'database' => 'database', + 'username' => 'root', + 'password' => '', + 'charset' => 'utf8mb4', + 'collation' => 'utf8mb4_unicode_ci', + 'prefix' => '', + ], + ], + + 'Full example from doc that prove that there isn\'t any Breaking Change' => [ + [ + 'driver' => 'mysql', + 'host' => '127.0.0.1', + 'port' => '3306', + 'database' => 'forge', + 'username' => 'forge', + 'password' => '', + 'unix_socket' => '', + 'charset' => 'utf8mb4', + 'collation' => 'utf8mb4_unicode_ci', + 'prefix' => '', + 'prefix_indexes' => true, + 'strict' => true, + 'engine' => null, + 'options' => ['foo' => 'bar'], + ], + [ + 'driver' => 'mysql', + 'host' => '127.0.0.1', + 'port' => '3306', + 'database' => 'forge', + 'username' => 'forge', + 'password' => '', + 'unix_socket' => '', + 'charset' => 'utf8mb4', + 'collation' => 'utf8mb4_unicode_ci', + 'prefix' => '', + 'prefix_indexes' => true, + 'strict' => true, + 'engine' => null, + 'options' => ['foo' => 'bar'], + ], + ], + + 'Full example from doc with url overwriting parameters' => [ + [ + 'url' => 'mysql://root:pass@db/local', + 'driver' => 'mysql', + 'host' => '127.0.0.1', + 'port' => '3306', + 'database' => 'forge', + 'username' => 'forge', + 'password' => '', + 'unix_socket' => '', + 'charset' => 'utf8mb4', + 'collation' => 'utf8mb4_unicode_ci', + 'prefix' => '', + 'prefix_indexes' => true, + 'strict' => true, + 'engine' => null, + 'options' => ['foo' => 'bar'], + ], + [ + 'driver' => 'mysql', + 'host' => 'db', + 'port' => '3306', + 'database' => 'local', + 'username' => 'root', + 'password' => 'pass', + 'unix_socket' => '', + 'charset' => 'utf8mb4', + 'collation' => 'utf8mb4_unicode_ci', + 'prefix' => '', + 'prefix_indexes' => true, + 'strict' => true, + 'engine' => null, + 'options' => ['foo' => 'bar'], + ], + ], + 'Redis Example' => [ + [ + // Coming directly from Heroku documentation + 'url' => 'redis://h:asdfqwer1234asdf@ec2-111-1-1-1.compute-1.amazonaws.com:111', + 'host' => '127.0.0.1', + 'password' => null, + 'port' => 6379, + 'database' => 0, + ], + [ + 'driver' => 'tcp', + 'host' => 'ec2-111-1-1-1.compute-1.amazonaws.com', + 'port' => 111, + 'database' => 0, + 'username' => 'h', + 'password' => 'asdfqwer1234asdf', + ], + ], + 'Redis example where URL ends with "/" and database is not present' => [ + [ + 'url' => 'redis://h:asdfqwer1234asdf@ec2-111-1-1-1.compute-1.amazonaws.com:111/', + 'host' => '127.0.0.1', + 'password' => null, + 'port' => 6379, + 'database' => 2, + ], + [ + 'driver' => 'tcp', + 'host' => 'ec2-111-1-1-1.compute-1.amazonaws.com', + 'port' => 111, + 'database' => 2, + 'username' => 'h', + 'password' => 'asdfqwer1234asdf', + ], + ], + 'Redis Example with tls scheme' => [ + [ + 'url' => 'tls://h:asdfqwer1234asdf@ec2-111-1-1-1.compute-1.amazonaws.com:111', + 'host' => '127.0.0.1', + 'password' => null, + 'port' => 6379, + 'database' => 0, + ], + [ + 'driver' => 'tls', + 'host' => 'ec2-111-1-1-1.compute-1.amazonaws.com', + 'port' => 111, + 'database' => 0, + 'username' => 'h', + 'password' => 'asdfqwer1234asdf', + ], + ], + 'Redis Example with rediss scheme' => [ + [ + 'url' => 'rediss://h:asdfqwer1234asdf@ec2-111-1-1-1.compute-1.amazonaws.com:111', + 'host' => '127.0.0.1', + 'password' => null, + 'port' => 6379, + 'database' => 0, + ], + [ + 'driver' => 'tls', + 'host' => 'ec2-111-1-1-1.compute-1.amazonaws.com', + 'port' => 111, + 'database' => 0, + 'username' => 'h', + 'password' => 'asdfqwer1234asdf', + ], + ], + ]; + } + + public function testDriversAliases() + { + $this->assertEquals([ + 'mssql' => 'sqlsrv', + 'mysql2' => 'mysql', + 'postgres' => 'pgsql', + 'postgresql' => 'pgsql', + 'sqlite3' => 'sqlite', + 'redis' => 'tcp', + 'rediss' => 'tls', + ], ConfigurationUrlParser::getDriverAliases()); + + ConfigurationUrlParser::addDriverAlias('some-particular-alias', 'mysql'); + + $this->assertEquals([ + 'mssql' => 'sqlsrv', + 'mysql2' => 'mysql', + 'postgres' => 'pgsql', + 'postgresql' => 'pgsql', + 'sqlite3' => 'sqlite', + 'redis' => 'tcp', + 'rediss' => 'tls', + 'some-particular-alias' => 'mysql', + ], ConfigurationUrlParser::getDriverAliases()); + + $this->assertEquals([ + 'driver' => 'mysql', + ], (new ConfigurationUrlParser())->parseConfiguration('some-particular-alias://null')); + } +} diff --git a/tests/Support/DatabaseIntegrationTestCase.php b/tests/Support/DatabaseIntegrationTestCase.php new file mode 100644 index 000000000..7a423cab8 --- /dev/null +++ b/tests/Support/DatabaseIntegrationTestCase.php @@ -0,0 +1,213 @@ +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('config'); + + $this->registerConnectors($driver); + + $connectionConfig = match ($driver) { + 'mysql' => $this->getMySqlConnectionConfig(), + 'pgsql' => $this->getPostgresConnectionConfig(), + 'sqlite' => $this->getSqliteConnectionConfig(), + default => throw new InvalidArgumentException("Unsupported driver: {$driver}"), + }; + + $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/DateFacadeTest.php b/tests/Support/DateFacadeTest.php new file mode 100644 index 000000000..18a9b3ccc --- /dev/null +++ b/tests/Support/DateFacadeTest.php @@ -0,0 +1,107 @@ +getTimestamp()) + ) + ); + } + + public function testUseClosure() + { + $start = Carbon::now()->getTimestamp(); + $this->assertSame(Carbon::class, get_class(Date::now())); + $this->assertBetweenStartAndNow($start, Date::now()->getTimestamp()); + DateFactory::use(function (Carbon $date) { + return new DateTime($date->format('Y-m-d H:i:s.u'), $date->getTimezone()); + }); + $start = Carbon::now()->getTimestamp(); + $this->assertSame(DateTime::class, get_class(Date::now())); + $this->assertBetweenStartAndNow($start, Date::now()->getTimestamp()); + } + + public function testUseClassName() + { + $start = Carbon::now()->getTimestamp(); + $this->assertSame(Carbon::class, get_class(Date::now())); + $this->assertBetweenStartAndNow($start, Date::now()->getTimestamp()); + DateFactory::use(DateTime::class); + $start = Carbon::now()->getTimestamp(); + $this->assertSame(DateTime::class, get_class(Date::now())); + $this->assertBetweenStartAndNow($start, Date::now()->getTimestamp()); + } + + public function testCarbonImmutable() + { + DateFactory::use(CarbonImmutable::class); + $this->assertSame(CarbonImmutable::class, get_class(Date::now())); + DateFactory::use(Carbon::class); + $this->assertSame(Carbon::class, get_class(Date::now())); + DateFactory::use(function (Carbon $date) { + return $date->toImmutable(); + }); + $this->assertSame(CarbonImmutable::class, get_class(Date::now())); + DateFactory::use(function ($date) { + return $date; + }); + $this->assertSame(Carbon::class, get_class(Date::now())); + + DateFactory::use(new Factory([ + 'locale' => 'fr', + ])); + $this->assertSame('fr', Date::now()->locale); + DateFactory::use(Carbon::class); + $this->assertSame('en', Date::now()->locale); + DateFactory::use(CustomDateClass::class); + $this->assertInstanceOf(CustomDateClass::class, Date::now()); + $this->assertInstanceOf(Carbon::class, Date::now()->getOriginal()); + DateFactory::use(Carbon::class); + } + + public function testUseInvalidHandler() + { + $this->expectException(InvalidArgumentException::class); + + DateFactory::use(42); + } + + public function testMacro() + { + Date::macro('returnNonDate', function () { + return 'string'; + }); + + $this->assertSame('string', Date::returnNonDate()); + } +} diff --git a/tests/Support/DotenvManagerTest.php b/tests/Support/DotenvManagerTest.php new file mode 100644 index 000000000..9107068dc --- /dev/null +++ b/tests/Support/DotenvManagerTest.php @@ -0,0 +1,63 @@ +assertSame('1.0', env('TEST_VERSION')); + $this->assertTrue(env('OLD_FLAG')); + } + + public function testReload() + { + DotenvManager::load([__DIR__ . '/envs/oldEnv']); + $this->assertSame('1.0', env('TEST_VERSION')); + $this->assertTrue(env('OLD_FLAG')); + + DotenvManager::reload([__DIR__ . '/envs/newEnv'], true); + $this->assertSame('2.0', env('TEST_VERSION')); + $this->assertNull(env('OLD_FLAG')); + $this->assertTrue(env('NEW_FLAG')); + } + + public function testEnvDefaultValue() + { + DotenvManager::load([__DIR__ . '/envs/oldEnv']); + + $this->assertSame('fallback', env('NONEXISTENT_KEY', 'fallback')); + $this->assertNull(env('NONEXISTENT_KEY')); + } +} diff --git a/tests/Support/Enums.php b/tests/Support/Enums.php new file mode 100644 index 000000000..31ed848e6 --- /dev/null +++ b/tests/Support/Enums.php @@ -0,0 +1,22 @@ +assertSame('production', $env->get()); + } + + public function testSetChangesEnvironment() + { + $env = new Environment('local'); + $env->set('staging'); + + $this->assertSame('staging', $env->get()); + } + + public function testSetReturnsSelf() + { + $env = new Environment('local'); + + $this->assertSame($env, $env->set('production')); + } + + public function testIsMatchesSingleEnvironment() + { + $env = new Environment('production'); + + $this->assertTrue($env->is('production')); + $this->assertFalse($env->is('local')); + } + + public function testIsMatchesMultipleEnvironments() + { + $env = new Environment('staging'); + + $this->assertTrue($env->is('local', 'staging')); + $this->assertFalse($env->is('production', 'testing')); + } + + public function testIsMatchesArrayOfEnvironments() + { + $env = new Environment('testing'); + + $this->assertTrue($env->is(['testing', 'local'])); + $this->assertFalse($env->is(['production'])); + } + + public function testIsMatchesWildcardPattern() + { + $env = new Environment('production'); + + $this->assertTrue($env->is('prod*')); + $this->assertFalse($env->is('dev*')); + } + + public function testIsDebugReturnsBooleanValue() + { + $env = new Environment('local', true); + $this->assertTrue($env->isDebug()); + + $env = new Environment('production', false); + $this->assertFalse($env->isDebug()); + } + + public function testSetDebugChangesDebugState() + { + $env = new Environment('local', false); + $env->setDebug(true); + + $this->assertTrue($env->isDebug()); + } + + public function testSetDebugReturnsSelf() + { + $env = new Environment('local'); + + $this->assertSame($env, $env->setDebug(true)); + } + + public function testMagicCallIsLocal() + { + $env = new Environment('local'); + + $this->assertTrue($env->isLocal()); + $this->assertFalse($env->isProduction()); + } + + public function testMagicCallIsTesting() + { + $env = new Environment('testing'); + + $this->assertTrue($env->isTesting()); + $this->assertFalse($env->isLocal()); + } + + public function testMagicCallIsProduction() + { + $env = new Environment('production'); + + $this->assertTrue($env->isProduction()); + $this->assertFalse($env->isTesting()); + } + + public function testMagicCallThrowsForNonIsMethods() + { + $this->expectException(BadMethodCallException::class); + + $env = new Environment('local'); + $env->fooBar(); + } +} diff --git a/tests/Support/Fixtures/ClassesWithAttributes.php b/tests/Support/Fixtures/ClassesWithAttributes.php new file mode 100644 index 000000000..f74fde461 --- /dev/null +++ b/tests/Support/Fixtures/ClassesWithAttributes.php @@ -0,0 +1,43 @@ +original = $original; + } + + public static function instance(mixed $original): static + { + return new static($original); + } + + public function getOriginal(): mixed + { + return $this->original; + } +} diff --git a/tests/Support/Fixtures/IntBackedEnum.php b/tests/Support/Fixtures/IntBackedEnum.php new file mode 100644 index 000000000..26dd33a6d --- /dev/null +++ b/tests/Support/Fixtures/IntBackedEnum.php @@ -0,0 +1,11 @@ +value = $value; + } + + public function __toString(): string + { + return $this->value; + } +} diff --git a/tests/Support/ForwardsCallsTest.php b/tests/Support/ForwardsCallsTest.php new file mode 100644 index 000000000..4d30e85e1 --- /dev/null +++ b/tests/Support/ForwardsCallsTest.php @@ -0,0 +1,106 @@ +forwardedTwo('foo', 'bar'); + + $this->assertEquals(['foo', 'bar'], $results); + } + + public function testNestedForwardCalls() + { + $results = (new ForwardsCallsOne())->forwardedBase('foo', 'bar'); + + $this->assertEquals(['foo', 'bar'], $results); + } + + public function testMissingForwardedCallThrowsCorrectError() + { + $this->expectException(BadMethodCallException::class); + $this->expectExceptionMessage('Call to undefined method Hypervel\Tests\Support\ForwardsCallsOne::missingMethod()'); + + (new ForwardsCallsOne())->missingMethod('foo', 'bar'); + } + + public function testMissingAlphanumericForwardedCallThrowsCorrectError() + { + $this->expectException(BadMethodCallException::class); + $this->expectExceptionMessage('Call to undefined method Hypervel\Tests\Support\ForwardsCallsOne::this1_shouldWork_too()'); + + (new ForwardsCallsOne())->this1_shouldWork_too('foo', 'bar'); + } + + public function testNonForwardedErrorIsNotTamperedWith() + { + $this->expectException(Error::class); + $this->expectExceptionMessage('Call to undefined method Hypervel\Tests\Support\ForwardsCallsBase::missingMethod()'); + + (new ForwardsCallsOne())->baseError('foo', 'bar'); + } + + public function testThrowBadMethodCallException() + { + $this->expectException(BadMethodCallException::class); + $this->expectExceptionMessage('Call to undefined method Hypervel\Tests\Support\ForwardsCallsOne::test()'); + + (new ForwardsCallsOne())->throwTestException('test'); + } +} + +class ForwardsCallsOne +{ + use ForwardsCalls; + + public function __call($method, $parameters) + { + return $this->forwardCallTo(new ForwardsCallsTwo(), $method, $parameters); + } + + public function throwTestException($method) + { + static::throwBadMethodCallException($method); + } +} + +class ForwardsCallsTwo +{ + use ForwardsCalls; + + public function __call($method, $parameters) + { + return $this->forwardCallTo(new ForwardsCallsBase(), $method, $parameters); + } + + public function forwardedTwo(...$parameters) + { + return $parameters; + } +} + +class ForwardsCallsBase +{ + public function forwardedBase(...$parameters) + { + return $parameters; + } + + public function baseError() + { + return $this->missingMethod(); + } +} diff --git a/tests/Support/HigherOrderProxyTest.php b/tests/Support/HigherOrderProxyTest.php new file mode 100644 index 000000000..267206c02 --- /dev/null +++ b/tests/Support/HigherOrderProxyTest.php @@ -0,0 +1,74 @@ + 'Alice'], + (object) ['name' => 'Bob'], + ]); + + $proxy = new HigherOrderCollectionProxy($items, 'pluck'); + + // The proxied method returns a Collection instance; assert type and values + $this->assertInstanceOf(Collection::class, $proxy->name); + $this->assertEquals(['Alice', 'Bob'], $proxy->name->all()); + } + + public function testCallProxiesMethodCallToItems() + { + $items = new Collection([ + new class { + public function shout($s) + { + return strtoupper($s); + } + }, + new class { + public function shout($s) + { + return strtoupper($s) . '!'; + } + }, + ]); + + $proxy = new HigherOrderCollectionProxy($items, 'map'); + + $result = $proxy->shout('hey'); + + $this->assertEquals(['HEY', 'HEY!'], $result->all()); + } + + public function testCallForwardsAndReturnsTarget() + { + $target = new class { + public $count = 0; + + public function increment($by = 1) + { + $this->count += $by; + } + }; + + $proxy = new HigherOrderTapProxy($target); + + $result = $proxy->increment(3); + + $this->assertSame(3, $target->count); + $this->assertSame($target, $result); + } +} diff --git a/tests/Support/JsonTest.php b/tests/Support/JsonTest.php new file mode 100644 index 000000000..8ff5b7a67 --- /dev/null +++ b/tests/Support/JsonTest.php @@ -0,0 +1,98 @@ +assertSame('{"name":"test"}', Json::encode(['name' => 'test'])); + } + + public function testEncodeString() + { + $this->assertSame('"hello"', Json::encode('hello')); + } + + public function testEncodeInteger() + { + $this->assertSame('42', Json::encode(42)); + } + + public function testEncodeNull() + { + $this->assertSame('null', Json::encode(null)); + } + + public function testEncodeUnicode() + { + $result = Json::encode(['name' => '日本語']); + + $this->assertSame('{"name":"日本語"}', $result); + } + + public function testEncodeJsonable() + { + $jsonable = new class implements Jsonable { + public function toJson(int $options = 0): string + { + return '{"custom":true}'; + } + }; + + $this->assertSame('{"custom":true}', Json::encode($jsonable)); + } + + public function testEncodeArrayable() + { + $arrayable = new class implements Arrayable { + public function toArray(): array + { + return ['key' => 'value']; + } + }; + + $this->assertSame('{"key":"value"}', Json::encode($arrayable)); + } + + public function testDecodeReturnsArray() + { + $result = Json::decode('{"name":"test","count":5}'); + + $this->assertSame(['name' => 'test', 'count' => 5], $result); + } + + public function testDecodeReturnsObject() + { + $result = Json::decode('{"name":"test"}', false); + + $this->assertIsObject($result); + $this->assertSame('test', $result->name); + } + + public function testDecodeThrowsOnInvalidJson() + { + $this->expectException(JsonException::class); + + Json::decode('{invalid}'); + } + + public function testEncodeThrowsOnInvalidValue() + { + $this->expectException(JsonException::class); + + Json::encode(NAN); + } +} diff --git a/tests/Support/MeilisearchIntegrationTestCase.php b/tests/Support/MeilisearchIntegrationTestCase.php index f0c4e1aeb..3063fefa8 100644 --- a/tests/Support/MeilisearchIntegrationTestCase.php +++ b/tests/Support/MeilisearchIntegrationTestCase.php @@ -4,31 +4,29 @@ namespace Hypervel\Tests\Support; -use Hyperf\Contract\ConfigInterface; +use Hypervel\Foundation\Testing\Concerns\InteractsWithMeilisearch; use Hypervel\Scout\ScoutServiceProvider; use Hypervel\Testbench\TestCase; -use Meilisearch\Client as MeilisearchClient; use Throwable; /** * Base test case for Meilisearch integration tests. * - * Provides parallel-safe Meilisearch testing infrastructure: - * - Uses TEST_TOKEN env var (from paratest) to create unique index prefixes - * - Configures Meilisearch client from environment variables - * - Cleans up test indexes in setUp/tearDown + * Uses InteractsWithMeilisearch trait for: + * - Auto-skip: Skips tests if Meilisearch is unavailable (no env var needed) + * - Parallel-safe: Uses TEST_TOKEN for unique index prefixes + * - Auto-cleanup: Removes test indexes in teardown * * NOTE: This base class does NOT include RunTestsInCoroutine. Subclasses * should add the trait if they need coroutine context for their tests. * - * NOTE: Concrete test classes extending this MUST add @group integration - * and @group meilisearch-integration for proper test filtering in CI. - * * @internal * @coversNothing */ abstract class MeilisearchIntegrationTestCase extends TestCase { + use InteractsWithMeilisearch; + /** * Base index prefix for integration tests. */ @@ -39,11 +37,6 @@ abstract class MeilisearchIntegrationTestCase extends TestCase */ protected string $testPrefix; - /** - * The Meilisearch client instance. - */ - protected MeilisearchClient $meilisearch; - /** * Track indexes created during tests for cleanup. * @@ -53,13 +46,8 @@ abstract class MeilisearchIntegrationTestCase extends TestCase protected function setUp(): void { - if (! env('RUN_MEILISEARCH_INTEGRATION_TESTS', false)) { - $this->markTestSkipped( - 'Meilisearch integration tests are disabled. Set RUN_MEILISEARCH_INTEGRATION_TESTS=true to enable.' - ); - } - $this->computeTestPrefix(); + $this->meilisearchTestPrefix = $this->testPrefix; // Sync trait's prefix parent::setUp(); @@ -72,18 +60,18 @@ protected function setUp(): void * * Subclasses using RunTestsInCoroutine should call this in setUpInCoroutine(). * Subclasses NOT using the trait should call this at the end of setUp(). + * + * Uses the trait's auto-skip logic - skips if Meilisearch is unavailable. */ protected function initializeMeilisearch(): void { - $this->meilisearch = $this->app->get(MeilisearchClient::class); - $this->cleanupTestIndexes(); + $this->setUpInteractsWithMeilisearch(); } protected function tearDown(): void { - if (isset($this->meilisearch)) { - $this->cleanupTestIndexes(); - } + $this->tearDownInteractsWithMeilisearch(); + $this->createdIndexes = []; parent::tearDown(); } @@ -107,7 +95,7 @@ protected function computeTestPrefix(): void */ protected function configureMeilisearch(): void { - $config = $this->app->get(ConfigInterface::class); + $config = $this->app->get('config'); $host = env('MEILISEARCH_HOST', '127.0.0.1'); $port = env('MEILISEARCH_PORT', '7700'); diff --git a/tests/Support/NumberTest.php b/tests/Support/NumberTest.php new file mode 100644 index 000000000..9d5849dcb --- /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([[0, 9], [10, 19], [20, 25]], Number::pairs(25, 10)); + $this->assertSame([[0, 10], [10, 20], [20, 25]], Number::pairs(25, 10, 0, 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/OnceableTest.php b/tests/Support/OnceableTest.php index 3adf6830d..10c274500 100644 --- a/tests/Support/OnceableTest.php +++ b/tests/Support/OnceableTest.php @@ -4,7 +4,7 @@ namespace Hypervel\Tests\Support; -use Hypervel\Support\Contracts\HasOnceHash; +use Hypervel\Contracts\Support\HasOnceHash; use Hypervel\Support\Onceable; use Hypervel\Tests\TestCase; 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 @@ +call(fn () => 'hello'); + + $this->assertSame('hello', $result); + } + + public function testCallReportsExceptionAndReturnsNull() + { + $exception = new RuntimeException('test error'); + + $handler = m::mock(ExceptionHandlerContract::class); + $handler->shouldReceive('report')->once()->with($exception); + + $container = m::mock(ContainerInterface::class); + $container->shouldReceive('has')->with(ExceptionHandlerContract::class)->andReturnTrue(); + $container->shouldReceive('get')->with(ExceptionHandlerContract::class)->andReturn($handler); + + $caller = new SafeCaller($container); + $result = $caller->call(fn () => throw $exception); + + $this->assertNull($result); + } + + public function testCallReturnsDefaultClosureOnException() + { + $handler = m::mock(ExceptionHandlerContract::class); + $handler->shouldReceive('report')->once(); + + $container = m::mock(ContainerInterface::class); + $container->shouldReceive('has')->with(ExceptionHandlerContract::class)->andReturnTrue(); + $container->shouldReceive('get')->with(ExceptionHandlerContract::class)->andReturn($handler); + + $caller = new SafeCaller($container); + $result = $caller->call( + fn () => throw new RuntimeException('fail'), + fn () => 'fallback' + ); + + $this->assertSame('fallback', $result); + } + + public function testCallWithoutExceptionHandlerInContainer() + { + $container = m::mock(ContainerInterface::class); + $container->shouldReceive('has')->with(ExceptionHandlerContract::class)->andReturnFalse(); + + $caller = new SafeCaller($container); + $result = $caller->call(fn () => throw new RuntimeException('fail')); + + $this->assertNull($result); + } + + public function testCallWithNullDefaultReturnsNull() + { + $handler = m::mock(ExceptionHandlerContract::class); + $handler->shouldReceive('report')->once(); + + $container = m::mock(ContainerInterface::class); + $container->shouldReceive('has')->with(ExceptionHandlerContract::class)->andReturnTrue(); + $container->shouldReceive('get')->with(ExceptionHandlerContract::class)->andReturn($handler); + + $caller = new SafeCaller($container); + $result = $caller->call( + fn () => throw new RuntimeException('fail'), + null + ); + + $this->assertNull($result); + } +} diff --git a/tests/Support/SleepTest.php b/tests/Support/SleepTest.php new file mode 100644 index 000000000..e5fc8b32a --- /dev/null +++ b/tests/Support/SleepTest.php @@ -0,0 +1,661 @@ +seconds(); + $end = microtime(true); + + $this->assertEqualsWithDelta(1, $end - $start, 0.03); + } + + public function testCallbacksMayBeExecutedUsingThen() + { + $this->assertEquals(123, Sleep::for(1)->milliseconds()->then(fn () => 123)); + } + + public function testSleepRespectsWhile() + { + $_SERVER['__sleep.while'] = 0; + + $result = Sleep::for(10)->milliseconds()->while(function () { + static $results = [true, true, false]; + ++$_SERVER['__sleep.while']; + + return array_shift($results); + })->then(fn () => 100); + + $this->assertEquals(3, $_SERVER['__sleep.while']); + $this->assertEquals(100, $result); + + unset($_SERVER['__sleep.while']); + } + + public function testItSleepsForSecondsWithMilliseconds() + { + $start = microtime(true); + Sleep::for(1.5)->seconds(); + $end = microtime(true); + + $this->assertEqualsWithDelta(1.5, round($end - $start, 1, PHP_ROUND_HALF_DOWN), 0.03); + } + + public function testItCanFakeSleeping() + { + Sleep::fake(); + + $start = microtime(true); + Sleep::for(1.5)->seconds(); + $end = microtime(true); + + $this->assertEqualsWithDelta(0, $end - $start, 0.03); + } + + public function testItCanSpecifyMinutes() + { + Sleep::fake(); + + $sleep = Sleep::for(1.5)->minutes(); + + $this->assertSame((float) $sleep->duration->totalMicroseconds, 90_000_000.0); + } + + public function testItCanSpecifyMinute() + { + Sleep::fake(); + + $sleep = Sleep::for(1)->minute(); + + $this->assertSame((float) $sleep->duration->totalMicroseconds, 60_000_000.0); + } + + public function testItCanSpecifySeconds() + { + Sleep::fake(); + + $sleep = Sleep::for(1.5)->seconds(); + + $this->assertSame((float) $sleep->duration->totalMicroseconds, 1_500_000.0); + } + + public function testItCanSpecifySecond() + { + Sleep::fake(); + + $sleep = Sleep::for(1)->second(); + + $this->assertSame((float) $sleep->duration->totalMicroseconds, 1_000_000.0); + } + + public function testItCanSpecifyMilliseconds() + { + Sleep::fake(); + + $sleep = Sleep::for(1.5)->milliseconds(); + + $this->assertSame((float) $sleep->duration->totalMicroseconds, 1_500.0); + } + + public function testItCanSpecifyMillisecond() + { + Sleep::fake(); + + $sleep = Sleep::for(1)->millisecond(); + + $this->assertSame((float) $sleep->duration->totalMicroseconds, 1_000.0); + } + + public function testItCanSpecifyMicroseconds() + { + Sleep::fake(); + + $sleep = Sleep::for(1.5)->microseconds(); + + // rounded as microseconds is the smallest unit supported... + $this->assertSame((float) $sleep->duration->totalMicroseconds, 1.0); + } + + public function testItCanSpecifyMicrosecond() + { + Sleep::fake(); + + $sleep = Sleep::for(1)->microsecond(); + + $this->assertSame((float) $sleep->duration->totalMicroseconds, 1.0); + } + + public function testItCanChainDurations() + { + Sleep::fake(); + + $sleep = Sleep::for(1)->second() + ->and(500)->microseconds(); + + $this->assertSame((float) $sleep->duration->totalMicroseconds, 1000500.0); + } + + public function testItCanUseDateInterval() + { + Sleep::fake(); + + $sleep = Sleep::for(CarbonInterval::seconds(1)->addMilliseconds(5)); + + $this->assertSame((float) $sleep->duration->totalMicroseconds, 1_005_000.0); + } + + public function testItThrowsForUnknownTimeUnit() + { + try { + Sleep::for(5); + $this->fail(); + } catch (RuntimeException $e) { + $this->assertSame('Unknown duration unit.', $e->getMessage()); + } + } + + public function testItCanAssertSequence() + { + Sleep::fake(); + + Sleep::for(5)->seconds(); + Sleep::for(1)->seconds()->and(5)->microsecond(); + + Sleep::assertSequence([ + Sleep::for(5)->seconds(), + Sleep::for(1)->seconds()->and(5)->microsecond(), + ]); + } + + public function testItFailsSequenceAssertion() + { + Sleep::fake(); + + Sleep::for(5)->seconds(); + Sleep::for(1)->seconds()->and(5)->microseconds(); + + try { + Sleep::assertSequence([ + Sleep::for(5)->seconds(), + Sleep::for(9)->seconds()->and(8)->milliseconds(), + ]); + $this->fail(); + } catch (AssertionFailedError $e) { + $this->assertSame("Expected sleep duration of [9 seconds 8 milliseconds] but actually slept for [1 second 5 microseconds].\nFailed asserting that false is true.", $e->getMessage()); + } + } + + public function testItCanUseSleep() + { + Sleep::fake(); + + Sleep::sleep(3); + + Sleep::assertSequence([ + Sleep::for(3)->seconds(), + ]); + } + + public function testItCanUseUSleep() + { + Sleep::fake(); + + Sleep::usleep(3); + + Sleep::assertSequence([ + Sleep::for(3)->microseconds(), + ]); + } + + public function testItCanSleepTillGivenTime() + { + Sleep::fake(); + Carbon::setTestNow(now()->startOfDay()); + + Sleep::until(now()->addMinute()); + + Sleep::assertSequence([ + Sleep::for(60)->seconds(), + ]); + } + + public function testItCanSleepTillGivenTimestamp() + { + Sleep::fake(); + Carbon::setTestNow(now()->startOfDay()); + + Sleep::until(now()->addMinute()->timestamp); + + Sleep::assertSequence([ + Sleep::for(60)->seconds(), + ]); + } + + public function testItCanSleepTillGivenTimestampAsString() + { + Sleep::fake(); + Carbon::setTestNow(now()->startOfDay()); + + Sleep::until((string) now()->addMinute()->timestamp); + + Sleep::assertSequence([ + Sleep::for(60)->seconds(), + ]); + } + + public function testItCanSleepTillGivenTimestampAsStringWithMilliseconds() + { + Sleep::fake(); + Carbon::setTestNow('2000-01-01 00:00:00.000'); // 946684800 + + Sleep::until('946684899.123'); + + Sleep::assertSequence([ + Sleep::for(1)->minute() + ->and(39)->seconds() + ->and(123)->milliseconds(), + ]); + } + + public function testItSleepsForZeroTimeWithNegativeDateTime() + { + Sleep::fake(); + Carbon::setTestNow(now()->startOfDay()); + + Sleep::until(now()->subMinutes(100)); + + Sleep::assertSequence([ + Sleep::for(0)->seconds(), + ]); + } + + public function testSleepingForZeroTime() + { + Sleep::fake(); + + Sleep::for(0)->seconds(); + + try { + Sleep::assertSequence([ + Sleep::for(1)->seconds(), + ]); + $this->fail(); + } catch (AssertionFailedError $e) { + $this->assertSame("Expected sleep duration of [1 second] but actually slept for [0 microseconds].\nFailed asserting that false is true.", $e->getMessage()); + } + } + + public function testItFailsWhenSequenceContainsTooManySleeps() + { + Sleep::fake(); + + Sleep::for(1)->seconds(); + + try { + Sleep::assertSequence([ + Sleep::for(1)->seconds(), + Sleep::for(1)->seconds(), + ]); + $this->fail(); + } catch (AssertionFailedError $e) { + $this->assertSame("Expected [2] sleeps but found [1].\nFailed asserting that 1 is identical to 2.", $e->getMessage()); + } + } + + public function testSilentlySetsDurationToZeroForNegativeValues() + { + Sleep::fake(); + + Sleep::for(-1)->seconds(); + + Sleep::assertSequence([ + Sleep::for(0)->seconds(), + ]); + } + + public function testItDoesntCaptureAssertionInstances() + { + Sleep::fake(); + + Sleep::for(1)->second(); + + Sleep::assertSequence([ + Sleep::for(1)->second(), + ]); + + try { + Sleep::assertSequence([ + Sleep::for(1)->second(), + Sleep::for(1)->second(), + ]); + $this->fail(); + } catch (AssertionFailedError $e) { + $this->assertSame("Expected [2] sleeps but found [1].\nFailed asserting that 1 is identical to 2.", $e->getMessage()); + } + } + + public function testAssertNeverSlept() + { + Sleep::fake(); + + Sleep::assertNeverSlept(); + + Sleep::for(1)->seconds(); + + try { + Sleep::assertNeverSlept(); + $this->fail(); + } catch (AssertionFailedError $e) { + $this->assertSame("Expected [0] sleeps but found [1].\nFailed asserting that 1 is identical to 0.", $e->getMessage()); + } + } + + public function testAssertNeverAgainstZeroSecondSleep() + { + Sleep::fake(); + + Sleep::assertNeverSlept(); + + Sleep::for(0)->seconds(); + + try { + Sleep::assertNeverSlept(); + $this->fail(); + } catch (AssertionFailedError $e) { + $this->assertSame("Expected [0] sleeps but found [1].\nFailed asserting that 1 is identical to 0.", $e->getMessage()); + } + } + + public function testItCanAssertNoSleepingOccurred() + { + Sleep::fake(); + + Sleep::assertInsomniac(); + + Sleep::for(0)->second(); + + // we still have not slept... + Sleep::assertInsomniac(); + + Sleep::for(1)->second(); + + try { + Sleep::assertInsomniac(); + $this->fail(); + } catch (AssertionFailedError $e) { + $this->assertSame("Unexpected sleep duration of [1 second] found.\nFailed asserting that 1000000 is identical to 0.", $e->getMessage()); + } + } + + public function testItCanAssertSleepCount() + { + Sleep::fake(); + + Sleep::assertSleptTimes(0); + + Sleep::for(1)->second(); + + Sleep::assertSleptTimes(1); + + try { + Sleep::assertSleptTimes(0); + $this->fail(); + } catch (AssertionFailedError $e) { + $this->assertSame("Expected [0] sleeps but found [1].\nFailed asserting that 1 is identical to 0.", $e->getMessage()); + } + + try { + Sleep::assertSleptTimes(2); + $this->fail(); + } catch (AssertionFailedError $e) { + $this->assertSame("Expected [2] sleeps but found [1].\nFailed asserting that 1 is identical to 2.", $e->getMessage()); + } + } + + public function testAssertSlept() + { + Sleep::fake(); + + Sleep::assertSlept(fn () => true, 0); + + try { + Sleep::assertSlept(fn () => true); + $this->fail(); + } catch (AssertionFailedError $e) { + $this->assertSame("The expected sleep was found [0] times instead of [1].\nFailed asserting that 0 is identical to 1.", $e->getMessage()); + } + + Sleep::for(5)->seconds(); + + Sleep::assertSlept(fn (CarbonInterval $duration) => (float) $duration->totalSeconds === 5.0); + + try { + Sleep::assertSlept(fn (CarbonInterval $duration) => (float) $duration->totalSeconds === 5.0, 2); + $this->fail(); + } catch (AssertionFailedError $e) { + $this->assertSame("The expected sleep was found [1] times instead of [2].\nFailed asserting that 1 is identical to 2.", $e->getMessage()); + } + + try { + Sleep::assertSlept(fn (CarbonInterval $duration) => (float) $duration->totalSeconds === 6.0); + $this->fail(); + } catch (AssertionFailedError $e) { + $this->assertSame("The expected sleep was found [0] times instead of [1].\nFailed asserting that 0 is identical to 1.", $e->getMessage()); + } + } + + public function testItCanCreateMacrosViaMacroable() + { + Sleep::fake(); + + Sleep::macro('forSomeConfiguredAmountOfTime', static function () { + return Sleep::for(3)->seconds(); + }); + + Sleep::macro('useSomeOtherAmountOfTime', function () { + /** @var Sleep $this */ + return $this->duration(1.234)->seconds(); + }); + + Sleep::macro('andSomeMoreGranularControl', function () { + /** @var Sleep $this */ + return $this->and(567)->microseconds(); + }); + + // A static macro can be referenced + $sleep = Sleep::forSomeConfiguredAmountOfTime(); + $this->assertSame((float) $sleep->duration->totalMicroseconds, 3000000.0); + + // A macro can specify a new duration + $sleep = $sleep->useSomeOtherAmountOfTime(); + $this->assertSame((float) $sleep->duration->totalMicroseconds, 1234000.0); + + // A macro can supplement an existing duration + $sleep = $sleep->andSomeMoreGranularControl(); + $this->assertSame((float) $sleep->duration->totalMicroseconds, 1234567.0); + } + + public function testItCanReplacePreviouslyDefinedDurations() + { + Sleep::fake(); + + Sleep::macro('setDuration', function ($duration) { + return $this->duration($duration); + }); + + $sleep = Sleep::for(1)->second(); + $this->assertSame((float) $sleep->duration->totalMicroseconds, 1000000.0); + + $sleep->setDuration(2)->second(); + $this->assertSame((float) $sleep->duration->totalMicroseconds, 2000000.0); + + $sleep->setDuration(500)->milliseconds(); + $this->assertSame((float) $sleep->duration->totalMicroseconds, 500000.0); + } + + public function testItCanSleepConditionallyWhen() + { + Sleep::fake(); + + // Control test + Sleep::assertSlept(fn () => true, 0); + Sleep::for(1)->second(); + Sleep::assertSlept(fn () => true, 1); + Sleep::fake(); + Sleep::assertSlept(fn () => true, 0); + + // Reset + Sleep::fake(); + + // Will not sleep if `when()` yields `false` + Sleep::for(1)->second()->when(false); + Sleep::for(1)->second()->when(fn () => false); + + // Will not sleep if `unless()` yields `true` + Sleep::for(1)->second()->unless(true); + Sleep::for(1)->second()->unless(fn () => true); + + // Finish 'do not sleep' tests - assert no sleeping occurred + Sleep::assertSlept(fn () => true, 0); + + // Will sleep if `when()` yields `true` + Sleep::for(1)->second()->when(true); + Sleep::assertSlept(fn () => true, 1); + Sleep::for(1)->second()->when(fn () => true); + Sleep::assertSlept(fn () => true, 2); + + // Will sleep if `unless()` yields `false` + Sleep::for(1)->second()->unless(false); + Sleep::assertSlept(fn () => true, 3); + Sleep::for(1)->second()->unless(fn () => false); + Sleep::assertSlept(fn () => true, 4); + } + + public function testItCanRegisterCallbacksToRunInTests() + { + $countA = 0; + $countB = 0; + Sleep::fake(); + Sleep::whenFakingSleep(function ($duration) use (&$countA) { + $countA += $duration->totalMilliseconds; + }); + Sleep::whenFakingSleep(function ($duration) use (&$countB) { + $countB += $duration->totalMilliseconds; + }); + + Sleep::for(1)->millisecond(); + Sleep::for(2)->millisecond(); + + Sleep::assertSequence([ + Sleep::for(1)->millisecond(), + Sleep::for(2)->millisecond(), + ]); + + $this->assertSame(3.0, (float) $countA); + $this->assertSame(3.0, (float) $countB); + } + + public function testItDoesntRunCallbacksWhenNotFaking() + { + Sleep::whenFakingSleep(function () { + throw new Exception('Should not run without faking.'); + }); + + Sleep::for(1)->millisecond(); + + $this->assertTrue(true); + } + + public function testItDoesNotSyncCarbon() + { + Carbon::setTestNow('2000-01-01 00:00:00'); + Sleep::fake(); + + Sleep::for(5)->minutes() + ->and(3)->seconds(); + + Sleep::assertSequence([ + Sleep::for(303)->seconds(), + ]); + $this->assertSame('2000-01-01 00:00:00', Date::now()->toDateTimeString()); + } + + public function testItCanSyncCarbon() + { + Carbon::setTestNow('2000-01-01 00:00:00'); + Sleep::fake(); + Sleep::syncWithCarbon(); + + Sleep::for(5)->minutes() + ->and(3)->seconds(); + + Sleep::assertSequence([ + Sleep::for(303)->seconds(), + ]); + $this->assertSame('2000-01-01 00:05:03', Date::now()->toDateTimeString()); + } + + #[TestWith([ + 'syncWithCarbon' => true, + 'datetime' => '2000-01-01 00:05:03', + ])] + #[TestWith([ + 'syncWithCarbon' => false, + 'datetime' => '2000-01-01 00:00:00', + ])] + public function testFakeCanSetSyncWithCarbon(bool $syncWithCarbon, string $datetime) + { + Carbon::setTestNow('2000-01-01 00:00:00'); + Sleep::fake(syncWithCarbon: $syncWithCarbon); + + Sleep::for(5)->minutes() + ->and(3)->seconds(); + + Sleep::assertSequence([ + Sleep::for(303)->seconds(), + ]); + $this->assertSame($datetime, Date::now()->toDateTimeString()); + } + + public function testFakeDoesNotNeedToSyncWithCarbon() + { + Carbon::setTestNow('2000-01-01 00:00:00'); + Sleep::fake(); + + Sleep::for(5)->minutes() + ->and(3)->seconds(); + + Sleep::assertSequence([ + Sleep::for(303)->seconds(), + ]); + $this->assertSame('2000-01-01 00:00:00', Date::now()->toDateTimeString()); + } +} diff --git a/tests/Support/StrCacheTest.php b/tests/Support/StrCacheTest.php new file mode 100644 index 000000000..3a71478ba --- /dev/null +++ b/tests/Support/StrCacheTest.php @@ -0,0 +1,196 @@ +assertSame('foo_bar', StrCache::snake('fooBar')); + $this->assertSame('foo_bar_baz', StrCache::snake('fooBarBaz')); + } + + public function testSnakeWithCustomDelimiter() + { + $this->assertSame('foo-bar', StrCache::snake('fooBar', '-')); + } + + public function testSnakeReturnsCachedResult() + { + $first = StrCache::snake('fooBar'); + $second = StrCache::snake('fooBar'); + + $this->assertSame($first, $second); + $this->assertSame('foo_bar', $second); + } + + public function testCamel() + { + $this->assertSame('fooBar', StrCache::camel('foo_bar')); + $this->assertSame('fooBarBaz', StrCache::camel('foo_bar_baz')); + } + + public function testCamelReturnsCachedResult() + { + $first = StrCache::camel('foo_bar'); + $second = StrCache::camel('foo_bar'); + + $this->assertSame($first, $second); + } + + public function testStudly() + { + $this->assertSame('FooBar', StrCache::studly('foo_bar')); + $this->assertSame('FooBarBaz', StrCache::studly('foo_bar_baz')); + } + + public function testStudlyReturnsCachedResult() + { + $first = StrCache::studly('foo_bar'); + $second = StrCache::studly('foo_bar'); + + $this->assertSame($first, $second); + } + + public function testPlural() + { + $this->assertSame('users', StrCache::plural('user')); + $this->assertSame('children', StrCache::plural('child')); + } + + public function testPluralReturnsCachedResult() + { + $first = StrCache::plural('user'); + $second = StrCache::plural('user'); + + $this->assertSame($first, $second); + } + + public function testPluralWithCountNotCached() + { + $this->assertSame('user', StrCache::plural('user', 1)); + $this->assertSame('users', StrCache::plural('user', 3)); + } + + public function testSingular() + { + $this->assertSame('user', StrCache::singular('users')); + $this->assertSame('child', StrCache::singular('children')); + } + + public function testSingularReturnsCachedResult() + { + $first = StrCache::singular('users'); + $second = StrCache::singular('users'); + + $this->assertSame($first, $second); + } + + public function testPluralStudly() + { + $this->assertSame('UserProfiles', StrCache::pluralStudly('UserProfile')); + } + + public function testPluralStudlyReturnsCachedResult() + { + $first = StrCache::pluralStudly('UserProfile'); + $second = StrCache::pluralStudly('UserProfile'); + + $this->assertSame($first, $second); + } + + public function testPluralStudlyWithCountNotCached() + { + $this->assertSame('UserProfile', StrCache::pluralStudly('UserProfile', 1)); + $this->assertSame('UserProfiles', StrCache::pluralStudly('UserProfile', 5)); + } + + public function testFlush() + { + StrCache::snake('fooBar'); + StrCache::camel('foo_bar'); + StrCache::studly('foo_bar'); + StrCache::plural('user'); + StrCache::singular('users'); + StrCache::pluralStudly('UserProfile'); + + StrCache::flush(); + + // After flush, results are recomputed (same values, but cache was cleared) + $this->assertSame('foo_bar', StrCache::snake('fooBar')); + $this->assertSame('fooBar', StrCache::camel('foo_bar')); + } + + public function testFlushSnake() + { + StrCache::snake('fooBar'); + StrCache::camel('foo_bar'); + + StrCache::flushSnake(); + + // Camel cache should still work + $this->assertSame('fooBar', StrCache::camel('foo_bar')); + } + + public function testFlushCamel() + { + StrCache::camel('foo_bar'); + + StrCache::flushCamel(); + + // Recomputes after flush + $this->assertSame('fooBar', StrCache::camel('foo_bar')); + } + + public function testFlushStudly() + { + StrCache::studly('foo_bar'); + + StrCache::flushStudly(); + + $this->assertSame('FooBar', StrCache::studly('foo_bar')); + } + + public function testFlushPlural() + { + StrCache::plural('user'); + + StrCache::flushPlural(); + + $this->assertSame('users', StrCache::plural('user')); + } + + public function testFlushSingular() + { + StrCache::singular('users'); + + StrCache::flushSingular(); + + $this->assertSame('user', StrCache::singular('users')); + } + + public function testFlushPluralStudly() + { + StrCache::pluralStudly('UserProfile'); + + StrCache::flushPluralStudly(); + + $this->assertSame('UserProfiles', StrCache::pluralStudly('UserProfile')); + } +} diff --git a/tests/Support/StrTest.php b/tests/Support/StrTest.php deleted file mode 100644 index a8c968fd4..000000000 --- a/tests/Support/StrTest.php +++ /dev/null @@ -1,176 +0,0 @@ -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/SupportArrTest.php b/tests/Support/SupportArrTest.php new file mode 100644 index 000000000..0eec99b1d --- /dev/null +++ b/tests/Support/SupportArrTest.php @@ -0,0 +1,1920 @@ +assertTrue(Arr::accessible([])); + $this->assertTrue(Arr::accessible([1, 2])); + $this->assertTrue(Arr::accessible(['a' => 1, 'b' => 2])); + $this->assertTrue(Arr::accessible(new Collection())); + + $this->assertFalse(Arr::accessible(null)); + $this->assertFalse(Arr::accessible('abc')); + $this->assertFalse(Arr::accessible(new stdClass())); + $this->assertFalse(Arr::accessible((object) ['a' => 1, 'b' => 2])); + $this->assertFalse(Arr::accessible(123)); + $this->assertFalse(Arr::accessible(12.34)); + $this->assertFalse(Arr::accessible(true)); + $this->assertFalse(Arr::accessible(new DateTime())); + $this->assertFalse(Arr::accessible(static fn () => null)); + } + + public function testArrayable(): void + { + $this->assertTrue(Arr::arrayable([])); + $this->assertTrue(Arr::arrayable(new TestArrayableObject())); + $this->assertTrue(Arr::arrayable(new TestJsonableObject())); + $this->assertTrue(Arr::arrayable(new TestJsonSerializeObject())); + $this->assertTrue(Arr::arrayable(new TestTraversableAndJsonSerializableObject())); + + $this->assertFalse(Arr::arrayable(null)); + $this->assertFalse(Arr::arrayable('abc')); + $this->assertFalse(Arr::arrayable(new stdClass())); + $this->assertFalse(Arr::arrayable((object) ['a' => 1, 'b' => 2])); + $this->assertFalse(Arr::arrayable(123)); + $this->assertFalse(Arr::arrayable(12.34)); + $this->assertFalse(Arr::arrayable(true)); + $this->assertFalse(Arr::arrayable(new DateTime())); + $this->assertFalse(Arr::arrayable(static fn () => null)); + } + + public function testAdd() + { + $array = Arr::add(['name' => 'Desk'], 'price', 100); + $this->assertEquals(['name' => 'Desk', 'price' => 100], $array); + + $this->assertEquals(['surname' => 'Mövsümov'], Arr::add([], 'surname', 'Mövsümov')); + $this->assertEquals(['developer' => ['name' => 'Ferid']], Arr::add([], 'developer.name', 'Ferid')); + $this->assertEquals([1 => 'hAz'], Arr::add([], 1, 'hAz')); + $this->assertEquals([1 => [1 => 'hAz']], Arr::add([], 1.1, 'hAz')); + + // Case where the key already exists + $this->assertEquals(['type' => 'Table'], Arr::add(['type' => 'Table'], 'type', 'Chair')); + $this->assertEquals(['category' => ['type' => 'Table']], Arr::add(['category' => ['type' => 'Table']], 'category.type', 'Chair')); + } + + public function testPush() + { + $array = []; + + Arr::push($array, 'office.furniture', 'Desk'); + $this->assertEquals(['Desk'], $array['office']['furniture']); + + Arr::push($array, 'office.furniture', 'Chair', 'Lamp'); + $this->assertEquals(['Desk', 'Chair', 'Lamp'], $array['office']['furniture']); + + $array = []; + + Arr::push($array, null, 'Chris', 'Nuno'); + $this->assertEquals(['Chris', 'Nuno'], $array); + + Arr::push($array, null, 'Taylor'); + $this->assertEquals(['Chris', 'Nuno', 'Taylor'], $array); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Array value for key [foo.bar] must be an array, boolean found.'); + + $array = ['foo' => ['bar' => false]]; + Arr::push($array, 'foo.bar', 'baz'); + } + + public function testCollapse() + { + // Normal case: a two-dimensional array with different elements + $data = [['foo', 'bar'], ['baz']]; + $this->assertEquals(['foo', 'bar', 'baz'], Arr::collapse($data)); + + // Case including numeric and string elements + $array = [[1], [2], [3], ['foo', 'bar']]; + $this->assertEquals([1, 2, 3, 'foo', 'bar'], Arr::collapse($array)); + + // Case with empty two-dimensional arrays + $emptyArray = [[], [], []]; + $this->assertEquals([], Arr::collapse($emptyArray)); + + // Case with both empty arrays and arrays with elements + $mixedArray = [[], [1, 2], [], ['foo', 'bar']]; + $this->assertEquals([1, 2, 'foo', 'bar'], Arr::collapse($mixedArray)); + + // Case including collections and arrays + $collection = collect(['baz', 'boom']); + $mixedArray = [[1], [2], [3], ['foo', 'bar'], $collection]; + $this->assertEquals([1, 2, 3, 'foo', 'bar', 'baz', 'boom'], Arr::collapse($mixedArray)); + } + + public function testCrossJoin() + { + // Single dimension + $this->assertSame( + [[1, 'a'], [1, 'b'], [1, 'c']], + Arr::crossJoin([1], ['a', 'b', 'c']) + ); + + // Square matrix + $this->assertSame( + [[1, 'a'], [1, 'b'], [2, 'a'], [2, 'b']], + Arr::crossJoin([1, 2], ['a', 'b']) + ); + + // Rectangular matrix + $this->assertSame( + [[1, 'a'], [1, 'b'], [1, 'c'], [2, 'a'], [2, 'b'], [2, 'c']], + Arr::crossJoin([1, 2], ['a', 'b', 'c']) + ); + + // 3D matrix + $this->assertSame( + [ + [1, 'a', 'I'], [1, 'a', 'II'], [1, 'a', 'III'], + [1, 'b', 'I'], [1, 'b', 'II'], [1, 'b', 'III'], + [2, 'a', 'I'], [2, 'a', 'II'], [2, 'a', 'III'], + [2, 'b', 'I'], [2, 'b', 'II'], [2, 'b', 'III'], + ], + Arr::crossJoin([1, 2], ['a', 'b'], ['I', 'II', 'III']) + ); + + // With 1 empty dimension + $this->assertEmpty(Arr::crossJoin([], ['a', 'b'], ['I', 'II', 'III'])); + $this->assertEmpty(Arr::crossJoin([1, 2], [], ['I', 'II', 'III'])); + $this->assertEmpty(Arr::crossJoin([1, 2], ['a', 'b'], [])); + + // With empty arrays + $this->assertEmpty(Arr::crossJoin([], [], [])); + $this->assertEmpty(Arr::crossJoin([], [])); + $this->assertEmpty(Arr::crossJoin([])); + + // Not really a proper usage, still, test for preserving BC + $this->assertSame([[]], Arr::crossJoin()); + } + + #[IgnoreDeprecations] + public function testDivide(): void + { + // Test dividing an empty array + [$keys, $values] = Arr::divide([]); + $this->assertEquals([], $keys); + $this->assertEquals([], $values); + + // Test dividing an array with a single key-value pair + [$keys, $values] = Arr::divide(['name' => 'Desk']); + $this->assertEquals(['name'], $keys); + $this->assertEquals(['Desk'], $values); + + // Test dividing an array with multiple key-value pairs + [$keys, $values] = Arr::divide(['name' => 'Desk', 'price' => 100, 'available' => true]); + $this->assertEquals(['name', 'price', 'available'], $keys); + $this->assertEquals(['Desk', 100, true], $values); + + // Test dividing an array with numeric keys + [$keys, $values] = Arr::divide([0 => 'first', 1 => 'second']); + $this->assertEquals([0, 1], $keys); + $this->assertEquals(['first', 'second'], $values); + + // Test dividing an array with null key + [$keys, $values] = Arr::divide([null => 'Null', 1 => 'one']); + $this->assertEquals([null, 1], $keys); + $this->assertEquals(['Null', 'one'], $values); + + // Test dividing an array where the keys are arrays + [$keys, $values] = Arr::divide([['one' => 1, 2 => 'second'], 1 => 'one']); + $this->assertEquals([0, 1], $keys); + $this->assertEquals([['one' => 1, 2 => 'second'], 'one'], $values); + + // Test dividing an array where the values are arrays + [$keys, $values] = Arr::divide(['' => ['one' => 1, 2 => 'second'], 1 => 'one']); + $this->assertEquals([null, 1], $keys); + $this->assertEquals([['one' => 1, 2 => 'second'], 'one'], $values); + + // Test dividing an array where the values are arrays (with null key) + [$keys, $values] = Arr::divide([null => ['one' => 1, 2 => 'second'], 1 => 'one']); + $this->assertEquals([null, 1], $keys); + $this->assertEquals([['one' => 1, 2 => 'second'], 'one'], $values); + } + + public function testDot() + { + $array = Arr::dot(['foo' => ['bar' => 'baz']]); + $this->assertSame(['foo.bar' => 'baz'], $array); + + $array = Arr::dot([10 => 100]); + $this->assertSame([10 => 100], $array); + + $array = Arr::dot(['foo' => [10 => 100]]); + $this->assertSame(['foo.10' => 100], $array); + + $array = Arr::dot([]); + $this->assertSame([], $array); + + $array = Arr::dot(['foo' => []]); + $this->assertSame(['foo' => []], $array); + + $array = Arr::dot(['foo' => ['bar' => []]]); + $this->assertSame(['foo.bar' => []], $array); + + $array = Arr::dot(['name' => 'taylor', 'languages' => ['php' => true]]); + $this->assertSame(['name' => 'taylor', 'languages.php' => true], $array); + + $array = Arr::dot(['user' => ['name' => 'Taylor', 'age' => 25, 'languages' => ['PHP', 'C#']]]); + $this->assertSame([ + 'user.name' => 'Taylor', + 'user.age' => 25, + 'user.languages.0' => 'PHP', + 'user.languages.1' => 'C#', + ], $array); + + $array = Arr::dot(['foo', 'foo' => ['bar' => 'baz', 'baz' => ['a' => 'b']]]); + $this->assertSame([ + 'foo', + 'foo.bar' => 'baz', + 'foo.baz.a' => 'b', + ], $array); + + $array = Arr::dot(['foo' => 'bar', 'empty_array' => [], 'user' => ['name' => 'Taylor'], 'key' => 'value']); + $this->assertSame([ + 'foo' => 'bar', + 'empty_array' => [], + 'user.name' => 'Taylor', + 'key' => 'value', + ], $array); + } + + public function testUndot() + { + $array = Arr::undot([ + 'user.name' => 'Taylor', + 'user.age' => 25, + 'user.languages.0' => 'PHP', + 'user.languages.1' => 'C#', + ]); + $this->assertEquals(['user' => ['name' => 'Taylor', 'age' => 25, 'languages' => ['PHP', 'C#']]], $array); + + $array = Arr::undot([ + 'pagination.previous' => '<<', + 'pagination.next' => '>>', + ]); + $this->assertEquals(['pagination' => ['previous' => '<<', 'next' => '>>']], $array); + + $array = Arr::undot([ + 'foo', + 'foo.bar' => 'baz', + 'foo.baz' => ['a' => 'b'], + ]); + $this->assertEquals(['foo', 'foo' => ['bar' => 'baz', 'baz' => ['a' => 'b']]], $array); + } + + public function testExcept() + { + $array = ['name' => 'taylor', 'age' => 26]; + $this->assertEquals(['age' => 26], Arr::except($array, ['name'])); + $this->assertEquals(['age' => 26], Arr::except($array, 'name')); + + $array = ['name' => 'taylor', 'framework' => ['language' => 'PHP', 'name' => 'Laravel']]; + $this->assertEquals(['name' => 'taylor'], Arr::except($array, 'framework')); + $this->assertEquals(['name' => 'taylor', 'framework' => ['name' => 'Laravel']], Arr::except($array, 'framework.language')); + $this->assertEquals(['framework' => ['language' => 'PHP']], Arr::except($array, ['name', 'framework.name'])); + + $array = [1 => 'hAz', 2 => [5 => 'foo', 12 => 'baz']]; + $this->assertEquals([1 => 'hAz'], Arr::except($array, 2)); + $this->assertEquals([1 => 'hAz', 2 => [12 => 'baz']], Arr::except($array, 2.5)); + } + + public function testExceptValues() + { + $array = ['name' => 'taylor', 'age' => 26, 'city' => 'austin']; + $this->assertEquals(['name' => 'taylor', 'city' => 'austin'], Arr::exceptValues($array, [26])); + $this->assertEquals(['name' => 'taylor', 'city' => 'austin'], Arr::exceptValues($array, 26)); + + $array = ['foo', 'bar', 'baz', 'qux']; + $this->assertEquals([1 => 'bar', 3 => 'qux'], Arr::exceptValues($array, ['foo', 'baz'])); + $this->assertEquals([0 => 'foo', 1 => 'bar', 3 => 'qux'], Arr::exceptValues($array, 'baz')); + + $array = [1, 2, 3, 4, 5]; + $this->assertEquals([0 => 1, 1 => 2, 4 => 5], Arr::exceptValues($array, [3, 4])); + + $array = ['a' => 1, 'b' => 2, 'c' => 1, 'd' => 3]; + $this->assertEquals(['b' => 2, 'd' => 3], Arr::exceptValues($array, 1)); + + $this->assertEquals([], Arr::exceptValues([], 'foo')); + $this->assertEquals(['foo', 'bar'], Arr::exceptValues(['foo', 'bar'], [])); + + $array = [1, '1', 2, '2', 3]; + $this->assertEquals([1 => '1', 3 => '2'], Arr::exceptValues($array, [1, 2, 3], true)); + $this->assertEquals([], Arr::exceptValues($array, [1, 2, 3])); + + $array = ['a' => true, 'b' => false, 'c' => 1, 'd' => 0]; + $this->assertEquals(['a' => true, 'b' => false], Arr::exceptValues($array, [1, 0], true)); + $this->assertEquals([], Arr::exceptValues($array, [1, 0])); + } + + public function testExists() + { + $this->assertTrue(Arr::exists([1], 0)); + $this->assertTrue(Arr::exists([null], 0)); + $this->assertTrue(Arr::exists(['a' => 1], 'a')); + $this->assertTrue(Arr::exists(['a' => null], 'a')); + $this->assertTrue(Arr::exists(new Collection(['a' => null]), 'a')); + + $this->assertFalse(Arr::exists([1], 1)); + $this->assertFalse(Arr::exists([null], 1)); + $this->assertFalse(Arr::exists(['a' => 1], 0)); + $this->assertFalse(Arr::exists(new Collection(['a' => null]), 'b')); + } + + public function testWhereNotNull(): void + { + $array = array_values(Arr::whereNotNull([null, 0, false, '', null, []])); + $this->assertEquals([0, false, '', []], $array); + + $array = array_values(Arr::whereNotNull([1, 2, 3])); + $this->assertEquals([1, 2, 3], $array); + + $array = array_values(Arr::whereNotNull([null, null, null])); + $this->assertEquals([], $array); + + $array = array_values(Arr::whereNotNull(['a', null, 'b', null, 'c'])); + $this->assertEquals(['a', 'b', 'c'], $array); + + $array = array_values(Arr::whereNotNull([null, 1, 'string', 0.0, false, [], $class = new stdClass(), $function = fn () => null])); + $this->assertEquals([1, 'string', 0.0, false, [], $class, $function], $array); + } + + public function testFirst() + { + $array = [100, 200, 300]; + + // Callback is null and array is empty + $this->assertNull(Arr::first([], null)); + $this->assertSame('foo', Arr::first([], null, 'foo')); + $this->assertSame('bar', Arr::first([], null, function () { + return 'bar'; + })); + + // Callback is null and array is not empty + $this->assertEquals(100, Arr::first($array)); + + // Callback is not null and array is not empty + $value = Arr::first($array, function ($value) { + return $value >= 150; + }); + $this->assertEquals(200, $value); + + // Callback is not null, array is not empty but no satisfied item + $value2 = Arr::first($array, function ($value) { + return $value > 300; + }); + $value3 = Arr::first($array, function ($value) { + return $value > 300; + }, 'bar'); + $value4 = Arr::first($array, function ($value) { + return $value > 300; + }, function () { + return 'baz'; + }); + $value5 = Arr::first($array, function ($value, $key) { + return $key < 2; + }); + $this->assertNull($value2); + $this->assertSame('bar', $value3); + $this->assertSame('baz', $value4); + $this->assertEquals(100, $value5); + + $cursor = (function () { + while (false) { + yield 1; + } + })(); + $this->assertNull(Arr::first($cursor)); + } + + public function testFirstWorksWithArrayObject() + { + $arrayObject = new ArrayObject([0, 10, 20]); + + $result = Arr::first($arrayObject, fn ($value) => $value === 0); + + $this->assertSame(0, $result); + } + + public function testJoin() + { + $this->assertSame('a, b, c', Arr::join(['a', 'b', 'c'], ', ')); + + $this->assertSame('a, b and c', Arr::join(['a', 'b', 'c'], ', ', ' and ')); + + $this->assertSame('a and b', Arr::join(['a', 'b'], ', ', ' and ')); + + $this->assertSame('a', Arr::join(['a'], ', ', ' and ')); + + $this->assertSame('', Arr::join([], ', ', ' and ')); + } + + public function testLast() + { + $array = [100, 200, 300]; + + // Callback is null and array is empty + $this->assertNull(Arr::last([], null)); + $this->assertSame('foo', Arr::last([], null, 'foo')); + $this->assertSame('bar', Arr::last([], null, function () { + return 'bar'; + })); + + // Callback is null and array is not empty + $this->assertEquals(300, Arr::last($array)); + + // Callback is not null and array is not empty + $value = Arr::last($array, function ($value) { + return $value < 250; + }); + $this->assertEquals(200, $value); + + // Callback is not null, array is not empty but no satisfied item + $value2 = Arr::last($array, function ($value) { + return $value > 300; + }); + $value3 = Arr::last($array, function ($value) { + return $value > 300; + }, 'bar'); + $value4 = Arr::last($array, function ($value) { + return $value > 300; + }, function () { + return 'baz'; + }); + $value5 = Arr::last($array, function ($value, $key) { + return $key < 2; + }); + $this->assertNull($value2); + $this->assertSame('bar', $value3); + $this->assertSame('baz', $value4); + $this->assertEquals(200, $value5); + } + + public function testFlatten() + { + // Flat arrays are unaffected + $array = ['#foo', '#bar', '#baz']; + $this->assertEquals(['#foo', '#bar', '#baz'], Arr::flatten($array)); + + // Nested arrays are flattened with existing flat items + $array = [['#foo', '#bar'], '#baz']; + $this->assertEquals(['#foo', '#bar', '#baz'], Arr::flatten($array)); + + // Flattened array includes "null" items + $array = [['#foo', null], '#baz', null]; + $this->assertEquals(['#foo', null, '#baz', null], Arr::flatten($array)); + + // Sets of nested arrays are flattened + $array = [['#foo', '#bar'], ['#baz']]; + $this->assertEquals(['#foo', '#bar', '#baz'], Arr::flatten($array)); + + // Deeply nested arrays are flattened + $array = [['#foo', ['#bar']], ['#baz']]; + $this->assertEquals(['#foo', '#bar', '#baz'], Arr::flatten($array)); + + // Nested arrays are flattened alongside arrays + $array = [new Collection(['#foo', '#bar']), ['#baz']]; + $this->assertEquals(['#foo', '#bar', '#baz'], Arr::flatten($array)); + + // Nested arrays containing plain arrays are flattened + $array = [new Collection(['#foo', ['#bar']]), ['#baz']]; + $this->assertEquals(['#foo', '#bar', '#baz'], Arr::flatten($array)); + + // Nested arrays containing arrays are flattened + $array = [['#foo', new Collection(['#bar'])], ['#baz']]; + $this->assertEquals(['#foo', '#bar', '#baz'], Arr::flatten($array)); + + // Nested arrays containing arrays containing arrays are flattened + $array = [['#foo', new Collection(['#bar', ['#zap']])], ['#baz']]; + $this->assertEquals(['#foo', '#bar', '#zap', '#baz'], Arr::flatten($array)); + } + + public function testFlattenWithDepth() + { + // No depth flattens recursively + $array = [['#foo', ['#bar', ['#baz']]], '#zap']; + $this->assertEquals(['#foo', '#bar', '#baz', '#zap'], Arr::flatten($array)); + + // Specifying a depth only flattens to that depth + $array = [['#foo', ['#bar', ['#baz']]], '#zap']; + $this->assertEquals(['#foo', ['#bar', ['#baz']], '#zap'], Arr::flatten($array, 1)); + + $array = [['#foo', ['#bar', ['#baz']]], '#zap']; + $this->assertEquals(['#foo', '#bar', ['#baz'], '#zap'], Arr::flatten($array, 2)); + } + + public function testGet() + { + $array = ['products.desk' => ['price' => 100]]; + $this->assertEquals(['price' => 100], Arr::get($array, 'products.desk')); + + $array = ['products' => ['desk' => ['price' => 100]]]; + $value = Arr::get($array, 'products.desk'); + $this->assertEquals(['price' => 100], $value); + + // Test null array values + $array = ['foo' => null, 'bar' => ['baz' => null]]; + $this->assertNull(Arr::get($array, 'foo', 'default')); + $this->assertNull(Arr::get($array, 'bar.baz', 'default')); + + // Test direct ArrayAccess object + $array = ['products' => ['desk' => ['price' => 100]]]; + $arrayAccessObject = new ArrayObject($array); + $value = Arr::get($arrayAccessObject, 'products.desk'); + $this->assertEquals(['price' => 100], $value); + + // Test array containing ArrayAccess object + $arrayAccessChild = new ArrayObject(['products' => ['desk' => ['price' => 100]]]); + $array = ['child' => $arrayAccessChild]; + $value = Arr::get($array, 'child.products.desk'); + $this->assertEquals(['price' => 100], $value); + + // Test array containing multiple nested ArrayAccess objects + $arrayAccessChild = new ArrayObject(['products' => ['desk' => ['price' => 100]]]); + $arrayAccessParent = new ArrayObject(['child' => $arrayAccessChild]); + $array = ['parent' => $arrayAccessParent]; + $value = Arr::get($array, 'parent.child.products.desk'); + $this->assertEquals(['price' => 100], $value); + + // Test missing ArrayAccess object field + $arrayAccessChild = new ArrayObject(['products' => ['desk' => ['price' => 100]]]); + $arrayAccessParent = new ArrayObject(['child' => $arrayAccessChild]); + $array = ['parent' => $arrayAccessParent]; + $value = Arr::get($array, 'parent.child.desk'); + $this->assertNull($value); + + // Test missing ArrayAccess object field + $arrayAccessObject = new ArrayObject(['products' => ['desk' => null]]); + $array = ['parent' => $arrayAccessObject]; + $value = Arr::get($array, 'parent.products.desk.price'); + $this->assertNull($value); + + // Test null ArrayAccess object fields + $array = new ArrayObject(['foo' => null, 'bar' => new ArrayObject(['baz' => null])]); + $this->assertNull(Arr::get($array, 'foo', 'default')); + $this->assertNull(Arr::get($array, 'bar.baz', 'default')); + + // Test null key returns the whole array + $array = ['foo', 'bar']; + $this->assertEquals($array, Arr::get($array, null)); + + // Test $array not an array + $this->assertSame('default', Arr::get(null, 'foo', 'default')); + $this->assertSame('default', Arr::get(false, 'foo', 'default')); + + // Test $array not an array and key is null + $this->assertSame('default', Arr::get(null, null, 'default')); + + // Test $array is empty and key is null + $this->assertEmpty(Arr::get([], null)); + $this->assertEmpty(Arr::get([], null, 'default')); + + // Test numeric keys + $array = [ + 'products' => [ + ['name' => 'desk'], + ['name' => 'chair'], + ], + ]; + $this->assertSame('desk', Arr::get($array, 'products.0.name')); + $this->assertSame('chair', Arr::get($array, 'products.1.name')); + + // Test return default value for non-existing key. + $array = ['names' => ['developer' => 'taylor']]; + $this->assertSame('dayle', Arr::get($array, 'names.otherDeveloper', 'dayle')); + $this->assertSame('dayle', Arr::get($array, 'names.otherDeveloper', function () { + return 'dayle'; + })); + + // Test array has a null key + $this->assertSame('bar', Arr::get(['' => 'bar'], '')); + $this->assertSame('bar', Arr::get(['' => ['' => 'bar']], '.')); + } + + public function testItGetsAString() + { + $test_array = ['string' => 'foo bar', 'integer' => 1234]; + + // Test string values are returned as strings + $this->assertSame( + 'foo bar', + Arr::string($test_array, 'string') + ); + + // Test that default string values are returned for missing keys + $this->assertSame( + 'default', + Arr::string($test_array, 'missing_key', 'default') + ); + + // Test that an exception is raised if the value is not a string + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageMatches('#^Array value for key \[integer\] must be a string, (.*) found.#'); + Arr::string($test_array, 'integer'); + } + + public function testItGetsAnInteger() + { + $test_array = ['string' => 'foo bar', 'integer' => 1234]; + + // Test integer values are returned as integers + $this->assertSame( + 1234, + Arr::integer($test_array, 'integer') + ); + + // Test that default integer values are returned for missing keys + $this->assertSame( + 999, + Arr::integer($test_array, 'missing_key', 999) + ); + + // Test that an exception is raised if the value is not an integer + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageMatches('#^Array value for key \[string\] must be an integer, (.*) found.#'); + Arr::integer($test_array, 'string'); + } + + public function testItGetsAFloat() + { + $test_array = ['string' => 'foo bar', 'float' => 12.34]; + + // Test float values are returned as floats + $this->assertSame( + 12.34, + Arr::float($test_array, 'float') + ); + + // Test that default float values are returned for missing keys + $this->assertSame( + 56.78, + Arr::float($test_array, 'missing_key', 56.78) + ); + + // Test that an exception is raised if the value is not a float + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageMatches('#^Array value for key \[string\] must be a float, (.*) found.#'); + Arr::float($test_array, 'string'); + } + + public function testItGetsABoolean() + { + $test_array = ['string' => 'foo bar', 'boolean' => true]; + + // Test boolean values are returned as booleans + $this->assertSame( + true, + Arr::boolean($test_array, 'boolean') + ); + + // Test that default boolean values are returned for missing keys + $this->assertSame( + true, + Arr::boolean($test_array, 'missing_key', true) + ); + + // Test that an exception is raised if the value is not a boolean + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageMatches('#^Array value for key \[string\] must be a boolean, (.*) found.#'); + Arr::boolean($test_array, 'string'); + } + + public function testItGetsAnArray() + { + $test_array = ['string' => 'foo bar', 'array' => ['foo', 'bar']]; + + // Test array values are returned as arrays + $this->assertSame( + ['foo', 'bar'], + Arr::array($test_array, 'array') + ); + + // Test that default array values are returned for missing keys + $this->assertSame( + [1, 'two'], + Arr::array($test_array, 'missing_key', [1, 'two']) + ); + + // Test that an exception is raised if the value is not an array + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageMatches('#^Array value for key \[string\] must be an array, (.*) found.#'); + Arr::array($test_array, 'string'); + } + + public function testHas() + { + $array = ['products.desk' => ['price' => 100]]; + $this->assertTrue(Arr::has($array, 'products.desk')); + + $array = ['products' => ['desk' => ['price' => 100]]]; + $this->assertTrue(Arr::has($array, 'products.desk')); + $this->assertTrue(Arr::has($array, 'products.desk.price')); + $this->assertFalse(Arr::has($array, 'products.foo')); + $this->assertFalse(Arr::has($array, 'products.desk.foo')); + + $array = ['foo' => null, 'bar' => ['baz' => null]]; + $this->assertTrue(Arr::has($array, 'foo')); + $this->assertTrue(Arr::has($array, 'bar.baz')); + + $array = new ArrayObject(['foo' => 10, 'bar' => new ArrayObject(['baz' => 10])]); + $this->assertTrue(Arr::has($array, 'foo')); + $this->assertTrue(Arr::has($array, 'bar')); + $this->assertTrue(Arr::has($array, 'bar.baz')); + $this->assertFalse(Arr::has($array, 'xxx')); + $this->assertFalse(Arr::has($array, 'xxx.yyy')); + $this->assertFalse(Arr::has($array, 'foo.xxx')); + $this->assertFalse(Arr::has($array, 'bar.xxx')); + + $array = new ArrayObject(['foo' => null, 'bar' => new ArrayObject(['baz' => null])]); + $this->assertTrue(Arr::has($array, 'foo')); + $this->assertTrue(Arr::has($array, 'bar.baz')); + + $array = ['foo', 'bar']; + $this->assertFalse(Arr::has($array, null)); + + $this->assertFalse(Arr::has(null, 'foo')); + $this->assertFalse(Arr::has(false, 'foo')); + + $this->assertFalse(Arr::has(null, null)); + $this->assertFalse(Arr::has([], null)); + + $array = ['products' => ['desk' => ['price' => 100]]]; + $this->assertTrue(Arr::has($array, ['products.desk'])); + $this->assertTrue(Arr::has($array, ['products.desk', 'products.desk.price'])); + $this->assertTrue(Arr::has($array, ['products', 'products'])); + $this->assertFalse(Arr::has($array, ['foo'])); + $this->assertFalse(Arr::has($array, [])); + $this->assertFalse(Arr::has($array, ['products.desk', 'products.price'])); + + $array = [ + 'products' => [ + ['name' => 'desk'], + ], + ]; + $this->assertTrue(Arr::has($array, 'products.0.name')); + $this->assertFalse(Arr::has($array, 'products.0.price')); + + $this->assertFalse(Arr::has([], [null])); + $this->assertFalse(Arr::has(null, [null])); + + $this->assertTrue(Arr::has(['' => 'some'], '')); + $this->assertTrue(Arr::has(['' => 'some'], [''])); + $this->assertFalse(Arr::has([''], '')); + $this->assertFalse(Arr::has([], '')); + $this->assertFalse(Arr::has([], [''])); + } + + public function testHasAllMethod() + { + $array = ['name' => 'Taylor', 'age' => '', 'city' => null]; + $this->assertTrue(Arr::hasAll($array, 'name')); + $this->assertTrue(Arr::hasAll($array, 'age')); + $this->assertFalse(Arr::hasAll($array, ['age', 'car'])); + $this->assertTrue(Arr::hasAll($array, 'city')); + $this->assertFalse(Arr::hasAll($array, ['city', 'some'])); + $this->assertTrue(Arr::hasAll($array, ['name', 'age', 'city'])); + $this->assertFalse(Arr::hasAll($array, ['name', 'age', 'city', 'country'])); + + $array = ['user' => ['name' => 'Taylor']]; + $this->assertTrue(Arr::hasAll($array, 'user.name')); + $this->assertFalse(Arr::hasAll($array, 'user.age')); + + $array = ['name' => 'Taylor', 'age' => '', 'city' => null]; + $this->assertFalse(Arr::hasAll($array, 'foo')); + $this->assertFalse(Arr::hasAll($array, 'bar')); + $this->assertFalse(Arr::hasAll($array, 'baz')); + $this->assertFalse(Arr::hasAll($array, 'bah')); + $this->assertFalse(Arr::hasAll($array, ['foo', 'bar', 'baz', 'bar'])); + } + + public function testHasAnyMethod() + { + $array = ['name' => 'Taylor', 'age' => '', 'city' => null]; + $this->assertTrue(Arr::hasAny($array, 'name')); + $this->assertTrue(Arr::hasAny($array, 'age')); + $this->assertTrue(Arr::hasAny($array, 'city')); + $this->assertFalse(Arr::hasAny($array, 'foo')); + $this->assertTrue(Arr::hasAny($array, 'name', 'email')); + $this->assertTrue(Arr::hasAny($array, ['name', 'email'])); + + $array = ['name' => 'Taylor', 'email' => 'foo']; + $this->assertTrue(Arr::hasAny($array, 'name', 'email')); + $this->assertFalse(Arr::hasAny($array, 'surname', 'password')); + $this->assertFalse(Arr::hasAny($array, ['surname', 'password'])); + + $array = ['foo' => ['bar' => null, 'baz' => '']]; + $this->assertTrue(Arr::hasAny($array, 'foo.bar')); + $this->assertTrue(Arr::hasAny($array, 'foo.baz')); + $this->assertFalse(Arr::hasAny($array, 'foo.bax')); + $this->assertTrue(Arr::hasAny($array, ['foo.bax', 'foo.baz'])); + } + + public function testEvery() + { + $this->assertFalse(Arr::every([1, 2], fn ($value, $key) => is_string($value))); + $this->assertFalse(Arr::every(['foo', 2], fn ($value, $key) => is_string($value))); + $this->assertTrue(Arr::every(['foo', 'bar'], fn ($value, $key) => is_string($value))); + } + + public function testSome() + { + $this->assertFalse(Arr::some([1, 2], fn ($value, $key) => is_string($value))); + $this->assertTrue(Arr::some(['foo', 2], fn ($value, $key) => is_string($value))); + $this->assertTrue(Arr::some(['foo', 'bar'], fn ($value, $key) => is_string($value))); + } + + public function testIsAssoc() + { + $this->assertTrue(Arr::isAssoc(['a' => 'a', 0 => 'b'])); + $this->assertTrue(Arr::isAssoc([1 => 'a', 0 => 'b'])); + $this->assertTrue(Arr::isAssoc([1 => 'a', 2 => 'b'])); + $this->assertFalse(Arr::isAssoc([0 => 'a', 1 => 'b'])); + $this->assertFalse(Arr::isAssoc(['a', 'b'])); + + $this->assertFalse(Arr::isAssoc([])); + $this->assertFalse(Arr::isAssoc([1, 2, 3])); + $this->assertFalse(Arr::isAssoc(['foo', 2, 3])); + $this->assertFalse(Arr::isAssoc([0 => 'foo', 'bar'])); + + $this->assertTrue(Arr::isAssoc([1 => 'foo', 'bar'])); + $this->assertTrue(Arr::isAssoc([0 => 'foo', 'bar' => 'baz'])); + $this->assertTrue(Arr::isAssoc([0 => 'foo', 2 => 'bar'])); + $this->assertTrue(Arr::isAssoc(['foo' => 'bar', 'baz' => 'qux'])); + } + + public function testIsList() + { + $this->assertTrue(Arr::isList([])); + $this->assertTrue(Arr::isList([1, 2, 3])); + $this->assertTrue(Arr::isList(['foo', 2, 3])); + $this->assertTrue(Arr::isList(['foo', 'bar'])); + $this->assertTrue(Arr::isList([0 => 'foo', 'bar'])); + $this->assertTrue(Arr::isList([0 => 'foo', 1 => 'bar'])); + + $this->assertFalse(Arr::isList([-1 => 1])); + $this->assertFalse(Arr::isList([-1 => 1, 0 => 2])); + $this->assertFalse(Arr::isList([1 => 'foo', 'bar'])); + $this->assertFalse(Arr::isList([1 => 'foo', 0 => 'bar'])); + $this->assertFalse(Arr::isList([0 => 'foo', 'bar' => 'baz'])); + $this->assertFalse(Arr::isList([0 => 'foo', 2 => 'bar'])); + $this->assertFalse(Arr::isList(['foo' => 'bar', 'baz' => 'qux'])); + } + + public function testOnly() + { + $array = ['name' => 'Desk', 'price' => 100, 'orders' => 10]; + $array = Arr::only($array, ['name', 'price']); + $this->assertEquals(['name' => 'Desk', 'price' => 100], $array); + $this->assertEmpty(Arr::only($array, ['nonExistingKey'])); + + $this->assertEmpty(Arr::only($array, null)); + + // Test with array having numeric keys + $this->assertEquals(['foo'], Arr::only(['foo', 'bar', 'baz'], 0)); + $this->assertEquals([1 => 'bar', 2 => 'baz'], Arr::only(['foo', 'bar', 'baz'], [1, 2])); + $this->assertEmpty(Arr::only(['foo', 'bar', 'baz'], [3])); + + // Test with array having numeric key and string key + $this->assertEquals(['foo'], Arr::only(['foo', 'bar' => 'baz'], 0)); + $this->assertEquals(['bar' => 'baz'], Arr::only(['foo', 'bar' => 'baz'], 'bar')); + } + + public function testOnlyValues() + { + $array = ['name' => 'taylor', 'age' => 26, 'city' => 'austin']; + $this->assertEquals(['age' => 26], Arr::onlyValues($array, [26])); + $this->assertEquals(['age' => 26], Arr::onlyValues($array, 26)); + + $array = ['foo', 'bar', 'baz', 'qux']; + $this->assertEquals([0 => 'foo', 2 => 'baz'], Arr::onlyValues($array, ['foo', 'baz'])); + $this->assertEquals([2 => 'baz'], Arr::onlyValues($array, 'baz')); + + $array = [1, 2, 3, 4, 5]; + $this->assertEquals([2 => 3, 3 => 4], Arr::onlyValues($array, [3, 4])); + + $array = ['a' => 1, 'b' => 2, 'c' => 1, 'd' => 3]; + $this->assertEquals(['a' => 1, 'c' => 1], Arr::onlyValues($array, 1)); + + $this->assertEquals([], Arr::onlyValues([], 'foo')); + $this->assertEquals([], Arr::onlyValues(['foo', 'bar'], [])); + + $array = [1, '1', 2, '2', 3]; + $this->assertEquals([0 => 1, 2 => 2, 4 => 3], Arr::onlyValues($array, [1, 2, 3], true)); + $this->assertEquals([0 => 1, 1 => '1', 2 => 2, 3 => '2', 4 => 3], Arr::onlyValues($array, [1, 2, 3])); + + $array = ['a' => true, 'b' => false, 'c' => 1, 'd' => 0]; + $this->assertEquals(['c' => 1, 'd' => 0], Arr::onlyValues($array, [1, 0], true)); + $this->assertEquals(['a' => true, 'b' => false, 'c' => 1, 'd' => 0], Arr::onlyValues($array, [1, 0])); + } + + public function testPluck() + { + $data = [ + 'post-1' => [ + 'comments' => [ + 'tags' => [ + '#foo', '#bar', + ], + ], + ], + 'post-2' => [ + 'comments' => [ + 'tags' => [ + '#baz', + ], + ], + ], + ]; + + $this->assertEquals([ + 0 => [ + 'tags' => [ + '#foo', '#bar', + ], + ], + 1 => [ + 'tags' => [ + '#baz', + ], + ], + ], Arr::pluck($data, 'comments')); + + $this->assertEquals([['#foo', '#bar'], ['#baz']], Arr::pluck($data, 'comments.tags')); + $this->assertEquals([null, null], Arr::pluck($data, 'foo')); + $this->assertEquals([null, null], Arr::pluck($data, 'foo.bar')); + + $array = [ + ['developer' => ['name' => 'Taylor']], + ['developer' => ['name' => 'Abigail']], + ]; + + $array = Arr::pluck($array, 'developer.name'); + + $this->assertEquals(['Taylor', 'Abigail'], $array); + } + + public function testPluckWithArrayValue() + { + $array = [ + ['developer' => ['name' => 'Taylor']], + ['developer' => ['name' => 'Abigail']], + ]; + $array = Arr::pluck($array, ['developer', 'name']); + $this->assertEquals(['Taylor', 'Abigail'], $array); + } + + public function testPluckWithKeys() + { + $array = [ + ['name' => 'Taylor', 'role' => 'developer'], + ['name' => 'Abigail', 'role' => 'developer'], + ]; + + $test1 = Arr::pluck($array, 'role', 'name'); + $test2 = Arr::pluck($array, null, 'name'); + + $this->assertEquals([ + 'Taylor' => 'developer', + 'Abigail' => 'developer', + ], $test1); + + $this->assertEquals([ + 'Taylor' => ['name' => 'Taylor', 'role' => 'developer'], + 'Abigail' => ['name' => 'Abigail', 'role' => 'developer'], + ], $test2); + } + + public function testPluckWithCarbonKeys() + { + $array = [ + ['start' => new Carbon('2017-07-25 00:00:00'), 'end' => new Carbon('2017-07-30 00:00:00')], + ]; + $array = Arr::pluck($array, 'end', 'start'); + $this->assertEquals(['2017-07-25 00:00:00' => '2017-07-30 00:00:00'], $array); + } + + public function testArrayPluckWithArrayAndObjectValues() + { + $array = [(object) ['name' => 'taylor', 'email' => 'foo'], ['name' => 'dayle', 'email' => 'bar']]; + $this->assertEquals(['taylor', 'dayle'], Arr::pluck($array, 'name')); + $this->assertEquals(['taylor' => 'foo', 'dayle' => 'bar'], Arr::pluck($array, 'email', 'name')); + } + + public function testArrayPluckWithNestedKeys() + { + $array = [['user' => ['taylor', 'otwell']], ['user' => ['dayle', 'rees']]]; + $this->assertEquals(['taylor', 'dayle'], Arr::pluck($array, 'user.0')); + $this->assertEquals(['taylor', 'dayle'], Arr::pluck($array, ['user', 0])); + $this->assertEquals(['taylor' => 'otwell', 'dayle' => 'rees'], Arr::pluck($array, 'user.1', 'user.0')); + $this->assertEquals(['taylor' => 'otwell', 'dayle' => 'rees'], Arr::pluck($array, ['user', 1], ['user', 0])); + } + + public function testArrayPluckWithNestedArrays() + { + $array = [ + [ + 'account' => 'a', + 'users' => [ + ['first' => 'taylor', 'last' => 'otwell', 'email' => 'taylorotwell@gmail.com'], + ], + ], + [ + 'account' => 'b', + 'users' => [ + ['first' => 'abigail', 'last' => 'otwell'], + ['first' => 'dayle', 'last' => 'rees'], + ], + ], + ]; + + $this->assertEquals([['taylor'], ['abigail', 'dayle']], Arr::pluck($array, 'users.*.first')); + $this->assertEquals(['a' => ['taylor'], 'b' => ['abigail', 'dayle']], Arr::pluck($array, 'users.*.first', 'account')); + $this->assertEquals([['taylorotwell@gmail.com'], [null, null]], Arr::pluck($array, 'users.*.email')); + } + + public function testMap() + { + $data = ['first' => 'taylor', 'last' => 'otwell']; + $mapped = Arr::map($data, function ($value, $key) { + return $key . '-' . strrev($value); + }); + $this->assertEquals(['first' => 'first-rolyat', 'last' => 'last-llewto'], $mapped); + $this->assertEquals(['first' => 'taylor', 'last' => 'otwell'], $data); + } + + public function testMapWithEmptyArray() + { + $mapped = Arr::map([], static function ($value, $key) { + return $key . '-' . $value; + }); + $this->assertEquals([], $mapped); + } + + public function testMapNullValues() + { + $data = ['first' => 'taylor', 'last' => null]; + $mapped = Arr::map($data, static function ($value, $key) { + return $key . '-' . $value; + }); + $this->assertEquals(['first' => 'first-taylor', 'last' => 'last-'], $mapped); + } + + public function testMapWithKeys() + { + $data = [ + ['name' => 'Blastoise', 'type' => 'Water', 'idx' => 9], + ['name' => 'Charmander', 'type' => 'Fire', 'idx' => 4], + ['name' => 'Dragonair', 'type' => 'Dragon', 'idx' => 148], + ]; + + $data = Arr::mapWithKeys($data, function ($pokemon) { + return [$pokemon['name'] => $pokemon['type']]; + }); + + $this->assertEquals( + ['Blastoise' => 'Water', 'Charmander' => 'Fire', 'Dragonair' => 'Dragon'], + $data + ); + } + + public function testMapByReference() + { + $data = ['first' => 'taylor', 'last' => 'otwell']; + $mapped = Arr::map($data, 'strrev'); + + $this->assertEquals(['first' => 'rolyat', 'last' => 'llewto'], $mapped); + $this->assertEquals(['first' => 'taylor', 'last' => 'otwell'], $data); + } + + public function testMapSpread() + { + $c = [[1, 'a'], [2, 'b']]; + + $result = Arr::mapSpread($c, function ($number, $character) { + return "{$number}-{$character}"; + }); + $this->assertEquals(['1-a', '2-b'], $result); + + $result = Arr::mapSpread($c, function ($number, $character, $key) { + return "{$number}-{$character}-{$key}"; + }); + $this->assertEquals(['1-a-0', '2-b-1'], $result); + } + + #[IgnoreDeprecations] + public function testPrepend() + { + $array = Arr::prepend(['one', 'two', 'three', 'four'], 'zero'); + $this->assertEquals(['zero', 'one', 'two', 'three', 'four'], $array); + + $array = Arr::prepend(['one' => 1, 'two' => 2], 0, 'zero'); + $this->assertEquals(['zero' => 0, 'one' => 1, 'two' => 2], $array); + + $array = Arr::prepend(['one' => 1, 'two' => 2], 0, ''); + $this->assertEquals(['' => 0, 'one' => 1, 'two' => 2], $array); + + $array = Arr::prepend(['one' => 1, 'two' => 2], 0, null); + $this->assertEquals([null => 0, 'one' => 1, 'two' => 2], $array); + + $array = Arr::prepend(['one', 'two'], null, ''); + $this->assertEquals(['' => null, 'one', 'two'], $array); + + $array = Arr::prepend([], 'zero'); + $this->assertEquals(['zero'], $array); + + $array = Arr::prepend([''], 'zero'); + $this->assertEquals(['zero', ''], $array); + + $array = Arr::prepend(['one', 'two'], ['zero']); + $this->assertEquals([['zero'], 'one', 'two'], $array); + + $array = Arr::prepend(['one', 'two'], ['zero'], 'key'); + $this->assertEquals(['key' => ['zero'], 'one', 'two'], $array); + + $array = Arr::prepend(['one', 'two'], ['zero'], ''); + $this->assertEquals(['one', 'two', '' => ['zero']], $array); + + $array = Arr::prepend(['one', 'two', '' => 'three'], ['zero'], ''); + $this->assertEquals(['one', 'two', '' => ['zero']], $array); + + $array = Arr::prepend(['one', 'two'], ['zero'], null); + $this->assertEquals(['one', 'two', null => ['zero']], $array); + + $array = Arr::prepend(['one', 'two', '' => 'three'], ['zero'], null); + $this->assertEquals(['one', 'two', null => ['zero']], $array); + } + + public function testPull() + { + $array = ['name' => 'Desk', 'price' => 100]; + $name = Arr::pull($array, 'name'); + $this->assertSame('Desk', $name); + $this->assertSame(['price' => 100], $array); + + // Only works on first level keys + $array = ['joe@example.com' => 'Joe', 'jane@localhost' => 'Jane']; + $name = Arr::pull($array, 'joe@example.com'); + $this->assertSame('Joe', $name); + $this->assertSame(['jane@localhost' => 'Jane'], $array); + + // Does not work for nested keys + $array = ['emails' => ['joe@example.com' => 'Joe', 'jane@localhost' => 'Jane']]; + $name = Arr::pull($array, 'emails.joe@example.com'); + $this->assertNull($name); + $this->assertSame(['emails' => ['joe@example.com' => 'Joe', 'jane@localhost' => 'Jane']], $array); + + // Works with int keys + $array = ['First', 'Second']; + $first = Arr::pull($array, 0); + $this->assertSame('First', $first); + $this->assertSame([1 => 'Second'], $array); + } + + public function testQuery() + { + $this->assertSame('', Arr::query([])); + $this->assertSame('foo=bar', Arr::query(['foo' => 'bar'])); + $this->assertSame('foo=bar&bar=baz', Arr::query(['foo' => 'bar', 'bar' => 'baz'])); + $this->assertSame('foo=bar&bar=1', Arr::query(['foo' => 'bar', 'bar' => true])); + $this->assertSame('foo=bar', Arr::query(['foo' => 'bar', 'bar' => null])); + $this->assertSame('foo=bar&bar=', Arr::query(['foo' => 'bar', 'bar' => ''])); + } + + public function testRandom() + { + $random = Arr::random(['foo', 'bar', 'baz']); + $this->assertContains($random, ['foo', 'bar', 'baz']); + + $random = Arr::random(['foo', 'bar', 'baz'], 0); + $this->assertIsArray($random); + $this->assertCount(0, $random); + + $random = Arr::random(['foo', 'bar', 'baz'], 1); + $this->assertIsArray($random); + $this->assertCount(1, $random); + $this->assertContains($random[0], ['foo', 'bar', 'baz']); + + $random = Arr::random(['foo', 'bar', 'baz'], 2); + $this->assertIsArray($random); + $this->assertCount(2, $random); + $this->assertContains($random[0], ['foo', 'bar', 'baz']); + $this->assertContains($random[1], ['foo', 'bar', 'baz']); + + $random = Arr::random(['foo', 'bar', 'baz'], '0'); + $this->assertIsArray($random); + $this->assertCount(0, $random); + + $random = Arr::random(['foo', 'bar', 'baz'], '1'); + $this->assertIsArray($random); + $this->assertCount(1, $random); + $this->assertContains($random[0], ['foo', 'bar', 'baz']); + + $random = Arr::random(['foo', 'bar', 'baz'], '2'); + $this->assertIsArray($random); + $this->assertCount(2, $random); + $this->assertContains($random[0], ['foo', 'bar', 'baz']); + $this->assertContains($random[1], ['foo', 'bar', 'baz']); + + // preserve keys + $random = Arr::random(['one' => 'foo', 'two' => 'bar', 'three' => 'baz'], 2, true); + $this->assertIsArray($random); + $this->assertCount(2, $random); + $this->assertCount(2, array_intersect_assoc(['one' => 'foo', 'two' => 'bar', 'three' => 'baz'], $random)); + } + + public function testRandomNotIncrementingKeys() + { + $random = Arr::random(['foo' => 'foo', 'bar' => 'bar', 'baz' => 'baz']); + $this->assertContains($random, ['foo', 'bar', 'baz']); + } + + public function testRandomOnEmptyArray() + { + $random = Arr::random([], 0); + $this->assertIsArray($random); + $this->assertCount(0, $random); + + $random = Arr::random([], '0'); + $this->assertIsArray($random); + $this->assertCount(0, $random); + } + + public function testRandomThrowsAnErrorWhenRequestingMoreItemsThanAreAvailable() + { + $exceptions = 0; + + try { + Arr::random([]); + } catch (InvalidArgumentException) { + ++$exceptions; + } + + try { + Arr::random([], 1); + } catch (InvalidArgumentException) { + ++$exceptions; + } + + try { + Arr::random([], 2); + } catch (InvalidArgumentException) { + ++$exceptions; + } + + $this->assertSame(3, $exceptions); + } + + public function testSet() + { + $array = ['products' => ['desk' => ['price' => 100]]]; + Arr::set($array, 'products.desk.price', 200); + $this->assertEquals(['products' => ['desk' => ['price' => 200]]], $array); + + // No key is given + $array = ['products' => ['desk' => ['price' => 100]]]; + Arr::set($array, null, ['price' => 300]); + $this->assertSame(['price' => 300], $array); + + // The key doesn't exist at the depth + $array = ['products' => 'desk']; + Arr::set($array, 'products.desk.price', 200); + $this->assertSame(['products' => ['desk' => ['price' => 200]]], $array); + + // No corresponding key exists + $array = ['products']; + Arr::set($array, 'products.desk.price', 200); + $this->assertSame(['products', 'products' => ['desk' => ['price' => 200]]], $array); + + $array = ['products' => ['desk' => ['price' => 100]]]; + Arr::set($array, 'table', 500); + $this->assertSame(['products' => ['desk' => ['price' => 100]], 'table' => 500], $array); + + $array = ['products' => ['desk' => ['price' => 100]]]; + Arr::set($array, 'table.price', 350); + $this->assertSame(['products' => ['desk' => ['price' => 100]], 'table' => ['price' => 350]], $array); + + $array = []; + Arr::set($array, 'products.desk.price', 200); + $this->assertSame(['products' => ['desk' => ['price' => 200]]], $array); + + // Override + $array = ['products' => 'table']; + Arr::set($array, 'products.desk.price', 300); + $this->assertSame(['products' => ['desk' => ['price' => 300]]], $array); + + $array = [1 => 'test']; + $this->assertEquals([1 => 'hAz'], Arr::set($array, 1, 'hAz')); + } + + public function testShuffleProducesDifferentShuffles() + { + $input = range('a', 'z'); + + $this->assertFalse( + Arr::shuffle($input) === Arr::shuffle($input) && Arr::shuffle($input) === Arr::shuffle($input), + "The shuffles produced the same output each time, which shouldn't happen." + ); + } + + public function testShuffleActuallyShuffles() + { + $input = range('a', 'z'); + + $this->assertFalse( + Arr::shuffle($input) === $input && Arr::shuffle($input) === $input, + "The shuffles were unshuffled each time, which shouldn't happen." + ); + } + + public function testShuffleKeepsSameValues() + { + $input = range('a', 'z'); + $shuffled = Arr::shuffle($input); + sort($shuffled); + + $this->assertEquals($input, $shuffled); + } + + public function testSoleReturnsFirstItemInCollectionIfOnlyOneExists() + { + $this->assertSame('foo', Arr::sole(['foo'])); + + $array = [ + ['name' => 'foo'], + ['name' => 'bar'], + ]; + + $this->assertSame( + ['name' => 'foo'], + Arr::sole($array, fn (array $value) => $value['name'] === 'foo') + ); + } + + public function testSoleThrowsExceptionIfNoItemsExist() + { + $this->expectException(ItemNotFoundException::class); + + Arr::sole(['foo'], fn (string $value) => $value === 'baz'); + } + + public function testSoleThrowsExceptionIfMoreThanOneItemExists() + { + $this->expectExceptionObject(new MultipleItemsFoundException(2)); + + Arr::sole(['baz', 'foo', 'baz'], fn (string $value) => $value === 'baz'); + } + + public function testEmptyShuffle() + { + $this->assertEquals([], Arr::shuffle([])); + } + + public function testSort() + { + $unsorted = [ + ['name' => 'Desk'], + ['name' => 'Chair'], + ]; + + $expected = [ + ['name' => 'Chair'], + ['name' => 'Desk'], + ]; + + $sorted = array_values(Arr::sort($unsorted)); + $this->assertEquals($expected, $sorted); + + // sort with closure + $sortedWithClosure = array_values(Arr::sort($unsorted, function ($value) { + return $value['name']; + })); + $this->assertEquals($expected, $sortedWithClosure); + + // sort with dot notation + $sortedWithDotNotation = array_values(Arr::sort($unsorted, 'name')); + $this->assertEquals($expected, $sortedWithDotNotation); + } + + public function testSortDesc() + { + $unsorted = [ + ['name' => 'Chair'], + ['name' => 'Desk'], + ]; + + $expected = [ + ['name' => 'Desk'], + ['name' => 'Chair'], + ]; + + $sorted = array_values(Arr::sortDesc($unsorted)); + $this->assertEquals($expected, $sorted); + + // sort with closure + $sortedWithClosure = array_values(Arr::sortDesc($unsorted, function ($value) { + return $value['name']; + })); + $this->assertEquals($expected, $sortedWithClosure); + + // sort with dot notation + $sortedWithDotNotation = array_values(Arr::sortDesc($unsorted, 'name')); + $this->assertEquals($expected, $sortedWithDotNotation); + } + + public function testSortRecursive() + { + $array = [ + 'users' => [ + [ + // should sort associative arrays by keys + 'name' => 'joe', + 'mail' => 'joe@example.com', + // should sort deeply nested arrays + 'numbers' => [2, 1, 0], + ], + [ + 'name' => 'jane', + 'age' => 25, + ], + ], + 'repositories' => [ + // should use weird `sort()` behavior on arrays of arrays + ['id' => 1], + ['id' => 0], + ], + // should sort non-associative arrays by value + 20 => [2, 1, 0], + 30 => [ + // should sort non-incrementing numerical keys by keys + 2 => 'a', + 1 => 'b', + 0 => 'c', + ], + ]; + + $expect = [ + 20 => [0, 1, 2], + 30 => [ + 0 => 'c', + 1 => 'b', + 2 => 'a', + ], + 'repositories' => [ + ['id' => 0], + ['id' => 1], + ], + 'users' => [ + [ + 'age' => 25, + 'name' => 'jane', + ], + [ + 'mail' => 'joe@example.com', + 'name' => 'joe', + 'numbers' => [0, 1, 2], + ], + ], + ]; + + $this->assertEquals($expect, Arr::sortRecursive($array)); + } + + public function testSortRecursiveDesc() + { + $array = [ + 'empty' => [], + 'nested' => [ + 'level1' => [ + 'level2' => [ + 'level3' => [2, 3, 1], + ], + 'values' => [4, 5, 6], + ], + ], + 'mixed' => [ + 'a' => 1, + 2 => 'b', + 'c' => 3, + 1 => 'd', + ], + 'numbered_index' => [ + 1 => 'e', + 3 => 'c', + 4 => 'b', + 5 => 'a', + 2 => 'd', + ], + ]; + + $expect = [ + 'empty' => [], + 'mixed' => [ + 'c' => 3, + 'a' => 1, + 2 => 'b', + 1 => 'd', + ], + 'nested' => [ + 'level1' => [ + 'values' => [6, 5, 4], + 'level2' => [ + 'level3' => [3, 2, 1], + ], + ], + ], + 'numbered_index' => [ + 5 => 'a', + 4 => 'b', + 3 => 'c', + 2 => 'd', + 1 => 'e', + ], + ]; + + $this->assertEquals($expect, Arr::sortRecursiveDesc($array)); + } + + public function testToCssClasses() + { + $classes = Arr::toCssClasses([ + 'font-bold', + 'mt-4', + ]); + + $this->assertSame('font-bold mt-4', $classes); + + $classes = Arr::toCssClasses([ + 'font-bold', + 'mt-4', + 'ml-2' => true, + 'mr-2' => false, + ]); + + $this->assertSame('font-bold mt-4 ml-2', $classes); + } + + public function testToCssStyles() + { + $styles = Arr::toCssStyles([ + 'font-weight: bold', + 'margin-top: 4px;', + ]); + + $this->assertSame('font-weight: bold; margin-top: 4px;', $styles); + + $styles = Arr::toCssStyles([ + 'font-weight: bold;', + 'margin-top: 4px', + 'margin-left: 2px;' => true, + 'margin-right: 2px' => false, + ]); + + $this->assertSame('font-weight: bold; margin-top: 4px; margin-left: 2px;', $styles); + } + + public function testWhere() + { + $array = [100, '200', 300, '400', 500]; + + $array = Arr::where($array, function ($value, $key) { + return is_string($value); + }); + + $this->assertEquals([1 => '200', 3 => '400'], $array); + } + + public function testWhereKey() + { + $array = ['10' => 1, 'foo' => 3, 20 => 2]; + + $array = Arr::where($array, function ($value, $key) { + return is_numeric($key); + }); + + $this->assertEquals(['10' => 1, 20 => 2], $array); + } + + public function testForget() + { + $array = ['products' => ['desk' => ['price' => 100]]]; + Arr::forget($array, null); + $this->assertEquals(['products' => ['desk' => ['price' => 100]]], $array); + + $array = ['products' => ['desk' => ['price' => 100]]]; + Arr::forget($array, []); + $this->assertEquals(['products' => ['desk' => ['price' => 100]]], $array); + + $array = ['products' => ['desk' => ['price' => 100]]]; + Arr::forget($array, 'products.desk'); + $this->assertEquals(['products' => []], $array); + + $array = ['products' => ['desk' => ['price' => 100]]]; + Arr::forget($array, 'products.desk.price'); + $this->assertEquals(['products' => ['desk' => []]], $array); + + $array = ['products' => ['desk' => ['price' => 100]]]; + Arr::forget($array, 'products.final.price'); + $this->assertEquals(['products' => ['desk' => ['price' => 100]]], $array); + + $array = ['shop' => ['cart' => [150 => 0]]]; + Arr::forget($array, 'shop.final.cart'); + $this->assertEquals(['shop' => ['cart' => [150 => 0]]], $array); + + $array = ['products' => ['desk' => ['price' => ['original' => 50, 'taxes' => 60]]]]; + Arr::forget($array, 'products.desk.price.taxes'); + $this->assertEquals(['products' => ['desk' => ['price' => ['original' => 50]]]], $array); + + $array = ['products' => ['desk' => ['price' => ['original' => 50, 'taxes' => 60]]]]; + Arr::forget($array, 'products.desk.final.taxes'); + $this->assertEquals(['products' => ['desk' => ['price' => ['original' => 50, 'taxes' => 60]]]], $array); + + $array = ['products' => ['desk' => ['price' => 50], '' => 'something']]; + Arr::forget($array, ['products.amount.all', 'products.desk.price']); + $this->assertEquals(['products' => ['desk' => [], '' => 'something']], $array); + + // Only works on first level keys + $array = ['joe@example.com' => 'Joe', 'jane@example.com' => 'Jane']; + Arr::forget($array, 'joe@example.com'); + $this->assertEquals(['jane@example.com' => 'Jane'], $array); + + // Does not work for nested keys + $array = ['emails' => ['joe@example.com' => ['name' => 'Joe'], 'jane@localhost' => ['name' => 'Jane']]]; + Arr::forget($array, ['emails.joe@example.com', 'emails.jane@localhost']); + $this->assertEquals(['emails' => ['joe@example.com' => ['name' => 'Joe']]], $array); + + $array = ['name' => 'hAz', '1' => 'test', 2 => 'bAz']; + Arr::forget($array, 1); + $this->assertEquals(['name' => 'hAz', 2 => 'bAz'], $array); + + $array = [2 => [1 => 'products', 3 => 'users']]; + Arr::forget($array, 2.3); + $this->assertEquals([2 => [1 => 'products']], $array); + } + + public function testFrom() + { + $this->assertSame(['foo' => 'bar'], Arr::from(['foo' => 'bar'])); + $this->assertSame(['foo' => 'bar'], Arr::from((object) ['foo' => 'bar'])); + $this->assertSame(['foo' => 'bar'], Arr::from(new TestArrayableObject())); + $this->assertSame(['foo' => 'bar'], Arr::from(new TestJsonableObject())); + $this->assertSame(['foo' => 'bar'], Arr::from(new TestJsonSerializeObject())); + $this->assertSame(['foo'], Arr::from(new TestJsonSerializeWithScalarValueObject())); + + $this->assertSame(['name' => 'A'], Arr::from(TestEnum::A)); + $this->assertSame(['name' => 'A', 'value' => 1], Arr::from(TestBackedEnum::A)); + $this->assertSame(['name' => 'A', 'value' => 'A'], Arr::from(TestStringBackedEnum::A)); + + $subject = [new stdClass(), new stdClass()]; + $items = new TestTraversableAndJsonSerializableObject($subject); + $this->assertSame($subject, Arr::from($items)); + + $items = new WeakMap(); + $items[$temp = new class {}] = 'bar'; + $this->assertSame(['bar'], Arr::from($items)); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Items cannot be represented by a scalar value.'); + Arr::from(123); + } + + public function testWrap() + { + $string = 'a'; + $array = ['a']; + $object = new stdClass(); + $object->value = 'a'; + $this->assertEquals(['a'], Arr::wrap($string)); + $this->assertEquals($array, Arr::wrap($array)); + $this->assertEquals([$object], Arr::wrap($object)); + $this->assertEquals([], Arr::wrap(null)); + $this->assertEquals([null], Arr::wrap([null])); + $this->assertEquals([null, null], Arr::wrap([null, null])); + $this->assertEquals([''], Arr::wrap('')); + $this->assertEquals([''], Arr::wrap([''])); + $this->assertEquals([false], Arr::wrap(false)); + $this->assertEquals([false], Arr::wrap([false])); + $this->assertEquals([0], Arr::wrap(0)); + + $obj = new stdClass(); + $obj->value = 'a'; + $obj = unserialize(serialize($obj)); + $this->assertEquals([$obj], Arr::wrap($obj)); + $this->assertSame($obj, Arr::wrap($obj)[0]); + } + + public function testSortByMany() + { + $unsorted = [ + ['name' => 'John', 'age' => 8, 'meta' => ['key' => 3]], + ['name' => 'John', 'age' => 10, 'meta' => ['key' => 5]], + ['name' => 'Dave', 'age' => 10, 'meta' => ['key' => 3]], + ['name' => 'John', 'age' => 8, 'meta' => ['key' => 2]], + ]; + + // sort using keys + $sorted = array_values(Arr::sort($unsorted, [ + 'name', + 'age', + 'meta.key', + ])); + $this->assertEquals([ + ['name' => 'Dave', 'age' => 10, 'meta' => ['key' => 3]], + ['name' => 'John', 'age' => 8, 'meta' => ['key' => 2]], + ['name' => 'John', 'age' => 8, 'meta' => ['key' => 3]], + ['name' => 'John', 'age' => 10, 'meta' => ['key' => 5]], + ], $sorted); + + // sort with order + $sortedWithOrder = array_values(Arr::sort($unsorted, [ + 'name', + ['age', false], + ['meta.key', true], + ])); + $this->assertEquals([ + ['name' => 'Dave', 'age' => 10, 'meta' => ['key' => 3]], + ['name' => 'John', 'age' => 10, 'meta' => ['key' => 5]], + ['name' => 'John', 'age' => 8, 'meta' => ['key' => 2]], + ['name' => 'John', 'age' => 8, 'meta' => ['key' => 3]], + ], $sortedWithOrder); + + // sort using callable + $sortedWithCallable = array_values(Arr::sort($unsorted, [ + function ($a, $b) { + return $a['name'] <=> $b['name']; + }, + function ($a, $b) { + return $b['age'] <=> $a['age']; + }, + ['meta.key', true], + ])); + $this->assertEquals([ + ['name' => 'Dave', 'age' => 10, 'meta' => ['key' => 3]], + ['name' => 'John', 'age' => 10, 'meta' => ['key' => 5]], + ['name' => 'John', 'age' => 8, 'meta' => ['key' => 2]], + ['name' => 'John', 'age' => 8, 'meta' => ['key' => 3]], + ], $sortedWithCallable); + } + + public function testKeyBy() + { + $array = [ + ['id' => '123', 'data' => 'abc'], + ['id' => '345', 'data' => 'def'], + ['id' => '498', 'data' => 'hgi'], + ]; + + $this->assertEquals([ + '123' => ['id' => '123', 'data' => 'abc'], + '345' => ['id' => '345', 'data' => 'def'], + '498' => ['id' => '498', 'data' => 'hgi'], + ], Arr::keyBy($array, 'id')); + } + + public function testPrependKeysWith() + { + $array = [ + 'id' => '123', + 'data' => '456', + 'list' => [1, 2, 3], + 'meta' => [ + 'key' => 1, + ], + ]; + + $this->assertEquals([ + 'test.id' => '123', + 'test.data' => '456', + 'test.list' => [1, 2, 3], + 'test.meta' => [ + 'key' => 1, + ], + ], Arr::prependKeysWith($array, 'test.')); + } + + public function testTake(): void + { + $array = [1, 2, 3, 4, 5, 6]; + + // Test with a positive limit, should return the first 'limit' elements. + $this->assertEquals([1, 2, 3], Arr::take($array, 3)); + + // Test with a negative limit, should return the last 'abs(limit)' elements. + $this->assertEquals([4, 5, 6], Arr::take($array, -3)); + + // Test with zero limit, should return an empty array. + $this->assertEquals([], Arr::take($array, 0)); + + // Test with a limit greater than the array size, should return the entire array. + $this->assertEquals([1, 2, 3, 4, 5, 6], Arr::take($array, 10)); + + // Test with a negative limit greater than the array size, should return the entire array. + $this->assertEquals([1, 2, 3, 4, 5, 6], Arr::take($array, -10)); + } + + public function testSelect() + { + $array = [ + [ + 'name' => 'Taylor', + 'role' => 'Developer', + 'age' => 1, + ], + [ + 'name' => 'Abigail', + 'role' => 'Infrastructure', + 'age' => 2, + ], + ]; + + $this->assertEquals([ + [ + 'name' => 'Taylor', + 'age' => 1, + ], + [ + 'name' => 'Abigail', + 'age' => 2, + ], + ], Arr::select($array, ['name', 'age'])); + + $this->assertEquals([ + [ + 'name' => 'Taylor', + ], + [ + 'name' => 'Abigail', + ], + ], Arr::select($array, 'name')); + + $this->assertEquals([ + [], + [], + ], Arr::select($array, 'nonExistingKey')); + + $this->assertEquals([ + [], + [], + ], Arr::select($array, null)); + } + + public function testReject() + { + $array = [1, 2, 3, 4, 5, 6]; + + // Test rejection behavior (removing even numbers) + $result = Arr::reject($array, function ($value) { + return $value % 2 === 0; + }); + + $this->assertEquals([ + 0 => 1, + 2 => 3, + 4 => 5, + ], $result); + + // Test key preservation with associative array + $assocArray = ['a' => 1, 'b' => 2, 'c' => 3, 'd' => 4]; + + $result = Arr::reject($assocArray, function ($value) { + return $value > 2; + }); + + $this->assertEquals([ + 'a' => 1, + 'b' => 2, + ], $result); + } + + public function testPartition() + { + $array = ['John', 'Jane', 'Greg']; + + $result = Arr::partition($array, fn (string $value) => str_contains($value, 'J')); + + $this->assertEquals([[0 => 'John', 1 => 'Jane'], [2 => 'Greg']], $result); + } +} diff --git a/tests/Support/SupportBinaryCodecTest.php b/tests/Support/SupportBinaryCodecTest.php new file mode 100644 index 000000000..d767c194e --- /dev/null +++ b/tests/Support/SupportBinaryCodecTest.php @@ -0,0 +1,186 @@ +getProperty('customCodecs'); + $property->setValue(null, []); + + parent::tearDown(); + } + + public function testFormatsReturnsDefaultFormats() + { + $formats = BinaryCodec::formats(); + + $this->assertContains('uuid', $formats); + $this->assertContains('ulid', $formats); + } + + public function testRegisterAddsCustomFormat() + { + BinaryCodec::register('hex', fn ($v) => bin2hex($v ?? ''), fn ($v) => hex2bin($v ?? '')); + + $this->assertContains('hex', BinaryCodec::formats()); + } + + public function testRegisterOverridesDefaultFormat() + { + BinaryCodec::register('uuid', fn ($v) => 'custom-encode', fn ($v) => 'custom-decode'); + + $this->assertSame('custom-encode', BinaryCodec::encode('test', 'uuid')); + $this->assertSame('custom-decode', BinaryCodec::decode('test', 'uuid')); + } + + #[DataProvider('nullAndEmptyProvider')] + public function testEncodeReturnsNullForNullAndEmpty($value) + { + $this->assertNull(BinaryCodec::encode($value, 'uuid')); + $this->assertNull(BinaryCodec::encode($value, 'ulid')); + } + + #[DataProvider('nullAndEmptyProvider')] + public function testDecodeReturnsNullForNullAndEmpty($value) + { + $this->assertNull(BinaryCodec::decode($value, 'uuid')); + $this->assertNull(BinaryCodec::decode($value, 'ulid')); + } + + public static function nullAndEmptyProvider(): array + { + return [ + 'null' => [null], + 'empty string' => [''], + ]; + } + + public function testEncodeThrowsOnInvalidFormat() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Format [invalid] is invalid.'); + + BinaryCodec::encode('value', 'invalid'); + } + + public function testDecodeThrowsOnInvalidFormat() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Format [invalid] is invalid.'); + + BinaryCodec::decode('value', 'invalid'); + } + + public function testUuidEncodeFromString() + { + $uuid = '550e8400-e29b-41d4-a716-446655440000'; + + $this->assertSame(Uuid::fromString($uuid)->getBytes(), BinaryCodec::encode($uuid, 'uuid')); + } + + public function testUuidEncodeFromBinary() + { + $bytes = Uuid::fromString('550e8400-e29b-41d4-a716-446655440000')->getBytes(); + + $this->assertSame($bytes, BinaryCodec::encode($bytes, 'uuid')); + } + + public function testUuidEncodeFromInstance() + { + $uuid = Uuid::fromString('550e8400-e29b-41d4-a716-446655440000'); + + $this->assertSame($uuid->getBytes(), BinaryCodec::encode($uuid, 'uuid')); + } + + public function testUuidDecodeFromBinary() + { + $uuid = '550e8400-e29b-41d4-a716-446655440000'; + $bytes = Uuid::fromString($uuid)->getBytes(); + + $this->assertSame($uuid, BinaryCodec::decode($bytes, 'uuid')); + } + + public function testUuidDecodeFromString() + { + $uuid = '550e8400-e29b-41d4-a716-446655440000'; + + $this->assertSame($uuid, BinaryCodec::decode($uuid, 'uuid')); + } + + public function testUlidEncodeFromString() + { + $ulid = '01ARZ3NDEKTSV4RRFFQ69G5FAV'; + + $this->assertSame(Ulid::fromString($ulid)->toBinary(), BinaryCodec::encode($ulid, 'ulid')); + } + + public function testUlidEncodeFromBinary() + { + $bytes = Ulid::fromString('01ARZ3NDEKTSV4RRFFQ69G5FAV')->toBinary(); + + $this->assertSame($bytes, BinaryCodec::encode($bytes, 'ulid')); + } + + public function testUlidEncodeFromInstance() + { + $ulid = Ulid::fromString('01ARZ3NDEKTSV4RRFFQ69G5FAV'); + + $this->assertSame($ulid->toBinary(), BinaryCodec::encode($ulid, 'ulid')); + } + + public function testUlidDecodeFromBinary() + { + $ulid = '01ARZ3NDEKTSV4RRFFQ69G5FAV'; + $bytes = Ulid::fromString($ulid)->toBinary(); + + $this->assertSame($ulid, BinaryCodec::decode($bytes, 'ulid')); + } + + public function testUlidDecodeFromString() + { + $ulid = '01ARZ3NDEKTSV4RRFFQ69G5FAV'; + + $this->assertSame($ulid, BinaryCodec::decode($ulid, 'ulid')); + } + + public function testIsBinary() + { + // Non-string values + $this->assertFalse(BinaryCodec::isBinary(null)); + $this->assertFalse(BinaryCodec::isBinary(123)); + $this->assertFalse(BinaryCodec::isBinary([])); + + // Empty string + $this->assertFalse(BinaryCodec::isBinary('')); + + // Valid UTF-8 strings + $this->assertFalse(BinaryCodec::isBinary('hello')); + $this->assertFalse(BinaryCodec::isBinary('héllo')); + $this->assertFalse(BinaryCodec::isBinary('日本語')); + + // Binary data with null byte + $this->assertTrue(BinaryCodec::isBinary("hello\0world")); + $this->assertTrue(BinaryCodec::isBinary("\0")); + + // Invalid UTF-8 sequences + $this->assertTrue(BinaryCodec::isBinary("\xFF\xFE")); + $this->assertTrue(BinaryCodec::isBinary(random_bytes(16))); + } +} diff --git a/tests/Support/SupportCapsuleManagerTraitTest.php b/tests/Support/SupportCapsuleManagerTraitTest.php new file mode 100644 index 000000000..95476ff3b --- /dev/null +++ b/tests/Support/SupportCapsuleManagerTraitTest.php @@ -0,0 +1,40 @@ +setupContainer($app); + $this->assertEquals($app, $this->getContainer()); + $this->assertInstanceOf(Fluent::class, $app['config']); + } + + public function testSetupContainerForCapsuleWhenConfigIsBound() + { + $app = new Container(new DefinitionSource([])); + $app['config'] = new Repository([]); + + $this->setupContainer($app); + $this->assertEquals($app, $this->getContainer()); + $this->assertInstanceOf(Repository::class, $app['config']); + } +} diff --git a/tests/Support/SupportCarbonTest.php b/tests/Support/SupportCarbonTest.php new file mode 100644 index 000000000..2fceec6fe --- /dev/null +++ b/tests/Support/SupportCarbonTest.php @@ -0,0 +1,147 @@ +now = Carbon::create(2017, 6, 27, 13, 14, 15, 'UTC')); + } + + protected function tearDown(): void + { + Carbon::setTestNow(null); + Carbon::serializeUsing(null); + + parent::tearDown(); + } + + public function testInstance() + { + $this->assertInstanceOf(Carbon::class, $this->now); + $this->assertInstanceOf(DateTimeInterface::class, $this->now); + $this->assertInstanceOf(BaseCarbon::class, $this->now); + $this->assertInstanceOf(Carbon::class, $this->now); + } + + public function testCarbonIsMacroableWhenNotCalledStatically() + { + Carbon::macro('diffInDecades', function (?Carbon $dt = null, $abs = true) { + return (int) ($this->diffInYears($dt, $abs) / 10); + }); + + $this->assertSame(2, $this->now->diffInDecades(Carbon::now()->addYears(25))); + } + + public function testCarbonIsMacroableWhenCalledStatically() + { + Carbon::macro('twoDaysAgoAtNoon', function () { + return Carbon::now()->subDays(2)->setTime(12, 0, 0); + }); + + $this->assertSame('2017-06-25 12:00:00', Carbon::twoDaysAgoAtNoon()->toDateTimeString()); + } + + public function testCarbonRaisesExceptionWhenStaticMacroIsNotFound() + { + $this->expectException(BadMethodCallException::class); + $this->expectExceptionMessage('nonExistingStaticMacro does not exist.'); + + Carbon::nonExistingStaticMacro(); + } + + public function testCarbonRaisesExceptionWhenMacroIsNotFound() + { + $this->expectException(BadMethodCallException::class); + $this->expectExceptionMessage('nonExistingMacro does not exist.'); + + Carbon::now()->nonExistingMacro(); + } + + public function testCarbonAllowsCustomSerializer() + { + Carbon::serializeUsing(function (Carbon $carbon) { + return $carbon->getTimestamp(); + }); + + $result = json_decode(json_encode($this->now), true); + + $this->assertSame(1498569255, $result); + } + + public function testCarbonCanSerializeToJson() + { + $this->assertSame('2017-06-27T13:14:15.000000Z', $this->now->jsonSerialize()); + } + + public function testSetStateReturnsCorrectType() + { + $carbon = Carbon::__set_state([ + 'date' => '2017-06-27 13:14:15.000000', + 'timezone_type' => 3, + 'timezone' => 'UTC', + ]); + + $this->assertInstanceOf(Carbon::class, $carbon); + } + + public function testDeserializationOccursCorrectly() + { + $carbon = new Carbon('2017-06-27 13:14:15.000000'); + $serialized = 'return ' . var_export($carbon, true) . ';'; + $deserialized = eval($serialized); + + $this->assertInstanceOf(Carbon::class, $deserialized); + } + + public function testSetTestNowWillPersistBetweenImmutableAndMutableInstance() + { + Carbon::setTestNow(new Carbon('2017-06-27 13:14:15.000000')); + + $this->assertSame('2017-06-27 13:14:15', Carbon::now()->toDateTimeString()); + $this->assertSame('2017-06-27 13:14:15', BaseCarbon::now()->toDateTimeString()); + $this->assertSame('2017-06-27 13:14:15', BaseCarbonImmutable::now()->toDateTimeString()); + } + + public function testCarbonIsConditionable() + { + $this->assertTrue(Carbon::now()->when(null, fn (Carbon $carbon) => $carbon->addDays(1))->isToday()); + $this->assertTrue(Carbon::now()->when(true, fn (Carbon $carbon) => $carbon->addDays(1))->isTomorrow()); + } + + public function testCreateFromUid() + { + $ulid = Carbon::createFromId('01DXH9C4P0ED4AGJJP9CRKQ55C'); + $this->assertEquals('2020-01-01 19:30:00.000000', $ulid->toDateTimeString('microsecond')); + + $uuidv1 = Carbon::createFromId('71513cb4-f071-11ed-a0cf-325096b39f47'); + $this->assertEquals('2023-05-12 03:02:34.147346', $uuidv1->toDateTimeString('microsecond')); + + $uuidv2 = Carbon::createFromId('000003e8-f072-21ed-9200-325096b39f47'); + $this->assertEquals('2023-05-12 03:06:33.529139', $uuidv2->toDateTimeString('microsecond')); + + $uuidv6 = Carbon::createFromId('1edf0746-5d1c-6ce8-88ad-e0cb4effa035'); + $this->assertEquals('2023-05-12 03:23:43.347428', $uuidv6->toDateTimeString('microsecond')); + + $uuidv7 = Carbon::createFromId('01880dfa-2825-72e4-acbb-b1e4981cf8af'); + $this->assertEquals('2023-05-12 03:21:18.117000', $uuidv7->toDateTimeString('microsecond')); + } +} diff --git a/tests/Support/SupportCollectionTest.php b/tests/Support/SupportCollectionTest.php new file mode 100644 index 000000000..60b29c848 --- /dev/null +++ b/tests/Support/SupportCollectionTest.php @@ -0,0 +1,6194 @@ +assertSame('foo', $c->first()); + } + + #[DataProvider('collectionClassProvider')] + public function testFirstWithCallback($collection) + { + $data = new $collection(['foo', 'bar', 'baz']); + $result = $data->first(function ($value) { + return $value === 'bar'; + }); + $this->assertSame('bar', $result); + } + + #[DataProvider('collectionClassProvider')] + public function testFirstWithCallbackAndDefault($collection) + { + $data = new $collection(['foo', 'bar']); + $result = $data->first(function ($value) { + return $value === 'baz'; + }, 'default'); + $this->assertSame('default', $result); + } + + #[DataProvider('collectionClassProvider')] + public function testFirstWithDefaultAndWithoutCallback($collection) + { + $data = new $collection(); + $result = $data->first(null, 'default'); + $this->assertSame('default', $result); + + $data = new $collection(['foo', 'bar']); + $result = $data->first(null, 'default'); + $this->assertSame('foo', $result); + } + + #[DataProvider('collectionClassProvider')] + public function testSoleReturnsFirstItemInCollectionIfOnlyOneExists($collection) + { + $collection = new $collection([ + ['name' => 'foo'], + ['name' => 'bar'], + ]); + + $this->assertSame(['name' => 'foo'], $collection->where('name', 'foo')->sole()); + $this->assertSame(['name' => 'foo'], $collection->sole('name', '=', 'foo')); + $this->assertSame(['name' => 'foo'], $collection->sole('name', 'foo')); + } + + #[DataProvider('collectionClassProvider')] + public function testSoleThrowsExceptionIfNoItemsExist($collection) + { + $this->expectException(ItemNotFoundException::class); + + $collection = new $collection([ + ['name' => 'foo'], + ['name' => 'bar'], + ]); + + $collection->where('name', 'INVALID')->sole(); + } + + #[DataProvider('collectionClassProvider')] + public function testSoleThrowsExceptionIfMoreThanOneItemExists($collection) + { + $this->expectExceptionObject(new MultipleItemsFoundException(2)); + + $collection = new $collection([ + ['name' => 'foo'], + ['name' => 'foo'], + ['name' => 'bar'], + ]); + + $collection->where('name', 'foo')->sole(); + } + + #[DataProvider('collectionClassProvider')] + public function testSoleReturnsFirstItemInCollectionIfOnlyOneExistsWithCallback($collection) + { + $data = new $collection(['foo', 'bar', 'baz']); + $result = $data->sole(function ($value) { + return $value === 'bar'; + }); + $this->assertSame('bar', $result); + } + + #[DataProvider('collectionClassProvider')] + public function testSoleThrowsExceptionIfNoItemsExistWithCallback($collection) + { + $this->expectException(ItemNotFoundException::class); + + $data = new $collection(['foo', 'bar', 'baz']); + + $data->sole(function ($value) { + return $value === 'invalid'; + }); + } + + #[DataProvider('collectionClassProvider')] + public function testSoleThrowsExceptionIfMoreThanOneItemExistsWithCallback($collection) + { + $this->expectExceptionObject(new MultipleItemsFoundException(2)); + + $data = new $collection(['foo', 'bar', 'bar']); + + $data->sole(function ($value) { + return $value === 'bar'; + }); + } + + #[DataProvider('collectionClassProvider')] + public function testHasSole($collection) + { + $collection = new $collection([ + ['age' => 2], + ['age' => 3], + ]); + + $this->assertFalse($collection->hasSole()); + $this->assertFalse($collection->where('age', 1)->hasSole()); + $this->assertTrue($collection->where('age', 2)->hasSole()); + + $this->assertFalse($collection->hasSole(fn () => true)); + $this->assertFalse($collection->hasSole(fn () => false)); + $this->assertTrue($collection->hasSole(fn ($item) => $item['age'] === 2)); + + $this->assertFalse($collection->hasSole('age', '>', 1)); + $this->assertFalse($collection->hasSole('age', '<', 1)); + $this->assertTrue($collection->hasSole('age', 2)); + + $data = new $collection([ + (object) ['active' => true, 'verified' => true], + (object) ['active' => false, 'verified' => true], + ]); + + $this->assertFalse($data->hasSole->verified); + } + + #[DataProvider('collectionClassProvider')] + public function testHasMany($collection) + { + $collection = new $collection([ + ['age' => 2], + ['age' => 3], + ]); + + $this->assertTrue($collection->hasMany()); + $this->assertFalse($collection->where('age', 1)->hasMany()); + $this->assertFalse($collection->where('age', 2)->hasMany()); + + $this->assertTrue($collection->hasMany(fn () => true)); + $this->assertFalse($collection->hasMany(fn () => false)); + $this->assertFalse($collection->hasMany(fn ($item) => $item['age'] === 2)); + + $this->assertTrue($collection->hasMany('age', '>', 1)); + $this->assertFalse($collection->hasMany('age', '<', 1)); + $this->assertFalse($collection->hasMany('age', 2)); + + $data = new $collection([ + (object) ['active' => true, 'verified' => true], + (object) ['active' => false, 'verified' => true], + ]); + + $this->assertTrue($data->hasMany->verified); + } + + #[DataProvider('collectionClassProvider')] + public function testFirstOrFailReturnsFirstItemInCollection($collection) + { + $collection = new $collection([ + ['name' => 'foo'], + ['name' => 'bar'], + ]); + + $this->assertSame(['name' => 'foo'], $collection->where('name', 'foo')->firstOrFail()); + $this->assertSame(['name' => 'foo'], $collection->firstOrFail('name', '=', 'foo')); + $this->assertSame(['name' => 'foo'], $collection->firstOrFail('name', 'foo')); + } + + #[DataProvider('collectionClassProvider')] + public function testFirstOrFailThrowsExceptionIfNoItemsExist($collection) + { + $this->expectException(ItemNotFoundException::class); + + $collection = new $collection([ + ['name' => 'foo'], + ['name' => 'bar'], + ]); + + $collection->where('name', 'INVALID')->firstOrFail(); + } + + #[DataProvider('collectionClassProvider')] + public function testFirstOrFailDoesntThrowExceptionIfMoreThanOneItemExists($collection) + { + $collection = new $collection([ + ['name' => 'foo'], + ['name' => 'foo'], + ['name' => 'bar'], + ]); + + $this->assertSame(['name' => 'foo'], $collection->where('name', 'foo')->firstOrFail()); + } + + #[DataProvider('collectionClassProvider')] + public function testFirstOrFailReturnsFirstItemInCollectionIfOnlyOneExistsWithCallback($collection) + { + $data = new $collection(['foo', 'bar', 'baz']); + $result = $data->firstOrFail(function ($value) { + return $value === 'bar'; + }); + $this->assertSame('bar', $result); + } + + #[DataProvider('collectionClassProvider')] + public function testFirstOrFailThrowsExceptionIfNoItemsExistWithCallback($collection) + { + $this->expectException(ItemNotFoundException::class); + + $data = new $collection(['foo', 'bar', 'baz']); + + $data->firstOrFail(function ($value) { + return $value === 'invalid'; + }); + } + + #[DataProvider('collectionClassProvider')] + public function testFirstOrFailDoesntThrowExceptionIfMoreThanOneItemExistsWithCallback($collection) + { + $data = new $collection(['foo', 'bar', 'bar']); + + $this->assertSame( + 'bar', + $data->firstOrFail(function ($value) { + return $value === 'bar'; + }) + ); + } + + #[DataProvider('collectionClassProvider')] + public function testFirstOrFailStopsIteratingAtFirstMatch($collection) + { + $data = new $collection([ + function () { + return false; + }, + function () { + return true; + }, + function () { + throw new Exception(); + }, + ]); + + $this->assertNotNull($data->firstOrFail(function ($callback) { + return $callback(); + })); + } + + #[DataProvider('collectionClassProvider')] + public function testFirstWhere($collection) + { + $data = new $collection([ + ['material' => 'paper', 'type' => 'book'], + ['material' => 'rubber', 'type' => 'gasket'], + ]); + + $this->assertSame('book', $data->firstWhere('material', 'paper')['type']); + $this->assertSame('gasket', $data->firstWhere('material', 'rubber')['type']); + $this->assertNull($data->firstWhere('material', 'nonexistent')); + $this->assertNull($data->firstWhere('nonexistent', 'key')); + + $this->assertSame('book', $data->firstWhere(fn ($value) => $value['material'] === 'paper')['type']); + $this->assertSame('gasket', $data->firstWhere(fn ($value) => $value['material'] === 'rubber')['type']); + $this->assertNull($data->firstWhere(fn ($value) => $value['material'] === 'nonexistent')); + $this->assertNull($data->firstWhere(fn ($value) => ($value['nonexistent'] ?? null) === 'key')); + } + + #[DataProvider('collectionClassProvider')] + public function testFirstWhereUsingEnum($collection) + { + $data = new $collection([ + ['id' => 1, 'name' => StaffEnum::Taylor], + ['id' => 2, 'name' => StaffEnum::Joe], + ['id' => 3, 'name' => StaffEnum::James], + ]); + + $this->assertSame(1, $data->firstWhere('name', 'Taylor')['id']); + $this->assertSame(2, $data->firstWhere('name', StaffEnum::Joe)['id']); + $this->assertSame(3, $data->firstWhere('name', StaffEnum::James)['id']); + } + + #[DataProvider('collectionClassProvider')] + public function testLastReturnsLastItemInCollection($collection) + { + $c = new $collection(['foo', 'bar']); + $this->assertSame('bar', $c->last()); + + $c = new $collection([]); + $this->assertNull($c->last()); + } + + #[DataProvider('collectionClassProvider')] + public function testLastWithCallback($collection) + { + $data = new $collection([100, 200, 300]); + $result = $data->last(function ($value) { + return $value < 250; + }); + $this->assertEquals(200, $result); + + $result = $data->last(function ($value, $key) { + return $key < 2; + }); + $this->assertEquals(200, $result); + + $result = $data->last(function ($value) { + return $value > 300; + }); + $this->assertNull($result); + } + + #[DataProvider('collectionClassProvider')] + public function testLastWithCallbackAndDefault($collection) + { + $data = new $collection(['foo', 'bar']); + $result = $data->last(function ($value) { + return $value === 'baz'; + }, 'default'); + $this->assertSame('default', $result); + + $data = new $collection(['foo', 'bar', 'Bar']); + $result = $data->last(function ($value) { + return $value === 'bar'; + }, 'default'); + $this->assertSame('bar', $result); + } + + #[DataProvider('collectionClassProvider')] + public function testLastWithDefaultAndWithoutCallback($collection) + { + $data = new $collection(); + $result = $data->last(null, 'default'); + $this->assertSame('default', $result); + } + + public function testPopReturnsAndRemovesLastItemInCollection() + { + $c = new Collection(['foo', 'bar']); + + $this->assertSame('bar', $c->pop()); + $this->assertSame('foo', $c->first()); + } + + public function testPopReturnsAndRemovesLastXItemsInCollection() + { + $c = new Collection(['foo', 'bar', 'baz']); + + $this->assertEquals(new Collection(['baz', 'bar']), $c->pop(2)); + $this->assertSame('foo', $c->first()); + + $this->assertEquals(new Collection(['baz', 'bar', 'foo']), (new Collection(['foo', 'bar', 'baz']))->pop(6)); + } + + public function testShiftReturnsAndRemovesFirstItemInCollection() + { + $data = new Collection(['Taylor', 'Otwell']); + + $this->assertSame('Taylor', $data->shift()); + $this->assertSame('Otwell', $data->first()); + $this->assertSame('Otwell', $data->shift()); + $this->assertNull($data->first()); + } + + public function testShiftReturnsAndRemovesFirstXItemsInCollection() + { + $data = new Collection(['foo', 'bar', 'baz']); + + $this->assertEquals(new Collection(['foo', 'bar']), $data->shift(2)); + $this->assertSame('baz', $data->first()); + + $this->assertEquals(new Collection(['foo', 'bar', 'baz']), (new Collection(['foo', 'bar', 'baz']))->shift(6)); + + $data = new Collection(['foo', 'bar', 'baz']); + + $this->assertEquals(new Collection([]), $data->shift(0)); + $this->assertEquals(collect(['foo', 'bar', 'baz']), $data); + + $this->expectException('InvalidArgumentException'); + (new Collection(['foo', 'bar', 'baz']))->shift(-1); + + $this->expectException('InvalidArgumentException'); + (new Collection(['foo', 'bar', 'baz']))->shift(-2); + } + + public function testShiftReturnsNullOnEmptyCollection() + { + $itemFoo = new stdClass(); + $itemFoo->text = 'f'; + $itemBar = new stdClass(); + $itemBar->text = 'x'; + + $items = collect([$itemFoo, $itemBar]); + + $foo = $items->shift(); + $bar = $items->shift(); + + $this->assertSame('f', $foo?->text); + $this->assertSame('x', $bar?->text); + $this->assertNull($items->shift()); + } + + #[DataProvider('collectionClassProvider')] + public function testSliding($collection) + { + // Default parameters: $size = 2, $step = 1 + $this->assertSame([], $collection::times(0)->sliding()->toArray()); + $this->assertSame([], $collection::times(1)->sliding()->toArray()); + $this->assertSame([[1, 2]], $collection::times(2)->sliding()->toArray()); + $this->assertSame( + [[1, 2], [2, 3]], + $collection::times(3)->sliding()->map->values()->toArray() + ); + + // Custom step: $size = 2, $step = 3 + $this->assertSame([], $collection::times(1)->sliding(2, 3)->toArray()); + $this->assertSame([[1, 2]], $collection::times(2)->sliding(2, 3)->toArray()); + $this->assertSame([[1, 2]], $collection::times(3)->sliding(2, 3)->toArray()); + $this->assertSame([[1, 2]], $collection::times(4)->sliding(2, 3)->toArray()); + $this->assertSame( + [[1, 2], [4, 5]], + $collection::times(5)->sliding(2, 3)->map->values()->toArray() + ); + + // Custom size: $size = 3, $step = 1 + $this->assertSame([], $collection::times(2)->sliding(3)->toArray()); + $this->assertSame([[1, 2, 3]], $collection::times(3)->sliding(3)->toArray()); + $this->assertSame( + [[1, 2, 3], [2, 3, 4]], + $collection::times(4)->sliding(3)->map->values()->toArray() + ); + $this->assertSame( + [[1, 2, 3], [2, 3, 4]], + $collection::times(4)->sliding(3)->map->values()->toArray() + ); + + // Custom size and custom step: $size = 3, $step = 2 + $this->assertSame([], $collection::times(2)->sliding(3, 2)->toArray()); + $this->assertSame([[1, 2, 3]], $collection::times(3)->sliding(3, 2)->toArray()); + $this->assertSame([[1, 2, 3]], $collection::times(4)->sliding(3, 2)->toArray()); + $this->assertSame( + [[1, 2, 3], [3, 4, 5]], + $collection::times(5)->sliding(3, 2)->map->values()->toArray() + ); + $this->assertSame( + [[1, 2, 3], [3, 4, 5]], + $collection::times(6)->sliding(3, 2)->map->values()->toArray() + ); + + // Ensure keys are preserved, and inner chunks are also collections + $chunks = $collection::times(3)->sliding(); + + $this->assertSame([[0 => 1, 1 => 2], [1 => 2, 2 => 3]], $chunks->toArray()); + + $this->assertInstanceOf($collection, $chunks); + $this->assertInstanceOf($collection, $chunks->first()); + $this->assertInstanceOf($collection, $chunks->skip(1)->first()); + + // Test invalid size parameter (size must be at least 1) + // instead of throwing an error. Now it throws InvalidArgumentException. + try { + $collection::times(5)->sliding(0, 1)->toArray(); + $this->fail('Expected InvalidArgumentException for size = 0'); + } catch (InvalidArgumentException $e) { + $this->assertSame('Size value must be at least 1.', $e->getMessage()); + } + + try { + $collection::times(5)->sliding(-1, 1)->toArray(); + $this->fail('Expected InvalidArgumentException for size = -1'); + } catch (InvalidArgumentException $e) { + $this->assertSame('Size value must be at least 1.', $e->getMessage()); + } + + // Test invalid step parameter (step must be at least 1) + // Now it throws InvalidArgumentException with an error message. + try { + $collection::times(5)->sliding(2, 0)->toArray(); + $this->fail('Expected InvalidArgumentException for step = 0'); + } catch (InvalidArgumentException $e) { + $this->assertSame('Step value must be at least 1.', $e->getMessage()); + } + + try { + $collection::times(5)->sliding(2, -1)->toArray(); + $this->fail('Expected InvalidArgumentException for step = -1'); + } catch (InvalidArgumentException $e) { + $this->assertSame('Step value must be at least 1.', $e->getMessage()); + } + } + + #[DataProvider('collectionClassProvider')] + public function testEmptyCollectionIsEmpty($collection) + { + $c = new $collection(); + + $this->assertTrue($c->isEmpty()); + } + + #[DataProvider('collectionClassProvider')] + public function testEmptyCollectionIsNotEmpty($collection) + { + $c = new $collection(['foo', 'bar']); + + $this->assertFalse($c->isEmpty()); + $this->assertTrue($c->isNotEmpty()); + } + + #[DataProvider('collectionClassProvider')] + public function testCollectionIsConstructed($collection) + { + $data = new $collection('foo'); + $this->assertSame(['foo'], $data->all()); + + $data = new $collection(2); + $this->assertSame([2], $data->all()); + + $data = new $collection(false); + $this->assertSame([false], $data->all()); + + $data = new $collection(null); + $this->assertEmpty($data->all()); + + $data = new $collection(); + $this->assertEmpty($data->all()); + } + + #[DataProvider('collectionClassProvider')] + public function testSkipMethod($collection) + { + $data = new $collection([1, 2, 3, 4, 5, 6]); + + // Total items to skip is smaller than collection length + $this->assertSame([5, 6], $data->skip(4)->values()->all()); + + // Total items to skip is more than collection length + $this->assertSame([], $data->skip(10)->values()->all()); + } + + #[DataProvider('collectionClassProvider')] + public function testSkipUntil($collection) + { + $data = new $collection([1, 1, 2, 2, 3, 3, 4, 4]); + + // Item at the beginning of the collection + $this->assertSame([1, 1, 2, 2, 3, 3, 4, 4], $data->skipUntil(1)->values()->all()); + + // Item at the middle of the collection + $this->assertSame([3, 3, 4, 4], $data->skipUntil(3)->values()->all()); + + // Item not in the collection + $this->assertSame([], $data->skipUntil(5)->values()->all()); + + // Item at the beginning of the collection + $data = $data->skipUntil(function ($value, $key) { + return $value <= 1; + })->values(); + + $this->assertSame([1, 1, 2, 2, 3, 3, 4, 4], $data->all()); + + // Item at the middle of the collection + $data = $data->skipUntil(function ($value, $key) { + return $value >= 3; + })->values(); + + $this->assertSame([3, 3, 4, 4], $data->all()); + + // Item not in the collection + $data = $data->skipUntil(function ($value, $key) { + return $value >= 5; + })->values(); + + $this->assertSame([], $data->all()); + } + + #[DataProvider('collectionClassProvider')] + public function testSkipWhile($collection) + { + $data = new $collection([1, 1, 2, 2, 3, 3, 4, 4]); + + // Item at the beginning of the collection + $this->assertSame([2, 2, 3, 3, 4, 4], $data->skipWhile(1)->values()->all()); + + // Item not in the collection + $this->assertSame([1, 1, 2, 2, 3, 3, 4, 4], $data->skipWhile(5)->values()->all()); + + // Item in the collection but not at the beginning + $this->assertSame([1, 1, 2, 2, 3, 3, 4, 4], $data->skipWhile(2)->values()->all()); + + // Item not in the collection + $data = $data->skipWhile(function ($value, $key) { + return $value >= 5; + })->values(); + + $this->assertSame([1, 1, 2, 2, 3, 3, 4, 4], $data->all()); + + // Item in the collection but not at the beginning + $data = $data->skipWhile(function ($value, $key) { + return $value >= 2; + })->values(); + + $this->assertSame([1, 1, 2, 2, 3, 3, 4, 4], $data->all()); + + // Item at the beginning of the collection + $data = $data->skipWhile(function ($value, $key) { + return $value < 3; + })->values(); + + $this->assertSame([3, 3, 4, 4], $data->all()); + } + + #[DataProvider('collectionClassProvider')] + public function testGetArrayableItems($collection) + { + $data = new $collection(); + + $class = new ReflectionClass($collection); + $method = $class->getMethod('getArrayableItems'); + + $items = new TestArrayableObject(); + $array = $method->invokeArgs($data, [$items]); + $this->assertSame(['foo' => 'bar'], $array); + + $items = new TestJsonableObject(); + $array = $method->invokeArgs($data, [$items]); + $this->assertSame(['foo' => 'bar'], $array); + + $items = new TestJsonSerializeObject(); + $array = $method->invokeArgs($data, [$items]); + $this->assertSame(['foo' => 'bar'], $array); + + $items = new TestJsonSerializeWithScalarValueObject(); + $array = $method->invokeArgs($data, [$items]); + $this->assertSame(['foo'], $array); + + $subject = [new stdClass(), new stdClass()]; + $items = new TestTraversableAndJsonSerializableObject($subject); + $array = $method->invokeArgs($data, [$items]); + $this->assertSame($subject, $array); + + $items = new $collection(['foo' => 'bar']); + $array = $method->invokeArgs($data, [$items]); + $this->assertSame(['foo' => 'bar'], $array); + + $items = ['foo' => 'bar']; + $array = $method->invokeArgs($data, [$items]); + $this->assertSame(['foo' => 'bar'], $array); + } + + #[DataProvider('collectionClassProvider')] + public function testToArrayCallsToArrayOnEachItemInCollection($collection) + { + $item1 = m::mock(Arrayable::class); + $item1->shouldReceive('toArray')->once()->andReturn(['foo.array']); + $item2 = m::mock(Arrayable::class); + $item2->shouldReceive('toArray')->once()->andReturn(['bar.array']); + $c = new $collection([$item1, $item2]); + $results = $c->toArray(); + + $this->assertEquals([['foo.array'], ['bar.array']], $results); + } + + public function testLazyReturnsLazyCollection() + { + $data = new Collection([1, 2, 3, 4, 5]); + + $lazy = $data->lazy(); + + $data->add(6); + + $this->assertInstanceOf(LazyCollection::class, $lazy); + $this->assertSame([1, 2, 3, 4, 5], $lazy->all()); + } + + #[DataProvider('collectionClassProvider')] + public function testJsonSerializeCallsToArrayOrJsonSerializeOnEachItemInCollection($collection) + { + $item1 = m::mock(JsonSerializable::class); + $item1->shouldReceive('jsonSerialize')->once()->andReturn('foo.json'); + $item2 = m::mock(Arrayable::class); + $item2->shouldReceive('toArray')->once()->andReturn(['bar.array']); + $c = new $collection([$item1, $item2]); + $results = $c->jsonSerialize(); + + $this->assertEquals(['foo.json', ['bar.array']], $results); + } + + #[DataProvider('collectionClassProvider')] + public function testToJsonEncodesTheJsonSerializeResult($collection) + { + $c = $this->getMockBuilder($collection)->onlyMethods(['jsonSerialize'])->getMock(); + $c->expects($this->once())->method('jsonSerialize')->willReturn(['foo']); + $results = $c->toJson(); + $this->assertJsonStringEqualsJsonString(json_encode(['foo']), $results); + } + + #[DataProvider('collectionClassProvider')] + public function testToPrettyJsonEncodesTheJsonSerializeResult($collection) + { + $c = $this->getMockBuilder($collection)->onlyMethods(['jsonSerialize'])->getMock(); + $c->expects($this->once())->method('jsonSerialize')->willReturn(['foo' => 'bar', 'baz' => 'qux']); + $results = $c->toPrettyJson(); + $expected = json_encode(['foo' => 'bar', 'baz' => 'qux'], JSON_PRETTY_PRINT); + $this->assertJsonStringEqualsJsonString($expected, $results); + $this->assertSame($expected, $results); + $this->assertStringContainsString("\n", $results); + $this->assertStringContainsString(' ', $results); + } + + #[DataProvider('collectionClassProvider')] + public function testCastingToStringJsonEncodesTheToArrayResult($collection) + { + $c = $this->getMockBuilder($collection)->onlyMethods(['jsonSerialize'])->getMock(); + $c->expects($this->once())->method('jsonSerialize')->willReturn(['foo']); + + $this->assertJsonStringEqualsJsonString(json_encode(['foo']), (string) $c); + } + + public function testOffsetAccess() + { + $c = new Collection(['name' => 'taylor']); + $this->assertSame('taylor', $c['name']); + $c['name'] = 'dayle'; + $this->assertSame('dayle', $c['name']); + $this->assertTrue(isset($c['name'])); + unset($c['name']); + $this->assertFalse(isset($c['name'])); + $c[] = 'jason'; + $this->assertSame('jason', $c[0]); + } + + public function testArrayAccessOffsetExists() + { + $c = new Collection(['foo', 'bar', null]); + $this->assertTrue($c->offsetExists(0)); + $this->assertTrue($c->offsetExists(1)); + $this->assertFalse($c->offsetExists(2)); + } + + public function testBehavesLikeAnArrayWithArrayAccess() + { + // indexed array + $input = ['foo', null]; + $c = new Collection($input); + $this->assertEquals(isset($input[0]), isset($c[0])); // existing value + $this->assertEquals(isset($input[1]), isset($c[1])); // existing but null value + $this->assertEquals(isset($input[1000]), isset($c[1000])); // non-existing value + $this->assertEquals($input[0], $c[0]); + $this->assertEquals($input[1], $c[1]); + + // associative array + $input = ['k1' => 'foo', 'k2' => null]; + $c = new Collection($input); + $this->assertEquals(isset($input['k1']), isset($c['k1'])); // existing value + $this->assertEquals(isset($input['k2']), isset($c['k2'])); // existing but null value + $this->assertEquals(isset($input['k3']), isset($c['k3'])); // non-existing value + $this->assertEquals($input['k1'], $c['k1']); + $this->assertEquals($input['k2'], $c['k2']); + } + + public function testArrayAccessOffsetGet() + { + $c = new Collection(['foo', 'bar']); + $this->assertSame('foo', $c->offsetGet(0)); + $this->assertSame('bar', $c->offsetGet(1)); + } + + public function testArrayAccessOffsetSet() + { + $c = new Collection(['foo', 'foo']); + + $c->offsetSet(1, 'bar'); + $this->assertSame('bar', $c[1]); + + $c->offsetSet(null, 'qux'); + $this->assertSame('qux', $c[2]); + } + + public function testArrayAccessOffsetUnset() + { + $c = new Collection(['foo', 'bar']); + + $c->offsetUnset(1); + $this->assertFalse(isset($c[1])); + } + + public function testForgetSingleKey() + { + $c = new Collection(['foo', 'bar']); + $c = $c->forget(0)->all(); + $this->assertFalse(isset($c['foo'])); + $this->assertFalse(isset($c[0])); + $this->assertTrue(isset($c[1])); + + $c = new Collection(['foo' => 'bar', 'baz' => 'qux']); + $c = $c->forget('foo')->all(); + $this->assertFalse(isset($c['foo'])); + $this->assertTrue(isset($c['baz'])); + } + + public function testForgetArrayOfKeys() + { + $c = new Collection(['foo', 'bar', 'baz']); + $c = $c->forget([0, 2])->all(); + $this->assertFalse(isset($c[0])); + $this->assertFalse(isset($c[2])); + $this->assertTrue(isset($c[1])); + + $c = new Collection(['name' => 'taylor', 'foo' => 'bar', 'baz' => 'qux']); + $c = $c->forget(['foo', 'baz'])->all(); + $this->assertFalse(isset($c['foo'])); + $this->assertFalse(isset($c['baz'])); + $this->assertTrue(isset($c['name'])); + } + + public function testForgetCollectionOfKeys() + { + $c = new Collection(['foo', 'bar', 'baz']); + $c = $c->forget(collect([0, 2]))->all(); + $this->assertFalse(isset($c[0])); + $this->assertFalse(isset($c[2])); + $this->assertTrue(isset($c[1])); + + $c = new Collection(['name' => 'taylor', 'foo' => 'bar', 'baz' => 'qux']); + $c = $c->forget(collect(['foo', 'baz']))->all(); + $this->assertFalse(isset($c['foo'])); + $this->assertFalse(isset($c['baz'])); + $this->assertTrue(isset($c['name'])); + } + + #[DataProvider('collectionClassProvider')] + public function testCountable($collection) + { + $c = new $collection(['foo', 'bar']); + $this->assertCount(2, $c); + } + + #[DataProvider('collectionClassProvider')] + public function testCountByStandalone($collection) + { + $c = new $collection(['foo', 'foo', 'foo', 'bar', 'bar', 'foobar']); + $this->assertEquals(['foo' => 3, 'bar' => 2, 'foobar' => 1], $c->countBy()->all()); + + $c = new $collection([true, true, false, false, false]); + $this->assertEquals([true => 2, false => 3], $c->countBy()->all()); + + $c = new $collection([1, 5, 1, 5, 5, 1]); + $this->assertEquals([1 => 3, 5 => 3], $c->countBy()->all()); + + $c = new $collection([StaffEnum::James, StaffEnum::Joe, StaffEnum::Taylor]); + $this->assertEquals(['James' => 1, 'Joe' => 1, 'Taylor' => 1], $c->countBy()->all()); + } + + #[DataProvider('collectionClassProvider')] + public function testCountByWithKey($collection) + { + $c = new $collection([ + ['key' => 'a'], ['key' => 'a'], ['key' => 'a'], ['key' => 'a'], + ['key' => 'b'], ['key' => 'b'], ['key' => 'b'], + ]); + $this->assertEquals(['a' => 4, 'b' => 3], $c->countBy('key')->all()); + + $c = new $collection([ + ['key' => TestBackedEnum::A], + ['key' => TestBackedEnum::B], ['key' => TestBackedEnum::B], + ]); + $this->assertEquals([1 => 1, 2 => 2], $c->countBy('key')->all()); + } + + #[DataProvider('collectionClassProvider')] + public function testCountableByWithCallback($collection) + { + $c = new $collection(['alice', 'aaron', 'bob', 'carla']); + $this->assertEquals(['a' => 2, 'b' => 1, 'c' => 1], $c->countBy(function ($name) { + return substr($name, 0, 1); + })->all()); + + $c = new $collection([1, 2, 3, 4, 5]); + $this->assertEquals([true => 2, false => 3], $c->countBy(function ($i) { + return $i % 2 === 0; + })->all()); + + $c = new $collection(['A', 'A', 'B', 'A']); + $this->assertEquals(['A' => 3, 'B' => 1], $c->countBy(static fn ($i) => TestStringBackedEnum::from($i))->all()); + } + + public function testAdd() + { + $c = new Collection([]); + $this->assertEquals([1], $c->add(1)->values()->all()); + $this->assertEquals([1, 2], $c->add(2)->values()->all()); + $this->assertEquals([1, 2, ''], $c->add('')->values()->all()); + $this->assertEquals([1, 2, '', null], $c->add(null)->values()->all()); + $this->assertEquals([1, 2, '', null, false], $c->add(false)->values()->all()); + $this->assertEquals([1, 2, '', null, false, []], $c->add([])->values()->all()); + $this->assertEquals([1, 2, '', null, false, [], 'name'], $c->add('name')->values()->all()); + } + + #[DataProvider('collectionClassProvider')] + public function testContainsOneItem($collection) + { + $this->assertFalse((new $collection([]))->containsOneItem()); + $this->assertTrue((new $collection([1]))->containsOneItem()); + $this->assertFalse((new $collection([1, 2]))->containsOneItem()); + + $this->assertFalse(collect([1, 2, 2])->containsOneItem(fn ($number) => $number === 2)); + $this->assertTrue(collect(['ant', 'bear', 'cat'])->containsOneItem(fn ($word) => strlen($word) === 4)); + $this->assertFalse(collect(['ant', 'bear', 'cat'])->containsOneItem(fn ($word) => strlen($word) > 4)); + } + + #[DataProvider('collectionClassProvider')] + public function testContainsManyItems($collection) + { + $this->assertFalse((new $collection([]))->containsManyItems()); + $this->assertFalse((new $collection([1]))->containsManyItems()); + $this->assertTrue((new $collection([1, 2]))->containsManyItems()); + $this->assertTrue((new $collection([1, 2, 3]))->containsManyItems()); + + $this->assertTrue(collect([1, 2, 2])->containsManyItems(fn ($number) => $number === 2)); + $this->assertFalse(collect(['ant', 'bear', 'cat'])->containsManyItems(fn ($word) => strlen($word) === 4)); + $this->assertFalse(collect(['ant', 'bear', 'cat'])->containsManyItems(fn ($word) => strlen($word) > 4)); + $this->assertTrue(collect(['ant', 'bear', 'cat'])->containsManyItems(fn ($word) => strlen($word) === 3)); + } + + public function testIterable() + { + $c = new Collection(['foo']); + $this->assertInstanceOf(ArrayIterator::class, $c->getIterator()); + $this->assertEquals(['foo'], $c->getIterator()->getArrayCopy()); + } + + #[DataProvider('collectionClassProvider')] + public function testCachingIterator($collection) + { + $c = new $collection(['foo']); + $this->assertInstanceOf(CachingIterator::class, $c->getCachingIterator()); + } + + #[DataProvider('collectionClassProvider')] + public function testFilter($collection) + { + $c = new $collection([['id' => 1, 'name' => 'Hello'], ['id' => 2, 'name' => 'World']]); + $this->assertEquals([1 => ['id' => 2, 'name' => 'World']], $c->filter(function ($item) { + return $item['id'] == 2; + })->all()); + + $c = new $collection(['', 'Hello', '', 'World']); + $this->assertEquals(['Hello', 'World'], $c->filter()->values()->toArray()); + + $c = new $collection(['id' => 1, 'first' => 'Hello', 'second' => 'World']); + $this->assertEquals(['first' => 'Hello', 'second' => 'World'], $c->filter(function ($item, $key) { + return $key !== 'id'; + })->all()); + + $c = new $collection([1, 2, 3, null, false, '', 0, []]); + $this->assertEquals([1, 2, 3], $c->filter()->all()); + } + + #[DataProvider('collectionClassProvider')] + public function testHigherOrderKeyBy($collection) + { + $c = new $collection([ + ['id' => 'id1', 'name' => 'first'], + ['id' => 'id2', 'name' => 'second'], + ]); + + $this->assertEquals(['id1' => 'first', 'id2' => 'second'], $c->keyBy->id->map->name->all()); + } + + #[DataProvider('collectionClassProvider')] + public function testHigherOrderUnique($collection) + { + $c = new $collection([ + ['id' => '1', 'name' => 'first'], + ['id' => '1', 'name' => 'second'], + ]); + + $this->assertCount(1, $c->unique->id); + } + + #[DataProvider('collectionClassProvider')] + public function testHigherOrderFilter($collection) + { + $c = new $collection([ + new class { + public $name = 'Alex'; + + public function active() + { + return true; + } + }, + new class { + public $name = 'John'; + + public function active() + { + return false; + } + }, + ]); + + $this->assertCount(1, $c->filter->active()); + } + + #[DataProvider('collectionClassProvider')] + public function testWhere($collection) + { + $c = new $collection([['v' => 1], ['v' => 2], ['v' => 3], ['v' => '3'], ['v' => 4]]); + + $this->assertEquals( + [['v' => 3], ['v' => '3']], + $c->where('v', 3)->values()->all() + ); + $this->assertEquals( + [['v' => 3], ['v' => '3']], + $c->where('v', '=', 3)->values()->all() + ); + $this->assertEquals( + [['v' => 3], ['v' => '3']], + $c->where('v', '==', 3)->values()->all() + ); + $this->assertEquals( + [['v' => 3], ['v' => '3']], + $c->where('v', 'garbage', 3)->values()->all() + ); + $this->assertEquals( + [['v' => 3]], + $c->where('v', '===', 3)->values()->all() + ); + + $this->assertEquals( + [['v' => 1], ['v' => 2], ['v' => 4]], + $c->where('v', '<>', 3)->values()->all() + ); + $this->assertEquals( + [['v' => 1], ['v' => 2], ['v' => 4]], + $c->where('v', '!=', 3)->values()->all() + ); + $this->assertEquals( + [['v' => 1], ['v' => 2], ['v' => '3'], ['v' => 4]], + $c->where('v', '!==', 3)->values()->all() + ); + $this->assertEquals( + [['v' => 1], ['v' => 2], ['v' => 3], ['v' => '3']], + $c->where('v', '<=', 3)->values()->all() + ); + $this->assertEquals( + [['v' => 3], ['v' => '3'], ['v' => 4]], + $c->where('v', '>=', 3)->values()->all() + ); + $this->assertEquals( + [['v' => 1], ['v' => 2]], + $c->where('v', '<', 3)->values()->all() + ); + $this->assertEquals( + [['v' => 4]], + $c->where('v', '>', 3)->values()->all() + ); + + $object = (object) ['foo' => 'bar']; + + $this->assertEquals( + [], + $c->where('v', $object)->values()->all() + ); + + $this->assertEquals( + [['v' => 1], ['v' => 2], ['v' => 3], ['v' => '3'], ['v' => 4]], + $c->where('v', '<>', $object)->values()->all() + ); + + $this->assertEquals( + [['v' => 1], ['v' => 2], ['v' => 3], ['v' => '3'], ['v' => 4]], + $c->where('v', '!=', $object)->values()->all() + ); + + $this->assertEquals( + [['v' => 1], ['v' => 2], ['v' => 3], ['v' => '3'], ['v' => 4]], + $c->where('v', '!==', $object)->values()->all() + ); + + $this->assertEquals( + [], + $c->where('v', '>', $object)->values()->all() + ); + + $this->assertEquals( + [['v' => 3], ['v' => '3']], + $c->where(fn ($value) => $value['v'] == 3)->values()->all() + ); + + $this->assertEquals( + [['v' => 3]], + $c->where(fn ($value) => $value['v'] === 3)->values()->all() + ); + + $c = new $collection([['v' => 1], ['v' => $object]]); + $this->assertEquals( + [['v' => $object]], + $c->where('v', $object)->values()->all() + ); + + $this->assertEquals( + [['v' => 1], ['v' => $object]], + $c->where('v', '<>', null)->values()->all() + ); + + $this->assertEquals( + [], + $c->where('v', '<', null)->values()->all() + ); + + $c = new $collection([['v' => 1], ['v' => new HtmlString('hello')]]); + $this->assertEquals( + [['v' => new HtmlString('hello')]], + $c->where('v', 'hello')->values()->all() + ); + + $c = new $collection([['v' => 1], ['v' => 'hello']]); + $this->assertEquals( + [['v' => 'hello']], + $c->where('v', new HtmlString('hello'))->values()->all() + ); + + $c = new $collection([['v' => 1], ['v' => 2], ['v' => null]]); + $this->assertEquals( + [['v' => 1], ['v' => 2]], + $c->where('v')->values()->all() + ); + + $c = new $collection([ + ['v' => 1, 'g' => 3], + ['v' => 2, 'g' => 2], + ['v' => 2, 'g' => 3], + ['v' => 2, 'g' => null], + ]); + $this->assertEquals([['v' => 2, 'g' => 3]], $c->where('v', 2)->where('g', 3)->values()->all()); + $this->assertEquals([['v' => 2, 'g' => 3]], $c->where('v', 2)->where('g', '>', 2)->values()->all()); + $this->assertEquals([], $c->where('v', 2)->where('g', 4)->values()->all()); + $this->assertEquals([['v' => 2, 'g' => null]], $c->where('v', 2)->whereNull('g')->values()->all()); + } + + #[DataProvider('collectionClassProvider')] + public function testWhereStrict($collection) + { + $c = new $collection([['v' => 3], ['v' => '3']]); + + $this->assertEquals( + [['v' => 3]], + $c->whereStrict('v', 3)->values()->all() + ); + } + + #[DataProvider('collectionClassProvider')] + public function testWhereInstanceOf($collection) + { + $c = new $collection([new stdClass(), new stdClass(), new $collection(), new stdClass(), new Str()]); + $this->assertCount(3, $c->whereInstanceOf(stdClass::class)); + + $this->assertCount(4, $c->whereInstanceOf([stdClass::class, Str::class])); + } + + #[DataProvider('collectionClassProvider')] + public function testWhereIn($collection) + { + $c = new $collection([['v' => 1], ['v' => 2], ['v' => 3], ['v' => '3'], ['v' => 4]]); + $this->assertEquals([['v' => 1], ['v' => 3], ['v' => '3']], $c->whereIn('v', [1, 3])->values()->all()); + $this->assertEquals([], $c->whereIn('v', [2])->whereIn('v', [1, 3])->values()->all()); + $this->assertEquals([['v' => 1]], $c->whereIn('v', [1])->whereIn('v', [1, 3])->values()->all()); + } + + #[DataProvider('collectionClassProvider')] + public function testWhereInStrict($collection) + { + $c = new $collection([['v' => 1], ['v' => 2], ['v' => 3], ['v' => '3'], ['v' => 4]]); + $this->assertEquals([['v' => 1], ['v' => 3]], $c->whereInStrict('v', [1, 3])->values()->all()); + } + + #[DataProvider('collectionClassProvider')] + public function testWhereNotIn($collection) + { + $c = new $collection([['v' => 1], ['v' => 2], ['v' => 3], ['v' => '3'], ['v' => 4]]); + $this->assertEquals([['v' => 2], ['v' => 4]], $c->whereNotIn('v', [1, 3])->values()->all()); + $this->assertEquals([['v' => 4]], $c->whereNotIn('v', [2])->whereNotIn('v', [1, 3])->values()->all()); + } + + #[DataProvider('collectionClassProvider')] + public function testWhereNotInStrict($collection) + { + $c = new $collection([['v' => 1], ['v' => 2], ['v' => 3], ['v' => '3'], ['v' => 4]]); + $this->assertEquals([['v' => 2], ['v' => '3'], ['v' => 4]], $c->whereNotInStrict('v', [1, 3])->values()->all()); + } + + #[DataProvider('collectionClassProvider')] + public function testValues($collection) + { + $c = new $collection([['id' => 1, 'name' => 'Hello'], ['id' => 2, 'name' => 'World']]); + $this->assertEquals([['id' => 2, 'name' => 'World']], $c->filter(function ($item) { + return $item['id'] == 2; + })->values()->all()); + } + + #[DataProvider('collectionClassProvider')] + public function testValuesResetKey($collection) + { + $data = new $collection([1 => 'a', 2 => 'b', 3 => 'c']); + $this->assertEquals([0 => 'a', 1 => 'b', 2 => 'c'], $data->values()->all()); + } + + #[DataProvider('collectionClassProvider')] + public function testValue($collection) + { + $c = new $collection([['id' => 1, 'name' => 'Hello'], ['id' => 2, 'name' => 'World']]); + + $this->assertEquals('Hello', $c->value('name')); + $this->assertEquals('World', $c->where('id', 2)->value('name')); + + $c = new $collection([ + ['id' => 1, 'pivot' => ['value' => 'foo']], + ['id' => 2, 'pivot' => ['value' => 'bar']], + ]); + + $this->assertEquals(['value' => 'foo'], $c->value('pivot')); + $this->assertEquals('foo', $c->value('pivot.value')); + $this->assertEquals('bar', $c->where('id', 2)->value('pivot.value')); + } + + #[DataProvider('collectionClassProvider')] + public function testValueUsingEnum($collection) + { + $c = new $collection([['id' => 1, 'name' => StaffEnum::Taylor], ['id' => 2, 'name' => StaffEnum::Joe]]); + + $this->assertSame(StaffEnum::Taylor, $c->value('name')); + $this->assertEquals(StaffEnum::Joe, $c->where('id', 2)->value('name')); + } + + #[DataProvider('collectionClassProvider')] + public function testValueWithNegativeValue($collection) + { + $c = new $collection([['id' => 1, 'balance' => 0], ['id' => 2, 'balance' => 200]]); + + $this->assertEquals(0, $c->value('balance')); + + $c = new $collection([['id' => 1, 'balance' => ''], ['id' => 2, 'balance' => 200]]); + + $this->assertEquals('', $c->value('balance')); + + $c = new $collection([['id' => 1, 'balance' => null], ['id' => 2, 'balance' => 200]]); + + $this->assertEquals(null, $c->value('balance')); + + $c = new $collection([['id' => 1], ['id' => 2, 'balance' => 200]]); + + $this->assertEquals(200, $c->value('balance')); + + $c = new $collection([['id' => 1], ['id' => 2, 'balance' => 0], ['id' => 3, 'balance' => 200]]); + + $this->assertEquals(0, $c->value('balance')); + } + + #[DataProvider('collectionClassProvider')] + public function testValueWithObjects($collection) + { + $c = new $collection([ + literal(id: 1), + literal(id: 2, balance: ''), + literal(id: 3, balance: 200), + ]); + + $this->assertEquals('', $c->value('balance')); + + $c = new $collection([ + literal(id: 1), + literal(id: 2, balance: literal(currency: 'USD', value: 0)), + literal(id: 3, balance: literal(currency: 'USD', value: 200)), + ]); + + $this->assertEquals(0, $c->value('balance.value')); + } + + #[DataProvider('collectionClassProvider')] + public function testBetween($collection) + { + $c = new $collection([['v' => 1], ['v' => 2], ['v' => 3], ['v' => '3'], ['v' => 4]]); + + $this->assertEquals( + [['v' => 2], ['v' => 3], ['v' => '3'], ['v' => 4]], + $c->whereBetween('v', [2, 4])->values()->all() + ); + $this->assertEquals([['v' => 1]], $c->whereBetween('v', [-1, 1])->all()); + $this->assertEquals([['v' => 3], ['v' => '3']], $c->whereBetween('v', [3, 3])->values()->all()); + } + + #[DataProvider('collectionClassProvider')] + public function testWhereNotBetween($collection) + { + $c = new $collection([['v' => 1], ['v' => 2], ['v' => 3], ['v' => '3'], ['v' => 4]]); + + $this->assertEquals([['v' => 1]], $c->whereNotBetween('v', [2, 4])->values()->all()); + $this->assertEquals([['v' => 2], ['v' => 3], ['v' => 3], ['v' => 4]], $c->whereNotBetween('v', [-1, 1])->values()->all()); + $this->assertEquals([['v' => 1], ['v' => '2'], ['v' => '4']], $c->whereNotBetween('v', [3, 3])->values()->all()); + } + + #[DataProvider('collectionClassProvider')] + public function testFlatten($collection) + { + // Flat arrays are unaffected + $c = new $collection(['#foo', '#bar', '#baz']); + $this->assertEquals(['#foo', '#bar', '#baz'], $c->flatten()->all()); + + // Nested arrays are flattened with existing flat items + $c = new $collection([['#foo', '#bar'], '#baz']); + $this->assertEquals(['#foo', '#bar', '#baz'], $c->flatten()->all()); + + // Sets of nested arrays are flattened + $c = new $collection([['#foo', '#bar'], ['#baz']]); + $this->assertEquals(['#foo', '#bar', '#baz'], $c->flatten()->all()); + + // Deeply nested arrays are flattened + $c = new $collection([['#foo', ['#bar']], ['#baz']]); + $this->assertEquals(['#foo', '#bar', '#baz'], $c->flatten()->all()); + + // Nested collections are flattened alongside arrays + $c = new $collection([new $collection(['#foo', '#bar']), ['#baz']]); + $this->assertEquals(['#foo', '#bar', '#baz'], $c->flatten()->all()); + + // Nested collections containing plain arrays are flattened + $c = new $collection([new $collection(['#foo', ['#bar']]), ['#baz']]); + $this->assertEquals(['#foo', '#bar', '#baz'], $c->flatten()->all()); + + // Nested arrays containing collections are flattened + $c = new $collection([['#foo', new $collection(['#bar'])], ['#baz']]); + $this->assertEquals(['#foo', '#bar', '#baz'], $c->flatten()->all()); + + // Nested arrays containing collections containing arrays are flattened + $c = new $collection([['#foo', new $collection(['#bar', ['#zap']])], ['#baz']]); + $this->assertEquals(['#foo', '#bar', '#zap', '#baz'], $c->flatten()->all()); + } + + #[DataProvider('collectionClassProvider')] + public function testFlattenWithDepth($collection) + { + // No depth flattens recursively + $c = new $collection([['#foo', ['#bar', ['#baz']]], '#zap']); + $this->assertEquals(['#foo', '#bar', '#baz', '#zap'], $c->flatten()->all()); + + // Specifying a depth only flattens to that depth + $c = new $collection([['#foo', ['#bar', ['#baz']]], '#zap']); + $this->assertEquals(['#foo', ['#bar', ['#baz']], '#zap'], $c->flatten(1)->all()); + + $c = new $collection([['#foo', ['#bar', ['#baz']]], '#zap']); + $this->assertEquals(['#foo', '#bar', ['#baz'], '#zap'], $c->flatten(2)->all()); + } + + #[DataProvider('collectionClassProvider')] + public function testFlattenIgnoresKeys($collection) + { + // No depth ignores keys + $c = new $collection(['#foo', ['key' => '#bar'], ['key' => '#baz'], 'key' => '#zap']); + $this->assertEquals(['#foo', '#bar', '#baz', '#zap'], $c->flatten()->all()); + + // Depth of 1 ignores keys + $c = new $collection(['#foo', ['key' => '#bar'], ['key' => '#baz'], 'key' => '#zap']); + $this->assertEquals(['#foo', '#bar', '#baz', '#zap'], $c->flatten(1)->all()); + } + + #[DataProvider('collectionClassProvider')] + public function testMergeNull($collection) + { + $c = new $collection(['name' => 'Hello']); + $this->assertEquals(['name' => 'Hello'], $c->merge(null)->all()); + } + + #[DataProvider('collectionClassProvider')] + public function testMergeArray($collection) + { + $c = new $collection(['name' => 'Hello']); + $this->assertEquals(['name' => 'Hello', 'id' => 1], $c->merge(['id' => 1])->all()); + } + + #[DataProvider('collectionClassProvider')] + public function testMergeCollection($collection) + { + $c = new $collection(['name' => 'Hello']); + $this->assertEquals(['name' => 'World', 'id' => 1], $c->merge(new $collection(['name' => 'World', 'id' => 1]))->all()); + } + + #[DataProvider('collectionClassProvider')] + public function testMergeRecursiveNull($collection) + { + $c = new $collection(['name' => 'Hello']); + $this->assertEquals(['name' => 'Hello'], $c->mergeRecursive(null)->all()); + } + + #[DataProvider('collectionClassProvider')] + public function testMergeRecursiveArray($collection) + { + $c = new $collection(['name' => 'Hello', 'id' => 1]); + $this->assertEquals(['name' => 'Hello', 'id' => [1, 2]], $c->mergeRecursive(['id' => 2])->all()); + } + + #[DataProvider('collectionClassProvider')] + public function testMergeRecursiveCollection($collection) + { + $c = new $collection(['name' => 'Hello', 'id' => 1, 'meta' => ['tags' => ['a', 'b'], 'roles' => 'admin']]); + $this->assertEquals( + ['name' => 'Hello', 'id' => 1, 'meta' => ['tags' => ['a', 'b', 'c'], 'roles' => ['admin', 'editor']]], + $c->mergeRecursive(new $collection(['meta' => ['tags' => ['c'], 'roles' => 'editor']]))->all() + ); + } + + #[DataProvider('collectionClassProvider')] + public function testMultiplyCollection($collection) + { + $c = new $collection(['Hello', 1, ['tags' => ['a', 'b'], 'admin']]); + + $this->assertEquals([], $c->multiply(-1)->all()); + $this->assertEquals([], $c->multiply(0)->all()); + + $this->assertEquals( + ['Hello', 1, ['tags' => ['a', 'b'], 'admin']], + $c->multiply(1)->all() + ); + + $this->assertEquals( + ['Hello', 1, ['tags' => ['a', 'b'], 'admin'], 'Hello', 1, ['tags' => ['a', 'b'], 'admin'], 'Hello', 1, ['tags' => ['a', 'b'], 'admin']], + $c->multiply(3)->all() + ); + } + + #[DataProvider('collectionClassProvider')] + public function testReplaceNull($collection) + { + $c = new $collection(['a', 'b', 'c']); + $this->assertEquals(['a', 'b', 'c'], $c->replace(null)->all()); + } + + #[DataProvider('collectionClassProvider')] + public function testReplaceArray($collection) + { + $c = new $collection(['a', 'b', 'c']); + $this->assertEquals(['a', 'd', 'e'], $c->replace([1 => 'd', 2 => 'e'])->all()); + + $c = new $collection(['a', 'b', 'c']); + $this->assertEquals(['a', 'd', 'e', 'f', 'g'], $c->replace([1 => 'd', 2 => 'e', 3 => 'f', 4 => 'g'])->all()); + + $c = new $collection(['name' => 'amir', 'family' => 'otwell']); + $this->assertEquals(['name' => 'taylor', 'family' => 'otwell', 'age' => 26], $c->replace(['name' => 'taylor', 'age' => 26])->all()); + } + + #[DataProvider('collectionClassProvider')] + public function testReplaceCollection($collection) + { + $c = new $collection(['a', 'b', 'c']); + $this->assertEquals( + ['a', 'd', 'e'], + $c->replace(new $collection([1 => 'd', 2 => 'e']))->all() + ); + + $c = new $collection(['a', 'b', 'c']); + $this->assertEquals( + ['a', 'd', 'e', 'f', 'g'], + $c->replace(new $collection([1 => 'd', 2 => 'e', 3 => 'f', 4 => 'g']))->all() + ); + + $c = new $collection(['name' => 'amir', 'family' => 'otwell']); + $this->assertEquals( + ['name' => 'taylor', 'family' => 'otwell', 'age' => 26], + $c->replace(new $collection(['name' => 'taylor', 'age' => 26]))->all() + ); + } + + #[DataProvider('collectionClassProvider')] + public function testReplaceRecursiveNull($collection) + { + $c = new $collection(['a', 'b', ['c', 'd']]); + $this->assertEquals(['a', 'b', ['c', 'd']], $c->replaceRecursive(null)->all()); + } + + #[DataProvider('collectionClassProvider')] + public function testReplaceRecursiveArray($collection) + { + $c = new $collection(['a', 'b', ['c', 'd']]); + $this->assertEquals(['z', 'b', ['c', 'e']], $c->replaceRecursive(['z', 2 => [1 => 'e']])->all()); + + $c = new $collection(['a', 'b', ['c', 'd']]); + $this->assertEquals(['z', 'b', ['c', 'e'], 'f'], $c->replaceRecursive(['z', 2 => [1 => 'e'], 'f'])->all()); + } + + #[DataProvider('collectionClassProvider')] + public function testReplaceRecursiveCollection($collection) + { + $c = new $collection(['a', 'b', ['c', 'd']]); + $this->assertEquals( + ['z', 'b', ['c', 'e']], + $c->replaceRecursive(new $collection(['z', 2 => [1 => 'e']]))->all() + ); + } + + #[DataProvider('collectionClassProvider')] + public function testUnionNull($collection) + { + $c = new $collection(['name' => 'Hello']); + $this->assertEquals(['name' => 'Hello'], $c->union(null)->all()); + } + + #[DataProvider('collectionClassProvider')] + public function testUnionArray($collection) + { + $c = new $collection(['name' => 'Hello']); + $this->assertEquals(['name' => 'Hello', 'id' => 1], $c->union(['id' => 1])->all()); + } + + #[DataProvider('collectionClassProvider')] + public function testUnionCollection($collection) + { + $c = new $collection(['name' => 'Hello']); + $this->assertEquals(['name' => 'Hello', 'id' => 1], $c->union(new $collection(['name' => 'World', 'id' => 1]))->all()); + } + + #[DataProvider('collectionClassProvider')] + public function testDiffCollection($collection) + { + $c = new $collection(['id' => 1, 'first_word' => 'Hello']); + $this->assertEquals(['id' => 1], $c->diff(new $collection(['first_word' => 'Hello', 'last_word' => 'World']))->all()); + } + + #[DataProvider('collectionClassProvider')] + public function testDiffUsingWithCollection($collection) + { + $c = new $collection(['en_GB', 'fr', 'HR']); + // demonstrate that diff won't support case insensitivity + $this->assertEquals(['en_GB', 'fr', 'HR'], $c->diff(new $collection(['en_gb', 'hr']))->values()->toArray()); + // allow for case insensitive difference + $this->assertEquals(['fr'], $c->diffUsing(new $collection(['en_gb', 'hr']), 'strcasecmp')->values()->toArray()); + } + + #[DataProvider('collectionClassProvider')] + public function testDiffUsingWithNull($collection) + { + $c = new $collection(['en_GB', 'fr', 'HR']); + $this->assertEquals(['en_GB', 'fr', 'HR'], $c->diffUsing(null, 'strcasecmp')->values()->toArray()); + } + + #[DataProvider('collectionClassProvider')] + public function testDiffNull($collection) + { + $c = new $collection(['id' => 1, 'first_word' => 'Hello']); + $this->assertEquals(['id' => 1, 'first_word' => 'Hello'], $c->diff(null)->all()); + } + + #[DataProvider('collectionClassProvider')] + public function testDiffKeys($collection) + { + $c1 = new $collection(['id' => 1, 'first_word' => 'Hello']); + $c2 = new $collection(['id' => 123, 'foo_bar' => 'Hello']); + $this->assertEquals(['first_word' => 'Hello'], $c1->diffKeys($c2)->all()); + } + + #[DataProvider('collectionClassProvider')] + public function testDiffKeysUsing($collection) + { + $c1 = new $collection(['id' => 1, 'first_word' => 'Hello']); + $c2 = new $collection(['ID' => 123, 'foo_bar' => 'Hello']); + // demonstrate that diffKeys won't support case insensitivity + $this->assertEquals(['id' => 1, 'first_word' => 'Hello'], $c1->diffKeys($c2)->all()); + // allow for case insensitive difference + $this->assertEquals(['first_word' => 'Hello'], $c1->diffKeysUsing($c2, 'strcasecmp')->all()); + } + + #[DataProvider('collectionClassProvider')] + public function testDiffAssoc($collection) + { + $c1 = new $collection(['id' => 1, 'first_word' => 'Hello', 'not_affected' => 'value']); + $c2 = new $collection(['id' => 123, 'foo_bar' => 'Hello', 'not_affected' => 'value']); + $this->assertEquals(['id' => 1, 'first_word' => 'Hello'], $c1->diffAssoc($c2)->all()); + } + + #[DataProvider('collectionClassProvider')] + public function testDiffAssocUsing($collection) + { + $c1 = new $collection(['a' => 'green', 'b' => 'brown', 'c' => 'blue', 'red']); + $c2 = new $collection(['A' => 'green', 'yellow', 'red']); + // demonstrate that the case of the keys will affect the output when diffAssoc is used + $this->assertEquals(['a' => 'green', 'b' => 'brown', 'c' => 'blue', 'red'], $c1->diffAssoc($c2)->all()); + // allow for case insensitive difference + $this->assertEquals(['b' => 'brown', 'c' => 'blue', 'red'], $c1->diffAssocUsing($c2, 'strcasecmp')->all()); + } + + #[DataProvider('collectionClassProvider')] + public function testDuplicates($collection) + { + $duplicates = $collection::make([1, 2, 1, 'laravel', null, 'laravel', 'php', null])->duplicates()->all(); + $this->assertSame([2 => 1, 5 => 'laravel', 7 => null], $duplicates); + + // does loose comparison + $duplicates = $collection::make([2, '2', [], null])->duplicates()->all(); + $this->assertSame([1 => '2', 3 => null], $duplicates); + + // works with mix of primitives + $duplicates = $collection::make([1, '2', ['laravel'], ['laravel'], null, '2'])->duplicates()->all(); + $this->assertSame([3 => ['laravel'], 5 => '2'], $duplicates); + + // works with mix of objects and primitives **excepts numbers**. + $expected = new Collection(['laravel']); + $duplicates = $collection::make([new Collection(['laravel']), $expected, $expected, [], '2', '2'])->duplicates()->all(); + $this->assertSame([1 => $expected, 2 => $expected, 5 => '2'], $duplicates); + } + + #[DataProvider('collectionClassProvider')] + public function testDuplicatesWithKey($collection) + { + $items = [['framework' => 'vue'], ['framework' => 'laravel'], ['framework' => 'laravel']]; + $duplicates = $collection::make($items)->duplicates('framework')->all(); + $this->assertSame([2 => 'laravel'], $duplicates); + + // works with key and strict + $items = [['Framework' => 'vue'], ['framework' => 'vue'], ['Framework' => 'vue']]; + $duplicates = $collection::make($items)->duplicates('Framework', true)->all(); + $this->assertSame([2 => 'vue'], $duplicates); + } + + #[DataProvider('collectionClassProvider')] + public function testDuplicatesWithCallback($collection) + { + $items = [['framework' => 'vue'], ['framework' => 'laravel'], ['framework' => 'laravel']]; + $duplicates = $collection::make($items)->duplicates(function ($item) { + return $item['framework']; + })->all(); + $this->assertSame([2 => 'laravel'], $duplicates); + } + + #[DataProvider('collectionClassProvider')] + public function testDuplicatesWithStrict($collection) + { + $duplicates = $collection::make([1, 2, 1, 'laravel', null, 'laravel', 'php', null])->duplicatesStrict()->all(); + $this->assertSame([2 => 1, 5 => 'laravel', 7 => null], $duplicates); + + // does strict comparison + $duplicates = $collection::make([2, '2', [], null])->duplicatesStrict()->all(); + $this->assertSame([], $duplicates); + + // works with mix of primitives + $duplicates = $collection::make([1, '2', ['laravel'], ['laravel'], null, '2'])->duplicatesStrict()->all(); + $this->assertSame([3 => ['laravel'], 5 => '2'], $duplicates); + + // works with mix of primitives, objects, and numbers + $expected = new $collection(['laravel']); + $duplicates = $collection::make([new $collection(['laravel']), $expected, $expected, [], '2', '2'])->duplicatesStrict()->all(); + $this->assertSame([2 => $expected, 5 => '2'], $duplicates); + } + + #[DataProvider('collectionClassProvider')] + public function testEach($collection) + { + $c = new $collection($original = [1, 2, 'foo' => 'bar', 'bam' => 'baz']); + + $result = []; + $c->each(function ($item, $key) use (&$result) { + $result[$key] = $item; + }); + $this->assertEquals($original, $result); + + $result = []; + $c->each(function ($item, $key) use (&$result) { + $result[$key] = $item; + if (is_string($key)) { + return false; + } + }); + $this->assertEquals([1, 2, 'foo' => 'bar'], $result); + } + + #[DataProvider('collectionClassProvider')] + public function testEachSpread($collection) + { + $c = new $collection([[1, 'a'], [2, 'b']]); + + $result = []; + $c->eachSpread(function ($number, $character) use (&$result) { + $result[] = [$number, $character]; + }); + $this->assertEquals($c->all(), $result); + + $result = []; + $c->eachSpread(function ($number, $character) use (&$result) { + $result[] = [$number, $character]; + + return false; + }); + $this->assertEquals([[1, 'a']], $result); + + $result = []; + $c->eachSpread(function ($number, $character, $key) use (&$result) { + $result[] = [$number, $character, $key]; + }); + $this->assertEquals([[1, 'a', 0], [2, 'b', 1]], $result); + + $c = new $collection([new Collection([1, 'a']), new Collection([2, 'b'])]); + $result = []; + $c->eachSpread(function ($number, $character, $key) use (&$result) { + $result[] = [$number, $character, $key]; + }); + $this->assertEquals([[1, 'a', 0], [2, 'b', 1]], $result); + } + + #[DataProvider('collectionClassProvider')] + public function testIntersectNull($collection) + { + $c = new $collection(['id' => 1, 'first_word' => 'Hello']); + $this->assertEquals([], $c->intersect(null)->all()); + } + + #[DataProvider('collectionClassProvider')] + public function testIntersectCollection($collection) + { + $c = new $collection(['id' => 1, 'first_word' => 'Hello']); + $this->assertEquals(['first_word' => 'Hello'], $c->intersect(new $collection(['first_world' => 'Hello', 'last_word' => 'World']))->all()); + } + + #[DataProvider('collectionClassProvider')] + public function testIntersectUsingWithNull($collection) + { + $collect = new $collection(['green', 'brown', 'blue']); + + $this->assertEquals([], $collect->intersectUsing(null, 'strcasecmp')->all()); + } + + #[DataProvider('collectionClassProvider')] + public function testIntersectUsingCollection($collection) + { + $collect = new $collection(['green', 'brown', 'blue']); + + $this->assertEquals(['green', 'brown'], $collect->intersectUsing(new $collection(['GREEN', 'brown', 'yellow']), 'strcasecmp')->all()); + } + + #[DataProvider('collectionClassProvider')] + public function testIntersectAssocWithNull($collection) + { + $array1 = new $collection(['a' => 'green', 'b' => 'brown', 'c' => 'blue', 'red']); + + $this->assertEquals([], $array1->intersectAssoc(null)->all()); + } + + #[DataProvider('collectionClassProvider')] + public function testIntersectAssocCollection($collection) + { + $array1 = new $collection(['a' => 'green', 'b' => 'brown', 'c' => 'blue', 'red']); + $array2 = new $collection(['a' => 'green', 'b' => 'yellow', 'blue', 'red']); + + $this->assertEquals(['a' => 'green'], $array1->intersectAssoc($array2)->all()); + } + + #[DataProvider('collectionClassProvider')] + public function testIntersectAssocUsingWithNull($collection) + { + $array1 = new $collection(['a' => 'green', 'b' => 'brown', 'c' => 'blue', 'red']); + + $this->assertEquals([], $array1->intersectAssocUsing(null, 'strcasecmp')->all()); + } + + #[DataProvider('collectionClassProvider')] + public function testIntersectAssocUsingCollection($collection) + { + $array1 = new $collection(['a' => 'green', 'b' => 'brown', 'c' => 'blue', 'red']); + $array2 = new $collection(['a' => 'GREEN', 'B' => 'brown', 'yellow', 'red']); + + $this->assertEquals(['b' => 'brown'], $array1->intersectAssocUsing($array2, 'strcasecmp')->all()); + } + + #[DataProvider('collectionClassProvider')] + public function testIntersectByKeysNull($collection) + { + $c = new $collection(['name' => 'Mateus', 'age' => 18]); + $this->assertEquals([], $c->intersectByKeys(null)->all()); + } + + #[DataProvider('collectionClassProvider')] + public function testIntersectByKeys($collection) + { + $c = new $collection(['name' => 'Mateus', 'age' => 18]); + $this->assertEquals(['name' => 'Mateus'], $c->intersectByKeys(new $collection(['name' => 'Mateus', 'surname' => 'Guimaraes']))->all()); + + $c = new $collection(['name' => 'taylor', 'family' => 'otwell', 'age' => 26]); + $this->assertEquals(['name' => 'taylor', 'family' => 'otwell'], $c->intersectByKeys(new $collection(['height' => 180, 'name' => 'amir', 'family' => 'moharami']))->all()); + } + + #[DataProvider('collectionClassProvider')] + public function testUnique($collection) + { + $c = new $collection(['Hello', 'World', 'World']); + $this->assertEquals(['Hello', 'World'], $c->unique()->all()); + + $c = new $collection([[1, 2], [1, 2], [2, 3], [3, 4], [2, 3]]); + $this->assertEquals([[1, 2], [2, 3], [3, 4]], $c->unique()->values()->all()); + } + + #[DataProvider('collectionClassProvider')] + public function testUniqueWithCallback($collection) + { + $c = new $collection([ + 1 => ['id' => 1, 'first' => 'Taylor', 'last' => 'Otwell'], + 2 => ['id' => 2, 'first' => 'Taylor', 'last' => 'Otwell'], + 3 => ['id' => 3, 'first' => 'Abigail', 'last' => 'Otwell'], + 4 => ['id' => 4, 'first' => 'Abigail', 'last' => 'Otwell'], + 5 => ['id' => 5, 'first' => 'Taylor', 'last' => 'Swift'], + 6 => ['id' => 6, 'first' => 'Taylor', 'last' => 'Swift'], + ]); + + $this->assertEquals([ + 1 => ['id' => 1, 'first' => 'Taylor', 'last' => 'Otwell'], + 3 => ['id' => 3, 'first' => 'Abigail', 'last' => 'Otwell'], + ], $c->unique('first')->all()); + + $this->assertEquals([ + 1 => ['id' => 1, 'first' => 'Taylor', 'last' => 'Otwell'], + 3 => ['id' => 3, 'first' => 'Abigail', 'last' => 'Otwell'], + 5 => ['id' => 5, 'first' => 'Taylor', 'last' => 'Swift'], + ], $c->unique(function ($item) { + return $item['first'] . $item['last']; + })->all()); + + $this->assertEquals([ + 1 => ['id' => 1, 'first' => 'Taylor', 'last' => 'Otwell'], + 2 => ['id' => 2, 'first' => 'Taylor', 'last' => 'Otwell'], + ], $c->unique(function ($item, $key) { + return $key % 2; + })->all()); + } + + #[DataProvider('collectionClassProvider')] + public function testUniqueStrict($collection) + { + $c = new $collection([ + [ + 'id' => '0', + 'name' => 'zero', + ], + [ + 'id' => '00', + 'name' => 'double zero', + ], + [ + 'id' => '0', + 'name' => 'again zero', + ], + ]); + + $this->assertEquals([ + [ + 'id' => '0', + 'name' => 'zero', + ], + [ + 'id' => '00', + 'name' => 'double zero', + ], + ], $c->uniqueStrict('id')->all()); + } + + #[DataProvider('collectionClassProvider')] + public function testCollapse($collection) + { + // Normal case: a two-dimensional array with different elements + $data = new $collection([[$object1 = new stdClass()], [$object2 = new stdClass()]]); + $this->assertEquals([$object1, $object2], $data->collapse()->all()); + + // Case including numeric and string elements + $data = new $collection([[1], [2], [3], ['foo', 'bar'], new $collection(['baz', 'boom'])]); + $this->assertEquals([1, 2, 3, 'foo', 'bar', 'baz', 'boom'], $data->collapse()->all()); + + // Case with empty two-dimensional arrays + $data = new $collection([[], [], []]); + $this->assertEquals([], $data->collapse()->all()); + + // Case with both empty arrays and arrays with elements + $data = new $collection([[], [1, 2], [], ['foo', 'bar']]); + $this->assertEquals([1, 2, 'foo', 'bar'], $data->collapse()->all()); + + // Case including collections and arrays + $collection = new $collection(['baz', 'boom']); + $data = new $collection([[1], [2], [3], ['foo', 'bar'], $collection]); + $this->assertEquals([1, 2, 3, 'foo', 'bar', 'baz', 'boom'], $data->collapse()->all()); + } + + #[DataProvider('collectionClassProvider')] + public function testCollapseWithNestedCollections($collection) + { + $data = new $collection([new $collection([1, 2, 3]), new $collection([4, 5, 6])]); + $this->assertEquals([1, 2, 3, 4, 5, 6], $data->collapse()->all()); + } + + #[DataProvider('collectionClassProvider')] + public function testCollapseWithKeys($collection) + { + $data = new $collection([[1 => 'a'], [3 => 'c'], [2 => 'b'], 'drop']); + $this->assertEquals([1 => 'a', 3 => 'c', 2 => 'b'], $data->collapseWithKeys()->all()); + + // Case with an already flat collection + $data = new $collection(['a', 'b', 'c']); + $this->assertEquals([], $data->collapseWithKeys()->all()); + } + + #[DataProvider('collectionClassProvider')] + public function testCollapseWithKeysOnNestedCollections($collection) + { + $data = new $collection([new $collection(['a' => '1a', 'b' => '1b']), new $collection(['b' => '2b', 'c' => '2c']), 'drop']); + $this->assertEquals(['a' => '1a', 'b' => '2b', 'c' => '2c'], $data->collapseWithKeys()->all()); + } + + #[DataProvider('collectionClassProvider')] + public function testJoin($collection) + { + $this->assertSame('a, b, c', (new $collection(['a', 'b', 'c']))->join(', ')); + + $this->assertSame('a, b and c', (new $collection(['a', 'b', 'c']))->join(', ', ' and ')); + + $this->assertSame('a and b', (new $collection(['a', 'b']))->join(', ', ' and ')); + + $this->assertSame('a', (new $collection(['a']))->join(', ', ' and ')); + + $this->assertSame('', (new $collection([]))->join(', ', ' and ')); + } + + #[DataProvider('collectionClassProvider')] + public function testCrossJoin($collection) + { + // Cross join with an array + $this->assertEquals( + [[1, 'a'], [1, 'b'], [2, 'a'], [2, 'b']], + (new $collection([1, 2]))->crossJoin(['a', 'b'])->all() + ); + + // Cross join with a collection + $this->assertEquals( + [[1, 'a'], [1, 'b'], [2, 'a'], [2, 'b']], + (new $collection([1, 2]))->crossJoin(new $collection(['a', 'b']))->all() + ); + + // Cross join with 2 collections + $this->assertEquals( + [ + [1, 'a', 'I'], [1, 'a', 'II'], + [1, 'b', 'I'], [1, 'b', 'II'], + [2, 'a', 'I'], [2, 'a', 'II'], + [2, 'b', 'I'], [2, 'b', 'II'], + ], + (new $collection([1, 2]))->crossJoin( + new $collection(['a', 'b']), + new $collection(['I', 'II']) + )->all() + ); + } + + #[DataProvider('collectionClassProvider')] + public function testSort($collection) + { + $data = (new $collection([5, 3, 1, 2, 4]))->sort(); + $this->assertEquals([1, 2, 3, 4, 5], $data->values()->all()); + + $data = (new $collection([-1, -3, -2, -4, -5, 0, 5, 3, 1, 2, 4]))->sort(); + $this->assertEquals([-5, -4, -3, -2, -1, 0, 1, 2, 3, 4, 5], $data->values()->all()); + + $data = (new $collection(['foo', 'bar-10', 'bar-1']))->sort(); + $this->assertEquals(['bar-1', 'bar-10', 'foo'], $data->values()->all()); + + $data = (new $collection(['T2', 'T1', 'T10']))->sort(); + $this->assertEquals(['T1', 'T10', 'T2'], $data->values()->all()); + + $data = (new $collection(['T2', 'T1', 'T10']))->sort(SORT_NATURAL); + $this->assertEquals(['T1', 'T2', 'T10'], $data->values()->all()); + } + + #[DataProvider('collectionClassProvider')] + public function testSortDesc($collection) + { + $data = (new $collection([5, 3, 1, 2, 4]))->sortDesc(); + $this->assertEquals([5, 4, 3, 2, 1], $data->values()->all()); + + $data = (new $collection([-1, -3, -2, -4, -5, 0, 5, 3, 1, 2, 4]))->sortDesc(); + $this->assertEquals([5, 4, 3, 2, 1, 0, -1, -2, -3, -4, -5], $data->values()->all()); + + $data = (new $collection(['bar-1', 'foo', 'bar-10']))->sortDesc(); + $this->assertEquals(['foo', 'bar-10', 'bar-1'], $data->values()->all()); + + $data = (new $collection(['T2', 'T1', 'T10']))->sortDesc(); + $this->assertEquals(['T2', 'T10', 'T1'], $data->values()->all()); + + $data = (new $collection(['T2', 'T1', 'T10']))->sortDesc(SORT_NATURAL); + $this->assertEquals(['T10', 'T2', 'T1'], $data->values()->all()); + } + + #[DataProvider('collectionClassProvider')] + public function testSortWithCallback($collection) + { + $data = (new $collection([5, 3, 1, 2, 4]))->sort(function ($a, $b) { + if ($a === $b) { + return 0; + } + + return ($a < $b) ? -1 : 1; + }); + + $this->assertEquals(range(1, 5), array_values($data->all())); + } + + #[DataProvider('collectionClassProvider')] + public function testSortBy($collection) + { + $data = new $collection(['taylor', 'dayle']); + $data = $data->sortBy(function ($x) { + return $x; + }); + + $this->assertEquals(['dayle', 'taylor'], array_values($data->all())); + + $data = new $collection(['dayle', 'taylor']); + $data = $data->sortByDesc(function ($x) { + return $x; + }); + + $this->assertEquals(['taylor', 'dayle'], array_values($data->all())); + } + + #[DataProvider('collectionClassProvider')] + public function testSortByString($collection) + { + $data = new $collection([['name' => 'taylor'], ['name' => 'dayle']]); + $data = $data->sortBy('name', SORT_STRING); + + $this->assertEquals([['name' => 'dayle'], ['name' => 'taylor']], array_values($data->all())); + + $data = new $collection([['name' => 'taylor'], ['name' => 'dayle']]); + $data = $data->sortBy('name', SORT_STRING, true); + + $this->assertEquals([['name' => 'taylor'], ['name' => 'dayle']], array_values($data->all())); + } + + #[DataProvider('collectionClassProvider')] + public function testSortByCallableString($collection) + { + $data = new $collection([['sort' => 2], ['sort' => 1]]); + $data = $data->sortBy([['sort', 'asc']]); + + $this->assertEquals([['sort' => 1], ['sort' => 2]], array_values($data->all())); + } + + #[DataProvider('collectionClassProvider')] + public function testSortByCallableStringDesc($collection) + { + $data = new $collection([['id' => 1, 'name' => 'foo'], ['id' => 2, 'name' => 'bar']]); + $data = $data->sortByDesc(['id']); + $this->assertEquals([['id' => 2, 'name' => 'bar'], ['id' => 1, 'name' => 'foo']], array_values($data->all())); + + $data = new $collection([['id' => 1, 'name' => 'foo'], ['id' => 2, 'name' => 'bar'], ['id' => 2, 'name' => 'baz']]); + $data = $data->sortByDesc(['id']); + $this->assertEquals([['id' => 2, 'name' => 'bar'], ['id' => 2, 'name' => 'baz'], ['id' => 1, 'name' => 'foo']], array_values($data->all())); + + $data = $data->sortByDesc(['id', 'name']); + $this->assertEquals([['id' => 2, 'name' => 'baz'], ['id' => 2, 'name' => 'bar'], ['id' => 1, 'name' => 'foo']], array_values($data->all())); + } + + #[DataProvider('collectionClassProvider')] + public function testSortByAlwaysReturnsAssoc($collection) + { + $data = new $collection(['a' => 'taylor', 'b' => 'dayle']); + $data = $data->sortBy(function ($x) { + return $x; + }); + + $this->assertEquals(['b' => 'dayle', 'a' => 'taylor'], $data->all()); + + $data = new $collection(['taylor', 'dayle']); + $data = $data->sortBy(function ($x) { + return $x; + }); + + $this->assertEquals([1 => 'dayle', 0 => 'taylor'], $data->all()); + + $data = new $collection(['a' => ['sort' => 2], 'b' => ['sort' => 1]]); + $data = $data->sortBy([['sort', 'asc']]); + + $this->assertEquals(['b' => ['sort' => 1], 'a' => ['sort' => 2]], $data->all()); + + $data = new $collection([['sort' => 2], ['sort' => 1]]); + $data = $data->sortBy([['sort', 'asc']]); + + $this->assertEquals([1 => ['sort' => 1], 0 => ['sort' => 2]], $data->all()); + } + + #[DataProvider('collectionClassProvider')] + public function testSortByMany($collection) + { + $defaultLocale = setlocale(LC_ALL, 0); + + $data = new $collection([['item' => '1'], ['item' => '10'], ['item' => 5], ['item' => 20]]); + $expected = $data->pluck('item')->toArray(); + + sort($expected); + $data = $data->sortBy(['item']); + $this->assertEquals($data->pluck('item')->toArray(), $expected); + + rsort($expected); + $data = $data->sortBy([['item', 'desc']]); + $this->assertEquals($data->pluck('item')->toArray(), $expected); + + sort($expected, SORT_STRING); + $data = $data->sortBy(['item'], SORT_STRING); + $this->assertEquals($data->pluck('item')->toArray(), $expected); + + rsort($expected, SORT_STRING); + $data = $data->sortBy([['item', 'desc']], SORT_STRING); + $this->assertEquals($data->pluck('item')->toArray(), $expected); + + sort($expected, SORT_NUMERIC); + $data = $data->sortBy(['item'], SORT_NUMERIC); + $this->assertEquals($data->pluck('item')->toArray(), $expected); + + rsort($expected, SORT_NUMERIC); + $data = $data->sortBy([['item', 'desc']], SORT_NUMERIC); + $this->assertEquals($data->pluck('item')->toArray(), $expected); + + $data = new $collection([['item' => 'img1'], ['item' => 'img101'], ['item' => 'img10'], ['item' => 'img11']]); + $expected = $data->pluck('item')->toArray(); + + sort($expected, SORT_NUMERIC); + $data = $data->sortBy(['item'], SORT_NUMERIC); + $this->assertEquals($data->pluck('item')->toArray(), $expected); + + sort($expected); + $data = $data->sortBy(['item']); + $this->assertEquals($data->pluck('item')->toArray(), $expected); + + sort($expected, SORT_NATURAL); + $data = $data->sortBy(['item'], SORT_NATURAL); + $this->assertEquals($data->pluck('item')->toArray(), $expected); + + $data = new $collection([['item' => 'img1'], ['item' => 'Img101'], ['item' => 'img10'], ['item' => 'Img11']]); + $expected = $data->pluck('item')->toArray(); + + sort($expected); + $data = $data->sortBy(['item']); + $this->assertEquals($data->pluck('item')->toArray(), $expected); + + sort($expected, SORT_NATURAL | SORT_FLAG_CASE); + $data = $data->sortBy(['item'], SORT_NATURAL | SORT_FLAG_CASE); + $this->assertEquals($data->pluck('item')->toArray(), $expected); + + sort($expected, SORT_FLAG_CASE | SORT_STRING); + $data = $data->sortBy(['item'], SORT_FLAG_CASE | SORT_STRING); + $this->assertEquals($data->pluck('item')->toArray(), $expected); + + sort($expected, SORT_FLAG_CASE | SORT_NUMERIC); + $data = $data->sortBy(['item'], SORT_FLAG_CASE | SORT_NUMERIC); + $this->assertEquals($data->pluck('item')->toArray(), $expected); + + $data = new $collection([['item' => 'Österreich'], ['item' => 'Oesterreich'], ['item' => 'Zeta']]); + $expected = $data->pluck('item')->toArray(); + + sort($expected); + $data = $data->sortBy(['item']); + $this->assertEquals($data->pluck('item')->toArray(), $expected); + + sort($expected, SORT_LOCALE_STRING); + $data = $data->sortBy(['item'], SORT_LOCALE_STRING); + $this->assertEquals($data->pluck('item')->toArray(), $expected); + + setlocale(LC_ALL, 'de_DE'); + + sort($expected, SORT_LOCALE_STRING); + $data = $data->sortBy(['item'], SORT_LOCALE_STRING); + $this->assertEquals($data->pluck('item')->toArray(), $expected); + + setlocale(LC_ALL, $defaultLocale); + } + + #[DataProvider('collectionClassProvider')] + public function testNaturalSortByManyWithNull($collection) + { + $itemFoo = new stdClass(); + $itemFoo->first = 'f'; + $itemFoo->second = null; + $itemBar = new stdClass(); + $itemBar->first = 'f'; + $itemBar->second = 's'; + + $data = new $collection([$itemFoo, $itemBar]); + $data = $data->sortBy([ + ['first', 'desc'], + ['second', 'desc'], + ], SORT_NATURAL); + + $this->assertEquals($itemBar, $data->first()); + $this->assertEquals($itemFoo, $data->skip(1)->first()); + } + + #[DataProvider('collectionClassProvider')] + public function testSortKeys($collection) + { + $data = new $collection(['b' => 'dayle', 'a' => 'taylor']); + + $this->assertSame(['a' => 'taylor', 'b' => 'dayle'], $data->sortKeys()->all()); + } + + #[DataProvider('collectionClassProvider')] + public function testSortKeysDesc($collection) + { + $data = new $collection(['a' => 'taylor', 'b' => 'dayle']); + + $this->assertSame(['b' => 'dayle', 'a' => 'taylor'], $data->sortKeysDesc()->all()); + } + + #[DataProvider('collectionClassProvider')] + public function testSortKeysUsing($collection) + { + $data = new $collection(['B' => 'dayle', 'a' => 'taylor']); + + $this->assertSame(['a' => 'taylor', 'B' => 'dayle'], $data->sortKeysUsing('strnatcasecmp')->all()); + } + + #[DataProvider('collectionClassProvider')] + public function testReverse($collection) + { + $data = new $collection(['zaeed', 'alan']); + $reversed = $data->reverse(); + + $this->assertSame([1 => 'alan', 0 => 'zaeed'], $reversed->all()); + + $data = new $collection(['name' => 'taylor', 'framework' => 'laravel']); + $reversed = $data->reverse(); + + $this->assertSame(['framework' => 'laravel', 'name' => 'taylor'], $reversed->all()); + } + + #[DataProvider('collectionClassProvider')] + public function testFlip($collection) + { + $data = new $collection(['name' => 'taylor', 'framework' => 'laravel']); + $this->assertEquals(['taylor' => 'name', 'laravel' => 'framework'], $data->flip()->toArray()); + } + + #[DataProvider('collectionClassProvider')] + public function testChunk($collection) + { + $data = new $collection([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); + $data = $data->chunk(3); + + $this->assertInstanceOf($collection, $data); + $this->assertInstanceOf($collection, $data->first()); + $this->assertCount(4, $data); + $this->assertEquals([1, 2, 3], $data->first()->toArray()); + $this->assertEquals([9 => 10], $data->get(3)->toArray()); + } + + #[DataProvider('collectionClassProvider')] + public function testChunkWhenGivenZeroAsSize($collection) + { + $data = new $collection([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); + + $this->assertEquals( + [], + $data->chunk(0)->toArray() + ); + } + + #[DataProvider('collectionClassProvider')] + public function testChunkWhenGivenLessThanZero($collection) + { + $data = new $collection([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); + + $this->assertEquals( + [], + $data->chunk(-1)->toArray() + ); + } + + #[DataProvider('collectionClassProvider')] + public function testChunkPreservingKeys($collection) + { + $data = new $collection(['a' => 1, 'b' => 2, 'c' => 3, 'd' => 4, 'e' => 5]); + + $this->assertEquals( + [['a' => 1, 'b' => 2], ['c' => 3, 'd' => 4], ['e' => 5]], + $data->chunk(2)->toArray() + ); + + $data = new $collection([1, 2, 3, 4, 5]); + + $this->assertEquals( + [[0 => 1, 1 => 2], [0 => 3, 1 => 4], [0 => 5]], + $data->chunk(2, false)->toArray() + ); + } + + #[DataProvider('collectionClassProvider')] + public function testSplitIn($collection) + { + $data = new $collection([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); + $data = $data->splitIn(3); + + $this->assertInstanceOf($collection, $data); + $this->assertInstanceOf($collection, $data->first()); + $this->assertCount(3, $data); + $this->assertEquals([1, 2, 3, 4], $data->get(0)->values()->toArray()); + $this->assertEquals([5, 6, 7, 8], $data->get(1)->values()->toArray()); + $this->assertEquals([9, 10], $data->get(2)->values()->toArray()); + } + + #[DataProvider('collectionClassProvider')] + public function testChunkWhileOnEqualElements($collection) + { + $data = (new $collection(['A', 'A', 'B', 'B', 'C', 'C', 'C'])) + ->chunkWhile(function ($current, $key, $chunk) { + return $chunk->last() === $current; + }); + + $this->assertInstanceOf($collection, $data); + $this->assertInstanceOf($collection, $data->first()); + $this->assertEquals([0 => 'A', 1 => 'A'], $data->first()->toArray()); + $this->assertEquals([2 => 'B', 3 => 'B'], $data->get(1)->toArray()); + $this->assertEquals([4 => 'C', 5 => 'C', 6 => 'C'], $data->last()->toArray()); + } + + #[DataProvider('collectionClassProvider')] + public function testChunkWhileOnContiguouslyIncreasingIntegers($collection) + { + $data = (new $collection([1, 4, 9, 10, 11, 12, 15, 16, 19, 20, 21])) + ->chunkWhile(function ($current, $key, $chunk) { + return $chunk->last() + 1 == $current; + }); + + $this->assertInstanceOf($collection, $data); + $this->assertInstanceOf($collection, $data->first()); + $this->assertEquals([0 => 1], $data->first()->toArray()); + $this->assertEquals([1 => 4], $data->get(1)->toArray()); + $this->assertEquals([2 => 9, 3 => 10, 4 => 11, 5 => 12], $data->get(2)->toArray()); + $this->assertEquals([6 => 15, 7 => 16], $data->get(3)->toArray()); + $this->assertEquals([8 => 19, 9 => 20, 10 => 21], $data->last()->toArray()); + } + + #[DataProvider('collectionClassProvider')] + public function testChunkWhilePreservingStringKeys($collection) + { + $data = (new $collection(['a' => 1, 'b' => 1, 'c' => 2, 'd' => 2, 'e' => 3, 'f' => 3, 'g' => 3])) + ->chunkWhile(function ($current, $key, $chunk) { + return $chunk->last() === $current; + }); + + $this->assertInstanceOf($collection, $data); + $this->assertInstanceOf($collection, $data->first()); + $this->assertEquals(['a' => 1, 'b' => 1], $data->first()->toArray()); + $this->assertEquals(['c' => 2, 'd' => 2], $data->get(1)->toArray()); + $this->assertEquals(['e' => 3, 'f' => 3, 'g' => 3], $data->last()->toArray()); + } + + #[DataProvider('collectionClassProvider')] + public function testEvery($collection) + { + $c = new $collection([]); + $this->assertTrue($c->every('key', 'value')); + $this->assertTrue($c->every(function () { + return false; + })); + + $c = new $collection([['age' => 18], ['age' => 20], ['age' => 20]]); + $this->assertFalse($c->every('age', 18)); + $this->assertTrue($c->every('age', '>=', 18)); + $this->assertTrue($c->every(function ($item) { + return $item['age'] >= 18; + })); + $this->assertFalse($c->every(function ($item) { + return $item['age'] >= 20; + })); + + $c = new $collection([null, null]); + $this->assertTrue($c->every(function ($item) { + return $item === null; + })); + + $c = new $collection([['active' => true], ['active' => true]]); + $this->assertTrue($c->every('active')); + $this->assertTrue($c->every->active); + $this->assertFalse($c->concat([['active' => false]])->every->active); + } + + #[DataProvider('collectionClassProvider')] + public function testExcept($collection) + { + $data = new $collection(['first' => 'Taylor', 'last' => 'Otwell', 'email' => 'taylorotwell@gmail.com']); + + $this->assertEquals($data->all(), $data->except(null)->all()); + $this->assertEquals(['first' => 'Taylor'], $data->except(['last', 'email', 'missing'])->all()); + $this->assertEquals(['first' => 'Taylor'], $data->except('last', 'email', 'missing')->all()); + $this->assertEquals(['first' => 'Taylor'], $data->except(collect(['last', 'email', 'missing']))->all()); + + $this->assertEquals(['first' => 'Taylor', 'email' => 'taylorotwell@gmail.com'], $data->except(['last'])->all()); + $this->assertEquals(['first' => 'Taylor', 'email' => 'taylorotwell@gmail.com'], $data->except('last')->all()); + $this->assertEquals(['first' => 'Taylor', 'email' => 'taylorotwell@gmail.com'], $data->except(collect(['last']))->all()); + } + + #[DataProvider('collectionClassProvider')] + public function testExceptSelf($collection) + { + $data = new $collection(['first' => 'Taylor', 'last' => 'Otwell']); + $this->assertEquals(['first' => 'Taylor', 'last' => 'Otwell'], $data->except($data)->all()); + } + + #[DataProvider('collectionClassProvider')] + public function testPluckWithArrayAndObjectValues($collection) + { + $data = new $collection([(object) ['name' => 'taylor', 'email' => 'foo'], ['name' => 'dayle', 'email' => 'bar']]); + $this->assertEquals(['taylor' => 'foo', 'dayle' => 'bar'], $data->pluck('email', 'name')->all()); + $this->assertEquals(['foo', 'bar'], $data->pluck('email')->all()); + } + + #[DataProvider('collectionClassProvider')] + public function testPluckWithArrayAccessValues($collection) + { + $data = new $collection([ + new TestArrayAccessImplementation(['name' => 'taylor', 'email' => 'foo']), + new TestArrayAccessImplementation(['name' => 'dayle', 'email' => 'bar']), + ]); + + $this->assertEquals(['taylor' => 'foo', 'dayle' => 'bar'], $data->pluck('email', 'name')->all()); + $this->assertEquals(['foo', 'bar'], $data->pluck('email')->all()); + } + + #[DataProvider('collectionClassProvider')] + public function testPluckWithDotNotation($collection) + { + $data = new $collection([ + [ + 'name' => 'amir', + 'skill' => [ + 'backend' => ['php', 'python'], + ], + ], + [ + 'name' => 'taylor', + 'skill' => [ + 'backend' => ['php', 'asp', 'java'], + ], + ], + ]); + + $this->assertEquals([['php', 'python'], ['php', 'asp', 'java']], $data->pluck('skill.backend')->all()); + } + + #[DataProvider('collectionClassProvider')] + public function testPluckWithClosure($collection) + { + $data = new $collection([ + [ + 'name' => 'amir', + 'skill' => [ + 'backend' => ['php', 'python'], + ], + ], + [ + 'name' => 'taylor', + 'skill' => [ + 'backend' => ['php', 'asp', 'java'], + ], + ], + ]); + + $this->assertEquals(['amir (verified)', 'taylor (verified)'], $data->pluck(fn (array $row) => "{$row['name']} (verified)")->all()); + $this->assertEquals(['php/python' => 'amir', 'php/asp/java' => 'taylor'], $data->pluck('name', fn (array $row) => implode('/', $row['skill']['backend']))->all()); + } + + #[DataProvider('collectionClassProvider')] + public function testPluckDuplicateKeysExist($collection) + { + $data = new $collection([ + ['brand' => 'Tesla', 'color' => 'red'], + ['brand' => 'Pagani', 'color' => 'white'], + ['brand' => 'Tesla', 'color' => 'black'], + ['brand' => 'Pagani', 'color' => 'orange'], + ]); + + $this->assertEquals(['Tesla' => 'black', 'Pagani' => 'orange'], $data->pluck('color', 'brand')->all()); + } + + #[DataProvider('collectionClassProvider')] + public function testHas($collection) + { + $data = new $collection(['id' => 1, 'first' => 'Hello', 'second' => 'World']); + $this->assertTrue($data->has('first')); + $this->assertFalse($data->has('third')); + $this->assertTrue($data->has(['first', 'second'])); + $this->assertFalse($data->has(['third', 'first'])); + $this->assertTrue($data->has('first', 'second')); + } + + #[DataProvider('collectionClassProvider')] + public function testHasAny($collection) + { + $data = new $collection(['id' => 1, 'first' => 'Hello', 'second' => 'World']); + + $this->assertTrue($data->hasAny('first')); + $this->assertFalse($data->hasAny('third')); + $this->assertTrue($data->hasAny(['first', 'second'])); + $this->assertTrue($data->hasAny(['first', 'fourth'])); + $this->assertFalse($data->hasAny(['third', 'fourth'])); + $this->assertFalse($data->hasAny('third', 'fourth')); + $this->assertFalse($data->hasAny([])); + } + + #[DataProvider('collectionClassProvider')] + public function testImplode($collection) + { + $data = new $collection([['name' => 'taylor', 'email' => 'foo'], ['name' => 'dayle', 'email' => 'bar']]); + $this->assertSame('foobar', $data->implode('email')); + $this->assertSame('foo,bar', $data->implode('email', ',')); + + $data = new $collection(['taylor', 'dayle']); + $this->assertSame('taylordayle', $data->implode('')); + $this->assertSame('taylor,dayle', $data->implode(',')); + + $data = new $collection([ + ['name' => new Stringable('taylor'), 'email' => new Stringable('foo')], + ['name' => new Stringable('dayle'), 'email' => new Stringable('bar')], + ]); + $this->assertSame('foobar', $data->implode('email')); + $this->assertSame('foo,bar', $data->implode('email', ',')); + + $data = new $collection([new Stringable('taylor'), new Stringable('dayle')]); + $this->assertSame('taylordayle', $data->implode('')); + $this->assertSame('taylor,dayle', $data->implode(',')); + $this->assertSame('taylor_dayle', $data->implode('_')); + + $data = new $collection([['name' => 'taylor', 'email' => 'foo'], ['name' => 'dayle', 'email' => 'bar']]); + $this->assertSame('taylor-foodayle-bar', $data->implode(fn ($user) => $user['name'] . '-' . $user['email'])); + $this->assertSame('taylor-foo,dayle-bar', $data->implode(fn ($user) => $user['name'] . '-' . $user['email'], ',')); + } + + #[DataProvider('collectionClassProvider')] + public function testImplodeModels($collection) + { + $model = new class extends Model { + }; + $model->setAttribute('email', 'foo'); + $modelTwo = new class extends Model { + }; + $modelTwo->setAttribute('email', 'bar'); + $data = new $collection([$model, $modelTwo]); + + $this->assertSame('foobar', $data->implode('email')); + $this->assertSame('foo,bar', $data->implode('email', ',')); + } + + #[DataProvider('collectionClassProvider')] + public function testTake($collection) + { + $data = new $collection(['taylor', 'dayle', 'shawn']); + $data = $data->take(2); + $this->assertEquals(['taylor', 'dayle'], $data->all()); + } + + public function testGetOrPut() + { + $data = new Collection(['name' => 'taylor', 'email' => 'foo']); + + $this->assertSame('taylor', $data->getOrPut('name', null)); + $this->assertSame('foo', $data->getOrPut('email', null)); + $this->assertSame('male', $data->getOrPut('gender', 'male')); + + $this->assertSame('taylor', $data->get('name')); + $this->assertSame('foo', $data->get('email')); + $this->assertSame('male', $data->get('gender')); + + $data = new Collection(['name' => 'taylor', 'email' => 'foo']); + + $this->assertSame('taylor', $data->getOrPut('name', function () { + return null; + })); + + $this->assertSame('foo', $data->getOrPut('email', function () { + return null; + })); + + $this->assertSame('male', $data->getOrPut('gender', function () { + return 'male'; + })); + + $this->assertSame('taylor', $data->get('name')); + $this->assertSame('foo', $data->get('email')); + $this->assertSame('male', $data->get('gender')); + } + + public function testGetOrPutWithNoKey() + { + $data = new Collection(['taylor', 'shawn']); + $this->assertSame('dayle', $data->getOrPut(null, 'dayle')); + $this->assertSame('john', $data->getOrPut(null, 'john')); + $this->assertSame(['taylor', 'shawn', 'dayle', 'john'], $data->all()); + + $data = new Collection(['taylor', '' => 'shawn']); + $this->assertSame('shawn', $data->getOrPut(null, 'dayle')); + $this->assertSame(['taylor', '' => 'shawn'], $data->all()); + } + + public function testPut() + { + $data = new Collection(['name' => 'taylor', 'email' => 'foo']); + $data = $data->put('name', 'dayle'); + $this->assertEquals(['name' => 'dayle', 'email' => 'foo'], $data->all()); + } + + public function testPutWithNoKey() + { + $data = new Collection(['taylor', 'shawn']); + $data = $data->put(null, 'dayle'); + $this->assertEquals(['taylor', 'shawn', 'dayle'], $data->all()); + } + + #[DataProvider('collectionClassProvider')] + public function testRandom($collection) + { + $data = new $collection([1, 2, 3, 4, 5, 6]); + + $random = $data->random(); + $this->assertIsInt($random); + $this->assertContains($random, $data->all()); + + $random = $data->random(0); + $this->assertInstanceOf($collection, $random); + $this->assertCount(0, $random); + + $random = $data->random(1); + $this->assertInstanceOf($collection, $random); + $this->assertCount(1, $random); + + $random = $data->random(2); + $this->assertInstanceOf($collection, $random); + $this->assertCount(2, $random); + + $random = $data->random('0'); + $this->assertInstanceOf($collection, $random); + $this->assertCount(0, $random); + + $random = $data->random('1'); + $this->assertInstanceOf($collection, $random); + $this->assertCount(1, $random); + + $random = $data->random('2'); + $this->assertInstanceOf($collection, $random); + $this->assertCount(2, $random); + + $random = $data->random(2, true); + $this->assertInstanceOf($collection, $random); + $this->assertCount(2, $random); + $this->assertCount(2, array_intersect_assoc($random->all(), $data->all())); + + $random = $data->random(fn ($items) => min(10, count($items))); + $this->assertInstanceOf($collection, $random); + $this->assertCount(6, $random); + + $random = $data->random(fn ($items) => min(10, count($items) - 1), true); + $this->assertInstanceOf($collection, $random); + $this->assertCount(5, $random); + $this->assertCount(5, array_intersect_assoc($random->all(), $data->all())); + } + + #[DataProvider('collectionClassProvider')] + public function testRandomOnEmptyCollection($collection) + { + $data = new $collection(); + + $random = $data->random(0); + $this->assertInstanceOf($collection, $random); + $this->assertCount(0, $random); + + $random = $data->random('0'); + $this->assertInstanceOf($collection, $random); + $this->assertCount(0, $random); + } + + #[DataProvider('collectionClassProvider')] + public function testTakeLast($collection) + { + $data = new $collection(['taylor', 'dayle', 'shawn']); + $data = $data->take(-2); + $this->assertEquals([1 => 'dayle', 2 => 'shawn'], $data->all()); + } + + #[DataProvider('collectionClassProvider')] + public function testTakeUntilUsingValue($collection) + { + $data = new $collection([1, 2, 3, 4]); + + $data = $data->takeUntil(3); + + $this->assertSame([1, 2], $data->toArray()); + } + + #[DataProvider('collectionClassProvider')] + public function testTakeUntilUsingCallback($collection) + { + $data = new $collection([1, 2, 3, 4]); + + $data = $data->takeUntil(function ($item) { + return $item >= 3; + }); + + $this->assertSame([1, 2], $data->toArray()); + } + + #[DataProvider('collectionClassProvider')] + public function testTakeUntilReturnsAllItemsForUnmetValue($collection) + { + $data = new $collection([1, 2, 3, 4]); + + $actual = $data->takeUntil(99); + + $this->assertSame($data->toArray(), $actual->toArray()); + + $actual = $data->takeUntil(function ($item) { + return $item >= 99; + }); + + $this->assertSame($data->toArray(), $actual->toArray()); + } + + #[DataProvider('collectionClassProvider')] + public function testTakeUntilCanBeProxied($collection) + { + $data = new $collection([ + new TestSupportCollectionHigherOrderItem('Adam'), + new TestSupportCollectionHigherOrderItem('Taylor'), + new TestSupportCollectionHigherOrderItem('Jason'), + ]); + + $actual = $data->takeUntil->is('Jason'); + + $this->assertCount(2, $actual); + $this->assertSame('Adam', $actual->get(0)->name); + $this->assertSame('Taylor', $actual->get(1)->name); + } + + #[DataProvider('collectionClassProvider')] + public function testTakeWhileUsingValue($collection) + { + $data = new $collection([1, 1, 2, 2, 3, 3]); + + $data = $data->takeWhile(1); + + $this->assertSame([1, 1], $data->toArray()); + } + + #[DataProvider('collectionClassProvider')] + public function testTakeWhileUsingCallback($collection) + { + $data = new $collection([1, 2, 3, 4]); + + $data = $data->takeWhile(function ($item) { + return $item < 3; + }); + + $this->assertSame([1, 2], $data->toArray()); + } + + #[DataProvider('collectionClassProvider')] + public function testTakeWhileReturnsNoItemsForUnmetValue($collection) + { + $data = new $collection([1, 2, 3, 4]); + + $actual = $data->takeWhile(2); + + $this->assertSame([], $actual->toArray()); + + $actual = $data->takeWhile(function ($item) { + return $item == 99; + }); + + $this->assertSame([], $actual->toArray()); + } + + #[DataProvider('collectionClassProvider')] + public function testTakeWhileCanBeProxied($collection) + { + $data = new $collection([ + new TestSupportCollectionHigherOrderItem('Adam'), + new TestSupportCollectionHigherOrderItem('Adam'), + new TestSupportCollectionHigherOrderItem('Taylor'), + new TestSupportCollectionHigherOrderItem('Taylor'), + ]); + + $actual = $data->takeWhile->is('Adam'); + + $this->assertCount(2, $actual); + $this->assertSame('Adam', $actual->get(0)->name); + $this->assertSame('Adam', $actual->get(1)->name); + } + + #[DataProvider('collectionClassProvider')] + public function testMacroable($collection) + { + // Foo() macro : unique values starting with A + $collection::macro('foo', function () { + return $this->filter(function ($item) { + return str_starts_with($item, 'a'); + }) + ->unique() + ->values(); + }); + + $c = new $collection(['a', 'a', 'aa', 'aaa', 'bar']); + + $this->assertSame(['a', 'aa', 'aaa'], $c->foo()->all()); + } + + #[DataProvider('collectionClassProvider')] + public function testCanAddMethodsToProxy($collection) + { + $collection::macro('adults', function ($callback) { + return $this->filter(function ($item) use ($callback) { + return $callback($item) >= 18; + }); + }); + + $collection::proxy('adults'); + + $c = new $collection([['age' => 3], ['age' => 12], ['age' => 18], ['age' => 56]]); + + $this->assertSame([['age' => 18], ['age' => 56]], $c->adults->age->values()->all()); + } + + #[DataProvider('collectionClassProvider')] + public function testMakeMethod($collection) + { + $data = $collection::make('foo'); + $this->assertEquals(['foo'], $data->all()); + } + + #[DataProvider('collectionClassProvider')] + public function testMakeMethodFromNull($collection) + { + $data = $collection::make(null); + $this->assertEquals([], $data->all()); + + $data = $collection::make(); + $this->assertEquals([], $data->all()); + } + + #[DataProvider('collectionClassProvider')] + public function testMakeMethodFromCollection($collection) + { + $firstCollection = $collection::make(['foo' => 'bar']); + $secondCollection = $collection::make($firstCollection); + $this->assertEquals(['foo' => 'bar'], $secondCollection->all()); + } + + #[DataProvider('collectionClassProvider')] + public function testMakeMethodFromArray($collection) + { + $data = $collection::make(['foo' => 'bar']); + $this->assertEquals(['foo' => 'bar'], $data->all()); + } + + #[DataProvider('collectionClassProvider')] + public function testWrapWithScalar($collection) + { + $data = $collection::wrap('foo'); + $this->assertEquals(['foo'], $data->all()); + } + + #[DataProvider('collectionClassProvider')] + public function testWrapWithArray($collection) + { + $data = $collection::wrap(['foo']); + $this->assertEquals(['foo'], $data->all()); + } + + #[DataProvider('collectionClassProvider')] + public function testWrapWithArrayable($collection) + { + $data = $collection::wrap($o = new TestArrayableObject()); + $this->assertEquals([$o], $data->all()); + } + + #[DataProvider('collectionClassProvider')] + public function testWrapWithJsonable($collection) + { + $data = $collection::wrap($o = new TestJsonableObject()); + $this->assertEquals([$o], $data->all()); + } + + #[DataProvider('collectionClassProvider')] + public function testWrapWithJsonSerialize($collection) + { + $data = $collection::wrap($o = new TestJsonSerializeObject()); + $this->assertEquals([$o], $data->all()); + } + + #[DataProvider('collectionClassProvider')] + public function testWrapWithCollectionClass($collection) + { + $data = $collection::wrap($collection::make(['foo'])); + $this->assertEquals(['foo'], $data->all()); + } + + #[DataProvider('collectionClassProvider')] + public function testWrapWithCollectionSubclass($collection) + { + $data = TestCollectionSubclass::wrap($collection::make(['foo'])); + $this->assertEquals(['foo'], $data->all()); + $this->assertInstanceOf(TestCollectionSubclass::class, $data); + } + + #[DataProvider('collectionClassProvider')] + public function testUnwrapCollection($collection) + { + $data = new $collection(['foo']); + $this->assertEquals(['foo'], $collection::unwrap($data)); + } + + #[DataProvider('collectionClassProvider')] + public function testUnwrapCollectionWithArray($collection) + { + $this->assertEquals(['foo'], $collection::unwrap(['foo'])); + } + + #[DataProvider('collectionClassProvider')] + public function testUnwrapCollectionWithScalar($collection) + { + $this->assertSame('foo', $collection::unwrap('foo')); + } + + #[DataProvider('collectionClassProvider')] + public function testEmptyMethod($collection) + { + $collection = $collection::empty(); + + $this->assertCount(0, $collection->all()); + } + + #[DataProvider('collectionClassProvider')] + public function testTimesMethod($collection) + { + $two = $collection::times(2, function ($number) { + return 'slug-' . $number; + }); + + $zero = $collection::times(0, function ($number) { + return 'slug-' . $number; + }); + + $negative = $collection::times(-4, function ($number) { + return 'slug-' . $number; + }); + + $range = $collection::times(5); + + $this->assertEquals(['slug-1', 'slug-2'], $two->all()); + $this->assertTrue($zero->isEmpty()); + $this->assertTrue($negative->isEmpty()); + $this->assertEquals(range(1, 5), $range->all()); + } + + #[DataProvider('collectionClassProvider')] + public function testRangeMethod($collection) + { + $this->assertSame( + [1, 2, 3, 4, 5], + $collection::range(1, 5)->all() + ); + + $this->assertSame( + [-2, -1, 0, 1, 2], + $collection::range(-2, 2)->all() + ); + + $this->assertSame( + [-4, -3, -2], + $collection::range(-4, -2)->all() + ); + + $this->assertSame( + [5, 4, 3, 2, 1], + $collection::range(5, 1)->all() + ); + + $this->assertSame( + [2, 1, 0, -1, -2], + $collection::range(2, -2)->all() + ); + + $this->assertSame( + [-2, -3, -4], + $collection::range(-2, -4)->all() + ); + } + + #[DataProvider('collectionClassProvider')] + public function testFromJson($collection) + { + $json = json_encode($array = ['foo' => 'bar', 'baz' => 'quz']); + + $instance = $collection::fromJson($json); + + $this->assertSame($array, $instance->toArray()); + } + + #[DataProvider('collectionClassProvider')] + public function testFromJsonWithDepth($collection) + { + $json = json_encode(['foo' => ['baz' => ['quz']], 'bar' => 'baz']); + + $instance = $collection::fromJson($json, 1); + + $this->assertEmpty($instance->toArray()); + $this->assertSame(JSON_ERROR_DEPTH, json_last_error()); + } + + #[DataProvider('collectionClassProvider')] + public function testFromJsonWithFlags($collection) + { + $instance = $collection::fromJson('{"int":99999999999999999999999}', 512, JSON_BIGINT_AS_STRING); + + $this->assertSame(['int' => '99999999999999999999999'], $instance->toArray()); + } + + #[DataProvider('collectionClassProvider')] + public function testConstructMakeFromObject($collection) + { + $object = new stdClass(); + $object->foo = 'bar'; + $data = $collection::make($object); + $this->assertEquals(['foo' => 'bar'], $data->all()); + } + + #[DataProvider('collectionClassProvider')] + public function testConstructMethod($collection) + { + $data = new $collection('foo'); + $this->assertEquals(['foo'], $data->all()); + } + + #[DataProvider('collectionClassProvider')] + public function testConstructMethodFromNull($collection) + { + $data = new $collection(null); + $this->assertEquals([], $data->all()); + + $data = new $collection(); + $this->assertEquals([], $data->all()); + } + + #[DataProvider('collectionClassProvider')] + public function testConstructMethodFromCollection($collection) + { + $firstCollection = new $collection(['foo' => 'bar']); + $secondCollection = new $collection($firstCollection); + $this->assertEquals(['foo' => 'bar'], $secondCollection->all()); + } + + #[DataProvider('collectionClassProvider')] + public function testConstructMethodFromArray($collection) + { + $data = new $collection(['foo' => 'bar']); + $this->assertEquals(['foo' => 'bar'], $data->all()); + } + + #[DataProvider('collectionClassProvider')] + public function testConstructMethodFromObject($collection) + { + $object = new stdClass(); + $object->foo = 'bar'; + $data = new $collection($object); + $this->assertEquals(['foo' => 'bar'], $data->all()); + } + + #[DataProvider('collectionClassProvider')] + public function testConstructMethodFromWeakMap($collection) + { + $map = new WeakMap(); + $object = new stdClass(); + $object->foo = 'bar'; + $map[$object] = 3; + $data = new $collection($map); + $this->assertEquals([3], $data->all()); + } + + public function testSplice() + { + $data = new Collection(['foo', 'baz']); + $data->splice(1); + $this->assertEquals(['foo'], $data->all()); + + $data = new Collection(['foo', 'baz']); + $data->splice(1, 0, 'bar'); + $this->assertEquals(['foo', 'bar', 'baz'], $data->all()); + + $data = new Collection(['foo', 'baz']); + $data->splice(1, 1); + $this->assertEquals(['foo'], $data->all()); + + $data = new Collection(['foo', 'baz']); + $cut = $data->splice(1, 1, 'bar'); + $this->assertEquals(['foo', 'bar'], $data->all()); + $this->assertEquals(['baz'], $cut->all()); + + $data = new Collection(['foo', 'baz']); + $data->splice(1, 0, ['bar']); + $this->assertEquals(['foo', 'bar', 'baz'], $data->all()); + + $data = new Collection(['foo', 'baz']); + $data->splice(1, 0, new Collection(['bar'])); + $this->assertEquals(['foo', 'bar', 'baz'], $data->all()); + } + + #[DataProvider('collectionClassProvider')] + public function testGetPluckValueWithAccessors($collection) + { + $model = new TestAccessorEloquentTestStub(['some' => 'foo']); + $modelTwo = new TestAccessorEloquentTestStub(['some' => 'bar']); + $data = new $collection([$model, $modelTwo]); + + $this->assertEquals(['foo', 'bar'], $data->pluck('some')->all()); + } + + #[DataProvider('collectionClassProvider')] + public function testMap($collection) + { + $data = new $collection([1, 2, 3]); + $mapped = $data->map(function ($item, $key) { + return $item * 2; + }); + $this->assertEquals([2, 4, 6], $mapped->all()); + $this->assertEquals([1, 2, 3], $data->all()); + + $data = new $collection(['first' => 'taylor', 'last' => 'otwell']); + $data = $data->map(function ($item, $key) { + return $key . '-' . strrev($item); + }); + $this->assertEquals(['first' => 'first-rolyat', 'last' => 'last-llewto'], $data->all()); + } + + #[DataProvider('collectionClassProvider')] + public function testMapSpread($collection) + { + $c = new $collection([[1, 'a'], [2, 'b']]); + + $result = $c->mapSpread(function ($number, $character) { + return "{$number}-{$character}"; + }); + $this->assertEquals(['1-a', '2-b'], $result->all()); + + $result = $c->mapSpread(function ($number, $character, $key) { + return "{$number}-{$character}-{$key}"; + }); + $this->assertEquals(['1-a-0', '2-b-1'], $result->all()); + + $c = new $collection([new Collection([1, 'a']), new Collection([2, 'b'])]); + $result = $c->mapSpread(function ($number, $character, $key) { + return "{$number}-{$character}-{$key}"; + }); + $this->assertEquals(['1-a-0', '2-b-1'], $result->all()); + } + + #[DataProvider('collectionClassProvider')] + public function testFlatMap($collection) + { + $data = new $collection([ + ['name' => 'taylor', 'hobbies' => ['programming', 'basketball']], + ['name' => 'adam', 'hobbies' => ['music', 'powerlifting']], + ]); + $data = $data->flatMap(function ($person) { + return $person['hobbies']; + }); + $this->assertEquals(['programming', 'basketball', 'music', 'powerlifting'], $data->all()); + } + + #[DataProvider('collectionClassProvider')] + public function testMapToDictionary($collection) + { + $data = new $collection([ + ['id' => 1, 'name' => 'A'], + ['id' => 2, 'name' => 'B'], + ['id' => 3, 'name' => 'C'], + ['id' => 4, 'name' => 'B'], + ]); + + $groups = $data->mapToDictionary(function ($item, $key) { + return [$item['name'] => $item['id']]; + }); + + $this->assertInstanceOf($collection, $groups); + $this->assertEquals(['A' => [1], 'B' => [2, 4], 'C' => [3]], $groups->toArray()); + $this->assertIsArray($groups->get('A')); + } + + #[DataProvider('collectionClassProvider')] + public function testMapToDictionaryWithNumericKeys($collection) + { + $data = new $collection([1, 2, 3, 2, 1]); + + $groups = $data->mapToDictionary(function ($item, $key) { + return [$item => $key]; + }); + + $this->assertEquals([1 => [0, 4], 2 => [1, 3], 3 => [2]], $groups->toArray()); + } + + #[DataProvider('collectionClassProvider')] + public function testMapToGroups($collection) + { + $data = new $collection([ + ['id' => 1, 'name' => 'A'], + ['id' => 2, 'name' => 'B'], + ['id' => 3, 'name' => 'C'], + ['id' => 4, 'name' => 'B'], + ]); + + $groups = $data->mapToGroups(function ($item, $key) { + return [$item['name'] => $item['id']]; + }); + + $this->assertInstanceOf($collection, $groups); + $this->assertEquals(['A' => [1], 'B' => [2, 4], 'C' => [3]], $groups->toArray()); + $this->assertInstanceOf($collection, $groups->get('A')); + } + + #[DataProvider('collectionClassProvider')] + public function testMapToGroupsWithNumericKeys($collection) + { + $data = new $collection([1, 2, 3, 2, 1]); + + $groups = $data->mapToGroups(function ($item, $key) { + return [$item => $key]; + }); + + $this->assertEquals([1 => [0, 4], 2 => [1, 3], 3 => [2]], $groups->toArray()); + $this->assertEquals([1, 2, 3, 2, 1], $data->all()); + } + + #[DataProvider('collectionClassProvider')] + public function testMapWithKeys($collection) + { + $data = new $collection([ + ['name' => 'Blastoise', 'type' => 'Water', 'idx' => 9], + ['name' => 'Charmander', 'type' => 'Fire', 'idx' => 4], + ['name' => 'Dragonair', 'type' => 'Dragon', 'idx' => 148], + ]); + $data = $data->mapWithKeys(function ($pokemon) { + return [$pokemon['name'] => $pokemon['type']]; + }); + $this->assertEquals( + ['Blastoise' => 'Water', 'Charmander' => 'Fire', 'Dragonair' => 'Dragon'], + $data->all() + ); + } + + #[DataProvider('collectionClassProvider')] + public function testMapWithKeysIntegerKeys($collection) + { + $data = new $collection([ + ['id' => 1, 'name' => 'A'], + ['id' => 3, 'name' => 'B'], + ['id' => 2, 'name' => 'C'], + ]); + $data = $data->mapWithKeys(function ($item) { + return [$item['id'] => $item]; + }); + $this->assertSame( + [1, 3, 2], + $data->keys()->all() + ); + } + + #[DataProvider('collectionClassProvider')] + public function testMapWithKeysMultipleRows($collection) + { + $data = new $collection([ + ['id' => 1, 'name' => 'A'], + ['id' => 2, 'name' => 'B'], + ['id' => 3, 'name' => 'C'], + ]); + $data = $data->mapWithKeys(function ($item) { + return [$item['id'] => $item['name'], $item['name'] => $item['id']]; + }); + $this->assertSame( + [ + 1 => 'A', + 'A' => 1, + 2 => 'B', + 'B' => 2, + 3 => 'C', + 'C' => 3, + ], + $data->all() + ); + } + + #[DataProvider('collectionClassProvider')] + public function testMapWithKeysCallbackKey($collection) + { + $data = new $collection([ + 3 => ['id' => 1, 'name' => 'A'], + 5 => ['id' => 3, 'name' => 'B'], + 4 => ['id' => 2, 'name' => 'C'], + ]); + $data = $data->mapWithKeys(function ($item, $key) { + return [$key => $item['id']]; + }); + $this->assertSame( + [3, 5, 4], + $data->keys()->all() + ); + } + + #[DataProvider('collectionClassProvider')] + public function testMapInto($collection) + { + $data = new $collection([ + 'first', 'second', + ]); + + $data = $data->mapInto(TestCollectionMapIntoObject::class); + + $this->assertSame('first', $data->get(0)->value); + $this->assertSame('second', $data->get(1)->value); + } + + #[DataProvider('collectionClassProvider')] + public function testMapIntoWithIntBackedEnums($collection) + { + $data = new $collection([ + 1, 2, + ]); + + $data = $data->mapInto(TestBackedEnum::class); + + $this->assertSame(TestBackedEnum::A, $data->get(0)); + $this->assertSame(TestBackedEnum::B, $data->get(1)); + } + + #[DataProvider('collectionClassProvider')] + public function testMapIntoWithStringBackedEnums($collection) + { + $data = new $collection([ + 'A', 'B', + ]); + + $data = $data->mapInto(TestStringBackedEnum::class); + + $this->assertSame(TestStringBackedEnum::A, $data->get(0)); + $this->assertSame(TestStringBackedEnum::B, $data->get(1)); + } + + #[DataProvider('collectionClassProvider')] + public function testNth($collection) + { + $data = new $collection([ + 6 => 'a', + 4 => 'b', + 7 => 'c', + 1 => 'd', + 5 => 'e', + 3 => 'f', + ]); + + $this->assertEquals(['a', 'e'], $data->nth(4)->all()); + $this->assertEquals(['b', 'f'], $data->nth(4, 1)->all()); + $this->assertEquals(['c'], $data->nth(4, 2)->all()); + $this->assertEquals(['d'], $data->nth(4, 3)->all()); + $this->assertEquals(['c', 'e'], $data->nth(2, 2)->all()); + $this->assertEquals(['c', 'd', 'e', 'f'], $data->nth(1, 2)->all()); + $this->assertEquals(['c', 'd', 'e', 'f'], $data->nth(1, 2)->all()); + $this->assertEquals(['e', 'f'], $data->nth(1, -2)->all()); + $this->assertEquals(['c', 'e'], $data->nth(2, -4)->all()); + $this->assertEquals(['e'], $data->nth(4, -2)->all()); + $this->assertEquals(['e'], $data->nth(2, -2)->all()); + } + + #[DataProvider('collectionClassProvider')] + public function testNthThrowsExceptionForInvalidStep($collection) + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Step value must be at least 1.'); + + (new $collection([1, 2, 3]))->nth(0)->all(); + } + + #[DataProvider('collectionClassProvider')] + public function testNthThrowsExceptionForNegativeStep($collection) + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Step value must be at least 1.'); + + (new $collection([1, 2, 3]))->nth(-1)->all(); + } + + #[DataProvider('collectionClassProvider')] + public function testSplitThrowsExceptionForInvalidNumberOfGroups($collection) + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Number of groups must be at least 1.'); + + (new $collection([1, 2, 3]))->split(0); + } + + #[DataProvider('collectionClassProvider')] + public function testSplitThrowsExceptionForNegativeNumberOfGroups($collection) + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Number of groups must be at least 1.'); + + (new $collection([1, 2, 3]))->split(-1); + } + + #[DataProvider('collectionClassProvider')] + public function testSplitInThrowsExceptionForInvalidNumberOfGroups($collection) + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Number of groups must be at least 1.'); + + (new $collection([1, 2, 3]))->splitIn(0); + } + + #[DataProvider('collectionClassProvider')] + public function testSplitInThrowsExceptionForNegativeNumberOfGroups($collection) + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Number of groups must be at least 1.'); + + (new $collection([1, 2, 3]))->splitIn(-1); + } + + #[DataProvider('collectionClassProvider')] + public function testMapWithKeysOverwritingKeys($collection) + { + $data = new $collection([ + ['id' => 1, 'name' => 'A'], + ['id' => 2, 'name' => 'B'], + ['id' => 1, 'name' => 'C'], + ]); + $data = $data->mapWithKeys(function ($item) { + return [$item['id'] => $item['name']]; + }); + $this->assertSame( + [ + 1 => 'C', + 2 => 'B', + ], + $data->all() + ); + } + + public function testTransform() + { + $data = new Collection(['first' => 'taylor', 'last' => 'otwell']); + $data->transform(function ($item, $key) { + return $key . '-' . strrev($item); + }); + $this->assertEquals(['first' => 'first-rolyat', 'last' => 'last-llewto'], $data->all()); + } + + #[DataProvider('collectionClassProvider')] + public function testGroupByAttribute($collection) + { + $data = new $collection([['rating' => 1, 'url' => '1'], ['rating' => 1, 'url' => '1'], ['rating' => 2, 'url' => '2']]); + + $result = $data->groupBy('rating'); + $this->assertEquals([1 => [['rating' => 1, 'url' => '1'], ['rating' => 1, 'url' => '1']], 2 => [['rating' => 2, 'url' => '2']]], $result->toArray()); + + $result = $data->groupBy('url'); + $this->assertEquals([1 => [['rating' => 1, 'url' => '1'], ['rating' => 1, 'url' => '1']], 2 => [['rating' => 2, 'url' => '2']]], $result->toArray()); + } + + #[DataProvider('collectionClassProvider')] + public function testGroupByAttributeWithStringableKey($collection) + { + $data = new $collection($payload = [ + ['name' => new Stringable('Laravel'), 'url' => '1'], + ['name' => new HtmlString('Laravel'), 'url' => '1'], + ['name' => new class { + public function __toString() + { + return 'Framework'; + } + }, 'url' => '2', ], + ]); + + $result = $data->groupBy('name'); + $this->assertEquals(['Laravel' => [$payload[0], $payload[1]], 'Framework' => [$payload[2]]], $result->toArray()); + + $result = $data->groupBy('url'); + $this->assertEquals(['1' => [$payload[0], $payload[1]], '2' => [$payload[2]]], $result->toArray()); + } + + #[DataProvider('collectionClassProvider')] + public function testGroupByAttributeWithEnumKey($collection) + { + $data = new $collection($payload = [ + ['name' => TestEnum::A, 'url' => '1'], + ['name' => TestBackedEnum::A, 'url' => '1'], + ['name' => TestStringBackedEnum::A, 'url' => '2'], + ]); + + $result = $data->groupBy('name'); + $this->assertEquals(['A' => [$payload[0], $payload[2]], '1' => [$payload[1]]], $result->toArray()); + + $result = $data->groupBy('url'); + $this->assertEquals(['1' => [$payload[0], $payload[1]], '2' => [$payload[2]]], $result->toArray()); + } + + #[DataProvider('collectionClassProvider')] + public function testGroupByCallable($collection) + { + $data = new $collection([['rating' => 1, 'url' => '1'], ['rating' => 1, 'url' => '1'], ['rating' => 2, 'url' => '2']]); + + $result = $data->groupBy([$this, 'sortByRating']); + $this->assertEquals([1 => [['rating' => 1, 'url' => '1'], ['rating' => 1, 'url' => '1']], 2 => [['rating' => 2, 'url' => '2']]], $result->toArray()); + + $result = $data->groupBy([$this, 'sortByUrl']); + $this->assertEquals([1 => [['rating' => 1, 'url' => '1'], ['rating' => 1, 'url' => '1']], 2 => [['rating' => 2, 'url' => '2']]], $result->toArray()); + } + + public function sortByRating(array $value) + { + return $value['rating']; + } + + public function sortByUrl(array $value) + { + return $value['url']; + } + + #[DataProvider('collectionClassProvider')] + public function testGroupByAttributeWithBackedEnumKey($collection) + { + $data = new $collection([ + ['rating' => TestBackedEnum::A, 'url' => '1'], + ['rating' => TestBackedEnum::B, 'url' => '1'], + ]); + + $result = $data->groupBy('rating'); + $this->assertEquals([TestBackedEnum::A->value => [['rating' => TestBackedEnum::A, 'url' => '1']], TestBackedEnum::B->value => [['rating' => TestBackedEnum::B, 'url' => '1']]], $result->toArray()); + } + + #[DataProvider('collectionClassProvider')] + public function testGroupByAttributePreservingKeys($collection) + { + $data = new $collection([10 => ['rating' => 1, 'url' => '1'], 20 => ['rating' => 1, 'url' => '1'], 30 => ['rating' => 2, 'url' => '2']]); + + $result = $data->groupBy('rating', true); + + $expected_result = [ + 1 => [10 => ['rating' => 1, 'url' => '1'], 20 => ['rating' => 1, 'url' => '1']], + 2 => [30 => ['rating' => 2, 'url' => '2']], + ]; + + $this->assertEquals($expected_result, $result->toArray()); + } + + #[DataProvider('collectionClassProvider')] + public function testGroupByClosureWhereItemsHaveSingleGroup($collection) + { + $data = new $collection([['rating' => 1, 'url' => '1'], ['rating' => 1, 'url' => '1'], ['rating' => 2, 'url' => '2']]); + + $result = $data->groupBy(function ($item) { + return $item['rating']; + }); + + $this->assertEquals([1 => [['rating' => 1, 'url' => '1'], ['rating' => 1, 'url' => '1']], 2 => [['rating' => 2, 'url' => '2']]], $result->toArray()); + } + + #[DataProvider('collectionClassProvider')] + public function testGroupByClosureWhereItemsHaveSingleGroupPreservingKeys($collection) + { + $data = new $collection([10 => ['rating' => 1, 'url' => '1'], 20 => ['rating' => 1, 'url' => '1'], 30 => ['rating' => 2, 'url' => '2']]); + + $result = $data->groupBy(function ($item) { + return $item['rating']; + }, true); + + $expected_result = [ + 1 => [10 => ['rating' => 1, 'url' => '1'], 20 => ['rating' => 1, 'url' => '1']], + 2 => [30 => ['rating' => 2, 'url' => '2']], + ]; + + $this->assertEquals($expected_result, $result->toArray()); + } + + #[DataProvider('collectionClassProvider')] + public function testGroupByClosureWhereItemsHaveMultipleGroups($collection) + { + $data = new $collection([ + ['user' => 1, 'roles' => ['Role_1', 'Role_3']], + ['user' => 2, 'roles' => ['Role_1', 'Role_2']], + ['user' => 3, 'roles' => ['Role_1']], + ]); + + $result = $data->groupBy(function ($item) { + return $item['roles']; + }); + + $expected_result = [ + 'Role_1' => [ + ['user' => 1, 'roles' => ['Role_1', 'Role_3']], + ['user' => 2, 'roles' => ['Role_1', 'Role_2']], + ['user' => 3, 'roles' => ['Role_1']], + ], + 'Role_2' => [ + ['user' => 2, 'roles' => ['Role_1', 'Role_2']], + ], + 'Role_3' => [ + ['user' => 1, 'roles' => ['Role_1', 'Role_3']], + ], + ]; + + $this->assertEquals($expected_result, $result->toArray()); + } + + #[DataProvider('collectionClassProvider')] + public function testGroupByClosureWhereItemsHaveMultipleGroupsPreservingKeys($collection) + { + $data = new $collection([ + 10 => ['user' => 1, 'roles' => ['Role_1', 'Role_3']], + 20 => ['user' => 2, 'roles' => ['Role_1', 'Role_2']], + 30 => ['user' => 3, 'roles' => ['Role_1']], + ]); + + $result = $data->groupBy(function ($item) { + return $item['roles']; + }, true); + + $expected_result = [ + 'Role_1' => [ + 10 => ['user' => 1, 'roles' => ['Role_1', 'Role_3']], + 20 => ['user' => 2, 'roles' => ['Role_1', 'Role_2']], + 30 => ['user' => 3, 'roles' => ['Role_1']], + ], + 'Role_2' => [ + 20 => ['user' => 2, 'roles' => ['Role_1', 'Role_2']], + ], + 'Role_3' => [ + 10 => ['user' => 1, 'roles' => ['Role_1', 'Role_3']], + ], + ]; + + $this->assertEquals($expected_result, $result->toArray()); + } + + #[DataProvider('collectionClassProvider')] + public function testGroupByMultiLevelAndClosurePreservingKeys($collection) + { + $data = new $collection([ + 10 => ['user' => 1, 'skilllevel' => 1, 'roles' => ['Role_1', 'Role_3']], + 20 => ['user' => 2, 'skilllevel' => 1, 'roles' => ['Role_1', 'Role_2']], + 30 => ['user' => 3, 'skilllevel' => 2, 'roles' => ['Role_1']], + 40 => ['user' => 4, 'skilllevel' => 2, 'roles' => ['Role_2']], + ]); + + $result = $data->groupBy([ + 'skilllevel', + function ($item) { + return $item['roles']; + }, + ], true); + + $expected_result = [ + 1 => [ + 'Role_1' => [ + 10 => ['user' => 1, 'skilllevel' => 1, 'roles' => ['Role_1', 'Role_3']], + 20 => ['user' => 2, 'skilllevel' => 1, 'roles' => ['Role_1', 'Role_2']], + ], + 'Role_3' => [ + 10 => ['user' => 1, 'skilllevel' => 1, 'roles' => ['Role_1', 'Role_3']], + ], + 'Role_2' => [ + 20 => ['user' => 2, 'skilllevel' => 1, 'roles' => ['Role_1', 'Role_2']], + ], + ], + 2 => [ + 'Role_1' => [ + 30 => ['user' => 3, 'skilllevel' => 2, 'roles' => ['Role_1']], + ], + 'Role_2' => [ + 40 => ['user' => 4, 'skilllevel' => 2, 'roles' => ['Role_2']], + ], + ], + ]; + + $this->assertEquals($expected_result, $result->toArray()); + } + + #[DataProvider('collectionClassProvider')] + public function testKeyByAttribute($collection) + { + $data = new $collection([['rating' => 1, 'name' => '1'], ['rating' => 2, 'name' => '2'], ['rating' => 3, 'name' => '3']]); + + $result = $data->keyBy('rating'); + $this->assertEquals([1 => ['rating' => 1, 'name' => '1'], 2 => ['rating' => 2, 'name' => '2'], 3 => ['rating' => 3, 'name' => '3']], $result->all()); + + $result = $data->keyBy(function ($item) { + return $item['rating'] * 2; + }); + $this->assertEquals([2 => ['rating' => 1, 'name' => '1'], 4 => ['rating' => 2, 'name' => '2'], 6 => ['rating' => 3, 'name' => '3']], $result->all()); + } + + #[DataProvider('collectionClassProvider')] + public function testKeyByClosure($collection) + { + $data = new $collection([ + ['firstname' => 'Taylor', 'lastname' => 'Otwell', 'locale' => 'US'], + ['firstname' => 'Lucas', 'lastname' => 'Michot', 'locale' => 'FR'], + ]); + $result = $data->keyBy(function ($item, $key) { + return strtolower($key . '-' . $item['firstname'] . $item['lastname']); + }); + $this->assertEquals([ + '0-taylorotwell' => ['firstname' => 'Taylor', 'lastname' => 'Otwell', 'locale' => 'US'], + '1-lucasmichot' => ['firstname' => 'Lucas', 'lastname' => 'Michot', 'locale' => 'FR'], + ], $result->all()); + } + + #[DataProvider('collectionClassProvider')] + public function testKeyByObject($collection) + { + $data = new $collection([ + ['firstname' => 'Taylor', 'lastname' => 'Otwell', 'locale' => 'US'], + ['firstname' => 'Lucas', 'lastname' => 'Michot', 'locale' => 'FR'], + ]); + $result = $data->keyBy(function ($item, $key) use ($collection) { + return new $collection([$key, $item['firstname'], $item['lastname']]); + }); + $this->assertEquals([ + '[0,"Taylor","Otwell"]' => ['firstname' => 'Taylor', 'lastname' => 'Otwell', 'locale' => 'US'], + '[1,"Lucas","Michot"]' => ['firstname' => 'Lucas', 'lastname' => 'Michot', 'locale' => 'FR'], + ], $result->all()); + } + + #[DataProvider('collectionClassProvider')] + public function testContains($collection) + { + $c = new $collection([1, 3, 5]); + + $this->assertTrue($c->contains(1)); + $this->assertTrue($c->contains('1')); + $this->assertFalse($c->contains(2)); + $this->assertFalse($c->contains('2')); + + $c = new $collection(['1']); + $this->assertTrue($c->contains('1')); + $this->assertTrue($c->contains(1)); + + $c = new $collection([null]); + $this->assertTrue($c->contains(false)); + $this->assertTrue($c->contains(null)); + $this->assertTrue($c->contains([])); + $this->assertTrue($c->contains(0)); + $this->assertTrue($c->contains('')); + + $c = new $collection([0]); + $this->assertTrue($c->contains(0)); + $this->assertTrue($c->contains('0')); + $this->assertTrue($c->contains(false)); + $this->assertTrue($c->contains(null)); + + $this->assertTrue($c->contains(function ($value) { + return $value < 5; + })); + $this->assertFalse($c->contains(function ($value) { + return $value > 5; + })); + + $c = new $collection([['v' => 1], ['v' => 3], ['v' => 5]]); + + $this->assertTrue($c->contains('v', 1)); + $this->assertFalse($c->contains('v', 2)); + + $c = new $collection(['date', 'class', (object) ['foo' => 50]]); + + $this->assertTrue($c->contains('date')); + $this->assertTrue($c->contains('class')); + $this->assertFalse($c->contains('foo')); + + $c = new $collection([['a' => false, 'b' => false], ['a' => true, 'b' => false]]); + + $this->assertTrue($c->contains->a); + $this->assertFalse($c->contains->b); + + $c = new $collection([ + null, 1, 2, + ]); + + $this->assertTrue($c->contains(function ($value) { + return is_null($value); + })); + } + + #[DataProvider('collectionClassProvider')] + public function testDoesntContain($collection) + { + $c = new $collection([1, 3, 5]); + + $this->assertFalse($c->doesntContain(1)); + $this->assertFalse($c->doesntContain('1')); + $this->assertTrue($c->doesntContain(2)); + $this->assertTrue($c->doesntContain('2')); + + $c = new $collection(['1']); + $this->assertFalse($c->doesntContain('1')); + $this->assertFalse($c->doesntContain(1)); + + $c = new $collection([null]); + $this->assertFalse($c->doesntContain(false)); + $this->assertFalse($c->doesntContain(null)); + $this->assertFalse($c->doesntContain([])); + $this->assertFalse($c->doesntContain(0)); + $this->assertFalse($c->doesntContain('')); + + $c = new $collection([0]); + $this->assertFalse($c->doesntContain(0)); + $this->assertFalse($c->doesntContain('0')); + $this->assertFalse($c->doesntContain(false)); + $this->assertFalse($c->doesntContain(null)); + + $this->assertFalse($c->doesntContain(function ($value) { + return $value < 5; + })); + $this->assertTrue($c->doesntContain(function ($value) { + return $value > 5; + })); + + $c = new $collection([['v' => 1], ['v' => 3], ['v' => 5]]); + + $this->assertFalse($c->doesntContain('v', 1)); + $this->assertTrue($c->doesntContain('v', 2)); + + $c = new $collection(['date', 'class', (object) ['foo' => 50]]); + + $this->assertFalse($c->doesntContain('date')); + $this->assertFalse($c->doesntContain('class')); + $this->assertTrue($c->doesntContain('foo')); + + $c = new $collection([['a' => false, 'b' => false], ['a' => true, 'b' => false]]); + + $this->assertFalse($c->doesntContain->a); + $this->assertTrue($c->doesntContain->b); + + $c = new $collection([ + null, 1, 2, + ]); + + $this->assertFalse($c->doesntContain(function ($value) { + return is_null($value); + })); + } + + #[DataProvider('collectionClassProvider')] + public function testDoesntContainStrict($collection) + { + $c = new $collection([1, 3, 5, '02']); + + $this->assertFalse($c->doesntContainStrict(1)); + $this->assertTrue($c->doesntContainStrict('1')); + $this->assertTrue($c->doesntContainStrict(2)); + $this->assertFalse($c->doesntContainStrict('02')); + $this->assertTrue($c->doesntContainStrict('2')); + $this->assertTrue($c->doesntContainStrict(true)); + $this->assertFalse($c->doesntContainStrict(function ($value) { + return $value < 5; + })); + $this->assertTrue($c->doesntContainStrict(function ($value) { + return $value > 5; + })); + + $c = new $collection([0]); + $this->assertFalse($c->doesntContainStrict(0)); + $this->assertTrue($c->doesntContainStrict('0')); + + $this->assertTrue($c->doesntContainStrict(false)); + $this->assertTrue($c->doesntContainStrict(null)); + + $c = new $collection([1, null]); + $this->assertFalse($c->doesntContainStrict(null)); + $this->assertTrue($c->doesntContainStrict(0)); + $this->assertTrue($c->doesntContainStrict(false)); + + $c = new $collection([['v' => 1], ['v' => 3], ['v' => '04'], ['v' => 5]]); + + $this->assertFalse($c->doesntContainStrict('v', 1)); + $this->assertTrue($c->doesntContainStrict('v', 2)); + $this->assertTrue($c->doesntContainStrict('v', '1')); + $this->assertTrue($c->doesntContainStrict('v', 4)); + $this->assertFalse($c->doesntContainStrict('v', '04')); + $this->assertTrue($c->doesntContainStrict('v', '4')); + + $c = new $collection(['date', 'class', (object) ['foo' => 50], '']); + + $this->assertFalse($c->doesntContainStrict('date')); + $this->assertFalse($c->doesntContainStrict('class')); + $this->assertTrue($c->doesntContainStrict('foo')); + $this->assertTrue($c->doesntContainStrict(null)); + $this->assertFalse($c->doesntContainStrict('')); + } + + #[DataProvider('collectionClassProvider')] + public function testSome($collection) + { + $c = new $collection([1, 3, 5]); + + $this->assertTrue($c->some(1)); + $this->assertFalse($c->some(2)); + $this->assertTrue($c->some(function ($value) { + return $value < 5; + })); + $this->assertFalse($c->some(function ($value) { + return $value > 5; + })); + + $c = new $collection([['v' => 1], ['v' => 3], ['v' => 5]]); + + $this->assertTrue($c->some('v', 1)); + $this->assertFalse($c->some('v', 2)); + + $c = new $collection(['date', 'class', (object) ['foo' => 50]]); + + $this->assertTrue($c->some('date')); + $this->assertTrue($c->some('class')); + $this->assertFalse($c->some('foo')); + + $c = new $collection([['a' => false, 'b' => false], ['a' => true, 'b' => false]]); + + $this->assertTrue($c->some->a); + $this->assertFalse($c->some->b); + + $c = new $collection([ + null, 1, 2, + ]); + + $this->assertTrue($c->some(function ($value) { + return is_null($value); + })); + } + + #[DataProvider('collectionClassProvider')] + public function testContainsStrict($collection) + { + $c = new $collection([1, 3, 5, '02']); + + $this->assertTrue($c->containsStrict(1)); + $this->assertFalse($c->containsStrict('1')); + $this->assertFalse($c->containsStrict(2)); + $this->assertTrue($c->containsStrict('02')); + $this->assertFalse($c->containsStrict(true)); + $this->assertTrue($c->containsStrict(function ($value) { + return $value < 5; + })); + $this->assertFalse($c->containsStrict(function ($value) { + return $value > 5; + })); + + $c = new $collection([0]); + $this->assertTrue($c->containsStrict(0)); + $this->assertFalse($c->containsStrict('0')); + + $this->assertFalse($c->containsStrict(false)); + $this->assertFalse($c->containsStrict(null)); + + $c = new $collection([1, null]); + $this->assertTrue($c->containsStrict(null)); + $this->assertFalse($c->containsStrict(0)); + $this->assertFalse($c->containsStrict(false)); + + $c = new $collection([['v' => 1], ['v' => 3], ['v' => '04'], ['v' => 5]]); + + $this->assertTrue($c->containsStrict('v', 1)); + $this->assertFalse($c->containsStrict('v', 2)); + $this->assertFalse($c->containsStrict('v', '1')); + $this->assertFalse($c->containsStrict('v', 4)); + $this->assertTrue($c->containsStrict('v', '04')); + + $c = new $collection(['date', 'class', (object) ['foo' => 50], '']); + + $this->assertTrue($c->containsStrict('date')); + $this->assertTrue($c->containsStrict('class')); + $this->assertFalse($c->containsStrict('foo')); + $this->assertFalse($c->containsStrict(null)); + $this->assertTrue($c->containsStrict('')); + } + + #[DataProvider('collectionClassProvider')] + public function testContainsWithOperator($collection) + { + $c = new $collection([['v' => 1], ['v' => 3], ['v' => '4'], ['v' => 5]]); + + $this->assertTrue($c->contains('v', '=', 4)); + $this->assertTrue($c->contains('v', '==', 4)); + $this->assertFalse($c->contains('v', '===', 4)); + $this->assertTrue($c->contains('v', '>', 4)); + } + + #[DataProvider('collectionClassProvider')] + public function testGettingSumFromCollection($collection) + { + $c = new $collection([(object) ['foo' => 50], (object) ['foo' => 50]]); + $this->assertEquals(100, $c->sum('foo')); + + $c = new $collection([(object) ['foo' => 50], (object) ['foo' => 50]]); + $this->assertEquals(100, $c->sum(function ($i) { + return $i->foo; + })); + } + + #[DataProvider('collectionClassProvider')] + public function testCanSumValuesWithoutACallback($collection) + { + $c = new $collection([1, 2, 3, 4, 5]); + $this->assertEquals(15, $c->sum()); + } + + #[DataProvider('collectionClassProvider')] + public function testGettingSumFromEmptyCollection($collection) + { + $c = new $collection(); + $this->assertEquals(0, $c->sum('foo')); + } + + #[DataProvider('collectionClassProvider')] + public function testValueRetrieverAcceptsDotNotation($collection) + { + $c = new $collection([ + (object) ['id' => 1, 'foo' => ['bar' => 'B']], (object) ['id' => 2, 'foo' => ['bar' => 'A']], + ]); + + $c = $c->sortBy('foo.bar'); + $this->assertEquals([2, 1], $c->pluck('id')->all()); + } + + public function testPullRetrievesItemFromCollection() + { + $c = new Collection(['foo', 'bar']); + + $this->assertSame('foo', $c->pull(0)); + $this->assertSame('bar', $c->pull(1)); + + $c = new Collection(['foo', 'bar']); + + $this->assertNull($c->pull(-1)); + $this->assertNull($c->pull(2)); + } + + public function testPullRemovesItemFromCollection() + { + $c = new Collection(['foo', 'bar']); + $c->pull(0); + $this->assertEquals([1 => 'bar'], $c->all()); + $c->pull(1); + $this->assertEquals([], $c->all()); + } + + public function testPullRemovesItemFromNestedCollection() + { + $nestedCollection = new Collection([ + new Collection([ + 'value', + new Collection([ + 'bar' => 'baz', + 'test' => 'value', + ]), + ]), + 'bar', + ]); + + $nestedCollection->pull('0.1.test'); + + $actualArray = $nestedCollection->toArray(); + $expectedArray = [ + [ + 'value', + ['bar' => 'baz'], + ], + 'bar', + ]; + + $this->assertEquals($expectedArray, $actualArray); + } + + public function testPullReturnsDefault() + { + $c = new Collection([]); + $value = $c->pull(0, 'foo'); + $this->assertSame('foo', $value); + } + + #[DataProvider('collectionClassProvider')] + public function testRejectRemovesElementsPassingTruthTest($collection) + { + $c = new $collection(['foo', 'bar']); + $this->assertEquals(['foo'], $c->reject('bar')->values()->all()); + + $c = new $collection(['foo', 'bar']); + $this->assertEquals(['foo'], $c->reject(function ($v) { + return $v === 'bar'; + })->values()->all()); + + $c = new $collection(['foo', null]); + $this->assertEquals(['foo'], $c->reject(null)->values()->all()); + + $c = new $collection(['foo', 'bar']); + $this->assertEquals(['foo', 'bar'], $c->reject('baz')->values()->all()); + + $c = new $collection(['foo', 'bar']); + $this->assertEquals(['foo', 'bar'], $c->reject(function ($v) { + return $v === 'baz'; + })->values()->all()); + + $c = new $collection(['id' => 1, 'primary' => 'foo', 'secondary' => 'bar']); + $this->assertEquals(['primary' => 'foo', 'secondary' => 'bar'], $c->reject(function ($item, $key) { + return $key === 'id'; + })->all()); + } + + #[DataProvider('collectionClassProvider')] + public function testRejectWithoutAnArgumentRemovesTruthyValues($collection) + { + $data1 = new $collection([ + false, + true, + new $collection(), + 0, + ]); + $this->assertSame([0 => false, 3 => 0], $data1->reject()->all()); + + $data2 = new $collection([ + 'a' => true, + 'b' => true, + 'c' => true, + ]); + $this->assertTrue( + $data2->reject()->isEmpty() + ); + } + + #[DataProvider('collectionClassProvider')] + public function testSearchReturnsIndexOfFirstFoundItem($collection) + { + $c = new $collection([1, 2, 3, 4, 5, 2, 5, 'foo' => 'bar']); + + $this->assertEquals(1, $c->search(2)); + $this->assertEquals(1, $c->search('2')); + $this->assertSame('foo', $c->search('bar')); + $this->assertEquals(4, $c->search(function ($value) { + return $value > 4; + })); + $this->assertSame('foo', $c->search(function ($value) { + return ! is_numeric($value); + })); + } + + #[DataProvider('collectionClassProvider')] + public function testSearchInStrictMode($collection) + { + $c = new $collection([false, 0, 1, [], '']); + $this->assertFalse($c->search('false', true)); + $this->assertFalse($c->search('1', true)); + $this->assertEquals(0, $c->search(false, true)); + $this->assertEquals(1, $c->search(0, true)); + $this->assertEquals(2, $c->search(1, true)); + $this->assertEquals(3, $c->search([], true)); + $this->assertEquals(4, $c->search('', true)); + } + + #[DataProvider('collectionClassProvider')] + public function testSearchReturnsFalseWhenItemIsNotFound($collection) + { + $c = new $collection([1, 2, 3, 4, 5, 'foo' => 'bar']); + + $this->assertFalse($c->search(6)); + $this->assertFalse($c->search('foo')); + $this->assertFalse($c->search(function ($value) { + return $value < 1 && is_numeric($value); + })); + $this->assertFalse($c->search(function ($value) { + return $value === 'nope'; + })); + } + + #[DataProvider('collectionClassProvider')] + public function testBeforeReturnsItemBeforeTheGivenItem($collection) + { + $c = new $collection([1, 2, 3, 4, 5, 2, 5, 'name' => 'taylor', 'framework' => 'laravel']); + + $this->assertEquals(1, $c->before(2)); + $this->assertEquals(1, $c->before('2')); + $this->assertEquals(5, $c->before('taylor')); + $this->assertSame('taylor', $c->before('laravel')); + $this->assertEquals(4, $c->before(function ($value) { + return $value > 4; + })); + $this->assertEquals(5, $c->before(function ($value) { + return ! is_numeric($value); + })); + } + + #[DataProvider('collectionClassProvider')] + public function testBeforeInStrictMode($collection) + { + $c = new $collection([false, 0, 1, [], '']); + $this->assertNull($c->before('false', true)); + $this->assertNull($c->before('1', true)); + $this->assertNull($c->before(false, true)); + $this->assertEquals(false, $c->before(0, true)); + $this->assertEquals(0, $c->before(1, true)); + $this->assertEquals(1, $c->before([], true)); + $this->assertEquals([], $c->before('', true)); + } + + #[DataProvider('collectionClassProvider')] + public function testBeforeReturnsNullWhenItemIsNotFound($collection) + { + $c = new $collection([1, 2, 3, 4, 5, 'foo' => 'bar']); + + $this->assertNull($c->before(6)); + $this->assertNull($c->before('foo')); + $this->assertNull($c->before(function ($value) { + return $value < 1 && is_numeric($value); + })); + $this->assertNull($c->before(function ($value) { + return $value === 'nope'; + })); + } + + #[DataProvider('collectionClassProvider')] + public function testBeforeReturnsNullWhenItemOnTheFirstitem($collection) + { + $c = new $collection([1, 2, 3, 4, 5, 'foo' => 'bar']); + + $this->assertNull($c->before(1)); + $this->assertNull($c->before(function ($value) { + return $value < 2 && is_numeric($value); + })); + + $c = new $collection(['foo' => 'bar', 1, 2, 3, 4, 5]); + $this->assertNull($c->before('bar')); + } + + #[DataProvider('collectionClassProvider')] + public function testAfterReturnsItemAfterTheGivenItem($collection) + { + $c = new $collection([1, 2, 3, 4, 2, 5, 'name' => 'taylor', 'framework' => 'laravel']); + + $this->assertEquals(2, $c->after(1)); + $this->assertEquals(3, $c->after(2)); + $this->assertEquals(4, $c->after(3)); + $this->assertEquals(2, $c->after(4)); + $this->assertEquals('taylor', $c->after(5)); + $this->assertEquals('laravel', $c->after('taylor')); + + $this->assertEquals(4, $c->after(function ($value) { + return $value > 2; + })); + $this->assertEquals('laravel', $c->after(function ($value) { + return ! is_numeric($value); + })); + } + + #[DataProvider('collectionClassProvider')] + public function testAfterInStrictMode($collection) + { + $c = new $collection([false, 0, 1, [], '']); + + $this->assertNull($c->after('false', true)); + $this->assertNull($c->after('1', true)); + $this->assertNull($c->after('', true)); + $this->assertEquals(0, $c->after(false, true)); + $this->assertEquals([], $c->after(1, true)); + $this->assertEquals('', $c->after([], true)); + } + + #[DataProvider('collectionClassProvider')] + public function testAfterReturnsNullWhenItemIsNotFound($collection) + { + $c = new $collection([1, 2, 3, 4, 5, 'foo' => 'bar']); + + $this->assertNull($c->after(6)); + $this->assertNull($c->after('foo')); + $this->assertNull($c->after(function ($value) { + return $value < 1 && is_numeric($value); + })); + $this->assertNull($c->after(function ($value) { + return $value === 'nope'; + })); + } + + #[DataProvider('collectionClassProvider')] + public function testAfterReturnsNullWhenItemOnTheLastItem($collection) + { + $c = new $collection([1, 2, 3, 4, 5, 'foo' => 'bar']); + + $this->assertNull($c->after('bar')); + $this->assertNull($c->after(function ($value) { + return $value > 4 && ! is_numeric($value); + })); + + $c = new $collection(['foo' => 'bar', 1, 2, 3, 4, 5]); + $this->assertNull($c->after(5)); + } + + #[DataProvider('collectionClassProvider')] + public function testKeys($collection) + { + $c = new $collection(['name' => 'taylor', 'framework' => 'laravel']); + $this->assertEquals(['name', 'framework'], $c->keys()->all()); + + $c = new $collection(['taylor', 'laravel']); + $this->assertEquals([0, 1], $c->keys()->all()); + } + + #[DataProvider('collectionClassProvider')] + public function testPaginate($collection) + { + $c = new $collection(['one', 'two', 'three', 'four']); + $this->assertEquals(['one', 'two'], $c->forPage(0, 2)->all()); + $this->assertEquals(['one', 'two'], $c->forPage(1, 2)->all()); + $this->assertEquals([2 => 'three', 3 => 'four'], $c->forPage(2, 2)->all()); + $this->assertEquals([], $c->forPage(3, 2)->all()); + } + + #[IgnoreDeprecations] + public function testPrepend() + { + $c = new Collection(['one', 'two', 'three', 'four']); + $this->assertEquals( + ['zero', 'one', 'two', 'three', 'four'], + $c->prepend('zero')->all() + ); + + $c = new Collection(['one' => 1, 'two' => 2]); + $this->assertEquals( + ['zero' => 0, 'one' => 1, 'two' => 2], + $c->prepend(0, 'zero')->all() + ); + + $c = new Collection(['one' => 1, 'two' => 2]); + $this->assertEquals( + [null => 0, 'one' => 1, 'two' => 2], + $c->prepend(0, null)->all() + ); + + $c = new Collection(['one' => 1, 'two' => 2]); + $this->assertEquals( + [null => 0, 'one' => 1, 'two' => 2], + $c->prepend(0, '')->all() + ); + } + + public function testPushWithOneItem() + { + $expected = [ + 0 => 4, + 1 => 5, + 2 => 6, + 3 => ['a', 'b', 'c'], + 4 => ['who' => 'Jonny', 'preposition' => 'from', 'where' => 'Laroe'], + 5 => 'Jonny from Laroe', + ]; + + $data = new Collection([4, 5, 6]); + $data->push(['a', 'b', 'c']); + $data->push(['who' => 'Jonny', 'preposition' => 'from', 'where' => 'Laroe']); + $actual = $data->push('Jonny from Laroe')->toArray(); + + $this->assertSame($expected, $actual); + } + + public function testPushWithMultipleItems() + { + $expected = [ + 0 => 4, + 1 => 5, + 2 => 6, + 3 => 'Jonny', + 4 => 'from', + 5 => 'Laroe', + 6 => 'Jonny', + 7 => 'from', + 8 => 'Laroe', + 9 => 'a', + 10 => 'b', + 11 => 'c', + ]; + + $data = new Collection([4, 5, 6]); + $data->push('Jonny', 'from', 'Laroe'); + $data->push(...[11 => 'Jonny', 12 => 'from', 13 => 'Laroe']); + $data->push(...collect(['a', 'b', 'c'])); + $actual = $data->push(...[])->toArray(); + + $this->assertSame($expected, $actual); + } + + public function testUnshiftWithOneItem() + { + $expected = [ + 0 => 'Jonny from Laroe', + 1 => ['who' => 'Jonny', 'preposition' => 'from', 'where' => 'Laroe'], + 2 => ['a', 'b', 'c'], + 3 => 4, + 4 => 5, + 5 => 6, + ]; + + $data = new Collection([4, 5, 6]); + $data->unshift(['a', 'b', 'c']); + $data->unshift(['who' => 'Jonny', 'preposition' => 'from', 'where' => 'Laroe']); + $actual = $data->unshift('Jonny from Laroe')->toArray(); + + $this->assertSame($expected, $actual); + } + + public function testUnshiftWithMultipleItems() + { + $expected = [ + 0 => 'a', + 1 => 'b', + 2 => 'c', + 3 => 'Jonny', + 4 => 'from', + 5 => 'Laroe', + 6 => 'Jonny', + 7 => 'from', + 8 => 'Laroe', + 9 => 4, + 10 => 5, + 11 => 6, + ]; + + $data = new Collection([4, 5, 6]); + $data->unshift('Jonny', 'from', 'Laroe'); + $data->unshift(...[11 => 'Jonny', 12 => 'from', 13 => 'Laroe']); + $data->unshift(...collect(['a', 'b', 'c'])); + $actual = $data->unshift(...[])->toArray(); + + $this->assertSame($expected, $actual); + } + + #[DataProvider('collectionClassProvider')] + public function testZip($collection) + { + $c = new $collection([1, 2, 3]); + $c = $c->zip(new $collection([4, 5, 6])); + $this->assertInstanceOf($collection, $c); + $this->assertInstanceOf($collection, $c->get(0)); + $this->assertInstanceOf($collection, $c->get(1)); + $this->assertInstanceOf($collection, $c->get(2)); + $this->assertCount(3, $c); + $this->assertEquals([1, 4], $c->get(0)->all()); + $this->assertEquals([2, 5], $c->get(1)->all()); + $this->assertEquals([3, 6], $c->get(2)->all()); + + $c = new $collection([1, 2, 3]); + $c = $c->zip([4, 5, 6], [7, 8, 9]); + $this->assertCount(3, $c); + $this->assertEquals([1, 4, 7], $c->get(0)->all()); + $this->assertEquals([2, 5, 8], $c->get(1)->all()); + $this->assertEquals([3, 6, 9], $c->get(2)->all()); + + $c = new $collection([1, 2, 3]); + $c = $c->zip([4, 5, 6], [7]); + $this->assertCount(3, $c); + $this->assertEquals([1, 4, 7], $c->get(0)->all()); + $this->assertEquals([2, 5, null], $c->get(1)->all()); + $this->assertEquals([3, 6, null], $c->get(2)->all()); + } + + #[DataProvider('collectionClassProvider')] + public function testPadPadsArrayWithValue($collection) + { + $c = new $collection([1, 2, 3]); + $c = $c->pad(4, 0); + $this->assertEquals([1, 2, 3, 0], $c->all()); + + $c = new $collection([1, 2, 3, 4, 5]); + $c = $c->pad(4, 0); + $this->assertEquals([1, 2, 3, 4, 5], $c->all()); + + $c = new $collection([1, 2, 3]); + $c = $c->pad(-4, 0); + $this->assertEquals([0, 1, 2, 3], $c->all()); + + $c = new $collection([1, 2, 3, 4, 5]); + $c = $c->pad(-4, 0); + $this->assertEquals([1, 2, 3, 4, 5], $c->all()); + } + + #[DataProvider('collectionClassProvider')] + public function testGettingMaxItemsFromCollection($collection) + { + $c = new $collection([(object) ['foo' => 10], (object) ['foo' => 20]]); + $this->assertEquals(20, $c->max(function ($item) { + return $item->foo; + })); + $this->assertEquals(20, $c->max('foo')); + $this->assertEquals(20, $c->max->foo); + + $c = new $collection([['foo' => 10], ['foo' => 20]]); + $this->assertEquals(20, $c->max('foo')); + $this->assertEquals(20, $c->max->foo); + + $c = new $collection([1, 2, 3, 4, 5]); + $this->assertEquals(5, $c->max()); + + $c = new $collection(); + $this->assertNull($c->max()); + } + + #[DataProvider('collectionClassProvider')] + public function testGettingMinItemsFromCollection($collection) + { + $c = new $collection([(object) ['foo' => 10], (object) ['foo' => 20]]); + $this->assertEquals(10, $c->min(function ($item) { + return $item->foo; + })); + $this->assertEquals(10, $c->min('foo')); + $this->assertEquals(10, $c->min->foo); + + $c = new $collection([['foo' => 10], ['foo' => 20]]); + $this->assertEquals(10, $c->min('foo')); + $this->assertEquals(10, $c->min->foo); + + $c = new $collection([['foo' => 10], ['foo' => 20], ['foo' => null]]); + $this->assertEquals(10, $c->min('foo')); + $this->assertEquals(10, $c->min->foo); + + $c = new $collection([1, 2, 3, 4, 5]); + $this->assertEquals(1, $c->min()); + + $c = new $collection([1, null, 3, 4, 5]); + $this->assertEquals(1, $c->min()); + + $c = new $collection([0, 1, 2, 3, 4]); + $this->assertEquals(0, $c->min()); + + $c = new $collection(); + $this->assertNull($c->min()); + } + + #[DataProvider('collectionClassProvider')] + public function testOnly($collection) + { + $data = new $collection(['first' => 'Taylor', 'last' => 'Otwell', 'email' => 'taylorotwell@gmail.com']); + + $this->assertEquals($data->all(), $data->only(null)->all()); + $this->assertEquals(['first' => 'Taylor'], $data->only(['first', 'missing'])->all()); + $this->assertEquals(['first' => 'Taylor'], $data->only('first', 'missing')->all()); + $this->assertEquals(['first' => 'Taylor'], $data->only(collect(['first', 'missing']))->all()); + + $this->assertEquals(['first' => 'Taylor', 'email' => 'taylorotwell@gmail.com'], $data->only(['first', 'email'])->all()); + $this->assertEquals(['first' => 'Taylor', 'email' => 'taylorotwell@gmail.com'], $data->only('first', 'email')->all()); + $this->assertEquals(['first' => 'Taylor', 'email' => 'taylorotwell@gmail.com'], $data->only(collect(['first', 'email']))->all()); + } + + #[DataProvider('collectionClassProvider')] + public function testSelectWithArrays($collection) + { + $data = new $collection([ + ['first' => 'Taylor', 'last' => 'Otwell', 'email' => 'taylorotwell@gmail.com'], + ['first' => 'Jess', 'last' => 'Archer', 'email' => 'jessarcher@gmail.com'], + ]); + + $this->assertEquals($data->all(), $data->select(null)->all()); + $this->assertEquals([['first' => 'Taylor'], ['first' => 'Jess']], $data->select(['first', 'missing'])->all()); + $this->assertEquals([['first' => 'Taylor'], ['first' => 'Jess']], $data->select('first', 'missing')->all()); + $this->assertEquals([['first' => 'Taylor'], ['first' => 'Jess']], $data->select(collect(['first', 'missing']))->all()); + + $this->assertEquals([ + ['first' => 'Taylor', 'email' => 'taylorotwell@gmail.com'], + ['first' => 'Jess', 'email' => 'jessarcher@gmail.com'], + ], $data->select(['first', 'email'])->all()); + + $this->assertEquals([ + ['first' => 'Taylor', 'email' => 'taylorotwell@gmail.com'], + ['first' => 'Jess', 'email' => 'jessarcher@gmail.com'], + ], $data->select('first', 'email')->all()); + + $this->assertEquals([ + ['first' => 'Taylor', 'email' => 'taylorotwell@gmail.com'], + ['first' => 'Jess', 'email' => 'jessarcher@gmail.com'], + ], $data->select(collect(['first', 'email']))->all()); + } + + #[DataProvider('collectionClassProvider')] + public function testSelectWithArrayAccess($collection) + { + $data = new $collection([ + new TestArrayAccessImplementation(['first' => 'Taylor', 'last' => 'Otwell', 'email' => 'taylorotwell@gmail.com']), + new TestArrayAccessImplementation(['first' => 'Jess', 'last' => 'Archer', 'email' => 'jessarcher@gmail.com']), + ]); + + $this->assertEquals($data->all(), $data->select(null)->all()); + $this->assertEquals([['first' => 'Taylor'], ['first' => 'Jess']], $data->select(['first', 'missing'])->all()); + $this->assertEquals([['first' => 'Taylor'], ['first' => 'Jess']], $data->select('first', 'missing')->all()); + $this->assertEquals([['first' => 'Taylor'], ['first' => 'Jess']], $data->select(collect(['first', 'missing']))->all()); + + $this->assertEquals([ + ['first' => 'Taylor', 'email' => 'taylorotwell@gmail.com'], + ['first' => 'Jess', 'email' => 'jessarcher@gmail.com'], + ], $data->select(['first', 'email'])->all()); + + $this->assertEquals([ + ['first' => 'Taylor', 'email' => 'taylorotwell@gmail.com'], + ['first' => 'Jess', 'email' => 'jessarcher@gmail.com'], + ], $data->select('first', 'email')->all()); + + $this->assertEquals([ + ['first' => 'Taylor', 'email' => 'taylorotwell@gmail.com'], + ['first' => 'Jess', 'email' => 'jessarcher@gmail.com'], + ], $data->select(collect(['first', 'email']))->all()); + } + + #[DataProvider('collectionClassProvider')] + public function testSelectWithObjects($collection) + { + $data = new $collection([ + (object) ['first' => 'Taylor', 'last' => 'Otwell', 'email' => 'taylorotwell@gmail.com'], + (object) ['first' => 'Jess', 'last' => 'Archer', 'email' => 'jessarcher@gmail.com'], + ]); + + $this->assertEquals($data->all(), $data->select(null)->all()); + $this->assertEquals([['first' => 'Taylor'], ['first' => 'Jess']], $data->select(['first', 'missing'])->all()); + $this->assertEquals([['first' => 'Taylor'], ['first' => 'Jess']], $data->select('first', 'missing')->all()); + $this->assertEquals([['first' => 'Taylor'], ['first' => 'Jess']], $data->select(collect(['first', 'missing']))->all()); + + $this->assertEquals([ + ['first' => 'Taylor', 'email' => 'taylorotwell@gmail.com'], + ['first' => 'Jess', 'email' => 'jessarcher@gmail.com'], + ], $data->select(['first', 'email'])->all()); + + $this->assertEquals([ + ['first' => 'Taylor', 'email' => 'taylorotwell@gmail.com'], + ['first' => 'Jess', 'email' => 'jessarcher@gmail.com'], + ], $data->select('first', 'email')->all()); + + $this->assertEquals([ + ['first' => 'Taylor', 'email' => 'taylorotwell@gmail.com'], + ['first' => 'Jess', 'email' => 'jessarcher@gmail.com'], + ], $data->select(collect(['first', 'email']))->all()); + } + + #[DataProvider('collectionClassProvider')] + public function testGettingAvgItemsFromCollection($collection) + { + $c = new $collection([(object) ['foo' => 10], (object) ['foo' => 20]]); + $this->assertEquals(15, $c->avg(function ($item) { + return $item->foo; + })); + $this->assertEquals(15, $c->avg('foo')); + $this->assertEquals(15, $c->avg->foo); + + $c = new $collection([(object) ['foo' => 10], (object) ['foo' => 20], (object) ['foo' => null]]); + $this->assertEquals(15, $c->avg(function ($item) { + return $item->foo; + })); + $this->assertEquals(15, $c->avg('foo')); + $this->assertEquals(15, $c->avg->foo); + + $c = new $collection([['foo' => 10], ['foo' => 20]]); + $this->assertEquals(15, $c->avg('foo')); + $this->assertEquals(15, $c->avg->foo); + + $c = new $collection([1, 2, 3, 4, 5]); + $this->assertEquals(3, $c->avg()); + + $c = new $collection(); + $this->assertNull($c->avg()); + + $c = new $collection([['foo' => '4'], ['foo' => '2']]); + $this->assertIsInt($c->avg('foo')); + $this->assertEquals(3, $c->avg('foo')); + + $c = new $collection([['foo' => 1], ['foo' => 2]]); + $this->assertIsFloat($c->avg('foo')); + $this->assertEquals(1.5, $c->avg('foo')); + + $c = new $collection([ + ['foo' => 1], ['foo' => 2], + (object) ['foo' => 6], + ]); + $this->assertEquals(3, $c->avg('foo')); + + $c = new $collection([0]); + $this->assertEquals(0, $c->avg()); + } + + #[DataProvider('collectionClassProvider')] + public function testJsonSerialize($collection) + { + $c = new $collection([ + new TestArrayableObject(), + new TestJsonableObject(), + new TestJsonSerializeObject(), + new TestJsonSerializeToStringObject(), + 'baz', + ]); + + $this->assertSame([ + ['foo' => 'bar'], + ['foo' => 'bar'], + ['foo' => 'bar'], + 'foobar', + 'baz', + ], $c->jsonSerialize()); + } + + #[DataProvider('collectionClassProvider')] + public function testCombineWithArray($collection) + { + $c = new $collection([1, 2, 3]); + $actual = $c->combine([4, 5, 6])->toArray(); + $expected = [ + 1 => 4, + 2 => 5, + 3 => 6, + ]; + + $this->assertSame($expected, $actual); + + $c = new $collection(['name', 'family']); + $actual = $c->combine([1 => 'taylor', 2 => 'otwell'])->toArray(); + $expected = [ + 'name' => 'taylor', + 'family' => 'otwell', + ]; + + $this->assertSame($expected, $actual); + + $c = new $collection([1 => 'name', 2 => 'family']); + $actual = $c->combine(['taylor', 'otwell'])->toArray(); + $expected = [ + 'name' => 'taylor', + 'family' => 'otwell', + ]; + + $this->assertSame($expected, $actual); + + $c = new $collection([1 => 'name', 2 => 'family']); + $actual = $c->combine([2 => 'taylor', 3 => 'otwell'])->toArray(); + $expected = [ + 'name' => 'taylor', + 'family' => 'otwell', + ]; + + $this->assertSame($expected, $actual); + } + + #[DataProvider('collectionClassProvider')] + public function testCombineWithCollection($collection) + { + $expected = [ + 1 => 4, + 2 => 5, + 3 => 6, + ]; + + $keyCollection = new $collection(array_keys($expected)); + $valueCollection = new $collection(array_values($expected)); + $actual = $keyCollection->combine($valueCollection)->toArray(); + + $this->assertSame($expected, $actual); + } + + #[DataProvider('collectionClassProvider')] + public function testConcatWithArray($collection) + { + $expected = [ + 0 => 4, + 1 => 5, + 2 => 6, + 3 => 'a', + 4 => 'b', + 5 => 'c', + 6 => 'Jonny', + 7 => 'from', + 8 => 'Laroe', + 9 => 'Jonny', + 10 => 'from', + 11 => 'Laroe', + ]; + + $data = new $collection([4, 5, 6]); + $data = $data->concat(['a', 'b', 'c']); + $data = $data->concat(['who' => 'Jonny', 'preposition' => 'from', 'where' => 'Laroe']); + $actual = $data->concat(['who' => 'Jonny', 'preposition' => 'from', 'where' => 'Laroe'])->toArray(); + + $this->assertSame($expected, $actual); + } + + #[DataProvider('collectionClassProvider')] + public function testConcatWithCollection($collection) + { + $expected = [ + 0 => 4, + 1 => 5, + 2 => 6, + 3 => 'a', + 4 => 'b', + 5 => 'c', + 6 => 'Jonny', + 7 => 'from', + 8 => 'Laroe', + 9 => 'Jonny', + 10 => 'from', + 11 => 'Laroe', + ]; + + $firstCollection = new $collection([4, 5, 6]); + $secondCollection = new $collection(['a', 'b', 'c']); + $thirdCollection = new $collection(['who' => 'Jonny', 'preposition' => 'from', 'where' => 'Laroe']); + $firstCollection = $firstCollection->concat($secondCollection); + $firstCollection = $firstCollection->concat($thirdCollection); + $actual = $firstCollection->concat($thirdCollection)->toArray(); + + $this->assertSame($expected, $actual); + } + + #[DataProvider('collectionClassProvider')] + public function testDump($collection) + { + $log = new Collection(); + + VarDumper::setHandler(function ($value) use ($log) { + $log->add($value); + }); + + (new $collection([1, 2, 3]))->dump('one', 'two'); + + $this->assertSame([[1, 2, 3], 'one', 'two'], $log->all()); + + VarDumper::setHandler(null); + } + + #[DataProvider('collectionClassProvider')] + public function testReduce($collection) + { + $data = new $collection([1, 2, 3]); + $this->assertEquals(6, $data->reduce(function ($carry, $element) { + return $carry += $element; + })); + + $data = new $collection([ + 'foo' => 'bar', + 'baz' => 'qux', + ]); + $this->assertSame('foobarbazqux', $data->reduce(function ($carry, $element, $key) { + return $carry .= $key . $element; + })); + } + + #[DataProvider('collectionClassProvider')] + public function testReduceSpread($collection) + { + $data = new $collection([-1, 0, 1, 2, 3, 4, 5]); + + [$sum, $max, $min] = $data->reduceSpread(function ($sum, $max, $min, $value) { + $sum += $value; + $max = max($max, $value); + $min = min($min, $value); + + return [$sum, $max, $min]; + }, 0, PHP_INT_MIN, PHP_INT_MAX); + + $this->assertEquals(14, $sum); + $this->assertEquals(5, $max); + $this->assertEquals(-1, $min); + } + + #[DataProvider('collectionClassProvider')] + public function testReduceSpreadThrowsAnExceptionIfReducerDoesNotReturnAnArray($collection) + { + $data = new $collection([1]); + + $this->expectException(UnexpectedValueException::class); + + $data->reduceSpread(function () { + return false; + }, null); + } + + #[DataProvider('collectionClassProvider')] + public function testRandomThrowsAnExceptionUsingAmountBiggerThanCollectionSize($collection) + { + $this->expectException(InvalidArgumentException::class); + + $data = new $collection([1, 2, 3]); + $data->random(4); + } + + #[DataProvider('collectionClassProvider')] + public function testPipe($collection) + { + $data = new $collection([1, 2, 3]); + + $this->assertEquals(6, $data->pipe(function ($data) { + return $data->sum(); + })); + } + + #[DataProvider('collectionClassProvider')] + public function testPipeInto($collection) + { + $data = new $collection([ + 'first', 'second', + ]); + + $instance = $data->pipeInto(TestCollectionMapIntoObject::class); + + $this->assertSame($data, $instance->value); + } + + #[DataProvider('collectionClassProvider')] + public function testPipeThrough($collection) + { + $data = new $collection([1, 2, 3]); + + $result = $data->pipeThrough([ + function ($data) { + return $data->merge([4, 5]); + }, + function ($data) { + return $data->sum(); + }, + ]); + + $this->assertEquals(15, $result); + } + + #[DataProvider('collectionClassProvider')] + public function testMedianValueWithArrayCollection($collection) + { + $data = new $collection([1, 2, 2, 4]); + + $this->assertEquals(2, $data->median()); + } + + #[DataProvider('collectionClassProvider')] + public function testMedianValueByKey($collection) + { + $data = new $collection([ + (object) ['foo' => 1], + (object) ['foo' => 2], + (object) ['foo' => 2], + (object) ['foo' => 4], + ]); + $this->assertEquals(2, $data->median('foo')); + } + + #[DataProvider('collectionClassProvider')] + public function testMedianOnCollectionWithNull($collection) + { + $data = new $collection([ + (object) ['foo' => 1], + (object) ['foo' => 2], + (object) ['foo' => 4], + (object) ['foo' => null], + ]); + $this->assertEquals(2, $data->median('foo')); + } + + #[DataProvider('collectionClassProvider')] + public function testEvenMedianCollection($collection) + { + $data = new $collection([ + (object) ['foo' => 0], + (object) ['foo' => 3], + ]); + $this->assertEquals(1.5, $data->median('foo')); + } + + #[DataProvider('collectionClassProvider')] + public function testMedianOutOfOrderCollection($collection) + { + $data = new $collection([ + (object) ['foo' => 0], + (object) ['foo' => 5], + (object) ['foo' => 3], + ]); + $this->assertEquals(3, $data->median('foo')); + } + + #[DataProvider('collectionClassProvider')] + public function testMedianOnEmptyCollectionReturnsNull($collection) + { + $data = new $collection(); + $this->assertNull($data->median()); + } + + #[DataProvider('collectionClassProvider')] + public function testModeOnNullCollection($collection) + { + $data = new $collection(); + $this->assertNull($data->mode()); + } + + #[DataProvider('collectionClassProvider')] + public function testMode($collection) + { + $data = new $collection([1, 2, 3, 4, 4, 5]); + $this->assertIsArray($data->mode()); + $this->assertEquals([4], $data->mode()); + } + + #[DataProvider('collectionClassProvider')] + public function testModeValueByKey($collection) + { + $data = new $collection([ + (object) ['foo' => 1], + (object) ['foo' => 1], + (object) ['foo' => 2], + (object) ['foo' => 4], + ]); + $data2 = new Collection([ + ['foo' => 1], + ['foo' => 1], + ['foo' => 2], + ['foo' => 4], + ]); + $this->assertEquals([1], $data->mode('foo')); + $this->assertEquals($data2->mode('foo'), $data->mode('foo')); + } + + #[DataProvider('collectionClassProvider')] + public function testWithMultipleModeValues($collection) + { + $data = new $collection([1, 2, 2, 1]); + $this->assertEquals([1, 2], $data->mode()); + } + + #[DataProvider('collectionClassProvider')] + public function testSliceOffset($collection) + { + $data = new $collection([1, 2, 3, 4, 5, 6, 7, 8]); + $this->assertEquals([4, 5, 6, 7, 8], $data->slice(3)->values()->toArray()); + } + + #[DataProvider('collectionClassProvider')] + public function testSliceNegativeOffset($collection) + { + $data = new $collection([1, 2, 3, 4, 5, 6, 7, 8]); + $this->assertEquals([6, 7, 8], $data->slice(-3)->values()->toArray()); + } + + #[DataProvider('collectionClassProvider')] + public function testSliceOffsetAndLength($collection) + { + $data = new $collection([1, 2, 3, 4, 5, 6, 7, 8]); + $this->assertEquals([4, 5, 6], $data->slice(3, 3)->values()->toArray()); + } + + #[DataProvider('collectionClassProvider')] + public function testSliceOffsetAndNegativeLength($collection) + { + $data = new $collection([1, 2, 3, 4, 5, 6, 7, 8]); + $this->assertEquals([4, 5, 6, 7], $data->slice(3, -1)->values()->toArray()); + } + + #[DataProvider('collectionClassProvider')] + public function testSliceNegativeOffsetAndLength($collection) + { + $data = new $collection([1, 2, 3, 4, 5, 6, 7, 8]); + $this->assertEquals([4, 5, 6], $data->slice(-5, 3)->values()->toArray()); + } + + #[DataProvider('collectionClassProvider')] + public function testSliceNegativeOffsetAndNegativeLength($collection) + { + $data = new $collection([1, 2, 3, 4, 5, 6, 7, 8]); + $this->assertEquals([3, 4, 5, 6], $data->slice(-6, -2)->values()->toArray()); + } + + #[DataProvider('collectionClassProvider')] + public function testCollectionFromTraversable($collection) + { + $data = new $collection(new ArrayObject([1, 2, 3])); + $this->assertEquals([1, 2, 3], $data->toArray()); + } + + #[DataProvider('collectionClassProvider')] + public function testCollectionFromTraversableWithKeys($collection) + { + $data = new $collection(new ArrayObject(['foo' => 1, 'bar' => 2, 'baz' => 3])); + $this->assertEquals(['foo' => 1, 'bar' => 2, 'baz' => 3], $data->toArray()); + } + + #[DataProvider('collectionClassProvider')] + public function testCollectionFromEnum($collection) + { + $data = new $collection(TestEnum::A); + $this->assertEquals([TestEnum::A], $data->toArray()); + } + + #[DataProvider('collectionClassProvider')] + public function testCollectionFromBackedEnum($collection) + { + $data = new $collection(TestBackedEnum::A); + $this->assertEquals([TestBackedEnum::A], $data->toArray()); + } + + #[DataProvider('collectionClassProvider')] + public function testSplitCollectionWithADivisibleCount($collection) + { + $data = new $collection(['a', 'b', 'c', 'd']); + $split = $data->split(2); + + $this->assertSame(['a', 'b'], $split->get(0)->all()); + $this->assertSame(['c', 'd'], $split->get(1)->all()); + $this->assertInstanceOf($collection, $split); + + $this->assertEquals( + [['a', 'b'], ['c', 'd']], + $data->split(2)->map(function (Collection $chunk) { + return $chunk->values()->toArray(); + })->toArray() + ); + + $data = new $collection([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); + $split = $data->split(2); + + $this->assertSame([1, 2, 3, 4, 5], $split->get(0)->all()); + $this->assertSame([6, 7, 8, 9, 10], $split->get(1)->all()); + + $this->assertEquals( + [[1, 2, 3, 4, 5], [6, 7, 8, 9, 10]], + $data->split(2)->map(function (Collection $chunk) { + return $chunk->values()->toArray(); + })->toArray() + ); + } + + #[DataProvider('collectionClassProvider')] + public function testSplitCollectionWithAnUndivisableCount($collection) + { + $data = new $collection(['a', 'b', 'c']); + $split = $data->split(2); + + $this->assertSame(['a', 'b'], $split->get(0)->all()); + $this->assertSame(['c'], $split->get(1)->all()); + + $this->assertEquals( + [['a', 'b'], ['c']], + $data->split(2)->map(function (Collection $chunk) { + return $chunk->values()->toArray(); + })->toArray() + ); + } + + #[DataProvider('collectionClassProvider')] + public function testSplitCollectionWithCountLessThenDivisor($collection) + { + $data = new $collection(['a']); + $split = $data->split(2); + + $this->assertSame(['a'], $split->get(0)->all()); + $this->assertNull($split->get(1)); + + $this->assertEquals( + [['a']], + $data->split(2)->map(function (Collection $chunk) { + return $chunk->values()->toArray(); + })->toArray() + ); + } + + #[DataProvider('collectionClassProvider')] + public function testSplitCollectionIntoThreeWithCountOfFour($collection) + { + $data = new $collection(['a', 'b', 'c', 'd']); + $split = $data->split(3); + + $this->assertSame(['a', 'b'], $split->get(0)->all()); + $this->assertSame(['c'], $split->get(1)->all()); + $this->assertSame(['d'], $split->get(2)->all()); + + $this->assertEquals( + [['a', 'b'], ['c'], ['d']], + $data->split(3)->map(function (Collection $chunk) { + return $chunk->values()->toArray(); + })->toArray() + ); + } + + #[DataProvider('collectionClassProvider')] + public function testSplitCollectionIntoThreeWithCountOfFive($collection) + { + $data = new $collection(['a', 'b', 'c', 'd', 'e']); + $split = $data->split(3); + + $this->assertSame(['a', 'b'], $split->get(0)->all()); + $this->assertSame(['c', 'd'], $split->get(1)->all()); + $this->assertSame(['e'], $split->get(2)->all()); + + $this->assertEquals( + [['a', 'b'], ['c', 'd'], ['e']], + $data->split(3)->map(function (Collection $chunk) { + return $chunk->values()->toArray(); + })->toArray() + ); + } + + #[DataProvider('collectionClassProvider')] + public function testSplitCollectionIntoSixWithCountOfTen($collection) + { + $data = new $collection(['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j']); + $split = $data->split(6); + + $this->assertSame(['a', 'b'], $split->get(0)->all()); + $this->assertSame(['c', 'd'], $split->get(1)->all()); + $this->assertSame(['e', 'f'], $split->get(2)->all()); + $this->assertSame(['g', 'h'], $split->get(3)->all()); + $this->assertSame(['i'], $split->get(4)->all()); + $this->assertSame(['j'], $split->get(5)->all()); + + $this->assertEquals( + [['a', 'b'], ['c', 'd'], ['e', 'f'], ['g', 'h'], ['i'], ['j']], + $data->split(6)->map(function (Collection $chunk) { + return $chunk->values()->toArray(); + })->toArray() + ); + } + + #[DataProvider('collectionClassProvider')] + public function testSplitEmptyCollection($collection) + { + $data = new $collection(); + $split = $data->split(2); + + $this->assertNull($split->get(0)); + $this->assertNull($split->get(1)); + + $this->assertEquals( + [], + $data->split(2)->map(function (Collection $chunk) { + return $chunk->values()->toArray(); + })->toArray() + ); + } + + #[DataProvider('collectionClassProvider')] + public function testHigherOrderCollectionGroupBy($collection) + { + $data = new $collection([ + new TestSupportCollectionHigherOrderItem(), + new TestSupportCollectionHigherOrderItem('TAYLOR'), + new TestSupportCollectionHigherOrderItem('foo'), + ]); + + $this->assertEquals([ + 'taylor' => [$data->get(0)], + 'TAYLOR' => [$data->get(1)], + 'foo' => [$data->get(2)], + ], $data->groupBy->name->toArray()); + + $this->assertEquals([ + 'TAYLOR' => [$data->get(0), $data->get(1)], + 'FOO' => [$data->get(2)], + ], $data->groupBy->uppercase()->toArray()); + } + + #[DataProvider('collectionClassProvider')] + public function testHigherOrderCollectionMap($collection) + { + $person1 = (object) ['name' => 'Taylor']; + $person2 = (object) ['name' => 'Yaz']; + + $data = new $collection([$person1, $person2]); + + $this->assertEquals(['Taylor', 'Yaz'], $data->map->name->toArray()); + + $data = new $collection([new TestSupportCollectionHigherOrderItem(), new TestSupportCollectionHigherOrderItem()]); + + $this->assertEquals(['TAYLOR', 'TAYLOR'], $data->each->uppercase()->map->name->toArray()); + } + + #[DataProvider('collectionClassProvider')] + public function testHigherOrderCollectionMapFromArrays($collection) + { + $person1 = ['name' => 'Taylor']; + $person2 = ['name' => 'Yaz']; + + $data = new $collection([$person1, $person2]); + + $this->assertEquals(['Taylor', 'Yaz'], $data->map->name->toArray()); + + $data = new $collection([new TestSupportCollectionHigherOrderItem(), new TestSupportCollectionHigherOrderItem()]); + + $this->assertEquals(['TAYLOR', 'TAYLOR'], $data->each->uppercase()->map->name->toArray()); + } + + #[DataProvider('collectionClassProvider')] + public function testHigherOrderCollectionStaticCall($collection) + { + $class1 = TestSupportCollectionHigherOrderStaticClass1::class; + $class2 = TestSupportCollectionHigherOrderStaticClass2::class; + + $classes = new $collection([$class1, $class2]); + + $this->assertEquals(['TAYLOR', 't a y l o r'], $classes->map->transform('taylor')->toArray()); + $this->assertEquals($class1, $classes->first->matches('Taylor')); + $this->assertEquals($class2, $classes->first->matches('Otwell')); + } + + #[DataProvider('collectionClassProvider')] + public function testPartition($collection) + { + $data = new $collection(range(1, 10)); + + [$firstPartition, $secondPartition] = $data->partition(function ($i) { + return $i <= 5; + })->all(); + + $this->assertEquals([1, 2, 3, 4, 5], $firstPartition->values()->toArray()); + $this->assertEquals([6, 7, 8, 9, 10], $secondPartition->values()->toArray()); + } + + #[DataProvider('collectionClassProvider')] + public function testPartitionCallbackWithKey($collection) + { + $data = new $collection(['zero', 'one', 'two', 'three']); + + [$even, $odd] = $data->partition(function ($item, $index) { + return $index % 2 === 0; + })->all(); + + $this->assertEquals(['zero', 'two'], $even->values()->toArray()); + $this->assertEquals(['one', 'three'], $odd->values()->toArray()); + } + + #[DataProvider('collectionClassProvider')] + public function testPartitionByKey($collection) + { + $courses = new $collection([ + ['free' => true, 'title' => 'Basic'], ['free' => false, 'title' => 'Premium'], + ]); + + [$free, $premium] = $courses->partition('free')->all(); + + $this->assertSame([['free' => true, 'title' => 'Basic']], $free->values()->toArray()); + $this->assertSame([['free' => false, 'title' => 'Premium']], $premium->values()->toArray()); + } + + #[DataProvider('collectionClassProvider')] + public function testPartitionWithOperators($collection) + { + $data = new $collection([ + ['name' => 'Tim', 'age' => 17], + ['name' => 'Agatha', 'age' => 62], + ['name' => 'Kristina', 'age' => 33], + ['name' => 'Tim', 'age' => 41], + ]); + + [$tims, $others] = $data->partition('name', 'Tim')->all(); + + $this->assertEquals([ + ['name' => 'Tim', 'age' => 17], + ['name' => 'Tim', 'age' => 41], + ], $tims->values()->all()); + + $this->assertEquals([ + ['name' => 'Agatha', 'age' => 62], + ['name' => 'Kristina', 'age' => 33], + ], $others->values()->all()); + + [$adults, $minors] = $data->partition('age', '>=', 18)->all(); + + $this->assertEquals([ + ['name' => 'Agatha', 'age' => 62], + ['name' => 'Kristina', 'age' => 33], + ['name' => 'Tim', 'age' => 41], + ], $adults->values()->all()); + + $this->assertEquals([ + ['name' => 'Tim', 'age' => 17], + ], $minors->values()->all()); + } + + #[DataProvider('collectionClassProvider')] + public function testPartitionPreservesKeys($collection) + { + $courses = new $collection([ + 'a' => ['free' => true], 'b' => ['free' => false], 'c' => ['free' => true], + ]); + + [$free, $premium] = $courses->partition('free')->all(); + + $this->assertSame(['a' => ['free' => true], 'c' => ['free' => true]], $free->toArray()); + $this->assertSame(['b' => ['free' => false]], $premium->toArray()); + } + + #[DataProvider('collectionClassProvider')] + public function testPartitionEmptyCollection($collection) + { + $data = new $collection(); + + $this->assertCount(2, $data->partition(function () { + return true; + })); + } + + #[DataProvider('collectionClassProvider')] + public function testHigherOrderPartition($collection) + { + $courses = new $collection([ + 'a' => ['free' => true], 'b' => ['free' => false], 'c' => ['free' => true], + ]); + + [$free, $premium] = $courses->partition->free->all(); + + $this->assertSame(['a' => ['free' => true], 'c' => ['free' => true]], $free->toArray()); + + $this->assertSame(['b' => ['free' => false]], $premium->toArray()); + } + + #[DataProvider('collectionClassProvider')] + public function testTap($collection) + { + $data = new $collection([1, 2, 3]); + + $fromTap = []; + $tappedInstance = null; + $data = $data->tap(function ($data) use (&$fromTap, &$tappedInstance) { + $fromTap = $data->slice(0, 1)->toArray(); + $tappedInstance = $data; + }); + + $this->assertSame($data, $tappedInstance); + $this->assertSame([1], $fromTap); + $this->assertSame([1, 2, 3], $data->toArray()); + } + + #[DataProvider('collectionClassProvider')] + public function testWhen($collection) + { + $data = new $collection(['michael', 'tom']); + + $data = $data->when('adam', function ($data, $newName) { + return $data->concat([$newName]); + }); + + $this->assertSame(['michael', 'tom', 'adam'], $data->toArray()); + + $data = new $collection(['michael', 'tom']); + + $data = $data->when(false, function ($data) { + return $data->concat(['adam']); + }); + + $this->assertSame(['michael', 'tom'], $data->toArray()); + } + + #[DataProvider('collectionClassProvider')] + public function testWhenDefault($collection) + { + $data = new $collection(['michael', 'tom']); + + $data = $data->when(false, function ($data) { + return $data->concat(['adam']); + }, function ($data) { + return $data->concat(['taylor']); + }); + + $this->assertSame(['michael', 'tom', 'taylor'], $data->toArray()); + } + + #[DataProvider('collectionClassProvider')] + public function testWhenEmpty($collection) + { + $data = new $collection(['michael', 'tom']); + + $data = $data->whenEmpty(function () { + throw new Exception('whenEmpty() should not trigger on a collection with items'); + }); + + $this->assertSame(['michael', 'tom'], $data->toArray()); + + $data = new $collection(); + + $data = $data->whenEmpty(function ($data) { + return $data->concat(['adam']); + }); + + $this->assertSame(['adam'], $data->toArray()); + } + + #[DataProvider('collectionClassProvider')] + public function testWhenEmptyDefault($collection) + { + $data = new $collection(['michael', 'tom']); + + $data = $data->whenEmpty(function ($data) { + return $data->concat(['adam']); + }, function ($data) { + return $data->concat(['taylor']); + }); + + $this->assertSame(['michael', 'tom', 'taylor'], $data->toArray()); + } + + #[DataProvider('collectionClassProvider')] + public function testWhenNotEmpty($collection) + { + $data = new $collection(['michael', 'tom']); + + $data = $data->whenNotEmpty(function ($data) { + return $data->concat(['adam']); + }); + + $this->assertSame(['michael', 'tom', 'adam'], $data->toArray()); + + $data = new $collection(); + + $data = $data->whenNotEmpty(function ($data) { + return $data->concat(['adam']); + }); + + $this->assertSame([], $data->toArray()); + } + + #[DataProvider('collectionClassProvider')] + public function testWhenNotEmptyDefault($collection) + { + $data = new $collection(['michael', 'tom']); + + $data = $data->whenNotEmpty(function ($data) { + return $data->concat(['adam']); + }, function ($data) { + return $data->concat(['taylor']); + }); + + $this->assertSame(['michael', 'tom', 'adam'], $data->toArray()); + } + + #[DataProvider('collectionClassProvider')] + public function testHigherOrderWhenAndUnless($collection) + { + $data = new $collection(['michael', 'tom']); + + $data = $data->when(true)->concat(['chris']); + + $this->assertSame(['michael', 'tom', 'chris'], $data->toArray()); + + $data = $data->when(false)->concat(['adam']); + + $this->assertSame(['michael', 'tom', 'chris'], $data->toArray()); + + $data = $data->unless(false)->concat(['adam']); + + $this->assertSame(['michael', 'tom', 'chris', 'adam'], $data->toArray()); + + $data = $data->unless(true)->concat(['bogdan']); + + $this->assertSame(['michael', 'tom', 'chris', 'adam'], $data->toArray()); + } + + #[DataProvider('collectionClassProvider')] + public function testHigherOrderWhenAndUnlessWithProxy($collection) + { + $data = new $collection(['michael', 'tom']); + + $data = $data->when->contains('michael')->concat(['chris']); + + $this->assertSame(['michael', 'tom', 'chris'], $data->toArray()); + + $data = $data->when->contains('missing')->concat(['adam']); + + $this->assertSame(['michael', 'tom', 'chris'], $data->toArray()); + + $data = $data->unless->contains('missing')->concat(['adam']); + + $this->assertSame(['michael', 'tom', 'chris', 'adam'], $data->toArray()); + + $data = $data->unless->contains('adam')->concat(['bogdan']); + + $this->assertSame(['michael', 'tom', 'chris', 'adam'], $data->toArray()); + } + + #[DataProvider('collectionClassProvider')] + public function testUnless($collection) + { + $data = new $collection(['michael', 'tom']); + + $data = $data->unless(false, function ($data) { + return $data->concat(['caleb']); + }); + + $this->assertSame(['michael', 'tom', 'caleb'], $data->toArray()); + + $data = new $collection(['michael', 'tom']); + + $data = $data->unless(true, function ($data) { + return $data->concat(['caleb']); + }); + + $this->assertSame(['michael', 'tom'], $data->toArray()); + } + + #[DataProvider('collectionClassProvider')] + public function testUnlessDefault($collection) + { + $data = new $collection(['michael', 'tom']); + + $data = $data->unless(true, function ($data) { + return $data->concat(['caleb']); + }, function ($data) { + return $data->concat(['taylor']); + }); + + $this->assertSame(['michael', 'tom', 'taylor'], $data->toArray()); + } + + #[DataProvider('collectionClassProvider')] + public function testUnlessEmpty($collection) + { + $data = new $collection(['michael', 'tom']); + + $data = $data->unlessEmpty(function ($data) { + return $data->concat(['adam']); + }); + + $this->assertSame(['michael', 'tom', 'adam'], $data->toArray()); + + $data = new $collection(); + + $data = $data->unlessEmpty(function ($data) { + return $data->concat(['adam']); + }); + + $this->assertSame([], $data->toArray()); + } + + #[DataProvider('collectionClassProvider')] + public function testUnlessEmptyDefault($collection) + { + $data = new $collection(['michael', 'tom']); + + $data = $data->unlessEmpty(function ($data) { + return $data->concat(['adam']); + }, function ($data) { + return $data->concat(['taylor']); + }); + + $this->assertSame(['michael', 'tom', 'adam'], $data->toArray()); + } + + #[DataProvider('collectionClassProvider')] + public function testUnlessNotEmpty($collection) + { + $data = new $collection(['michael', 'tom']); + + $data = $data->unlessNotEmpty(function ($data) { + return $data->concat(['adam']); + }); + + $this->assertSame(['michael', 'tom'], $data->toArray()); + + $data = new $collection(); + + $data = $data->unlessNotEmpty(function ($data) { + return $data->concat(['adam']); + }); + + $this->assertSame(['adam'], $data->toArray()); + } + + #[DataProvider('collectionClassProvider')] + public function testUnlessNotEmptyDefault($collection) + { + $data = new $collection(['michael', 'tom']); + + $data = $data->unlessNotEmpty(function ($data) { + return $data->concat(['adam']); + }, function ($data) { + return $data->concat(['taylor']); + }); + + $this->assertSame(['michael', 'tom', 'taylor'], $data->toArray()); + } + + #[DataProvider('collectionClassProvider')] + public function testHasReturnsValidResults($collection) + { + $data = new $collection(['foo' => 'one', 'bar' => 'two', 1 => 'three']); + $this->assertTrue($data->has('foo')); + $this->assertTrue($data->has('foo', 'bar', 1)); + $this->assertFalse($data->has('foo', 'bar', 1, 'baz')); + $this->assertFalse($data->has('baz')); + } + + public function testPutAddsItemToCollection() + { + $data = new Collection(); + $this->assertSame([], $data->toArray()); + $data->put('foo', 1); + $this->assertSame(['foo' => 1], $data->toArray()); + $data->put('bar', ['nested' => 'two']); + $this->assertSame(['foo' => 1, 'bar' => ['nested' => 'two']], $data->toArray()); + $data->put('foo', 3); + $this->assertSame(['foo' => 3, 'bar' => ['nested' => 'two']], $data->toArray()); + } + + #[DataProvider('collectionClassProvider')] + public function testItThrowsExceptionWhenTryingToAccessNoProxyProperty($collection) + { + $data = new $collection(); + $this->expectException(Exception::class); + $this->expectExceptionMessage('Property [foo] does not exist on this collection instance.'); + $data->foo; + } + + #[DataProvider('collectionClassProvider')] + public function testGetWithNullReturnsNull($collection) + { + $data = new $collection([1, 2, 3]); + $this->assertNull($data->get(null)); + } + + #[DataProvider('collectionClassProvider')] + public function testGetWithDefaultValue($collection) + { + $data = new $collection(['name' => 'taylor', 'framework' => 'laravel']); + $this->assertEquals('34', $data->get('age', 34)); + } + + #[DataProvider('collectionClassProvider')] + public function testGetWithCallbackAsDefaultValue($collection) + { + $data = new $collection(['name' => 'taylor', 'framework' => 'laravel']); + $result = $data->get('email', function () { + return 'taylor@example.com'; + }); + $this->assertEquals('taylor@example.com', $result); + } + + #[DataProvider('collectionClassProvider')] + public function testWhereNull($collection) + { + $data = new $collection([ + ['name' => 'Taylor'], + ['name' => null], + ['name' => 'Bert'], + ['name' => false], + ['name' => ''], + ]); + + $this->assertSame([ + 1 => ['name' => null], + ], $data->whereNull('name')->all()); + + $this->assertSame([], $data->whereNull()->all()); + } + + #[DataProvider('collectionClassProvider')] + public function testWhereNullWithoutKey($collection) + { + $collection = new $collection([1, null, 3, 'null', false, true]); + $this->assertSame([ + 1 => null, + ], $collection->whereNull()->all()); + } + + #[DataProvider('collectionClassProvider')] + public function testWhereNotNull($collection) + { + $data = new $collection($originalData = [ + ['name' => 'Taylor'], + ['name' => null], + ['name' => 'Bert'], + ['name' => false], + ['name' => ''], + ]); + + $this->assertSame([ + 0 => ['name' => 'Taylor'], + 2 => ['name' => 'Bert'], + 3 => ['name' => false], + 4 => ['name' => ''], + ], $data->whereNotNull('name')->all()); + + $this->assertSame($originalData, $data->whereNotNull()->all()); + } + + #[DataProvider('collectionClassProvider')] + public function testWhereNotNullWithoutKey($collection) + { + $data = new $collection([1, null, 3, 'null', false, true]); + + $this->assertSame([ + 0 => 1, + 2 => 3, + 3 => 'null', + 4 => false, + 5 => true, + ], $data->whereNotNull()->all()); + } + + #[DataProvider('collectionClassProvider')] + public function testCollect($collection) + { + $data = $collection::make([ + 'a' => 1, + 'b' => 2, + 'c' => 3, + ])->collect(); + + $this->assertInstanceOf(Collection::class, $data); + + $this->assertSame([ + 'a' => 1, + 'b' => 2, + 'c' => 3, + ], $data->all()); + } + + #[DataProvider('collectionClassProvider')] + public function testUndot($collection) + { + $data = $collection::make([ + 'name' => 'Taylor', + 'meta.foo' => 'bar', + 'meta.baz' => 'boom', + 'meta.bam.boom' => 'bip', + ])->undot(); + $this->assertSame([ + 'name' => 'Taylor', + 'meta' => [ + 'foo' => 'bar', + 'baz' => 'boom', + 'bam' => [ + 'boom' => 'bip', + ], + ], + ], $data->all()); + + $data = $collection::make([ + 'foo.0' => 'bar', + 'foo.1' => 'baz', + 'foo.baz' => 'boom', + ])->undot(); + $this->assertSame([ + 'foo' => [ + 'bar', + 'baz', + 'baz' => 'boom', + ], + ], $data->all()); + } + + #[DataProvider('collectionClassProvider')] + public function testDot($collection) + { + $data = $collection::make([ + 'name' => 'Taylor', + 'meta' => [ + 'foo' => 'bar', + 'baz' => 'boom', + 'bam' => [ + 'boom' => 'bip', + ], + ], + ])->dot(); + $this->assertSame([ + 'name' => 'Taylor', + 'meta.foo' => 'bar', + 'meta.baz' => 'boom', + 'meta.bam.boom' => 'bip', + ], $data->all()); + + $data = $collection::make([ + 'foo' => [ + 'bar', + 'baz', + 'baz' => 'boom', + ], + ])->dot(); + $this->assertSame([ + 'foo.0' => 'bar', + 'foo.1' => 'baz', + 'foo.baz' => 'boom', + ], $data->all()); + } + + #[DataProvider('collectionClassProvider')] + public function testEnsureForScalar($collection) + { + $data = $collection::make([1, 2, 3]); + $data->ensure('int'); + + $data = $collection::make([1, 2, 3, 'foo']); + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage("Collection should only include [int] items, but 'string' found at position 3."); + $data->ensure('int'); + } + + #[DataProvider('collectionClassProvider')] + public function testEnsureForObjects($collection) + { + $data = $collection::make([new stdClass(), new stdClass(), new stdClass()]); + $data->ensure(stdClass::class); + + $data = $collection::make([new stdClass(), new stdClass(), new stdClass(), $collection]); + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage(sprintf('Collection should only include [%s] items, but \'%s\' found at position %d.', class_basename(new stdClass()), gettype($collection), 3)); + $data->ensure(stdClass::class); + } + + #[DataProvider('collectionClassProvider')] + public function testEnsureForInheritance($collection) + { + $data = $collection::make([new Error(), new Error()]); + $data->ensure(Throwable::class); + + $wrongType = new $collection(); + $data = $collection::make([new Error(), new Error(), $wrongType]); + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage(sprintf("Collection should only include [%s] items, but '%s' found at position %d.", Throwable::class, get_class($wrongType), 2)); + $data->ensure(Throwable::class); + } + + #[DataProvider('collectionClassProvider')] + public function testEnsureForMultipleTypes($collection) + { + $data = $collection::make([new Error(), 123]); + $data->ensure([Throwable::class, 'int']); + + $wrongType = new $collection(); + $data = $collection::make([new Error(), new Error(), $wrongType]); + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage(sprintf('Collection should only include [%s] items, but \'%s\' found at position %d.', implode(', ', [Throwable::class, 'int']), get_class($wrongType), 2)); + $data->ensure([Throwable::class, 'int']); + } + + #[DataProvider('collectionClassProvider')] + public function testPercentageWithFlatCollection($collection) + { + $collection = new $collection([1, 1, 2, 2, 2, 3]); + + $this->assertSame(33.33, $collection->percentage(fn ($value) => $value === 1)); + $this->assertSame(50.00, $collection->percentage(fn ($value) => $value === 2)); + $this->assertSame(16.67, $collection->percentage(fn ($value) => $value === 3)); + $this->assertSame(0.0, $collection->percentage(fn ($value) => $value === 5)); + } + + #[DataProvider('collectionClassProvider')] + public function testPercentageWithNestedCollection($collection) + { + $collection = new $collection([ + ['name' => 'Taylor', 'foo' => 'foo'], + ['name' => 'Nuno', 'foo' => 'bar'], + ['name' => 'Dries', 'foo' => 'bar'], + ['name' => 'Jess', 'foo' => 'baz'], + ]); + + $this->assertSame(25.00, $collection->percentage(fn ($value) => $value['foo'] === 'foo')); + $this->assertSame(50.00, $collection->percentage(fn ($value) => $value['foo'] === 'bar')); + $this->assertSame(25.00, $collection->percentage(fn ($value) => $value['foo'] === 'baz')); + $this->assertSame(0.0, $collection->percentage(fn ($value) => $value['foo'] === 'test')); + } + + #[DataProvider('collectionClassProvider')] + public function testHighOrderPercentage($collection) + { + $collection = new $collection([ + ['name' => 'Taylor', 'active' => true], + ['name' => 'Nuno', 'active' => true], + ['name' => 'Dries', 'active' => false], + ['name' => 'Jess', 'active' => true], + ]); + + $this->assertSame(75.00, $collection->percentage->active); + } + + #[DataProvider('collectionClassProvider')] + public function testPercentageReturnsNullForEmptyCollections($collection) + { + $collection = new $collection([]); + + $this->assertNull($collection->percentage(fn ($value) => $value === 1)); + } + + /** + * Provides each collection class, respectively. + * + * @return array + */ + public static function collectionClassProvider() + { + return [ + [Collection::class], + [LazyCollection::class], + ]; + } +} + +class TestSupportCollectionHigherOrderItem +{ + public $name; + + public function __construct($name = 'taylor') + { + $this->name = $name; + } + + public function uppercase() + { + return $this->name = strtoupper($this->name); + } + + public function is($name) + { + return $this->name === $name; + } +} + +class TestSupportCollectionHigherOrderStaticClass1 +{ + public static function transform($name) + { + return strtoupper($name); + } + + public static function matches($name) + { + return str_starts_with($name, 'T'); + } +} + +class TestSupportCollectionHigherOrderStaticClass2 +{ + public static function transform($name) + { + return trim(chunk_split($name, 1, ' ')); + } + + public static function matches($name) + { + return str_starts_with($name, 'O'); + } +} + +class TestAccessorEloquentTestStub +{ + protected $attributes = []; + + public function __construct($attributes) + { + $this->attributes = $attributes; + } + + public function __get($attribute) + { + $accessor = 'get' . lcfirst($attribute) . 'Attribute'; + if (method_exists($this, $accessor)) { + return $this->{$accessor}(); + } + + return $this->{$attribute}; + } + + public function __isset($attribute) + { + $accessor = 'get' . lcfirst($attribute) . 'Attribute'; + + if (method_exists($this, $accessor)) { + return ! is_null($this->{$accessor}()); + } + + return isset($this->{$attribute}); + } + + public function getSomeAttribute() + { + return $this->attributes['some']; + } +} + +class TestArrayAccessImplementation implements ArrayAccess +{ + private $arr; + + public function __construct($arr) + { + $this->arr = $arr; + } + + public function offsetExists($offset): bool + { + return isset($this->arr[$offset]); + } + + public function offsetGet($offset): mixed + { + return $this->arr[$offset]; + } + + public function offsetSet($offset, $value): void + { + $this->arr[$offset] = $value; + } + + public function offsetUnset($offset): void + { + unset($this->arr[$offset]); + } +} + +class TestJsonSerializeToStringObject implements JsonSerializable +{ + public function jsonSerialize(): string + { + return 'foobar'; + } +} + +class TestCollectionMapIntoObject +{ + public $value; + + public function __construct($value) + { + $this->value = $value; + } +} + +class TestCollectionSubclass extends Collection +{ +} + +enum StaffEnum +{ + case Taylor; + case Joe; + case James; +} diff --git a/tests/Support/SupportEnumValueFunctionTest.php b/tests/Support/SupportEnumValueFunctionTest.php new file mode 100644 index 000000000..2e11795e4 --- /dev/null +++ b/tests/Support/SupportEnumValueFunctionTest.php @@ -0,0 +1,54 @@ +assertSame($expected, enum_value($given)); + } + + public static function scalarDataProvider() + { + yield [TestEnum::A, 'A']; + yield [TestBackedEnum::A, 1]; + yield [TestBackedEnum::B, 2]; + yield [TestStringBackedEnum::A, 'A']; + yield [TestStringBackedEnum::B, 'B']; + yield [null, null]; + yield [0, 0]; + yield ['0', '0']; + yield [false, false]; + yield [1, 1]; + yield ['1', '1']; + yield [true, true]; + yield [[], []]; + yield ['', '']; + yield ['laravel', 'laravel']; + yield [true, true]; + yield [1337, 1337]; + yield [1.0, 1.0]; + yield [$collect = collect(), $collect]; + } + + public function testItCanFallbackToUseDefaultIfValueIsNull() + { + $this->assertSame('laravel', enum_value(null, 'laravel')); + $this->assertSame('laravel', enum_value(null, fn () => 'laravel')); + } +} diff --git a/tests/Support/SupportFluentTest.php b/tests/Support/SupportFluentTest.php new file mode 100644 index 000000000..c5c9fb27b --- /dev/null +++ b/tests/Support/SupportFluentTest.php @@ -0,0 +1,527 @@ + 'Taylor', 'age' => 25]; + $fluent = new Fluent($array); + + $refl = new ReflectionObject($fluent); + $attributes = $refl->getProperty('attributes'); + + $this->assertEquals($array, $attributes->getValue($fluent)); + $this->assertEquals($array, $fluent->getAttributes()); + } + + public function testAttributesAreSetByConstructorGivenstdClass() + { + $array = ['name' => 'Taylor', 'age' => 25]; + $fluent = new Fluent((object) $array); + + $refl = new ReflectionObject($fluent); + $attributes = $refl->getProperty('attributes'); + + $this->assertEquals($array, $attributes->getValue($fluent)); + $this->assertEquals($array, $fluent->getAttributes()); + } + + public function testAttributesAreSetByConstructorGivenArrayIterator() + { + $array = ['name' => 'Taylor', 'age' => 25]; + $fluent = new Fluent(new FluentArrayIteratorStub($array)); + + $refl = new ReflectionObject($fluent); + $attributes = $refl->getProperty('attributes'); + + $this->assertEquals($array, $attributes->getValue($fluent)); + $this->assertEquals($array, $fluent->getAttributes()); + } + + public function testGetMethodReturnsAttribute() + { + $fluent = new Fluent(['name' => 'Taylor']); + + $this->assertSame('Taylor', $fluent->get('name')); + $this->assertSame('Default', $fluent->get('foo', 'Default')); + $this->assertSame('Taylor', $fluent->name); + $this->assertNull($fluent->foo); + } + + public function testSetMethodSetsAttribute() + { + $fluent = new Fluent(); + + $fluent->set('name', 'Taylor'); + $fluent->set('developer', true); + $fluent->set('posts', 25); + $fluent->set('computer.color', 'silver'); + + $this->assertSame('Taylor', $fluent->name); + $this->assertTrue($fluent->developer); + $this->assertSame(25, $fluent->posts); + $this->assertSame(['color' => 'silver'], $fluent->computer); + } + + public function testArrayAccessToAttributes() + { + $fluent = new Fluent(['attributes' => '1']); + + $this->assertTrue(isset($fluent['attributes'])); + $this->assertEquals(1, $fluent['attributes']); + + $fluent->attributes(); + + $this->assertTrue($fluent['attributes']); + } + + public function testMagicMethodsCanBeUsedToSetAttributes() + { + $fluent = new Fluent(); + + $fluent->name = 'Taylor'; + $fluent->developer(); + $fluent->age(25); + + $this->assertSame('Taylor', $fluent->name); + $this->assertTrue($fluent->developer); + $this->assertEquals(25, $fluent->age); + $this->assertInstanceOf(Fluent::class, $fluent->programmer()); + } + + public function testIssetMagicMethod() + { + $array = ['name' => 'Taylor', 'age' => 25]; + $fluent = new Fluent($array); + + $this->assertTrue(isset($fluent->name)); + + unset($fluent->name); + + $this->assertFalse(isset($fluent->name)); + } + + public function testToArrayReturnsAttribute() + { + $array = ['name' => 'Taylor', 'age' => 25]; + $fluent = new Fluent($array); + + $this->assertEquals($array, $fluent->toArray()); + } + + public function testToJsonEncodesTheToArrayResult() + { + $fluent = $this->getMockBuilder(Fluent::class)->onlyMethods(['toArray'])->getMock(); + $fluent->expects($this->once())->method('toArray')->willReturn(['foo']); + $results = $fluent->toJson(); + + $this->assertJsonStringEqualsJsonString(json_encode(['foo']), $results); + } + + public function testToPrettyJson() + { + $fluent = $this->getMockBuilder(Fluent::class)->onlyMethods(['toArray'])->getMock(); + $fluent->expects($this->exactly(2))->method('toArray')->willReturn(['foo' => 'bar', 'bar' => 'foo']); + $results = $fluent->toPrettyJson(); + $expected = $fluent->toJson(JSON_PRETTY_PRINT); + + $this->assertJsonStringEqualsJsonString($expected, $results); + $this->assertSame($expected, $results); + $this->assertStringContainsString("\n", $results); + $this->assertStringContainsString(' ', $results); + } + + public function testScope() + { + $fluent = new Fluent(['user' => ['name' => 'taylor']]); + $this->assertEquals(['taylor'], $fluent->scope('user.name')->toArray()); + $this->assertEquals(['dayle'], $fluent->scope('user.age', 'dayle')->toArray()); + + $fluent = new Fluent(['products' => ['forge', 'vapour', 'spark']]); + $this->assertEquals(['forge', 'vapour', 'spark'], $fluent->scope('products')->toArray()); + $this->assertEquals(['foo', 'bar'], $fluent->scope('missing', ['foo', 'bar'])->toArray()); + + $fluent = new Fluent(['authors' => ['taylor' => ['products' => ['forge', 'vapour', 'spark']]]]); + $this->assertEquals(['forge', 'vapour', 'spark'], $fluent->scope('authors.taylor.products')->toArray()); + } + + public function testToCollection() + { + $fluent = new Fluent(['forge', 'vapour', 'spark']); + $this->assertEquals(['forge', 'vapour', 'spark'], $fluent->collect()->all()); + + $fluent = new Fluent(['authors' => ['taylor' => ['products' => ['forge', 'vapour', 'spark']]]]); + $this->assertEquals(['forge', 'vapour', 'spark'], $fluent->collect('authors.taylor.products')->all()); + } + + public function testStringMethod() + { + $fluent = new Fluent([ + 'int' => 123, + 'int_str' => '456', + 'float' => 123.456, + 'float_str' => '123.456', + 'float_zero' => 0.000, + 'float_str_zero' => '0.000', + 'str' => 'abc', + 'empty_str' => '', + 'null' => null, + ]); + $this->assertTrue($fluent->string('int') instanceof Stringable); + $this->assertTrue($fluent->string('unknown_key') instanceof Stringable); + $this->assertSame('123', $fluent->string('int')->value()); + $this->assertSame('456', $fluent->string('int_str')->value()); + $this->assertSame('123.456', $fluent->string('float')->value()); + $this->assertSame('123.456', $fluent->string('float_str')->value()); + $this->assertSame('0', $fluent->string('float_zero')->value()); + $this->assertSame('0.000', $fluent->string('float_str_zero')->value()); + $this->assertSame('', $fluent->string('empty_str')->value()); + $this->assertSame('', $fluent->string('null')->value()); + $this->assertSame('', $fluent->string('unknown_key')->value()); + } + + public function testBooleanMethod() + { + $fluent = new Fluent(['with_trashed' => 'false', 'download' => true, 'checked' => 1, 'unchecked' => '0', 'with_on' => 'on', 'with_yes' => 'yes']); + $this->assertTrue($fluent->boolean('checked')); + $this->assertTrue($fluent->boolean('download')); + $this->assertFalse($fluent->boolean('unchecked')); + $this->assertFalse($fluent->boolean('with_trashed')); + $this->assertFalse($fluent->boolean('some_undefined_key')); + $this->assertTrue($fluent->boolean('with_on')); + $this->assertTrue($fluent->boolean('with_yes')); + } + + public function testIntegerMethod() + { + $fluent = new Fluent([ + 'int' => '123', + 'raw_int' => 456, + 'zero_padded' => '078', + 'space_padded' => ' 901', + 'nan' => 'nan', + 'mixed' => '1ab', + 'underscore_notation' => '2_000', + 'null' => null, + ]); + $this->assertSame(123, $fluent->integer('int')); + $this->assertSame(456, $fluent->integer('raw_int')); + $this->assertSame(78, $fluent->integer('zero_padded')); + $this->assertSame(901, $fluent->integer('space_padded')); + $this->assertSame(0, $fluent->integer('nan')); + $this->assertSame(1, $fluent->integer('mixed')); + $this->assertSame(2, $fluent->integer('underscore_notation')); + $this->assertSame(123456, $fluent->integer('unknown_key', 123456)); + $this->assertSame(0, $fluent->integer('null')); + $this->assertSame(0, $fluent->integer('null', 123456)); + } + + public function testFloatMethod() + { + $fluent = new Fluent([ + 'float' => '1.23', + 'raw_float' => 45.6, + 'decimal_only' => '.6', + 'zero_padded' => '0.78', + 'space_padded' => ' 90.1', + 'nan' => 'nan', + 'mixed' => '1.ab', + 'scientific_notation' => '1e3', + 'null' => null, + ]); + $this->assertSame(1.23, $fluent->float('float')); + $this->assertSame(45.6, $fluent->float('raw_float')); + $this->assertSame(.6, $fluent->float('decimal_only')); + $this->assertSame(0.78, $fluent->float('zero_padded')); + $this->assertSame(90.1, $fluent->float('space_padded')); + $this->assertSame(0.0, $fluent->float('nan')); + $this->assertSame(1.0, $fluent->float('mixed')); + $this->assertSame(1e3, $fluent->float('scientific_notation')); + $this->assertSame(123.456, $fluent->float('unknown_key', 123.456)); + $this->assertSame(0.0, $fluent->float('null')); + $this->assertSame(0.0, $fluent->float('null', 123.456)); + } + + public function testArrayMethod() + { + $fluent = new Fluent(['users' => [1, 2, 3]]); + + $this->assertIsArray($fluent->array('users')); + $this->assertEquals([1, 2, 3], $fluent->array('users')); + $this->assertEquals(['users' => [1, 2, 3]], $fluent->array()); + + $fluent = new Fluent(['text-payload']); + $this->assertEquals(['text-payload'], $fluent->array()); + + $fluent = new Fluent(['email' => 'test@example.com']); + $this->assertEquals(['test@example.com'], $fluent->array('email')); + + $fluent = new Fluent([]); + $this->assertIsArray($fluent->array()); + $this->assertEmpty($fluent->array()); + + $fluent = new Fluent(['users' => [1, 2, 3], 'roles' => [4, 5, 6], 'foo' => ['bar', 'baz'], 'email' => 'test@example.com']); + $this->assertEmpty($fluent->array(['developers'])); + $this->assertNotEmpty($fluent->array(['roles'])); + $this->assertEquals(['roles' => [4, 5, 6]], $fluent->array(['roles'])); + $this->assertEquals(['users' => [1, 2, 3], 'email' => 'test@example.com'], $fluent->array(['users', 'email'])); + $this->assertEquals(['roles' => [4, 5, 6], 'foo' => ['bar', 'baz']], $fluent->array(['roles', 'foo'])); + $this->assertEquals(['users' => [1, 2, 3], 'roles' => [4, 5, 6], 'foo' => ['bar', 'baz'], 'email' => 'test@example.com'], $fluent->array()); + } + + public function testCollectMethod() + { + $fluent = new Fluent(['users' => [1, 2, 3]]); + + $this->assertInstanceOf(Collection::class, $fluent->collect('users')); + $this->assertTrue($fluent->collect('developers')->isEmpty()); + $this->assertEquals([1, 2, 3], $fluent->collect('users')->all()); + $this->assertEquals(['users' => [1, 2, 3]], $fluent->collect()->all()); + + $fluent = new Fluent(['text-payload']); + $this->assertEquals(['text-payload'], $fluent->collect()->all()); + + $fluent = new Fluent(['email' => 'test@example.com']); + $this->assertEquals(['test@example.com'], $fluent->collect('email')->all()); + + $fluent = new Fluent([]); + $this->assertInstanceOf(Collection::class, $fluent->collect()); + $this->assertTrue($fluent->collect()->isEmpty()); + + $fluent = new Fluent(['users' => [1, 2, 3], 'roles' => [4, 5, 6], 'foo' => ['bar', 'baz'], 'email' => 'test@example.com']); + $this->assertInstanceOf(Collection::class, $fluent->collect(['users'])); + $this->assertTrue($fluent->collect(['developers'])->isEmpty()); + $this->assertTrue($fluent->collect(['roles'])->isNotEmpty()); + $this->assertEquals(['roles' => [4, 5, 6]], $fluent->collect(['roles'])->all()); + $this->assertEquals(['users' => [1, 2, 3], 'email' => 'test@example.com'], $fluent->collect(['users', 'email'])->all()); + $this->assertEquals(collect(['roles' => [4, 5, 6], 'foo' => ['bar', 'baz']]), $fluent->collect(['roles', 'foo'])); + $this->assertEquals(['users' => [1, 2, 3], 'roles' => [4, 5, 6], 'foo' => ['bar', 'baz'], 'email' => 'test@example.com'], $fluent->collect()->all()); + } + + public function testDateMethod() + { + $fluent = new Fluent([ + 'as_null' => null, + 'as_invalid' => 'invalid', + + 'as_datetime' => '20-01-01 16:30:25', + 'as_format' => '1577896225', + 'as_timezone' => '20-01-01 13:30:25', + + 'as_date' => '2020-01-01', + 'as_time' => '16:30:25', + ]); + + $current = Carbon::create(2020, 1, 1, 16, 30, 25); + + $this->assertNull($fluent->date('as_null')); + $this->assertNull($fluent->date('doesnt_exists')); + + $this->assertEquals($current, $fluent->date('as_datetime')); + $this->assertEquals($current->format('Y-m-d H:i:s P'), $fluent->date('as_format', 'U')->format('Y-m-d H:i:s P')); + $this->assertEquals($current, $fluent->date('as_timezone', null, 'America/Santiago')); + + $this->assertTrue($fluent->date('as_date')->isSameDay($current)); + $this->assertTrue($fluent->date('as_time')->isSameSecond('16:30:25')); + } + + public function testDateMethodExceptionWhenValueInvalid() + { + $this->expectException(InvalidArgumentException::class); + + $fluent = new Fluent([ + 'date' => 'invalid', + ]); + + $fluent->date('date'); + } + + public function testDateMethodExceptionWhenFormatInvalid() + { + $this->expectException(InvalidArgumentException::class); + + $fluent = new Fluent([ + 'date' => '20-01-01 16:30:25', + ]); + + $fluent->date('date', 'invalid_format'); + } + + public function testEnumMethod() + { + $fluent = new Fluent([ + 'valid_enum_value' => 'A', + 'invalid_enum_value' => 'invalid', + 'empty_value_request' => '', + 'string' => [ + 'a' => '1', + 'b' => '2', + 'doesnt_exist' => '-1024', + ], + 'int' => [ + 'a' => 1, + 'b' => 2, + 'doesnt_exist' => 1024, + ], + ]); + + $this->assertNull($fluent->enum('doesnt_exist', TestEnum::class)); + + $this->assertEquals(TestStringBackedEnum::A, $fluent->enum('valid_enum_value', TestStringBackedEnum::class)); + + $this->assertNull($fluent->enum('invalid_enum_value', TestStringBackedEnum::class)); + $this->assertNull($fluent->enum('empty_value_request', TestStringBackedEnum::class)); + $this->assertNull($fluent->enum('valid_enum_value', TestEnum::class)); + + $this->assertEquals(TestBackedEnum::A, $fluent->enum('string.a', TestBackedEnum::class)); + $this->assertEquals(TestBackedEnum::B, $fluent->enum('string.b', TestBackedEnum::class)); + $this->assertNull($fluent->enum('string.doesnt_exist', TestBackedEnum::class)); + $this->assertEquals(TestBackedEnum::A, $fluent->enum('int.a', TestBackedEnum::class)); + $this->assertEquals(TestBackedEnum::B, $fluent->enum('int.b', TestBackedEnum::class)); + $this->assertNull($fluent->enum('int.doesnt_exist', TestBackedEnum::class)); + } + + public function testEnumsMethod() + { + $fluent = new Fluent([ + 'valid_enum_values' => ['A', 'B'], + 'invalid_enum_values' => ['invalid', 'invalid'], + 'empty_value_request' => [], + 'string' => [ + 'a' => ['1', '2'], + 'b' => '2', + 'doesnt_exist' => '-1024', + ], + 'int' => [ + 'a' => [1, 2], + 'b' => 2, + 'doesnt_exist' => 1024, + ], + ]); + + $this->assertEmpty($fluent->enums('doesnt_exist', TestEnum::class)); + + $this->assertEquals([TestStringBackedEnum::A, TestStringBackedEnum::B], $fluent->enums('valid_enum_values', TestStringBackedEnum::class)); + + $this->assertEmpty($fluent->enums('invalid_enum_value', TestStringBackedEnum::class)); + $this->assertEmpty($fluent->enums('empty_value_request', TestStringBackedEnum::class)); + $this->assertEmpty($fluent->enums('valid_enum_value', TestEnum::class)); + + $this->assertEquals([TestBackedEnum::A, TestBackedEnum::B], $fluent->enums('string.a', TestBackedEnum::class)); + $this->assertEquals([TestBackedEnum::B], $fluent->enums('string.b', TestBackedEnum::class)); + $this->assertEmpty($fluent->enums('string.doesnt_exist', TestBackedEnum::class)); + + $this->assertEquals([TestBackedEnum::A, TestBackedEnum::B], $fluent->enums('int.a', TestBackedEnum::class)); + $this->assertEquals([TestBackedEnum::B], $fluent->enums('int.b', TestBackedEnum::class)); + $this->assertEmpty($fluent->enums('int.doesnt_exist', TestBackedEnum::class)); + } + + public function testFill() + { + $fluent = new Fluent(['name' => 'John Doe']); + + $fluent->fill([ + 'email' => 'john.doe@example.com', + 'age' => 30, + ]); + + $this->assertEquals([ + 'name' => 'John Doe', + 'email' => 'john.doe@example.com', + 'age' => 30, + ], $fluent->getAttributes()); + } + + public function testMacroable() + { + Fluent::macro('foo', function () { + return $this->fill([ + 'foo' => 'bar', + 'baz' => 'zal', + ]); + }); + + $fluent = new Fluent([ + 'bee' => 'ser', + ]); + + $this->assertSame([ + 'bee' => 'ser', + 'foo' => 'bar', + 'baz' => 'zal', + ], $fluent->foo()->all()); + } + + public function testFluentIsIterable() + { + $fluent = new Fluent([ + 'name' => 'Taylor', + 'role' => 'admin', + ]); + + $result = []; + + foreach ($fluent as $key => $value) { + $result[$key] = $value; + } + + $this->assertSame([ + 'name' => 'Taylor', + 'role' => 'admin', + ], $result); + } + + public function testFluentIsEmpty() + { + $fluent = new Fluent(); + + $this->assertTrue($fluent->isEmpty()); + $this->assertFalse($fluent->isNotEmpty()); + } + + public function testFluentIsNotEmpty() + { + $fluent = new Fluent([ + 'name' => 'Taylor', + 'role' => 'admin', + ]); + + $this->assertTrue($fluent->isNotEmpty()); + $this->assertFalse($fluent->isEmpty()); + } +} + +class FluentArrayIteratorStub implements IteratorAggregate +{ + protected array $items = []; + + public function __construct(array $items = []) + { + $this->items = $items; + } + + public function getIterator(): ArrayIterator + { + return new ArrayIterator($this->items); + } +} diff --git a/tests/Support/SupportHtmlStringTest.php b/tests/Support/SupportHtmlStringTest.php new file mode 100644 index 000000000..cd6e0b905 --- /dev/null +++ b/tests/Support/SupportHtmlStringTest.php @@ -0,0 +1,73 @@ +foo'; + $html = new HtmlString('

foo

'); + $this->assertEquals($str, $html->toHtml()); + + // Check if HtmlString correctly preserves leading blank spaces in the HTML string + $startWithBlankSpaces = '

foo

'; + $html = new HtmlString('

foo

'); + $this->assertEquals($startWithBlankSpaces, $html->toHtml()); + + // Check if HtmlString correctly preserves trailing blank spaces in the HTML string + $endsWithBlankSpaces = '

foo

'; + $html = new HtmlString('

foo

'); + $this->assertEquals($endsWithBlankSpaces, $html->toHtml()); + + // Check if HtmlString correctly handles an empty string + $emptyHtml = new HtmlString(''); + $this->assertEquals('', $emptyHtml->toHtml()); + + // Check if HtmlString correctly converts a plain text string + $str = 'foo bar'; + $html = new HtmlString($str); + $this->assertEquals($str, $html->toHtml()); + } + + public function testToString() + { + $str = '

foo

'; + $html = new HtmlString('

foo

'); + $this->assertEquals($str, (string) $html); + + // Check if HtmlString gracefully handles a null value + $html = new HtmlString(null); + $this->assertIsString((string) $html); + } + + public function testIsEmpty(): void + { + // Check if HtmlString correctly identifies an empty string as empty + $this->assertTrue((new HtmlString(''))->isEmpty()); + + // Check if HtmlString identifies a null value as empty + $this->assertTrue((new HtmlString(null))->isEmpty()); + + // HtmlString with whitespace should not be considered as empty + $this->assertFalse((new HtmlString(' '))->isEmpty()); + + // HtmlString with content should not be considered as empty + $this->assertFalse((new HtmlString('

Hello

'))->isEmpty()); + } + + public function testIsNotEmpty() + { + $this->assertTrue((new HtmlString('foo'))->isNotEmpty()); + } +} diff --git a/tests/Support/SupportJsTest.php b/tests/Support/SupportJsTest.php new file mode 100644 index 000000000..84f7acbe8 --- /dev/null +++ b/tests/Support/SupportJsTest.php @@ -0,0 +1,204 @@ +assertSame('false', (string) Js::from(false)); + $this->assertSame('true', (string) Js::from(true)); + $this->assertSame('1', (string) Js::from(1)); + $this->assertSame('1.1', (string) Js::from(1.1)); + $this->assertSame('[]', (string) Js::from([])); + $this->assertSame('[]', (string) Js::from(collect())); + $this->assertSame('null', (string) Js::from(null)); + $this->assertSame("'Hello world'", (string) Js::from('Hello world')); + $this->assertSame("'Hèlló world'", (string) Js::from('Hèlló world')); + $this->assertEquals( + "'\\u003Cdiv class=\\u0022foo\\u0022\\u003E\\u0027quoted html\\u0027\\u003C\\/div\\u003E'", + (string) Js::from('
\'quoted html\'
') + ); + } + + public function testArrays() + { + $this->assertEquals( + "JSON.parse('[\\u0022hello\\u0022,\\u0022world\\u0022]')", + (string) Js::from(['hello', 'world']) + ); + + $this->assertEquals( + "JSON.parse('{\\u0022foo\\u0022:\\u0022hello\\u0022,\\u0022bar\\u0022:\\u0022world\\u0022}')", + (string) Js::from(['foo' => 'hello', 'bar' => 'world']) + ); + } + + public function testObjects() + { + $this->assertEquals( + "JSON.parse('{\\u0022foo\\u0022:\\u0022hello\\u0022,\\u0022bar\\u0022:\\u0022world\\u0022}')", + (string) Js::from((object) ['foo' => 'hello', 'bar' => 'world']) + ); + } + + public function testJsonSerializable() + { + // JsonSerializable should take precedence over Arrayable, so we'll + // implement both and make sure the correct data is used. + $data = new class implements JsonSerializable, Arrayable { + public $foo = 'not hello'; + + public $bar = 'not world'; + + public function jsonSerialize(): mixed + { + return ['foo' => 'hello', 'bar' => 'world']; + } + + public function toArray(): array + { + return ['foo' => 'not hello', 'bar' => 'not world']; + } + }; + + $this->assertEquals( + "JSON.parse('{\\u0022foo\\u0022:\\u0022hello\\u0022,\\u0022bar\\u0022:\\u0022world\\u0022}')", + (string) Js::from($data) + ); + } + + public function testJsonable() + { + // Jsonable should take precedence over JsonSerializable and Arrayable, so we'll + // implement all three and make sure the correct data is used. + $data = new class implements Jsonable, JsonSerializable, Arrayable { + public $foo = 'not hello'; + + public $bar = 'not world'; + + public function toJson(int $options = 0): string + { + return json_encode(['foo' => 'hello', 'bar' => 'world'], $options); + } + + public function jsonSerialize(): mixed + { + return ['foo' => 'not hello', 'bar' => 'not world']; + } + + public function toArray(): array + { + return ['foo' => 'not hello', 'bar' => 'not world']; + } + }; + + $this->assertEquals( + "JSON.parse('{\\u0022foo\\u0022:\\u0022hello\\u0022,\\u0022bar\\u0022:\\u0022world\\u0022}')", + (string) Js::from($data) + ); + } + + public function testArrayable() + { + $data = new class implements Arrayable { + public $foo = 'not hello'; + + public $bar = 'not world'; + + public function toArray(): array + { + return ['foo' => 'hello', 'bar' => 'world']; + } + }; + + $this->assertEquals( + "JSON.parse('{\\u0022foo\\u0022:\\u0022hello\\u0022,\\u0022bar\\u0022:\\u0022world\\u0022}')", + (string) Js::from($data) + ); + } + + public function testHtmlable() + { + $data = new class implements Htmlable { + public function toHtml(): string + { + return '

Hello, World!

'; + } + }; + + $this->assertEquals("'\\u003Cp\\u003EHello, World!\\u003C\\/p\\u003E'", (string) Js::from($data)); + + $data = new class implements Htmlable, Arrayable { + public function toHtml(): string + { + return '

Hello, World!

'; + } + + public function toArray(): array + { + return ['foo' => 'hello', 'bar' => 'world']; + } + }; + + $this->assertEquals( + "JSON.parse('{\\u0022foo\\u0022:\\u0022hello\\u0022,\\u0022bar\\u0022:\\u0022world\\u0022}')", + (string) Js::from($data) + ); + + $data = new class implements Htmlable, Jsonable { + public function toHtml(): string + { + return '

Hello, World!

'; + } + + public function toJson(int $options = 0): string + { + return json_encode(['foo' => 'hello', 'bar' => 'world'], $options); + } + }; + + $this->assertEquals( + "JSON.parse('{\\u0022foo\\u0022:\\u0022hello\\u0022,\\u0022bar\\u0022:\\u0022world\\u0022}')", + (string) Js::from($data) + ); + + $data = new class implements Htmlable, JsonSerializable { + public function toHtml(): string + { + return '

Hello, World!

'; + } + + public function jsonSerialize(): mixed + { + return ['foo' => 'hello', 'bar' => 'world']; + } + }; + + $this->assertEquals( + "JSON.parse('{\\u0022foo\\u0022:\\u0022hello\\u0022,\\u0022bar\\u0022:\\u0022world\\u0022}')", + (string) Js::from($data) + ); + } + + public function testBackedEnums() + { + $this->assertSame('2', (string) Js::from(IntBackedEnum::TWO)); + $this->assertSame("'Hello world'", (string) Js::from(StringBackedEnum::HELLO_WORLD)); + } +} diff --git a/tests/Support/SupportLazyCollectionIsLazyTest.php b/tests/Support/SupportLazyCollectionIsLazyTest.php new file mode 100644 index 000000000..48a92806f --- /dev/null +++ b/tests/Support/SupportLazyCollectionIsLazyTest.php @@ -0,0 +1,1801 @@ +makeGeneratorFunctionWithRecorder(); + + LazyCollection::make($closure); + + $this->assertEquals([], $recorder->all()); + } + + public function testMakeWithLazyCollectionIsLazy() + { + $this->assertDoesNotEnumerate(function ($collection) { + LazyCollection::make($collection); + }); + } + + public function testEagerEnumeratesOnce() + { + $this->assertEnumeratesOnce(function ($collection) { + $collection = $collection->eager(); + + $collection->count(); + $collection->all(); + }); + } + + public function testChunkIsLazy() + { + $this->assertDoesNotEnumerate(function ($collection) { + $collection->chunk(3); + }); + + $this->assertEnumerates(15, function ($collection) { + $collection->chunk(5)->take(3)->all(); + }); + } + + public function testChunkWhileIsLazy() + { + $collection = LazyCollection::make(['A', 'A', 'B', 'B', 'C', 'C', 'C']); + + $this->assertDoesNotEnumerateCollection($collection, function ($collection) { + $collection->chunkWhile(function ($current, $key, $chunk) { + return $current === $chunk->last(); + }); + }); + + $this->assertEnumeratesCollection($collection, 3, function ($collection) { + $collection->chunkWhile(function ($current, $key, $chunk) { + return $current === $chunk->last(); + })->first(); + }); + + $this->assertEnumeratesCollectionOnce($collection, function ($collection) { + $collection->chunkWhile(function ($current, $key, $chunk) { + return $current === $chunk->last(); + })->all(); + }); + } + + public function testCollapseIsLazy() + { + $collection = LazyCollection::make([ + [1, 2, 3], + [4, 5, 6], + [7, 8, 9], + ]); + + $this->assertDoesNotEnumerateCollection($collection, function ($collection) { + $collection->collapse(); + }); + + $this->assertEnumeratesCollection($collection, 1, function ($collection) { + $collection->collapse()->take(3)->all(); + }); + } + + public function testCombineIsLazy() + { + $firstEnumerations = 0; + $secondEnumerations = 0; + $first = $this->countEnumerations($this->make([1, 2]), $firstEnumerations); + $second = $this->countEnumerations($this->make([1, 2]), $secondEnumerations); + + $first->combine($second); + + $this->assertEnumerations(0, $firstEnumerations); + $this->assertEnumerations(0, $secondEnumerations); + + $first->combine($second)->take(1)->all(); + + $this->assertEnumerations(1, $firstEnumerations); + $this->assertEnumerations(1, $secondEnumerations); + } + + public function testConcatIsLazy() + { + $firstEnumerations = 0; + $secondEnumerations = 0; + $first = $this->countEnumerations($this->make([1, 2]), $firstEnumerations); + $second = $this->countEnumerations($this->make([1, 2]), $secondEnumerations); + + $first->concat($second); + + $this->assertEnumerations(0, $firstEnumerations); + $this->assertEnumerations(0, $secondEnumerations); + + $first->concat($second)->take(2)->all(); + + $this->assertEnumerations(2, $firstEnumerations); + $this->assertEnumerations(0, $secondEnumerations); + + $firstEnumerations = 0; + $secondEnumerations = 0; + + $first->concat($second)->take(3)->all(); + + $this->assertEnumerations(2, $firstEnumerations); + $this->assertEnumerations(1, $secondEnumerations); + } + + public function testMultiplyIsLazy() + { + $this->assertDoesNotEnumerate(function ($collection) { + $collection->multiply(2); + }); + + $this->assertEnumeratesCollectionOnce( + $this->make([1, 2, 3]), + function ($collection) { + return $collection->multiply(3)->all(); + } + ); + } + + public function testContainsIsLazy() + { + $this->assertEnumerates(5, function ($collection) { + $collection->contains(5); + }); + } + + public function testDoesntContainIsLazy() + { + $this->assertEnumerates(5, function ($collection) { + $collection->doesntContain(5); + }); + } + + public function testContainsStrictIsLazy() + { + $this->assertEnumerates(5, function ($collection) { + $collection->containsStrict(5); + }); + } + + public function testCountEnumeratesOnce() + { + $this->assertEnumeratesOnce(function ($collection) { + $collection->count(); + }); + } + + public function testCountByIsLazy() + { + $this->assertDoesNotEnumerate(function ($collection) { + $collection->countBy(); + }); + + $this->assertEnumeratesCollectionOnce( + $this->make([1, 2, 2, 3]), + function ($collection) { + $collection->countBy()->all(); + } + ); + } + + public function testCrossJoinIsLazy() + { + $this->assertDoesNotEnumerate(function ($collection) { + $collection->crossJoin([1]); + }); + + $this->assertEnumeratesOnce(function ($collection) { + $collection->crossJoin([1], [2])->all(); + }); + } + + public function testDiffIsLazy() + { + $this->assertDoesNotEnumerate(function ($collection) { + $collection->diff([1, 2]); + }); + + $this->assertEnumeratesOnce(function ($collection) { + $collection->diff([1, 2])->all(); + }); + } + + public function testDiffAssocIsLazy() + { + $this->assertDoesNotEnumerate(function ($collection) { + $collection->diffAssoc([1, 2]); + }); + + $this->assertEnumeratesOnce(function ($collection) { + $collection->diffAssoc([1, 2])->all(); + }); + } + + public function testDiffAssocUsingIsLazy() + { + $this->assertDoesNotEnumerate(function ($collection) { + $collection->diffAssocUsing([1, 2], 'strcasecmp'); + }); + + $this->assertEnumeratesOnce(function ($collection) { + $collection->diffAssocUsing([1, 2], 'strcasecmp')->all(); + }); + } + + public function testDiffKeysIsLazy() + { + $this->assertDoesNotEnumerate(function ($collection) { + $collection->diffKeys([1, 2]); + }); + + $this->assertEnumeratesOnce(function ($collection) { + $collection->diffKeys([1, 2])->all(); + }); + } + + public function testDiffKeysUsingIsLazy() + { + $this->assertDoesNotEnumerate(function ($collection) { + $collection->diffKeysUsing([1, 2], 'strcasecmp'); + }); + + $this->assertEnumeratesOnce(function ($collection) { + $collection->diffKeysUsing([1, 2], 'strcasecmp')->all(); + }); + } + + public function testDiffUsingIsLazy() + { + $this->assertDoesNotEnumerate(function ($collection) { + $collection->diffUsing([1, 2], 'strcasecmp'); + }); + + $this->assertEnumeratesOnce(function ($collection) { + $collection->diffUsing([1, 2], 'strcasecmp')->all(); + }); + } + + public function testDuplicatesIsLazy() + { + $this->assertDoesNotEnumerate(function ($collection) { + $collection->duplicates(); + }); + + $this->assertEnumeratesOnce(function ($collection) { + $collection->duplicates()->all(); + }); + } + + public function testDuplicatesStrictIsLazy() + { + $this->assertDoesNotEnumerate(function ($collection) { + $collection->duplicatesStrict(); + }); + + $this->assertEnumeratesOnce(function ($collection) { + $collection->duplicatesStrict()->all(); + }); + } + + public function testEachIsLazy() + { + $this->assertEnumerates(5, function ($collection) { + $collection->each(function ($value, $key) { + if ($value == 5) { + return false; + } + }); + }); + + $this->assertEnumeratesOnce(function ($collection) { + $collection->each(function ($value, $key) { + // Silence is golden! + }); + }); + + $this->assertEnumerates(5, function ($collection) { + foreach ($collection as $key => $value) { + if ($value == 5) { + return false; + } + } + }); + + $this->assertEnumeratesOnce(function ($collection) { + foreach ($collection as $key => $value) { + // Silence is golden! + } + }); + } + + public function testEachSpreadIsLazy() + { + $data = $this->make([[1, 2], [3, 4], [5, 6], [7, 8]]); + + $this->assertEnumeratesCollection($data, 2, function ($collection) { + $collection->eachSpread(function ($first, $second, $key) { + if ($first == 3) { + return false; + } + }); + }); + + $this->assertEnumeratesCollectionOnce($data, function ($collection) { + $collection->eachSpread(function ($first, $second, $key) { + // Silence is golden! + }); + }); + } + + public function testEveryIsLazy() + { + $this->assertEnumerates(2, function ($collection) { + $collection->every(function ($value) { + return $value == 1; + }); + }); + + $data = $this->make([['a' => 1], ['a' => 2], ['a' => 3]]); + + $this->assertEnumeratesCollection($data, 2, function ($collection) { + $collection->every('a', 1); + }); + } + + public function testExceptIsLazy() + { + $this->assertDoesNotEnumerate(function ($collection) { + $collection->except([1, 2]); + }); + + $this->assertEnumeratesOnce(function ($collection) { + $collection->except([1, 2])->all(); + }); + } + + public function testFilterIsLazy() + { + $this->assertDoesNotEnumerate(function ($collection) { + $collection->filter(function ($value) { + return $value > 5; + }); + }); + + $this->assertEnumeratesOnce(function ($collection) { + $collection->filter(function ($value) { + return $value > 5; + })->all(); + }); + } + + public function testFirstIsLazy() + { + $this->assertEnumerates(1, function ($collection) { + $collection->first(); + }); + + $this->assertEnumerates(2, function ($collection) { + $collection->first(function ($value) { + return $value == 2; + }); + }); + } + + public function testFirstWhereIsLazy() + { + $data = $this->make([['a' => 1], ['a' => 2], ['a' => 3]]); + + $this->assertEnumeratesCollection($data, 2, function ($collection) { + $collection->firstWhere('a', 2); + }); + } + + public function testFlatMapIsLazy() + { + $data = $this->make([1, 2, 3, 4, 5]); + + $this->assertDoesNotEnumerateCollection($data, function ($collection) { + $collection->flatMap(function ($values) { + return array_sum($values); + }); + }); + + $this->assertEnumeratesCollection($data, 3, function ($collection) { + $collection->flatMap(function ($value) { + return range(1, $value); + })->take(5)->all(); + }); + } + + public function testFlattenIsLazy() + { + $data = $this->make([1, [2, 3], [4, 5], [6, 7]]); + + $this->assertDoesNotEnumerateCollection($data, function ($collection) { + $collection->flatten(); + }); + + $this->assertEnumeratesCollection($data, 2, function ($collection) { + $collection->flatten()->take(3)->all(); + }); + } + + public function testFlipIsLazy() + { + $this->assertDoesNotEnumerate(function ($collection) { + $collection->flip(); + }); + + $this->assertEnumerates(2, function ($collection) { + $collection->flip()->take(2)->all(); + }); + } + + public function testForPageIsLazy() + { + $this->assertDoesNotEnumerate(function ($collection) { + $collection->forPage(2, 10); + }); + + $this->assertEnumerates(20, function ($collection) { + $collection->forPage(2, 10)->all(); + }); + } + + public function testGetIsLazy() + { + $this->assertEnumerates(5, function ($collection) { + $collection->get(4); + }); + } + + public function testGroupByIsLazy() + { + $this->assertDoesNotEnumerate(function ($collection) { + $collection->groupBy(function ($value) { + return $value % 5; + }); + }); + + $this->assertEnumeratesOnce(function ($collection) { + $collection->groupBy(function ($value) { + return $value % 5; + })->all(); + }); + } + + public function testHasIsLazy() + { + $this->assertEnumerates(5, function ($collection) { + $collection->has(4); + }); + + $this->assertEnumeratesOnce(function ($collection) { + $collection->has('non-existent'); + }); + } + + public function testHasAnyIsLazy() + { + $this->assertEnumerates(5, function ($collection) { + $collection->hasAny(4); + }); + + $this->assertEnumerates(2, function ($collection) { + $collection->hasAny([1, 4]); + }); + + $this->assertEnumeratesOnce(function ($collection) { + $collection->hasAny(['non', 'existent']); + }); + } + + public function testImplodeEnumeratesOnce() + { + $this->assertEnumeratesOnce(function ($collection) { + $collection->implode(', '); + }); + } + + public function testIntersectIsLazy() + { + $this->assertDoesNotEnumerate(function ($collection) { + $collection->intersect([1, 2, 3]); + }); + + $this->assertEnumeratesOnce(function ($collection) { + $collection->intersect([1, 2, 3])->all(); + }); + } + + public function testIntersectUsingIsLazy() + { + $this->assertDoesNotEnumerate(function ($collection) { + $collection->intersectUsing([1, 2], 'strcasecmp'); + }); + + $this->assertEnumeratesOnce(function ($collection) { + $collection->intersectUsing([1, 2], 'strcasecmp')->all(); + }); + } + + public function testIntersectAssocIsLazy() + { + $this->assertDoesNotEnumerate(function ($collection) { + $collection->intersectAssoc([1, 2]); + }); + + $this->assertEnumeratesOnce(function ($collection) { + $collection->intersectAssoc([1, 2])->all(); + }); + } + + public function testIntersectAssocUsingIsLazy() + { + $this->assertDoesNotEnumerate(function ($collection) { + $collection->intersectAssocUsing([1, 2], 'strcasecmp'); + }); + + $this->assertEnumeratesOnce(function ($collection) { + $collection->intersectAssocUsing([1, 2], 'strcasecmp')->all(); + }); + } + + public function testIntersectByKeysIsLazy() + { + $this->assertDoesNotEnumerate(function ($collection) { + $collection->intersectByKeys([1, 2, 3]); + }); + + $this->assertEnumeratesOnce(function ($collection) { + $collection->intersectByKeys([1, 2, 3])->all(); + }); + } + + public function testIsEmptyIsLazy() + { + $this->assertEnumerates(1, function ($collection) { + $collection->isEmpty(); + }); + } + + public function testIsNotEmptyIsLazy() + { + $this->assertEnumerates(1, function ($collection) { + $collection->isNotEmpty(); + }); + } + + public function testContainsOneItemIsLazy() + { + $this->assertEnumerates(2, function ($collection) { + $collection->containsOneItem(); + }); + } + + public function testHasSoleIsLazy() + { + $this->assertEnumerates(2, function ($collection) { + $collection->hasSole(); + }); + + $this->assertEnumerates(2, function ($collection) { + $collection->hasSole(fn ($item) => $item <= 2); + }); + + $this->assertEnumeratesCollection( + LazyCollection::times(10, fn ($i) => ['age' => $i]), + 2, + fn ($collection) => $collection->hasSole('age', '<=', 2), + ); + } + + public function testHasManyIsLazy() + { + $this->assertEnumerates(2, function ($collection) { + $collection->hasMany(); + }); + + $this->assertEnumerates(2, function ($collection) { + $collection->hasMany(fn ($item) => $item <= 2); + }); + + $this->assertEnumeratesCollection( + LazyCollection::times(10, fn ($i) => ['age' => $i]), + 2, + fn ($collection) => $collection->hasMany('age', '<=', 2), + ); + } + + public function testJoinIsLazy() + { + $this->assertEnumeratesOnce(function ($collection) { + $collection->join(', ', ' and '); + }); + } + + public function testJsonSerializeEnumeratesOnce() + { + $this->assertEnumeratesOnce(function ($collection) { + $collection->jsonSerialize(); + }); + } + + public function testKeyByIsLazy() + { + $this->assertDoesNotEnumerate(function ($collection) { + $collection->keyBy(function ($value) { + return "key-of-{$value}"; + }); + }); + + $this->assertEnumerates(2, function ($collection) { + $collection->keyBy(function ($value) { + return "key-of-{$value}"; + })->take(2)->all(); + }); + } + + public function testKeysIsLazy() + { + $this->assertDoesNotEnumerate(function ($collection) { + $collection->keys(); + }); + + $this->assertEnumerates(2, function ($collection) { + $collection->keys()->take(2)->all(); + }); + } + + public function testLastEnumeratesOnce() + { + $this->assertEnumeratesOnce(function ($collection) { + $collection->last(); + }); + } + + public function testMapIsLazy() + { + $this->assertDoesNotEnumerate(function ($collection) { + $collection->map(function ($value) { + return $value + 1; + }); + }); + + $this->assertEnumerates(2, function ($collection) { + $collection->map(function ($value) { + return $value + 1; + })->take(2)->all(); + }); + } + + public function testMapIntoIsLazy() + { + $this->assertDoesNotEnumerate(function ($collection) { + $collection->mapInto(stdClass::class); + }); + + $this->assertEnumerates(2, function ($collection) { + $collection->mapInto(stdClass::class)->take(2)->all(); + }); + } + + public function testMapSpreadIsLazy() + { + $data = $this->make([[1, 2], [3, 4], [5, 6], [7, 8]]); + + $this->assertDoesNotEnumerateCollection($data, function ($collection) { + $collection->mapSpread(function ($first, $second, $key) { + return $first + $second + $key; + }); + }); + + $this->assertEnumeratesCollection($data, 2, function ($collection) { + $collection->mapSpread(function ($first, $second, $key) { + return $first + $second + $key; + })->take(2)->all(); + }); + } + + public function testMapToDictionaryIsLazy() + { + $this->assertDoesNotEnumerate(function ($collection) { + $collection->mapToDictionary(function ($value, $key) { + return [$value => $key]; + }); + }); + + $this->assertEnumeratesOnce(function ($collection) { + $collection->mapToDictionary(function ($value, $key) { + return [$value => $key]; + })->all(); + }); + } + + public function testMapToGroupsIsLazy() + { + $this->assertDoesNotEnumerate(function ($collection) { + $collection->mapToGroups(function ($value, $key) { + return [$value => $key]; + }); + }); + + $this->assertEnumeratesOnce(function ($collection) { + $collection->mapToGroups(function ($value, $key) { + return [$value => $key]; + })->all(); + }); + } + + public function testMapWithKeysIsLazy() + { + $this->assertDoesNotEnumerate(function ($collection) { + $collection->mapWithKeys(function ($value, $key) { + return [$value => $key]; + }); + }); + + $this->assertEnumerates(2, function ($collection) { + $collection->mapWithKeys(function ($value, $key) { + return [$value => $key]; + })->take(2)->all(); + }); + } + + public function testMaxEnumeratesOnce() + { + $this->assertEnumeratesOnce(function ($collection) { + $collection->max(); + }); + } + + public function testMedianEnumeratesOnce() + { + $this->assertEnumeratesOnce(function ($collection) { + $collection->median(); + }); + } + + public function testAvgEnumeratesOnce() + { + $this->assertEnumeratesOnce(function ($collection) { + $collection->avg(); + }); + } + + public function testMergeIsLazy() + { + $this->assertDoesNotEnumerate(function ($collection) { + $collection->merge([1, 2, 3]); + }); + + $this->assertEnumeratesOnce(function ($collection) { + $collection->merge([1, 2, 3])->all(); + }); + } + + public function testMergeRecursiveIsLazy() + { + $this->assertDoesNotEnumerate(function ($collection) { + $collection->mergeRecursive([1, 2, 3]); + }); + + $this->assertEnumeratesOnce(function ($collection) { + $collection->mergeRecursive([1, 2, 3])->all(); + }); + } + + public function testMinEnumeratesOnce() + { + $this->assertEnumeratesOnce(function ($collection) { + $collection->min(); + }); + } + + public function testModeEnumeratesOnce() + { + $this->assertEnumeratesOnce(function ($collection) { + $collection->mode(); + }); + } + + public function testNthIsLazy() + { + $this->assertDoesNotEnumerate(function ($collection) { + $collection->nth(5); + }); + + $this->assertEnumerates(11, function ($collection) { + $collection->nth(5)->take(3)->all(); + }); + } + + public function testOnlyIsLazy() + { + $this->assertDoesNotEnumerate(function ($collection) { + $collection->only(5, 6, 7); + }); + + $this->assertEnumerates(8, function ($collection) { + $collection->only(5, 6, 7)->all(); + }); + } + + public function testPadIsLazy() + { + $this->assertDoesNotEnumerate(function ($collection) { + $collection->pad(200, null); + $collection->pad(-200, null); + }); + + $this->assertEnumeratesOnce(function ($collection) { + $collection->pad(20, null)->all(); + }); + + $this->assertEnumeratesOnce(function ($collection) { + $collection->pad(-20, null)->all(); + }); + } + + public function testPartitionEnumeratesOnce() + { + $this->assertEnumeratesOnce(function ($collection) { + $collection->partition(function ($value) { + return $value > 10; + }); + }); + } + + public function testPipeDoesNotEnumerate() + { + $this->assertDoesNotEnumerate(function ($collection) { + $collection->pipe(function () { + // Silence is golden! + }); + }); + } + + public function testPluckIsLazy() + { + $data = $this->make([['a' => 1], ['a' => 2], ['a' => 3], ['a' => 4]]); + + $this->assertDoesNotEnumerateCollection($data, function ($collection) { + $collection->pluck('a'); + }); + + $this->assertEnumeratesCollectionOnce($data, function ($collection) { + $collection->pluck('a')->all(); + }); + } + + public function testRandomEnumeratesOnce() + { + $this->assertEnumeratesOnce(function ($collection) { + $collection->random(); + }); + + $this->assertEnumeratesOnce(function ($collection) { + $collection->random(5); + }); + } + + public function testRangeIsLazy() + { + $data = LazyCollection::range(10, 1000); + + $this->assertDoesNotEnumerateCollection($data, function ($collection) { + $collection->take(50); + }); + + $this->assertEnumeratesCollection($data, 5, function ($collection) { + $collection->take(5)->all(); + }); + } + + public function testReduceIsLazy() + { + $this->assertEnumerates(1, function ($collection) { + $this->rescue(function () use ($collection) { + $collection->reduce(function ($total, $value) { + throw new Exception('Short-circuit'); + }, 0); + }); + }); + + $this->assertEnumeratesOnce(function ($collection) { + $collection->reduce(function ($total, $value) { + return $total + $value; + }, 0); + }); + } + + public function testReduceSpreadIsLazy() + { + $this->assertEnumerates(1, function ($collection) { + $this->rescue(function () use ($collection) { + $collection->reduceSpread(function ($one, $two, $value) { + throw new Exception('Short-circuit'); + }, 0, 0); + }); + }); + + $this->assertEnumeratesOnce(function ($collection) { + $collection->reduceSpread(function ($total, $max, $value) { + return [$total + $value, max($max, $value)]; + }, 0, 0); + }); + } + + public function testRejectIsLazy() + { + $this->assertDoesNotEnumerate(function ($collection) { + $collection->reject(function ($value) { + return $value % 2; + }); + }); + + $this->assertEnumeratesOnce(function ($collection) { + $collection->reject(function ($value) { + return $value % 2; + })->all(); + }); + } + + public function testRememberIsLazy() + { + $this->assertDoesNotEnumerate(function ($collection) { + $collection->remember(); + }); + + $this->assertEnumeratesOnce(function ($collection) { + $collection = $collection->remember(); + + $collection->all(); + $collection->all(); + }); + + $this->assertEnumerates(5, function ($collection) { + $collection = $collection->remember(); + + $collection->take(5)->all(); + $collection->take(5)->all(); + }); + } + + public function testReplaceIsLazy() + { + $this->assertDoesNotEnumerate(function ($collection) { + $collection->replace([5 => 'a', 10 => 'b']); + }); + + $this->assertEnumeratesOnce(function ($collection) { + $collection->replace([5 => 'a', 10 => 'b'])->all(); + }); + } + + public function testReplaceRecursiveIsLazy() + { + $this->assertDoesNotEnumerate(function ($collection) { + $collection->replaceRecursive([5 => 'a', 10 => 'b']); + }); + + $this->assertEnumeratesOnce(function ($collection) { + $collection->replaceRecursive([5 => 'a', 10 => 'b'])->all(); + }); + } + + public function testReverseIsLazy() + { + $this->assertDoesNotEnumerate(function ($collection) { + $collection->reverse(); + }); + + $this->assertEnumeratesOnce(function ($collection) { + $collection->reverse()->all(); + }); + } + + public function testSearchIsLazy() + { + $this->assertEnumerates(5, function ($collection) { + $collection->search(5); + }); + + $this->assertEnumeratesOnce(function ($collection) { + $collection->search('missing'); + }); + } + + public function testShuffleIsLazy() + { + $this->assertDoesNotEnumerate(function ($collection) { + $collection->shuffle(); + }); + + $this->assertEnumeratesOnce(function ($collection) { + $collection->shuffle()->all(); + }); + } + + public function testSlidingIsLazy() + { + $this->assertDoesNotEnumerate(function ($collection) { + $collection->sliding(); + }); + + $this->assertEnumerates(2, function ($collection) { + $collection->sliding()->take(1)->all(); + }); + + $this->assertEnumerates(3, function ($collection) { + $collection->sliding()->take(2)->all(); + }); + + $this->assertEnumerates(13, function ($collection) { + $collection->sliding(3, 5)->take(3)->all(); + }); + + $this->assertEnumeratesOnce(function ($collection) { + $collection->sliding()->all(); + }); + } + + public function testSkipIsLazy() + { + $this->assertDoesNotEnumerate(function ($collection) { + $collection->skip(10); + }); + + $this->assertEnumerates(12, function ($collection) { + $collection->skip(10)->take(2)->all(); + }); + } + + public function testSkipUntilIsLazy() + { + $this->assertDoesNotEnumerate(function ($collection) { + $collection->skipUntil(INF); + }); + + $this->assertEnumerates(10, function ($collection) { + $collection->skipUntil(10)->first(); + }); + + $this->assertEnumerates(10, function ($collection) { + $collection->skipUntil(function ($item) { + return $item === 10; + })->first(); + }); + } + + public function testSkipWhileIsLazy() + { + $this->assertDoesNotEnumerate(function ($collection) { + $collection->skipWhile(1); + }); + + $this->assertEnumerates(2, function ($collection) { + $collection->skipWhile(1)->first(); + }); + + $this->assertEnumerates(10, function ($collection) { + $collection->skipWhile(function ($item) { + return $item < 10; + })->first(); + }); + } + + public function testSliceIsLazy() + { + $this->assertDoesNotEnumerate(function ($collection) { + $collection->slice(2); + $collection->slice(2, 2); + $collection->slice(-2, 2); + }); + + $this->assertEnumerates(4, function ($collection) { + $collection->slice(2)->take(2)->all(); + }); + + $this->assertEnumerates(4, function ($collection) { + $collection->slice(2, 2)->all(); + }); + + $this->assertEnumeratesOnce(function ($collection) { + $collection->slice(-2, 2)->all(); + }); + } + + public function testFindFirstOrFailIsLazy() + { + $this->assertEnumerates(1, function ($collection) { + $collection->firstOrFail(); + }); + + $this->assertEnumerates(1, function ($collection) { + $collection->firstOrFail(function ($item) { + return $item === 1; + }); + }); + + $this->assertEnumerates(100, function ($collection) { + try { + $collection->firstOrFail(function ($item) { + return $item === 101; + }); + } catch (ItemNotFoundException) { + } + }); + + $this->assertEnumerates(2, function ($collection) { + $collection->firstOrFail(function ($item) { + return $item % 2 === 0; + }); + }); + } + + public function testSomeIsLazy() + { + $this->assertEnumerates(5, function ($collection) { + $collection->some(function ($value) { + return $value == 5; + }); + }); + + $this->assertEnumeratesOnce(function ($collection) { + $collection->some(function ($value) { + return false; + }); + }); + } + + public function testSoleIsLazy() + { + $this->assertEnumerates(2, function ($collection) { + try { + $collection->sole(); + } catch (MultipleItemsFoundException) { + } + }); + + $this->assertEnumeratesOnce(function ($collection) { + $collection->sole(function ($item) { + return $item === 1; + }); + }); + + $this->assertEnumerates(4, function ($collection) { + try { + $collection->sole(function ($item) { + return $item % 2 === 0; + }); + } catch (MultipleItemsFoundException) { + } + }); + } + + public function testSortIsLazy() + { + $this->assertDoesNotEnumerate(function ($collection) { + $collection->sort(); + }); + + $this->assertEnumeratesOnce(function ($collection) { + $collection->sort()->all(); + }); + } + + public function testSortDescIsLazy() + { + $this->assertDoesNotEnumerate(function ($collection) { + $collection->sortDesc(); + }); + + $this->assertEnumeratesOnce(function ($collection) { + $collection->sortDesc()->all(); + }); + } + + public function testSortByIsLazy() + { + $this->assertDoesNotEnumerate(function ($collection) { + $collection->sortBy(function ($value) { + return $value; + }); + }); + + $this->assertEnumeratesOnce(function ($collection) { + $collection->sortBy(function ($value) { + return $value; + })->all(); + }); + } + + public function testSortByDescIsLazy() + { + $this->assertDoesNotEnumerate(function ($collection) { + $collection->sortByDesc(function ($value) { + return $value; + }); + }); + + $this->assertEnumeratesOnce(function ($collection) { + $collection->sortByDesc(function ($value) { + return $value; + })->all(); + }); + } + + public function testSortKeysIsLazy() + { + $this->assertDoesNotEnumerate(function ($collection) { + $collection->sortKeys(); + }); + + $this->assertEnumeratesOnce(function ($collection) { + $collection->sortKeys()->all(); + }); + } + + public function testSortKeysDescIsLazy() + { + $this->assertDoesNotEnumerate(function ($collection) { + $collection->sortKeysDesc(); + }); + + $this->assertEnumeratesOnce(function ($collection) { + $collection->sortKeysDesc()->all(); + }); + } + + public function testSplitIsLazy() + { + $this->assertDoesNotEnumerate(function ($collection) { + $collection->split(4); + }); + + $this->assertEnumeratesOnce(function ($collection) { + $collection->split(4)->all(); + }); + } + + public function testSumEnumeratesOnce() + { + $this->assertEnumeratesOnce(function ($collection) { + $collection->sum(); + }); + } + + public function testTakeIsLazy() + { + $this->assertDoesNotEnumerate(function ($collection) { + $collection->take(10); + }); + + $this->assertEnumerates(10, function ($collection) { + $collection->take(10)->all(); + }); + } + + public function testTakeUntilIsLazy() + { + $this->assertDoesNotEnumerate(function ($collection) { + $collection->takeUntil(INF); + }); + + $this->assertEnumerates(10, function ($collection) { + $collection->takeUntil(10)->all(); + }); + + $this->assertEnumerates(10, function ($collection) { + $collection->takeUntil(function ($item) { + return $item === 10; + })->all(); + }); + } + + public function testTakeUntilTimeoutIsLazy() + { + tap(m::mock(LazyCollection::class . '[now]')->times(100), function ($mock) { + $this->assertDoesNotEnumerateCollection($mock, function ($mock) { + $timeout = Carbon::now(); + + $results = $mock + ->tap(function ($collection) use ($mock, $timeout) { + tap($collection) + ->mockery_init($mock->mockery_getContainer()) + ->shouldAllowMockingProtectedMethods() + ->shouldReceive('now') + ->times(1) + ->andReturn( + $timeout->getTimestamp() + ); + }) + ->takeUntilTimeout($timeout) + ->all(); + }); + }); + + tap(m::mock(LazyCollection::class . '[now]')->times(100), function ($mock) { + $this->assertEnumeratesCollection($mock, 1, function ($mock) { + $timeout = Carbon::now(); + + $results = $mock + ->tap(function ($collection) use ($mock, $timeout) { + tap($collection) + ->mockery_init($mock->mockery_getContainer()) + ->shouldAllowMockingProtectedMethods() + ->shouldReceive('now') + ->times(2) + ->andReturn( + (clone $timeout)->sub(1, 'minute')->getTimestamp(), + $timeout->getTimestamp() + ); + }) + ->takeUntilTimeout($timeout) + ->all(); + }); + }); + + tap(m::mock(LazyCollection::class . '[now]')->times(100), function ($mock) { + $this->assertEnumeratesCollectionOnce($mock, function ($mock) { + $timeout = Carbon::now(); + + $results = $mock + ->tap(function ($collection) use ($mock, $timeout) { + tap($collection) + ->mockery_init($mock->mockery_getContainer()) + ->shouldAllowMockingProtectedMethods() + ->shouldReceive('now') + ->times(100) + ->andReturn( + (clone $timeout)->sub(1, 'minute')->getTimestamp() + ); + }) + ->takeUntilTimeout($timeout) + ->all(); + }); + }); + } + + public function testTakeWhileIsLazy() + { + $this->assertDoesNotEnumerate(function ($collection) { + $collection->takeWhile(0); + }); + + $this->assertEnumerates(1, function ($collection) { + $collection->takeWhile(0)->all(); + }); + + $this->assertEnumerates(10, function ($collection) { + $collection->takeWhile(function ($item) { + return $item < 10; + })->all(); + }); + } + + public function testTapDoesNotEnumerate() + { + $this->assertDoesNotEnumerate(function ($collection) { + $collection->tap(function ($collection) { + // Silence is golden! + }); + }); + } + + public function testTapEachIsLazy() + { + $this->assertDoesNotEnumerate(function ($collection) { + $collection->tapEach(function ($value) { + // Silence is golden! + }); + }); + + $this->assertEnumeratesOnce(function ($collection) { + $collection->tapEach(function ($value) { + // Silence is golden! + })->all(); + }); + } + + public function testThrottleIsLazy() + { + Sleep::fake(); + + $this->assertDoesNotEnumerate(function ($collection) { + $collection->throttle(10); + }); + + $this->assertEnumerates(5, function ($collection) { + $collection->throttle(10)->take(5)->all(); + }); + + $this->assertEnumeratesOnce(function ($collection) { + $collection->throttle(10)->all(); + }); + + Sleep::fake(false); + } + + public function testTimesIsLazy() + { + $data = LazyCollection::times(INF); + + $this->assertEnumeratesCollection($data, 2, function ($collection) { + $collection->take(2)->all(); + }); + } + + public function testToArrayEnumeratesOnce() + { + $this->assertEnumeratesOnce(function ($collection) { + $collection->toArray(); + }); + } + + public function testToJsonEnumeratesOnce() + { + $this->assertEnumeratesOnce(function ($collection) { + $collection->toJson(); + }); + } + + public function testUnionIsLazy() + { + $this->assertDoesNotEnumerate(function ($collection) { + $collection->union([4, 5, 6]); + }); + + $this->assertEnumeratesOnce(function ($collection) { + $collection->union([4, 5, 6])->all(); + }); + } + + public function testUniqueIsLazy() + { + $this->assertDoesNotEnumerate(function ($collection) { + $collection->unique(); + }); + + $this->assertEnumeratesOnce(function ($collection) { + $collection->unique()->all(); + }); + } + + public function testUniqueStrictIsLazy() + { + $this->assertDoesNotEnumerate(function ($collection) { + $collection->uniqueStrict(); + }); + + $this->assertEnumeratesOnce(function ($collection) { + $collection->uniqueStrict()->all(); + }); + } + + public function testUnlessDoesNotEnumerate() + { + $this->assertDoesNotEnumerate(function ($collection) { + $collection->unless(true, function ($collection) { + // Silence is golden! + }); + + $collection->unless(false, function ($collection) { + // Silence is golden! + }); + }); + } + + public function testUnlessEmptyIsLazy() + { + $this->assertEnumerates(1, function ($collection) { + $collection->unlessEmpty(function ($collection) { + // Silence is golden! + }); + }); + } + + public function testUnlessNotEmptyIsLazy() + { + $this->assertEnumerates(1, function ($collection) { + $collection->unlessNotEmpty(function ($collection) { + // Silence is golden! + }); + }); + } + + public function testUnwrapEnumeratesOne() + { + $this->assertEnumeratesOnce(function ($collection) { + LazyCollection::unwrap($collection); + }); + } + + public function testValuesIsLazy() + { + $this->assertDoesNotEnumerate(function ($collection) { + $collection->values(); + }); + + $this->assertEnumerates(2, function ($collection) { + $collection->values()->take(2)->all(); + }); + } + + public function testWhenDoesNotEnumerate() + { + $this->assertDoesNotEnumerate(function ($collection) { + $collection->when(true, function ($collection) { + // Silence is golden! + }); + + $collection->when(false, function ($collection) { + // Silence is golden! + }); + }); + } + + public function testWhenEmptyIsLazy() + { + $this->assertEnumerates(1, function ($collection) { + $collection->whenEmpty(function ($collection) { + // Silence is golden! + }); + }); + } + + public function testWhenNotEmptyIsLazy() + { + $this->assertEnumerates(1, function ($collection) { + $collection->whenNotEmpty(function ($collection) { + // Silence is golden! + }); + }); + } + + public function testWhereIsLazy() + { + $data = $this->make([['a' => 1], ['a' => 2], ['a' => 3], ['a' => 4]]); + + $this->assertDoesNotEnumerateCollection($data, function ($collection) { + $collection->where('a', '<', 3); + }); + + $this->assertEnumeratesCollection($data, 1, function ($collection) { + $collection->where('a', '<', 3)->take(1)->all(); + }); + } + + public function testWhereBetweenIsLazy() + { + $data = $this->make([['a' => 1], ['a' => 2], ['a' => 3], ['a' => 4]]); + + $this->assertDoesNotEnumerateCollection($data, function ($collection) { + $collection->whereBetween('a', [2, 4]); + }); + + $this->assertEnumeratesCollection($data, 2, function ($collection) { + $collection->whereBetween('a', [2, 4])->take(1)->all(); + }); + } + + public function testWhereInIsLazy() + { + $data = $this->make([['a' => 1], ['a' => 2], ['a' => 3], ['a' => 4]]); + + $this->assertDoesNotEnumerateCollection($data, function ($collection) { + $collection->whereIn('a', [2, 3]); + }); + + $this->assertEnumeratesCollection($data, 2, function ($collection) { + $collection->whereIn('a', [2, 3])->take(1)->all(); + }); + } + + public function testWhereInstanceOfIsLazy() + { + $data = $this->make(['a' => 0])->concat( + $this->make([['a' => 1], ['a' => 2], ['a' => 3], ['a' => 4]]) + ->mapInto(stdClass::class) + ); + + $this->assertDoesNotEnumerateCollection($data, function ($collection) { + $collection->whereInstanceOf(stdClass::class); + }); + + $this->assertEnumeratesCollection($data, 2, function ($collection) { + $collection->whereInstanceOf(stdClass::class)->take(1)->all(); + }); + } + + public function testWhereInStrictIsLazy() + { + $data = $this->make([['a' => 1], ['a' => 2], ['a' => 3], ['a' => 4]]); + + $this->assertDoesNotEnumerateCollection($data, function ($collection) { + $collection->whereInStrict('a', ['2', 3]); + }); + + $this->assertEnumeratesCollection($data, 3, function ($collection) { + $collection->whereInStrict('a', ['2', 3])->take(1)->all(); + }); + } + + public function testWhereNotBetweenIsLazy() + { + $data = $this->make([['a' => 1], ['a' => 2], ['a' => 3], ['a' => 4]]); + + $this->assertDoesNotEnumerateCollection($data, function ($collection) { + $collection->whereNotBetween('a', [1, 2]); + }); + + $this->assertEnumeratesCollection($data, 3, function ($collection) { + $collection->whereNotBetween('a', [1, 2])->take(1)->all(); + }); + } + + public function testWhereNotInIsLazy() + { + $data = $this->make([['a' => 1], ['a' => 2], ['a' => 3], ['a' => 4]]); + + $this->assertDoesNotEnumerateCollection($data, function ($collection) { + $collection->whereNotIn('a', [1, 2]); + }); + + $this->assertEnumeratesCollection($data, 3, function ($collection) { + $collection->whereNotIn('a', [1, 2])->take(1)->all(); + }); + } + + public function testWhereNotInStrictIsLazy() + { + $data = $this->make([['a' => 1], ['a' => 2], ['a' => 3], ['a' => 4]]); + + $this->assertDoesNotEnumerateCollection($data, function ($collection) { + $collection->whereNotInStrict('a', ['1', 2]); + }); + + $this->assertEnumeratesCollection($data, 2, function ($collection) { + $collection->whereNotInStrict('a', [1, '2'])->take(1)->all(); + }); + } + + public function testWhereNotNullIsLazy() + { + $data = $this->make([['a' => 1], ['a' => null], ['a' => 2], ['a' => 3]]); + + $this->assertDoesNotEnumerateCollection($data, function ($collection) { + $collection->whereNotNull('a'); + }); + + $this->assertEnumeratesCollectionOnce($data, function ($collection) { + $collection->whereNotNull('a')->all(); + }); + + $data = $this->make([1, null, 2, null, 3]); + + $this->assertDoesNotEnumerateCollection($data, function ($collection) { + $collection->whereNotNull(); + }); + + $this->assertEnumeratesCollectionOnce($data, function ($collection) { + $collection->whereNotNull()->all(); + }); + } + + public function testWhereNullIsLazy() + { + $data = $this->make([['a' => 1], ['a' => null], ['a' => 2], ['a' => 3]]); + + $this->assertDoesNotEnumerateCollection($data, function ($collection) { + $collection->whereNull('a'); + }); + + $this->assertEnumeratesCollectionOnce($data, function ($collection) { + $collection->whereNull('a')->all(); + }); + + $data = $this->make([1, null, 2, null, 3]); + + $this->assertDoesNotEnumerateCollection($data, function ($collection) { + $collection->whereNull(); + }); + + $this->assertEnumeratesCollectionOnce($data, function ($collection) { + $collection->whereNull()->all(); + }); + } + + public function testWhereStrictIsLazy() + { + $data = $this->make([['a' => 1], ['a' => 2], ['a' => 3], ['a' => 4]]); + + $this->assertDoesNotEnumerateCollection($data, function ($collection) { + $collection->whereStrict('a', 2); + }); + + $this->assertEnumeratesCollection($data, 2, function ($collection) { + $collection->whereStrict('a', 2)->take(1)->all(); + }); + } + + public function testWithHeartbeatIsLazy() + { + $this->assertDoesNotEnumerate(function ($collection) { + $collection->withHeartbeat(1, function () { + // Heartbeat callback + }); + }); + + $this->assertEnumeratesOnce(function ($collection) { + $collection->withHeartbeat(1, function () { + // Heartbeat callback + })->all(); + }); + } + + public function testWrapIsLazy() + { + $this->assertDoesNotEnumerate(function ($collection) { + LazyCollection::wrap($collection); + }); + + $this->assertEnumeratesOnce(function ($collection) { + LazyCollection::wrap($collection)->all(); + }); + } + + public function testZipIsLazy() + { + $firstEnumerations = 0; + $secondEnumerations = 0; + $first = $this->countEnumerations($this->make([1, 2]), $firstEnumerations); + $second = $this->countEnumerations($this->make([1, 2]), $secondEnumerations); + + $first->zip($second); + + $this->assertEnumerations(0, $firstEnumerations); + $this->assertEnumerations(0, $secondEnumerations); + + $first->zip($second)->take(1)->all(); + + $this->assertEnumerations(1, $firstEnumerations); + $this->assertEnumerations(1, $secondEnumerations); + } + + protected function make($source) + { + return new LazyCollection($source); + } + + protected function rescue($callback) + { + try { + $callback(); + } catch (Exception) { + // Silence is golden + } + } +} diff --git a/tests/Support/SupportLazyCollectionTest.php b/tests/Support/SupportLazyCollectionTest.php new file mode 100644 index 000000000..645cbab48 --- /dev/null +++ b/tests/Support/SupportLazyCollectionTest.php @@ -0,0 +1,525 @@ +assertSame([], LazyCollection::make()->all()); + $this->assertSame([], LazyCollection::empty()->all()); + } + + public function testCanCreateCollectionFromArray() + { + $array = [1, 2, 3]; + + $data = LazyCollection::make($array); + + $this->assertSame($array, $data->all()); + + $array = ['a' => 1, 'b' => 2, 'c' => 3]; + + $data = LazyCollection::make($array); + + $this->assertSame($array, $data->all()); + } + + public function testCanCreateCollectionFromArrayable() + { + $array = [1, 2, 3]; + + $data = LazyCollection::make(Collection::make($array)); + + $this->assertSame($array, $data->all()); + + $array = ['a' => 1, 'b' => 2, 'c' => 3]; + + $data = LazyCollection::make(Collection::make($array)); + + $this->assertSame($array, $data->all()); + } + + public function testCanCreateCollectionFromGeneratorFunction() + { + $data = LazyCollection::make(function () { + yield 1; + yield 2; + yield 3; + }); + + $this->assertSame([1, 2, 3], $data->all()); + + $data = LazyCollection::make(function () { + yield 'a' => 1; + yield 'b' => 2; + yield 'c' => 3; + }); + + $this->assertSame([ + 'a' => 1, + 'b' => 2, + 'c' => 3, + ], $data->all()); + } + + public function testCanCreateCollectionFromNonGeneratorFunction() + { + $data = LazyCollection::make(function () { + return 'laravel'; + }); + + $this->assertSame(['laravel'], $data->all()); + } + + public function testDoesNotCreateCollectionFromGenerator() + { + $this->expectException(InvalidArgumentException::class); + + $generateNumber = function () { + yield 1; + }; + + LazyCollection::make($generateNumber()); + } + + public function testEager() + { + $source = [1, 2, 3, 4, 5]; + + $data = LazyCollection::make(function () use (&$source) { + yield from $source; + })->eager(); + + $source[] = 6; + + $this->assertSame([1, 2, 3, 4, 5], $data->all()); + } + + public function testRemember() + { + $source = [1, 2, 3, 4]; + + $collection = LazyCollection::make(function () use (&$source) { + yield from $source; + })->remember(); + + $this->assertSame([1, 2, 3, 4], $collection->all()); + + $source = []; + + $this->assertSame([1, 2, 3, 4], $collection->all()); + } + + public function testRememberWithTwoRunners() + { + $source = [1, 2, 3, 4]; + + $collection = LazyCollection::make(function () use (&$source) { + yield from $source; + })->remember(); + + $a = $collection->getIterator(); + $b = $collection->getIterator(); + + $this->assertEquals(1, $a->current()); + $this->assertEquals(1, $b->current()); + + $b->next(); + + $this->assertEquals(1, $a->current()); + $this->assertEquals(2, $b->current()); + + $b->next(); + + $this->assertEquals(1, $a->current()); + $this->assertEquals(3, $b->current()); + + $a->next(); + + $this->assertEquals(2, $a->current()); + $this->assertEquals(3, $b->current()); + + $a->next(); + + $this->assertEquals(3, $a->current()); + $this->assertEquals(3, $b->current()); + + $a->next(); + + $this->assertEquals(4, $a->current()); + $this->assertEquals(3, $b->current()); + + $b->next(); + + $this->assertEquals(4, $a->current()); + $this->assertEquals(4, $b->current()); + } + + public function testRememberWithDuplicateKeys() + { + $collection = LazyCollection::make(function () { + yield 'key' => 1; + yield 'key' => 2; + })->remember(); + + $results = $collection->map(function ($value, $key) { + return [$key, $value]; + })->values()->all(); + + $this->assertSame([['key', 1], ['key', 2]], $results); + } + + public function testTakeUntilTimeout() + { + $timeout = Carbon::now(); + + $mock = m::mock(LazyCollection::class . '[now]'); + + $timedOutWith = []; + + $results = $mock + ->times(10) + ->tap(function ($collection) use ($mock, $timeout) { + tap($collection) + ->mockery_init($mock->mockery_getContainer()) + ->shouldAllowMockingProtectedMethods() + ->shouldReceive('now') + ->times(3) + ->andReturn( + (clone $timeout)->sub(2, 'minute')->getTimestamp(), + (clone $timeout)->sub(1, 'minute')->getTimestamp(), + $timeout->getTimestamp() + ); + }) + ->takeUntilTimeout($timeout, function ($value, $key) use (&$timedOutWith) { + $timedOutWith = [$value, $key]; + }) + ->all(); + + $this->assertSame([1, 2], $results); + $this->assertSame([2, 1], $timedOutWith); + } + + public function testTapEach() + { + $data = LazyCollection::times(10); + + $tapped = []; + + $data = $data->tapEach(function ($value, $key) use (&$tapped) { + $tapped[$key] = $value; + }); + + $this->assertEmpty($tapped); + + $data = $data->take(5)->all(); + + $this->assertSame([1, 2, 3, 4, 5], $data); + $this->assertSame([1, 2, 3, 4, 5], $tapped); + } + + public function testThrottle() + { + Sleep::fake(); + + $data = LazyCollection::times(3) + ->throttle(2) + ->all(); + + Sleep::assertSlept(function (Duration $duration) { + $this->assertEqualsWithDelta( + 2_000_000, + $duration->totalMicroseconds, + 1_000 + ); + + return true; + }, times: 3); + + $this->assertSame([1, 2, 3], $data); + + Sleep::fake(false); + } + + public function testThrottleAccountsForTimePassed() + { + Sleep::fake(); + Carbon::setTestNow(now()); + + $data = LazyCollection::times(3) + ->throttle(3) + ->tapEach(function ($value, $index) { + if ($index == 1) { + // Travel in time... + (new Wormhole(1))->second(); + } + }) + ->all(); + + Sleep::assertSlept(function (Duration $duration, int $index) { + $expectation = $index == 1 ? 2_000_000 : 3_000_000; + + $this->assertEqualsWithDelta( + $expectation, + $duration->totalMicroseconds, + 1_000 + ); + + return true; + }, times: 3); + + $this->assertSame([1, 2, 3], $data); + + Sleep::fake(false); + Carbon::setTestNow(); + } + + public function testUniqueDoubleEnumeration() + { + $data = LazyCollection::times(2)->unique(); + + $data->all(); + + $this->assertSame([1, 2], $data->all()); + } + + public function testAfter() + { + $data = new LazyCollection([1, '2', 3, 4]); + + // Test finding item after value with non-strict comparison + $result = $data->after(1); + $this->assertSame('2', $result); + + // Test with strict comparison + $result = $data->after('2', true); + $this->assertSame(3, $result); + + $users = new LazyCollection([ + ['name' => 'Taylor', 'age' => 35], + ['name' => 'Jeffrey', 'age' => 45], + ['name' => 'Mohamed', 'age' => 35], + ]); + + // Test finding item after the one that matches a condition + $result = $users->after(function ($user) { + return $user['name'] === 'Jeffrey'; + }); + + $this->assertSame(['name' => 'Mohamed', 'age' => 35], $result); + } + + public function testBefore() + { + // Test finding item before value with non-strict comparison + $data = new LazyCollection([1, 2, '3', 4]); + $result = $data->before(2); + $this->assertSame(1, $result); + + // Test finding item before value with strict comparison + $result = $data->before(4, true); + $this->assertSame('3', $result); + + // Test finding item before the one that matches a callback condition + $users = new LazyCollection([ + ['name' => 'Taylor', 'age' => 35], + ['name' => 'Jeffrey', 'age' => 45], + ['name' => 'Mohamed', 'age' => 35], + ]); + $result = $users->before(function ($user) { + return $user['name'] === 'Jeffrey'; + }); + $this->assertSame(['name' => 'Taylor', 'age' => 35], $result); + } + + public function testShuffle() + { + $data = new LazyCollection([1, 2, 3, 4, 5]); + $shuffled = $data->shuffle(); + + $this->assertCount(5, $shuffled); + $this->assertEquals([1, 2, 3, 4, 5], $shuffled->sort()->values()->all()); + + // Test shuffling associative array maintains key-value pairs + $users = new LazyCollection([ + 'first' => ['name' => 'Taylor'], + 'second' => ['name' => 'Jeffrey'], + ]); + $shuffled = $users->shuffle(); + + $this->assertCount(2, $shuffled); + $this->assertTrue($shuffled->contains('name', 'Taylor')); + $this->assertTrue($shuffled->contains('name', 'Jeffrey')); + } + + public function testCollapseWithKeys() + { + $collection = new LazyCollection([ + ['a' => 1, 'b' => 2], + ['c' => 3, 'd' => 4], + ]); + $collapsed = $collection->collapseWithKeys(); + + $this->assertEquals(['a' => 1, 'b' => 2, 'c' => 3, 'd' => 4], $collapsed->all()); + + $collection = new LazyCollection([ + ['a' => 1], + new LazyCollection(['b' => 2]), + ]); + $collapsed = $collection->collapseWithKeys(); + + $this->assertEquals(['a' => 1, 'b' => 2], $collapsed->all()); + } + + public function testContainsOneItem() + { + $collection = new LazyCollection([5]); + $this->assertTrue($collection->containsOneItem()); + + $emptyCollection = new LazyCollection([]); + $this->assertFalse($emptyCollection->containsOneItem()); + + $multipleCollection = new LazyCollection([1, 2, 3]); + $this->assertFalse($multipleCollection->containsOneItem()); + } + + public function testContainsManyItems() + { + $emptyCollection = new LazyCollection([]); + $this->assertFalse($emptyCollection->containsManyItems()); + + $singleCollection = new LazyCollection([1]); + $this->assertFalse($singleCollection->containsManyItems()); + + $multipleCollection = new LazyCollection([1, 2]); + $this->assertTrue($multipleCollection->containsManyItems()); + + $manyCollection = new LazyCollection([1, 2, 3]); + $this->assertTrue($manyCollection->containsManyItems()); + } + + public function testDoesntContain() + { + $collection = new LazyCollection([1, 2, 3, 4, 5]); + + $this->assertTrue($collection->doesntContain(10)); + $this->assertFalse($collection->doesntContain(3)); + $this->assertTrue($collection->doesntContain('value', '>', 10)); + $this->assertTrue($collection->doesntContain(function ($value) { + return $value > 10; + })); + + $users = new LazyCollection([ + [ + 'name' => 'Taylor', + 'role' => 'developer', + ], + [ + 'name' => 'Jeffrey', + 'role' => 'designer', + ], + ]); + + $this->assertTrue($users->doesntContain('name', 'Adam')); + $this->assertFalse($users->doesntContain('name', 'Taylor')); + } + + public function testDot() + { + $collection = new LazyCollection([ + 'foo' => [ + 'bar' => 'baz', + ], + 'user' => [ + 'name' => 'Taylor', + 'profile' => [ + 'age' => 30, + ], + ], + 'users' => [ + 0 => [ + 'name' => 'Taylor', + ], + 1 => [ + 'name' => 'Jeffrey', + ], + ], + ]); + + $dotted = $collection->dot(); + + $expected = [ + 'foo.bar' => 'baz', + 'user.name' => 'Taylor', + 'user.profile.age' => 30, + 'users.0.name' => 'Taylor', + 'users.1.name' => 'Jeffrey', + ]; + + $this->assertEquals($expected, $dotted->all()); + } + + public function testWithHeartbeat() + { + $start = Carbon::create(2000, 1, 1); + $after2Minutes = $start->copy()->addMinutes(2); + $after5Minutes = $start->copy()->addMinutes(5); + $after7Minutes = $start->copy()->addMinutes(7); + $after11Minutes = $start->copy()->addMinutes(11); + + Carbon::setTestNow($start); + + $output = new Collection(); + + $numbers = LazyCollection::range(1, 10) + + // Move the clock to possibly trigger the heartbeat... + ->tapEach(fn ($number) => Carbon::setTestNow( + match ($number) { + 3 => $after2Minutes, + 4 => $after5Minutes, + 6 => $after7Minutes, + 9 => $after11Minutes, + default => Carbon::now(), + } + )) + + // Push the current date to `output` when heartbeat is triggered... + ->withHeartbeat(Duration::minutes(5), fn () => $output[] = Carbon::now()) + + // Push every number onto `output` as it's enumerated... + ->tapEach(fn ($number) => $output[] = $number)->all(); + + $this->assertEquals(range(1, 10), $numbers); + + $this->assertEquals( + [ + 1, 2, 3, + $after5Minutes, + 4, 5, 6, 7, 8, + $after11Minutes, + 9, 10, + ], + $output->all(), + ); + + Carbon::setTestNow(); + } +} diff --git a/tests/Support/SupportNamespacedItemResolverTest.php b/tests/Support/SupportNamespacedItemResolverTest.php new file mode 100644 index 000000000..a8640f219 --- /dev/null +++ b/tests/Support/SupportNamespacedItemResolverTest.php @@ -0,0 +1,46 @@ +assertEquals(['foo', 'bar', 'baz'], $r->parseKey('foo::bar.baz')); + $this->assertEquals(['foo', 'bar', null], $r->parseKey('foo::bar')); + $this->assertEquals([null, 'bar', 'baz'], $r->parseKey('bar.baz')); + $this->assertEquals([null, 'bar', null], $r->parseKey('bar')); + } + + public function testParsedItemsAreCached() + { + $r = $this->getMockBuilder(NamespacedItemResolver::class)->onlyMethods(['parseBasicSegments', 'parseNamespacedSegments'])->getMock(); + $r->setParsedKey('foo.bar', ['foo']); + $r->expects($this->never())->method('parseBasicSegments'); + $r->expects($this->never())->method('parseNamespacedSegments'); + + $this->assertEquals(['foo'], $r->parseKey('foo.bar')); + } + + public function testParsedItemsMayBeFlushed() + { + $r = $this->getMockBuilder(NamespacedItemResolver::class)->onlyMethods(['parseBasicSegments', 'parseNamespacedSegments'])->getMock(); + $r->expects($this->once())->method('parseBasicSegments')->willReturn(['bar']); + + $r->setParsedKey('foo.bar', ['foo']); + $r->flushParsedKeys(); + + $this->assertEquals(['bar'], $r->parseKey('foo.bar')); + } +} diff --git a/tests/Support/SupportNumberTest.php b/tests/Support/SupportNumberTest.php new file mode 100644 index 000000000..73b1b5f50 --- /dev/null +++ b/tests/Support/SupportNumberTest.php @@ -0,0 +1,400 @@ +assertSame('en', Number::defaultLocale()); + } + + public function testDefaultCurrency() + { + $this->assertSame('USD', Number::defaultCurrency()); + } + + #[RequiresPhpExtension('intl')] + public function testFormat() + { + $this->assertSame('0', Number::format(0)); + $this->assertSame('0', Number::format(0.0)); + $this->assertSame('0', Number::format(0.00)); + $this->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('100,000', Number::format(100000)); + $this->assertSame('100,000.00', Number::format(100000, precision: 2)); + $this->assertSame('100,000.12', Number::format(100000.123, precision: 2)); + $this->assertSame('100,000.123', Number::format(100000.1234, maxPrecision: 3)); + $this->assertSame('100,000.124', Number::format(100000.1236, maxPrecision: 3)); + $this->assertSame('123,456,789', Number::format(123456789)); + + $this->assertSame('-1', Number::format(-1)); + $this->assertSame('-10', Number::format(-10)); + $this->assertSame('-25', Number::format(-25)); + + $this->assertSame('0.2', Number::format(0.2)); + $this->assertSame('0.20', Number::format(0.2, precision: 2)); + $this->assertSame('0.123', Number::format(0.1234, maxPrecision: 3)); + $this->assertSame('1.23', Number::format(1.23)); + $this->assertSame('-1.23', Number::format(-1.23)); + $this->assertSame('123.456', Number::format(123.456)); + + $this->assertSame('∞', Number::format(INF)); + $this->assertSame('NaN', Number::format(NAN)); + } + + #[RequiresPhpExtension('intl')] + public function testFormatWithDifferentLocale() + { + $this->assertSame('123,456,789', Number::format(123456789, locale: 'en')); + $this->assertSame('123.456.789', Number::format(123456789, locale: 'de')); + $this->assertSame('123 456 789', Number::format(123456789, locale: 'fr')); + $this->assertSame('123 456 789', Number::format(123456789, locale: 'ru')); + $this->assertSame('123 456 789', Number::format(123456789, locale: 'sv')); + } + + #[RequiresPhpExtension('intl')] + public function testFormatWithAppLocale() + { + $this->assertSame('123,456,789', Number::format(123456789)); + + Number::useLocale('de'); + + $this->assertSame('123.456.789', Number::format(123456789)); + + Number::useLocale('en'); + } + + #[RequiresPhpExtension('intl')] + public function testSpellout() + { + $this->assertSame('ten', Number::spell(10)); + $this->assertSame('one point two', Number::spell(1.2)); + } + + #[RequiresPhpExtension('intl')] + public function testSpelloutWithLocale() + { + $this->assertSame('trois', Number::spell(3, 'fr')); + } + + #[RequiresPhpExtension('intl')] + public function testSpelloutWithThreshold() + { + $this->assertSame('9', Number::spell(9, after: 10)); + $this->assertSame('10', Number::spell(10, after: 10)); + $this->assertSame('eleven', Number::spell(11, after: 10)); + + $this->assertSame('nine', Number::spell(9, until: 10)); + $this->assertSame('10', Number::spell(10, until: 10)); + $this->assertSame('11', Number::spell(11, until: 10)); + + $this->assertSame('ten thousand', Number::spell(10000, until: 50000)); + $this->assertSame('100,000', Number::spell(100000, until: 50000)); + } + + #[RequiresPhpExtension('intl')] + public function testOrdinal() + { + $this->assertSame('1st', Number::ordinal(1)); + $this->assertSame('2nd', Number::ordinal(2)); + $this->assertSame('3rd', Number::ordinal(3)); + } + + #[RequiresPhpExtension('intl')] + public function testSpellOrdinal() + { + $this->assertSame('first', Number::spellOrdinal(1)); + $this->assertSame('second', Number::spellOrdinal(2)); + $this->assertSame('third', Number::spellOrdinal(3)); + } + + #[RequiresPhpExtension('intl')] + public function testToPercent() + { + $this->assertSame('0%', Number::percentage(0, precision: 0)); + $this->assertSame('0%', Number::percentage(0)); + $this->assertSame('1%', Number::percentage(1)); + $this->assertSame('10.00%', Number::percentage(10, precision: 2)); + $this->assertSame('100%', Number::percentage(100)); + $this->assertSame('100.00%', Number::percentage(100, precision: 2)); + $this->assertSame('100.123%', Number::percentage(100.1234, maxPrecision: 3)); + + $this->assertSame('300%', Number::percentage(300)); + $this->assertSame('1,000%', Number::percentage(1000)); + + $this->assertSame('2%', Number::percentage(1.75)); + $this->assertSame('1.75%', Number::percentage(1.75, precision: 2)); + $this->assertSame('1.750%', Number::percentage(1.75, precision: 3)); + $this->assertSame('0%', Number::percentage(0.12345)); + $this->assertSame('0.00%', Number::percentage(0, precision: 2)); + $this->assertSame('0.12%', Number::percentage(0.12345, precision: 2)); + $this->assertSame('0.1235%', Number::percentage(0.12345, precision: 4)); + } + + #[RequiresPhpExtension('intl')] + public function testToCurrency() + { + $this->assertSame('$0.00', Number::currency(0)); + $this->assertSame('$1.00', Number::currency(1)); + $this->assertSame('$10.00', Number::currency(10)); + + $this->assertSame('€0.00', Number::currency(0, 'EUR')); + $this->assertSame('€1.00', Number::currency(1, 'EUR')); + $this->assertSame('€10.00', Number::currency(10, 'EUR')); + + $this->assertSame('-$5.00', Number::currency(-5)); + $this->assertSame('$5.00', Number::currency(5.00)); + $this->assertSame('$5.32', Number::currency(5.325)); + + $this->assertSame('$0', Number::currency(0, precision: 0)); + $this->assertSame('$5', Number::currency(5.00, precision: 0)); + $this->assertSame('$10', Number::currency(10.252, precision: 0)); + } + + #[RequiresPhpExtension('intl')] + public function testToCurrencyWithDifferentLocale() + { + $this->assertSame('1,00 €', Number::currency(1, 'EUR', 'de')); + $this->assertSame('1,00 $', Number::currency(1, 'USD', 'de')); + $this->assertSame('1,00 £', Number::currency(1, 'GBP', 'de')); + + $this->assertSame('123.456.789,12 $', Number::currency(123456789.12345, 'USD', 'de')); + $this->assertSame('123.456.789,12 €', Number::currency(123456789.12345, 'EUR', 'de')); + $this->assertSame('1 234,56 $US', Number::currency(1234.56, 'USD', 'fr')); + } + + #[RequiresPhpExtension('intl')] + public function testBytesToHuman() + { + $this->assertSame('0 B', Number::fileSize(0)); + $this->assertSame('0.00 B', Number::fileSize(0, precision: 2)); + $this->assertSame('1 B', Number::fileSize(1)); + $this->assertSame('1 KB', Number::fileSize(1024)); + $this->assertSame('2 KB', Number::fileSize(2048)); + $this->assertSame('2.00 KB', Number::fileSize(2048, precision: 2)); + $this->assertSame('1.23 KB', Number::fileSize(1264, precision: 2)); + $this->assertSame('1.234 KB', Number::fileSize(1264.12345, maxPrecision: 3)); + $this->assertSame('1.234 KB', Number::fileSize(1264, 3)); + $this->assertSame('5 GB', Number::fileSize(1024 * 1024 * 1024 * 5)); + $this->assertSame('10 TB', Number::fileSize((1024 ** 4) * 10)); + $this->assertSame('10 PB', Number::fileSize((1024 ** 5) * 10)); + $this->assertSame('1 ZB', Number::fileSize(1024 ** 7)); + $this->assertSame('1 YB', Number::fileSize(1024 ** 8)); + $this->assertSame('1,024 YB', Number::fileSize(1024 ** 9)); + } + + public function testClamp() + { + $this->assertSame(2, Number::clamp(1, 2, 3)); + $this->assertSame(3, Number::clamp(5, 2, 3)); + $this->assertSame(5, Number::clamp(5, 1, 10)); + $this->assertSame(4.5, Number::clamp(4.5, 1, 10)); + $this->assertSame(1, Number::clamp(-10, 1, 5)); + } + + #[RequiresPhpExtension('intl')] + public function testToHuman() + { + $this->assertSame('1', Number::forHumans(1)); + $this->assertSame('1.00', Number::forHumans(1, precision: 2)); + $this->assertSame('10', Number::forHumans(10)); + $this->assertSame('100', Number::forHumans(100)); + $this->assertSame('1 thousand', Number::forHumans(1000)); + $this->assertSame('1.00 thousand', Number::forHumans(1000, precision: 2)); + $this->assertSame('1 thousand', Number::forHumans(1000, maxPrecision: 2)); + $this->assertSame('1 thousand', Number::forHumans(1230)); + $this->assertSame('1.2 thousand', Number::forHumans(1230, maxPrecision: 1)); + $this->assertSame('1 million', Number::forHumans(1000000)); + $this->assertSame('1 billion', Number::forHumans(1000000000)); + $this->assertSame('1 trillion', Number::forHumans(1000000000000)); + $this->assertSame('1 quadrillion', Number::forHumans(1000000000000000)); + $this->assertSame('1 thousand quadrillion', Number::forHumans(1000000000000000000)); + + $this->assertSame('123', Number::forHumans(123)); + $this->assertSame('1 thousand', Number::forHumans(1234)); + $this->assertSame('1.23 thousand', Number::forHumans(1234, precision: 2)); + $this->assertSame('12 thousand', Number::forHumans(12345)); + $this->assertSame('1 million', Number::forHumans(1234567)); + $this->assertSame('1 billion', Number::forHumans(1234567890)); + $this->assertSame('1 trillion', Number::forHumans(1234567890123)); + $this->assertSame('1.23 trillion', Number::forHumans(1234567890123, precision: 2)); + $this->assertSame('1 quadrillion', Number::forHumans(1234567890123456)); + $this->assertSame('1.23 thousand quadrillion', Number::forHumans(1234567890123456789, precision: 2)); + $this->assertSame('490 thousand', Number::forHumans(489939)); + $this->assertSame('489.9390 thousand', Number::forHumans(489939, precision: 4)); + $this->assertSame('500.00000 million', Number::forHumans(500000000, precision: 5)); + + $this->assertSame('1 million quadrillion', Number::forHumans(1000000000000000000000)); + $this->assertSame('1 billion quadrillion', Number::forHumans(1000000000000000000000000)); + $this->assertSame('1 trillion quadrillion', Number::forHumans(1000000000000000000000000000)); + $this->assertSame('1 quadrillion quadrillion', Number::forHumans(1000000000000000000000000000000)); + $this->assertSame('1 thousand quadrillion quadrillion', Number::forHumans(1000000000000000000000000000000000)); + + $this->assertSame('0', Number::forHumans(0)); + $this->assertSame('0', Number::forHumans(0.0)); + $this->assertSame('0.00', Number::forHumans(0, 2)); + $this->assertSame('0.00', Number::forHumans(0.0, 2)); + $this->assertSame('-1', Number::forHumans(-1)); + $this->assertSame('-1.00', Number::forHumans(-1, precision: 2)); + $this->assertSame('-10', Number::forHumans(-10)); + $this->assertSame('-100', Number::forHumans(-100)); + $this->assertSame('-1 thousand', Number::forHumans(-1000)); + $this->assertSame('-1.23 thousand', Number::forHumans(-1234, precision: 2)); + $this->assertSame('-1.2 thousand', Number::forHumans(-1234, maxPrecision: 1)); + $this->assertSame('-1 million', Number::forHumans(-1000000)); + $this->assertSame('-1 billion', Number::forHumans(-1000000000)); + $this->assertSame('-1 trillion', Number::forHumans(-1000000000000)); + $this->assertSame('-1.1 trillion', Number::forHumans(-1100000000000, maxPrecision: 1)); + $this->assertSame('-1 quadrillion', Number::forHumans(-1000000000000000)); + $this->assertSame('-1 thousand quadrillion', Number::forHumans(-1000000000000000000)); + } + + #[RequiresPhpExtension('intl')] + public function testSummarize() + { + $this->assertSame('1', Number::abbreviate(1)); + $this->assertSame('1.00', Number::abbreviate(1, precision: 2)); + $this->assertSame('10', Number::abbreviate(10)); + $this->assertSame('100', Number::abbreviate(100)); + $this->assertSame('1K', Number::abbreviate(1000)); + $this->assertSame('1.00K', Number::abbreviate(1000, precision: 2)); + $this->assertSame('1K', Number::abbreviate(1000, maxPrecision: 2)); + $this->assertSame('1K', Number::abbreviate(1230)); + $this->assertSame('1.2K', Number::abbreviate(1230, maxPrecision: 1)); + $this->assertSame('1M', Number::abbreviate(1000000)); + $this->assertSame('1B', Number::abbreviate(1000000000)); + $this->assertSame('1T', Number::abbreviate(1000000000000)); + $this->assertSame('1Q', Number::abbreviate(1000000000000000)); + $this->assertSame('1KQ', Number::abbreviate(1000000000000000000)); + + $this->assertSame('123', Number::abbreviate(123)); + $this->assertSame('1K', Number::abbreviate(1234)); + $this->assertSame('1.23K', Number::abbreviate(1234, precision: 2)); + $this->assertSame('12K', Number::abbreviate(12345)); + $this->assertSame('1M', Number::abbreviate(1234567)); + $this->assertSame('1B', Number::abbreviate(1234567890)); + $this->assertSame('1T', Number::abbreviate(1234567890123)); + $this->assertSame('1.23T', Number::abbreviate(1234567890123, precision: 2)); + $this->assertSame('1Q', Number::abbreviate(1234567890123456)); + $this->assertSame('1.23KQ', Number::abbreviate(1234567890123456789, precision: 2)); + $this->assertSame('490K', Number::abbreviate(489939)); + $this->assertSame('489.9390K', Number::abbreviate(489939, precision: 4)); + $this->assertSame('500.00000M', Number::abbreviate(500000000, precision: 5)); + + $this->assertSame('1MQ', Number::abbreviate(1000000000000000000000)); + $this->assertSame('1BQ', Number::abbreviate(1000000000000000000000000)); + $this->assertSame('1TQ', Number::abbreviate(1000000000000000000000000000)); + $this->assertSame('1QQ', Number::abbreviate(1000000000000000000000000000000)); + $this->assertSame('1KQQ', Number::abbreviate(1000000000000000000000000000000000)); + + $this->assertSame('0', Number::abbreviate(0)); + $this->assertSame('0', Number::abbreviate(0.0)); + $this->assertSame('0.00', Number::abbreviate(0, 2)); + $this->assertSame('0.00', Number::abbreviate(0.0, 2)); + $this->assertSame('-1', Number::abbreviate(-1)); + $this->assertSame('-1.00', Number::abbreviate(-1, precision: 2)); + $this->assertSame('-10', Number::abbreviate(-10)); + $this->assertSame('-100', Number::abbreviate(-100)); + $this->assertSame('-1K', Number::abbreviate(-1000)); + $this->assertSame('-1.23K', Number::abbreviate(-1234, precision: 2)); + $this->assertSame('-1.2K', Number::abbreviate(-1234, maxPrecision: 1)); + $this->assertSame('-1M', Number::abbreviate(-1000000)); + $this->assertSame('-1B', Number::abbreviate(-1000000000)); + $this->assertSame('-1T', Number::abbreviate(-1000000000000)); + $this->assertSame('-1.1T', Number::abbreviate(-1100000000000, maxPrecision: 1)); + $this->assertSame('-1Q', Number::abbreviate(-1000000000000000)); + $this->assertSame('-1KQ', Number::abbreviate(-1000000000000000000)); + } + + public function testPairs() + { + $this->assertSame([[0, 10], [10, 20], [20, 25]], Number::pairs(25, 10, 0, 0)); + $this->assertSame([[0, 9], [10, 19], [20, 25]], Number::pairs(25, 10, 0, 1)); + $this->assertSame([[1, 11], [11, 21], [21, 25]], Number::pairs(25, 10, 1, 0)); + $this->assertSame([[1, 10], [11, 20], [21, 25]], Number::pairs(25, 10, 1, 1)); + $this->assertSame([[0, 1000], [1000, 2000], [2000, 2500]], Number::pairs(2500, 1000, 0, 0)); + $this->assertSame([[0, 999], [1000, 1999], [2000, 2500]], Number::pairs(2500, 1000, 0, 1)); + $this->assertSame([[1, 1001], [1001, 2001], [2001, 2500]], Number::pairs(2500, 1000, 1, 0)); + $this->assertSame([[1, 1000], [1001, 2000], [2001, 2500]], Number::pairs(2500, 1000, 1, 1)); + $this->assertSame([[0, 2.5], [2.5, 5.0], [5.0, 7.5], [7.5, 10.0]], Number::pairs(10, 2.5, 0, 0)); + $this->assertSame([[0, 2.0], [2.5, 4.5], [5.0, 7.0], [7.5, 9.5]], Number::pairs(10, 2.5, 0, 0.5)); + $this->assertSame([[0.5, 3.0], [3.0, 5.5], [5.5, 8.0], [8.0, 10]], Number::pairs(10, 2.5, 0.5, 0)); + $this->assertSame([[0.5, 2.5], [3.0, 5.0], [5.5, 7.5], [8.0, 10.0]], Number::pairs(10, 2.5, 0.5, 0.5)); + } + + public function testTrim() + { + $this->assertSame(12, Number::trim(12)); + $this->assertSame(120, Number::trim(120)); + $this->assertSame(12, Number::trim(12.0)); + $this->assertSame(12.3, Number::trim(12.3)); + $this->assertSame(12.3, Number::trim(12.30)); + $this->assertSame(12.3456789, Number::trim(12.3456789)); + $this->assertSame(12.3456789, Number::trim(12.34567890000)); + } + + #[RequiresPhpExtension('intl')] + public function testParse() + { + $this->assertSame(1234.0, Number::parse('1,234')); + $this->assertSame(1234.5, Number::parse('1,234.5')); + $this->assertSame(1234.56, Number::parse('1,234.56')); + $this->assertSame(-1234.56, Number::parse('-1,234.56')); + + $this->assertSame(1234.56, Number::parse('1.234,56', locale: 'de')); + $this->assertSame(1234.56, Number::parse('1 234,56', locale: 'fr')); + } + + #[RequiresPhpExtension('intl')] + public function testParseInt() + { + $this->assertSame(1234, Number::parseInt('1,234')); + $this->assertSame(1234, Number::parseInt('1,234.5')); + $this->assertSame(-1234, Number::parseInt('-1,234.56')); + + $this->assertSame(1234, Number::parseInt('1.234', locale: 'de')); + $this->assertSame(1234, Number::parseInt('1 234', locale: 'fr')); + } + + #[RequiresPhpExtension('intl')] + public function testParseFloat() + { + $this->assertSame(1234.0, Number::parseFloat('1,234')); + $this->assertSame(1234.5, Number::parseFloat('1,234.5')); + $this->assertSame(1234.56, Number::parseFloat('1,234.56')); + $this->assertSame(-1234.56, Number::parseFloat('-1,234.56')); + + $this->assertSame(1234.56, Number::parseFloat('1.234,56', locale: 'de')); + $this->assertSame(1234.56, Number::parseFloat('1 234,56', locale: 'fr')); + } +} diff --git a/tests/Support/SupportOptionalTest.php b/tests/Support/SupportOptionalTest.php new file mode 100644 index 000000000..c6bd506c9 --- /dev/null +++ b/tests/Support/SupportOptionalTest.php @@ -0,0 +1,109 @@ +item = $expected; + + $optional = new Optional($targetObj); + + $this->assertEquals($expected, $optional->item); + } + + public function testGetNotExistItemOnObject() + { + $targetObj = new stdClass(); + + $optional = new Optional($targetObj); + + $this->assertNull($optional->item); + } + + public function testIssetExistItemOnObject() + { + $targetObj = new stdClass(); + $targetObj->item = ''; + + $optional = new Optional($targetObj); + + $this->assertTrue(isset($optional->item)); + } + + public function testIssetNotExistItemOnObject() + { + $targetObj = new stdClass(); + + $optional = new Optional($targetObj); + + $this->assertFalse(isset($optional->item)); + } + + public function testGetExistItemOnArray() + { + $expected = 'test'; + + $targetArr = [ + 'item' => $expected, + ]; + + $optional = new Optional($targetArr); + + $this->assertEquals($expected, $optional['item']); + } + + public function testGetNotExistItemOnArray() + { + $targetObj = []; + + $optional = new Optional($targetObj); + + $this->assertNull($optional['item']); + } + + public function testIssetExistItemOnArray() + { + $targetArr = [ + 'item' => '', + ]; + + $optional = new Optional($targetArr); + + $this->assertTrue(isset($optional['item'])); + $this->assertTrue(isset($optional->item)); + } + + public function testIssetNotExistItemOnArray() + { + $targetArr = []; + + $optional = new Optional($targetArr); + + $this->assertFalse(isset($optional['item'])); + $this->assertFalse(isset($optional->item)); + } + + public function testIssetExistItemOnNull() + { + $targetNull = null; + + $optional = new Optional($targetNull); + + $this->assertFalse(isset($optional->item)); + } +} diff --git a/tests/Support/SupportPluralizerTest.php b/tests/Support/SupportPluralizerTest.php new file mode 100644 index 000000000..b4f97a0c5 --- /dev/null +++ b/tests/Support/SupportPluralizerTest.php @@ -0,0 +1,126 @@ +assertSame('child', Str::singular('children')); + } + + public function testBasicPlural() + { + $this->assertSame('children', Str::plural('child')); + $this->assertSame('cod', Str::plural('cod')); + $this->assertSame('The words', Str::plural('The word')); + $this->assertSame('Bouquetés', Str::plural('Bouqueté')); + } + + public function testCaseSensitiveSingularUsage() + { + $this->assertSame('Child', Str::singular('Children')); + $this->assertSame('CHILD', Str::singular('CHILDREN')); + $this->assertSame('Test', Str::singular('Tests')); + } + + public function testCaseSensitiveSingularPlural() + { + $this->assertSame('Children', Str::plural('Child')); + $this->assertSame('CHILDREN', Str::plural('CHILD')); + $this->assertSame('Tests', Str::plural('Test')); + $this->assertSame('children', Str::plural('cHiLd')); + } + + public function testIfEndOfWordPlural() + { + $this->assertSame('VortexFields', Str::plural('VortexField')); + $this->assertSame('MatrixFields', Str::plural('MatrixField')); + $this->assertSame('IndexFields', Str::plural('IndexField')); + $this->assertSame('VertexFields', Str::plural('VertexField')); + + // This is expected behavior, use "Str::pluralStudly" instead. + $this->assertSame('RealHumen', Str::plural('RealHuman')); + } + + public function testPluralWithNegativeCount() + { + $this->assertSame('test', Str::plural('test', 1)); + $this->assertSame('tests', Str::plural('test', 2)); + $this->assertSame('test', Str::plural('test', -1)); + $this->assertSame('tests', Str::plural('test', -2)); + } + + public function testPluralStudly() + { + $this->assertPluralStudly('RealHumans', 'RealHuman'); + $this->assertPluralStudly('Models', 'Model'); + $this->assertPluralStudly('VortexFields', 'VortexField'); + $this->assertPluralStudly('MultipleWordsInOneStrings', 'MultipleWordsInOneString'); + } + + public function testPluralStudlyWithCount() + { + $this->assertPluralStudly('RealHuman', 'RealHuman', 1); + $this->assertPluralStudly('RealHumans', 'RealHuman', 2); + $this->assertPluralStudly('RealHuman', 'RealHuman', -1); + $this->assertPluralStudly('RealHumans', 'RealHuman', -2); + } + + public function testPluralNotAppliedForStringEndingWithNonAlphanumericCharacter() + { + $this->assertSame('Alien.', Str::plural('Alien.')); + $this->assertSame('Alien!', Str::plural('Alien!')); + $this->assertSame('Alien ', Str::plural('Alien ')); + $this->assertSame('50%', Str::plural('50%')); + } + + public function testPluralAppliedForStringEndingWithNumericCharacter() + { + $this->assertSame('User1s', Str::plural('User1')); + $this->assertSame('User2s', Str::plural('User2')); + $this->assertSame('User3s', Str::plural('User3')); + } + + public function testPluralSupportsArrays() + { + $this->assertSame('users', Str::plural('user', [])); + $this->assertSame('user', Str::plural('user', ['one'])); + $this->assertSame('users', Str::plural('user', ['one', 'two'])); + } + + public function testPluralSupportsCollections() + { + $this->assertSame('users', Str::plural('user', collect())); + $this->assertSame('user', Str::plural('user', collect(['one']))); + $this->assertSame('users', Str::plural('user', collect(['one', 'two']))); + } + + public function testPluralStudlySupportsArrays() + { + $this->assertPluralStudly('SomeUsers', 'SomeUser', []); + $this->assertPluralStudly('SomeUser', 'SomeUser', ['one']); + $this->assertPluralStudly('SomeUsers', 'SomeUser', ['one', 'two']); + } + + public function testPluralStudlySupportsCollections() + { + $this->assertPluralStudly('SomeUsers', 'SomeUser', collect()); + $this->assertPluralStudly('SomeUser', 'SomeUser', collect(['one'])); + $this->assertPluralStudly('SomeUsers', 'SomeUser', collect(['one', 'two'])); + } + + private function assertPluralStudly($expected, $value, $count = 2) + { + $this->assertSame($expected, Str::pluralStudly($value, $count)); + } +} diff --git a/tests/Support/SupportReflectorTest.php b/tests/Support/SupportReflectorTest.php new file mode 100644 index 000000000..a03bcb820 --- /dev/null +++ b/tests/Support/SupportReflectorTest.php @@ -0,0 +1,188 @@ +getMethod('send'); + + $this->assertSame(Mailable::class, Reflector::getParameterClassName($method->getParameters()[0])); + } + + public function testEmptyClassName() + { + $method = (new ReflectionClass(MailFake::class))->getMethod('assertSent'); + + $this->assertNull(Reflector::getParameterClassName($method->getParameters()[0])); + } + + public function testStringTypeName() + { + $method = (new ReflectionClass(BusFake::class))->getMethod('dispatchedAfterResponse'); + + $this->assertNull(Reflector::getParameterClassName($method->getParameters()[0])); + } + + public function testSelfClassName() + { + $method = (new ReflectionClass(Model::class))->getMethod('newPivot'); + + $this->assertSame(Model::class, Reflector::getParameterClassName($method->getParameters()[0])); + } + + public function testParentClassName() + { + $method = (new ReflectionClass(B::class))->getMethod('f'); + + $this->assertSame(A::class, Reflector::getParameterClassName($method->getParameters()[0])); + } + + public function testParameterSubclassOfInterface() + { + $method = (new ReflectionClass(TestClassWithInterfaceSubclassParameter::class))->getMethod('f'); + + $this->assertTrue(Reflector::isParameterSubclassOf($method->getParameters()[0], IA::class)); + } + + public function testUnionTypeName() + { + $method = (new ReflectionClass(C::class))->getMethod('f'); + + $this->assertNull(Reflector::getParameterClassName($method->getParameters()[0])); + } + + public function testIsCallable() + { + $this->assertTrue(Reflector::isCallable(function () { + })); + $this->assertTrue(Reflector::isCallable([B::class, 'f'])); + $this->assertFalse(Reflector::isCallable([TestClassWithCall::class, 'f'])); + $this->assertTrue(Reflector::isCallable([new TestClassWithCall(), 'f'])); + $this->assertTrue(Reflector::isCallable([TestClassWithCallStatic::class, 'f'])); + $this->assertFalse(Reflector::isCallable([new TestClassWithCallStatic(), 'f'])); + $this->assertFalse(Reflector::isCallable([new TestClassWithCallStatic()])); + $this->assertFalse(Reflector::isCallable(['TotallyMissingClass', 'foo'])); + $this->assertTrue(Reflector::isCallable(['TotallyMissingClass', 'foo'], true)); + } + + public function testGetClassAttributes() + { + require_once __DIR__ . '/Fixtures/ClassesWithAttributes.php'; + + $this->assertSame([], Reflector::getClassAttributes(Fixtures\ChildClass::class, Fixtures\UnusedAttr::class)->toArray()); + + $this->assertSame( + [Fixtures\ChildClass::class => [], Fixtures\ParentClass::class => []], + Reflector::getClassAttributes(Fixtures\ChildClass::class, Fixtures\UnusedAttr::class, true)->toArray() + ); + + $this->assertSame( + ['quick', 'brown', 'fox'], + Reflector::getClassAttributes(Fixtures\ChildClass::class, Fixtures\StrAttr::class)->map->string->all() + ); + + $this->assertSame( + ['quick', 'brown', 'fox', 'lazy', 'dog'], + Reflector::getClassAttributes(Fixtures\ChildClass::class, Fixtures\StrAttr::class, true)->flatten()->map->string->all() + ); + + $this->assertSame(7, Reflector::getClassAttributes(Fixtures\ChildClass::class, Fixtures\NumAttr::class)->sum->number); + $this->assertSame(12, Reflector::getClassAttributes(Fixtures\ChildClass::class, Fixtures\NumAttr::class, true)->flatten()->sum->number); + $this->assertSame(5, Reflector::getClassAttributes(Fixtures\ParentClass::class, Fixtures\NumAttr::class)->sum->number); + $this->assertSame(5, Reflector::getClassAttributes(Fixtures\ParentClass::class, Fixtures\NumAttr::class, true)->flatten()->sum->number); + + $this->assertSame( + [Fixtures\ChildClass::class, Fixtures\ParentClass::class], + Reflector::getClassAttributes(Fixtures\ChildClass::class, Fixtures\StrAttr::class, true)->keys()->all() + ); + + $this->assertContainsOnlyInstancesOf( + Fixtures\StrAttr::class, + Reflector::getClassAttributes(Fixtures\ChildClass::class, Fixtures\StrAttr::class)->all() + ); + + $this->assertContainsOnlyInstancesOf( + Fixtures\StrAttr::class, + Reflector::getClassAttributes(Fixtures\ChildClass::class, Fixtures\StrAttr::class, true)->flatten()->all() + ); + } + + public function testGetClassAttribute() + { + require_once __DIR__ . '/Fixtures/ClassesWithAttributes.php'; + + $this->assertNull(Reflector::getClassAttribute(Fixtures\ChildClass::class, Fixtures\UnusedAttr::class)); + $this->assertNull(Reflector::getClassAttribute(Fixtures\ChildClass::class, Fixtures\UnusedAttr::class, true)); + $this->assertNull(Reflector::getClassAttribute(Fixtures\ChildClass::class, Fixtures\ParentOnlyAttr::class)); + $this->assertInstanceOf(Fixtures\ParentOnlyAttr::class, Reflector::getClassAttribute(Fixtures\ChildClass::class, Fixtures\ParentOnlyAttr::class, true)); + $this->assertInstanceOf(Fixtures\StrAttr::class, Reflector::getClassAttribute(Fixtures\ChildClass::class, Fixtures\StrAttr::class)); + $this->assertInstanceOf(Fixtures\StrAttr::class, Reflector::getClassAttribute(Fixtures\ChildClass::class, Fixtures\StrAttr::class, true)); + $this->assertSame('quick', Reflector::getClassAttribute(Fixtures\ChildClass::class, Fixtures\StrAttr::class)->string); + $this->assertSame('quick', Reflector::getClassAttribute(Fixtures\ChildClass::class, Fixtures\StrAttr::class, true)->string); + $this->assertSame('lazy', Reflector::getClassAttribute(Fixtures\ParentClass::class, Fixtures\StrAttr::class)->string); + } +} + +class A +{ +} + +class B extends A +{ + public function f(parent $x) + { + } +} + +class C +{ + public function f(A|Model $x) + { + } +} + +class TestClassWithCall +{ + public function __call($method, $parameters) + { + } +} + +class TestClassWithCallStatic +{ + public static function __callStatic($method, $parameters) + { + } +} + +interface IA +{ +} + +interface IB extends IA +{ +} + +class TestClassWithInterfaceSubclassParameter +{ + public function f(IB $x) + { + } +} 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/Support/SupportStrTest.php b/tests/Support/SupportStrTest.php new file mode 100644 index 000000000..099c9faef --- /dev/null +++ b/tests/Support/SupportStrTest.php @@ -0,0 +1,1983 @@ +assertSame('Taylor...', Str::words('Taylor Otwell', 1)); + $this->assertSame('Taylor___', Str::words('Taylor Otwell', 1, '___')); + $this->assertSame('Taylor Otwell', Str::words('Taylor Otwell', 3)); + $this->assertSame('Taylor Otwell', Str::words('Taylor Otwell', -1, '...')); + $this->assertSame('', Str::words('', 3, '...')); + } + + public function testStringCanBeLimitedByWordsNonAscii() + { + $this->assertSame('这是...', Str::words('这是 段中文', 1)); + $this->assertSame('这是___', Str::words('这是 段中文', 1, '___')); + $this->assertSame('这是-段中文', Str::words('这是-段中文', 3, '___')); + $this->assertSame('这是___', Str::words('这是 段中文', 1, '___')); + } + + public function testStringTrimmedOnlyWhereNecessary() + { + $this->assertSame(' Taylor Otwell ', Str::words(' Taylor Otwell ', 3)); + $this->assertSame(' Taylor...', Str::words(' Taylor Otwell ', 1)); + } + + public function testStringTitle() + { + $this->assertSame('Jefferson Costella', Str::title('jefferson costella')); + $this->assertSame('Jefferson Costella', Str::title('jefFErson coSTella')); + + $this->assertSame('', Str::title('')); + $this->assertSame('123 Laravel', Str::title('123 laravel')); + $this->assertSame('❤Laravel', Str::title('❤laravel')); + $this->assertSame('Laravel ❤', Str::title('laravel ❤')); + $this->assertSame('Laravel123', Str::title('laravel123')); + $this->assertSame('Laravel123', Str::title('Laravel123')); + + $longString = 'lorem ipsum ' . str_repeat('dolor sit amet ', 1000); + $expectedResult = 'Lorem Ipsum Dolor Sit Amet ' . str_repeat('Dolor Sit Amet ', 999); + $this->assertSame($expectedResult, Str::title($longString)); + } + + public function testStringHeadline() + { + $this->assertSame('Jefferson Costella', Str::headline('jefferson costella')); + $this->assertSame('Jefferson Costella', Str::headline('jefFErson coSTella')); + $this->assertSame('Jefferson Costella Uses Laravel', Str::headline('jefferson_costella uses-_Laravel')); + $this->assertSame('Jefferson Costella Uses Laravel', Str::headline('jefferson_costella uses__Laravel')); + + $this->assertSame('Laravel P H P Framework', Str::headline('laravel_p_h_p_framework')); + $this->assertSame('Laravel P H P Framework', Str::headline('laravel _p _h _p _framework')); + $this->assertSame('Laravel Php Framework', Str::headline('laravel_php_framework')); + $this->assertSame('Laravel Ph P Framework', Str::headline('laravel-phP-framework')); + $this->assertSame('Laravel Php Framework', Str::headline('laravel -_- php -_- framework ')); + + $this->assertSame('Foo Bar', Str::headline('fooBar')); + $this->assertSame('Foo Bar', Str::headline('foo_bar')); + $this->assertSame('Foo Bar Baz', Str::headline('foo-barBaz')); + $this->assertSame('Foo Bar Baz', Str::headline('foo-bar_baz')); + + $this->assertSame('Öffentliche Überraschungen', Str::headline('öffentliche-überraschungen')); + $this->assertSame('Öffentliche Überraschungen', Str::headline('-_öffentliche_überraschungen_-')); + $this->assertSame('Öffentliche Überraschungen', Str::headline('-öffentliche überraschungen')); + + $this->assertSame('Sind Öde Und So', Str::headline('sindÖdeUndSo')); + + $this->assertSame('Orwell 1984', Str::headline('orwell 1984')); + $this->assertSame('Orwell 1984', Str::headline('orwell 1984')); + $this->assertSame('Orwell 1984', Str::headline('-orwell-1984 -')); + $this->assertSame('Orwell 1984', Str::headline(' orwell_- 1984 ')); + } + + public function testStringApa() + { + $this->assertSame('Tom and Jerry', Str::apa('tom and jerry')); + $this->assertSame('Tom and Jerry', Str::apa('TOM AND JERRY')); + $this->assertSame('Tom and Jerry', Str::apa('Tom And Jerry')); + + $this->assertSame('Back to the Future', Str::apa('back to the future')); + $this->assertSame('Back to the Future', Str::apa('BACK TO THE FUTURE')); + $this->assertSame('Back to the Future', Str::apa('Back To The Future')); + + $this->assertSame('This, Then That', Str::apa('this, then that')); + $this->assertSame('This, Then That', Str::apa('THIS, THEN THAT')); + $this->assertSame('This, Then That', Str::apa('This, Then That')); + + $this->assertSame('Bond. James Bond.', Str::apa('bond. james bond.')); + $this->assertSame('Bond. James Bond.', Str::apa('BOND. JAMES BOND.')); + $this->assertSame('Bond. James Bond.', Str::apa('Bond. James Bond.')); + + $this->assertSame('Self-Report', Str::apa('self-report')); + $this->assertSame('Self-Report', Str::apa('Self-report')); + $this->assertSame('Self-Report', Str::apa('SELF-REPORT')); + + $this->assertSame('As the World Turns, So Are the Days of Our Lives', Str::apa('as the world turns, so are the days of our lives')); + $this->assertSame('As the World Turns, So Are the Days of Our Lives', Str::apa('AS THE WORLD TURNS, SO ARE THE DAYS OF OUR LIVES')); + $this->assertSame('As the World Turns, So Are the Days of Our Lives', Str::apa('As The World Turns, So Are The Days Of Our Lives')); + + $this->assertSame('To Kill a Mockingbird', Str::apa('to kill a mockingbird')); + $this->assertSame('To Kill a Mockingbird', Str::apa('TO KILL A MOCKINGBIRD')); + $this->assertSame('To Kill a Mockingbird', Str::apa('To Kill A Mockingbird')); + + $this->assertSame('Être Écrivain Commence par Être un Lecteur.', Str::apa('Être écrivain commence par être un lecteur.')); + $this->assertSame('Être Écrivain Commence par Être un Lecteur.', Str::apa('Être Écrivain Commence par Être un Lecteur.')); + $this->assertSame('Être Écrivain Commence par Être un Lecteur.', Str::apa('ÊTRE ÉCRIVAIN COMMENCE PAR ÊTRE UN LECTEUR.')); + + $this->assertSame("C'est-à-Dire.", Str::apa("c'est-à-dire.")); + $this->assertSame("C'est-à-Dire.", Str::apa("C'est-à-Dire.")); + $this->assertSame("C'est-à-Dire.", Str::apa("C'EsT-À-DIRE.")); + + $this->assertSame('Устное Слово – Не Воробей. Как Только Он Вылетит, Его Не Поймаешь.', Str::apa('устное слово – не воробей. как только он вылетит, его не поймаешь.')); + $this->assertSame('Устное Слово – Не Воробей. Как Только Он Вылетит, Его Не Поймаешь.', Str::apa('Устное Слово – Не Воробей. Как Только Он Вылетит, Его Не Поймаешь.')); + $this->assertSame('Устное Слово – Не Воробей. Как Только Он Вылетит, Его Не Поймаешь.', Str::apa('УСТНОЕ СЛОВО – НЕ ВОРОБЕЙ. КАК ТОЛЬКО ОН ВЫЛЕТИТ, ЕГО НЕ ПОЙМАЕШЬ.')); + + $this->assertSame('', Str::apa('')); + $this->assertSame(' ', Str::apa(' ')); + } + + public function testStringWithoutWordsDoesntProduceError(): void + { + $nbsp = chr(0xC2) . chr(0xA0); + $this->assertSame(' ', Str::words(' ')); + $this->assertEquals($nbsp, Str::words($nbsp)); + $this->assertSame(' ', Str::words(' ')); + $this->assertSame("\t\t\t", Str::words("\t\t\t")); + } + + public function testStringAscii(): void + { + $this->assertSame('@', Str::ascii('@')); + $this->assertSame('u', Str::ascii('ü')); + $this->assertSame('', Str::ascii('')); + $this->assertSame('a!2e', Str::ascii('a!2ë')); + } + + public function testStringAsciiWithSpecificLocale() + { + $this->assertSame('h H sht Sht a A ia yo', Str::ascii('х Х щ Щ ъ Ъ иа йо', 'bg')); + $this->assertSame('ae oe ue Ae Oe Ue', Str::ascii('ä ö ü Ä Ö Ü', 'de')); + } + + public function testStartsWith() + { + $this->assertTrue(Str::startsWith('jason', 'jas')); + $this->assertTrue(Str::startsWith('jason', 'jason')); + $this->assertTrue(Str::startsWith('jason', ['jas'])); + $this->assertTrue(Str::startsWith('jason', ['day', 'jas'])); + $this->assertTrue(Str::startsWith('jason', collect(['day', 'jas']))); + $this->assertFalse(Str::startsWith('jason', 'day')); + $this->assertFalse(Str::startsWith('jason', ['day'])); + $this->assertFalse(Str::startsWith('jason', null)); + $this->assertFalse(Str::startsWith('jason', [null])); + $this->assertFalse(Str::startsWith('0123', [null])); + $this->assertTrue(Str::startsWith('0123', 0)); + $this->assertFalse(Str::startsWith('jason', 'J')); + $this->assertFalse(Str::startsWith('jason', '')); + $this->assertFalse(Str::startsWith('', '')); + $this->assertFalse(Str::startsWith('7', ' 7')); + $this->assertTrue(Str::startsWith('7a', '7')); + $this->assertTrue(Str::startsWith('7a', 7)); + $this->assertTrue(Str::startsWith('7.12a', 7.12)); + $this->assertFalse(Str::startsWith('7.12a', 7.13)); + $this->assertTrue(Str::startsWith(7.123, '7')); + $this->assertTrue(Str::startsWith(7.123, '7.12')); + $this->assertFalse(Str::startsWith(7.123, '7.13')); + $this->assertFalse(Str::startsWith(null, 'Marc')); + // Test for multibyte string support + $this->assertTrue(Str::startsWith('Jönköping', 'Jö')); + $this->assertTrue(Str::startsWith('Malmö', 'Malmö')); + $this->assertFalse(Str::startsWith('Jönköping', 'Jonko')); + $this->assertFalse(Str::startsWith('Malmö', 'Malmo')); + $this->assertTrue(Str::startsWith('你好', '你')); + $this->assertFalse(Str::startsWith('你好', '好')); + $this->assertFalse(Str::startsWith('你好', 'a')); + } + + public function testDoesntStartWith() + { + $this->assertFalse(Str::doesntStartWith('jason', 'jas')); + $this->assertFalse(Str::doesntStartWith('jason', 'jason')); + $this->assertFalse(Str::doesntStartWith('jason', ['jas'])); + $this->assertFalse(Str::doesntStartWith('jason', ['day', 'jas'])); + $this->assertFalse(Str::doesntStartWith('jason', collect(['day', 'jas']))); + $this->assertTrue(Str::doesntStartWith('jason', 'day')); + $this->assertTrue(Str::doesntStartWith('jason', ['day'])); + $this->assertTrue(Str::doesntStartWith('jason', null)); + $this->assertTrue(Str::doesntStartWith('jason', [null])); + $this->assertTrue(Str::doesntStartWith('0123', [null])); + $this->assertFalse(Str::doesntStartWith('0123', 0)); + $this->assertTrue(Str::doesntStartWith('jason', 'J')); + $this->assertTrue(Str::doesntStartWith('jason', '')); + $this->assertTrue(Str::doesntStartWith('', '')); + $this->assertTrue(Str::doesntStartWith('7', ' 7')); + $this->assertFalse(Str::doesntStartWith('7a', '7')); + $this->assertFalse(Str::doesntStartWith('7a', 7)); + $this->assertFalse(Str::doesntStartWith('7.12a', 7.12)); + $this->assertTrue(Str::doesntStartWith('7.12a', 7.13)); + $this->assertFalse(Str::doesntStartWith(7.123, '7')); + $this->assertFalse(Str::doesntStartWith(7.123, '7.12')); + $this->assertTrue(Str::doesntStartWith(7.123, '7.13')); + $this->assertTrue(Str::doesntStartWith(null, 'Marc')); + // Test for multibyte string support + $this->assertFalse(Str::doesntStartWith('Jönköping', 'Jö')); + $this->assertFalse(Str::doesntStartWith('Malmö', 'Malmö')); + $this->assertTrue(Str::doesntStartWith('Jönköping', 'Jonko')); + $this->assertTrue(Str::doesntStartWith('Malmö', 'Malmo')); + $this->assertFalse(Str::doesntStartWith('你好', '你')); + $this->assertTrue(Str::doesntStartWith('你好', '好')); + $this->assertTrue(Str::doesntStartWith('你好', 'a')); + } + + public function testEndsWith() + { + $this->assertTrue(Str::endsWith('jason', 'on')); + $this->assertTrue(Str::endsWith('jason', 'jason')); + $this->assertTrue(Str::endsWith('jason', ['on'])); + $this->assertTrue(Str::endsWith('jason', ['no', 'on'])); + $this->assertTrue(Str::endsWith('jason', collect(['no', 'on']))); + $this->assertFalse(Str::endsWith('jason', 'no')); + $this->assertFalse(Str::endsWith('jason', ['no'])); + $this->assertFalse(Str::endsWith('jason', '')); + $this->assertFalse(Str::endsWith('', '')); + $this->assertFalse(Str::endsWith('jason', [null])); + $this->assertFalse(Str::endsWith('jason', null)); + $this->assertFalse(Str::endsWith('jason', 'N')); + $this->assertFalse(Str::endsWith('7', ' 7')); + $this->assertTrue(Str::endsWith('a7', '7')); + $this->assertTrue(Str::endsWith('a7', 7)); + $this->assertTrue(Str::endsWith('a7.12', 7.12)); + $this->assertFalse(Str::endsWith('a7.12', 7.13)); + $this->assertTrue(Str::endsWith(0.27, '7')); + $this->assertTrue(Str::endsWith(0.27, '0.27')); + $this->assertFalse(Str::endsWith(0.27, '8')); + $this->assertFalse(Str::endsWith(null, 'Marc')); + // Test for multibyte string support + $this->assertTrue(Str::endsWith('Jönköping', 'öping')); + $this->assertTrue(Str::endsWith('Malmö', 'mö')); + $this->assertFalse(Str::endsWith('Jönköping', 'oping')); + $this->assertFalse(Str::endsWith('Malmö', 'mo')); + $this->assertTrue(Str::endsWith('你好', '好')); + $this->assertFalse(Str::endsWith('你好', '你')); + $this->assertFalse(Str::endsWith('你好', 'a')); + } + + public function testDoesntEndWith() + { + $this->assertFalse(Str::doesntEndWith('jason', 'on')); + $this->assertFalse(Str::doesntEndWith('jason', 'jason')); + $this->assertFalse(Str::doesntEndWith('jason', ['on'])); + $this->assertFalse(Str::doesntEndWith('jason', ['no', 'on'])); + $this->assertFalse(Str::doesntEndWith('jason', collect(['no', 'on']))); + $this->assertTrue(Str::doesntEndWith('jason', 'no')); + $this->assertTrue(Str::doesntEndWith('jason', ['no'])); + $this->assertTrue(Str::doesntEndWith('jason', '')); + $this->assertTrue(Str::doesntEndWith('', '')); + $this->assertTrue(Str::doesntEndWith('jason', [null])); + $this->assertTrue(Str::doesntEndWith('jason', null)); + $this->assertTrue(Str::doesntEndWith('jason', 'N')); + $this->assertTrue(Str::doesntEndWith('7', ' 7')); + $this->assertFalse(Str::doesntEndWith('a7', '7')); + $this->assertFalse(Str::doesntEndWith('a7', 7)); + $this->assertFalse(Str::doesntEndWith('a7.12', 7.12)); + $this->assertTrue(Str::doesntEndWith('a7.12', 7.13)); + $this->assertFalse(Str::doesntEndWith(0.27, '7')); + $this->assertFalse(Str::doesntEndWith(0.27, '0.27')); + $this->assertTrue(Str::doesntEndWith(0.27, '8')); + $this->assertTrue(Str::doesntEndWith(null, 'Marc')); + // Test for multibyte string support + $this->assertFalse(Str::doesntEndWith('Jönköping', 'öping')); + $this->assertFalse(Str::doesntEndWith('Malmö', 'mö')); + $this->assertTrue(Str::doesntEndWith('Jönköping', 'oping')); + $this->assertTrue(Str::doesntEndWith('Malmö', 'mo')); + $this->assertFalse(Str::doesntEndWith('你好', '好')); + $this->assertTrue(Str::doesntEndWith('你好', '你')); + $this->assertTrue(Str::doesntEndWith('你好', 'a')); + } + + public function testStrExcerpt() + { + $this->assertSame('...is a beautiful morn...', Str::excerpt('This is a beautiful morning', 'beautiful', ['radius' => 5])); + $this->assertSame('This is a...', Str::excerpt('This is a beautiful morning', 'this', ['radius' => 5])); + $this->assertSame('...iful morning', Str::excerpt('This is a beautiful morning', 'morning', ['radius' => 5])); + $this->assertNull(Str::excerpt('This is a beautiful morning', 'day')); + $this->assertSame('...is a beautiful! mor...', Str::excerpt('This is a beautiful! morning', 'Beautiful', ['radius' => 5])); + $this->assertSame('...is a beautiful? mor...', Str::excerpt('This is a beautiful? morning', 'beautiful', ['radius' => 5])); + $this->assertSame('', Str::excerpt('', '', ['radius' => 0])); + $this->assertSame('a', Str::excerpt('a', 'a', ['radius' => 0])); + $this->assertSame('...b...', Str::excerpt('abc', 'B', ['radius' => 0])); + $this->assertSame('abc', Str::excerpt('abc', 'b', ['radius' => 1])); + $this->assertSame('abc...', Str::excerpt('abcd', 'b', ['radius' => 1])); + $this->assertSame('...abc', Str::excerpt('zabc', 'b', ['radius' => 1])); + $this->assertSame('...abc...', Str::excerpt('zabcd', 'b', ['radius' => 1])); + $this->assertSame('zabcd', Str::excerpt('zabcd', 'b', ['radius' => 2])); + $this->assertSame('zabcd', Str::excerpt(' zabcd ', 'b', ['radius' => 4])); + $this->assertSame('...abc...', Str::excerpt('z abc d', 'b', ['radius' => 1])); + $this->assertSame('[...]is a beautiful morn[...]', Str::excerpt('This is a beautiful morning', 'beautiful', ['omission' => '[...]', 'radius' => 5])); + $this->assertSame( + 'This is the ultimate supercalifragilisticexpialidocious very looooooooooooooooooong looooooooooooong beautiful morning with amazing sunshine and awesome tempera[...]', + Str::excerpt( + 'This is the ultimate supercalifragilisticexpialidocious very looooooooooooooooooong looooooooooooong beautiful morning with amazing sunshine and awesome temperatures. So what are you gonna do about it?', + 'very', + ['omission' => '[...]'], + ) + ); + + $this->assertSame('...y...', Str::excerpt('taylor', 'y', ['radius' => 0])); + $this->assertSame('...ayl...', Str::excerpt('taylor', 'Y', ['radius' => 1])); + $this->assertSame('
The article description
', Str::excerpt('
The article description
', 'article')); + $this->assertSame('...The article desc...', Str::excerpt('
The article description
', 'article', ['radius' => 5])); + $this->assertSame('The article description', Str::excerpt(strip_tags('
The article description
'), 'article')); + $this->assertSame('', Str::excerpt(null)); + $this->assertSame('', Str::excerpt('')); + $this->assertSame('', Str::excerpt(null, '')); + $this->assertSame('T...', Str::excerpt('The article description', null, ['radius' => 1])); + $this->assertSame('The arti...', Str::excerpt('The article description', '', ['radius' => 8])); + $this->assertSame('', Str::excerpt(' ')); + $this->assertSame('The arti...', Str::excerpt('The article description', ' ', ['radius' => 4])); + $this->assertSame('...cle description', Str::excerpt('The article description', 'description', ['radius' => 4])); + $this->assertSame('T...', Str::excerpt('The article description', 'T', ['radius' => 0])); + $this->assertSame('What i?', Str::excerpt('What is the article?', 'What', ['radius' => 2, 'omission' => '?'])); + + $this->assertSame('...ö - 二 sān 大åè...', Str::excerpt('åèö - 二 sān 大åèö', '二 sān', ['radius' => 4])); + $this->assertSame('åèö - 二...', Str::excerpt('åèö - 二 sān 大åèö', 'åèö', ['radius' => 4])); + $this->assertSame('åèö - 二 sān 大åèö', Str::excerpt('åèö - 二 sān 大åèö', 'åèö - 二 sān 大åèö', ['radius' => 4])); + $this->assertSame('åèö - 二 sān 大åèö', Str::excerpt('åèö - 二 sān 大åèö', 'åèö - 二 sān 大åèö', ['radius' => 4])); + $this->assertSame('...༼...', Str::excerpt('㏗༼㏗', '༼', ['radius' => 0])); + $this->assertSame('...༼...', Str::excerpt('㏗༼㏗', '༼', ['radius' => 0])); + $this->assertSame('...ocê e...', Str::excerpt('Como você está', 'ê', ['radius' => 2])); + $this->assertSame('...ocê e...', Str::excerpt('Como você está', 'Ê', ['radius' => 2])); + $this->assertSame('João...', Str::excerpt('João Antônio ', 'jo', ['radius' => 2])); + $this->assertSame('João Antô...', Str::excerpt('João Antônio', 'JOÃO', ['radius' => 5])); + $this->assertNull(Str::excerpt('', '/')); + } + + public function testStrBefore(): void + { + $this->assertSame('han', Str::before('hannah', 'nah')); + $this->assertSame('ha', Str::before('hannah', 'n')); + $this->assertSame('ééé ', Str::before('ééé hannah', 'han')); + $this->assertSame('hannah', Str::before('hannah', 'xxxx')); + $this->assertSame('hannah', Str::before('hannah', '')); + $this->assertSame('han', Str::before('han0nah', '0')); + $this->assertSame('han', Str::before('han0nah', 0)); + $this->assertSame('han', Str::before('han2nah', 2)); + $this->assertSame('', Str::before('', '')); + $this->assertSame('', Str::before('', 'a')); + $this->assertSame('', Str::before('a', 'a')); + $this->assertSame('foo', Str::before('foo@bar.com', '@')); + $this->assertSame('foo', Str::before('foo@@bar.com', '@')); + $this->assertSame('', Str::before('@foo@bar.com', '@')); + } + + public function testStrBeforeLast(): void + { + $this->assertSame('yve', Str::beforeLast('yvette', 'tte')); + $this->assertSame('yvet', Str::beforeLast('yvette', 't')); + $this->assertSame('ééé ', Str::beforeLast('ééé yvette', 'yve')); + $this->assertSame('', Str::beforeLast('yvette', 'yve')); + $this->assertSame('yvette', Str::beforeLast('yvette', 'xxxx')); + $this->assertSame('yvette', Str::beforeLast('yvette', '')); + $this->assertSame('yv0et', Str::beforeLast('yv0et0te', '0')); + $this->assertSame('yv0et', Str::beforeLast('yv0et0te', 0)); + $this->assertSame('yv2et', Str::beforeLast('yv2et2te', 2)); + $this->assertSame('', Str::beforeLast('', 'test')); + $this->assertSame('', Str::beforeLast('yvette', 'yvette')); + $this->assertSame('laravel', Str::beforeLast('laravel framework', ' ')); + $this->assertSame('yvette', Str::beforeLast("yvette\tyv0et0te", "\t")); + } + + public function testStrBetween(): void + { + $this->assertSame('abc', Str::between('abc', '', 'c')); + $this->assertSame('abc', Str::between('abc', 'a', '')); + $this->assertSame('abc', Str::between('abc', '', '')); + $this->assertSame('b', Str::between('abc', 'a', 'c')); + $this->assertSame('b', Str::between('dddabc', 'a', 'c')); + $this->assertSame('b', Str::between('abcddd', 'a', 'c')); + $this->assertSame('b', Str::between('dddabcddd', 'a', 'c')); + $this->assertSame('nn', Str::between('hannah', 'ha', 'ah')); + $this->assertSame('a]ab[b', Str::between('[a]ab[b]', '[', ']')); + $this->assertSame('foo', Str::between('foofoobar', 'foo', 'bar')); + $this->assertSame('bar', Str::between('foobarbar', 'foo', 'bar')); + $this->assertSame('234', Str::between('12345', 1, 5)); + $this->assertSame('45', Str::between('123456789', '123', '6789')); + $this->assertSame('nothing', Str::between('nothing', 'foo', 'bar')); + } + + public function testStrBetweenFirst() + { + $this->assertSame('abc', Str::betweenFirst('abc', '', 'c')); + $this->assertSame('abc', Str::betweenFirst('abc', 'a', '')); + $this->assertSame('abc', Str::betweenFirst('abc', '', '')); + $this->assertSame('b', Str::betweenFirst('abc', 'a', 'c')); + $this->assertSame('b', Str::betweenFirst('dddabc', 'a', 'c')); + $this->assertSame('b', Str::betweenFirst('abcddd', 'a', 'c')); + $this->assertSame('b', Str::betweenFirst('dddabcddd', 'a', 'c')); + $this->assertSame('nn', Str::betweenFirst('hannah', 'ha', 'ah')); + $this->assertSame('a', Str::betweenFirst('[a]ab[b]', '[', ']')); + $this->assertSame('foo', Str::betweenFirst('foofoobar', 'foo', 'bar')); + $this->assertSame('', Str::betweenFirst('foobarbar', 'foo', 'bar')); + } + + public function testStrAfter() + { + $this->assertSame('nah', Str::after('hannah', 'han')); + $this->assertSame('nah', Str::after('hannah', 'n')); + $this->assertSame('nah', Str::after('ééé hannah', 'han')); + $this->assertSame('hannah', Str::after('hannah', 'xxxx')); + $this->assertSame('hannah', Str::after('hannah', '')); + $this->assertSame('nah', Str::after('han0nah', '0')); + $this->assertSame('nah', Str::after('han0nah', 0)); + $this->assertSame('nah', Str::after('han2nah', 2)); + } + + public function testStrAfterLast() + { + $this->assertSame('tte', Str::afterLast('yvette', 'yve')); + $this->assertSame('e', Str::afterLast('yvette', 't')); + $this->assertSame('e', Str::afterLast('ééé yvette', 't')); + $this->assertSame('', Str::afterLast('yvette', 'tte')); + $this->assertSame('yvette', Str::afterLast('yvette', 'xxxx')); + $this->assertSame('yvette', Str::afterLast('yvette', '')); + $this->assertSame('te', Str::afterLast('yv0et0te', '0')); + $this->assertSame('te', Str::afterLast('yv0et0te', 0)); + $this->assertSame('te', Str::afterLast('yv2et2te', 2)); + $this->assertSame('foo', Str::afterLast('----foo', '---')); + // Test with multibyte characters in search string + $this->assertSame('', Str::afterLast('café au café', 'café')); + $this->assertSame('', Str::afterLast('こんにちは世界こんにちは', 'こんにちは')); + } + + #[DataProvider('strContainsProvider')] + public function testStrContains($haystack, $needles, $expected, $ignoreCase = false) + { + $this->assertEquals($expected, Str::contains($haystack, $needles, $ignoreCase)); + } + + public static function strContainsProvider() + { + return [ + ['Taylor', 'ylo', true, true], + ['Taylor', 'ylo', true, false], + ['Taylor', 'taylor', true, true], + ['Taylor', 'taylor', false, false], + ['Taylor', ['ylo'], true, true], + ['Taylor', ['ylo'], true, false], + ['Taylor', ['xxx', 'ylo'], true, true], + ['Taylor', collect(['xxx', 'ylo']), true, true], + ['Taylor', ['xxx', 'ylo'], true, false], + ['Taylor', 'xxx', false], + ['Taylor', ['xxx'], false], + ['Taylor', '', false], + ['', '', false], + ]; + } + + #[DataProvider('strContainsAllProvider')] + public function testStrContainsAll($haystack, $needles, $expected, $ignoreCase = false) + { + $this->assertEquals($expected, Str::containsAll($haystack, $needles, $ignoreCase)); + } + + public static function strContainsAllProvider() + { + return [ + ['Taylor Otwell', ['taylor', 'otwell'], false, false], + ['Taylor Otwell', ['taylor', 'otwell'], true, true], + ['Taylor Otwell', ['taylor'], false, false], + ['Taylor Otwell', ['taylor'], true, true], + ['Taylor Otwell', ['taylor', 'xxx'], false, false], + ['Taylor Otwell', ['taylor', 'xxx'], false, true], + ]; + } + + #[DataProvider('strDoesntContainProvider')] + public function testStrDoesntContain($haystack, $needles, $expected, $ignoreCase = false) + { + $this->assertEquals($expected, Str::doesntContain($haystack, $needles, $ignoreCase)); + } + + public static function strDoesntContainProvider() + { + return [ + ['Tar', 'ylo', true, true], + ]; + } + + public function testConvertCase() + { + // Upper Case Conversion + $this->assertSame('HELLO', Str::convertCase('hello', MB_CASE_UPPER)); + $this->assertSame('WORLD', Str::convertCase('WORLD', MB_CASE_UPPER)); + + // Lower Case Conversion + $this->assertSame('hello', Str::convertCase('HELLO', MB_CASE_LOWER)); + $this->assertSame('world', Str::convertCase('WORLD', MB_CASE_LOWER)); + + // Case Folding + $this->assertSame('hello', Str::convertCase('HeLLo', MB_CASE_FOLD)); + $this->assertSame('world', Str::convertCase('WoRLD', MB_CASE_FOLD)); + + // Multi-byte String + $this->assertSame('ÜÖÄ', Str::convertCase('üöä', MB_CASE_UPPER, 'UTF-8')); + $this->assertSame('üöä', Str::convertCase('ÜÖÄ', MB_CASE_LOWER, 'UTF-8')); + + // Unsupported Mode + $this->expectException(ValueError::class); + Str::convertCase('Hello', -1); + } + + public function testDedup() + { + $this->assertSame(' laravel php framework ', Str::deduplicate(' laravel php framework ')); + $this->assertSame('what', Str::deduplicate('whaaat', 'a')); + $this->assertSame('/some/odd/path/', Str::deduplicate('/some//odd//path/', '/')); + $this->assertSame('ムだム', Str::deduplicate('ムだだム', 'だ')); + $this->assertSame(' laravel forever ', Str::deduplicate(' laravell foreverrr ', [' ', 'l', 'r'])); + } + + public function testParseCallback() + { + $this->assertEquals(['Class', 'method'], Str::parseCallback('Class@method')); + $this->assertEquals(['Class', 'method'], Str::parseCallback('Class@method', 'foo')); + $this->assertEquals(['Class', 'foo'], Str::parseCallback('Class', 'foo')); + $this->assertEquals(['Class', null], Str::parseCallback('Class')); + + $this->assertEquals(["Class@anonymous\0/laravel/382.php:8$2ec", 'method'], Str::parseCallback("Class@anonymous\0/laravel/382.php:8$2ec@method")); + $this->assertEquals(["Class@anonymous\0/laravel/382.php:8$2ec", 'method'], Str::parseCallback("Class@anonymous\0/laravel/382.php:8$2ec@method", 'foo')); + $this->assertEquals(["Class@anonymous\0/laravel/382.php:8$2ec", 'foo'], Str::parseCallback("Class@anonymous\0/laravel/382.php:8$2ec", 'foo')); + $this->assertEquals(["Class@anonymous\0/laravel/382.php:8$2ec", null], Str::parseCallback("Class@anonymous\0/laravel/382.php:8$2ec")); + } + + public function testSlug() + { + $this->assertSame('hello-world', Str::slug('hello world')); + $this->assertSame('hello-world', Str::slug('hello-world')); + $this->assertSame('hello-world', Str::slug('hello_world')); + $this->assertSame('hello_world', Str::slug('hello_world', '_')); + $this->assertSame('user-at-host', Str::slug('user@host')); + $this->assertSame('سلام-دنیا', Str::slug('سلام دنیا', '-', null)); + $this->assertSame('sometext', Str::slug('some text', '')); + $this->assertSame('', Str::slug('', '')); + $this->assertSame('', Str::slug('')); + $this->assertSame('bsm-allah', Str::slug('بسم الله', '-', 'en', ['allh' => 'allah'])); + $this->assertSame('500-dollar-bill', Str::slug('500$ bill', '-', 'en', ['$' => 'dollar'])); + $this->assertSame('500-dollar-bill', Str::slug('500--$----bill', '-', 'en', ['$' => 'dollar'])); + $this->assertSame('500-dollar-bill', Str::slug('500-$-bill', '-', 'en', ['$' => 'dollar'])); + $this->assertSame('500-dollar-bill', Str::slug('500$--bill', '-', 'en', ['$' => 'dollar'])); + $this->assertSame('500-dollar-bill', Str::slug('500-$--bill', '-', 'en', ['$' => 'dollar'])); + $this->assertSame('أحمد-في-المدرسة', Str::slug('أحمد@المدرسة', '-', null, ['@' => 'في'])); + } + + public function testStrStart() + { + $this->assertSame('/test/string', Str::start('test/string', '/')); + $this->assertSame('/test/string', Str::start('/test/string', '/')); + $this->assertSame('/test/string', Str::start('//test/string', '/')); + } + + public function testFlushCache() + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Str::flushCache() is not implemented in Hypervel because Str casing caches are intentionally not used. Use StrCache for persistent casing caching.'); + + Str::flushCache(); + } + + public function testFinish() + { + $this->assertSame('abbc', Str::finish('ab', 'bc')); + $this->assertSame('abbc', Str::finish('abbcbc', 'bc')); + $this->assertSame('abcbbc', Str::finish('abcbbcbc', 'bc')); + } + + public function testWrap() + { + $this->assertEquals('"value"', Str::wrap('value', '"')); + $this->assertEquals('foo-bar-baz', Str::wrap('-bar-', 'foo', 'baz')); + } + + public function testWrapEdgeCases() + { + $this->assertSame('[]mid[]', Str::wrap('mid', '[]')); + $this->assertSame('(mid', Str::wrap('mid', '(', '')); + $this->assertSame('assertSame('value', Str::wrap('value', '')); + $this->assertSame('[][]', Str::wrap('', '[]')); + $this->assertSame('«値»', Str::wrap('値', '«', '»')); + $this->assertSame('🧪X🧪', Str::wrap('X', '🧪')); + } + + public function testUnwrap() + { + $this->assertEquals('value', Str::unwrap('"value"', '"')); + $this->assertEquals('value', Str::unwrap('"value', '"')); + $this->assertEquals('value', Str::unwrap('value"', '"')); + $this->assertEquals('bar', Str::unwrap('foo-bar-baz', 'foo-', '-baz')); + $this->assertEquals('some: "json"', Str::unwrap('{some: "json"}', '{', '}')); + } + + public function testIs() + { + $this->assertTrue(Str::is('/', '/')); + $this->assertFalse(Str::is('/', ' /')); + $this->assertFalse(Str::is('/', '/a')); + $this->assertTrue(Str::is('foo/*', 'foo/bar/baz')); + + $this->assertTrue(Str::is('*@*', 'App\Class@method')); + $this->assertTrue(Str::is('*@*', 'app\Class@')); + $this->assertTrue(Str::is('*@*', '@method')); + + // is case sensitive + $this->assertFalse(Str::is('*BAZ*', 'foo/bar/baz')); + $this->assertFalse(Str::is('*FOO*', 'foo/bar/baz')); + $this->assertFalse(Str::is('A', 'a')); + + // is not case sensitive + $this->assertTrue(Str::is('A', 'a', true)); + $this->assertTrue(Str::is('*BAZ*', 'foo/bar/baz', true)); + $this->assertTrue(Str::is(['A*', 'B*'], 'a/', true)); + $this->assertFalse(Str::is(['A*', 'B*'], 'f/', true)); + $this->assertTrue(Str::is('FOO', 'foo', true)); + $this->assertTrue(Str::is('*FOO*', 'foo/bar/baz', true)); + $this->assertTrue(Str::is('foo/*', 'FOO/bar', true)); + + // Accepts array of patterns + $this->assertTrue(Str::is(['a*', 'b*'], 'a/')); + $this->assertTrue(Str::is(['a*', 'b*'], 'b/')); + $this->assertFalse(Str::is(['a*', 'b*'], 'f/')); + + // numeric values and patterns + $this->assertFalse(Str::is(['a*', 'b*'], 123)); + $this->assertTrue(Str::is(['*2*', 'b*'], 11211)); + + $this->assertTrue(Str::is('*/foo', 'blah/baz/foo')); + + $valueObject = new StringableObjectStub('foo/bar/baz'); + $patternObject = new StringableObjectStub('foo/*'); + + $this->assertTrue(Str::is('foo/bar/baz', $valueObject)); + $this->assertTrue(Str::is($patternObject, $valueObject)); + + // empty patterns + $this->assertFalse(Str::is([], 'test')); + + $this->assertFalse(Str::is('', 0)); + $this->assertFalse(Str::is([null], 0)); + $this->assertTrue(Str::is([null], null)); + } + + public function testIsWithMultilineStrings() + { + $this->assertFalse(Str::is('/', "/\n")); + $this->assertTrue(Str::is('/*', "/\n")); + $this->assertTrue(Str::is('*/*', "/\n")); + $this->assertTrue(Str::is('*/*', "\n/\n")); + + $this->assertTrue(Str::is('*', "\n")); + $this->assertTrue(Str::is('*', "\n\n")); + $this->assertFalse(Str::is('', "\n")); + $this->assertFalse(Str::is('', "\n\n")); + + $multilineValue = <<<'VALUE' + assertTrue(Str::is($multilineValue, $multilineValue)); + $this->assertTrue(Str::is('*', $multilineValue)); + $this->assertTrue(Str::is('*namespace Illuminate\Tests\*', $multilineValue)); + $this->assertFalse(Str::is('namespace Illuminate\Tests\*', $multilineValue)); + $this->assertFalse(Str::is('*namespace Illuminate\Tests', $multilineValue)); + $this->assertTrue(Str::is('assertTrue(Str::is('assertFalse(Str::is('use Exception;', $multilineValue)); + $this->assertFalse(Str::is('use Exception;*', $multilineValue)); + $this->assertTrue(Str::is('*use Exception;', $multilineValue)); + + $this->assertTrue(Str::is("assertTrue(Str::is(<<<'PATTERN' + assertTrue(Str::is(<<<'PATTERN' + assertTrue(Str::isUrl('https://laravel.com')); + $this->assertTrue(Str::isUrl('http://localhost')); + $this->assertFalse(Str::isUrl('invalid url')); + } + + #[DataProvider('validUuidList')] + public function testIsUuidWithValidUuid($uuid) + { + $this->assertTrue(Str::isUuid($uuid)); + } + + public static function validUuidList() + { + return [ + ['a0a2a2d2-0b87-4a18-83f2-2529882be2de'], + ['145a1e72-d11d-11e8-a8d5-f2801f1b9fd1'], + ['00000000-0000-0000-0000-000000000000'], + ['e60d3f48-95d7-4d8d-aad0-856f29a27da2'], + ['ff6f8cb0-c57d-11e1-9b21-0800200c9a66'], + ['ff6f8cb0-c57d-21e1-9b21-0800200c9a66'], + ['ff6f8cb0-c57d-31e1-9b21-0800200c9a66'], + ['ff6f8cb0-c57d-41e1-9b21-0800200c9a66'], + ['ff6f8cb0-c57d-51e1-9b21-0800200c9a66'], + ['FF6F8CB0-C57D-11E1-9B21-0800200C9A66'], + ]; + } + + #[DataProvider('invalidUuidList')] + public function testIsUuidWithInvalidUuid($uuid) + { + $this->assertFalse(Str::isUuid($uuid)); + } + + public static function invalidUuidList() + { + return [ + ['not a valid uuid so we can test this'], + ['zf6f8cb0-c57d-11e1-9b21-0800200c9a66'], + ['145a1e72-d11d-11e8-a8d5-f2801f1b9fd1' . PHP_EOL], + ['145a1e72-d11d-11e8-a8d5-f2801f1b9fd1 '], + [' 145a1e72-d11d-11e8-a8d5-f2801f1b9fd1'], + ['145a1e72-d11d-11e8-a8d5-f2z01f1b9fd1'], + ['3f6f8cb0-c57d-11e1-9b21-0800200c9a6'], + ['af6f8cb-c57d-11e1-9b21-0800200c9a66'], + ['af6f8cb0c57d11e19b210800200c9a66'], + ['ff6f8cb0-c57da-51e1-9b21-0800200c9a66'], + ]; + } + + #[DataProvider('uuidVersionList')] + public function testIsUuidWithVersion($uuid, $version, $passes) + { + $this->assertSame(Str::isUuid($uuid, $version), $passes); + } + + public static function uuidVersionList() + { + return [ + ['00000000-0000-0000-0000-000000000000', null, true], + ['00000000-0000-0000-0000-000000000000', 0, true], + ['00000000-0000-0000-0000-000000000000', 1, false], + ['00000000-0000-0000-0000-000000000000', 42, false], + ['145a1e72-d11d-11e8-a8d5-f2801f1b9fd1', null, true], + ['145a1e72-d11d-11e8-a8d5-f2801f1b9fd1', 1, true], + ['145a1e72-d11d-11e8-a8d5-f2801f1b9fd1', 4, false], + ['145a1e72-d11d-11e8-a8d5-f2801f1b9fd1', 42, false], + ['ff6f8cb0-c57d-21e1-9b21-0800200c9a66', null, true], + ['ff6f8cb0-c57d-21e1-9b21-0800200c9a66', 1, false], + ['ff6f8cb0-c57d-21e1-9b21-0800200c9a66', 2, true], + ['ff6f8cb0-c57d-21e1-9b21-0800200c9a66', 42, false], + ['76a4ba72-cc4e-3e1d-b52d-856382f408c3', null, true], + ['76a4ba72-cc4e-3e1d-b52d-856382f408c3', 1, false], + ['76a4ba72-cc4e-3e1d-b52d-856382f408c3', 3, true], + ['76a4ba72-cc4e-3e1d-b52d-856382f408c3', 42, false], + ['a0a2a2d2-0b87-4a18-83f2-2529882be2de', null, true], + ['a0a2a2d2-0b87-4a18-83f2-2529882be2de', 1, false], + ['a0a2a2d2-0b87-4a18-83f2-2529882be2de', 4, true], + ['a0a2a2d2-0b87-4a18-83f2-2529882be2de', 42, false], + ['d3b2b5a9-d433-5c58-b038-4fa13696e357', null, true], + ['d3b2b5a9-d433-5c58-b038-4fa13696e357', 1, false], + ['d3b2b5a9-d433-5c58-b038-4fa13696e357', 5, true], + ['d3b2b5a9-d433-5c58-b038-4fa13696e357', 42, false], + ['1ef97d97-b5ab-67d8-9f12-5600051f1387', null, true], + ['1ef97d97-b5ab-67d8-9f12-5600051f1387', 1, false], + ['1ef97d97-b5ab-67d8-9f12-5600051f1387', 6, true], + ['1ef97d97-b5ab-67d8-9f12-5600051f1387', 42, false], + ['0192e4b9-92eb-7aec-8707-1becfb1e3eb7', null, true], + ['0192e4b9-92eb-7aec-8707-1becfb1e3eb7', 1, false], + ['0192e4b9-92eb-7aec-8707-1becfb1e3eb7', 7, true], + ['0192e4b9-92eb-7aec-8707-1becfb1e3eb7', 42, false], + ['07e80a1f-1629-831f-811f-c595103c91b5', null, true], + ['07e80a1f-1629-831f-811f-c595103c91b5', 1, false], + ['07e80a1f-1629-831f-811f-c595103c91b5', 8, true], + ['07e80a1f-1629-831f-811f-c595103c91b5', 42, false], + ['FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFFF', null, true], + ['FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFFF', 1, false], + ['FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFFF', 42, false], + ['FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFFF', 'max', true], + ['a0a2a2d2-0b87-4a18-83f2-2529882be2de', null, true], + ['a0a2a2d2-0b87-4a18-83f2-2529882be2de', 1, false], + ['a0a2a2d2-0b87-4a18-83f2-2529882be2de', 4, true], + ['a0a2a2d2-0b87-4a18-83f2-2529882be2de', 42, false], + ['zf6f8cb0-c57d-11e1-9b21-0800200c9a66', null, false], + ['zf6f8cb0-c57d-11e1-9b21-0800200c9a66', 1, false], + ['zf6f8cb0-c57d-11e1-9b21-0800200c9a66', 4, false], + ['zf6f8cb0-c57d-11e1-9b21-0800200c9a66', 42, false], + ]; + } + + public function testIsJson() + { + $this->assertTrue(Str::isJson('1')); + $this->assertTrue(Str::isJson('[1,2,3]')); + $this->assertTrue(Str::isJson('[1, 2, 3]')); + $this->assertTrue(Str::isJson('{"first": "John", "last": "Doe"}')); + $this->assertTrue(Str::isJson('[{"first": "John", "last": "Doe"}, {"first": "Jane", "last": "Doe"}]')); + + $this->assertFalse(Str::isJson('1,')); + $this->assertFalse(Str::isJson('[1,2,3')); + $this->assertFalse(Str::isJson('[1, 2 3]')); + $this->assertFalse(Str::isJson('{first: "John"}')); + $this->assertFalse(Str::isJson('[{first: "John"}, {first: "Jane"}]')); + $this->assertFalse(Str::isJson('')); + $this->assertFalse(Str::isJson(null)); + $this->assertFalse(Str::isJson([])); + } + + public function testIsMatch() + { + $this->assertTrue(Str::isMatch('/.*,.*!/', 'Hello, Laravel!')); + $this->assertTrue(Str::isMatch('/^.*$(.*)/', 'Hello, Laravel!')); + $this->assertTrue(Str::isMatch('/laravel/i', 'Hello, Laravel!')); + $this->assertTrue(Str::isMatch('/^(.*(.*(.*)))/', 'Hello, Laravel!')); + + $this->assertFalse(Str::isMatch('/H.o/', 'Hello, Laravel!')); + $this->assertFalse(Str::isMatch('/^laravel!/i', 'Hello, Laravel!')); + $this->assertFalse(Str::isMatch('/laravel!(.*)/', 'Hello, Laravel!')); + $this->assertFalse(Str::isMatch('/^[a-zA-Z,!]+$/', 'Hello, Laravel!')); + + $this->assertTrue(Str::isMatch(['/.*,.*!/', '/H.o/'], 'Hello, Laravel!')); + $this->assertTrue(Str::isMatch(['/^laravel!/i', '/^.*$(.*)/'], 'Hello, Laravel!')); + $this->assertTrue(Str::isMatch(['/laravel/i', '/laravel!(.*)/'], 'Hello, Laravel!')); + $this->assertTrue(Str::isMatch(['/^[a-zA-Z,!]+$/', '/^(.*(.*(.*)))/'], 'Hello, Laravel!')); + } + + public function testKebab() + { + $this->assertSame('laravel-php-framework', Str::kebab('LaravelPhpFramework')); + $this->assertSame('laravel-php-framework', Str::kebab('Laravel Php Framework')); + $this->assertSame('laravel❤-php-framework', Str::kebab('Laravel ❤ Php Framework')); + $this->assertSame('', Str::kebab('')); + } + + public function testLower() + { + $this->assertSame('foo bar baz', Str::lower('FOO BAR BAZ')); + $this->assertSame('foo bar baz', Str::lower('fOo Bar bAz')); + } + + public function testUpper() + { + $this->assertSame('FOO BAR BAZ', Str::upper('foo bar baz')); + $this->assertSame('FOO BAR BAZ', Str::upper('foO bAr BaZ')); + } + + public function testLimit() + { + $this->assertSame('Laravel is...', Str::limit('Laravel is a free, open source PHP web application framework.', 10)); + $this->assertSame('这是一...', Str::limit('这是一段中文', 6)); + $this->assertSame('Laravel is a...', Str::limit('Laravel is a free, open source PHP web application framework.', 15, preserveWords: true)); + + $string = 'The PHP framework for web artisans.'; + $this->assertSame('The PHP...', Str::limit($string, 7)); + $this->assertSame('The PHP...', Str::limit($string, 10, preserveWords: true)); + $this->assertSame('The PHP', Str::limit($string, 7, '')); + $this->assertSame('The PHP', Str::limit($string, 10, '', true)); + $this->assertSame('The PHP framework for web artisans.', Str::limit($string, 100)); + $this->assertSame('The PHP framework for web artisans.', Str::limit($string, 100, preserveWords: true)); + $this->assertSame('The PHP framework...', Str::limit($string, 20, preserveWords: true)); + + $nonAsciiString = '这是一段中文'; + $this->assertSame('这是一...', Str::limit($nonAsciiString, 6)); + $this->assertSame('这是一...', Str::limit($nonAsciiString, 6, preserveWords: true)); + $this->assertSame('这是一', Str::limit($nonAsciiString, 6, '')); + $this->assertSame('这是一', Str::limit($nonAsciiString, 6, '', true)); + } + + public function testLength() + { + $this->assertEquals(11, Str::length('foo bar baz')); + $this->assertEquals(11, Str::length('foo bar baz', 'UTF-8')); + } + + public function testNumbers() + { + $this->assertSame('5551234567', Str::numbers('(555) 123-4567')); + $this->assertSame('443', Str::numbers('L4r4v3l!')); + $this->assertSame('', Str::numbers('Laravel!')); + + $arrayValue = ['(555) 123-4567', 'L4r4v3l', 'Laravel!']; + $arrayExpected = ['5551234567', '443', '']; + $this->assertSame($arrayExpected, Str::numbers($arrayValue)); + } + + public function testRandom() + { + $this->assertEquals(16, strlen(Str::random())); + $randomInteger = random_int(1, 100); + $this->assertEquals($randomInteger, strlen(Str::random($randomInteger))); + $this->assertIsString(Str::random()); + } + + public function testWhetherTheNumberOfGeneratedCharactersIsEquallyDistributed() + { + $results = []; + // take 6.200.000 samples, because there are 62 different characters + for ($i = 0; $i < 620000; ++$i) { + $random = Str::random(1); + $results[$random] = ($results[$random] ?? 0) + 1; + } + + // each character should occur 100.000 times with a variance of 5%. + foreach ($results as $result) { + $this->assertEqualsWithDelta(10000, $result, 500); + } + } + + public function testRandomStringFactoryCanBeSet() + { + Str::createRandomStringsUsing(fn ($length) => 'length:' . $length); + + $this->assertSame('length:7', Str::random(7)); + $this->assertSame('length:7', Str::random(7)); + + Str::createRandomStringsNormally(); + + $this->assertNotSame('length:7', Str::random()); + } + + public function testItCanSpecifyASequenceOfRandomStringsToUtilise() + { + Str::createRandomStringsUsingSequence([ + 0 => 'x', + // 1 => just generate a random one here... + 2 => 'y', + 3 => 'z', + // ... => continue to generate random strings... + ]); + + $this->assertSame('x', Str::random()); + $this->assertSame(16, mb_strlen(Str::random())); + $this->assertSame('y', Str::random()); + $this->assertSame('z', Str::random()); + $this->assertSame(16, mb_strlen(Str::random())); + $this->assertSame(16, mb_strlen(Str::random())); + + Str::createRandomStringsNormally(); + } + + public function testItCanSpecifyAFallbackForARandomStringSequence() + { + Str::createRandomStringsUsingSequence([Str::random(), Str::random()], fn () => throw new Exception('Out of random strings.')); + Str::random(); + Str::random(); + + try { + $this->expectExceptionMessage('Out of random strings.'); + Str::random(); + $this->fail(); + } finally { + Str::createRandomStringsNormally(); + } + } + + public function testReplace() + { + $this->assertSame('foo bar laravel', Str::replace('baz', 'laravel', 'foo bar baz')); + $this->assertSame('foo bar laravel', Str::replace('baz', 'laravel', 'foo bar Baz', false)); + $this->assertSame('foo bar baz 8.x', Str::replace('?', '8.x', 'foo bar baz ?')); + $this->assertSame('foo bar baz 8.x', Str::replace('x', '8.x', 'foo bar baz X', false)); + $this->assertSame('foo/bar/baz', Str::replace(' ', '/', 'foo bar baz')); + $this->assertSame('foo bar baz', Str::replace(['?1', '?2', '?3'], ['foo', 'bar', 'baz'], '?1 ?2 ?3')); + $this->assertSame(['foo', 'bar', 'baz'], Str::replace(collect(['?1', '?2', '?3']), collect(['foo', 'bar', 'baz']), collect(['?1', '?2', '?3']))); + } + + public function testReplaceArray() + { + $this->assertSame('foo/bar/baz', Str::replaceArray('?', ['foo', 'bar', 'baz'], '?/?/?')); + $this->assertSame('foo/bar/baz/?', Str::replaceArray('?', ['foo', 'bar', 'baz'], '?/?/?/?')); + $this->assertSame('foo/bar', Str::replaceArray('?', ['foo', 'bar', 'baz'], '?/?')); + $this->assertSame('?/?/?', Str::replaceArray('x', ['foo', 'bar', 'baz'], '?/?/?')); + // Ensure recursive replacements are avoided + $this->assertSame('foo?/bar/baz', Str::replaceArray('?', ['foo?', 'bar', 'baz'], '?/?/?')); + // Test for associative array support + $this->assertSame('foo/bar', Str::replaceArray('?', [1 => 'foo', 2 => 'bar'], '?/?')); + $this->assertSame('foo/bar', Str::replaceArray('?', ['x' => 'foo', 'y' => 'bar'], '?/?')); + // Test does not crash on bad input + $this->assertSame('?', Str::replaceArray('?', [(object) ['foo' => 'bar']], '?')); + } + + public function testReplaceFirst() + { + $this->assertSame('fooqux foobar', Str::replaceFirst('bar', 'qux', 'foobar foobar')); + $this->assertSame('foo/qux? foo/bar?', Str::replaceFirst('bar?', 'qux?', 'foo/bar? foo/bar?')); + $this->assertSame('foo foobar', Str::replaceFirst('bar', '', 'foobar foobar')); + $this->assertSame('foobar foobar', Str::replaceFirst('xxx', 'yyy', 'foobar foobar')); + $this->assertSame('foobar foobar', Str::replaceFirst('', 'yyy', 'foobar foobar')); + $this->assertSame('1', Str::replaceFirst(0, '1', '0')); + // Test for multibyte string support + $this->assertSame('Jxxxnköping Malmö', Str::replaceFirst('ö', 'xxx', 'Jönköping Malmö')); + $this->assertSame('Jönköping Malmö', Str::replaceFirst('', 'yyy', 'Jönköping Malmö')); + } + + public function testReplaceStart() + { + $this->assertSame('foobar foobar', Str::replaceStart('bar', 'qux', 'foobar foobar')); + $this->assertSame('foo/bar? foo/bar?', Str::replaceStart('bar?', 'qux?', 'foo/bar? foo/bar?')); + $this->assertSame('quxbar foobar', Str::replaceStart('foo', 'qux', 'foobar foobar')); + $this->assertSame('qux? foo/bar?', Str::replaceStart('foo/bar?', 'qux?', 'foo/bar? foo/bar?')); + $this->assertSame('bar foobar', Str::replaceStart('foo', '', 'foobar foobar')); + $this->assertSame('1', Str::replaceStart(0, '1', '0')); + // Test for multibyte string support + $this->assertSame('xxxnköping Malmö', Str::replaceStart('Jö', 'xxx', 'Jönköping Malmö')); + $this->assertSame('Jönköping Malmö', Str::replaceStart('', 'yyy', 'Jönköping Malmö')); + } + + public function testReplaceLast() + { + $this->assertSame('foobar fooqux', Str::replaceLast('bar', 'qux', 'foobar foobar')); + $this->assertSame('foo/bar? foo/qux?', Str::replaceLast('bar?', 'qux?', 'foo/bar? foo/bar?')); + $this->assertSame('foobar foo', Str::replaceLast('bar', '', 'foobar foobar')); + $this->assertSame('foobar foobar', Str::replaceLast('xxx', 'yyy', 'foobar foobar')); + $this->assertSame('foobar foobar', Str::replaceLast('', 'yyy', 'foobar foobar')); + // Test for multibyte string support + $this->assertSame('Malmö Jönkxxxping', Str::replaceLast('ö', 'xxx', 'Malmö Jönköping')); + $this->assertSame('Malmö Jönköping', Str::replaceLast('', 'yyy', 'Malmö Jönköping')); + } + + public function testReplaceEnd() + { + $this->assertSame('foobar fooqux', Str::replaceEnd('bar', 'qux', 'foobar foobar')); + $this->assertSame('foo/bar? foo/qux?', Str::replaceEnd('bar?', 'qux?', 'foo/bar? foo/bar?')); + $this->assertSame('foobar foo', Str::replaceEnd('bar', '', 'foobar foobar')); + $this->assertSame('foobar foobar', Str::replaceEnd('xxx', 'yyy', 'foobar foobar')); + $this->assertSame('foobar foobar', Str::replaceEnd('', 'yyy', 'foobar foobar')); + $this->assertSame('fooxxx foobar', Str::replaceEnd('xxx', 'yyy', 'fooxxx foobar')); + + // // Test for multibyte string support + $this->assertSame('Malmö Jönköping', Str::replaceEnd('ö', 'xxx', 'Malmö Jönköping')); + $this->assertSame('Malmö Jönkyyy', Str::replaceEnd('öping', 'yyy', 'Malmö Jönköping')); + } + + public function testRemove() + { + $this->assertSame('Fbar', Str::remove('o', 'Foobar')); + $this->assertSame('Foo', Str::remove('bar', 'Foobar')); + $this->assertSame('oobar', Str::remove('F', 'Foobar')); + $this->assertSame('Foobar', Str::remove('f', 'Foobar')); + $this->assertSame('oobar', Str::remove('f', 'Foobar', false)); + + $this->assertSame('Fbr', Str::remove(['o', 'a'], 'Foobar')); + $this->assertSame('Fooar', Str::remove(['f', 'b'], 'Foobar')); + $this->assertSame('ooar', Str::remove(['f', 'b'], 'Foobar', false)); + $this->assertSame('Foobar', Str::remove(['f', '|'], 'Foo|bar')); + } + + public function testReverse() + { + $this->assertSame('FooBar', Str::reverse('raBooF')); + $this->assertSame('Teniszütő', Str::reverse('őtüzsineT')); + $this->assertSame('❤MultiByte☆', Str::reverse('☆etyBitluM❤')); + } + + public function testSnake() + { + $this->assertSame('laravel_p_h_p_framework', Str::snake('LaravelPHPFramework')); + $this->assertSame('laravel_php_framework', Str::snake('LaravelPhpFramework')); + $this->assertSame('laravel php framework', Str::snake('LaravelPhpFramework', ' ')); + $this->assertSame('laravel_php_framework', Str::snake('Laravel Php Framework')); + $this->assertSame('laravel_php_framework', Str::snake('Laravel Php Framework ')); + // ensure cache keys don't overlap + $this->assertSame('laravel__php__framework', Str::snake('LaravelPhpFramework', '__')); + $this->assertSame('laravel_php_framework_', Str::snake('LaravelPhpFramework_', '_')); + $this->assertSame('laravel_php_framework', Str::snake('laravel php Framework')); + $this->assertSame('laravel_php_frame_work', Str::snake('laravel php FrameWork')); + // prevent breaking changes + $this->assertSame('foo-bar', Str::snake('foo-bar')); + $this->assertSame('foo-_bar', Str::snake('Foo-Bar')); + $this->assertSame('foo__bar', Str::snake('Foo_Bar')); + $this->assertSame('żółtałódka', Str::snake('ŻółtaŁódka')); + } + + public function testTrim() + { + $this->assertSame('foo bar', Str::trim(' foo bar ')); + $this->assertSame('foo bar', Str::trim('foo bar ')); + $this->assertSame('foo bar', Str::trim(' foo bar')); + $this->assertSame('foo bar', Str::trim('foo bar')); + $this->assertSame(' foo bar ', Str::trim(' foo bar ', '')); + $this->assertSame('foo bar', Str::trim(' foo bar ', ' ')); + $this->assertSame('foo bar', Str::trim('-foo bar_', '-_')); + + $this->assertSame('foo bar', Str::trim(' foo bar ')); + + $this->assertSame('123', Str::trim('  123   ')); + $this->assertSame('だ', Str::trim('だ')); + $this->assertSame('ム', Str::trim('ム')); + $this->assertSame('だ', Str::trim('  だ   ')); + $this->assertSame('ム', Str::trim('  ム   ')); + + $this->assertSame( + 'foo bar', + Str::trim(' + foo bar + ') + ); + $this->assertSame( + 'foo + bar', + Str::trim(' + foo + bar + ') + ); + + $this->assertSame("\xE9", Str::trim(" \xE9 ")); + + $trimDefaultChars = [' ', "\n", "\r", "\t", "\v", "\0"]; + + foreach ($trimDefaultChars as $char) { + $this->assertSame('', Str::trim(" {$char} ")); + $this->assertSame(trim(" {$char} "), Str::trim(" {$char} ")); + + $this->assertSame('foo bar', Str::trim("{$char} foo bar {$char}")); + $this->assertSame(trim("{$char} foo bar {$char}"), Str::trim("{$char} foo bar {$char}")); + } + } + + public function testLtrim() + { + $this->assertSame('foo bar ', Str::ltrim(' foo bar ')); + + $this->assertSame('123   ', Str::ltrim('  123   ')); + $this->assertSame('だ', Str::ltrim('だ')); + $this->assertSame('ム', Str::ltrim('ム')); + $this->assertSame('だ   ', Str::ltrim('  だ   ')); + $this->assertSame('ム   ', Str::ltrim('  ム   ')); + + $this->assertSame( + 'foo bar + ', + Str::ltrim(' + foo bar + ') + ); + $this->assertSame("\xE9 ", Str::ltrim(" \xE9 ")); + + $ltrimDefaultChars = [' ', "\n", "\r", "\t", "\v", "\0"]; + + foreach ($ltrimDefaultChars as $char) { + $this->assertSame('', Str::ltrim(" {$char} ")); + $this->assertSame(ltrim(" {$char} "), Str::ltrim(" {$char} ")); + + $this->assertSame("foo bar {$char}", Str::ltrim("{$char} foo bar {$char}")); + $this->assertSame(ltrim("{$char} foo bar {$char}"), Str::ltrim("{$char} foo bar {$char}")); + } + } + + public function testRtrim() + { + $this->assertSame(' foo bar', Str::rtrim(' foo bar ')); + + $this->assertSame('  123', Str::rtrim('  123   ')); + $this->assertSame('だ', Str::rtrim('だ')); + $this->assertSame('ム', Str::rtrim('ム')); + $this->assertSame('  だ', Str::rtrim('  だ   ')); + $this->assertSame('  ム', Str::rtrim('  ム   ')); + + $this->assertSame( + ' + foo bar', + Str::rtrim(' + foo bar + ') + ); + + $this->assertSame(" \xE9", Str::rtrim(" \xE9 ")); + + $rtrimDefaultChars = [' ', "\n", "\r", "\t", "\v", "\0"]; + + foreach ($rtrimDefaultChars as $char) { + $this->assertSame('', Str::rtrim(" {$char} ")); + $this->assertSame(rtrim(" {$char} "), Str::rtrim(" {$char} ")); + + $this->assertSame("{$char} foo bar", Str::rtrim("{$char} foo bar {$char}")); + $this->assertSame(rtrim("{$char} foo bar {$char}"), Str::rtrim("{$char} foo bar {$char}")); + } + } + + public function testSquish() + { + $this->assertSame('laravel php framework', Str::squish(' laravel php framework ')); + $this->assertSame('laravel php framework', Str::squish("laravel\t\tphp\n\nframework")); + $this->assertSame('laravel php framework', Str::squish(' + laravel + php + framework + ')); + $this->assertSame('laravel php framework', Str::squish('   laravel   php   framework   ')); + $this->assertSame('123', Str::squish('  123   ')); + $this->assertSame('だ', Str::squish('だ')); + $this->assertSame('ム', Str::squish('ム')); + $this->assertSame('だ', Str::squish('  だ   ')); + $this->assertSame('ム', Str::squish('  ム   ')); + $this->assertSame('laravel php framework', Str::squish('laravelㅤㅤㅤphpㅤframework')); + $this->assertSame('laravel php framework', Str::squish('laravelᅠᅠᅠᅠᅠᅠᅠᅠᅠᅠphpᅠᅠframework')); + } + + public function testStudly() + { + $this->assertSame('LaravelPHPFramework', Str::studly('laravel_p_h_p_framework')); + $this->assertSame('LaravelPhpFramework', Str::studly('laravel_php_framework')); + $this->assertSame('LaravelPhPFramework', Str::studly('laravel-phP-framework')); + $this->assertSame('LaravelPhpFramework', Str::studly('laravel -_- php -_- framework ')); + + $this->assertSame('FooBar', Str::studly('fooBar')); + $this->assertSame('FooBar', Str::studly('foo_bar')); + $this->assertSame('FooBar', Str::studly('foo_bar')); // test cache + $this->assertSame('FooBarBaz', Str::studly('foo-barBaz')); + $this->assertSame('FooBarBaz', Str::studly('foo-bar_baz')); + + $this->assertSame('ÖffentlicheÜberraschungen', Str::studly('öffentliche-überraschungen')); + } + + public function testPascal() + { + $this->assertSame('LaravelPhpFramework', Str::pascal('laravel_php_framework')); + $this->assertSame('LaravelPhpFramework', Str::pascal('laravel-php-framework')); + $this->assertSame('LaravelPhpFramework', Str::pascal('laravel -_- php -_- framework ')); + + $this->assertSame('FooBar', Str::pascal('fooBar')); + $this->assertSame('FooBar', Str::pascal('foo_bar')); + $this->assertSame('FooBar', Str::pascal('foo_bar')); // test cache + $this->assertSame('FooBarBaz', Str::pascal('foo-barBaz')); + $this->assertSame('FooBarBaz', Str::pascal('foo-bar_baz')); + + $this->assertSame('ÖffentlicheÜberraschungen', Str::pascal('öffentliche-überraschungen')); + } + + public function testMask() + { + $this->assertSame('tay*************', Str::mask('taylor@email.com', '*', 3)); + $this->assertSame('******@email.com', Str::mask('taylor@email.com', '*', 0, 6)); + $this->assertSame('tay*************', Str::mask('taylor@email.com', '*', -13)); + $this->assertSame('tay***@email.com', Str::mask('taylor@email.com', '*', -13, 3)); + + $this->assertSame('****************', Str::mask('taylor@email.com', '*', -17)); + $this->assertSame('*****r@email.com', Str::mask('taylor@email.com', '*', -99, 5)); + + $this->assertSame('taylor@email.com', Str::mask('taylor@email.com', '*', 16)); + $this->assertSame('taylor@email.com', Str::mask('taylor@email.com', '*', 16, 99)); + + $this->assertSame('taylor@email.com', Str::mask('taylor@email.com', '', 3)); + + $this->assertSame('taysssssssssssss', Str::mask('taylor@email.com', 'something', 3)); + $this->assertSame('taysssssssssssss', Str::mask('taylor@email.com', Str::of('something'), 3)); + + $this->assertSame('这是一***', Str::mask('这是一段中文', '*', 3)); + $this->assertSame('**一段中文', Str::mask('这是一段中文', '*', 0, 2)); + + $this->assertSame('ma*n@email.com', Str::mask('maan@email.com', '*', 2, 1)); + $this->assertSame('ma***email.com', Str::mask('maan@email.com', '*', 2, 3)); + $this->assertSame('ma************', Str::mask('maan@email.com', '*', 2)); + + $this->assertSame('mari*@email.com', Str::mask('maria@email.com', '*', 4, 1)); + $this->assertSame('tamar*@email.com', Str::mask('tamara@email.com', '*', 5, 1)); + + $this->assertSame('*aria@email.com', Str::mask('maria@email.com', '*', 0, 1)); + $this->assertSame('maria@email.co*', Str::mask('maria@email.com', '*', -1, 1)); + $this->assertSame('maria@email.co*', Str::mask('maria@email.com', '*', -1)); + $this->assertSame('***************', Str::mask('maria@email.com', '*', -15)); + $this->assertSame('***************', Str::mask('maria@email.com', '*', 0)); + } + + public function testMatch(): void + { + $this->assertSame('bar', Str::match('/bar/', 'foo bar')); + $this->assertSame('bar', Str::match('/foo (.*)/', 'foo bar')); + $this->assertEmpty(Str::match('/nothing/', 'foo bar')); + + $this->assertEquals(['bar', 'bar'], Str::matchAll('/bar/', 'bar foo bar')->all()); + + $this->assertEquals(['un', 'ly'], Str::matchAll('/f(\w*)/', 'bar fun bar fly')->all()); + $this->assertEmpty(Str::matchAll('/nothing/', 'bar fun bar fly')); + + $this->assertEmpty(Str::match('/pattern/', '')); + $this->assertEmpty(Str::matchAll('/pattern/', '')); + } + + public function testCamel(): void + { + $this->assertSame('laravelPHPFramework', Str::camel('Laravel_p_h_p_framework')); + $this->assertSame('laravelPhpFramework', Str::camel('Laravel_php_framework')); + $this->assertSame('laravelPhPFramework', Str::camel('Laravel-phP-framework')); + $this->assertSame('laravelPhpFramework', Str::camel('Laravel -_- php -_- framework ')); + + $this->assertSame('fooBar', Str::camel('FooBar')); + $this->assertSame('fooBar', Str::camel('foo_bar')); + $this->assertSame('fooBar', Str::camel('foo_bar')); // test cache + $this->assertSame('fooBarBaz', Str::camel('Foo-barBaz')); + $this->assertSame('fooBarBaz', Str::camel('foo-bar_baz')); + + $this->assertSame('', Str::camel('')); + $this->assertSame('lARAVELPHPFRAMEWORK', Str::camel('LARAVEL_PHP_FRAMEWORK')); + $this->assertSame('laravelPhpFramework', Str::camel(' laravel php framework ')); + + $this->assertSame('foo1Bar', Str::camel('foo1_bar')); + $this->assertSame('1FooBar', Str::camel('1 foo bar')); + } + + public function testCharAt() + { + $this->assertEquals('р', Str::charAt('Привет, мир!', 1)); + $this->assertEquals('ち', Str::charAt('「こんにちは世界」', 4)); + $this->assertEquals('w', Str::charAt('Привет, world!', 8)); + $this->assertEquals('界', Str::charAt('「こんにちは世界」', -2)); + $this->assertEquals(null, Str::charAt('「こんにちは世界」', -200)); + $this->assertEquals(null, Str::charAt('Привет, мир!', 100)); + } + + public function testSubstr() + { + $this->assertSame('Ё', Str::substr('БГДЖИЛЁ', -1)); + $this->assertSame('ЛЁ', Str::substr('БГДЖИЛЁ', -2)); + $this->assertSame('И', Str::substr('БГДЖИЛЁ', -3, 1)); + $this->assertSame('ДЖИЛ', Str::substr('БГДЖИЛЁ', 2, -1)); + $this->assertEmpty(Str::substr('БГДЖИЛЁ', 4, -4)); + $this->assertSame('ИЛ', Str::substr('БГДЖИЛЁ', -3, -1)); + $this->assertSame('ГДЖИЛЁ', Str::substr('БГДЖИЛЁ', 1)); + $this->assertSame('ГДЖ', Str::substr('БГДЖИЛЁ', 1, 3)); + $this->assertSame('БГДЖ', Str::substr('БГДЖИЛЁ', 0, 4)); + $this->assertSame('Ё', Str::substr('БГДЖИЛЁ', -1, 1)); + $this->assertEmpty(Str::substr('Б', 2)); + } + + public function testSubstrCount() + { + $this->assertSame(3, Str::substrCount('laravelPHPFramework', 'a')); + $this->assertSame(0, Str::substrCount('laravelPHPFramework', 'z')); + $this->assertSame(1, Str::substrCount('laravelPHPFramework', 'l', 2)); + $this->assertSame(0, Str::substrCount('laravelPHPFramework', 'z', 2)); + $this->assertSame(1, Str::substrCount('laravelPHPFramework', 'k', -1)); + $this->assertSame(1, Str::substrCount('laravelPHPFramework', 'k', -1)); + $this->assertSame(1, Str::substrCount('laravelPHPFramework', 'a', 1, 2)); + $this->assertSame(1, Str::substrCount('laravelPHPFramework', 'a', 1, 2)); + $this->assertSame(3, Str::substrCount('laravelPHPFramework', 'a', 1, -2)); + $this->assertSame(1, Str::substrCount('laravelPHPFramework', 'a', -10, -3)); + } + + public function testPosition() + { + $this->assertSame(7, Str::position('Hello, World!', 'W')); + $this->assertSame(10, Str::position('This is a test string.', 'test')); + $this->assertSame(23, Str::position('This is a test string, test again.', 'test', 15)); + $this->assertSame(0, Str::position('Hello, World!', 'Hello')); + $this->assertSame(7, Str::position('Hello, World!', 'World!')); + $this->assertSame(10, Str::position('This is a tEsT string.', 'tEsT', 0, 'UTF-8')); + $this->assertSame(7, Str::position('Hello, World!', 'W', -6)); + $this->assertSame(18, Str::position('Äpfel, Birnen und Kirschen', 'Kirschen', -10, 'UTF-8')); + $this->assertSame(9, Str::position('@%€/=!"][$', '$', 0, 'UTF-8')); + $this->assertFalse(Str::position('Hello, World!', 'w', 0, 'UTF-8')); + $this->assertFalse(Str::position('Hello, World!', 'X', 0, 'UTF-8')); + $this->assertFalse(Str::position('', 'test')); + $this->assertFalse(Str::position('Hello, World!', 'X')); + } + + public function testSubstrReplace() + { + $this->assertSame('12:00', Str::substrReplace('1200', ':', 2, 0)); + $this->assertSame('The Laravel Framework', Str::substrReplace('The Framework', 'Laravel ', 4, 0)); + $this->assertSame('Laravel – The PHP Framework for Web Artisans', Str::substrReplace('Laravel Framework', '– The PHP Framework for Web Artisans', 8)); + } + + public function testSubstrReplaceWithMultibyte() + { + $this->assertSame('kengä', Str::substrReplace('kenkä', 'ng', -3, 2)); + $this->assertSame('kenga', Str::substrReplace('kenka', 'ng', -3, 2)); + } + + public function testTake() + { + $this->assertSame('ab', Str::take('abcdef', 2)); + $this->assertSame('ef', Str::take('abcdef', -2)); + $this->assertSame('', Str::take('abcdef', 0)); + $this->assertSame('', Str::take('', 2)); + $this->assertSame('abcdef', Str::take('abcdef', 10)); + $this->assertSame('abcdef', Str::take('abcdef', 6)); + $this->assertSame('ü', Str::take('üöä', 1)); + } + + public function testLcfirst() + { + $this->assertSame('laravel', Str::lcfirst('Laravel')); + $this->assertSame('laravel framework', Str::lcfirst('Laravel framework')); + $this->assertSame('мама', Str::lcfirst('Мама')); + $this->assertSame('мама мыла раму', Str::lcfirst('Мама мыла раму')); + } + + public function testUcfirst() + { + $this->assertSame('Laravel', Str::ucfirst('laravel')); + $this->assertSame('Laravel framework', Str::ucfirst('laravel framework')); + $this->assertSame('Мама', Str::ucfirst('мама')); + $this->assertSame('Мама мыла раму', Str::ucfirst('мама мыла раму')); + } + + public function testUcwords() + { + $this->assertSame('Laravel', Str::ucwords('laravel')); + $this->assertSame('Laravel Framework', Str::ucwords('laravel framework')); + $this->assertSame('Laravel-Framework', Str::ucwords('laravel-framework', '-')); + $this->assertSame('Мама', Str::ucwords('мама')); + $this->assertSame('Мама Мыла Раму', Str::ucwords('мама мыла раму')); + $this->assertSame('JJ Watt', Str::ucwords('JJ watt')); + } + + public function testUcsplit() + { + $this->assertSame(['Laravel_p_h_p_framework'], Str::ucsplit('Laravel_p_h_p_framework')); + $this->assertSame(['Laravel_', 'P_h_p_framework'], Str::ucsplit('Laravel_P_h_p_framework')); + $this->assertSame(['laravel', 'P', 'H', 'P', 'Framework'], Str::ucsplit('laravelPHPFramework')); + $this->assertSame(['Laravel-ph', 'P-framework'], Str::ucsplit('Laravel-phP-framework')); + + $this->assertSame(['Żółta', 'Łódka'], Str::ucsplit('ŻółtaŁódka')); + $this->assertSame(['sind', 'Öde', 'Und', 'So'], Str::ucsplit('sindÖdeUndSo')); + $this->assertSame(['Öffentliche', 'Überraschungen'], Str::ucsplit('ÖffentlicheÜberraschungen')); + } + + public function testUuid() + { + $this->assertInstanceOf(UuidInterface::class, Str::uuid()); + $this->assertInstanceOf(UuidInterface::class, Str::orderedUuid()); + $this->assertInstanceOf(UuidInterface::class, Str::uuid7()); + } + + public function testAsciiNull() + { + $this->assertSame('', Str::ascii(null)); + $this->assertTrue(Str::isAscii(null)); + $this->assertSame('', Str::slug(null)); + } + + public function testPadBoth() + { + $this->assertSame('__Alien___', Str::padBoth('Alien', 10, '_')); + $this->assertSame(' Alien ', Str::padBoth('Alien', 10)); + $this->assertSame(' ❤MultiByte☆ ', Str::padBoth('❤MultiByte☆', 16)); + $this->assertSame('❤☆❤MultiByte☆❤☆❤', Str::padBoth('❤MultiByte☆', 16, '❤☆')); + } + + public function testPadLeft() + { + $this->assertSame('-=-=-Alien', Str::padLeft('Alien', 10, '-=')); + $this->assertSame(' Alien', Str::padLeft('Alien', 10)); + $this->assertSame(' ❤MultiByte☆', Str::padLeft('❤MultiByte☆', 16)); + $this->assertSame('❤☆❤☆❤❤MultiByte☆', Str::padLeft('❤MultiByte☆', 16, '❤☆')); + } + + public function testPadRight() + { + $this->assertSame('Alien-=-=-', Str::padRight('Alien', 10, '-=')); + $this->assertSame('Alien ', Str::padRight('Alien', 10)); + $this->assertSame('❤MultiByte☆ ', Str::padRight('❤MultiByte☆', 16)); + $this->assertSame('❤MultiByte☆❤☆❤☆❤', Str::padRight('❤MultiByte☆', 16, '❤☆')); + } + + public function testSwapKeywords(): void + { + $this->assertSame( + 'PHP 8 is fantastic', + Str::swap([ + 'PHP' => 'PHP 8', + 'awesome' => 'fantastic', + ], 'PHP is awesome') + ); + + $this->assertSame( + 'foo bar baz', + Str::swap([ + 'ⓐⓑ' => 'baz', + ], 'foo bar ⓐⓑ') + ); + } + + public function testWordCount() + { + $this->assertEquals(2, Str::wordCount('Hello, world!')); + $this->assertEquals(10, Str::wordCount('Hi, this is my first contribution to the Laravel framework.')); + + $this->assertEquals(0, Str::wordCount('мама')); + $this->assertEquals(0, Str::wordCount('мама мыла раму')); + + $this->assertEquals(1, Str::wordCount('мама', 'абвгдеёжзийклмнопрстуфхцчшщъыьэюяАБВГДЕЁЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯ')); + $this->assertEquals(3, Str::wordCount('мама мыла раму', 'абвгдеёжзийклмнопрстуфхцчшщъыьэюяАБВГДЕЁЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯ')); + + $this->assertEquals(1, Str::wordCount('МАМА', 'абвгдеёжзийклмнопрстуфхцчшщъыьэюяАБВГДЕЁЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯ')); + $this->assertEquals(3, Str::wordCount('МАМА МЫЛА РАМУ', 'абвгдеёжзийклмнопрстуфхцчшщъыьэюяАБВГДЕЁЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯ')); + } + + public function testWordWrap() + { + $this->assertEquals('Hello
World', Str::wordWrap('Hello World', 3, '
')); + $this->assertEquals('Hel
lo
Wor
ld', Str::wordWrap('Hello World', 3, '
', true)); + + $this->assertEquals('❤Multi
Byte☆❤☆❤☆❤', Str::wordWrap('❤Multi Byte☆❤☆❤☆❤', 3, '
')); + } + + public function testMarkdown() + { + $this->assertSame("

hello world

\n", Str::markdown('*hello world*')); + $this->assertSame("

hello world

\n", Str::markdown('# hello world')); + } + + public function testInlineMarkdown() + { + $this->assertSame("hello world\n", Str::inlineMarkdown('*hello world*')); + $this->assertSame("Laravel\n", Str::inlineMarkdown('[**Laravel**](https://laravel.com)')); + } + + public function testRepeat() + { + $this->assertSame('', Str::repeat('Hello', 0)); + $this->assertSame('Hello', Str::repeat('Hello', 1)); + $this->assertSame('aaaaa', Str::repeat('a', 5)); + $this->assertSame('', Str::repeat('', 5)); + } + + public function testRepeatWhenTimesIsNegative() + { + $this->expectException(ValueError::class); + Str::repeat('Hello', -2); + } + + #[DataProvider('specialCharacterProvider')] + public function testTransliterate(string $value, string $expected): void + { + $this->assertSame($expected, Str::transliterate($value)); + } + + public static function specialCharacterProvider(): array + { + return [ + ['ⓐⓑⓒⓓⓔⓕⓖⓗⓘⓙⓚⓛⓜⓝⓞⓟⓠⓡⓢⓣⓤⓥⓦⓧⓨⓩ', 'abcdefghijklmnopqrstuvwxyz'], + ['⓪①②③④⑤⑥⑦⑧⑨⑩⑪⑫⑬⑭⑮⑯⑰⑱⑲⑳', '01234567891011121314151617181920'], + ['⓵⓶⓷⓸⓹⓺⓻⓼⓽⓾', '12345678910'], + ['⓿⓫⓬⓭⓮⓯⓰⓱⓲⓳⓴', '011121314151617181920'], + ['ⓣⓔⓢⓣ@ⓛⓐⓡⓐⓥⓔⓛ.ⓒⓞⓜ', 'test@laravel.com'], + ['🎂', '?'], + ['abcdefghijklmnopqrstuvwxyz', 'abcdefghijklmnopqrstuvwxyz'], + ['0123456789', '0123456789'], + ]; + } + + public function testTransliterateOverrideUnknown(): void + { + $this->assertSame('HHH', Str::transliterate('🎂🚧🏆', 'H')); + $this->assertSame('Hello', Str::transliterate('🎂', 'Hello')); + } + + #[DataProvider('specialCharacterProvider')] + public function testTransliterateStrict(string $value, string $expected): void + { + $this->assertSame($expected, Str::transliterate($value, '?', true)); + } + + public function testItCanFreezeUuids() + { + $this->assertNotSame((string) Str::uuid(), (string) Str::uuid()); + $this->assertNotSame(Str::uuid(), Str::uuid()); + + $uuid = Str::freezeUuids(); + + $this->assertSame($uuid, Str::uuid()); + $this->assertSame(Str::uuid(), Str::uuid()); + $this->assertSame((string) $uuid, (string) Str::uuid()); + $this->assertSame((string) Str::uuid(), (string) Str::uuid()); + + Str::createUuidsNormally(); + + $this->assertNotSame(Str::uuid(), Str::uuid()); + $this->assertNotSame((string) Str::uuid(), (string) Str::uuid()); + } + + public function testItCanFreezeUuidsInAClosure() + { + $uuids = []; + + $uuid = Str::freezeUuids(function ($uuid) use (&$uuids) { + $uuids[] = $uuid; + $uuids[] = Str::uuid(); + $uuids[] = Str::uuid(); + }); + + $this->assertSame($uuid, $uuids[0]); + $this->assertSame((string) $uuid, (string) $uuids[0]); + $this->assertSame((string) $uuids[0], (string) $uuids[1]); + $this->assertSame($uuids[0], $uuids[1]); + $this->assertSame((string) $uuids[0], (string) $uuids[1]); + $this->assertSame($uuids[1], $uuids[2]); + $this->assertSame((string) $uuids[1], (string) $uuids[2]); + $this->assertNotSame(Str::uuid(), Str::uuid()); + $this->assertNotSame((string) Str::uuid(), (string) Str::uuid()); + + Str::createUuidsNormally(); + } + + public function testItCreatesUuidsNormallyAfterFailureWithinFreezeMethod() + { + $frozenUuid = Uuid::fromString('00000000-0000-0000-0000-000000000123'); + + try { + Str::freezeUuids(function () use ($frozenUuid) { + Str::createUuidsUsing(fn () => $frozenUuid); + $this->assertSame($frozenUuid->toString(), Str::uuid()->toString()); + throw new Exception('Something failed.'); + }); + } catch (Exception) { + $this->assertNotSame($frozenUuid->toString(), Str::uuid()->toString()); + } + } + + public function testItCanSpecifyASequenceOfUuidsToUtilise() + { + Str::createUuidsUsingSequence([ + 0 => ($zeroth = Str::uuid()), + 1 => ($first = Str::uuid7()), + // just generate a random one here... + 3 => ($third = Str::uuid()), + // continue to generate random uuids... + ]); + + $retrieved = Str::uuid(); + $this->assertSame($zeroth, $retrieved); + $this->assertSame((string) $zeroth, (string) $retrieved); + + $retrieved = Str::uuid(); + $this->assertSame($first, $retrieved); + $this->assertSame((string) $first, (string) $retrieved); + + $retrieved = Str::uuid(); + $this->assertFalse(in_array($retrieved, [$zeroth, $first, $third], true)); + $this->assertFalse(in_array((string) $retrieved, [(string) $zeroth, (string) $first, (string) $third], true)); + + $retrieved = Str::uuid(); + $this->assertSame($third, $retrieved); + $this->assertSame((string) $third, (string) $retrieved); + + $retrieved = Str::uuid(); + $this->assertFalse(in_array($retrieved, [$zeroth, $first, $third], true)); + $this->assertFalse(in_array((string) $retrieved, [(string) $zeroth, (string) $first, (string) $third], true)); + + Str::createUuidsNormally(); + } + + public function testItCanSpecifyAFallbackForASequence() + { + Str::createUuidsUsingSequence([Str::uuid(), Str::uuid()], fn () => throw new Exception('Out of Uuids.')); + Str::uuid(); + Str::uuid(); + + try { + $this->expectExceptionMessage('Out of Uuids.'); + Str::uuid(); + $this->fail(); + } finally { + Str::createUuidsNormally(); + } + } + + public function testItCanFreezeUlids() + { + $this->assertNotSame((string) Str::ulid(), (string) Str::ulid()); + $this->assertNotSame(Str::ulid(), Str::ulid()); + + $ulid = Str::freezeUlids(); + + $this->assertSame($ulid, Str::ulid()); + $this->assertSame(Str::ulid(), Str::ulid()); + $this->assertSame((string) $ulid, (string) Str::ulid()); + $this->assertSame((string) Str::ulid(), (string) Str::ulid()); + + Str::createUlidsNormally(); + + $this->assertNotSame(Str::ulid(), Str::ulid()); + $this->assertNotSame((string) Str::ulid(), (string) Str::ulid()); + } + + public function testItCanFreezeUlidsInAClosure() + { + $ulids = []; + + $ulid = Str::freezeUlids(function ($ulid) use (&$ulids) { + $ulids[] = $ulid; + $ulids[] = Str::ulid(); + $ulids[] = Str::ulid(); + }); + + $this->assertSame($ulid, $ulids[0]); + $this->assertSame((string) $ulid, (string) $ulids[0]); + $this->assertSame((string) $ulids[0], (string) $ulids[1]); + $this->assertSame($ulids[0], $ulids[1]); + $this->assertSame((string) $ulids[0], (string) $ulids[1]); + $this->assertSame($ulids[1], $ulids[2]); + $this->assertSame((string) $ulids[1], (string) $ulids[2]); + $this->assertNotSame(Str::ulid(), Str::ulid()); + $this->assertNotSame((string) Str::ulid(), (string) Str::ulid()); + + Str::createUlidsNormally(); + } + + public function testItCreatesUlidsNormallyAfterFailureWithinFreezeMethod() + { + $frozenUlid = new Ulid('01HGJ9Y6P4RT2R4PQJ4M0N9N8C'); + + try { + Str::freezeUlids(function () use ($frozenUlid) { + Str::createUlidsUsing(fn () => $frozenUlid); + $this->assertSame((string) $frozenUlid, (string) Str::ulid()); + throw new Exception('Something failed'); + }); + } catch (Exception) { + $this->assertNotSame((string) $frozenUlid, (string) Str::ulid()); + } + } + + public function testItCanSpecifyASequenceOfUlidsToUtilise() + { + Str::createUlidsUsingSequence([ + 0 => ($zeroth = Str::ulid()), + 1 => ($first = Str::ulid()), + // just generate a random one here... + 3 => ($third = Str::ulid()), + // continue to generate random ulids... + ]); + + $retrieved = Str::ulid(); + $this->assertSame($zeroth, $retrieved); + $this->assertSame((string) $zeroth, (string) $retrieved); + + $retrieved = Str::ulid(); + $this->assertSame($first, $retrieved); + $this->assertSame((string) $first, (string) $retrieved); + + $retrieved = Str::ulid(); + $this->assertFalse(in_array($retrieved, [$zeroth, $first, $third], true)); + $this->assertFalse(in_array((string) $retrieved, [(string) $zeroth, (string) $first, (string) $third], true)); + + $retrieved = Str::ulid(); + $this->assertSame($third, $retrieved); + $this->assertSame((string) $third, (string) $retrieved); + + $retrieved = Str::ulid(); + $this->assertFalse(in_array($retrieved, [$zeroth, $first, $third], true)); + $this->assertFalse(in_array((string) $retrieved, [(string) $zeroth, (string) $first, (string) $third], true)); + + Str::createUlidsNormally(); + } + + public function testItCanSpecifyAFallbackForAUlidSequence() + { + Str::createUlidsUsingSequence( + [Str::ulid(), Str::ulid()], + fn () => throw new Exception('Out of Ulids'), + ); + Str::ulid(); + Str::ulid(); + + try { + $this->expectExceptionMessage('Out of Ulids'); + Str::ulid(); + $this->fail(); + } finally { + Str::createUlidsNormally(); + } + } + + public function testPasswordCreation() + { + $this->assertTrue(strlen(Str::password()) === 32); + + $this->assertStringNotContainsString(' ', Str::password()); + $this->assertStringContainsString(' ', Str::password(spaces: true)); + + $this->assertTrue( + Str::of(Str::password())->contains(['0', '1', '2', '3', '4', '5', '6', '7', '8', '9']) + ); + } + + public function testToBase64() + { + $this->assertSame(base64_encode('foo'), Str::toBase64('foo')); + $this->assertSame(base64_encode('foobar'), Str::toBase64('foobar')); + } + + public function testFromBase64() + { + $this->assertSame('foo', Str::fromBase64(base64_encode('foo'))); + $this->assertSame('foobar', Str::fromBase64(base64_encode('foobar'), true)); + } + + public function testChopStart() + { + foreach ([ + '' => ['', ''], + 'Laravel' => ['', 'Laravel'], + 'Ship it' => [['', 'Ship '], 'it'], + 'http://laravel.com' => ['http://', 'laravel.com'], + 'http://-http://' => ['http://', '-http://'], + 'http://laravel.com' => ['htp:/', 'http://laravel.com'], + 'http://laravel.com' => ['http://www.', 'http://laravel.com'], + 'http://laravel.com' => ['-http://', 'http://laravel.com'], + 'http://laravel.com' => [['https://', 'http://'], 'laravel.com'], + 'http://www.laravel.com' => [['http://', 'www.'], 'www.laravel.com'], + 'http://http-is-fun.test' => ['http://', 'http-is-fun.test'], + // Multibyte emoji tests + '🌊✋' => ['🌊', '✋'], + '🌊✋' => ['✋', '🌊✋'], + '🚀🌟💫' => ['🚀', '🌟💫'], + '🚀🌟💫' => ['🚀🌟', '💫'], + // Multibyte character tests (Japanese, Chinese, Arabic, etc.) + 'こんにちは世界' => ['こんにちは', '世界'], + '你好世界' => ['你好', '世界'], + 'مرحبا بك' => ['مرحبا ', 'بك'], + // Mixed multibyte and ASCII + '🎉Laravel' => ['🎉', 'Laravel'], + 'Hello🌍World' => ['Hello🌍', 'World'], + // Multiple needle array with multibyte + '🌊✋🎉' => [['🚀', '🌊'], '✋🎉'], + 'こんにちは世界' => [['Hello', 'こんにちは'], '世界'], + ] as $subject => $value) { + [$needle, $expected] = $value; + + $this->assertSame($expected, Str::chopStart($subject, $needle)); + } + } + + public function testChopEnd() + { + foreach ([ + '' => ['', ''], + 'Laravel' => ['', 'Laravel'], + 'Ship it' => [['', ' it'], 'Ship'], + 'path/to/file.php' => ['.php', 'path/to/file'], + '.php-.php' => ['.php', '.php-'], + 'path/to/file.php' => ['.ph', 'path/to/file.php'], + 'path/to/file.php' => ['foo.php', 'path/to/file.php'], + 'path/to/file.php' => ['.php-', 'path/to/file.php'], + 'path/to/file.php' => [['.html', '.php'], 'path/to/file'], + 'path/to/file.php' => [['.php', 'file'], 'path/to/file'], + 'path/to/php.php' => ['.php', 'path/to/php'], + // Multibyte emoji tests + '✋🌊' => ['🌊', '✋'], + '✋🌊' => ['✋', '✋🌊'], + '🌟💫🚀' => ['🚀', '🌟💫'], + '🌟💫🚀' => ['💫🚀', '🌟'], + // Multibyte character tests (Japanese, Chinese, Arabic, etc.) + '世界こんにちは' => ['こんにちは', '世界'], + '世界你好' => ['你好', '世界'], + 'بك مرحبا' => [' مرحبا', 'بك'], + // Mixed multibyte and ASCII + 'Laravel🎉' => ['🎉', 'Laravel'], + 'Hello🌍World' => ['World', 'Hello🌍'], + // Multiple needle array with multibyte + '🎉✋🌊' => [['🚀', '🌊'], '🎉✋'], + '世界こんにちは' => [['Hello', 'こんにちは'], '世界'], + ] as $subject => $value) { + [$needle, $expected] = $value; + + $this->assertSame($expected, Str::chopEnd($subject, $needle)); + } + } + + public function testReplaceMatches() + { + // Test basic string replacement + $this->assertSame('foo bar bar', Str::replaceMatches('/baz/', 'bar', 'foo baz bar')); + $this->assertSame('foo baz baz', Str::replaceMatches('/404/', 'found', 'foo baz baz')); + + // Test with array of patterns + $this->assertSame('foo XXX YYY', Str::replaceMatches(['/bar/', '/baz/'], ['XXX', 'YYY'], 'foo bar baz')); + + // Test with callback + $result = Str::replaceMatches('/ba(.)/', function ($match) { + return 'ba' . strtoupper($match[1]); + }, 'foo baz bar'); + + $this->assertSame('foo baZ baR', $result); + + $result = Str::replaceMatches('/(\d+)/', function ($match) { + return $match[1] * 2; + }, 'foo 123 bar 456'); + + $this->assertSame('foo 246 bar 912', $result); + + // Test with limit parameter + $this->assertSame('foo baz baz', Str::replaceMatches('/ba(.)/', 'ba$1', 'foo baz baz', 1)); + + $result = Str::replaceMatches('/ba(.)/', function ($match) { + return 'ba' . strtoupper($match[1]); + }, 'foo baz baz bar', 1); + + $this->assertSame('foo baZ baz bar', $result); + } + + #[RequiresPhpExtension('intl')] + public function testPlural(): void + { + $this->assertSame('Laracon', Str::plural('Laracon', 1)); + $this->assertSame('Laracon', Str::plural('Laracon', [2025])); + + $this->assertSame('Laracons', Str::plural('Laracon', 3)); + $this->assertSame('Laracons', Str::plural('Laracon', [2024, 2025])); + + $this->assertSame('1 Laracon', Str::plural('Laracon', 1, prependCount: true)); + $this->assertSame('1 Laracon', Str::plural('Laracon', [2025], prependCount: true)); + + $this->assertSame('1,000 Laracons', Str::plural('Laracon', 1000, prependCount: true)); + $this->assertSame('2 Laracons', Str::plural('Laracon', [2024, 2025], prependCount: true)); + } + + public function testPluralPascal(): void + { + // Test basic functionality with default count + $this->assertSame('UserGroups', Str::pluralPascal('UserGroup')); + $this->assertSame('ProductCategories', Str::pluralPascal('ProductCategory')); + + // Test with different count values and array + $this->assertSame('UserGroups', Str::pluralPascal('UserGroup', 0)); // plural + $this->assertSame('UserGroup', Str::pluralPascal('UserGroup', 1)); // singular + $this->assertSame('UserGroups', Str::pluralPascal('UserGroup', 2)); // plural + $this->assertSame('UserGroups', Str::pluralPascal('UserGroup', [])); // plural (empty array count is 0) + + // Test with Countable + $countable = new class implements Countable { + public function count(): int + { + return 3; + } + }; + + $this->assertSame('UserGroups', Str::pluralPascal('UserGroup', $countable)); + } +} diff --git a/tests/Support/SupportStringableTest.php b/tests/Support/SupportStringableTest.php new file mode 100644 index 000000000..07b00ee60 --- /dev/null +++ b/tests/Support/SupportStringableTest.php @@ -0,0 +1,1606 @@ +assertEquals( + class_basename(static::class), + $this->stringable(static::class)->classBasename() + ); + } + + public function testIsAscii() + { + $this->assertTrue($this->stringable('A')->isAscii()); + $this->assertFalse($this->stringable('ù')->isAscii()); + } + + public function testIsUrl() + { + $this->assertTrue($this->stringable('https://laravel.com')->isUrl()); + $this->assertTrue($this->stringable('https://laravel.com')->isUrl(['https'])); + + $this->assertFalse($this->stringable('invalid url')->isUrl()); + $this->assertFalse($this->stringable('https://laravel.com')->isUrl(['http'])); + } + + public function testIsUuid() + { + $this->assertTrue($this->stringable('2cdc7039-65a6-4ac7-8e5d-d554a98e7b15')->isUuid()); + $this->assertTrue($this->stringable('2cdc7039-65a6-4ac7-8e5d-d554a98e7b15')->isUuid(4)); + + $this->assertFalse($this->stringable('2cdc7039-65a6-4ac7-8e5d-d554a98')->isUuid()); + $this->assertFalse($this->stringable('2cdc7039-65a6-4ac7-8e5d-d554a98e7b15')->isUuid(7)); + } + + public function testIsUlid() + { + $this->assertTrue($this->stringable('01GJSNW9MAF792C0XYY8RX6QFT')->isUlid()); + $this->assertFalse($this->stringable('01GJSNW9MAF-792C0XYY8RX6ssssss-QFT')->isUlid()); + } + + public function testIsJson() + { + $this->assertTrue($this->stringable('1')->isJson()); + $this->assertTrue($this->stringable('[1,2,3]')->isJson()); + $this->assertTrue($this->stringable('[1, 2, 3]')->isJson()); + $this->assertTrue($this->stringable('{"first": "John", "last": "Doe"}')->isJson()); + $this->assertTrue($this->stringable('[{"first": "John", "last": "Doe"}, {"first": "Jane", "last": "Doe"}]')->isJson()); + + $this->assertFalse($this->stringable('1,')->isJson()); + $this->assertFalse($this->stringable('[1,2,3')->isJson()); + $this->assertFalse($this->stringable('[1, 2 3]')->isJson()); + $this->assertFalse($this->stringable('{first: "John"}')->isJson()); + $this->assertFalse($this->stringable('[{first: "John"}, {first: "Jane"}]')->isJson()); + $this->assertFalse($this->stringable('')->isJson()); + $this->assertFalse($this->stringable(null)->isJson()); + } + + public function testIsMatch() + { + $this->assertTrue($this->stringable('Hello, Laravel!')->isMatch('/.*,.*!/')); + $this->assertTrue($this->stringable('Hello, Laravel!')->isMatch('/^.*$(.*)/')); + $this->assertTrue($this->stringable('Hello, Laravel!')->isMatch('/laravel/i')); + $this->assertTrue($this->stringable('Hello, Laravel!')->isMatch('/^(.*(.*(.*)))/')); + + $this->assertFalse($this->stringable('Hello, Laravel!')->isMatch('/H.o/')); + $this->assertFalse($this->stringable('Hello, Laravel!')->isMatch('/^laravel!/i')); + $this->assertFalse($this->stringable('Hello, Laravel!')->isMatch('/laravel!(.*)/')); + $this->assertFalse($this->stringable('Hello, Laravel!')->isMatch('/^[a-zA-Z,!]+$/')); + + $this->assertTrue($this->stringable('Hello, Laravel!')->isMatch(['/.*,.*!/', '/H.o/'])); + $this->assertTrue($this->stringable('Hello, Laravel!')->isMatch(['/^laravel!/i', '/^.*$(.*)/'])); + $this->assertTrue($this->stringable('Hello, Laravel!')->isMatch(['/laravel/i', '/laravel!(.*)/'])); + $this->assertTrue($this->stringable('Hello, Laravel!')->isMatch(['/^[a-zA-Z,!]+$/', '/^(.*(.*(.*)))/'])); + } + + public function testIsEmpty() + { + $this->assertTrue($this->stringable('')->isEmpty()); + $this->assertFalse($this->stringable('A')->isEmpty()); + $this->assertFalse($this->stringable('0')->isEmpty()); + } + + public function testIsNotEmpty() + { + $this->assertFalse($this->stringable('')->isNotEmpty()); + $this->assertTrue($this->stringable('A')->isNotEmpty()); + } + + public function testPluralStudly() + { + $this->assertSame('LaraCon', (string) $this->stringable('LaraCon')->pluralStudly(1)); + $this->assertSame('LaraCons', (string) $this->stringable('LaraCon')->pluralStudly(2)); + $this->assertSame('LaraCon', (string) $this->stringable('LaraCon')->pluralStudly(-1)); + $this->assertSame('LaraCons', (string) $this->stringable('LaraCon')->pluralStudly(-2)); + } + + public function testPluralPascal() + { + $this->assertSame('LaraCons', (string) $this->stringable('LaraCon')->pluralPascal(2)); + $this->assertSame('LaraCon', (string) $this->stringable('LaraCon')->pluralPascal(1)); + $this->assertSame('LaraCons', (string) $this->stringable('LaraCon')->pluralPascal(-2)); + $this->assertSame('LaraCon', (string) $this->stringable('LaraCon')->pluralPascal(-1)); + } + + public function testMatch() + { + $stringable = $this->stringable('foo bar'); + + $this->assertSame('bar', (string) $stringable->match('/bar/')); + $this->assertSame('bar', (string) $stringable->match('/foo (.*)/')); + $this->assertTrue($stringable->match('/nothing/')->isEmpty()); + + $this->assertEquals(['bar', 'bar'], $this->stringable('bar foo bar')->matchAll('/bar/')->all()); + + $stringable = $this->stringable('bar fun bar fly'); + + $this->assertEquals(['un', 'ly'], $stringable->matchAll('/f(\w*)/')->all()); + $this->assertTrue($stringable->matchAll('/nothing/')->isEmpty()); + } + + public function testTake() + { + $this->assertSame('ab', (string) $this->stringable('abcdef')->take(2)); + $this->assertSame('ef', (string) $this->stringable('abcdef')->take(-2)); + } + + public function testTest() + { + $stringable = $this->stringable('foo bar'); + + $this->assertTrue($stringable->test('/bar/')); + $this->assertTrue($stringable->test('/foo (.*)/')); + } + + public function testTrim() + { + $this->assertSame('foo', (string) $this->stringable(' foo ')->trim()); + } + + public function testLtrim() + { + $this->assertSame('foo ', (string) $this->stringable(' foo ')->ltrim()); + } + + public function testRtrim() + { + $this->assertSame(' foo', (string) $this->stringable(' foo ')->rtrim()); + } + + public function testCanBeLimitedByWords() + { + $this->assertSame('Taylor...', (string) $this->stringable('Taylor Otwell')->words(1)); + $this->assertSame('Taylor___', (string) $this->stringable('Taylor Otwell')->words(1, '___')); + $this->assertSame('Taylor Otwell', (string) $this->stringable('Taylor Otwell')->words(3)); + } + + public function testUcwords() + { + $this->assertSame('Laravel', (string) $this->stringable('laravel')->ucwords()); + $this->assertSame('Laravel Framework', (string) $this->stringable('laravel framework')->ucwords()); + $this->assertSame('Laravel-Framework', (string) $this->stringable('laravel-framework')->ucwords('-')); + $this->assertSame('Мама', (string) $this->stringable('мама')->ucwords()); + $this->assertSame('Мама Мыла Раму', (string) $this->stringable('мама мыла раму')->ucwords()); + $this->assertSame('JJ Watt', (string) $this->stringable('JJ watt')->ucwords()); + } + + public function testUnless() + { + $this->assertSame('unless false', (string) $this->stringable('unless')->unless(false, function ($stringable, $value) { + return $stringable->append(' false'); + })); + + $this->assertSame('unless true fallbacks to default', (string) $this->stringable('unless')->unless(true, function ($stringable, $value) { + return $stringable->append($value); + }, function ($stringable) { + return $stringable->append(' true fallbacks to default'); + })); + } + + public function testWhenContains() + { + $this->assertSame('Tony Stark', (string) $this->stringable('stark')->whenContains('tar', function ($stringable) { + return $stringable->prepend('Tony ')->title(); + }, function ($stringable) { + return $stringable->prepend('Arno ')->title(); + })); + + $this->assertSame('stark', (string) $this->stringable('stark')->whenContains('xxx', function ($stringable) { + return $stringable->prepend('Tony ')->title(); + })); + + $this->assertSame('Arno Stark', (string) $this->stringable('stark')->whenContains('xxx', function ($stringable) { + return $stringable->prepend('Tony ')->title(); + }, function ($stringable) { + return $stringable->prepend('Arno ')->title(); + })); + } + + public function testWhenContainsAll() + { + $this->assertSame('Tony Stark', (string) $this->stringable('tony stark')->whenContainsAll(['tony', 'stark'], function ($stringable) { + return $stringable->title(); + }, function ($stringable) { + return $stringable->studly(); + })); + + $this->assertSame('tony stark', (string) $this->stringable('tony stark')->whenContainsAll(['xxx'], function ($stringable) { + return $stringable->title(); + })); + + $this->assertSame('TonyStark', (string) $this->stringable('tony stark')->whenContainsAll(['tony', 'xxx'], function ($stringable) { + return $stringable->title(); + }, function ($stringable) { + return $stringable->studly(); + })); + } + + public function testDedup() + { + $this->assertSame(' laravel php framework ', (string) $this->stringable(' laravel php framework ')->deduplicate()); + $this->assertSame('what', (string) $this->stringable('whaaat')->deduplicate('a')); + $this->assertSame('/some/odd/path/', (string) $this->stringable('/some//odd//path/')->deduplicate('/')); + $this->assertSame('ムだム', (string) $this->stringable('ムだだム')->deduplicate('だ')); + } + + public function testDirname() + { + $this->assertSame('/framework/tests', (string) $this->stringable('/framework/tests/Support')->dirname()); + $this->assertSame('/framework', (string) $this->stringable('/framework/tests/Support')->dirname(2)); + $this->assertSame('.', (string) $this->stringable('framework')->dirname()); + + $this->assertSame('.', (string) $this->stringable('.')->dirname()); + + $this->assertSame(DIRECTORY_SEPARATOR, (string) $this->stringable('/framework/')->dirname()); + $this->assertSame(DIRECTORY_SEPARATOR, (string) $this->stringable('/')->dirname()); + } + + public function testUcsplitOnStringable() + { + $this->assertSame(['Taylor', 'Otwell'], $this->stringable('TaylorOtwell')->ucsplit()->toArray()); + $this->assertSame(['Hello', 'From', 'Laravel'], $this->stringable('HelloFromLaravel')->ucsplit()->toArray()); + $this->assertSame(['He_llo_', 'World'], $this->stringable('He_llo_World')->ucsplit()->toArray()); + } + + public function testWhenEndsWith() + { + $this->assertSame('Tony Stark', (string) $this->stringable('tony stark')->whenEndsWith('ark', function ($stringable) { + return $stringable->title(); + }, function ($stringable) { + return $stringable->studly(); + })); + + $this->assertSame('Tony Stark', (string) $this->stringable('tony stark')->whenEndsWith(['kra', 'ark'], function ($stringable) { + return $stringable->title(); + }, function ($stringable) { + return $stringable->studly(); + })); + + $this->assertSame('tony stark', (string) $this->stringable('tony stark')->whenEndsWith(['xxx'], function ($stringable) { + return $stringable->title(); + })); + + $this->assertSame('TonyStark', (string) $this->stringable('tony stark')->whenEndsWith(['tony', 'xxx'], function ($stringable) { + return $stringable->title(); + }, function ($stringable) { + return $stringable->studly(); + })); + } + + public function testWhenDoesntEndWith() + { + $this->assertSame('Tony Stark', (string) $this->stringable('tony stark')->whenDoesntEndWith('ark', function ($stringable) { + return $stringable->studly(); + }, function ($stringable) { + return $stringable->title(); + })); + + $this->assertSame('Tony Stark', (string) $this->stringable('tony stark')->whenDoesntEndWith(['kra', 'ark'], function ($stringable) { + return $stringable->studly(); + }, function ($stringable) { + return $stringable->title(); + })); + + $this->assertSame('tony stark', (string) $this->stringable('tony stark')->whenDoesntEndWith(['xxx'], function ($stringable) { + return $stringable; + })); + + $this->assertSame('TonyStark', (string) $this->stringable('tony stark')->whenDoesntEndWith(['tony', 'xxx'], function ($stringable) { + return $stringable->studly(); + }, function ($stringable) { + return $stringable->title(); + })); + } + + public function testWhenExactly() + { + $this->assertSame('Nailed it...!', (string) $this->stringable('Tony Stark')->whenExactly('Tony Stark', function ($stringable) { + return 'Nailed it...!'; + }, function ($stringable) { + return 'Swing and a miss...!'; + })); + + $this->assertSame('Swing and a miss...!', (string) $this->stringable('Tony Stark')->whenExactly('Iron Man', function ($stringable) { + return 'Nailed it...!'; + }, function ($stringable) { + return 'Swing and a miss...!'; + })); + + $this->assertSame('Tony Stark', (string) $this->stringable('Tony Stark')->whenExactly('Iron Man', function ($stringable) { + return 'Nailed it...!'; + })); + } + + public function testWhenNotExactly() + { + $this->assertSame( + 'Iron Man', + (string) $this->stringable('Tony')->whenNotExactly('Tony Stark', function ($stringable) { + return 'Iron Man'; + }) + ); + + $this->assertSame( + 'Swing and a miss...!', + (string) $this->stringable('Tony Stark')->whenNotExactly('Tony Stark', function ($stringable) { + return 'Iron Man'; + }, function ($stringable) { + return 'Swing and a miss...!'; + }) + ); + } + + public function testWhenIs() + { + $this->assertSame('Winner: /', (string) $this->stringable('/')->whenIs('/', function ($stringable) { + return $stringable->prepend('Winner: '); + }, function ($stringable) { + return 'Try again'; + })); + + $this->assertSame('/', (string) $this->stringable('/')->whenIs(' /', function ($stringable) { + return $stringable->prepend('Winner: '); + })); + + $this->assertSame('Try again', (string) $this->stringable('/')->whenIs(' /', function ($stringable) { + return $stringable->prepend('Winner: '); + }, function ($stringable) { + return 'Try again'; + })); + + $this->assertSame('Winner: foo/bar/baz', (string) $this->stringable('foo/bar/baz')->whenIs('foo/*', function ($stringable) { + return $stringable->prepend('Winner: '); + })); + } + + public function testWhenIsAscii() + { + $this->assertSame('Ascii: A', (string) $this->stringable('A')->whenIsAscii(function ($stringable) { + return $stringable->prepend('Ascii: '); + }, function ($stringable) { + return $stringable->prepend('Not Ascii: '); + })); + + $this->assertSame('ù', (string) $this->stringable('ù')->whenIsAscii(function ($stringable) { + return $stringable->prepend('Ascii: '); + })); + + $this->assertSame('Not Ascii: ù', (string) $this->stringable('ù')->whenIsAscii(function ($stringable) { + return $stringable->prepend('Ascii: '); + }, function ($stringable) { + return $stringable->prepend('Not Ascii: '); + })); + } + + public function testWhenIsUuid() + { + $this->assertSame('Uuid: 2cdc7039-65a6-4ac7-8e5d-d554a98e7b15', (string) $this->stringable('2cdc7039-65a6-4ac7-8e5d-d554a98e7b15')->whenIsUuid(function ($stringable) { + return $stringable->prepend('Uuid: '); + }, function ($stringable) { + return $stringable->prepend('Not Uuid: '); + })); + + $this->assertSame('2cdc7039-65a6-4ac7-8e5d-d554a98', (string) $this->stringable('2cdc7039-65a6-4ac7-8e5d-d554a98')->whenIsUuid(function ($stringable) { + return $stringable->prepend('Uuid: '); + })); + + $this->assertSame('Not Uuid: 2cdc7039-65a6-4ac7-8e5d-d554a98', (string) $this->stringable('2cdc7039-65a6-4ac7-8e5d-d554a98')->whenIsUuid(function ($stringable) { + return $stringable->prepend('Uuid: '); + }, function ($stringable) { + return $stringable->prepend('Not Uuid: '); + })); + } + + public function testWhenIsUlid() + { + $this->assertSame('Ulid: 01GJSNW9MAF792C0XYY8RX6QFT', (string) $this->stringable('01GJSNW9MAF792C0XYY8RX6QFT')->whenIsUlid(function ($stringable) { + return $stringable->prepend('Ulid: '); + }, function ($stringable) { + return $stringable->prepend('Not Ulid: '); + })); + + $this->assertSame('2cdc7039-65a6-4ac7-8e5d-d554a98', (string) $this->stringable('2cdc7039-65a6-4ac7-8e5d-d554a98')->whenIsUlid(function ($stringable) { + return $stringable->prepend('Ulid: '); + })); + + $this->assertSame('Not Ulid: ss-01GJSNW9MAF792C0XYY8RX6QFT', (string) $this->stringable('ss-01GJSNW9MAF792C0XYY8RX6QFT')->whenIsUlid(function ($stringable) { + return $stringable->prepend('Ulid: '); + }, function ($stringable) { + return $stringable->prepend('Not Ulid: '); + })); + } + + public function testWhenTest() + { + $this->assertSame('Winner: foo bar', (string) $this->stringable('foo bar')->whenTest('/bar/', function ($stringable) { + return $stringable->prepend('Winner: '); + }, function ($stringable) { + return 'Try again'; + })); + + $this->assertSame('Try again', (string) $this->stringable('foo bar')->whenTest('/link/', function ($stringable) { + return $stringable->prepend('Winner: '); + }, function ($stringable) { + return 'Try again'; + })); + + $this->assertSame('foo bar', (string) $this->stringable('foo bar')->whenTest('/link/', function ($stringable) { + return $stringable->prepend('Winner: '); + })); + } + + public function testWhenStartsWith() + { + $this->assertSame('Tony Stark', (string) $this->stringable('tony stark')->whenStartsWith('ton', function ($stringable) { + return $stringable->title(); + }, function ($stringable) { + return $stringable->studly(); + })); + + $this->assertSame('Tony Stark', (string) $this->stringable('tony stark')->whenStartsWith(['ton', 'not'], function ($stringable) { + return $stringable->title(); + }, function ($stringable) { + return $stringable->studly(); + })); + + $this->assertSame('tony stark', (string) $this->stringable('tony stark')->whenStartsWith(['xxx'], function ($stringable) { + return $stringable->title(); + })); + + $this->assertSame('Tony Stark', (string) $this->stringable('tony stark')->whenStartsWith(['tony', 'xxx'], function ($stringable) { + return $stringable->title(); + }, function ($stringable) { + return $stringable->studly(); + })); + } + + public function testWhenDoesntStartWith() + { + $this->assertSame('Tony Stark', (string) $this->stringable('tony stark')->whenDoesntStartWith('ton', function ($stringable) { + return $stringable->studly(); + }, function ($stringable) { + return $stringable->title(); + })); + + $this->assertSame('Tony Stark', (string) $this->stringable('tony stark')->whenDoesntStartWith(['ton', 'not'], function ($stringable) { + return $stringable->studly(); + }, function ($stringable) { + return $stringable->title(); + })); + + $this->assertSame('tony stark', (string) $this->stringable('tony stark')->whenDoesntStartWith(['xxx'], function ($stringable) { + return $stringable; + })); + + $this->assertSame('Tony Stark', (string) $this->stringable('tony stark')->whenDoesntStartWith(['tony', 'xxx'], function ($stringable) { + return $stringable->studly(); + }, function ($stringable) { + return $stringable->title(); + })); + } + + public function testWhenEmpty() + { + tap($this->stringable(), function ($stringable) { + $this->assertSame($stringable, $stringable->whenEmpty(function () { + })); + }); + + $this->assertSame('empty', (string) $this->stringable()->whenEmpty(function () { + return 'empty'; + })); + + $this->assertSame('not-empty', (string) $this->stringable('not-empty')->whenEmpty(function () { + return 'empty'; + })); + } + + public function testWhenNotEmpty() + { + tap($this->stringable(), function ($stringable) { + $this->assertSame($stringable, $stringable->whenNotEmpty(function ($stringable) { + return $stringable . '.'; + })); + }); + + $this->assertSame('', (string) $this->stringable()->whenNotEmpty(function ($stringable) { + return $stringable . '.'; + })); + + $this->assertSame('Not empty.', (string) $this->stringable('Not empty')->whenNotEmpty(function ($stringable) { + return $stringable . '.'; + })); + } + + public function testWhenFalse() + { + $this->assertSame('when', (string) $this->stringable('when')->when(false, function ($stringable, $value) { + return $stringable->append($value)->append('false'); + })); + + $this->assertSame('when false fallbacks to default', (string) $this->stringable('when false ')->when(false, function ($stringable, $value) { + return $stringable->append($value); + }, function ($stringable) { + return $stringable->append('fallbacks to default'); + })); + } + + public function testWhenTrue() + { + $this->assertSame('when true', (string) $this->stringable('when ')->when(true, function ($stringable) { + return $stringable->append('true'); + })); + + $this->assertSame('gets a value from if', (string) $this->stringable('gets a value ')->when('from if', function ($stringable, $value) { + return $stringable->append($value); + }, function ($stringable) { + return $stringable->append('fallbacks to default'); + })); + } + + public function testUnlessTruthy() + { + $this->assertSame('unless', (string) $this->stringable('unless')->unless(1, function ($stringable, $value) { + return $stringable->append($value)->append('true'); + })); + + $this->assertSame( + 'unless true fallbacks to default with value 1', + (string) $this->stringable('unless true ')->unless(1, function ($stringable, $value) { + return $stringable->append($value); + }, function ($stringable, $value) { + return $stringable->append('fallbacks to default with value ')->append($value); + }) + ); + } + + public function testUnlessFalsy() + { + $this->assertSame('unless 0', (string) $this->stringable('unless ')->unless(0, function ($stringable, $value) { + return $stringable->append($value); + })); + + $this->assertSame( + 'gets the value 0', + (string) $this->stringable('gets the value ')->unless(0, function ($stringable, $value) { + return $stringable->append($value); + }, function ($stringable) { + return $stringable->append('fallbacks to default'); + }) + ); + } + + public function testTrimmedOnlyWhereNecessary() + { + $this->assertSame(' Taylor Otwell ', (string) $this->stringable(' Taylor Otwell ')->words(3)); + $this->assertSame(' Taylor...', (string) $this->stringable(' Taylor Otwell ')->words(1)); + } + + public function testTitle() + { + $this->assertSame('Jefferson Costella', (string) $this->stringable('jefferson costella')->title()); + $this->assertSame('Jefferson Costella', (string) $this->stringable('jefFErson coSTella')->title()); + } + + public function testWithoutWordsDoesntProduceError() + { + $nbsp = chr(0xC2) . chr(0xA0); + $this->assertSame(' ', (string) $this->stringable(' ')->words()); + $this->assertEquals($nbsp, (string) $this->stringable($nbsp)->words()); + } + + public function testAscii() + { + $this->assertSame('@', (string) $this->stringable('@')->ascii()); + $this->assertSame('u', (string) $this->stringable('ü')->ascii()); + } + + public function testTransliterate() + { + $this->assertSame('HHH', (string) $this->stringable('🎂🚧🏆')->transliterate('H')); + $this->assertSame('Hello', (string) $this->stringable('🎂')->transliterate('Hello')); + } + + public function testNewLine() + { + $this->assertSame('Laravel' . PHP_EOL, (string) $this->stringable('Laravel')->newLine()); + $this->assertSame('foo' . PHP_EOL . PHP_EOL . 'bar', (string) $this->stringable('foo')->newLine(2)->append('bar')); + } + + public function testAsciiWithSpecificLocale() + { + $this->assertSame('h H sht Sht a A ia yo', (string) $this->stringable('х Х щ Щ ъ Ъ иа йо')->ascii('bg')); + $this->assertSame('ae oe ue Ae Oe Ue', (string) $this->stringable('ä ö ü Ä Ö Ü')->ascii('de')); + } + + public function testStartsWith() + { + $this->assertTrue($this->stringable('jason')->startsWith('jas')); + $this->assertTrue($this->stringable('jason')->startsWith('jason')); + $this->assertTrue($this->stringable('jason')->startsWith(['jas'])); + $this->assertTrue($this->stringable('jason')->startsWith(['day', 'jas'])); + $this->assertTrue($this->stringable('jason')->startsWith(collect(['day', 'jas']))); + $this->assertFalse($this->stringable('jason')->startsWith('day')); + $this->assertFalse($this->stringable('jason')->startsWith(['day'])); + $this->assertFalse($this->stringable('jason')->startsWith(null)); + $this->assertFalse($this->stringable('jason')->startsWith([null])); + $this->assertFalse($this->stringable('0123')->startsWith([null])); + $this->assertTrue($this->stringable('0123')->startsWith(0)); + $this->assertFalse($this->stringable('jason')->startsWith('J')); + $this->assertFalse($this->stringable('jason')->startsWith('')); + $this->assertFalse($this->stringable('7')->startsWith(' 7')); + $this->assertTrue($this->stringable('7a')->startsWith('7')); + $this->assertTrue($this->stringable('7a')->startsWith(7)); + $this->assertTrue($this->stringable('7.12a')->startsWith(7.12)); + $this->assertFalse($this->stringable('7.12a')->startsWith(7.13)); + $this->assertTrue($this->stringable(7.123)->startsWith('7')); + $this->assertTrue($this->stringable(7.123)->startsWith('7.12')); + $this->assertFalse($this->stringable(7.123)->startsWith('7.13')); + // Test for multibyte string support + $this->assertTrue($this->stringable('Jönköping')->startsWith('Jö')); + $this->assertTrue($this->stringable('Malmö')->startsWith('Malmö')); + $this->assertFalse($this->stringable('Jönköping')->startsWith('Jonko')); + $this->assertFalse($this->stringable('Malmö')->startsWith('Malmo')); + } + + public function testDoesntStartWith() + { + $this->assertFalse($this->stringable('jason')->doesntStartWith('jas')); + $this->assertFalse($this->stringable('jason')->doesntStartWith('jason')); + $this->assertFalse($this->stringable('jason')->doesntStartWith(['jas'])); + $this->assertFalse($this->stringable('jason')->doesntStartWith(['day', 'jas'])); + $this->assertFalse($this->stringable('jason')->doesntStartWith(collect(['day', 'jas']))); + $this->assertTrue($this->stringable('jason')->doesntStartWith('day')); + $this->assertTrue($this->stringable('jason')->doesntStartWith(['day'])); + $this->assertTrue($this->stringable('jason')->doesntStartWith(null)); + $this->assertTrue($this->stringable('jason')->doesntStartWith([null])); + $this->assertTrue($this->stringable('0123')->doesntStartWith([null])); + $this->assertFalse($this->stringable('0123')->doesntStartWith(0)); + $this->assertTrue($this->stringable('jason')->doesntStartWith('J')); + $this->assertTrue($this->stringable('jason')->doesntStartWith('')); + $this->assertTrue($this->stringable('7')->doesntStartWith(' 7')); + $this->assertFalse($this->stringable('7a')->doesntStartWith('7')); + $this->assertFalse($this->stringable('7a')->doesntStartWith(7)); + $this->assertFalse($this->stringable('7.12a')->doesntStartWith(7.12)); + $this->assertTrue($this->stringable('7.12a')->doesntStartWith(7.13)); + $this->assertFalse($this->stringable(7.123)->doesntStartWith('7')); + $this->assertFalse($this->stringable(7.123)->doesntStartWith('7.12')); + $this->assertTrue($this->stringable(7.123)->doesntStartWith('7.13')); + // Test for multibyte string support + $this->assertFalse($this->stringable('Jönköping')->doesntStartWith('Jö')); + $this->assertFalse($this->stringable('Malmö')->doesntStartWith('Malmö')); + $this->assertTrue($this->stringable('Jönköping')->doesntStartWith('Jonko')); + $this->assertTrue($this->stringable('Malmö')->doesntStartWith('Malmo')); + } + + public function testEndsWith() + { + $this->assertTrue($this->stringable('jason')->endsWith('on')); + $this->assertTrue($this->stringable('jason')->endsWith('jason')); + $this->assertTrue($this->stringable('jason')->endsWith(['on'])); + $this->assertTrue($this->stringable('jason')->endsWith(['no', 'on'])); + $this->assertTrue($this->stringable('jason')->endsWith(collect(['no', 'on']))); + $this->assertFalse($this->stringable('jason')->endsWith('no')); + $this->assertFalse($this->stringable('jason')->endsWith(['no'])); + $this->assertFalse($this->stringable('jason')->endsWith('')); + $this->assertFalse($this->stringable('jason')->endsWith([null])); + $this->assertFalse($this->stringable('jason')->endsWith(null)); + $this->assertFalse($this->stringable('jason')->endsWith('N')); + $this->assertFalse($this->stringable('7')->endsWith(' 7')); + $this->assertTrue($this->stringable('a7')->endsWith('7')); + $this->assertTrue($this->stringable('a7')->endsWith(7)); + $this->assertTrue($this->stringable('a7.12')->endsWith(7.12)); + $this->assertFalse($this->stringable('a7.12')->endsWith(7.13)); + $this->assertTrue($this->stringable(0.27)->endsWith('7')); + $this->assertTrue($this->stringable(0.27)->endsWith('0.27')); + $this->assertFalse($this->stringable(0.27)->endsWith('8')); + // Test for multibyte string support + $this->assertTrue($this->stringable('Jönköping')->endsWith('öping')); + $this->assertTrue($this->stringable('Malmö')->endsWith('mö')); + $this->assertFalse($this->stringable('Jönköping')->endsWith('oping')); + $this->assertFalse($this->stringable('Malmö')->endsWith('mo')); + } + + public function testDoesntEndWith() + { + $this->assertFalse($this->stringable('jason')->doesntEndWith('on')); + $this->assertFalse($this->stringable('jason')->doesntEndWith('jason')); + $this->assertFalse($this->stringable('jason')->doesntEndWith(['on'])); + $this->assertFalse($this->stringable('jason')->doesntEndWith(['no', 'on'])); + $this->assertFalse($this->stringable('jason')->doesntEndWith(collect(['no', 'on']))); + $this->assertTrue($this->stringable('jason')->doesntEndWith('no')); + $this->assertTrue($this->stringable('jason')->doesntEndWith(['no'])); + $this->assertTrue($this->stringable('jason')->doesntEndWith('')); + $this->assertTrue($this->stringable('jason')->doesntEndWith([null])); + $this->assertTrue($this->stringable('jason')->doesntEndWith(null)); + $this->assertTrue($this->stringable('jason')->doesntEndWith('N')); + $this->assertTrue($this->stringable('7')->doesntEndWith(' 7')); + $this->assertFalse($this->stringable('a7')->doesntEndWith('7')); + $this->assertFalse($this->stringable('a7')->doesntEndWith(7)); + $this->assertFalse($this->stringable('a7.12')->doesntEndWith(7.12)); + $this->assertTrue($this->stringable('a7.12')->doesntEndWith(7.13)); + $this->assertFalse($this->stringable(0.27)->doesntEndWith('7')); + $this->assertFalse($this->stringable(0.27)->doesntEndWith('0.27')); + $this->assertTrue($this->stringable(0.27)->doesntEndWith('8')); + // Test for multibyte string support + $this->assertFalse($this->stringable('Jönköping')->doesntEndWith('öping')); + $this->assertFalse($this->stringable('Malmö')->doesntEndWith('mö')); + $this->assertTrue($this->stringable('Jönköping')->doesntEndWith('oping')); + $this->assertTrue($this->stringable('Malmö')->doesntEndWith('mo')); + } + + public function testExcerpt() + { + $this->assertSame('...is a beautiful morn...', (string) $this->stringable('This is a beautiful morning')->excerpt('beautiful', ['radius' => 5])); + } + + public function testBefore() + { + $this->assertSame('han', (string) $this->stringable('hannah')->before('nah')); + $this->assertSame('ha', (string) $this->stringable('hannah')->before('n')); + $this->assertSame('ééé ', (string) $this->stringable('ééé hannah')->before('han')); + $this->assertSame('hannah', (string) $this->stringable('hannah')->before('xxxx')); + $this->assertSame('hannah', (string) $this->stringable('hannah')->before('')); + $this->assertSame('han', (string) $this->stringable('han0nah')->before('0')); + $this->assertSame('han', (string) $this->stringable('han0nah')->before(0)); + $this->assertSame('han', (string) $this->stringable('han2nah')->before(2)); + } + + public function testBeforeLast() + { + $this->assertSame('yve', (string) $this->stringable('yvette')->beforeLast('tte')); + $this->assertSame('yvet', (string) $this->stringable('yvette')->beforeLast('t')); + $this->assertSame('ééé ', (string) $this->stringable('ééé yvette')->beforeLast('yve')); + $this->assertSame('', (string) $this->stringable('yvette')->beforeLast('yve')); + $this->assertSame('yvette', (string) $this->stringable('yvette')->beforeLast('xxxx')); + $this->assertSame('yvette', (string) $this->stringable('yvette')->beforeLast('')); + $this->assertSame('yv0et', (string) $this->stringable('yv0et0te')->beforeLast('0')); + $this->assertSame('yv0et', (string) $this->stringable('yv0et0te')->beforeLast(0)); + $this->assertSame('yv2et', (string) $this->stringable('yv2et2te')->beforeLast(2)); + } + + public function testBetween() + { + $this->assertSame('abc', (string) $this->stringable('abc')->between('', 'c')); + $this->assertSame('abc', (string) $this->stringable('abc')->between('a', '')); + $this->assertSame('abc', (string) $this->stringable('abc')->between('', '')); + $this->assertSame('b', (string) $this->stringable('abc')->between('a', 'c')); + $this->assertSame('b', (string) $this->stringable('dddabc')->between('a', 'c')); + $this->assertSame('b', (string) $this->stringable('abcddd')->between('a', 'c')); + $this->assertSame('b', (string) $this->stringable('dddabcddd')->between('a', 'c')); + $this->assertSame('nn', (string) $this->stringable('hannah')->between('ha', 'ah')); + $this->assertSame('a]ab[b', (string) $this->stringable('[a]ab[b]')->between('[', ']')); + $this->assertSame('foo', (string) $this->stringable('foofoobar')->between('foo', 'bar')); + $this->assertSame('bar', (string) $this->stringable('foobarbar')->between('foo', 'bar')); + } + + public function testBetweenFirst() + { + $this->assertSame('abc', (string) $this->stringable('abc')->betweenFirst('', 'c')); + $this->assertSame('abc', (string) $this->stringable('abc')->betweenFirst('a', '')); + $this->assertSame('abc', (string) $this->stringable('abc')->betweenFirst('', '')); + $this->assertSame('b', (string) $this->stringable('abc')->betweenFirst('a', 'c')); + $this->assertSame('b', (string) $this->stringable('dddabc')->betweenFirst('a', 'c')); + $this->assertSame('b', (string) $this->stringable('abcddd')->betweenFirst('a', 'c')); + $this->assertSame('b', (string) $this->stringable('dddabcddd')->betweenFirst('a', 'c')); + $this->assertSame('nn', (string) $this->stringable('hannah')->betweenFirst('ha', 'ah')); + $this->assertSame('a', (string) $this->stringable('[a]ab[b]')->betweenFirst('[', ']')); + $this->assertSame('foo', (string) $this->stringable('foofoobar')->betweenFirst('foo', 'bar')); + $this->assertSame('', (string) $this->stringable('foobarbar')->betweenFirst('foo', 'bar')); + } + + public function testAfter() + { + $this->assertSame('nah', (string) $this->stringable('hannah')->after('han')); + $this->assertSame('nah', (string) $this->stringable('hannah')->after('n')); + $this->assertSame('nah', (string) $this->stringable('ééé hannah')->after('han')); + $this->assertSame('hannah', (string) $this->stringable('hannah')->after('xxxx')); + $this->assertSame('hannah', (string) $this->stringable('hannah')->after('')); + $this->assertSame('nah', (string) $this->stringable('han0nah')->after('0')); + $this->assertSame('nah', (string) $this->stringable('han0nah')->after(0)); + $this->assertSame('nah', (string) $this->stringable('han2nah')->after(2)); + } + + public function testAfterLast() + { + $this->assertSame('tte', (string) $this->stringable('yvette')->afterLast('yve')); + $this->assertSame('e', (string) $this->stringable('yvette')->afterLast('t')); + $this->assertSame('e', (string) $this->stringable('ééé yvette')->afterLast('t')); + $this->assertSame('', (string) $this->stringable('yvette')->afterLast('tte')); + $this->assertSame('yvette', (string) $this->stringable('yvette')->afterLast('xxxx')); + $this->assertSame('yvette', (string) $this->stringable('yvette')->afterLast('')); + $this->assertSame('te', (string) $this->stringable('yv0et0te')->afterLast('0')); + $this->assertSame('te', (string) $this->stringable('yv0et0te')->afterLast(0)); + $this->assertSame('te', (string) $this->stringable('yv2et2te')->afterLast(2)); + $this->assertSame('foo', (string) $this->stringable('----foo')->afterLast('---')); + } + + public function testContains() + { + $this->assertTrue($this->stringable('taylor')->contains('ylo')); + $this->assertTrue($this->stringable('taylor')->contains('taylor')); + $this->assertTrue($this->stringable('taylor')->contains(['ylo'])); + $this->assertTrue($this->stringable('taylor')->contains(['xxx', 'ylo'])); + $this->assertTrue($this->stringable('taylor')->contains(collect(['xxx', 'ylo']))); + $this->assertTrue($this->stringable('taylor')->contains(['LOR'], true)); + $this->assertFalse($this->stringable('taylor')->contains('xxx')); + $this->assertFalse($this->stringable('taylor')->contains(['xxx'])); + $this->assertFalse($this->stringable('taylor')->contains('')); + } + + public function testContainsAll() + { + $this->assertTrue($this->stringable('taylor otwell')->containsAll(['taylor', 'otwell'])); + $this->assertTrue($this->stringable('taylor otwell')->containsAll(['TAYLOR', 'OTWELL'], true)); + $this->assertTrue($this->stringable('taylor otwell')->containsAll(collect(['taylor', 'otwell']))); + $this->assertTrue($this->stringable('taylor otwell')->containsAll(['taylor'])); + $this->assertFalse($this->stringable('taylor otwell')->containsAll(['taylor', 'xxx'])); + } + + public function testDoesntContain() + { + $this->assertTrue($this->stringable('taylor')->doesntContain('xxx')); + $this->assertTrue($this->stringable('taylor')->doesntContain(['xxx'])); + $this->assertTrue($this->stringable('taylor')->doesntContain(['xxx', 'yyy'])); + $this->assertTrue($this->stringable('taylor')->doesntContain(collect(['xxx', 'yyy']))); + $this->assertTrue($this->stringable('taylor')->doesntContain('')); + $this->assertFalse($this->stringable('taylor')->doesntContain('ylo')); + $this->assertFalse($this->stringable('taylor')->doesntContain('taylor')); + $this->assertFalse($this->stringable('taylor')->doesntContain(['xxx', 'ylo'])); + $this->assertFalse($this->stringable('taylor')->doesntContain(['LOR'], true)); + } + + public function testParseCallback() + { + $this->assertEquals(['Class', 'method'], $this->stringable('Class@method')->parseCallback('foo')); + $this->assertEquals(['Class', 'foo'], $this->stringable('Class')->parseCallback('foo')); + $this->assertEquals(['Class', null], $this->stringable('Class')->parseCallback()); + } + + public function testSlug() + { + $this->assertSame('hello-world', (string) $this->stringable('hello world')->slug()); + $this->assertSame('hello-world', (string) $this->stringable('hello-world')->slug()); + $this->assertSame('hello-world', (string) $this->stringable('hello_world')->slug()); + $this->assertSame('hello_world', (string) $this->stringable('hello_world')->slug('_')); + $this->assertSame('user-at-host', (string) $this->stringable('user@host')->slug()); + $this->assertSame('سلام-دنیا', (string) $this->stringable('سلام دنیا')->slug('-', null)); + $this->assertSame('sometext', (string) $this->stringable('some text')->slug('')); + $this->assertSame('', (string) $this->stringable('')->slug('')); + $this->assertSame('', (string) $this->stringable('')->slug()); + } + + public function testSquish() + { + $this->assertSame('words with spaces', (string) $this->stringable(' words with spaces ')->squish()); + $this->assertSame('words with spaces', (string) $this->stringable("words\t\twith\n\nspaces")->squish()); + $this->assertSame('words with spaces', (string) $this->stringable(' + words + with + spaces + ')->squish()); + $this->assertSame('laravel php framework', (string) $this->stringable('   laravel   php   framework   ')->squish()); + $this->assertSame('123', (string) $this->stringable('  123   ')->squish()); + $this->assertSame('だ', (string) $this->stringable('だ')->squish()); + $this->assertSame('ム', (string) $this->stringable('ム')->squish()); + $this->assertSame('だ', (string) $this->stringable('  だ   ')->squish()); + $this->assertSame('ム', (string) $this->stringable('  ム   ')->squish()); + $this->assertSame('ム', (string) $this->stringable('  ム    ')->squish()); + } + + public function testStart() + { + $this->assertSame('/test/string', (string) $this->stringable('test/string')->start('/')); + $this->assertSame('/test/string', (string) $this->stringable('/test/string')->start('/')); + $this->assertSame('/test/string', (string) $this->stringable('//test/string')->start('/')); + } + + public function testFinish() + { + $this->assertSame('abbc', (string) $this->stringable('ab')->finish('bc')); + $this->assertSame('abbc', (string) $this->stringable('abbcbc')->finish('bc')); + $this->assertSame('abcbbc', (string) $this->stringable('abcbbcbc')->finish('bc')); + } + + public function testIs() + { + $this->assertTrue($this->stringable('/')->is('/')); + $this->assertFalse($this->stringable('/')->is(' /')); + $this->assertFalse($this->stringable('/a')->is('/')); + $this->assertTrue($this->stringable('foo/bar/baz')->is('foo/*')); + + $this->assertTrue($this->stringable('App\Class@method')->is('*@*')); + $this->assertTrue($this->stringable('app\Class@')->is('*@*')); + $this->assertTrue($this->stringable('@method')->is('*@*')); + + // is case sensitive + $this->assertFalse($this->stringable('foo/bar/baz')->is('*BAZ*')); + $this->assertFalse($this->stringable('foo/bar/baz')->is('*FOO*')); + $this->assertFalse($this->stringable('a')->is('A')); + + // is not case sensitive + $this->assertTrue($this->stringable('a')->is('A', true)); + $this->assertTrue($this->stringable('foo/bar/baz')->is('*BAZ*', true)); + $this->assertTrue($this->stringable('a/')->is(['A*', 'B*'], true)); + $this->assertFalse($this->stringable('f/')->is(['A*', 'B*'], true)); + $this->assertTrue($this->stringable('foo')->is('FOO', true)); + $this->assertTrue($this->stringable('foo/bar/baz')->is('*FOO*', true)); + $this->assertTrue($this->stringable('FOO/bar')->is('foo/*', true)); + + // Accepts array of patterns + $this->assertTrue($this->stringable('a/')->is(['a*', 'b*'])); + $this->assertTrue($this->stringable('b/')->is(['a*', 'b*'])); + $this->assertFalse($this->stringable('f/')->is(['a*', 'b*'])); + + // numeric values and patterns + $this->assertFalse($this->stringable(123)->is(['a*', 'b*'])); + $this->assertTrue($this->stringable(11211)->is(['*2*', 'b*'])); + + $this->assertTrue($this->stringable('blah/baz/foo')->is('*/foo')); + + $valueObject = new StringableObjectStub('foo/bar/baz'); + $patternObject = new StringableObjectStub('foo/*'); + + $this->assertTrue($this->stringable($valueObject)->is('foo/bar/baz')); + $this->assertTrue($this->stringable($valueObject)->is($patternObject)); + + // empty patterns + $this->assertFalse($this->stringable('test')->is([])); + } + + public function testIsWithMultilineStrings() + { + $this->assertFalse($this->stringable("/\n")->is('/')); + $this->assertTrue($this->stringable("/\n")->is('/*')); + $this->assertTrue($this->stringable("/\n")->is('*/*')); + $this->assertTrue($this->stringable("\n/\n")->is('*/*')); + + $this->assertTrue($this->stringable("\n")->is('*')); + $this->assertTrue($this->stringable("\n\n")->is('*')); + $this->assertFalse($this->stringable("\n")->is('')); + $this->assertFalse($this->stringable("\n\n")->is('')); + + $multilineValue = <<<'VALUE' + assertTrue($this->stringable($multilineValue)->is($multilineValue)); + $this->assertTrue($this->stringable($multilineValue)->is('*')); + $this->assertTrue($this->stringable($multilineValue)->is('*namespace Illuminate\Tests\*')); + $this->assertFalse($this->stringable($multilineValue)->is('namespace Illuminate\Tests\*')); + $this->assertFalse($this->stringable($multilineValue)->is('*namespace Illuminate\Tests')); + $this->assertTrue($this->stringable($multilineValue)->is('assertTrue($this->stringable($multilineValue)->is('assertFalse($this->stringable($multilineValue)->is('use Exception;')); + $this->assertFalse($this->stringable($multilineValue)->is('use Exception;*')); + $this->assertTrue($this->stringable($multilineValue)->is('*use Exception;')); + } + + public function testKebab() + { + $this->assertSame('laravel-php-framework', (string) $this->stringable('LaravelPhpFramework')->kebab()); + } + + public function testLower() + { + $this->assertSame('foo bar baz', (string) $this->stringable('FOO BAR BAZ')->lower()); + $this->assertSame('foo bar baz', (string) $this->stringable('fOo Bar bAz')->lower()); + } + + public function testUpper() + { + $this->assertSame('FOO BAR BAZ', (string) $this->stringable('foo bar baz')->upper()); + $this->assertSame('FOO BAR BAZ', (string) $this->stringable('foO bAr BaZ')->upper()); + } + + public function testLimit() + { + $this->assertSame( + 'Laravel is...', + (string) $this->stringable('Laravel is a free, open source PHP web application framework.')->limit(10) + ); + $this->assertSame('这是一...', (string) $this->stringable('这是一段中文')->limit(6)); + + $string = 'The PHP framework for web artisans.'; + $this->assertSame('The PHP...', (string) $this->stringable($string)->limit(7)); + $this->assertSame('The PHP', (string) $this->stringable($string)->limit(7, '')); + $this->assertSame('The PHP framework for web artisans.', (string) $this->stringable($string)->limit(100)); + + $nonAsciiString = '这是一段中文'; + $this->assertSame('这是一...', (string) $this->stringable($nonAsciiString)->limit(6)); + $this->assertSame('这是一', (string) $this->stringable($nonAsciiString)->limit(6, '')); + } + + public function testLength() + { + $this->assertSame(11, $this->stringable('foo bar baz')->length()); + $this->assertSame(11, $this->stringable('foo bar baz')->length('UTF-8')); + } + + public function testReplace() + { + $this->assertSame('foo/foo/foo', (string) $this->stringable('?/?/?')->replace('?', 'foo')); + $this->assertSame('foo/foo/foo', (string) $this->stringable('x/x/x')->replace('X', 'foo', false)); + $this->assertSame('bar/bar', (string) $this->stringable('?/?')->replace('?', 'bar')); + $this->assertSame('?/?/?', (string) $this->stringable('? ? ?')->replace(' ', '/')); + $this->assertSame('foo/bar/baz/bam', (string) $this->stringable('?1/?2/?3/?4')->replace(['?1', '?2', '?3', '?4'], ['foo', 'bar', 'baz', 'bam'])); + $this->assertSame('?1/?2/?3/?4', (string) $this->stringable('foo/bar/baz/bam')->replace(['Foo', 'BaR', 'BAZ', 'bAm'], ['?1', '?2', '?3', '?4'], false)); + $this->assertSame('foo/bar/baz/bam', (string) $this->stringable('?1/?2/?3/?4')->replace(collect(['?1', '?2', '?3', '?4']), collect(['foo', 'bar', 'baz', 'bam']))); + } + + public function testReplaceArray() + { + $this->assertSame('foo/bar/baz', (string) $this->stringable('?/?/?')->replaceArray('?', ['foo', 'bar', 'baz'])); + $this->assertSame('foo/bar/baz/?', (string) $this->stringable('?/?/?/?')->replaceArray('?', ['foo', 'bar', 'baz'])); + $this->assertSame('foo/bar', (string) $this->stringable('?/?')->replaceArray('?', ['foo', 'bar', 'baz'])); + $this->assertSame('?/?/?', (string) $this->stringable('?/?/?')->replaceArray('x', ['foo', 'bar', 'baz'])); + $this->assertSame('foo?/bar/baz', (string) $this->stringable('?/?/?')->replaceArray('?', ['foo?', 'bar', 'baz'])); + $this->assertSame('foo/bar', (string) $this->stringable('?/?')->replaceArray('?', [1 => 'foo', 2 => 'bar'])); + $this->assertSame('foo/bar', (string) $this->stringable('?/?')->replaceArray('?', ['x' => 'foo', 'y' => 'bar'])); + $this->assertSame('foo/bar', (string) $this->stringable('?/?')->replaceArray('?', collect(['x' => 'foo', 'y' => 'bar']))); + } + + public function testReplaceFirst() + { + $this->assertSame('fooqux foobar', (string) $this->stringable('foobar foobar')->replaceFirst('bar', 'qux')); + $this->assertSame('foo/qux? foo/bar?', (string) $this->stringable('foo/bar? foo/bar?')->replaceFirst('bar?', 'qux?')); + $this->assertSame('foo foobar', (string) $this->stringable('foobar foobar')->replaceFirst('bar', '')); + $this->assertSame('foobar foobar', (string) $this->stringable('foobar foobar')->replaceFirst('xxx', 'yyy')); + $this->assertSame('foobar foobar', (string) $this->stringable('foobar foobar')->replaceFirst('', 'yyy')); + // Test for multibyte string support + $this->assertSame('Jxxxnköping Malmö', (string) $this->stringable('Jönköping Malmö')->replaceFirst('ö', 'xxx')); + $this->assertSame('Jönköping Malmö', (string) $this->stringable('Jönköping Malmö')->replaceFirst('', 'yyy')); + } + + public function testReplaceStart() + { + $this->assertSame('foobar foobar', (string) $this->stringable('foobar foobar')->replaceStart('bar', 'qux')); + $this->assertSame('foo/bar? foo/bar?', (string) $this->stringable('foo/bar? foo/bar?')->replaceStart('bar?', 'qux?')); + $this->assertSame('quxbar foobar', (string) $this->stringable('foobar foobar')->replaceStart('foo', 'qux')); + $this->assertSame('qux? foo/bar?', (string) $this->stringable('foo/bar? foo/bar?')->replaceStart('foo/bar?', 'qux?')); + $this->assertSame('bar foobar', (string) $this->stringable('foobar foobar')->replaceStart('foo', '')); + $this->assertSame('1', (string) $this->stringable('0')->replaceStart(0, '1')); + // Test for multibyte string support + $this->assertSame('xxxnköping Malmö', (string) $this->stringable('Jönköping Malmö')->replaceStart('Jö', 'xxx')); + $this->assertSame('Jönköping Malmö', (string) $this->stringable('Jönköping Malmö')->replaceStart('', 'yyy')); + } + + public function testReplaceLast() + { + $this->assertSame('foobar fooqux', (string) $this->stringable('foobar foobar')->replaceLast('bar', 'qux')); + $this->assertSame('foo/bar? foo/qux?', (string) $this->stringable('foo/bar? foo/bar?')->replaceLast('bar?', 'qux?')); + $this->assertSame('foobar foo', (string) $this->stringable('foobar foobar')->replaceLast('bar', '')); + $this->assertSame('foobar foobar', (string) $this->stringable('foobar foobar')->replaceLast('xxx', 'yyy')); + $this->assertSame('foobar foobar', (string) $this->stringable('foobar foobar')->replaceLast('', 'yyy')); + // Test for multibyte string support + $this->assertSame('Malmö Jönkxxxping', (string) $this->stringable('Malmö Jönköping')->replaceLast('ö', 'xxx')); + $this->assertSame('Malmö Jönköping', (string) $this->stringable('Malmö Jönköping')->replaceLast('', 'yyy')); + } + + public function testReplaceEnd() + { + $this->assertSame('foobar fooqux', (string) $this->stringable('foobar foobar')->replaceEnd('bar', 'qux')); + $this->assertSame('foo/bar? foo/qux?', (string) $this->stringable('foo/bar? foo/bar?')->replaceEnd('bar?', 'qux?')); + $this->assertSame('foobar foo', (string) $this->stringable('foobar foobar')->replaceEnd('bar', '')); + $this->assertSame('foobar foobar', (string) $this->stringable('foobar foobar')->replaceLast('xxx', 'yyy')); + $this->assertSame('foobar foobar', (string) $this->stringable('foobar foobar')->replaceEnd('', 'yyy')); + $this->assertSame('fooxxx foobar', (string) $this->stringable('fooxxx foobar')->replaceEnd('xxx', 'yyy')); + + // // Test for multibyte string support + $this->assertSame('Malmö Jönköping', (string) $this->stringable('Malmö Jönköping')->replaceEnd('ö', 'xxx')); + $this->assertSame('Malmö Jönkyyy', (string) $this->stringable('Malmö Jönköping')->replaceEnd('öping', 'yyy')); + } + + public function testRemove() + { + $this->assertSame('Fbar', (string) $this->stringable('Foobar')->remove('o')); + $this->assertSame('Foo', (string) $this->stringable('Foobar')->remove('bar')); + $this->assertSame('oobar', (string) $this->stringable('Foobar')->remove('F')); + $this->assertSame('Foobar', (string) $this->stringable('Foobar')->remove('f')); + $this->assertSame('oobar', (string) $this->stringable('Foobar')->remove('f', false)); + + $this->assertSame('Fbr', (string) $this->stringable('Foobar')->remove(['o', 'a'])); + $this->assertSame('Fbr', (string) $this->stringable('Foobar')->remove(collect(['o', 'a']))); + $this->assertSame('Fooar', (string) $this->stringable('Foobar')->remove(['f', 'b'])); + $this->assertSame('ooar', (string) $this->stringable('Foobar')->remove(['f', 'b'], false)); + $this->assertSame('Foobar', (string) $this->stringable('Foo|bar')->remove(['f', '|'])); + } + + public function testReverse() + { + $this->assertSame('FooBar', (string) $this->stringable('raBooF')->reverse()); + $this->assertSame('Teniszütő', (string) $this->stringable('őtüzsineT')->reverse()); + $this->assertSame('❤MultiByte☆', (string) $this->stringable('☆etyBitluM❤')->reverse()); + } + + public function testSnake() + { + $this->assertSame('laravel_p_h_p_framework', (string) $this->stringable('LaravelPHPFramework')->snake()); + $this->assertSame('laravel_php_framework', (string) $this->stringable('LaravelPhpFramework')->snake()); + $this->assertSame('laravel php framework', (string) $this->stringable('LaravelPhpFramework')->snake(' ')); + $this->assertSame('laravel_php_framework', (string) $this->stringable('Laravel Php Framework')->snake()); + $this->assertSame('laravel_php_framework', (string) $this->stringable('Laravel Php Framework ')->snake()); + // ensure cache keys don't overlap + $this->assertSame('laravel__php__framework', (string) $this->stringable('LaravelPhpFramework')->snake('__')); + $this->assertSame('laravel_php_framework_', (string) $this->stringable('LaravelPhpFramework_')->snake('_')); + $this->assertSame('laravel_php_framework', (string) $this->stringable('laravel php Framework')->snake()); + $this->assertSame('laravel_php_frame_work', (string) $this->stringable('laravel php FrameWork')->snake()); + // prevent breaking changes + $this->assertSame('foo-bar', (string) $this->stringable('foo-bar')->snake()); + $this->assertSame('foo-_bar', (string) $this->stringable('Foo-Bar')->snake()); + $this->assertSame('foo__bar', (string) $this->stringable('Foo_Bar')->snake()); + $this->assertSame('żółtałódka', (string) $this->stringable('ŻółtaŁódka')->snake()); + } + + public function testStudly() + { + $this->assertSame('LaravelPHPFramework', (string) $this->stringable('laravel_p_h_p_framework')->studly()); + $this->assertSame('LaravelPhpFramework', (string) $this->stringable('laravel_php_framework')->studly()); + $this->assertSame('LaravelPhPFramework', (string) $this->stringable('laravel-phP-framework')->studly()); + $this->assertSame('LaravelPhpFramework', (string) $this->stringable('laravel -_- php -_- framework ')->studly()); + + $this->assertSame('FooBar', (string) $this->stringable('fooBar')->studly()); + $this->assertSame('FooBar', (string) $this->stringable('foo_bar')->studly()); + $this->assertSame('FooBar', (string) $this->stringable('foo_bar')->studly()); // test cache + $this->assertSame('FooBarBaz', (string) $this->stringable('foo-barBaz')->studly()); + $this->assertSame('FooBarBaz', (string) $this->stringable('foo-bar_baz')->studly()); + } + + public function testPascal() + { + $this->assertSame('LaravelPHPFramework', (string) $this->stringable('laravel_p_h_p_framework')->pascal()); + $this->assertSame('LaravelPhpFramework', (string) $this->stringable('laravel_php_framework')->pascal()); + $this->assertSame('LaravelPhPFramework', (string) $this->stringable('laravel-phP-framework')->pascal()); + $this->assertSame('LaravelPhpFramework', (string) $this->stringable('laravel -_- php -_- framework ')->pascal()); + + $this->assertSame('FooBar', (string) $this->stringable('fooBar')->pascal()); + $this->assertSame('FooBar', (string) $this->stringable('foo_bar')->pascal()); + $this->assertSame('FooBar', (string) $this->stringable('foo_bar')->pascal()); // test cache + $this->assertSame('FooBarBaz', (string) $this->stringable('foo-barBaz')->pascal()); + $this->assertSame('FooBarBaz', (string) $this->stringable('foo-bar_baz')->pascal()); + } + + public function testCamel() + { + $this->assertSame('laravelPHPFramework', (string) $this->stringable('Laravel_p_h_p_framework')->camel()); + $this->assertSame('laravelPhpFramework', (string) $this->stringable('Laravel_php_framework')->camel()); + $this->assertSame('laravelPhPFramework', (string) $this->stringable('Laravel-phP-framework')->camel()); + $this->assertSame('laravelPhpFramework', (string) $this->stringable('Laravel -_- php -_- framework ')->camel()); + + $this->assertSame('fooBar', (string) $this->stringable('FooBar')->camel()); + $this->assertSame('fooBar', (string) $this->stringable('foo_bar')->camel()); + $this->assertSame('fooBar', (string) $this->stringable('foo_bar')->camel()); // test cache + $this->assertSame('fooBarBaz', (string) $this->stringable('Foo-barBaz')->camel()); + $this->assertSame('fooBarBaz', (string) $this->stringable('foo-bar_baz')->camel()); + } + + public function testCharAt() + { + $this->assertEquals('р', $this->stringable('Привет, мир!')->charAt(1)); + $this->assertEquals('ち', $this->stringable('「こんにちは世界」')->charAt(4)); + $this->assertEquals('w', $this->stringable('Привет, world!')->charAt(8)); + $this->assertEquals('界', $this->stringable('「こんにちは世界」')->charAt(-2)); + $this->assertEquals(null, $this->stringable('「こんにちは世界」')->charAt(-200)); + $this->assertEquals(null, $this->stringable('Привет, мир!')->charAt('Привет, мир!', 100)); + } + + public function testSubstr() + { + $this->assertSame('Ё', (string) $this->stringable('БГДЖИЛЁ')->substr(-1)); + $this->assertSame('ЛЁ', (string) $this->stringable('БГДЖИЛЁ')->substr(-2)); + $this->assertSame('И', (string) $this->stringable('БГДЖИЛЁ')->substr(-3, 1)); + $this->assertSame('ДЖИЛ', (string) $this->stringable('БГДЖИЛЁ')->substr(2, -1)); + $this->assertSame('', (string) $this->stringable('БГДЖИЛЁ')->substr(4, -4)); + $this->assertSame('ИЛ', (string) $this->stringable('БГДЖИЛЁ')->substr(-3, -1)); + $this->assertSame('ГДЖИЛЁ', (string) $this->stringable('БГДЖИЛЁ')->substr(1)); + $this->assertSame('ГДЖ', (string) $this->stringable('БГДЖИЛЁ')->substr(1, 3)); + $this->assertSame('БГДЖ', (string) $this->stringable('БГДЖИЛЁ')->substr(0, 4)); + $this->assertSame('Ё', (string) $this->stringable('БГДЖИЛЁ')->substr(-1, 1)); + $this->assertSame('', (string) $this->stringable('Б')->substr(2)); + } + + public function testSwap() + { + $this->assertSame('PHP 8 is fantastic', (string) $this->stringable('PHP is awesome')->swap([ + 'PHP' => 'PHP 8', + 'awesome' => 'fantastic', + ])); + } + + public function testSubstrCount() + { + $this->assertSame(3, $this->stringable('laravelPHPFramework')->substrCount('a')); + $this->assertSame(0, $this->stringable('laravelPHPFramework')->substrCount('z')); + $this->assertSame(1, $this->stringable('laravelPHPFramework')->substrCount('l', 2)); + $this->assertSame(0, $this->stringable('laravelPHPFramework')->substrCount('z', 2)); + $this->assertSame(1, $this->stringable('laravelPHPFramework')->substrCount('k', -1)); + $this->assertSame(1, $this->stringable('laravelPHPFramework')->substrCount('k', -1)); + $this->assertSame(1, $this->stringable('laravelPHPFramework')->substrCount('a', 1, 2)); + $this->assertSame(1, $this->stringable('laravelPHPFramework')->substrCount('a', 1, 2)); + $this->assertSame(3, $this->stringable('laravelPHPFramework')->substrCount('a', 1, -2)); + $this->assertSame(1, $this->stringable('laravelPHPFramework')->substrCount('a', -10, -3)); + } + + public function testPosition() + { + $this->assertSame(7, $this->stringable('Hello, World!')->position('W')); + $this->assertSame(10, $this->stringable('This is a test string.')->position('test')); + $this->assertSame(23, $this->stringable('This is a test string, test again.')->position('test', 15)); + $this->assertSame(0, $this->stringable('Hello, World!')->position('Hello')); + $this->assertSame(7, $this->stringable('Hello, World!')->position('World!')); + $this->assertSame(10, $this->stringable('This is a tEsT string.')->position('tEsT', 0, 'UTF-8')); + $this->assertSame(7, $this->stringable('Hello, World!')->position('W', -6)); + $this->assertSame(18, $this->stringable('Äpfel, Birnen und Kirschen')->position('Kirschen', -10, 'UTF-8')); + $this->assertSame(9, $this->stringable('@%€/=!"][$')->position('$', 0, 'UTF-8')); + $this->assertFalse($this->stringable('Hello, World!')->position('w', 0, 'UTF-8')); + $this->assertFalse($this->stringable('Hello, World!')->position('X', 0, 'UTF-8')); + $this->assertFalse($this->stringable('')->position('test')); + $this->assertFalse($this->stringable('Hello, World!')->position('X')); + } + + public function testSubstrReplace() + { + $this->assertSame('12:00', (string) $this->stringable('1200')->substrReplace(':', 2, 0)); + $this->assertSame('The Laravel Framework', (string) $this->stringable('The Framework')->substrReplace('Laravel ', 4, 0)); + $this->assertSame('Laravel – The PHP Framework for Web Artisans', (string) $this->stringable('Laravel Framework')->substrReplace('– The PHP Framework for Web Artisans', 8)); + } + + public function testPadBoth() + { + $this->assertSame('__Alien___', (string) $this->stringable('Alien')->padBoth(10, '_')); + $this->assertSame(' Alien ', (string) $this->stringable('Alien')->padBoth(10)); + $this->assertSame(' ❤MultiByte☆ ', (string) $this->stringable('❤MultiByte☆')->padBoth(16)); + } + + public function testPadLeft() + { + $this->assertSame('-=-=-Alien', (string) $this->stringable('Alien')->padLeft(10, '-=')); + $this->assertSame(' Alien', (string) $this->stringable('Alien')->padLeft(10)); + $this->assertSame(' ❤MultiByte☆', (string) $this->stringable('❤MultiByte☆')->padLeft(16)); + } + + public function testPadRight() + { + $this->assertSame('Alien-----', (string) $this->stringable('Alien')->padRight(10, '-')); + $this->assertSame('Alien ', (string) $this->stringable('Alien')->padRight(10)); + $this->assertSame('❤MultiByte☆ ', (string) $this->stringable('❤MultiByte☆')->padRight(16)); + } + + public function testExplode() + { + $this->assertInstanceOf(Collection::class, $this->stringable('Foo Bar Baz')->explode(' ')); + + $this->assertSame('["Foo","Bar","Baz"]', (string) $this->stringable('Foo Bar Baz')->explode(' ')); + + // with limit + $this->assertSame('["Foo","Bar Baz"]', (string) $this->stringable('Foo Bar Baz')->explode(' ', 2)); + $this->assertSame('["Foo","Bar"]', (string) $this->stringable('Foo Bar Baz')->explode(' ', -1)); + } + + public function testChunk() + { + $chunks = $this->stringable('foobarbaz')->split(3); + + $this->assertInstanceOf(Collection::class, $chunks); + $this->assertSame(['foo', 'bar', 'baz'], $chunks->all()); + } + + public function testJsonSerialize() + { + $this->assertSame('"foo"', json_encode($this->stringable('foo'))); + $this->assertSame('"laravel-php-framework"', json_encode($this->stringable('LaravelPhpFramework')->kebab())); + $this->assertSame('["laravel-php-framework"]', json_encode([$this->stringable('LaravelPhpFramework')->kebab()])); + $this->assertSame('{"title":"laravel-php-framework"}', json_encode(['title' => $this->stringable('LaravelPhpFramework')->kebab()])); + } + + public function testTap() + { + $stringable = $this->stringable('foobarbaz'); + + $fromTheTap = ''; + + $stringable = $stringable->tap(function (Stringable $string) use (&$fromTheTap) { + $fromTheTap = $string->substr(0, 3); + }); + + $this->assertSame('foo', (string) $fromTheTap); + $this->assertSame('foobarbaz', (string) $stringable); + } + + public function testPipe() + { + $callback = function ($stringable) { + return 'bar'; + }; + + $this->assertInstanceOf(Stringable::class, $this->stringable('foo')->pipe($callback)); + $this->assertSame('bar', (string) $this->stringable('foo')->pipe($callback)); + } + + public function testMarkdown() + { + $this->assertEquals("

hello world

\n", $this->stringable('*hello world*')->markdown()); + $this->assertEquals("

hello world

\n", $this->stringable('# hello world')->markdown()); + + $extension = new class implements ExtensionInterface { + public bool $configured = false; + + public function register(EnvironmentBuilderInterface $environment): void + { + $this->configured = true; + } + }; + $this->stringable('# hello world')->markdown([], [$extension]); + $this->assertTrue($extension->configured); + } + + public function testInlineMarkdown() + { + $this->assertEquals("hello world\n", $this->stringable('*hello world*')->inlineMarkdown()); + $this->assertEquals("Laravel\n", $this->stringable('[**Laravel**](https://laravel.com)')->inlineMarkdown()); + + $extension = new class implements ExtensionInterface { + public bool $configured = false; + + public function register(EnvironmentBuilderInterface $environment): void + { + $this->configured = true; + } + }; + + $this->stringable('# hello world')->inlineMarkdown([], [$extension]); + $this->assertTrue($extension->configured); + } + + public function testMask() + { + $this->assertSame('tay*************', (string) $this->stringable('taylor@email.com')->mask('*', 3)); + $this->assertSame('******@email.com', (string) $this->stringable('taylor@email.com')->mask('*', 0, 6)); + $this->assertSame('tay*************', (string) $this->stringable('taylor@email.com')->mask('*', -13)); + $this->assertSame('tay***@email.com', (string) $this->stringable('taylor@email.com')->mask('*', -13, 3)); + + $this->assertSame('****************', (string) $this->stringable('taylor@email.com')->mask('*', -17)); + $this->assertSame('*****r@email.com', (string) $this->stringable('taylor@email.com')->mask('*', -99, 5)); + + $this->assertSame('taylor@email.com', (string) $this->stringable('taylor@email.com')->mask('*', 16)); + $this->assertSame('taylor@email.com', (string) $this->stringable('taylor@email.com')->mask('*', 16, 99)); + + $this->assertSame('taylor@email.com', (string) $this->stringable('taylor@email.com')->mask('', 3)); + + $this->assertSame('taysssssssssssss', (string) $this->stringable('taylor@email.com')->mask('something', 3)); + + $this->assertSame('这是一***', (string) $this->stringable('这是一段中文')->mask('*', 3)); + $this->assertSame('**一段中文', (string) $this->stringable('这是一段中文')->mask('*', 0, 2)); + } + + public function testRepeat() + { + $this->assertSame('aaaaa', (string) $this->stringable('a')->repeat(5)); + $this->assertSame('', (string) $this->stringable('')->repeat(5)); + } + + public function testWordCount() + { + $this->assertEquals(2, $this->stringable('Hello, world!')->wordCount()); + $this->assertEquals(10, $this->stringable('Hi, this is my first contribution to the Laravel framework.')->wordCount()); + } + + public function testWrap() + { + $this->assertEquals('This is me!', $this->stringable('is')->wrap('This ', ' me!')); + $this->assertEquals('"value"', $this->stringable('value')->wrap('"')); + } + + public function testUnwrap() + { + $this->assertEquals('value', $this->stringable('"value"')->unwrap('"')); + $this->assertEquals('bar', $this->stringable('foo-bar-baz')->unwrap('foo-', '-baz')); + $this->assertEquals('some: "json"', $this->stringable('{some: "json"}')->unwrap('{', '}')); + } + + public function testToHtmlString() + { + $this->assertEquals( + new HtmlString('

Test String

'), + $this->stringable('

Test String

')->toHtmlString() + ); + } + + public function testStripTags() + { + $this->assertSame('beforeafter', (string) $this->stringable('before
after')->stripTags()); + $this->assertSame('before
after', (string) $this->stringable('before
after')->stripTags('
')); + $this->assertSame('before
after', (string) $this->stringable('before
after')->stripTags('
')); + $this->assertSame('before
after', (string) $this->stringable('before
after')->stripTags('
')); + } + + public function testReplaceMatches() + { + $stringable = $this->stringable('Hello world!'); + $result = $stringable->replaceMatches('/world/', function ($match) { + return strtoupper($match[0]); + }); + + $this->assertSame('Hello WORLD!', $result->value); + + $stringable = $this->stringable('apple orange apple'); + $result = $stringable->replaceMatches('/apple/', 'fruit', 1); + + $this->assertSame('fruit orange apple', $result->value); + } + + public function testScan() + { + $this->assertSame([123456], $this->stringable('SN/123456')->scan('SN/%d')->toArray()); + $this->assertSame(['Otwell', 'Taylor'], $this->stringable('Otwell, Taylor')->scan('%[^,],%s')->toArray()); + $this->assertSame(['filename', 'jpg'], $this->stringable('filename.jpg')->scan('%[^.].%s')->toArray()); + } + + public function testGet() + { + $this->assertSame('foo', $this->stringable('foo')->value()); + $this->assertSame('foo', $this->stringable('foo')->toString()); + } + + public function testExactly() + { + $this->assertTrue($this->stringable('foo')->exactly($this->stringable('foo'))); + $this->assertTrue($this->stringable('foo')->exactly('foo')); + + $this->assertFalse($this->stringable('Foo')->exactly($this->stringable('foo'))); + $this->assertFalse($this->stringable('Foo')->exactly('foo')); + $this->assertFalse($this->stringable('[]')->exactly([])); + $this->assertFalse($this->stringable('0')->exactly(0)); + } + + public function testToInteger() + { + $this->assertSame(123, $this->stringable('123')->toInteger()); + $this->assertSame(456, $this->stringable(456)->toInteger()); + $this->assertSame(78, $this->stringable('078')->toInteger()); + $this->assertSame(901, $this->stringable(' 901')->toInteger()); + $this->assertSame(0, $this->stringable('nan')->toInteger()); + $this->assertSame(1, $this->stringable('1ab')->toInteger()); + $this->assertSame(2, $this->stringable('2_000')->toInteger()); + } + + public function testToFloat() + { + $this->assertSame(1.23, $this->stringable('1.23')->toFloat()); + $this->assertSame(45.6, $this->stringable(45.6)->toFloat()); + $this->assertSame(.6, $this->stringable('.6')->toFloat()); + $this->assertSame(0.78, $this->stringable('0.78')->toFloat()); + $this->assertSame(90.1, $this->stringable(' 90.1')->toFloat()); + $this->assertSame(0.0, $this->stringable('nan')->toFloat()); + $this->assertSame(1.0, $this->stringable('1.ab')->toFloat()); + $this->assertSame(1e3, $this->stringable('1e3')->toFloat()); + } + + public function testBooleanMethod() + { + $this->assertTrue($this->stringable(true)->toBoolean()); + $this->assertTrue($this->stringable('true')->toBoolean()); + $this->assertFalse($this->stringable('false')->toBoolean()); + $this->assertTrue($this->stringable('1')->toBoolean()); + $this->assertFalse($this->stringable('0')->toBoolean()); + $this->assertTrue($this->stringable('on')->toBoolean()); + $this->assertFalse($this->stringable('off')->toBoolean()); + $this->assertTrue($this->stringable('yes')->toBoolean()); + $this->assertFalse($this->stringable('no')->toBoolean()); + } + + public function testNumbers() + { + $this->assertSame('5551234567', (string) $this->stringable('(555) 123-4567')->numbers()); + } + + public function testToDate() + { + $current = Carbon::create(2020, 1, 1, 16, 30, 25); + + $this->assertEquals($current, $this->stringable('20-01-01 16:30:25')->toDate()); + $this->assertEquals($current, $this->stringable('1577896225')->toDate('U')); + $this->assertEquals($current, $this->stringable('20-01-01 13:30:25')->toDate(null, 'America/Santiago')); + + $this->assertTrue($this->stringable('2020-01-01')->toDate()->isSameDay($current)); + $this->assertTrue($this->stringable('16:30:25')->toDate()->isSameSecond('16:30:25')); + } + + public function testToDateThrowsException() + { + $this->expectException(\Carbon\Exceptions\InvalidFormatException::class); + + $this->stringable('not a date')->toDate(); + } + + public function testToUri() + { + $sentence = 'Laravel is a PHP framework. You can access the docs in: {https://laravel.com/docs}'; + + $uri = $this->stringable($sentence)->between('{', '}')->toUri(); + + $this->assertInstanceOf(Uri::class, $uri); + $this->assertSame('https://laravel.com/docs', (string) $uri); + $this->assertSame('https://laravel.com/docs', $uri->toHtml()); + } + + public function testArrayAccess() + { + $str = $this->stringable('my string'); + $this->assertSame('m', $str[0]); + $this->assertSame('t', $str[4]); + $this->assertTrue(isset($str[2])); + $this->assertFalse(isset($str[10])); + } + + public function testToBase64() + { + $this->assertSame(base64_encode('foo'), (string) $this->stringable('foo')->toBase64()); + $this->assertSame(base64_encode('foobar'), (string) $this->stringable('foobar')->toBase64()); + $this->assertSame(base64_encode('foobarbaz'), (string) $this->stringable('foobarbaz')->toBase64()); + } + + public function testFromBase64() + { + $this->assertSame('foo', (string) $this->stringable(base64_encode('foo'))->fromBase64()); + $this->assertSame('foobar', (string) $this->stringable(base64_encode('foobar'))->fromBase64(true)); + $this->assertSame('foobarbaz', (string) $this->stringable(base64_encode('foobarbaz'))->fromBase64()); + } + + public function testHash() + { + $this->assertSame(hash('xxh3', 'foo'), (string) $this->stringable('foo')->hash('xxh3')); + $this->assertSame(hash('xxh3', 'foobar'), (string) $this->stringable('foobar')->hash('xxh3')); + $this->assertSame(hash('sha256', 'foobarbaz'), (string) $this->stringable('foobarbaz')->hash('sha256')); + } + + public function testEncryptAndDecrypt() + { + Container::setInstance($this->container = new Container(new DefinitionSource([]))); + + $this->container->bind('encrypter', fn () => new Encrypter(str_repeat('b', 16))); + + $encrypted = $this->stringable('foo')->encrypt(); + + $this->assertNotSame('foo', $encrypted->value()); + $this->assertSame('foo', $encrypted->decrypt()->value()); + } +} diff --git a/tests/Support/SupportTappableTest.php b/tests/Support/SupportTappableTest.php new file mode 100644 index 000000000..70ca29b81 --- /dev/null +++ b/tests/Support/SupportTappableTest.php @@ -0,0 +1,79 @@ +tap(function ($tappable) { + $tappable->setName('MyName'); + })->getName(); + + $this->assertSame('MyName', $name); + } + + public function testTappableClassWithInvokableClass() + { + $name = TappableClass::make()->tap(new class { + public function __invoke($tappable) + { + $tappable->setName('MyName'); + } + })->getName(); + + $this->assertSame('MyName', $name); + } + + public function testTappableClassWithNoneInvokableClass() + { + $this->expectException('Error'); + + $name = TappableClass::make()->tap(new class { + public function setName($tappable) + { + $tappable->setName('MyName'); + } + })->getName(); + + $this->assertSame('MyName', $name); + } + + public function testTappableClassWithoutCallback() + { + $name = TappableClass::make()->tap()->setName('MyName')->getName(); + + $this->assertSame('MyName', $name); + } +} + +class TappableClass +{ + use Tappable; + + private string $name = ''; + + public static function make() + { + return new static(); + } + + public function setName(string $name): void + { + $this->name = $name; + } + + public function getName(): string + { + return $this->name; + } +} diff --git a/tests/Support/Testing/Fakes/ExceptionHandlerFakeTest.php b/tests/Support/Testing/Fakes/ExceptionHandlerFakeTest.php new file mode 100644 index 000000000..8bc05cc4e --- /dev/null +++ b/tests/Support/Testing/Fakes/ExceptionHandlerFakeTest.php @@ -0,0 +1,236 @@ +assertInstanceOf(ExceptionHandlerFake::class, $fake); + $this->assertInstanceOf(ExceptionHandlerFake::class, Exceptions::getFacadeRoot()); + $this->assertInstanceOf(Handler::class, $fake->handler()); + } + + public function testFakeCalledTwiceReturnsNewFakeWithOriginalHandler(): void + { + $fake1 = Exceptions::fake(); + $fake2 = Exceptions::fake(); + + $this->assertNotSame($fake1, $fake2); + $this->assertInstanceOf(Handler::class, $fake2->handler()); + } + + public function testAssertReportedWithClassString(): void + { + Exceptions::fake(); + + Exceptions::report(new RuntimeException('test')); + + Exceptions::assertReported(RuntimeException::class); + } + + public function testAssertReportedWithClosure(): void + { + Exceptions::fake(); + + Exceptions::report(new RuntimeException('test message')); + + Exceptions::assertReported(fn (RuntimeException $e) => $e->getMessage() === 'test message'); + } + + public function testAssertReportedFailsWhenExceptionNotReported(): void + { + $this->expectException(ExpectationFailedException::class); + $this->expectExceptionMessage('The expected [InvalidArgumentException] exception was not reported.'); + + Exceptions::fake(); + + Exceptions::report(new RuntimeException('test')); + + Exceptions::assertReported(InvalidArgumentException::class); + } + + public function testAssertReportedWithClosureFailsWhenNoMatch(): void + { + $this->expectException(ExpectationFailedException::class); + $this->expectExceptionMessage('The expected [RuntimeException] exception was not reported.'); + + Exceptions::fake(); + + Exceptions::report(new RuntimeException('wrong message')); + + Exceptions::assertReported(fn (RuntimeException $e) => $e->getMessage() === 'right message'); + } + + public function testAssertReportedCount(): void + { + Exceptions::fake(); + + Exceptions::report(new RuntimeException('test 1')); + Exceptions::report(new RuntimeException('test 2')); + + Exceptions::assertReportedCount(2); + } + + public function testAssertReportedCountFails(): void + { + $this->expectException(ExpectationFailedException::class); + $this->expectExceptionMessage('The total number of exceptions reported was 2 instead of 1.'); + + Exceptions::fake(); + + Exceptions::report(new RuntimeException('test 1')); + Exceptions::report(new RuntimeException('test 2')); + + Exceptions::assertReportedCount(1); + } + + public function testAssertNotReported(): void + { + Exceptions::fake(); + + Exceptions::report(new RuntimeException('test')); + + Exceptions::assertNotReported(InvalidArgumentException::class); + } + + public function testAssertNotReportedFails(): void + { + $this->expectException(ExpectationFailedException::class); + $this->expectExceptionMessage('The expected [RuntimeException] exception was reported.'); + + Exceptions::fake(); + + Exceptions::report(new RuntimeException('test')); + + Exceptions::assertNotReported(RuntimeException::class); + } + + public function testAssertNotReportedWithClosureFails(): void + { + $this->expectException(ExpectationFailedException::class); + $this->expectExceptionMessage('The expected [RuntimeException] exception was reported.'); + + Exceptions::fake(); + + Exceptions::report(new RuntimeException('test message')); + + Exceptions::assertNotReported(fn (RuntimeException $e) => $e->getMessage() === 'test message'); + } + + public function testAssertNothingReported(): void + { + Exceptions::fake(); + + Exceptions::assertNothingReported(); + } + + public function testAssertNothingReportedFails(): void + { + $this->expectException(ExpectationFailedException::class); + $this->expectExceptionMessage('The following exceptions were reported: RuntimeException, InvalidArgumentException.'); + + Exceptions::fake(); + + Exceptions::report(new RuntimeException('test 1')); + Exceptions::report(new InvalidArgumentException('test 2')); + + Exceptions::assertNothingReported(); + } + + public function testReportedReturnsAllReportedExceptions(): void + { + Exceptions::fake(); + + $exception1 = new RuntimeException('test 1'); + $exception2 = new InvalidArgumentException('test 2'); + + Exceptions::report($exception1); + Exceptions::report($exception2); + + $reported = Exceptions::reported(); + + $this->assertCount(2, $reported); + $this->assertSame($exception1, $reported[0]); + $this->assertSame($exception2, $reported[1]); + } + + public function testFakeWithSpecificExceptionsOnlyFakesThose(): void + { + Exceptions::fake([RuntimeException::class]); + + Exceptions::report(new RuntimeException('test 1')); + Exceptions::report(new RuntimeException('test 2')); + + Exceptions::assertReported(RuntimeException::class); + Exceptions::assertReportedCount(2); + Exceptions::assertNotReported(InvalidArgumentException::class); + } + + public function testThrowOnReport(): void + { + Exceptions::fake()->throwOnReport(); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('test exception'); + + Exceptions::report(new RuntimeException('test exception')); + } + + public function testThrowFirstReported(): void + { + Exceptions::fake(); + + Exceptions::report(new RuntimeException('first')); + Exceptions::report(new InvalidArgumentException('second')); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('first'); + + Exceptions::throwFirstReported(); + } + + public function testThrowFirstReportedDoesNothingWhenEmpty(): void + { + Exceptions::fake(); + + Exceptions::throwFirstReported(); + + $this->assertTrue(true); // No exception thrown + } + + public function testSetHandler(): void + { + $fake = Exceptions::fake(); + $newHandler = $this->createMock(ExceptionHandler::class); + + $result = $fake->setHandler($newHandler); + + $this->assertSame($fake, $result); + $this->assertSame($newHandler, $fake->handler()); + } +} diff --git a/tests/Support/Traits/InteractsWithDataTest.php b/tests/Support/Traits/InteractsWithDataTest.php index d40616315..054fb58b4 100644 --- a/tests/Support/Traits/InteractsWithDataTest.php +++ b/tests/Support/Traits/InteractsWithDataTest.php @@ -4,7 +4,7 @@ namespace Hypervel\Tests\Support\Traits; -use Hyperf\Context\ApplicationContext; +use Hypervel\Context\ApplicationContext; use Hypervel\Support\Carbon; use Hypervel\Support\Collection; use Hypervel\Support\Facades\Date; diff --git a/tests/Support/TypesenseIntegrationTestCase.php b/tests/Support/TypesenseIntegrationTestCase.php index a6e1cd6ab..b41510f54 100644 --- a/tests/Support/TypesenseIntegrationTestCase.php +++ b/tests/Support/TypesenseIntegrationTestCase.php @@ -4,31 +4,29 @@ namespace Hypervel\Tests\Support; -use Hyperf\Contract\ConfigInterface; +use Hypervel\Foundation\Testing\Concerns\InteractsWithTypesense; use Hypervel\Scout\ScoutServiceProvider; use Hypervel\Testbench\TestCase; use Throwable; -use Typesense\Client as TypesenseClient; /** * Base test case for Typesense integration tests. * - * Provides parallel-safe Typesense testing infrastructure: - * - Uses TEST_TOKEN env var (from paratest) to create unique collection prefixes - * - Configures Typesense client from environment variables - * - Cleans up test collections in setUp/tearDown + * Uses InteractsWithTypesense trait for: + * - Auto-skip: Skips tests if Typesense is unavailable (no env var needed) + * - Parallel-safe: Uses TEST_TOKEN for unique collection prefixes + * - Auto-cleanup: Removes test collections in teardown * * NOTE: This base class does NOT include RunTestsInCoroutine. Subclasses * should add the trait if they need coroutine context for their tests. * - * NOTE: Concrete test classes extending this MUST add @group integration - * and @group typesense-integration for proper test filtering in CI. - * * @internal * @coversNothing */ abstract class TypesenseIntegrationTestCase extends TestCase { + use InteractsWithTypesense; + /** * Base collection prefix for integration tests. */ @@ -39,11 +37,6 @@ abstract class TypesenseIntegrationTestCase extends TestCase */ protected string $testPrefix; - /** - * The Typesense client instance. - */ - protected TypesenseClient $typesense; - /** * Track collections created during tests for cleanup. * @@ -53,13 +46,8 @@ abstract class TypesenseIntegrationTestCase extends TestCase protected function setUp(): void { - if (! env('RUN_TYPESENSE_INTEGRATION_TESTS', false)) { - $this->markTestSkipped( - 'Typesense integration tests are disabled. Set RUN_TYPESENSE_INTEGRATION_TESTS=true to enable.' - ); - } - $this->computeTestPrefix(); + $this->typesenseTestPrefix = $this->testPrefix; // Sync trait's prefix parent::setUp(); @@ -72,18 +60,18 @@ protected function setUp(): void * * Subclasses using RunTestsInCoroutine should call this in setUpInCoroutine(). * Subclasses NOT using the trait should call this at the end of setUp(). + * + * Uses the trait's auto-skip logic - skips if Typesense is unavailable. */ protected function initializeTypesense(): void { - $this->typesense = $this->app->get(TypesenseClient::class); - $this->cleanupTestCollections(); + $this->setUpInteractsWithTypesense(); } protected function tearDown(): void { - if (isset($this->typesense)) { - $this->cleanupTestCollections(); - } + $this->tearDownInteractsWithTypesense(); + $this->createdCollections = []; parent::tearDown(); } @@ -107,7 +95,7 @@ protected function computeTestPrefix(): void */ protected function configureTypesense(): void { - $config = $this->app->get(ConfigInterface::class); + $config = $this->app->get('config'); $host = env('TYPESENSE_HOST', '127.0.0.1'); $port = env('TYPESENSE_PORT', '8108'); diff --git a/tests/Support/ValidatedInputTest.php b/tests/Support/ValidatedInputTest.php new file mode 100644 index 000000000..3f5ca8b6c --- /dev/null +++ b/tests/Support/ValidatedInputTest.php @@ -0,0 +1,549 @@ + 'Taylor', 'votes' => 100]); + + $this->assertSame('Taylor', $input->name); + $this->assertSame('Taylor', $input['name']); + $this->assertEquals(['name' => 'Taylor'], $input->all(['name'])); + $this->assertEquals(['name' => 'Taylor'], $input->only(['name'])); + $this->assertEquals(['name' => 'Taylor'], $input->except(['votes'])); + $this->assertEquals(['name' => 'Taylor', 'votes' => 100], $input->all()); + } + + public function testCanMergeItems() + { + $input = new ValidatedInput(['name' => 'Taylor']); + + $input = $input->merge(['votes' => 100]); + + $this->assertSame('Taylor', $input->name); + $this->assertSame('Taylor', $input['name']); + $this->assertEquals(['name' => 'Taylor'], $input->only(['name'])); + $this->assertEquals(['name' => 'Taylor'], $input->except(['votes'])); + $this->assertEquals(['name' => 'Taylor', 'votes' => 100], $input->all()); + } + + public function testInputExistence() + { + $inputA = new ValidatedInput(['name' => 'Taylor']); + + $this->assertTrue($inputA->has('name')); + $this->assertTrue($inputA->missing('votes')); + $this->assertTrue($inputA->missing(['votes'])); + $this->assertFalse($inputA->missing('name')); + + $inputB = new ValidatedInput(['name' => 'Taylor', 'votes' => 100]); + + $this->assertTrue($inputB->has(['name', 'votes'])); + } + + public function testExistsMethod() + { + $input = new ValidatedInput(['name' => 'Fatih', 'surname' => 'AYDIN', 'foo' => ['bar' => null, 'baz' => '']]); + + $this->assertTrue($input->exists('name')); + $this->assertTrue($input->exists('surname')); + $this->assertTrue($input->exists(['name', 'surname'])); + $this->assertTrue($input->exists('foo.bar')); + $this->assertTrue($input->exists(['name', 'foo.baz'])); + $this->assertTrue($input->exists(['name', 'foo'])); + $this->assertTrue($input->exists('foo')); + + $this->assertFalse($input->exists('votes')); + $this->assertFalse($input->exists(['name', 'votes'])); + $this->assertFalse($input->exists(['votes', 'foo.bar'])); + } + + public function testHasMethod() + { + $input = new ValidatedInput(['name' => 'Fatih', 'surname' => 'AYDIN', 'foo' => ['bar' => null, 'baz' => '']]); + + $this->assertTrue($input->has('name')); + $this->assertTrue($input->has('surname')); + $this->assertTrue($input->has(['name', 'surname'])); + $this->assertTrue($input->has('foo.bar')); + $this->assertTrue($input->has(['name', 'foo.baz'])); + $this->assertTrue($input->has(['name', 'foo'])); + $this->assertTrue($input->has('foo')); + + $this->assertFalse($input->has('votes')); + $this->assertFalse($input->has(['name', 'votes'])); + $this->assertFalse($input->has(['votes', 'foo.bar'])); + } + + public function testHasAnyMethod() + { + $input = new ValidatedInput(['name' => 'Fatih', 'surname' => 'AYDIN', 'foo' => ['bar' => null, 'baz' => '']]); + + $this->assertTrue($input->hasAny('name')); + $this->assertTrue($input->hasAny('surname')); + $this->assertTrue($input->hasAny('foo.bar')); + $this->assertTrue($input->hasAny(['name', 'surname'])); + $this->assertTrue($input->hasAny(['name', 'foo.bat'])); + $this->assertTrue($input->hasAny(['votes', 'foo'])); + + $this->assertFalse($input->hasAny('votes')); + $this->assertFalse($input->hasAny(['votes', 'foo.bat'])); + } + + public function testWhenHasMethod() + { + $input = new ValidatedInput(['name' => 'Fatih', 'age' => '', 'foo' => ['bar' => null]]); + + $name = $age = $city = $foo = $bar = $baz = false; + + $input->whenHas('name', function ($value) use (&$name) { + $name = $value; + }); + + $input->whenHas('age', function ($value) use (&$age) { + $age = $value; + }); + + $input->whenHas('city', function ($value) use (&$city) { + $city = $value; + }); + + $input->whenHas('foo', function ($value) use (&$foo) { + $foo = $value; + }); + + $input->whenHas('foo.bar', function ($value) use (&$bar) { + $bar = $value; + }); + + $input->whenHas('foo.baz', function () use (&$baz) { + $baz = 'test'; + }, function () use (&$baz) { + $baz = true; + }); + + $this->assertSame('Fatih', $name); + $this->assertSame('', $age); + $this->assertFalse($city); + $this->assertEquals(['bar' => null], $foo); + $this->assertTrue($baz); + $this->assertNull($bar); + } + + public function testFilledMethod() + { + $input = new ValidatedInput(['name' => 'Fatih', 'surname' => 'AYDIN', 'foo' => ['bar' => null, 'baz' => '']]); + + $this->assertTrue($input->filled('name')); + $this->assertTrue($input->filled('surname')); + $this->assertTrue($input->filled(['name', 'surname'])); + $this->assertTrue($input->filled(['name', 'foo'])); + $this->assertTrue($input->filled('foo')); + + $this->assertFalse($input->filled('foo.bar')); + $this->assertFalse($input->filled(['name', 'foo.baz'])); + $this->assertFalse($input->filled('votes')); + $this->assertFalse($input->filled(['name', 'votes'])); + $this->assertFalse($input->filled(['votes', 'foo.bar'])); + } + + public function testIsNotFilledMethod() + { + $input = new ValidatedInput(['name' => 'Fatih', 'surname' => 'AYDIN', 'foo' => ['bar' => null, 'baz' => '']]); + + $this->assertFalse($input->isNotFilled('name')); + $this->assertFalse($input->isNotFilled('surname')); + $this->assertFalse($input->isNotFilled(['name', 'surname'])); + $this->assertFalse($input->isNotFilled(['name', 'foo'])); + $this->assertFalse($input->isNotFilled('foo')); + $this->assertFalse($input->isNotFilled(['name', 'foo.baz'])); + $this->assertFalse($input->isNotFilled(['name', 'votes'])); + + $this->assertTrue($input->isNotFilled('foo.bar')); + $this->assertTrue($input->isNotFilled('votes')); + $this->assertTrue($input->isNotFilled(['votes', 'foo.bar'])); + } + + public function testAnyFilledMethod() + { + $input = new ValidatedInput(['name' => 'Fatih', 'surname' => 'AYDIN', 'foo' => ['bar' => null, 'baz' => '']]); + + $this->assertTrue($input->anyFilled('name')); + $this->assertTrue($input->anyFilled('surname')); + $this->assertTrue($input->anyFilled(['name', 'surname'])); + $this->assertTrue($input->anyFilled(['name', 'foo'])); + $this->assertTrue($input->anyFilled('foo')); + $this->assertTrue($input->anyFilled(['name', 'foo.baz'])); + $this->assertTrue($input->anyFilled(['name', 'votes'])); + + $this->assertFalse($input->anyFilled('foo.bar')); + $this->assertFalse($input->anyFilled('votes')); + $this->assertFalse($input->anyFilled(['votes', 'foo.bar'])); + } + + public function testWhenFilledMethod() + { + $input = new ValidatedInput(['name' => 'Fatih', 'age' => '', 'foo' => ['bar' => null]]); + + $name = $age = $city = $foo = $bar = $baz = false; + + $input->whenFilled('name', function ($value) use (&$name) { + $name = $value; + }); + + $input->whenFilled('age', function ($value) use (&$age) { + $age = $value; + }); + + $input->whenFilled('city', function ($value) use (&$city) { + $city = $value; + }); + + $input->whenFilled('foo', function ($value) use (&$foo) { + $foo = $value; + }); + + $input->whenFilled('foo.bar', function ($value) use (&$bar) { + $bar = $value; + }); + + $input->whenFilled('foo.baz', function () use (&$baz) { + $baz = 'test'; + }, function () use (&$baz) { + $baz = true; + }); + + $this->assertSame('Fatih', $name); + $this->assertEquals(['bar' => null], $foo); + $this->assertTrue($baz); + $this->assertFalse($age); + $this->assertFalse($city); + $this->assertFalse($bar); + } + + public function testMissingMethod() + { + $input = new ValidatedInput(['name' => 'Fatih', 'surname' => 'AYDIN', 'foo' => ['bar' => null, 'baz' => '']]); + + $this->assertFalse($input->missing('name')); + $this->assertFalse($input->missing('surname')); + $this->assertFalse($input->missing(['name', 'surname'])); + $this->assertFalse($input->missing('foo.bar')); + $this->assertFalse($input->missing(['name', 'foo.baz'])); + $this->assertFalse($input->missing(['name', 'foo'])); + $this->assertFalse($input->missing('foo')); + + $this->assertTrue($input->missing('votes')); + $this->assertTrue($input->missing(['name', 'votes'])); + $this->assertTrue($input->missing(['votes', 'foo.bar'])); + } + + public function testWhenMissingMethod() + { + $input = new ValidatedInput(['foo' => ['bar' => null]]); + + $name = $age = $city = $foo = $bar = $baz = false; + + $input->whenMissing('name', function () use (&$name) { + $name = 'Fatih'; + }); + + $input->whenMissing('age', function () use (&$age) { + $age = ''; + }); + + $input->whenMissing('city', function () use (&$city) { + $city = null; + }); + + $input->whenMissing('foo', function ($value) use (&$foo) { + $foo = $value; + }); + + $input->whenMissing('foo.baz', function () use (&$baz) { + $baz = true; + }); + + $input->whenMissing('foo.bar', function () use (&$bar) { + $bar = 'test'; + }, function () use (&$bar) { + $bar = true; + }); + + $this->assertSame('Fatih', $name); + $this->assertSame('', $age); + $this->assertNull($city); + $this->assertFalse($foo); + $this->assertTrue($baz); + $this->assertTrue($bar); + } + + public function testKeysMethod() + { + $input = new ValidatedInput(['name' => 'Fatih', 'surname' => 'AYDIN', 'foo' => ['bar' => null, 'baz' => '']]); + + $this->assertEquals(['name', 'surname', 'foo'], $input->keys()); + } + + public function testAllMethod() + { + $input = new ValidatedInput(['name' => 'Fatih', 'surname' => 'AYDIN', 'foo' => ['bar' => null, 'baz' => '']]); + + $this->assertEquals(['name' => 'Fatih', 'surname' => 'AYDIN', 'foo' => ['bar' => null, 'baz' => '']], $input->all()); + } + + public function testInputMethod() + { + $input = new ValidatedInput(['name' => 'Fatih', 'surname' => 'AYDIN', 'foo' => ['bar' => null, 'baz' => '']]); + + $this->assertSame('Fatih', $input->input('name')); + $this->assertSame(null, $input->input('foo.bar')); + $this->assertSame('test', $input->input('foo.bat', 'test')); + } + + public function testStrMethod() + { + $input = new ValidatedInput([ + 'int' => 123, + 'int_str' => '456', + 'float' => 123.456, + 'float_str' => '123.456', + 'float_zero' => 0.000, + 'float_str_zero' => '0.000', + 'str' => 'abc', + 'empty_str' => '', + 'null' => null, + ]); + + $this->assertTrue($input->str('int') instanceof Stringable); + $this->assertTrue($input->str('int') instanceof Stringable); + $this->assertTrue($input->str('unknown_key') instanceof Stringable); + $this->assertSame('123', $input->str('int')->value()); + $this->assertSame('456', $input->str('int_str')->value()); + $this->assertSame('123.456', $input->str('float')->value()); + $this->assertSame('123.456', $input->str('float_str')->value()); + $this->assertSame('0', $input->str('float_zero')->value()); + $this->assertSame('0.000', $input->str('float_str_zero')->value()); + $this->assertSame('', $input->str('empty_str')->value()); + $this->assertSame('', $input->str('null')->value()); + $this->assertSame('', $input->str('unknown_key')->value()); + } + + public function testStringMethod() + { + $input = new ValidatedInput([ + 'int' => 123, + 'int_str' => '456', + 'float' => 123.456, + 'float_str' => '123.456', + 'float_zero' => 0.000, + 'float_str_zero' => '0.000', + 'str' => 'abc', + 'empty_str' => '', + 'null' => null, + ]); + + $this->assertTrue($input->string('int') instanceof Stringable); + $this->assertTrue($input->string('int') instanceof Stringable); + $this->assertTrue($input->string('unknown_key') instanceof Stringable); + $this->assertSame('123', $input->string('int')->value()); + $this->assertSame('456', $input->string('int_str')->value()); + $this->assertSame('123.456', $input->string('float')->value()); + $this->assertSame('123.456', $input->string('float_str')->value()); + $this->assertSame('0', $input->string('float_zero')->value()); + $this->assertSame('0.000', $input->string('float_str_zero')->value()); + $this->assertSame('', $input->string('empty_str')->value()); + $this->assertSame('', $input->string('null')->value()); + $this->assertSame('', $input->string('unknown_key')->value()); + } + + public function testBooleanMethod() + { + $input = new ValidatedInput([ + 'with_trashed' => 'false', + 'download' => true, + 'checked' => 1, + 'unchecked' => '0', + 'with_on' => 'on', + 'with_yes' => 'yes', + ]); + + $this->assertTrue($input->boolean('checked')); + $this->assertTrue($input->boolean('download')); + $this->assertFalse($input->boolean('unchecked')); + $this->assertFalse($input->boolean('with_trashed')); + $this->assertFalse($input->boolean('some_undefined_key')); + $this->assertTrue($input->boolean('with_on')); + $this->assertTrue($input->boolean('with_yes')); + } + + public function testIntegerMethod() + { + $input = new ValidatedInput([ + 'int' => '123', + 'raw_int' => 456, + 'zero_padded' => '078', + 'space_padded' => ' 901', + 'nan' => 'nan', + 'mixed' => '1ab', + 'underscore_notation' => '2_000', + 'null' => null, + ]); + + $this->assertSame(123, $input->integer('int')); + $this->assertSame(456, $input->integer('raw_int')); + $this->assertSame(78, $input->integer('zero_padded')); + $this->assertSame(901, $input->integer('space_padded')); + $this->assertSame(0, $input->integer('nan')); + $this->assertSame(1, $input->integer('mixed')); + $this->assertSame(2, $input->integer('underscore_notation')); + $this->assertSame(123456, $input->integer('unknown_key', 123456)); + $this->assertSame(0, $input->integer('null')); + $this->assertSame(0, $input->integer('null', 123456)); + } + + public function testFloatMethod() + { + $input = new ValidatedInput([ + 'float' => '1.23', + 'raw_float' => 45.6, + 'decimal_only' => '.6', + 'zero_padded' => '0.78', + 'space_padded' => ' 90.1', + 'nan' => 'nan', + 'mixed' => '1.ab', + 'scientific_notation' => '1e3', + 'null' => null, + ]); + + $this->assertSame(1.23, $input->float('float')); + $this->assertSame(45.6, $input->float('raw_float')); + $this->assertSame(.6, $input->float('decimal_only')); + $this->assertSame(0.78, $input->float('zero_padded')); + $this->assertSame(90.1, $input->float('space_padded')); + $this->assertSame(0.0, $input->float('nan')); + $this->assertSame(1.0, $input->float('mixed')); + $this->assertSame(1e3, $input->float('scientific_notation')); + $this->assertSame(123.456, $input->float('unknown_key', 123.456)); + $this->assertSame(0.0, $input->float('null')); + $this->assertSame(0.0, $input->float('null', 123.456)); + } + + public function testDateMethod() + { + $input = new ValidatedInput([ + 'as_null' => null, + 'as_invalid' => 'invalid', + + 'as_datetime' => '24-01-01 16:30:25', + 'as_format' => '1704126625', + 'as_timezone' => '24-01-01 13:30:25', + + 'as_date' => '2024-01-01', + 'as_time' => '16:30:25', + ]); + + $current = Carbon::create(2024, 1, 1, 16, 30, 25); + + $this->assertNull($input->date('as_null')); + $this->assertNull($input->date('doesnt_exists')); + + $this->assertEquals($current, $input->date('as_datetime')); + $this->assertEquals($current->format('Y-m-d H:i:s P'), $input->date('as_format', 'U')->format('Y-m-d H:i:s P')); + $this->assertEquals($current, $input->date('as_timezone', null, 'America/Santiago')); + + $this->assertTrue($input->date('as_date')->isSameDay($current)); + $this->assertTrue($input->date('as_time')->isSameSecond('16:30:25')); + } + + public function testEnumMethod() + { + $input = new ValidatedInput([ + 'valid_enum_value' => 'Hello world', + 'invalid_enum_value' => 'invalid', + ]); + + $this->assertNull($input->enum('doesnt_exists', StringBackedEnum::class)); + + $this->assertEquals(StringBackedEnum::HELLO_WORLD, $input->enum('valid_enum_value', StringBackedEnum::class)); + + $this->assertNull($input->enum('invalid_enum_value', StringBackedEnum::class)); + } + + public function testEnumsMethod() + { + $input = new ValidatedInput([ + 'valid_enum_value' => 'Hello world', + 'invalid_enum_value' => 'invalid', + ]); + + $this->assertEmpty($input->enums('doesnt_exists', StringBackedEnum::class)); + + $this->assertEquals([StringBackedEnum::HELLO_WORLD], $input->enums('valid_enum_value', StringBackedEnum::class)); + + $this->assertEmpty($input->enums('invalid_enum_value', StringBackedEnum::class)); + } + + public function testCollectMethod() + { + $input = new ValidatedInput(['users' => [1, 2, 3]]); + + $this->assertInstanceOf(Collection::class, $input->collect('users')); + $this->assertTrue($input->collect('developers')->isEmpty()); + $this->assertEquals([1, 2, 3], $input->collect('users')->all()); + $this->assertEquals(['users' => [1, 2, 3]], $input->collect()->all()); + + $input = new ValidatedInput(['text-payload']); + $this->assertEquals(['text-payload'], $input->collect()->all()); + + $input = new ValidatedInput(['email' => 'test@example.com']); + $this->assertEquals(['test@example.com'], $input->collect('email')->all()); + + $input = new ValidatedInput([]); + $this->assertInstanceOf(Collection::class, $input->collect()); + $this->assertTrue($input->collect()->isEmpty()); + + $input = new ValidatedInput(['users' => [1, 2, 3], 'roles' => [4, 5, 6], 'foo' => ['bar', 'baz'], 'email' => 'test@example.com']); + $this->assertInstanceOf(Collection::class, $input->collect(['users'])); + $this->assertTrue($input->collect(['developers'])->isEmpty()); + $this->assertTrue($input->collect(['roles'])->isNotEmpty()); + $this->assertEquals(['roles' => [4, 5, 6]], $input->collect(['roles'])->all()); + $this->assertEquals(['users' => [1, 2, 3], 'email' => 'test@example.com'], $input->collect(['users', 'email'])->all()); + $this->assertEquals(collect(['roles' => [4, 5, 6], 'foo' => ['bar', 'baz']]), $input->collect(['roles', 'foo'])); + $this->assertEquals(['users' => [1, 2, 3], 'roles' => [4, 5, 6], 'foo' => ['bar', 'baz'], 'email' => 'test@example.com'], $input->collect()->all()); + } + + public function testOnlyMethod() + { + $input = new ValidatedInput(['name' => 'Fatih', 'surname' => 'AYDIN', 'foo' => ['bar' => null, 'baz' => '']]); + + $this->assertEquals(['name' => 'Fatih', 'surname' => 'AYDIN', 'foo' => ['bar' => null]], $input->only('name', 'surname', 'foo.bar')); + $this->assertEquals(['name' => 'Fatih', 'foo' => ['bar' => null, 'baz' => '']], $input->only('name', 'foo')); + $this->assertEquals(['foo' => ['baz' => '']], $input->only('foo.baz')); + $this->assertEquals(['name' => 'Fatih'], $input->only('name')); + } + + public function testExceptMethod() + { + $input = new ValidatedInput(['name' => 'Fatih', 'surname' => 'AYDIN', 'foo' => ['bar' => null, 'baz' => '']]); + + $this->assertEquals(['name' => 'Fatih', 'surname' => 'AYDIN', 'foo' => ['bar' => null]], $input->except('foo.baz')); + $this->assertEquals(['surname' => 'AYDIN'], $input->except('name', 'foo')); + $this->assertEquals([], $input->except('name', 'surname', 'foo')); + } +} diff --git a/tests/Support/envs/newEnv/.env b/tests/Support/envs/newEnv/.env new file mode 100644 index 000000000..76381c83e --- /dev/null +++ b/tests/Support/envs/newEnv/.env @@ -0,0 +1,3 @@ +TEST_VERSION=2.0 +NEW_FLAG=true +SW_VERSION="0.0.0" diff --git a/tests/Support/envs/oldEnv/.env b/tests/Support/envs/oldEnv/.env new file mode 100644 index 000000000..809f3d4ef --- /dev/null +++ b/tests/Support/envs/oldEnv/.env @@ -0,0 +1,3 @@ +TEST_VERSION=1.0 +OLD_FLAG=true +SW_VERSION="0.0.0" diff --git a/tests/Support/fixtures/composer/composer.lock b/tests/Support/fixtures/composer/composer.lock new file mode 100644 index 000000000..5957ce8d0 --- /dev/null +++ b/tests/Support/fixtures/composer/composer.lock @@ -0,0 +1,30 @@ +{ + "packages": [ + { + "name": "vendor/package-one", + "version": "1.2.3", + "extra": { + "hypervel": { + "providers": [ + "Vendor\\PackageOne\\ServiceProvider" + ] + } + }, + "scripts": { + "post-install-cmd": "echo installed" + } + }, + { + "name": "vendor/package-two", + "version": "4.5.6", + "extra": { + "hypervel": { + "config": [ + "Vendor\\PackageTwo\\ConfigProvider" + ] + } + } + } + ], + "packages-dev": [] +} diff --git a/tests/Telescope/FeatureTestCase.php b/tests/Telescope/FeatureTestCase.php index 830f64b0b..3366f6f68 100644 --- a/tests/Telescope/FeatureTestCase.php +++ b/tests/Telescope/FeatureTestCase.php @@ -6,11 +6,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; @@ -49,7 +48,7 @@ protected function setUp(): void EntriesRepository::class, fn ($container) => $container->get(DatabaseEntriesRepository::class) ); - $this->app->get(ConfigInterface::class) + $this->app->get('config') ->set('telescope', [ 'enabled' => true, 'path' => 'telescope', @@ -58,9 +57,9 @@ protected function setUp(): void ], 'defer' => false, ]); - $this->app->get(ConfigInterface::class) + $this->app->get('config') ->set('cache.default', 'array'); - $this->app->get(ConfigInterface::class) + $this->app->get('config') ->set('cache.stores.array', [ 'driver' => 'array', 'serialize' => false, 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..510744ab5 100644 --- a/tests/Telescope/Http/AvatarTest.php +++ b/tests/Telescope/Http/AvatarTest.php @@ -4,8 +4,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; @@ -25,11 +24,11 @@ protected function setUp(): void $this->withoutMiddleware(Authorize::class); - $this->app->get(ConfigInterface::class) + $this->app->get('config') ->set('telescope.watchers', [ LogWatcher::class => true, ]); - $this->app->get(ConfigInterface::class) + $this->app->get('config') ->set('logging.default', 'null'); $this->startTelescope(); diff --git a/tests/Telescope/Http/RouteTest.php b/tests/Telescope/Http/RouteTest.php index d716c54d9..f869d1232 100644 --- a/tests/Telescope/Http/RouteTest.php +++ b/tests/Telescope/Http/RouteTest.php @@ -4,9 +4,9 @@ namespace Hypervel\Tests\Telescope\Http; -use Hypervel\Foundation\Testing\Http\TestResponse; use Hypervel\Telescope\EntryType; use Hypervel\Telescope\Http\Middleware\Authorize; +use Hypervel\Testing\TestResponse; use Hypervel\Tests\Telescope\FeatureTestCase; use PHPUnit\Framework\Assert as PHPUnit; diff --git a/tests/Telescope/TelescopeTest.php b/tests/Telescope/TelescopeTest.php index 28fae46b7..2eaf04d97 100644 --- a/tests/Telescope/TelescopeTest.php +++ b/tests/Telescope/TelescopeTest.php @@ -4,9 +4,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; @@ -25,7 +24,7 @@ protected function setUp(): void { parent::setUp(); - $this->app->get(ConfigInterface::class) + $this->app->get('config') ->set('telescope.watchers', [ QueryWatcher::class => [ 'enabled' => true, diff --git a/tests/Telescope/Watchers/BatchWatcherTest.php b/tests/Telescope/Watchers/BatchWatcherTest.php index 6f175c319..9936cf9a7 100644 --- a/tests/Telescope/Watchers/BatchWatcherTest.php +++ b/tests/Telescope/Watchers/BatchWatcherTest.php @@ -4,7 +4,6 @@ namespace Hypervel\Tests\Telescope\Watchers; -use Hyperf\Contract\ConfigInterface; use Hypervel\Bus\Batch; use Hypervel\Bus\Events\BatchDispatched; use Hypervel\Telescope\EntryType; @@ -24,7 +23,7 @@ protected function setUp(): void { parent::setUp(); - $this->app->get(ConfigInterface::class) + $this->app->get('config') ->set('telescope.watchers', [ JobWatcher::class => true, BatchWatcher::class => true, diff --git a/tests/Telescope/Watchers/CacheWatcherTest.php b/tests/Telescope/Watchers/CacheWatcherTest.php index b38361787..cc3f57606 100644 --- a/tests/Telescope/Watchers/CacheWatcherTest.php +++ b/tests/Telescope/Watchers/CacheWatcherTest.php @@ -4,8 +4,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; @@ -21,7 +20,7 @@ protected function setUp(): void { parent::setUp(); - $this->app->get(ConfigInterface::class) + $this->app->get('config') ->set('telescope.watchers', [ CacheWatcher::class => [ 'enabled' => true, diff --git a/tests/Telescope/Watchers/CommandWatcherTest.php b/tests/Telescope/Watchers/CommandWatcherTest.php index e097488b6..715ca81ac 100644 --- a/tests/Telescope/Watchers/CommandWatcherTest.php +++ b/tests/Telescope/Watchers/CommandWatcherTest.php @@ -4,9 +4,8 @@ namespace Hypervel\Tests\Telescope\Watchers; -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; @@ -21,7 +20,7 @@ protected function setUp(): void { parent::setUp(); - $this->app->get(ConfigInterface::class) + $this->app->get('config') ->set('telescope.watchers', [ CommandWatcher::class => true, ]); diff --git a/tests/Telescope/Watchers/DumpWatcherTest.php b/tests/Telescope/Watchers/DumpWatcherTest.php index 54fa259d8..30b13cb47 100644 --- a/tests/Telescope/Watchers/DumpWatcherTest.php +++ b/tests/Telescope/Watchers/DumpWatcherTest.php @@ -4,7 +4,6 @@ namespace Hypervel\Tests\Telescope\Watchers; -use Hyperf\Contract\ConfigInterface; use Hypervel\Telescope\EntryType; use Hypervel\Telescope\Watchers\DumpWatcher; use Hypervel\Tests\Telescope\FeatureTestCase; @@ -19,7 +18,7 @@ protected function setUp(): void { parent::setUp(); - $this->app->get(ConfigInterface::class) + $this->app->get('config') ->set('telescope.watchers', [ DumpWatcher::class => true, ]); diff --git a/tests/Telescope/Watchers/EventWatcherTest.php b/tests/Telescope/Watchers/EventWatcherTest.php index 66e11dbcd..6dd969bd4 100644 --- a/tests/Telescope/Watchers/EventWatcherTest.php +++ b/tests/Telescope/Watchers/EventWatcherTest.php @@ -4,7 +4,6 @@ namespace Hypervel\Tests\Telescope\Watchers; -use Hyperf\Contract\ConfigInterface; use Hypervel\Telescope\EntryType; use Hypervel\Telescope\Watchers\EventWatcher; use Hypervel\Tests\Telescope\FeatureTestCase; @@ -26,7 +25,7 @@ protected function setUp(): void { parent::setUp(); - $this->app->get(ConfigInterface::class) + $this->app->get('config') ->set('telescope.watchers', [ EventWatcher::class => [ 'enabled' => true, diff --git a/tests/Telescope/Watchers/ExceptionWatcherTest.php b/tests/Telescope/Watchers/ExceptionWatcherTest.php index 76d061e95..6450f5b3e 100644 --- a/tests/Telescope/Watchers/ExceptionWatcherTest.php +++ b/tests/Telescope/Watchers/ExceptionWatcherTest.php @@ -7,8 +7,7 @@ use Error; 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; @@ -24,11 +23,11 @@ protected function setUp(): void { parent::setUp(); - $this->app->get(ConfigInterface::class) + $this->app->get('config') ->set('telescope.watchers', [ ExceptionWatcher::class => true, ]); - $this->app->get(ConfigInterface::class) + $this->app->get('config') ->set('logging.default', 'null'); $this->startTelescope(); diff --git a/tests/Telescope/Watchers/GateWatcherTest.php b/tests/Telescope/Watchers/GateWatcherTest.php index c223c8382..97b033e62 100644 --- a/tests/Telescope/Watchers/GateWatcherTest.php +++ b/tests/Telescope/Watchers/GateWatcherTest.php @@ -5,12 +5,11 @@ namespace Hypervel\Tests\Telescope\Watchers; use Exception; -use Hyperf\Contract\ConfigInterface; 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; @@ -25,7 +24,7 @@ protected function setUp(): void { parent::setUp(); - $this->app->get(ConfigInterface::class) + $this->app->get('config') ->set('telescope.watchers', [ GateWatcher::class => true, ]); diff --git a/tests/Telescope/Watchers/JobWatcherTest.php b/tests/Telescope/Watchers/JobWatcherTest.php index c5442ccf1..0fabc4b52 100644 --- a/tests/Telescope/Watchers/JobWatcherTest.php +++ b/tests/Telescope/Watchers/JobWatcherTest.php @@ -5,11 +5,10 @@ namespace Hypervel\Tests\Telescope\Watchers; 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; @@ -31,7 +30,7 @@ protected function setUp(): void { parent::setUp(); - $this->app->get(ConfigInterface::class) + $this->app->get('config') ->set('telescope.watchers', [ JobWatcher::class => true, ]); diff --git a/tests/Telescope/Watchers/LogWatcherTest.php b/tests/Telescope/Watchers/LogWatcherTest.php index 502944469..93e7e4a91 100644 --- a/tests/Telescope/Watchers/LogWatcherTest.php +++ b/tests/Telescope/Watchers/LogWatcherTest.php @@ -4,7 +4,6 @@ namespace Hypervel\Tests\Telescope\Watchers; -use Hyperf\Contract\ConfigInterface; use Hypervel\Telescope\EntryType; use Hypervel\Telescope\Watchers\LogWatcher; use Hypervel\Tests\Telescope\FeatureTestCase; @@ -38,11 +37,11 @@ protected function setUp(): void 'testLogWatcherRegistersRetryWithExceptionKey' => true, }; - $this->app->get(ConfigInterface::class) + $this->app->get('config') ->set('telescope.watchers', [ LogWatcher::class => $config, ]); - $this->app->get(ConfigInterface::class) + $this->app->get('config') ->set('logging.default', 'null'); $this->startTelescope(); diff --git a/tests/Telescope/Watchers/MailWatcherTest.php b/tests/Telescope/Watchers/MailWatcherTest.php index ea299bca9..7168ac177 100644 --- a/tests/Telescope/Watchers/MailWatcherTest.php +++ b/tests/Telescope/Watchers/MailWatcherTest.php @@ -4,7 +4,6 @@ namespace Hypervel\Tests\Telescope\Watchers; -use Hyperf\Contract\ConfigInterface; use Hypervel\Mail\Events\MessageSent; use Hypervel\Mail\SentMessage; use Hypervel\Telescope\EntryType; @@ -23,7 +22,7 @@ protected function setUp(): void { parent::setUp(); - $this->app->get(ConfigInterface::class) + $this->app->get('config') ->set('telescope.watchers', [ MailWatcher::class => true, ]); diff --git a/tests/Telescope/Watchers/ModelWatcherTest.php b/tests/Telescope/Watchers/ModelWatcherTest.php index 310051677..26871a298 100644 --- a/tests/Telescope/Watchers/ModelWatcherTest.php +++ b/tests/Telescope/Watchers/ModelWatcherTest.php @@ -4,9 +4,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; @@ -22,14 +21,14 @@ protected function setUp(): void { parent::setUp(); - $this->app->get(ConfigInterface::class) + $this->app->get('config') ->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/NotificationWatcherTest.php b/tests/Telescope/Watchers/NotificationWatcherTest.php index 34447bf68..686507911 100644 --- a/tests/Telescope/Watchers/NotificationWatcherTest.php +++ b/tests/Telescope/Watchers/NotificationWatcherTest.php @@ -4,7 +4,6 @@ namespace Hypervel\Tests\Telescope\Watchers; -use Hyperf\Contract\ConfigInterface; use Hypervel\Notifications\AnonymousNotifiable; use Hypervel\Notifications\Events\NotificationSent; use Hypervel\Notifications\Notification; @@ -23,7 +22,7 @@ protected function setUp(): void { parent::setUp(); - $this->app->get(ConfigInterface::class) + $this->app->get('config') ->set('telescope.watchers', [ NotificationWatcher::class => true, ]); diff --git a/tests/Telescope/Watchers/QueryWatcherTest.php b/tests/Telescope/Watchers/QueryWatcherTest.php index d716299ab..48bd2d734 100644 --- a/tests/Telescope/Watchers/QueryWatcherTest.php +++ b/tests/Telescope/Watchers/QueryWatcherTest.php @@ -4,15 +4,18 @@ namespace Hypervel\Tests\Telescope\Watchers; -use Hyperf\Contract\ConfigInterface; -use Hyperf\Database\Connection; -use Hyperf\Database\Events\QueryExecuted; +use Exception; +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 @@ -24,7 +27,7 @@ protected function setUp(): void { parent::setUp(); - $this->app->get(ConfigInterface::class) + $this->app->get('config') ->set('telescope.watchers', [ QueryWatcher::class => [ 'enabled' => true, @@ -121,7 +124,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/Telescope/Watchers/RedisWatcherTest.php b/tests/Telescope/Watchers/RedisWatcherTest.php index 7b97cb19f..ccac8360b 100644 --- a/tests/Telescope/Watchers/RedisWatcherTest.php +++ b/tests/Telescope/Watchers/RedisWatcherTest.php @@ -4,9 +4,8 @@ namespace Hypervel\Tests\Telescope\Watchers; -use Hyperf\Contract\ConfigInterface; -use Hyperf\Redis\Event\CommandExecuted; -use Hyperf\Redis\RedisConnection; +use Hypervel\Redis\Events\CommandExecuted; +use Hypervel\Redis\RedisConnection; use Hypervel\Telescope\EntryType; use Hypervel\Telescope\Watchers\RedisWatcher; use Hypervel\Tests\Telescope\FeatureTestCase; @@ -23,12 +22,16 @@ protected function setUp(): void { parent::setUp(); - $this->app->get(ConfigInterface::class) + $this->app->get('config') ->set('telescope.watchers', [ RedisWatcher::class => true, ]); - $this->app->get(ConfigInterface::class) - ->set('redis.foo', []); + $this->app->get('config') + ->set('database.redis.foo', [ + 'host' => '127.0.0.1', + 'port' => 6379, + 'db' => 0, + ]); RedisWatcher::enableRedisEvents($this->app); @@ -38,8 +41,8 @@ protected function setUp(): void public function testRegisterEnableRedisEvents() { $this->assertTrue( - $this->app->get(ConfigInterface::class) - ->get('redis.foo.event.enable', false) + $this->app->get('config') + ->get('database.redis.foo.event.enable', false) ); } diff --git a/tests/Telescope/Watchers/RequestWatchersTest.php b/tests/Telescope/Watchers/RequestWatchersTest.php index c297bce8f..dda938eb9 100644 --- a/tests/Telescope/Watchers/RequestWatchersTest.php +++ b/tests/Telescope/Watchers/RequestWatchersTest.php @@ -4,7 +4,6 @@ namespace Hypervel\Tests\Telescope\Watchers; -use Hyperf\Contract\ConfigInterface; use Hyperf\HttpServer\Server as HttpServer; use Hyperf\Server\Event; use Hypervel\Http\UploadedFile; @@ -26,11 +25,11 @@ protected function setUp(): void { parent::setUp(); - $this->app->get(ConfigInterface::class) + $this->app->get('config') ->set('telescope.watchers', [ RequestWatcher::class => true, ]); - $this->app->get(ConfigInterface::class) + $this->app->get('config') ->set('server.servers', [ 'http' => [ 'name' => 'http', @@ -47,7 +46,7 @@ protected function setUp(): void public function testRegisterEnableRequestEvents() { $this->assertTrue( - $this->app->get(ConfigInterface::class) + $this->app->get('config') ->get('server.servers.http.options.enable_request_lifecycle', false) ); } diff --git a/tests/Telescope/Watchers/ScheduleWatcherTest.php b/tests/Telescope/Watchers/ScheduleWatcherTest.php index 5959cac8c..8f6de8e66 100644 --- a/tests/Telescope/Watchers/ScheduleWatcherTest.php +++ b/tests/Telescope/Watchers/ScheduleWatcherTest.php @@ -4,7 +4,6 @@ namespace Hypervel\Tests\Telescope\Watchers; -use Hyperf\Contract\ConfigInterface; use Hypervel\Console\Events\ScheduledTaskFinished; use Hypervel\Console\Events\ScheduledTaskStarting; use Hypervel\Console\Scheduling\Event; @@ -24,7 +23,7 @@ protected function setUp(): void { parent::setUp(); - $this->app->get(ConfigInterface::class) + $this->app->get('config') ->set('telescope.watchers', [ ScheduleWatcher::class => true, ]); diff --git a/tests/Telescope/Watchers/ViewWatcherTest.php b/tests/Telescope/Watchers/ViewWatcherTest.php index 614bfbefd..dc94c7811 100644 --- a/tests/Telescope/Watchers/ViewWatcherTest.php +++ b/tests/Telescope/Watchers/ViewWatcherTest.php @@ -4,7 +4,6 @@ namespace Hypervel\Tests\Telescope\Watchers; -use Hyperf\Contract\ConfigInterface; use Hyperf\ViewEngine\Contract\ViewInterface; use Hypervel\Telescope\EntryType; use Hypervel\Telescope\Watchers\ViewWatcher; @@ -23,7 +22,7 @@ protected function setUp(): void { parent::setUp(); - $this->app->get(ConfigInterface::class) + $this->app->get('config') ->set('telescope.watchers', [ ViewWatcher::class => true, ]); diff --git a/tests/TestCase.php b/tests/TestCase.php index f70d6c116..cae96de88 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -6,7 +6,7 @@ use Carbon\Carbon; use Carbon\CarbonImmutable; -use Mockery; +use Hypervel\Support\Sleep; use PHPUnit\Framework\TestCase as BaseTestCase; /** @@ -17,12 +17,7 @@ class TestCase extends BaseTestCase { protected function tearDown(): void { - if ($container = Mockery::getContainer()) { - $this->addToAssertionCount($container->mockery_getExpectationCount()); - } - - Mockery::close(); - + Sleep::fake(false); Carbon::setTestNow(); CarbonImmutable::setTestNow(); } diff --git a/tests/Testbench/Concerns/CreatesApplicationTest.php b/tests/Testbench/Concerns/CreatesApplicationTest.php index 40dd7896e..ca2bde2ad 100644 --- a/tests/Testbench/Concerns/CreatesApplicationTest.php +++ b/tests/Testbench/Concerns/CreatesApplicationTest.php @@ -4,7 +4,7 @@ namespace Hypervel\Tests\Testbench\Concerns; -use Hypervel\Foundation\Contracts\Application as ApplicationContract; +use Hypervel\Contracts\Foundation\Application as ApplicationContract; use Hypervel\Testbench\TestCase; /** diff --git a/tests/Testbench/RemoteCommandCleanupTest.php b/tests/Testbench/RemoteCommandCleanupTest.php new file mode 100644 index 000000000..6ce1b6ae4 --- /dev/null +++ b/tests/Testbench/RemoteCommandCleanupTest.php @@ -0,0 +1,37 @@ + [], 'packages-dev' => []])); + + try { + $result = remote('list')->mustRun(); + + $this->assertSame(0, $result->getExitCode()); + $this->assertFileExists($composerLockPath); + } finally { + if ($originalContent === null) { + @unlink($composerLockPath); + } else { + file_put_contents($composerLockPath, $originalContent); + } + } + } +} diff --git a/tests/Testbench/TestCaseTest.php b/tests/Testbench/TestCaseTest.php index 1d3ea02a8..2f49e3326 100644 --- a/tests/Testbench/TestCaseTest.php +++ b/tests/Testbench/TestCaseTest.php @@ -4,13 +4,13 @@ namespace Hypervel\Tests\Testbench; -use Hypervel\Foundation\Contracts\Application as ApplicationContract; -use Hypervel\Foundation\Testing\Attributes\WithConfig; -use Hypervel\Foundation\Testing\Concerns\HandlesAttributes; -use Hypervel\Foundation\Testing\Concerns\InteractsWithTestCase; +use Hypervel\Contracts\Foundation\Application as ApplicationContract; +use Hypervel\Testbench\Attributes\WithConfig; use Hypervel\Testbench\Concerns\CreatesApplication; +use Hypervel\Testbench\Concerns\HandlesAttributes; use Hypervel\Testbench\Concerns\HandlesDatabases; use Hypervel\Testbench\Concerns\HandlesRoutes; +use Hypervel\Testbench\Concerns\InteractsWithTestCase; use Hypervel\Testbench\TestCase; /** 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/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/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/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..3a3b25eac 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; } /** @@ -225,7 +280,6 @@ class Comment extends Model */ class CommonBuilder extends Builder { - /** @return $this */ public function foo(): static { return $this->where('foo', 'bar'); 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 26e851e89..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; } @@ -36,14 +64,16 @@ class Post extends Model * @template TKey of array-key * @template TModel of Post * - * @extends Collection - */ + * @extends Collection */ class Posts extends Collection { } final class Comment extends Model { + /** @use HasCollection */ + use HasCollection; + /** @param array $models */ public function newCollection(array $models = []): Comments { @@ -56,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..84814149f 100644 --- a/types/Database/Eloquent/Relations.php +++ b/types/Database/Eloquent/Relations.php @@ -23,7 +23,7 @@ function test(User $user, Post $post, Comment $comment, ChildUser $child): void { assertType('Hypervel\Database\Eloquent\Relations\HasOne', $user->address()); assertType('Hypervel\Types\Relations\Address|null', $user->address()->getResults()); - assertType('Hypervel\Database\Eloquent\Collection', $user->address()->get()); + assertType('Hypervel\Support\Collection', $user->address()->get()); assertType('Hypervel\Types\Relations\Address', $user->address()->make()); assertType('Hypervel\Types\Relations\Address', $user->address()->create()); assertType('Hypervel\Database\Eloquent\Relations\HasOne', $child->address()); @@ -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..84d58b19c 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('array|object|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);