Skip to content

Commit

Permalink
Initial JsonSurgeon
Browse files Browse the repository at this point in the history
  • Loading branch information
prdoyle committed Jan 25, 2025
1 parent ee395e0 commit 651e7b0
Show file tree
Hide file tree
Showing 3 changed files with 360 additions and 0 deletions.
4 changes: 4 additions & 0 deletions bosk-core/src/main/java/works/bosk/Reference.java
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,10 @@ default BindingEnvironment parametersFrom(Path definitePath) {
return path().parametersFrom(definitePath);
}

default boolean isRoot() {
return path().isEmpty();
}

/**
* @return The equivalent of {@link Bosk#rootReference()} on the <code>bosk</code> to which this Reference applies,
* but without static type checking; the intent is that you'd call {@link #then} on the resulting reference,
Expand Down
145 changes: 145 additions & 0 deletions bosk-jackson/src/main/java/works/bosk/jackson/JsonSurgeon.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
package works.bosk.jackson;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.fasterxml.jackson.databind.node.TextNode;
import java.util.Map;
import works.bosk.Catalog;
import works.bosk.Listing;
import works.bosk.Reference;
import works.bosk.SideTable;
import works.bosk.exceptions.NotYetImplementedException;
import works.bosk.jackson.JsonSurgeon.NodeLocation.ArrayElement;
import works.bosk.jackson.JsonSurgeon.NodeLocation.NonexistentParent;
import works.bosk.jackson.JsonSurgeon.NodeLocation.ObjectMember;
import works.bosk.jackson.JsonSurgeon.NodeLocation.Root;

import static works.bosk.jackson.JsonSurgeon.ReplacementStyle.ID_ONLY;
import static works.bosk.jackson.JsonSurgeon.ReplacementStyle.PLAIN;
import static works.bosk.jackson.JsonSurgeon.ReplacementStyle.WRAPPED_ENTITY;

/**
* Note: currently only works with {@link works.bosk.jackson.JacksonPluginConfiguration.MapShape#ARRAY}.
*/
public class JsonSurgeon {
public sealed interface NodeLocation {
public record Root() implements NodeLocation {}
public record ObjectMember(ObjectNode parent, String memberName) implements NodeLocation {}
public record ArrayElement(ArrayNode parent, int elementIndex) implements NodeLocation {}
public record NonexistentParent() implements NodeLocation {}
}

/**
* Describes what should be placed at a {@link NodeLocation} in order to
* {@link works.bosk.BoskDriver#submitReplacement replace} a bosk node.
*/
public enum ReplacementStyle {
/**
* The serialized form of the resired value
*/
PLAIN,

/**
* The {@link works.bosk.Entity#id id} of the desired entity
*/
ID_ONLY,

/**
* A {@link ObjectNode} having a single member whose name is the desired
* entity's {@link works.bosk.Entity#id id} and whose value is the serialized
* form of the desired entity.
*/
WRAPPED_ENTITY,
};

public record NodeInfo(NodeLocation location, ReplacementStyle replacementStyle) {}

/**
* @return null if {@code doc} has no node corresponding to {@code ref}
*/
public JsonNode node(JsonNode doc, Reference<?> ref) {
return getNode(nodeInfo(doc, ref).location, doc);
}

/**
* @return the JSON node that must be modified to replace or delete {@code ref}.
*/
public NodeInfo nodeInfo(JsonNode doc, Reference<?> ref) {
if (ref.isRoot()) {
return new NodeInfo(new Root(), PLAIN);
}

// For some kinds of enclosing nodes, the JSON structure differs from the bosk structure.
// Peel off those cases.
Reference<?> enclosingRef = ref.enclosingReference(Object.class);
NodeLocation parentLocation = nodeInfo(doc, enclosingRef).location;
var parent = getNode(parentLocation, doc);
if (Listing.class.isAssignableFrom(enclosingRef.targetClass())) {
if (parent == null) {
return new NodeInfo(new NonexistentParent(), ID_ONLY);
}
var ids = (ArrayNode)parent.get("ids");
var id = ref.path().lastSegment();
for (int i = 0; i < ids.size(); i++) {
if (id.equals(((TextNode)ids.get(i)).textValue())) {
return new NodeInfo(new ArrayElement(ids, i), ID_ONLY);
}
}
// New entries go at the end
return new NodeInfo(new ArrayElement(ids, ids.size()), ID_ONLY);
} else if (Catalog.class.isAssignableFrom(enclosingRef.targetClass())) {
return new NodeInfo(
(parent == null)
? new NonexistentParent()
: findArrayEntryWithId(parent, ref.path().lastSegment()),
WRAPPED_ENTITY);
} else if (SideTable.class.isAssignableFrom(enclosingRef.targetClass())) {
return new NodeInfo(
(parent == null)
? new NonexistentParent()
: findArrayEntryWithId(parent.get("valuesById"), ref.path().lastSegment()),
WRAPPED_ENTITY);
}

// For every other type, this is an object member contained in the parent
return new NodeInfo(
parent == null
? new NonexistentParent()
: new ObjectMember((ObjectNode) parent, ref.path().lastSegment()),
PLAIN);
}

private static NodeLocation findArrayEntryWithId(JsonNode entries, String id) {
ArrayNode entriesArray = (ArrayNode) entries;
for (int i = 0; i < entriesArray.size(); i++) {
ObjectNode entryObject = (ObjectNode) entries.get(i);
var properties = entryObject.properties();
if (properties.size() != 1) {
throw new NotYetImplementedException();
}
Map.Entry<String, JsonNode> entry = properties.iterator().next();
if (id.equals(entry.getKey())) {
return new ArrayElement(entriesArray, i);
}
}
return new ArrayElement(entriesArray, entries.size());
}

private static JsonNode getNode(NodeLocation nodeLocation, JsonNode rootDocument) {
return switch (nodeLocation) {
case ArrayElement a -> {
yield a.parent().get(a.elementIndex());
}
case ObjectMember o -> {
yield o.parent().get(o.memberName());
}
case Root() -> {
yield rootDocument;
}
case NonexistentParent() -> {
yield null;
}
};
}
}
211 changes: 211 additions & 0 deletions bosk-jackson/src/test/java/works/bosk/jackson/JsonSurgeonTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
package works.bosk.jackson;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import java.io.IOException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import works.bosk.Bosk;
import works.bosk.Catalog;
import works.bosk.CatalogReference;
import works.bosk.Entity;
import works.bosk.Identifier;
import works.bosk.Listing;
import works.bosk.ListingEntry;
import works.bosk.ListingReference;
import works.bosk.Reference;
import works.bosk.SideTable;
import works.bosk.SideTableReference;
import works.bosk.StateTreeNode;
import works.bosk.annotations.ReferencePath;
import works.bosk.exceptions.InvalidTypeException;
import works.bosk.jackson.JsonSurgeon.NodeInfo;
import works.bosk.jackson.JsonSurgeon.NodeLocation.ArrayElement;
import works.bosk.jackson.JsonSurgeon.NodeLocation.NonexistentParent;
import works.bosk.jackson.JsonSurgeon.NodeLocation.ObjectMember;
import works.bosk.jackson.JsonSurgeon.NodeLocation.Root;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertSame;
import static works.bosk.BoskTestUtils.boskName;
import static works.bosk.ListingEntry.LISTING_ENTRY;
import static works.bosk.jackson.JacksonPluginConfiguration.MapShape.ARRAY;
import static works.bosk.jackson.JsonSurgeon.ReplacementStyle.ID_ONLY;
import static works.bosk.jackson.JsonSurgeon.ReplacementStyle.PLAIN;
import static works.bosk.jackson.JsonSurgeon.ReplacementStyle.WRAPPED_ENTITY;

