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:
+18
@@ -48,4 +48,22 @@ public interface DictionaryRecordRepository extends JpaRepository<DictionaryReco
|
|||||||
@Param("dictionaryId") UUID dictionaryId,
|
@Param("dictionaryId") UUID dictionaryId,
|
||||||
@Param("allowedScopes") List<DataScope> allowedScopes,
|
@Param("allowedScopes") List<DataScope> allowedScopes,
|
||||||
@Param("at") OffsetDateTime at);
|
@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<DictionaryRecord> findActiveByJsonField(
|
||||||
|
@Param("dictionaryId") UUID dictionaryId,
|
||||||
|
@Param("at") OffsetDateTime at,
|
||||||
|
@Param("fieldName") String fieldName,
|
||||||
|
@Param("fieldValue") String fieldValue);
|
||||||
}
|
}
|
||||||
|
|||||||
+81
-3
@@ -76,17 +76,21 @@ public class DictionaryRecordService {
|
|||||||
OffsetDateTime validFrom = req.validFrom() == null ? OffsetDateTime.now() : req.validFrom();
|
OffsetDateTime validFrom = req.validFrom() == null ? OffsetDateTime.now() : req.validFrom();
|
||||||
OffsetDateTime validTo = req.validTo() == null ? FAR_FUTURE : req.validTo();
|
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(
|
throw OrdinisException.conflict(
|
||||||
"record_already_active",
|
"record_already_active",
|
||||||
"Запись с business_key=" + req.businessKey() + " уже активна на " + validFrom +
|
"Запись с business_key=" + businessKey + " уже активна на " + validFrom +
|
||||||
". Используйте PUT (update) для новой версии.");
|
". Используйте PUT (update) для новой версии.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enforceUniqueFields(d, req.data(), validFrom, null);
|
||||||
|
|
||||||
var record = new DictionaryRecord(
|
var record = new DictionaryRecord(
|
||||||
UUID.randomUUID(),
|
UUID.randomUUID(),
|
||||||
d.getId(),
|
d.getId(),
|
||||||
req.businessKey(),
|
businessKey,
|
||||||
req.data(),
|
req.data(),
|
||||||
d.getScope(),
|
d.getScope(),
|
||||||
validFrom);
|
validFrom);
|
||||||
@@ -114,6 +118,8 @@ public class DictionaryRecordService {
|
|||||||
var prevData = current.getData();
|
var prevData = current.getData();
|
||||||
UUID previousRecordId = current.getId();
|
UUID previousRecordId = current.getId();
|
||||||
|
|
||||||
|
enforceUniqueFields(d, req.data(), newValidFrom, current.getId());
|
||||||
|
|
||||||
current.setValidTo(newValidFrom);
|
current.setValidTo(newValidFrom);
|
||||||
// saveAndFlush обязателен: иначе Hibernate отложит UPDATE до commit, INSERT новой
|
// saveAndFlush обязателен: иначе Hibernate отложит UPDATE до commit, INSERT новой
|
||||||
// записи пойдёт первым → exclusion constraint dictionary_records_no_overlap бьёт,
|
// записи пойдёт первым → exclusion constraint dictionary_records_no_overlap бьёт,
|
||||||
@@ -158,6 +164,78 @@ public class DictionaryRecordService {
|
|||||||
if (!vr.valid()) throw new ValidationException(vr.errors());
|
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) {
|
private Geometry parseWkt(String wkt) {
|
||||||
if (wkt == null || wkt.isBlank()) return null;
|
if (wkt == null || wkt.isBlank()) return null;
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user