Merge branch 'feat/graph-outgoing-batch' into 'main'
feat(api): GET /dictionaries/graph/outgoing — batch outgoing FK summary (backend #5) See merge request 2-6/2-6-4/terravault/ordinis!95
This commit is contained in:
+58
@@ -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,
|
||||
|
||||
+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.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) {}
|
||||
}
|
||||
|
||||
+88
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user