├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── composer.json ├── config └── elasticlens.php ├── database └── migrations │ ├── create_indexable_build_index.php │ └── create_indexable_migration_logs_index.php ├── resources ├── stubs │ └── IndexedBase.php.stub └── views │ ├── .gitkeep │ └── cli │ ├── bulk.blade.php │ └── components │ ├── code-trait.blade.php │ ├── data-row-value.blade.php │ ├── hr.blade.php │ ├── loader-spin.blade.php │ ├── title-row.blade.php │ └── title.blade.php └── src ├── Builder ├── IndexBuilder.php └── IndexField.php ├── Commands ├── LensBuildCommand.php ├── LensCommands.php ├── LensHealthCommand.php ├── LensMakeCommand.php ├── LensMigrateCommand.php ├── LensStatusCommand.php └── Scripts │ ├── ConfigCheck.php │ ├── HealthCheck.php │ ├── IndexCheck.php │ └── QualifyModel.php ├── ElasticLensServiceProvider.php ├── Enums ├── IndexableBuildState.php └── IndexableMigrationLogState.php ├── HasWatcher.php ├── Index ├── BuildResult.php ├── BulkIndexer.php ├── LensBuilder.php ├── LensIndex.php ├── LensMigration.php ├── LensState.php └── MigrationValidator.php ├── IndexModel.php ├── Indexable.php ├── Jobs ├── BulkBuildStateUpdateJob.php ├── IndexBuildJob.php └── IndexDeletedJob.php ├── Lens.php ├── Models ├── IndexableBuild.php └── IndexableMigrationLog.php ├── Observers ├── BaseModelObserver.php └── ObserverRegistry.php ├── Traits ├── IndexBaseModel.php ├── IndexFieldMap.php ├── IndexMigrationMap.php └── Timer.php ├── Utils └── IndexHelper.php └── Watchers └── EmbeddedModelTrigger.php /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to `elasticlens` will be documented in this file. 4 | 5 | ## v3.0.2 - 2025-06-04 6 | 7 | ### What's Changed 8 | 9 | * Bug fix: Stub template update 10 | * Bump dependabot/fetch-metadata from 2.3.0 to 2.4.0 by @dependabot in https://github.com/pdphilip/elasticlens/pull/3 11 | 12 | **Full Changelog**: https://github.com/pdphilip/elasticlens/compare/v3.0.1...v3.0.2 13 | 14 | ## v3.0.1 - 2025-03-28 15 | 16 | This release is compatible with Laravel 10, 11 & 12 17 | 18 | ### What's changed 19 | 20 | #### New features 21 | 22 | - Skippable models via optional `excludeIndex(): bool` method in your base model 23 | - Delete an index from your model instance: `$user->removeIndex()` 24 | - New Index Model method: `IndexedUser::whereIndexBuilds()->get()` - returns index build logs for model 25 | - New Index Model method: `IndexedUser::whereFailedIndexBuilds()->get()` - returns failed index build logs for model 26 | - New Index Model method: `IndexedUser::whereMigrations()->get()` - returns migration logs for model 27 | - New Index Model method: `IndexedUser::whereMigrationErrors()->get()` - returns failed migrations for model 28 | - New index Model method: `IndexedUser::lensHealth()` - returns an array of the index health 29 | 30 | #### Fixes 31 | 32 | - v5 compatibility fixes with bulk insert and saving without refresh 33 | - Indexable trait `search()` runs `searchPhrasePrefix()` under the hood 34 | - `paginateBase()` honors current path 35 | 36 | **Full Changelog**: https://github.com/pdphilip/elasticlens/compare/v3.0.0...v3.0.1 37 | 38 | ## v3.0.0 - 2025-03-28 39 | 40 | This is an updated dependency release compatible with: 41 | 42 | - Laravel 10/11/12 43 | - `laravel-elasticsearch` package v5 44 | 45 | ### What's Changed 46 | 47 | * Bump dependabot/fetch-metadata from 2.2.0 to 2.3.0 by @dependabot in https://github.com/pdphilip/elasticlens/pull/1 48 | * Bump aglipanci/laravel-pint-action from 2.4 to 2.5 by @dependabot in https://github.com/pdphilip/elasticlens/pull/2 49 | 50 | ### New Contributors 51 | 52 | * @dependabot made their first contribution in https://github.com/pdphilip/elasticlens/pull/1 53 | 54 | **Full Changelog**: https://github.com/pdphilip/elasticlens/compare/v2.0.1...v3.0.0 55 | 56 | ## v2.0.1 - 2024-11-04 57 | 58 | Bug fix: `lens:make` command fixed to properly accommodate Domain spaced setups 59 | 60 | **Full Changelog**: https://github.com/pdphilip/elasticlens/compare/v2.0.0...v2.0.1 61 | 62 | ## v2.0.0 - 2024-10-21 63 | 64 | **Version 2 introduces breaking changes** to support multiple model namespace mappings, providing flexibility for domain-driven architecture. This update allows the use of multiple model sources. 65 | 66 | The elasticlens.php config file now requires the following structure: 67 | 68 | ```php 69 | 'namespaces' => [ 70 | 'App\Models' => 'App\Models\Indexes', 71 | ], 72 | 73 | 'index_paths' => [ 74 | 'app/Models/Indexes/' => 'App\Models\Indexes', 75 | ], 76 | 77 | 78 | 79 | 80 | 81 | 82 | ``` 83 | • The **namespaces** key maps models to their respective index namespaces. 84 | • The **index_paths** key maps file paths to the corresponding index namespaces. 85 | 86 | **Full Changelog**: https://github.com/pdphilip/elasticlens/compare/v1.3.1...v2.0.0 87 | 88 | ## v1.3.1 - 2024-10-03 89 | 90 | - Bug fix: Bulk insert was not writing to the `IndexableBuild` model correctly 91 | - Better IDE support for IndexModel macros, ie: `getBase()`, `asBase()` & `paginateBase()` 92 | 93 | **Full Changelog**: https://github.com/pdphilip/elasticlens/compare/v1.3.0...v1.3.1 94 | 95 | ## v1.3.0 - 2024-10-02 96 | 97 | ### Changes 98 | 99 | - Renamed `asModel()` to `asBase()` 100 | - Renamed `paginateModels()` to `paginateBase()` 101 | - Added convenience method `getBase()` that can replace `....->get()->asBase()` 102 | 103 | Dependency update to use [laravel-elasticsearch ^v4.4](https://github.com/pdphilip/laravel-elasticsearch/releases/tag/v4.4.0) 104 | 105 | **Full Changelog**: https://github.com/pdphilip/elasticlens/compare/v1.2.2...v1.3.0 106 | 107 | ## v1.2.0 - 2024-09-16 108 | 109 | Dependency update to use [laravel-elasticsearch v4.2](https://github.com/pdphilip/laravel-elasticsearch/releases/tag/v4.2.0) 110 | 111 | **Full Changelog**: https://github.com/pdphilip/elasticlens/compare/v1.1.0...v1.2.0 112 | 113 | ## v1.1.0 - 2024-09-09 114 | 115 | ### New Feature 116 | 117 | Bulk index (re)builder with: 118 | 119 | ```bash 120 | php artisan lens:build {model} 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | ``` 131 |
132 | ElasticLens Build 136 |
137 | ### Changes 138 | The previous `lens:build` command is now `lens:migrate`, which better describes the feature. 139 | ```bash 140 | php artisan lens:migrate {model} 141 | ``` 142 |
143 | ElasticLens Migrate 147 |
148 | See changelog for other minor updates: 149 | **Full Changelog**: https://github.com/pdphilip/elasticlens/compare/v1.1.0...v1.1.0 150 | ## v1.0.0 - 2024-09-02 151 | ### ElasticLens v1.0.0 152 | ElasticLens is proud to announce its initial release. This powerful and flexible Laravel package is designed to allow developers to search their Laravel models with the convenience of Eloquent and the power of Elasticsearch. 153 | #### Features 154 | - **Eloquent Integration**: Easily index and search your Laravel models with Elasticsearch. 155 | - **Automatic Indexing**: Models are automatically indexed when created, updated, or deleted. 156 | - **Custom Mappings**: Define custom Elasticsearch mappings for your models. 157 | - **Flexible Searching**: Perform simple searches or complex queries using Elasticsearch's full-text search capabilities. 158 | - **Query Builder**: Intuitive query builder for constructing complex Elasticsearch queries. 159 | - **Aggregations**: Support for Elasticsearch aggregations to perform complex data analysis. 160 | - **Pagination**: Built-in support for paginating search results. 161 | - **Console Commands**: Artisan commands for managing indices and performing bulk operations. 162 | - **Model Observers**: Customizable model observation for index builds. 163 | #### Installation 164 | 165 | You can install ElasticLens via Composer: 166 | 167 | ```bash 168 | composer require pdphilip/elasticlens 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | ``` 180 | Then run install: 181 | 182 | ```bash 183 | php artisan lens:install 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | ``` 195 | #### Documentation 196 | 197 | For detailed documentation and advanced usage, please refer to the [GitHub repository](https://github.com/pdphilip/elasticlens). 198 | 199 | #### Feedback and Contributions 200 | 201 | Feedback, bug reports, and contributions are highly appreciated. Users and developers are encouraged to open issues or submit pull requests on the GitHub repository. The ElasticLens community looks forward to collaborating and improving the package together. 202 | 203 | ### Happy searching with ElasticLens! 204 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) PDPhilip 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | ElasticLens for Laravel 3 |

