SysTableComponent.java
/*
* Copyright 2022 Global Crop Diversity Trust
*
* 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 org.gringlobal.compatibility.component;
import java.lang.reflect.Field;
import java.lang.reflect.Modifier;
import java.math.BigDecimal;
import java.time.Instant;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.Collection;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import javax.annotation.Resource;
import javax.persistence.Column;
import javax.persistence.DiscriminatorValue;
import javax.persistence.GeneratedValue;
import javax.persistence.Id;
import javax.persistence.JoinColumn;
import javax.persistence.Lob;
import javax.persistence.ManyToOne;
import javax.persistence.OneToOne;
import javax.persistence.Table;
import javax.validation.constraints.NotNull;
import lombok.extern.slf4j.Slf4j;
import org.aopalliance.intercept.MethodInvocation;
import org.apache.commons.lang3.StringUtils;
import org.genesys.blocks.model.AuditedVersionedModel;
import org.genesys.blocks.model.BasicModel;
import org.genesys.blocks.model.EmptyModel;
import org.gringlobal.compatibility.SysTableFieldInfo;
import org.gringlobal.compatibility.SysTableInfo;
import org.gringlobal.compatibility.service.impl.VirtualDataviewServiceImpl;
import org.gringlobal.compatibility.service.impl.VirtualLookupServiceImpl;
import org.gringlobal.custom.validation.javax.CodeValueField;
import org.gringlobal.model.AuditedModel;
import org.gringlobal.model.CooperatorOwnedModel;
import org.gringlobal.model.SysTable;
import org.gringlobal.model.SysTableField;
import org.hibernate.annotations.Formula;
import org.springframework.aop.framework.ProxyFactory;
import org.springframework.aop.support.DelegatingIntroductionInterceptor;
import org.springframework.aop.support.NameMatchMethodPointcutAdvisor;
import org.springframework.data.annotation.CreatedBy;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedBy;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.stereotype.Component;
import org.springframework.util.ReflectionUtils;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonProperty.Access;
@Component
@Slf4j
public class SysTableComponent {
private static final String FALSE = "N";
private static final String TRUE = "Y";
private static final String SYS_TABLE_ID_PREFIX = "sys_table_id";
private static final String SYS_TABLE_FIELD_ID_PREFIX = "sys_table_field_id";
@Resource
private Set<Class<?>> entityClassSet;
// Clustered cache of identifiers
@Resource(name = "legacySystemPrimaryKeys")
private Map<String, Long> legacySystemPrimaryKeys;
// JVM-bound cache
private HashMap<String, SysTable> sysTableCache = new HashMap<>();
private HashMap<String, SysTableField> sysTableFieldCache = new HashMap<>();
/**
* Get SysTable for database table name.
*
* @param databaseTableName the database table
* @return SysTable or empty
*/
public Optional<SysTable> getSysTableByTableName(String databaseTableName) {
Optional<Class<?>> optionalEntityClass = entityClassSet.stream().filter(entity -> StringUtils.equalsIgnoreCase(databaseTableName, getDatabaseTableName(entity))).findFirst();
if (optionalEntityClass.isEmpty()) {
log.info("Entity not found for {}", databaseTableName);
return Optional.empty();
}
return Optional.ofNullable(getSysTable(optionalEntityClass.get()));
}
public SysTable getSysTable(Class<?> target) {
return sysTableCache.computeIfAbsent(target.getName(), (key) -> {
log.debug("SysTable not cached for key={} and needs to be generated", key);
return generateSysTable(target);
});
}
private String getDatabaseTableName(Class<?> target) {
Table table = target.getAnnotation(Table.class);
if (table != null) {
return table.name();
} else {
// Handle JPA inheritance
var discriminatorValue = target.getAnnotation(DiscriminatorValue.class);
if (discriminatorValue != null) {
table = target.getSuperclass().getAnnotation(Table.class);
if (table != null) {
return table.name();
} else {
log.debug("No SysTable for {}, missing @Table on superclass {}", target, target.getSuperclass());
return null;
}
} else {
log.debug("No SysTable for {}, missing @DiscriminatorValue", target);
return null;
}
}
}
public SysTable generateSysTable(Class<?> target) {
String tableName = getDatabaseTableName(target);
if (tableName == null) {
log.warn("Could not find database table name for {}", target);
return null;
}
SysTable sysTable = new SysTable();
sysTable.setId(getCachedId(SYS_TABLE_ID_PREFIX, tableName));
sysTable.setTableName(tableName);
sysTable.setIsEnabled("Y");
sysTable.setIsReadonly(Modifier.isAbstract(target.getModifiers()) ? "Y" : "N");
sysTable.setAuditsCreated("N");
sysTable.setAuditsModified("N");
sysTable.setAuditsOwned("N");
if (CooperatorOwnedModel.class.isAssignableFrom(target)) {
sysTable.setAuditsCreated("Y");
sysTable.setAuditsModified("Y");
sysTable.setAuditsOwned("Y");
} else if (AuditedVersionedModel.class.isAssignableFrom(target)) {
sysTable.setAuditsCreated("Y");
sysTable.setAuditsModified("Y");
}
SysTableInfo sysTableInfo = target.getAnnotation(SysTableInfo.class);
if (Objects.nonNull(sysTableInfo)) {
String area = sysTableInfo.area();
sysTable.setDatabaseAreaCode(DatabaseAreaCode.fromValue(area).value);
}
sysTable.setFields(getSysTableFields(target));
// Because fields are statically cached, the next line changes their sysTable
sysTable.getFields().forEach(field -> field.setTable(sysTable));
return sysTable;
}
public List<SysTableField> getSysTableFields(Class<?> target) {
List<SysTableField> fields = new ArrayList<>();
ReflectionUtils.doWithFields(target, field -> {
if (Modifier.isStatic(field.getModifiers())) {
// Skip static fields
return;
}
try {
SysTableField sysTableField = getSysTableField(field, target);
if (Objects.nonNull(sysTableField)) {
log.trace("Adding field {}", field.getName());
fields.add(sysTableField);
}
} catch (Exception e) {
log.error("Exception in getting SysTableField from field", e);
}
});
fields.sort((f1, f2) -> {
var compareOrder = Optional.ofNullable(f1.getFieldOrdinal()).orElse(2)
.compareTo(Optional.ofNullable(f2.getFieldOrdinal()).orElse(2));
if (compareOrder == 0) {
// required fields before other fields
if (Objects.equals(f1.getIsNullable(), FALSE) && Objects.equals(f2.getIsNullable(), TRUE)) {
return -1;
} else if (Objects.equals(f2.getIsNullable(), FALSE)) {
return 1;
}
}
return compareOrder;
});
return fields;
}
public SysTableField getSysTableField(Field targetField, Class<?> targetClass) {
var fieldCacheKey = String.format("%s#%s", targetClass.getName(), targetField.getName());
return sysTableFieldCache.computeIfAbsent(fieldCacheKey, (key) -> {
log.debug("SysTableField not cached for key={}", key);
return generateSysTableField(targetField, targetClass);
});
}
private SysTableField generateSysTableField(Field targetField, Class<?> targetClass) {
log.trace("Generating SysTableField {}.{}", targetClass.getName(), targetField.getName());
var stfi = targetField.getAnnotation(SysTableFieldInfo.class);
if (stfi != null && stfi.ignore()) {
log.debug("Field {}.{} is ignored", targetClass.getSimpleName(), targetField.getName());
return null;
}
ReflectionUtils.makeAccessible(targetField);
String declaredFieldName = getFieldGGName(targetField);
if (StringUtils.isEmpty(declaredFieldName)) {
return null;
}
if (Collection.class.isAssignableFrom(targetField.getType())) {
return null; // all collections are ignored
}
if (Map.class.isAssignableFrom(targetField.getType())) {
return null; // all maps are ignored
}
if (byte[].class.isAssignableFrom(targetField.getType())) {
return null; // all byte[] are ignored
}
// if (Objects.nonNull(targetField.getAnnotation(OneToMany.class))) { // is a collection
// return null;
// }
var fieldCacheKey = String.format("%s#%s", targetClass.getName(), targetField.getName()); // Full name as key: org.gringlobal.model.Accession#accession
SysTableField field = new SysTableField();
field.setPropertyName(targetField.getName());
field.setId(getCachedId(SYS_TABLE_FIELD_ID_PREFIX, fieldCacheKey));
field.setFieldName(declaredFieldName);
field.setFieldType(FieldType.typeOfField(targetField).name());
field.setIsForeignKey(isForeignKey(targetField) ? TRUE : FALSE);
field.setIsPrimaryKey(isPrimaryKey(targetField) ? TRUE : FALSE);
if (Objects.equals(field.getIsPrimaryKey(), TRUE)) {
field.setFieldOrdinal(0); // set to 0
}
field.setMinLength(0);
field.setMaxLength(getMaxLength(targetField));
field.setIsNullable(isNullable(targetField) ? TRUE : FALSE);
if (targetField.getName().startsWith("is")) {
field.setDefaultValue("N");
}
field.setIsAutoincrement(isAutoincrement(targetField) ? TRUE : FALSE);
field.setFieldPurpose(getFieldPurpose(targetField));
var aColumn = targetField.getAnnotation(Column.class);
var aCodeValueField = targetField.getAnnotation(CodeValueField.class);
if (aCodeValueField == null) {
// Check getter for AbstractAction implementations
var targetFieldGetter = ReflectionUtils.findMethod(targetClass, "get" + StringUtils.capitalize(targetField.getName()));
if (targetFieldGetter != null) {
aCodeValueField = targetFieldGetter.getAnnotation(CodeValueField.class);
}
}
if (Objects.nonNull(aCodeValueField)) {
// Annotated with @CodeValueField
field.setGuiHint(GuiHint.SMALL_SINGLE_SELECT_CONTROL.name());
field.setGroupName(aCodeValueField.value());
} else if (Objects.isNull(field.getGuiHint())) {
field.setGuiHint(GuiHint.guiHintFromField(targetField).name());
}
if (Number.class.isAssignableFrom(targetField.getType())) {
if (aColumn != null && aColumn.precision() > 0) {
field.setNumericPrecision(aColumn.precision());
} else {
field.setNumericPrecision(18);
}
if (aColumn != null && aColumn.scale() > 0) {
field.setNumericScale(aColumn.scale());
} else if (Objects.equals(field.getFieldType(), FieldType.DECIMAL.name())) {
field.setNumericScale(7);
} else {
field.setNumericScale(0);
}
}
if (targetField.isAnnotationPresent(GeneratedValue.class)) {
field.setIsReadonly(TRUE);
} else if (targetField.isAnnotationPresent(Formula.class)) {
field.setIsReadonly(TRUE);
} else if (targetField.getName().equals("ownedBy")) { // No annotations
field.setIsReadonly(TRUE);
field.setFieldOrdinal(8);
} else if (targetField.getName().equals("ownedDate")) { // No annotations
field.setIsReadonly(TRUE);
field.setFieldOrdinal(9);
} else if (targetField.isAnnotationPresent(CreatedBy.class)) {
field.setIsReadonly(TRUE);
field.setFieldOrdinal(10);
} else if (targetField.isAnnotationPresent(CreatedDate.class)) {
field.setIsReadonly(TRUE);
field.setFieldOrdinal(11);
} else if (targetField.isAnnotationPresent(LastModifiedBy.class)) {
field.setIsReadonly(TRUE);
field.setFieldOrdinal(12);
} else if (targetField.isAnnotationPresent(LastModifiedDate.class) || targetField.getName().equals("modifiedDate")) { // No modifiedDate is @Version in GGCE
field.setIsReadonly(TRUE);
field.setFieldOrdinal(13);
} else if (stfi != null) {
field.setIsReadonly(stfi.readonly() ? TRUE : FALSE);
} else if (targetField.isAnnotationPresent(JsonProperty.class)) {
var jsonProperty = targetField.getAnnotation(JsonProperty.class);
if (jsonProperty.access() == Access.READ_ONLY) {
field.setIsReadonly(TRUE);
}
// TODO Include other relevant annotations
} else {
field.setIsReadonly(FALSE);
}
if (isForeignKey(targetField)) {
// And all we need is the ID of the field
// We are at risk of never-ending-loops here because of circular references.
// That's why we have a Proxy (like a Hibernate Proxy) that will call
// #getSysTableField() for target field when invoked.
var foreignClass = targetField.getType();
if (AuditedModel.class.isAssignableFrom(foreignClass)
// Core
|| BasicModel.class.isAssignableFrom(foreignClass)
// Ugh
|| EmptyModel.class.isAssignableFrom(foreignClass)
) {
String lookupName = VirtualDataviewServiceImpl.GGCE_PREFIX + foreignClass.getSimpleName() + VirtualLookupServiceImpl.LOOKUP_SUFFIX; // gget_TaxonomySpecies_lookup
// Check for id field in foreignClass
var foreignField = ReflectionUtils.findField(foreignClass, "id", Long.class);
if (foreignField != null) {
field.setForeignKeyDataviewName(lookupName);
field.setIsForeignKey("Y");
field.setGuiHint(GuiHint.LARGE_SINGLE_SELECT_CONTROL.name()); // Picker
field = makeProxyForForeignKey(field, foreignClass); // FIXME This does not handle CooperatorOwnedLang.entity with @AssociationOverride
} else { // No id field in foreignClass
field.setGuiHint(GuiHint.INTEGER_CONTROL.name());
}
}
}
// Should this be filled with data?
// field.setFieldOrdinal(); // can be null, leave it null
// field.setDefaultValue(); // leave it null
return field;
}
private SysTableField makeProxyForForeignKey(SysTableField field, Class<?> foreignClass) {
ProxyFactory factory = new ProxyFactory();
factory.setTarget(field);
var methodNameAdvisor = new NameMatchMethodPointcutAdvisor();
methodNameAdvisor.setMappedName("getForeignKeyTableField"); // this should limit execution to one method
methodNameAdvisor.setAdvice(new DelegatingIntroductionInterceptor(field) {
private static final long serialVersionUID = 2075892232786388301L;
@Override
public Object invoke(MethodInvocation mi) throws Throwable {
log.trace("Doing stuff {} #{}", mi.getThis(), mi.getMethod().getName());
// TODO Maybe foreign keys reference something else but "id"?
var foreignField = ReflectionUtils.findField(foreignClass, "id", Long.class);
if (foreignField == null) {
log.error("No id field found in {}", foreignClass.getName());
throw new RuntimeException("Type is missing field with name 'id'");
}
return getSysTableField(foreignField, foreignClass);
}
});
factory.addAdvisor(methodNameAdvisor);
return (SysTableField) factory.getProxy();
}
public enum FieldType {
INTEGER, STRING, DATETIME, DECIMAL, LONG, GUID;
public static FieldType typeOfField(Field field) {
Class<?> fieldType = field.getType();
if (fieldType == String.class) {
return STRING;
} else if (fieldType == Integer.class || fieldType == int.class) {
return INTEGER;
} else if (fieldType == Long.class || fieldType == long.class) {
return LONG;
} else if (fieldType == Date.class) {
return DATETIME;
} else if (fieldType == BigDecimal.class || fieldType == Double.class || fieldType == double.class || fieldType == Float.class || fieldType == float.class) {
return DECIMAL;
} else if (fieldType == Calendar.class) {
return DATETIME;
} else if (fieldType == Instant.class) {
return DATETIME;
} else if (fieldType == LocalDate.class) {
return DATETIME;
} else if (fieldType == UUID.class) {
return GUID;
} else if (fieldType == boolean.class) {
return FieldType.STRING; // GG!
} else if (fieldType.isEnum()) {
return STRING;
} else if (AuditedModel.class.isAssignableFrom(fieldType)) {
return INTEGER;
} else if (BasicModel.class.isAssignableFrom(fieldType)) {
return LONG;
} else if (EmptyModel.class.isAssignableFrom(fieldType)) {
return LONG;
}
log.error("Unmanaged SysTableField {}.{} of type {}", field.getDeclaringClass().getName(), field.getName(), fieldType.getName());
return STRING;
}
}
public enum FieldPurpose {
PRIMARY_KEY, AUTO_DATE_CREATE, AUTO_ASSIGN_OWN, AUTO_ASSIGN_CREATE,
AUTO_ASSIGN_MODIFY, DATA, AUTO_DATE_MODIFY, AUTO_DATE_OWN
}
public enum DatabaseAreaCode {
NULL(null), ACCESSION("Accession"), CROP("Crop"), INVENTORY("Inventory"),
ORDER("Order"), OTHER("Other"), TAXONOMY("Taxonomy"), CITATION("Citation"),
CODE("Code"), COOPERATOR("Cooperator"), DATAVIEW("Dataview"), GENETIC("Genetic"),
GEOGRAPHIC("Geographic"), IMPORT_WIZARD("Import Wizard"), LOOKUP_TABLE("Lookup Table"),
METHOD("Method"), SEARCH("Search"), SYSTEM("System"), SOURCE_HABITAT("Source/Habitat"),
ACCESSION_INVENTORY("Accession/Inventory"), SITE("Site"), WEB("Web");
public final String value;
DatabaseAreaCode(String value) {
this.value = value;
}
public static DatabaseAreaCode fromValue(String value) {
var areaCode = NULL;
for (DatabaseAreaCode databaseAreaCode : values()) {
if (Objects.equals(databaseAreaCode.value, value)) {
areaCode = databaseAreaCode;
break;
}
}
return areaCode;
}
}
public enum GuiHint {
DECIMAL_CONTROL, DATE_CONTROL, TOGGLE_CONTROL, TEXT_CONTROL,
LARGE_SINGLE_SELECT_CONTROL, SMALL_SINGLE_SELECT_CONTROL, INTEGER_CONTROL;
public static GuiHint guiHintFromField(Field targetField) {
if (Double.class.isAssignableFrom(targetField.getType())) {
return DECIMAL_CONTROL;
}
if (Float.class.isAssignableFrom(targetField.getType())) {
return DECIMAL_CONTROL;
}
if (Number.class.isAssignableFrom(targetField.getType())) {
return INTEGER_CONTROL;
}
if (Date.class.isAssignableFrom(targetField.getType())) {
return DATE_CONTROL;
}
if (Calendar.class.isAssignableFrom(targetField.getType())) {
return DATE_CONTROL;
}
if (targetField.getName().startsWith("is")) {
return TOGGLE_CONTROL;
}
return TEXT_CONTROL;
}
}
/**
* Generate a GRIN-Global compatible field name from Field definition.
*
* @param targetField the field
* @return a GRIN-Global compatible field name
*/
private String getFieldGGName(Field targetField) {
var columnAnno = targetField.getAnnotation(Column.class);
if (Objects.nonNull(columnAnno) && StringUtils.isNotEmpty(columnAnno.name())) {
// Has @Column with name
return columnAnno.name();
} else {
// Is foreign key
if (isForeignKey(targetField)) {
var joinAnno = targetField.getAnnotation(JoinColumn.class);
if (Objects.nonNull(joinAnno) && StringUtils.isNotEmpty(joinAnno.name())) {
// Has @JoinColumn with name
return joinAnno.name();
} else {
// Add _id to field, e.g. "private TaxonomySpecies taxonomySpecies" -->
// "taxonomy_species_id"
return targetField.getName() + "_id"; // in under_score_case
}
}
}
// Default to field name
return targetField.getName(); // in under_score_case
}
private boolean isNullable(Field targetField) {
if (targetField.getType().isPrimitive()) {
return false;
}
if (Objects.nonNull(targetField.getAnnotation(Id.class))) {
return false;
}
if (Objects.nonNull(targetField.getAnnotation(NotNull.class))) {
return false;
}
var columnAnno = targetField.getAnnotation(Column.class);
if (Objects.nonNull(columnAnno)) {
return columnAnno.nullable();
}
var joinAnno = targetField.getAnnotation(JoinColumn.class);
if (Objects.nonNull(joinAnno)) {
return joinAnno.nullable();
}
return true;
}
private String getFieldPurpose(Field targetField) {
// Map field to fieldPurpose
if (Objects.nonNull(targetField.getAnnotation(Id.class))) {
return "PRIMARY_KEY";
} else if (Objects.nonNull(targetField.getAnnotation(LastModifiedDate.class))) {
return "AUTO_DATE_MODIFY";
} else if (Objects.nonNull(targetField.getAnnotation(CreatedDate.class))) {
return "AUTO_DATE_CREATE";
} else if (Objects.nonNull(targetField.getAnnotation(CreatedBy.class))) {
return "AUTO_ASSIGN_CREATE"; // CT uses Cooperator.id which doesn't help
} else if (Objects.nonNull(targetField.getAnnotation(LastModifiedBy.class))) {
return "AUTO_ASSIGN_MODIFY"; // CT uses Cooperator.id which doesn't help
} else if (targetField.getName().equals("ownedBy")) {
return "AUTO_ASSIGN_OWN"; // CT uses Cooperator.id which doesn't help
} else if (targetField.getName().equals("ownedDate")) {
return "AUTO_DATE_OWN";
}
return "DATA";
}
private boolean isPrimaryKey(Field targetField) {
return Objects.nonNull(targetField.getAnnotation(Id.class));
}
public static boolean isForeignKey(Field targetField) {
return Objects.nonNull(targetField.getAnnotation(ManyToOne.class)) || Objects.nonNull(targetField.getAnnotation(OneToOne.class));
}
private int getMaxLength(Field targetField) {
if (targetField.getAnnotation(Lob.class) != null) {
return -1; // Lob's have no limit
}
var fieldType = targetField.getType();
if (fieldType == String.class) {
if (targetField.getName().startsWith("is")) { // GG boolean
return 1;
}
var columnAnno = targetField.getAnnotation(Column.class);
if (Objects.nonNull(columnAnno))
return columnAnno.length();
else
return 255;
}
if (fieldType == Boolean.class || fieldType == boolean.class) {
return 5; // 5 for 'false'
}
return 0;
}
private boolean isAutoincrement(Field targetField) {
return Objects.nonNull(targetField.getAnnotation(GeneratedValue.class));
}
private Long getCachedId(String componentPrefix, String componentName) {
String key = String.format("%s:%s", componentPrefix, componentName);
var id = legacySystemPrimaryKeys.get(key);
return id != null ? id : _generateId(componentPrefix, componentName);
}
private synchronized Long _generateId(String componentPrefix, String componentName) {
String key = String.format("%s:%s", componentPrefix, componentName);
String maxIdKey = String.format("maxId:%s", componentPrefix);
var existingMaxId = legacySystemPrimaryKeys.computeIfAbsent(maxIdKey, (prefixKey) -> {
log.trace("Initializing counter for pref={} key {}", componentPrefix, prefixKey);
return 0L;
});
var id = legacySystemPrimaryKeys.get(key);
if (id == null) {
id = existingMaxId + 1;
log.trace("Generating value for pref={} name={} ~= {}", componentPrefix, componentName, id);
if (legacySystemPrimaryKeys.putIfAbsent(key, id) == null) {
legacySystemPrimaryKeys.put(maxIdKey, id);
}
}
return id;
}
}