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:
+72
@@ -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) {}
|
||||||
|
}
|
||||||
+97
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+289
@@ -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) {}
|
||||||
|
}
|
||||||
+94
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+368
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
+157
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user