/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you 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.apache.isis.core.runtime.system.transaction;
import java.sql.Timestamp;
import java.util.*;
import java.util.Map.Entry;
import com.google.common.base.Function;
import com.google.common.base.Objects;
import com.google.common.base.Predicate;
import com.google.common.base.Predicates;
import com.google.common.collect.Iterables;
import com.google.common.collect.Lists;
import com.google.common.collect.Maps;
import com.google.common.collect.Sets;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.apache.isis.applib.Identifier;
import org.apache.isis.applib.RecoverableException;
import org.apache.isis.applib.annotation.Bulk;
import org.apache.isis.applib.annotation.PublishedAction;
import org.apache.isis.applib.annotation.PublishedObject;
import org.apache.isis.applib.annotation.PublishedObject.ChangeKind;
import org.apache.isis.applib.clock.Clock;
import org.apache.isis.applib.services.audit.AuditingService3;
import org.apache.isis.applib.services.bookmark.Bookmark;
import org.apache.isis.applib.services.command.Command;
import org.apache.isis.applib.services.command.Command2;
import org.apache.isis.applib.services.command.CommandContext;
import org.apache.isis.applib.services.command.spi.CommandService;
import org.apache.isis.applib.services.eventbus.ActionInteractionEvent;
import org.apache.isis.applib.services.publish.*;
import org.apache.isis.core.commons.authentication.AuthenticationSession;
import org.apache.isis.core.commons.components.TransactionScopedComponent;
import org.apache.isis.core.commons.config.IsisConfiguration;
import org.apache.isis.core.commons.ensure.Ensure;
import org.apache.isis.core.commons.exceptions.IsisException;
import org.apache.isis.core.commons.util.ToString;
import org.apache.isis.core.metamodel.adapter.ObjectAdapter;
import org.apache.isis.core.metamodel.adapter.ResolveState;
import org.apache.isis.core.metamodel.adapter.mgr.AdapterManager;
import org.apache.isis.core.metamodel.adapter.oid.Oid;
import org.apache.isis.core.metamodel.adapter.oid.OidMarshaller;
import org.apache.isis.core.metamodel.adapter.oid.RootOid;
import org.apache.isis.core.metamodel.facetapi.IdentifiedHolder;
import org.apache.isis.core.metamodel.facets.actions.interaction.ActionInvocationFacet;
import org.apache.isis.core.metamodel.facets.actions.interaction.ActionInvocationFacet.CurrentInvocation;
import org.apache.isis.core.metamodel.facets.actions.interaction.CommandUtil;
import org.apache.isis.core.metamodel.facets.actions.publish.PublishedActionFacet;
import org.apache.isis.core.metamodel.facets.object.audit.AuditableFacet;
import org.apache.isis.core.metamodel.facets.object.encodeable.EncodableFacet;
import org.apache.isis.core.metamodel.facets.object.publishedobject.PublishedObjectFacet;
import org.apache.isis.core.metamodel.runtimecontext.RuntimeContext.TransactionState;
import org.apache.isis.core.metamodel.runtimecontext.ServicesInjector;
import org.apache.isis.core.metamodel.spec.feature.Contributed;
import org.apache.isis.core.metamodel.spec.feature.ObjectAssociation;
import org.apache.isis.core.runtime.persistence.ObjectPersistenceException;
import org.apache.isis.core.runtime.persistence.PersistenceConstants;
import org.apache.isis.core.runtime.persistence.objectstore.transaction.*;
import org.apache.isis.core.runtime.services.eventbus.EventBusServiceDefault;
import org.apache.isis.core.runtime.system.context.IsisContext;
import static org.apache.isis.core.commons.ensure.Ensure.ensureThatArg;
import static org.apache.isis.core.commons.ensure.Ensure.ensureThatState;
import static org.hamcrest.CoreMatchers.nullValue;
import static org.hamcrest.Matchers.is;
import static org.hamcrest.Matchers.not;
/**
* Used by the {@link IsisTransactionManager} to captures a set of changes to be
* applied.
*
* <p>
* Note that methods such as <tt>flush()</tt>, <tt>commit()</tt> and
* <tt>abort()</tt> are not part of the API. The place to control transactions
* is through the {@link IsisTransactionManager transaction manager}, because
* some implementations may support nesting and such like. It is also the job of
* the {@link IsisTransactionManager} to ensure that the underlying persistence
* mechanism (for example, the <tt>ObjectStore</tt>) is also committed.
*/
public class IsisTransaction implements TransactionScopedComponent {
public static final Predicate<ObjectAdapter> IS_COMMAND = new Predicate<ObjectAdapter>() {
@Override
public boolean apply(ObjectAdapter input) {
return Command.class.isAssignableFrom(input.getSpecification().getCorrespondingClass());
}
};
public static enum State {
/**
* Started, still in progress.
*
* <p>
* May {@link IsisTransaction#flush() flush},
* {@link IsisTransaction#commit() commit} or
* {@link IsisTransaction#markAsAborted() abort}.
*/
IN_PROGRESS(TransactionState.IN_PROGRESS),
/**
* Started, but has hit an exception.
*
* <p>
* May not {@link IsisTransaction#flush()} or
* {@link IsisTransaction#commit() commit} (will throw an
* {@link IllegalStateException}), but can only
* {@link IsisTransaction#markAsAborted() abort}.
*
* <p>
* Similar to <tt>setRollbackOnly</tt> in EJBs.
*/
MUST_ABORT(TransactionState.MUST_ABORT),
/**
* Completed, having successfully committed.
*
* <p>
* May not {@link IsisTransaction#flush()} or
* {@link IsisTransaction#markAsAborted() abort}.
* {@link IsisTransaction#commit() commit} (will throw
* {@link IllegalStateException}).
*/
COMMITTED(TransactionState.COMMITTED),
/**
* Completed, having aborted.
*
* <p>
* May not {@link IsisTransaction#flush()},
* {@link IsisTransaction#commit() commit} or
* {@link IsisTransaction#markAsAborted() abort} (will throw
* {@link IllegalStateException}).
*/
ABORTED(TransactionState.ABORTED);
public final TransactionState transactionState;
private State(TransactionState transactionState){
this.transactionState = transactionState;
}
/**
* Whether it is valid to {@link IsisTransaction#flush() flush} this
* {@link IsisTransaction transaction}.
*/
public boolean canFlush() {
return this == IN_PROGRESS;
}
/**
* Whether it is valid to {@link IsisTransaction#commit() commit} this
* {@link IsisTransaction transaction}.
*/
public boolean canCommit() {
return this == IN_PROGRESS;
}
/**
* Whether it is valid to {@link IsisTransaction#markAsAborted() abort} this
* {@link IsisTransaction transaction}.
*/
public boolean canAbort() {
return this == IN_PROGRESS || this == MUST_ABORT;
}
/**
* Whether the {@link IsisTransaction transaction} is complete (and so a
* new one can be started).
*/
public boolean isComplete() {
return this == COMMITTED || this == ABORTED;
}
public boolean mustAbort() {
return this == MUST_ABORT;
}
public TransactionState getRuntimeContextState() {
return transactionState;
}
}
private static final Logger LOG = LoggerFactory.getLogger(IsisTransaction.class);
private final TransactionalResource objectStore;
private final List<PersistenceCommand> commands = Lists.newArrayList();
private final IsisTransactionManager transactionManager;
private final org.apache.isis.core.commons.authentication.MessageBroker messageBroker;
private final UpdateNotifier updateNotifier;
private final ServicesInjector servicesInjector;
/**
* the 'owning' command, (if service configured).
*/
private final Command command;
/**
* Could be null if not configured as a domain service.
*/
private final CommandContext commandContext;
/**
* could be null if none has been registered.
*/
private final AuditingService3 auditingService3;
/**
* could be null if none has been registered
*/
private final PublishingServiceWithDefaultPayloadFactories publishingService;
/**
* Will be that of the {@link #command} if not <tt>null</tt>, otherwise will be randomly created.
*/
private final UUID transactionId;
private State state;
private IsisException abortCause;
public IsisTransaction(
final IsisTransactionManager transactionManager,
final org.apache.isis.core.commons.authentication.MessageBroker messageBroker,
final UpdateNotifier updateNotifier,
final TransactionalResource objectStore,
final ServicesInjector servicesInjector) {
ensureThatArg(transactionManager, is(not(nullValue())), "transaction manager is required");
ensureThatArg(messageBroker, is(not(nullValue())), "message broker is required");
ensureThatArg(updateNotifier, is(not(nullValue())), "update notifier is required");
ensureThatArg(servicesInjector, is(not(nullValue())), "services injector is required");
this.transactionManager = transactionManager;
this.messageBroker = messageBroker;
this.updateNotifier = updateNotifier;
this.servicesInjector = servicesInjector;
this.commandContext = servicesInjector.lookupService(CommandContext.class);
this.auditingService3 = servicesInjector.lookupService(AuditingService3.class);
this.publishingService = getPublishingServiceIfAny(servicesInjector);
// determine whether this xactn is taking place in the context of an
// existing command in which a previous xactn has already occurred.
// if so, reuse that transactionId.
UUID previousTransactionId = null;
if(commandContext != null) {
command = commandContext.getCommand();
previousTransactionId = command.getTransactionId();
} else {
command = null;
}
if (previousTransactionId != null) {
this.transactionId = previousTransactionId;
} else {
this.transactionId = UUID.randomUUID();
}
this.state = State.IN_PROGRESS;
this.objectStore = objectStore;
if (LOG.isDebugEnabled()) {
LOG.debug("new transaction " + this);
}
}
// ///////////////////////////////////////////
// Publishing service
// ///////////////////////////////////////////
private PublishingServiceWithDefaultPayloadFactories getPublishingServiceIfAny(ServicesInjector servicesInjector) {
final PublishingService publishingService = servicesInjector.lookupService(PublishingService.class);
if(publishingService == null) {
return null;
}
PublishedObject.PayloadFactory objectPayloadFactory = servicesInjector.lookupService(PublishedObject.PayloadFactory.class);
if(objectPayloadFactory == null) {
objectPayloadFactory = newDefaultObjectPayloadFactory();
}
PublishedAction.PayloadFactory actionPayloadFactory = servicesInjector.lookupService(PublishedAction.PayloadFactory.class);
if(actionPayloadFactory == null) {
actionPayloadFactory = newDefaultActionPayloadFactory();
}
return new PublishingServiceWithDefaultPayloadFactories(publishingService, objectPayloadFactory, actionPayloadFactory);
}
protected EventSerializer newSimpleEventSerializer() {
return new EventSerializer.Simple();
}
protected PublishedObject.PayloadFactory newDefaultObjectPayloadFactory() {
return new PublishedObject.PayloadFactory() {
@Override
public EventPayload payloadFor(final Object changedObject, ChangeKind changeKind) {
return new EventPayloadForObjectChanged<Object>(changedObject);
}
};
}
protected PublishedAction.PayloadFactory newDefaultActionPayloadFactory() {
return new PublishedAction.PayloadFactory(){
@Override
public EventPayload payloadFor(Identifier actionIdentifier, Object target, List<Object> arguments, Object result) {
return new EventPayloadForActionInvocation<Object>(
actionIdentifier,
target,
arguments,
result);
}
};
}
// ////////////////////////////////////////////////////////////////
// GUID
// ////////////////////////////////////////////////////////////////
public final UUID getTransactionId() {
return transactionId;
}
// ////////////////////////////////////////////////////////////////
// State
// ////////////////////////////////////////////////////////////////
public State getState() {
return state;
}
private void setState(final State state) {
this.state = state;
}
// //////////////////////////////////////////////////////////
// Commands
// //////////////////////////////////////////////////////////
/**
* Add the non-null command to the list of commands to execute at the end of
* the transaction.
*/
public void addCommand(final PersistenceCommand command) {
if (command == null) {
return;
}
final ObjectAdapter onObject = command.onAdapter();
// Saves are ignored when preceded by another save, or a delete
if (command instanceof SaveObjectCommand) {
if (alreadyHasCreate(onObject) || alreadyHasSave(onObject)) {
if (LOG.isDebugEnabled()) {
LOG.debug("ignored command as object already created/saved" + command);
}
return;
}
if (alreadyHasDestroy(onObject)) {
if (LOG.isDebugEnabled()) {
LOG.debug("ignored command " + command + " as object no longer exists");
}
return;
}
}
// Destroys are ignored when preceded by a create, or another destroy
if (command instanceof DestroyObjectCommand) {
if (alreadyHasCreate(onObject)) {
removeCreate(onObject);
if (LOG.isDebugEnabled()) {
LOG.debug("ignored both create and destroy command " + command);
}
return;
}
if (alreadyHasSave(onObject)) {
removeSave(onObject);
if (LOG.isDebugEnabled()) {
LOG.debug("removed prior save command " + command);
}
}
if (alreadyHasDestroy(onObject)) {
if (LOG.isDebugEnabled()) {
LOG.debug("ignored command " + command + " as command already recorded");
}
return;
}
}
if (LOG.isDebugEnabled()) {
LOG.debug("add command " + command);
}
commands.add(command);
}
// ////////////////////////////////////////////////////////////////
// flush
// ////////////////////////////////////////////////////////////////
public synchronized final void flush() {
// have removed THIS guard because we hit a situation where a xactn is aborted
// from a no-arg action, the Wicket viewer attempts to render a new page that (of course)
// contains the service menu items, and some of the 'disableXxx()' methods of those
// service actions perform repository queries (while xactn is still in a state of ABORTED)
//
// ensureThatState(getState().canFlush(), is(true), "state is: " + getState());
//
if (LOG.isDebugEnabled()) {
LOG.debug("flush transaction " + this);
}
try {
doFlush();
} catch (final RuntimeException ex) {
setAbortCause(new IsisTransactionFlushException(ex));
throw ex;
}
}
/**
* Maximum number of times we attempt to flush the transaction before giving up.
*/
private final static int MAX_FLUSH_ATTEMPTS = 10;
/**
* Mandatory hook method for subclasses to persist all pending changes.
*
* <p>
* Called by both {@link #commit()} and by {@link #flush()}:
* <table>
* <tr>
* <th>called from</th>
* <th>next {@link #getState() state} if ok</th>
* <th>next {@link #getState() state} if exception</th>
* </tr>
* <tr>
* <td>{@link #commit()}</td>
* <td>{@link State#COMMITTED}</td>
* <td>{@link State#ABORTED}</td>
* </tr>
* <tr>
* <td>{@link #flush()}</td>
* <td>{@link State#IN_PROGRESS}</td>
* <td>{@link State#MUST_ABORT}</td>
* </tr>
* </table>
*/
private void doFlush() {
int i = 0;
//
// it's possible that in executing these commands that more will be created.
// so we keep flushing until no more are available (ISIS-533)
//
// this is a do...while rather than a while... just for backward compatibilty
// with previous algorithm that always went through the execute phase at least once.
//
do {
// We take a copy of the commands to be executed (executing these
// might add to this.commands).
final List<PersistenceCommand> commandsPrior =
Collections.unmodifiableList(Lists.newArrayList(commands));
try {
objectStore.execute(commandsPrior);
for (final PersistenceCommand command : commandsPrior) {
if (command instanceof DestroyObjectCommand) {
final ObjectAdapter adapter = command.onAdapter();
adapter.setVersion(null);
if(!adapter.isDestroyed()) {
adapter.changeState(ResolveState.DESTROYED);
}
}
}
commands.removeAll(commandsPrior);
} catch(final RuntimeException ex) {
// if there's an exception, we want to make sure that
// all commands are cleared and propagate
commands.clear();
throw ex;
}
} while(!commands.isEmpty() && i++ < MAX_FLUSH_ATTEMPTS);
if(!commands.isEmpty()) {
// must have hit max flush
final List<PersistenceCommand> commandsStillToFlush =
Collections.unmodifiableList(Lists.newArrayList(commands));
commands.clear();
throw new ObjectPersistenceException("Failed to flush transaction after " + MAX_FLUSH_ATTEMPTS + " attempts; commands still to flush:\n " + commandsStillToFlush.toString());
}
}
protected void doAudit(final Set<Entry<AdapterAndProperty, PreAndPostValues>> changedObjectProperties) {
try {
if(auditingService3 == null) {
return;
}
// else
final String currentUser = getTransactionManager().getAuthenticationSession().getUserName();
final java.sql.Timestamp currentTime = Clock.getTimeAsJavaSqlTimestamp();
for (Entry<AdapterAndProperty, PreAndPostValues> auditEntry : changedObjectProperties) {
auditChangedProperty(currentTime, currentUser, auditEntry);
}
} finally {
// not needed in production, but is required for integration testing
this.changedObjectProperties.clear();
}
}
protected void publishActionIfRequired(final String currentUser, final java.sql.Timestamp timestamp) {
if(publishingService == null) {
return;
}
try {
final CurrentInvocation currentInvocation = ActionInvocationFacet.currentInvocation.get();
if(currentInvocation == null) {
return;
}
IdentifiedHolder action = currentInvocation.getAction();
final PublishedActionFacet publishedActionFacet = action.getFacet(PublishedActionFacet.class);
if(publishedActionFacet == null) {
return;
}
final PublishedAction.PayloadFactory payloadFactory = publishedActionFacet.value();
final RootOid adapterOid = (RootOid) currentInvocation.getTarget().getOid();
final String oidStr = getOidMarshaller().marshal(adapterOid);
final Identifier actionIdentifier = action.getIdentifier();
final String title = oidStr + ": " + actionIdentifier.toNameParmsIdentityString();
final Command command = currentInvocation.getCommand();
final String targetClass = command.getTargetClass();
final String targetAction = command.getTargetAction();
final Bookmark target = command.getTarget();
final String memberIdentifier = command.getMemberIdentifier();
final EventMetadata metadata = newEventMetadata(EventType.ACTION_INVOCATION, currentUser, timestamp, title, targetClass, targetAction, target, memberIdentifier);
publishingService.publishAction(payloadFactory, metadata, currentInvocation, objectStringifier());
} finally {
// ensures that cannot publish this action more than once
ActionInvocationFacet.currentInvocation.set(null);
}
}
/**
* @return the adapters that were published (if any were).
*/
protected List<ObjectAdapter> publishedChangedObjectsIfRequired(final String currentUser, final java.sql.Timestamp timestamp) {
if(publishingService == null) {
return Collections.emptyList();
}
// take a copy of enlisted adapters ... the JDO implementation of the PublishingService
// creates further entities which would be enlisted; taking copy of the keys avoids ConcurrentModificationException
List<ObjectAdapter> enlistedAdapters = Lists.newArrayList(changeKindByEnlistedAdapter.keySet());
for (final ObjectAdapter enlistedAdapter : enlistedAdapters) {
final ChangeKind changeKind = changeKindByEnlistedAdapter.get(enlistedAdapter);
final PublishedObjectFacet publishedObjectFacet = enlistedAdapter.getSpecification().getFacet(PublishedObjectFacet.class);
if(publishedObjectFacet == null) {
continue;
}
final PublishedObject.PayloadFactory payloadFactory = publishedObjectFacet.value();
final RootOid enlistedAdapterOid = (RootOid) enlistedAdapter.getOid();
final String oidStr = getOidMarshaller().marshal(enlistedAdapterOid);
final String title = oidStr;
final EventType eventTypeFor = eventTypeFor(changeKind);
final String enlistedAdapterClass = CommandUtil.targetClassNameFor(enlistedAdapter);
final Bookmark enlistedTarget = enlistedAdapterOid.asBookmark();
final EventMetadata metadata = newEventMetadata(eventTypeFor, currentUser, timestamp, title, enlistedAdapterClass, null, enlistedTarget, null);
publishingService.publishObject(payloadFactory, metadata, enlistedAdapter, changeKind, objectStringifier());
}
return enlistedAdapters;
}
private static EventType eventTypeFor(ChangeKind changeKind) {
if(changeKind == ChangeKind.UPDATE) {
return EventType.OBJECT_UPDATED;
}
if(changeKind == ChangeKind.CREATE) {
return EventType.OBJECT_CREATED;
}
if(changeKind == ChangeKind.DELETE) {
return EventType.OBJECT_DELETED;
}
throw new IllegalArgumentException("unknown ChangeKind '" + changeKind + "'");
}
protected ObjectStringifier objectStringifier() {
if(objectStringifier == null) {
// lazily created; is threadsafe so no need to guard against race conditions
objectStringifier = new ObjectStringifier() {
@Override
public String toString(Object object) {
if(object == null) {
return null;
}
final ObjectAdapter adapter = getAdapterManager().adapterFor(object);
Oid oid = adapter.getOid();
return oid != null? oid.enString(getOidMarshaller()): encodedValueOf(adapter);
}
private String encodedValueOf(ObjectAdapter adapter) {
EncodableFacet facet = adapter.getSpecification().getFacet(EncodableFacet.class);
return facet != null? facet.toEncodedString(adapter): adapter.toString();
}
@Override
public String classNameOf(Object object) {
final ObjectAdapter adapter = getAdapterManager().adapterFor(object);
final String className = adapter.getSpecification().getFullIdentifier();
return className;
}
};
}
return objectStringifier;
}
private EventMetadata newEventMetadata(final EventType eventType, final String currentUser, final java.sql.Timestamp timestampEpoch, final String title, String targetClass, String targetAction, Bookmark target, String memberIdentifier) {
int nextEventSequence = nextEventSequence();
return new EventMetadata(
getTransactionId(), nextEventSequence, eventType, currentUser, timestampEpoch, title,
targetClass, targetAction, target, memberIdentifier);
}
private int nextEventSequence() {
if(command == null) {
throw new IllegalStateException("CommandContext service is required to support Publishing.");
}
return command.next("publishedEvent");
}
public void auditChangedProperty(
final java.sql.Timestamp timestamp, final String user, final Entry<AdapterAndProperty, PreAndPostValues> auditEntry) {
final AdapterAndProperty aap = auditEntry.getKey();
final ObjectAdapter adapter = aap.getAdapter();
final AuditableFacet auditableFacet = adapter.getSpecification().getFacet(AuditableFacet.class);
if(auditableFacet == null || auditableFacet.isDisabled()) {
return;
}
final RootOid oid = (RootOid) adapter.getOid();
final String objectType = oid.getObjectSpecId().asString();
final String identifier = oid.getIdentifier();
final PreAndPostValues papv = auditEntry.getValue();
final String preValue = papv.getPreString();
final String postValue = papv.getPostString();
final ObjectAssociation property = aap.getProperty();
final String memberId = property.getIdentifier().toClassAndNameIdentityString();
final String propertyId = property.getId();
final String targetClass = CommandUtil.targetClassNameFor(adapter);
final Bookmark target = new Bookmark(objectType, identifier);
auditingService3.audit(getTransactionId(), targetClass, target, memberId, propertyId, preValue, postValue, user, timestamp);
}
private static String asString(Object object) {
return object != null? object.toString(): null;
}
protected AuthenticationSession getAuthenticationSession() {
return IsisContext.getAuthenticationSession();
}
// ////////////////////////////////////////////////////////////////
// preCommit, commit
// ////////////////////////////////////////////////////////////////
synchronized void preCommit() {
ensureThatState(getState().canCommit(), is(true), "state is: " + getState());
ensureThatState(abortCause, is(nullValue()), "cannot commit: an abort cause has been set");
if (LOG.isDebugEnabled()) {
LOG.debug("preCommit transaction " + this);
}
if (getState() == State.COMMITTED) {
if (LOG.isInfoEnabled()) {
LOG.info("already committed; ignoring");
}
return;
}
try {
final Set<Entry<AdapterAndProperty, PreAndPostValues>> changedObjectProperties = getChangedObjectProperties();
ensureCommandsPersistedIfDirtyXactnAndAnySafeSemanticsHonoured(changedObjectProperties);
preCommitServices(changedObjectProperties);
} catch (final RuntimeException ex) {
setAbortCause(new IsisTransactionManagerException(ex));
clearCommandServiceIfConfigured();
throw ex;
}
}
private void ensureCommandsPersistedIfDirtyXactnAndAnySafeSemanticsHonoured(final Set<Entry<AdapterAndProperty, PreAndPostValues>> changedObjectProperties) {
final CommandContext commandContext = getServiceOrNull(CommandContext.class);
if (commandContext == null) {
return;
}
final Command command = commandContext.getCommand();
if(command == null) {
return;
}
// ensure that any changed objects means that the command should be persisted
final Set<ObjectAdapter> changedAdapters = findChangedAdapters(changedObjectProperties);
if(!changedAdapters.isEmpty() && command.getMemberIdentifier() != null) {
command.setPersistHint(true);
}
ensureSafeSemanticsHonoured(command, changedAdapters);
}
private void ensureSafeSemanticsHonoured(Command command, Set<ObjectAdapter> changedAdapters) {
if(true) {
// ISIS-921: disabling this functionality...
//
// ... the issue is that an edit (which mutates state, obviously), can cause a contributed property to
// be evaluated, which has safe semantics.
//
// the solution, I think, is to set up some sort of "dummy" action to represent the edit.
// this needs to be installed pretty early up in the stack trace. ISIS-922 raised for this.
//
return;
}
if (!(command instanceof Command2)) {
return;
}
final Command2 command2 = (Command2) command;
final List<ActionInteractionEvent<?>> events = command2.flushActionInteractionEvents();
if (events.isEmpty()) {
return;
}
// are all safe?
for (ActionInteractionEvent<?> event : events) {
if(!event.getActionSemantics().isSafe()) {
// found at least one non-safe action, so all bets are off.
return;
}
}
// all actions invoked had safe semantics; were any objects changed?
if (changedAdapters.isEmpty()) {
return;
}
final String msg = "Action '" + events.get(0).getIdentifier().toFullIdentityString() + "'" +
" (with safe semantics)" +
" caused " + changedAdapters.size() + " object" + (changedAdapters.size() != 1 ? "s" : "") +
" to be modified";
LOG.error(msg);
for (ObjectAdapter changedAdapter : changedAdapters) {
final StringBuilder builder = new StringBuilder(" > ")
.append(changedAdapter.getSpecification().getFullIdentifier())
.append(": ");
if(!changedAdapter.isDestroyed()) {
builder.append(changedAdapter.titleString(null));
} else {
builder.append("(deleted object)");
}
LOG.error(builder.toString());
}
final boolean enforceSafeSemantics = getConfiguration().getBoolean(PersistenceConstants.ENFORCE_SAFE_SEMANTICS, PersistenceConstants.ENFORCE_SAFE_SEMANTICS_DEFAULT);
if(enforceSafeSemantics) {
throw new RecoverableException(msg);
}
}
private static Set<ObjectAdapter> findChangedAdapters(
final Set<Entry<AdapterAndProperty, PreAndPostValues>> changedObjectProperties) {
return Sets.newHashSet(
Iterables.filter(
Iterables.transform(
changedObjectProperties,
AdapterAndProperty.Functions.GET_ADAPTER),
Predicates.not(IS_COMMAND)));
}
private void preCommitServices(final Set<Entry<AdapterAndProperty, PreAndPostValues>> changedObjectProperties) {
doAudit(changedObjectProperties);
final String currentUser = getTransactionManager().getAuthenticationSession().getUserName();
final Timestamp endTimestamp = Clock.getTimeAsJavaSqlTimestamp();
publishActionIfRequired(currentUser, endTimestamp);
doFlush();
publishedChangedObjectsIfRequired(currentUser, endTimestamp);
doFlush();
closeServices();
doFlush();
}
private void clearCommandServiceIfConfigured() {
completeCommandIfConfigured();
}
private void closeServices() {
closeOtherApplibServicesIfConfigured();
completeCommandIfConfigured();
}
/**
* @return - the service, or <tt>null</tt> if no service registered of specified type.
*/
public <T> T getServiceOrNull(Class<T> serviceType) {
return servicesInjector.lookupService(serviceType);
}
private void closeOtherApplibServicesIfConfigured() {
Bulk.InteractionContext bic = getServiceOrNull(Bulk.InteractionContext.class);
if(bic != null) {
Bulk.InteractionContext.current.set(null);
}
EventBusServiceDefault ebs = getServiceOrNull(EventBusServiceDefault.class);
if(ebs != null) {
ebs.close();
}
}
private void completeCommandIfConfigured() {
final CommandContext commandContext = getServiceOrNull(CommandContext.class);
if(commandContext != null) {
final CommandService commandService = getServiceOrNull(CommandService.class);
if(commandService != null) {
final Command command = commandContext.getCommand();
commandService.complete(command);
if(command instanceof Command2) {
final Command2 command2 = (Command2) command;
command2.flushActionInteractionEvents();
}
}
}
}
// ////////////////////////////////////////////////////////////////
public synchronized void commit() {
ensureThatState(getState().canCommit(), is(true), "state is: " + getState());
ensureThatState(abortCause, is(nullValue()), "cannot commit: an abort cause has been set");
if (LOG.isDebugEnabled()) {
LOG.debug("postCommit transaction " + this);
}
if (getState() == State.COMMITTED) {
if (LOG.isInfoEnabled()) {
LOG.info("already committed; ignoring");
}
return;
}
setState(State.COMMITTED);
}
// ////////////////////////////////////////////////////////////////
// markAsAborted
// ////////////////////////////////////////////////////////////////
public synchronized final void markAsAborted() {
ensureThatState(getState().canAbort(), is(true), "state is: " + getState());
if (LOG.isInfoEnabled()) {
LOG.info("abort transaction " + this);
}
setState(State.ABORTED);
}
/////////////////////////////////////////////////////////////////////////
// handle exceptions on load, flush or commit
/////////////////////////////////////////////////////////////////////////
@Deprecated
public void ensureNoAbortCause() {
Ensure.ensureThatArg(abortCause, is(nullValue()), "abort cause has been set");
}
/**
* Indicate that the transaction must be aborted, and that there is
* an unhandled exception to be rendered somehow.
*
* <p>
* If the cause is subsequently rendered by code higher up the stack, then the
* cause can be {@link #clearAbortCause() cleared}. However, it is not possible
* to change the state from {@link State#MUST_ABORT}.
*/
public void setAbortCause(IsisException abortCause) {
setState(State.MUST_ABORT);
this.abortCause = abortCause;
}
public IsisException getAbortCause() {
return abortCause;
}
/**
* If the cause has been rendered higher up in the stack, then clear the cause so that
* it won't be picked up and rendered elsewhere.
*/
public void clearAbortCause() {
abortCause = null;
}
// //////////////////////////////////////////////////////////
// Helpers
// //////////////////////////////////////////////////////////
private boolean alreadyHasCommand(final Class<?> commandClass, final ObjectAdapter onObject) {
return getCommand(commandClass, onObject) != null;
}
private boolean alreadyHasCreate(final ObjectAdapter onObject) {
return alreadyHasCommand(CreateObjectCommand.class, onObject);
}
private boolean alreadyHasDestroy(final ObjectAdapter onObject) {
return alreadyHasCommand(DestroyObjectCommand.class, onObject);
}
private boolean alreadyHasSave(final ObjectAdapter onObject) {
return alreadyHasCommand(SaveObjectCommand.class, onObject);
}
private PersistenceCommand getCommand(final Class<?> commandClass, final ObjectAdapter onObject) {
for (final PersistenceCommand command : commands) {
if (command.onAdapter().equals(onObject)) {
if (commandClass.isAssignableFrom(command.getClass())) {
return command;
}
}
}
return null;
}
private void removeCommand(final Class<?> commandClass, final ObjectAdapter onObject) {
final PersistenceCommand toDelete = getCommand(commandClass, onObject);
commands.remove(toDelete);
}
private void removeCreate(final ObjectAdapter onObject) {
removeCommand(CreateObjectCommand.class, onObject);
}
private void removeSave(final ObjectAdapter onObject) {
removeCommand(SaveObjectCommand.class, onObject);
}
// ////////////////////////////////////////////////////////////////
// toString
// ////////////////////////////////////////////////////////////////
@Override
public String toString() {
return appendTo(new ToString(this)).toString();
}
protected ToString appendTo(final ToString str) {
str.append("state", state);
str.append("commands", commands.size());
return str;
}
// ////////////////////////////////////////////////////////////////
// Depenendencies (from constructor)
// ////////////////////////////////////////////////////////////////
/**
* The owning {@link IsisTransactionManager transaction manager}.
*
* <p>
* Injected in constructor
*/
public IsisTransactionManager getTransactionManager() {
return transactionManager;
}
/**
* The {@link MessageBroker} for this transaction.
*
* <p>
* Injected in constructor
*
* @deprecated - obtain the {@link org.apache.isis.core.commons.authentication.MessageBroker} instead from the {@link AuthenticationSession}.
*/
@Deprecated
public MessageBroker getMessageBroker() {
return (MessageBroker) messageBroker;
}
/**
* The {@link UpdateNotifier} for this transaction.
*
* <p>
* Injected in constructor
*/
public UpdateNotifier getUpdateNotifier() {
return updateNotifier;
}
public static class AdapterAndProperty {
private final ObjectAdapter objectAdapter;
private final ObjectAssociation property;
public static AdapterAndProperty of(ObjectAdapter adapter, ObjectAssociation property) {
return new AdapterAndProperty(adapter, property);
}
private AdapterAndProperty(ObjectAdapter adapter, ObjectAssociation property) {
this.objectAdapter = adapter;
this.property = property;
}
public ObjectAdapter getAdapter() {
return objectAdapter;
}
public ObjectAssociation getProperty() {
return property;
}
@Override
public int hashCode() {
final int prime = 31;
int result = 1;
result = prime * result + ((objectAdapter == null) ? 0 : objectAdapter.hashCode());
result = prime * result + ((property == null) ? 0 : property.hashCode());
return result;
}
@Override
public boolean equals(Object obj) {
if (this == obj)
return true;
if (obj == null)
return false;
if (getClass() != obj.getClass())
return false;
AdapterAndProperty other = (AdapterAndProperty) obj;
if (objectAdapter == null) {
if (other.objectAdapter != null)
return false;
} else if (!objectAdapter.equals(other.objectAdapter))
return false;
if (property == null) {
if (other.property != null)
return false;
} else if (!property.equals(other.property))
return false;
return true;
}
@Override
public String toString() {
return getAdapter().getOid().enStringNoVersion(getMarshaller()) + " , " + getProperty().getId();
}
protected OidMarshaller getMarshaller() {
return new OidMarshaller();
}
private Object getPropertyValue() {
ObjectAdapter referencedAdapter = property.get(objectAdapter);
return referencedAdapter == null ? null : referencedAdapter.getObject();
}
static class Functions {
private Functions(){}
static final Function<Entry<AdapterAndProperty, PreAndPostValues>, ObjectAdapter> GET_ADAPTER = new Function<Entry<AdapterAndProperty, PreAndPostValues>, ObjectAdapter>() {
@Override
public ObjectAdapter apply(Entry<AdapterAndProperty, PreAndPostValues> input) {
final AdapterAndProperty aap = input.getKey();
return aap.getAdapter();
}
};
}
}
////////////////////////////////////////////////////////////////////////
// Auditing/Publishing object tracking
////////////////////////////////////////////////////////////////////////
public static class PreAndPostValues {
static class Predicates {
final static Predicate<Entry<?, PreAndPostValues>> CHANGED = new Predicate<Entry<?, PreAndPostValues>>(){
@Override
public boolean apply(Entry<?, PreAndPostValues> input) {
final PreAndPostValues papv = input.getValue();
return papv.differ();
}};
}
private final Object pre;
/**
* Eagerly calculated because it could be that the object referenced ends up being deleted by the time that the xactn completes.
*/
private final String preString;
/**
* Updated in {@link #setPost(Object)}
*/
private Object post;
/**
* Updated in {@link #setPost(Object)}, along with {@link #post}.
*/
private String postString;
public static PreAndPostValues pre(Object preValue) {
return new PreAndPostValues(preValue, null);
}
private PreAndPostValues(Object pre, Object post) {
this.pre = pre;
this.post = post;
this.preString = asString(pre);
}
/**
* The object that was referenced before this object was changed
*
* <p>
* Note that this referenced object itself could end up being deleted in the course of the transaction; in which case use
* {@link #getPreString()} which is the eagerly cached <tt>toString</tt> of said object.
*/
public Object getPre() {
return pre;
}
public String getPreString() {
return preString;
}
public Object getPost() {
return post;
}
public String getPostString() {
return postString;
}
public void setPost(Object post) {
this.post = post;
this.postString = asString(post);
}
@Override
public String toString() {
return getPre() + " -> " + getPost();
}
public boolean differ() {
return !Objects.equal(getPre(), getPost());
}
}
private final Map<ObjectAdapter,ChangeKind> changeKindByEnlistedAdapter = Maps.newLinkedHashMap();
private final Map<AdapterAndProperty, PreAndPostValues> changedObjectProperties = Maps.newLinkedHashMap();
private ObjectStringifier objectStringifier;
/**
* Auditing and publishing support: for object stores to enlist an object that has just been created,
* capturing a dummy value <tt>'[NEW]'</tt> for the pre-modification value.
*
* <p>
* The post-modification values are captured in as a side-effect of calling {@link #getChangedObjectProperties()},
* which returns the pre- and post- values for each {@link ObjectAdapter} in a map.
*
* <p>
* Supported by the JDO object store; check documentation for support in other objectstores.
*/
public void enlistCreated(ObjectAdapter adapter) {
enlist(adapter, ChangeKind.CREATE);
for (ObjectAssociation property : adapter.getSpecification().getAssociations(Contributed.EXCLUDED, ObjectAssociation.Filters.PROPERTIES)) {
final AdapterAndProperty aap = AdapterAndProperty.of(adapter, property);
if(property.isNotPersisted()) {
continue;
}
PreAndPostValues papv = PreAndPostValues.pre("[NEW]");
changedObjectProperties.put(aap, papv);
}
}
/**
* Auditing and publishing support: for object stores to enlist an object that is about to be updated,
* capturing the pre-modification values of the properties of the {@link ObjectAdapter}.
*
* <p>
* The post-modification values are captured in as a side-effect of calling {@link #getChangedObjectProperties()},
* which returns the pre- and post- values for each {@link ObjectAdapter} in a map.
*
* <p>
* Supported by the JDO object store; check documentation for support in other objectstores.
*/
public void enlistUpdating(ObjectAdapter adapter) {
enlist(adapter, ChangeKind.UPDATE);
for (ObjectAssociation property : adapter.getSpecification().getAssociations(Contributed.EXCLUDED, ObjectAssociation.Filters.PROPERTIES)) {
final AdapterAndProperty aap = AdapterAndProperty.of(adapter, property);
if(property.isNotPersisted()) {
continue;
}
PreAndPostValues papv = PreAndPostValues.pre(aap.getPropertyValue());
changedObjectProperties.put(aap, papv);
}
}
/**
* Auditing and publishing support: for object stores to enlist an object that is about to be deleted,
* capturing the pre-deletion value of the properties of the {@link ObjectAdapter}.
*
* <p>
* The post-modification values are captured in as a side-effect of calling {@link #getChangedObjectProperties()}.
* In the case of deleted objects, a dummy value <tt>'[DELETED]'</tt> is used as the post-modification value.
*
* <p>
* Supported by the JDO object store; check documentation for support in other objectstores.
*/
public void enlistDeleting(ObjectAdapter adapter) {
enlist(adapter, ChangeKind.DELETE);
for (ObjectAssociation property : adapter.getSpecification().getAssociations(Contributed.EXCLUDED, ObjectAssociation.Filters.PROPERTIES)) {
final AdapterAndProperty aap = AdapterAndProperty.of(adapter, property);
if(property.isNotPersisted()) {
continue;
}
PreAndPostValues papv = PreAndPostValues.pre(aap.getPropertyValue());
changedObjectProperties.put(aap, papv);
}
}
private void enlist(ObjectAdapter adapter, ChangeKind changeKind) {
changeKindByEnlistedAdapter.put(adapter, changeKind);
}
/**
* Returns the pre- and post-values of all {@link ObjectAdapter}s that were enlisted and dirtied
* in this transaction.
*
* <p>
* This requires that the object store called {@link #enlistUpdating(ObjectAdapter)} for each object being
* enlisted.
*
* <p>
* Supported by the JDO object store (since it calls {@link #enlistUpdating(ObjectAdapter)});
* check documentation for support in other object stores.
*/
private Set<Entry<AdapterAndProperty, PreAndPostValues>> getChangedObjectProperties() {
updatePostValues(changedObjectProperties.entrySet());
return Collections.unmodifiableSet(Sets.filter(changedObjectProperties.entrySet(), PreAndPostValues.Predicates.CHANGED));
}
private static void updatePostValues(Set<Entry<AdapterAndProperty, PreAndPostValues>> entrySet) {
for (Entry<AdapterAndProperty, PreAndPostValues> entry : entrySet) {
final AdapterAndProperty aap = entry.getKey();
final PreAndPostValues papv = entry.getValue();
ObjectAdapter adapter = aap.getAdapter();
if(adapter.isDestroyed()) {
// don't touch the object!!!
// JDO, for example, will complain otherwise...
papv.setPost("[DELETED]");
} else {
papv.setPost(aap.getPropertyValue());
}
}
}
////////////////////////////////////////////////////////////////////////
// Dependencies (from context)
////////////////////////////////////////////////////////////////////////
protected AdapterManager getAdapterManager() {
return IsisContext.getPersistenceSession().getAdapterManager();
}
protected OidMarshaller getOidMarshaller() {
return IsisContext.getOidMarshaller();
}
protected IsisConfiguration getConfiguration() {
return IsisContext.getConfiguration();
}
}