ForeignKeyHandler.java

package nz.co.gregs.dbvolution.internal.properties;

import java.io.Serializable;
import java.util.HashMap;
import java.util.Map;

import nz.co.gregs.dbvolution.DBRow;
import nz.co.gregs.dbvolution.annotations.DBColumn;
import nz.co.gregs.dbvolution.annotations.DBForeignKey;
import nz.co.gregs.dbvolution.exceptions.DBRuntimeException;
import nz.co.gregs.dbvolution.exceptions.ReferenceToUndefinedPrimaryKeyException;
import nz.co.gregs.dbvolution.exceptions.UnableToInterpolateReferencedColumnInMultiColumnPrimaryKeyException;
import nz.co.gregs.dbvolution.query.RowDefinition;
import org.simmetrics.StringMetric;
import org.simmetrics.metrics.DamerauLevenshtein;
import org.simmetrics.simplifiers.Simplifiers;
import static org.simmetrics.builders.StringMetricBuilder.with;

/**
 * Handles annotation processing, business logic, validation rules, defaulting,
 * and error handling associated with determining the table/class and column
 * referenced by this property. This includes handling the {@link DBForeignKey}
 * annotation on a class.
 *
 * <p>
 * This class behaves correctly when no {@link DBForeignKey} annotation is
 * present.
 *
 * @author Malcolm Lett
 */
class ForeignKeyHandler<FOREIGNROW extends DBRow,FOREIGNBASETYPE> implements Serializable {

	private static final long serialVersionUID = 1l;

	private final boolean identityOnly;
	private final Class<FOREIGNROW> referencedClass;
	private final PropertyWrapperDefinition<FOREIGNROW, FOREIGNBASETYPE> identityOnlyReferencedProperty; // stores identity info only
	private transient final DBForeignKey foreignKeyAnnotation; // null if not present on property
	private final ColumnHandler<FOREIGNBASETYPE> originalColumn;

	@SuppressWarnings("unchecked")
	ForeignKeyHandler(JavaProperty<FOREIGNBASETYPE> adaptee, boolean processIdentityOnly) {
		this.originalColumn = new ColumnHandler<>(adaptee);
		if (processIdentityOnly) {
			// skip processing of foreign keys
			this.foreignKeyAnnotation = null;
			this.identityOnly = true;
		} else {
			this.foreignKeyAnnotation = adaptee.getAnnotation(DBForeignKey.class);
			this.identityOnly = false;
		}

		// pre-calculate referenced class
		if (foreignKeyAnnotation != null) {
			this.referencedClass = (Class<FOREIGNROW>) foreignKeyAnnotation.value();
		} else {
			this.referencedClass = null;
		}

		// pre-calculate declared referenced column
		String declaredReferencedColumnName = null;
		if (foreignKeyAnnotation != null) {
			if (foreignKeyAnnotation.column() != null && foreignKeyAnnotation.column().trim().length() > 0) {
				declaredReferencedColumnName = foreignKeyAnnotation.column();
			}
		}

		// pre-calculate referenced property
		// (from annotations etc. on referenced class)
		PropertyWrapperDefinition<?,?> identifiedReferencedProperty = null;
		if (referencedClass != null) {
			RowDefinitionClassWrapper<?> referencedClassWrapper = new RowDefinitionClassWrapper<>(referencedClass, true);

			// validate: referenced class is valid enough for the purposes of doing queries on this class
			if (!referencedClassWrapper.isTable()) {
				throw new ReferenceToUndefinedPrimaryKeyException(adaptee.qualifiedName() + " is a foreign key to class " + referencedClassWrapper.javaName()
						+ ", which is not a table");
			}
			if (referencedClassWrapper.tableName() == null) {
				// not expected
				throw new DBRuntimeException(adaptee.qualifiedName() + " is a foreign key to class " + referencedClassWrapper.javaName()
						+ ", which is a table but doesn't have a table name (this is probably a bug in DBvolution)");
			}

			// validate: explicitly declared column name exists on referenced table
			if (declaredReferencedColumnName != null) {
				var properties = referencedClassWrapper.getPropertyDefinitionIdentitiesByColumnNameCaseInsensitive(declaredReferencedColumnName);
				if (properties.size() > 1) {
					throw new ReferenceToUndefinedPrimaryKeyException(adaptee.qualifiedName() + " references column " + declaredReferencedColumnName
							+ ", however there are " + properties.size() + " such properties in " + referencedClassWrapper.javaName() + ".");
				}
				if (properties.isEmpty()) {
					throw new ReferenceToUndefinedPrimaryKeyException("Property " + adaptee.qualifiedName() + " references class " + referencedClassWrapper.javaName()
							+ " and column " + declaredReferencedColumnName + ", but the column doesn't exist");
				}

				// get explicitly identified property
				identifiedReferencedProperty = properties.get(0);
			}

			// validate: referenced class has single primary key when implicitly referencing primary key column
			if (declaredReferencedColumnName == null) {
				PropertyWrapperDefinition<?,?>[] primaryKeys = referencedClassWrapper.primaryKeyDefinitions();
				if (primaryKeys == null || primaryKeys.length == 0) {
					throw new ReferenceToUndefinedPrimaryKeyException(adaptee, referencedClassWrapper);
				} else if (primaryKeys.length > 1) {
					final String columnName = originalColumn.getColumnName();
					StringMetric metric = with(new DamerauLevenshtein())
							.simplify(Simplifiers.replaceNonWord())
							.simplify(Simplifiers.toLowerCase())
							.build();
					Map<Float, PropertyWrapperDefinition<?,?>> pkComps = new HashMap<>();
//					Map<PropertyWrapperDefinition, Float> pkMetrics = new HashMap<>();
					Float maxComp = 0.0F;

					for (PropertyWrapperDefinition<?,?> primaryKey : primaryKeys) {
						final String pkName = primaryKey.getColumnName();
						float result = metric.compare(columnName, pkName);
						pkComps.put(result, primaryKey);
//						pkMetrics.put(primaryKey,result);
						maxComp = maxComp > result ? maxComp : result;
					}
					if (maxComp <= 0.15F) {
						throw new UnableToInterpolateReferencedColumnInMultiColumnPrimaryKeyException(adaptee, referencedClassWrapper, primaryKeys);
					} else {
						identifiedReferencedProperty = pkComps.get(maxComp);
					}
				} else {
					identifiedReferencedProperty = primaryKeys[0];
				}
			}
		}
		this.identityOnlyReferencedProperty = (PropertyWrapperDefinition<FOREIGNROW, FOREIGNBASETYPE>) identifiedReferencedProperty;
	}

