diff --git a/gson/src/main/java/com/google/gson/MapAsArrayTypeAdapter.java b/gson/src/main/java/com/google/gson/MapAsArrayTypeAdapter.java new file mode 100644 index 00000000..80cd6900 --- /dev/null +++ b/gson/src/main/java/com/google/gson/MapAsArrayTypeAdapter.java @@ -0,0 +1,168 @@ +/* + * Copyright (C) 2010 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.gson; + +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Adapts maps containing complex keys as arrays of map entries. + * + *

Maps as JSON objects

+ * The standard GSON map type adapter converts Java {@link Map Maps} to JSON + * Objects. This requires that map keys can be serialized as strings; this is + * insufficient for some key types. For example, consider a map whose keys are + * points on a grid. The default JSON form encodes reasonably:
   {@code
+ *   Map original = new LinkedHashMap();
+ *   original.put(new Point(5, 6), "a");
+ *   original.put(new Point(8, 8), "b");
+ *   System.out.println(gson.toJson(original, type));
+ * }
+ * The above code prints this JSON object:
   {@code
+ *   {
+ *     "(5,6)": "a",
+ *     "(8,8)": "b"
+ *   }
+ * }
+ * But GSON is unable to deserialize this value because the JSON string name is + * just the {@link Object#toString() toString()} of the map key. Attempting to + * convert the above JSON to an object fails with a parse exception: + *
com.google.gson.JsonParseException: Expecting object found: "(5,6)"
+ *   at com.google.gson.JsonObjectDeserializationVisitor.visitFieldUsingCustomHandler
+ *   at com.google.gson.ObjectNavigator.navigateClassFields
+ *   ...
+ * + *

Maps as JSON arrays

+ * An alternative approach taken by this type adapter is to encode maps as + * arrays of map entries. Each map entry is a two element array containing a key + * and a value. This approach is more flexible because any type can be used as + * the map's key; not just strings. But it's also less portable because the + * receiver of such JSON must be aware of the map entry convention. + * + *

Register this adapter when you are creating your GSON instance. + *

   {@code
+ *   Gson gson = new GsonBuilder()
+ *     .registerTypeAdapter(Map.class, new MapAsArrayTypeAdapter())
+ *     .create();
+ * }
+ * This will change the structure of the JSON emitted by the code above. Now we + * get an array. In this case the arrays elements are map entries: + *
   {@code
+ *   [
+ *     [
+ *       {
+ *         "x": 5,
+ *         "y": 6
+ *       },
+ *       "a",
+ *     ],
+ *     [
+ *       {
+ *         "x": 8,
+ *         "y": 8
+ *       },
+ *       "b"
+ *     ]
+ *   ]
+ * }
+ * This format will serialize and deserialize just fine as long as this adapter + * is registered. + * + *

This adapter returns regular JSON objects for maps whose keys are not + * complex. A key is complex if its JSON-serialized form is an array or an + * object. + */ +public final class MapAsArrayTypeAdapter + implements JsonSerializer>, JsonDeserializer> { + + public Map deserialize(JsonElement json, Type typeOfT, + JsonDeserializationContext context) throws JsonParseException { + Map result = new LinkedHashMap(); + Type[] keyAndValueType = typeToTypeArguments(typeOfT); + if (json.isJsonArray()) { + JsonArray array = json.getAsJsonArray(); + for (int i = 0; i < array.size(); i++) { + JsonArray entryArray = array.get(i).getAsJsonArray(); + Object k = context.deserialize(entryArray.get(0), keyAndValueType[0]); + Object v = context.deserialize(entryArray.get(1), keyAndValueType[1]); + result.put(k, v); + } + checkSize(array, array.size(), result, result.size()); + } else { + JsonObject object = json.getAsJsonObject(); + for (Map.Entry entry : object.entrySet()) { + Object k = context.deserialize(new JsonPrimitive(entry.getKey()), keyAndValueType[0]); + Object v = context.deserialize(entry.getValue(), keyAndValueType[1]); + result.put(k, v); + } + checkSize(object, object.entrySet().size(), result, result.size()); + } + return result; + } + + public JsonElement serialize(Map src, Type typeOfSrc, JsonSerializationContext context) { + Type[] keyAndValueType = typeToTypeArguments(typeOfSrc); + boolean serializeAsArray = false; + List keysAndValues = new ArrayList(); + for (Map.Entry entry : src.entrySet()) { + JsonElement key = context.serialize(entry.getKey(), keyAndValueType[0]); + serializeAsArray |= key.isJsonObject() || key.isJsonArray(); + keysAndValues.add(key); + keysAndValues.add(context.serialize(entry.getValue(), keyAndValueType[1])); + } + + if (serializeAsArray) { + JsonArray result = new JsonArray(); + for (int i = 0; i < keysAndValues.size(); i+=2) { + JsonArray entryArray = new JsonArray(); + entryArray.add(keysAndValues.get(i)); + entryArray.add(keysAndValues.get(i + 1)); + result.add(entryArray); + } + return result; + } else { + JsonObject result = new JsonObject(); + for (int i = 0; i < keysAndValues.size(); i+=2) { + result.add(keysAndValues.get(i).getAsString(), keysAndValues.get(i + 1)); + } + checkSize(src, src.size(), result, result.entrySet().size()); + return result; + } + } + + private Type[] typeToTypeArguments(Type typeOfT) { + if (typeOfT instanceof ParameterizedType) { + Type[] typeArguments = ((ParameterizedType) typeOfT).getActualTypeArguments(); + if (typeArguments.length != 2) { + throw new IllegalArgumentException("MapAsArrayTypeAdapter cannot handle " + typeOfT); + } + return typeArguments; + } + return new Type[] { Object.class, Object.class }; + } + + private void checkSize(Object input, int inputSize, Object output, int outputSize) { + if (inputSize != outputSize) { + throw new JsonSyntaxException("Input size " + inputSize + " != output size " + outputSize + + " for input " + input + " and output " + output); + } + } +} diff --git a/gson/src/test/java/com/google/gson/functional/MapAsArrayTypeAdapterTest.java b/gson/src/test/java/com/google/gson/functional/MapAsArrayTypeAdapterTest.java new file mode 100644 index 00000000..04541a4c --- /dev/null +++ b/gson/src/test/java/com/google/gson/functional/MapAsArrayTypeAdapterTest.java @@ -0,0 +1,102 @@ +/* + * Copyright (C) 2010 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.gson.functional; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonSyntaxException; +import com.google.gson.MapAsArrayTypeAdapter; +import com.google.gson.reflect.TypeToken; +import java.lang.reflect.Type; +import java.util.LinkedHashMap; +import java.util.Map; +import junit.framework.TestCase; + +public class MapAsArrayTypeAdapterTest extends TestCase { + + public void testSerializeComplexMapWithTypeAdapter() { + Type type = new TypeToken>() {}.getType(); + Gson gson = new GsonBuilder() + .registerTypeAdapter(Map.class, new MapAsArrayTypeAdapter()) + .create(); + + Map original = new LinkedHashMap(); + original.put(new Point(5, 5), "a"); + original.put(new Point(8, 8), "b"); + String json = gson.toJson(original, type); + assertEquals("[[{\"x\":5,\"y\":5},\"a\"],[{\"x\":8,\"y\":8},\"b\"]]", json); + assertEquals(original, gson.>fromJson(json, type)); + + // test that registering a type adapter for one map doesn't interfere with others + Map otherMap = new LinkedHashMap(); + otherMap.put("t", true); + otherMap.put("f", false); + assertEquals("{\"t\":true,\"f\":false}", + gson.toJson(otherMap, Map.class)); + assertEquals("{\"t\":true,\"f\":false}", + gson.toJson(otherMap, new TypeToken>() {}.getType())); + assertEquals(otherMap, gson.fromJson("{\"t\":true,\"f\":false}", + new TypeToken>() {}.getType())); + } + + public void testTwoTypesCollapseToOneSerialize() { + Gson gson = new GsonBuilder() + .registerTypeAdapter(Map.class, new MapAsArrayTypeAdapter()) + .create(); + + Map original = new LinkedHashMap(); + original.put(new Double(1.0), "a"); + original.put(new Float(1.0), "b"); + try { + gson.toJson(original, new TypeToken>() {}.getType()); + fail(); + } catch (JsonSyntaxException expected) { + } + } + + public void testTwoTypesCollapseToOneDeserialize() { + Gson gson = new GsonBuilder() + .registerTypeAdapter(Map.class, new MapAsArrayTypeAdapter()) + .create(); + + String s = "[[\"1.00\",\"a\"],[\"1.0\",\"b\"]]"; + try { + gson.fromJson(s, new TypeToken>() {}.getType()); + fail(); + } catch (JsonSyntaxException expected) { + } + } + + static class Point { + int x; + int y; + Point(int x, int y) { + this.x = x; + this.y = y; + } + Point() {} + @Override public boolean equals(Object o) { + return o instanceof Point && ((Point) o).x == x && ((Point) o).y == y; + } + @Override public int hashCode() { + return x * 37 + y; + } + @Override public String toString() { + return "(" + x + "," + y + ")"; + } + } +}