Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
112 / 112
100.00% covered (success)
100.00%
8 / 8
CRAP
100.00% covered (success)
100.00%
1 / 1
Searchable
100.00% covered (success)
100.00%
112 / 112
100.00% covered (success)
100.00%
8 / 8
34
100.00% covered (success)
100.00%
1 / 1
 processSearch
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
5
 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
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        foreach ($filters as $column => $param) {
53            $column = Str::snake($column);
54            $hasRelation = str_contains($column, '.');
55
56            if (is_array($param)) {
57                $this->applyOperatorFilters($query, $column, $param, $hasRelation);
58                continue;
59            }
60
61            if (is_string($param)) {
62                $this->applySimpleEquality($query, $column, $param, $hasRelation);
63                continue;
64            }
65
66            $query->where($column, $param);
67        }
68    }
69
70    /**
71     * Applies filters with explicit operators.
72     *
73     * @param Builder $query The query builder instance
74     * @param string $column The column name (snake_case)
75     * @param array<string, mixed> $params Operator-value pairs
76     * @param bool $hasRelation Whether the column references a relation
77     *
78     * @throws Exception When date parsing fails
79     */
80    private function applyOperatorFilters(Builder $query, string $column, array $params, bool $hasRelation): void
81    {
82        foreach ($params as $operatorKey => $value) {
83            if ($operatorKey === SearchOperator::NOT_NULL->value) {
84                $query->whereNotNull($column);
85                continue;
86            }
87
88            if (empty($value)) {
89                continue;
90            }
91
92            $value = !is_array($value) ? trim($value) : $value;
93            $operator = SearchOperator::from($operatorKey);
94
95            match ($operator) {
96                SearchOperator::NOT_NULL => $query->whereNotNull($column),
97                SearchOperator::EQUAL => $query->where($column, $value),
98                SearchOperator::NOT_EQUAL => $query->whereNot($column, $value),
99                SearchOperator::LESS_THAN => $query->where($column, '<', $value),
100                SearchOperator::LESS_THAN_EQUAL => $query->where($column, '<=', $value),
101                SearchOperator::LESSER_THAN_OR_NULL => $query->where(
102                    fn(Builder $q): Builder => $q->whereNull($column)->orWhere($column, '<', $value)
103                ),
104                SearchOperator::GREATER_THAN => $query->where($column, '>', $value),
105                SearchOperator::GREATER_THAN_EQUAL => $query->where($column, '>=', $value),
106                SearchOperator::GREATER_THAN_OR_NULL => $query->where(
107                    fn(Builder $q): Builder => $q->whereNull($column)->orWhere($column, '>', $value)
108                ),
109                SearchOperator::IN => $this->applyInFilter($query, $column, $value, $hasRelation),
110                SearchOperator::NOT_IN => $query->whereNotIn($column, explode(',', $value)),
111                SearchOperator::LIKE => $this->applyLikeFilter($query, $column, $value, $hasRelation),
112                SearchOperator::BETWEEN => $this->applyBetweenFilter($query, $column, $value, $hasRelation),
113                SearchOperator::JSON => $this->applyJsonFilter($query, $column, $value),
114            };
115        }
116    }
117
118    /**
119     * Applies IN filter with relation support.
120     *
121     * @param Builder $query The query builder instance
122     * @param string $column The column name
123     * @param string $value Comma-separated values
124     * @param bool $hasRelation Whether the column references a relation
125     */
126    private function applyInFilter(Builder $query, string $column, string $value, bool $hasRelation): void
127    {
128        $values = explode(',', $value);
129
130        if ($hasRelation && count(explode('.', $column)) <= 2) {
131            [$relation, $field] = explode('.', $column);
132            $query->whereHas(Str::camel($relation), fn(Builder $q): Builder => $q->whereIn($field, $values));
133            return;
134        }
135
136        $query->whereIn($column, $values);
137    }
138
139    /**
140     * Applies case-insensitive LIKE filter with relation support.
141     *
142     * Supports up to 2 levels of nested relations.
143     * Uses ILIKE for PostgreSQL and LIKE for MySQL/other databases.
144     *
145     * @param Builder $query The query builder instance
146     * @param string $column The column name (may include relation path)
147     * @param string $value The search value
148     * @param bool $hasRelation Whether the column references a relation
149     */
150    private function applyLikeFilter(Builder $query, string $column, string $value, bool $hasRelation): void
151    {
152        $likeOperator = $this->getLikeOperator($query);
153
154        if ($hasRelation) {
155            $parts = explode('.', $column);
156            if (count($parts) === 2) {
157                [$relation, $field] = $parts;
158                $query->whereHas(
159                    Str::camel($relation),
160                    fn(Builder $q): Builder => $q->where($field, $likeOperator, "%{$value}%")
161                );
162            } elseif (count($parts) === 3) {
163                [$relation1, $relation2, $field] = $parts;
164                $query->whereHas(
165                    $relation1,
166                    fn(Builder $q): Builder => $q->whereHas(
167                        $relation2,
168                        fn(Builder $q2): Builder => $q2->where($field, $likeOperator, "%{$value}%")
169                    )
170                );
171            }
172            return;
173        }
174
175        $query->where($column, $likeOperator, "%{$value}%");
176    }
177
178    /**
179     * Gets the appropriate LIKE operator based on the database driver.
180     *
181     * Returns ILIKE for PostgreSQL (case-insensitive) and LIKE for other databases.
182     * MySQL LIKE is case-insensitive by default with standard collations.
183     *
184     * @param Builder $query The query builder instance
185     * @return string The LIKE operator to use
186     */
187    private function getLikeOperator(Builder $query): string
188    {
189        $driver = $query->getConnection()->getDriverName();
190
191        return $driver === 'pgsql' ? 'ILIKE' : 'LIKE';
192    }
193
194    /**
195     * Applies BETWEEN filter for date ranges.
196     *
197     * Supports two formats:
198     * - Full date: "2024-01-01,2024-12-31"
199     * - Month format: "2024-01" (expands to full month range)
200     * - Single date: "2024-01-15" (searches entire day)
201     *
202     * @param Builder $query The query builder instance
203     * @param string $column The column name
204     * @param string $value Comma-separated date values
205     * @param bool $hasRelation Whether the column references a relation
206     *
207     * @throws Exception When date parsing fails
208     */
209    private function applyBetweenFilter(Builder $query, string $column, string $value, bool $hasRelation): void
210    {
211        $dates = explode(',', $value);
212        $isMonthFormat = strlen($dates[0]) === 7;
213
214        $date1 = $isMonthFormat
215            ? (new DateTimeImmutable($dates[0]))->format('Y-m-01 00:00:00')
216            : "{$dates[0]} 00:00:00";
217
218        $date2 = isset($dates[1])
219            ? ($isMonthFormat
220                ? (new DateTimeImmutable($dates[1]))->format('Y-m-t 23:59:59')
221                : "{$dates[1]} 23:59:59")
222            : ($isMonthFormat
223                ? (new DateTimeImmutable($dates[0]))->format('Y-m-t 23:59:59')
224                : "{$dates[0]} 23:59:59");
225
226        if ($hasRelation && count(explode('.', $column)) <= 2) {
227            [$relation, $field] = explode('.', $column);
228            $query->whereHas(
229                Str::camel($relation),
230                fn(Builder $q): Builder => $q->whereBetween($field, [$date1, $date2])
231            );
232            return;
233        }
234
235        $query->whereBetween($column, [$date1, $date2]);
236    }
237
238    /**
239     * Applies JSON column filter using whereJsonContains.
240     *
241     * @param Builder $query The query builder instance
242     * @param string $column The JSON column name
243     * @param array<string, string> $value Key-value pair to search for in JSON
244     */
245    private function applyJsonFilter(Builder $query, string $column, array $value): void
246    {
247        $key = array_key_first($value);
248        $val = $value[$key];
249
250        if (str_contains($val, ',')) {
251            $values = explode(',', $val);
252            foreach ($values as $field) {
253                $query->whereJsonContains($column, [$key => $field], 'or');
254            }
255        } else {
256            $query->whereJsonContains($column, [$key => $val]);
257        }
258    }
259
260    /**
261     * Applies simple equality filter with relation support.
262     *
263     * For relation fields named 'id', automatically converts to 'external_id'.
264     *
265     * @param Builder $query The query builder instance
266     * @param string $column The column name (may include relation path)
267     * @param string $value The value to match
268     * @param bool $hasRelation Whether the column references a relation
269     */
270    private function applySimpleEquality(Builder $query, string $column, string $value, bool $hasRelation): void
271    {
272        if ($hasRelation) {
273            $parts = explode('.', $column);
274            if (count($parts) === 2) {
275                [$relation, $field] = $parts;
276                $query->whereHas(
277                    Str::camel($relation),
278                    fn(Builder $q): Builder => $q->where($field === 'id' ? 'external_id' : $field, $value)
279                );
280            } elseif (count($parts) === 3) {
281                [$r1, $r2, $field] = $parts;
282                $query->whereHas(
283                    $r1,
284                    fn(Builder $q): Builder => $q->whereHas(
285                        $r2,
286                        fn(Builder $q2): Builder => $q2->where($field, $value)
287                    )
288                );
289            }
290            return;
291        }
292
293        $query->where($column, $value);
294    }
295}