DBEnum.java

package nz.co.gregs.dbvolution.datatypes;

import java.lang.reflect.Array;
import java.security.InvalidParameterException;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
import nz.co.gregs.dbvolution.databases.definitions.DBDefinition;
import nz.co.gregs.dbvolution.expressions.DBExpression;
import nz.co.gregs.dbvolution.internal.properties.PropertyWrapperDefinition;
import nz.co.gregs.dbvolution.operators.DBPermittedValuesOperator;

/**
 * Base class for enumeration-aware queryable datatypes. Enumeration-aware
 * queryable datatypes map automatically from the database value to the
 * enumeration value via the {@link DBEnumValue} interface.
 *
 * <p>
 * Internally stores only the database-centric literal value in its type.
 * Conversion to the enumeration type is done lazily so that it's possible to
 * handle the case where a database has an invalid value or a new value that
 * isn't in the enumeration.</p>
 *
 * <p>
 * The easiest way to use DBEnum is via {@link DBIntegerEnum} or
 * {@link DBStringEnum}.
 * </p>
 *
 * <p>
 * Example implementations are available in
 * </p>
 *
 * @param <ENUM> the enumeration type. Must implement {@link DBEnumValue}.
 * @param <BASETYPE> the base type used on the database.
 */
public abstract class DBEnum<ENUM extends Enum<ENUM> & DBEnumValue<BASETYPE>, BASETYPE> extends QueryableDatatype<BASETYPE> {

	private static final long serialVersionUID = 1L;
	private Class<ENUM> enumType;

	/**
	 * The default constructor for DBEnums.
	 *
	 * <p>
	 * Creates an unset undefined DBEnum object.
	 *
	 * <p>
	 * Normal used in your DBRow sub-classes as:
	 * {@code			public DBEnum<MyDBEnumValue> field = new DBEnum<MyDBEnumValue>();}
	 * Where MyDBEnumValue is a sub-class of {@link DBEnumValue} and probably a
	 * {@link DBIntegerEnum} or {@link DBStringEnum}.
	 */
	public DBEnum() {
	}

	/**
	 * Creates a DBEnum with the value provided.
	 *
	 * <p>
	 * The resulting DBEnum will be set as having the value provided but will not
	 * be defined in the database.
	 *
	 * @param literalValue	literalValue
	 */
	protected DBEnum(BASETYPE literalValue) {
		super(literalValue);
	}

	/**
	 * Create a DBEnum with a permanent column expression.
	 *
	 * <p>
	 * Use this method within a DBRow sub-class to create a column that uses an
	 * expression to create the value at query time.
	 *
	 * <p>
	 * This is particularly useful for trimming strings or converting between
	 * types but also allows for complex arithmetic and transformations.
	 *
	 * @param columnExpression	columnExpression
	 */
	protected DBEnum(DBExpression columnExpression) {
		super(columnExpression);
	}

	/**
	 * Creates a DBEnum with the value set to the provided value.
	 *
	 * <p>
	 * Creates an undefined DBEnum object.
	 *
	 * <p>
	 * Normally used in your DBRow sub-classes as:
	 * {@code			public DBEnum<MyDBEnumValue> field = new DBEnum<MyDBEnumValue>();}
	 * Where MyDBEnumValue is a sub-class of {@link DBEnumValue} and probably a
	 * {@link DBIntegerEnum} or {@link DBStringEnum}.
	 *
	 * @param value an enumeration value.
	 */
	@SuppressWarnings("unchecked")
	public DBEnum(ENUM value) {
		this.enumType = (value == null) ? null : (Class<ENUM>) value.getClass();
		setLiteralValue(convertToLiteral(value));
	}

	/**
	 * Sets the value based on the given enumeration.
	 *
	 * @param enumValue	enumValue
	 */
	@SuppressWarnings("unchecked")
	public void setValue(ENUM enumValue) {
		this.enumType = (enumValue == null) ? null : (Class<ENUM>) enumValue.getClass();
		super.setLiteralValue(convertToLiteral(enumValue));
	}

	/**
	 * Sets the value based on the given enumeration.
	 *
	 * @param enumName the name of the enumeration value
	 */
	@SuppressWarnings("unchecked")
	public void setValueByName(String enumName) {
		setValue(ENUM.valueOf(enumType, enumName));
	}

	/**
	 * Validates whether the given type is acceptable as a literal value. Enum
	 * values with null literal values are tolerated and should not be rejected by
	 * this method. See documentation for {@link DBEnumValue#getCode()}.
	 *
	 * <p>
	 * Throw an IncompatibleClassChangeError to indicate that the literal value
	 * failed validation</p>
	 *
	 * @param enumValue non-null enum value, for which the literal value may be
	 * null
	 * @throws IncompatibleClassChangeError on incompatible types
	 */
	protected abstract void validateLiteralValue(ENUM enumValue) throws IncompatibleClassChangeError;

