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:
Zimin A.N.
2026-05-04 03:52:51 +03:00
parent b0746de128
commit c96b6706fc
2 changed files with 99 additions and 3 deletions
@@ -48,4 +48,22 @@ public interface DictionaryRecordRepository extends JpaRepository<DictionaryReco
@Param("dictionaryId") UUID dictionaryId,
@Param("allowedScopes") List<DataScope> 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<DictionaryRecord> findActiveByJsonField(
@Param("dictionaryId") UUID dictionaryId,
@Param("at") OffsetDateTime at,
@Param("fieldName") String fieldName,
@Param("fieldValue") String fieldValue);
}