├── .circleci └── config.yml ├── .dockerignore ├── .github ├── ISSUE_TEMPLATE.md └── PULL_REQUEST_TEMPLATE.md ├── DOCKER_README.MD ├── Dockerfile ├── LICENSE.md ├── composer.json ├── resources └── views │ └── config.blade.php └── src ├── Algolia.php ├── Builder.php ├── Console └── Commands │ ├── FlushCommand.php │ ├── ImportCommand.php │ ├── MakeAggregatorCommand.php │ ├── OptimizeCommand.php │ ├── ReImportCommand.php │ ├── StatusCommand.php │ ├── SyncCommand.php │ └── stubs │ └── aggregator.stub ├── Contracts ├── LocalSettingsRepositoryContract.php ├── SearchableCountableContract.php ├── SplitterContract.php └── TransformerContract.php ├── Engines └── AlgoliaEngine.php ├── Exceptions ├── ModelNotDefinedInAggregatorException.php ├── ModelNotFoundException.php ├── SettingsNotFound.php └── ShouldReimportSearchableException.php ├── Facades └── Algolia.php ├── Helpers └── SearchableFinder.php ├── Jobs ├── DeleteJob.php └── UpdateJob.php ├── Managers └── EngineManager.php ├── Repositories ├── ApiKeysRepository.php ├── LocalSettingsRepository.php ├── RemoteSettingsRepository.php └── UserDataRepository.php ├── ScoutExtendedServiceProvider.php ├── Searchable ├── Aggregator.php ├── AggregatorCollection.php ├── AggregatorObserver.php ├── Aggregators.php ├── ModelsResolver.php ├── ObjectIdEncrypter.php └── RecordsCounter.php ├── Settings ├── Compiler.php ├── Encrypter.php ├── LocalFactory.php ├── Settings.php ├── Status.php └── Synchronizer.php ├── Splitters └── HtmlSplitter.php └── Transformers ├── ConvertDatesToTimestamps.php └── ConvertNumericStringsToNumbers.php /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | # PHP CircleCI 2.0 configuration file 2 | # 3 | # Check https://circleci.com/docs/2.0/language-php/ for more details 4 | # 5 | version: 2.1 6 | 7 | aliases: 8 | - &restore_cache 9 | name: restore Composer cache 10 | keys: 11 | - composer-deps-<>-{{ checksum "composer.json" }} 12 | 13 | - &install_dependencies 14 | name: Install Composer dependencies 15 | command: COMPOSER_MEMORY_LIMIT=-1 composer require "illuminate/contracts=<>" --dev --prefer-dist --no-interaction 16 | 17 | - &save_cache 18 | name: save Composer cache 19 | key: composer-deps-<>-{{ checksum "composer.json" }} 20 | paths: 21 | - ./vendor 22 | 23 | - &credentials 24 | name: Get API Key Dealer client 25 | command: | 26 | if [ "${CIRCLE_PR_REPONAME}" ] 27 | then 28 | curl -s https://algoliasearch-client-keygen.herokuapp.com | bash >> "$BASH_ENV" 29 | fi 30 | 31 | - &test 32 | name: Run tests 33 | command: | 34 | export CI_BUILD_NUM=$CIRCLE_BUILD_NUM 35 | export COMPOSER_PROCESS_TIMEOUT=900 36 | if [ -z ${CIRCLE_PR_REPONAME+x} ] 37 | then 38 | composer test 39 | else 40 | export CI_PROJ_USERNAME=$CIRCLE_PROJECT_USERNAME 41 | export CI_PROJ_REPONAME=$CIRCLE_PROJECT_REPONAME 42 | export TRAVIS_REPO_SLUG="$CIRCLE_PROJECT_USERNAME/$CIRCLE_PROJECT_REPONAME" 43 | 44 | eval $(./algolia-keys export) && composer test 45 | fi 46 | 47 | executors: 48 | php-docker: # declares a reusable executor 49 | parameters: 50 | version: 51 | description: "PHP version tag" 52 | type: string 53 | docker: 54 | - image: cimg/php:<> 55 | 56 | jobs: 57 | test: 58 | parameters: 59 | version: 60 | description: "PHP version tag" 61 | type: string 62 | laravel: 63 | description: "Laravel version tag" 64 | type: string 65 | 66 | executor: 67 | name: php-docker 68 | version: <> 69 | 70 | steps: 71 | - checkout 72 | 73 | - run: sudo add-apt-repository ppa:ondrej/php -y 74 | - run: sudo apt-get update 75 | - run: sudo apt-get install -y php<>-zip php<>-sqlite3 76 | - restore_cache: *restore_cache 77 | - run: *install_dependencies 78 | - save_cache: *save_cache 79 | - run: *credentials 80 | - run: *test 81 | 82 | workflows: 83 | workflow: 84 | jobs: 85 | - test: 86 | matrix: 87 | parameters: 88 | version: ['8.0', '8.1', '8.2'] 89 | laravel: ['^9.0'] 90 | - test: 91 | matrix: 92 | parameters: 93 | version: ['8.1', '8.2'] 94 | laravel: ['^10.0'] 95 | - test: 96 | matrix: 97 | parameters: 98 | version: ['8.2', '8.3', '8.4'] 99 | laravel: ['^11.0'] 100 | - test: 101 | matrix: 102 | parameters: 103 | version: ['8.2', '8.3', '8.4'] 104 | laravel: ['^12.0'] 105 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .idea/* 2 | .phpintel 3 | /vendor/ 4 | .phpunit.result.cache 5 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 13 | 14 | - Laravel version: 15 | - Algolia Scout Extended version: 16 | - Algolia Client Version: #.#.# 17 | - Language Version: 18 | 19 | ### Description 20 | 21 | 22 | ### Steps To Reproduce 23 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | | Q | A 2 | | ----------------- | ---------- 3 | | Bug fix? | yes/no 4 | | New feature? | yes/no 5 | | BC breaks? | no 6 | | Related Issue | Fix #... 7 | | Need Doc update | yes/no 8 | 9 | 10 | ## Describe your change 11 | 12 | 16 | 17 | ## What problem is this fixing? 18 | 19 | 23 | -------------------------------------------------------------------------------- /DOCKER_README.MD: -------------------------------------------------------------------------------- 1 | In this page you will find our recommended way of installing Docker on your machine. 2 | This guide is made for OSX users. 3 | 4 | ## Install Docker 5 | 6 | First install Docker using [Homebrew](https://brew.sh/) 7 | ``` 8 | $ brew install docker 9 | ``` 10 | 11 | You can then install [Docker Desktop](https://docs.docker.com/get-docker/) if you wish, or use `docker-machine`. As we prefer the second option, we will only document this one. 12 | 13 | ## Setup your Docker 14 | 15 | Install `docker-machine` 16 | ``` 17 | $ brew install docker-machine 18 | ``` 19 | 20 | Then install [VirtualBox](https://www.virtualbox.org/) with [Homebrew Cask](https://github.com/Homebrew/homebrew-cask) to get a driver for your Docker machine 21 | ``` 22 | $ brew cask install virtualbox 23 | ``` 24 | 25 | You may need to enter your password and authorize the application in your `System Settings` > `Security & Privacy`. 26 | 27 | Create now a new machine, set it up as default and connect your shell to it (here we use zsh. The commands should anyway be displayed in each steps' output) 28 | 29 | ``` 30 | $ docker-machine create --driver virtualbox default 31 | $ docker-machine env default 32 | $ eval "$(docker-machine env default)" 33 | ``` 34 | 35 | Now you're all setup to use our provided Docker image! 36 | 37 | ## Build the image 38 | 39 | ```bash 40 | docker build -t algolia-scout-extended . 41 | ``` 42 | 43 | ## Run the image 44 | 45 | You need to provide few environment variables at runtime to be able to run the [Common Test Suite](https://github.com/algolia/algoliasearch-client-specs/tree/master/common-test-suite). 46 | You can set them up directly in the command: 47 | 48 | ```bash 49 | docker run -it --rm --env ALGOLIA_APP_ID=XXXXXX [...] -v $PWD:/app -w /app algolia-scout-extended bash 50 | ``` 51 | 52 | However, we advise you to export them in your `.bashrc` or `.zshrc`. That way, you can use [Docker's shorten syntax](https://docs.docker.com/engine/reference/commandline/run/#set-environment-variables--e---env---env-file) to set your variables. 53 | 54 | ```bash 55 | docker run -it --rm --env ALGOLIA_APP_ID \ 56 | --env ALGOLIA_SECRET \ 57 | -v $PWD:/app -w /app algolia-scout-extended bash 58 | ``` 59 | 60 | Once your container is running, any changes you make in your IDE are directly reflected in the container. 61 | 62 | To launch the tests, you can use one of the following commands 63 | ```shell script 64 | # run only the unit tests 65 | composer test 66 | 67 | # run a single test 68 | ./vendor/bin/phpunit --filter=nameOfYourTests 69 | ``` 70 | 71 | You can find more commands in the `composer.json` file. 72 | 73 | Feel free to contact us if you have any questions. 74 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Dockerfile 2 | FROM php:7.4.1-fpm 3 | 4 | RUN apt-get update -y \ 5 | && apt-get install -y --no-install-recommends \ 6 | wget \ 7 | zip \ 8 | unzip \ 9 | && apt-get clean \ 10 | && rm -rf /var/lib/apt/lists/* 11 | 12 | # Install Composer 13 | COPY --from=composer:1.9.0 /usr/bin/composer /usr/bin/composer 14 | 15 | WORKDIR /app 16 | ADD . /app/ 17 | 18 | RUN composer install 19 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2014-Present Algolia 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "algolia/scout-extended", 3 | "description": "Scout Extended extends Laravel Scout adding algolia-specific features", 4 | "keywords": [ 5 | "laravel", 6 | "scout", 7 | "algolia", 8 | "extended", 9 | "search", 10 | "places", 11 | "analytics" 12 | ], 13 | "license": "MIT", 14 | "authors": [ 15 | { 16 | "name": "Nuno Maduro", 17 | "email": "enunomaduro@gmail.com" 18 | }, 19 | { 20 | "name": "Algolia Team", 21 | "email": "contact@algolia.com" 22 | } 23 | ], 24 | "require": { 25 | "php": "^8.0", 26 | "ext-json": "*", 27 | "algolia/algoliasearch-client-php": "^3.0.0", 28 | "illuminate/console": "^9.0|^10.0|^11.0|^12.0", 29 | "illuminate/contracts": "^9.0|^10.0|^11.0|^12.0", 30 | "illuminate/database": "^9.0|^10.0|^11.0|^12.0", 31 | "illuminate/filesystem": "^9.0|^10.0|^11.0|^12.0", 32 | "illuminate/support": "^9.0|^10.0|^11.0|^12.0", 33 | "laravel/scout": "^9.0|^10.0", 34 | "riimu/kit-phpencoder": "^2.4" 35 | }, 36 | "suggest": { 37 | "ext-dom": "Required to use the HTML Splitter." 38 | }, 39 | "require-dev": { 40 | "fakerphp/faker": "^1.13", 41 | "mockery/mockery": "^1.4", 42 | "larastan/larastan": "^1.0|^2.0|^3.0", 43 | "orchestra/testbench": "^4.9|^5.9|^6.6|^7.0|^8.0|^9.0|^10.0", 44 | "phpstan/phpstan": "^1.3|^2.1", 45 | "phpunit/phpunit": "^8.0|^9.0|^10.5|^11.5.3", 46 | "laravel/legacy-factories": "^1.1" 47 | }, 48 | "autoload-dev": { 49 | "psr-4": { 50 | "Tests\\": "tests/", 51 | "App\\": "tests/laravel/app", 52 | "Modules\\": "tests/laravel/modules" 53 | }, 54 | "files": [ 55 | "vendor/mockery/mockery/library/helpers.php" 56 | ] 57 | }, 58 | "minimum-stability": "dev", 59 | "prefer-stable": true, 60 | "autoload": { 61 | "psr-4": { 62 | "Algolia\\ScoutExtended\\": "src/" 63 | } 64 | }, 65 | "extra": { 66 | "laravel": { 67 | "providers": [ 68 | "Algolia\\ScoutExtended\\ScoutExtendedServiceProvider" 69 | ] 70 | } 71 | }, 72 | "config": { 73 | "sort-packages": true, 74 | "preferred-install": "dist" 75 | }, 76 | "scripts": { 77 | "phpstan:test": "phpstan analyse --ansi --memory-limit=-1", 78 | "phpunit:test": "phpunit --colors=always", 79 | "test": [ 80 | "@phpstan:test", 81 | "@phpunit:test" 82 | ] 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /resources/views/config.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | return [ 4 | 5 | /* 6 | |-------------------------------------------------------------------------- 7 | | Searchable Attributes 8 | |-------------------------------------------------------------------------- 9 | | 10 | | Limits the scope of a search to the attributes listed in this setting. Defining 11 | | specific attributes as searchable is critical for relevance because it gives 12 | | you direct control over what information the search engine should look at. 13 | | 14 | | Supported: Null, Array 15 | | Example: ["name", "email", "unordered(city)"] 16 | | 17 | */ 18 | 19 | 'searchableAttributes' => {!! $searchableAttributes ?? 'null' !!}, 20 | 21 | /* 22 | |-------------------------------------------------------------------------- 23 | | Custom Ranking 24 | |-------------------------------------------------------------------------- 25 | | 26 | | Custom Ranking is about leveraging business metrics to effectively rank search 27 | | results - it's crucial for any successful search experience. Make sure that 28 | | only "numeric" attributes are used, such as the number of sales or views. 29 | | 30 | | Supported: Null, Array 31 | | Examples: ['desc(comments_count)', 'desc(views_count)'] 32 | | 33 | */ 34 | 35 | 'customRanking' => {!! $customRanking ?? 'null'!!}, 36 | 37 | /* 38 | |-------------------------------------------------------------------------- 39 | | Remove Stop Words 40 | |-------------------------------------------------------------------------- 41 | | 42 | | Stop word removal is useful when you have a query in natural language, e.g. 43 | | “what is a record?”. In that case, the engine will remove “what”, “is”, 44 | | before executing the query, and therefore just search for “record”. 45 | | 46 | | Supported: Null, Boolean, Array 47 | | 48 | */ 49 | 50 | 'removeStopWords' => {!! $removeStopWords ?? 'null' !!}, 51 | 52 | /* 53 | |-------------------------------------------------------------------------- 54 | | Disable Typo Tolerance 55 | |-------------------------------------------------------------------------- 56 | | 57 | | Algolia provides robust "typo-tolerance" out-of-the-box. This parameter accepts an 58 | | array of attributes for which typo-tolerance should be disabled. This is useful, 59 | | for example, products that might require SKU search without "typo-tolerance". 60 | | 61 | | Supported: Null, Array 62 | | Example: ['id', 'sku', 'reference', 'code'] 63 | | 64 | */ 65 | 66 | 'disableTypoToleranceOnAttributes' => {!! $disableTypoToleranceOnAttributes ?? 'null' !!}, 67 | 68 | /* 69 | |-------------------------------------------------------------------------- 70 | | Attributes For Faceting 71 | |-------------------------------------------------------------------------- 72 | | 73 | | Your index comes with no categories. By designating an attribute as a facet, this enables 74 | | Algolia to compute a set of possible values that can later be used to create categories 75 | | or filters. You can also get a count of records that match those values. 76 | | 77 | | Supported: Null, Array 78 | | Example: ['type', 'filterOnly(country)', 'searchable(city)',] 79 | | 80 | */ 81 | 82 | 'attributesForFaceting' => {!! $attributesForFaceting ?? 'null' !!}, 83 | 84 | /* 85 | |-------------------------------------------------------------------------- 86 | | Unretrievable Attributes 87 | |-------------------------------------------------------------------------- 88 | | 89 | | This is particularly important for security or business reasons, where some attributes are 90 | | used only for ranking or other technical purposes, but should never be seen by your end 91 | | users, such as: total_sales, permissions, stock_count, and other private information. 92 | | 93 | | Supported: Null, Array 94 | | Example: ['total_sales', 'permissions', 'stock_count',] 95 | | 96 | */ 97 | 98 | 'unretrievableAttributes' => {!! $unretrievableAttributes ?? 'null' !!}, 99 | 100 | /* 101 | |-------------------------------------------------------------------------- 102 | | Ignore Plurals 103 | |-------------------------------------------------------------------------- 104 | | 105 | | Treats singular, plurals, and other forms of declensions as matching terms. When 106 | | enabled, will make the engine consider “car” and “cars”, or “foot” and “feet”, 107 | | equivalent. This is used in conjunction with the "queryLanguages" setting. 108 | | 109 | | Supported: Null, Boolean, Array 110 | | 111 | */ 112 | 113 | 'ignorePlurals' => {!! $ignorePlurals ?? 'null' !!}, 114 | 115 | /* 116 | |-------------------------------------------------------------------------- 117 | | Query Languages 118 | |-------------------------------------------------------------------------- 119 | | 120 | | Sets the languages to be used by language-specific settings such as 121 | | "removeStopWords" or "ignorePlurals". For optimum relevance, it is 122 | | recommended to only enable languages that are used in your data. 123 | | 124 | | Supported: Null, Array 125 | | Example: ['en', 'fr',] 126 | | 127 | */ 128 | 129 | 'queryLanguages' => {!! $queryLanguages ?? 'null' !!}, 130 | 131 | /* 132 | |-------------------------------------------------------------------------- 133 | | Distinct 134 | |-------------------------------------------------------------------------- 135 | | 136 | | Using this attribute, you can limit the number of returned records that contain the same 137 | | value in that attribute. For example, if the distinct attribute is the series_name and 138 | | several hits (Episodes) have the same value for series_name (Laravel From Scratch). 139 | | 140 | | Supported(distinct): Boolean 141 | | Supported(attributeForDistinct): Null, String 142 | | Example(attributeForDistinct): 'slug' 143 | */ 144 | 145 | 'distinct' => {!! $distinct ?? 'false' !!}, 146 | 'attributeForDistinct' => {!! $attributeForDistinct ?? 'null' !!}, 147 | 148 | /* 149 | |-------------------------------------------------------------------------- 150 | | Other Settings 151 | |-------------------------------------------------------------------------- 152 | | 153 | | The easiest way to manage your settings is usually to go to your Algolia dashboard because 154 | | it has a nice UI and you can test the relevancy directly there. Once you fine-tuned your 155 | | configuration, just use the command `scout:sync` to get remote settings in this file. 156 | | 157 | */ 158 | {!! $__indexChangedSettings !!}; 159 | -------------------------------------------------------------------------------- /src/Algolia.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Algolia\ScoutExtended; 15 | 16 | use Algolia\AlgoliaSearch\AnalyticsClient; 17 | use Algolia\AlgoliaSearch\SearchClient; 18 | use Algolia\AlgoliaSearch\SearchIndex; 19 | use Algolia\ScoutExtended\Repositories\ApiKeysRepository; 20 | use Illuminate\Contracts\Container\Container; 21 | use function is_string; 22 | 23 | class Algolia 24 | { 25 | /** 26 | * @var \Illuminate\Contracts\Container\Container 27 | */ 28 | private $container; 29 | 30 | /** 31 | * Algolia constructor. 32 | * 33 | * @param \Illuminate\Contracts\Container\Container $container 34 | * 35 | * @return void 36 | */ 37 | public function __construct(Container $container) 38 | { 39 | $this->container = $container; 40 | } 41 | 42 | /** 43 | * Get a index instance. 44 | * 45 | * @param string|object $searchable 46 | * 47 | * @return \Algolia\AlgoliaSearch\SearchIndex 48 | */ 49 | public function index($searchable): SearchIndex 50 | { 51 | $searchable = is_string($searchable) ? new $searchable : $searchable; 52 | 53 | return $this->client()->initIndex($searchable->searchableAs()); 54 | } 55 | 56 | /** 57 | * Get a client instance. 58 | * 59 | * @return \Algolia\AlgoliaSearch\SearchClient 60 | */ 61 | public function client(): SearchClient 62 | { 63 | return $this->container->get('algolia.client'); 64 | } 65 | 66 | /** 67 | * Get a analytics instance. 68 | * 69 | * @return \Algolia\AlgoliaSearch\AnalyticsClient 70 | */ 71 | public function analytics(): AnalyticsClient 72 | { 73 | return $this->container->get('algolia.analytics'); 74 | } 75 | 76 | /** 77 | * Get a search key for the given searchable. 78 | * 79 | * @param string|object $searchable 80 | * 81 | * @return string 82 | */ 83 | public function searchKey($searchable): string 84 | { 85 | $searchable = is_string($searchable) ? new $searchable : $searchable; 86 | 87 | return $this->container->make(ApiKeysRepository::class)->getSearchKey($searchable); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/Builder.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Algolia\ScoutExtended; 15 | 16 | use Illuminate\Support\Collection; 17 | use function func_num_args; 18 | use function is_callable; 19 | use Laravel\Scout\Builder as BaseBuilder; 20 | 21 | class Builder extends BaseBuilder 22 | { 23 | /** 24 | * @var Collection 25 | */ 26 | private $optionalFilters; 27 | 28 | /** 29 | * {@inheritdoc} 30 | * 31 | * @see https://github.com/algolia/scout-extended/issues/98 32 | */ 33 | public function __construct($model, $query, $callback = null, $softDelete = false) 34 | { 35 | parent::__construct($model, (string) $query, $callback, $softDelete); 36 | $this->optionalFilters = collect(); 37 | } 38 | 39 | /** 40 | * Customize the search to be around a given location. 41 | * 42 | * @link https://www.algolia.com/doc/guides/geo-search/geo-search-overview 43 | * 44 | * @param float $lat Latitude of the center 45 | * @param float $lng Longitude of the center 46 | * 47 | * @return $this 48 | */ 49 | public function aroundLatLng(float $lat, float $lng): self 50 | { 51 | return $this->with([ 52 | 'aroundLatLng' => $lat.','.$lng, 53 | ]); 54 | } 55 | 56 | /** 57 | * Count the number of items in the search results. 58 | * 59 | * @return int 60 | */ 61 | public function count(): int 62 | { 63 | $raw = $this->raw(); 64 | 65 | return array_key_exists('nbHits', $raw) ? (int) $raw['nbHits'] : 0; 66 | } 67 | 68 | /** 69 | * Customize the search adding a where clause. 70 | * 71 | * @param string $field 72 | * @param mixed $operator 73 | * @param mixed $value 74 | * 75 | * @return $this 76 | */ 77 | public function where($field, $operator, $value = null): self 78 | { 79 | // Here we will make some assumptions about the operator. If only 2 values are 80 | // passed to the method, we will assume that the operator is an equals sign 81 | // and keep going. Otherwise, we'll require the operator to be passed in. 82 | if (func_num_args() === 2) { 83 | return parent::where($field, $this->transform($operator)); 84 | } 85 | 86 | return parent::where($field, "$operator {$this->transform($value)}"); 87 | } 88 | 89 | /** 90 | * Customize the search adding a where between clause. 91 | * 92 | * @param string $field 93 | * @param array $values 94 | * 95 | * @return $this 96 | */ 97 | public function whereBetween($field, array $values): self 98 | { 99 | return $this->where("$field:", "{$this->transform($values[0])} TO {$this->transform($values[1])}"); 100 | } 101 | 102 | /** 103 | * Customize the search adding a where in clause. 104 | * 105 | * @param string $field 106 | * @param array $values 107 | * 108 | * @return $this 109 | */ 110 | public function whereIn($field, $values): self 111 | { 112 | if(! empty($values)) { 113 | $wheres = array_map(function ($value) use ($field) { 114 | return "$field={$this->transform($value)}"; 115 | }, array_values($values)); 116 | } else { 117 | $wheres = ['0 = 1']; 118 | } 119 | 120 | $this->wheres[] = $wheres; 121 | 122 | return $this; 123 | } 124 | 125 | /** 126 | * Add an optional filter to the search parameters. 127 | * 128 | * @link https://www.algolia.com/doc/api-reference/api-parameters/optionalFilters/ 129 | * 130 | * @param string $field 131 | * @param mixed $value 132 | * 133 | * @return $this 134 | */ 135 | public function whereOptional($field, $value): self 136 | { 137 | $this->optionalFilters->push("$field:$value"); 138 | return $this->with(['optionalFilters' => $this->optionalFilters->join(',')]); 139 | } 140 | 141 | /** 142 | * Customize the search with the provided search parameters. 143 | * 144 | * @link https://www.algolia.com/doc/api-reference/search-api-parameters 145 | * 146 | * @param array $parameters The search parameters. 147 | * 148 | * @return $this 149 | */ 150 | public function with(array $parameters): self 151 | { 152 | $callback = $this->callback; 153 | 154 | $this->callback = function ($algolia, $query, $baseParameters) use ($parameters, $callback) { 155 | $parameters = array_merge($parameters, $baseParameters); 156 | 157 | if (is_callable($callback)) { 158 | return $callback($algolia, $query, $parameters); 159 | } 160 | 161 | return $algolia->search($query, $parameters); 162 | }; 163 | 164 | return $this; 165 | } 166 | 167 | /** 168 | * Transform the given where value. 169 | * 170 | * @param mixed $value 171 | * 172 | * @return mixed 173 | */ 174 | private function transform($value) 175 | { 176 | /* 177 | * Casts carbon instances to timestamp. 178 | */ 179 | if ($value instanceof \Illuminate\Support\Carbon) { 180 | $value = $value->getTimestamp(); 181 | } 182 | 183 | return $value; 184 | } 185 | } 186 | -------------------------------------------------------------------------------- /src/Console/Commands/FlushCommand.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Algolia\ScoutExtended\Console\Commands; 15 | 16 | use Algolia\ScoutExtended\Algolia; 17 | use Algolia\ScoutExtended\Helpers\SearchableFinder; 18 | use Illuminate\Console\Command; 19 | 20 | class FlushCommand extends Command 21 | { 22 | /** 23 | * {@inheritdoc} 24 | */ 25 | protected $signature = 'scout:flush {searchable? : The name of the searchable}'; 26 | 27 | /** 28 | * {@inheritdoc} 29 | */ 30 | protected $description = 'Flush the index of the the given searchable'; 31 | 32 | /** 33 | * {@inheritdoc} 34 | */ 35 | public function handle(Algolia $algolia, SearchableFinder $searchableFinder): void 36 | { 37 | foreach ($searchableFinder->fromCommand($this) as $searchable) { 38 | $searchable::removeAllFromSearch(); 39 | 40 | $this->output->success('All ['.$searchable.'] records have been flushed.'); 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Console/Commands/ImportCommand.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Algolia\ScoutExtended\Console\Commands; 15 | 16 | use Algolia\ScoutExtended\Helpers\SearchableFinder; 17 | use Algolia\ScoutExtended\Searchable\ObjectIdEncrypter; 18 | use Illuminate\Console\Command; 19 | use Illuminate\Contracts\Events\Dispatcher; 20 | use Illuminate\Support\Collection; 21 | use Laravel\Scout\Events\ModelsImported; 22 | 23 | class ImportCommand extends Command 24 | { 25 | /** 26 | * {@inheritdoc} 27 | */ 28 | protected $signature = 'scout:import {searchable? : The name of the searchable}'; 29 | 30 | /** 31 | * {@inheritdoc} 32 | */ 33 | protected $description = 'Import the given searchable into the search index'; 34 | 35 | /** 36 | * {@inheritdoc} 37 | */ 38 | public function handle(Dispatcher $events, SearchableFinder $searchableFinder): void 39 | { 40 | foreach ($searchableFinder->fromCommand($this) as $searchable) { 41 | $this->call('scout:flush', ['searchable' => $searchable]); 42 | 43 | $events->listen(ModelsImported::class, function ($event) use ($searchable) { 44 | $this->resultMessage($event->models, $searchable); 45 | }); 46 | 47 | $searchable::makeAllSearchable(); 48 | 49 | $events->forget(ModelsImported::class); 50 | 51 | $this->output->success('All ['.$searchable.'] records have been imported.'); 52 | } 53 | } 54 | 55 | /** 56 | * Prints last imported object ID to console output, if any. 57 | * 58 | * @param \Illuminate\Support\Collection $models 59 | * @param string $searchable 60 | * 61 | * @return void 62 | */ 63 | private function resultMessage(Collection $models, string $searchable): void 64 | { 65 | if ($models->count() > 0) { 66 | $last = ObjectIdEncrypter::encrypt($models->last()); 67 | 68 | $this->line('Imported ['.$searchable.'] models up to ID: '.$last); 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/Console/Commands/MakeAggregatorCommand.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Algolia\ScoutExtended\Console\Commands; 15 | 16 | use Illuminate\Console\GeneratorCommand; 17 | 18 | class MakeAggregatorCommand extends GeneratorCommand 19 | { 20 | /** 21 | * {@inheritdoc} 22 | */ 23 | protected $signature = 'scout:make-aggregator {name : The name of the aggregator}'; 24 | 25 | /** 26 | * {@inheritdoc} 27 | */ 28 | protected $description = 'Create a new aggregator class'; 29 | 30 | /** 31 | * {@inheritdoc} 32 | */ 33 | protected $type = 'Aggregator class'; 34 | 35 | /** 36 | * {@inheritdoc} 37 | */ 38 | protected function getStub(): string 39 | { 40 | return __DIR__.'/stubs/aggregator.stub'; 41 | } 42 | 43 | /** 44 | * {@inheritdoc} 45 | */ 46 | protected function getDefaultNamespace($rootNamespace): string 47 | { 48 | return $rootNamespace.'\Search'; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Console/Commands/OptimizeCommand.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Algolia\ScoutExtended\Console\Commands; 15 | 16 | use Algolia\ScoutExtended\Algolia; 17 | use Algolia\ScoutExtended\Contracts\LocalSettingsRepositoryContract; 18 | use Algolia\ScoutExtended\Exceptions\ModelNotFoundException; 19 | use Algolia\ScoutExtended\Helpers\SearchableFinder; 20 | use Algolia\ScoutExtended\Settings\Compiler; 21 | use Algolia\ScoutExtended\Settings\LocalFactory; 22 | use Illuminate\Console\Command; 23 | 24 | class OptimizeCommand extends Command 25 | { 26 | /** 27 | * {@inheritdoc} 28 | */ 29 | protected $signature = 'scout:optimize {searchable? : The name of the searchable}'; 30 | 31 | /** 32 | * {@inheritdoc} 33 | */ 34 | protected $description = 'Optimize the given searchable creating a settings file'; 35 | 36 | /** 37 | * {@inheritdoc} 38 | */ 39 | public function handle( 40 | Algolia $algolia, 41 | LocalFactory $localFactory, 42 | Compiler $compiler, 43 | SearchableFinder $searchableFinder, 44 | LocalSettingsRepositoryContract $localRepository 45 | ) { 46 | foreach ($searchableFinder->fromCommand($this) as $searchable) { 47 | $this->output->text('🔎 Optimizing search experience in: ['.$searchable.']'); 48 | $index = $algolia->index($searchable); 49 | if (! $localRepository->exists($index) || 50 | $this->confirm('Local settings already exists, do you wish to overwrite?')) { 51 | try { 52 | $settings = $localFactory->create($index, $searchable); 53 | } catch (ModelNotFoundException $e) { 54 | $model = $e->getModel(); 55 | $this->output->error("Model not found [$model] resolving [$searchable] settings. Please seed your database with records of this model."); 56 | 57 | return 1; 58 | } 59 | $path = $localRepository->getPath($index); 60 | $compiler->compile($settings, $path); 61 | $this->output->success('Settings file created at: '.$path); 62 | $this->output->note('Please review the settings file and synchronize it with Algolia using '. 63 | 'the Artisan command `scout:sync`.'); 64 | } 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/Console/Commands/ReImportCommand.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Algolia\ScoutExtended\Console\Commands; 15 | 16 | use Algolia\AlgoliaSearch\Exceptions\NotFoundException; 17 | use Algolia\AlgoliaSearch\SearchClient; 18 | use Algolia\AlgoliaSearch\SearchIndex; 19 | use Algolia\ScoutExtended\Helpers\SearchableFinder; 20 | use function count; 21 | use Illuminate\Console\Command; 22 | 23 | class ReImportCommand extends Command 24 | { 25 | /** 26 | * {@inheritdoc} 27 | */ 28 | protected $signature = 'scout:reimport {searchable? : The name of the searchable}'; 29 | 30 | /** 31 | * {@inheritdoc} 32 | */ 33 | protected $description = 'Reimport the given searchable into the search index'; 34 | 35 | /** 36 | * @var string 37 | */ 38 | private static $prefix = 'temp'; 39 | 40 | /** 41 | * {@inheritdoc} 42 | */ 43 | public function handle( 44 | SearchClient $client, 45 | SearchableFinder $searchableModelsFinder 46 | ): void { 47 | $searchables = $searchableModelsFinder->fromCommand($this); 48 | 49 | $config = config(); 50 | 51 | $scoutPrefix = $config->get('scout.prefix'); 52 | 53 | $this->output->text('🔎 Importing: ['.implode(',', $searchables).']'); 54 | $this->output->newLine(); 55 | $this->output->progressStart(count($searchables) * 3); 56 | 57 | foreach ($searchables as $searchable) { 58 | $index = $client->initIndex((new $searchable)->searchableAs()); 59 | $temporaryName = $this->getTemporaryIndexName($index); 60 | 61 | tap($this->output)->progressAdvance()->text("Creating temporary index {$temporaryName}"); 62 | 63 | try { 64 | $index->getSettings(); 65 | 66 | $client->copyIndex($index->getIndexName(), $temporaryName, [ 67 | 'scope' => [ 68 | 'settings', 69 | 'synonyms', 70 | 'rules', 71 | ], 72 | ])->wait(); 73 | } catch (NotFoundException $e) { 74 | // .. 75 | } 76 | 77 | tap($this->output)->progressAdvance()->text("Importing records to index {$temporaryName}"); 78 | 79 | // Force disable queueing to prevent race conditions in indexing with large number of records. 80 | $useQueues = $config->get('scout.queue'); 81 | $config->set('scout.queue', false); 82 | 83 | try { 84 | $config->set('scout.prefix', self::$prefix.'_'.$scoutPrefix); 85 | $searchable::makeAllSearchable(); 86 | } finally { 87 | $config->set('scout.prefix', $scoutPrefix); 88 | } 89 | 90 | $config->set('scout.queue', $useQueues); 91 | 92 | tap($this->output)->progressAdvance() 93 | ->text("Replacing index {$index->getIndexName()} by index {$temporaryName}"); 94 | 95 | $temporaryIndex = $client->initIndex($temporaryName); 96 | 97 | try { 98 | $temporaryIndex->getSettings(); 99 | 100 | $response = $client->moveIndex($temporaryName, $index->getIndexName()); 101 | 102 | if ($config->get('scout.synchronous', false)) { 103 | $response->wait(); 104 | } 105 | } catch (NotFoundException $e) { 106 | if (!$index->exists()) { 107 | $index->setSettings(['attributesForFaceting' => null])->wait(); 108 | } 109 | } 110 | } 111 | 112 | tap($this->output)->success('All ['.implode(',', $searchables).'] records have been imported')->newLine(); 113 | } 114 | 115 | /** 116 | * Get a temporary index name. 117 | * 118 | * @param \Algolia\AlgoliaSearch\SearchIndex $index 119 | * 120 | * @return string 121 | */ 122 | private function getTemporaryIndexName(SearchIndex $index): string 123 | { 124 | return self::$prefix.'_'.$index->getIndexName(); 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/Console/Commands/StatusCommand.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Algolia\ScoutExtended\Console\Commands; 15 | 16 | use Algolia\ScoutExtended\Algolia; 17 | use Algolia\ScoutExtended\Helpers\SearchableFinder; 18 | use Algolia\ScoutExtended\Searchable\RecordsCounter; 19 | use Algolia\ScoutExtended\Settings\Synchronizer; 20 | use function count; 21 | use Illuminate\Console\Command; 22 | 23 | class StatusCommand extends Command 24 | { 25 | /** 26 | * {@inheritdoc} 27 | */ 28 | protected $signature = 'scout:status {searchable? : The name of the searchable}'; 29 | 30 | /** 31 | * {@inheritdoc} 32 | */ 33 | protected $description = 'Show the status of the index of the the given searchable'; 34 | 35 | /** 36 | * {@inheritdoc} 37 | */ 38 | public function handle( 39 | Algolia $algolia, 40 | SearchableFinder $searchableFinder, 41 | Synchronizer $synchronizer, 42 | RecordsCounter $recordsCounter 43 | ): void { 44 | $searchables = $searchableFinder->fromCommand($this); 45 | 46 | $rows = []; 47 | 48 | $this->output->text('🔎 Analysing information from: ['.implode(',', $searchables).']'); 49 | $this->output->newLine(); 50 | $this->output->progressStart(count($searchables)); 51 | 52 | foreach ($searchables as $searchable) { 53 | $row = []; 54 | $instance = $this->laravel->make($searchable); 55 | $index = $algolia->index($instance); 56 | $row[] = $searchable; 57 | $row[] = $instance->searchableAs(); 58 | 59 | $status = $synchronizer->analyse($index); 60 | $description = $status->toHumanString(); 61 | if (! $status->bothAreEqual()) { 62 | $description = "$description"; 63 | } else { 64 | $description = 'Synchronized'; 65 | } 66 | 67 | $row[] = $description; 68 | $row[] = $recordsCounter->local($searchable); 69 | $row[] = $recordsCounter->remote($searchable); 70 | 71 | $rows[] = $row; 72 | $this->output->progressAdvance(); 73 | } 74 | 75 | $this->output->progressFinish(); 76 | $this->output->table(['Searchable', 'Index', 'Settings', 'Local records', 'Remote records'], $rows); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/Console/Commands/SyncCommand.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Algolia\ScoutExtended\Console\Commands; 15 | 16 | use Algolia\ScoutExtended\Algolia; 17 | use Algolia\ScoutExtended\Contracts\LocalSettingsRepositoryContract; 18 | use Algolia\ScoutExtended\Helpers\SearchableFinder; 19 | use Algolia\ScoutExtended\Settings\Status; 20 | use Algolia\ScoutExtended\Settings\Synchronizer; 21 | use Illuminate\Console\Command; 22 | 23 | class SyncCommand extends Command 24 | { 25 | /** 26 | * {@inheritdoc} 27 | */ 28 | protected $signature = 'scout:sync 29 | {searchable? : The name of the searchable} 30 | {--keep=none} : In conflict keep the given option'; 31 | 32 | /** 33 | * {@inheritdoc} 34 | */ 35 | protected $description = 'Synchronize the given searchable settings'; 36 | 37 | /** 38 | * {@inheritdoc} 39 | */ 40 | public function handle( 41 | Algolia $algolia, 42 | Synchronizer $synchronizer, 43 | SearchableFinder $searchableFinder, 44 | LocalSettingsRepositoryContract $localRepository 45 | ): void { 46 | foreach ($searchableFinder->fromCommand($this) as $searchable) { 47 | $this->output->text('🔎 Analysing settings from: ['.$searchable.']'); 48 | $status = $synchronizer->analyse($index = $algolia->index($searchable)); 49 | $path = $localRepository->getPath($index); 50 | 51 | switch ($status->toString()) { 52 | case Status::LOCAL_NOT_FOUND: 53 | if ($status->remoteNotFound()) { 54 | $this->output->note('No settings found.'); 55 | if ($this->output->confirm('Wish to optimize the search experience based on information from the searchable class?')) { 56 | $this->call('scout:optimize', ['searchable' => $searchable]); 57 | 58 | return; 59 | } 60 | } else { 61 | $this->output->note('Remote settings found!'); 62 | $this->output->newLine(); 63 | } 64 | 65 | $this->output->text('⬇️ Downloading remote settings...'); 66 | $synchronizer->download($index); 67 | $this->output->success('Settings file created at: '.$path); 68 | break; 69 | case Status::REMOTE_NOT_FOUND: 70 | $this->output->success('Remote settings does not exists. Uploading settings file: '.$path); 71 | $synchronizer->upload($index); 72 | break; 73 | case Status::BOTH_ARE_EQUAL: 74 | $this->output->success('Local and remote settings match.'); 75 | break; 76 | case Status::LOCAL_GOT_UPDATED: 77 | if ($this->output->confirm('Local settings got updated. Wish to upload them?')) { 78 | $this->output->text('Uploading local settings...'); 79 | $this->output->newLine(); 80 | $synchronizer->upload($index); 81 | } 82 | break; 83 | case Status::REMOTE_GOT_UPDATED: 84 | if ($this->output->confirm('Remote settings got updated. Wish to download them?')) { 85 | $this->output->text('Downloading remote settings...'); 86 | $this->output->newLine(); 87 | $synchronizer->download($index); 88 | } 89 | break; 90 | case Status::BOTH_GOT_UPDATED: 91 | $options = ['none', 'local', 'remote']; 92 | 93 | /** @var string $keep */ 94 | $keep = $this->option('keep'); 95 | 96 | $choice = 97 | $this->output->choice('Remote & Local settings got updated. Which one you want to preserve?', 98 | $options, $keep); 99 | 100 | switch ($choice) { 101 | case 'local': 102 | $this->output->text('Uploading local settings...'); 103 | $this->output->newLine(); 104 | $synchronizer->upload($index); 105 | break; 106 | case 'remote': 107 | $this->output->text('Downloading remote settings...'); 108 | $this->output->newLine(); 109 | $synchronizer->download($index); 110 | break; 111 | } 112 | break; 113 | } 114 | } 115 | 116 | $this->output->newLine(); 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/Console/Commands/stubs/aggregator.stub: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Algolia\ScoutExtended\Contracts; 15 | 16 | use Algolia\AlgoliaSearch\SearchIndex; 17 | use Algolia\ScoutExtended\Settings\Settings; 18 | 19 | interface LocalSettingsRepositoryContract 20 | { 21 | /** 22 | * Checks if the given index settings exists. 23 | * 24 | * @param \Algolia\AlgoliaSearch\SearchIndex $index 25 | * 26 | * @return bool 27 | */ 28 | public function exists(SearchIndex $index): bool; 29 | 30 | /** 31 | * Get the settings path of the given index name. 32 | * 33 | * @param \Algolia\AlgoliaSearch\SearchIndex $index 34 | * 35 | * @return string 36 | */ 37 | public function getPath(SearchIndex $index): string; 38 | 39 | /** 40 | * Find the settings of the given Index. 41 | * 42 | * @param \Algolia\AlgoliaSearch\SearchIndex $index 43 | * 44 | * @return \Algolia\ScoutExtended\Settings\Settings 45 | */ 46 | public function find(SearchIndex $index): Settings; 47 | } 48 | -------------------------------------------------------------------------------- /src/Contracts/SearchableCountableContract.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Algolia\ScoutExtended\Contracts; 15 | 16 | interface SearchableCountableContract 17 | { 18 | /** 19 | * Get the number of searchable records. 20 | * 21 | * @return int 22 | */ 23 | public function getSearchableCount(): int; 24 | } 25 | -------------------------------------------------------------------------------- /src/Contracts/SplitterContract.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Algolia\ScoutExtended\Contracts; 15 | 16 | interface SplitterContract 17 | { 18 | /** 19 | * Splits the given value. 20 | * 21 | * @param object $searchable 22 | * @param string $value 23 | * 24 | * @return array 25 | */ 26 | public function split($searchable, $value): array; 27 | } 28 | -------------------------------------------------------------------------------- /src/Contracts/TransformerContract.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Algolia\ScoutExtended\Contracts; 15 | 16 | interface TransformerContract 17 | { 18 | /** 19 | * Transform the given array. 20 | * 21 | * @param object $searchable 22 | * @param array $array 23 | * 24 | * @return array 25 | */ 26 | public function transform($searchable, array $array): array; 27 | } 28 | -------------------------------------------------------------------------------- /src/Engines/AlgoliaEngine.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Algolia\ScoutExtended\Engines; 15 | 16 | use Algolia\AlgoliaSearch\SearchClient; 17 | use Algolia\ScoutExtended\Jobs\DeleteJob; 18 | use Algolia\ScoutExtended\Jobs\UpdateJob; 19 | use Algolia\ScoutExtended\Searchable\ModelsResolver; 20 | use Algolia\ScoutExtended\Searchable\ObjectIdEncrypter; 21 | use Illuminate\Support\LazyCollection; 22 | use Illuminate\Support\Str; 23 | use Laravel\Scout\Engines\Algolia3Engine; 24 | use Laravel\Scout\Scout; 25 | use function is_array; 26 | use Laravel\Scout\Builder; 27 | 28 | if (version_compare(Scout::VERSION, '10.11.6', '>=')) { 29 | // New Laravel Scout base class for Algolia 30 | class_alias(Algolia3Engine::class, BaseAlgoliaEngine::class); 31 | } else { 32 | // Legacy Laravel Scout class 33 | class_alias(\Laravel\Scout\Engines\AlgoliaEngine::class, BaseAlgoliaEngine::class); 34 | } 35 | 36 | class AlgoliaEngine extends BaseAlgoliaEngine 37 | { 38 | /** 39 | * The Algolia client. 40 | * 41 | * @var \Algolia\AlgoliaSearch\SearchClient 42 | */ 43 | protected $algolia; 44 | 45 | /** 46 | * Create a new engine instance. 47 | * 48 | * @param \Algolia\AlgoliaSearch\SearchClient $algolia 49 | * @return void 50 | */ 51 | public function __construct(SearchClient $algolia) 52 | { 53 | $this->algolia = $algolia; 54 | } 55 | 56 | /** 57 | * @param \Algolia\AlgoliaSearch\SearchClient $algolia 58 | * 59 | * @return void 60 | */ 61 | public function setClient($algolia): void 62 | { 63 | $this->algolia = $algolia; 64 | } 65 | 66 | /** 67 | * Get the client. 68 | * 69 | * @return \Algolia\AlgoliaSearch\SearchClient $algolia 70 | */ 71 | public function getClient(): SearchClient 72 | { 73 | return $this->algolia; 74 | } 75 | 76 | /** 77 | * {@inheritdoc} 78 | */ 79 | public function update($searchables) 80 | { 81 | dispatch_sync(new UpdateJob($searchables)); 82 | } 83 | 84 | /** 85 | * {@inheritdoc} 86 | */ 87 | public function delete($searchables) 88 | { 89 | dispatch_sync(new DeleteJob($searchables)); 90 | } 91 | 92 | /** 93 | * {@inheritdoc} 94 | */ 95 | public function map(Builder $builder, $results, $searchable) 96 | { 97 | if (count($results['hits']) === 0) { 98 | return $searchable->newCollection(); 99 | } 100 | 101 | return app(ModelsResolver::class)->from($builder, $searchable, $results); 102 | } 103 | 104 | /** 105 | * {@inheritdoc} 106 | */ 107 | public function lazyMap(Builder $builder, $results, $searchable) 108 | { 109 | return LazyCollection::make($this->map($builder, $results, $searchable)); 110 | } 111 | 112 | /** 113 | * {@inheritdoc} 114 | */ 115 | public function flush($model) 116 | { 117 | $index = $this->algolia->initIndex($model->searchableAs()); 118 | 119 | $index->clearObjects(); 120 | } 121 | 122 | /** 123 | * {@inheritdoc} 124 | */ 125 | protected function filters(Builder $builder): array 126 | { 127 | $operators = ['<', '<=', '=', '!=', '>=', '>', ':']; 128 | 129 | return collect($builder->wheres)->map(function ($value, $key) use ($operators) { 130 | if (! is_array($value)) { 131 | if (Str::endsWith($key, $operators) || Str::startsWith($value, $operators)) { 132 | return $key.' '.$value; 133 | } 134 | 135 | return $key.'='.$value; 136 | } 137 | 138 | return $value; 139 | })->values()->all(); 140 | } 141 | 142 | /** 143 | * Pluck and return the primary keys of the given results. 144 | * 145 | * @param mixed $results 146 | * @return \Illuminate\Support\Collection 147 | */ 148 | public function mapIds($results) 149 | { 150 | return collect($results['hits'])->pluck('objectID')->values() 151 | ->map([ObjectIdEncrypter::class, 'decryptSearchableKey']); 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /src/Exceptions/ModelNotDefinedInAggregatorException.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Algolia\ScoutExtended\Exceptions; 15 | 16 | use RuntimeException; 17 | use Throwable; 18 | 19 | class ModelNotDefinedInAggregatorException extends RuntimeException 20 | { 21 | /** 22 | * {@inheritdoc} 23 | */ 24 | public function __construct(string $message = '', int $code = 0, ?Throwable $previous = null) 25 | { 26 | if (empty($message)) { 27 | $message = 'Model not defined in aggregator.'; 28 | } 29 | 30 | parent::__construct($message, $code, $previous); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Exceptions/ModelNotFoundException.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Algolia\ScoutExtended\Exceptions; 15 | 16 | use RuntimeException; 17 | 18 | /** 19 | * @internal 20 | */ 21 | class ModelNotFoundException extends RuntimeException 22 | { 23 | /** 24 | * Name of the affected model. 25 | * 26 | * @var string 27 | */ 28 | private $model; 29 | 30 | /** 31 | * Sets the effected model. 32 | * 33 | * @param string $model 34 | * 35 | * @return void 36 | */ 37 | public function setModel(string $model): void 38 | { 39 | $this->model = $model; 40 | } 41 | 42 | /** 43 | * Get the effected model. 44 | * 45 | * @return string 46 | */ 47 | public function getModel(): string 48 | { 49 | return $this->model; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/Exceptions/SettingsNotFound.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Algolia\ScoutExtended\Exceptions; 15 | 16 | use Exception; 17 | use Throwable; 18 | 19 | class SettingsNotFound extends Exception 20 | { 21 | /** 22 | * {@inheritdoc} 23 | */ 24 | public function __construct(string $message = '', int $code = 0, ?Throwable $previous = null) 25 | { 26 | if (empty($message)) { 27 | $message = 'Settings not found.'; 28 | } 29 | 30 | parent::__construct($message, $code, $previous); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Exceptions/ShouldReimportSearchableException.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Algolia\ScoutExtended\Exceptions; 15 | 16 | use RuntimeException; 17 | 18 | class ShouldReimportSearchableException extends RuntimeException 19 | { 20 | } 21 | -------------------------------------------------------------------------------- /src/Facades/Algolia.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Algolia\ScoutExtended\Facades; 15 | 16 | use Illuminate\Support\Facades\Facade; 17 | 18 | /** 19 | * @method static \Algolia\AlgoliaSearch\SearchIndex index($searchable) 20 | * @method static \Algolia\AlgoliaSearch\SearchClient client() 21 | * @method static \Algolia\AlgoliaSearch\AnalyticsClient analytics() 22 | * @method static string searchKey($searchable) 23 | * 24 | * @see \Algolia\ScoutExtended\Algolia 25 | */ 26 | class Algolia extends Facade 27 | { 28 | /** 29 | * {@inheritdoc} 30 | */ 31 | protected static function getFacadeAccessor(): string 32 | { 33 | return 'algolia'; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Helpers/SearchableFinder.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Algolia\ScoutExtended\Helpers; 15 | 16 | use Error; 17 | use Illuminate\Console\Command; 18 | use Illuminate\Support\Str; 19 | use Laravel\Scout\Searchable; 20 | use RuntimeException; 21 | use Symfony\Component\Console\Exception\InvalidArgumentException; 22 | use Symfony\Component\Finder\Finder; 23 | 24 | use function in_array; 25 | 26 | /** 27 | * @internal 28 | */ 29 | class SearchableFinder 30 | { 31 | /** 32 | * @var array 33 | */ 34 | private $declaredClasses; 35 | 36 | /** 37 | * Get a list of searchable models from the given command. 38 | * 39 | * @param \Illuminate\Console\Command $command 40 | * 41 | * @return array 42 | */ 43 | public function fromCommand(Command $command): array 44 | { 45 | $searchables = (array) $command->argument('searchable'); 46 | 47 | if (empty($searchables) && empty($searchables = $this->find($command))) { 48 | throw new InvalidArgumentException('No searchable classes found.'); 49 | } 50 | 51 | return $searchables; 52 | } 53 | 54 | /** 55 | * Get a list of searchable models. 56 | * 57 | * @return string[] 58 | */ 59 | public function find(Command $command): array 60 | { 61 | [$sources, $namespaces] = $this->inferProjectSourcePaths(); 62 | 63 | return array_values(array_filter( 64 | $this->getProjectClasses($sources, $command), 65 | function (string $class) use ($namespaces) { 66 | return Str::startsWith($class, $namespaces) && $this->isSearchableModel($class); 67 | } 68 | )); 69 | } 70 | 71 | /** 72 | * @param string $class 73 | * 74 | * @return bool 75 | */ 76 | private function isSearchableModel($class): bool 77 | { 78 | return in_array(Searchable::class, class_uses_recursive($class), true); 79 | } 80 | 81 | /** 82 | * @param array $sources 83 | * @param Command $command 84 | * @return array 85 | */ 86 | private function getProjectClasses(array $sources, Command $command): array 87 | { 88 | if ($this->declaredClasses === null) { 89 | $configFiles = Finder::create() 90 | ->files() 91 | ->notName('*.blade.php') 92 | ->name('*.php') 93 | ->in($sources); 94 | 95 | foreach ($configFiles->files() as $file) { 96 | try { 97 | require_once $file; 98 | } catch (Error $e) { 99 | // log a warning to the user and continue 100 | $command->info("{$file} could not be inspected due to an error being thrown while loading it."); 101 | } 102 | } 103 | 104 | $this->declaredClasses = get_declared_classes(); 105 | } 106 | 107 | return $this->declaredClasses; 108 | } 109 | 110 | /** 111 | * Using the laravel project's composer.json retrieve the PSR-4 autoload to determine 112 | * the paths to search and namespaces to check against. 113 | * 114 | * @return array [$sources, $namespaces] 115 | */ 116 | private function inferProjectSourcePaths(): array 117 | { 118 | if (! ($composer = file_get_contents(base_path('composer.json')))) { 119 | throw new RuntimeException('Error reading composer.json'); 120 | } 121 | $autoload = json_decode($composer, true)['autoload'] ?? []; 122 | 123 | if (! isset($autoload['psr-4'])) { 124 | throw new RuntimeException('psr-4 autoload mappings are not present in composer.json'); 125 | } 126 | 127 | $psr4 = collect($autoload['psr-4']); 128 | 129 | $sources = $psr4->values()->map(function ($path) { 130 | return base_path($path); 131 | })->toArray(); 132 | $namespaces = $psr4->keys()->toArray(); 133 | 134 | return [$sources, $namespaces]; 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /src/Jobs/DeleteJob.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Algolia\ScoutExtended\Jobs; 15 | 16 | use Algolia\AlgoliaSearch\SearchClient; 17 | use Algolia\ScoutExtended\Searchable\ObjectIdEncrypter; 18 | use Illuminate\Support\Collection; 19 | 20 | /** 21 | * @internal 22 | */ 23 | class DeleteJob 24 | { 25 | /** 26 | * @var \Illuminate\Support\Collection 27 | */ 28 | private $searchables; 29 | 30 | /** 31 | * DeleteJob constructor. 32 | * 33 | * @param \Illuminate\Support\Collection $searchables 34 | * 35 | * @return void 36 | */ 37 | public function __construct(Collection $searchables) 38 | { 39 | $this->searchables = $searchables; 40 | } 41 | 42 | /** 43 | * @param \Algolia\AlgoliaSearch\SearchClient $client 44 | * 45 | * @return void 46 | */ 47 | public function handle(SearchClient $client): void 48 | { 49 | if ($this->searchables->isEmpty()) { 50 | return; 51 | } 52 | 53 | // Checking whether or not to use the deprecated `deleteBy` method 54 | // here instead of creating an second job for it so that any existing 55 | // integrations will still function even if they dispatch jobs manually 56 | // or extend this class. 57 | 58 | // NOTE: Currently defaulting `scout.algolia.use_deprecated_delete_by` to 59 | // `true` so that there's no change to the existing behaviour. 60 | if (config('scout.algolia.use_deprecated_delete_by', true)) { 61 | $this->handleDeprecatedDeleteBy($client); 62 | } else { 63 | $this->handleDeleteObjects($client); 64 | } 65 | } 66 | 67 | /** 68 | * Handle deleting objects. 69 | * 70 | * @param \Algolia\AlgoliaSearch\SearchClient $client 71 | * @return void 72 | */ 73 | protected function handleDeleteObjects(SearchClient $client) 74 | { 75 | $index = $client->initIndex($this->searchables->first()->searchableAs()); 76 | 77 | // First fetch all object IDs by tags. 78 | $objects = $index->browseObjects([ 79 | 'attributesToRetrieve' => [ 80 | 'objectID', 81 | ], 82 | 'tagFilters' => [ 83 | $this->searchables->map(function ($searchable) { 84 | return ObjectIdEncrypter::encrypt($searchable); 85 | })->toArray(), 86 | ], 87 | ]); 88 | 89 | // The ObjectIterator will fetch all pages for us automatically. 90 | $objectIds = []; 91 | foreach ($objects as $object) { 92 | if (isset($object['objectID'])) { 93 | $objectIds[] = $object['objectID']; 94 | } 95 | } 96 | 97 | // Then delete the objects using their object IDs. 98 | $result = $index->deleteObjects($objectIds); 99 | 100 | if (config('scout.synchronous', false)) { 101 | $result->wait(); 102 | } 103 | } 104 | 105 | /** 106 | * Handle deleting objects using the deprecated `deleteBy` method. 107 | * 108 | * @param \Algolia\AlgoliaSearch\SearchClient $client 109 | * @return void 110 | */ 111 | protected function handleDeprecatedDeleteBy(SearchClient $client) 112 | { 113 | $index = $client->initIndex($this->searchables->first()->searchableAs()); 114 | 115 | $result = $index->deleteBy([ 116 | 'tagFilters' => [ 117 | $this->searchables->map(function ($searchable) { 118 | return ObjectIdEncrypter::encrypt($searchable); 119 | })->toArray(), 120 | ], 121 | ]); 122 | 123 | if (config('scout.synchronous', false)) { 124 | $result->wait(); 125 | } 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /src/Jobs/UpdateJob.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Algolia\ScoutExtended\Jobs; 15 | 16 | use Algolia\AlgoliaSearch\SearchClient; 17 | use Algolia\ScoutExtended\Contracts\SplitterContract; 18 | use Algolia\ScoutExtended\Searchable\ModelsResolver; 19 | use Algolia\ScoutExtended\Searchable\ObjectIdEncrypter; 20 | use Algolia\ScoutExtended\Transformers\ConvertDatesToTimestamps; 21 | use Algolia\ScoutExtended\Transformers\ConvertNumericStringsToNumbers; 22 | use Laravel\Scout\Searchable; 23 | use function get_class; 24 | use Illuminate\Database\Eloquent\Model; 25 | use Illuminate\Database\Eloquent\SoftDeletes; 26 | use Illuminate\Support\Arr; 27 | use Illuminate\Support\Collection; 28 | use Illuminate\Support\Str; 29 | use function in_array; 30 | use function is_array; 31 | use function is_string; 32 | use ReflectionClass; 33 | 34 | /** 35 | * @internal 36 | */ 37 | class UpdateJob 38 | { 39 | /** 40 | * Contains a list of splittables searchables. 41 | * 42 | * Example: [ 43 | * '\App\Thread' => true, 44 | * '\App\User' => false, 45 | * ]; 46 | * 47 | * @var array 48 | */ 49 | private $splittables = []; 50 | 51 | /** 52 | * @var \Illuminate\Support\Collection 53 | */ 54 | private $searchables; 55 | 56 | /** 57 | * Holds the searchables with a declared 58 | * toSearchableArray method. 59 | * 60 | * @var array 61 | */ 62 | private $searchablesWithToSearchableArray = []; 63 | 64 | /** 65 | * Holds a list of transformers to apply by 66 | * default. 67 | * 68 | * @var array 69 | */ 70 | private static $transformers = [ 71 | ConvertNumericStringsToNumbers::class, 72 | ConvertDatesToTimestamps::class, 73 | ]; 74 | 75 | /** 76 | * UpdateJob constructor. 77 | * 78 | * @param \Illuminate\Support\Collection $searchables 79 | * 80 | * @return void 81 | */ 82 | public function __construct(Collection $searchables) 83 | { 84 | $this->searchables = $searchables; 85 | } 86 | 87 | /** 88 | * @param \Algolia\AlgoliaSearch\SearchClient $client 89 | * 90 | * @return void 91 | */ 92 | public function handle(SearchClient $client): void 93 | { 94 | if ($this->searchables->isEmpty()) { 95 | return; 96 | } 97 | 98 | if (config('scout.soft_delete', false) && $this->usesSoftDelete($this->searchables->first())) { 99 | $this->searchables->each->pushSoftDeleteMetadata(); 100 | } 101 | 102 | $index = $client->initIndex($this->searchables->first()->searchableAs()); 103 | 104 | $objectsToSave = []; 105 | $searchablesToDelete = []; 106 | 107 | foreach ($this->searchables as $key => $searchable) { 108 | $metadata = Arr::except($searchable->scoutMetadata(), ModelsResolver::$metadata); 109 | 110 | if (empty($searchableArray = $searchable->toSearchableArray())) { 111 | continue; 112 | } 113 | 114 | $array = array_merge($searchableArray, $metadata); 115 | 116 | if (! $this->hasToSearchableArray($searchable)) { 117 | $array = $searchable->getModel()->transform($array); 118 | } 119 | 120 | $array['_tags'] = (array) ($array['_tags'] ?? []); 121 | 122 | $array['_tags'][] = ObjectIdEncrypter::encrypt($searchable); 123 | 124 | if ($this->shouldBeSplitted($searchable)) { 125 | $objects = $this->splitSearchable($searchable, $array); 126 | 127 | foreach ($objects as $part => $object) { 128 | $object['objectID'] = ObjectIdEncrypter::encrypt($searchable, (int) $part); 129 | $objectsToSave[] = $object; 130 | } 131 | $searchablesToDelete[] = $searchable; 132 | } else { 133 | $array['objectID'] = ObjectIdEncrypter::encrypt($searchable); 134 | $objectsToSave[] = $array; 135 | } 136 | } 137 | 138 | dispatch_sync(new DeleteJob(collect($searchablesToDelete))); 139 | 140 | $result = $index->saveObjects($objectsToSave); 141 | if (config('scout.synchronous', false)) { 142 | $result->wait(); 143 | } 144 | } 145 | 146 | /** 147 | * @param object $searchable 148 | * 149 | * @return bool 150 | */ 151 | private function shouldBeSplitted($searchable): bool 152 | { 153 | $class = get_class($searchable->getModel()); 154 | 155 | if (! array_key_exists($class, $this->splittables)) { 156 | $this->splittables[$class] = false; 157 | 158 | foreach ($searchable->toSearchableArray() as $key => $value) { 159 | $method = 'split'.Str::camel($key); 160 | $model = $searchable->getModel(); 161 | if (method_exists($model, $method)) { 162 | $this->splittables[$class] = true; 163 | break; 164 | } 165 | } 166 | } 167 | 168 | return $this->splittables[$class]; 169 | } 170 | 171 | /** 172 | * @param object $searchable 173 | * @param array $array 174 | * 175 | * @return array 176 | */ 177 | private function splitSearchable($searchable, array $array): array 178 | { 179 | $pieces = []; 180 | foreach ($array as $key => $value) { 181 | $method = 'split'.Str::camel((string) $key); 182 | $model = $searchable->getModel(); 183 | if (method_exists($model, $method)) { 184 | $result = $model->{$method}($value); 185 | $splittedBy = $key; 186 | $pieces[$splittedBy] = []; 187 | switch (true) { 188 | case is_array($result): 189 | $pieces[$splittedBy] = $result; 190 | break; 191 | case is_string($result): 192 | $pieces[$splittedBy] = app($result)->split($model, $value); 193 | break; 194 | case $result instanceof SplitterContract: 195 | $pieces[$splittedBy] = $result->split($model, $value); 196 | break; 197 | } 198 | } 199 | } 200 | 201 | $objects = [[]]; 202 | foreach ($pieces as $splittedBy => $values) { 203 | $temp = []; 204 | foreach ($objects as $object) { 205 | foreach ($values as $value) { 206 | $temp[] = array_merge($object, [$splittedBy => $value]); 207 | } 208 | } 209 | $objects = $temp; 210 | } 211 | 212 | return array_map(function ($object) use ($array) { 213 | return array_merge($array, $object); 214 | }, $objects); 215 | } 216 | 217 | /** 218 | * Determine if the given searchable uses soft deletes. 219 | * 220 | * @param object $searchable 221 | * 222 | * @return bool 223 | */ 224 | private function usesSoftDelete($searchable): bool 225 | { 226 | return $searchable instanceof Model && in_array(SoftDeletes::class, class_uses_recursive($searchable), true); 227 | } 228 | 229 | /** 230 | * @param object $searchable 231 | * 232 | * @return bool 233 | */ 234 | private function hasToSearchableArray($searchable): bool 235 | { 236 | $searchableClass = get_class($searchable); 237 | $scoutTraitFileName = (new ReflectionClass(Searchable::class))->getFileName(); 238 | 239 | if (! array_key_exists($searchableClass, $this->searchablesWithToSearchableArray)) { 240 | $reflectionClass = new ReflectionClass(get_class($searchable)); 241 | 242 | // File where the method `toSearchableArray` is defined. 243 | $methodDefinitionFileName = $reflectionClass->getMethod('toSearchableArray')->getFileName(); 244 | 245 | // If the method toSearchableArray is defined in the Scout's 246 | // trait, then the Model doesn't have a custom toSearchableArray. 247 | $this->searchablesWithToSearchableArray[$searchableClass] = $methodDefinitionFileName !== $scoutTraitFileName; 248 | } 249 | 250 | return $this->searchablesWithToSearchableArray[$searchableClass]; 251 | } 252 | 253 | /** 254 | * Returns the default update job transformers. 255 | * 256 | * @return array 257 | */ 258 | public static function getTransformers(): array 259 | { 260 | return self::$transformers; 261 | } 262 | } 263 | -------------------------------------------------------------------------------- /src/Managers/EngineManager.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Algolia\ScoutExtended\Managers; 15 | 16 | use Algolia\AlgoliaSearch\SearchClient; 17 | use Algolia\AlgoliaSearch\Support\UserAgent; 18 | use Algolia\ScoutExtended\Engines\AlgoliaEngine; 19 | use Laravel\Scout\EngineManager as BaseEngineManager; 20 | 21 | class EngineManager extends BaseEngineManager 22 | { 23 | /** 24 | * Create an Algolia engine instance. 25 | * 26 | * @return \Algolia\ScoutExtended\Engines\AlgoliaEngine 27 | */ 28 | public function createAlgoliaDriver(): AlgoliaEngine 29 | { 30 | UserAgent::addCustomUserAgent('Laravel Scout Extended', '3.2.2'); 31 | 32 | return new AlgoliaEngine(SearchClient::create(config('scout.algolia.id'), config('scout.algolia.secret'))); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Repositories/ApiKeysRepository.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Algolia\ScoutExtended\Repositories; 15 | 16 | use Algolia\AlgoliaSearch\SearchClient; 17 | use DateInterval; 18 | use Illuminate\Contracts\Cache\Repository; 19 | use function is_string; 20 | 21 | /** 22 | * @internal 23 | */ 24 | class ApiKeysRepository 25 | { 26 | /** 27 | * Holds the search key. 28 | */ 29 | private const SEARCH_KEY = 'scout-extended.user-data.search-key'; 30 | 31 | /** 32 | * @var \Illuminate\Contracts\Cache\Repository 33 | */ 34 | private $cache; 35 | 36 | /** 37 | * @var \Algolia\AlgoliaSearch\SearchClient 38 | */ 39 | private $client; 40 | 41 | /** 42 | * ApiKeysRepository constructor. 43 | * 44 | * @param \Illuminate\Contracts\Cache\Repository $cache 45 | * @param \Algolia\AlgoliaSearch\SearchClient $client 46 | * 47 | * @return void 48 | */ 49 | public function __construct(Repository $cache, SearchClient $client) 50 | { 51 | $this->cache = $cache; 52 | $this->client = $client; 53 | } 54 | 55 | /** 56 | * @param string|object $searchable 57 | * 58 | * @return string 59 | */ 60 | public function getSearchKey($searchable): string 61 | { 62 | $searchable = is_string($searchable) ? new $searchable : $searchable; 63 | 64 | $searchableAs = $searchable->searchableAs(); 65 | 66 | $securedSearchKey = $this->cache->get(self::SEARCH_KEY.'.'.$searchableAs); 67 | 68 | if ($securedSearchKey === null) { 69 | $id = config('app.name').'::searchKey'; 70 | 71 | $keys = $this->client->listApiKeys()['keys']; 72 | 73 | $searchKey = null; 74 | 75 | foreach ($keys as $key) { 76 | if (array_key_exists('description', $key) && $key['description'] === $id) { 77 | $searchKey = $key['value']; 78 | } 79 | } 80 | 81 | $searchKey = $searchKey ?? $this->client->addApiKey(['search'], [ 82 | 'description' => config('app.name').'::searchKey', 83 | ])->getBody()['key']; 84 | 85 | // Key will be valid for 25 hours. 86 | $validUntil = time() + (3600 * 25); 87 | 88 | $securedSearchKey = $this->client::generateSecuredApiKey($searchKey, [ 89 | 'restrictIndices' => $searchableAs, 90 | 'validUntil' => $validUntil, 91 | ]); 92 | 93 | $this->cache->put( 94 | self::SEARCH_KEY.'.'.$searchableAs, $securedSearchKey, DateInterval::createFromDateString('24 hours') 95 | ); 96 | } 97 | 98 | return $securedSearchKey; 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/Repositories/LocalSettingsRepository.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Algolia\ScoutExtended\Repositories; 15 | 16 | use Algolia\AlgoliaSearch\SearchIndex; 17 | use Algolia\ScoutExtended\Contracts\LocalSettingsRepositoryContract; 18 | use Algolia\ScoutExtended\Settings\Settings; 19 | use Illuminate\Filesystem\Filesystem; 20 | use Illuminate\Support\Str; 21 | 22 | /** 23 | * @internal 24 | */ 25 | class LocalSettingsRepository implements LocalSettingsRepositoryContract 26 | { 27 | /** 28 | * @var \Algolia\ScoutExtended\Repositories\RemoteSettingsRepository 29 | */ 30 | private $remoteRepository; 31 | 32 | /** 33 | * @var \Illuminate\Filesystem\Filesystem 34 | */ 35 | private $files; 36 | 37 | /** 38 | * LocalRepository constructor. 39 | * 40 | * @param \Algolia\ScoutExtended\Repositories\RemoteSettingsRepository $remoteRepository 41 | * @param \Illuminate\Filesystem\Filesystem $files 42 | */ 43 | public function __construct(RemoteSettingsRepository $remoteRepository, Filesystem $files) 44 | { 45 | $this->remoteRepository = $remoteRepository; 46 | $this->files = $files; 47 | } 48 | 49 | /** 50 | * Checks if the given index settings exists. 51 | * 52 | * @param \Algolia\AlgoliaSearch\SearchIndex $index 53 | * 54 | * @return bool 55 | */ 56 | public function exists(SearchIndex $index): bool 57 | { 58 | return $this->files->exists($this->getPath($index)); 59 | } 60 | 61 | /** 62 | * Get the settings path of the given index name. 63 | * 64 | * @param \Algolia\AlgoliaSearch\SearchIndex $index 65 | * 66 | * @return string 67 | */ 68 | public function getPath(SearchIndex $index): string 69 | { 70 | $name = str_replace('_', '-', $index->getIndexName()); 71 | 72 | $name = is_array($name) ? current($name) : $name; 73 | 74 | $fileName = 'scout-'.Str::lower($name).'.php'; 75 | $settingsPath = config('scout.algolia.settings_path'); 76 | 77 | if ($settingsPath === null) { 78 | return app('path.config').DIRECTORY_SEPARATOR.$fileName; 79 | } 80 | 81 | if (! $this->files->exists($settingsPath)) { 82 | $this->files->makeDirectory($settingsPath, 0755, true); 83 | } 84 | 85 | return $settingsPath.DIRECTORY_SEPARATOR.$fileName; 86 | } 87 | 88 | /** 89 | * Find the settings of the given Index. 90 | * 91 | * @param \Algolia\AlgoliaSearch\SearchIndex $index 92 | * 93 | * @return \Algolia\ScoutExtended\Settings\Settings 94 | */ 95 | public function find(SearchIndex $index): Settings 96 | { 97 | return new Settings(($this->exists($index) ? require $this->getPath($index) : []), 98 | $this->remoteRepository->defaults()); 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/Repositories/RemoteSettingsRepository.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Algolia\ScoutExtended\Repositories; 15 | 16 | use Algolia\AlgoliaSearch\Exceptions\NotFoundException; 17 | use Algolia\AlgoliaSearch\SearchClient; 18 | use Algolia\AlgoliaSearch\SearchIndex; 19 | use Algolia\ScoutExtended\Settings\Settings; 20 | 21 | /** 22 | * @internal 23 | */ 24 | class RemoteSettingsRepository 25 | { 26 | /** 27 | * Settings that may be know by other names. 28 | * 29 | * @var array 30 | */ 31 | private static $aliases = [ 32 | 'attributesToIndex' => 'searchableAttributes', 33 | ]; 34 | 35 | /** 36 | * @var \Algolia\AlgoliaSearch\SearchClient 37 | */ 38 | private $client; 39 | 40 | /** 41 | * @var array 42 | */ 43 | private $defaults; 44 | 45 | /** 46 | * RemoteRepository constructor. 47 | * 48 | * @param \Algolia\AlgoliaSearch\SearchClient $client 49 | * 50 | * @return void 51 | */ 52 | public function __construct(SearchClient $client) 53 | { 54 | $this->client = $client; 55 | } 56 | 57 | /** 58 | * Get the default settings. 59 | * 60 | * @return array 61 | */ 62 | public function defaults(): array 63 | { 64 | if ($this->defaults === null) { 65 | $indexName = 'temp-laravel-scout-extended'; 66 | $index = $this->client->initIndex($indexName); 67 | $this->defaults = $this->getSettingsRaw($index); 68 | $index->delete(); 69 | } 70 | 71 | return $this->defaults; 72 | } 73 | 74 | /** 75 | * Find the settings of the given Index. 76 | * 77 | * @param \Algolia\AlgoliaSearch\SearchIndex $index 78 | * 79 | * @return \Algolia\ScoutExtended\Settings\Settings 80 | */ 81 | public function find(SearchIndex $index): Settings 82 | { 83 | return new Settings($this->getSettingsRaw($index), $this->defaults()); 84 | } 85 | 86 | /** 87 | * @param \Algolia\AlgoliaSearch\SearchIndex $index 88 | * @param \Algolia\ScoutExtended\Settings\Settings $settings 89 | * 90 | * @return void 91 | */ 92 | public function save(SearchIndex $index, Settings $settings): void 93 | { 94 | $index->setSettings($settings->compiled())->wait(); 95 | } 96 | 97 | /** 98 | * @param \Algolia\AlgoliaSearch\SearchIndex $index 99 | * 100 | * @return array 101 | */ 102 | public function getSettingsRaw(SearchIndex $index): array 103 | { 104 | try { 105 | $settings = $index->getSettings(); 106 | } catch (NotFoundException $e) { 107 | $index->saveObject(['objectID' => 'temp'])->wait(); 108 | $settings = $index->getSettings(); 109 | 110 | $index->clearObjects(); 111 | } 112 | 113 | foreach (self::$aliases as $from => $to) { 114 | if (array_key_exists($from, $settings)) { 115 | $settings[$to] = $settings[$from]; 116 | unset($settings[$from]); 117 | } 118 | } 119 | 120 | return $settings; 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /src/Repositories/UserDataRepository.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Algolia\ScoutExtended\Repositories; 15 | 16 | use Algolia\AlgoliaSearch\SearchIndex; 17 | 18 | /** 19 | * @internal 20 | */ 21 | class UserDataRepository 22 | { 23 | /** 24 | * @var \Algolia\ScoutExtended\Repositories\RemoteSettingsRepository 25 | */ 26 | private $remoteRepository; 27 | 28 | /** 29 | * UserDataRepository constructor. 30 | * 31 | * @param \Algolia\ScoutExtended\Repositories\RemoteSettingsRepository $remoteRepository 32 | */ 33 | public function __construct(RemoteSettingsRepository $remoteRepository) 34 | { 35 | $this->remoteRepository = $remoteRepository; 36 | } 37 | 38 | /** 39 | * Find the User Data of the given Index. 40 | * 41 | * @param \Algolia\AlgoliaSearch\SearchIndex $index 42 | * 43 | * @return array 44 | */ 45 | public function find(SearchIndex $index): array 46 | { 47 | $settings = $this->remoteRepository->getSettingsRaw($index); 48 | 49 | if (array_key_exists('userData', $settings)) { 50 | $userData = @json_decode($settings['userData'], true); 51 | } 52 | 53 | return $userData ?? []; 54 | } 55 | 56 | /** 57 | * Save the User Data of the given Index. 58 | * 59 | * @param \Algolia\AlgoliaSearch\SearchIndex $index 60 | * @param array $userData 61 | * 62 | * @return void 63 | */ 64 | public function save(SearchIndex $index, array $userData): void 65 | { 66 | $currentUserData = $this->find($index); 67 | 68 | $userDataJson = json_encode(array_merge($currentUserData, $userData)); 69 | 70 | $index->setSettings(['userData' => $userDataJson])->wait(); 71 | } 72 | 73 | /** 74 | * Get the settings hash. 75 | * 76 | * @param \Algolia\AlgoliaSearch\SearchIndex $index 77 | * 78 | * @return string 79 | */ 80 | public function getSettingsHash(SearchIndex $index): string 81 | { 82 | $userData = $this->find($index); 83 | 84 | return $userData['settingsHash'] ?? ''; 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/ScoutExtendedServiceProvider.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Algolia\ScoutExtended; 15 | 16 | use Algolia\AlgoliaSearch\AnalyticsClient; 17 | use Algolia\AlgoliaSearch\SearchClient; 18 | use Algolia\ScoutExtended\Console\Commands\FlushCommand; 19 | use Algolia\ScoutExtended\Console\Commands\ImportCommand; 20 | use Algolia\ScoutExtended\Console\Commands\MakeAggregatorCommand; 21 | use Algolia\ScoutExtended\Console\Commands\OptimizeCommand; 22 | use Algolia\ScoutExtended\Console\Commands\ReImportCommand; 23 | use Algolia\ScoutExtended\Console\Commands\StatusCommand; 24 | use Algolia\ScoutExtended\Console\Commands\SyncCommand; 25 | use Algolia\ScoutExtended\Contracts\LocalSettingsRepositoryContract; 26 | use Algolia\ScoutExtended\Engines\AlgoliaEngine; 27 | use Algolia\ScoutExtended\Helpers\SearchableFinder; 28 | use Algolia\ScoutExtended\Jobs\UpdateJob; 29 | use Algolia\ScoutExtended\Managers\EngineManager; 30 | use Algolia\ScoutExtended\Repositories\LocalSettingsRepository; 31 | use Algolia\ScoutExtended\Searchable\AggregatorObserver; 32 | use Illuminate\Support\ServiceProvider; 33 | use Laravel\Scout\ScoutServiceProvider; 34 | 35 | class ScoutExtendedServiceProvider extends ServiceProvider 36 | { 37 | /** 38 | * {@inheritdoc} 39 | */ 40 | public function boot(): void 41 | { 42 | $this->loadViewsFrom(__DIR__.'/../resources/views', 'algolia'); 43 | $this->registerCommands(); 44 | $this->registerMacros(); 45 | } 46 | 47 | /** 48 | * {@inheritdoc} 49 | */ 50 | public function register(): void 51 | { 52 | $this->app->register(ScoutServiceProvider::class); 53 | 54 | $this->registerBinds(); 55 | } 56 | 57 | /** 58 | * Binds Algolia services into the container. 59 | * 60 | * @return void 61 | */ 62 | private function registerBinds(): void 63 | { 64 | $this->app->bind(Algolia::class, function ($app) { 65 | return new Algolia($app); 66 | }); 67 | 68 | $this->app->alias(Algolia::class, 'algolia'); 69 | 70 | $this->app->singleton(EngineManager::class, function ($app) { 71 | return new EngineManager($app); 72 | }); 73 | 74 | $this->app->alias(EngineManager::class, \Laravel\Scout\EngineManager::class); 75 | 76 | $this->app->bind(AlgoliaEngine::class, function ($app): AlgoliaEngine { 77 | return $app->make(\Laravel\Scout\EngineManager::class)->createAlgoliaDriver(); 78 | }); 79 | 80 | $this->app->alias(AlgoliaEngine::class, 'algolia.engine'); 81 | $this->app->bind(SearchClient::class, function ($app): SearchClient { 82 | return $app->make('algolia.engine')->getClient(); 83 | }); 84 | 85 | $this->app->alias(SearchClient::class, 'algolia.client'); 86 | 87 | $this->app->bind(AnalyticsClient::class, function (): AnalyticsClient { 88 | return AnalyticsClient::create(config('scout.algolia.id'), config('scout.algolia.secret')); 89 | }); 90 | 91 | $this->app->alias(AnalyticsClient::class, 'algolia.analytics'); 92 | 93 | $this->app->singleton(AggregatorObserver::class, AggregatorObserver::class); 94 | $this->app->bind(\Laravel\Scout\Builder::class, Builder::class); 95 | 96 | $this->app->bind(SearchableFinder::class, SearchableFinder::class); 97 | 98 | $this->app->singleton(LocalSettingsRepositoryContract::class, LocalSettingsRepository::class); 99 | } 100 | 101 | /** 102 | * Register artisan commands. 103 | * 104 | * @return void 105 | */ 106 | private function registerCommands(): void 107 | { 108 | if ($this->app->runningInConsole()) { 109 | $this->commands([ 110 | MakeAggregatorCommand::class, 111 | ImportCommand::class, 112 | FlushCommand::class, 113 | OptimizeCommand::class, 114 | ReImportCommand::class, 115 | StatusCommand::class, 116 | SyncCommand::class, 117 | ]); 118 | } 119 | } 120 | 121 | /** 122 | * Register macros. 123 | * 124 | * @return void 125 | */ 126 | private function registerMacros(): void 127 | { 128 | \Illuminate\Database\Eloquent\Builder::macro('transform', function (array $array, ?array $transformers = null) { 129 | foreach ($transformers ?? UpdateJob::getTransformers() as $transformer) { 130 | $array = app($transformer)->transform($this->getModel(), $array); 131 | } 132 | 133 | return $array; 134 | }); 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /src/Searchable/Aggregator.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Algolia\ScoutExtended\Searchable; 15 | 16 | use Algolia\ScoutExtended\Contracts\SearchableCountableContract; 17 | use Algolia\ScoutExtended\Exceptions\ModelNotDefinedInAggregatorException; 18 | use Illuminate\Database\Eloquent\Collection; 19 | use Illuminate\Database\Eloquent\Model; 20 | use Illuminate\Database\Eloquent\SoftDeletes; 21 | use Illuminate\Support\Str; 22 | use function in_array; 23 | use Laravel\Scout\Events\ModelsImported; 24 | use Laravel\Scout\Searchable; 25 | 26 | abstract class Aggregator implements SearchableCountableContract 27 | { 28 | use Searchable; 29 | 30 | /** 31 | * The names of the models that should be aggregated. 32 | * 33 | * @var string[] 34 | */ 35 | protected $models = []; 36 | 37 | /** 38 | * The model being queried, if any. 39 | * 40 | * @var \Illuminate\Database\Eloquent\Model|null 41 | */ 42 | protected $model; 43 | 44 | /** 45 | * The relationships per model that should be loaded. 46 | * 47 | * @var mixed[] 48 | */ 49 | protected $relations = []; 50 | 51 | /** 52 | * Returns the index name. 53 | * 54 | * @var string 55 | */ 56 | protected $indexName; 57 | 58 | /** 59 | * Boot the aggregator. 60 | * 61 | * @return void 62 | */ 63 | public static function bootSearchable(): void 64 | { 65 | ($self = new static)->registerSearchableMacros(); 66 | $observer = tap(app(AggregatorObserver::class))->setAggregator(static::class, $models = (new static)->getModels()); 67 | 68 | foreach ($models as $model) { 69 | $model::observe($observer); 70 | } 71 | } 72 | 73 | /** 74 | * Creates an instance of the aggregator. 75 | * 76 | * @param \Illuminate\Database\Eloquent\Model $model 77 | * 78 | * @return \Algolia\ScoutExtended\Searchable\Aggregator 79 | */ 80 | public static function create(Model $model): Aggregator 81 | { 82 | return (new static)->setModel($model); 83 | } 84 | 85 | /** 86 | * Get the names of the models that should be aggregated. 87 | * 88 | * @return string[] 89 | */ 90 | public function getModels(): array 91 | { 92 | return $this->models; 93 | } 94 | 95 | /** 96 | * Get the model instance being queried. 97 | * 98 | * @return \Illuminate\Database\Eloquent\Model 99 | * 100 | * @throws \Algolia\ScoutExtended\Exceptions\ModelNotDefinedInAggregatorException 101 | */ 102 | public function getModel(): Model 103 | { 104 | if ($this->model === null) { 105 | throw new ModelNotDefinedInAggregatorException(); 106 | } 107 | 108 | return $this->model; 109 | } 110 | 111 | /** 112 | * Set a model instance for the model being queried. 113 | * 114 | * @param \Illuminate\Database\Eloquent\Model $model 115 | * 116 | * @return \Algolia\ScoutExtended\Searchable\Aggregator 117 | */ 118 | public function setModel(Model $model): Aggregator 119 | { 120 | $this->model = $model; 121 | 122 | return $this; 123 | } 124 | 125 | /** 126 | * Get the relations to load. 127 | * 128 | * @param string $modelClass 129 | * 130 | * @return array 131 | */ 132 | public function getRelations($modelClass): array 133 | { 134 | return $this->relations[$modelClass] ?? []; 135 | } 136 | 137 | /** 138 | * Get the value used to index the model. 139 | * 140 | * @return mixed 141 | */ 142 | public function getScoutKey() 143 | { 144 | if ($this->model === null) { 145 | throw new ModelNotDefinedInAggregatorException(); 146 | } 147 | 148 | return method_exists($this->model, 'getScoutKey') ? $this->model->getScoutKey() : $this->model->getKey(); 149 | } 150 | 151 | /** 152 | * Get the index name for the searchable. 153 | * 154 | * @return string 155 | */ 156 | public function searchableAs(): string 157 | { 158 | return config('scout.prefix').str_replace('\\', '', Str::snake(class_basename(static::class))); 159 | } 160 | 161 | /** 162 | * Get the searchable array of the searchable. 163 | * 164 | * @return array 165 | */ 166 | public function toSearchableArray(): array 167 | { 168 | if ($this->model === null) { 169 | throw new ModelNotDefinedInAggregatorException(); 170 | } 171 | 172 | return method_exists($this->model, 'toSearchableArray') ? $this->model->toSearchableArray() : 173 | $this->model->toArray(); 174 | } 175 | 176 | /** 177 | * Make all instances of the model searchable. 178 | * 179 | * @return void 180 | */ 181 | public static function makeAllSearchable() 182 | { 183 | foreach ((new static)->getModels() as $model) { 184 | $instance = new $model; 185 | 186 | $softDeletes = 187 | in_array(SoftDeletes::class, class_uses_recursive($model)) && config('scout.soft_delete', false); 188 | 189 | $instance->newQuery()->when($softDeletes, function ($query) { 190 | $query->withTrashed(); 191 | })->orderBy($instance->getKeyName())->chunk(config('scout.chunk.searchable', 500), function ($models) { 192 | $models = $models->map(function ($model) { 193 | return static::create($model); 194 | })->filter->shouldBeSearchable(); 195 | 196 | $models->searchable(); 197 | 198 | event(new ModelsImported($models)); 199 | }); 200 | } 201 | } 202 | 203 | /** 204 | * {@inheritdoc} 205 | * 206 | * @internal 207 | */ 208 | public function searchable(): void 209 | { 210 | AggregatorCollection::make([$this])->searchable(); 211 | } 212 | 213 | /** 214 | * {@inheritdoc} 215 | * 216 | * @internal 217 | */ 218 | public function unsearchable(): void 219 | { 220 | AggregatorCollection::make([$this])->unsearchable(); 221 | } 222 | 223 | /** 224 | * {@inheritdoc} 225 | */ 226 | public function getSearchableCount(): int 227 | { 228 | $count = 0; 229 | 230 | foreach ($this->getModels() as $model) { 231 | $softDeletes = 232 | in_array(SoftDeletes::class, class_uses_recursive($model), true) && config('scout.soft_delete', false); 233 | 234 | $count += $model::query()->when($softDeletes, function ($query) { 235 | $query->withTrashed(); 236 | })->count(); 237 | } 238 | 239 | return (int) $count; 240 | } 241 | 242 | /** 243 | * Create a new Eloquent Collection instance. 244 | * 245 | * @param array $searchables 246 | * 247 | * @return \Illuminate\Database\Eloquent\Collection 248 | */ 249 | public function newCollection(array $searchables = []): Collection 250 | { 251 | return new Collection($searchables); 252 | } 253 | 254 | /** 255 | * Dispatch the job to make the given models unsearchable. 256 | * 257 | * @param \Illuminate\Database\Eloquent\Collection $models 258 | * @return void 259 | */ 260 | public function queueRemoveFromSearch($models) 261 | { 262 | if ($models->isEmpty()) { 263 | return; 264 | } 265 | 266 | $models->first()->searchableUsing()->delete($models); 267 | } 268 | 269 | /** 270 | * Handle dynamic method calls into the model. 271 | * 272 | * @param string $method 273 | * @param array $parameters 274 | * 275 | * @return mixed 276 | */ 277 | public function __call($method, $parameters) 278 | { 279 | $model = $this->model ?? new class extends Model { 280 | }; 281 | 282 | return $model->$method(...$parameters); 283 | } 284 | } 285 | -------------------------------------------------------------------------------- /src/Searchable/AggregatorCollection.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Algolia\ScoutExtended\Searchable; 15 | 16 | use function get_class; 17 | use Illuminate\Database\Eloquent\Collection as EloquentCollection; 18 | use Illuminate\Queue\SerializesAndRestoresModelIdentifiers; 19 | use Illuminate\Support\Collection; 20 | 21 | /** 22 | * @method static void searchable() 23 | */ 24 | class AggregatorCollection extends Collection 25 | { 26 | use SerializesAndRestoresModelIdentifiers; 27 | 28 | /** 29 | * The class name of the aggregator. 30 | * 31 | * @var string|null 32 | */ 33 | public $aggregator; 34 | 35 | /** 36 | * Make all the models in this collection unsearchable. 37 | * 38 | * @return void 39 | */ 40 | public function unsearchable(): void 41 | { 42 | $aggregator = get_class($this->first()); 43 | 44 | (new $aggregator)->queueRemoveFromSearch($this); 45 | } 46 | 47 | /** 48 | * Prepare the instance for serialization. 49 | * 50 | * @return string[] 51 | */ 52 | public function __sleep() 53 | { 54 | $this->aggregator = get_class($this->first()); 55 | 56 | $this->items = $this->getSerializedPropertyValue(EloquentCollection::make($this->map(function ($aggregator) { 57 | return $aggregator->getModel(); 58 | }))); 59 | 60 | return ['aggregator', 'items']; 61 | } 62 | 63 | /** 64 | * Restore the model after serialization. 65 | * 66 | * @return void 67 | */ 68 | public function __wakeup() 69 | { 70 | $this->items = $this->getRestoredPropertyValue($this->items)->map(function ($model) { 71 | return $this->aggregator::create($model); 72 | })->toArray(); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/Searchable/AggregatorObserver.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Algolia\ScoutExtended\Searchable; 15 | 16 | use function get_class; 17 | use Laravel\Scout\ModelObserver as BaseModelObserver; 18 | 19 | class AggregatorObserver extends BaseModelObserver 20 | { 21 | /** 22 | * @var array [ 23 | * '\App\Post' => [ 24 | * '\App\Search\NewsAggregator', 25 | * '\App\Search\BlogAggregator', 26 | * ] 27 | * ] 28 | */ 29 | private $aggregators = []; 30 | 31 | /** 32 | * Set the aggregator. 33 | * 34 | * @param string $aggregator 35 | * @param string[] $models 36 | * 37 | * @return void 38 | */ 39 | public function setAggregator(string $aggregator, array $models): void 40 | { 41 | foreach ($models as $model) { 42 | if (! array_key_exists($model, $this->aggregators)) { 43 | $this->aggregators[$model] = []; 44 | } 45 | 46 | $this->aggregators[$model][] = $aggregator; 47 | } 48 | } 49 | 50 | /** 51 | * Set multiple aggregators. 52 | * 53 | * @param string[] $aggregators 54 | * @param string $model 55 | * 56 | * @return void 57 | */ 58 | public function setAggregators(array $aggregators, string $model): void 59 | { 60 | if (! array_key_exists($model, $this->aggregators)) { 61 | $this->aggregators[$model] = []; 62 | } 63 | 64 | $this->aggregators[$model] = $aggregators; 65 | } 66 | 67 | /** 68 | * {@inheritdoc} 69 | */ 70 | public function saved($model): void 71 | { 72 | $class = get_class($model); 73 | 74 | if (! array_key_exists($class, $this->aggregators)) { 75 | return; 76 | } 77 | 78 | foreach ($this->aggregators[$class] as $aggregator) { 79 | parent::saved($aggregator::create($model)); 80 | } 81 | } 82 | 83 | /** 84 | * {@inheritdoc} 85 | */ 86 | public function deleted($model): void 87 | { 88 | if ($this->usesSoftDelete($model) && config('scout.soft_delete', false)) { 89 | $this->saved($model); 90 | } else { 91 | $class = get_class($model); 92 | 93 | if (! array_key_exists($class, $this->aggregators)) { 94 | return; 95 | } 96 | 97 | foreach ($this->aggregators[$class] as $aggregator) { 98 | $aggregator::create($model)->unsearchable(); 99 | } 100 | } 101 | } 102 | 103 | /** 104 | * Handle the force deleted event for the model. 105 | * 106 | * @param \Illuminate\Database\Eloquent\Model $model 107 | * @return void 108 | */ 109 | public function forceDeleted($model): void 110 | { 111 | $class = get_class($model); 112 | 113 | if (! array_key_exists($class, $this->aggregators)) { 114 | return; 115 | } 116 | 117 | foreach ($this->aggregators[$class] as $aggregator) { 118 | $aggregator::create($model)->unsearchable(); 119 | } 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /src/Searchable/Aggregators.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Algolia\ScoutExtended\Searchable; 15 | 16 | class Aggregators extends Aggregator 17 | { 18 | /** 19 | * Boot multiple aggregators. 20 | * 21 | * @return void 22 | */ 23 | public static function bootSearchables(array $searchables): void 24 | { 25 | (new static)->registerSearchableMacros(); 26 | 27 | $models = []; 28 | 29 | foreach ($searchables as $searchable) { 30 | foreach ((new $searchable)->getModels() as $model) { 31 | $models[(string) $model][] = $searchable; 32 | } 33 | } 34 | 35 | foreach ($models as $model => $searchables) { 36 | $observer = tap(app(AggregatorObserver::class))->setAggregators($searchables, $model); 37 | 38 | $model::observe($observer); 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Searchable/ModelsResolver.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Algolia\ScoutExtended\Searchable; 15 | 16 | use function call_user_func; 17 | use Illuminate\Database\Eloquent\Collection; 18 | use Illuminate\Database\Eloquent\SoftDeletes; 19 | use Illuminate\Support\Arr; 20 | use function in_array; 21 | use Laravel\Scout\Builder; 22 | use Laravel\Scout\Searchable; 23 | 24 | /** 25 | * @internal 26 | */ 27 | class ModelsResolver 28 | { 29 | /** 30 | * @var string[] 31 | */ 32 | public static $metadata = [ 33 | '_snippetResult', 34 | '_highlightResult', 35 | '_rankingInfo', 36 | ]; 37 | 38 | /** 39 | * Get a set of models from the provided results. 40 | * 41 | * @param \Laravel\Scout\Builder $builder 42 | * @param object $searchable 43 | * @param array $results 44 | * 45 | * @return \Illuminate\Database\Eloquent\Collection 46 | */ 47 | public function from(Builder $builder, $searchable, array $results): Collection 48 | { 49 | $instances = collect(); 50 | $hits = collect($results['hits'])->keyBy('objectID'); 51 | 52 | $models = []; 53 | foreach ($hits->keys() as $id) { 54 | $modelClass = ObjectIdEncrypter::decryptSearchable((string) $id); 55 | $modelKey = ObjectIdEncrypter::decryptSearchableKey((string) $id); 56 | if (! array_key_exists($modelClass, $models)) { 57 | $models[$modelClass] = []; 58 | } 59 | 60 | $models[$modelClass][] = $modelKey; 61 | } 62 | 63 | foreach ($models as $modelClass => $modelKeys) { 64 | $model = new $modelClass; 65 | 66 | if (in_array(Searchable::class, class_uses_recursive($model), true)) { 67 | if (! empty($models = $model->getScoutModelsByIds($builder, $modelKeys))) { 68 | $instances = $instances->merge($models->load($searchable->getRelations($modelClass))); 69 | } 70 | } else { 71 | $query = in_array(SoftDeletes::class, class_uses_recursive($model), 72 | true) ? $model->withTrashed() : $model->newQuery(); 73 | 74 | if ($builder->queryCallback) { 75 | call_user_func($builder->queryCallback, $query); 76 | } 77 | 78 | $scoutKey = method_exists($model, 79 | 'getScoutKeyName') ? $model->getScoutKeyName() : $model->getQualifiedKeyName(); 80 | if ($models = $query->whereIn($scoutKey, $modelKeys)->get()) { 81 | $instances = $instances->merge($models->load($searchable->getRelations($modelClass))); 82 | } 83 | } 84 | } 85 | 86 | $result = $searchable->newCollection(); 87 | 88 | foreach ($hits as $id => $hit) { 89 | foreach ($instances as $instance) { 90 | if (ObjectIdEncrypter::encrypt($instance) === ObjectIdEncrypter::withoutPart((string) $id)) { 91 | if (method_exists($instance, 'withScoutMetadata')) { 92 | foreach (Arr::only($hit, self::$metadata) as $metadataKey => $metadataValue) { 93 | $instance->withScoutMetadata($metadataKey, $metadataValue); 94 | } 95 | } 96 | 97 | $result->push($instance); 98 | break; 99 | } 100 | } 101 | } 102 | 103 | return $result; 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/Searchable/ObjectIdEncrypter.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Algolia\ScoutExtended\Searchable; 15 | 16 | use Algolia\ScoutExtended\Exceptions\ShouldReimportSearchableException; 17 | use function count; 18 | use function get_class; 19 | 20 | /** 21 | * @internal 22 | */ 23 | class ObjectIdEncrypter 24 | { 25 | /** 26 | * Holds the metadata separator. 27 | * 28 | * @var non-empty-string 29 | */ 30 | private static $separator = '::'; 31 | 32 | /** 33 | * Encrypt the given searchable. 34 | * 35 | * @param mixed $searchable 36 | * @param int|null $part 37 | * 38 | * @return string 39 | */ 40 | public static function encrypt($searchable, ?int $part = null): string 41 | { 42 | $scoutKey = method_exists($searchable, 'getScoutKey') ? $searchable->getScoutKey() : $searchable->getKey(); 43 | 44 | $meta = [get_class($searchable->getModel()), $scoutKey]; 45 | 46 | if ($part !== null) { 47 | $meta[] = $part; 48 | } 49 | 50 | return implode(self::$separator, $meta); 51 | } 52 | 53 | /** 54 | * @param string $objectId 55 | * 56 | * @return string 57 | */ 58 | public static function withoutPart(string $objectId): string 59 | { 60 | return implode(self::$separator, [self::decryptSearchable($objectId), self::decryptSearchableKey($objectId)]); 61 | } 62 | 63 | /** 64 | * @param string $objectId 65 | * 66 | * @return string 67 | */ 68 | public static function decryptSearchable(string $objectId): string 69 | { 70 | return (string) self::getSearchableExploded($objectId)[0]; 71 | } 72 | 73 | /** 74 | * @param string $objectId 75 | * 76 | * @return string 77 | */ 78 | public static function decryptSearchableKey(string $objectId): string 79 | { 80 | return (string) self::getSearchableExploded($objectId)[1]; 81 | } 82 | 83 | /** 84 | * @param string $objectId 85 | * 86 | * @return string[] 87 | */ 88 | private static function getSearchableExploded(string $objectId): array 89 | { 90 | $parts = explode(self::$separator, $objectId); 91 | 92 | if (count($parts) < 2) { 93 | throw new ShouldReimportSearchableException('ObjectID seems invalid. You may need to 94 | re-import your data using the `scout-reimport` Artisan command.'); 95 | } 96 | 97 | return $parts; 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/Searchable/RecordsCounter.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Algolia\ScoutExtended\Searchable; 15 | 16 | use Algolia\ScoutExtended\Contracts\SearchableCountableContract; 17 | use Illuminate\Database\Eloquent\SoftDeletes; 18 | use function in_array; 19 | 20 | /** 21 | * @internal 22 | */ 23 | class RecordsCounter 24 | { 25 | /** 26 | * Get the number of local searchable records of 27 | * the given searchable class. 28 | * 29 | * @param string $searchable 30 | * 31 | * @return int 32 | */ 33 | public function local(string $searchable): int 34 | { 35 | if (($instance = new $searchable) instanceof SearchableCountableContract) { 36 | $count = $instance->getSearchableCount(); 37 | } else { 38 | $softDeletes = in_array(SoftDeletes::class, class_uses_recursive($searchable), true) && config('scout.soft_delete', false); 39 | 40 | $count = $searchable::query()->when($softDeletes, function ($query) { 41 | $query->withTrashed(); 42 | })->count(); 43 | } 44 | 45 | return (int) $count; 46 | } 47 | 48 | /** 49 | * Get the number of remote searchable records of 50 | * the given searchable class. 51 | * 52 | * @param string $searchable 53 | * 54 | * @return int 55 | */ 56 | public function remote(string $searchable): int 57 | { 58 | return (int) $searchable::search()->count(); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/Settings/Compiler.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Algolia\ScoutExtended\Settings; 15 | 16 | use Illuminate\Contracts\View\Factory as ViewFactory; 17 | use Illuminate\Filesystem\Filesystem; 18 | use Illuminate\Support\Facades\File; 19 | use Riimu\Kit\PHPEncoder\PHPEncoder; 20 | 21 | /** 22 | * @internal 23 | */ 24 | class Compiler 25 | { 26 | /** 27 | * Array of opening and closing tags for raw echos. 28 | * 29 | * @var string[] 30 | */ 31 | private static $rawTags = ['{!!', '!!}']; 32 | 33 | /** 34 | * @var \Illuminate\Contracts\View\Factory 35 | */ 36 | private $viewFactory; 37 | 38 | /** 39 | * @var \Illuminate\Filesystem\Filesystem 40 | */ 41 | private $files; 42 | 43 | /** 44 | * @var \Riimu\Kit\PHPEncoder\PHPEncoder 45 | */ 46 | private $encoder; 47 | 48 | /** 49 | * Compiler constructor. 50 | * 51 | * @param \Illuminate\Contracts\View\Factory $viewFactory 52 | * @param \Illuminate\Filesystem\Filesystem $files 53 | * @param \Riimu\Kit\PHPEncoder\PHPEncoder $encoder 54 | * 55 | * @return void 56 | */ 57 | public function __construct(ViewFactory $viewFactory, Filesystem $files, PHPEncoder $encoder) 58 | { 59 | $this->viewFactory = $viewFactory; 60 | $this->files = $files; 61 | $this->encoder = $encoder; 62 | } 63 | 64 | /** 65 | * Compiles the provided settings into the provided path. 66 | * 67 | * @param \Algolia\ScoutExtended\Settings\Settings $settings 68 | * @param string $path 69 | * 70 | * @return void 71 | */ 72 | public function compile(Settings $settings, string $path): void 73 | { 74 | $viewVariables = self::getViewVariables(); 75 | 76 | $viewParams = []; 77 | $all = $settings->all(); 78 | 79 | foreach ($viewVariables as $viewVariable) { 80 | if (array_key_exists($viewVariable, $all)) { 81 | $viewParams[$viewVariable] = $this->encoder->encode($all[$viewVariable], ['array.base' => 4]); 82 | } 83 | } 84 | 85 | $indexChangedSettings = []; 86 | foreach ($settings->changed() as $setting => $value) { 87 | if (! array_key_exists($setting, $viewParams)) { 88 | $indexChangedSettings[$setting] = $value; 89 | } 90 | } 91 | 92 | $viewParams['__indexChangedSettings'] = $this->encoder->encode($indexChangedSettings, ['array.base' => 0]); 93 | 94 | if (empty($indexChangedSettings)) { 95 | $viewParams['__indexChangedSettings'] = ']'; 96 | } else { 97 | $viewParams['__indexChangedSettings'] = preg_replace('/^.+\n/', '', $viewParams['__indexChangedSettings']); 98 | } 99 | 100 | $this->files->put($path, 'viewFactory->make('algolia::config', $viewParams)->render()); 103 | } 104 | 105 | /** 106 | * Returns the view variables. 107 | * 108 | * @return array 109 | */ 110 | public static function getViewVariables(): array 111 | { 112 | $contents = File::get(__DIR__.'/../../resources/views/config.blade.php'); 113 | $pattern = sprintf('/(@)?%s\s*(.+?)\s*%s(\r?\n)?/s', self::$rawTags[0], self::$rawTags[1]); 114 | preg_match_all($pattern, $contents, $matches); 115 | 116 | array_pop($matches[2]); 117 | 118 | return array_map(function ($match) { 119 | return ltrim(explode(' ', $match)[0], '$'); 120 | }, $matches[2]); 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /src/Settings/Encrypter.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Algolia\ScoutExtended\Settings; 15 | 16 | /** 17 | * @internal 18 | */ 19 | class Encrypter 20 | { 21 | /** 22 | * @param \Algolia\ScoutExtended\Settings\Settings $settings 23 | * 24 | * @return string 25 | */ 26 | public function encrypt(Settings $settings): string 27 | { 28 | return md5(serialize($settings->compiled())); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Settings/LocalFactory.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Algolia\ScoutExtended\Settings; 15 | 16 | use Algolia\AlgoliaSearch\SearchIndex; 17 | use Algolia\ScoutExtended\Exceptions\ModelNotFoundException; 18 | use Algolia\ScoutExtended\Repositories\RemoteSettingsRepository; 19 | use Algolia\ScoutExtended\Searchable\Aggregator; 20 | use Illuminate\Database\Eloquent\ModelNotFoundException as BaseModelNotFoundException; 21 | use Illuminate\Database\QueryException; 22 | use Illuminate\Support\Str; 23 | use function in_array; 24 | use function is_string; 25 | 26 | /** 27 | * @internal 28 | */ 29 | class LocalFactory 30 | { 31 | /** 32 | * @var \Algolia\ScoutExtended\Repositories\RemoteSettingsRepository 33 | */ 34 | private $remoteRepository; 35 | 36 | /** 37 | * @var string[] 38 | */ 39 | private static $customRankingKeys = [ 40 | '*ed_at', 41 | 'count_*', 42 | '*_count', 43 | 'number_*', 44 | '*_number', 45 | ]; 46 | 47 | /** 48 | * @var string[] 49 | */ 50 | private static $unsearchableAttributesKeys = [ 51 | 'id', 52 | '*_id', 53 | 'id_*', 54 | '*ed_at', 55 | '*_count', 56 | 'count_*', 57 | 'number_*', 58 | '*_number', 59 | '*image*', 60 | '*url*', 61 | '*link*', 62 | '*password*', 63 | '*token*', 64 | '*hash*', 65 | ]; 66 | 67 | /** 68 | * @var string[] 69 | */ 70 | private static $attributesForFacetingKeys = [ 71 | '*category*', 72 | '*list*', 73 | '*country*', 74 | '*city*', 75 | '*type*', 76 | ]; 77 | 78 | /** 79 | * @var string[] 80 | */ 81 | private static $unretrievableAttributes = [ 82 | '*password*', 83 | '*token*', 84 | '*secret*', 85 | '*hash*', 86 | ]; 87 | 88 | /** 89 | * @var string[] 90 | */ 91 | private static $unsearchableAttributesValues = [ 92 | 'http://*', 93 | 'https://*', 94 | ]; 95 | 96 | /** 97 | * @var string[] 98 | */ 99 | private static $disableTypoToleranceOnAttributesKeys = [ 100 | 'slug', 101 | '*_slug', 102 | 'slug_*', 103 | '*code*', 104 | '*sku*', 105 | '*reference*', 106 | ]; 107 | 108 | /** 109 | * SettingsFactory constructor. 110 | * 111 | * @param \Algolia\ScoutExtended\Repositories\RemoteSettingsRepository $remoteRepository 112 | * 113 | * @return void 114 | */ 115 | public function __construct(RemoteSettingsRepository $remoteRepository) 116 | { 117 | $this->remoteRepository = $remoteRepository; 118 | } 119 | 120 | /** 121 | * Creates settings for the given model. 122 | * 123 | * @param \Algolia\AlgoliaSearch\SearchIndex $index 124 | * @param string $model 125 | * 126 | * @return \Algolia\ScoutExtended\Settings\Settings 127 | */ 128 | public function create(SearchIndex $index, string $model): Settings 129 | { 130 | $attributes = $this->getAttributes($model); 131 | $searchableAttributes = []; 132 | $attributesForFaceting = []; 133 | $customRanking = []; 134 | $disableTypoToleranceOnAttributes = []; 135 | $unretrievableAttributes = []; 136 | foreach ($attributes as $key => $value) { 137 | $key = (string) $key; 138 | 139 | if ($this->isSearchableAttributes($key, $value)) { 140 | $searchableAttributes[] = $key; 141 | } 142 | 143 | if ($this->isAttributesForFaceting($key, $value)) { 144 | $attributesForFaceting[] = $key; 145 | } 146 | 147 | if ($this->isCustomRanking($key, $value)) { 148 | $customRanking[] = "desc({$key})"; 149 | } 150 | 151 | if ($this->isDisableTypoToleranceOnAttributes($key, $value)) { 152 | $disableTypoToleranceOnAttributes[] = $key; 153 | } 154 | 155 | if ($this->isUnretrievableAttributes($key, $value)) { 156 | $unretrievableAttributes[] = $key; 157 | } 158 | } 159 | 160 | $detectedSettings = [ 161 | 'searchableAttributes' => ! empty($searchableAttributes) ? $searchableAttributes : null, 162 | 'attributesForFaceting' => ! empty($attributesForFaceting) ? $attributesForFaceting : null, 163 | 'customRanking' => ! empty($customRanking) ? $customRanking : null, 164 | 'disableTypoToleranceOnAttributes' => ! empty($disableTypoToleranceOnAttributes) ? 165 | $disableTypoToleranceOnAttributes : null, 166 | 'unretrievableAttributes' => ! empty($unretrievableAttributes) ? $unretrievableAttributes : null, 167 | 'queryLanguages' => array_unique([config('app.locale'), config('app.fallback_locale')]), 168 | ]; 169 | 170 | $settings = array_merge($this->remoteRepository->find($index)->compiled(), $detectedSettings); 171 | 172 | return new Settings($settings, $this->remoteRepository->defaults()); 173 | } 174 | 175 | /** 176 | * Checks if the given key/value is a 'searchableAttributes'. 177 | * 178 | * @param string $key 179 | * @param mixed $value 180 | * 181 | * @return bool 182 | */ 183 | public function isSearchableAttributes(string $key, $value): bool 184 | { 185 | return ! is_object($value) && ! is_array($value) && 186 | ! Str::is(self::$unsearchableAttributesKeys, $key) && 187 | ! Str::is(self::$unsearchableAttributesValues, $value); 188 | } 189 | 190 | /** 191 | * Checks if the given key/value is a 'attributesForFaceting'. 192 | * 193 | * @param string $key 194 | * @param mixed $value 195 | * 196 | * @return bool 197 | */ 198 | public function isAttributesForFaceting(string $key, $value): bool 199 | { 200 | return Str::is(self::$attributesForFacetingKeys, $key); 201 | } 202 | 203 | /** 204 | * Checks if the given key/value is a 'customRanking'. 205 | * 206 | * @param string $key 207 | * @param mixed $value 208 | * 209 | * @return bool 210 | */ 211 | public function isCustomRanking(string $key, $value): bool 212 | { 213 | return Str::is(self::$customRankingKeys, $key); 214 | } 215 | 216 | /** 217 | * Checks if the given key/value is a 'disableTypoToleranceOnAttributes'. 218 | * 219 | * @param string $key 220 | * @param mixed $value 221 | * 222 | * @return bool 223 | */ 224 | public function isDisableTypoToleranceOnAttributes(string $key, $value): bool 225 | { 226 | return Str::is(self::$disableTypoToleranceOnAttributesKeys, $key); 227 | } 228 | 229 | /** 230 | * Checks if the given key/value is a 'unretrievableAttributes'. 231 | * 232 | * @param string $key 233 | * @param mixed $value 234 | * 235 | * @return bool 236 | */ 237 | public function isUnretrievableAttributes(string $key, $value): bool 238 | { 239 | return Str::is(self::$unretrievableAttributes, $key); 240 | } 241 | 242 | /** 243 | * Tries to get attributes from the searchable class. 244 | * 245 | * @param string $searchable 246 | * 247 | * @return array 248 | */ 249 | private function getAttributes(string $searchable): array 250 | { 251 | $attributes = []; 252 | 253 | if (in_array(Aggregator::class, (array) class_parents($searchable), true)) { 254 | foreach (($instance = new $searchable)->getModels() as $model) { 255 | $attributes = array_merge($attributes, $this->getAttributes($model)); 256 | } 257 | } else { 258 | $instance = null; 259 | 260 | try { 261 | $instance = $searchable::firstOrFail(); 262 | } catch (QueryException | BaseModelNotFoundException $e) { 263 | throw tap(new ModelNotFoundException())->setModel($searchable); 264 | } 265 | 266 | $attributes = method_exists($instance, 'toSearchableArray') ? $instance->toSearchableArray() : 267 | $instance->toArray(); 268 | } 269 | 270 | return $attributes; 271 | } 272 | } 273 | -------------------------------------------------------------------------------- /src/Settings/Settings.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Algolia\ScoutExtended\Settings; 15 | 16 | use function in_array; 17 | 18 | /** 19 | * @internal 20 | */ 21 | class Settings 22 | { 23 | /** 24 | * @var array 25 | */ 26 | private $settings; 27 | 28 | /** 29 | * @var string[] 30 | */ 31 | private static $ignore = [ 32 | 'version', 33 | 'userData', 34 | ]; 35 | 36 | /** 37 | * The default options. 38 | * 39 | * @var array 40 | */ 41 | private $defaults; 42 | 43 | /** 44 | * Settings constructor. 45 | * 46 | * @param array $settings 47 | * @param array $defaults 48 | * 49 | * @return void 50 | */ 51 | public function __construct(array $settings, array $defaults) 52 | { 53 | $this->settings = $settings; 54 | $this->defaults = $defaults; 55 | } 56 | 57 | /** 58 | * Get all of the items in the settings. 59 | * 60 | * @return array 61 | */ 62 | public function all(): array 63 | { 64 | $settings = $this->settings; 65 | foreach (Compiler::getViewVariables() as $key) { 66 | if (array_key_exists($key, $this->settings)) { 67 | $settings[$key] = $this->settings[$key]; 68 | } elseif (array_key_exists($key, $this->defaults)) { 69 | $settings[$key] = $this->defaults[$key]; 70 | } else { 71 | $settings[$key] = null; 72 | } 73 | } 74 | 75 | return array_filter($settings, function ($value, $setting) { 76 | return ! in_array($setting, self::$ignore, true); 77 | }, ARRAY_FILTER_USE_BOTH); 78 | } 79 | 80 | /** 81 | * Get the changed items in the settings. 82 | * 83 | * @return array 84 | */ 85 | public function changed(): array 86 | { 87 | return array_filter($this->all(), function ($value, $setting) { 88 | return ! array_key_exists($setting, $this->defaults) || $value !== $this->defaults[$setting]; 89 | }, ARRAY_FILTER_USE_BOTH); 90 | } 91 | 92 | /** 93 | * Get the changed items in the settings. 94 | * 95 | * @return array 96 | */ 97 | public function compiled(): array 98 | { 99 | $viewVariables = Compiler::getViewVariables(); 100 | $changed = $this->changed(); 101 | 102 | $compiled = array_filter($this->all(), function ($value, $setting) use ($viewVariables, $changed) { 103 | return in_array($setting, $viewVariables, true) || array_key_exists($setting, $changed); 104 | }, ARRAY_FILTER_USE_BOTH); 105 | 106 | ksort($compiled); 107 | 108 | return $compiled; 109 | } 110 | 111 | /** 112 | * Get the hash. 113 | * 114 | * @return string 115 | */ 116 | public function previousHash(): string 117 | { 118 | return $this->settings['userData'] ?? ''; 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /src/Settings/Status.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Algolia\ScoutExtended\Settings; 15 | 16 | use Algolia\AlgoliaSearch\SearchIndex; 17 | use Algolia\ScoutExtended\Contracts\LocalSettingsRepositoryContract; 18 | use Algolia\ScoutExtended\Repositories\UserDataRepository; 19 | use Illuminate\Support\Str; 20 | use LogicException; 21 | 22 | /** 23 | * @internal 24 | */ 25 | class Status 26 | { 27 | /** 28 | * @var \Algolia\ScoutExtended\Settings\Encrypter 29 | */ 30 | private $encrypter; 31 | 32 | /** 33 | * @var \Algolia\ScoutExtended\Repositories\UserDataRepository 34 | */ 35 | private $userDataRepository; 36 | 37 | /** 38 | * @var \Algolia\ScoutExtended\Contracts\LocalSettingsRepositoryContract 39 | */ 40 | private $localRepository; 41 | 42 | /** 43 | * @var \Algolia\ScoutExtended\Settings\Settings 44 | */ 45 | private $remoteSettings; 46 | 47 | /** 48 | * @var \Algolia\AlgoliaSearch\SearchIndex 49 | */ 50 | private $index; 51 | 52 | public const LOCAL_NOT_FOUND = 'localNotFound'; 53 | 54 | public const REMOTE_NOT_FOUND = 'remoteNotFound'; 55 | 56 | public const BOTH_ARE_EQUAL = 'bothAreEqual'; 57 | 58 | public const LOCAL_GOT_UPDATED = 'localGotUpdated'; 59 | 60 | public const REMOTE_GOT_UPDATED = 'remoteGotUpdated'; 61 | 62 | public const BOTH_GOT_UPDATED = 'bothGotUpdated'; 63 | 64 | /** 65 | * Status constructor. 66 | * 67 | * @param \Algolia\ScoutExtended\Contracts\LocalSettingsRepositoryContract $localRepository 68 | * @param \Algolia\ScoutExtended\Settings\Encrypter $encrypter 69 | * @param \Algolia\ScoutExtended\Settings\Settings $remoteSettings 70 | * @param \Algolia\AlgoliaSearch\SearchIndex $index 71 | * 72 | * @return void 73 | */ 74 | public function __construct( 75 | LocalSettingsRepositoryContract $localRepository, 76 | UserDataRepository $userDataRepository, 77 | Encrypter $encrypter, 78 | Settings $remoteSettings, 79 | SearchIndex $index 80 | ) { 81 | $this->encrypter = $encrypter; 82 | $this->localRepository = $localRepository; 83 | $this->userDataRepository = $userDataRepository; 84 | $this->remoteSettings = $remoteSettings; 85 | $this->index = $index; 86 | } 87 | 88 | /** 89 | * @return bool 90 | */ 91 | public function localNotFound(): bool 92 | { 93 | return ! $this->localRepository->exists($this->index); 94 | } 95 | 96 | /** 97 | * @return bool 98 | */ 99 | public function remoteNotFound(): bool 100 | { 101 | return empty($this->userDataRepository->getSettingsHash($this->index)); 102 | } 103 | 104 | /** 105 | * @return bool 106 | */ 107 | public function bothAreEqual(): bool 108 | { 109 | return $this->encrypter->encrypt($this->localRepository->find($this->index)) === 110 | $this->userDataRepository->getSettingsHash($this->index) && 111 | $this->encrypter->encrypt($this->remoteSettings) === $this->userDataRepository->getSettingsHash($this->index); 112 | } 113 | 114 | /** 115 | * @return bool 116 | */ 117 | public function localGotUpdated(): bool 118 | { 119 | return $this->encrypter->encrypt($this->localRepository->find($this->index)) !== 120 | $this->userDataRepository->getSettingsHash($this->index) && 121 | $this->encrypter->encrypt($this->remoteSettings) === $this->userDataRepository->getSettingsHash($this->index); 122 | } 123 | 124 | /** 125 | * @return bool 126 | */ 127 | public function remoteGotUpdated(): bool 128 | { 129 | return $this->encrypter->encrypt($this->localRepository->find($this->index)) === 130 | $this->userDataRepository->getSettingsHash($this->index) && 131 | $this->encrypter->encrypt($this->remoteSettings) !== $this->userDataRepository->getSettingsHash($this->index); 132 | } 133 | 134 | /** 135 | * @return bool 136 | */ 137 | public function bothGotUpdated(): bool 138 | { 139 | return $this->encrypter->encrypt($this->localRepository->find($this->index)) !== 140 | $this->userDataRepository->getSettingsHash($this->index) && 141 | $this->encrypter->encrypt($this->remoteSettings) !== $this->userDataRepository->getSettingsHash($this->index); 142 | } 143 | 144 | /** 145 | * Get the current state. 146 | * 147 | * @return string 148 | */ 149 | public function toString(): string 150 | { 151 | $methods = [ 152 | self::LOCAL_NOT_FOUND, 153 | self::REMOTE_NOT_FOUND, 154 | self::BOTH_ARE_EQUAL, 155 | self::LOCAL_GOT_UPDATED, 156 | self::REMOTE_GOT_UPDATED, 157 | self::BOTH_GOT_UPDATED, 158 | ]; 159 | 160 | foreach ($methods as $method) { 161 | if ($this->{$method}()) { 162 | return $method; 163 | } 164 | } 165 | 166 | throw new LogicException('This should not happen'); 167 | } 168 | 169 | /** 170 | * Get a human description of the current status. 171 | * 172 | * @return string 173 | */ 174 | public function toHumanString(): string 175 | { 176 | $string = Str::snake($this->toString()); 177 | 178 | return Str::ucfirst(str_replace('_', ' ', $string)); 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /src/Settings/Synchronizer.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Algolia\ScoutExtended\Settings; 15 | 16 | use Algolia\AlgoliaSearch\SearchIndex; 17 | use Algolia\ScoutExtended\Contracts\LocalSettingsRepositoryContract; 18 | use Algolia\ScoutExtended\Repositories\RemoteSettingsRepository; 19 | use Algolia\ScoutExtended\Repositories\UserDataRepository; 20 | 21 | /** 22 | * @internal 23 | */ 24 | class Synchronizer 25 | { 26 | /** 27 | * @var \Algolia\ScoutExtended\Settings\Compiler 28 | */ 29 | private $compiler; 30 | 31 | /** 32 | * @var \Algolia\ScoutExtended\Settings\Encrypter 33 | */ 34 | private $encrypter; 35 | 36 | /** 37 | * @var \Algolia\ScoutExtended\Contracts\LocalSettingsRepositoryContract 38 | */ 39 | private $localRepository; 40 | 41 | /** 42 | * @var \Algolia\ScoutExtended\Repositories\RemoteSettingsRepository 43 | */ 44 | private $remoteRepository; 45 | 46 | /** 47 | * @var \Algolia\ScoutExtended\Repositories\UserDataRepository 48 | */ 49 | private $userDataRepository; 50 | 51 | /** 52 | * Synchronizer constructor. 53 | * 54 | * @param \Algolia\ScoutExtended\Settings\Compiler $compiler 55 | * @param \Algolia\ScoutExtended\Settings\Encrypter $encrypter 56 | * @param \Algolia\ScoutExtended\Contracts\LocalSettingsRepositoryContract $localRepository 57 | * @param \Algolia\ScoutExtended\Repositories\RemoteSettingsRepository $remoteRepository 58 | * @param \Algolia\ScoutExtended\Repositories\UserDataRepository $userDataRepository 59 | * 60 | * @return void 61 | */ 62 | public function __construct( 63 | Compiler $compiler, 64 | Encrypter $encrypter, 65 | LocalSettingsRepositoryContract $localRepository, 66 | RemoteSettingsRepository $remoteRepository, 67 | UserDataRepository $userDataRepository 68 | ) { 69 | $this->compiler = $compiler; 70 | $this->encrypter = $encrypter; 71 | $this->localRepository = $localRepository; 72 | $this->remoteRepository = $remoteRepository; 73 | $this->userDataRepository = $userDataRepository; 74 | } 75 | 76 | /** 77 | * Analyses the settings of the given index. 78 | * 79 | * @param \Algolia\AlgoliaSearch\SearchIndex $index 80 | * 81 | * @return \Algolia\ScoutExtended\Settings\Status 82 | */ 83 | public function analyse(SearchIndex $index): Status 84 | { 85 | $remoteSettings = $this->remoteRepository->find($index); 86 | 87 | return new Status($this->localRepository, $this->userDataRepository, $this->encrypter, $remoteSettings, $index); 88 | } 89 | 90 | /** 91 | * Downloads the settings of the given index. 92 | * 93 | * @param \Algolia\AlgoliaSearch\SearchIndex $index 94 | * 95 | * @return void 96 | */ 97 | public function download(SearchIndex $index): void 98 | { 99 | $settings = $this->remoteRepository->find($index); 100 | 101 | $path = $this->localRepository->getPath($index); 102 | 103 | $this->compiler->compile($settings, $path); 104 | 105 | $settingsHash = $this->encrypter->encrypt($settings); 106 | 107 | $this->userDataRepository->save($index, ['settingsHash' => $settingsHash]); 108 | } 109 | 110 | /** 111 | * Uploads the settings of the given index. 112 | * 113 | * @param \Algolia\AlgoliaSearch\SearchIndex $index 114 | * 115 | * @return void 116 | */ 117 | public function upload(SearchIndex $index): void 118 | { 119 | $settings = $this->localRepository->find($index); 120 | 121 | $settingsHash = $this->encrypter->encrypt($settings); 122 | 123 | $this->userDataRepository->save($index, ['settingsHash' => $settingsHash]); 124 | $this->remoteRepository->save($index, $settings); 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/Splitters/HtmlSplitter.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Algolia\ScoutExtended\Splitters; 15 | 16 | use Algolia\ScoutExtended\Contracts\SplitterContract; 17 | use DOMDocument; 18 | 19 | class HtmlSplitter implements SplitterContract 20 | { 21 | /** 22 | * The list of html tags. 23 | * 24 | * @var string[] 25 | */ 26 | protected $tags = [ 27 | 'h1', 28 | 'h2', 29 | 'h3', 30 | 'h4', 31 | 'h5', 32 | 'p', 33 | ]; 34 | 35 | /** 36 | * Creates a new instance of the class. 37 | * 38 | * @param array|null $tags 39 | * 40 | * @return void 41 | */ 42 | public function __construct(?array $tags = null) 43 | { 44 | if ($tags !== null) { 45 | $this->tags = $tags; 46 | } 47 | } 48 | 49 | /** 50 | * Acts a static factory. 51 | * 52 | * @param string|array $tags 53 | * 54 | * @return static 55 | */ 56 | public static function by($tags) 57 | { 58 | return new static((array) $tags); 59 | } 60 | 61 | /** 62 | * Splits the given value. 63 | * 64 | * @param object $searchable 65 | * @param string $value 66 | * 67 | * @return array 68 | */ 69 | public function split($searchable, $value): array 70 | { 71 | $dom = new DOMDocument(); 72 | $dom->loadHTML($value); 73 | $values = []; 74 | 75 | foreach ($this->tags as $tag) { 76 | foreach ($dom->getElementsByTagName($tag) as $node) { 77 | $values[] = $node->textContent; 78 | 79 | while (($node = $node->nextSibling) && $node->nodeName !== $tag) { 80 | $values[] = $node->textContent; 81 | } 82 | } 83 | } 84 | 85 | return $values; 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/Transformers/ConvertDatesToTimestamps.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Algolia\ScoutExtended\Transformers; 15 | 16 | use Algolia\ScoutExtended\Contracts\TransformerContract; 17 | 18 | class ConvertDatesToTimestamps implements TransformerContract 19 | { 20 | /** 21 | * Converts the given array numeric strings to numbers. 22 | * 23 | * @param object $searchable 24 | * @param array $array 25 | * 26 | * @return array 27 | */ 28 | public function transform($searchable, array $array): array 29 | { 30 | foreach ($array as $key => $value) { 31 | $attributeValue = $searchable->getModel()->getAttribute($key); 32 | 33 | /* 34 | * Casts carbon instances to timestamp. 35 | */ 36 | if ($attributeValue instanceof \Illuminate\Support\Carbon) { 37 | $array[$key] = $attributeValue->getTimestamp(); 38 | } 39 | } 40 | 41 | return $array; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Transformers/ConvertNumericStringsToNumbers.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Algolia\ScoutExtended\Transformers; 15 | 16 | use Algolia\ScoutExtended\Contracts\TransformerContract; 17 | use function is_string; 18 | 19 | class ConvertNumericStringsToNumbers implements TransformerContract 20 | { 21 | /** 22 | * Converts the given array numeric strings to numbers. 23 | * 24 | * @param object $searchable 25 | * @param array $array 26 | * 27 | * @return array 28 | */ 29 | public function transform($searchable, array $array): array 30 | { 31 | foreach ($array as $key => $value) { 32 | /* 33 | * Casts numeric strings to integers/floats. 34 | */ 35 | if (is_string($value) && is_numeric($value)) { 36 | $array[$key] = ctype_digit($value) ? (int) $value : (float) $value; 37 | } 38 | } 39 | 40 | return $array; 41 | } 42 | } 43 | --------------------------------------------------------------------------------