/*
* Copyright (C) 2015 Red Hat, Inc. and/or its affiliates.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jboss.errai.jpa.sync.client.local;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import javax.annotation.PostConstruct;
import javax.inject.Inject;
import javax.persistence.NamedQuery;
import javax.persistence.TypedQuery;
import org.jboss.errai.bus.client.api.BusErrorCallback;
import org.jboss.errai.bus.client.api.messaging.Message;
import org.jboss.errai.common.client.api.Assert;
import org.jboss.errai.common.client.api.Caller;
import org.jboss.errai.common.client.api.ErrorCallback;
import org.jboss.errai.common.client.api.RemoteCallback;
import org.jboss.errai.common.client.util.CreationalCallback;
import org.jboss.errai.ioc.client.api.EntryPoint;
import org.jboss.errai.ioc.client.container.IOC;
import org.jboss.errai.ioc.client.container.RefHolder;
import org.jboss.errai.jpa.client.local.ErraiEntityManager;
import org.jboss.errai.jpa.client.local.ErraiIdGenerator;
import org.jboss.errai.jpa.client.local.ErraiIdentifiableType;
import org.jboss.errai.jpa.client.local.ErraiSingularAttribute;
import org.jboss.errai.jpa.client.local.Key;
import org.jboss.errai.jpa.client.local.backend.StorageBackend;
import org.jboss.errai.jpa.client.local.backend.StorageBackendFactory;
import org.jboss.errai.jpa.client.local.backend.WebStorageBackend;
import org.jboss.errai.jpa.sync.client.shared.ConflictResponse;
import org.jboss.errai.jpa.sync.client.shared.DataSyncService;
import org.jboss.errai.jpa.sync.client.shared.DeleteResponse;
import org.jboss.errai.jpa.sync.client.shared.EntityComparator;
import org.jboss.errai.jpa.sync.client.shared.IdChangeResponse;
import org.jboss.errai.jpa.sync.client.shared.JpaAttributeAccessor;
import org.jboss.errai.jpa.sync.client.shared.NewRemoteEntityResponse;
import org.jboss.errai.jpa.sync.client.shared.SyncRequestOperation;
import org.jboss.errai.jpa.sync.client.shared.SyncResponse;
import org.jboss.errai.jpa.sync.client.shared.SyncableDataSet;
import org.jboss.errai.jpa.sync.client.shared.UpdateResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* The main contact point for applications that want to initiate data sync
* operations from the client side of an Errai application.
*
* @author Jonathan Fuerth <jfuerth@redhat.com>
*/
@EntryPoint
public class ClientSyncManager {
private static final Logger logger = LoggerFactory.getLogger(ClientSyncManager.class);
protected static final ErrorCallback<?> DEFAULT_ERROR_CALLBACK = new BusErrorCallback() {
@Override
public boolean error(Message message, Throwable throwable) {
logger.error("Encountered error during data sync. The application did not provide its own error handler.", throwable);
return true;
}
};
/**
* Three puppies were maimed in the creation of this field.
*/
private static ClientSyncManager INSTANCE;
/**
* Temporarily public so we can override the caller from within the tests. Will find a better way in the future!
*/
public @Inject Caller<DataSyncService> dataSyncService;
/**
* This is the entity manager that client code interacts with. From a data
* sync point of view, it contains the "desired state" of the entities.
*/
@Inject private ErraiEntityManager desiredStateEm;
/**
* This entity manager tracks the state of entities according to the most
* recent information we've received from the server side. From a data sync
* point of view, it contains the "expected state" of the entities we're
* trying to update.
*/
private ErraiEntityManager expectedStateEm;
/**
* The entity comparator that detects differences between desired state and expected state.
*/
private EntityComparator entityComparator;
/**
* The attribute accessor for reading and writing attribute values in JPA
* entities. Since this is a client-side class, this is always an
* ErraiAttributeAccessor.
*/
private final JpaAttributeAccessor attributeAccessor = new ErraiAttributeAccessor();
///**
// * These are all the data sets we're currently keeping in sync.
// */
//private final List<SyncableDataSet<?>> activeSyncSets = new ArrayList<SyncableDataSet<?>>();
/**
* If true, there is a pending sync request sent to the server.
*/
private boolean syncInProgress;
/**
* Returns the global instance of ClientSyncManager.
*/
public static ClientSyncManager getInstance() {
if (INSTANCE == null) {
final RefHolder<ClientSyncManager> manager = new RefHolder<ClientSyncManager>();
IOC.getAsyncBeanManager().lookupBean(ClientSyncManager.class).getInstance(
new CreationalCallback<ClientSyncManager>() {
@Override
public void callback(ClientSyncManager beanInstance) {
manager.set(beanInstance);
}
});
// The assumption here is that the ClientSyncManager will never be declared as an async bean
Assert.notNull("Failed to lookup instance of ClientSyncManager synchronously!", manager.get());
INSTANCE = manager.get();
}
return INSTANCE;
}
/**
* Resets the global instance of ClientSyncManager.
*/
public static void resetInstance() {
INSTANCE = null;
}
@PostConstruct
private void setup() {
expectedStateEm = new ErraiEntityManager(desiredStateEm, new StorageBackendFactory() {
@Override
public StorageBackend createInstanceFor(ErraiEntityManager em) {
return new WebStorageBackend(em, "expected-state:");
}
});
entityComparator = new EntityComparator(desiredStateEm.getMetamodel(), attributeAccessor);
}
/**
* Performs a "cold" synchronization on the results of the given query with the given parameters.
* After a successful synchronization, both the expected state and desired state entity managers
* will yield the same results as the server-side entity manager does for the given query with the
* given set of parameters.
*
* @param queryName
* The name of a JPA named query. This query must be defined in a {@link NamedQuery}
* annotation that is visible to both the client and server applications. This usually
* means it is defined on an entity in the <code>shared</code> package.
* @param queryResultType
* The result type returned by the query. Must be a JPA entity type known to both the
* client and server applications.
* @param queryParams
* The name-value pairs to use for filling in the named parameters in the query.
* @param onCompletion
* Called when the data sync response has been received from the server, and the sync
* response operations have been applied to the expected state and desired state entity
* managers. Must not be null. In case of conflicts, the original client values are
* available in the list of SyncResponse objects, which gives you a chance to implement a
* different conflict resolution policy.
* @param onError
* Called when the data sync fails: either because the remote service threw an exception,
* or because of a communication error. Can be null, in which case the default error
* handling for the {@code Caller<DataSyncService>} will apply.
*/
public <E> void coldSync(
String queryName, Class<E> queryResultType, Map<String, Object> queryParams,
final RemoteCallback<List<SyncResponse<E>>> onCompletion,
final ErrorCallback<?> onError) {
if (syncInProgress) {
throw new IllegalStateException("A data sync operation is already in progress");
}
syncInProgress = true;
final TypedQuery<E> query = desiredStateEm.createNamedQuery(queryName, queryResultType);
final TypedQuery<E> expectedQuery = expectedStateEm.createNamedQuery(queryName, queryResultType);
for (Map.Entry<String, Object> param : queryParams.entrySet()) {
query.setParameter(param.getKey(), param.getValue());
expectedQuery.setParameter(param.getKey(), param.getValue());
}
final Map<Key<E, Object>, E> expectedResults = new HashMap<Key<E, Object>, E>();
for (E expectedState : expectedQuery.getResultList()) {
expectedResults.put((Key<E, Object>) expectedStateEm.keyFor(expectedState), expectedState);
}
final List<SyncRequestOperation<E>> syncRequests = new ArrayList<SyncRequestOperation<E>>();
for (E desiredState : query.getResultList()) {
Key<E, ?> key = desiredStateEm.keyFor(desiredState);
E expectedState = expectedResults.remove(key);
if (expectedState == null) {
syncRequests.add(SyncRequestOperation.created(desiredState));
}
else if (entityComparator.isDifferent(desiredState, expectedState)) {
syncRequests.add(SyncRequestOperation.updated(desiredState, expectedState));
}
else /* desiredState == expectedState */ {
syncRequests.add(SyncRequestOperation.unchanged(expectedState));
}
}
for (Map.Entry<Key<E, Object>, E> remainingEntry : expectedResults.entrySet()) {
syncRequests.add(SyncRequestOperation.deleted(remainingEntry.getValue()));
}
System.out.println("Sending sync requests:");
for (SyncRequestOperation<?> sro : syncRequests) {
System.out.println(" " + sro);
}
final SyncableDataSet<E> syncSet = SyncableDataSet.from(queryName, queryResultType, queryParams);
RemoteCallback<List<SyncResponse<E>>> onSuccess = new RemoteCallback<List<SyncResponse<E>>>() {
@Override
public void callback(List<SyncResponse<E>> syncResponse) {
try {
applyResults(syncResponse);
}
finally {
syncInProgress = false;
}
onCompletion.callback(syncResponse);
}
};
@SuppressWarnings("rawtypes")
ErrorCallback errorCallback = new ErrorCallback() {
@SuppressWarnings("unchecked")
@Override
public boolean error(Object message, Throwable throwable) {
syncInProgress = false;
ErrorCallback rawOnError = onError == null ? DEFAULT_ERROR_CALLBACK : onError;
return rawOnError.error(message, throwable);
}
};
dataSyncService.call(onSuccess, errorCallback).coldSync(syncSet, syncRequests);
}
/**
* Returns true if a sync request has been sent to the server for which no
* response or error has yet been received; false if no sync operation is
* currently pending. If this method returns true, a call to
* {@link #coldSync(String, Class, Map, RemoteCallback, ErrorCallback)} will
* fail immediately with an IllegalStateException.
*/
public boolean isSyncInProgress() {
return syncInProgress;
}
/**
* Clears the sync in progress flag, to allow future sync operations.
* Calling this method does not actually cancel an active sync.
* This typically should only be called after a network failure when there
* a sync operation has actually failed, but there is not chance that the
* ErrorCallback will actually be called. Reference [Errai-872] for
* more information.
*/
public void clearSyncInProgress()
{
syncInProgress = false;
}
/**
* Performs operations on the desired and expected state entity managers to
* reconcile them with the new information in the given sync response
* operations.
*
* @param syncResponses
* a list of sync response operations that was received from the
* server.
*/
private <E> void applyResults(List<SyncResponse<E>> syncResponses) {
// XXX could we factor this decision tree into apply() methods on the sync response objects?
for (SyncResponse<E> response : syncResponses) {
System.out.println("SSS Handling Sync response " + response.getClass());
if (response instanceof ConflictResponse) {
ConflictResponse<E> cr = (ConflictResponse<E>) response;
E actualNew = cr.getActualNew();
E requestedNew = cr.getRequestedNew();
System.out.println("Got a conflict for " + actualNew);
System.out.println(" was: " + cr.getExpected());
System.out.println(" wanted: " + requestedNew);
System.out.println(" ... accepting server's version of reality for now");
if (actualNew == null) {
E resolved = expectedStateEm.find(expectedStateEm.keyFor(requestedNew), Collections.<String,Object>emptyMap());
expectedStateEm.remove(resolved);
resolved = desiredStateEm.find(desiredStateEm.keyFor(requestedNew), Collections.<String,Object>emptyMap());
desiredStateEm.remove(resolved);
}
else {
expectedStateEm.merge(actualNew);
desiredStateEm.merge(actualNew);
}
// TODO (need transaction support in client)
// desiredStateEm.getTransaction().setRollbackOnly();
// expectedStateEm.merge(cr.getActualNew());
// throw new RuntimeException("TODO: notify conflict listeners");
}
else if (response instanceof DeleteResponse) {
DeleteResponse<E> dr = (DeleteResponse<E>) response;
System.out.println(" -> Delete " + dr.getEntity());
E resolved = expectedStateEm.find(expectedStateEm.keyFor(dr.getEntity()), Collections.<String,Object>emptyMap());
expectedStateEm.remove(resolved);
expectedStateEm.detach(resolved);
// the DeleteResponse could be a reaction to our own delete request, in which case resolved == null
resolved = desiredStateEm.find(desiredStateEm.keyFor(dr.getEntity()), Collections.<String,Object>emptyMap());
if (resolved != null) {
desiredStateEm.remove(resolved);
desiredStateEm.detach(resolved);
}
}
else if (response instanceof IdChangeResponse) {
IdChangeResponse<E> icr = (IdChangeResponse<E>) response;
System.out.println(" -> ID Change from " + icr.getOldId() + " to " + icr.getEntity());
E newEntity = icr.getEntity();
newEntity = expectedStateEm.merge(newEntity);
@SuppressWarnings("unchecked")
ErraiIdentifiableType<E> entityType = desiredStateEm.getMetamodel().entity((Class<E>) newEntity.getClass());
ErraiSingularAttribute<? super E, Object> idAttr = entityType.getId(Object.class);
changeId(entityType, icr.getOldId(), idAttr.get(newEntity));
desiredStateEm.merge(newEntity);
}
else if (response instanceof NewRemoteEntityResponse) {
NewRemoteEntityResponse<E> nrer = (NewRemoteEntityResponse<E>) response;
System.out.println(" -> New " + nrer.getEntity());
@SuppressWarnings("unchecked")
Class<E> entityClass = (Class<E>) nrer.getEntity().getClass();
Key<E, ?> conflictingKey = desiredStateEm.keyFor(nrer.getEntity());
E inTheWay = desiredStateEm.find(conflictingKey, Collections.<String,Object>emptyMap());
if (inTheWay != null) {
ErraiIdentifiableType<E> entityType = desiredStateEm.getMetamodel().entity(entityClass);
ErraiSingularAttribute<? super E, Object> idAttr = entityType.getId(Object.class);
ErraiIdGenerator<Object> idGenerator = idAttr.getValueGenerator();
if (idGenerator != null && idGenerator.hasNext(desiredStateEm)) {
Object newLocalId = idGenerator.next(desiredStateEm);
changeId(entityType, conflictingKey.getId(), newLocalId);
}
else {
throw new IllegalStateException(
"New entity from server would clobber local entity with same id, and we are unable to generate a new ID." +
" Conflict is for: " + conflictingKey);
}
}
// these are really "persist" operations, but using merge so each entity manager gets its own instance
expectedStateEm.merge(nrer.getEntity());
desiredStateEm.merge(nrer.getEntity());
}
else if (response instanceof UpdateResponse) {
UpdateResponse<E> ur = (UpdateResponse<E>) response;
System.out.println(" -> Update " + ur.getEntity());
expectedStateEm.merge(ur.getEntity());
desiredStateEm.merge(ur.getEntity());
}
else {
throw new RuntimeException("Unexpected kind of sync response: " + response);
}
}
}
/**
* Changes the ID of an existing entity in desiredStateEm.
*
* @param entityType The metamodel type for the entity whose ID is to be changed
* @param oldId The ID that the entity currently has.
* @param newId The ID that the entity will have when this method returns.
*/
private <E> void changeId(ErraiIdentifiableType<E> entityType, Object oldId, Object newId) {
// XXX this routine is probably better handled internally by the ErraiEntityManager
// TODO what about related entities that refer to this one? (needs tests)
E entity = desiredStateEm.find(entityType.getJavaType(), oldId);
desiredStateEm.remove(entity);
desiredStateEm.flush();
desiredStateEm.detach(entity);
entityType.getId(Object.class).set(entity, newId);
desiredStateEm.persist(entity);
}
/**
* Returns the persistence context that holds the "expected state" of this
* Client-side Sync Manager (the state that we believe the entities have on
* the server). This method exists mostly to promote testability, and is
* rarely needed by applications at runtime.
*/
public ErraiEntityManager getExpectedStateEm() {
return expectedStateEm;
}
/**
* Returns the persistence context that holds the "desired state" of this
* Client-side Sync Manager (the state that the application has set up, which
* we will eventually sync to the server). This method exists mostly to
* promote testability, and is rarely needed by applications at runtime.
*/
public ErraiEntityManager getDesiredStateEm() {
return desiredStateEm;
}
/**
* Clears all expected state and actual state data (essentially wiping out all
* localStorage data that Errai cares about). This operation will destroy any
* local data that has not been synced to the server, including data that the
* sync manager was never told about.
*/
public void clear() {
desiredStateEm.removeAll();
expectedStateEm.removeAll();
}
}