From 886edf29c813c40874eba404fe8546383f5b0636 Mon Sep 17 00:00:00 2001 From: Robert Zondervan Date: Wed, 19 Nov 2025 12:04:06 +0100 Subject: [PATCH] Sort objects according to specification --- lib/Controller/ObjectsController.php | 44 ++++---- lib/Db/ObjectEntity.php | 28 +++--- lib/Service/ObjectHandlers/RenderObject.php | 82 +++++++++++---- lib/Service/ObjectService.php | 105 ++------------------ 4 files changed, 109 insertions(+), 150 deletions(-) diff --git a/lib/Controller/ObjectsController.php b/lib/Controller/ObjectsController.php index 22be43bdf..f709d9681 100644 --- a/lib/Controller/ObjectsController.php +++ b/lib/Controller/ObjectsController.php @@ -368,27 +368,27 @@ public function index(string $register, string $schema, ObjectService $objectSer // Build search query with resolved numeric IDs $query = $objectService->buildSearchQuery($this->request->getParams(), $resolved['register'], $resolved['schema']); - + // Extract filtering parameters from request $params = $this->request->getParams(); $rbac = filter_var($params['rbac'] ?? true, FILTER_VALIDATE_BOOLEAN); $multi = filter_var($params['multi'] ?? true, FILTER_VALIDATE_BOOLEAN); $published = filter_var($params['_published'] ?? false, FILTER_VALIDATE_BOOLEAN); $deleted = filter_var($params['deleted'] ?? false, FILTER_VALIDATE_BOOLEAN); - + // **INTELLIGENT SOURCE SELECTION**: ObjectService automatically chooses optimal source $result = $objectService->searchObjectsPaginated($query, $rbac, $multi, $published, $deleted); - - + + // **SUB-SECOND OPTIMIZATION**: Enable response compression for large payloads $response = new JSONResponse($result); - + // Enable gzip compression for responses > 1KB if (isset($result['results']) && count($result['results']) > 10) { $response->addHeader('Content-Encoding', 'gzip'); $response->addHeader('Vary', 'Accept-Encoding'); } - + return $response; }//end index() @@ -490,7 +490,7 @@ public function show( $fields = explode(',', $fields); } - // Convert filter to array if it's a string + // Convert filter to array if it's a string if (is_string($filter)) { $filter = explode(',', $filter); } @@ -1024,20 +1024,20 @@ public function uses(string $id, string $register, string $schema, ObjectService // Build search query using ObjectService searchObjectsPaginated directly $queryParams = $this->request->getParams(); $searchQuery = $queryParams; - + // Clean up unwanted parameters unset($searchQuery['id'], $searchQuery['_route']); - + // Use ObjectService searchObjectsPaginated directly - pass ids as named parameter $result = $objectService->searchObjectsPaginated( - query: $searchQuery, - rbac: true, - multi: true, - published: true, + query: $searchQuery, + rbac: true, + multi: true, + published: true, deleted: false, ids: $relations ); - + // Add relations being searched for debugging $result['relations'] = $relations; @@ -1072,20 +1072,20 @@ public function used(string $id, string $register, string $schema, ObjectService // Build search query using ObjectService searchObjectsPaginated directly $queryParams = $this->request->getParams(); $searchQuery = $queryParams; - + // Clean up unwanted parameters unset($searchQuery['id'], $searchQuery['_route']); - + // Use ObjectService searchObjectsPaginated directly - pass uses as named parameter $result = $objectService->searchObjectsPaginated( - query: $searchQuery, - rbac: true, - multi: true, - published: true, + query: $searchQuery, + rbac: true, + multi: true, + published: true, deleted: false, uses: $id ); - + // Add what we're searching for in debugging $result['uses'] = $id; @@ -1781,7 +1781,7 @@ public function getObjectVectorizationStats(): JSONResponse // Get ObjectService from container for view-aware counting $objectService = $this->container->get(\OCA\OpenRegister\Service\ObjectService::class); - + // Count objects with view filter support $totalObjects = $objectService->searchObjects( query: [ diff --git a/lib/Db/ObjectEntity.php b/lib/Db/ObjectEntity.php index 0b83b6db1..6d0cec60a 100644 --- a/lib/Db/ObjectEntity.php +++ b/lib/Db/ObjectEntity.php @@ -32,25 +32,25 @@ * * This class handles storage and manipulation of objects including their metadata, * locking mechanisms, and serialization for API responses. - * + * * ⚠️ BULK OPERATIONS INTEGRATION: * When adding new database fields to this entity, consider whether they should be * excluded from bulk operation change detection in: * - OptimizedBulkOperations::buildMassiveInsertOnDuplicateKeyUpdateSQL() - * + * * Database-managed fields (auto-populated/controlled by DB) should be added to the * $databaseManagedFields array to prevent false change detection: * - id, uuid: Primary identifiers (never change) - * - created: Set by database DEFAULT CURRENT_TIMESTAMP + * - created: Set by database DEFAULT CURRENT_TIMESTAMP * - updated: Set by database ON UPDATE CURRENT_TIMESTAMP * - published: Auto-managed by schema autoPublish logic - * + * * User/application-managed fields that CAN trigger updates: - * - name, description, summary, image: Extracted metadata + * - name, description, summary, image: Extracted metadata * - object: The actual data payload * - register, schema: Context fields * - owner, organisation: Ownership fields - * + * * Adding fields? Check if they should trigger change detection or be database-managed. */ class ObjectEntity extends Entity implements JsonSerializable @@ -210,7 +210,7 @@ class ObjectEntity extends Entity implements JsonSerializable /** * Last update timestamp. - * + * * 🔒 DATABASE-MANAGED: Set by database ON UPDATE CURRENT_TIMESTAMP * This field should NOT be set during bulk preparation to avoid false change detection. * @@ -220,8 +220,8 @@ class ObjectEntity extends Entity implements JsonSerializable /** * Creation timestamp. - * - * 🔒 DATABASE-MANAGED: Set by database DEFAULT CURRENT_TIMESTAMP + * + * 🔒 DATABASE-MANAGED: Set by database DEFAULT CURRENT_TIMESTAMP * This field should NOT be set during bulk preparation to avoid false change detection. * * @var DateTime|null Creation timestamp @@ -234,7 +234,7 @@ class ObjectEntity extends Entity implements JsonSerializable * This field can be automatically populated via schema metadata mapping configuration. * Configure in schema: { "configuration": { "objectPublishedField": "publicatieDatum" } } * Supports various datetime formats which will be parsed to DateTime objects. - * + * * ⚠️ PARTIALLY DATABASE-MANAGED: Auto-publish logic sets this for NEW objects only. * Excluded from bulk change detection to avoid false updates on existing objects. * @@ -383,10 +383,10 @@ public function __construct() /** * Override getter to provide default empty arrays for JSON array fields - * + * * We only override this one method from parent Entity - everything else * (setters, type conversion, change tracking) uses parent's implementation. - * + * * The ONLY difference: we return [] instead of null for specific JSON fields * that represent collections, making code cleaner throughout the app. * @@ -527,7 +527,9 @@ public function jsonSerialize(): array // Backwards compatibility for old objects. $object = ($this->object ?? []); // Default to an empty array if $this->object is null. - $object['@self'] = $this->getObjectArray($object); + $self['@self'] = $this->getObjectArray($object); + + $object = $self + $object; // Check if name is empty and set uuid as fallback if (empty($object['@self']['name'])) { diff --git a/lib/Service/ObjectHandlers/RenderObject.php b/lib/Service/ObjectHandlers/RenderObject.php index c8bab2a8a..2b725f765 100644 --- a/lib/Service/ObjectHandlers/RenderObject.php +++ b/lib/Service/ObjectHandlers/RenderObject.php @@ -83,7 +83,7 @@ class RenderObject /** * Ultra-aggressive preload cache for sub-second performance - * + * * Contains ALL relationship objects preloaded in a single query * for instant access during rendering without any additional database calls. * @@ -132,11 +132,11 @@ public function __construct( * @param array $extend Array of properties to extend * * @return array Array of preloaded objects indexed by ID/UUID - * + * * @phpstan-param array $objects * @phpstan-param array $extend * @phpstan-return array - * @psalm-param array $objects + * @psalm-param array $objects * @psalm-param array $extend * @psalm-return array */ @@ -155,7 +155,7 @@ public function preloadRelatedObjects(array $objects, array $extend): array } $objectData = $object->getObject(); - + foreach ($extend as $extendField) { // Skip special fields if (str_starts_with($extendField, '@')) { @@ -163,7 +163,7 @@ public function preloadRelatedObjects(array $objects, array $extend): array } $value = $objectData[$extendField] ?? null; - + if (is_array($value)) { // Multiple relationships foreach ($value as $relatedId) { @@ -180,7 +180,7 @@ public function preloadRelatedObjects(array $objects, array $extend): array // Step 2: Remove duplicates and empty values $uniqueIds = array_filter(array_unique($allRelatedIds), fn($id) => !empty($id)); - + if (empty($uniqueIds)) { return []; } @@ -190,13 +190,13 @@ public function preloadRelatedObjects(array $objects, array $extend): array $preloadStart = microtime(true); $relatedObjects = $this->objectCacheService->preloadObjects($uniqueIds); $preloadTime = round((microtime(true) - $preloadStart) * 1000, 2); - + $this->logger->debug('ObjectCache preload completed', [ 'preloadTime' => $preloadTime . 'ms', 'requestedIds' => count($uniqueIds), 'foundObjects' => count($relatedObjects) ]); - + // Step 4: Index by both ID and UUID for quick lookup $indexedObjects = []; foreach ($relatedObjects as $relatedObject) { @@ -207,12 +207,12 @@ public function preloadRelatedObjects(array $objects, array $extend): array } } } - + // Step 5: Add to local cache for backward compatibility $this->objectsCache = array_merge($this->objectsCache, $indexedObjects); - + return $indexedObjects; - + } catch (\Exception $e) { // Log error but don't break the process $this->logger->error('Bulk preloading failed', [ @@ -244,7 +244,7 @@ public function setUltraPreloadCache(array $ultraPreloadCache): void $this->logger->debug('Ultra preload cache set', [ 'cachedObjectCount' => count($ultraPreloadCache) ]); - + }//end setUltraPreloadCache() @@ -256,7 +256,7 @@ public function setUltraPreloadCache(array $ultraPreloadCache): void public function getUltraCacheSize(): int { return count($this->ultraPreloadCache); - + }//end getUltraCacheSize() @@ -334,7 +334,7 @@ private function getObject(int | string $id): ?ObjectEntity // Use cache service for optimized loading (only if not in ultra cache) $object = $this->objectCacheService->getObject($id); - + // Update local cache for backward compatibility if ($object !== null) { $this->objectsCache[$id] = $object; @@ -342,7 +342,7 @@ private function getObject(int | string $id): ?ObjectEntity $this->objectsCache[$object->getUuid()] = $object; } } - + return $object; }//end getObject() @@ -584,7 +584,7 @@ private function renderFileProperties(ObjectEntity $entity): ObjectEntity foreach ($schemaProperties as $propertyName => $propertyConfig) { if ($this->isFilePropertyConfig($propertyConfig)) { $isArrayProperty = ($propertyConfig['type'] ?? '') === 'array'; - + // If it's an array property and not set, initialize it as empty array if ($isArrayProperty && !isset($objectData[$propertyName])) { $objectData[$propertyName] = []; @@ -712,7 +712,7 @@ private function hydrateFileProperty($propertyValue, array $propertyConfig, stri /** * Hydrates metadata (@self) from file properties after they've been converted to file objects. - * + * * This method extracts metadata like image URLs from file properties that have been * hydrated with accessUrl, downloadUrl, etc. * @@ -735,10 +735,10 @@ private function hydrateMetadataFromFileProperties(ObjectEntity $entity): Object // Check if objectImageField is configured if (!empty($config['objectImageField'])) { $imageField = $config['objectImageField']; - + // Get the value from the configured field $value = $this->getValueFromPath($objectData, $imageField); - + // Check if the value is a file object (has downloadUrl or accessUrl) if (is_array($value) && (isset($value['downloadUrl']) || isset($value['accessUrl']))) { // Set the image URL on the entity itself (not in object data) @@ -837,6 +837,48 @@ private function getFileObject($fileId): ?array }//end getFileObject() + public function sortObjectData(array $objectData, ObjectEntity $objectEntity): array + { + $schema = $this->schemasCache[$objectEntity->getSchema()]; + + if($schema === null) { + $schema = $this->schemaMapper->find($objectEntity->getSchema()); + } + + uksort($objectData, function ($a, $b) use ($schema) { + if($b === '@self') { + return 1; + } else if ($a === '@self') { + return -1; + } + + $properties = $schema->getProperties(); + + if(array_key_exists($a, $properties) && array_key_exists($b, $properties)) { + + if ($properties[$a]['order'] === null) { + return 1; + } else if ($properties[$b]['order'] === null) { + return -1; + } + + return $properties[$a]['order'] < $properties[$b]['order'] ? -1 : 1; + } else if (array_key_exists($a, $properties)) { + return -1; + } else if ($a === 'id') { + return -1; + } else if ($b === 'id') { + return 1; + } else { + + return 1; + } + }); + + return $objectData; + + } + /** * Renders an entity with optional extensions and filters. * @@ -988,6 +1030,8 @@ public function renderEntity( $objectData = $this->extendObject($entity, $extend, $objectData, $depth, $filter, $fields, $unset, $visitedIds); } + $objectData = $this->sortObjectData(objectData: $objectData, objectEntity: $entity); + $entity->setObject($objectData); return $entity; diff --git a/lib/Service/ObjectService.php b/lib/Service/ObjectService.php index bc0590596..d8899f4ba 100644 --- a/lib/Service/ObjectService.php +++ b/lib/Service/ObjectService.php @@ -1918,10 +1918,10 @@ private function applyViewsToQuery(array $query, array $viewIds): array // Apply search terms if (!empty($viewQuery['searchTerms'])) { - $searchTerms = is_array($viewQuery['searchTerms']) - ? implode(' ', $viewQuery['searchTerms']) + $searchTerms = is_array($viewQuery['searchTerms']) + ? implode(' ', $viewQuery['searchTerms']) : $viewQuery['searchTerms']; - + $existingSearch = $query['_search'] ?? ''; $query['_search'] = trim($existingSearch . ' ' . $searchTerms); } @@ -2048,6 +2048,8 @@ public function searchObjects(array $query=[], bool $rbac=true, bool $multi=true // **ULTRA-FAST**: Get object data and add minimal @self metadata $objectData = $object->getObject(); + + // Add essential @self metadata without additional database queries $objectData['@self'] = [ 'id' => $object->getId(), @@ -2072,8 +2074,12 @@ public function searchObjects(array $query=[], bool $rbac=true, bool $multi=true $objectData['@self']['depublished'] = $object->getDepublished()->format('Y-m-d\TH:i:s\Z'); } + $objectData = $this->renderHandler->sortObjectData(objectData: $objectData, objectEntity: $object); + $object->setObject($objectData); $objects[$key] = $object; + + } $simpleRenderTime = round((microtime(true) - $startSimpleRender) * 1000, 2); @@ -2086,7 +2092,6 @@ public function searchObjects(array $query=[], bool $rbac=true, bool $multi=true return $objects; } - // **COMPLEX RENDERING PATH**: Full operations for requests needing extensions/filtering $this->logger->debug('Complex rendering path - loading additional context', [ 'objectCount' => count($objects), @@ -4052,98 +4057,6 @@ private function createSlugHelper(string $text): string return $text; }//end createSlugHelper() - - - - - - - - - - - - - - /** - * Handle post-save writeBack operations for inverse relations - * - * This method processes writeBack operations after objects have been saved to the database. - * It uses the SaveObject handler's writeBack functionality for properties that have - * both inversedBy and writeBack enabled. - * - * @param array $savedObjects Array of saved ObjectEntity objects - * @param array $schemaCache Cached schemas indexed by schema ID - * - * @return void - */ - private function handlePostSaveInverseRelations(array $savedObjects, array $schemaCache): void - { - $writeBackCount = 0; - $bulkWriteBackUpdates = []; // PERFORMANCE OPTIMIZATION: Collect updates for bulk processing - - foreach ($savedObjects as $savedObject) { - $objectData = $savedObject->getObject(); - $schemaId = $savedObject->getSchema(); - - if (!isset($schemaCache[$schemaId])) { - continue; - } - - $schema = $schemaCache[$schemaId]; - $schemaProperties = $schema->getProperties(); - - foreach ($objectData as $property => $value) { - if (!isset($schemaProperties[$property])) { - continue; - } - - $propertyConfig = $schemaProperties[$property]; - $items = $propertyConfig['items'] ?? []; - - // Check for writeBack enabled properties - $writeBack = $propertyConfig['writeBack'] ?? ($items['writeBack'] ?? false); - $inversedBy = $propertyConfig['inversedBy'] ?? ($items['inversedBy'] ?? null); - - if ($writeBack && $inversedBy && !empty($value)) { - // Use SaveObject handler's writeBack functionality - try { - // Create a temporary object data array for writeBack processing - $writeBackData = [$property => $value]; - $this->saveHandler->handleInverseRelationsWriteBack($savedObject, $schema, $writeBackData); - $writeBackCount++; - - // After writeBack, update the source object's property with the current value - // This ensures the source object reflects the relationship - $currentObjectData = $savedObject->getObject(); - if (!isset($currentObjectData[$property]) || $currentObjectData[$property] !== $value) { - $currentObjectData[$property] = $value; - $savedObject->setObject($currentObjectData); - - // PERFORMANCE OPTIMIZATION: Collect for bulk update instead of individual UPDATE - $objectUuid = $savedObject->getUuid(); - if (!isset($bulkWriteBackUpdates[$objectUuid])) { - $bulkWriteBackUpdates[$objectUuid] = $savedObject; - } - } - } catch (\Exception $e) { - } - }//end if - }//end foreach - }//end foreach - - // PERFORMANCE OPTIMIZATION: Execute all writeBack updates in a single bulk operation - if (!empty($bulkWriteBackUpdates)) { - $this->performBulkWriteBackUpdates(array_values($bulkWriteBackUpdates)); - } - - - }//end handlePostSaveInverseRelations() - - - - - /** * Filter objects based on RBAC and multi-organization permissions *