4 | 5 | [![Latest Version on Packagist](https://img.shields.io/packagist/v/pdphilip/elasticlens.svg?style=flat-square)](https://packagist.org/packages/pdphilip/elasticlens) 6 | [![GitHub Tests Action Status](https://img.shields.io/github/actions/workflow/status/pdphilip/elasticlens/run-tests.yml?branch=main&label=tests&style=flat-square)](https://github.com/pdphilip/elasticlens/actions?query=workflow%3Arun-tests+branch%3Amain) 7 | [![GitHub Code Style Action Status](https://img.shields.io/github/actions/workflow/status/pdphilip/elasticlens/fix-php-code-style-issues.yml?branch=main&label=code%20style&style=flat-square)](https://github.com/pdphilip/elasticlens/actions?query=workflow%3A"Fix+PHP+code+style+issues"+branch%3Amain) 8 | [![Total Downloads](http://img.shields.io/packagist/dt/pdphilip/elasticlens.svg)](https://packagist.org/packages/pdphilip/elasticlens) 9 | 10 |

11 |

12 | Search your Laravel models with the convenience of Eloquent and the power of Elasticsearch 13 |

14 |

15 | ElasticLens for Laravel uses Elasticsearch to create and sync a searchable index of your Laravel models. 16 |

17 |
18 |
19 | ElasticLens Migrate 23 |
24 | 25 | ```php 26 | User::viaIndex()->searchPhrase('loves dogs')->where('status','active')->get(); 27 | ``` 28 | 29 | 30 | ## Wait, isn't this what Laravel Scout does? 31 | 32 | Yes, but mostly no. 33 | 34 | **ElasticLens is built from the ground up around Elasticsearch**. 35 | 36 | It integrates directly with the [Laravel-Elasticsearch](https://github.com/pdphilip/laravel-elasticsearch) package (Elasticsearch using Eloquent), creating a dedicated `Index-Model` that is fully accessible and automatically synced with 37 | your `Base-Model`. 38 | 39 |
40 | How? 41 | 42 | 43 | > The `Index-Model` acts as a separate Elasticsearch model managed by ElasticLens, yet you retain full control over it, just like any other Laravel model. In addition to working directly with the `Index-Model`, ElasticLens offers tools for 44 | > mapping fields (with embedding relationships) during the build process, and managing index migrations. 45 | 46 | > For Example, a base `User` Model will sync with an Elasticsearch `IndexedUser` Model that provides all the features from [Laravel-Elasticsearch](https://github.com/pdphilip/laravel-elasticsearch) to search your `Base-Model`. 47 | 48 |
49 | 50 | 51 | 52 | # Requirements 53 | 54 | - Laravel 10.x/11.x/12.x 55 | - Elasticsearch 8.x 56 | 57 | 58 | # Installation 59 | 60 |
61 | NB: Before you start, set the Laravel-Elasticsearch DB Config (click to expand) 62 | 63 | > See [Laravel-Elasticsearch](https://github.com/pdphilip/laravel-elasticsearch) for more details 64 | > 65 | > Update `.env` 66 | 67 | ```dotenv 68 | ES_AUTH_TYPE=http 69 | ES_HOSTS="http://localhost:9200" 70 | ES_USERNAME= 71 | ES_PASSWORD= 72 | ES_CLOUD_ID= 73 | ES_API_ID= 74 | ES_API_KEY= 75 | ES_SSL_CA= 76 | ES_INDEX_PREFIX=my_app_ 77 | # prefix will be added to all indexes created by the package with an underscore 78 | # ex: my_app_user_logs for UserLog model 79 | ES_SSL_CERT= 80 | ES_SSL_CERT_PASSWORD= 81 | ES_SSL_KEY= 82 | ES_SSL_KEY_PASSWORD= 83 | # Options 84 | ES_OPT_ID_SORTABLE=false 85 | ES_OPT_VERIFY_SSL=true 86 | ES_OPT_RETRIES= 87 | ES_OPT_META_HEADERS=true 88 | ES_ERROR_INDEX= 89 | ES_OPT_BYPASS_MAP_VALIDATION=false 90 | ES_OPT_DEFAULT_LIMIT=1000 91 | ``` 92 | 93 | > Update `config/database.php` 94 | 95 | ```php 96 | 'elasticsearch' => [ 97 | 'driver' => 'elasticsearch', 98 | 'auth_type' => env('ES_AUTH_TYPE', 'http'), //http or cloud 99 | 'hosts' => explode(',', env('ES_HOSTS', 'http://localhost:9200')), 100 | 'username' => env('ES_USERNAME', ''), 101 | 'password' => env('ES_PASSWORD', ''), 102 | 'cloud_id' => env('ES_CLOUD_ID', ''), 103 | 'api_id' => env('ES_API_ID', ''), 104 | 'api_key' => env('ES_API_KEY', ''), 105 | 'ssl_cert' => env('ES_SSL_CA', ''), 106 | 'ssl' => [ 107 | 'cert' => env('ES_SSL_CERT', ''), 108 | 'cert_password' => env('ES_SSL_CERT_PASSWORD', ''), 109 | 'key' => env('ES_SSL_KEY', ''), 110 | 'key_password' => env('ES_SSL_KEY_PASSWORD', ''), 111 | ], 112 | 'index_prefix' => env('ES_INDEX_PREFIX', false), 113 | 'options' => [ 114 | 'bypass_map_validation' => env('ES_OPT_BYPASS_MAP_VALIDATION', false), 115 | 'logging' => env('ES_OPT_LOGGING', false), 116 | 'ssl_verification' => env('ES_OPT_VERIFY_SSL', true), 117 | 'retires' => env('ES_OPT_RETRIES', null), 118 | 'meta_header' => env('ES_OPT_META_HEADERS', true), 119 | 'default_limit' => env('ES_OPT_DEFAULT_LIMIT', 1000), 120 | 'allow_id_sort' => env('ES_OPT_ID_SORTABLE', false), 121 | ], 122 | ], 123 | ``` 124 |
125 | 126 | Install the package via composer: 127 | 128 | ```bash 129 | composer require pdphilip/elasticlens 130 | ``` 131 | Publish the config file: 132 | ```bash 133 | php artisan lens:install 134 | ``` 135 | Run the migrations to create the index build and migration logs indexes: 136 | ```bash 137 | php artisan migrate 138 | ``` 139 | 140 | 141 | 142 | # Read the [Documentation](https://elasticsearch.pdphilip.com/elasticlens/getting-started/) 143 | 144 | 145 | ## Features 146 | 147 | - [Zero config setup](#step-1-zero-config-setup): Start indexing with minimal configuration. [Docs](https://elasticsearch.pdphilip.com/elasticlens/index-model/) 148 | - [Eloquent Querying](#step-2-search-your-models): Search your models with Eloquent and the full power of Elasticsearch. [Docs](https://elasticsearch.pdphilip.com/elasticlens/full-text-search) 149 | - [Custom Field Mapping](#step-3-create-a-field-map): Control how your index is built, including [mapping model relationships as embedded fields](#step-4-update-fieldmap-to-include-relationships-as-embedded-fields). [Docs](https://elasticsearch.pdphilip.com/elasticlens/field-mapping/) 150 | - [Manage Elasticsearch Migrations](#step-5-define-your-index-models-migrationmap): Define a required blueprint for your index migrations. [Docs](https://elasticsearch.pdphilip.com/elasticlens/index-model-migrations/) 151 | - [Control Observed models](#step-6-fine-tune-the-observers): Tailor which models are observed for changes. [Docs](https://elasticsearch.pdphilip.com/elasticlens/model-observers/) 152 | - [Comprehensive CLI Tools](#step-7-monitor-and-administer-all-your-indexes-with-artisan-commands): Manage index health, migrate/rebuild indexes, and more with Artisan commands. [Docs](https://elasticsearch.pdphilip.com/elasticlens/artisan-cli-tools/) 153 | - [Built-in IndexableBuildState model](#step-8-optionally-access-the-built-in-indexablebuild-model-to-track-index-build-states): Track the build states of your indexes. [Docs](https://elasticsearch.pdphilip.com/elasticlens/build-migration-states/) 154 | - [Built-in Migration Logs](#step-9-optionally-access-the-built-in-indexablemigrationlog-model-for-index-migration-status): Track the build states of your indexes. [Docs](https://elasticsearch.pdphilip.com/elasticlens/build-migration-states/) 155 | 156 | 157 | ### Example Usage 158 | 159 | The Walkthrough below will demonstrate all the features by way of an example. In this example, we'll index a `User` model. 160 | 161 | # Step 1: Zero config setup 162 | 163 | ## [Docs → Indexing your Base-Model](https://elasticsearch.pdphilip.com/elasticlens/index-model/) 164 | 165 | #### 1. Add the `Indexable` Trait to Your Base-Model: 166 | 167 | ```php 168 | use PDPhilip\ElasticLens\Indexable; 169 | 170 | class User extends Eloquent implements Authenticatable, CanResetPassword 171 | { 172 | use Indexable; 173 | ``` 174 | 175 | #### 2. Create an Index-Model for Your Base-Model: 176 | 177 | ElasticLens expects the `Index-Model` to be named as `Indexed` + `BaseModelName` and located in the `App\Models\Indexes` directory. 178 | 179 | 2(a) Create the `User` index with artisan: 180 | ```bash 181 | php artisan lens:make User 182 | ``` 183 | 184 | 185 | 2(b) or create the `User` index directly: 186 | 187 | ```php 188 | /** 189 | * Create: App\Models\Indexes\IndexedUser.php 190 | */ 191 | namespace App\Models\Indexes; 192 | 193 | use PDPhilip\ElasticLens\IndexModel; 194 | 195 | class IndexedUser extends IndexModel{} 196 | 197 | ``` 198 | 199 | - That's it! Your User model will now automatically sync with the IndexedUser model whenever changes occur. You can search your User model effortlessly, like: 200 | 201 | ```php 202 | User::viaIndex()->searchTerm('running')->orSearchTerm('swimming')->get(); 203 | ``` 204 | 205 | # Step 2: Search your models 206 | 207 | ## [Docs → Full-text base-model search](https://elasticsearch.pdphilip.com/elasticlens/full-text-search) 208 | 209 | Perform quick and easy full-text searches: 210 | 211 | ```php 212 | User::search('loves espressos'); 213 | ``` 214 | 215 | > Search for the phrase `loves espressos` across all fields and return the base `User` models 216 | 217 | Cute. But that's not why we're here... 218 | 219 | To truly harness the power of [Laravel-Elasticsearch](https://github.com/pdphilip/laravel-elasticsearch) for eloquent-like querying, you can use more advanced queries: 220 | 221 | ```php 222 | BaseModel::viaIndex()->{build_your_es_eloquent_query}->first(); 223 | BaseModel::viaIndex()->{build_your_es_eloquent_query}->get(); 224 | BaseModel::viaIndex()->{build_your_es_eloquent_query}->paginate(); 225 | BaseModel::viaIndex()->{build_your_es_eloquent_query}->avg('orders'); 226 | BaseModel::viaIndex()->{build_your_es_eloquent_query}->distinct(); 227 | BaseModel::viaIndex()->{build_your_es_eloquent_query}->{etc} 228 | ``` 229 | 230 | #### Examples: 231 | 232 | ##### 1. Basic Term Search: 233 | 234 | ```php 235 | User::viaIndex()->searchTerm('nara') 236 | ->where('state','active') 237 | ->limit(3)->get(); 238 | ``` 239 | 240 | > This searches all users who are `active` for the term 'nara' across all fields and returns the top 3 results. 241 | > - [https://elasticsearch.pdphilip.com/full-text-search#term-search-term](https://elasticsearch.pdphilip.com/eloquent/search-queries/#search-term) 242 | 243 | #### 2. Phrase Search: 244 | 245 | ```php 246 | User::viaIndex()->searchPhrase('Ice bathing') 247 | ->orderByDesc('created_at') 248 | ->limit(5)->get(); 249 | ``` 250 | 251 | > Searches all fields for the phrase 'Ice bathing' and returns the three newest results. Phrases match exact words in order. 252 | > - [https://elasticsearch.pdphilip.com/eloquent/search-queries/#search-phrase](https://elasticsearch.pdphilip.com/eloquent/search-queries/#search-phrase) 253 | 254 | #### 3. Boosting Terms fields: 255 | 256 | ```php 257 | User::viaIndex()->searchTerm('David',['first_name^3', 'last_name^2', 'bio'])->get(); 258 | ``` 259 | 260 | > Searches for the term 'David', boosts the first_name field by 3, last_name by 2, and checks the bio field. Results are ordered by score. 261 | > - [https://elasticsearch.pdphilip.com/full-text-search#boosting-terms](https://elasticsearch.pdphilip.com/eloquent/search-queries/#parameter-fields) 262 | > - [https://elasticsearch.pdphilip.com/full-text-search#minimum-score](https://elasticsearch.pdphilip.com/eloquent/search-queries/#parameter-options) 263 | 264 | #### 4. Geolocation Filtering: 265 | 266 | ```php 267 | User::viaIndex()->where('status', 'active') 268 | ->filterGeoPoint('home.location', '5km', [0, 0]) 269 | ->orderByGeo('home.location',[0, 0]) 270 | ->get(); 271 | ``` 272 | 273 | > Finds all active users within a 5km radius from the coordinates [0, 0], ordering them from closest to farthest. Not kidding. 274 | > - [https://elasticsearch.pdphilip.com/es-specific#geo-point](https://elasticsearch.pdphilip.com/eloquent/es-queries/#where-geo-distance) 275 | > - [https://elasticsearch.pdphilip.com/ordering-and-pagination#order-by-geo-distance](https://elasticsearch.pdphilip.com/eloquent/ordering-and-pagination/#orderby-geo-distance) 276 | 277 | #### 5. Regex Search: 278 | 279 | ```php 280 | User::viaIndex()->whereRegex('favourite_color', 'bl(ue)?(ack)?')->get(); 281 | ``` 282 | 283 | > Finds all users whose favourite colour is blue or black. 284 | > - [https://elasticsearch.pdphilip.com/full-text-search#regular-expressions](https://elasticsearch.pdphilip.com/eloquent/es-queries/#where-regex) 285 | 286 | #### 6. Pagination: 287 | 288 | ```php 289 | User::viaIndex()->whereRegex('favorite_color', 'bl(ue)?(ack)?')->paginate(10); 290 | ``` 291 | 292 | > Paginate search results. 293 | > - [https://elasticsearch.pdphilip.com/ordering-and-pagination](https://elasticsearch.pdphilip.com/eloquent/ordering-and-pagination/) 294 | 295 | #### 7. Nested Object Search: 296 | 297 | ```php 298 | User::viaIndex()->whereNestedObject('user_logs', function (Builder $query) { 299 | $query->where('user_logs.country', 'Norway') 300 | ->where('user_logs.created_at', '>=',Carbon::now()->modify('-1 week')); 301 | })->get(); 302 | ``` 303 | 304 | > Searches nested user_logs for users who logged in from Norway within the last week. Whoa. 305 | > - [https://elasticsearch.pdphilip.com/nested-queries](https://elasticsearch.pdphilip.com/eloquent/nested-queries/) 306 | 307 | #### 8. Fuzzy Search: 308 | 309 | ```php 310 | User::viaIndex()->searchFuzzy('quikc') 311 | ->orSearchFuzzy('brwn') 312 | ->orSearchFuzzy('foks') 313 | ->get(); 314 | ``` 315 | 316 | > No spell, no problem. Search Fuzzy. 317 | > - [https://elasticsearch.pdphilip.com/full-text-search](https://elasticsearch.pdphilip.com/eloquent/search-queries/#search-term-fuzzy) 318 | 319 | #### 9. Highlighting Search Results: 320 | 321 | ```php 322 | User::viaIndex()->searchTerm('espresso') 323 | ->withHighlights()->get(); 324 | ``` 325 | 326 | > Searches for 'espresso' across all fields and highlights where it was found. 327 | > - [https://elasticsearch.pdphilip.com/full-text-search#highlighting](https://elasticsearch.pdphilip.com/eloquent/search-queries/#highlighting) 328 | 329 | #### 10. Phrase prefix search: 330 | 331 | ```php 332 | User::viaIndex()->searchPhrasePrefix('loves espr') 333 | ->withHighlights()->get(); 334 | ``` 335 | 336 | > Searches for the phrase prefix 'loves espr' across all fields and highlights where it was found. 337 | > - [https://elasticsearch.pdphilip.com/full-text-search#highlighting](https://elasticsearch.pdphilip.com/eloquent/search-queries/#search-phrase-prefix) 338 | 339 | ### Note on `Index-Model` vs `Base-Model` Results 340 | 341 | - Since the `viaIndex()` taps into the `IndexModel`, the results will be instances of `IndexedUser`, not the base `User` model. 342 | - This can be useful for display purposes, such as highlighting embedded fields. 343 | - **However, in most cases you'll need to return and work with the `Base-Model`** 344 | 345 | ### To search and return results as `Base-Models`: 346 | 347 | #### 1. use `asBase()` 348 | 349 | - Simply chain `->asBase()` at the end of your query: 350 | 351 | ```php 352 | User::viaIndex()->searchTerm('david')->orderByDesc('created_at')->limit(3)->get()->asBase(); 353 | User::viaIndex()->whereRegex('favorite_color', 'bl(ue)?(ack)?')->get()->asBase(); 354 | User::viaIndex()->whereRegex('favorite_color', 'bl(ue)?(ack)?')->first()->asBase(); 355 | ``` 356 | 357 | #### 2. use `getBase()` instead of `get()->asBase()` 358 | 359 | ```php 360 | User::viaIndex()->searchTerm('david')->orderByDesc('created_at')->limit(3)->getBase(); 361 | User::viaIndex()->whereRegex('favorite_color', 'bl(ue)?(ack)?')->getBase(); 362 | ``` 363 | 364 | ### To search and paginate results as `Base-Models` use: `paginateBase()` 365 | 366 | - Complete the query string with `->paginateBase()` 367 | 368 | ```php 369 | // Returns a pagination instance of Users ✔️: 370 | User::viaIndex()->whereRegex('favorite_color', 'bl(ue)?(ack)?')->paginateBase(10); 371 | 372 | // Returns a pagination instance of IndexedUsers: 373 | User::viaIndex()->whereRegex('favorite_color', 'bl(ue)?(ack)?')->paginate(10); 374 | 375 | // Will not paginate ❌ (but will at least return a collection of 10 Users): 376 | User::viaIndex()->whereRegex('favorite_color', 'bl(ue)?(ack)?')->paginate(10)->asBase(); 377 | ``` 378 | 379 | # Step 3: Create a field Map 380 | 381 | ## [Docs → Index-model field mapping](https://elasticsearch.pdphilip.com/elasticlens/field-mapping/) 382 | 383 | You can define the `fieldMap()` method in your `Index-Model` to control how the index is built during synchronisation. 384 | 385 | ```php 386 | use PDPhilip\ElasticLens\Builder\IndexBuilder; 387 | use PDPhilip\ElasticLens\Builder\IndexField; 388 | 389 | class IndexedUser extends IndexModel 390 | { 391 | protected $baseModel = User::class; 392 | 393 | public function fieldMap(): IndexBuilder 394 | { 395 | return IndexBuilder::map(User::class, function (IndexField $field) { 396 | $field->text('first_name'); 397 | $field->text('last_name'); 398 | $field->text('email'); 399 | $field->bool('is_active'); 400 | $field->type('state', UserState::class); //Maps enum 401 | $field->text('created_at'); 402 | $field->text('updated_at'); 403 | }); 404 | } 405 | ``` 406 | 407 | ### Notes: 408 | 409 | - The `IndexedUser` records will contain only the fields defined in the `fieldMap()`. The value of `$user->id` will correspond to `$indexedUser->id`. 410 | - Fields can also be derived from attributes in the `Base-Model`. For example, `$field->bool('is_active')` could be derived from a custom attribute in the `Base-Model`: 411 | ```php 412 | public function getIsActiveAttribute(): bool 413 | { 414 | return $this->updated_at >= Carbon::now()->modify('-30 days'); 415 | } 416 | ``` 417 | - When mapping enums, ensure that you also cast them in the `Index-Model`. 418 | - If a value is not found during the build process, it will be stored as `null`. 419 | 420 | 421 | # Step 4: Update `fieldMap()` to include relationships as embedded fields 422 | 423 | ## [Docs → Relationships as embedded fields](https://elasticsearch.pdphilip.com/elasticlens/field-mapping/#relationships-as-embedded-fields) 424 | 425 | You can further customise indexing by embedding relationships as nested objects within your Index-Model. The builder allows you to define fields and embed relationships, enabling more complex data structures in your 426 | Elasticsearch index. 427 | 428 | ### Examples: 429 | 430 | 1. If a `User` has many `Profiles` 431 | 432 | ```php 433 | use PDPhilip\ElasticLens\Builder\IndexBuilder; 434 | use PDPhilip\ElasticLens\Builder\IndexField; 435 | 436 | class IndexedUser extends IndexModel 437 | { 438 | protected $baseModel = User::class; 439 | 440 | public function fieldMap(): IndexBuilder 441 | { 442 | return IndexBuilder::map(User::class, function (IndexField $field) { 443 | $field->text('first_name'); 444 | $field->text('last_name'); 445 | $field->text('email'); 446 | $field->bool('is_active'); 447 | $field->type('type', UserType::class); 448 | $field->type('state', UserState::class); 449 | $field->text('created_at'); 450 | $field->text('updated_at'); 451 | $field->embedsMany('profiles', Profile::class)->embedMap(function (IndexField $field) { 452 | $field->text('profile_name'); 453 | $field->text('about'); 454 | $field->array('profile_tags'); 455 | }); 456 | }); 457 | } 458 | ``` 459 | 460 | 2. If a `Profile` has one `ProfileStatus` 461 | 462 | ```php 463 | use PDPhilip\ElasticLens\Builder\IndexBuilder; 464 | use PDPhilip\ElasticLens\Builder\IndexField; 465 | 466 | class IndexedUser extends IndexModel 467 | { 468 | protected $baseModel = User::class; 469 | 470 | public function fieldMap(): IndexBuilder 471 | { 472 | return IndexBuilder::map(User::class, function (IndexField $field) { 473 | $field->text('first_name'); 474 | $field->text('last_name'); 475 | $field->text('email'); 476 | $field->bool('is_active'); 477 | $field->type('type', UserType::class); 478 | $field->type('state', UserState::class); 479 | $field->text('created_at'); 480 | $field->text('updated_at'); 481 | $field->embedsMany('profiles', Profile::class)->embedMap(function (IndexField $field) { 482 | $field->text('profile_name'); 483 | $field->text('about'); 484 | $field->array('profile_tags'); 485 | $field->embedsOne('status', ProfileStatus::class)->embedMap(function (IndexField $field) { 486 | $field->text('id'); 487 | $field->text('status'); 488 | }); 489 | }); 490 | }); 491 | } 492 | ``` 493 | 494 | 3. If a `User` belongs to an `Account` 495 | 496 | ```php 497 | use PDPhilip\ElasticLens\Builder\IndexBuilder; 498 | use PDPhilip\ElasticLens\Builder\IndexField; 499 | 500 | class IndexedUser extends IndexModel 501 | { 502 | protected $baseModel = User::class; 503 | 504 | public function fieldMap(): IndexBuilder 505 | { 506 | return IndexBuilder::map(User::class, function (IndexField $field) { 507 | $field->text('first_name'); 508 | $field->text('last_name'); 509 | $field->text('email'); 510 | $field->bool('is_active'); 511 | $field->type('type', UserType::class); 512 | $field->type('state', UserState::class); 513 | $field->text('created_at'); 514 | $field->text('updated_at'); 515 | $field->embedsMany('profiles', Profile::class)->embedMap(function (IndexField $field) { 516 | $field->text('profile_name'); 517 | $field->text('about'); 518 | $field->array('profile_tags'); 519 | $field->embedsOne('status', ProfileStatus::class)->embedMap(function (IndexField $field) { 520 | $field->text('id'); 521 | $field->text('status'); 522 | }); 523 | }); 524 | $field->embedsBelongTo('account', Account::class)->embedMap(function (IndexField $field) { 525 | $field->text('name'); 526 | $field->text('url'); 527 | }); 528 | }); 529 | } 530 | ``` 531 | 532 | 4. If a `User` belongs to a `Country` and you don't need to observe the `Country` model: 533 | 534 | ```php 535 | use PDPhilip\ElasticLens\Builder\IndexBuilder; 536 | use PDPhilip\ElasticLens\Builder\IndexField; 537 | 538 | class IndexedUser extends IndexModel 539 | { 540 | protected $baseModel = User::class; 541 | 542 | public function fieldMap(): IndexBuilder 543 | { 544 | return IndexBuilder::map(User::class, function (IndexField $field) { 545 | $field->text('first_name'); 546 | $field->text('last_name'); 547 | $field->text('email'); 548 | $field->bool('is_active'); 549 | $field->type('type', UserType::class); 550 | $field->type('state', UserState::class); 551 | $field->text('created_at'); 552 | $field->text('updated_at'); 553 | $field->embedsMany('profiles', Profile::class)->embedMap(function (IndexField $field) { 554 | $field->text('profile_name'); 555 | $field->text('about'); 556 | $field->array('profile_tags'); 557 | $field->embedsOne('status', ProfileStatus::class)->embedMap(function (IndexField $field) { 558 | $field->text('id'); 559 | $field->text('status'); 560 | }); 561 | }); 562 | $field->embedsBelongTo('account', Account::class)->embedMap(function (IndexField $field) { 563 | $field->text('name'); 564 | $field->text('url'); 565 | }); 566 | $field->embedsBelongTo('country', Country::class)->embedMap(function (IndexField $field) { 567 | $field->text('country_code'); 568 | $field->text('name'); 569 | $field->text('currency'); 570 | })->dontObserve(); // Don't observe changes in the country model 571 | }); 572 | } 573 | ``` 574 | 575 | 5. If a `User` has Many `UserLogs` and you only want to embed the last 10: 576 | 577 | ```php 578 | use PDPhilip\ElasticLens\Builder\IndexBuilder; 579 | use PDPhilip\ElasticLens\Builder\IndexField; 580 | 581 | class IndexedUser extends IndexModel 582 | { 583 | protected $baseModel = User::class; 584 | 585 | public function fieldMap(): IndexBuilder 586 | { 587 | return IndexBuilder::map(User::class, function (IndexField $field) { 588 | $field->text('first_name'); 589 | $field->text('last_name'); 590 | $field->text('email'); 591 | $field->bool('is_active'); 592 | $field->type('type', UserType::class); 593 | $field->type('state', UserState::class); 594 | $field->text('created_at'); 595 | $field->text('updated_at'); 596 | $field->embedsMany('profiles', Profile::class)->embedMap(function (IndexField $field) { 597 | $field->text('profile_name'); 598 | $field->text('about'); 599 | $field->array('profile_tags'); 600 | $field->embedsOne('status', ProfileStatus::class)->embedMap(function (IndexField $field) { 601 | $field->text('id'); 602 | $field->text('status'); 603 | }); 604 | }); 605 | $field->embedsBelongTo('account', Account::class)->embedMap(function (IndexField $field) { 606 | $field->text('name'); 607 | $field->text('url'); 608 | }); 609 | $field->embedsBelongTo('country', Country::class)->embedMap(function (IndexField $field) { 610 | $field->text('country_code'); 611 | $field->text('name'); 612 | $field->text('currency'); 613 | })->dontObserve(); // Don't observe changes in the country model 614 | $field->embedsMany('logs', UserLog::class, null, null, function ($query) { 615 | $query->orderBy('created_at', 'desc')->limit(10); // Limit the logs to the 10 most recent 616 | })->embedMap(function (IndexField $field) { 617 | $field->text('title'); 618 | $field->text('ip'); 619 | $field->array('log_data'); 620 | }); 621 | }); 622 | } 623 | ``` 624 | 625 | ### `IndexField $field` Methods: 626 | 627 | - `text($field)` 628 | - `integer($field)` 629 | - `array($field)` 630 | - `bool($field)` 631 | - `type($field, $type)` - Set own type (like Enums) 632 | - `embedsMany($field, $relatedModelClass, $whereRelatedField, $equalsLocalField, $query)` 633 | - `embedsBelongTo($field, $relatedModelClass, $whereRelatedField, $equalsLocalField, $query)` 634 | - `embedsOne($field, $relatedModelClass, $whereRelatedField, $equalsLocalField, $query)` 635 | 636 | **Note**: For embeds the `$whereRelatedField`, `$equalsLocalField`, `$query` parameters are optional. 637 | 638 | - `$whereRelatedField` is the `foreignKey`, and `$equalsLocalField` is the `localKey`; if they are not provided, they will be inferred from the relationship. 639 | - `$query` is a closure that allows you to customise the query for the related model. 640 | 641 | ### Embedded Relationship Builder Methods: 642 | 643 | - `embedMap(function (IndexField $field) {})` - Define the mapping for the embedded relationship 644 | - `dontObserve()` - Don't observe changes in the `$relatedModelClass` 645 | 646 | 647 | # Step 5: Define your `Index-Model`'s `migrationMap()` 648 | 649 | ## [Docs → Index-model migrations](https://elasticsearch.pdphilip.com/elasticlens/index-model-migrations/) 650 | 651 | Elasticsearch automatically indexes new fields it encounters, but it might not always index them in the way you need. To ensure the index is structured correctly, you can define a `migrationMap()` in your Index-Model. 652 | 653 | Since the `Index-Model` utilises the [Laravel-Elasticsearch](https://github.com/pdphilip/laravel-elasticsearch) package, you can use `IndexBlueprint` to customise your `migrationMap()` 654 | 655 | ```php 656 | use PDPhilip\Elasticsearch\Schema\Blueprint; 657 | 658 | class IndexedUser extends IndexModel 659 | { 660 | //...... 661 | public function migrationMap(): callable 662 | { 663 | return function (Blueprint $index) { 664 | $index->text('name'); 665 | $index->keyword('first_name'); 666 | $index->text('first_name'); 667 | $index->keyword('last_name'); 668 | $index->text('last_name'); 669 | $index->keyword('email'); 670 | $index->text('email'); 671 | $index->text('avatar')->indexField(false); 672 | $index->keyword('type'); 673 | $index->text('type'); 674 | $index->keyword('state'); 675 | $index->text('state'); 676 | //...etc 677 | }; 678 | } 679 | ``` 680 | 681 | ### Notes: 682 | 683 | - **Documentation**: For more details on migrations, refer to the: https://elasticsearch.pdphilip.com/migrations 684 | - **Running the Migration**: To execute the migration and rebuild all your indexed, use the following command: 685 | 686 | ```bash 687 | php artisan lens:migrate User 688 | ``` 689 | 690 | This command will delete the existing index, run the migration, and rebuild all records. 691 | 692 | # Step 6: Fine-tune the Observers 693 | 694 | ## [Docs → Base Model Observers](https://elasticsearch.pdphilip.com/elasticlens/model-observers/) 695 | 696 | By default, the `Base Model` is observed for changes (saves) and deletions. When the `Base Model` is deleted, the corresponding `Index Model` will also be deleted, even in cases of soft deletion. 697 | 698 | ### Handling Embedded Models 699 | 700 | The related models are also observed when you define a `fieldMap()` with embedded fields. For example: 701 | 702 | - A save or delete action on `ProfileStatus` will trigger a chain reaction, fetching the related `Profile` and then `User`, which in turn initiates a rebuild of the `IndexedUser`. 703 | 704 | However, to ensure these observers are loaded, you need to reference the User model explicitly: 705 | 706 | ```php 707 | //This alone will not trigger a rebuild 708 | $profileStatus->status = 'Unavailable'; 709 | $profileStatus->save(); 710 | 711 | //This will since the observers are loaded in the User model 712 | new User::class 713 | $profileStatus->status = 'Unavailable'; 714 | $profileStatus->save(); 715 | ``` 716 | 717 | ### Customising Observers 718 | 719 | 720 | If you want ElasticLens to observe `ProfileStatus` without requiring a reference to `User`, follow these steps: 721 | 722 | 1. Add the `HasWatcher` Trait to `ProfileStatus`: 723 | 724 | ```php 725 | use PDPhilip\ElasticLens\HasWatcher; 726 | 727 | class ProfileStatus extends Eloquent 728 | { 729 | use HasWatcher; 730 | ``` 731 | 732 | 2. Define the Watcher in the `elasticlens.php` Config File: 733 | 734 | ```php 735 | 'watchers' => [ 736 | \App\Models\ProfileStatus::class => [ 737 | \App\Models\Indexes\IndexedUser::class, 738 | ], 739 | ], 740 | ``` 741 | 742 | ### Disabling Base-Model Observation 743 | 744 | If you want to disable the automatic observation of the `Base-Model`, include the following in your `Index-Model`: 745 | 746 | ```php 747 | class IndexedUser extends IndexModel 748 | { 749 | protected $baseModel = User::class; 750 | 751 | protected $observeBase = false; 752 | 753 | ``` 754 | 755 | --- 756 | 757 | # Step 7: Monitor and administer all your indexes with Artisan commands 758 | 759 | ## [Docs → Artisan CLI Tools](https://elasticsearch.pdphilip.com/elasticlens/artisan-cli-tools/) 760 | 761 | Use the following Artisan commands to manage and monitor your Elasticsearch indexes: 762 | 763 | 1. Check Overall Status: 764 | 765 | ```bash 766 | php artisan lens:status 767 | ``` 768 | 769 | Displays the overall status of all your indexes and the ElasticLens configuration. 770 | 771 |
772 | ElasticLens Build 776 |
777 | 778 | 2. Check Index Health: 779 | 780 | ```bash 781 | php artisan lens:health User 782 | ``` 783 | 784 |
785 | ElasticLens Build 789 |
790 | Provides a comprehensive state of a specific index, in this case, for the `User` model. 791 | 792 | 3. Migrate and Build/Rebuild an Index: 793 | 794 | ```bash 795 | php artisan lens:migrate User 796 | ``` 797 | 798 | Deletes the existing User index, runs the migration, and rebuilds all records. 799 | 800 |
801 | ElasticLens Migrate 805 |
806 | 807 | 4. Create a New `Index-Model` for a `Base-Model`: 808 | 809 | ```bash 810 | php artisan lens:make Profile 811 | ``` 812 | 813 | Generates a new index for the `Profile` model. 814 | 815 |
816 | ElasticLens Build 820 |
821 | 822 | 5. Bulk (Re)Build Indexes for a `Base-Model`: 823 | 824 | ```bash 825 | php artisan lens:build Profile 826 | ``` 827 | 828 | Rebuilds all the `IndexedProfile` records for the `Profile` model. 829 | 830 |
831 | ElasticLens Build 835 |
836 | 837 | --- 838 | 839 | # Step 8: Optionally access the built-in `IndexableBuild` model to track index build states 840 | 841 | ## [Docs → Accessing IndexableBuild model](https://elasticsearch.pdphilip.com/elasticlens/build-migration-states/#accessing-indexablebuild-model) 842 | 843 | ElasticLens includes a built-in `IndexableBuild` model that allows you to monitor and track the state of your index builds. This model records the status of each index build, providing you with insights into the indexing process. 844 | 845 |
846 | Fields 847 | 848 | ### Model Fields: 849 | 850 | - string `$model`: The Base-Model being indexed. 851 | - string `$model_id`: The ID of the Base-Model. 852 | - string `$index_model`: The corresponding Index-Model. 853 | - string `$last_source`: The last source of the build state. 854 | - IndexableStateType `$state`: The current state of the index build. 855 | - array `$state_data`: Additional data related to the build state. 856 | - array `$logs`: Logs of the indexing process. 857 | - Carbon `$created_at`: Timestamp of when the build state was created. 858 | - Carbon `$updated_at`: Timestamp of the last update to the build state. 859 | 860 | ### Attributes: 861 | 862 | - @property-read string `$state_name`: The name of the current state. 863 | - @property-read string `$state_color`: The colour associated with the current state. 864 | 865 |
866 | 867 | 868 | Built-in methods include: 869 | 870 | ```php 871 | IndexableBuild::returnState($model, $modelId, $indexModel); 872 | IndexableBuild::countModelErrors($indexModel); 873 | IndexableBuild::countModelRecords($indexModel); 874 | ``` 875 | 876 | **Note**: While you can query the `IndexableBuild` model directly, avoid writing or deleting records within it manually, as this can interfere with the health checks and overall integrity of the indexing process. The model should be 877 | used for reading purposes only to ensure accurate monitoring and reporting. 878 | 879 | --- 880 | 881 | # Step 9: Optionally Access the Built-in `IndexableMigrationLog` Model for Index Migration Status 882 | 883 | ## [Docs → Access IndexableMigrationLog model](https://elasticsearch.pdphilip.com/elasticlens/build-migration-states/#access-indexablemigrationlog-model) 884 | 885 | ElasticLens includes a built-in `IndexableMigrationLog` model for monitoring and tracking the state of index migrations. This model logs each migration related to an `Index-Model`. 886 | 887 |
888 | Fields 889 | 890 | - string `$index_model`: The migrated Index-Model. 891 | - IndexableMigrationLogState `$state`: State of the migration 892 | - array `$map`: Migration map passed to Elasticsearch. 893 | - int `$version_major`: Major version of the indexing process. 894 | - int `$version_minor`: Minor version of the indexing process. 895 | - Carbon `$created_at`: Timestamp of when the migration was created. 896 | 897 | ### Attributes: 898 | 899 | - @property-read string `$version`: Parsed version, ex v2.03 900 | - @property-read string `$state_name`: Current state name. 901 | - @property-read string `$state_color`: Colour representing the current state. 902 | 903 |
904 | 905 | 906 | Built-in methods include: 907 | 908 | ```php 909 | IndexableMigrationLog::getLatestVersion($indexModel); 910 | IndexableMigrationLog::getLatestMigration($indexModel); 911 | ``` 912 | 913 | **Note**: While you can query the `IndexableMigrationLog` model directly, avoid writing or deleting records within it manually, as this can interfere with versioning of the migrations. The model should be used for reading purposes only, to 914 | ensure accuracy. 915 | 916 | --- 917 | 918 | ## Credits 919 | 920 | - [David Philip](https://github.com/pdphilip) 921 | 922 | ## License 923 | 924 | The MIT License (MIT). Please see [License File](LICENSE.md) for more information. 925 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pdphilip/elasticlens", 3 | "description": "Search your Laravel models with the convenience of Eloquent and the power of Elasticsearch", 4 | "keywords": [ 5 | "PDPhilip", 6 | "laravel", 7 | "Elasticsearch", 8 | "Eloquent", 9 | "elasticlens" 10 | ], 11 | "homepage": "https://github.com/pdphilip/elasticlens", 12 | "license": "MIT", 13 | "authors": [ 14 | { 15 | "name": "David Philip", 16 | "email": "pd.philip@gmail.com", 17 | "role": "Developer" 18 | } 19 | ], 20 | "require": { 21 | "php": "^8.2", 22 | "illuminate/contracts": "^10.0||^11.0||^12.0", 23 | "pdphilip/elasticsearch": "^5.0.2", 24 | "pdphilip/omniterm": "^1.0.5", 25 | "spatie/laravel-package-tools": "^1.16" 26 | }, 27 | "require-dev": { 28 | "laravel/pint": "^1.14", 29 | "nunomaduro/collision": "^8.1.1||^7.10.0", 30 | "larastan/larastan": "^2.9", 31 | "orchestra/testbench": "^9.0.0||^8.22.0", 32 | "pestphp/pest": "^2.34", 33 | "pestphp/pest-plugin-arch": "^2.7", 34 | "pestphp/pest-plugin-laravel": "^2.3", 35 | "phpstan/extension-installer": "^1.3", 36 | "phpstan/phpstan-deprecation-rules": "^1.1", 37 | "phpstan/phpstan-phpunit": "^1.3" 38 | }, 39 | "autoload": { 40 | "psr-4": { 41 | "PDPhilip\\ElasticLens\\": "src/" 42 | } 43 | }, 44 | "autoload-dev": { 45 | "psr-4": { 46 | "PDPhilip\\ElasticLens\\Tests\\": "tests/", 47 | "Workbench\\App\\": "workbench/app/" 48 | } 49 | }, 50 | "scripts": { 51 | "post-autoload-dump": "@composer run prepare", 52 | "clear": "@php vendor/bin/testbench package:purge-elasticlens --ansi", 53 | "prepare": "@php vendor/bin/testbench package:discover --ansi", 54 | "build": [ 55 | "@composer run prepare", 56 | "@php vendor/bin/testbench workbench:build --ansi" 57 | ], 58 | "start": [ 59 | "Composer\\Config::disableProcessTimeout", 60 | "@composer run build", 61 | "@php vendor/bin/testbench serve" 62 | ], 63 | "types": "phpstan analyse --ansi --memory-limit=2G", 64 | "lint": "pint -v", 65 | "pest": "pest --colors=always", 66 | "test-coverage": "pest --coverage", 67 | "test:lint": "pint --test -v", 68 | "test:types": "@types", 69 | "test:unit": "pest --colors=always", 70 | "test": [ 71 | "@test:lint", 72 | "@test:types", 73 | "@test:unit" 74 | ] 75 | }, 76 | "config": { 77 | "sort-packages": true, 78 | "allow-plugins": { 79 | "pestphp/pest-plugin": true, 80 | "phpstan/extension-installer": true, 81 | "php-http/discovery": true 82 | } 83 | }, 84 | "extra": { 85 | "laravel": { 86 | "providers": [ 87 | "PDPhilip\\ElasticLens\\ElasticLensServiceProvider" 88 | ] 89 | } 90 | }, 91 | "minimum-stability": "dev", 92 | "prefer-stable": true 93 | } 94 | -------------------------------------------------------------------------------- /config/elasticlens.php: -------------------------------------------------------------------------------- 1 | 'elasticsearch', 5 | 6 | 'queue' => null, // Set queue to use for dispatching index builds, ex: default, high, low, etc. 7 | 8 | 'watchers' => [ 9 | // \App\Models\Profile::class => [ 10 | // \App\Models\Indexes\IndexedUser::class, 11 | // ], 12 | ], 13 | 14 | 'index_build_state' => [ 15 | 'enabled' => true, // Recommended to keep this enabled 16 | 'log_trim' => 2, // If null, the logs field will be empty 17 | ], 18 | 19 | 'index_migration_logs' => [ 20 | 'enabled' => true, // Recommended to keep this enabled 21 | ], 22 | 'namespaces' => [ 23 | 'App\Models' => 'App\Models\Indexes', 24 | ], 25 | 26 | 'index_paths' => [ 27 | 'app/Models/Indexes/' => 'App\Models\Indexes', 28 | ], 29 | ]; 30 | -------------------------------------------------------------------------------- /database/migrations/create_indexable_build_index.php: -------------------------------------------------------------------------------- 1 | deleteIfExists('indexable_builds'); 14 | 15 | Schema::on($connectionName)->create('indexable_builds', function (Blueprint $index) { 16 | $index->keyword('model'); 17 | $index->keyword('model_id'); 18 | $index->keyword('index_model'); 19 | $index->keyword('state'); 20 | $index->keyword('last_source'); 21 | $index->text('last_source'); 22 | 23 | $index->flattened('state_data')->indexField(false); 24 | $index->flattened('logs')->indexField(false); 25 | }); 26 | } 27 | 28 | public function down() 29 | { 30 | $connectionName = config('elasticlens.connection') ?? 'elasticsearch'; 31 | 32 | Schema::on($connectionName)->deleteIfExists('indexable_builds'); 33 | } 34 | }; 35 | -------------------------------------------------------------------------------- /database/migrations/create_indexable_migration_logs_index.php: -------------------------------------------------------------------------------- 1 | deleteIfExists('indexable_migration_logs'); 14 | 15 | Schema::on($connectionName)->create('indexable_migration_logs', function (Blueprint $index) { 16 | $index->keyword('index_model'); 17 | $index->keyword('state'); 18 | $index->integer('version_major'); 19 | $index->integer('version_minor'); 20 | $index->flattened('map')->indexField(false); 21 | }); 22 | } 23 | 24 | public function down() 25 | { 26 | $connectionName = config('elasticlens.connection') ?? 'elasticsearch'; 27 | 28 | Schema::on($connectionName)->deleteIfExists('indexable_migration_logs'); 29 | } 30 | }; 31 | -------------------------------------------------------------------------------- /resources/stubs/IndexedBase.php.stub: -------------------------------------------------------------------------------- 1 | 10 |
11 | @include('elasticlens::cli.components.loader-spin',['message' => $title,'i' => $i,'state' => $state]) 12 | @include('elasticlens::cli.components.hr') 13 | @include('elasticlens::cli.components.data-row-value',['key' => 'Created','value' => $created,'class' => 'text-sky-500']) 14 | @include('elasticlens::cli.components.data-row-value',['key' => 'Updated','value' => $updated,'class' => 'text-emerald-500']) 15 | @include('elasticlens::cli.components.data-row-value',['key' => 'Skipped','value' => $skipped,'class' => 'text-amber-500']) 16 | @include('elasticlens::cli.components.data-row-value',['key' => 'Failed','value' => $failed,'class' => 'text-rose-500']) 17 |
-------------------------------------------------------------------------------- /resources/views/cli/components/code-trait.blade.php: -------------------------------------------------------------------------------- 1 |
2 | 3 | namespace App\Models; 4 | 5 | use PDPhilip\ElasticLens\Indexable; 6 | 7 | class {{$model}} extends Model 8 | { 9 | use Indexable; 10 | 11 | 12 |
-------------------------------------------------------------------------------- /resources/views/cli/components/data-row-value.blade.php: -------------------------------------------------------------------------------- 1 | 26 |
27 |
28 | {{ $name }} 29 | 30 | 31 | {{$value}} 32 | 33 |
34 |
35 | -------------------------------------------------------------------------------- /resources/views/cli/components/hr.blade.php: -------------------------------------------------------------------------------- 1 | 8 |
9 | 10 |
-------------------------------------------------------------------------------- /resources/views/cli/components/loader-spin.blade.php: -------------------------------------------------------------------------------- 1 | $intervals) { 7 | $i -= $intervals; 8 | $j++; 9 | if ($j > 3) { 10 | $j = 0; 11 | } 12 | } 13 | 14 | $show = $characters[$i]; 15 | $textColor = $colors[$j]; 16 | switch ($state) { 17 | case 'success': 18 | $textColor = "text-emerald-500"; 19 | $show = "✔"; 20 | break; 21 | case 'warning': 22 | $textColor = "text-amber-500"; 23 | $show = "⚠"; 24 | break; 25 | case 'failover': 26 | $textColor = "text-amber-500"; 27 | $show = "◴"; 28 | break; 29 | case 'error': 30 | $textColor = "text-rose-500"; 31 | $show = "✘"; 32 | break; 33 | 34 | } 35 | ?> 36 |
37 | 38 | {{ $show }} 39 | 40 | {{$message}} 41 | @if(!empty($details)) 42 | {{$details}} 43 | @endif 44 |
-------------------------------------------------------------------------------- /resources/views/cli/components/title-row.blade.php: -------------------------------------------------------------------------------- 1 | 4 |
5 | 6 | 7 | 8 | 9 | 10 | {{$t}} 11 | 12 | 13 | 14 | 15 | 16 |
-------------------------------------------------------------------------------- /resources/views/cli/components/title.blade.php: -------------------------------------------------------------------------------- 1 | 4 |
5 | @include('elasticlens::cli.components.title-row') 6 | @include('elasticlens::cli.components.title-row',['t' => $title]) 7 | @include('elasticlens::cli.components.title-row') 8 |
-------------------------------------------------------------------------------- /src/Builder/IndexBuilder.php: -------------------------------------------------------------------------------- 1 | for($model); 31 | $mapper->setBaseMap($mapper); 32 | if (is_callable($callback)) { 33 | $callback(new IndexField($mapper)); 34 | } 35 | 36 | return $mapper; 37 | } 38 | 39 | public function getFieldMap() 40 | { 41 | return $this->fields; 42 | } 43 | 44 | public function getObservers() 45 | { 46 | return $this->baseMap->observers; 47 | } 48 | 49 | public function getRelationships() 50 | { 51 | return $this->baseMap->relationships; 52 | } 53 | 54 | public function for($model): static 55 | { 56 | $this->model = $model; 57 | 58 | return $this; 59 | } 60 | 61 | public function addField($field, $type): static 62 | { 63 | $this->fields[$field] = $type; 64 | 65 | return $this; 66 | } 67 | 68 | public function setEmbedField($field): static 69 | { 70 | $this->embedFieldName = $field; 71 | 72 | return $this; 73 | } 74 | 75 | public function setParentMap($parent): static 76 | { 77 | $this->parentMap = $parent; 78 | 79 | return $this; 80 | } 81 | 82 | public function setBaseMap($builder): static 83 | { 84 | $this->baseMap = $builder; 85 | 86 | return $this; 87 | } 88 | 89 | public function setRelationship($field, $relationship): static 90 | { 91 | $this->baseMap->relationships[$field] = $relationship; 92 | 93 | return $this; 94 | } 95 | 96 | public function getBaseMap() 97 | { 98 | return $this->baseMap; 99 | } 100 | 101 | // ---------------------------------------------------------------------- 102 | // Embedded Methods 103 | // ---------------------------------------------------------------------- 104 | 105 | public function isEmbedded(): bool 106 | { 107 | return ! empty($this->embedFieldName) && $this->parentMap; 108 | } 109 | 110 | // Add an embedded relationship to the map 111 | public function addEmbed($field, $relation, $type, $whereRelatedField = null, $equalsLocalField = null, $query = null): IndexBuilder 112 | { 113 | $current = $this; 114 | 115 | $mapper = new self; 116 | $mapper->for($relation); 117 | $mapper->setEmbedField($field); 118 | $mapper->setParentMap($current); 119 | $mapper->setBaseMap($current->getBaseMap()); 120 | $relationship = $mapper->buildRelationship($mapper->parentMap->model, $relation, $type, $whereRelatedField, $equalsLocalField, $query); 121 | $mapper->attachEmbeddedObserver($relationship); 122 | $mapper->setRelationship($field, $relationship); 123 | 124 | return $mapper; 125 | } 126 | 127 | // Embed map for related models 128 | public function embedMap($callback): self 129 | { 130 | if (! $this->isEmbedded()) { 131 | throw new RuntimeException('Embedded Maps can only be called for embedded fields'); 132 | } 133 | 134 | if (is_callable($callback)) { 135 | $callback(new IndexField($this)); 136 | } 137 | 138 | $this->parentMap->addField($this->embedFieldName, $this->fields); 139 | 140 | return $this; 141 | } 142 | 143 | public function attachEmbeddedObserver($relationship): void 144 | { 145 | $this->baseMap->_attachObserver($relationship); 146 | 147 | } 148 | 149 | public function attachBaseObserver(): void 150 | { 151 | $relationship = $this->buildRelationship($this->model, $this->model, 'base'); 152 | $this->baseMap->_attachObserver($relationship); 153 | } 154 | 155 | public function dontObserve(): self 156 | { 157 | if (! $this->isEmbedded()) { 158 | return $this; 159 | } 160 | $observers = $this->baseMap->observers; 161 | $observer = array_pop($observers); 162 | $observer['observe'] = false; 163 | $observers[] = $observer; 164 | $this->baseMap->observers = $observers; 165 | 166 | return $this; 167 | } 168 | 169 | public function _attachObserver($relationship): void 170 | { 171 | $observers = $this->baseMap->observers; 172 | $observers[] = $relationship; 173 | 174 | $this->baseMap->observers = $observers; 175 | } 176 | 177 | protected function buildRelationship($baseModel, $relation, $type, $whereRelatedField = null, $equalsLocalField = null, $query = null): array 178 | { 179 | if (! $whereRelatedField || $equalsLocalField) { 180 | [$whereRelatedField, $equalsLocalField] = $this->_inferKeys($baseModel, $relation, $type, $whereRelatedField, $equalsLocalField); 181 | } 182 | 183 | return [ 184 | 'observe' => true, 185 | 'model' => $baseModel, 186 | 'type' => $type, 187 | 'relation' => $relation, 188 | 'whereRelatedField' => $whereRelatedField, 189 | 'equalsModelField' => $equalsLocalField, 190 | 'query' => $query, 191 | ]; 192 | } 193 | 194 | private function _inferKeys($baseModel, $relation, $type, $foreignKey = null, $localKey = null): array 195 | { 196 | 197 | if ($type === 'base') { 198 | $base = (new $baseModel); 199 | $id = $base->getKeyName(); 200 | $foreignKey = $id; 201 | $localKey = $id; 202 | } 203 | 204 | if ($type == 'belongsTo') { 205 | if (! $foreignKey) { 206 | $base = (new $baseModel); 207 | $foreignKey = $base->getKeyName(); 208 | } 209 | if (! $localKey) { 210 | $rel = (new $relation); 211 | $table = $rel->getTable(); 212 | $localKey = Str::singular($table).'_id'; 213 | } 214 | 215 | } 216 | 217 | if (in_array($type, ['hasMany', 'hasOne'])) { 218 | if (! $foreignKey) { 219 | $base = (new $baseModel); 220 | $table = $base->getTable(); 221 | $foreignKey = Str::singular($table).'_id'; 222 | 223 | } 224 | if (! $localKey) { 225 | $rel = (new $relation); 226 | $localKey = $rel->getKeyName(); 227 | } 228 | 229 | } 230 | 231 | return [$foreignKey, $localKey]; 232 | } 233 | } 234 | -------------------------------------------------------------------------------- /src/Builder/IndexField.php: -------------------------------------------------------------------------------- 1 | mapper = $mapper; 16 | } 17 | 18 | // ---------------------------------------------------------------------- 19 | // Field sets 20 | // ---------------------------------------------------------------------- 21 | 22 | public function text($field): IndexField|static 23 | { 24 | return $this->addField($field, 'string'); 25 | } 26 | 27 | public function integer($field): IndexField|static 28 | { 29 | return $this->addField($field, 'integer'); 30 | } 31 | 32 | public function array($field): IndexField|static 33 | { 34 | return $this->addField($field, 'array'); 35 | } 36 | 37 | public function bool($field): IndexField|static 38 | { 39 | return $this->addField($field, 'bool'); 40 | } 41 | 42 | public function carbon($field): IndexField|static 43 | { 44 | return $this->addField($field, Carbon::class); 45 | } 46 | 47 | public function type($field, $type): IndexField|static 48 | { 49 | return $this->addField($field, $type); 50 | } 51 | 52 | // ---------------------------------------------------------------------- 53 | // Embeds 54 | // ---------------------------------------------------------------------- 55 | 56 | public function embedsMany($field, $relation, $whereRelatedField = null, $equalsLocalField = null, $query = null) 57 | { 58 | return $this->addEmbed($field, $relation, 'hasMany', $whereRelatedField, $equalsLocalField, $query); 59 | } 60 | 61 | public function embedsOne($field, $relation, $whereRelatedField = null, $equalsLocalField = null, $query = null) 62 | { 63 | return $this->addEmbed($field, $relation, 'hasOne', $whereRelatedField, $equalsLocalField, $query); 64 | } 65 | 66 | public function embedsBelongTo($field, $relation, $whereRelatedField = null, $equalsLocalField = null, $query = null) 67 | { 68 | return $this->addEmbed($field, $relation, 'belongsTo', $whereRelatedField, $equalsLocalField, $query); 69 | } 70 | 71 | // ---------------------------------------------------------------------- 72 | // Builder entry points 73 | // ---------------------------------------------------------------------- 74 | 75 | protected function addField($field, $type): static 76 | { 77 | $this->mapper->addField($field, $type); 78 | 79 | return $this; 80 | } 81 | 82 | protected function addEmbed($field, $relation, $type, $whereRelatedField = null, $equalsLocalField = null, $query = null): IndexBuilder 83 | { 84 | return $this->mapper->addEmbed($field, $relation, $type, $whereRelatedField, $equalsLocalField, $query); 85 | 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/Commands/LensBuildCommand.php: -------------------------------------------------------------------------------- 1 | initOmni(); 58 | $model = $this->argument('model'); 59 | $modelCheck = QualifyModel::check($model); 60 | if (! $modelCheck['qualified']) { 61 | $this->omni->statusError('ERROR', 'Model not found', ['Model: '.$model]); 62 | 63 | return self::FAILURE; 64 | } 65 | $model = $modelCheck['qualified']; 66 | $this->model = $model; 67 | $this->indexModel = Lens::fetchIndexModelClass($this->model); 68 | $this->newLine(); 69 | $name = Str::plural($this->model); 70 | render((string) view('elasticlens::cli.components.title', ['title' => 'Rebuild '.$name, 'color' => 'cyan'])); 71 | $this->newLine(); 72 | $health = new LensState($this->indexModel); 73 | $this->baseModel = $health->baseModel; 74 | if (! $health->indexExists) { 75 | $this->omni->statusError('ERROR', $health->indexModelTable.' index not found'); 76 | 77 | $this->migrate = null; 78 | $this->migrationStep(); 79 | } 80 | $health = new LensState($this->indexModel); 81 | 82 | if (! $health->indexExists) { 83 | $this->omni->statusError('ERROR', 'Index required', [ 84 | 'Migrate to create the "'.$health->indexModelTable.'" index', 85 | ]); 86 | 87 | return self::FAILURE; 88 | } 89 | $this->setChunkRate($health); 90 | $built = $this->processAsyncBuild($health, $this->model); 91 | 92 | return $built ? self::SUCCESS : self::FAILURE; 93 | } 94 | 95 | /** 96 | * @throws Exception 97 | */ 98 | private function processAsyncBuild($health, $model): bool 99 | { 100 | try { 101 | $recordsCount = $health->baseModel::count(); 102 | } catch (Exception $e) { 103 | $this->omni->statusError('ERROR', 'Base Model not found', [ 104 | $e->getMessage(), 105 | ]); 106 | 107 | return false; 108 | } 109 | if (! $recordsCount) { 110 | $this->omni->statusWarning('BUILD SKIPPED', 'No records found for '.$health->baseModel); 111 | 112 | return false; 113 | } 114 | $this->startTimer(); 115 | 116 | $async = asyncFunction(function () {}); 117 | $async->render((string) view('elasticlens::cli.bulk', [ 118 | 'screenWidth' => $async->getScreenWidth(), 119 | 'model' => $this->model, 120 | 'i' => $async->getInterval(), 121 | 'created' => $this->created, 122 | 'skipped' => $this->skipped, 123 | 'updated' => $this->modified, 124 | 'failed' => $this->failed, 125 | 'completed' => false, 126 | 'took' => false, 127 | ])); 128 | $this->baseModel::chunk($this->chunkRate, function ($records) use ($async) { 129 | $result = $async->withTask(function () use ($records) { 130 | return $this->bulkInsertTask($records); 131 | })->run(function () use ($async) { 132 | $async->render((string) view('elasticlens::cli.bulk', [ 133 | 'screenWidth' => $async->getScreenWidth(), 134 | 'model' => $this->model, 135 | 'i' => $async->getInterval(), 136 | 'created' => $this->created, 137 | 'skipped' => $this->skipped, 138 | 'updated' => $this->modified, 139 | 'failed' => $this->failed, 140 | 'completed' => false, 141 | 'took' => false, 142 | ])); 143 | }); 144 | $this->created += $result['created']; 145 | $this->skipped += $result['skipped']; 146 | $this->modified += $result['modified']; 147 | $this->failed += $result['failed']; 148 | }); 149 | 150 | $async->render((string) view('elasticlens::cli.bulk', [ 151 | 'screenWidth' => $async->getScreenWidth(), 152 | 'model' => $model, 153 | 'i' => $async->getInterval(), 154 | 'created' => $this->created, 155 | 'skipped' => $this->skipped, 156 | 'updated' => $this->modified, 157 | 'failed' => $this->failed, 158 | 'completed' => true, 159 | ])); 160 | 161 | $this->newLine(); 162 | $name = Str::plural($model); 163 | $total = $this->created + $this->modified; 164 | $time = $this->getTime(); 165 | if ($total > 0) { 166 | $total = number_format($total); 167 | $this->omni->info('Indexed '.$total.' '.$name.' in '.$time['sec'].' seconds'); 168 | } else { 169 | $this->omni->error('All indexes failed to build'); 170 | } 171 | 172 | $this->newLine(); 173 | 174 | return true; 175 | } 176 | 177 | /** 178 | * @throws Exception 179 | */ 180 | public function bulkInsertTask($records): array 181 | { 182 | $bulk = new BulkIndexer($this->baseModel); 183 | $bulk->setRecords($records)->build(); 184 | $result = $bulk->getResult(); 185 | 186 | return [ 187 | 'total' => $result['results']['total'], 188 | 'created' => $result['results']['created'], 189 | 'skipped' => $result['results']['skipped'], 190 | 'modified' => $result['results']['modified'], 191 | 'failed' => $result['results']['failed'], 192 | ]; 193 | } 194 | 195 | public function setChunkRate(LensState $health): void 196 | { 197 | $chunk = $this->chunkRate; 198 | $relationships = count($health->indexModelInstance->getRelationships()); 199 | if ($relationships > 3) { 200 | $chunk = 750; 201 | } 202 | if ($relationships > 6) { 203 | $chunk = 500; 204 | } 205 | if ($relationships > 9) { 206 | $chunk = 250; 207 | } 208 | $this->chunkRate = $chunk; 209 | } 210 | } 211 | -------------------------------------------------------------------------------- /src/Commands/LensCommands.php: -------------------------------------------------------------------------------- 1 | newLine(); 23 | $this->omni->status($loadError['status'], $loadError['name'], $loadError['title'], $loadError['help']); 24 | 25 | $this->newLine(); 26 | 27 | return false; 28 | } 29 | try { 30 | $check = HealthCheck::check($model); 31 | if (! empty($check['configStatusHelp']['critical'])) { 32 | foreach ($check['configStatusHelp']['critical'] as $critical) { 33 | $this->newLine(); 34 | $this->omni->statusError('ERROR', $critical['name'], $critical['help']); 35 | } 36 | $this->newLine(); 37 | 38 | return false; 39 | } 40 | } catch (Exception $e) { 41 | $this->newLine(); 42 | $this->omni->statusError('ERROR', $e->getMessage()); 43 | $this->newLine(); 44 | 45 | return false; 46 | } 47 | 48 | return true; 49 | } 50 | 51 | // ---------------------------------------------------------------------- 52 | // Migrations 53 | // ---------------------------------------------------------------------- 54 | 55 | public function migrationStep(): void 56 | { 57 | while (! in_array($this->migrate, ['yes', 'no', 'y', 'n'])) { 58 | $this->migrate = $this->omni->ask('Migrate index?', ['yes', 'no']); 59 | } 60 | if (in_array($this->migrate, ['yes', 'y'])) { 61 | $this->migrationPassed = $this->processMigration($this->indexModel); 62 | } else { 63 | $this->migrationPassed = true; 64 | $this->newLine(); 65 | $this->omni->info('Index migration skipped'); 66 | } 67 | } 68 | 69 | private function processMigration($indexModel): bool 70 | { 71 | $this->omni->newLoader(); 72 | $result = $this->omni->runTask('Migrating Index', function () use ($indexModel) { 73 | try { 74 | $migration = new LensMigration($indexModel); 75 | $migration->runMigration(); 76 | 77 | return [ 78 | 'state' => 'success', 79 | 'message' => 'Migration Successful', 80 | 'details' => '', 81 | ]; 82 | } catch (Exception $e) { 83 | return [ 84 | 'state' => 'error', 85 | 'message' => 'Migration Failed', 86 | 'details' => $e->getMessage(), 87 | ]; 88 | } 89 | }); 90 | $this->newLine(); 91 | 92 | return ! empty($result['state']) && $result['state'] === 'success'; 93 | 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/Commands/LensHealthCommand.php: -------------------------------------------------------------------------------- 1 | initOmni(); 28 | $model = $this->argument('model'); 29 | 30 | $loadError = HealthCheck::loadErrorCheck($model); 31 | $this->newLine(); 32 | if ($loadError) { 33 | $this->omni->status($loadError['status'], $loadError['name'], $loadError['title'], $loadError['help']); 34 | $this->newLine(); 35 | 36 | return self::FAILURE; 37 | } 38 | $health = HealthCheck::check($model); 39 | render((string) view('elasticlens::cli.components.title', ['title' => $health['title'], 'color' => 'emerald'])); 40 | $this->omni->status($health['indexStatus']['status'], $health['indexStatus']['title'], $health['indexStatus']['name'], $health['indexStatus']['help'] ?? []); 41 | $this->newLine(); 42 | $this->omni->header('Index Model', 'Value'); 43 | foreach ($health['indexData'] as $detail => $value) { 44 | $this->omni->row($detail, $value); 45 | } 46 | $this->newLine(); 47 | $this->omni->header('Base Model', 'Value'); 48 | foreach ($health['modelData'] as $detail => $value) { 49 | $this->omni->row($detail, $value); 50 | } 51 | $this->newLine(); 52 | $this->omni->header('Build Data', 'Value'); 53 | foreach ($health['buildData'] as $detail => $value) { 54 | $this->omni->row($detail, $value); 55 | } 56 | $this->omni->status($health['configStatus']['status'], $health['configStatus']['name'], $health['configStatus']['title'], $health['configStatus']['help'] ?? []); 57 | $this->newLine(); 58 | $this->omni->header('Config', 'Value'); 59 | foreach ($health['configData'] as $detail => $value) { 60 | $this->omni->row($detail, $value); 61 | } 62 | $this->newLine(); 63 | if (! $health['observers']) { 64 | $this->omni->warning('No observers found'); 65 | } else { 66 | $this->omni->header('Observed Model', 'Type'); 67 | foreach ($health['observers'] as $observer) { 68 | $this->omni->row($observer['key'], $observer['value']); 69 | } 70 | } 71 | if ($health['configStatusHelp']['critical'] || $health['configStatusHelp']['warning']) { 72 | $this->newLine(); 73 | $this->omni->info('Config Help'); 74 | if ($health['configStatusHelp']['critical']) { 75 | foreach ($health['configStatusHelp']['critical'] as $critical) { 76 | $this->omni->statusError('Config Error', $critical['name'], $critical['help'] ?? []); 77 | $this->newLine(); 78 | } 79 | } 80 | if ($health['configStatusHelp']['warning']) { 81 | foreach ($health['configStatusHelp']['warning'] as $warning) { 82 | $this->omni->statusWarning('Config Recommendation', $warning['name'], $warning['help'] ?? []); 83 | $this->newLine(); 84 | } 85 | } 86 | } 87 | 88 | return self::SUCCESS; 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/Commands/LensMakeCommand.php: -------------------------------------------------------------------------------- 1 | initOmni(); 27 | 28 | $this->newLine(); 29 | $model = $this->argument('model'); 30 | // ensure casing is correct 31 | $model = Str::studly($model); 32 | 33 | // Check if model exists 34 | $modelFound = null; 35 | $indexedModel = null; 36 | $namespaces = config('elasticlens.namespaces'); 37 | $paths = config('elasticlens.index_paths'); 38 | 39 | $config = [ 40 | 'model' => [ 41 | 'name' => '', 42 | 'namespace' => '', 43 | 'full' => '', 44 | ], 45 | 'index' => [ 46 | 'name' => '', 47 | 'namespace' => '', 48 | 'full' => '', 49 | 'path' => '', 50 | ], 51 | 52 | ]; 53 | 54 | $notFound = []; 55 | foreach ($namespaces as $modelNamespace => $indexNameSpace) { 56 | $modelCheck = $modelNamespace.'\\'.$model; 57 | if ($this->class_exists_case_sensitive($modelCheck)) { 58 | $modelFound = $modelCheck; 59 | $config['model']['name'] = $model; 60 | $config['model']['namespace'] = $modelNamespace; 61 | $config['model']['full'] = $modelCheck; 62 | $config['index']['name'] = 'Indexed'.$model; 63 | $config['index']['namespace'] = $indexNameSpace; 64 | $config['index']['full'] = $indexNameSpace.'\\'.$config['index']['name']; 65 | 66 | $path = array_search($indexNameSpace, $paths); 67 | if (! $path) { 68 | $this->omni->statusError('ERROR', 'Path for namespace '.$indexNameSpace.' not found', [ 69 | 'Namespace found: '.$indexNameSpace, 70 | 'Check config("elasticlens.index_paths") for the correct {path} => \''.$indexNameSpace.'\'', 71 | ]); 72 | $this->newLine(); 73 | 74 | return self::FAILURE; 75 | } 76 | $config['index']['path'] = $path; 77 | break; 78 | } else { 79 | $notFound[] = $modelCheck; 80 | } 81 | } 82 | 83 | if (! $modelFound) { 84 | foreach ($notFound as $modelCheck) { 85 | $this->omni->statusError('ERROR', 'Base Model ('.$model.') was not found at: '.$modelCheck); 86 | $this->newLine(); 87 | } 88 | 89 | return self::FAILURE; 90 | } 91 | if ($this->class_exists_case_sensitive($config['index']['full'])) { 92 | $this->omni->statusError('ERROR', 'Indexed Model (for '.$model.' Model) already exists at: '.$config['index']['full']); 93 | 94 | return self::FAILURE; 95 | } 96 | 97 | $path = $config['index']['path'].$config['index']['name']; 98 | 99 | $finalPath = $this->getPath($path); 100 | // Make sure the directory exists 101 | $this->makeDirectory($finalPath); 102 | 103 | // Get the stub file contents 104 | $stub = $this->files->get($this->getStub()); 105 | // Replace the stub variables 106 | $stub = $this->replaceNamespaceCustom($stub, $config['model']['namespace']); 107 | $stub = $this->replaceModel($stub, $config['model']['name']); 108 | 109 | // Write the file to disk 110 | $this->files->put($finalPath, $stub); 111 | 112 | $this->omni->statusSuccess('SUCCESS', 'Indexed Model (for '.$model.' Model) created at: '.$indexedModel); 113 | $this->omni->statusInfo('1', 'Add the Indexable trait to your '.$model.' model'); 114 | render((string) view('elasticlens::cli.components.code-trait', ['model' => $model])); 115 | $this->newLine(); 116 | $this->omni->statusInfo('2', 'Then run: "php artisan lens:build '.$model.'" to index your model'); 117 | 118 | return self::SUCCESS; 119 | } 120 | 121 | protected $type = 'Model'; 122 | 123 | protected function getStub(): string 124 | { 125 | $stubPath = __DIR__.'/../../resources/stubs/IndexedBase.php.stub'; 126 | 127 | if (! file_exists($stubPath)) { 128 | throw new RuntimeException('Stub file not found: '.$stubPath); 129 | } 130 | 131 | return $stubPath; 132 | } 133 | 134 | protected function getPath($name) 135 | { 136 | $name = Str::replaceFirst($this->rootNamespace(), '', $name); 137 | 138 | return $this->laravel['path.base'].'/'.str_replace('\\', '/', $name).'.php'; 139 | } 140 | 141 | public function replaceNamespaceCustom($stub, $namespace): string 142 | { 143 | return str_replace('{{ namespace }}', $namespace, $stub); 144 | } 145 | 146 | public function replaceModel($stub, $name): string 147 | { 148 | return str_replace('{{ model }}', $name, $stub); 149 | } 150 | 151 | public function class_exists_case_sensitive(string $class_name): bool 152 | { 153 | if (in_array($class_name, get_declared_classes(), true)) { 154 | return true; 155 | } 156 | 157 | try { 158 | $reflectionClass = new ReflectionClass($class_name); 159 | 160 | return $reflectionClass->getName() === $class_name; 161 | } catch (Exception $e) { 162 | // Class doesn't exist or couldn't be autoloaded 163 | return false; 164 | } 165 | 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /src/Commands/LensMigrateCommand.php: -------------------------------------------------------------------------------- 1 | false, 32 | 'processed' => 0, 33 | 'success' => 0, 34 | 'skipped' => 0, 35 | 'failed' => 0, 36 | 'total' => 0, 37 | 'state' => 'error', 38 | 'message' => '', 39 | ]; 40 | 41 | protected bool $buildPassed = false; 42 | 43 | /** 44 | * @throws Exception 45 | */ 46 | public function handle(): int 47 | { 48 | $this->initOmni(); 49 | $model = $this->argument('model'); 50 | $force = $this->option('force'); 51 | $modelCheck = QualifyModel::check($model); 52 | if (! $modelCheck['qualified']) { 53 | $this->omni->statusError('ERROR', 'Model not found', ['Model: '.$model]); 54 | 55 | return self::FAILURE; 56 | } 57 | $model = $modelCheck['qualified']; 58 | $this->model = $model; 59 | $this->indexModel = Lens::fetchIndexModelClass($model); 60 | $this->newLine(); 61 | render((string) view('elasticlens::cli.components.title', ['title' => 'Migrate and Build '.class_basename($this->indexModel), 'color' => 'sky'])); 62 | $this->newLine(); 63 | $this->migrate = $force ? 'yes' : null; 64 | $this->build = $force ? 'yes' : null; 65 | $this->migrationStep(); 66 | $this->newLine(); 67 | $this->buildStep(); 68 | $this->newLine(); 69 | $this->showStatus(); 70 | $this->newLine(); 71 | 72 | return $this->buildPassed ? self::SUCCESS : self::FAILURE; 73 | } 74 | 75 | // ---------------------------------------------------------------------- 76 | // Builds 77 | // ---------------------------------------------------------------------- 78 | 79 | public function buildStep(): void 80 | { 81 | $question = 'Rebuild Indexes?'; 82 | if (! $this->migrationPassed) { 83 | $question = 'Migration Failed. Build Anyway?'; 84 | } 85 | while (! in_array($this->build, ['yes', 'no', 'y', 'n'])) { 86 | $this->build = $this->omni->ask($question, ['yes', 'no']); 87 | } 88 | if (in_array($this->build, ['yes', 'y'])) { 89 | $this->buildPassed = $this->processBuild($this->indexModel); 90 | } else { 91 | $this->omni->info('Build Cancelled'); 92 | $this->buildPassed = false; 93 | } 94 | } 95 | 96 | private function processBuild($indexModel): bool 97 | { 98 | try { 99 | $builder = new LensBuilder($indexModel); 100 | $recordsCount = $builder->baseModel::count(); 101 | } catch (Exception $e) { 102 | $this->omni->statusError('ERROR', 'Base Model not found', [$e->getMessage()]); 103 | 104 | return false; 105 | } 106 | if (! $recordsCount) { 107 | $this->omni->statusWarning('BUILD SKIPPED', 'No records found for '.$builder->baseModel); 108 | 109 | return false; 110 | } 111 | $this->buildData['didRun'] = true; 112 | $this->buildData['total'] = $recordsCount; 113 | $this->omni->createSimpleProgressBar($this->buildData['total']); 114 | $migrationVersion = $builder->fetchCurrentMigrationVersion(); 115 | $builder->baseModel::chunk(100, function ($records) use ($builder, $migrationVersion) { 116 | foreach ($records as $record) { 117 | $id = $record->{$builder->baseModelPrimaryKey}; 118 | $build = $builder->buildIndex($id, 'Index Rebuild', $migrationVersion); 119 | $this->buildData['processed']++; 120 | if (! empty($build->success)) { 121 | $this->buildData['success']++; 122 | } elseif (! empty($build->skipped)) { 123 | $this->buildData['skipped']++; 124 | } else { 125 | $this->buildData['failed']++; 126 | } 127 | $this->omni->progressAdvance(); 128 | } 129 | }); 130 | $this->omni->progressFinish(); 131 | $this->buildData['state'] = 'success'; 132 | $this->buildData['message'] = 'Indexes Synced'; 133 | if ($this->buildData['failed']) { 134 | $this->buildData['state'] = 'warning'; 135 | $this->buildData['message'] = 'Some Build Errors'; 136 | if ($this->buildData['failed'] === $this->buildData['processed']) { 137 | $this->buildData['state'] = 'error'; 138 | $this->buildData['message'] = 'All Builds Failed'; 139 | } 140 | } 141 | 142 | return true; 143 | } 144 | 145 | private function showStatus(): void 146 | { 147 | if ($this->buildData['didRun']) { 148 | $this->omni->header('Build Data', 'Value'); 149 | $this->omni->row('Success', $this->buildData['success'], null, 'text-emerald-500'); 150 | $this->omni->row('Skipped', $this->buildData['skipped'], null, 'text-amber-500'); 151 | $this->omni->row('Failed', $this->buildData['failed'], null, 'text-rose-500'); 152 | $this->omni->row('Total', $this->buildData['total'], null, 'text-emerald-500'); 153 | $this->newLine(); 154 | $this->omni->status($this->buildData['state'], 'Build Status', $this->buildData['message']); 155 | $this->newLine(); 156 | } 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /src/Commands/LensStatusCommand.php: -------------------------------------------------------------------------------- 1 | initOmni(); 25 | 26 | $this->newLine(); 27 | render((string) view('elasticlens::cli.components.title', ['title' => 'ElasticLens Status', 'color' => 'teal'])); 28 | $this->newLine(); 29 | $checks = ConfigCheck::check(); 30 | $indexes = IndexCheck::get(); 31 | 32 | $this->omni->header('Config', 'Status', 'Value'); 33 | foreach ($checks as $check) { 34 | $this->omni->rowAsStatus($check['label'], $check['status'], $check['extra'] ?? null, $check['help'] ?? []); 35 | } 36 | $this->newLine(2); 37 | if (! empty($indexes)) { 38 | foreach ($indexes as $index) { 39 | $this->omni->status($index['indexStatus']['status'], $index['name'], $index['indexStatus']['name'], $index['indexStatus']['help'] ?? []); 40 | foreach ($index['checks'] as $check) { 41 | $this->omni->rowAsStatus($check['label'], $check['status'], $check['extra'] ?? null, $check['help'] ?? []); 42 | } 43 | $this->newLine(2); 44 | } 45 | } else { 46 | $this->omni->statusError('ERROR', 'No indexes found'); 47 | $this->newLine(2); 48 | } 49 | 50 | return self::SUCCESS; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/Commands/Scripts/ConfigCheck.php: -------------------------------------------------------------------------------- 1 | [ 20 | 'label' => 'Config File', 21 | 'extra' => 'ElasticLens.php', 22 | 'status' => 'error', 23 | 'help' => [ 24 | 'Unexpected error: Config (ElasticLens.php) could not be loaded from anywhere', 25 | ], 26 | ], 27 | ]; 28 | } 29 | $output = []; 30 | // ---------------------------------------------------------------------- 31 | // Config File 32 | // ---------------------------------------------------------------------- 33 | self::checkConfig($output); 34 | $connectionOK = self::checkConnection($output); 35 | self::checkQueue($output); 36 | if ($connectionOK) { 37 | self::buildIndexableBuildState($output); 38 | } 39 | 40 | return $output; 41 | } 42 | 43 | private static function checkConfig(&$output): void 44 | { 45 | $output['config_file'] = [ 46 | 'label' => 'Config File', 47 | 'extra' => 'elasticlens.php', 48 | 'status' => 'ok', 49 | ]; 50 | if (! file_exists(config_path('elasticlens.php'))) { 51 | $output['config_file']['status'] = 'warning'; 52 | $output['config_file']['help'] = [ 53 | 'Config file not published, using package default. Run: `php artisan lens:install` to publish', 54 | ]; 55 | } 56 | } 57 | 58 | private static function checkConnection(&$output): bool 59 | { 60 | $connection = config('elasticlens.database'); 61 | $output['connection'] = [ 62 | 'label' => 'Connection', 63 | 'extra' => $connection, 64 | 'status' => 'ok', 65 | ]; 66 | if (! $connection) { 67 | $output['connection']['status'] = 'error'; 68 | $output['connection']['help'] = [ 69 | "Connection empty in elasticlens.php config. Set 'database' => 'elasticsearch' in config file", 70 | ]; 71 | 72 | return false; 73 | } 74 | try { 75 | Schema::on($connection)->getIndices(); 76 | } catch (Exception $e) { 77 | $output['connection']['status'] = 'error'; 78 | $output['connection']['help'] = [ 79 | 'Connection error on ['.$connection.']: '.$e->getMessage(), 80 | ]; 81 | 82 | // If we get here, return output 83 | return false; 84 | 85 | } 86 | 87 | return true; 88 | } 89 | 90 | private static function checkQueue(&$output): void 91 | { 92 | $output['queue'] = [ 93 | 'label' => 'Queue Priority', 94 | 'extra' => config('elasticlens.queue') ?? 'default (not set)', 95 | 'status' => 'ok', 96 | ]; 97 | } 98 | 99 | private static function buildIndexableBuildState(&$output): void 100 | { 101 | 102 | $enabled = config('elasticlens.index_build_state.enabled') ?? false; 103 | $trim = config('elasticlens.index_build_state.log_trim') ?? false; 104 | $output['indexable_state'] = [ 105 | 'label' => 'IndexableBuildState Model', 106 | 'extra' => '', 107 | 'status' => 'ok', 108 | ]; 109 | if (! $enabled) { 110 | $output['indexable_state']['status'] = 'disabled'; 111 | $output['indexable_state']['help'] = [ 112 | 'Indexable State Tracking is disabled. Build logs will not be tracked in the indexable_states index', 113 | ]; 114 | 115 | return; 116 | } 117 | $output['indexable_state_connect'] = [ 118 | 'label' => 'IndexableBuildState Connection', 119 | 'extra' => '', 120 | 'status' => 'ok', 121 | ]; 122 | $hasIndex = IndexableBuild::checkHasIndex(); 123 | if (! $hasIndex) { 124 | $output['indexable_state_connect']['status'] = 'error'; 125 | $output['indexable_state_connect']['help'] = [ 126 | 'Indexable State Tracking index not found. Run: php artisan lens:install', 127 | ]; 128 | } 129 | $output['indexable_state_log_trim'] = [ 130 | 'label' => 'Indexable States Log trim', 131 | 'extra' => $trim, 132 | 'status' => 'ok', 133 | ]; 134 | if (! $trim) { 135 | $output['indexable_state_log_trim']['status'] = 'disabled'; 136 | $output['indexable_state_log_trim']['help'] = [ 137 | 'Logs will not be stored in the indexable_states index', 138 | ]; 139 | } 140 | 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /src/Commands/Scripts/HealthCheck.php: -------------------------------------------------------------------------------- 1 | 'ERROR', 25 | 'status' => 'error', 26 | 'title' => 'Base Model not found', 27 | 'help' => $help, 28 | ]; 29 | } 30 | 31 | $indexModel = Lens::fetchIndexModelClass($modelClass); 32 | if (! class_exists($indexModel)) { 33 | return [ 34 | 'name' => 'ERROR', 35 | 'status' => 'error', 36 | 'title' => 'Index Model not found', 37 | 'help' => [ 38 | $indexModel.' could not be found for base model: '.$model, 39 | ], 40 | ]; 41 | } 42 | try { 43 | $index = new LensState($indexModel); 44 | $index->healthCheck(); 45 | } catch (Exception $e) { 46 | return [ 47 | 'name' => 'ERROR', 48 | 'status' => 'error', 49 | 'title' => 'Load Error', 50 | 'help' => [ 51 | $e->getMessage(), 52 | ], 53 | ]; 54 | } 55 | 56 | return false; 57 | } 58 | 59 | /** 60 | * @throws Exception 61 | */ 62 | public static function check($model): array 63 | { 64 | $modelCheck = QualifyModel::check($model); 65 | $model = $modelCheck['qualified']; 66 | if (! $model) { 67 | return [ 68 | 'name' => 'ERROR', 69 | 'status' => 'error', 70 | 'title' => 'Base Model not found', 71 | 'help' => $modelCheck['notFound'], 72 | ]; 73 | } 74 | $indexModel = Lens::fetchIndexModelClass($model); 75 | $index = new LensState($indexModel); 76 | $index = $index->healthCheck(); 77 | 78 | $health['title'] = $model.' → '.$index['indexModel']; 79 | $health['name'] = $index['name']; 80 | $health['indexStatus'] = $index['state']['status']; 81 | $health['indexStatus']['title'] = 'Index Status'; 82 | $health['indexData'] = $index['state']['index']; 83 | $health['modelData'] = $index['state']['model']; 84 | $health['buildData'] = $index['state']['builds']; 85 | $health['configStatus'] = [ 86 | 'name' => $index['config']['status']['name'], 87 | 'status' => $index['config']['status']['status'], 88 | 'title' => 'Config Status', 89 | ]; 90 | self::setConfig($health, $index); 91 | self::setObservers($health, $index); 92 | $health['configStatusHelp'] = [ 93 | 'critical' => $index['config']['status']['critical'], 94 | 'warning' => $index['config']['status']['warning'], 95 | ]; 96 | 97 | return $health; 98 | } 99 | 100 | private static function setObservers(&$health, $index): void 101 | { 102 | $base = $index['config']['observers']['base']; 103 | $embedded = $index['config']['observers']['embedded']; 104 | $health['observers'] = []; 105 | if ($base) { 106 | $health['observers'][] = [ 107 | 'key' => class_basename($base), 108 | 'value' => '(Base)', 109 | ]; 110 | } 111 | if (! empty($embedded)) { 112 | foreach ($embedded as $embed) { 113 | if ($embed['observe']) { 114 | $chain = []; 115 | self::buildTriggerChain($embed, $chain); 116 | 117 | $chain = implode(' → ', $chain); 118 | 119 | $health['observers'][] = [ 120 | 'key' => class_basename($embed['relation']), 121 | 'value' => 'Triggers → '.$chain, 122 | ]; 123 | } 124 | } 125 | } 126 | } 127 | 128 | public static function buildTriggerChain($embed, &$chain): void 129 | { 130 | $chain[] = class_basename($embed['model']); 131 | if (! empty($embed['upstream'])) { 132 | self::buildTriggerChain($embed['upstream'], $chain); 133 | } 134 | } 135 | 136 | private static function setConfig(&$health, $index): void 137 | { 138 | 139 | $health['configData'] = [ 140 | 'baseModelIndexable' => $index['config']['base_model_indexable'], 141 | 'baseModelSet' => $index['config']['base_model'], 142 | 'fieldMapSet' => $index['config']['field_map'], 143 | 'migrationMapSet' => $index['config']['migration']['has'], 144 | ]; 145 | if ($index['config']['migration']['version']) { 146 | $health['configData']['migrationVersion'] = $index['config']['migration']['version']; 147 | } 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /src/Commands/Scripts/IndexCheck.php: -------------------------------------------------------------------------------- 1 | healthCheck(); 22 | } catch (Exception $e) { 23 | // skip, I guess 24 | } 25 | } 26 | } 27 | $states = []; 28 | foreach ($indexes as $index) { 29 | $state['name'] = $index['name']; 30 | $state['indexStatus'] = $index['state']['status']; 31 | $state['checks'] = []; 32 | self::indexState($state['checks'], $index); 33 | self::modelState($state['checks'], $index); 34 | self::buildState($state['checks'], $index); 35 | self::configState($state['checks'], $index); 36 | 37 | $states[] = $state; 38 | } 39 | 40 | return $states; 41 | } 42 | 43 | public static function indexState(&$checks, $index): void 44 | { 45 | $modelName = $index['state']['index']['modelName']; 46 | $table = $index['state']['index']['table']; 47 | $accessible = $index['state']['index']['accessible']; 48 | $records = $index['state']['index']['records']; 49 | $checks['indexModel'] = [ 50 | 'label' => $modelName, 51 | 'extra' => 'accessible', 52 | 'status' => 'ok', 53 | ]; 54 | if (! $accessible) { 55 | $checks['indexModel']['status'] = 'error'; 56 | $checks['indexModel']['help'] = [ 57 | 'Index model not accessible', 58 | ]; 59 | } 60 | $checks['indexModelRecords'] = [ 61 | 'label' => $table.' records', 62 | 'extra' => $records, 63 | 'status' => 'ok', 64 | ]; 65 | if (! $records) { 66 | $checks['indexModelRecords']['status'] = 'warning'; 67 | } 68 | } 69 | 70 | public static function modelState(&$checks, $index): void 71 | { 72 | $defined = $index['state']['model']['defined']; 73 | $modelName = $index['state']['model']['modelName']; 74 | $table = $index['state']['model']['table']; 75 | $accessible = $index['state']['model']['accessible']; 76 | $records = $index['state']['model']['records']; 77 | if (! $defined) { 78 | $checks['model'] = [ 79 | 'label' => $modelName, 80 | 'extra' => 'defined', 81 | 'status' => 'error', 82 | 'help' => [ 83 | 'Model not defined', 84 | ], 85 | ]; 86 | 87 | return; 88 | } 89 | 90 | $checks['model'] = [ 91 | 'label' => $modelName, 92 | 'extra' => 'accessible', 93 | 'status' => 'ok', 94 | ]; 95 | if (! $accessible) { 96 | $checks['model']['status'] = 'error'; 97 | $checks['model']['help'] = [ 98 | 'Index model not accessible', 99 | ]; 100 | } 101 | $checks['modelRecords'] = [ 102 | 'label' => $table.' records', 103 | 'extra' => $records, 104 | 'status' => 'ok', 105 | ]; 106 | if (! $records) { 107 | $checks['modelRecords']['status'] = 'warning'; 108 | } 109 | } 110 | 111 | public static function buildState(&$checks, $index): void 112 | { 113 | $builds = $index['state']['builds']; 114 | $total = $builds['total']; 115 | $errors = $builds['errors']; 116 | $success = $builds['success']; 117 | if (! $total) { 118 | $checks['builds'] = [ 119 | 'label' => 'Builds (IndexedState)', 120 | 'extra' => 'No builds found', 121 | 'status' => 'warning', 122 | ]; 123 | 124 | return; 125 | } 126 | if ($errors) { 127 | $checks['builds'] = [ 128 | 'label' => 'Builds (IndexedState)', 129 | 'extra' => $total.' ('.$errors.')', 130 | 'status' => 'error', 131 | 'help' => [ 132 | $errors.' index(es) could not be built, check logs', 133 | ], 134 | ]; 135 | 136 | return; 137 | } 138 | $checks['builds'] = [ 139 | 'label' => 'Builds (IndexedState)', 140 | 'extra' => $total, 141 | 'status' => 'ok', 142 | ]; 143 | 144 | } 145 | 146 | public static function configState(&$checks, $index): void 147 | { 148 | $config = $index['config']; 149 | $status = $config['status']; 150 | $checks['config'] = [ 151 | 'label' => 'Config State', 152 | 'extra' => $status['name'], 153 | 'status' => $status['status'], 154 | ]; 155 | if ($status['status'] !== 'ok') { 156 | $checks['config']['help'] = [ 157 | 'For details, run: php artisan lens:health '.$index['state']['model']['modelName'], 158 | ]; 159 | } 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /src/Commands/Scripts/QualifyModel.php: -------------------------------------------------------------------------------- 1 | $indexNameSpace) { 13 | $modelPath = $modelNameSpace.'\\'.$model; 14 | if (class_exists($modelPath)) { 15 | $found = $modelPath; 16 | } else { 17 | $notFound[] = $modelPath; 18 | } 19 | } 20 | 21 | return [ 22 | 'qualified' => $found, 23 | 'notFound' => $notFound, 24 | ]; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/ElasticLensServiceProvider.php: -------------------------------------------------------------------------------- 1 | name('elasticlens') 27 | ->hasConfigFile() 28 | ->hasViews('elasticlens') 29 | ->hasMigrations(['create_indexable_build_index', 'create_indexable_migration_logs_index']) 30 | ->runsMigrations() 31 | ->hasCommand(LensHealthCommand::class) 32 | ->hasCommand(LensStatusCommand::class) 33 | ->hasCommand(LensBuildCommand::class) 34 | ->hasCommand(LensMigrateCommand::class) 35 | ->hasCommand(LensMakeCommand::class) 36 | ->hasInstallCommand(function (InstallCommand $command) { 37 | $command 38 | ->setName('lens:install') 39 | ->publishConfigFile() 40 | ->publishMigrations() 41 | ->askToRunMigrations() 42 | ->copyAndRegisterServiceProviderInApp() 43 | ->askToStarRepoOnGitHub('pdphilip/elasticlens'); 44 | }); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/Enums/IndexableBuildState.php: -------------------------------------------------------------------------------- 1 | 'slate', 18 | IndexableBuildState::SUCCESS => 'emerald', 19 | IndexableBuildState::SKIPPED => 'emerald', 20 | IndexableBuildState::FAILED => 'rose', 21 | }; 22 | } 23 | 24 | public function label(): string 25 | { 26 | return match ($this) { 27 | IndexableBuildState::INIT => 'Build Initializing', 28 | IndexableBuildState::SUCCESS => 'Index Build Successful', 29 | IndexableBuildState::SKIPPED => 'Index Build Skipped', 30 | IndexableBuildState::FAILED => 'Index Build Failed', 31 | }; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Enums/IndexableMigrationLogState.php: -------------------------------------------------------------------------------- 1 | 'slate', 17 | IndexableMigrationLogState::SUCCESS => 'emerald', 18 | IndexableMigrationLogState::FAILED => 'rose', 19 | }; 20 | } 21 | 22 | public function label(): string 23 | { 24 | return match ($this) { 25 | IndexableMigrationLogState::UNDEFINED => 'No Blueprint defined', 26 | IndexableMigrationLogState::SUCCESS => 'Migration Successful', 27 | IndexableMigrationLogState::FAILED => 'Migration Failed', 28 | }; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/HasWatcher.php: -------------------------------------------------------------------------------- 1 | id = $id; 34 | $this->model = $model; 35 | $this->migration_version = $migrationVersion; 36 | $this->startTimer(); 37 | } 38 | 39 | public function setMessage($msg, $details = ''): void 40 | { 41 | $this->msg = $msg; 42 | $this->details = $details; 43 | } 44 | 45 | public function setMap($map): void 46 | { 47 | $this->map = $map; 48 | } 49 | 50 | public function failed(): static 51 | { 52 | $this->success = false; 53 | $this->took = $this->getTime(); 54 | 55 | return $this; 56 | } 57 | 58 | public function successful($details = ''): static 59 | { 60 | $this->details = $details; 61 | $this->success = true; 62 | $this->took = $this->getTime(); 63 | 64 | return $this; 65 | } 66 | 67 | public function attachMigrationVersion($version): static 68 | { 69 | $this->migration_version = $version; 70 | 71 | return $this; 72 | } 73 | 74 | public function toArray(): array 75 | { 76 | return [ 77 | 'id' => $this->id, 78 | 'model' => $this->model, 79 | 'success' => $this->success, 80 | 'msg' => $this->msg, 81 | 'details' => $this->details, 82 | 'map' => $this->map, 83 | 'migration_version' => $this->migration_version, 84 | 'took' => $this->took, 85 | ]; 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/Index/BulkIndexer.php: -------------------------------------------------------------------------------- 1 | baseModel = $baseModel; 38 | $this->indexModel = Lens::fetchIndexModelClass($baseModel); 39 | $this->builder = new LensBuilder($this->indexModel); 40 | if (! $this->builder->baseModel) { 41 | throw new Exception('BulkIndexing not available for "'.$this->indexModel.'": BaseModel not set - Set property: `protected $baseModel = User::class;`'); 42 | } 43 | 44 | if (! $this->builder->baseModelIndexable) { 45 | throw new Exception('BulkIndexing not available for "'.$this->indexModel.'": Base model not indexable - Add trait to base model: `use Indexable`'); 46 | } 47 | 48 | $this->migrationVersion = $this->builder->fetchCurrentMigrationVersion(); 49 | } 50 | 51 | public function setRecords($records): static 52 | { 53 | $this->clearActivity(); 54 | $builds = []; 55 | if ($records) { 56 | foreach ($records as $record) { 57 | $id = $record->{$this->builder->baseModelPrimaryKey}; 58 | $setup = $this->builder->prepareMap($id); 59 | $setup->attachMigrationVersion($this->migrationVersion); 60 | // Assume ok 61 | if (! $setup->skipped) { 62 | $setup->successful('Bulk Ok'); 63 | } 64 | 65 | $builds[$id] = $setup; 66 | } 67 | } 68 | 69 | $this->buildMaps = $builds; 70 | 71 | return $this; 72 | } 73 | 74 | public function build(): static 75 | { 76 | $values = []; 77 | if ($this->buildMaps) { 78 | foreach ($this->buildMaps as $build) { 79 | if ($build->skipped) { 80 | $this->skipped++; 81 | 82 | continue; 83 | } 84 | $values[] = $build->map; 85 | } 86 | } 87 | if ($values) { 88 | $this->result = $this->indexModel::bulkInsert($values); 89 | } 90 | $this->updateAnyErrors(); 91 | $this->result['skipped'] = $this->skipped; 92 | BulkBuildStateUpdateJob::dispatch($this->indexModel, $this->baseModel, $this->buildMaps); 93 | $this->took = $this->getTime(); 94 | 95 | return $this; 96 | } 97 | 98 | public function updateAnyErrors(): void 99 | { 100 | if ($this->result) { 101 | if (! empty($this->result['error'])) { 102 | $builds = $this->buildMaps; 103 | foreach ($this->result['error'] as $error) { 104 | $id = $error['payload']['id'] ?? null; 105 | $build = $builds[$id]; 106 | $build->setMessage($error['error']['type'], $error['error']['reason']); 107 | $build->failed(); 108 | $builds[$id] = $build; 109 | } 110 | $this->buildMaps = $builds; 111 | } 112 | } 113 | } 114 | 115 | public function getResult(): array 116 | { 117 | return [ 118 | 'took' => $this->took, 119 | 'results' => $this->result, 120 | ]; 121 | } 122 | 123 | public function clearActivity(): void 124 | { 125 | $this->buildMaps = []; 126 | $this->startTimer(); 127 | 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /src/Index/LensBuilder.php: -------------------------------------------------------------------------------- 1 | _buildInit($id); 23 | $passed = true; 24 | try { 25 | $callback(); 26 | } catch (Exception $e) { 27 | $this->buildResult->setMessage('Custom Build Failed', 'Exception: '.$e->getMessage()); 28 | $passed = false; 29 | } 30 | if ($passed) { 31 | $this->buildResult->successful('Custom Build Passed'); 32 | } 33 | IndexableBuild::writeState($this->baseModelName, $id, $this->indexModelName, $this->buildResult, $source); 34 | 35 | return $this->buildResult; 36 | } 37 | 38 | public function prepareMap($id) 39 | { 40 | $this->_buildInit($id); 41 | $this->_buildMap(); 42 | 43 | return $this->buildResult; 44 | } 45 | 46 | public function buildIndex($id, $source, $migrationVersion = null): BuildResult 47 | { 48 | if (! $migrationVersion) { 49 | $migrationVersion = $this->fetchCurrentMigrationVersion(); 50 | } 51 | $this->buildProcess($id); 52 | $this->buildResult->attachMigrationVersion($migrationVersion); 53 | IndexableBuild::writeState($this->baseModelName, $id, $this->indexModelName, $this->buildResult, $source); 54 | 55 | return $this->buildResult; 56 | } 57 | 58 | public function dryRun($id): BuildResult 59 | { 60 | $this->_buildInit($id); 61 | $passedSetup = $this->checkSetup(); 62 | if (! $passedSetup) { 63 | return $this->buildResult->failed(); 64 | } 65 | $passedMapping = $this->_buildMap(); 66 | if (! $passedMapping) { 67 | return $this->buildResult->failed(); 68 | } 69 | 70 | return $this->buildResult->successful(); 71 | 72 | } 73 | 74 | protected function buildProcess($id): BuildResult 75 | { 76 | $this->_buildInit($id); 77 | $passedSetup = $this->checkSetup(); 78 | if (! $passedSetup) { 79 | return $this->buildResult->failed(); 80 | } 81 | $passedMapping = $this->_buildMap(); 82 | if (! $passedMapping) { 83 | return $this->buildResult->failed(); 84 | } 85 | 86 | $createdIndex = $this->_createIndex(); 87 | 88 | if (! $createdIndex) { 89 | return $this->buildResult->failed(); 90 | } 91 | 92 | return $this->buildResult->successful('Indexed successfully'); 93 | } 94 | 95 | // ---------------------------------------------------------------------- 96 | // Init 97 | // ---------------------------------------------------------------------- 98 | 99 | private function _buildInit($id): void 100 | { 101 | $this->buildResult = new BuildResult($id, $this->baseModel, 0); 102 | } 103 | 104 | // ---------------------------------------------------------------------- 105 | // Setup Check 106 | // ---------------------------------------------------------------------- 107 | 108 | public function checkSetup(): bool 109 | { 110 | 111 | if (! $this->baseModel) { 112 | $this->buildResult->setMessage('BaseModel not set', 'Set property: `protected $baseModel = User::class;`'); 113 | 114 | return false; 115 | } 116 | 117 | if (! $this->baseModelIndexable) { 118 | $this->buildResult->setMessage('Base model not indexable', 'Add trait to base model: `use Indexable`'); 119 | 120 | return false; 121 | } 122 | 123 | return true; 124 | } 125 | 126 | // ---------------------------------------------------------------------- 127 | // Mapping Process 128 | // ---------------------------------------------------------------------- 129 | 130 | private function _buildMap(): bool 131 | { 132 | $id = $this->buildResult->id; 133 | $fieldMap = $this->fieldMap; 134 | 135 | $model = $this->baseModelInstance->find($id); 136 | if (! $model) { 137 | $this->buildResult->setMessage('BaseModel not found', 'BaseModel '.$this->baseModel.' did not have a record for id: '.$id); 138 | 139 | return false; 140 | } 141 | 142 | if ($model->excludeIndex()) { 143 | $this->buildResult->skipped = true; 144 | $this->buildResult->setMessage('BaseModel excluded', 'BaseModel '.$this->baseModel.' has excludeIndex() set to true'); 145 | 146 | return false; 147 | } 148 | 149 | $data = $this->mapId($model, $fieldMap); 150 | try { 151 | $dataMap = $this->mapRecordsToFields($fieldMap, $model); 152 | } catch (Exception $e) { 153 | $this->buildResult->setMessage('Record Mapping Error', 'Exception: '.$e->getMessage()); 154 | 155 | return false; 156 | } 157 | $data = $data + $dataMap; 158 | 159 | $this->buildResult->setMap($data); 160 | 161 | return true; 162 | } 163 | 164 | private function mapId($model, $fieldMap): array 165 | { 166 | $data = []; 167 | $data['id'] = $model->{$model->getKeyName()}; 168 | if (isset($fieldMap['id'])) { 169 | $data['id'] = $this->setType($data['id'], $fieldMap['id']); 170 | unset($fieldMap['id']); 171 | } 172 | if (isset($fieldMap['id'])) { 173 | $data['id'] = $this->setType($data['id'], $fieldMap['id']); 174 | unset($fieldMap['id']); 175 | } 176 | 177 | return $data; 178 | } 179 | 180 | private function mapRecordsToFields($fields, $modelData): array 181 | { 182 | 183 | $data = []; 184 | if ($fields) { 185 | foreach ($fields as $field => $type) { 186 | if (is_array($type)) { 187 | $embedFields = $type; 188 | $data[$field] = $this->buildEmbeddedRelationship($field, $embedFields, $modelData); 189 | 190 | continue; 191 | } 192 | 193 | $value = $modelData->{$field} ?? null; 194 | if ($value) { 195 | $value = $this->setType($value, $type); 196 | } 197 | 198 | $data[$field] = $value; 199 | 200 | } 201 | 202 | return $data; 203 | } 204 | 205 | // Else, take what you can get. 206 | // ....I Also Like to Live Dangerously 207 | $data = $modelData->toArray(); 208 | 209 | // If this was the base model, kick the ID, we has it already 210 | if ($modelData instanceof $this->baseModel) { 211 | unset($data[$this->baseModelPrimaryKey]); 212 | } 213 | 214 | return $data; 215 | } 216 | 217 | private function buildEmbeddedRelationship($field, $embedFields, $parentData): array 218 | { 219 | $relationships = $this->relationships; 220 | $data = []; 221 | if (! empty($relationships[$field])) { 222 | $relationship = $relationships[$field]; 223 | $type = $relationship['type']; 224 | $relation = $relationship['relation']; 225 | $whereRelatedField = $relationship['whereRelatedField']; 226 | $equalsModelField = $relationship['equalsModelField']; 227 | $modelFieldValue = $parentData->{$equalsModelField}; 228 | $query = $relationship['query']; 229 | 230 | $records = $relation::where($whereRelatedField, $modelFieldValue); 231 | if ($query) { 232 | $records = $records->tap($query); 233 | } 234 | if ($type == 'hasMany') { 235 | $records = $records->get(); 236 | if ($records->isNotEmpty()) { 237 | foreach ($records as $record) { 238 | $data[] = $this->mapRecordsToFields($embedFields, $record); 239 | } 240 | } 241 | 242 | return $data; 243 | } 244 | $record = $records->first(); 245 | if ($record) { 246 | $data = $this->mapRecordsToFields($embedFields, $record); 247 | } 248 | 249 | return $data; 250 | 251 | } 252 | 253 | return $data; 254 | } 255 | 256 | // ---------------------------------------------------------------------- 257 | // Create Index 258 | // ---------------------------------------------------------------------- 259 | 260 | public function _createIndex(): bool 261 | { 262 | try { 263 | $modelId = $this->buildResult->id; 264 | $index = $this->indexModelInstance::find($modelId); 265 | if (! $index) { 266 | $index = new $this->indexModelInstance; 267 | } 268 | $index->id = $modelId; 269 | 270 | foreach ($this->buildResult->map as $field => $value) { 271 | $index->{$field} = $value; 272 | } 273 | $index->withoutRefresh()->save(); 274 | } catch (Exception $e) { 275 | $this->buildResult->setMessage('Index build Error', $e->getMessage()); 276 | 277 | return false; 278 | } 279 | 280 | return true; 281 | } 282 | 283 | // ---------------------------------------------------------------------- 284 | // Delete Index (And Build) 285 | // ---------------------------------------------------------------------- 286 | 287 | public function processDelete($id) 288 | { 289 | IndexableBuild::deleteState($this->baseModelName, $id, $this->indexModelName); 290 | $index = $this->indexModelInstance::find($id); 291 | $index->delete(); 292 | } 293 | 294 | // ---------------------------------------------------------------------- 295 | // Dispatchers 296 | // ---------------------------------------------------------------------- 297 | 298 | public function dispatchBuild($modelId, $observedModel) 299 | { 300 | IndexBuildJob::dispatch($this->indexModel, $modelId, $observedModel); 301 | } 302 | 303 | public function dispatchDeleted($modelId) 304 | { 305 | IndexDeletedJob::dispatch($this->indexModel, $modelId); 306 | } 307 | 308 | // ---------------------------------------------------------------------- 309 | // Helpers 310 | // ---------------------------------------------------------------------- 311 | 312 | private function setType($value, $type) 313 | { 314 | if ($type == Carbon::class) { 315 | return Carbon::create($value); 316 | } 317 | if (enum_exists($type)) { 318 | $value = $value->value ?? $value; 319 | $type = 'string'; 320 | } 321 | settype($value, $type); 322 | 323 | return $value; 324 | } 325 | } 326 | -------------------------------------------------------------------------------- /src/Index/LensIndex.php: -------------------------------------------------------------------------------- 1 | indexModel = $indexModel; 59 | $this->indexModelInstance = $instance; 60 | $migrationSettings = $instance->getMigrationSettings(); 61 | $this->indexModelName = class_basename($indexModel); 62 | $this->indexModelTable = $instance->getTable(); 63 | $this->indexExists = $instance::indexExists(); 64 | $this->fieldMap = $instance->getFieldSet(); 65 | $this->observers = $instance->getObserverSet(); 66 | $this->relationships = $instance->getRelationships(); 67 | $this->indexMigration = $migrationSettings; 68 | $this->baseModelDefined = $instance->isBaseModelDefined(); 69 | $baseModel = $instance->getBaseModel(); 70 | if ($baseModel) { 71 | $this->baseModel = $baseModel; 72 | $this->baseModelInstance = (new $baseModel); 73 | $this->baseModelName = class_basename($baseModel); 74 | $this->baseModelTable = $this->baseModelInstance->getTable(); 75 | $this->baseModelPrimaryKey = $this->baseModelInstance->getKeyName(); 76 | try { 77 | $baseModel::indexModel(); 78 | $this->baseModelIndexable = true; 79 | } catch (Exception) { 80 | $this->baseModelIndexable = false; 81 | } 82 | } 83 | 84 | } 85 | 86 | public function fetchCurrentMigrationVersion(): string 87 | { 88 | $this->indexMigrationVersion = $this->indexModelInstance->getCurrentMigrationVersion(); 89 | 90 | return $this->indexMigrationVersion; 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/Index/LensMigration.php: -------------------------------------------------------------------------------- 1 | false, 21 | 'message' => '', 22 | 'state' => [ 23 | 'index_model' => '', 24 | 'index_count' => 0, 25 | 'base_model' => false, 26 | 'base_count' => 0, 27 | 'has_blueprint' => false, 28 | 'migration_version' => false, 29 | ], 30 | ]; 31 | 32 | public function migrationState(): array 33 | { 34 | $this->_state['state']['index_model'] = $this->indexModelName; 35 | try { 36 | 37 | $indexCount = 0; 38 | try { 39 | $indexCount = $this->indexModelInstance::count(); 40 | } catch (Exception $e) { 41 | 42 | } 43 | $this->_state['state']['index_count'] = $indexCount; 44 | 45 | if ($this->baseModel) { 46 | $this->_state['state']['base_model'] = $this->baseModelName; 47 | $this->_state['state']['base_count'] = $this->baseModelInstance::count(); 48 | } 49 | 50 | $this->_state['state']['has_blueprint'] = $this->indexModelInstance->migrationMap() !== null; 51 | $this->_state['state']['migration_version'] = $this->fetchCurrentMigrationVersion(); 52 | 53 | } catch (Exception $e) { 54 | $this->_state['error'] = true; 55 | $this->_state['message'] = $e->getMessage(); 56 | } 57 | 58 | return $this->_state; 59 | } 60 | 61 | public function runMigration(): bool 62 | { 63 | $validBlueprint = false; 64 | $tableName = $this->indexModelTable; 65 | $blueprint = $this->indexMigration['blueprint'] ?? null; 66 | $version = $this->indexMigration['majorVersion'] ?? null; 67 | if ($blueprint) { 68 | $validBlueprint = $this->validateMigration()['validated']; 69 | } 70 | try { 71 | IndexableBuild::deleteStateModel($this->indexModelName); 72 | 73 | Schema::deleteIfExists($tableName); 74 | if (! $validBlueprint) { 75 | Schema::create($tableName, function (Blueprint $index) { 76 | $index->date('created_at'); 77 | }); 78 | $map = Schema::getMappings($tableName, true); 79 | IndexableMigrationLog::saveMigrationLog($this->indexModelName, $version, IndexableMigrationLogState::UNDEFINED, $map); 80 | } else { 81 | Schema::create($tableName, $blueprint); 82 | $map = Schema::getMappings($tableName, true); 83 | IndexableMigrationLog::saveMigrationLog($this->indexModelName, $version, IndexableMigrationLogState::SUCCESS, $map); 84 | } 85 | 86 | return true; 87 | } catch (Exception $e) { 88 | $map = ['error' => $e->getMessage()]; 89 | IndexableMigrationLog::saveMigrationLog($this->indexModelName, $version, IndexableMigrationLogState::FAILED, $map); 90 | } 91 | 92 | return false; 93 | } 94 | 95 | public function validateMigration(): array 96 | { 97 | 98 | $test = new MigrationValidator( 99 | $this->fetchCurrentMigrationVersion(), 100 | $this->indexModelInstance->migrationMap(), 101 | $this->indexModelTable 102 | ); 103 | 104 | return $test->testMigration(); 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/Index/LensState.php: -------------------------------------------------------------------------------- 1 | indexModelTable; 16 | $name = Str::title(str_replace('_', ' ', $indexTable)); 17 | 18 | return [ 19 | 'name' => $name, 20 | 'indexModel' => $this->indexModelName, 21 | 'state' => $this->_indexStatusCheck(), 22 | 'config' => $this->_configCheck(), 23 | ]; 24 | 25 | } 26 | 27 | public function getObservedModelNames() 28 | { 29 | return $this->observers; 30 | } 31 | 32 | private function _indexStatusCheck(): array 33 | { 34 | 35 | $indexData['modelName'] = $this->indexModelName; 36 | $indexData['table'] = $this->indexModelTable; 37 | $indexData['accessible'] = false; 38 | $indexData['records'] = 0; 39 | try { 40 | 41 | $indexData['records'] = $this->indexModelInstance::count(); 42 | $indexData['accessible'] = true; 43 | } catch (Exception $e) { 44 | 45 | } 46 | $modelData['modelName'] = $this->baseModelName; 47 | $modelData['defined'] = $this->baseModelDefined; 48 | $modelData['table'] = $this->baseModelTable; 49 | $modelData['accessible'] = false; 50 | $modelData['records'] = 0; 51 | if ($this->baseModel) { 52 | try { 53 | $modelData['records'] = $this->baseModel::count(); 54 | $modelData['accessible'] = true; 55 | } catch (Exception $e) { 56 | 57 | } 58 | } 59 | $builds = []; 60 | $builds['success'] = 0; 61 | $builds['errors'] = 0; 62 | $builds['skipped'] = 0; 63 | $builds['total'] = IndexableBuild::countModelRecords($this->indexModelName); 64 | if ($builds['total'] > 0) { 65 | $builds['errors'] = IndexableBuild::countModelErrors($this->indexModelName); 66 | $builds['skipped'] = IndexableBuild::countModelSkips($this->indexModelName); 67 | $builds['success'] = $builds['total'] - $builds['errors']; 68 | } 69 | $status = $this->_buildStatus($indexData, $modelData, $builds); 70 | 71 | return [ 72 | 'index' => $indexData, 73 | 'model' => $modelData, 74 | 'builds' => $builds, 75 | 'status' => $status, 76 | ]; 77 | // Status calc 78 | } 79 | 80 | private function _configCheck(): array 81 | { 82 | $config = [ 83 | 'base_model_indexable' => $this->baseModelIndexable, 84 | 'base_model' => $this->baseModelDefined, 85 | 'field_map' => ! empty($this->fieldMap), 86 | 'migration' => [ 87 | 'has' => $this->indexMigration['blueprint'] !== null, 88 | 'version' => $this->fetchCurrentMigrationVersion(), 89 | ], 90 | 'observers' => $this->getObservedModelNames(), 91 | 'status' => [], 92 | ]; 93 | $config['status'] = $this->_buildConfigStatus($config); 94 | 95 | return $config; 96 | } 97 | 98 | private function _buildStatus($indexData, $modelData, $builds): array 99 | { 100 | if (! $indexData['accessible']) { 101 | return [ 102 | 'status' => 'error', 103 | 'name' => 'Index Not Accessible', 104 | 'help' => ['Check ES connection & index migration for ('.$indexData['table'].')'], 105 | ]; 106 | } 107 | if (! $modelData['accessible']) { 108 | return [ 109 | 'status' => 'error', 110 | 'name' => 'Model Not Accessible', 111 | 'help' => ['Base model ('.$modelData['modelName'].') could not be reached. Does it exist?'], 112 | ]; 113 | } 114 | if ($builds['errors']) { 115 | return [ 116 | 'status' => 'warning', 117 | 'name' => 'Index Build Errors', 118 | 'help' => ['Some indexed could not be built, check logs. Total: '.$builds['errors']], 119 | ]; 120 | } 121 | 122 | if ($modelData['records'] !== $indexData['records'] + $builds['skipped']) { 123 | return [ 124 | 'status' => 'warning', 125 | 'name' => 'Indexes out of sync', 126 | 'help' => ['Index count ('.$indexData['records'].') does not match model count ('.$modelData['records'].')'], 127 | ]; 128 | } 129 | if (! $builds['total']) { 130 | return [ 131 | 'status' => 'warning', 132 | 'name' => 'No indexes', 133 | 'help' => ['No indexes found for ('.$indexData['modelName'].')'], 134 | ]; 135 | } 136 | 137 | return [ 138 | 'status' => 'ok', 139 | 'name' => 'Index Synced', 140 | ]; 141 | } 142 | 143 | private function _buildConfigStatus($config): array 144 | { 145 | $critical = []; 146 | $warning = []; 147 | if (! $config['base_model_indexable']) { 148 | $critical[] = [ 149 | 'status' => 'error', 150 | 'name' => 'Base Model Not Indexable', 151 | 'help' => ['Add trait to base model: `use Indexable`'], 152 | ]; 153 | } 154 | if (! $config['base_model']) { 155 | 156 | $baseModel = $this->baseModel; 157 | if (! $baseModel) { 158 | $baseModel = 'MyModel::class'; 159 | } else { 160 | $baseModel = class_basename($this->baseModel).'::class'; 161 | } 162 | 163 | $warning[] = [ 164 | 'status' => 'warning', 165 | 'name' => 'Base Model Not Set', 166 | 'help' => [ 167 | 'Base model will be guessed', 168 | 'Set property: `protected $baseModel = '.$baseModel.';`', 169 | ], 170 | ]; 171 | } 172 | if (! $config['field_map']) { 173 | $warning[] = [ 174 | 'status' => 'warning', 175 | 'name' => 'Field Map Recommended', 176 | 'help' => [ 177 | 'Fields will taken as is from the base model when it is indexed', 178 | 'You can define your own in the Index Model with: `public function fieldMap(): IndexBuilder`', 179 | ], 180 | ]; 181 | } 182 | if (! $config['observers']['base'] && ! $config['observers']['embedded']) { 183 | $warning[] = [ 184 | 'status' => 'warning', 185 | 'name' => 'No Models Observed', 186 | 'help' => ['There are no events that will trigger indexing'], 187 | ]; 188 | } 189 | if (! $config['migration']['has']) { 190 | $warning[] = [ 191 | 'status' => 'warning', 192 | 'name' => 'Migration Recommended', 193 | 'help' => [ 194 | 'The index will automatically infer the field types as data is indexed', 195 | 'You can define your own in the Index Model with: `public function migrationMap(): MigrationBuilder`', 196 | ], 197 | ]; 198 | } 199 | $status = [ 200 | 'status' => 'ok', 201 | 'name' => 'OK', 202 | 'critical' => $critical, 203 | 'warning' => $warning, 204 | ]; 205 | if ($warning) { 206 | $warnings = count($warning); 207 | $status['status'] = 'warning'; 208 | $status['name'] = '1 Config Warning'; 209 | if ($warnings > 1) { 210 | $status['name'] = $warnings.' Config Warnings'; 211 | } 212 | 213 | } 214 | if ($critical) { 215 | $status['status'] = 'error'; 216 | $status['name'] = 'Critical Config Error'; 217 | 218 | } 219 | 220 | return $status; 221 | 222 | } 223 | } 224 | -------------------------------------------------------------------------------- /src/Index/MigrationValidator.php: -------------------------------------------------------------------------------- 1 | version = $version; 29 | $this->blueprint = $blueprint; 30 | $this->indexModelTable = $indexModelTable; 31 | } 32 | 33 | public function testMigration(): array 34 | { 35 | if (! $this->blueprint) { 36 | $this->state = 'No Blueprint'; 37 | $this->message = ''; 38 | 39 | return $this->asArray(); 40 | } 41 | if (! is_callable($this->blueprint)) { 42 | $this->state = 'Blueprint Error'; 43 | $this->message = 'Blueprint is not callable'; 44 | 45 | return $this->asArray(); 46 | } 47 | 48 | $tempIndex = 'elasticlens_test_index_for_'.$this->indexModelTable; 49 | Schema::deleteIfExists($tempIndex); 50 | try { 51 | Schema::create($tempIndex, $this->blueprint); 52 | $this->indexMap = Schema::getMappings($tempIndex); 53 | Schema::deleteIfExists($tempIndex); 54 | $this->validated = true; 55 | $this->state = 'Validated'; 56 | 57 | return $this->asArray(); 58 | } catch (Exception $e) { 59 | $this->message = $e->getMessage(); 60 | $this->state = 'Failed'; 61 | 62 | return $this->asArray(); 63 | } 64 | } 65 | 66 | private function asArray(): array 67 | { 68 | return [ 69 | 'version' => $this->version, 70 | 'blueprint' => $this->blueprint, 71 | 'validated' => $this->validated, 72 | 'state' => $this->state, 73 | 'message' => $this->message, 74 | 'indexMap' => $this->indexMap, 75 | ]; 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/IndexModel.php: -------------------------------------------------------------------------------- 1 | baseModel) { 48 | $this->baseModel = $this->guessBaseModelName(); 49 | } 50 | $this->setConnection(config('elasticlens.database') ?? 'elasticsearch'); 51 | Builder::macro('paginateBase', function ($perPage = 15, $pageName = 'page', $page = null, $options = []) { 52 | $page = $page ?: LengthAwarePaginator::resolveCurrentPage($pageName); 53 | $path = LengthAwarePaginator::resolveCurrentPath(); 54 | $items = $this->get()->forPage($page, $perPage); 55 | $items = $items->map(function ($value) { 56 | // @phpstan-ignore-next-line 57 | return $value->base; 58 | }); 59 | $pagi = new LengthAwarePaginator( 60 | $items, 61 | $this->count(), 62 | $perPage, 63 | $page, 64 | $options 65 | ); 66 | $pagi->setPath($path); 67 | 68 | return $pagi; 69 | }); 70 | Builder::macro('getBase', function () { 71 | return $this->get()->map(function ($value) { 72 | // @phpstan-ignore-next-line 73 | return $value->base; 74 | }); 75 | }); 76 | Collection::macro('asBase', function () { 77 | return $this->map(function ($value) { 78 | return $value->base; 79 | }); 80 | }); 81 | } 82 | 83 | public function getBaseAttribute() 84 | { 85 | return $this->baseModel::find($this->id); 86 | } 87 | 88 | public function asBase() 89 | { 90 | return $this->base; 91 | } 92 | 93 | public static function lensHealth(): array 94 | { 95 | $lens = new LensState(static::class); 96 | 97 | return $lens->healthCheck(); 98 | } 99 | 100 | public static function whereIndexBuilds($byLatest = false): Builder 101 | { 102 | 103 | $indexModel = strtolower(class_basename(static::class)); 104 | $query = IndexableBuild::query()->where('index_model', $indexModel); 105 | if ($byLatest) { 106 | $query->orderByDesc('created_at'); 107 | } 108 | 109 | return $query; 110 | } 111 | 112 | public static function whereFailedIndexBuilds($byLatest = false): Builder 113 | { 114 | $indexModel = strtolower(class_basename(static::class)); 115 | $query = IndexableBuild::query()->where('index_model', $indexModel)->where('state', IndexableBuildState::FAILED); 116 | if ($byLatest) { 117 | $query->orderByDesc('created_at'); 118 | } 119 | 120 | return $query; 121 | } 122 | 123 | public static function whereMigrations($byLatest = false): Builder 124 | { 125 | $indexModel = strtolower(class_basename(static::class)); 126 | $query = IndexableMigrationLog::query()->where('index_model', $indexModel); 127 | if ($byLatest) { 128 | $query->orderByDesc('created_at'); 129 | } 130 | 131 | return $query; 132 | } 133 | 134 | public static function whereMigrationErrors($byLatest = false): Builder 135 | { 136 | $indexModel = strtolower(class_basename(static::class)); 137 | $query = IndexableMigrationLog::query()->where('index_model', $indexModel)->where('state', IndexableMigrationLogState::FAILED); 138 | if ($byLatest) { 139 | $query->orderByDesc('created_at'); 140 | } 141 | 142 | return $query; 143 | 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /src/Indexable.php: -------------------------------------------------------------------------------- 1 | searchPhrasePrefix($phrase)->getBase(); 30 | } 31 | 32 | public static function viaIndex(): IndexModel|Builder 33 | { 34 | $indexModel = Lens::fetchIndexModelClass((new static)); 35 | 36 | return $indexModel::query(); 37 | 38 | } 39 | 40 | /** 41 | * Fetch the fully qualified class name of the index model. 42 | * 43 | * @return class-string The fully qualified class name of the index model. 44 | */ 45 | public static function indexModel(): string 46 | { 47 | return Lens::fetchIndexModelClass((new static)); 48 | } 49 | 50 | public function returnIndex(): ?IndexModel 51 | { 52 | $modelId = $this->{$this->getKeyName()}; 53 | $indexModel = Lens::fetchIndexModelClass($this); 54 | 55 | try { 56 | return $indexModel::where('id', $modelId)->first(); 57 | } catch (Exception $e) { 58 | 59 | } 60 | 61 | return null; 62 | } 63 | 64 | public function buildIndex(): array 65 | { 66 | $modelId = $this->{$this->getKeyName()}; 67 | $indexModel = Lens::fetchIndexModelClass($this); 68 | 69 | $build = $indexModel::indexBuild($modelId, 'Direct call from '.get_class($this).' trait'); 70 | 71 | return $build->toArray(); 72 | 73 | } 74 | 75 | public function excludeIndex(): bool 76 | { 77 | return false; 78 | } 79 | 80 | public function removeIndex(): bool 81 | { 82 | $modelId = $this->{$this->getKeyName()}; 83 | $indexModel = Lens::fetchIndexModelClass($this); 84 | 85 | try { 86 | $deleted = $indexModel::destory($modelId); 87 | } catch (Exception $e) { 88 | return false; 89 | } 90 | 91 | return $deleted; 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/Jobs/BulkBuildStateUpdateJob.php: -------------------------------------------------------------------------------- 1 | buildStates)) { 23 | foreach ($this->buildStates as $modelId => $buildState) { 24 | IndexableBuild::writeState(class_basename($this->baseModel), $modelId, class_basename($this->indexModel), $buildState, 'Bulk Index'); 25 | } 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Jobs/IndexBuildJob.php: -------------------------------------------------------------------------------- 1 | onQueue(config('elasticlens.queue')); 22 | } 23 | } 24 | 25 | /** 26 | * @throws Exception 27 | */ 28 | public function handle(): void 29 | { 30 | $this->indexModelClass::indexBuild($this->modelId, 'Observed: '.$this->observedModel); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Jobs/IndexDeletedJob.php: -------------------------------------------------------------------------------- 1 | onQueue(config('elasticlens.queue')); 23 | } 24 | } 25 | 26 | /** 27 | * @throws Exception 28 | */ 29 | public function handle(): void 30 | { 31 | $builder = new LensBuilder($this->indexModel); 32 | $builder->processDelete($this->modelId); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Lens.php: -------------------------------------------------------------------------------- 1 | $namespace) { 38 | foreach (glob(base_path($path.'*.php')) as $file) { 39 | $indexModel = $namespace.'\\'.basename($file, '.php'); 40 | $indexes[] = (new $indexModel)::class; 41 | } 42 | } 43 | 44 | return $indexes; 45 | } 46 | 47 | public static function checkIfWatched($model, $indexModel): bool 48 | { 49 | $watchers = config('elasticlens.watchers'); 50 | if (isset($watchers[$model])) { 51 | return in_array($indexModel, $watchers[$model]); 52 | } 53 | 54 | return false; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/Models/IndexableBuild.php: -------------------------------------------------------------------------------- 1 | IndexableBuildState::class, 46 | ]; 47 | 48 | public function __construct() 49 | { 50 | parent::__construct(); 51 | $this->setConnection(self::getConnectionName()); 52 | } 53 | 54 | public static function isEnabled() 55 | { 56 | return config('elasticlens.index_build_state.enabled', true); 57 | } 58 | 59 | public function getStateNameAttribute(): string 60 | { 61 | return $this->state->label(); 62 | } 63 | 64 | public function getStateColorAttribute(): string 65 | { 66 | return $this->state->color(); 67 | } 68 | 69 | public static function returnState($model, $modelId, $indexModel): mixed 70 | { 71 | return self::where('model', strtolower($model))->where('model_id', $modelId)->where('index_model', strtolower($indexModel))->first(); 72 | } 73 | 74 | public static function writeState($model, $modelId, $indexModel, BuildResult $buildResult, $observerModel): ?IndexableBuild 75 | { 76 | if (! self::isEnabled()) { 77 | return null; 78 | } 79 | $model = strtolower($model); 80 | $indexModel = strtolower($indexModel); 81 | $stateData = $buildResult->toArray(); 82 | unset($stateData['model']); 83 | $state = IndexableBuildState::FAILED; 84 | if ($buildResult->success) { 85 | $state = IndexableBuildState::SUCCESS; 86 | unset($stateData['msg']); 87 | unset($stateData['details']); 88 | unset($stateData['map']); 89 | } 90 | if ($buildResult->skipped) { 91 | $state = IndexableBuildState::SKIPPED; 92 | unset($stateData['msg']); 93 | unset($stateData['details']); 94 | unset($stateData['map']); 95 | } 96 | 97 | $source = $observerModel; 98 | $stateModel = self::returnState($model, $modelId, $indexModel); 99 | if (! $stateModel) { 100 | $stateModel = new IndexableBuild; 101 | $stateModel->model = $model; 102 | $stateModel->model_id = $modelId; 103 | $stateModel->index_model = $indexModel; 104 | } 105 | $stateModel->state = $state; 106 | $stateModel->state_data = $stateData; 107 | $stateModel->last_source = $source; 108 | $logs = $stateModel->_prepLogs($stateData, $source); 109 | $stateModel->logs = $logs; 110 | $stateModel->withoutRefresh()->save(); 111 | 112 | return $stateModel; 113 | } 114 | 115 | public static function countModelErrors($indexModel): int 116 | { 117 | return IndexableBuild::where('index_model', strtolower($indexModel))->where('state', IndexableBuildState::FAILED)->count(); 118 | } 119 | 120 | public static function countModelSkips($indexModel): int 121 | { 122 | return IndexableBuild::where('index_model', strtolower($indexModel))->where('state', IndexableBuildState::SKIPPED)->count(); 123 | } 124 | 125 | public static function countModelRecords($indexModel): int 126 | { 127 | return IndexableBuild::where('index_model', strtolower($indexModel))->count(); 128 | } 129 | 130 | public static function deleteState($model, $modelId, $indexModel): void 131 | { 132 | $stateModel = IndexableBuild::returnState(strtolower($model), $modelId, strtolower($indexModel)); 133 | $stateModel?->delete(); 134 | } 135 | 136 | public static function deleteStateModel($indexModel): void 137 | { 138 | IndexableBuild::where('index_model', strtolower($indexModel))->delete(); 139 | 140 | } 141 | 142 | // ---------------------------------------------------------------------- 143 | // Helpers 144 | // ---------------------------------------------------------------------- 145 | 146 | public function _prepLogs($stateData, $source): array 147 | { 148 | $trim = config('elasticlens.index_build_state.log_trim', 2); 149 | if (! $trim) { 150 | return []; 151 | } 152 | $logs = $this->logs ?? []; 153 | unset($stateData['id']); 154 | $logs[] = [ 155 | 'ts' => time(), 156 | 'success' => $stateData['success'], 157 | 'data' => $stateData, 158 | 'source' => $source, 159 | ]; 160 | $collection = collect($logs); 161 | 162 | return $collection->sortByDesc('ts')->take($trim)->values()->all(); 163 | 164 | } 165 | 166 | // ---------------------------------------------------------------------- 167 | // Config 168 | // ---------------------------------------------------------------------- 169 | 170 | public static function connectionName(): string 171 | { 172 | return config('elasticlens.database', 'elasticsearch'); 173 | } 174 | 175 | public static function checkHasIndex(): bool 176 | { 177 | $connectionName = self::connectionName(); 178 | 179 | return Schema::on($connectionName)->indexExists('indexable_builds'); 180 | } 181 | } 182 | -------------------------------------------------------------------------------- /src/Models/IndexableMigrationLog.php: -------------------------------------------------------------------------------- 1 | IndexableMigrationLogState::class, 39 | ]; 40 | 41 | public static function isEnabled() 42 | { 43 | return config('elasticlens.index_migration_logs.enabled', true); 44 | } 45 | 46 | public function __construct() 47 | { 48 | parent::__construct(); 49 | $this->setConnection(self::getConnectionName()); 50 | } 51 | 52 | public function getVersionAttribute() 53 | { 54 | return 'v'.$this->version_major.'.'.$this->version_minor; 55 | } 56 | 57 | public static function getLatestVersion($indexModel): ?string 58 | { 59 | $indexModel = strtolower($indexModel); 60 | if (! self::isEnabled()) { 61 | return null; 62 | } 63 | $latest = self::getLatestMigration($indexModel); 64 | 65 | return $latest?->version; 66 | } 67 | 68 | public static function getLatestMigration($indexModel): mixed 69 | { 70 | $indexModel = strtolower($indexModel); 71 | $log = null; 72 | try { 73 | $log = self::where('index_model', $indexModel)->orderBy('version_major', 'desc')->orderBy('version_minor', 'desc')->first(); 74 | } catch (Exception $e) { 75 | 76 | } 77 | 78 | return $log; 79 | } 80 | 81 | public static function saveMigrationLog($indexModel, $majorVersion, $state, $map) 82 | { 83 | $indexModel = strtolower($indexModel); 84 | 85 | $minor = self::calculateNextMinorVersion($indexModel, $majorVersion); 86 | $log = new self; 87 | $log->index_model = $indexModel; 88 | $log->version_major = $majorVersion; 89 | $log->version_minor = $minor; 90 | $log->state = $state; 91 | $log->map = $map; 92 | $log->save(); 93 | } 94 | 95 | public static function calculateNextMinorVersion($indexModel, $majorVersion): int 96 | { 97 | $indexModel = strtolower($indexModel); 98 | $lastMigration = self::getLatestMigration($indexModel); 99 | if ($lastMigration) { 100 | if ($lastMigration->version_major == $majorVersion) { 101 | return $lastMigration->version_minor + 1; 102 | } 103 | } 104 | 105 | return 0; 106 | } 107 | 108 | // ---------------------------------------------------------------------- 109 | // Config 110 | // ---------------------------------------------------------------------- 111 | 112 | public static function connectionName(): string 113 | { 114 | return config('elasticlens.database', 'elasticsearch'); 115 | } 116 | 117 | public static function checkHasIndex(): bool 118 | { 119 | $connectionName = self::connectionName(); 120 | 121 | return Schema::on($connectionName)->indexExists('indexable_migration_logs'); 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /src/Observers/BaseModelObserver.php: -------------------------------------------------------------------------------- 1 | dispatchBuild($model->{$builder->baseModelPrimaryKey}, ($builder->baseModel).' (saved)'); 21 | } catch (Exception $e) { 22 | 23 | } 24 | } 25 | 26 | public function deleting($model): void 27 | { 28 | $modelId = $model->{$model->getKeyName()}; 29 | $indexModel = Lens::fetchIndexModelClass($model); 30 | try { 31 | $index = new LensBuilder($indexModel); 32 | $index->dispatchDeleted($modelId); 33 | } catch (Exception $e) { 34 | 35 | } 36 | 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Observers/ObserverRegistry.php: -------------------------------------------------------------------------------- 1 | getObserverSet(); 25 | 26 | if (! empty($observers['base'])) { 27 | $baseModel::observe(new BaseModelObserver); 28 | } 29 | if (! empty($observers['embedded'])) { 30 | foreach ($observers['embedded'] as $settings) { 31 | if ($settings['observe']) { 32 | $embeddedModel = $settings['relation']; 33 | if (! Lens::checkIfWatched($embeddedModel, $indexModel)) { 34 | self::watchEmbedded($embeddedModel, $settings, $baseModel); 35 | } 36 | } 37 | } 38 | } 39 | } 40 | 41 | /** 42 | * @throws Exception 43 | */ 44 | public static function registerWatcher($watchedModel, $indexModel): void 45 | { 46 | $indexModelInstance = new $indexModel; 47 | $observers = $indexModelInstance->getObserverSet(); 48 | $baseModel = $indexModelInstance->getBaseModel(); 49 | if (! empty($observers['embedded'])) { 50 | foreach ($observers['embedded'] as $settings) { 51 | if ($watchedModel == $settings['relation'] && $settings['observe']) { 52 | self::watchEmbedded($watchedModel, $settings, $baseModel); 53 | } 54 | 55 | } 56 | } 57 | } 58 | 59 | /** 60 | * @throws Exception 61 | */ 62 | private static function watchEmbedded($watchedModel, $settings, $baseModel): void 63 | { 64 | $watchedModel::saved(function ($model) use ($settings, $baseModel) { 65 | $watcher = new EmbeddedModelTrigger($model, $baseModel, $settings); 66 | $watcher->handle('saved'); 67 | }); 68 | $watchedModel::deleted(function ($model) use ($settings, $baseModel) { 69 | $watcher = new EmbeddedModelTrigger($model, $baseModel, $settings); 70 | $watcher->handle('deleted'); 71 | }); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/Traits/IndexBaseModel.php: -------------------------------------------------------------------------------- 1 | baseModel) { 20 | return $this->guessBaseModelName(); 21 | } 22 | 23 | return $this->baseModel; 24 | } 25 | 26 | public function isBaseModelDefined(): bool 27 | { 28 | return ! empty($this->baseModel); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Traits/IndexFieldMap.php: -------------------------------------------------------------------------------- 1 | baseModel); 21 | } 22 | 23 | public function getFieldSet() 24 | { 25 | return $this->fieldMap()->getFieldMap(); 26 | } 27 | 28 | public function getRelationships() 29 | { 30 | return $this->fieldMap()->getRelationships(); 31 | } 32 | 33 | public function getObserverSet() 34 | { 35 | $base = null; 36 | if (! empty($this->observeBase)) { 37 | $base = $this->getBaseModel(); 38 | } 39 | $embedded = $this->fieldMap()->getObservers(); 40 | if ($embedded) { 41 | $embedded = $this->mapUpstreamEmbeds($embedded); 42 | } 43 | 44 | return [ 45 | 'base' => $base, 46 | 'embedded' => $embedded, 47 | ]; 48 | } 49 | 50 | public function getObservedModels() 51 | { 52 | $set = $this->getObserverSet(); 53 | $embedded = $set['embedded']; 54 | $embeddedModels = []; 55 | if ($embedded) { 56 | foreach ($embedded as $embed) { 57 | if ($embed['observe']) { 58 | $embeddedModels[] = $embed['model']; 59 | } 60 | } 61 | } 62 | 63 | return [ 64 | 'base' => $set['base'], 65 | 'embedded' => $embeddedModels, 66 | ]; 67 | } 68 | 69 | // ---------------------------------------------------------------------- 70 | // Build Index 71 | // ---------------------------------------------------------------------- 72 | 73 | /** 74 | * @throws Exception 75 | */ 76 | public static function indexBuild($id, $source): BuildResult 77 | { 78 | return (new static)->getBuilder()->buildIndex($id, $source); 79 | } 80 | 81 | /** 82 | * @throws Exception 83 | */ 84 | public function indexRebuild($source): BuildResult 85 | { 86 | return $this->getBuilder()->buildIndex($this->id, $source); 87 | } 88 | 89 | /** 90 | * @throws Exception 91 | */ 92 | public function getBuilder(): LensBuilder 93 | { 94 | return new LensBuilder(get_class($this)); 95 | } 96 | 97 | // ---------------------------------------------------------------------- 98 | // Map any upstream embeds 99 | // ---------------------------------------------------------------------- 100 | 101 | public function mapUpstreamEmbeds($embeds) 102 | { 103 | foreach ($embeds as $i => $embed) { 104 | $embeds[$i] = $this->_fetchUpstream($embed, $embeds); 105 | } 106 | 107 | return $embeds; 108 | } 109 | 110 | private function _fetchUpstream($embed, $embeds) 111 | { 112 | if ($embed['model'] !== $this->baseModel) { 113 | foreach ($embeds as $em) { 114 | if ($em['relation'] === $embed['model']) { 115 | $embed['upstream'] = $this->_fetchUpstream($em, $embeds); 116 | } 117 | } 118 | } 119 | 120 | return $embed; 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /src/Traits/IndexMigrationMap.php: -------------------------------------------------------------------------------- 1 | $this->migrationMajorVersion, 23 | 'blueprint' => $this->migrationMap(), 24 | ]; 25 | } 26 | 27 | public function getCurrentMigrationVersion(): string 28 | { 29 | $version = IndexableMigrationLog::getLatestVersion(class_basename($this)); 30 | if (! $version) { 31 | $version = 'v'.$this->migrationMajorVersion.'.0'; 32 | } 33 | 34 | return $version; 35 | } 36 | 37 | public static function validateIndexMigrationBlueprint(): array 38 | { 39 | $indexModel = new static; 40 | $version = $indexModel->getCurrentMigrationVersion(); 41 | $blueprint = $indexModel->migrationMap(); 42 | $indexModelTable = $indexModel->getTable(); 43 | $validator = new MigrationValidator($version, $blueprint, $indexModelTable); 44 | 45 | return $validator->testMigration(); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/Traits/Timer.php: -------------------------------------------------------------------------------- 1 | timer['start'] = microtime(true); 14 | 15 | return $this->timer; 16 | } 17 | 18 | private function _endTimer(): array 19 | { 20 | if (! empty($this->timer['start'])) { 21 | $this->timer['end'] = microtime(true); 22 | $this->timer['took'] = round(($this->timer['end'] - $this->timer['start']) * 1000, 0); 23 | $this->timer['time']['ms'] = $this->timer['took']; 24 | $this->timer['time']['sec'] = round($this->timer['took'] / 1000, 2); 25 | $this->timer['time']['min'] = round($this->timer['took'] / 60000, 2); 26 | } else { 27 | $this->timer['time'] = 'Time was not initialized'; 28 | } 29 | 30 | return $this->timer; 31 | } 32 | 33 | public function getTime() 34 | { 35 | $this->_endTimer(); 36 | 37 | return $this->timer['time']; 38 | 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Utils/IndexHelper.php: -------------------------------------------------------------------------------- 1 | indexModelTable); 15 | $mappings = reset($mappings); 16 | $schema = []; 17 | if (! empty($mappings['mappings']['properties'])) { 18 | 19 | $mappings = $mappings['mappings']['properties']; 20 | if (! empty($mappings['_meta']['properties']['id'])) { 21 | $id = $mappings['_meta']['properties']['id']; 22 | 23 | $schema['id'] = $id; 24 | 25 | } else { 26 | $schema['id'] = [ 27 | 'type' => 'text', 28 | ]; 29 | } 30 | 31 | unset($mappings['_meta']); 32 | 33 | foreach ($mappings as $field => $mapping) { 34 | $schema[$field] = $mapping; 35 | } 36 | 37 | } 38 | 39 | return $schema; 40 | } 41 | 42 | public function indexFields($sorted = false, $excludeEmbedded = false): array 43 | { 44 | $schema = $this->indexMapping(); 45 | if (! $schema) { 46 | return []; 47 | } 48 | 49 | $maps = $this->_mapProperties($schema); 50 | if ($sorted) { 51 | $maps = $this->_reasonableMapSort($maps, $excludeEmbedded); 52 | } 53 | 54 | return $maps; 55 | } 56 | 57 | private function _mapField($val, $field): array 58 | { 59 | $maps = []; 60 | if (isset($val['properties'])) { 61 | return $this->_mapProperties($val['properties'], $field); 62 | 63 | } 64 | $maps[$field] = $val['type']; 65 | 66 | return $maps; 67 | } 68 | 69 | private function _mapProperties($properties, $parentField = null): array 70 | { 71 | $propertyMaps = []; 72 | foreach ($properties as $field => $value) { 73 | if ($parentField) { 74 | $field = $parentField.'.'.$field; 75 | } 76 | $maps = $this->_mapField($value, $field); 77 | 78 | foreach ($maps as $f => $type) { 79 | $propertyMaps[$f] = $type; 80 | } 81 | } 82 | 83 | return $propertyMaps; 84 | } 85 | 86 | private function _reasonableMapSort($maps, $excludeEmbedded): array 87 | { 88 | $sorted = []; 89 | $commonFields = [ 90 | 'id', 91 | 'name', 92 | 'email', 93 | 'first_name', 94 | 'last_name', 95 | 'type', 96 | 'state', 97 | 'status', 98 | 'created_at', 99 | 'updated_at', 100 | ]; 101 | foreach ($commonFields as $field) { 102 | if (isset($maps[$field])) { 103 | $sorted[$field] = $maps[$field]; 104 | unset($maps[$field]); 105 | } 106 | } 107 | $embedded = []; 108 | foreach ($maps as $field => $type) { 109 | if (str_contains($field, '.')) { 110 | $embedded[$field] = $type; 111 | unset($maps[$field]); 112 | 113 | continue; 114 | } 115 | $sorted[$field] = $type; 116 | } 117 | if (! $excludeEmbedded) { 118 | if (! empty($embedded)) { 119 | foreach ($embedded as $field => $type) { 120 | $sorted[$field] = $type; 121 | } 122 | } 123 | } 124 | 125 | return $sorted; 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /src/Watchers/EmbeddedModelTrigger.php: -------------------------------------------------------------------------------- 1 | model = $model; 24 | $this->baseModel = $baseModel; 25 | $this->settings = $settings; 26 | } 27 | 28 | /** 29 | * @throws Exception 30 | */ 31 | public function handle($event): void 32 | { 33 | $this->modelChain[] = '['.class_basename($this->model).'::'.$event.']'; 34 | $this->fetchRecords($this->model, $this->settings); 35 | } 36 | 37 | /** 38 | * @throws Exception 39 | */ 40 | public function fetchRecords($modelInstance, $settings): void 41 | { 42 | $this->modelChain[] = class_basename($settings['relation']); 43 | if ($settings['model'] === $this->baseModel) { 44 | $this->modelChain[] = class_basename($this->baseModel); 45 | $this->_dispatchBuild($modelInstance, $settings); 46 | } else { 47 | $modelKey = $settings['whereRelatedField']; 48 | $modelValue = $modelInstance->{$modelKey}; 49 | $parentKey = $settings['equalsModelField']; 50 | $parentModel = $settings['model']; 51 | $parentModel::where($parentKey, $modelValue)->chunk(100, function ($records) use ($settings) { 52 | foreach ($records as $record) { 53 | if (! empty($settings['upstream'])) { 54 | $this->fetchRecords($record, $settings['upstream']); 55 | } else { 56 | $this->fetchRecords($record, $settings); 57 | } 58 | } 59 | }); 60 | } 61 | 62 | } 63 | 64 | /** 65 | * @throws Exception 66 | */ 67 | public function _dispatchBuild($modelInstance, $settings): void 68 | { 69 | $modelInstanceKey = $settings['whereRelatedField']; 70 | $value = $modelInstance->{$modelInstanceKey}; 71 | $baseKey = $settings['equalsModelField']; 72 | $observers = implode(' -> ', $this->modelChain); 73 | $indexModel = Lens::fetchIndexModelClass($this->baseModel); 74 | $builder = new LensBuilder($indexModel); 75 | (new $this->baseModel)::where($baseKey, $value)->chunk(100, function ($records) use ($builder, $observers) { 76 | foreach ($records as $record) { 77 | $builder->dispatchBuild($record->{$builder->baseModelPrimaryKey}, $observers); 78 | } 79 | }); 80 | } 81 | } 82 | --------------------------------------------------------------------------------