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 / Modules / MCP / Support / AdvancedSearch.php

AdvancedSearch.php in FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler 1.6.4, at app/Modules/MCP/Support/AdvancedSearch.php

1,035 lines 42.2 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\Modules\MCP\Support;
4
5 use FluentCart\Api\ModuleSettings;
6 use FluentCart\App\App;
7 use FluentCart\App\Modules\MCP\Tools\ContextTools;
8 use FluentCart\App\Modules\Subscriptions\Services\Filter\SubscriptionFilter;
9 use FluentCart\App\Services\Filter\CustomerFilter;
10 use FluentCart\App\Services\Filter\LicenseFilter;
11 use FluentCart\App\Services\Filter\OrderFilter;
12 use FluentCart\App\Services\Filter\ProductFilter;
13 use FluentCart\Framework\Support\Arr;
14
15 /**
16 * Bridge between MCP tools and the admin UI's advanced filter engine
17 * (BaseFilter + per-entity subclasses) — the same condition-group search the
18 * admin list pages offer.
19 *
20 * Why a bridge instead of passing the raw payload through: the engine SILENTLY
21 * skips malformed conditions (parseSearchGroups drops them) and SILENTLY
22 * no-ops without Pro (applyAdvancedFilter early-returns) — both are the
23 * "confidently wrong result" failure mode an agent can't detect. So this class
24 * (1) validates every condition against the entity's live catalog and returns
25 * a structured error the agent can self-correct from, (2) errors loudly when
26 * Pro is absent, and (3) lets the agent send a lean {property, operator,
27 * value} triple — the engine-internal fields (filter_type, relation, column)
28 * are filled in from the catalog, never trusted from input.
29 *
30 * The catalog is derived live from each filter's advanceFilterOptions()
31 * through the same `fluent_cart/{name}_filter_options` hook the engine itself
32 * applies, so Pro/add-on providers (license, labels) appear automatically and
33 * the schema can never drift from what actually executes.
34 */
35 class AdvancedSearch
36 {
37 const MAX_GROUPS = 5;
38
39 const MAX_CONDITIONS_PER_GROUP = 10;
40
41 // Upper bound on the number of values inside a single condition, so a huge
42 // whereIn / in_all fan-out cannot be built from one condition.
43 const MAX_VALUES_PER_CONDITION = 100;
44
45 // Catalog operator labels the SQL layer can't execute, mapped to what it
46 // can (searchFromString would emit WHERE col equals ... otherwise).
47 // Applied on non-custom conditions only: custom callbacks (searchByFullName,
48 // searchByPayerEmail) consume their advertised labels verbatim.
49 const OPERATOR_ALIASES = ['equals' => '=', 'not_equals' => '!='];
50
51 /**
52 * Entities that expose advanced search, keyed by the public MCP name.
53 * Each maps to its admin filter class, the capability that gates reading
54 * it, and the list tool that accepts advanced_filters.
55 *
56 * @return array
57 */
58 public static function entities(): array
59 {
60 $entities = [
61 'orders' => [
62 'filter' => OrderFilter::class,
63 'permission' => 'orders/view',
64 'list_tool' => 'list-orders',
65 ],
66 'customers' => [
67 'filter' => CustomerFilter::class,
68 'permission' => 'customers/view',
69 'list_tool' => 'list-customers',
70 ],
71 'products' => [
72 'filter' => ProductFilter::class,
73 'permission' => 'products/view',
74 'list_tool' => 'list-products',
75 ],
76 'subscriptions' => [
77 'filter' => SubscriptionFilter::class,
78 'permission' => 'subscriptions/view',
79 'list_tool' => 'list-subscriptions',
80 ],
81 ];
82
83 // LicenseFilter lives in this plugin but its model is Pro's — only
84 // offer the entity when the model can actually be queried.
85 if (self::licensingAvailable()) {
86 $entities['licenses'] = [
87 'filter' => LicenseFilter::class,
88 'permission' => 'licenses/view',
89 'list_tool' => 'list-licenses',
90 ];
91 }
92
93 /**
94 * The entities MCP advanced search exposes. Lets Pro/add-ons register
95 * their own (filter class must extend BaseFilter and declare
96 * advanceFilterOptions()).
97 *
98 * @since 1.0.0
99 *
100 * @param array $entities entity => { filter: class, permission: cap, list_tool: string }
101 */
102 return apply_filters('fluent_cart/mcp_advanced_search_entities', $entities);
103 }
104
105 /** Entity names, for input_schema enums. */
106 public static function entityNames(): array
107 {
108 return array_keys(self::entities());
109 }
110
111 private static function licensingAvailable(): bool
112 {
113 return App::isProActive()
114 && class_exists('\FluentCartPro\App\Modules\Licensing\Models\License')
115 && ModuleSettings::isActive('license');
116 }
117
118 // -----------------------------------------------------------------
119 // Schema (what get-search-schema returns)
120 // -----------------------------------------------------------------
121
122 /**
123 * The agent-facing search schema for one entity: every filterable property
124 * with its operators, value type, options and hints, plus the payload
125 * format and a worked example.
126 *
127 * @param string $entity
128 * @return array|\WP_Error
129 */
130 public static function schemaFor($entity)
131 {
132 $spec = Arr::get(self::entities(), $entity);
133 if (!$spec) {
134 return self::unknownEntityError($entity);
135 }
136
137 $filterClass = Arr::get($spec, 'filter');
138 $catalog = self::catalog($filterClass, $entity);
139 if (!$catalog) {
140 return MCPHelper::error(
141 'no_advanced_options',
142 sprintf(
143 /* translators: %1$s: entity name */
144 __('%1$s exposes no advanced filter properties on this site.', 'fluent-cart'),
145 $entity
146 ),
147 ['entity' => $entity]
148 );
149 }
150
151 $centColumns = self::centColumnsFor($filterClass);
152
153 $properties = [];
154 foreach ($catalog as $key => $entry) {
155 $def = $entry['def'];
156 $kind = self::valueKind($def, $centColumns);
157
158 $prop = [
159 'property' => $key,
160 'label' => Arr::get($def, 'label', $key),
161 'type' => $kind,
162 'operators' => self::resolveOperators($def),
163 ];
164
165 if ($kind === 'enum') {
166 $options = Arr::get($def, 'options', []);
167 if (is_array($options) && $options) {
168 $prop['options'] = array_map('strval', array_keys($options));
169 } elseif (Arr::get($def, 'remote_data_key') === 'labels') {
170 $prop['value_hint'] = 'Array of label IDs — resolve names to ids via list-reference-data kinds=["labels"].';
171 }
172 } elseif ($kind === 'id_list') {
173 $prop['value_hint'] = self::idListHint($def);
174 $tree = Arr::get($def, 'options');
175 if (is_array($tree) && $tree) {
176 $prop['options'] = self::flattenTree($tree);
177 }
178 } elseif ($kind === 'money') {
179 $prop['value_hint'] = 'Number in store currency (e.g. 49.99) — converted to the stored cents automatically.';
180 } elseif ($kind === 'date') {
181 $prop['value_hint'] = 'before/after/date_equal take YYYY-MM-DD or ISO-8601, UTC. days_within = within the LAST N days, days_before = more than N days ago (both take a plain number of days, past-facing). For future windows (e.g. upcoming renewals or expirations) use before/after with absolute dates.';
182 } elseif ($entry['property'] === 'has') {
183 $prop['value_hint'] = 'Compares the COUNT of related records (a plain number).';
184 } elseif ($kind === 'text' && Arr::get($def, 'filter_type') === 'relation') {
185 $prop['value_hint'] = 'Related-record property: contains/not_contains match the whole value exactly (equality against any listed value), NOT a substring.';
186 }
187
188 $properties[] = $prop;
189 }
190
191 return [
192 'entity' => $entity,
193 'usage' => [
194 'parameter' => 'advanced_filters',
195 'tool' => Arr::get($spec, 'list_tool'),
196 'format' => 'Array of OR groups; each group is an array of AND conditions {property, operator, value}. [[A,B],[C]] means (A AND B) OR C. A flat array of conditions is treated as one group (all AND-ed).',
197 'combining' => 'advanced_filters AND-combines with the named filters of the same call; sorting and paging parameters work unchanged.',
198 'limits' => sprintf('Up to %1$d OR groups, %2$d conditions per group.', self::MAX_GROUPS, self::MAX_CONDITIONS_PER_GROUP),
199 'requires' => 'FluentCart Pro',
200 'example' => self::exampleFor($entity),
201 ],
202 'properties' => $properties,
203 ];
204 }
205
206 // -----------------------------------------------------------------
207 // Query building (what the list tools call)
208 // -----------------------------------------------------------------
209
210 /**
211 * Validate an agent-supplied advanced_filters payload and return a query
212 * for the entity with the condition groups applied — ready for the calling
213 * tool to add its own named filters, eager loads, sort, and pagination.
214 *
215 * @param string $entity
216 * @param mixed $raw the advanced_filters input
217 * @return array|\WP_Error { query: Builder, warnings: string[] }
218 */
219 public static function buildQuery($entity, $raw)
220 {
221 $spec = Arr::get(self::entities(), $entity);
222 if (!$spec) {
223 return self::unknownEntityError($entity);
224 }
225
226 // The engine's applyAdvancedFilter() silently no-ops without Pro — the
227 // agent would get the FULL unfiltered list presented as filtered. Error
228 // instead: recoverable beats confidently wrong.
229 if (!App::isProActive()) {
230 return MCPHelper::error(
231 'pro_required',
232 __('advanced_filters requires FluentCart Pro — without it the conditions would be silently ignored, so this call is rejected instead. Remove advanced_filters and use the tool\'s named filters.', 'fluent-cart'),
233 ['entity' => $entity]
234 );
235 }
236
237 $filterClass = Arr::get($spec, 'filter');
238 $normalized = self::normalize($entity, $filterClass, $raw);
239 if (is_wp_error($normalized)) {
240 return $normalized;
241 }
242
243 $filter = $filterClass::make([
244 'filter_type' => 'advanced',
245 'advanced_filters' => wp_json_encode($normalized['groups']),
246 ]);
247
248 // The engine applies OR groups at the TOP level of the builder
249 // (where g0, orWhere g1, ...). Returned as-is, the calling tool's named
250 // filters would AND onto that top level and SQL precedence (AND binds
251 // tighter than OR) would let group 0 escape every named filter:
252 // (g0) OR (g1 AND named). Nest the whole group layer inside ONE where()
253 // closure so the tool's filters AND correctly: named AND (g0 OR g1 ...).
254 // This mirrors the engine's own applySavedViewFilter() nesting, using
255 // only its public surface (no core edit).
256 $modelClass = $filter->getModel();
257 // Neutralise buildCommonQuery() side effects inside the closure: its
258 // eager loads / scopes would otherwise land inside the OR group (or call
259 // ->with() on the nested base builder). No core filter sets a default
260 // $with/$scopes, and the calling tool re-applies its own eager loads,
261 // sort and pagination on the returned builder.
262 $filter->with = [];
263 $filter->select = [];
264 $filter->scopes = [];
265
266 $query = (new $modelClass)->newQuery();
267 $query->where(function ($nested) use ($filter) {
268 $filter->setQuery($nested);
269 $filter->buildQuery();
270 });
271 // Strip the engine's default ORDER BY so the calling tool's own sort
272 // (with its deterministic id tie-breaker) is the only one.
273 $query->reorder();
274
275 return ['query' => $query, 'warnings' => $normalized['warnings']];
276 }
277
278 // -----------------------------------------------------------------
279 // Catalog — live property map per entity
280 // -----------------------------------------------------------------
281
282 /**
283 * Status properties whose admin dropdown is a curated subset of the values
284 * the column can actually hold, mapped to the canonical enum that completes
285 * them. The admin UI ships the short list on purpose (those are the statuses
286 * a merchant filters by day to day); an agent needs the full set, because
287 * "find the failed orders" is a normal question and the engine executes
288 * WHERE status IN (...) against the raw column either way.
289 *
290 * MCP-side only — the shared fluent_cart/{name}_filter_options hook is left
291 * untouched so the admin dropdowns keep their curated lists.
292 */
293 const CANONICAL_OPTIONS = [
294 'orders.order.status' => 'order_statuses',
295 'orders.order.payment_status' => 'payment_statuses',
296 'orders.order.type' => 'order_types',
297 'subscriptions.subscription.status' => 'subscription_statuses',
298 'subscriptions.subscription.billing_interval' => 'billing_intervals',
299 ];
300
301 /**
302 * provider.property => { provider, property, def } for one filter class,
303 * through the same fluent_cart/{name}_filter_options hook the engine
304 * applies, so Pro/add-on providers appear here exactly as they execute.
305 *
306 * $entity is used only to complete curated status option lists from the
307 * canonical enums (see CANONICAL_OPTIONS); pass it so the schema the agent
308 * reads and the values normalize() accepts stay the same list.
309 */
310 private static function catalog($filterClass, $entity = ''): array
311 {
312 if (!$filterClass || !class_exists($filterClass) || !is_callable([$filterClass, 'advanceFilterOptions'])) {
313 return [];
314 }
315
316 $filterName = $filterClass::getFilterName();
317 $providers = apply_filters("fluent_cart/{$filterName}_filter_options", $filterClass::advanceFilterOptions());
318 if (!is_array($providers)) {
319 return [];
320 }
321
322 $map = [];
323 foreach ($providers as $providerKey => $provider) {
324 $children = Arr::get((array) $provider, 'children', []);
325 if (!is_array($children)) {
326 continue;
327 }
328 foreach ($children as $child) {
329 if (!is_array($child)) {
330 continue;
331 }
332 $property = Arr::get($child, 'value');
333 if (!is_string($property) || $property === '') {
334 continue;
335 }
336 $key = $providerKey . '.' . $property;
337 // Core lists subscription.status twice — first wins.
338 if (isset($map[$key])) {
339 continue;
340 }
341 $map[$key] = [
342 'provider' => (string) $providerKey,
343 'property' => $property,
344 'def' => self::completeOptions($entity, $key, $child),
345 ];
346 }
347 }
348
349 return $map;
350 }
351
352 /**
353 * Union a curated status dropdown with its canonical enum, preserving the
354 * catalog's labels for the values it already had. Returns the definition
355 * unchanged for every property without a canonical counterpart.
356 */
357 private static function completeOptions($entity, $key, array $def): array
358 {
359 $enumKey = Arr::get(self::CANONICAL_OPTIONS, $entity . '.' . $key);
360 if (!$enumKey) {
361 return $def;
362 }
363 // Only selection widgets carry an options map; leave anything else alone.
364 if (Arr::get($def, 'type') !== 'selections') {
365 return $def;
366 }
367
368 $enums = ContextTools::enums();
369 if (empty($enums[$enumKey])) {
370 return $def;
371 }
372
373 $options = (array) Arr::get($def, 'options', []);
374 foreach ($enums[$enumKey] as $value) {
375 if (!array_key_exists($value, $options)) {
376 $options[$value] = $value;
377 }
378 }
379 $def['options'] = $options;
380
381 return $def;
382 }
383
384 /** Money columns for the entity, so values can be documented/validated as store-currency decimals. */
385 private static function centColumnsFor($filterClass): array
386 {
387 try {
388 $instance = new $filterClass([]);
389 return (array) $instance->centColumns();
390 } catch (\Throwable $e) {
391 return [];
392 }
393 }
394
395 /** The column the engine will actually compare (relation column, explicit column, or the property itself). */
396 private static function effectiveColumn(array $def)
397 {
398 $column = Arr::get($def, 'column');
399
400 return $column ? $column : Arr::get($def, 'value');
401 }
402
403 /** Normalize the catalog's UI widget type into an agent-facing value kind. */
404 private static function valueKind(array $def, array $centColumns): string
405 {
406 $type = Arr::get($def, 'type');
407
408 if ($type === 'dates') {
409 return 'date';
410 }
411 if ($type === 'selections') {
412 return 'enum';
413 }
414 if ($type === 'remote_tree_select' || $type === 'cascading_select') {
415 return 'id_list';
416 }
417 if ($type === 'numeric') {
418 return in_array(self::effectiveColumn($def), $centColumns, true) ? 'money' : 'numeric';
419 }
420
421 return 'text';
422 }
423
424 /**
425 * The operators a property accepts — the same resolution the admin filter
426 * UI applies (custom operator map first, then by widget type), constrained
427 * to what the SQL layer actually executes.
428 */
429 private static function resolveOperators(array $def): array
430 {
431 $customOps = Arr::get($def, 'operators');
432 if (is_array($customOps) && $customOps) {
433 $ops = array_map('strval', array_keys($customOps));
434 if (Arr::get($def, 'filter_type') !== 'custom') {
435 foreach ($ops as $i => $op) {
436 if (isset(self::OPERATOR_ALIASES[$op])) {
437 $ops[$i] = self::OPERATOR_ALIASES[$op];
438 }
439 }
440 }
441 return array_values(array_unique($ops));
442 }
443
444 $isCustom = Arr::get($def, 'filter_type') === 'custom';
445 $type = Arr::get($def, 'type');
446
447 if ($type === 'numeric') {
448 return ['>', '<', '>=', '<=', '=', '!='];
449 }
450 if ($type === 'dates') {
451 return ['before', 'after', 'date_equal', 'days_before', 'days_within'];
452 }
453 if ($type === 'selections') {
454 // Custom selection callbacks (stock_status, b2b_purchase) read a
455 // single scalar value and ignore richer operators.
456 return $isCustom ? ['in'] : ['in', 'not_in'];
457 }
458 if ($type === 'remote_tree_select' || $type === 'cascading_select') {
459 $ops = ['in', 'not_in'];
460 if (Arr::get($def, 'filter_type') === 'relation') {
461 // "has ALL of these" is only meaningful across related rows.
462 // not_in_all is deliberately NOT offered: the engine compiles it
463 // as whereDoesntHave(one related row = a AND = b), which is
464 // always true for 2+ distinct ids — a silent match-everything
465 // no-op. in_all (one whereHas per id) is correct.
466 $ops[] = 'in_all';
467 }
468 return $ops;
469 }
470
471 return ['=', '!=', 'contains', 'not_contains'];
472 }
473
474 // -----------------------------------------------------------------
475 // Validation + translation to the engine payload
476 // -----------------------------------------------------------------
477
478 /**
479 * Validate the raw agent payload and translate it into the exact structure
480 * BaseFilter::parseSearchGroups() consumes. Returns
481 * { groups: array, warnings: string[] } or a WP_Error naming the first
482 * offending condition — never a silently-trimmed payload.
483 */
484 private static function normalize($entity, $filterClass, $raw)
485 {
486 if (!is_array($raw)) {
487 return self::structureError($entity);
488 }
489
490 // Accept three shapes: full groups [[c,c],[c]], a flat condition list
491 // [c,c] (one AND group), or a single condition object {property,...}.
492 if (isset($raw['property']) || isset($raw['source'])) {
493 $raw = [[$raw]];
494 } elseif (self::isConditionList($raw)) {
495 $raw = [$raw];
496 }
497
498 if (count($raw) > self::MAX_GROUPS) {
499 return self::limitError($entity);
500 }
501
502 $catalog = self::catalog($filterClass, $entity);
503 $centColumns = self::centColumnsFor($filterClass);
504
505 $groups = [];
506 $warnings = [];
507
508 $groupNo = 0;
509 foreach ($raw as $group) {
510 $groupNo++;
511 if (!is_array($group)) {
512 return self::structureError($entity);
513 }
514 if (count($group) > self::MAX_CONDITIONS_PER_GROUP) {
515 return self::limitError($entity);
516 }
517
518 $engineGroup = [];
519 $conditionNo = 0;
520 foreach ($group as $condition) {
521 $conditionNo++;
522 $item = self::normalizeCondition($entity, $condition, $catalog, $centColumns, $groupNo, $conditionNo, $warnings);
523 if (is_wp_error($item)) {
524 return $item;
525 }
526 $engineGroup[] = $item;
527 }
528
529 if ($engineGroup) {
530 self::warnOrMergedRelations($engineGroup, $warnings);
531 $groups[] = $engineGroup;
532 }
533 }
534
535 if (!$groups) {
536 return MCPHelper::error(
537 'empty_filters',
538 __('advanced_filters contained no conditions. Provide at least one {property, operator, value} condition, or omit the parameter.', 'fluent-cart'),
539 ['entity' => $entity]
540 );
541 }
542
543 return ['groups' => $groups, 'warnings' => $warnings];
544 }
545
546 /** True when every element looks like a condition object (flat list form). */
547 private static function isConditionList(array $raw): bool
548 {
549 foreach ($raw as $element) {
550 if (!is_array($element) || (!isset($element['property']) && !isset($element['source']))) {
551 return false;
552 }
553 }
554
555 return (bool) $raw;
556 }
557
558 /**
559 * Validate one condition and build the engine item. The engine-internal
560 * fields (filter_type / relation / column) always come from the catalog
561 * definition, never from the caller.
562 *
563 * @return array|\WP_Error
564 */
565 private static function normalizeCondition($entity, $condition, array $catalog, array $centColumns, $groupNo, $conditionNo, array &$warnings)
566 {
567 if (!is_array($condition)) {
568 return self::structureError($entity);
569 }
570
571 $property = Arr::get($condition, 'property');
572 // Also accept the admin UI's own wire format: source: [provider, property].
573 if (!$property) {
574 $source = Arr::get($condition, 'source');
575 if (is_array($source) && count($source) === 2) {
576 $source = array_values($source);
577 if (is_scalar($source[0]) && is_scalar($source[1])) {
578 $property = $source[0] . '.' . $source[1];
579 }
580 }
581 }
582
583 if (!is_string($property) || $property === '') {
584 return MCPHelper::error(
585 'invalid_structure',
586 sprintf(
587 /* translators: 1: group number, 2: condition number */
588 __('The condition in group %1$d position %2$d has no property. Each condition needs {property, operator, value}.', 'fluent-cart'),
589 $groupNo,
590 $conditionNo
591 ),
592 ['entity' => $entity, 'group' => $groupNo, 'condition' => $conditionNo]
593 );
594 }
595
596 if (!isset($catalog[$property])) {
597 return MCPHelper::error(
598 'unknown_property',
599 sprintf(
600 /* translators: 1: property name, 2: group number, 3: condition number, 4: entity name, 5: valid property names */
601 __('Unknown property "%1$s" (group %2$d, condition %3$d). Valid properties for %4$s: %5$s. Call get-search-schema with entity=%4$s for operators and value formats.', 'fluent-cart'),
602 $property,
603 $groupNo,
604 $conditionNo,
605 $entity,
606 implode(', ', array_keys($catalog))
607 ),
608 ['entity' => $entity, 'property' => $property, 'valid_properties' => array_keys($catalog)]
609 );
610 }
611
612 $entry = $catalog[$property];
613 $def = $entry['def'];
614
615 $rawOperator = Arr::get($condition, 'operator', '');
616 if (!is_scalar($rawOperator)) {
617 return MCPHelper::error(
618 'invalid_operator',
619 sprintf(
620 /* translators: 1: property name */
621 __('The operator for property "%1$s" must be a string.', 'fluent-cart'),
622 $property
623 ),
624 ['entity' => $entity, 'property' => $property]
625 );
626 }
627 $operator = (string) $rawOperator;
628 if (Arr::get($def, 'filter_type') !== 'custom' && isset(self::OPERATOR_ALIASES[$operator])) {
629 $operator = self::OPERATOR_ALIASES[$operator];
630 }
631 $allowed = self::resolveOperators($def);
632 if (!in_array($operator, $allowed, true)) {
633 return MCPHelper::error(
634 'invalid_operator',
635 sprintf(
636 /* translators: 1: operator, 2: property name, 3: allowed operators */
637 __('Operator "%1$s" is not valid for property "%2$s". Allowed: %3$s.', 'fluent-cart'),
638 $operator,
639 $property,
640 implode(', ', $allowed)
641 ),
642 ['entity' => $entity, 'property' => $property, 'operator' => $operator, 'allowed' => $allowed]
643 );
644 }
645
646 if (!array_key_exists('value', $condition)) {
647 return MCPHelper::error(
648 'missing_value',
649 sprintf(
650 /* translators: 1: property name */
651 __('The condition on "%1$s" has no value.', 'fluent-cart'),
652 $property
653 ),
654 ['entity' => $entity, 'property' => $property]
655 );
656 }
657
658 $value = self::normalizeValue($def, $centColumns, $property, $operator, $condition['value'], $warnings);
659 if (is_wp_error($value)) {
660 return $value;
661 }
662
663 $item = [
664 'source' => [$entry['provider'], $entry['property']],
665 'operator' => $operator,
666 'value' => $value,
667 ];
668
669 $filterType = Arr::get($def, 'filter_type');
670 if ($filterType) {
671 $item['filter_type'] = $filterType;
672 }
673 if ($filterType === 'relation') {
674 $item['relation'] = Arr::get($def, 'relation', $entry['property']);
675 $item['column'] = Arr::get($def, 'column', 'id');
676 }
677
678 return $item;
679 }
680
681 /**
682 * Coerce/validate the value for the property's kind. Numeric scalars are
683 * stringified deliberately: HandleRelationalFilter silently drops values
684 * that are neither string nor array, so an integer 0 through a relation
685 * filter would otherwise vanish without a trace.
686 *
687 * @return mixed|\WP_Error
688 */
689 private static function normalizeValue(array $def, array $centColumns, $property, $operator, $value, array &$warnings)
690 {
691 $kind = self::valueKind($def, $centColumns);
692
693 if ($kind === 'money' || $kind === 'numeric') {
694 if (!is_numeric($value)) {
695 return self::valueError($property, __('a number', 'fluent-cart'));
696 }
697 // Reject non-finite / out-of-range magnitudes: "9e18" overflows the
698 // BIGINT cents column to a negative and silently matches all rows;
699 // "1e400" is +INF and throws deep in the SQL grammar.
700 $num = (float) $value;
701 if (!is_finite($num) || abs($num) > 1000000000000) {
702 return self::valueError($property, __('a number within a sensible range', 'fluent-cart'));
703 }
704 return (string) $value;
705 }
706
707 if ($kind === 'date') {
708 if ($operator === 'days_before' || $operator === 'days_within') {
709 if (!is_numeric($value)) {
710 return self::valueError($property, __('a number of days', 'fluent-cart'));
711 }
712 // Beyond ~100 years the engine's now-minus-N-days underflows past
713 // year 0000 into a malformed DATETIME the DB rejects (uncaught).
714 $days = (int) $value;
715 if ($days < 0 || $days > 36500) {
716 return self::valueError($property, __('a number of days between 0 and 36500', 'fluent-cart'));
717 }
718 return (string) $days;
719 }
720 // strtotime() maps junk like "GMT" / "T" / " " (and even a null byte)
721 // to the CURRENT time, which would silently match "everything before
722 // now". Require an actual date literal shape first.
723 if (!is_string($value)
724 || !preg_match('/^\d{4}-\d{2}-\d{2}([ T]\d{2}:\d{2}(:\d{2})?(\.\d+)?(Z|[+-]\d{2}:?\d{2})?)?$/', $value)
725 || strtotime($value) === false) {
726 return self::valueError($property, __('a date (YYYY-MM-DD or ISO-8601, UTC)', 'fluent-cart'));
727 }
728 return $value;
729 }
730
731 if ($kind === 'enum') {
732 $options = Arr::get($def, 'options', []);
733 $known = (is_array($options) && $options) ? array_map('strval', array_keys($options)) : [];
734
735 if (Arr::get($def, 'filter_type') === 'custom') {
736 // Custom selection callbacks read one scalar.
737 if (is_array($value)) {
738 if (count($value) !== 1) {
739 return self::valueError($property, __('a single value', 'fluent-cart'));
740 }
741 $value = reset($value);
742 }
743 if (!is_scalar($value)) {
744 return self::valueError($property, __('a single value', 'fluent-cart'));
745 }
746 $value = (string) $value;
747 self::warnUnknownOption($property, [$value], $known, $warnings);
748 return $value;
749 }
750
751 if (!is_array($value)) {
752 $value = [$value];
753 }
754 $value = self::capValueArray($property, $value, $warnings);
755 $flat = [];
756 foreach ($value as $entryValue) {
757 if (!is_scalar($entryValue)) {
758 return self::valueError($property, __('a value or array of values', 'fluent-cart'));
759 }
760 $flat[] = (string) $entryValue;
761 }
762 if (!$flat) {
763 return self::valueError($property, __('a non-empty array of values', 'fluent-cart'));
764 }
765 self::warnUnknownOption($property, $flat, $known, $warnings);
766 return $flat;
767 }
768
769 if ($kind === 'id_list') {
770 if (!is_array($value)) {
771 $value = [$value];
772 }
773 $value = self::capValueArray($property, $value, $warnings);
774 $ids = [];
775 foreach ($value as $id) {
776 if (!is_numeric($id)) {
777 return self::valueError($property, __('an array of numeric ids', 'fluent-cart'));
778 }
779 $ids[] = (int) $id;
780 }
781 if (!$ids) {
782 return self::valueError($property, __('a non-empty array of numeric ids', 'fluent-cart'));
783 }
784 return $ids;
785 }
786
787 // text
788 if (!is_scalar($value)) {
789 return self::valueError($property, __('a single text value', 'fluent-cart'));
790 }
791 $value = (string) $value;
792 // On a relation-backed property the engine silently drops an empty
793 // string (HandleRelationalFilter bails on empty), returning the full
794 // unfiltered list as if filtered. Reject it here instead.
795 if ($value === '' && Arr::get($def, 'filter_type') === 'relation') {
796 return self::valueError($property, __('a non-empty value', 'fluent-cart'));
797 }
798
799 return $value;
800 }
801
802 /** Cap a per-condition value array so a huge whereIn / in_all fan-out can't be built. */
803 private static function capValueArray($property, array $values, array &$warnings): array
804 {
805 if (count($values) > self::MAX_VALUES_PER_CONDITION) {
806 $warnings[] = sprintf(
807 /* translators: 1: property name, 2: cap, 3: received count */
808 __('Property %1$s: only the first %2$d values were applied (received %3$d).', 'fluent-cart'),
809 $property,
810 self::MAX_VALUES_PER_CONDITION,
811 count($values)
812 );
813 $values = array_slice($values, 0, self::MAX_VALUES_PER_CONDITION);
814 }
815 return $values;
816 }
817
818 /**
819 * The engine's mergeRelationFilters() unions same relation+property 'in'
820 * conditions within a group into one whereIn (OR), which contradicts the
821 * "conditions within a group are AND-ed" contract. Warn once per property so
822 * the agent knows to use in_all for AND semantics.
823 */
824 private static function warnOrMergedRelations(array $engineGroup, array &$warnings)
825 {
826 $seen = [];
827 $warned = [];
828 foreach ($engineGroup as $item) {
829 if (Arr::get($item, 'filter_type') !== 'relation') {
830 continue;
831 }
832 $op = Arr::get($item, 'operator');
833 // The engine's mergeRelationFilters() OR-merges same-property
834 // conditions for all of these operators within a group.
835 if (!in_array($op, ['in', 'not_in', 'contains', 'not_contains'], true)) {
836 continue;
837 }
838 $source = Arr::get($item, 'source', []);
839 $prop = is_array($source) ? implode('.', $source) : (string) $source;
840 $key = $prop . '|' . $op;
841 if (isset($seen[$key]) && !isset($warned[$key])) {
842 $warnings[] = sprintf(
843 /* translators: 1: property name, 2: operator */
844 __('Property %1$s: multiple "%2$s" conditions on the same related property in one group are combined as OR (match any), not AND. Split them across separate OR groups, or use in_all for id-list AND matching.', 'fluent-cart'),
845 $prop,
846 $op
847 );
848 $warned[$key] = true;
849 }
850 $seen[$key] = true;
851 }
852 }
853
854 /** A value outside the documented options isn't fatal (the engine matches it as-is) — but say so. */
855 private static function warnUnknownOption($property, array $values, array $known, array &$warnings)
856 {
857 if (!$known) {
858 return;
859 }
860 $unknown = [];
861 foreach ($values as $value) {
862 if (!in_array($value, $known, true)) {
863 $unknown[] = $value;
864 }
865 }
866 if (!$unknown) {
867 return;
868 }
869 // One warning per property, listing every off-catalog value, rather than
870 // one warning per value (a capped-but-large list would flood the meta).
871 $warnings[] = sprintf(
872 /* translators: 1: unrecognized values, 2: property name, 3: known option values */
873 __('Values "%1$s" on %2$s are not among the documented options (%3$s); they were matched literally, so unintended values return zero rows.', 'fluent-cart'),
874 implode(', ', $unknown),
875 $property,
876 implode(', ', $known)
877 );
878 }
879
880 // -----------------------------------------------------------------
881 // Errors & hints
882 // -----------------------------------------------------------------
883
884 private static function unknownEntityError($entity)
885 {
886 return MCPHelper::error(
887 'unknown_entity',
888 sprintf(
889 /* translators: 1: entity name, 2: valid entity names */
890 __('Unknown entity "%1$s". Valid entities: %2$s.', 'fluent-cart'),
891 (string) $entity,
892 implode(', ', self::entityNames())
893 ),
894 ['entities' => self::entityNames()]
895 );
896 }
897
898 private static function structureError($entity)
899 {
900 return MCPHelper::error(
901 'invalid_structure',
902 __('advanced_filters must be an array of OR groups, each an array of AND conditions {property, operator, value}. A flat array of conditions is also accepted and treated as one group.', 'fluent-cart'),
903 [
904 'entity' => $entity,
905 'hint' => 'Example: [[{"property":"order.status","operator":"in","value":["completed"]}]] — call get-search-schema for the full reference.',
906 ]
907 );
908 }
909
910 private static function limitError($entity)
911 {
912 return MCPHelper::error(
913 'too_many_conditions',
914 sprintf(
915 /* translators: 1: max groups, 2: max conditions per group */
916 __('advanced_filters allows at most %1$d OR groups with %2$d conditions each.', 'fluent-cart'),
917 self::MAX_GROUPS,
918 self::MAX_CONDITIONS_PER_GROUP
919 ),
920 ['entity' => $entity, 'max_groups' => self::MAX_GROUPS, 'max_conditions_per_group' => self::MAX_CONDITIONS_PER_GROUP]
921 );
922 }
923
924 private static function valueError($property, $expected)
925 {
926 return MCPHelper::error(
927 'invalid_value',
928 sprintf(
929 /* translators: 1: property name, 2: what the value should be */
930 __('The value for "%1$s" must be %2$s.', 'fluent-cart'),
931 $property,
932 $expected
933 ),
934 ['property' => $property]
935 );
936 }
937
938 private static function idListHint(array $def): string
939 {
940 $remoteKey = Arr::get($def, 'remote_data_key');
941 if ($remoteKey === 'product_variations') {
942 return 'Array of VARIATION ids (not product ids) — find them via get-product (variations[].id) or list-products.';
943 }
944 if ($remoteKey === 'labels') {
945 return 'Array of label ids — resolve names to ids via list-reference-data kinds=["labels"].';
946 }
947 if (Arr::get($def, 'relation') === 'wpTerms') {
948 return 'Array of term ids — see options, or list-reference-data kinds=["product_categories"].';
949 }
950
951 return 'Array of numeric ids.';
952 }
953
954 /** Flatten a nested {value,label,children} option tree to a bounded [{id,label}] list. */
955 private static function flattenTree($tree, $cap = 100): array
956 {
957 $out = [];
958 foreach ((array) $tree as $option) {
959 if (count($out) >= $cap) {
960 break;
961 }
962 if (!is_array($option)) {
963 continue;
964 }
965 $out[] = ['id' => Arr::get($option, 'value'), 'label' => Arr::get($option, 'label')];
966 $children = Arr::get($option, 'children', []);
967 if (is_array($children) && $children) {
968 foreach (self::flattenTree($children, $cap - count($out)) as $child) {
969 $out[] = $child;
970 }
971 }
972 }
973
974 return array_slice($out, 0, $cap);
975 }
976
977 /** A worked, copy-adaptable example per core entity (null for add-on entities). */
978 private static function exampleFor($entity)
979 {
980 $examples = [
981 'orders' => [
982 'meaning' => '(completed or processing orders from the last 90 days over 100 in store currency) OR (any renewal order)',
983 'value' => [
984 [
985 ['property' => 'order.status', 'operator' => 'in', 'value' => ['completed', 'processing']],
986 ['property' => 'order.created_at', 'operator' => 'days_within', 'value' => 90],
987 ['property' => 'order.total_amount', 'operator' => '>', 'value' => 100],
988 ],
989 [
990 ['property' => 'order.type', 'operator' => 'in', 'value' => ['renewal']],
991 ],
992 ],
993 ],
994 'customers' => [
995 'meaning' => 'customers with LTV over 500 who purchased within the last 180 days',
996 'value' => [
997 [
998 ['property' => 'customer.ltv', 'operator' => '>', 'value' => 500],
999 ['property' => 'order.last_purchase_date', 'operator' => 'days_within', 'value' => 180],
1000 ],
1001 ],
1002 ],
1003 'products' => [
1004 'meaning' => 'simple products priced 50 or more',
1005 'value' => [
1006 [
1007 ['property' => 'pricing.min_price', 'operator' => '>=', 'value' => 50],
1008 ['property' => 'variations.variation_type', 'operator' => 'in', 'value' => ['simple']],
1009 ],
1010 ],
1011 ],
1012 'subscriptions' => [
1013 'meaning' => 'active or trialing subscriptions that have been billed at least 3 times',
1014 'value' => [
1015 [
1016 ['property' => 'subscription.status', 'operator' => 'in', 'value' => ['active', 'trialing']],
1017 ['property' => 'subscription.bill_count', 'operator' => '>=', 'value' => 3],
1018 ],
1019 ],
1020 ],
1021 'licenses' => [
1022 'meaning' => 'active licenses with at least one activation',
1023 'value' => [
1024 [
1025 ['property' => 'license.status', 'operator' => 'in', 'value' => ['active']],
1026 ['property' => 'license.activation_count', 'operator' => '>', 'value' => 0],
1027 ],
1028 ],
1029 ],
1030 ];
1031
1032 return Arr::get($examples, $entity);
1033 }
1034 }
1035