From 1eadf82f4edf7d4c90604ac860e4fe058b06fb94 Mon Sep 17 00:00:00 2001 From: Arie Timmerman Date: Thu, 18 Sep 2025 20:56:38 +0000 Subject: [PATCH] Implement add operation for email values in Complex class and add corresponding tests Fix #119 --- src/Attribute/Complex.php | 93 +++++++++++++++++++++++++++++++-- tests/EmailValuePathAddTest.php | 82 +++++++++++++++++++++++++++++ 2 files changed, 171 insertions(+), 4 deletions(-) create mode 100644 tests/EmailValuePathAddTest.php diff --git a/src/Attribute/Complex.php b/src/Attribute/Complex.php index 968b8d8..f09e989 100644 --- a/src/Attribute/Complex.php +++ b/src/Attribute/Complex.php @@ -102,13 +102,24 @@ public function patch($operation, $value, Model &$object, Path $path = null, $re } } - if (empty($matchedIndexes)) { - return; - } - $attributeNames = $path?->getAttributePath()?->getAttributeNames() ?? []; $modified = false; + if (empty($matchedIndexes)) { + if ($operation === 'add') { + $newElement = $this->createElementFromFilter($filterNode, $attributeNames, $value); + + if ($newElement !== null) { + $currentValues[] = $newElement; + $modified = true; + } + } + + if (!$modified) { + return; + } + } + foreach ($matchedIndexes as $index) { if (empty($attributeNames)) { if ($operation === 'remove') { @@ -638,4 +649,78 @@ private function restoreStructure(array $original, array $normalized): array return $result; } + + private function createElementFromFilter(AstFilter $filter, array $attributePath, mixed $value): ?array + { + $base = $this->extractAssignmentsFromFilter($filter); + + if (!empty($attributePath)) { + $base = $this->setNestedValue($base, $attributePath, $value); + } elseif (is_array($value)) { + $base = array_replace_recursive($base, $this->normalizeElement($value)); + } else { + return null; + } + + return $this->normalizeElement($base); + } + + private function extractAssignmentsFromFilter(AstFilter $filter): array + { + if ($filter instanceof ComparisonExpression) { + if (strtolower($filter->operator) !== 'eq') { + return []; + } + + $attributeNames = $filter->attributePath->getAttributeNames(); + + if (empty($attributeNames)) { + return []; + } + + return $this->setNestedValue([], $attributeNames, $filter->compareValue); + } + + if ($filter instanceof Conjunction) { + $result = []; + + foreach ($filter->getFactors() as $factor) { + $result = array_replace_recursive($result, $this->extractAssignmentsFromFilter($factor)); + } + + return $result; + } + + if ($filter instanceof AstValuePath) { + $nested = $this->extractAssignmentsFromFilter($filter->getFilter()); + + $attributeNames = $filter->getAttributePath()->getAttributeNames(); + + return $this->setNestedValue([], $attributeNames, $nested); + } + + return []; + } + + private function setNestedValue(array $array, array $path, mixed $value): array + { + if (empty($path)) { + return is_array($value) ? array_replace_recursive($array, $value) : $array; + } + + $segment = array_shift($path); + + if (!array_key_exists($segment, $array) || !is_array($array[$segment])) { + $array[$segment] = []; + } + + if (empty($path)) { + $array[$segment] = $value; + return $array; + } + + $array[$segment] = $this->setNestedValue($array[$segment], $path, $value); + + return $array; + } } diff --git a/tests/EmailValuePathAddTest.php b/tests/EmailValuePathAddTest.php new file mode 100644 index 0000000..4aa654f --- /dev/null +++ b/tests/EmailValuePathAddTest.php @@ -0,0 +1,82 @@ +firstOrFail(); + + $payload = [ + 'schemas' => ['urn:ietf:params:scim:api:messages:2.0:PatchOp'], + 'Operations' => [ + [ + 'op' => 'Add', + 'path' => 'emails[type eq "other"].value', + 'value' => 'someone@someplace.com', + ], + ], + ]; + + $response = $this->patchJson('/scim/v2/Users/' . $user->id, $payload); + + $response->assertStatus(200); + + $user->refresh(); + + $this->assertSame('someone@someplace.com', $user->email); + } + + public function testAddOperationCreatesNewElementWhenFilterMatchesNone(): void + { + $model = new class extends Model { + protected $table = 'users'; + public $timestamps = false; + }; + + $model->emails = [ + ['value' => 'work@example.com', 'type' => 'work'], + ]; + + $attribute = $this->makeEmailAttribute(); + + $path = Parser::parse('emails[type eq "other"].value'); + $path->shiftValuePathAttributes(); + + $attribute->patch('add', 'someone@someplace.com', $model, $path); + + $this->assertCount(2, $model->emails); + $this->assertSame('work@example.com', $model->emails[0]['value']); + $this->assertSame('other', $model->emails[1]['type']); + $this->assertSame('someone@someplace.com', $model->emails[1]['value']); + } + + private function makeEmailAttribute(): 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; + } + }; + } +}