queried = CTP_TARGET_CLIENT
+ .execute(CustomerQuery.of()
+ .withPredicates(QueryPredicate.of(format("key = \"%s\"", EXISTING_CUSTOMER_KEY))))
+ .toCompletableFuture().join().head();
+
+ assertThat(errorCallBackExceptions).isEmpty();
+ assertThat(errorCallBackMessages).isEmpty();
+ assertThat(queried).isNotEmpty();
+ final Customer fetchedCustomer = queried.get();
+ assertThat(fetchedCustomer.getEmail()).isEqualTo(updatedCustomer.getEmail());
+ assertThat(fetchedCustomer.getPassword()).isEqualTo(updatedCustomer.getPassword());
+
+ }
+
+}
diff --git a/src/main/java/com/commercetools/sync/cartdiscounts/helpers/CartDiscountSyncStatistics.java b/src/main/java/com/commercetools/sync/cartdiscounts/helpers/CartDiscountSyncStatistics.java
index 5593ff6d6c..3b8f7be625 100644
--- a/src/main/java/com/commercetools/sync/cartdiscounts/helpers/CartDiscountSyncStatistics.java
+++ b/src/main/java/com/commercetools/sync/cartdiscounts/helpers/CartDiscountSyncStatistics.java
@@ -2,8 +2,6 @@
import com.commercetools.sync.commons.helpers.BaseSyncStatistics;
-import static java.lang.String.format;
-
public final class CartDiscountSyncStatistics extends BaseSyncStatistics {
/**
@@ -15,10 +13,6 @@ public final class CartDiscountSyncStatistics extends BaseSyncStatistics {
*/
@Override
public String getReportMessage() {
- reportMessage = format(
- "Summary: %s cart discounts were processed in total (%s created, %s updated and %s failed to sync).",
- getProcessed(), getCreated(), getUpdated(), getFailed());
-
- return reportMessage;
+ return getDefaultReportMessageForResource("cart discounts");
}
}
diff --git a/src/main/java/com/commercetools/sync/categories/helpers/CategorySyncStatistics.java b/src/main/java/com/commercetools/sync/categories/helpers/CategorySyncStatistics.java
index 0c8c8087bc..58fa863b38 100644
--- a/src/main/java/com/commercetools/sync/categories/helpers/CategorySyncStatistics.java
+++ b/src/main/java/com/commercetools/sync/categories/helpers/CategorySyncStatistics.java
@@ -47,10 +47,9 @@ public CategorySyncStatistics() {
*/
@Override
public String getReportMessage() {
- reportMessage = format("Summary: %s categories were processed in total "
+ return format("Summary: %s categories were processed in total "
+ "(%s created, %s updated, %s failed to sync and %s categories with a missing parent).",
getProcessed(), getCreated(), getUpdated(), getFailed(), getNumberOfCategoriesWithMissingParents());
- return reportMessage;
}
/**
diff --git a/src/main/java/com/commercetools/sync/commons/helpers/BaseSyncStatistics.java b/src/main/java/com/commercetools/sync/commons/helpers/BaseSyncStatistics.java
index 03592712af..94828371aa 100644
--- a/src/main/java/com/commercetools/sync/commons/helpers/BaseSyncStatistics.java
+++ b/src/main/java/com/commercetools/sync/commons/helpers/BaseSyncStatistics.java
@@ -2,13 +2,13 @@
import io.netty.util.internal.StringUtil;
+import javax.annotation.Nonnull;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import static java.lang.String.format;
public abstract class BaseSyncStatistics {
- protected String reportMessage;
private AtomicInteger updated;
private AtomicInteger created;
private AtomicInteger failed;
@@ -31,7 +31,6 @@ public BaseSyncStatistics() {
created = new AtomicInteger();
failed = new AtomicInteger();
processed = new AtomicInteger();
- reportMessage = StringUtil.EMPTY_STRING;
latestBatchHumanReadableProcessingTime = StringUtil.EMPTY_STRING;
}
@@ -285,4 +284,18 @@ public long getLatestBatchProcessingTimeInMillis() {
* @return a summary message of the statistics report.
*/
public abstract String getReportMessage();
+
+ /**
+ * Builds a proper summary message of the statistics report of a given {@code resourceString} in following format:
+ *
+ * "Summary: 2 resources were processed in total (2 created, 2 updated and 0 failed to sync)."
+ *
+ * @param resourceString a string representing the resource
+ * @return a summary message of the statistics report
+ */
+ protected String getDefaultReportMessageForResource(@Nonnull final String resourceString) {
+ return format(
+ "Summary: %s %s were processed in total (%s created, %s updated and %s failed to sync).",
+ getProcessed(), resourceString, getCreated(), getUpdated(), getFailed());
+ }
}
diff --git a/src/main/java/com/commercetools/sync/customers/CustomerSync.java b/src/main/java/com/commercetools/sync/customers/CustomerSync.java
new file mode 100644
index 0000000000..8cf532b638
--- /dev/null
+++ b/src/main/java/com/commercetools/sync/customers/CustomerSync.java
@@ -0,0 +1,290 @@
+package com.commercetools.sync.customers;
+
+import com.commercetools.sync.commons.BaseSync;
+import com.commercetools.sync.commons.exceptions.SyncException;
+import com.commercetools.sync.customers.helpers.CustomerBatchValidator;
+import com.commercetools.sync.customers.helpers.CustomerReferenceResolver;
+import com.commercetools.sync.customers.helpers.CustomerSyncStatistics;
+import com.commercetools.sync.services.CustomerGroupService;
+import com.commercetools.sync.services.CustomerService;
+import com.commercetools.sync.services.TypeService;
+import com.commercetools.sync.services.impl.CustomerGroupServiceImpl;
+import com.commercetools.sync.services.impl.CustomerServiceImpl;
+import com.commercetools.sync.services.impl.TypeServiceImpl;
+import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
+import io.sphere.sdk.commands.UpdateAction;
+import io.sphere.sdk.customers.Customer;
+import io.sphere.sdk.customers.CustomerDraft;
+import org.apache.commons.lang3.tuple.ImmutablePair;
+
+import javax.annotation.Nonnull;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionStage;
+
+import static com.commercetools.sync.commons.utils.SyncUtils.batchElements;
+import static com.commercetools.sync.customers.utils.CustomerSyncUtils.buildActions;
+import static java.lang.String.format;
+import static java.util.concurrent.CompletableFuture.completedFuture;
+import static java.util.function.Function.identity;
+import static java.util.stream.Collectors.toMap;
+import static java.util.stream.Collectors.toSet;
+
+/**
+ * This class syncs customer drafts with the corresponding customers in the CTP project.
+ */
+public class CustomerSync extends BaseSync {
+
+ private static final String CTP_CUSTOMER_FETCH_FAILED = "Failed to fetch existing customers with keys: '%s'.";
+ private static final String FAILED_TO_PROCESS = "Failed to process the CustomerDraft with key:'%s'. Reason: %s";
+ private static final String CTP_CUSTOMER_UPDATE_FAILED = "Failed to update customer with key: '%s'. Reason: %s";
+
+ private final CustomerService customerService;
+ private final CustomerReferenceResolver referenceResolver;
+ private final CustomerBatchValidator batchValidator;
+
+ /**
+ * Takes a {@link CustomerSyncOptions} to instantiate a new {@link CustomerSync} instance that could be used to
+ * sync customer drafts in the CTP project specified in the injected {@link CustomerSync} instance.
+ *
+ * @param customerSyncOptions the container of all the options of the sync process including the CTP project
+ * client and/or configuration and other sync-specific options.
+ */
+ public CustomerSync(@Nonnull final CustomerSyncOptions customerSyncOptions) {
+ this(customerSyncOptions,
+ new CustomerServiceImpl(customerSyncOptions),
+ new TypeServiceImpl(customerSyncOptions),
+ new CustomerGroupServiceImpl(customerSyncOptions));
+ }
+
+ /**
+ * Takes a {@link CustomerSyncOptions} and service instances to instantiate a new {@link CustomerSync} instance
+ * that could be used to sync customer drafts in the CTP project specified in the injected
+ * {@link CustomerSyncOptions} instance.
+ *
+ * NOTE: This constructor is mainly to be used for tests where the services can be mocked and passed to.
+ *
+ * @param customerSyncOptions the container of all the options of the sync process including the CTP project
+ * client and/or configuration and other sync-specific options.
+ * @param customerService the customer service which is responsible for fetching/caching the Customers from the
+ * CTP project.
+ * @param typeService the type service which is responsible for fetching/caching the Types from the CTP project.
+ * @param customerGroupService the customer group service which is responsible for fetching/caching the
+ * CustomerGroups from the CTP project.
+ */
+ protected CustomerSync(@Nonnull final CustomerSyncOptions customerSyncOptions,
+ @Nonnull final CustomerService customerService,
+ @Nonnull final TypeService typeService,
+ @Nonnull final CustomerGroupService customerGroupService) {
+ super(new CustomerSyncStatistics(), customerSyncOptions);
+ this.customerService = customerService;
+ this.referenceResolver = new CustomerReferenceResolver(getSyncOptions(), typeService, customerGroupService);
+ this.batchValidator = new CustomerBatchValidator(getSyncOptions(), getStatistics());
+ }
+
+ /**
+ * Iterates through the whole {@code customerDrafts} list and accumulates its valid drafts to batches.
+ * Every batch is then processed by {@link CustomerSync#processBatch(List)}.
+ *
+ *
Inherited doc:
+ * {@inheritDoc}
+ *
+ * @param customerDrafts {@link List} of {@link CustomerDraft}'s that would be synced into CTP project.
+ * @return {@link CompletionStage} with {@link CustomerSyncStatistics} holding statistics of all sync
+ * processes performed by this sync instance.
+ */
+ @Override
+ protected CompletionStage process(@Nonnull final List customerDrafts) {
+ final List> batches = batchElements(customerDrafts, syncOptions.getBatchSize());
+ return syncBatches(batches, completedFuture(statistics));
+
+ }
+
+ @Override
+ protected CompletionStage processBatch(@Nonnull final List batch) {
+ final ImmutablePair, CustomerBatchValidator.ReferencedKeys> result =
+ batchValidator.validateAndCollectReferencedKeys(batch);
+
+ final Set validCustomerDrafts = result.getLeft();
+ if (validCustomerDrafts.isEmpty()) {
+ statistics.incrementProcessed(batch.size());
+ return completedFuture(statistics);
+ }
+
+ return referenceResolver
+ .populateKeyToIdCachesForReferencedKeys(result.getRight())
+ .handle(ImmutablePair::new)
+ .thenCompose(cachingResponse -> {
+ final Throwable cachingException = cachingResponse.getValue();
+ if (cachingException != null) {
+ handleError(new SyncException("Failed to build a cache of keys to ids.", cachingException),
+ validCustomerDrafts.size());
+ return CompletableFuture.completedFuture(null);
+ }
+
+ final Set validCustomerKeys =
+ validCustomerDrafts.stream().map(CustomerDraft::getKey).collect(toSet());
+
+ return customerService
+ .fetchMatchingCustomersByKeys(validCustomerKeys)
+ .handle(ImmutablePair::new)
+ .thenCompose(fetchResponse -> {
+ final Set fetchedCustomers = fetchResponse.getKey();
+ final Throwable exception = fetchResponse.getValue();
+
+ if (exception != null) {
+ final String errorMessage = format(CTP_CUSTOMER_FETCH_FAILED, validCustomerKeys);
+ handleError(new SyncException(errorMessage, exception), validCustomerKeys.size());
+ return CompletableFuture.completedFuture(null);
+ } else {
+ return syncBatch(fetchedCustomers, validCustomerDrafts);
+ }
+ });
+ })
+ .thenApply(ignoredResult -> {
+ statistics.incrementProcessed(batch.size());
+ return statistics;
+ });
+ }
+
+ @Nonnull
+ private CompletionStage syncBatch(
+ @Nonnull final Set oldCustomers,
+ @Nonnull final Set newCustomerDrafts) {
+
+ final Map oldCustomerMap = oldCustomers
+ .stream()
+ .collect(toMap(Customer::getKey, identity()));
+
+ return CompletableFuture.allOf(newCustomerDrafts
+ .stream()
+ .map(customerDraft ->
+ referenceResolver
+ .resolveReferences(customerDraft)
+ .thenCompose(resolvedCustomerDraft -> syncDraft(oldCustomerMap, resolvedCustomerDraft))
+ .exceptionally(completionException -> {
+ final String errorMessage = format(FAILED_TO_PROCESS, customerDraft.getKey(),
+ completionException.getMessage());
+ handleError(new SyncException(errorMessage, completionException), 1);
+ return null;
+ })
+ )
+ .map(CompletionStage::toCompletableFuture)
+ .toArray(CompletableFuture[]::new));
+ }
+
+ @Nonnull
+ private CompletionStage syncDraft(
+ @Nonnull final Map oldCustomerMap,
+ @Nonnull final CustomerDraft newCustomerDraft) {
+
+ final Customer oldCustomer = oldCustomerMap.get(newCustomerDraft.getKey());
+ return Optional.ofNullable(oldCustomer)
+ .map(customer -> buildActionsAndUpdate(oldCustomer, newCustomerDraft))
+ .orElseGet(() -> applyCallbackAndCreate(newCustomerDraft));
+ }
+
+ @SuppressFBWarnings("NP_NONNULL_PARAM_VIOLATION") // https://github.com/findbugsproject/findbugs/issues/79
+ @Nonnull
+ private CompletionStage buildActionsAndUpdate(
+ @Nonnull final Customer oldCustomer,
+ @Nonnull final CustomerDraft newCustomerDraft) {
+
+ final List> updateActions =
+ buildActions(oldCustomer, newCustomerDraft, syncOptions);
+
+ final List> updateActionsAfterCallback
+ = syncOptions.applyBeforeUpdateCallback(updateActions, newCustomerDraft, oldCustomer);
+
+ if (!updateActionsAfterCallback.isEmpty()) {
+ return updateCustomer(oldCustomer, newCustomerDraft, updateActionsAfterCallback);
+ }
+
+ return completedFuture(null);
+ }
+
+ @Nonnull
+ private CompletionStage updateCustomer(
+ @Nonnull final Customer oldCustomer,
+ @Nonnull final CustomerDraft newCustomerDraft,
+ @Nonnull final List> updateActionsAfterCallback) {
+
+ return customerService
+ .updateCustomer(oldCustomer, updateActionsAfterCallback)
+ .handle(ImmutablePair::of)
+ .thenCompose(updateResponse -> {
+ final Throwable exception = updateResponse.getValue();
+ if (exception != null) {
+ return executeSupplierIfConcurrentModificationException(
+ exception,
+ () -> fetchAndUpdate(oldCustomer, newCustomerDraft),
+ () -> {
+ final String errorMessage =
+ format(CTP_CUSTOMER_UPDATE_FAILED, newCustomerDraft.getKey(), exception.getMessage());
+ handleError(new SyncException(errorMessage, exception), 1);
+ return CompletableFuture.completedFuture(null);
+ });
+ } else {
+ statistics.incrementUpdated();
+ return CompletableFuture.completedFuture(null);
+ }
+ });
+ }
+
+ @Nonnull
+ private CompletionStage fetchAndUpdate(
+ @Nonnull final Customer oldCustomer,
+ @Nonnull final CustomerDraft newCustomerDraft) {
+
+ final String customerKey = oldCustomer.getKey();
+ return customerService
+ .fetchCustomerByKey(customerKey)
+ .handle(ImmutablePair::of)
+ .thenCompose(fetchResponse -> {
+ final Optional fetchedCustomerOptional = fetchResponse.getKey();
+ final Throwable exception = fetchResponse.getValue();
+
+ if (exception != null) {
+ final String errorMessage = format(CTP_CUSTOMER_UPDATE_FAILED, customerKey,
+ "Failed to fetch from CTP while retrying after concurrency modification.");
+ handleError(new SyncException(errorMessage, exception), 1);
+ return CompletableFuture.completedFuture(null);
+ }
+
+ return fetchedCustomerOptional
+ .map(fetchedCustomer -> buildActionsAndUpdate(fetchedCustomer, newCustomerDraft))
+ .orElseGet(() -> {
+ final String errorMessage = format(CTP_CUSTOMER_UPDATE_FAILED, customerKey,
+ "Not found when attempting to fetch while retrying after concurrency modification.");
+ handleError(new SyncException(errorMessage, null), 1);
+ return CompletableFuture.completedFuture(null);
+ });
+ });
+ }
+
+ @Nonnull
+ private CompletionStage applyCallbackAndCreate(@Nonnull final CustomerDraft customerDraft) {
+
+ return syncOptions
+ .applyBeforeCreateCallback(customerDraft)
+ .map(draft -> customerService
+ .createCustomer(draft)
+ .thenAccept(customerOptional -> {
+ if (customerOptional.isPresent()) {
+ statistics.incrementCreated();
+ } else {
+ statistics.incrementFailed();
+ }
+ }))
+ .orElseGet(() -> CompletableFuture.completedFuture(null));
+ }
+
+ private void handleError(@Nonnull final SyncException syncException,
+ final int failedTimes) {
+ syncOptions.applyErrorCallback(syncException);
+ statistics.incrementFailed(failedTimes);
+ }
+}
diff --git a/src/main/java/com/commercetools/sync/customers/CustomerSyncOptions.java b/src/main/java/com/commercetools/sync/customers/CustomerSyncOptions.java
new file mode 100644
index 0000000000..526cce4b8b
--- /dev/null
+++ b/src/main/java/com/commercetools/sync/customers/CustomerSyncOptions.java
@@ -0,0 +1,38 @@
+package com.commercetools.sync.customers;
+
+import com.commercetools.sync.commons.BaseSyncOptions;
+import com.commercetools.sync.commons.exceptions.SyncException;
+import com.commercetools.sync.commons.utils.QuadConsumer;
+import com.commercetools.sync.commons.utils.TriConsumer;
+import com.commercetools.sync.commons.utils.TriFunction;
+import io.sphere.sdk.client.SphereClient;
+import io.sphere.sdk.commands.UpdateAction;
+import io.sphere.sdk.customers.Customer;
+import io.sphere.sdk.customers.CustomerDraft;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+import java.util.List;
+import java.util.Optional;
+import java.util.function.Function;
+
+public final class CustomerSyncOptions extends BaseSyncOptions {
+
+ CustomerSyncOptions(
+ @Nonnull final SphereClient ctpClient,
+ @Nullable final QuadConsumer, Optional,
+ List>> errorCallback,
+ @Nullable final TriConsumer, Optional>
+ warningCallback,
+ final int batchSize,
+ @Nullable final TriFunction>, CustomerDraft, Customer,
+ List>> beforeUpdateCallback,
+ @Nullable final Function beforeCreateCallback) {
+ super(ctpClient,
+ errorCallback,
+ warningCallback,
+ batchSize,
+ beforeUpdateCallback,
+ beforeCreateCallback);
+ }
+}
diff --git a/src/main/java/com/commercetools/sync/customers/CustomerSyncOptionsBuilder.java b/src/main/java/com/commercetools/sync/customers/CustomerSyncOptionsBuilder.java
new file mode 100644
index 0000000000..a957180f84
--- /dev/null
+++ b/src/main/java/com/commercetools/sync/customers/CustomerSyncOptionsBuilder.java
@@ -0,0 +1,58 @@
+package com.commercetools.sync.customers;
+
+
+import com.commercetools.sync.commons.BaseSyncOptionsBuilder;
+import io.sphere.sdk.client.SphereClient;
+import io.sphere.sdk.customers.Customer;
+import io.sphere.sdk.customers.CustomerDraft;
+
+import javax.annotation.Nonnull;
+
+public final class CustomerSyncOptionsBuilder extends BaseSyncOptionsBuilder {
+ public static final int BATCH_SIZE_DEFAULT = 50;
+
+ private CustomerSyncOptionsBuilder(@Nonnull final SphereClient ctpClient) {
+ this.ctpClient = ctpClient;
+ }
+
+ /**
+ * Creates a new instance of {@link CustomerSyncOptionsBuilder} given a {@link SphereClient} responsible for
+ * interaction with the target CTP project, with the default batch size ({@code BATCH_SIZE_DEFAULT} = 50).
+ *
+ * @param ctpClient instance of the {@link SphereClient} responsible for interaction with the target CTP project.
+ * @return new instance of {@link CustomerSyncOptionsBuilder}
+ */
+ public static CustomerSyncOptionsBuilder of(@Nonnull final SphereClient ctpClient) {
+ return new CustomerSyncOptionsBuilder(ctpClient)
+ .batchSize(BATCH_SIZE_DEFAULT);
+ }
+
+ /**
+ * Creates a new instance of {@link CustomerSyncOptions} enriched with all attributes provided to {@code this}
+ * builder.
+ *
+ * @return new instance of {@link CustomerSyncOptions}
+ */
+ @Override
+ public CustomerSyncOptions build() {
+ return new CustomerSyncOptions(
+ ctpClient,
+ errorCallback,
+ warningCallback,
+ batchSize,
+ beforeUpdateCallback,
+ beforeCreateCallback);
+ }
+
+ /**
+ * Returns an instance of this class to be used in the superclass's generic methods. Please see the JavaDoc in the
+ * overridden method for further details.
+ *
+ * @return an instance of this class.
+ */
+ @Override
+ protected CustomerSyncOptionsBuilder getThis() {
+ return this;
+ }
+}
diff --git a/src/main/java/com/commercetools/sync/customers/commands/updateactions/AddBillingAddressIdWithKey.java b/src/main/java/com/commercetools/sync/customers/commands/updateactions/AddBillingAddressIdWithKey.java
new file mode 100644
index 0000000000..ab1d8f6f53
--- /dev/null
+++ b/src/main/java/com/commercetools/sync/customers/commands/updateactions/AddBillingAddressIdWithKey.java
@@ -0,0 +1,26 @@
+package com.commercetools.sync.customers.commands.updateactions;
+
+import io.sphere.sdk.commands.UpdateActionImpl;
+import io.sphere.sdk.customers.Customer;
+
+import javax.annotation.Nonnull;
+
+// TODO (JVM-SDK), see: SUPPORT-10260, Address selection by key is not supported yet.
+// https://github.com/commercetools/commercetools-jvm-sdk/issues/2071
+public final class AddBillingAddressIdWithKey extends UpdateActionImpl {
+ private final String addressKey;
+
+ private AddBillingAddressIdWithKey(@Nonnull final String addressKey) {
+ super("addBillingAddressId");
+ this.addressKey = addressKey;
+ }
+
+ public static AddBillingAddressIdWithKey of(@Nonnull final String addressKey) {
+ return new AddBillingAddressIdWithKey(addressKey);
+ }
+
+ @Nonnull
+ public String getAddressKey() {
+ return addressKey;
+ }
+}
diff --git a/src/main/java/com/commercetools/sync/customers/commands/updateactions/AddShippingAddressIdWithKey.java b/src/main/java/com/commercetools/sync/customers/commands/updateactions/AddShippingAddressIdWithKey.java
new file mode 100644
index 0000000000..35a2d683f7
--- /dev/null
+++ b/src/main/java/com/commercetools/sync/customers/commands/updateactions/AddShippingAddressIdWithKey.java
@@ -0,0 +1,26 @@
+package com.commercetools.sync.customers.commands.updateactions;
+
+import io.sphere.sdk.commands.UpdateActionImpl;
+import io.sphere.sdk.customers.Customer;
+
+import javax.annotation.Nonnull;
+
+// TODO (JVM-SDK), see: SUPPORT-10260, Address selection by key is not supported yet.
+// https://github.com/commercetools/commercetools-jvm-sdk/issues/2071
+public final class AddShippingAddressIdWithKey extends UpdateActionImpl {
+ private final String addressKey;
+
+ private AddShippingAddressIdWithKey(@Nonnull final String addressKey) {
+ super("addShippingAddressId");
+ this.addressKey = addressKey;
+ }
+
+ public static AddShippingAddressIdWithKey of(@Nonnull final String addressKey) {
+ return new AddShippingAddressIdWithKey(addressKey);
+ }
+
+ @Nonnull
+ public String getAddressKey() {
+ return addressKey;
+ }
+}
diff --git a/src/main/java/com/commercetools/sync/customers/commands/updateactions/SetDefaultBillingAddressWithKey.java b/src/main/java/com/commercetools/sync/customers/commands/updateactions/SetDefaultBillingAddressWithKey.java
new file mode 100644
index 0000000000..b57875bbf0
--- /dev/null
+++ b/src/main/java/com/commercetools/sync/customers/commands/updateactions/SetDefaultBillingAddressWithKey.java
@@ -0,0 +1,27 @@
+package com.commercetools.sync.customers.commands.updateactions;
+
+import io.sphere.sdk.commands.UpdateActionImpl;
+import io.sphere.sdk.customers.Customer;
+
+import javax.annotation.Nullable;
+
+// TODO (JVM-SDK), see: SUPPORT-10260, Address selection by key is not supported yet.
+// https://github.com/commercetools/commercetools-jvm-sdk/issues/2071
+public final class SetDefaultBillingAddressWithKey extends UpdateActionImpl {
+ private final String addressKey;
+
+ private SetDefaultBillingAddressWithKey(@Nullable final String addressKey) {
+ super("setDefaultBillingAddress");
+ this.addressKey = addressKey;
+ }
+
+ public static SetDefaultBillingAddressWithKey of(@Nullable final String addressKey) {
+ return new SetDefaultBillingAddressWithKey(addressKey);
+ }
+
+ @Nullable
+ public String getAddressKey() {
+ return addressKey;
+ }
+}
+
diff --git a/src/main/java/com/commercetools/sync/customers/commands/updateactions/SetDefaultShippingAddressWithKey.java b/src/main/java/com/commercetools/sync/customers/commands/updateactions/SetDefaultShippingAddressWithKey.java
new file mode 100644
index 0000000000..848526bd2d
--- /dev/null
+++ b/src/main/java/com/commercetools/sync/customers/commands/updateactions/SetDefaultShippingAddressWithKey.java
@@ -0,0 +1,27 @@
+package com.commercetools.sync.customers.commands.updateactions;
+
+import io.sphere.sdk.commands.UpdateActionImpl;
+import io.sphere.sdk.customers.Customer;
+
+import javax.annotation.Nullable;
+
+// TODO (JVM-SDK), see: SUPPORT-10260, Address selection by key is not supported yet.
+// https://github.com/commercetools/commercetools-jvm-sdk/issues/2071
+public final class SetDefaultShippingAddressWithKey extends UpdateActionImpl {
+ private final String addressKey;
+
+ private SetDefaultShippingAddressWithKey(@Nullable final String addressKey) {
+ super("setDefaultShippingAddress");
+ this.addressKey = addressKey;
+ }
+
+ public static SetDefaultShippingAddressWithKey of(@Nullable final String addressKey) {
+ return new SetDefaultShippingAddressWithKey(addressKey);
+ }
+
+ @Nullable
+ public String getAddressKey() {
+ return addressKey;
+ }
+}
+
diff --git a/src/main/java/com/commercetools/sync/customers/helpers/CustomerBatchValidator.java b/src/main/java/com/commercetools/sync/customers/helpers/CustomerBatchValidator.java
new file mode 100644
index 0000000000..9bab5bb30d
--- /dev/null
+++ b/src/main/java/com/commercetools/sync/customers/helpers/CustomerBatchValidator.java
@@ -0,0 +1,229 @@
+package com.commercetools.sync.customers.helpers;
+
+import com.commercetools.sync.commons.helpers.BaseBatchValidator;
+import com.commercetools.sync.customers.CustomerSyncOptions;
+import io.sphere.sdk.customers.CustomerDraft;
+import io.sphere.sdk.models.Address;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Objects;
+import java.util.Set;
+import java.util.function.Predicate;
+
+import org.apache.commons.lang3.tuple.ImmutablePair;
+
+import static java.lang.String.format;
+import static java.util.stream.Collectors.toSet;
+import static org.apache.commons.lang3.StringUtils.isBlank;
+
+public class CustomerBatchValidator
+ extends BaseBatchValidator {
+
+ public static final String CUSTOMER_DRAFT_IS_NULL = "CustomerDraft is null.";
+ public static final String CUSTOMER_DRAFT_KEY_NOT_SET = "CustomerDraft with email: %s doesn't have a key. "
+ + "Please make sure all customer drafts have keys.";
+ public static final String CUSTOMER_DRAFT_EMAIL_NOT_SET = "CustomerDraft with key: %s doesn't have an email. "
+ + "Please make sure all customer drafts have emails.";
+ static final String CUSTOMER_DRAFT_PASSWORD_NOT_SET = "CustomerDraft with key: %s doesn't have a password. "
+ + "Please make sure all customer drafts have passwords.";
+
+ static final String ADDRESSES_ARE_NULL = "CustomerDraft with key: '%s' has null addresses on indexes: '%s'. "
+ + "Please make sure each address is set in the addresses list.";
+ static final String ADDRESSES_THAT_KEYS_NOT_SET = "CustomerDraft with key: '%s' has blank address keys on indexes: "
+ + "'%s'. Please make sure each address has a key in the addresses list";
+ static final String ADDRESSES_THAT_KEYS_NOT_UNIQUE = "CustomerDraft with key: '%s' has duplicate address keys on "
+ + "indexes: '%s'. Please make sure each address key is unique in the addresses list.";
+
+ static final String BILLING_ADDRESSES_ARE_NOT_VALID = "CustomerDraft with key: '%s' does not contain indices: '%s' "
+ + "of the 'billingAddresses' in the addresses list. "
+ + "Please make sure all customer drafts have valid index values for the 'billingAddresses'.";
+
+ static final String SHIPPING_ADDRESSES_ARE_NOT_VALID = "CustomerDraft with key: '%s' does not contain indices: '%s'"
+ + " of the 'shippingAddresses' in the addresses list. "
+ + "Please make sure all customer drafts have valid index values for the 'shippingAddresses'.";
+
+ public CustomerBatchValidator(@Nonnull final CustomerSyncOptions syncOptions,
+ @Nonnull final CustomerSyncStatistics syncStatistics) {
+ super(syncOptions, syncStatistics);
+ }
+
+ /**
+ * Given the {@link List}<{@link CustomerDraft}> of drafts this method attempts to validate
+ * drafts and return an {@link ImmutablePair}<{@link Set}<{@link CustomerDraft}>,
+ * {@link CustomerBatchValidator.ReferencedKeys}> which contains the {@link Set} of valid drafts and
+ * referenced keys within a wrapper.
+ *
+ * A valid customer draft is one which satisfies the following conditions:
+ *
+ * - It is not null
+ * - It has a key which is not blank (null/empty)
+ * - It has a email which is not blank (null/empty)
+ * - Each address in the addresses list satisfies the following conditions:
+ *
+ * - It is not null
+ * - It has a key which is not blank (null/empty)
+ * - It has a unique key
+ *
+ *
+ * - Each address index in the 'billing' and 'shipping' addresses list are valid and contained
+ * in the addresses list.
+ *
+ *
+ *
+ * @param customerDrafts the customer drafts to validate and collect referenced keys.
+ * @return {@link ImmutablePair}<{@link Set}<{@link CustomerDraft}>,
+ * {@link CustomerBatchValidator.ReferencedKeys}> which contains the {@link Set} of valid drafts and
+ * referenced keys within a wrapper.
+ */
+ @Override
+ public ImmutablePair, ReferencedKeys> validateAndCollectReferencedKeys(
+ @Nonnull final List customerDrafts) {
+
+ final ReferencedKeys referencedKeys = new ReferencedKeys();
+ final Set validDrafts = customerDrafts
+ .stream()
+ .filter(this::isValidCustomerDraft)
+ .peek(customerDraft -> collectReferencedKeys(referencedKeys, customerDraft))
+ .collect(toSet());
+
+ return ImmutablePair.of(validDrafts, referencedKeys);
+ }
+
+ private boolean isValidCustomerDraft(@Nullable final CustomerDraft customerDraft) {
+
+ if (customerDraft == null) {
+ handleError(CUSTOMER_DRAFT_IS_NULL);
+ } else if (isBlank(customerDraft.getKey())) {
+ handleError(format(CUSTOMER_DRAFT_KEY_NOT_SET, customerDraft.getEmail()));
+ } else if (isBlank(customerDraft.getEmail())) {
+ handleError(format(CUSTOMER_DRAFT_EMAIL_NOT_SET, customerDraft.getKey()));
+ } else if (isBlank(customerDraft.getPassword())) {
+ handleError(format(CUSTOMER_DRAFT_PASSWORD_NOT_SET, customerDraft.getKey()));
+ } else if (hasValidAddresses(customerDraft)) {
+ return hasValidBillingAndShippingAddresses(customerDraft);
+ }
+ return false;
+ }
+
+ private boolean hasValidAddresses(@Nonnull final CustomerDraft customerDraft) {
+
+ final List addressList = customerDraft.getAddresses();
+ if (addressList == null || addressList.isEmpty()) {
+ return true;
+ }
+
+ final List indexesOfNullAddresses = getIndexes(addressList, Objects::isNull);
+ if (!indexesOfNullAddresses.isEmpty()) {
+ handleError(format(ADDRESSES_ARE_NULL, customerDraft.getKey(), indexesOfNullAddresses));
+ return false;
+ }
+
+ final List indexesOfBlankAddressKeys = getIndexes(addressList, address -> isBlank(address.getKey()));
+ if (!indexesOfBlankAddressKeys.isEmpty()) {
+ handleError(format(ADDRESSES_THAT_KEYS_NOT_SET, customerDraft.getKey(), indexesOfBlankAddressKeys));
+ return false;
+ }
+
+ final Predicate searchDuplicateKeys = (Address address) ->
+ addressList.stream().filter(a -> Objects.equals(a.getKey(), address.getKey())).count() > 1;
+
+ final List indexesOfDuplicateAddressKeys = getIndexes(addressList, searchDuplicateKeys);
+ if (!indexesOfDuplicateAddressKeys.isEmpty()) {
+ handleError(format(ADDRESSES_THAT_KEYS_NOT_UNIQUE, customerDraft.getKey(), indexesOfDuplicateAddressKeys));
+ return false;
+ }
+
+ return true;
+ }
+
+ @Nonnull
+ private List getIndexes(@Nonnull final List list, @Nonnull final Predicate predicate) {
+
+ final List indexes = new ArrayList<>();
+ for (int i = 0; i < list.size(); i++) {
+ if (predicate.test(list.get(i))) {
+ indexes.add(i);
+ }
+ }
+ return indexes;
+ }
+
+ private boolean hasValidBillingAndShippingAddresses(@Nonnull final CustomerDraft customerDraft) {
+ /* An example error response from the API, when the indexes are not valid:
+ {
+ "statusCode": 400,
+ "message": "Customer does not contain an address at index '1'.",
+ "errors": [{
+ "code": "InvalidOperation",
+ "message": "Customer does not contain an address at index '-1'."
+ },
+ {
+ "code": "InvalidOperation",
+ "message": "Customer does not contain an address at index '1'."
+ }]
+ }
+ */
+
+ final List addressList = customerDraft.getAddresses();
+ final Predicate isInvalidIndex = index -> index == null || addressList == null
+ || addressList.isEmpty() || index < 0 || index > addressList.size() - 1;
+
+ if (customerDraft.getBillingAddresses() != null && !customerDraft.getBillingAddresses().isEmpty()) {
+ final List invalidIndexes = getIndexValues(customerDraft.getBillingAddresses(), isInvalidIndex);
+ if (!invalidIndexes.isEmpty()) {
+ handleError(format(BILLING_ADDRESSES_ARE_NOT_VALID, customerDraft.getKey(), invalidIndexes));
+ return false;
+ }
+ }
+
+ if (customerDraft.getShippingAddresses() != null && !customerDraft.getShippingAddresses().isEmpty()) {
+ final List invalidIndexes = getIndexValues(customerDraft.getShippingAddresses(), isInvalidIndex);
+ if (!invalidIndexes.isEmpty()) {
+ handleError(format(SHIPPING_ADDRESSES_ARE_NOT_VALID, customerDraft.getKey(), invalidIndexes));
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ @Nonnull
+ private List getIndexValues(@Nonnull final List list,
+ @Nonnull final Predicate predicate) {
+
+ final List indexes = new ArrayList<>();
+ for (Integer integer : list) {
+ if (predicate.test(integer)) {
+ indexes.add(integer);
+ }
+ }
+ return indexes;
+ }
+
+ private void collectReferencedKeys(
+ @Nonnull final CustomerBatchValidator.ReferencedKeys referencedKeys,
+ @Nonnull final CustomerDraft customerDraft) {
+
+ collectReferencedKeyFromResourceIdentifier(customerDraft.getCustomerGroup(),
+ referencedKeys.customerGroupKeys::add);
+ collectReferencedKeyFromCustomFieldsDraft(customerDraft.getCustom(),
+ referencedKeys.typeKeys::add);
+ }
+
+ public static class ReferencedKeys {
+ private final Set customerGroupKeys = new HashSet<>();
+ private final Set typeKeys = new HashSet<>();
+
+ public Set getTypeKeys() {
+ return typeKeys;
+ }
+
+ public Set getCustomerGroupKeys() {
+ return customerGroupKeys;
+ }
+ }
+}
diff --git a/src/main/java/com/commercetools/sync/customers/helpers/CustomerReferenceResolver.java b/src/main/java/com/commercetools/sync/customers/helpers/CustomerReferenceResolver.java
new file mode 100644
index 0000000000..5d7078fe1a
--- /dev/null
+++ b/src/main/java/com/commercetools/sync/customers/helpers/CustomerReferenceResolver.java
@@ -0,0 +1,203 @@
+package com.commercetools.sync.customers.helpers;
+
+import com.commercetools.sync.commons.exceptions.ReferenceResolutionException;
+import com.commercetools.sync.commons.helpers.CustomReferenceResolver;
+import com.commercetools.sync.customers.CustomerSyncOptions;
+import com.commercetools.sync.services.CustomerGroupService;
+import com.commercetools.sync.services.TypeService;
+import io.sphere.sdk.customergroups.CustomerGroup;
+import io.sphere.sdk.customers.CustomerDraft;
+import io.sphere.sdk.customers.CustomerDraftBuilder;
+import io.sphere.sdk.models.ResourceIdentifier;
+import io.sphere.sdk.stores.Store;
+
+import javax.annotation.Nonnull;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionStage;
+
+import static com.commercetools.sync.commons.utils.CompletableFutureUtils.collectionOfFuturesToFutureOfCollection;
+import static io.sphere.sdk.utils.CompletableFutureUtils.exceptionallyCompletedFuture;
+import static java.lang.String.format;
+import static java.util.concurrent.CompletableFuture.completedFuture;
+import static java.util.stream.Collectors.toList;
+
+public final class CustomerReferenceResolver
+ extends CustomReferenceResolver {
+
+ public static final String FAILED_TO_RESOLVE_CUSTOMER_GROUP_REFERENCE =
+ "Failed to resolve customer group resource identifier on CustomerDraft with key:'%s'. Reason: %s";
+ public static final String FAILED_TO_RESOLVE_STORE_REFERENCE =
+ "Failed to resolve store resource identifier on CustomerDraft with key:'%s'. Reason: %s";
+ public static final String FAILED_TO_RESOLVE_CUSTOM_TYPE = "Failed to resolve custom type reference on "
+ + "CustomerDraft with key:'%s'.";
+ public static final String CUSTOMER_GROUP_DOES_NOT_EXIST = "CustomerGroup with key '%s' doesn't exist.";
+
+ private final TypeService typeService;
+ private final CustomerGroupService customerGroupService;
+
+ /**
+ * Takes a {@link CustomerSyncOptions} instance, a {@link TypeService} and a {@link CustomerGroupService} to
+ * instantiate a {@link CustomerReferenceResolver} instance that could be used to resolve the customer drafts in the
+ * CTP project specified in the injected {@link CustomerSyncOptions} instance.
+ *
+ * @param options the container of all the options of the sync process including the CTP project client
+ * and/or configuration and other sync-specific options.
+ * @param typeService the service to fetch the custom types for reference resolution.
+ * @param customerGroupService the service to fetch the customer groups for reference resolution.
+ */
+ public CustomerReferenceResolver(
+ @Nonnull final CustomerSyncOptions options,
+ @Nonnull final TypeService typeService,
+ @Nonnull final CustomerGroupService customerGroupService) {
+ super(options, typeService);
+ this.typeService = typeService;
+ this.customerGroupService = customerGroupService;
+ }
+
+ /**
+ * Given a {@link CustomerDraft} this method attempts to resolve the stores, customer group and custom type
+ * references to return a {@link CompletionStage} which contains a new instance of the draft with the resolved
+ * references or, in case an error occurs during reference resolution, a {@link ReferenceResolutionException}.
+ *
+ * @param customerDraft the draft to resolve its references.
+ * @return a {@link CompletionStage} that contains as a result a new CustomerDraft instance with resolved
+ * custom type reference or, in case an error occurs during reference resolution,
+ * a {@link ReferenceResolutionException}.
+ */
+ @Override
+ @Nonnull
+ public CompletionStage resolveReferences(@Nonnull final CustomerDraft customerDraft) {
+ return resolveCustomTypeReference(CustomerDraftBuilder.of(customerDraft))
+ .thenCompose(this::resolveCustomerGroupReference)
+ .thenCompose(this::resolveStoreReferences)
+ .thenApply(CustomerDraftBuilder::build);
+ }
+
+ @Override
+ protected CompletionStage resolveCustomTypeReference(
+ @Nonnull final CustomerDraftBuilder draftBuilder) {
+
+ return resolveCustomTypeReference(draftBuilder,
+ CustomerDraftBuilder::getCustom,
+ CustomerDraftBuilder::custom,
+ format(FAILED_TO_RESOLVE_CUSTOM_TYPE, draftBuilder.getKey()));
+ }
+
+ /**
+ * Given a {@link CustomerDraftBuilder} this method attempts to resolve the customer group to return a
+ * {@link CompletionStage} which contains a new instance of the builder with the resolved customer group reference.
+ *
+ * @param draftBuilder the customerDraft to resolve its customer group reference.
+ * @return a {@link CompletionStage} that contains as a result a new builder instance with resolved customer group
+ * reference or, in case an error occurs during reference resolution,
+ * a {@link ReferenceResolutionException}.
+ */
+ @Nonnull
+ public CompletionStage resolveCustomerGroupReference(
+ @Nonnull final CustomerDraftBuilder draftBuilder) {
+
+ final ResourceIdentifier customerGroupResourceIdentifier = draftBuilder.getCustomerGroup();
+ if (customerGroupResourceIdentifier != null && customerGroupResourceIdentifier.getId() == null) {
+ String customerGroupKey;
+ try {
+ customerGroupKey = getKeyFromResourceIdentifier(customerGroupResourceIdentifier);
+ } catch (ReferenceResolutionException referenceResolutionException) {
+ return exceptionallyCompletedFuture(new ReferenceResolutionException(
+ format(FAILED_TO_RESOLVE_CUSTOMER_GROUP_REFERENCE, draftBuilder.getKey(),
+ referenceResolutionException.getMessage())));
+ }
+
+ return fetchAndResolveCustomerGroupReference(draftBuilder, customerGroupKey);
+ }
+ return completedFuture(draftBuilder);
+ }
+
+ @Nonnull
+ private CompletionStage fetchAndResolveCustomerGroupReference(
+ @Nonnull final CustomerDraftBuilder draftBuilder,
+ @Nonnull final String customerGroupKey) {
+
+ return customerGroupService
+ .fetchCachedCustomerGroupId(customerGroupKey)
+ .thenCompose(resolvedCustomerGroupIdOptional -> resolvedCustomerGroupIdOptional
+ .map(resolvedCustomerGroupId ->
+ completedFuture(draftBuilder.customerGroup(
+ CustomerGroup.referenceOfId(resolvedCustomerGroupId).toResourceIdentifier())))
+ .orElseGet(() -> {
+ final String errorMessage = format(CUSTOMER_GROUP_DOES_NOT_EXIST, customerGroupKey);
+ return exceptionallyCompletedFuture(new ReferenceResolutionException(
+ format(FAILED_TO_RESOLVE_CUSTOMER_GROUP_REFERENCE, draftBuilder.getKey(),
+ errorMessage)));
+ }));
+ }
+
+ /**
+ * Given a {@link CustomerDraftBuilder} this method attempts to resolve the stores and return
+ * a {@link CompletionStage} which contains a new instance of the builder with the resolved references.
+ *
+ * @param draftBuilder the customer draft to resolve its store references.
+ * @return a {@link CompletionStage} that contains as a result a new builder instance with resolved references or,
+ * in case an error occurs during reference resolution, a {@link ReferenceResolutionException}.
+ */
+ @Nonnull
+ public CompletionStage resolveStoreReferences(
+ @Nonnull final CustomerDraftBuilder draftBuilder) {
+
+ final List> storeResourceIdentifiers = draftBuilder.getStores();
+ if (storeResourceIdentifiers == null || storeResourceIdentifiers.isEmpty()) {
+ return completedFuture(draftBuilder);
+ }
+ final List> resolvedReferences = new ArrayList<>();
+ for (ResourceIdentifier storeResourceIdentifier : storeResourceIdentifiers) {
+ if (storeResourceIdentifier != null) {
+ if (storeResourceIdentifier.getId() == null) {
+ try {
+ final String storeKey = getKeyFromResourceIdentifier(storeResourceIdentifier);
+ resolvedReferences.add(ResourceIdentifier.ofKey(storeKey));
+ } catch (ReferenceResolutionException referenceResolutionException) {
+ return exceptionallyCompletedFuture(
+ new ReferenceResolutionException(
+ format(FAILED_TO_RESOLVE_STORE_REFERENCE,
+ draftBuilder.getKey(), referenceResolutionException.getMessage())));
+ }
+ } else {
+ resolvedReferences.add(ResourceIdentifier.ofId(storeResourceIdentifier.getId()));
+ }
+ }
+ }
+ return completedFuture(draftBuilder.stores(resolvedReferences));
+ }
+
+ /**
+ * Calls the {@code cacheKeysToIds} service methods to fetch all the referenced keys
+ * (i.e custom type, customer group) from the commercetools to populate caches for the reference resolution.
+ *
+ * Note: This method is meant be only used internally by the library to improve performance.
+ *
+ * @param referencedKeys a wrapper for the product references to fetch and cache the id's for.
+ * @return {@link CompletionStage}<{@link Map}<{@link String}>{@link String}>> in which the results
+ * of it's completions contains a map of requested references keys -> ids of customer references.
+ */
+ @Nonnull
+ public CompletableFuture>> populateKeyToIdCachesForReferencedKeys(
+ @Nonnull final CustomerBatchValidator.ReferencedKeys referencedKeys) {
+
+ final List>> futures = new ArrayList<>();
+
+ final Set typeKeys = referencedKeys.getTypeKeys();
+ if (!typeKeys.isEmpty()) {
+ futures.add(typeService.cacheKeysToIds(typeKeys));
+ }
+
+ final Set customerGroupKeys = referencedKeys.getCustomerGroupKeys();
+ if (!customerGroupKeys.isEmpty()) {
+ futures.add(customerGroupService.cacheKeysToIds(customerGroupKeys));
+ }
+
+ return collectionOfFuturesToFutureOfCollection(futures, toList());
+ }
+}
diff --git a/src/main/java/com/commercetools/sync/customers/helpers/CustomerSyncStatistics.java b/src/main/java/com/commercetools/sync/customers/helpers/CustomerSyncStatistics.java
new file mode 100644
index 0000000000..c1a4fa93df
--- /dev/null
+++ b/src/main/java/com/commercetools/sync/customers/helpers/CustomerSyncStatistics.java
@@ -0,0 +1,18 @@
+package com.commercetools.sync.customers.helpers;
+
+import com.commercetools.sync.commons.helpers.BaseSyncStatistics;
+
+public class CustomerSyncStatistics extends BaseSyncStatistics {
+
+ /**
+ * Builds a summary of the customer sync statistics instance that looks like the following example:
+ *
+ * "Summary: 2 customers have been processed in total (0 created, 0 updated and 0 failed to sync)."
+ *
+ * @return a summary message of the customer sync statistics instance.
+ */
+ @Override
+ public String getReportMessage() {
+ return getDefaultReportMessageForResource("customers");
+ }
+}
diff --git a/src/main/java/com/commercetools/sync/customers/utils/CustomerCustomActionBuilder.java b/src/main/java/com/commercetools/sync/customers/utils/CustomerCustomActionBuilder.java
new file mode 100644
index 0000000000..7ccfe93184
--- /dev/null
+++ b/src/main/java/com/commercetools/sync/customers/utils/CustomerCustomActionBuilder.java
@@ -0,0 +1,57 @@
+package com.commercetools.sync.customers.utils;
+
+import com.commercetools.sync.commons.helpers.GenericCustomActionBuilder;
+import com.fasterxml.jackson.databind.JsonNode;
+import io.sphere.sdk.commands.UpdateAction;
+import io.sphere.sdk.customers.Customer;
+import io.sphere.sdk.customers.commands.updateactions.SetCustomField;
+import io.sphere.sdk.customers.commands.updateactions.SetCustomType;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+import java.util.Map;
+
+public final class CustomerCustomActionBuilder implements GenericCustomActionBuilder {
+
+ private static final CustomerCustomActionBuilder builder = new CustomerCustomActionBuilder();
+
+ private CustomerCustomActionBuilder() {
+ super();
+ }
+
+ @Nonnull
+ public static CustomerCustomActionBuilder of() {
+ return builder;
+ }
+
+ @Nonnull
+ @Override
+ public UpdateAction buildRemoveCustomTypeAction(
+ @Nullable final Integer variantId,
+ @Nullable final String objectId) {
+
+ return SetCustomType.ofRemoveType();
+ }
+
+ @Nonnull
+ @Override
+ public UpdateAction buildSetCustomTypeAction(
+ @Nullable final Integer variantId,
+ @Nullable final String objectId,
+ @Nonnull final String customTypeId,
+ @Nullable final Map customFieldsJsonMap) {
+
+ return SetCustomType.ofTypeIdAndJson(customTypeId, customFieldsJsonMap);
+ }
+
+ @Nonnull
+ @Override
+ public UpdateAction buildSetCustomFieldAction(
+ @Nullable final Integer variantId,
+ @Nullable final String objectId,
+ @Nullable final String customFieldName,
+ @Nullable final JsonNode customFieldValue) {
+
+ return SetCustomField.ofJson(customFieldName, customFieldValue);
+ }
+}
diff --git a/src/main/java/com/commercetools/sync/customers/utils/CustomerReferenceResolutionUtils.java b/src/main/java/com/commercetools/sync/customers/utils/CustomerReferenceResolutionUtils.java
new file mode 100644
index 0000000000..1df8d20e7f
--- /dev/null
+++ b/src/main/java/com/commercetools/sync/customers/utils/CustomerReferenceResolutionUtils.java
@@ -0,0 +1,180 @@
+package com.commercetools.sync.customers.utils;
+
+import io.sphere.sdk.customergroups.CustomerGroup;
+import io.sphere.sdk.customers.Customer;
+import io.sphere.sdk.customers.CustomerDraft;
+import io.sphere.sdk.customers.CustomerDraftBuilder;
+import io.sphere.sdk.customers.expansion.CustomerExpansionModel;
+import io.sphere.sdk.customers.queries.CustomerQuery;
+import io.sphere.sdk.expansion.ExpansionPath;
+import io.sphere.sdk.models.Address;
+import io.sphere.sdk.models.KeyReference;
+import io.sphere.sdk.models.Reference;
+import io.sphere.sdk.models.ResourceIdentifier;
+import io.sphere.sdk.stores.Store;
+import io.sphere.sdk.types.Type;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+
+import static com.commercetools.sync.commons.utils.CustomTypeReferenceResolutionUtils.mapToCustomFieldsDraft;
+import static com.commercetools.sync.commons.utils.SyncUtils.getResourceIdentifierWithKey;
+import static java.util.stream.Collectors.toList;
+import static org.apache.http.util.TextUtils.isBlank;
+
+/**
+ * Util class which provides utilities that can be used when syncing resources from a source commercetools project
+ * to a target one.
+ */
+public final class CustomerReferenceResolutionUtils {
+
+ /**
+ * Returns a {@link List}<{@link CustomerDraft}> consisting of the results of applying the
+ * mapping from {@link Customer} to {@link CustomerDraft} with considering reference resolution.
+ *
+ *
+ *
+ *
+ * Reference field |
+ * from |
+ * to |
+ *
+ *
+ *
+ *
+ * customerGroup |
+ * {@link Reference}<{@link CustomerGroup}> |
+ * {@link ResourceIdentifier}<{@link CustomerGroup}> |
+ *
+ *
+ * stores |
+ * {@link Set}<{@link KeyReference}<{@link Store}>> |
+ * {@link Set}<{@link ResourceIdentifier}<{@link Store}>> |
+ *
+ *
+ * custom.type |
+ * {@link Reference}<{@link Type}> |
+ * {@link ResourceIdentifier}<{@link Type}> |
+ *
+ *
+ *
+ *
+ * Note: The {@link CustomerGroup} and {@link Type} references should be expanded with a key.
+ * Any reference that is not expanded will have its id in place and not replaced by the key will be
+ * considered as existing resources on the target commercetools project and
+ * the library will issues an update/create API request without reference resolution.
+ *
+ * @param customers the customers with expanded references.
+ * @return a {@link List} of {@link CustomerDraft} built from the supplied {@link List} of {@link Customer}.
+ */
+ @Nonnull
+ public static List mapToCustomerDrafts(
+ @Nonnull final List customers) {
+ return customers
+ .stream()
+ .map(CustomerReferenceResolutionUtils::mapToCustomerDraft)
+ .collect(toList());
+ }
+
+ @Nonnull
+ private static CustomerDraft mapToCustomerDraft(@Nonnull final Customer customer) {
+ return CustomerDraftBuilder
+ .of(customer.getEmail(), customer.getPassword())
+ .customerNumber(customer.getCustomerNumber())
+ .key(customer.getKey())
+ .firstName(customer.getFirstName())
+ .lastName(customer.getLastName())
+ .middleName(customer.getMiddleName())
+ .title(customer.getTitle())
+ .externalId(customer.getExternalId())
+ .companyName(customer.getCompanyName())
+ .customerGroup(getResourceIdentifierWithKey(customer.getCustomerGroup()))
+ .dateOfBirth(customer.getDateOfBirth())
+ .isEmailVerified(customer.isEmailVerified())
+ .vatId(customer.getVatId())
+ .addresses(customer.getAddresses())
+ .defaultBillingAddress(getAddressIndex(customer.getAddresses(), customer.getDefaultBillingAddressId()))
+ .billingAddresses(getAddressIndexList(customer.getAddresses(), customer.getBillingAddressIds()))
+ .defaultShippingAddress(getAddressIndex(customer.getAddresses(), customer.getDefaultShippingAddressId()))
+ .shippingAddresses(getAddressIndexList(customer.getAddresses(), customer.getShippingAddressIds()))
+ .custom(mapToCustomFieldsDraft(customer))
+ .locale(customer.getLocale())
+ .salutation(customer.getSalutation())
+ .stores(mapToStores(customer))
+ .build();
+ }
+
+
+ @Nullable
+ private static Integer getAddressIndex(
+ @Nullable final List allAddresses,
+ @Nullable final String addressId) {
+
+ if (allAddresses == null) {
+ return null;
+ }
+ if (isBlank(addressId)) {
+ return null;
+ }
+ for (int i = 0; i < allAddresses.size(); i++) {
+ String id = allAddresses.get(i).getId();
+ if (id != null && id.equals(addressId)) {
+ return i;
+ }
+ }
+ return null;
+ }
+
+ @Nullable
+ private static List getAddressIndexList(
+ @Nullable final List allAddresses,
+ @Nullable final List addressIds) {
+ if (allAddresses == null || addressIds == null) {
+ return null;
+ }
+ final List indexes = new ArrayList<>();
+ for (String addressId : addressIds) {
+ indexes.add(getAddressIndex(allAddresses, addressId));
+ }
+ return indexes;
+ }
+
+ @Nullable
+ private static List> mapToStores(@Nonnull final Customer customer) {
+ final List> storeReferences = customer.getStores();
+ if (storeReferences != null) {
+ return storeReferences
+ .stream()
+ .map(storeKeyReference -> ResourceIdentifier.ofKey(storeKeyReference.getKey()))
+ .collect(toList());
+ }
+ return null;
+ }
+
+ /**
+ * Builds a {@link CustomerQuery} for fetching customers from a source CTP project with all the needed
+ * references expanded for the sync:
+ *
+ * - Custom Type
+ * - CustomerGroup
+ *
+ *
+ * Note: Please only use this util if you desire to sync all the aforementioned references from
+ * a source commercetools project. Otherwise, it is more efficient to build the query without expansions, if they
+ * are not needed, to avoid unnecessarily bigger payloads fetched from the source project.
+ *
+ * @return the query for fetching customers from the source CTP project with all the aforementioned references
+ * expanded.
+ */
+ public static CustomerQuery buildCustomerQuery() {
+ return CustomerQuery.of()
+ .withExpansionPaths(CustomerExpansionModel::customerGroup)
+ .plusExpansionPaths(ExpansionPath.of("custom.type"));
+ }
+
+ private CustomerReferenceResolutionUtils() {
+ }
+}
diff --git a/src/main/java/com/commercetools/sync/customers/utils/CustomerSyncUtils.java b/src/main/java/com/commercetools/sync/customers/utils/CustomerSyncUtils.java
new file mode 100644
index 0000000000..97ea1e287a
--- /dev/null
+++ b/src/main/java/com/commercetools/sync/customers/utils/CustomerSyncUtils.java
@@ -0,0 +1,92 @@
+package com.commercetools.sync.customers.utils;
+
+import com.commercetools.sync.customers.CustomerSyncOptions;
+import io.sphere.sdk.commands.UpdateAction;
+import io.sphere.sdk.customers.Customer;
+import io.sphere.sdk.customers.CustomerDraft;
+
+import javax.annotation.Nonnull;
+import java.util.List;
+
+import static com.commercetools.sync.commons.utils.CustomUpdateActionUtils.buildPrimaryResourceCustomUpdateActions;
+import static com.commercetools.sync.commons.utils.OptionalUtils.filterEmptyOptionals;
+import static com.commercetools.sync.customers.utils.CustomerUpdateActionUtils.buildAllAddressUpdateActions;
+import static com.commercetools.sync.customers.utils.CustomerUpdateActionUtils.buildChangeEmailUpdateAction;
+import static com.commercetools.sync.customers.utils.CustomerUpdateActionUtils.buildSetCompanyNameUpdateAction;
+import static com.commercetools.sync.customers.utils.CustomerUpdateActionUtils.buildSetCustomerGroupUpdateAction;
+import static com.commercetools.sync.customers.utils.CustomerUpdateActionUtils.buildSetCustomerNumberUpdateAction;
+import static com.commercetools.sync.customers.utils.CustomerUpdateActionUtils.buildSetDateOfBirthUpdateAction;
+import static com.commercetools.sync.customers.utils.CustomerUpdateActionUtils.buildSetExternalIdUpdateAction;
+import static com.commercetools.sync.customers.utils.CustomerUpdateActionUtils.buildSetFirstNameUpdateAction;
+import static com.commercetools.sync.customers.utils.CustomerUpdateActionUtils.buildSetLastNameUpdateAction;
+import static com.commercetools.sync.customers.utils.CustomerUpdateActionUtils.buildSetLocaleUpdateAction;
+import static com.commercetools.sync.customers.utils.CustomerUpdateActionUtils.buildSetMiddleNameUpdateAction;
+import static com.commercetools.sync.customers.utils.CustomerUpdateActionUtils.buildSetSalutationUpdateAction;
+import static com.commercetools.sync.customers.utils.CustomerUpdateActionUtils.buildSetTitleUpdateAction;
+import static com.commercetools.sync.customers.utils.CustomerUpdateActionUtils.buildSetVatIdUpdateAction;
+import static com.commercetools.sync.customers.utils.CustomerUpdateActionUtils.buildStoreUpdateActions;
+
+public final class CustomerSyncUtils {
+
+ private static final CustomerCustomActionBuilder customerCustomActionBuilder = CustomerCustomActionBuilder.of();
+
+ /**
+ * Compares all the fields of a {@link Customer} and a {@link CustomerDraft}. It returns a {@link List} of
+ * {@link UpdateAction}<{@link Customer}> as a result. If no update action is needed, for example in
+ * case where both the {@link CustomerDraft} and the {@link CustomerDraft} have the same fields, an empty
+ * {@link List} is returned.
+ *
+ * @param oldCustomer the customer which should be updated.
+ * @param newCustomer the customer draft where we get the new data.
+ * @param syncOptions the sync options wrapper which contains options related to the sync process supplied
+ * by the user. For example, custom callbacks to call in case of warnings or errors occurring
+ * on the build update action process. And other options (See {@link CustomerSyncOptions}
+ * for more info.
+ * @return A list of customer specific update actions.
+ */
+ @Nonnull
+ public static List> buildActions(
+ @Nonnull final Customer oldCustomer,
+ @Nonnull final CustomerDraft newCustomer,
+ @Nonnull final CustomerSyncOptions syncOptions) {
+
+ final List> updateActions = filterEmptyOptionals(
+ buildChangeEmailUpdateAction(oldCustomer, newCustomer),
+ buildSetFirstNameUpdateAction(oldCustomer, newCustomer),
+ buildSetLastNameUpdateAction(oldCustomer, newCustomer),
+ buildSetMiddleNameUpdateAction(oldCustomer, newCustomer),
+ buildSetTitleUpdateAction(oldCustomer, newCustomer),
+ buildSetSalutationUpdateAction(oldCustomer, newCustomer),
+ buildSetCustomerGroupUpdateAction(oldCustomer, newCustomer),
+ buildSetCustomerNumberUpdateAction(oldCustomer, newCustomer, syncOptions),
+ buildSetExternalIdUpdateAction(oldCustomer, newCustomer),
+ buildSetCompanyNameUpdateAction(oldCustomer, newCustomer),
+ buildSetDateOfBirthUpdateAction(oldCustomer, newCustomer),
+ buildSetVatIdUpdateAction(oldCustomer, newCustomer),
+ buildSetLocaleUpdateAction(oldCustomer, newCustomer)
+ );
+
+ final List> addressUpdateActions =
+ buildAllAddressUpdateActions(oldCustomer, newCustomer);
+
+ updateActions.addAll(addressUpdateActions);
+
+ final List> customerCustomUpdateActions =
+ buildPrimaryResourceCustomUpdateActions(oldCustomer,
+ newCustomer,
+ customerCustomActionBuilder,
+ syncOptions);
+
+ updateActions.addAll(customerCustomUpdateActions);
+
+ final List> buildStoreUpdateActions =
+ buildStoreUpdateActions(oldCustomer, newCustomer);
+
+ updateActions.addAll(buildStoreUpdateActions);
+
+ return updateActions;
+ }
+
+ private CustomerSyncUtils() {
+ }
+}
diff --git a/src/main/java/com/commercetools/sync/customers/utils/CustomerUpdateActionUtils.java b/src/main/java/com/commercetools/sync/customers/utils/CustomerUpdateActionUtils.java
new file mode 100644
index 0000000000..e650cd96ea
--- /dev/null
+++ b/src/main/java/com/commercetools/sync/customers/utils/CustomerUpdateActionUtils.java
@@ -0,0 +1,1043 @@
+package com.commercetools.sync.customers.utils;
+
+import com.commercetools.sync.commons.exceptions.SyncException;
+import com.commercetools.sync.customers.CustomerSyncOptions;
+import com.commercetools.sync.customers.commands.updateactions.AddBillingAddressIdWithKey;
+import com.commercetools.sync.customers.commands.updateactions.AddShippingAddressIdWithKey;
+import com.commercetools.sync.customers.commands.updateactions.SetDefaultBillingAddressWithKey;
+import com.commercetools.sync.customers.commands.updateactions.SetDefaultShippingAddressWithKey;
+import io.sphere.sdk.commands.UpdateAction;
+import io.sphere.sdk.customergroups.CustomerGroup;
+import io.sphere.sdk.customers.Customer;
+import io.sphere.sdk.customers.CustomerDraft;
+import io.sphere.sdk.customers.commands.updateactions.AddAddress;
+import io.sphere.sdk.customers.commands.updateactions.AddStore;
+import io.sphere.sdk.customers.commands.updateactions.ChangeAddress;
+import io.sphere.sdk.customers.commands.updateactions.ChangeEmail;
+import io.sphere.sdk.customers.commands.updateactions.RemoveAddress;
+import io.sphere.sdk.customers.commands.updateactions.RemoveBillingAddressId;
+import io.sphere.sdk.customers.commands.updateactions.RemoveShippingAddressId;
+import io.sphere.sdk.customers.commands.updateactions.RemoveStore;
+import io.sphere.sdk.customers.commands.updateactions.SetCompanyName;
+import io.sphere.sdk.customers.commands.updateactions.SetCustomerGroup;
+import io.sphere.sdk.customers.commands.updateactions.SetCustomerNumber;
+import io.sphere.sdk.customers.commands.updateactions.SetDateOfBirth;
+import io.sphere.sdk.customers.commands.updateactions.SetExternalId;
+import io.sphere.sdk.customers.commands.updateactions.SetFirstName;
+import io.sphere.sdk.customers.commands.updateactions.SetLastName;
+import io.sphere.sdk.customers.commands.updateactions.SetLocale;
+import io.sphere.sdk.customers.commands.updateactions.SetMiddleName;
+import io.sphere.sdk.customers.commands.updateactions.SetSalutation;
+import io.sphere.sdk.customers.commands.updateactions.SetStores;
+import io.sphere.sdk.customers.commands.updateactions.SetTitle;
+import io.sphere.sdk.customers.commands.updateactions.SetVatId;
+import io.sphere.sdk.models.Address;
+import io.sphere.sdk.models.KeyReference;
+import io.sphere.sdk.models.Reference;
+import io.sphere.sdk.models.Referenceable;
+import io.sphere.sdk.models.ResourceIdentifier;
+import io.sphere.sdk.models.ResourceImpl;
+import io.sphere.sdk.stores.Store;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import static com.commercetools.sync.commons.utils.CommonTypeUpdateActionUtils.buildUpdateAction;
+import static com.commercetools.sync.commons.utils.CommonTypeUpdateActionUtils.buildUpdateActionForReferences;
+import static java.lang.String.format;
+import static java.util.Collections.emptyList;
+import static java.util.Collections.singletonList;
+import static java.util.function.Function.identity;
+import static java.util.stream.Collectors.toList;
+import static java.util.stream.Collectors.toMap;
+import static java.util.stream.Collectors.toSet;
+import static org.apache.commons.lang3.StringUtils.isBlank;
+
+public final class CustomerUpdateActionUtils {
+
+ public static final String CUSTOMER_NUMBER_EXISTS_WARNING = "Customer with key: \"%s\" has "
+ + "already a customer number: \"%s\", once it's set it cannot be changed. "
+ + "Hereby, the update action is not created.";
+
+ private CustomerUpdateActionUtils() {
+ }
+
+ /**
+ * Compares the {@code email} values of a {@link Customer} and a {@link CustomerDraft}
+ * and returns an {@link Optional} of update action, which would contain the {@code "changeEmail"}
+ * {@link UpdateAction}. If both {@link Customer} and {@link CustomerDraft} have the same
+ * {@code email} values, then no update action is needed and empty optional will be returned.
+ *
+ * @param oldCustomer the customer that should be updated.
+ * @param newCustomer the customer draft that contains the new email.
+ * @return optional containing update action or empty optional if emails are identical.
+ */
+ @Nonnull
+ public static Optional> buildChangeEmailUpdateAction(
+ @Nonnull final Customer oldCustomer,
+ @Nonnull final CustomerDraft newCustomer) {
+
+ return buildUpdateAction(oldCustomer.getEmail(), newCustomer.getEmail(),
+ () -> ChangeEmail.of(newCustomer.getEmail()));
+ }
+
+ /**
+ * Compares the {@code firstName} values of a {@link Customer} and a {@link CustomerDraft}
+ * and returns an {@link Optional} of update action, which would contain the {@code "setFirstName"}
+ * {@link UpdateAction}. If both {@link Customer} and {@link CustomerDraft} have the same
+ * {@code firstName} values, then no update action is needed and empty optional will be returned.
+ *
+ * @param oldCustomer the customer that should be updated.
+ * @param newCustomer the customer draft that contains the new first name.
+ * @return optional containing update action or empty optional if first names are identical.
+ */
+ @Nonnull
+ public static Optional> buildSetFirstNameUpdateAction(
+ @Nonnull final Customer oldCustomer,
+ @Nonnull final CustomerDraft newCustomer) {
+
+ return buildUpdateAction(oldCustomer.getFirstName(), newCustomer.getFirstName(),
+ () -> SetFirstName.of(newCustomer.getFirstName()));
+ }
+
+ /**
+ * Compares the {@code lastName} values of a {@link Customer} and a {@link CustomerDraft}
+ * and returns an {@link Optional} of update action, which would contain the {@code "setLastName"}
+ * {@link UpdateAction}. If both {@link Customer} and {@link CustomerDraft} have the same
+ * {@code lastName} values, then no update action is needed and empty optional will be returned.
+ *
+ * @param oldCustomer the customer that should be updated.
+ * @param newCustomer the customer draft that contains the new last name.
+ * @return optional containing update action or empty optional if last names are identical.
+ */
+ @Nonnull
+ public static Optional> buildSetLastNameUpdateAction(
+ @Nonnull final Customer oldCustomer,
+ @Nonnull final CustomerDraft newCustomer) {
+
+ return buildUpdateAction(oldCustomer.getLastName(), newCustomer.getLastName(),
+ () -> SetLastName.of(newCustomer.getLastName()));
+ }
+
+ /**
+ * Compares the {@code middleName} values of a {@link Customer} and a {@link CustomerDraft}
+ * and returns an {@link Optional} of update action, which would contain the {@code "setMiddleName"}
+ * {@link UpdateAction}. If both {@link Customer} and {@link CustomerDraft} have the same
+ * {@code middleName} values, then no update action is needed and empty optional will be returned.
+ *
+ * @param oldCustomer the customer that should be updated.
+ * @param newCustomer the customer draft that contains the new middle name.
+ * @return optional containing update action or empty optional if middle names are identical.
+ */
+ @Nonnull
+ public static Optional> buildSetMiddleNameUpdateAction(
+ @Nonnull final Customer oldCustomer,
+ @Nonnull final CustomerDraft newCustomer) {
+
+ return buildUpdateAction(oldCustomer.getMiddleName(), newCustomer.getMiddleName(),
+ () -> SetMiddleName.of(newCustomer.getMiddleName()));
+ }
+
+ /**
+ * Compares the {@code title} values of a {@link Customer} and a {@link CustomerDraft}
+ * and returns an {@link Optional} of update action, which would contain the {@code "setTitle"}
+ * {@link UpdateAction}. If both {@link Customer} and {@link CustomerDraft} have the same
+ * {@code title} values, then no update action is needed and empty optional will be returned.
+ *
+ * @param oldCustomer the customer that should be updated.
+ * @param newCustomer the customer draft that contains the new title.
+ * @return optional containing update action or empty optional if titles are identical.
+ */
+ @Nonnull
+ public static Optional> buildSetTitleUpdateAction(
+ @Nonnull final Customer oldCustomer,
+ @Nonnull final CustomerDraft newCustomer) {
+
+ return buildUpdateAction(oldCustomer.getTitle(), newCustomer.getTitle(),
+ () -> SetTitle.of(newCustomer.getTitle()));
+ }
+
+ /**
+ * Compares the {@code salutation} values of a {@link Customer} and a {@link CustomerDraft}
+ * and returns an {@link Optional} of update action, which would contain the {@code "SetSalutation"}
+ * {@link UpdateAction}. If both {@link Customer} and {@link CustomerDraft} have the same
+ * {@code salutation} values, then no update action is needed and empty optional will be returned.
+ *
+ * @param oldCustomer the Customer that should be updated.
+ * @param newCustomer the Customer draft that contains the new salutation.
+ * @return optional containing update action or empty optional if salutations are identical.
+ */
+ @Nonnull
+ public static Optional> buildSetSalutationUpdateAction(
+ @Nonnull final Customer oldCustomer,
+ @Nonnull final CustomerDraft newCustomer) {
+
+ return buildUpdateAction(oldCustomer.getSalutation(), newCustomer.getSalutation(),
+ () -> SetSalutation.of(newCustomer.getSalutation()));
+ }
+
+ /**
+ * Compares the {@code customerNumber} values of a {@link Customer} and a {@link CustomerDraft}
+ * and returns an {@link Optional} of update action, which would contain the {@code "setCustomerNumber"}
+ * {@link UpdateAction}. If both {@link Customer} and {@link CustomerDraft} have the same
+ * {@code customerNumber} values, then no update action is needed and empty optional will be returned.
+ *
+ * Note: Customer number should be unique across a project. Once it's set it cannot be changed. For this case,
+ * warning callback will be triggered and an empty optional will be returned.
+ *
+ * @param oldCustomer the customer that should be updated.
+ * @param newCustomer the customer draft that contains the new customer number.
+ * @param syncOptions responsible for supplying the sync options to the sync utility method. It is used for
+ * triggering the warning callback when trying to change an existing customer number.
+ * @return optional containing update action or empty optional if customer numbers are identical.
+ */
+ @Nonnull
+ public static Optional> buildSetCustomerNumberUpdateAction(
+ @Nonnull final Customer oldCustomer,
+ @Nonnull final CustomerDraft newCustomer,
+ @Nonnull final CustomerSyncOptions syncOptions) {
+
+ final Optional> setCustomerNumberAction =
+ buildUpdateAction(oldCustomer.getCustomerNumber(), newCustomer.getCustomerNumber(),
+ () -> SetCustomerNumber.of(newCustomer.getCustomerNumber()));
+
+ if (setCustomerNumberAction.isPresent() && !isBlank(oldCustomer.getCustomerNumber())) {
+
+ syncOptions.applyWarningCallback(
+ new SyncException(format(CUSTOMER_NUMBER_EXISTS_WARNING, oldCustomer.getKey(),
+ oldCustomer.getCustomerNumber())), oldCustomer, newCustomer);
+
+ return Optional.empty();
+ }
+
+ return setCustomerNumberAction;
+ }
+
+ /**
+ * Compares the {@code externalId} values of a {@link Customer} and a {@link CustomerDraft}
+ * and returns an {@link Optional} of update action, which would contain the {@code "setExternalId"}
+ * {@link UpdateAction}. If both {@link Customer} and {@link CustomerDraft} have the same
+ * {@code externalId} values, then no update action is needed and empty optional will be returned.
+ *
+ * @param oldCustomer the customer that should be updated.
+ * @param newCustomer the customer draft that contains the new external id.
+ * @return optional containing update action or empty optional if external ids are identical.
+ */
+ @Nonnull
+ public static Optional> buildSetExternalIdUpdateAction(
+ @Nonnull final Customer oldCustomer,
+ @Nonnull final CustomerDraft newCustomer) {
+
+ return buildUpdateAction(oldCustomer.getExternalId(), newCustomer.getExternalId(),
+ () -> SetExternalId.of(newCustomer.getExternalId()));
+ }
+
+ /**
+ * Compares the {@code companyName} values of a {@link Customer} and a {@link CustomerDraft}
+ * and returns an {@link Optional} of update action, which would contain the {@code "setCompanyName"}
+ * {@link UpdateAction}. If both {@link Customer} and {@link CustomerDraft} have the same
+ * {@code companyName} values, then no update action is needed and empty optional will be returned.
+ *
+ * @param oldCustomer the customer that should be updated.
+ * @param newCustomer the customer draft that contains the new company name.
+ * @return optional containing update action or empty optional if company names are identical.
+ */
+ @Nonnull
+ public static Optional> buildSetCompanyNameUpdateAction(
+ @Nonnull final Customer oldCustomer,
+ @Nonnull final CustomerDraft newCustomer) {
+
+ return buildUpdateAction(oldCustomer.getCompanyName(), newCustomer.getCompanyName(),
+ () -> SetCompanyName.of(newCustomer.getCompanyName()));
+ }
+
+ /**
+ * Compares the {@code dateOfBirth} values of a {@link Customer} and a {@link CustomerDraft}
+ * and returns an {@link Optional} of update action, which would contain the {@code "setDateOfBirth"}
+ * {@link UpdateAction}. If both {@link Customer} and {@link CustomerDraft} have the same
+ * {@code dateOfBirth} values, then no update action is needed and empty optional will be returned.
+ *
+ * @param oldCustomer the customer that should be updated.
+ * @param newCustomer the customer draft that contains the new date of birth.
+ * @return optional containing update action or empty optional if dates of birth are identical.
+ */
+ @Nonnull
+ public static Optional> buildSetDateOfBirthUpdateAction(
+ @Nonnull final Customer oldCustomer,
+ @Nonnull final CustomerDraft newCustomer) {
+
+ return buildUpdateAction(oldCustomer.getDateOfBirth(), newCustomer.getDateOfBirth(),
+ () -> SetDateOfBirth.of(newCustomer.getDateOfBirth()));
+ }
+
+ /**
+ * Compares the {@code vatId} values of a {@link Customer} and a {@link CustomerDraft}
+ * and returns an {@link Optional} of update action, which would contain the {@code "setVatId"}
+ * {@link UpdateAction}. If both {@link Customer} and {@link CustomerDraft} have the same
+ * {@code vatId} values, then no update action is needed and empty optional will be returned.
+ *
+ * @param oldCustomer the customer that should be updated.
+ * @param newCustomer the customer draft that contains the new vat id.
+ * @return optional containing update action or empty optional if vat ids are identical.
+ */
+ @Nonnull
+ public static Optional> buildSetVatIdUpdateAction(
+ @Nonnull final Customer oldCustomer,
+ @Nonnull final CustomerDraft newCustomer) {
+
+ return buildUpdateAction(oldCustomer.getVatId(), newCustomer.getVatId(),
+ () -> SetVatId.of(newCustomer.getVatId()));
+ }
+
+ /**
+ * Compares the {@code locale} values of a {@link Customer} and a {@link CustomerDraft}
+ * and returns an {@link Optional} of update action, which would contain the {@code "setLocale"}
+ * {@link UpdateAction}. If both {@link Customer} and {@link CustomerDraft} have the same
+ * {@code locale} values, then no update action is needed and empty optional will be returned.
+ *
+ * @param oldCustomer the customer that should be updated.
+ * @param newCustomer the customer draft that contains the new locale.
+ * @return optional containing update action or empty optional if locales are identical.
+ */
+ @Nonnull
+ public static Optional> buildSetLocaleUpdateAction(
+ @Nonnull final Customer oldCustomer,
+ @Nonnull final CustomerDraft newCustomer) {
+
+ return buildUpdateAction(oldCustomer.getLocale(), newCustomer.getLocale(),
+ () -> SetLocale.of(newCustomer.getLocale()));
+ }
+
+ /**
+ * Compares the {@link CustomerGroup} references of an old {@link Customer} and
+ * new {@link CustomerDraft}. If they are different - return {@link SetCustomerGroup} update action.
+ *
+ * If the old value is set, but the new one is empty - the command will unset the customer group.
+ *
+ * @param oldCustomer the customer that should be updated.
+ * @param newCustomer the customer draft with new {@link CustomerGroup} reference.
+ * @return An optional with {@link SetCustomerGroup} update action.
+ */
+ @Nonnull
+ public static Optional> buildSetCustomerGroupUpdateAction(
+ @Nonnull final Customer oldCustomer,
+ @Nonnull final CustomerDraft newCustomer) {
+
+ return buildUpdateActionForReferences(oldCustomer.getCustomerGroup(), newCustomer.getCustomerGroup(),
+ () -> SetCustomerGroup.of(mapResourceIdentifierToReferenceable(newCustomer.getCustomerGroup())));
+ }
+
+ @Nullable
+ private static Referenceable mapResourceIdentifierToReferenceable(
+ @Nullable final ResourceIdentifier resourceIdentifier) {
+
+ if (resourceIdentifier == null) {
+ return null; // unset
+ }
+
+ // TODO (JVM-SDK), see: SUPPORT-10261 SetCustomerGroup could be created with a ResourceIdentifier
+ // https://github.com/commercetools/commercetools-jvm-sdk/issues/2072
+ return new ResourceImpl(null, null, null, null) {
+ @Override
+ public Reference toReference() {
+ return Reference.of(CustomerGroup.referenceTypeId(), resourceIdentifier.getId());
+ }
+ };
+ }
+
+ /**
+ * Compares the stores of a {@link Customer} and a {@link CustomerDraft}. It returns a {@link List} of
+ * {@link UpdateAction}<{@link Customer}> as a result. If no update action is needed, for example in
+ * case where both the {@link Customer} and the {@link CustomerDraft} have the identical stores, an empty
+ * {@link List} is returned.
+ *
+ * Note: Null values of the stores are filtered out.
+ *
+ * @param oldCustomer the customer which should be updated.
+ * @param newCustomer the customer draft where we get the new data.
+ * @return A list of customer store-related update actions.
+ */
+ @Nonnull
+ public static List> buildStoreUpdateActions(
+ @Nonnull final Customer oldCustomer,
+ @Nonnull final CustomerDraft newCustomer) {
+
+ final List> oldStores = oldCustomer.getStores();
+ final List> newStores = newCustomer.getStores();
+
+ return buildSetStoreUpdateAction(oldStores, newStores)
+ .map(Collections::singletonList)
+ .orElseGet(() -> prepareStoreActions(oldStores, newStores));
+ }
+
+ private static List> prepareStoreActions(
+ @Nullable final List> oldStores,
+ @Nullable final List> newStores) {
+
+ if (oldStores != null && newStores != null) {
+ final List> removeStoreUpdateActions =
+ buildRemoveStoreUpdateActions(oldStores, newStores);
+
+ final List> addStoreUpdateActions =
+ buildAddStoreUpdateActions(oldStores, newStores);
+
+ if (!removeStoreUpdateActions.isEmpty() && !addStoreUpdateActions.isEmpty()) {
+ return buildSetStoreUpdateAction(newStores)
+ .map(Collections::singletonList)
+ .orElseGet(Collections::emptyList);
+ }
+
+ return removeStoreUpdateActions.isEmpty() ? addStoreUpdateActions : removeStoreUpdateActions;
+ }
+
+ return emptyList();
+ }
+
+ /**
+ * Compares the {@link List} of {@link Store} {@link KeyReference}s and {@link Store} {@link ResourceIdentifier}s
+ * of a {@link CustomerDraft} and a {@link Customer}. It returns a {@link SetStores} update action as a result.
+ * If both the {@link Customer} and the {@link CustomerDraft} have the same set of stores, then no update actions
+ * are needed and hence an empty {@link List} is returned.
+ *
+ * Note: Null values of the stores are filtered out.
+ *
+ * @param oldStores the stores which should be updated.
+ * @param newStores the stores where we get the new store.
+ * @return A list containing the update actions or an empty list if the store references are identical.
+ */
+ @Nonnull
+ private static Optional> buildSetStoreUpdateAction(
+ @Nullable final List> oldStores,
+ @Nullable final List> newStores) {
+
+ if (oldStores != null && !oldStores.isEmpty()) {
+ if (newStores == null || newStores.isEmpty()) {
+ return Optional.of(SetStores.of(emptyList()));
+ }
+ } else if (newStores != null && !newStores.isEmpty()) {
+ return buildSetStoreUpdateAction(newStores);
+ }
+
+ return Optional.empty();
+ }
+
+ private static Optional> buildSetStoreUpdateAction(
+ @Nonnull final List> newStores) {
+
+ final List> stores =
+ newStores.stream()
+ .filter(Objects::nonNull)
+ .collect(toList());
+
+ if (!stores.isEmpty()) {
+ return Optional.of(SetStores.of(stores));
+ }
+
+ return Optional.empty();
+ }
+
+ /**
+ * Compares the {@link List} of {@link Store} {@link KeyReference}s and {@link Store} {@link ResourceIdentifier}s
+ * of a {@link CustomerDraft} and a {@link Customer}. It returns a {@link List} of {@link RemoveStore} update
+ * actions as a result, if the old store needs to be removed from a customer to have the same set of stores as
+ * the new customer. If both the {@link Customer} and the {@link CustomerDraft} have the same set of stores,
+ * then no update actions are needed and hence an empty {@link List} is returned.
+ *
+ * Note: Null values of the stores are filtered out.
+ *
+ * @param oldStores the stores which should be updated.
+ * @param newStores the stores where we get the new store.
+ * @return A list containing the update actions or an empty list if the store references are identical.
+ */
+ @Nonnull
+ public static List> buildRemoveStoreUpdateActions(
+ @Nonnull final List> oldStores,
+ @Nonnull final List> newStores) {
+
+ final Map> newStoreKeyToStoreMap =
+ newStores.stream()
+ .filter(Objects::nonNull)
+ .filter(storeResourceIdentifier -> storeResourceIdentifier.getKey() != null)
+ .collect(toMap(ResourceIdentifier::getKey, identity()));
+
+ return oldStores
+ .stream()
+ .filter(Objects::nonNull)
+ .filter(storeKeyReference -> !newStoreKeyToStoreMap.containsKey(storeKeyReference.getKey()))
+ .map(storeKeyReference -> RemoveStore.of(ResourceIdentifier.ofKey(storeKeyReference.getKey())))
+ .collect(toList());
+ }
+
+ /**
+ * Compares the {@link List} of {@link Store} {@link KeyReference}s and {@link Store} {@link ResourceIdentifier}s
+ * of a {@link CustomerDraft} and a {@link Customer}. It returns a {@link List} of {@link AddStore} update actions
+ * as a result, if the old store needs to be added to a customer to have the same set of stores as the new customer.
+ * If both the {@link Customer} and the {@link CustomerDraft} have the same set of stores, then no update actions
+ * are needed and hence an empty {@link List} is returned.
+ *
+ * Note: Null values of the stores are filtered out.
+ *
+ * @param oldStores the stores which should be updated.
+ * @param newStores the stores where we get the new store.
+ * @return A list containing the update actions or an empty list if the store references are identical.
+ */
+ @Nonnull
+ public static List> buildAddStoreUpdateActions(
+ @Nonnull final List> oldStores,
+ @Nonnull final List> newStores) {
+
+ final Map> oldStoreKeyToStoreMap =
+ oldStores.stream()
+ .filter(Objects::nonNull)
+ .collect(toMap(KeyReference::getKey, identity()));
+
+ return newStores
+ .stream()
+ .filter(Objects::nonNull)
+ .filter(storeResourceIdentifier -> !oldStoreKeyToStoreMap.containsKey(storeResourceIdentifier.getKey()))
+ .map(AddStore::of)
+ .collect(toList());
+ }
+
+ /**
+ * Compares the addresses of a {@link Customer} and a {@link CustomerDraft}. It returns a {@link List} of
+ * {@link UpdateAction}<{@link Customer}> as a result. If both the {@link Customer} and the
+ * {@link CustomerDraft} have the same set of addresses, then no update actions are needed and hence an empty
+ * {@link List} is returned.
+ *
+ * @param oldCustomer the customer which should be updated.
+ * @param newCustomer the customer draft where we get the new data.
+ * @return A list of customer address-related update actions.
+ */
+ @Nonnull
+ public static List> buildAllAddressUpdateActions(
+ @Nonnull final Customer oldCustomer,
+ @Nonnull final CustomerDraft newCustomer) {
+
+ final List> addressActions = new ArrayList<>();
+
+ final List> removeAddressActions =
+ buildRemoveAddressUpdateActions(oldCustomer, newCustomer);
+
+ addressActions.addAll(removeAddressActions);
+ addressActions.addAll(buildChangeAddressUpdateActions(oldCustomer, newCustomer));
+ addressActions.addAll(buildAddAddressUpdateActions(oldCustomer, newCustomer));
+
+ addressActions.addAll(
+ collectAndFilterRemoveShippingAndBillingActions(removeAddressActions, oldCustomer, newCustomer));
+
+ buildSetDefaultShippingAddressUpdateAction(oldCustomer, newCustomer).ifPresent(addressActions::add);
+ buildSetDefaultBillingAddressUpdateAction(oldCustomer, newCustomer).ifPresent(addressActions::add);
+
+ addressActions.addAll(buildAddShippingAddressUpdateActions(oldCustomer, newCustomer));
+ addressActions.addAll(buildAddBillingAddressUpdateActions(oldCustomer, newCustomer));
+
+ return addressActions;
+ }
+
+ @Nonnull
+ private static List> collectAndFilterRemoveShippingAndBillingActions(
+ @Nonnull final List> removeAddressActions,
+ @Nonnull final Customer oldCustomer,
+ @Nonnull final CustomerDraft newCustomer) {
+
+ /* An action combination like below will cause a bad request error in API, so we need to filter out
+ to avoid such cases:
+
+ {
+ "version": 1,
+ "actions": [
+ {
+ "action" : "removeAddress",
+ "addressId": "-FWSGZNy"
+ },
+ {
+ "action" : "removeBillingAddressId",
+ "addressId" : "-FWSGZNy"
+ }
+ ]
+ }
+
+ {
+ "statusCode": 400,
+ "message": "The customers billingAddressIds don't contain id '-FWSGZNy'.",
+ "errors": [
+ {
+ "code": "InvalidOperation",
+ "message": "The customers billingAddressIds don't contain id '-FWSGZNy'.",
+ "action": {
+ "action": "removeBillingAddressId",
+ "addressId": "-FWSGZNy"
+ },
+ "actionIndex": 2
+ }
+ ]
+ }
+ */
+ final Set addressIdsToRemove =
+ removeAddressActions.stream()
+ .map(customerUpdateAction -> (RemoveAddress)customerUpdateAction)
+ .map(RemoveAddress::getAddressId)
+ .collect(toSet());
+
+
+ final List> removeActions = new ArrayList<>();
+
+ removeActions.addAll(buildRemoveShippingAddressUpdateActions(oldCustomer, newCustomer)
+ .stream()
+ .map(customerUpdateAction -> (RemoveShippingAddressId)customerUpdateAction)
+ .filter(action -> !addressIdsToRemove.contains(action.getAddressId()))
+ .collect(toList()));
+
+ removeActions.addAll(buildRemoveBillingAddressUpdateActions(oldCustomer, newCustomer)
+ .stream()
+ .map(customerUpdateAction -> (RemoveBillingAddressId)customerUpdateAction)
+ .filter(action -> !addressIdsToRemove.contains(action.getAddressId()))
+ .collect(toList()));
+
+ return removeActions;
+ }
+
+ /**
+ * Compares the {@link List} of a {@link CustomerDraft#getAddresses()} and a {@link Customer#getAddresses()}.
+ * It returns a {@link List} of {@link RemoveAddress} update actions as a result, if the old address needs to be
+ * removed from the {@code oldCustomer} to have the same set of addresses as the {@code newCustomer}.
+ * If both the {@link Customer} and the {@link CustomerDraft} have the same set of addresses,
+ * then no update actions are needed and hence an empty {@link List} is returned.
+ *
+ * Notes:
+ *
Addresses are matching by their keys.
+ * Null values of the new addresses are filtered out.
+ * Address values without keys are filtered out.
+ *
+ * @param oldCustomer the customer which should be updated.
+ * @param newCustomer the customer draft where we get the new addresses.
+ * @return A list containing the update actions or an empty list if the addresses are identical.
+ */
+ @Nonnull
+ public static List> buildRemoveAddressUpdateActions(
+ @Nonnull final Customer oldCustomer,
+ @Nonnull final CustomerDraft newCustomer) {
+
+ if (oldCustomer.getAddresses().isEmpty()) {
+ return Collections.emptyList();
+ }
+
+ if (newCustomer.getAddresses() == null || newCustomer.getAddresses().isEmpty()) {
+
+ return oldCustomer.getAddresses()
+ .stream()
+ .map(address -> RemoveAddress.of(address.getId()))
+ .collect(Collectors.toList());
+ }
+
+ final Set newAddressKeys =
+ newCustomer.getAddresses()
+ .stream()
+ .filter(Objects::nonNull)
+ .filter(newAddress -> !isBlank(newAddress.getKey()))
+ .map(Address::getKey)
+ .collect(toSet());
+
+ return oldCustomer.getAddresses()
+ .stream()
+ .filter(oldAddress -> isBlank(oldAddress.getKey())
+ || !newAddressKeys.contains(oldAddress.getKey()))
+ .map(RemoveAddress::of)
+ .collect(toList());
+ }
+
+ /**
+ * Compares the {@link List} of a {@link CustomerDraft#getAddresses()} and a {@link Customer#getAddresses()}.
+ * It returns a {@link List} of {@link ChangeAddress} update actions as a result, if the old address needs to be
+ * changed/updated from the {@code oldCustomer} to have the same set of addresses as the {@code newCustomer}.
+ * If both the {@link Customer} and the {@link CustomerDraft} have the same set of addresses,
+ * then no update actions are needed and hence an empty {@link List} is returned.
+ *
+ * Notes:
+ *
Addresses are matching by their keys.
+ * Null values of the new addresses are filtered out.
+ * Address values without keys are filtered out.
+ *
+ * @param oldCustomer the customer which should be updated.
+ * @param newCustomer the customer draft where we get the new addresses.
+ * @return A list containing the update actions or an empty list if the addresses are identical.
+ */
+ @Nonnull
+ public static List> buildChangeAddressUpdateActions(
+ @Nonnull final Customer oldCustomer,
+ @Nonnull final CustomerDraft newCustomer) {
+
+ if (newCustomer.getAddresses() == null || newCustomer.getAddresses().isEmpty()) {
+ return Collections.emptyList();
+ }
+
+ final Map oldAddressKeyToAddressMap =
+ oldCustomer.getAddresses()
+ .stream()
+ .filter(address -> !isBlank(address.getKey()))
+ .collect(toMap(Address::getKey, identity()));
+
+ return newCustomer.getAddresses()
+ .stream()
+ .filter(Objects::nonNull)
+ .filter(newAddress -> !isBlank(newAddress.getKey()))
+ .filter(newAddress -> oldAddressKeyToAddressMap.containsKey(newAddress.getKey()))
+ .map(newAddress -> {
+ final Address oldAddress = oldAddressKeyToAddressMap.get(newAddress.getKey());
+ if (!newAddress.equalsIgnoreId(oldAddress)) {
+ return ChangeAddress.of(oldAddress.getId(), newAddress);
+ }
+ return null;
+ })
+ .filter(Objects::nonNull)
+ .collect(toList());
+ }
+
+ /**
+ * Compares the {@link List} of a {@link CustomerDraft#getAddresses()} and a {@link Customer#getAddresses()}.
+ * It returns a {@link List} of {@link AddAddress} update actions as a result, if the new address needs to be
+ * added to have the same set of addresses as the {@code newCustomer}.
+ * If both the {@link Customer} and the {@link CustomerDraft} have the same set of addresses,
+ * then no update actions are needed and hence an empty {@link List} is returned.
+ *
+ * Notes:
+ *
Addresses are matching by their keys.
+ * Null values of the new addresses are filtered out.
+ * Address values without keys are filtered out.
+ *
+ * @param oldCustomer the customer which should be updated.
+ * @param newCustomer the customer draft where we get the new addresses.
+ * @return A list containing the update actions or an empty list if the addresses are identical.
+ */
+ @Nonnull
+ public static List> buildAddAddressUpdateActions(
+ @Nonnull final Customer oldCustomer,
+ @Nonnull final CustomerDraft newCustomer) {
+
+ if (newCustomer.getAddresses() == null || newCustomer.getAddresses().isEmpty()) {
+ return Collections.emptyList();
+ }
+
+ final Map oldAddressKeyToAddressMap =
+ oldCustomer.getAddresses()
+ .stream()
+ .filter(address -> !isBlank(address.getKey()))
+ .collect(toMap(Address::getKey, identity()));
+
+ return newCustomer.getAddresses()
+ .stream()
+ .filter(Objects::nonNull)
+ .filter(newAddress -> !isBlank(newAddress.getKey()))
+ .filter(newAddress -> !oldAddressKeyToAddressMap.containsKey(newAddress.getKey()))
+ .map(AddAddress::of)
+ .collect(toList());
+ }
+
+ /**
+ * Compares the {@link Customer#getDefaultShippingAddress()} and {@link CustomerDraft#getDefaultShippingAddress()}.
+ * If they are different - return {@link SetDefaultShippingAddressWithKey} update action. If the old shipping
+ * address is set, but the new one is empty - the command will unset the default shipping address.
+ *
+ * @param oldCustomer the customer that should be updated.
+ * @param newCustomer the customer draft with new default shipping address.
+ * @return An optional with {@link SetDefaultShippingAddressWithKey} update action.
+ */
+ @Nonnull
+ public static Optional> buildSetDefaultShippingAddressUpdateAction(
+ @Nonnull final Customer oldCustomer,
+ @Nonnull final CustomerDraft newCustomer) {
+
+ final Address oldAddress = oldCustomer.getDefaultShippingAddress();
+ final String newAddressKey =
+ getAddressKeyAt(newCustomer.getAddresses(), newCustomer.getDefaultShippingAddress());
+
+ if (newAddressKey != null) {
+ if (oldAddress == null || !Objects.equals(oldAddress.getKey(), newAddressKey)) {
+ return Optional.of(SetDefaultShippingAddressWithKey.of(newAddressKey));
+ }
+ } else if (oldAddress != null) { // unset
+ return Optional.of(SetDefaultShippingAddressWithKey.of(null));
+ }
+
+ return Optional.empty();
+ }
+
+ /**
+ * Compares the {@link Customer#getDefaultBillingAddress()} and {@link CustomerDraft#getDefaultBillingAddress()}.
+ * If they are different - return {@link SetDefaultShippingAddressWithKey} update action. If the old billing address
+ * id value is set, but the new one is empty - the command will unset the default billing address.
+ *
+ * @param oldCustomer the customer that should be updated.
+ * @param newCustomer the customer draft with new default billing address.
+ * @return An optional with {@link SetDefaultShippingAddressWithKey} update action.
+ */
+ @Nonnull
+ public static Optional> buildSetDefaultBillingAddressUpdateAction(
+ @Nonnull final Customer oldCustomer,
+ @Nonnull final CustomerDraft newCustomer) {
+
+ final Address oldAddress = oldCustomer.getDefaultBillingAddress();
+ final String newAddressKey =
+ getAddressKeyAt(newCustomer.getAddresses(), newCustomer.getDefaultBillingAddress());
+
+ if (newAddressKey != null) {
+ if (oldAddress == null || !Objects.equals(oldAddress.getKey(), newAddressKey)) {
+ return Optional.of(SetDefaultBillingAddressWithKey.of(newAddressKey));
+ }
+ } else if (oldAddress != null) { // unset
+ return Optional.of(SetDefaultBillingAddressWithKey.of(null));
+ }
+
+ return Optional.empty();
+ }
+
+ @Nullable
+ private static String getAddressKeyAt(
+ @Nullable final List addressList,
+ @Nullable final Integer index) {
+
+ if (index == null) {
+ return null;
+ }
+
+ if (addressList == null || index < 0 || index >= addressList.size()) {
+ throw new IllegalArgumentException(
+ format("Addresses list does not contain an address at the index: %s", index));
+ }
+
+ final Address address = addressList.get(index);
+ if (address == null) {
+ throw new IllegalArgumentException(
+ format("Address is null at the index: %s of the addresses list.", index));
+ } else if (isBlank(address.getKey())) {
+ throw new IllegalArgumentException(
+ format("Address does not have a key at the index: %s of the addresses list.", index));
+ } else {
+ return address.getKey();
+ }
+ }
+
+ /**
+ * Compares the {@link List} of a {@link Customer#getShippingAddresses()} and a
+ * {@link CustomerDraft#getShippingAddresses()}. It returns a {@link List} of {@link AddShippingAddressIdWithKey}
+ * update actions as a result, if the new shipping address needs to be added to have the same set of addresses as
+ * the {@code newCustomer}. If both the {@link Customer} and the {@link CustomerDraft} have the same set of
+ * shipping addresses, then no update actions are needed and hence an empty {@link List} is returned.
+ *
+ * Notes:
+ *
Addresses are matching by their keys.
+ * Old address values without keys are filtered out.
+ * Each address in the new addresses list satisfies the following conditions:
+ *
+ * - It is not null
+ * - It has a key which is not blank (null/empty)
+ *
+ * Otherwise, a {@link IllegalArgumentException} will be thrown.
+ *
+ *
+ * @param oldCustomer the customer which should be updated.
+ * @param newCustomer the customer draft where we get the new shipping addresses.
+ * @return A list containing the update actions or an empty list if the shipping addresses are identical.
+ */
+ @Nonnull
+ public static List> buildAddShippingAddressUpdateActions(
+ @Nonnull final Customer oldCustomer,
+ @Nonnull final CustomerDraft newCustomer) {
+
+ if (newCustomer.getShippingAddresses() == null
+ || newCustomer.getShippingAddresses().isEmpty()) {
+ return Collections.emptyList();
+ }
+
+ final Map oldAddressKeyToAddressMap =
+ oldCustomer.getShippingAddresses()
+ .stream()
+ .filter(address -> !isBlank(address.getKey()))
+ .collect(toMap(Address::getKey, identity()));
+
+ final Set newAddressKeys =
+ newCustomer.getShippingAddresses()
+ .stream()
+ .map(index -> getAddressKeyAt(newCustomer.getAddresses(), index))
+ .collect(toSet());
+
+ return newAddressKeys
+ .stream()
+ .filter(newAddressKey -> !oldAddressKeyToAddressMap.containsKey(newAddressKey))
+ .map(AddShippingAddressIdWithKey::of)
+ .collect(toList());
+ }
+
+ /**
+ * Compares the {@link List} of a {@link Customer#getShippingAddresses()} and a
+ * {@link CustomerDraft#getShippingAddresses()}. It returns a {@link List} of {@link RemoveShippingAddressId}
+ * update actions as a result, if the old shipping address needs to be removed to have the same set of addresses as
+ * the {@code newCustomer}. If both the {@link Customer} and the {@link CustomerDraft} have the same set of
+ * shipping addresses, then no update actions are needed and hence an empty {@link List} is returned.
+ *
+ *
+ * Notes:
+ *
Addresses are matching by their keys.
+ * Old shipping addresses without keys will be removed.
+ * Each address in the new addresses list satisfies the following conditions:
+ *
+ * - It exists in the given index.
+ * - It has a key which is not blank (null/empty)
+ *
+ * Otherwise, a {@link IllegalArgumentException} will be thrown.
+ *
+ *
+ * @param oldCustomer the customer which should be updated.
+ * @param newCustomer the customer draft where we get the new shipping addresses.
+ * @return A list containing the update actions or an empty list if the shipping addresses are identical.
+ */
+ @Nonnull
+ public static List> buildRemoveShippingAddressUpdateActions(
+ @Nonnull final Customer oldCustomer,
+ @Nonnull final CustomerDraft newCustomer) {
+
+ if ((oldCustomer.getShippingAddresses() == null
+ || oldCustomer.getShippingAddresses().isEmpty())) {
+
+ return Collections.emptyList();
+ }
+
+ if (newCustomer.getShippingAddresses() == null
+ || newCustomer.getShippingAddresses().isEmpty()) {
+
+ return oldCustomer.getShippingAddresses()
+ .stream()
+ .map(address -> RemoveShippingAddressId.of(address.getId()))
+ .collect(Collectors.toList());
+ }
+
+ final Set newAddressKeys =
+ newCustomer.getShippingAddresses()
+ .stream()
+ .map(index -> getAddressKeyAt(newCustomer.getAddresses(), index))
+ .collect(toSet());
+
+ return oldCustomer.getShippingAddresses()
+ .stream()
+ .filter(address -> isBlank(address.getKey()) || !newAddressKeys.contains(address.getKey()))
+ .map(address -> RemoveShippingAddressId.of(address.getId()))
+ .collect(toList());
+ }
+
+ /**
+ * Compares the {@link List} of a {@link Customer#getBillingAddresses()} and a
+ * {@link CustomerDraft#getBillingAddresses()}. It returns a {@link List} of {@link AddBillingAddressIdWithKey}
+ * update actions as a result, if the new billing address needs to be added to have the same set of addresses as
+ * the {@code newCustomer}. If both the {@link Customer} and the {@link CustomerDraft} have the same set of
+ * billing addresses, then no update actions are needed and hence an empty {@link List} is returned.
+ *
+ * Notes:
+ *
Addresses are matching by their keys.
+ * Old address values without keys are filtered out.
+ * Each address in the new addresses list satisfies the following conditions:
+ *
+ * - It is not null
+ * - It has a key which is not blank (null/empty)
+ *
+ * Otherwise, a {@link IllegalArgumentException} will be thrown.
+ *
+ *
+ * @param oldCustomer the customer which should be updated.
+ * @param newCustomer the customer draft where we get the new billing addresses.
+ * @return A list containing the update actions or an empty list if the billing addresses are identical.
+ */
+ @Nonnull
+ public static List> buildAddBillingAddressUpdateActions(
+ @Nonnull final Customer oldCustomer,
+ @Nonnull final CustomerDraft newCustomer) {
+
+ if (newCustomer.getBillingAddresses() == null
+ || newCustomer.getBillingAddresses().isEmpty()) {
+ return Collections.emptyList();
+ }
+
+ final Map oldAddressKeyToAddressMap =
+ oldCustomer.getBillingAddresses()
+ .stream()
+ .filter(address -> !isBlank(address.getKey()))
+ .collect(toMap(Address::getKey, identity()));
+
+
+ final Set newAddressKeys =
+ newCustomer.getBillingAddresses()
+ .stream()
+ .map(index -> getAddressKeyAt(newCustomer.getAddresses(), index))
+ .collect(toSet());
+
+ return newAddressKeys
+ .stream()
+ .filter(newAddressKey -> !oldAddressKeyToAddressMap.containsKey(newAddressKey))
+ .map(AddBillingAddressIdWithKey::of)
+ .collect(toList());
+ }
+
+ /**
+ * Compares the {@link List} of a {@link Customer#getBillingAddresses()} and a
+ * {@link CustomerDraft#getBillingAddresses()}. It returns a {@link List} of {@link RemoveBillingAddressId}
+ * update actions as a result, if the old billing address needs to be removed to have the same set of addresses as
+ * the {@code newCustomer}. If both the {@link Customer} and the {@link CustomerDraft} have the same set of
+ * billing addresses, then no update actions are needed and hence an empty {@link List} is returned.
+ *
+ * Notes:
+ *
Addresses are matching by their keys.
+ * Null values of the old addresses are filtered out.
+ * Old shipping address values without keys are filtered out.
+ * Each address in the new addresses list satisfies the following conditions:
+ *
+ * - It exists in the given index.
+ * - It has a key which is not blank (null/empty)
+ *
+ * Otherwise, a {@link IllegalArgumentException} will be thrown.
+ *
+ *
+ * @param oldCustomer the customer which should be updated.
+ * @param newCustomer the customer draft where we get the new shipping addresses.
+ * @return A list containing the update actions or an empty list if the shipping addresses are identical.
+ */
+ @Nonnull
+ public static List> buildRemoveBillingAddressUpdateActions(
+ @Nonnull final Customer oldCustomer,
+ @Nonnull final CustomerDraft newCustomer) {
+
+ if ((oldCustomer.getBillingAddresses() == null
+ || oldCustomer.getBillingAddresses().isEmpty())) {
+
+ return Collections.emptyList();
+ }
+
+ if (newCustomer.getBillingAddresses() == null
+ || newCustomer.getBillingAddresses().isEmpty()) {
+
+ return oldCustomer.getBillingAddresses()
+ .stream()
+ .map(address -> RemoveBillingAddressId.of(address.getId()))
+ .collect(Collectors.toList());
+ }
+
+ final Set newAddressKeys =
+ newCustomer.getBillingAddresses()
+ .stream()
+ .map(index -> getAddressKeyAt(newCustomer.getAddresses(), index))
+ .collect(toSet());
+
+ return oldCustomer.getBillingAddresses()
+ .stream()
+ .filter(address -> isBlank(address.getKey()) || !newAddressKeys.contains(address.getKey()))
+ .map(address -> RemoveBillingAddressId.of(address.getId()))
+ .collect(toList());
+ }
+}
diff --git a/src/main/java/com/commercetools/sync/customobjects/helpers/CustomObjectSyncStatistics.java b/src/main/java/com/commercetools/sync/customobjects/helpers/CustomObjectSyncStatistics.java
index 3950da3c1c..0374dc47b1 100644
--- a/src/main/java/com/commercetools/sync/customobjects/helpers/CustomObjectSyncStatistics.java
+++ b/src/main/java/com/commercetools/sync/customobjects/helpers/CustomObjectSyncStatistics.java
@@ -2,8 +2,6 @@
import com.commercetools.sync.commons.helpers.BaseSyncStatistics;
-import static java.lang.String.format;
-
public class CustomObjectSyncStatistics extends BaseSyncStatistics {
/**
* Builds a summary of the custom object sync statistics instance that looks like the following example:
@@ -14,10 +12,6 @@ public class CustomObjectSyncStatistics extends BaseSyncStatistics {
*/
@Override
public String getReportMessage() {
- reportMessage = format(
- "Summary: %s custom objects were processed in total (%s created, %s updated and %s failed to sync).",
- getProcessed(), getCreated(), getUpdated(), getFailed());
-
- return reportMessage;
+ return getDefaultReportMessageForResource("custom objects");
}
}
diff --git a/src/main/java/com/commercetools/sync/inventories/helpers/InventorySyncStatistics.java b/src/main/java/com/commercetools/sync/inventories/helpers/InventorySyncStatistics.java
index 964fa803af..af74d57274 100644
--- a/src/main/java/com/commercetools/sync/inventories/helpers/InventorySyncStatistics.java
+++ b/src/main/java/com/commercetools/sync/inventories/helpers/InventorySyncStatistics.java
@@ -2,8 +2,6 @@
import com.commercetools.sync.commons.helpers.BaseSyncStatistics;
-import static java.lang.String.format;
-
public class InventorySyncStatistics extends BaseSyncStatistics {
public InventorySyncStatistics() {
@@ -20,9 +18,6 @@ public InventorySyncStatistics() {
*/
@Override
public String getReportMessage() {
- reportMessage = format("Summary: %s inventory entries were processed in total "
- + "(%s created, %s updated and %s failed to sync).",
- getProcessed(), getCreated(), getUpdated(), getFailed());
- return reportMessage;
+ return getDefaultReportMessageForResource("inventory entries");
}
}
diff --git a/src/main/java/com/commercetools/sync/products/helpers/ProductSyncStatistics.java b/src/main/java/com/commercetools/sync/products/helpers/ProductSyncStatistics.java
index 1383d99217..a288435af8 100644
--- a/src/main/java/com/commercetools/sync/products/helpers/ProductSyncStatistics.java
+++ b/src/main/java/com/commercetools/sync/products/helpers/ProductSyncStatistics.java
@@ -38,10 +38,9 @@ public class ProductSyncStatistics extends BaseSyncStatistics {
*/
@Override
public String getReportMessage() {
- reportMessage = format("Summary: %s product(s) were processed in total "
+ return format("Summary: %s product(s) were processed in total "
+ "(%s created, %s updated, %s failed to sync and %s product(s) with missing reference(s)).",
getProcessed(), getCreated(), getUpdated(), getFailed(), getNumberOfProductsWithMissingParents());
- return reportMessage;
}
/**
diff --git a/src/main/java/com/commercetools/sync/producttypes/helpers/ProductTypeSyncStatistics.java b/src/main/java/com/commercetools/sync/producttypes/helpers/ProductTypeSyncStatistics.java
index 362f6af413..d7ceaf3698 100644
--- a/src/main/java/com/commercetools/sync/producttypes/helpers/ProductTypeSyncStatistics.java
+++ b/src/main/java/com/commercetools/sync/producttypes/helpers/ProductTypeSyncStatistics.java
@@ -72,14 +72,12 @@ public ProductTypeSyncStatistics() {
*/
@Override
public String getReportMessage() {
- reportMessage = format(
+ return format(
"Summary: %s product types were processed in total (%s created, %s updated, %s failed to sync"
+ " and %s product types with at least one NestedType or a Set of NestedType attribute definition(s)"
+ " referencing a missing product type).",
getProcessed(), getCreated(), getUpdated(), getFailed(),
getNumberOfProductTypesWithMissingNestedProductTypes());
-
- return reportMessage;
}
/**
diff --git a/src/main/java/com/commercetools/sync/services/CustomerService.java b/src/main/java/com/commercetools/sync/services/CustomerService.java
new file mode 100644
index 0000000000..cfbc6af398
--- /dev/null
+++ b/src/main/java/com/commercetools/sync/services/CustomerService.java
@@ -0,0 +1,106 @@
+package com.commercetools.sync.services;
+
+import io.sphere.sdk.client.SphereClient;
+import io.sphere.sdk.commands.UpdateAction;
+import io.sphere.sdk.customers.Customer;
+import io.sphere.sdk.customers.CustomerDraft;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.concurrent.CompletionStage;
+
+public interface CustomerService {
+
+ /**
+ * Given a set of keys this method caches in-memory a mapping of key -> id only for those keys which are not
+ * already cached.
+ *
+ * @param keysToCache a set of keys to cache.
+ * @return a map of key to ids of the requested keys.
+ */
+ @Nonnull
+ CompletionStage