BaseImporter.java

package org.dynamoframework.importer.impl;

/*-
 * #%L
 * Dynamo Framework
 * %%
 * Copyright (C) 2014 - 2024 Open Circle Solutions
 * %%
 * 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.
 * #L%
 */

import org.apache.commons.lang3.StringUtils;
import org.dynamoframework.exception.OCSImportException;
import org.dynamoframework.importer.ImportField;
import org.dynamoframework.importer.dto.AbstractDTO;
import org.dynamoframework.utils.ClassUtils;
import org.dynamoframework.utils.NumberUtils;
import org.springframework.beans.BeanUtils;

import java.beans.PropertyDescriptor;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;

import static java.lang.String.format;

/**
 * Base class for smart upload functionality
 *
 * @param <R> the type of a single row
 * @param <U> the type of a single cell or field
 * @author bas.rutten
 */
public abstract class BaseImporter<R, U> {

	private static final double PERCENTAGE_FACTOR = 100.;

	/**
	 * Counts the number of rows in the input. This method will count all rows,
	 * including the header, and will not check if any of the rows are valid
	 *
	 * @param bytes      the byte representation of the input file
	 * @param sheetIndex the index of the sheet (if appropriate)
	 * @return the number of rows
	 */
	public abstract int countRows(byte[] bytes, int sheetIndex);

	/**
	 * Retrieves a boolean value from the input and falls back to a default if the
	 * value is empty or not defined
	 *
	 * @param unit  the unit of data value to process (string, Excel cell etc)
	 * @param field the field definition
	 * @return the Boolean value
	 */
	protected abstract Boolean getBooleanValueWithDefault(U unit, ImportField field);

	/**
	 * Retrieves a date value from the input and falls back to a default if the
	 * value is empty or not defined
	 *
	 * @param unit  the unit of data to process
	 * @param field the field definition
	 * @return the LocalDate value
	 */
	protected abstract LocalDate getDateValueWithDefault(U unit, ImportField field);

	/**
	 * Retrieves a value from a unit of data
	 *
	 * @param d     the property descriptor that tells the process the type of the
	 *              value to retrieve
	 * @param unit  the unit of data to process
	 * @param field the field definition
	 * @return the field value
	 */
	@SuppressWarnings("unchecked")
	protected Object getFieldValue(PropertyDescriptor d, U unit, ImportField field) {
		Object obj = null;
		if (String.class.equals(d.getPropertyType())) {
			String value = getStringValueWithDefault(unit, field);
			if (value != null) {
				value = value.trim();
			}
			obj = StringUtils.isEmpty(value) ? null : value;
		} else if (d.getPropertyType().isEnum()) {
			String value = getStringValueWithDefault(unit, field);
			if (value != null) {
				value = value.trim();
				try {
					@SuppressWarnings("rawtypes")
					Class<? extends Enum> enumType = d.getPropertyType().asSubclass(Enum.class);
					obj = Enum.valueOf(enumType, value.toUpperCase());
				} catch (IllegalArgumentException ex) {
					throw new OCSImportException("Value " + value + " cannot be translated to an enumeration value",
						ex);
				}
			}
		} else if (isNumeric(d.getPropertyType())) {
			// numeric field
			Double value = getNumericValueWithDefault(unit, field);
			if (value != null) {

				// if the field represents a percentage but it is
				// received as a
				// fraction, we multiply it by 100
				if (field.percentage() && isPercentageCorrectionSupported()) {
					value = PERCENTAGE_FACTOR * value;
				}

				// illegal negative value
				if (field.cannotBeNegative() && value < 0.0) {
					throw new OCSImportException("Negative value " + value + " found for field '" + d.getName() + "'");
				}

				// round to the nearest integer, then use intValue() or longValue()
				BigDecimal rounded = BigDecimal.valueOf(value).setScale(0, RoundingMode.HALF_UP);
				Class<?> pType = d.getPropertyType();
				if (NumberUtils.isInteger(pType)) {
					obj = rounded.intValue();
				} else if (NumberUtils.isLong(pType)) {
					obj = rounded.longValue();
				} else if (NumberUtils.isFloat(pType)) {
					obj = value.floatValue();
				} else if (NumberUtils.isDouble(pType)) {
					obj = value;
				} else if (BigDecimal.class.equals(pType)) {
					obj = BigDecimal.valueOf(value);
				}
			}
		} else if (Boolean.class.isAssignableFrom(d.getPropertyType())) {
			obj = getBooleanValueWithDefault(unit, field);
		} else if (LocalDate.class.isAssignableFrom(d.getPropertyType())) {
			obj = getDateValueWithDefault(unit, field);
		}
		return obj;
	}

	/**
	 * Retrieves a numeric value from an input unit and falls back to a default if
	 * the value is empty or not defined
	 *
	 * @param unit  the input unit
	 * @param field the field definition
	 * @return the Double value
	 */
	protected abstract Double getNumericValueWithDefault(U unit, ImportField field);

	/**
	 * Retrieves a string from the input and falls back to a default if the value is
	 * empty or not defined
	 *
	 * @param unit  the input unit
	 * @param field the field definition
	 * @return the String value
	 */
	protected abstract String getStringValueWithDefault(U unit, ImportField field);

	/**
	 * Retrieves a unit (a single cell or field) from a row
	 *
	 * @param row   the row
	 * @param field the field definition
	 * @return the retrieved unit
	 */
	protected abstract U getUnit(R row, ImportField field);

	/**
	 * Check if the class is a numeric class
	 *
	 * @param clazz the class to check
	 * @return true if the value is numeric, false otherwise
	 */
	private boolean isNumeric(Class<?> clazz) {
		return Number.class.isAssignableFrom(clazz) || int.class.equals(clazz) || long.class.equals(clazz)
			|| double.class.equals(clazz) || float.class.equals(clazz);
	}

	/**
	 * Indicates whether fraction values are automatically converted to percentages
	 *
	 * @return true if this is the case, false otherwise
	 */
	public abstract boolean isPercentageCorrectionSupported();

	/**
	 * Checks whether a field index is within the range of available columns
	 *
	 * @param row   the row to check
	 * @param field the field definition
	 * @return true if this is the case, false otherwise
	 */
	protected abstract boolean isWithinRange(R row, ImportField field);

	/**
	 * Processes a single row from the input and turns it into an object
	 *
	 * @param rowNum the row number
	 * @param row    the row
	 * @param clazz  the class of the object that must be created
	 * @return
	 */
	public <T extends AbstractDTO> T processRow(int rowNum, R row, Class<T> clazz) {
		T dto = ClassUtils.instantiateClass(clazz);
		dto.setRowNum(rowNum);

		PropertyDescriptor[] descriptors = BeanUtils.getPropertyDescriptors(clazz);
		for (PropertyDescriptor descriptor : descriptors) {
			ImportField field = ClassUtils.getAnnotation(clazz, descriptor.getName(), ImportField.class);
			if (field != null) {
				if (isWithinRange(row, field)) {
					U unit = getUnit(row, field);

					Object obj = getFieldValue(descriptor, unit, field);
					if (obj != null) {
						ClassUtils.setFieldValue(dto, descriptor.getName(), obj);
					} else if (field.required()) {
						// a required value is missing!
						throw new OCSImportException(
							format("Required value for field '%s' is missing", descriptor.getName()));
					}
				} else {
					throw new OCSImportException(format("Row %d doesn't have enough columns", rowNum));
				}
			}
		}
		return dto;
	}
}