forked from boskworks/bosk
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
3 changed files
with
360 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
145 changes: 145 additions & 0 deletions
145
bosk-jackson/src/main/java/works/bosk/jackson/JsonSurgeon.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
211
bosk-jackson/src/test/java/works/bosk/jackson/JsonSurgeonTest.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} | ||
} | ||
} |