├── .gitignore ├── tests ├── Fixtures │ ├── FooModel.php │ └── SoftDeleteModel.php ├── TestCase.php ├── Unit │ ├── ConditionTest.php │ └── SearchBuilderTest.php └── Pest.php ├── src ├── HasSearchBuilder.php ├── Condition.php └── SearchBuilder.php ├── phpunit.xml ├── composer.json └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor/ 2 | -------------------------------------------------------------------------------- /tests/Fixtures/FooModel.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ./tests 6 | 7 | 8 | 9 | 10 | 11 | ./app 12 | ./src 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "whitecube/laravel-search-builder", 3 | "description": "A package to build fast, index-friendly search queries for Laravel", 4 | "require": { 5 | "laravel/framework": "^9.0|^10.0|^11.0|^12.0", 6 | "staudenmeir/laravel-cte": "^1.0" 7 | }, 8 | "require-dev": { 9 | "pestphp/pest": "^2.5", 10 | "laravel/pint": "^1.8", 11 | "orchestra/testbench": "^8.5" 12 | }, 13 | "autoload": { 14 | "psr-4": { 15 | "Whitecube\\SearchBuilder\\": "src/", 16 | "Whitecube\\SearchBuilder\\Tests\\": "tests/" 17 | } 18 | }, 19 | "authors": [ 20 | { 21 | "name": "Adrien Leloup", 22 | "email": "adrien@whitecube.be" 23 | } 24 | ], 25 | "config": { 26 | "allow-plugins": { 27 | "pestphp/pest-plugin": true 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /tests/TestCase.php: -------------------------------------------------------------------------------- 1 | set('database.default', 'testbench'); 19 | $app['config']->set('database.connections.testbench', [ 20 | 'driver' => 'mysql', 21 | 'database' => ':memory:', 22 | 'prefix' => '', 23 | ]); 24 | } 25 | 26 | /** 27 | * Get package providers. 28 | * 29 | * @param \Illuminate\Foundation\Application $app 30 | * 31 | * @return array> 32 | */ 33 | protected function getPackageProviders($app) 34 | { 35 | return [ 36 | DatabaseServiceProvider::class, 37 | ]; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /tests/Unit/ConditionTest.php: -------------------------------------------------------------------------------- 1 | query(SoftDeleteModel::where('foo', '=', 'bar')); 11 | 12 | expect($condition->getQuery()->toSql()) 13 | ->not()->toContain('`soft_delete_models`.`deleted_at` is null'); 14 | }); 15 | 16 | test('the condition can apply its score to its query', function () { 17 | $condition = new Condition(); 18 | 19 | $condition->query(FooModel::where('foo', '=', 'bar')); 20 | $condition->score(100); 21 | 22 | $condition->applyScore(fallbackScore: 1); 23 | 24 | expect($condition->getQuery()->toSql()) 25 | ->toContain('select 100 as score'); 26 | }); 27 | 28 | test('the condition can use a fallback score if a score was not previously set', function () { 29 | $condition = new Condition(); 30 | 31 | $condition->query(FooModel::where('foo', '=', 'bar')); 32 | 33 | $condition->applyScore(fallbackScore: 10); 34 | 35 | expect($condition->getQuery()->toSql()) 36 | ->toContain('select 10 as score'); 37 | }); 38 | -------------------------------------------------------------------------------- /src/Condition.php: -------------------------------------------------------------------------------- 1 | query = $query->withoutGlobalScopes(); 28 | 29 | return $this; 30 | } 31 | 32 | /** 33 | * Get the query instance 34 | */ 35 | public function getQuery(): Builder 36 | { 37 | return $this->query; 38 | } 39 | 40 | /** 41 | * Set the score/weight to apply for this condition 42 | */ 43 | public function score(?int $score): static 44 | { 45 | $this->score = $score; 46 | 47 | return $this; 48 | } 49 | 50 | /** 51 | * Set the fallback score and apply it on the query 52 | */ 53 | public function applyScore(int $fallbackScore): static 54 | { 55 | if (is_null($this->score)) { 56 | $this->score($fallbackScore); 57 | } 58 | 59 | $this->query->selectRaw($this->score.' as score'); 60 | 61 | return $this; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /tests/Pest.php: -------------------------------------------------------------------------------- 1 | in('Unit'); 19 | 20 | /* 21 | |-------------------------------------------------------------------------- 22 | | Expectations 23 | |-------------------------------------------------------------------------- 24 | | 25 | | When you're writing tests, you often need to check that values meet certain conditions. The 26 | | "expect()" function gives you access to a set of "expectations" methods that you can use 27 | | to assert different things. Of course, you may extend the Expectation API at any time. 28 | | 29 | */ 30 | 31 | expect()->extend('toBeOne', function () { 32 | return $this->toBe(1); 33 | }); 34 | 35 | /* 36 | |-------------------------------------------------------------------------- 37 | | Functions 38 | |-------------------------------------------------------------------------- 39 | | 40 | | While Pest is very powerful out-of-the-box, you may have some testing code specific to your 41 | | project that you don't want to repeat in every file. Here you can also expose helpers as 42 | | global functions to help you to reduce the number of lines of code in your test files. 43 | | 44 | */ 45 | 46 | function something() 47 | { 48 | // .. 49 | } 50 | -------------------------------------------------------------------------------- /tests/Unit/SearchBuilderTest.php: -------------------------------------------------------------------------------- 1 | toBeInstanceOf(SearchBuilder::class); 10 | }); 11 | 12 | test('a search builder can be instanciated with a string class name', function () { 13 | $builder = new SearchBuilder(FooModel::class); 14 | 15 | expect($builder)->toBeInstanceOf(SearchBuilder::class); 16 | }); 17 | 18 | test('a model with the trait can get a configured search builder', function () { 19 | $builder = FooModel::searchBuilder(); 20 | 21 | expect($builder)->toBeInstanceOf(SearchBuilder::class); 22 | }); 23 | 24 | test('the search builder can insert condition sub-queries', function () { 25 | $query = FooModel::searchBuilder() 26 | ->search(FooModel::select('id')->where('foo', '=', 'bar')) 27 | ->getQuery(); 28 | 29 | expect($query->toSql()) 30 | ->toContain('select `id`, 1 as score from `foo_models` where `foo` = ?'); 31 | }); 32 | 33 | test('the search builder can insert condition sub-queries with a specified score', function () { 34 | $query = FooModel::searchBuilder() 35 | ->search(FooModel::select('id')->where('foo', '=', 'bar'), score: 100) 36 | ->getQuery(); 37 | 38 | expect($query->toSql()) 39 | ->toContain('select `id`, 100 as score from `foo_models` where `foo` = ?'); 40 | }); 41 | 42 | test('the search builder can have multiple condition sub-queries', function () { 43 | $query = FooModel::searchBuilder() 44 | ->search(FooModel::select('id')->where('foo', '=', 'bar')) 45 | ->search(FooModel::select('id')->where('bar', '=', 'baz')) 46 | ->getQuery(); 47 | 48 | expect($query->toSql()) 49 | ->toContain('(select `id`, 2 as score from `foo_models` where `foo` = ?) union all (select `id`, 1 as score from `foo_models` where `bar` = ?)'); 50 | }); 51 | -------------------------------------------------------------------------------- /src/SearchBuilder.php: -------------------------------------------------------------------------------- 1 | model = $model; 35 | } 36 | 37 | /** 38 | * Set the query builder instance to add the search conditions to 39 | */ 40 | public function setQuery(QueryBuilder|EloquentBuilder $query): static 41 | { 42 | $this->query = $query; 43 | 44 | return $this; 45 | } 46 | 47 | /** 48 | * Register a search condition subquery with an optional score/weight 49 | */ 50 | public function search(QueryBuilder|EloquentBuilder $query, ?int $score = null): static 51 | { 52 | $this->conditions[] = (new Condition()) 53 | ->query($query) 54 | ->score($score); 55 | 56 | return $this; 57 | } 58 | 59 | /** 60 | * Split the search terms and execute the callback to allow 61 | * adding search conditions for each one separately 62 | */ 63 | public function splitTerms(string $terms, callable $callback): static 64 | { 65 | $split = array_unique(array_filter(explode(' ', str_replace(['-', '_', '.'], ' ', $terms)))); 66 | 67 | foreach ($split as $term) { 68 | $callback($this, $term); 69 | } 70 | 71 | return $this; 72 | } 73 | 74 | /** 75 | * Get the results 76 | */ 77 | public function get(): Collection 78 | { 79 | return $this->getQuery()->get(); 80 | } 81 | 82 | /** 83 | * Build and get the search query 84 | */ 85 | public function getQuery(): EloquentBuilder 86 | { 87 | return $this->getQueryWithoutOrderBy() 88 | ->orderBy('score', 'desc'); 89 | } 90 | 91 | /** 92 | * Build and get the search query 93 | */ 94 | public function getQueryWithoutOrderBy(): EloquentBuilder 95 | { 96 | if (is_null($this->query)) { 97 | $this->setQuery($this->model->query()); 98 | } 99 | 100 | $table = $this->query->getQuery()->from; 101 | 102 | return $this->query 103 | ->withExpression('id_and_total_score', $this->getScoreQuery()) 104 | ->join('id_and_total_score', $table.'.id', 'id_and_total_score.id'); 105 | } 106 | 107 | /** 108 | * Get the score-calculating sub query 109 | */ 110 | protected function getScoreQuery(): QueryBuilder 111 | { 112 | $subquery = DB::query()->selectRaw('null as id, 0 as score'); 113 | 114 | foreach ($this->conditions as $index => $condition) { 115 | $condition->applyScore(fallbackScore: count($this->conditions) - $index); 116 | 117 | $subquery->unionAll($condition->getQuery()); 118 | } 119 | 120 | return DB::query() 121 | ->fromSub($subquery, as: 'ids_and_scores') 122 | ->selectRaw('ids_and_scores.id as id, sum(ids_and_scores.score) as score') 123 | ->groupBy('id'); 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Search Builder for Laravel 2 | A package to build fast, index-friendly search queries for Laravel. 3 | 4 | ## What does it do? 5 | The purpose of this package is to allow you to build multi-condition search queries with scores, and make use of covering indexes to be super fast. 6 | This means simply using this package as shown is not everything – you need to have a well designed database, with properly designed indexes for your searches, to get good results. To learn more about this topic, we recommend watching the free [PlanetScale MySQL for Developers course](https://planetscale.com/courses/mysql-for-developers/introduction/course-introduction). 7 | 8 | ## Installation 9 | 10 | ```bash 11 | composer require whitecube/laravel-search-builder 12 | ``` 13 | 14 | ## How to structure your query 15 | 16 | To make your search queries extremely fast, the search builder will pack all of your conditions in a subquery that will aim to hit as many covering indexes as possible, in order to build an aggregated table that only contains ids of the pertinent models (along with a score, more on this later). This aggregated table will then be used to filter down the actual table with an inner join. This means that the processing of your search logic is done entirely on your indexes, and the full table is only accessed at the end, which dramatically speeds everything up. 17 | 18 | However, the package can not detect your database structure, so it is your responsibility to create your indexes correctly, and in such a way that your search condition queries will not have to access your main tables' data. 19 | 20 | Here's an example of what we're looking to achieve, in raw SQL. Given that we have a products table, and we want to search it by reference and by name, and prioritise the reference over the name: 21 | 22 | ```sql 23 | with id_and_total_score as ( 24 | select id, sum(score) as score from ( 25 | -- This query makes use of a covering index on the ref column 26 | select id, 100 as score 27 | from products 28 | where ref = 'SEARCH_STRING' 29 | 30 | union all 31 | 32 | -- This query makes use of a covering index on the name column 33 | select id, 50 as score 34 | from products 35 | where name = 'SEARCH_STRING' 36 | ) 37 | as ids_and_scores 38 | group by id 39 | ) 40 | 41 | select * from products 42 | inner join id_and_total_score on id_and_total_score.id = products.id 43 | order by score desc; 44 | ``` 45 | 46 | ## The search builder instance 47 | 48 | You can get a search builder instance just by passing it the model you want to search. 49 | 50 | ```php 51 | use \App\Models\Product; 52 | use \Whitecube\SearchBuilder\SearchBuilder; 53 | 54 | $builder = new SearchBuilder(Product::class); // You can also pass it an instance of your model 55 | ``` 56 | 57 | Or, if your model uses the `HasSearchBuilder` trait, you can easily get a search builder instance this way, which allows you to cleanly chain your condition methods later. 58 | 59 | ```php 60 | use Whitecube\SearchBuilder\HasSearchBuilder; 61 | 62 | class Product extends Model 63 | { 64 | use HasSearchBuilder; 65 | } 66 | ``` 67 | 68 | ```php 69 | $builder = Product::searchBuilder(); 70 | ``` 71 | 72 | ## Defining search conditions 73 | 74 | Once you have a search builder instance, you can use it to define your search conditions, by passing eloquent builder instances to the `search` method. 75 | 76 | ```php 77 | Product::searchBuilder() 78 | ->search(Product::select('id')->where('ref', 'SEARCH_STRING'), score: 100) 79 | ->search(Product::select('id')->where('name', 'SEARCH_STRING'), score: 50); 80 | ``` 81 | 82 | The score is optional and will be automatically computed if missing, using the order in which the conditions are defined, with the highest score on top. 83 | 84 | ```php 85 | Product::searchBuilder() 86 | ->search(Product::select('id')->where('ref', 'SEARCH_STRING'), score: 100) // score = 100 87 | ->search(Product::select('id')->where('name', 'SEARCH_STRING')) // score = 3 88 | ->search(Product::select('id')->where('description', 'SEARCH_STRING')) // score = 2 89 | ->search(Product::select('id')->where('content', 'SEARCH_STRING')); // score = 1 90 | ``` 91 | 92 | You can easily search on related tables. Remember to only select the column that references the id of the table you're searching. 93 | 94 | ```php 95 | Product::searchBuilder() 96 | // Search on a related table 97 | ->search(Lot::select('product_id')->where('barcode', 'SEARCH_STRING')) 98 | // Search on a relation of a related table 99 | ->search(Lot::select('product_id')->whereHas('delivery', function ($query) { 100 | $query->where('address', 'SEARCH_STRING'); 101 | })) 102 | ``` 103 | 104 | If you wish to split the search terms on spaces, dashes, dots and underscores, and perform individual queries on each term, you can call the `splitTerms` method. 105 | 106 | ```php 107 | $terms = 'foo bar baz'; 108 | 109 | Product::searchBuilder() 110 | ->splitTerms($terms, function (SearchBuilder $searchBuilder, string $term) { 111 | // Called once with $term = foo, once with $term = bar, and once with $term = baz 112 | return $searchBuilder->search(Product::select('id')->where('ref', $term)); 113 | }); 114 | ``` 115 | 116 | ## Getting the results 117 | 118 | After defining your conditions, you can get the collection of results by calling the `get` method. 119 | 120 | ```php 121 | $results = Product::searchBuilder() 122 | ->search(Product::select('id')->where('ref', 'SEARCH_STRING'), score: 100) 123 | ->search(Product::select('id')->where('name', 'SEARCH_STRING'), score: 50) 124 | ->get(); 125 | ``` 126 | 127 | Or, if you need to do more work on the query yourself, you can get the query builder instance. 128 | 129 | ```php 130 | $query = Product::searchBuilder() 131 | ->search(Product::select('id')->where('ref', 'SEARCH_STRING'), score: 100) 132 | ->search(Product::select('id')->where('name', 'SEARCH_STRING'), score: 50) 133 | ->getQuery(); 134 | ``` 135 | 136 | ## 💖 Sponsorships 137 | 138 | If you are reliant on this package in your production applications, consider [sponsoring us](https://github.com/sponsors/whitecube)! It is the best way to help us keep doing what we love to do: making great open source software. 139 | 140 | ## Contributing 141 | 142 | Feel free to suggest changes, ask for new features or fix bugs yourself. We're sure there are still a lot of improvements that could be made, and we would be very happy to merge useful pull requests. 143 | 144 | Thanks! 145 | 146 | ### Unit tests 147 | 148 | When adding a new feature or fixing a bug, please add corresponding unit tests. The current set of tests is limited, but every unit test added will improve the quality of the package. 149 | 150 | Run the test suite by calling `./vendor/bin/pest`. 151 | 152 | ## Made with ❤️ for open source 153 | 154 | At [Whitecube](https://www.whitecube.be) we use a lot of open source software as part of our daily work. 155 | So when we have an opportunity to give something back, we're super excited! 156 | 157 | We hope you will enjoy this small contribution from us and would love to [hear from you](mailto:hello@whitecube.be) if you find it useful in your projects. Follow us on [Twitter](https://twitter.com/whitecube_be) for more updates! 158 | --------------------------------------------------------------------------------