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 9ed001b
Show file tree
Hide file tree
Showing 5 changed files with 144 additions and 39 deletions.
137 changes: 117 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,14 @@ private abstract static class BoskDeserializer<T> extends JsonDeserializer<T> {
}

private <V> void writeMapEntries(JsonGenerator gen, Set<Entry<Identifier,V>> entries, SerializerProvider serializers) throws IOException {
if (config.useLinkedEntries()) {
writeEntriesAsLinkedMap(gen, entries, serializers);
} else {
writeEntriesAsArray(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 +593,122 @@ 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;
}

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 +951,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,5 +1,8 @@
package works.bosk.jackson;

/**
* @param useLinkedEntries
*/
public record JacksonPluginConfiguration(
boolean useLinkedEntries
) {
Expand Down
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,21 @@
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;

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.defaultConfiguration(),
new JacksonPluginConfiguration(true)
);
}
}
17 changes: 12 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,6 +31,7 @@
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;
Expand All @@ -43,7 +44,8 @@ static <R extends Entity> Stream<DriverFactory<R>> driverFactories() {
directFactory(),
factoryThatMakesAReference(),

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

bsonRoundTripFactory()
);
Expand All @@ -60,13 +62,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 9ed001b

Please sign in to comment.