feat: node list back end
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
@@ -163,56 +163,13 @@ class DefaultController extends ControllerAbstract {
|
|||||||
'entity.write' => $this->entityWrite($tenantId, $userId, $data),
|
'entity.write' => $this->entityWrite($tenantId, $userId, $data),
|
||||||
'entity.write.chunk' => $this->entityWriteChunk($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)
|
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 ====================
|
// ==================== Provider Operations ====================
|
||||||
|
|
||||||
private function providerList(string $tenantId, string $userId, array $data): mixed {
|
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 {
|
private function entityFetch(string $tenantId, string $userId, array $data): mixed {
|
||||||
if (!isset($data['targets'])) {
|
if (!isset($data['targets'])) {
|
||||||
throw new InvalidArgumentException(self::ERR_MISSING_TARGETS);
|
throw new InvalidArgumentException(self::ERR_MISSING_TARGETS);
|
||||||
@@ -780,9 +688,6 @@ class DefaultController extends ControllerAbstract {
|
|||||||
return $this->entityRelocate($tenantId, $userId, $data, 'entityCopy');
|
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 {
|
private function entityRelocate(string $tenantId, string $userId, array $data, string $method): mixed {
|
||||||
if (!isset($data['sources'])) {
|
if (!isset($data['sources'])) {
|
||||||
throw new InvalidArgumentException(self::ERR_MISSING_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];
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ use KTXF\Documents\Service\ServiceBaseInterface;
|
|||||||
use KTXF\Documents\Service\ServiceCollectionMutableInterface;
|
use KTXF\Documents\Service\ServiceCollectionMutableInterface;
|
||||||
use KTXF\Documents\Service\ServiceEntityMutableInterface;
|
use KTXF\Documents\Service\ServiceEntityMutableInterface;
|
||||||
use KTXF\Documents\Service\ServiceMutableInterface;
|
use KTXF\Documents\Service\ServiceMutableInterface;
|
||||||
|
use KTXF\Documents\Service\ServiceNodeListInterface;
|
||||||
use KTXF\Resource\Filter\IFilter;
|
use KTXF\Resource\Filter\IFilter;
|
||||||
use KTXF\Preview\PreviewSource;
|
use KTXF\Preview\PreviewSource;
|
||||||
use KTXF\Resource\BinaryResource;
|
use KTXF\Resource\BinaryResource;
|
||||||
@@ -1404,6 +1405,85 @@ class Manager {
|
|||||||
|
|
||||||
return $this->previewManager->fetch($tenantId, $source, $variant);
|
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<int,CollectionBaseInterface|EntityBaseInterface> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user