PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.5.3
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.5.3
1.6.5 1.6.4 1.6.3 1.6.2 1.6.1 1.6.0 1.5.4 1.5.5 1.5.3 1.5.2 1.5.1 1.5.0 1.4.2 1.4.1 1.4.0 1.3.28 1.3.27 1.3.26 1.3.25 1.3.23 1.3.22 1.3.21 1.3.20 1.3.19 trunk All 48 releases
fluent-cart / app / Services / Filter / BaseFilter.php

BaseFilter.php in FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler 1.5.3, at app/Services/Filter/BaseFilter.php

1,228 lines 35.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentCart\App\Services\Filter;
4
5 use FluentCart\App\App;
6 use FluentCart\App\Helpers\Helper;
7 use FluentCart\App\Models\Model;
8 use FluentCart\App\Services\DateTime\DateTime;
9 use FluentCart\App\Services\Filter\Concerns\HandleDateFilter;
10 use FluentCart\App\Services\Filter\Concerns\HandleRelationalFilter;
11 use FluentCart\Framework\Database\Orm\Builder;
12 use FluentCart\Framework\Http\Request\Request;
13 use FluentCart\Framework\Pagination\LengthAwarePaginator;
14 use FluentCart\Framework\Support\Arr;
15 use FluentCart\Framework\Support\Str;
16 use InvalidArgumentException;
17
18 /**
19 * Class BaseFilter
20 *
21 * Base class for filtering and querying models with simple and advanced filters.
22 *
23 * @package FluentCart\App\Services\Filter
24 */
25 abstract class BaseFilter
26 {
27 use HandleRelationalFilter, HandleDateFilter;
28
29
30 /**
31 * Determines if the filter type is simple or advanced.
32 *
33 * @var string
34 */
35 public string $filterType = 'simple';
36
37 /**
38 * default primary key of the table
39 *
40 * @var ?string
41 */
42 public ?string $primaryKey = null;
43
44 /**
45 * Default column used for sorting.
46 *
47 * @var string
48 */
49 public string $defaultSortBy = 'id';
50
51 /**
52 * Column used for sorting, dynamically set from arguments.
53 *
54 * @var string
55 */
56 public string $sortBy = '';
57
58 /**
59 * Default sorting order.
60 *
61 * @var string
62 */
63 public string $defaultSortType = 'desc';
64
65 /**
66 * Sorting order (asc/desc).
67 *
68 * @var string
69 */
70 public string $sortType = '';
71
72 /**
73 * Ids that must be loaded.
74 *
75 * @var array
76 */
77 public array $includeIds = [];
78
79 /**
80 * Relations to be loaded with the query.
81 *
82 * @var array
83 */
84 public array $with = [];
85
86 /**
87 * Select fields for the query.
88 *
89 * @var ?array
90 */
91 public array $select = [];
92
93
94 /**
95 * Model scopes.
96 *
97 * @var ?array
98 */
99 public ?array $scopes = [];
100
101 /**
102 * Search query string for simple filtering.
103 *
104 * @var string
105 */
106 public string $search = '';
107
108 /**
109 * Limit of records.
110 *
111 * @var ?int
112 */
113 public ?int $limit = null;
114
115
116 /**
117 * Number of records to retrieve per page.
118 *
119 * @var int
120 */
121 public int $perPage = 10;
122
123
124 /**
125 * Current page number
126 *
127 * @var ?int
128 */
129 public ?int $page = null;
130
131 /**
132 * The offset for paginated results.
133 *
134 * @var ?int
135 */
136 public ?int $offset = null;
137
138 /**
139 * The active view/tab to be filtered.
140 *
141 * @var string|null
142 */
143 public ?string $activeView = '';
144
145 /**
146 * HTTP request instance.
147 *
148 * @var Request
149 */
150 protected Request $request;
151
152 /**
153 * Query builder instance used for filtering and retrieving data.
154 *
155 * @var Builder|LengthAwarePaginator
156 */
157 public $query;
158
159 /**
160 * Additional filtering arguments.
161 *
162 * @var array
163 */
164 public array $args = [];
165
166 /**
167 * Parsed search groups for advanced filtering.
168 *
169 * @var array
170 */
171 public array $searchGroups = [];
172
173
174 /**
175 * User timezone
176 *
177 * @var ?string
178 */
179 public ?string $userTz = null;
180
181 /**
182 * The resolved SavedView model when active_view is a saved view slug.
183 *
184 * @var SavedView|null
185 */
186 protected $activeSavedView = null;
187
188
189 /**
190 * BaseFilter constructor.
191 *
192 * @param array $args Filtering arguments.
193 */
194 public function __construct(array $args = [])
195 {
196 $this->validateModel();
197 $this->parseArgs($args);
198 $this->query = $this->customQuery();
199 }
200
201 /**
202 * Validates the model instance.
203 *
204 * @return void
205 * @throws InvalidArgumentException
206 */
207 protected function validateModel()
208 {
209 $modelClass = $this->getModel();
210 $model = new $modelClass;
211 if (!$model instanceof Model) {
212 throw new InvalidArgumentException('Model class must be an instance of Model');
213 }
214
215 $this->primaryKey = $model->getKeyName();
216 $this->query = $model->newQuery();
217 }
218
219 /**
220 * Parses filtering arguments.
221 *
222 * @param array $args Filtering arguments.
223 * @return void
224 */
225 protected function parseArgs(array $args)
226 {
227
228 $this->args = $args;
229 $this->select = $this->parseSelect();
230 $this->filterType = Arr::get($args, $this->getParsableKey('filter_type'), $this->filterType);
231 $this->search = Arr::get($args, $this->getParsableKey('search'), $this->search);
232 $this->with = Arr::get($args, $this->getParsableKey('with'), $this->with);
233 $this->scopes = Arr::get($args, $this->getParsableKey('scopes'), $this->scopes);
234 $this->limit = Arr::get($args, $this->getParsableKey('limit'), $this->limit);
235 $this->offset = Arr::get($args, $this->getParsableKey('offset'), $this->offset);
236 $this->userTz = Arr::get($args, $this->getParsableKey('user_tz'), $this->userTz);
237 $this->includeIds = $this->parseIncludeIds();
238 $this->activeView = $this->parseAcceptedView();
239 $this->sortBy = $this->parseSortBy();
240 $this->sortType = $this->parseSortType();
241 $this->searchGroups = $this->parseSearchGroups();
242 $this->perPage = $this->parsePerPage();
243 $this->page = $this->parsePageNumber();
244 }
245
246 protected function parseSelect(): array
247 {
248 $select = Arr::get($this->args, $this->getParsableKey('select'));
249
250 if (!$select) {
251 return [];
252 }
253
254 if (is_string($select)) {
255 $select = explode(',', $select);
256 }
257
258 $parsedSelect = [];
259 foreach ($select as $selectItem) {
260 if (is_string($selectItem)) {
261 $parsedSelect[] = sanitize_text_field($selectItem);
262 }
263 }
264
265 return $parsedSelect;
266 }
267
268
269 protected function parseIncludeIds(): array
270 {
271 $includedIds = Arr::get($this->args, $this->getParsableKey('include_ids'), []);
272 if (is_string($includedIds)) {
273 $includedIds = explode(',', $includedIds);
274 }
275 return empty($includedIds) ? [] : Arr::wrap($includedIds);
276 }
277
278 protected function parsePerPage()
279 {
280 $perPage = Arr::get($this->args, $this->getParsableKey('per_page'));
281 if (is_numeric($perPage) && $perPage > 0 && $perPage < 200) {
282 return $perPage;
283 }
284 return $this->perPage;
285 }
286
287 protected function parsePageNumber(): ?int
288 {
289 $page = Arr::get($this->args, $this->getParsableKey('page'), $this->page);
290 return is_numeric($page) ? (int)$page : null;
291 }
292
293 /**
294 * Parses and validates the sorting column.
295 *
296 * @return string
297 */
298 protected function parseSortBy(): string
299 {
300 $sortBy = Arr::get($this->args, $this->getParsableKey('sort_by'));
301
302 if (empty($sortBy)) {
303 return $this->defaultSortBy;
304 }
305
306 /**
307 * @var Model $modelObject
308 */
309 $modelClass = $this->getModel();
310 $modelObject = new $modelClass;
311
312 return in_array($sortBy, $modelObject->getFillable()) ? $sortBy : $this->defaultSortBy;
313 }
314
315 /**
316 * Parses and validates the sorting order.
317 *
318 * @return string
319 */
320 protected function parseSortType(): string
321 {
322 $sortType = strtolower((string)Arr::get($this->args, $this->getParsableKey('sort_type'), ''));
323
324 return in_array($sortType, ['desc', 'asc']) ? $sortType : $this->defaultSortType;
325 }
326
327 /**
328 * Parses and validates the accepted view.
329 * First checks static tabs; if not found, fires a filter so Pro can resolve
330 * the slug to a saved view (returns an object/array with query_params).
331 *
332 * @return string|null
333 */
334 protected function parseAcceptedView(): ?string
335 {
336 $activeView = Arr::get($this->args, $this->getParsableKey('active_view'), $this->activeView);
337
338 if (empty($activeView)) {
339 return null;
340 }
341
342 // Static tab takes priority
343 if (Arr::has($this->tabsMap(), $activeView)) {
344 return $activeView;
345 }
346
347 // Ask Pro to resolve the slug for the current filter only.
348 $resolvedView = apply_filters('fluent_cart/filter_resolve_saved_view', null, [
349 'slug' => $activeView,
350 'filter_name' => static::getFilterName(),
351 'user_id' => get_current_user_id(),
352 ]);
353
354 if (is_array($resolvedView) || is_object($resolvedView)) {
355 $this->activeSavedView = $resolvedView;
356 return null;
357 }
358
359 // Backward compatibility for older Pro versions that only inject the admin table config blob.
360 $tableConfig = apply_filters('fluent_cart/admin_table_saved_views', [], [
361 'filterOptions' => []
362 ]);
363 foreach ($tableConfig as $entry) {
364 $savedViews = isset($entry['saved_views']) ? $entry['saved_views'] : [];
365 foreach ($savedViews as $view) {
366 if (Arr::get($view, 'slug') === $activeView) {
367 $this->activeSavedView = $view;
368 break 2;
369 }
370 }
371 }
372
373 return null;
374 }
375
376 /**
377 * Parses advanced search filters.
378 *
379 * @return array
380 */
381 protected function parseSearchGroups(?string $json = null): array
382 {
383 if ($json === null) {
384 $json = Arr::get($this->args, $this->getParsableKey('advanced_filters'), '[]');
385 }
386
387 $filters = [];
388
389 try {
390 $filters = json_decode($json, true);
391 } catch (\Exception $exception) {
392 // Ignore exception, return empty filters
393 }
394
395 if (empty($filters)) {
396 return [];
397 }
398
399 $groups = [];
400
401 foreach ($filters as $filterGroup) {
402 $group = [];
403 foreach ($filterGroup as $filterItem) {
404 if (count($filterItem['source']) != 2 || empty($filterItem['source'][0]) || empty($filterItem['source'][1]) || empty($filterItem['operator'])) {
405 continue;
406 }
407 $provider = $filterItem['source'][0];
408
409 if (!isset($group[$provider])) {
410 $group[$provider] = [];
411 }
412
413 $property = $filterItem['source'][1];
414
415 $filterData = [
416 'property' => $property,
417 'operator' => Arr::get($filterItem, 'operator'),
418 'value' => Arr::get($filterItem, 'value'),
419 'filter_type' => Arr::get($filterItem, 'filter_type'),
420 ];
421
422 if (Arr::get($filterData, 'filter_type') === 'relation') {
423 $filterData['relation'] = Arr::get($filterItem, 'relation', $property);
424 $filterData['column'] = Arr::get($filterItem, 'column', 'id');
425 }
426
427 $group[$provider][] = $filterData;
428
429
430 }
431
432 if ($group) {
433 $groups[] = $group;
434 }
435 }
436
437 return $groups;
438 }
439
440 /**
441 * Sets the HTTP request instance.
442 *
443 * @param Request $request
444 * @return $this
445 */
446 public function setRequest(Request $request): BaseFilter
447 {
448 $this->request = $request;
449 return $this;
450 }
451
452 /**
453 * Builds the query based on filters.
454 *
455 * @return Builder
456 */
457 public function buildQuery(): Builder
458 {
459 $this->buildCommonQuery();
460 $this->applyCurrentFilterLayer();
461
462 if ($this->activeSavedView) {
463 $this->applySavedViewFilter();
464 }
465
466 return $this->query;
467 }
468
469 protected function applyCurrentFilterLayer(): void
470 {
471 if ($this->filterType == 'simple') {
472 $this->applyActiveViewFilter();
473 $this->applySimpleFilter();
474 } else if ($this->filterType == 'advanced') {
475 $this->applyAdvancedFilter();
476 }
477 }
478
479 protected function applySavedViewFilter(?array $params = null): void
480 {
481 $params = $params ?? $this->getSavedViewParams();
482
483 if (empty($params)) {
484 return;
485 }
486
487 $this->query->where(function ($savedQuery) use ($params) {
488 $originalQuery = $this->query;
489 $this->query = $savedQuery;
490
491 $savedFilterType = Arr::get($params, 'filter_type', 'simple');
492
493 if ($savedFilterType === 'advanced') {
494 $savedGroups = $this->getSavedViewSearchGroups($params);
495 if (!empty($savedGroups)) {
496 $this->applyAdvancedFilter($savedGroups);
497 }
498 } else {
499 $savedActiveView = Arr::get($params, 'active_view');
500 if ($savedActiveView && Arr::has($this->tabsMap(), $savedActiveView)) {
501 $this->applyActiveViewFilter($savedActiveView);
502 }
503
504 $savedSearch = Arr::get($params, 'search', '');
505 if (!empty($savedSearch)) {
506 $this->applySimpleFilter($savedSearch);
507 }
508 }
509
510 $this->query = $originalQuery;
511 });
512 }
513
514 protected function getSavedViewParams(): array
515 {
516 $savedView = $this->activeSavedView;
517
518 return is_array($savedView)
519 ? Arr::get($savedView, 'query_params', [])
520 : (is_object($savedView) ? $savedView->query_params : []);
521 }
522
523 protected function getSavedViewSearchGroups(array $params): array
524 {
525 $json = Arr::get($params, 'advanced_filters', '[]');
526 if (is_array($json)) {
527 $json = wp_json_encode($json);
528 }
529
530 return $this->parseSearchGroups($json);
531 }
532
533 /**
534 * Builds the common query that should be applied in every query.
535 *
536 * @return void
537 */
538
539 protected function buildCommonQuery()
540 {
541 $this->applySelect();
542 $this->applyWith();
543 $this->applyScopes();
544
545 if (count($this->includeIds) > 0) {
546 $this->applyMustLoadIds();
547 }
548 $this->applySort();
549 }
550
551
552 public function applySelect()
553 {
554 if (empty($this->select)) {
555 return;
556 }
557 $this->query->select($this->select);
558 }
559
560 protected function getPrimaryKey(): string
561 {
562 return $this->primaryKey;
563 }
564
565 protected function applyMustLoadIds()
566 {
567
568 if ($this->search === '') {
569 $this->query = $this->query->whereIn(
570 $this->getPrimaryKey(),
571 $this->includeIds
572 )->orWhereNotNull($this->getPrimaryKey());
573 } else {
574 $this->query = $this->query->orWhereIn(
575 $this->getPrimaryKey(),
576 $this->includeIds
577 );
578 }
579
580 }
581
582 protected function applyLimit()
583 {
584 $this->query = $this->query->limit($this->limit);
585 }
586
587 protected function applyOffset()
588 {
589 $this->query = $this->query->offset($this->offset);
590 }
591
592 protected function applySort()
593 {
594 $this->query = $this->query->orderBy($this->sortBy, $this->sortType);
595 }
596
597 protected function applyWith()
598 {
599 $withs = Arr::wrap($this->with);
600
601 foreach ($withs as $with) {
602 if (Str::of($with)->lower()->endsWith('count')) {
603 //Get the relation name from count
604 //e.g.: variantsCount converts to variants
605 $relationName = Str::of($with)->substr(0, -5)->toString();
606 $this->query = $this->query->withCount($relationName);
607 } else {
608 $this->query = $this->query->with($with);
609 }
610 }
611
612 }
613
614 protected function applyScopes()
615 {
616 $scopes = Arr::wrap($this->scopes);
617 foreach ($scopes as $scope) {
618 if (is_array($scope)) {
619 $this->query = $this->query->{$scope[0]}($scope[1]);
620 continue;
621 }
622 $this->query = $this->query->{$scope}();
623 }
624 }
625
626 /**
627 * Applies advanced filters to the query.
628 *
629 * @return void
630 */
631 protected function applyAdvancedFilter(?array $searchGroups = null): void
632 {
633
634 if (!App::isProActive()) {
635 return;
636 }
637
638 $filtersGroups = $searchGroups ?? $this->searchGroups;
639 if (empty($filtersGroups)) {
640 return;
641 }
642
643
644 $filterName = static::getFilterName();
645 $allFilterOptions = apply_filters("fluent_cart/{$filterName}_filter_options", static::advanceFilterOptions());
646 foreach ($filtersGroups as $groupIndex => $group) {
647
648
649 $method = $groupIndex == 0 ? 'where' : 'orWhere';
650
651 $this->query->{$method}(function ($query) use ($group, $filterName, $allFilterOptions) {
652 foreach ($group as $providerName => $items) {
653 $items = $this->mergeRelationFilters($items);
654 foreach ($items as $item) {
655 if ($item['filter_type'] === 'custom') {
656 $filters = $allFilterOptions;
657 $filter = Arr::get($filters, $providerName . '.children', null);
658 $property = Arr::get($item, 'property');
659 $isCallbackFound = false;
660 if (is_array($filter)) {
661 foreach ($filter as $filterItem) {
662 if ($filterItem['value'] === $item['property']) {
663 $callback = Arr::get($filterItem, 'callback', null);
664 if ($callback) {
665 $callback($query, $item);
666 $isCallbackFound = true;
667 }
668 break;
669 }
670 }
671 }
672
673 if ($isCallbackFound) {
674 continue;
675 }
676 do_action_ref_array("fluent_cart/{$filterName}_filter/{$providerName}/{$item['property']}", [&$query, $item]);
677 } else {
678 $this->handleAdvanceFilter($query, $item);
679 }
680
681 }
682 }
683 });
684 }
685 }
686
687 /**
688 * Merge relation filter items that target the same relation field with compatible operators.
689 * This prevents multiple whereHas() subqueries ANDed together for the same relation column,
690 * which would return no results (e.g., license status IN ('active') AND license status IN ('expired')).
691 */
692 private function mergeRelationFilters(array $items): array
693 {
694 $merged = [];
695 $relationGroups = [];
696
697 foreach ($items as $item) {
698 if (Arr::get($item, 'filter_type') !== 'relation') {
699 $merged[] = $item;
700 continue;
701 }
702
703 $value = Arr::get($item, 'value');
704 $operator = $item['operator'] ?? '';
705 $mergeableOperators = ['in', 'contains', 'not_in', 'not_contains'];
706
707 if (!in_array($operator, $mergeableOperators)) {
708 $merged[] = $item;
709 continue;
710 }
711
712 // Normalize string value to array for merging
713 if (is_string($value) && $value !== '') {
714 $value = [$value];
715 $item['value'] = $value;
716 }
717
718 if (!is_array($value) || empty($value)) {
719 $merged[] = $item;
720 continue;
721 }
722
723 $key = $item['property'] . ':' . $item['relation'] . ':' . $item['column'] . ':' . $operator;
724 if (!isset($relationGroups[$key])) {
725 $relationGroups[$key] = $item;
726 } else {
727 $relationGroups[$key]['value'] = array_values(
728 array_unique(array_merge($relationGroups[$key]['value'], $value))
729 );
730 }
731 }
732
733 return array_merge($merged, array_values($relationGroups));
734 }
735
736 private function handleAdvanceFilter($query, $filterItem)
737 {
738 if (Arr::get($filterItem, 'filter_type') === 'relation') {
739 $this->handleRelation($query, $filterItem);
740 } else if (Arr::get($filterItem, 'filter_type') === 'date') {
741 $this->handleDate($query, $filterItem);
742 } else {
743 $this->handleOperator($query, $filterItem);
744 }
745
746
747 //
748 }
749
750 private function handleOperator(Builder &$query, array $filterItem)
751 {
752
753 $searchTerm = $filterItem['value'];
754
755 if (is_array($searchTerm)) {
756 $this->searchFromArray($query, $filterItem);
757 } else {
758 $this->searchFromString($query, $filterItem);
759 }
760 }
761
762 private function searchFromArray(Builder &$query, array $filterItem)
763 {
764 $property = $filterItem['property'];
765 $operator = $filterItem['operator'];
766 $searchTerm = $filterItem['value'];
767 $methodName = 'modify' . Str::studly($property . '_value');
768
769 if (in_array($property, $this->centColumns())) {
770 $searchTerm = array_map(function ($value) {
771 return Helper::toCent($value);
772 }, $searchTerm);
773 }
774 if (method_exists($this, $methodName)) {
775 $searchTerm = $this->{$methodName}($searchTerm, $filterItem, $query);
776 if ($searchTerm === null) {
777 return;
778 }
779 }
780
781 if (in_array($operator, ['in', 'contains'])) {
782 $query = $query->whereIn($property, $searchTerm);
783 } else if (in_array($operator, ['not_in', 'not_contains'])) {
784 $query = $query->whereNotIn($property, $searchTerm);
785 } elseif (in_array($operator, ['in_all', 'not_in_all'])) {
786 $condition = $operator === 'in_all' ? '=' : '!=';
787 foreach ($searchTerm as $term) {
788 $query = $query->where($property, $condition, $term);
789 }
790 } else if (in_array($operator, $this->getSimpleOperators(['::']))) {
791 $query = $query->where($property, $operator, $searchTerm);
792 }
793 }
794
795 private function searchFromString(Builder &$query, array $filterItem)
796 {
797 $property = $filterItem['property'];
798 $operator = $filterItem['operator'];
799 $searchTerm = $filterItem['value'];
800
801 $methodName = 'modify' . Str::studly($property . '_value');
802
803 if (in_array($property, $this->centColumns())) {
804 $searchTerm = Helper::toCent($searchTerm);
805 }
806 if (method_exists($this, $methodName)) {
807 $searchTerm = $this->{$methodName}($searchTerm, $filterItem, $query);
808
809 if ($searchTerm === null) {
810 return;
811 }
812 }
813
814
815 if (in_array($operator, ['contains', 'in'])) {
816 $query = $query->where($property, 'LIKE', '%' . $searchTerm . '%');
817 } else if (in_array($operator, ['not_contains', 'not_in'])) {
818 $query = $query->where($property, 'NOT LIKE', '%' . $searchTerm . '%');
819 } else if ($operator === 'is_null') {
820 $query = $query->where(function (Builder $q) use ($property) {
821 return $q->whereNull($property)
822 ->orWhere($property, '=', '');
823 });
824 } else if ($operator === 'not_null') {
825 $query = $query->where(function (Builder $q) use ($property) {
826 return $q->whereNotNull($property)
827 ->orWhere($property, '!=', '');
828 });
829 } else if (in_array($operator, $this->getSimpleOperators(['::']))) {
830 $query = $query->where($property, $operator, $searchTerm);
831 } else {
832 $query = $query->where($property, $operator, $searchTerm);
833 }
834 }
835
836 /**
837 * Apply the simple Filters.
838 *
839 * @return void
840 */
841
842 public abstract function applySimpleFilter(?string $search = null): void;
843
844 public abstract function applyActiveViewFilter(?string $activeView = null): void;
845
846 /**
847 * Return the maps of [table-column, tabs-name]
848 *
849 * @return array
850 */
851 public abstract function tabsMap(): array;
852
853 /**
854 * Return Model name
855 *
856 * @return string
857 */
858 public abstract function getModel(): string;
859
860
861 private function getDbColumns(): array
862 {
863 $modelClass = $this->getModel();
864 $model = new $modelClass;
865 // Get fillable columns and add primary key
866 return array_merge($model->getFillable(), [$this->primaryKey]);
867 }
868
869
870 /**
871 * Return the columns that are searchable
872 *
873 * @return array
874 */
875 public static function getSearchableFields(): array
876 {
877 $self = (new static);
878 $columns = $self->getDbColumns();
879 // Create case-insensitive lookup array
880 $searchableColumns = [];
881 foreach ($columns as $column) {
882 $searchableColumns[strtolower($column)] = $column;
883 $searchableColumns[$column] = $column;
884 }
885
886 return $searchableColumns;
887 }
888
889 /**
890 * Return the operators that are supported for simple filters
891 *
892 * @return array
893 */
894 public function getSimpleOperators($except = []): array
895 {
896 return Arr::except(
897 ['=', '!=', '>', '<', '>=', '<=', '::'],
898 $except
899 );
900 }
901
902 public function applySimpleOperatorFilter(?string $search = null): bool
903 {
904 $operators = $this->getSimpleOperators();
905
906 // check if search has an operator with regexp
907 $operatorPattern = '/\s*(' . implode('|', $operators) . ')\s*/';
908
909 $search = trim($search ?? $this->search);
910 if (preg_match($operatorPattern, $search, $matches)) {
911 $operator = $matches[1];
912 $searchParts = explode($operator, $search);
913
914 if (count($searchParts) >= 2) {
915 $column = trim($searchParts[0]);
916 $value = trim($searchParts[1]);
917
918 // Check if the column is valid
919 $validColumns = static::getSearchableFields();
920 $column = strtolower($column);
921 if ($columnSchema = Arr::get($validColumns, $column, null)) {
922
923 $type = Arr::get($columnSchema, 'type', 'string');
924
925 if ($type === 'custom') {
926 $callback = $columnSchema['callback'];
927 $callback($this->query, $value, $operator, $this);
928 return true;
929 }
930 if (is_array($columnSchema)) {
931 $column = $columnSchema['column'];
932 } else {
933 $column = $columnSchema;
934 }
935
936 if ($operator == '::') {
937 $values = explode('-', $value);
938 if (count($values) == 2) {
939 if (in_array($column, $this->centColumns())) {
940 $values[0] = Helper::toCent($values[0]);
941 $values[1] = Helper::toCent($values[1]);
942 } else if (in_array($column, $this->dateColumns())) {
943 $values[0] = DateTime::anyTimeToGmt($values[0], $this->userTz)->format('Y-m-d H:i:s');
944 $values[1] = DateTime::anyTimeToGmt($values[1], $this->userTz)->format('Y-m-d H:i:s');
945 }
946 $this->query->whereBetween($column, $values);
947 return true;
948 }
949 }
950
951 if (in_array($column, $this->centColumns())) {
952 $value = Helper::toCent($value);
953 } else if (in_array($column, $this->dateColumns())) {
954 $value = DateTime::anyTimeToGmt($value, $this->userTz)->format('Y-m-d H:i:s');
955 }
956
957 if ($this->shouldApplyMatchFilter($operator)) {
958 $this->applyMatchFilter($this->query, $column, $value, $operator);
959 } else {
960 $this->query->where($column, $operator, $value);
961 }
962
963
964 return true;
965 }
966 }
967 }
968
969 return false;
970 }
971
972 public function shouldApplyMatchFilter(string $operator): bool
973 {
974 $operator = trim($operator);
975 return $operator === '=' || $operator==='!=';
976 }
977
978 public function applyMatchFilter(Builder $query, string $column, $value, string $operator = '='): Builder
979 {
980 $value = sanitize_text_field($value);
981
982 $hasStartWildcard = Str::startsWith($value, '*');
983 $hasEndWildcard = Str::endsWith($value, '*');
984
985 // No wildcards → strict equality / inequality
986 if (!$hasStartWildcard && !$hasEndWildcard) {
987 return $query->where($column, $operator === '!=' ? '!=' : '=', $value);
988 }
989
990 // Remove * wildcards
991 $likeValue = $value;
992
993 if ($hasStartWildcard) {
994 $likeValue = ltrim($likeValue, '*');
995 }
996
997 if ($hasEndWildcard) {
998 $likeValue = rtrim($likeValue, '*');
999 }
1000
1001 // Convert to SQL LIKE pattern
1002 if ($hasStartWildcard && $hasEndWildcard) {
1003 $likeValue = '%' . $likeValue . '%'; // *value*
1004 } elseif ($hasStartWildcard) {
1005 $likeValue = '%' . $likeValue; // *value
1006 } else {
1007 $likeValue = $likeValue . '%'; // value*
1008 }
1009
1010 $sqlOperator = $operator === '!=' ? 'NOT LIKE' : 'LIKE';
1011
1012 return $query->where($column, $sqlOperator, $likeValue);
1013 }
1014
1015
1016
1017
1018 public function centColumns(): array
1019 {
1020 return [];
1021 }
1022
1023 public function dateColumns(): array
1024 {
1025 return ['updated_at', 'created_at'];
1026 }
1027
1028 /**
1029 * Return the name of filter
1030 *
1031 * @return string
1032 */
1033
1034 public static abstract function getFilterName(): string;
1035
1036
1037 /**
1038 * Return the maps of [key, key-name]
1039 * It's used for parse the data
1040 *
1041 * @return array
1042 */
1043 public static function parseableKeyMap(): array
1044 {
1045 return [
1046 'filter_type' => 'filter_type',
1047 'with' => 'with',
1048 'search' => 'search',
1049 'limit' => 'limit',
1050 'offset' => 'offset',
1051 'active_view' => 'active_view',
1052 'sort_by' => 'sort_by',
1053 'sort_type' => 'sort_type',
1054 'advanced_filters' => 'advanced_filters',
1055 'per_page' => 'per_page',
1056 'include_ids' => 'include_ids',
1057 'scopes' => 'scopes',
1058 'user_tz' => 'user_tz',
1059 'select' => 'select',
1060 'page' => 'page'
1061 ];
1062 }
1063
1064 /**
1065 * Return the names of allowed keys preserved in data
1066 *
1067 * @return array
1068 */
1069
1070 public static function parseableKeys(): array
1071 {
1072 return static::parseableKeyMap();
1073 }
1074
1075 /**
1076 * Return the names of the kye, which should be used to parse the value
1077 *
1078 * @param string $key // Name of the Key
1079 * @return string
1080 */
1081 private function getParsableKey(string $key): string
1082 {
1083 return Arr::has(static::parseableKeyMap(), $key) ?
1084 static::parseableKeyMap()[$key] : $key;
1085
1086 }
1087
1088 public function query()
1089 {
1090 return $this->query;
1091 }
1092
1093 public function setQuery(Builder $query): BaseFilter
1094 {
1095 $this->query = $query;
1096 return $this;
1097 }
1098
1099 public function customQuery()
1100 {
1101 return $this->query;
1102 }
1103
1104 public function get()
1105 {
1106 //Apply limit and offset only when using get
1107 //While using pagination, limit and offset are auto calculated
1108
1109 $this->buildQuery();
1110
1111 if (!empty($this->limit)) {
1112 $this->applyLimit();
1113 }
1114
1115 if (!empty($this->offset)) {
1116 $this->applyOffset();
1117 }
1118 $filter = $this->getFilterName();
1119 $this->query = apply_filters("fluent_cart/{$filter}_list_filter_query", $this->query, $this->toArray());
1120 return $this->query->get();
1121 }
1122
1123 public function paginate($perPage = null): LengthAwarePaginator
1124 {
1125 $this->buildQuery();
1126 $perPage = empty($perPage) ? $this->perPage : $perPage;
1127 $filter = $this->getFilterName();
1128 $this->query = apply_filters("fluent_cart/{$filter}_list_filter_query", $this->query, $this->toArray());
1129 return $this->query->paginate(
1130 $perPage,
1131 ['*'],
1132 'page',
1133 $this->page
1134 );
1135 }
1136
1137 public static function fromRequest(Request $request): BaseFilter
1138 {
1139 return new static($request->only(
1140 static::parseableKeys()
1141 ));
1142 }
1143
1144 public static function make(array $args): BaseFilter
1145 {
1146 return new static($args);
1147 }
1148
1149 public static function getAdvanceFilterOptions(): ?array
1150 {
1151 $filterName = static::getFilterName();
1152 $options = apply_filters("fluent_cart/{$filterName}_filter_options", static::advanceFilterOptions());
1153 return is_array($options) ? array_values($options) : null;
1154 }
1155
1156 private static function advanceFilterOptions(): ?array
1157 {
1158 return null;
1159 }
1160
1161 public static function getCustomColumns()
1162 {
1163 // $data = [
1164 // [
1165 // 'title' => 'Product One',
1166 // 'meta' => [
1167 // 'max_price' => '100',
1168 // 'min_price' => '80',
1169 // ]
1170 // ],
1171 // [
1172 // 'title' => 'Product Two',
1173 // 'meta' => [
1174 // 'max_price' => '150',
1175 // 'min_price' => '90',
1176 // ]
1177 // ]
1178 // ];
1179 // $example_columns = [
1180 // 'title' => [
1181 // 'label' => 'Title',
1182 // 'accessor' => 'title',
1183 // 'as_link' => false,
1184 // ],
1185 // 'max_price' => [
1186 // 'label' => 'Max Price',
1187 // 'accessor' => 'meta.max_price'
1188 // ],
1189 // 'min_price' => [
1190 // 'label' => 'Min Price',
1191 // 'accessor' => 'meta.min_price'
1192 // ]
1193 // ];
1194 $filterName = static::getFilterName();
1195 return apply_filters("fluent_cart/{$filterName}_table_columns", []);
1196 }
1197
1198 public static function getTableFilterOptions(): array
1199 {
1200 return [
1201 'advance' => static::getAdvanceFilterOptions(),
1202 'guide' => static::getSearchableFields(),
1203 'columns' => static::getCustomColumns(),
1204 ];
1205 }
1206
1207 public function toArray(): array
1208 {
1209 return [
1210 'select' => $this->select,
1211 'filterType' => $this->filterType,
1212 'search' => $this->search,
1213 'with' => $this->with,
1214 'scopes' => $this->scopes,
1215 'limit' => $this->limit,
1216 'offset' => $this->offset,
1217 'userTz' => $this->userTz,
1218 'includeIds' => $this->includeIds,
1219 'activeView' => $this->activeView,
1220 'sortBy' => $this->sortBy,
1221 'sortType' => $this->sortType,
1222 'searchGroups' => $this->searchGroups,
1223 'perPage' => $this->perPage,
1224 ];
1225 }
1226
1227 }
1228