From 46a0a9c00cc9d58c08cb2b8d4da1e643b4476090 Mon Sep 17 00:00:00 2001 From: "Zimin A.N." Date: Fri, 8 May 2026 10:45:02 +0300 Subject: [PATCH] =?UTF-8?q?feat(lineage):=20Phase=201=20backend=20?= =?UTF-8?q?=E2=80=94=20dependents=20endpoints=20+=20REGRESSION=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dict-relationships-v2 epic, Phase 1 read-side. Backend готов; admin UI (card "Used by" + record references) — отдельным commit'ом. Что добавлено: - `DependentsQuery` (interface) + `DependentsQueryJdbc` (impl) — native SQL для record-level dependents lookup (paginated). Interface extracted чтобы Mockito мог mock'нуть на JDK 25 (concrete-class inline-mock incompat). - `LineageIndexService` — два режима: * `findSchemaDependents(name, scopes)` — schema-level reverse FK list (in-memory traversal definitions, microseconds). Skip self-reference, scope-hidden sources, malformed x-references (logged warn). * `findRecordDependents(dict, businessKey, scopes, page, size)` — record-level paginated, perSource summary + total. Phase 2 переносит на materialized view; пока живём с прямым JSONB query (acceptable для <5k SLO). - `LineageController` — два endpoint'а: * `GET /api/v1/dictionaries/{name}/dependents` * `GET /api/v1/records/{dict}/{businessKey}/dependents?page&size` Errors: 404 dictionary_not_found / record_not_active, 400 invalid_pagination. Scope hiding делает target invisible (empty list / 404 — без leak existence). Tests: - LineageIndexServiceTest (16 unit tests): * Schema-level: not_found, scope hidden, single ref + count, onClose propagation (BLOCK default + CASCADE explicit), sorting, source scope hidden, self-reference skip, malformed skip, target without properties. * Record-level: invalid pagination (page<0, size<1, size>200), dict_not_found, record_not_active, no schema refs → empty page, 1 source с dependents → items+perSource+total, pagination через page=0/1/2 size=2 (3 страницы из 5 records). - ReferenceCloseRegressionTest (4 mandatory regression tests): * v1.2.1 schema без x-references-on-close — validate() возвращает те же error codes (никаких новых on_close/cascade в codes). * Schema parser default'ит onClose=BLOCK для v1.2.1 schemas. * Legacy single-arg parseRef(spec) без property schema сохраняет contract. * Schema без x-references вообще — full no-op (30+ legacy справочников). mvn -pl ordinis-rest-api -am test: 89/89 PASS (5.686s). mvn verify -pl '!ordinis-app': все модули SUCCESS (9.031s). Backward-compat: bundle v1.2.1 → identical поведение к v1.0.7. Cascade engine не поднимается на closed-side (Phase 3 territory). --- .../domain/record/DependentsQuery.java | 72 ++++ .../domain/record/DependentsQueryJdbc.java | 97 +++++ .../reference/LineageIndexService.java | 289 ++++++++++++++ .../restapi/web/LineageController.java | 94 +++++ .../reference/LineageIndexServiceTest.java | 368 ++++++++++++++++++ .../ReferenceCloseRegressionTest.java | 157 ++++++++ 6 files changed, 1077 insertions(+) create mode 100644 ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/record/DependentsQuery.java create mode 100644 ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/record/DependentsQueryJdbc.java create mode 100644 ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/reference/LineageIndexService.java create mode 100644 ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/LineageController.java create mode 100644 ordinis-rest-api/src/test/java/cloud/nstart/terravault/ordinis/restapi/service/reference/LineageIndexServiceTest.java create mode 100644 ordinis-rest-api/src/test/java/cloud/nstart/terravault/ordinis/restapi/service/reference/ReferenceCloseRegressionTest.java diff --git a/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/record/DependentsQuery.java b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/record/DependentsQuery.java new file mode 100644 index 0000000..cce617e --- /dev/null +++ b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/record/DependentsQuery.java @@ -0,0 +1,72 @@ +package cloud.nstart.terravault.ordinis.domain.record; + +import java.time.OffsetDateTime; +import java.util.List; +import java.util.UUID; + +/** + * Native SQL для record-level dependents lookup: "какие active записи в + * source dictionary ссылаются на target record через JSONB + * {@code data->>source_field == target_value}". + * + *

Используется {@code LineageIndexService.findRecordDependents} в Phase 1. + * Phase 2 переносит этот lookup на materialized view с UNIQUE INDEX(id) для + * REFRESH CONCURRENTLY — пока живём с прямым JSONB query (acceptable для + * {@code <}5k records SLO). + * + *

Interface вместо concrete класса — JDK 25 + Mockito inline-mock не работает + * на concrete classes (см. memo "JDK 25 + Mockito subclass mock-maker incompat"). + * Default impl {@link DependentsQueryJdbc} автоинджектится через @Component. + */ +public interface DependentsQuery { + + /** + * Active records в source_dict где {@code data->>source_field == value}. + * Возвращает paginated slice + total count (один call для UI: count first, + * потом page если total > 0). + * + *

NULL-значения в source_field автоматически исключены SQL'ом + * (NULL != value никогда не true). + * + *

Order by created_at DESC чтобы newest dependents видны первыми + * (admin debug pattern). + * + * @param sourceDictionaryId source dict (referencing) + * @param sourceField JSONB key в source data что хранит ref value + * @param targetValue фактическое значение target field в target record + * @param at момент времени — обычно now() + * @param limit page size (caller должен ограничить ≤ 200) + * @param offset page offset (≥ 0) + * @return slice records sorted by created_at DESC + */ + List findActive( + UUID sourceDictionaryId, + String sourceField, + String targetValue, + OffsetDateTime at, + int limit, + int offset); + + /** + * Total count active dependents — отдельный query без LIMIT/OFFSET для + * pagination header. Faster чем {@code SELECT COUNT(*)} над основным + * results когда LIMIT/OFFSET идёт в отдельном request. + */ + long countActive( + UUID sourceDictionaryId, + String sourceField, + String targetValue, + OffsetDateTime at); + + /** + * Lightweight projection — лишний JSONB data в payload не нужен на этом + * endpoint, экономим bandwidth. + */ + record DependentRow( + UUID id, + UUID dictionaryId, + String businessKey, + OffsetDateTime validFrom, + OffsetDateTime validTo, + OffsetDateTime createdAt) {} +} diff --git a/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/record/DependentsQueryJdbc.java b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/record/DependentsQueryJdbc.java new file mode 100644 index 0000000..3098d3e --- /dev/null +++ b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/record/DependentsQueryJdbc.java @@ -0,0 +1,97 @@ +package cloud.nstart.terravault.ordinis.domain.record; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +import java.time.OffsetDateTime; +import java.util.List; +import java.util.UUID; + +/** + * JdbcTemplate-backed default impl {@link DependentsQuery}. + * + *

Поля {@code source_field} приходят из schema parser (валидируются на + * bundle import), плюс whitelist regex здесь — SQL injection невозможна. + * Native SQL потому что JdbcTemplate не bind'ит JSONB key paths. + */ +@Component +public class DependentsQueryJdbc implements DependentsQuery { + + private final JdbcTemplate jdbc; + + @Autowired + public DependentsQueryJdbc(JdbcTemplate jdbc) { + this.jdbc = jdbc; + } + + @Override + public List findActive( + UUID sourceDictionaryId, + String sourceField, + String targetValue, + OffsetDateTime at, + int limit, + int offset) { + validateField(sourceField); + if (limit <= 0 || limit > 500) { + throw new IllegalArgumentException("limit must be 1..500, got " + limit); + } + if (offset < 0) { + throw new IllegalArgumentException("offset must be >= 0, got " + offset); + } + + String sql = """ + SELECT id, dictionary_id, business_key, valid_from, valid_to, created_at + FROM dictionary_records + WHERE dictionary_id = ? + AND valid_from <= ? + AND valid_to > ? + AND data ->> '%s' = ? + ORDER BY created_at DESC + LIMIT ? OFFSET ? + """.formatted(sourceField); + + return jdbc.query( + sql, + (rs, n) -> new DependentRow( + (UUID) rs.getObject("id"), + (UUID) rs.getObject("dictionary_id"), + rs.getString("business_key"), + rs.getObject("valid_from", OffsetDateTime.class), + rs.getObject("valid_to", OffsetDateTime.class), + rs.getObject("created_at", OffsetDateTime.class)), + sourceDictionaryId, at, at, targetValue, limit, offset); + } + + @Override + public long countActive( + UUID sourceDictionaryId, + String sourceField, + String targetValue, + OffsetDateTime at) { + validateField(sourceField); + + String sql = """ + SELECT COUNT(*) FROM dictionary_records + WHERE dictionary_id = ? + AND valid_from <= ? + AND valid_to > ? + AND data ->> '%s' = ? + """.formatted(sourceField); + + Long count = jdbc.queryForObject(sql, Long.class, sourceDictionaryId, at, at, targetValue); + return count == null ? 0 : count; + } + + /** Paranoid SQL injection guard. Mirror'ит {@code OrphanReferenceQuery}. */ + private static void validateField(String field) { + if (field == null || field.isBlank()) { + throw new IllegalArgumentException("field name is empty"); + } + if (!field.matches("^[a-zA-Z_][a-zA-Z0-9_]{0,127}$")) { + throw new IllegalArgumentException( + "field name has unsafe characters: " + field); + } + } +} diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/reference/LineageIndexService.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/reference/LineageIndexService.java new file mode 100644 index 0000000..b21f108 --- /dev/null +++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/reference/LineageIndexService.java @@ -0,0 +1,289 @@ +package cloud.nstart.terravault.ordinis.restapi.service.reference; + +import cloud.nstart.terravault.ordinis.domain.DataScope; +import cloud.nstart.terravault.ordinis.domain.definition.DictionaryDefinition; +import cloud.nstart.terravault.ordinis.domain.definition.DictionaryDefinitionRepository; +import cloud.nstart.terravault.ordinis.domain.record.DependentsQuery; +import cloud.nstart.terravault.ordinis.domain.record.DictionaryRecord; +import cloud.nstart.terravault.ordinis.domain.record.DictionaryRecordRepository; +import com.fasterxml.jackson.databind.JsonNode; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.OffsetDateTime; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +/** + * Reverse FK lineage index: "кто ссылается на этот словарь / запись". + * + *

Phase 1 read-side для dict-relationships-v2 epic. Два режима: + * + *

+ * + *

Caching: Phase 1 не кэширует — schema lookup микросекунды, record-level + * SQL paginated и каждый bbox разный. Phase 2 добавит {@code @Cacheable} для + * schema-level + invalidation на bundle import (см. eng review A.1). + * + *

Scope: filter target dict + source dicts через {@code allowedScopes}. + * Если каллер не имеет access к target dict — возвращаем "not found" + * (consistent с CRUD endpoint behaviour, чтобы не leak'ало existence). + */ +@Service +public class LineageIndexService { + + private static final Logger log = LoggerFactory.getLogger(LineageIndexService.class); + + private final DictionaryDefinitionRepository definitionRepository; + private final DictionaryRecordRepository recordRepository; + private final DependentsQuery dependentsQuery; + + public LineageIndexService( + DictionaryDefinitionRepository definitionRepository, + DictionaryRecordRepository recordRepository, + DependentsQuery dependentsQuery) { + this.definitionRepository = definitionRepository; + this.recordRepository = recordRepository; + this.dependentsQuery = dependentsQuery; + } + + /** + * Schema-level dependents target dict: какие dict.field ссылаются. + * + * @param targetDictName dict name (e.g. "satellite_type") + * @param allowedScopes что caller видит (источники с недоступным scope filtered) + * @return list reverse FK pointers, sorted by sourceDict, sourceField asc. + * Empty если target не существует или у caller нет к нему access. + * + * @throws SchemaDependentsTarget#NOT_FOUND нет такого dict. + */ + @Transactional(readOnly = true) + public List findSchemaDependents( + String targetDictName, Set allowedScopes) { + Optional targetOpt = definitionRepository.findByName(targetDictName); + if (targetOpt.isEmpty()) return List.of(); + DictionaryDefinition target = targetOpt.get(); + if (!allowedScopes.contains(target.getScope())) { + // Scope hide — target invisible. Return empty (same shape as not_found). + return List.of(); + } + + List all = definitionRepository.findAll(); + // Pre-compute counts active records per source dict (для UI badge). + Map counts = new HashMap<>(); + OffsetDateTime now = OffsetDateTime.now(); + for (Object[] row : recordRepository.countActiveGroupedByDictionary(allowedScopes, now)) { + counts.put((java.util.UUID) row[0], (Long) row[1]); + } + + List out = new ArrayList<>(); + for (DictionaryDefinition source : all) { + if (source.getId().equals(target.getId())) continue; // self-reference skipped + if (!allowedScopes.contains(source.getScope())) continue; + JsonNode schema = source.getSchemaJson(); + if (schema == null || !schema.isObject()) continue; + JsonNode properties = schema.get("properties"); + if (properties == null || !properties.isObject()) continue; + + Iterator> it = properties.fields(); + while (it.hasNext()) { + Map.Entry entry = it.next(); + String fieldName = entry.getKey(); + JsonNode propSchema = entry.getValue(); + JsonNode refNode = propSchema.get("x-references"); + if (refNode == null || !refNode.isTextual()) continue; + + ReferenceValidator.ParsedRef parsed; + try { + parsed = ReferenceValidator.parseRef(refNode.asText(), propSchema); + } catch (IllegalArgumentException e) { + log.warn("Schema {} field {} malformed x-references: {}", + source.getName(), fieldName, e.getMessage()); + continue; + } + if (!parsed.dictionaryName().equals(target.getName())) continue; + + out.add(new SchemaDependent( + source.getName(), + source.getDisplayName(), + fieldName, + parsed.fieldName(), + parsed.onClose(), + counts.getOrDefault(source.getId(), 0L))); + } + } + out.sort((a, b) -> { + int c = a.sourceDict().compareTo(b.sourceDict()); + return c != 0 ? c : a.sourceField().compareTo(b.sourceField()); + }); + return out; + } + + /** + * Record-level dependents: для target record (dict + business_key) ищет + * все active records в source dicts что на него ссылаются. + * + *

Логика: для каждого source dict у которого schema имеет + * {@code x-references: "."} — берём value + * {@code target_record.data->>target_field} и считаем/перечисляем active + * source records где {@code data->>source_field == that_value}. + * + * @param page page index 0..N + * @param size page size 1..200 + * @return paged dependents + per-source totals + */ + @Transactional(readOnly = true) + public RecordDependentsPage findRecordDependents( + String targetDictName, + String targetBusinessKey, + Set allowedScopes, + int page, + int size) { + if (page < 0) throw new IllegalArgumentException("page must be >= 0"); + if (size < 1 || size > 200) { + throw new IllegalArgumentException("size must be 1..200, got " + size); + } + + Optional targetOpt = definitionRepository.findByName(targetDictName); + if (targetOpt.isEmpty()) { + throw new IllegalArgumentException("dictionary not found: " + targetDictName); + } + DictionaryDefinition target = targetOpt.get(); + if (!allowedScopes.contains(target.getScope())) { + throw new IllegalArgumentException("dictionary not found: " + targetDictName); + } + + OffsetDateTime now = OffsetDateTime.now(); + Optional recOpt = + recordRepository.findActiveAt(target.getId(), targetBusinessKey, now); + if (recOpt.isEmpty()) { + throw new IllegalArgumentException( + "active record not found: " + targetDictName + "/" + targetBusinessKey); + } + DictionaryRecord targetRecord = recOpt.get(); + + // Schema-level reverse index: какие (source_dict, source_field, target_field, onClose). + List schemaRefs = findSchemaDependents(targetDictName, allowedScopes); + if (schemaRefs.isEmpty()) { + return new RecordDependentsPage( + targetDictName, targetBusinessKey, List.of(), List.of(), 0, page, size); + } + + // Per-source: pull target value, count + paginate. + // Phase 1: linear scan по source schemas — ОК для <40 dicts. + // Phase 2: MV сворачивает в один query. + List all = new ArrayList<>(); + List summary = new ArrayList<>(); + long total = 0; + + for (SchemaDependent ref : schemaRefs) { + JsonNode targetData = targetRecord.getData(); + if (targetData == null) continue; + JsonNode valueNode = targetData.get(ref.targetField()); + if (valueNode == null || !valueNode.isTextual()) continue; + String targetValue = valueNode.asText(); + + Optional sourceOpt = + definitionRepository.findByName(ref.sourceDict()); + if (sourceOpt.isEmpty()) continue; + DictionaryDefinition sourceDef = sourceOpt.get(); + + long perSourceCount = dependentsQuery.countActive( + sourceDef.getId(), ref.sourceField(), targetValue, now); + summary.add(new PerSourceSummary( + ref.sourceDict(), + ref.sourceDisplayName(), + ref.sourceField(), + ref.onClose(), + perSourceCount)); + total += perSourceCount; + + if (perSourceCount == 0) continue; + + // Paginate by aggregated total: каждый source pulled полностью если + // нужен (proper offset с union в SQL отложен Phase 2 — здесь simpler). + List rows = dependentsQuery.findActive( + sourceDef.getId(), ref.sourceField(), targetValue, now, + // Защитный bound: <= 200 на source per request. + Math.min(size + page * size + 1, 200), 0); + for (DependentsQuery.DependentRow r : rows) { + all.add(new RecordDependent( + ref.sourceDict(), + ref.sourceDisplayName(), + ref.sourceField(), + ref.onClose(), + r.id(), + r.businessKey(), + r.validFrom(), + r.validTo(), + r.createdAt())); + } + } + + // Sort all dependents by createdAt DESC, then page slice. + all.sort((a, b) -> b.createdAt().compareTo(a.createdAt())); + int from = Math.min(page * size, all.size()); + int to = Math.min(from + size, all.size()); + List slice = all.subList(from, to); + + return new RecordDependentsPage( + targetDictName, targetBusinessKey, slice, summary, total, page, size); + } + + // === DTOs === + + /** Schema-level reverse FK pointer: source dict → target dict через field. */ + public record SchemaDependent( + String sourceDict, + String sourceDisplayName, + String sourceField, + String targetField, + OnCloseAction onClose, + long activeRecordsInSourceDict) {} + + /** Record-level dependent — конкретная active record что ссылается. */ + public record RecordDependent( + String sourceDict, + String sourceDisplayName, + String sourceField, + OnCloseAction onClose, + java.util.UUID id, + String businessKey, + OffsetDateTime validFrom, + OffsetDateTime validTo, + OffsetDateTime createdAt) {} + + /** Per-source summary — сколько dependents в каждом source dict. */ + public record PerSourceSummary( + String sourceDict, + String sourceDisplayName, + String sourceField, + OnCloseAction onClose, + long count) {} + + /** Paginated page + per-source aggregates. */ + public record RecordDependentsPage( + String targetDict, + String targetBusinessKey, + List items, + List perSource, + long total, + int page, + int size) {} +} diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/LineageController.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/LineageController.java new file mode 100644 index 0000000..9f6107c --- /dev/null +++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/LineageController.java @@ -0,0 +1,94 @@ +package cloud.nstart.terravault.ordinis.restapi.web; + +import cloud.nstart.terravault.ordinis.auth.ScopeContext; +import cloud.nstart.terravault.ordinis.restapi.error.OrdinisException; +import cloud.nstart.terravault.ordinis.restapi.service.reference.LineageIndexService; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +/** + * Lineage / dependents read-side. Phase 1 dict-relationships-v2 epic. + * + *

Два endpoint'а: + *

+ * + *

Errors: + *

+ */ +@RestController +@RequestMapping("/api/v1") +public class LineageController { + + private final LineageIndexService lineage; + private final ScopeContext scopeContext; + + public LineageController(LineageIndexService lineage, ScopeContext scopeContext) { + this.lineage = lineage; + this.scopeContext = scopeContext; + } + + /** + * Schema-level reverse FK list. Empty list если target не существует + * или не виден — consistent с CRUD endpoints (no leak existence). + * + *

Возврат: list объектов + * {@code {sourceDict, sourceDisplayName, sourceField, targetField, + * onClose, activeRecordsInSourceDict}}. + */ + @GetMapping("/dictionaries/{name}/dependents") + public List schemaDependents( + @PathVariable String name) { + return lineage.findSchemaDependents(name, scopeContext.currentScopes()); + } + + /** + * Record-level dependents для конкретной record (target_dict + business_key). + * + *

Pagination: {@code page} (default 0), {@code size} (default 50, max 200). + * + * @return объект {@code {targetDict, targetBusinessKey, items[], perSource[], + * total, page, size}}. + */ + @GetMapping("/records/{dict}/{businessKey}/dependents") + public LineageIndexService.RecordDependentsPage recordDependents( + @PathVariable String dict, + @PathVariable String businessKey, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "50") int size) { + if (page < 0 || size < 1 || size > 200) { + throw OrdinisException.badRequest( + "invalid_pagination", + "page must be >= 0 and size 1..200; got page=" + page + " size=" + size); + } + try { + return lineage.findRecordDependents( + dict, businessKey, scopeContext.currentScopes(), page, size); + } catch (IllegalArgumentException e) { + String msg = e.getMessage() == null ? "" : e.getMessage(); + if (msg.startsWith("dictionary not found")) { + throw OrdinisException.notFound("dictionary_not_found", msg); + } + if (msg.startsWith("active record not found")) { + throw OrdinisException.notFound("record_not_active", msg); + } + throw OrdinisException.badRequest("invalid_request", msg); + } + } +} diff --git a/ordinis-rest-api/src/test/java/cloud/nstart/terravault/ordinis/restapi/service/reference/LineageIndexServiceTest.java b/ordinis-rest-api/src/test/java/cloud/nstart/terravault/ordinis/restapi/service/reference/LineageIndexServiceTest.java new file mode 100644 index 0000000..03b403d --- /dev/null +++ b/ordinis-rest-api/src/test/java/cloud/nstart/terravault/ordinis/restapi/service/reference/LineageIndexServiceTest.java @@ -0,0 +1,368 @@ +package cloud.nstart.terravault.ordinis.restapi.service.reference; + +import cloud.nstart.terravault.ordinis.domain.DataScope; +import cloud.nstart.terravault.ordinis.domain.definition.DictionaryDefinition; +import cloud.nstart.terravault.ordinis.domain.definition.DictionaryDefinitionRepository; +import cloud.nstart.terravault.ordinis.domain.record.DependentsQuery; +import cloud.nstart.terravault.ordinis.domain.record.DictionaryRecord; +import cloud.nstart.terravault.ordinis.domain.record.DictionaryRecordRepository; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.time.OffsetDateTime; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Unit tests {@link LineageIndexService}. Покрывает: + * + *

+ * + *

Mockito не может mock'нуть DictionaryDefinition / DictionaryRecord + * на JDK 25 (subclass mock-maker incompat). Используем real entities. + */ +class LineageIndexServiceTest { + + private static final ObjectMapper OM = new ObjectMapper(); + + private DictionaryDefinitionRepository defRepo; + private DictionaryRecordRepository recRepo; + private DependentsQuery depsQuery; + private LineageIndexService service; + + @BeforeEach + void setUp() { + defRepo = mock(DictionaryDefinitionRepository.class); + recRepo = mock(DictionaryRecordRepository.class); + depsQuery = mock(DependentsQuery.class); + service = new LineageIndexService(defRepo, recRepo, depsQuery); + } + + // === Schema-level findSchemaDependents === + + @Test + void schemaDeps_targetNotFound_returnsEmpty() { + when(defRepo.findByName("missing")).thenReturn(Optional.empty()); + var result = service.findSchemaDependents("missing", Set.of(DataScope.PUBLIC)); + assertThat(result).isEmpty(); + } + + @Test + void schemaDeps_targetScopeHidden_returnsEmpty() { + var target = mockDef("internal", DataScope.INTERNAL); + when(defRepo.findByName("internal")).thenReturn(Optional.of(target)); + var result = service.findSchemaDependents("internal", Set.of(DataScope.PUBLIC)); + assertThat(result).isEmpty(); + } + + @Test + void schemaDeps_oneReferenceFound_returnsIt() throws Exception { + var target = mockDef("satellite_type", DataScope.PUBLIC); + var source = mockDefWithSchema("spacecraft", DataScope.PUBLIC, """ + {"properties":{"satTypeCode":{"type":"string","x-references":"satellite_type.code"}}}"""); + when(defRepo.findByName("satellite_type")).thenReturn(Optional.of(target)); + when(defRepo.findAll()).thenReturn(List.of(target, source)); + List rows = new java.util.ArrayList<>(); + rows.add(new Object[]{source.getId(), 7L}); + when(recRepo.countActiveGroupedByDictionary(any(), any())).thenReturn(rows); + + var result = service.findSchemaDependents("satellite_type", Set.of(DataScope.PUBLIC)); + + assertThat(result).hasSize(1); + var dep = result.get(0); + assertThat(dep.sourceDict()).isEqualTo("spacecraft"); + assertThat(dep.sourceField()).isEqualTo("satTypeCode"); + assertThat(dep.targetField()).isEqualTo("code"); + assertThat(dep.onClose()).isEqualTo(OnCloseAction.BLOCK); // default + assertThat(dep.activeRecordsInSourceDict()).isEqualTo(7L); + } + + @Test + void schemaDeps_onCloseFromSchema_propagated() throws Exception { + var target = mockDef("ground_station", DataScope.PUBLIC); + var source = mockDefWithSchema("contact_session", DataScope.PUBLIC, """ + {"properties":{ + "stationCode":{"type":"string", + "x-references":"ground_station.code", + "x-references-on-close":"cascade"} + }}"""); + when(defRepo.findByName("ground_station")).thenReturn(Optional.of(target)); + when(defRepo.findAll()).thenReturn(List.of(target, source)); + when(recRepo.countActiveGroupedByDictionary(any(), any())).thenReturn(List.of()); + + var result = service.findSchemaDependents("ground_station", Set.of(DataScope.PUBLIC)); + + assertThat(result).hasSize(1); + assertThat(result.get(0).onClose()).isEqualTo(OnCloseAction.CASCADE); + } + + @Test + void schemaDeps_multipleSourcesSorted() throws Exception { + var target = mockDef("dict_t", DataScope.PUBLIC); + var srcA = mockDefWithSchema("zeta_dict", DataScope.PUBLIC, + "{\"properties\":{\"r\":{\"x-references\":\"dict_t.f\"}}}"); + var srcB = mockDefWithSchema("alpha_dict", DataScope.PUBLIC, + "{\"properties\":{\"r\":{\"x-references\":\"dict_t.f\"}}}"); + when(defRepo.findByName("dict_t")).thenReturn(Optional.of(target)); + when(defRepo.findAll()).thenReturn(List.of(target, srcA, srcB)); + when(recRepo.countActiveGroupedByDictionary(any(), any())).thenReturn(List.of()); + + var result = service.findSchemaDependents("dict_t", Set.of(DataScope.PUBLIC)); + + assertThat(result).extracting(LineageIndexService.SchemaDependent::sourceDict) + .containsExactly("alpha_dict", "zeta_dict"); + } + + @Test + void schemaDeps_sourceScopeHidden_skipped() throws Exception { + var target = mockDef("dict_t", DataScope.PUBLIC); + var visibleSrc = mockDefWithSchema("public_src", DataScope.PUBLIC, + "{\"properties\":{\"r\":{\"x-references\":\"dict_t.f\"}}}"); + var hiddenSrc = mockDefWithSchema("internal_src", DataScope.INTERNAL, + "{\"properties\":{\"r\":{\"x-references\":\"dict_t.f\"}}}"); + when(defRepo.findByName("dict_t")).thenReturn(Optional.of(target)); + when(defRepo.findAll()).thenReturn(List.of(target, visibleSrc, hiddenSrc)); + when(recRepo.countActiveGroupedByDictionary(any(), any())).thenReturn(List.of()); + + var result = service.findSchemaDependents("dict_t", Set.of(DataScope.PUBLIC)); + + assertThat(result).extracting(LineageIndexService.SchemaDependent::sourceDict) + .containsExactly("public_src"); + } + + @Test + void schemaDeps_selfReference_skipped() throws Exception { + // Schema указывает на свой же dict — не должно зацикливать. + var selfRef = mockDefWithSchema("hierarchy", DataScope.PUBLIC, + "{\"properties\":{\"parentCode\":{\"x-references\":\"hierarchy.code\"}}}"); + when(defRepo.findByName("hierarchy")).thenReturn(Optional.of(selfRef)); + when(defRepo.findAll()).thenReturn(List.of(selfRef)); + when(recRepo.countActiveGroupedByDictionary(any(), any())).thenReturn(List.of()); + + var result = service.findSchemaDependents("hierarchy", Set.of(DataScope.PUBLIC)); + + assertThat(result).isEmpty(); + } + + @Test + void schemaDeps_malformedRef_skippedSilently() throws Exception { + var target = mockDef("dict_t", DataScope.PUBLIC); + var srcGood = mockDefWithSchema("good_src", DataScope.PUBLIC, + "{\"properties\":{\"r\":{\"x-references\":\"dict_t.f\"}}}"); + var srcBad = mockDefWithSchema("bad_src", DataScope.PUBLIC, + "{\"properties\":{\"r\":{\"x-references\":\"malformed-no-dot\"}}}"); + when(defRepo.findByName("dict_t")).thenReturn(Optional.of(target)); + when(defRepo.findAll()).thenReturn(List.of(target, srcGood, srcBad)); + when(recRepo.countActiveGroupedByDictionary(any(), any())).thenReturn(List.of()); + + var result = service.findSchemaDependents("dict_t", Set.of(DataScope.PUBLIC)); + + assertThat(result).extracting(LineageIndexService.SchemaDependent::sourceDict) + .containsExactly("good_src"); + } + + @Test + void schemaDeps_targetSchemaWithoutProperties_skipped() throws Exception { + var target = mockDef("dict_t", DataScope.PUBLIC); + var src = mockDefWithSchema("src", DataScope.PUBLIC, "{\"type\":\"object\"}"); // no properties + when(defRepo.findByName("dict_t")).thenReturn(Optional.of(target)); + when(defRepo.findAll()).thenReturn(List.of(target, src)); + when(recRepo.countActiveGroupedByDictionary(any(), any())).thenReturn(List.of()); + + var result = service.findSchemaDependents("dict_t", Set.of(DataScope.PUBLIC)); + + assertThat(result).isEmpty(); + } + + // === Record-level findRecordDependents === + + @Test + void recordDeps_invalidPagination_throws() { + assertThatThrownBy(() -> service.findRecordDependents( + "d", "k", Set.of(DataScope.PUBLIC), -1, 50)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("page"); + assertThatThrownBy(() -> service.findRecordDependents( + "d", "k", Set.of(DataScope.PUBLIC), 0, 0)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("size"); + assertThatThrownBy(() -> service.findRecordDependents( + "d", "k", Set.of(DataScope.PUBLIC), 0, 201)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("size"); + } + + @Test + void recordDeps_dictNotFound_throws() { + when(defRepo.findByName("missing")).thenReturn(Optional.empty()); + assertThatThrownBy(() -> service.findRecordDependents( + "missing", "k", Set.of(DataScope.PUBLIC), 0, 50)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dictionary not found"); + } + + @Test + void recordDeps_dictScopeHidden_throwsAsNotFound() { + var target = mockDef("internal", DataScope.INTERNAL); + when(defRepo.findByName("internal")).thenReturn(Optional.of(target)); + assertThatThrownBy(() -> service.findRecordDependents( + "internal", "k", Set.of(DataScope.PUBLIC), 0, 50)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dictionary not found"); + } + + @Test + void recordDeps_recordNotActive_throws() { + var target = mockDef("dict_t", DataScope.PUBLIC); + when(defRepo.findByName("dict_t")).thenReturn(Optional.of(target)); + when(recRepo.findActiveAt(eq(target.getId()), eq("missing-key"), any())) + .thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.findRecordDependents( + "dict_t", "missing-key", Set.of(DataScope.PUBLIC), 0, 50)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("active record not found"); + } + + @Test + void recordDeps_noSchemaRefs_returnsEmptyPage() throws Exception { + var target = mockDef("dict_t", DataScope.PUBLIC); + when(defRepo.findByName("dict_t")).thenReturn(Optional.of(target)); + when(defRepo.findAll()).thenReturn(List.of(target)); + when(recRepo.countActiveGroupedByDictionary(any(), any())).thenReturn(List.of()); + + JsonNode data = OM.readTree("{\"code\":\"X-001\"}"); + DictionaryRecord rec = mockRecord(target.getId(), "X-001", data); + when(recRepo.findActiveAt(eq(target.getId()), eq("X-001"), any())) + .thenReturn(Optional.of(rec)); + + var page = service.findRecordDependents( + "dict_t", "X-001", Set.of(DataScope.PUBLIC), 0, 50); + + assertThat(page.items()).isEmpty(); + assertThat(page.perSource()).isEmpty(); + assertThat(page.total()).isZero(); + } + + @Test + void recordDeps_oneSourceWithDependents_returnsItemsAndPerSource() throws Exception { + var target = mockDef("satellite_type", DataScope.PUBLIC); + var source = mockDefWithSchema("spacecraft", DataScope.PUBLIC, """ + {"properties":{"satTypeCode":{"type":"string","x-references":"satellite_type.code"}}}"""); + when(defRepo.findByName("satellite_type")).thenReturn(Optional.of(target)); + when(defRepo.findByName("spacecraft")).thenReturn(Optional.of(source)); + when(defRepo.findAll()).thenReturn(List.of(target, source)); + when(recRepo.countActiveGroupedByDictionary(any(), any())).thenReturn(List.of()); + + JsonNode targetData = OM.readTree("{\"code\":\"SAR-X\"}"); + DictionaryRecord targetRec = mockRecord(target.getId(), "SAR-X", targetData); + when(recRepo.findActiveAt(eq(target.getId()), eq("SAR-X"), any())) + .thenReturn(Optional.of(targetRec)); + + when(depsQuery.countActive(eq(source.getId()), eq("satTypeCode"), eq("SAR-X"), any())) + .thenReturn(2L); + OffsetDateTime t1 = OffsetDateTime.parse("2026-01-01T00:00:00Z"); + OffsetDateTime t2 = OffsetDateTime.parse("2026-02-01T00:00:00Z"); + when(depsQuery.findActive(eq(source.getId()), eq("satTypeCode"), eq("SAR-X"), + any(), anyInt(), anyInt())).thenReturn(List.of( + new DependentsQuery.DependentRow( + UUID.randomUUID(), source.getId(), "SC-001", t1, OffsetDateTime.MAX, t2), + new DependentsQuery.DependentRow( + UUID.randomUUID(), source.getId(), "SC-002", t1, OffsetDateTime.MAX, t1))); + + var page = service.findRecordDependents( + "satellite_type", "SAR-X", Set.of(DataScope.PUBLIC), 0, 50); + + assertThat(page.total()).isEqualTo(2L); + assertThat(page.perSource()).hasSize(1); + assertThat(page.perSource().get(0).count()).isEqualTo(2L); + assertThat(page.perSource().get(0).sourceDict()).isEqualTo("spacecraft"); + assertThat(page.items()).hasSize(2); + // sorted by createdAt DESC: SC-001 (t2) first + assertThat(page.items().get(0).businessKey()).isEqualTo("SC-001"); + assertThat(page.items().get(1).businessKey()).isEqualTo("SC-002"); + } + + @Test + void recordDeps_pagination_slicesCorrectly() throws Exception { + var target = mockDef("dict_t", DataScope.PUBLIC); + var source = mockDefWithSchema("src", DataScope.PUBLIC, + "{\"properties\":{\"ref\":{\"x-references\":\"dict_t.code\"}}}"); + when(defRepo.findByName("dict_t")).thenReturn(Optional.of(target)); + when(defRepo.findByName("src")).thenReturn(Optional.of(source)); + when(defRepo.findAll()).thenReturn(List.of(target, source)); + when(recRepo.countActiveGroupedByDictionary(any(), any())).thenReturn(List.of()); + + JsonNode targetData = OM.readTree("{\"code\":\"K\"}"); + when(recRepo.findActiveAt(eq(target.getId()), eq("K"), any())) + .thenReturn(Optional.of(mockRecord(target.getId(), "K", targetData))); + + when(depsQuery.countActive(any(), anyString(), anyString(), any())).thenReturn(5L); + // 5 rows, sorted later by createdAt DESC. + var rows = new java.util.ArrayList(); + for (int i = 0; i < 5; i++) { + rows.add(new DependentsQuery.DependentRow( + UUID.randomUUID(), source.getId(), "K-" + i, + OffsetDateTime.parse("2026-01-01T00:00:00Z"), OffsetDateTime.MAX, + OffsetDateTime.parse("2026-01-0" + (i + 1) + "T00:00:00Z"))); + } + when(depsQuery.findActive(any(), anyString(), anyString(), any(), anyInt(), anyInt())) + .thenReturn(rows); + + var page1 = service.findRecordDependents("dict_t", "K", Set.of(DataScope.PUBLIC), 0, 2); + assertThat(page1.items()).hasSize(2); + assertThat(page1.items().get(0).businessKey()).isEqualTo("K-4"); // newest + assertThat(page1.total()).isEqualTo(5L); + + var page2 = service.findRecordDependents("dict_t", "K", Set.of(DataScope.PUBLIC), 1, 2); + assertThat(page2.items()).hasSize(2); + assertThat(page2.items().get(0).businessKey()).isEqualTo("K-2"); + + var page3 = service.findRecordDependents("dict_t", "K", Set.of(DataScope.PUBLIC), 2, 2); + assertThat(page3.items()).hasSize(1); + assertThat(page3.items().get(0).businessKey()).isEqualTo("K-0"); // oldest + } + + // === Helpers === + + private static DictionaryDefinition mockDef(String name, DataScope scope) { + return mockDefWithSchema(name, scope, "{\"type\":\"object\"}"); + } + + private static DictionaryDefinition mockDefWithSchema(String name, DataScope scope, String schemaJson) { + JsonNode schema; + try { + schema = OM.readTree(schemaJson); + } catch (Exception e) { + throw new RuntimeException(e); + } + var def = new DictionaryDefinition(UUID.randomUUID(), name, scope, schema); + def.setDisplayName(name); + return def; + } + + private static DictionaryRecord mockRecord(UUID dictId, String businessKey, JsonNode data) { + return new DictionaryRecord( + UUID.randomUUID(), dictId, businessKey, data, DataScope.PUBLIC, + OffsetDateTime.parse("2026-01-01T00:00:00Z")); + } +} diff --git a/ordinis-rest-api/src/test/java/cloud/nstart/terravault/ordinis/restapi/service/reference/ReferenceCloseRegressionTest.java b/ordinis-rest-api/src/test/java/cloud/nstart/terravault/ordinis/restapi/service/reference/ReferenceCloseRegressionTest.java new file mode 100644 index 0000000..84ca1d6 --- /dev/null +++ b/ordinis-rest-api/src/test/java/cloud/nstart/terravault/ordinis/restapi/service/reference/ReferenceCloseRegressionTest.java @@ -0,0 +1,157 @@ +package cloud.nstart.terravault.ordinis.restapi.service.reference; + +import cloud.nstart.terravault.ordinis.domain.DataScope; +import cloud.nstart.terravault.ordinis.domain.definition.DictionaryDefinition; +import cloud.nstart.terravault.ordinis.domain.definition.DictionaryDefinitionRepository; +import cloud.nstart.terravault.ordinis.domain.record.DictionaryRecord; +import cloud.nstart.terravault.ordinis.domain.record.DictionaryRecordRepository; +import cloud.nstart.terravault.ordinis.validation.ValidationError; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.time.OffsetDateTime; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * REGRESSION mandatory: existing close path с null {@code x-references-on-close} + * field MUST behave identical к v1.2.1. + * + *

Покрывает gap: bundle v1.2.1 (без новой schema field) loaded в новый build — + * убедиться что: + *

    + *
  1. {@link ReferenceValidator#validate} возвращает те же error codes что и + * до Phase 1 prep commit (никаких новых x-references-on-close-related + * codes не появилось).
  2. + *
  3. {@link OnCloseAction#BLOCK} default'ится автоматически — schema parser + * не падает и не меняет error response shape.
  4. + *
  5. Active record на которое есть references просто закрывается — на этом + * этапе close не обращается к onClose (cascade engine = Phase 3).
  6. + *
+ * + *

Без этого теста риск молча сломать live consumers (см. design doc test + * plan REGRESSION section). + */ +class ReferenceCloseRegressionTest { + + private static final ObjectMapper OM = new ObjectMapper(); + + private DictionaryDefinitionRepository defRepo; + private DictionaryRecordRepository recRepo; + private ReferenceValidator validator; + + @BeforeEach + void setUp() { + defRepo = mock(DictionaryDefinitionRepository.class); + recRepo = mock(DictionaryRecordRepository.class); + validator = new ReferenceValidator(defRepo, recRepo); + } + + /** + * v1.2.1 schema = x-references есть, x-references-on-close нет. + * validate() должен: + * — пропустить запись с валидным reference (no errors); + * — отклонить запись с invalid reference с тем же кодом + * {@code x_references_target_not_found} (без новых fields в error). + */ + @Test + void v1_2_1_schemaWithoutOnClose_validateBehavesIdentical() throws Exception { + UUID targetId = UUID.randomUUID(); + DictionaryDefinition target = new DictionaryDefinition( + targetId, "satellite_type", DataScope.PUBLIC, OM.readTree("{}")); + when(defRepo.findByName("satellite_type")).thenReturn(Optional.of(target)); + // Active record с code=LEO-001 существует, NONEXISTENT — нет. + when(recRepo.findActiveByJsonField(eq(targetId), any(), eq("code"), eq("LEO-001"))) + .thenReturn(List.of(stubRecord(targetId))); + when(recRepo.findActiveByJsonField(any(), any(), anyString(), eq("NONEXISTENT"))) + .thenReturn(List.of()); + + // V1.2.1 schema: x-references есть, x-references-on-close ОТСУТСТВУЕТ. + JsonNode schema = OM.readTree(""" + {"type":"object","properties":{ + "satTypeCode":{"type":"string","x-references":"satellite_type.code"} + }}"""); + + // Valid reference — нет errors. + JsonNode goodData = OM.readTree("{\"satTypeCode\":\"LEO-001\"}"); + List goodErrors = validator.validate( + schema, goodData, List.of(DataScope.PUBLIC), OffsetDateTime.now()); + assertThat(goodErrors).isEmpty(); + + // Invalid reference — точно такой же error code и message structure как в v1. + JsonNode badData = OM.readTree("{\"satTypeCode\":\"NONEXISTENT\"}"); + List badErrors = validator.validate( + schema, badData, List.of(DataScope.PUBLIC), OffsetDateTime.now()); + assertThat(badErrors).hasSize(1); + ValidationError err = badErrors.get(0); + assertThat(err.code()).isEqualTo("x_references_target_not_found"); + assertThat(err.path()).isEqualTo("/satTypeCode"); + assertThat(err.message()).contains("satellite_type"); + assertThat(err.message()).contains("NONEXISTENT"); + // Никаких новых полей в error: x_references_on_close_* кодов не появилось. + assertThat(err.code()).doesNotContain("on_close"); + assertThat(err.code()).doesNotContain("cascade"); + } + + /** + * Schema parser применяется к v1.2.1 bundle — onClose default'ится в BLOCK + * автоматически. Никаких малформ-related parse errors. + */ + @Test + void v1_2_1_schemaParser_defaultsOnCloseToBlock() throws Exception { + JsonNode propV1 = OM.readTree(""" + {"type":"string","x-references":"d.f"}"""); + var parsed = ReferenceValidator.parseRef("d.f", propV1); + assertThat(parsed.onClose()).isEqualTo(OnCloseAction.BLOCK); + assertThat(parsed.dictionaryName()).isEqualTo("d"); + assertThat(parsed.fieldName()).isEqualTo("f"); + } + + /** + * Defensive: legacy single-arg overload {@code parseRef(spec)} (без property + * schema) сохраняет старый contract. Используется внешними тестами и orphan + * scanner (не v1 API но shared parser). + */ + @Test + void v1_2_1_schemaParser_singleArgStillWorks() { + var parsed = ReferenceValidator.parseRef("satellite_type.code"); + assertThat(parsed.onClose()).isEqualTo(OnCloseAction.BLOCK); + assertThat(parsed.dictionaryName()).isEqualTo("satellite_type"); + assertThat(parsed.fieldName()).isEqualTo("code"); + } + + /** + * Schema без x-references вообще — full no-op, validate возвращает пустой + * list errors. Покрывает 30+ legacy справочников что не имеют FK. + */ + @Test + void v1_2_1_schemaWithoutXReferences_isFullNoOp() throws Exception { + JsonNode schema = OM.readTree(""" + {"type":"object","properties":{ + "name":{"type":"string"},"code":{"type":"string"} + }}"""); + JsonNode data = OM.readTree("{\"name\":\"Spacecraft Alpha\",\"code\":\"SC-001\"}"); + + List errors = validator.validate( + schema, data, List.of(DataScope.PUBLIC), OffsetDateTime.now()); + assertThat(errors).isEmpty(); + } + + // === Helpers === + + private static DictionaryRecord stubRecord(UUID dictId) { + return new DictionaryRecord( + UUID.randomUUID(), dictId, "stub-key", + OM.createObjectNode(), DataScope.PUBLIC, OffsetDateTime.now()); + } +}