public class JsonSurgeonTest {
Bosk<JsonRoot> bosk;
Refs refs;
JacksonPlugin jacksonPlugin;
ObjectMapper mapper;
JsonSurgeon surgeon;

@BeforeEach
void setUp() throws InvalidTypeException {
bosk = new Bosk<JsonRoot>(
boskName(),
JsonRoot.class,
b->JsonRoot.empty(b.buildReferences(Refs.class)),
Bosk.simpleDriver());
refs = bosk.buildReferences(Refs.class);
jacksonPlugin = new JacksonPlugin(new JacksonPluginConfiguration(ARRAY));
mapper = new ObjectMapper();
mapper.registerModule(jacksonPlugin.moduleFor(bosk));
surgeon = new JsonSurgeon();
}

public record JsonRoot(
Identifier id,
String string,
Catalog<JsonEntity> catalog,
Listing<JsonEntity> listing,
SideTable<JsonEntity, JsonEntity> sideTable
) implements StateTreeNode {
public static final JsonRoot empty(Refs refs) {
return new JsonRoot(
Identifier.from("testID"),
"testString",
Catalog.empty(),
Listing.empty(refs.catalog()),
SideTable.empty(refs.catalog()));
}
}

public record JsonEntity(
Identifier id
) implements Entity {}

public interface Refs {
@ReferencePath("/id") Reference<Identifier> id();
@ReferencePath("/string") Reference<String> string();
@ReferencePath("/catalog") CatalogReference<JsonEntity> catalog();
@ReferencePath("/catalog/-jsonEntity-") Reference<JsonEntity> catalogEntry(Identifier jsonEntity);
@ReferencePath("/catalog/-jsonEntity-/id") Reference<Identifier> catalogEntryID(Identifier jsonEntity);
@ReferencePath("/listing") ListingReference<JsonEntity> listing();
@ReferencePath("/listing/-jsonEntity-") Reference<ListingEntry> listingEntry(Identifier jsonEntity);
@ReferencePath("/sideTable") SideTableReference<JsonEntity, JsonEntity> sideTable();
@ReferencePath("/sideTable/-jsonEntity-") Reference<JsonEntity> sideTableEntry(Identifier jsonEntity);
@ReferencePath("/sideTable/-jsonEntity-/id") Reference<Identifier> sideTableEntryID(Identifier jsonEntity);
}

@Test
void root() throws IOException, InterruptedException {
JsonNode doc = boskContents();
NodeInfo expected = new NodeInfo(new Root(), PLAIN);
NodeInfo actual = surgeon.nodeInfo(doc, bosk.rootReference());
assertEquals(expected, actual);
assertEquals(doc, surgeon.node(doc, bosk.rootReference()));
}

@Test
void id() throws IOException, InterruptedException {
JsonNode doc = boskContents();
NodeInfo expected = new NodeInfo(new ObjectMember((ObjectNode) doc, "id"), PLAIN);
NodeInfo actual = surgeon.nodeInfo(doc, refs.id());
assertEquals(expected, actual);
}

@Test
void string() throws IOException, InterruptedException {
JsonNode doc = boskContents();
NodeInfo expected = new NodeInfo(new ObjectMember((ObjectNode) doc, "string"), PLAIN);
NodeInfo actual = surgeon.nodeInfo(doc, refs.string());
assertEquals(expected, actual);
}

@Test
void catalog() throws IOException, InterruptedException {
JsonNode doc = boskContents();
NodeInfo expected = new NodeInfo(new ObjectMember((ObjectNode) doc, "catalog"), PLAIN);
NodeInfo actual = surgeon.nodeInfo(doc, refs.catalog());
assertEquals(expected, actual);
}

@Test
void catalogEntry() throws IOException, InterruptedException {
Identifier id = Identifier.from("testEntry");
bosk.driver().submitReplacement(refs.catalogEntry(id), new JsonEntity(id));
JsonNode doc = boskContents();
JsonNode catalogArray = doc.get("catalog");
{
NodeInfo expected = new NodeInfo(new ArrayElement((ArrayNode) catalogArray, 0), WRAPPED_ENTITY);
NodeInfo actual = surgeon.nodeInfo(doc, refs.catalogEntry(id));
assertEquals(expected, actual);
}
{
NodeInfo expected = new NodeInfo(new ArrayElement((ArrayNode) catalogArray, 1), WRAPPED_ENTITY);
NodeInfo actual = surgeon.nodeInfo(doc, refs.catalogEntry(Identifier.from("NONEXISTENT")));
assertEquals(expected, actual);
}
{
NodeInfo expected = new NodeInfo(new NonexistentParent(), PLAIN);
NodeInfo actual = surgeon.nodeInfo(doc, refs.catalogEntryID(Identifier.from("NONEXISTENT")));
assertEquals(expected, actual);
}
}

@Test void listing() throws IOException, InterruptedException {
JsonNode doc = boskContents();
NodeInfo expected = new NodeInfo(new ObjectMember((ObjectNode) doc, "listing"), PLAIN);
NodeInfo actual = surgeon.nodeInfo(doc, refs.listing());
assertEquals(expected, actual);
}

@Test
void listingEntry() throws IOException, InterruptedException {
Identifier id = Identifier.from("testEntry");
bosk.driver().submitReplacement(refs.listingEntry(id), LISTING_ENTRY);
JsonNode doc = boskContents();
JsonNode idsArray = doc.get("listing").get("ids");
{
NodeInfo expected = new NodeInfo(new ArrayElement((ArrayNode) idsArray, 0), ID_ONLY);
NodeInfo actual = surgeon.nodeInfo(doc, refs.listingEntry(id));
assertEquals(expected, actual);
}
{
NodeInfo expected = new NodeInfo(new ArrayElement((ArrayNode) idsArray, 1), ID_ONLY);
NodeInfo actual = surgeon.nodeInfo(doc, refs.listingEntry(Identifier.from("NONEXISTENT")));
assertEquals(expected, actual);
}
}

@Test void sideTable() throws IOException, InterruptedException {
JsonNode doc = boskContents();
NodeInfo expected = new NodeInfo(new ObjectMember((ObjectNode) doc, "sideTable"), PLAIN);
NodeInfo actual = surgeon.nodeInfo(doc, refs.sideTable());
assertEquals(expected, actual);
}

@Test
void sideTableEntry() throws IOException, InterruptedException {
Identifier id = Identifier.from("testKey");
bosk.driver().submitReplacement(refs.sideTableEntry(id), new JsonEntity(Identifier.from("testValue")));
JsonNode doc = boskContents();
JsonNode valuesById = doc.get("sideTable").get("valuesById");
{
NodeInfo expected = new NodeInfo(new ArrayElement((ArrayNode) valuesById, 0), WRAPPED_ENTITY);
NodeInfo actual = surgeon.nodeInfo(doc, refs.sideTableEntry(id));
assertEquals(expected, actual);
}
{
NodeInfo expected = new NodeInfo(new ArrayElement((ArrayNode) valuesById, 1), WRAPPED_ENTITY);
NodeInfo actual = surgeon.nodeInfo(doc, refs.sideTableEntry(Identifier.from("NONEXISTENT")));
assertEquals(expected, actual);
}
{
NodeInfo expected = new NodeInfo(new NonexistentParent(), PLAIN);
NodeInfo actual = surgeon.nodeInfo(doc, refs.sideTableEntryID(Identifier.from("NONEXISTENT")));
assertEquals(expected, actual);
}
}

JsonNode boskContents() throws IOException, InterruptedException {
bosk.driver().flush();
try (var __ = bosk.readContext()) {
return mapper.convertValue(bosk.rootReference().value(), JsonNode.class);
}
}
}

0 comments on commit 651e7b0

Please sign in to comment.