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 index 97564c4..cb127d3 100644 --- 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 @@ -322,8 +322,66 @@ public class LineageIndexService { return new LineageMeta(true, m.lastRefreshedAt(), m.lastStatus(), m.lastDurationMs()); } + /** + * Batch outgoing FK summary: для каждого visible dict — сколько outgoing + * FK refs в schema + список target dict names. + * + *

Используется admin-UI catalog list (`→` column) — раньше frontend + * парсил schemaJson каждого dict для подсчёта, что требовало включения + * schemaJson в list response. Этот endpoint позволяет catalog list + * вернуть только metadata + один graph batch. + * + * @param allowedScopes scopes caller видит. Source dicts вне scope skip. + * @param dictFilter optional whitelist names (null = все visible). + * @return map (dictName → OutgoingFkInfo). Empty dicts тоже включены + * (fkCount=0) чтобы frontend мог дисплейнуть "—" единообразно. + */ + @Transactional(readOnly = true) + public Map findOutgoingFkSummary( + Set allowedScopes, Set dictFilter) { + List all = definitionRepository.findAll(); + Map out = new java.util.LinkedHashMap<>(); + for (DictionaryDefinition d : all) { + if (!allowedScopes.contains(d.getScope())) continue; + if (dictFilter != null && !dictFilter.contains(d.getName())) continue; + + JsonNode schema = d.getSchemaJson(); + List targets = new ArrayList<>(); + if (schema != null && schema.isObject()) { + JsonNode properties = schema.get("properties"); + if (properties != null && properties.isObject()) { + Iterator> it = properties.fields(); + while (it.hasNext()) { + Map.Entry entry = it.next(); + JsonNode refNode = entry.getValue().get("x-references"); + if (refNode == null || !refNode.isTextual()) continue; + try { + ReferenceValidator.ParsedRef parsed = + ReferenceValidator.parseRef(refNode.asText(), entry.getValue()); + // Dedup target dict names (один target может быть указан несколько + // раз через разные fields — UI показывает unique targets list). + if (!targets.contains(parsed.dictionaryName())) { + targets.add(parsed.dictionaryName()); + } + } catch (IllegalArgumentException e) { + log.warn("Schema {} field {} malformed x-references: {}", + d.getName(), entry.getKey(), e.getMessage()); + } + } + } + } + // Sort target list для stable response (test stability + cache keys). + targets.sort(String::compareTo); + out.put(d.getName(), new OutgoingFkInfo(targets.size(), targets)); + } + return out; + } + // === DTOs === + /** Per-dict outgoing FK summary. */ + public record OutgoingFkInfo(int fkCount, List targets) {} + /** Schema-level reverse FK pointer: source dict → target dict через field. */ public record SchemaDependent( String sourceDict, 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 index 9f6107c..d10c22d 100644 --- 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 @@ -9,7 +9,11 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import java.util.Arrays; import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; /** * Lineage / dependents read-side. Phase 1 dict-relationships-v2 epic. @@ -91,4 +95,47 @@ public class LineageController { throw OrdinisException.badRequest("invalid_request", msg); } } + + /** + * Batch outgoing FK summary. Для admin-UI catalog list: + * frontend нужен сколько outgoing FK refs у каждого dict и список target + * names — раньше парсил schemaJson per-row на клиенте, теперь один + * batch endpoint. + * + *

Query: {@code GET /api/v1/dictionaries/graph/outgoing?dicts=a,b,c} + * + *

+ * + *

Response: {@code { "outgoing": { "": { "fkCount", "targets" } } }}. + * Dicts вне scope caller'а skip — same hide pattern что и {@code GET /dictionaries}. + * + * @return wrapped в outer object для будущего extension (например meta). + */ + @GetMapping("/dictionaries/graph/outgoing") + public OutgoingGraphResponse outgoingGraph( + @RequestParam(required = false) String dicts) { + Set filter = parseDictsFilter(dicts); + Map outgoing = + lineage.findOutgoingFkSummary(scopeContext.currentScopes(), filter); + return new OutgoingGraphResponse(outgoing); + } + + /** Parse CSV "a,b,c" → Set. Returns null если param empty (means "all"). */ + private static Set parseDictsFilter(String dictsCsv) { + if (dictsCsv == null || dictsCsv.isBlank()) return null; + return Arrays.stream(dictsCsv.split(",")) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .collect(Collectors.toSet()); + } + + /** + * Wrapper для batch outgoing graph response. Inner map не серилизуется + * напрямую (consumer'у удобнее видеть {@code outgoing: { ... }} ключ — + * легче добавить sibling fields позже без breaking change). + */ + public record OutgoingGraphResponse( + Map outgoing) {} } 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 index 37b9c02..ee62847 100644 --- 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 @@ -342,6 +342,94 @@ class LineageIndexServiceTest { assertThat(page3.items().get(0).businessKey()).isEqualTo("K-0"); // oldest } + // === Batch outgoing FK summary === + + @Test + void outgoingFk_emptyDicts_returnsEmpty() { + when(defRepo.findAll()).thenReturn(List.of()); + var result = service.findOutgoingFkSummary(Set.of(DataScope.PUBLIC), null); + assertThat(result).isEmpty(); + } + + @Test + void outgoingFk_basicCounts_returnsCountsAndTargets() { + var noRefs = mockDefWithSchema("operators", DataScope.PUBLIC, + "{\"properties\":{\"name\":{\"type\":\"string\"}}}"); + var threeRefs = mockDefWithSchema("satellites", DataScope.PUBLIC, """ + {"properties":{ + "operator":{"type":"string","x-references":"operators.code"}, + "launchSite":{"type":"string","x-references":"launch_sites.code"}, + "mission":{"type":"string","x-references":"missions.code"} + }}"""); + when(defRepo.findAll()).thenReturn(List.of(noRefs, threeRefs)); + + var result = service.findOutgoingFkSummary(Set.of(DataScope.PUBLIC), null); + + assertThat(result).hasSize(2); + assertThat(result.get("operators").fkCount()).isEqualTo(0); + assertThat(result.get("operators").targets()).isEmpty(); + assertThat(result.get("satellites").fkCount()).isEqualTo(3); + // Sorted alphabetically для stable test + cacheability. + assertThat(result.get("satellites").targets()) + .containsExactly("launch_sites", "missions", "operators"); + } + + @Test + void outgoingFk_scopeHidesSource_skipped() { + var publicDict = mockDef("pub", DataScope.PUBLIC); + var internalDict = mockDef("internal", DataScope.INTERNAL); + when(defRepo.findAll()).thenReturn(List.of(publicDict, internalDict)); + + var result = service.findOutgoingFkSummary(Set.of(DataScope.PUBLIC), null); + + assertThat(result).containsOnlyKeys("pub"); + } + + @Test + void outgoingFk_filterWhitelist_respectsFilter() { + var a = mockDef("a", DataScope.PUBLIC); + var b = mockDef("b", DataScope.PUBLIC); + var c = mockDef("c", DataScope.PUBLIC); + when(defRepo.findAll()).thenReturn(List.of(a, b, c)); + + var result = service.findOutgoingFkSummary( + Set.of(DataScope.PUBLIC), Set.of("a", "c")); + + assertThat(result).containsOnlyKeys("a", "c"); + } + + @Test + void outgoingFk_dedupRepeatedTarget_returnsUnique() { + // Source с двумя fields ссылающимися на same target dict — target должен + // появиться в targets list один раз, но fkCount считает unique targets. + var src = mockDefWithSchema("doc", DataScope.PUBLIC, """ + {"properties":{ + "primaryAuthor":{"type":"string","x-references":"users.id"}, + "reviewerAuthor":{"type":"string","x-references":"users.id"} + }}"""); + when(defRepo.findAll()).thenReturn(List.of(src)); + + var result = service.findOutgoingFkSummary(Set.of(DataScope.PUBLIC), null); + + assertThat(result.get("doc").fkCount()).isEqualTo(1); + assertThat(result.get("doc").targets()).containsExactly("users"); + } + + @Test + void outgoingFk_malformedRef_skippedNotFatal() { + var src = mockDefWithSchema("buggy", DataScope.PUBLIC, """ + {"properties":{ + "good":{"type":"string","x-references":"ok.code"}, + "bad":{"type":"string","x-references":"no_dot_format"} + }}"""); + when(defRepo.findAll()).thenReturn(List.of(src)); + + var result = service.findOutgoingFkSummary(Set.of(DataScope.PUBLIC), null); + + assertThat(result.get("buggy").fkCount()).isEqualTo(1); + assertThat(result.get("buggy").targets()).containsExactly("ok"); + } + // === Helpers === private static DictionaryDefinition mockDef(String name, DataScope scope) {