├── .env.test ├── .github └── workflows │ ├── check-coverage.yml │ ├── check-quality.yml │ ├── publish-api-doc.yml │ ├── publish-coverage.yml │ ├── publish-doc.yml │ └── run-tests.yml ├── .gitignore ├── Dockerfile ├── README.md ├── composer.json ├── config └── scout.php ├── docker-compose.databases.yml ├── docker-compose.yml ├── markdown-to-html.json ├── phpcs.xml ├── phpunit.xml ├── run ├── src ├── Builder.php ├── Engine.php ├── Migrations │ ├── MigrateMakeCommand.php │ ├── MigrationCreator.php │ └── stubs │ │ └── migration.stub ├── SearchIndex.php ├── Searchable.php ├── SqloutServiceProvider.php └── helpers.php └── tests ├── Factories ├── CommentFactory.php └── PostFactory.php ├── Models ├── Comment.php └── Post.php ├── SearchTest.php ├── TestCase.php └── database ├── .gitignore └── migrations ├── 0000_00_00_000005_create_posts_table.php ├── 0000_00_00_000006_create_comments_table.php └── 2019_08_11_164134_create_sqlout_index.php /.env.test: -------------------------------------------------------------------------------- 1 | DB_HOST=database 2 | DB_DATABASE=test 3 | DB_USERNAME=test 4 | DB_PASSWORD=test 5 | 6 | SQLITE_DATABASE=./tests/database/database.sqlite 7 | 8 | MYSQL_PORT=3306 9 | MARIADB_PORT=3306 10 | PGSQL_PORT=5432 11 | 12 | SQLSRV_PORT=1433 13 | SQLSRV_USERNAME=SA 14 | SQLSRV_PASSWORD=MyS3cureP4ssw0rd 15 | SQLSRV_DATABASE=master 16 | -------------------------------------------------------------------------------- /.github/workflows/check-coverage.yml: -------------------------------------------------------------------------------- 1 | name: "Check coverage" 2 | on: 3 | pull_request: 4 | types: [opened, reopened, edited, synchronize] 5 | branches: [master, main] 6 | paths: 7 | - src/** 8 | - tests/** 9 | - phpunit.xml 10 | concurrency: 11 | group: ${{ github.workflow }}-${{ github.ref }} 12 | cancel-in-progress: true 13 | jobs: 14 | generate-coverage: 15 | name: "Generate coverage" 16 | uses: michaelbaril/shared/.github/workflows/run-tests.yml@main 17 | with: 18 | with-coverage: true 19 | database-engine: mysql 20 | database-version: 8.4 21 | check-coverage: 22 | needs: generate-coverage 23 | uses: michaelbaril/shared/.github/workflows/check-coverage.yml@main 24 | -------------------------------------------------------------------------------- /.github/workflows/check-quality.yml: -------------------------------------------------------------------------------- 1 | name: "Check quality" 2 | on: 3 | push: 4 | branches: [master, main] 5 | paths: 6 | - src/** 7 | - phpcs.xml 8 | pull_request: 9 | types: [opened, reopened, edited, synchronize] 10 | branches: [master, main] 11 | paths: 12 | - src/** 13 | - phpcs.xml 14 | concurrency: 15 | group: ${{ github.workflow }}-${{ github.ref }} 16 | cancel-in-progress: true 17 | jobs: 18 | phpcs: 19 | uses: michaelbaril/shared/.github/workflows/phpcs.yml@main 20 | -------------------------------------------------------------------------------- /.github/workflows/publish-api-doc.yml: -------------------------------------------------------------------------------- 1 | name: "Publish API documentation" 2 | on: 3 | push: 4 | branches: [master, main] 5 | paths: 6 | - src/** 7 | - apigen.neon 8 | concurrency: 9 | group: ${{ github.workflow }}-${{ github.ref }} 10 | cancel-in-progress: true 11 | jobs: 12 | publish-api-doc: 13 | uses: michaelbaril/shared/.github/workflows/publish-api-doc.yml@main 14 | -------------------------------------------------------------------------------- /.github/workflows/publish-coverage.yml: -------------------------------------------------------------------------------- 1 | name: "Publish coverage" 2 | on: 3 | push: 4 | branches: [master, main] 5 | paths: 6 | - src/** 7 | - tests/** 8 | - phpunit.xml 9 | concurrency: 10 | group: ${{ github.workflow }}-${{ github.ref }} 11 | cancel-in-progress: true 12 | jobs: 13 | generate-coverage: 14 | name: "Generate coverage" 15 | uses: michaelbaril/shared/.github/workflows/run-tests.yml@main 16 | with: 17 | with-coverage: true 18 | database-engine: mysql 19 | database-version: 8.4 20 | publish-coverage: 21 | needs: generate-coverage 22 | uses: michaelbaril/shared/.github/workflows/publish-coverage.yml@main 23 | -------------------------------------------------------------------------------- /.github/workflows/publish-doc.yml: -------------------------------------------------------------------------------- 1 | name: "Publish documentation" 2 | on: 3 | push: 4 | branches: [master, main] 5 | paths: 6 | - README.md 7 | - doc/**.md 8 | - markdown-to-html.json 9 | concurrency: 10 | group: ${{ github.workflow }}-${{ github.ref }} 11 | cancel-in-progress: true 12 | jobs: 13 | publish-doc: 14 | uses: michaelbaril/shared/.github/workflows/publish-doc.yml@main 15 | 16 | -------------------------------------------------------------------------------- /.github/workflows/run-tests.yml: -------------------------------------------------------------------------------- 1 | name: "Run tests" 2 | on: 3 | push: 4 | branches: [master, main, test] 5 | paths: 6 | - src/** 7 | - tests/** 8 | - phpunit.xml 9 | pull_request: 10 | types: [opened, reopened, edited, synchronize] 11 | branches: [master, main] 12 | paths: 13 | - src/** 14 | - tests/** 15 | - phpunit.xml 16 | concurrency: 17 | group: ${{ github.workflow }}-${{ github.ref }} 18 | cancel-in-progress: true 19 | jobs: 20 | tests: 21 | strategy: 22 | fail-fast: false 23 | matrix: 24 | php: [8.4, 8.3, 8.2, 8.1, 8.0, 7.4, 7.3] 25 | laravel: [12.*, 11.*, 10.*, 9.*, 8.*] 26 | dependency-version: [lowest, stable] 27 | database-engine: [mysql] 28 | exclude: 29 | - laravel: 12.* 30 | php: 8.1 31 | - laravel: 12.* 32 | php: 8.0 33 | - laravel: 12.* 34 | php: 7.4 35 | - laravel: 12.* 36 | php: 7.3 37 | - laravel: 11.* 38 | php: 8.1 39 | - laravel: 11.* 40 | php: 8.0 41 | - laravel: 11.* 42 | php: 7.4 43 | - laravel: 11.* 44 | php: 7.3 45 | - laravel: 10.* 46 | php: 8.0 47 | - laravel: 10.* 48 | php: 7.4 49 | - laravel: 10.* 50 | php: 7.3 51 | - laravel: 9.* 52 | php: 8.4 53 | - laravel: 9.* 54 | php: 8.3 55 | - laravel: 9.* 56 | php: 8.2 57 | dependency-version: lowest 58 | - laravel: 9.* 59 | php: 7.4 60 | - laravel: 9.* 61 | php: 7.3 62 | - laravel: 8.* 63 | php: 8.4 64 | - laravel: 8.* 65 | php: 8.3 66 | - laravel: 8.* 67 | php: 8.2 68 | dependency-version: lowest 69 | include: 70 | - php: 8.4 71 | database-version: 8.4 72 | - php: 8.3 73 | database-version: 8.4 74 | - php: 8.2 75 | database-version: 8.4 76 | - php: 8.1 77 | database-version: 8.4 78 | - php: 8.0 79 | database-version: 8.4 80 | - php: 7.4 81 | database-version: 8.4 82 | - php: 7.3 83 | database-version: 5.7 84 | name: "php:${{ matrix.php }}/lara:${{ matrix.laravel }}/${{ matrix.dependency-version }}/${{ matrix.database-engine }}:${{ matrix.database-version }}" 85 | uses: michaelbaril/shared/.github/workflows/run-tests.yml@main 86 | with: 87 | php-version: ${{ matrix.php }} 88 | composer-options: > 89 | --with laravel/framework:${{ matrix.laravel }} 90 | --prefer-${{ matrix.dependency-version }} 91 | database-engine: ${{ matrix.database-engine }} 92 | database-version: ${{ matrix.database-version }} 93 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | .phpunit.cache 3 | .phpunit.result.cache 4 | composer.lock 5 | /coverage 6 | /vendor 7 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ARG PHP_VERSION=8.3 2 | 3 | FROM php:${PHP_VERSION}-cli-alpine 4 | 5 | RUN mv $PHP_INI_DIR/php.ini-development $PHP_INI_DIR/php.ini 6 | 7 | RUN apk update && \ 8 | apk add oniguruma-dev && \ 9 | apk add libpq-dev && \ 10 | docker-php-ext-install -j$(nproc) mbstring pdo pdo_mysql pgsql pdo_pgsql 11 | 12 | # SQL Server (inspired from https://github.com/kool-dev/docker-php-sqlsrv/blob/main/7.4-nginx-sqlsrv/Dockerfile) 13 | RUN curl -O https://download.microsoft.com/download/fae28b9a-d880-42fd-9b98-d779f0fdd77f/msodbcsql18_18.5.1.1-1_amd64.apk && \ 14 | curl -O https://download.microsoft.com/download/7/6/d/76de322a-d860-4894-9945-f0cc5d6a45f8/mssql-tools18_18.4.1.1-1_amd64.apk && \ 15 | apk add --allow-untrusted msodbcsql18_18.5.1.1-1_amd64.apk && \ 16 | apk add --allow-untrusted mssql-tools18_18.4.1.1-1_amd64.apk && \ 17 | apk add --no-cache --virtual .persistent-deps freetds unixodbc && \ 18 | apk add --no-cache --virtual .build-deps $PHPIZE_DEPS freetds-dev unixodbc-dev && \ 19 | docker-php-ext-configure pdo_odbc --with-pdo-odbc=unixODBC,/usr && \ 20 | docker-php-ext-install pdo_odbc && \ 21 | pecl install sqlsrv pdo_sqlsrv && \ 22 | docker-php-ext-enable sqlsrv pdo_sqlsrv || \ 23 | echo "Can't install ODBC drivers" 24 | 25 | RUN apk add linux-headers && \ 26 | apk add $PHPIZE_DEPS && \ 27 | pecl install xdebug && \ 28 | docker-php-ext-enable xdebug || \ 29 | echo "Can't install XDEBUG" 30 | 31 | COPY --from=composer:latest /usr/bin/composer /usr/local/bin/ 32 | 33 | ENV XDEBUG_MODE=coverage 34 | 35 | ARG UID=1000 36 | ARG GID=1000 37 | USER ${UID}:${GID} 38 | 39 | WORKDIR /app 40 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Sqlout :dolphin: 2 | 3 | [![Version](https://img.shields.io/packagist/v/baril/sqlout?label=stable)](https://packagist.org/packages/baril/sqlout) 4 | [![License](https://img.shields.io/packagist/l/baril/sqlout)](https://packagist.org/packages/baril/sqlout) 5 | [![Downloads](https://img.shields.io/packagist/dt/baril/sqlout)](https://packagist.org/packages/baril/sqlout/stats) 6 | [![Tests](https://img.shields.io/github/actions/workflow/status/michaelbaril/sqlout/run-tests.yml?branch=master&label=tests)](https://github.com/michaelbaril/sqlout/actions/workflows/run-tests.yml?query=branch%3Amaster) 7 | [![Coverage](https://img.shields.io/endpoint?url=https%3A%2F%2Fmichaelbaril.github.io%2Fsqlout%2Fcoverage%2Fbadge.json)](https://michaelbaril.github.io/sqlout/coverage/) 8 | 9 | Sqlout is a MySQL driver for Laravel Scout. It indexes the data into 10 | a dedicated table of the MySQL database, and uses a fulltext index to search. 11 | It is meant for small-sized projects, for which bigger solutions such as 12 | ElasticSearch would be an overkill. 13 | 14 | Sqlout is different than Scout's native `Database` engine because it indexes 15 | data in a separate, dedicated table, and uses a fulltext index. Sqlout has more 16 | features such as field weights and word stemming. 17 | 18 | Sqlout is compatible with Laravel 5.8+ to 12.x and Scout 7.1+ / 8.x / 9.x / 10.x 19 | (credit goes to [ikari7789](https://github.com/ikari7789) for Laravel 9 and 10 / Scout 20 | 9 and 10 support). 21 | 22 | You can find the full API documentation [here](https://michaelbaril.github.io/sqlout/api/). 23 | 24 | ## Version compatibility 25 | 26 | Laravel | Scout | Sqlout 27 | :------------|:----------|:---------- 28 | 11.x / 12.x | 10.x | 5.1+ 29 | 9.x / 10.x | 10.x | 5.x 30 | 8.x / 9.x | 9.x | 4.x 31 | 8.x | 8.x | 3.x 32 | 7.x | 8.x | 2.0 33 | 6.x | 8.x | 2.0 34 | 6.x | 7.1 / 7.2 | 1.x / 2.0 35 | 5.8 | 7.1 / 7.2 | 1.x / 2.0 36 | 37 | ## Setup 38 | 39 | Require the package: 40 | 41 | ```bash 42 | composer require baril/sqlout 43 | ``` 44 | 45 | Publish the configuration: 46 | 47 | ```bash 48 | php artisan vendor:publish 49 | ``` 50 | 51 | If you're not using package discovery, manually add the service providers 52 | (Scout's and Sqlout's) to your `config/app.php` file: 53 | 54 | ```php 55 | return [ 56 | // ... 57 | 'providers' => [ 58 | // ... 59 | Laravel\Scout\ScoutServiceProvider::class, 60 | Baril\Sqlout\SqloutServiceProvider::class, 61 | ], 62 | ]; 63 | ``` 64 | 65 | Migrate your database: 66 | 67 | ```bash 68 | php artisan sqlout:make-migration 69 | php artisan migrate 70 | ``` 71 | 72 | This will create a `searchindex` table in your database (the table name can 73 | be customized in the config file). 74 | 75 | If you want to index models that belong to different connections, you need 76 | a table for Sqlout on each connection. To create the table on a connection that 77 | is not the default connection, you can call the `sqlout:make-migration` command 78 | and pass the name of the connection: 79 | 80 | ```bash 81 | php artisan sqlout:make-migration my_other_connection 82 | php artisan migrate 83 | ``` 84 | 85 | ## Making a model searchable 86 | 87 | ```php 88 | namespace App\Models; 89 | 90 | use Baril\Sqlout\Searchable; 91 | 92 | class Post extends Model 93 | { 94 | use Searchable; 95 | 96 | protected $weights = [ 97 | 'title' => 4, 98 | 'excerpt' => 2, 99 | ]; 100 | 101 | public function toSearchableArray() 102 | { 103 | return [ 104 | 'title' => $this->post_title, 105 | 'excerpt' => $this->post_excerpt, 106 | 'body' => $this->post_content, 107 | ]; 108 | } 109 | } 110 | ``` 111 | 112 | The example above is similar to what is described in 113 | [Scout's documentation](https://laravel.com/docs/master/scout#configuration), 114 | with the following differences/additions: 115 | 116 | * The model uses the `Baril\Sqlout\Searchable` trait instead of 117 | `Laravel\Scout\Searchable`. 118 | * The `$weight` property can be used to "boost" some fields. 119 | The default value is 1. 120 | 121 | Once this is done, you can index your data using Scout's Artisan command: 122 | 123 | ```bash 124 | php artisan scout:import "App\\Models\\Post" 125 | ``` 126 | 127 | Your models will also be indexed automatically on save. 128 | 129 | ## Searching 130 | 131 | ### Basics 132 | 133 | ```php 134 | $results = Post::search('this rug really tied the room together')->get(); 135 | $results = Post::search('the dude abides')->withTrashed()->get(); 136 | ``` 137 | 138 | See [Scout's documentation](https://laravel.com/docs/master/scout#searching) 139 | for more details. 140 | 141 | Sqlout's builder also provides the following additional methods: 142 | 143 | ```php 144 | // Restrict the search to some fields only: 145 | $builder->only('title'); 146 | $builder->only(['title', 'excerpt']); 147 | // (use the same names as in the toSearchableArray method) 148 | 149 | // Retrieve the total number of results: 150 | $nbHits = $builder->count(); 151 | ``` 152 | 153 | ### Using scopes 154 | 155 | With Sqlout, you can also use your model scopes on the search builder, 156 | as if it was a query builder on the model itself. Similarly, all calls to the 157 | `where` method on the search builder will be 158 | forwarded to the model's query builder. 159 | 160 | ```php 161 | $results = Post::search('you see what happens larry') 162 | ->published() // the `published` scope is defined in the Post class 163 | ->where('date', '>', '2010-10-10') 164 | ->get(); 165 | ``` 166 | 167 | > :warning: Keep in mind that these forwarded scopes will actually be applied 168 | > to a subquery (the main query here being the one on the `searchindex` table). 169 | > This means that for example a scope that adds an `order by` clause won't have 170 | > any effect. See below for the proper way to order results. 171 | 172 | If the name of your scope collides with the name of a method of the 173 | `Baril\Sqlout\Builder` object, you can wrap your scope into the `scope` method: 174 | 175 | ```php 176 | $results = Post::search('ve vant ze money lebowski') 177 | ->scope(function ($query) { 178 | $query->within('something'); 179 | }) 180 | ->get(); 181 | ``` 182 | 183 | ### Search modes 184 | 185 | MySQL's fulltext search comes in 3 flavours: 186 | * natural language mode, 187 | * natural language mode with query expansion, 188 | * boolean mode. 189 | 190 | Sqlout's default mode is "natural language" (but this can be changed in the 191 | config file). 192 | 193 | You can also switch between all 3 modes on a per-query basis, by using the 194 | following methods: 195 | 196 | ```php 197 | $builder->inNaturalLanguageMode(); 198 | $builder->withQueryExpansion(); 199 | $builder->inBooleanMode(); 200 | ``` 201 | 202 | ### Ordering the results 203 | 204 | If no order is specified, the results will be ordered by score (most relevant 205 | first). But you can also order the results by any column of your table. 206 | 207 | ```php 208 | $builder->orderBy('post_status', 'asc')->orderByScore(); 209 | // "post_status" is a column of the original table 210 | ``` 211 | 212 | In the example below, the results will be ordered by status first, and then 213 | by descending score. 214 | 215 | ### Filters, tokenizer, stopwords and stemming 216 | 217 | In your config file, you can customize the way the indexed content and search 218 | terms will be processed: 219 | 220 | ```php 221 | return [ 222 | // ... 223 | 'sqlout' => [ 224 | // ... 225 | 'filters' => [ // anything callable (function name, closure...) 226 | 'strip_tags', 227 | 'html_entity_decode', 228 | 'mb_strtolower', 229 | 'strip_punctuation', // this helper is provided by Sqlout (see helpers.php) 230 | ], 231 | 'token_delimiter' => '/[\s]+/', 232 | 'minimum_length' => 2, 233 | 'stopwords' => [ 234 | 'est', 235 | 'les', 236 | ], 237 | 'stemmer' => Wamania\Snowball\Stemmer\French::class, 238 | ], 239 | ]; 240 | ``` 241 | 242 | In the example, the stemmer comes from the package [`wamania/php-stemmer`], 243 | but any class with a `stem` method, or anything callable such as a closure, will do. 244 | 245 | [`wamania/php-stemmer`]: https://github.com/wamania/php-stemmer 246 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "baril/sqlout", 3 | "description": "MySQL fulltext driver for Laravel Scout.", 4 | "keywords": [ 5 | "laravel", "scout", "mysql", "fulltext", "search" 6 | ], 7 | "license": "MIT", 8 | "type": "library", 9 | "require": { 10 | "php": "^7.3|^8.0", 11 | "illuminate/database": "^8.79|^9.46|^10.0|^11.0|^12.0", 12 | "illuminate/support": "^8.79|^9.46|^10.0|^11.0|^12.0", 13 | "laravel/scout": "^9.0|^10.0" 14 | }, 15 | "require-dev": { 16 | "squizlabs/php_codesniffer": "^3.7", 17 | "wamania/php-stemmer": "^3.0|^4.0", 18 | "orchestra/testbench": "^6.23|^7.0|^8.0|^9.0|^10.0" 19 | }, 20 | "autoload": { 21 | "files": [ 22 | "src/helpers.php" 23 | ], 24 | "psr-4": { 25 | "Baril\\Sqlout\\": "src/" 26 | } 27 | }, 28 | "autoload-dev": { 29 | "psr-4": { 30 | "Baril\\Sqlout\\Tests\\": "tests/" 31 | } 32 | }, 33 | "suggest": { 34 | "wamania/php-stemmer": "PHP stemmer that can be used together with Sqlout." 35 | }, 36 | "extra": { 37 | "branch-alias": { 38 | "dev-master": "5.0.x-dev" 39 | }, 40 | "laravel": { 41 | "providers": [ 42 | "Baril\\Sqlout\\SqloutServiceProvider" 43 | ] 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /config/scout.php: -------------------------------------------------------------------------------- 1 | env('SCOUT_DRIVER', 'sqlout'), 19 | 20 | /* 21 | |-------------------------------------------------------------------------- 22 | | Index Prefix 23 | |-------------------------------------------------------------------------- 24 | | 25 | | Here you may specify a prefix that will be applied to all search index 26 | | names used by Scout. This prefix may be useful if you have multiple 27 | | "tenants" or applications sharing the same search infrastructure. 28 | | 29 | */ 30 | 31 | 'prefix' => env('SCOUT_PREFIX', ''), 32 | 33 | /* 34 | |-------------------------------------------------------------------------- 35 | | Queue Data Syncing 36 | |-------------------------------------------------------------------------- 37 | | 38 | | This option allows you to control if the operations that sync your data 39 | | with your search engines are queued. When this is set to "true" then 40 | | all automatic data syncing will get queued for better performance. 41 | | 42 | */ 43 | 44 | 'queue' => env('SCOUT_QUEUE', false), 45 | 46 | /* 47 | |-------------------------------------------------------------------------- 48 | | Database Transactions 49 | |-------------------------------------------------------------------------- 50 | | 51 | | This configuration option determines if your data will only be synced 52 | | with your search indexes after every open database transaction has 53 | | been committed, thus preventing any discarded data from syncing. 54 | | 55 | */ 56 | 57 | 'after_commit' => false, 58 | 59 | /* 60 | |-------------------------------------------------------------------------- 61 | | Chunk Sizes 62 | |-------------------------------------------------------------------------- 63 | | 64 | | These options allow you to control the maximum chunk size when you are 65 | | mass importing data into the search engine. This allows you to fine 66 | | tune each of these chunk sizes based on the power of the servers. 67 | | 68 | */ 69 | 70 | 'chunk' => [ 71 | 'searchable' => 500, 72 | 'unsearchable' => 500, 73 | ], 74 | 75 | /* 76 | |-------------------------------------------------------------------------- 77 | | Soft Deletes 78 | |-------------------------------------------------------------------------- 79 | | 80 | | This option allows to control whether to keep soft deleted records in 81 | | the search indexes. Maintaining soft deleted records can be useful 82 | | if your application still needs to search for the records later. 83 | | 84 | */ 85 | 86 | 'soft_delete' => false, 87 | 88 | /* 89 | |-------------------------------------------------------------------------- 90 | | Identify User 91 | |-------------------------------------------------------------------------- 92 | | 93 | | This option allows you to control whether to notify the search engine 94 | | of the user performing the search. This is sometimes useful if the 95 | | engine supports any analytics based on this application's users. 96 | | 97 | | Supported engines: "algolia" 98 | | 99 | */ 100 | 101 | 'identify' => env('SCOUT_IDENTIFY', false), 102 | 103 | /* 104 | |-------------------------------------------------------------------------- 105 | | Algolia Configuration 106 | |-------------------------------------------------------------------------- 107 | | 108 | | Here you may configure your Algolia settings. Algolia is a cloud hosted 109 | | search engine which works great with Scout out of the box. Just plug 110 | | in your application ID and admin API key to get started searching. 111 | | 112 | */ 113 | 114 | 'algolia' => [ 115 | 'id' => env('ALGOLIA_APP_ID', ''), 116 | 'secret' => env('ALGOLIA_SECRET', ''), 117 | ], 118 | 119 | /* 120 | |-------------------------------------------------------------------------- 121 | | Meilisearch Configuration 122 | |-------------------------------------------------------------------------- 123 | | 124 | | Here you may configure your Meilisearch settings. Meilisearch is an open 125 | | source search engine with minimal configuration. Below, you can state 126 | | the host and key information for your own Meilisearch installation. 127 | | 128 | | See: https://www.meilisearch.com/docs/learn/configuration/instance_options#all-instance-options 129 | | 130 | */ 131 | 132 | 'meilisearch' => [ 133 | 'host' => env('MEILISEARCH_HOST', 'http://localhost:7700'), 134 | 'key' => env('MEILISEARCH_KEY'), 135 | 'index-settings' => [ 136 | // 'users' => [ 137 | // 'filterableAttributes'=> ['id', 'name', 'email'], 138 | // ], 139 | ], 140 | ], 141 | 142 | /* 143 | |-------------------------------------------------------------------------- 144 | | Sqlout Configuration 145 | |-------------------------------------------------------------------------- 146 | | 147 | */ 148 | 149 | 'sqlout' => [ 150 | 'table_name' => 'searchindex', 151 | 'default_mode' => 'in natural language mode', 152 | // 'filters' => [ 153 | // 'strip_tags', 154 | // 'html_entity_decode', 155 | // 'mb_strtolower', 156 | // 'strip_punctuation', 157 | // ], 158 | // 'token_delimiter' => '/[\s]+/', 159 | // 'minimum_length' => 2, 160 | // 'stopwords' => [ 161 | // 'est', 162 | // 'les', 163 | // ], 164 | // 'stemmer' => Wamania\Snowball\StemmerFactory::create('french'), // from package wamania/php-stemmer 165 | ], 166 | 167 | ]; 168 | -------------------------------------------------------------------------------- /docker-compose.databases.yml: -------------------------------------------------------------------------------- 1 | 2 | services: 3 | 4 | database: 5 | healthcheck: 6 | retries: 3 7 | timeout: 5s 8 | restart: always 9 | 10 | sqlite: # just a dummy container 11 | extends: database 12 | image: alpine 13 | command: sleep infinity # keep the container running 14 | healthcheck: 15 | test: ["CMD", "true"] # always return 0 16 | 17 | mysql: 18 | extends: database 19 | image: mysql:${DB_VERSION:-8.4} 20 | environment: 21 | MYSQL_DATABASE: test 22 | MYSQL_PASSWORD: test 23 | MYSQL_USER: test 24 | MYSQL_ALLOW_EMPTY_PASSWORD: true 25 | ports: 26 | - 3306:3306 27 | healthcheck: 28 | test: ["CMD", "mysqladmin", "ping", "-proot"] 29 | # volumes: 30 | # - mysql_data:/var/lib/mysql 31 | 32 | mariadb: 33 | extends: mysql 34 | image: mariadb:${DB_VERSION:-11.7} 35 | healthcheck: 36 | test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] 37 | 38 | pgsql: 39 | extends: database 40 | image: postgres:${DB_VERSION:-17.4} 41 | environment: 42 | POSTGRES_USER: test 43 | POSTGRES_DB: test 44 | POSTGRES_PASSWORD: test 45 | ports: 46 | - 5432:5432 47 | healthcheck: 48 | test: ["CMD-SHELL", "sh -c 'pg_isready -U test -d test'"] 49 | # volumes: 50 | # - pgsql_data:/var/lib/postgresql/data 51 | 52 | sqlsrv: 53 | extends: database 54 | image: mcr.microsoft.com/mssql/server:${DB_VERSION:-2022}-latest 55 | environment: 56 | ACCEPT_EULA: Y 57 | MSSQL_SA_PASSWORD: MyS3cureP4ssw0rd 58 | DB_USER: test 59 | DB_NAME: test 60 | ports: 61 | - 1433:1433 62 | healthcheck: 63 | test: ["CMD", "/opt/mssql-tools18/bin/sqlcmd", "-C", "-U", "sa", "-P", "MyS3cureP4ssw0rd", "-Q", "SELECT 1"] 64 | # volumes: 65 | # - sqlsrv_data:/var/opt/mssql 66 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | 2 | services: 3 | 4 | database: 5 | extends: 6 | file: ./docker-compose.databases.yml 7 | service: ${DB_ENGINE:-sqlite} 8 | 9 | php: 10 | build: 11 | context: . 12 | args: 13 | PHP_VERSION: ${PHP_VERSION:-8.4} 14 | UID: ${UID:-1000} 15 | GID: ${GID:-1000} 16 | tags: 17 | - ${COMPOSE_PROJECT_NAME}-php-${PHP_VERSION} 18 | image: ${COMPOSE_PROJECT_NAME}-php-${PHP_VERSION} 19 | pull_policy: never 20 | command: sleep infinity # keep the container running 21 | depends_on: 22 | database: 23 | condition: service_healthy 24 | volumes: 25 | - .:/app 26 | - ${COMPOSER_CACHE_DIR:-/.composer/cache}:/.composer/cache 27 | -------------------------------------------------------------------------------- /markdown-to-html.json: -------------------------------------------------------------------------------- 1 | { 2 | "document": { 3 | "title": "{title}", 4 | "link": [ 5 | { 6 | "rel": "icon", 7 | "href": "data:image/svg+xml,{favicon}", 8 | "type": "image/x-icon" 9 | } 10 | ] 11 | }, 12 | "repository": "{repo}", 13 | "reurls": { 14 | "README.md": "index.html" 15 | } 16 | } -------------------------------------------------------------------------------- /phpcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 10 | ./tests/ 11 | 12 | 13 | 14 | 15 | ./src 16 | 17 | 18 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /run: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | RED='\033[0;31m' 4 | PURPLE='\033[0;35m' 5 | GRAY='\033[1;30m' 6 | NC='\033[0m' 7 | 8 | myself="$(realpath $0)" 9 | preset_min='7.3 8.* lowest' 10 | preset_max='8.4 12.* stable' 11 | preset_sqlite_min="$preset_min sqlite" 12 | preset_sqlite_max="$preset_max sqlite" 13 | preset_sqlite="$preset_sqlite_max" 14 | preset_mysql_min="$preset_min mysql 5.7" 15 | preset_mysql_max="$preset_max mysql 8.4" 16 | preset_mysql="$preset_mysql_max" 17 | preset_mariadb_min="$preset_min mariadb 10.2" 18 | preset_mariadb_max="$preset_max mariadb 11.7" 19 | preset_mariadb="$preset_mariadb_max" 20 | preset_pgsql_min="$preset_min pgsql 9.6" 21 | preset_pgsql_max="$preset_max pgsql 17.4" 22 | preset_pgsql="$preset_pgsql_max" 23 | preset_sqlsrv_min="8.1 8.* lowest sqlsrv 2019" 24 | preset_sqlsrv_max="$preset_max sqlsrv 2022" 25 | preset_sqlsrv="$preset_sqlsrv_max" 26 | compose_project_name=${PWD##*\/} 27 | 28 | # Check if variable exists in .env file 29 | env_exists() { 30 | touch .env 31 | grep "$1=" .env >>/dev/null 32 | return $? 33 | } 34 | 35 | # Add variable to .env file unless it already exists 36 | env_add() { 37 | if ! env_exists $1; then 38 | echo "$1=$2" >> .env 39 | return $? 40 | fi 41 | return 1 42 | } 43 | 44 | # Add or replace variable in .env file 45 | env_replace() { 46 | env_add ${1} ${2} || sed -i -E "s/^${1}=.*$/${1}=${2}/g" .env 47 | return $? 48 | } 49 | 50 | if [[ $# == 0 || $# == 1 && ($1 == 'help') ]]; then 51 | echo 'Usage:' 52 | echo -e "${PURPLE}run ${NC}: run command with the specicied preset" 53 | echo -e "${PURPLE}run ${NC}: run command with the specicied requirements" 54 | echo -e "${PURPLE}run ${NC}: run command with the same requirements as previous run" 55 | echo 56 | echo 'Available commands:' 57 | echo -e "${PURPLE}setup${NC}: just setup the containers with the provided requirements" 58 | echo -e "${PURPLE}test${NC}: run PHPUnit tests" 59 | echo -e "${PURPLE}cs${NC}: run PHP_CodeSniffer (show errors)" 60 | echo -e "${PURPLE}cbf${NC}: run PHP Code Beautifier and Fixer (fix errors)" 61 | echo -e "${PURPLE}help${NC}: this screen" 62 | 63 | exit 0 64 | fi 65 | 66 | command=$1 67 | 68 | if [ $# -eq 1 ] && [ ! -f .env ]; then 69 | $myself $command $preset_max 70 | exit $? 71 | fi 72 | 73 | # Try to load preset 74 | if [[ $# = 2 && $2 =~ ^[a-zA-Z_]+$ ]]; then 75 | preset="preset_$2" 76 | if [ -z ${!preset+x} ]; then 77 | echo -e "${RED}Preset $2 does not exist${NC}" 78 | exit 1 79 | fi 80 | args=${!preset} 81 | $myself $command $args 82 | exit $? 83 | fi 84 | 85 | # Rebuild PHP image if needed 86 | if [ $# -gt 1 ]; then 87 | 88 | # Set env variables for docker compose 89 | env_add COMPOSE_PROJECT_NAME $compose_project_name 90 | env_add UID $(id -u) 91 | env_add GID $(id -g) 92 | env_add COMPOSER_CACHE_DIR $(composer config -g cache-dir 2>/dev/null || echo "$HOME/.composer/cache") 93 | 94 | php_version=$2 95 | laravel_version=${3:-'*'} 96 | dependency_version=${4:-'stable'} 97 | db_engine=${5:-'sqlite'} 98 | db_version=${6:-''} 99 | 100 | echo 101 | echo 'Using:' 102 | echo -e "- PHP ${PURPLE}${php_version}${NC}" 103 | echo -e "- Laravel ${PURPLE}${laravel_version}${NC}" 104 | echo -e "- ${PURPLE}${dependency_version}${NC} dependencies" 105 | echo -e "- Database ${PURPLE}${db_engine} ${db_version}${NC}" 106 | echo 107 | 108 | env_replace PHP_VERSION $php_version 109 | env_replace MYSQL_VERSION $mysql_version 110 | env_replace DB_ENGINE $db_engine 111 | env_replace DB_VERSION $db_version 112 | 113 | docker compose down 114 | docker volume rm ${compose_project_name}_mysql_data >/dev/null 2>&1 115 | fi 116 | 117 | # Start containers 118 | docker compose up --no-recreate -d 119 | 120 | # Reinstall packages if needed 121 | if [ $# -gt 1 ]; then 122 | docker compose exec php rm -f /app/composer.lock 123 | docker compose exec php composer update --with laravel/framework:$laravel_version --prefer-$dependency_version --prefer-dist --no-interaction 124 | fi 125 | 126 | # Run tests 127 | echo 128 | if [[ $1 == 'test' ]]; then 129 | docker compose exec php ./vendor/bin/phpunit 130 | elif [[ $1 == 'cs' ]]; then 131 | docker compose exec php ./vendor/bin/phpcs --standard=./phpcs.xml ./src 132 | elif [[ $1 == 'cbf' ]]; then 133 | docker compose exec php ./vendor/bin/phpcbf --standard=./phpcs.xml ./src ./tests 134 | elif [[ $1 != 'setup' ]]; then 135 | $myself help 136 | exit $? 137 | fi 138 | echo 139 | 140 | # Show remaining containers 141 | echo -e "${GRAY}Remaining containers:${NC}" 142 | echo -e "${GRAY}=====================${NC}" 143 | docker compose ps -a 144 | -------------------------------------------------------------------------------- /src/Builder.php: -------------------------------------------------------------------------------- 1 | wheres['__soft_deleted']); 42 | } 43 | } 44 | 45 | public function __call($method, $parameters) 46 | { 47 | if (static::hasMacro($method)) { 48 | return parent::__call($method, $parameters); 49 | } 50 | 51 | $this->scopes[] = [$method, $parameters]; 52 | return $this; 53 | } 54 | 55 | public function scope(Closure $callback) 56 | { 57 | $this->scopes[] = $callback; 58 | return $this; 59 | } 60 | 61 | /** 62 | * Add a constraint to the search query. 63 | * 64 | * @param string $field 65 | * @param mixed $value 66 | * @return $this 67 | */ 68 | public function where($field, $value) 69 | { 70 | $args = func_get_args(); 71 | $this->scopes[] = ['where', $args]; 72 | return $this; 73 | } 74 | 75 | /** 76 | * Include soft deleted records in the results. 77 | * 78 | * @return $this 79 | */ 80 | public function withTrashed() 81 | { 82 | $this->scopes[] = ['withTrashed', []]; 83 | return $this; 84 | } 85 | 86 | /** 87 | * Include only soft deleted records in the results. 88 | * 89 | * @return $this 90 | */ 91 | public function onlyTrashed() 92 | { 93 | $this->scopes[] = ['onlyTrashed', []]; 94 | return $this; 95 | } 96 | 97 | /** 98 | * Order the query by score. 99 | * 100 | * @param string $direction 101 | * @return $this 102 | */ 103 | public function orderByScore($direction = 'desc') 104 | { 105 | return $this->orderBy('_score', $direction); 106 | } 107 | 108 | /** 109 | * Restrict the search to the provided field(s). 110 | * 111 | * @param string|array|\Illuminate\Contracts\Support\Arrayable $fields 112 | * @return $this 113 | */ 114 | public function only($fields) 115 | { 116 | return parent::where('field', $fields); 117 | } 118 | 119 | /** 120 | * Switches to the provided mode. 121 | * 122 | * @param string $mode 123 | * @return $this 124 | */ 125 | public function mode($mode) 126 | { 127 | $this->mode = trim(strtolower($mode)); 128 | return $this; 129 | } 130 | 131 | /** 132 | * Switches to natural language mode. 133 | * 134 | * @param string $mode 135 | * @return $this 136 | */ 137 | public function inNaturalLanguageMode() 138 | { 139 | return $this->mode(static::NATURAL_LANGUAGE); 140 | } 141 | 142 | /** 143 | * Switches to natural language mode with query expansion. 144 | * 145 | * @param string $mode 146 | * @return $this 147 | */ 148 | public function withQueryExpansion() 149 | { 150 | return $this->mode(static::QUERY_EXPANSION); 151 | } 152 | 153 | /** 154 | * Switches to boolean mode. 155 | * 156 | * @param string $mode 157 | * @return $this 158 | */ 159 | public function inBooleanMode() 160 | { 161 | return $this->mode(static::BOOLEAN); 162 | } 163 | 164 | /** 165 | * Returns the total number of hits 166 | * 167 | * @return int 168 | */ 169 | public function count() 170 | { 171 | return $this->engine()->getTotalCount( 172 | $this->engine()->search($this) 173 | ); 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /src/Engine.php: -------------------------------------------------------------------------------- 1 | getModel() 19 | ->setConnection($model->getConnectionName()) 20 | ->setTable(config('scout.sqlout.table_name')); 21 | return $query->setModel($searchModel); 22 | } 23 | 24 | /** 25 | * Apply the filters to the indexed content or search terms, tokenize it 26 | * and stem the words. 27 | * 28 | * @param string $content 29 | * @return string 30 | */ 31 | protected function processString($content) 32 | { 33 | // Apply custom filters: 34 | foreach (config('scout.sqlout.filters', []) as $filter) { 35 | if (is_callable($filter)) { 36 | $content = call_user_func($filter, $content); 37 | } 38 | } 39 | 40 | // Tokenize: 41 | $words = preg_split(config('scout.sqlout.token_delimiter', '/[\s]+/'), $content); 42 | 43 | // Remove stopwords & short words: 44 | $minLength = config('scout.sqlout.minimum_length', 0); 45 | $stopwords = config('scout.sqlout.stopwords', []); 46 | $words = (new Collection($words))->reject(function ($word) use ($minLength, $stopwords) { 47 | return mb_strlen($word) < $minLength || in_array($word, $stopwords); 48 | })->all(); 49 | 50 | // Stem: 51 | $stemmer = config('scout.sqlout.stemmer'); 52 | if ($stemmer) { 53 | if (is_string($stemmer) && class_exists($stemmer) && method_exists($stemmer, 'stem')) { 54 | $stemmer = [new $stemmer(), 'stem']; 55 | } 56 | if (is_object($stemmer) && method_exists($stemmer, 'stem')) { 57 | foreach ($words as $k => $word) { 58 | $words[$k] = $stemmer->stem($word); 59 | } 60 | } elseif (is_callable($stemmer)) { 61 | foreach ($words as $k => $word) { 62 | $words[$k] = call_user_func($stemmer, $word); 63 | } 64 | } else { 65 | throw new Exception('Invalid stemmer!'); 66 | } 67 | } 68 | 69 | // Return result: 70 | return implode(' ', $words); 71 | } 72 | 73 | /** 74 | * Update the given model in the index. 75 | * 76 | * @param \Illuminate\Database\Eloquent\Collection $models 77 | * @return void 78 | */ 79 | public function update($models) 80 | { 81 | $models->each(function ($model) { 82 | $type = $model->getMorphClass(); 83 | $id = $model->getKey(); 84 | $this->newSearchQuery($model)->where('record_type', $type)->where('record_id', $id)->delete(); 85 | 86 | $data = $model->toSearchableArray(); 87 | foreach (array_filter($data) as $field => $content) { 88 | $this->newSearchQuery($model)->create([ 89 | 'record_type' => $type, 90 | 'record_id' => $id, 91 | 'field' => $field, 92 | 'weight' => $model->getSearchWeight($field), 93 | 'content' => $this->processString($content), 94 | ]); 95 | } 96 | }); 97 | } 98 | 99 | /** 100 | * Remove the given model from the index. 101 | * 102 | * @param \Illuminate\Database\Eloquent\Collection $models 103 | * @return void 104 | */ 105 | public function delete($models) 106 | { 107 | if (!$models->count()) { 108 | return; 109 | } 110 | $this->newSearchQuery($models->first()) 111 | ->where('record_type', $models->first()->getMorphClass()) 112 | ->whereIn('record_id', $models->modelKeys()) 113 | ->delete(); 114 | } 115 | 116 | /** 117 | * Perform the given search on the engine. 118 | * 119 | * @param \Laravel\Scout\Builder $builder 120 | * @return mixed 121 | */ 122 | public function search(Builder $builder) 123 | { 124 | return $this->performSearch($builder, array_filter([ 125 | 'hitsPerPage' => $builder->limit, 126 | ])); 127 | } 128 | 129 | /** 130 | * Perform the given search on the engine. 131 | * 132 | * @param \Laravel\Scout\Builder $builder 133 | * @param int $perPage 134 | * @param int $page 135 | * @return mixed 136 | */ 137 | public function paginate(Builder $builder, $perPage, $page) 138 | { 139 | return $this->performSearch($builder, [ 140 | 'hitsPerPage' => $perPage, 141 | 'page' => $page - 1, 142 | ]); 143 | } 144 | 145 | /** 146 | * Perform the given search on the engine. 147 | * 148 | * @param \Laravel\Scout\Builder $builder 149 | * @param array $options 150 | * @return mixed 151 | */ 152 | protected function performSearch(Builder $builder, array $options = []) 153 | { 154 | $mode = $builder->mode ?? config('scout.sqlout.default_mode'); 155 | $terms = $this->processString($builder->query); 156 | 157 | // Creating search query: 158 | $query = $this->newSearchQuery($builder->model) 159 | ->with('record') 160 | ->where('record_type', $builder->model->getMorphClass()) 161 | ->whereRaw("match(content) against (? $mode)", [$terms]) 162 | ->groupBy('record_type') 163 | ->groupBy('record_id') 164 | ->selectRaw("sum(weight * (match(content) against (? $mode))) as _score", [$terms]) 165 | ->addSelect(['record_type', 'record_id']); 166 | foreach ($builder->wheres as $field => $value) { 167 | if (is_array($value) || $value instanceof Arrayable) { 168 | $query->whereIn($field, $value); 169 | } else { 170 | $query->where($field, $value); 171 | } 172 | } 173 | 174 | // Order clauses: 175 | if (!$builder->orders) { 176 | $builder->orderByScore(); 177 | } 178 | if ($builder->orders) { 179 | foreach ($builder->orders as $i => $order) { 180 | if ($order['column'] == '_score') { 181 | $query->orderBy($order['column'], $order['direction']); 182 | continue; 183 | } 184 | $alias = 'sqlout_reserved_order_' . $i; 185 | $subQuery = $builder->model->newQuery() 186 | ->select([ 187 | $builder->model->getKeyName() . " as {$alias}_id", 188 | $order['column'] . " as {$alias}_order", 189 | ]); 190 | $query->joinSub($subQuery, $alias, function ($join) use ($alias) { 191 | $join->on('record_id', '=', $alias . '_id'); 192 | }); 193 | $query->orderBy($alias . '_order', $order['direction']); 194 | } 195 | } 196 | 197 | // Applying scopes to the model query: 198 | $query->whereHasMorph('record', get_class($builder->model), function ($query) use ($builder) { 199 | foreach ($builder->scopes as $scope) { 200 | if ($scope instanceof Closure) { 201 | $scope($query); 202 | } else { 203 | list($method, $parameters) = $scope; 204 | $query->$method(...$parameters); 205 | } 206 | } 207 | }); 208 | 209 | // Applying limit/offset: 210 | if ($options['hitsPerPage'] ?? null) { 211 | $query->limit($options['hitsPerPage']); 212 | if ($options['page'] ?? null) { 213 | $offset = $options['page'] * $options['hitsPerPage']; 214 | $query->offset($offset); 215 | } 216 | } 217 | 218 | // Performing a first query to determine the total number of hits: 219 | $countQuery = $query->getQuery() 220 | ->cloneWithout(['groups', 'orders', 'offset', 'limit']) 221 | ->cloneWithoutBindings(['order']); 222 | $results = ['nbHits' => $countQuery->count($countQuery->getConnection()->raw('distinct record_id'))]; 223 | 224 | // Preparing the actual query: 225 | $results['query'] = $query->with('record'); 226 | 227 | return $results; 228 | } 229 | 230 | /** 231 | * Pluck and return the primary keys of the given results. 232 | * 233 | * @param mixed $results 234 | * @return \Illuminate\Support\Collection 235 | */ 236 | public function mapIds($results) 237 | { 238 | return $results['query']->pluck('record_id')->values(); 239 | } 240 | 241 | /** 242 | * Extract the Model from the search hit. 243 | * 244 | * @param SearchIndex $hit 245 | * @return \Illuminate\Database\Eloquent\Model 246 | */ 247 | protected function getRecord($hit) 248 | { 249 | $hit->record->_score = $hit->_score; 250 | return $hit->record; 251 | } 252 | 253 | /** 254 | * Map the given results to instances of the given model. 255 | * 256 | * @param \Laravel\Scout\Builder $builder 257 | * @param mixed $results 258 | * @param \Illuminate\Database\Eloquent\Model $model 259 | * @return \Illuminate\Database\Eloquent\Collection 260 | */ 261 | public function map(Builder $builder, $results, $model) 262 | { 263 | $models = $results['query']->get()->map(function ($hit) { 264 | return $this->getRecord($hit); 265 | })->all(); 266 | return $model->newCollection($models); 267 | } 268 | 269 | /** 270 | * Map the given results to instances of the given model via a lazy collection. 271 | * 272 | * @param \Laravel\Scout\Builder $builder 273 | * @param mixed $results 274 | * @param \Illuminate\Database\Eloquent\Model $model 275 | * @return \Illuminate\Support\LazyCollection 276 | */ 277 | public function lazyMap(Builder $builder, $results, $model) 278 | { 279 | return $results['query']->lazy()->map(function ($hit) { 280 | return $this->getRecord($hit); 281 | }); 282 | } 283 | 284 | /** 285 | * Get the total count from a raw result returned by the engine. 286 | * 287 | * @param mixed $results 288 | * @return int 289 | */ 290 | public function getTotalCount($results) 291 | { 292 | return $results['nbHits']; 293 | } 294 | 295 | /** 296 | * Flush all of the model's records from the engine. 297 | * 298 | * @param \Illuminate\Database\Eloquent\Model $model 299 | * @return void 300 | */ 301 | public function flush($model) 302 | { 303 | $this->newSearchQuery($model)->where('record_type', $model->getMorphClass())->delete(); 304 | } 305 | 306 | /** 307 | * Create a search index. 308 | * 309 | * @param string $name 310 | * @param array $options 311 | * @return mixed 312 | */ 313 | public function createIndex($name, array $options = []) 314 | { 315 | // 316 | } 317 | 318 | /** 319 | * Delete a search index. 320 | * 321 | * @param string $name 322 | * @return mixed 323 | */ 324 | public function deleteIndex($name) 325 | { 326 | // 327 | } 328 | } 329 | -------------------------------------------------------------------------------- /src/Migrations/MigrateMakeCommand.php: -------------------------------------------------------------------------------- 1 | input->getArgument('connection') ?? config('database.default'); 27 | 28 | $this->writeSqloutMigration($connection); 29 | $this->composer->dumpAutoloads(); 30 | 31 | if ($this->input->hasOption('migrate') && $this->option('migrate')) { 32 | $this->call('migrate'); 33 | } 34 | } 35 | 36 | protected function writeSqloutMigration($connection) 37 | { 38 | // Get the name for the migration file: 39 | $name = $this->input->getOption('name') ?: 'create_sqlout_index_for_' . $connection; 40 | $name = Str::snake(trim($name)); 41 | $className = Str::studly($name); 42 | $tableName = config('scout.sqlout.table_name'); 43 | 44 | // Generate the content of the migration file: 45 | $contents = $this->getMigrationContents($className, $connection, $tableName); 46 | 47 | // Generate the file: 48 | $file = $this->creator->create( 49 | $name, 50 | $this->getMigrationPath(), 51 | $tableName, 52 | true 53 | ); 54 | file_put_contents($file, $contents); 55 | 56 | // Output information: 57 | $file = pathinfo($file, PATHINFO_FILENAME); 58 | $this->line("Created Migration: {$file}"); 59 | } 60 | 61 | protected function getMigrationContents($className, $connection, $tableName) 62 | { 63 | $contents = file_get_contents(__DIR__ . '/stubs/migration.stub'); 64 | $contents = str_replace([ 65 | 'class CreateSqloutIndex', 66 | '::connection()', 67 | "config('scout.sqlout.table_name')", 68 | ], [ 69 | 'class ' . $className, 70 | "::connection('$connection')", 71 | "'$tableName'" 72 | ], $contents); 73 | $contents = preg_replace('/\;[\s]*\/\/.*\n/U', ";\n", $contents); 74 | return $contents; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/Migrations/MigrationCreator.php: -------------------------------------------------------------------------------- 1 | create(config('scout.sqlout.table_name'), function (Blueprint $table) { 18 | $table->bigIncrements('id'); 19 | $table->string('record_type', 191)->index(); 20 | $table->unsignedBigInteger('record_id')->index(); 21 | $table->string('field', 191)->index(); 22 | $table->unsignedSmallInteger('weight')->default(1); 23 | $table->text('content'); 24 | $table->timestamps(); 25 | }); 26 | $tableName = DB::connection()->getTablePrefix() . config('scout.sqlout.table_name'); 27 | DB::connection()->statement("ALTER TABLE $tableName ADD FULLTEXT searchindex_content (content)"); 28 | } 29 | 30 | /** 31 | * Reverse the migrations. 32 | * 33 | * @return void 34 | */ 35 | public function down() 36 | { 37 | Schema::connection()->dropIfExists(config('scout.sqlout.table_name')); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/SearchIndex.php: -------------------------------------------------------------------------------- 1 | morphTo(); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/Searchable.php: -------------------------------------------------------------------------------- 1 | new static(), 22 | 'query' => $query, 23 | 'callback' => $callback, 24 | 'softDelete' => static::usesSoftDelete() && config('scout.soft_delete', false), 25 | ]); 26 | } 27 | 28 | /** 29 | * Get the weight of the specified field. 30 | * 31 | * @param string $field 32 | * @return int 33 | */ 34 | public function getSearchWeight($field) 35 | { 36 | return $this->weights[$field] ?? 1; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/SqloutServiceProvider.php: -------------------------------------------------------------------------------- 1 | extend('sqlout', function () { 19 | return new Engine(); 20 | }); 21 | 22 | if ($this->app->runningInConsole()) { 23 | $this->commands([ 24 | MigrateMakeCommand::class, 25 | ]); 26 | } 27 | $this->publishes([ 28 | __DIR__ . '/../config/scout.php' => $this->app['path.config'] . DIRECTORY_SEPARATOR . 'scout.php', 29 | ]); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/helpers.php: -------------------------------------------------------------------------------- 1 | $this->faker->name(), 18 | 'text' => $this->faker->text(50), 19 | ]; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /tests/Factories/PostFactory.php: -------------------------------------------------------------------------------- 1 | $this->faker->sentence(3), 18 | 'body' => $this->faker->text(50), 19 | ]; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /tests/Models/Comment.php: -------------------------------------------------------------------------------- 1 | belongsTo(Post::class); 18 | } 19 | 20 | public function toSearchableArray() 21 | { 22 | return [ 23 | 'author' => $this->author, 24 | 'text' => $this->text, 25 | ]; 26 | } 27 | 28 | public function scopeAuthor($query, $author) 29 | { 30 | $query->where('author', $author); 31 | } 32 | 33 | protected static function newFactory() 34 | { 35 | return CommentFactory::new(); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /tests/Models/Post.php: -------------------------------------------------------------------------------- 1 | 4, 19 | ]; 20 | 21 | public function toSearchableArray() 22 | { 23 | return [ 24 | 'title' => $this->title, 25 | 'body' => $this->body, 26 | ]; 27 | } 28 | 29 | protected static function newFactory() 30 | { 31 | return PostFactory::new(); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /tests/SearchTest.php: -------------------------------------------------------------------------------- 1 | count(5)->create(); 20 | Comment::factory()->count(5)->create(); 21 | } 22 | 23 | protected function newSearchQuery() 24 | { 25 | $query = SearchIndex::query(); 26 | $model = $query->getModel(); 27 | $model->setTable(config('scout.sqlout.table_name')); 28 | $query->setModel($model); 29 | return $query; 30 | } 31 | 32 | public function test_index() 33 | { 34 | $indexed = $this->newSearchQuery()->groupBy('record_type')->selectRaw('record_type, count(*) as count')->pluck('count', 'record_type')->all(); 35 | $this->assertArrayHasKey(Comment::class, $indexed); 36 | $this->assertArrayHasKey(Post::class, $indexed); 37 | $this->assertEquals(5 * 2, $indexed[Comment::class]); 38 | $this->assertEquals(5 * 2, $indexed[Post::class]); 39 | } 40 | 41 | public function test_simple_search() 42 | { 43 | $post = Post::first(); 44 | $post->title = 'gloubiboulga'; 45 | $post->save(); 46 | 47 | $search = Post::search('gloubiboulga'); 48 | $this->assertEquals(1, $search->count()); 49 | $results = $search->get(); 50 | $this->assertEquals($post->id, $results->first()->id); 51 | } 52 | 53 | public function test_search_by_model() 54 | { 55 | $post = Post::first(); 56 | $post->title = 'tralalatsointsoin'; 57 | $post->save(); 58 | 59 | $comment = Comment::first(); 60 | $comment->text = 'tralalatsointsoin'; 61 | $comment->save(); 62 | 63 | $search = Comment::search('tralalatsointsoin'); 64 | $this->assertEquals(1, $search->count()); 65 | } 66 | 67 | public function test_search_with_weight() 68 | { 69 | $posts = Post::all(); 70 | $posts[3]->title = 'schtroumpf'; 71 | $posts[3]->save(); 72 | $posts[2]->body = 'schtroumpf'; 73 | $posts[2]->save(); 74 | 75 | $results = Post::search('schtroumpf')->orderByScore()->get(); 76 | $this->assertCount(2, $results); 77 | $this->assertEquals($posts[3]->id, $results[0]->id); 78 | $this->assertEquals($posts[2]->id, $results[1]->id); 79 | } 80 | 81 | public function test_restricted_search() 82 | { 83 | $posts = Post::all(); 84 | $posts[3]->title = 'schtroumpf'; 85 | $posts[3]->save(); 86 | $posts[2]->body = 'schtroumpf'; 87 | $posts[2]->save(); 88 | 89 | $results = Post::search('schtroumpf')->only(['body'])->get(); 90 | $this->assertCount(1, $results); 91 | $this->assertEquals($posts[2]->id, $results[0]->id); 92 | } 93 | 94 | public function test_forwarded_scope() 95 | { 96 | Comment::query()->update(['text' => 'schtroumpf']); 97 | Comment::all()->searchable(); 98 | $comment = Comment::first(); 99 | $comment->author = 'gargamel'; 100 | $comment->save(); 101 | 102 | $this->assertEquals(5, Comment::search('schtroumpf')->count()); 103 | 104 | $results = Comment::search('schtroumpf')->author('gargamel')->get(); 105 | $this->assertCount(1, $results); 106 | $this->assertEquals($comment->id, $results[0]->id); 107 | 108 | $results = Comment::search('schtroumpf')->scope(function ($builder) { 109 | $builder->author('gargamel'); 110 | })->get(); 111 | $this->assertCount(1, $results); 112 | $this->assertEquals($comment->id, $results[0]->id); 113 | } 114 | 115 | public function test_ordering() 116 | { 117 | $posts = Post::all(); 118 | $posts[3]->title = 'schtroumpf'; 119 | $posts[3]->save(); 120 | $posts[2]->title = 'gargamel'; 121 | $posts[2]->body = 'schtroumpf'; 122 | $posts[2]->save(); 123 | 124 | // Order by score by default: 125 | $results = Post::search('schtroumpf')->get(); 126 | $this->assertEquals($posts[3]->id, $results[0]->id); 127 | $this->assertEquals($posts[2]->id, $results[1]->id); 128 | 129 | $results = Post::search('schtroumpf')->orderBy('title')->get(); 130 | $this->assertEquals($posts[2]->id, $results[0]->id); 131 | $this->assertEquals($posts[3]->id, $results[1]->id); 132 | } 133 | 134 | public function test_search_modes() 135 | { 136 | app('config')->set('scout.sqlout.default_mode', Builder::BOOLEAN); 137 | 138 | Post::search('coucou')->get(); 139 | $log = DB::getQueryLog(); 140 | $query = end($log)['query']; 141 | $this->assertStringContainsString(Builder::BOOLEAN, $query); 142 | 143 | Post::search('kiki')->inNaturalLanguageMode()->get(); 144 | $log = DB::getQueryLog(); 145 | $query = end($log)['query']; 146 | $this->assertStringContainsString(Builder::NATURAL_LANGUAGE, $query); 147 | } 148 | 149 | public function test_filters() 150 | { 151 | app('config')->set('scout.sqlout.filters', [ 152 | 'strip_tags', 153 | 'html_entity_decode', 154 | ]); 155 | 156 | $post = Post::first(); 157 | $post->body = '

salut ça boume ?

'; 158 | $post->save(); 159 | 160 | $indexed = $this->newSearchQuery()->where('record_type', Post::class)->where('record_id', $post->id)->where('field', 'body')->value('content'); 161 | $this->assertEquals('salut ça boume ?', $indexed); 162 | } 163 | 164 | public function test_stopwords() 165 | { 166 | app('config')->set('scout.sqlout.stopwords', [ 167 | 'fuck', 168 | ]); 169 | 170 | $post = Post::first(); 171 | $post->body = 'shut the fuck up donny'; 172 | $post->save(); 173 | 174 | $indexed = $this->newSearchQuery()->where('record_type', Post::class)->where('record_id', $post->id)->where('field', 'body')->value('content'); 175 | $this->assertEquals('shut the up donny', $indexed); 176 | } 177 | 178 | public function test_minimum_length() 179 | { 180 | app('config')->set('scout.sqlout.minimum_length', 4); 181 | 182 | $post = Post::first(); 183 | $post->body = 'shut the fuck up donny'; 184 | $post->save(); 185 | 186 | $indexed = $this->newSearchQuery()->where('record_type', Post::class)->where('record_id', $post->id)->where('field', 'body')->value('content'); 187 | $this->assertEquals('shut fuck donny', $indexed); 188 | } 189 | 190 | public function test_stemming() 191 | { 192 | $posts = Post::limit(2)->get(); 193 | 194 | $posts[0]->body = 'les chaussettes de l\'archiduchesse sont-elles sèches archi-sèches'; 195 | $posts[0]->save(); 196 | $posts[1]->body = 'la cigale ayant chanté tout l\'été se trouva fort dépourvue quand la bise fut venue'; 197 | $posts[1]->save(); 198 | 199 | Post::whereNotIn('id', [$posts[0]->id, $posts[1]->id])->get()->unsearchable(); 200 | 201 | $this->assertEquals(0, Post::search('chanter')->count()); 202 | 203 | app('config')->set('scout.sqlout.stemmer', StemmerFactory::create('french')); 204 | $posts->searchable(); 205 | 206 | $this->assertEquals(1, Post::search('chanter')->count()); 207 | $this->assertEquals(1, Post::search('chantées')->count()); 208 | $this->assertEquals(1, Post::search('sèche')->count()); 209 | $this->assertEquals(1, Post::search('chaussette')->count()); 210 | } 211 | 212 | public function test_closure_as_stemmer() 213 | { 214 | $closure = function ($word) { 215 | return 'tralalatsointsoin'; 216 | }; 217 | app('config')->set('scout.sqlout.stemmer', $closure); 218 | Post::first()->searchable(); 219 | $this->assertEquals(1, Post::search('tralalatsointsoin')->count()); 220 | } 221 | 222 | public function test_soft_delete() 223 | { 224 | app('config')->set('scout.soft_delete', true); 225 | Post::query()->update(['body' => 'does marsellus wallace look like a bitch']); 226 | Post::all()->searchable(); 227 | Post::first()->delete(); 228 | $this->assertEquals(4, Post::search('bitch')->count()); 229 | $this->assertEquals(5, Post::search('bitch')->withTrashed()->count()); 230 | } 231 | 232 | public function test_morph_map() 233 | { 234 | Relation::morphMap([ 235 | Post::class, 236 | ]); 237 | Post::query()->update(['title' => 'testing morphs']); 238 | Post::all()->searchable(); 239 | $this->assertEquals(5, Post::search('morphs')->count()); 240 | } 241 | 242 | public function test_lazy() 243 | { 244 | Post::skip(1)->take(3)->get()->each(function ($post) { 245 | $post->title = 'kikikuku'; 246 | $post->save(); 247 | }); 248 | 249 | $search = Post::search('kikikuku'); 250 | $results = $search->get()->all(); 251 | $lazyResults = $search->cursor()->all(); 252 | $this->assertCount(3, $lazyResults); 253 | $this->assertEquals($results, $lazyResults); 254 | } 255 | } 256 | -------------------------------------------------------------------------------- /tests/TestCase.php: -------------------------------------------------------------------------------- 1 | loadEnv(['.env.test', '.env']); 15 | $this->setupDatabase($app, env('DB_ENGINE', 'sqlite')); 16 | $app['config']->set('scout', require __DIR__ . '/../config/scout.php'); 17 | } 18 | 19 | protected function loadEnv($file) 20 | { 21 | if (is_array($file)) { 22 | foreach ($file as $f) { 23 | $this->loadEnv($f); 24 | } 25 | return; 26 | } 27 | if (file_exists(dirname(__DIR__) . DIRECTORY_SEPARATOR . $file)) { 28 | $dotenv = Dotenv::createImmutable(dirname(__DIR__), $file); 29 | $dotenv->load(); 30 | } 31 | } 32 | 33 | protected function setupDatabase($app, $engine = 'mysql') 34 | { 35 | $method = 'setup' . ucfirst($engine); 36 | method_exists($this, $method) ? $this->$method($app) : $this->setupOtherSgbd($app, $engine); 37 | $app['config']->set('database.default', $engine); 38 | } 39 | 40 | protected function setupSqlite($app) 41 | { 42 | $database = env('SQLITE_DATABASE', database_path('database.sqlite')); 43 | if (file_exists($database)) { 44 | unlink($database); 45 | } 46 | touch($database); 47 | 48 | $app['config']->set('database.connections.sqlite', [ 49 | 'driver' => 'sqlite', 50 | 'database' => $database, 51 | 'prefix' => '', 52 | 'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true), 53 | 'busy_timeout' => null, 54 | 'journal_mode' => null, 55 | 'synchronous' => null, 56 | ]); 57 | } 58 | 59 | protected function setupMariadb($app) 60 | { 61 | $engine = class_exists(\Illuminate\Database\MariaDbConnection::class) 62 | ? 'mariadb' 63 | : 'mysql'; 64 | $this->setupOtherSgbd($app, $engine); 65 | $app['config']->set('database.connections.mariadb', $app['config']["database.connections.$engine"]); 66 | } 67 | 68 | protected function setupSqlsrv($app) 69 | { 70 | $this->setupOtherSgbd($app, 'sqlsrv'); 71 | $app['config']->set('database.connections.sqlsrv.trust_server_certificate', true); 72 | } 73 | 74 | protected function setupOtherSgbd($app, $engine) 75 | { 76 | $envPrefix = strtoupper($engine); 77 | $app['config']->set("database.connections.$engine", [ 78 | 'driver' => $engine, 79 | 'host' => env('DB_HOST'), 80 | 'port' => env("{$envPrefix}_PORT"), 81 | 'database' => env("{$envPrefix}_DATABASE", env('DB_DATABASE')), 82 | 'username' => env("{$envPrefix}_USERNAME", env('DB_USERNAME')), 83 | 'password' => env("{$envPrefix}_PASSWORD", env('DB_PASSWORD')), 84 | 'prefix' => '', 85 | ]); 86 | } 87 | 88 | protected function getPackageProviders($app) 89 | { 90 | return [ 91 | ScoutServiceProvider::class, 92 | SqloutServiceProvider::class, 93 | ]; 94 | } 95 | 96 | protected function setUp(): void 97 | { 98 | parent::setUp(); 99 | $this->loadMigrationsFrom(__DIR__ . '/database/migrations'); 100 | \DB::enableQueryLog(); 101 | } 102 | 103 | protected function dumpQueryLog() 104 | { 105 | dump(\DB::getQueryLog()); 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /tests/database/.gitignore: -------------------------------------------------------------------------------- 1 | database.sqlite -------------------------------------------------------------------------------- /tests/database/migrations/0000_00_00_000005_create_posts_table.php: -------------------------------------------------------------------------------- 1 | increments('id'); 18 | $table->string('title', 100); 19 | $table->text('body'); 20 | $table->timestamps(); 21 | $table->softDeletes(); 22 | }); 23 | } 24 | 25 | /** 26 | * Reverse the migrations. 27 | * 28 | * @return void 29 | */ 30 | public function down() 31 | { 32 | Schema::dropIfExists('posts'); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /tests/database/migrations/0000_00_00_000006_create_comments_table.php: -------------------------------------------------------------------------------- 1 | increments('id'); 18 | $table->unsignedInteger('post_id')->nullable(); 19 | $table->string('author', 100); 20 | $table->text('text'); 21 | $table->timestamps(); 22 | }); 23 | } 24 | 25 | /** 26 | * Reverse the migrations. 27 | * 28 | * @return void 29 | */ 30 | public function down() 31 | { 32 | Schema::dropIfExists('comments'); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /tests/database/migrations/2019_08_11_164134_create_sqlout_index.php: -------------------------------------------------------------------------------- 1 | bigIncrements('id'); 19 | $table->string('record_type', 191)->index(); 20 | $table->unsignedBigInteger('record_id')->index(); 21 | $table->string('field', 191)->index(); 22 | $table->unsignedSmallInteger('weight')->default(1); 23 | $table->text('content'); 24 | $table->timestamps(); 25 | }); 26 | $tableName = DB::getTablePrefix() . config('scout.sqlout.table_name'); 27 | DB::statement("ALTER TABLE $tableName ADD FULLTEXT searchindex_content (content)"); 28 | } 29 | 30 | /** 31 | * Reverse the migrations. 32 | * 33 | * @return void 34 | */ 35 | public function down() 36 | { 37 | Schema::dropIfExists(config('scout.sqlout.table_name')); 38 | } 39 | } 40 | --------------------------------------------------------------------------------