PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.1
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.1
1.6.6 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 All 49 releases
fluent-cart / app / Services / Filter / BaseFilter.php

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

1,409 lines 42.5 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\App\Services\Permission\PermissionManager;
12 use FluentCart\Framework\Database\Orm\Builder;
13 use FluentCart\Framework\Http\Request\Request;
14 use FluentCart\Framework\Pagination\LengthAwarePaginator;
15 use FluentCart\Framework\Support\Arr;
16 use FluentCart\Framework\Support\Str;
17 use InvalidArgumentException;
18
19 /**
20 * Class BaseFilter
21 *
22 * Base class for filtering and querying models with simple and advanced filters.
23 *
24 * @package FluentCart\App\Services\Filter
25 */
26 abstract class BaseFilter
27 {
28 use HandleRelationalFilter, HandleDateFilter;
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 /**
598 * What the `with` request parameter may load.
599 *
600 * DENY BY DEFAULT — the base returns an empty map, so a filter that does not
601 * override this loads nothing at all. That matters because the ORM resolves
602 * a relation by literally calling that method on the model
603 * (`Builder::getRelation()`), catching only BadMethodCallException. A method
604 * that exists but is not a relation still RUNS: before this allow-list,
605 * `with[]=recountTotalPaidAndRefund` inserted a phantom refunded order row.
606 *
607 * ## The entry form — one form, no others
608 *
609 * Every entry is a LITERAL request key mapped to a CALLABLE. Nothing else is
610 * an entry: a non-callable value is refused. The key is never decomposed,
611 * pattern-matched or prefix-parsed, so what the client sends is either a key
612 * in this map or it is dropped.
613 *
614 * protected function allowedWiths(): array
615 * {
616 * return [
617 * 'admin_product_list' => [$this, 'adminProductList'],
618 * ];
619 * }
620 *
621 * protected function adminProductList($query)
622 * {
623 * if (!$this->userCanAny('products/view')) {
624 * return false; // contributes nothing
625 * }
626 *
627 * return $query->with(['detail' => function ($q) {
628 * $q->select(['id', 'post_id', 'featured_media']);
629 * }]);
630 * }
631 *
632 * ## Callback contract
633 *
634 * The callback receives the query as built so far — every entry already
635 * adopted is on it — checks its own permission, applies its own eager load
636 * (`with()`, `withCount()`, whatever it needs), and returns the query. It
637 * runs EXACTLY ONCE. The base never learns a relation name; the callback owns
638 * the whole path, the selects and the gate.
639 *
640 * return false (or anything that is not a Builder) → contributes nothing
641 * return the Builder → the base adopts it
642 *
643 * A count is not a special case: it is a callback that calls `withCount()`.
644 *
645 * ## Nested paths
646 *
647 * Nesting is not a special case either: name the full path (`'a.b'`) inside
648 * the callback. Two consequences, both of them sharp:
649 *
650 * 1. `with('a.b')` auto-injects the parent `a` with an EMPTY closure, so a
651 * nested callback must repeat whatever GATE the parent carries — the
652 * nested path is otherwise a way around it.
653 * 2. That injected empty closure also overwrites a CONSTRAINT an earlier
654 * entry put on `a`, because each callback issues its own `with()` call and
655 * Builder::with() array_merges into $eagerLoad (addNestedWiths() only
656 * protects entries inside a single call). So a nested callback must also
657 * re-state the parent's constraint in its own `with()` call:
658 *
659 * return $query->with([
660 * 'a' => function ($q) { $q->select([...]); },
661 * 'a.b' => function ($q) { $q->select([...]); },
662 * ]);
663 *
664 * ## Why the builder you are handed is safe to mutate
665 *
666 * `$query->with([...])` mutates in place and returns the same instance, so a
667 * callback that mutated and then refused would already have changed the
668 * query. The base therefore invokes every callback against `clone
669 * $this->query` and adopts the result only when a Builder comes back:
670 * Orm\Builder::__clone() also clones the underlying Query\Builder, and both
671 * hold their wheres, bindings, columns and eager loads in plain arrays, which
672 * PHP copies by value. A refusing callback is structurally incapable of
673 * touching the live query.
674 *
675 * ## Escape valve
676 *
677 * The gate lives in applyWith(), so it covers every instance including a
678 * `with` assigned after construction, and it reads the CURRENT user — a cron
679 * or WP-CLI caller loses permission-gated entries. The
680 * `fluent_cart/{filter}_allowed_withs` filter is how an add-on or a
681 * privileged background job adds its own entry.
682 *
683 * @return array<string, callable>
684 */
685 protected function allowedWiths(): array
686 {
687 return [];
688 }
689
690 /**
691 * What the `scopes` request parameter may apply.
692 *
693 * DENY BY DEFAULT. The old applyScopes() invoked whatever method name the
694 * request supplied directly on the query builder, before any WHERE clause
695 * existed — `scopes[]=delete` emptied the table, `scopes[]=truncate` wiped it
696 * and the array form mass-updated every row.
697 *
698 * Same single entry form as allowedWiths(): literal key => callable. This is
699 * a separate map only because the request delivers it on a different
700 * parameter; the resolution and the safety properties are identical.
701 *
702 * Scope callbacks are invoked as `($query, $args)`, where `$args` is the raw
703 * remainder of an array-form request entry (`scopes[]=['x','y']` → `['y']`).
704 * `$args` is UNVALIDATED client input. Declare the parameter only if you
705 * intend to validate it; a callback declared `($query)` simply never sees it,
706 * which is the default refusal of request-supplied arguments.
707 *
708 * A scope name is not reachable unless a callback routes to it, and routing
709 * through `Builder::scopes()` adds a second structural gate: it resolves via
710 * Model::callNamedScope(), which prefixes with "scope", so only a real
711 * `scopeX()` method exists at the end of that path.
712 *
713 * @return array<string, callable>
714 */
715 protected function allowedScopes(): array
716 {
717 return [];
718 }
719
720 protected function applyWith()
721 {
722 $filterName = static::getFilterName();
723
724 $withMap = apply_filters(
725 "fluent_cart/{$filterName}_allowed_withs", $this->allowedWiths(), ['filter' => $this]
726 );
727
728 foreach (Arr::wrap($this->with) as $requestKey) {
729 // Dropping non-strings also kills the array-nested with[parent][]=child
730 // form, which the ORM would otherwise read as the path parent.child.
731 if (!is_string($requestKey) || !array_key_exists($requestKey, $withMap)) {
732 continue;
733 }
734
735 $this->adoptAllowEntry($withMap[$requestKey]);
736 }
737 }
738
739 protected function applyScopes()
740 {
741 $filterName = static::getFilterName();
742
743 $scopeMap = apply_filters(
744 "fluent_cart/{$filterName}_allowed_scopes", $this->allowedScopes(), ['filter' => $this]
745 );
746
747 foreach (Arr::wrap($this->scopes) as $requested) {
748 $args = [];
749
750 if (is_array($requested)) {
751 $requestKey = isset($requested[0]) ? $requested[0] : null;
752 $args = array_slice($requested, 1);
753 } else {
754 $requestKey = $requested;
755 }
756
757 if (!is_string($requestKey) || !array_key_exists($requestKey, $scopeMap)) {
758 continue;
759 }
760
761 $this->adoptAllowEntry($scopeMap[$requestKey], $args);
762 }
763 }
764
765 /**
766 * Invoke one allow-map entry and adopt what it hands back.
767 *
768 * The callback gets a CLONE of the live query, so a callback that mutates
769 * and then refuses cannot leave its mutation behind. Only a Builder is
770 * adopted; every other return value — false, null, a stray string — means the
771 * entry contributes nothing.
772 *
773 * @param mixed $entry Anything non-callable is refused.
774 * @param array $args Unvalidated client arguments; always empty for a `with`.
775 * @return void
776 */
777 protected function adoptAllowEntry($entry, array $args = []): void
778 {
779 if (!is_callable($entry)) {
780 return;
781 }
782
783 $result = $entry(clone $this->query, $args);
784
785 if ($result instanceof Builder) {
786 $this->query = $result;
787 }
788 }
789
790 /**
791 * @param string|array $permission
792 * @return bool
793 */
794 protected function userCan($permission): bool
795 {
796 return PermissionManager::hasPermission((array)$permission);
797 }
798
799 /**
800 * @param string|array $permission
801 * @return bool
802 */
803 protected function userCanAny($permission): bool
804 {
805 return PermissionManager::hasAnyPermission((array)$permission);
806 }
807 /**
808 * Applies advanced filters to the query.
809 *
810 * @return void
811 */
812 protected function applyAdvancedFilter(?array $searchGroups = null): void
813 {
814
815 if (!App::isProActive()) {
816 return;
817 }
818
819 $filtersGroups = $searchGroups ?? $this->searchGroups;
820 if (empty($filtersGroups)) {
821 return;
822 }
823
824
825 $filterName = static::getFilterName();
826 $allFilterOptions = apply_filters("fluent_cart/{$filterName}_filter_options", static::advanceFilterOptions());
827 foreach ($filtersGroups as $groupIndex => $group) {
828
829
830 $method = $groupIndex == 0 ? 'where' : 'orWhere';
831
832 $this->query->{$method}(function ($query) use ($group, $filterName, $allFilterOptions) {
833 foreach ($group as $providerName => $items) {
834 $items = $this->mergeRelationFilters($items);
835 foreach ($items as $item) {
836 if ($item['filter_type'] === 'custom') {
837 $filters = $allFilterOptions;
838 $filter = Arr::get($filters, $providerName . '.children', null);
839 $property = Arr::get($item, 'property');
840 $isCallbackFound = false;
841 if (is_array($filter)) {
842 foreach ($filter as $filterItem) {
843 if ($filterItem['value'] === $item['property']) {
844 $callback = Arr::get($filterItem, 'callback', null);
845 if ($callback) {
846 $callback($query, $item);
847 $isCallbackFound = true;
848 }
849 break;
850 }
851 }
852 }
853
854 if ($isCallbackFound) {
855 continue;
856 }
857 do_action_ref_array("fluent_cart/{$filterName}_filter/{$providerName}/{$item['property']}", [&$query, $item]);
858 } else {
859 $this->handleAdvanceFilter($query, $item);
860 }
861
862 }
863 }
864 });
865 }
866 }
867
868 /**
869 * Merge relation filter items that target the same relation field with compatible operators.
870 * This prevents multiple whereHas() subqueries ANDed together for the same relation column,
871 * which would return no results (e.g., license status IN ('active') AND license status IN ('expired')).
872 */
873 private function mergeRelationFilters(array $items): array
874 {
875 $merged = [];
876 $relationGroups = [];
877
878 foreach ($items as $item) {
879 if (Arr::get($item, 'filter_type') !== 'relation') {
880 $merged[] = $item;
881 continue;
882 }
883
884 $value = Arr::get($item, 'value');
885 $operator = $item['operator'] ?? '';
886 $mergeableOperators = ['in', 'contains', 'not_in', 'not_contains'];
887
888 if (!in_array($operator, $mergeableOperators)) {
889 $merged[] = $item;
890 continue;
891 }
892
893 // Normalize string value to array for merging
894 if (is_string($value) && $value !== '') {
895 $value = [$value];
896 $item['value'] = $value;
897 }
898
899 if (!is_array($value) || empty($value)) {
900 $merged[] = $item;
901 continue;
902 }
903
904 $key = $item['property'] . ':' . $item['relation'] . ':' . $item['column'] . ':' . $operator;
905 if (!isset($relationGroups[$key])) {
906 $relationGroups[$key] = $item;
907 } else {
908 $relationGroups[$key]['value'] = array_values(
909 array_unique(array_merge($relationGroups[$key]['value'], $value))
910 );
911 }
912 }
913
914 return array_merge($merged, array_values($relationGroups));
915 }
916
917 private function handleAdvanceFilter($query, $filterItem)
918 {
919 if (Arr::get($filterItem, 'filter_type') === 'relation') {
920 $this->handleRelation($query, $filterItem);
921 } else if (Arr::get($filterItem, 'filter_type') === 'date') {
922 $this->handleDate($query, $filterItem);
923 } else {
924 $this->handleOperator($query, $filterItem);
925 }
926
927
928 //
929 }
930
931 private function handleOperator(Builder &$query, array $filterItem)
932 {
933
934 $searchTerm = $filterItem['value'];
935
936 if (is_array($searchTerm)) {
937 $this->searchFromArray($query, $filterItem);
938 } else {
939 $this->searchFromString($query, $filterItem);
940 }
941 }
942
943 private function searchFromArray(Builder &$query, array $filterItem)
944 {
945 $property = $filterItem['property'];
946 $operator = $filterItem['operator'];
947 $searchTerm = $filterItem['value'];
948 $methodName = 'modify' . Str::studly($property . '_value');
949
950 if (in_array($property, $this->centColumns())) {
951 $searchTerm = array_map(function ($value) {
952 return Helper::toCent($value);
953 }, $searchTerm);
954 }
955 if (method_exists($this, $methodName)) {
956 $searchTerm = $this->{$methodName}($searchTerm, $filterItem, $query);
957 if ($searchTerm === null) {
958 return;
959 }
960 }
961
962 if (in_array($operator, ['in', 'contains'])) {
963 $query = $query->whereIn($property, $searchTerm);
964 } else if (in_array($operator, ['not_in', 'not_contains'])) {
965 $query = $query->whereNotIn($property, $searchTerm);
966 } elseif (in_array($operator, ['in_all', 'not_in_all'])) {
967 $condition = $operator === 'in_all' ? '=' : '!=';
968 foreach ($searchTerm as $term) {
969 $query = $query->where($property, $condition, $term);
970 }
971 } else if (in_array($operator, $this->getSimpleOperators(['::']))) {
972 $query = $query->where($property, $operator, $searchTerm);
973 }
974 }
975
976 private function searchFromString(Builder &$query, array $filterItem)
977 {
978 $property = $filterItem['property'];
979 $operator = $filterItem['operator'];
980 $searchTerm = $filterItem['value'];
981
982 $methodName = 'modify' . Str::studly($property . '_value');
983
984 if (in_array($property, $this->centColumns())) {
985 $searchTerm = Helper::toCent($searchTerm);
986 }
987 if (method_exists($this, $methodName)) {
988 $searchTerm = $this->{$methodName}($searchTerm, $filterItem, $query);
989
990 if ($searchTerm === null) {
991 return;
992 }
993 }
994
995
996 if (in_array($operator, ['contains', 'in'])) {
997 $query = $query->where($property, 'LIKE', '%' . $searchTerm . '%');
998 } else if (in_array($operator, ['not_contains', 'not_in'])) {
999 $query = $query->where($property, 'NOT LIKE', '%' . $searchTerm . '%');
1000 } else if ($operator === 'is_null') {
1001 $query = $query->where(function (Builder $q) use ($property) {
1002 return $q->whereNull($property)
1003 ->orWhere($property, '=', '');
1004 });
1005 } else if ($operator === 'not_null') {
1006 $query = $query->where(function (Builder $q) use ($property) {
1007 return $q->whereNotNull($property)
1008 ->orWhere($property, '!=', '');
1009 });
1010 } else if (in_array($operator, $this->getSimpleOperators(['::']))) {
1011 $query = $query->where($property, $operator, $searchTerm);
1012 } else {
1013 $query = $query->where($property, $operator, $searchTerm);
1014 }
1015 }
1016
1017 /**
1018 * Apply the simple Filters.
1019 *
1020 * @return void
1021 */
1022
1023 public abstract function applySimpleFilter(?string $search = null): void;
1024
1025 public abstract function applyActiveViewFilter(?string $activeView = null): void;
1026
1027 /**
1028 * Return the maps of [table-column, tabs-name]
1029 *
1030 * @return array
1031 */
1032 public abstract function tabsMap(): array;
1033
1034 /**
1035 * Return Model name
1036 *
1037 * @return string
1038 */
1039 public abstract function getModel(): string;
1040
1041
1042 private function getDbColumns(): array
1043 {
1044 $modelClass = $this->getModel();
1045 $model = new $modelClass;
1046 // Get fillable columns and add primary key
1047 return array_merge($model->getFillable(), [$this->primaryKey]);
1048 }
1049
1050
1051 /**
1052 * Return the columns that are searchable
1053 *
1054 * @return array
1055 */
1056 public static function getSearchableFields(): array
1057 {
1058 $self = (new static);
1059 $columns = $self->getDbColumns();
1060 // Create case-insensitive lookup array
1061 $searchableColumns = [];
1062 foreach ($columns as $column) {
1063 $searchableColumns[strtolower($column)] = $column;
1064 $searchableColumns[$column] = $column;
1065 }
1066
1067 return $searchableColumns;
1068 }
1069
1070 /**
1071 * Return the operators that are supported for simple filters
1072 *
1073 * @return array
1074 */
1075 public function getSimpleOperators($except = []): array
1076 {
1077 return Arr::except(
1078 ['=', '!=', '>', '<', '>=', '<=', '::'],
1079 $except
1080 );
1081 }
1082
1083 public function applySimpleOperatorFilter(?string $search = null): bool
1084 {
1085 $operators = $this->getSimpleOperators();
1086
1087 // check if search has an operator with regexp
1088 $operatorPattern = '/\s*(' . implode('|', $operators) . ')\s*/';
1089
1090 $search = trim($search ?? $this->search);
1091 if (preg_match($operatorPattern, $search, $matches)) {
1092 $operator = $matches[1];
1093 $searchParts = explode($operator, $search);
1094
1095 if (count($searchParts) >= 2) {
1096 $column = trim($searchParts[0]);
1097 $value = trim($searchParts[1]);
1098
1099 // Check if the column is valid
1100 $validColumns = static::getSearchableFields();
1101 $column = strtolower($column);
1102 if ($columnSchema = Arr::get($validColumns, $column, null)) {
1103
1104 $type = Arr::get($columnSchema, 'type', 'string');
1105
1106 if ($type === 'custom') {
1107 $callback = $columnSchema['callback'];
1108 $callback($this->query, $value, $operator, $this);
1109 return true;
1110 }
1111 if (is_array($columnSchema)) {
1112 $column = $columnSchema['column'];
1113 } else {
1114 $column = $columnSchema;
1115 }
1116
1117 if ($operator == '::') {
1118 $values = explode('-', $value);
1119 if (count($values) == 2) {
1120 if (in_array($column, $this->centColumns())) {
1121 $values[0] = Helper::toCent($values[0]);
1122 $values[1] = Helper::toCent($values[1]);
1123 } else if (in_array($column, $this->dateColumns())) {
1124 $values[0] = DateTime::anyTimeToGmt($values[0], $this->userTz)->format('Y-m-d H:i:s');
1125 $values[1] = DateTime::anyTimeToGmt($values[1], $this->userTz)->format('Y-m-d H:i:s');
1126 }
1127 $this->query->whereBetween($column, $values);
1128 return true;
1129 }
1130 }
1131
1132 if (in_array($column, $this->centColumns())) {
1133 $value = Helper::toCent($value);
1134 } else if (in_array($column, $this->dateColumns())) {
1135 $value = DateTime::anyTimeToGmt($value, $this->userTz)->format('Y-m-d H:i:s');
1136 }
1137
1138 if ($this->shouldApplyMatchFilter($operator)) {
1139 $this->applyMatchFilter($this->query, $column, $value, $operator);
1140 } else {
1141 $this->query->where($column, $operator, $value);
1142 }
1143
1144
1145 return true;
1146 }
1147 }
1148 }
1149
1150 return false;
1151 }
1152
1153 public function shouldApplyMatchFilter(string $operator): bool
1154 {
1155 $operator = trim($operator);
1156 return $operator === '=' || $operator==='!=';
1157 }
1158
1159 public function applyMatchFilter(Builder $query, string $column, $value, string $operator = '='): Builder
1160 {
1161 $value = sanitize_text_field($value);
1162
1163 $hasStartWildcard = Str::startsWith($value, '*');
1164 $hasEndWildcard = Str::endsWith($value, '*');
1165
1166 // No wildcards → strict equality / inequality
1167 if (!$hasStartWildcard && !$hasEndWildcard) {
1168 return $query->where($column, $operator === '!=' ? '!=' : '=', $value);
1169 }
1170
1171 // Remove * wildcards
1172 $likeValue = $value;
1173
1174 if ($hasStartWildcard) {
1175 $likeValue = ltrim($likeValue, '*');
1176 }
1177
1178 if ($hasEndWildcard) {
1179 $likeValue = rtrim($likeValue, '*');
1180 }
1181
1182 // Convert to SQL LIKE pattern
1183 if ($hasStartWildcard && $hasEndWildcard) {
1184 $likeValue = '%' . $likeValue . '%'; // *value*
1185 } elseif ($hasStartWildcard) {
1186 $likeValue = '%' . $likeValue; // *value
1187 } else {
1188 $likeValue = $likeValue . '%'; // value*
1189 }
1190
1191 $sqlOperator = $operator === '!=' ? 'NOT LIKE' : 'LIKE';
1192
1193 return $query->where($column, $sqlOperator, $likeValue);
1194 }
1195
1196
1197
1198
1199 public function centColumns(): array
1200 {
1201 return [];
1202 }
1203
1204 public function dateColumns(): array
1205 {
1206 return ['updated_at', 'created_at'];
1207 }
1208
1209 /**
1210 * Return the name of filter
1211 *
1212 * @return string
1213 */
1214
1215 public static abstract function getFilterName(): string;
1216
1217
1218 /**
1219 * Return the maps of [key, key-name]
1220 * It's used for parse the data
1221 *
1222 * @return array
1223 */
1224 public static function parseableKeyMap(): array
1225 {
1226 return [
1227 'filter_type' => 'filter_type',
1228 'with' => 'with',
1229 'search' => 'search',
1230 'limit' => 'limit',
1231 'offset' => 'offset',
1232 'active_view' => 'active_view',
1233 'sort_by' => 'sort_by',
1234 'sort_type' => 'sort_type',
1235 'advanced_filters' => 'advanced_filters',
1236 'per_page' => 'per_page',
1237 'include_ids' => 'include_ids',
1238 'scopes' => 'scopes',
1239 'user_tz' => 'user_tz',
1240 'select' => 'select',
1241 'page' => 'page'
1242 ];
1243 }
1244
1245 /**
1246 * Return the names of allowed keys preserved in data
1247 *
1248 * @return array
1249 */
1250
1251 public static function parseableKeys(): array
1252 {
1253 return static::parseableKeyMap();
1254 }
1255
1256 /**
1257 * Return the names of the kye, which should be used to parse the value
1258 *
1259 * @param string $key // Name of the Key
1260 * @return string
1261 */
1262 private function getParsableKey(string $key): string
1263 {
1264 return Arr::has(static::parseableKeyMap(), $key) ?
1265 static::parseableKeyMap()[$key] : $key;
1266
1267 }
1268
1269 public function query()
1270 {
1271 return $this->query;
1272 }
1273
1274 public function setQuery(Builder $query): BaseFilter
1275 {
1276 $this->query = $query;
1277 return $this;
1278 }
1279
1280 public function customQuery()
1281 {
1282 return $this->query;
1283 }
1284
1285 public function get()
1286 {
1287 //Apply limit and offset only when using get
1288 //While using pagination, limit and offset are auto calculated
1289
1290 $this->buildQuery();
1291
1292 if (!empty($this->limit)) {
1293 $this->applyLimit();
1294 }
1295
1296 if (!empty($this->offset)) {
1297 $this->applyOffset();
1298 }
1299 $filter = $this->getFilterName();
1300 $this->query = apply_filters("fluent_cart/{$filter}_list_filter_query", $this->query, $this->toArray());
1301 return $this->query->get();
1302 }
1303
1304 public function paginate($perPage = null): LengthAwarePaginator
1305 {
1306 $this->buildQuery();
1307 $perPage = empty($perPage) ? $this->perPage : $perPage;
1308 $filter = $this->getFilterName();
1309 $this->query = apply_filters("fluent_cart/{$filter}_list_filter_query", $this->query, $this->toArray());
1310 return $this->query->paginate(
1311 $perPage,
1312 ['*'],
1313 'page',
1314 $this->page
1315 );
1316 }
1317
1318 public static function fromRequest(Request $request): BaseFilter
1319 {
1320 return new static($request->only(
1321 static::parseableKeys()
1322 ));
1323 }
1324
1325 public static function make(array $args): BaseFilter
1326 {
1327 return new static($args);
1328 }
1329
1330 public static function getAdvanceFilterOptions(): ?array
1331 {
1332 $filterName = static::getFilterName();
1333 $options = apply_filters("fluent_cart/{$filterName}_filter_options", static::advanceFilterOptions());
1334 return is_array($options) ? array_values($options) : null;
1335 }
1336
1337 private static function advanceFilterOptions(): ?array
1338 {
1339 return null;
1340 }
1341
1342 public static function getCustomColumns()
1343 {
1344 // $data = [
1345 // [
1346 // 'title' => 'Product One',
1347 // 'meta' => [
1348 // 'max_price' => '100',
1349 // 'min_price' => '80',
1350 // ]
1351 // ],
1352 // [
1353 // 'title' => 'Product Two',
1354 // 'meta' => [
1355 // 'max_price' => '150',
1356 // 'min_price' => '90',
1357 // ]
1358 // ]
1359 // ];
1360 // $example_columns = [
1361 // 'title' => [
1362 // 'label' => 'Title',
1363 // 'accessor' => 'title',
1364 // 'as_link' => false,
1365 // ],
1366 // 'max_price' => [
1367 // 'label' => 'Max Price',
1368 // 'accessor' => 'meta.max_price'
1369 // ],
1370 // 'min_price' => [
1371 // 'label' => 'Min Price',
1372 // 'accessor' => 'meta.min_price'
1373 // ]
1374 // ];
1375 $filterName = static::getFilterName();
1376 return apply_filters("fluent_cart/{$filterName}_table_columns", []);
1377 }
1378
1379 public static function getTableFilterOptions(): array
1380 {
1381 return [
1382 'advance' => static::getAdvanceFilterOptions(),
1383 'guide' => static::getSearchableFields(),
1384 'columns' => static::getCustomColumns(),
1385 ];
1386 }
1387
1388 public function toArray(): array
1389 {
1390 return [
1391 'select' => $this->select,
1392 'filterType' => $this->filterType,
1393 'search' => $this->search,
1394 'with' => $this->with,
1395 'scopes' => $this->scopes,
1396 'limit' => $this->limit,
1397 'offset' => $this->offset,
1398 'userTz' => $this->userTz,
1399 'includeIds' => $this->includeIds,
1400 'activeView' => $this->activeView,
1401 'sortBy' => $this->sortBy,
1402 'sortType' => $this->sortType,
1403 'searchGroups' => $this->searchGroups,
1404 'perPage' => $this->perPage,
1405 ];
1406 }
1407
1408 }
1409