feat: implement entiry read and write

Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
2026-10-01 21:31:00 -04:00
parent dbfb5f8b37
commit cc1deb4c07
3 changed files with 335 additions and 67 deletions
+2 -2
View File
@@ -44,8 +44,8 @@ class EntityProperties extends EntityPropertiesMutableAbstract {
public function toJmap(): array {
$parameters = array_filter([
'name' => $this->data[self::PROPERTY_LABEL],
'type' => $this->data[self::PROPERTY_MIME] ?? 'application/octet-stream'
'name' => $this->data[self::PROPERTY_LABEL] ?? null,
'type' => $this->data[self::PROPERTY_MIME] ?? null
], static fn($value) => $value !== null);
return $parameters;
+208 -4
View File
@@ -10,19 +10,27 @@ declare(strict_types=1);
namespace KTXM\ProviderJmapc\Providers\Document;
use Generator;
use KTXF\Blob\Signature;
use KTXF\Documents\Collection\CollectionBaseInterface;
use KTXF\Documents\Collection\CollectionMutableInterface;
use KTXF\Documents\Collection\CollectionPropertiesBaseInterface;
use KTXF\Documents\Entity\EntityBaseInterface;
use KTXF\Documents\Entity\EntityMutableInterface;
use KTXF\Documents\Entity\EntityPropertiesMutableInterface;
use KTXF\Documents\Service\ServiceBaseInterface;
use KTXF\Documents\Service\ServiceCollectionMutableInterface;
use KTXF\Documents\Service\ServiceConfigurableInterface;
use KTXF\Documents\Service\ServiceEntityMutableInterface;
use KTXF\Documents\Service\ServiceMutableInterface;
use KTXF\Documents\Service\ServiceNodeListInterface;
use KTXF\Resource\Delta\Delta;
use KTXF\Resource\Exceptions\InvalidParameterException;
use KTXF\Resource\Exceptions\UnsupportedException;
use KTXF\Resource\Filter\Filter;
use KTXF\Resource\Filter\IFilter;
use KTXF\Resource\Identifier\CollectionIdentifier;
use KTXF\Resource\Identifier\CollectionIdentifierInterface;
use KTXF\Resource\Identifier\EntityIdentifier;
use KTXF\Resource\Identifier\EntityIdentifierInterface;
use KTXF\Resource\Provider\ResourceServiceIdentityInterface;
use KTXF\Resource\Provider\ResourceServiceLocationInterface;
@@ -38,7 +46,7 @@ use KTXM\ProviderJmapc\Providers\ServiceLocation;
use KTXM\ProviderJmapc\Service\Remote\RemoteFilesService;
use KTXM\ProviderJmapc\Service\Remote\RemoteService;
class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceConfigurableInterface, ServiceCollectionMutableInterface, ServiceNodeListInterface
class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceConfigurableInterface, ServiceCollectionMutableInterface, ServiceEntityMutableInterface, ServiceNodeListInterface
{
public const JSON_TYPE = ServiceBaseInterface::JSON_TYPE;
@@ -80,6 +88,13 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC
self::CAPABILITY_ENTITY_DELTA => true,
self::CAPABILITY_ENTITY_EXTANT => true,
self::CAPABILITY_ENTITY_FETCH => true,
self::CAPABILITY_ENTITY_READ => true,
self::CAPABILITY_ENTITY_CREATE => true,
self::CAPABILITY_ENTITY_MODIFY => true,
self::CAPABILITY_ENTITY_DELETE => true,
self::CAPABILITY_ENTITY_MOVE => true,
self::CAPABILITY_ENTITY_COPY => true,
self::CAPABILITY_ENTITY_WRITE => true,
self::CAPABILITY_NODE_LIST => true,
self::CAPABILITY_NODE_LIST_FILTER => [
self::CAPABILITY_NODE_FILTER_LABEL => 's:100:256:256',
@@ -504,19 +519,208 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC
yield from $this->entityFetchBulk(...$identifiers);
}
public function entityFresh(): EntityMutableInterface
{
return new EntityResource(provider: $this->provider(), service: $this->identifier());
}
/**
* Convert incoming entity properties into a native EntityResource
*/
private function entityNative(EntityPropertiesMutableInterface $properties, string|int|null $identifier = null): EntityResource
{
if ($properties instanceof EntityResource) {
return clone $properties;
}
$object = new EntityResource(provider: $this->provider(), service: $this->identifier());
$object->jsonDeserialize(array_filter([
EntityResource::PROPERTY_IDENTIFIER => $identifier,
EntityResource::PROPERTY_PROPERTIES => $properties->jsonSerialize(),
], fn($v) => $v !== null));
return $object;
}
/**
* Retrieve the remote parameters of an entity
*/
private function entityRemote(EntityIdentifierInterface $target): ?array
{
$this->initialize();
$identifier = (string)$target->entity();
$entity = $this->remoteService->entityFetch($identifier)[$identifier] ?? null;
// folders are not entities
if (!is_array($entity) || empty($entity['blobId'])) {
return null;
}
return $entity;
}
public function entityCreate(CollectionIdentifierInterface $target, EntityPropertiesMutableInterface $properties, array $options = []): EntityBaseInterface
{
$this->initialize();
$entity = $this->entityNative($properties)->toJmap();
$entity['type'] ??= 'application/octet-stream';
// a file node must reference a blob, so start with empty content until it is written
$content = '';
$blob = $this->remoteService->blobUpload($entity['type'], $content);
$entity['blobId'] = $blob['blobId'];
$entity = $this->remoteService->entityCreate((string)$target->collection(), $entity);
if ($entity === null) {
throw new \RuntimeException("Failed to create entity in collection: {$target->collection()}");
}
$object = new EntityResource(provider: $this->provider(), service: $this->identifier());
$object->fromJmap($entity);
return $object;
}
public function entityModify(EntityIdentifierInterface $target, EntityPropertiesMutableInterface $properties): EntityBaseInterface
{
$this->initialize();
$identifier = (string)$target->entity();
$entity = $this->entityNative($properties, $identifier)->toJmap();
if ($this->remoteService->entityModify($identifier, $entity) === null) {
throw new \RuntimeException("Failed to modify entity: $identifier");
}
$object = new EntityResource(provider: $this->provider(), service: $this->identifier());
$object->fromJmap($this->entityRemote($target) ?? $entity);
return $object;
}
public function entityDelete(EntityIdentifierInterface ...$targets): array
{
$this->initialize();
$results = [];
foreach ($targets as $target) {
$key = (string)$target;
try {
$deleted = $this->remoteService->entityDelete((string)$target->entity()) !== null;
} catch (\Throwable) {
$deleted = false;
}
$results[$key] = ['disposition' => $deleted ? 'deleted' : 'error', 'destination' => null, 'mutation' => $target];
}
return $results;
}
public function entityMove(CollectionIdentifierInterface $target, EntityIdentifierInterface ...$sources): array
{
return $this->entityRelocate($target, true, ...$sources);
}
public function entityCopy(CollectionIdentifierInterface $target, EntityIdentifierInterface ...$sources): array
{
return $this->entityRelocate($target, false, ...$sources);
}
/**
* Relocate (move or copy) entities into a target collection
*
* @return array<string, array{disposition: string, destination: ?CollectionIdentifierInterface, mutation: EntityIdentifierInterface}>
*/
private function entityRelocate(CollectionIdentifierInterface $target, bool $remove, EntityIdentifierInterface ...$sources): array
{
$this->initialize();
$results = [];
$collection = (string)$target->collection();
foreach ($sources as $source) {
$key = (string)$source;
try {
$entity = $remove
? $this->remoteService->entityMove($collection, (string)$source->entity())
: $this->remoteService->entityCopy($collection, (string)$source->entity());
} catch (\Throwable) {
$entity = null;
}
if ($entity === null) {
$results[$key] = ['disposition' => 'error', 'destination' => null, 'mutation' => $source];
continue;
}
$results[$key] = [
'disposition' => $remove ? 'moved' : 'copied',
'destination' => new CollectionIdentifier($this->provider(), $this->identifier(), $collection),
'mutation' => new EntityIdentifier($this->provider(), $this->identifier(), $collection, (string)$entity['id']),
];
}
return $results;
}
public function entityRead(EntityIdentifierInterface $target): ?string
{
return null;
$entity = $this->entityRemote($target);
if ($entity === null) {
return null;
}
return $this->remoteService->blobDownload($entity['blobId'], $entity['type'] ?? 'application/octet-stream');
}
public function entityReadStream(EntityIdentifierInterface $target)
{
return null;
$entity = $this->entityRemote($target);
if ($entity === null) {
return null;
}
return $this->remoteService->blobDownloadStream($entity['blobId'], $entity['type'] ?? 'application/octet-stream');
}
public function entityReadChunk(EntityIdentifierInterface $target, int $offset, int $length): ?string
{
return null;
$content = $this->entityRead($target);
if ($content === null) {
return null;
}
return substr($content, $offset, $length);
}
public function entityWrite(EntityIdentifierInterface $target, string $data): int
{
$entity = $this->entityRemote($target);
$identifier = (string)$target->entity();
if ($entity === null) {
throw new InvalidParameterException("Entity not found: $identifier");
}
// blobs are immutable, so content is replaced by pointing the node at a new blob
$type = $entity['type'] ?? null;
if (empty($type) || $type === 'application/octet-stream') {
$type = Signature::detect(substr($data, 0, Signature::SAMPLE_SIZE), $data)['mime'];
}
$blob = $this->remoteService->blobUpload($type, $data);
if ($this->remoteService->entityModify($identifier, ['blobId' => $blob['blobId'], 'type' => $type]) === null) {
throw new \RuntimeException("Failed to write to entity: $identifier");
}
return strlen($data);
}
public function entityWriteChunk(EntityIdentifierInterface $target, int $offset, string $data): int
{
if ($offset === 0) {
$content = $data;
} else {
// blobs can not be partially updated, so the chunk is merged into the current content
$content = $this->entityRead($target);
if ($content === null) {
throw new InvalidParameterException("Entity not found: {$target->entity()}");
}
$content = substr(str_pad($content, $offset, "\0"), 0, $offset) . $data . substr($content, $offset + strlen($data));
}
$this->entityWrite($target, $content);
return strlen($data);
}
public function entityWriteStream(EntityIdentifierInterface $target)
{
throw new UnsupportedException('Streamed writes are not supported by JMAP file storage');
}
// Node operations (unified collections + entities)