diff --git a/lib/Db/File.php b/lib/Db/File.php new file mode 100644 index 0000000000..3214645fa8 --- /dev/null +++ b/lib/Db/File.php @@ -0,0 +1,217 @@ +addType('uuid', 'string'); + $this->addType('filename', 'string'); + $this->addType('downloadUrl', 'string'); + $this->addType('shareUrl', 'string'); + $this->addType('accessUrl', 'string'); + $this->addType('extension', 'string'); + $this->addType('checksum', 'string'); + $this->addType('source', 'int'); + $this->addType('userId', 'string'); + $this->addType('base64', 'string'); + $this->addType('filePath', 'string'); + $this->addType('created', 'datetime'); + $this->addType('updated', 'datetime'); + } + + /** + * Retrieves the fields that should be treated as JSON. + * + * @return array List of JSON field names. + */ + public function getJsonFields(): array + { + return array_keys( + array_filter($this->getFieldTypes(), function ($field) { + return $field === 'json'; + }) + ); + } + + /** + * Populates the entity with data from an array. + * + * @param array $object Data to populate the entity. + * + * @return self The hydrated entity. + */ + public function hydrate(array $object): self + { + $jsonFields = $this->getJsonFields(); + + foreach ($object as $key => $value) { + if (in_array($key, $jsonFields) === true && $value === []) { + $value = []; + } + + $method = 'set' . ucfirst($key); + + try { + $this->$method($value); + } catch (Exception $exception) { + // Log or handle the exception. + } + } + + return $this; + } + + /** + * Serializes the entity to a JSON-compatible array. + * + * @return array The serialized entity data. + */ + public function jsonSerialize(): array + { + return [ + 'id' => $this->id, + 'uuid' => $this->uuid, + 'filename' => $this->filename, + 'downloadUrl' => $this->downloadUrl, + 'shareUrl' => $this->shareUrl, + 'accessUrl' => $this->accessUrl, + 'extension' => $this->extension, + 'checksum' => $this->checksum, + 'source' => $this->source, + 'userId' => $this->userId, + 'base64' => $this->base64, + 'filePath' => $this->filePath, + 'created' => isset($this->created) === true ? $this->created->format('c') : null, + 'updated' => isset($this->updated) === true ? $this->updated->format('c') : null, + ]; + } + + /** + * Generates a JSON schema for the File entity. + * + * @param IURLGenerator $IURLGenerator The URL generator instance. + * + * @return string The JSON schema as a string. + */ + public static function getSchema(IURLGenerator $IURLGenerator): string + { + return json_encode([ + '$id' => $IURLGenerator->getBaseUrl().'/apps/openconnector/api/files/schema', + '$schema' => 'https://json-schema.org/draft/2020-12/schema', + 'type' => 'object', + 'required' => [ + ], + 'properties' => [ + 'filename' => [ + 'type' => 'string', + 'minLength' => 1, + 'maxLength' => 255 + ], + 'downloadUrl' => [ + 'type' => 'string', + 'format' => 'uri', + ], + 'shareUrl' => [ + 'type' => 'string', + 'format' => 'uri', + ], + 'accessUrl' => [ + 'type' => 'string', + 'format' => 'uri', + ], + 'extension' => [ + 'type' => 'string', + 'maxLength' => 10, + ], + 'checksum' => [ + 'type' => 'string', + ], + 'source' => [ + 'type' => 'number', + ], + 'userId' => [ + 'type' => 'string', + ], + 'base64' => [ + 'type' => 'string' + ] + ] + ]); + } +} diff --git a/lib/Db/FileMapper.php b/lib/Db/FileMapper.php new file mode 100644 index 0000000000..59bf430bdf --- /dev/null +++ b/lib/Db/FileMapper.php @@ -0,0 +1,191 @@ +db->getQueryBuilder(); + + $qb->select('*') + ->from('openregister_files') + ->where( + $qb->expr()->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT)) + ); + + return $this->findEntity(query: $qb); + } + + /** + * Retrieves all File entities with optional filtering, search, and pagination. + * + * @param int|null $limit Maximum number of results to return. + * @param int|null $offset Number of results to skip. + * @param array|null $filters Key-value pairs to filter results. + * @param array|null $searchConditions Search conditions for query. + * @param array|null $searchParams Parameters for search conditions. + * + * @return array List of File entities. + * @throws Exception If a database error occurs. + */ + public function findAll(?int $limit = null, ?int $offset = null, ?array $filters = [], ?array $searchConditions = [], ?array $searchParams = []): array + { + $qb = $this->db->getQueryBuilder(); + + $qb->select('*') + ->from('openregister_files') + ->setMaxResults($limit) + ->setFirstResult($offset); + + foreach ($filters as $filter => $value) { + $filter = strtolower(preg_replace('/(?andWhere($qb->expr()->isNotNull($filter)); + } elseif ($value === 'IS NULL') { + $qb->andWhere($qb->expr()->isNull($filter)); + } else { + $qb->andWhere($qb->expr()->eq($filter, $qb->createNamedParameter($value))); + } + } + + if (empty($searchConditions) === false) { + $qb->andWhere('(' . implode(' OR ', $searchConditions) . ')'); + foreach ($searchParams as $param => $value) { + $qb->setParameter($param, $value); + } + } + + return $this->findEntities(query: $qb); + } + + /** + * @inheritDoc + * + * @param \OCA\OpenRegister\Db\File|Entity $entity + * @return \OCA\OpenRegister\Db\File + * @throws \OCP\DB\Exception + */ + public function insert(File|Entity $entity): File + { + // Set created and updated fields + $entity->setCreated(new DateTime()); + $entity->setUpdated(new DateTime()); + + if ($entity->getUuid() === null) { + $entity->setUuid(Uuid::v4()); + } + + return parent::insert($entity); + } + + /** + * @inheritDoc + * + * @param \OCA\OpenRegister\Db\File|Entity $entity + * @return \OCA\OpenRegister\Db\File + * @throws \OCP\DB\Exception + */ + public function update(File|Entity $entity): File + { + // Set updated field + $entity->setUpdated(new DateTime()); + + return parent::update($entity); + } + + /** + * Creates a File entity from an array of data. + * + * @param array $object The data to create the entity from. + * + * @return \OCA\OpenRegister\Db\File The created File entity. + * @throws Exception If a database error occurs. + */ + public function createFromArray(array $object): File + { + $obj = new File(); + $obj->hydrate($object); + // Set UUID + if ($obj->getUuid() === null) { + $obj->setUuid(Uuid::v4()); + } + return $this->insert(entity: $obj); + } + + /** + * Updates a File entity by its ID using an array of data. + * + * @param int $id The ID of the file to update. + * @param array $object The data to update the entity with. + * + * @return \OCA\OpenRegister\Db\File The updated File entity. + * @throws DoesNotExistException If no file is found with the given ID. + * @throws Exception If a database error occurs. + * @throws MultipleObjectsReturnedException If multiple files are found with the given ID. + */ + public function updateFromArray(int $id, array $object): File + { + $obj = $this->find($id); + $obj->hydrate($object); + + // Set or update the version + $version = explode('.', $obj->getVersion()); + $version[2] = (int)$version[2] + 1; + $obj->setVersion(implode('.', $version)); + + return $this->update($obj); + } + + /** + * Gets the total count of files. + * + * @return int The total number of files in the database. + * @throws Exception If a database error occurs. + */ + public function countAll(): int + { + $qb = $this->db->getQueryBuilder(); + + // Select count of all files + $qb->select($qb->createFunction('COUNT(*) as count')) + ->from('openregister_files'); + + $result = $qb->execute(); + $row = $result->fetch(); + + // Return the total count + return (int)$row['count']; + } +} diff --git a/lib/Db/ObjectAuditLogMapper.php b/lib/Db/ObjectAuditLogMapper.php index f31fdafdf8..387fb6ff24 100644 --- a/lib/Db/ObjectAuditLogMapper.php +++ b/lib/Db/ObjectAuditLogMapper.php @@ -78,7 +78,7 @@ public function updateFromArray(int $id, array $object): ObjectAuditLog // Set or update the version if (isset($object['version']) === false) { $version = explode('.', $obj->getVersion()); - $version[2] = (int)$version[2] + 1; + $version[2] = (int) $version[2] + 1; $obj->setVersion(implode('.', $version)); } diff --git a/lib/Db/ObjectEntityMapper.php b/lib/Db/ObjectEntityMapper.php index 5d7507928a..1b3f0a8933 100644 --- a/lib/Db/ObjectEntityMapper.php +++ b/lib/Db/ObjectEntityMapper.php @@ -92,6 +92,29 @@ public function findByUuid(Register $register, Schema $schema, string $uuid): Ob } } + /** + * Find an object by UUID only + * + * @param string $uuid The UUID of the object to find + * @return ObjectEntity The object + */ + public function findByUuidOnly(string $uuid): ObjectEntity|null + { + $qb = $this->db->getQueryBuilder(); + + $qb->select('*') + ->from('openregister_objects') + ->where( + $qb->expr()->eq('uuid', $qb->createNamedParameter($uuid)) + ); + + try { + return $this->findEntity($qb); + } catch (\OCP\AppFramework\Db\DoesNotExistException $e) { + return null; + } + } + /** * Find objects by register and schema * @@ -246,7 +269,7 @@ public function updateFromArray(int $id, array $object): ObjectEntity // Set or update the version if (isset($object['version']) === false) { $version = explode('.', $obj->getVersion()); - $version[2] = (int)$version[2] + 1; + $version[2] = (int) $version[2] + 1; $obj->setVersion(implode('.', $version)); } diff --git a/lib/Db/RegisterMapper.php b/lib/Db/RegisterMapper.php index 569d8f5e31..1f573d8fff 100644 --- a/lib/Db/RegisterMapper.php +++ b/lib/Db/RegisterMapper.php @@ -130,7 +130,7 @@ public function updateFromArray(int $id, array $object): Register // Update the version if (isset($object['version']) === false) { $version = explode('.', $obj->getVersion()); - $version[2] = (int)$version[2] + 1; + $version[2] = (int) $version[2] + 1; $obj->setVersion(implode('.', $version)); } diff --git a/lib/Db/Schema.php b/lib/Db/Schema.php index 571618ce66..db08bf7745 100644 --- a/lib/Db/Schema.php +++ b/lib/Db/Schema.php @@ -132,6 +132,28 @@ public function getSchemaObject(IURLGenerator $urlGenerator): object foreach ($data['properties'] as $title => $property) { // Remove empty fields with array_filter(). $data['properties'][$title] = array_filter($property); + + if ($property['type'] === 'file') { + $data['properties'][$title] = ['$ref' => $urlGenerator->getBaseUrl().'/apps/openregister/api/files/schema']; + } + if ($property['type'] === 'oneOf') { + unset($data['properties'][$title]['type']); + $data['properties'][$title]['oneOf'] = array_map( + callback: function (array $item) use ($urlGenerator) { + if ($item['type'] === 'file') { + unset($item['type']); + $item['$ref'] = $urlGenerator->getBaseUrl().'/apps/openregister/api/files/schema'; + } + + return $item; + }, + array: $property['oneOf']); + } + if ($property['type'] === 'array' + && isset($property['items']['type']) === true + && $property['items']['type'] === 'oneOf') { + unset($data['properties'][$title]['items']['type']); + } } unset($data['id'], $data['uuid'], $data['summary'], $data['archive'], $data['source'], @@ -141,7 +163,7 @@ public function getSchemaObject(IURLGenerator $urlGenerator): object // Validator needs this specific $schema $data['$schema'] = 'https://json-schema.org/draft/2020-12/schema'; - $data['$id'] = $urlGenerator->getAbsoluteURL($urlGenerator->linkToRoute('openregister.Schemas.show', ['id' => $this->getUuid()])); + $data['$id'] = $urlGenerator->getAbsoluteURL($urlGenerator->linkToRoute('openregister.Schemas.show', ['id' => $this->getId()])); return json_decode(json_encode($data)); } diff --git a/lib/Db/SchemaMapper.php b/lib/Db/SchemaMapper.php index 35b22c3447..11f3953405 100644 --- a/lib/Db/SchemaMapper.php +++ b/lib/Db/SchemaMapper.php @@ -135,7 +135,7 @@ public function updateFromArray(int $id, array $object): Schema // Set or update the version if (isset($object['version']) === false) { $version = explode('.', $obj->getVersion()); - $version[2] = (int)$version[2] + 1; + $version[2] = (int) $version[2] + 1; $obj->setVersion(implode('.', $version)); } diff --git a/lib/Db/SourceMapper.php b/lib/Db/SourceMapper.php index 02d0175bfd..7151106573 100644 --- a/lib/Db/SourceMapper.php +++ b/lib/Db/SourceMapper.php @@ -118,7 +118,7 @@ public function updateFromArray(int $id, array $object): Source // Set or update the version if (isset($object['version']) === false) { $version = explode('.', $obj->getVersion()); - $version[2] = (int)$version[2] + 1; + $version[2] = (int) $version[2] + 1; $obj->setVersion(implode('.', $version)); } diff --git a/lib/Migration/Version1Date20241216094112.php b/lib/Migration/Version1Date20241216094112.php new file mode 100644 index 0000000000..f899f7ab16 --- /dev/null +++ b/lib/Migration/Version1Date20241216094112.php @@ -0,0 +1,70 @@ +hasTable('openregister_files') === false) { + $table = $schema->createTable('openregister_files'); + $table->addColumn(name: 'id', typeName: Types::BIGINT, options: ['autoincrement' => true, 'notnull' => true, 'length' => 255]); + $table->addColumn(name: 'uuid', typeName: Types::STRING, options: ['notnull' => true, 'length' => 255]); + $table->addColumn(name: 'filename', typeName: Types::STRING, options: ['notnull' => false, 'length' => 255]); + $table->addColumn(name: 'download_url', typeName: Types::STRING, options: ['notnull' => false, 'length' => 1023]); + $table->addColumn(name: 'share_url', typeName: Types::STRING, options: ['notnull' => false, 'length' => 1023]); + $table->addColumn(name: 'access_url', typeName: Types::STRING, options: ['notnull' => false, 'length' => 1023]); + $table->addColumn(name: 'extension', typeName: Types::STRING, options: ['notnull' => false, 'length' => 255]); + $table->addColumn(name: 'checksum', typeName: Types::STRING, options: ['notnull' => false, 'length' => 255]); + $table->addColumn(name: 'source', typeName: Types::INTEGER, options: ['notnull' => false, 'length' => 255]); + $table->addColumn(name: 'user_id', typeName: Types::STRING, options: ['notnull' => false, 'length' => 255]); + $table->addColumn(name: 'created', typeName: Types::DATETIME_IMMUTABLE, options: ['notnull' => true, 'length' => 255]); + $table->addColumn(name: 'updated', typeName: Types::DATETIME_MUTABLE, options: ['notnull' => true, 'length' => 255]); + $table->addColumn(name: 'file_path', typeName: Types::STRING)->setNotnull(false)->setDefault(null); + + $table->setPrimaryKey(['id']); + } + + return $schema; + } + + /** + * @param IOutput $output + * @param Closure(): ISchemaWrapper $schemaClosure + * @param array $options + */ + public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void { + } +} diff --git a/lib/Service/FileService.php b/lib/Service/FileService.php index 9d547b559b..2d9a8326ff 100644 --- a/lib/Service/FileService.php +++ b/lib/Service/FileService.php @@ -97,7 +97,7 @@ private function getCurrentDomain(): string $baseUrl = $this->urlGenerator->getBaseUrl(); $trustedDomains = $this->config->getSystemValue('trusted_domains'); - if(isset($trustedDomains[1]) === true) { + if (isset($trustedDomains[1]) === true) { $baseUrl = str_replace(search: 'localhost', replace: $trustedDomains[1], subject: $baseUrl); } diff --git a/lib/Service/MySQLJsonService.php b/lib/Service/MySQLJsonService.php index 3331ef1eae..43074209b6 100644 --- a/lib/Service/MySQLJsonService.php +++ b/lib/Service/MySQLJsonService.php @@ -140,7 +140,7 @@ public function filterJson(IQueryBuilder $builder, array $filters): IQueryBuilde // Create parameter for JSON path $builder->createNamedParameter(value: "$.$filter", placeHolder: ":path$filter"); - if(is_array($value) === true && array_is_list($value) === false) { + if (is_array($value) === true && array_is_list($value) === false) { // Handle complex filters (after/before) $builder = $this->jsonFilterArray(builder: $builder, filter: $filter, values: $value); continue; diff --git a/lib/Service/ObjectService.php b/lib/Service/ObjectService.php index 32a6190cb0..0ec366366a 100755 --- a/lib/Service/ObjectService.php +++ b/lib/Service/ObjectService.php @@ -3,10 +3,13 @@ namespace OCA\OpenRegister\Service; use Adbar\Dot; +use DateTime; use Exception; use GuzzleHttp\Exception\GuzzleException; use InvalidArgumentException; -use OC\URLGenerator; +use JsonSerializable; +use OCA\OpenRegister\Db\File; +use OCA\OpenRegister\Db\FileMapper; use OCA\OpenRegister\Db\Source; use OCA\OpenRegister\Db\SourceMapper; use OCA\OpenRegister\Db\Schema; @@ -15,15 +18,16 @@ use OCA\OpenRegister\Db\RegisterMapper; use OCA\OpenRegister\Db\ObjectEntity; use OCA\OpenRegister\Db\ObjectEntityMapper; -use OCA\OpenRegister\Db\AuditTrail; use OCA\OpenRegister\Db\AuditTrailMapper; use OCA\OpenRegister\Db\ObjectAuditLogMapper; use OCA\OpenRegister\Exception\ValidationException; use OCA\OpenRegister\Formats\BsnFormat; use OCP\App\IAppManager; +use OCP\IAppConfig; use OCP\IURLGenerator; use Opis\JsonSchema\ValidationResult; use Opis\JsonSchema\Validator; +use Opis\Uri\Uri; use Psr\Container\ContainerExceptionInterface; use Psr\Container\ContainerInterface; use Psr\Container\NotFoundExceptionInterface; @@ -32,70 +36,70 @@ use GuzzleHttp\Client; /** - * Service class for handling object operations + * Service class for handling object operations. * - * This service provides methods for CRUD operations on objects, including: - * - Creating, reading, updating and deleting objects - * - Finding objects by ID/UUID - * - Getting audit trails - * - Extending objects with related data + * This service provides methods for: + * - CRUD operations on objects. + * - Schema resolution and validation. + * - Managing relations and linked data (extending objects with related sub-objects). + * - Audit trails and data aggregation. * * @package OCA\OpenRegister\Service */ class ObjectService { - /** @var int The current register ID */ - private int $register; + /** @var int The current register ID */ + private int $register; - /** @var int The current schema ID */ - private int $schema; + /** @var int The current schema ID */ + private int $schema; - /** @var AuditTrailMapper For tracking object changes */ - private AuditTrailMapper $auditTrailMapper; - - /** - * Constructor for ObjectService - * - * Initializes the service with required mappers for database operations - * - * @param ObjectEntityMapper $objectEntityMapper Mapper for object entities - * @param RegisterMapper $registerMapper Mapper for registers - * @param SchemaMapper $schemaMapper Mapper for schemas - * @param AuditTrailMapper $auditTrailMapper Mapper for audit trails - */ - public function __construct( - ObjectEntityMapper $objectEntityMapper, - RegisterMapper $registerMapper, - SchemaMapper $schemaMapper, - AuditTrailMapper $auditTrailMapper, - ObjectAuditLogMapper $objectAuditLogMapper, - private ContainerInterface $container, - private readonly IURLGenerator $urlGenerator, - private readonly FileService $fileService, - private readonly IAppManager $appManager - ) - { - $this->objectEntityMapper = $objectEntityMapper; - $this->registerMapper = $registerMapper; - $this->schemaMapper = $schemaMapper; - $this->auditTrailMapper = $auditTrailMapper; - $this->objectAuditLogMapper = $objectAuditLogMapper; - } + /** + * Constructor for ObjectService. + * + * Initializes the service with dependencies required for database and object operations. + * + * @param ObjectEntityMapper $objectEntityMapper Object entity data mapper. + * @param RegisterMapper $registerMapper Register data mapper. + * @param SchemaMapper $schemaMapper Schema data mapper. + * @param AuditTrailMapper $auditTrailMapper Audit trail data mapper. + * @param ContainerInterface $container Dependency injection container. + * @param IURLGenerator $urlGenerator URL generator service. + * @param FileService $fileService File service for managing files. + * @param IAppManager $appManager Application manager service. + * @param IAppConfig $config Configuration manager. + */ + public function __construct( + private readonly ObjectEntityMapper $objectEntityMapper, + private readonly RegisterMapper $registerMapper, + private readonly SchemaMapper $schemaMapper, + private readonly AuditTrailMapper $auditTrailMapper, + private readonly ObjectAuditLogMapper $objectAuditLogMapper, + private readonly ContainerInterface $container, + private readonly IURLGenerator $urlGenerator, + private readonly FileService $fileService, + private readonly IAppManager $appManager, + private readonly IAppConfig $config, + private readonly FileMapper $fileMapper, + ) + { + } /** - * Attempts to retrieve the OpenConnector service from the container. + * Retrieves the OpenConnector service from the container. + * + * @param string $filePath Optional file path for the OpenConnector service. * - * @return mixed|null The OpenConnector service if available, null otherwise. - * @throws ContainerExceptionInterface|NotFoundExceptionInterface + * @return mixed|null The OpenConnector service instance or null if not available. + * @throws ContainerExceptionInterface If there is a container exception. + * @throws NotFoundExceptionInterface If the service is not found. */ public function getOpenConnector(string $filePath = '\Service\ObjectService'): mixed { - if (in_array(needle: 'openconnector', haystack: $this->appManager->getInstalledApps()) === true) { + if (in_array('openconnector', $this->appManager->getInstalledApps())) { try { - // Attempt to get a OpenConnector file from the container return $this->container->get("OCA\OpenConnector$filePath"); } catch (Exception $e) { - // If the file is not available, return null return null; } } @@ -104,14 +108,51 @@ public function getOpenConnector(string $filePath = '\Service\ObjectService'): m } /** - * Validate an object with a schema. - * If schema is not given and schemaObject is filled, the object will validate to the schemaObject. + * Resolves a schema from a given URI. + * + * @param Uri $uri The URI pointing to the schema. + * + * @return string The schema content in JSON format. + * @throws GuzzleException If there is an error during schema fetching. + */ + public function resolveSchema(Uri $uri): string + { + // Local schema resolution + if ($this->urlGenerator->getBaseUrl() === $uri->scheme() . '://' . $uri->host() + && str_contains($uri->path(), '/api/schemas') + ) { + $exploded = explode('/', $uri->path()); + $schema = $this->schemaMapper->find(end($exploded)); + + return json_encode($schema->getSchemaObject($this->urlGenerator)); + } + + // File schema resolution + if ($this->urlGenerator->getBaseUrl() === $uri->scheme() . '://' . $uri->host() + && str_contains($uri->path(), '/api/files/schema') + ) { + return File::getSchema($this->urlGenerator); + } + + // External schema resolution + if ($this->config->getValueBool('openregister', 'allowExternalSchemas')) { + $client = new Client(); + $result = $client->get(\GuzzleHttp\Psr7\Uri::fromParts($uri->components())); + + return $result->getBody()->getContents(); + } + + return ''; + } + + /** + * Validates an object against a schema. * - * @param array $object The object to validate. - * @param int|null $schemaId The id of the schema to validate to. - * @param object $schemaObject A schema object to validate to. + * @param array $object The object to validate. + * @param int|null $schemaId The schema ID to validate against. + * @param object $schemaObject A custom schema object for validation. * - * @return ValidationResult The validation result from opis/json-schema. + * @return ValidationResult The result of the validation. */ public function validateObject(array $object, ?int $schemaId = null, object $schemaObject = new stdClass()): ValidationResult { @@ -119,119 +160,129 @@ public function validateObject(array $object, ?int $schemaId = null, object $sch $schemaObject = $this->schemaMapper->find($schemaId)->getSchemaObject($this->urlGenerator); } + // if there are no properties we dont have to validate + if ($schemaObject instanceof stdClass || !method_exists($schemaObject, 'getProperties')) { + // Return a default ValidationResult indicating success + return new ValidationResult(null); + } + $validator = new Validator(); $validator->setMaxErrors(100); $validator->parser()->getFormatResolver()->register('string', 'bsn', new BsnFormat()); + $validator->loader()->resolver()->registerProtocol('http', [$this, 'resolveSchema']); - return $validator->validate(data: json_decode(json_encode($object)), schema: $schemaObject); - + return $validator->validate(json_decode(json_encode($object)), $schemaObject); } /** - * Find an object by ID or UUID + * Finds an object by ID or UUID. * - * @param int|string $id The ID or UUID to search for - * @param array|null $extend Properties to extend with related data + * @param int|string $id The object ID or UUID. + * @param array|null $extend Properties to extend the object with. * * @return ObjectEntity|null The found object or null if not found - * @throws Exception + * @throws Exception If the object is not found. */ public function find(int|string $id, ?array $extend = []): ?ObjectEntity { - return $this->getObject( - register: $this->registerMapper->find($this->getRegister()), - schema: $this->schemaMapper->find($this->getSchema()), - uuid: $id, - extend: $extend - ); - } + return $this->getObject( + $this->registerMapper->find($this->getRegister()), + $this->schemaMapper->find($this->getSchema()), + $id, + $extend + ); + } /** - * Create a new object from array data + * Creates a new object from provided data. * - * @param array $object The object data + * @param array $object The object data. * - * @return ObjectEntity The created object - * @throws ValidationException + * @return ObjectEntity The created object entity. + * @throws ValidationException If validation fails. + * @throws GuzzleException If there is an error during file upload. */ - public function createFromArray(array $object): ObjectEntity + public function createFromArray(array $object): ObjectEntity { - return $this->saveObject( - register: $this->getRegister(), - schema: $this->getSchema(), - object: $object - ); - } + return $this->saveObject( + register: $this->getRegister(), + schema: $this->getSchema(), + object: $object + ); + } /** - * Update an existing object from array data + * Updates an existing object with new data. * - * @param string $id The object ID to update - * @param array $object The new object data - * @param bool $updatedObject Whether this is an update operation + * @param string $id The ID of the object to update. + * @param array $object The new data for the object. + * @param bool $updatedObject If true, performs a full update. If false, performs a patch update. + * @param bool $patch Determines if the update should merge with existing data. * - * @return ObjectEntity The updated object - * @throws ValidationException + * @return ObjectEntity The updated object entity. + * @throws ValidationException If validation fails. + * @throws GuzzleException If there is an error during file upload. */ - public function updateFromArray(string $id, array $object, bool $updatedObject, bool $patch = false): ObjectEntity + public function updateFromArray(string $id, array $object, bool $updatedObject, bool $patch = false): ObjectEntity { - // Add ID to object data for update - $object['id'] = $id; + $object['id'] = $id; - // If we want the update to behave like patch, merge with existing object. if ($patch === true) { - $oldObject = $this->getObject($this->registerMapper->find($this->getRegister()), $this->schemaMapper->find($this->getSchema()), $id)->jsonSerialize(); + $oldObject = $this->getObject( + $this->registerMapper->find($this->getRegister()), + $this->schemaMapper->find($this->getSchema()), + $id + )->jsonSerialize(); $object = array_merge($oldObject, $object); } return $this->saveObject( - register: $this->getRegister(), - schema: $this->getSchema(), - object: $object - ); - } + register: $this->getRegister(), + schema: $this->getSchema(), + object: $object + ); + } /** - * Delete an object + * Deletes an object. * - * @param array|\JsonSerializable $object The object to delete + * @param array|JsonSerializable $object The object to delete. * - * @return bool True if deletion was successful - * @throws Exception + * @return bool True if deletion is successful, false otherwise. + * @throws Exception If deletion fails. */ - public function delete(array|\JsonSerializable $object): bool - { - // Convert JsonSerializable objects to array - if ($object instanceof \JsonSerializable === true) { - $object = $object->jsonSerialize(); - } + public function delete(array|JsonSerializable $object): bool + { + if ($object instanceof JsonSerializable) { + $object = $object->jsonSerialize(); + } - return $this->deleteObject( - register: $this->registerMapper->find($this->getRegister()), - schema: $this->schemaMapper->find($this->getSchema()), - uuid: $object['id'] - ); - } + return $this->deleteObject( + register: $this->getRegister(), + schema: $this->getSchema(), + uuid: $object['id'] + ); + } /** - * Find all objects matching given criteria + * Retrieves all objects matching criteria. * - * @param int|null $limit Maximum number of results - * @param int|null $offset Starting offset for pagination - * @param array $filters Filter criteria - * @param array $sort Sorting criteria - * @param string|null $search Search term - * @param array|null $extend Properties to extend with related data + * @param int|null $limit Maximum number of results. + * @param int|null $offset Starting offset for pagination. + * @param array $filters Criteria to filter the objects. + * @param array $sort Sorting options. + * @param string|null $search Search term. + * @param array|null $extend Properties to extend the results with. * - * @return array List of matching objects + * @return array List of matching objects. */ public function findAll( - ?int $limit = null, - ?int $offset = null, - array $filters = [], - array $sort = [], - ?string $search = null, + ?int $limit = null, + ?int $offset = null, + array $filters = [], + array $sort = [], + ?string $search = null, ?array $extend = [] ): array { @@ -257,42 +308,43 @@ public function findAll( return $objects; } - /** - * Count total objects matching filters - * - * @param array $filters Filter criteria - * @param string|null $search Search term - * @return int Total count - */ - public function count(array $filters = [], ?string $search = null): int - { - // Add register and schema filters if set - if ($this->getSchema() !== null && $this->getRegister() !== null) { - $filters['register'] = $this->getRegister(); - $filters['schema'] = $this->getSchema(); - } + /** + * Counts the total number of objects matching criteria. + * + * @param array $filters Criteria to filter the objects. + * @param string|null $search Search term. + * + * @return int The total count of matching objects. + */ + public function count(array $filters = [], ?string $search = null): int + { + // Add register and schema filters if set + if ($this->getSchema() !== null && $this->getRegister() !== null) { + $filters['register'] = $this->getRegister(); + $filters['schema'] = $this->getSchema(); + } - return $this->objectEntityMapper - ->countAll(filters: $filters, search: $search); - } + return $this->objectEntityMapper + ->countAll(filters: $filters, search: $search); + } /** - * Find multiple objects by their IDs + * Retrieves multiple objects by their IDs. * - * @param array $ids Array of object IDs to find + * @param array $ids List of object IDs to retrieve. * - * @return array Array of found objects - * @throws Exception + * @return array List of retrieved objects. + * @throws Exception If an error occurs during retrieval. */ - public function findMultiple(array $ids): array - { - $result = []; - foreach ($ids as $id) { - $result[] = $this->find($id); - } + public function findMultiple(array $ids): array + { + $result = []; + foreach ($ids as $id) { + $result[] = $this->find($id); + } - return $result; - } + return $result; + } /** * Find subobjects for a certain property with given ids @@ -325,36 +377,37 @@ public function findSubObjects(array $ids, string $property): array * @param array $filters Filter criteria * @param string|null $search Search term * - * @return array Aggregation results - */ - public function getAggregations(array $filters, ?string $search = null): array - { - $mapper = $this->getMapper(objectType: 'objectEntity'); + * @param array $filters Criteria to filter objects. + * @param string|null $search Search term. + * + * @return array Aggregated data results. + */ + public function getAggregations(array $filters, ?string $search = null): array + { + $mapper = $this->getMapper(objectType: 'objectEntity'); - $filters['register'] = $this->getRegister(); - $filters['schema'] = $this->getSchema(); + $filters['register'] = $this->getRegister(); + $filters['schema'] = $this->getSchema(); - // Only ObjectEntityMapper supports facets - if ($mapper instanceof ObjectEntityMapper === true) { - $facets = $this->objectEntityMapper->getFacets($filters, $search); - return $facets; - } + if ($mapper instanceof ObjectEntityMapper) { + return $mapper->getFacets($filters, $search); + } - return []; - } + return []; + } /** - * Extract object data from an entity + * Extracts object data from an entity. * - * @param mixed $object The object to extract data from - * @param array|null $extend Properties to extend with related data + * @param mixed $object The object entity. + * @param array|null $extend Properties to extend the object data with. * - * @return mixed The extracted object data + * @return mixed The extracted object data. */ - private function getDataFromObject(mixed $object, ?array $extend = []): mixed + private function getDataFromObject(mixed $object, ?array $extend = []): mixed { - return $object->getObject(); - } + return $object->getObject(); + } /** * Find all objects conforming to the request parameters, surrounded with pagination data. @@ -413,26 +466,27 @@ public function findAllPaginated(array $requestParams): array /** * Gets all objects of a specific type. * - * @param string|null $objectType The type of objects to retrieve. - * @param int|null $register - * @param int|null $schema - * @param int|null $limit The maximum number of objects to retrieve. - * @param int|null $offset The offset from which to start retrieving objects. - * @param array $filters - * @param array $sort - * @param string|null $search - * @param array|null $extend Properties to extend with related data + * @param string|null $objectType The type of objects to retrieve. Defaults to 'objectEntity' if register and schema are provided. + * @param int|null $register The ID of the register to filter objects by. + * @param int|null $schema The ID of the schema to filter objects by. + * @param int|null $limit The maximum number of objects to retrieve. Null for no limit. + * @param int|null $offset The offset for pagination. Null for no offset. + * @param array $filters Additional filters for retrieving objects. + * @param array $sort Sorting criteria for the retrieved objects. + * @param string|null $search Search term for filtering objects. + * @param array|null $extend Properties to extend with related data. * - * @return array The retrieved objects. + * @return array An array of objects matching the specified criteria. + * @throws InvalidArgumentException If an invalid object type is specified. */ public function getObjects( - ?string $objectType = null, - ?int $register = null, - ?int $schema = null, - ?int $limit = null, - ?int $offset = null, - array $filters = [], - array $sort = [], + ?string $objectType = null, + ?int $register = null, + ?int $schema = null, + ?int $limit = null, + ?int $offset = null, + array $filters = [], + array $sort = [], ?string $search = null ) { @@ -443,29 +497,28 @@ public function getObjects( $filters['schema'] = $schema; } - // Get the appropriate mapper for the object type - $mapper = $this->getMapper($objectType); + $mapper = $this->getMapper($objectType); // Use the mapper to find and return all objects of the specified type return $mapper->findAll( - limit: $limit, - offset: $offset, - filters: $filters, - sort: $sort, + limit: $limit, + offset: $offset, + filters: $filters, + sort: $sort, search: $search ); } - /** - * Save an object + /** + * Saves an object to the database. * - * @param int $register The register to save the object to. - * @param int $schema The schema to save the object to. - * @param array $object The data to be saved. + * @param int $register The ID of the register to save the object to. + * @param int $schema The ID of the schema to save the object to. + * @param array $object The data of the object to save. * - * @return ObjectEntity The resulting object. - * @throws ValidationException When the validation fails and returns an error. - * @throws Exception + * @return ObjectEntity The saved object entity. + * @throws ValidationException If the object fails validation. + * @throws Exception|GuzzleException If an error occurs during object saving or file handling. */ public function saveObject(int $register, int $schema, array $object): ObjectEntity { @@ -479,77 +532,82 @@ public function saveObject(int $register, int $schema, array $object): ObjectEnt $register = $this->registerMapper->find($register); } - if (is_string($schema)) { - $schema = $this->schemaMapper->find($schema); - } + if (is_string($schema)) { + $schema = $this->schemaMapper->find($schema); + } - // Check if object already exists - if (isset($object['id']) === true) { - $objectEntity = $this->objectEntityMapper->findByUuid( - $this->registerMapper->find($register), - $this->schemaMapper->find($schema), - $object['id'] - ); - } + // Check if object already exists + if (isset($object['id']) === true) { + $objectEntity = $this->objectEntityMapper->findByUuid( + $this->registerMapper->find($register), + $this->schemaMapper->find($schema), + $object['id'] + ); + } - // Create new entity if none exists - if (isset($object['id']) === false || $objectEntity === null) { - $objectEntity = new ObjectEntity(); - $objectEntity->setRegister($register); - $objectEntity->setSchema($schema); - } + $validationResult = $this->validateObject(object: $object, schemaId: $schema); - // Handle UUID assignment - if (isset($object['id']) && !empty($object['id'])) { - $objectEntity->setUuid($object['id']); - } else { - $objectEntity->setUuid(Uuid::v4()); - $object['id'] = $objectEntity->getUuid(); - } + // Create new entity if none exists + if (isset($object['id']) === false || $objectEntity === null) { + $objectEntity = new ObjectEntity(); + $objectEntity->setRegister($register); + $objectEntity->setSchema($schema); + } - // Store old version for audit trail - $oldObject = clone $objectEntity; - $objectEntity->setObject($object); + // Handle UUID assignment + if (isset($object['id']) && !empty($object['id'])) { + $objectEntity->setUuid($object['id']); + } else { + $objectEntity->setUuid(Uuid::v4()); + $object['id'] = $objectEntity->getUuid(); + } - // Ensure UUID exists //@todo: this is not needed anymore? this kinde of uuid is set in the handleLinkRelations function - if (empty($objectEntity->getUuid()) === true) { - $objectEntity->setUuid(Uuid::v4()); - } + // Store old version for audit trail + $oldObject = clone $objectEntity; + $objectEntity->setObject($object); - // Let grap any links that we can - $objectEntity = $this->handleLinkRelations($objectEntity, $object); + // Ensure UUID exists //@todo: this is not needed anymore? this kinde of uuid is set in the handleLinkRelations function + if (empty($objectEntity->getUuid()) === true) { + $objectEntity->setUuid(Uuid::v4()); + } + + // Let grap any links that we can + $objectEntity = $this->handleLinkRelations($objectEntity, $object); $schemaObject = $this->schemaMapper->find($schema); - // Handle object properties that are either nested objects or files + // Handle object properties that are either nested objects or files if ($schemaObject->getProperties() !== null && is_array($schemaObject->getProperties()) === true) { $objectEntity = $this->handleObjectRelations($objectEntity, $object, $schemaObject->getProperties(), $register, $schema); } $objectEntity->setUri($this->urlGenerator->getAbsoluteURL($this->urlGenerator->linkToRoute('openregister.Objects.show', ['id' => $objectEntity->getUuid()]))); - if ($objectEntity->getId()) { - $objectEntity = $this->objectEntityMapper->update($objectEntity); - $this->auditTrailMapper->createAuditTrail(new: $objectEntity, old: $oldObject); - } else { - $objectEntity = $this->objectEntityMapper->insert($objectEntity); - $this->auditTrailMapper->createAuditTrail(new: $objectEntity); - } + if ($objectEntity->getId() && ($schemaObject->getHardValidation() === false || $validationResult->isValid() === true)) { + $objectEntity = $this->objectEntityMapper->update($objectEntity); + $this->auditTrailMapper->createAuditTrail(new: $objectEntity, old: $oldObject); + } else if ($schemaObject->getHardValidation() === false || $validationResult->isValid() === true) { + $objectEntity = $this->objectEntityMapper->insert($objectEntity); + $this->auditTrailMapper->createAuditTrail(new: $objectEntity); + } - return $objectEntity; - } + if ($validationResult->isValid() === false) { + throw new ValidationException(message: 'The object could not be validated', errors: $validationResult->error()); + } + + return $objectEntity; + } /** - * Handle link relations efficiently using JSON path traversal - * - * Finds all links or UUIDs in the object and adds them to the relations - * using dot notation paths for nested properties - * - * @param ObjectEntity $objectEntity The object entity to handle relations for - * @param array $object The object data - * - * @return ObjectEntity Updated object data - */ + * Efficiently processes link relations within an object using JSON path traversal. + * + * Identifies and maps all URLs or UUIDs to their corresponding relations using dot notation paths + * for nested properties, excluding self-references of the object entity. + * + * @param ObjectEntity $objectEntity The object entity to analyze and update relations for. + * + * @return ObjectEntity The updated object entity with new relations mapped. + */ private function handleLinkRelations(ObjectEntity $objectEntity): ObjectEntity { $relations = $objectEntity->getRelations() ?? []; @@ -562,7 +620,7 @@ private function handleLinkRelations(ObjectEntity $objectEntity): ObjectEntity ]; // Function to recursively find links/UUIDs and build dot notation paths - $findRelations = function($data, $path = '') use (&$findRelations, &$relations, $selfIdentifiers) { + $findRelations = function ($data, $path = '') use (&$findRelations, &$relations, $selfIdentifiers) { foreach ($data as $key => $value) { $currentPath = $path ? "$path.$key" : $key; @@ -572,7 +630,7 @@ private function handleLinkRelations(ObjectEntity $objectEntity): ObjectEntity } else if (is_string($value) === true) { // Check for URLs and UUIDs if ((filter_var($value, FILTER_VALIDATE_URL) !== false - || preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i', $value) === 1) + || preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i', $value) === 1) && in_array($value, $selfIdentifiers, true) === false ) { $relations[$currentPath] = $value; @@ -589,224 +647,384 @@ private function handleLinkRelations(ObjectEntity $objectEntity): ObjectEntity } /** - * Handle object relations and file properties in schema properties and array items + * Adds a nested subobject based on schema and property details and incorporates it into the main object. * - * @param ObjectEntity $objectEntity The object entity to handle relations for - * @param array $object The object data - * @param array $properties The schema properties - * @param int $register The register ID - * @param int $schema The schema ID + * Handles $ref resolution for schema subtypes, stores relations, and replaces nested subobject + * data with its reference URI or UUID. * - * @return ObjectEntity Updated object with linked data - * @throws Exception|ValidationException When file handling fails + * @param array $property The property schema details for the nested object. + * @param string $propertyName The name of the property in the parent object. + * @param array $item The nested subobject data to process. + * @param ObjectEntity $objectEntity The parent object entity to associate the nested subobject with. + * @param int $register The register associated with the schema. + * @param int $schema The schema identifier for the subobject. + * @param int|null $index Optional index of the subobject if it resides in an array. + * + * @return string The UUID of the nested subobject. + * @throws ValidationException When schema or object validation fails. + * @throws GuzzleException */ - private function handleObjectRelations(ObjectEntity $objectEntity, array $object, array $properties, int $register, int $schema): ObjectEntity + private function addObject( + array $property, + string $propertyName, + array $item, + ObjectEntity $objectEntity, + int $register, + int $schema, + ?int $index = null + ): string { - // @todo: Multidimensional suport should be added - foreach ($properties as $propertyName => $property) { - // Skip if property not in object - if (isset($object[$propertyName]) === false) { - continue; - } - - // Handle array type with items that may contain objects/files - if ($property['type'] === 'array' && isset($property['items']) === true) { - // Skip if not array in data - if (is_array($object[$propertyName]) === false) { - continue; - } - - // Process each array item - foreach ($object[$propertyName] as $index => $item) { - if ($property['items']['type'] === 'object') { - $subSchema = $schema; - - if(is_int($property['items']['$ref']) === true) { - $subSchema = $property['items']['$ref']; - } else if (filter_var(value: $property['items']['$ref'], filter: FILTER_VALIDATE_URL) !== false) { - $parsedUrl = parse_url($property['items']['$ref']); - $explodedPath = explode(separator: '/', string: $parsedUrl['path']); - $subSchema = end($explodedPath); - } - - if(is_array($item) === true) { - // Handle nested object in array - $nestedObject = $this->saveObject( - register: $register, - schema: $subSchema, - object: $item - ); - - // Store relation and replace with reference - $relations = $objectEntity->getRelations() ?? []; - $relations[$propertyName . '.' . $index] = $nestedObject->getUri(); - $objectEntity->setRelations($relations); - $object[$propertyName][$index] = $nestedObject->getUri(); - - } else { - $relations = $objectEntity->getRelations() ?? []; - $relations[$propertyName . '.' . $index] = $item; - $objectEntity->setRelations($relations); - } + $subSchema = $schema; + if (is_int($property['$ref']) === true) { + $subSchema = $property['$ref']; + } else if (filter_var(value: $property['$ref'], filter: FILTER_VALIDATE_URL) !== false) { + $parsedUrl = parse_url($property['$ref']); + $explodedPath = explode(separator: '/', string: $parsedUrl['path']); + $subSchema = end($explodedPath); + } - } else if ($property['items']['type'] === 'file') { - // Handle file in array - $object[$propertyName][$index] = $this->handleFileProperty( - objectEntity: $objectEntity, - object: [$propertyName => [$index => $item]], - propertyName: $propertyName . '.' . $index - )[$propertyName]; - } - } - } + // Handle nested object in array + $nestedObject = $this->saveObject( + register: $register, + schema: $subSchema, + object: $item + ); - // Handle single object type - else if ($property['type'] === 'object') { - - $subSchema = $schema; - - // $ref is a int, id or uuid - if (is_int($property['$ref']) === true - || is_numeric($property['$ref']) === true - || preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i', $property['$ref']) === 1 - ) { - $subSchema = $property['$ref']; - } else if (filter_var(value: $property['$ref'], filter: FILTER_VALIDATE_URL) !== false) { - $parsedUrl = parse_url($property['$ref']); - $explodedPath = explode(separator: '/', string: $parsedUrl['path']); - $subSchema = end($explodedPath); - } + if ($index === null) { + // Store relation and replace with reference + $relations = $objectEntity->getRelations() ?? []; + $relations[$propertyName] = $nestedObject->getUri(); + $objectEntity->setRelations($relations); + } else { + $relations = $objectEntity->getRelations() ?? []; + $relations[$propertyName . '.' . $index] = $nestedObject->getUri(); + $objectEntity->setRelations($relations); + } - if(is_array($object[$propertyName]) === true) { - $nestedObject = $this->saveObject( - register: $register, - schema: $subSchema, - object: $object[$propertyName] - ); + return $nestedObject->getUuid(); + } - // Store relation and replace with reference - $relations = $objectEntity->getRelations() ?? []; - $relations[$propertyName] = $nestedObject->getUri(); - $objectEntity->setRelations($relations); - $object[$propertyName] = $nestedObject->getUri(); + /** + * Processes an object property by delegating it to a subobject handling mechanism. + * + * @param array $property The schema definition for the object property. + * @param string $propertyName The name of the object property. + * @param array $item The data corresponding to the property in the parent object. + * @param ObjectEntity $objectEntity The object entity to link the processed data to. + * @param int $register The register associated with the schema. + * @param int $schema The schema identifier for the property. + * + * @return string The updated property data, typically a reference UUID. + * @throws ValidationException When schema or object validation fails. + * @throws GuzzleException + */ + private function handleObjectProperty( + array $property, + string $propertyName, + array $item, + ObjectEntity $objectEntity, + int $register, + int $schema + ): string + { + return $this->addObject( + property: $property, + propertyName: $propertyName, + item: $item, + objectEntity: $objectEntity, + register: $register, + schema: $schema + ); + } - } else { - $relations = $objectEntity->getRelations() ?? []; - $relations[$propertyName] = $object[$propertyName]; - $objectEntity->setRelations($relations); - } + /** + * Handles array-type properties by processing each element based on its schema type. + * + * Supports nested objects, files, or oneOf schema types, delegating to specific handlers + * for each element in the array. + * + * @param array $property The schema definition for the array property. + * @param string $propertyName The name of the array property. + * @param array $items The elements of the array to process. + * @param ObjectEntity $objectEntity The object entity the data belongs to. + * @param int $register The register associated with the schema. + * @param int $schema The schema identifier for the array elements. + * + * @return array The processed array with updated references or data. + * @throws GuzzleException|ValidationException When schema validation or file handling fails. + */ + private function handleArrayProperty( + array $property, + string $propertyName, + array $items, + ObjectEntity $objectEntity, + int $register, + int $schema + ): array + { + if (isset($property['items']) === false) { + return $items; + } + if (isset($property['items']['oneOf']) === true) { + foreach ($items as $index => $item) { + $items[$index] = $this->handleOneOfProperty( + property: $property['items']['oneOf'], + propertyName: $propertyName, + item: $item, + objectEntity: $objectEntity, + register: $register, + schema: $schema, + index: $index + ); } - // Handle single file type - else if ($property['type'] === 'file') { + return $items; + } + + if ($property['items']['type'] !== 'object' + && $property['items']['type'] !== 'file' + ) { + return $items; + } - $object = $this->handleFileProperty( + if ($property['items']['type'] === 'file') { + foreach ($items as $index => $item) { + $items[$index] = $this->handleFileProperty( objectEntity: $objectEntity, - object: $object, - propertyName: $propertyName - ); + object: [$propertyName => [$index => $item]], + propertyName: $propertyName . '.' . $index, + format: $item['format'] ?? null + )[$propertyName]; } + return $items; } - $objectEntity->setObject($object); + foreach ($items as $index => $item) { + $items[$index] = $this->addObject( + property: $property['items'], + propertyName: $propertyName, + item: $item, + objectEntity: $objectEntity, + register: $register, + schema: $schema, + index: $index + ); + } - return $objectEntity; + return $items; } /** - * Handle file property processing + * Processes properties defined as oneOf, selecting the appropriate schema option for the data. * - * @param ObjectEntity $objectEntity The object entity - * @param array $object The object data - * @param string $propertyName The name of the file property + * Handles various types of schemas, including files and references, to correctly process + * and replace the input data with the resolved references or processed results. * - * @return array Updated object data - * @throws Exception|GuzzleException When file handling fails + * @param array $property The oneOf schema definition. + * @param string $propertyName The name of the property in the parent object. + * @param string|array $item The data to process, either as a scalar or a nested array. + * @param ObjectEntity $objectEntity The object entity the data belongs to. + * @param int $register The register associated with the schema. + * @param int $schema The schema identifier for the property. + * @param int|null $index Optional index for array-based oneOf properties. + * + * @return string|array The processed data, resolved to a reference or updated structure. + * @throws GuzzleException|ValidationException When schema validation or file handling fails. */ - private function handleFileProperty(ObjectEntity $objectEntity, array $object, string $propertyName): array + private function handleOneOfProperty( + array $property, + string $propertyName, + string|array $item, + ObjectEntity $objectEntity, + int $register, + int $schema, + ?int $index = null + ): string|array { - $fileName = str_replace('.', '_', $propertyName); - $objectDot = new Dot($object); - - // Check if it's a Nextcloud file URL -// if (str_starts_with($object[$propertyName], $this->urlGenerator->getAbsoluteURL())) { -// $urlPath = parse_url($object[$propertyName], PHP_URL_PATH); -// if (preg_match('/\/f\/(\d+)/', $urlPath, $matches)) { -// $files = $objectEntity->getFiles() ?? []; -// $files[$propertyName] = (int)$matches[1]; -// $objectEntity->setFiles($files); -// $object[$propertyName] = (int)$matches[1]; -// return $object; -// } -// } + if (array_is_list($property) === false) { + return $item; + } - // Handle base64 encoded file - if (is_string($objectDot->get($propertyName)) === true - && preg_match('/^data:([^;]*);base64,(.*)/', $objectDot->get($propertyName), $matches) + if (in_array(needle:'file', haystack: array_column(array: $property, column_key: 'type')) === true + && is_array($item) === true + && $index !== null ) { - $fileContent = base64_decode($matches[2], true); - if ($fileContent === false) { - throw new Exception('Invalid base64 encoded file'); - } + $fileIndex = array_search(needle: 'file', haystack: array_column(array: $property, column_key: 'type')); + return $this->handleFileProperty( + objectEntity: $objectEntity, + object: [$propertyName => [$index => $item]], + propertyName: $propertyName, + format: $property[$fileIndex]['format'] ?? null + ); + } + if (in_array(needle: 'file', haystack: array_column(array: $property, column_key: 'type')) === true + && is_array($item) === true + && $index === null + ) { + $fileIndex = array_search(needle: 'file', haystack: array_column(array: $property, column_key: 'type')); + return $this->handleFileProperty( + objectEntity: $objectEntity, + object: [$propertyName => $item], + propertyName: $propertyName, + format: $property[$fileIndex]['format'] ?? null + ); } - // Handle URL file - else { - // Encode special characters in the URL - $encodedUrl = rawurlencode($objectDot->get("$propertyName.downloadUrl")); //@todo hardcoded .downloadUrl - // Decode valid path separators and reserved characters - $encodedUrl = str_replace(['%2F', '%3A', '%28', '%29'], ['/', ':', '(', ')'], $encodedUrl); + if (array_column(array: $property, column_key: '$ref') === []) { + return $item; + } - if (filter_var($encodedUrl, FILTER_VALIDATE_URL)) { - try { - // @todo hacky tacky - // Regular expression to get the filename and extension from url //@todo hardcoded .downloadUrl - if (preg_match("/\/([^\/]+)'\)\/\\\$value$/", $objectDot->get("$propertyName.downloadUrl"), $matches)) { - // @todo hardcoded way of getting the filename and extension from the url - $fileNameFromUrl = $matches[1]; - // @todo use only the extension from the url ? - // $fileName = $fileNameFromUrl; - $extension = substr(strrchr($fileNameFromUrl, '.'), 1); - $fileName = "$fileName.$extension"; - } + if (is_array($item) === false) { + return $item; + } - if ($objectDot->has("$propertyName.source") === true) { - $sourceMapper = $this->getOpenConnector(filePath: '\Db\SourceMapper'); - $source = $sourceMapper->find($objectDot->get("$propertyName.source")); + $oneOf = array_filter( + array: $property, + callback: function (array $option) { + return isset($option['$ref']) === true; + } + )[0]; + + return $this->addObject( + property: $oneOf, + propertyName: $propertyName, + item: $item, + objectEntity: $objectEntity, + register: $register, + schema: $schema, + index: $index + ); + } - $callService = $this->getOpenConnector(filePath: '\Service\CallService'); - if ($callService === null) { - throw new Exception("OpenConnector service not available"); - } - $endpoint = str_replace($source->getLocation(), "", $encodedUrl); + /** + * Processes and rewrites properties within an object based on their schema definitions. + * + * Determines the type of each property (object, array, oneOf, or file) and delegates to the + * corresponding handler. Updates the object data with references or processed results. + * + * @param array $property The schema definition of the property. + * @param string $propertyName The name of the property in the object. + * @param int $register The register ID associated with the schema. + * @param int $schema The schema ID associated with the property. + * @param array $object The parent object data to update. + * @param ObjectEntity $objectEntity The object entity being processed. + * + * @return array The updated object with processed properties. + * @throws GuzzleException|ValidationException When schema validation or file handling fails. + */ + private function handleProperty( + array $property, + string $propertyName, + int $register, + int $schema, + array $object, + ObjectEntity $objectEntity + ): array + { + switch($property['type']) { + case 'object': + $object[$propertyName] = $this->handleObjectProperty( + property: $property, + propertyName: $propertyName, + item: $object[$propertyName], + objectEntity: $objectEntity, + register: $register, + schema: $schema, + ); + break; + case 'array': + $object[$propertyName] = $this->handleArrayProperty( + property: $property, + propertyName: $propertyName, + items: $object[$propertyName], + objectEntity: $objectEntity, + register: $register, + schema: $schema, + ); + break; + case 'oneOf': + $object[$propertyName] = $this->handleOneOfProperty( + property: $property['oneOf'], + propertyName: $propertyName, + item: $object[$propertyName], + objectEntity: $objectEntity, + register: $register, + schema: $schema); + break; + case 'file': + $object[$propertyName] = $this->handleFileProperty( + objectEntity: $objectEntity, + object: $object, + propertyName: $propertyName, + format: $property['format'] ?? null + ); + default: + break; + } + return $object; + } - $endpoint = urldecode($endpoint); - $response = $callService->call(source: $source, endpoint: $endpoint, method: 'GET')->getResponse(); + /** + * Links object relations and handles file-based properties within an object schema. + * + * Iterates through schema-defined properties, processing and resolving nested relations, + * array items, and file-based data. Updates the object entity with resolved references. + * + * @param ObjectEntity $objectEntity The object entity being processed. + * @param array $object The parent object data to analyze. + * @param array $properties The schema properties defining the object structure. + * @param int $register The register ID associated with the schema. + * @param int $schema The schema ID associated with the object. + * + * @return ObjectEntity The updated object entity with resolved relations and file references. + * @throws Exception|ValidationException|GuzzleException When file handling or schema processing fails. + */ + private function handleObjectRelations( + ObjectEntity $objectEntity, + array $object, + array $properties, + int $register, + int $schema + ): ObjectEntity + { + // @todo: Multidimensional support should be added + foreach ($properties as $propertyName => $property) { + // Skip if property not in object + if (isset($object[$propertyName]) === false) { + continue; + } + + $object = $this->handleProperty( + property: $property, + propertyName: $propertyName, + register: $register, + schema: $schema, + object: $object, + objectEntity: $objectEntity, + ); + } - $fileContent = $response['body']; + $objectEntity->setObject($object); - if( - $response['encoding'] === 'base64' - ) { - $fileContent = base64_decode(string: $fileContent); - } + return $objectEntity; + } - } else { - $client = new \GuzzleHttp\Client(); - $response = $client->get($encodedUrl); - $fileContent = $response->getBody()->getContents(); - } - } catch (Exception|NotFoundExceptionInterface $e) { - throw new Exception('Failed to download file from URL: ' . $e->getMessage()); - } - } else if (str_contains($objectDot->get($propertyName), $this->urlGenerator->getBaseUrl()) === true) { - return $object; - } else { - throw new Exception('Invalid file format - must be base64 encoded or valid URL'); - } - } + /** + * @todo + * + * @param string $fileContent + * @param string $propertyName + * @param ObjectEntity $objectEntity + * @param File $file + * + * @return File + * @throws Exception + */ + private function writeFile(string $fileContent, string $propertyName, ObjectEntity $objectEntity, File $file): File + { + $fileName = $file->getFilename(); try { $schema = $this->schemaMapper->find($objectEntity->getSchema()); @@ -816,13 +1034,19 @@ private function handleFileProperty(ObjectEntity $objectEntity, array $object, s $this->fileService->createFolder(folderPath: 'Objects'); $this->fileService->createFolder(folderPath: "Objects/$schemaFolder"); $this->fileService->createFolder(folderPath: "Objects/$schemaFolder/$objectFolder"); - $filePath = "Objects/$schemaFolder/$objectFolder/$fileName"; + + $filePath = $file->getFilePath(); + + if ($filePath === null) { + $filePath = "Objects/$schemaFolder/$objectFolder/$fileName"; + } $succes = $this->fileService->updateFile( content: $fileContent, filePath: $filePath, createNew: true ); + if ($succes === false) { throw new Exception('Failed to upload this file: $filePath to NextCloud'); } @@ -830,9 +1054,11 @@ private function handleFileProperty(ObjectEntity $objectEntity, array $object, s // Create or find ShareLink $share = $this->fileService->findShare(path: $filePath); if ($share !== null) { - $shareLink = $this->fileService->getShareLink($share).'/download'; + $shareLink = $this->fileService->getShareLink($share); + $downloadLink = $shareLink . '/download'; } else { - $shareLink = $this->fileService->createShareLink(path: $filePath).'/download'; + $shareLink = $this->fileService->createShareLink(path: $filePath); + $downloadLink = $shareLink . '/download'; } $filesDot = new Dot($objectEntity->getFiles() ?? []); @@ -840,394 +1066,648 @@ private function handleFileProperty(ObjectEntity $objectEntity, array $object, s $objectEntity->setFiles($filesDot->all()); // Preserve the original uri in the object 'json blob' - $objectDot = $objectDot->set($propertyName, $shareLink); - $object = $objectDot->all(); + $file->setDownloadUrl($downloadLink); + $file->setShareUrl($shareLink); + $file->setFilePath($filePath); } catch (Exception $e) { throw new Exception('Failed to store file: ' . $e->getMessage()); } - return $object; + return $file; } - /** - * Get an object - * - * @param Register $register The register to get the object from - * @param Schema $schema The schema of the object - * @param string $uuid The UUID of the object to get - * @param array $extend Properties to extend with related data - * - * @return ObjectEntity|null The resulting object or null if not found - * @throws Exception If source type is unsupported - */ - public function getObject(Register $register, Schema $schema, string $uuid, ?array $extend = []): ?ObjectEntity - { - // Handle internal source - if ($register->getSource() === 'internal' || $register->getSource() === '') { - return $this->objectEntityMapper->findByUuid($register, $schema, $uuid); - } + /** + * @todo + * + * @param File $file + * + * @return File + */ + private function setExtension(File $file): File + { + // Regular expression to get the filename and extension from url + if ($file->getExtension() === false && preg_match("/\/([^\/]+)'\)\/\\\$value$/", $file->getAccessUrl(), $matches)) { + $fileNameFromUrl = $matches[1]; + $file->setExtension(substr(strrchr($fileNameFromUrl, '.'), 1)); + } - //@todo mongodb support + return $file; + } - throw new Exception('Unsupported source type'); - } + /** + * @todo + * + * @param File $file + * @param string $propertyName + * @param ObjectEntity $objectEntity + * + * @return File + * @throws ContainerExceptionInterface + * @throws GuzzleException + */ + private function fetchFile(File $file, string $propertyName, ObjectEntity $objectEntity): File + { + $fileContent = null; - /** - * Delete an object - * - * @param Register $register The register to delete from - * @param Schema $schema The schema of the object - * @param string $uuid The UUID of the object to delete - * - * @return bool True if deletion was successful - * @throws Exception If source type is unsupported - */ - public function deleteObject(Register $register, Schema $schema, string $uuid): bool - { - // Handle internal source - if ($register->getSource() === 'internal' || $register->getSource() === '') { - $object = $this->objectEntityMapper->findByUuid(register: $register, schema: $schema, uuid: $uuid); - $this->objectEntityMapper->delete($object); - return true; - } + // Encode special characters in the URL + $encodedUrl = rawurlencode($file->getAccessUrl()); - //@todo mongodb support + // Decode valid path separators and reserved characters + $encodedUrl = str_replace(['%2F', '%3A', '%28', '%29'], ['/', ':', '(', ')'], $encodedUrl); - throw new Exception('Unsupported source type'); - } + if (filter_var($encodedUrl, FILTER_VALIDATE_URL)) { + $this->setExtension($file); + try { + if ($file->getSource() !== null) { + $sourceMapper = $this->getOpenConnector(filePath: '\Db\SourceMapper'); + $source = $sourceMapper->find($file->getSource()); - /** - * Gets the appropriate mapper based on the object type. - * - * @param string|null $objectType The type of object to retrieve the mapper for - * @param int|null $register Optional register ID - * @param int|null $schema Optional schema ID - * @return mixed The appropriate mapper - * @throws InvalidArgumentException If unknown object type - */ - public function getMapper(?string $objectType = null, ?int $register = null, ?int $schema = null): mixed - { - // Return self if register and schema provided - if ($register !== null && $schema !== null) { - $this->setSchema($schema); - $this->setRegister($register); - return $this; - } + $callService = $this->getOpenConnector(filePath: '\Service\CallService'); + if ($callService === null) { + throw new Exception("OpenConnector service not available"); + } + $endpoint = str_replace($source->getLocation(), "", $encodedUrl); + $endpoint = urldecode($endpoint); + $response = $callService->call(source: $source, endpoint: $endpoint, method: 'GET')->getResponse(); - // Return appropriate mapper based on object type - switch ($objectType) { - case 'register': - return $this->registerMapper; - case 'schema': - return $this->schemaMapper; - case 'objectEntity': - return $this->objectEntityMapper; - default: - throw new InvalidArgumentException("Unknown object type: $objectType"); - } - } + $fileContent = $response['body']; - /** - * Gets multiple objects based on the object type and ids. - * - * @param string $objectType The type of objects to retrieve - * @param array $ids The ids of the objects to retrieve - * @return array The retrieved objects - * @throws InvalidArgumentException If unknown object type - */ - public function getMultipleObjects(string $objectType, array $ids): array + if ($response['encoding'] === 'base64') { + $fileContent = base64_decode(string: $fileContent); + } + + } else { + $client = new Client(); + $response = $client->get($encodedUrl); + $fileContent = $response->getBody()->getContents(); + } + } catch (Exception|NotFoundExceptionInterface $e) { + throw new Exception('Failed to download file from URL: ' . $e->getMessage()); + } + } + + $this->writeFile(fileContent: $fileContent, propertyName: $propertyName, objectEntity: $objectEntity, file: $file); + + return $file; + } + + /** + * Processes file properties within an object, storing and resolving file content to sharable URLs. + * + * Handles both base64-encoded and URL-based file sources, storing the resolved content and + * updating the object data with the resulting file references. + * + * @param ObjectEntity $objectEntity The object entity containing the file property. + * @param array $object The parent object data containing the file reference. + * @param string $propertyName The name of the file property. + * @param string|null $format + * + * @return string The updated object with resolved file references. + * @throws ContainerExceptionInterface + * @throws GuzzleException When file handling fails + * @throws \OCP\DB\Exception + */ + private function handleFileProperty(ObjectEntity $objectEntity, array $object, string $propertyName, ?string $format = null): string { - // Process the ids to handle different formats - $processedIds = array_map(function($id) { - if (is_object($id) && method_exists($id, 'getId')) { - return $id->getId(); - } elseif (is_array($id) && isset($id['id'])) { - return $id['id']; - } else { - return $id; - } - }, $ids); - - // Clean up URIs to get just the ID portion - $cleanedIds = array_map(function($id) { - if (filter_var($id, FILTER_VALIDATE_URL)) { - $parts = explode('/', rtrim($id, '/')); - return end($parts); - } - return $id; - }, $processedIds); - - // Get mapper and find objects - $mapper = $this->getMapper($objectType); - return $mapper->findMultiple($cleanedIds); - } + $fileName = str_replace('.', '_', $propertyName); + $objectDot = new Dot($object); - /** - * Renders the entity by replacing the files and relations with their respective objects - * - * @param array $entity The entity to render - * @param array|null $extend Optional array of properties to extend, defaults to files and relations if not provided - * @return array The rendered entity with expanded files and relations - */ - public function renderEntity(array $entity, ?array $extend = []): array - { - // check if entity has files or relations and if not just return the entity - if (array_key_exists(key: 'files', array: $entity) === false && array_key_exists(key: 'relations', array: $entity) === false) { - return $entity; - } + // Handle base64 encoded file + if (is_string($objectDot->get("$propertyName.base64")) === true + && preg_match('/^data:([^;]*);base64,(.*)/', $objectDot->get("$propertyName.base64"), $matches) + ) { + unset($object[$propertyName]['base64']); + $fileEntity = new File(); + $fileEntity->hydrate($object[$propertyName]); + $fileEntity->setFilename($fileName); + $this->setExtension($fileEntity); + $this->fileMapper->insert($fileEntity); + $fileContent = base64_decode($matches[2], true); + if ($fileContent === false) { + throw new Exception('Invalid base64 encoded file'); + } - // Lets create a dot array of the entity - $dotEntity = new Dot($entity); + $fileEntity = $this->writeFile(fileContent: $fileContent, propertyName: $propertyName, objectEntity: $objectEntity, file: $fileEntity); + } // Handle URL file + else { + $fileEntities = $this->fileMapper->findAll(filters: ['accessUrl' => $objectDot->get("$propertyName.accessUrl")]); + if (count($fileEntities) > 0) { + $fileEntity = $fileEntities[0]; + } - // loop through the files and replace the file ids with the file objects) - if (array_key_exists(key: 'files', array: $entity) === true && empty($entity['files']) === false) { - // Loop through the files array where key is dot notation path and value is file id - foreach ($entity['files'] as $path => $fileId) { - // Replace the value at the dot notation path with the file URL - $dotEntity->set($path, $filesById[$fileId]->getUrl()); - } - } + if (count($fileEntities) === 0) { + $fileEntity = $this->fileMapper->createFromArray($object[$propertyName]); + } - // Loop through the relations and replace the relation ids with the relation objects if extended - if (array_key_exists(key: 'relations', array: $entity) === true && empty($entity['relations']) === false) { - // loop through the relations and replace the relation ids with the relation objects - foreach ($entity['relations'] as $path => $relationId) { - // if the relation is not in the extend array, skip it - if (in_array(needle: $path, haystack: $extend) === false) { - continue; - } - // Replace the value at the dot notation path with the relation object - $dotEntity->set($path, $this->getObject(register: $this->getRegister(), schema: $this->getSchema(), uuid: $relationId)); - } - } + if ($fileEntity->getFilename() === null) { + $fileEntity->setFilename($fileName); + } - // Update the entity with modified values - $entity = $dotEntity->all(); + if ($fileEntity->getChecksum() === null || $fileEntity->getUpdated() > new DateTime('-5 minutes')) { + $fileEntity = $this->fetchFile(file: $fileEntity, propertyName: $propertyName, objectEntity: $objectEntity); + $fileEntity->setUpdated(new DateTime()); + } + } - return $this->extendEntity(entity: $entity, extend: $extend); - } + $fileEntity->setChecksum(md5(serialize($fileContent))); + + $this->fileMapper->update($fileEntity); + + switch ($format) { + case 'filename': + return $fileEntity->getFileName(); + case 'extension': + return $fileEntity->getExtension(); + case 'shareUrl': + return $fileEntity->getShareUrl(); + case 'accessUrl': + return $fileEntity->getAccessUrl(); + case 'downloadUrl': + default: + return $fileEntity->getDownloadUrl(); + } + } - /** - * Extends an entity with related objects based on the extend array. - * - * @param mixed $entity The entity to extend - * @param array $extend Properties to extend with related data - * @return array The extended entity as an array - * @throws Exception If property not found - */ - public function extendEntity(array $entity, array $extend): array - { - // Convert entity to array if needed - if (is_array($entity)) { - $result = $entity; - } else { - $result = $entity->jsonSerialize(); - } + /** + * Retrieves an object from a specified register and schema using its UUID. + * + * Supports only internal sources and raises an exception for unsupported source types. + * + * @param Register $register The register from which the object is retrieved. + * @param Schema $schema The schema defining the object structure. + * @param string $uuid The unique identifier of the object to retrieve. + * @param array|null $extend Optional properties to include in the retrieved object. + * + * @return ObjectEntity The retrieved object as an entity. + * @throws Exception If the source type is unsupported. + */ + public function getObject(Register $register, Schema $schema, string $uuid, ?array $extend = []): ObjectEntity + { - // Process each property to extend - foreach ($extend as $property) { - $singularProperty = rtrim($property, 's'); - - // Check if property exists - if (array_key_exists(key: $property, array: $result) === true) { - $value = $result[$property]; - if (empty($value)) { - continue; - } - } elseif (array_key_exists(key: $singularProperty, array: $result)) { - $value = $result[$singularProperty]; - } else { - throw new Exception("Property '$property' or '$singularProperty' is not present in the entity."); - } - - // Try to get mapper for property - try { - $mapper = $this->getMapper(objectType: $property); - $propertyObject = $singularProperty; - - // Extend with related objects using specific mapper - if (is_array($value) === true) { - $result[$property] = $this->getMultipleObjects(objectType: $propertyObject, ids: $value); - } else { - $objectId = is_object(value: $value) ? $value->getId() : $value; - $result[$property] = $mapper->find($objectId); - } - } catch (Exception $e) { - // If no specific mapper found, try to look up values in default database - try { - if (is_array($value)) { - // Handle array of values - $extendedValues = []; - foreach ($value as $val) { - try { - $found = $this->objectEntityMapper->find($val); - if ($found) { - $extendedValues[] = $found; - } - } catch (Exception $e) { - continue; - } - } - if (!empty($extendedValues)) { - $result[$property] = $extendedValues; - } - } else { - // Handle single value - $found = $this->objectEntityMapper->find($value); - if ($found) { - $result[$property] = $found; - } - } - } catch (Exception $e2) { - // If lookup fails, keep original value - continue; - } - } - } + // Handle internal source + if ($register->getSource() === 'internal' || $register->getSource() === '') { + return $this->objectEntityMapper->findByUuid($register, $schema, $uuid); + } - return $result; - } + //@todo mongodb support - /** - * Get all registers extended with their schemas - * - * @return array The registers with schema data - * @throws Exception If extension fails - */ - public function getRegisters(): array - { - // Get all registers - $registers = $this->registerMapper->findAll(); - - // Convert to arrays - $registers = array_map(function($object) { - return $object->jsonSerialize(); - }, $registers); - - // Extend with schemas - $extend = ['schemas']; - if (empty($extend) === false) { - $registers = array_map(function($object) use ($extend) { - return $this->extendEntity(entity: $object, extend: $extend); - }, $registers); - } + throw new Exception('Unsupported source type'); + } - return $registers; - } + /** + * Check if a string contains a dot and get the substring before the first dot. + * + * @param string $input The input string. + * + * @return string The substring before the first dot, or the original string if no dot is found. + */ + private function getStringBeforeDot(string $input): string + { + // Find the position of the first dot + $dotPosition = strpos($input, '.'); - /** - * Get current register ID - * - * @return int The register ID - */ - public function getRegister(): int - { - return $this->register; - } + // Return the substring before the dot, or the original string if no dot is found + return $dotPosition !== false ? substr($input, 0, $dotPosition) : $input; + } - /** - * Set current register ID - * - * @param int $register The register ID to set - */ - public function setRegister(int $register): void - { - $this->register = $register; - } + /** + * Get the substring after the last slash in a string. + * + * @param string $input The input string. + * + * @return string The substring after the last slash. + */ + function getStringAfterLastSlash(string $input): string + { + // Find the position of the last slash + $lastSlashPos = strrpos($input, '/'); - /** - * Get current schema ID - * - * @return int The schema ID - */ - public function getSchema(): int - { - return $this->schema; - } + // Return the substring after the last slash, or the original string if no slash is found + return $lastSlashPos !== false ? substr($input, $lastSlashPos + 1) : $input; + } - /** - * Set current schema ID - * - * @param int $schema The schema ID to set - */ - public function setSchema(int $schema): void - { - $this->schema = $schema; - } + /** + * Cascade delete related objects based on schema properties. + * + * This method identifies properties in the schema marked for cascade deletion and deletes + * related objects associated with those properties in the given object. + * + * @param Register $register The register containing the objects. + * @param Schema $schema The schema defining the properties and relationships. + * @param ObjectEntity $object The object entity whose related objects should be deleted. + * + * @return void + * + * @throws Exception If any errors occur during the deletion process. + */ + private function cascadeDeleteObjects(Register $register, Schema $schema, ObjectEntity $object, string $originalObjectId): void + { + $cascadeDeleteProperties = []; + foreach ($schema->getProperties() as $propertyName => $property) { + if ((isset($property['cascadeDelete']) === true && $property['cascadeDelete'] === true) || (isset($property['items']['cascadeDelete']) === true && $property['items']['cascadeDelete'] === true)) { + $cascadeDeleteProperties[] = $propertyName; + } + } - /** - * Get the audit trail for a specific object - * - * @param string $id The object ID - * @param int|null $register Optional register ID to override current register - * @param int|null $schema Optional schema ID to override current schema - * @return array The audit trail entries - */ - public function getAuditTrail(string $id, ?int $register = null, ?int $schema = null): array - { - // Get the object to get its URI and UUID - $object = $this->find($id); + foreach ($object->getRelations() as $relationName => $relation) { + $relationName = $this->getStringBeforeDot(input: $relationName); + $relatedObjectId = $this->getStringAfterLastSlash(input: $relation); + // Check if this sub object has cacsadeDelete = true and is not the original object that started this delete streakt + if (in_array(needle: $relationName, haystack: $cascadeDeleteProperties) === true && $relatedObjectId !== $originalObjectId) { + $this->deleteObject(register: $register->getId(), schema: $schema->getId(), uuid: $relatedObjectId, originalObjectId: $originalObjectId); + } + } + } + + /** + * Delete an object + * + * @param string|int $register The register to delete from + * @param string|int $schema The schema of the object + * @param string $uuid The UUID of the object to delete + * @param string|null $originalObjectId The UUID of the parent object so we dont delete the object we come from and cause a loop + * + * @return bool True if deletion was successful + * @throws Exception If source type is unsupported + */ + public function deleteObject($register, $schema, string $uuid, ?string $originalObjectId = null): bool + { + $register = $this->registerMapper->find($register); + $schema = $this->schemaMapper->find($schema); + + // Handle internal source + if ($register->getSource() === 'internal' || $register->getSource() === '') { + $object = $this->objectEntityMapper->findByUuidOnly(uuid: $uuid); + + if ($object === null) { + return false; + } + + // If internal register and schema should be found from the object himself. Makes it possible to delete cascaded objects. + $register = $this->registerMapper->find($object->getRegister()); + $schema = $this->schemaMapper->find($object->getSchema()); + + if ($originalObjectId === null) { + $originalObjectId = $object->getUuid(); + } + + $this->cascadeDeleteObjects(register: $register, schema: $schema, object: $object, originalObjectId: $originalObjectId); + + $this->objectEntityMapper->delete($object); + return true; + } - // @todo this is not working, it fails to find the logs + //@todo mongodb support + + throw new Exception('Unsupported source type'); + } + + /** + * Retrieves the appropriate mapper for a specific object type. + * + * Optionally sets the current register and schema when both are provided. + * + * @param string|null $objectType The type of the object for which a mapper is needed. + * @param int|null $register Optional register ID to set for the mapper. + * @param int|null $schema Optional schema ID to set for the mapper. + * + * @return mixed The mapper for the specified object type. + * @throws InvalidArgumentException If the object type is unknown. + */ + public function getMapper(?string $objectType = null, ?int $register = null, ?int $schema = null): mixed + { + // Return self if register and schema provided + if ($register !== null && $schema !== null) { + $this->setSchema($schema); + $this->setRegister($register); + return $this; + } + + // Return appropriate mapper based on object type + switch ($objectType) { + case 'register': + return $this->registerMapper; + case 'schema': + return $this->schemaMapper; + case 'objectEntity': + return $this->objectEntityMapper; + default: + throw new InvalidArgumentException("Unknown object type: $objectType"); + } + } + + /** + * Retrieves multiple objects of a specified type using their identifiers. + * + * Processes and cleans input IDs to ensure compatibility with the mapper. + * + * @param string $objectType The type of objects to retrieve. + * @param array $ids The list of object IDs to retrieve. + * + * @return array The retrieved objects. + * @throws InvalidArgumentException If the object type is unknown. + */ + public function getMultipleObjects(string $objectType, array $ids): array + { + // Process the ids to handle different formats + $processedIds = array_map(function ($id) { + if (is_object($id) && method_exists($id, 'getId')) { + return $id->getId(); + } elseif (is_array($id) && isset($id['id'])) { + return $id['id']; + } else { + return $id; + } + }, $ids); + + // Clean up URIs to get just the ID portion + $cleanedIds = array_map(function ($id) { + if (filter_var($id, FILTER_VALIDATE_URL)) { + $parts = explode('/', rtrim($id, '/')); + return end($parts); + } + return $id; + }, $processedIds); + + // Get mapper and find objects + $mapper = $this->getMapper($objectType); + return $mapper->findMultiple($cleanedIds); + } + + /** + * Renders an entity by replacing file and relation IDs with their respective objects. + * + * Expands files and relations within the entity based on the provided extend array. + * + * @param array $entity The entity data to render. + * @param array|null $extend Optional properties to expand within the entity. + * + * @return array The rendered entity with expanded properties. + * @throws Exception If rendering or extending fails. + */ + public function renderEntity(array $entity, ?array $extend = []): array + { + // check if entity has files or relations and if not just return the entity + if (array_key_exists(key: 'files', array: $entity) === false && array_key_exists(key: 'relations', array: $entity) === false) { + return $entity; + } + + // Lets create a dot array of the entity + $dotEntity = new Dot($entity); + + // loop through the files and replace the file ids with the file objects) + if (array_key_exists(key: 'files', array: $entity) === true && empty($entity['files']) === false) { + // Loop through the files array where key is dot notation path and value is file id + foreach ($entity['files'] as $path => $fileId) { + // Replace the value at the dot notation path with the file URL + // @todo: does not work +// $dotEntity->set($path, $filesById[$fileId]->getUrl()); + } + } + + // Loop through the relations and replace the relation ids with the relation objects if extended + if (array_key_exists(key: 'relations', array: $entity) === true && empty($entity['relations']) === false) { + // loop through the relations and replace the relation ids with the relation objects + foreach ($entity['relations'] as $path => $relationId) { + // if the relation is not in the extend array, skip it + if (in_array(needle: $path, haystack: $extend) === false) { + continue; + } + // Replace the value at the dot notation path with the relation object + // @todo: does not work +// $dotEntity->set($path, $this->getObject(register: $this->getRegister(), schema: $this->getSchema(), uuid: $relationId)); + } + } + + // Update the entity with modified values + $entity = $dotEntity->all(); + + return $this->extendEntity(entity: $entity, extend: $extend); + } + + /** + * Extends an entity with related objects based on the extend array. + * + * @param mixed $entity The entity to extend + * @param array $extend Properties to extend with related data + * @return array The extended entity as an array + * @throws Exception If property not found + */ + public function extendEntity(array $entity, array $extend): array + { + // Convert entity to array if needed + if (is_array($entity)) { + $result = $entity; + } else { + $result = $entity->jsonSerialize(); + } + + // Process each property to extend + foreach ($extend as $property) { + $singularProperty = rtrim($property, 's'); + + // Check if property exists + if (array_key_exists(key: $property, array: $result) === true) { + $value = $result[$property]; + if (empty($value)) { + continue; + } + } elseif (array_key_exists(key: $singularProperty, array: $result)) { + $value = $result[$singularProperty]; + } else { + throw new Exception("Property '$property' or '$singularProperty' is not present in the entity."); + } + + // Try to get mapper for property + try { + $mapper = $this->getMapper(objectType: $property); + $propertyObject = $singularProperty; + + // Extend with related objects using specific mapper + if (is_array($value) === true) { + $result[$property] = $this->getMultipleObjects(objectType: $propertyObject, ids: $value); + } else { + $objectId = is_object(value: $value) ? $value->getId() : $value; + $result[$property] = $mapper->find($objectId); + } + } catch (Exception $e) { + // If no specific mapper found, try to look up values in default database + try { + if (is_array($value)) { + // Handle array of values + $extendedValues = []; + foreach ($value as $val) { + try { + $found = $this->objectEntityMapper->find($val); + if ($found) { + $extendedValues[] = $found; + } + } catch (Exception $e) { + continue; + } + } + if (!empty($extendedValues)) { + $result[$property] = $extendedValues; + } + } else { + // Handle single value + $found = $this->objectEntityMapper->find($value); + if ($found) { + $result[$property] = $found; + } + } + } catch (Exception $e2) { + // If lookup fails, keep original value + continue; + } + } + } + + return $result; + } + + /** + * Retrieves all registers with their associated schema data. + * + * Converts registers to arrays and extends them with schema information as needed. + * + * @return array The list of registers with extended schema details. + * @throws Exception If extending schemas fails. + */ + public function getRegisters(): array + { + // Get all registers + $registers = $this->registerMapper->findAll(); + + // Convert to arrays + $registers = array_map(function ($object) { + return $object->jsonSerialize(); + }, $registers); + + // Extend with schemas + $extend = ['schemas']; + if (empty($extend) === false) { + $registers = array_map(function ($object) use ($extend) { + return $this->extendEntity(entity: $object, extend: $extend); + }, $registers); + } + + return $registers; + } + + /** + * Retrieves the current register ID. + * + * @return int The current register ID. + */ + public function getRegister(): int + { + return $this->register; + } + + /** + * Sets the current register ID. + * + * @param int $register The register ID to set. + */ + public function setRegister(int $register): void + { + $this->register = $register; + } + + /** + * Retrieves the current schema ID. + * + * @return int The current schema ID. + */ + public function getSchema(): int + { + return $this->schema; + } + + /** + * Sets the current schema ID. + * + * @param int $schema The schema ID to set. + */ + public function setSchema(int $schema): void + { + $this->schema = $schema; + } + + /** + * Get the audit trail for a specific object + * + * @param string $id The object ID + * @param int|null $register Optional register ID to override current register + * @param int|null $schema Optional schema ID to override current schema + * @return array The audit trail entries + */ + public function getAuditTrail(string $id, ?int $register = null, ?int $schema = null): array + { + // Get the object to get its URI and UUID + $object = $this->find($id); + + // @todo this is not working, it fails to find the logs $auditTrails = $this->auditTrailMapper->findAll(filters: ['object' => $object->getId()]); - - return $auditTrails; - } - /** - * Get all relations for a specific object - * Returns objects that link to this object (incoming references) - * - * @param string $id The object ID - * @param int|null $register Optional register ID to override current register - * @param int|null $schema Optional schema ID to override current schema - * @return array The objects that reference this object - */ - public function getRelations(string $id, ?int $register = null, ?int $schema = null): array - { - $register = $register ?? $this->getRegister(); - $schema = $schema ?? $this->getSchema(); - - // Get the object to get its URI and UUID - $object = $this->find($id); - - // Find objects that reference this object's URI or UUID - $referencingObjects = $this->objectEntityMapper->findByRelationUri( - search: $object->getUuid(), - partialMatch: true - ); + return $auditTrails; + } - // Filter out self-references if any - return array_filter($referencingObjects, function($referencingObject) use ($id) { - return $referencingObject->getUuid() !== $id; - }); - } + /** + * Get all relations for a specific object + * Returns objects that link to this object (incoming references) + * + * @param string $id The object ID + * @param int|null $register Optional register ID to override current register + * @param int|null $schema Optional schema ID to override current schema + * @return array The objects that reference this object + */ + public function getRelations(string $id, ?int $register = null, ?int $schema = null): array + { + $register = $register ?? $this->getRegister(); + $schema = $schema ?? $this->getSchema(); - /** - * Get all uses of a specific object - * Returns objects that this object links to (outgoing references) - * - * @param string $id The object ID - * @param int|null $register Optional register ID to override current register - * @param int|null $schema Optional schema ID to override current schema - * @return array The objects this object references - */ - public function getUses(string $id, ?int $register = null, ?int $schema = null): array - { - // First get the object to access its relations - $object = $this->find($id); - $relations = $object->getRelations() ?? []; - - // Get all referenced objects - $referencedObjects = []; - foreach ($relations as $path => $relationId) { - $referencedObjects[$path] = $this->objectEntityMapper->find($relationId); - - if($referencedObjects[$path] === null){ - $referencedObjects[$path] = $relationId; - } - } + // Get the object to get its URI and UUID + $object = $this->find($id); - return $referencedObjects; - } + // Find objects that reference this object's URI or UUID + $referencingObjects = $this->objectEntityMapper->findByRelationUri( + search: $object->getUuid(), + partialMatch: true + ); + + // Filter out self-references if any + return array_filter($referencingObjects, function($referencingObject) use ($id) { + return $referencingObject->getUuid() !== $id; + }); + } + + /** + * Get all uses of a specific object + * Returns objects that this object links to (outgoing references) + * + * @param string $id The object ID + * @param int|null $register Optional register ID to override current register + * @param int|null $schema Optional schema ID to override current schema + * @return array The objects this object references + */ + public function getUses(string $id, ?int $register = null, ?int $schema = null): array + { + // First get the object to access its relations + $object = $this->find($id); + $relations = $object->getRelations() ?? []; + + // Get all referenced objects + $referencedObjects = []; + foreach ($relations as $path => $relationId) { + $referencedObjects[$path] = $this->objectEntityMapper->find($relationId); + + if($referencedObjects[$path] === null){ + $referencedObjects[$path] = $relationId; + } + } + + return $referencedObjects; + } } diff --git a/src/modals/schema/EditSchemaProperty.vue b/src/modals/schema/EditSchemaProperty.vue index b46abb518d..eb59593595 100644 --- a/src/modals/schema/EditSchemaProperty.vue +++ b/src/modals/schema/EditSchemaProperty.vue @@ -34,7 +34,8 @@ import { navigationStore, schemaStore } from '../../store/store.js' - @@ -174,13 +175,22 @@ import { navigationStore, schemaStore } from '../../store/store.js' :value.sync="properties.default" :loading="loading" /> - +
+ + + + Cascade delete + +
+ + + Cascade delete + + + +
+
+ type: oneOf +
+ +
+
oneOf entry {{ index + 1 }}
+ +
+ +
+ +
+ +
+ + + Remove oneOf entry + +
+ + + Add oneOf entry + +