diff --git a/src/Attribute/Complex.php b/src/Attribute/Complex.php index 6b28bfc..968b8d8 100644 --- a/src/Attribute/Complex.php +++ b/src/Attribute/Complex.php @@ -3,8 +3,16 @@ namespace ArieTimmerman\Laravel\SCIMServer\Attribute; use ArieTimmerman\Laravel\SCIMServer\Exceptions\SCIMException; +use ArieTimmerman\Laravel\SCIMServer\Filter\Ast\ComparisonExpression; +use ArieTimmerman\Laravel\SCIMServer\Filter\Ast\Conjunction; +use ArieTimmerman\Laravel\SCIMServer\Filter\Ast\Disjunction; +use ArieTimmerman\Laravel\SCIMServer\Filter\Ast\Filter as AstFilter; +use ArieTimmerman\Laravel\SCIMServer\Filter\Ast\Negation; +use ArieTimmerman\Laravel\SCIMServer\Filter\Ast\ValuePath as AstValuePath; +use ArieTimmerman\Laravel\SCIMServer\Parser\Filter as ParserFilter; use ArieTimmerman\Laravel\SCIMServer\Parser\Parser; use ArieTimmerman\Laravel\SCIMServer\Parser\Path; +use Illuminate\Contracts\Support\Arrayable; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; @@ -70,9 +78,77 @@ public function patch($operation, $value, Model &$object, Path $path = null, $re throw new SCIMException('Unknown path: ' . (string)$path . ", in object: " . $this->getFullKey()); } } elseif ($path->getValuePathFilter() != null) { - // TODO: Handle valuePath filters for PATCH operations on multi-valued attributes (RFC 7644 ยง3.5.2). - // apply filtering here, for each match, call replace with updated path - throw new \Exception('Filtering not implemented for this complex attribute'); + if (!$this->getMultiValued()) { + 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'); + } + + $filterWrapper = $path->getValuePathFilter(); + $filterNode = $filterWrapper instanceof ParserFilter ? $filterWrapper->filter : null; + + if (!$filterNode instanceof AstFilter) { + return; + } + + $currentRaw = $this->doRead($object); + $currentValues = $this->normalizeMultiValuedItems($currentRaw); + if (empty($currentValues)) { + return; + } + + $matchedIndexes = []; + foreach ($currentValues as $index => $item) { + if ($this->matchesFilter($filterNode, $item)) { + $matchedIndexes[] = $index; + } + } + + if (empty($matchedIndexes)) { + return; + } + + $attributeNames = $path?->getAttributePath()?->getAttributeNames() ?? []; + $modified = false; + + foreach ($matchedIndexes as $index) { + if (empty($attributeNames)) { + if ($operation === 'remove') { + unset($currentValues[$index]); + $modified = true; + continue; + } + + $valuePayload = $this->normalizeElement($value); + + if ($operation === 'add') { + $currentValues[$index] = array_merge($currentValues[$index], $valuePayload); + } elseif ($operation === 'replace') { + $currentValues[$index] = $valuePayload; + } else { + throw new SCIMException('Unsupported operation: ' . $operation); + } + + $modified = true; + continue; + } + + $updated = $this->applyAttributeOperation($currentValues[$index], $attributeNames, $operation, $value); + + if ($updated !== $currentValues[$index]) { + $currentValues[$index] = $updated; + $modified = true; + } + } + + if ($modified) { + $normalized = array_values($currentValues); + + // Attempt to preserve original representation when no normalization occurred. + if (is_array($currentRaw) && $this->isAssoc($currentRaw) === $this->isAssoc($normalized)) { + $normalized = $this->restoreStructure($currentRaw, $normalized); + } + + $this->writeMultiValuedItems($object, $normalized); + } } elseif ($path->getAttributePath() != null) { $attributeNames = $path?->getAttributePath()?->getAttributeNames() ?? []; @@ -311,4 +387,255 @@ public function getDefaultSchema() { return collect($this->subAttributes)->first(fn($element) => $element instanceof Schema)->name; } + + private function normalizeMultiValuedItems(mixed $items): array + { + if ($items === null) { + return []; + } + + if (!is_array($items)) { + $items = [$items]; + } + + return array_values(array_map(fn($item) => $this->normalizeElement($item), $items)); + } + + private function normalizeElement(mixed $element): array + { + if ($element instanceof Arrayable) { + return $element->toArray(); + } + + if ($element instanceof \JsonSerializable) { + $serialized = $element->jsonSerialize(); + return is_array($serialized) ? $serialized : ['value' => $serialized]; + } + + if (is_object($element)) { + $objectVars = get_object_vars($element); + return !empty($objectVars) ? $objectVars : ['value' => (string)$element]; + } + + if (is_array($element)) { + return $element; + } + + return ['value' => $element]; + } + + private function matchesFilter(AstFilter $filter, array $item): bool + { + if ($filter instanceof ComparisonExpression) { + $attributeNames = $filter->attributePath->getAttributeNames(); + $actual = $this->extractValue($item, $attributeNames); + return $this->compare($actual, $filter->operator, $filter->compareValue); + } + + if ($filter instanceof Conjunction) { + foreach ($filter->getFactors() as $factor) { + if (!$this->matchesFilter($factor, $item)) { + return false; + } + } + + return true; + } + + if ($filter instanceof Disjunction) { + foreach ($filter->getTerms() as $term) { + if ($this->matchesFilter($term, $item)) { + return true; + } + } + + return false; + } + + if ($filter instanceof Negation) { + return !$this->matchesFilter($filter->getFilter(), $item); + } + + if ($filter instanceof AstValuePath) { + $nestedValues = $this->extractValue($item, $filter->getAttributePath()->getAttributeNames()); + $normalized = $this->normalizeNested($nestedValues); + + foreach ($normalized as $nested) { + if ($this->matchesFilter($filter->getFilter(), $nested)) { + return true; + } + } + + return false; + } + + return false; + } + + private function normalizeNested(mixed $value): array + { + if ($value === null) { + return []; + } + + if (is_array($value)) { + if ($this->isAssoc($value)) { + return [$this->normalizeElement($value)]; + } + + return array_map(fn($item) => $this->normalizeElement($item), $value); + } + + return [$this->normalizeElement($value)]; + } + + private function extractValue(array $item, array $attributeNames): mixed + { + $current = $item; + + foreach ($attributeNames as $segment) { + if (!is_array($current) || !array_key_exists($segment, $current)) { + return null; + } + + $current = $current[$segment]; + } + + return $current; + } + + private function compare(mixed $actual, string $operator, mixed $expected): bool + { + $operator = strtolower($operator); + + switch ($operator) { + case 'eq': + return $this->normalizeComparable($actual) == $this->normalizeComparable($expected); + case 'ne': + return $this->normalizeComparable($actual) != $this->normalizeComparable($expected); + case 'co': + $actualString = (string)$this->normalizeComparable($actual); + $expectedString = (string)$this->normalizeComparable($expected); + return $actualString !== '' && $expectedString !== '' && str_contains($actualString, $expectedString); + case 'sw': + $actualString = (string)$this->normalizeComparable($actual); + $expectedString = (string)$this->normalizeComparable($expected); + return $actualString !== '' && $expectedString !== '' && str_starts_with($actualString, $expectedString); + case 'ew': + $actualString = (string)$this->normalizeComparable($actual); + $expectedString = (string)$this->normalizeComparable($expected); + return $actualString !== '' && $expectedString !== '' && str_ends_with($actualString, $expectedString); + case 'gt': + return $this->normalizeComparable($actual) > $this->normalizeComparable($expected); + case 'ge': + return $this->normalizeComparable($actual) >= $this->normalizeComparable($expected); + case 'lt': + return $this->normalizeComparable($actual) < $this->normalizeComparable($expected); + case 'le': + return $this->normalizeComparable($actual) <= $this->normalizeComparable($expected); + case 'pr': + $value = $this->normalizeComparable($actual); + return $value !== null && $value !== ''; + default: + throw new SCIMException('Unsupported filter operator ' . $operator); + } + } + + private function normalizeComparable(mixed $value): mixed + { + if ($value instanceof \DateTimeInterface) { + return $value->getTimestamp(); + } + + if (is_int($value) || is_float($value)) { + return $value; + } + + if (is_bool($value) || $value === null) { + return $value; + } + + if (is_numeric($value)) { + return $value + 0; + } + + if (is_array($value)) { + return null; + } + + return (string)$value; + } + + private function applyAttributeOperation(array $item, array $attributeNames, string $operation, mixed $value): array + { + $operation = strtolower($operation); + $segment = array_shift($attributeNames); + + if ($segment === null) { + return $item; + } + + if (empty($attributeNames)) { + if ($operation === 'remove') { + unset($item[$segment]); + return $item; + } + + if ($operation === 'add' && array_key_exists($segment, $item) && is_array($item[$segment]) && is_array($value)) { + $item[$segment] = array_merge($item[$segment], $value); + return $item; + } + + if (in_array($operation, ['add', 'replace'], true)) { + $item[$segment] = $value; + return $item; + } + + throw new SCIMException('Unsupported operation: ' . $operation); + } + + $child = $item[$segment] ?? []; + if (!is_array($child)) { + $child = $this->normalizeElement($child); + } + + $item[$segment] = $this->applyAttributeOperation($child, $attributeNames, $operation, $value); + + return $item; + } + + private function isAssoc(array $array): bool + { + return array_values($array) !== $array; + } + + private function writeMultiValuedItems(Model &$object, array $items): void + { + $reflection = new \ReflectionMethod($this, 'replace'); + + if ($reflection->getDeclaringClass()->getName() !== self::class) { + $this->replace($items, $object, null, false); + return; + } + + $object->{$this->name} = $items; + $this->dirty = true; + } + + private function restoreStructure(array $original, array $normalized): array + { + if (!$this->isAssoc($original)) { + return $normalized; + } + + $keys = array_keys($original); + $result = []; + + foreach ($normalized as $index => $value) { + $key = $keys[$index] ?? $index; + $result[$key] = $value; + } + + return $result; + } } diff --git a/tests/ComplexValuePathPatchTest.php b/tests/ComplexValuePathPatchTest.php new file mode 100644 index 0000000..2f32d46 --- /dev/null +++ b/tests/ComplexValuePathPatchTest.php @@ -0,0 +1,79 @@ +emails = [ + ['value' => 'work@example.com', 'type' => 'work'], + ['value' => 'home@example.com', 'type' => 'home'], + ]; + + $attribute = $this->makeMultiValuedComplexAttribute(); + + $path = Parser::parse('emails[type eq "work"]')->shiftValuePathAttributes(); + + $attribute->patch('remove', null, $model, $path); + + $this->assertCount(1, $model->emails); + $this->assertSame('home@example.com', $model->emails[0]['value']); + } + + public function testReplaceOperationWithValuePathFilterUpdatesSubAttribute(): void + { + $model = new class extends Model { + protected $table = 'users'; + public $timestamps = false; + }; + + $model->emails = [ + ['value' => 'work@example.com', 'type' => 'work'], + ['value' => 'home@example.com', 'type' => 'home'], + ]; + + $attribute = $this->makeMultiValuedComplexAttribute(); + + $path = Parser::parse('emails[type eq "work"].value'); + $path->shiftValuePathAttributes(); + + $attribute->patch('replace', 'new-work@example.com', $model, $path); + + $this->assertSame('new-work@example.com', $model->emails[0]['value']); + $this->assertSame('home@example.com', $model->emails[1]['value']); + } + + private function makeMultiValuedComplexAttribute(): Complex + { + return new class('emails') extends Complex { + public function __construct($name) + { + parent::__construct($name); + $this->setMultiValued(true); + } + + protected function doRead(&$object, $attributes = []) + { + return $object->{$this->name} ?? []; + } + + public function replace($value, Model &$object, Path $path = null, $removeIfNotSet = false) + { + $object->{$this->name} = $value; + $this->dirty = true; + } + }; + } +}