Skip to content

Commit 91cedb8

Browse files
committed
Implement value path filtering for multi-valued attributes in Complex class and add corresponding tests
1 parent e746a44 commit 91cedb8

File tree

2 files changed

+409
-3
lines changed

2 files changed

+409
-3
lines changed

src/Attribute/Complex.php

Lines changed: 330 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,16 @@
33
namespace ArieTimmerman\Laravel\SCIMServer\Attribute;
44

55
use ArieTimmerman\Laravel\SCIMServer\Exceptions\SCIMException;
6+
use ArieTimmerman\Laravel\SCIMServer\Filter\Ast\ComparisonExpression;
7+
use ArieTimmerman\Laravel\SCIMServer\Filter\Ast\Conjunction;
8+
use ArieTimmerman\Laravel\SCIMServer\Filter\Ast\Disjunction;
9+
use ArieTimmerman\Laravel\SCIMServer\Filter\Ast\Filter as AstFilter;
10+
use ArieTimmerman\Laravel\SCIMServer\Filter\Ast\Negation;
11+
use ArieTimmerman\Laravel\SCIMServer\Filter\Ast\ValuePath as AstValuePath;
12+
use ArieTimmerman\Laravel\SCIMServer\Parser\Filter as ParserFilter;
613
use ArieTimmerman\Laravel\SCIMServer\Parser\Parser;
714
use ArieTimmerman\Laravel\SCIMServer\Parser\Path;
15+
use Illuminate\Contracts\Support\Arrayable;
816
use Illuminate\Database\Eloquent\Builder;
917
use Illuminate\Database\Eloquent\Model;
1018

