├── .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 |
--------------------------------------------------------------------------------