	/**
	 * Gets the enumeration value.
	 * <p>
	 * Converts in-line from the database's raw value to the enum type.
	 *
	 * <p style="color: #F90;">Support DBvolution at
	 * <a href="http://patreon.com/dbvolution" target=new>Patreon</a></p>
	 *
	 * @return the Enumeration instance that is appropriate to this instance
	 * @throws IllegalArgumentException if the database's raw value does not have
	 * a corresponding value in the enum
	 */
	public ENUM enumValue() {
		// get actual literal value: a String or a Long
		Object localValue = super.getValue();
		if (localValue == null) {
			return null;
		}

		// attempt conversion
		ENUM[] enumValues = getValidValues();//getEnumType().getEnumConstants();
		for (ENUM enumValue : enumValues) {
			Object enumLiteralValue = enumValue.getCode();
			if (areLiteralValuesEqual(localValue, enumLiteralValue)) {
				return enumValue;
			}
		}
		throw new IncompatibleClassChangeError("Invalid literal value [" + localValue + "] encountered"
				+ " when converting to enum type " + enumType.getName());
	}

	public ENUM[] getValidValues() {
		return getEnumType().getEnumConstants();
	}

	public List<String> getValidNames() {
		return Arrays.stream(getValidValues()).map(e -> e.name()).collect(Collectors.toList());
	}

	public List<BASETYPE> getValidCodesList() {
		final ENUM[] validValues = getValidValues();
		List<BASETYPE> validCodesList = Arrays.stream(validValues).map(v -> v.getCode()).collect(Collectors.toList());
		return validCodesList;
	}

	public BASETYPE[] getValidCodesArray() {
		List<BASETYPE> validCodesList = getValidCodesList();
		@SuppressWarnings("unchecked")
		BASETYPE[] validCodesArray = (BASETYPE[]) validCodesList.toArray();
		return validCodesArray;
	}

	public ENUM getEnumFromName(String enumName) {
		return ENUM.valueOf(enumType, enumName);
	}

	public ENUM getEnumFromCode(BASETYPE enumValue) {
		ENUM[] enums = getValidValues();
		for (ENUM enum1 : enums) {
			if (enum1.getCode() == null && enumValue == null) {
				return enum1;
			}
			if (enum1.getCode() != null && enum1.getCode().equals(enumValue)) {
				return enum1;
			}
		}
		throw new InvalidParameterException("Value '" + enumValue + "' is not a valid code for the " + getEnumType().getSimpleName() + " enumeration");
	}

	/**
	 * Tests whether two objects represent the same value. Handles subtle
	 * differences in type.
	 *
	 *
	 *
	 * <p style="color: #F90;">Support DBvolution at
	 * <a href="http://patreon.com/dbvolution" target=new>Patreon</a></p>
	 *
	 * @return {@code true} if both null or equivalent on value, {@code false} if
	 * not equal
	 * @throws IncompatibleClassChangeError if can't recognise the type
	 */
	private static boolean areLiteralValuesEqual(Object o1, Object o2) {
		if (o1 == null && o2 == null) {
			return true;
		} else if (o1 == null ^ o2 == null) {
			return false;
		}

		// handle same types and related types
		// (includes support for: String, BicDecimal, BigInteger, custom types)
		if (o1.getClass().isAssignableFrom(o2.getClass())) {
			// t2 extends t1: assume t2 knows how to compare them
			return o2.equals(o1);
		} else if (o2.getClass().isAssignableFrom(o1.getClass())) {
			// t1 extends t2: assume t1 knows how to compare them
			return o1.equals(o2);
		}

		// handle java.lang.Number variations
		// (Get the values at the greatest common precision,
		//  then compare them)
		if (o1 instanceof Number && o2 instanceof Number
				&& isRecognisedRealOrIntegerType((Number) o1)
				&& isRecognisedRealOrIntegerType((Number) o2)) {
			Number n1 = (Number) o1;
			Number n2 = (Number) o2;
			Object v1 = null; // value at greatest common precision
			Object v2 = null; // value at greatest common precision

			if (n1 instanceof Double || n2 instanceof Double) {
				v1 = n1.doubleValue();
				v2 = n2.doubleValue();
			} else if (n1 instanceof Float || n2 instanceof Float) {
				v1 = n1.floatValue();
				v2 = n2.floatValue();
			} else if (n1 instanceof Long || n2 instanceof Long) {
				v1 = n1.longValue();
				v2 = n2.longValue();
			} else if (n1 instanceof Integer || n2 instanceof Integer) {
				v1 = n1.intValue();
				v2 = n2.intValue();
			} else if (n1 instanceof Short || n2 instanceof Short) {
				v1 = n1.shortValue();
				v2 = n2.shortValue();
			}

			if (v1 != null && v2 != null) {
				return v1.equals(v2);
			}
		}

		throw new IncompatibleClassChangeError("Unable to compare " + o1.getClass().getName() + " with " + o2.getClass().getName());
	}

	/**
	 * Checks whether its one of the recognised types that can be easily converted
	 * between each other in {@link #areLiteralValuesEqual()}.
	 */
	private static boolean isRecognisedRealOrIntegerType(Number n) {
		return (n instanceof Double)
				|| (n instanceof Float)
				|| (n instanceof Short)
				|| (n instanceof Long)
				|| (n instanceof Integer);
	}

