diff --git a/.github/workflows/build-ci-atlas.yml b/.github/workflows/build-ci-atlas.yml new file mode 100644 index 000000000..41f14e376 --- /dev/null +++ b/.github/workflows/build-ci-atlas.yml @@ -0,0 +1,100 @@ +name: "Atlas CI" + +on: + push: + branches: + - "[0-9]+.[0-9x]+" + - "feature/*" + pull_request: + branches: + - "[0-9]+.[0-9x]+" + - "feature/*" + +env: + MONGODB_EXT_V1: mongodb-1.21.0 + MONGODB_EXT_V2: stable + +jobs: + build: + runs-on: "${{ matrix.os }}" + + name: "PHP/${{ matrix.php }} Laravel/${{ matrix.laravel }} Driver/${{ matrix.driver }}" + + strategy: + matrix: + os: + - "ubuntu-latest" + php: + - "8.2" + - "8.3" + - "8.4" + laravel: + - "11.*" + - "12.*" + driver: + - 1 + include: + - php: "8.4" + laravel: "12.*" + os: "ubuntu-latest" + driver: 2 + + steps: + - uses: "actions/checkout@v4" + + - name: "Create MongoDB Atlas Local" + run: | + docker run --name mongodb -p 27017:27017 --detach mongodb/mongodb-atlas-local:latest + until docker exec --tty mongodb mongosh --eval "db.runCommand({ ping: 1 })"; do + sleep 1 + done + until docker exec --tty mongodb mongosh --eval "db.createCollection('connection_test') && db.getCollection('connection_test').createSearchIndex({mappings:{dynamic: true}})"; do + sleep 1 + done + + - name: "Show MongoDB server status" + run: | + docker exec --tty mongodb mongosh --eval "db.runCommand({ serverStatus: 1 })" + + - name: Setup cache environment + id: extcache + uses: shivammathur/cache-extensions@v1 + with: + php-version: ${{ matrix.php }} + extensions: ${{ matrix.driver == 1 && env.MONGODB_EXT_V1 || env.MONGODB_EXT_V2 }} + key: "extcache-v1" + + - name: "Installing php" + uses: "shivammathur/setup-php@v2" + with: + php-version: ${{ matrix.php }} + extensions: "curl,mbstring,xdebug,${{ matrix.driver == 1 && env.MONGODB_EXT_V1 || env.MONGODB_EXT_V2 }}" + coverage: "xdebug" + tools: "composer" + + - name: "Show Docker version" + if: ${{ runner.debug }} + run: "docker version && env" + + - name: "Restrict Laravel version" + run: "composer require --dev --no-update 'laravel/framework:${{ matrix.laravel }}'" + + - name: "Download Composer cache dependencies from cache" + id: "composer-cache" + run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT + + - name: "Cache Composer dependencies" + uses: "actions/cache@v4" + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: "${{ matrix.os }}-composer-${{ hashFiles('**/composer.json') }}" + restore-keys: "${{ matrix.os }}-composer-" + + - name: "Install dependencies" + run: | + composer update --no-interaction + + - name: "Run tests" + run: | + export MONGODB_URI="mongodb://127.0.0.1:27017/?directConnection=true" + php -d zend.assertions=1 ./vendor/bin/phpunit --coverage-clover coverage.xml --group atlas-search diff --git a/.github/workflows/build-ci.yml b/.github/workflows/build-ci.yml index 45833d579..f94a32e79 100644 --- a/.github/workflows/build-ci.yml +++ b/.github/workflows/build-ci.yml @@ -1,96 +1,127 @@ name: "CI" on: - push: - pull_request: + push: + branches: + - "[0-9]+.[0-9x]+" + - "feature/*" + pull_request: + branches: + - "[0-9]+.[0-9x]+" + - "feature/*" + +env: + MONGODB_EXT_V1: mongodb-1.21.0 + MONGODB_EXT_V2: stable jobs: - build: - runs-on: "${{ matrix.os }}" - - name: "PHP ${{ matrix.php }} Laravel ${{ matrix.laravel }} MongoDB ${{ matrix.mongodb }} ${{ matrix.mode }}" - - strategy: - matrix: - os: - - "ubuntu-latest" - mongodb: - - "4.4" - - "5.0" - - "6.0" - - "7.0" - php: - - "8.1" - - "8.2" - - "8.3" - laravel: - - "10.*" - - "11.*" - include: - - php: "8.1" - laravel: "10.*" - mongodb: "5.0" - mode: "low-deps" - os: "ubuntu-latest" - - php: "8.4" - laravel: "11.*" - mongodb: "7.0" - mode: "ignore-php-req" - os: "ubuntu-latest" - exclude: - - php: "8.1" - laravel: "11.*" - - steps: - - uses: "actions/checkout@v4" - - - name: "Create MongoDB Replica Set" - run: | - docker run --name mongodb -p 27017:27017 -e MONGO_INITDB_DATABASE=unittest --detach mongo:${{ matrix.mongodb }} mongod --replSet rs --setParameter transactionLifetimeLimitSeconds=5 - - if [ "${{ matrix.mongodb }}" = "4.4" ]; then MONGOSH_BIN="mongo"; else MONGOSH_BIN="mongosh"; fi - until docker exec --tty mongodb $MONGOSH_BIN 127.0.0.1:27017 --eval "db.runCommand({ ping: 1 })"; do - sleep 1 - done - sudo docker exec --tty mongodb $MONGOSH_BIN 127.0.0.1:27017 --eval "rs.initiate({\"_id\":\"rs\",\"members\":[{\"_id\":0,\"host\":\"127.0.0.1:27017\" }]})" - - - name: "Show MongoDB server status" - run: | - if [ "${{ matrix.mongodb }}" = "4.4" ]; then MONGOSH_BIN="mongo"; else MONGOSH_BIN="mongosh"; fi - docker exec --tty mongodb $MONGOSH_BIN 127.0.0.1:27017 --eval "db.runCommand({ serverStatus: 1 })" - - - name: "Installing php" - uses: "shivammathur/setup-php@v2" - with: - php-version: ${{ matrix.php }} - extensions: "curl,mbstring,xdebug" - coverage: "xdebug" - tools: "composer" - - - name: "Show Docker version" - if: ${{ runner.debug }} - run: "docker version && env" - - - name: "Restrict Laravel version" - run: "composer require --dev --no-update 'laravel/framework:${{ matrix.laravel }}'" - - - name: "Download Composer cache dependencies from cache" - id: "composer-cache" - run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - - - name: "Cache Composer dependencies" - uses: "actions/cache@v4" - with: - path: ${{ steps.composer-cache.outputs.dir }} - key: "${{ matrix.os }}-composer-${{ hashFiles('**/composer.json') }}" - restore-keys: "${{ matrix.os }}-composer-" - - - name: "Install dependencies" - run: | - composer update --no-interaction \ - $([[ "${{ matrix.mode }}" == low-deps ]] && echo ' --prefer-lowest') \ - $([[ "${{ matrix.mode }}" == ignore-php-req ]] && echo ' --ignore-platform-req=php+') - - name: "Run tests" - run: "./vendor/bin/phpunit --coverage-clover coverage.xml" - env: - MONGODB_URI: 'mongodb://127.0.0.1/?replicaSet=rs' + build: + runs-on: "${{ matrix.os }}" + + name: "PHP/${{ matrix.php }} Laravel/${{ matrix.laravel }} Driver/${{ matrix.driver }} Server/${{ matrix.mongodb }} ${{ matrix.mode }}" + + strategy: + matrix: + os: + - "ubuntu-latest" + mongodb: + - "4.4" + - "5.0" + - "6.0" + - "7.0" + - "8.0" + php: + - "8.1" + - "8.2" + - "8.3" + - "8.4" + laravel: + - "10.*" + - "11.*" + - "12.*" + driver: + - 2 + include: + - php: "8.1" + laravel: "10.*" + mongodb: "5.0" + mode: "low-deps" + os: "ubuntu-latest" + driver: 1 + - php: "8.3" + laravel: "11.*" + mongodb: "8.0" + os: "ubuntu-latest" + driver: 1 + - php: "8.4" + laravel: "12.*" + mongodb: "8.0" + os: "ubuntu-latest" + driver: 1 + exclude: + - php: "8.1" + laravel: "11.*" + - php: "8.1" + laravel: "12.*" + + steps: + - uses: "actions/checkout@v4" + + - name: "Create MongoDB Replica Set" + run: | + docker run --name mongodb -p 27017:27017 -e MONGO_INITDB_DATABASE=unittest --detach mongo:${{ matrix.mongodb }} mongod --replSet rs --setParameter transactionLifetimeLimitSeconds=5 + + if [ "${{ matrix.mongodb }}" = "4.4" ]; then MONGOSH_BIN="mongo"; else MONGOSH_BIN="mongosh"; fi + until docker exec --tty mongodb $MONGOSH_BIN --eval "db.runCommand({ ping: 1 })"; do + sleep 1 + done + sudo docker exec --tty mongodb $MONGOSH_BIN --eval "rs.initiate({\"_id\":\"rs\",\"members\":[{\"_id\":0,\"host\":\"127.0.0.1:27017\" }]})" + + - name: "Show MongoDB server status" + run: | + if [ "${{ matrix.mongodb }}" = "4.4" ]; then MONGOSH_BIN="mongo"; else MONGOSH_BIN="mongosh"; fi + docker exec --tty mongodb $MONGOSH_BIN --eval "db.runCommand({ serverStatus: 1 })" + + - name: Setup cache environment + id: extcache + uses: shivammathur/cache-extensions@v1 + with: + php-version: ${{ matrix.php }} + extensions: ${{ matrix.driver == 1 && env.MONGODB_EXT_V1 || env.MONGODB_EXT_V2 }} + key: "extcache-v1" + + - name: "Installing php" + uses: "shivammathur/setup-php@v2" + with: + php-version: ${{ matrix.php }} + extensions: "curl,mbstring,xdebug,${{ matrix.driver == 1 && env.MONGODB_EXT_V1 || env.MONGODB_EXT_V2 }}" + coverage: "xdebug" + tools: "composer" + + - name: "Show Docker version" + if: ${{ runner.debug }} + run: "docker version && env" + + - name: "Restrict Laravel version" + run: "composer require --dev --no-update 'laravel/framework:${{ matrix.laravel }}'" + + - name: "Download Composer cache dependencies from cache" + id: "composer-cache" + run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT + + - name: "Cache Composer dependencies" + uses: "actions/cache@v4" + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: "${{ matrix.os }}-composer-${{ hashFiles('**/composer.json') }}" + restore-keys: "${{ matrix.os }}-composer-" + + - name: "Install dependencies" + run: | + composer update --no-interaction \ + $([[ "${{ matrix.mode }}" == low-deps ]] && echo ' --prefer-lowest') \ + $([[ "${{ matrix.mode }}" == ignore-php-req ]] && echo ' --ignore-platform-req=php+') + - name: "Run tests" + run: | + export MONGODB_URI="mongodb://127.0.0.1:27017/?replicaSet=rs" + php -d zend.assertions=1 ./vendor/bin/phpunit --coverage-clover coverage.xml --exclude-group atlas-search diff --git a/.github/workflows/coding-standards.yml b/.github/workflows/coding-standards.yml index 24d397294..f3fc3e369 100644 --- a/.github/workflows/coding-standards.yml +++ b/.github/workflows/coding-standards.yml @@ -2,10 +2,16 @@ name: "Coding Standards" on: push: + branches: + - "[0-9]+.[0-9x]+" + - "feature/*" pull_request: + branches: + - "[0-9]+.[0-9x]+" + - "feature/*" env: - PHP_VERSION: "8.2" + PHP_VERSION: "8.4" DRIVER_VERSION: "stable" jobs: @@ -49,21 +55,10 @@ jobs: run: "php --ri mongodb" - name: "Install dependencies with Composer" - uses: "ramsey/composer-install@3.0.0" - with: - composer-options: "--no-suggest" - - - name: "Format the code" - continue-on-error: true - run: | - mkdir .cache - ./vendor/bin/phpcbf + uses: "ramsey/composer-install@3.1.1" # The -q option is required until phpcs v4 is released - name: "Run PHP_CodeSniffer" - run: "vendor/bin/phpcs -q --no-colors --report=checkstyle | cs2pr" - - - name: "Commit the changes" - uses: stefanzweifel/git-auto-commit-action@v5 - with: - commit_message: "apply phpcbf formatting" + run: | + mkdir .cache + vendor/bin/phpcs -q --no-colors --report=checkstyle | cs2pr diff --git a/.github/workflows/merge-up.yml b/.github/workflows/merge-up.yml index 1ddbb7228..2ed3feaea 100644 --- a/.github/workflows/merge-up.yml +++ b/.github/workflows/merge-up.yml @@ -4,6 +4,7 @@ on: push: branches: - "[0-9]+.[0-9x]+" + - "feature/*" env: GH_TOKEN: ${{ secrets.MERGE_UP_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4afbe78f1..bc60a79cc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -32,7 +32,7 @@ jobs: run: | echo RELEASE_VERSION=${{ inputs.version }} >> $GITHUB_ENV echo RELEASE_BRANCH=$(echo ${{ inputs.version }} | cut -d '.' -f-2) >> $GITHUB_ENV - echo DEV_BRANCH=$(echo ${{ inputs.version }} | cut -d '.' - f-1).x >> $GITHUB_ENV + echo DEV_BRANCH=$(echo ${{ inputs.version }} | cut -d '.' -f-1).x >> $GITHUB_ENV - name: "Ensure release tag does not already exist" run: | diff --git a/.github/workflows/static-analysis.yml b/.github/workflows/static-analysis.yml index a66100d93..8bc18e0f9 100644 --- a/.github/workflows/static-analysis.yml +++ b/.github/workflows/static-analysis.yml @@ -2,7 +2,13 @@ name: "Static Analysis" on: push: + branches: + - "[0-9]+.[0-9x]+" + - "feature/*" pull_request: + branches: + - "[0-9]+.[0-9x]+" + - "feature/*" workflow_call: inputs: ref: @@ -13,9 +19,12 @@ on: env: PHP_VERSION: "8.2" DRIVER_VERSION: "stable" + MONGODB_EXT_V1: mongodb-1.21.0 + MONGODB_EXT_V2: mongodb-mongodb/mongo-php-driver@v2.x jobs: phpstan: + name: "PHP/${{ matrix.php }} Driver/${{ matrix.driver }}" runs-on: "ubuntu-22.04" continue-on-error: true strategy: @@ -24,6 +33,10 @@ jobs: - '8.1' - '8.2' - '8.3' + - '8.4' + driver: + - 1 + - 2 steps: - name: Checkout uses: actions/checkout@v4 @@ -35,11 +48,19 @@ jobs: run: | echo CHECKED_OUT_SHA=$(git rev-parse HEAD) >> $GITHUB_ENV + - name: Setup cache environment + id: extcache + uses: shivammathur/cache-extensions@v1 + with: + php-version: ${{ matrix.php }} + extensions: ${{ matrix.driver == 1 && env.MONGODB_EXT_V1 || env.MONGODB_EXT_V2 }} + key: "extcache-v1" + - name: Setup PHP uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php }} - extensions: curl, mbstring + extensions: "curl,mbstring,${{ matrix.driver == 1 && env.MONGODB_EXT_V1 || env.MONGODB_EXT_V2 }}" tools: composer:v2 coverage: none diff --git a/Dockerfile b/Dockerfile index 43529d9e4..39e37531d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -ARG PHP_VERSION=8.1 +ARG PHP_VERSION=8.2 FROM php:${PHP_VERSION}-cli diff --git a/composer.json b/composer.json index 9c958f1c4..6edd8d484 100644 --- a/composer.json +++ b/composer.json @@ -16,6 +16,7 @@ }, "authors": [ { "name": "Andreas Braun", "email": "andreas.braun@mongodb.com", "role": "Leader" }, + { "name": "Pauline Vos", "email": "pauline.vos@mongodb.com", "role": "Maintainer" }, { "name": "Jérôme Tamarelle", "email": "jerome.tamarelle@mongodb.com", "role": "Maintainer" }, { "name": "Jeremy Mikola", "email": "jmikola@gmail.com", "role": "Maintainer" }, { "name": "Jens Segers", "homepage": "https://jenssegers.com", "role": "Creator" } @@ -23,24 +24,25 @@ "license": "MIT", "require": { "php": "^8.1", - "ext-mongodb": "^1.15", + "ext-mongodb": "^1.21|^2", "composer-runtime-api": "^2.0.0", - "illuminate/cache": "^10.36|^11", - "illuminate/container": "^10.0|^11", - "illuminate/database": "^10.30|^11", - "illuminate/events": "^10.0|^11", - "illuminate/support": "^10.0|^11", - "mongodb/mongodb": "^1.18" + "illuminate/cache": "^10.36|^11|^12", + "illuminate/container": "^10.0|^11|^12", + "illuminate/database": "^10.30|^11|^12", + "illuminate/events": "^10.0|^11|^12", + "illuminate/support": "^10.0|^11|^12", + "mongodb/mongodb": "^1.21|^2", + "symfony/http-foundation": "^6.4|^7" }, "require-dev": { - "mongodb/builder": "^0.2", + "laravel/scout": "^10.3", "league/flysystem-gridfs": "^3.28", "league/flysystem-read-only": "^3.0", - "phpunit/phpunit": "^10.3", - "orchestra/testbench": "^8.0|^9.0", + "phpunit/phpunit": "^10.3|^11.5.3", + "orchestra/testbench": "^8.0|^9.0|^10.0", "mockery/mockery": "^1.4.4", "doctrine/coding-standard": "12.0.x-dev", - "spatie/laravel-query-builder": "^5.6", + "spatie/laravel-query-builder": "^5.6|^6", "phpstan/phpstan": "^1.10", "rector/rector": "^1.2" }, @@ -48,10 +50,10 @@ "illuminate/bus": "< 10.37.2" }, "suggest": { - "league/flysystem-gridfs": "Filesystem storage in MongoDB with GridFS", - "mongodb/builder": "Provides a fluent aggregation builder for MongoDB pipelines" + "league/flysystem-gridfs": "Filesystem storage in MongoDB with GridFS" }, "minimum-stability": "dev", + "prefer-stable": true, "replace": { "jenssegers/mongodb": "self.version" }, diff --git a/docker-compose.yml b/docker-compose.yml index f757ec3cd..463da5f79 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,3 @@ -version: '3.5' - services: app: tty: true @@ -16,11 +14,11 @@ services: mongodb: container_name: mongodb - image: mongo:latest + image: mongodb/mongodb-atlas-local:8 ports: - "27017:27017" healthcheck: - test: echo 'db.runCommand("ping").ok' | mongosh mongodb:27017 --quiet + test: mongosh --quiet --eval 'db.runCommand("ping").ok' interval: 10s timeout: 10s retries: 5 diff --git a/docs/cache.txt b/docs/cache.txt index d3fd0f6e6..629065f09 100644 --- a/docs/cache.txt +++ b/docs/cache.txt @@ -14,7 +14,7 @@ Cache and Locks Configuration ------------- -To use MongoDB as a backend for `Laravel Cache and Locks `__, +To use MongoDB as a back end for `Laravel Cache and Locks `__, add a store configuration by specifying the ``mongodb`` driver in ``config/cache.php``: .. code-block:: php diff --git a/docs/compatibility.txt b/docs/compatibility.txt index fd3e2da02..55971c9dd 100644 --- a/docs/compatibility.txt +++ b/docs/compatibility.txt @@ -15,7 +15,7 @@ Compatibility :class: singlecol .. meta:: - :keywords: laravel 9, laravel 10, laravel 11, 4.0, 4.1, 4.2, 5.0, 5.1 + :keywords: laravel 9, laravel 10, laravel 11, laravel 12, 4.0, 4.1, 4.2, 5.0, 5.1, 5.2, 5.3, 5.4 Laravel Compatibility --------------------- @@ -28,3 +28,15 @@ the {+odm-short+} that you can use together. To find compatibility information for unmaintained versions of the {+odm-short+}, see `Laravel Version Compatibility <{+mongodb-laravel-gh+}/blob/3.9/README.md#installation>`__ on GitHub. + +PHP Driver Compatibility +------------------------ + +To use {+odm-long+} v5.2 or later, you must install v1.21 of the +{+php-library+} and {+php-extension+}. + +.. important:: {+php-extension+} v2.0 Compatibility + + If you upgrade the {+php-extension+} to v2.0, you must also upgrade + {+odm-long+} to v5.2.1. {+odm-long+} v5.2.1 still supports v1.x + versions of the extension. diff --git a/docs/database-collection.txt b/docs/database-collection.txt index fb6573147..e8d97711a 100644 --- a/docs/database-collection.txt +++ b/docs/database-collection.txt @@ -213,19 +213,19 @@ methods in your application: .. note:: - MongoDB is a schemaless database, so the preceding schema builder methods - query the database data rather than the schema. + MongoDB is a schema-flexible database, so the preceding schema + builder methods query the database data rather than the schema. Example ``````` -The following example accesses a database connection, then calls the +The following example accesses the database of the connection, then calls the ``listCollections()`` query builder method to retrieve information about the collections in the database: .. code-block:: php - $collections = DB::connection('mongodb')->getMongoDB()->listCollections(); + $collections = DB::connection('mongodb')->getDatabase()->listCollections(); List Collection Fields ---------------------- @@ -269,9 +269,10 @@ collection fields: - ``Schema::hasColumns(string $, string[] $)``: checks if each specified field exists in at least one document -MongoDB is a schemaless database, so the preceding methods query the collection -data rather than the database schema. If the specified collection doesn't exist -or is empty, these methods return a value of ``false``. +MongoDB is a schema-flexible database, so the preceding methods query +the collection data rather than the database schema. If the specified +collection doesn't exist or is empty, these methods return a value of +``false``. .. note:: id Alias diff --git a/docs/eloquent-models/model-class.txt b/docs/eloquent-models/model-class.txt index a2a9861bc..da820b18c 100644 --- a/docs/eloquent-models/model-class.txt +++ b/docs/eloquent-models/model-class.txt @@ -168,7 +168,7 @@ Eloquent includes a soft delete feature that changes the behavior of the database. It sets a timestamp on the ``deleted_at`` field to exclude it from retrieve operations automatically. -To enable soft deletes on a class, add the ``MongoDB\Laravel\Eloquent\SoftDeletes`` +To enable soft deletes on a class, add the ``Illuminate\Database\Eloquent\SoftDeletes`` trait as shown in the following code example: .. literalinclude:: /includes/eloquent-models/PlanetSoftDelete.php @@ -200,9 +200,10 @@ model attribute, stored in MongoDB as a :php:`MongoDB\\BSON\\UTCDateTime .. tip:: Casts in Laravel 11 - In Laravel 11, you can define a ``casts()`` method to specify data type conversions - instead of using the ``$casts`` attribute. The following code performs the same - conversion as the preceding example by using a ``casts()`` method: + Starting in Laravel 11, you can define a ``casts()`` method to + specify data type conversions instead of using the ``$casts`` + attribute. The following code performs the same conversion as the + preceding example by using a ``casts()`` method: .. code-block:: php diff --git a/docs/eloquent-models/schema-builder.txt b/docs/eloquent-models/schema-builder.txt index 510365d06..a3e1df913 100644 --- a/docs/eloquent-models/schema-builder.txt +++ b/docs/eloquent-models/schema-builder.txt @@ -21,8 +21,9 @@ Overview -------- Laravel provides a **facade** to access the schema builder class ``Schema``, -which lets you create and modify tables. Facades are static interfaces to -classes that make the syntax more concise and improve testability. +which lets you create and modify tables, or collections in MongoDB. +Facades are static interfaces to classes that make the syntax more +concise and improve testability. The {+odm-short+} supports a subset of the index and collection management methods in the Laravel ``Schema`` facade. @@ -33,16 +34,10 @@ in the Laravel documentation. The following sections describe the Laravel schema builder features available in the {+odm-short+} and show examples of how to use them: -- :ref:`` -- :ref:`` -- :ref:`` - -.. note:: - - The {+odm-short+} supports managing indexes and collections, but - excludes support for MongoDB JSON schemas for data validation. To learn - more about JSON schema validation, see :manual:`Schema Validation ` - in the {+server-docs-name+}. +- :ref:`laravel-eloquent-migrations` +- :ref:`laravel-eloquent-schema-validation` +- :ref:`laravel-eloquent-collection-exists` +- :ref:`laravel-eloquent-indexes` .. _laravel-eloquent-migrations: @@ -117,6 +112,60 @@ To learn more about Laravel migrations, see `Database: Migrations `__ in the Laravel documentation. +.. _laravel-eloquent-schema-validation: + +Implement Schema Validation +--------------------------- + +Starting in {+odm-short+} v5.5, you can use the ``jsonSchema()`` method +to implement :manual:`schema validation ` when +using the following schema builder methods: + +- ``Schema::create()``: When creating a new collection +- ``Schema::table()``: When updating collection properties + +You can use schema validation to restrict data types and value ranges of +document fields in a specified collection. After you implement schema +validation, the server restricts write operations that don't follow the +validation rules. + +You can pass the following parameters to ``jsonSchema()``: + +- ``schema``: Array that specifies the validation rules for the + collection. To learn more about constructing a schema, see + the :manual:`$jsonSchema ` + reference in the {+server-docs-name+}. + +- ``validationLevel``: Sets the level of validation enforcement. + Accepted values are ``"strict"`` (default) and ``"moderate"``. + +- ``validationAction``: Specifies the action to take when invalid + operations are attempted. Accepted values are ``"error"`` (default) and + ``"warn"``. + +This example demonstrates how to specify a schema in the +``jsonSchema()`` method when creating a collection. The schema +validation has the following specifications: + +- Documents in the ``pilots`` collection must + contain the ``license_number`` field. + +- The ``license_number`` field must have an integer value between + ``1000`` and ``9999``. + +- If you attempt to perform invalid write operations, the server raises + an error. + +.. literalinclude:: /includes/schema-builder/flights_migration.php + :language: php + :dedent: + :start-after: begin-json-schema + :end-before: end-json-schema + +If you attempt to insert a document into the ``pilots`` collection that +violates the schema validation rule, {+odm-long+} returns a +:php:`BulkWriteException `. + .. _laravel-eloquent-collection-exists: Check Whether a Collection Exists @@ -157,10 +206,15 @@ drop various types of indexes on a collection. Create an Index ~~~~~~~~~~~~~~~ -To create indexes, call the ``create()`` method on the ``Schema`` facade -in your migration file. Pass it the collection name and a callback -method with a ``MongoDB\Laravel\Schema\Blueprint`` parameter. Specify the -index creation details on the ``Blueprint`` instance. +To create indexes, perform the following actions: + +1. Call the ``create()`` method on the ``Schema`` facade + in your migration file. + +#. Pass it the collection name and a callback method with a + ``MongoDB\Laravel\Schema\Blueprint`` parameter. + +#. Specify the index creation details on the ``Blueprint`` instance. The following example migration creates indexes on the following collection fields: @@ -248,6 +302,8 @@ field: To learn more about index options, see :manual:`Options for All Index Types ` in the {+server-docs-name+}. +.. _laravel-schema-builder-special-idx: + Create Sparse, TTL, and Unique Indexes ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -260,11 +316,16 @@ indexes: - Unique indexes, which prevent inserting documents that contain duplicate values for the indexed field -To create these index types, call the ``create()`` method on the ``Schema`` facade -in your migration file. Pass ``create()`` the collection name and a callback -method with a ``MongoDB\Laravel\Schema\Blueprint`` parameter. Call the -appropriate helper method on the ``Blueprint`` instance and pass the -index creation details. +To create these index types, perform the following actions: + +1. Call the ``create()`` method on the ``Schema`` facade + in your migration file. + +#. Pass ``create()`` the collection name and a callback method with a + ``MongoDB\Laravel\Schema\Blueprint`` parameter. + +#. Call the appropriate helper method for the index type on the + ``Blueprint`` instance and pass the index creation details. The following migration code shows how to create a sparse and a TTL index by using the index helpers. Click the :guilabel:`{+code-output-label+}` button to see @@ -337,10 +398,16 @@ Create a Geospatial Index In MongoDB, geospatial indexes let you query geospatial coordinate data for inclusion, intersection, and proximity. -To create geospatial indexes, call the ``create()`` method on the ``Schema`` facade -in your migration file. Pass ``create()`` the collection name and a callback -method with a ``MongoDB\Laravel\Schema\Blueprint`` parameter. Specify the -geospatial index creation details on the ``Blueprint`` instance. +To create geospatial indexes, perform the following actions: + +1. Call the ``create()`` method on the ``Schema`` facade + in your migration file. + +#. Pass ``create()`` the collection name and a callback method with a + ``MongoDB\Laravel\Schema\Blueprint`` parameter. + +#. Specify the geospatial index creation details on the ``Blueprint`` + instance. The following example migration creates a ``2d`` and ``2dsphere`` geospatial index on the ``spaceports`` collection. Click the :guilabel:`{+code-output-label+}` @@ -377,11 +444,16 @@ the {+server-docs-name+}. Drop an Index ~~~~~~~~~~~~~ -To drop indexes from a collection, call the ``table()`` method on the -``Schema`` facade in your migration file. Pass it the table name and a -callback method with a ``MongoDB\Laravel\Schema\Blueprint`` parameter. -Call the ``dropIndex()`` method with the index name on the ``Blueprint`` -instance. +To drop indexes from a collection, perform the following actions: + +1. Call the ``table()`` method on the ``Schema`` facade in your + migration file. + +#. Pass it the table name and a callback method with a + ``MongoDB\Laravel\Schema\Blueprint`` parameter. + +#. Call the ``dropIndex()`` method with the index name on the + ``Blueprint`` instance. .. note:: @@ -397,4 +469,155 @@ from the ``flights`` collection: :start-after: begin drop index :end-before: end drop index +.. _laravel-schema-builder-atlas-idx: + +Manage Atlas Search and Vector Search Indexes +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +In MongoDB, :atlas:`Atlas Search indexes +` support your full-text queries. +:atlas:`Atlas Vector Search indexes +` support similarity +searches that compare query vectors to vector embeddings in your +documents. + +View the following guides to learn more about the Atlas Search and +Vector Search features: + +- :ref:`laravel-atlas-search` guide +- :ref:`laravel-vector-search` guide + +Atlas Search +```````````` + +To create Atlas Search indexes, perform the following actions: + +1. Call the ``create()`` method on the ``Schema`` facade in your + migration file. + +#. Pass ``create()`` the collection name and a callback method with a + ``MongoDB\Laravel\Schema\Blueprint`` parameter. + +#. Pass the Atlas index creation details to the ``searchIndex()`` method + on the ``Blueprint`` instance. +This example migration creates the following Atlas Search indexes on the +``galaxies`` collection: + +- ``dynamic_index``: Creates dynamic mappings +- ``auto_index``: Supports autocomplete queries on the ``name`` field + +Click the :guilabel:`{+code-output-label+}` button to see the Search +indexes created by running the migration: + +.. io-code-block:: + + .. input:: /includes/schema-builder/galaxies_migration.php + :language: php + :dedent: + :start-after: begin-create-search-indexes + :end-before: end-create-search-indexes + + .. output:: + :language: json + :visible: false + + { + "id": "...", + "name": "dynamic_index", + "type": "search", + "status": "READY", + "queryable": true, + "latestDefinition": { + "mappings": { "dynamic": true } + }, + ... + } + { + "id": "...", + "name": "auto_index", + "type": "search", + "status": "READY", + "queryable": true, + "latestDefinition": { + "mappings": { + "fields": { "name": [ + { "type": "string", "analyzer": "lucene.english" }, + { "type": "autocomplete", "analyzer": "lucene.english" }, + { "type": "token" } + ] } + } + }, + ... + } + +Vector Search +````````````` + +To create Vector Search indexes, perform the following actions: + +1. Call the ``create()`` method on the ``Schema`` facade in your + migration file. + +#. Pass ``create()`` the collection name and a callback method with a + ``MongoDB\Laravel\Schema\Blueprint`` parameter. + +#. Pass the vector index creation details to the ``vectorSearchIndex()`` + method on the ``Blueprint`` instance. + +The following example migration creates a Vector Search index called +``vs_index`` on the ``galaxies`` collection. + +Click the :guilabel:`{+code-output-label+}` button to see the Search +indexes created by running the migration: + +.. io-code-block:: + .. input:: /includes/schema-builder/galaxies_migration.php + :language: php + :dedent: + :start-after: begin-create-vs-index + :end-before: end-create-vs-index + + .. output:: + :language: json + :visible: false + + { + "id": "...", + "name": "vs_index", + "type": "vectorSearch", + "status": "READY", + "queryable": true, + "latestDefinition": { + "fields": [ { + "type": "vector", + "numDimensions": 4, + "path": "embeddings", + "similarity": "cosine" + } ] + }, + ... + } + +Drop a Search Index +``````````````````` + +To drop an Atlas Search or Vector Search index from a collection, +perform the following actions: + +1. Call the ``table()`` method on the ``Schema`` facade in your migration file. + +#. Pass it the collection name and a callback method with a + ``MongoDB\Laravel\Schema\Blueprint`` parameter. + +#. Call the ``dropSearchIndex()`` method with the Search index name on + the ``Blueprint`` instance. + +The following example migration drops an index called ``auto_index`` +from the ``galaxies`` collection: + +.. literalinclude:: /includes/schema-builder/galaxies_migration.php + :language: php + :dedent: + :start-after: begin-drop-search-index + :end-before: end-drop-search-index diff --git a/docs/feature-compatibility.txt b/docs/feature-compatibility.txt index 57c8c7486..cce0932cc 100644 --- a/docs/feature-compatibility.txt +++ b/docs/feature-compatibility.txt @@ -21,17 +21,24 @@ Overview -------- This guide describes the Laravel features that are supported by -{+odm-long+}. This page discusses Laravel version 11.x feature +{+odm-long+}. This page discusses Laravel version 12.x feature availability in the {+odm-short+}. The following sections contain tables that describe whether individual features are available in the {+odm-short+}. +.. tip:: SQL Concepts in MongoDB + + To learn about how MongoDB represents SQL terminology, concepts, and + functionality, see the :manual:`SQL to MongoDB Mapping Chart + `. + Database Features ----------------- .. list-table:: :header-rows: 1 + :widths: 40 60 * - Eloquent Feature - Availability @@ -63,6 +70,13 @@ Database Features * - Database Monitoring - *Unsupported* + * - Multi-database Support / Multiple Schemas + - | *Unsupported* + | Laravel uses a dot separator (``.``) + between SQL schema and table names, but MongoDB allows ``.`` + characters within collection names, which might lead to + unexpected namespace parsing. + Query Features -------------- @@ -109,24 +123,32 @@ The following Eloquent methods are not supported in the {+odm-short+}: - *Unsupported* * - Joins - - *Unsupported* + - Use the ``$lookup`` aggregation stage. To learn more, see the + :manual:`$lookup reference + ` in the + {+server-docs-name+}. {+odm-long+} provides the + :ref:`laravel-aggregation-builder` to perform aggregations. * - Unions - - *Unsupported* + - Use the ``$unionWith`` aggregation stage. To learn more, see the + :manual:`$unionWith reference + ` in the + {+server-docs-name+}. {+odm-long+} provides the + :ref:`laravel-aggregation-builder` to perform aggregations. - * - `Basic Where Clauses `__ + * - `Basic Where Clauses `__ - ✓ - * - `Additional Where Clauses `__ + * - `Additional Where Clauses `__ - ✓ * - Logical Grouping - ✓ - * - `Advanced Where Clauses `__ + * - `Advanced Where Clauses `__ - ✓ - * - `Subquery Where Clauses `__ + * - `Subquery Where Clauses `__ - *Unsupported* * - Ordering @@ -136,7 +158,11 @@ The following Eloquent methods are not supported in the {+odm-short+}: - *Unsupported* * - Grouping - - Partially supported, use :ref:`Aggregations `. + - Use the ``$group`` aggregation stage. To learn more, see the + :manual:`$group reference + ` in the + {+server-docs-name+}. {+odm-long+} provides the + :ref:`laravel-aggregation-builder` to perform aggregations. * - Limit and Offset - ✓ @@ -175,7 +201,7 @@ Migration Features ------------------ The {+odm-short+} supports all Laravel migration features, but the -implementation is specific to MongoDB's schemaless model. +implementation is specific to MongoDB's schema-flexible model. Seeding Features ---------------- diff --git a/docs/filesystems.txt b/docs/filesystems.txt index 725b799af..c62853f58 100644 --- a/docs/filesystems.txt +++ b/docs/filesystems.txt @@ -79,7 +79,7 @@ You can configure the following settings in ``config/filesystems.php``: * - ``throw`` - If ``true``, exceptions are thrown when an operation cannot be performed. If ``false``, - operations return ``true`` on success and ``false`` on error. Defaults to ``false``. + operations return ``true`` on success and ``false`` on error. Defaults to ``false``. You can also use a factory or a service name to create an instance of ``MongoDB\GridFS\Bucket``. In this case, the options ``connection`` and ``database`` are ignored: @@ -94,7 +94,7 @@ In this case, the options ``connection`` and ``database`` are ignored: 'driver' => 'gridfs', 'bucket' => static function (Application $app): Bucket { return $app['db']->connection('mongodb') - ->getMongoDB() + ->getDatabase() ->selectGridFSBucket([ 'bucketName' => 'avatars', 'chunkSizeBytes' => 261120, @@ -133,7 +133,7 @@ metadata, including the file name and a unique ObjectId. If multiple documents share the same file name, they are considered "revisions" and further distinguished by creation timestamps. -The Laravel MongoDB integration uses the GridFS Flysystem adapter. It interacts +{+odm-long+} uses the GridFS Flysystem adapter. It interacts with file revisions in the following ways: - Reading a file reads the last revision of this file name @@ -150,7 +150,7 @@ if you need to work with revisions, as shown in the following code: // Create a bucket service from the MongoDB connection /** @var \MongoDB\GridFS\Bucket $bucket */ - $bucket = $app['db']->connection('mongodb')->getMongoDB()->selectGridFSBucket(); + $bucket = $app['db']->connection('mongodb')->getDatabase()->selectGridFSBucket(); // Download the last but one version of a file $bucket->openDownloadStreamByName('hello.txt', ['revision' => -2]) diff --git a/docs/fundamentals.txt b/docs/fundamentals.txt index fc67d4c48..40c3b55ae 100644 --- a/docs/fundamentals.txt +++ b/docs/fundamentals.txt @@ -19,6 +19,8 @@ Fundamentals Read Operations Write Operations Aggregation Builder + Atlas Search + Atlas Vector Search Learn more about the following concepts related to {+odm-long+}: @@ -26,3 +28,5 @@ Learn more about the following concepts related to {+odm-long+}: - :ref:`laravel-fundamentals-read-ops` - :ref:`laravel-fundamentals-write-ops` - :ref:`laravel-aggregation-builder` +- :ref:`laravel-atlas-search` +- :ref:`laravel-vector-search` diff --git a/docs/fundamentals/aggregation-builder.txt b/docs/fundamentals/aggregation-builder.txt index 3169acfeb..47994ce9e 100644 --- a/docs/fundamentals/aggregation-builder.txt +++ b/docs/fundamentals/aggregation-builder.txt @@ -37,7 +37,6 @@ The {+odm-long+} aggregation builder lets you build aggregation stages and aggregation pipelines. The following sections show examples of how to use the aggregation builder to create the stages of an aggregation pipeline: -- :ref:`laravel-add-aggregation-dependency` - :ref:`laravel-build-aggregation` - :ref:`laravel-aggregation-examples` - :ref:`laravel-create-custom-operator-factory` @@ -49,27 +48,6 @@ aggregation builder to create the stages of an aggregation pipeline: aggregation builder, see :ref:`laravel-query-builder-aggregations` in the Query Builder guide. -.. _laravel-add-aggregation-dependency: - -Add the Aggregation Builder Dependency --------------------------------------- - -The aggregation builder is part of the {+agg-builder-package-name+} package. -You must add this package as a dependency to your project to use it. Run the -following command to add the aggregation builder dependency to your -application: - -.. code-block:: bash - - composer require {+agg-builder-package-name+}:{+agg-builder-version+} - -When the installation completes, verify that the ``composer.json`` file -includes the following line in the ``require`` object: - -.. code-block:: json - - "{+agg-builder-package-name+}": "{+agg-builder-version+}", - .. _laravel-build-aggregation: Create Aggregation Stages @@ -88,12 +66,6 @@ to build aggregation stages: - ``MongoDB\Builder\Query`` - ``MongoDB\Builder\Type`` -.. tip:: - - To learn more about builder classes, see the - :github:`mongodb/mongodb-php-builder ` - GitHub repository. - This section features the following examples that show how to use common aggregation stages: diff --git a/docs/fundamentals/atlas-search.txt b/docs/fundamentals/atlas-search.txt new file mode 100644 index 000000000..a41385fda --- /dev/null +++ b/docs/fundamentals/atlas-search.txt @@ -0,0 +1,259 @@ +.. _laravel-atlas-search: + +============ +Atlas Search +============ + +.. facet:: + :name: genre + :values: reference + +.. meta:: + :keywords: code example, semantic, text + +.. contents:: On this page + :local: + :backlinks: none + :depth: 2 + :class: singlecol + +Overview +-------- + +In this guide, you can learn how to perform searches on your documents +by using the Atlas Search feature. {+odm-long+} provides an API to +perform Atlas Search queries directly with your models. This guide +describes how to create Atlas Search indexes and provides examples of +how to use the {+odm-short+} to perform searches. + +.. note:: Deployment Compatibility + + You can use the Atlas Search feature only when + you connect to MongoDB Atlas clusters. This feature is not + available for self-managed deployments. + +To learn more about Atlas Search, see the :atlas:`Overview +` in the +Atlas documentation. The Atlas Search API internally uses the +``$search`` aggregation operator to perform queries. To learn more about +this operator, see the :atlas:`$search +` reference in the Atlas +documentation. + +.. note:: + + You might not be able to use the methods described in + this guide for every type of Atlas Search query. + For more complex use cases, create an aggregation pipeline by using + the :ref:`laravel-aggregation-builder`. + + To perform searches on vector embeddings in MongoDB, you can use the + {+odm-long+} Atlas Vector Search API. To learn about this feature, see + the :ref:`laravel-vector-search` guide. + +.. _laravel-as-index: + +Create an Atlas Search Index +---------------------------- + +You can create an Atlas Search index in either of the following ways: + +- Call the ``create()`` method on the ``Schema`` facade and pass the + ``searchIndex()`` helper method with index creation details. To learn + more about this strategy, see the + :ref:`laravel-schema-builder-atlas-idx` section of the Schema Builder guide. + +- Access a collection, then call the + :phpmethod:`createSearchIndex() ` + method from the {+php-library+}, as shown in the following code: + + .. code-block:: php + + $collection = DB::connection('mongodb')->getCollection('movies'); + + $collection->createSearchIndex( + ['mappings' => ['dynamic' => true]], + ['name' => 'search_index'] + ); + +Perform Queries +--------------- + +In this section, you can learn how to use the Atlas Search API in the +{+odm-short+}. + +General Queries +~~~~~~~~~~~~~~~ + +The {+odm-short+} provides the ``search()`` method as a query +builder method and as an Eloquent model method. You can use the +``search()`` method to run Atlas Search queries on documents in your +collections. + +You must pass an ``operator`` parameter to the ``search()`` method that +is an instance of ``SearchOperatorInterface`` or an array that contains +the operator type, field name, and query value. You can +create an instance of ``SearchOperatorInterface`` by calling the +``Search::text()`` method and passing the field you are +querying and your search term or phrase. + +You must include the following import statement in your application to +create a ``SearchOperatorInterface`` instance: + +.. code-block:: php + + use MongoDB\Builder\Search; + +The following code performs an Atlas Search query on the ``Movie`` +model's ``title`` field for the term ``'dream'``: + +.. io-code-block:: + :copyable: true + + .. input:: /includes/fundamentals/as-avs/AtlasSearchTest.php + :language: php + :dedent: + :start-after: start-search-query + :end-before: end-search-query + + .. output:: + :language: json + :visible: false + + [ + { "title": "Dreaming of Jakarta", + "year": 1990 + }, + { "title": "See You in My Dreams", + "year": 1996 + } + ] + +You can use the ``search()`` method to perform many types of Atlas +Search queries. Depending on your desired query, you can pass the +following optional parameters to ``search()``: + +.. list-table:: + :header-rows: 1 + + * - Optional Parameter + - Type + - Description + + * - ``index`` + - ``string`` + - Provides the name of the Atlas Search index to use + + * - ``highlight`` + - ``array`` + - Specifies highlighting options for displaying search terms in their + original context + + * - ``concurrent`` + - ``bool`` + - Parallelizes search query across segments on dedicated search nodes + + * - ``count`` + - ``string`` + - Specifies the count options for retrieving a count of the results + + * - ``searchAfter`` + - ``string`` + - Specifies a reference point for returning documents starting + immediately following that point + + * - ``searchBefore`` + - ``string`` + - Specifies a reference point for returning documents starting + immediately preceding that point + + * - ``scoreDetails`` + - ``bool`` + - Specifies whether to retrieve a detailed breakdown of the score + for results + + * - ``sort`` + - ``array`` + - Specifies the fields on which to sort the results + + * - ``returnStoredSource`` + - ``bool`` + - Specifies whether to perform a full document lookup on the + back end database or return only stored source fields directly + from Atlas Search + + * - ``tracking`` + - ``array`` + - Specifies the tracking option to retrieve analytics information + on the search terms + +To learn more about these parameters, see the :atlas:`Fields +` section of the +``$search`` operator reference in the Atlas documentation. + +Autocomplete Queries +~~~~~~~~~~~~~~~~~~~~ + +The {+odm-short+} provides the ``autocomplete()`` method as a query +builder method and as an Eloquent model method. You can use the +``autocomplete()`` method to run autocomplete searches on documents in your +collections. This method returns only the values of the field you +specify as the query path. + +To learn more about this type of Atlas Search query, see the +:atlas:`autocomplete ` reference in the +Atlas documentation. + +.. note:: + + You must create an Atlas Search index with an autocomplete configuration + on your collection before you can perform autocomplete searches. See the + :ref:`laravel-as-index` section of this guide to learn more about + creating Search indexes. + +The following code performs an Atlas Search autocomplete query for the +string ``"jak"`` on the ``title`` field: + +.. io-code-block:: + :copyable: true + + .. input:: /includes/fundamentals/as-avs/AtlasSearchTest.php + :language: php + :dedent: + :start-after: start-auto-query + :end-before: end-auto-query + + .. output:: + :language: json + :visible: false + + [ + "Dreaming of Jakarta", + "Jakob the Liar", + "Emily Calling Jake" + ] + +You can also pass the following optional parameters to the ``autocomplete()`` +method to customize the query: + +.. list-table:: + :header-rows: 1 + + * - Optional Parameter + - Type + - Description + - Default Value + + * - ``fuzzy`` + - ``bool`` or ``array`` + - Enables fuzzy search and fuzzy search options + - ``false`` + + * - ``tokenOrder`` + - ``string`` + - Specifies order in which to search for tokens + - ``'any'`` + +To learn more about these parameters, see the :atlas:`Options +` section of the +``autocomplete`` operator reference in the Atlas documentation. diff --git a/docs/fundamentals/connection/connection-options.txt b/docs/fundamentals/connection/connection-options.txt index 03e98ed06..1a2cdb085 100644 --- a/docs/fundamentals/connection/connection-options.txt +++ b/docs/fundamentals/connection/connection-options.txt @@ -32,6 +32,7 @@ This guide covers the following topics: - :ref:`laravel-connection-auth-options` - :ref:`laravel-driver-options` +- :ref:`laravel-disable-id-alias` .. _laravel-connection-auth-options: @@ -349,3 +350,47 @@ item, as shown in the following example: See the `$driverOptions: array `__ section of the {+php-library+} documentation for a list of driver options. + +.. _laravel-disable-id-alias: + +Disable Use of id Field Name Conversion +--------------------------------------- + +Starting in {+odm-long+} v5.0, ``id`` is an alias for the ``_id`` field +in MongoDB documents, and the library automatically converts ``id`` +to ``_id`` for both top level and embedded fields when querying and +storing data. + +When using {+odm-long+} v5.3 or later, you can disable the automatic +conversion of ``id`` to ``_id`` for embedded documents. To do so, +perform either of the following actions: + +1. Set the ``rename_embedded_id_field`` setting to ``false`` in your + ``config/database.php`` file: + + .. code-block:: php + :emphasize-lines: 6 + + 'connections' => [ + 'mongodb' => [ + 'dsn' => 'mongodb+srv://mongodb0.example.com/', + 'driver' => 'mongodb', + 'database' => 'sample_mflix', + 'rename_embedded_id_field' => false, + // Other settings + ], + ], + +#. Pass ``false`` to the ``setRenameEmbeddedIdField()`` method in your + application: + + .. code-block:: php + + DB::connection('mongodb')->setRenameEmbeddedIdField(false); + +.. important:: + + We recommend using this option only to provide backwards + compatibility with existing document schemas. In new projects, + avoid using ``id`` for field names in embedded documents so that + you can maintain {+odm-long+}'s default behavior. diff --git a/docs/fundamentals/read-operations.txt b/docs/fundamentals/read-operations.txt index 303e53a3e..674615ffb 100644 --- a/docs/fundamentals/read-operations.txt +++ b/docs/fundamentals/read-operations.txt @@ -17,6 +17,8 @@ Read Operations Retrieve Data Search Text Modify Query Results + Read Preference + Query Logging .. contents:: On this page :local: @@ -178,3 +180,18 @@ from MongoDB: To learn more about modifying how {+odm-long+} returns results, see the :ref:`laravel-read-modify-results` guide. + +Set a Read Preference +--------------------- + +The following code shows how to set a read preference when performing a +find operation: + +.. code-block:: php + + SampleModel::where('field name', '') + ->readPreference(ReadPreference::SECONDARY_PREFERRED) + ->get(); + +To learn more about read preferences, see the :ref:`laravel-read-pref` +guide. diff --git a/docs/fundamentals/read-operations/query-logging.txt b/docs/fundamentals/read-operations/query-logging.txt new file mode 100644 index 000000000..27816b298 --- /dev/null +++ b/docs/fundamentals/read-operations/query-logging.txt @@ -0,0 +1,82 @@ +.. _laravel-query-logging: + +==================== +Enable Query Logging +==================== + +.. facet:: + :name: genre + :values: reference + +.. meta:: + :keywords: monitoring, CRUD, code example + +.. contents:: On this page + :local: + :backlinks: none + :depth: 2 + :class: singlecol + +Overview +-------- + +In this guide, you can learn how to enable query logging in +{+odm-long+}. Query logging can help you debug your queries and monitor +database interactions. + +.. include:: /includes/fundamentals/read-operations/before-you-get-started.rst + +Enable Logs On a Connection +--------------------------- + +To enable logs on a connection, you can use the ``enableQueryLog()`` +method on the ``DB`` facade. This method enables MongoDB command logging +on any queries that you perform on the database connection. + +After you enable query logging, any queries you perform are stored in +memory. To retrieve the logs, use one of the following methods: + +- ``getQueryLog()``: Returns a log of MongoDB queries +- ``getRawQueryLog()``: Returns a log of raw MongoDB queries + +The following example enables query logging, performs some queries, then +prints the query log: + +.. io-code-block:: + :copyable: true + + .. input:: /includes/fundamentals/read-operations/ReadOperationsTest.php + :language: php + :dedent: + :start-after: start-query-log + :end-before: end-query-log + :emphasize-lines: 1, 7 + + .. output:: + :language: json + :visible: false + + { + "query": "{ \"find\" : \"movies\", \"filter\" : { \"title\" : \"Carrie\" } }", + "bindings": [], + "time": 29476 + } + { + "query": "{ \"find\" : \"movies\", \"filter\" : { \"year\" : { \"$lt\" : { \"$numberInt\" : \"2005\" } } } }", + "bindings": [], + "time": 29861 + } + { + "query": "{ \"find\" : \"movies\", \"filter\" : { \"imdb.rating\" : { \"$gt\" : { \"$numberDouble\" : \"8.5\" } } } }", + "bindings": [], + "time": 27251 + } + +Additional Information +---------------------- + +To learn more about connecting to MongoDB, see the +:ref:`laravel-connect-to-mongodb`. + +To learn how to retrieve data based on filter criteria, see the +:ref:`laravel-fundamentals-read-retrieve` guide. diff --git a/docs/fundamentals/read-operations/read-pref.txt b/docs/fundamentals/read-operations/read-pref.txt new file mode 100644 index 000000000..075c74380 --- /dev/null +++ b/docs/fundamentals/read-operations/read-pref.txt @@ -0,0 +1,141 @@ +.. _laravel-read-pref: + +===================== +Set a Read Preference +===================== + +.. facet:: + :name: genre + :values: reference + +.. meta:: + :keywords: consistency, durability, CRUD, code example + +.. contents:: On this page + :local: + :backlinks: none + :depth: 2 + :class: singlecol + +Overview +-------- + +In this guide, you can learn how to set a read preference when +performing find operations with {+odm-long+}. + +.. include:: /includes/fundamentals/read-operations/before-you-get-started.rst + +Set a Read Preference +--------------------- + +To specify which replica set members receive your read operations, +set a read preference by using the ``readPreference()`` method. + +The ``readPreference()`` method accepts the following parameters: + +- ``mode``: *(Required)* A string value specifying the read preference + mode. + +- ``tagSets``: *(Optional)* An array value specifying key-value tags that correspond to + certain replica set members. + +- ``options``: *(Optional)* An array value specifying additional read preference options. + +.. tip:: + + To view a full list of available read preference modes and options, see + :php:`MongoDB\Driver\ReadPreference::__construct ` + in the MongoDB PHP extension documentation. + +The following example queries for documents in which the value of the ``title`` +field is ``"Carrie"`` and sets the read preference to ``ReadPreference::SECONDARY_PREFERRED``. +As a result, the query retrieves the results from secondary replica set +members or the primary member if no secondaries are available: + +.. tabs:: + + .. tab:: Query Syntax + :tabid: query-syntax + + Use the following syntax to specify the query: + + .. literalinclude:: /includes/fundamentals/read-operations/ReadOperationsTest.php + :language: php + :dedent: + :start-after: start-read-pref + :end-before: end-read-pref + + .. tab:: Controller Method + :tabid: controller + + To see the query results in the ``browse_movies`` view, edit the ``show()`` function + in the ``MovieController.php`` file to resemble the following code: + + .. io-code-block:: + :copyable: true + + .. input:: + :language: php + + class MovieController + { + public function show() + { + $movies = Movie::where('title', 'Carrie') + ->readPreference(ReadPreference::SECONDARY_PREFERRED) + ->get(); + + return view('browse_movies', [ + 'movies' => $movies + ]); + } + } + + .. output:: + :language: none + :visible: false + + Title: Carrie + Year: 1952 + Runtime: 118 + IMDB Rating: 7.5 + IMDB Votes: 1458 + Plot: Carrie boards the train to Chicago with big ambitions. She gets a + job stitching shoes and her sister's husband takes almost all of her pay + for room and board. Then she injures a finger and ... + + Title: Carrie + Year: 1976 + Runtime: 98 + IMDB Rating: 7.4 + IMDB Votes: 115528 + Plot: A shy, outcast 17-year old girl is humiliated by her classmates for the + last time. + + Title: Carrie + Year: 2002 + Runtime: 132 + IMDB Rating: 5.5 + IMDB Votes: 7412 + Plot: Carrie White is a lonely and painfully shy teenage girl with telekinetic + powers who is slowly pushed to the edge of insanity by frequent bullying from + both her classmates and her domineering, religious mother. + + Title: Carrie + Year: 2013 + Runtime: 100 + IMDB Rating: 6 + IMDB Votes: 98171 + Plot: A reimagining of the classic horror tale about Carrie White, a shy girl + outcast by her peers and sheltered by her deeply religious mother, who unleashes + telekinetic terror on her small town after being pushed too + far at her senior prom. + +Additional Information +---------------------- + +To learn how to retrieve data based on filter criteria, see the +:ref:`laravel-fundamentals-read-retrieve` guide. + +To learn how to modify the way that the {+odm-short+} returns results, +see the :ref:`laravel-read-modify-results` guide. diff --git a/docs/fundamentals/vector-search.txt b/docs/fundamentals/vector-search.txt new file mode 100644 index 000000000..c06b28320 --- /dev/null +++ b/docs/fundamentals/vector-search.txt @@ -0,0 +1,187 @@ +.. _laravel-vector-search: + +=================== +Atlas Vector Search +=================== + +.. facet:: + :name: genre + :values: reference + +.. meta:: + :keywords: code example, semantic, text, embeddings + +.. contents:: On this page + :local: + :backlinks: none + :depth: 2 + :class: singlecol + +Overview +-------- + +In this guide, you can learn how to perform searches on your documents +by using the Atlas Vector Search feature. {+odm-long+} provides an API to +perform Atlas Vector Search queries directly with your models. This guide +describes how to create Atlas Vector Search indexes and provides +examples of how to use the {+odm-short+} to perform searches. + +.. note:: Deployment Compatibility + + You can use the Atlas Vector Search feature only when + you connect to MongoDB Atlas clusters. This feature is not + available for self-managed deployments. + +To learn more about Atlas Vector Search, see the :atlas:`Overview +` in the +Atlas documentation. The Atlas Vector Search API internally uses the +``$vectorSearch`` aggregation operator to perform queries. To learn more about +this operator, see the :atlas:`$vectorSearch +` reference in the Atlas +documentation. + +.. note:: + + You might not be able to use the methods described in + this guide for every type of Atlas Vector Search query. + For more complex use cases, create an aggregation pipeline by using + the :ref:`laravel-aggregation-builder`. + + To perform advanced full-text searches on your documents, you can use the + {+odm-long+} Atlas Search API. To learn about this feature, see + the :ref:`laravel-atlas-search` guide. + +.. _laravel-avs-index: + +Create an Atlas Vector Search Index +----------------------------------- + +You can create an Atlas Search index in either of the following ways: + +- Call the ``create()`` method on the ``Schema`` facade and pass the + ``vectorSearchIndex()`` helper method with index creation details. To learn + more about this strategy, see the + :ref:`laravel-schema-builder-atlas-idx` section of the Schema Builder guide. + +- Access a collection, then call the + :phpmethod:`createSearchIndex() ` + method from the {+php-library+}. You must specify the ``type`` option as + ``'vectorSearch'``, as shown in the following code: + + .. code-block:: php + + $collection = DB::connection('mongodb')->getCollection('movies'); + + $collection->createSearchIndex([ + 'fields' => [ + [ + 'type' => 'vector', + 'numDimensions' => 4, + 'path' => 'embeddings', + 'similarity' => 'cosine' + ], + ], + ], ['name' => 'vector_index', 'type' => 'vectorSearch']); + +Perform Queries +--------------- + +In this section, you can learn how to use the Atlas Vector Search API in +the {+odm-short+}. The {+odm-short+} provides the ``vectorSearch()`` +method as a query builder method and as an Eloquent model method. You +can use the ``vectorSearch()`` method to run Atlas Vector Search queries +on documents in your collections. + +You must pass the following parameters to the ``vectorSearch()`` method: + +.. list-table:: + :header-rows: 1 + + * - Parameter + - Type + - Description + + * - ``index`` + - ``string`` + - Name of the vector search index + + * - ``path`` + - ``string`` + - Field that stores vector embeddings + + * - ``queryVector`` + - ``array`` + - Vector representation of your query + + * - ``limit`` + - ``int`` + - Number of results to return + +The following code uses the ``vector`` index created in the preceding +:ref:`laravel-avs-index` section to perform an Atlas Vector Search query on the +``movies`` collection: + +.. io-code-block:: + :copyable: true + + .. input:: + :language: php + + $movies = Book::vectorSearch( + index: 'vector', + path: 'vector_embeddings', + // Vector representation of the query `coming of age` + queryVector: [-0.0016261312, -0.028070757, ...], + limit: 3, + ); + + .. output:: + :language: json + :visible: false + + [ + { "title": "Sunrising", + "plot": "A shy teenager discovers confidence and new friendships during a transformative summer camp experience." + }, + { "title": "Last Semester", + "plot": "High school friends navigate love, identity, and unexpected challenges before graduating together." + } + ] + +You can use the ``vectorSearch()`` method to perform many types of Atlas +Search queries. Depending on your desired query, you can pass the +following optional parameters to ``vectorSearch()``: + +.. list-table:: + :header-rows: 1 + + * - Optional Parameter + - Type + - Description + - Default Value + + * - ``exact`` + - ``bool`` + - Specifies whether to run an Exact Nearest Neighbor (``true``) or + Approximate Nearest Neighbor (``false``) search + - ``false`` + + * - ``filter`` + - ``QueryInterface`` or ``array`` + - Specifies a pre-filter for documents to search on + - no filtering + + * - ``numCandidates`` + - ``int`` or ``null`` + - Specifies the number of nearest neighbors to use during the + search + - ``null`` + +.. note:: + + To construct a ``QueryInterface`` instance, you must import the + ``MongoDB\Builder\Query`` class into your application. + +To learn more about these parameters, see the :atlas:`Fields +` section of the +``$vectorSearch`` operator reference in the Atlas documentation. diff --git a/docs/includes/eloquent-models/PlanetSoftDelete.php b/docs/includes/eloquent-models/PlanetSoftDelete.php index 05d106206..70ccba24b 100644 --- a/docs/includes/eloquent-models/PlanetSoftDelete.php +++ b/docs/includes/eloquent-models/PlanetSoftDelete.php @@ -2,8 +2,8 @@ namespace App\Models; +use Illuminate\Database\Eloquent\SoftDeletes; use MongoDB\Laravel\Eloquent\Model; -use MongoDB\Laravel\Eloquent\SoftDeletes; class Planet extends Model { diff --git a/docs/includes/framework-compatibility-laravel.rst b/docs/includes/framework-compatibility-laravel.rst index 16c405e21..4b0055692 100644 --- a/docs/includes/framework-compatibility-laravel.rst +++ b/docs/includes/framework-compatibility-laravel.rst @@ -3,21 +3,31 @@ :stub-columns: 1 * - {+odm-long+} Version + - Laravel 12.x - Laravel 11.x - Laravel 10.x - Laravel 9.x + * - 5.2 to 5.4 + - ✓ + - ✓ + - ✓ + - + * - 4.2 to 5.1 + - - ✓ - ✓ - * - 4.1 + - - - ✓ - * - 4.0 + - - - ✓ - diff --git a/docs/includes/fundamentals/as-avs/AtlasSearchTest.php b/docs/includes/fundamentals/as-avs/AtlasSearchTest.php new file mode 100644 index 000000000..79dfe46df --- /dev/null +++ b/docs/includes/fundamentals/as-avs/AtlasSearchTest.php @@ -0,0 +1,159 @@ +getCollection('movies'); + $moviesCollection->drop(); + + Movie::insert([ + ['title' => 'Dreaming of Jakarta', 'year' => 1990], + ['title' => 'See You in My Dreams', 'year' => 1996], + ['title' => 'On the Run', 'year' => 2004], + ['title' => 'Jakob the Liar', 'year' => 1999], + ['title' => 'Emily Calling Jake', 'year' => 2001], + ]); + + Movie::insert($this->addVector([ + ['title' => 'A', 'plot' => 'A shy teenager discovers confidence and new friendships during a transformative summer camp experience.'], + ['title' => 'B', 'plot' => 'A detective teams up with a hacker to unravel a global conspiracy threatening personal freedoms.'], + ['title' => 'C', 'plot' => 'High school friends navigate love, identity, and unexpected challenges before graduating together.'], + ['title' => 'D', 'plot' => 'Stranded on a distant planet, astronauts must repair their ship before supplies run out.'], + ])); + + $moviesCollection = DB::connection('mongodb')->getCollection('movies'); + + try { + $moviesCollection->createSearchIndex([ + 'mappings' => [ + 'fields' => [ + 'title' => [ + ['type' => 'string', 'analyzer' => 'lucene.english'], + ['type' => 'autocomplete', 'analyzer' => 'lucene.english'], + ['type' => 'token'], + ], + ], + ], + ]); + + $moviesCollection->createSearchIndex([ + 'mappings' => ['dynamic' => true], + ], ['name' => 'dynamic_search']); + + $moviesCollection->createSearchIndex([ + 'fields' => [ + ['type' => 'vector', 'numDimensions' => 4, 'path' => 'vector4', 'similarity' => 'cosine'], + ['type' => 'filter', 'path' => 'title'], + ], + ], ['name' => 'vector', 'type' => 'vectorSearch']); + } catch (ServerException $e) { + if (Builder::isAtlasSearchNotSupportedException($e)) { + self::markTestSkipped('Atlas Search not supported. ' . $e->getMessage()); + } + + throw $e; + } + + // Waits for the index to be ready + do { + $ready = true; + usleep(10_000); + foreach ($moviesCollection->listSearchIndexes() as $index) { + if ($index['status'] !== 'READY') { + $ready = false; + } + } + } while (! $ready); + } + + /** + * @runInSeparateProcess + * @preserveGlobalState disabled + */ + public function testSimpleSearch(): void + { + // start-search-query + $movies = Movie::search( + sort: ['title' => 1], + operator: Search::text('title', 'dream'), + )->all(); + // end-search-query + + $this->assertNotNull($movies); + $this->assertCount(2, $movies); + } + + /** + * @runInSeparateProcess + * @preserveGlobalState disabled + */ + public function testAutocompleteSearch(): void + { + // start-auto-query + $movies = Movie::autocomplete('title', 'jak')->all(); + // end-auto-query + + $this->assertNotNull($movies); + $this->assertCount(3, $movies); + } + + /** + * @runInSeparateProcess + * @preserveGlobalState disabled + */ + public function testVectorSearch(): void + { + $results = Movie::vectorSearch( + index: 'vector', + path: 'vector4', + queryVector: $this->vectors[0], + limit: 3, + numCandidates: 10, + filter: Query::query( + title: Query::ne('A'), + ), + ); + + $this->assertNotNull($results); + $this->assertSame('D', $results->first()->title); + } + + /** Generates random vectors using fixed seed to make tests deterministic */ + private function addVector(array $items): array + { + srand(1); + foreach ($items as &$item) { + $this->vectors[] = $item['vector4'] = array_map(fn () => rand() / mt_getrandmax(), range(0, 3)); + } + + return $items; + } +} diff --git a/docs/includes/fundamentals/as-avs/Movie.php b/docs/includes/fundamentals/as-avs/Movie.php new file mode 100644 index 000000000..2098db9ec --- /dev/null +++ b/docs/includes/fundamentals/as-avs/Movie.php @@ -0,0 +1,12 @@ + 'movie_a', 'plot' => 'this is a love story'], ['title' => 'movie_b', 'plot' => 'love is a long story'], ['title' => 'movie_c', 'plot' => 'went on a trip'], + ['title' => 'Carrie', 'year' => 1976], + ['title' => 'Carrie', 'year' => 2002], ]); } @@ -164,4 +174,53 @@ public function arrayElemMatch(): void $this->assertNotNull($movies); $this->assertCount(2, $movies); } + + /** + * @runInSeparateProcess + * @preserveGlobalState disabled + */ + public function testReadPreference(): void + { + // start-read-pref + $movies = Movie::where('title', 'Carrie') + ->readPreference(ReadPreference::SECONDARY_PREFERRED) + ->get(); + // end-read-pref + + $this->assertNotNull($movies); + $this->assertCount(2, $movies); + } + + /** + * @runInSeparateProcess + * @preserveGlobalState disabled + */ + public function testQueryLog(): void + { + $output = ''; + ob_start(function (string $buffer) use (&$output) { + $output .= $buffer; + }); + // start-query-log + DB::connection('mongodb')->enableQueryLog(); + + Movie::where('title', 'Carrie')->get(); + Movie::where('year', '<', 2005)->get(); + Movie::where('imdb.rating', '>', 8.5)->get(); + + $logs = DB::connection('mongodb')->getQueryLog(); + foreach ($logs as $log) { + echo json_encode($log, JSON_PRETTY_PRINT) . PHP_EOL; + } + + // end-query-log + $output = ob_get_flush(); + $this->assertNotNull($logs); + $this->assertNotEmpty($output); + + $this->assertStringContainsString('"query": "{ \"find\" : \"movies\", \"filter\" : { \"title\" : \"Carrie\" } }"', $output); + $this->assertStringContainsString('"query": "{ \"find\" : \"movies\", \"filter\" : { \"imdb.rating\" : { \"$gt\" : { \"$numberDouble\" : \"8.5\" } } } }"', $output); + $this->assertStringContainsString('"query": "{ \"find\" : \"movies\", \"filter\" : { \"imdb.rating\" : { \"$gt\" : { \"$numberDouble\" : \"8.5\" } } } }"', $output); + $this->assertMatchesRegularExpression('/"time": \d+/', $output); + } } diff --git a/docs/includes/query-builder/QueryBuilderTest.php b/docs/includes/query-builder/QueryBuilderTest.php index 884c54b5f..a90f1685f 100644 --- a/docs/includes/query-builder/QueryBuilderTest.php +++ b/docs/includes/query-builder/QueryBuilderTest.php @@ -12,6 +12,7 @@ use MongoDB\BSON\Regex; use MongoDB\BSON\UTCDateTime; use MongoDB\Collection; +use MongoDB\Driver\ReadPreference; use MongoDB\Laravel\Tests\TestCase; use function file_get_contents; @@ -212,10 +213,10 @@ public function testGroupBy(): void { // begin query groupBy $result = DB::table('movies') - ->where('rated', 'G') - ->groupBy('runtime') - ->orderBy('runtime', 'asc') - ->get(['title']); + ->where('rated', 'G') + ->groupBy('runtime') + ->orderBy('runtime', 'asc') + ->get(['title']); // end query groupBy $this->assertInstanceOf(\Illuminate\Support\Collection::class, $result); @@ -350,7 +351,7 @@ public function testAll(): void { // begin query all $result = DB::table('movies') - ->where('movies', 'all', ['title', 'rated', 'imdb.rating']) + ->where('writers', 'all', ['Ben Affleck', 'Matt Damon']) ->get(); // end query all @@ -419,10 +420,10 @@ public function testWhereRaw(): void // begin query raw $result = DB::table('movies') ->whereRaw([ - 'imdb.votes' => ['$gte' => 1000 ], + 'imdb.votes' => ['$gte' => 1000], '$or' => [ ['imdb.rating' => ['$gt' => 7]], - ['directors' => ['$in' => [ 'Yasujiro Ozu', 'Sofia Coppola', 'Federico Fellini' ]]], + ['directors' => ['$in' => ['Yasujiro Ozu', 'Sofia Coppola', 'Federico Fellini']]], ], ])->get(); // end query raw @@ -453,11 +454,23 @@ public function testCursorTimeout(): void $this->assertInstanceOf(\Illuminate\Support\Collection::class, $result); } + public function testReadPreference(): void + { + // begin query read pref + $result = DB::table('movies') + ->where('runtime', '>', 240) + ->readPreference(ReadPreference::SECONDARY_PREFERRED) + ->get(); + // end query read pref + + $this->assertInstanceOf(\Illuminate\Support\Collection::class, $result); + } + public function testNear(): void { $this->importTheaters(); - // begin query near + // begin query near $results = DB::table('theaters') ->where('location.geo', 'near', [ '$geometry' => [ @@ -575,7 +588,7 @@ public function testUpdateUpsert(): void [ 'plot' => 'An autobiographical movie', 'year' => 1998, - 'writers' => [ 'Will Hunting' ], + 'writers' => ['Will Hunting'], ], ['upsert' => true], ); @@ -584,6 +597,29 @@ public function testUpdateUpsert(): void $this->assertIsInt($result); } + public function testMultiplyDivide(): void + { + // begin multiply divide + $result = DB::table('movies') + ->where('year', 2001) + ->multiply('imdb.votes', 5); + + $result = DB::table('movies') + ->where('year', 2001) + ->divide('runtime', 2); + // end multiply divide + + $this->assertIsInt($result); + + // begin multiply with set + $result = DB::table('movies') + ->where('year', 1958) + ->multiply('runtime', 1.5, ['note' => 'Adds recovered footage.']); + // end multiply with set + + $this->assertIsInt($result); + } + public function testIncrement(): void { // begin increment diff --git a/docs/includes/schema-builder/flights_migration.php b/docs/includes/schema-builder/flights_migration.php index 861c339ef..4f776f260 100644 --- a/docs/includes/schema-builder/flights_migration.php +++ b/docs/includes/schema-builder/flights_migration.php @@ -19,6 +19,25 @@ public function up(): void $collection->unique('mission_id', options: ['name' => 'unique_mission_id_idx']); }); // end create index + + // begin-json-schema + Schema::create('pilots', function (Blueprint $collection) { + $collection->jsonSchema( + schema: [ + 'bsonType' => 'object', + 'required' => ['license_number'], + 'properties' => [ + 'license_number' => [ + 'bsonType' => 'int', + 'minimum' => 1000, + 'maximum' => 9999, + ], + ], + ], + validationAction: 'error', + ); + }); + // end-json-schema } public function down(): void diff --git a/docs/includes/schema-builder/galaxies_migration.php b/docs/includes/schema-builder/galaxies_migration.php new file mode 100644 index 000000000..fc92ff026 --- /dev/null +++ b/docs/includes/schema-builder/galaxies_migration.php @@ -0,0 +1,119 @@ +searchIndex([ + 'mappings' => [ + 'dynamic' => true, + ], + ], 'dynamic_index'); + $collection->searchIndex([ + 'mappings' => [ + 'fields' => [ + 'name' => [ + ['type' => 'string', 'analyzer' => 'lucene.english'], + ['type' => 'autocomplete', 'analyzer' => 'lucene.english'], + ['type' => 'token'], + ], + ], + ], + ], 'auto_index'); + }); + // end-create-search-indexes + + $index = $this->getSearchIndex('galaxies', 'dynamic_index'); + self::assertNotNull($index); + + self::assertSame('dynamic_index', $index['name']); + self::assertSame('search', $index['type']); + self::assertTrue($index['latestDefinition']['mappings']['dynamic']); + + $index = $this->getSearchIndex('galaxies', 'auto_index'); + self::assertNotNull($index); + + self::assertSame('auto_index', $index['name']); + self::assertSame('search', $index['type']); + } + + public function testVectorSearchIdx(): void + { + // begin-create-vs-index + Schema::create('galaxies', function (Blueprint $collection) { + $collection->vectorSearchIndex([ + 'fields' => [ + [ + 'type' => 'vector', + 'numDimensions' => 4, + 'path' => 'embeddings', + 'similarity' => 'cosine', + ], + ], + ], 'vs_index'); + }); + // end-create-vs-index + + $index = $this->getSearchIndex('galaxies', 'vs_index'); + self::assertNotNull($index); + + self::assertSame('vs_index', $index['name']); + self::assertSame('vectorSearch', $index['type']); + self::assertSame('vector', $index['latestDefinition']['fields'][0]['type']); + } + + public function testDropIndexes(): void + { + // begin-drop-search-index + Schema::table('galaxies', function (Blueprint $collection) { + $collection->dropSearchIndex('auto_index'); + }); + // end-drop-search-index + + Schema::table('galaxies', function (Blueprint $collection) { + $collection->dropSearchIndex('dynamic_index'); + }); + + Schema::table('galaxies', function (Blueprint $collection) { + $collection->dropSearchIndex('vs_index'); + }); + + $index = $this->getSearchIndex('galaxies', 'auto_index'); + self::assertNull($index); + + $index = $this->getSearchIndex('galaxies', 'dynamic_index'); + self::assertNull($index); + + $index = $this->getSearchIndex('galaxies', 'vs_index'); + self::assertNull($index); + } + + protected function getSearchIndex(string $collection, string $name): ?array + { + $collection = $this->getConnection('mongodb')->getCollection($collection); + assert($collection instanceof Collection); + + foreach ($collection->listSearchIndexes(['name' => $name, 'typeMap' => ['root' => 'array', 'array' => 'array', 'document' => 'array']]) as $index) { + return $index; + } + + return null; + } +} diff --git a/docs/index.txt b/docs/index.txt index 6b91880f9..0b01b7349 100644 --- a/docs/index.txt +++ b/docs/index.txt @@ -1,3 +1,5 @@ +.. _laravel-docs-landing: + =============== {+odm-long+} =============== @@ -22,6 +24,8 @@ Databases & Collections User Authentication Cache & Locks + Scout Integration + HTTP Sessions Queues Transactions GridFS Filesystems @@ -85,6 +89,8 @@ see the following content: - :ref:`laravel-aggregation-builder` - :ref:`laravel-user-authentication` - :ref:`laravel-cache` +- :ref:`laravel-scout` +- :ref:`laravel-sessions` - :ref:`laravel-queues` - :ref:`laravel-transactions` - :ref:`laravel-filesystems` diff --git a/docs/query-builder.txt b/docs/query-builder.txt index b3c89b0ae..2358ed7d5 100644 --- a/docs/query-builder.txt +++ b/docs/query-builder.txt @@ -195,7 +195,7 @@ the value of the ``title`` field is ``"Back to the Future"``: :start-after: begin query orWhere :end-before: end query orWhere -.. note:: +.. note:: id Alias You can use the ``id`` alias in your queries to represent the ``_id`` field in MongoDB documents, as shown in the preceding @@ -208,6 +208,9 @@ the value of the ``title`` field is ``"Back to the Future"``: Because of this behavior, you cannot have two separate ``id`` and ``_id`` fields in your documents. + To learn how to disable this behavior for embedded documents, see the + :ref:`laravel-disable-id-alias` section of the Connection Options guide. + .. _laravel-query-builder-logical-and: Logical AND Example @@ -227,7 +230,7 @@ value greater than ``8.5`` and a ``year`` value of less than .. tip:: - For compatibility with Laravel, Laravel MongoDB v5.1 supports both arrow + For compatibility with Laravel, {+odm-long+} v5.1 supports both arrow (``->``) and dot (``.``) notation to access nested fields in a query filter. The preceding example uses dot notation to query the ``imdb.rating`` nested field, which is the recommended syntax. @@ -678,7 +681,7 @@ a query: :end-before: end options The query builder accepts the same options that you can set for -the :phpmethod:`MongoDB\Collection::find()` method in the +the :phpmethod:`find() ` method in the {+php-library+}. Some of the options to modify query results, such as ``skip``, ``sort``, and ``limit``, are settable directly as query builder methods and are described in the @@ -840,6 +843,7 @@ to use the following MongoDB-specific query operations: - :ref:`Run MongoDB Query API operations ` - :ref:`Match documents that contain array elements ` - :ref:`Specify a cursor timeout ` +- :ref:`Specify a read preference ` - :ref:`Match locations by using geospatial searches ` .. _laravel-query-builder-exists: @@ -868,7 +872,8 @@ Contains All Fields Example The following example shows how to use the ``all`` query operator with the ``where()`` query builder method to match -documents that contain all the specified fields: +documents that have a ``writers`` array field containing all +the specified values: .. literalinclude:: /includes/query-builder/QueryBuilderTest.php :language: php @@ -1033,6 +1038,31 @@ to specify a maximum duration to wait for cursor operations to complete. `MongoDB\Collection::find() `__ in the PHP Library documentation. +.. _laravel-query-builder-read-pref: + +Specify a Read Preference Example +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +You can control how the {+odm-short+} directs read operations to replica +set members by setting a read preference. + +The following example queries the ``movies`` collection for documents +in which the ``runtime`` value is greater than ``240``. The example passes a +value of ``ReadPreference::SECONDARY_PREFERRED`` to the ``readPreference()`` +method, which sends the query to secondary replica set members +or the primary member if no secondaries are available: + +.. literalinclude:: /includes/query-builder/QueryBuilderTest.php + :language: php + :dedent: + :start-after: begin query read pref + :end-before: end query read pref + +.. tip:: + + To learn more about read preferences, see :manual:`Read Preference + ` in the MongoDB {+server-docs-name+}. + .. _laravel-query-builder-geospatial: Match Locations by Using Geospatial Operations @@ -1061,7 +1091,7 @@ in the {+server-docs-name+}. .. _laravel-query-builder-geospatial-near: Near a Position Example -~~~~~~~~~~~~~~~~~~~~~~~ +^^^^^^^^^^^^^^^^^^^^^^^ The following example shows how to use the ``near`` query operator with the ``where()`` query builder method to match documents that @@ -1081,7 +1111,7 @@ in the {+server-docs-name+}. .. _laravel-query-builder-geospatial-geoWithin: Within an Area Example -~~~~~~~~~~~~~~~~~~~~~~ +^^^^^^^^^^^^^^^^^^^^^^ The following example shows how to use the ``geoWithin`` query operator with the ``where()`` @@ -1098,7 +1128,7 @@ GeoJSON object: .. _laravel-query-builder-geospatial-geoIntersects: Intersecting a Geometry Example -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ The following example shows how to use the ``geoInstersects`` query operator with the ``where()`` query builder method to @@ -1114,7 +1144,7 @@ the specified ``LineString`` GeoJSON object: .. _laravel-query-builder-geospatial-geoNear: Proximity Data for Nearby Matches Example -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ The following example shows how to use the ``geoNear`` aggregation operator with the ``raw()`` query builder method to perform an aggregation that returns @@ -1139,6 +1169,7 @@ This section includes query builder examples that show how to use the following MongoDB-specific write operations: - :ref:`Upsert a document ` +- :ref:`Multiply and divide values ` - :ref:`Increment a numerical value ` - :ref:`Decrement a numerical value ` - :ref:`Add an array element ` @@ -1222,6 +1253,41 @@ and the ``title`` field and value specified in the ``where()`` query operation: The ``update()`` query builder method returns the number of documents that the operation updated or inserted. +.. _laravel-mongodb-query-builder-mul-div: + +Multiply and Divide Numerical Values Example +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Starting in {+odm-short+} v5.5, you can perform multiplication and +division operations on numerical values by using the ``multiply()`` and +``divide()`` query builder methods. + +The following example shows how to use the ``multiply()`` and +``divide()`` methods to manipulate the values of the +``imdb.votes`` and ``runtime`` fields: + +.. literalinclude:: /includes/query-builder/QueryBuilderTest.php + :language: php + :dedent: + :start-after: begin multiply divide + :end-before: end multiply divide + +.. tip:: update() Method + + You can perform the same operations by using the ``update()`` + method and passing an update document that includes the :manual:`$mul + ` operator. To learn more about + ``update()``, see the :ref:`laravel-fundamentals-write-modify` guide. + +You can optionally pass an array parameter to perform a ``$set`` update +in the same operation, as shown in the following example: + +.. literalinclude:: /includes/query-builder/QueryBuilderTest.php + :language: php + :dedent: + :start-after: begin multiply with set + :end-before: end multiply with set + .. _laravel-mongodb-query-builder-increment: Increment a Numerical Value Example diff --git a/docs/quick-start.txt b/docs/quick-start.txt index 1d188ad84..5bd04f353 100644 --- a/docs/quick-start.txt +++ b/docs/quick-start.txt @@ -26,6 +26,7 @@ Quick Start View Data Write Data Next Steps + Tutorial: Build a Back End Overview -------- @@ -41,13 +42,8 @@ read and write operations on the data. `How to Build a Laravel + MongoDB Back End Service `__ MongoDB Developer Center tutorial. - You can learn how to set up a local Laravel development environment - and perform CRUD operations by viewing the - :mdbu-course:`Getting Started with Laravel and MongoDB ` - MongoDB University Learning Byte. - If you prefer to connect to MongoDB by using the {+php-library+} without - Laravel, see `Connecting to MongoDB `__ + Laravel, see `Connect to MongoDB `__ in the {+php-library+} documentation. The {+odm-short+} extends the Laravel Eloquent and Query Builder syntax to diff --git a/docs/quick-start/backend-service-tutorial.txt b/docs/quick-start/backend-service-tutorial.txt new file mode 100644 index 000000000..7ecdf8cf8 --- /dev/null +++ b/docs/quick-start/backend-service-tutorial.txt @@ -0,0 +1,481 @@ +.. _laravel-tutorial-backend-service: + +=========================================================== +Tutorial: Build a Back End Service by Using {+odm-long+} +=========================================================== + +.. facet:: + :name: genre + :values: tutorial + +.. meta:: + :keywords: php framework, odm, code example, crud + :description: Learn how to set up a back end and perform CRUD operations by using Laravel MongoDB. + +.. contents:: On this page + :local: + :backlinks: none + :depth: 2 + :class: singlecol + +Overview +-------- + +In this tutorial, you create a simple REST back end for a front-end app +by using {+odm-long+}. The tutorial uses Laravel's built-in API routing +features. + +Prerequisites +------------- + +Before you can start this tutorial, you need the following software +installed in your development environment: + +- MongoDB Atlas cluster with sample data loaded. To learn how to create + a cluster, see the :ref:`laravel-quick-start-create-deployment` step + of the Quick Start guide. +- `PHP `__. +- `Composer `__. +- `{+php-extension+} `__. +- A terminal app and shell. For MacOS users, use Terminal or a similar app. + For Windows users, use PowerShell. + +Steps +----- + +.. procedure:: + :style: connected + + .. step:: Create a Laravel project. + + First, create a Laravel project directory. Then, run the following + command to create a new Laravel project called ``laraproject``: + + .. code-block:: bash + + composer create-project laravel/laravel laraproject + + .. step:: Install {+odm-long+}. + + To check that {+odm-long+} is running in the web server, + add a webpage to your Laravel website. In your project, + navigate to ``/routes/web.php`` and add the following route: + + .. code-block:: php + + Route::get('/info', function () { + phpinfo(); + }); + + Then, run the following command in your shell to start + your application: + + .. code-block:: bash + + php artisan serve + + After the application begins running, navigate to + http://127.0.0.1:8000/info to view the PHPinfo page. Scroll down + to or search for the **mongodb** entry to verify that + the {+php-extension+} is installed. + + Run the following command in your shell to install {+odm-long+}: + + .. code-block:: bash + + composer require mongodb/laravel-mongodb:^{+package-version+} + + .. step:: Configure your MongoDB connection. + + Open your project's ``config/database.php`` file and update the + ``connections`` array as shown in the following code: + + .. code-block:: php + + 'connections' => [ + 'mongodb' => [ + 'driver' => 'mongodb', + 'dsn' => '', + 'database' => 'db', + ], + + Ensure that you replace the connection string placeholder + in the preceding code with your connection string before you run + your application. To learn how to locate your connection string, see + :ref:`laravel-quick-start-connection-string` in the Quick Start + guide. + + You can also set the default database connection. At the top of + the ``config/database.php`` file, change 'default' to the following: + + .. code-block:: php + + 'default' => 'mongodb', + + The Laravel application can now connect to the ``db`` database in + your MongoDB cluster. + + .. step:: Create an endpoint to ping your deployment. + + Run the following shell command to set up API routing: + + .. code-block:: bash + + php artisan install:api + + In the newly created ``routes/api.php`` file, add the following + route: + + .. code-block:: php + + // Add the DB use statement to the top of the file. + use Illuminate\Support\Facades\DB; + + Route::get('/ping', function (Request $request) { + $connection = DB::connection('mongodb'); + $msg = 'MongoDB is accessible!'; + try { + $connection->command(['ping' => 1]); + } catch (\Exception $e) { + $msg = 'MongoDB is not accessible. Error: ' . $e->getMessage(); + } + return ['msg' => $msg]; + }); + + Reload the application, then verify that + http://127.0.0.1:8000/api/ping shows the succesful ping message. + + .. step:: Create Eloquent models. + + Laravel is integrated with Eloquent, an ORM that abstracts the + database back end so that you can connect to different databases by + using a common interface. + + Eloquent provides a ``Model`` class to serve as the interface + between your code and a specific collection. Instances of the + ``Model`` classes represent rows of tables in relational + databases. In MongoDB, they are documents in the collection. + + .. tip:: + + You can define fillable fields in your Eloquent models + to enforce a document schema in your application and prevent + errors such as name typos. To learn more, see the + :ref:`laravel-model-mass-assignment` section of the Eloquent + Model Class guide. + + Create an Eloquent model called ``CustomerMongoDB`` by running + the following command from the project root: + + .. code-block:: bash + + php artisan make:model CustomerMongoDB + + Laravel creates the ``CustomerMongoDB`` class in the ``/models`` + directory. By default, models use the ``default`` database + connection, but you can specify which connection to use by adding + the ``$connection`` member to the class. You can also + specify the collection name by adding the ``$collection`` member. + + Ensure you include the ``use`` statement for the MongoDB Eloquent + model. This is necessary to set ``_id`` as the primary key. + + Replace the contents of the ``CustomerMongoDB.php`` file with the + following code: + + .. code-block:: php + + use MongoDB\Laravel\Eloquent\Model; + + class CustomerMongoDB extends Model + { + // the selected database as defined in /config/database.php + protected $connection = 'mongodb'; + + // equivalent to $table for MySQL + protected $collection = 'laracoll'; + + // defines the schema for top-level properties (optional). + protected $fillable = ['guid', 'first_name', 'family_name', 'email', 'address']; + } + + .. step:: Perform CRUD operations. + + After you create your models, you can perform data operations. + + Create the following route in your ``api.php`` file: + + .. code-block:: php + + Route::get('/create_eloquent_mongo/', function (Request $request) { + $success = CustomerMongoDB::create([ + 'guid'=> 'cust_1111', + 'first_name'=> 'John', + 'family_name' => 'Doe', + 'email' => 'j.doe@gmail.com', + 'address' => '123 my street, my city, zip, state, country' + ]); + }); + + After you insert the document, you can retrieve it by using the + ``where()`` method as shown in the following code: + + .. code-block:: php + + Route::get('/find_eloquent/', function (Request $request) { + $customer = CustomerMongoDB::where('guid', 'cust_1111')->get(); + }); + + Eloquent allows you to find data by using complex queries with + multiple matching conditions. + + You can also update and delete data shown in the following routes: + + .. code-block:: php + + Route::get('/update_eloquent/', function (Request $request) { + $result = CustomerMongoDB::where('guid', 'cust_1111')->update( ['first_name' => 'Jimmy'] ); + }); + + Route::get('/delete_eloquent/', function (Request $request) { + $result = CustomerMongoDB::where('guid', 'cust_1111')->delete(); + }); + + At this point, your MongoDB-connected back-end service is + running, but MongoDB provides more functionality to support your + operations. + + .. step:: Perform operations on nested data. + + {+odm-long+} offers MongoDB-specific operations for nested data. + However, adding nested data is also intuitive without using + the ``embedsMany()`` and ``embedsOne()`` methods. + + As shown in the preceding step, you can define top-level schema + attributes. However, it is more complicated when to define these + attribute if your documents include arrays and embedded documents. + + You can create the model's data structures in PHP. In the + following example, the ``address`` field is an object type. + The ``email`` field is an array of strings: + + .. code-block:: php + + Route::get('/create_nested/', function (Request $request) { + $message = "executed"; + $success = null; + + $address = new stdClass; + $address->street = '123 my street name'; + $address->city = 'my city'; + $address->zip= '12345'; + $emails = ['j.doe@gmail.com', 'j.doe@work.com']; + + try { + $customer = new CustomerMongoDB(); + $customer->guid = 'cust_2222'; + $customer->first_name = 'John'; + $customer->family_name= 'Doe'; + $customer->email= $emails; + $customer->address= $address; + $success = $customer->save(); // save() returns 1 or 0 + } + catch (\Exception $e) { + $message = $e->getMessage(); + } + return ['msg' => $message, 'data' => $success]; + }); + + When you access the ``/api/create_nested/`` endpoint, it creates a + document in MongoDB: + + .. code-block:: json + + { + "_id": {...}, + "guid": "cust_2222", + "first_name": "John", + "family_name": "Doe", + "email": [ + "j.doe@gmail.com", + "j.doe@work.com" + ], + "address": { + "street": "123 my street name", + "city": "my city", + "zip": "12345" + }, + "updated_at": { + "$date": "2025-05-27T17:38:28.793Z" + }, + "created_at": { + "$date": "2025-05-27T17:38:28.793Z" + } + } + + .. step:: Use the MongoDB Query API. + + MongoDB provides the Query API for optimized queries. + + You can begin to build a query by using a ``collection`` object. + Eloquent exposes the full capabilities of the underlying database + by using "raw queries," which Laravel sends to the database + without any processing from the Eloquent Query Builder. + + You can perform a raw native MongoDB query from the model as shown + in the following code: + + .. code-block:: php + + $mongodbquery = ['guid' => 'cust_1111']; + + // returns a "Illuminate\Database\Eloquent\Collection" Object + $results = CustomerMongoDB::whereRaw( $mongodbquery )->get(); + + You can also access the native MongoDB collection object and + perform a query that returns objects such as native MongoDB + documents or cursors: + + .. code-block:: php + + $mongodbquery = ['guid' => 'cust_1111', ]; + $mongodb_native_collection = DB::connection('mongodb')->getCollection('laracoll'); + $document = $mongodb_native_collection->findOne( $mongodbquery ); + $cursor = $mongodb_native_collection->find( $mongodbquery ); + + The following code demonstrates multiple ways to perform queries: + + .. code-block:: php + + Route::get('/find_native/', function (Request $request) { + // a simple MongoDB query that looks for a customer based on the guid + $mongodbquery = ['guid' => 'cust_2222']; + + // Option #1 + // ========= + // use Eloquent's whereRaw() function + // returns a "Illuminate\Database\Eloquent\Collection" Object + + $results = CustomerMongoDB::whereRaw( $mongodbquery )->get(); + + // Option #2 & #3 + // ============== + // use the native MongoDB driver Collection object and the Query API + + $mdb_collection = DB::connection('mongodb')->getCollection('laracoll'); + + // find the first document that matches the query + $mdb_bsondoc = $mdb_collection->findOne( $mongodbquery ); // returns a "MongoDB\Model\BSONDocument" Object + + // to convert the MongoDB Document to a Laravel Model, use the Model's newFromBuilder() method + $cust = new CustomerMongoDB(); + $one_doc = $cust->newFromBuilder((array) $mdb_bsondoc); + + // find all documents because you pass an empty query + $mdb_cursor = $mdb_collection->find(); // returns a "MongoDB\Driver\Cursor" object + $cust_array = array(); + foreach ($mdb_cursor->toArray() as $bson) { + $cust_array[] = $cust->newFromBuilder( $bson ); + } + + return ['msg' => 'executed', 'whereraw' => $results, 'document' => $one_doc, 'cursor_array' => $cust_array]; + }); + + The following code demonstrates how to use the ``updateOne()`` + method to update documents: + + .. code-block:: php + + Route::get('/update_native/', function (Request $request) { + $mdb_collection = DB::connection('mongodb')->getCollection('laracoll'); + $match = ['guid' => 'cust_2222']; + $update = ['$set' => ['first_name' => 'Henry', 'address.street' => '777 new street name'] ]; + $result = $mdb_collection->updateOne($match, $update ); + return ['msg' => 'executed', 'matched_docs' => $result->getMatchedCount(), 'modified_docs' => $result->getModifiedCount()]; + }); + + The following code demonstrates how to use the ``deleteOne()`` + method to delete documents: + + .. code-block:: php + + Route::get('/delete_native/', function (Request $request) { + $mdb_collection = DB::connection('mongodb')->getCollection('laracoll'); + $match = ['guid' => 'cust_2222']; + $result = $mdb_collection->deleteOne($match ); + return ['msg' => 'executed', 'deleted_docs' => + $result->getDeletedCount() ]; + }); + + To learn more about how to perform CRUD operations, see the + :ref:`laravel-fundamentals-write-ops` and + :ref:`laravel-fundamentals-read-ops` guides. + + .. step:: Use the aggregation framework. + + An aggregation pipeline is a task in MongoDB's aggregation + framework. You can use the aggregation framework to perform + various tasks such as real-time dashboards and big data analysis. + + An aggregation pipeline consists of multiple stages in which the + output of each stage is the input of the following stage. + This step uses the ``sample_mflix`` from the :atlas:`Atlas sample + datasets `. Laravel allows you to access multiple + MongoDB databases in the same app, so add the ``sample_mflix`` + database connection to ``database.php``: + + .. code-block:: php + + 'mongodb_mflix' => [ + 'driver' => 'mongodb', + 'dsn' => env('DB_URI'), + 'database' => 'sample_mflix', + ], + + Next, create the ``/aggregate/`` API endpoint and define an + aggregation pipeline to retrieve data from the ``movies`` + collection, compute the average movie rating for each genre, and + return a list. + + .. code-block:: php + + Route::get('/aggregate/', function (Request $request) { + $mdb_collection = DB::connection('mongodb_mflix')->getCollection('movies'); + + $stage0 = ['$unwind' => ['path' => '$genres']]; + $stage1 = ['$group' => ['_id' => '$genres', 'averageGenreRating' => ['$avg' => '$imdb.rating']]]; + $stage2 = ['$sort' => ['averageGenreRating' => -1]]; + $aggregation = [$stage0, $stage1, $stage2]; + + $mdb_cursor = $mdb_collection->aggregate( $aggregation ); + + return ['msg' => 'executed', 'data' => $mdb_cursor->toArray() ]; + }); + + {+odm-long+} provides the :ref:`laravel-aggregation-builder` to + build type-safe aggregation pipelines directly from your models. + We recommend using the aggregation builder to perform + aggregations. + + .. step:: Use indexes to optimize query performance. + + You can create indexes to support your queries and improve + performance. To learn more about how to create indexes + programmatically, see the :ref:`laravel-eloquent-indexes` section + of the Schema Builder guide. + +Conclusion +---------- + +In this tutorial, you learned how to create a back-end service by using +Laravel and MongoDB for a front-end web application. +This tutorial also showed how you can use the document model to improve +database efficiency and scalability. You can use the document model with the +MongoDB Query API to create better apps with less downtime. + +You can access the full code for this tutorial in the +:github:`laravel-mongodb-tutorial +` repository on GitHub. + +Navigate through the rest of the :ref:`laravel-docs-landing` +documentation to learn more about {+odm-long+}'s features. diff --git a/docs/quick-start/download-and-install.txt b/docs/quick-start/download-and-install.txt index 696861a43..293425791 100644 --- a/docs/quick-start/download-and-install.txt +++ b/docs/quick-start/download-and-install.txt @@ -31,7 +31,7 @@ to a Laravel web application. .. tip:: As an alternative to the following installation steps, you can use Laravel Herd - to install MongoDB and configure a Laravel MongoDB development environment. For + to install MongoDB and configure a development environment for {+odm-long+}. For more information about using Laravel Herd with MongoDB, see the following resources: - `Installing MongoDB via Herd Pro diff --git a/docs/quick-start/next-steps.txt b/docs/quick-start/next-steps.txt index 1a7f45c6e..dc155326f 100644 --- a/docs/quick-start/next-steps.txt +++ b/docs/quick-start/next-steps.txt @@ -21,6 +21,19 @@ You can download the web application project by cloning the `laravel-quickstart `__ GitHub repository. +.. tip:: Tutorials + + Learn how to implement more CRUD functionality in a {+odm-long+} + application by following the :ref:`Build a Back End Service by Using + {+odm-long+} ` tutorial. + + Learn how to build a full stack application that uses {+odm-long+} by + following along with the `Full Stack Instagram Clone with Laravel and + MongoDB `__ tutorial on YouTube. + +Further Learning +---------------- + Learn more about {+odm-long+} features from the following resources: - :ref:`laravel-fundamentals-connection`: learn how to configure your MongoDB @@ -34,4 +47,3 @@ Learn more about {+odm-long+} features from the following resources: - :ref:`laravel-query-builder`: use the query builder to specify MongoDB queries and aggregations. - diff --git a/docs/quick-start/view-data.txt b/docs/quick-start/view-data.txt index f29b2bd12..34be94e9e 100644 --- a/docs/quick-start/view-data.txt +++ b/docs/quick-start/view-data.txt @@ -105,9 +105,9 @@ View MongoDB Data .. code-block:: none :copyable: false - INFO View [resources/views/browse_movie.blade.php] created successfully. + INFO View [resources/views/browse_movies.blade.php] created successfully. - Open the ``browse_movie.blade.php`` view file in the ``resources/views`` + Open the ``browse_movies.blade.php`` view file in the ``resources/views`` directory. Replace the contents with the following code and save the changes: @@ -141,7 +141,7 @@ View MongoDB Data .. step:: Optionally, view your results as JSON documents - Rather than generating a view and editing the ``browse_movie.blade.php`` file, you can + Rather than generating a view and editing the ``browse_movies.blade.php`` file, you can use the ``toJson()`` method to display your results in JSON format. Replace the ``show()`` function with the following code to retrieve results and diff --git a/docs/scout.txt b/docs/scout.txt new file mode 100644 index 000000000..8f409148b --- /dev/null +++ b/docs/scout.txt @@ -0,0 +1,259 @@ +.. _laravel-scout: + +=========================== +Full-Text Search with Scout +=========================== + +.. facet:: + :name: genre + :values: reference + +.. meta:: + :keywords: php framework, odm, code example, text search, atlas + +.. contents:: On this page + :local: + :backlinks: none + :depth: 2 + :class: singlecol + +Overview +-------- + +In this guide, you can learn how to use the Laravel Scout feature in +your {+odm-long+} application. Scout enables you to implement full-text +search on your Eloquent models. To learn more, see `Laravel Scout +`__ in the +Laravel documentation. + +The Scout integration for {+odm-long+} provides the following +functionality: + +- Provides an abstraction to create :atlas:`Atlas Search indexes + ` from any MongoDB or SQL model. + + .. important:: Use Schema Builder to Create Search Indexes + + If your documents are already in MongoDB, create Search indexes + by using {+php-library+} or ``Schema`` builder methods to improve + search query performance. To learn more about creating Search + indexes, see the :ref:`laravel-as-index` section of the Atlas + Search guide. + +- Enables you to automatically replicate data from MongoDB into a + search engine such as `Meilisearch `__ + or `Algolia `__. You can use a MongoDB Eloquent + model as the source to import and index. To learn more about indexing + to a search engine, see the `Indexing + `__ + section of the Laravel Scout documentation. + +.. important:: Deployment Compatibility + + You can use Laravel Scout only when you connect to MongoDB Atlas + deployments. This feature is not available for self-managed + deployments. + +Scout for Atlas Search Tutorial +------------------------------- + +This tutorial demonstrates how to use Scout to compound and index +documents for MongoDB Atlas Search from Eloquent models (MongoDB or SQL). + +.. procedure:: + :style: connected + + .. step:: Install the Scout package + + Before you can use Scout in your application, run the following + command from your application's root directory to install the + ``laravel/scout`` package: + + .. code-block:: bash + + composer require laravel/scout + + .. step:: Add the Searchable trait to your model + + Add the ``Laravel\Scout\Searchable`` trait to an Eloquent model to make + it searchable. The following example adds this trait to the ``Movie`` + model, which represents documents in the ``sample_mflix.movies`` + collection: + + .. code-block:: php + :emphasize-lines: 6, 10 + + `__ + section of the Laravel Scout documentation. + + .. step:: Configure Scout in your application + + Ensure that your application is configured to use MongoDB as its + database connection. To learn how to configure MongoDB, see the + :ref:`laravel-quick-start-connect-to-mongodb` section of the Quick Start + guide. + + To configure Scout in your application, create a file named + ``scout.php`` in your application's ``config`` directory. Paste the + following code into the file to configure Scout: + + .. code-block:: php + :caption: config/scout.php + + env('SCOUT_DRIVER', 'mongodb'), + 'mongodb' => [ + 'connection' => env('SCOUT_MONGODB_CONNECTION', 'mongodb'), + ], + 'prefix' => env('SCOUT_PREFIX', 'scout_'), + ]; + + The preceding code specifies the following configuration: + + - Uses the value of the ``SCOUT_DRIVER`` environment variable as + the default search driver, or ``mongodb`` if the environment + variable is not set + + - Specifies ``scout_`` as the prefix for the collection name of the + searchable collection + + In the ``config/scout.php`` file, you can also specify a custom + Atlas Search index definition. To learn more, see the :ref:`custom + index definition example ` in the + following step. + + Set the following environment variable in your application's + ``.env`` file to select ``mongodb`` as the default search driver: + + .. code-block:: none + :caption: .env + + SCOUT_DRIVER=mongodb + + .. tip:: Queueing + + When using Scout, consider configuring a queue driver to reduce + response times for your application's web interface. To learn more, + see the `Queuing section + `__ + of the Laravel Scout documentation and the :ref:`laravel-queues` guide. + + .. step:: Create the Atlas Search index + + After you configure Scout and set your default search driver, you can + create your searchable collection and search index by running the + following command from your application's root directory: + + .. code-block:: bash + + php artisan scout:index 'App\Models\Movie' + + Because you set MongoDB as the default search driver, the preceding + command creates the search collection with an Atlas Search index in your + MongoDB database. The collection is named ``scout_movies``, based on the prefix + set in the preceding step. The Atlas Search index is named ``scout`` + and has the following configuration by default: + + .. code-block:: json + + { + "mappings": { + "dynamic": true + } + } + + .. _laravel-scout-custom-index: + + To customize the index definition, add the ``index-definitions`` + configuration to the ``mongodb`` entry in your + ``config/scout.php`` file. The following code demonstrates how to + specify a custom index definition to create on the + ``scout_movies`` collection: + + .. code-block:: php + + 'mongodb' => [ + 'connection' => env('SCOUT_MONGODB_CONNECTION', 'mongodb'), + 'index-definitions' => [ + 'scout_movies' => [ + 'mappings' => [ + 'dynamic' => false, + 'fields' => ['title' => ['type' => 'string']] + ] + ] + ] + ], ... + + To learn more about defining Atlas Search index definitions, see the + :atlas:`Define Field Mappings + ` guide in the Atlas + documentation. + + .. note:: + + MongoDB can take up to a minute to create and finalize + an Atlas Search index, so the ``scout:index`` command might not + return a success message immediately. + + .. step:: Import data into the searchable collection + + You can use Scout to replicate data from a source collection + modeled by your Eloquent model into a searchable collection. The + following command replicates and indexes data from the ``movies`` + collection into the ``scout_movies`` collection indexed in the + preceding step: + + .. code-block:: bash + + php artisan scout:import 'App\Models\Movie' + + The documents are automatically indexed for Atlas Search queries. + + .. tip:: Select Fields to Import + + You might not need all the fields from your source documents in your + searchable collection. Limiting the amount of data you replicate can improve + your application's speed and performance. + + You can select specific fields to import by defining the + ``toSearchableArray()`` method in your Eloquent model class. The + following code demonstrates how to define ``toSearchableArray()`` to + select only the ``plot`` and ``title`` fields for replication: + + .. code-block:: php + + class Movie extends Model + { + .... + public function toSearchableArray(): array + { + return [ + 'plot' => $this->plot, + 'title' => $this->title, + ]; + } + } + +After completing these steps, you can perform Atlas Search queries on the +``scout_movies`` collection in your {+odm-long+} application. To learn +how to perform full-text searches, see the :ref:`laravel-atlas-search` +guide. diff --git a/docs/sessions.txt b/docs/sessions.txt new file mode 100644 index 000000000..0f334b873 --- /dev/null +++ b/docs/sessions.txt @@ -0,0 +1,111 @@ +.. _laravel-sessions: + +============= +HTTP Sessions +============= + +.. facet:: + :name: genre + :values: reference + +.. meta:: + :keywords: php framework, odm, cookies, multiple requests + +.. contents:: On this page + :local: + :backlinks: none + :depth: 2 + :class: singlecol + +Overview +-------- + +In this guide, you can learn how to set up HTTP sessions by +using {+odm-long+}. Sessions allow your application to store information +about a user across multiple server requests. Your application stores this +information in a specified location that it can access in future +requests that the user makes. + +.. note:: Session Handler Implementation + + The v5.4 {+odm-long+} introduces the dedicated + ``MongoDbSessionHandler`` class that extends the Laravel + ``DatabaseSessionHandler`` class to store session information. + The ``mongodb`` session driver saves user IDs, IP addresses, and user + agents if present. + + In v5.3 and earlier, the session driver uses the + ``MongoDbSessionHandler`` class from the Symfony framework. + +To learn more about support for sessions, see `HTTP Session +`__ in the +Laravel documentation. + +Register a Session +------------------ + +Before you can register a session, you must configure your connection to +MongoDB in your application's ``config/database.php`` file. To learn how +to set up this connection, see the +:ref:`laravel-quick-start-connect-to-mongodb` step of the Quick Start +guide. + +Next, you can select the session driver and connection in one of the +following ways: + +1. In an ``.env`` file, by setting the following environment variables: + + .. code-block:: ini + :caption: .env + + SESSION_DRIVER=mongodb + # Optional, this is the default value + SESSION_CONNECTION=mongodb + +#. In the ``config/session.php`` file, as shown in the following code: + + .. code-block:: php + :caption: config/session.php + + 'mongodb', // Required + 'connection' => 'mongodb', // Database connection name, default is "mongodb" + 'table' => 'sessions', // Collection name, default is "sessions" + 'lifetime' => null, // TTL of session in minutes, default is 120 + 'options' => [] // Other driver options + ]; + +The following list describes other driver options that you can set in +the ``options`` array: + +- ``id_field``: Custom field name for storing the session ID (default: ``_id``) +- ``data_field``: Custom field name for storing the session data (default: ``data``) +- ``time_field``: Custom field name for storing the timestamp (default: ``time``) +- ``expiry_field``: Custom field name for storing the expiry timestamp (default: ``expires_at``) +- ``ttl``: Time to live in seconds + +We recommend that you create an index on the ``expiry_field`` field for +garbage collection. You can also automatically expire sessions in the +database by creating a TTL index on the collection that stores session +information. + +You can use the ``Schema`` builder to create a TTL index, as shown in +the following code: + +.. code-block:: php + + Schema::create('sessions', function (Blueprint $collection) { + $collection->expire('expires_at', 0); + }); + +Setting the time value to ``0`` in the index definition instructs +MongoDB to expire documents at the clock time specified in the +``expires_at`` field. + +To learn more about using the ``Schema`` builder to create indexes, see +the :ref:`laravel-schema-builder-special-idx` section of the Schema +Builder guide. + +To learn more about TTL indexes, see :manual:`Expire Data from +Collections by Setting TTL ` in the +{+server-docs-name+}. diff --git a/docs/transactions.txt b/docs/transactions.txt index b4a7827ba..d42151d41 100644 --- a/docs/transactions.txt +++ b/docs/transactions.txt @@ -60,20 +60,11 @@ This guide contains the following sections: Requirements and Limitations ---------------------------- -To perform transactions in MongoDB, you must use the following MongoDB -version and topology: - -- MongoDB version 4.0 or later -- A replica set deployment or sharded cluster - MongoDB Server and the {+odm-short+} have the following limitations: -- In MongoDB versions 4.2 and earlier, write operations performed within a - transaction must be on existing collections. In MongoDB versions 4.4 and - later, the server automatically creates collections as necessary when - you perform write operations in a transaction. To learn more about this - limitation, see :manual:`Create Collections and Indexes in a Transaction ` - in the {+server-docs-name+}. +- MongoDB standalone deployments do not support transactions. To use + transactions, your deployment must be a multiple node replica set or + sharded cluster. - MongoDB does not support nested transactions. If you attempt to start a transaction within another one, the extension raises a ``RuntimeException``. diff --git a/docs/upgrade.txt b/docs/upgrade.txt index a87d314a2..3c6ec40a4 100644 --- a/docs/upgrade.txt +++ b/docs/upgrade.txt @@ -127,6 +127,11 @@ This library version introduces the following breaking changes: method results before hydrating a Model instance. When passing a complex query filter, use the ``DB::where()`` method instead of ``Model::raw()``. + Starting in v5.3, you can disable automatic conversion of ``id`` to + ``_id`` for embedded documents. To learn more, see the + :ref:`laravel-disable-id-alias` section of the Connection Options + guide. + - Removes support for the ``$collection`` property. The following code shows how to assign a MongoDB collection to a variable in your ``User`` class in older versions compared to v5.0: diff --git a/docs/user-authentication.txt b/docs/user-authentication.txt index 88b0da603..63e883d13 100644 --- a/docs/user-authentication.txt +++ b/docs/user-authentication.txt @@ -224,7 +224,7 @@ to the ``guards`` array: ], ], -Use Laravel Passport with Laravel MongoDB +Use Laravel Passport with {+odm-long+} ````````````````````````````````````````` After installing Laravel Passport, you must enable Passport compatibility with MongoDB by @@ -300,4 +300,5 @@ Additional Information To learn more about user authentication, see `Authentication `__ in the Laravel documentation. -To learn more about Eloquent models, see the :ref:`laravel-eloquent-model-class` guide. \ No newline at end of file +To learn more about Eloquent models, see the +:ref:`laravel-eloquent-model-class` guide. diff --git a/phpcs.xml.dist b/phpcs.xml.dist index 3b7cc671c..f83429905 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -53,4 +53,8 @@ tests/Ticket/*.php + + + src/Schema/Blueprint.php + diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index e85adb7d2..ba1f3b7aa 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1,15 +1,60 @@ parameters: ignoreErrors: + - + message: "#^Class MongoDB\\\\Laravel\\\\Query\\\\Grammar does not have a constructor and must be instantiated without any parameters\\.$#" + count: 1 + path: src/Connection.php + + - + message: "#^Class MongoDB\\\\Laravel\\\\Schema\\\\Grammar does not have a constructor and must be instantiated without any parameters\\.$#" + count: 1 + path: src/Connection.php + - message: "#^Access to an undefined property Illuminate\\\\Container\\\\Container\\:\\:\\$config\\.$#" count: 3 path: src/MongoDBBusServiceProvider.php + - + message: "#^Access to an undefined property Illuminate\\\\Foundation\\\\Application\\:\\:\\$config\\.$#" + count: 4 + path: src/MongoDBServiceProvider.php + + - + message: "#^Call to an undefined method TDeclaringModel of Illuminate\\\\Database\\\\Eloquent\\\\Model\\:\\:pull\\(\\)\\.$#" + count: 1 + path: src/Relations/BelongsToMany.php + - message: "#^Method Illuminate\\\\Database\\\\Eloquent\\\\Model\\:\\:push\\(\\) invoked with 3 parameters, 0 required\\.$#" count: 3 path: src/Relations/BelongsToMany.php + - + message: "#^Call to an undefined method MongoDB\\\\Laravel\\\\Relations\\\\EmbedsMany\\\\:\\:contains\\(\\)\\.$#" + count: 1 + path: src/Relations/EmbedsMany.php + + - + message: "#^Call to an undefined method TDeclaringModel of Illuminate\\\\Database\\\\Eloquent\\\\Model\\:\\:getParentRelation\\(\\)\\.$#" + count: 1 + path: src/Relations/EmbedsOneOrMany.php + + - + message: "#^Call to an undefined method TDeclaringModel of Illuminate\\\\Database\\\\Eloquent\\\\Model\\:\\:setParentRelation\\(\\)\\.$#" + count: 1 + path: src/Relations/EmbedsOneOrMany.php + + - + message: "#^Call to an undefined method TRelatedModel of Illuminate\\\\Database\\\\Eloquent\\\\Model\\:\\:setParentRelation\\(\\)\\.$#" + count: 2 + path: src/Relations/EmbedsOneOrMany.php + + - + message: "#^Call to an undefined method TDeclaringModel of Illuminate\\\\Database\\\\Eloquent\\\\Model\\:\\:pull\\(\\)\\.$#" + count: 2 + path: src/Relations/MorphToMany.php + - message: "#^Method Illuminate\\\\Database\\\\Eloquent\\\\Model\\:\\:push\\(\\) invoked with 3 parameters, 0 required\\.$#" count: 6 @@ -19,3 +64,8 @@ parameters: message: "#^Method Illuminate\\\\Database\\\\Schema\\\\Blueprint\\:\\:create\\(\\) invoked with 1 parameter, 0 required\\.$#" count: 1 path: src/Schema/Builder.php + + - + message: "#^Call to an undefined method Illuminate\\\\Support\\\\HigherOrderCollectionProxy\\<\\(int\\|string\\), Illuminate\\\\Database\\\\Eloquent\\\\Model\\>\\:\\:pushSoftDeleteMetadata\\(\\)\\.$#" + count: 1 + path: src/Scout/ScoutEngine.php diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 926d9e726..03228b162 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -11,6 +11,9 @@ parameters: editorUrl: 'phpstorm://open?file=%%file%%&line=%%line%%' + universalObjectCratesClasses: + - MongoDB\BSON\Document + ignoreErrors: - '#Unsafe usage of new static#' - '#Call to an undefined method [a-zA-Z0-9\\_\<\>\(\)]+::[a-zA-Z]+\(\)#' diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 5431164d8..d7f066483 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -17,12 +17,14 @@ - + + + collection->deleteMany( @@ -229,6 +230,7 @@ public function pruneUnfinished(DateTimeInterface $before): int } /** Prune all the cancelled entries older than the given date. */ + #[Override] public function pruneCancelled(DateTimeInterface $before): int { $result = $this->collection->deleteMany( diff --git a/src/Cache/MongoLock.php b/src/Cache/MongoLock.php index d273b4d99..50d04c7ce 100644 --- a/src/Cache/MongoLock.php +++ b/src/Cache/MongoLock.php @@ -41,6 +41,7 @@ public function __construct( /** * Attempt to acquire the lock. */ + #[Override] public function acquire(): bool { // The lock can be acquired if: it doesn't exist, it has expired, diff --git a/src/CommandSubscriber.php b/src/CommandSubscriber.php index 569c7c909..1cad23280 100644 --- a/src/CommandSubscriber.php +++ b/src/CommandSubscriber.php @@ -7,6 +7,7 @@ use MongoDB\Driver\Monitoring\CommandStartedEvent; use MongoDB\Driver\Monitoring\CommandSubscriber as CommandSubscriberInterface; use MongoDB\Driver\Monitoring\CommandSucceededEvent; +use Override; use function get_object_vars; use function in_array; @@ -21,16 +22,19 @@ public function __construct(private Connection $connection) { } + #[Override] public function commandStarted(CommandStartedEvent $event): void { $this->commands[$event->getOperationId()] = $event; } + #[Override] public function commandFailed(CommandFailedEvent $event): void { $this->logQuery($event); } + #[Override] public function commandSucceeded(CommandSucceededEvent $event): void { $this->logQuery($event); @@ -48,6 +52,6 @@ private function logQuery(CommandSucceededEvent|CommandFailedEvent $event): void } } - $this->connection->logQuery(Document::fromPHP($command)->toCanonicalExtendedJSON(), [], $event->getDurationMicros()); + $this->connection->logQuery(Document::fromPHP($command)->toCanonicalExtendedJSON(), [], $event->getDurationMicros() / 1000); } } diff --git a/src/Concerns/ManagesTransactions.php b/src/Concerns/ManagesTransactions.php index ac3c1c6f7..6403cc45d 100644 --- a/src/Concerns/ManagesTransactions.php +++ b/src/Concerns/ManagesTransactions.php @@ -12,15 +12,18 @@ use function MongoDB\with_transaction; -/** @see https://docs.mongodb.com/manual/core/transactions/ */ +/** + * @internal + * + * @see https://docs.mongodb.com/manual/core/transactions/ + */ trait ManagesTransactions { protected ?Session $session = null; protected $transactions = 0; - /** @return Client */ - abstract public function getMongoClient(); + abstract public function getClient(): ?Client; public function getSession(): ?Session { @@ -30,7 +33,7 @@ public function getSession(): ?Session private function getSessionOrCreate(): Session { if ($this->session === null) { - $this->session = $this->getMongoClient()->startSession(); + $this->session = $this->getClient()->startSession(); } return $this->session; diff --git a/src/Connection.php b/src/Connection.php index 592e500e5..780cad321 100644 --- a/src/Connection.php +++ b/src/Connection.php @@ -16,14 +16,18 @@ use MongoDB\Driver\ReadPreference; use MongoDB\Laravel\Concerns\ManagesTransactions; use OutOfBoundsException; +use Override; use Throwable; use function filter_var; use function implode; use function is_array; use function preg_match; +use function sprintf; use function str_contains; +use function trigger_error; +use const E_USER_DEPRECATED; use const FILTER_FLAG_IPV6; use const FILTER_VALIDATE_IP; @@ -50,6 +54,9 @@ class Connection extends BaseConnection private ?CommandSubscriber $commandSubscriber = null; + /** @var bool Whether to rename the rename "id" into "_id" for embedded documents. */ + private bool $renameEmbeddedIdField; + /** * Create a new database connection instance. */ @@ -65,9 +72,10 @@ public function __construct(array $config) // Create the connection $this->connection = $this->createConnection($dsn, $config, $options); + $this->database = $this->getDefaultDatabaseName($dsn, $config); // Select database - $this->db = $this->connection->selectDatabase($this->getDefaultDatabaseName($dsn, $config)); + $this->db = $this->connection->getDatabase($this->database); $this->tablePrefix = $config['prefix'] ?? ''; @@ -76,6 +84,8 @@ public function __construct(array $config) $this->useDefaultSchemaGrammar(); $this->useDefaultQueryGrammar(); + + $this->renameEmbeddedIdField = $config['rename_embedded_id_field'] ?? true; } /** @@ -86,6 +96,7 @@ public function __construct(array $config) * * @return Query\Builder */ + #[Override] public function table($table, $as = null) { $query = new Query\Builder($this, $this->getQueryGrammar(), $this->getPostProcessor()); @@ -106,6 +117,7 @@ public function getCollection($name): Collection } /** @inheritdoc */ + #[Override] public function getSchemaBuilder() { return new Schema\Builder($this); @@ -114,31 +126,57 @@ public function getSchemaBuilder() /** * Get the MongoDB database object. * + * @deprecated since mongodb/laravel-mongodb:5.2, use getDatabase() instead + * * @return Database */ public function getMongoDB() { + trigger_error(sprintf('Since mongodb/laravel-mongodb:5.2, Method "%s()" is deprecated, use "getDatabase()" instead.', __FUNCTION__), E_USER_DEPRECATED); + return $this->db; } /** - * return MongoDB object. + * Get the MongoDB database object. + * + * @param string|null $name Name of the database, if not provided the default database will be returned. + * + * @return Database + */ + public function getDatabase(?string $name = null): Database + { + if ($name && $name !== $this->database) { + return $this->connection->getDatabase($name); + } + + return $this->db; + } + + /** + * Return MongoDB object. + * + * @deprecated since mongodb/laravel-mongodb:5.2, use getClient() instead * * @return Client */ public function getMongoClient() { - return $this->connection; + trigger_error(sprintf('Since mongodb/laravel-mongodb:5.2, method "%s()" is deprecated, use "getClient()" instead.', __FUNCTION__), E_USER_DEPRECATED); + + return $this->getClient(); } /** - * {@inheritDoc} + * Get the MongoDB client. */ - public function getDatabaseName() + public function getClient(): ?Client { - return $this->getMongoDB()->getDatabaseName(); + return $this->connection; } + /** @inheritdoc */ + #[Override] public function enableQueryLog() { parent::enableQueryLog(); @@ -149,6 +187,7 @@ public function enableQueryLog() } } + #[Override] public function disableQueryLog() { parent::disableQueryLog(); @@ -159,6 +198,7 @@ public function disableQueryLog() } } + #[Override] protected function withFreshQueryLog($callback) { try { @@ -181,7 +221,7 @@ protected function withFreshQueryLog($callback) protected function getDefaultDatabaseName(string $dsn, array $config): string { if (empty($config['database'])) { - if (! preg_match('/^mongodb(?:[+]srv)?:\\/\\/.+\\/([^?&]+)/s', $dsn, $matches)) { + if (! preg_match('/^mongodb(?:[+]srv)?:\\/\\/.+?\\/([^?&]+)/s', $dsn, $matches)) { throw new InvalidArgumentException('Database is not properly configured.'); } @@ -233,7 +273,7 @@ protected function createConnection(string $dsn, array $config, array $options): */ public function ping(): void { - $this->getMongoClient()->getManager()->selectServer(new ReadPreference(ReadPreference::PRIMARY_PREFERRED)); + $this->getClient()->getManager()->selectServer(new ReadPreference(ReadPreference::PRIMARY_PREFERRED)); } /** @inheritdoc */ @@ -307,6 +347,7 @@ protected function getDsn(array $config): string } /** @inheritdoc */ + #[Override] public function getDriverName() { return 'mongodb'; @@ -319,21 +360,26 @@ public function getDriverTitle() } /** @inheritdoc */ + #[Override] protected function getDefaultPostProcessor() { return new Query\Processor(); } /** @inheritdoc */ + #[Override] protected function getDefaultQueryGrammar() { - return new Query\Grammar(); + // Argument added in Laravel 12 + return new Query\Grammar($this); } /** @inheritdoc */ + #[Override] protected function getDefaultSchemaGrammar() { - return new Schema\Grammar(); + // Argument added in Laravel 12 + return new Schema\Grammar($this); } /** @@ -365,6 +411,18 @@ public function __call($method, $parameters) return $this->db->$method(...$parameters); } + /** Set whether to rename "id" field into "_id" for embedded documents. */ + public function setRenameEmbeddedIdField(bool $rename): void + { + $this->renameEmbeddedIdField = $rename; + } + + /** Get whether to rename "id" field into "_id" for embedded documents. */ + public function getRenameEmbeddedIdField(): bool + { + return $this->renameEmbeddedIdField; + } + /** * Return the server version of one of the MongoDB servers: primary for * replica sets and standalone, and the selected server for sharded clusters. diff --git a/src/Eloquent/Builder.php b/src/Eloquent/Builder.php index 4fd4880df..5d4018f9d 100644 --- a/src/Eloquent/Builder.php +++ b/src/Eloquent/Builder.php @@ -4,24 +4,35 @@ namespace MongoDB\Laravel\Eloquent; +use Closure; use Illuminate\Database\Eloquent\Builder as EloquentBuilder; +use Illuminate\Database\Eloquent\Collection; +use Illuminate\Database\Eloquent\Model; use MongoDB\BSON\Document; +use MongoDB\Builder\Expression; +use MongoDB\Builder\Type\QueryInterface; +use MongoDB\Builder\Type\SearchOperatorInterface; use MongoDB\Driver\CursorInterface; -use MongoDB\Driver\Exception\WriteException; +use MongoDB\Driver\Exception\BulkWriteException; use MongoDB\Laravel\Connection; use MongoDB\Laravel\Helpers\QueriesRelationships; use MongoDB\Laravel\Query\AggregationBuilder; use MongoDB\Model\BSONDocument; +use Override; use function array_key_exists; -use function array_merge; +use function array_map; +use function array_replace; use function collect; use function is_array; use function is_object; use function iterator_to_array; use function property_exists; -/** @method \MongoDB\Laravel\Query\Builder toBase() */ +/** + * @method \MongoDB\Laravel\Query\Builder toBase() + * @template TModel of Model + */ class Builder extends EloquentBuilder { private const DUPLICATE_KEY_ERROR = 11000; @@ -49,6 +60,7 @@ class Builder extends EloquentBuilder 'insertusing', 'max', 'min', + 'autocomplete', 'pluck', 'pull', 'push', @@ -58,7 +70,7 @@ class Builder extends EloquentBuilder ]; /** - * @return ($function is null ? AggregationBuilder : self) + * @return ($function is null ? AggregationBuilder : $this) * * @inheritdoc */ @@ -69,7 +81,59 @@ public function aggregate($function = null, $columns = ['*']) return $result ?: $this; } - /** @inheritdoc */ + /** + * Performs a full-text search of the field or fields in an Atlas collection. + * + * @see https://www.mongodb.com/docs/atlas/atlas-search/aggregation-stages/search/ + * + * @return Collection + */ + public function search( + SearchOperatorInterface|array $operator, + ?string $index = null, + ?array $highlight = null, + ?bool $concurrent = null, + ?string $count = null, + ?string $searchAfter = null, + ?string $searchBefore = null, + ?bool $scoreDetails = null, + ?array $sort = null, + ?bool $returnStoredSource = null, + ?array $tracking = null, + ): Collection { + $results = $this->toBase()->search($operator, $index, $highlight, $concurrent, $count, $searchAfter, $searchBefore, $scoreDetails, $sort, $returnStoredSource, $tracking); + + return $this->model->hydrate($results->all()); + } + + /** + * Performs a semantic search on data in your Atlas Vector Search index. + * NOTE: $vectorSearch is only available for MongoDB Atlas clusters, and is not available for self-managed deployments. + * + * @see https://www.mongodb.com/docs/atlas/atlas-vector-search/vector-search-stage/ + * + * @return Collection + */ + public function vectorSearch( + string $index, + string $path, + array $queryVector, + int $limit, + bool $exact = false, + QueryInterface|array $filter = [], + int|null $numCandidates = null, + ): Collection { + $results = $this->toBase()->vectorSearch($index, $path, $queryVector, $limit, $exact, $filter, $numCandidates); + + return $this->model->hydrate($results->all()); + } + + /** + * @param array $options + * + * @inheritdoc + */ + #[Override] public function update(array $values, array $options = []) { // Intercept operations on embedded models and delegate logic @@ -173,7 +237,13 @@ public function decrement($column, $amount = 1, array $extra = []) return parent::decrement($column, $amount, $extra); } - /** @inheritdoc */ + /** + * @param (Closure():T)|Expression|null $value + * + * @return ($value is Closure ? T : ($value is null ? Collection : Expression)) + * + * @template T + */ public function raw($value = null) { // Get raw results from the query builder. @@ -182,7 +252,7 @@ public function raw($value = null) // Convert MongoCursor results to a collection of models. if ($results instanceof CursorInterface) { $results->setTypeMap(['root' => 'array', 'document' => 'array', 'array' => 'array']); - $results = $this->query->aliasIdForResult(iterator_to_array($results)); + $results = array_map(fn ($document) => $this->query->aliasIdForResult($document), iterator_to_array($results)); return $this->model->hydrate($results); } @@ -206,6 +276,7 @@ public function raw($value = null) return $results; } + #[Override] public function firstOrCreate(array $attributes = [], array $values = []) { $instance = (clone $this)->where($attributes)->first(); @@ -215,12 +286,13 @@ public function firstOrCreate(array $attributes = [], array $values = []) // createOrFirst is not supported in transaction. if ($this->getConnection()->getSession()?->isInTransaction()) { - return $this->create(array_merge($attributes, $values)); + return $this->create(array_replace($attributes, $values)); } return $this->createOrFirst($attributes, $values); } + #[Override] public function createOrFirst(array $attributes = [], array $values = []) { // The duplicate key error would abort the transaction. Using the regular firstOrCreate in that case. @@ -229,8 +301,8 @@ public function createOrFirst(array $attributes = [], array $values = []) } try { - return $this->create(array_merge($attributes, $values)); - } catch (WriteException $e) { + return $this->create(array_replace($attributes, $values)); + } catch (BulkWriteException $e) { if ($e->getCode() === self::DUPLICATE_KEY_ERROR) { return $this->where($attributes)->first() ?? throw $e; } @@ -244,9 +316,8 @@ public function createOrFirst(array $attributes = [], array $values = []) * TODO Remove if https://github.com/laravel/framework/commit/6484744326531829341e1ff886cc9b628b20d73e * will be reverted * Issue in laravel/frawework https://github.com/laravel/framework/issues/27791. - * - * @return array */ + #[Override] protected function addUpdatedAtColumn(array $values) { if (! $this->model->usesTimestamps() || $this->model->getUpdatedAtColumn() === null) { @@ -254,7 +325,7 @@ protected function addUpdatedAtColumn(array $values) } $column = $this->model->getUpdatedAtColumn(); - $values = array_merge( + $values = array_replace( [$column => $this->model->freshTimestampString()], $values, ); @@ -268,6 +339,7 @@ public function getConnection(): Connection } /** @inheritdoc */ + #[Override] protected function ensureOrderForCursorPagination($shouldReverse = false) { if (empty($this->query->orders)) { diff --git a/src/Eloquent/DocumentModel.php b/src/Eloquent/DocumentModel.php index 930ed6286..f8d399e62 100644 --- a/src/Eloquent/DocumentModel.php +++ b/src/Eloquent/DocumentModel.php @@ -5,7 +5,6 @@ namespace MongoDB\Laravel\Eloquent; use BackedEnum; -use Carbon\Carbon; use Carbon\CarbonInterface; use DateTimeInterface; use DateTimeZone; @@ -30,7 +29,7 @@ use function array_key_exists; use function array_keys; -use function array_merge; +use function array_replace; use function array_unique; use function array_values; use function class_basename; @@ -51,6 +50,7 @@ use function strlen; use function var_export; +/** @mixin Builder */ trait DocumentModel { use HybridRelations; @@ -128,7 +128,7 @@ public function fromDateTime($value): UTCDateTime * * @param mixed $value */ - protected function asDateTime($value): Carbon + protected function asDateTime($value): DateTimeInterface { // Convert UTCDateTime instances to Carbon. if ($value instanceof UTCDateTime) { @@ -192,7 +192,7 @@ protected function transformModelValue($key, $value) // to a Carbon or CarbonImmutable instance. // @see Model::setAttribute() if ($this->hasCast($key) && $value instanceof CarbonInterface) { - $value->settings(array_merge($value->getSettings(), ['toStringFormat' => $this->getDateFormat()])); + $value->settings(array_replace($value->getSettings(), ['toStringFormat' => $this->getDateFormat()])); // "date" cast resets the time to 00:00:00. $castType = $this->getCasts()[$key]; diff --git a/src/Eloquent/MassPrunable.php b/src/Eloquent/MassPrunable.php index 98e947842..ecf033a3b 100644 --- a/src/Eloquent/MassPrunable.php +++ b/src/Eloquent/MassPrunable.php @@ -5,6 +5,7 @@ namespace MongoDB\Laravel\Eloquent; use Illuminate\Database\Eloquent\MassPrunable as EloquentMassPrunable; +use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Events\ModelsPruned; use function class_uses_recursive; diff --git a/src/Eloquent/SoftDeletes.php b/src/Eloquent/SoftDeletes.php index 135c55dcf..438219f3c 100644 --- a/src/Eloquent/SoftDeletes.php +++ b/src/Eloquent/SoftDeletes.php @@ -4,6 +4,14 @@ namespace MongoDB\Laravel\Eloquent; +use function sprintf; +use function trigger_error; + +use const E_USER_DEPRECATED; + +trigger_error(sprintf('Since mongodb/laravel-mongodb:5.5, trait "%s" is deprecated, use "%s" instead.', SoftDeletes::class, \Illuminate\Database\Eloquent\SoftDeletes::class), E_USER_DEPRECATED); + +/** @deprecated since mongodb/laravel-mongodb:5.5, use \Illuminate\Database\Eloquent\SoftDeletes instead */ trait SoftDeletes { use \Illuminate\Database\Eloquent\SoftDeletes; diff --git a/src/Helpers/QueriesRelationships.php b/src/Helpers/QueriesRelationships.php index 1f1ffa34b..29d708e3c 100644 --- a/src/Helpers/QueriesRelationships.php +++ b/src/Helpers/QueriesRelationships.php @@ -12,6 +12,7 @@ use Illuminate\Database\Eloquent\Relations\HasOneOrMany; use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Support\Collection; +use LogicException; use MongoDB\Laravel\Eloquent\Model; use MongoDB\Laravel\Relations\MorphToMany; @@ -104,6 +105,8 @@ protected function isAcrossConnections(Relation $relation) */ public function addHybridHas(Relation $relation, $operator = '>=', $count = 1, $boolean = 'and', ?Closure $callback = null) { + $this->assertHybridRelationSupported($relation); + $hasQuery = $relation->getQuery(); if ($callback) { $hasQuery->callScope($callback); @@ -128,6 +131,26 @@ public function addHybridHas(Relation $relation, $operator = '>=', $count = 1, $ return $this->whereIn($this->getRelatedConstraintKey($relation), $relatedIds, $boolean, $not); } + /** + * @param Relation $relation + * + * @return void + * + * @throws Exception + */ + private function assertHybridRelationSupported(Relation $relation): void + { + if ( + $relation instanceof HasOneOrMany + || $relation instanceof BelongsTo + || ($relation instanceof BelongsToMany && ! $this->isAcrossConnections($relation)) + ) { + return; + } + + throw new LogicException(class_basename($relation) . ' is not supported for hybrid query constraints.'); + } + /** * @param Builder $hasQuery * @param Relation $relation @@ -213,6 +236,8 @@ protected function getConstrainedRelatedIds($relations, $operator, $count) */ protected function getRelatedConstraintKey(Relation $relation) { + $this->assertHybridRelationSupported($relation); + if ($relation instanceof HasOneOrMany) { return $relation->getLocalKeyName(); } @@ -221,7 +246,7 @@ protected function getRelatedConstraintKey(Relation $relation) return $relation->getForeignKeyName(); } - if ($relation instanceof BelongsToMany && ! $this->isAcrossConnections($relation)) { + if ($relation instanceof BelongsToMany) { return $this->model->getKeyName(); } diff --git a/src/MongoDBBusServiceProvider.php b/src/MongoDBBusServiceProvider.php index d3d6f25fc..ab0afb588 100644 --- a/src/MongoDBBusServiceProvider.php +++ b/src/MongoDBBusServiceProvider.php @@ -10,6 +10,7 @@ use Illuminate\Support\ServiceProvider; use InvalidArgumentException; use MongoDB\Laravel\Bus\MongoBatchRepository; +use Override; use function sprintf; @@ -18,6 +19,7 @@ class MongoDBBusServiceProvider extends ServiceProvider implements DeferrablePro /** * Register the service provider. */ + #[Override] public function register() { $this->app->singleton(MongoBatchRepository::class, function (Container $app) { @@ -46,6 +48,8 @@ public function register() }); } + /** @inheritdoc */ + #[Override] public function provides() { return [ diff --git a/src/MongoDBServiceProvider.php b/src/MongoDBServiceProvider.php index 0932048c9..644eb7a56 100644 --- a/src/MongoDBServiceProvider.php +++ b/src/MongoDBServiceProvider.php @@ -7,11 +7,14 @@ use Closure; use Illuminate\Cache\CacheManager; use Illuminate\Cache\Repository; +use Illuminate\Container\Container; use Illuminate\Filesystem\FilesystemAdapter; use Illuminate\Filesystem\FilesystemManager; use Illuminate\Foundation\Application; +use Illuminate\Session\SessionManager; use Illuminate\Support\ServiceProvider; use InvalidArgumentException; +use Laravel\Scout\EngineManager; use League\Flysystem\Filesystem; use League\Flysystem\GridFS\GridFSAdapter; use League\Flysystem\ReadOnly\ReadOnlyFilesystemAdapter; @@ -19,6 +22,9 @@ use MongoDB\Laravel\Cache\MongoStore; use MongoDB\Laravel\Eloquent\Model; use MongoDB\Laravel\Queue\MongoConnector; +use MongoDB\Laravel\Scout\ScoutEngine; +use MongoDB\Laravel\Session\MongoDbSessionHandler; +use Override; use RuntimeException; use function assert; @@ -42,6 +48,7 @@ public function boot() /** * Register the service provider. */ + #[Override] public function register() { // Add database driver. @@ -53,6 +60,23 @@ public function register() }); }); + // Session handler for MongoDB + $this->app->resolving(SessionManager::class, function (SessionManager $sessionManager) { + $sessionManager->extend('mongodb', function (Application $app) { + $connectionName = $app->config->get('session.connection') ?: 'mongodb'; + $connection = $app->make('db')->connection($connectionName); + + assert($connection instanceof Connection, new InvalidArgumentException(sprintf('The database connection "%s" used for the session does not use the "mongodb" driver.', $connectionName))); + + return new MongoDbSessionHandler( + $connection, + $app->config->get('session.table', 'sessions'), + $app->config->get('session.lifetime'), + $app, + ); + }); + }); + // Add cache and lock drivers. $this->app->resolving('cache', function (CacheManager $cache) { $cache->extend('mongodb', function (Application $app, array $config): Repository { @@ -81,6 +105,7 @@ public function register() }); $this->registerFlysystemAdapter(); + $this->registerScoutEngine(); } private function registerFlysystemAdapter(): void @@ -107,8 +132,8 @@ private function registerFlysystemAdapter(): void throw new InvalidArgumentException(sprintf('The database connection "%s" does not use the "mongodb" driver.', $config['connection'] ?? $app['config']['database.default'])); } - $bucket = $connection->getMongoClient() - ->selectDatabase($config['database'] ?? $connection->getDatabaseName()) + $bucket = $connection->getClient() + ->getDatabase($config['database'] ?? $connection->getDatabaseName()) ->selectGridFSBucket(['bucketName' => $config['bucket'] ?? 'fs', 'disableMD5' => true]); } @@ -134,4 +159,22 @@ private function registerFlysystemAdapter(): void }); }); } + + private function registerScoutEngine(): void + { + $this->app->resolving(EngineManager::class, function (EngineManager $engineManager) { + $engineManager->extend('mongodb', function (Container $app) { + $connectionName = $app->get('config')->get('scout.mongodb.connection', 'mongodb'); + $connection = $app->get('db')->connection($connectionName); + $softDelete = (bool) $app->get('config')->get('scout.soft_delete', false); + $indexDefinitions = $app->get('config')->get('scout.mongodb.index-definitions', []); + + assert($connection instanceof Connection, new InvalidArgumentException(sprintf('The connection "%s" is not a MongoDB connection.', $connectionName))); + + return new ScoutEngine($connection->getDatabase(), $softDelete, $indexDefinitions); + }); + + return $engineManager; + }); + } } diff --git a/src/Query/Builder.php b/src/Query/Builder.php index c62709ce5..6fb38fba1 100644 --- a/src/Query/Builder.php +++ b/src/Query/Builder.php @@ -23,17 +23,25 @@ use MongoDB\BSON\ObjectID; use MongoDB\BSON\Regex; use MongoDB\BSON\UTCDateTime; +use MongoDB\Builder\Search; use MongoDB\Builder\Stage\FluentFactoryTrait; +use MongoDB\Builder\Type\QueryInterface; +use MongoDB\Builder\Type\SearchOperatorInterface; use MongoDB\Driver\Cursor; +use MongoDB\Driver\ReadPreference; +use MongoDB\Laravel\Connection; use Override; use RuntimeException; use stdClass; +use TypeError; use function array_fill_keys; +use function array_filter; use function array_is_list; use function array_key_exists; use function array_map; use function array_merge; +use function array_replace; use function array_values; use function assert; use function blank; @@ -76,6 +84,7 @@ use function trait_exists; use function var_export; +/** @property Connection $connection */ class Builder extends BaseBuilder { private const REGEX_DELIMITERS = ['/', '#', '~']; @@ -97,7 +106,7 @@ class Builder extends BaseBuilder /** * The maximum amount of seconds to allow the query to run. * - * @var int + * @var int|float */ public $timeout; @@ -108,6 +117,8 @@ class Builder extends BaseBuilder */ public $hint; + private ReadPreference $readPreference; + /** * Custom options to add to the query. * @@ -206,7 +217,7 @@ public function project($columns) /** * The maximum amount of seconds to allow the query to run. * - * @param int $seconds + * @param int|float $seconds * * @return $this */ @@ -232,12 +243,14 @@ public function hint($index) } /** @inheritdoc */ + #[Override] public function find($id, $columns = []) { return $this->where('_id', '=', $this->convertKey($id))->first($columns); } /** @inheritdoc */ + #[Override] public function value($column) { $result = (array) $this->first([$column]); @@ -246,12 +259,14 @@ public function value($column) } /** @inheritdoc */ + #[Override] public function get($columns = []) { return $this->getFresh($columns); } /** @inheritdoc */ + #[Override] public function cursor($columns = []) { $result = $this->getFresh($columns, true); @@ -311,6 +326,7 @@ public function toMql(): array if ($this->groups || $this->aggregate) { $group = []; $unwinds = []; + $set = []; // Add grouping columns to the $group part of the aggregation pipeline. if ($this->groups) { @@ -321,8 +337,10 @@ public function toMql(): array // this mimics SQL's behaviour a bit. $group[$column] = ['$last' => '$' . $column]; } + } - // Do the same for other columns that are selected. + // Add the last value of each column when there is no aggregate function. + if ($this->groups && ! $this->aggregate) { foreach ($columns as $column) { $key = str_replace('.', '_', $column); @@ -346,15 +364,22 @@ public function toMql(): array $aggregations = blank($this->aggregate['columns']) ? [] : $this->aggregate['columns']; - if (in_array('*', $aggregations) && $function === 'count') { + if ($column === '*' && $function === 'count' && ! $this->groups) { $options = $this->inheritConnectionOptions($this->options); return ['countDocuments' => [$wheres, $options]]; } + // "aggregate" is the name of the field that will hold the aggregated value. if ($function === 'count') { - // Translate count into sum. - $group['aggregate'] = ['$sum' => 1]; + if ($column === '*' || $aggregations === []) { + // Translate count into sum. + $group['aggregate'] = ['$sum' => 1]; + } else { + // Count the number of distinct values. + $group['aggregate'] = ['$addToSet' => '$' . $column]; + $set['aggregate'] = ['$size' => '$aggregate']; + } } else { $group['aggregate'] = ['$' . $function => '$' . $column]; } @@ -381,6 +406,10 @@ public function toMql(): array $pipeline[] = ['$group' => $group]; } + if ($set) { + $pipeline[] = ['$set' => $set]; + } + // Apply order and limit if ($this->orders) { $pipeline[] = ['$sort' => $this->aliasIdForQuery($this->orders)]; @@ -404,7 +433,7 @@ public function toMql(): array // Add custom query options if (count($this->options)) { - $options = array_merge($options, $this->options); + $options = array_replace($options, $this->options); } $options = $this->inheritConnectionOptions($options); @@ -428,14 +457,14 @@ public function toMql(): array // Add custom projections. if ($this->projections) { - $projection = array_merge($projection, $this->projections); + $projection = array_replace($projection, $this->projections); } $options = []; // Apply order, offset, limit and projection if ($this->timeout) { - $options['maxTimeMS'] = $this->timeout * 1000; + $options['maxTimeMS'] = (int) ($this->timeout * 1000); } if ($this->orders) { @@ -462,7 +491,7 @@ public function toMql(): array // Add custom query options if (count($this->options)) { - $options = array_merge($options, $this->options); + $options = array_replace($options, $this->options); } $options = $this->inheritConnectionOptions($options); @@ -554,8 +583,11 @@ public function generateCacheKey() } /** @return ($function is null ? AggregationBuilder : mixed) */ + #[Override] public function aggregate($function = null, $columns = ['*']) { + assert(is_array($columns), new TypeError(sprintf('Argument #2 ($columns) must be of type array, %s given', get_debug_type($columns)))); + if ($function === null) { if (! trait_exists(FluentFactoryTrait::class)) { // This error will be unreachable when the mongodb/builder package will be merged into mongodb/mongodb @@ -596,6 +628,15 @@ public function aggregate($function = null, $columns = ['*']) $this->columns = $previousColumns; $this->bindings['select'] = $previousSelectBindings; + // When the aggregation is per group, we return the results as is. + if ($this->groups) { + return $results->map(function (object $result) { + unset($result->id); + + return $result; + }); + } + if (isset($results[0])) { $result = (array) $results[0]; @@ -603,7 +644,23 @@ public function aggregate($function = null, $columns = ['*']) } } + /** + * @param string $function + * @param array $columns + * + * @return mixed + */ + public function aggregateByGroup(string $function, array $columns = ['*']) + { + if (count($columns) > 1) { + throw new InvalidArgumentException('Aggregating by group requires zero or one columns.'); + } + + return $this->aggregate($function, $columns); + } + /** @inheritdoc */ + #[Override] public function exists() { return $this->first(['id']) !== null; @@ -626,6 +683,7 @@ public function distinct($column = false) * * @inheritdoc */ + #[Override] public function orderBy($column, $direction = 'asc') { if (is_string($direction)) { @@ -647,6 +705,7 @@ public function orderBy($column, $direction = 'asc') } /** @inheritdoc */ + #[Override] public function whereBetween($column, iterable $values, $boolean = 'and', $not = false) { $type = 'between'; @@ -671,6 +730,7 @@ public function whereBetween($column, iterable $values, $boolean = 'and', $not = } /** @inheritdoc */ + #[Override] public function insert(array $values) { // Allow empty insert batch for consistency with Eloquent SQL @@ -705,6 +765,7 @@ public function insert(array $values) } /** @inheritdoc */ + #[Override] public function insertGetId(array $values, $sequence = null) { $options = $this->inheritConnectionOptions(); @@ -724,6 +785,7 @@ public function insertGetId(array $values, $sequence = null) } /** @inheritdoc */ + #[Override] public function update(array $values, array $options = []) { // Use $set as default operator for field names that are not in an operator @@ -736,17 +798,11 @@ public function update(array $values, array $options = []) unset($values[$key]); } - // Since "id" is an alias for "_id", we prevent updating it - foreach ($values as $fields) { - if (array_key_exists('id', $fields)) { - throw new InvalidArgumentException('Cannot update "id" field.'); - } - } - return $this->performUpdate($values, $options); } /** @inheritdoc */ + #[Override] public function upsert(array $values, $uniqueBy, $update = null): int { if ($values === []) { @@ -793,6 +849,7 @@ public function upsert(array $values, $uniqueBy, $update = null): int } /** @inheritdoc */ + #[Override] public function increment($column, $amount = 1, array $extra = [], array $options = []) { $query = ['$inc' => [(string) $column => $amount]]; @@ -813,6 +870,12 @@ public function increment($column, $amount = 1, array $extra = [], array $option return $this->performUpdate($query, $options); } + /** + * @param array $options + * + * @inheritdoc + */ + #[Override] public function incrementEach(array $columns, array $extra = [], array $options = []) { $stage['$addFields'] = $extra; @@ -830,12 +893,14 @@ public function incrementEach(array $columns, array $extra = [], array $options } /** @inheritdoc */ + #[Override] public function decrement($column, $amount = 1, array $extra = [], array $options = []) { return $this->increment($column, -1 * $amount, $extra, $options); } /** @inheritdoc */ + #[Override] public function decrementEach(array $columns, array $extra = [], array $options = []) { $decrement = []; @@ -847,7 +912,49 @@ public function decrementEach(array $columns, array $extra = [], array $options return $this->incrementEach($decrement, $extra, $options); } + /** + * Multiply a column's value by a given amount. + * + * @param string $column + * @param float|int $amount + * + * @return int + */ + public function multiply($column, $amount, array $extra = [], array $options = []) + { + $query = ['$mul' => [(string) $column => $amount]]; + + if (! empty($extra)) { + $query['$set'] = $extra; + } + + // Protect + $this->where(function ($query) use ($column) { + $query->where($column, 'exists', true); + + $query->whereNotNull($column); + }); + + $options = $this->inheritConnectionOptions($options); + + return $this->performUpdate($query, $options); + } + + /** + * Divide a column's value by a given amount. + * + * @param string $column + * @param float|int $amount + * + * @return int + */ + public function divide($column, $amount, array $extra = [], array $options = []) + { + return $this->multiply($column, 1 / $amount, $extra, $options); + } + /** @inheritdoc */ + #[Override] public function pluck($column, $key = null) { $results = $this->get($key === null ? [$column] : [$column, $key]); @@ -858,6 +965,7 @@ public function pluck($column, $key = null) } /** @inheritdoc */ + #[Override] public function delete($id = null) { // If an ID is passed to the method, we will set the where clause to check @@ -889,6 +997,7 @@ public function delete($id = null) } /** @inheritdoc */ + #[Override] public function from($collection, $as = null) { if ($collection) { @@ -921,7 +1030,14 @@ public function lists($column, $key = null) return $this->pluck($column, $key); } - /** @inheritdoc */ + /** + * @param (Closure():T)|Expression|null $value + * + * @return ($value is Closure ? T : ($value is null ? Collection : Expression)) + * + * @template T + */ + #[Override] public function raw($value = null) { // Execute the closure on the mongodb collection @@ -1024,11 +1140,13 @@ public function drop($columns) * * @inheritdoc */ + #[Override] public function newQuery() { return new static($this->connection, $this->grammar, $this->processor); } + #[Override] public function runPaginationCountQuery($columns = ['*']) { if ($this->distinct) { @@ -1111,6 +1229,7 @@ public function convertKey($id) * * @return $this */ + #[Override] public function where($column, $operator = null, $value = null, $boolean = 'and') { $params = func_get_args(); @@ -1490,6 +1609,120 @@ public function options(array $options) return $this; } + /** + * Set the read preference for the query + * + * @see https://www.php.net/manual/en/class.mongodb-driver-readpreference.php + * + * @param string $mode + * @param array $tagSets + * @param array $options + * + * @return $this + */ + public function readPreference(string $mode, ?array $tagSets = null, ?array $options = null): static + { + $this->readPreference = new ReadPreference($mode, $tagSets, $options); + + return $this; + } + + /** + * Performs a full-text search of the field or fields in an Atlas collection. + * NOTE: $search is only available for MongoDB Atlas clusters, and is not available for self-managed deployments. + * + * @see https://www.mongodb.com/docs/atlas/atlas-search/aggregation-stages/search/ + * + * @return Collection + */ + public function search( + SearchOperatorInterface|array $operator, + ?string $index = null, + ?array $highlight = null, + ?bool $concurrent = null, + ?string $count = null, + ?string $searchAfter = null, + ?string $searchBefore = null, + ?bool $scoreDetails = null, + ?array $sort = null, + ?bool $returnStoredSource = null, + ?array $tracking = null, + ): Collection { + // Forward named arguments to the search stage, skip null values + $args = array_filter([ + 'operator' => $operator, + 'index' => $index, + 'highlight' => $highlight, + 'concurrent' => $concurrent, + 'count' => $count, + 'searchAfter' => $searchAfter, + 'searchBefore' => $searchBefore, + 'scoreDetails' => $scoreDetails, + 'sort' => $sort, + 'returnStoredSource' => $returnStoredSource, + 'tracking' => $tracking, + ], fn ($arg) => $arg !== null); + + return $this->aggregate()->search(...$args)->get(); + } + + /** + * Performs a semantic search on data in your Atlas Vector Search index. + * NOTE: $vectorSearch is only available for MongoDB Atlas clusters, and is not available for self-managed deployments. + * + * @see https://www.mongodb.com/docs/atlas/atlas-vector-search/vector-search-stage/ + * + * @return Collection + */ + public function vectorSearch( + string $index, + string $path, + array $queryVector, + int $limit, + bool $exact = false, + QueryInterface|array|null $filter = null, + int|null $numCandidates = null, + ): Collection { + // Forward named arguments to the vectorSearch stage, skip null values + $args = array_filter([ + 'index' => $index, + 'limit' => $limit, + 'path' => $path, + 'queryVector' => $queryVector, + 'exact' => $exact, + 'filter' => $filter, + 'numCandidates' => $numCandidates, + ], fn ($arg) => $arg !== null); + + return $this->aggregate() + ->vectorSearch(...$args) + ->addFields(vectorSearchScore: ['$meta' => 'vectorSearchScore']) + ->get(); + } + + /** + * Performs an autocomplete search of the field using an Atlas Search index. + * NOTE: $search is only available for MongoDB Atlas clusters, and is not available for self-managed deployments. + * You must create an Atlas Search index with an autocomplete configuration before you can use this stage. + * + * @see https://www.mongodb.com/docs/atlas/atlas-search/autocomplete/ + * + * @return Collection + */ + public function autocomplete(string $path, string $query, bool|array $fuzzy = false, string $tokenOrder = 'any'): Collection + { + $args = ['path' => $path, 'query' => $query, 'tokenOrder' => $tokenOrder]; + if ($fuzzy === true) { + $args['fuzzy'] = ['maxEdits' => 2]; + } elseif ($fuzzy !== false) { + $args['fuzzy'] = $fuzzy; + } + + return $this->aggregate()->search( + Search::autocomplete(...$args), + )->get()->pluck($path); + } + /** * Apply the connection's session to options if it's not already specified. */ @@ -1502,10 +1735,15 @@ private function inheritConnectionOptions(array $options = []): array } } + if (! isset($options['readPreference']) && isset($this->readPreference)) { + $options['readPreference'] = $this->readPreference; + } + return $options; } /** @inheritdoc */ + #[Override] public function __call($method, $parameters) { if ($method === 'unset') { @@ -1516,98 +1754,113 @@ public function __call($method, $parameters) } /** @internal This method is not supported by MongoDB. */ + #[Override] public function toSql() { throw new BadMethodCallException('This method is not supported by MongoDB. Try "toMql()" instead.'); } /** @internal This method is not supported by MongoDB. */ + #[Override] public function toRawSql() { throw new BadMethodCallException('This method is not supported by MongoDB. Try "toMql()" instead.'); } /** @internal This method is not supported by MongoDB. */ + #[Override] public function whereColumn($first, $operator = null, $second = null, $boolean = 'and') { throw new BadMethodCallException('This method is not supported by MongoDB'); } /** @internal This method is not supported by MongoDB. */ + #[Override] public function whereFullText($columns, $value, array $options = [], $boolean = 'and') { throw new BadMethodCallException('This method is not supported by MongoDB'); } /** @internal This method is not supported by MongoDB. */ + #[Override] public function groupByRaw($sql, array $bindings = []) { throw new BadMethodCallException('This method is not supported by MongoDB'); } /** @internal This method is not supported by MongoDB. */ + #[Override] public function orderByRaw($sql, $bindings = []) { throw new BadMethodCallException('This method is not supported by MongoDB'); } /** @internal This method is not supported by MongoDB. */ + #[Override] public function unionAll($query) { throw new BadMethodCallException('This method is not supported by MongoDB'); } /** @internal This method is not supported by MongoDB. */ + #[Override] public function union($query, $all = false) { throw new BadMethodCallException('This method is not supported by MongoDB'); } /** @internal This method is not supported by MongoDB. */ + #[Override] public function having($column, $operator = null, $value = null, $boolean = 'and') { throw new BadMethodCallException('This method is not supported by MongoDB'); } /** @internal This method is not supported by MongoDB. */ + #[Override] public function havingRaw($sql, array $bindings = [], $boolean = 'and') { throw new BadMethodCallException('This method is not supported by MongoDB'); } /** @internal This method is not supported by MongoDB. */ + #[Override] public function havingBetween($column, iterable $values, $boolean = 'and', $not = false) { throw new BadMethodCallException('This method is not supported by MongoDB'); } /** @internal This method is not supported by MongoDB. */ + #[Override] public function whereIntegerInRaw($column, $values, $boolean = 'and', $not = false) { throw new BadMethodCallException('This method is not supported by MongoDB'); } /** @internal This method is not supported by MongoDB. */ + #[Override] public function orWhereIntegerInRaw($column, $values) { throw new BadMethodCallException('This method is not supported by MongoDB'); } /** @internal This method is not supported by MongoDB. */ + #[Override] public function whereIntegerNotInRaw($column, $values, $boolean = 'and') { throw new BadMethodCallException('This method is not supported by MongoDB'); } /** @internal This method is not supported by MongoDB. */ + #[Override] public function orWhereIntegerNotInRaw($column, $values, $boolean = 'and') { throw new BadMethodCallException('This method is not supported by MongoDB'); } - private function aliasIdForQuery(array $values): array + private function aliasIdForQuery(array $values, bool $root = true): array { - if (array_key_exists('id', $values)) { + if (array_key_exists('id', $values) && ($root || $this->connection->getRenameEmbeddedIdField())) { if (array_key_exists('_id', $values) && $values['id'] !== $values['_id']) { throw new InvalidArgumentException('Cannot have both "id" and "_id" fields.'); } @@ -1634,7 +1887,7 @@ private function aliasIdForQuery(array $values): array } // ".id" subfield are alias for "._id" - if (str_ends_with($key, '.id')) { + if (str_ends_with($key, '.id') && $this->connection->getRenameEmbeddedIdField()) { $newkey = substr($key, 0, -3) . '._id'; if (array_key_exists($newkey, $values) && $value !== $values[$newkey]) { throw new InvalidArgumentException(sprintf('Cannot have both "%s" and "%s" fields.', $key, $newkey)); @@ -1647,7 +1900,7 @@ private function aliasIdForQuery(array $values): array foreach ($values as &$value) { if (is_array($value)) { - $value = $this->aliasIdForQuery($value); + $value = $this->aliasIdForQuery($value, false); } elseif ($value instanceof DateTimeInterface) { $value = new UTCDateTime($value); } @@ -1665,10 +1918,13 @@ private function aliasIdForQuery(array $values): array * * @template T of array|object */ - public function aliasIdForResult(array|object $values): array|object + public function aliasIdForResult(array|object $values, bool $root = true): array|object { if (is_array($values)) { - if (array_key_exists('_id', $values) && ! array_key_exists('id', $values)) { + if ( + array_key_exists('_id', $values) && ! array_key_exists('id', $values) + && ($root || $this->connection->getRenameEmbeddedIdField()) + ) { $values['id'] = $values['_id']; unset($values['_id']); } @@ -1678,13 +1934,16 @@ public function aliasIdForResult(array|object $values): array|object $values[$key] = Date::instance($value->toDateTime()) ->setTimezone(new DateTimeZone(date_default_timezone_get())); } elseif (is_array($value) || is_object($value)) { - $values[$key] = $this->aliasIdForResult($value); + $values[$key] = $this->aliasIdForResult($value, false); } } } if ($values instanceof stdClass) { - if (property_exists($values, '_id') && ! property_exists($values, 'id')) { + if ( + property_exists($values, '_id') && ! property_exists($values, 'id') + && ($root || $this->connection->getRenameEmbeddedIdField()) + ) { $values->id = $values->_id; unset($values->_id); } @@ -1694,7 +1953,7 @@ public function aliasIdForResult(array|object $values): array|object $values->{$key} = Date::instance($value->toDateTime()) ->setTimezone(new DateTimeZone(date_default_timezone_get())); } elseif (is_array($value) || is_object($value)) { - $values->{$key} = $this->aliasIdForResult($value); + $values->{$key} = $this->aliasIdForResult($value, false); } } } diff --git a/src/Queue/MongoQueue.php b/src/Queue/MongoQueue.php index 7810aab92..1e353bd65 100644 --- a/src/Queue/MongoQueue.php +++ b/src/Queue/MongoQueue.php @@ -8,6 +8,7 @@ use Illuminate\Queue\DatabaseQueue; use MongoDB\Laravel\Connection; use MongoDB\Operation\FindOneAndUpdate; +use Override; use stdClass; class MongoQueue extends DatabaseQueue @@ -34,7 +35,12 @@ public function __construct(Connection $database, $table, $default = 'default', $this->retryAfter = $retryAfter; } - /** @inheritdoc */ + /** + * @return MongoJob|null + * + * @inheritdoc + */ + #[Override] public function pop($queue = null) { $queue = $this->getQueue($queue); @@ -138,12 +144,14 @@ protected function releaseJob($id, $attempts) } /** @inheritdoc */ + #[Override] public function deleteReserved($queue, $id) { $this->database->table($this->table)->where('_id', $id)->delete(); } /** @inheritdoc */ + #[Override] public function deleteAndRelease($queue, $job, $delay) { $this->deleteReserved($queue, $job->getJobId()); diff --git a/src/Relations/BelongsTo.php b/src/Relations/BelongsTo.php index 175a53e49..15447c219 100644 --- a/src/Relations/BelongsTo.php +++ b/src/Relations/BelongsTo.php @@ -6,8 +6,15 @@ use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo as EloquentBelongsTo; +use Override; -class BelongsTo extends \Illuminate\Database\Eloquent\Relations\BelongsTo +/** + * @template TRelatedModel of Model + * @template TDeclaringModel of Model + * @extends EloquentBelongsTo + */ +class BelongsTo extends EloquentBelongsTo { /** * Get the key for comparing against the parent key in "has" query. @@ -20,6 +27,7 @@ public function getHasCompareKey() } /** @inheritdoc */ + #[Override] public function addConstraints() { if (static::$constraints) { @@ -31,6 +39,7 @@ public function addConstraints() } /** @inheritdoc */ + #[Override] public function addEagerConstraints(array $models) { // We'll grab the primary key name of the related models since it could be set to @@ -40,6 +49,7 @@ public function addEagerConstraints(array $models) } /** @inheritdoc */ + #[Override] public function getRelationExistenceQuery(Builder $query, Builder $parentQuery, $columns = ['*']) { return $query; @@ -52,11 +62,13 @@ public function getRelationExistenceQuery(Builder $query, Builder $parentQuery, * * @return string */ + #[Override] protected function whereInMethod(Model $model, $key) { return 'whereIn'; } + #[Override] public function getQualifiedForeignKeyName(): string { return $this->foreignKey; diff --git a/src/Relations/BelongsToMany.php b/src/Relations/BelongsToMany.php index b68c79d4c..8978483ec 100644 --- a/src/Relations/BelongsToMany.php +++ b/src/Relations/BelongsToMany.php @@ -10,17 +10,23 @@ use Illuminate\Database\Eloquent\Relations\BelongsToMany as EloquentBelongsToMany; use Illuminate\Support\Arr; use MongoDB\Laravel\Eloquent\Model as DocumentModel; +use Override; use function array_diff; use function array_keys; use function array_map; -use function array_merge; +use function array_replace; use function array_values; use function assert; use function count; use function in_array; use function is_numeric; +/** + * @template TRelatedModel of Model + * @template TDeclaringModel of Model + * @extends EloquentBelongsToMany + */ class BelongsToMany extends EloquentBelongsToMany { /** @@ -34,12 +40,14 @@ public function getHasCompareKey() } /** @inheritdoc */ + #[Override] public function getRelationExistenceQuery(Builder $query, Builder $parentQuery, $columns = ['*']) { return $query; } /** @inheritdoc */ + #[Override] protected function hydratePivotRelation(array $models) { // Do nothing. @@ -56,12 +64,14 @@ protected function getSelectColumns(array $columns = ['*']) } /** @inheritdoc */ + #[Override] protected function shouldSelect(array $columns = ['*']) { return $columns; } /** @inheritdoc */ + #[Override] public function addConstraints() { if (static::$constraints) { @@ -84,6 +94,7 @@ protected function setWhere() } /** @inheritdoc */ + #[Override] public function save(Model $model, array $pivotAttributes = [], $touch = true) { $model->save(['touch' => false]); @@ -94,6 +105,7 @@ public function save(Model $model, array $pivotAttributes = [], $touch = true) } /** @inheritdoc */ + #[Override] public function create(array $attributes = [], array $joining = [], $touch = true) { $instance = $this->related->newInstance($attributes); @@ -109,6 +121,7 @@ public function create(array $attributes = [], array $joining = [], $touch = tru } /** @inheritdoc */ + #[Override] public function sync($ids, $detaching = true) { $changes = [ @@ -159,7 +172,7 @@ public function sync($ids, $detaching = true) // 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 = array_replace( $changes, $this->attachNew($records, $current, false), ); @@ -172,6 +185,7 @@ public function sync($ids, $detaching = true) } /** @inheritdoc */ + #[Override] public function updateExistingPivot($id, array $attributes, $touch = true) { // Do nothing, we have no pivot table. @@ -179,6 +193,7 @@ public function updateExistingPivot($id, array $attributes, $touch = true) } /** @inheritdoc */ + #[Override] public function attach($id, array $attributes = [], $touch = true) { if ($id instanceof Model) { @@ -219,6 +234,7 @@ public function attach($id, array $attributes = [], $touch = true) } /** @inheritdoc */ + #[Override] public function detach($ids = [], $touch = true) { if ($ids instanceof Model) { @@ -259,6 +275,7 @@ public function detach($ids = [], $touch = true) } /** @inheritdoc */ + #[Override] protected function buildDictionary(Collection $results) { $foreign = $this->foreignPivotKey; @@ -278,6 +295,7 @@ protected function buildDictionary(Collection $results) } /** @inheritdoc */ + #[Override] public function newPivotQuery() { return $this->newRelatedQuery(); @@ -304,12 +322,14 @@ public function getForeignKey() } /** @inheritdoc */ + #[Override] public function getQualifiedForeignPivotKeyName() { return $this->foreignPivotKey; } /** @inheritdoc */ + #[Override] public function getQualifiedRelatedPivotKeyName() { return $this->relatedPivotKey; @@ -318,10 +338,9 @@ public function getQualifiedRelatedPivotKeyName() /** * Get the name of the "where in" method for eager loading. * - * @param string $key - * - * @return string + * @inheritdoc */ + #[Override] protected function whereInMethod(Model $model, $key) { return 'whereIn'; diff --git a/src/Relations/EmbedsMany.php b/src/Relations/EmbedsMany.php index e4bbf535f..49e1afa2d 100644 --- a/src/Relations/EmbedsMany.php +++ b/src/Relations/EmbedsMany.php @@ -21,6 +21,12 @@ use function throw_if; use function value; +/** + * @template TRelatedModel of Model + * @template TDeclaringModel of Model + * @template TResult + * @extends EmbedsOneOrMany + */ class EmbedsMany extends EmbedsOneOrMany { /** @inheritdoc */ diff --git a/src/Relations/EmbedsOne.php b/src/Relations/EmbedsOne.php index 95d5cc15d..be7fb192f 100644 --- a/src/Relations/EmbedsOne.php +++ b/src/Relations/EmbedsOne.php @@ -11,6 +11,12 @@ use function throw_if; +/** + * @template TRelatedModel of Model + * @template TDeclaringModel of Model + * @template TResult + * @extends EmbedsOneOrMany + */ class EmbedsOne extends EmbedsOneOrMany { public function initRelation(array $models, $relation) diff --git a/src/Relations/EmbedsOneOrMany.php b/src/Relations/EmbedsOneOrMany.php index f18d3d526..cc9376dcc 100644 --- a/src/Relations/EmbedsOneOrMany.php +++ b/src/Relations/EmbedsOneOrMany.php @@ -12,6 +12,7 @@ use Illuminate\Database\Query\Expression; use MongoDB\Driver\Exception\LogicException; use MongoDB\Laravel\Eloquent\Model as DocumentModel; +use Override; use Throwable; use function array_merge; @@ -21,6 +22,12 @@ use function str_starts_with; use function throw_if; +/** + * @template TRelatedModel of Model + * @template TDeclaringModel of Model + * @template TResult + * @extends Relation + */ abstract class EmbedsOneOrMany extends Relation { /** @@ -72,6 +79,7 @@ public function __construct(Builder $query, Model $parent, Model $related, strin } /** @inheritdoc */ + #[Override] public function addConstraints() { if (static::$constraints) { @@ -80,12 +88,14 @@ public function addConstraints() } /** @inheritdoc */ + #[Override] public function addEagerConstraints(array $models) { // There are no eager loading constraints. } /** @inheritdoc */ + #[Override] public function match(array $models, Collection $results, $relation) { foreach ($models as $model) { @@ -99,13 +109,7 @@ public function match(array $models, Collection $results, $relation) return $models; } - /** - * Shorthand to get the results of the relationship. - * - * @param array $columns - * - * @return Collection - */ + #[Override] public function get($columns = ['*']) { return $this->getResults(); @@ -318,6 +322,7 @@ protected function getParentRelation() } /** @inheritdoc */ + #[Override] public function getQuery() { // Because we are sharing this relation instance to models, we need @@ -326,6 +331,7 @@ public function getQuery() } /** @inheritdoc */ + #[Override] public function toBase() { // Because we are sharing this relation instance to models, we need @@ -361,6 +367,7 @@ protected function getPathHierarchy($glue = '.') } /** @inheritdoc */ + #[Override] public function getQualifiedParentKeyName() { $parentRelation = $this->getParentRelation(); @@ -419,10 +426,10 @@ public function getQualifiedForeignKeyName() * Get the name of the "where in" method for eager loading. * * @param EloquentModel $model - * @param string $key * - * @return string + * @inheritdoc */ + #[Override] protected function whereInMethod(EloquentModel $model, $key) { return 'whereIn'; diff --git a/src/Relations/HasMany.php b/src/Relations/HasMany.php index a38fba15a..052230495 100644 --- a/src/Relations/HasMany.php +++ b/src/Relations/HasMany.php @@ -7,7 +7,13 @@ use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasMany as EloquentHasMany; +use Override; +/** + * @template TRelatedModel of Model + * @template TDeclaringModel of Model + * @extends EloquentHasMany + */ class HasMany extends EloquentHasMany { /** @@ -15,6 +21,7 @@ class HasMany extends EloquentHasMany * * @return string */ + #[Override] public function getForeignKeyName() { return $this->foreignKey; @@ -31,6 +38,7 @@ public function getHasCompareKey() } /** @inheritdoc */ + #[Override] public function getRelationExistenceQuery(Builder $query, Builder $parentQuery, $columns = ['*']) { $foreignKey = $this->getHasCompareKey(); @@ -41,10 +49,9 @@ public function getRelationExistenceQuery(Builder $query, Builder $parentQuery, /** * Get the name of the "where in" method for eager loading. * - * @param string $key - * - * @return string + * @inheritdoc */ + #[Override] protected function whereInMethod(Model $model, $key) { return 'whereIn'; diff --git a/src/Relations/HasOne.php b/src/Relations/HasOne.php index 740a489d8..bfa297c4e 100644 --- a/src/Relations/HasOne.php +++ b/src/Relations/HasOne.php @@ -7,7 +7,13 @@ use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasOne as EloquentHasOne; +use Override; +/** + * @template TRelatedModel of Model + * @template TDeclaringModel of Model + * @extends EloquentHasOne + */ class HasOne extends EloquentHasOne { /** @@ -15,6 +21,7 @@ class HasOne extends EloquentHasOne * * @return string */ + #[Override] public function getForeignKeyName() { return $this->foreignKey; @@ -31,6 +38,7 @@ public function getHasCompareKey() } /** @inheritdoc */ + #[Override] public function getRelationExistenceQuery(Builder $query, Builder $parentQuery, $columns = ['*']) { $foreignKey = $this->getForeignKeyName(); @@ -38,13 +46,8 @@ public function getRelationExistenceQuery(Builder $query, Builder $parentQuery, return $query->select($foreignKey)->where($foreignKey, 'exists', true); } - /** - * Get the name of the "where in" method for eager loading. - * - * @param string $key - * - * @return string - */ + /** Get the name of the "where in" method for eager loading. */ + #[Override] protected function whereInMethod(Model $model, $key) { return 'whereIn'; diff --git a/src/Relations/MorphMany.php b/src/Relations/MorphMany.php index 88f825dc0..925ebcfa9 100644 --- a/src/Relations/MorphMany.php +++ b/src/Relations/MorphMany.php @@ -6,16 +6,16 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\MorphMany as EloquentMorphMany; +use Override; +/** + * @template TRelatedModel of Model + * @template TDeclaringModel of Model + * @extends EloquentMorphMany + */ class MorphMany extends EloquentMorphMany { - /** - * Get the name of the "where in" method for eager loading. - * - * @param string $key - * - * @return string - */ + #[Override] protected function whereInMethod(Model $model, $key) { return 'whereIn'; diff --git a/src/Relations/MorphTo.php b/src/Relations/MorphTo.php index 4874b23bb..9f1bf1441 100644 --- a/src/Relations/MorphTo.php +++ b/src/Relations/MorphTo.php @@ -6,10 +6,17 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\MorphTo as EloquentMorphTo; +use Override; +/** + * @template TRelatedModel of Model + * @template TDeclaringModel of Model + * @extends EloquentMorphTo + */ class MorphTo extends EloquentMorphTo { /** @inheritdoc */ + #[Override] public function addConstraints() { if (static::$constraints) { @@ -25,6 +32,7 @@ public function addConstraints() } /** @inheritdoc */ + #[Override] protected function getResultsByType($type) { $instance = $this->createModelByType($type); @@ -36,13 +44,8 @@ protected function getResultsByType($type) return $query->whereIn($key, $this->gatherKeysByType($type, $instance->getKeyType()))->get(); } - /** - * Get the name of the "where in" method for eager loading. - * - * @param string $key - * - * @return string - */ + /** Get the name of the "where in" method for eager loading. */ + #[Override] protected function whereInMethod(Model $model, $key) { return 'whereIn'; diff --git a/src/Relations/MorphToMany.php b/src/Relations/MorphToMany.php index f11d25473..724dad912 100644 --- a/src/Relations/MorphToMany.php +++ b/src/Relations/MorphToMany.php @@ -10,13 +10,14 @@ use Illuminate\Database\Eloquent\Relations\MorphToMany as EloquentMorphToMany; use Illuminate\Support\Arr; use MongoDB\BSON\ObjectId; +use Override; use function array_diff; use function array_key_exists; use function array_keys; use function array_map; -use function array_merge; use function array_reduce; +use function array_replace; use function array_values; use function collect; use function count; @@ -24,27 +25,32 @@ use function is_array; use function is_numeric; +/** + * @template TRelatedModel of Model + * @template TDeclaringModel of Model + * @extends EloquentMorphToMany + */ class MorphToMany extends EloquentMorphToMany { - /** @inheritdoc */ + #[Override] public function getRelationExistenceQuery(Builder $query, Builder $parentQuery, $columns = ['*']) { return $query; } - /** @inheritdoc */ + #[Override] protected function hydratePivotRelation(array $models) { // Do nothing. } - /** @inheritdoc */ + #[Override] protected function shouldSelect(array $columns = ['*']) { return $columns; } - /** @inheritdoc */ + #[Override] public function addConstraints() { if (static::$constraints) { @@ -52,7 +58,7 @@ public function addConstraints() } } - /** @inheritdoc */ + #[Override] public function addEagerConstraints(array $models) { // To load relation's data, we act normally on MorphToMany relation, @@ -97,6 +103,7 @@ protected function setWhere() } /** @inheritdoc */ + #[Override] public function save(Model $model, array $pivotAttributes = [], $touch = true) { $model->save(['touch' => false]); @@ -107,6 +114,7 @@ public function save(Model $model, array $pivotAttributes = [], $touch = true) } /** @inheritdoc */ + #[Override] public function create(array $attributes = [], array $joining = [], $touch = true) { $instance = $this->related->newInstance($attributes); @@ -122,6 +130,7 @@ public function create(array $attributes = [], array $joining = [], $touch = tru } /** @inheritdoc */ + #[Override] public function sync($ids, $detaching = true) { $changes = [ @@ -185,7 +194,7 @@ public function sync($ids, $detaching = true) // 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 = array_replace( $changes, $this->attachNew($records, $current, false), ); @@ -198,12 +207,14 @@ public function sync($ids, $detaching = true) } /** @inheritdoc */ + #[Override] public function updateExistingPivot($id, array $attributes, $touch = true): void { // Do nothing, we have no pivot table. } /** @inheritdoc */ + #[Override] public function attach($id, array $attributes = [], $touch = true) { if ($id instanceof Model) { @@ -297,6 +308,7 @@ public function attach($id, array $attributes = [], $touch = true) } /** @inheritdoc */ + #[Override] public function detach($ids = [], $touch = true) { if ($ids instanceof Model) { @@ -371,6 +383,7 @@ public function detach($ids = [], $touch = true) } /** @inheritdoc */ + #[Override] protected function buildDictionary(Collection $results) { $foreign = $this->foreignPivotKey; @@ -398,6 +411,7 @@ protected function buildDictionary(Collection $results) } /** @inheritdoc */ + #[Override] public function newPivotQuery() { return $this->newRelatedQuery(); @@ -413,19 +427,13 @@ public function newRelatedQuery() return $this->related->newQuery(); } - /** @inheritdoc */ + #[Override] public function getQualifiedRelatedPivotKeyName() { return $this->relatedPivotKey; } - /** - * Get the name of the "where in" method for eager loading. - * - * @param string $key - * - * @return string - */ + #[Override] protected function whereInMethod(Model $model, $key) { return 'whereIn'; diff --git a/src/Schema/Blueprint.php b/src/Schema/Blueprint.php index f107bd7e5..24e23d50e 100644 --- a/src/Schema/Blueprint.php +++ b/src/Schema/Blueprint.php @@ -4,11 +4,13 @@ namespace MongoDB\Laravel\Schema; -use Illuminate\Database\Connection; -use Illuminate\Database\Schema\Blueprint as SchemaBlueprint; +use Illuminate\Database\Schema\Blueprint as BaseBlueprint; use MongoDB\Collection; +use MongoDB\Laravel\Connection; +use Override; use function array_flip; +use function array_merge; use function implode; use function in_array; use function is_array; @@ -16,17 +18,14 @@ use function is_string; use function key; -class Blueprint extends SchemaBlueprint +/** @property Connection $connection */ +class Blueprint extends BaseBlueprint { - /** - * The MongoConnection object for this blueprint. - * - * @var Connection - */ - protected $connection; + // Import $connection property and constructor for Laravel 12 compatibility + use BlueprintLaravelCompatibility; /** - * The Collection object for this blueprint. + * The MongoDB collection object for this blueprint. * * @var Collection */ @@ -39,19 +38,8 @@ class Blueprint extends SchemaBlueprint */ protected $columns = []; - /** - * Create a new schema blueprint. - */ - public function __construct(Connection $connection, string $collection) - { - parent::__construct($collection); - - $this->connection = $connection; - - $this->collection = $this->connection->getCollection($collection); - } - /** @inheritdoc */ + #[Override] public function index($columns = null, $name = null, $algorithm = null, $options = []) { $columns = $this->fluent($columns); @@ -78,12 +66,14 @@ public function index($columns = null, $name = null, $algorithm = null, $options } /** @inheritdoc */ + #[Override] public function primary($columns = null, $name = null, $algorithm = null, $options = []) { return $this->unique($columns, $name, $algorithm, $options); } /** @inheritdoc */ + #[Override] public function dropIndex($index = null) { $index = $this->transformColumns($index); @@ -132,6 +122,24 @@ public function hasIndex($indexOrColumns = null) return false; } + public function jsonSchema( + array $schema = [], + ?string $validationLevel = null, + ?string $validationAction = null, + ): void { + $options = array_merge( + [ + 'validator' => [ + '$jsonSchema' => $schema, + ], + ], + $validationLevel ? ['validationLevel' => $validationLevel] : [], + $validationAction ? ['validationAction' => $validationAction] : [], + ); + + $this->connection->getDatabase()->modifyCollection($this->collection->getCollectionName(), $options); + } + /** * @param string|array $indexOrColumns * @@ -166,6 +174,7 @@ protected function transformColumns($indexOrColumns) } /** @inheritdoc */ + #[Override] public function unique($columns = null, $name = null, $algorithm = null, $options = []) { $columns = $this->fluent($columns); @@ -247,17 +256,19 @@ public function expire($columns, $seconds) * * @return void */ + #[Override] public function create($options = []) { $collection = $this->collection->getCollectionName(); - $db = $this->connection->getMongoDB(); + $db = $this->connection->getDatabase(); // Ensure the collection is created. $db->createCollection($collection, $options); } /** @inheritdoc */ + #[Override] public function drop() { $this->collection->drop(); @@ -266,6 +277,7 @@ public function drop() } /** @inheritdoc */ + #[Override] public function renameColumn($from, $to) { $this->collection->updateMany([$from => ['$exists' => true]], ['$rename' => [$from => $to]]); @@ -274,6 +286,7 @@ public function renameColumn($from, $to) } /** @inheritdoc */ + #[Override] public function addColumn($type, $name, array $parameters = []) { $this->fluent($name); @@ -303,6 +316,52 @@ public function sparse_and_unique($columns = null, $options = []) return $this; } + /** + * Create an Atlas Search Index. + * + * @see https://www.mongodb.com/docs/manual/reference/command/createSearchIndexes/#std-label-search-index-definition-create + * + * @phpstan-param array{ + * analyzer?: string, + * analyzers?: list, + * searchAnalyzer?: string, + * mappings: array{dynamic: true} | array{dynamic?: bool, fields: array}, + * storedSource?: bool|array, + * synonyms?: list, + * ... + * } $definition + */ + public function searchIndex(array $definition, string $name = 'default'): static + { + $this->collection->createSearchIndex($definition, ['name' => $name, 'type' => 'search']); + + return $this; + } + + /** + * Create an Atlas Vector Search Index. + * + * @see https://www.mongodb.com/docs/manual/reference/command/createSearchIndexes/#std-label-vector-search-index-definition-create + * + * @phpstan-param array{fields: array} $definition + */ + public function vectorSearchIndex(array $definition, string $name = 'default'): static + { + $this->collection->createSearchIndex($definition, ['name' => $name, 'type' => 'vectorSearch']); + + return $this; + } + + /** + * Drop an Atlas Search or Vector Search index + */ + public function dropSearchIndex(string $name): static + { + $this->collection->dropSearchIndex($name); + + return $this; + } + /** * Allow fluent columns. * diff --git a/src/Schema/BlueprintLaravelCompatibility.php b/src/Schema/BlueprintLaravelCompatibility.php new file mode 100644 index 000000000..bf288eae8 --- /dev/null +++ b/src/Schema/BlueprintLaravelCompatibility.php @@ -0,0 +1,50 @@ +connection = $connection; + $this->collection = $connection->getCollection($collection); + } + } +} else { + /** @internal For compatibility with Laravel 12+ */ + trait BlueprintLaravelCompatibility + { + public function __construct(Connection $connection, string $collection, ?Closure $callback = null) + { + parent::__construct($connection, $collection, $callback); + + $this->collection = $connection->getCollection($collection); + } + } +} diff --git a/src/Schema/Builder.php b/src/Schema/Builder.php index ade4b0fb7..207f4f1b3 100644 --- a/src/Schema/Builder.php +++ b/src/Schema/Builder.php @@ -5,25 +5,42 @@ namespace MongoDB\Laravel\Schema; use Closure; +use MongoDB\Collection; +use MongoDB\Driver\Exception\ServerException; +use MongoDB\Laravel\Connection; use MongoDB\Model\CollectionInfo; use MongoDB\Model\IndexInfo; +use Override; +use function array_column; use function array_fill_keys; use function array_filter; +use function array_key_exists; use function array_keys; use function array_map; +use function array_merge; +use function array_values; use function assert; use function count; use function current; +use function explode; use function implode; use function in_array; +use function is_array; +use function is_bool; +use function is_string; use function iterator_to_array; use function sort; use function sprintf; +use function str_contains; use function str_ends_with; use function substr; +use function trigger_error; use function usort; +use const E_USER_DEPRECATED; + +/** @property Connection $connection */ class Builder extends \Illuminate\Database\Schema\Builder { /** @@ -38,7 +55,7 @@ public function hasColumn($table, $column): bool } /** - * Check if columns exists in the collection schema. + * Check if columns exist in the collection schema. * * @param string $table * @param string[] $columns @@ -72,7 +89,7 @@ public function hasColumns($table, array $columns): bool */ public function hasCollection($name) { - $db = $this->connection->getMongoDB(); + $db = $this->connection->getDatabase(); $collections = iterator_to_array($db->listCollections([ 'filter' => ['name' => $name], @@ -82,12 +99,14 @@ public function hasCollection($name) } /** @inheritdoc */ + #[Override] public function hasTable($table) { return $this->hasCollection($table); } /** @inheritdoc */ + #[Override] public function table($table, Closure $callback) { $blueprint = $this->createBlueprint($table); @@ -98,6 +117,7 @@ public function table($table, Closure $callback) } /** @inheritdoc */ + #[Override] public function create($table, ?Closure $callback = null, array $options = []) { $blueprint = $this->createBlueprint($table); @@ -110,6 +130,7 @@ public function create($table, ?Closure $callback = null, array $options = []) } /** @inheritdoc */ + #[Override] public function dropIfExists($table) { if ($this->hasCollection($table)) { @@ -118,6 +139,7 @@ public function dropIfExists($table) } /** @inheritdoc */ + #[Override] public function drop($table) { $blueprint = $this->createBlueprint($table); @@ -125,54 +147,82 @@ public function drop($table) $blueprint->drop(); } - /** @inheritdoc */ + /** + * @inheritdoc + * + * Drops the entire database instead of deleting each collection individually. + * + * In MongoDB, dropping the whole database is much faster than dropping collections + * one by one. The database will be automatically recreated when a new connection + * writes to it. + */ + #[Override] public function dropAllTables() { - foreach ($this->getAllCollections() as $collection) { - $this->drop($collection); - } + $this->connection->getDatabase()->drop(); } - public function getTables() + /** + * @param string|null $schema Database name + * + * @inheritdoc + */ + #[Override] + public function getTables($schema = null) { - $db = $this->connection->getMongoDB(); - $collections = []; + return $this->getCollectionRows('collection', $schema); + } - foreach ($db->listCollectionNames() as $collectionName) { - $stats = $db->selectCollection($collectionName)->aggregate([ - ['$collStats' => ['storageStats' => ['scale' => 1]]], - ['$project' => ['storageStats.totalSize' => 1]], - ])->toArray(); + /** + * @param string|null $schema Database name + * + * @inheritdoc + */ + #[Override] + public function getViews($schema = null) + { + return $this->getCollectionRows('view', $schema); + } - $collections[] = [ - 'name' => $collectionName, - 'schema' => null, - 'size' => $stats[0]?->storageStats?->totalSize ?? null, - 'comment' => null, - 'collation' => null, - 'engine' => null, - ]; - } + /** + * @param string|null $schema + * @param bool $schemaQualified If a schema is provided, prefix the collection names with the schema name + * + * @return array + */ + #[Override] + public function getTableListing($schema = null, $schemaQualified = false) + { + $collections = []; - usort($collections, function ($a, $b) { - return $a['name'] <=> $b['name']; - }); + if ($schema === null || is_string($schema)) { + $collections[$schema ?? 0] = iterator_to_array($this->connection->getDatabase($schema)->listCollectionNames()); + } elseif (is_array($schema)) { + foreach ($schema as $db) { + $collections[$db] = iterator_to_array($this->connection->getDatabase($db)->listCollectionNames()); + } + } - return $collections; - } + if ($schema && $schemaQualified) { + $collections = array_map(fn ($db, $collections) => array_map(static fn ($collection) => $db . '.' . $collection, $collections), array_keys($collections), $collections); + } - public function getTableListing() - { - $collections = iterator_to_array($this->connection->getMongoDB()->listCollectionNames()); + $collections = array_merge(...array_values($collections)); sort($collections); return $collections; } + #[Override] public function getColumns($table) { - $stats = $this->connection->getMongoDB()->selectCollection($table)->aggregate([ + $db = null; + if (str_contains($table, '.')) { + [$db, $table] = explode('.', $table, 2); + } + + $stats = $this->connection->getDatabase($db)->getCollection($table)->aggregate([ // Sample 1,000 documents to get a representative sample of the collection ['$sample' => ['size' => 1_000]], // Convert each document to an array of fields @@ -223,11 +273,14 @@ public function getColumns($table) return $columns; } + #[Override] public function getIndexes($table) { - $indexes = $this->connection->getMongoDB()->selectCollection($table)->listIndexes(); - + $collection = $this->connection->getDatabase()->selectCollection($table); + assert($collection instanceof Collection); $indexList = []; + + $indexes = $collection->listIndexes(); foreach ($indexes as $index) { assert($index instanceof IndexInfo); $indexList[] = [ @@ -238,21 +291,55 @@ public function getIndexes($table) $index->isText() => 'text', $index->is2dSphere() => '2dsphere', $index->isTtl() => 'ttl', - default => 'default', + default => null, }, 'unique' => $index->isUnique(), ]; } + try { + $indexes = $collection->listSearchIndexes(['typeMap' => ['root' => 'array', 'array' => 'array', 'document' => 'array']]); + foreach ($indexes as $index) { + // Status 'DOES_NOT_EXIST' means the index has been dropped but is still in the process of being removed + if ($index['status'] === 'DOES_NOT_EXIST') { + continue; + } + + $indexList[] = [ + 'name' => $index['name'], + 'columns' => match ($index['type']) { + 'search' => array_merge( + $index['latestDefinition']['mappings']['dynamic'] ? ['dynamic'] : [], + array_keys($index['latestDefinition']['mappings']['fields'] ?? []), + ), + 'vectorSearch' => array_column($index['latestDefinition']['fields'], 'path'), + }, + 'type' => $index['type'], + 'primary' => false, + 'unique' => false, + ]; + } + } catch (ServerException $exception) { + if (! self::isAtlasSearchNotSupportedException($exception)) { + throw $exception; + } + } + return $indexList; } + #[Override] public function getForeignKeys($table) { return []; } - /** @inheritdoc */ + /** + * @return Blueprint + * + * @inheritdoc + */ + #[Override] protected function createBlueprint($table, ?Closure $callback = null) { return new Blueprint($this->connection, $table); @@ -267,7 +354,7 @@ protected function createBlueprint($table, ?Closure $callback = null) */ public function getCollection($name) { - $db = $this->connection->getMongoDB(); + $db = $this->connection->getDatabase(); $collections = iterator_to_array($db->listCollections([ 'filter' => ['name' => $name], @@ -277,17 +364,97 @@ public function getCollection($name) } /** - * Get all of the collections names for the database. + * Get all the collections names for the database. + * + * @deprecated * * @return array */ protected function getAllCollections() { + trigger_error(sprintf('Since mongodb/laravel-mongodb:5.4, Method "%s()" is deprecated without replacement.', __METHOD__), E_USER_DEPRECATED); + $collections = []; - foreach ($this->connection->getMongoDB()->listCollections() as $collection) { + foreach ($this->connection->getDatabase()->listCollections() as $collection) { $collections[] = $collection->getName(); } return $collections; } + + /** @internal */ + public static function isAtlasSearchNotSupportedException(ServerException $e): bool + { + return in_array($e->getCode(), [ + 59, // MongoDB 4 to 6, 7-community: no such command: 'createSearchIndexes' + 40324, // MongoDB 4 to 6: Unrecognized pipeline stage name: '$listSearchIndexes' + 115, // MongoDB 7-ent: Search index commands are only supported with Atlas. + 6047401, // MongoDB 7: $listSearchIndexes stage is only allowed on MongoDB Atlas + 31082, // MongoDB 8: Using Atlas Search Database Commands and the $listSearchIndexes aggregation stage requires additional configuration. + ], true); + } + + /** @param string|null $schema Database name */ + private function getCollectionRows(string $collectionType, $schema = null) + { + $db = $this->connection->getDatabase($schema); + $collections = []; + + foreach ($db->listCollections() as $collectionInfo) { + $collectionName = $collectionInfo->getName(); + + if ($collectionInfo->getType() !== $collectionType) { + continue; + } + + $options = $collectionInfo->getOptions(); + $collation = $options['collation'] ?? []; + + // Aggregation is not supported on views + $stats = $collectionType !== 'view' ? $db->selectCollection($collectionName)->aggregate([ + ['$collStats' => ['storageStats' => ['scale' => 1]]], + ['$project' => ['storageStats.totalSize' => 1]], + ])->toArray() : null; + + $collections[] = [ + 'name' => $collectionName, + 'schema' => $db->getDatabaseName(), + 'schema_qualified_name' => $db->getDatabaseName() . '.' . $collectionName, + 'size' => $stats[0]?->storageStats?->totalSize ?? null, + 'comment' => null, + 'collation' => $this->collationToString($collation), + 'engine' => null, + ]; + } + + usort($collections, fn ($a, $b) => $a['name'] <=> $b['name']); + + return $collections; + } + + private function collationToString(array $collation): string + { + $map = [ + 'locale' => 'l', + 'strength' => 's', + 'caseLevel' => 'cl', + 'caseFirst' => 'cf', + 'numericOrdering' => 'no', + 'alternate' => 'a', + 'maxVariable' => 'mv', + 'normalization' => 'n', + 'backwards' => 'b', + ]; + + $parts = []; + foreach ($collation as $key => $value) { + if (array_key_exists($key, $map)) { + $shortKey = $map[$key]; + $shortValue = is_bool($value) ? ($value ? '1' : '0') : $value; + $parts[] = $shortKey . '=' . $shortValue; + } + } + + return implode(';', $parts); + } } diff --git a/src/Scout/ScoutEngine.php b/src/Scout/ScoutEngine.php new file mode 100644 index 000000000..9455608bb --- /dev/null +++ b/src/Scout/ScoutEngine.php @@ -0,0 +1,556 @@ + [ + 'dynamic' => true, + ], + ]; + + private const TYPEMAP = ['root' => 'object', 'document' => 'bson', 'array' => 'bson']; + + /** @param array $indexDefinitions */ + public function __construct( + private Database $database, + private bool $softDelete, + private array $indexDefinitions = [], + ) { + } + + /** + * Update the given model in the index. + * + * @see Engine::update() + * + * @param EloquentCollection $models + * + * @throws MongoDBRuntimeException + */ + #[Override] + public function update($models) + { + assert($models instanceof EloquentCollection, new TypeError(sprintf('Argument #1 ($models) must be of type %s, %s given', EloquentCollection::class, get_debug_type($models)))); + + if ($models->isEmpty()) { + return; + } + + if ($this->softDelete && $this->usesSoftDelete($models)) { + $models->each->pushSoftDeleteMetadata(); + } + + $bulk = []; + foreach ($models as $model) { + assert($model instanceof Model && method_exists($model, 'toSearchableArray'), new LogicException(sprintf('Model "%s" must use "%s" trait', $model::class, Searchable::class))); + + $searchableData = $model->toSearchableArray(); + $searchableData = self::serialize($searchableData); + + // Skip/remove the model if it doesn't provide any searchable data + if (! $searchableData) { + $bulk[] = [ + 'deleteOne' => [ + ['_id' => $model->getScoutKey()], + ], + ]; + + continue; + } + + unset($searchableData['_id']); + + $searchableData = array_replace($searchableData, $model->scoutMetadata()); + + /** Convert the __soft_deleted set by {@see Searchable::pushSoftDeleteMetadata()} + * into a boolean for efficient storage and indexing. */ + if (isset($searchableData['__soft_deleted'])) { + $searchableData['__soft_deleted'] = (bool) $searchableData['__soft_deleted']; + } + + $bulk[] = [ + 'updateOne' => [ + ['_id' => $model->getScoutKey()], + [ + // The _id field is added automatically when the document is inserted + // Update all other fields + '$set' => $searchableData, + ], + ['upsert' => true], + ], + ]; + } + + $this->getIndexableCollection($models)->bulkWrite($bulk); + } + + /** + * Remove the given model from the index. + * + * @see Engine::delete() + * + * @param EloquentCollection $models + */ + #[Override] + public function delete($models): void + { + assert($models instanceof EloquentCollection, new TypeError(sprintf('Argument #1 ($models) must be of type %s, %s given', Collection::class, get_debug_type($models)))); + + if ($models->isEmpty()) { + return; + } + + $collection = $this->getIndexableCollection($models); + $ids = $models->map(fn (Model $model) => $model->getScoutKey())->all(); + $collection->deleteMany(['_id' => ['$in' => $ids]]); + } + + /** + * Perform the given search on the engine. + * + * @see Engine::search() + * + * @return array + */ + #[Override] + public function search(Builder $builder) + { + return $this->performSearch($builder); + } + + /** + * Perform the given search on the engine with pagination. + * + * @see Engine::paginate() + * + * @param int $perPage + * @param int $page + * + * @return array + */ + #[Override] + public function paginate(Builder $builder, $perPage, $page) + { + assert(is_int($perPage), new TypeError(sprintf('Argument #2 ($perPage) must be of type int, %s given', get_debug_type($perPage)))); + assert(is_int($page), new TypeError(sprintf('Argument #3 ($page) must be of type int, %s given', get_debug_type($page)))); + + $builder = clone $builder; + $builder->take($perPage); + + return $this->performSearch($builder, $perPage * ($page - 1)); + } + + /** + * Perform the given search on the engine. + */ + private function performSearch(Builder $builder, ?int $offset = null): array + { + $collection = $this->getSearchableCollection($builder->model); + + if ($builder->callback) { + $cursor = call_user_func( + $builder->callback, + $collection, + $builder->query, + $offset, + ); + assert($cursor instanceof CursorInterface, new LogicException(sprintf('The search builder closure must return a MongoDB cursor, %s returned', get_debug_type($cursor)))); + $cursor->setTypeMap(self::TYPEMAP); + + return $cursor->toArray(); + } + + // Using compound to combine search operators + // https://www.mongodb.com/docs/atlas/atlas-search/compound/#options + // "should" specifies conditions that contribute to the relevance score + // at least one of them must match, + // - "text" search for the text including fuzzy matching + // - "wildcard" allows special characters like * and ?, similar to LIKE in SQL + // These are the only search operators to accept wildcard path. + $compound = [ + 'should' => [ + [ + 'text' => [ + 'query' => $builder->query, + 'path' => ['wildcard' => '*'], + 'fuzzy' => ['maxEdits' => 2], + 'score' => ['boost' => ['value' => 5]], + ], + ], + [ + 'wildcard' => [ + 'query' => $builder->query . '*', + 'path' => ['wildcard' => '*'], + 'allowAnalyzedField' => true, + ], + ], + ], + 'minimumShouldMatch' => 1, + ]; + + // "filter" specifies conditions on exact values to match + // "mustNot" specifies conditions on exact values that must not match + // They don't contribute to the relevance score + foreach ($builder->wheres as $field => $value) { + if ($field === '__soft_deleted') { + $value = (bool) $value; + } + + $compound['filter'][] = ['equals' => ['path' => $field, 'value' => $value]]; + } + + foreach ($builder->whereIns as $field => $value) { + $compound['filter'][] = ['in' => ['path' => $field, 'value' => $value]]; + } + + foreach ($builder->whereNotIns as $field => $value) { + $compound['mustNot'][] = ['in' => ['path' => $field, 'value' => $value]]; + } + + // Sort by field value only if specified + $sort = []; + foreach ($builder->orders as $order) { + $sort[$order['column']] = $order['direction'] === 'asc' ? 1 : -1; + } + + $pipeline = [ + [ + '$search' => [ + 'index' => self::INDEX_NAME, + 'compound' => $compound, + 'count' => ['type' => 'lowerBound'], + ...($sort ? ['sort' => $sort] : []), + ], + ], + [ + '$addFields' => [ + // Metadata field with the total count of documents + '__count' => '$$SEARCH_META.count.lowerBound', + ], + ], + ]; + + if ($offset) { + $pipeline[] = ['$skip' => $offset]; + } + + if ($builder->limit) { + $pipeline[] = ['$limit' => $builder->limit]; + } + + $cursor = $collection->aggregate($pipeline); + $cursor->setTypeMap(self::TYPEMAP); + + return $cursor->toArray(); + } + + /** + * Pluck and return the primary keys of the given results. + * + * @see Engine::mapIds() + * + * @param list $results + */ + #[Override] + public function mapIds($results): Collection + { + assert(is_array($results), new TypeError(sprintf('Argument #1 ($results) must be of type array, %s given', get_debug_type($results)))); + + return new Collection(array_column($results, '_id')); + } + + /** + * Map the given results to instances of the given model. + * + * @see Engine::map() + * + * @param Builder $builder + * @param array $results + * @param Model $model + * + * @return Collection + */ + #[Override] + public function map(Builder $builder, $results, $model): Collection + { + return $this->performMap($builder, $results, $model, false); + } + + /** + * Map the given results to instances of the given model via a lazy collection. + * + * @see Engine::lazyMap() + * + * @param Builder $builder + * @param array $results + * @param Model $model + * + * @return LazyCollection + */ + #[Override] + public function lazyMap(Builder $builder, $results, $model): LazyCollection + { + return $this->performMap($builder, $results, $model, true); + } + + /** @return ($lazy is true ? LazyCollection : Collection) */ + private function performMap(Builder $builder, array $results, Model $model, bool $lazy): Collection|LazyCollection + { + if (! $results) { + $collection = $model->newCollection(); + + return $lazy ? LazyCollection::make($collection) : $collection; + } + + $objectIds = array_column($results, '_id'); + $objectIdPositions = array_flip($objectIds); + + return $model->queryScoutModelsByIds($builder, $objectIds) + ->{$lazy ? 'cursor' : 'get'}() + ->filter(function ($model) use ($objectIds) { + return in_array($model->getScoutKey(), $objectIds); + }) + ->map(function ($model) use ($results, $objectIdPositions) { + $result = $results[$objectIdPositions[$model->getScoutKey()]] ?? []; + + foreach ($result as $key => $value) { + if ($key[0] === '_' && $key !== '_id') { + $model->withScoutMetadata($key, $value); + } + } + + return $model; + }) + ->sortBy(function ($model) use ($objectIdPositions) { + return $objectIdPositions[$model->getScoutKey()]; + }) + ->values(); + } + + /** + * Get the total count from a raw result returned by the engine. + * This is an estimate if the count is larger than 1000. + * + * @see Engine::getTotalCount() + * @see https://www.mongodb.com/docs/atlas/atlas-search/counting/ + * + * @param stdClass[] $results + */ + #[Override] + public function getTotalCount($results): int + { + if (! $results) { + return 0; + } + + // __count field is added by the aggregation pipeline in performSearch() + // using the count.lowerBound in the $search stage + return $results[0]->__count; + } + + /** + * Flush all records from the engine. + * + * @see Engine::flush() + * + * @param Model $model + */ + #[Override] + public function flush($model): void + { + assert($model instanceof Model, new TypeError(sprintf('Argument #1 ($model) must be of type %s, %s given', Model::class, get_debug_type($model)))); + + $collection = $this->getIndexableCollection($model); + + $collection->deleteMany([]); + } + + /** + * Create the MongoDB Atlas Search index. + * + * Accepted options: + * - wait: bool, default true. Wait for the index to be created. + * + * @see Engine::createIndex() + * + * @param string $name Collection name + * @param array{wait?:bool} $options + */ + #[Override] + public function createIndex($name, array $options = []): void + { + assert(is_string($name), new TypeError(sprintf('Argument #1 ($name) must be of type string, %s given', get_debug_type($name)))); + + $definition = $this->indexDefinitions[$name] ?? self::DEFAULT_DEFINITION; + if (! isset($definition['mappings'])) { + throw new InvalidArgumentException(sprintf('Invalid search index definition for collection "%s", the "mappings" key is required. Find documentation at https://www.mongodb.com/docs/manual/reference/command/createSearchIndexes/#search-index-definition-syntax', $name)); + } + + // Ensure the collection exists before creating the search index + $this->database->createCollection($name); + + $collection = $this->database->selectCollection($name); + $collection->createSearchIndex($definition, ['name' => self::INDEX_NAME]); + + if ($options['wait'] ?? true) { + $this->wait(function () use ($collection) { + $indexes = $collection->listSearchIndexes([ + 'name' => self::INDEX_NAME, + 'typeMap' => ['root' => 'bson'], + ]); + + return $indexes->current() && $indexes->current()->status === 'READY'; + }); + } + } + + /** + * Delete a "search index", i.e. a MongoDB collection. + * + * @see Engine::deleteIndex() + */ + #[Override] + public function deleteIndex($name): void + { + assert(is_string($name), new TypeError(sprintf('Argument #1 ($name) must be of type string, %s given', get_debug_type($name)))); + + $this->database->dropCollection($name); + } + + /** Get the MongoDB collection used to search for the provided model */ + private function getSearchableCollection(Model|EloquentCollection $model): MongoDBCollection + { + if ($model instanceof EloquentCollection) { + $model = $model->first(); + } + + assert(method_exists($model, 'searchableAs'), sprintf('Model "%s" must use "%s" trait', $model::class, Searchable::class)); + + return $this->database->selectCollection($model->searchableAs()); + } + + /** Get the MongoDB collection used to index the provided model */ + private function getIndexableCollection(Model|EloquentCollection $model): MongoDBCollection + { + if ($model instanceof EloquentCollection) { + $model = $model->first(); + } + + assert($model instanceof Model); + assert(method_exists($model, 'indexableAs'), sprintf('Model "%s" must use "%s" trait', $model::class, Searchable::class)); + + if ( + $model->getConnection() instanceof Connection + && $model->getConnection()->getDatabaseName() === $this->database->getDatabaseName() + && $model->getTable() === $model->indexableAs() + ) { + throw new LogicException(sprintf('The MongoDB Scout collection "%s.%s" must use a different collection from the collection name of the model "%s". Set the "scout.prefix" configuration or use a distinct MongoDB database', $this->database->getDatabaseName(), $model->indexableAs(), $model::class)); + } + + return $this->database->selectCollection($model->indexableAs()); + } + + private static function serialize(mixed $value): mixed + { + if ($value instanceof DateTimeInterface) { + return new UTCDateTime($value); + } + + if ($value instanceof Serializable || ! is_iterable($value)) { + return $value; + } + + // Convert Laravel Collections and other Iterators to arrays + if ($value instanceof Traversable) { + $value = iterator_to_array($value); + } + + // Recursively serialize arrays + return array_map(self::serialize(...), $value); + } + + private function usesSoftDelete(Model|EloquentCollection $model): bool + { + if ($model instanceof EloquentCollection) { + $model = $model->first(); + } + + return in_array(SoftDeletes::class, class_uses_recursive($model)); + } + + /** + * Wait for the callback to return true, use it for asynchronous + * Atlas Search index management operations. + */ + private function wait(Closure $callback): void + { + // Fallback to time() if hrtime() is not supported + $timeout = (hrtime()[0] ?? time()) + self::WAIT_TIMEOUT_SEC; + while ((hrtime()[0] ?? time()) < $timeout) { + if ($callback()) { + return; + } + + sleep(1); + } + + throw new MongoDBRuntimeException(sprintf('Atlas search index operation time out after %s seconds', self::WAIT_TIMEOUT_SEC)); + } +} diff --git a/src/Session/MongoDbSessionHandler.php b/src/Session/MongoDbSessionHandler.php new file mode 100644 index 000000000..3677ea758 --- /dev/null +++ b/src/Session/MongoDbSessionHandler.php @@ -0,0 +1,123 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace MongoDB\Laravel\Session; + +use Illuminate\Session\DatabaseSessionHandler; +use MongoDB\BSON\Binary; +use MongoDB\BSON\Document; +use MongoDB\BSON\UTCDateTime; +use MongoDB\Collection; +use Override; + +use function tap; +use function time; + +/** + * Session handler using the MongoDB driver extension. + */ +final class MongoDbSessionHandler extends DatabaseSessionHandler +{ + private Collection $collection; + + public function close(): bool + { + return true; + } + + #[Override] + public function gc($lifetime): int + { + $result = $this->getCollection()->deleteMany(['last_activity' => ['$lt' => $this->getUTCDateTime(-$lifetime)]]); + + return $result->getDeletedCount() ?? 0; + } + + #[Override] + public function destroy($sessionId): bool + { + $this->getCollection()->deleteOne(['_id' => (string) $sessionId]); + + return true; + } + + #[Override] + public function read($sessionId): string|false + { + $result = $this->getCollection()->findOne( + ['_id' => (string) $sessionId, 'expires_at' => ['$gte' => $this->getUTCDateTime()]], + [ + 'projection' => ['_id' => false, 'payload' => true], + 'typeMap' => ['root' => 'bson'], + ], + ); + + if ($result instanceof Document) { + return (string) $result->payload; + } + + return false; + } + + #[Override] + public function write($sessionId, $data): bool + { + $payload = $this->getDefaultPayload($data); + + $this->getCollection()->replaceOne( + ['_id' => (string) $sessionId], + $payload, + ['upsert' => true], + ); + + return true; + } + + /** Creates a TTL index that automatically deletes expired objects. */ + public function createTTLIndex(): void + { + $this->collection->createIndex( + // UTCDateTime field that holds the expiration date + ['expires_at' => 1], + // Delay to remove items after expiration + ['expireAfterSeconds' => 0], + ); + } + + #[Override] + protected function getDefaultPayload($data): array + { + $payload = [ + 'payload' => new Binary($data), + 'last_activity' => $this->getUTCDateTime(), + 'expires_at' => $this->getUTCDateTime($this->minutes * 60), + ]; + + if (! $this->container) { + return $payload; + } + + return tap($payload, function (&$payload) { + $this->addUserInformation($payload) + ->addRequestInformation($payload); + }); + } + + private function getCollection(): Collection + { + return $this->collection ??= $this->connection->getCollection($this->table); + } + + private function getUTCDateTime(int $additionalSeconds = 0): UTCDateTime + { + return new UTCDateTime((time() + $additionalSeconds) * 1000); + } +} diff --git a/src/Validation/DatabasePresenceVerifier.php b/src/Validation/DatabasePresenceVerifier.php index c5c378539..fdd783ab5 100644 --- a/src/Validation/DatabasePresenceVerifier.php +++ b/src/Validation/DatabasePresenceVerifier.php @@ -5,6 +5,7 @@ namespace MongoDB\Laravel\Validation; use MongoDB\BSON\Regex; +use Override; use function array_map; use function implode; @@ -12,17 +13,8 @@ class DatabasePresenceVerifier extends \Illuminate\Validation\DatabasePresenceVerifier { - /** - * Count the number of objects in a collection having the given value. - * - * @param string $collection - * @param string $column - * @param string $value - * @param int $excludeId - * @param string $idColumn - * - * @return int - */ + /** Count the number of objects in a collection having the given value. */ + #[Override] public function getCount($collection, $column, $value, $excludeId = null, $idColumn = null, array $extra = []) { $query = $this->table($collection)->where($column, new Regex('^' . preg_quote($value) . '$', '/i')); @@ -38,16 +30,8 @@ public function getCount($collection, $column, $value, $excludeId = null, $idCol return $query->count(); } - /** - * Count the number of objects in a collection with the given values. - * - * @param string $collection - * @param string $column - * @param array $values - * @param array $extra - * - * @return int - */ + /** Count the number of objects in a collection with the given values. */ + #[Override] public function getMultiCount($collection, $column, array $values, array $extra = []) { // Nothing can match an empty array. Return early to avoid matching an empty string. diff --git a/src/Validation/ValidationServiceProvider.php b/src/Validation/ValidationServiceProvider.php index 1095e93a3..6f7ebd980 100644 --- a/src/Validation/ValidationServiceProvider.php +++ b/src/Validation/ValidationServiceProvider.php @@ -5,9 +5,11 @@ namespace MongoDB\Laravel\Validation; use Illuminate\Validation\ValidationServiceProvider as BaseProvider; +use Override; class ValidationServiceProvider extends BaseProvider { + #[Override] protected function registerPresenceVerifier() { $this->app->singleton('validation.presence', function ($app) { diff --git a/tests/AtlasSearchTest.php b/tests/AtlasSearchTest.php new file mode 100644 index 000000000..43848c09a --- /dev/null +++ b/tests/AtlasSearchTest.php @@ -0,0 +1,266 @@ +addVector([ + ['title' => 'Introduction to Algorithms'], + ['title' => 'Clean Code: A Handbook of Agile Software Craftsmanship'], + ['title' => 'Design Patterns: Elements of Reusable Object-Oriented Software'], + ['title' => 'The Pragmatic Programmer: Your Journey to Mastery'], + ['title' => 'Artificial Intelligence: A Modern Approach'], + ['title' => 'Structure and Interpretation of Computer Programs'], + ['title' => 'Code Complete: A Practical Handbook of Software Construction'], + ['title' => 'The Art of Computer Programming'], + ['title' => 'Computer Networks'], + ['title' => 'Operating System Concepts'], + ['title' => 'Database System Concepts'], + ['title' => 'Compilers: Principles, Techniques, and Tools'], + ['title' => 'Introduction to the Theory of Computation'], + ['title' => 'Modern Operating Systems'], + ['title' => 'Computer Organization and Design'], + ['title' => 'The Mythical Man-Month: Essays on Software Engineering'], + ['title' => 'Algorithms'], + ['title' => 'Understanding Machine Learning: From Theory to Algorithms'], + ['title' => 'Deep Learning'], + ['title' => 'Pattern Recognition and Machine Learning'], + ])); + + $collection = $this->getConnection('mongodb')->getCollection('books'); + assert($collection instanceof MongoDBCollection); + + try { + $collection->createSearchIndex([ + 'mappings' => [ + 'fields' => [ + 'title' => [ + ['type' => 'string', 'analyzer' => 'lucene.english'], + ['type' => 'autocomplete', 'analyzer' => 'lucene.english'], + ['type' => 'token'], + ], + ], + ], + ]); + + $collection->createSearchIndex([ + 'mappings' => ['dynamic' => true], + ], ['name' => 'dynamic_search']); + + $collection->createSearchIndex([ + 'fields' => [ + ['type' => 'vector', 'numDimensions' => 4, 'path' => 'vector4', 'similarity' => 'cosine'], + ['type' => 'vector', 'numDimensions' => 32, 'path' => 'vector32', 'similarity' => 'euclidean'], + ['type' => 'filter', 'path' => 'title'], + ], + ], ['name' => 'vector', 'type' => 'vectorSearch']); + } catch (ServerException $e) { + if (Builder::isAtlasSearchNotSupportedException($e)) { + self::markTestSkipped('Atlas Search not supported. ' . $e->getMessage()); + } + + throw $e; + } + + // Wait for the index to be ready + do { + $ready = true; + usleep(10_000); + foreach ($collection->listSearchIndexes() as $index) { + if ($index['status'] !== 'READY') { + $ready = false; + } + } + } while (! $ready); + } + + public function tearDown(): void + { + $this->getConnection('mongodb')->getCollection('books')->drop(); + + parent::tearDown(); + } + + public function testGetIndexes() + { + $indexes = Schema::getIndexes('books'); + + self::assertIsArray($indexes); + self::assertCount(4, $indexes); + + // Order of indexes is not guaranteed + usort($indexes, fn ($a, $b) => $a['name'] <=> $b['name']); + + $expected = [ + [ + 'name' => '_id_', + 'columns' => ['_id'], + 'primary' => true, + 'type' => null, + 'unique' => false, + ], + [ + 'name' => 'default', + 'columns' => ['title'], + 'type' => 'search', + 'primary' => false, + 'unique' => false, + ], + [ + 'name' => 'dynamic_search', + 'columns' => ['dynamic'], + 'type' => 'search', + 'primary' => false, + 'unique' => false, + ], + [ + 'name' => 'vector', + 'columns' => ['vector4', 'vector32', 'title'], + 'type' => 'vectorSearch', + 'primary' => false, + 'unique' => false, + ], + ]; + + self::assertSame($expected, $indexes); + } + + public function testEloquentBuilderSearch() + { + $results = Book::search( + sort: ['title' => 1], + operator: Search::text('title', 'systems'), + ); + + self::assertInstanceOf(EloquentCollection::class, $results); + self::assertCount(3, $results); + self::assertInstanceOf(Book::class, $results->first()); + self::assertSame([ + 'Database System Concepts', + 'Modern Operating Systems', + 'Operating System Concepts', + ], $results->pluck('title')->all()); + } + + public function testDatabaseBuilderSearch() + { + $results = $this->getConnection('mongodb')->table('books') + ->search(Search::text('title', 'systems'), sort: ['title' => 1]); + + self::assertInstanceOf(LaravelCollection::class, $results); + self::assertCount(3, $results); + self::assertIsArray($results->first()); + self::assertSame([ + 'Database System Concepts', + 'Modern Operating Systems', + 'Operating System Concepts', + ], $results->pluck('title')->all()); + } + + public function testEloquentBuilderAutocomplete() + { + $results = Book::autocomplete('title', 'system'); + + self::assertInstanceOf(LaravelCollection::class, $results); + self::assertCount(3, $results); + self::assertSame([ + 'Database System Concepts', + 'Modern Operating Systems', + 'Operating System Concepts', + ], $results->sort()->values()->all()); + } + + public function testDatabaseBuilderAutocomplete() + { + $results = $this->getConnection('mongodb')->table('books') + ->autocomplete('title', 'system'); + + self::assertInstanceOf(LaravelCollection::class, $results); + self::assertCount(3, $results); + self::assertSame([ + 'Database System Concepts', + 'Modern Operating Systems', + 'Operating System Concepts', + ], $results->sort()->values()->all()); + } + + public function testDatabaseBuilderVectorSearch() + { + $results = $this->getConnection('mongodb')->table('books') + ->vectorSearch( + index: 'vector', + path: 'vector4', + queryVector: $this->vectors[7], // This is an exact match of the vector + limit: 4, + exact: true, + ); + + self::assertInstanceOf(LaravelCollection::class, $results); + self::assertCount(4, $results); + self::assertSame('The Art of Computer Programming', $results->first()['title']); + self::assertSame(1.0, $results->first()['vectorSearchScore']); + } + + public function testEloquentBuilderVectorSearch() + { + $results = Book::vectorSearch( + index: 'vector', + path: 'vector4', + queryVector: $this->vectors[7], + limit: 5, + numCandidates: 15, + // excludes the exact match + filter: Query::query( + title: Query::ne('The Art of Computer Programming'), + ), + ); + + self::assertInstanceOf(EloquentCollection::class, $results); + self::assertCount(5, $results); + self::assertInstanceOf(Book::class, $results->first()); + self::assertNotSame('The Art of Computer Programming', $results->first()->title); + self::assertSame('The Mythical Man-Month: Essays on Software Engineering', $results->first()->title); + self::assertThat( + $results->first()->vectorSearchScore, + self::logicalAnd(self::isType('float'), self::greaterThan(0.9), self::lessThan(1.0)), + ); + } + + /** Generate random vectors using fixed seed to make tests deterministic */ + private function addVector(array $items): array + { + srand(1); + foreach ($items as &$item) { + $this->vectors[] = $item['vector4'] = array_map(fn () => rand() / mt_getrandmax(), range(0, 3)); + } + + return $items; + } +} diff --git a/tests/AuthTest.php b/tests/AuthTest.php index 98d42832e..998c07f2d 100644 --- a/tests/AuthTest.php +++ b/tests/AuthTest.php @@ -17,10 +17,10 @@ class AuthTest extends TestCase { public function tearDown(): void { - parent::setUp(); - User::truncate(); DB::table('password_reset_tokens')->truncate(); + + parent::tearDown(); } public function testAuthAttempt() diff --git a/tests/Casts/EncryptionTest.php b/tests/Casts/EncryptionTest.php index 0c40254f1..acb7520cc 100644 --- a/tests/Casts/EncryptionTest.php +++ b/tests/Casts/EncryptionTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Casts; +namespace MongoDB\Laravel\Tests\Casts; use Illuminate\Database\Eloquent\Casts\Json; use Illuminate\Encryption\Encrypter; diff --git a/tests/ConnectionTest.php b/tests/ConnectionTest.php index 1efd17be0..de77da7f7 100644 --- a/tests/ConnectionTest.php +++ b/tests/ConnectionTest.php @@ -48,23 +48,23 @@ public function testDisconnectAndCreateNewConnection() { $connection = DB::connection('mongodb'); $this->assertInstanceOf(Connection::class, $connection); - $client = $connection->getMongoClient(); + $client = $connection->getClient(); $this->assertInstanceOf(Client::class, $client); $connection->disconnect(); - $client = $connection->getMongoClient(); + $client = $connection->getClient(); $this->assertNull($client); DB::purge('mongodb'); $connection = DB::connection('mongodb'); $this->assertInstanceOf(Connection::class, $connection); - $client = $connection->getMongoClient(); + $client = $connection->getClient(); $this->assertInstanceOf(Client::class, $client); } public function testDb() { $connection = DB::connection('mongodb'); - $this->assertInstanceOf(Database::class, $connection->getMongoDB()); - $this->assertInstanceOf(Client::class, $connection->getMongoClient()); + $this->assertInstanceOf(Database::class, $connection->getDatabase()); + $this->assertInstanceOf(Client::class, $connection->getClient()); } public static function dataConnectionConfig(): Generator @@ -190,20 +190,63 @@ public static function dataConnectionConfig(): Generator 'expectedDatabaseName' => 'tests', 'config' => ['dsn' => 'mongodb://some-host:12345/tests'], ]; + + yield 'Database is extracted from DSN with CA path in options' => [ + 'expectedUri' => 'mongodb://some-host:12345/tests?tls=true&tlsCAFile=/path/to/ca.pem&retryWrites=false', + 'expectedDatabaseName' => 'tests', + 'config' => ['dsn' => 'mongodb://some-host:12345/tests?tls=true&tlsCAFile=/path/to/ca.pem&retryWrites=false'], + ]; } #[DataProvider('dataConnectionConfig')] public function testConnectionConfig(string $expectedUri, string $expectedDatabaseName, array $config): void { $connection = new Connection($config); - $client = $connection->getMongoClient(); + $client = $connection->getClient(); $this->assertSame($expectedUri, (string) $client); - $this->assertSame($expectedDatabaseName, $connection->getMongoDB()->getDatabaseName()); + $this->assertSame($expectedDatabaseName, $connection->getDatabase()->getDatabaseName()); $this->assertSame('foo', $connection->getCollection('foo')->getCollectionName()); $this->assertSame('foo', $connection->table('foo')->raw()->getCollectionName()); } + public function testLegacyGetMongoClient(): void + { + $connection = DB::connection('mongodb'); + $expected = $connection->getClient(); + + $this->assertSame($expected, $connection->getMongoClient()); + } + + public function testLegacyGetMongoDB(): void + { + $connection = DB::connection('mongodb'); + $expected = $connection->getDatabase(); + + $this->assertSame($expected, $connection->getMongoDB()); + } + + public function testGetDatabase(): void + { + $connection = DB::connection('mongodb'); + $defaultName = env('MONGODB_DATABASE', 'unittest'); + $database = $connection->getDatabase(); + + $this->assertInstanceOf(Database::class, $database); + $this->assertSame($defaultName, $database->getDatabaseName()); + $this->assertSame($database, $connection->getDatabase($defaultName), 'Same instance for the default database'); + } + + public function testGetOtherDatabase(): void + { + $connection = DB::connection('mongodb'); + $name = 'other_random_database'; + $database = $connection->getDatabase($name); + + $this->assertInstanceOf(Database::class, $database); + $this->assertSame($name, $database->getDatabaseName($name)); + } + public function testConnectionWithoutConfiguredDatabase(): void { $this->expectException(InvalidArgumentException::class); @@ -252,6 +295,8 @@ public function testQueryLog() DB::table('items')->get(); $this->assertCount(1, $logs = DB::getQueryLog()); $this->assertJsonStringEqualsJsonString('{"find":"items","filter":{}}', $logs[0]['query']); + $this->assertLessThan(10, $logs[0]['time'], 'Query time is in milliseconds'); + $this->assertGreaterThan(0.01, $logs[0]['time'], 'Query time is in milliseconds'); DB::table('items')->insert(['id' => $id = new ObjectId(), 'name' => 'test']); $this->assertCount(2, $logs = DB::getQueryLog()); diff --git a/tests/DateTimeImmutableTest.php b/tests/DateTimeImmutableTest.php new file mode 100644 index 000000000..7fd6fa2b1 --- /dev/null +++ b/tests/DateTimeImmutableTest.php @@ -0,0 +1,42 @@ + 'John', + 'anniversary' => new CarbonImmutable('2020-01-01 00:00:00'), + ]); + + $anniversary = Anniversary::sole(); + assert($anniversary instanceof Anniversary); + self::assertInstanceOf(CarbonImmutable::class, $anniversary->anniversary); + } +} diff --git a/tests/Eloquent/CallBuilderTest.php b/tests/Eloquent/CallBuilderTest.php index fa4cb4580..39643f1c1 100644 --- a/tests/Eloquent/CallBuilderTest.php +++ b/tests/Eloquent/CallBuilderTest.php @@ -21,6 +21,8 @@ final class CallBuilderTest extends TestCase protected function tearDown(): void { User::truncate(); + + parent::tearDown(); } #[Dataprovider('provideFunctionNames')] diff --git a/tests/Eloquent/MassPrunableTest.php b/tests/Eloquent/MassPrunableTest.php index 0f6f2ab15..884f90ac6 100644 --- a/tests/Eloquent/MassPrunableTest.php +++ b/tests/Eloquent/MassPrunableTest.php @@ -20,6 +20,8 @@ public function tearDown(): void { User::truncate(); Soft::truncate(); + + parent::tearDown(); } public function testPruneWithQuery(): void diff --git a/tests/EmbeddedRelationsTest.php b/tests/EmbeddedRelationsTest.php index 8ee8297f7..1c68e2d34 100644 --- a/tests/EmbeddedRelationsTest.php +++ b/tests/EmbeddedRelationsTest.php @@ -20,6 +20,8 @@ public function tearDown(): void { Mockery::close(); User::truncate(); + + parent::tearDown(); } public function testEmbedsManySave() diff --git a/tests/FilesystemsTest.php b/tests/FilesystemsTest.php index 3b9fa8e5f..7b8141905 100644 --- a/tests/FilesystemsTest.php +++ b/tests/FilesystemsTest.php @@ -56,7 +56,7 @@ public static function provideValidOptions(): Generator 'driver' => 'gridfs', 'bucket' => static fn (Application $app) => $app['db'] ->connection('mongodb') - ->getMongoDB() + ->getDatabase() ->selectGridFSBucket(), ], ]; @@ -68,7 +68,7 @@ public function testValidOptions(array $options) // Service used by "bucket-service" $this->app->singleton('bucket', static fn (Application $app) => $app['db'] ->connection('mongodb') - ->getMongoDB() + ->getDatabase() ->selectGridFSBucket()); $this->app['config']->set('filesystems.disks.' . $this->dataName(), $options); @@ -145,6 +145,6 @@ public function testPrefix() private function getBucket(): Bucket { - return DB::connection('mongodb')->getMongoDB()->selectGridFSBucket(); + return DB::connection('mongodb')->getDatabase()->selectGridFSBucket(); } } diff --git a/tests/GeospatialTest.php b/tests/GeospatialTest.php index 724bb580b..b29a3240a 100644 --- a/tests/GeospatialTest.php +++ b/tests/GeospatialTest.php @@ -53,6 +53,8 @@ public function setUp(): void public function tearDown(): void { Schema::drop('locations'); + + parent::tearDown(); } public function testGeoWithin() diff --git a/tests/HybridRelationsTest.php b/tests/HybridRelationsTest.php index 71958d27d..71fb0830b 100644 --- a/tests/HybridRelationsTest.php +++ b/tests/HybridRelationsTest.php @@ -42,6 +42,8 @@ public function tearDown(): void Skill::truncate(); Experience::truncate(); Label::truncate(); + + parent::tearDown(); } public function testSqlRelations() @@ -76,7 +78,7 @@ public function testSqlRelations() $this->assertEquals('John Doe', $role->sqlUser->name); // MongoDB User - $user = new User(); + $user = new User(); $user->name = 'John Doe'; $user->save(); @@ -103,7 +105,7 @@ public function testSqlRelations() public function testHybridWhereHas() { - $user = new SqlUser(); + $user = new SqlUser(); $otherUser = new SqlUser(); $this->assertInstanceOf(SqlUser::class, $user); $this->assertInstanceOf(SQLiteConnection::class, $user->getConnection()); @@ -112,11 +114,11 @@ public function testHybridWhereHas() // SQL User $user->name = 'John Doe'; - $user->id = 2; + $user->id = 2; $user->save(); // Other user $otherUser->name = 'Other User'; - $otherUser->id = 3; + $otherUser->id = 3; $otherUser->save(); // Make sure they are created $this->assertIsInt($user->id); @@ -157,7 +159,7 @@ public function testHybridWhereHas() public function testHybridWith() { - $user = new SqlUser(); + $user = new SqlUser(); $otherUser = new SqlUser(); $this->assertInstanceOf(SqlUser::class, $user); $this->assertInstanceOf(SQLiteConnection::class, $user->getConnection()); @@ -166,11 +168,11 @@ public function testHybridWith() // SQL User $user->name = 'John Doe'; - $user->id = 2; + $user->id = 2; $user->save(); // Other user $otherUser->name = 'Other User'; - $otherUser->id = 3; + $otherUser->id = 3; $otherUser->save(); // Make sure they are created $this->assertIsInt($user->id); @@ -266,6 +268,23 @@ public function testHybridBelongsToMany() $this->assertEquals(1, $check->skills->count()); } + public function testQueryingHybridBelongsToManyRelationFails() + { + $user = new SqlUser(); + $this->assertInstanceOf(SQLiteConnection::class, $user->getConnection()); + + // Create Mysql Users + $user->fill(['name' => 'John Doe'])->save(); + $skill = Skill::query()->create(['name' => 'MongoDB']); + $user->skills()->save($skill); + + $this->expectExceptionMessage('BelongsToMany is not supported for hybrid query constraints.'); + + SqlUser::whereHas('skills', function ($query) { + return $query->where('name', 'LIKE', 'MongoDB'); + }); + } + public function testHybridMorphToManySqlModelToMongoModel() { // SqlModel -> MorphToMany -> MongoModel diff --git a/tests/ModelTest.php b/tests/ModelTest.php index c532eea55..ecfcb2b6a 100644 --- a/tests/ModelTest.php +++ b/tests/ModelTest.php @@ -56,6 +56,8 @@ public function tearDown(): void Book::truncate(); Item::truncate(); Guarded::truncate(); + + parent::tearDown(); } public function testNewModel(): void @@ -406,8 +408,9 @@ public function testSoftDelete(): void $this->assertEquals(2, Soft::count()); } + /** @param class-string $model */ #[DataProvider('provideId')] - public function testPrimaryKey(string $model, $id, $expected, bool $expectedFound): void + public function testPrimaryKey(string $model, mixed $id, mixed $expected, bool $expectedFound): void { $model::truncate(); $expectedType = get_debug_type($expected); diff --git a/tests/Models/Anniversary.php b/tests/Models/Anniversary.php new file mode 100644 index 000000000..c37196c16 --- /dev/null +++ b/tests/Models/Anniversary.php @@ -0,0 +1,24 @@ + 'immutable_datetime']; +} diff --git a/tests/Models/HiddenAnimal.php b/tests/Models/HiddenAnimal.php index 240238da0..f6217177c 100644 --- a/tests/Models/HiddenAnimal.php +++ b/tests/Models/HiddenAnimal.php @@ -6,17 +6,11 @@ use Illuminate\Database\Eloquent\Model; use MongoDB\Laravel\Eloquent\DocumentModel; -use MongoDB\Laravel\Eloquent\Model as Eloquent; -use MongoDB\Laravel\Query\Builder; /** * @property string $name * @property string $country * @property bool $can_be_eaten - * @mixin Eloquent - * @method static Builder create(...$values) - * @method static Builder truncate() - * @method static Eloquent sole(...$parameters) */ final class HiddenAnimal extends Model { diff --git a/tests/Models/SchemaVersion.php b/tests/Models/SchemaVersion.php index 8acd73545..b142d8bda 100644 --- a/tests/Models/SchemaVersion.php +++ b/tests/Models/SchemaVersion.php @@ -5,9 +5,9 @@ namespace MongoDB\Laravel\Tests\Models; use MongoDB\Laravel\Eloquent\HasSchemaVersion; -use MongoDB\Laravel\Eloquent\Model as Eloquent; +use MongoDB\Laravel\Eloquent\Model; -class SchemaVersion extends Eloquent +class SchemaVersion extends Model { use HasSchemaVersion; diff --git a/tests/Models/Soft.php b/tests/Models/Soft.php index f887d05a9..999d13fd4 100644 --- a/tests/Models/Soft.php +++ b/tests/Models/Soft.php @@ -6,10 +6,10 @@ use Carbon\Carbon; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\SoftDeletes; use MongoDB\Laravel\Eloquent\Builder; use MongoDB\Laravel\Eloquent\DocumentModel; use MongoDB\Laravel\Eloquent\MassPrunable; -use MongoDB\Laravel\Eloquent\SoftDeletes; /** @property Carbon $deleted_at */ class Soft extends Model diff --git a/tests/PropertyTest.php b/tests/PropertyTest.php index c71fd68c9..67153006b 100644 --- a/tests/PropertyTest.php +++ b/tests/PropertyTest.php @@ -2,10 +2,9 @@ declare(strict_types=1); -namespace MongoDB\Laravel\Tests\Eloquent; +namespace MongoDB\Laravel\Tests; use MongoDB\Laravel\Tests\Models\HiddenAnimal; -use MongoDB\Laravel\Tests\TestCase; use function assert; diff --git a/tests/Query/BuilderTest.php b/tests/Query/BuilderTest.php index 20f4a4db2..a7bcc64cb 100644 --- a/tests/Query/BuilderTest.php +++ b/tests/Query/BuilderTest.php @@ -12,9 +12,9 @@ use Illuminate\Tests\Database\DatabaseQueryBuilderTest; use InvalidArgumentException; use LogicException; -use Mockery as m; use MongoDB\BSON\Regex; use MongoDB\BSON\UTCDateTime; +use MongoDB\Driver\ReadPreference; use MongoDB\Laravel\Connection; use MongoDB\Laravel\Query\Builder; use MongoDB\Laravel\Query\Grammar; @@ -38,7 +38,7 @@ public function testMql(array $expected, Closure $build, ?string $requiredMethod $this->markTestSkipped(sprintf('Method "%s::%s()" does not exist.', Builder::class, $requiredMethod)); } - $builder = $build(self::getBuilder()); + $builder = $build($this->getBuilder()); $this->assertInstanceOf(Builder::class, $builder); $mql = $builder->toMql(); @@ -868,7 +868,7 @@ function (Builder $builder) { [], ], ], - fn (Builder $builder) => $builder->whereDate('created_at', '=', new DateTimeImmutable('2018-09-30 15:00:00 +02:00')), + fn (Builder $builder) => $builder->whereDate('created_at', '=', new DateTimeImmutable('2018-09-30 15:00:00 +00:00')), ]; yield 'where date !=' => [ @@ -1416,12 +1416,37 @@ function (Builder $elemMatchQuery): void { ['find' => [['embedded._id' => 1], []]], fn (Builder $builder) => $builder->where('embedded->id', 1), ]; + + yield 'options' => [ + ['find' => [[], ['comment' => 'hello']]], + fn (Builder $builder) => $builder->options(['comment' => 'hello']), + ]; + + yield 'readPreference' => [ + ['find' => [[], ['readPreference' => new ReadPreference(ReadPreference::SECONDARY_PREFERRED)]]], + fn (Builder $builder) => $builder->readPreference(ReadPreference::SECONDARY_PREFERRED), + ]; + + yield 'readPreference advanced' => [ + ['find' => [[], ['readPreference' => new ReadPreference(ReadPreference::NEAREST, [['dc' => 'ny']], ['maxStalenessSeconds' => 120])]]], + fn (Builder $builder) => $builder->readPreference(ReadPreference::NEAREST, [['dc' => 'ny']], ['maxStalenessSeconds' => 120]), + ]; + + yield 'hint' => [ + ['find' => [[], ['hint' => ['foo' => 1]]]], + fn (Builder $builder) => $builder->hint(['foo' => 1]), + ]; + + yield 'timeout' => [ + ['find' => [[], ['maxTimeMS' => 2345]]], + fn (Builder $builder) => $builder->timeout(2.3456), + ]; } #[DataProvider('provideExceptions')] public function testException($class, $message, Closure $build): void { - $builder = self::getBuilder(); + $builder = $this->getBuilder(); $this->expectException($class); $this->expectExceptionMessage($message); @@ -1519,7 +1544,7 @@ public static function provideExceptions(): iterable #[DataProvider('getEloquentMethodsNotSupported')] public function testEloquentMethodsNotSupported(Closure $callback) { - $builder = self::getBuilder(); + $builder = $this->getBuilder(); $this->expectException(BadMethodCallException::class); $this->expectExceptionMessage('This method is not supported by MongoDB'); @@ -1574,12 +1599,95 @@ public static function getEloquentMethodsNotSupported() yield 'orWhereIntegerNotInRaw' => [fn (Builder $builder) => $builder->orWhereIntegerNotInRaw('id', ['1a', 2])]; } - private static function getBuilder(): Builder + #[DataProvider('provideDisableRenameEmbeddedIdField')] + public function testDisableRenameEmbeddedIdField(array $expected, Closure $build) + { + $builder = $this->getBuilder(false); + $this->assertFalse($builder->getConnection()->getRenameEmbeddedIdField()); + + $mql = $build($builder)->toMql(); + + $this->assertEquals($expected, $mql); + } + + public static function provideDisableRenameEmbeddedIdField() + { + yield 'rename embedded id field' => [ + [ + 'find' => [ + [ + '$and' => [ + ['_id' => 10], + ['nested.id' => 20], + ['embed' => ['id' => 30]], + ], + ], + ['typeMap' => ['root' => 'object', 'document' => 'array']], + ], + ], + fn (Builder $builder) => $builder->where('id', '=', 10) + ->where('nested.id', '=', 20) + ->where('embed', '=', ['id' => 30]), + ]; + + yield 'rename root id' => [ + ['find' => [['_id' => 10], ['typeMap' => ['root' => 'object', 'document' => 'array']]]], + fn (Builder $builder) => $builder->where('id', '=', 10), + ]; + + yield 'nested id not renamed' => [ + ['find' => [['nested.id' => 20], ['typeMap' => ['root' => 'object', 'document' => 'array']]]], + fn (Builder $builder) => $builder->where('nested.id', '=', 20), + ]; + + yield 'embed id not renamed' => [ + ['find' => [['embed' => ['id' => 30]], ['typeMap' => ['root' => 'object', 'document' => 'array']]]], + fn (Builder $builder) => $builder->where('embed', '=', ['id' => 30]), + ]; + + yield 'nested $and in $or' => [ + [ + 'find' => [ + [ + '$or' => [ + [ + '$and' => [ + ['_id' => 10], + ['nested.id' => 20], + ['embed' => ['id' => 30]], + ], + ], + [ + '$and' => [ + ['_id' => 40], + ['nested.id' => 50], + ['embed' => ['id' => 60]], + ], + ], + ], + ], + ['typeMap' => ['root' => 'object', 'document' => 'array']], + ], + ], + fn (Builder $builder) => $builder->orWhere(function (Builder $builder) { + return $builder->where('id', '=', 10) + ->where('nested.id', '=', 20) + ->where('embed', '=', ['id' => 30]); + })->orWhere(function (Builder $builder) { + return $builder->where('id', '=', 40) + ->where('nested.id', '=', 50) + ->where('embed', '=', ['id' => 60]); + }), + ]; + } + + private function getBuilder(bool $renameEmbeddedIdField = true): Builder { - $connection = m::mock(Connection::class); - $processor = m::mock(Processor::class); - $connection->shouldReceive('getSession')->andReturn(null); - $connection->shouldReceive('getQueryGrammar')->andReturn(new Grammar()); + $connection = $this->createStub(Connection::class); + $connection->method('getRenameEmbeddedIdField')->willReturn($renameEmbeddedIdField); + $processor = $this->createStub(Processor::class); + $connection->method('getSession')->willReturn(null); + $connection->method('getQueryGrammar')->willReturn(new Grammar($connection)); return new Builder($connection, null, $processor); } diff --git a/tests/QueryBuilderTest.php b/tests/QueryBuilderTest.php index 136b1cf72..5c52b9003 100644 --- a/tests/QueryBuilderTest.php +++ b/tests/QueryBuilderTest.php @@ -7,6 +7,7 @@ use Carbon\Carbon; use DateTime; use DateTimeImmutable; +use Illuminate\Support\Collection as LaravelCollection; use Illuminate\Support\Facades\Date; use Illuminate\Support\Facades\DB; use Illuminate\Support\LazyCollection; @@ -19,10 +20,12 @@ use MongoDB\BSON\UTCDateTime; use MongoDB\Collection; use MongoDB\Driver\Cursor; +use MongoDB\Driver\CursorInterface; use MongoDB\Driver\Monitoring\CommandFailedEvent; use MongoDB\Driver\Monitoring\CommandStartedEvent; use MongoDB\Driver\Monitoring\CommandSubscriber; use MongoDB\Driver\Monitoring\CommandSucceededEvent; +use MongoDB\Laravel\Connection; use MongoDB\Laravel\Query\Builder; use MongoDB\Laravel\Tests\Models\Item; use MongoDB\Laravel\Tests\Models\User; @@ -32,6 +35,7 @@ use function count; use function key; use function md5; +use function method_exists; use function sort; use function strlen; @@ -41,6 +45,8 @@ public function tearDown(): void { DB::table('users')->truncate(); DB::table('items')->truncate(); + + parent::tearDown(); } public function testDeleteWithId() @@ -157,7 +163,7 @@ public function testFindWithTimeout() $id = DB::table('users')->insertGetId(['name' => 'John Doe']); $subscriber = new class implements CommandSubscriber { - public function commandStarted(CommandStartedEvent $event) + public function commandStarted(CommandStartedEvent $event): void { if ($event->getCommandName() !== 'find') { return; @@ -167,11 +173,11 @@ public function commandStarted(CommandStartedEvent $event) Assert::assertSame(1000, $event->getCommand()->maxTimeMS); } - public function commandFailed(CommandFailedEvent $event) + public function commandFailed(CommandFailedEvent $event): void { } - public function commandSucceeded(CommandSucceededEvent $event) + public function commandSucceeded(CommandSucceededEvent $event): void { } }; @@ -332,6 +338,93 @@ public function testRaw() $this->assertEquals('Jane Doe', $results[0]->name); } + public function testRawResultRenameId() + { + $connection = DB::connection('mongodb'); + self::assertInstanceOf(Connection::class, $connection); + + $date = Carbon::createFromDate(1986, 12, 31)->setTime(12, 0, 0); + User::insert([ + ['id' => 1, 'name' => 'Jane Doe', 'address' => ['id' => 11, 'city' => 'Ghent'], 'birthday' => $date], + ['id' => 2, 'name' => 'John Doe', 'address' => ['id' => 12, 'city' => 'Brussels'], 'birthday' => $date], + ]); + + // Using raw database query, result is not altered + $results = $connection->table('users')->raw(fn (Collection $collection) => $collection->find([])); + self::assertInstanceOf(CursorInterface::class, $results); + $results = $results->toArray(); + self::assertCount(2, $results); + + self::assertObjectHasProperty('_id', $results[0]); + self::assertObjectNotHasProperty('id', $results[0]); + self::assertSame(1, $results[0]->_id); + + self::assertObjectHasProperty('_id', $results[0]->address); + self::assertObjectNotHasProperty('id', $results[0]->address); + self::assertSame(11, $results[0]->address->_id); + + self::assertInstanceOf(UTCDateTime::class, $results[0]->birthday); + + // Using Eloquent query, result is transformed + self::assertTrue($connection->getRenameEmbeddedIdField()); + $results = User::raw(fn (Collection $collection) => $collection->find([])); + self::assertInstanceOf(LaravelCollection::class, $results); + self::assertCount(2, $results); + + $attributes = $results->first()->getAttributes(); + self::assertArrayHasKey('id', $attributes); + self::assertArrayNotHasKey('_id', $attributes); + self::assertSame(1, $attributes['id']); + + self::assertArrayHasKey('id', $attributes['address']); + self::assertArrayNotHasKey('_id', $attributes['address']); + self::assertSame(11, $attributes['address']['id']); + + self::assertEquals($date, $attributes['birthday']); + + // Single result + $result = User::raw(fn (Collection $collection) => $collection->findOne([], ['typeMap' => ['root' => 'object', 'document' => 'array']])); + self::assertInstanceOf(User::class, $result); + + $attributes = $result->getAttributes(); + self::assertArrayHasKey('id', $attributes); + self::assertArrayNotHasKey('_id', $attributes); + self::assertSame(1, $attributes['id']); + + self::assertArrayHasKey('id', $attributes['address']); + self::assertArrayNotHasKey('_id', $attributes['address']); + self::assertSame(11, $attributes['address']['id']); + + // Change the renameEmbeddedIdField option + $connection->setRenameEmbeddedIdField(false); + + $results = User::raw(fn (Collection $collection) => $collection->find([])); + self::assertInstanceOf(LaravelCollection::class, $results); + self::assertCount(2, $results); + + $attributes = $results->first()->getAttributes(); + self::assertArrayHasKey('id', $attributes); + self::assertArrayNotHasKey('_id', $attributes); + self::assertSame(1, $attributes['id']); + + self::assertArrayHasKey('_id', $attributes['address']); + self::assertArrayNotHasKey('id', $attributes['address']); + self::assertSame(11, $attributes['address']['_id']); + + // Single result + $result = User::raw(fn (Collection $collection) => $collection->findOne([])); + self::assertInstanceOf(User::class, $result); + + $attributes = $result->getAttributes(); + self::assertArrayHasKey('id', $attributes); + self::assertArrayNotHasKey('_id', $attributes); + self::assertSame(1, $attributes['id']); + + self::assertArrayHasKey('_id', $attributes['address']); + self::assertArrayNotHasKey('id', $attributes['address']); + self::assertSame(11, $attributes['address']['_id']); + } + public function testPush() { $id = DB::table('users')->insertGetId([ @@ -617,6 +710,59 @@ public function testSubdocumentArrayAggregate() $this->assertEquals(12, DB::table('items')->avg('amount.*.hidden')); } + public function testAggregateGroupBy() + { + DB::table('users')->insert([ + ['name' => 'John Doe', 'role' => 'admin', 'score' => 1, 'active' => true], + ['name' => 'Jane Doe', 'role' => 'admin', 'score' => 2, 'active' => true], + ['name' => 'Robert Roe', 'role' => 'user', 'score' => 4], + ]); + + $results = DB::table('users')->groupBy('role')->orderBy('role')->aggregateByGroup('count'); + $this->assertInstanceOf(LaravelCollection::class, $results); + $this->assertEquals([(object) ['role' => 'admin', 'aggregate' => 2], (object) ['role' => 'user', 'aggregate' => 1]], $results->toArray()); + + $results = DB::table('users')->groupBy('role')->orderBy('role')->aggregateByGroup('count', ['active']); + $this->assertInstanceOf(LaravelCollection::class, $results); + $this->assertEquals([(object) ['role' => 'admin', 'aggregate' => 1], (object) ['role' => 'user', 'aggregate' => 0]], $results->toArray()); + + $results = DB::table('users')->groupBy('role')->orderBy('role')->aggregateByGroup('max', ['score']); + $this->assertInstanceOf(LaravelCollection::class, $results); + $this->assertEquals([(object) ['role' => 'admin', 'aggregate' => 2], (object) ['role' => 'user', 'aggregate' => 4]], $results->toArray()); + + if (! method_exists(Builder::class, 'countByGroup')) { + $this->markTestSkipped('*byGroup functions require Laravel v11.38+'); + } + + $results = DB::table('users')->groupBy('role')->orderBy('role')->countByGroup(); + $this->assertInstanceOf(LaravelCollection::class, $results); + $this->assertEquals([(object) ['role' => 'admin', 'aggregate' => 2], (object) ['role' => 'user', 'aggregate' => 1]], $results->toArray()); + + $results = DB::table('users')->groupBy('role')->orderBy('role')->maxByGroup('score'); + $this->assertInstanceOf(LaravelCollection::class, $results); + $this->assertEquals([(object) ['role' => 'admin', 'aggregate' => 2], (object) ['role' => 'user', 'aggregate' => 4]], $results->toArray()); + + $results = DB::table('users')->groupBy('role')->orderBy('role')->minByGroup('score'); + $this->assertInstanceOf(LaravelCollection::class, $results); + $this->assertEquals([(object) ['role' => 'admin', 'aggregate' => 1], (object) ['role' => 'user', 'aggregate' => 4]], $results->toArray()); + + $results = DB::table('users')->groupBy('role')->orderBy('role')->sumByGroup('score'); + $this->assertInstanceOf(LaravelCollection::class, $results); + $this->assertEquals([(object) ['role' => 'admin', 'aggregate' => 3], (object) ['role' => 'user', 'aggregate' => 4]], $results->toArray()); + + $results = DB::table('users')->groupBy('role')->orderBy('role')->avgByGroup('score'); + $this->assertInstanceOf(LaravelCollection::class, $results); + $this->assertEquals([(object) ['role' => 'admin', 'aggregate' => 1.5], (object) ['role' => 'user', 'aggregate' => 4]], $results->toArray()); + } + + public function testAggregateByGroupException(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Aggregating by group requires zero or one columns.'); + + DB::table('users')->aggregateByGroup('max', ['foo', 'bar']); + } + public function testUpdateWithUpsert() { DB::table('items')->where('name', 'knife') @@ -906,6 +1052,55 @@ public function testIncrement() $this->assertEquals(1, $user->age); } + public function testMultiplyAndDivide() + { + DB::table('users')->insert([ + ['name' => 'John Doe', 'salary' => 88000, 'note' => 'senior'], + ['name' => 'Jane Doe', 'salary' => 64000, 'note' => 'junior'], + ['name' => 'Robert Roe', 'salary' => null], + ['name' => 'Mark Moe'], + ]); + + $user = DB::table('users')->where('name', 'John Doe')->first(); + $this->assertEquals(88000, $user->salary); + + DB::table('users')->where('name', 'John Doe')->multiply('salary', 1); + $user = DB::table('users')->where('name', 'John Doe')->first(); + $this->assertEquals(88000, $user->salary); + + DB::table('users')->where('name', 'John Doe')->divide('salary', 1); + $user = DB::table('users')->where('name', 'John Doe')->first(); + $this->assertEquals(88000, $user->salary); + + DB::table('users')->where('name', 'John Doe')->multiply('salary', 2); + $user = DB::table('users')->where('name', 'John Doe')->first(); + $this->assertEquals(176000, $user->salary); + + DB::table('users')->where('name', 'John Doe')->divide('salary', 2); + $user = DB::table('users')->where('name', 'John Doe')->first(); + $this->assertEquals(88000, $user->salary); + + DB::table('users')->where('name', 'Jane Doe')->multiply('salary', 10, ['note' => 'senior']); + $user = DB::table('users')->where('name', 'Jane Doe')->first(); + $this->assertEquals(640000, $user->salary); + $this->assertEquals('senior', $user->note); + + DB::table('users')->where('name', 'John Doe')->divide('salary', 2, ['note' => 'junior']); + $user = DB::table('users')->where('name', 'John Doe')->first(); + $this->assertEquals(44000, $user->salary); + $this->assertEquals('junior', $user->note); + + DB::table('users')->multiply('salary', 1); + $user = DB::table('users')->where('name', 'John Doe')->first(); + $this->assertEquals(44000, $user->salary); + $user = DB::table('users')->where('name', 'Jane Doe')->first(); + $this->assertEquals(640000, $user->salary); + $user = DB::table('users')->where('name', 'Robert Roe')->first(); + $this->assertNull($user->salary); + $user = DB::table('users')->where('name', 'Mark Moe')->first(); + $this->assertFalse(isset($user->salary)); + } + public function testProjections() { DB::table('items')->insert([ diff --git a/tests/QueryTest.php b/tests/QueryTest.php index 78a7b1bee..4fd362ae9 100644 --- a/tests/QueryTest.php +++ b/tests/QueryTest.php @@ -411,6 +411,8 @@ public function testExists(): void { $this->assertFalse(User::where('age', '>', 37)->exists()); $this->assertTrue(User::where('age', '<', 37)->exists()); + $this->assertTrue(User::where('age', '>', 37)->doesntExist()); + $this->assertFalse(User::where('age', '<', 37)->doesntExist()); } public function testSubQuery(): void diff --git a/tests/QueueTest.php b/tests/QueueTest.php index efc8f07ff..4de63391d 100644 --- a/tests/QueueTest.php +++ b/tests/QueueTest.php @@ -15,7 +15,7 @@ use MongoDB\Laravel\Queue\MongoQueue; use function app; -use function json_encode; +use function json_decode; class QueueTest extends TestCase { @@ -42,17 +42,16 @@ public function testQueueJobLifeCycle(): void $job = Queue::pop('test'); $this->assertInstanceOf(MongoJob::class, $job); $this->assertEquals(1, $job->isReserved()); - $this->assertEquals(json_encode([ - 'uuid' => $uuid, - 'displayName' => 'test', - 'job' => 'test', - 'maxTries' => null, - 'maxExceptions' => null, - 'failOnTimeout' => false, - 'backoff' => null, - 'timeout' => null, - 'data' => ['action' => 'QueueJobLifeCycle'], - ]), $job->getRawBody()); + $payload = json_decode($job->getRawBody(), true); + $this->assertEquals($uuid, $payload['uuid']); + $this->assertEquals('test', $payload['displayName']); + $this->assertEquals('test', $payload['job']); + $this->assertNull($payload['maxTries']); + $this->assertNull($payload['maxExceptions']); + $this->assertFalse($payload['failOnTimeout']); + $this->assertNull($payload['backoff']); + $this->assertNull($payload['timeout']); + $this->assertEquals(['action' => 'QueueJobLifeCycle'], $payload['data']); // Remove reserved job $job->delete(); diff --git a/tests/RelationsTest.php b/tests/RelationsTest.php index a58fef02f..643e00e6a 100644 --- a/tests/RelationsTest.php +++ b/tests/RelationsTest.php @@ -35,6 +35,9 @@ public function tearDown(): void Photo::truncate(); Label::truncate(); Skill::truncate(); + Soft::truncate(); + + parent::tearDown(); } public function testHasMany(): void diff --git a/tests/SchemaTest.php b/tests/SchemaTest.php index ff3dfe626..9726eb705 100644 --- a/tests/SchemaTest.php +++ b/tests/SchemaTest.php @@ -8,313 +8,343 @@ use Illuminate\Support\Facades\Schema; use MongoDB\BSON\Binary; use MongoDB\BSON\UTCDateTime; +use MongoDB\Collection; +use MongoDB\Database; use MongoDB\Laravel\Schema\Blueprint; +use MongoDB\Model\IndexInfo; +use function assert; use function collect; use function count; +use function sprintf; class SchemaTest extends TestCase { + private const COLL_1 = 'new_collection'; + private const COLL_2 = 'new_collection_two'; + private const COLL_WITH_COLLATION = 'collection_with_collation'; + public function tearDown(): void { - Schema::drop('newcollection'); - Schema::drop('newcollection_two'); + $database = $this->getConnection('mongodb')->getDatabase(); + assert($database instanceof Database); + $database->dropCollection(self::COLL_1); + $database->dropCollection(self::COLL_2); + $database->dropCollection(self::COLL_WITH_COLLATION); + $database->dropCollection('test_view'); + + parent::tearDown(); } public function testCreate(): void { - Schema::create('newcollection'); - $this->assertTrue(Schema::hasCollection('newcollection')); - $this->assertTrue(Schema::hasTable('newcollection')); + Schema::create(self::COLL_1); + $this->assertTrue(Schema::hasCollection(self::COLL_1)); + $this->assertTrue(Schema::hasTable(self::COLL_1)); } public function testCreateWithCallback(): void { - $instance = $this; - - Schema::create('newcollection', function ($collection) use ($instance) { - $instance->assertInstanceOf(Blueprint::class, $collection); + Schema::create(self::COLL_1, static function ($collection) { + self::assertInstanceOf(Blueprint::class, $collection); }); - $this->assertTrue(Schema::hasCollection('newcollection')); + $this->assertTrue(Schema::hasCollection(self::COLL_1)); } public function testCreateWithOptions(): void { - Schema::create('newcollection_two', null, ['capped' => true, 'size' => 1024]); - $this->assertTrue(Schema::hasCollection('newcollection_two')); - $this->assertTrue(Schema::hasTable('newcollection_two')); + Schema::create(self::COLL_2, null, ['capped' => true, 'size' => 1024]); + $this->assertTrue(Schema::hasCollection(self::COLL_2)); + $this->assertTrue(Schema::hasTable(self::COLL_2)); - $collection = Schema::getCollection('newcollection_two'); + $collection = Schema::getCollection(self::COLL_2); $this->assertTrue($collection['options']['capped']); $this->assertEquals(1024, $collection['options']['size']); } + public function testCreateWithSchemaValidator(): void + { + $schema = [ + 'bsonType' => 'object', + 'required' => [ 'username' ], + 'properties' => [ + 'username' => [ + 'bsonType' => 'string', + 'description' => 'must be a string and is required', + ], + ], + ]; + + Schema::create(self::COLL_2, function (Blueprint $collection) use ($schema) { + $collection->string('username'); + $collection->jsonSchema(schema: $schema, validationAction: 'warn'); + }); + + $this->assertTrue(Schema::hasCollection(self::COLL_2)); + $this->assertTrue(Schema::hasTable(self::COLL_2)); + + $collection = Schema::getCollection(self::COLL_2); + $this->assertEquals( + ['$jsonSchema' => $schema], + $collection['options']['validator'], + ); + + $this->assertEquals( + 'warn', + $collection['options']['validationAction'], + ); + } + public function testDrop(): void { - Schema::create('newcollection'); - Schema::drop('newcollection'); - $this->assertFalse(Schema::hasCollection('newcollection')); + Schema::create(self::COLL_1); + Schema::drop(self::COLL_1); + $this->assertFalse(Schema::hasCollection(self::COLL_1)); } public function testBluePrint(): void { - $instance = $this; - - Schema::table('newcollection', function ($collection) use ($instance) { - $instance->assertInstanceOf(Blueprint::class, $collection); + Schema::table(self::COLL_1, static function ($collection) { + self::assertInstanceOf(Blueprint::class, $collection); }); - Schema::table('newcollection', function ($collection) use ($instance) { - $instance->assertInstanceOf(Blueprint::class, $collection); + Schema::table(self::COLL_1, static function ($collection) { + self::assertInstanceOf(Blueprint::class, $collection); }); } public function testIndex(): void { - Schema::table('newcollection', function ($collection) { + Schema::table(self::COLL_1, function ($collection) { $collection->index('mykey1'); }); - $index = $this->getIndex('newcollection', 'mykey1'); + $index = $this->assertIndexExists(self::COLL_1, 'mykey1_1'); $this->assertEquals(1, $index['key']['mykey1']); - Schema::table('newcollection', function ($collection) { + Schema::table(self::COLL_1, function ($collection) { $collection->index(['mykey2']); }); - $index = $this->getIndex('newcollection', 'mykey2'); + $index = $this->assertIndexExists(self::COLL_1, 'mykey2_1'); $this->assertEquals(1, $index['key']['mykey2']); - Schema::table('newcollection', function ($collection) { + Schema::table(self::COLL_1, function ($collection) { $collection->string('mykey3')->index(); }); - $index = $this->getIndex('newcollection', 'mykey3'); + $index = $this->assertIndexExists(self::COLL_1, 'mykey3_1'); $this->assertEquals(1, $index['key']['mykey3']); } public function testPrimary(): void { - Schema::table('newcollection', function ($collection) { + Schema::table(self::COLL_1, function ($collection) { $collection->string('mykey', 100)->primary(); }); - $index = $this->getIndex('newcollection', 'mykey'); + $index = $this->assertIndexExists(self::COLL_1, 'mykey_1'); $this->assertEquals(1, $index['unique']); } public function testUnique(): void { - Schema::table('newcollection', function ($collection) { + Schema::table(self::COLL_1, function ($collection) { $collection->unique('uniquekey'); }); - $index = $this->getIndex('newcollection', 'uniquekey'); + $index = $this->assertIndexExists(self::COLL_1, 'uniquekey_1'); $this->assertEquals(1, $index['unique']); } public function testDropIndex(): void { - Schema::table('newcollection', function ($collection) { + Schema::table(self::COLL_1, function ($collection) { $collection->unique('uniquekey'); $collection->dropIndex('uniquekey_1'); }); - $index = $this->getIndex('newcollection', 'uniquekey'); - $this->assertEquals(null, $index); + $this->assertIndexNotExists(self::COLL_1, 'uniquekey_1'); - Schema::table('newcollection', function ($collection) { + Schema::table(self::COLL_1, function ($collection) { $collection->unique('uniquekey'); $collection->dropIndex(['uniquekey']); }); - $index = $this->getIndex('newcollection', 'uniquekey'); - $this->assertEquals(null, $index); + $this->assertIndexNotExists(self::COLL_1, 'uniquekey_1'); - Schema::table('newcollection', function ($collection) { + Schema::table(self::COLL_1, function ($collection) { $collection->index(['field_a', 'field_b']); }); - $index = $this->getIndex('newcollection', 'field_a_1_field_b_1'); - $this->assertNotNull($index); + $this->assertIndexExists(self::COLL_1, 'field_a_1_field_b_1'); - Schema::table('newcollection', function ($collection) { + Schema::table(self::COLL_1, function ($collection) { $collection->dropIndex(['field_a', 'field_b']); }); - $index = $this->getIndex('newcollection', 'field_a_1_field_b_1'); - $this->assertFalse($index); + $this->assertIndexNotExists(self::COLL_1, 'field_a_1_field_b_1'); - Schema::table('newcollection', function ($collection) { + $indexName = 'field_a_-1_field_b_1'; + Schema::table(self::COLL_1, function ($collection) { $collection->index(['field_a' => -1, 'field_b' => 1]); }); - $index = $this->getIndex('newcollection', 'field_a_-1_field_b_1'); - $this->assertNotNull($index); + $this->assertIndexExists(self::COLL_1, $indexName); - Schema::table('newcollection', function ($collection) { + Schema::table(self::COLL_1, function ($collection) { $collection->dropIndex(['field_a' => -1, 'field_b' => 1]); }); - $index = $this->getIndex('newcollection', 'field_a_-1_field_b_1'); - $this->assertFalse($index); + $this->assertIndexNotExists(self::COLL_1, $indexName); - Schema::table('newcollection', function ($collection) { - $collection->index(['field_a', 'field_b'], 'custom_index_name'); + $indexName = 'custom_index_name'; + Schema::table(self::COLL_1, function ($collection) use ($indexName) { + $collection->index(['field_a', 'field_b'], $indexName); }); - $index = $this->getIndex('newcollection', 'custom_index_name'); - $this->assertNotNull($index); + $this->assertIndexExists(self::COLL_1, $indexName); - Schema::table('newcollection', function ($collection) { - $collection->dropIndex('custom_index_name'); + Schema::table(self::COLL_1, function ($collection) use ($indexName) { + $collection->dropIndex($indexName); }); - $index = $this->getIndex('newcollection', 'custom_index_name'); - $this->assertFalse($index); + $this->assertIndexNotExists(self::COLL_1, $indexName); } public function testDropIndexIfExists(): void { - Schema::table('newcollection', function (Blueprint $collection) { + Schema::table(self::COLL_1, function (Blueprint $collection) { $collection->unique('uniquekey'); $collection->dropIndexIfExists('uniquekey_1'); }); - $index = $this->getIndex('newcollection', 'uniquekey'); - $this->assertEquals(null, $index); + $this->assertIndexNotExists(self::COLL_1, 'uniquekey'); - Schema::table('newcollection', function (Blueprint $collection) { + Schema::table(self::COLL_1, function (Blueprint $collection) { $collection->unique('uniquekey'); $collection->dropIndexIfExists(['uniquekey']); }); - $index = $this->getIndex('newcollection', 'uniquekey'); - $this->assertEquals(null, $index); + $this->assertIndexNotExists(self::COLL_1, 'uniquekey'); - Schema::table('newcollection', function (Blueprint $collection) { + Schema::table(self::COLL_1, function (Blueprint $collection) { $collection->index(['field_a', 'field_b']); }); - $index = $this->getIndex('newcollection', 'field_a_1_field_b_1'); - $this->assertNotNull($index); + $this->assertIndexExists(self::COLL_1, 'field_a_1_field_b_1'); - Schema::table('newcollection', function (Blueprint $collection) { + Schema::table(self::COLL_1, function (Blueprint $collection) { $collection->dropIndexIfExists(['field_a', 'field_b']); }); - $index = $this->getIndex('newcollection', 'field_a_1_field_b_1'); - $this->assertFalse($index); + $this->assertIndexNotExists(self::COLL_1, 'field_a_1_field_b_1'); - Schema::table('newcollection', function (Blueprint $collection) { + Schema::table(self::COLL_1, function (Blueprint $collection) { $collection->index(['field_a', 'field_b'], 'custom_index_name'); }); - $index = $this->getIndex('newcollection', 'custom_index_name'); - $this->assertNotNull($index); + $this->assertIndexExists(self::COLL_1, 'custom_index_name'); - Schema::table('newcollection', function (Blueprint $collection) { + Schema::table(self::COLL_1, function (Blueprint $collection) { $collection->dropIndexIfExists('custom_index_name'); }); - $index = $this->getIndex('newcollection', 'custom_index_name'); - $this->assertFalse($index); + $this->assertIndexNotExists(self::COLL_1, 'custom_index_name'); } public function testHasIndex(): void { - $instance = $this; - - Schema::table('newcollection', function (Blueprint $collection) use ($instance) { + Schema::table(self::COLL_1, function (Blueprint $collection) { $collection->index('myhaskey1'); - $instance->assertTrue($collection->hasIndex('myhaskey1_1')); - $instance->assertFalse($collection->hasIndex('myhaskey1')); + $this->assertTrue($collection->hasIndex('myhaskey1_1')); + $this->assertFalse($collection->hasIndex('myhaskey1')); }); - Schema::table('newcollection', function (Blueprint $collection) use ($instance) { + Schema::table(self::COLL_1, function (Blueprint $collection) { $collection->index('myhaskey2'); - $instance->assertTrue($collection->hasIndex(['myhaskey2'])); - $instance->assertFalse($collection->hasIndex(['myhaskey2_1'])); + $this->assertTrue($collection->hasIndex(['myhaskey2'])); + $this->assertFalse($collection->hasIndex(['myhaskey2_1'])); }); - Schema::table('newcollection', function (Blueprint $collection) use ($instance) { + Schema::table(self::COLL_1, function (Blueprint $collection) { $collection->index(['field_a', 'field_b']); - $instance->assertTrue($collection->hasIndex(['field_a_1_field_b'])); - $instance->assertFalse($collection->hasIndex(['field_a_1_field_b_1'])); + $this->assertTrue($collection->hasIndex(['field_a_1_field_b'])); + $this->assertFalse($collection->hasIndex(['field_a_1_field_b_1'])); }); } public function testSparse(): void { - Schema::table('newcollection', function ($collection) { + Schema::table(self::COLL_1, function ($collection) { $collection->sparse('sparsekey'); }); - $index = $this->getIndex('newcollection', 'sparsekey'); + $index = $this->assertIndexExists(self::COLL_1, 'sparsekey_1'); $this->assertEquals(1, $index['sparse']); } public function testExpire(): void { - Schema::table('newcollection', function ($collection) { + Schema::table(self::COLL_1, function ($collection) { $collection->expire('expirekey', 60); }); - $index = $this->getIndex('newcollection', 'expirekey'); + $index = $this->assertIndexExists(self::COLL_1, 'expirekey_1'); $this->assertEquals(60, $index['expireAfterSeconds']); } public function testSoftDeletes(): void { - Schema::table('newcollection', function ($collection) { + Schema::table(self::COLL_1, function ($collection) { $collection->softDeletes(); }); - Schema::table('newcollection', function ($collection) { + Schema::table(self::COLL_1, function ($collection) { $collection->string('email')->nullable()->index(); }); - $index = $this->getIndex('newcollection', 'email'); + $index = $this->assertIndexExists(self::COLL_1, 'email_1'); $this->assertEquals(1, $index['key']['email']); } public function testFluent(): void { - Schema::table('newcollection', function ($collection) { + Schema::table(self::COLL_1, function ($collection) { $collection->string('email')->index(); $collection->string('token')->index(); $collection->timestamp('created_at'); }); - $index = $this->getIndex('newcollection', 'email'); + $index = $this->assertIndexExists(self::COLL_1, 'email_1'); $this->assertEquals(1, $index['key']['email']); - $index = $this->getIndex('newcollection', 'token'); + $index = $this->assertIndexExists(self::COLL_1, 'token_1'); $this->assertEquals(1, $index['key']['token']); } public function testGeospatial(): void { - Schema::table('newcollection', function ($collection) { + Schema::table(self::COLL_1, function ($collection) { $collection->geospatial('point'); $collection->geospatial('area', '2d'); $collection->geospatial('continent', '2dsphere'); }); - $index = $this->getIndex('newcollection', 'point'); + $index = $this->assertIndexExists(self::COLL_1, 'point_2d'); $this->assertEquals('2d', $index['key']['point']); - $index = $this->getIndex('newcollection', 'area'); + $index = $this->assertIndexExists(self::COLL_1, 'area_2d'); $this->assertEquals('2d', $index['key']['area']); - $index = $this->getIndex('newcollection', 'continent'); + $index = $this->assertIndexExists(self::COLL_1, 'continent_2dsphere'); $this->assertEquals('2dsphere', $index['key']['continent']); } public function testDummies(): void { - Schema::table('newcollection', function ($collection) { + Schema::table(self::COLL_1, function ($collection) { $collection->boolean('activated')->default(0); $collection->integer('user_id')->unsigned(); }); @@ -323,22 +353,22 @@ public function testDummies(): void public function testSparseUnique(): void { - Schema::table('newcollection', function ($collection) { + Schema::table(self::COLL_1, function ($collection) { $collection->sparse_and_unique('sparseuniquekey'); }); - $index = $this->getIndex('newcollection', 'sparseuniquekey'); + $index = $this->assertIndexExists(self::COLL_1, 'sparseuniquekey_1'); $this->assertEquals(1, $index['sparse']); $this->assertEquals(1, $index['unique']); } public function testRenameColumn(): void { - DB::connection()->table('newcollection')->insert(['test' => 'value']); - DB::connection()->table('newcollection')->insert(['test' => 'value 2']); - DB::connection()->table('newcollection')->insert(['column' => 'column value']); + DB::connection()->table(self::COLL_1)->insert(['test' => 'value']); + DB::connection()->table(self::COLL_1)->insert(['test' => 'value 2']); + DB::connection()->table(self::COLL_1)->insert(['column' => 'column value']); - $check = DB::connection()->table('newcollection')->get(); + $check = DB::connection()->table(self::COLL_1)->get(); $this->assertCount(3, $check); $this->assertObjectHasProperty('test', $check[0]); @@ -351,11 +381,11 @@ public function testRenameColumn(): void $this->assertObjectNotHasProperty('test', $check[2]); $this->assertObjectNotHasProperty('newtest', $check[2]); - Schema::table('newcollection', function (Blueprint $collection) { + Schema::table(self::COLL_1, function (Blueprint $collection) { $collection->renameColumn('test', 'newtest'); }); - $check2 = DB::connection()->table('newcollection')->get(); + $check2 = DB::connection()->table(self::COLL_1)->get(); $this->assertCount(3, $check2); $this->assertObjectHasProperty('newtest', $check2[0]); @@ -374,36 +404,46 @@ public function testRenameColumn(): void public function testHasColumn(): void { - $this->assertTrue(Schema::hasColumn('newcollection', '_id')); - $this->assertTrue(Schema::hasColumn('newcollection', 'id')); + $this->assertTrue(Schema::hasColumn(self::COLL_1, '_id')); + $this->assertTrue(Schema::hasColumn(self::COLL_1, 'id')); - DB::connection()->table('newcollection')->insert(['column1' => 'value', 'embed' => ['_id' => 1]]); + DB::connection()->table(self::COLL_1)->insert(['column1' => 'value', 'embed' => ['_id' => 1]]); - $this->assertTrue(Schema::hasColumn('newcollection', 'column1')); - $this->assertFalse(Schema::hasColumn('newcollection', 'column2')); - $this->assertTrue(Schema::hasColumn('newcollection', 'embed._id')); - $this->assertTrue(Schema::hasColumn('newcollection', 'embed.id')); + $this->assertTrue(Schema::hasColumn(self::COLL_1, 'column1')); + $this->assertFalse(Schema::hasColumn(self::COLL_1, 'column2')); + $this->assertTrue(Schema::hasColumn(self::COLL_1, 'embed._id')); + $this->assertTrue(Schema::hasColumn(self::COLL_1, 'embed.id')); } public function testHasColumns(): void { - $this->assertTrue(Schema::hasColumns('newcollection', ['_id'])); - $this->assertTrue(Schema::hasColumns('newcollection', ['id'])); + $this->assertTrue(Schema::hasColumns(self::COLL_1, ['_id'])); + $this->assertTrue(Schema::hasColumns(self::COLL_1, ['id'])); // Insert documents with both column1 and column2 - DB::connection()->table('newcollection')->insert([ + DB::connection()->table(self::COLL_1)->insert([ ['column1' => 'value1', 'column2' => 'value2'], ['column1' => 'value3'], ]); - $this->assertTrue(Schema::hasColumns('newcollection', ['column1', 'column2'])); - $this->assertFalse(Schema::hasColumns('newcollection', ['column1', 'column3'])); + $this->assertTrue(Schema::hasColumns(self::COLL_1, ['column1', 'column2'])); + $this->assertFalse(Schema::hasColumns(self::COLL_1, ['column1', 'column3'])); } public function testGetTables() { - DB::connection('mongodb')->table('newcollection')->insert(['test' => 'value']); - DB::connection('mongodb')->table('newcollection_two')->insert(['test' => 'value']); + $db = DB::connection('mongodb')->getDatabase(); + $db->createCollection(self::COLL_WITH_COLLATION, [ + 'collation' => [ + 'locale' => 'fr', + 'strength' => 2, + ], + ]); + + DB::connection('mongodb')->table(self::COLL_1)->insert(['test' => 'value']); + DB::connection('mongodb')->table(self::COLL_2)->insert(['test' => 'value']); + $db->createCollection('test_view', ['viewOn' => self::COLL_1]); + $dbName = DB::connection('mongodb')->getDatabaseName(); $tables = Schema::getTables(); $this->assertIsArray($tables); @@ -412,38 +452,103 @@ public function testGetTables() foreach ($tables as $table) { $this->assertArrayHasKey('name', $table); $this->assertArrayHasKey('size', $table); + $this->assertArrayHasKey('schema', $table); + $this->assertArrayHasKey('collation', $table); + $this->assertArrayHasKey('schema_qualified_name', $table); + $this->assertNotEquals('test_view', $table['name'], 'Standard views should not be included in the result of getTables.'); - if ($table['name'] === 'newcollection') { + if ($table['name'] === self::COLL_1) { $this->assertEquals(8192, $table['size']); + $this->assertEquals($dbName, $table['schema']); + $this->assertEquals($dbName . '.' . self::COLL_1, $table['schema_qualified_name']); + $found = true; + } + + if ($table['name'] === self::COLL_WITH_COLLATION) { + $this->assertEquals('l=fr;cl=0;cf=off;s=2;no=0;a=non-ignorable;mv=punct;n=0;b=0', $table['collation']); + } + } + + if (! $found) { + $this->fail('Collection "' . self::COLL_1 . '" not found'); + } + } + + public function testGetViews() + { + DB::connection('mongodb')->table(self::COLL_1)->insert(['test' => 'value']); + DB::connection('mongodb')->table(self::COLL_2)->insert(['test' => 'value']); + $dbName = DB::connection('mongodb')->getDatabaseName(); + + DB::connection('mongodb')->getDatabase()->createCollection('test_view', ['viewOn' => self::COLL_1]); + + $tables = Schema::getViews(); + + $this->assertIsArray($tables); + $this->assertGreaterThanOrEqual(1, count($tables)); + $found = false; + foreach ($tables as $table) { + $this->assertArrayHasKey('name', $table); + $this->assertArrayHasKey('size', $table); + $this->assertArrayHasKey('schema', $table); + $this->assertArrayHasKey('schema_qualified_name', $table); + + // Ensure "normal collections" are not in the views list + $this->assertNotEquals(self::COLL_1, $table['name'], 'Normal collections should not be included in the result of getViews.'); + + if ($table['name'] === 'test_view') { + $this->assertEquals($dbName, $table['schema']); + $this->assertEquals($dbName . '.test_view', $table['schema_qualified_name']); $found = true; } } if (! $found) { - $this->fail('Collection "newcollection" not found'); + $this->fail('Collection "test_view" not found'); } } public function testGetTableListing() { - DB::connection('mongodb')->table('newcollection')->insert(['test' => 'value']); - DB::connection('mongodb')->table('newcollection_two')->insert(['test' => 'value']); + DB::connection('mongodb')->table(self::COLL_1)->insert(['test' => 'value']); + DB::connection('mongodb')->table(self::COLL_2)->insert(['test' => 'value']); $tables = Schema::getTableListing(); $this->assertIsArray($tables); $this->assertGreaterThanOrEqual(2, count($tables)); - $this->assertContains('newcollection', $tables); - $this->assertContains('newcollection_two', $tables); + $this->assertContains(self::COLL_1, $tables); + $this->assertContains(self::COLL_2, $tables); + } + + public function testGetTableListingBySchema() + { + DB::connection('mongodb')->table(self::COLL_1)->insert(['test' => 'value']); + DB::connection('mongodb')->table(self::COLL_2)->insert(['test' => 'value']); + $dbName = DB::connection('mongodb')->getDatabaseName(); + + $tables = Schema::getTableListing([$dbName, 'database__that_does_not_exists'], schemaQualified: true); + + $this->assertIsArray($tables); + $this->assertGreaterThanOrEqual(2, count($tables)); + $this->assertContains($dbName . '.' . self::COLL_1, $tables); + $this->assertContains($dbName . '.' . self::COLL_2, $tables); + + $tables = Schema::getTableListing([$dbName, 'database__that_does_not_exists'], schemaQualified: false); + + $this->assertIsArray($tables); + $this->assertGreaterThanOrEqual(2, count($tables)); + $this->assertContains(self::COLL_1, $tables); + $this->assertContains(self::COLL_2, $tables); } public function testGetColumns() { - $collection = DB::connection('mongodb')->table('newcollection'); + $collection = DB::connection('mongodb')->table(self::COLL_1); $collection->insert(['text' => 'value', 'mixed' => ['key' => 'value']]); $collection->insert(['date' => new UTCDateTime(), 'binary' => new Binary('binary'), 'mixed' => true]); - $columns = Schema::getColumns('newcollection'); + $columns = Schema::getColumns(self::COLL_1); $this->assertIsArray($columns); $this->assertCount(5, $columns); @@ -472,46 +577,161 @@ public function testGetColumns() // Non-existent collection $columns = Schema::getColumns('missing'); $this->assertSame([], $columns); + + // Qualified table name + $columns = Schema::getColumns(DB::getDatabaseName() . '.' . self::COLL_1); + $this->assertIsArray($columns); + $this->assertCount(5, $columns); } + /** @see AtlasSearchTest::testGetIndexes() */ public function testGetIndexes() { - Schema::create('newcollection', function (Blueprint $collection) { + Schema::create(self::COLL_1, function (Blueprint $collection) { $collection->index('mykey1'); $collection->string('mykey2')->unique('unique_index'); $collection->string('mykey3')->index(); }); - $indexes = Schema::getIndexes('newcollection'); - $this->assertIsArray($indexes); - $this->assertCount(4, $indexes); - - $indexes = collect($indexes)->keyBy('name'); - - $indexes->each(function ($index) { - $this->assertIsString($index['name']); - $this->assertIsString($index['type']); - $this->assertIsArray($index['columns']); - $this->assertIsBool($index['unique']); - $this->assertIsBool($index['primary']); - }); - $this->assertTrue($indexes->get('_id_')['primary']); - $this->assertTrue($indexes->get('unique_index_1')['unique']); + $indexes = Schema::getIndexes(self::COLL_1); + self::assertIsArray($indexes); + self::assertCount(4, $indexes); + + $expected = [ + [ + 'name' => '_id_', + 'columns' => ['_id'], + 'primary' => true, + 'type' => null, + 'unique' => false, + ], + [ + 'name' => 'mykey1_1', + 'columns' => ['mykey1'], + 'primary' => false, + 'type' => null, + 'unique' => false, + ], + [ + 'name' => 'unique_index_1', + 'columns' => ['unique_index'], + 'primary' => false, + 'type' => null, + 'unique' => true, + ], + [ + 'name' => 'mykey3_1', + 'columns' => ['mykey3'], + 'primary' => false, + 'type' => null, + 'unique' => false, + ], + ]; + + self::assertSame($expected, $indexes); // Non-existent collection $indexes = Schema::getIndexes('missing'); $this->assertSame([], $indexes); } - protected function getIndex(string $collection, string $name) + public function testSearchIndex(): void { - $collection = DB::getCollection($collection); + $this->skipIfSearchIndexManagementIsNotSupported(); + + Schema::create(self::COLL_1, function (Blueprint $collection) { + $collection->searchIndex([ + 'mappings' => [ + 'dynamic' => false, + 'fields' => [ + 'foo' => ['type' => 'string', 'analyzer' => 'lucene.whitespace'], + ], + ], + ]); + }); + + $index = $this->getSearchIndex(self::COLL_1, 'default'); + self::assertNotNull($index); + + self::assertSame('default', $index['name']); + self::assertSame('search', $index['type']); + self::assertFalse($index['latestDefinition']['mappings']['dynamic']); + self::assertSame('lucene.whitespace', $index['latestDefinition']['mappings']['fields']['foo']['analyzer']); + + Schema::table(self::COLL_1, function (Blueprint $collection) { + $collection->dropSearchIndex('default'); + }); + + $index = $this->getSearchIndex(self::COLL_1, 'default'); + self::assertNull($index); + } + + public function testVectorSearchIndex() + { + $this->skipIfSearchIndexManagementIsNotSupported(); + + Schema::create(self::COLL_1, function (Blueprint $collection) { + $collection->vectorSearchIndex([ + 'fields' => [ + ['type' => 'vector', 'path' => 'foo', 'numDimensions' => 128, 'similarity' => 'euclidean', 'quantization' => 'none'], + ], + ], 'vector'); + }); + + $index = $this->getSearchIndex(self::COLL_1, 'vector'); + self::assertNotNull($index); + + self::assertSame('vector', $index['name']); + self::assertSame('vectorSearch', $index['type']); + self::assertSame('vector', $index['latestDefinition']['fields'][0]['type']); + + // Drop the index + Schema::table(self::COLL_1, function (Blueprint $collection) { + $collection->dropSearchIndex('vector'); + }); + + $index = $this->getSearchIndex(self::COLL_1, 'vector'); + self::assertNull($index); + } + + protected function assertIndexExists(string $collection, string $name): IndexInfo + { + $index = $this->getIndex($collection, $name); + + self::assertNotNull($index, sprintf('Index "%s.%s" does not exist.', $collection, $name)); + + return $index; + } + + protected function assertIndexNotExists(string $collection, string $name): void + { + $index = $this->getIndex($collection, $name); + + self::assertNull($index, sprintf('Index "%s.%s" exists.', $collection, $name)); + } + + protected function getIndex(string $collection, string $name): ?IndexInfo + { + $collection = $this->getConnection('mongodb')->getCollection($collection); + assert($collection instanceof Collection); foreach ($collection->listIndexes() as $index) { - if (isset($index['key'][$name])) { + if ($index->getName() === $name) { return $index; } } - return false; + return null; + } + + protected function getSearchIndex(string $collection, string $name): ?array + { + $collection = $this->getConnection('mongodb')->getCollection($collection); + assert($collection instanceof Collection); + + foreach ($collection->listSearchIndexes(['name' => $name, 'typeMap' => ['root' => 'array', 'array' => 'array', 'document' => 'array']]) as $index) { + return $index; + } + + return null; } } diff --git a/tests/SchemaVersionTest.php b/tests/SchemaVersionTest.php index 4a205c77b..b8048b71a 100644 --- a/tests/SchemaVersionTest.php +++ b/tests/SchemaVersionTest.php @@ -15,6 +15,8 @@ class SchemaVersionTest extends TestCase public function tearDown(): void { SchemaVersion::truncate(); + + parent::tearDown(); } public function testWithBasicDocument() diff --git a/tests/Scout/Models/ScoutUser.php b/tests/Scout/Models/ScoutUser.php new file mode 100644 index 000000000..581606f75 --- /dev/null +++ b/tests/Scout/Models/ScoutUser.php @@ -0,0 +1,43 @@ +dropIfExists('scout_users'); + $schema->create('scout_users', function (Blueprint $table) { + $table->increments('id'); + $table->string('name'); + $table->string('email')->nullable(); + $table->date('email_verified_at')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + } +} diff --git a/tests/Scout/Models/SearchableInSameNamespace.php b/tests/Scout/Models/SearchableInSameNamespace.php new file mode 100644 index 000000000..91b909067 --- /dev/null +++ b/tests/Scout/Models/SearchableInSameNamespace.php @@ -0,0 +1,30 @@ +getTable(); + } +} diff --git a/tests/Scout/Models/SearchableModel.php b/tests/Scout/Models/SearchableModel.php new file mode 100644 index 000000000..e53200f1a --- /dev/null +++ b/tests/Scout/Models/SearchableModel.php @@ -0,0 +1,50 @@ +getAttribute($this->getScoutKeyName()) ?: 'key_' . $this->getKey(); + } + + /** + * This method must be overridden when the `getScoutKey` method is also overridden, + * to support model serialization for async indexing jobs. + * + * @see Searchable::getScoutKeyName() + */ + public function getScoutKeyName(): string + { + return 'scout_key'; + } +} diff --git a/tests/Scout/ScoutEngineTest.php b/tests/Scout/ScoutEngineTest.php new file mode 100644 index 000000000..7b254ec9c --- /dev/null +++ b/tests/Scout/ScoutEngineTest.php @@ -0,0 +1,673 @@ + 'object', 'document' => 'bson', 'array' => 'bson']; + + public function testCreateIndexInvalidDefinition(): void + { + $database = $this->createMock(Database::class); + $engine = new ScoutEngine($database, false, ['collection_invalid' => ['foo' => 'bar']]); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Invalid search index definition for collection "collection_invalid", the "mappings" key is required.'); + $engine->createIndex('collection_invalid'); + } + + public function testCreateIndex(): void + { + $collectionName = 'collection_custom'; + $expectedDefinition = [ + 'mappings' => [ + 'dynamic' => true, + ], + ]; + + $database = $this->createMock(Database::class); + $collection = $this->createMock(Collection::class); + $database->expects($this->once()) + ->method('createCollection') + ->with($collectionName); + $database->expects($this->once()) + ->method('selectCollection') + ->with($collectionName) + ->willReturn($collection); + $collection->expects($this->once()) + ->method('createSearchIndex') + ->with($expectedDefinition, ['name' => 'scout']); + $collection->expects($this->once()) + ->method('listSearchIndexes') + ->with(['name' => 'scout', 'typeMap' => ['root' => 'bson']]) + ->willReturn(new ArrayIterator([Document::fromPHP(['name' => 'scout', 'status' => 'READY'])])); + + $engine = new ScoutEngine($database, false, []); + $engine->createIndex($collectionName); + } + + public function testCreateIndexCustomDefinition(): void + { + $collectionName = 'collection_custom'; + $expectedDefinition = [ + 'mappings' => [ + [ + 'analyzer' => 'lucene.standard', + 'fields' => [ + [ + 'name' => 'wildcard', + 'type' => 'string', + ], + ], + ], + ], + ]; + + $database = $this->createMock(Database::class); + $collection = $this->createMock(Collection::class); + $database->expects($this->once()) + ->method('createCollection') + ->with($collectionName); + $database->expects($this->once()) + ->method('selectCollection') + ->with($collectionName) + ->willReturn($collection); + $collection->expects($this->once()) + ->method('createSearchIndex') + ->with($expectedDefinition, ['name' => 'scout']); + $collection->expects($this->once()) + ->method('listSearchIndexes') + ->with(['name' => 'scout', 'typeMap' => ['root' => 'bson']]) + ->willReturn(new ArrayIterator([Document::fromPHP(['name' => 'scout', 'status' => 'READY'])])); + + $engine = new ScoutEngine($database, false, [$collectionName => $expectedDefinition]); + $engine->createIndex($collectionName); + } + + /** @param callable(): Builder $builder */ + #[DataProvider('provideSearchPipelines')] + public function testSearch(Closure $builder, array $expectedPipeline): void + { + $data = [['_id' => 'key_1', '__count' => 15], ['_id' => 'key_2', '__count' => 15]]; + $database = $this->createMock(Database::class); + $collection = $this->createMock(Collection::class); + $database->expects($this->once()) + ->method('selectCollection') + ->with('collection_searchable') + ->willReturn($collection); + $cursor = $this->createMock(CursorInterface::class); + $cursor->expects($this->once()) + ->method('setTypeMap') + ->with(self::EXPECTED_TYPEMAP); + $cursor->expects($this->once()) + ->method('toArray') + ->with() + ->willReturn($data); + + $collection->expects($this->any()) + ->method('getCollectionName') + ->willReturn('collection_searchable'); + $collection->expects($this->once()) + ->method('aggregate') + ->with($expectedPipeline) + ->willReturn($cursor); + + $engine = new ScoutEngine($database, softDelete: false); + $result = $engine->search($builder()); + $this->assertEquals($data, $result); + } + + public static function provideSearchPipelines(): iterable + { + $defaultPipeline = [ + [ + '$search' => [ + 'index' => 'scout', + 'compound' => [ + 'should' => [ + [ + 'text' => [ + 'path' => ['wildcard' => '*'], + 'query' => 'lar', + 'fuzzy' => ['maxEdits' => 2], + 'score' => ['boost' => ['value' => 5]], + ], + ], + [ + 'wildcard' => [ + 'query' => 'lar*', + 'path' => ['wildcard' => '*'], + 'allowAnalyzedField' => true, + ], + ], + ], + 'minimumShouldMatch' => 1, + ], + 'count' => [ + 'type' => 'lowerBound', + ], + ], + ], + [ + '$addFields' => [ + '__count' => '$$SEARCH_META.count.lowerBound', + ], + ], + ]; + + yield 'simple string' => [ + function () { + return new Builder(new SearchableModel(), 'lar'); + }, + $defaultPipeline, + ]; + + yield 'where conditions' => [ + function () { + $builder = new Builder(new SearchableModel(), 'lar'); + $builder->where('foo', 'bar'); + $builder->where('key', 'value'); + + return $builder; + }, + array_replace_recursive($defaultPipeline, [ + [ + '$search' => [ + 'compound' => [ + 'filter' => [ + ['equals' => ['path' => 'foo', 'value' => 'bar']], + ['equals' => ['path' => 'key', 'value' => 'value']], + ], + ], + ], + ], + ]), + ]; + + yield 'where in conditions' => [ + function () { + $builder = new Builder(new SearchableModel(), 'lar'); + $builder->where('foo', 'bar'); + $builder->where('bar', 'baz'); + $builder->whereIn('qux', [1, 2]); + $builder->whereIn('quux', [1, 2]); + + return $builder; + }, + array_replace_recursive($defaultPipeline, [ + [ + '$search' => [ + 'compound' => [ + 'filter' => [ + ['equals' => ['path' => 'foo', 'value' => 'bar']], + ['equals' => ['path' => 'bar', 'value' => 'baz']], + ['in' => ['path' => 'qux', 'value' => [1, 2]]], + ['in' => ['path' => 'quux', 'value' => [1, 2]]], + ], + ], + ], + ], + ]), + ]; + + yield 'where not in conditions' => [ + function () { + $builder = new Builder(new SearchableModel(), 'lar'); + $builder->where('foo', 'bar'); + $builder->where('bar', 'baz'); + $builder->whereIn('qux', [1, 2]); + $builder->whereIn('quux', [1, 2]); + $builder->whereNotIn('eaea', [3]); + + return $builder; + }, + array_replace_recursive($defaultPipeline, [ + [ + '$search' => [ + 'compound' => [ + 'filter' => [ + ['equals' => ['path' => 'foo', 'value' => 'bar']], + ['equals' => ['path' => 'bar', 'value' => 'baz']], + ['in' => ['path' => 'qux', 'value' => [1, 2]]], + ['in' => ['path' => 'quux', 'value' => [1, 2]]], + ], + 'mustNot' => [ + ['in' => ['path' => 'eaea', 'value' => [3]]], + ], + ], + ], + ], + ]), + ]; + + yield 'where in conditions without other conditions' => [ + function () { + $builder = new Builder(new SearchableModel(), 'lar'); + $builder->whereIn('qux', [1, 2]); + $builder->whereIn('quux', [1, 2]); + + return $builder; + }, + array_replace_recursive($defaultPipeline, [ + [ + '$search' => [ + 'compound' => [ + 'filter' => [ + ['in' => ['path' => 'qux', 'value' => [1, 2]]], + ['in' => ['path' => 'quux', 'value' => [1, 2]]], + ], + ], + ], + ], + ]), + ]; + + yield 'where not in conditions without other conditions' => [ + function () { + $builder = new Builder(new SearchableModel(), 'lar'); + $builder->whereIn('qux', [1, 2]); + $builder->whereIn('quux', [1, 2]); + $builder->whereNotIn('eaea', [3]); + + return $builder; + }, + array_replace_recursive($defaultPipeline, [ + [ + '$search' => [ + 'compound' => [ + 'filter' => [ + ['in' => ['path' => 'qux', 'value' => [1, 2]]], + ['in' => ['path' => 'quux', 'value' => [1, 2]]], + ], + 'mustNot' => [ + ['in' => ['path' => 'eaea', 'value' => [3]]], + ], + ], + ], + ], + ]), + ]; + + yield 'empty where in conditions' => [ + function () { + $builder = new Builder(new SearchableModel(), 'lar'); + $builder->whereIn('qux', [1, 2]); + $builder->whereIn('quux', [1, 2]); + $builder->whereNotIn('eaea', [3]); + + return $builder; + }, + array_replace_recursive($defaultPipeline, [ + [ + '$search' => [ + 'compound' => [ + 'filter' => [ + ['in' => ['path' => 'qux', 'value' => [1, 2]]], + ['in' => ['path' => 'quux', 'value' => [1, 2]]], + ], + 'mustNot' => [ + ['in' => ['path' => 'eaea', 'value' => [3]]], + ], + ], + ], + ], + ]), + ]; + + yield 'exclude soft-deleted' => [ + function () { + return new Builder(new SearchableModel(), 'lar', softDelete: true); + }, + array_replace_recursive($defaultPipeline, [ + [ + '$search' => [ + 'compound' => [ + 'filter' => [ + ['equals' => ['path' => '__soft_deleted', 'value' => false]], + ], + ], + ], + ], + ]), + ]; + + yield 'only trashed' => [ + function () { + $builder = new Builder(new SearchableModel(), 'lar', softDelete: true); + $builder->onlyTrashed(); + + return $builder; + }, + array_replace_recursive($defaultPipeline, [ + [ + '$search' => [ + 'compound' => [ + 'filter' => [ + ['equals' => ['path' => '__soft_deleted', 'value' => true]], + ], + ], + ], + ], + ]), + ]; + + yield 'with callback' => [ + fn () => new Builder(new SearchableModel(), 'query', callback: function (...$args) { + self::assertCount(3, $args); + self::assertInstanceOf(Collection::class, $args[0]); + self::assertSame('collection_searchable', $args[0]->getCollectionName()); + self::assertSame('query', $args[1]); + self::assertNull($args[2]); + + return $args[0]->aggregate(['pipeline']); + }), + ['pipeline'], + ]; + + yield 'ordered' => [ + function () { + $builder = new Builder(new SearchableModel(), 'lar'); + $builder->orderBy('name', 'desc'); + $builder->orderBy('age', 'asc'); + + return $builder; + }, + array_replace_recursive($defaultPipeline, [ + [ + '$search' => [ + 'sort' => [ + 'name' => -1, + 'age' => 1, + ], + ], + ], + ]), + ]; + } + + public function testPaginate() + { + $perPage = 5; + $page = 3; + + $database = $this->createMock(Database::class); + $collection = $this->createMock(Collection::class); + $cursor = $this->createMock(CursorInterface::class); + $database->method('selectCollection') + ->with('collection_searchable') + ->willReturn($collection); + $collection->expects($this->once()) + ->method('aggregate') + ->willReturnCallback(function (...$args) use ($cursor) { + self::assertSame([ + [ + '$search' => [ + 'index' => 'scout', + 'compound' => [ + 'should' => [ + [ + 'text' => [ + 'query' => 'mustang', + 'path' => ['wildcard' => '*'], + 'fuzzy' => ['maxEdits' => 2], + 'score' => ['boost' => ['value' => 5]], + ], + ], + [ + 'wildcard' => [ + 'query' => 'mustang*', + 'path' => ['wildcard' => '*'], + 'allowAnalyzedField' => true, + ], + ], + ], + 'minimumShouldMatch' => 1, + ], + 'count' => [ + 'type' => 'lowerBound', + ], + 'sort' => [ + 'name' => -1, + ], + ], + ], + [ + '$addFields' => [ + '__count' => '$$SEARCH_META.count.lowerBound', + ], + ], + [ + '$skip' => 10, + ], + [ + '$limit' => 5, + ], + ], $args[0]); + + return $cursor; + }); + $cursor->expects($this->once())->method('setTypeMap')->with(self::EXPECTED_TYPEMAP); + $cursor->expects($this->once())->method('toArray')->with() + ->willReturn([['_id' => 'key_1', '__count' => 17], ['_id' => 'key_2', '__count' => 17]]); + + $engine = new ScoutEngine($database, softDelete: false); + $builder = new Builder(new SearchableModel(), 'mustang'); + $builder->orderBy('name', 'desc'); + $engine->paginate($builder, $perPage, $page); + } + + public function testMapMethodRespectsOrder() + { + $database = $this->createMock(Database::class); + $query = $this->createMock(Builder::class); + $engine = new ScoutEngine($database, false); + + $model = $this->createMock(SearchableModel::class); + $model->expects($this->any()) + ->method('getScoutKeyName') + ->willReturn('id'); + $model->expects($this->once()) + ->method('queryScoutModelsByIds') + ->willReturn($query); + $query->expects($this->once()) + ->method('get') + ->willReturn(LaravelCollection::make([ + new ScoutUser(['id' => 1]), + new ScoutUser(['id' => 2]), + new ScoutUser(['id' => 3]), + new ScoutUser(['id' => 4]), + ])); + + $builder = $this->createMock(Builder::class); + + $results = $engine->map($builder, [ + ['_id' => 1, '__count' => 4], + ['_id' => 2, '__count' => 4], + ['_id' => 4, '__count' => 4], + ['_id' => 3, '__count' => 4], + ], $model); + + $this->assertEquals(4, count($results)); + $this->assertEquals([ + 0 => ['id' => 1], + 1 => ['id' => 2], + 2 => ['id' => 4], + 3 => ['id' => 3], + ], $results->toArray()); + } + + public function testLazyMapMethodRespectsOrder() + { + $database = $this->createMock(Database::class); + $query = $this->createMock(Builder::class); + $engine = new ScoutEngine($database, false); + + $model = $this->createMock(SearchableModel::class); + $model->expects($this->any()) + ->method('getScoutKeyName') + ->willReturn('id'); + $model->expects($this->once()) + ->method('queryScoutModelsByIds') + ->willReturn($query); + $query->expects($this->once()) + ->method('cursor') + ->willReturn(LazyCollection::make([ + new ScoutUser(['id' => 1]), + new ScoutUser(['id' => 2]), + new ScoutUser(['id' => 3]), + new ScoutUser(['id' => 4]), + ])); + + $builder = $this->createMock(Builder::class); + + $results = $engine->lazyMap($builder, [ + ['_id' => 1, '__count' => 4], + ['_id' => 2, '__count' => 4], + ['_id' => 4, '__count' => 4], + ['_id' => 3, '__count' => 4], + ], $model); + + $this->assertEquals(4, count($results)); + $this->assertEquals([ + 0 => ['id' => 1], + 1 => ['id' => 2], + 2 => ['id' => 4], + 3 => ['id' => 3], + ], $results->toArray()); + } + + public function testUpdate(): void + { + $date = new DateTimeImmutable('2000-01-02 03:04:05'); + $database = $this->createMock(Database::class); + $collection = $this->createMock(Collection::class); + $database->expects($this->once()) + ->method('selectCollection') + ->with('collection_indexable') + ->willReturn($collection); + $collection->expects($this->once()) + ->method('bulkWrite') + ->with([ + [ + 'updateOne' => [ + ['_id' => 'key_1'], + ['$set' => ['id' => 1, 'date' => new UTCDateTime($date)]], + ['upsert' => true], + ], + ], + [ + 'updateOne' => [ + ['_id' => 'key_2'], + ['$set' => ['id' => 2]], + ['upsert' => true], + ], + ], + ]); + + $engine = new ScoutEngine($database, softDelete: false); + $engine->update(EloquentCollection::make([ + new SearchableModel([ + 'id' => 1, + 'date' => $date, + ]), + new SearchableModel([ + 'id' => 2, + ]), + ])); + } + + public function testUpdateWithSoftDelete(): void + { + $date = new DateTimeImmutable('2000-01-02 03:04:05'); + $database = $this->createMock(Database::class); + $collection = $this->createMock(Collection::class); + $database->expects($this->once()) + ->method('selectCollection') + ->with('collection_indexable') + ->willReturn($collection); + $collection->expects($this->once()) + ->method('bulkWrite') + ->with([ + [ + 'updateOne' => [ + ['_id' => 'key_1'], + ['$set' => ['id' => 1, '__soft_deleted' => false]], + ['upsert' => true], + ], + ], + ]); + + $model = new SearchableModel(['id' => 1]); + $model->delete(); + + $engine = new ScoutEngine($database, softDelete: true); + $engine->update(EloquentCollection::make([$model])); + } + + public function testDelete(): void + { + $database = $this->createMock(Database::class); + $collection = $this->createMock(Collection::class); + $database->expects($this->once()) + ->method('selectCollection') + ->with('collection_indexable') + ->willReturn($collection); + $collection->expects($this->once()) + ->method('deleteMany') + ->with(['_id' => ['$in' => ['key_1', 'key_2']]]); + + $engine = new ScoutEngine($database, softDelete: false); + $engine->delete(EloquentCollection::make([ + new SearchableModel(['id' => 1]), + new SearchableModel(['id' => 2]), + ])); + } + + public function testDeleteWithRemoveableScoutCollection(): void + { + $job = new RemoveFromSearch(EloquentCollection::make([ + new SearchableModel(['id' => 5, 'scout_key' => 'key_5']), + ])); + + $job = unserialize(serialize($job)); + + $database = $this->createMock(Database::class); + $collection = $this->createMock(Collection::class); + $database->expects($this->once()) + ->method('selectCollection') + ->with('collection_indexable') + ->willReturn($collection); + $collection->expects($this->once()) + ->method('deleteMany') + ->with(['_id' => ['$in' => ['key_5']]]); + + $engine = new ScoutEngine($database, softDelete: false); + $engine->delete($job->models); + } +} diff --git a/tests/Scout/ScoutIntegrationTest.php b/tests/Scout/ScoutIntegrationTest.php new file mode 100644 index 000000000..b40a455ab --- /dev/null +++ b/tests/Scout/ScoutIntegrationTest.php @@ -0,0 +1,269 @@ +set('scout.driver', 'mongodb'); + $app['config']->set('scout.prefix', 'prefix_'); + $app['config']->set('scout.mongodb.index-definitions', [ + 'prefix_scout_users' => ['mappings' => ['dynamic' => true, 'fields' => ['bool_field' => ['type' => 'boolean']]]], + ]); + } + + public function setUp(): void + { + parent::setUp(); + + $this->skipIfSearchIndexManagementIsNotSupported(); + + // Init the SQL database with some objects that will be indexed + // Test data copied from Laravel Scout tests + // https://github.com/laravel/scout/blob/10.x/tests/Integration/SearchableTests.php + ScoutUser::executeSchema(); + + $collect = LazyCollection::make(function () { + yield ['name' => 'Laravel Framework']; + + foreach (range(2, 10) as $key) { + yield ['name' => 'Example ' . $key]; + } + + yield ['name' => 'Larry Casper', 'email_verified_at' => null]; + yield ['name' => 'Reta Larkin']; + + foreach (range(13, 19) as $key) { + yield ['name' => 'Example ' . $key]; + } + + yield ['name' => 'Prof. Larry Prosacco DVM', 'email_verified_at' => null]; + + foreach (range(21, 38) as $key) { + yield ['name' => 'Example ' . $key, 'email_verified_at' => null]; + } + + yield ['name' => 'Linkwood Larkin', 'email_verified_at' => null]; + yield ['name' => 'Otis Larson MD']; + yield ['name' => 'Gudrun Larkin']; + yield ['name' => 'Dax Larkin']; + yield ['name' => 'Dana Larson Sr.']; + yield ['name' => 'Amos Larson Sr.']; + }); + + $id = 0; + $date = new DateTimeImmutable('2021-01-01 00:00:00'); + foreach ($collect as $data) { + $data = array_merge(['id' => ++$id, 'email_verified_at' => $date], $data); + ScoutUser::create($data)->save(); + } + + self::assertSame(44, ScoutUser::count()); + } + + /** This test create the search index for tests performing search */ + public function testItCanCreateTheCollection() + { + $collection = DB::connection('mongodb')->getCollection('prefix_scout_users'); + $collection->drop(); + + // Recreate the indexes using the artisan commands + // Ensure they return a success exit code (0) + self::assertSame(0, artisan($this, 'scout:delete-index', ['name' => ScoutUser::class])); + self::assertSame(0, artisan($this, 'scout:index', ['name' => ScoutUser::class])); + self::assertSame(0, artisan($this, 'scout:import', ['model' => ScoutUser::class])); + + self::assertSame(44, $collection->countDocuments()); + + $searchIndexes = $collection->listSearchIndexes(['name' => 'scout', 'typeMap' => ['root' => 'array', 'document' => 'array', 'array' => 'array']]); + self::assertCount(1, $searchIndexes); + self::assertSame(['mappings' => ['dynamic' => true, 'fields' => ['bool_field' => ['type' => 'boolean']]]], iterator_to_array($searchIndexes)[0]['latestDefinition']); + + // Wait for all documents to be indexed asynchronously + $i = 100; + while (true) { + $indexedDocuments = $collection->aggregate([ + ['$search' => ['index' => 'scout', 'exists' => ['path' => 'name']]], + ])->toArray(); + + if (count($indexedDocuments) >= 44) { + break; + } + + if ($i-- === 0) { + self::fail('Documents not indexed'); + } + + usleep(100_000); + } + + self::assertCount(44, $indexedDocuments); + } + + #[Depends('testItCanCreateTheCollection')] + public function testItCanUseBasicSearch() + { + // All the search queries use "sort" option to ensure the results are deterministic + $results = ScoutUser::search('lar')->take(10)->orderBy('id')->get(); + + self::assertSame([ + 1 => 'Laravel Framework', + 11 => 'Larry Casper', + 12 => 'Reta Larkin', + 20 => 'Prof. Larry Prosacco DVM', + 39 => 'Linkwood Larkin', + 40 => 'Otis Larson MD', + 41 => 'Gudrun Larkin', + 42 => 'Dax Larkin', + 43 => 'Dana Larson Sr.', + 44 => 'Amos Larson Sr.', + ], $results->pluck('name', 'id')->all()); + } + + #[Depends('testItCanCreateTheCollection')] + public function testItCanUseBasicSearchCursor() + { + // All the search queries use "sort" option to ensure the results are deterministic + $results = ScoutUser::search('lar')->take(10)->orderBy('id')->cursor(); + + self::assertSame([ + 1 => 'Laravel Framework', + 11 => 'Larry Casper', + 12 => 'Reta Larkin', + 20 => 'Prof. Larry Prosacco DVM', + 39 => 'Linkwood Larkin', + 40 => 'Otis Larson MD', + 41 => 'Gudrun Larkin', + 42 => 'Dax Larkin', + 43 => 'Dana Larson Sr.', + 44 => 'Amos Larson Sr.', + ], $results->pluck('name', 'id')->all()); + } + + #[Depends('testItCanCreateTheCollection')] + public function testItCanUseBasicSearchWithQueryCallback() + { + $results = ScoutUser::search('lar')->take(10)->orderBy('id')->query(function ($query) { + return $query->whereNotNull('email_verified_at'); + })->get(); + + self::assertSame([ + 1 => 'Laravel Framework', + 12 => 'Reta Larkin', + 40 => 'Otis Larson MD', + 41 => 'Gudrun Larkin', + 42 => 'Dax Larkin', + 43 => 'Dana Larson Sr.', + 44 => 'Amos Larson Sr.', + ], $results->pluck('name', 'id')->all()); + } + + #[Depends('testItCanCreateTheCollection')] + public function testItCanUseBasicSearchToFetchKeys() + { + $results = ScoutUser::search('lar')->orderBy('id')->take(10)->keys(); + + self::assertSame([1, 11, 12, 20, 39, 40, 41, 42, 43, 44], $results->all()); + } + + #[Depends('testItCanCreateTheCollection')] + public function testItCanUseBasicSearchWithQueryCallbackToFetchKeys() + { + $results = ScoutUser::search('lar')->take(10)->orderBy('id', 'desc')->query(function ($query) { + return $query->whereNotNull('email_verified_at'); + })->keys(); + + self::assertSame([44, 43, 42, 41, 40, 39, 20, 12, 11, 1], $results->all()); + } + + #[Depends('testItCanCreateTheCollection')] + public function testItCanUsePaginatedSearch() + { + $page1 = ScoutUser::search('lar')->take(10)->orderBy('id')->paginate(5, 'page', 1); + $page2 = ScoutUser::search('lar')->take(10)->orderBy('id')->paginate(5, 'page', 2); + + self::assertSame([ + 1 => 'Laravel Framework', + 11 => 'Larry Casper', + 12 => 'Reta Larkin', + 20 => 'Prof. Larry Prosacco DVM', + 39 => 'Linkwood Larkin', + ], $page1->pluck('name', 'id')->all()); + + self::assertSame([ + 40 => 'Otis Larson MD', + 41 => 'Gudrun Larkin', + 42 => 'Dax Larkin', + 43 => 'Dana Larson Sr.', + 44 => 'Amos Larson Sr.', + ], $page2->pluck('name', 'id')->all()); + } + + #[Depends('testItCanCreateTheCollection')] + public function testItCanUsePaginatedSearchWithQueryCallback() + { + $queryCallback = function ($query) { + return $query->whereNotNull('email_verified_at'); + }; + + $page1 = ScoutUser::search('lar')->take(10)->orderBy('id')->query($queryCallback)->paginate(5, 'page', 1); + $page2 = ScoutUser::search('lar')->take(10)->orderBy('id')->query($queryCallback)->paginate(5, 'page', 2); + + self::assertSame([ + 1 => 'Laravel Framework', + 12 => 'Reta Larkin', + ], $page1->pluck('name', 'id')->all()); + + self::assertSame([ + 40 => 'Otis Larson MD', + 41 => 'Gudrun Larkin', + 42 => 'Dax Larkin', + 43 => 'Dana Larson Sr.', + 44 => 'Amos Larson Sr.', + ], $page2->pluck('name', 'id')->all()); + } + + public function testItCannotIndexInTheSameNamespace() + { + self::expectException(LogicException::class); + self::expectExceptionMessage(sprintf( + 'The MongoDB Scout collection "%s.searchable_in_same_namespaces" must use a different collection from the collection name of the model "%s". Set the "scout.prefix" configuration or use a distinct MongoDB database', + env('MONGODB_DATABASE', 'unittest'), + SearchableInSameNamespace::class, + ),); + + SearchableInSameNamespace::create(['name' => 'test']); + } +} diff --git a/tests/SeederTest.php b/tests/SeederTest.php index a6122ce17..71f36943c 100644 --- a/tests/SeederTest.php +++ b/tests/SeederTest.php @@ -14,6 +14,8 @@ class SeederTest extends TestCase public function tearDown(): void { User::truncate(); + + parent::tearDown(); } public function testSeed(): void diff --git a/tests/SessionTest.php b/tests/SessionTest.php index 7ffbb51f0..f334dc746 100644 --- a/tests/SessionTest.php +++ b/tests/SessionTest.php @@ -3,7 +3,11 @@ namespace MongoDB\Laravel\Tests; use Illuminate\Session\DatabaseSessionHandler; +use Illuminate\Session\SessionManager; use Illuminate\Support\Facades\DB; +use MongoDB\Laravel\Session\MongoDbSessionHandler; +use PHPUnit\Framework\Attributes\TestWith; +use SessionHandlerInterface; class SessionTest extends TestCase { @@ -14,20 +18,77 @@ protected function tearDown(): void parent::tearDown(); } - public function testDatabaseSessionHandler() + /** @param class-string $class */ + #[TestWith([DatabaseSessionHandler::class])] + #[TestWith([MongoDbSessionHandler::class])] + public function testSessionHandlerFunctionality(string $class) { - $sessionId = '123'; - - $handler = new DatabaseSessionHandler( + $handler = new $class( $this->app['db']->connection('mongodb'), 'sessions', 10, ); + $sessionId = '123'; + $handler->write($sessionId, 'foo'); $this->assertEquals('foo', $handler->read($sessionId)); $handler->write($sessionId, 'bar'); $this->assertEquals('bar', $handler->read($sessionId)); + + $handler->destroy($sessionId); + $this->assertEmpty($handler->read($sessionId)); + + $handler->write($sessionId, 'bar'); + $handler->gc(-1); + $this->assertEmpty($handler->read($sessionId)); + } + + public function testDatabaseSessionHandlerRegistration() + { + $this->app['config']->set('session.driver', 'database'); + $this->app['config']->set('session.connection', 'mongodb'); + + $session = $this->app['session']; + $this->assertInstanceOf(SessionManager::class, $session); + $this->assertInstanceOf(DatabaseSessionHandler::class, $session->getHandler()); + + $this->assertSessionCanStoreInMongoDB($session); + } + + public function testMongoDBSessionHandlerRegistration() + { + $this->app['config']->set('session.driver', 'mongodb'); + $this->app['config']->set('session.connection', 'mongodb'); + + $session = $this->app['session']; + $this->assertInstanceOf(SessionManager::class, $session); + $this->assertInstanceOf(MongoDbSessionHandler::class, $session->getHandler()); + + $this->assertSessionCanStoreInMongoDB($session); + } + + private function assertSessionCanStoreInMongoDB(SessionManager $session): void + { + $session->put('foo', 'bar'); + $session->save(); + + $this->assertNotNull($session->getId()); + + $data = DB::connection('mongodb') + ->getCollection('sessions') + ->findOne(['_id' => $session->getId()]); + + self::assertIsObject($data); + self::assertSame($session->getId(), $data->_id); + + $session->remove('foo'); + $data = DB::connection('mongodb') + ->getCollection('sessions') + ->findOne(['_id' => $session->getId()]); + + self::assertIsObject($data); + self::assertSame($session->getId(), $data->_id); } } diff --git a/tests/TestCase.php b/tests/TestCase.php index 5f5bbecdc..d924777ce 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -5,7 +5,9 @@ namespace MongoDB\Laravel\Tests; use Illuminate\Foundation\Application; +use MongoDB\Driver\Exception\ServerException; use MongoDB\Laravel\MongoDBServiceProvider; +use MongoDB\Laravel\Schema\Builder; use MongoDB\Laravel\Tests\Models\User; use MongoDB\Laravel\Validation\ValidationServiceProvider; use Orchestra\Testbench\TestCase as OrchestraTestCase; @@ -64,4 +66,17 @@ protected function getEnvironmentSetUp($app): void $app['config']->set('queue.failed.database', 'mongodb2'); $app['config']->set('queue.failed.driver', 'mongodb'); } + + public function skipIfSearchIndexManagementIsNotSupported(): void + { + try { + $this->getConnection('mongodb')->getCollection('test')->listSearchIndexes(['name' => 'just_for_testing']); + } catch (ServerException $e) { + if (Builder::isAtlasSearchNotSupportedException($e)) { + self::markTestSkipped('Search index management is not supported on this server'); + } + + throw $e; + } + } } diff --git a/tests/Ticket/GH2489Test.php b/tests/Ticket/GH2489Test.php index 62ce11d0e..09fa111ea 100644 --- a/tests/Ticket/GH2489Test.php +++ b/tests/Ticket/GH2489Test.php @@ -13,6 +13,8 @@ class GH2489Test extends TestCase public function tearDown(): void { Location::truncate(); + + parent::tearDown(); } public function testQuerySubdocumentsUsingWhereInId() diff --git a/tests/Ticket/GH3326Test.php b/tests/Ticket/GH3326Test.php new file mode 100644 index 000000000..d3f339acc --- /dev/null +++ b/tests/Ticket/GH3326Test.php @@ -0,0 +1,42 @@ +foo = 'bar'; + $model->save(); + + $fresh = $model->fresh(); + + $this->assertEquals('bar', $fresh->foo); + $this->assertEquals('written-in-created', $fresh->extra); + } +} + +class GH3326Model extends Model +{ + protected $connection = 'mongodb'; + protected $collection = 'test_gh3326'; + protected $guarded = []; + + protected static function booted(): void + { + static::created(function ($model) { + $model->extra = 'written-in-created'; + $model->saveQuietly(); + }); + } +} diff --git a/tests/Ticket/GH3335Test.php b/tests/Ticket/GH3335Test.php new file mode 100644 index 000000000..f37782a4b --- /dev/null +++ b/tests/Ticket/GH3335Test.php @@ -0,0 +1,33 @@ +id = 'foo'; + $model->save(); + + $model = Location::find('foo'); + $model->{'38'} = 'PHP'; + $model->save(); + + $model = Location::find('foo'); + self::assertSame('PHP', $model->{'38'}); + } +} diff --git a/tests/ValidationTest.php b/tests/ValidationTest.php index d5122ce7b..9d2089af5 100644 --- a/tests/ValidationTest.php +++ b/tests/ValidationTest.php @@ -12,6 +12,8 @@ class ValidationTest extends TestCase public function tearDown(): void { User::truncate(); + + parent::tearDown(); } public function testUnique(): void