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.test.valueobjects.util;
017
018import static de.cuioss.tools.collect.CollectionLiterals.immutableSet;
019import static de.cuioss.tools.collect.CollectionLiterals.immutableSortedSet;
020import static java.util.Objects.requireNonNull;
021
022import java.lang.reflect.Field;
023import java.lang.reflect.Method;
024import java.lang.reflect.ParameterizedType;
025import java.util.ArrayList;
026import java.util.Arrays;
027import java.util.Collection;
028import java.util.HashSet;
029import java.util.List;
030import java.util.Optional;
031import java.util.Set;
032import java.util.SortedSet;
033import java.util.TreeSet;
034
035import de.cuioss.test.valueobjects.api.property.PropertyConfig;
036import de.cuioss.test.valueobjects.api.property.PropertyReflectionConfig;
037import de.cuioss.test.valueobjects.generator.dynamic.GeneratorResolver;
038import de.cuioss.test.valueobjects.property.PropertyMetadata;
039import de.cuioss.test.valueobjects.property.impl.PropertyMetadataImpl;
040import de.cuioss.test.valueobjects.property.util.CollectionType;
041import de.cuioss.tools.collect.CollectionBuilder;
042import de.cuioss.tools.logging.CuiLogger;
043import de.cuioss.tools.property.PropertyHolder;
044import de.cuioss.tools.reflect.MoreReflection;
045import lombok.experimental.UtilityClass;
046
047/**
048 * @author Oliver Wolff
049 */
050@UtilityClass
051public final class ReflectionHelper {
052
053    /**
054     * Identifies the properties to be ignored. Currently only consisting of 'class'
055     */
056    @SuppressWarnings("squid:S2386") // owolff: False positive -> is immutable
057    public static final Set<String> PROPERTY_IGNORE_SET = immutableSet("class");
058
059    private static final CuiLogger log = new CuiLogger(ReflectionHelper.class);
060
061    /**
062     * One stop method for the deriving of configured metadata
063     *
064     * @param annotated   must not be null
065     * @param targetClass must not be null
066     * @return an immutable {@link List} of {@link PropertyMetadata} containing the
067     *         result of the actual scanning with
068     *         {@link #scanBeanTypeForProperties(Class, PropertyReflectionConfig)}
069     *         and {@link #handlePostProcess(Class, SortedSet)} and
070     *         {@link PropertyHelper#handlePropertyConfigAnnotations(Class)} and
071     *         {@link PropertyHelper#handlePrimitiveAsDefaults(Collection)}
072     */
073    public static <T> List<PropertyMetadata> handlePropertyMetadata(final Class<?> annotated,
074            final Class<T> targetClass) {
075        requireNonNull(annotated);
076        requireNonNull(targetClass);
077
078        final List<PropertyMetadata> builder = new ArrayList<>();
079        if (shouldScanClass(annotated)) {
080            final SortedSet<PropertyMetadata> scanned = new TreeSet<>(scanBeanTypeForProperties(targetClass,
081                    MoreReflection.extractAnnotation(annotated, PropertyReflectionConfig.class).orElse(null)));
082
083            builder.addAll(handlePostProcess(annotated, scanned));
084        }
085        final var mapView = PropertyHelper.toMapView(builder);
086        PropertyHelper.handlePropertyConfigAnnotations(annotated).forEach(p -> mapView.put(p.getName(), p));
087        final var handled = PropertyHelper.handlePrimitiveAsDefaults(mapView.values());
088        PropertyHelper.logMessageForPropertyMetadata(handled);
089        return new ArrayList<>(handled);
090    }
091
092    /**
093     * One stop method for the deriving of configured metadata
094     *
095     * @param propertyReflectionConfig extracted from the target class, may be null.
096     * @param propertyConfig           extracted from the target class, may be empty
097     *
098     * @param targetClass              must not be null
099     * @return an immutable {@link List} of {@link PropertyMetadata} containing the
100     *         result of the actual scanning with
101     *         {@link #scanBeanTypeForProperties(Class, PropertyReflectionConfig)}
102     *         and {@link #handlePostProcess(Class, SortedSet)} and
103     *         {@link PropertyHelper#handlePropertyConfigAnnotations(Class)} and
104     *         {@link PropertyHelper#handlePrimitiveAsDefaults(Collection)}
105     */
106    public static <T> List<PropertyMetadata> handlePropertyMetadata(PropertyReflectionConfig propertyReflectionConfig,
107            final List<PropertyConfig> propertyConfig, final Class<T> targetClass) {
108        final List<PropertyMetadata> builder = new ArrayList<>();
109        if (shouldScanClass(propertyReflectionConfig)) {
110            final SortedSet<PropertyMetadata> scanned = new TreeSet<>(
111                    scanBeanTypeForProperties(targetClass, propertyReflectionConfig));
112
113            builder.addAll(handlePostProcessConfig(propertyReflectionConfig, scanned));
114        }
115        final var mapView = PropertyHelper.toMapView(builder);
116        PropertyHelper.handlePropertyConfigAnnotations(propertyConfig).forEach(p -> mapView.put(p.getName(), p));
117        final var handled = PropertyHelper.handlePrimitiveAsDefaults(mapView.values());
118        PropertyHelper.logMessageForPropertyMetadata(handled);
119        return new ArrayList<>(handled);
120    }
121
122    /**
123     * Uses {@link MoreReflection} to scan the concrete bean and describe the
124     * properties with fitting {@link PropertyMetadata}. Each property will contain
125     * the derived data for the attributes:
126     * <ul>
127     * <li>{@link PropertyMetadata#getName()}</li>
128     * <li>{@link PropertyMetadata#getGenerator()} with the generator being
129     * dynamically resolved using
130     * {@link GeneratorResolver#resolveGenerator(Class)}</li>
131     * <li>{@link PropertyMetadata#getPropertyReadWrite()}</li>
132     * <li>{@link PropertyMetadata#getCollectionType()}</li>
133     * <li>{@link PropertyMetadata#getPropertyMemberInfo()}</li>
134     * </ul>
135     * Other attributes use there defaults:
136     * <ul>
137     * <li>{@link PropertyMetadata#isRequired()} defaults to false</li>
138     * <li>{@link PropertyMetadata#isDefaultValue()} defaults to false</li>
139     * </ul>
140     *
141     * @param beanType to be checked by reflection
142     * @param config   optional instance of {@link PropertyReflectionConfig} used
143     *                 for filtering the scanning
144     * @return a {@link SortedSet} containing the result of the inspection
145     */
146    public static SortedSet<PropertyMetadata> scanBeanTypeForProperties(final Class<?> beanType,
147            final PropertyReflectionConfig config) {
148        final Set<String> filter = new HashSet<>(PROPERTY_IGNORE_SET);
149        if (null != config) {
150            filter.addAll(Arrays.asList(config.exclude()));
151        }
152        final var found = new CollectionBuilder<PropertyMetadata>();
153
154        var builder = new CollectionBuilder<PropertyHolder>();
155        for (Method method : MoreReflection.retrieveAccessMethods(beanType)) {
156            var attributeName = MoreReflection.computePropertyNameFromMethodName(method.getName());
157            if (filter.contains(attributeName)) {
158                log.debug("Filtering attribute '%s' for type '%s' as configured", attributeName, beanType);
159                continue;
160            }
161            var holder = PropertyHolder.from(beanType, attributeName);
162            if (holder.isEmpty()) {
163                log.info("Unable to extract metadata for type '%s' and method '%s'", beanType, method.getName());
164            } else {
165                builder.add(holder.get());
166            }
167        }
168
169        for (PropertyHolder holder : builder) {
170            found.add(createPropertyMetadata(beanType, holder));
171        }
172        return found.toImmutableNavigableSet();
173    }
174
175    /**
176     * Creates a {@link PropertyMetadata} for a given field.
177     *
178     * @param beanType       providing the property, must not be null
179     * @param propertyHolder identifying the property-metadata, must not be null
180     * @return an instance of {@link PropertyMetadata} describing the field.
181     * @throws IllegalArgumentException wrapping underlying reflection exceptions.
182     */
183    public static PropertyMetadata createPropertyMetadata(final Class<?> beanType, PropertyHolder propertyHolder) {
184        requireNonNull(beanType);
185        requireNonNull(propertyHolder);
186
187        var collectionType = CollectionType.NO_ITERABLE;
188        Class<?> propertyType = propertyHolder.getType();
189
190        final var field = MoreReflection.accessField(beanType, propertyHolder.getName());
191
192        if (field.isPresent()) {
193            final var collectionTypeOption = CollectionType.findResponsibleCollectionType(field.get().getType());
194            if (collectionTypeOption.isPresent()) {
195                collectionType = collectionTypeOption.get();
196                if (CollectionType.ARRAY_MARKER.equals(collectionType)) {
197                    propertyType = field.get().getType().getComponentType();
198                } else {
199                    propertyType = extractParameterizedType(field.get(),
200                            (ParameterizedType) field.get().getGenericType());
201                }
202            }
203        }
204        if (null == propertyType) {
205            throw new IllegalArgumentException("Unable to extract property '%s' on type '%s'"
206                    .formatted(propertyHolder.getName(), beanType.getName()));
207        }
208        var defaultValued = propertyType.isPrimitive();
209        if (defaultValued && CollectionType.ARRAY_MARKER.equals(collectionType)) {
210            defaultValued = false;
211        }
212        return PropertyMetadataImpl.builder().name(propertyHolder.getName()).defaultValue(defaultValued)
213                .collectionType(collectionType).propertyMemberInfo(propertyHolder.getMemberInfo())
214                .propertyReadWrite(propertyHolder.getReadWrite())
215                .generator(GeneratorResolver.resolveGenerator(propertyType)).build();
216    }
217
218    private static Class<?> extractParameterizedType(final Field field, final ParameterizedType parameterizedType) {
219        try {
220            return (Class<?>) parameterizedType.getActualTypeArguments()[0];
221        } catch (final ClassCastException e) {
222            throw new IllegalStateException("""
223                    Unable to determine generic-type for %s, ususally this is the case with nested generics.\s\
224
225                    You need to provide a custom @PropertyConfig for this field and exclude it from scanning\
226                    , by using PropertyReflectionConfig#exclude.
227                    See package-javadoc of de.cuioss.test.valueobjects for samples.""".formatted(field.toString()), e);
228        }
229    }
230
231    /**
232     * Filters the given properties according to the annotated element given. It
233     * checks for the annotation {@link PropertyReflectionConfig} and filters the
234     * given {@link SortedSet}
235     *
236     * @param annotated must not be null
237     * @param metatdata must not be null
238     * @return the filtered {@link SortedSet}
239     */
240    public static SortedSet<PropertyMetadata> handlePostProcess(final Class<?> annotated,
241            final SortedSet<PropertyMetadata> metatdata) {
242        requireNonNull(annotated);
243
244        final Optional<PropertyReflectionConfig> configOption = MoreReflection.extractAnnotation(annotated,
245                PropertyReflectionConfig.class);
246
247        return handlePostProcessConfig(configOption.orElse(null), metatdata);
248    }
249
250    /**
251     * Filters the given properties according to the annotated element given. It
252     * checks for the annotation {@link PropertyReflectionConfig} and filters the
253     * given {@link SortedSet}
254     *
255     * @param config    must not be null
256     * @param metatdata must not be null
257     * @return the filtered {@link SortedSet}
258     */
259    public static SortedSet<PropertyMetadata> handlePostProcessConfig(final PropertyReflectionConfig config,
260            final SortedSet<PropertyMetadata> metatdata) {
261        requireNonNull(metatdata);
262
263        if (metatdata.isEmpty() || null == config) {
264            return metatdata;
265        }
266
267        var map = PropertyHelper.handleWhiteAndBlacklist(config.of(), config.exclude(), metatdata);
268
269        map = AnnotationHelper.modifyPropertyMetadata(map, config.defaultValued(), config.readOnly(), config.required(),
270                config.transientProperties(), config.writeOnly(), config.assertUnorderedCollection());
271
272        return immutableSortedSet(map.values());
273    }
274
275    /**
276     * Checks the given type for the annotation {@link PropertyReflectionConfig} if
277     * it is there it checks on the value {@link PropertyReflectionConfig#skip()}
278     *
279     * @param annotated the class that may or may not provide the annotations, must
280     *                  not be null
281     * @return boolean indicating whether to skip property scanning.
282     */
283    public static boolean shouldScanClass(final Class<?> annotated) {
284        requireNonNull(annotated);
285
286        return shouldScanClass(
287                MoreReflection.extractAnnotation(annotated, PropertyReflectionConfig.class).orElse(null));
288    }
289
290    /**
291     * Checks the given type for the annotation {@link PropertyReflectionConfig} if
292     * it is there it checks on the value {@link PropertyReflectionConfig#skip()}
293     *
294     * @param config the optional annotation, may be null
295     * @return boolean indicating whether to skip property scanning.
296     */
297    public static boolean shouldScanClass(final PropertyReflectionConfig config) {
298        if (null != config) {
299            return !config.skip();
300        }
301        return true;
302    }
303
304    /**
305     * Helper method that determines the actual type of a given {@link Iterable} by
306     * peeking into it. <em>For testing only, should never be used in productive
307     * code</em>
308     *
309     * @param iterable must not be null nor empty, the iterator must be reentrant.
310     * @return The Class of the given {@link Iterable}.
311     */
312    @SuppressWarnings("unchecked")
313    public static <T> Class<T> determineSupertypeFromIterable(final Iterable<T> iterable) {
314        requireNonNull(iterable, "iterable must not be null");
315        final var iterator = iterable.iterator();
316        if (iterator.hasNext()) {
317            return (Class<T>) iterator.next().getClass();
318        }
319        throw new IllegalArgumentException("Must contain at least a single element");
320    }
321}