From 053c272fe4b18eb5c5ba9f252734ef547953c54e Mon Sep 17 00:00:00 2001 From: Sebastian Krupinski Date: Sat, 20 Jun 2026 16:34:08 -0400 Subject: [PATCH] refactor: server side code to mirror common design Signed-off-by: Sebastian Krupinski --- lib/Controllers/DefaultController.php | 634 ++++++++------- lib/Manager.php | 1015 ++++++++++++++++--------- 2 files changed, 1024 insertions(+), 625 deletions(-) diff --git a/lib/Controllers/DefaultController.php b/lib/Controllers/DefaultController.php index 1d33a73..80cf87c 100644 --- a/lib/Controllers/DefaultController.php +++ b/lib/Controllers/DefaultController.php @@ -11,10 +11,17 @@ namespace KTXM\PeopleManager\Controllers; use InvalidArgumentException; use KTXC\Http\Response\JsonResponse; +use KTXC\Http\Response\Response; +use KTXC\Http\Response\StreamedNdJsonResponse; use KTXC\SessionIdentity; use KTXC\SessionTenant; use KTXF\Controller\ControllerAbstract; -use KTXF\Resource\Selector\SourceSelector; +use KTXF\Json\JsonSerializable; +use KTXF\Resource\Identifier\CollectionIdentifier; +use KTXF\Resource\Identifier\EntityIdentifier; +use KTXF\Resource\Identifier\ResourceIdentifier; +use KTXF\Resource\Identifier\ResourceIdentifiers; +use KTXF\Resource\Identifier\ServiceIdentifier; use KTXF\Routing\Attributes\AuthenticatedRoute; use KTXM\PeopleManager\Manager; use Psr\Log\LoggerInterface; @@ -22,20 +29,21 @@ use Throwable; class DefaultController extends ControllerAbstract { - private const ERR_MISSING_PROVIDER = 'Missing parameter: provider'; + private const ERR_MISSING_PROVIDER = 'Missing parameter: provider'; private const ERR_MISSING_IDENTIFIER = 'Missing parameter: identifier'; private const ERR_MISSING_SERVICE = 'Missing parameter: service'; - private const ERR_MISSING_COLLECTION = 'Missing parameter: collection'; private const ERR_MISSING_DATA = 'Missing parameter: data'; private const ERR_MISSING_SOURCES = 'Missing parameter: sources'; - private const ERR_MISSING_IDENTIFIERS = 'Missing parameter: identifiers'; + private const ERR_MISSING_TARGET = 'Missing parameter: target'; + private const ERR_MISSING_TARGETS = 'Missing parameter: targets'; private const ERR_INVALID_OPERATION = 'Invalid operation: '; private const ERR_INVALID_PROVIDER = 'Invalid parameter: provider must be a string'; private const ERR_INVALID_SERVICE = 'Invalid parameter: service must be a string'; + private const ERR_INVALID_COLLECTION = 'Invalid parameter: collection must be a string'; private const ERR_INVALID_IDENTIFIER = 'Invalid parameter: identifier must be a string'; - private const ERR_INVALID_COLLECTION = 'Invalid parameter: collection must be a string or integer'; private const ERR_INVALID_SOURCES = 'Invalid parameter: sources must be an array'; - private const ERR_INVALID_IDENTIFIERS = 'Invalid parameter: identifiers must be an array'; + private const ERR_INVALID_TARGET = 'Invalid parameter: target must be an array'; + private const ERR_INVALID_TARGETS = 'Invalid parameter: targets must be an array'; private const ERR_INVALID_DATA = 'Invalid parameter: data must be an array'; public function __construct( @@ -65,7 +73,7 @@ class DefaultController extends ControllerAbstract { string|null $operation = null, array|null $data = null, string|null $user = null - ): JsonResponse { + ): Response { // authorize request $tenantId = $this->tenantIdentity->identifier(); @@ -74,7 +82,12 @@ class DefaultController extends ControllerAbstract { try { if ($operation !== null) { - $result = $this->processOperation($tenantId, $userId, $operation, $data ?? [], []); + $result = $this->processOperation($tenantId, $userId, $operation, $data ?? [], $version, $transaction); + + if ($result instanceof Response) { + return $result; + } + return new JsonResponse([ 'version' => $version, 'transaction' => $transaction, @@ -101,10 +114,11 @@ class DefaultController extends ControllerAbstract { } } + /** * Process a single operation */ - private function processOperation(string $tenantId, string $userId, string $operation, array $data): mixed { + private function processOperation(string $tenantId, string $userId, string $operation, array $data, int $version = 1, string $transaction = ''): mixed { return match ($operation) { // Provider operations 'provider.list' => $this->providerList($tenantId, $userId, $data), @@ -118,7 +132,7 @@ class DefaultController extends ControllerAbstract { 'service.create' => $this->serviceCreate($tenantId, $userId, $data), 'service.update' => $this->serviceUpdate($tenantId, $userId, $data), 'service.delete' => $this->serviceDelete($tenantId, $userId, $data), - 'service.test' => $this->serviceTest($tenantId, $userId, $data), + 'service.test' => throw new InvalidArgumentException('Operation not implemented: ' . $operation), // Collection operations 'collection.list' => $this->collectionList($tenantId, $userId, $data), @@ -129,15 +143,16 @@ class DefaultController extends ControllerAbstract { 'collection.delete' => $this->collectionDelete($tenantId, $userId, $data), // Entity operations - 'entity.list' => $this->entityList($tenantId, $userId, $data), + 'entity.listBulk' => $this->entityListBulk($tenantId, $userId, $data), + 'entity.listStream' => $this->entityListStream($tenantId, $userId, $data, $version, $transaction), 'entity.fetch' => $this->entityFetch($tenantId, $userId, $data), 'entity.extant' => $this->entityExtant($tenantId, $userId, $data), + 'entity.delta' => $this->entityDelta($tenantId, $userId, $data), 'entity.create' => $this->entityCreate($tenantId, $userId, $data), 'entity.update' => $this->entityUpdate($tenantId, $userId, $data), 'entity.delete' => $this->entityDelete($tenantId, $userId, $data), - 'entity.delta' => $this->entityDelta($tenantId, $userId, $data), - 'entity.move' => throw new InvalidArgumentException('Operation not implemented: ' . $operation), - 'entity.copy' => throw new InvalidArgumentException('Operation not implemented: ' . $operation), + 'entity.move' => $this->entityMove($tenantId, $userId, $data), + 'entity.copy' => $this->entityCopy($tenantId, $userId, $data), default => throw new InvalidArgumentException(self::ERR_INVALID_OPERATION . $operation) }; @@ -147,55 +162,66 @@ class DefaultController extends ControllerAbstract { private function providerList(string $tenantId, string $userId, array $data): mixed { - $sources = null; - if (isset($data['sources']) && is_array($data['sources'])) { - $sources = new SourceSelector(); - $sources->jsonDeserialize($data['sources']); - } + if (isset($data['targets'])) { + if (!is_array($data['targets'])) { + throw new InvalidArgumentException(self::ERR_INVALID_TARGETS); + } + + foreach ($data['targets'] as $target) { + if (!is_string($target)) { + throw new InvalidArgumentException(self::ERR_INVALID_TARGETS); + } + } + } - return $this->manager->providerList($tenantId, $userId, $sources); + return $this->manager->providerList($tenantId, $userId, $data['targets'] ?? []); } private function providerFetch(string $tenantId, string $userId, array $data): mixed { - if (!isset($data['identifier'])) { - throw new InvalidArgumentException(self::ERR_MISSING_IDENTIFIER); + if (!isset($data['target'])) { + throw new InvalidArgumentException(self::ERR_MISSING_TARGET); } - if (!is_string($data['identifier'])) { - throw new InvalidArgumentException(self::ERR_INVALID_IDENTIFIER); + if (!is_string($data['target'])) { + throw new InvalidArgumentException(self::ERR_INVALID_TARGET); } - return $this->manager->providerFetch($tenantId, $userId, $data['identifier']); + return $this->manager->providerFetch($tenantId, $userId, $data['target']); + } private function providerExtant(string $tenantId, string $userId, array $data): mixed { - if (!isset($data['sources'])) { - throw new InvalidArgumentException(self::ERR_MISSING_SOURCES); - } - if (!is_array($data['sources'])) { - throw new InvalidArgumentException(self::ERR_INVALID_SOURCES); - } - $sources = new SourceSelector(); - $sources->jsonDeserialize($data['sources']); + if (!isset($data['targets'])) { + throw new InvalidArgumentException(self::ERR_MISSING_TARGETS); + } + + foreach ($data['targets'] as $target) { + if (!is_string($target)) { + throw new InvalidArgumentException(self::ERR_INVALID_TARGETS); + } + } - return $this->manager->providerExtant($tenantId, $userId, $sources); + return $this->manager->providerExtant($tenantId, $userId, $data['targets']); } - // ==================== Service Operations ===================== private function serviceList(string $tenantId, string $userId, array $data): mixed { - $sources = null; - if (isset($data['sources']) && is_array($data['sources'])) { - $sources = new SourceSelector(); - $sources->jsonDeserialize($data['sources']); + $targets = null; + if (isset($data['targets']) && is_array($data['targets'])) { + $targets = ResourceIdentifiers::fromArray($data['targets']); + foreach ($targets as $target) { + if (!$target instanceof CollectionIdentifier && !$target instanceof ServiceIdentifier) { + throw new InvalidArgumentException('Invalid parameter: targets must contain provider:service, provider:service:collection, or provider:service:collection:entity identifiers'); + } + } } - return $this->manager->serviceList($tenantId, $userId, $sources); + return $this->manager->serviceList($tenantId, $userId, $targets); } @@ -219,16 +245,20 @@ class DefaultController extends ControllerAbstract { private function serviceExtant(string $tenantId, string $userId, array $data): mixed { - if (!isset($data['sources'])) { - throw new InvalidArgumentException(self::ERR_MISSING_SOURCES); + if (!isset($data['targets'])) { + throw new InvalidArgumentException(self::ERR_MISSING_TARGETS); } - if (!is_array($data['sources'])) { - throw new InvalidArgumentException(self::ERR_INVALID_SOURCES); + if (!is_array($data['targets'])) { + throw new InvalidArgumentException(self::ERR_INVALID_TARGETS); } - $sources = new SourceSelector(); - $sources->jsonDeserialize($data['sources']); - - return $this->manager->serviceExtant($tenantId, $userId, $sources); + $targets = ResourceIdentifiers::fromArray($data['targets']); + foreach ($targets as $target) { + if (!$target instanceof ServiceIdentifier) { + throw new InvalidArgumentException('Invalid parameter: targets must contain provider:service identifiers'); + } + } + + return $this->manager->serviceExtant($tenantId, $userId, $targets); } private function serviceCreate(string $tenantId, string $userId, array $data): mixed { @@ -272,13 +302,17 @@ class DefaultController extends ControllerAbstract { if (!is_array($data['data'])) { throw new InvalidArgumentException(self::ERR_INVALID_DATA); } + if (isset($data['delta']) && !is_bool($data['delta'])) { + throw new InvalidArgumentException('Invalid parameter: delta must be a boolean'); + } return $this->manager->serviceUpdate( $tenantId, $userId, $data['provider'], $data['identifier'], - $data['data'] + $data['data'], + $data['delta'] ?? false, ); } @@ -304,36 +338,17 @@ class DefaultController extends ControllerAbstract { ); } - private function serviceTest(string $tenantId, string $userId, array $data): mixed { - - if (!isset($data['provider'])) { - throw new InvalidArgumentException(self::ERR_MISSING_PROVIDER); - } - if (!is_string($data['provider'])) { - throw new InvalidArgumentException(self::ERR_INVALID_PROVIDER); - } - - if (!isset($data['identifier']) && !isset($data['location']) && !isset($data['identity'])) { - throw new InvalidArgumentException('Either a service identifier or location and identity must be provided for service test'); - } - - return $this->manager->serviceTest( - $tenantId, - $userId, - $data['provider'], - $data['identifier'] ?? null, - $data['location'] ?? null, - $data['identity'] ?? null, - ); - } - // ==================== Collection Operations ==================== private function collectionList(string $tenantId, string $userId, array $data): mixed { $sources = null; if (isset($data['sources']) && is_array($data['sources'])) { - $sources = new SourceSelector(); - $sources->jsonDeserialize($data['sources']); + $sources = ResourceIdentifiers::fromArray($data['sources']); + foreach ($sources as $source) { + if (!$source instanceof CollectionIdentifier && !$source instanceof ServiceIdentifier) { + throw new InvalidArgumentException('Invalid parameter: sources must contain provider:service, provider:service:collection, or provider:service:collection:entity identifiers'); + } + } } $filter = $data['filter'] ?? null; @@ -342,47 +357,45 @@ class DefaultController extends ControllerAbstract { return $this->manager->collectionList($tenantId, $userId, $sources, $filter, $sort); } - private function collectionExtant(string $tenantId, string $userId, array $data): mixed { - if (!isset($data['sources'])) { - throw new InvalidArgumentException(self::ERR_MISSING_SOURCES); - } - if (!is_array($data['sources'])) { - throw new InvalidArgumentException(self::ERR_INVALID_SOURCES); - } - - $sources = new SourceSelector(); - $sources->jsonDeserialize($data['sources']); - - return $this->manager->collectionExtant($tenantId, $userId, $sources); - } - private function collectionFetch(string $tenantId, string $userId, array $data): mixed { - if (!isset($data['provider'])) { - throw new InvalidArgumentException(self::ERR_MISSING_PROVIDER); + if (!isset($data['targets'])) { + throw new InvalidArgumentException(self::ERR_MISSING_TARGETS); } - if (!is_string($data['provider'])) { - throw new InvalidArgumentException(self::ERR_INVALID_PROVIDER); + if (!is_array($data['targets'])) { + throw new InvalidArgumentException(self::ERR_INVALID_TARGETS); } - if (!isset($data['service'])) { - throw new InvalidArgumentException(self::ERR_MISSING_SERVICE); + + $targetIdentifiers = ResourceIdentifiers::fromArray($data['targets']); + foreach ($targetIdentifiers as $targetIdentifier) { + if (!$targetIdentifier instanceof CollectionIdentifier) { + throw new InvalidArgumentException('Invalid parameter: target must be provider:service:collection'); + } } - if (!is_string($data['service'])) { - throw new InvalidArgumentException(self::ERR_INVALID_SERVICE); - } - if (!isset($data['identifier'])) { - throw new InvalidArgumentException(self::ERR_MISSING_IDENTIFIER); - } - if (!is_string($data['identifier']) && !is_int($data['identifier'])) { - throw new InvalidArgumentException(self::ERR_INVALID_COLLECTION); - } - - return $this->manager->collectionFetch( + + $list = $this->manager->collectionFetch( $tenantId, $userId, - $data['provider'], - $data['service'], - $data['identifier'] + $targetIdentifier ); + return $list; + } + + private function collectionExtant(string $tenantId, string $userId, array $data): mixed { + if (!isset($data['targets'])) { + throw new InvalidArgumentException(self::ERR_MISSING_TARGETS); + } + if (!is_array($data['targets'])) { + throw new InvalidArgumentException(self::ERR_INVALID_TARGETS); + } + + $sources = ResourceIdentifiers::fromArray($data['targets']); + foreach ($sources as $source) { + if (!$source instanceof CollectionIdentifier) { + throw new InvalidArgumentException('Invalid parameter: targets must contain provider:service, provider:service:collection, or provider:service:collection:entity identifiers'); + } + } + + return $this->manager->collectionExtant($tenantId, $userId, $sources); } private function collectionCreate(string $tenantId, string $userId, array $data): mixed { @@ -398,8 +411,8 @@ class DefaultController extends ControllerAbstract { if (!is_string($data['service'])) { throw new InvalidArgumentException(self::ERR_INVALID_SERVICE); } - if (isset($data['collection']) && !is_string($data['collection']) && !is_int($data['collection'])) { - throw new InvalidArgumentException(self::ERR_INVALID_COLLECTION); + if (isset($data['target']) && !is_string($data['target']) && !is_int($data['target'])) { + throw new InvalidArgumentException(self::ERR_INVALID_TARGET); } if (!isset($data['properties'])) { throw new InvalidArgumentException(self::ERR_MISSING_DATA); @@ -407,35 +420,30 @@ class DefaultController extends ControllerAbstract { if (!is_array($data['properties'])) { throw new InvalidArgumentException(self::ERR_INVALID_DATA); } + + if (isset($data['target'])) { + $targetIdentifier = ResourceIdentifier::fromString($data['target']); + if (!$targetIdentifier instanceof CollectionIdentifier) { + throw new InvalidArgumentException('Invalid parameter: target must be provider:service:collection'); + } + } return $this->manager->collectionCreate( $tenantId, $userId, $data['provider'], $data['service'], - $data['collection'] ?? null, + $targetIdentifier ?? null, $data['properties'] ); } private function collectionUpdate(string $tenantId, string $userId, array $data): mixed { - if (!isset($data['provider'])) { - throw new InvalidArgumentException(self::ERR_MISSING_PROVIDER); + if (!isset($data['target'])) { + throw new InvalidArgumentException(self::ERR_MISSING_TARGET); } - if (!is_string($data['provider'])) { - throw new InvalidArgumentException(self::ERR_INVALID_PROVIDER); - } - if (!isset($data['service'])) { - throw new InvalidArgumentException(self::ERR_MISSING_SERVICE); - } - if (!is_string($data['service'])) { - throw new InvalidArgumentException(self::ERR_INVALID_SERVICE); - } - if (!isset($data['identifier'])) { - throw new InvalidArgumentException(self::ERR_MISSING_IDENTIFIER); - } - if (!is_string($data['identifier']) && !is_int($data['identifier'])) { - throw new InvalidArgumentException(self::ERR_INVALID_COLLECTION); + if (!is_string($data['target'])) { + throw new InvalidArgumentException(self::ERR_INVALID_TARGET); } if (!isset($data['properties'])) { throw new InvalidArgumentException(self::ERR_MISSING_DATA); @@ -443,50 +451,54 @@ class DefaultController extends ControllerAbstract { if (!is_array($data['properties'])) { throw new InvalidArgumentException(self::ERR_INVALID_DATA); } + + $targetIdentifier = ResourceIdentifier::fromString($data['target']); + if (!$targetIdentifier instanceof CollectionIdentifier) { + throw new InvalidArgumentException('Invalid parameter: target must be provider:service:collection'); + } return $this->manager->collectionUpdate( $tenantId, $userId, - $data['provider'], - $data['service'], - $data['identifier'], + $targetIdentifier, $data['properties'] ); } private function collectionDelete(string $tenantId, string $userId, array $data): mixed { - if (!isset($data['provider'])) { - throw new InvalidArgumentException(self::ERR_MISSING_PROVIDER); + if (!isset($data['target'])) { + throw new InvalidArgumentException(self::ERR_MISSING_TARGET); } - if (!is_string($data['provider'])) { - throw new InvalidArgumentException(self::ERR_INVALID_PROVIDER); + if (!is_string($data['target'])) { + throw new InvalidArgumentException(self::ERR_INVALID_TARGET); } - if (!isset($data['service'])) { - throw new InvalidArgumentException(self::ERR_MISSING_SERVICE); - } - if (!is_string($data['service'])) { - throw new InvalidArgumentException(self::ERR_INVALID_SERVICE); - } - if (!isset($data['identifier'])) { - throw new InvalidArgumentException(self::ERR_MISSING_IDENTIFIER); - } - if (!is_string($data['identifier']) && !is_int($data['identifier'])) { - throw new InvalidArgumentException(self::ERR_INVALID_IDENTIFIER); + + $targetIdentifier = ResourceIdentifier::fromString($data['target']); + if (!$targetIdentifier instanceof CollectionIdentifier) { + throw new InvalidArgumentException('Invalid parameter: target must be provider:service:collection'); } - return $this->manager->collectionDelete( - $tenantId, - $userId, - $data['provider'], - $data['service'], - $data['identifier'], - $data['options'] ?? [] - ); + $result = $this->manager->collectionDelete($tenantId, $userId, $targetIdentifier, $data['options'] ?? [] ); + + if (is_bool($result)) { + return [ + 'disposition' => 'deleted' + ]; + } + + if ($result instanceof JsonSerializable) { + return [ + 'disposition' => 'moved', + 'mutation' => $result + ]; + } + + return $result; } // ==================== Entity Operations ==================== - private function entityList(string $tenantId, string $userId, array $data): mixed { + private function entityListBulk(string $tenantId, string $userId, array $data): mixed { if (!isset($data['sources'])) { throw new InvalidArgumentException(self::ERR_MISSING_SOURCES); } @@ -494,131 +506,199 @@ class DefaultController extends ControllerAbstract { throw new InvalidArgumentException(self::ERR_INVALID_SOURCES); } - $sources = new SourceSelector(); - $sources->jsonDeserialize($data['sources']); + $sources = ResourceIdentifiers::fromArray($data['sources']); + foreach ($sources as $source) { + if (!$source instanceof EntityIdentifier) { + throw new InvalidArgumentException('Invalid parameter: sources must contain provider:service:collection:entity identifiers'); + } + } $filter = $data['filter'] ?? null; $sort = $data['sort'] ?? null; $range = $data['range'] ?? null; - return $this->manager->entityList($tenantId, $userId, $sources, $filter, $sort, $range); + return $this->manager->entityListBulk($tenantId, $userId, $sources, $filter, $sort, $range); } + private function entityListStream(string $tenantId, string $userId, array $data, int $version, string $transaction): StreamedNdJsonResponse { + if (!isset($data['sources'])) { + throw new InvalidArgumentException(self::ERR_MISSING_SOURCES); + } + if (!is_array($data['sources'])) { + throw new InvalidArgumentException(self::ERR_INVALID_SOURCES); + } + + $sources = ResourceIdentifiers::fromArray($data['sources']); + foreach ($sources as $source) { + if (!$source instanceof ServiceIdentifier && !$source instanceof CollectionIdentifier) { + throw new InvalidArgumentException('Invalid parameter: sources must contain provider:service or provider:service:collection identifiers'); + } + } + + $filter = $data['filter'] ?? null; + $sort = $data['sort'] ?? null; + $range = $data['range'] ?? null; + + $entityGenerator = $this->manager->entityListStream($tenantId, $userId, $sources, $filter, $sort, $range); + $logger = $this->logger; + + $responseGenerator = (function () use ($entityGenerator, $version, $transaction, $logger): \Generator { + yield ['type' => 'control', 'status' => 'start', 'version' => $version, 'transaction' => $transaction]; + + $total = 0; + try { + foreach ($entityGenerator as $entity) { + if (!$entity instanceof JsonSerializable) { + continue; + } + yield [ + 'type' => 'data', + 'data' => $entity->jsonSerialize() + ]; + $total++; + } + } catch (\Throwable $t) { + $logger->error('Error streaming entities', ['exception' => $t]); + yield ['type' => 'error', 'message' => $t->getMessage()]; + return; + } + + yield ['type' => 'control', 'status' => 'end', 'total' => $total]; + })(); + + return new StreamedNdJsonResponse($responseGenerator, 1, 200, ['Content-Type' => 'application/json']); + } + private function entityFetch(string $tenantId, string $userId, array $data): mixed { - if (!isset($data['provider'])) { - throw new InvalidArgumentException(self::ERR_MISSING_PROVIDER); + if (!isset($data['targets'])) { + throw new InvalidArgumentException(self::ERR_MISSING_TARGETS); } - if (!is_string($data['provider'])) { - throw new InvalidArgumentException(self::ERR_INVALID_PROVIDER); + if (!is_array($data['targets'])) { + throw new InvalidArgumentException(self::ERR_INVALID_TARGETS); } - if (!isset($data['service'])) { - throw new InvalidArgumentException(self::ERR_MISSING_SERVICE); - } - if (!is_string($data['service'])) { - throw new InvalidArgumentException(self::ERR_INVALID_SERVICE); - } - if (!isset($data['collection'])) { - throw new InvalidArgumentException(self::ERR_MISSING_COLLECTION); - } - if (!is_string($data['collection']) && !is_int($data['collection'])) { - throw new InvalidArgumentException(self::ERR_INVALID_COLLECTION); - } - if (!isset($data['identifiers'])) { - throw new InvalidArgumentException(self::ERR_MISSING_IDENTIFIERS); - } - if (!is_array($data['identifiers'])) { - throw new InvalidArgumentException(self::ERR_INVALID_IDENTIFIERS); + + $targets = ResourceIdentifiers::fromArray($data['targets']); + foreach ($targets as $target) { + if (!$target instanceof EntityIdentifier) { + throw new InvalidArgumentException('Invalid parameter: targets must contain provider:service:collection:entity identifiers'); + } } - return $this->manager->entityFetch( + return $this->manager->entityFetchBulk( $tenantId, $userId, - $data['provider'], - $data['service'], - $data['collection'], - $data['identifiers'] + ...$targets->all() ); } private function entityExtant(string $tenantId, string $userId, array $data): mixed { - if (!isset($data['sources'])) { - throw new InvalidArgumentException(self::ERR_MISSING_SOURCES); + if (!isset($data['targets'])) { + throw new InvalidArgumentException(self::ERR_MISSING_TARGETS); } - if (!is_array($data['sources'])) { - throw new InvalidArgumentException(self::ERR_INVALID_SOURCES); + if (!is_array($data['targets'])) { + throw new InvalidArgumentException(self::ERR_INVALID_TARGETS); } - $sources = new SourceSelector(); - $sources->jsonDeserialize($data['sources']); - - return $this->manager->entityExtant($tenantId, $userId, $sources); + $targets = ResourceIdentifiers::fromArray($data['targets']); + foreach ($targets as $target) { + if (!$target instanceof CollectionIdentifier && !$target instanceof EntityIdentifier) { + throw new InvalidArgumentException('Invalid parameter: targets must contain provider:service:collection or provider:service:collection:entity identifiers'); + } + } + + return $this->manager->entityExtant($tenantId, $userId, $targets); } - private function entityCreate(string $tenantId, string $userId, array $data = []): mixed { - - if (!isset($data['provider']) || !is_string($data['provider'])) { - throw new InvalidArgumentException(self::ERR_INVALID_PROVIDER); - } - if (!isset($data['service']) || !is_string($data['service'])) { - throw new InvalidArgumentException(self::ERR_INVALID_SERVICE); - } - if (!isset($data['collection'])) { - throw new InvalidArgumentException(self::ERR_INVALID_COLLECTION); - } - $properties = $data['properties'] ?? $data['data'] ?? null; - if (!is_array($properties)) { - throw new InvalidArgumentException('Invalid parameter: properties must be an array'); - } - $options = $data['options'] ?? []; - - return $this->manager->entityCreate($tenantId, $userId, $data['provider'], $data['service'], $data['collection'], $properties, $options); - - } - - private function entityUpdate(string $tenantId, string $userId, array $data = []): mixed { - - if (!isset($data['provider']) || !is_string($data['provider'])) { - throw new InvalidArgumentException(self::ERR_INVALID_PROVIDER); - } - if (!isset($data['service']) || !is_string($data['service'])) { - throw new InvalidArgumentException(self::ERR_INVALID_SERVICE); - } - if (!isset($data['collection'])) { - throw new InvalidArgumentException(self::ERR_INVALID_COLLECTION); - } - if (!isset($data['identifier'])) { - throw new InvalidArgumentException(self::ERR_INVALID_IDENTIFIER); - } - $properties = $data['properties'] ?? $data['data'] ?? null; - if (!is_array($properties)) { - throw new InvalidArgumentException('Invalid parameter: properties must be an array'); - } - - return $this->manager->entityUpdate($tenantId, $userId, $data['provider'], $data['service'], $data['collection'], $data['identifier'], $properties); - - } - - private function entityDelete(string $tenantId, string $userId, array $data = []): mixed { - - if (!isset($data['provider']) || !is_string($data['provider'])) { - throw new InvalidArgumentException(self::ERR_INVALID_PROVIDER); - } - if (!isset($data['service']) || !is_string($data['service'])) { - throw new InvalidArgumentException(self::ERR_INVALID_SERVICE); - } - if (!isset($data['collection'])) { - throw new InvalidArgumentException(self::ERR_INVALID_COLLECTION); - } - if (!isset($data['identifier'])) { - throw new InvalidArgumentException(self::ERR_INVALID_IDENTIFIER); - } - - return $this->manager->entityDelete($tenantId, $userId, $data['provider'], $data['service'], $data['collection'], $data['identifier']); - - } - private function entityDelta(string $tenantId, string $userId, array $data): mixed { + if (!isset($data['targets'])) { + throw new InvalidArgumentException(self::ERR_MISSING_TARGETS); + } + if (!is_array($data['targets'])) { + throw new InvalidArgumentException(self::ERR_INVALID_TARGETS); + } + + $targets = ResourceIdentifiers::fromArray($data['targets']); + foreach ($targets as $target) { + if (!$target instanceof CollectionIdentifier && !$target instanceof EntityIdentifier) { + throw new InvalidArgumentException('Invalid parameter: targets must contain provider:service:collection or provider:service:collection:signature identifiers'); + } + } + + return $this->manager->entityDelta($tenantId, $userId, $targets); + } + + private function entityCreate(string $tenantId, string $userId, array $data = []): mixed { + if (!isset($data['target'])) { + throw new InvalidArgumentException(self::ERR_MISSING_TARGET); + } + if (!is_string($data['target'])) { + throw new InvalidArgumentException(self::ERR_INVALID_TARGET); + } + if (!isset($data['properties'])) { + throw new InvalidArgumentException(self::ERR_MISSING_DATA); + } + if (!is_array($data['properties'])) { + throw new InvalidArgumentException(self::ERR_INVALID_DATA); + } + + $target = ResourceIdentifier::fromString($data['target']); + if (!$target instanceof CollectionIdentifier) { + throw new InvalidArgumentException('Invalid parameter: target must be provider:service:collection'); + } + + $options = $data['options'] ?? []; + + return $this->manager->entityCreate($tenantId, $userId, $target, $data['properties'], $options); + } + + private function entityUpdate(string $tenantId, string $userId, array $data = []): mixed { + if (!isset($data['target'])) { + throw new InvalidArgumentException(self::ERR_MISSING_TARGET); + } + if (!is_string($data['target'])) { + throw new InvalidArgumentException(self::ERR_INVALID_TARGET); + } + if (!isset($data['properties'])) { + throw new InvalidArgumentException(self::ERR_MISSING_DATA); + } + if (!is_array($data['properties'])) { + throw new InvalidArgumentException(self::ERR_INVALID_DATA); + } + + $target = ResourceIdentifier::fromString($data['target']); + if (!$target instanceof EntityIdentifier) { + throw new InvalidArgumentException('Invalid parameter: target must be provider:service:collection:entity'); + } + + return $this->manager->entityModify($tenantId, $userId, $target, $data['properties']); + } + + private function entityDelete(string $tenantId, string $userId, array $data): mixed { + if (!isset($data['targets'])) { + throw new InvalidArgumentException(self::ERR_MISSING_TARGETS); + } + if (!is_array($data['targets'])) { + throw new InvalidArgumentException(self::ERR_INVALID_TARGETS); + } + + $targets = ResourceIdentifiers::fromArray($data['targets']); + foreach ($targets as $target) { + if (!$target instanceof EntityIdentifier) { + throw new InvalidArgumentException('Invalid parameter: targets must contain provider:service:collection:entity identifiers'); + } + } + + return $this->manager->entityDelete($tenantId, $userId, ...$targets->all()); + } + + private function entityMove(string $tenantId, string $userId, array $data): mixed { + if (!isset($data['target'])) { + throw new InvalidArgumentException(self::ERR_MISSING_TARGET); + } + if (!is_string($data['target'])) { + throw new InvalidArgumentException(self::ERR_INVALID_TARGET); + } if (!isset($data['sources'])) { throw new InvalidArgumentException(self::ERR_MISSING_SOURCES); } @@ -626,10 +706,48 @@ class DefaultController extends ControllerAbstract { throw new InvalidArgumentException(self::ERR_INVALID_SOURCES); } - $sources = new SourceSelector(); - $sources->jsonDeserialize($data['sources']); - - return $this->manager->entityDelta($tenantId, $userId, $sources); + $target = ResourceIdentifier::fromString($data['target']); + if (!$target instanceof CollectionIdentifier) { + throw new InvalidArgumentException('Invalid parameter: target must be provider:service:collection'); + } + + $sources = ResourceIdentifiers::fromArray($data['sources']); + foreach ($sources as $source) { + if (!$source instanceof EntityIdentifier) { + throw new InvalidArgumentException('Invalid parameter: sources must contain provider:service:collection:entity identifiers'); + } + } + + return $this->manager->entityMove($tenantId, $userId, $target, ...$sources->all()); + } + + private function entityCopy(string $tenantId, string $userId, array $data): mixed { + if (!isset($data['target'])) { + throw new InvalidArgumentException(self::ERR_MISSING_TARGET); + } + if (!is_string($data['target'])) { + throw new InvalidArgumentException(self::ERR_INVALID_TARGET); + } + if (!isset($data['sources'])) { + throw new InvalidArgumentException(self::ERR_MISSING_SOURCES); + } + if (!is_array($data['sources'])) { + throw new InvalidArgumentException(self::ERR_INVALID_SOURCES); + } + + $target = ResourceIdentifier::fromString($data['target']); + if (!$target instanceof CollectionIdentifier) { + throw new InvalidArgumentException('Invalid parameter: target must be provider:service:collection'); + } + + $sources = ResourceIdentifiers::fromArray($data['sources']); + foreach ($sources as $source) { + if (!$source instanceof EntityIdentifier) { + throw new InvalidArgumentException('Invalid parameter: sources must contain provider:service:collection:entity identifiers'); + } + } + + return $this->manager->entityCopy($tenantId, $userId, $target, ...$sources->all()); } } \ No newline at end of file diff --git a/lib/Manager.php b/lib/Manager.php index 23f33d3..931aa34 100644 --- a/lib/Manager.php +++ b/lib/Manager.php @@ -7,25 +7,29 @@ namespace KTXM\PeopleManager; use InvalidArgumentException; use KTXC\Resource\ProviderManager; use KTXF\People\Collection\CollectionBaseInterface; -use KTXF\People\Collection\CollectionMutableInterface; +use KTXF\People\Collection\CollectionPropertiesMutableInterface; use KTXF\People\Entity\EntityBaseInterface; -use KTXF\People\Entity\EntityMutableInterface; +use KTXF\People\Entity\EntityPropertiesMutableInterface; use KTXF\People\Provider\ProviderBaseInterface; use KTXF\People\Provider\ProviderServiceMutateInterface; use KTXF\People\Service\ServiceBaseInterface; use KTXF\People\Service\ServiceCollectionMutableInterface; use KTXF\People\Service\ServiceEntityMutableInterface; +use KTXF\People\Service\ServiceMutableInterface; use KTXF\Resource\Filter\IFilter; -use KTXF\Resource\Provider\ProviderInterface; +use KTXF\Resource\Identifier\CollectionIdentifier; +use KTXF\Resource\Identifier\EntityIdentifier; +use KTXF\Resource\Identifier\ResourceIdentifiers; use KTXF\Resource\Range\RangeAnchorType; use KTXF\Resource\Range\RangeType; -use KTXF\Resource\Selector\CollectionSelector; -use KTXF\Resource\Selector\EntitySelector; -use KTXF\Resource\Selector\ServiceSelector; -use KTXF\Resource\Selector\SourceSelector; use KTXF\Resource\Sort\ISort; use Psr\Log\LoggerInterface; +/** + * People Manager + * + * Provides unified people/contact management across multiple providers + */ class Manager { public function __construct( @@ -36,48 +40,46 @@ class Manager { /** * Retrieve available providers * - * @param SourceSelector|null $sources collection of provider identifiers - * + * @param array|null $targets collection of provider identifiers + * * @return array collection of available providers e.g. ['provider1' => IProvider, 'provider2' => IProvider] */ - public function providerList(string $tenantId, string $userId, ?SourceSelector $sources = null): array { - // determine filter from sources - $filter = ($sources !== null && $sources->identifiers() !== []) ? $sources->identifiers() : null; + public function providerList(string $tenantId, string $userId, array|null $targets = []): array { // retrieve providers from provider manager - return $this->providerManager->providers(ProviderBaseInterface::TYPE_PEOPLE, $filter); + return $this->providerManager->providers(ProviderBaseInterface::TYPE_PEOPLE, $targets ?: null); } /** * Retrieve specific provider for specific user - * + * * @param string $tenantId tenant identifier * @param string $userId user identifier - * @param string $provider provider identifier - * + * @param string $target provider identifier + * * @return ProviderBaseInterface * @throws InvalidArgumentException */ - public function providerFetch(string $tenantId, string $userId, string $provider): ProviderBaseInterface { + public function providerFetch(string $tenantId, string $userId, string $target): ProviderBaseInterface { // retrieve provider - $providers = $this->providerList($tenantId, $userId, new SourceSelector([$provider => true])); - if (!isset($providers[$provider])) { - throw new InvalidArgumentException("Provider '$provider' not found"); + $providers = $this->providerList($tenantId, $userId, [$target]); + if (!isset($providers[$target])) { + throw new InvalidArgumentException("Provider '$target' not found"); } - return $providers[$provider]; + return $providers[$target]; } /** * Confirm which providers are available * - * @param SourceSelector|null $sources collection of provider identifiers to confirm - * + * @param array $targets collection of provider identifiers to confirm + * * @return array collection of providers and their availability status e.g. ['provider1' => true, 'provider2' => false] */ - public function providerExtant(string $tenantId, string $userId, SourceSelector $sources): array { + public function providerExtant(string $tenantId, string $userId, array $targets): array { // determine which providers are available - $providersResolved = $this->providerList($tenantId, $userId, $sources); + $providersResolved = $this->providerList($tenantId, $userId, $targets); $providersAvailable = array_keys($providersResolved); - $providersUnavailable = array_diff($sources->identifiers(), $providersAvailable); + $providersUnavailable = array_diff($targets, $providersAvailable); // construct response data $responseData = array_merge( array_fill_keys($providersAvailable, true), @@ -91,18 +93,24 @@ class Manager { * * @param string $tenantId tenant identifier * @param string $userId user identifier - * @param SourceSelector|null $sources list of provider and service identifiers - * - * @return array> collections of available services e.g. ['provider1' => ['service1' => IServiceBase], 'provider2' => ['service2' => IServiceBase]] + * @param ResourceIdentifiers|null $targets list of provider and service identifiers + * + * @return array> collections of available services e.g. ['provider1' => ['service1' => IServiceBase], 'provider2' => ['service2' => IServiceBase]] */ - public function serviceList(string $tenantId, string $userId, ?SourceSelector $sources = null): array { + public function serviceList(string $tenantId, string $userId, ?ResourceIdentifiers $targets = null): array { // retrieve providers - $providers = $this->providerList($tenantId, $userId, $sources); + $providerFilter = $targets !== null ? $targets->providers() : null; + $providers = $this->providerList($tenantId, $userId, $providerFilter); // retrieve services for each provider $responseData = []; foreach ($providers as $provider) { - $serviceFilter = $sources[$provider->identifier()] instanceof ServiceSelector ? $sources[$provider->identifier()]->identifiers() : []; - $services = $provider->serviceList($tenantId, $userId, $serviceFilter); + if ($targets !== null) { + $servicesSelected = $targets->byProvider($provider->identifier()); + $servicesFilter = $servicesSelected->services(); + } else { + $servicesFilter = []; + } + $services = $provider->serviceList($tenantId, $userId, $servicesFilter); $responseData[$provider->identifier()] = $services; } return $responseData; @@ -115,7 +123,7 @@ class Manager { * @param string $userId user identifier * @param string $providerId provider identifier * @param string|int $serviceId service identifier - * + * * @return ServiceBaseInterface * @throws InvalidArgumentException */ @@ -134,24 +142,23 @@ class Manager { * * @param string $tenantId tenant identifier * @param string $userId user identifier - * @param SourceSelector|null $sources collection of provider and service identifiers to confirm - * + * @param ResourceIdentifiers $targets collection of provider and service identifiers to confirm + * * @return array collection of providers and their availability status e.g. ['provider1' => ['service1' => false], 'provider2' => ['service2' => true, 'service3' => true]] */ - public function serviceExtant(string $tenantId, string $userId, SourceSelector $sources): array { - // retrieve providers - $providers = $this->providerList($tenantId, $userId, $sources); - $providersRequested = $sources->identifiers(); + public function serviceExtant(string $tenantId, string $userId, ResourceIdentifiers $targets): array { + // retrieve available providers + $providersRequested = $targets->providers(); + $providers = $this->providerList($tenantId, $userId, $providersRequested); $providersUnavailable = array_diff($providersRequested, array_keys($providers)); - + // initialize response with unavailable providers $responseData = array_fill_keys($providersUnavailable, false); - + // retrieve services for each available provider - foreach ($providers as $provider) { - $serviceSelector = $sources[$provider->identifier()]; - $serviceAvailability = $provider->serviceExtant($tenantId, $userId, ...$serviceSelector->identifiers()); - $responseData[$provider->identifier()] = $serviceAvailability; + foreach ($providers as $providerId => $provider) { + $servicesRequested = $targets->byProvider($providerId)->services(); + $responseData[$providerId] = $provider->serviceExtant($tenantId, $userId, ...$servicesRequested); } return $responseData; } @@ -176,13 +183,19 @@ class Manager { if ($provider instanceof ProviderServiceMutateInterface === false) { throw new InvalidArgumentException("Provider '$providerId' does not support service creation"); } - - // Create a service instance with provided data - $service = $provider->serviceFresh()->jsonDeserialize($data); - + if ($provider->capable(ProviderServiceMutateInterface::CAPABILITY_SERVICE_CREATE) === false) { + throw new InvalidArgumentException("Provider '$providerId' is not capable of creating services"); + } + + // Create a fresh service instance + $service = $provider->serviceFresh(); + + // Deserialize the data into the service + $service->jsonDeserialize($data); + // Create the service $serviceId = $provider->serviceCreate($tenantId, $userId, $service); - + // Fetch and return the created service return $provider->serviceFetch($tenantId, $userId, $serviceId); } @@ -197,30 +210,37 @@ class Manager { * @param string $providerId Provider identifier * @param string|int $serviceId Service identifier * @param array $data Updated service configuration data + * @param bool $delta Whether the update is a delta (partial) update or a full replacement * * @return ServiceBaseInterface Updated service * * @throws InvalidArgumentException If provider doesn't support service modification or service not found */ - public function serviceUpdate(string $tenantId, string $userId, string $providerId, string|int $serviceId, array $data): ServiceBaseInterface { + public function serviceUpdate(string $tenantId, string $userId, string $providerId, string|int $serviceId, array $data, bool $delta = false): ServiceBaseInterface { // retrieve provider and service $provider = $this->providerFetch($tenantId, $userId, $providerId); if ($provider instanceof ProviderServiceMutateInterface === false) { throw new InvalidArgumentException("Provider '$providerId' does not support service modification"); } - + if ($provider->capable(ProviderServiceMutateInterface::CAPABILITY_SERVICE_MODIFY) === false) { + throw new InvalidArgumentException("Provider '$providerId' is not capable of modifying services"); + } + // Fetch existing service $service = $provider->serviceFetch($tenantId, $userId, $serviceId); if ($service === null) { throw new InvalidArgumentException("Service '$serviceId' not found"); } - - // Create a service instance with updated data - $service = $provider->serviceFresh()->jsonDeserialize($data); - + if ($service instanceof ServiceMutableInterface === false) { + throw new InvalidArgumentException("Service '$serviceId' is not mutable and cannot be updated"); + } + + // Update with new data + $service->jsonDeserialize($data, $delta); + // Modify the service $provider->serviceModify($tenantId, $userId, $service); - + // Fetch and return the updated service return $provider->serviceFetch($tenantId, $userId, $serviceId); } @@ -243,15 +263,18 @@ class Manager { // retrieve provider and service $provider = $this->providerFetch($tenantId, $userId, $providerId); if ($provider instanceof ProviderServiceMutateInterface === false) { - throw new InvalidArgumentException("Provider '$providerId' does not support service creation"); + throw new InvalidArgumentException("Provider '$providerId' does not support service deletion"); } - + if ($provider->capable(ProviderServiceMutateInterface::CAPABILITY_SERVICE_DESTROY) === false) { + throw new InvalidArgumentException("Provider '$providerId' is not capable of deleting services"); + } + // Fetch existing service $service = $provider->serviceFetch($tenantId, $userId, $serviceId); if ($service === null) { throw new InvalidArgumentException("Service '$serviceId' not found"); } - + // Delete the service return $provider->serviceDestroy($tenantId, $userId, $service); } @@ -265,27 +288,26 @@ class Manager { * * @param string $tenantId Tenant identifier * @param string|null $userId User identifier for context - * @param SourceSelector|null $sources Provider/service sources + * @param ResourceIdentifiers|null $targets Provider/service sources * @param IFilter|null $filter Collection filter * @param ISort|null $sort Collection sort * - * @return array>> Collections grouped by provider/service + * @return array>> Collections grouped by provider/service */ - public function collectionList(string $tenantId, ?string $userId, ?SourceSelector $sources = null, ?IFilter $filter = null, ?ISort $sort = null): array { + public function collectionList(string $tenantId, ?string $userId, ?ResourceIdentifiers $targets = null, ?IFilter $filter = null, ?ISort $sort = null): array { // confirm that sources are provided - if ($sources === null) { - $sources = new SourceSelector([]); + if ($targets === null) { + $targets = new ResourceIdentifiers([]); } - // retrieve providers - $providers = $this->providerList($tenantId, $userId, $sources); // retrieve services for each provider + $aggregateServices = $this->serviceList($tenantId, $userId, $targets); + // retrieve collections for each service $responseData = []; - foreach ($providers as $provider) { - $serviceFilter = $sources[$provider->identifier()] instanceof ServiceSelector ? $sources[$provider->identifier()]->identifiers() : []; - /** @var ServiceBaseInterface[] $services */ - $services = $provider->serviceList($tenantId, $userId, $serviceFilter); - // retrieve collections for each service + foreach ($aggregateServices as $services) { foreach ($services as $service) { + if ($service->getEnabled() === false) { + continue; + } // construct filter for collections $collectionFilter = null; if ($filter !== null && $filter !== []) { @@ -302,64 +324,19 @@ class Manager { $collectionSort->condition($attribute, $direction); } } - $collections = $service->collectionList('', $collectionFilter, $collectionSort); - if ($collections !== []) { - $responseData[$provider->identifier()][$service->identifier()] = $collections; - } - } - } - return $responseData; - } - /** - * Check if collections exist - * - * @since 2025.05.01 - * - * @param string $tenantId Tenant identifier - * @param string|null $userId User identifier for context - * @param SourceSelector $sources Collection sources with identifiers - * - * @return array>> Existence map grouped by provider/service - */ - public function collectionExtant(string $tenantId, ?string $userId, SourceSelector $sources): array { - // retrieve available providers - $providers = $this->providerList($tenantId, $userId, $sources); - $providersRequested = $sources->identifiers(); - $providersUnavailable = array_diff($providersRequested, array_keys($providers)); - - // initialize response with unavailable providers - $responseData = array_fill_keys($providersUnavailable, false); - - // check services and collections for each available provider - foreach ($providers as $provider) { - $serviceSelector = $sources[$provider->identifier()]; - $servicesRequested = $serviceSelector->identifiers(); - /** @var ServiceBaseInterface[] $servicesAvailable */ - $servicesAvailable = $provider->serviceList($tenantId, $userId, $servicesRequested); - $servicesUnavailable = array_diff($servicesRequested, array_keys($servicesAvailable)); - - // mark unavailable services as false - if ($servicesUnavailable !== []) { - $responseData[$provider->identifier()] = array_fill_keys($servicesUnavailable, false); - } - - // confirm collections for each available service - foreach ($servicesAvailable as $service) { - $collectionSelector = $serviceSelector[$service->identifier()]; - $collectionsRequested = $collectionSelector->identifiers(); - - if ($collectionsRequested === []) { - continue; + $collectionIdentifiers = $targets->byProvider($service->provider())->byService($service->identifier())->collections(); + if ($collectionIdentifiers !== []) { + $collections = []; + foreach ($collectionIdentifiers as $collectionIdentifier) { + $collections = array_merge($collections, $service->collectionList($collectionIdentifier, $collectionFilter, $collectionSort)); + } + } else { + $collections = $service->collectionList('', $collectionFilter, $collectionSort); + } + if ($collections !== []) { + $responseData[$service->provider()][$service->identifier()] = $collections; } - - // check each requested collection - $collectionsAvailable = $service->collectionExtant(...$collectionsRequested); - $collectionsUnavailable = array_diff($collectionsRequested, array_keys($collectionsAvailable)); - $responseData[$provider->identifier()][$service->identifier()] = array_merge( - $collectionsAvailable, - array_fill_keys($collectionsUnavailable, false) - ); } } return $responseData; @@ -372,20 +349,66 @@ class Manager { * * @param string $tenantId Tenant identifier * @param string|null $userId User identifier for context - * @param string $providerId Provider identifier - * @param string|int $serviceId Service identifier - * @param string|int $collectionId Collection identifier + * @param CollectionIdentifier $target Target collection identifier * * @return CollectionBaseInterface|null */ - public function collectionFetch(string $tenantId, ?string $userId, string $providerId, string|int $serviceId, string|int $collectionId): ?CollectionBaseInterface { + public function collectionFetch(string $tenantId, ?string $userId, CollectionIdentifier $target): ?CollectionBaseInterface { // retrieve service - $service = $this->serviceFetch($tenantId, $userId, $providerId, $serviceId); - if ($service === null) { - return null; - } + $service = $this->serviceFetch($tenantId, $userId, $target->provider(), $target->service()); // retrieve collection - return $service->collectionFetch($collectionId); + return $service->collectionFetch($target->collection()); + } + + /** + * Check if collections exist + * + * @since 2025.05.01 + * + * @param string $tenantId Tenant identifier + * @param string|null $userId User identifier for context + * @param ResourceIdentifiers $targets Collection sources with identifiers + * + * @return array>> Existence map grouped by provider/service + */ + public function collectionExtant(string $tenantId, ?string $userId, ResourceIdentifiers $targets): array { + // retrieve available services grouped by provider + $aggregateServices = $this->serviceList($tenantId, $userId, $targets); + // initialize response with unavailable providers marked as false + $providersRequested = $targets->providers(); + $providersUnavailable = array_diff($providersRequested, array_keys($aggregateServices)); + $responseData = array_fill_keys($providersUnavailable, false); + + // check services and collections for each available provider + foreach ($aggregateServices as $providerId => $services) { + // mark unavailable services as false + $servicesRequested = $targets->byProvider($providerId)->services(); + $servicesUnavailable = array_diff($servicesRequested, array_keys($services)); + if ($servicesUnavailable !== []) { + $responseData[$providerId] = array_fill_keys($servicesUnavailable, false); + } + // confirm collections for each available service + foreach ($services as $service) { + // omit disabled services + if ($service->getEnabled() === false) { + $responseData[$providerId][$service->identifier()] = false; + continue; + } + // extract collections requested for this service + $collectionsRequested = $targets->byProvider($providerId)->byService($service->identifier())->collections(); + if ($collectionsRequested === []) { + continue; + } + // check each requested collection + $collectionsAvailable = $service->collectionExtant(...$collectionsRequested); + $collectionsUnavailable = array_diff($collectionsRequested, array_keys($collectionsAvailable)); + $responseData[$providerId][$service->identifier()] = array_merge( + $collectionsAvailable, + array_fill_keys($collectionsUnavailable, false) + ); + } + } + return $responseData; } /** @@ -393,36 +416,34 @@ class Manager { * * @param string $tenantId tenant identifier * @param string $userId user identifier - * @param string $providerId provider identifier - * @param string|int $serviceId service identifier - * @param string|int|null $collectionId collection identifier (parent collection) - * @param CollectionMutableInterface|array $object collection to create + * @param string $provider provider identifier + * @param string|int $service service identifier + * @param CollectionIdentifier|null $target target parent collection identifier + * @param CollectionPropertiesMutableInterface|array $properties properties for the new collection * @param array $options additional options for creation - * + * * @return CollectionBaseInterface * @throws InvalidArgumentException */ - public function collectionCreate(string $tenantId, string $userId, string $providerId, string|int $serviceId, string|int|null $collectionId, CollectionMutableInterface|array $object, array $options = []): CollectionBaseInterface { + public function collectionCreate(string $tenantId, string $userId, string $provider, string|int $service, CollectionIdentifier|null $target, CollectionPropertiesMutableInterface|array $properties, array $options = []): CollectionBaseInterface { // retrieve service - $service = $this->serviceFetch($tenantId, $userId, $providerId, $serviceId); - + $service = $this->serviceFetch($tenantId, $userId, $provider, $service); // Check if service supports collection creation - if (!($service instanceof ServiceCollectionMutableInterface)) { - throw new InvalidArgumentException("Service does not support collection mutations"); + if ($service->getEnabled() === false) { + throw new InvalidArgumentException("Service '{$service->identifier()}' not found or is disabled"); + } + if ($service instanceof ServiceCollectionMutableInterface === false) { + throw new InvalidArgumentException("Service '{$service->identifier()}' does not support collection mutations"); } if (!$service->capable(ServiceCollectionMutableInterface::CAPABILITY_COLLECTION_CREATE)) { - throw new InvalidArgumentException("Service is not capable of creating collections"); + throw new InvalidArgumentException("Service '{$service->identifier()}' is not capable of creating collections"); } - - if (is_array($object)) { - $collection = $service->collectionFresh(); - $collection->getProperties()->jsonDeserialize($object); - } else { - $collection = $object; + // convert properties if necessary + if ($properties instanceof CollectionPropertiesMutableInterface === false) { + $properties = $service->collectionFresh()->getProperties()->jsonDeserialize($properties); } - // Create collection - return $service->collectionCreate($collectionId, $collection, $options); + return $service->collectionCreate($target, $properties, $options); } /** @@ -430,35 +451,31 @@ class Manager { * * @param string $tenantId tenant identifier * @param string $userId user identifier - * @param string $providerId provider identifier - * @param string|int $serviceId service identifier - * @param string|int $collectionId collection identifier - * @param CollectionMutableInterface|array $object collection to modify - * + * @param CollectionIdentifier $target target collection identifier + * @param CollectionPropertiesMutableInterface|array $properties properties to modify + * * @return CollectionBaseInterface * @throws InvalidArgumentException */ - public function collectionUpdate(string $tenantId, string $userId, string $providerId, string|int $serviceId, string|int $collectionId, CollectionMutableInterface|array $object): CollectionBaseInterface { + public function collectionUpdate(string $tenantId, string $userId, CollectionIdentifier $target, CollectionPropertiesMutableInterface|array $properties): CollectionBaseInterface { // retrieve service - $service = $this->serviceFetch($tenantId, $userId, $providerId, $serviceId); - - // Check if service supports collection creation - if (!($service instanceof ServiceCollectionMutableInterface)) { - throw new InvalidArgumentException("Service does not support collection mutations"); + $service = $this->serviceFetch($tenantId, $userId, $target->provider(), $target->service()); + // Check if service supports collection mutations + if ($service->getEnabled() === false) { + throw new InvalidArgumentException("Service '{$service->identifier()}' not found or is disabled"); + } + if ($service instanceof ServiceCollectionMutableInterface === false) { + throw new InvalidArgumentException("Service '{$service->identifier()}' does not support collection mutations"); } if (!$service->capable(ServiceCollectionMutableInterface::CAPABILITY_COLLECTION_UPDATE)) { - throw new InvalidArgumentException("Service is not capable of updating collections"); + throw new InvalidArgumentException("Service '{$service->identifier()}' is not capable of updating collections"); } - - if (is_array($object)) { - $collection = $service->collectionFresh(); - $collection->getProperties()->jsonDeserialize($object); - } else { - $collection = $object; + // convert properties if necessary + if ($properties instanceof CollectionPropertiesMutableInterface === false) { + $properties = $service->collectionFresh()->getProperties()->jsonDeserialize($properties); } - // Update collection - return $service->collectionUpdate($collectionId, $collection); + return $service->collectionUpdate($target, $properties); } /** @@ -468,72 +485,75 @@ class Manager { * * @param string $tenantId Tenant identifier * @param string|null $userId User identifier for context - * @param string $providerId Provider identifier - * @param string|int $serviceId Service identifier - * @param string|int $collectionId Collection identifier + * @param CollectionIdentifier $target Target collection identifier + * @param array $options Additional options for deletion (e.g., 'force' => true to force delete even if not empty) * - * @return CollectionBaseInterface|null + * @return CollectionBaseInterface|bool */ - public function collectionDelete(string $tenantId, ?string $userId, string $providerId, string|int $serviceId, string|int $collectionId, array $options = []): bool { + public function collectionDelete(string $tenantId, ?string $userId, CollectionIdentifier $target, array $options = []): CollectionBaseInterface | bool { // retrieve service - $service = $this->serviceFetch($tenantId, $userId, $providerId, $serviceId); - + $service = $this->serviceFetch($tenantId, $userId, $target->provider(), $target->service()); // Check if service supports collection deletion - if (!($service instanceof ServiceCollectionMutableInterface)) { - throw new InvalidArgumentException("Service does not support collection mutations"); + if ($service->getEnabled() === false) { + throw new InvalidArgumentException("Service '{$service->identifier()}' not found or is disabled"); + } + if ($service instanceof ServiceCollectionMutableInterface === false) { + throw new InvalidArgumentException("Service '{$service->identifier()}' does not support collection mutations"); } if (!$service->capable(ServiceCollectionMutableInterface::CAPABILITY_COLLECTION_DELETE)) { - throw new InvalidArgumentException("Service is not capable of deleting collections"); + throw new InvalidArgumentException("Service '{$service->identifier()}' is not capable of deleting collections"); } - + // convert options $force = $options['force'] ?? false; - $recursive = $options['recursive'] ?? false; - // delete collection - return $service->collectionDelete($collectionId, $force, $recursive); + return $service->collectionDelete($target, $force); } // ==================== Entity Operations ==================== /** - * List Entities in a collection + * List entities in a collection * * @since 2025.05.01 * * @param string $tenantId Tenant identifier * @param string $userId User identifier - * @param SourceSelector $sources Entity sources with collection identifiers + * @param ResourceIdentifiers|null $targets Entity sources with collection identifiers * @param array|null $filter Entity filter * @param array|null $sort Entity sort * @param array|null $range Entity range/pagination * - * @return array>>> Entities grouped by provider/service/collection + * @return array>>> Entities grouped by provider/service/collection */ - public function entityList(string $tenantId, string $userId, SourceSelector $sources, array|null $filter = null, array|null $sort = null, array|null $range = null): array { - // retrieve providers - $providers = $this->providerList($tenantId, $userId, $sources); + public function entityListBulk(string $tenantId, string $userId, ?ResourceIdentifiers $targets = null, array|null $filter = null, array|null $sort = null, array|null $range = null): array { + // confirm that sources are provided + if ($targets === null) { + $targets = new ResourceIdentifiers([]); + } // retrieve services for each provider + $aggregateServices = $this->serviceList($tenantId, $userId, $targets); + // retrieve entities for each service $responseData = []; - foreach ($providers as $provider) { - // retrieve services for each provider - $serviceSelector = $sources[$provider->identifier()]; - $servicesSelected = $provider->serviceList($tenantId,$userId, $serviceSelector->identifiers()); + foreach ($aggregateServices as $services) { /** @var ServiceBaseInterface $service */ - foreach ($servicesSelected as $service) { - // retrieve collections for each service - $collectionSelector = $serviceSelector[$service->identifier()]; - $collectionSelected = $collectionSelector instanceof CollectionSelector ? $collectionSelector->identifiers() : []; - if ($collectionSelected === []) { - $collections = $service->collectionList(''); - $collectionSelected = array_map( - fn($collection) => $collection->identifier(), - $collections - ); - } - if ($collectionSelected === []) { - continue; - } - // construct filter for entities + foreach ($services as $service) { + // omit disabled services + if ($service->getEnabled() === false) { + continue; + } + // retrieve collections for each service + $collectionSelected = $targets->byProvider($service->provider())->byService($service->identifier())->collections(); + if ($collectionSelected === []) { + $collections = $service->collectionList(''); + $collectionSelected = array_map( + fn($collection) => $collection->identifier(), + $collections + ); + } + if ($collectionSelected === []) { + continue; + } + // construct filter for entities $entityFilter = null; if ($filter !== null && $filter !== []) { $entityFilter = $service->entityListFilter(); @@ -555,7 +575,7 @@ class Manager { $entityRange = $service->entityListRange(RangeType::from($range['type'])); // Cast to IRangeTally if the range type is TALLY if ($entityRange->type() === RangeType::TALLY) { - /** @var IRangeTally $entityRange */ + /** @var \KTXF\Resource\Range\IRangeTally $entityRange */ if (isset($range['anchor'])) { $entityRange->setAnchor(RangeAnchorType::from($range['anchor'])); } @@ -567,110 +587,222 @@ class Manager { } } } - // retrieve entities for each collection - foreach ($collectionSelected as $collectionId) { - $entities = $service->entityList($collectionId, $entityFilter, $entitySort, $entityRange, null); - // skip collections with no entities - if ($entities === []) { - continue; - } - $responseData[$provider->identifier()][$service->identifier()][$collectionId] = $entities; - } - } + // retrieve entities for each collection + foreach ($collectionSelected as $collectionId) { + $entities = $service->entityListBulk($collectionId, $entityFilter, $entitySort, $entityRange, null); + // skip collections with no entities + if ($entities === []) { + continue; + } + $responseData[$service->provider()][$service->identifier()][$collectionId] = $entities; + } + } } return $responseData; } - + /** - * Fetch specific messages + * Stream entities * - * @since 2025.05.01 + * @since 2026.02.01 * * @param string $tenantId Tenant identifier - * @param string|null $userId User identifier for context - * @param string $providerId Provider identifier - * @param string|int $serviceId Service identifier - * @param string|int $collectionId Collection identifier - * @param array $identifiers Message identifiers + * @param string $userId User identifier + * @param ResourceIdentifiers|null $targets Entity sources with collection identifiers + * @param array|null $filter Entity filter + * @param array|null $sort Entity sort + * @param array|null $range Entity range/pagination * - * @return array Messages indexed by ID + * @return \Generator Yields each entity as it is retrieved */ - public function entityFetch(string $tenantId, ?string $userId, string $providerId, string|int $serviceId, string|int $collectionId, array $identifiers): array { - $service = $this->serviceFetch($tenantId, $userId, $providerId, $serviceId); - - // retrieve collection - return $service->entityFetch($collectionId, ...$identifiers); + public function entityListStream(string $tenantId, string $userId, ?ResourceIdentifiers $targets = null, array|null $filter = null, array|null $sort = null, array|null $range = null): \Generator { + // confirm that sources are provided + if ($targets === null) { + $targets = new ResourceIdentifiers([]); + } + // retrieve services for each provider + $aggregateServices = $this->serviceList($tenantId, $userId, $targets); + foreach ($aggregateServices as $services) { + /** @var ServiceBaseInterface $service */ + foreach ($services as $service) { + // omit disabled services + if ($service->getEnabled() === false) { + continue; + } + // retrieve collections for each service + $collectionSelected = $targets->byProvider($service->provider())->byService($service->identifier())->collections(); + if ($collectionSelected === []) { + $collections = $service->collectionList(''); + $collectionSelected = array_map( + fn($collection) => $collection->identifier(), + $collections + ); + } + if ($collectionSelected === []) { + continue; + } + // construct filter for entities + $entityFilter = null; + if ($filter !== null && $filter !== []) { + $entityFilter = $service->entityListFilter(); + foreach ($filter as $attribute => $value) { + $entityFilter->condition($attribute, $value); + } + } + // construct sort for entities + $entitySort = null; + if ($sort !== null && $sort !== []) { + $entitySort = $service->entityListSort(); + foreach ($sort as $attribute => $direction) { + $entitySort->condition($attribute, $direction); + } + } + // construct range for entities + $entityRange = null; + if ($range !== null && $range !== [] && isset($range['type'])) { + $entityRange = $service->entityListRange(RangeType::from($range['type'])); + if ($entityRange->type() === RangeType::TALLY) { + /** @var \KTXF\Resource\Range\IRangeTally $entityRange */ + if (isset($range['anchor'])) { + $entityRange->setAnchor(RangeAnchorType::from($range['anchor'])); + } + if (isset($range['position'])) { + $entityRange->setPosition($range['position']); + } + if (isset($range['tally'])) { + $entityRange->setTally($range['tally']); + } + } + } + // yield entities for each collection individually + foreach ($collectionSelected as $collectionId) { + yield from $service->entityListStream($collectionId, $entityFilter, $entitySort, $entityRange, null); + } + } + } } /** - * Check if messages exist + * Fetch specific entities in bulk * * @since 2025.05.01 * * @param string $tenantId Tenant identifier * @param string|null $userId User identifier for context - * @param SourceSelector $sources Message sources with identifiers + * @param EntityIdentifier ...$identifiers Specific entity identifiers to fetch + * + * @return array + */ + public function entityFetchBulk(string $tenantId, ?string $userId, EntityIdentifier ...$identifiers): array { + // group identifiers by provider/service + $groupedIdentifiers = []; + foreach ($identifiers as $identifier) { + $groupedIdentifiers[$identifier->provider()][$identifier->service()][] = $identifier; + } + // retrieve each service and fetch entities + $list = []; + foreach ($groupedIdentifiers as $providerId => $services) { + foreach ($services as $serviceId => $entities) { + $service = $this->serviceFetch($tenantId, $userId, $providerId, $serviceId); + if ($service->getEnabled() === false) { + throw new InvalidArgumentException("Service '{$providerId}:{$serviceId}' not found or is disabled"); + } + // retrieve entities and merge into list + $list = array_merge($list, $service->entityFetchBulk(...$entities)); + } + } + return $list; + } + + /** + * Fetch specific entities as a stream + * + * @since 2025.05.01 + * + * @param string $tenantId Tenant identifier + * @param string|null $userId User identifier for context + * @param EntityIdentifier ...$identifiers Specific entity identifiers to fetch + * + * @return \Generator + */ + public function entityFetchStream(string $tenantId, ?string $userId, EntityIdentifier ...$identifiers): \Generator { + // group identifiers by provider/service + $groupedIdentifiers = []; + foreach ($identifiers as $identifier) { + $groupedIdentifiers[$identifier->provider()][$identifier->service()][] = $identifier; + } + // retrieve each service and fetch entities + foreach ($groupedIdentifiers as $providerId => $services) { + foreach ($services as $serviceId => $entities) { + $service = $this->serviceFetch($tenantId, $userId, $providerId, $serviceId); + if ($service->getEnabled() === false) { + throw new InvalidArgumentException("Service '{$providerId}:{$serviceId}' not found or is disabled"); + } + // retrieve entities and yield each one + yield from $service->entityFetchStream(...$entities); + } + } + } + + /** + * Check if entities exist + * + * @since 2025.05.01 + * + * @param string $tenantId Tenant identifier + * @param string|null $userId User identifier for context + * @param ResourceIdentifiers $targets Entity sources with identifiers * * @return array>>> Existence map grouped by provider/service/collection */ - public function entityExtant(string $tenantId, string $userId, SourceSelector $sources): array { - // confirm that sources are provided - if ($sources === null) { - $sources = new SourceSelector([]); - } - // retrieve available providers - $providers = $this->providerList($tenantId, $userId, $sources); - $providersRequested = $sources->identifiers(); - $providersUnavailable = array_diff($providersRequested, array_keys($providers)); - - // initialize response with unavailable providers + public function entityExtant(string $tenantId, string $userId, ResourceIdentifiers $targets): array { + // retrieve available services grouped by provider + $aggregateServices = $this->serviceList($tenantId, $userId, $targets); + // initialize response with unavailable providers marked as false + $providersRequested = $targets->providers(); + $providersUnavailable = array_diff($providersRequested, array_keys($aggregateServices)); $responseData = array_fill_keys($providersUnavailable, false); - + // check services, collections, and entities for each available provider - foreach ($providers as $provider) { - $serviceSelector = $sources[$provider->identifier()]; - $servicesRequested = $serviceSelector->identifiers(); - /** @var ServiceBaseInterface[] $servicesAvailable */ - $servicesAvailable = $provider->serviceList($tenantId, $userId, $servicesRequested); - $servicesUnavailable = array_diff($servicesRequested, array_keys($servicesAvailable)); - + foreach ($aggregateServices as $providerId => $services) { // mark unavailable services as false + $servicesRequested = $targets->byProvider($providerId)->services(); + $servicesUnavailable = array_diff($servicesRequested, array_keys($services)); if ($servicesUnavailable !== []) { - $responseData[$provider->identifier()] = array_fill_keys($servicesUnavailable, false); + $responseData[$providerId] = array_fill_keys($servicesUnavailable, false); } - // check collections and entities for each available service - foreach ($servicesAvailable as $service) { - $collectionSelector = $serviceSelector[$service->identifier()]; - $collectionsRequested = $collectionSelector instanceof CollectionSelector ? $collectionSelector->identifiers() : []; - + foreach ($services as $service) { + // omit disabled services + if ($service->getEnabled() === false) { + $responseData[$providerId][$service->identifier()] = false; + continue; + } + // extract collections requested for this service + $serviceTargets = $targets->byProvider($providerId)->byService($service->identifier()); + $collectionsRequested = $serviceTargets->collections(); if ($collectionsRequested === []) { continue; } - // check entities for each requested collection foreach ($collectionsRequested as $collectionId) { // first check if collection exists $collectionExists = $service->collectionExtant((string)$collectionId); - if (!$collectionExists) { // collection doesn't exist, mark as false - $responseData[$provider->identifier()][$service->identifier()][$collectionId] = false; + $responseData[$providerId][$service->identifier()][$collectionId] = false; continue; } - - // extract entity identifiers from collection selector - $entitySelector = $collectionSelector[$collectionId]; - - // handle both array of entity IDs and boolean true (meaning check if collection exists) - if ($entitySelector instanceof EntitySelector) { - // check specific entities within the collection - $responseData[$provider->identifier()][$service->identifier()][$collectionId] = $service->entityExtant($collectionId, ...$entitySelector->identifiers()); - } elseif ($entitySelector === true) { + // extract entity identifiers requested for this collection + $entitiesRequested = $serviceTargets->byCollection($collectionId)->entities(); + if ($entitiesRequested === []) { // just checking if collection exists (already confirmed above) - $responseData[$provider->identifier()][$service->identifier()][$collectionId] = true; + $responseData[$providerId][$service->identifier()][$collectionId] = true; + continue; } + // check specific entities within the collection + $responseData[$providerId][$service->identifier()][$collectionId] = $service->entityExtant($collectionId, ...$entitiesRequested); } } } @@ -678,48 +810,51 @@ class Manager { } /** - * Get message delta/changes + * Get entity delta/changes * * @since 2025.05.01 * * @param string $tenantId Tenant identifier * @param string|null $userId User identifier for context - * @param SourceSelector $sources Message sources with signatures + * @param ResourceIdentifiers $targets Entity sources with signatures * * @return array>> Delta grouped by provider/service/collection */ - public function entityDelta(string $tenantId, string $userId, SourceSelector $sources): array { - // confirm that sources are provided - if ($sources === null) { - $sources = new SourceSelector([]); - } - // retrieve providers - $providers = $this->providerList($tenantId, $userId, $sources); - $providersRequested = $sources->identifiers(); - $providersUnavailable = array_diff($providersRequested, array_keys($providers)); - // initialize response with unavailable providers + public function entityDelta(string $tenantId, string $userId, ResourceIdentifiers $targets): array { + // retrieve available services grouped by provider + $aggregateServices = $this->serviceList($tenantId, $userId, $targets); + // initialize response with unavailable providers marked as false + $providersRequested = $targets->providers(); + $providersUnavailable = array_diff($providersRequested, array_keys($aggregateServices)); $responseData = array_fill_keys($providersUnavailable, false); + // iterate through available providers - foreach ($providers as $provider) { - $serviceSelector = $sources[$provider->identifier()]; - $servicesRequested = $serviceSelector instanceof ServiceSelector ? $serviceSelector->identifiers() : []; - /** @var ServiceBaseInterface[] $services */ - $services = $provider->serviceList($tenantId, $userId, $servicesRequested); + foreach ($aggregateServices as $providerId => $services) { + // mark unavailable services as false + $servicesRequested = $targets->byProvider($providerId)->services(); $servicesUnavailable = array_diff($servicesRequested, array_keys($services)); if ($servicesUnavailable !== []) { - $responseData[$provider->identifier()] = array_fill_keys($servicesUnavailable, false); + $responseData[$providerId] = array_fill_keys($servicesUnavailable, false); } // iterate through available services foreach ($services as $service) { - $collectionSelector = $serviceSelector[$service->identifier()]; - $collectionsRequested = $collectionSelector instanceof CollectionSelector ? $collectionSelector->identifiers() : []; - if ($collectionsRequested === []) { - $responseData[$provider->identifier()][$service->identifier()] = false; + // omit disabled services + if ($service->getEnabled() === false) { + $responseData[$providerId][$service->identifier()] = false; continue; } + // extract collections requested for this service + $serviceTargets = $targets->byProvider($providerId)->byService($service->identifier()); + $collectionsRequested = $serviceTargets->collections(); + if ($collectionsRequested === []) { + $responseData[$providerId][$service->identifier()] = false; + continue; + } + // check delta for each requested collection foreach ($collectionsRequested as $collection) { - $entitySelector = $collectionSelector[$collection] ?? null; - $responseData[$provider->identifier()][$service->identifier()][$collection] = $service->entityDelta($collection, $entitySelector); + // signature for the collection is carried in the entity slot of the identifier + $signature = $serviceTargets->byCollection($collection)->entities()[0] ?? ''; + $responseData[$providerId][$service->identifier()][$collection] = $service->entityDelta($collection, $signature); } } } @@ -731,35 +866,32 @@ class Manager { * * @param string $tenantId tenant identifier * @param string $userId user identifier - * @param string $providerId provider identifier - * @param string|int $serviceId service identifier - * @param string|int $collectionId collection identifier - * @param EntityMutableInterface|array $entity entity to create - * @param array $options additional options - * + * @param CollectionIdentifier $target target collection identifier + * @param EntityPropertiesMutableInterface|array $properties properties for the new entity + * @param array $options additional options for creation + * * @return EntityBaseInterface * @throws InvalidArgumentException */ - public function entityCreate(string $tenantId, string $userId, string $providerId, string|int $serviceId, string|int $collectionId, EntityMutableInterface|array $object, array $options = []): EntityBaseInterface { + public function entityCreate(string $tenantId, string $userId, CollectionIdentifier $target, EntityPropertiesMutableInterface|array $properties, array $options = []): EntityBaseInterface { // retrieve service - $service = $this->serviceFetch($tenantId, $userId, $providerId, $serviceId); - + $service = $this->serviceFetch($tenantId, $userId, $target->provider(), $target->service()); // Check if service supports entity creation - if (!($service instanceof ServiceEntityMutableInterface)) { - throw new InvalidArgumentException("Service does not support entity mutations"); + if ($service->getEnabled() === false) { + throw new InvalidArgumentException("Service '{$service->identifier()}' not found or is disabled"); + } + if ($service instanceof ServiceEntityMutableInterface === false) { + throw new InvalidArgumentException("Service '{$service->identifier()}' does not support entity mutations"); } if (!$service->capable(ServiceEntityMutableInterface::CAPABILITY_ENTITY_CREATE)) { - throw new InvalidArgumentException("Service is not capable of creating entities"); + throw new InvalidArgumentException("Service '{$service->identifier()}' is not capable of creating entities"); } - - if (is_array($object)) { - $entity = $service->entityFresh(); - $entity->getProperties()->jsonDeserialize($object); - } else { - $entity = $object; + // convert properties if necessary + if ($properties instanceof EntityPropertiesMutableInterface === false) { + $properties = $service->entityFresh()->getProperties()->jsonDeserialize($properties); } - - return $service->entityCreate($collectionId, $entity, $options); + // create entity + return $service->entityCreate($target, $properties, $options); } /** @@ -767,60 +899,209 @@ class Manager { * * @param string $tenantId tenant identifier * @param string $userId user identifier - * @param string|int $providerId provider identifier - * @param string|int $serviceId service identifier - * @param string|int $collectionId collection identifier - * @param string|int $identifier entity identifier - * @param EntityBaseInterface|array $entity entity with modifications - * + * @param EntityIdentifier $target target entity identifier + * @param EntityPropertiesMutableInterface|array $properties properties to modify + * * @return EntityBaseInterface * @throws InvalidArgumentException */ - public function entityUpdate(string $tenantId, string $userId, string|int $providerId, string|int $serviceId, string|int $collectionId, string|int $identifier, EntityBaseInterface|array $object): EntityBaseInterface { + public function entityModify(string $tenantId, string $userId, EntityIdentifier $target, EntityPropertiesMutableInterface|array $properties): EntityBaseInterface { // retrieve service - $service = $this->serviceFetch($tenantId, $userId, $providerId, $serviceId); - - // Check if service supports entity creation - if (!($service instanceof ServiceEntityMutableInterface)) { - throw new InvalidArgumentException("Service does not support entity mutations"); + $service = $this->serviceFetch($tenantId, $userId, $target->provider(), $target->service()); + // Check if service supports entity modification + if ($service->getEnabled() === false) { + throw new InvalidArgumentException("Service '{$service->identifier()}' not found or is disabled"); } - if (!$service->capable(ServiceEntityMutableInterface::CAPABILITY_ENTITY_CREATE)) { - throw new InvalidArgumentException("Service is not capable of creating entities"); + if ($service instanceof ServiceEntityMutableInterface === false) { + throw new InvalidArgumentException("Service '{$service->identifier()}' does not support entity mutations"); } - - if (is_array($object)) { - $entity = $service->entityFresh(); - $entity->getProperties()->jsonDeserialize($object); - } else { - $entity = $object; + if (!$service->capable(ServiceEntityMutableInterface::CAPABILITY_ENTITY_MODIFY)) { + throw new InvalidArgumentException("Service '{$service->identifier()}' is not capable of modifying entities"); } - - return $service->entityUpdate($collectionId, $identifier, $entity); + // convert properties if necessary + if ($properties instanceof EntityPropertiesMutableInterface === false) { + $properties = $service->entityFresh()->getProperties()->jsonDeserialize($properties); + } + // modify entity + return $service->entityModify($target, $properties); } /** - * Destroy an entity from a collection + * Deletes entities * - * @param string $tenantId tenant identifier - * @param string $userId user identifier - * @param string|int $providerId provider identifier - * @param string|int $serviceId service identifier - * @param string|int $collectionId collection identifier - * @param string|int $identifier entity identifier - * - * @return bool - * @throws InvalidArgumentException + * @since 2026.04.01 + * + * @param string $tenantId Tenant identifier + * @param string|null $userId User identifier for context + * @param EntityIdentifier ...$targets Source entities to delete + * + * @return array Results keyed by source entity identifier */ - public function entityDelete(string $tenantId, string $userId, string|int $providerId, string|int $serviceId, string|int $collectionId, string|int $identifier): bool { - $service = $this->serviceFetch($tenantId, $userId, $providerId, $serviceId); - - if (!($service instanceof ServiceEntityMutableInterface)) { - throw new InvalidArgumentException('Service does not support entity destruction'); + public function entityDelete(string $tenantId, string $userId, EntityIdentifier ...$targets): array { + $operationOutcome = []; + $targetIdentifiers = new ResourceIdentifiers(); + + foreach ($targets as $target) { + $targetIdentifiers->add($target); } - - $entity = $service->entityDelete($collectionId, $identifier); - - return $entity !== null; + + // process targets grouped by provider + foreach ($targetIdentifiers->providers() as $providerId) { + // retrieve provider and validate + $providerTargets = $targetIdentifiers->byProvider($providerId); + // process targets grouped by service for this provider + foreach ($providerTargets->services() as $serviceId) { + // extract services requested for this provider + $serviceTargets = $providerTargets->byService($serviceId); + // retrieve and validate service + $service = null; + $error = null; + try { + $service = $this->serviceFetch($tenantId, $userId, $providerId, $serviceId); + } catch (\Throwable $e) { + $error = "Service $serviceId not found"; + } + if ($service instanceof ServiceEntityMutableInterface === false && $error === null) { + $error = "Service $serviceId does not support entity mutation"; + } + if ($error === null && !$service->capable(ServiceEntityMutableInterface::CAPABILITY_ENTITY_DELETE)) { + $error = "Service $serviceId does not support entity deletion"; + } + // on error, mark all identifiers for this service as failed and continue to next service + if ($error !== null) { + foreach ($serviceTargets as $identifier) { + $operationOutcome[(string)$identifier] = ['disposition' => 'error', 'error' => $error]; + } + continue; + } + /** @var ServiceEntityMutableInterface $service */ + $operationOutcome = array_merge($operationOutcome, $service->entityDelete(...$serviceTargets->all())); + } + } + + return $operationOutcome; } -} \ No newline at end of file + /** + * Moves entities to another collection + * + * @since 2025.05.01 + * + * @param string $tenantId Tenant identifier + * @param string|null $userId User identifier for context + * @param CollectionIdentifier $target Target collection identifier + * @param EntityIdentifier ...$sources Source entities to move + * + * @return array Results keyed by source entity identifier + */ + public function entityMove(string $tenantId, string $userId, CollectionIdentifier $target, EntityIdentifier ...$sources): array { + return $this->entityRelocate( + $tenantId, + $userId, + $target, + ServiceEntityMutableInterface::CAPABILITY_ENTITY_MOVE, + 'entityMove', + $sources + ); + } + + /** + * Copies entities to another collection + * + * @since 2025.05.01 + * + * @param string $tenantId Tenant identifier + * @param string|null $userId User identifier for context + * @param CollectionIdentifier $target Target collection identifier + * @param EntityIdentifier ...$sources Source entities to copy + * + * @return array Results keyed by source entity identifier + */ + public function entityCopy(string $tenantId, string $userId, CollectionIdentifier $target, EntityIdentifier ...$sources): array { + return $this->entityRelocate( + $tenantId, + $userId, + $target, + ServiceEntityMutableInterface::CAPABILITY_ENTITY_COPY, + 'entityCopy', + $sources + ); + } + + /** + * Shared implementation for entity move/copy operations + * + * @param string $tenantId Tenant identifier + * @param string|null $userId User identifier for context + * @param CollectionIdentifier $target Target collection identifier + * @param string $capability Required service capability + * @param string $method Service method to invoke (entityMove|entityCopy) + * @param EntityIdentifier[] $sources Source entities to relocate + * + * @return array Results keyed by source entity identifier + */ + private function entityRelocate(string $tenantId, ?string $userId, CollectionIdentifier $target, string $capability, string $method, array $sources): array { + $operationOutcome = []; + // retrieve and validate service + $targetService = null; + $error = null; + try { + $targetService = $this->serviceFetch($tenantId, $userId, $target->provider(), $target->service()); + } catch (\Throwable $e) { + // do nothing here, error will be handled in validation below + } + if ($targetService === null || $targetService->getEnabled() === false) { + $error = "Service {$target->service()} not found or is disabled"; + } + if ($targetService instanceof ServiceEntityMutableInterface === false && $error === null) { + $error = "Service {$target->service()} does not support entity mutation"; + } + if ($error === null && !$targetService->capable($capability)) { + $error = "Service {$target->service()} does not support this entity operation"; + } + // on error, mark all identifiers as failed + if ($error !== null) { + foreach ($sources as $identifier) { + $operationOutcome[(string)$identifier] = ['disposition' => 'error', 'error' => $error]; + } + return $operationOutcome; + } + // validate that sources and target are the same service and group sources by service for processing + $groupedSources = []; + foreach ($sources as $source) { + if ($source->provider() !== $target->provider() || $source->service() !== $target->service()) { + $operationOutcome[(string)$source] = [ + 'disposition' => 'error', + 'error' => "Source '{$source}' and target '{$target}' must belong to the same provider and service" + ]; + continue; + } + $groupedSources[] = $source; + } + + if ($groupedSources === []) { + return $operationOutcome; + } + + // perform operation for entities on the same service as the target + $operationOutcome = array_merge( + $operationOutcome, + $targetService->{$method}($target, ...$groupedSources) + ); + + return $operationOutcome; + } + +}