Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
333 changes: 330 additions & 3 deletions src/Attribute/Complex.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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() ?? [];

Expand Down Expand Up @@ -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;
}
}
Loading