diff --git a/CHANGELOG.md b/CHANGELOG.md index 37b89a8d..fb2b4c97 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/) ## [Unreleased] +## [2.0.2] - 2019-05-10 +### Added +- Search amongst multiple models + ## [2.0.1] - 2019-05-06 ### Added - Progress report for console commands diff --git a/Makefile b/Makefile index c3f45a13..fecda02e 100644 --- a/Makefile +++ b/Makefile @@ -42,3 +42,6 @@ test: up ## Execute application tests test-coverage: up ## Execute application tests and generate report $(docker_compose_bin) exec "$(APP_CONTAINER_NAME)" ./vendor/bin/phpstan analyze $(docker_compose_bin) exec "$(APP_CONTAINER_NAME)" ./vendor/bin/phpunit --coverage-html build/coverage-report + +test-filter: + $(docker_compose_bin) exec "$(APP_CONTAINER_NAME)" ./vendor/bin/phpunit --filter=$(filter) --testdox diff --git a/README.md b/README.md index bcd7690c..cd5994d4 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,9 @@ If you need any help, [stack overflow](https://stackoverflow.com/questions/tagge ## :two_hearts: Features +- [Search amongst multiple models](#search-amongst-multiple-models) - [**Zero downtime** reimport](#zero-downtime-reimport) - it’s a breeze to import data in production. +- [Elasticsearch **7.0** ready](https://www.elastic.co/guide/en/elasticsearch/reference/current/removal-of-types.html) - We don't use mapping types. - Import all searchable models at once. - A fully configurable mapping for each model. - Full power of ElasticSearch in your queries @@ -135,6 +137,19 @@ $results = Product::search('zonga', function($client, $body) { `$client` is `\ElasticSearch\Client` object from [elasticsearch/elasticsearch](https://packagist.org/packages/elasticsearch/elasticsearch) package And `$body` is `ONGR\ElasticsearchDSL\Search` from [ongr/elasticsearch-dsl](https://packagist.org/packages/ongr/elasticsearch-dsl) package +### Search amongst multiple models +You can do it with `Mixed` class, just pass indices names separated by commas to `within` method. +```php +Mixed::search('title:Barselona or to:Barselona') + within(implode(',', [ + (new Ticket())->serchableAs(), + (new Book())->serchableAs(), + ])) +->get(); +``` +In this example you will get collection of `Ticket` and `Book` models where tisket arrival city or +book title is `Barselona` + >Don't forget to :star: the package if you like it. :pray: ## :free: License diff --git a/composer.json b/composer.json index 61463f23..e5fd3f2f 100644 --- a/composer.json +++ b/composer.json @@ -1,7 +1,7 @@ { "name": "matchish/laravel-scout-elasticsearch", "description": "This package extends Laravel Scout adding full power of ElasticSearch", - "version": "2.0.1", + "version": "2.0.2", "keywords": [ "laravel", "scout", diff --git a/docs/demo.gif b/docs/demo.gif index 17ea84a3..c01a5cf0 100644 Binary files a/docs/demo.gif and b/docs/demo.gif differ diff --git a/src/ElasticSearch/EloquentHitsIteratorAggregate.php b/src/ElasticSearch/EloquentHitsIteratorAggregate.php index db6ef26b..e76c07d9 100644 --- a/src/ElasticSearch/EloquentHitsIteratorAggregate.php +++ b/src/ElasticSearch/EloquentHitsIteratorAggregate.php @@ -11,7 +11,6 @@ use Traversable; use Laravel\Scout\Builder; use Laravel\Scout\Searchable; -use Illuminate\Database\Eloquent\Model; /** * @internal @@ -21,25 +20,19 @@ final class EloquentHitsIteratorAggregate implements \IteratorAggregate /** * @var array */ - private $keys; - /** - * @var Model - */ - private $model; + private $results; /** * @var callable|null */ private $callback; /** - * @param array $keys - * @param Model $model + * @param array $results * @param callable|null $callback */ - public function __construct(array $keys, Model $model, callable $callback = null) + public function __construct(array $results, callable $callback = null) { - $this->keys = $keys; - $this->model = $model; + $this->results = $results; $this->callback = $callback; } @@ -52,20 +45,30 @@ public function __construct(array $keys, Model $model, callable $callback = null */ public function getIterator() { - /** @var Searchable $model */ - $model = $this->model; - $builder = new Builder($this->model, ''); - if (! empty($this->callback)) { - $builder->query($this->callback); + $hits = collect(); + if ($this->results['hits']['total']) { + $hits = $this->results['hits']['hits']; + $models = collect($hits)->groupBy('_source.__class_name') + ->map(function ($results, $class) { + $model = new $class; + $builder = new Builder($model, ''); + if (! empty($this->callback)) { + $builder->query($this->callback); + } + /* @var Searchable $model */ + return $models = $model->getScoutModelsByIds( + $builder, $results->pluck('_id')->all() + ); + }) + ->flatten()->keyBy(function ($model) { + return get_class($model).'::'.$model->getScoutKey(); + }); + $hits = collect($hits)->map(function ($hit) use ($models) { + $key = $hit['_source']['__class_name'].'::'.$hit['_id']; + + return isset($models[$key]) ? $models[$key] : null; + })->filter()->all(); } - $models = $model->getScoutModelsByIds( - $builder, $this->keys - )->keyBy(function ($model) { - return $model->getScoutKey(); - }); - $hits = collect($this->keys)->map(function ($key) use ($models) { - return isset($models[$key]) ? $models[$key] : null; - })->filter()->all(); return new \ArrayIterator($hits); } diff --git a/src/ElasticSearch/Params/Bulk.php b/src/ElasticSearch/Params/Bulk.php index 5cc6202d..38f26209 100644 --- a/src/ElasticSearch/Params/Bulk.php +++ b/src/ElasticSearch/Params/Bulk.php @@ -52,7 +52,10 @@ function ($payload, $model) { $payload['body'][] = array_merge( $model->toSearchableArray(), - $model->scoutMetadata() + $model->scoutMetadata(), + [ + '__class_name' => get_class($model), + ] ); return $payload; diff --git a/src/Engines/ElasticSearchEngine.php b/src/Engines/ElasticSearchEngine.php index 260432ff..559fe7be 100644 --- a/src/Engines/ElasticSearchEngine.php +++ b/src/Engines/ElasticSearchEngine.php @@ -6,6 +6,7 @@ use Laravel\Scout\Engines\Engine; use ONGR\ElasticsearchDSL\Search; use Laravel\Scout\Builder as BaseBuilder; +use Illuminate\Database\Eloquent\Collection; use ONGR\ElasticsearchDSL\Query\MatchAllQuery; use Matchish\ScoutElasticSearch\ElasticSearch\Index; use Matchish\ScoutElasticSearch\ElasticSearch\Params\Bulk; @@ -102,10 +103,9 @@ public function mapIds($results) */ public function map(BaseBuilder $builder, $results, $model) { - $ids = $this->mapIds($results)->all(); - $hits = new EloquentHitsIteratorAggregate($ids, $model, $builder->queryCallback); + $hits = new EloquentHitsIteratorAggregate($results, $builder->queryCallback); - return new \Illuminate\Database\Eloquent\Collection($hits); + return new Collection($hits); } /** diff --git a/src/Jobs/ImportStages.php b/src/Jobs/ImportStages.php index 86224595..b94d675c 100644 --- a/src/Jobs/ImportStages.php +++ b/src/Jobs/ImportStages.php @@ -20,17 +20,13 @@ class ImportStages extends Collection public static function fromSearchable(Model $searchable) { $index = Index::fromSearchable($searchable); - $main = PullFromSource::chunked($searchable); - if ($main->count()) { - return (new static([ - new CleanUp($searchable), - new CreateWriteIndex($searchable, $index), - PullFromSource::chunked($searchable), - new RefreshIndex($index), - new SwitchToNewAndRemoveOldIndex($searchable, $index), - ]))->flatten(); - } else { - return new static; - } + + return (new static([ + new CleanUp($searchable), + new CreateWriteIndex($searchable, $index), + PullFromSource::chunked($searchable), + new RefreshIndex($index), + new SwitchToNewAndRemoveOldIndex($searchable, $index), + ]))->flatten()->filter(); } } diff --git a/src/Mixed.php b/src/Mixed.php new file mode 100644 index 00000000..4fd3985b --- /dev/null +++ b/src/Mixed.php @@ -0,0 +1,24 @@ +assertEquals( '[OK] '.trans('scout::import.done', ['searchable' => Product::class]), - trim($output[2])); + trim($output[14])); $this->assertEquals( trans('scout::import.start', ['searchable' => Book::class]), - trim($output[4])); + trim($output[16])); $this->assertEquals( '[OK] '.trans('scout::import.done', ['searchable' => Book::class]), - trim($output[6])); + trim($output[30])); } public function test_progress_report_in_queue() diff --git a/tests/Feature/SearchTest.php b/tests/Feature/SearchTest.php index caacceb5..0c518f2a 100644 --- a/tests/Feature/SearchTest.php +++ b/tests/Feature/SearchTest.php @@ -4,9 +4,12 @@ namespace Tests\Feature; +use App\Book; +use App\Ticket; use App\Product; use Tests\IntegrationTestCase; use Illuminate\Pagination\Paginator; +use Matchish\ScoutElasticSearch\Mixed; use Illuminate\Support\Facades\Artisan; final class SearchTest extends IntegrationTestCase @@ -88,4 +91,46 @@ public function test_within() ->get(); $this->assertEquals($kindleAmount, $kindle->count()); } + + public function test_mixed() + { + $newyorkAmount = rand(1, 5); + $barselonaAmount = rand(1, 5); + + $dispatcher = Ticket::getEventDispatcher(); + Ticket::unsetEventDispatcher(); + + factory(Ticket::class, $newyorkAmount)->state('new-york')->create(); + factory(Ticket::class, $barselonaAmount)->state('barselona')->create(); + + Ticket::setEventDispatcher($dispatcher); + + $dispatcher = Book::getEventDispatcher(); + Book::unsetEventDispatcher(); + + factory(Book::class, $newyorkAmount)->state('new-york')->create(); + factory(Book::class, $barselonaAmount)->state('barselona')->create(); + + Book::setEventDispatcher($dispatcher); + + Artisan::call('scout:import'); + + $mixed = Mixed::search('new-york')->within( + implode(',', [(new Book)->searchableAs(), + (new Ticket())->searchableAs(), + ]))->get(); + $this->assertEquals($newyorkAmount * 2, $mixed->count()); + $this->assertEquals(['tickets' => $newyorkAmount, 'books' => $newyorkAmount], $mixed->map->getTable()->countBy()->all()); + } + + public function test_mixed_no_results() + { + Artisan::call('scout:import'); + + $mixed = Mixed::search('lisbon')->within( + implode(',', [(new Book)->searchableAs(), + (new Ticket())->searchableAs(), + ]))->get(); + $this->assertEquals(0, $mixed->count()); + } } diff --git a/tests/Integration/Engines/ElasticSearchEngineTest.php b/tests/Integration/Engines/ElasticSearchEngineTest.php index d8dabaf6..7e0f4501 100644 --- a/tests/Integration/Engines/ElasticSearchEngineTest.php +++ b/tests/Integration/Engines/ElasticSearchEngineTest.php @@ -2,6 +2,7 @@ namespace Tests\Integration\Engines; +use stdClass; use App\Product; use Laravel\Scout\Builder; use Tests\IntegrationTestCase; @@ -43,7 +44,7 @@ public function test_update() 'index' => 'products', 'body' => [ 'query' => [ - 'match_all' => new \stdClass(), + 'match_all' => new stdClass(), ], ], ]; @@ -66,7 +67,7 @@ public function test_delete() 'index' => 'products', 'body' => [ 'query' => [ - 'match_all' => new \stdClass(), + 'match_all' => new stdClass(), ], ], ]; @@ -88,7 +89,7 @@ public function test_flush() 'index' => 'products', 'body' => [ 'query' => [ - 'match_all' => new \stdClass(), + 'match_all' => new stdClass(), ], ], ]; @@ -101,9 +102,11 @@ public function test_map_with_custom_key_name() $this->app['config']['scout.key'] = 'custom_key'; $models = Product::all(); $keys = $models->map(function ($product) { - return ['_id' => $product->getScoutKey()]; + return ['_id' => $product->getScoutKey(), '_source' => [ + '__class_name' => Product::class, + ]]; })->all(); - $results = ['hits' => ['hits' => $keys]]; + $results = ['hits' => ['hits' => $keys, 'total' => $models->count()]]; $mappedModels = $this->engine->map(new Builder(new Product(), 'zonga'), $results, new Product()); $this->assertEquals($models->map->id->all(), $mappedModels->map->id->all()); } diff --git a/tests/Unit/ElasticSearch/Params/BulkTest.php b/tests/Unit/ElasticSearch/Params/BulkTest.php index fae89b7d..795721f0 100644 --- a/tests/Unit/ElasticSearch/Params/BulkTest.php +++ b/tests/Unit/ElasticSearch/Params/BulkTest.php @@ -46,7 +46,7 @@ public function test_index() $this->assertEquals([ 'body' => [ ['index' => ['_index' => 'products', '_type' => '_doc', '_id' => 2]], - ['title' => 'Scout', 'id' => 2], + ['title' => 'Scout', 'id' => 2, '__class_name' => 'App\Product'], ], ], $params); } @@ -63,7 +63,7 @@ public function test_index_with_custom_key_name() $this->assertEquals([ 'body' => [ ['index' => ['_index' => 'products', '_type' => '_doc', '_id' => 'Scout']], - ['title' => 'Scout', 'id' => 2], + ['title' => 'Scout', 'id' => 2, '__class_name' => 'App\Product'], ], ], $params); } @@ -79,7 +79,7 @@ public function test_push_soft_delete_meta_data() $this->assertEquals([ 'body' => [ ['index' => ['_index' => 'products', '_type' => '_doc', '_id' => 2]], - ['title' => 'Scout', '__soft_deleted' => 0, 'id' => 2], + ['title' => 'Scout', '__soft_deleted' => 0, 'id' => 2, '__class_name' => 'App\Product'], ], ], $params); } diff --git a/tests/Unit/Engines/ElasticSearchEngineTest.php b/tests/Unit/Engines/ElasticSearchEngineTest.php index 77bba15e..a37768fd 100644 --- a/tests/Unit/Engines/ElasticSearchEngineTest.php +++ b/tests/Unit/Engines/ElasticSearchEngineTest.php @@ -54,9 +54,20 @@ public function test_pass_query_to_callback_before_executing() return $query; }); $engine = new ElasticSearchEngine(app(Client::class)); - $engine->map($builder, ['hits' => ['hits' => ['_id' => 1], - ['_id' => 2], - ]], new Product()); + $engine->map($builder, [ + 'hits' => [ + 'hits' => [ + [ + '_id' => 1, '_source' => [ + '__class_name' => Product::class, + ], ], + [ + '_id' => 2, '_source' => [ + '__class_name' => Product::class, + ], ], + ], + 'total' => 2, + ], ], new Product()); $this->assertTrue($spy->executed); } } diff --git a/tests/Unit/Jobs/ImportStagesTest.php b/tests/Unit/Jobs/ImportStagesTest.php index 764f5c84..55d8fe78 100644 --- a/tests/Unit/Jobs/ImportStagesTest.php +++ b/tests/Unit/Jobs/ImportStagesTest.php @@ -13,10 +13,14 @@ class ImportStagesTest extends TestCase { - public function test_no_stages_if_no_searchables() + public function test_no_pull_stages_if_no_searchables() { $stages = ImportStages::fromSearchable(new Product()); - $this->assertEquals(0, $stages->count()); + $this->assertEquals(4, $stages->count()); + $this->assertInstanceOf(CleanUp::class, $stages->get(0)); + $this->assertInstanceOf(CreateWriteIndex::class, $stages->get(1)); + $this->assertInstanceOf(RefreshIndex::class, $stages->get(2)); + $this->assertInstanceOf(SwitchToNewAndRemoveOldIndex::class, $stages->get(3)); } public function test_stages() diff --git a/tests/Unit/MixedTest.php b/tests/Unit/MixedTest.php new file mode 100644 index 00000000..db42f16c --- /dev/null +++ b/tests/Unit/MixedTest.php @@ -0,0 +1,24 @@ +getMethod('search')->getParameters(); + $searchableDoc = $searchable->getMethod('search')->getDocComment(); + $mixed = new ReflectionClass(Mixed::class); + $mixedParameters = $mixed->getMethod('search')->getParameters(); + $mixedDoc = $mixed->getMethod('search')->getDocComment(); + + $this->assertEquals($searchableParameters, $mixedParameters); + $this->assertEquals($searchableDoc, $mixedDoc); + } +} diff --git a/tests/laravel/app/Ticket.php b/tests/laravel/app/Ticket.php new file mode 100644 index 00000000..6582f259 --- /dev/null +++ b/tests/laravel/app/Ticket.php @@ -0,0 +1,28 @@ +getKeyName()); + } + + public function getScoutKey() + { + return $this->getAttribute($this->getScoutKeyName()); + } +} diff --git a/tests/laravel/database/factories/BookFactory.php b/tests/laravel/database/factories/BookFactory.php index af23a933..b4bccc73 100644 --- a/tests/laravel/database/factories/BookFactory.php +++ b/tests/laravel/database/factories/BookFactory.php @@ -8,8 +8,20 @@ $factory->define(Book::class, function (Faker $faker) { return [ 'custom_key' => $faker->uuid, - 'title' => $faker->text, + 'title' => $faker->sentence, 'author' => $faker->name, 'year' => $faker->year, ]; }); + +$factory->state(Book::class, 'new-york', function (Faker $faker) { + return [ + 'title' => "{$faker->sentence} New-York {$faker->sentence}", + ]; +}); + +$factory->state(Book::class, 'barselona', function (Faker $faker) { + return [ + 'title' => "{$faker->sentence} Barselona {$faker->sentence}", + ]; +}); diff --git a/tests/laravel/database/factories/TicketFactory.php b/tests/laravel/database/factories/TicketFactory.php new file mode 100644 index 00000000..a5d52749 --- /dev/null +++ b/tests/laravel/database/factories/TicketFactory.php @@ -0,0 +1,27 @@ +define(Ticket::class, function (Faker $faker) { + return [ + 'custom_key' => $faker->uuid, + 'from' => $faker->city, + 'to' => $faker->city, + 'date' => $faker->date(), + ]; +}); + +$factory->state(Ticket::class, 'new-york', function (Faker $faker) { + return [ + 'to' => 'New-York', + ]; +}); + +$factory->state(Ticket::class, 'barselona', function (Faker $faker) { + return [ + 'to' => 'Barselona', + ]; +}); diff --git a/tests/laravel/database/migrations/2019_15_02_000002_create_tickets_table.php b/tests/laravel/database/migrations/2019_15_02_000002_create_tickets_table.php new file mode 100644 index 00000000..81a2581c --- /dev/null +++ b/tests/laravel/database/migrations/2019_15_02_000002_create_tickets_table.php @@ -0,0 +1,28 @@ +increments('id'); + $table->string('custom_key'); + $table->string('from'); + $table->string('to'); + $table->dateTime('date'); + $table->softDeletes(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('books'); + } +}