001/*
002 * Copyright 2023 the original author or authors.
003 * <p>
004 * Licensed under the Apache License, Version 2.0 (the "License");
005 * you may not use this file except in compliance with the License.
006 * You may obtain a copy of the License at
007 * <p>
008 * https://www.apache.org/licenses/LICENSE-2.0
009 * <p>
010 * Unless required by applicable law or agreed to in writing, software
011 * distributed under the License is distributed on an "AS IS" BASIS,
012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 * See the License for the specific language governing permissions and
014 * limitations under the License.
015 */
016package de.cuioss.tools.property;
017
018import static de.cuioss.tools.string.MoreStrings.requireNotEmptyTrimmed;
019import static java.util.Objects.requireNonNull;
020
021import java.lang.reflect.InvocationTargetException;
022import java.lang.reflect.Method;
023import java.util.Objects;
024import java.util.Optional;
025
026import de.cuioss.tools.base.Preconditions;
027import de.cuioss.tools.logging.CuiLogger;
028import de.cuioss.tools.reflect.MoreReflection;
029import de.cuioss.tools.string.MoreStrings;
030import lombok.experimental.UtilityClass;
031
032/**
033 * Helper class providing convenient methods for reading from / writing to java
034 * beans.
035 * <h2>Caution:</h2>
036 * <p>
037 * Use reflection only if there is no other way. Even if some of the problems
038 * are minimized by using this type. It should be used either in test-code, what
039 * is we actually do, and not production code. An other reason could be
040 * framework code. as for that you should exactly know what you do.
041 * </p>
042 *
043 * @author Oliver Wolff
044 *
045 */
046@UtilityClass
047public class PropertyUtil {
048
049    private static final CuiLogger log = new CuiLogger(PropertyUtil.class);
050
051    static final String UNABLE_TO_WRITE_PROPERTY = "Unable to write property '%s' to beanType '%s': no suitable write method found. Needed property-type '%s'";
052
053    static final String UNABLE_TO_WRITE_PROPERTY_RUNTIME = "Unable to write property '%s' to beanType '%s'. Needed property-type '%s'";
054
055    static final String UNABLE_TO_READ_PROPERTY = "Unable to read property '%s' from beanType '%s'.";
056
057    /**
058     * @param bean         instance to be read from, must not be null
059     * @param propertyName to be read, must not be null nor empty nor blank
060     * @return the object read from the property
061     * @throws IllegalArgumentException in case the property does not exist
062     *                                  (determined by a read method)
063     * @throws IllegalStateException    in case some Exception occurred while
064     *                                  reading
065     */
066    @SuppressWarnings("squid:S3655") // owolff: False Positive, Optional#isPresent is checked
067    public static Object readProperty(Object bean, String propertyName) {
068        log.debug("Reading property '%s' from %s", propertyName, bean);
069        requireNonNull(bean);
070        requireNotEmptyTrimmed(propertyName);
071        var reader = MoreReflection.retrieveAccessMethod(bean.getClass(), propertyName);
072        Preconditions.checkArgument(reader.isPresent(), UNABLE_TO_READ_PROPERTY, propertyName, bean.getClass());
073        try {
074            return reader.get().invoke(bean);
075        } catch (IllegalAccessException | IllegalArgumentException | InvocationTargetException e) {
076            throw new IllegalStateException(
077                    MoreStrings.lenientFormat(UNABLE_TO_READ_PROPERTY, propertyName, bean.getClass()), e);
078        }
079    }
080
081    /**
082     * @param bean          instance to be read from, must not be null
083     * @param propertyName  to be read, must not be null nor empty nor blank
084     * @param propertyValue to be set
085     * @return In case the property set method is void the given bean will be
086     *         returned. Otherwise, the return value of the method invocation,
087     *         assuming the setMethods is a builder type.
088     * @throws IllegalArgumentException in case the property does not exist
089     *                                  (determined by a write method)
090     * @throws IllegalStateException    in case some Exception occurred while
091     *                                  writing
092     */
093    public static Object writeProperty(Object bean, String propertyName, Object propertyValue) {
094        log.debug("Writing '%s' to property '%s' on '%s'", propertyValue, propertyName, bean);
095        requireNonNull(bean);
096        requireNotEmptyTrimmed(propertyName);
097        var writeMethod = determineWriteMethod(bean, propertyName, propertyValue);
098        try {
099            var result = writeMethod.invoke(bean, propertyValue);
100            return Objects.requireNonNullElse(result, bean);
101        } catch (IllegalAccessException | IllegalArgumentException | InvocationTargetException e) {
102            var target = propertyValue != null ? propertyValue.getClass().getName() : "Undefined";
103            throw new IllegalStateException(
104                    MoreStrings.lenientFormat(UNABLE_TO_WRITE_PROPERTY_RUNTIME, propertyName, bean.getClass(), target),
105                    e);
106        }
107    }
108
109    /**
110     * Tries to determine the type of given property
111     *
112     * @param beanType     to be checked, must not be null
113     * @param propertyName to be checked, must not be null
114     * @return an {@link Optional} on the actual type of the property. First it
115     *         checks an access-methods, then it tries to directly access the field,
116     *         and if that fails it uses the first read method found,
117     *         {@link Optional#empty()} otherwise.
118     */
119    public static Optional<Class<?>> resolvePropertyType(Class<?> beanType, String propertyName) {
120        var retrieveAccessMethod = MoreReflection.retrieveAccessMethod(beanType, propertyName);
121        if (retrieveAccessMethod.isPresent()) {
122            log.trace("Found read-method on class '%s' for property-name '%s'", beanType, propertyName);
123            return Optional.of(retrieveAccessMethod.get().getReturnType());
124        }
125        var field = MoreReflection.accessField(beanType, propertyName);
126        if (field.isPresent()) {
127            log.trace("Found field on class '%s' with name '%s'", beanType, propertyName);
128            return Optional.of(field.get().getType());
129        }
130        log.debug(
131                "Neither read-method nor field found on class '%s' for property-name '%s', checking write methods, returning first type found",
132                beanType, propertyName);
133        var candidates = MoreReflection.retrieveWriteMethodCandidates(beanType, propertyName);
134        if (!candidates.isEmpty()) {
135            return Optional.of(candidates.iterator().next().getParameterTypes()[0]);
136        }
137        log.debug("Unable to detect property-type on class '%s' for property-name '%s'", beanType, propertyName);
138        return Optional.empty();
139    }
140
141    static Method determineWriteMethod(Object bean, String propertyName, Object propertyValue) {
142        var candidates = MoreReflection.retrieveWriteMethodCandidates(bean.getClass(), propertyName);
143        var target = propertyValue != null ? propertyValue.getClass().getName() : "Undefined";
144        Preconditions.checkArgument(!candidates.isEmpty(), UNABLE_TO_WRITE_PROPERTY, propertyName, bean.getClass(),
145                target);
146        if (null == propertyValue) {
147            log.trace("No / Null propertyValue given, so any method should suffice to write property '%s' to %s",
148                    propertyName, bean);
149            return candidates.iterator().next();
150        }
151        for (Method candidate : candidates) {
152            if (MoreReflection.checkWhetherParameterIsAssignable(candidate.getParameterTypes()[0],
153                    propertyValue.getClass())) {
154                log.trace("Found method %s to write property '%s' to %s", candidate, propertyName, bean);
155                return candidate;
156            }
157        }
158        throw new IllegalArgumentException(
159                MoreStrings.lenientFormat(UNABLE_TO_WRITE_PROPERTY, propertyName, bean.getClass(), target));
160    }
161}