├── .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 | [](https://packagist.org/packages/baril/sqlout)
4 | [](https://packagist.org/packages/baril/sqlout)
5 | [](https://packagist.org/packages/baril/sqlout/stats)
6 | [](https://github.com/michaelbaril/sqlout/actions/workflows/run-tests.yml?query=branch%3Amaster)
7 | [](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,",
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 |
--------------------------------------------------------------------------------