001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.data;
003
004import java.io.StringReader;
005import java.io.StringWriter;
006import java.lang.annotation.Retention;
007import java.lang.annotation.RetentionPolicy;
008import java.lang.reflect.Field;
009import java.util.ArrayList;
010import java.util.Collection;
011import java.util.Collections;
012import java.util.HashMap;
013import java.util.LinkedHashMap;
014import java.util.List;
015import java.util.Map;
016import java.util.Objects;
017import java.util.Optional;
018import java.util.Set;
019import java.util.stream.Collectors;
020
021import javax.json.Json;
022import javax.json.JsonArray;
023import javax.json.JsonArrayBuilder;
024import javax.json.JsonObject;
025import javax.json.JsonObjectBuilder;
026import javax.json.JsonReader;
027import javax.json.JsonString;
028import javax.json.JsonValue;
029import javax.json.JsonWriter;
030
031import org.openstreetmap.josm.spi.preferences.IPreferences;
032import org.openstreetmap.josm.tools.JosmRuntimeException;
033import org.openstreetmap.josm.tools.Logging;
034import org.openstreetmap.josm.tools.MultiMap;
035import org.openstreetmap.josm.tools.ReflectionUtils;
036
037/**
038 * Utility methods to convert struct-like classes to a string map and back.
039 *
040 * A "struct" is a class that has some fields annotated with {@link StructEntry}.
041 * Those fields will be respected when converting an object to a {@link Map} and back.
042 * @since 12851
043 */
044public final class StructUtils {
045
046    private StructUtils() {
047        // hide constructor
048    }
049
050    /**
051     * Annotation used for converting objects to String Maps and vice versa.
052     * Indicates that a certain field should be considered in the conversion process. Otherwise it is ignored.
053     *
054     * @see #serializeStruct(java.lang.Object, java.lang.Class)
055     * @see #deserializeStruct(java.util.Map, java.lang.Class)
056     */
057    @Retention(RetentionPolicy.RUNTIME) // keep annotation at runtime
058    public @interface StructEntry { }
059
060    /**
061     * Annotation used for converting objects to String Maps.
062     * Indicates that a certain field should be written to the map, even if the value is the same as the default value.
063     *
064     * @see #serializeStruct(java.lang.Object, java.lang.Class)
065     */
066    @Retention(RetentionPolicy.RUNTIME) // keep annotation at runtime
067    public @interface WriteExplicitly { }
068
069    /**
070     * Get a list of hashes which are represented by a struct-like class.
071     * Possible properties are given by fields of the class klass that have the @StructEntry annotation.
072     * Default constructor is used to initialize the struct objects, properties then override some of these default values.
073     * @param <T> klass type
074     * @param preferences preferences to look up the value
075     * @param key main preference key
076     * @param klass The struct class
077     * @return a list of objects of type T or an empty list if nothing was found
078     */
079    public static <T> List<T> getListOfStructs(IPreferences preferences, String key, Class<T> klass) {
080        return Optional.ofNullable(getListOfStructs(preferences, key, null, klass)).orElseGet(Collections::emptyList);
081    }
082
083    /**
084     * same as above, but returns def if nothing was found
085     * @param <T> klass type
086     * @param preferences preferences to look up the value
087     * @param key main preference key
088     * @param def default value
089     * @param klass The struct class
090     * @return a list of objects of type T or {@code def} if nothing was found
091     */
092    public static <T> List<T> getListOfStructs(IPreferences preferences, String key, Collection<T> def, Class<T> klass) {
093        List<Map<String, String>> prop =
094            preferences.getListOfMaps(key, def == null ? null : serializeListOfStructs(def, klass));
095        if (prop == null)
096            return def == null ? null : new ArrayList<>(def);
097        return prop.stream().map(p -> deserializeStruct(p, klass)).collect(Collectors.toList());
098    }
099
100    /**
101     * Convenience method that saves a MapListSetting which is provided as a collection of objects.
102     *
103     * Each object is converted to a <code>Map&lt;String, String&gt;</code> using the fields with {@link StructEntry} annotation.
104     * The field name is the key and the value will be converted to a string.
105     *
106     * Considers only fields that have the {@code @StructEntry} annotation.
107     * In addition it does not write fields with null values. (Thus they are cleared)
108     * Default values are given by the field values after default constructor has been called.
109     * Fields equal to the default value are not written unless the field has the {@link WriteExplicitly} annotation.
110     * @param <T> the class,
111     * @param preferences the preferences to save to
112     * @param key main preference key
113     * @param val the list that is supposed to be saved
114     * @param klass The struct class
115     * @return true if something has changed
116     */
117    public static <T> boolean putListOfStructs(IPreferences preferences, String key, Collection<T> val, Class<T> klass) {
118        return preferences.putListOfMaps(key, serializeListOfStructs(val, klass));
119    }
120
121    private static <T> List<Map<String, String>> serializeListOfStructs(Collection<T> l, Class<T> klass) {
122        if (l == null)
123            return null;
124        List<Map<String, String>> vals = new ArrayList<>();
125        for (T struct : l) {
126            if (struct != null) {
127                vals.add(serializeStruct(struct, klass));
128            }
129        }
130        return vals;
131    }
132
133    /**
134     * Convert an object to a String Map, by using field names and values as map key and value.
135     *
136     * The field value is converted to a String.
137     *
138     * Only fields with annotation {@link StructEntry} are taken into account.
139     *
140     * Fields will not be written to the map if the value is null or unchanged
141     * (compared to an object created with the no-arg-constructor).
142     * The {@link WriteExplicitly} annotation overrides this behavior, i.e. the default value will also be written.
143     *
144     * @param <T> the class of the object <code>struct</code>
145     * @param struct the object to be converted
146     * @param klass the class T
147     * @return the resulting map (same data content as <code>struct</code>)
148     */
149    public static <T> Map<String, String> serializeStruct(T struct, Class<T> klass) {
150        T structPrototype;
151        try {
152            structPrototype = klass.getConstructor().newInstance();
153        } catch (ReflectiveOperationException ex) {
154            throw new IllegalArgumentException(ex);
155        }
156
157        Map<String, String> hash = new LinkedHashMap<>();
158        for (Field f : klass.getDeclaredFields()) {
159            if (f.getAnnotation(StructEntry.class) == null) {
160                continue;
161            }
162            try {
163                ReflectionUtils.setObjectsAccessible(f);
164                Object fieldValue = f.get(struct);
165                Object defaultFieldValue = f.get(structPrototype);
166                if (fieldValue != null && (
167                        f.getAnnotation(WriteExplicitly.class) != null ||
168                        !Objects.equals(fieldValue, defaultFieldValue))) {
169                    String key = f.getName().replace('_', '-');
170                    if (fieldValue instanceof Map) {
171                        hash.put(key, mapToJson((Map<?, ?>) fieldValue));
172                    } else if (fieldValue instanceof MultiMap) {
173                        hash.put(key, multiMapToJson((MultiMap<?, ?>) fieldValue));
174                    } else {
175                        hash.put(key, fieldValue.toString());
176                    }
177                }
178            } catch (IllegalAccessException | SecurityException ex) {
179                throw new JosmRuntimeException(ex);
180            }
181        }
182        return hash;
183    }
184
185    /**
186     * Converts a String-Map to an object of a certain class, by comparing map keys to field names of the class and assigning
187     * map values to the corresponding fields.
188     *
189     * The map value (a String) is converted to the field type. Supported types are: boolean, Boolean, int, Integer, double,
190     * Double, String, Map&lt;String, String&gt; and Map&lt;String, List&lt;String&gt;&gt;.
191     *
192     * Only fields with annotation {@link StructEntry} are taken into account.
193     * @param <T> the class
194     * @param hash the string map with initial values
195     * @param klass the class T
196     * @return an object of class T, initialized as described above
197     */
198    public static <T> T deserializeStruct(Map<String, String> hash, Class<T> klass) {
199        T struct = null;
200        try {
201            struct = klass.getConstructor().newInstance();
202        } catch (ReflectiveOperationException ex) {
203            throw new IllegalArgumentException(ex);
204        }
205        for (Map.Entry<String, String> keyValue : hash.entrySet()) {
206            Object value;
207            Field f;
208            try {
209                f = klass.getDeclaredField(keyValue.getKey().replace('-', '_'));
210            } catch (NoSuchFieldException ex) {
211                Logging.trace(ex);
212                continue;
213            }
214            if (f.getAnnotation(StructEntry.class) == null) {
215                continue;
216            }
217            ReflectionUtils.setObjectsAccessible(f);
218            if (f.getType() == Boolean.class || f.getType() == boolean.class) {
219                value = Boolean.valueOf(keyValue.getValue());
220            } else if (f.getType() == Integer.class || f.getType() == int.class) {
221                try {
222                    value = Integer.valueOf(keyValue.getValue());
223                } catch (NumberFormatException nfe) {
224                    continue;
225                }
226            } else if (f.getType() == Double.class || f.getType() == double.class) {
227                try {
228                    value = Double.valueOf(keyValue.getValue());
229                } catch (NumberFormatException nfe) {
230                    continue;
231                }
232            } else if (f.getType() == String.class) {
233                value = keyValue.getValue();
234            } else if (f.getType().isAssignableFrom(Map.class)) {
235                value = mapFromJson(keyValue.getValue());
236            } else if (f.getType().isAssignableFrom(MultiMap.class)) {
237                value = multiMapFromJson(keyValue.getValue());
238            } else
239                throw new JosmRuntimeException("unsupported preference primitive type");
240
241            try {
242                f.set(struct, value);
243            } catch (IllegalArgumentException ex) {
244                throw new AssertionError(ex);
245            } catch (IllegalAccessException ex) {
246                throw new JosmRuntimeException(ex);
247            }
248        }
249        return struct;
250    }
251
252    @SuppressWarnings("rawtypes")
253    private static String mapToJson(Map map) {
254        StringWriter stringWriter = new StringWriter();
255        try (JsonWriter writer = Json.createWriter(stringWriter)) {
256            JsonObjectBuilder object = Json.createObjectBuilder();
257            for (Object o: map.entrySet()) {
258                Map.Entry e = (Map.Entry) o;
259                Object evalue = e.getValue();
260                object.add(e.getKey().toString(), evalue.toString());
261            }
262            writer.writeObject(object.build());
263        }
264        return stringWriter.toString();
265    }
266
267    @SuppressWarnings({ "rawtypes", "unchecked" })
268    private static Map mapFromJson(String s) {
269        Map ret = null;
270        try (JsonReader reader = Json.createReader(new StringReader(s))) {
271            JsonObject object = reader.readObject();
272            ret = new HashMap(object.size());
273            for (Map.Entry<String, JsonValue> e: object.entrySet()) {
274                JsonValue value = e.getValue();
275                if (value instanceof JsonString) {
276                    // in some cases, when JsonValue.toString() is called, then additional quotation marks are left in value
277                    ret.put(e.getKey(), ((JsonString) value).getString());
278                } else {
279                    ret.put(e.getKey(), e.getValue().toString());
280                }
281            }
282        }
283        return ret;
284    }
285
286    @SuppressWarnings("rawtypes")
287    private static String multiMapToJson(MultiMap map) {
288        StringWriter stringWriter = new StringWriter();
289        try (JsonWriter writer = Json.createWriter(stringWriter)) {
290            JsonObjectBuilder object = Json.createObjectBuilder();
291            for (Object o: map.entrySet()) {
292                Map.Entry e = (Map.Entry) o;
293                Set evalue = (Set) e.getValue();
294                JsonArrayBuilder a = Json.createArrayBuilder();
295                for (Object evo: evalue) {
296                    a.add(evo.toString());
297                }
298                object.add(e.getKey().toString(), a.build());
299            }
300            writer.writeObject(object.build());
301        }
302        return stringWriter.toString();
303    }
304
305    @SuppressWarnings({ "rawtypes", "unchecked" })
306    private static MultiMap multiMapFromJson(String s) {
307        MultiMap ret = null;
308        try (JsonReader reader = Json.createReader(new StringReader(s))) {
309            JsonObject object = reader.readObject();
310            ret = new MultiMap(object.size());
311            for (Map.Entry<String, JsonValue> e: object.entrySet()) {
312                JsonValue value = e.getValue();
313                if (value instanceof JsonArray) {
314                    for (JsonString js: ((JsonArray) value).getValuesAs(JsonString.class)) {
315                        ret.put(e.getKey(), js.getString());
316                    }
317                } else if (value instanceof JsonString) {
318                    // in some cases, when JsonValue.toString() is called, then additional quotation marks are left in value
319                    ret.put(e.getKey(), ((JsonString) value).getString());
320                } else {
321                    ret.put(e.getKey(), e.getValue().toString());
322                }
323            }
324        }
325        return ret;
326    }
327}