@@ -70,9 +78,77 @@ public function patch($operation, $value, Model &$object, Path $path = null, $re
7078
throw new SCIMException('Unknown path: ' . (string)$path . ", in object: " . $this->getFullKey());
7179
}
7280
} elseif ($path->getValuePathFilter() != null) {
73-
// TODO: Handle valuePath filters for PATCH operations on multi-valued attributes (RFC 7644 §3.5.2).
74-
// apply filtering here, for each match, call replace with updated path
75-
throw new \Exception('Filtering not implemented for this complex attribute');
81+
if (!$this->getMultiValued()) {
82+
throw (new SCIMException(sprintf('ValuePath filters are only supported on multi-valued attributes. Attribute "%s" is not multi-valued.', $this->getFullKey())))->setCode(400)->setScimType('invalidFilter');
83+
}
84+
85+
$filterWrapper = $path->getValuePathFilter();
86+
$filterNode = $filterWrapper instanceof ParserFilter ? $filterWrapper->filter : null;
87+
88+
if (!$filterNode instanceof AstFilter) {
89+
return;
90+
}
91+
92+
$currentRaw = $this->doRead($object);
93+
$currentValues = $this->normalizeMultiValuedItems($currentRaw);
94+
if (empty($currentValues)) {
95+
return;
96+
}
97+
98+
$matchedIndexes = [];
99+
foreach ($currentValues as $index => $item) {
100+
if ($this->matchesFilter($filterNode, $item)) {
101+
$matchedIndexes[] = $index;
102+
}
103+
}
104+
105+
if (empty($matchedIndexes)) {
106+
return;
107+
}
108+
109+
$attributeNames = $path?->getAttributePath()?->getAttributeNames() ?? [];
110+
$modified = false;
111+
112+
foreach ($matchedIndexes as $index) {
113+
if (empty($attributeNames)) {
114+
if ($operation === 'remove') {
115+
unset($currentValues[$index]);
116+
$modified = true;
117+
continue;
118+
}
119+
120+
$valuePayload = $this->normalizeElement($value);
121+
122+
if ($operation === 'add') {
123+
$currentValues[$index] = array_merge($currentValues[$index], $valuePayload);
124+
} elseif ($operation === 'replace') {
125+
$currentValues[$index] = $valuePayload;
126+
} else {
127+
throw new SCIMException('Unsupported operation: ' . $operation);
128+
}
129+
130+
$modified = true;
131+
continue;
132+
}
133+
134+
$updated = $this->applyAttributeOperation($currentValues[$index], $attributeNames, $operation, $value);
135+
136+
if ($updated !== $currentValues[$index]) {
137+
$currentValues[$index] = $updated;
138+
$modified = true;
139+
}
140+
}
141+
142+
if ($modified) {
143+
$normalized = array_values($currentValues);
144+
145+
// Attempt to preserve original representation when no normalization occurred.
146+
if (is_array($currentRaw) && $this->isAssoc($currentRaw) === $this->isAssoc($normalized)) {
147+
$normalized = $this->restoreStructure($currentRaw, $normalized);
148+
}
149+
150+
$this->writeMultiValuedItems($object, $normalized);
151+
}
76152
} elseif ($path->getAttributePath() != null) {
77153
$attributeNames = $path?->getAttributePath()?->getAttributeNames() ?? [];
78154

@@ -311,4 +387,255 @@ public function getDefaultSchema()
311387
{
312388
return collect($this->subAttributes)->first(fn($element) => $element instanceof Schema)->name;
313389
}
390+
391+
private function normalizeMultiValuedItems(mixed $items): array
392+
{
393+
if ($items === null) {
394+
return [];
395+
}
396+
397+
if (!is_array($items)) {
398+
$items = [$items];
399+
}
400+
401+
return array_values(array_map(fn($item) => $this->normalizeElement($item), $items));
402+
}
403+
404+
private function normalizeElement(mixed $element): array
405+
{
406+
if ($element instanceof Arrayable) {
407+
return $element->toArray();
408+
}
409+
410+
if ($element instanceof \JsonSerializable) {
411+
$serialized = $element->jsonSerialize();
412+
return is_array($serialized) ? $serialized : ['value' => $serialized];
413+
}
414+
415+
if (is_object($element)) {
416+
$objectVars = get_object_vars($element);
417+
return !empty($objectVars) ? $objectVars : ['value' => (string)$element];
418+
}
419+
420+
if (is_array($element)) {
421+
return $element;
422+
}
423+
424+
return ['value' => $element];
425+
}
426+
427+
private function matchesFilter(AstFilter $filter, array $item): bool
428+
{
429+
if ($filter instanceof ComparisonExpression) {
430+
$attributeNames = $filter->attributePath->getAttributeNames();
431+
$actual = $this->extractValue($item, $attributeNames);
432+
return $this->compare($actual, $filter->operator, $filter->compareValue);
433+
}
434+
435+
if ($filter instanceof Conjunction) {
436+
foreach ($filter->getFactors() as $factor) {
437+
if (!$this->matchesFilter($factor, $item)) {
438+
return false;
439+
}
440+
}
441+
442+
return true;
443+
}
444+
445+
if ($filter instanceof Disjunction) {
446+
foreach ($filter->getTerms() as $term) {
447+
if ($this->matchesFilter($term, $item)) {
448+
return true;
449+
}
450+
}
451+
452+
return false;
453+
}
454+
455+
if ($filter instanceof Negation) {
456+
return !$this->matchesFilter($filter->getFilter(), $item);
457+
}
458+
459+
if ($filter instanceof AstValuePath) {
460+
$nestedValues = $this->extractValue($item, $filter->getAttributePath()->getAttributeNames());
461+
$normalized = $this->normalizeNested($nestedValues);
462+
463+
foreach ($normalized as $nested) {
464+
if ($this->matchesFilter($filter->getFilter(), $nested)) {
465+
return true;
466+
}
467+
}
468+
469+
return false;
470+
}
471+
472+
return false;
473+
}
474+
475+
private function normalizeNested(mixed $value): array
476+
{
477+
if ($value === null) {
478+
return [];
479+
}
480+
481+
if (is_array($value)) {
482+
if ($this->isAssoc($value)) {
483+
return [$this->normalizeElement($value)];
484+
}
485+
486+
return array_map(fn($item) => $this->normalizeElement($item), $value);
487+
}
488+
489+
return [$this->normalizeElement($value)];
490+
}
491+
492+
private function extractValue(array $item, array $attributeNames): mixed
493+
{
494+
$current = $item;
495+
496+
foreach ($attributeNames as $segment) {
497+
if (!is_array($current) || !array_key_exists($segment, $current)) {
498+
return null;
499+
}
500+
501+
$current = $current[$segment];
502+
}
503+
504+
return $current;
505+
}
506+
507+
private function compare(mixed $actual, string $operator, mixed $expected): bool
508+
{
509+
$operator = strtolower($operator);
510+
511+
switch ($operator) {
512+
case 'eq':
513+
return $this->normalizeComparable($actual) == $this->normalizeComparable($expected);
514+
case 'ne':
515+
return $this->normalizeComparable($actual) != $this->normalizeComparable($expected);
516+
case 'co':
517+
$actualString = (string)$this->normalizeComparable($actual);
518+
$expectedString = (string)$this->normalizeComparable($expected);
519+
return $actualString !== '' && $expectedString !== '' && str_contains($actualString, $expectedString);
520+
case 'sw':
521+
$actualString = (string)$this->normalizeComparable($actual);
522+
$expectedString = (string)$this->normalizeComparable($expected);
523+
return $actualString !== '' && $expectedString !== '' && str_starts_with($actualString, $expectedString);
524+
case 'ew':
525+
$actualString = (string)$this->normalizeComparable($actual);
526+
$expectedString = (string)$this->normalizeComparable($expected);
527+
return $actualString !== '' && $expectedString !== '' && str_ends_with($actualString, $expectedString);
528+
case 'gt':
529+
return $this->normalizeComparable($actual) > $this->normalizeComparable($expected);
530+
case 'ge':
531+
return $this->normalizeComparable($actual) >= $this->normalizeComparable($expected);
532+
case 'lt':
533+
return $this->normalizeComparable($actual) < $this->normalizeComparable($expected);
534+
case 'le':
535+
return $this->normalizeComparable($actual) <= $this->normalizeComparable($expected);
536+
case 'pr':
537+
$value = $this->normalizeComparable($actual);
538+
return $value !== null && $value !== '';
539+
default:
540+
throw new SCIMException('Unsupported filter operator ' . $operator);
541+
}
542+
}
543+
544+
private function normalizeComparable(mixed $value): mixed
545+
{
546+
if ($value instanceof \DateTimeInterface) {
547+
return $value->getTimestamp();
548+
}
549+
550+
if (is_int($value) || is_float($value)) {
551+
return $value;
552+
}
553+
554+
if (is_bool($value) || $value === null) {
555+
return $value;
556+
}
557+
558+
if (is_numeric($value)) {
559+
return $value + 0;
560+
}
561+
562+
if (is_array($value)) {
563+
return null;
564+
}
565+
566+
return (string)$value;
567+
}
568+
569+
private function applyAttributeOperation(array $item, array $attributeNames, string $operation, mixed $value): array
570+
{
571+
$operation = strtolower($operation);
572+
$segment = array_shift($attributeNames);
573+
574+
if ($segment === null) {
575+
return $item;
576+
}
577+
578+
if (empty($attributeNames)) {
579+
if ($operation === 'remove') {
580+
unset($item[$segment]);
581+
return $item;
582+
}
583+
584+
if ($operation === 'add' && array_key_exists($segment, $item) && is_array($item[$segment]) && is_array($value)) {
585+
$item[$segment] = array_merge($item[$segment], $value);
586+
return $item;
587+
}
588+
589+
if (in_array($operation, ['add', 'replace'], true)) {
590+
$item[$segment] = $value;
591+
return $item;
592+
}
593+
594+
throw new SCIMException('Unsupported operation: ' . $operation);
595+
}
596+
597+
$child = $item[$segment] ?? [];
598+
if (!is_array($child)) {
599+
$child = $this->normalizeElement($child);
600+
}
601+
602+
$item[$segment] = $this->applyAttributeOperation($child, $attributeNames, $operation, $value);
603+
604+
return $item;
605+
}
606+
607+
private function isAssoc(array $array): bool
608+
{
609+
return array_values($array) !== $array;
610+
}
611+
612+
private function writeMultiValuedItems(Model &$object, array $items): void
613+
{
614+
$reflection = new \ReflectionMethod($this, 'replace');
615+
616+
if ($reflection->getDeclaringClass()->getName() !== self::class) {
617+
$this->replace($items, $object, null, false);
618+
return;
619+
}
620+
621+
$object->{$this->name} = $items;
622+
$this->dirty = true;
623+
}
624+
625+
private function restoreStructure(array $original, array $normalized): array
626+
{
627+
if (!$this->isAssoc($original)) {
628+
return $normalized;
629+
}
630+
631+
$keys = array_keys($original);
632+
$result = [];
633+
634+
foreach ($normalized as $index => $value) {
635+
$key = $keys[$index] ?? $index;
636+
$result[$key] = $value;
637+
}
638+
639+
return $result;
640+
}
314641
}

0 commit comments

Comments
 (0)