feat(api): GET /dictionaries/graph/outgoing — batch outgoing FK summary

Frontend admin-UI catalog list (`→` column) парсил schemaJson per-row
для подсчёта outgoing FK refs. Этот endpoint возвращает batch:

  GET /api/v1/dictionaries/graph/outgoing?dicts=satellites,operators

  → { "outgoing": {
      "satellites": { "fkCount": 3, "targets": ["launch_sites", "missions", "operators"] },
      "operators":  { "fkCount": 0, "targets": [] }
    } }

Implementation:
- LineageIndexService.findOutgoingFkSummary — обходит DictionaryDefinitionRepo.findAll()
  + ScopeContext filter, парсит x-references через ReferenceValidator.parseRef,
  dedup target dict names, sort alphabetically.
- LineageController новый endpoint + OutgoingGraphResponse wrapper record
  (extensible — sibling fields в будущем без breaking).

Errors: scope hide (тихо skip — same pattern что и GET /dictionaries).
Malformed x-references логируются WARN + skip (не падают на одной плохой
schema).

Tests +6 cases: empty, basic counts, scope hide, whitelist filter, dedup
repeated target, malformed-not-fatal.
This commit is contained in:
Zimin A.N.
2026-05-11 22:54:43 +03:00
parent 8bc7eef703
commit ff05ef9a9f
3 changed files with 193 additions and 0 deletions
@@ -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.
*
* <p>Используется 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<String, OutgoingFkInfo> findOutgoingFkSummary(
Set<DataScope> allowedScopes, Set<String> dictFilter) {
List<DictionaryDefinition> all = definitionRepository.findAll();
Map<String, OutgoingFkInfo> 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<String> targets = new ArrayList<>();
if (schema != null && schema.isObject()) {
JsonNode properties = schema.get("properties");
if (properties != null && properties.isObject()) {
Iterator<Map.Entry<String, JsonNode>> it = properties.fields();
while (it.hasNext()) {
Map.Entry<String, JsonNode> 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<String> targets) {}
/** Schema-level reverse FK pointer: source dict → target dict через field. */
public record SchemaDependent(
String sourceDict,
@@ -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.
*
* <p>Query: {@code GET /api/v1/dictionaries/graph/outgoing?dicts=a,b,c}
*
* <ul>
* <li>{@code dicts} — optional CSV filter. Null/empty = все visible.</li>
* </ul>
*
* <p>Response: {@code { "outgoing": { "<name>": { "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<String> filter = parseDictsFilter(dicts);
Map<String, LineageIndexService.OutgoingFkInfo> 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<String> 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<String, LineageIndexService.OutgoingFkInfo> outgoing) {}
}
@@ -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) {