From 7d18bcab1a8e1d7435ca5bcc5b5262a14a317165 Mon Sep 17 00:00:00 2001 From: Sebastian Krupinski Date: Fri, 4 Sep 2026 22:12:27 -0400 Subject: [PATCH] feat: node list back end Signed-off-by: Sebastian Krupinski --- lib/Controllers/DefaultController.php | 232 +++++++++++++++----------- lib/Manager.php | 80 +++++++++ 2 files changed, 214 insertions(+), 98 deletions(-) diff --git a/lib/Controllers/DefaultController.php b/lib/Controllers/DefaultController.php index 5bfe5b9..cda1ea9 100644 --- a/lib/Controllers/DefaultController.php +++ b/lib/Controllers/DefaultController.php @@ -163,56 +163,13 @@ class DefaultController extends ControllerAbstract { 'entity.write' => $this->entityWrite($tenantId, $userId, $data), 'entity.write.chunk' => $this->entityWriteChunk($tenantId, $userId, $data), + // Node operations (unified collections + entities) + 'node.list' => $this->nodeList($tenantId, $userId, $data, $version, $transaction), + default => throw new InvalidArgumentException(self::ERR_INVALID_OPERATION . $operation) }; } - // ==================== Identifier Helpers ==================== - - /** - * Parse a required collection target identifier (provider:service:collection) - */ - private function collectionTarget(array $data, string $key = 'target'): CollectionIdentifier { - if (!isset($data[$key])) { - throw new InvalidArgumentException('Missing parameter: ' . $key); - } - if (!is_string($data[$key])) { - throw new InvalidArgumentException("Invalid parameter: $key must be a string"); - } - $identifier = ResourceIdentifier::fromString($data[$key]); - if (!$identifier instanceof CollectionIdentifier) { - throw new InvalidArgumentException(self::ERR_TARGET_COLLECTION); - } - return $identifier; - } - - /** - * Parse an optional collection target identifier (absent = root) - */ - private function collectionTargetOptional(array $data, string $key = 'target'): ?CollectionIdentifier { - if (!isset($data[$key]) || $data[$key] === null || $data[$key] === '') { - return null; - } - return $this->collectionTarget($data, $key); - } - - /** - * Parse a required entity target identifier (provider:service:collection:entity) - */ - private function entityTarget(array $data, string $key = 'target'): EntityIdentifier { - if (!isset($data[$key])) { - throw new InvalidArgumentException('Missing parameter: ' . $key); - } - if (!is_string($data[$key])) { - throw new InvalidArgumentException("Invalid parameter: $key must be a string"); - } - $identifier = ResourceIdentifier::fromString($data[$key]); - if (!$identifier instanceof EntityIdentifier) { - throw new InvalidArgumentException(self::ERR_TARGET_ENTITY); - } - return $identifier; - } - // ==================== Provider Operations ==================== private function providerList(string $tenantId, string $userId, array $data): mixed { @@ -620,55 +577,6 @@ class DefaultController extends ControllerAbstract { ); } - /** - * Wrap a generator of JsonSerializable domain objects in the canonical NDJSON - * stream envelope shared by every streaming operation: - * - * control:start {version, transaction, total?} — total? = expected count - * data {data} — one per domain object - * error {message} — on failure, then stop - * control:end {total} — total = objects emitted - * - * If the generator leads with an {@see ExpectedTotal} event, its value is - * folded into the start frame's `total` (the progress denominator) rather - * than emitted as a data frame. - * - * @param \Generator<\JsonSerializable> $items - */ - private function streamEnvelope(\Generator $items, int $version, string $transaction): \Generator { - // Peek the first event: an expected-total marker rides on the start frame. - $expected = null; - $items->rewind(); - if ($items->valid() && $items->current() instanceof ExpectedTotal) { - $expected = $items->current()->expectedTotal(); - $items->next(); - } - - $start = ['type' => 'control', 'status' => 'start', 'version' => $version, 'transaction' => $transaction]; - if ($expected !== null) { - $start['total'] = $expected; - } - yield $start; - - $total = 0; - try { - for (; $items->valid(); $items->next()) { - $item = $items->current(); - if (!$item instanceof \JsonSerializable) { - continue; - } - yield ['type' => 'data', 'data' => $item->jsonSerialize()]; - $total++; - } - } catch (\Throwable $t) { - $this->logger->error('Error streaming response', ['exception' => $t]); - yield ['type' => 'error', 'message' => $t->getMessage()]; - return; - } - - yield ['type' => 'control', 'status' => 'end', 'total' => $total]; - } - private function entityFetch(string $tenantId, string $userId, array $data): mixed { if (!isset($data['targets'])) { throw new InvalidArgumentException(self::ERR_MISSING_TARGETS); @@ -780,9 +688,6 @@ class DefaultController extends ControllerAbstract { return $this->entityRelocate($tenantId, $userId, $data, 'entityCopy'); } - /** - * Shared request handling for entity move/copy operations - */ private function entityRelocate(string $tenantId, string $userId, array $data, string $method): mixed { if (!isset($data['sources'])) { throw new InvalidArgumentException(self::ERR_MISSING_SOURCES); @@ -898,4 +803,135 @@ class DefaultController extends ControllerAbstract { ]; } + // ==================== Node Operations ==================== + + private function nodeList(string $tenantId, string $userId, array $data, int $version, string $transaction): StreamedNdJsonResponse { + + if (isset($data['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'); + } + } + } else { + $sources = null; + } + + $filter = $data['filter'] ?? null; + $sort = $data['sort'] ?? null; + $range = $data['range'] ?? null; + + $nodes = $this->manager->nodeList($tenantId, $userId, $sources, $filter, $sort, $range); + return new StreamedNdJsonResponse( + $this->streamEnvelope($nodes, $version, $transaction, static function (\JsonSerializable $node): array { + $data = $node->jsonSerialize(); + $data['@type'] = $node instanceof \KTXF\Documents\Collection\CollectionBaseInterface + ? 'document:collection' + : 'document:entity'; + return $data; + }), + 1, + 200, + ['Content-Type' => 'application/x-ndjson'], + ); + + } + + // ==================== Helper Methods ==================== + + private function collectionTarget(array $data, string $key = 'target'): CollectionIdentifier { + if (!isset($data[$key])) { + throw new InvalidArgumentException('Missing parameter: ' . $key); + } + if (!is_string($data[$key])) { + throw new InvalidArgumentException("Invalid parameter: $key must be a string"); + } + $identifier = ResourceIdentifier::fromString($data[$key]); + if (!$identifier instanceof CollectionIdentifier) { + throw new InvalidArgumentException(self::ERR_TARGET_COLLECTION); + } + return $identifier; + } + + private function collectionTargetOptional(array $data, string $key = 'target'): ?CollectionIdentifier { + if (!isset($data[$key]) || $data[$key] === null || $data[$key] === '') { + return null; + } + return $this->collectionTarget($data, $key); + } + + private function entityTarget(array $data, string $key = 'target'): EntityIdentifier { + if (!isset($data[$key])) { + throw new InvalidArgumentException('Missing parameter: ' . $key); + } + if (!is_string($data[$key])) { + throw new InvalidArgumentException("Invalid parameter: $key must be a string"); + } + $identifier = ResourceIdentifier::fromString($data[$key]); + if (!$identifier instanceof EntityIdentifier) { + throw new InvalidArgumentException(self::ERR_TARGET_ENTITY); + } + return $identifier; + } + + /** + * Wrap a generator of JsonSerializable domain objects in the canonical NDJSON + * stream envelope shared by every streaming operation: + * + * control:start {version, transaction, total?} — total? = expected count + * data {data} — one per domain object + * error {message} — on failure, then stop + * control:end {total} — total = objects emitted + * + * If the generator leads with an {@see ExpectedTotal} event, its value is + * folded into the start frame's `total` (the progress denominator) rather + * than emitted as a data frame. + * + * @param \Generator<\JsonSerializable> $items + */ + private function streamEnvelope(\Generator $items, int $version, string $transaction, ?callable $serialize = null): \Generator { + // Peek the first event: an expected-total marker rides on the start frame. + $expected = null; + try { + $items->rewind(); + if ($items->valid() && $items->current() instanceof ExpectedTotal) { + $expected = $items->current()->expectedTotal(); + $items->next(); + } + } catch (\Throwable $t) { + $this->logger->error('Error starting stream', ['exception' => $t]); + yield ['type' => 'error', 'message' => $t->getMessage()]; + return; + } + + $start = ['type' => 'control', 'status' => 'start', 'version' => $version, 'transaction' => $transaction]; + if ($expected !== null) { + $start['total'] = $expected; + } + yield $start; + + $total = 0; + try { + for (; $items->valid(); $items->next()) { + $item = $items->current(); + if (!$item instanceof \JsonSerializable) { + continue; + } + yield ['type' => 'data', 'data' => $serialize !== null ? $serialize($item) : $item->jsonSerialize()]; + $total++; + } + } catch (\Throwable $t) { + $this->logger->error('Error streaming response', ['exception' => $t]); + yield ['type' => 'error', 'message' => $t->getMessage()]; + return; + } + + yield ['type' => 'control', 'status' => 'end', 'total' => $total]; + } + } diff --git a/lib/Manager.php b/lib/Manager.php index 4b4c434..914e345 100644 --- a/lib/Manager.php +++ b/lib/Manager.php @@ -20,6 +20,7 @@ use KTXF\Documents\Service\ServiceBaseInterface; use KTXF\Documents\Service\ServiceCollectionMutableInterface; use KTXF\Documents\Service\ServiceEntityMutableInterface; use KTXF\Documents\Service\ServiceMutableInterface; +use KTXF\Documents\Service\ServiceNodeListInterface; use KTXF\Resource\Filter\IFilter; use KTXF\Preview\PreviewSource; use KTXF\Resource\BinaryResource; @@ -1404,6 +1405,85 @@ class Manager { return $this->previewManager->fetch($tenantId, $source, $variant); } + + /** + * List collections and entities within a location as one unified, paginated set + * + * Folders are ordered before files within the returned range. Unlike + * {@see collectionList} and {@see entityListBulk}, this requires the target + * service to natively implement {@see ServiceNodeListInterface} — there is + * intentionally no fallback for services that only support the separate + * collection/entity listing operations, since every current caller targets a + * single provider/service/location per request. + * + * @since 2026.09.01 + * + * @param string $tenantId Tenant identifier + * @param string $userId User identifier + * @param ResourceIdentifiers|null $targets Node sources with collection identifiers + * @param array|null $filter Node filter + * @param array|null $sort Node sort + * @param array|null $range Node range/pagination + * + * @return \Generator Nodes in provider order + * + * @throws InvalidArgumentException If the resolved service does not support unified node listing + */ + public function nodeList(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); + // retrieve nodes for each service + foreach ($aggregateServices as $services) { + /** @var ServiceBaseInterface $service */ + foreach ($services as $service) { + // omit disabled services + if ($service->getEnabled() === false) { + continue; + } + if ($service instanceof ServiceNodeListInterface === false) { + throw new InvalidArgumentException("Service '{$service->identifier()}' does not support paginated node listing"); + } + // retrieve collections for each service + $collectionSelected = $targets->byProvider($service->provider())->byService($service->identifier())->collections(); + if ($collectionSelected === []) { + // documents are hierarchical: service level selection lists the root collection + $collectionSelected = ['']; + } + // construct filter for nodes + $nodeFilter = null; + if ($filter !== null && $filter !== []) { + $nodeFilter = $service->nodeListFilter(); + foreach ($filter as $attribute => $value) { + $nodeFilter->condition($attribute, $value); + } + } + // construct sort for nodes + $nodeSort = null; + if ($sort !== null && $sort !== []) { + $nodeSort = $service->nodeListSort(); + foreach ($sort as $attribute => $direction) { + $nodeSort->condition($attribute, $direction); + } + } + // construct range for nodes + $nodeRange = null; + if ($range !== null && $range !== [] && isset($range['type'])) { + $nodeRange = $service->nodeListRange(RangeType::from($range['type']))->jsonDeserialize($range); + } + // Preserve provider order without grouping or buffering nodes. + foreach ($collectionSelected as $collectionId) { + $nodes = $service->nodeList($collectionId, $nodeFilter, $nodeSort, $nodeRange); + foreach ($nodes as $node) { + yield $node; + } + } + } + } + } }