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

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

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