diff --git a/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/record/DictionaryRecordRepository.java b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/record/DictionaryRecordRepository.java index 97be205..e87b0fa 100644 --- a/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/record/DictionaryRecordRepository.java +++ b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/record/DictionaryRecordRepository.java @@ -48,4 +48,22 @@ public interface DictionaryRecordRepository extends JpaRepository allowedScopes, @Param("at") OffsetDateTime at); + + /** + * Поиск активных записей в момент {@code at} по значению поля внутри JSONB data. + * Используется для x-unique валидации: ищем записи с тем же значением, исключая + * текущую версию (если update). + */ + @Query(value = """ + SELECT * FROM dictionary_records r + WHERE r.dictionary_id = :dictionaryId + AND r.valid_from <= :at + AND r.valid_to > :at + AND r.data ->> :fieldName = :fieldValue + """, nativeQuery = true) + List findActiveByJsonField( + @Param("dictionaryId") UUID dictionaryId, + @Param("at") OffsetDateTime at, + @Param("fieldName") String fieldName, + @Param("fieldValue") String fieldValue); } diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/DictionaryRecordService.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/DictionaryRecordService.java index 4dfc784..591539c 100644 --- a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/DictionaryRecordService.java +++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/DictionaryRecordService.java @@ -76,17 +76,21 @@ public class DictionaryRecordService { OffsetDateTime validFrom = req.validFrom() == null ? OffsetDateTime.now() : req.validFrom(); OffsetDateTime validTo = req.validTo() == null ? FAR_FUTURE : req.validTo(); - if (repository.findActiveAt(d.getId(), req.businessKey(), validFrom).isPresent()) { + String businessKey = resolveBusinessKey(d, req); + + if (repository.findActiveAt(d.getId(), businessKey, validFrom).isPresent()) { throw OrdinisException.conflict( "record_already_active", - "Запись с business_key=" + req.businessKey() + " уже активна на " + validFrom + + "Запись с business_key=" + businessKey + " уже активна на " + validFrom + ". Используйте PUT (update) для новой версии."); } + enforceUniqueFields(d, req.data(), validFrom, null); + var record = new DictionaryRecord( UUID.randomUUID(), d.getId(), - req.businessKey(), + businessKey, req.data(), d.getScope(), validFrom); @@ -114,6 +118,8 @@ public class DictionaryRecordService { var prevData = current.getData(); UUID previousRecordId = current.getId(); + enforceUniqueFields(d, req.data(), newValidFrom, current.getId()); + current.setValidTo(newValidFrom); // saveAndFlush обязателен: иначе Hibernate отложит UPDATE до commit, INSERT новой // записи пойдёт первым → exclusion constraint dictionary_records_no_overlap бьёт, @@ -158,6 +164,78 @@ public class DictionaryRecordService { if (!vr.valid()) throw new ValidationException(vr.errors()); } + /** + * Если в schema задано {@code x-id-source: }, бизнес-ключ автоматически + * берётся из {@code data[field]}. Если клиент явно прислал businessKey — он + * должен совпадать, иначе 422 (защита от рассинхронизации). + */ + private String resolveBusinessKey(DictionaryDefinition d, CreateRecordRequest req) { + var schema = d.getSchemaJson(); + var idSourceNode = schema == null ? null : schema.get("x-id-source"); + if (idSourceNode == null || !idSourceNode.isTextual() || idSourceNode.asText().isBlank()) { + if (req.businessKey() == null || req.businessKey().isBlank()) { + throw OrdinisException.badRequest( + "business_key_required", + "businessKey обязателен (в schema нет x-id-source)."); + } + return req.businessKey(); + } + String fieldName = idSourceNode.asText(); + var valueNode = req.data() == null ? null : req.data().get(fieldName); + if (valueNode == null || valueNode.isNull()) { + throw OrdinisException.badRequest( + "id_source_missing", + "Поле x-id-source='" + fieldName + "' пустое в data — бизнес-ключ невозможно вывести."); + } + String derived = valueNode.isTextual() ? valueNode.asText() : valueNode.toString(); + if (req.businessKey() != null && !req.businessKey().isBlank() + && !req.businessKey().equals(derived)) { + throw OrdinisException.badRequest( + "business_key_mismatch", + "businessKey='" + req.businessKey() + "' не совпадает с x-id-source='" + + fieldName + "' value='" + derived + "'."); + } + return derived; + } + + /** + * Для каждого property с {@code x-unique: true} ищет другие активные записи + * (на момент {@code at}) с тем же значением. Исключает текущую версию (для + * update). Бьёт 409 при дубликате. + */ + private void enforceUniqueFields( + DictionaryDefinition d, + com.fasterxml.jackson.databind.JsonNode data, + OffsetDateTime at, + UUID excludeRecordId) { + if (data == null) return; + var schema = d.getSchemaJson(); + if (schema == null) return; + var properties = schema.get("properties"); + if (properties == null || !properties.isObject()) return; + + var fieldNames = properties.fieldNames(); + while (fieldNames.hasNext()) { + String fieldName = fieldNames.next(); + var fieldSchema = properties.get(fieldName); + var unique = fieldSchema == null ? null : fieldSchema.get("x-unique"); + if (unique == null || !unique.asBoolean(false)) continue; + + var valueNode = data.get(fieldName); + if (valueNode == null || valueNode.isNull()) continue; + String value = valueNode.isTextual() ? valueNode.asText() : valueNode.toString(); + + var conflicts = repository.findActiveByJsonField(d.getId(), at, fieldName, value); + for (var c : conflicts) { + if (excludeRecordId != null && c.getId().equals(excludeRecordId)) continue; + throw OrdinisException.conflict( + "unique_field_violation", + "Поле '" + fieldName + "' помечено x-unique. Значение '" + value + + "' уже используется в записи business_key='" + c.getBusinessKey() + "'."); + } + } + } + private Geometry parseWkt(String wkt) { if (wkt == null || wkt.isBlank()) return null; try {