diff --git a/examples/conformance/README.md b/examples/conformance/README.md index 91981e5f..23ac7cbe 100644 --- a/examples/conformance/README.md +++ b/examples/conformance/README.md @@ -3,6 +3,8 @@ This retained SQLite workspace is generated from `model.xml`. It verifies explicit `ensureSchema`, Checker rejection before persistence, Create, typed Q and `SmartList`, E loaded/null/not-loaded semantics, Update/version, and Delete. +It also proves that optimistic versions remain isolated when different entity +types use the same numeric ID and their mutation ledgers are merged. ```bash make run diff --git a/examples/conformance/src/main/java/com/teaql/runtimeexampleconformanceservice/App.java b/examples/conformance/src/main/java/com/teaql/runtimeexampleconformanceservice/App.java index 69821de2..22a29f7f 100644 --- a/examples/conformance/src/main/java/com/teaql/runtimeexampleconformanceservice/App.java +++ b/examples/conformance/src/main/java/com/teaql/runtimeexampleconformanceservice/App.java @@ -9,6 +9,8 @@ import io.teaql.core.meta.EntityMetaFactory; import io.teaql.core.meta.SimpleEntityMetaFactory; import io.teaql.core.DataServiceExecutor; +import io.teaql.core.EntityKey; +import io.teaql.core.EntityMutationLedger; import io.teaql.core.DataServiceRegistry; import io.teaql.core.sql.portable.IdSpaceIdGenerator; import io.teaql.core.SchemaExecutor; @@ -80,6 +82,7 @@ public TeaQLRuntime teaQLRuntime( public CommandLineRunner teaQLConsoleStartup( TeaQLRuntime runtime, DataServiceExecutor dataServiceExecutor) { return args -> { + verifySameIdVersionIsolation(); UserContext context = new CustomUserContext(runtime); if (!(dataServiceExecutor instanceof SchemaExecutor schema)) { throw new IllegalStateException("default data service has no schema capability"); @@ -181,10 +184,26 @@ public CommandLineRunner teaQLConsoleStartup( .executeForList(context); require(remaining.isEmpty(), "Deleted row remains visible to ordinary Q API"); System.out.println("PASS Delete (default Q excludes deleted rows)"); - System.out.println("PASS Java minimum runtime conformance: 7/7"); + System.out.println("PASS Java minimum runtime conformance: 8/8"); }; } + private static void verifySameIdVersionIsolation() { + EntityKey order = new EntityKey("Order", 1L); + EntityKey execution = new EntityKey("InferenceExecution", 1L); + EntityMutationLedger target = new EntityMutationLedger(); + EntityMutationLedger source = new EntityMutationLedger(); + target.setOriginalVersion(order, 3L); + source.setOriginalVersion(execution, 9L); + source.set(execution, "execution_status", "COMPLETED"); + target.mergeFrom(source); + require(Long.valueOf(3L).equals(target.getOriginalVersion(order)), + "Order#1 version was overwritten"); + require(Long.valueOf(9L).equals(target.getOriginalVersion(execution)), + "InferenceExecution#1 version was resolved through Order#1"); + System.out.println("PASS Mutation ledger identity (same ID, different entity types keep versions 3/9)"); + } + private static void require(boolean condition, String message) { if (!condition) { throw new IllegalStateException(message); diff --git a/teaql-core/src/main/java/io/teaql/core/checker/CheckResult.java b/teaql-core/src/main/java/io/teaql/core/checker/CheckResult.java index 9ba150e9..f9a2c7ee 100644 --- a/teaql-core/src/main/java/io/teaql/core/checker/CheckResult.java +++ b/teaql-core/src/main/java/io/teaql/core/checker/CheckResult.java @@ -1,6 +1,7 @@ package io.teaql.core.checker; import java.time.LocalDateTime; +import java.util.Locale; public class CheckResult { private RuleId ruleId; @@ -12,6 +13,7 @@ public class CheckResult { private Object systemValue; private String naturalLanguageStatement; + private String sourceInstancePath; public static CheckResult required(ObjectLocation location) { CheckResult checkResult = new CheckResult(); @@ -144,6 +146,27 @@ public void setNaturalLanguageStatement(String pNaturalLanguageStatement) { naturalLanguageStatement = pNaturalLanguageStatement; } + /** Exact RFC 6901 pointer submitted through an accepted input alias. */ + public String getSourceInstancePath() { + return sourceInstancePath; + } + + public void setSourceInstancePath(String pSourceInstancePath) { + sourceInstancePath = pSourceInstancePath; + } + + public WireCheckResult toWire(JsonFieldNamingProfile profile) { + return new WireCheckResult( + ruleId == null ? null : ruleId.name().toLowerCase(Locale.ROOT), + rootType, + location == null ? null : location.segments(), + location == null ? null : location.instancePath(profile), + sourceInstancePath, + inputValue, + systemValue, + naturalLanguageStatement); + } + public enum RuleId { MIN, MAX, diff --git a/teaql-core/src/main/java/io/teaql/core/checker/JsonFieldNamingProfile.java b/teaql-core/src/main/java/io/teaql/core/checker/JsonFieldNamingProfile.java new file mode 100644 index 00000000..cb99e243 --- /dev/null +++ b/teaql-core/src/main/java/io/teaql/core/checker/JsonFieldNamingProfile.java @@ -0,0 +1,46 @@ +package io.teaql.core.checker; + +/** Model-selected naming policy for JSON fields and RFC 6901 instance paths. */ +public enum JsonFieldNamingProfile { + CAMEL_CASE("camelCase"), + SNAKE_CASE("snake_case"), + PASCAL_CASE("PascalCase"); + + private final String modelValue; + + JsonFieldNamingProfile(String pModelValue) { + modelValue = pModelValue; + } + + public String modelValue() { + return modelValue; + } + + public String render(String canonicalName) { + if (this == SNAKE_CASE) { + return canonicalName; + } + String[] parts = canonicalName.split("_", -1); + StringBuilder result = new StringBuilder(); + for (int i = 0; i < parts.length; i++) { + if (parts[i].isEmpty()) continue; + if (i == 0 && this == CAMEL_CASE) { + result.append(parts[i]); + } else { + result.append(Character.toUpperCase(parts[i].charAt(0))) + .append(parts[i].substring(1)); + } + } + return result.toString(); + } + + public static JsonFieldNamingProfile fromModelValue(String value) { + if (value == null || value.isBlank() || "camelCase".equals(value)) { + return CAMEL_CASE; + } + for (JsonFieldNamingProfile profile : values()) { + if (profile.modelValue.equals(value)) return profile; + } + throw new IllegalArgumentException("Unsupported json_field_naming: " + value); + } +} diff --git a/teaql-core/src/main/java/io/teaql/core/checker/ObjectLocation.java b/teaql-core/src/main/java/io/teaql/core/checker/ObjectLocation.java index 66dc1e37..14a705b7 100644 --- a/teaql-core/src/main/java/io/teaql/core/checker/ObjectLocation.java +++ b/teaql-core/src/main/java/io/teaql/core/checker/ObjectLocation.java @@ -61,10 +61,31 @@ public String nativePath() { /** RFC 6901 JSON pointer using TeaQL's default lower-camel wire policy. */ public String instancePath() { - return render(true, true); + return instancePath(JsonFieldNamingProfile.CAMEL_CASE); + } + + /** RFC 6901 JSON pointer rendered with the model-selected wire profile. */ + public String instancePath(JsonFieldNamingProfile profile) { + return render(profile, true); + } + + public List segments() { + List result = new ArrayList<>(); + for (ObjectLocation current = this; current != null; current = current.getParent()) { + if (current instanceof HashLocation hash) { + result.add(0, WireLocationSegment.property(hash.getMember())); + } else if (current instanceof ArrayLocation array) { + result.add(0, WireLocationSegment.index(array.getIndex())); + } + } + return List.copyOf(result); } private String render(boolean lowerCamel, boolean pointer) { + return render(lowerCamel ? JsonFieldNamingProfile.CAMEL_CASE : null, pointer); + } + + private String render(JsonFieldNamingProfile profile, boolean pointer) { List locations = new ArrayList<>(); for (ObjectLocation current = this; current != null; current = current.getParent()) { locations.add(0, current); @@ -72,7 +93,7 @@ private String render(boolean lowerCamel, boolean pointer) { StringBuilder result = new StringBuilder(); for (ObjectLocation location : locations) { if (location instanceof HashLocation hash) { - String member = lowerCamel ? lowerCamel(hash.getMember()) : hash.getMember(); + String member = profile == null ? hash.getMember() : profile.render(hash.getMember()); if (pointer) { result.append('/').append(escapePointer(member)); } else { diff --git a/teaql-core/src/main/java/io/teaql/core/checker/WireCheckResult.java b/teaql-core/src/main/java/io/teaql/core/checker/WireCheckResult.java new file mode 100644 index 00000000..750bca99 --- /dev/null +++ b/teaql-core/src/main/java/io/teaql/core/checker/WireCheckResult.java @@ -0,0 +1,14 @@ +package io.teaql.core.checker; + +import java.util.List; + +/** Stable external projection of a Checker violation. */ +public record WireCheckResult( + String ruleId, + String entityType, + List location, + String instancePath, + String sourceInstancePath, + Object inputValue, + Object systemValue, + String message) {} diff --git a/teaql-core/src/main/java/io/teaql/core/checker/WireLocationSegment.java b/teaql-core/src/main/java/io/teaql/core/checker/WireLocationSegment.java new file mode 100644 index 00000000..46d7f2c4 --- /dev/null +++ b/teaql-core/src/main/java/io/teaql/core/checker/WireLocationSegment.java @@ -0,0 +1,12 @@ +package io.teaql.core.checker; + +/** Canonical KSML property or array-index segment used by wire violations. */ +public record WireLocationSegment(String kind, String name, Integer index) { + public static WireLocationSegment property(String name) { + return new WireLocationSegment("property", name, null); + } + + public static WireLocationSegment index(int index) { + return new WireLocationSegment("index", null, index); + } +} diff --git a/teaql-core/src/test/java/io/teaql/core/checker/ObjectLocationTest.java b/teaql-core/src/test/java/io/teaql/core/checker/ObjectLocationTest.java index 57f038bb..c90e3104 100644 --- a/teaql-core/src/test/java/io/teaql/core/checker/ObjectLocationTest.java +++ b/teaql-core/src/test/java/io/teaql/core/checker/ObjectLocationTest.java @@ -1,5 +1,6 @@ package io.teaql.core.checker; +import java.util.List; import org.junit.Test; import static org.junit.Assert.*; @@ -14,9 +15,25 @@ public void rendersCanonicalNativeAndExternalPaths() { assertEquals("order_items[2].user_url", location.modelPath()); assertEquals("orderItems[2].userUrl", location.nativePath()); assertEquals("/orderItems/2/userUrl", location.instancePath()); + assertEquals("/order_items/2/user_url", location.instancePath(JsonFieldNamingProfile.SNAKE_CASE)); + assertEquals("/OrderItems/2/UserUrl", location.instancePath(JsonFieldNamingProfile.PASCAL_CASE)); assertEquals("order_items[2].user_url", location.toString()); } + @Test + public void projectsCheckerResultWithProfileAndSubmittedAlias() { + CheckResult result = CheckResult.required(ObjectLocation.hashRoot("user_url")); + result.setRootType("customer_account"); + result.setSourceInstancePath("/user_url"); + + WireCheckResult wire = result.toWire(JsonFieldNamingProfile.CAMEL_CASE); + assertEquals("required", wire.ruleId()); + assertEquals("customer_account", wire.entityType()); + assertEquals(List.of(WireLocationSegment.property("user_url")), wire.location()); + assertEquals("/userUrl", wire.instancePath()); + assertEquals("/user_url", wire.sourceInstancePath()); + } + @Test public void escapesJsonPointerMembers() { assertEquals("/a~0~1b", ObjectLocation.hashRoot("a~/b").instancePath()); diff --git a/teaql-tfp-endpoint/src/main/java/io/teaql/tfp/NormalizedWireObject.java b/teaql-tfp-endpoint/src/main/java/io/teaql/tfp/NormalizedWireObject.java new file mode 100644 index 00000000..05d73861 --- /dev/null +++ b/teaql-tfp-endpoint/src/main/java/io/teaql/tfp/NormalizedWireObject.java @@ -0,0 +1,7 @@ +package io.teaql.tfp; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import java.util.Map; + +/** Canonical payload plus the exact accepted JSON pointer used for each KSML field. */ +public record NormalizedWireObject(ObjectNode values, Map sourceInstancePaths) {} diff --git a/teaql-tfp-endpoint/src/main/java/io/teaql/tfp/TfpEndpointHandler.java b/teaql-tfp-endpoint/src/main/java/io/teaql/tfp/TfpEndpointHandler.java index 2d70ce18..1f2e4c20 100644 --- a/teaql-tfp-endpoint/src/main/java/io/teaql/tfp/TfpEndpointHandler.java +++ b/teaql-tfp-endpoint/src/main/java/io/teaql/tfp/TfpEndpointHandler.java @@ -294,8 +294,13 @@ private Map handleMutationActive(UserContext context, TrustedFed if (writable == null || !entityPayload.isObject()) { throw new TfpEndpointException("TFP_INVALID_REQUEST", "Invalid mutation payload"); } + ObjectNode normalizedPayload = (ObjectNode) entityPayload; + WireEntityMetadata metadata = trusted.wireMetadata(entityName); + if (metadata != null) { + normalizedPayload = WireFieldAdapter.normalize(normalizedPayload, metadata).values(); + } ObjectNode mappedPayload = objectMapper.createObjectNode(); - entityPayload.fields().forEachRemaining(entry -> { + normalizedPayload.fields().forEachRemaining(entry -> { String mapped = writable.get(entry.getKey()); if (mapped == null) throw new TfpEndpointException("TFP_FORBIDDEN_FIELD", "Mutation field is not allowed: " + entry.getKey()); diff --git a/teaql-tfp-endpoint/src/main/java/io/teaql/tfp/TrustedFederalContext.java b/teaql-tfp-endpoint/src/main/java/io/teaql/tfp/TrustedFederalContext.java index 95076870..8c136fa7 100644 --- a/teaql-tfp-endpoint/src/main/java/io/teaql/tfp/TrustedFederalContext.java +++ b/teaql-tfp-endpoint/src/main/java/io/teaql/tfp/TrustedFederalContext.java @@ -13,6 +13,7 @@ public final class TrustedFederalContext { private final Map> readableFields; private final Map> writableFields; private final Map> allowedActions; + private final Map wireMetadata; private final int maxPageSize; public TrustedFederalContext(String tenantField, Object tenantId, @@ -21,6 +22,17 @@ public TrustedFederalContext(String tenantField, Object tenantId, Map> readableFields, Map> writableFields, Map> allowedActions, int maxPageSize) { + this(tenantField, tenantId, authenticatedUser, approvedPurpose, allowedEntities, + readableFields, writableFields, allowedActions, maxPageSize, Map.of()); + } + + public TrustedFederalContext(String tenantField, Object tenantId, + String authenticatedUser, String approvedPurpose, + Set allowedEntities, + Map> readableFields, + Map> writableFields, + Map> allowedActions, int maxPageSize, + Map wireMetadata) { this.tenantField = tenantField; this.tenantId = tenantId; this.authenticatedUser = authenticatedUser; @@ -30,6 +42,7 @@ public TrustedFederalContext(String tenantField, Object tenantId, this.writableFields = Map.copyOf(writableFields); this.allowedActions = Map.copyOf(allowedActions); this.maxPageSize = maxPageSize; + this.wireMetadata = Map.copyOf(wireMetadata); } public String tenantField() { return tenantField; } @@ -37,8 +50,18 @@ public TrustedFederalContext(String tenantField, Object tenantId, public String authenticatedUser() { return authenticatedUser; } public String approvedPurpose() { return approvedPurpose; } public Set allowedEntities() { return allowedEntities; } - public Map readableFields(String entity) { return readableFields.get(entity); } - public Map writableFields(String entity) { return writableFields.get(entity); } + public Map readableFields(String entity) { + return effectiveFields(entity, readableFields.get(entity)); + } + public Map writableFields(String entity) { + return effectiveFields(entity, writableFields.get(entity)); + } public Set allowedActions(String entity) { return allowedActions.get(entity); } public int maxPageSize() { return maxPageSize; } + public WireEntityMetadata wireMetadata(String entity) { return wireMetadata.get(entity); } + + private Map effectiveFields(String entity, Map fields) { + WireEntityMetadata metadata = wireMetadata.get(entity); + return fields == null || metadata == null ? fields : metadata.acceptedPolicyMap(fields); + } } diff --git a/teaql-tfp-endpoint/src/main/java/io/teaql/tfp/WireEntityMetadata.java b/teaql-tfp-endpoint/src/main/java/io/teaql/tfp/WireEntityMetadata.java new file mode 100644 index 00000000..74c68743 --- /dev/null +++ b/teaql-tfp-endpoint/src/main/java/io/teaql/tfp/WireEntityMetadata.java @@ -0,0 +1,65 @@ +package io.teaql.tfp; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** Generated, serializer-independent mapping between wire names and canonical KSML fields. */ +public final class WireEntityMetadata { + private final String entity; + private final Map acceptedToCanonical; + private final Map canonicalToWire; + + public WireEntityMetadata(String entity, Map canonicalToWire, + Map aliases) { + this.entity = entity; + this.canonicalToWire = Map.copyOf(canonicalToWire); + Map accepted = new LinkedHashMap<>(); + canonicalToWire.forEach((canonical, wire) -> { + register(accepted, canonical, canonical); + register(accepted, wire, canonical); + }); + aliases.forEach((alias, canonical) -> { + if (!canonicalToWire.containsKey(canonical)) { + throw new IllegalArgumentException("Unknown canonical field for alias: " + canonical); + } + register(accepted, alias, canonical); + }); + this.acceptedToCanonical = Map.copyOf(accepted); + } + + /** Converts dependency-free metadata emitted by GeneratedRuntimeModule. */ + public static Map fromGenerated( + Map> mappings, + Map> aliases) { + Map result = new LinkedHashMap<>(); + mappings.forEach((entity, fields) -> result.put(entity, + new WireEntityMetadata(entity, fields, aliases.getOrDefault(entity, Map.of())))); + return Map.copyOf(result); + } + + private static void register(Map accepted, String name, String canonical) { + String previous = accepted.putIfAbsent(name, canonical); + if (previous != null && !previous.equals(canonical)) { + throw new IllegalArgumentException("Wire field alias is ambiguous: " + name); + } + } + + public String entity() { return entity; } + + public String canonicalField(String submitted) { + return acceptedToCanonical.get(submitted); + } + + public String wireField(String canonical) { + return canonicalToWire.get(canonical); + } + + Map acceptedPolicyMap(Map canonicalPolicy) { + Map result = new LinkedHashMap<>(); + acceptedToCanonical.forEach((accepted, canonical) -> { + String internal = canonicalPolicy.get(canonical); + if (internal != null) result.put(accepted, internal); + }); + return Map.copyOf(result); + } +} diff --git a/teaql-tfp-endpoint/src/main/java/io/teaql/tfp/WireFieldAdapter.java b/teaql-tfp-endpoint/src/main/java/io/teaql/tfp/WireFieldAdapter.java new file mode 100644 index 00000000..fc6ef961 --- /dev/null +++ b/teaql-tfp-endpoint/src/main/java/io/teaql/tfp/WireFieldAdapter.java @@ -0,0 +1,62 @@ +package io.teaql.tfp; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.JsonNodeFactory; +import com.fasterxml.jackson.databind.node.ObjectNode; +import java.util.LinkedHashMap; +import java.util.Map; +import io.teaql.core.checker.CheckResult; +import io.teaql.core.checker.WireLocationSegment; + +/** Strict boundary adapter shared by TFP mutation and generated HTTP input adapters. */ +public final class WireFieldAdapter { + private WireFieldAdapter() {} + + public static NormalizedWireObject normalize(ObjectNode submitted, WireEntityMetadata metadata) { + ObjectNode canonical = JsonNodeFactory.instance.objectNode(); + Map paths = new LinkedHashMap<>(); + submitted.fields().forEachRemaining(entry -> { + String canonicalName = metadata.canonicalField(entry.getKey()); + if (canonicalName == null) { + throw new TfpEndpointException("WIRE_UNKNOWN_FIELD", + "Unknown field at /" + escape(entry.getKey())); + } + if (canonical.has(canonicalName)) { + throw new TfpEndpointException("WIRE_FIELD_COLLISION", + "Multiple submitted fields resolve to " + canonicalName); + } + canonical.set(canonicalName, entry.getValue()); + paths.put(canonicalName, "/" + escape(entry.getKey())); + }); + return new NormalizedWireObject(canonical, Map.copyOf(paths)); + } + + public static ObjectNode encode(ObjectNode canonical, WireEntityMetadata metadata) { + ObjectNode wire = JsonNodeFactory.instance.objectNode(); + canonical.fields().forEachRemaining(entry -> { + String wireName = metadata.wireField(entry.getKey()); + if (wireName == null) { + throw new TfpEndpointException("WIRE_UNKNOWN_FIELD", + "Unknown canonical field: " + entry.getKey()); + } + wire.set(wireName, entry.getValue()); + }); + return wire; + } + + /** Adds the submitted alias path without changing the canonical checker location. */ + public static void retainSubmittedPaths(Iterable results, + NormalizedWireObject normalized) { + for (CheckResult result : results) { + if (result.getLocation() == null || result.getLocation().segments().isEmpty()) continue; + WireLocationSegment first = result.getLocation().segments().get(0); + if (!"property".equals(first.kind())) continue; + String path = normalized.sourceInstancePaths().get(first.name()); + if (path != null) result.setSourceInstancePath(path); + } + } + + private static String escape(String value) { + return value.replace("~", "~0").replace("/", "~1"); + } +} diff --git a/teaql-tfp-endpoint/src/test/java/io/teaql/tfp/WireFieldAdapterTest.java b/teaql-tfp-endpoint/src/test/java/io/teaql/tfp/WireFieldAdapterTest.java new file mode 100644 index 00000000..8c6c3c14 --- /dev/null +++ b/teaql-tfp-endpoint/src/test/java/io/teaql/tfp/WireFieldAdapterTest.java @@ -0,0 +1,61 @@ +package io.teaql.tfp; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import java.util.Map; +import org.junit.Test; +import io.teaql.core.checker.CheckResult; +import io.teaql.core.checker.JsonFieldNamingProfile; +import io.teaql.core.checker.ObjectLocation; + +public class WireFieldAdapterTest { + private final ObjectMapper json = new ObjectMapper(); + private final WireEntityMetadata metadata = new WireEntityMetadata("School", Map.of( + "user_url", "userUrl", "school_type", "schoolType"), + Map.of("legacyUrl", "user_url")); + + @Test + public void matchesTypescriptFixtureAndRetainsSubmittedPath() throws Exception { + ObjectNode input = (ObjectNode) json.readTree( + "{\"legacyUrl\":\"https://teaql.io\",\"schoolType\":1001}"); + NormalizedWireObject normalized = WireFieldAdapter.normalize(input, metadata); + assertEquals("https://teaql.io", normalized.values().path("user_url").asText()); + assertEquals(1001, normalized.values().path("school_type").asInt()); + assertEquals("/legacyUrl", normalized.sourceInstancePaths().get("user_url")); + assertEquals("/schoolType", normalized.sourceInstancePaths().get("school_type")); + assertEquals("userUrl", metadata.wireField("user_url")); + CheckResult violation = CheckResult.required(ObjectLocation.hashRoot("user_url")); + WireFieldAdapter.retainSubmittedPaths(java.util.List.of(violation), normalized); + assertEquals("/legacyUrl", violation.getSourceInstancePath()); + } + + @Test + public void rejectsUnknownAndAliasCollisionBeforeSave() throws Exception { + TfpEndpointException unknown = assertThrows(TfpEndpointException.class, + () -> WireFieldAdapter.normalize((ObjectNode) json.readTree("{\"unknown\":1}"), metadata)); + assertEquals("WIRE_UNKNOWN_FIELD", unknown.getCode()); + TfpEndpointException collision = assertThrows(TfpEndpointException.class, + () -> WireFieldAdapter.normalize((ObjectNode) json.readTree( + "{\"userUrl\":\"a\",\"legacyUrl\":\"a\"}"), metadata)); + assertEquals("WIRE_FIELD_COLLISION", collision.getCode()); + } + + @Test + public void serializesCanonicalLocationAndSelectedWirePath() throws Exception { + CheckResult violation = CheckResult.required(ObjectLocation.hashRoot("school_type")); + violation.setRootType("School"); + violation.setSourceInstancePath("/legacySchoolType"); + + ObjectNode wire = (ObjectNode) json.valueToTree( + violation.toWire(JsonFieldNamingProfile.CAMEL_CASE)); + assertEquals("required", wire.path("ruleId").asText()); + assertEquals("School", wire.path("entityType").asText()); + assertEquals("property", wire.path("location").path(0).path("kind").asText()); + assertEquals("school_type", wire.path("location").path(0).path("name").asText()); + assertEquals("/schoolType", wire.path("instancePath").asText()); + assertEquals("/legacySchoolType", wire.path("sourceInstancePath").asText()); + } +}