	/**
	 * Gets the declared type of enumeration that the literal value is to be
	 * mapped to. Dependent on the property wrapper being injected, or the
	 * enumType being set
	 *
	 * @return non-null enum type
	 * @throws IllegalStateException if not configured correctly
	 */
	@SuppressWarnings("unchecked")
	private Class<ENUM> getEnumType() {
		if (enumType == null) {
			PropertyWrapperDefinition<?, ?> propertyWrapper = getPropertyWrapperDefinition();
			if (propertyWrapper == null) {
				throw new IllegalStateException(
						"Unable to convert literal value to enum: enum type unable to be inferred at this point. "
						+ "Row needs to be queried from database, or value set with an actual enum.");
			}
			Class<?> type = propertyWrapper.getEnumType();
			if (type == null) {
				throw new IllegalStateException(
						"Unable to convert literal value to enum: enum type unable to be inferred at this point. "
						+ "Row needs to be queried from database, or value set with an actual enum, "
						+ "on " + propertyWrapper.qualifiedJavaName() + ".");
			}
			enumType = (Class<ENUM>) type;
		}
		return enumType;
	}

	@Override
	protected String formatValueForSQLStatement(DBDefinition db) {
		final Object databaseValue = super.getValue();
		if (databaseValue == null) {
			return db.getNull();
		} else {
			QueryableDatatype<?> qdt = QueryableDatatype.getQueryableDatatypeForObject(databaseValue);
			return qdt.formatValueForSQLStatement(db);
		}
	}

	/**
	 * Provides the literal values for all the enumeration values provided.
	 *
	 * @param enumValues	enumValues
	 * @return a list of the literal database values for the enumeration values.
	 */
	@SuppressWarnings("unchecked")
	protected BASETYPE[] convertToLiteral(ENUM... enumValues) {
		// Use Array native method to create array
		// of a type only known at run time
		if (enumValues.length == 0) {
			return (BASETYPE[]) new Object[]{};
		} else {
			int index = 0;
			ENUM firstValue = null;
			while (firstValue == null && index < enumValues.length) {
				firstValue = enumValues[index];
				index++;
			}
			if (enumType == null && firstValue == null) {
				return (BASETYPE[]) new Object[]{};
			} else {
				Class<?> baseType = convertToLiteral(firstValue).getClass();
				enumType = (Class<ENUM>) baseType;
				final BASETYPE[] result = (BASETYPE[]) Array.newInstance(enumType, enumValues.length);
				for (int i = 0; i < enumValues.length; i++) {
					ENUM enumValue = enumValues[i];
					result[i] = convertToLiteral(enumValue);
				}
				return result;
			}
		}
	}

	/**
	 * Provides the value for the enumeration value provided.
	 *
	 * @param enumValue	enumValue
	 * @return the literal database value for the enumeration value.
	 */
	protected final BASETYPE convertToLiteral(ENUM enumValue) {
		if (enumValue == null || enumValue.getCode() == null) {
			return null;
		} else {
			validateLiteralValue(enumValue);
			BASETYPE newLiteralValue = enumValue.getCode();
			return newLiteralValue;
		}
	}

	@Override
	public boolean isAggregator() {
		return false;
	}

	/**
	 *
	 * reduces the rows to only the object, Set, List, Array, or vararg of objects
	 *
	 * @param permitted	permitted
	 */
	@SafeVarargs
	public final void permittedValues(BASETYPE... permitted) {
		this.setOperator(new DBPermittedValuesOperator<BASETYPE>(permitted));
	}

	/**
	 *
	 * reduces the rows to only the object, Set, List, Array, or vararg of objects
	 *
	 * @param permitted	permitted
	 */
	@SafeVarargs
	public final void permittedValues(ENUM... permitted) {
		this.setOperator(new DBPermittedValuesOperator<BASETYPE>(convertToLiteral(permitted)));
	}

	/**
	 * Reduces the rows returned from a query by excluding those matching the
	 * provided objects.
	 *
	 * <p>
	 * The case, upper or lower, will be ignored.
	 *
	 * <p>
	 * Defining case for Unicode characters is complicated and may not work as
	 * expected.
	 *
	 * @param excluded	excluded
	 */
	@SafeVarargs
	public final void excludedValues(BASETYPE... excluded) {
		this.setOperator(new DBPermittedValuesOperator<BASETYPE>(excluded));
		negateOperator();
	}

	/**
	 * Reduces the rows returned from a query by excluding those matching the
	 * provided objects.
	 *
	 * <p>
	 * For Strings, the case, upper or lower, will be ignored.</p>
	 *
	 * @param excluded	excluded
	 */
	@SafeVarargs
	public final void excludedValues(ENUM... excluded) {
		this.setOperator(new DBPermittedValuesOperator<BASETYPE>(convertToLiteral(excluded)));
		negateOperator();
	}
}