content: /* 
   * SnapAdmin - An automatically generated CRUD admin UI for Spring Boot apps
   * Copyright (C) 2023 Ailef (http://ailef.tech)
   * 
  
   */
  
  
  package tech.ailef.snapadmin.external.dbmapping;
  
  import java.lang.reflect.InvocationTargetException;
  import java.lang.reflect.Method;
  import java.util.ArrayList;
  import java.util.Arrays;
  import java.util.Collections;
  import java.util.HashMap;
  import java.util.List;
  import java.util.Map;
  import java.util.Objects;
  import java.util.Optional;
  import java.util.stream.Collectors;
  
  import org.springframework.web.multipart.MultipartFile;
  
  import com.fasterxml.jackson.annotation.JsonIgnore;
  
  import jakarta.persistence.ManyToMany;
  import jakarta.persistence.OneToMany;
  import jakarta.persistence.OneToOne;
  import jakarta.persistence.Table;
  import tech.ailef.snapadmin.external.SnapAdmin;
  import tech.ailef.snapadmin.external.annotations.ComputedColumn;
  import tech.ailef.snapadmin.external.annotations.DisableCreate;
  import tech.ailef.snapadmin.external.annotations.DisableDelete;
  import tech.ailef.snapadmin.external.annotations.DisableEdit;
  import tech.ailef.snapadmin.external.annotations.DisableExport;
  import tech.ailef.snapadmin.external.annotations.HiddenColumn;
  import tech.ailef.snapadmin.external.dbmapping.fields.DbField;
  import tech.ailef.snapadmin.external.dto.MappingError;
  import tech.ailef.snapadmin.external.exceptions.SnapAdminException;
  import tech.ailef.snapadmin.external.exceptions.SnapAdminNotFoundException;
  import tech.ailef.snapadmin.external.misc.Utils;
  
  /**
   * A class that represents a table/`@Entity` as reconstructed from the
   * JPA annotations found on its fields.
   *
   */
  public class DbObjectSchema {
  	/**
  	 * All the fields in this table. The fields include all the
  	 * columns present in the table plus relationship fields.
  	 */
  	@JsonIgnore
  	private List<DbField> fields = new ArrayList<>();
  	
  	/**
  	 * The methods designated as computed columns in the `@Entity` class.
  	 */
  	@JsonIgnore
  	private Map<String, Method> computedColumns = new HashMap<>();
  	
  	/**
  	 * A JPA repository to operate on the database
  	 */
  	private CustomJpaRepository jpaRepository;
  	
  	private SnapAdmin snapAdmin;
  	
  	/**
  	 * The corresponding `@Entity` class that this schema describes
  	 */
  	@JsonIgnore
  	private Class<?> entityClass;
  	
  	/**
  	 * The name of this table on the database
  	 */
  	private String tableName;
  	
  	private List<MappingError> errors = new ArrayList<>();
  	
  	/**
  	 * Initializes this schema for the specific `@Entity` class. 
  	 * Determines the table name from the `@Table` annotation and also
  	 * which methods are `@ComputedColumn`s
  	 * @param klass the `@Entity` class
  	 * @param snapAdmin the SnapAdmin instance
  	 */
  	public DbObjectSchema(Class<?> klass, SnapAdmin snapAdmin) {
  		this.snapAdmin = snapAdmin;
  		this.entityClass = klass;
  		
  		Table tableAnnotation = klass.getAnnotation(Table.class);
  		
  		String tableName = Utils.camelToSnake(getJavaClass().getSimpleName());
  		if (tableAnnotation != null && tableAnnotation.name() != null
  			&& !tableAnnotation.name().isBlank()) { 
  			tableName = tableAnnotation.name();
  		}
  
  		this.tableName = tableName;
  		
  		List<Method> methods = Arrays.stream(entityClass.getMethods())
  				.filter(m -> m.getAnnotation(ComputedColumn.class) != null)
  				.collect(Collectors.toList());
  		for (Method m : methods) {
  			if (m.getParameterCount() > 0)
  				throw new SnapAdminException("@ComputedColumn can only be applied on no-args methods");
  			
  			String name = m.getAnnotation(ComputedColumn.class).name();
  			if (name.isBlank())
  				name = Utils.camelToSnake(m.getName());
  			
  			computedColumns.put(name, m);
  		}
  	}
  	
  	public String getBasePackage() {
  		return entityClass.getPackageName();
  	}
  	
  	/**
  	 * Returns the SnapAdmin instance
  	 * @return the SnapAdmin instance
  	 */
  	public SnapAdmin getSnapAdmin() {
  		return snapAdmin;
  	}
  	
  	/**
  	 * Returns the Java class for the underlying `@Entity` this schema
  	 * corresponds to
  	 * @return  the Java class for the `@Entity` this schema corresponds to
  	 */
  	@JsonIgnore
  	public Class<?> getJavaClass() {
  		return entityClass;
  	}
  	
  	/**
  	 * Returns the name of the Java class for the underlying `@Entity` this schema
  	 * corresponds to
  	 * @return  the name of the Java class for the `@Entity` this schema corresponds to
  	 */
  	@JsonIgnore
  	public String getClassName() {
  		return entityClass.getName();
  	}
  	
  	/**
  	 * Returns an unmodifiable list of all the fields in the schema
  	 * @return an unmodifiable list of all the fields in the schema
  	 */
  	public List<DbField> getFields() {
  		return Collections.unmodifiableList(fields);
  	}
  	
  	public List<MappingError> getErrors() {
  		return Collections.unmodifiableList(errors);
  	}
  	
  	/**
  	 * Get a field by its Java name, i.e. the name of the instance variable
  	 * in the `@Entity` class
  	 * @param name	name of the instance variable
  	 * @return	the DbField if found, null otherwise
  	 */
  	public DbField getFieldByJavaName(String name) {
  		return fields.stream().filter(f -> f.getJavaName().equals(name)).findFirst().orElse(null);
  	}
  	
  	/**
  	 * Get a field by its database name, i.e. the name of the column corresponding
  	 * to the field
  	 * @param name	name of the column
  	 * @return	the DbField if found, null otherwise
  	 */
  	public DbField getFieldByName(String name) {
  		return fields.stream().filter(f -> f.getName().equals(name)).findFirst().orElse(null);
  	}
  	
  	/**
  	 * Adds a field to this schema. This is used by the SnapAdmin instance
  	 * during initialization and it's not supposed to be called afterwards
  	 * @param f	the DbField to add
  	 */
  	public void addField(DbField f) {
  		fields.add(f);
  	}
  	
  	public void addError(MappingError error) {
  		errors.add(error);
  	}
  	
  	/**
  	 * Returns the underlying CustomJpaRepository
  	 * @return
  	 */
  	public CustomJpaRepository getJpaRepository() {
  		return jpaRepository;
  	}
  
  	/**
  	 * Sets the underlying CustomJpaRepository
  	 * @param jpaRepository
  	 */
  	public void setJpaRepository(CustomJpaRepository jpaRepository) {
  		this.jpaRepository = jpaRepository;
  	}
  	
  	/**
  	 * Returns the inferred table name for this schema 
  	 * @return
  	 */
  	public String getTableName() {
  		return tableName;
  	}
  	
  	/**
  	 * See {@link DbObjectSchema#getSortedFields()} 
  	 * @return
  	 */
  	@JsonIgnore
  	public List<DbField> getSortedFields() {
  		return getSortedFields(true);
  	}
  	
  	/**
  	 * Returns a sorted list of physical fields (i.e., fields that correspond to
  	 * a column in the table as opposed to fields that are just present as 
  	 * instance variables, like relationship fields). Sorted alphabetically
  	 * with priority the primary key, and non nullable fields.
  	 * 
  	 * If readOnly is true, `@HiddenColumn`s are not returned. If instead
  	 * readOnly is false, i.e. how it gets called in the create/edit page,
  	 * hidden columns are included if they are not nullable.
  	 * 
  	 * @param readOnly whether we only need to read the fields are create/edit
  	 * @return 
  	 */
  	public List<DbField> getSortedFields(boolean readOnly) {
  		return getFields().stream()
  			.filter(f -> {
  				boolean toMany = f.getPrimitiveField().getAnnotation(OneToMany.class) == null
  					&& f.getPrimitiveField().getAnnotation(ManyToMany.class) == null;
  				
  				OneToOne oneToOne = f.getPrimitiveField().getAnnotation(OneToOne.class);
  				boolean mappedBy = oneToOne != null && !oneToOne.mappedBy().isBlank();
  				
  				boolean hidden = f.getPrimitiveField().getAnnotation(HiddenColumn.class) != null;
  				
  				
  				return toMany && !mappedBy && (!hidden || !readOnly);
  			})
  			.sorted((a, b) -> {
  				if (a.isPrimaryKey() && !b.isPrimaryKey())
  					return -1;
  				if (b.isPrimaryKey() && !a.isPrimaryKey())
  					return 1;
  				
  				if (!a.isNullable() && b.isNullable())
  					return -1;
  				if (a.isNullable() && !b.isNullable())
  					return 1;
  				
  				return a.getName().compareTo(b.getName());
  			}).collect(Collectors.toList());
  	}
  	
  	/**
  	 * Returns the list of relationship fields
  	 * @return
  	 */
  	public List<DbField> getRelationshipFields() {
  		List<DbField> res = getFields().stream().filter(f -> {
  			return f.getPrimitiveField().getAnnotation(OneToMany.class) != null
  				|| f.getPrimitiveField().getAnnotation(ManyToMany.class) != null;
  		})
  		.filter(f -> snapAdmin.isManagedClass(f.getConnectedType()))
  		.collect(Collectors.toList());
  		return res;
  	}
  	
  	/**
  	 * Returns the list of ManyToMany fields owned by this class (i.e. they
  	 * do not have "mappedBy")
  	 * @return
  	 */
  	public List<DbField> getManyToManyOwnedFields() {
  		List<DbField> res = getFields().stream().filter(f -> {
  			ManyToMany anno = f.getPrimitiveField().getAnnotation(ManyToMany.class);
  			return anno != null && anno.mappedBy().isBlank();
  		}).collect(Collectors.toList());
  		return res;
  	}
  	
  	/**
  	 * Returns the DbField which serves as the primary key for this schema
  	 * @return
  	 */
  	@JsonIgnore
  	public DbField getPrimaryKey() {
  		Optional<DbField> pk = fields.stream().filter(f -> f.isPrimaryKey()).findFirst();
  		if (pk.isPresent())
  			return pk.get();
  		else
  			throw new RuntimeException("No primary key defined on " + entityClass.getName() + " (table `" + tableName + "`)");
  	}
  	
  	/**
  	 * Returns the names of the `@ComputedColumn`s in this schema
  	 * @return
  	 */
  	public List<String> getComputedColumnNames() {
  		return computedColumns.keySet().stream().sorted().toList();
  	}
  	
  	/**
  	 * Returns the method for the given `@ComputedColumn` name
  	 * @param name the name of the `@ComputedColumn`
  	 * @return the corresponding instance method if found, null otherwise
  	 */
  	public Method getComputedColumn(String name) {
  		return computedColumns.get(name);
  	}
  	
  	/**
  	 * Returns the list of fields that are `@Filterable`
  	 * @return 
  	 */
  	public List<DbField> getFilterableFields() {
  		return getSortedFields().stream().filter(f -> { 
  			return !f.isBinary() && !f.isPrimaryKey() && f.isFilterable();
  		}).toList();
  	}
  	
  	public boolean isDeleteEnabled() {
  		return entityClass.getAnnotation(DisableDelete.class) == null;
  	}
  	
  	public boolean isEditEnabled() {
  		return entityClass.getAnnotation(DisableEdit.class) == null;
  	}
  	
  	public boolean isCreateEnabled() {
  		return entityClass.getAnnotation(DisableCreate.class) == null;
  	}
  	
  	public boolean isExportEnabled() {
  		return entityClass.getAnnotation(DisableExport.class) == null;
  	}
  	
  	/**
  	 * Returns all the data in this schema, as `DbObject`s
  	 * @return
  	 */
  	public List<DbObject> findAll() {
  		List<?> r = jpaRepository.findAll();
  		return r.stream().map(o -> new DbObject(o, this)).toList();
  	}
  
  	public DbObject buildObject(Map<String, String> params, Map<String, MultipartFile> files) {
  		try {
  			Object instance = getJavaClass().getConstructor().newInstance();
  			DbObject dbObject = new DbObject(instance, this);
  			
  			for (String param : params.keySet()) {
  				// Parameters starting with __ are hidden and not related to the object creation
  				if (param.startsWith("__") 
  					|| param.equals("_csrf")) continue;
  				
  				DbField dbField = getFieldByName(param);
  				
  				if (dbField == null)
  					throw new SnapAdminNotFoundException("Cannot find field " + param + " in " + getJavaClass().getName());
  				
  				String javaFieldName = dbField.getJavaName();
  				Method setter = dbObject.findSetter(javaFieldName);
  				
  				if (setter ==  null) {
  					throw new RuntimeException("Cannot find setter for " + javaFieldName);
  				}
  				
  				Object parsedFieldValue = 
  					getFieldByName(param).getType().parseValue(params.get(param));
  
  				if (parsedFieldValue != null && getFieldByName(param).isSettable()) {
  					setter.invoke(instance, parsedFieldValue);
  				}
  				
  				if (parsedFieldValue != null && getFieldByName(param).isToOne()) {
  					dbObject.setRelationship(param, parsedFieldValue);
  				}
  			}
  			
  			for (String fileParam : files.keySet()) {
  				if (fileParam.startsWith("__")) continue;
  
  				String javaFieldName = getFieldByName(fileParam).getJavaName();
  				Method setter = dbObject.findSetter(javaFieldName);
  				
  				if (setter ==  null) {
  					throw new RuntimeException("Cannot find setter for " + fileParam);
  				}
  				
  				Object parsedFieldValue = 
  						getFieldByName(fileParam).getType().parseValue(params.get(fileParam));
  				
  				if (parsedFieldValue != null && getFieldByName(fileParam).isSettable()) {
  					setter.invoke(instance, parsedFieldValue);
  				}
  			}
  			
  			return dbObject;
  		} catch (InstantiationException | IllegalAccessException | IllegalArgumentException | InvocationTargetException
  				| NoSuchMethodException | SecurityException e) {
  			throw new SnapAdminException(e);
  		}
  		
  	}
  	
  	@Override
  	public String toString() {
  		return "DbObjectSchema [fields=" + fields + ", className=" + entityClass.getName() + "]";
  	}
  
  	@Override
  	public int hashCode() {
  		return Objects.hash(tableName);
  	}
  
  	@Override
  	public boolean equals(Object obj) {
  		if (this == obj)
  			return true;
  		if (obj == null)
  			return false;
  		if (getClass() != obj.getClass())
  			return false;
  		DbObjectSchema other = (DbObjectSchema) obj;
  		return Objects.equals(tableName, other.tableName);
  	}
  	
  }
  