├── .env.example ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ ├── sonar.yml │ └── test-application.yaml ├── LICENSE.md ├── Makefile ├── _config.yml ├── composer.json ├── config └── elasticsearch.php ├── docker-compose.yml ├── docker └── app │ └── Dockerfile ├── resources └── lang │ └── en │ ├── flush.php │ └── import.php ├── sonar-project.properties └── src ├── Console └── Commands │ ├── FlushCommand.php │ ├── ImportCommand.php │ └── ProgressBarFactory.php ├── Database └── Scopes │ ├── ChunkScope.php │ └── PageScope.php ├── ElasticSearch ├── Alias.php ├── Config │ ├── Config.php │ └── Storage.php ├── DefaultAlias.php ├── EloquentHitsIteratorAggregate.php ├── FilteredAlias.php ├── HitsIteratorAggregate.php ├── Index.php ├── Params │ ├── Bulk.php │ ├── Indices │ │ ├── Alias │ │ │ ├── Get.php │ │ │ └── Update.php │ │ ├── Create.php │ │ ├── Delete.php │ │ └── Refresh.php │ └── Search.php ├── SearchFactory.php ├── SearchResults.php └── WriteAlias.php ├── ElasticSearchServiceProvider.php ├── Engines └── ElasticSearchEngine.php ├── Jobs ├── Import.php ├── ImportStages.php ├── QueueableJob.php └── Stages │ ├── CleanUp.php │ ├── CreateWriteIndex.php │ ├── PullFromSource.php │ ├── RefreshIndex.php │ ├── StageInterface.php │ └── SwitchToNewAndRemoveOldIndex.php ├── MixedSearch.php ├── ProgressReportable.php ├── ScoutElasticSearchServiceProvider.php └── Searchable ├── DefaultImportSource.php ├── DefaultImportSourceFactory.php ├── ImportSource.php ├── ImportSourceFactory.php └── SearchableListFactory.php /.env.example: -------------------------------------------------------------------------------- 1 | APP_ENV=development 2 | XDEBUG_CONFIG=remote_port=9001 remote_host=172.17.0.1 remote_autostart=1 remote_log=/home/user/xdebug.log 3 | PHP_IDE_CONFIG=serverName=Scout 4 | ELASTICSEARCH_HOST=elasticsearch:9200 5 | DB_HOST=db 6 | DB_PORT=3306 7 | DB_DATABASE=my_database 8 | DB_USERNAME=root 9 | DB_PASSWORD=my_password 10 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: matchish 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 13 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "[BUG]" 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Create a model with '...' 16 | 2. Run a command '....' 17 | 3. Search term '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Additional context** 24 | Add any other context about the problem here. 25 | 26 | **Version** 27 | Versions of Laravel, Scout, and the package. 28 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "[Feature]" 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/workflows/sonar.yml: -------------------------------------------------------------------------------- 1 | name: Sonar Analysis 2 | on: 3 | workflow_run: 4 | workflows: ["Test application"] 5 | types: 6 | - completed 7 | 8 | jobs: 9 | sonar: 10 | name: SonarQube Analysis 11 | runs-on: ubuntu-latest 12 | if: github.event.workflow_run.conclusion == 'success' 13 | steps: 14 | - uses: actions/checkout@v4 15 | with: 16 | repository: ${{ github.event.workflow_run.head_repository.full_name }} 17 | ref: ${{ github.event.workflow_run.head_branch }} 18 | fetch-depth: 0 19 | 20 | - name: Download code coverage 21 | uses: actions/github-script@v6 22 | with: 23 | script: | 24 | console.log('Workflow run ID:', context.payload.workflow_run.id); 25 | 26 | const artifactsResponse = await github.rest.actions.listWorkflowRunArtifacts({ 27 | owner: context.repo.owner, 28 | repo: context.repo.repo, 29 | run_id: context.payload.workflow_run.id, 30 | }); 31 | 32 | console.log('Available artifacts:', artifactsResponse.data.artifacts.map(a => a.name)); 33 | 34 | const matchArtifact = artifactsResponse.data.artifacts.find(artifact => 35 | artifact.name === "coverage-report" 36 | ); 37 | 38 | if (!matchArtifact) { 39 | core.setFailed('No coverage-report artifact found. Available artifacts: ' + 40 | artifactsResponse.data.artifacts.map(a => a.name).join(', ')); 41 | return; 42 | } 43 | 44 | console.log('Found coverage artifact:', matchArtifact.name, 'ID:', matchArtifact.id); 45 | 46 | const download = await github.rest.actions.downloadArtifact({ 47 | owner: context.repo.owner, 48 | repo: context.repo.repo, 49 | artifact_id: matchArtifact.id, 50 | archive_format: 'zip', 51 | }); 52 | let fs = require('fs'); 53 | fs.writeFileSync(`${process.env.GITHUB_WORKSPACE}/coverage-report.zip`, Buffer.from(download.data)); 54 | 55 | - name: Extract coverage report 56 | run: unzip coverage-report.zip 57 | 58 | - name: SonarQube Scan 59 | uses: SonarSource/sonarqube-scan-action@v4.2.1 60 | env: 61 | SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} 62 | with: 63 | args: > 64 | -Dsonar.scm.revision=${{ github.event.workflow_run.head_sha }} 65 | -Dsonar.coverage.jacoco.xmlReportPaths=coverage.xml 66 | ${{ github.event.workflow_run.pull_requests != null && format('-Dsonar.pullrequest.key={0}', github.event.workflow_run.pull_requests[0].number) || '' }} 67 | ${{ github.event.workflow_run.pull_requests != null && format('-Dsonar.pullrequest.branch={0}', github.event.workflow_run.pull_requests[0].head.ref) || '' }} 68 | ${{ github.event.workflow_run.pull_requests != null && format('-Dsonar.pullrequest.base={0}', github.event.workflow_run.pull_requests[0].base.ref) || '' }} 69 | -------------------------------------------------------------------------------- /.github/workflows/test-application.yaml: -------------------------------------------------------------------------------- 1 | name: Test application 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - master 8 | 9 | jobs: 10 | php: 11 | name: 'Run tests with php ${{ matrix.php-version }}' 12 | runs-on: ubuntu-latest 13 | 14 | strategy: 15 | fail-fast: false 16 | max-parallel: 1 # all versions are using same elasticsearch and mysql service. They need to run 1 by 1. 17 | matrix: 18 | include: 19 | - php-version: '8.0.16' 20 | - php-version: '8.1' 21 | - php-version: '8.2' 22 | - php-version: '8.3' 23 | services: 24 | elasticsearch: 25 | image: docker.elastic.co/elasticsearch/elasticsearch:7.17.1 26 | ports: 27 | - 9200:9200 28 | env: 29 | discovery.type: 'single-node' 30 | xpack.security.enabled: 'false' 31 | options: --health-cmd="curl http://localhost:9200/_cluster/health" --health-interval=10s --health-timeout=5s --health-retries=5 32 | mysql: 33 | image: mysql:5.7.22 34 | env: 35 | MYSQL_ALLOW_EMPTY_PASSWORD: yes 36 | MYSQL_DATABASE: unittest 37 | ports: 38 | - 3306:3306 39 | options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3 40 | 41 | steps: 42 | - name: Checkout project 43 | uses: actions/checkout@v2 44 | 45 | - name: Install and configure PHP 46 | uses: shivammathur/setup-php@v2 47 | with: 48 | php-version: ${{ matrix.php-version }} 49 | tools: 'composer' 50 | 51 | - name: Get composer cache directory 52 | id: composer-cache-dir 53 | run: echo "::set-output name=dir::$(composer config cache-files-dir)" 54 | 55 | - name: Cache dependencies 56 | uses: actions/cache@v4 57 | id: composer-cache 58 | with: 59 | path: ${{ steps.composer-cache-dir.outputs.dir }} 60 | key: ${{ runner.os }}-composer-${{ hashFiles('composer.lock') }} 61 | restore-keys: | 62 | ${{ runner.os }}-composer- 63 | 64 | - name: Install dependencies 65 | run: | 66 | composer validate --strict 67 | composer install --no-interaction --prefer-dist 68 | 69 | - name: Run tests 70 | run: vendor/bin/phpunit --coverage-clover=coverage.xml 71 | env: 72 | DB_DATABASE: unittest 73 | DB_USERNAME: root 74 | ELASTICSEARCH_HOST: '127.0.0.1:9200' 75 | 76 | - name: Upload coverage results 77 | uses: actions/upload-artifact@v4 78 | if: matrix.php-version == '8.3' 79 | with: 80 | name: coverage-report 81 | path: coverage.xml 82 | retention-days: 1 83 | compression-level: 9 84 | 85 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | #!/usr/bin/make 2 | # Makefile readme (ru): 3 | # Makefile readme (en): 4 | 5 | SHELL = /bin/sh 6 | APP_CONTAINER_NAME := app 7 | 8 | docker_bin := $(shell command -v docker 2> /dev/null) 9 | docker_compose_bin := $(shell command -v docker-compose 2> /dev/null) 10 | 11 | .PHONY : help test \ 12 | up down restart shell install 13 | .DEFAULT_GOAL := help 14 | 15 | # This will output the help for each task. thanks to https://marmelab.com/blog/2016/02/29/auto-documented-makefile.html 16 | help: ## Show this help 17 | @awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf " \033[36m%-15s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST) 18 | 19 | # --- [ Development tasks ] ------------------------------------------------------------------------------------------- 20 | 21 | ---------------: ## --------------- 22 | 23 | up: ## Start all containers (in background) for development 24 | ifeq ($(OS), Windows_NT) 25 | sudo sysctl -w vm.max_map_count=262144 26 | endif 27 | $(docker_compose_bin) up -d 28 | 29 | down: ## Stop all started for development containers 30 | $(docker_compose_bin) down 31 | 32 | restart: up ## Restart all started for development containers 33 | $(docker_compose_bin) restart 34 | 35 | shell: up ## Start shell into application container 36 | $(docker_compose_bin) exec "$(APP_CONTAINER_NAME)" /bin/sh 37 | 38 | install: up ## Install application dependencies into application container 39 | $(docker_compose_bin) exec "$(APP_CONTAINER_NAME)" composer install --no-interaction --ansi 40 | 41 | test: up ## Execute application tests 42 | $(docker_compose_bin) exec "$(APP_CONTAINER_NAME)" ./vendor/bin/phpstan analyze --memory-limit=4000M 43 | $(docker_compose_bin) exec "$(APP_CONTAINER_NAME)" ./vendor/bin/phpunit --testdox --stop-on-failure 44 | 45 | test-coverage: up ## Execute application tests and generate report 46 | $(docker_compose_bin) exec "$(APP_CONTAINER_NAME)" ./vendor/bin/phpstan analyze 47 | $(docker_compose_bin) exec "$(APP_CONTAINER_NAME)" ./vendor/bin/phpunit --coverage-html build/coverage-report 48 | 49 | test-filter: 50 | $(docker_compose_bin) exec "$(APP_CONTAINER_NAME)" ./vendor/bin/phpunit --filter=$(filter) --testdox 51 | -------------------------------------------------------------------------------- /_config.yml: -------------------------------------------------------------------------------- 1 | theme: jekyll-theme-cayman -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "matchish/laravel-scout-elasticsearch", 3 | "description": "Search among multiple models with ElasticSearch and Laravel Scout", 4 | "keywords": [ 5 | "laravel", 6 | "scout", 7 | "extended", 8 | "search", 9 | "elasticsearch" 10 | ], 11 | "license": "MIT", 12 | "authors": [ 13 | { 14 | "name": "Sergey Shliakhov", 15 | "email": "shlyakhov.up@gmail.com" 16 | } 17 | ], 18 | "require": { 19 | "php": "^8.0.12|^8.1", 20 | "elasticsearch/elasticsearch": "^8.0", 21 | "handcraftedinthealps/elasticsearch-dsl": "^8.0", 22 | "laravel/scout": "^8.0|^9.0|^10.0", 23 | "roave/better-reflection": "^4.3|^5.0|^6.18|^6.36" 24 | }, 25 | "require-dev": { 26 | "laravel/legacy-factories": "^1.0", 27 | "nunomaduro/larastan": "^2.4", 28 | "orchestra/testbench": "^6.17|^7.0|^8.0", 29 | "php-http/guzzle7-adapter": "^1.0", 30 | "phpunit/phpunit": "^9.4.0" 31 | }, 32 | "autoload-dev": { 33 | "psr-4": { 34 | "Tests\\": "tests/", 35 | "App\\": "tests/laravel/app" 36 | } 37 | }, 38 | "minimum-stability": "dev", 39 | "prefer-stable": true, 40 | "autoload": { 41 | "psr-4": { 42 | "Matchish\\ScoutElasticSearch\\": "src/" 43 | } 44 | }, 45 | "extra": { 46 | "laravel": { 47 | "providers": [ 48 | "Matchish\\ScoutElasticSearch\\ScoutElasticSearchServiceProvider" 49 | ] 50 | } 51 | }, 52 | "config": { 53 | "sort-packages": true, 54 | "preferred-install": "dist" 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /config/elasticsearch.php: -------------------------------------------------------------------------------- 1 | env('ELASTICSEARCH_PORT') && env('ELASTICSEARCH_SCHEME') 5 | ? env('ELASTICSEARCH_SCHEME').'://'.env('ELASTICSEARCH_HOST').':'.env('ELASTICSEARCH_PORT') 6 | : env('ELASTICSEARCH_HOST'), 7 | 'user' => env('ELASTICSEARCH_USER'), 8 | 'password' => env('ELASTICSEARCH_PASSWORD', env('ELASTICSEARCH_PASS')), 9 | 'cloud_id' => env('ELASTICSEARCH_CLOUD_ID', env('ELASTICSEARCH_API_ID')), 10 | 'api_key' => env('ELASTICSEARCH_API_KEY'), 11 | 'ssl_verification' => env('ELASTICSEARCH_SSL_VERIFICATION', true), 12 | 'queue' => [ 13 | 'timeout' => env('SCOUT_QUEUE_TIMEOUT'), 14 | ], 15 | 'indices' => [ 16 | 'mappings' => [ 17 | 'default' => [ 18 | 'properties' => [ 19 | 'id' => [ 20 | 'type' => 'keyword', 21 | ], 22 | ], 23 | ], 24 | ], 25 | 'settings' => [ 26 | 'default' => [ 27 | 'number_of_shards' => 1, 28 | 'number_of_replicas' => 0, 29 | ], 30 | ], 31 | ], 32 | ]; 33 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.4' 2 | 3 | services: 4 | app: 5 | build: 6 | context: ./docker/app 7 | dockerfile: Dockerfile 8 | target: php-${APP_ENV} 9 | user: "1000:1000" 10 | depends_on: 11 | - elasticsearch 12 | - db 13 | volumes: 14 | - /etc/passwd:/etc/passwd:ro 15 | - /etc/group:/etc/group:ro 16 | - ./:/app:rw 17 | - home-dir:/home/user 18 | working_dir: /app 19 | environment: 20 | HOME: /home/user 21 | XDEBUG_CONFIG: ${XDEBUG_CONFIG} 22 | PHP_IDE_CONFIG: ${PHP_IDE_CONFIG} 23 | ELASTICSEARCH_HOST: ${ELASTICSEARCH_HOST} 24 | ELASTICSEARCH_USER: ${ELASTICSEARCH_USER} 25 | ELASTICSEARCH_PASSWORD: ${ELASTICSEARCH_PASSWORD} 26 | DB_HOST: ${DB_HOST} 27 | DB_DATABASE: ${DB_DATABASE} 28 | DB_USERNAME: ${DB_USERNAME} 29 | DB_PASSWORD: ${DB_PASSWORD} 30 | networks: 31 | - default 32 | db: 33 | image: mysql:5.7.22 34 | environment: 35 | MYSQL_DATABASE: ${DB_DATABASE} 36 | MYSQL_USER: ${DB_USERNAME} 37 | MYSQL_PASSWORD: ${DB_PASSWORD} 38 | MYSQL_ROOT_PASSWORD: ${DB_PASSWORD} 39 | networks: 40 | - default 41 | elasticsearch: 42 | image: docker.elastic.co/elasticsearch/elasticsearch:8.1.3 43 | user: "1000:1000" 44 | volumes: 45 | - elasticsearch-data:/usr/share/elasticsearch/data 46 | environment: 47 | - ELASTIC_PASSWORD=${ELASTICSEARCH_PASSWORD} 48 | - discovery.type=single-node 49 | - cluster.routing.allocation.disk.threshold_enabled=false 50 | - bootstrap.memory_lock=true 51 | - action.destructive_requires_name=false 52 | - "ES_JAVA_OPTS=-Xms512m -Xmx512m" 53 | ulimits: 54 | memlock: 55 | soft: -1 56 | hard: -1 57 | ports: 58 | - "9200:9200" 59 | networks: 60 | - default 61 | cerebro: 62 | image: lmenezes/cerebro 63 | ports: 64 | - "9002:9000" 65 | networks: 66 | - default 67 | networks: 68 | default: 69 | driver: bridge 70 | 71 | volumes: 72 | elasticsearch-data: 73 | home-dir: 74 | -------------------------------------------------------------------------------- /docker/app/Dockerfile: -------------------------------------------------------------------------------- 1 | # See https://github.com/docker-library/php/blob/master/7.1/fpm/Dockerfile 2 | FROM php:8.1-fpm as php 3 | ARG TIMEZONE 4 | 5 | MAINTAINER Shliakhov Sergey 6 | 7 | RUN apt-get update && apt-get install -y \ 8 | openssl \ 9 | git \ 10 | unzip 11 | 12 | RUN rm -rf /home/user \ 13 | && mkdir /home/user \ 14 | && chmod 777 /home/user 15 | 16 | 17 | # Install Composer 18 | RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer 19 | RUN composer --version 20 | 21 | ENV COMPOSER_ALLOW_SUPERUSER 1 22 | ENV COMPOSER_HOME /tmp 23 | 24 | # Set timezone 25 | RUN ln -snf /usr/share/zoneinfo/${TIMEZONE} /etc/localtime && echo ${TIMEZONE} > /etc/timezone 26 | RUN printf '[PHP]\ndate.timezone = "%s"\n', ${TIMEZONE} > /usr/local/etc/php/conf.d/tzone.ini 27 | RUN "date" 28 | 29 | RUN apt-get update && apt-get install -y libmcrypt-dev mariadb-client \ 30 | && docker-php-ext-install pdo_mysql 31 | 32 | FROM php as php-development 33 | # install xdebug 34 | RUN pecl install xdebug 35 | RUN docker-php-ext-enable xdebug 36 | RUN echo "error_reporting = E_ALL" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini 37 | RUN echo "display_startup_errors = On" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini 38 | RUN echo "display_errors = On" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini 39 | RUN echo "xdebug.remote_enable=1" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini 40 | RUN echo "xdebug.remote_connect_back=0" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini 41 | RUN echo "xdebug.idekey=\"PHPSTORM\"" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini 42 | RUN echo "xdebug.remote_port=9000" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini 43 | RUN echo "xdebug.remote_autostart=1" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini 44 | 45 | RUN apt-get update && apt-get install -y procps 46 | RUN apt-get update && apt-get install -y netcat 47 | RUN apt-get update && apt-get install -y net-tools 48 | 49 | FROM php as php-testing 50 | -------------------------------------------------------------------------------- /resources/lang/en/flush.php: -------------------------------------------------------------------------------- 1 | 'All [:searchable] records have been flushed.', 5 | ]; 6 | -------------------------------------------------------------------------------- /resources/lang/en/import.php: -------------------------------------------------------------------------------- 1 | 'Importing [:searchable]', 5 | 'done' => 'All [:searchable] records have been imported.', 6 | 'done.queue' => 'Import job dispatched to the queue.', 7 | ]; 8 | -------------------------------------------------------------------------------- /sonar-project.properties: -------------------------------------------------------------------------------- 1 | sonar.projectKey=matchish_laravel-scout-elasticsearch 2 | sonar.organization=matchish 3 | 4 | sonar.php.coverage.reportPaths=coverage.xml 5 | -------------------------------------------------------------------------------- /src/Console/Commands/FlushCommand.php: -------------------------------------------------------------------------------- 1 | argument('searchable'))->whenEmpty(function () { 29 | $factory = new SearchableListFactory(app()->getNamespace(), app()->path()); 30 | 31 | return $factory->make(); 32 | }); 33 | $searchableList->each(function ($searchable) { 34 | $searchable::removeAllFromSearch(); 35 | $doneMessage = trans('scout::flush.done', [ 36 | 'searchable' => $searchable, 37 | ]); 38 | $this->output->success($doneMessage); 39 | }); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Console/Commands/ImportCommand.php: -------------------------------------------------------------------------------- 1 | searchableList((array) $this->argument('searchable')) 33 | ->each(function ($searchable) { 34 | $this->import($searchable); 35 | }); 36 | } 37 | 38 | private function searchableList(array $argument): Collection 39 | { 40 | return collect($argument)->whenEmpty(function () { 41 | $factory = new SearchableListFactory(app()->getNamespace(), app()->path()); 42 | 43 | return $factory->make(); 44 | }); 45 | } 46 | 47 | private function import(string $searchable): void 48 | { 49 | $sourceFactory = app(ImportSourceFactory::class); 50 | $source = $sourceFactory::from($searchable); 51 | $job = new Import($source); 52 | $job->timeout = Config::queueTimeout(); 53 | 54 | if (config('scout.queue')) { 55 | $job = (new QueueableJob())->chain([$job]); 56 | $job->timeout = Config::queueTimeout(); 57 | } 58 | 59 | $bar = (new ProgressBarFactory($this->output))->create(); 60 | $job->withProgressReport($bar); 61 | 62 | $startMessage = trans('scout::import.start', ['searchable' => "$searchable"]); 63 | $this->line($startMessage); 64 | 65 | /* @var ImportSource $source */ 66 | dispatch($job)->allOnQueue($source->syncWithSearchUsingQueue()) 67 | ->allOnConnection($source->syncWithSearchUsing()); 68 | 69 | $doneMessage = trans(config('scout.queue') ? 'scout::import.done.queue' : 'scout::import.done', [ 70 | 'searchable' => $searchable, 71 | ]); 72 | $this->output->success($doneMessage); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/Console/Commands/ProgressBarFactory.php: -------------------------------------------------------------------------------- 1 | output = $output; 21 | } 22 | 23 | public function create(int $max = 0): ProgressBar 24 | { 25 | $bar = $this->output->createProgressBar($max); 26 | $bar->setBarCharacter('⚬'); 27 | $bar->setEmptyBarCharacter('⚬'); 28 | $bar->setProgressCharacter('➤'); 29 | $bar->setRedrawFrequency(1); 30 | $bar->maxSecondsBetweenRedraws(0); 31 | $bar->minSecondsBetweenRedraws(0); 32 | $bar->setFormat( 33 | "%message%\n%current%/%max% [%bar%] %percent:3s%%\n" 34 | ); 35 | 36 | return $bar; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Database/Scopes/ChunkScope.php: -------------------------------------------------------------------------------- 1 | start = $start; 29 | $this->end = $end; 30 | } 31 | 32 | /** 33 | * Apply the scope to a given Eloquent query builder. 34 | * 35 | * @param \Illuminate\Database\Eloquent\Builder $builder 36 | * @param \Illuminate\Database\Eloquent\Model $model 37 | * @return void 38 | */ 39 | public function apply(Builder $builder, Model $model) 40 | { 41 | $start = $this->start; 42 | $end = $this->end; 43 | $builder 44 | ->when(! is_null($start), function ($query) use ($start, $model) { 45 | return $query->where($model->getKeyName(), '>', $start); 46 | }) 47 | ->when(! is_null($end), function ($query) use ($end, $model) { 48 | return $query->where($model->getKeyName(), '<=', $end); 49 | }); 50 | } 51 | 52 | public function key(): string 53 | { 54 | return static::class; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/Database/Scopes/PageScope.php: -------------------------------------------------------------------------------- 1 | page = $page; 29 | $this->perPage = $perPage; 30 | } 31 | 32 | /** 33 | * Apply the scope to a given Eloquent query builder. 34 | * 35 | * @param \Illuminate\Database\Eloquent\Builder $builder 36 | * @param \Illuminate\Database\Eloquent\Model $model 37 | * @return void 38 | */ 39 | public function apply(Builder $builder, Model $model) 40 | { 41 | $builder->forPage($this->page, $this->perPage); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/ElasticSearch/Alias.php: -------------------------------------------------------------------------------- 1 | parse()->$method(...$parameters); 24 | } 25 | 26 | /** 27 | * @param string $method 28 | * @param array $parameters 29 | * @return mixed 30 | */ 31 | public static function __callStatic(string $method, array $parameters) 32 | { 33 | return (new self())->parse()->$method(...$parameters); 34 | } 35 | 36 | /** 37 | * @return Storage 38 | */ 39 | public function parse(): Storage 40 | { 41 | return Storage::load('elasticsearch'); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/ElasticSearch/Config/Storage.php: -------------------------------------------------------------------------------- 1 | config = $config; 15 | } 16 | 17 | /** 18 | * @param string $config 19 | * @return Storage 20 | */ 21 | public static function load(string $config): Storage 22 | { 23 | return new self($config); 24 | } 25 | 26 | /** 27 | * @return array 28 | */ 29 | public function hosts(): array 30 | { 31 | return explode(',', $this->loadConfig('host')); 32 | } 33 | 34 | /** 35 | * @return ?string 36 | */ 37 | public function user(): ?string 38 | { 39 | return $this->loadConfig('user'); 40 | } 41 | 42 | /** 43 | * @return ?string 44 | */ 45 | public function password(): ?string 46 | { 47 | return $this->loadConfig('password'); 48 | } 49 | 50 | /** 51 | * @return ?string 52 | */ 53 | public function elasticCloudId(): ?string 54 | { 55 | return $this->loadConfig('cloud_id'); 56 | } 57 | 58 | /** 59 | * @return ?string 60 | */ 61 | public function apiKey(): ?string 62 | { 63 | return $this->loadConfig('api_key'); 64 | } 65 | 66 | /** 67 | * @return bool 68 | */ 69 | public function sslVerification(): bool 70 | { 71 | return (bool) ($this->loadConfig('ssl_verification') ?? true); 72 | } 73 | 74 | /** 75 | * @return ?int 76 | */ 77 | public function queueTimeout(): ?int 78 | { 79 | return (int) $this->loadConfig('queue.timeout') ?: null; 80 | } 81 | 82 | /** 83 | * @param string $path 84 | * @return mixed 85 | */ 86 | private function loadConfig(string $path): mixed 87 | { 88 | return config($this->getKey($path)); 89 | } 90 | 91 | /** 92 | * @param string $path 93 | * @return string 94 | */ 95 | private function getKey(string $path): string 96 | { 97 | return sprintf('%s.%s', $this->config, $path); 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/ElasticSearch/DefaultAlias.php: -------------------------------------------------------------------------------- 1 | name = $name; 21 | } 22 | 23 | /** 24 | * @return string 25 | */ 26 | public function name(): string 27 | { 28 | return $this->name; 29 | } 30 | 31 | /** 32 | * @return array 33 | */ 34 | public function config(): array 35 | { 36 | return []; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/ElasticSearch/EloquentHitsIteratorAggregate.php: -------------------------------------------------------------------------------- 1 | results = $results; 31 | $this->callback = $callback; 32 | } 33 | 34 | /** 35 | * Retrieve an external iterator. 36 | * 37 | * @link https://php.net/manual/en/iteratoraggregate.getiterator.php 38 | * 39 | * @return Traversable An instance of an object implementing Iterator or 40 | * Traversable 41 | * 42 | * @since 5.0.0 43 | */ 44 | public function getIterator() 45 | { 46 | $hits = collect(); 47 | if ($this->results['hits']['total']) { 48 | $hits = $this->results['hits']['hits']; 49 | $models = collect($hits)->groupBy('_source.__class_name') 50 | ->map(function ($results, $class) { 51 | /** @var Searchable $model */ 52 | $model = new $class; 53 | $model->setKeyType('string'); 54 | $builder = new Builder($model, ''); 55 | if (! empty($this->callback)) { 56 | $builder->query($this->callback); 57 | } 58 | 59 | return $models = $model->getScoutModelsByIds( 60 | $builder, $results->pluck('_id')->all() 61 | ); 62 | }) 63 | ->flatten()->keyBy(function ($model) { 64 | return get_class($model).'::'.$model->getScoutKey(); 65 | }); 66 | $hits = collect($hits)->map(function ($hit) use ($models) { 67 | $key = $hit['_source']['__class_name'].'::'.$hit['_id']; 68 | 69 | return isset($models[$key]) ? $models[$key] : null; 70 | })->filter()->all(); 71 | } 72 | 73 | return new \ArrayIterator((array) $hits); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/ElasticSearch/FilteredAlias.php: -------------------------------------------------------------------------------- 1 | origin = $origin; 20 | $this->index = $index; 21 | } 22 | 23 | public function name(): string 24 | { 25 | return $this->origin->name(); 26 | } 27 | 28 | public function config(): array 29 | { 30 | return array_merge($this->origin->config(), [ 31 | 'filter' => [ 32 | 'bool' => [ 33 | 'must_not' => [ 34 | [ 35 | 'term' => [ 36 | '_index' => $this->index->name(), 37 | ], 38 | ], 39 | ], 40 | ], 41 | ], 42 | ]); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/ElasticSearch/HitsIteratorAggregate.php: -------------------------------------------------------------------------------- 1 | name = $name; 40 | $this->settings = $settings; 41 | $this->mappings = $mappings; 42 | } 43 | 44 | /** 45 | * @return array 46 | */ 47 | public function aliases(): array 48 | { 49 | return $this->aliases; 50 | } 51 | 52 | /** 53 | * @return string 54 | */ 55 | public function name(): string 56 | { 57 | return $this->name; 58 | } 59 | 60 | /** 61 | * @param Alias $alias 62 | */ 63 | public function addAlias(Alias $alias): void 64 | { 65 | $this->aliases[$alias->name()] = $alias->config() ?: new \stdClass(); 66 | } 67 | 68 | /** 69 | * @return array 70 | */ 71 | public function config(): array 72 | { 73 | $config = []; 74 | if (! empty($this->settings)) { 75 | $config['settings'] = $this->settings; 76 | } 77 | if (! empty($this->mappings)) { 78 | $config['mappings'] = $this->mappings; 79 | } 80 | if (! empty($this->aliases())) { 81 | $config['aliases'] = $this->aliases(); 82 | } 83 | 84 | return $config; 85 | } 86 | 87 | public static function fromSource(ImportSource $source): Index 88 | { 89 | $name = $source->searchableAs().'_'.time(); 90 | $settingsConfigKey = "elasticsearch.indices.settings.{$source->searchableAs()}"; 91 | $mappingsConfigKey = "elasticsearch.indices.mappings.{$source->searchableAs()}"; 92 | $defaultSettings = [ 93 | 'number_of_shards' => 1, 94 | 'number_of_replicas' => 0, 95 | 96 | ]; 97 | $settings = config($settingsConfigKey, config('elasticsearch.indices.settings.default', $defaultSettings)); 98 | $mappings = config($mappingsConfigKey, config('elasticsearch.indices.mappings.default')); 99 | 100 | return new static($name, $settings, $mappings); 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/ElasticSearch/Params/Bulk.php: -------------------------------------------------------------------------------- 1 | delete($doc); 28 | } 29 | } else { 30 | $this->deleteDocs[$docs->getScoutKey()] = $docs; 31 | } 32 | } 33 | 34 | /** 35 | * TODO: Add ability to extend payload without modifying the class. 36 | * 37 | * @return array 38 | */ 39 | public function toArray(): array 40 | { 41 | $payload = ['body' => []]; 42 | $payload = collect($this->indexDocs)->reduce( 43 | function ($payload, $model) { 44 | if (config('scout.soft_delete', false) && $model::usesSoftDelete()) { 45 | $model->pushSoftDeleteMetadata(); 46 | } 47 | 48 | $attributes = $model->getAttributes(); 49 | $routing = array_key_exists('routing', $attributes) ? $model->routing : null; 50 | $scoutKey = $model->getScoutKey(); 51 | 52 | $payload['body'][] = [ 53 | 'index' => [ 54 | '_index' => $model->searchableAs(), 55 | '_id' => $scoutKey, 56 | 'routing' => false === empty($routing) ? $routing : $scoutKey, 57 | ], 58 | ]; 59 | 60 | $payload['body'][] = array_merge( 61 | $model->toSearchableArray(), 62 | $model->scoutMetadata(), 63 | [ 64 | '__class_name' => get_class($model), 65 | ] 66 | ); 67 | 68 | return $payload; 69 | }, $payload); 70 | 71 | $payload = collect($this->deleteDocs)->reduce( 72 | function ($payload, $model) { 73 | $attributes = $model->getAttributes(); 74 | $routing = array_key_exists('routing', $attributes) ? $model->routing : null; 75 | $scoutKey = $model->getScoutKey(); 76 | 77 | $payload['body'][] = [ 78 | 'delete' => [ 79 | '_index' => $model->searchableAs(), 80 | '_id' => $scoutKey, 81 | 'routing' => false === empty($routing) ? $routing : $scoutKey, 82 | ], 83 | ]; 84 | 85 | return $payload; 86 | }, $payload); 87 | 88 | return $payload; 89 | } 90 | 91 | /** 92 | * @param array|object $docs 93 | */ 94 | public function index($docs): void 95 | { 96 | if (is_iterable($docs)) { 97 | foreach ($docs as $doc) { 98 | $this->index($doc); 99 | } 100 | } else { 101 | $this->indexDocs[$docs->getScoutKey()] = $docs; 102 | } 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/ElasticSearch/Params/Indices/Alias/Get.php: -------------------------------------------------------------------------------- 1 | alias = $alias; 28 | $this->index = $index; 29 | } 30 | 31 | /** 32 | * @return array 33 | */ 34 | public function toArray() 35 | { 36 | return [ 37 | 'index' => $this->index, 38 | 'name' => $this->alias, 39 | ]; 40 | } 41 | 42 | public static function anyIndex(string $alias): Get 43 | { 44 | return new static($alias, '*'); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/ElasticSearch/Params/Indices/Alias/Update.php: -------------------------------------------------------------------------------- 1 | actions = $actions; 21 | } 22 | 23 | /** 24 | * @return array 25 | */ 26 | public function toArray(): array 27 | { 28 | return [ 29 | 'body' => [ 30 | 'actions' => $this->actions, 31 | ], 32 | ]; 33 | } 34 | 35 | public function add(string $index, string $alias): void 36 | { 37 | $this->actions[] = ['add' => [ 38 | 'index' => $index, 39 | 'alias' => $alias, 40 | ]]; 41 | } 42 | 43 | public function removeIndex(string $index): void 44 | { 45 | $this->actions[] = ['remove_index' => ['index' => $index]]; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/ElasticSearch/Params/Indices/Create.php: -------------------------------------------------------------------------------- 1 | index = $index; 28 | $this->config = $config; 29 | } 30 | 31 | /** 32 | * @return array 33 | */ 34 | public function toArray(): array 35 | { 36 | return [ 37 | 'index' => $this->index, 38 | 'body' => $this->config, 39 | ]; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/ElasticSearch/Params/Indices/Delete.php: -------------------------------------------------------------------------------- 1 | index = $index; 23 | } 24 | 25 | /** 26 | * @return array 27 | */ 28 | public function toArray() 29 | { 30 | return [ 31 | 'index' => $this->index, 32 | ]; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/ElasticSearch/Params/Indices/Refresh.php: -------------------------------------------------------------------------------- 1 | index = $index; 23 | } 24 | 25 | /** 26 | * @return array 27 | */ 28 | public function toArray() 29 | { 30 | return [ 31 | 'index' => $this->index, 32 | ]; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/ElasticSearch/Params/Search.php: -------------------------------------------------------------------------------- 1 | index = $index; 26 | $this->body = $body; 27 | } 28 | 29 | public function toArray(): array 30 | { 31 | return [ 32 | 'index' => $this->index, 33 | 'body' => $this->body, 34 | ]; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/ElasticSearch/SearchFactory.php: -------------------------------------------------------------------------------- 1 | query)) { 32 | $boolQuery->add(new QueryStringQuery($builder->query)); 33 | } 34 | $search->addQuery($boolQuery); 35 | } elseif (! empty($builder->query)) { 36 | $search->addQuery(new QueryStringQuery($builder->query)); 37 | } 38 | if (array_key_exists('from', $options)) { 39 | $search->setFrom($options['from']); 40 | } 41 | if (array_key_exists('size', $options)) { 42 | $search->setSize($options['size']); 43 | } 44 | if (array_key_exists('source', $options)) { 45 | $search->setSource($options['source']); 46 | } 47 | if (! empty($builder->orders)) { 48 | foreach ($builder->orders as $order) { 49 | $search->addSort(new FieldSort($order['column'], $order['direction'])); 50 | } 51 | } 52 | 53 | return $search; 54 | } 55 | 56 | /** 57 | * @param Builder $builder 58 | * @return bool 59 | */ 60 | private static function hasWhereFilters($builder): bool 61 | { 62 | return static::hasWheres($builder) || static::hasWhereIns($builder) || static::hasWhereNotIns($builder); 63 | } 64 | 65 | /** 66 | * @param Builder $builder 67 | * @param BoolQuery $boolQuery 68 | * @return BoolQuery 69 | */ 70 | private static function addWheres($builder, $boolQuery): BoolQuery 71 | { 72 | if (static::hasWheres($builder)) { 73 | foreach ($builder->wheres as $field => $value) { 74 | if (! ($value instanceof BuilderInterface)) { 75 | $value = new TermQuery((string) $field, $value); 76 | } 77 | $boolQuery->add($value, BoolQuery::FILTER); 78 | } 79 | } 80 | 81 | return $boolQuery; 82 | } 83 | 84 | /** 85 | * @param Builder $builder 86 | * @param BoolQuery $boolQuery 87 | * @return BoolQuery 88 | */ 89 | private static function addWhereIns($builder, $boolQuery): BoolQuery 90 | { 91 | if (static::hasWhereIns($builder)) { 92 | foreach ($builder->whereIns as $field => $arrayOfValues) { 93 | $boolQuery->add(new TermsQuery((string) $field, $arrayOfValues), BoolQuery::FILTER); 94 | } 95 | } 96 | 97 | return $boolQuery; 98 | } 99 | 100 | /** 101 | * @param Builder $builder 102 | * @param BoolQuery $boolQuery 103 | * @return BoolQuery 104 | */ 105 | private static function addWhereNotIns($builder, $boolQuery): BoolQuery 106 | { 107 | if (static::hasWhereNotIns($builder)) { 108 | foreach ($builder->whereNotIns as $field => $arrayOfValues) { 109 | $boolQuery->add(new TermsQuery((string) $field, $arrayOfValues), BoolQuery::MUST_NOT); 110 | } 111 | } 112 | 113 | return $boolQuery; 114 | } 115 | 116 | /** 117 | * @param Builder $builder 118 | * @return bool 119 | */ 120 | private static function hasWheres($builder): bool 121 | { 122 | return ! empty($builder->wheres); 123 | } 124 | 125 | /** 126 | * @param Builder $builder 127 | * @return bool 128 | */ 129 | private static function hasWhereIns($builder): bool 130 | { 131 | return isset($builder->whereIns) && ! empty($builder->whereIns); 132 | } 133 | 134 | /** 135 | * @param Builder $builder 136 | * @return bool 137 | */ 138 | private static function hasWhereNotIns($builder): bool 139 | { 140 | return isset($builder->whereNotIns) && ! empty($builder->whereNotIns); 141 | } 142 | 143 | private static function prepareOptions(Builder $builder, array $enforceOptions = []): array 144 | { 145 | $options = []; 146 | 147 | if (isset($builder->limit)) { 148 | $options['size'] = $builder->limit; 149 | } 150 | 151 | return array_merge($options, self::supportedOptions($builder), $enforceOptions); 152 | } 153 | 154 | private static function supportedOptions(Builder $builder): array 155 | { 156 | return Arr::only($builder->options, [ 157 | 'from', 158 | 'source', 159 | ]); 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /src/ElasticSearch/SearchResults.php: -------------------------------------------------------------------------------- 1 | origin = $origin; 21 | } 22 | 23 | public function name(): string 24 | { 25 | return $this->origin->name(); 26 | } 27 | 28 | public function config(): array 29 | { 30 | return array_merge($this->origin->config(), ['is_write_index' => true]); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/ElasticSearchServiceProvider.php: -------------------------------------------------------------------------------- 1 | mergeConfigFrom(__DIR__.'/../config/elasticsearch.php', 'elasticsearch'); 22 | 23 | $this->app->bind(Client::class, function () { 24 | $clientBuilder = ClientBuilder::create() 25 | ->setHosts(Config::hosts()) 26 | ->setSSLVerification(Config::sslVerification()); 27 | if ($user = Config::user()) { 28 | $clientBuilder->setBasicAuthentication($user, Config::password()); 29 | } 30 | 31 | if ($cloudId = Config::elasticCloudId()) { 32 | $clientBuilder->setElasticCloudId($cloudId) 33 | ->setApiKey(Config::apiKey()); 34 | } 35 | 36 | return $clientBuilder->build(); 37 | }); 38 | 39 | $this->app->bind( 40 | HitsIteratorAggregate::class, 41 | EloquentHitsIteratorAggregate::class 42 | ); 43 | } 44 | 45 | /** 46 | * {@inheritdoc} 47 | */ 48 | public function boot(): void 49 | { 50 | $this->publishes([ 51 | __DIR__.'/../config/elasticsearch.php' => config_path('elasticsearch.php'), 52 | ], 'config'); 53 | } 54 | 55 | /** 56 | * {@inheritdoc} 57 | */ 58 | public function provides(): array 59 | { 60 | return [Client::class]; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/Engines/ElasticSearchEngine.php: -------------------------------------------------------------------------------- 1 | elasticsearch = $elasticsearch; 39 | } 40 | 41 | /** 42 | * {@inheritdoc} 43 | */ 44 | public function update($models) 45 | { 46 | $params = new Bulk(); 47 | $params->index($models); 48 | $response = $this->elasticsearch->bulk($params->toArray())->asArray(); 49 | if (array_key_exists('errors', $response) && $response['errors']) { 50 | $error = new ServerResponseException(json_encode($response, JSON_PRETTY_PRINT)); 51 | throw new \Exception('Bulk update error', $error->getCode(), $error); 52 | } 53 | } 54 | 55 | /** 56 | * {@inheritdoc} 57 | */ 58 | public function delete($models) 59 | { 60 | $params = new Bulk(); 61 | $params->delete($models); 62 | $this->elasticsearch->bulk($params->toArray()); 63 | } 64 | 65 | /** 66 | * {@inheritdoc} 67 | */ 68 | public function flush($model) 69 | { 70 | $indexName = $model->searchableAs(); 71 | $exist = $this->elasticsearch->indices()->exists(['index' => $indexName])->asBool(); 72 | if ($exist) { 73 | $body = (new Search())->addQuery(new MatchAllQuery())->toArray(); 74 | $params = new SearchParams($indexName, $body); 75 | $this->elasticsearch->deleteByQuery($params->toArray()); 76 | $this->elasticsearch->indices()->refresh((new Refresh($indexName))->toArray()); 77 | } 78 | } 79 | 80 | /** 81 | * {@inheritdoc} 82 | */ 83 | public function search(BaseBuilder $builder) 84 | { 85 | return $this->performSearch($builder, []); 86 | } 87 | 88 | /** 89 | * {@inheritdoc} 90 | */ 91 | public function paginate(BaseBuilder $builder, $perPage, $page) 92 | { 93 | return $this->performSearch($builder, [ 94 | 'from' => ($page - 1) * $perPage, 95 | 'size' => $perPage, 96 | ]); 97 | } 98 | 99 | /** 100 | * {@inheritdoc} 101 | */ 102 | public function mapIds($results) 103 | { 104 | return collect($results['hits']['hits'])->pluck('_id'); 105 | } 106 | 107 | /** 108 | * {@inheritdoc} 109 | */ 110 | public function map(BaseBuilder $builder, $results, $model) 111 | { 112 | $hits = app()->makeWith( 113 | HitsIteratorAggregate::class, 114 | [ 115 | 'results' => $results, 116 | 'callback' => $builder->queryCallback, 117 | ] 118 | ); 119 | 120 | return new Collection($hits); 121 | } 122 | 123 | /** 124 | * Map the given results to instances of the given model via a lazy collection. 125 | * 126 | * @param \Laravel\Scout\Builder $builder 127 | * @param mixed $results 128 | * @param \Illuminate\Database\Eloquent\Model $model 129 | * @return \Illuminate\Support\LazyCollection 130 | */ 131 | public function lazyMap(Builder $builder, $results, $model) 132 | { 133 | if ((new \ReflectionClass($model))->isAnonymous()) { 134 | throw new \Error('Not implemented for MixedSearch'); 135 | } 136 | 137 | if (count($results['hits']['hits']) === 0) { 138 | return LazyCollection::make($model->newCollection()); 139 | } 140 | 141 | $objectIds = collect($results['hits']['hits'])->pluck('_id')->values()->all(); 142 | $objectIdPositions = array_flip($objectIds); 143 | 144 | return $model->queryScoutModelsByIds( 145 | $builder, $objectIds 146 | )->cursor()->filter(function ($model) use ($objectIds) { 147 | return in_array($model->getScoutKey(), $objectIds); 148 | })->sortBy(function ($model) use ($objectIdPositions) { 149 | return $objectIdPositions[$model->getScoutKey()]; 150 | })->values(); 151 | } 152 | 153 | /** 154 | * Create a search index. 155 | * 156 | * @param string $name 157 | * @param array $options 158 | * @return mixed 159 | */ 160 | public function createIndex($name, array $options = []) 161 | { 162 | throw new \Error('Not implemented'); 163 | } 164 | 165 | /** 166 | * Delete a search index. 167 | * 168 | * @param string $name 169 | * @return mixed 170 | */ 171 | public function deleteIndex($name) 172 | { 173 | throw new \Error('Not implemented'); 174 | } 175 | 176 | /** 177 | * {@inheritdoc} 178 | */ 179 | public function getTotalCount($results) 180 | { 181 | return $results['hits']['total']['value']; 182 | } 183 | 184 | /** 185 | * @param BaseBuilder $builder 186 | * @param array $options 187 | * @return SearchResults|mixed 188 | */ 189 | private function performSearch(BaseBuilder $builder, $options = []) 190 | { 191 | $searchBody = SearchFactory::create($builder, $options); 192 | if ($builder->callback) { 193 | /** @var callable */ 194 | $callback = $builder->callback; 195 | 196 | return call_user_func( 197 | $callback, 198 | $this->elasticsearch, 199 | $searchBody 200 | ); 201 | } 202 | 203 | $model = $builder->model; 204 | $indexName = $builder->index ?: $model->searchableAs(); 205 | $params = new SearchParams($indexName, $searchBody->toArray()); 206 | 207 | return $this->elasticsearch->search($params->toArray())->asArray(); 208 | } 209 | } 210 | -------------------------------------------------------------------------------- /src/Jobs/Import.php: -------------------------------------------------------------------------------- 1 | source = $source; 33 | } 34 | 35 | /** 36 | * @param Client $elasticsearch 37 | */ 38 | public function handle(Client $elasticsearch): void 39 | { 40 | $stages = $this->stages(); 41 | $estimate = $stages->sum->estimate(); 42 | $this->progressBar()->setMaxSteps($estimate); 43 | $stages->each(function ($stage) use ($elasticsearch) { 44 | /** @var StageInterface $stage */ 45 | $this->progressBar()->setMessage($stage->title()); 46 | $stage->handle($elasticsearch); 47 | $this->progressBar()->advance($stage->estimate()); 48 | }); 49 | } 50 | 51 | private function stages(): Collection 52 | { 53 | return ImportStages::fromSource($this->source); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/Jobs/ImportStages.php: -------------------------------------------------------------------------------- 1 | flatten()->filter(); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Jobs/QueueableJob.php: -------------------------------------------------------------------------------- 1 | source = $source; 27 | } 28 | 29 | public function handle(Client $elasticsearch): void 30 | { 31 | $source = $this->source; 32 | $params = GetAliasParams::anyIndex($source->searchableAs()); 33 | try { 34 | $response = $elasticsearch->indices()->getAlias($params->toArray())->asArray(); 35 | } catch (ClientResponseException $e) { 36 | $response = []; 37 | } 38 | foreach ($response as $indexName => $data) { 39 | foreach ($data['aliases'] as $alias => $config) { 40 | if (array_key_exists('is_write_index', $config) && $config['is_write_index']) { 41 | $params = new DeleteIndexParams((string) $indexName); 42 | $elasticsearch->indices()->delete($params->toArray()); 43 | continue 2; 44 | } 45 | } 46 | } 47 | } 48 | 49 | public function title(): string 50 | { 51 | return 'Clean up'; 52 | } 53 | 54 | public function estimate(): int 55 | { 56 | return 1; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/Jobs/Stages/CreateWriteIndex.php: -------------------------------------------------------------------------------- 1 | source = $source; 34 | $this->index = $index; 35 | } 36 | 37 | public function handle(Client $elasticsearch): void 38 | { 39 | $source = $this->source; 40 | $this->index->addAlias( 41 | new FilteredAlias( 42 | new WriteAlias(new DefaultAlias($source->searchableAs())), 43 | $this->index 44 | ) 45 | ); 46 | 47 | $params = new Create( 48 | $this->index->name(), 49 | $this->index->config() 50 | ); 51 | 52 | $elasticsearch->indices()->create($params->toArray()); 53 | } 54 | 55 | public function title(): string 56 | { 57 | return 'Create write index'; 58 | } 59 | 60 | public function estimate(): int 61 | { 62 | return 1; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/Jobs/Stages/PullFromSource.php: -------------------------------------------------------------------------------- 1 | source = $source; 25 | } 26 | 27 | public function handle(?Client $elasticsearch = null): void 28 | { 29 | $results = $this->source->get()->filter->shouldBeSearchable(); 30 | if (! $results->isEmpty()) { 31 | $results->first()->searchableUsing()->update($results); 32 | } 33 | } 34 | 35 | public function estimate(): int 36 | { 37 | return 1; 38 | } 39 | 40 | public function title(): string 41 | { 42 | return 'Indexing...'; 43 | } 44 | 45 | /** 46 | * @param ImportSource $source 47 | * @return Collection 48 | */ 49 | public static function chunked(ImportSource $source): Collection 50 | { 51 | return $source->chunked()->map(function ($chunk) { 52 | return new static($chunk); 53 | }); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/Jobs/Stages/RefreshIndex.php: -------------------------------------------------------------------------------- 1 | index = $index; 27 | } 28 | 29 | public function handle(Client $elasticsearch): void 30 | { 31 | $params = new Refresh($this->index->name()); 32 | $elasticsearch->indices()->refresh($params->toArray()); 33 | } 34 | 35 | public function estimate(): int 36 | { 37 | return 1; 38 | } 39 | 40 | public function title(): string 41 | { 42 | return 'Refreshing index'; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/Jobs/Stages/StageInterface.php: -------------------------------------------------------------------------------- 1 | source = $source; 32 | $this->index = $index; 33 | } 34 | 35 | public function handle(Client $elasticsearch): void 36 | { 37 | $source = $this->source; 38 | $params = Get::anyIndex($source->searchableAs()); 39 | $response = $elasticsearch->indices()->getAlias($params->toArray())->asArray(); 40 | 41 | $params = new Update(); 42 | foreach ($response as $indexName => $alias) { 43 | if ($indexName != $this->index->name()) { 44 | $params->removeIndex((string) $indexName); 45 | } else { 46 | $params->add((string) $indexName, $source->searchableAs()); 47 | } 48 | } 49 | $elasticsearch->indices()->updateAliases($params->toArray()); 50 | } 51 | 52 | public function estimate(): int 53 | { 54 | return 1; 55 | } 56 | 57 | public function title(): string 58 | { 59 | return 'Switching to the new index'; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/MixedSearch.php: -------------------------------------------------------------------------------- 1 | progressBar = $progressBar; 18 | } 19 | 20 | private function progressBar(): ProgressBar 21 | { 22 | return $this->progressBar ?: new ProgressBar(new NullOutput()); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/ScoutElasticSearchServiceProvider.php: -------------------------------------------------------------------------------- 1 | loadTranslationsFrom(__DIR__.'/../resources/lang', 'scout'); 25 | 26 | $this->app->make(EngineManager::class)->extend(ElasticSearchEngine::class, function () { 27 | $elasticsearch = app(Client::class); 28 | 29 | return new ElasticSearchEngine($elasticsearch); 30 | }); 31 | $this->registerCommands(); 32 | } 33 | 34 | /** 35 | * {@inheritdoc} 36 | */ 37 | public function register(): void 38 | { 39 | $this->app->register(ScoutServiceProvider::class); 40 | $this->app->bind(ImportSourceFactory::class, DefaultImportSourceFactory::class); 41 | } 42 | 43 | /** 44 | * Register artisan commands. 45 | * 46 | * @return void 47 | */ 48 | private function registerCommands(): void 49 | { 50 | if ($this->app->runningInConsole()) { 51 | $this->commands([ 52 | ImportCommand::class, 53 | FlushCommand::class, 54 | ]); 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/Searchable/DefaultImportSource.php: -------------------------------------------------------------------------------- 1 | className = $className; 32 | $this->scopes = $scopes; 33 | } 34 | 35 | public function syncWithSearchUsingQueue(): ?string 36 | { 37 | return $this->model()->syncWithSearchUsingQueue(); 38 | } 39 | 40 | public function syncWithSearchUsing(): ?string 41 | { 42 | return $this->model()->syncWithSearchUsing(); 43 | } 44 | 45 | public function searchableAs(): string 46 | { 47 | return $this->model()->searchableAs(); 48 | } 49 | 50 | public function chunked(): Collection 51 | { 52 | $query = $this->newQuery(); 53 | $totalSearchables = $query->toBase()->getCountForPagination(); 54 | if ($totalSearchables) { 55 | $chunkSize = (int) config('scout.chunk.searchable', self::DEFAULT_CHUNK_SIZE); 56 | $totalChunks = (int) ceil($totalSearchables / $chunkSize); 57 | 58 | return collect(range(1, $totalChunks))->map(function ($page) use ($chunkSize) { 59 | $chunkScope = new PageScope($page, $chunkSize); 60 | 61 | return new static($this->className, array_merge($this->scopes, [$chunkScope])); 62 | }); 63 | } else { 64 | return collect(); 65 | } 66 | } 67 | 68 | /** 69 | * @return mixed 70 | */ 71 | private function model() 72 | { 73 | return new $this->className; 74 | } 75 | 76 | private function newQuery(): Builder 77 | { 78 | $query = $this->className::makeAllSearchableUsing($this->model()->newQuery()); 79 | $softDelete = $this->className::usesSoftDelete() && config('scout.soft_delete', false); 80 | $query 81 | ->when($softDelete, function ($query) { 82 | return $query->withTrashed(); 83 | }) 84 | ->orderBy($this->model()->getQualifiedKeyName()); 85 | $scopes = $this->scopes; 86 | 87 | return collect($scopes)->reduce(function ($instance, $scope) { 88 | $instance->withGlobalScope(get_class($scope), $scope); 89 | 90 | return $instance; 91 | }, $query); 92 | } 93 | 94 | public function get(): EloquentCollection 95 | { 96 | /** @var EloquentCollection $models */ 97 | $models = $this->newQuery()->get(); 98 | 99 | return $models; 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/Searchable/DefaultImportSourceFactory.php: -------------------------------------------------------------------------------- 1 | namespace = $namespace; 52 | $this->appPath = $appPath; 53 | } 54 | 55 | /** 56 | * @return array 57 | */ 58 | public function getErrors(): array 59 | { 60 | return $this->errors; 61 | } 62 | 63 | /** 64 | * @return Collection 65 | */ 66 | public function make(): Collection 67 | { 68 | return new Collection($this->find()); 69 | } 70 | 71 | /** 72 | * Get a list of searchable models. 73 | * 74 | * @return string[] 75 | */ 76 | private function find(): array 77 | { 78 | $appNamespace = $this->namespace; 79 | 80 | return array_values(array_filter($this->getSearchableClasses(), static function (string $class) use ($appNamespace) { 81 | return Str::startsWith($class, $appNamespace); 82 | })); 83 | } 84 | 85 | /** 86 | * @return string[] 87 | */ 88 | private function getSearchableClasses(): array 89 | { 90 | if (self::$searchableClasses === null) { 91 | self::$searchableClasses = $this->getProjectClasses()->filter(function ($class) { 92 | return $this->findSearchableTraitRecursively($class); 93 | })->toArray(); 94 | } 95 | 96 | return self::$searchableClasses; 97 | } 98 | 99 | /** 100 | * @return Collection 101 | */ 102 | private function getProjectClasses(): Collection 103 | { 104 | /** @var Class_[] $nodes */ 105 | $nodes = (new NodeFinder())->find($this->getStmts(), function (Node $node) { 106 | return $node instanceof Class_; 107 | }); 108 | 109 | return Collection::make($nodes)->map(function ($node) { 110 | return $node->namespacedName->toCodeString(); 111 | }); 112 | } 113 | 114 | /** 115 | * @return array 116 | */ 117 | private function getStmts(): array 118 | { 119 | $parser = (new ParserFactory())->createForHostVersion(); 120 | $nameResolverVisitor = new NameResolver(); 121 | $nodeTraverser = new NodeTraverser(); 122 | $nodeTraverser->addVisitor($nameResolverVisitor); 123 | $stmts = []; 124 | foreach (Finder::create()->files()->name('*.php')->in($this->appPath) as $file) { 125 | try { 126 | $stmts[] = $parser->parse($file->getContents()); 127 | } catch (Error $e) { 128 | $this->errors[] = $e->getMessage(); 129 | } 130 | } 131 | 132 | $stmts = Collection::make($stmts)->flatten(1)->toArray(); 133 | 134 | return $nodeTraverser->traverse($stmts); 135 | } 136 | 137 | /** 138 | * @param string $class 139 | * @return bool 140 | */ 141 | private function findSearchableTraitRecursively(string $class): bool 142 | { 143 | try { 144 | $reflection = $this->reflector()->reflectClass($class); 145 | 146 | if (in_array(Searchable::class, $traits = $reflection->getTraitNames())) { 147 | return true; 148 | } 149 | 150 | foreach ($traits as $trait) { 151 | if ($this->findSearchableTraitRecursively($trait)) { 152 | return true; 153 | } 154 | } 155 | 156 | return ($parent = $reflection->getParentClass()) && $this->findSearchableTraitRecursively($parent->getName()); 157 | } catch (IdentifierNotFound $e) { 158 | $this->errors[] = $e->getMessage(); 159 | 160 | return false; 161 | } 162 | } 163 | 164 | /** 165 | * @return Reflector 166 | */ 167 | private function reflector(): Reflector 168 | { 169 | if (null === $this->reflector) { 170 | $this->reflector = (new BetterReflection())->reflector(); 171 | } 172 | 173 | return $this->reflector; 174 | } 175 | } 176 | --------------------------------------------------------------------------------