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

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

1,577 lines 49.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\App\Services\Permission\PermissionManager;
12 use FluentCart\Framework\Database\Orm\Builder;
13 use FluentCart\Framework\Database\Query\Builder as QueryBuilder;
14 use FluentCart\Framework\Http\Request\Request;
15 use FluentCart\Framework\Pagination\LengthAwarePaginator;
16 use FluentCart\Framework\Support\Arr;
17 use FluentCart\Framework\Support\Str;
18 use InvalidArgumentException;
19
20 /**
21 * Class BaseFilter
22 *
23 * Base class for filtering and querying models with simple and advanced filters.
24 *
25 * @package FluentCart\App\Services\Filter
26 */
27 abstract class BaseFilter
28 {
29 use HandleRelationalFilter, HandleDateFilter;
30
31 /**
32 * Determines if the filter type is simple or advanced.
33 *
34 * @var string
35 */
36 public string $filterType = 'simple';
37
38 /**
39 * default primary key of the table
40 *
41 * @var ?string
42 */
43 public ?string $primaryKey = null;
44
45 /**
46 * Default column used for sorting.
47 *
48 * @var string
49 */
50 public string $defaultSortBy = 'id';
51
52 /**
53 * Column used for sorting, dynamically set from arguments.
54 *
55 * @var string
56 */
57 public string $sortBy = '';
58
59 /**
60 * Default sorting order.
61 *
62 * @var string
63 */
64 public string $defaultSortType = 'desc';
65
66 /**
67 * Sorting order (asc/desc).
68 *
69 * @var string
70 */
71 public string $sortType = '';
72
73 /**
74 * Ids that must be loaded.
75 *
76 * @var array
77 */
78 public array $includeIds = [];
79
80 /**
81 * Relations to be loaded with the query.
82 *
83 * @var array
84 */
85 public array $with = [];
86
87 /**
88 * Select fields for the query.
89 *
90 * @var ?array
91 */
92 public array $select = [];
93
94
95 /**
96 * Model scopes.
97 *
98 * @var ?array
99 */
100 public ?array $scopes = [];
101
102 /**
103 * Search query string for simple filtering.
104 *
105 * @var string
106 */
107 public string $search = '';
108
109 /**
110 * Limit of records.
111 *
112 * @var ?int
113 */
114 public ?int $limit = null;
115
116
117 /**
118 * Number of records to retrieve per page.
119 *
120 * @var int
121 */
122 public int $perPage = 10;
123
124
125 /**
126 * Current page number
127 *
128 * @var ?int
129 */
130 public ?int $page = null;
131
132 /**
133 * The offset for paginated results.
134 *
135 * @var ?int
136 */
137 public ?int $offset = null;
138
139 /**
140 * The active view/tab to be filtered.
141 *
142 * @var string|null
143 */
144 public ?string $activeView = '';
145
146 /**
147 * HTTP request instance.
148 *
149 * @var Request
150 */
151 protected Request $request;
152
153 /**
154 * Query builder instance used for filtering and retrieving data.
155 *
156 * @var Builder|LengthAwarePaginator
157 */
158 public $query;
159
160 /**
161 * Additional filtering arguments.
162 *
163 * @var array
164 */
165 public array $args = [];
166
167 /**
168 * Parsed search groups for advanced filtering.
169 *
170 * @var array
171 */
172 public array $searchGroups = [];
173
174
175 /**
176 * User timezone
177 *
178 * @var ?string
179 */
180 public ?string $userTz = null;
181
182 /**
183 * The resolved SavedView model when active_view is a saved view slug.
184 *
185 * @var SavedView|null
186 */
187 protected $activeSavedView = null;
188
189
190 /**
191 * BaseFilter constructor.
192 *
193 * @param array $args Filtering arguments.
194 */
195 public function __construct(array $args = [])
196 {
197 $this->validateModel();
198 $this->parseArgs($args);
199 $this->query = $this->customQuery();
200 }
201
202 /**
203 * Validates the model instance.
204 *
205 * @return void
206 * @throws InvalidArgumentException
207 */
208 protected function validateModel()
209 {
210 $modelClass = $this->getModel();
211 $model = new $modelClass;
212 if (!$model instanceof Model) {
213 throw new InvalidArgumentException('Model class must be an instance of Model');
214 }
215
216 $this->primaryKey = $model->getKeyName();
217 $this->query = $model->newQuery();
218 }
219
220 /**
221 * Parses filtering arguments.
222 *
223 * @param array $args Filtering arguments.
224 * @return void
225 */
226 protected function parseArgs(array $args)
227 {
228
229 $this->args = $args;
230 $this->select = $this->parseSelect();
231 $this->filterType = Arr::get($args, $this->getParsableKey('filter_type'), $this->filterType);
232 $this->search = Arr::get($args, $this->getParsableKey('search'), $this->search);
233 $this->with = Arr::get($args, $this->getParsableKey('with'), $this->with);
234 $this->scopes = Arr::get($args, $this->getParsableKey('scopes'), $this->scopes);
235 $this->limit = Arr::get($args, $this->getParsableKey('limit'), $this->limit);
236 $this->offset = Arr::get($args, $this->getParsableKey('offset'), $this->offset);
237 $this->userTz = Arr::get($args, $this->getParsableKey('user_tz'), $this->userTz);
238 $this->includeIds = $this->parseIncludeIds();
239 $this->activeView = $this->parseAcceptedView();
240 $this->sortBy = $this->parseSortBy();
241 $this->sortType = $this->parseSortType();
242 $this->searchGroups = $this->parseSearchGroups();
243 $this->perPage = $this->parsePerPage();
244 $this->page = $this->parsePageNumber();
245 }
246
247 protected function parseSelect(): array
248 {
249 $select = Arr::get($this->args, $this->getParsableKey('select'));
250
251 if (!$select) {
252 return [];
253 }
254
255 if (is_string($select)) {
256 $select = explode(',', $select);
257 }
258
259 $parsedSelect = [];
260 foreach ($select as $selectItem) {
261 if (is_string($selectItem)) {
262 $parsedSelect[] = sanitize_text_field($selectItem);
263 }
264 }
265
266 return $parsedSelect;
267 }
268
269
270 protected function parseIncludeIds(): array
271 {
272 $includedIds = Arr::get($this->args, $this->getParsableKey('include_ids'), []);
273 if (is_string($includedIds)) {
274 $includedIds = explode(',', $includedIds);
275 }
276 return empty($includedIds) ? [] : Arr::wrap($includedIds);
277 }
278
279 protected function parsePerPage()
280 {
281 $perPage = Arr::get($this->args, $this->getParsableKey('per_page'));
282 if (is_numeric($perPage) && $perPage > 0 && $perPage < 200) {
283 return $perPage;
284 }
285 return $this->perPage;
286 }
287
288 protected function parsePageNumber(): ?int
289 {
290 $page = Arr::get($this->args, $this->getParsableKey('page'), $this->page);
291 return is_numeric($page) ? (int)$page : null;
292 }
293
294 /**
295 * Parses and validates the sorting column.
296 *
297 * @return string
298 */
299 protected function parseSortBy(): string
300 {
301 $sortBy = Arr::get($this->args, $this->getParsableKey('sort_by'));
302
303 if (empty($sortBy)) {
304 return $this->defaultSortBy;
305 }
306
307 // Declared sort options are an allow-list written in PHP (the filter
308 // class + the {filterName}_table_sorts hook), so they are trusted the
309 // same way $fillable is — that is what lets a filter or an add-on offer
310 // a sort that is not a fillable column.
311 if (array_key_exists($sortBy, static::getSortableColumns())) {
312 return $sortBy;
313 }
314
315 /**
316 * @var Model $modelObject
317 */
318 $modelClass = $this->getModel();
319 $modelObject = new $modelClass;
320
321 return in_array($sortBy, $modelObject->getFillable()) ? $sortBy : $this->defaultSortBy;
322 }
323
324 /**
325 * Parses and validates the sorting order.
326 *
327 * @return string
328 */
329 protected function parseSortType(): string
330 {
331 $sortType = strtolower((string)Arr::get($this->args, $this->getParsableKey('sort_type'), ''));
332
333 return in_array($sortType, ['desc', 'asc']) ? $sortType : $this->defaultSortType;
334 }
335
336 /**
337 * Parses and validates the accepted view.
338 * First checks static tabs; if not found, fires a filter so Pro can resolve
339 * the slug to a saved view (returns an object/array with query_params).
340 *
341 * @return string|null
342 */
343 protected function parseAcceptedView(): ?string
344 {
345 $activeView = Arr::get($this->args, $this->getParsableKey('active_view'), $this->activeView);
346
347 if (empty($activeView)) {
348 return null;
349 }
350
351 // Static tab takes priority
352 if (Arr::has($this->tabsMap(), $activeView)) {
353 return $activeView;
354 }
355
356 // Ask Pro to resolve the slug for the current filter only.
357 $resolvedView = apply_filters('fluent_cart/filter_resolve_saved_view', null, [
358 'slug' => $activeView,
359 'filter_name' => static::getFilterName(),
360 'user_id' => get_current_user_id(),
361 ]);
362
363 if (is_array($resolvedView) || is_object($resolvedView)) {
364 $this->activeSavedView = $resolvedView;
365 return null;
366 }
367
368 // Backward compatibility for older Pro versions that only inject the admin table config blob.
369 $tableConfig = apply_filters('fluent_cart/admin_table_saved_views', [], [
370 'filterOptions' => []
371 ]);
372 foreach ($tableConfig as $entry) {
373 $savedViews = isset($entry['saved_views']) ? $entry['saved_views'] : [];
374 foreach ($savedViews as $view) {
375 if (Arr::get($view, 'slug') === $activeView) {
376 $this->activeSavedView = $view;
377 break 2;
378 }
379 }
380 }
381
382 return null;
383 }
384
385 /**
386 * Parses advanced search filters.
387 *
388 * @return array
389 */
390 protected function parseSearchGroups(?string $json = null): array
391 {
392 if ($json === null) {
393 $json = Arr::get($this->args, $this->getParsableKey('advanced_filters'), '[]');
394 }
395
396 $filters = [];
397
398 try {
399 $filters = json_decode($json, true);
400 } catch (\Exception $exception) {
401 // Ignore exception, return empty filters
402 }
403
404 if (empty($filters)) {
405 return [];
406 }
407
408 $groups = [];
409
410 foreach ($filters as $filterGroup) {
411 $group = [];
412 foreach ($filterGroup as $filterItem) {
413 if (count($filterItem['source']) != 2 || empty($filterItem['source'][0]) || empty($filterItem['source'][1]) || empty($filterItem['operator'])) {
414 continue;
415 }
416 $provider = $filterItem['source'][0];
417
418 if (!isset($group[$provider])) {
419 $group[$provider] = [];
420 }
421
422 $property = $filterItem['source'][1];
423
424 $filterData = [
425 'property' => $property,
426 'operator' => Arr::get($filterItem, 'operator'),
427 'value' => Arr::get($filterItem, 'value'),
428 'filter_type' => Arr::get($filterItem, 'filter_type'),
429 ];
430
431 if (Arr::get($filterData, 'filter_type') === 'relation') {
432 $filterData['relation'] = Arr::get($filterItem, 'relation', $property);
433 $filterData['column'] = Arr::get($filterItem, 'column', 'id');
434 }
435
436 $group[$provider][] = $filterData;
437
438
439 }
440
441 if ($group) {
442 $groups[] = $group;
443 }
444 }
445
446 return $groups;
447 }
448
449 /**
450 * Sets the HTTP request instance.
451 *
452 * @param Request $request
453 * @return $this
454 */
455 public function setRequest(Request $request): BaseFilter
456 {
457 $this->request = $request;
458 return $this;
459 }
460
461 /**
462 * Builds the query based on filters.
463 *
464 * @return Builder
465 */
466 public function buildQuery(): Builder
467 {
468 $this->buildCommonQuery();
469 $this->applyCurrentFilterLayer();
470
471 if ($this->activeSavedView) {
472 $this->applySavedViewFilter();
473 }
474
475 return $this->query;
476 }
477
478 protected function applyCurrentFilterLayer(): void
479 {
480 if ($this->filterType == 'simple') {
481 $this->applyActiveViewFilter();
482 $this->applySimpleFilter();
483 } else if ($this->filterType == 'advanced') {
484 $this->applyAdvancedFilter();
485 }
486 }
487
488 protected function applySavedViewFilter(?array $params = null): void
489 {
490 $params = $params ?? $this->getSavedViewParams();
491
492 if (empty($params)) {
493 return;
494 }
495
496 $this->query->where(function ($savedQuery) use ($params) {
497 $originalQuery = $this->query;
498 $this->query = $savedQuery;
499
500 $savedFilterType = Arr::get($params, 'filter_type', 'simple');
501
502 if ($savedFilterType === 'advanced') {
503 $savedGroups = $this->getSavedViewSearchGroups($params);
504 if (!empty($savedGroups)) {
505 $this->applyAdvancedFilter($savedGroups);
506 }
507 } else {
508 $savedActiveView = Arr::get($params, 'active_view');
509 if ($savedActiveView && Arr::has($this->tabsMap(), $savedActiveView)) {
510 $this->applyActiveViewFilter($savedActiveView);
511 }
512
513 $savedSearch = Arr::get($params, 'search', '');
514 if (!empty($savedSearch)) {
515 $this->applySimpleFilter($savedSearch);
516 }
517 }
518
519 $this->query = $originalQuery;
520 });
521 }
522
523 protected function getSavedViewParams(): array
524 {
525 $savedView = $this->activeSavedView;
526
527 return is_array($savedView)
528 ? Arr::get($savedView, 'query_params', [])
529 : (is_object($savedView) ? $savedView->query_params : []);
530 }
531
532 protected function getSavedViewSearchGroups(array $params): array
533 {
534 $json = Arr::get($params, 'advanced_filters', '[]');
535 if (is_array($json)) {
536 $json = wp_json_encode($json);
537 }
538
539 return $this->parseSearchGroups($json);
540 }
541
542 /**
543 * Builds the common query that should be applied in every query.
544 *
545 * @return void
546 */
547
548 protected function buildCommonQuery()
549 {
550 $this->applySelect();
551 $this->applyWith();
552 $this->applyScopes();
553
554 if (count($this->includeIds) > 0) {
555 $this->applyMustLoadIds();
556 }
557 $this->applySort();
558 }
559
560
561 public function applySelect()
562 {
563 if (empty($this->select)) {
564 return;
565 }
566 $this->query->select($this->select);
567 }
568
569 protected function getPrimaryKey(): string
570 {
571 return $this->primaryKey;
572 }
573
574 protected function applyMustLoadIds()
575 {
576
577 if ($this->search === '') {
578 $this->query = $this->query->whereIn(
579 $this->getPrimaryKey(),
580 $this->includeIds
581 )->orWhereNotNull($this->getPrimaryKey());
582 } else {
583 $this->query = $this->query->orWhereIn(
584 $this->getPrimaryKey(),
585 $this->includeIds
586 );
587 }
588
589 }
590
591 protected function applyLimit()
592 {
593 $this->query = $this->query->limit($this->limit);
594 }
595
596 protected function applyOffset()
597 {
598 $this->query = $this->query->offset($this->offset);
599 }
600
601 protected function applySort()
602 {
603 $column = Arr::get(static::getSortableColumns(), $this->sortBy . '.column');
604
605 // A declared option may hand over its own ordering — that is how a sort
606 // that is not a plain column on this table (an aggregate, a joined
607 // relation) gets applied.
608 if (is_callable($column)) {
609 $sorted = call_user_func($column, $this->query, $this->sortType);
610
611 // Only a builder replaces the query. A callback that orders in place
612 // and returns nothing — or returns something else entirely — must not
613 // be able to swap the query out from under the rest of the filter.
614 if ($sorted instanceof Builder || $sorted instanceof QueryBuilder) {
615 $this->query = $sorted;
616 }
617
618 return;
619 }
620
621 if (!is_string($column) || $column === '') {
622 $column = $this->sortBy;
623 }
624
625 $this->query = $this->query->orderBy($column, $this->sortType);
626 }
627
628 /**
629 * What the `with` request parameter may load.
630 *
631 * DENY BY DEFAULT — the base returns an empty map, so a filter that does not
632 * override this loads nothing at all. That matters because the ORM resolves
633 * a relation by literally calling that method on the model
634 * (`Builder::getRelation()`), catching only BadMethodCallException. A method
635 * that exists but is not a relation still RUNS: before this allow-list,
636 * `with[]=recountTotalPaidAndRefund` inserted a phantom refunded order row.
637 *
638 * ## The entry form — one form, no others
639 *
640 * Every entry is a LITERAL request key mapped to a CALLABLE. Nothing else is
641 * an entry: a non-callable value is refused. The key is never decomposed,
642 * pattern-matched or prefix-parsed, so what the client sends is either a key
643 * in this map or it is dropped.
644 *
645 * protected function allowedWiths(): array
646 * {
647 * return [
648 * 'admin_product_list' => [$this, 'adminProductList'],
649 * ];
650 * }
651 *
652 * protected function adminProductList($query)
653 * {
654 * if (!$this->userCanAny('products/view')) {
655 * return false; // contributes nothing
656 * }
657 *
658 * return $query->with(['detail' => function ($q) {
659 * $q->select(['id', 'post_id', 'featured_media']);
660 * }]);
661 * }
662 *
663 * ## Callback contract
664 *
665 * The callback receives the query as built so far — every entry already
666 * adopted is on it — checks its own permission, applies its own eager load
667 * (`with()`, `withCount()`, whatever it needs), and returns the query. It
668 * runs EXACTLY ONCE. The base never learns a relation name; the callback owns
669 * the whole path, the selects and the gate.
670 *
671 * return false (or anything that is not a Builder) → contributes nothing
672 * return the Builder → the base adopts it
673 *
674 * A count is not a special case: it is a callback that calls `withCount()`.
675 *
676 * ## Nested paths
677 *
678 * Nesting is not a special case either: name the full path (`'a.b'`) inside
679 * the callback. Two consequences, both of them sharp:
680 *
681 * 1. `with('a.b')` auto-injects the parent `a` with an EMPTY closure, so a
682 * nested callback must repeat whatever GATE the parent carries — the
683 * nested path is otherwise a way around it.
684 * 2. That injected empty closure also overwrites a CONSTRAINT an earlier
685 * entry put on `a`, because each callback issues its own `with()` call and
686 * Builder::with() array_merges into $eagerLoad (addNestedWiths() only
687 * protects entries inside a single call). So a nested callback must also
688 * re-state the parent's constraint in its own `with()` call:
689 *
690 * return $query->with([
691 * 'a' => function ($q) { $q->select([...]); },
692 * 'a.b' => function ($q) { $q->select([...]); },
693 * ]);
694 *
695 * ## Why the builder you are handed is safe to mutate
696 *
697 * `$query->with([...])` mutates in place and returns the same instance, so a
698 * callback that mutated and then refused would already have changed the
699 * query. The base therefore invokes every callback against `clone
700 * $this->query` and adopts the result only when a Builder comes back:
701 * Orm\Builder::__clone() also clones the underlying Query\Builder, and both
702 * hold their wheres, bindings, columns and eager loads in plain arrays, which
703 * PHP copies by value. A refusing callback is structurally incapable of
704 * touching the live query.
705 *
706 * ## Escape valve
707 *
708 * The gate lives in applyWith(), so it covers every instance including a
709 * `with` assigned after construction, and it reads the CURRENT user — a cron
710 * or WP-CLI caller loses permission-gated entries. The
711 * `fluent_cart/{filter}_allowed_withs` filter is how an add-on or a
712 * privileged background job adds its own entry.
713 *
714 * @return array<string, callable>
715 */
716 protected function allowedWiths(): array
717 {
718 return [];
719 }
720
721 /**
722 * What the `scopes` request parameter may apply.
723 *
724 * DENY BY DEFAULT. The old applyScopes() invoked whatever method name the
725 * request supplied directly on the query builder, before any WHERE clause
726 * existed — `scopes[]=delete` emptied the table, `scopes[]=truncate` wiped it
727 * and the array form mass-updated every row.
728 *
729 * Same single entry form as allowedWiths(): literal key => callable. This is
730 * a separate map only because the request delivers it on a different
731 * parameter; the resolution and the safety properties are identical.
732 *
733 * Scope callbacks are invoked as `($query, $args)`, where `$args` is the raw
734 * remainder of an array-form request entry (`scopes[]=['x','y']` → `['y']`).
735 * `$args` is UNVALIDATED client input. Declare the parameter only if you
736 * intend to validate it; a callback declared `($query)` simply never sees it,
737 * which is the default refusal of request-supplied arguments.
738 *
739 * A scope name is not reachable unless a callback routes to it, and routing
740 * through `Builder::scopes()` adds a second structural gate: it resolves via
741 * Model::callNamedScope(), which prefixes with "scope", so only a real
742 * `scopeX()` method exists at the end of that path.
743 *
744 * @return array<string, callable>
745 */
746 protected function allowedScopes(): array
747 {
748 return [];
749 }
750
751 protected function applyWith()
752 {
753 $filterName = static::getFilterName();
754
755 $withMap = apply_filters(
756 "fluent_cart/{$filterName}_allowed_withs", $this->allowedWiths(), ['filter' => $this]
757 );
758
759 foreach (Arr::wrap($this->with) as $requestKey) {
760 // Dropping non-strings also kills the array-nested with[parent][]=child
761 // form, which the ORM would otherwise read as the path parent.child.
762 if (!is_string($requestKey) || !array_key_exists($requestKey, $withMap)) {
763 continue;
764 }
765
766 $this->adoptAllowEntry($withMap[$requestKey]);
767 }
768 }
769
770 protected function applyScopes()
771 {
772 $filterName = static::getFilterName();
773
774 $scopeMap = apply_filters(
775 "fluent_cart/{$filterName}_allowed_scopes", $this->allowedScopes(), ['filter' => $this]
776 );
777
778 foreach (Arr::wrap($this->scopes) as $requested) {
779 $args = [];
780
781 if (is_array($requested)) {
782 $requestKey = isset($requested[0]) ? $requested[0] : null;
783 $args = array_slice($requested, 1);
784 } else {
785 $requestKey = $requested;
786 }
787
788 if (!is_string($requestKey) || !array_key_exists($requestKey, $scopeMap)) {
789 continue;
790 }
791
792 $this->adoptAllowEntry($scopeMap[$requestKey], $args);
793 }
794 }
795
796 /**
797 * Invoke one allow-map entry and adopt what it hands back.
798 *
799 * The callback gets a CLONE of the live query, so a callback that mutates
800 * and then refuses cannot leave its mutation behind. Only a Builder is
801 * adopted; every other return value — false, null, a stray string — means the
802 * entry contributes nothing.
803 *
804 * @param mixed $entry Anything non-callable is refused.
805 * @param array $args Unvalidated client arguments; always empty for a `with`.
806 * @return void
807 */
808 protected function adoptAllowEntry($entry, array $args = []): void
809 {
810 if (!is_callable($entry)) {
811 return;
812 }
813
814 $result = $entry(clone $this->query, $args);
815
816 if ($result instanceof Builder) {
817 $this->query = $result;
818 }
819 }
820
821 /**
822 * @param string|array $permission
823 * @return bool
824 */
825 protected function userCan($permission): bool
826 {
827 return PermissionManager::hasPermission((array)$permission);
828 }
829
830 /**
831 * @param string|array $permission
832 * @return bool
833 */
834 protected function userCanAny($permission): bool
835 {
836 return PermissionManager::hasAnyPermission((array)$permission);
837 }
838 /**
839 * Applies advanced filters to the query.
840 *
841 * @return void
842 */
843 protected function applyAdvancedFilter(?array $searchGroups = null): void
844 {
845
846 if (!App::isProActive()) {
847 return;
848 }
849
850 $filtersGroups = $searchGroups ?? $this->searchGroups;
851 if (empty($filtersGroups)) {
852 return;
853 }
854
855
856 $filterName = static::getFilterName();
857 $allFilterOptions = apply_filters("fluent_cart/{$filterName}_filter_options", static::advanceFilterOptions());
858 foreach ($filtersGroups as $groupIndex => $group) {
859
860
861 $method = $groupIndex == 0 ? 'where' : 'orWhere';
862
863 $this->query->{$method}(function ($query) use ($group, $filterName, $allFilterOptions) {
864 foreach ($group as $providerName => $items) {
865 $items = $this->mergeRelationFilters($items);
866 foreach ($items as $item) {
867 if ($item['filter_type'] === 'custom') {
868 $filters = $allFilterOptions;
869 $filter = Arr::get($filters, $providerName . '.children', null);
870 $property = Arr::get($item, 'property');
871 $isCallbackFound = false;
872 if (is_array($filter)) {
873 foreach ($filter as $filterItem) {
874 if ($filterItem['value'] === $item['property']) {
875 $callback = Arr::get($filterItem, 'callback', null);
876 if ($callback) {
877 $callback($query, $item);
878 $isCallbackFound = true;
879 }
880 break;
881 }
882 }
883 }
884
885 if ($isCallbackFound) {
886 continue;
887 }
888 do_action_ref_array("fluent_cart/{$filterName}_filter/{$providerName}/{$item['property']}", [&$query, $item]);
889 } else {
890 $this->handleAdvanceFilter($query, $item);
891 }
892
893 }
894 }
895 });
896 }
897 }
898
899 /**
900 * Merge relation filter items that target the same relation field with compatible operators.
901 * This prevents multiple whereHas() subqueries ANDed together for the same relation column,
902 * which would return no results (e.g., license status IN ('active') AND license status IN ('expired')).
903 */
904 private function mergeRelationFilters(array $items): array
905 {
906 $merged = [];
907 $relationGroups = [];
908
909 foreach ($items as $item) {
910 if (Arr::get($item, 'filter_type') !== 'relation') {
911 $merged[] = $item;
912 continue;
913 }
914
915 $value = Arr::get($item, 'value');
916 $operator = $item['operator'] ?? '';
917 $mergeableOperators = ['in', 'contains', 'not_in', 'not_contains'];
918
919 if (!in_array($operator, $mergeableOperators)) {
920 $merged[] = $item;
921 continue;
922 }
923
924 // Normalize string value to array for merging.
925 //
926 // Only for the membership operators. `contains`/`not_contains` on a
927 // text field are substring searches ("Includes gmail"), and wrapping
928 // one into an array sends it down the whereIn path below — an exact
929 // match, so the search silently returns nothing. Left as a string it
930 // is passed through unmerged and handleRelation answers it with a
931 // LIKE. Array values for those operators (ids from a
932 // remote_tree_select field such as Order Items) still merge, which is
933 // what this merge was written for.
934 if (is_string($value) && $value !== '' && in_array($operator, ['in', 'not_in'], true)) {
935 $value = [$value];
936 $item['value'] = $value;
937 }
938
939 if (!is_array($value) || empty($value)) {
940 $merged[] = $item;
941 continue;
942 }
943
944 $key = $item['property'] . ':' . $item['relation'] . ':' . $item['column'] . ':' . $operator;
945 if (!isset($relationGroups[$key])) {
946 $relationGroups[$key] = $item;
947 } else {
948 $relationGroups[$key]['value'] = array_values(
949 array_unique(array_merge($relationGroups[$key]['value'], $value))
950 );
951 }
952 }
953
954 return array_merge($merged, array_values($relationGroups));
955 }
956
957 private function handleAdvanceFilter($query, $filterItem)
958 {
959 if (Arr::get($filterItem, 'filter_type') === 'relation') {
960 $this->handleRelation($query, $filterItem);
961 } else if (Arr::get($filterItem, 'filter_type') === 'date') {
962 $this->handleDate($query, $filterItem);
963 } else {
964 $this->handleOperator($query, $filterItem);
965 }
966
967
968 //
969 }
970
971 private function handleOperator(Builder &$query, array $filterItem)
972 {
973
974 $searchTerm = $filterItem['value'];
975
976 if (is_array($searchTerm)) {
977 $this->searchFromArray($query, $filterItem);
978 } else {
979 $this->searchFromString($query, $filterItem);
980 }
981 }
982
983 private function searchFromArray(Builder &$query, array $filterItem)
984 {
985 $property = $filterItem['property'];
986 $operator = $this->resolveOperator($filterItem['operator']);
987 $searchTerm = $filterItem['value'];
988 $methodName = 'modify' . Str::studly($property . '_value');
989
990 if (in_array($property, $this->centColumns())) {
991 $searchTerm = array_map(function ($value) {
992 return Helper::toCent($value);
993 }, $searchTerm);
994 }
995 if (method_exists($this, $methodName)) {
996 $searchTerm = $this->{$methodName}($searchTerm, $filterItem, $query);
997 if ($searchTerm === null) {
998 return;
999 }
1000 }
1001
1002 if (in_array($operator, ['in', 'contains'])) {
1003 $query = $query->whereIn($property, $searchTerm);
1004 } else if (in_array($operator, ['not_in', 'not_contains'])) {
1005 $query = $query->whereNotIn($property, $searchTerm);
1006 } elseif (in_array($operator, ['in_all', 'not_in_all'])) {
1007 $condition = $operator === 'in_all' ? '=' : '!=';
1008 foreach ($searchTerm as $term) {
1009 $query = $query->where($property, $condition, $term);
1010 }
1011 } else if (in_array($operator, $this->getSimpleOperators(['::']))) {
1012 $query = $query->where($property, $operator, $searchTerm);
1013 }
1014 }
1015
1016 private function searchFromString(Builder &$query, array $filterItem)
1017 {
1018 $property = $filterItem['property'];
1019 $operator = $this->resolveOperator($filterItem['operator']);
1020 $searchTerm = $filterItem['value'];
1021
1022 $methodName = 'modify' . Str::studly($property . '_value');
1023
1024 if (in_array($property, $this->centColumns())) {
1025 $searchTerm = Helper::toCent($searchTerm);
1026 }
1027 if (method_exists($this, $methodName)) {
1028 $searchTerm = $this->{$methodName}($searchTerm, $filterItem, $query);
1029
1030 if ($searchTerm === null) {
1031 return;
1032 }
1033 }
1034
1035
1036 if (in_array($operator, ['contains', 'in'])) {
1037 $query = $query->where($property, 'LIKE', '%' . $searchTerm . '%');
1038 } else if (in_array($operator, ['not_contains', 'not_in'])) {
1039 $query = $query->where($property, 'NOT LIKE', '%' . $searchTerm . '%');
1040 } else if ($operator === 'is_null') {
1041 $query = $query->where(function (Builder $q) use ($property) {
1042 return $q->whereNull($property)
1043 ->orWhere($property, '=', '');
1044 });
1045 } else if ($operator === 'not_null') {
1046 $query = $query->where(function (Builder $q) use ($property) {
1047 return $q->whereNotNull($property)
1048 ->orWhere($property, '!=', '');
1049 });
1050 } else if (in_array($operator, $this->getSimpleOperators(['::']))) {
1051 $query = $query->where($property, $operator, $searchTerm);
1052 } else {
1053 $query = $query->where($property, $operator, $searchTerm);
1054 }
1055 }
1056
1057 /**
1058 * Apply the simple Filters.
1059 *
1060 * @return void
1061 */
1062
1063 public abstract function applySimpleFilter(?string $search = null): void;
1064
1065 public abstract function applyActiveViewFilter(?string $activeView = null): void;
1066
1067 /**
1068 * Return the maps of [table-column, tabs-name]
1069 *
1070 * @return array
1071 */
1072 public abstract function tabsMap(): array;
1073
1074 /**
1075 * Return Model name
1076 *
1077 * @return string
1078 */
1079 public abstract function getModel(): string;
1080
1081
1082 private function getDbColumns(): array
1083 {
1084 $modelClass = $this->getModel();
1085 $model = new $modelClass;
1086 // Get fillable columns and add primary key
1087 return array_merge($model->getFillable(), [$this->primaryKey]);
1088 }
1089
1090
1091 /**
1092 * Return the columns that are searchable
1093 *
1094 * @return array
1095 */
1096 public static function getSearchableFields(): array
1097 {
1098 $self = (new static);
1099 $columns = $self->getDbColumns();
1100 // Create case-insensitive lookup array
1101 $searchableColumns = [];
1102 foreach ($columns as $column) {
1103 $searchableColumns[strtolower($column)] = $column;
1104 $searchableColumns[$column] = $column;
1105 }
1106
1107 return $searchableColumns;
1108 }
1109
1110 /**
1111 * Return the operators that are supported for simple filters
1112 *
1113 * @return array
1114 */
1115 public function getSimpleOperators($except = []): array
1116 {
1117 return Arr::except(
1118 ['=', '!=', '>', '<', '>=', '<=', '::'],
1119 $except
1120 );
1121 }
1122
1123 /**
1124 * The operator vocabulary this engine resolves: the operator a request may
1125 * name => the operator actually applied to the query.
1126 *
1127 * Every entry core ships is an identity mapping, so translating through this
1128 * map changes nothing on its own. The indirection exists so that filter
1129 * options contributed from outside this class — an add-on's, or one of the
1130 * named operators the built-in options already advertise — can be taught to
1131 * the engine without editing it.
1132 *
1133 * Why it matters that an unknown operator never reaches the query builder:
1134 * Query\Builder::invalidOperator() silently rewrites one it does not
1135 * recognise into `=` and compares the column against the operator STRING, so
1136 * the filter does not error — it returns wrong rows.
1137 *
1138 * @return array<string, string>
1139 */
1140 public static function supportedOperators(): array
1141 {
1142 $operators = [
1143 '=' => '=',
1144 '!=' => '!=',
1145 '>' => '>',
1146 '<' => '<',
1147 '>=' => '>=',
1148 '<=' => '<=',
1149 '::' => '::',
1150 'in' => 'in',
1151 'not_in' => 'not_in',
1152 'in_all' => 'in_all',
1153 'not_in_all' => 'not_in_all',
1154 'contains' => 'contains',
1155 'not_contains' => 'not_contains',
1156 'is_null' => 'is_null',
1157 'not_null' => 'not_null',
1158 'before' => 'before',
1159 'after' => 'after',
1160 'date_equal' => 'date_equal',
1161 'between' => 'between',
1162 ];
1163
1164 // Listeners must declare add_filter(..., 10, 2) to receive the context.
1165 return apply_filters(
1166 'fluent_cart/filter/supported_operators',
1167 $operators,
1168 ['filter_name' => static::getFilterName()]
1169 );
1170 }
1171
1172 /**
1173 * Translate a requested operator into the one the engine applies.
1174 *
1175 * An operator with no entry is returned untouched — declining to resolve it
1176 * here keeps this behaviour-neutral for anything already in use.
1177 *
1178 * @param mixed $operator
1179 * @return mixed
1180 */
1181 protected function resolveOperator($operator)
1182 {
1183 if (!is_string($operator)) {
1184 return $operator;
1185 }
1186
1187 $resolved = Arr::get(static::supportedOperators(), $operator);
1188
1189 return is_string($resolved) && $resolved !== '' ? $resolved : $operator;
1190 }
1191
1192 public function applySimpleOperatorFilter(?string $search = null): bool
1193 {
1194 $operators = $this->getSimpleOperators();
1195
1196 // check if search has an operator with regexp
1197 $operatorPattern = '/\s*(' . implode('|', $operators) . ')\s*/';
1198
1199 $search = trim($search ?? $this->search);
1200 if (preg_match($operatorPattern, $search, $matches)) {
1201 $operator = $matches[1];
1202 $searchParts = explode($operator, $search);
1203
1204 if (count($searchParts) >= 2) {
1205 $column = trim($searchParts[0]);
1206 $value = trim($searchParts[1]);
1207
1208 // Check if the column is valid
1209 $validColumns = static::getSearchableFields();
1210 $column = strtolower($column);
1211 if ($columnSchema = Arr::get($validColumns, $column, null)) {
1212
1213 $type = Arr::get($columnSchema, 'type', 'string');
1214
1215 if ($type === 'custom') {
1216 $callback = $columnSchema['callback'];
1217 $callback($this->query, $value, $operator, $this);
1218 return true;
1219 }
1220 if (is_array($columnSchema)) {
1221 $column = $columnSchema['column'];
1222 } else {
1223 $column = $columnSchema;
1224 }
1225
1226 if ($operator == '::') {
1227 $values = explode('-', $value);
1228 if (count($values) == 2) {
1229 if (in_array($column, $this->centColumns())) {
1230 $values[0] = Helper::toCent($values[0]);
1231 $values[1] = Helper::toCent($values[1]);
1232 } else if (in_array($column, $this->dateColumns())) {
1233 $values[0] = DateTime::anyTimeToGmt($values[0], $this->userTz)->format('Y-m-d H:i:s');
1234 $values[1] = DateTime::anyTimeToGmt($values[1], $this->userTz)->format('Y-m-d H:i:s');
1235 }
1236 $this->query->whereBetween($column, $values);
1237 return true;
1238 }
1239 }
1240
1241 if (in_array($column, $this->centColumns())) {
1242 $value = Helper::toCent($value);
1243 } else if (in_array($column, $this->dateColumns())) {
1244 $value = DateTime::anyTimeToGmt($value, $this->userTz)->format('Y-m-d H:i:s');
1245 }
1246
1247 if ($this->shouldApplyMatchFilter($operator)) {
1248 $this->applyMatchFilter($this->query, $column, $value, $operator);
1249 } else {
1250 $this->query->where($column, $operator, $value);
1251 }
1252
1253
1254 return true;
1255 }
1256 }
1257 }
1258
1259 return false;
1260 }
1261
1262 public function shouldApplyMatchFilter(string $operator): bool
1263 {
1264 $operator = trim($operator);
1265 return $operator === '=' || $operator==='!=';
1266 }
1267
1268 public function applyMatchFilter(Builder $query, string $column, $value, string $operator = '='): Builder
1269 {
1270 $value = sanitize_text_field($value);
1271
1272 $hasStartWildcard = Str::startsWith($value, '*');
1273 $hasEndWildcard = Str::endsWith($value, '*');
1274
1275 // No wildcards → strict equality / inequality
1276 if (!$hasStartWildcard && !$hasEndWildcard) {
1277 return $query->where($column, $operator === '!=' ? '!=' : '=', $value);
1278 }
1279
1280 // Remove * wildcards
1281 $likeValue = $value;
1282
1283 if ($hasStartWildcard) {
1284 $likeValue = ltrim($likeValue, '*');
1285 }
1286
1287 if ($hasEndWildcard) {
1288 $likeValue = rtrim($likeValue, '*');
1289 }
1290
1291 // Convert to SQL LIKE pattern
1292 if ($hasStartWildcard && $hasEndWildcard) {
1293 $likeValue = '%' . $likeValue . '%'; // *value*
1294 } elseif ($hasStartWildcard) {
1295 $likeValue = '%' . $likeValue; // *value
1296 } else {
1297 $likeValue = $likeValue . '%'; // value*
1298 }
1299
1300 $sqlOperator = $operator === '!=' ? 'NOT LIKE' : 'LIKE';
1301
1302 return $query->where($column, $sqlOperator, $likeValue);
1303 }
1304
1305
1306
1307
1308 public function centColumns(): array
1309 {
1310 return [];
1311 }
1312
1313 public function dateColumns(): array
1314 {
1315 return ['updated_at', 'created_at'];
1316 }
1317
1318 /**
1319 * Return the name of filter
1320 *
1321 * @return string
1322 */
1323
1324 public static abstract function getFilterName(): string;
1325
1326
1327 /**
1328 * Return the maps of [key, key-name]
1329 * It's used for parse the data
1330 *
1331 * @return array
1332 */
1333 public static function parseableKeyMap(): array
1334 {
1335 return [
1336 'filter_type' => 'filter_type',
1337 'with' => 'with',
1338 'search' => 'search',
1339 'limit' => 'limit',
1340 'offset' => 'offset',
1341 'active_view' => 'active_view',
1342 'sort_by' => 'sort_by',
1343 'sort_type' => 'sort_type',
1344 'advanced_filters' => 'advanced_filters',
1345 'per_page' => 'per_page',
1346 'include_ids' => 'include_ids',
1347 'scopes' => 'scopes',
1348 'user_tz' => 'user_tz',
1349 'select' => 'select',
1350 'page' => 'page'
1351 ];
1352 }
1353
1354 /**
1355 * Return the names of allowed keys preserved in data
1356 *
1357 * @return array
1358 */
1359
1360 public static function parseableKeys(): array
1361 {
1362 return static::parseableKeyMap();
1363 }
1364
1365 /**
1366 * Return the names of the kye, which should be used to parse the value
1367 *
1368 * @param string $key // Name of the Key
1369 * @return string
1370 */
1371 private function getParsableKey(string $key): string
1372 {
1373 return Arr::has(static::parseableKeyMap(), $key) ?
1374 static::parseableKeyMap()[$key] : $key;
1375
1376 }
1377
1378 public function query()
1379 {
1380 return $this->query;
1381 }
1382
1383 public function setQuery(Builder $query): BaseFilter
1384 {
1385 $this->query = $query;
1386 return $this;
1387 }
1388
1389 public function customQuery()
1390 {
1391 return $this->query;
1392 }
1393
1394 public function get()
1395 {
1396 //Apply limit and offset only when using get
1397 //While using pagination, limit and offset are auto calculated
1398
1399 $this->buildQuery();
1400
1401 if (!empty($this->limit)) {
1402 $this->applyLimit();
1403 }
1404
1405 if (!empty($this->offset)) {
1406 $this->applyOffset();
1407 }
1408 $filter = $this->getFilterName();
1409 $this->query = apply_filters("fluent_cart/{$filter}_list_filter_query", $this->query, $this->toArray());
1410 return $this->query->get();
1411 }
1412
1413 public function paginate($perPage = null): LengthAwarePaginator
1414 {
1415 $this->buildQuery();
1416 $perPage = empty($perPage) ? $this->perPage : $perPage;
1417 $filter = $this->getFilterName();
1418 $this->query = apply_filters("fluent_cart/{$filter}_list_filter_query", $this->query, $this->toArray());
1419 return $this->query->paginate(
1420 $perPage,
1421 ['*'],
1422 'page',
1423 $this->page
1424 );
1425 }
1426
1427 public static function fromRequest(Request $request): BaseFilter
1428 {
1429 return new static($request->only(
1430 static::parseableKeys()
1431 ));
1432 }
1433
1434 public static function make(array $args): BaseFilter
1435 {
1436 return new static($args);
1437 }
1438
1439 public static function getAdvanceFilterOptions(): ?array
1440 {
1441 $filterName = static::getFilterName();
1442 $options = apply_filters("fluent_cart/{$filterName}_filter_options", static::advanceFilterOptions());
1443 return is_array($options) ? array_values($options) : null;
1444 }
1445
1446 private static function advanceFilterOptions(): ?array
1447 {
1448 return null;
1449 }
1450
1451 public static function getCustomColumns()
1452 {
1453 // $data = [
1454 // [
1455 // 'title' => 'Product One',
1456 // 'meta' => [
1457 // 'max_price' => '100',
1458 // 'min_price' => '80',
1459 // ]
1460 // ],
1461 // [
1462 // 'title' => 'Product Two',
1463 // 'meta' => [
1464 // 'max_price' => '150',
1465 // 'min_price' => '90',
1466 // ]
1467 // ]
1468 // ];
1469 // $example_columns = [
1470 // 'title' => [
1471 // 'label' => 'Title',
1472 // 'accessor' => 'title',
1473 // 'as_link' => false,
1474 // ],
1475 // 'max_price' => [
1476 // 'label' => 'Max Price',
1477 // 'accessor' => 'meta.max_price'
1478 // ],
1479 // 'min_price' => [
1480 // 'label' => 'Min Price',
1481 // 'accessor' => 'meta.min_price'
1482 // ]
1483 // ];
1484 $filterName = static::getFilterName();
1485 return apply_filters("fluent_cart/{$filterName}_table_columns", []);
1486 }
1487
1488 /**
1489 * Sort options this filter offers, keyed by the value the table sends as
1490 * `sort_by`:
1491 *
1492 * 'id' => ['label' => 'Order ID', 'column' => 'id'],
1493 * 'best_seller' => ['label' => 'Best Selling', 'column' => function ($query, $direction) {
1494 * return $query->orderBy('order_items_count', $direction);
1495 * }],
1496 *
1497 * `column` is the real DB column to ORDER BY, or a callable that receives
1498 * ($query, $direction) and applies its own ordering — which is how an
1499 * add-on sorts by something that is not a plain column on this table.
1500 *
1501 * Filter classes override this with the core defaults; add-ons append
1502 * through the hook in getSortableColumns().
1503 *
1504 * @return array<string, array<string, mixed>>
1505 */
1506 protected static function sortableColumns(): array
1507 {
1508 return [];
1509 }
1510
1511 /**
1512 * The declared sort options plus whatever add-ons registered.
1513 *
1514 * This map is the allow-list parseSortBy() validates `sort_by` against and
1515 * the handler table applySort() resolves against, so a registered option
1516 * actually reaches ORDER BY instead of being dropped for not being fillable.
1517 *
1518 * @return array<string, array<string, mixed>>
1519 */
1520 public static function getSortableColumns(): array
1521 {
1522 $filterName = static::getFilterName();
1523 $columns = apply_filters("fluent_cart/{$filterName}_table_sorts", static::sortableColumns());
1524
1525 return is_array($columns) ? $columns : [];
1526 }
1527
1528 /**
1529 * What the admin table receives: a `sort_by value => label` map. The column
1530 * (or the callable behind it) stays server side — it is not serializable,
1531 * and resolving it is the backend's job.
1532 *
1533 * @return array<string, string>
1534 */
1535 public static function getSortOptions(): array
1536 {
1537 $options = [];
1538
1539 foreach (static::getSortableColumns() as $value => $option) {
1540 $options[$value] = (string)Arr::get($option, 'label', $value);
1541 }
1542
1543 return $options;
1544 }
1545
1546 public static function getTableFilterOptions(): array
1547 {
1548 return [
1549 'advance' => static::getAdvanceFilterOptions(),
1550 'guide' => static::getSearchableFields(),
1551 'columns' => static::getCustomColumns(),
1552 'sorts' => static::getSortOptions(),
1553 ];
1554 }
1555
1556 public function toArray(): array
1557 {
1558 return [
1559 'select' => $this->select,
1560 'filterType' => $this->filterType,
1561 'search' => $this->search,
1562 'with' => $this->with,
1563 'scopes' => $this->scopes,
1564 'limit' => $this->limit,
1565 'offset' => $this->offset,
1566 'userTz' => $this->userTz,
1567 'includeIds' => $this->includeIds,
1568 'activeView' => $this->activeView,
1569 'sortBy' => $this->sortBy,
1570 'sortType' => $this->sortType,
1571 'searchGroups' => $this->searchGroups,
1572 'perPage' => $this->perPage,
1573 ];
1574 }
1575
1576 }
1577