diff --git a/appinfo/routes.php b/appinfo/routes.php index b266b9477e..5b3895ce9b 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -1249,6 +1249,7 @@ ['name' => 'objectRelations#removeReferences', 'url' => '/api/objects/{register}/{schema}/{id}/relation-references/{anchor}', 'verb' => 'DELETE', 'requirements' => ['id' => '[^/]+', 'anchor' => '[^/]+']], ['name' => 'objectRelations#derive', 'url' => '/api/objects/{register}/{schema}/{id}/derive', 'verb' => 'POST', 'requirements' => ['id' => '[^/]+']], ['name' => 'objectRelations#graph', 'url' => '/api/objects/{register}/{schema}/{id}/graph', 'verb' => 'GET', 'requirements' => ['id' => '[^/]+']], + ['name' => 'objectRelations#tree', 'url' => '/api/objects/{register}/{schema}/{id}/tree', 'verb' => 'GET', 'requirements' => ['id' => '[^/]+']], ['name' => 'objectRelations#exportGraph', 'url' => '/api/objects/{register}/{schema}/{id}/graph/export', 'verb' => 'GET', 'requirements' => ['id' => '[^/]+']], // Locks. // Whether a row exists in other registers, and nothing about the diff --git a/lib/Controller/ObjectRelationsController.php b/lib/Controller/ObjectRelationsController.php index 91498cca11..0942f0628b 100644 --- a/lib/Controller/ObjectRelationsController.php +++ b/lib/Controller/ObjectRelationsController.php @@ -39,6 +39,7 @@ use OCA\OpenRegister\Service\Object\ObjectReadAccess; use OCA\OpenRegister\Service\ObjectService; use OCA\OpenRegister\Service\Relation\ObjectRelationService; +use OCA\OpenRegister\Service\Relation\ObjectTreeService; use OCA\OpenRegister\Service\Relation\RelationGraphService; use OCA\OpenRegister\Service\Relation\RelationTypeResolver; use OCP\AppFramework\Controller; @@ -67,6 +68,7 @@ class ObjectRelationsController extends Controller { * @param ObjectService $objectService Reads and writes objects, with RBAC. * @param ExportGate $exportGate The export verb, checked before the graph leaves. * @param ObjectReadAccess $access Resolves an object under the caller's own RBAC. + * @param ObjectTreeService $trees The bounded walk along a schema's parent property. * * @spec openspec/changes/relation-types-with-inverses/specs/referential-integrity/spec.md */ @@ -78,6 +80,7 @@ public function __construct( private readonly ObjectService $objectService, private readonly ExportGate $exportGate, private readonly ObjectReadAccess $access, + private readonly ObjectTreeService $trees, ) { parent::__construct(appName: $appName, request: $request); }//end __construct() @@ -483,6 +486,64 @@ public function graph(string $register, string $schema, string $id): JSONRespons ); }//end graph() + /** + * The ancestors and descendants of an object along its schema's parent property. + * + * Query: `direction` (descendants, ancestors or both; default descendants), + * `depth` (default the schema's declared bound) and `fields` (comma-separated + * properties copied onto each node). Every node passes the caller's RBAC. + * + * @param string $register The register slug or id. + * @param string $schema The schema slug or id. + * @param string $id The object's uuid. + * + * @return JSONResponse The tree. + * + * @spec openspec/changes/object-tree-read/specs/referential-integrity/spec.md + */ + #[NoAdminRequired] + #[NoCSRFRequired] + public function tree(string $register, string $schema, string $id): JSONResponse { + $object = $this->access->readable(register: $register, schema: $schema, id: $id); + if ($object === null) { + return $this->access->notReadable(); + } + + $direction = (string)$this->request->getParam('direction', ObjectTreeService::DIRECTION_DESCENDANTS); + $directions = [ + ObjectTreeService::DIRECTION_DESCENDANTS, + ObjectTreeService::DIRECTION_ANCESTORS, + ObjectTreeService::DIRECTION_BOTH, + ]; + if (in_array($direction, $directions, true) === false) { + return new JSONResponse( + data: ['error' => 'direction must be one of: '.implode(', ', $directions)], + statusCode: 400 + ); + } + + $depth = null; + if ($this->stringParam(name: 'depth') !== null) { + $depth = $this->depth(); + } + + $fields = array_values( + array_filter( + array_map('trim', explode(',', (string)$this->stringParam(name: 'fields'))), + static fn (string $field): bool => $field !== '' + ) + ); + + return new JSONResponse( + data: $this->trees->tree( + root: $object, + direction: $direction, + depth: $depth, + fields: $fields + ) + ); + }//end tree() + /** * The same graph, as rows a spreadsheet opens. * diff --git a/lib/Service/Relation/ObjectTreeService.php b/lib/Service/Relation/ObjectTreeService.php new file mode 100644 index 0000000000..fd2d6fb5d4 --- /dev/null +++ b/lib/Service/Relation/ObjectTreeService.php @@ -0,0 +1,509 @@ + + * + * @category Service + * @package OCA\OpenRegister\Service\Relation + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/object-tree-read/specs/referential-integrity/spec.md + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Relation; + +use OCA\OpenRegister\Db\MagicMapper; +use OCA\OpenRegister\Db\ObjectEntity; +use OCA\OpenRegister\Db\Register; +use OCA\OpenRegister\Db\RegisterMapper; +use OCA\OpenRegister\Db\Schema; +use OCA\OpenRegister\Db\SchemaMapper; +use OCA\OpenRegister\Service\Party\PartyDefinition; +use OCA\OpenRegister\Service\Rbac\HierarchyDescender; +use OCA\OpenRegister\Service\Rbac\HierarchyGrantExpander; +use Psr\Log\LoggerInterface; +use Throwable; + +/** + * Walks one object's tree up and down, under the caller's RBAC. + * + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) The walk reads the declaration, + * the table, the access filter and the instance cap; each is its own class. + * + * @spec openspec/changes/object-tree-read/specs/referential-integrity/spec.md + */ +class ObjectTreeService { + + /** + * Read the objects below the root. + * + * @var string + */ + public const DIRECTION_DESCENDANTS = 'descendants'; + + /** + * Read the objects above the root. + * + * @var string + */ + public const DIRECTION_ANCESTORS = 'ancestors'; + + /** + * Read both. + * + * @var string + */ + public const DIRECTION_BOTH = 'both'; + + /** + * The walk stopped at the depth bound with more below or above it. + * + * @var string + */ + public const TRUNCATED_DEPTH = 'depth'; + + /** + * The walk stopped at the node cap. + * + * @var string + */ + public const TRUNCATED_CAP = 'cap'; + + /** + * A level could not be read from the table. + * + * @var string + */ + public const TRUNCATED_UNREADABLE = 'unreadable'; + + /** + * Constructor. + * + * @param HierarchyDescender $descender Reads one level of children from a magic table. + * @param HierarchyGrantExpander $hierarchies Reads a schema's hierarchy declaration. + * @param MagicMapper $objectMapper Loads objects and filters them by read access. + * @param RegisterMapper $registerMapper Resolves the root's register. + * @param SchemaMapper $schemaMapper Resolves the root's schema. + * @param RelationGraphService $graphs Holds the instance's node cap. + * @param LoggerInterface $logger The logger. + */ + public function __construct( + private readonly HierarchyDescender $descender, + private readonly HierarchyGrantExpander $hierarchies, + private readonly MagicMapper $objectMapper, + private readonly RegisterMapper $registerMapper, + private readonly SchemaMapper $schemaMapper, + private readonly RelationGraphService $graphs, + private readonly LoggerInterface $logger, + ) { + }//end __construct() + + /** + * The parent property a schema declares, with its depth bound, or null. + * + * `x-openregister-hierarchy` wins where both are declared, because it is the + * declaration access inheritance already reads. + * + * @param Schema $schema The schema. + * + * @return array{property: string, maxDepth: int, source: string}|null The declaration. + * + * @SuppressWarnings(PHPMD.StaticAccess) PartyDefinition::fromSchema() is the party model's one reader of the annotation. + * + * @spec openspec/changes/object-tree-read/specs/referential-integrity/spec.md + */ + public function declarationFor(Schema $schema): ?array { + $hierarchy = $this->hierarchies->declarationFor(schema: $schema); + if ($hierarchy !== null) { + return [ + 'property' => $hierarchy['parent'], + 'maxDepth' => $hierarchy['maxDepth'], + 'source' => HierarchyGrantExpander::ANNOTATION, + ]; + } + + $party = PartyDefinition::fromSchema(schema: $schema); + if ($party === null || $party->parentProperty() === null) { + return null; + } + + return [ + 'property' => $party->parentProperty(), + 'maxDepth' => min($party->maxDepth(), HierarchyGrantExpander::DEPTH_CEILING), + 'source' => 'x-openregister-party', + ]; + }//end declarationFor() + + /** + * The tree around one object. + * + * The root is always in the answer at level 0: the caller has already been + * allowed to read it. Ancestors carry negative levels, nearest first as -1. + * A node the caller may not read is left out, and so is everything reached + * only through it. + * + * @param ObjectEntity $root The object to walk from, already read under the caller's RBAC. + * @param string $direction One of the DIRECTION_ constants. + * @param int|null $depth The depth asked for; null means the declared bound. + * @param string[] $fields Object properties to copy onto each node. + * @param bool $_rbac Whether to apply the caller's read access to every node. + * + * @return array{root: string, register: int, schema: int, parentProperty: string|null, + * direction: string, depth: int, requestedDepth: int|null, maxDepth: int, + * nodes: array>, truncated: bool, truncatedBy: string|null} + * The tree. + * + * @SuppressWarnings(PHPMD.BooleanArgumentFlag) `_rbac` follows ObjectService's own read signature. + * + * @spec openspec/changes/object-tree-read/specs/referential-integrity/spec.md + */ + public function tree( + ObjectEntity $root, + string $direction = self::DIRECTION_DESCENDANTS, + ?int $depth = null, + array $fields = [], + bool $_rbac = true + ): array { + $registerId = (int)$root->getRegister(); + $schemaId = (int)$root->getSchema(); + $rootUuid = (string)$root->getUuid(); + + $answer = [ + 'root' => $rootUuid, + 'register' => $registerId, + 'schema' => $schemaId, + 'parentProperty' => null, + 'direction' => $direction, + 'depth' => 0, + 'requestedDepth' => $depth, + 'maxDepth' => 0, + 'nodes' => [$this->node(object: $root, level: 0, parent: null, fields: $fields)], + 'truncated' => false, + 'truncatedBy' => null, + ]; + + $context = $this->context(registerId: $registerId, schemaId: $schemaId); + if ($context === null) { + return $answer; + } + + $declaration = $this->declarationFor(schema: $context['schema']); + if ($declaration === null) { + return $answer; + } + + $effective = $declaration['maxDepth']; + if ($depth !== null) { + $effective = min(max(1, $depth), $declaration['maxDepth']); + } + + $answer['parentProperty'] = $declaration['property']; + $answer['maxDepth'] = $declaration['maxDepth']; + $answer['depth'] = $effective; + + $walk = [ + 'register' => $context['register'], + 'schema' => $context['schema'], + 'property' => $declaration['property'], + 'depth' => $effective, + 'fields' => $fields, + 'rbac' => $_rbac, + ]; + + $ancestors = ['nodes' => [], 'truncatedBy' => null]; + if ($direction === self::DIRECTION_ANCESTORS || $direction === self::DIRECTION_BOTH) { + $ancestors = $this->ancestors(root: $root, walk: $walk); + } + + $descendants = ['nodes' => [], 'truncatedBy' => null]; + if ($direction === self::DIRECTION_DESCENDANTS || $direction === self::DIRECTION_BOTH) { + $descendants = $this->descendants(rootUuid: $rootUuid, walk: $walk); + } + + $answer['nodes'] = array_merge( + array_reverse($ancestors['nodes']), + $answer['nodes'], + $descendants['nodes'] + ); + $answer['truncatedBy'] = ($descendants['truncatedBy'] ?? $ancestors['truncatedBy']); + $answer['truncated'] = ($answer['truncatedBy'] !== null); + + return $answer; + }//end tree() + + /** + * The register and schema of the root, or null when either does not resolve. + * + * @param int $registerId The register id. + * @param int $schemaId The schema id. + * + * @return array{register: Register, schema: Schema}|null The pair. + */ + private function context(int $registerId, int $schemaId): ?array { + try { + // Metadata only: which table and which declaration. Access to the + // rows is decided per node below. + return [ + 'register' => $this->registerMapper->find(id: $registerId, _rbac: false, _multitenancy: false), + 'schema' => $this->schemaMapper->find(id: $schemaId, _rbac: false, _multitenancy: false), + ]; + } catch (Throwable $e) { + return null; + } + }//end context() + + /** + * The descendants of the root, breadth first. + * + * @param string $rootUuid The root. + * @param array $walk The register, schema, property, depth, fields and rbac flag. + * + * @return array{nodes: array>, truncatedBy: string|null} The nodes. + */ + private function descendants(string $rootUuid, array $walk): array { + $table = MagicMapper::TABLE_PREFIX . (int)$walk['register']->getId() . '_' . (int)$walk['schema']->getId(); + $column = $this->descender->columnFor(property: $walk['property']); + $cap = $this->graphs->maximumNodes(); + + $nodes = []; + $seen = [$rootUuid => true]; + $frontier = [$rootUuid]; + + for ($level = 1; $level <= $walk['depth']; $level++) { + $children = $this->readableChildren(table: $table, column: $column, parents: $frontier, seen: $seen, walk: $walk); + if ($children === null) { + return ['nodes' => $nodes, 'truncatedBy' => self::TRUNCATED_UNREADABLE]; + } + + if ($children === []) { + return ['nodes' => $nodes, 'truncatedBy' => null]; + } + + if ((count($nodes) + count($children) + 1) > $cap) { + return ['nodes' => $nodes, 'truncatedBy' => self::TRUNCATED_CAP]; + } + + $objects = $this->load(walk: $walk, uuids: array_keys($children)); + $frontier = []; + foreach ($children as $child => $parent) { + $seen[$child] = true; + $frontier[] = $child; + $nodes[] = $this->node(object: ($objects[$child] ?? null), level: $level, parent: $parent, fields: $walk['fields'], uuid: $child); + } + } + + $more = $this->readableChildren(table: $table, column: $column, parents: $frontier, seen: $seen, walk: $walk); + if ($more === null) { + return ['nodes' => $nodes, 'truncatedBy' => self::TRUNCATED_UNREADABLE]; + } + + if ($more !== []) { + return ['nodes' => $nodes, 'truncatedBy' => self::TRUNCATED_DEPTH]; + } + + return ['nodes' => $nodes, 'truncatedBy' => null]; + }//end descendants() + + /** + * The children of a level the caller may read and has not met yet, or null when the level cannot be read. + * + * A cycle in the data shows up here as a child already seen, and is dropped. + * + * @param string $table The magic table. + * @param string $column The parent column. + * @param string[] $parents The level to read below. + * @param array $seen Every uuid already in the answer. + * @param array $walk The walk. + * + * @return array|null Child uuid => parent uuid. + */ + private function readableChildren(string $table, string $column, array $parents, array $seen, array $walk): ?array { + if ($parents === []) { + return []; + } + + try { + $children = $this->descender->childrenOf(table: $table, parentColumn: $column, parentUuids: $parents); + } catch (Throwable $e) { + $this->logger->warning( + message: '[ObjectTreeService] A level of the tree could not be read', + context: [ + 'file' => __FILE__, + 'line' => __LINE__, + 'table' => $table, + 'column' => $column, + 'exception' => $e->getMessage(), + ] + ); + return null; + } + + $children = array_diff_key($children, $seen); + + return $this->readableOnly(walk: $walk, uuids: $children); + }//end readableChildren() + + /** + * The ancestors of the root, nearest first. + * + * The walk stops at the first ancestor the caller may not read: a level + * above a hidden one would carry the wrong level number. + * + * @param ObjectEntity $root The root. + * @param array $walk The walk. + * + * @return array{nodes: array>, truncatedBy: string|null} The nodes, nearest first. + */ + private function ancestors(ObjectEntity $root, array $walk): array { + $nodes = []; + $seen = [(string)$root->getUuid() => true]; + $current = $root; + + for ($level = 1; $level <= $walk['depth']; $level++) { + $parent = $this->readableParent(object: $current, seen: $seen, walk: $walk); + if ($parent === null) { + return ['nodes' => $nodes, 'truncatedBy' => null]; + } + + $uuid = (string)$parent->getUuid(); + $seen[$uuid] = true; + $grandparent = self::reference(value: ($parent->getObject()[$walk['property']] ?? null)); + $nodes[] = $this->node(object: $parent, level: -$level, parent: $grandparent, fields: $walk['fields']); + $current = $parent; + } + + if ($this->readableParent(object: $current, seen: $seen, walk: $walk) !== null) { + return ['nodes' => $nodes, 'truncatedBy' => self::TRUNCATED_DEPTH]; + } + + return ['nodes' => $nodes, 'truncatedBy' => null]; + }//end ancestors() + + /** + * The parent of one object when it exists, is new to the walk and is readable. + * + * @param ObjectEntity $object The object. + * @param array $seen Every uuid already in the answer. + * @param array $walk The walk. + * + * @return ObjectEntity|null The parent. + */ + private function readableParent(ObjectEntity $object, array $seen, array $walk): ?ObjectEntity { + $parentUuid = self::reference(value: ($object->getObject()[$walk['property']] ?? null)); + if ($parentUuid === null || isset($seen[$parentUuid]) === true) { + return null; + } + + if ($this->readableOnly(walk: $walk, uuids: [$parentUuid => '']) === []) { + return null; + } + + return ($this->load(walk: $walk, uuids: [$parentUuid])[$parentUuid] ?? null); + }//end readableParent() + + /** + * The entries of a uuid-keyed map the caller may read. + * + * @param array $walk The walk. + * @param array $uuids Uuid => anything. + * + * @return array The readable entries, in their order. + */ + private function readableOnly(array $walk, array $uuids): array { + if ($uuids === [] || $walk['rbac'] === false) { + return $uuids; + } + + $readable = $this->objectMapper->filterReadableUuids( + registerId: (int)$walk['register']->getId(), + schemaId: (int)$walk['schema']->getId(), + uuids: array_map('strval', array_keys($uuids)) + ); + + return array_intersect_key($uuids, array_flip($readable)); + }//end readableOnly() + + /** + * Load objects of the walk's table by uuid. + * + * @param array $walk The walk. + * @param string[] $uuids The uuids. + * + * @return array Uuid => object. + */ + private function load(array $walk, array $uuids): array { + return $this->objectMapper->findObjectsByUuidsInRegisterSchema( + register: $walk['register'], + schema: $walk['schema'], + uuids: array_map('strval', $uuids) + ); + }//end load() + + /** + * One node of the answer. + * + * @param ObjectEntity|null $object The object, when it loaded. + * @param int $level 0 for the root, negative above, positive below. + * @param string|null $parent The parent uuid. + * @param string[] $fields Properties to copy. + * @param string|null $uuid The uuid when the object did not load. + * + * @return array The node. + */ + private function node(?ObjectEntity $object, int $level, ?string $parent, array $fields, ?string $uuid = null): array { + $uuid = (string)($object?->getUuid() ?? $uuid); + $data = ($object?->getObject() ?? []); + + $properties = []; + foreach ($fields as $field) { + $properties[$field] = ($data[$field] ?? null); + } + + return [ + 'uuid' => $uuid, + 'name' => ($object?->getName() ?? $uuid), + 'level' => $level, + 'parent' => $parent, + 'properties' => $properties, + ]; + }//end node() + + /** + * The uuid a parent property holds, or null. + * + * @param mixed $value The stored value: a uuid, or a reference object carrying one. + * + * @return string|null The uuid. + */ + private static function reference(mixed $value): ?string { + if (is_array($value) === true) { + $value = ($value['uuid'] ?? ($value['id'] ?? ($value['value'] ?? null))); + } + + if (is_string($value) === false || trim($value) === '') { + return null; + } + + return trim($value); + }//end reference() +}//end class diff --git a/openspec/changes/object-tree-read/design.md b/openspec/changes/object-tree-read/design.md new file mode 100644 index 0000000000..048d323663 --- /dev/null +++ b/openspec/changes/object-tree-read/design.md @@ -0,0 +1,54 @@ +# Design: object-tree-read + +Read at openregister development b9e5d8df4. + +## D-1: no second declaration + +The parent property comes from `x-openregister-hierarchy.parent` when present, else from +`x-openregister-party.parentProperty`. Both already exist and both mean "this property +holds the uuid of the parent object in this schema". The hierarchy one also sends +grants down, which an organisation group must not do (a person allowed to read the +holding is not thereby allowed to read every subsidiary), so the party declaration is +how an organisation tree is declared. The depth bound is the declaration's `maxDepth`, +held under `HierarchyGrantExpander::DEPTH_CEILING`. + +## D-2: one query per level + +Parent and child are rows of the same magic table, so a level is +`HierarchyDescender::childrenOf()` (one chunked `IN` query). Names and requested fields +come from `MagicMapper::findObjectsByUuidsInRegisterSchema()`, one query per level. + +## D-3: RBAC per node, pruning the subtree + +Every level is filtered by `MagicMapper::filterReadableUuids()`, the same access filter +the single-object read applies. A node the caller may not read is left out and the walk +does not continue below it, so the answer never shows a level number that skips a +hidden organisation. Walking up stops at the first unreadable ancestor for the same +reason. In-process callers that scope their own reads (portaliq reads with +`_rbac: false` everywhere and applies the portal subject's scope itself) pass +`_rbac: false`. The API route always applies RBAC. + +## D-4: bounds and an answer that says it is partial + +Three bounds: the declared `maxDepth`, the requested `depth`, and the instance node cap +(`RelationGraphService::maximumNodes()`, setting `relationGraphMaxNodes`). The answer +carries `truncated` and `truncatedBy` (`depth`, `cap`, or `unreadable` when a level +could not be read). `depth` is only reported when there really is more below the last +level, so a consumer can refuse a partial tree, which is what portaliq does. + +## D-5: cycles + +A uuid already in the answer is never added again, in either direction, so a cycle in +the data ends the walk. Writes are already guarded by `PartyTreeGuardListener`; the read +does not trust that. + +## Answer shape + +``` +{ root, register, schema, parentProperty, direction, depth, requestedDepth, maxDepth, + nodes: [{ uuid, name, level, parent, properties: {: value} }], + truncated, truncatedBy } +``` + +Ancestors have negative levels (nearest is -1) and come first, the root is level 0, +descendants follow breadth first. diff --git a/openspec/changes/object-tree-read/proposal.md b/openspec/changes/object-tree-read/proposal.md new file mode 100644 index 0000000000..3e30b3a3b3 --- /dev/null +++ b/openspec/changes/object-tree-read/proposal.md @@ -0,0 +1,79 @@ +--- +kind: code +--- + +# Proposal: object-tree-read + +## Summary + +One generic read answers "which objects sit below (or above) this one" along the parent +property a schema already declares, with a level per object, a depth bound, cycle +protection, a node cap, and the caller's RBAC on every node. An organisation's group +(parent company and subsidiaries) is the first consumer: portaliq's "Reikt tot" on the +mandates screen and its case list under a mandate that reaches down (decision 250). + +## Why + +Decision 250 (Ruben, 15 Oct): an organisation's parent/subsidiary tree is a generic +OpenRegister relation between organisation objects, named by register, schema and field, +and every app reads it the same way. + +What exists, measured on development b9e5d8df4: + +- **Where organisations live as objects.** OpenRegister's own `Organisation` entity + is the tenancy concept, not a party. The party objects with a KvK identity are + dossiq's `kvkCompany` (register `dossiq`, `lib/Settings/register.d/25-brp-kvk.json`, + identity `kvkNumber`, name `tradeName`, kept current from KvK by integriq's + `KvkMutatieProvider`). pipelinq's `client` carries no KvK number; dossiq's + `partnerOrganization` is a chain partner keyed by OIN; OpenRegister's + `kvk_register.json` is a mock Handelsregister. portaliq has no organisation schema: + `PortalPartyTreeResolver` reads `portaliq/organisation.parent`, which does not exist. + No schema in the fleet records an organisation's parent today. +- **How a parent is declared.** Two declarations already exist and both name a + property that points at the same schema: + - `x-openregister-hierarchy.parent` (`HierarchyGrantExpander::declarationFor()`), + which also makes grants on a parent reach its children; + - `x-openregister-party.parentProperty` (`PartyDefinition::parentProperty()`), the + party model's organisation parent, already guarded against cycles and depth on + write by `PartyTreeGuardListener` / `PartyService::assertParentAllowed()`. +- **What reads them.** `HierarchyDescender::childrenOf()` reads one level of children + from the magic table; `ancestorsOf()` walks up, but only for the hierarchy + annotation. `RelationGraphService::graph()` walks every relation, typed, without + levels along one property. Nothing returns the descendants of an object with their + level, and nothing reads the party parent at all. + +## What changes + +- `ObjectTreeService::tree()` walks descendants, ancestors or both of one object along + the declared parent property, reusing `HierarchyDescender::childrenOf()` for each + level and `MagicMapper::filterReadableUuids()` for read access. +- `GET /api/objects/{register}/{schema}/{id}/tree?direction=&depth=&fields=` exposes it, + next to the existing `graph` route, resolving the root through `ObjectReadAccess`. +- No new annotation. An organisation tree is declared with + `x-openregister-party.parentProperty` (no access is inherited); a tree that should + also carry access uses `x-openregister-hierarchy` (which wins when both are present). + +## Consumers + +- portaliq: "Reikt tot" on the Machtigingen board, and mandated cases that reach down + (`PortalPartyTreeResolver`). +- dossiq: `kvkCompany` gains the parent property and the party declaration (its own + register change, named in tasks). +- Any app with a self-referencing schema (departments, product groups) gets the same + read; `records-tree-view` stays the one-level browse for lists. + +## Impact + +- Additive: one service, one route. A schema without a declared parent answers with the + root alone. +- Size: S. + +## Out of scope + +- Filling the relation from KvK. The KvK APIs integriq reads (Zoeken, Basisprofiel, + Vestigingsprofiel, Naamgeving, Mutatieservice) carry no group relations; + "vestigingen" are branches of one registration, not subsidiaries. KvK's + "Handelsregister Concernrelaties" (enig aandeelhouder, aansprakelijke, bestuurder) + is an ordered product, not an API. So the parent is recorded by hand (or by a later + source) and this change says so rather than faking a feed. +- Trees across schemas. diff --git a/openspec/changes/object-tree-read/specs/referential-integrity/spec.md b/openspec/changes/object-tree-read/specs/referential-integrity/spec.md new file mode 100644 index 0000000000..5f7988fbce --- /dev/null +++ b/openspec/changes/object-tree-read/specs/referential-integrity/spec.md @@ -0,0 +1,57 @@ +# referential-integrity + +## ADDED Requirements + +### Requirement: An object's tree along its declared parent property can be read (REQ-OTR-001) + +OpenRegister SHALL return the descendants, the ancestors, or both of one object along +the parent property its schema declares (`x-openregister-hierarchy.parent`, else +`x-openregister-party.parentProperty`), each with its level, its parent and its name. +The walk MUST stop at the declared `maxDepth`, at the requested depth and at the +instance node cap, MUST never visit an object twice, and MUST say in `truncatedBy` +which bound stopped it when more objects exist past it. A schema without a declared +parent SHALL answer with the root alone. + +#### Scenario: a holding's subsidiaries by level + +- **GIVEN** an organisation schema declaring `x-openregister-party` with `parentProperty` `parentOrganisation` +- **AND** a holding with two subsidiaries, one of which has a subsidiary of its own +- **WHEN** a client reads the tree of the holding +- **THEN** it gets four nodes with levels 0, 1, 1 and 2, each with its name and parent +- @e2e exclude {API read, covered by ObjectTreeServiceTest with a real schema fragment} + +#### Scenario: a cycle in the data ends the walk + +- **GIVEN** three organisations whose parents form a cycle +- **WHEN** a client reads the tree of one of them +- **THEN** each organisation appears once and the answer returns +- @e2e exclude {cycle guard, covered by ObjectTreeServiceTest} + +#### Scenario: a depth below the tree's height is reported + +- **GIVEN** the holding of the first scenario +- **WHEN** a client reads its tree with depth 1 +- **THEN** it gets the holding and its two subsidiaries +- **AND** `truncated` is true with `truncatedBy` `depth` +- @e2e exclude {bound logic, covered by ObjectTreeServiceTest} + +### Requirement: The tree read honours the caller's read access (REQ-OTR-002) + +Every node of the tree SHALL pass the caller's read access. A node the caller may not +read MUST be left out together with everything reached only through it, and the walk up +MUST stop at the first ancestor the caller may not read. A root the caller may not read +SHALL be answered with 404 before anything is walked. + +#### Scenario: a hidden subsidiary is not returned + +- **GIVEN** the holding of REQ-OTR-001 and a user who may read the holding, the second subsidiary and the grandchild, but not the first subsidiary +- **WHEN** that user reads the holding's tree +- **THEN** only the holding and the second subsidiary are returned +- @e2e exclude {RBAC pruning, covered by ObjectTreeServiceTest; a live check is task 3.1} + +#### Scenario: an unreadable root is not found + +- **GIVEN** an organisation the user may not read +- **WHEN** the user requests its tree +- **THEN** the answer is 404 and no level is read +- @e2e exclude {covered by ObjectRelationsControllerTreeTest} diff --git a/openspec/changes/object-tree-read/tasks.md b/openspec/changes/object-tree-read/tasks.md new file mode 100644 index 0000000000..8a45fd415f --- /dev/null +++ b/openspec/changes/object-tree-read/tasks.md @@ -0,0 +1,15 @@ +# Tasks: object-tree-read + +## 1. Backend + +- [x] 1.1 `ObjectTreeService::tree()` and `declarationFor()`. Verify: `tests/Unit/Service/Relation/ObjectTreeServiceTest.php` (real organisation fragment, cycle, depth bound, node cap, RBAC pruning down and up, hierarchy declaration). +- [x] 1.2 `GET /api/objects/{register}/{schema}/{id}/tree` on `ObjectRelationsController`. Verify: `tests/Unit/Controller/ObjectRelationsControllerTreeTest.php` (404 before any walk, 400 on an unknown direction, query reaches the walk). + +## 2. Consumers (other repositories) + +- [ ] 2.1 dossiq: `kvkCompany` gains `parentCompany` (uuid of a `kvkCompany`) and `x-openregister-party` `{kind: organisation, nameProperty: tradeName, parentProperty: parentCompany}`. +- [ ] 2.2 portaliq: `PortalPartyTreeResolver` reads this tree from the configured party schema; "Reikt tot" lists it. + +## 3. Live + +- [ ] 3.1 On a live instance: a holding with two subsidiaries and one below them; the tree route returns four nodes with levels 0, 1, 1, 2; a user without read on one subsidiary gets three. diff --git a/tests/Unit/Controller/ObjectRelationsControllerTreeTest.php b/tests/Unit/Controller/ObjectRelationsControllerTreeTest.php new file mode 100644 index 0000000000..11e2659e26 --- /dev/null +++ b/tests/Unit/Controller/ObjectRelationsControllerTreeTest.php @@ -0,0 +1,146 @@ + + * + * @category Test + * @package OCA\OpenRegister\Tests\Unit\Controller + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/object-tree-read/specs/referential-integrity/spec.md + */ + +namespace Unit\Controller; + +use OCA\OpenRegister\Controller\ObjectRelationsController; +use OCA\OpenRegister\Db\ObjectEntity; +use OCA\OpenRegister\Service\Export\ExportGate; +use OCA\OpenRegister\Service\Object\ObjectReadAccess; +use OCA\OpenRegister\Service\ObjectService; +use OCA\OpenRegister\Service\Relation\ObjectRelationService; +use OCA\OpenRegister\Service\Relation\ObjectTreeService; +use OCA\OpenRegister\Service\Relation\RelationGraphService; +use OCP\AppFramework\Http\JSONResponse; +use OCP\IRequest; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; + +/** + * @covers \OCA\OpenRegister\Controller\ObjectRelationsController + * @uses \OCA\OpenRegister\Db\ObjectEntity + */ +class ObjectRelationsControllerTreeTest extends TestCase { + + /** + * The tree walk double. + * + * @var ObjectTreeService&MockObject + */ + private ObjectTreeService&MockObject $trees; + + /** + * The access double. + * + * @var ObjectReadAccess&MockObject + */ + private ObjectReadAccess&MockObject $access; + + /** + * Build the controller over a set of query parameters. + * + * @param array $params The query. + * + * @return ObjectRelationsController The controller. + */ + private function controller(array $params): ObjectRelationsController { + $request = $this->createMock(IRequest::class); + $request->method('getParam')->willReturnCallback( + static fn (string $key, mixed $default = null): mixed => ($params[$key] ?? $default) + ); + + $this->trees = $this->createMock(ObjectTreeService::class); + $this->access = $this->createMock(ObjectReadAccess::class); + $this->access->method('notReadable')->willReturn(new JSONResponse(data: ['error' => 'Object not found'], statusCode: 404)); + + return new ObjectRelationsController( + appName: 'openregister', + request: $request, + relations: $this->createMock(ObjectRelationService::class), + graphs: $this->createMock(RelationGraphService::class), + objectService: $this->createMock(ObjectService::class), + exportGate: $this->createMock(ExportGate::class), + access: $this->access, + trees: $this->trees + ); + }//end controller() + + /** + * A root the caller may not read is a 404 and nothing is walked. + * + * @return void + */ + public function testAnUnreadableRootIsNotFoundAndNotWalked(): void { + $controller = $this->controller(params: []); + $this->access->method('readable')->willReturn(null); + $this->trees->expects($this->never())->method('tree'); + + $this->assertSame(404, $controller->tree(register: 'r', schema: 's', id: 'x')->getStatus()); + }//end testAnUnreadableRootIsNotFoundAndNotWalked() + + /** + * An unknown direction is refused. + * + * @return void + */ + public function testAnUnknownDirectionIsRefused(): void { + $controller = $this->controller(params: ['direction' => 'sideways']); + $this->access->method('readable')->willReturn(new ObjectEntity()); + $this->trees->expects($this->never())->method('tree'); + + $this->assertSame(400, $controller->tree(register: 'r', schema: 's', id: 'x')->getStatus()); + }//end testAnUnknownDirectionIsRefused() + + /** + * The query reaches the walk, with RBAC left on. + * + * @return void + */ + public function testTheQueryReachesTheWalk(): void { + $controller = $this->controller(params: ['direction' => 'both', 'depth' => '2', 'fields' => 'kvkNumber, tradeName']); + $root = new ObjectEntity(); + $this->access->method('readable')->willReturn($root); + $this->trees->expects($this->once())->method('tree') + ->with($root, 'both', 2, ['kvkNumber', 'tradeName']) + ->willReturn(['nodes' => []]); + + $response = $controller->tree(register: 'r', schema: 's', id: 'x'); + + $this->assertSame(200, $response->getStatus()); + $this->assertSame(['nodes' => []], $response->getData()); + }//end testTheQueryReachesTheWalk() + + /** + * Without a depth the walk gets null, which means the schema's own bound. + * + * @return void + */ + public function testNoDepthMeansTheDeclaredBound(): void { + $controller = $this->controller(params: []); + $this->access->method('readable')->willReturn(new ObjectEntity()); + $this->trees->expects($this->once())->method('tree') + ->with($this->anything(), 'descendants', null, []) + ->willReturn([]); + + $controller->tree(register: 'r', schema: 's', id: 'x'); + }//end testNoDepthMeansTheDeclaredBound() +}//end class diff --git a/tests/Unit/Service/Relation/ObjectTreeServiceTest.php b/tests/Unit/Service/Relation/ObjectTreeServiceTest.php new file mode 100644 index 0000000000..9f20cc8830 --- /dev/null +++ b/tests/Unit/Service/Relation/ObjectTreeServiceTest.php @@ -0,0 +1,405 @@ + + * + * @category Test + * @package OCA\OpenRegister\Tests\Unit\Service\Relation + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/object-tree-read/specs/referential-integrity/spec.md + */ + +namespace Unit\Service\Relation; + +use OCA\OpenRegister\Db\MagicMapper; +use OCA\OpenRegister\Db\ObjectEntity; +use OCA\OpenRegister\Db\ObjectRelationMapper; +use OCA\OpenRegister\Db\Register; +use OCA\OpenRegister\Db\RegisterMapper; +use OCA\OpenRegister\Db\Schema; +use OCA\OpenRegister\Db\SchemaMapper; +use OCA\OpenRegister\Service\Party\PartyAnnotationValidator; +use OCA\OpenRegister\Service\Rbac\HierarchyAnnotationValidator; +use OCA\OpenRegister\Service\Rbac\HierarchyDescender; +use OCA\OpenRegister\Service\Rbac\HierarchyGrantExpander; +use OCA\OpenRegister\Service\Relation\ObjectTreeService; +use OCA\OpenRegister\Service\Relation\RelationGraphService; +use OCA\OpenRegister\Service\Relation\RelationTypeResolver; +use OCP\IAppConfig; +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; + +/** + * @covers \OCA\OpenRegister\Service\Relation\ObjectTreeService + * @uses \OCA\OpenRegister\Service\Party\PartyDefinition + * @uses \OCA\OpenRegister\Service\Party\PartyAnnotationValidator + * @uses \OCA\OpenRegister\Service\Rbac\HierarchyAnnotationValidator + * @uses \OCA\OpenRegister\Service\Rbac\HierarchyGrantExpander + * @uses \OCA\OpenRegister\Service\Rbac\HierarchyDescender + * @uses \OCA\OpenRegister\Service\Relation\RelationGraphService + * @uses \OCA\OpenRegister\Db\ObjectEntity + * @uses \OCA\OpenRegister\Db\Schema + * @uses \OCA\OpenRegister\Db\Register + */ +class ObjectTreeServiceTest extends TestCase { + + /** + * An organisation schema fragment, as a register file declares it. + * + * @param array $extra Extra schema-level keys. + * + * @return array The fragment. + */ + private static function fragment(array $extra = []): array { + return array_merge( + [ + 'slug' => 'organisation', + 'properties' => [ + 'kvkNumber' => ['type' => 'string', 'pattern' => '^[0-9]{8}$'], + 'tradeName' => ['type' => 'string'], + 'parentOrganisation' => [ + 'type' => 'string', + 'format' => 'uuid', + '$ref' => '#/components/schemas/organisation', + ], + ], + 'x-openregister-party' => [ + 'kind' => 'organisation', + 'nameProperty' => 'tradeName', + 'parentProperty' => 'parentOrganisation', + 'maxDepth' => 4, + ], + ], + $extra + ); + }//end fragment() + + /** + * The schema entity for a fragment, after the save-time validators passed it. + * + * @param array $fragment The fragment. + * + * @return Schema The schema. + */ + private function schema(array $fragment): Schema { + $this->assertSame([], (new PartyAnnotationValidator())->validate(schema: $fragment)); + $this->assertSame([], (new HierarchyAnnotationValidator())->validate(schema: $fragment)); + + $schema = new Schema(); + $schema->setId(2); + $schema->setSlug((string)$fragment['slug']); + $schema->setProperties($fragment['properties']); + $configuration = []; + foreach ($fragment as $key => $value) { + if (str_starts_with((string)$key, 'x-openregister-') === true) { + $configuration[$key] = $value; + } + } + + $schema->setConfiguration($configuration); + + return $schema; + }//end schema() + + /** + * Build the service over a table of organisations. + * + * @param array $parentOf Uuid => parent uuid. + * @param array|null $readable The uuids the caller may read; null = all. + * @param array|null $fragment The schema fragment. + * @param int $cap The instance node cap. + * + * @return ObjectTreeService The service. + */ + private function service(array $parentOf, ?array $readable = null, ?array $fragment = null, int $cap = 250): ObjectTreeService { + $logger = $this->createMock(LoggerInterface::class); + + $descender = $this->getMockBuilder(HierarchyDescender::class) + ->disableOriginalConstructor() + ->onlyMethods(['childrenOf']) + ->getMock(); + $descender->method('childrenOf')->willReturnCallback( + function (string $table, string $parentColumn, array $parentUuids) use ($parentOf): array { + $this->assertSame('openregister_table_1_2', $table); + $this->assertSame('parent_organisation', $parentColumn); + $children = []; + foreach ($parentOf as $child => $parent) { + if ($parent !== null && $child !== $parent && in_array($parent, $parentUuids, true) === true) { + $children[$child] = $parent; + } + } + + return $children; + } + ); + + $objectMapper = $this->createMock(MagicMapper::class); + $objectMapper->method('filterReadableUuids')->willReturnCallback( + static fn (int $registerId, int $schemaId, array $uuids): array => array_values( + array_filter($uuids, static fn (string $uuid): bool => $readable === null || in_array($uuid, $readable, true)) + ) + ); + $objectMapper->method('findObjectsByUuidsInRegisterSchema')->willReturnCallback( + function (Register $register, Schema $schema, array $uuids) use ($parentOf): array { + $found = []; + foreach ($uuids as $uuid) { + if (array_key_exists($uuid, $parentOf) === true) { + $found[$uuid] = $this->organisation(uuid: $uuid, parent: $parentOf[$uuid]); + } + } + + return $found; + } + ); + + $register = new Register(); + $register->setId(1); + $registerMapper = $this->createMock(RegisterMapper::class); + $registerMapper->method('find')->willReturn($register); + + $schemaMapper = $this->createMock(SchemaMapper::class); + $schemaMapper->method('find')->willReturn($this->schema(fragment: ($fragment ?? self::fragment()))); + + $appConfig = $this->createMock(IAppConfig::class); + $appConfig->method('getValueInt')->willReturnCallback( + static fn (string $app, string $key, int $default): int => ($key === RelationGraphService::MAX_NODES_KEY ? $cap : $default) + ); + $graphs = new RelationGraphService( + objectEntityMapper: $objectMapper, + relationMapper: $this->createMock(ObjectRelationMapper::class), + schemaMapper: $schemaMapper, + relationTypes: $this->createMock(RelationTypeResolver::class), + appConfig: $appConfig, + logger: $logger + ); + + return new ObjectTreeService( + descender: $descender, + hierarchies: new HierarchyGrantExpander(descender: $descender, logger: $logger), + objectMapper: $objectMapper, + registerMapper: $registerMapper, + schemaMapper: $schemaMapper, + graphs: $graphs, + logger: $logger + ); + }//end service() + + /** + * One organisation object. + * + * @param string $uuid The uuid. + * @param string|null $parent The parent uuid. + * + * @return ObjectEntity The object. + */ + private function organisation(string $uuid, ?string $parent): ObjectEntity { + $entity = new ObjectEntity(); + $entity->setUuid($uuid); + $entity->setName('Org '.$uuid); + $entity->setRegister('1'); + $entity->setSchema('2'); + $entity->setObject(['tradeName' => 'Org '.$uuid, 'kvkNumber' => '1000000'.strlen($uuid), 'parentOrganisation' => $parent]); + + return $entity; + }//end organisation() + + /** + * Levels per uuid in an answer. + * + * @param array $tree The answer. + * + * @return array Uuid => level. + */ + private static function levels(array $tree): array { + $levels = []; + foreach ($tree['nodes'] as $node) { + $levels[$node['uuid']] = $node['level']; + } + + return $levels; + }//end levels() + + /** + * A holding with two subsidiaries and one below one of them. + * + * @return array Uuid => parent. + */ + private static function group(): array { + return ['holding' => null, 'sub-a' => 'holding', 'sub-b' => 'holding', 'sub-a1' => 'sub-a']; + }//end group() + + /** + * The descendants come back by level with their names and chosen fields. + * + * @return void + */ + public function testDescendantsCarryLevelNameAndFields(): void { + $tree = $this->service(parentOf: self::group())->tree( + root: $this->organisation(uuid: 'holding', parent: null), + fields: ['kvkNumber'] + ); + + $this->assertSame(['holding' => 0, 'sub-a' => 1, 'sub-b' => 1, 'sub-a1' => 2], self::levels(tree: $tree)); + $this->assertSame('parentOrganisation', $tree['parentProperty']); + $this->assertSame(4, $tree['maxDepth']); + $this->assertFalse($tree['truncated']); + $this->assertSame('Org sub-a1', $tree['nodes'][3]['name']); + $this->assertSame('sub-a', $tree['nodes'][3]['parent']); + $this->assertSame(['kvkNumber' => '10000006'], $tree['nodes'][3]['properties']); + }//end testDescendantsCarryLevelNameAndFields() + + /** + * A cycle in the data ends the walk instead of repeating it. + * + * @return void + */ + public function testACycleIsWalkedOnce(): void { + $cycle = ['a' => 'c', 'b' => 'a', 'c' => 'b']; + $service = $this->service(parentOf: $cycle); + + $down = $service->tree(root: $this->organisation(uuid: 'a', parent: 'c')); + $this->assertSame(['a' => 0, 'b' => 1, 'c' => 2], self::levels(tree: $down)); + $this->assertFalse($down['truncated']); + + $up = $service->tree(root: $this->organisation(uuid: 'a', parent: 'c'), direction: ObjectTreeService::DIRECTION_ANCESTORS); + $this->assertSame(['b' => -2, 'c' => -1, 'a' => 0], self::levels(tree: $up)); + }//end testACycleIsWalkedOnce() + + /** + * A depth below the tree's height stops there and says so. + * + * @return void + */ + public function testTheDepthBoundIsReportedAsTruncation(): void { + $tree = $this->service(parentOf: self::group())->tree( + root: $this->organisation(uuid: 'holding', parent: null), + depth: 1 + ); + + $this->assertSame(['holding' => 0, 'sub-a' => 1, 'sub-b' => 1], self::levels(tree: $tree)); + $this->assertTrue($tree['truncated']); + $this->assertSame(ObjectTreeService::TRUNCATED_DEPTH, $tree['truncatedBy']); + }//end testTheDepthBoundIsReportedAsTruncation() + + /** + * A depth above the schema's bound is held to the bound. + * + * @return void + */ + public function testTheDeclaredDepthCapsTheRequest(): void { + $chain = ['l0' => null, 'l1' => 'l0', 'l2' => 'l1', 'l3' => 'l2', 'l4' => 'l3', 'l5' => 'l4']; + $tree = $this->service(parentOf: $chain)->tree(root: $this->organisation(uuid: 'l0', parent: null), depth: 50); + + $this->assertSame(4, $tree['depth']); + $this->assertArrayNotHasKey('l5', self::levels(tree: $tree)); + $this->assertSame(ObjectTreeService::TRUNCATED_DEPTH, $tree['truncatedBy']); + }//end testTheDeclaredDepthCapsTheRequest() + + /** + * The node cap stops the walk and says so. + * + * @return void + */ + public function testTheNodeCapIsReportedAsTruncation(): void { + $tree = $this->service(parentOf: self::group(), cap: 2)->tree(root: $this->organisation(uuid: 'holding', parent: null)); + + $this->assertSame(['holding' => 0], self::levels(tree: $tree)); + $this->assertSame(ObjectTreeService::TRUNCATED_CAP, $tree['truncatedBy']); + }//end testTheNodeCapIsReportedAsTruncation() + + /** + * A subsidiary the caller may not read is not returned, nor anything below it. + * + * @return void + */ + public function testAnUnreadableDescendantIsLeftOutWithItsSubtree(): void { + $tree = $this->service(parentOf: self::group(), readable: ['holding', 'sub-b', 'sub-a1'])->tree( + root: $this->organisation(uuid: 'holding', parent: null) + ); + + $this->assertSame(['holding' => 0, 'sub-b' => 1], self::levels(tree: $tree)); + $this->assertFalse($tree['truncated']); + }//end testAnUnreadableDescendantIsLeftOutWithItsSubtree() + + /** + * Without RBAC, for an in-process caller that scopes its own reads, every node is returned. + * + * @return void + */ + public function testWithoutRbacEveryNodeIsReturned(): void { + $tree = $this->service(parentOf: self::group(), readable: ['holding'])->tree( + root: $this->organisation(uuid: 'holding', parent: null), + _rbac: false + ); + + $this->assertCount(4, $tree['nodes']); + }//end testWithoutRbacEveryNodeIsReturned() + + /** + * The ancestors come back above the root, and an unreadable one ends the walk up. + * + * @return void + */ + public function testAncestorsStopAtAnUnreadableOne(): void { + $root = $this->organisation(uuid: 'sub-a1', parent: 'sub-a'); + + $all = $this->service(parentOf: self::group())->tree(root: $root, direction: ObjectTreeService::DIRECTION_BOTH); + $this->assertSame(['holding' => -2, 'sub-a' => -1, 'sub-a1' => 0], self::levels(tree: $all)); + + $hidden = $this->service(parentOf: self::group(), readable: ['sub-a1', 'holding'])->tree( + root: $root, + direction: ObjectTreeService::DIRECTION_ANCESTORS + ); + $this->assertSame(['sub-a1' => 0], self::levels(tree: $hidden)); + }//end testAncestorsStopAtAnUnreadableOne() + + /** + * A schema declaring no parent answers with the root alone. + * + * @return void + */ + public function testASchemaWithoutAParentAnswersWithTheRootAlone(): void { + $fragment = self::fragment(); + unset($fragment['x-openregister-party']); + + $tree = $this->service(parentOf: self::group(), fragment: $fragment)->tree(root: $this->organisation(uuid: 'holding', parent: null)); + + $this->assertSame(['holding' => 0], self::levels(tree: $tree)); + $this->assertNull($tree['parentProperty']); + $this->assertFalse($tree['truncated']); + }//end testASchemaWithoutAParentAnswersWithTheRootAlone() + + /** + * The hierarchy declaration is read the same way and wins over the party one. + * + * @return void + */ + public function testTheHierarchyDeclarationIsReadToo(): void { + $fragment = self::fragment(['x-openregister-hierarchy' => ['parent' => 'parentOrganisation', 'maxDepth' => 1]]); + $service = $this->service(parentOf: self::group(), fragment: $fragment); + + $declaration = $service->declarationFor(schema: $this->schema(fragment: $fragment)); + $this->assertSame(HierarchyGrantExpander::ANNOTATION, $declaration['source']); + + $tree = $service->tree(root: $this->organisation(uuid: 'holding', parent: null)); + $this->assertSame(1, $tree['maxDepth']); + $this->assertSame(['holding' => 0, 'sub-a' => 1, 'sub-b' => 1], self::levels(tree: $tree)); + }//end testTheHierarchyDeclarationIsReadToo() +}//end class