From e97fe56c6df9db8dbc5af2ece23506cbef48de39 Mon Sep 17 00:00:00 2001 From: Juan Claude Date: Wed, 11 Nov 2026 18:13:35 +0000 Subject: [PATCH 1/2] test(metadata): pin MetadataService patch, upsert and filter branches before the split Assisted-by: ClaudeCode:claude-opus-5-5 --- .../Service/MetadataServiceBranchesTest.php | 313 ++++++++++++++++++ 1 file changed, 313 insertions(+) create mode 100644 tests/Unit/Service/MetadataServiceBranchesTest.php diff --git a/tests/Unit/Service/MetadataServiceBranchesTest.php b/tests/Unit/Service/MetadataServiceBranchesTest.php new file mode 100644 index 000000000..181c31c58 --- /dev/null +++ b/tests/Unit/Service/MetadataServiceBranchesTest.php @@ -0,0 +1,313 @@ + + * @copyright 2026 Conduction b.v. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace Unit\Service; + +use OCA\LaunchPad\Db\MetadataField; +use OCA\LaunchPad\Db\MetadataFieldMapper; +use OCA\LaunchPad\Db\MetadataValue; +use OCA\LaunchPad\Db\MetadataValueMapper; +use OCA\LaunchPad\Exception\InvalidMetadataFieldException; +use OCA\LaunchPad\Service\MetadataService; +use OCA\LaunchPad\Service\MetadataValidationService; +use OCP\AppFramework\Db\DoesNotExistException; +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; + +class MetadataServiceBranchesTest extends TestCase { + private MetadataFieldMapper $fieldMapper; + private MetadataValueMapper $valueMapper; + private MetadataService $service; + + protected function setUp(): void { + $this->fieldMapper = $this->createMock(MetadataFieldMapper::class); + $this->valueMapper = $this->createMock(MetadataValueMapper::class); + $this->service = new MetadataService( + $this->fieldMapper, + $this->valueMapper, + new MetadataValidationService(), + $this->createMock(LoggerInterface::class), + ); + } + + private function field(int $id, string $key, string $type, ?array $options = null, int $required = 0): MetadataField { + $field = new MetadataField(); + $field->setId($id); + $field->setFieldKey($key); + $field->setLabel('Label'); + $field->setType($type); + $field->setRequired($required); + $field->setSortOrder(3); + if ($options !== null) { + $field->setOptionsArray($options); + } + + return $field; + } + + private function value(int $fieldId, string $value, string $uuid): MetadataValue { + $row = new MetadataValue(); + $row->setDashboardUuid($uuid); + $row->setFieldId($fieldId); + $row->setValue($value); + + return $row; + } + + /** + * Run an update patch against a stored field and return what was saved. + */ + private function patched(MetadataField $field, array $patch): MetadataField { + $this->fieldMapper->method('findById')->willReturn($field); + $this->fieldMapper->method('update')->willReturnArgument(0); + + return $this->service->updateFieldDefinition(id: 1, patch: $patch); + } + + public function testRenameIsRefusedBeforeTheFieldIsRead(): void { + $this->fieldMapper->expects($this->never())->method('findById'); + $this->expectException(InvalidMetadataFieldException::class); + $this->expectExceptionMessage('Field key cannot be renamed'); + + $this->service->updateFieldDefinition(id: 1, patch: ['key' => null, 'label' => '']); + } + + public function testEveryPatchKeyIsApplied(): void { + $field = $this->patched( + $this->field(1, 'tags', MetadataField::TYPE_SELECT, ['a']), + ['label' => 'New', 'sortOrder' => '7', 'required' => '1', 'options' => ['x', 'y'], 'ignored' => 1] + ); + + $this->assertSame('New', $field->getLabel()); + $this->assertSame(7, $field->getSortOrder()); + $this->assertSame(1, $field->getRequired()); + $this->assertSame(['x', 'y'], $field->getOptionsArray()); + $this->assertNotNull($field->getUpdatedAt()); + } + + /** + * Required values and what they store. + * + * @return array + */ + public static function requiredValues(): array { + return ['one' => [1, 1], 'true' => [true, 1], 'two' => [2, 0], 'zero' => ['0', 0], 'text' => ['yes', 0]]; + } + + /** + * @dataProvider requiredValues + */ + public function testRequiredIsOneOnlyForOne(mixed $required, int $stored): void { + $field = $this->patched($this->field(1, 'k', MetadataField::TYPE_TEXT, null, 1), ['required' => $required]); + + $this->assertSame($stored, $field->getRequired()); + } + + public function testEmptyPatchOnlyStampsTheUpdate(): void { + $field = $this->patched($this->field(1, 'k', MetadataField::TYPE_TEXT), []); + + $this->assertSame('Label', $field->getLabel()); + $this->assertSame(3, $field->getSortOrder()); + $this->assertNotNull($field->getUpdatedAt()); + } + + /** + * Patches that fail validation and their message. + * + * @return array|null,2:array,3:string}> + */ + public static function invalidPatches(): array { + return [ + 'empty label' => [MetadataField::TYPE_TEXT, null, ['label' => ''], 'Field label must be 1..'], + 'label before options' => [MetadataField::TYPE_TEXT, null, ['options' => 'x', 'label' => ''], 'Field label must be 1..'], + 'scalar options' => [MetadataField::TYPE_SELECT, ['a'], ['options' => 'x'], 'Options must be an array of strings or null'], + 'null select options' => [MetadataField::TYPE_SELECT, ['a'], ['options' => null], 'Select type requires non-empty options array'], + 'empty select options' => [MetadataField::TYPE_MULTI_SELECT, ['a'], ['options' => []], 'Select type requires non-empty options array'], + 'non-string option' => [MetadataField::TYPE_SELECT, ['a'], ['options' => ['a', 3]], 'Select options must be non-empty strings'], + 'empty option' => [MetadataField::TYPE_SELECT, ['a'], ['options' => ['']], 'Select options must be non-empty strings'], + 'text with options' => [MetadataField::TYPE_TEXT, null, ['options' => ['a']], "Type 'text' does not support options"], + ]; + } + + /** + * @dataProvider invalidPatches + */ + public function testInvalidPatchIsRefused(string $type, ?array $options, array $patch, string $message): void { + $this->fieldMapper->method('findById')->willReturn($this->field(1, 'k', $type, $options)); + $this->fieldMapper->expects($this->never())->method('update'); + $this->expectException(InvalidMetadataFieldException::class); + $this->expectExceptionMessage($message); + + $this->service->updateFieldDefinition(id: 1, patch: $patch); + } + + public function testTextFieldAcceptsNullAndEmptyOptions(): void { + $field = $this->patched($this->field(1, 'k', MetadataField::TYPE_TEXT), ['options' => []]); + + $this->assertSame([], $field->getOptionsArray() ?? []); + } + + public function testEmptyMetadataKeyIsRefused(): void { + $this->fieldMapper->expects($this->never())->method('findByKey'); + $this->expectException(InvalidMetadataFieldException::class); + $this->expectExceptionMessage('Metadata keys must be non-empty strings'); + + $this->service->setMetadataForDashboard(dashboardUuid: 'd', keyValues: ['' => 'x']); + } + + public function testUnknownMetadataKeyNamesTheKey(): void { + $this->fieldMapper->method('findByKey')->willThrowException(new DoesNotExistException('')); + $this->expectException(InvalidMetadataFieldException::class); + $this->expectExceptionMessage("Unknown metadata field 'ghost'"); + + $this->service->setMetadataForDashboard(dashboardUuid: 'd', keyValues: ['ghost' => 'x']); + } + + public function testEmptyOptionalValueClearsTheRowAndRequiredOneIsUpserted(): void { + $optional = $this->field(1, 'note', MetadataField::TYPE_TEXT); + $required = $this->field(2, 'owner', MetadataField::TYPE_TEXT, null, 1); + $absent = $this->field(3, 'other', MetadataField::TYPE_TEXT); + $this->fieldMapper->method('findByKey')->willReturnCallback( + static fn (string $key): MetadataField => ['note' => $optional, 'owner' => $required, 'other' => $absent, '7' => $required][$key] + ); + $existing = $this->value(1, 'old', 'd'); + $this->valueMapper->method('findOne')->willReturnCallback( + static fn (string $dashboardUuid, int $fieldId): ?MetadataValue => $fieldId === 1 ? $existing : null + ); + $calls = []; + $this->valueMapper->method('delete')->willReturnCallback( + static function ($entity) use (&$calls) { + $calls[] = ['delete', $entity->getFieldId()]; + return $entity; + } + ); + $this->valueMapper->method('upsert')->willReturnCallback( + static function (string $dashboardUuid, int $fieldId, string $value) use (&$calls) { + $calls[] = ['upsert', $dashboardUuid, $fieldId, $value]; + return new MetadataValue(); + } + ); + $this->valueMapper->method('findByDashboard')->willReturn([]); + + $result = $this->service->setMetadataForDashboard( + dashboardUuid: 'd', + keyValues: ['note' => '', 'other' => '', 'owner' => 'ann', 7 => 'x'], + ); + + $this->assertSame([], $result); + $this->assertSame([['delete', 1], ['upsert', 'd', 2, 'ann'], ['upsert', 'd', 2, 'x']], $calls); + } + + /** + * Run a one-field filter over three dashboards. + * + * @return list The UUIDs kept. + */ + private function filterBy(string $type, array $stored, mixed $criterion): array { + $field = $this->field(1, 'f', $type); + $this->fieldMapper->method('findByKey')->willReturn($field); + $rows = []; + foreach ($stored as $uuid => $value) { + $rows[] = $this->value(1, $value, $uuid); + } + + $this->valueMapper->method('findByField')->willReturn($rows); + $dashboards = array_map(static fn (string $uuid): array => ['uuid' => $uuid], array_keys($stored)); + + return array_map( + static fn (array $d): string => $d['uuid'], + $this->service->filterDashboards($dashboards, ['f' => $criterion]) + ); + } + + /** + * Number criteria and the UUIDs they keep. + * + * @return array}> + */ + public static function numberCriteria(): array { + return [ + 'min only' => [['min' => '5'], ['b', 'c']], + 'max only' => [['max' => 5], ['a', 'b']], + 'both inclusive' => [['min' => 5, 'max' => 5], ['b']], + 'empty range' => [[], ['a', 'b', 'c']], + 'exact' => ['5.0', ['b']], + 'exact non-num' => ['five', []], + ]; + } + + /** + * @dataProvider numberCriteria + */ + public function testNumberCriteria(mixed $criterion, array $kept): void { + $this->assertSame($kept, $this->filterBy(MetadataField::TYPE_NUMBER, ['a' => '1', 'b' => '5', 'c' => '9', 'x' => 'n/a'], $criterion)); + } + + /** + * Date criteria and the UUIDs they keep. + * + * @return array}> + */ + public static function dateCriteria(): array { + return [ + 'after' => [['after' => '2026-02-01'], ['b', 'c']], + 'before' => [['before' => '2026-02-01'], ['a', 'b']], + 'between' => [['after' => '2026-02-01', 'before' => '2026-02-01'], ['b']], + 'empty range' => [['x' => 1], ['a', 'b', 'c']], + 'exact' => ['2026-03-01', ['c']], + ]; + } + + /** + * @dataProvider dateCriteria + */ + public function testDateCriteria(mixed $criterion, array $kept): void { + $this->assertSame($kept, $this->filterBy(MetadataField::TYPE_DATE, ['a' => '2026-01-01', 'b' => '2026-02-01', 'c' => '2026-03-01'], $criterion)); + } + + public function testDashboardUuidsComeFromEntitiesAndRows(): void { + $field = $this->field(1, 'f', MetadataField::TYPE_TEXT); + $this->fieldMapper->method('findByKey')->willReturn($field); + $this->valueMapper->method('findByField')->willReturn([ + $this->value(1, 'x', 'e1'), + $this->value(1, 'x', 'r1'), + $this->value(1, 'x', ''), + ]); + $entity = new class() { + public function getUuid(): string { + return 'e1'; + } + }; + $nullUuid = new class() { + public function getUuid(): ?string { + return null; + } + }; + $noMethod = new \stdClass(); + $dashboards = [$entity, $nullUuid, $noMethod, ['uuid' => 'r1'], ['id' => 3], ['uuid' => null], 'text']; + + $result = $this->service->filterDashboards($dashboards, ['f' => 'x']); + + $this->assertSame([$entity, ['uuid' => 'r1'], ['uuid' => null]], $result); + } +}//end class From b1af8c3f16a1e4e6bce07bd944ee66299ca3b61c Mon Sep 17 00:00:00 2001 From: Juan Claude Date: Wed, 11 Nov 2026 18:14:58 +0000 Subject: [PATCH 2/2] refactor(metadata): split MetadataService patch, upsert, uuid and range checks into named steps Assisted-by: ClaudeCode:claude-opus-5-5 --- lib/Service/MetadataService.php | 351 +++++++++++++++++++--------- lib/Service/MetadataValueWriter.php | 95 ++++++++ 2 files changed, 341 insertions(+), 105 deletions(-) create mode 100644 lib/Service/MetadataValueWriter.php diff --git a/lib/Service/MetadataService.php b/lib/Service/MetadataService.php index 6e5e9c9da..8848abdfd 100644 --- a/lib/Service/MetadataService.php +++ b/lib/Service/MetadataService.php @@ -185,43 +185,107 @@ public function updateFieldDefinition(int $id, array $patch): MetadataField { $field = $this->fieldMapper->findById(id: $id); + self::patchLabel(field: $field, patch: $patch); + self::patchSortOrder(field: $field, patch: $patch); + self::patchRequired(field: $field, patch: $patch); + self::patchOptions(field: $field, patch: $patch); + // phpcs:disable CustomSniffs.Functions.NamedParameters.RequireNamedParameters - if (array_key_exists(key: 'label', array: $patch) === true) { - $label = (string)$patch['label']; - self::assertLabelShape(label: $label); - $field->setLabel($label); - } + $field->setUpdatedAt((new DateTime())->format(format: 'c')); + // phpcs:enable CustomSniffs.Functions.NamedParameters.RequireNamedParameters - if (array_key_exists(key: 'sortOrder', array: $patch) === true) { - $field->setSortOrder((int)$patch['sortOrder']); + return $this->fieldMapper->update(entity: $field); + }//end updateFieldDefinition() + + /** + * Apply a patched `label`, validating its length first. + * + * @param MetadataField $field The field being patched. + * @param array $patch The shallow patch. + * + * @return void + * + * @throws InvalidMetadataFieldException When the label is malformed. + */ + private static function patchLabel(MetadataField $field, array $patch): void { + if (array_key_exists(key: 'label', array: $patch) === false) { + return; } - if (array_key_exists(key: 'required', array: $patch) === true) { - $required = 0; - if ((int)$patch['required'] === 1) { - $required = 1; - } + $label = (string)$patch['label']; + self::assertLabelShape(label: $label); + // phpcs:disable CustomSniffs.Functions.NamedParameters.RequireNamedParameters + $field->setLabel($label); + // phpcs:enable CustomSniffs.Functions.NamedParameters.RequireNamedParameters + }//end patchLabel() - $field->setRequired($required); + /** + * Apply a patched `sortOrder`. + * + * @param MetadataField $field The field being patched. + * @param array $patch The shallow patch. + * + * @return void + */ + private static function patchSortOrder(MetadataField $field, array $patch): void { + if (array_key_exists(key: 'sortOrder', array: $patch) === false) { + return; } - if (array_key_exists(key: 'options', array: $patch) === true) { - $options = $patch['options']; - if ($options !== null && is_array($options) === false) { - throw new InvalidMetadataFieldException( - message: 'Options must be an array of strings or null' - ); - } + // phpcs:disable CustomSniffs.Functions.NamedParameters.RequireNamedParameters + $field->setSortOrder((int)$patch['sortOrder']); + // phpcs:enable CustomSniffs.Functions.NamedParameters.RequireNamedParameters + }//end patchSortOrder() - self::assertOptionsShape(type: $field->getType(), options: $options); - $field->setOptionsArray(options: $options); + /** + * Apply a patched `required` flag: 1 only for a value that casts to 1. + * + * @param MetadataField $field The field being patched. + * @param array $patch The shallow patch. + * + * @return void + */ + private static function patchRequired(MetadataField $field, array $patch): void { + if (array_key_exists(key: 'required', array: $patch) === false) { + return; } - $field->setUpdatedAt((new DateTime())->format(format: 'c')); + $required = 0; + if ((int)$patch['required'] === 1) { + $required = 1; + } + + // phpcs:disable CustomSniffs.Functions.NamedParameters.RequireNamedParameters + $field->setRequired($required); // phpcs:enable CustomSniffs.Functions.NamedParameters.RequireNamedParameters + }//end patchRequired() - return $this->fieldMapper->update(entity: $field); - }//end updateFieldDefinition() + /** + * Apply patched `options`, which must be an array or null and fit the + * field's type. + * + * @param MetadataField $field The field being patched. + * @param array $patch The shallow patch. + * + * @return void + * + * @throws InvalidMetadataFieldException When the options do not fit. + */ + private static function patchOptions(MetadataField $field, array $patch): void { + if (array_key_exists(key: 'options', array: $patch) === false) { + return; + } + + $options = $patch['options']; + if ($options !== null && is_array($options) === false) { + throw new InvalidMetadataFieldException( + message: 'Options must be an array of strings or null' + ); + } + + self::assertOptionsShape(type: $field->getType(), options: $options); + $field->setOptionsArray(options: $options); + }//end patchOptions() /** * Delete a field definition (REQ-MDFL-003). @@ -323,52 +387,43 @@ public function setMetadataForDashboard( string $dashboardUuid, array $keyValues, ): array { + $writer = new MetadataValueWriter(valueMapper: $this->valueMapper, dashboardUuid: $dashboardUuid); foreach ($keyValues as $key => $value) { - $stringKey = (string)$key; - if ($stringKey === '') { - throw new InvalidMetadataFieldException( - message: 'Metadata keys must be non-empty strings' - ); - } - - try { - $field = $this->fieldMapper->findByKey(key: $stringKey); - } catch (DoesNotExistException) { - throw new InvalidMetadataFieldException( - message: "Unknown metadata field '" . $stringKey . "'" - ); - } - + $field = $this->fieldForKey(key: (string)$key); $encoded = $this->validationService->validateValue( value: $value, field: $field ); + $writer->write(field: $field, encoded: $encoded); + } - if ($encoded === '' && $field->getRequired() === 0) { - // Empty optional value: remove the row so the read - // payload omits the key (matches scenario "omitted - // keys are not deleted" by allowing explicit empty - // to clear the value — keeps client UX coherent). - $existing = $this->valueMapper->findOne( - dashboardUuid: $dashboardUuid, - fieldId: (int)$field->getId() - ); - if ($existing !== null) { - $this->valueMapper->delete(entity: $existing); - } - - continue; - } + return $this->getMetadataForDashboard(dashboardUuid: $dashboardUuid); + }//end setMetadataForDashboard() - $this->valueMapper->upsert( - dashboardUuid: $dashboardUuid, - fieldId: (int)$field->getId(), - value: $encoded + /** + * The field definition a metadata key names. + * + * @param string $key The metadata key. + * + * @return MetadataField The field. + * + * @throws InvalidMetadataFieldException When the key is empty or unknown. + */ + private function fieldForKey(string $key): MetadataField { + if ($key === '') { + throw new InvalidMetadataFieldException( + message: 'Metadata keys must be non-empty strings' ); - }//end foreach + } - return $this->getMetadataForDashboard(dashboardUuid: $dashboardUuid); - }//end setMetadataForDashboard() + try { + return $this->fieldMapper->findByKey(key: $key); + } catch (DoesNotExistException) { + throw new InvalidMetadataFieldException( + message: "Unknown metadata field '" . $key . "'" + ); + } + }//end fieldForKey() /** * Apply `?metadata.=…` filters to a dashboard list @@ -546,20 +601,43 @@ private static function selectDashboardsByUuid( */ private static function extractUuid(mixed $dashboard): ?string { if (is_object($dashboard) === true && method_exists($dashboard, 'getUuid') === true) { - $uuid = $dashboard->getUuid(); - if ($uuid === null) { - return null; - } + return self::entityUuid(dashboard: $dashboard); + } - return (string)$uuid; + return self::rowUuid(dashboard: $dashboard); + }//end extractUuid() + + /** + * The UUID of a dashboard entity, null when it has none. + * + * @param object $dashboard An object exposing `getUuid()`. + * + * @return string|null The UUID, or null. + */ + private static function entityUuid(object $dashboard): ?string { + $uuid = $dashboard->getUuid(); + if ($uuid === null) { + return null; } + return (string)$uuid; + }//end entityUuid() + + /** + * The `uuid` of a serialised dashboard row, null when it is not a row + * carrying one. + * + * @param mixed $dashboard The candidate row. + * + * @return string|null The UUID, or null. + */ + private static function rowUuid(mixed $dashboard): ?string { if (is_array($dashboard) === true && array_key_exists(key: 'uuid', array: $dashboard) === true) { return (string)$dashboard['uuid']; } return null; - }//end extractUuid() + }//end rowUuid() /** * Per-criterion match logic. @@ -628,19 +706,7 @@ private static function matchesNumberRange(string $stored, mixed $criterion): bo $value = (float)$stored; if (is_array($criterion) === true) { - if (array_key_exists(key: 'min', array: $criterion) === true - && $value < (float)$criterion['min'] - ) { - return false; - } - - if (array_key_exists(key: 'max', array: $criterion) === true - && $value > (float)$criterion['max'] - ) { - return false; - } - - return true; + return self::withinNumberRange(value: $value, range: $criterion); } if (is_numeric(value: $criterion) === false) { @@ -650,6 +716,31 @@ private static function matchesNumberRange(string $stored, mixed $criterion): bo return ($value === (float)$criterion); }//end matchesNumberRange() + /** + * Whether a number lies inside an inclusive `min` / `max` range; + * a missing bound does not limit. + * + * @param float $value The stored number. + * @param array $range The `min` / `max` criterion. + * + * @return bool True when inside the range. + */ + private static function withinNumberRange(float $value, array $range): bool { + if (array_key_exists(key: 'min', array: $range) === true + && $value < (float)$range['min'] + ) { + return false; + } + + if (array_key_exists(key: 'max', array: $range) === true + && $value > (float)$range['max'] + ) { + return false; + } + + return true; + }//end withinNumberRange() + /** * Date range filter — supports `after` / `before` (inclusive) or * exact-match. @@ -661,29 +752,57 @@ private static function matchesNumberRange(string $stored, mixed $criterion): bo */ private static function matchesDateRange(string $stored, mixed $criterion): bool { if (is_array($criterion) === true) { - if (array_key_exists(key: 'after', array: $criterion) === true - && strcmp( - string1: $stored, - string2: (string)$criterion['after'] - ) < 0 - ) { - return false; - } + return self::withinDateRange(stored: $stored, range: $criterion); + } - if (array_key_exists(key: 'before', array: $criterion) === true - && strcmp( - string1: $stored, - string2: (string)$criterion['before'] - ) > 0 - ) { - return false; - } + return ($stored === (string)$criterion); + }//end matchesDateRange() + /** + * Whether an ISO-8601 date lies inside an inclusive `after` / `before` + * range; a missing bound does not limit. + * + * @param string $stored The persisted ISO-8601 date. + * @param array $range The `after` / `before` criterion. + * + * @return bool True when inside the range. + */ + private static function withinDateRange(string $stored, array $range): bool { + return self::isOnOrAfter(stored: $stored, range: $range) === true + && self::isOnOrBefore(stored: $stored, range: $range) === true; + }//end withinDateRange() + + /** + * Whether an ISO-8601 date is not before the range's `after` bound. + * + * @param string $stored The persisted ISO-8601 date. + * @param array $range The `after` / `before` criterion. + * + * @return bool True when there is no bound or the date is on or after it. + */ + private static function isOnOrAfter(string $stored, array $range): bool { + if (array_key_exists(key: 'after', array: $range) === false) { return true; - }//end if + } - return ($stored === (string)$criterion); - }//end matchesDateRange() + return strcmp(string1: $stored, string2: (string)$range['after']) >= 0; + }//end isOnOrAfter() + + /** + * Whether an ISO-8601 date is not after the range's `before` bound. + * + * @param string $stored The persisted ISO-8601 date. + * @param array $range The `after` / `before` criterion. + * + * @return bool True when there is no bound or the date is on or before it. + */ + private static function isOnOrBefore(string $stored, array $range): bool { + if (array_key_exists(key: 'before', array: $range) === false) { + return true; + } + + return strcmp(string1: $stored, string2: (string)$range['before']) <= 0; + }//end isOnOrBefore() /** * Multi-select containment: the stored value is a JSON array; @@ -783,10 +902,7 @@ private static function assertTypeShape(string $type): void { * @throws InvalidMetadataFieldException When mismatched. */ private static function assertOptionsShape(string $type, ?array $options): void { - $isSelect = ($type === MetadataField::TYPE_SELECT - || $type === MetadataField::TYPE_MULTI_SELECT); - - if ($isSelect === true) { + if (self::isSelectType(type: $type) === true) { self::assertSelectOptions(options: $options); return; } @@ -798,6 +914,18 @@ private static function assertOptionsShape(string $type, ?array $options): void } }//end assertOptionsShape() + /** + * Whether a field type takes a list of options (select / multi-select). + * + * @param string $type The field type. + * + * @return bool True for the select types. + */ + private static function isSelectType(string $type): bool { + return ($type === MetadataField::TYPE_SELECT + || $type === MetadataField::TYPE_MULTI_SELECT); + }//end isSelectType() + /** * Validate the `options` array of a select / multi-select field. * @@ -819,6 +947,19 @@ private static function assertSelectOptions(?array $options): void { ); } + self::assertOptionEntries(options: $options); + }//end assertSelectOptions() + + /** + * Validate that every option is a non-empty string. + * + * @param array $options The candidate options. + * + * @return void + * + * @throws InvalidMetadataFieldException On a non-string or empty entry. + */ + private static function assertOptionEntries(array $options): void { // Defensive runtime check: callers may pass non-string entries. foreach ($options as $option) { if (is_string($option) === false || $option === '') { @@ -827,5 +968,5 @@ private static function assertSelectOptions(?array $options): void { ); } } - }//end assertSelectOptions() + }//end assertOptionEntries() }//end class diff --git a/lib/Service/MetadataValueWriter.php b/lib/Service/MetadataValueWriter.php new file mode 100644 index 000000000..23a2bb5ab --- /dev/null +++ b/lib/Service/MetadataValueWriter.php @@ -0,0 +1,95 @@ + + * @copyright 2026 Conduction b.v. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * @version GIT:auto + * @link https://conduction.nl + * + * SPDX-FileCopyrightText: 2024 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + * + * @spec openspec/specs/dashboard-metadata-fields/spec.md + */ + +declare(strict_types=1); + +namespace OCA\LaunchPad\Service; + +use OCA\LaunchPad\Db\MetadataField; +use OCA\LaunchPad\Db\MetadataValueMapper; + +/** + * Writes encoded metadata values for one dashboard. + * + * Built by {@see MetadataService::setMetadataForDashboard()} for one + * write; never shared. + * + * @spec openspec/specs/dashboard-metadata-fields/spec.md + */ +final class MetadataValueWriter { + /** + * Constructor. + * + * @param MetadataValueMapper $valueMapper The value mapper. + * @param string $dashboardUuid The dashboard the values belong to. + */ + public function __construct( + private readonly MetadataValueMapper $valueMapper, + private readonly string $dashboardUuid, + ) { + }//end __construct() + + /** + * Write one field's encoded value. + * + * @param MetadataField $field The field definition. + * @param string $encoded The validated, encoded value. + * + * @return void + * + * @spec openspec/specs/dashboard-metadata-fields/spec.md + */ + public function write(MetadataField $field, string $encoded): void { + if ($encoded === '' && $field->getRequired() === 0) { + // Empty optional value: remove the row so the read + // payload omits the key (matches scenario "omitted + // keys are not deleted" by allowing explicit empty + // to clear the value — keeps client UX coherent). + $this->clear(fieldId: (int)$field->getId()); + return; + } + + $this->valueMapper->upsert( + dashboardUuid: $this->dashboardUuid, + fieldId: (int)$field->getId(), + value: $encoded + ); + }//end write() + + /** + * Remove the dashboard's row for a field, if it has one. + * + * @param integer $fieldId The field id. + * + * @return void + */ + private function clear(int $fieldId): void { + $existing = $this->valueMapper->findOne( + dashboardUuid: $this->dashboardUuid, + fieldId: $fieldId + ); + if ($existing !== null) { + $this->valueMapper->delete(entity: $existing); + } + }//end clear() +}//end class