Skip to content

Commit

Permalink
Add Jackson linked-map representation
Browse files Browse the repository at this point in the history
  • Loading branch information
prdoyle committed Sep 8, 2024
1 parent 5081338 commit 66a50aa
Show file tree
Hide file tree
Showing 5 changed files with 201 additions and 41 deletions.
142 changes: 122 additions & 20 deletions bosk-jackson/src/main/java/works/bosk/jackson/JacksonPlugin.java
Original file line number Diff line number Diff line change
Expand Up @@ -343,8 +343,7 @@ private JsonDeserializer<Catalog<Entity>> catalogDeserializer(JavaType type, Des
@Override
@SuppressWarnings({"rawtypes", "unchecked"})
public Catalog<Entity> deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
JsonDeserializer valueDeserializer = ctxt.findContextualValueDeserializer(entryType, null);
LinkedHashMap<Identifier, Entity> entries = readMapEntries(p, valueDeserializer, ctxt);
LinkedHashMap<Identifier, Entity> entries = readMapEntries(p, entryType, ctxt);
return Catalog.of(entries.values());
}
};
Expand Down Expand Up @@ -438,15 +437,13 @@ public SideTable<Entity, Object> deserialize(JsonParser p, DeserializationContex
Reference<Catalog<Entity>> domain = null;
LinkedHashMap<Identifier, Object> valuesById = null;

JsonDeserializer<Object> valueDeserializer = ctxt.findContextualValueDeserializer(valueType, null);

expect(START_OBJECT, p);
while (p.nextToken() != END_OBJECT) {
p.nextValue();
switch (p.currentName()) {
case "valuesById":
if (valuesById == null) {
valuesById = readMapEntries(p, valueDeserializer, ctxt);
valuesById = readMapEntries(p, valueType, ctxt);
} else {
throw new JsonParseException(p, "'valuesById' field appears twice");
}
Expand Down Expand Up @@ -577,6 +574,13 @@ private abstract static class BoskDeserializer<T> extends JsonDeserializer<T> {
}

private <V> void writeMapEntries(JsonGenerator gen, Set<Entry<Identifier,V>> entries, SerializerProvider serializers) throws IOException {
switch (config.mapShape()) {
case ARRAY -> writeEntriesAsArray(gen, entries, serializers);
case LINKED_MAP -> writeEntriesAsLinkedMap(gen, entries, serializers);
}
}

private static <V> void writeEntriesAsArray(JsonGenerator gen, Set<Entry<Identifier, V>> entries, SerializerProvider serializers) throws IOException {
gen.writeStartArray();
for (Entry<Identifier, V> entry: entries) {
gen.writeStartObject();
Expand All @@ -588,32 +592,128 @@ private <V> void writeMapEntries(JsonGenerator gen, Set<Entry<Identifier,V>> ent
gen.writeEndArray();
}

private static <V> void writeEntriesAsLinkedMap(JsonGenerator gen, Set<Entry<Identifier, V>> entries, SerializerProvider serializers) throws IOException {
gen.writeStartObject();
if (!entries.isEmpty()) {
if (entries.size() == 1) {
var entry = entries.iterator().next();
gen.writeStringField(FIRST, entry.getKey().toString());
gen.writeStringField(LAST, entry.getKey().toString());
writeEntryAsField(gen, Optional.empty(), entry, Optional.empty(), serializers);
} else {
// This will be so much easier with a list
List<Entry<Identifier, V>> list = List.copyOf(entries);
gen.writeStringField(FIRST, list.getFirst().getKey().toString());
gen.writeStringField(LAST, list.getLast().getKey().toString());
writeEntryAsField(gen,
Optional.empty(),
list.getFirst(),
Optional.of(list.get(1).getKey()),
serializers);
for (int i = 1; i < list.size()-1; i++) {
writeEntryAsField(gen,
Optional.of(list.get(i-1).getKey()),
list.get(i),
Optional.of(list.get(i+1).getKey()),
serializers);
}
writeEntryAsField(gen,
Optional.of(list.get(list.size()-2).getKey()),
list.getLast(),
Optional.empty(),
serializers);
}
}
gen.writeEndObject();
}

private static <V> void writeEntryAsField(JsonGenerator gen, Optional<Identifier> prev, Entry<Identifier, V> entry, Optional<Identifier> next, SerializerProvider serializers) throws IOException {
gen.writeFieldName(entry.getKey().toString());
JsonSerializer<Object> entryDeserializer = serializers.findContentValueSerializer(
TypeFactory.defaultInstance().constructParametricType(LinkedMapEntry.class, entry.getValue().getClass()),
null);
entryDeserializer.serialize(new LinkedMapEntry<>(prev.map(Object::toString), next.map(Object::toString), entry.getValue()), gen, serializers);
}

/**
* Leaves the parser sitting on the END_ARRAY token. You could call nextToken() to continue with parsing.
*/
private <V> LinkedHashMap<Identifier, V> readMapEntries(JsonParser p, JsonDeserializer<V> valueDeserializer, DeserializationContext ctxt) throws IOException {
private <V> LinkedHashMap<Identifier, V> readMapEntries(JsonParser p, JavaType valueType, DeserializationContext ctxt) throws IOException {
JsonDeserializer<V> valueDeserializer = (JsonDeserializer<V>) ctxt.findContextualValueDeserializer(valueType, null);
LinkedHashMap<Identifier, V> result = new LinkedHashMap<>();
expect(START_ARRAY, p);
while (p.nextToken() != END_ARRAY) {
expect(START_OBJECT, p);
p.nextValue();
String fieldName = p.currentName();
Identifier entryID = Identifier.from(fieldName);
V value;
try (@SuppressWarnings("unused") DeserializationScope scope = entryDeserializationScope(entryID)) {
value = valueDeserializer.deserialize(p, ctxt);
if (p.currentToken() == START_OBJECT) {
JsonDeserializer<Object> entryDeserializer = ctxt.findContextualValueDeserializer(
TypeFactory.defaultInstance().constructParametricType(LinkedMapEntry.class, valueType),
null);
HashMap<String, LinkedMapEntry<V>> entries = new HashMap<>();
String first = null;
String last = null;
while (p.nextToken() != END_OBJECT) {
p.nextValue();
String fieldName = p.currentName();
switch (fieldName) {
case FIRST -> first = p.getText();
case LAST -> last = p.getText();
default -> {
Identifier entryID = Identifier.from(fieldName);
try (@SuppressWarnings("unused") DeserializationScope scope = entryDeserializationScope(entryID)) {
@SuppressWarnings("unchecked")
LinkedMapEntry<V> entry = (LinkedMapEntry<V>) entryDeserializer.deserialize(p, ctxt);
entries.put(fieldName, entry);
// p.nextToken();
}
}
}
}
String cur = first;
while (cur != null) {
LinkedMapEntry<V> entry = entries.get(cur);
if (entry == null) {
throw new JsonParseException(p, "No such entry: \"" + cur + "\"");
}
result.put(Identifier.from(cur), entry.value());
String next = entry.next().orElse(null);
if (next == null && !cur.equals(last)) {
throw new JsonParseException(p, "Entry \" + cur + \" has no next pointer but does not match last = \" + last + \"");
}
// TODO: Verify "prev" pointers
cur = next;
}
p.nextToken();
expect(END_OBJECT, p);
} else {
expect(START_ARRAY, p);
while (p.nextToken() != END_ARRAY) {
expect(START_OBJECT, p);
p.nextValue();
String fieldName = p.currentName();
Identifier entryID = Identifier.from(fieldName);
V value;
try (@SuppressWarnings("unused") DeserializationScope scope = entryDeserializationScope(entryID)) {
value = valueDeserializer.deserialize(p, ctxt);
}
p.nextToken();
expect(END_OBJECT, p);

V oldValue = result.put(entryID, value);
if (oldValue != null) {
throw new JsonParseException(p, "Duplicate sideTable entry '" + fieldName + "'");
V oldValue = result.put(entryID, value);
if (oldValue != null) {
throw new JsonParseException(p, "Duplicate sideTable entry '" + fieldName + "'");
}
}
}
return result;
}

/**
* Structure of the field values used by the {@link JacksonPluginConfiguration.MapShape#LINKED_MAP LINKED_MAP} format.
* @param prev the key corresponding to the previous map entry, or {@link Optional#empty() empty} if none.
* @param next the key corresponding to the next map entry, or {@link Optional#empty() empty} if none.
* @param value the actual map entry's value
*/
public record LinkedMapEntry<V>(
Optional<String> prev,
Optional<String> next,
V value
) implements StateTreeNode {}

private static final JavaType ID_LIST_TYPE = TypeFactory.defaultInstance().constructType(new TypeReference<
List<Identifier>>() {});

Expand Down Expand Up @@ -856,4 +956,6 @@ public static void expect(JsonToken expected, JsonParser p) throws IOException {
}
}

private static final String FIRST = "-first";
private static final String LAST = "-last";
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,62 @@
package works.bosk.jackson;

public record JacksonPluginConfiguration(
boolean useLinkedEntries
MapShape mapShape
) {
public static JacksonPluginConfiguration defaultConfiguration() {
return new JacksonPluginConfiguration(false);
return new JacksonPluginConfiguration(MapShape.ARRAY);
}

/**
* How bosk's ordered maps should translate to JSON, where the order of object
* fields is generally not preserved.
*/
public enum MapShape {
/**
* A shape intended for brevity, human readability, and efficient serialization/deserialization.
* <p>
* An array of single-field objects, where the field name is the map entry's key
* and the field value is the map entry's value.
*/
ARRAY,

/**
* A shape intended for efficient incremental modification,
* especially for values stored in a database that supports JSON
* but does not preserve object field order (like Postgresql).
* Inspired by {@link java.util.LinkedHashMap LinkedHashMap}.
* <p>
* An object containing the natural keys and values of the map being stored,
* with a few changes:
*
* <ul>
* <li>
* The object also has fields "{@code -first}" and "{@code -last}" pointing
* at the first and last map entries, so they can be found efficiently.
* (These names have leading dashes to distinguish them from valid
* {@link works.bosk.Identifier Identifier} values.)
* </li>
* <li>
* The object's field values are themselves objects defined by
* {@link works.bosk.jackson.JacksonPlugin.LinkedMapEntry LinkedMapEntry}.
* </li>
* </ul>
*
* The resulting structure supports the following operations in O(1) time:
* <ul>
* <li>
* Lookup an entry given its ID
* </li>
* <li>
* Add a new entry at the end.
* </li>
* <li>
* Delete an entry.
* </li>
* </ul>
*
* It also supports linear time walking of the entries in both forward and reverse order.
*/
LINKED_MAP,
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -626,11 +626,6 @@ void nonexistentPath_throws() {
.readValue("\"/some/nonexistent/path\""));
}

@Test
void catalogFromEmptyMap_throws() {
assertJsonException("{}", Catalog.class, TestEntity.class);
}

@Test
void catalogWithContentsArray_throws() {
assertJsonException("{ \"contents\": [] }", Catalog.class, TestEntity.class);
Expand Down Expand Up @@ -681,11 +676,6 @@ void sideTableWithTwoDomains_throws() {
assertJsonException("{ \"domain\": \"/entities\", \"domain\": \"/entities\", \"valuesById\": [] }", SideTable.class, TestEntity.class, String.class);
}

@Test
void sideTableWithValuesMap_throws() {
assertJsonException("{ \"domain\": \"/entities\", \"valuesById\": {} }", SideTable.class, TestEntity.class, String.class);
}

@Test
void sideTableWithTwoValuesFields_throws() {
assertJsonException("{ \"domain\": \"/entities\", \"valuesById\": [], \"valuesById\": [] }", SideTable.class, TestEntity.class, String.class);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,20 @@
package works.bosk.jackson;

import org.junit.jupiter.api.BeforeEach;
import java.util.stream.Stream;
import works.bosk.drivers.DriverConformanceTest;
import works.bosk.junit.ParametersByName;

import static works.bosk.AbstractRoundTripTest.jacksonRoundTripFactory;
import static works.bosk.jackson.JacksonPluginConfiguration.MapShape.LINKED_MAP;

public class JacksonRoundTripConformanceTest extends DriverConformanceTest {
@BeforeEach
void setupDriverFactory() {
driverFactory = jacksonRoundTripFactory();
@ParametersByName
JacksonRoundTripConformanceTest(JacksonPluginConfiguration config) {
driverFactory = jacksonRoundTripFactory(config);
}

static Stream<JacksonPluginConfiguration> config() {
return Stream.of(JacksonPluginConfiguration.MapShape.values())
.map(shape -> new JacksonPluginConfiguration(shape));
}
}
18 changes: 13 additions & 5 deletions lib-testing/src/main/java/works/bosk/AbstractRoundTripTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,12 @@
import works.bosk.drivers.mongo.BsonPlugin;
import works.bosk.exceptions.InvalidTypeException;
import works.bosk.jackson.JacksonPlugin;
import works.bosk.jackson.JacksonPluginConfiguration;

import static com.fasterxml.jackson.databind.SerializationFeature.INDENT_OUTPUT;
import static java.lang.System.identityHashCode;
import static java.util.Collections.newSetFromMap;
import static works.bosk.jackson.JacksonPluginConfiguration.MapShape.LINKED_MAP;

public abstract class AbstractRoundTripTest extends AbstractBoskTest {

Expand All @@ -43,7 +45,8 @@ static <R extends Entity> Stream<DriverFactory<R>> driverFactories() {
directFactory(),
factoryThatMakesAReference(),

jacksonRoundTripFactory(),
jacksonRoundTripFactory(JacksonPluginConfiguration.defaultConfiguration()),
jacksonRoundTripFactory(new JacksonPluginConfiguration(LINKED_MAP)),

bsonRoundTripFactory()
);
Expand All @@ -60,13 +63,18 @@ public static <R extends Entity> DriverFactory<R> factoryThatMakesAReference() {
};
}

public static <R extends Entity> DriverFactory<R> jacksonRoundTripFactory() {
return new JacksonRoundTripDriverFactory<>();
public static <R extends Entity> DriverFactory<R> jacksonRoundTripFactory(JacksonPluginConfiguration config) {
return new JacksonRoundTripDriverFactory<>(config);
}

@RequiredArgsConstructor
private static class JacksonRoundTripDriverFactory<R extends Entity> implements DriverFactory<R> {
private final JacksonPlugin jp = new JacksonPlugin();
private final JacksonPluginConfiguration config;
private final JacksonPlugin jp;

private JacksonRoundTripDriverFactory(JacksonPluginConfiguration config) {
this.config = config;
this.jp = new JacksonPlugin(config);
}

@Override
public BoskDriver build(BoskInfo<R> boskInfo, BoskDriver driver) {
Expand Down

0 comments on commit 66a50aa

Please sign in to comment.