feat(lineage): Phase 1 backend — dependents endpoints + REGRESSION test

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).
This commit is contained in:
Zimin A.N.
2026-05-08 10:45:02 +03:00
parent c79ea22702
commit 46a0a9c00c
6 changed files with 1077 additions and 0 deletions
@@ -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}".
*
* <p>Используется {@code LineageIndexService.findRecordDependents} в Phase 1.
* Phase 2 переносит этот lookup на materialized view с UNIQUE INDEX(id) для
* REFRESH CONCURRENTLY — пока живём с прямым JSONB query (acceptable для
* {@code <}5k records SLO).
*
* <p>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).
*
* <p>NULL-значения в source_field автоматически исключены SQL'ом
* (NULL != value никогда не true).
*
* <p>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<DependentRow> 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) {}
}
@@ -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}.
*
* <p>Поля {@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<DependentRow> 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);
}
}
}
@@ -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: "кто ссылается на этот словарь / запись".
*
* <p>Phase 1 read-side для dict-relationships-v2 epic. Два режима:
*
* <ul>
* <li><b>Schema-level</b>: {@link #findSchemaDependents(String, Set)} —
* берёт target dict name, обходит все definitions и собирает все
* fields у которых {@code x-references = "<target>.<field>"}. Дёшево
* (in-memory traversal, ~40 dicts × ~20 fields), кэш не нужен в v1.</li>
* <li><b>Record-level</b>: {@link #findRecordDependents} — для конкретного
* business_key в target_dict находит active records в source dicts
* что на него ссылаются. Paginated через {@link DependentsQuery} (native
* SQL JSONB lookup). Phase 2 переносит на materialized view.</li>
* </ul>
*
* <p>Caching: Phase 1 не кэширует — schema lookup микросекунды, record-level
* SQL paginated и каждый bbox разный. Phase 2 добавит {@code @Cacheable} для
* schema-level + invalidation на bundle import (см. eng review A.1).
*
* <p>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<SchemaDependent> findSchemaDependents(
String targetDictName, Set<DataScope> allowedScopes) {
Optional<DictionaryDefinition> 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<DictionaryDefinition> all = definitionRepository.findAll();
// Pre-compute counts active records per source dict (для UI badge).
Map<java.util.UUID, Long> 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<SchemaDependent> 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<Map.Entry<String, JsonNode>> it = properties.fields();
while (it.hasNext()) {
Map.Entry<String, JsonNode> 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 что на него ссылаются.
*
* <p>Логика: для каждого source dict у которого schema имеет
* {@code x-references: "<target_dict>.<target_field>"} — берём 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<DataScope> 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<DictionaryDefinition> 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<DictionaryRecord> 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<SchemaDependent> 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<RecordDependent> all = new ArrayList<>();
List<PerSourceSummary> 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<DictionaryDefinition> 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<DependentsQuery.DependentRow> 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<RecordDependent> 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<RecordDependent> items,
List<PerSourceSummary> perSource,
long total,
int page,
int size) {}
}
@@ -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.
*
* <p>Два endpoint'а:
* <ul>
* <li>{@code GET /api/v1/dictionaries/{name}/dependents} — schema-level
* reverse FK list ("какие словари ссылаются на этот словарь и через
* какие поля").</li>
* <li>{@code GET /api/v1/records/{dict}/{businessKey}/dependents} —
* record-level dependents (paginated, per-source counts).</li>
* </ul>
*
* <p>Errors:
* <ul>
* <li>404 {@code dictionary_not_found} — target dict не существует или
* не виден caller'у в его scopes (одинаковая ошибка чтобы не leak'ало
* existence).</li>
* <li>404 {@code record_not_active} — нет active record на now().</li>
* <li>400 {@code invalid_pagination} — page/size вне допустимого диапазона.</li>
* </ul>
*/
@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).
*
* <p>Возврат: list объектов
* {@code {sourceDict, sourceDisplayName, sourceField, targetField,
* onClose, activeRecordsInSourceDict}}.
*/
@GetMapping("/dictionaries/{name}/dependents")
public List<LineageIndexService.SchemaDependent> schemaDependents(
@PathVariable String name) {
return lineage.findSchemaDependents(name, scopeContext.currentScopes());
}
/**
* Record-level dependents для конкретной record (target_dict + business_key).
*
* <p>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);
}
}
}
@@ -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}. Покрывает:
*
* <ul>
* <li>Schema-level reverse FK: not_found, scope hidden, malformed,
* self-reference skip, sorting, onClose из x-references-on-close.</li>
* <li>Record-level: not_active record → exception, no schema refs → empty,
* perSource counts + paginated items, pagination across multiple
* sources.</li>
* </ul>
*
* <p>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<Object[]> 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<DependentsQuery.DependentRow>();
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"));
}
}
@@ -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.
*
* <p>Покрывает gap: bundle v1.2.1 (без новой schema field) loaded в новый build —
* убедиться что:
* <ol>
* <li>{@link ReferenceValidator#validate} возвращает те же error codes что и
* до Phase 1 prep commit (никаких новых x-references-on-close-related
* codes не появилось).</li>
* <li>{@link OnCloseAction#BLOCK} default'ится автоматически — schema parser
* не падает и не меняет error response shape.</li>
* <li>Active record на которое есть references просто закрывается — на этом
* этапе close не обращается к onClose (cascade engine = Phase 3).</li>
* </ol>
*
* <p>Без этого теста риск молча сломать 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<ValidationError> 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<ValidationError> 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<ValidationError> 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());
}
}