diff --git a/config/services.yaml b/config/services.yaml index e5e69a1..4ac8db7 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -25,6 +25,9 @@ services: Bareapi\Repository\MetaObjectRepositoryInterface: class: Bareapi\Repository\MetaObjectRepository + Bareapi\Repository\MetaRefRepositoryInterface: + class: Bareapi\Repository\MetaRefRepository + # Service interfaces Bareapi\Service\SchemaServiceInterface: class: Bareapi\Service\SchemaService diff --git a/migrations/Version20250115120000.php b/migrations/Version20250115120000.php new file mode 100644 index 0000000..bda0aa9 --- /dev/null +++ b/migrations/Version20250115120000.php @@ -0,0 +1,74 @@ +addSql(<<<'SQL' + CREATE TABLE meta_refs ( + project_id INTEGER, + from_type VARCHAR(100) NOT NULL, + from_uuid UUID NOT NULL, + path VARCHAR(255) NOT NULL, + to_type VARCHAR(100) NOT NULL, + to_uuid UUID NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (from_type, from_uuid, path, to_type, to_uuid) + ) + SQL); + + // Partial unique index for NULL project_id rows + $this->addSql(<<<'SQL' + CREATE UNIQUE INDEX uniq_meta_refs_null_project + ON meta_refs (from_type, from_uuid, path, to_type, to_uuid) + WHERE project_id IS NULL + SQL); + + // Index for inbound lookups (who references target object?) + $this->addSql(<<<'SQL' + CREATE INDEX idx_meta_refs_inbound + ON meta_refs (project_id, to_type, to_uuid) + SQL); + + // Index for outbound lookups (what does source object reference?) + $this->addSql(<<<'SQL' + CREATE INDEX idx_meta_refs_outbound + ON meta_refs (project_id, from_type, from_uuid) + SQL); + + $this->addSql(<<<'SQL' + COMMENT ON TABLE meta_refs IS 'Reverse index for x-metastore.refersTo relationships' + SQL); + + $this->addSql(<<<'SQL' + COMMENT ON COLUMN meta_refs.from_uuid IS '(DC2Type:uuid)' + SQL); + + $this->addSql(<<<'SQL' + COMMENT ON COLUMN meta_refs.to_uuid IS '(DC2Type:uuid)' + SQL); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP TABLE IF EXISTS meta_refs'); + } +} diff --git a/phpstan.neon b/phpstan.neon index 6463498..0c1e2e9 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -81,5 +81,10 @@ parameters: message: '#Method class@anonymous.+toArray\(\) return type has no value type specified in iterable type array#' path: tests/* + # Allow assertTrue(true) pattern for "no exception thrown" tests + - + message: '#Call to method PHPUnit\\Framework\\Assert::assertTrue\(\) with true will always evaluate to true#' + path: tests/* + # Treat warnings as errors treatPhpDocTypesAsCertain: false diff --git a/rfc/x-metastore-refersTo-documentation.md b/rfc/x-metastore-refersTo-documentation.md new file mode 100644 index 0000000..a96b4cb --- /dev/null +++ b/rfc/x-metastore-refersTo-documentation.md @@ -0,0 +1,283 @@ +# RFC: Introduce `x-metastore.refersTo` (uuid-only) + Reference Integrity & Relationship Enrichment + +## Why + +Today, Metastore has no first-class way to declare and enforce relationships between objects. This leads to: + +* Possible **orphaned references** and inconsistent data. +* Clients doing repetitive lookups (N+1) to render related info. +* No systematic way to **block deletes** that would break other objects or to **cascade** cleanly. +* No fast method to answer **“who references this object?”** + +We are introducing a new schema keyword — `x-metastore.refersTo` — to declare references, validate them at write time, define deletion behavior, and (optionally) enrich reads with related objects. Design decisions intentionally keep v1 small and robust: **uuid-only**, **depth=1**, **project-scoped**, **model-level ACL respected**. + +## What we decided (drivers) + +* **Reference key = `uuid` only** (avoid uniqueness/indexing complexity of arbitrary keys). +* **Depth=1** enrichment only (avoid recursion, payload blowups). Delete cascades can follow deeper chains; enrichment stays shallow. +* **Deletion semantics** declared per reference via `x-metastore.onDelete`: + + * `restrict` (default) | `cascade`. +* **Optional enrichment** via `?include=relationships` (+ selective `?relationships=...`). +* **Reverse index table** to power fast inbound lookups, delete plans, and counts. +* **Project/org isolation** enforced in all lookups (align with current ACL). + +--- + +## New Schema Keyword: `x-metastore.refersTo` + +### Syntax (field-level) + +```json +{ + "type": "string", + "x-metastore": { + "refersTo": { "type": "tag", "field": "uuid" }, + "onDelete": "restrict" // or "cascade" + } +} +``` + +### Rules + +* `refersTo.type` = target object type (e.g., `"tag"`). +* `refersTo.field` must be `"uuid"` (only supported key in v1). +* `onDelete`: + + * Default: `"restrict"`. + * `"cascade"`: delete the referrer when target is deleted (soft-delete, consistent with model, and repeats along any downstream cascade chains). +* Empty value (`""` or `null`) is treated as “no reference”. + +### Scope + +* **Project-scoped only**: target must be in the same project (and organization where applicable). +* Only **non-deleted** targets can be referenced. + +--- + +## Behavior + +### 1) Write-time validation (create/update) + +* Run standard JSON Schema validation. +* Walk schema for `x-metastore.refersTo`. +* Collect all **uuid** values (depth=1 only), batch-validate they exist & are live in the same project/org. +* On failure: 422 with your standard error format, plus: + + ```json + "meta": { + "ref": { "path": "data.tagId", "type": "tag", "uuid": "…" } + } + ``` + +### 2) Reverse index (`meta_refs`) maintenance + +* On every successful write, compute current set of refs `{path, to_type, to_uuid}` and **sync** `meta_refs` for that `(project_id, from_type, from_uuid)`. +* Use it for fast: inbound lookups, pre-delete checks, cascade plans, simple analytics (“how many tag-bindings reference tag X?”). +* Add a lightweight **ref integrity job** that can rebuild `meta_refs` for a project/type on demand. It walks stored objects, replays schema evaluation, and repairs rows. We do not need it on day one, but documenting it now keeps future maintenance straightforward once production data exists. + +### 3) Delete semantics (`onDelete`) + +When deleting a **target** object: + +* Fetch inbound referrers via `meta_refs`. +* Build a delete **plan** that lists actions per `(from_type, path)` and stage it in a work queue (e.g., in-memory coroutine or durable job table). The planner transaction only persists the plan and marks the target for deletion. +* Execute the plan atomically within the worker: for each reference path apply `onDelete`: + + * `restrict`: abort the plan with an error if any refs exist (include counts and sample). + * `cascade`: soft-delete the referrers, enqueue their own delete plans if their schema also declares `cascade`, and only delete the original target once descendant cascades succeed. +* Because cascades can chain arbitrarily, the worker keeps track of visited `(type, uuid)` pairs to avoid loops, and every hop reuses the same logic (depth=∞ for deletes, depth=1 for enrichment). +* The worker refreshes `meta_refs` for every object it touches so the reverse index remains consistent. + +### 4) Read enrichment (optional) + +* Off by default. +* `?include=relationships=true` turns it on. +* `?relationships=field1,field2` limits to chosen paths; if omitted, **all declared** refs are enriched (depth=1). +* Response adds a `relationships` object keyed by **data path** (normalized): + + * `"data.tagId"` → single object. + * `"data.items.tagId"` → array of objects (when the source path is array-backed). +* Each relationship entry includes: + + * `data` (the full referenced object, latest revision) + * `url` (canonical API URL) + * `meta.sourcePath` (the data path) +* ACL or lifecycle filters may hide a referenced object. In that case the entry is omitted; when the source expects an array, omit each filtered item and return an empty array if no related entities survive. +* Reference payloads never contain duplicate UUIDs (enforced by schema/business logic), so enrichment never emits duplicates. + +--- + +## `meta_refs` Repair / Consistency Checker + +Even though the service is not yet in production, we want a future-proof story for rebuilding the reverse index. A lightweight maintenance tool will: + +1. Take `(project_id, object_type[, object_uuid])` as input. +2. Load objects in batches, re-run `x-metastore.refersTo` evaluation, and compute `{path, to_type, to_uuid}`. +3. Diff the results against `meta_refs` and upsert/delete rows accordingly. +4. Emit metrics/logs on drift so operators can alert. + +We can implement this as both: + +* a CLI (`task metastore:verify-meta-refs`) for manual/CI runs, and +* an automated job using **pg_cron**, mirroring the Query service pattern. A Goose migration will create a helper SQL function (calling into our Go repair logic via `SELECT` or direct SQL), guard `CREATE EXTENSION IF NOT EXISTS pg_cron`, and register schedules with `cron.schedule`. Down migrations unschedule. This lets Postgres trigger nightly/weekly repairs without another control plane, while remaining optional when pg_cron isn’t installed. + +--- + +## Error & Logging (for debugging) + +* Keep existing error contract; add `meta.ref { path, type, uuid }` on ref failures. +* Log warnings with the same fields; include index positions for arrays in logs (not required in API error). + +--- + +## Observability (minimal v1) + +* Counter: how many `tag-binding` reference `tag` (derive from `meta_refs`). +* Optional: counts of validation failures and enriched items per request (later). + +--- + +## Rollout / Compatibility + +* This is **new**: no migration needed. +* Feature guarded by schema presence; legacy schemas without `x-metastore.refersTo` are unaffected. +* Documentation & examples added; lint rule optionally warns if deprecated keywords (like `setNull`) show up in schemas. + +--- + +# Actionable PR Tasks + +> Keep PRs small and linear; each PR should include tests and docs updates relevant to the change. + +### PR-1: Schema Keyword Introduction + +**Goal:** Teach the schema layer the new keyword. + +* Add support for `x-metastore.refersTo` and `x-metastore.onDelete` at **field level**. +* Validation rules: + + * `refersTo.field` must equal `"uuid"`. + * `onDelete` ∈ {`restrict`, `cascade`} (default `restrict`). +* Docs: add keyword spec, examples, and call out that `setNull` is intentionally omitted until we have a concrete use case. + +### PR-2: DB Migration — Reverse Index + +**Goal:** Create `meta_refs` table and basic indices. + +* Table with `(project_id, from_type, from_uuid, path, to_type, to_uuid, created_at)`. +* Primary key across those columns; secondary indices for inbound lookups and counts. +* Docs: purpose, example queries (counts, inbound). + +### PR-3: Write Path — Reference Validation (uuid-only) + +**Goal:** Enforce referential integrity on create/update. + +* Traverse validated payload (depth=1) per schema; collect candidate UUIDs by `(to_type, path)`. +* Batch-validate existence (same project/org, not deleted). +* On failure, return 422 with `meta.ref`. +* Respect current ACL filters in SQL. +* Tests: simple, array, nested object at depth=1, empty/null treated as absent. + +### PR-4: Write Path — Reverse Index Sync + +**Goal:** Keep `meta_refs` exact for latest revision. + +* After a successful write, diff `{path, to_type, to_uuid}` vs existing rows for `(project_id, from_type, from_uuid)`; delete missing, insert new (idempotent). +* Tests: insert, update change of target, removal to empty/null. + +### PR-5: Delete Planner — `onDelete` Enforcement + +**Goal:** Apply `restrict | cascade` on target deletion using the staged plan/worker model. + +* Lookup inbound references via `meta_refs`. +* For each `(from_type, path)`: + + * `restrict`: reject with counts & sample. + * `cascade`: soft-delete referrers (then clean their `meta_refs` and trigger any downstream cascades). +* Planner transaction stores the work items; worker/coroutine executes them atomically. +* Tests: restrict-only errors, cascade chains, mixed modes per path, large counts (batching behavior if applicable), and the staged execution path. + +### PR-6: Read API — Relationship Enrichment (depth=1) + +**Goal:** Optional embedding of referenced objects. + +* Query params: + + * `include=relationships` (bool, default false). + * `relationships=field1,field2` (optional filter). +* Build `relationships` keyed by normalized data path: + + * Single vs array shape matches source field shape. +* Include `data`, `url`, `meta.sourcePath`. +* Tests: full enrichment, selective enrichment, array paths. + +### PR-7: Documentation & Examples + +**Goal:** Developer-facing docs. + +* Keyword spec with examples for: + + * `restrict` (default) and `cascade` (e.g., tag-binding → tag). +* Example GET with `?include=relationships` and `?relationships=…`. +* Delete behavior table and example responses. + +### PR-8: Minimal Analytics (optional now, easy later) + +**Goal:** Count references and validation outcomes. + +* One “how many tag-bindings → tag” example query using `meta_refs`. +* Optional: increment counters for validation success/fail and enrichment items total. + +--- + +## Example Snippets (non-code, conceptual) + +**Schema (tag-binding referencing tag with cascade):** + +```json +{ + "type": "object", + "properties": { + "tagId": { + "type": "string", + "x-metastore": { + "refersTo": { "type": "tag", "field": "uuid" }, + "onDelete": "cascade" + } + } + }, + "required": ["tagId"] +} +``` + +**GET Enrichment (requested):** + +* Request: `GET /repository/tag-binding/?include=relationships` +* Response excerpt: + +```json +"relationships": { + "data.tagId": { + "data": { "uuid": "…", "objectType": "tag", "name": "Production", "revision": 2, "data": { "color": "red" } }, + "url": "/api/v1/repository/tag/…", + "meta": { "sourcePath": "data.tagId" } + } +} +``` + +**Delete Outcome Summary (restrict/cascade):** + +* `restrict`: planner surfaces `{ count: , examples: [] }` and the job aborts. +* `cascade`: worker soft-deletes referrers (chaining through their schemas if needed) and finally soft-deletes the original target. + +--- + +## Risk Review + +* **Payload growth**: mitigated by opt-in `?include=relationships` and depth=1. +* **Query load**: batch validations; enrichment reuses single fetch per target uuid. +* **Complex deletes**: staged work-queue keeps transactions small; cascade chains honor schemas but guard against loops. +* **Index drift**: repair tool plus write-path sync protects `meta_refs` correctness even after failures. +* **ACL**: unchanged; lookups reuse existing project/org filters. + diff --git a/src/Controller/Api/RepositoryController.php b/src/Controller/Api/RepositoryController.php index cfcc28b..a3ecaa8 100644 --- a/src/Controller/Api/RepositoryController.php +++ b/src/Controller/Api/RepositoryController.php @@ -11,17 +11,23 @@ use Bareapi\DTO\UpdatePutRequest; use Bareapi\Entity\MetaObject; use Bareapi\Entity\MetaObjectRevision; +use Bareapi\Exception\DeleteRestrictedException; use Bareapi\Exception\ForbiddenException; use Bareapi\Exception\MetaObjectNotFoundException; +use Bareapi\Exception\ReferenceValidationException; use Bareapi\Exception\SchemaNotFoundException; use Bareapi\Exception\ValidationException; use Bareapi\Repository\MetaObjectRepositoryInterface; use Bareapi\Response\ErrorResponse; use Bareapi\Response\JsonApiSerializer; use Bareapi\Security\ApiKeyUser; +use Bareapi\Service\DeletePlannerService; +use Bareapi\Service\ReferenceIndexService; +use Bareapi\Service\RelationshipEnrichmentService; use Bareapi\Service\SchemaServiceInterface; use Bareapi\Service\TransactionManager; use Bareapi\Validation\JsonSchemaValidator; +use Bareapi\Validation\ReferenceValidator; use DateTimeImmutable; use Symfony\Bundle\SecurityBundle\Security; use Symfony\Component\HttpFoundation\JsonResponse; @@ -35,6 +41,10 @@ public function __construct( private MetaObjectRepositoryInterface $repository, private SchemaServiceInterface $schemaService, private JsonSchemaValidator $validator, + private ReferenceValidator $referenceValidator, + private ReferenceIndexService $referenceIndexService, + private DeletePlannerService $deletePlannerService, + private RelationshipEnrichmentService $relationshipEnrichmentService, private TransactionManager $transactionManager, private AuthorizationService $authService, private JsonApiSerializer $serializer, @@ -109,6 +119,14 @@ public function create(string $objectType, Request $request): JsonResponse // Check for existing object with same name in scope $projectId = $this->getProjectIdFromScope($request, $createRequest->scope); + + // Validate references + $this->referenceValidator->validate( + $createRequest->data, + $objectType, + $projectId, + $this->getOrganizationId($request) + ); $branch = $createRequest->branch ?? 'main'; $existing = $this->repository->findByNameAndScope( @@ -153,6 +171,9 @@ public function create(string $objectType, Request $request): JsonResponse return $metaObject; }); + // Sync reference index + $this->referenceIndexService->syncRefsForObject($metaObject, $createRequest->data); + $response = MetaObjectResponse::fromEntity($metaObject); return $this->serializer->created($response); @@ -160,13 +181,15 @@ public function create(string $objectType, Request $request): JsonResponse return ErrorResponse::notFound($e->getMessage()); } catch (ValidationException $e) { return ErrorResponse::validationErrorFromRaw($e->getErrors()); + } catch (ReferenceValidationException $e) { + return ErrorResponse::referenceError($e->getRef()); } catch (ForbiddenException $e) { return ErrorResponse::forbidden($e->getMessage()); } } #[Route('/{objectType}/{uuid}', name: 'repository_get', methods: ['GET'])] - public function get(string $objectType, string $uuid): JsonResponse + public function get(string $objectType, string $uuid, Request $request): JsonResponse { try { $metaObject = $this->findOrFail($uuid, $objectType); @@ -178,6 +201,26 @@ public function get(string $objectType, string $uuid): JsonResponse $response = MetaObjectResponse::fromEntity($metaObject, $latestRevision); + // Check for relationship enrichment + $includeRelationships = $request->query->getBoolean('include') + || $request->query->has('relationships'); + + if ($includeRelationships) { + $requestedPaths = $this->parseRelationshipsParam($request); + $baseUrl = $this->getBaseApiUrl($request); + + $relationships = $this->relationshipEnrichmentService->enrichRelationships( + $latestRevision->getData(), + $objectType, + $requestedPaths, + $metaObject->getProjectId(), + $metaObject->getOrganizationId(), + $baseUrl + ); + + return $this->serializer->successWithRelationships($response, $relationships); + } + return $this->serializer->success($response); } catch (MetaObjectNotFoundException $e) { return ErrorResponse::notFound($e->getMessage()); @@ -217,6 +260,14 @@ public function patch(string $objectType, string $uuid, Request $request): JsonR // Validate merged data against schema $this->validator->validate($mergedData, $schema->getSchema()); + // Validate references + $this->referenceValidator->validate( + $mergedData, + $objectType, + $metaObject->getProjectId(), + $metaObject->getOrganizationId() + ); + $metaObject = $this->transactionManager->transactional(function () use ($metaObject, $mergedData) { $newRevision = new MetaObjectRevision( $metaObject, @@ -231,6 +282,9 @@ public function patch(string $objectType, string $uuid, Request $request): JsonR return $metaObject; }); + // Sync reference index + $this->referenceIndexService->syncRefsForObject($metaObject, $mergedData); + $response = MetaObjectResponse::fromEntity($metaObject); return $this->serializer->success($response); @@ -240,6 +294,8 @@ public function patch(string $objectType, string $uuid, Request $request): JsonR return ErrorResponse::notFound($e->getMessage()); } catch (ValidationException $e) { return ErrorResponse::validationErrorFromRaw($e->getErrors()); + } catch (ReferenceValidationException $e) { + return ErrorResponse::referenceError($e->getRef()); } catch (ForbiddenException $e) { return ErrorResponse::forbidden($e->getMessage()); } @@ -271,6 +327,14 @@ public function put(string $objectType, string $uuid, Request $request): JsonRes // Validate data against schema $this->validator->validate($putRequest->data, $schema->getSchema()); + // Validate references + $this->referenceValidator->validate( + $putRequest->data, + $objectType, + $metaObject->getProjectId(), + $metaObject->getOrganizationId() + ); + $metaObject = $this->transactionManager->transactional(function () use ( $metaObject, $putRequest, @@ -294,6 +358,9 @@ public function put(string $objectType, string $uuid, Request $request): JsonRes return $metaObject; }); + // Sync reference index + $this->referenceIndexService->syncRefsForObject($metaObject, $putRequest->data); + $response = MetaObjectResponse::fromEntity($metaObject); return $this->serializer->success($response); @@ -303,6 +370,8 @@ public function put(string $objectType, string $uuid, Request $request): JsonRes return ErrorResponse::notFound($e->getMessage()); } catch (ValidationException $e) { return ErrorResponse::validationErrorFromRaw($e->getErrors()); + } catch (ReferenceValidationException $e) { + return ErrorResponse::referenceError($e->getRef()); } catch (ForbiddenException $e) { return ErrorResponse::forbidden($e->getMessage()); } @@ -327,13 +396,16 @@ public function delete(string $objectType, string $uuid, Request $request): Json $metaObject->getOrganizationId() ); - $this->repository->softDelete($metaObject); + // Execute delete with referential integrity checks + $this->deletePlannerService->executeDelete($metaObject); return new JsonResponse(null, 204); } catch (MetaObjectNotFoundException $e) { return ErrorResponse::notFound($e->getMessage()); } catch (SchemaNotFoundException $e) { return ErrorResponse::notFound($e->getMessage()); + } catch (DeleteRestrictedException $e) { + return ErrorResponse::deleteRestricted($e->getViolations()); } catch (ForbiddenException $e) { return ErrorResponse::forbidden($e->getMessage()); } @@ -525,4 +597,31 @@ private function extractFilters(Request $request, string $objectType): array return $filters; } + + /** + * Parse ?relationships=field1,field2 query param. + * + * @return string[] + */ + private function parseRelationshipsParam(Request $request): array + { + $param = $request->query->getString('relationships', ''); + if ($param === '') { + return []; // Empty means "all relationships" + } + + return array_map('trim', explode(',', $param)); + } + + /** + * Get base API URL for relationship links. + */ + private function getBaseApiUrl(Request $request): string + { + return sprintf( + '%s://%s/api/v1/repository', + $request->getScheme(), + $request->getHttpHost() + ); + } } diff --git a/src/Entity/MetaRef.php b/src/Entity/MetaRef.php new file mode 100644 index 0000000..c5c99e1 --- /dev/null +++ b/src/Entity/MetaRef.php @@ -0,0 +1,101 @@ +projectId = $projectId; + $this->fromType = $fromType; + $this->fromUuid = $fromUuid; + $this->path = $path; + $this->toType = $toType; + $this->toUuid = $toUuid; + $this->createdAt = new DateTimeImmutable(); + } + + public function getProjectId(): ?int + { + return $this->projectId; + } + + public function getFromType(): string + { + return $this->fromType; + } + + public function getFromUuid(): UuidInterface + { + return $this->fromUuid; + } + + public function getPath(): string + { + return $this->path; + } + + public function getToType(): string + { + return $this->toType; + } + + public function getToUuid(): UuidInterface + { + return $this->toUuid; + } + + public function getCreatedAt(): DateTimeImmutable + { + return $this->createdAt; + } +} diff --git a/src/Exception/DeleteRestrictedException.php b/src/Exception/DeleteRestrictedException.php new file mode 100644 index 0000000..18c0460 --- /dev/null +++ b/src/Exception/DeleteRestrictedException.php @@ -0,0 +1,41 @@ + $violations + */ + public function __construct( + private MetaObject $target, + private array $violations, + ) { + $totalCount = array_sum(array_column($violations, 'count')); + parent::__construct( + "Cannot delete {$target->getObjectType()}/{$target->getUuid()}: " . + "{$totalCount} object(s) reference this object" + ); + } + + public function getTarget(): MetaObject + { + return $this->target; + } + + /** + * @return array + */ + public function getViolations(): array + { + return $this->violations; + } +} diff --git a/src/Exception/InvalidRefersToException.php b/src/Exception/InvalidRefersToException.php new file mode 100644 index 0000000..c291ddd --- /dev/null +++ b/src/Exception/InvalidRefersToException.php @@ -0,0 +1,27 @@ +path; + } + + public function getReason(): string + { + return $this->reason; + } +} diff --git a/src/Exception/ReferenceValidationException.php b/src/Exception/ReferenceValidationException.php new file mode 100644 index 0000000..e2a98d6 --- /dev/null +++ b/src/Exception/ReferenceValidationException.php @@ -0,0 +1,48 @@ +ref; + } + + public function getPath(): string + { + return $this->ref['path']; + } + + public function getType(): string + { + return $this->ref['type']; + } + + public function getRefUuid(): string + { + return $this->ref['uuid']; + } +} diff --git a/src/Repository/MetaObjectRepository.php b/src/Repository/MetaObjectRepository.php index e0a102a..fd8dbc5 100644 --- a/src/Repository/MetaObjectRepository.php +++ b/src/Repository/MetaObjectRepository.php @@ -357,4 +357,66 @@ public function count( return isset($result['cnt']) && is_numeric($result['cnt']) ? (int) $result['cnt'] : 0; } + + /** + * @param string[] $uuids + * @return array + */ + public function checkUuidsExist( + array $uuids, + string $objectType, + ?int $projectId, + string $organizationId, + ): array { + if (empty($uuids)) { + return []; + } + + $conn = $this->em->getConnection(); + + // Build parameterized query + $placeholders = []; + $params = [ + 'objectType' => $objectType, + 'organizationId' => $organizationId, + ]; + + foreach (array_values($uuids) as $i => $uuid) { + $placeholders[] = ':uuid_' . $i; + $params['uuid_' . $i] = $uuid; + } + + $sql = 'SELECT uuid FROM meta_objects '; + $sql .= 'WHERE object_type = :objectType '; + $sql .= 'AND organization_id = :organizationId '; + $sql .= 'AND deleted_at IS NULL '; + + if ($projectId !== null) { + $sql .= 'AND project_id = :projectId '; + $params['projectId'] = $projectId; + } else { + $sql .= 'AND project_id IS NULL '; + } + + $sql .= 'AND uuid IN (' . implode(', ', $placeholders) . ')'; + + $stmt = $conn->prepare($sql); + $rows = $stmt->executeQuery($params)->fetchAllAssociative(); + + // Build result map + $existing = []; + foreach ($rows as $row) { + if (isset($row['uuid']) && is_string($row['uuid'])) { + $existing[$row['uuid']] = true; + } + } + + // Return map for all requested UUIDs + $result = []; + foreach ($uuids as $uuid) { + $result[$uuid] = isset($existing[$uuid]); + } + + return $result; + } } diff --git a/src/Repository/MetaObjectRepositoryInterface.php b/src/Repository/MetaObjectRepositoryInterface.php index 7a1fa45..f7f162b 100644 --- a/src/Repository/MetaObjectRepositoryInterface.php +++ b/src/Repository/MetaObjectRepositoryInterface.php @@ -115,4 +115,17 @@ public function count( array $filters = [], bool $includeDeleted = false, ): int; + + /** + * Check which UUIDs exist (not deleted) for a given type in project/org scope. + * + * @param string[] $uuids + * @return array UUID => exists + */ + public function checkUuidsExist( + array $uuids, + string $objectType, + ?int $projectId, + string $organizationId, + ): array; } diff --git a/src/Repository/MetaRefRepository.php b/src/Repository/MetaRefRepository.php new file mode 100644 index 0000000..293f0b4 --- /dev/null +++ b/src/Repository/MetaRefRepository.php @@ -0,0 +1,226 @@ + + */ + private EntityRepository $repository; + + public function __construct( + private EntityManagerInterface $em, + ) { + $this->repository = $em->getRepository(MetaRef::class); + } + + /** + * @return MetaRef[] + */ + public function findInboundRefs( + ?int $projectId, + string $toType, + UuidInterface $toUuid, + ): array { + $qb = $this->repository->createQueryBuilder('r') + ->where('r.toType = :toType') + ->andWhere('r.toUuid = :toUuid') + ->setParameter('toType', $toType) + ->setParameter('toUuid', $toUuid); + + if ($projectId !== null) { + $qb->andWhere('r.projectId = :projectId') + ->setParameter('projectId', $projectId); + } else { + $qb->andWhere('r.projectId IS NULL'); + } + + /** @var MetaRef[] $result */ + $result = $qb->getQuery()->getResult(); + + return $result; + } + + /** + * @return MetaRef[] + */ + public function findOutboundRefs( + ?int $projectId, + string $fromType, + UuidInterface $fromUuid, + ): array { + $qb = $this->repository->createQueryBuilder('r') + ->where('r.fromType = :fromType') + ->andWhere('r.fromUuid = :fromUuid') + ->setParameter('fromType', $fromType) + ->setParameter('fromUuid', $fromUuid); + + if ($projectId !== null) { + $qb->andWhere('r.projectId = :projectId') + ->setParameter('projectId', $projectId); + } else { + $qb->andWhere('r.projectId IS NULL'); + } + + /** @var MetaRef[] $result */ + $result = $qb->getQuery()->getResult(); + + return $result; + } + + /** + * @return array + */ + public function countInboundRefsByPath( + ?int $projectId, + string $toType, + UuidInterface $toUuid, + ): array { + $conn = $this->em->getConnection(); + + $sql = <<<'SQL' + SELECT from_type, path, COUNT(*) as count + FROM meta_refs + WHERE to_type = :toType + AND to_uuid = :toUuid + SQL; + + if ($projectId !== null) { + $sql .= ' AND project_id = :projectId'; + } else { + $sql .= ' AND project_id IS NULL'; + } + + $sql .= ' GROUP BY from_type, path'; + + $params = [ + 'toType' => $toType, + 'toUuid' => $toUuid->toString(), + ]; + + if ($projectId !== null) { + $params['projectId'] = $projectId; + } + + /** @var array $rows */ + $rows = $conn->fetchAllAssociative($sql, $params); + + return array_map( + fn (array $row) => [ + 'fromType' => $row['from_type'], + 'path' => $row['path'], + 'count' => (int) $row['count'], + ], + $rows + ); + } + + public function deleteBySource( + ?int $projectId, + string $fromType, + UuidInterface $fromUuid, + ): int { + $qb = $this->em->createQueryBuilder() + ->delete(MetaRef::class, 'r') + ->where('r.fromType = :fromType') + ->andWhere('r.fromUuid = :fromUuid') + ->setParameter('fromType', $fromType) + ->setParameter('fromUuid', $fromUuid); + + if ($projectId !== null) { + $qb->andWhere('r.projectId = :projectId') + ->setParameter('projectId', $projectId); + } else { + $qb->andWhere('r.projectId IS NULL'); + } + + /** @var int $result */ + $result = $qb->getQuery()->execute(); + + return $result; + } + + /** + * @param MetaRef[] $refs + */ + public function batchInsert(array $refs): void + { + foreach ($refs as $ref) { + $this->em->persist($ref); + } + $this->em->flush(); + } + + /** + * @param MetaRef[] $newRefs + */ + public function syncRefs( + ?int $projectId, + string $fromType, + UuidInterface $fromUuid, + array $newRefs, + ): void { + // Get existing refs + $existingRefs = $this->findOutboundRefs($projectId, $fromType, $fromUuid); + + // Build key sets for comparison + $existingKeys = []; + foreach ($existingRefs as $ref) { + $key = $this->buildRefKey($ref); + $existingKeys[$key] = $ref; + } + + $newKeys = []; + foreach ($newRefs as $ref) { + $key = $this->buildRefKey($ref); + $newKeys[$key] = $ref; + } + + // Delete refs that no longer exist + foreach ($existingKeys as $key => $ref) { + if (! isset($newKeys[$key])) { + $this->em->remove($ref); + } + } + + // Insert new refs + foreach ($newKeys as $key => $ref) { + if (! isset($existingKeys[$key])) { + $this->em->persist($ref); + } + } + + $this->em->flush(); + } + + public function save(MetaRef $ref): void + { + $this->em->persist($ref); + $this->em->flush(); + } + + public function remove(MetaRef $ref): void + { + $this->em->remove($ref); + $this->em->flush(); + } + + private function buildRefKey(MetaRef $ref): string + { + return sprintf( + '%s|%s|%s|%s', + $ref->getPath(), + $ref->getToType(), + $ref->getToUuid()->toString(), + $ref->getProjectId() ?? 'null' + ); + } +} diff --git a/src/Repository/MetaRefRepositoryInterface.php b/src/Repository/MetaRefRepositoryInterface.php new file mode 100644 index 0000000..4698a12 --- /dev/null +++ b/src/Repository/MetaRefRepositoryInterface.php @@ -0,0 +1,84 @@ + + */ + public function countInboundRefsByPath( + ?int $projectId, + string $toType, + UuidInterface $toUuid, + ): array; + + /** + * Delete all refs from a specific source object. + * + * @return int Number of deleted rows + */ + public function deleteBySource( + ?int $projectId, + string $fromType, + UuidInterface $fromUuid, + ): int; + + /** + * Batch insert refs. + * + * @param MetaRef[] $refs + */ + public function batchInsert(array $refs): void; + + /** + * Sync refs for a source object (delete removed, insert new). + * + * @param MetaRef[] $newRefs Expected refs after operation + */ + public function syncRefs( + ?int $projectId, + string $fromType, + UuidInterface $fromUuid, + array $newRefs, + ): void; + + /** + * Save a single MetaRef entity. + */ + public function save(MetaRef $ref): void; + + /** + * Remove a single MetaRef entity. + */ + public function remove(MetaRef $ref): void; +} diff --git a/src/Response/ErrorResponse.php b/src/Response/ErrorResponse.php index 40da3b0..df7abb2 100644 --- a/src/Response/ErrorResponse.php +++ b/src/Response/ErrorResponse.php @@ -115,6 +115,55 @@ public static function internalError(string $message = 'Internal Server Error'): return self::create(500, $message, null, self::generateExceptionId()); } + /** + * Create reference validation error response. + * + * @param array{path: string, type: string, uuid: string} $ref + */ + public static function referenceError(array $ref): JsonResponse + { + return new JsonResponse([ + 'error' => 422, + 'code' => '422', + 'message' => 'Reference validation failed', + 'status' => 'error', + 'errors' => [[ + 'message' => 'Referenced object not found', + 'path' => $ref['path'], + ]], + 'meta' => [ + 'ref' => $ref, + ], + ], 422, [ + 'Content-Type' => self::CONTENT_TYPE, + ]); + } + + /** + * Create delete restricted error response. + * + * @param array $violations + */ + public static function deleteRestricted(array $violations): JsonResponse + { + return new JsonResponse([ + 'error' => 409, + 'code' => '409', + 'message' => 'Cannot delete: object is referenced by other objects', + 'status' => 'error', + 'meta' => [ + 'inboundRefs' => array_map(fn (array $v) => [ + 'type' => $v['fromType'], + 'path' => $v['path'], + 'count' => $v['count'], + 'sample' => $v['sample'], + ], $violations), + ], + ], 409, [ + 'Content-Type' => self::CONTENT_TYPE, + ]); + } + private static function generateExceptionId(): string { return 'metastore-' . bin2hex(random_bytes(8)); diff --git a/src/Response/JsonApiSerializer.php b/src/Response/JsonApiSerializer.php index e54d58e..84b52b5 100644 --- a/src/Response/JsonApiSerializer.php +++ b/src/Response/JsonApiSerializer.php @@ -38,6 +38,31 @@ public function created(MetaObjectResponse $data): JsonResponse return $this->success($data, 201); } + /** + * Success response with enriched relationships. + * + * @param array> $enrichedRelationships + */ + public function successWithRelationships( + MetaObjectResponse $data, + array $enrichedRelationships, + int $statusCode = 200, + ): JsonResponse { + $baseUrl = $this->getBaseUrl(); + $serialized = $this->serializeOne($data, $baseUrl); + + // Merge enriched relationships + if (! empty($enrichedRelationships)) { + $serialized['enrichedRelationships'] = $enrichedRelationships; + } + + return new JsonResponse([ + 'data' => $serialized, + ], $statusCode, [ + 'Content-Type' => self::CONTENT_TYPE, + ]); + } + /** * @param MetaObjectResponse|MetaObjectResponse[] $data * @return array{data: array|array>} diff --git a/src/Schema/OnDeleteBehavior.php b/src/Schema/OnDeleteBehavior.php new file mode 100644 index 0000000..042270c --- /dev/null +++ b/src/Schema/OnDeleteBehavior.php @@ -0,0 +1,11 @@ + $schemaData + * @return RefersToDefinition[] + */ + public function parse(array $schemaData): array + { + $properties = $schemaData['properties'] ?? null; + + if (! is_array($properties)) { + return []; + } + + /** @var array $properties */ + return $this->extractFromProperties($properties, 'data'); + } + + /** + * Extract refersTo definitions from properties. + * Handles nested objects and arrays (depth=1 only). + * + * @param array $properties + * @param string $pathPrefix Current path prefix (e.g., "data") + * @return RefersToDefinition[] + */ + private function extractFromProperties(array $properties, string $pathPrefix): array + { + $definitions = []; + + foreach ($properties as $fieldName => $fieldDef) { + if (! is_array($fieldDef) || ! is_string($fieldName)) { + continue; + } + + /** @var array $fieldDef */ + $currentPath = $pathPrefix . '.' . $fieldName; + + // Check if this field has a refersTo definition + $definition = $this->parseFieldDefinition($fieldDef, $currentPath); + if ($definition !== null) { + $definitions[] = $definition; + } + + // Handle nested objects (depth=1 only) + $type = $fieldDef['type'] ?? null; + + if ($type === 'object' && isset($fieldDef['properties']) && is_array($fieldDef['properties'])) { + // Only go one level deep for nested objects + foreach ($fieldDef['properties'] as $nestedFieldName => $nestedFieldDef) { + if (! is_array($nestedFieldDef) || ! is_string($nestedFieldName)) { + continue; + } + /** @var array $nestedFieldDef */ + $nestedPath = $currentPath . '.' . $nestedFieldName; + $nestedDefinition = $this->parseFieldDefinition($nestedFieldDef, $nestedPath); + if ($nestedDefinition !== null) { + $definitions[] = $nestedDefinition; + } + } + } + + // Handle arrays with items (depth=1 only) + if ($type === 'array' && isset($fieldDef['items']) && is_array($fieldDef['items'])) { + /** @var array $itemsDef */ + $itemsDef = $fieldDef['items']; + $itemsPath = $currentPath; + + // Check if array items have refersTo + $itemsDefinition = $this->parseFieldDefinition($itemsDef, $itemsPath); + if ($itemsDefinition !== null) { + $definitions[] = $itemsDefinition; + } + + // Check nested properties in array items + if (($itemsDef['type'] ?? null) === 'object' + && isset($itemsDef['properties']) + && is_array($itemsDef['properties'])) { + foreach ($itemsDef['properties'] as $itemFieldName => $itemFieldDef) { + if (! is_array($itemFieldDef) || ! is_string($itemFieldName)) { + continue; + } + /** @var array $itemFieldDef */ + $itemFieldPath = $currentPath . '.' . $itemFieldName; + $itemFieldDefinition = $this->parseFieldDefinition($itemFieldDef, $itemFieldPath); + if ($itemFieldDefinition !== null) { + $definitions[] = $itemFieldDefinition; + } + } + } + } + } + + return $definitions; + } + + /** + * Parse a single field's x-metastore configuration. + * + * @param array $fieldDef + * @param string $path Full path to this field + * @throws InvalidRefersToException + */ + private function parseFieldDefinition(array $fieldDef, string $path): ?RefersToDefinition + { + $xMetastore = $fieldDef[self::METASTORE_KEY] ?? null; + + if (! is_array($xMetastore)) { + return null; + } + + $refersTo = $xMetastore['refersTo'] ?? null; + + if ($refersTo === null) { + return null; + } + + if (! is_array($refersTo)) { + throw new InvalidRefersToException($path, 'refersTo must be an object'); + } + + $targetType = $refersTo['type'] ?? null; + if (! is_string($targetType) || $targetType === '') { + throw new InvalidRefersToException($path, 'refersTo.type is required and must be a non-empty string'); + } + + $targetField = $refersTo['field'] ?? null; + if ($targetField !== 'uuid') { + throw new InvalidRefersToException($path, 'refersTo.field must be "uuid" (only supported key in v1)'); + } + + $onDeleteValue = $xMetastore['onDelete'] ?? 'restrict'; + if (! is_string($onDeleteValue)) { + throw new InvalidRefersToException($path, 'onDelete must be a string'); + } + + $onDelete = OnDeleteBehavior::tryFrom($onDeleteValue); + if ($onDelete === null) { + throw new InvalidRefersToException( + $path, + "onDelete must be 'restrict' or 'cascade', got '{$onDeleteValue}'" + ); + } + + return new RefersToDefinition( + path: $path, + targetType: $targetType, + targetField: $targetField, + onDelete: $onDelete, + ); + } +} diff --git a/src/Service/DeletePlannerService.php b/src/Service/DeletePlannerService.php new file mode 100644 index 0000000..3c24be8 --- /dev/null +++ b/src/Service/DeletePlannerService.php @@ -0,0 +1,160 @@ + Track visited objects to prevent loops + */ + private array $visited = []; + + public function __construct( + private MetaObjectRepositoryInterface $metaObjectRepository, + private MetaRefRepositoryInterface $metaRefRepository, + private SchemaServiceInterface $schemaService, + private ReferenceIndexServiceInterface $referenceIndexService, + ) { + } + + /** + * Execute deletion with referential integrity checks. + * + * @throws DeleteRestrictedException If restricted references exist + */ + public function executeDelete(MetaObject $targetObject): void + { + $this->visited = []; + $this->processDelete($targetObject); + } + + /** + * Process deletion of a single object, handling cascades. + */ + private function processDelete(MetaObject $target): void + { + $key = $target->getObjectType() . ':' . $target->getUuid()->toString(); + if (isset($this->visited[$key])) { + return; // Prevent infinite loops + } + $this->visited[$key] = true; + + // Get all inbound references + $inboundRefs = $this->metaRefRepository->findInboundRefs( + $target->getProjectId(), + $target->getObjectType(), + $target->getUuid() + ); + + if (empty($inboundRefs)) { + // No references, safe to delete + $this->metaObjectRepository->softDelete($target); + $this->referenceIndexService->removeRefsForObject($target); + + return; + } + + // Group by (from_type, path) to determine behavior + $grouped = $this->groupRefsByPath($inboundRefs); + + // Check for restrict violations + $restrictViolations = []; + foreach ($grouped as $groupKey => $group) { + $behavior = $this->getOnDeleteBehavior($group['fromType'], $group['path']); + if ($behavior === OnDeleteBehavior::Restrict && count($group['refs']) > 0) { + $restrictViolations[] = [ + 'fromType' => $group['fromType'], + 'path' => $group['path'], + 'count' => count($group['refs']), + 'sample' => array_slice( + array_map(fn (MetaRef $r) => $r->getFromUuid()->toString(), $group['refs']), + 0, + 5 + ), + ]; + } + } + + if (! empty($restrictViolations)) { + throw new DeleteRestrictedException($target, $restrictViolations); + } + + // Process cascades + foreach ($grouped as $groupKey => $group) { + $behavior = $this->getOnDeleteBehavior($group['fromType'], $group['path']); + if ($behavior === OnDeleteBehavior::Cascade) { + foreach ($group['refs'] as $ref) { + $referrer = $this->metaObjectRepository->findByUuid($ref->getFromUuid()); + if ($referrer !== null && ! $referrer->isDeleted()) { + $this->processDelete($referrer); + } + } + } + } + + // Finally, soft-delete the target and clean up its refs + $this->metaObjectRepository->softDelete($target); + $this->referenceIndexService->removeRefsForObject($target); + } + + /** + * Get onDelete behavior for a specific reference path. + */ + private function getOnDeleteBehavior(string $fromType, string $path): OnDeleteBehavior + { + try { + $definitions = $this->schemaService->getRefersToDefinitions($fromType); + foreach ($definitions as $def) { + if ($def->path === $path) { + return $def->onDelete; + } + } + } catch (\Exception) { + // Schema not found, default to restrict + } + + return OnDeleteBehavior::Restrict; + } + + /** + * Group refs by (from_type, path). + * + * @param MetaRef[] $refs + * @return array + */ + private function groupRefsByPath(array $refs): array + { + $grouped = []; + + foreach ($refs as $ref) { + $key = $ref->getFromType() . '|' . $ref->getPath(); + if (! isset($grouped[$key])) { + $grouped[$key] = [ + 'fromType' => $ref->getFromType(), + 'path' => $ref->getPath(), + 'refs' => [], + ]; + } + $grouped[$key]['refs'][] = $ref; + } + + return $grouped; + } +} diff --git a/src/Service/ReferenceIndexService.php b/src/Service/ReferenceIndexService.php new file mode 100644 index 0000000..fc42ffe --- /dev/null +++ b/src/Service/ReferenceIndexService.php @@ -0,0 +1,177 @@ + $data Current payload data + */ + public function syncRefsForObject(MetaObject $metaObject, array $data): void + { + $refDefs = $this->schemaService->getRefersToDefinitions($metaObject->getObjectType()); + + if (empty($refDefs)) { + // No refs defined, ensure any stale refs are removed + $this->metaRefRepository->deleteBySource( + $metaObject->getProjectId(), + $metaObject->getObjectType(), + $metaObject->getUuid() + ); + + return; + } + + $newRefs = $this->computeRefs($metaObject, $data, $refDefs); + + $this->metaRefRepository->syncRefs( + $metaObject->getProjectId(), + $metaObject->getObjectType(), + $metaObject->getUuid(), + $newRefs + ); + } + + /** + * Remove all refs when an object is deleted. + */ + public function removeRefsForObject(MetaObject $metaObject): void + { + $this->metaRefRepository->deleteBySource( + $metaObject->getProjectId(), + $metaObject->getObjectType(), + $metaObject->getUuid() + ); + } + + /** + * Compute MetaRef entities from current data. + * + * @param array $data + * @param RefersToDefinition[] $definitions + * @return MetaRef[] + */ + private function computeRefs( + MetaObject $metaObject, + array $data, + array $definitions, + ): array { + $refs = []; + + foreach ($definitions as $definition) { + $uuids = $this->getValuesAtPath($data, $definition->path); + + foreach ($uuids as $uuid) { + if ($uuid === '' || ! $this->isValidUuid($uuid)) { + continue; + } + + $refs[] = new MetaRef( + $metaObject->getProjectId(), + $metaObject->getObjectType(), + $metaObject->getUuid(), + $definition->path, + $definition->targetType, + Uuid::fromString($uuid) + ); + } + } + + return $refs; + } + + /** + * Get value(s) at a dotted path, handling arrays. + * + * @param array $data + * @return string[] + */ + private function getValuesAtPath(array $data, string $path): array + { + // Remove "data." prefix if present + if (str_starts_with($path, 'data.')) { + $path = substr($path, 5); + } + + return $this->extractValuesRecursive($data, explode('.', $path)); + } + + /** + * Recursively extract values at path segments. + * + * @param string[] $segments + * @return string[] + */ + private function extractValuesRecursive(mixed $current, array $segments): array + { + if (empty($segments)) { + if (is_string($current) && $current !== '') { + return [$current]; + } + + // Handle arrays of strings at leaf positions (e.g., tag_ids: [uuid1, uuid2]) + if (is_array($current) && array_is_list($current)) { + return array_values(array_filter($current, fn ($v) => is_string($v) && $v !== '')); + } + + return []; + } + + if (! is_array($current)) { + return []; + } + + $segment = array_shift($segments); + + // Check if this level is an indexed array (list) + if (array_is_list($current)) { + $values = []; + foreach ($current as $item) { + if (is_array($item) && isset($item[$segment])) { + $values = array_merge( + $values, + $this->extractValuesRecursive($item[$segment], $segments) + ); + } elseif (empty($segments) && is_string($item) && $item !== '') { + $values[] = $item; + } + } + + return $values; + } + + if (! isset($current[$segment])) { + return []; + } + + return $this->extractValuesRecursive($current[$segment], $segments); + } + + private function isValidUuid(string $value): bool + { + return Uuid::isValid($value); + } +} diff --git a/src/Service/ReferenceIndexServiceInterface.php b/src/Service/ReferenceIndexServiceInterface.php new file mode 100644 index 0000000..7250cd7 --- /dev/null +++ b/src/Service/ReferenceIndexServiceInterface.php @@ -0,0 +1,25 @@ + $data Current payload data + */ + public function syncRefsForObject(MetaObject $metaObject, array $data): void; + + /** + * Remove all refs when an object is deleted. + */ + public function removeRefsForObject(MetaObject $metaObject): void; +} diff --git a/src/Service/RelationshipEnrichmentService.php b/src/Service/RelationshipEnrichmentService.php new file mode 100644 index 0000000..8a03dbf --- /dev/null +++ b/src/Service/RelationshipEnrichmentService.php @@ -0,0 +1,305 @@ + $data Object data payload + * @param string $objectType Object type + * @param string[] $requestedPaths Optional filter for specific paths (empty = all) + * @param int|null $projectId Project scope for lookups + * @param string $organizationId Organization scope + * @param string $baseUrl Base URL for link generation + * @return array|array>|null, url: string|array, meta: array{sourcePath: string}}> + */ + public function enrichRelationships( + array $data, + string $objectType, + array $requestedPaths, + ?int $projectId, + string $organizationId, + string $baseUrl, + ): array { + $definitions = $this->schemaService->getRefersToDefinitions($objectType); + + if (empty($definitions)) { + return []; + } + + // Filter definitions by requested paths if specified + if (! empty($requestedPaths)) { + $definitions = array_filter( + $definitions, + fn (RefersToDefinition $def) => $this->matchesRequestedPath($def->path, $requestedPaths) + ); + } + + $relationships = []; + + foreach ($definitions as $definition) { + $uuids = $this->getValuesAtPath($data, $definition->path); + + if (empty($uuids)) { + continue; + } + + // Determine if this is an array field + $isArray = $this->isArrayPath($data, $definition->path); + + if ($isArray) { + $relationships[$definition->path] = $this->buildArrayRelationship( + $uuids, + $definition->targetType, + $definition->path, + $projectId, + $organizationId, + $baseUrl + ); + } else { + $relationships[$definition->path] = $this->buildSingleRelationship( + $uuids[0], + $definition->targetType, + $definition->path, + $projectId, + $organizationId, + $baseUrl + ); + } + } + + return $relationships; + } + + /** + * Build relationship entry for a single reference. + * + * @return array{data: array|null, url: string, meta: array{sourcePath: string}} + */ + private function buildSingleRelationship( + string $uuid, + string $targetType, + string $path, + ?int $projectId, + string $organizationId, + string $baseUrl, + ): array { + $related = $this->findRelatedObject($uuid, $targetType, $projectId, $organizationId); + + return [ + 'data' => $related?->jsonSerialize(), + 'url' => $baseUrl . '/' . $targetType . '/' . $uuid, + 'meta' => [ + 'sourcePath' => $path, + ], + ]; + } + + /** + * Build relationship entry for array of references. + * + * @param string[] $uuids + * @return array{data: array>, url: array, meta: array{sourcePath: string}} + */ + private function buildArrayRelationship( + array $uuids, + string $targetType, + string $path, + ?int $projectId, + string $organizationId, + string $baseUrl, + ): array { + $data = []; + $urls = []; + + foreach (array_unique($uuids) as $uuid) { + if (! Uuid::isValid($uuid)) { + continue; + } + + $related = $this->findRelatedObject($uuid, $targetType, $projectId, $organizationId); + if ($related !== null) { + $data[] = $related->jsonSerialize(); + $urls[] = $baseUrl . '/' . $targetType . '/' . $uuid; + } + } + + return [ + 'data' => $data, + 'url' => $urls, + 'meta' => [ + 'sourcePath' => $path, + ], + ]; + } + + private function findRelatedObject( + string $uuid, + string $targetType, + ?int $projectId, + string $organizationId, + ): ?MetaObject { + if (! Uuid::isValid($uuid)) { + return null; + } + + $object = $this->metaObjectRepository->findByUuidString($uuid); + + // Verify the object matches expected type and scope + if ($object === null + || $object->isDeleted() + || $object->getObjectType() !== $targetType + || $object->getOrganizationId() !== $organizationId) { + return null; + } + + // Check project scope + if ($projectId !== null && $object->getProjectId() !== $projectId) { + return null; + } + + return $object; + } + + /** + * Check if a path matches any of the requested paths. + * + * @param string[] $requestedPaths + */ + private function matchesRequestedPath(string $path, array $requestedPaths): bool + { + // Strip "data." prefix for comparison + $normalizedPath = str_starts_with($path, 'data.') ? substr($path, 5) : $path; + + foreach ($requestedPaths as $requested) { + if ($requested === $path || $requested === $normalizedPath) { + return true; + } + } + + return false; + } + + /** + * Check if a path represents an array field. + * + * @param array $data + */ + private function isArrayPath(array $data, string $path): bool + { + // Strip "data." prefix + if (str_starts_with($path, 'data.')) { + $path = substr($path, 5); + } + + $segments = explode('.', $path); + $current = $data; + + // Walk to the parent of the target field + foreach ($segments as $i => $segment) { + if (! is_array($current)) { + return false; + } + + // Check if current level is a list + if (array_is_list($current)) { + return true; + } + + if (! isset($current[$segment])) { + return false; + } + + // For the last segment, check if the value is an array + if ($i === count($segments) - 1) { + return is_array($current[$segment]) && array_is_list($current[$segment]); + } + + $current = $current[$segment]; + } + + return false; + } + + /** + * Get value(s) at a dotted path, handling arrays. + * + * @param array $data + * @return string[] + */ + private function getValuesAtPath(array $data, string $path): array + { + // Strip "data." prefix + if (str_starts_with($path, 'data.')) { + $path = substr($path, 5); + } + + return $this->extractValuesRecursive($data, explode('.', $path)); + } + + /** + * @param string[] $segments + * @return string[] + */ + private function extractValuesRecursive(mixed $current, array $segments): array + { + if (empty($segments)) { + if (is_string($current) && $current !== '') { + return [$current]; + } + if (is_array($current) && array_is_list($current)) { + return array_filter($current, fn ($v) => is_string($v) && $v !== ''); + } + + return []; + } + + if (! is_array($current)) { + return []; + } + + $segment = array_shift($segments); + + if (array_is_list($current)) { + $values = []; + foreach ($current as $item) { + if (is_array($item) && isset($item[$segment])) { + $values = array_merge( + $values, + $this->extractValuesRecursive($item[$segment], $segments) + ); + } + } + + return $values; + } + + if (! isset($current[$segment])) { + return []; + } + + return $this->extractValuesRecursive($current[$segment], $segments); + } +} diff --git a/src/Service/SchemaService.php b/src/Service/SchemaService.php index f3a4c88..9a0851d 100644 --- a/src/Service/SchemaService.php +++ b/src/Service/SchemaService.php @@ -7,11 +7,14 @@ use Bareapi\Entity\Schema; use Bareapi\Exception\SchemaNotFoundException; use Bareapi\Repository\SchemaRepositoryInterface; +use Bareapi\Schema\RefersToDefinition; +use Bareapi\Schema\RefersToParser; final class SchemaService implements SchemaServiceInterface { public function __construct( private SchemaRepositoryInterface $schemaRepository, + private RefersToParser $refersToParser, ) { } @@ -102,4 +105,14 @@ public function schemaExists(string $objectType): bool return false; } } + + /** + * @return RefersToDefinition[] + */ + public function getRefersToDefinitions(string $objectType): array + { + $schemaData = $this->getSchemaData($objectType); + + return $this->refersToParser->parse($schemaData); + } } diff --git a/src/Service/SchemaServiceInterface.php b/src/Service/SchemaServiceInterface.php index 8856169..b57ba1d 100644 --- a/src/Service/SchemaServiceInterface.php +++ b/src/Service/SchemaServiceInterface.php @@ -5,6 +5,7 @@ namespace Bareapi\Service; use Bareapi\Entity\Schema; +use Bareapi\Schema\RefersToDefinition; interface SchemaServiceInterface { @@ -56,4 +57,12 @@ public function listObjectTypes(): array; * Check if a schema exists for the given object type. */ public function schemaExists(string $objectType): bool; + + /** + * Get all refersTo definitions for an object type. + * + * @return RefersToDefinition[] + * @throws \Bareapi\Exception\SchemaNotFoundException + */ + public function getRefersToDefinitions(string $objectType): array; } diff --git a/src/Validation/ReferenceValidator.php b/src/Validation/ReferenceValidator.php new file mode 100644 index 0000000..00fa3f9 --- /dev/null +++ b/src/Validation/ReferenceValidator.php @@ -0,0 +1,182 @@ + $data The payload data + * @param string $objectType The type being created/updated + * @param int|null $projectId Project scope + * @param string $organizationId Organization scope + * @throws ReferenceValidationException If any reference is invalid + */ + public function validate( + array $data, + string $objectType, + ?int $projectId, + string $organizationId, + ): void { + $definitions = $this->schemaService->getRefersToDefinitions($objectType); + + if (empty($definitions)) { + return; + } + + // Extract all references from the data + $references = $this->extractReferences($data, $definitions); + + // Batch validate each target type + foreach ($references as $targetType => $refInfos) { + $uuids = []; + foreach ($refInfos as $refInfo) { + $uuids = array_merge($uuids, $refInfo['uuids']); + } + + if (empty($uuids)) { + continue; + } + + $uniqueUuids = array_unique($uuids); + $existence = $this->metaObjectRepository->checkUuidsExist( + $uniqueUuids, + $targetType, + $projectId, + $organizationId + ); + + // Check for missing references + foreach ($refInfos as $refInfo) { + foreach ($refInfo['uuids'] as $uuid) { + if (! ($existence[$uuid] ?? false)) { + throw new ReferenceValidationException([ + 'path' => $refInfo['path'], + 'type' => $targetType, + 'uuid' => $uuid, + ]); + } + } + } + } + } + + /** + * Extract UUID values from data based on refersTo definitions. + * + * @param array $data + * @param RefersToDefinition[] $definitions + * @return array> + */ + private function extractReferences(array $data, array $definitions): array + { + $references = []; + + foreach ($definitions as $definition) { + $path = $definition->path; + $targetType = $definition->targetType; + + // Extract UUIDs at this path + $uuids = $this->getValuesAtPath($data, $path); + + if (! empty($uuids)) { + if (! isset($references[$targetType])) { + $references[$targetType] = []; + } + $references[$targetType][] = [ + 'path' => $path, + 'uuids' => $uuids, + ]; + } + } + + return $references; + } + + /** + * Get value(s) at a dotted path, handling arrays. + * + * Path format: "data.field" or "data.items.field" for array items. + * + * @param array $data + * @return string[] + */ + private function getValuesAtPath(array $data, string $path): array + { + // Remove "data." prefix if present (schema paths include "data." prefix) + if (str_starts_with($path, 'data.')) { + $path = substr($path, 5); + } + + return $this->extractValuesRecursive($data, explode('.', $path)); + } + + /** + * Recursively extract values at path segments. + * + * @param string[] $segments + * @return string[] + */ + private function extractValuesRecursive(mixed $current, array $segments): array + { + if (empty($segments)) { + // We've reached the target + if (is_string($current) && $current !== '') { + return [$current]; + } + + return []; + } + + if (! is_array($current)) { + return []; + } + + $segment = array_shift($segments); + + // Check if this level is an indexed array (list) + if (array_is_list($current)) { + // Iterate over array items + $values = []; + foreach ($current as $item) { + if (is_array($item) && isset($item[$segment])) { + $values = array_merge( + $values, + $this->extractValuesRecursive($item[$segment], $segments) + ); + } elseif (empty($segments) && is_string($item) && $item !== '') { + // Direct array of strings + $values[] = $item; + } + } + + return $values; + } + + // Regular object access + if (! isset($current[$segment])) { + return []; + } + + return $this->extractValuesRecursive($current[$segment], $segments); + } +} diff --git a/tests/Unit/Schema/RefersToParserTest.php b/tests/Unit/Schema/RefersToParserTest.php new file mode 100644 index 0000000..b6f5321 --- /dev/null +++ b/tests/Unit/Schema/RefersToParserTest.php @@ -0,0 +1,410 @@ +parser = new RefersToParser(); + } + + public function testReturnsEmptyArrayWhenNoProperties(): void + { + $schemaData = [ + 'type' => 'object', + ]; + + $definitions = $this->parser->parse($schemaData); + + $this->assertSame([], $definitions); + } + + public function testReturnsEmptyArrayWhenNoRefersTo(): void + { + $schemaData = [ + 'type' => 'object', + 'properties' => [ + 'title' => [ + 'type' => 'string', + ], + 'count' => [ + 'type' => 'integer', + ], + ], + ]; + + $definitions = $this->parser->parse($schemaData); + + $this->assertSame([], $definitions); + } + + public function testParsesSimpleRefersToField(): void + { + $schemaData = [ + 'type' => 'object', + 'properties' => [ + 'tagId' => [ + 'type' => 'string', + 'x-metastore' => [ + 'refersTo' => [ + 'type' => 'tag', + 'field' => 'uuid', + ], + ], + ], + ], + ]; + + $definitions = $this->parser->parse($schemaData); + + $this->assertCount(1, $definitions); + $this->assertSame('data.tagId', $definitions[0]->path); + $this->assertSame('tag', $definitions[0]->targetType); + $this->assertSame('uuid', $definitions[0]->targetField); + $this->assertSame(OnDeleteBehavior::Restrict, $definitions[0]->onDelete); + } + + public function testParsesRefersToWithCascadeOnDelete(): void + { + $schemaData = [ + 'type' => 'object', + 'properties' => [ + 'tagId' => [ + 'type' => 'string', + 'x-metastore' => [ + 'refersTo' => [ + 'type' => 'tag', + 'field' => 'uuid', + ], + 'onDelete' => 'cascade', + ], + ], + ], + ]; + + $definitions = $this->parser->parse($schemaData); + + $this->assertCount(1, $definitions); + $this->assertSame(OnDeleteBehavior::Cascade, $definitions[0]->onDelete); + } + + public function testParsesRefersToWithExplicitRestrict(): void + { + $schemaData = [ + 'type' => 'object', + 'properties' => [ + 'tagId' => [ + 'type' => 'string', + 'x-metastore' => [ + 'refersTo' => [ + 'type' => 'tag', + 'field' => 'uuid', + ], + 'onDelete' => 'restrict', + ], + ], + ], + ]; + + $definitions = $this->parser->parse($schemaData); + + $this->assertCount(1, $definitions); + $this->assertSame(OnDeleteBehavior::Restrict, $definitions[0]->onDelete); + } + + public function testParsesMultipleRefersToFields(): void + { + $schemaData = [ + 'type' => 'object', + 'properties' => [ + 'tagId' => [ + 'type' => 'string', + 'x-metastore' => [ + 'refersTo' => [ + 'type' => 'tag', + 'field' => 'uuid', + ], + ], + ], + 'categoryId' => [ + 'type' => 'string', + 'x-metastore' => [ + 'refersTo' => [ + 'type' => 'category', + 'field' => 'uuid', + ], + 'onDelete' => 'cascade', + ], + ], + ], + ]; + + $definitions = $this->parser->parse($schemaData); + + $this->assertCount(2, $definitions); + + $paths = array_map(fn ($d) => $d->path, $definitions); + $this->assertContains('data.tagId', $paths); + $this->assertContains('data.categoryId', $paths); + } + + public function testParsesRefersToInNestedObject(): void + { + $schemaData = [ + 'type' => 'object', + 'properties' => [ + 'metadata' => [ + 'type' => 'object', + 'properties' => [ + 'tagId' => [ + 'type' => 'string', + 'x-metastore' => [ + 'refersTo' => [ + 'type' => 'tag', + 'field' => 'uuid', + ], + ], + ], + ], + ], + ], + ]; + + $definitions = $this->parser->parse($schemaData); + + $this->assertCount(1, $definitions); + $this->assertSame('data.metadata.tagId', $definitions[0]->path); + $this->assertSame('tag', $definitions[0]->targetType); + } + + public function testParsesRefersToInArrayItems(): void + { + $schemaData = [ + 'type' => 'object', + 'properties' => [ + 'tagIds' => [ + 'type' => 'array', + 'items' => [ + 'type' => 'string', + 'x-metastore' => [ + 'refersTo' => [ + 'type' => 'tag', + 'field' => 'uuid', + ], + ], + ], + ], + ], + ]; + + $definitions = $this->parser->parse($schemaData); + + $this->assertCount(1, $definitions); + $this->assertSame('data.tagIds', $definitions[0]->path); + $this->assertSame('tag', $definitions[0]->targetType); + } + + public function testParsesRefersToInArrayOfObjects(): void + { + $schemaData = [ + 'type' => 'object', + 'properties' => [ + 'items' => [ + 'type' => 'array', + 'items' => [ + 'type' => 'object', + 'properties' => [ + 'tagId' => [ + 'type' => 'string', + 'x-metastore' => [ + 'refersTo' => [ + 'type' => 'tag', + 'field' => 'uuid', + ], + ], + ], + ], + ], + ], + ], + ]; + + $definitions = $this->parser->parse($schemaData); + + $this->assertCount(1, $definitions); + $this->assertSame('data.items.tagId', $definitions[0]->path); + $this->assertSame('tag', $definitions[0]->targetType); + } + + public function testThrowsExceptionWhenRefersToFieldNotUuid(): void + { + $schemaData = [ + 'type' => 'object', + 'properties' => [ + 'tagId' => [ + 'type' => 'string', + 'x-metastore' => [ + 'refersTo' => [ + 'type' => 'tag', + 'field' => 'name', + ], + ], + ], + ], + ]; + + $this->expectException(InvalidRefersToException::class); + $this->expectExceptionMessage('refersTo.field must be "uuid"'); + + $this->parser->parse($schemaData); + } + + public function testThrowsExceptionWhenRefersToTypeEmpty(): void + { + $schemaData = [ + 'type' => 'object', + 'properties' => [ + 'tagId' => [ + 'type' => 'string', + 'x-metastore' => [ + 'refersTo' => [ + 'type' => '', + 'field' => 'uuid', + ], + ], + ], + ], + ]; + + $this->expectException(InvalidRefersToException::class); + $this->expectExceptionMessage('refersTo.type is required'); + + $this->parser->parse($schemaData); + } + + public function testThrowsExceptionWhenRefersToTypeMissing(): void + { + $schemaData = [ + 'type' => 'object', + 'properties' => [ + 'tagId' => [ + 'type' => 'string', + 'x-metastore' => [ + 'refersTo' => [ + 'field' => 'uuid', + ], + ], + ], + ], + ]; + + $this->expectException(InvalidRefersToException::class); + $this->expectExceptionMessage('refersTo.type is required'); + + $this->parser->parse($schemaData); + } + + public function testThrowsExceptionWhenOnDeleteInvalid(): void + { + $schemaData = [ + 'type' => 'object', + 'properties' => [ + 'tagId' => [ + 'type' => 'string', + 'x-metastore' => [ + 'refersTo' => [ + 'type' => 'tag', + 'field' => 'uuid', + ], + 'onDelete' => 'invalid', + ], + ], + ], + ]; + + $this->expectException(InvalidRefersToException::class); + $this->expectExceptionMessage("onDelete must be 'restrict' or 'cascade'"); + + $this->parser->parse($schemaData); + } + + public function testThrowsExceptionWhenRefersToNotObject(): void + { + $schemaData = [ + 'type' => 'object', + 'properties' => [ + 'tagId' => [ + 'type' => 'string', + 'x-metastore' => [ + 'refersTo' => 'tag', + ], + ], + ], + ]; + + $this->expectException(InvalidRefersToException::class); + $this->expectExceptionMessage('refersTo must be an object'); + + $this->parser->parse($schemaData); + } + + public function testIgnoresFieldsWithoutXMetastore(): void + { + $schemaData = [ + 'type' => 'object', + 'properties' => [ + 'title' => [ + 'type' => 'string', + ], + 'tagId' => [ + 'type' => 'string', + 'x-metastore' => [ + 'refersTo' => [ + 'type' => 'tag', + 'field' => 'uuid', + ], + ], + ], + 'description' => [ + 'type' => 'string', + ], + ], + ]; + + $definitions = $this->parser->parse($schemaData); + + $this->assertCount(1, $definitions); + $this->assertSame('data.tagId', $definitions[0]->path); + } + + public function testIgnoresXMetastoreWithoutRefersTo(): void + { + $schemaData = [ + 'type' => 'object', + 'properties' => [ + 'status' => [ + 'type' => 'string', + 'x-metastore' => [ + 'acl' => [ + 'create' => [], + ], + ], + ], + ], + ]; + + $definitions = $this->parser->parse($schemaData); + + $this->assertSame([], $definitions); + } +} diff --git a/tests/Unit/Service/DeletePlannerServiceTest.php b/tests/Unit/Service/DeletePlannerServiceTest.php new file mode 100644 index 0000000..b6b74ed --- /dev/null +++ b/tests/Unit/Service/DeletePlannerServiceTest.php @@ -0,0 +1,475 @@ +metaObjectRepository = $this->createMock(MetaObjectRepositoryInterface::class); + $this->metaRefRepository = $this->createMock(MetaRefRepositoryInterface::class); + $this->schemaService = $this->createMock(SchemaServiceInterface::class); + $this->referenceIndexService = $this->createMock(ReferenceIndexServiceInterface::class); + + $this->service = new DeletePlannerService( + $this->metaObjectRepository, + $this->metaRefRepository, + $this->schemaService, + $this->referenceIndexService, + ); + } + + public function testDeleteObjectWithNoReferences(): void + { + $target = MetaObjectFactory::create(); + + $this->metaRefRepository->expects($this->once()) + ->method('findInboundRefs') + ->with($target->getProjectId(), $target->getObjectType(), $target->getUuid()) + ->willReturn([]); + + $this->metaObjectRepository->expects($this->once()) + ->method('softDelete') + ->with($target); + + $this->referenceIndexService->expects($this->once()) + ->method('removeRefsForObject') + ->with($target); + + $this->service->executeDelete($target); + } + + public function testDeleteThrowsExceptionWhenRestrictReferencesExist(): void + { + $target = MetaObjectFactory::create( + objectType: 'users', + name: 'user-1', + ); + $referrer = MetaObjectFactory::create( + objectType: 'notes', + name: 'note-1', + ); + + $inboundRef = new MetaRef( + $target->getProjectId(), + $referrer->getObjectType(), + $referrer->getUuid(), + 'data.author_id', + $target->getObjectType(), + $target->getUuid(), + ); + + $this->metaRefRepository->expects($this->once()) + ->method('findInboundRefs') + ->with($target->getProjectId(), $target->getObjectType(), $target->getUuid()) + ->willReturn([$inboundRef]); + + $this->schemaService->expects($this->once()) + ->method('getRefersToDefinitions') + ->with('notes') + ->willReturn([ + new RefersToDefinition( + 'data.author_id', + 'users', + 'uuid', + OnDeleteBehavior::Restrict + ), + ]); + + $this->metaObjectRepository->expects($this->never()) + ->method('softDelete'); + + $this->expectException(DeleteRestrictedException::class); + $this->service->executeDelete($target); + } + + public function testDeleteWithCascadeDeletesReferrers(): void + { + $target = MetaObjectFactory::create( + objectType: 'users', + name: 'user-1', + ); + $referrer = MetaObjectFactory::create( + objectType: 'notes', + name: 'note-1', + ); + + $inboundRef = new MetaRef( + $target->getProjectId(), + $referrer->getObjectType(), + $referrer->getUuid(), + 'data.author_id', + $target->getObjectType(), + $target->getUuid(), + ); + + // First call is for the target (users), second call is for the referrer (notes) + $this->metaRefRepository->expects($this->exactly(2)) + ->method('findInboundRefs') + ->willReturnCallback(function ($projectId, $toType, $toUuid) use ($target, $referrer, $inboundRef) { + if ($toType === $target->getObjectType()) { + return [$inboundRef]; + } + + return []; // No refs to the referrer + }); + + // getRefersToDefinitions is called for the referrer type to determine cascade behavior + $this->schemaService->expects($this->atLeastOnce()) + ->method('getRefersToDefinitions') + ->willReturnCallback(function ($objectType) { + if ($objectType === 'notes') { + return [ + new RefersToDefinition( + 'data.author_id', + 'users', + 'uuid', + OnDeleteBehavior::Cascade + ), + ]; + } + + return []; + }); + + $this->metaObjectRepository->expects($this->once()) + ->method('findByUuid') + ->with($referrer->getUuid()) + ->willReturn($referrer); + + // Both referrer and target should be soft deleted + $softDeletedObjects = []; + $this->metaObjectRepository->expects($this->exactly(2)) + ->method('softDelete') + ->willReturnCallback(function ($obj) use (&$softDeletedObjects) { + $softDeletedObjects[] = $obj->getObjectType(); + }); + + // Both should have refs removed + $this->referenceIndexService->expects($this->exactly(2)) + ->method('removeRefsForObject'); + + $this->service->executeDelete($target); + + $this->assertContains('notes', $softDeletedObjects); + $this->assertContains('users', $softDeletedObjects); + } + + public function testDeleteWithAlreadyDeletedReferrerSkipsIt(): void + { + $target = MetaObjectFactory::create( + objectType: 'users', + name: 'user-1', + ); + $referrer = MetaObjectFactory::create( + objectType: 'notes', + name: 'note-1', + ); + $referrer->setDeletedAt(new \DateTimeImmutable()); + + $inboundRef = new MetaRef( + $target->getProjectId(), + $referrer->getObjectType(), + $referrer->getUuid(), + 'data.author_id', + $target->getObjectType(), + $target->getUuid(), + ); + + $this->metaRefRepository->expects($this->once()) + ->method('findInboundRefs') + ->willReturn([$inboundRef]); + + // Called twice: once for restrict check, once for cascade processing + $this->schemaService->expects($this->exactly(2)) + ->method('getRefersToDefinitions') + ->with('notes') + ->willReturn([ + new RefersToDefinition( + 'data.author_id', + 'users', + 'uuid', + OnDeleteBehavior::Cascade + ), + ]); + + $this->metaObjectRepository->expects($this->once()) + ->method('findByUuid') + ->willReturn($referrer); + + // Only target should be deleted since referrer is already deleted + $this->metaObjectRepository->expects($this->once()) + ->method('softDelete') + ->with($target); + + $this->referenceIndexService->expects($this->once()) + ->method('removeRefsForObject') + ->with($target); + + $this->service->executeDelete($target); + } + + public function testDeleteWithNullReferrerSkipsIt(): void + { + $target = MetaObjectFactory::create( + objectType: 'users', + name: 'user-1', + ); + $referrerUuid = Uuid::uuid7(); + + $inboundRef = new MetaRef( + $target->getProjectId(), + 'notes', + $referrerUuid, + 'data.author_id', + $target->getObjectType(), + $target->getUuid(), + ); + + $this->metaRefRepository->expects($this->once()) + ->method('findInboundRefs') + ->willReturn([$inboundRef]); + + // Called twice: once for restrict check, once for cascade processing + $this->schemaService->expects($this->exactly(2)) + ->method('getRefersToDefinitions') + ->with('notes') + ->willReturn([ + new RefersToDefinition( + 'data.author_id', + 'users', + 'uuid', + OnDeleteBehavior::Cascade + ), + ]); + + // Referrer not found in DB + $this->metaObjectRepository->expects($this->once()) + ->method('findByUuid') + ->with($referrerUuid) + ->willReturn(null); + + // Only target should be deleted + $this->metaObjectRepository->expects($this->once()) + ->method('softDelete') + ->with($target); + + $this->referenceIndexService->expects($this->once()) + ->method('removeRefsForObject') + ->with($target); + + $this->service->executeDelete($target); + } + + public function testDeleteWithMissingSchemaDefaultsToRestrict(): void + { + $target = MetaObjectFactory::create( + objectType: 'users', + name: 'user-1', + ); + $referrerUuid = Uuid::uuid7(); + + $inboundRef = new MetaRef( + $target->getProjectId(), + 'notes', + $referrerUuid, + 'data.author_id', + $target->getObjectType(), + $target->getUuid(), + ); + + $this->metaRefRepository->expects($this->once()) + ->method('findInboundRefs') + ->willReturn([$inboundRef]); + + // Schema throws exception - should default to restrict + $this->schemaService->expects($this->once()) + ->method('getRefersToDefinitions') + ->willThrowException(new \Exception('Schema not found')); + + $this->expectException(DeleteRestrictedException::class); + $this->service->executeDelete($target); + } + + public function testDeleteWithUnmatchedPathDefaultsToRestrict(): void + { + $target = MetaObjectFactory::create( + objectType: 'users', + name: 'user-1', + ); + $referrerUuid = Uuid::uuid7(); + + $inboundRef = new MetaRef( + $target->getProjectId(), + 'notes', + $referrerUuid, + 'data.author_id', + $target->getObjectType(), + $target->getUuid(), + ); + + $this->metaRefRepository->expects($this->once()) + ->method('findInboundRefs') + ->willReturn([$inboundRef]); + + // Schema returns definitions but none match the path + $this->schemaService->expects($this->once()) + ->method('getRefersToDefinitions') + ->with('notes') + ->willReturn([ + new RefersToDefinition( + 'data.different_field', // Different path + 'users', + 'uuid', + OnDeleteBehavior::Cascade + ), + ]); + + $this->expectException(DeleteRestrictedException::class); + $this->service->executeDelete($target); + } + + public function testDeleteRestrictedExceptionContainsViolationDetails(): void + { + $target = MetaObjectFactory::create( + objectType: 'users', + name: 'user-1', + ); + $referrer1Uuid = Uuid::uuid7(); + $referrer2Uuid = Uuid::uuid7(); + + $inboundRefs = [ + new MetaRef( + $target->getProjectId(), + 'notes', + $referrer1Uuid, + 'data.author_id', + $target->getObjectType(), + $target->getUuid(), + ), + new MetaRef( + $target->getProjectId(), + 'notes', + $referrer2Uuid, + 'data.author_id', + $target->getObjectType(), + $target->getUuid(), + ), + ]; + + $this->metaRefRepository->expects($this->once()) + ->method('findInboundRefs') + ->willReturn($inboundRefs); + + $this->schemaService->expects($this->once()) + ->method('getRefersToDefinitions') + ->with('notes') + ->willReturn([ + new RefersToDefinition( + 'data.author_id', + 'users', + 'uuid', + OnDeleteBehavior::Restrict + ), + ]); + + try { + $this->service->executeDelete($target); + $this->fail('Expected DeleteRestrictedException'); + } catch (DeleteRestrictedException $e) { + $violations = $e->getViolations(); + $this->assertCount(1, $violations); + $this->assertEquals('notes', $violations[0]['fromType']); + $this->assertEquals('data.author_id', $violations[0]['path']); + $this->assertEquals(2, $violations[0]['count']); + $this->assertCount(2, $violations[0]['sample']); + } + } + + public function testDeleteWithMultiplePathsGroupsCorrectly(): void + { + $target = MetaObjectFactory::create( + objectType: 'users', + name: 'user-1', + ); + + $inboundRefs = [ + new MetaRef( + $target->getProjectId(), + 'notes', + Uuid::uuid7(), + 'data.author_id', + $target->getObjectType(), + $target->getUuid(), + ), + new MetaRef( + $target->getProjectId(), + 'notes', + Uuid::uuid7(), + 'data.reviewer_id', + $target->getObjectType(), + $target->getUuid(), + ), + ]; + + $this->metaRefRepository->expects($this->once()) + ->method('findInboundRefs') + ->willReturn($inboundRefs); + + // Both paths configured as restrict + $this->schemaService->expects($this->exactly(2)) + ->method('getRefersToDefinitions') + ->with('notes') + ->willReturn([ + new RefersToDefinition('data.author_id', 'users', 'uuid', OnDeleteBehavior::Restrict), + new RefersToDefinition('data.reviewer_id', 'users', 'uuid', OnDeleteBehavior::Restrict), + ]); + + try { + $this->service->executeDelete($target); + $this->fail('Expected DeleteRestrictedException'); + } catch (DeleteRestrictedException $e) { + $violations = $e->getViolations(); + $this->assertCount(2, $violations); + } + } +} diff --git a/tests/Unit/Service/ReferenceIndexServiceTest.php b/tests/Unit/Service/ReferenceIndexServiceTest.php new file mode 100644 index 0000000..c74ce8e --- /dev/null +++ b/tests/Unit/Service/ReferenceIndexServiceTest.php @@ -0,0 +1,481 @@ +metaRefRepository = $this->createMock(MetaRefRepositoryInterface::class); + $this->schemaService = $this->createMock(SchemaServiceInterface::class); + $this->service = new ReferenceIndexService($this->metaRefRepository, $this->schemaService); + } + + public function testSyncRefsWithNoDefinitionsDeletesExisting(): void + { + $metaObject = MetaObjectFactory::create(); + $data = ['title' => 'Test']; + + $this->schemaService->expects($this->once()) + ->method('getRefersToDefinitions') + ->with($metaObject->getObjectType()) + ->willReturn([]); + + $this->metaRefRepository->expects($this->once()) + ->method('deleteBySource') + ->with( + $metaObject->getProjectId(), + $metaObject->getObjectType(), + $metaObject->getUuid() + ); + + $this->metaRefRepository->expects($this->never()) + ->method('syncRefs'); + + $this->service->syncRefsForObject($metaObject, $data); + } + + public function testSyncRefsWithSingleReference(): void + { + $metaObject = MetaObjectFactory::create( + objectType: 'notes', + ); + $targetUuid = '550e8400-e29b-41d4-a716-446655440000'; + $data = [ + 'title' => 'Test Note', + 'author_id' => $targetUuid, + ]; + + $definition = new RefersToDefinition( + 'data.author_id', + 'users', + 'uuid', + OnDeleteBehavior::Restrict + ); + + $this->schemaService->expects($this->once()) + ->method('getRefersToDefinitions') + ->with('notes') + ->willReturn([$definition]); + + $capturedRefs = null; + $this->metaRefRepository->expects($this->once()) + ->method('syncRefs') + ->willReturnCallback(function ($projectId, $fromType, $fromUuid, $refs) use (&$capturedRefs, $metaObject) { + $this->assertEquals($metaObject->getProjectId(), $projectId); + $this->assertEquals('notes', $fromType); + $this->assertEquals($metaObject->getUuid()->toString(), $fromUuid->toString()); + $capturedRefs = $refs; + }); + + $this->service->syncRefsForObject($metaObject, $data); + + $this->assertCount(1, $capturedRefs); + $this->assertEquals('data.author_id', $capturedRefs[0]->getPath()); + $this->assertEquals('users', $capturedRefs[0]->getToType()); + $this->assertEquals($targetUuid, $capturedRefs[0]->getToUuid()->toString()); + } + + public function testSyncRefsWithArrayOfReferences(): void + { + $metaObject = MetaObjectFactory::create(objectType: 'articles'); + $uuid1 = '550e8400-e29b-41d4-a716-446655440001'; + $uuid2 = '550e8400-e29b-41d4-a716-446655440002'; + $data = [ + 'title' => 'Test Article', + 'tag_ids' => [$uuid1, $uuid2], + ]; + + $definition = new RefersToDefinition( + 'data.tag_ids', + 'tags', + 'uuid', + OnDeleteBehavior::Restrict + ); + + $this->schemaService->expects($this->once()) + ->method('getRefersToDefinitions') + ->with('articles') + ->willReturn([$definition]); + + $capturedRefs = null; + $this->metaRefRepository->expects($this->once()) + ->method('syncRefs') + ->willReturnCallback(function ($projectId, $fromType, $fromUuid, $refs) use (&$capturedRefs) { + $capturedRefs = $refs; + }); + + $this->service->syncRefsForObject($metaObject, $data); + + $this->assertCount(2, $capturedRefs); + $this->assertEquals($uuid1, $capturedRefs[0]->getToUuid()->toString()); + $this->assertEquals($uuid2, $capturedRefs[1]->getToUuid()->toString()); + } + + public function testSyncRefsWithNestedPath(): void + { + $metaObject = MetaObjectFactory::create(objectType: 'orders'); + $customerUuid = '550e8400-e29b-41d4-a716-446655440000'; + $data = [ + 'title' => 'Order', + 'customer' => [ + 'id' => $customerUuid, + 'name' => 'Test Customer', + ], + ]; + + $definition = new RefersToDefinition( + 'data.customer.id', + 'customers', + 'uuid', + OnDeleteBehavior::Restrict + ); + + $this->schemaService->expects($this->once()) + ->method('getRefersToDefinitions') + ->with('orders') + ->willReturn([$definition]); + + $capturedRefs = null; + $this->metaRefRepository->expects($this->once()) + ->method('syncRefs') + ->willReturnCallback(function ($projectId, $fromType, $fromUuid, $refs) use (&$capturedRefs) { + $capturedRefs = $refs; + }); + + $this->service->syncRefsForObject($metaObject, $data); + + $this->assertCount(1, $capturedRefs); + $this->assertEquals($customerUuid, $capturedRefs[0]->getToUuid()->toString()); + } + + public function testSyncRefsWithArrayOfObjects(): void + { + $metaObject = MetaObjectFactory::create(objectType: 'invoices'); + $productUuid1 = '550e8400-e29b-41d4-a716-446655440001'; + $productUuid2 = '550e8400-e29b-41d4-a716-446655440002'; + $data = [ + 'title' => 'Invoice', + 'items' => [ + ['product_id' => $productUuid1, 'quantity' => 1], + ['product_id' => $productUuid2, 'quantity' => 2], + ], + ]; + + $definition = new RefersToDefinition( + 'data.items.product_id', + 'products', + 'uuid', + OnDeleteBehavior::Restrict + ); + + $this->schemaService->expects($this->once()) + ->method('getRefersToDefinitions') + ->with('invoices') + ->willReturn([$definition]); + + $capturedRefs = null; + $this->metaRefRepository->expects($this->once()) + ->method('syncRefs') + ->willReturnCallback(function ($projectId, $fromType, $fromUuid, $refs) use (&$capturedRefs) { + $capturedRefs = $refs; + }); + + $this->service->syncRefsForObject($metaObject, $data); + + $this->assertCount(2, $capturedRefs); + $this->assertEquals($productUuid1, $capturedRefs[0]->getToUuid()->toString()); + $this->assertEquals($productUuid2, $capturedRefs[1]->getToUuid()->toString()); + } + + public function testSyncRefsSkipsEmptyValues(): void + { + $metaObject = MetaObjectFactory::create(objectType: 'notes'); + $data = [ + 'title' => 'Test', + 'author_id' => '', // Empty string + ]; + + $definition = new RefersToDefinition( + 'data.author_id', + 'users', + 'uuid', + OnDeleteBehavior::Restrict + ); + + $this->schemaService->expects($this->once()) + ->method('getRefersToDefinitions') + ->with('notes') + ->willReturn([$definition]); + + $capturedRefs = null; + $this->metaRefRepository->expects($this->once()) + ->method('syncRefs') + ->willReturnCallback(function ($projectId, $fromType, $fromUuid, $refs) use (&$capturedRefs) { + $capturedRefs = $refs; + }); + + $this->service->syncRefsForObject($metaObject, $data); + + $this->assertCount(0, $capturedRefs); + } + + public function testSyncRefsSkipsInvalidUuids(): void + { + $metaObject = MetaObjectFactory::create(objectType: 'notes'); + $data = [ + 'title' => 'Test', + 'author_id' => 'not-a-valid-uuid', + ]; + + $definition = new RefersToDefinition( + 'data.author_id', + 'users', + 'uuid', + OnDeleteBehavior::Restrict + ); + + $this->schemaService->expects($this->once()) + ->method('getRefersToDefinitions') + ->with('notes') + ->willReturn([$definition]); + + $capturedRefs = null; + $this->metaRefRepository->expects($this->once()) + ->method('syncRefs') + ->willReturnCallback(function ($projectId, $fromType, $fromUuid, $refs) use (&$capturedRefs) { + $capturedRefs = $refs; + }); + + $this->service->syncRefsForObject($metaObject, $data); + + $this->assertCount(0, $capturedRefs); + } + + public function testSyncRefsHandlesMissingPath(): void + { + $metaObject = MetaObjectFactory::create(objectType: 'notes'); + $data = [ + 'title' => 'Test', + // author_id is missing + ]; + + $definition = new RefersToDefinition( + 'data.author_id', + 'users', + 'uuid', + OnDeleteBehavior::Restrict + ); + + $this->schemaService->expects($this->once()) + ->method('getRefersToDefinitions') + ->with('notes') + ->willReturn([$definition]); + + $capturedRefs = null; + $this->metaRefRepository->expects($this->once()) + ->method('syncRefs') + ->willReturnCallback(function ($projectId, $fromType, $fromUuid, $refs) use (&$capturedRefs) { + $capturedRefs = $refs; + }); + + $this->service->syncRefsForObject($metaObject, $data); + + $this->assertCount(0, $capturedRefs); + } + + public function testSyncRefsWithMultipleDefinitions(): void + { + $metaObject = MetaObjectFactory::create(objectType: 'notes'); + $authorUuid = '550e8400-e29b-41d4-a716-446655440001'; + $categoryUuid = '550e8400-e29b-41d4-a716-446655440002'; + $data = [ + 'title' => 'Test', + 'author_id' => $authorUuid, + 'category_id' => $categoryUuid, + ]; + + $definitions = [ + new RefersToDefinition( + 'data.author_id', + 'users', + 'uuid', + OnDeleteBehavior::Restrict + ), + new RefersToDefinition( + 'data.category_id', + 'categories', + 'uuid', + OnDeleteBehavior::Cascade + ), + ]; + + $this->schemaService->expects($this->once()) + ->method('getRefersToDefinitions') + ->with('notes') + ->willReturn($definitions); + + $capturedRefs = null; + $this->metaRefRepository->expects($this->once()) + ->method('syncRefs') + ->willReturnCallback(function ($projectId, $fromType, $fromUuid, $refs) use (&$capturedRefs) { + $capturedRefs = $refs; + }); + + $this->service->syncRefsForObject($metaObject, $data); + + $this->assertCount(2, $capturedRefs); + + $paths = array_map(fn (MetaRef $r) => $r->getPath(), $capturedRefs); + $this->assertContains('data.author_id', $paths); + $this->assertContains('data.category_id', $paths); + } + + public function testRemoveRefsForObject(): void + { + $metaObject = MetaObjectFactory::create(); + + $this->metaRefRepository->expects($this->once()) + ->method('deleteBySource') + ->with( + $metaObject->getProjectId(), + $metaObject->getObjectType(), + $metaObject->getUuid() + ); + + $this->service->removeRefsForObject($metaObject); + } + + public function testSyncRefsHandlesMixedValidInvalidUuids(): void + { + $metaObject = MetaObjectFactory::create(objectType: 'articles'); + $validUuid = '550e8400-e29b-41d4-a716-446655440001'; + $data = [ + 'title' => 'Test', + 'tag_ids' => [$validUuid, 'invalid', '', '550e8400-e29b-41d4-a716-446655440002'], + ]; + + $definition = new RefersToDefinition( + 'data.tag_ids', + 'tags', + 'uuid', + OnDeleteBehavior::Restrict + ); + + $this->schemaService->expects($this->once()) + ->method('getRefersToDefinitions') + ->willReturn([$definition]); + + $capturedRefs = null; + $this->metaRefRepository->expects($this->once()) + ->method('syncRefs') + ->willReturnCallback(function ($projectId, $fromType, $fromUuid, $refs) use (&$capturedRefs) { + $capturedRefs = $refs; + }); + + $this->service->syncRefsForObject($metaObject, $data); + + // Only valid UUIDs should be included + $this->assertCount(2, $capturedRefs); + } + + public function testSyncRefsStripsDataPrefix(): void + { + $metaObject = MetaObjectFactory::create(objectType: 'notes'); + $targetUuid = '550e8400-e29b-41d4-a716-446655440000'; + $data = [ + 'author_id' => $targetUuid, + ]; + + // Definition has "data." prefix but data doesn't + $definition = new RefersToDefinition( + 'data.author_id', + 'users', + 'uuid', + OnDeleteBehavior::Restrict + ); + + $this->schemaService->expects($this->once()) + ->method('getRefersToDefinitions') + ->willReturn([$definition]); + + $capturedRefs = null; + $this->metaRefRepository->expects($this->once()) + ->method('syncRefs') + ->willReturnCallback(function ($projectId, $fromType, $fromUuid, $refs) use (&$capturedRefs) { + $capturedRefs = $refs; + }); + + $this->service->syncRefsForObject($metaObject, $data); + + $this->assertCount(1, $capturedRefs); + $this->assertEquals($targetUuid, $capturedRefs[0]->getToUuid()->toString()); + } + + public function testSyncRefsPopulatesCorrectMetaRefFields(): void + { + $metaObject = MetaObjectFactory::create( + objectType: 'notes', + projectId: 456, + ); + $targetUuid = '550e8400-e29b-41d4-a716-446655440000'; + $data = [ + 'author_id' => $targetUuid, + ]; + + $definition = new RefersToDefinition( + 'data.author_id', + 'users', + 'uuid', + OnDeleteBehavior::Restrict + ); + + $this->schemaService->expects($this->once()) + ->method('getRefersToDefinitions') + ->willReturn([$definition]); + + $capturedRefs = null; + $this->metaRefRepository->expects($this->once()) + ->method('syncRefs') + ->willReturnCallback(function ($projectId, $fromType, $fromUuid, $refs) use (&$capturedRefs) { + $capturedRefs = $refs; + }); + + $this->service->syncRefsForObject($metaObject, $data); + + $this->assertCount(1, $capturedRefs); + $ref = $capturedRefs[0]; + + $this->assertEquals(456, $ref->getProjectId()); + $this->assertEquals('notes', $ref->getFromType()); + $this->assertEquals($metaObject->getUuid()->toString(), $ref->getFromUuid()->toString()); + $this->assertEquals('data.author_id', $ref->getPath()); + $this->assertEquals('users', $ref->getToType()); + $this->assertEquals($targetUuid, $ref->getToUuid()->toString()); + } +} diff --git a/tests/Unit/Service/RelationshipEnrichmentServiceTest.php b/tests/Unit/Service/RelationshipEnrichmentServiceTest.php new file mode 100644 index 0000000..04b4d94 --- /dev/null +++ b/tests/Unit/Service/RelationshipEnrichmentServiceTest.php @@ -0,0 +1,666 @@ +metaObjectRepository = $this->createMock(MetaObjectRepositoryInterface::class); + $this->schemaService = $this->createMock(SchemaServiceInterface::class); + $this->service = new RelationshipEnrichmentService( + $this->metaObjectRepository, + $this->schemaService, + ); + } + + public function testEnrichRelationshipsReturnsEmptyForNoDefinitions(): void + { + $this->schemaService->expects($this->once()) + ->method('getRefersToDefinitions') + ->with('notes') + ->willReturn([]); + + $result = $this->service->enrichRelationships( + ['title' => 'Test'], + 'notes', + [], + 123, + 'org-1', + 'https://api.example.com' + ); + + $this->assertSame([], $result); + } + + public function testEnrichRelationshipsWithSingleReference(): void + { + $targetUuid = '550e8400-e29b-41d4-a716-446655440000'; + $data = ['author_id' => $targetUuid]; + + $targetObject = MetaObjectFactory::create( + objectType: 'users', + name: 'user-1', + organizationId: 'org-1', + projectId: 123, + ); + $targetObject->setUuid(\Ramsey\Uuid\Uuid::fromString($targetUuid)); + + $definition = new RefersToDefinition( + 'data.author_id', + 'users', + 'uuid', + OnDeleteBehavior::Restrict + ); + + $this->schemaService->expects($this->once()) + ->method('getRefersToDefinitions') + ->with('notes') + ->willReturn([$definition]); + + $this->metaObjectRepository->expects($this->once()) + ->method('findByUuidString') + ->with($targetUuid) + ->willReturn($targetObject); + + $result = $this->service->enrichRelationships( + $data, + 'notes', + [], + 123, + 'org-1', + 'https://api.example.com' + ); + + $this->assertArrayHasKey('data.author_id', $result); + $this->assertEquals('https://api.example.com/users/' . $targetUuid, $result['data.author_id']['url']); + $this->assertEquals('data.author_id', $result['data.author_id']['meta']['sourcePath']); + $this->assertNotNull($result['data.author_id']['data']); + } + + public function testEnrichRelationshipsWithArrayOfReferences(): void + { + $uuid1 = '550e8400-e29b-41d4-a716-446655440001'; + $uuid2 = '550e8400-e29b-41d4-a716-446655440002'; + $data = ['tag_ids' => [$uuid1, $uuid2]]; + + $tag1 = MetaObjectFactory::create(objectType: 'tags', name: 'tag-1', organizationId: 'org-1', projectId: 123); + $tag1->setUuid(\Ramsey\Uuid\Uuid::fromString($uuid1)); + + $tag2 = MetaObjectFactory::create(objectType: 'tags', name: 'tag-2', organizationId: 'org-1', projectId: 123); + $tag2->setUuid(\Ramsey\Uuid\Uuid::fromString($uuid2)); + + $definition = new RefersToDefinition( + 'data.tag_ids', + 'tags', + 'uuid', + OnDeleteBehavior::Restrict + ); + + $this->schemaService->expects($this->once()) + ->method('getRefersToDefinitions') + ->with('articles') + ->willReturn([$definition]); + + $this->metaObjectRepository->expects($this->exactly(2)) + ->method('findByUuidString') + ->willReturnCallback(function ($uuid) use ($uuid1, $uuid2, $tag1, $tag2) { + return match ($uuid) { + $uuid1 => $tag1, + $uuid2 => $tag2, + default => null, + }; + }); + + $result = $this->service->enrichRelationships( + $data, + 'articles', + [], + 123, + 'org-1', + 'https://api.example.com' + ); + + $this->assertArrayHasKey('data.tag_ids', $result); + $this->assertIsArray($result['data.tag_ids']['data']); + $this->assertCount(2, $result['data.tag_ids']['data']); + $this->assertIsArray($result['data.tag_ids']['url']); + $this->assertCount(2, $result['data.tag_ids']['url']); + } + + public function testEnrichRelationshipsFiltersByRequestedPaths(): void + { + $authorUuid = '550e8400-e29b-41d4-a716-446655440001'; + $categoryUuid = '550e8400-e29b-41d4-a716-446655440002'; + $data = [ + 'author_id' => $authorUuid, + 'category_id' => $categoryUuid, + ]; + + $author = MetaObjectFactory::create(objectType: 'users', name: 'user-1', organizationId: 'org-1', projectId: 123); + $author->setUuid(\Ramsey\Uuid\Uuid::fromString($authorUuid)); + + $definitions = [ + new RefersToDefinition('data.author_id', 'users', 'uuid', OnDeleteBehavior::Restrict), + new RefersToDefinition('data.category_id', 'categories', 'uuid', OnDeleteBehavior::Restrict), + ]; + + $this->schemaService->expects($this->once()) + ->method('getRefersToDefinitions') + ->willReturn($definitions); + + $this->metaObjectRepository->expects($this->once()) + ->method('findByUuidString') + ->with($authorUuid) + ->willReturn($author); + + // Only request author_id + $result = $this->service->enrichRelationships( + $data, + 'notes', + ['author_id'], // Only include author_id + 123, + 'org-1', + 'https://api.example.com' + ); + + $this->assertArrayHasKey('data.author_id', $result); + $this->assertArrayNotHasKey('data.category_id', $result); + } + + public function testEnrichRelationshipsReturnsNullDataForMissingObject(): void + { + $targetUuid = '550e8400-e29b-41d4-a716-446655440000'; + $data = ['author_id' => $targetUuid]; + + $definition = new RefersToDefinition( + 'data.author_id', + 'users', + 'uuid', + OnDeleteBehavior::Restrict + ); + + $this->schemaService->expects($this->once()) + ->method('getRefersToDefinitions') + ->willReturn([$definition]); + + $this->metaObjectRepository->expects($this->once()) + ->method('findByUuidString') + ->with($targetUuid) + ->willReturn(null); + + $result = $this->service->enrichRelationships( + $data, + 'notes', + [], + 123, + 'org-1', + 'https://api.example.com' + ); + + $this->assertArrayHasKey('data.author_id', $result); + $this->assertNull($result['data.author_id']['data']); + $this->assertEquals('https://api.example.com/users/' . $targetUuid, $result['data.author_id']['url']); + } + + public function testEnrichRelationshipsSkipsDeletedObjects(): void + { + $targetUuid = '550e8400-e29b-41d4-a716-446655440000'; + $data = ['author_id' => $targetUuid]; + + $deletedObject = MetaObjectFactory::create( + objectType: 'users', + organizationId: 'org-1', + projectId: 123, + ); + $deletedObject->setUuid(\Ramsey\Uuid\Uuid::fromString($targetUuid)); + $deletedObject->setDeletedAt(new \DateTimeImmutable()); + + $definition = new RefersToDefinition( + 'data.author_id', + 'users', + 'uuid', + OnDeleteBehavior::Restrict + ); + + $this->schemaService->expects($this->once()) + ->method('getRefersToDefinitions') + ->willReturn([$definition]); + + $this->metaObjectRepository->expects($this->once()) + ->method('findByUuidString') + ->willReturn($deletedObject); + + $result = $this->service->enrichRelationships( + $data, + 'notes', + [], + 123, + 'org-1', + 'https://api.example.com' + ); + + $this->assertArrayHasKey('data.author_id', $result); + $this->assertNull($result['data.author_id']['data']); + } + + public function testEnrichRelationshipsSkipsWrongObjectType(): void + { + $targetUuid = '550e8400-e29b-41d4-a716-446655440000'; + $data = ['author_id' => $targetUuid]; + + $wrongTypeObject = MetaObjectFactory::create( + objectType: 'categories', // Expected 'users' + organizationId: 'org-1', + projectId: 123, + ); + $wrongTypeObject->setUuid(\Ramsey\Uuid\Uuid::fromString($targetUuid)); + + $definition = new RefersToDefinition( + 'data.author_id', + 'users', + 'uuid', + OnDeleteBehavior::Restrict + ); + + $this->schemaService->expects($this->once()) + ->method('getRefersToDefinitions') + ->willReturn([$definition]); + + $this->metaObjectRepository->expects($this->once()) + ->method('findByUuidString') + ->willReturn($wrongTypeObject); + + $result = $this->service->enrichRelationships( + $data, + 'notes', + [], + 123, + 'org-1', + 'https://api.example.com' + ); + + $this->assertNull($result['data.author_id']['data']); + } + + public function testEnrichRelationshipsSkipsWrongOrganization(): void + { + $targetUuid = '550e8400-e29b-41d4-a716-446655440000'; + $data = ['author_id' => $targetUuid]; + + $wrongOrgObject = MetaObjectFactory::create( + objectType: 'users', + organizationId: 'org-2', // Expected 'org-1' + projectId: 123, + ); + $wrongOrgObject->setUuid(\Ramsey\Uuid\Uuid::fromString($targetUuid)); + + $definition = new RefersToDefinition( + 'data.author_id', + 'users', + 'uuid', + OnDeleteBehavior::Restrict + ); + + $this->schemaService->expects($this->once()) + ->method('getRefersToDefinitions') + ->willReturn([$definition]); + + $this->metaObjectRepository->expects($this->once()) + ->method('findByUuidString') + ->willReturn($wrongOrgObject); + + $result = $this->service->enrichRelationships( + $data, + 'notes', + [], + 123, + 'org-1', + 'https://api.example.com' + ); + + $this->assertNull($result['data.author_id']['data']); + } + + public function testEnrichRelationshipsSkipsWrongProject(): void + { + $targetUuid = '550e8400-e29b-41d4-a716-446655440000'; + $data = ['author_id' => $targetUuid]; + + $wrongProjectObject = MetaObjectFactory::create( + objectType: 'users', + organizationId: 'org-1', + projectId: 999, // Expected 123 + ); + $wrongProjectObject->setUuid(\Ramsey\Uuid\Uuid::fromString($targetUuid)); + + $definition = new RefersToDefinition( + 'data.author_id', + 'users', + 'uuid', + OnDeleteBehavior::Restrict + ); + + $this->schemaService->expects($this->once()) + ->method('getRefersToDefinitions') + ->willReturn([$definition]); + + $this->metaObjectRepository->expects($this->once()) + ->method('findByUuidString') + ->willReturn($wrongProjectObject); + + $result = $this->service->enrichRelationships( + $data, + 'notes', + [], + 123, + 'org-1', + 'https://api.example.com' + ); + + $this->assertNull($result['data.author_id']['data']); + } + + public function testEnrichRelationshipsAllowsNullProjectScope(): void + { + $targetUuid = '550e8400-e29b-41d4-a716-446655440000'; + $data = ['author_id' => $targetUuid]; + + $targetObject = MetaObjectFactory::create( + objectType: 'users', + organizationId: 'org-1', + projectId: 456, // Object has a project + ); + $targetObject->setUuid(\Ramsey\Uuid\Uuid::fromString($targetUuid)); + + $definition = new RefersToDefinition( + 'data.author_id', + 'users', + 'uuid', + OnDeleteBehavior::Restrict + ); + + $this->schemaService->expects($this->once()) + ->method('getRefersToDefinitions') + ->willReturn([$definition]); + + $this->metaObjectRepository->expects($this->once()) + ->method('findByUuidString') + ->willReturn($targetObject); + + // Request with null projectId - should match any project + $result = $this->service->enrichRelationships( + $data, + 'notes', + [], + null, // Null project scope + 'org-1', + 'https://api.example.com' + ); + + $this->assertNotNull($result['data.author_id']['data']); + } + + public function testEnrichRelationshipsSkipsEmptyPaths(): void + { + $data = ['author_id' => '']; // Empty value + + $definition = new RefersToDefinition( + 'data.author_id', + 'users', + 'uuid', + OnDeleteBehavior::Restrict + ); + + $this->schemaService->expects($this->once()) + ->method('getRefersToDefinitions') + ->willReturn([$definition]); + + $this->metaObjectRepository->expects($this->never()) + ->method('findByUuidString'); + + $result = $this->service->enrichRelationships( + $data, + 'notes', + [], + 123, + 'org-1', + 'https://api.example.com' + ); + + $this->assertEmpty($result); + } + + public function testEnrichRelationshipsWithInvalidUuidReturnsNullData(): void + { + $data = ['author_id' => 'not-a-valid-uuid']; + + $definition = new RefersToDefinition( + 'data.author_id', + 'users', + 'uuid', + OnDeleteBehavior::Restrict + ); + + $this->schemaService->expects($this->once()) + ->method('getRefersToDefinitions') + ->willReturn([$definition]); + + // Invalid UUID won't be looked up in repository + $this->metaObjectRepository->expects($this->never()) + ->method('findByUuidString'); + + $result = $this->service->enrichRelationships( + $data, + 'notes', + [], + 123, + 'org-1', + 'https://api.example.com' + ); + + // Relationship entry still exists but with null data + $this->assertArrayHasKey('data.author_id', $result); + $this->assertNull($result['data.author_id']['data']); + } + + public function testEnrichRelationshipsWithNestedPath(): void + { + $customerUuid = '550e8400-e29b-41d4-a716-446655440000'; + $data = [ + 'customer' => [ + 'id' => $customerUuid, + ], + ]; + + $customer = MetaObjectFactory::create( + objectType: 'customers', + organizationId: 'org-1', + projectId: 123, + ); + $customer->setUuid(\Ramsey\Uuid\Uuid::fromString($customerUuid)); + + $definition = new RefersToDefinition( + 'data.customer.id', + 'customers', + 'uuid', + OnDeleteBehavior::Restrict + ); + + $this->schemaService->expects($this->once()) + ->method('getRefersToDefinitions') + ->willReturn([$definition]); + + $this->metaObjectRepository->expects($this->once()) + ->method('findByUuidString') + ->with($customerUuid) + ->willReturn($customer); + + $result = $this->service->enrichRelationships( + $data, + 'orders', + [], + 123, + 'org-1', + 'https://api.example.com' + ); + + $this->assertArrayHasKey('data.customer.id', $result); + $this->assertNotNull($result['data.customer.id']['data']); + } + + public function testEnrichRelationshipsWithArrayOfObjects(): void + { + $productUuid1 = '550e8400-e29b-41d4-a716-446655440001'; + $productUuid2 = '550e8400-e29b-41d4-a716-446655440002'; + $data = [ + 'items' => [ + ['product_id' => $productUuid1], + ['product_id' => $productUuid2], + ], + ]; + + $product1 = MetaObjectFactory::create(objectType: 'products', organizationId: 'org-1', projectId: 123); + $product1->setUuid(\Ramsey\Uuid\Uuid::fromString($productUuid1)); + + $product2 = MetaObjectFactory::create(objectType: 'products', organizationId: 'org-1', projectId: 123); + $product2->setUuid(\Ramsey\Uuid\Uuid::fromString($productUuid2)); + + $definition = new RefersToDefinition( + 'data.items.product_id', + 'products', + 'uuid', + OnDeleteBehavior::Restrict + ); + + $this->schemaService->expects($this->once()) + ->method('getRefersToDefinitions') + ->willReturn([$definition]); + + $this->metaObjectRepository->expects($this->exactly(2)) + ->method('findByUuidString') + ->willReturnCallback(function ($uuid) use ($productUuid1, $productUuid2, $product1, $product2) { + return match ($uuid) { + $productUuid1 => $product1, + $productUuid2 => $product2, + default => null, + }; + }); + + $result = $this->service->enrichRelationships( + $data, + 'invoices', + [], + 123, + 'org-1', + 'https://api.example.com' + ); + + $this->assertArrayHasKey('data.items.product_id', $result); + $this->assertCount(2, $result['data.items.product_id']['data']); + } + + public function testEnrichRelationshipsMatchesPathWithDataPrefix(): void + { + $this->schemaService->expects($this->once()) + ->method('getRefersToDefinitions') + ->willReturn([ + new RefersToDefinition('data.author_id', 'users', 'uuid', OnDeleteBehavior::Restrict), + ]); + + // Request with 'data.' prefix should match + $result = $this->service->enrichRelationships( + ['author_id' => '550e8400-e29b-41d4-a716-446655440000'], + 'notes', + ['data.author_id'], + 123, + 'org-1', + 'https://api.example.com' + ); + + // The path should be included (not filtered out) + $this->assertArrayHasKey('data.author_id', $result); + } + + public function testEnrichRelationshipsMatchesPathWithoutDataPrefix(): void + { + $this->schemaService->expects($this->once()) + ->method('getRefersToDefinitions') + ->willReturn([ + new RefersToDefinition('data.author_id', 'users', 'uuid', OnDeleteBehavior::Restrict), + ]); + + // Request without 'data.' prefix should also match + $result = $this->service->enrichRelationships( + ['author_id' => '550e8400-e29b-41d4-a716-446655440000'], + 'notes', + ['author_id'], // Without data. prefix + 123, + 'org-1', + 'https://api.example.com' + ); + + $this->assertArrayHasKey('data.author_id', $result); + } + + public function testEnrichRelationshipsDeduplicatesUuidsInArray(): void + { + $uuid = '550e8400-e29b-41d4-a716-446655440001'; + $data = ['tag_ids' => [$uuid, $uuid, $uuid]]; // Same UUID repeated + + $tag = MetaObjectFactory::create(objectType: 'tags', organizationId: 'org-1', projectId: 123); + $tag->setUuid(\Ramsey\Uuid\Uuid::fromString($uuid)); + + $definition = new RefersToDefinition( + 'data.tag_ids', + 'tags', + 'uuid', + OnDeleteBehavior::Restrict + ); + + $this->schemaService->expects($this->once()) + ->method('getRefersToDefinitions') + ->willReturn([$definition]); + + // Should only be called once due to deduplication + $this->metaObjectRepository->expects($this->once()) + ->method('findByUuidString') + ->with($uuid) + ->willReturn($tag); + + $result = $this->service->enrichRelationships( + $data, + 'articles', + [], + 123, + 'org-1', + 'https://api.example.com' + ); + + $this->assertCount(1, $result['data.tag_ids']['data']); + } +} diff --git a/tests/Unit/Service/SchemaServiceTest.php b/tests/Unit/Service/SchemaServiceTest.php index 07d9228..830f349 100644 --- a/tests/Unit/Service/SchemaServiceTest.php +++ b/tests/Unit/Service/SchemaServiceTest.php @@ -7,6 +7,7 @@ use Bareapi\Entity\Schema; use Bareapi\Exception\SchemaNotFoundException; use Bareapi\Repository\SchemaRepositoryInterface; +use Bareapi\Schema\RefersToParser; use Bareapi\Service\SchemaService; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -23,7 +24,8 @@ final class SchemaServiceTest extends TestCase protected function setUp(): void { $this->repository = $this->createMock(SchemaRepositoryInterface::class); - $this->service = new SchemaService($this->repository); + $refersToParser = new RefersToParser(); + $this->service = new SchemaService($this->repository, $refersToParser); } public function testGetDefaultSchemaReturnsSchema(): void diff --git a/tests/Unit/Validation/ReferenceValidatorTest.php b/tests/Unit/Validation/ReferenceValidatorTest.php new file mode 100644 index 0000000..00bc4d3 --- /dev/null +++ b/tests/Unit/Validation/ReferenceValidatorTest.php @@ -0,0 +1,349 @@ +metaObjectRepository = $this->createMock(MetaObjectRepositoryInterface::class); + $this->schemaService = $this->createMock(SchemaServiceInterface::class); + $this->validator = new ReferenceValidator( + $this->metaObjectRepository, + $this->schemaService + ); + } + + public function testValidationPassesWithNoRefersToDefinitions(): void + { + $this->schemaService->method('getRefersToDefinitions')->willReturn([]); + + $this->validator->validate( + [ + 'tagId' => 'some-uuid', + ], + 'tag-binding', + 1, + 'org-123' + ); + + // No exception means validation passed + $this->assertTrue(true); + } + + public function testValidationPassesWithValidReference(): void + { + $definition = new RefersToDefinition( + path: 'data.tagId', + targetType: 'tag', + targetField: 'uuid', + onDelete: OnDeleteBehavior::Restrict + ); + + $this->schemaService->method('getRefersToDefinitions')->willReturn([$definition]); + + $targetUuid = '01234567-89ab-cdef-0123-456789abcdef'; + + $this->metaObjectRepository->method('checkUuidsExist') + ->with([$targetUuid], 'tag', 1, 'org-123') + ->willReturn([ + $targetUuid => true, + ]); + + $this->validator->validate( + [ + 'tagId' => $targetUuid, + ], + 'tag-binding', + 1, + 'org-123' + ); + + // No exception means validation passed + $this->assertTrue(true); + } + + public function testValidationFailsWithMissingReference(): void + { + $definition = new RefersToDefinition( + path: 'data.tagId', + targetType: 'tag', + targetField: 'uuid', + onDelete: OnDeleteBehavior::Restrict + ); + + $this->schemaService->method('getRefersToDefinitions')->willReturn([$definition]); + + $targetUuid = '01234567-89ab-cdef-0123-456789abcdef'; + + $this->metaObjectRepository->method('checkUuidsExist') + ->with([$targetUuid], 'tag', 1, 'org-123') + ->willReturn([ + $targetUuid => false, + ]); + + $this->expectException(ReferenceValidationException::class); + + $this->validator->validate( + [ + 'tagId' => $targetUuid, + ], + 'tag-binding', + 1, + 'org-123' + ); + } + + public function testExceptionContainsReferenceInfo(): void + { + $definition = new RefersToDefinition( + path: 'data.tagId', + targetType: 'tag', + targetField: 'uuid', + onDelete: OnDeleteBehavior::Restrict + ); + + $this->schemaService->method('getRefersToDefinitions')->willReturn([$definition]); + + $targetUuid = '01234567-89ab-cdef-0123-456789abcdef'; + + $this->metaObjectRepository->method('checkUuidsExist') + ->willReturn([ + $targetUuid => false, + ]); + + try { + $this->validator->validate( + [ + 'tagId' => $targetUuid, + ], + 'tag-binding', + 1, + 'org-123' + ); + $this->fail('Expected ReferenceValidationException'); + } catch (ReferenceValidationException $e) { + $ref = $e->getRef(); + $this->assertSame('data.tagId', $ref['path']); + $this->assertSame('tag', $ref['type']); + $this->assertSame($targetUuid, $ref['uuid']); + } + } + + public function testValidationIgnoresEmptyValues(): void + { + $definition = new RefersToDefinition( + path: 'data.tagId', + targetType: 'tag', + targetField: 'uuid', + onDelete: OnDeleteBehavior::Restrict + ); + + $this->schemaService->method('getRefersToDefinitions')->willReturn([$definition]); + + // Should not be called since value is empty + $this->metaObjectRepository->expects($this->never())->method('checkUuidsExist'); + + $this->validator->validate( + [ + 'tagId' => '', + ], + 'tag-binding', + 1, + 'org-123' + ); + + // Also test with null + $this->validator->validate( + [ + 'tagId' => null, + ], + 'tag-binding', + 1, + 'org-123' + ); + + // And missing key + $this->validator->validate( + [ + 'otherField' => 'value', + ], + 'tag-binding', + 1, + 'org-123' + ); + + $this->assertTrue(true); + } + + public function testValidationHandlesArrayOfReferences(): void + { + $definition = new RefersToDefinition( + path: 'data.tagIds', + targetType: 'tag', + targetField: 'uuid', + onDelete: OnDeleteBehavior::Restrict + ); + + $this->schemaService->method('getRefersToDefinitions')->willReturn([$definition]); + + $uuid1 = '01234567-89ab-cdef-0123-456789abcdef'; + $uuid2 = 'fedcba98-7654-3210-fedc-ba9876543210'; + + $this->metaObjectRepository->method('checkUuidsExist') + ->willReturn([ + $uuid1 => true, + $uuid2 => true, + ]); + + $this->validator->validate( + [ + 'tagIds' => [$uuid1, $uuid2], + ], + 'tag-binding', + 1, + 'org-123' + ); + + $this->assertTrue(true); + } + + public function testValidationHandlesNestedObjectReference(): void + { + $definition = new RefersToDefinition( + path: 'data.metadata.tagId', + targetType: 'tag', + targetField: 'uuid', + onDelete: OnDeleteBehavior::Restrict + ); + + $this->schemaService->method('getRefersToDefinitions')->willReturn([$definition]); + + $targetUuid = '01234567-89ab-cdef-0123-456789abcdef'; + + $this->metaObjectRepository->method('checkUuidsExist') + ->willReturn([ + $targetUuid => true, + ]); + + $this->validator->validate( + [ + 'metadata' => [ + 'tagId' => $targetUuid, + ], + ], + 'some-type', + 1, + 'org-123' + ); + + $this->assertTrue(true); + } + + public function testValidationHandlesArrayOfObjectsWithReferences(): void + { + $definition = new RefersToDefinition( + path: 'data.items.tagId', + targetType: 'tag', + targetField: 'uuid', + onDelete: OnDeleteBehavior::Restrict + ); + + $this->schemaService->method('getRefersToDefinitions')->willReturn([$definition]); + + $uuid1 = '01234567-89ab-cdef-0123-456789abcdef'; + $uuid2 = 'fedcba98-7654-3210-fedc-ba9876543210'; + + $this->metaObjectRepository->method('checkUuidsExist') + ->willReturn([ + $uuid1 => true, + $uuid2 => true, + ]); + + $this->validator->validate( + [ + 'items' => [ + [ + 'tagId' => $uuid1, + ], + [ + 'tagId' => $uuid2, + ], + ], + ], + 'some-type', + 1, + 'org-123' + ); + + $this->assertTrue(true); + } + + public function testValidationWithMultipleRefersToDefinitions(): void + { + $tagDefinition = new RefersToDefinition( + path: 'data.tagId', + targetType: 'tag', + targetField: 'uuid', + onDelete: OnDeleteBehavior::Restrict + ); + + $categoryDefinition = new RefersToDefinition( + path: 'data.categoryId', + targetType: 'category', + targetField: 'uuid', + onDelete: OnDeleteBehavior::Cascade + ); + + $this->schemaService->method('getRefersToDefinitions') + ->willReturn([$tagDefinition, $categoryDefinition]); + + $tagUuid = '01234567-89ab-cdef-0123-456789abcdef'; + $categoryUuid = 'fedcba98-7654-3210-fedc-ba9876543210'; + + $this->metaObjectRepository->method('checkUuidsExist') + ->willReturnCallback(function (array $uuids, string $type) use ($tagUuid, $categoryUuid) { + if ($type === 'tag') { + return [ + $tagUuid => true, + ]; + } + if ($type === 'category') { + return [ + $categoryUuid => true, + ]; + } + + return []; + }); + + $this->validator->validate( + [ + 'tagId' => $tagUuid, + 'categoryId' => $categoryUuid, + ], + 'some-type', + 1, + 'org-123' + ); + + $this->assertTrue(true); + } +}