diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/reference/OnCloseAction.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/reference/OnCloseAction.java new file mode 100644 index 0000000..86d0347 --- /dev/null +++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/reference/OnCloseAction.java @@ -0,0 +1,63 @@ +package cloud.nstart.terravault.ordinis.restapi.service.reference; + +/** + * Поведение при попытке закрыть source record на который ссылаются другие + * (через JSON Schema marker {@code x-references-on-close}). + * + *
Значение объявляется в schema рядом с {@code x-references}: + *
+ * {
+ * "satelliteTypeCode": {
+ * "type": "string",
+ * "x-references": "satellite_type.code",
+ * "x-references-on-close": "block"
+ * }
+ * }
+ *
+ *
+ * Каждое значение enum описывает что произойдёт когда пользователь пытается + * close (или _bulk_close) запись в target словаре, на которую есть active + * referencing records: + * + *
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); + } +} diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/reference/ReferenceValidator.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/reference/ReferenceValidator.java index 3861f16..c82fc21 100644 --- a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/reference/ReferenceValidator.java +++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/reference/ReferenceValidator.java @@ -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). + * + *
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}). + * + *
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); + } + } } diff --git a/ordinis-rest-api/src/test/java/cloud/nstart/terravault/ordinis/restapi/service/reference/OnCloseActionTest.java b/ordinis-rest-api/src/test/java/cloud/nstart/terravault/ordinis/restapi/service/reference/OnCloseActionTest.java new file mode 100644 index 0000000..66b402f --- /dev/null +++ b/ordinis-rest-api/src/test/java/cloud/nstart/terravault/ordinis/restapi/service/reference/OnCloseActionTest.java @@ -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"); + } +} diff --git a/ordinis-rest-api/src/test/java/cloud/nstart/terravault/ordinis/restapi/service/reference/ReferenceValidatorTest.java b/ordinis-rest-api/src/test/java/cloud/nstart/terravault/ordinis/restapi/service/reference/ReferenceValidatorTest.java index b7fa05b..2f91af8 100644 --- a/ordinis-rest-api/src/test/java/cloud/nstart/terravault/ordinis/restapi/service/reference/ReferenceValidatorTest.java +++ b/ordinis-rest-api/src/test/java/cloud/nstart/terravault/ordinis/restapi/service/reference/ReferenceValidatorTest.java @@ -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