diff --git a/appinfo/info.xml b/appinfo/info.xml old mode 100644 new mode 100755 diff --git a/composer.json b/composer.json index 7e07700c81..48c0e0e437 100644 --- a/composer.json +++ b/composer.json @@ -36,7 +36,8 @@ "guzzlehttp/guzzle": "^7.0", "opis/json-schema": "^2.3", "symfony/uid": "^6.4", - "symfony/yaml": "^6.4" + "symfony/yaml": "^6.4", + "twig/twig": "^3.18" }, "require-dev": { "nextcloud/ocp": "dev-stable29", diff --git a/composer.lock b/composer.lock index 64ff9c404d..a59137f8dd 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "d4ee34c9190d5b1b8eb1a1841807244e", + "content-hash": "065490421925be3ad6cb1128af7a5e1c", "packages": [ { "name": "adbario/php-dot-notation", @@ -1466,27 +1466,33 @@ "time": "2024-09-09T11:45:10+00:00" }, { - "name": "symfony/polyfill-php80", - "version": "v1.30.0", + "name": "symfony/polyfill-mbstring", + "version": "v1.31.0", "source": { "type": "git", - "url": "https://github.com/symfony/polyfill-php80.git", - "reference": "77fa7995ac1b21ab60769b7323d600a991a90433" + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "85181ba99b2345b0ef10ce42ecac37612d9fd341" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/77fa7995ac1b21ab60769b7323d600a991a90433", - "reference": "77fa7995ac1b21ab60769b7323d600a991a90433", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/85181ba99b2345b0ef10ce42ecac37612d9fd341", + "reference": "85181ba99b2345b0ef10ce42ecac37612d9fd341", "shasum": "" }, "require": { - "php": ">=7.1" + "php": ">=7.2" + }, + "provide": { + "ext-mbstring": "*" + }, + "suggest": { + "ext-mbstring": "For best performance" }, "type": "library", "extra": { "thanks": { - "name": "symfony/polyfill", - "url": "https://github.com/symfony/polyfill" + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" } }, "autoload": { @@ -1494,21 +1500,14 @@ "bootstrap.php" ], "psr-4": { - "Symfony\\Polyfill\\Php80\\": "" - }, - "classmap": [ - "Resources/stubs" - ] + "Symfony\\Polyfill\\Mbstring\\": "" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ "MIT" ], "authors": [ - { - "name": "Ion Bazan", - "email": "ion.bazan@gmail.com" - }, { "name": "Nicolas Grekas", "email": "p@tchwork.com" @@ -1518,16 +1517,17 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", + "description": "Symfony polyfill for the Mbstring extension", "homepage": "https://symfony.com", "keywords": [ "compatibility", + "mbstring", "polyfill", "portable", "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php80/tree/v1.30.0" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.31.0" }, "funding": [ { @@ -1543,7 +1543,7 @@ "type": "tidelift" } ], - "time": "2024-05-31T15:07:36+00:00" + "time": "2024-09-09T11:45:10+00:00" }, { "name": "symfony/polyfill-php81", @@ -1921,6 +1921,86 @@ } ], "time": "2024-09-17T12:47:12+00:00" + }, + { + "name": "twig/twig", + "version": "v3.18.0", + "source": { + "type": "git", + "url": "https://github.com/twigphp/Twig.git", + "reference": "acffa88cc2b40dbe42eaf3a5025d6c0d4600cc50" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/twigphp/Twig/zipball/acffa88cc2b40dbe42eaf3a5025d6c0d4600cc50", + "reference": "acffa88cc2b40dbe42eaf3a5025d6c0d4600cc50", + "shasum": "" + }, + "require": { + "php": ">=8.0.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-ctype": "^1.8", + "symfony/polyfill-mbstring": "^1.3", + "symfony/polyfill-php81": "^1.29" + }, + "require-dev": { + "phpstan/phpstan": "^2.0", + "psr/container": "^1.0|^2.0", + "symfony/phpunit-bridge": "^5.4.9|^6.4|^7.0" + }, + "type": "library", + "autoload": { + "files": [ + "src/Resources/core.php", + "src/Resources/debug.php", + "src/Resources/escaper.php", + "src/Resources/string_loader.php" + ], + "psr-4": { + "Twig\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com", + "homepage": "http://fabien.potencier.org", + "role": "Lead Developer" + }, + { + "name": "Twig Team", + "role": "Contributors" + }, + { + "name": "Armin Ronacher", + "email": "armin.ronacher@active-4.com", + "role": "Project Founder" + } + ], + "description": "Twig, the flexible, fast, and secure template language for PHP", + "homepage": "https://twig.symfony.com", + "keywords": [ + "templating" + ], + "support": { + "issues": "https://github.com/twigphp/Twig/issues", + "source": "https://github.com/twigphp/Twig/tree/v3.18.0" + }, + "funding": [ + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/twig/twig", + "type": "tidelift" + } + ], + "time": "2024-12-29T10:51:50+00:00" } ], "packages-dev": [ diff --git a/docs/events.md b/docs/events.md new file mode 100644 index 0000000000..08a836d40d --- /dev/null +++ b/docs/events.md @@ -0,0 +1,137 @@ +# Events Documentation + +## Overview + +This document provides a comprehensive overview of all events in the OpenRegister application. Events are a crucial part of OpenRegister's component-based architecture, enabling seamless integration with other Nextcloud applications. In Nextcloud's ecosystem, each application functions as an independent component - events provide a standardized way for these components to communicate and interact with OpenRegister. + +These events can be used to hook into various actions and extend functionality, allowing other applications to respond to changes in OpenRegister's data and workflow without tight coupling. This event-driven approach promotes loose coupling between components while enabling rich integration possibilities. + +## Available Events + +### Schema Events + +#### SchemaCreatedEvent +- **Class**: `OCA\OpenRegister\Event\SchemaCreatedEvent` +- **Triggered**: When a new schema is created in the system +- **Data Provided**: + - `getSchema()`: Returns the Schema object that was created +- **Usage**: Can be used to perform additional setup or trigger notifications when new schemas are created + +#### SchemaUpdatedEvent +- **Class**: `OCA\OpenRegister\Event\SchemaUpdatedEvent` +- **Triggered**: When a schema is updated +- **Data Provided**: + - `getSchema()`: Returns the updated Schema object + - `getOldSchema()`: Returns the Schema object before updates +- **Usage**: Useful for tracking changes to schemas and triggering related actions + +#### SchemaDeletedEvent +- **Class**: `OCA\OpenRegister\Event\SchemaDeletedEvent` +- **Triggered**: When a schema is deleted from the system +- **Data Provided**: + - `getSchema()`: Returns the Schema object that was deleted +- **Usage**: Can be used to perform cleanup or trigger additional actions when schemas are removed + +### Register Events + +#### RegisterCreatedEvent +- **Class**: `OCA\OpenRegister\Event\RegisterCreatedEvent` +- **Triggered**: When a new register is created +- **Data Provided**: + - `getRegister()`: Returns the Register object that was created +- **Usage**: Can be used to perform additional setup or trigger notifications when new registers are created + +#### RegisterUpdatedEvent +- **Class**: `OCA\OpenRegister\Event\RegisterUpdatedEvent` +- **Triggered**: When a register is updated +- **Data Provided**: + - `getRegister()`: Returns the updated Register object + - `getOldRegister()`: Returns the Register object before updates +- **Usage**: Useful for tracking changes to registers and triggering related actions + +#### RegisterDeletedEvent +- **Class**: `OCA\OpenRegister\Event\RegisterDeletedEvent` +- **Triggered**: When a register is deleted +- **Data Provided**: + - `getRegister()`: Returns the Register object that was deleted +- **Usage**: Can be used for cleanup operations or notifications when registers are removed + +### Object Events + +#### ObjectCreatedEvent +- **Class**: `OCA\OpenRegister\Event\ObjectCreatedEvent` +- **Triggered**: When a new object is created in a register +- **Data Provided**: + - `getObject()`: Returns the ObjectEntity that was created +- **Usage**: Useful for tracking new entries, triggering notifications, or performing additional processing on new objects + +#### ObjectUpdatedEvent +- **Class**: `OCA\OpenRegister\Event\ObjectUpdatedEvent` +- **Triggered**: When an existing object is updated in a register +- **Data Provided**: + - `getObject()`: Returns the updated ObjectEntity + - `getOldObject()`: Returns the ObjectEntity before updates +- **Usage**: Useful for tracking changes to objects, auditing modifications, or triggering follow-up actions + +#### ObjectDeletedEvent +- **Class**: `OCA\OpenRegister\Event\ObjectDeletedEvent` +- **Triggered**: When an object is deleted from a register +- **Data Provided**: + - `getObject()`: Returns the ObjectEntity that was deleted +- **Usage**: Can be used for cleanup operations, maintaining related data integrity, or sending notifications about deletions + +## Using Events + +Events are a powerful way to decouple different parts of your application and respond to changes in the system. The OpenRegister app uses Nextcloud's event dispatcher system to broadcast various events that you can listen to. + +### Event Handling Overview + +Events are dispatched at key points in the application lifecycle, such as when objects are created, updated, or deleted. By implementing event listeners, you can: + +- Perform additional actions when changes occur +- Maintain data consistency across different parts of the system +- Send notifications or trigger external integrations +- Add custom business logic without modifying core code +- Create audit trails and logs + +### Implementation Steps + +To start handling events in your application, follow these steps: + +### 1. Create an Event Listener Class + +First, create a class that will handle the event. Your listener class should: + +- Implement the `IEventListener` interface +- Define a `handle()` method that receives the event object +- Be placed in an appropriate namespace in your application + +Here's a basic example structure: + +```php +namespace OCA\MyApp\Listener; + +use OCA\OpenRegister\Event\SchemaCreatedEvent; +use OCA\OpenRegister\Event\SchemaUpdatedEvent; +use OCA\OpenRegister\Event\SchemaDeletedEvent; + +class SchemaListener implements IEventListener { + public function handle(SchemaCreatedEvent $event) { + // Handle the event + } +} +``` + +### 2. Register the Event Listener + +After creating your listener class, you need to register it with Nextcloud's event dispatcher system. This is done in your application's `lib/AppInfo/Application.php` file by registering the listener in the `register()` method: + +```php +/* @var IEventDispatcher $dispatcher */ +$dispatcher = $this->getContainer()->get(IEventDispatcher::class); +$dispatcher->addServiceListener(eventName: SchemaCreatedEvent::class, className: SchemaCreatedListener::class); +``` + +This line registers the `SchemaListener` class to handle `SchemaCreatedEvent` events. You can register your listener to other events by adding similar lines. + +You can read more about event handling in the [Nextcloud documentation](https://docs.nextcloud.com/server/latest/developer_manual/basics/events.html). diff --git a/lib/Controller/ObjectsController.php b/lib/Controller/ObjectsController.php index bc8c25f8ce..6556d33aac 100644 --- a/lib/Controller/ObjectsController.php +++ b/lib/Controller/ObjectsController.php @@ -13,7 +13,7 @@ use OCP\AppFramework\Http\JSONResponse; use OCP\DB\Exception; use OCP\IAppConfig; -use OCP\IRequest; +use OCP\IRequest; use OCP\App\IAppManager; use Psr\Container\ContainerExceptionInterface; use Psr\Container\NotFoundExceptionInterface; @@ -111,7 +111,7 @@ public function index(ObjectService $objectService, SearchService $searchService public function show(string $id): JSONResponse { try { - return new JSONResponse($this->objectEntityMapper->find(idOrUuid: (int) $id)->getObjectArray()); + return new JSONResponse($this->objectEntityMapper->find((int) $id)->getObjectArray()); } catch (DoesNotExistException $exception) { return new JSONResponse(data: ['error' => 'Not Found'], statusCode: 404); } @@ -146,7 +146,7 @@ public function create(ObjectService $objectService): JSONResponse unset($data['id']); } - // If mapping ID is provided, transform the object using the mapping + // If mapping ID is provided, transform the object using the mapping $mappingService = $this->getOpenConnectorMappingService(); if ($mapping !== null && $mappingService !== null) { @@ -195,7 +195,7 @@ public function update(int $id, ObjectService $objectService): JSONResponse unset($data['id']); } - // If mapping ID is provided, transform the object using the mapping + // If mapping ID is provided, transform the object using the mapping $mappingService = $this->getOpenConnectorMappingService(); if ($mapping !== null && $mappingService !== null) { @@ -211,9 +211,7 @@ public function update(int $id, ObjectService $objectService): JSONResponse return new JSONResponse(['message' => $exception->getMessage(), 'validationErrors' => $formatter->format($exception->getErrors())], 400); } - $this->auditTrailMapper->createAuditTrail(new: $objectEntity, old: $oldObject); - - return new JSONResponse($objectEntity->getOBjectArray()); + return new JSONResponse($objectEntity->getObjectArray()); } /** @@ -330,11 +328,11 @@ public function logs(int $id): JSONResponse } } - + /** * Retrieves all available mappings - * + * * This method returns a JSON response containing all available mappings in the system. * * @NoAdminRequired @@ -346,15 +344,15 @@ public function mappings(): JSONResponse { // Get mapping service, which will return null based on implementation $mappingService = $this->getOpenConnectorMappingService(); - + // Initialize results array $results = []; - + // If mapping service exists, get all mappings using find() method if ($mappingService !== null) { $results = $mappingService->getMappings(); } - + // Return response with results array and total count return new JSONResponse([ 'results' => $results, diff --git a/lib/Db/ObjectEntityMapper.php b/lib/Db/ObjectEntityMapper.php index 1b3f0a8933..b62bc2a431 100644 --- a/lib/Db/ObjectEntityMapper.php +++ b/lib/Db/ObjectEntityMapper.php @@ -13,6 +13,10 @@ use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\IDBConnection; use Symfony\Component\Uid\Uuid; +use OCP\EventDispatcher\IEventDispatcher; +use OCA\OpenRegister\Event\ObjectCreatedEvent; +use OCA\OpenRegister\Event\ObjectUpdatedEvent; +use OCA\OpenRegister\Event\ObjectDeletedEvent; /** * The ObjectEntityMapper class @@ -22,6 +26,7 @@ class ObjectEntityMapper extends QBMapper { private IDatabaseJsonService $databaseJsonService; + private IEventDispatcher $eventDispatcher; public const MAIN_FILTERS = ['register', 'schema', 'uuid', 'created', 'updated']; @@ -30,14 +35,19 @@ class ObjectEntityMapper extends QBMapper * * @param IDBConnection $db The database connection * @param MySQLJsonService $mySQLJsonService The MySQL JSON service + * @param IEventDispatcher $eventDispatcher The event dispatcher */ - public function __construct(IDBConnection $db, MySQLJsonService $mySQLJsonService) - { + public function __construct( + IDBConnection $db, + MySQLJsonService $mySQLJsonService, + IEventDispatcher $eventDispatcher + ) { parent::__construct($db, 'openregister_objects'); if ($db->getDatabasePlatform() instanceof MySQLPlatform === true) { $this->databaseJsonService = $mySQLJsonService; } + $this->eventDispatcher = $eventDispatcher; } /** @@ -238,6 +248,19 @@ public function findAll(?int $limit = null, ?int $offset = null, ?array $filters return $this->findEntities(query: $qb); } + /** + * @inheritDoc + */ + public function insert(Entity $entity): Entity + { + $entity = parent::insert($entity); + // Dispatch creation event + $this->eventDispatcher->dispatchTyped(new ObjectCreatedEvent($entity)); + + return $entity; + + } + /** * Creates an object from an array * @@ -251,7 +274,26 @@ public function createFromArray(array $object): ObjectEntity if ($obj->getUuid() === null) { $obj->setUuid(Uuid::v4()); } - return $this->insert($obj); + + $obj = $this->insert($obj); + + + return $obj; + } + + /** + * @inheritDoc + */ + public function update(Entity $entity): Entity + { + $oldObject = $this->find($entity->getId()); + + $entity = parent::update($entity); + // Dispatch creation event + $this->eventDispatcher->dispatchTyped(new ObjectUpdatedEvent($entity, $oldObject)); + + return $entity; + } /** @@ -263,18 +305,39 @@ public function createFromArray(array $object): ObjectEntity */ public function updateFromArray(int $id, array $object): ObjectEntity { - $obj = $this->find($id); - $obj->hydrate($object); + $oldObject = $this->find($id); + $newObject = clone $oldObject; + $newObject->hydrate($object); // Set or update the version if (isset($object['version']) === false) { - $version = explode('.', $obj->getVersion()); + $version = explode('.', $newObject->getVersion()); $version[2] = (int) $version[2] + 1; - $obj->setVersion(implode('.', $version)); + $newObject->setVersion(implode('.', $version)); } + $newObject = $this->update($newObject); + + return $newObject; + } + + /** + * Delete an object + * + * @param ObjectEntity $object The object to delete + * @return ObjectEntity The deleted object + */ + public function delete(Entity $object): ObjectEntity + { + $result = parent::delete($object); + + // Dispatch deletion event + $this->eventDispatcher->dispatch( + ObjectDeletedEvent::class, + new ObjectDeletedEvent($object) + ); - return $this->update($obj); + return $result; } /** diff --git a/lib/Db/RegisterMapper.php b/lib/Db/RegisterMapper.php index 1f573d8fff..b68bfa9b3b 100644 --- a/lib/Db/RegisterMapper.php +++ b/lib/Db/RegisterMapper.php @@ -3,13 +3,16 @@ namespace OCA\OpenRegister\Db; use OCA\OpenRegister\Db\Register; +use OCA\OpenRegister\Event\SchemaCreatedEvent; use OCP\AppFramework\Db\Entity; use OCP\AppFramework\Db\QBMapper; use OCP\DB\QueryBuilder\IQueryBuilder; -use OCA\OpenRegister\Db\SchemaMapper; -use OCA\OpenRegister\Db\Schema; use OCP\IDBConnection; use Symfony\Component\Uid\Uuid; +use OCP\EventDispatcher\IEventDispatcher; +use OCA\OpenRegister\Event\RegisterCreatedEvent; +use OCA\OpenRegister\Event\RegisterUpdatedEvent; +use OCA\OpenRegister\Event\RegisterDeletedEvent; /** * The RegisterMapper class @@ -19,17 +22,23 @@ class RegisterMapper extends QBMapper { private $schemaMapper; + private $eventDispatcher; /** * Constructor for RegisterMapper * * @param IDBConnection $db The database connection * @param SchemaMapper $schemaMapper The schema mapper + * @param IEventDispatcher $eventDispatcher The event dispatcher */ - public function __construct(IDBConnection $db, SchemaMapper $schemaMapper) - { + public function __construct( + IDBConnection $db, + SchemaMapper $schemaMapper, + IEventDispatcher $eventDispatcher + ) { parent::__construct($db, 'openregister_registers'); $this->schemaMapper = $schemaMapper; + $this->eventDispatcher = $eventDispatcher; } /** @@ -96,6 +105,19 @@ public function findAll(?int $limit = null, ?int $offset = null, ?array $filters return $this->findEntities(query: $qb); } + /** + * @inheritdoc + */ + public function insert(Entity $entity): Entity + { + $entity = parent::insert($entity); + + // Dispatch creation event + $this->eventDispatcher->dispatchTyped(new RegisterCreatedEvent($entity)); + + return $entity; + } + /** * Create a new register from an array of data * @@ -107,12 +129,27 @@ public function createFromArray(array $object): Register $register = new Register(); $register->hydrate(object: $object); - // Set uuid if not provided if ($register->getUuid() === null) { $register->setUuid(Uuid::v4()); } - return $this->insert(entity: $register); + $register = $this->insert(entity: $register); + + return $register; + } + + /** + * @inheritdoc + */ + public function update(Entity $entity): Entity + { + $oldSchema = $this->find($entity->getId()); + $entity = parent::update($entity); + + // Dispatch update event + $this->eventDispatcher->dispatchTyped(new RegisterUpdatedEvent($entity, $oldSchema)); + + return $entity; } /** @@ -124,18 +161,36 @@ public function createFromArray(array $object): Register */ public function updateFromArray(int $id, array $object): Register { - $obj = $this->find($id); - $obj->hydrate($object); + $newRegister = $this->find($id); + $newRegister->hydrate($object); - // Update the version if (isset($object['version']) === false) { - $version = explode('.', $obj->getVersion()); + $version = explode('.', $newRegister->getVersion()); $version[2] = (int) $version[2] + 1; - $obj->setVersion(implode('.', $version)); + $newRegister->setVersion(implode('.', $version)); } - // Update the register and return it - return $this->update($obj); + $newRegister = $this->update($newRegister); + + return $newRegister; + } + + /** + * Delete a register + * + * @param Register $entity The register to delete + * @return Register The deleted register + */ + public function delete(Entity $entity): Register + { + $result = parent::delete($entity); + + // Dispatch deletion event + $this->eventDispatcher->dispatchTyped( + new RegisterDeletedEvent($entity) + ); + + return $result; } /** diff --git a/lib/Db/Schema.php b/lib/Db/Schema.php index db08bf7745..407d918e67 100644 --- a/lib/Db/Schema.php +++ b/lib/Db/Schema.php @@ -23,6 +23,7 @@ class Schema extends Entity implements JsonSerializable protected bool $hardValidation = false; protected ?DateTime $updated = null; protected ?DateTime $created = null; + protected int $maxDepth = 0; public function __construct() { $this->addType(fieldName: 'uuid', type: 'string'); @@ -37,6 +38,7 @@ public function __construct() { $this->addType(fieldName: 'hardValidation', type: Types::BOOLEAN); $this->addType(fieldName: 'updated', type: 'datetime'); $this->addType(fieldName: 'created', type: 'datetime'); + $this->addType(fieldName: 'maxDepth', type: Types::INTEGER); } public function getJsonFields(): array @@ -107,6 +109,7 @@ public function jsonSerialize(): array 'hardValidation' => $this->hardValidation, 'updated' => isset($this->updated) ? $this->updated->format('c') : null, 'created' => isset($this->created) ? $this->created->format('c') : null, + 'maxDepth' => $this->maxDepth, ]; $jsonFields = $this->getJsonFields(); diff --git a/lib/Db/SchemaMapper.php b/lib/Db/SchemaMapper.php index 11f3953405..9884ec6bb3 100644 --- a/lib/Db/SchemaMapper.php +++ b/lib/Db/SchemaMapper.php @@ -8,6 +8,10 @@ use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\IDBConnection; use Symfony\Component\Uid\Uuid; +use OCP\EventDispatcher\IEventDispatcher; +use OCA\OpenRegister\Event\SchemaCreatedEvent; +use OCA\OpenRegister\Event\SchemaUpdatedEvent; +use OCA\OpenRegister\Event\SchemaDeletedEvent; /** * The SchemaMapper class @@ -16,14 +20,20 @@ */ class SchemaMapper extends QBMapper { + private $eventDispatcher; + /** * Constructor for the SchemaMapper * * @param IDBConnection $db The database connection + * @param IEventDispatcher $eventDispatcher The event dispatcher */ - public function __construct(IDBConnection $db) - { + public function __construct( + IDBConnection $db, + IEventDispatcher $eventDispatcher + ) { parent::__construct($db, 'openregister_schemas'); + $this->eventDispatcher = $eventDispatcher; } /** @@ -101,6 +111,19 @@ public function findAll(?int $limit = null, ?int $offset = null, ?array $filters return $this->findEntities(query: $qb); } + /** + * @inheritdoc + */ + public function insert(Entity $entity): Entity + { + $entity = parent::insert($entity); + + // Dispatch creation event + $this->eventDispatcher->dispatchTyped(new SchemaCreatedEvent($entity)); + + return $entity; + } + /** * Creates a schema from an array * @@ -112,12 +135,27 @@ public function createFromArray(array $object): Schema $schema = new Schema(); $schema->hydrate(object: $object); - // Set uuid if not provided if ($schema->getUuid() === null) { $schema->setUuid(Uuid::v4()); } - return $this->insert(entity: $schema); + $schema = $this->insert(entity: $schema); + + return $schema; + } + + /** + * @inheritdoc + */ + public function update(Entity $entity): Entity + { + $oldSchema = $this->find($entity->getId()); + $entity = parent::update($entity); + + // Dispatch update event + $this->eventDispatcher->dispatchTyped(new SchemaUpdatedEvent($entity, $oldSchema)); + + return $entity; } /** @@ -129,16 +167,35 @@ public function createFromArray(array $object): Schema */ public function updateFromArray(int $id, array $object): Schema { - $obj = $this->find($id); - $obj->hydrate($object); + $newSchema = $this->find($id); + $newSchema->hydrate($object); - // Set or update the version if (isset($object['version']) === false) { - $version = explode('.', $obj->getVersion()); + $version = explode('.', $newSchema->getVersion()); $version[2] = (int) $version[2] + 1; - $obj->setVersion(implode('.', $version)); + $newSchema->setVersion(implode('.', $version)); } - return $this->update($obj); + $newSchema = $this->update($newSchema); + + return $newSchema; + } + + /** + * Delete a schema + * + * @param Schema $schema The schema to delete + * @return Schema The deleted schema + */ + public function delete(Entity $schema): Schema + { + $result = parent::delete($schema); + + // Dispatch deletion event + $this->eventDispatcher->dispatchTyped( + new SchemaDeletedEvent($schema) + ); + + return $result; } } diff --git a/lib/Event/ObjectCreatedEvent.php b/lib/Event/ObjectCreatedEvent.php new file mode 100644 index 0000000000..357ac589d4 --- /dev/null +++ b/lib/Event/ObjectCreatedEvent.php @@ -0,0 +1,34 @@ +object = $object; + } + + /** + * Get the created object entity + * + * @return ObjectEntity The object entity that was created + */ + public function getObject(): ObjectEntity { + return $this->object; + } +} diff --git a/lib/Event/ObjectDeletedEvent.php b/lib/Event/ObjectDeletedEvent.php new file mode 100644 index 0000000000..fb0f3d4c64 --- /dev/null +++ b/lib/Event/ObjectDeletedEvent.php @@ -0,0 +1,34 @@ +object = $object; + } + + /** + * Get the deleted object entity + * + * @return ObjectEntity The object entity that was deleted + */ + public function getObject(): ObjectEntity { + return $this->object; + } +} \ No newline at end of file diff --git a/lib/Event/ObjectUpdatedEvent.php b/lib/Event/ObjectUpdatedEvent.php new file mode 100644 index 0000000000..264f723a42 --- /dev/null +++ b/lib/Event/ObjectUpdatedEvent.php @@ -0,0 +1,48 @@ +newObject = $newObject; + $this->oldObject = $oldObject; + } + + /** + * Get the updated object entity + * + * @return ObjectEntity The object entity after update + */ + public function getNewObject(): ObjectEntity { + return $this->newObject; + } + + /** + * Get the original object entity + * + * @return ObjectEntity The object entity before update + */ + public function getOldObject(): ObjectEntity { + return $this->oldObject; + } +} \ No newline at end of file diff --git a/lib/Event/RegisterCreatedEvent.php b/lib/Event/RegisterCreatedEvent.php new file mode 100644 index 0000000000..e5ba94d91b --- /dev/null +++ b/lib/Event/RegisterCreatedEvent.php @@ -0,0 +1,34 @@ +register = $register; + } + + /** + * Get the created register + * + * @return Register The register that was created + */ + public function getRegister(): Register { + return $this->register; + } +} \ No newline at end of file diff --git a/lib/Event/RegisterDeletedEvent.php b/lib/Event/RegisterDeletedEvent.php new file mode 100644 index 0000000000..0eb3c90ffd --- /dev/null +++ b/lib/Event/RegisterDeletedEvent.php @@ -0,0 +1,34 @@ +register = $register; + } + + /** + * Get the deleted register + * + * @return Register The register that was deleted + */ + public function getRegister(): Register { + return $this->register; + } +} \ No newline at end of file diff --git a/lib/Event/RegisterUpdatedEvent.php b/lib/Event/RegisterUpdatedEvent.php new file mode 100644 index 0000000000..fe9b660539 --- /dev/null +++ b/lib/Event/RegisterUpdatedEvent.php @@ -0,0 +1,48 @@ +newRegister = $newRegister; + $this->oldRegister = $oldRegister; + } + + /** + * Get the updated register + * + * @return Register The register after update + */ + public function getNewRegister(): Register { + return $this->newRegister; + } + + /** + * Get the original register + * + * @return Register The register before update + */ + public function getOldRegister(): Register { + return $this->oldRegister; + } +} \ No newline at end of file diff --git a/lib/Event/SchemaCreatedEvent.php b/lib/Event/SchemaCreatedEvent.php new file mode 100644 index 0000000000..522c758a68 --- /dev/null +++ b/lib/Event/SchemaCreatedEvent.php @@ -0,0 +1,34 @@ +schema = $schema; + } + + /** + * Get the created schema + * + * @return Schema The schema that was created + */ + public function getSchema(): Schema { + return $this->schema; + } +} \ No newline at end of file diff --git a/lib/Event/SchemaDeletedEvent.php b/lib/Event/SchemaDeletedEvent.php new file mode 100644 index 0000000000..041f1993ea --- /dev/null +++ b/lib/Event/SchemaDeletedEvent.php @@ -0,0 +1,34 @@ +schema = $schema; + } + + /** + * Get the deleted schema + * + * @return Schema The schema that was deleted + */ + public function getSchema(): Schema { + return $this->schema; + } +} \ No newline at end of file diff --git a/lib/Event/SchemaUpdatedEvent.php b/lib/Event/SchemaUpdatedEvent.php new file mode 100644 index 0000000000..397d9c81b0 --- /dev/null +++ b/lib/Event/SchemaUpdatedEvent.php @@ -0,0 +1,48 @@ +newSchema = $newSchema; + $this->oldSchema = $oldSchema; + } + + /** + * Get the updated schema + * + * @return Schema The schema after update + */ + public function getNewSchema(): Schema { + return $this->newSchema; + } + + /** + * Get the original schema + * + * @return Schema The schema before update + */ + public function getOldSchema(): Schema { + return $this->oldSchema; + } +} \ No newline at end of file diff --git a/lib/Migration/Version1Date20241227153853.php b/lib/Migration/Version1Date20241227153853.php new file mode 100644 index 0000000000..2b5e51b7b0 --- /dev/null +++ b/lib/Migration/Version1Date20241227153853.php @@ -0,0 +1,57 @@ +getTable('openregister_schemas'); + if ($table->hasColumn('max_depth') === false) { + $table->addColumn(name: 'max_depth', typeName: Types::INTEGER, options: ['notnull' => true])->setDefault(default: 0); + } + + 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/MySQLJsonService.php b/lib/Service/MySQLJsonService.php index 43074209b6..669a939fb2 100644 --- a/lib/Service/MySQLJsonService.php +++ b/lib/Service/MySQLJsonService.php @@ -23,13 +23,13 @@ class MySQLJsonService implements IDatabaseJsonService public function orderJson(IQueryBuilder $builder, array $order = []): IQueryBuilder { // Loop through each ordering field and direction - foreach ($order as $item=>$direction) { + foreach ($order as $item => $direction) { // Create parameters for the JSON path and sort direction $builder->createNamedParameter(value: "$.$item", placeHolder: ":path$item"); $builder->createNamedParameter(value: $direction, placeHolder: ":direction$item"); // Add ORDER BY clause using JSON_UNQUOTE and JSON_EXTRACT - $builder->orderBy($builder->createFunction("json_unquote(json_extract(object, :path$item))"),$direction); + $builder->orderBy($builder->createFunction("json_unquote(json_extract(object, :path$item))"), $direction); } return $builder; @@ -67,20 +67,40 @@ public function searchJson(IQueryBuilder $builder, ?string $search = null): IQue */ private function jsonFilterArray(IQueryBuilder $builder, string $filter, array $values): IQueryBuilder { - foreach ($values as $key=>$value) { + foreach ($values as $key => $value) { switch ($key) { case 'after': + case 'gte': + case '>=': // Add >= filter for dates after specified value $builder->createNamedParameter(value: $value, type: IQueryBuilder::PARAM_STR, placeHolder: ":value{$filter}after"); $builder ->andWhere("json_unquote(json_extract(object, :path$filter)) >= (:value{$filter}after)"); break; case 'before': + case 'lte': + case '<=': // Add <= filter for dates before specified value $builder->createNamedParameter(value: $value, type: IQueryBuilder::PARAM_STR, placeHolder: ":value{$filter}before"); $builder ->andWhere("json_unquote(json_extract(object, :path$filter)) <= (:value{$filter}before)"); break; + case 'strictly_after': + case 'gt': + case '>': + // Add >= filter for dates after specified value + $builder->createNamedParameter(value: $value, type: IQueryBuilder::PARAM_STR, placeHolder: ":value{$filter}after"); + $builder + ->andWhere("json_unquote(json_extract(object, :path$filter)) > (:value{$filter}after)"); + break; + case 'strictly_before': + case 'lt': + case '<': + // Add <= filter for dates before specified value + $builder->createNamedParameter(value: $value, type: IQueryBuilder::PARAM_STR, placeHolder: ":value{$filter}before"); + $builder + ->andWhere("json_unquote(json_extract(object, :path$filter)) < (:value{$filter}before)"); + break; default: // Add IN clause for array of values $builder->createNamedParameter(value: $value, type: IQueryBuilder::PARAM_STR_ARRAY, placeHolder: ":value{$filter}"); @@ -105,11 +125,10 @@ private function jsonFilterArray(IQueryBuilder $builder, string $filter, array $ * @param IQueryBuilder $builder The query builder instance * @return string The resulting OR conditions as a string */ - private function getMultipleContains (array $values, string $filter, IQueryBuilder $builder): string + private function getMultipleContains(array $values, string $filter, IQueryBuilder $builder): string { $orString = ''; - foreach ($values as $key=>$value) - { + foreach ($values as $key => $value) { // Create parameter for each value $builder->createNamedParameter(value: $value, type: IQueryBuilder::PARAM_STR, placeHolder: ":value$filter$key"); // Add OR condition checking if value exists in JSON array @@ -119,6 +138,27 @@ private function getMultipleContains (array $values, string $filter, IQueryBuild return $orString; } + /** + * Parse filter in PHP style to MySQL style filter + * + * @param string $filter The original filter + * @return string The parsed filter for MySQL + */ + private function parseFilter(string $filter): string + { + $explodedFilter = explode( + separator: '_', + string: $filter + ); + + $explodedFilter = array_map(function($field) {return "\"$field\"";}, $explodedFilter); + + return implode( + separator: '**.', + array: $explodedFilter + ); + } + /** * Add JSON filtering to a query * @@ -137,8 +177,10 @@ public function filterJson(IQueryBuilder $builder, array $filters): IQueryBuilde unset($filters['register'], $filters['schema'], $filters['updated'], $filters['created'], $filters['_queries']); foreach ($filters as $filter=>$value) { + $parsedFilter = $this->parseFilter($filter); + // Create parameter for JSON path - $builder->createNamedParameter(value: "$.$filter", placeHolder: ":path$filter"); + $builder->createNamedParameter(value: "$.$parsedFilter", placeHolder: ":path$filter"); if (is_array($value) === true && array_is_list($value) === false) { // Handle complex filters (after/before) @@ -155,7 +197,7 @@ public function filterJson(IQueryBuilder $builder, array $filters): IQueryBuilde // Handle simple equality filter $builder->createNamedParameter(value: $value, placeHolder: ":value$filter"); $builder - ->andWhere("json_extract(object, :path$filter) = :value$filter OR json_contains(object, json_quote(:value$filter), :path$filter)"); + ->andWhere("json_extract(object, :path$filter) = :value$filter OR json_contains(json_extract(object, :path$filter), json_quote(:value$filter))"); } return $builder; diff --git a/lib/Service/ObjectService.php b/lib/Service/ObjectService.php old mode 100644 new mode 100755 index 39d64fe0a4..909bff769d --- a/lib/Service/ObjectService.php +++ b/lib/Service/ObjectService.php @@ -8,6 +8,8 @@ use GuzzleHttp\Exception\GuzzleException; use InvalidArgumentException; use JsonSerializable; +use OCA\OpenConnector\Twig\MappingExtension; +use OCA\OpenConnector\Twig\MappingRuntimeLoader; use OCA\OpenRegister\Db\File; use OCA\OpenRegister\Db\FileMapper; use OCA\OpenRegister\Db\Source; @@ -34,6 +36,8 @@ use stdClass; use Symfony\Component\Uid\Uuid; use GuzzleHttp\Client; +use Twig\Environment; +use Twig\Loader\ArrayLoader; /** * Service class for handling object operations. @@ -81,8 +85,11 @@ public function __construct( private readonly IAppManager $appManager, private readonly IAppConfig $config, private readonly FileMapper $fileMapper, + ArrayLoader $loader ) { + $this->twig = new Environment($loader); + $this->twig->addExtension(new MappingExtension()); } /** @@ -208,12 +215,12 @@ public function createFromArray(array $object, ?array $extend = []): array register: $this->getRegister(), schema: $this->getSchema(), object: $object - ); + ); // Lets turn the whole thing into an array $objectEntity = $objectEntity->jsonSerialize(); - // Extend object with properties if requested + // Extend object with properties if requested if (empty($extend) === false) { $objectEntity = $this->extendEntity(entity: $objectEntity, extend: $extend); } @@ -257,7 +264,7 @@ public function updateFromArray(string $id, array $object, bool $updateVersion, // Lets turn the whole thing into an array $objectEntity = $objectEntity->jsonSerialize(); - // Extend object with properties if requested + // Extend object with properties if requested if (empty($extend) === false) { $objectEntity = $this->extendEntity(entity: $objectEntity, extend: $extend); } @@ -541,22 +548,30 @@ public function getObjects( * @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 - { + public function saveObject(int $register, int $schema, array $object, ?int $depth = null): ObjectEntity + { + // Remove system properties (starting with _) $object = array_filter($object, function($key) { return !str_starts_with($key, '_'); }, ARRAY_FILTER_USE_KEY); // Convert register and schema to their respective objects if they are strings // @todo ??? - if (is_string($register)) { + if (is_string($register) === true) { $register = $this->registerMapper->find($register); } - if (is_string($schema)) { + if (is_string($schema) === true) { $schema = $this->schemaMapper->find($schema); } + if ($depth === null && $schema instanceof Schema) { + $depth = $schema->getMaxDepth();; + } else if ($depth === null) { + $schemaObject = $this->schemaMapper->find($schema); + $depth = $schemaObject->getMaxDepth(); + } + // Check if object already exists if (isset($object['id']) === true) { $objectEntity = $this->objectEntityMapper->findByUuid( @@ -592,6 +607,8 @@ public function saveObject(int $register, int $schema, array $object): ObjectEnt $objectEntity->setUuid(Uuid::v4()); } + $objectEntity->setUri($this->urlGenerator->getAbsoluteURL($this->urlGenerator->linkToRoute('openregister.Objects.show', ['id' => $objectEntity->getUuid()]))); + // Let grap any links that we can $objectEntity = $this->handleLinkRelations($objectEntity, $object); @@ -599,10 +616,10 @@ public function saveObject(int $register, int $schema, array $object): ObjectEnt // 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 = $this->handleObjectRelations($objectEntity, $object, $schemaObject->getProperties(), $register, $schema, depth: $depth); } - $objectEntity->setUri($this->urlGenerator->getAbsoluteURL($this->urlGenerator->linkToRoute('openregister.Objects.show', ['id' => $objectEntity->getUuid()]))); + $this->setDefaults($objectEntity); if ($objectEntity->getId() && ($schemaObject->getHardValidation() === false || $validationResult->isValid() === true)) { $objectEntity = $this->objectEntityMapper->update($objectEntity); @@ -692,8 +709,9 @@ private function addObject( ObjectEntity $objectEntity, int $register, int $schema, - ?int $index = null - ): string + ?int $index = null, + int $depth = 0, + ): string|array { $subSchema = $schema; if (is_int($property['$ref']) === true) { @@ -708,7 +726,8 @@ private function addObject( $nestedObject = $this->saveObject( register: $register, schema: $subSchema, - object: $item + object: $item, + depth: $depth-1 ); if ($index === null) { @@ -722,6 +741,9 @@ private function addObject( $objectEntity->setRelations($relations); } + if ($depth !== 0) { + return $nestedObject->jsonSerialize(); + } return $nestedObject->getUuid(); } @@ -745,8 +767,9 @@ private function handleObjectProperty( array $item, ObjectEntity $objectEntity, int $register, - int $schema - ): string + int $schema, + int $depth = 0 + ): string|array { return $this->addObject( property: $property, @@ -754,7 +777,8 @@ private function handleObjectProperty( item: $item, objectEntity: $objectEntity, register: $register, - schema: $schema + schema: $schema, + depth: $depth ); } @@ -780,7 +804,8 @@ private function handleArrayProperty( array $items, ObjectEntity $objectEntity, int $register, - int $schema + int $schema, + int $depth = 0 ): array { if (isset($property['items']) === false) { @@ -796,7 +821,8 @@ private function handleArrayProperty( objectEntity: $objectEntity, register: $register, schema: $schema, - index: $index + index: $index, + depth: $depth ); } return $items; @@ -828,7 +854,8 @@ private function handleArrayProperty( objectEntity: $objectEntity, register: $register, schema: $schema, - index: $index + index: $index, + depth: $depth ); } @@ -859,7 +886,8 @@ private function handleOneOfProperty( ObjectEntity $objectEntity, int $register, int $schema, - ?int $index = null + ?int $index = null, + int $depth = 0 ): string|array { if (array_is_list($property) === false) { @@ -913,7 +941,8 @@ private function handleOneOfProperty( objectEntity: $objectEntity, register: $register, schema: $schema, - index: $index + index: $index, + depth: $depth ); } @@ -939,7 +968,8 @@ private function handleProperty( int $register, int $schema, array $object, - ObjectEntity $objectEntity + ObjectEntity $objectEntity, + int $depth = 0 ): array { switch($property['type']) { @@ -951,6 +981,7 @@ private function handleProperty( objectEntity: $objectEntity, register: $register, schema: $schema, + depth: $depth ); break; case 'array': @@ -961,6 +992,7 @@ private function handleProperty( objectEntity: $objectEntity, register: $register, schema: $schema, + depth: $depth ); break; case 'oneOf': @@ -970,7 +1002,9 @@ private function handleProperty( item: $object[$propertyName], objectEntity: $objectEntity, register: $register, - schema: $schema); + schema: $schema, + depth: $depth + ); break; case 'file': $object[$propertyName] = $this->handleFileProperty( @@ -1007,7 +1041,8 @@ private function handleObjectRelations( array $object, array $properties, int $register, - int $schema + int $schema, + int $depth = 0 ): ObjectEntity { // @todo: Multidimensional support should be added @@ -1024,6 +1059,7 @@ private function handleObjectRelations( schema: $schema, object: $object, objectEntity: $objectEntity, + depth: $depth ); } @@ -1514,8 +1550,12 @@ public function renderEntity(array $entity, ?array $extend = []): array * @return array The extended entity as an array * @throws Exception If property not found */ - public function extendEntity(array $entity, array $extend): array + public function extendEntity(array $entity, array $extend, ?int $depth = 0): array { + if ($depth > 3) { + return $entity; + } + // Convert entity to array if needed if (is_array($entity)) { $result = $entity; @@ -1523,8 +1563,12 @@ public function extendEntity(array $entity, array $extend): array $result = $entity->jsonSerialize(); } + if (in_array('all', $extend)) { + $extendProperties = array_keys($result); + } + // Process each property to extend - foreach ($extend as $property) { + foreach ($extendProperties as $property) { $singularProperty = rtrim($property, 's'); // Check if property exists @@ -1561,7 +1605,8 @@ public function extendEntity(array $entity, array $extend): array try { $found = $this->objectEntityMapper->find($val); if ($found) { - $extendedValues[] = $found; + $extendedFound = $this->extendEntity($found->jsonSerialize(), $extend, $depth + 1); + $extendedValues[] = $extendedFound; } } catch (Exception $e) { continue; @@ -1574,7 +1619,8 @@ public function extendEntity(array $entity, array $extend): array // Handle single value $found = $this->objectEntityMapper->find($value); if ($found) { - $result[$property] = $found; + // Serialize and recursively extend the found object (apply depth tracking here) + $result[$property] = $this->extendEntity($found->jsonSerialize(), $extend, $depth + 1); // Start with depth 1 } } } catch (Exception $e2) { @@ -1601,7 +1647,7 @@ public function getRegisters(): array // Convert to arrays and extend schemas $registers = array_map(function($register) { $registerArray = is_array($register) ? $register : $register->jsonSerialize(); - + // Replace schema IDs with actual schema objects if schemas property exists if (isset($registerArray['schemas']) && is_array($registerArray['schemas'])) { $registerArray['schemas'] = array_map( @@ -1738,4 +1784,29 @@ public function getUses(string $id, ?int $register = null, ?int $schema = null): return $referencedObjects; } + + /** + * Sets default values for an object based upon its schema + * + * @param ObjectEntity $objectEntity The object to set default values in. + * + * @return ObjectEntity The resulting objectEntity. + * @throws \Twig\Error\LoaderError + * @throws \Twig\Error\SyntaxError + */ + public function setDefaults(ObjectEntity $objectEntity): ObjectEntity + { + $data = $objectEntity->jsonSerialize(); + $schema = $this->schemaMapper->find($objectEntity->getSchema()); + + foreach ($schema->getProperties() as $name=>$property) { + if (isset($data[$name]) === false && isset($property['default']) === true) { + $data[$name] = $this->twig->createTemplate($property['default'], "{$schema->getTitle()}.$name")->render($objectEntity->getObjectArray()); + } + } + + $objectEntity->setObject($data); + + return $objectEntity; + } } diff --git a/src/modals/object/EditObject.vue b/src/modals/object/EditObject.vue index 094d3725db..4c165a4b81 100644 --- a/src/modals/object/EditObject.vue +++ b/src/modals/object/EditObject.vue @@ -54,7 +54,7 @@ import { objectStore, schemaStore, registerStore, navigationStore } from '../../