package com.psddev.cms.view;
import java.lang.reflect.Modifier;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import java.util.stream.StreamSupport;
import javax.annotation.ParametersAreNonnullByDefault;
import com.google.common.cache.CacheBuilder;
import com.google.common.cache.CacheLoader;
import com.google.common.cache.LoadingCache;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.psddev.dari.util.ClassFinder;
import com.psddev.dari.util.CodeUtils;
import com.psddev.dari.util.TypeDefinition;
/**
* Binds a model with a view model to produce a view.
*
* @param <M> the model type to bind with the view model.
*/
public abstract class ViewModel<M> {
private static final Logger LOGGER = LoggerFactory.getLogger(ViewModel.class);
private static final LoadingCache<Class<?>, LoadingCache<Object, Optional<Class<?>>>> VIEW_BINDINGS = CacheBuilder.newBuilder()
.weakKeys()
.build(new CacheLoader<Class<?>, LoadingCache<Object, Optional<Class<?>>>>() {
@Override
@ParametersAreNonnullByDefault
public LoadingCache<Object, Optional<Class<?>>> load(Class<?> modelClass) {
return CacheBuilder.newBuilder()
.build(new CacheLoader<Object, Optional<Class<?>>>() {
@Override
@ParametersAreNonnullByDefault
public Optional<Class<?>> load(final Object viewTypeObject) {
Class<?> viewClass = viewTypeObject instanceof Class ? (Class<?>) viewTypeObject : null;
String viewType = viewTypeObject instanceof String ? (String) viewTypeObject : null;
return Optional.ofNullable(findViewModelClassCacheHelper(viewClass, viewType, modelClass));
}
});
}
});
static {
CodeUtils.addRedefineClassesListener(classes -> VIEW_BINDINGS.invalidateAll());
}
private ViewModelCreator viewModelCreator;
private ViewResponse viewResponse;
protected M model;
/**
* Called during creation of this view model before {@link #onCreate(ViewResponse)}.
* The object is fully initialized at this point so it is safe to utilize
* full functionality. The default implementation always returns {@code true}.
* Sub-classes may override this method to return {@code false} if the
* ViewModel creation should not be completed, causing {@code null} to be
* returned from the upstream caller.
*/
protected boolean shouldCreate() {
return true;
}
/**
* Called during creation of this view model. The object is fully initialized
* at this point so it is safe to utilize full functionality.
*
* @param response the current view response.
*/
protected void onCreate(ViewResponse response) {
// do nothing by default
}
/**
* Returns a iterable of views of type {@code viewClass} that are bound to
* the given {@code object}. If the object is itself an iterable, then each
* item is evaluated and the returned iterable of views will have at most
* the same number of items, otherwise the single object will be evaluated
* and the returned iterable will have at most one item.
*
* @param viewClass the type of views to create.
* @param object the object used to create the views.
* @param <V> the view type.
* @return Never {@code null}.
*/
protected final <V> Iterable<V> createViews(Class<V> viewClass, Object object) {
Iterable<?> models;
if (object instanceof Iterable) {
models = (Iterable<?>) object;
} else if (object != null) {
models = Collections.singleton(object);
} else {
models = Collections.emptyList();
}
return StreamSupport.stream(models.spliterator(), false)
.map(model -> unwrapModel(model, new HashSet<>()))
.map(model -> {
Class<? extends ViewModel<? super Object>> viewModelClass = findViewModelClassHelper(viewClass, null, model, true);
if (viewModelClass != null) {
ViewModel<? super Object> viewModel = viewModelCreator.createViewModel(viewModelClass, model, viewResponse);
if (viewModel != null && viewClass.isAssignableFrom(viewModel.getClass())) {
@SuppressWarnings("unchecked")
V view = (V) viewModel;
return view;
}
}
return null;
})
.filter(Objects::nonNull)
.collect(Collectors.toList());
}
/**
* Creates a view of type {@code viewClass} that is bound to the given
* {@code model}.
*
* @param viewClass the type of view to create.
* @param model the model used to create the view.
* @param <V> the view type.
* @return a newly created view.
*/
protected final <V> V createView(Class<V> viewClass, Object model) {
Iterator<V> views = createViews(viewClass, model).iterator();
return views.hasNext() ? views.next() : null;
}
/**
* Creates a view that is bound to the given {@code viewType} and
* {@code model}.
*
* @param viewType the view type key bound to the view and model.
* @param model the model used to create the view.
* @return a newly created view.
*/
protected final Object createView(String viewType, Object model) {
model = unwrapModel(model, new HashSet<>());
Class<? extends ViewModel<? super Object>> viewModelClass = findViewModelClassHelper(null, viewType, model, true);
if (viewModelClass != null) {
return viewModelCreator.createViewModel(viewModelClass, model, viewResponse);
}
return null;
}
// Recursively unwraps a ModelWrapper while detecting cyclic references.
private Object unwrapModel(Object model, Set<Object> unwrapped) {
if (model instanceof ModelWrapper && unwrapped.add(model)) {
return unwrapModel(((ModelWrapper) model).unwrap(), unwrapped);
} else {
return model;
}
}
/**
* The default view model creator.
*/
public static class DefaultCreator implements ViewModelCreator {
@Override
public final <M, VM extends ViewModel<? super M>> VM createViewModel(Class<VM> viewModelClass, M model, ViewResponse viewResponse) {
if (findViewModelClassHelper(viewModelClass, null, model, false) != null) {
VM viewModel = TypeDefinition.getInstance(viewModelClass).newInstance();
((ViewModel<? super M>) viewModel).viewModelCreator = this;
((ViewModel<? super M>) viewModel).viewResponse = viewResponse;
viewModel.model = model;
beforeViewModelOnCreate(viewModel);
if (!viewModel.shouldCreate()) {
return null;
}
viewModel.onCreate(viewResponse);
return viewModel;
}
return null;
}
/**
* Called immediately before {@link ViewModel#onCreate(ViewResponse)}
* is invoked. Sub-classes may override this method to further
* initialize the {@link ViewModel}.
*
* @param viewModel the viewModel to modify.
* @param <M> the model type for the ViewModel.
* @param <VM> the ViewModel type.
*/
protected <M, VM extends ViewModel<? super M>> void beforeViewModelOnCreate(VM viewModel) {
// do nothing by default
}
}
/**
* Finds an appropriate ViewModel class based on the given view class, and
* model. If more than one class is found, the result is ambiguous and null
* is returned.
*
* @param viewClass the desired compatible class of the returned view model.
* @param model the model used to look up available view model classes, that is also compatible with the returned view model class.
* @param <M> the model type
* @param <V> the view type
* @return the view model class that matches the bounds of the arguments.
*/
public static <M, V> Class<? extends ViewModel<? super M>> findViewModelClass(Class<V> viewClass, M model) {
return findViewModelClassHelper(viewClass, null, model, false);
}
/**
* Finds an appropriate ViewModel class based on the given view type, and
* model. If more than one class is found, the result is ambiguous and null
* is returned.
*
* @param viewType the desired view type that is bound to the returned view model.
* @param model the model used to look up available view model classes, that is also compatible with the returned view model class.
* @param <M> the model type
* @return the view model class that matches the bounds of the arguments.
*/
public static <M> Class<? extends ViewModel<? super M>> findViewModelClass(String viewType, M model) {
return findViewModelClassHelper(null, viewType, model, false);
}
private static <M, V> Class<? extends ViewModel<? super M>> findViewModelClassHelper(Class<V> viewClass, String viewType, M model, boolean logFailure) {
if (model == null) {
return null;
}
Class<? extends ViewModel<? super M>> viewModelClass = findViewModelClassHelper(viewClass, viewType, model);
if (viewModelClass == null && logFailure) {
String message = String.format("Could not find ViewModel class for model of type [%s] and view of type [%s].",
model.getClass().getName(),
Stream.of(viewClass != null ? viewClass.getName() : null, viewType).filter(Objects::nonNull).collect(Collectors.joining(" and")));
LOGGER.warn(message, new IllegalArgumentException());
}
return viewModelClass;
}
private static <M, V> Class<? extends ViewModel<? super M>> findViewModelClassHelper(Class<V> viewClass, String viewType, M model) {
if (model == null) {
return null;
}
LoadingCache<Object, Optional<Class<?>>> viewTypes = VIEW_BINDINGS.getUnchecked(model.getClass());
if (viewTypes != null) {
Object viewTypeObject = null;
if (viewClass != null) {
viewTypeObject = viewClass;
} else if (viewType != null) {
viewTypeObject = viewType;
}
if (viewTypeObject != null) {
Optional<Class<?>> viewModelClass = viewTypes.getUnchecked(viewTypeObject);
if (viewModelClass.isPresent()) {
return (Class<? extends ViewModel<? super M>>) viewModelClass.get();
}
}
}
return null;
}
private static Class<? extends ViewModel<?>> findViewModelClassCacheHelper(Class<?> viewClass, String viewType, Class<?> modelClass) {
if (modelClass == null) {
return null;
}
// if it's a concrete view model class, with no type specified, then just verify that the model types match.
if (viewClass != null && viewType == null
&& ViewModel.class.isAssignableFrom(viewClass)
&& !Modifier.isAbstract(viewClass.getModifiers())) {
Class<?> declaredModelClass = TypeDefinition.getInstance(viewClass).getInferredGenericTypeArgumentClass(ViewModel.class, 0);
if (declaredModelClass != null && declaredModelClass.isAssignableFrom(modelClass)) {
@SuppressWarnings("unchecked")
Class<? extends ViewModel<?>> viewModelClass = (Class<? extends ViewModel<?>>) viewClass;
return viewModelClass;
} else {
return null;
}
}
// Attempt automatic ViewBinding.
Class<?> concreteViewModelClass = null;
// If it's a class with no type specified, try to find a single
// compatible concrete ViewModel class using the following rules,
// otherwise, do a lookup of the view bindings.
// Rules:
// 1. Do NOT include ViewModels that implement UnboundView.
// 2. Do NOT include ViewModels whose generic type argument (Model) has
// @ViewBinding annotations set DIRECTLY on it.
// 3. If there are multiple valid ViewModel classes, check their
// inheritance hierarchy and the one that extends the rest should
// win. If they're not related, move to step 4.
// 4. If there are multiple valid ViewModel classes with differing
// inheritance hierarchies then choose the one whose generic type
// argument (the model) is "closest" to the model class argument
// passed to this method, where "closest" is defined by the sorting
// the model class hierarchy using the C3 linearization algorithm
// (https://en.wikipedia.org/wiki/C3_linearization) and finding the
// one earliest in the list that matches. If there is still no
// winner OR the class hierarchy cannot be linearized a warning
// will be logged due to an ambiguous result.
if (viewClass != null && viewType == null) {
Set<Class<?>> concreteViewClasses = new HashSet<>(ClassFinder.findConcreteClasses(viewClass));
// ClassFinder only finds sub-classes, so if the current viewClass is also concrete, add it to the set.
if (!viewClass.isInterface() && !Modifier.isAbstract(viewClass.getModifiers())) {
concreteViewClasses.add(viewClass);
}
Set<Class<?>> concreteViewModelClasses = concreteViewClasses
.stream()
// It must be a sub-class of ViewModel
.filter(ViewModel.class::isAssignableFrom)
// It should NOT implement UnboundView (Rule #1)
.filter(concreteClass -> !UnboundView.class.isAssignableFrom(concreteClass))
// It must have the correct generic type argument for its model, and that model must not have any ViewBindings (Rule #2).
.filter(concreteClass -> {
Class<?> declaredModelClass = TypeDefinition.getInstance(concreteClass).getInferredGenericTypeArgumentClass(ViewModel.class, 0);
return declaredModelClass != null
&& declaredModelClass.isAssignableFrom(modelClass)
&& declaredModelClass.getAnnotationsByType(ViewBinding.class).length == 0;
})
.collect(Collectors.toSet());
// Eliminate any super classes if there are sub-class / super-class
// combinations in the set since the sub-class takes precedence (Rule #3).
Set<Class<?>> superClassesToRemove = new HashSet<>();
for (Class<?> concreteClass : concreteViewModelClasses) {
Set<Class<?>> superClasses = new HashSet<>();
Class<?> superClass = concreteClass.getSuperclass();
while (superClass != null) {
superClasses.add(superClass);
superClass = superClass.getSuperclass();
}
superClassesToRemove.addAll(superClasses);
}
concreteViewModelClasses.removeAll(superClassesToRemove);
// If there is exactly one concrete view model class left, then it is automatically bound.
if (concreteViewModelClasses.size() == 1) {
concreteViewModelClass = concreteViewModelClasses.iterator().next();
} else if (concreteViewModelClasses.size() > 1) {
// If there is still more than 1, calculate the C3 linearization
// of the model class hierarchy and choose the ViewModel(s) whose
// generic type argument appears first in the list (Rule #4).
// Collect the view model classes based on their generic model class.
Map<Class<?>, Set<Class<?>>> modelToViewModels = new HashMap<>();
for (Class<?> viewModelClass : concreteViewModelClasses) {
Class<?> genericModelClass = TypeDefinition.getInstance(viewModelClass).getInferredGenericTypeArgumentClass(ViewModel.class, 0);
if (genericModelClass != null) {
modelToViewModels.computeIfAbsent(genericModelClass, k -> new HashSet<>()).add(viewModelClass);
}
}
// Loop through the C3 linearized modelClass hierarchy and find
// the first view model(s) that match
try {
for (Class<?> next : c3LinearizeClass(modelClass)) {
Set<Class<?>> viewModelClasses = modelToViewModels.get(next);
if (viewModelClasses != null) {
if (viewModelClasses.size() == 1) {
concreteViewModelClass = viewModelClasses.iterator().next();
}
concreteViewModelClasses = viewModelClasses;
break;
}
}
} catch (RuntimeException e) {
LOGGER.warn("Could not linearize the class hierarchy for model type [{}] to disambiguate view model bindings.");
}
}
if (concreteViewModelClasses.size() > 1) {
// More than one valid class found, log a warning and short circuit (Rule #3).
LOGGER.warn("Found [{}] conflicting view model bindings for model type [{}] and view type [{}]: [{}]",
new Object[] {
concreteViewModelClasses.size(),
modelClass,
viewClass.getName(),
concreteViewModelClasses.stream().map(Class::getName).collect(Collectors.joining(", "))
});
return null;
}
}
// if a single concrete view model class was found, then return.
if (concreteViewModelClass != null) {
@SuppressWarnings("unchecked")
Class<? extends ViewModel<?>> viewModelClass = (Class<? extends ViewModel<?>>) concreteViewModelClass;
return viewModelClass;
} else { // do a lookup of the view bindings on the model.
Map<Class<?>, List<Class<? extends ViewModel>>> modelToViewModelClassMap = new HashMap<>();
Set<Class<? extends ViewModel>> allViewModelClasses = new LinkedHashSet<>();
for (Class<?> annotatableClass : ViewUtils.getAnnotatableClasses(modelClass)) {
allViewModelClasses.addAll(Arrays.stream(annotatableClass.getAnnotationsByType(ViewBinding.class))
// check that it matches the view type if it exists
.filter(viewBinding -> viewType == null || Arrays.asList(viewBinding.types()).contains(viewType))
// get the annotation's view model class
.map(ViewBinding::value)
.collect(Collectors.toList()));
}
allViewModelClasses.forEach(viewModelClass -> {
TypeDefinition<? extends ViewModel> typeDef = TypeDefinition.getInstance(viewModelClass);
Class<?> declaredModelClass = typeDef.getInferredGenericTypeArgumentClass(ViewModel.class, 0);
if (declaredModelClass != null && declaredModelClass.isAssignableFrom(modelClass)
&& (viewClass == null || viewClass.isAssignableFrom(viewModelClass))) {
List<Class<? extends ViewModel>> viewModelClasses = modelToViewModelClassMap.get(declaredModelClass);
if (viewModelClasses == null) {
viewModelClasses = new ArrayList<>();
modelToViewModelClassMap.put(declaredModelClass, viewModelClasses);
}
viewModelClasses.add(viewModelClass);
}
});
if (!modelToViewModelClassMap.isEmpty()) {
Set<Class<?>> nearestModelClasses = ViewUtils.getNearestSuperClassesInSet(modelClass, modelToViewModelClassMap.keySet());
if (nearestModelClasses.size() == 1) {
List<Class<? extends ViewModel>> viewModelClasses = modelToViewModelClassMap.get(nearestModelClasses.iterator().next());
if (viewModelClasses.size() == 1) {
@SuppressWarnings("unchecked")
Class<? extends ViewModel<?>> viewModelClass = (Class<? extends ViewModel<?>>) viewModelClasses.get(0);
return viewModelClass;
} else {
LOGGER.warn("Found [{}] conflicting view model bindings for model type [{}] and view type [{}]: [{}]",
new Object[] {
viewModelClasses.size(),
modelClass,
viewClass != null ? viewClass.getName() : null,
viewModelClasses.stream().map(Class::getName).collect(Collectors.joining(", "))
});
}
} else {
Set<Class<? extends ViewModel>> conflictingViewModelClasses = new LinkedHashSet<>();
for (Class<?> nearestModelClass : nearestModelClasses) {
conflictingViewModelClasses.addAll(modelToViewModelClassMap.get(nearestModelClass));
}
LOGGER.warn("Found [{}] conflicting view model bindings for model type [{}] and view type [{}]: [{}]",
new Object[] {
conflictingViewModelClasses.size(),
modelClass.getName(),
viewClass != null ? viewClass.getName() : null,
conflictingViewModelClasses.stream().map(Class::getName).collect(Collectors.joining(", "))
});
}
}
}
return null;
}
// https://en.wikipedia.org/wiki/C3_linearization
private static List<Class<?>> c3LinearizeClass(Class<?> source) {
return c3Linearize(source, child -> {
List<Class<?>> parents = new ArrayList<>();
// super class first...
Class<?> superClass = child.getSuperclass();
if (superClass != null && superClass != Object.class) {
parents.add(superClass);
}
// interfaces second...
parents.addAll(Arrays.asList(child.getInterfaces()));
// and Object last for all top level classes and interfaces.
// This is to ensure Object always ends up last.
if (child != Object.class && (superClass == null || superClass == Object.class)) {
parents.add(Object.class);
}
return parents;
}, new HashSet<>());
}
private static <T> List<T> c3Linearize(T source, Function<T, List<T>> parentsFunction, Set<T> visited) {
// Guard against stack overflow.
if (!visited.add(source)) {
throw new IllegalStateException("Cyclic hierarchy detected.");
}
// Store the linearization result.
List<T> result = new ArrayList<>();
// The source is always first.
result.add(source);
// Collect the source's direct parents.
List<T> sourceParents = new ArrayList<>(parentsFunction.apply(source));
if (!sourceParents.isEmpty()) {
// Linearize each parent and add the result to merge list.
List<List<T>> toMerge = sourceParents.stream()
.map(parent -> c3Linearize(parent, parentsFunction, new HashSet<>(visited)))
.collect(Collectors.toCollection(ArrayList::new));
// Add the source parents as the last item in the merge list.
toMerge.add(sourceParents);
// Merge and add to result.
result.addAll(c3merge(toMerge));
}
return result;
}
private static <T> List<T> c3merge(List<List<T>> lists) {
List<T> merged = new ArrayList<>();
// while the lists are not empty
while (lists.stream().map(List::size).mapToInt(i -> i).sum() > 0) {
// grab the first item from each list
List<T> candidates = new ArrayList<>(lists.stream()
.filter(list -> !list.isEmpty())
.map(list -> list.get(0))
.collect(Collectors.toCollection(LinkedHashSet::new)));
// find the first candidate that is not present in the tail of any of the lists
T candidate = candidates.stream()
.filter(c -> lists.stream()
.allMatch(list -> list.size() <= 1 || !list.subList(1, list.size()).contains(c)))
.findFirst().orElse(null);
if (candidate != null) {
// remove the candidate from each list and add it the merge list.
lists.forEach(list -> list.remove(candidate));
merged.add(candidate);
} else {
throw new IllegalStateException("Cyclic hierarchy detected.");
}
}
return merged;
}
/**
* @deprecated Use {@link #findViewModelClass(Class, Object)} or {@link #findViewModelClass(String, Object)} instead.
*/
@Deprecated
public static <M, V> Class<? extends ViewModel<? super M>> findViewModelClass(Class<V> viewClass, String viewType, M model) {
return findViewModelClassHelper(viewClass, viewType, model, false);
}
}