001/*
002 * Configurate
003 * Copyright (C) zml and Configurate contributors
004 *
005 * Licensed under the Apache License, Version 2.0 (the "License");
006 * you may not use this file except in compliance with the License.
007 * You may obtain a copy of the License at
008 *
009 *    http://www.apache.org/licenses/LICENSE-2.0
010 *
011 * Unless required by applicable law or agreed to in writing, software
012 * distributed under the License is distributed on an "AS IS" BASIS,
013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
014 * See the License for the specific language governing permissions and
015 * limitations under the License.
016 */
017package ninja.leaping.configurate.objectmapping;
018
019import com.google.common.reflect.Invokable;
020import com.google.common.reflect.TypeToken;
021import ninja.leaping.configurate.ConfigurationNode;
022import ninja.leaping.configurate.commented.CommentedConfigurationNode;
023import ninja.leaping.configurate.objectmapping.serialize.TypeSerializer;
024import org.checkerframework.checker.nullness.qual.NonNull;
025import org.checkerframework.checker.nullness.qual.Nullable;
026
027import java.lang.reflect.Field;
028import java.lang.reflect.InvocationTargetException;
029import java.util.LinkedHashMap;
030import java.util.Map;
031
032import static java.util.Objects.requireNonNull;
033
034/**
035 * This is the object mapper. It handles conversion between configuration nodes and
036 * fields annotated with {@link Setting} in objects.
037 *
038 * Values in the node not used by the mapped object will be preserved.
039 *
040 * @param <T> The type to work with
041 */
042public class ObjectMapper<T> {
043    private final TypeToken<T> type;
044    private final Class<? super T> clazz;
045    @Nullable
046    private final Invokable<T, T> constructor;
047    private final Map<String, FieldData> cachedFields = new LinkedHashMap<>();
048
049
050    /**
051     * Create a new object mapper that can work with objects of the given class using the
052     * {@link DefaultObjectMapperFactory}.
053     *
054     * @param clazz The type of object
055     * @param <T> The type
056     * @return An appropriate object mapper instance. May be shared with other users.
057     * @throws ObjectMappingException If invalid annotated fields are presented
058     */
059    public static <T> ObjectMapper<T> forClass(@NonNull Class<T> clazz) throws ObjectMappingException {
060        return DefaultObjectMapperFactory.getInstance().getMapper(clazz);
061    }
062
063    /**
064     * Create a new object mapper that can work with objects of the given type using the
065     * {@link DefaultObjectMapperFactory}.
066     *
067     * @param type The type of object
068     * @param <T> The type
069     * @return An appropriate object mapper instance. May be shared with other users.
070     * @throws ObjectMappingException If invalid annotated fields are presented
071     */
072    public static <T> ObjectMapper<T> forType(@NonNull TypeToken<T> type) throws ObjectMappingException {
073        return DefaultObjectMapperFactory.getInstance().getMapper(type);
074    }
075
076    /**
077     * Creates a new object mapper bound to the given object.
078     *
079     * <strong>CAUTION</strong> Generic type information will be lost when creating a mapper. Provide a TypeToken to avoid this
080     *
081     * @param obj The object
082     * @param <T> The object type
083     * @return An appropriate object mapper instance.
084     * @throws ObjectMappingException when an object is provided that is not suitable for object mapping.
085     *                              Reasons may include but are not limited to:
086     *                              <ul>
087     *                                  <li>Not annotated with {@link ninja.leaping.configurate.objectmapping.serialize.ConfigSerializable} annotation</li>
088     *                                  <li>Invalid field types</li>
089     *                              </ul>
090     */
091    @SuppressWarnings("unchecked")
092    public static <T> ObjectMapper<T>.BoundInstance forObject(@NonNull T obj) throws ObjectMappingException {
093        return forClass((Class<T>) requireNonNull(obj).getClass()).bind(obj);
094    }
095
096    /**
097     * Creates a new object mapper bound to the given object.
098     *
099     * @param type generic type of object
100     * @param obj The object
101     * @param <T> The object type
102     * @return An appropriate object mapper instance.
103     * @throws ObjectMappingException when an object is provided that is not suitable for object mapping.
104     *                              Reasons may include but are not limited to:
105     *                              <ul>
106     *                                  <li>Not annotated with {@link ninja.leaping.configurate.objectmapping.serialize.ConfigSerializable} annotation</li>
107     *                                  <li>Invalid field types</li>
108     *                                  <li>Specified type is an interface</li>
109     *                              </ul>
110     */
111    @SuppressWarnings("unchecked")
112    public static <T> ObjectMapper<T>.BoundInstance forObject(TypeToken<T> type, @NonNull T obj) throws ObjectMappingException {
113        return forType(requireNonNull(type)).bind(obj);
114    }
115
116    /**
117     * Holder for field-specific information
118     */
119    protected static class FieldData {
120        private final Field field;
121        private final TypeToken<?> fieldType;
122        private final String comment;
123
124        public FieldData(Field field, String comment) throws ObjectMappingException {
125            this(field, comment, TypeToken.of(field.getGenericType()));
126        }
127
128        public FieldData(Field field, String comment, TypeToken<?> resolvedFieldType) {
129            this.field = field;
130            this.comment = comment;
131            this.fieldType = resolvedFieldType;
132        }
133
134        public void deserializeFrom(Object instance, ConfigurationNode node) throws ObjectMappingException {
135            TypeSerializer<?> serial = node.getOptions().getSerializers().get(this.fieldType);
136            if (serial == null) {
137                throw new ObjectMappingException("No TypeSerializer found for field " + field.getName() + " of type "
138                        + this.fieldType);
139            }
140            Object newVal = node.isVirtual() ? null : serial.deserialize(this.fieldType, node);
141            try {
142                if (newVal == null) {
143                    Object existingVal = field.get(instance);
144                    if (existingVal != null) {
145                        serializeTo(instance, node);
146                    }
147                } else {
148                    field.set(instance, newVal);
149                }
150            } catch (IllegalAccessException e) {
151                throw new ObjectMappingException("Unable to deserialize field " + field.getName(), e);
152            }
153        }
154
155        @SuppressWarnings({"rawtypes", "unchecked"})
156        public void serializeTo(Object instance, ConfigurationNode node) throws ObjectMappingException {
157            try {
158                Object fieldVal = this.field.get(instance);
159                if (fieldVal == null) {
160                    node.setValue(null);
161                } else {
162                    TypeSerializer serial = node.getOptions().getSerializers().get(this.fieldType);
163                    if (serial == null) {
164                        throw new ObjectMappingException("No TypeSerializer found for field " + field.getName() + " of type " + this.fieldType);
165                    }
166                    serial.serialize(this.fieldType, fieldVal, node);
167                }
168
169                if (node instanceof CommentedConfigurationNode && this.comment != null && !this.comment.isEmpty()) {
170                    CommentedConfigurationNode commentNode = ((CommentedConfigurationNode) node);
171                    if (!commentNode.getComment().isPresent()) {
172                        commentNode.setComment(this.comment);
173                    }
174                }
175            } catch (IllegalAccessException e) {
176                throw new ObjectMappingException("Unable to serialize field " + field.getName(), e);
177            }
178        }
179    }
180
181    /**
182     * Represents an object mapper bound to a certain instance of the object
183     */
184    public class BoundInstance {
185        private final T boundInstance;
186
187        protected BoundInstance(T boundInstance) {
188            this.boundInstance = boundInstance;
189        }
190
191        /**
192         * Populate the annotated fields in a pre-created object
193         *
194         * @param source The source to get data from
195         * @return The object provided, for easier chaining
196         * @throws ObjectMappingException If an error occurs while populating data
197         */
198        public T populate(ConfigurationNode source) throws ObjectMappingException {
199            for (Map.Entry<String, FieldData> ent : cachedFields.entrySet()) {
200                ConfigurationNode node = source.getNode(ent.getKey());
201                ent.getValue().deserializeFrom(boundInstance, node);
202            }
203            return boundInstance;
204        }
205
206        /**
207         * Serialize the data contained in annotated fields to the configuration node.
208         *
209         * @param target The target node to serialize to
210         * @throws ObjectMappingException if serialization was not possible due to some error.
211         */
212        public void serialize(ConfigurationNode target) throws ObjectMappingException {
213            for (Map.Entry<String, FieldData> ent : cachedFields.entrySet()) {
214                ConfigurationNode node = target.getNode(ent.getKey());
215                ent.getValue().serializeTo(boundInstance, node);
216            }
217        }
218
219        /**
220         * Return the instance this mapper is bound to.
221         *
222         * @return The active instance
223         */
224        public T getInstance() {
225            return boundInstance;
226        }
227    }
228
229    /**
230     * Create a new object mapper of a given type. The given type must not be an interface.
231     *
232     * @param type The class this object mapper will work with
233     * @throws ObjectMappingException When errors occur discovering fields in the class
234     * @deprecated Use {@link #ObjectMapper(TypeToken)} instead to support parameterized types
235     */
236    @Deprecated
237    protected ObjectMapper(Class<T> type) throws ObjectMappingException {
238        this(TypeToken.of(type));
239    }
240
241    /**
242     * Method to determine if this ObjectMapper instance needs to maintain pre-3.7 behaviour. Override this if you are
243     * ready to take advantage of new 3.7 changes and also will be providing a subclass of ObjectMapper that overrides
244     * the old collectFields
245     *
246     * @return true to get legacy, less generic-aware treatment
247     * @deprecated Backwards compatibility measure, to be removed in 4.0
248     */
249    @Deprecated
250    protected boolean isLegacy() {
251        if (this.getClass().getPackage() !=  ObjectMapper.class.getPackage()) {
252            try {
253                // If this is a child class that overrides the non-tokened colllectFields method, they should get legacy treatment
254                getClass().getDeclaredMethod("collectFields", Map.class, Class.class);
255                return true;
256            } catch (NoSuchMethodException ignore) {
257            }
258        }
259        return false;
260    }
261
262    /**
263     * Create a new object mapper of a given type
264     *
265     * @param type The type this object mapper will work with
266     * @throws ObjectMappingException When errors occur discovering fields in the class
267     */
268    @SuppressWarnings("unchecked")
269    protected ObjectMapper(TypeToken<T> type) throws ObjectMappingException {
270        this.type = type;
271        this.clazz = type.getRawType();
272        if (this.clazz.isInterface()) {
273            throw new ObjectMappingException("ObjectMapper can only work with concrete types");
274        }
275
276        Invokable<T, T> constructor = null;
277        try {
278            constructor = type.constructor(type.getRawType().getDeclaredConstructor());
279            constructor.setAccessible(true);
280        } catch (NoSuchMethodException ignore) {
281        }
282        this.constructor = constructor;
283        TypeToken<? super T> collectType = type;
284        Class<? super T> collectClass = type.getRawType();
285        boolean legacy = isLegacy();
286        while (true) {
287            if (legacy) {
288                collectFields(cachedFields, collectClass);
289            } else {
290                collectFields(cachedFields, collectType);
291            }
292
293            collectClass = collectClass.getSuperclass();
294            if (collectClass.equals(Object.class)) {
295                break;
296            }
297            collectType = collectType.getSupertype((Class) collectClass);
298        }
299    }
300
301    /**
302     * Gather fields from a class, without having calculated types present
303     * @param cachedFields map to contribute fields to
304     * @param clazz active class to scan
305     * @throws ObjectMappingException when an error occurs
306     * @deprecated Use {@link #collectFields(Map, TypeToken)} instead
307     */
308    @Deprecated
309    protected void collectFields(Map<String, FieldData> cachedFields, Class<? super T> clazz) throws ObjectMappingException {
310        // no-op
311    }
312
313    protected void collectFields(Map<String, FieldData> cachedFields, TypeToken<? super T> clazz) throws ObjectMappingException {
314        for (Field field : clazz.getRawType().getDeclaredFields()) {
315            if (field.isAnnotationPresent(Setting.class)) {
316                Setting setting = field.getAnnotation(Setting.class);
317                String path = setting.value();
318                if (path.isEmpty()) {
319                    path = field.getName();
320                }
321
322                TypeToken<?> fieldType = clazz.resolveType(field.getGenericType());
323
324                FieldData data = new FieldData(field, setting.comment(), fieldType);
325                field.setAccessible(true);
326                if (!cachedFields.containsKey(path)) {
327                    cachedFields.put(path, data);
328                }
329            }
330        }
331    }
332
333    /**
334     * Create a new instance of an object of the appropriate type. This method is not
335     * responsible for any population.
336     *
337     * @return The new object instance
338     * @throws ObjectMappingException If constructing a new instance was not possible
339     */
340    protected T constructObject() throws ObjectMappingException {
341        if (constructor == null) {
342            throw new ObjectMappingException("No zero-arg constructor is available for class " + type + " but is required to construct new instances!");
343        }
344        try {
345            return constructor.invoke(null);
346        } catch (IllegalAccessException | InvocationTargetException e) {
347            throw new ObjectMappingException("Unable to create instance of target class " + type, e);
348        }
349    }
350
351    /**
352     * Returns whether this object mapper can create new object instances. This may be
353     * false if the provided class has no zero-argument constructors.
354     *
355     * @return Whether new object instances can be created
356     */
357    public boolean canCreateInstances() {
358        return constructor != null;
359    }
360
361    /**
362     * Return a view on this mapper that is bound to a single object instance
363     *
364     * @param instance The instance to bind to
365     * @return A view referencing this mapper and the bound instance
366     */
367    public BoundInstance bind(T instance) {
368        return new BoundInstance(instance);
369    }
370
371    /**
372     * Returns a view on this mapper that is bound to a newly created object instance
373     *
374     * @see #bind(Object)
375     * @return Bound mapper attached to a new object instance
376     * @throws ObjectMappingException If the object could not be constructed correctly
377     */
378    public BoundInstance bindToNew() throws ObjectMappingException {
379        return new BoundInstance(constructObject());
380    }
381
382    /**
383     * Get the mapped class.
384     *
385     * @return class
386     * @deprecated Use {@link #getType()} to be aware of parameterized types
387     */
388    @Deprecated
389    @SuppressWarnings("unchecked")
390    public Class<T> getMappedType() {
391        return (Class<T>) this.clazz;
392    }
393
394    public TypeToken<T> getType() {
395        return this.type;
396    }
397}