import org.apache.commons.lang3.StringUtils;
import org.springframework.data.util.Pair;
import org.springframework.lang.Nullable;
import com.broadleafcommerce.catalog.domain.product.DefaultProductType;
import com.broadleafcommerce.catalog.domain.product.Product;
import com.broadleafcommerce.catalog.help.UrlUtils;
import com.broadleafcommerce.common.dataimport.conversion.AbstractRowConverter;
import com.broadleafcommerce.common.dataimport.messaging.BatchRecord;
import com.broadleafcommerce.common.dataimport.messaging.BatchRequest;
import com.broadleafcommerce.common.dataimport.util.ConversionUtils;
import com.broadleafcommerce.common.dataimport.util.IdResolver;
import com.broadleafcommerce.common.dataimport.util.RowUtils;
import com.broadleafcommerce.common.extension.TypeFactory;
import com.broadleafcommerce.data.tracking.core.type.OperationType;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.time.Instant;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
public class TutorialProductRowConverter
extends AbstractRowConverter<ConversionUtils.ConversionResponse<Product>> {
public static final String PRODUCT_ROW_TYPE = "PRODUCT";
/**
* In {@link BatchRequest.BatchContext#getAdditionalContextMap()}, we expect a nested map under
* this key to contain prefetched products by their external ID.
*/
public static final String BATCH_CONTEXT_PREFETCHED_PRODUCTS_BY_EXTERNAL_ID_MAP_KEY =
"BATCH_CONTEXT_PREFETCHED_PRODUCTS_BY_EXTERNAL_ID_MAP";
private final TypeFactory typeFactory;
private final ObjectMapper objectMapper;
private final IdResolver idResolver;
public TutorialProductRowConverter(TypeFactory typeFactory,
ObjectMapper objectMapper,
IdResolver idResolver) {
this.typeFactory = typeFactory;
this.objectMapper = objectMapper;
this.idResolver = idResolver;
}
@Override
public boolean canConvert(BatchRecord record, @Nullable BatchRequest.BatchContext context) {
return PRODUCT_ROW_TYPE.equals(record.getRecordType());
}
@Override
public ConversionUtils.ConversionResponse<Product> convert(
// Parent is irrelevant in this context
@Nullable Object parent,
BatchRecord record,
@Nullable BatchRequest.BatchContext context) {
try {
if (!canConvert(record, context)) {
throw new IllegalArgumentException(String.format(
"Could not convert a record with row type %s", record.getRecordType()));
}
Pair<Boolean, Product> productResolutionResult =
instantiateOrGetPrefetchedProduct(record, context);
boolean productAlreadyExistsInDatastore = productResolutionResult.getFirst();
Product product = productResolutionResult.getSecond();
final OperationType operation = determineOperationType(product,
productAlreadyExistsInDatastore, record, context);
initializeData(product, record, operation, context);
return ConversionUtils.ConversionResponse.success(product, operation);
} catch (Exception e) {
return ConversionUtils.ConversionResponse
.error(StringUtils.defaultString(e.getMessage()));
}
}
/**
* We expect the {@link TutorialProductImportBatchHandler} to have pre-fetched products by
* identifier fields in the original rows. Any products that were found in the datastore should
* have been populated into {@link BatchRequest.BatchContext#getAdditionalContextMap()}.
* <p>
* If a product is present in this map, we will use this existing instance.
* <p>
* If a product is not present in this map, we will simply instantiate a new instance.
*
* @param batchRecord the original {@link BatchRecord}
* @param context the {@link BatchRequest.BatchContext}, which should have any pre-fetched
* products in {@link BatchRequest.BatchContext#getAdditionalContextMap()}
* @return a {@link Pair} containing a boolean describing whether the product was found in the
* pre-fetched map, as well as the product instance itself
*/
protected Pair<Boolean, Product> instantiateOrGetPrefetchedProduct(
@lombok.NonNull BatchRecord batchRecord,
@Nullable BatchRequest.BatchContext context) {
Product preFetchedProduct = getPrefetchedProduct(batchRecord, context);
if (preFetchedProduct != null) {
return Pair.of(true, preFetchedProduct);
}
Product newlyInstantiatedProduct = typeFactory.get(Product.class);
return Pair.of(false, newlyInstantiatedProduct);
}
/**
* Checks the {@link BatchRequest.BatchContext#getAdditionalContextMap()} to see if there is
* already a pre-fetched product matching the {@code batchRecord}.
*
* @param batchRecord the batch record for which to find the corresponding pre-fetched product
* instance (if exists)
* @param context the batch context which should contain pre-fetched products set by
* {@link TutorialProductImportBatchHandler}
* @return the pre-fetched product instance if found, or {@code null}
*/
@Nullable
protected Product getPrefetchedProduct(BatchRecord batchRecord,
@Nullable BatchRequest.BatchContext context) {
Map<String, Object> additionalContextMap = Optional.ofNullable(context)
.map(BatchRequest.BatchContext::getAdditionalContextMap)
.orElse(null);
if (additionalContextMap == null) {
return null;
}
return Optional.ofNullable(batchRecord.getRow().get("externalId"))
.filter(StringUtils::isNotBlank)
.flatMap(providedExternalId -> Optional.ofNullable(
additionalContextMap.get(
BATCH_CONTEXT_PREFETCHED_PRODUCTS_BY_EXTERNAL_ID_MAP_KEY))
.map(prefetchedProductsMap -> (Map<String, Product>) prefetchedProductsMap)
.map(prefetchedProductsMap -> prefetchedProductsMap
.get(providedExternalId)))
.orElse(null);
}
/**
* Determine the effective operation type for the given batch record and product instance. This
* can help inform decisions on how to instantiate/map fields.
*
* @param product the resolved product instance
* @param productAlreadyExistsInDatastore whether the product was determined to already exist in
* the datastore. See
* {@link #instantiateOrGetPrefetchedProduct(BatchRecord, BatchRequest.BatchContext)}
* @param record the original batch record
* @param context the batch context
* @return the effective operation type for the given batch record
*/
protected OperationType determineOperationType(@lombok.NonNull Product product,
boolean productAlreadyExistsInDatastore,
@lombok.NonNull BatchRecord record,
@Nullable BatchRequest.BatchContext context) {
com.broadleafcommerce.common.dataimport.messaging.OperationType requestedOperation =
record.getOperation();
if (requestedOperation == null) {
// Nothing was explicitly requested, so determine what to do based on entity existence
if (productAlreadyExistsInDatastore) {
return OperationType.UPDATE;
} else {
return OperationType.CREATE;
}
}
if (productAlreadyExistsInDatastore &&
com.broadleafcommerce.common.dataimport.messaging.OperationType.CREATE
.equals(requestedOperation)) {
/*
* The file requested a create, but we found that this entity already exists in the data
* store. We will implicitly understand this to actually be an update operation.
*
* For example, this helps us gracefully handle situations like import retries, where
* the same file is imported multiple times. The first attempt may be partially
* successful in creating new records, and we don't want to force users to manually edit
* the file to change the operation type on those rows before submitting their next
* attempt.
*/
return OperationType.UPDATE;
}
return requestedOperation.toTrackingOperation();
}
/**
* Initialize the data from the row onto the product. Set additional data as needed. For
* example, set ID and activeStartDate.
* <p>
* This uses {@link RowUtils#copyRecordToEntity(Map, ObjectMapper, Object, boolean)}, which
* reflectively sets values on the target instance.
*
* @param product the target product onto which we set data.
* @param record the original BatchRecord
* @param operation the operation; typically CREATE or UPDATE
* @param context the BatchContext
*/
protected void initializeData(@lombok.NonNull final Product product,
@lombok.NonNull final BatchRecord record,
@lombok.NonNull OperationType operation,
@Nullable final BatchRequest.BatchContext context) {
if (OperationType.CREATE.equals(operation)) {
/*
* If a value for a particular field is not provided in the row, it will be 'null'.
* Since this is a 'create', we don't want the mapping process to overwrite defaulted
* values to 'null'. Thus, we force null values to be ignored when mapping.
*/
RowUtils.copyRecordToEntity(record.getRow(), objectMapper, product, true);
// Set values for items we don't expect in input but are required for creation
if (product.getActiveStartDate() == null) {
product.setActiveStartDate(Instant.now());
}
product.setProductType(DefaultProductType.STANDARD.name());
product.setUri(UrlUtils.generateUrl(product.getName()));
} else {
/*
* If a value for a particular header is not provided in the row, it will be 'null'.
* Since this is an 'update', the final update semantics will skip updating fields that
* are explicitly provided as 'null' on the instance. Thus, we want null values in the
* row to be force-set to null on the instance as well, which will ensure they're
* ignored.
*
* This ultimately doesn't have strong consequences, since in some cases certain headers
* may be omitted from the file altogether, and in that situation not even the row has a
* 'null' value for it. When this happens, the instance will just retain whatever
* existing value it had.
*/
RowUtils.copyRecordToEntity(record.getRow(), objectMapper, product, false);
}
final String id = resolveProductId(product, record, operation, context);
product.setId(id);
}
protected String resolveProductId(@lombok.NonNull Product product,
@lombok.NonNull BatchRecord record,
@lombok.NonNull OperationType operation,
@Nullable BatchRequest.BatchContext context) {
final String suppliedId;
if (StringUtils.isNotBlank(product.getId())) {
suppliedId = product.getId();
} else {
suppliedId = record.getResourceTierIdentifier();
}
return Objects.requireNonNull(idResolver.resolveId(record.getRecordType(),
com.broadleafcommerce.common.dataimport.messaging.OperationType
.valueOf(operation.name()),
suppliedId, record.getRow()));
}
}