feat(references): OnCloseAction enum + ParsedRef.onClose field (Phase 1 prep)

dict-relationships-v2 epic, Phase 1 prep commit. Готовит почву для
параллельных Phase 1 (read-side lineage) + Phase 3 (cascade engine) lanes.

Изменения:
- Новый enum `OnCloseAction { BLOCK, WARN, CASCADE }` с lenient parser
  (case-insensitive, null/blank → BLOCK, unknown → IllegalArgumentException).
- `ParsedRef` record расширен полем `onClose` (default BLOCK).
  Convenience ctor `ParsedRef(dict, field)` сохранён для backward-compat
  с текущими тестами и call sites.
- `parseRef(spec, propSchema)` overload читает `x-references-on-close`
  sibling field. Старый `parseRef(spec)` оставлен и делегирует с propSchema=null.
- ReferenceValidator.validate() теперь передаёт propSchema в parser.
  Runtime semantics не меняются — onClose читается, но не используется.
  Cascade engine (Phase 3) подхватит поле когда CascadeCloseService появится.

Tests:
- ReferenceValidatorTest: +6 tests для onClose parsing
  (default BLOCK, explicit BLOCK/WARN/CASCADE case-insensitive,
   trim, invalid value → throw, non-string → throw).
- OnCloseActionTest: 4 dedicated unit tests для enum.fromString().
- BulkRecordServiceTest (10) + DictionaryRecordServiceTest (11) проходят
  без изменений → close flow regression-safe.

mvn -pl ordinis-rest-api -am test: 69/69 PASS (3.857s).

