├── .gitignore ├── .travis.yml ├── LICENSE ├── composer.json ├── config └── cloud-search.php ├── database └── migrations │ └── 2017_06_19_161000_create_cloudsearch_queues_table.php ├── phpunit.xml ├── readme.md └── src ├── CloudSearcher.php ├── Console ├── AbstractCommand.php ├── FieldsCommand.php ├── FlushCommand.php ├── IndexCommand.php └── QueueCommand.php ├── Eloquent ├── Localized.php ├── LocalizedScope.php ├── Observer.php └── Searchable.php ├── LaravelCloudSearchServiceProvider.php ├── Query ├── Builder.php ├── StructuredQueryBuilder.php └── StructuredSearch.php └── Queue.php /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor 2 | composer.phar 3 | composer.lock 4 | .DS_Store 5 | Thumbs.db -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: php 2 | 3 | php: 4 | - 5.6 5 | - 7.0 6 | 7 | matrix: 8 | allow_failures: 9 | - php: 7.0 10 | 11 | before_script: 12 | - composer self-update 13 | - composer install --prefer-source --no-interaction 14 | 15 | script: 16 | - phpunit 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The BSD 2-Clause License 2 | Copyright (c) 2017-2018, Daniel Stainback 3 | All rights reserved. 4 | 5 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 6 | 7 | 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 8 | 9 | 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 10 | 11 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 12 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "torann/laravel-cloudsearch", 3 | "type": "library", 4 | "description": "Index and search Laravel models on Amazon's CloudSearch.", 5 | "keywords": [ 6 | "cloudsearch", 7 | "aws", 8 | "amazon cloudsearch", 9 | "eloquent", 10 | "laravel", 11 | "search" 12 | ], 13 | "homepage": "https://github.com/Torann/laravel-cloudsearch", 14 | "license": "BSD-2-Clause", 15 | "authors": [ 16 | { 17 | "name": "Daniel Stainback", 18 | "email": "torann@gmail.com" 19 | } 20 | ], 21 | "require": { 22 | "php": ">=5.6.4", 23 | "illuminate/support": "~5.4", 24 | "illuminate/config": "~5.4", 25 | "aws/aws-sdk-php": "~3.0" 26 | }, 27 | "require-dev": { 28 | "phpunit/phpunit": "~4.2|~5.0", 29 | "mockery/mockery": "^0.9.4" 30 | }, 31 | "autoload": { 32 | "psr-4": { 33 | "LaravelCloudSearch\\": "src/" 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /config/cloud-search.php: -------------------------------------------------------------------------------- 1 | true, 15 | 16 | /* 17 | |-------------------------------------------------------------------------- 18 | | Custom CloudSearch Client Configuration 19 | |-------------------------------------------------------------------------- 20 | | 21 | | This array will be passed to the CloudSearch client. 22 | | 23 | */ 24 | 25 | 'config' => [ 26 | 'endpoint' => env('CLOUDSEARCH_ENDPOINT'), 27 | 'region' => env('CLOUDSEARCH_REGION'), 28 | 29 | 'credentials' => [ 30 | 'key' => env('AWS_KEY'), 31 | 'secret' => env('AWS_SECRET') 32 | ], 33 | 34 | 'version' => '2013-01-01', 35 | ], 36 | 37 | /* 38 | |-------------------------------------------------------------------------- 39 | | Domain Name 40 | |-------------------------------------------------------------------------- 41 | | 42 | | The domain name used for the searching. 43 | | 44 | */ 45 | 46 | 'domain_name' => 'you-domain', 47 | 48 | /* 49 | |-------------------------------------------------------------------------- 50 | | Index Fields 51 | |-------------------------------------------------------------------------- 52 | | 53 | | This is used to specify your index fields and their data types. 54 | | 55 | */ 56 | 57 | 'fields' => [ 58 | 'id' => 'literal', 59 | ], 60 | 61 | /* 62 | |-------------------------------------------------------------------------- 63 | | Model Namespace 64 | |-------------------------------------------------------------------------- 65 | | 66 | | Change this if you use a different model namespace for Laravel. 67 | | 68 | */ 69 | 70 | 'model_namespace' => '\\App', 71 | 72 | /* 73 | |-------------------------------------------------------------------------- 74 | | Support Locales 75 | |-------------------------------------------------------------------------- 76 | | 77 | | This is used in the command line to import and map models. 78 | | 79 | */ 80 | 81 | 'support_locales' => [], 82 | 83 | /* 84 | |-------------------------------------------------------------------------- 85 | | Batching 86 | |-------------------------------------------------------------------------- 87 | | 88 | | In this section we can customize a few of the settings that in the long 89 | | run could save us some money. 90 | | 91 | */ 92 | 93 | 'batching_size' => 100, 94 | 95 | ]; 96 | -------------------------------------------------------------------------------- /database/migrations/2017_06_19_161000_create_cloudsearch_queues_table.php: -------------------------------------------------------------------------------- 1 | string('entry_id'); 18 | $table->string('entry_type'); 19 | $table->string('action', 10); 20 | $table->tinyInteger('status')->default(Queue::STATUS_WAITING); 21 | $table->unsignedInteger('created_at'); 22 | 23 | $table->primary(['entry_id', 'entry_type', 'action']); 24 | }); 25 | } 26 | 27 | /** 28 | * Reverse the migrations. 29 | * 30 | * @return void 31 | */ 32 | public function down() 33 | { 34 | Schema::drop('cloudsearch_queues'); 35 | } 36 | } -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 13 | 14 | 15 | ./tests/ 16 | 17 | 18 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Laravel CloudSearch 2 | 3 | [![Latest Stable Version](https://poser.pugx.org/torann/laravel-cloudsearch/v/stable.png)](https://packagist.org/packages/torann/laravel-cloudsearch) 4 | [![Total Downloads](https://poser.pugx.org/torann/laravel-cloudsearch/downloads.png)](https://packagist.org/packages/torann/laravel-cloudsearch) 5 | [![Patreon donate button](https://img.shields.io/badge/patreon-donate-yellow.svg)](https://www.patreon.com/torann) 6 | [![Donate weekly to this project using Gratipay](https://img.shields.io/badge/gratipay-donate-yellow.svg)](https://gratipay.com/~torann) 7 | [![Donate to this project using Flattr](https://img.shields.io/badge/flattr-donate-yellow.svg)](https://flattr.com/profile/torann) 8 | [![Donate to this project using Paypal](https://img.shields.io/badge/Donate-PayPal-green.svg)](https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=4CJA2A97NPYVU) 9 | 10 | Index and search Laravel models on Amazon's CloudSearch. To get started, you should have a basic knowledge of how CloudSearch works. 11 | 12 | ## Installation 13 | 14 | ### Composer 15 | 16 | From the command line run: 17 | 18 | ``` 19 | $ composer require torann/laravel-cloudsearch 20 | ``` 21 | 22 | ### Laravel 23 | 24 | Once installed you need to register the service provider with the application. Open up `config/app.php` and find the `providers` key. 25 | 26 | ``` php 27 | 'providers' => [ 28 | 29 | LaravelCloudSearch\LaravelCloudSearchServiceProvider::class, 30 | 31 | ] 32 | ``` 33 | 34 | ### Lumen 35 | 36 | For Lumen register the service provider in `bootstrap/app.php`. 37 | 38 | ``` php 39 | $app->register(LaravelCloudSearch\LaravelCloudSearchServiceProvider::class); 40 | ``` 41 | 42 | ### Publish the configurations 43 | 44 | Run this on the command line from the root of your project: 45 | 46 | ``` 47 | $ php artisan vendor:publish --provider="LaravelCloudSearch\LaravelCloudSearchServiceProvider" --tag=config 48 | ``` 49 | 50 | A configuration file will be publish to `config/cloud-search.php`. 51 | 52 | 53 | ### Migration 54 | 55 | The package uses a batch queue system for updating the documents on AWS. This is done to help reduce the number of calls made to the API (will save money in the long run). 56 | 57 | ```bash 58 | php artisan vendor:publish --provider="LaravelCloudSearch\LaravelCloudSearchServiceProvider" --tag=migrations 59 | ``` 60 | 61 | Run this on the command line from the root of your project to generate the table for storing currencies: 62 | 63 | ```bash 64 | $ php artisan migrate 65 | ``` 66 | 67 | ## Fields 68 | 69 | The better help manage fields, the package ships with a simple field management command. This is completely optional, as you can manage them in the AWS console. 70 | 71 | > **NOTE:** If you choose not to use this command to manage or setup your fields, you will still need to add the field `searchable_type` as a `literal`. This is used to store the model type. 72 | 73 | They can be found in the `config/cloud-search.php` file under the `fields` property: 74 | 75 | ```php 76 | 'fields' => [ 77 | 'title' => 'text', 78 | 'status' => 'literal', 79 | ], 80 | ``` 81 | 82 | ## Artisan Commands 83 | 84 | #### `search:fields` 85 | 86 | Initialize an Eloquent model map. 87 | 88 | #### `search:index ` 89 | 90 | Name or comma separated names of the model(s) to index. 91 | 92 | Arguments: 93 | 94 | ``` 95 | model Name or comma separated names of the model(s) to index 96 | ``` 97 | 98 | #### `search:flush ` 99 | 100 | Flush all of the model documents from the index. 101 | 102 | Arguments: 103 | 104 | ``` 105 | model Name or comma separated names of the model(s) to index 106 | ``` 107 | 108 | #### `search:queue` 109 | 110 | Reduces the number of calls made to the CloudSearch server by queueing the updates and deletes. 111 | 112 | ## Indexing 113 | 114 | Once you have added the `LaravelCloudSearch\Eloquent\Searchable` trait to a model, all you need to do is save a model instance and it will automatically be added to your index when the `search:queue` command is ran. 115 | 116 | ```php 117 | $post = new App\Post; 118 | 119 | // ... 120 | 121 | $post->save(); 122 | ``` 123 | 124 | > **Note**: if the model document has already been indexed, then it will simply be updated. If it does not exist, it will be added. 125 | 126 | ## Updating Documents 127 | 128 | To update an index model, you only need to update the model instance's properties and `save`` the model to your database. The package will automatically persist the changes to your search index: 129 | 130 | ```php 131 | $post = App\Post::find(1); 132 | 133 | // Update the post... 134 | 135 | $post->save(); 136 | ``` 137 | 138 | ## Removing Documents 139 | 140 | To remove a document from your index, simply `delete` the model from the database. This form of removal is even compatible with **soft deleted** models: 141 | 142 | ```php 143 | $post = App\Post::find(1); 144 | 145 | $post->delete(); 146 | ``` 147 | 148 | ## Searching 149 | 150 | You may begin searching a model using the `search` method. The search method accepts a single string that will be used to search your models. You should then chain the `get` method onto the search query to retrieve the Eloquent models that match the given search query: 151 | 152 | ```php 153 | $posts = App\Post::search('Kitten fluff')->get(); 154 | ``` 155 | 156 | Since package searches return a collection of Eloquent models, you may even return the results directly from a route or controller and they will automatically be converted to JSON: 157 | 158 | ```php 159 | use Illuminate\Http\Request; 160 | 161 | Route::get('/search', function (Request $request) { 162 | return App\Post::search($request->search)->get(); 163 | }); 164 | ``` 165 | 166 | ## Pagination 167 | 168 | In addition to retrieving a collection of models, you may paginate your search results using the `paginate` method. This method will return a `Paginator` instance just as if you had paginated a traditional Eloquent query: 169 | 170 | ```php 171 | $posts = App\Post::search('Kitten fluff')->paginate(); 172 | ``` 173 | You may specify how many models to retrieve per page by passing the amount as the first argument to the `paginate` method: 174 | 175 | ```php 176 | $posts = App\Post::search('Kitten fluff')->paginate(15); 177 | ``` 178 | Once you have retrieved the results, you may display the results and render the page links using Blade just as if you had paginated a traditional Eloquent query: 179 | 180 | ```blade 181 |
182 | @foreach ($posts as $post) 183 | {{ $post->title }} 184 | @endforeach 185 |
186 | 187 | {{ $posts->links() }} 188 | ``` 189 | 190 | ## Basic Builder Usage 191 | 192 | Initialize a builder instance: 193 | 194 | ```php 195 | $query = app(\LaravelCloudSearch\CloudSearcher::class)->newQuery(); 196 | ``` 197 | 198 | You can chain query methods like so: 199 | 200 | ```php 201 | $query->phrase('ford') 202 | ->term('National Equipment', 'seller') 203 | ->range('year', '2010'); 204 | ``` 205 | 206 | use the `get()` or `paginate()` methods to submit query and retrieve results from AWS. 207 | 208 | ```php 209 | $results = $query->get(); 210 | ``` 211 | 212 | In the example above we did not set the search type, so this means the results that are returned will match any document on CloudSearch domain. To refine you search to certain model, either use the model like shown in the example previously or use the `searchableType()` method to set the class name of the model (this is done automatically in the model instance call): 213 | 214 | ```php 215 | $query = app(\LaravelCloudSearch\CloudSearcher::class)->newQuery(); 216 | 217 | $results = $query->searchableType(\App\LawnMower::class) 218 | ->term('honda', 'name') 219 | ->get(); 220 | ``` 221 | 222 | ### Search Query Operators and Nested Queries 223 | 224 | You can use the `and`, `or`, and `not` operators to build compound and nested queries. The corresponding `and()`, `or()`, and `not()` methods expect a closure as their argument. You can chain all available methods as well nest more sub-queries inside of closures. 225 | 226 | ```php 227 | $query->or(function($builder) { 228 | $builder->phrase('ford') 229 | ->phrase('truck'); 230 | }); 231 | ``` 232 | 233 | ## Queue 234 | 235 | The help reduce the number of bulk requests made to the CloudSearch endpoint (because they cost) a queue system is used. This can be set in Laravel [Task Scheduling](https://laravel.com/docs/5.4/scheduling). You can decide how often it is ran using the scheduled task frequency options. Please note this uses the DB to function. 236 | 237 | Example of the task added to `/app/Console/Kernel.php`: 238 | 239 | ```php 240 | /** 241 | * Define the application's command schedule. 242 | * 243 | * @param \Illuminate\Console\Scheduling\Schedule $schedule 244 | * 245 | * @return void 246 | */ 247 | protected function schedule(Schedule $schedule) 248 | { 249 | $schedule->command('search:queue')->everyTenMinutes(); 250 | } 251 | ``` 252 | 253 | ## Multilingual 254 | 255 | > This feature is experimental 256 | 257 | Laravel CloudSearch can support multiple languages by appending the language code to the index type, so when the system performs a search it will only look for data that is on in the current system locale suffixed index type. For this to work the model needs to use the `LaravelCloudSearch\Eloquent\Localized` trait or something similar to it. 258 | -------------------------------------------------------------------------------- /src/CloudSearcher.php: -------------------------------------------------------------------------------- 1 | config = $config; 50 | } 51 | 52 | /** 53 | * Queue the given model action. 54 | * 55 | * @param string $action 56 | * @param Model $model 57 | * 58 | * @return bool 59 | */ 60 | public function queue($action, Model $model) 61 | { 62 | switch($action) { 63 | case 'update': 64 | return $this->searchQueue()->push('update', $model->getKey(), get_class($model)); 65 | break; 66 | case 'delete': 67 | return $this->searchQueue()->push('delete', $this->getSearchDocumentId($model), get_class($model)); 68 | break; 69 | } 70 | 71 | return false; 72 | } 73 | 74 | /** 75 | * Add/Update the given models in the index. 76 | * 77 | * @param Collection $models 78 | * 79 | * @return array 80 | */ 81 | public function update(Collection $models) 82 | { 83 | $payload = new Collection(); 84 | 85 | $models->each(function ($model) use ($payload) { 86 | if ($fields = $this->getSearchDocument($model)) { 87 | $payload->push([ 88 | 'type' => 'add', 89 | 'id' => $this->getSearchDocumentId($model), 90 | 'fields' => array_map(function ($value) { 91 | return is_null($value) ? '' : $value; 92 | }, $fields), 93 | ]); 94 | } 95 | }); 96 | 97 | return $this->domainClient()->uploadDocuments([ 98 | 'documents' => json_encode($payload->all()), 99 | 'contentType' => 'application/json', 100 | ]); 101 | } 102 | 103 | /** 104 | * Remove from search index 105 | * 106 | * @param Collection $entries 107 | * 108 | * @return array 109 | */ 110 | public function delete(Collection $entries) 111 | { 112 | $payload = new Collection(); 113 | 114 | // Add to the payload 115 | $entries->each(function ($model) use ($payload) { 116 | $search_document_id = $model instanceof Model 117 | ? $this->getSearchDocumentId($model) 118 | : $model; 119 | 120 | $payload->push([ 121 | 'type' => 'delete', 122 | 'id' => $search_document_id, 123 | ]); 124 | }); 125 | 126 | return $this->domainClient()->uploadDocuments([ 127 | 'documents' => json_encode($payload->all()), 128 | 'contentType' => 'application/json', 129 | ]); 130 | } 131 | 132 | /** 133 | * Quick and simple search used for autocompletion. 134 | * 135 | * @param string $term 136 | * @param int $perPage 137 | * 138 | * @return LengthAwarePaginator|array 139 | */ 140 | public function searchAll($term, $perPage = 15) 141 | { 142 | return $this->newQuery() 143 | ->term($term) 144 | ->take($perPage) 145 | ->get(); 146 | } 147 | 148 | /** 149 | * Get search results. 150 | * 151 | * @param StructuredQueryBuilder $builder 152 | * 153 | * @return Collection 154 | */ 155 | public function get(StructuredQueryBuilder $builder) 156 | { 157 | return $this->hydrateResults(Arr::get($this->execute($builder), 'hits.hit', [])); 158 | } 159 | 160 | /** 161 | * Paginate the given search results. 162 | * 163 | * @param StructuredQueryBuilder $builder 164 | * @param int $perPage 165 | * 166 | * @return \Illuminate\Contracts\Pagination\LengthAwarePaginator 167 | */ 168 | public function paginate(StructuredQueryBuilder $builder, $perPage = 15) 169 | { 170 | // Get current page 171 | $page = LengthAwarePaginator::resolveCurrentPage(); 172 | 173 | // Set pagination params 174 | $builder->size($perPage) 175 | ->start((($page * $perPage) - $perPage)); 176 | 177 | // Make request 178 | return $this->paginateResults($this->execute($builder), $page, $perPage); 179 | } 180 | 181 | /** 182 | * Perform the given search. 183 | * 184 | * @param StructuredQueryBuilder $builder 185 | * 186 | * @return mixed 187 | */ 188 | public function execute(StructuredQueryBuilder $builder) 189 | { 190 | try { 191 | return $this->domainClient()->search($builder->buildStructuredQuery()); 192 | } 193 | catch (CloudSearchDomainException $e) { 194 | dd($e->getAwsErrorMessage() ?: $e->getMessage()); 195 | 196 | return $e->getAwsErrorMessage() ?: $e->getMessage(); 197 | } 198 | } 199 | 200 | /** 201 | * Create collection from results. 202 | * 203 | * @param array $items 204 | * 205 | * @return Collection 206 | */ 207 | protected function hydrateResults(array $items) 208 | { 209 | $items = array_map(function ($item) { 210 | return $this->newFromHitBuilder($item); 211 | }, $items); 212 | 213 | return Collection::make($items); 214 | } 215 | 216 | /** 217 | * Paginate the given query into a simple paginator. 218 | * 219 | * @param Result $result 220 | * @param int $page 221 | * @param int $perPage 222 | * @param array $append 223 | * 224 | * @return \Illuminate\Contracts\Pagination\LengthAwarePaginator 225 | */ 226 | protected function paginateResults(Result $result, $page, $perPage, array $append = []) 227 | { 228 | // Get total number of pages 229 | $total = Arr::get($result, 'hits.found', 0); 230 | 231 | // Create pagination instance 232 | $paginator = (new LengthAwarePaginator($this->hydrateResults(Arr::get($result, 'hits.hit', [])), $total, $perPage, $page, [ 233 | 'path' => Paginator::resolveCurrentPath(), 234 | ])); 235 | 236 | return $paginator->appends($append); 237 | } 238 | 239 | /** 240 | * New from hit builder. 241 | * 242 | * @param array $hit 243 | * 244 | * @return Model 245 | */ 246 | protected function newFromHitBuilder($hit = []) 247 | { 248 | // Reconstitute the attributes from the field values 249 | $attributes = array_map(function ($field) { 250 | return $field[0]; 251 | }, Arr::get($hit, 'fields', [])); 252 | 253 | // Get model name from source 254 | if (!($model = Arr::pull($attributes, 'searchable_type'))) return null; 255 | 256 | // Set type 257 | $attributes['result_type'] = $this->getClassBasename($model); 258 | 259 | // Create model instance from type 260 | return $this->newFromBuilderRecursive(new $model, $attributes); 261 | } 262 | 263 | /** 264 | * Create a new model instance that is existing recursive. 265 | * 266 | * @param Model $model 267 | * @param array $attributes 268 | * @param Relation $parentRelation 269 | * 270 | * @return Model 271 | */ 272 | protected function newFromBuilderRecursive(Model $model, array $attributes = [], Relation $parentRelation = null) 273 | { 274 | // Create a new instance of the given model 275 | $instance = $model->newInstance([], $exists = true); 276 | 277 | // Set the array of model attributes 278 | $instance->setRawAttributes((array)$attributes, $sync = true); 279 | 280 | // Load relations recursive 281 | $this->loadRelationsAttributesRecursive($instance); 282 | 283 | // Load pivot 284 | $this->loadPivotAttribute($instance, $parentRelation); 285 | 286 | return $instance; 287 | } 288 | 289 | /** 290 | * Get the relations attributes from a model. 291 | * 292 | * @param Model $model 293 | */ 294 | protected function loadRelationsAttributesRecursive(Model $model) 295 | { 296 | $attributes = $model->getAttributes(); 297 | 298 | foreach ($attributes as $key => $value) { 299 | if (method_exists($model, $key)) { 300 | $reflection_method = new ReflectionMethod($model, $key); 301 | 302 | if ($reflection_method->class != 'Illuminate\Database\Eloquent\Model') { 303 | $relation = $model->$key(); 304 | 305 | if ($relation instanceof Relation) { 306 | // Check if the relation field is single model or collections 307 | if (is_null($value) === true || !$this->isMultiLevelArray($value)) { 308 | $value = [$value]; 309 | } 310 | 311 | $models = $this->hydrateRecursive($relation->getModel(), $value, $relation); 312 | 313 | // Unset attribute before match relation 314 | unset($model[$key]); 315 | $relation->match([$model], $models, $key); 316 | } 317 | } 318 | } 319 | } 320 | } 321 | 322 | /** 323 | * Get the pivot attribute from a model. 324 | * 325 | * @param Model $model 326 | * @param Relation $parentRelation 327 | */ 328 | protected function loadPivotAttribute(Model $model, Relation $parentRelation = null) 329 | { 330 | foreach ($model->getAttributes() as $key => $value) { 331 | if ($key === 'pivot') { 332 | unset($model[$key]); 333 | 334 | $pivot = $parentRelation->newExistingPivot($value); 335 | $model->setRelation($key, $pivot); 336 | } 337 | } 338 | } 339 | 340 | /** 341 | * Check if an array is multi-level array like [[id], [id], [id]]. 342 | * 343 | * For detect if a relation field is single model or collections. 344 | * 345 | * @param array $array 346 | * 347 | * @return boolean 348 | */ 349 | protected function isMultiLevelArray(array $array) 350 | { 351 | foreach ($array as $key => $value) { 352 | if (!is_array($value)) { 353 | return false; 354 | } 355 | } 356 | 357 | return true; 358 | } 359 | 360 | /** 361 | * Create a collection of models from plain arrays recursive. 362 | * 363 | * @param Model $model 364 | * @param Relation $parentRelation 365 | * @param array $items 366 | * 367 | * @return Collection 368 | */ 369 | protected function hydrateRecursive(Model $model, array $items, Relation $parentRelation = null) 370 | { 371 | $items = array_map(function ($item) use ($model, $parentRelation) { 372 | return $this->newFromBuilderRecursive($model, ($item ?: []), $parentRelation); 373 | }, $items); 374 | 375 | return $model->newCollection($items); 376 | } 377 | 378 | /** 379 | * Get index document data for Laravel CloudSearch. 380 | * 381 | * @param Model $model 382 | * 383 | * @return array|null 384 | */ 385 | protected function getSearchDocument(Model $model) 386 | { 387 | if ($data = $model->getSearchDocument()) { 388 | $data['searchable_type'] = get_class($model); 389 | } 390 | 391 | return $data; 392 | } 393 | 394 | /** 395 | * Create a document ID for Laravel CloudSearch using the searchable ID and 396 | * the class name of the model. 397 | * 398 | * @param Model $model 399 | * 400 | * @return string 401 | */ 402 | protected function getSearchDocumentId(Model $model) 403 | { 404 | return $this->getClassBasename($model) . '-' . $model->getSearchableId(); 405 | } 406 | 407 | /** 408 | * Get configuration value. 409 | * 410 | * @param string $key 411 | * @param mixed $default 412 | * 413 | * @return mixed 414 | */ 415 | public function config($key, $default = null) 416 | { 417 | return Arr::get($this->config, $key, $default); 418 | } 419 | 420 | /** 421 | * Return the CloudSearch domain client instance. 422 | * 423 | * @return CloudSearchDomainClient 424 | */ 425 | public function domainClient() 426 | { 427 | if (is_null($this->domainClient)) { 428 | $this->domainClient = new CloudSearchDomainClient($this->config('config')); 429 | } 430 | 431 | return $this->domainClient; 432 | } 433 | 434 | /** 435 | * Return the CloudSearch client instance. 436 | * 437 | * @return CloudSearchClient 438 | */ 439 | public function searchClient() 440 | { 441 | if (is_null($this->searchClient)) { 442 | $this->searchClient = new CloudSearchClient([ 443 | 'region' => $this->config('config.region'), 444 | 'credentials' => $this->config('config.credentials'), 445 | 'version' => $this->config('config.version'), 446 | ]); 447 | } 448 | 449 | return $this->searchClient; 450 | } 451 | 452 | /** 453 | * Get the queue instance. 454 | * 455 | * @return Queue 456 | */ 457 | public function searchQueue() 458 | { 459 | return app(Queue::class); 460 | } 461 | 462 | /** 463 | * Get the class "basename" of the given object / class. 464 | * 465 | * @param string|object $class 466 | * 467 | * @return string 468 | */ 469 | protected function getClassBasename($class) 470 | { 471 | $class = is_object($class) ? get_class($class) : $class; 472 | 473 | return basename(str_replace('\\', '/', strtolower($class))); 474 | } 475 | 476 | /** 477 | * Create a new query builder instance. 478 | * 479 | * @return Builder 480 | */ 481 | public function newQuery() 482 | { 483 | return new Builder($this); 484 | } 485 | } 486 | -------------------------------------------------------------------------------- /src/Console/AbstractCommand.php: -------------------------------------------------------------------------------- 1 | cloudSearcher = $cloudSearcher; 39 | $this->batching_size = $cloudSearcher->config('batching_size', 100); 40 | 41 | $this->models = config('cloud-search.model_namespace', '\\App'); 42 | } 43 | 44 | /** 45 | * Perform action model mapping. 46 | * 47 | * @param string $action 48 | */ 49 | protected function processModels($action) 50 | { 51 | // Check for multilingual support 52 | $locales = $this->getLocales(); 53 | 54 | // Process all provided models 55 | foreach ($this->getModelArgument() as $model) { 56 | if ($model = $this->validateModel("{$this->models}\\{$model}")) { 57 | 58 | // Get model instance 59 | $instance = new $model(); 60 | 61 | // Perform action 62 | if (empty($locales) === false && method_exists($instance, 'getLocalizedSearchableId')) { 63 | 64 | // Process each locale using the by locale macro 65 | foreach ($locales as $locale) { 66 | 67 | $this->line("\nIndexing locale: {$locale}"); 68 | 69 | $this->$action( 70 | $instance->byLocale($locale), 71 | $model 72 | ); 73 | } 74 | } 75 | else { 76 | $this->$action($instance, $model); 77 | } 78 | } 79 | } 80 | } 81 | 82 | /** 83 | * Get action argument. 84 | * 85 | * @param array $validActions 86 | * 87 | * @return array 88 | */ 89 | protected function getActionArgument($validActions = []) 90 | { 91 | $action = strtolower($this->argument('action')); 92 | 93 | if (in_array($action, $validActions) === false) { 94 | throw new \RuntimeException("The [{$action}] option does not exist."); 95 | } 96 | 97 | return $action; 98 | } 99 | 100 | /** 101 | * Get model argument. 102 | * 103 | * @return array 104 | */ 105 | protected function getModelArgument() 106 | { 107 | $models = explode(',', preg_replace('/\s+/', '', $this->argument('model'))); 108 | 109 | return array_map(function ($model) { 110 | $model = array_map(function ($m) { 111 | return Str::studly($m); 112 | }, explode('\\', $model)); 113 | 114 | return implode('\\', $model); 115 | }, $models); 116 | } 117 | 118 | /** 119 | * Get an array of supported locales. 120 | * 121 | * @return array|null 122 | */ 123 | protected function getLocales() 124 | { 125 | // Get user specified locales 126 | if ($locales = $this->option('locales')) { 127 | return array_filter(explode(',', preg_replace('/\s+/', '', $locales))); 128 | } 129 | 130 | // Check for package 131 | if (class_exists('\\Torann\\Localization\\LocaleManager')) { 132 | return app(LocaleManager::class)->getSupportedLanguagesKeys(); 133 | } 134 | 135 | return config('cloud-search.support_locales'); 136 | } 137 | 138 | /** 139 | * Validate model. 140 | * 141 | * @param string $model 142 | * 143 | * @return bool 144 | */ 145 | protected function validateModel($model) 146 | { 147 | // Verify model existence 148 | if (class_exists($model) === false) { 149 | $this->error("Model [{$model}] not found"); 150 | 151 | return false; 152 | } 153 | 154 | // Verify model is Elasticsearch ready 155 | if (method_exists($model, 'getSearchDocument') === false) { 156 | $this->error("Model [{$model}] does not support searching."); 157 | 158 | return false; 159 | } 160 | 161 | return $model; 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /src/Console/FieldsCommand.php: -------------------------------------------------------------------------------- 1 | error("No fields defined in the config."); 45 | return null; 46 | } 47 | 48 | // Ensure the system specific value is set 49 | $fields['searchable_type'] = 'literal'; 50 | 51 | // Set CloudSearch domain for later 52 | $this->domain = $this->cloudSearcher->config('domain_name'); 53 | 54 | $this->getOutput()->write('Syncing fields'); 55 | 56 | // Process everything 57 | $this->syncCurrentFields($fields); 58 | $this->syncNewFields($fields); 59 | 60 | $this->getOutput()->writeln('success'); 61 | $this->line(''); 62 | 63 | // Check for changes 64 | if ($this->changes > 0) { 65 | $this->comment("{$this->changes} field change(s) were made to the \"{$this->domain}\" domain, these changes will not be reflected in search results until the index is rebuilt."); 66 | 67 | if ($this->confirm("Would you like to rebuild the index?")) { 68 | $this->runIndexing(); 69 | } 70 | } 71 | else { 72 | $this->comment('Fields are up to date.'); 73 | } 74 | } 75 | 76 | /** 77 | * Update or remove any current fields. 78 | * 79 | * @param array $fields 80 | */ 81 | protected function syncCurrentFields(&$fields) 82 | { 83 | foreach($this->getFields() as $name=>$type) { 84 | 85 | // Was the field removed 86 | if (($current_type = Arr::get($fields, $name)) === null) { 87 | $this->deleteField($name); 88 | } 89 | 90 | // Was the field changed 91 | else if ($current_type !== $type) { 92 | $this->defineField($name, $type); 93 | } 94 | 95 | unset($fields[$name]); 96 | 97 | $this->getOutput()->write('.'); 98 | } 99 | } 100 | 101 | /** 102 | * Sync new fields. 103 | * 104 | * @param array $fields 105 | */ 106 | protected function syncNewFields($fields) 107 | { 108 | foreach($fields as $name=>$type) { 109 | $this->defineField($name, $type); 110 | $this->getOutput()->write('.'); 111 | } 112 | } 113 | 114 | /** 115 | * Get all fields for the domain. 116 | * 117 | * @param array $fields 118 | * 119 | * @return array 120 | */ 121 | protected function getFields(array $fields = []) 122 | { 123 | $response = $this->cloudSearcher->searchClient()->describeIndexFields([ 124 | 'DomainName' => $this->domain, 125 | ]); 126 | 127 | foreach(Arr::get($response, 'IndexFields', []) as $value) { 128 | $fields[Arr::get($value, 'Options.IndexFieldName')] = Arr::get($value, 'Options.IndexFieldType'); 129 | } 130 | 131 | return $fields; 132 | } 133 | 134 | /** 135 | * Create or updates a field in the domain. 136 | * 137 | * @param string $field 138 | * @param string $type 139 | * 140 | * @return bool 141 | */ 142 | protected function defineField($field, $type) 143 | { 144 | $response = $this->cloudSearcher->searchClient()->defineIndexField([ 145 | 'DomainName' => $this->domain, 146 | 'IndexField' => [ 147 | 'IndexFieldName' => $field, 148 | 'IndexFieldType' => $type, 149 | ], 150 | ]); 151 | 152 | // Check for success 153 | if ($result = Arr::get($response, '@metadata.statusCode') == 200) { 154 | $this->changes++; 155 | } 156 | 157 | return $result; 158 | } 159 | 160 | /** 161 | * Delete the given field from the domain. 162 | * 163 | * @param string $field 164 | * 165 | * @return bool 166 | */ 167 | protected function deleteField($field) 168 | { 169 | $response = $this->cloudSearcher->searchClient()->deleteIndexField([ 170 | 'DomainName' => $this->domain, 171 | 'IndexFieldName' => $field, 172 | ]); 173 | 174 | // Check for success 175 | if ($result = Arr::get($response, '@metadata.statusCode') == 200) { 176 | $this->changes++; 177 | } 178 | 179 | return $result; 180 | } 181 | 182 | /** 183 | * Tells the search domain to start indexing its documents using the latest indexing options. 184 | */ 185 | protected function runIndexing() 186 | { 187 | $response = $this->cloudSearcher->searchClient()->indexDocuments([ 188 | 'DomainName' => $this->domain, 189 | ]); 190 | 191 | if (Arr::get($response, '@metadata.statusCode') == 200) { 192 | $this->line('CloudSearch is currently rebuilding your index.'); 193 | } 194 | else { 195 | $this->error('Something prevented the rebuild process. Log into your AWS console to find out more.'); 196 | } 197 | } 198 | } 199 | -------------------------------------------------------------------------------- /src/Console/FlushCommand.php: -------------------------------------------------------------------------------- 1 | processModels('flush'); 33 | } 34 | 35 | /** 36 | * Index all model entries to ElasticSearch. 37 | * 38 | * @param Model $instance 39 | * @param string $name 40 | * 41 | * @return bool 42 | */ 43 | protected function flush(Model $instance, $name) 44 | { 45 | $this->getOutput()->write("Flushing [{$name}]"); 46 | 47 | $instance->chunk($this->batching_size, function ($models) { 48 | $this->cloudSearcher->delete($models); 49 | $this->getOutput()->write(str_repeat('.', $models->count())); 50 | }); 51 | 52 | $this->getOutput()->writeln('done'); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/Console/IndexCommand.php: -------------------------------------------------------------------------------- 1 | processModels('index'); 33 | } 34 | 35 | /** 36 | * Index all model entries to ElasticSearch. 37 | * 38 | * @param Model $instance 39 | * @param string $name 40 | * 41 | * @return bool 42 | */ 43 | protected function index(Model $instance, $name) 44 | { 45 | $this->getOutput()->write("Indexing [{$name}]"); 46 | 47 | $instance->chunk($this->batching_size, function ($models) use (&$total) { 48 | $this->cloudSearcher->update($models); 49 | $this->getOutput()->write(str_repeat('.', $models->count())); 50 | }); 51 | 52 | $this->getOutput()->writeln('done'); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/Console/QueueCommand.php: -------------------------------------------------------------------------------- 1 | cloudSearcher = $cloudSearcher; 51 | $this->batching_size = $cloudSearcher->config('batching_size', 100); 52 | $this->queue = $queue; 53 | } 54 | 55 | /** 56 | * Execute the console command. 57 | */ 58 | public function handle() 59 | { 60 | $this->line('Processing search queue'); 61 | 62 | $this->queue->getBatch()->each(function ($collection, $action) { 63 | $collection->groupBy('entry_type')->each(function ($items, $model) use ($action) { 64 | $this->{$action}($items, $model); 65 | }); 66 | }); 67 | 68 | $this->queue->flushBatch(); 69 | } 70 | 71 | /** 72 | * Add or update given models in the search index. 73 | * 74 | * @param \Illuminate\Support\Collection $items 75 | * @param string $model 76 | */ 77 | protected function update($items, $model) 78 | { 79 | // Get the model's primary key 80 | $instance = new $model; 81 | 82 | // Create a full column name 83 | $key = $instance->getTable() . '.' . $instance->getKeyName(); 84 | 85 | // Process all models 86 | $model::whereIn($key, $items->pluck('entry_id'))->chunk($this->batching_size, function($models) { 87 | $this->cloudSearcher->update($models); 88 | }); 89 | } 90 | 91 | /** 92 | * Delete given models from search index. 93 | * 94 | * @param \Illuminate\Support\Collection $items 95 | * @param string $model 96 | */ 97 | protected function delete($items, $model) 98 | { 99 | foreach($items->chunk($this->batching_size) as $models) { 100 | $this->cloudSearcher->delete($models->pluck('entry_id')); 101 | } 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/Eloquent/Localized.php: -------------------------------------------------------------------------------- 1 | locale . '-' . $this->getKey(); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Eloquent/LocalizedScope.php: -------------------------------------------------------------------------------- 1 | where($model->getTable() . '.locale', app()->getLocale()); 20 | } 21 | 22 | /** 23 | * Extend the query builder with the needed functions. 24 | * 25 | * @param \Illuminate\Database\Eloquent\Builder $builder 26 | */ 27 | public function extend(Builder $builder) 28 | { 29 | $this->addByLocale($builder); 30 | $this->addWithoutLocalization($builder); 31 | } 32 | 33 | /** 34 | * Add the by locale extension to the builder. 35 | * 36 | * @param \Illuminate\Database\Eloquent\Builder $builder 37 | * @return void 38 | */ 39 | public function addByLocale(Builder $builder) 40 | { 41 | $builder->macro('byLocale', function (Builder $builder, $locale) { 42 | 43 | $builder->withoutGlobalScope($this); 44 | 45 | return $builder->where($builder->getModel()->getTable() . '.locale', $locale); 46 | }); 47 | } 48 | 49 | /** 50 | * Add the without-moderated extension to the builder. 51 | * 52 | * @param \Illuminate\Database\Eloquent\Builder $builder 53 | */ 54 | protected function addWithoutLocalization(Builder $builder) 55 | { 56 | $builder->macro('withoutLocalization', function (Builder $builder) { 57 | return $builder->withoutGlobalScope($this); 58 | }); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/Eloquent/Observer.php: -------------------------------------------------------------------------------- 1 | addToCloudSearch(); 15 | } 16 | 17 | /** 18 | * Handle the deleted event for the model. 19 | * 20 | * @param \Illuminate\Database\Eloquent\Model $model 21 | */ 22 | public function deleted($model) 23 | { 24 | $model->deleteFromCloudSearch(); 25 | } 26 | 27 | /** 28 | * Handle the restored event for the model. 29 | * 30 | * @param \Illuminate\Database\Eloquent\Model $model 31 | */ 32 | public function restored($model) 33 | { 34 | $this->saved($model); 35 | } 36 | } -------------------------------------------------------------------------------- /src/Eloquent/Searchable.php: -------------------------------------------------------------------------------- 1 | getCloudSearch()->queue('update', $this); 37 | 38 | } 39 | 40 | /** 41 | * Dispatch the job to make the model unsearchable. 42 | * 43 | * @return bool 44 | */ 45 | public function deleteFromCloudSearch() 46 | { 47 | return $this->getCloudSearch()->queue('delete', $this); 48 | } 49 | 50 | /** 51 | * Perform a search against the model's indexed data. 52 | * 53 | * @param string $query 54 | * 55 | * @return \LaravelCloudSearch\Query\Builder 56 | */ 57 | public static function search($query) 58 | { 59 | return self::searchBuilder()->term($query); 60 | } 61 | 62 | /** 63 | * Get the search builder instance. 64 | * 65 | * @return \LaravelCloudSearch\Query\Builder 66 | */ 67 | public static function searchBuilder() 68 | { 69 | $instance = new static(); 70 | 71 | $builder = new Builder($instance->getCloudSearch()); 72 | 73 | return $builder->searchableType($instance); 74 | } 75 | 76 | /** 77 | * Get search document ID for the model. 78 | * 79 | * @return string|int 80 | */ 81 | public function getSearchableId() 82 | { 83 | if (method_exists($this, 'getLocalizedSearchableId')) { 84 | return $this->getLocalizedSearchableId(); 85 | } 86 | 87 | return $this->getKey(); 88 | } 89 | 90 | /** 91 | * Get search document data for the model. 92 | * 93 | * @return array 94 | */ 95 | abstract public function getSearchDocument(); 96 | 97 | /** 98 | * Get a CloudSearch for the model. 99 | * 100 | * @return CloudSearcher 101 | */ 102 | public function getCloudSearch() 103 | { 104 | return app(CloudSearcher::class); 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/LaravelCloudSearchServiceProvider.php: -------------------------------------------------------------------------------- 1 | app->singleton(CloudSearcher::class, function ($app) { 17 | return new CloudSearcher($app->config->get('cloud-search')); 18 | }); 19 | 20 | $this->app->singleton(Queue::class, function ($app) { 21 | return new Queue($app['db']); 22 | }); 23 | 24 | if ($this->app->runningInConsole()) { 25 | $this->registerResources(); 26 | $this->registerCommands(); 27 | } 28 | } 29 | 30 | /** 31 | * Register the resources. 32 | * 33 | * @return bool 34 | */ 35 | protected function registerResources() 36 | { 37 | if ($this->isLumen() === false) { 38 | $this->publishes([ 39 | __DIR__.'/../config/cloud-search.php' => config_path('cloud-search.php'), 40 | ], 'config'); 41 | 42 | $this->mergeConfigFrom( 43 | __DIR__.'/../config/cloud-search.php', 'cloud-search' 44 | ); 45 | } 46 | 47 | $this->publishes([ 48 | __DIR__ . '/../database/migrations' => base_path('/database/migrations'), 49 | ], 'migrations'); 50 | } 51 | 52 | /** 53 | * Register all commands. 54 | * 55 | * @return void 56 | */ 57 | public function registerCommands() 58 | { 59 | $this->commands([ 60 | Console\FieldsCommand::class, 61 | Console\FlushCommand::class, 62 | Console\IndexCommand::class, 63 | Console\QueueCommand::class, 64 | ]); 65 | } 66 | 67 | /** 68 | * Check if package is running under a Lumen app. 69 | * 70 | * @return bool 71 | */ 72 | protected function isLumen() 73 | { 74 | return str_contains($this->app->version(), 'Lumen') === true; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/Query/Builder.php: -------------------------------------------------------------------------------- 1 | cloudSearcher = $cloudSearcher; 25 | $this->builder = new StructuredQueryBuilder(); 26 | } 27 | 28 | /** 29 | * Set the searchable type. 30 | * 31 | * @param mixed $type 32 | * 33 | * @return self 34 | */ 35 | public function searchableType($type) 36 | { 37 | if (is_object($type)) { 38 | $type = get_class($type); 39 | } 40 | 41 | // Set the search type 42 | $this->phrase($type, 'searchable_type'); 43 | 44 | // Set locale 45 | if (method_exists($type, 'getLocalizedSearchableId')) { 46 | $this->byLocale(); 47 | } 48 | 49 | return $this; 50 | } 51 | 52 | /** 53 | * Set the locale to use for searching. 54 | * 55 | * @param string $locale 56 | * 57 | * @return self 58 | */ 59 | public function byLocale($locale = null) 60 | { 61 | // Use the current system locale if one is not set 62 | $this->phrase($locale ?: app()->getLocale(), 'locale'); 63 | 64 | return $this; 65 | } 66 | 67 | /** 68 | * Cursor method 69 | * 70 | * @param string $cursor 71 | * 72 | * @return self 73 | */ 74 | public function cursor($cursor = 'initial') 75 | { 76 | $this->builder->cursor($cursor); 77 | 78 | return $this; 79 | } 80 | 81 | /** 82 | * Set builder expression 83 | * 84 | * @param array $filters 85 | * 86 | * @return self 87 | */ 88 | public function filter(array $filters) 89 | { 90 | foreach($filters as $field=>$value) { 91 | if (is_array($value)) { 92 | foreach(array_flatten(array_filter($value)) as $v) { 93 | $this->phrase($v, $field); 94 | } 95 | } 96 | else { 97 | $this->phrase($value, $field); 98 | } 99 | } 100 | 101 | return $this; 102 | } 103 | 104 | /** 105 | * Set builder expression 106 | * 107 | * @param string $accessor 108 | * @param string $expression 109 | * 110 | * @return self 111 | */ 112 | public function expr($accessor, $expression) 113 | { 114 | $this->builder->expr($accessor, $expression); 115 | 116 | return $this; 117 | } 118 | 119 | /** 120 | * Build return facets array 121 | * 122 | * @param string $field 123 | * @param string $sort 124 | * @param integer $size 125 | * 126 | * @return self 127 | */ 128 | public function facet($field, $sort = "bucket", $size = 10) 129 | { 130 | $this->builder->facet($field, $sort, $size); 131 | 132 | return $this; 133 | } 134 | 135 | /** 136 | * Build return facets with explicit buckets 137 | * 138 | * @param string $field 139 | * @param array $buckets 140 | * @param string $method 141 | * 142 | * @return self 143 | */ 144 | public function facetBuckets($field, $buckets, $method = "filter") 145 | { 146 | $this->builder->facetBuckets($field, $buckets, $method); 147 | 148 | return $this; 149 | } 150 | 151 | /** 152 | * Create an 'and' wrapped query block 153 | * 154 | * @param Closure|string $block 155 | * 156 | * @return self 157 | */ 158 | public function qAnd($block) 159 | { 160 | $this->builder->q->qAnd($block); 161 | 162 | return $this; 163 | } 164 | 165 | /** 166 | * Create an 'and' wrapped filter query block 167 | * 168 | * @param Closure|string $block 169 | * 170 | * @return self 171 | */ 172 | public function filterAnd($block) 173 | { 174 | $this->builder->fq->qAnd($block); 175 | 176 | return $this; 177 | } 178 | 179 | /** 180 | * Build match all query 181 | * 182 | * @return self 183 | */ 184 | public function matchall() 185 | { 186 | $this->builder->q->matchall(); 187 | 188 | return $this; 189 | } 190 | 191 | /** 192 | * Create a near (sloppy) query 193 | * 194 | * @param string $value 195 | * @param string $field 196 | * @param int $distance 197 | * @param int $boost 198 | * 199 | * @return self 200 | */ 201 | public function near($value, $field = null, $distance = 3, $boost = null) 202 | { 203 | $this->builder->q->near($value, $field, $distance, $boost); 204 | 205 | return $this; 206 | } 207 | 208 | /** 209 | * Create a near (sloppy) query 210 | * 211 | * @param string $value 212 | * @param string $field 213 | * @param int $distance 214 | * @param int $boost 215 | * 216 | * @return self 217 | */ 218 | public function filterNear($value, $field, $distance = 3, $boost = null) 219 | { 220 | $this->builder->fq->near($value, $field, $distance, $boost); 221 | 222 | return $this; 223 | } 224 | 225 | /** 226 | * Create a 'not' wrapped query block 227 | * 228 | * @param Closure|string $block 229 | * 230 | * @return self 231 | */ 232 | public function qNot($block) 233 | { 234 | $this->builder->q->qNot($block); 235 | 236 | return $this; 237 | } 238 | 239 | /** 240 | * Create a 'not' wrapped query block 241 | * 242 | * @param Closure|string $block 243 | * 244 | * @return self 245 | */ 246 | public function filterNot($block) 247 | { 248 | $this->builder->fq->qNot($block); 249 | 250 | return $this; 251 | } 252 | 253 | /** 254 | * Create an 'or' wrapped query block 255 | * 256 | * @param Closure|string $block 257 | * 258 | * @return self 259 | */ 260 | public function qOr($block) 261 | { 262 | $this->builder->q->qOr($block); 263 | 264 | return $this; 265 | } 266 | 267 | /** 268 | * Create an 'or' wrapped query block 269 | * 270 | * @param Closure|string $block 271 | * 272 | * @return self 273 | */ 274 | public function filterOr($block) 275 | { 276 | $this->builder->fq->qOr($block); 277 | 278 | return $this; 279 | } 280 | 281 | /** 282 | * Create a phrase query 283 | * 284 | * @param string $value 285 | * @param string $field 286 | * @param int $boost 287 | * 288 | * @return self 289 | */ 290 | public function phrase($value, $field = null, $boost = null) 291 | { 292 | $this->builder->q->phrase($value, $field, $boost); 293 | 294 | return $this; 295 | } 296 | 297 | /** 298 | * Create a phrase query 299 | * 300 | * @param string $value 301 | * @param string $field 302 | * @param int $boost 303 | * 304 | * @return self 305 | */ 306 | public function filterPhrase($value, $field, $boost = null) 307 | { 308 | $this->builder->fq->phrase($value, $field, $boost); 309 | 310 | return $this; 311 | } 312 | 313 | /** 314 | * Create a prefix query 315 | * 316 | * @param string $value 317 | * @param string $field 318 | * @param int $boost 319 | * 320 | * @return self 321 | */ 322 | public function prefix($value, $field = null, $boost = null) 323 | { 324 | $this->builder->q->prefix($value, $field, $boost); 325 | 326 | return $this; 327 | } 328 | 329 | /** 330 | * Create a prefix query 331 | * 332 | * @param string $value 333 | * @param string $field 334 | * @param int $boost 335 | * 336 | * @return self 337 | */ 338 | public function filterPrefix($value, $field, $boost = null) 339 | { 340 | $this->builder->fq->prefix($value, $field, $boost); 341 | 342 | return $this; 343 | } 344 | 345 | /** 346 | * Create a range query 347 | * 348 | * @param string $field 349 | * @param string|int $min 350 | * @param string|int $max 351 | * 352 | * @return self 353 | */ 354 | public function range($field, $min, $max = null) 355 | { 356 | $this->builder->q->range($field, $min, $max); 357 | 358 | return $this; 359 | } 360 | 361 | /** 362 | * Create a range query 363 | * 364 | * @param string $field 365 | * @param string|int $min 366 | * @param string|int $max 367 | * 368 | * @return self 369 | */ 370 | public function filterRange($field, $min, $max = null) 371 | { 372 | $this->builder->fq->range($field, $min, $max); 373 | 374 | return $this; 375 | } 376 | 377 | /** 378 | * Create a term query 379 | * 380 | * @param string $value 381 | * @param string $field 382 | * @param int $boost 383 | * 384 | * @return self 385 | */ 386 | public function term($value, $field = null, $boost = null) 387 | { 388 | $this->builder->q->term($value, $field, $boost); 389 | 390 | return $this; 391 | } 392 | 393 | /** 394 | * Create a term query 395 | * 396 | * @param string $value 397 | * @param string $field 398 | * @param int $boost 399 | * 400 | * @return self 401 | */ 402 | public function filterTerm($value, $field, $boost = null) 403 | { 404 | $this->builder->fq->term($value, $field, $boost); 405 | 406 | return $this; 407 | } 408 | 409 | /** 410 | * Set return fields property of query 411 | * 412 | * @param string $value 413 | * 414 | * @return self 415 | */ 416 | public function returnFields($value) 417 | { 418 | $this->builder->returnFields($value); 419 | 420 | return $this; 421 | } 422 | 423 | /** 424 | * Set options property of query 425 | * 426 | * @param string $key 427 | * @param string $value 428 | * 429 | * @return self 430 | */ 431 | public function options($key, $value) 432 | { 433 | $this->builder->options($key, $value); 434 | 435 | return $this; 436 | } 437 | 438 | /** 439 | * Set the "limit" for the query. 440 | * 441 | * @param int $value 442 | * 443 | * @return self 444 | */ 445 | public function take($value) 446 | { 447 | $this->builder->size($value); 448 | 449 | return $this; 450 | } 451 | 452 | /** 453 | * Sort query 454 | * 455 | * @param string $field 456 | * @param string $direction 457 | * 458 | * @return self 459 | */ 460 | public function sort($field, $direction = 'asc') 461 | { 462 | $this->builder->sort($field, $direction); 463 | 464 | return $this; 465 | } 466 | 467 | /** 468 | * Set start property of query 469 | * 470 | * @param int $value 471 | * 472 | * @return self 473 | */ 474 | public function start($value) 475 | { 476 | $this->builder->start($value); 477 | 478 | return $this; 479 | } 480 | 481 | /** 482 | * Build field statistics 483 | * 484 | * @param string $field 485 | * 486 | * @return self 487 | */ 488 | public function stats($field) 489 | { 490 | $this->builder->stats($field); 491 | 492 | return $this; 493 | } 494 | 495 | /** 496 | * Build a location range filter 497 | * 498 | * @param string $field 499 | * @param string $lat 500 | * @param string $lon 501 | * @param integer $radius 502 | * @param bool $addExpr 503 | * 504 | * @return self 505 | */ 506 | public function latlon($field, $lat, $lon, $radius = 50, $addExpr = false) 507 | { 508 | $this->builder->latlon($field, $lat, $lon, $radius, $addExpr); 509 | 510 | return $this; 511 | } 512 | 513 | /** 514 | * Build distance expression 515 | * 516 | * @param string $field 517 | * @param string $lat 518 | * @param string $lon 519 | * 520 | * @return self 521 | */ 522 | public function addDistanceExpr($field, $lat, $lon) 523 | { 524 | $this->builder->addDistanceExpr($field, $lat, $lon); 525 | 526 | return $this; 527 | } 528 | 529 | /** 530 | * Get the first result from the search. 531 | * 532 | * @return \Illuminate\Database\Eloquent\Model 533 | */ 534 | public function first() 535 | { 536 | $this->take(1); 537 | 538 | return $this->get()->first(); 539 | } 540 | 541 | /** 542 | * Method to trigger request-response 543 | * 544 | * @return \Illuminate\Support\Collection 545 | */ 546 | public function get() 547 | { 548 | return $this->cloudSearch()->get($this->builder); 549 | } 550 | 551 | /** 552 | * Paginate the given query into a simple paginator. 553 | * 554 | * @param int $perPage 555 | * 556 | * @return \Illuminate\Contracts\Pagination\LengthAwarePaginator 557 | */ 558 | public function paginate($perPage = 15) 559 | { 560 | return $this->cloudSearch()->paginate($this->builder, $perPage); 561 | } 562 | 563 | /** 564 | * Get the CloudSearch to handle the query. 565 | * 566 | * @return \LaravelCloudSearch\CloudSearcher 567 | */ 568 | protected function cloudSearch() 569 | { 570 | return $this->cloudSearcher; 571 | } 572 | } -------------------------------------------------------------------------------- /src/Query/StructuredQueryBuilder.php: -------------------------------------------------------------------------------- 1 | q = new StructuredSearch; 102 | $this->fq = new StructuredSearch; 103 | } 104 | 105 | /** 106 | * Alias to get structured search query 107 | * 108 | * @return array 109 | */ 110 | public function getQuery() 111 | { 112 | return $this->q->getQuery(); 113 | } 114 | 115 | /** 116 | * Alias to get structured filter query 117 | * 118 | * @return array 119 | */ 120 | public function getFilterQuery() 121 | { 122 | return $this->fq->getQuery(); 123 | } 124 | 125 | /** 126 | * CURSOR 127 | * Retrieves a cursor value you can use to page through large result sets. 128 | * Use the size parameter to control the number of hits you want to include 129 | * in each response. You can specify either the cursor or start parameter in 130 | * a request, they are mutually exclusive. 131 | * 132 | * To get the first cursor, specify cursor=initial in your initial request. 133 | * In subsequent requests, specify the cursor value returned in the hits 134 | * section of the response. 135 | * 136 | * @param string $cursor 137 | * 138 | * @return self 139 | */ 140 | public function cursor($cursor = 'initial') 141 | { 142 | $this->cursor = $cursor == 0 ? 'initial' : $cursor; 143 | 144 | return $this; 145 | } 146 | 147 | /** 148 | * EXPRESSION 149 | * Defines an expression that can be used to sort results. You can also 150 | * specify an expression as a return field. 151 | * 152 | * @param string $accessor 153 | * @param string $expression 154 | * 155 | * @return self 156 | */ 157 | public function expr($accessor, $expression) 158 | { 159 | $this->expressions[$accessor] = $expression; 160 | } 161 | 162 | /** 163 | * FACET (sorted) 164 | * Specifies a field that you want to get facet information for—FIELD is the 165 | * name of the field. The specified field must be facet enabled in the 166 | * domain configuration. Facet options are specified as a JSON object. If 167 | * the JSON object is empty, facet.FIELD={}, facet counts are computed for 168 | * all field values, the facets are sorted by facet count, and the top 10 169 | * facets are returned in the results. 170 | * 171 | * sort specifies how you want to sort the facets in the results: bucket or 172 | * count. Specify bucket to sort alphabetically or numerically by facet 173 | * value (in ascending order). Specify count to sort by the facet counts 174 | * computed for each facet value (in descending order). 175 | * 176 | * size specifies the maximum number of facets to include in the results. 177 | * By default, Amazon CloudSearch returns counts for the top 10. 178 | * 179 | * @param string $field 180 | * @param string $sort 181 | * @param integer $size 182 | * 183 | * @return self 184 | */ 185 | public function facet($field, $sort = "bucket", $size = 10) 186 | { 187 | $this->facets[$field] = [ 188 | 'sort' => $sort, 189 | 'size' => $size, 190 | ]; 191 | } 192 | 193 | /** 194 | * FACET (BUCKETS) 195 | * specifies an array of the facet values or ranges you want to count. 196 | * Buckets are returned in the order they are specified in the request. To 197 | * specify a range of values, use a comma (,) to separate the upper and 198 | * lower bounds and enclose the range using brackets or braces. A square 199 | * bracket, [ or ], indicates that the bound is included in the range, a 200 | * curly brace, { or }, excludes the bound. You can omit the upper or lower 201 | * bound to specify an open-ended range. When omitting a bound, you must 202 | * use a curly brace. 203 | * 204 | * @param string $field 205 | * @@param array $buckets 206 | * @param string $method 207 | * 208 | * @return self 209 | */ 210 | public function facetBuckets($field, $buckets, $method = "filter") 211 | { 212 | $this->facets[$field] = [ 213 | 'buckets' => $buckets, 214 | 'method' => $method, 215 | ]; 216 | } 217 | 218 | /** 219 | * QUERY PARSER OPTIONS 220 | * Configure options for the query parser specified in the q.parser 221 | * parameter. The options are specified as a JSON object, for example: 222 | * q.options={defaultOperator: 'or', fields: ['title^5','description']} 223 | * 224 | * defaultOperator-The default operator used to combine individual terms 225 | * in the search string. (and|or) 226 | * defaultOperator: 'or' 227 | * 228 | * fields—An array of the fields to search when no fields are specified in 229 | * a search. You can specify a weight for each field to control the relative 230 | * importance of each field when Amazon CloudSearch calculates relevance scores. 231 | * fields: ['title^5','description'] 232 | * 233 | * @param string $key 234 | * @param mixed $value 235 | * 236 | * @return self 237 | */ 238 | public function options($key, $value) 239 | { 240 | $this->options[$key] = $value; 241 | 242 | return $this; 243 | } 244 | 245 | /** 246 | * The field and expression values to include in the response, specified as 247 | * a comma-separated list. By default, a search response includes all return 248 | * enabled fields (return=_all_fields). To return only the document IDs for 249 | * the matching documents, specify return=_no_fields. To retrieve the 250 | * relevance score calculated for each document, specify return=_score. You 251 | * specify multiple return fields as a comma separated list. For example, 252 | * return=title,_score returns just the title and relevance score of each 253 | * matching document. 254 | * 255 | * @param string $returnFields 256 | * 257 | * @return self 258 | */ 259 | public function returnFields($returnFields) 260 | { 261 | $this->returnFields = $returnFields; 262 | 263 | return $this; 264 | } 265 | 266 | /** 267 | * The maximum number of search hits to return. 268 | * 269 | * @param integer $size 270 | * 271 | * @return self 272 | */ 273 | public function size($size) 274 | { 275 | $this->size = $size; 276 | 277 | return $this; 278 | } 279 | 280 | /** 281 | * A comma-separated list of fields or custom expressions to use to sort the 282 | * search results. You must specify the sort direction (asc or desc) for 283 | * each field. For example, sort=year desc,title asc. You can specify a 284 | * maximum of 10 fields and expressions. To use a field to sort results, it 285 | * must be sort enabled in the domain configuration. Array type fields 286 | * cannot be used for sorting. If no sort parameter is specified, results 287 | * are sorted by their default relevance scores in descending order: 288 | * sort=_score desc. You can also sort by document ID (sort=_id) and 289 | * version (sort=_version). 290 | * 291 | * @param string $field 292 | * @param string $direction 293 | * 294 | * @return self 295 | */ 296 | public function sort($field, $direction = 'asc') 297 | { 298 | $this->sort = "{$field} {$direction}"; 299 | 300 | return $this; 301 | } 302 | 303 | /** 304 | * The offset of the first search hit you want to return. You can specify 305 | * either the start or cursor parameter in a request, they are mutually 306 | * exclusive. 307 | * 308 | * @param integer $start 309 | * 310 | * @return self 311 | */ 312 | public function start($start) 313 | { 314 | $this->start = $start; 315 | 316 | return $this; 317 | } 318 | 319 | /** 320 | * To get statistics for a field you use the stats.FIELD parameter. FIELD 321 | * is the name of a facet-enabled numeric field. You specify an empty JSON 322 | * object, stats.FIELD={}, to get all of the available statistics for the 323 | * specified field. (The stats.FIELD parameter does not support any options; 324 | * you must pass an empty JSON object.) You can request statistics for 325 | * multiple fields in the same request. 326 | * 327 | * You can get statistics only for facet-enabled numeric fields: date, 328 | * date-array, double, double-array, int, or int-array. Note that only the 329 | * count, max, min, and missing statistics are returned for date and 330 | * date-array fields. 331 | * 332 | * @param string $field 333 | * 334 | * @return self 335 | */ 336 | public function stats($field) 337 | { 338 | $this->stats[] = $field; 339 | } 340 | 341 | 342 | /** 343 | * Special function to filter by distance (lat/lon) 344 | * 345 | * @param string $field 346 | * @param float $lat 347 | * @param float $lon 348 | * @param integer $radius 349 | * @param boolean $addExpr 350 | * 351 | * @return self 352 | */ 353 | public function latlon($field, $lat, $lon, $radius = 50, $addExpr = false) 354 | { 355 | // upper left bound 356 | $lat1 = $lat + ($radius / 69); 357 | $lon1 = $lon - $radius / abs(cos(deg2rad($lat)) * 69); 358 | 359 | // lower right bound 360 | $lat2 = $lat - ($radius / 69); 361 | $lon2 = $lon + $radius / abs(cos(deg2rad($lat)) * 69); 362 | 363 | $min = "'{$lat1},{$lon1}'"; 364 | $max = "'{$lat2},{$lon2}'"; 365 | $this->fq->range($field, $min, $max); 366 | 367 | if ($addExpr) { 368 | $this->addDistanceExpr($field, $lat, $lon); 369 | } 370 | 371 | return $this; 372 | } 373 | 374 | /** 375 | * Special function to add 'distance' expression 376 | * 377 | * @param string $field 378 | * @param string $lat 379 | * @param string $lon 380 | * 381 | * @return self 382 | */ 383 | public function addDistanceExpr($field, $lat, $lon) 384 | { 385 | $expression = "haversin(" . 386 | "{$lat}," . 387 | "{$lon}," . 388 | "{$field}.latitude," . 389 | "{$field}.longitude)"; 390 | $this->expr("distance", $expression); 391 | 392 | return $this; 393 | } 394 | 395 | /** 396 | * Build the structured query array to send to AWS search 397 | * 398 | * @return array 399 | */ 400 | public function buildStructuredQuery() 401 | { 402 | $structuredQuery = []; 403 | 404 | // cursor 405 | if ($this->cursor) { 406 | $structuredQuery['cursor'] = $this->cursor; 407 | } 408 | 409 | // expressions 410 | if ($this->expressions) { 411 | $structuredQuery['expr'] = json_encode($this->expressions); 412 | } 413 | 414 | // facets 415 | if ($this->facets) { 416 | $structuredQuery['facet'] = json_encode($this->facets); 417 | } 418 | 419 | // filter query 420 | if ($this->fq->query) { 421 | $structuredQuery['filterQuery'] = (string)$this->fq; 422 | } 423 | 424 | // query 425 | if ($this->q->query) { 426 | $structuredQuery['query'] = (string)$this->q; 427 | } 428 | 429 | // options 430 | if ($this->options) { 431 | $structuredQuery['queryOptions'] = json_encode($this->options); 432 | } 433 | 434 | // highlights 435 | // partial 436 | // parser 437 | $structuredQuery['queryParser'] = 'structured'; 438 | 439 | // return 440 | if ($this->returnFields) { 441 | $structuredQuery['return'] = $this->returnFields; 442 | } 443 | 444 | // size 445 | $structuredQuery['size'] = $this->size; 446 | 447 | // sort 448 | if ($this->sort) { 449 | $structuredQuery['sort'] = $this->sort; 450 | } 451 | 452 | if (!$this->cursor) { 453 | $structuredQuery['start'] = $this->start; 454 | } 455 | 456 | // stats 457 | if ($this->stats) { 458 | 459 | // Parse fields 460 | $stats = array_map(function($field) { 461 | return "\"{$field}\":{}"; 462 | }, $this->stats); 463 | 464 | $structuredQuery['stats'] = "{" . implode(',', $stats) . "}"; 465 | } 466 | 467 | return $structuredQuery; 468 | } 469 | } -------------------------------------------------------------------------------- /src/Query/StructuredSearch.php: -------------------------------------------------------------------------------- 1 | query; 25 | } 26 | 27 | /** 28 | * Includes a document only if it matches all of the specified expressions. 29 | * (Boolean AND operator.) The expressions can contain any of the structured 30 | * query operators, or a simple search string. 31 | * 32 | * @param Closure|string $block 33 | * 34 | * @return StructuredSearch 35 | */ 36 | public function qAnd($block) 37 | { 38 | if ($block instanceof Closure) { 39 | 40 | $block($builder = new self); 41 | 42 | $this->query[] = "(and " . implode('', $builder->getQuery()) . ")"; 43 | } 44 | else if (gettype($block) == "string") { 45 | $this->query[] = "(and '{$block}')"; 46 | } 47 | 48 | return $this; 49 | } 50 | 51 | /** 52 | * Matches every document in the domain. 53 | * 54 | * @return StructuredSearch 55 | */ 56 | public function matchall() 57 | { 58 | $this->query[] = "(matchall)"; 59 | } 60 | 61 | /** 62 | * Searches a text or text-array field for the specified multi-term string and matches documents that contain the 63 | * terms within the specified distance of one another. (This is sometimes called a sloppy phrase search.) If you 64 | * omit the field option, Amazon CloudSearch searches all statically configured text and text-array fields by 65 | * default. Dynamic fields and literal fields are not searched by default. You can specify which fields you want to 66 | * search by default by specifying the q.options fields option. 67 | * 68 | * @param string $value 69 | * @param string $field 70 | * @param integer $distance 71 | * @param integer $boost 72 | * 73 | * @return StructuredSearch 74 | */ 75 | private function near($value, $field = null, $distance = 3, $boost = null) 76 | { 77 | $near = "(near "; 78 | 79 | if ($field) { 80 | $near .= "field='{$field}' "; 81 | } 82 | 83 | if ($distance) { 84 | $near .= "distance='{$distance}' "; 85 | } 86 | 87 | if ($boost) { 88 | $near .= "boost='{$boost}' "; 89 | } 90 | 91 | $near .= "'{$value}')"; 92 | 93 | $this->query[] = $near; 94 | 95 | return $this; 96 | } 97 | 98 | /** 99 | * Excludes a document if it matches the specified expression. (Boolean NOT 100 | * operator.) The expression can contain any of the structured query 101 | * operators, or a simple search string. 102 | * 103 | * @param Closure|string $block 104 | * 105 | * @return StructuredSearch 106 | */ 107 | public function qNot($block) 108 | { 109 | if (gettype($block) == "object") { 110 | 111 | $block($builder = new self); 112 | 113 | $this->query[] = "(not " . implode('', $builder->getQuery()) . ")"; 114 | } 115 | else if (gettype($block) == "string") { 116 | $this->query[] = "(not '{$block}')"; 117 | } 118 | 119 | return $this; 120 | } 121 | 122 | /** 123 | * Includes a document if it matches any of the specified expressions. 124 | * (Boolean OR operator.) The expressions can contain any of the structured 125 | * query operators, or a simple search string. 126 | * 127 | * @param Closure|string $block 128 | * 129 | * @return StructuredSearch 130 | */ 131 | public function qOr($block) 132 | { 133 | if ($block instanceof Closure) { 134 | 135 | $block($builder = new self); 136 | 137 | $this->query[] = "(or " . implode('', $builder->getQuery()) . ")"; 138 | } 139 | else if (gettype($block) == "string") { 140 | $this->query[] = "(or '{$block}')"; 141 | } 142 | 143 | return $this; 144 | } 145 | 146 | /** 147 | * Searches a text or text-array field for the specified phrase. If you 148 | * omit the field option, Amazon CloudSearch searches all statically 149 | * configured text and text-array fields by default. Dynamic fields and 150 | * literal fields are not searched by default. You can specify which fields 151 | * you want to search by default by specifying the q.options fields option. 152 | * 153 | * @param string $value 154 | * @param string $field 155 | * @param integer $boost 156 | * 157 | * @return StructuredSearch 158 | */ 159 | private function phrase($value, $field = null, $boost = null) 160 | { 161 | $phrase = "(phrase "; 162 | 163 | if ($field) { 164 | $phrase .= "field='{$field}' "; 165 | } 166 | 167 | if ($boost) { 168 | $phrase .= "boost='{$boost}' "; 169 | } 170 | 171 | $phrase .= "'{$value}')"; 172 | 173 | $this->query[] = $phrase; 174 | 175 | return $this; 176 | } 177 | 178 | /** 179 | * Searches a text, text-array, literal, or literal-array field for the 180 | * specified prefix followed by zero or more characters. If you omit the 181 | * field option, Amazon CloudSearch searches all statically configured text 182 | * and text-array fields by default. Dynamic fields and literal fields are 183 | * not searched by default. You can specify which fields you want to search 184 | * by default by specifying the q.options fields option. 185 | * 186 | * @param string $value 187 | * @param string $field 188 | * @param integer $boost 189 | * 190 | * @return StructuredSearch 191 | */ 192 | private function prefix($value, $field = null, $boost = null) 193 | { 194 | $prefix = "(prefix "; 195 | 196 | if ($field) { 197 | $prefix .= "field='{$field}' "; 198 | } 199 | 200 | if ($boost) { 201 | $prefix .= "boost='{$boost}' "; 202 | } 203 | 204 | $prefix .= "'{$value}')"; 205 | 206 | $this->query[] = $prefix; 207 | 208 | return $this; 209 | } 210 | 211 | /** 212 | * Searches a numeric field (double, double-array, int, int-array) or date 213 | * field (date, date-array) for values in the specified range. Matches 214 | * documents that have at least one value in the field within the specified 215 | * range. The field option must be specified. 216 | * 217 | * To specify a range of values, use a comma (,) to separate the upper and 218 | * lower bounds and enclose the range using brackets or braces. A square 219 | * bracket, [ or ], indicates that the bound is included in the range, a 220 | * curly brace, { or }, excludes the bound. You can omit the upper or lower 221 | * bound to specify an open-ended range. When omitting a bound, you must 222 | * use a curly brace. 223 | * 224 | * Dates and times are specified in UTC (Coordinated Universal Time) 225 | * according to IETF RFC3339: yyyy-mm-ddTHH:mm:ss.SSSZ. In UTC, for example, 226 | * 5:00 PM August 23, 1970 is: 1970-08-23T17:00:00Z. Note that you can also 227 | * specify fractional seconds when specifying times in UTC. For example, 228 | * 1967-01-31T23:20:50.650Z. 229 | * 230 | * @param string $field 231 | * @param integer|string $min 232 | * @param integer|string $max 233 | * 234 | * @return StructuredSearch 235 | */ 236 | public function range($field, $min, $max) 237 | { 238 | $range = "(range field={$field} "; 239 | 240 | if ($min and !$max) { 241 | $value = "[{$min},}"; 242 | } 243 | else if (!$min and $max) { 244 | $value = "{,{$max}]"; 245 | } 246 | else if ($min and $max) { 247 | $value = "[{$min},{$max}]"; 248 | } 249 | else { 250 | return $this; 251 | } 252 | 253 | $range .= "{$value})"; 254 | 255 | $this->query[] = $range; 256 | 257 | return $this; 258 | } 259 | 260 | /** 261 | * Searches the specified field for a string, numeric value, or date. The 262 | * field option must be specified when searching for a value. If you omit 263 | * the field option, Amazon CloudSearch searches all statically configured 264 | * text and text-array fields by default. Dynamic fields and literal fields 265 | * are not searched by default. You can specify which fields you want to 266 | * search by default by specifying the q.options fields option. 267 | * 268 | * Dates and times are specified in UTC (Coordinated Universal Time) 269 | * according to IETF RFC3339: yyyy-mm-ddTHH:mm:ss.SSSZ. In UTC, for example, 270 | * 5:00 PM August 23, 1970 is: 1970-08-23T17:00:00Z. Note that you can also 271 | * specify fractional seconds when specifying times in UTC. For example, 272 | * 1967-01-31T23:20:50.650Z. 273 | * 274 | * @param string $value 275 | * @param string $field 276 | * @param integer $boost 277 | * 278 | * @return StructuredSearch 279 | */ 280 | private function term($value, $field = null, $boost = null) 281 | { 282 | $term = "(term "; 283 | 284 | if ($field) { 285 | $term .= "field='{$field}' "; 286 | } 287 | 288 | if ($boost) { 289 | $term .= "boost='{$boost}' "; 290 | } 291 | 292 | $term .= "'{$value}')"; 293 | 294 | $this->query[] = $term; 295 | 296 | return $this; 297 | } 298 | 299 | /** 300 | * Query and filterQuery methods are made inaccessible in order to trigger 301 | * the __call magic method. Here we can escape argument strings prior to 302 | * calling the intended function 303 | * 304 | * @param string $method 305 | * @param array $args 306 | * 307 | * @return self 308 | * @throws Exception 309 | */ 310 | public function __call($method, $args) 311 | { 312 | // Raise exception if no method 313 | if (!method_exists($this, $method)) { 314 | throw new Exception("Method doesn't exist"); 315 | } 316 | 317 | // Escape string arguments 318 | foreach ($args as $key => $value) { 319 | if (gettype($value) == 'string') { 320 | $value = preg_replace('#\\\#i', "\\\\\\", $value); 321 | $value = preg_replace("#'#", "\\'", $value); 322 | $args[$key] = $value; 323 | } 324 | } 325 | 326 | // call the desired the method 327 | call_user_func_array([$this, $method], $args); 328 | 329 | return $this; 330 | } 331 | 332 | /** 333 | * Magic method when casting as string 334 | * Concatenate all query statements into wrapped 'and' statement 335 | * 336 | * @return string 337 | */ 338 | public function __toString() 339 | { 340 | return "(and " . implode('', $this->query) . ")"; 341 | } 342 | } -------------------------------------------------------------------------------- /src/Queue.php: -------------------------------------------------------------------------------- 1 | database = $database; 46 | } 47 | 48 | /** 49 | * Push a new job into the queue. 50 | * 51 | * @param string $action 52 | * @param string $entry_id 53 | * @param string $entry_type 54 | * 55 | * @return mixed 56 | */ 57 | public function push($action, $entry_id, $entry_type) 58 | { 59 | return $this->database->table($this->table)->updateOrInsert([ 60 | 'entry_id' => $entry_id, 61 | 'entry_type' => $entry_type, 62 | 'action' => $action, 63 | ], [ 64 | 'entry_id' => $entry_id, 65 | 'entry_type' => $entry_type, 66 | 'action' => $action, 67 | 'status' => self::STATUS_WAITING, 68 | 'created_at' => $this->currentTime(), 69 | ]); 70 | } 71 | 72 | /** 73 | * Get a queue batch. 74 | * 75 | * @return Collection 76 | */ 77 | public function getBatch() 78 | { 79 | // Mark all as running 80 | $this->database->table($this->table) 81 | ->where('status', self::STATUS_WAITING) 82 | ->update([ 83 | 'status' => self::STATUS_RUNNING, 84 | ]); 85 | 86 | // Start processing 87 | return $this->database->table($this->table) 88 | ->select(['entry_id', 'entry_type', 'action']) 89 | ->where('status', self::STATUS_RUNNING) 90 | ->get() 91 | ->groupBy('action'); 92 | } 93 | 94 | /** 95 | * Flush a batch. 96 | * 97 | * @return bool 98 | */ 99 | public function flushBatch() 100 | { 101 | return $this->database->table($this->table) 102 | ->where('status', self::STATUS_RUNNING) 103 | ->delete(); 104 | } 105 | 106 | /** 107 | * Get the current system time as a UNIX timestamp. 108 | * 109 | * @return int 110 | */ 111 | protected function currentTime() 112 | { 113 | return Carbon::now()->getTimestamp(); 114 | } 115 | } --------------------------------------------------------------------------------