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();
|
String refSpec = refNode.asText();
|
||||||
ParsedRef parsed;
|
ParsedRef parsed;
|
||||||
try {
|
try {
|
||||||
parsed = parseRef(refSpec);
|
parsed = parseRef(refSpec, propSchema);
|
||||||
} catch (IllegalArgumentException e) {
|
} catch (IllegalArgumentException e) {
|
||||||
errors.add(new ValidationError(
|
errors.add(new ValidationError(
|
||||||
"/" + fieldName,
|
"/" + fieldName,
|
||||||
@@ -103,6 +103,9 @@ public class ReferenceValidator {
|
|||||||
"x-references value malformed: " + e.getMessage()));
|
"x-references value malformed: " + e.getMessage()));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
// Phase 1 prep: onClose читается, но не используется — runtime cascade
|
||||||
|
// engine добавляется Phase 3. Здесь BLOCK (default) совпадает с текущим
|
||||||
|
// поведением CRUD validator'а.
|
||||||
|
|
||||||
JsonNode value = data.get(fieldName);
|
JsonNode value = data.get(fieldName);
|
||||||
if (value == null || value.isNull()) {
|
if (value == null || value.isNull()) {
|
||||||
@@ -161,8 +164,27 @@ public class ReferenceValidator {
|
|||||||
/**
|
/**
|
||||||
* Parse {@code "dict_name.field"} format. Validates что обе части
|
* Parse {@code "dict_name.field"} format. Validates что обе части
|
||||||
* непустые и не содержат точек (чтобы избежать unexpected nested paths).
|
* непустые и не содержат точек (чтобы избежать 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) {
|
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()) {
|
if (spec == null || spec.isBlank()) {
|
||||||
throw new IllegalArgumentException("empty x-references");
|
throw new IllegalArgumentException("empty x-references");
|
||||||
}
|
}
|
||||||
@@ -177,8 +199,31 @@ public class ReferenceValidator {
|
|||||||
throw new IllegalArgumentException(
|
throw new IllegalArgumentException(
|
||||||
"x-references nested paths not supported in v1: " + spec);
|
"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");
|
var p = ReferenceValidator.parseRef("satellite_type.code");
|
||||||
assertThat(p.dictionaryName()).isEqualTo("satellite_type");
|
assertThat(p.dictionaryName()).isEqualTo("satellite_type");
|
||||||
assertThat(p.fieldName()).isEqualTo("code");
|
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
|
@Test
|
||||||
|
|||||||
Reference in New Issue
Block a user