Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.53% covered (success)
98.53%
134 / 136
88.89% covered (warning)
88.89%
8 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
Searchable
98.53% covered (success)
98.53%
134 / 136
88.89% covered (warning)
88.89%
8 / 9
44
0.00% covered (danger)
0.00%
0 / 1
 processSearch
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
8
 applyOperatorFilters
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
1 / 1
5
 applyInFilter
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 applyLikeFilter
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
4
 getLikeOperator
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 applyBetweenFilter
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
7
 applyJsonFilter
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 applySimpleEquality
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
5
 applyTermFilter
89.47% covered (warning)
89.47%
17 / 19
0.00% covered (danger)
0.00%
0 / 1
7.06
1<?php
2
3declare(strict_types=1);
4
5namespace DevToolbelt\LaravelFastCrud\Traits;
6
7use DateTimeImmutable;
8use DevToolbelt\LaravelFastCrud\Enum\SearchOperator;
9use Exception;
10use Illuminate\Database\Eloquent\Builder;
11use Illuminate\Support\Str;
12
13/**
14 * Provides flexible search/filter functionality for Eloquent queries.
15 *
16 * Supports multiple operators, relation filtering, and various data types.
17 * Column names are automatically converted from camelCase to snake_case.
18 *
19 * @example
20 * ```
21 * // Simple equality
22 * GET /products?filter[status]=active
23 *
24 * // With operators
25 * GET /products?filter[name][like]=Samsung&filter[price][gte]=100
26 *
27 * // Relation filtering
28 * GET /products?filter[category.name][like]=Electronics
29 *
30 * // Date range
31 * GET /orders?filter[created_at][btw]=2024-01-01,2024-12-31
32 * ```
33 *
34 * @see SearchOperator For available filter operators
35 */
36trait Searchable
37{
38    /**
39     * Processes filter parameters and applies them to the query.
40     *
41     * @param Builder $query The query builder instance
42     * @param array<string, mixed> $filters Filter parameters from the request
43     *
44     * @throws Exception When date parsing fails in BETWEEN filter
45     */
46    public function processSearch(Builder $query, array $filters = []): void
47    {
48        if (empty($filters)) {
49            return;
50        }
51
52        $termFieldName = property_exists($this, 'termFieldName') ? $this->termFieldName : 'term';
53        $termFields = property_exists($this, 'termFields') ? $this->termFields : [];
54
55        if (array_key_exists($termFieldName, $filters)) {
56            $this->applyTermFilter($query, $filters[$termFieldName], $termFields);
57            unset($filters[$termFieldName]);
58        }
59
60        foreach ($filters as $column => $param) {
61            $column = Str::snake($column);
62            $hasRelation = str_contains($column, '.');
63
64            if (is_array($param)) {
65                $this->applyOperatorFilters($query, $column, $param, $hasRelation);
66                continue;
67            }
68
69            if (is_string($param)) {
70                $this->applySimpleEquality($query, $column, $param, $hasRelation);
71                continue;
72            }
73
74            $query->where($column, $param);
75        }
76    }
77
78    /**
79     * Applies filters with explicit operators.
80     *
81     * @param Builder $query The query builder instance
82     * @param string $column The column name (snake_case)
83     * @param array<string, mixed> $params Operator-value pairs
84     * @param bool $hasRelation Whether the column references a relation
85     *
86     * @throws Exception When date parsing fails
87     */
88    private function applyOperatorFilters(Builder $query, string $column, array $params, bool $hasRelation): void
89    {
90        foreach ($params as $operatorKey => $value) {
91            if ($operatorKey === SearchOperator::NOT_NULL->value) {
92                $query->whereNotNull($column);
93                continue;
94            }
95
96            if (empty($value)) {
97                continue;
98            }
99
100            $value = !is_array($value) ? trim($value) : $value;
101            $operator = SearchOperator::from($operatorKey);
102
103            match ($operator) {
104                SearchOperator::NOT_NULL => $query->whereNotNull($column),
105                SearchOperator::EQUAL => $query->where($column, $value),
106                SearchOperator::NOT_EQUAL => $query->whereNot($column, $value),
107                SearchOperator::LESS_THAN => $query->where($column, '<', $value),
108                SearchOperator::LESS_THAN_EQUAL => $query->where($column, '<=', $value),
109                SearchOperator::LESSER_THAN_OR_NULL => $query->where(
110                    fn(Builder $q): Builder => $q->whereNull($column)->orWhere($column, '<', $value)
111                ),
112                SearchOperator::GREATER_THAN => $query->where($column, '>', $value),
113                SearchOperator::GREATER_THAN_EQUAL => $query->where($column, '>=', $value),
114                SearchOperator::GREATER_THAN_OR_NULL => $query->where(
115                    fn(Builder $q): Builder => $q->whereNull($column)->orWhere($column, '>', $value)
116                ),
117                SearchOperator::IN => $this->applyInFilter($query, $column, $value, $hasRelation),
118                SearchOperator::NOT_IN => $query->whereNotIn($column, explode(',', $value)),
119                SearchOperator::LIKE => $this->applyLikeFilter($query, $column, $value, $hasRelation),
120                SearchOperator::BETWEEN => $this->applyBetweenFilter($query, $column, $value, $hasRelation),
121                SearchOperator::JSON => $this->applyJsonFilter($query, $column, $value),
122            };
123        }
124    }
125
126    /**
127     * Applies IN filter with relation support.
128     *
129     * @param Builder $query The query builder instance
130     * @param string $column The column name
131     * @param string $value Comma-separated values
132     * @param bool $hasRelation Whether the column references a relation
133     */
134    private function applyInFilter(Builder $query, string $column, string $value, bool $hasRelation): void
135    {
136        $values = explode(',', $value);
137
138        if ($hasRelation && count(explode('.', $column)) <= 2) {
139            [$relation, $field] = explode('.', $column);
140            $query->whereHas(Str::camel($relation), fn(Builder $q): Builder => $q->whereIn($field, $values));
141            return;
142        }
143
144        $query->whereIn($column, $values);
145    }
146
147    /**
148     * Applies case-insensitive LIKE filter with relation support.
149     *
150     * Supports up to 2 levels of nested relations.
151     * Uses ILIKE for PostgreSQL and LIKE for MySQL/other databases.
152     *
153     * @param Builder $query The query builder instance
154     * @param string $column The column name (may include relation path)
155     * @param string $value The search value
156     * @param bool $hasRelation Whether the column references a relation
157     */
158    private function applyLikeFilter(Builder $query, string $column, string $value, bool $hasRelation): void
159    {
160        $likeOperator = $this->getLikeOperator($query);
161
162        if ($hasRelation) {
163            $parts = explode('.', $column);
164            if (count($parts) === 2) {
165                [$relation, $field] = $parts;
166                $query->whereHas(
167                    Str::camel($relation),
168                    fn(Builder $q): Builder => $q->where($field, $likeOperator, "%{$value}%")
169                );
170            } elseif (count($parts) === 3) {
171                [$relation1, $relation2, $field] = $parts;
172                $query->whereHas(
173                    $relation1,
174                    fn(Builder $q): Builder => $q->whereHas(
175                        $relation2,
176                        fn(Builder $q2): Builder => $q2->where($field, $likeOperator, "%{$value}%")
177                    )
178                );
179            }
180            return;
181        }
182
183        $query->where($column, $likeOperator, "%{$value}%");
184    }
185
186    /**
187     * Gets the appropriate LIKE operator based on the database driver.
188     *
189     * Returns ILIKE for PostgreSQL (case-insensitive) and LIKE for other databases.
190     * MySQL LIKE is case-insensitive by default with standard collations.
191     *
192     * @param Builder $query The query builder instance
193     * @return string The LIKE operator to use
194     */
195    private function getLikeOperator(Builder $query): string
196    {
197        $driver = $query->getConnection()->getDriverName();
198
199        return $driver === 'pgsql' ? 'ILIKE' : 'LIKE';
200    }
201
202    /**
203     * Applies BETWEEN filter for date ranges.
204     *
205     * Supports two formats:
206     * - Full date: "2024-01-01,2024-12-31"
207     * - Month format: "2024-01" (expands to full month range)
208     * - Single date: "2024-01-15" (searches entire day)
209     *
210     * @param Builder $query The query builder instance
211     * @param string $column The column name
212     * @param string $value Comma-separated date values
213     * @param bool $hasRelation Whether the column references a relation
214     *
215     * @throws Exception When date parsing fails
216     */
217    private function applyBetweenFilter(Builder $query, string $column, string $value, bool $hasRelation): void
218    {
219        $dates = explode(',', $value);
220        $isMonthFormat = strlen($dates[0]) === 7;
221
222        $date1 = $isMonthFormat
223            ? (new DateTimeImmutable($dates[0]))->format('Y-m-01 00:00:00')
224            : "{$dates[0]} 00:00:00";
225
226        $date2 = isset($dates[1])
227            ? ($isMonthFormat
228                ? (new DateTimeImmutable($dates[1]))->format('Y-m-t 23:59:59')
229                : "{$dates[1]} 23:59:59")
230            : ($isMonthFormat
231                ? (new DateTimeImmutable($dates[0]))->format('Y-m-t 23:59:59')
232                : "{$dates[0]} 23:59:59");
233
234        if ($hasRelation && count(explode('.', $column)) <= 2) {
235            [$relation, $field] = explode('.', $column);
236            $query->whereHas(
237                Str::camel($relation),
238                fn(Builder $q): Builder => $q->whereBetween($field, [$date1, $date2])
239            );
240            return;
241        }
242
243        $query->whereBetween($column, [$date1, $date2]);
244    }
245
246    /**
247     * Applies JSON column filter using whereJsonContains.
248     *
249     * @param Builder $query The query builder instance
250     * @param string $column The JSON column name
251     * @param array<string, string> $value Key-value pair to search for in JSON
252     */
253    private function applyJsonFilter(Builder $query, string $column, array $value): void
254    {
255        $key = array_key_first($value);
256        $val = $value[$key];
257
258        if (str_contains($val, ',')) {
259            $values = explode(',', $val);
260            foreach ($values as $field) {
261                $query->whereJsonContains($column, [$key => $field], 'or');
262            }
263        } else {
264            $query->whereJsonContains($column, [$key => $val]);
265        }
266    }
267
268    /**
269     * Applies simple equality filter with relation support.
270     *
271     * For relation fields named 'id', automatically converts to 'external_id'.
272     *
273     * @param Builder $query The query builder instance
274     * @param string $column The column name (may include relation path)
275     * @param string $value The value to match
276     * @param bool $hasRelation Whether the column references a relation
277     */
278    private function applySimpleEquality(Builder $query, string $column, string $value, bool $hasRelation): void
279    {
280        if ($hasRelation) {
281            $parts = explode('.', $column);
282            if (count($parts) === 2) {
283                [$relation, $field] = $parts;
284                $query->whereHas(
285                    Str::camel($relation),
286                    fn(Builder $q): Builder => $q->where($field === 'id' ? 'external_id' : $field, $value)
287                );
288            } elseif (count($parts) === 3) {
289                [$r1, $r2, $field] = $parts;
290                $query->whereHas(
291                    $r1,
292                    fn(Builder $q): Builder => $q->whereHas(
293                        $r2,
294                        fn(Builder $q2): Builder => $q2->where($field, $value)
295                    )
296                );
297            }
298            return;
299        }
300
301        $query->where($column, $value);
302    }
303
304    /**
305     * Applies multi-column term search using LIKE/ILIKE depending on the database driver.
306     *
307     * @param Builder $query The query builder instance
308     * @param mixed $term The search term from filter[$termFieldName]
309     * @param array<int, string> $fields Columns to search against
310     */
311    private function applyTermFilter(Builder $query, mixed $term, array $fields): void
312    {
313        if (!is_scalar($term)) {
314            return;
315        }
316
317        $term = trim((string) $term);
318
319        if ($term === '' || empty($fields)) {
320            return;
321        }
322
323        $likeOperator = $this->getLikeOperator($query);
324        $normalizedFields = array_values(
325            array_filter(array_map(static fn(string $field): string => trim($field), $fields))
326        );
327
328        if (empty($normalizedFields)) {
329            return;
330        }
331
332        $query->where(function (Builder $builder) use ($normalizedFields, $likeOperator, $term): void {
333            foreach ($normalizedFields as $index => $field) {
334                $column = Str::snake($field);
335
336                if ($index === 0) {
337                    $builder->where($column, $likeOperator, "%{$term}%");
338                    continue;
339                }
340
341                $builder->orWhere($column, $likeOperator, "%{$term}%");
342            }
343        });
344    }
345}