	/**
	 * Indicates whether this property references another class/table.
	 *
	 * <p style="color: #F90;">Support DBvolution at
	 * <a href="http://patreon.com/dbvolution" target=new>Patreon</a></p>
	 *
	 * @return TRUE/FALSE
	 */
	public boolean isForeignKey() {
		if (identityOnly) {
			throw new AssertionError("Attempt to access non-identity information of identity-only foreign key handler");
		}
		return referencedClass != null;
	}

	/**
	 * Gets the class referenced by this foreign key.
	 *
	 * <p style="color: #F90;">Support DBvolution at
	 * <a href="http://patreon.com/dbvolution" target=new>Patreon</a></p>
	 *
	 * @return the referenced class if this property is a foreign key; null if not
	 * a foreign key
	 */
	public Class<FOREIGNROW> getReferencedClass() {
		if (identityOnly) {
			throw new AssertionError("Attempt to access non-identity information of identity-only foreign key handler");
		}
		return referencedClass;
	}

	/**
	 * Gets the name of the referenced table.
	 *
	 * <p style="color: #F90;">Support DBvolution at
	 * <a href="http://patreon.com/dbvolution" target=new>Patreon</a></p>
	 *
	 * @return the referenced table name if this property is a foreign key; null
	 * if not a foreign key
	 */
	public String getReferencedTableName() {
		if (identityOnly) {
			throw new AssertionError("Attempt to access non-identity information of identity-only foreign key handler");
		}
		return (identityOnlyReferencedProperty == null) ? null : identityOnlyReferencedProperty.tableName();
	}

	/**
	 * Gets the name of the referenced column in the referenced table. The
	 * referenced column is either explicitly indicated by use of the
	 * {@link DBForeignKey#column()} attribute, or it is implicitly the single
	 * primary key of the referenced table.
	 *
	 * <p style="color: #F90;">Support DBvolution at
	 * <a href="http://patreon.com/dbvolution" target=new>Patreon</a></p>
	 *
	 * @return the referenced column name if this property is a foreign key; null
	 * if not a foreign key
	 */
	public String getReferencedColumnName() {
		if (identityOnly) {
			throw new AssertionError("Attempt to access non-identity information of identity-only foreign key handler");
		}
		return (identityOnlyReferencedProperty == null) ? null : identityOnlyReferencedProperty.getColumnName();
	}

	/**
	 * Gets identity information for the referenced property in the referenced
	 * table. The referenced property is either explicitly indicated by use of the
	 * {@link DBForeignKey#column()} attribute, or it is implicitly the single
	 * primary key of the referenced table.
	 *
	 * <p>
	 * Note that the property definition returned provides identity of the
	 * property only. It provides access to the property's java name, column name,
	 * type, and identity information about the table it belongs to (ie the table
	 * name). Attempts to get or set its value or get the type adaptor instance
	 * will result in an internal exception.
	 *
	 * <p style="color: #F90;">Support DBvolution at
	 * <a href="http://patreon.com/dbvolution" target=new>Patreon</a></p>
	 *
	 * @return the referenced property if this property is a foreign key; null if
	 * not a foreign key
	 */
	public PropertyWrapperDefinition<?,?> getReferencedPropertyDefinitionIdentity() {
		if (identityOnly) {
			throw new AssertionError("Attempt to access non-identity information of identity-only foreign key handler");
		}
		return identityOnlyReferencedProperty;
	}

	/**
	 * Gets the {@link DBColumn} annotation on the class, if it exists.
	 *
	 * <p style="color: #F90;">Support DBvolution at
	 * <a href="http://patreon.com/dbvolution" target=new>Patreon</a></p>
	 *
	 * @return the annotation or null if it is not present
	 */
	public DBForeignKey getDBForeignKeyAnnotation() {
		if (identityOnly) {
			throw new AssertionError("Attempt to access non-identity information of identity-only foreign key handler");
		}
		return foreignKeyAnnotation;
	}

	boolean isForeignKeyTo(Class<? extends RowDefinition> aClass) {
		final Class<FOREIGNROW> reffedClass = getReferencedClass();
		final boolean assignableFrom = reffedClass.isAssignableFrom(aClass);
		return assignableFrom;
	}

	boolean isForeignKeyRecursive() {
		final Class<FOREIGNROW> reffedClass = getReferencedClass();
		final Class<? extends RowDefinition> originalReferencedClass = identityOnlyReferencedProperty.referencedClass();
		if (reffedClass != null && originalReferencedClass != null) {
			final boolean assignableFrom = reffedClass.isAssignableFrom(originalReferencedClass);
			return assignableFrom;
		}
		return false;
	}
}