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:
+63
@@ -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);
|
||||
}
|
||||
}
|
||||
+48
-3
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+36
@@ -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");
|
||||
}
|
||||
}
|
||||
+53
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user