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:
+58
@@ -322,8 +322,66 @@ public class LineageIndexService {
|
|||||||
return new LineageMeta(true, m.lastRefreshedAt(), m.lastStatus(), m.lastDurationMs());
|
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 ===
|
// === DTOs ===
|
||||||
|
|
||||||
|
/** Per-dict outgoing FK summary. */
|
||||||
|
public record OutgoingFkInfo(int fkCount, List<String> targets) {}
|
||||||
|
|
||||||
/** Schema-level reverse FK pointer: source dict → target dict через field. */
|
/** Schema-level reverse FK pointer: source dict → target dict через field. */
|
||||||
public record SchemaDependent(
|
public record SchemaDependent(
|
||||||
String sourceDict,
|
String sourceDict,
|
||||||
|
|||||||
+47
@@ -9,7 +9,11 @@ import org.springframework.web.bind.annotation.RequestMapping;
|
|||||||
import org.springframework.web.bind.annotation.RequestParam;
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
import java.util.List;
|
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.
|
* Lineage / dependents read-side. Phase 1 dict-relationships-v2 epic.
|
||||||
@@ -91,4 +95,47 @@ public class LineageController {
|
|||||||
throw OrdinisException.badRequest("invalid_request", msg);
|
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) {}
|
||||||
}
|
}
|
||||||
|
|||||||
+88
@@ -342,6 +342,94 @@ class LineageIndexServiceTest {
|
|||||||
assertThat(page3.items().get(0).businessKey()).isEqualTo("K-0"); // oldest
|
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 ===
|
// === Helpers ===
|
||||||
|
|
||||||
private static DictionaryDefinition mockDef(String name, DataScope scope) {
|
private static DictionaryDefinition mockDef(String name, DataScope scope) {
|
||||||
|
|||||||
Reference in New Issue
Block a user