/*
* Copyright 2018 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package androidx.appsearch.compiler;
import static androidx.appsearch.compiler.IntrospectionHelper.DOCUMENT_ANNOTATION_CLASS;
import static androidx.appsearch.compiler.IntrospectionHelper.getDocumentAnnotation;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RestrictTo;
import androidx.appsearch.compiler.IntrospectionHelper.PropertyClass;
import java.util.ArrayList;
import java.util.Collections;
import java.util.EnumMap;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import javax.annotation.processing.ProcessingEnvironment;
import javax.lang.model.element.AnnotationMirror;
import javax.lang.model.element.Element;
import javax.lang.model.element.ElementKind;
import javax.lang.model.element.ExecutableElement;
import javax.lang.model.element.Modifier;
import javax.lang.model.element.TypeElement;
import javax.lang.model.element.VariableElement;
import javax.lang.model.util.ElementFilter;
/**
* Processes @Document annotations.
*
* @hide
*/
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
class DocumentModel {
/** Enumeration of fields that must be handled specially (i.e. are not properties) */
enum SpecialField {ID, NAMESPACE, CREATION_TIMESTAMP_MILLIS, TTL_MILLIS, SCORE}
/** Determines how the annotation processor has decided to read the value of a field. */
enum ReadKind {FIELD, GETTER}
/** Determines how the annotation processor has decided to write the value of a field. */
enum WriteKind {FIELD, SETTER, CREATION_METHOD}
private final IntrospectionHelper mHelper;
private final TypeElement mClass;
private final AnnotationMirror mDocumentAnnotation;
// Warning: if you change this to a HashSet, we may choose different getters or setters from
// run to run, causing the generated code to bounce.
private final Set<ExecutableElement> mAllMethods = new LinkedHashSet<>();
private final boolean mIsAutoValueDocument;
// Key: Name of the field which is accessed through the getter method.
// Value: ExecutableElement of the getter method.
private final Map<String, ExecutableElement> mGetterMethods = new HashMap<>();
// Key: Name of the field whose value is set through the setter method.
// Value: ExecutableElement of the setter method.
private final Map<String, ExecutableElement> mSetterMethods = new HashMap<>();
// Warning: if you change this to a HashMap, we may assign fields in a different order from run
// to run, causing the generated code to bounce.
private final Map<String, VariableElement> mAllAppSearchFields = new LinkedHashMap<>();
// Warning: if you change this to a HashMap, we may assign fields in a different order from run
// to run, causing the generated code to bounce.
private final Map<String, VariableElement> mPropertyFields = new LinkedHashMap<>();
private final Map<SpecialField, String> mSpecialFieldNames = new EnumMap<>(SpecialField.class);
private final Map<VariableElement, ReadKind> mReadKinds = new HashMap<>();
private final Map<VariableElement, WriteKind> mWriteKinds = new HashMap<>();
// Contains the reason why that field couldn't be written either by field or by setter.
private final Map<VariableElement, ProcessingException> mWriteWhyCreationMethod =
new HashMap<>();
private ExecutableElement mChosenCreationMethod = null;
private List<String> mChosenCreationMethodParams = null;
private DocumentModel(
@NonNull ProcessingEnvironment env,
@NonNull TypeElement clazz,
@Nullable TypeElement generatedAutoValueElement)
throws ProcessingException {
if (clazz.getModifiers().contains(Modifier.PRIVATE)) {
throw new ProcessingException("@Document annotated class is private", clazz);
}
mHelper = new IntrospectionHelper(env);
mClass = clazz;
mDocumentAnnotation = getDocumentAnnotation(mClass);
if (generatedAutoValueElement != null) {
mIsAutoValueDocument = true;
// Scan factory methods from AutoValue class.
Set<ExecutableElement> creationMethods = new LinkedHashSet<>();
for (Element child : ElementFilter.methodsIn(mClass.getEnclosedElements())) {
ExecutableElement method = (ExecutableElement) child;
if (isFactoryMethod(method)) {
creationMethods.add(method);
}
}
mAllMethods.addAll(
ElementFilter.methodsIn(generatedAutoValueElement.getEnclosedElements()));
scanFields(generatedAutoValueElement);
scanCreationMethods(creationMethods);
} else {
mIsAutoValueDocument = false;
// Scan methods and constructors. We will need this info when processing fields to
// make sure the fields can be get and set.
Set<ExecutableElement> creationMethods = new LinkedHashSet<>();
for (Element child : mClass.getEnclosedElements()) {
if (child.getKind() == ElementKind.CONSTRUCTOR) {
creationMethods.add((ExecutableElement) child);
} else if (child.getKind() == ElementKind.METHOD) {
ExecutableElement method = (ExecutableElement) child;
mAllMethods.add(method);
if (isFactoryMethod(method)) {
creationMethods.add(method);
}
}
}
scanFields(mClass);
scanCreationMethods(creationMethods);
}
}
/**
* Tries to create an {@link DocumentModel} from the given {@link Element}.
*
* @throws ProcessingException if the @{@code Document}-annotated class is invalid.
*/
public static DocumentModel createPojoModel(
@NonNull ProcessingEnvironment env, @NonNull TypeElement clazz)
throws ProcessingException {
return new DocumentModel(env, clazz, null);
}
/**
* Tries to create an {@link DocumentModel} from the given AutoValue {@link Element} and
* corresponding generated class.
*
* @throws ProcessingException if the @{@code Document}-annotated class is invalid.
*/
public static DocumentModel createAutoValueModel(
@NonNull ProcessingEnvironment env, @NonNull TypeElement clazz,
@NonNull TypeElement generatedAutoValueElement)
throws ProcessingException {
return new DocumentModel(env, clazz, generatedAutoValueElement);
}
@NonNull
public TypeElement getClassElement() {
return mClass;
}
@NonNull
public String getSchemaName() {
Map<String, Object> params =
mHelper.getAnnotationParams(mDocumentAnnotation);
String name = params.get("name").toString();
if (name.isEmpty()) {
return mClass.getSimpleName().toString();
}
return name;
}
@NonNull
public Map<String, VariableElement> getAllFields() {
return Collections.unmodifiableMap(mAllAppSearchFields);
}
@NonNull
public Map<String, VariableElement> getPropertyFields() {
return Collections.unmodifiableMap(mPropertyFields);
}
@Nullable
public String getSpecialFieldName(SpecialField field) {
return mSpecialFieldNames.get(field);
}
@Nullable
public ReadKind getFieldReadKind(String fieldName) {
VariableElement element = mAllAppSearchFields.get(fieldName);
return mReadKinds.get(element);
}
@Nullable
public WriteKind getFieldWriteKind(String fieldName) {
VariableElement element = mAllAppSearchFields.get(fieldName);
return mWriteKinds.get(element);
}
@Nullable
public ExecutableElement getGetterForField(String fieldName) {
return mGetterMethods.get(fieldName);
}
@Nullable
public ExecutableElement getSetterForField(String fieldName) {
return mSetterMethods.get(fieldName);
}
/**
* Finds the AppSearch name for the given property.
*
* <p>This is usually the name of the field in Java, but may be changed if the developer
* specifies a different 'name' parameter in the annotation.
*/
@NonNull
public String getPropertyName(@NonNull VariableElement property) throws ProcessingException {
AnnotationMirror annotation = getPropertyAnnotation(property);
Map<String, Object> params = mHelper.getAnnotationParams(annotation);
String propertyName = params.get("name").toString();
if (propertyName.isEmpty()) {
propertyName = getNormalizedFieldName(property.getSimpleName().toString());
}
return propertyName;
}
/**
* Returns the first found AppSearch property annotation element from the input element's
* annotations.
*
* @throws ProcessingException if no AppSearch property annotation is found.
*/
@NonNull
public AnnotationMirror getPropertyAnnotation(@NonNull Element element)
throws ProcessingException {
Objects.requireNonNull(element);
if (mIsAutoValueDocument) {
element = getGetterForField(element.getSimpleName().toString());
}
Set<String> propertyClassPaths = new HashSet<>();
for (PropertyClass propertyClass : PropertyClass.values()) {
propertyClassPaths.add(propertyClass.getClassFullPath());
}
for (AnnotationMirror annotation : element.getAnnotationMirrors()) {
String annotationFq = annotation.getAnnotationType().toString();
if (propertyClassPaths.contains(annotationFq)) {
return annotation;
}
}
throw new ProcessingException("Missing AppSearch property annotation.", element);
}
@NonNull
public ExecutableElement getChosenCreationMethod() {
return mChosenCreationMethod;
}
@NonNull
public List<String> getChosenCreationMethodParams() {
return Collections.unmodifiableList(mChosenCreationMethodParams);
}
private boolean isFactoryMethod(ExecutableElement method) {
Set<Modifier> methodModifiers = method.getModifiers();
return methodModifiers.contains(Modifier.STATIC)
&& !methodModifiers.contains(Modifier.PRIVATE)
&& method.getReturnType() == mClass.asType();
}
private void scanFields(TypeElement element) throws ProcessingException {
Element namespaceField = null;
Element idField = null;
Element creationTimestampField = null;
Element ttlField = null;
Element scoreField = null;
List<? extends Element> enclosedElements = element.getEnclosedElements();
for (int i = 0; i < enclosedElements.size(); i++) {
Element childElement = enclosedElements.get(i);
if (mIsAutoValueDocument && childElement.getKind() != ElementKind.METHOD) {
continue;
}
String fieldName = childElement.getSimpleName().toString();
for (AnnotationMirror annotation : childElement.getAnnotationMirrors()) {
String annotationFq = annotation.getAnnotationType().toString();
if (!annotationFq.startsWith(DOCUMENT_ANNOTATION_CLASS)) {
continue;
}
VariableElement child;
if (mIsAutoValueDocument) {
child = findFieldForFunctionWithSameName(enclosedElements, childElement);
} else {
if (childElement.getKind() == ElementKind.METHOD) {
throw new ProcessingException(
"AppSearch annotation is not applicable to methods for "
+ "Non-AutoValue class",
childElement);
} else {
child = (VariableElement) childElement;
}
}
switch (annotationFq) {
case IntrospectionHelper.ID_CLASS:
if (idField != null) {
throw new ProcessingException(
"Class contains multiple fields annotated @Id", child);
}
idField = child;
mSpecialFieldNames.put(SpecialField.ID, fieldName);
break;
case IntrospectionHelper.NAMESPACE_CLASS:
if (namespaceField != null) {
throw new ProcessingException(
"Class contains multiple fields annotated @Namespace",
child);
}
namespaceField = child;
mSpecialFieldNames.put(SpecialField.NAMESPACE, fieldName);
break;
case IntrospectionHelper.CREATION_TIMESTAMP_MILLIS_CLASS:
if (creationTimestampField != null) {
throw new ProcessingException(
"Class contains multiple fields annotated "
+ "@CreationTimestampMillis",
child);
}
creationTimestampField = child;
mSpecialFieldNames.put(SpecialField.CREATION_TIMESTAMP_MILLIS, fieldName);
break;
case IntrospectionHelper.TTL_MILLIS_CLASS:
if (ttlField != null) {
throw new ProcessingException(
"Class contains multiple fields annotated @TtlMillis",
child);
}
ttlField = child;
mSpecialFieldNames.put(SpecialField.TTL_MILLIS, fieldName);
break;
case IntrospectionHelper.SCORE_CLASS:
if (scoreField != null) {
throw new ProcessingException(
"Class contains multiple fields annotated @Score", child);
}
scoreField = child;
mSpecialFieldNames.put(SpecialField.SCORE, fieldName);
break;
default:
PropertyClass propertyClass = getPropertyClass(annotationFq);
if (propertyClass != null) {
checkFieldTypeForPropertyAnnotation(child, propertyClass);
mPropertyFields.put(fieldName, child);
}
}
mAllAppSearchFields.put(fieldName, child);
}
}
// Every document must always have a namespace
if (namespaceField == null) {
throw new ProcessingException(
"All @Document classes must have exactly one field annotated with @Namespace",
mClass);
}
// Every document must always have an ID
if (idField == null) {
throw new ProcessingException(
"All @Document classes must have exactly one field annotated with @Id",
mClass);
}
for (VariableElement appSearchField : mAllAppSearchFields.values()) {
chooseAccessKinds(appSearchField);
}
}
@NonNull
private VariableElement findFieldForFunctionWithSameName(
@NonNull List<? extends Element> elements,
@NonNull Element functionElement) throws ProcessingException {
String fieldName = functionElement.getSimpleName().toString();
for (VariableElement field : ElementFilter.fieldsIn(elements)) {
if (fieldName.equals(field.getSimpleName().toString())) {
return field;
}
}
throw new ProcessingException(
"Cannot find the corresponding field for the annotated function",
functionElement);
}
/**
* Checks whether property's data type matches the {@code androidx.appsearch.annotation
* .Document} property annotation's requirement.
*
* @throws ProcessingException if data type doesn't match property annotation's requirement.
*/
void checkFieldTypeForPropertyAnnotation(@NonNull VariableElement property,
PropertyClass propertyClass) throws ProcessingException {
switch (propertyClass) {
case BOOLEAN_PROPERTY_CLASS:
if (mHelper.isFieldOfExactType(property, mHelper.mBooleanBoxType,
mHelper.mBooleanPrimitiveType)) {
return;
}
break;
case BYTES_PROPERTY_CLASS:
if (mHelper.isFieldOfExactType(property, mHelper.mByteBoxType,
mHelper.mBytePrimitiveType, mHelper.mByteBoxArrayType,
mHelper.mBytePrimitiveArrayType)) {
return;
}
break;
case DOCUMENT_PROPERTY_CLASS:
if (mHelper.isFieldOfDocumentType(property)) {
return;
}
break;
case DOUBLE_PROPERTY_CLASS:
if (mHelper.isFieldOfExactType(property, mHelper.mDoubleBoxType,
mHelper.mDoublePrimitiveType, mHelper.mFloatBoxType,
mHelper.mFloatPrimitiveType)) {
return;
}
break;
case LONG_PROPERTY_CLASS:
if (mHelper.isFieldOfExactType(property, mHelper.mIntegerBoxType,
mHelper.mIntPrimitiveType, mHelper.mLongBoxType,
mHelper.mLongPrimitiveType)) {
return;
}
break;
case STRING_PROPERTY_CLASS:
if (mHelper.isFieldOfExactType(property, mHelper.mStringType)) {
return;
}
break;
default:
// do nothing
}
throw new ProcessingException(
"Property Annotation " + propertyClass.getClassFullPath() + " doesn't accept the "
+ "data type of property field " + property.getSimpleName(), property);
}
/**
* Returns the {@link PropertyClass} with {@code annotationFq} as full class path, and {@code
* null} if failed to find such a {@link PropertyClass}.
*/
@Nullable
private PropertyClass getPropertyClass(@Nullable String annotationFq) {
for (PropertyClass propertyClass : PropertyClass.values()) {
if (propertyClass.isPropertyClass(annotationFq)) {
return propertyClass;
}
}
return null;
}
/**
* Chooses how to access the given field for read and write, subject to our requirements for all
* AppSearch-managed class fields:
*
* <p>For read: visible field, or visible getter
*
* <p>For write: visible mutable field, or visible setter, or visible creation method
* accepting at minimum all fields that aren't mutable and have no visible setter.
*
* @throws ProcessingException if no access type is possible for the given field
*/
private void chooseAccessKinds(@NonNull VariableElement field)
throws ProcessingException {
// Choose get access
String fieldName = field.getSimpleName().toString();
Set<Modifier> modifiers = field.getModifiers();
if (modifiers.contains(Modifier.PRIVATE)) {
findGetter(fieldName);
mReadKinds.put(field, ReadKind.GETTER);
} else {
mReadKinds.put(field, ReadKind.FIELD);
}
// Choose set access
if (modifiers.contains(Modifier.PRIVATE) || modifiers.contains(Modifier.FINAL)
|| modifiers.contains(Modifier.STATIC)) {
// Try to find a setter. If we can't find one, mark the WriteKind as {@code
// CREATION_METHOD}. We don't know if this is true yet, the creation methods will be
// inspected in a subsequent pass.
try {
findSetter(fieldName);
mWriteKinds.put(field, WriteKind.SETTER);
} catch (ProcessingException e) {
// We'll look for a creation method, so we may still be able to set this field,
// but it's more likely the developer configured the setter incorrectly. Keep
// the exception around to include it in the report if no creation method is found.
mWriteWhyCreationMethod.put(field, e);
mWriteKinds.put(field, WriteKind.CREATION_METHOD);
}
} else {
mWriteKinds.put(field, WriteKind.FIELD);
}
}
private void scanCreationMethods(Set<ExecutableElement> creationMethods)
throws ProcessingException {
// Maps field name to Element.
// If this is changed to a HashSet, we might report errors to the developer in a different
// order about why a field was written via creation method.
Map<String, VariableElement> creationMethodWrittenFields = new LinkedHashMap<>();
for (Map.Entry<VariableElement, WriteKind> it : mWriteKinds.entrySet()) {
if (it.getValue() == WriteKind.CREATION_METHOD) {
String name = it.getKey().getSimpleName().toString();
creationMethodWrittenFields.put(name, it.getKey());
}
}
// Maps normalized field name to real field name.
Map<String, String> normalizedToRawFieldName = new HashMap<>();
for (String fieldName : mAllAppSearchFields.keySet()) {
normalizedToRawFieldName.put(getNormalizedFieldName(fieldName), fieldName);
}
Map<ExecutableElement, String> whyNotCreationMethod = new HashMap<>();
creationMethodSearch:
for (ExecutableElement method : creationMethods) {
if (method.getModifiers().contains(Modifier.PRIVATE)) {
whyNotCreationMethod.put(method, "Creation method is private");
continue creationMethodSearch;
}
// The field name of each field that goes into the creation method, in the order they
// are declared in the creation method signature.
List<String> creationMethodParamFields = new ArrayList<>();
Set<String> remainingFields = new HashSet<>(creationMethodWrittenFields.keySet());
for (VariableElement parameter : method.getParameters()) {
String paramName = parameter.getSimpleName().toString();
String fieldName = normalizedToRawFieldName.get(paramName);
if (fieldName == null) {
whyNotCreationMethod.put(
method,
"Parameter \"" + paramName + "\" is not an AppSearch parameter; don't "
+ "know how to supply it.");
continue creationMethodSearch;
}
remainingFields.remove(fieldName);
creationMethodParamFields.add(fieldName);
}
if (!remainingFields.isEmpty()) {
whyNotCreationMethod.put(
method,
"This method doesn't have parameters for the following fields: "
+ remainingFields);
continue creationMethodSearch;
}
// Found one!
mChosenCreationMethod = method;
mChosenCreationMethodParams = creationMethodParamFields;
return;
}
// If we got here, we couldn't find any creation methods.
ProcessingException e =
new ProcessingException(
"Failed to find any suitable creation methods to build this class. See "
+ "warnings for details.", mClass);
// Inform the developer why we started looking for creation methods in the first place.
for (VariableElement field : creationMethodWrittenFields.values()) {
ProcessingException warning = mWriteWhyCreationMethod.get(field);
if (warning != null) {
e.addWarning(warning);
}
}
// Inform the developer about why each creation method we considered was rejected.
for (Map.Entry<ExecutableElement, String> it : whyNotCreationMethod.entrySet()) {
ProcessingException warning = new ProcessingException(
"Cannot use this creation method to construct the class: " + it.getValue(),
it.getKey());
e.addWarning(warning);
}
throw e;
}
/** Finds getter function for a private field. */
private void findGetter(@NonNull String fieldName) throws ProcessingException {
ProcessingException e = new ProcessingException(
"Field cannot be read: it is private and we failed to find a suitable getter "
+ "for field \"" + fieldName + "\"",
mAllAppSearchFields.get(fieldName));
for (ExecutableElement method : mAllMethods) {
String methodName = method.getSimpleName().toString();
String normalizedFieldName = getNormalizedFieldName(fieldName);
if (methodName.equals(normalizedFieldName)
|| methodName.equals("get"
+ normalizedFieldName.substring(0, 1).toUpperCase()
+ normalizedFieldName.substring(1))) {
if (method.getModifiers().contains(Modifier.PRIVATE)) {
e.addWarning(new ProcessingException(
"Getter cannot be used: private visibility", method));
continue;
}
if (!method.getParameters().isEmpty()) {
e.addWarning(new ProcessingException(
"Getter cannot be used: should take no parameters", method));
continue;
}
// Found one!
mGetterMethods.put(fieldName, method);
return;
}
}
// Broke out of the loop without finding anything.
throw e;
}
/** Finds setter function for a private field. */
private void findSetter(@NonNull String fieldName) throws ProcessingException {
// We can't report setter failure until we've searched the creation methods, so this
// message is anticipatory and should be buffered by the caller.
ProcessingException e = new ProcessingException(
"Field cannot be written directly or via setter because it is private, final, or "
+ "static, and we failed to find a suitable setter for field \""
+ fieldName
+ "\". Trying to find a suitable creation method.",
mAllAppSearchFields.get(fieldName));
for (ExecutableElement method : mAllMethods) {
String methodName = method.getSimpleName().toString();
String normalizedFieldName = getNormalizedFieldName(fieldName);
if (methodName.equals(normalizedFieldName)
|| methodName.equals("set"
+ normalizedFieldName.substring(0, 1).toUpperCase()
+ normalizedFieldName.substring(1))) {
if (method.getModifiers().contains(Modifier.PRIVATE)) {
e.addWarning(new ProcessingException(
"Setter cannot be used: private visibility", method));
continue;
}
if (method.getParameters().size() != 1) {
e.addWarning(new ProcessingException(
"Setter cannot be used: takes " + method.getParameters().size()
+ " parameters instead of 1",
method));
continue;
}
// Found one!
mSetterMethods.put(fieldName, method);
return;
}
}
// Broke out of the loop without finding anything.
throw e;
}
/**
* Produces the canonical name of a field (which is used as the default property name as well as
* to find accessors) by removing prefixes and suffixes of common conventions.
*/
private String getNormalizedFieldName(String fieldName) {
if (fieldName.length() < 2) {
return fieldName;
}
// Handle convention of having field names start with m
// (e.g. String mName; public String getName())
if (fieldName.charAt(0) == 'm' && Character.isUpperCase(fieldName.charAt(1))) {
return fieldName.substring(1, 2).toLowerCase() + fieldName.substring(2);
}
// Handle convention of having field names start with _
// (e.g. String _name; public String getName())
if (fieldName.charAt(0) == '_'
&& fieldName.charAt(1) != '_'
&& Character.isLowerCase(fieldName.charAt(1))) {
return fieldName.substring(1);
}
// Handle convention of having field names end with _
// (e.g. String name_; public String getName())
if (fieldName.charAt(fieldName.length() - 1) == '_'
&& fieldName.charAt(fieldName.length() - 2) != '_') {
return fieldName.substring(0, fieldName.length() - 1);
}
return fieldName;
}
}