Backward-compat: bundle v1.2.1 (без x-references-on-close) → onClose=BLOCK
→ identical поведение к v1. Контракт сохранён.
This commit is contained in:
Zimin A.N.
2026-05-08 10:31:30 +03:00
parent 080ecb7a65
commit c79ea22702
4 changed files with 200 additions and 3 deletions
@@ -0,0 +1,63 @@
package cloud.nstart.terravault.ordinis.restapi.service.reference;
/**
* Поведение при попытке закрыть source record на который ссылаются другие
* (через JSON Schema marker {@code x-references-on-close}).
*
* <p>Значение объявляется в schema рядом с {@code x-references}:
* <pre>
* {
* "satelliteTypeCode": {
* "type": "string",
* "x-references": "satellite_type.code",
* "x-references-on-close": "block"
* }
* }
* </pre>
*
* <p>Каждое значение enum описывает что произойдёт когда пользователь пытается
* close (или _bulk_close) запись в target словаре, на которую есть active
* referencing records:
*
* <ul>
* <li>{@link #BLOCK} (default) — close отклоняется с 409 + список dependents.
* Совместимо с текущим v1 поведением: dangling FK не появляются.</li>
* <li>{@link #WARN} — close проходит, но в response body возвращается
* {@code warnings[]} с количеством affected referencing records.
* Orphan scanner потом подхватит их в metric.</li>
* <li>{@link #CASCADE} — close source ⇒ atomic close всех direct dependents
* в одной transaction. One hop по design (F.OUT). N{@code >}1 hop требует
* multi-step orchestration — позже.</li>
* </ul>
*
* <p>Default = {@link #BLOCK} применяется если schema не объявляет
* {@code x-references-on-close} (включая legacy bundle{@code <}=v1.2.1).
* Phase 1 prep commit вводит этот enum + parsing — runtime semantics
* (актуальный cascade engine) добавляются Phase 3.
*/
public enum OnCloseAction {
/** Default. Reject close with 409 + dependents list. Backward-compat с v1. */
BLOCK,
/** Allow close, emit warnings[] в response body, дальше orphan scanner. */
WARN,
/** Atomic cascade close direct dependents в той же transaction (1 hop). */
CASCADE;
/**
* Lenient parser: case-insensitive, whitespace-trimmed, null/blank → BLOCK.
* Unknown value → IllegalArgumentException (validated на bundle import,
* не runtime — см. design doc § Cascade rules).
*/
public static OnCloseAction fromString(String value) {
if (value == null) return BLOCK;
String normalized = value.trim();
if (normalized.isEmpty()) return BLOCK;
for (OnCloseAction a : values()) {
if (a.name().equalsIgnoreCase(normalized)) return a;
}
throw new IllegalArgumentException(
"x-references-on-close must be one of [block, warn, cascade], got: " + value);
}
}
@@ -95,7 +95,7 @@ public class ReferenceValidator {
String refSpec = refNode.asText();
ParsedRef parsed;
try {
parsed = parseRef(refSpec);
parsed = parseRef(refSpec, propSchema);
} catch (IllegalArgumentException e) {
errors.add(new ValidationError(
"/" + fieldName,
@@ -103,6 +103,9 @@ public class ReferenceValidator {
"x-references value malformed: " + e.getMessage()));
continue;
}
// Phase 1 prep: onClose читается, но не используется — runtime cascade
// engine добавляется Phase 3. Здесь BLOCK (default) совпадает с текущим
// поведением CRUD validator'а.
JsonNode value = data.get(fieldName);
if (value == null || value.isNull()) {
@@ -161,8 +164,27 @@ public class ReferenceValidator {
/**
* Parse {@code "dict_name.field"} format. Validates что обе части
* непустые и не содержат точек (чтобы избежать unexpected nested paths).
*
* <p>Default {@link OnCloseAction#BLOCK} — overload без property schema
* не видит {@code x-references-on-close}. Используется внешними тестами +
* ситуациями где cascade semantics нерелевантны (orphan scan, simple parse
* checks). Internal call site в {@link #validate} использует
* {@link #parseRef(String, JsonNode)} чтобы подхватить onClose из schema.
*/
static ParsedRef parseRef(String spec) {
return parseRef(spec, null);
}
/**
* Parse {@code "dict_name.field"} формат + {@code x-references-on-close}
* sibling field из property schema. Если sibling отсутствует — onClose
* defaultится в {@link OnCloseAction#BLOCK} (backward-compat с bundle
* v1.2.1 где field не объявлен).
*
* @param spec значение field {@code x-references}
* @param propSchema parent JSON object (the property schema), может быть null
*/
static ParsedRef parseRef(String spec, JsonNode propSchema) {
if (spec == null || spec.isBlank()) {
throw new IllegalArgumentException("empty x-references");
}
@@ -177,8 +199,31 @@ public class ReferenceValidator {
throw new IllegalArgumentException(
"x-references nested paths not supported in v1: " + spec);
}
return new ParsedRef(dict, field);
OnCloseAction onClose = OnCloseAction.BLOCK;
if (propSchema != null && propSchema.isObject()) {
JsonNode onCloseNode = propSchema.get("x-references-on-close");
if (onCloseNode != null && !onCloseNode.isNull()) {
if (!onCloseNode.isTextual()) {
throw new IllegalArgumentException(
"x-references-on-close must be a string, got: " + onCloseNode.getNodeType());
}
onClose = OnCloseAction.fromString(onCloseNode.asText());
}
}
return new ParsedRef(dict, field, onClose);
}
public record ParsedRef(String dictionaryName, String fieldName) {}
/**
* Parsed FK reference. {@code onClose} = что делать при close source record
* на который ссылаются записи в этом dict (см. {@link OnCloseAction}).
*
* <p>Phase 1 prep: field читается parser'ом, но не используется в runtime —
* cascade engine = Phase 3. Default BLOCK совпадает с текущим v1 поведением.
*/
public record ParsedRef(String dictionaryName, String fieldName, OnCloseAction onClose) {
/** Convenience ctor для тестов / call sites что ещё не работают с onClose. */
public ParsedRef(String dictionaryName, String fieldName) {
this(dictionaryName, fieldName, OnCloseAction.BLOCK);
}
}
}
@@ -0,0 +1,36 @@
package cloud.nstart.terravault.ordinis.restapi.service.reference;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class OnCloseActionTest {
@Test
void nullDefaultsToBlock() {
assertThat(OnCloseAction.fromString(null)).isEqualTo(OnCloseAction.BLOCK);
}
@Test
void blankDefaultsToBlock() {
assertThat(OnCloseAction.fromString("")).isEqualTo(OnCloseAction.BLOCK);
assertThat(OnCloseAction.fromString(" ")).isEqualTo(OnCloseAction.BLOCK);
}
@Test
void caseInsensitiveAndTrimmed() {
assertThat(OnCloseAction.fromString("BLOCK")).isEqualTo(OnCloseAction.BLOCK);
assertThat(OnCloseAction.fromString("block")).isEqualTo(OnCloseAction.BLOCK);
assertThat(OnCloseAction.fromString(" Warn ")).isEqualTo(OnCloseAction.WARN);
assertThat(OnCloseAction.fromString("CaScAdE")).isEqualTo(OnCloseAction.CASCADE);
}
@Test
void unknownThrows() {
assertThatThrownBy(() -> OnCloseAction.fromString("yolo"))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("[block, warn, cascade]")
.hasMessageContaining("yolo");
}
}
@@ -46,6 +46,59 @@ class ReferenceValidatorTest {
var p = ReferenceValidator.parseRef("satellite_type.code");
assertThat(p.dictionaryName()).isEqualTo("satellite_type");
assertThat(p.fieldName()).isEqualTo("code");
// Без property schema → onClose defaultится в BLOCK (backward-compat v1.2.1).
assertThat(p.onClose()).isEqualTo(OnCloseAction.BLOCK);
}
@Test
void parseRefReadsOnCloseFromPropertySchema() throws Exception {
JsonNode prop = OM.readTree("""
{"type":"string","x-references":"d.f","x-references-on-close":"cascade"}""");
var p = ReferenceValidator.parseRef("d.f", prop);
assertThat(p.onClose()).isEqualTo(OnCloseAction.CASCADE);
}
@Test
void parseRefOnCloseMissingFallsBackToBlock() throws Exception {
JsonNode prop = OM.readTree("""
{"type":"string","x-references":"d.f"}""");
var p = ReferenceValidator.parseRef("d.f", prop);
assertThat(p.onClose()).isEqualTo(OnCloseAction.BLOCK);
}
@Test
void parseRefOnCloseAcceptsAllValuesCaseInsensitive() throws Exception {
JsonNode block = OM.readTree("""
{"x-references-on-close":"BLOCK"}""");
JsonNode warn = OM.readTree("""
{"x-references-on-close":" Warn "}""");
JsonNode cascade = OM.readTree("""
{"x-references-on-close":"cascade"}""");
assertThat(ReferenceValidator.parseRef("d.f", block).onClose())
.isEqualTo(OnCloseAction.BLOCK);
assertThat(ReferenceValidator.parseRef("d.f", warn).onClose())
.isEqualTo(OnCloseAction.WARN);
assertThat(ReferenceValidator.parseRef("d.f", cascade).onClose())
.isEqualTo(OnCloseAction.CASCADE);
}
@Test
void parseRefOnCloseRejectsInvalidValue() throws Exception {
JsonNode prop = OM.readTree("""
{"type":"string","x-references":"d.f","x-references-on-close":"yolo"}""");
assertThatThrownBy(() -> ReferenceValidator.parseRef("d.f", prop))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("x-references-on-close")
.hasMessageContaining("yolo");
}
@Test
void parseRefOnCloseRejectsNonString() throws Exception {
JsonNode prop = OM.readTree("""
{"type":"string","x-references":"d.f","x-references-on-close":42}""");
assertThatThrownBy(() -> ReferenceValidator.parseRef("d.f", prop))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("must be a string");
}
@Test