feat(rest-api): backend enforcement для x-unique и x-id-source schema extensions
x-id-source (auto-derive businessKey): - DictionaryRecordService.resolveBusinessKey() читает schemaJson['x-id-source']. - Если задан — businessKey берётся из data[idSource]. Если клиент явно прислал и значения не совпадают → 400 business_key_mismatch (защита от рассинхрона). - Если поле в data пустое → 400 id_source_missing. - Если x-id-source не задан и businessKey пуст → 400 business_key_required. x-unique (constraint на дубли значений): - DictionaryRecordRepository.findActiveByJsonField() — native SQL c data->>field. - DictionaryRecordService.enforceUniqueFields() walks schema.properties, для каждого property с x-unique=true ищет активные записи с тем же значением. Исключает current record на update. - Конфликт → 409 unique_field_violation с указанием business_key конфликтующей. Делается на каждом create/update в той же SERIALIZABLE транзакции что и save — без отдельного DB constraint (для MVP). Partial unique index per-field — оптимизация на потом, когда будет много справочников с x-unique.
This commit is contained in:
+81
-3
@@ -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: <field>}, бизнес-ключ автоматически
|
||||
* берётся из {@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 {
|
||||
|
||||
Reference in New Issue
Block a user