From 3e30275592c1010bd8c44f12b3142dfe67ab65d0 Mon Sep 17 00:00:00 2001 From: andi-huber Date: Thu, 16 Apr 2026 13:59:49 +0200 Subject: [PATCH 1/8] CAUSEWAY-3989: [v2] backport of command replay feature --- api/applib/src/main/java/module-info.java | 1 + .../applib/util/schema/CommandDtoUtils.java | 63 +++ .../apache/causeway/commons/io/YamlUtils.java | 26 +- ...omainObjectAnnotationFacetFactoryTest.java | 21 +- extensions/core/commandlog/applib/pom.xml | 10 + .../CausewayModuleExtCommandLogApplib.java | 33 +- .../commandlog/applib/app/CommandLogMenu.java | 37 +- .../applib/dom/CommandLogEntry.java | 41 +- .../applib/dom/CommandLogEntryRepository.java | 29 +- .../CommandLogEntryRepositoryAbstract.java | 114 ++--- .../commandlog/applib/dom/ReplayState.java | 51 ++- .../dom/replay/CommandExportManager.java | 185 ++++++++ .../CommandExportManager.layout.fallback.xml | 52 +++ .../dom/replay/CommandReplayManager.java | 187 ++++++++ .../CommandReplayManager.layout.fallback.xml | 52 +++ .../applib/dom/replay/ReplayContext.java | 40 ++ .../applib/dom/replay/ReplayableCommand.java | 406 ++++++++++++++++++ .../ReplayableCommand.layout.fallback.xml | 45 ++ .../CommandLog_IntegTestAbstract.java | 4 +- .../commandlog/jdo/dom/CommandLogEntry.java | 2 +- .../commandlog/jpa/dom/CommandLogEntry.java | 2 +- .../jobcallables/ReplicateAndRunCommands.java | 2 +- .../mixins/CommandLogEntry_replayQueue.java | 2 +- 23 files changed, 1268 insertions(+), 137 deletions(-) create mode 100644 extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandExportManager.java create mode 100644 extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandExportManager.layout.fallback.xml create mode 100644 extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandReplayManager.java create mode 100644 extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandReplayManager.layout.fallback.xml create mode 100644 extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayContext.java create mode 100644 extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand.java create mode 100644 extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand.layout.fallback.xml diff --git a/api/applib/src/main/java/module-info.java b/api/applib/src/main/java/module-info.java index 488891760ce..4131e569bcf 100644 --- a/api/applib/src/main/java/module-info.java +++ b/api/applib/src/main/java/module-info.java @@ -146,6 +146,7 @@ requires transitive spring.core; requires spring.tx; requires org.apache.logging.log4j.core; + requires com.fasterxml.jackson.annotation; // JAXB viewmodels opens org.apache.causeway.applib.annotation; diff --git a/api/applib/src/main/java/org/apache/causeway/applib/util/schema/CommandDtoUtils.java b/api/applib/src/main/java/org/apache/causeway/applib/util/schema/CommandDtoUtils.java index 935fb354918..83372393da9 100644 --- a/api/applib/src/main/java/org/apache/causeway/applib/util/schema/CommandDtoUtils.java +++ b/api/applib/src/main/java/org/apache/causeway/applib/util/schema/CommandDtoUtils.java @@ -18,18 +18,33 @@ */ package org.apache.causeway.applib.util.schema; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + import org.apache.causeway.applib.services.bookmark.Bookmark; import org.apache.causeway.commons.internal.base._Lazy; +import org.apache.causeway.commons.internal.base._NullSafe; import org.apache.causeway.commons.internal.base._Strings; +import org.apache.causeway.commons.io.DataSource; import org.apache.causeway.commons.io.DtoMapper; import org.apache.causeway.commons.io.JaxbUtils; +import org.apache.causeway.commons.io.JsonUtils; +import org.apache.causeway.commons.io.JsonUtils.JacksonCustomizer; +import org.apache.causeway.commons.io.YamlUtils; import org.apache.causeway.schema.cmd.v2.ActionDto; import org.apache.causeway.schema.cmd.v2.CommandDto; import org.apache.causeway.schema.cmd.v2.MapDto; +import org.apache.causeway.schema.cmd.v2.MemberDto; import org.apache.causeway.schema.cmd.v2.ParamsDto; +import org.apache.causeway.schema.cmd.v2.PropertyDto; import org.apache.causeway.schema.common.v2.OidsDto; import org.apache.causeway.schema.common.v2.PeriodDto; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.jsontype.NamedType; + import lombok.experimental.UtilityClass; /** @@ -117,4 +132,52 @@ private MapDto userDataFor(final CommandDto commandDto) { return userData; } + // -- YAML SUPPORT + + public String toYaml(final Iterable commandDtos) { + final JsonUtils.JacksonCustomizer customizer = new JacksonCustomizer() { + @Override + public ObjectMapper apply(ObjectMapper mapper) { + JsonUtils.jaxbAnnotationSupport(mapper); + CommandDtoUtils.memberDtoSupport(mapper); + JsonUtils.onlyIncludeNonNull(mapper); + return mapper; + } + }; + return YamlUtils.toStringUtf8( + _NullSafe.stream(commandDtos) + .collect(Collectors.toList()), + customizer); + } + + public List fromYaml(final DataSource commandDtosYaml) { + final JsonUtils.JacksonCustomizer customizer = new JacksonCustomizer() { + @Override + public ObjectMapper apply(ObjectMapper mapper) { + JsonUtils.jaxbAnnotationSupport(mapper); + CommandDtoUtils.memberDtoSupport(mapper); + return mapper; + } + }; + return YamlUtils.tryReadAsList(CommandDto.class, commandDtosYaml, customizer) + .ifFailureFail() + .getValue() + .orElseGet(Collections::emptyList); + } + + // Mix-in to add type metadata to MemberDto + @JsonTypeInfo( + use = JsonTypeInfo.Id.NAME, + include = JsonTypeInfo.As.PROPERTY, + property = "type") + private abstract class AbstractDtoMixIn {} + + private void memberDtoSupport(final ObjectMapper mb) { + // add mix-in so MemberDto carries @JsonTypeInfo without modifying source + mb.addMixIn(MemberDto.class, AbstractDtoMixIn.class); + // register concrete sub-types with logical names + mb.registerSubtypes(new NamedType(ActionDto.class, "ACT")); + mb.registerSubtypes(new NamedType(PropertyDto.class, "PROP")); + } + } diff --git a/commons/src/main/java/org/apache/causeway/commons/io/YamlUtils.java b/commons/src/main/java/org/apache/causeway/commons/io/YamlUtils.java index 9e7d3a79820..c8bc8ac187e 100644 --- a/commons/src/main/java/org/apache/causeway/commons/io/YamlUtils.java +++ b/commons/src/main/java/org/apache/causeway/commons/io/YamlUtils.java @@ -19,18 +19,17 @@ package org.apache.causeway.commons.io; import java.io.InputStream; +import java.util.List; import java.util.Optional; +import org.apache.causeway.commons.functional.Try; +import org.springframework.lang.Nullable; +import org.yaml.snakeyaml.DumperOptions; + import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; import com.fasterxml.jackson.dataformat.yaml.YAMLGenerator; -import org.yaml.snakeyaml.DumperOptions; - -import org.springframework.lang.Nullable; - -import org.apache.causeway.commons.functional.Try; - import lombok.NonNull; import lombok.SneakyThrows; import lombok.experimental.UtilityClass; @@ -69,6 +68,21 @@ public Try tryRead( .readValue(is, mappedType)); }); } + + /** + * Tries to deserialize YAML content from given {@link DataSource} into a {@link List} + * with given {@code elementType}. + */ + public Try> tryReadAsList( + final @NonNull Class elementType, + final @NonNull DataSource source, + final JsonUtils.JacksonCustomizer ... customizers) { + return source.tryReadAll((final InputStream is) -> Try.call(()->{ + var mapper = createJacksonReader(customizers); + var collectionType = mapper.getTypeFactory().constructCollectionType(List.class, elementType); + return mapper.readValue(is, collectionType); + })); + } // -- WRITING diff --git a/core/metamodel/src/test/java/org/apache/causeway/core/metamodel/facets/object/domainobject/DomainObjectAnnotationFacetFactoryTest.java b/core/metamodel/src/test/java/org/apache/causeway/core/metamodel/facets/object/domainobject/DomainObjectAnnotationFacetFactoryTest.java index 6448a0b53a7..1c82976bb51 100644 --- a/core/metamodel/src/test/java/org/apache/causeway/core/metamodel/facets/object/domainobject/DomainObjectAnnotationFacetFactoryTest.java +++ b/core/metamodel/src/test/java/org/apache/causeway/core/metamodel/facets/object/domainobject/DomainObjectAnnotationFacetFactoryTest.java @@ -18,17 +18,6 @@ */ package org.apache.causeway.core.metamodel.facets.object.domainobject; -import java.util.UUID; - -import javax.inject.Named; - -import org.apache.causeway.applib.annotation.Publishing; - -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; - import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.CoreMatchers.notNullValue; import static org.hamcrest.MatcherAssert.assertThat; @@ -37,16 +26,20 @@ import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; +import java.util.UUID; + +import javax.inject.Named; + import org.apache.causeway.applib.annotation.Bounding; import org.apache.causeway.applib.annotation.DomainObject; import org.apache.causeway.applib.annotation.DomainService; +import org.apache.causeway.applib.annotation.Publishing; import org.apache.causeway.applib.id.LogicalType; import org.apache.causeway.applib.mixins.system.HasInteractionId; import org.apache.causeway.commons.collections.Can; import org.apache.causeway.core.config.metamodel.facets.DomainObjectConfigOptions; import org.apache.causeway.core.metamodel._testing.MetaModelContext_forTesting; import org.apache.causeway.core.metamodel.facetapi.Facet; -import org.apache.causeway.core.metamodel.facets.AbstractTestWithMetaModelContext; import org.apache.causeway.core.metamodel.facets.FacetFactoryTestAbstract; import org.apache.causeway.core.metamodel.facets.object.autocomplete.AutoCompleteFacet; import org.apache.causeway.core.metamodel.facets.object.domainobject.autocomplete.AutoCompleteFacetForDomainObjectAnnotation; @@ -65,6 +58,10 @@ import org.apache.causeway.core.metamodel.facets.objectvalue.choices.ChoicesFacet; import org.apache.causeway.core.metamodel.spec.IntrospectionState; import org.apache.causeway.core.metamodel.specloader.validator.ValidationFailures; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; class DomainObjectAnnotationFacetFactoryTest extends FacetFactoryTestAbstract { diff --git a/extensions/core/commandlog/applib/pom.xml b/extensions/core/commandlog/applib/pom.xml index a6336d913d1..5fbf47564bb 100644 --- a/extensions/core/commandlog/applib/pom.xml +++ b/extensions/core/commandlog/applib/pom.xml @@ -76,6 +76,16 @@ causeway-core-runtimeservices + + + org.apache.causeway.valuetypes + causeway-valuetypes-asciidoc-applib + + + org.apache.causeway.valuetypes + causeway-valuetypes-asciidoc-builder + + org.quartz-scheduler quartz diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/CausewayModuleExtCommandLogApplib.java b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/CausewayModuleExtCommandLogApplib.java index dc025903595..d8837822808 100644 --- a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/CausewayModuleExtCommandLogApplib.java +++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/CausewayModuleExtCommandLogApplib.java @@ -18,11 +18,10 @@ */ package org.apache.causeway.extensions.commandlog.applib; -import org.apache.causeway.extensions.commandlog.applib.spi.RunBackgroundCommandsJobListener; - -import org.springframework.context.annotation.Configuration; -import org.springframework.context.annotation.Import; - +import org.apache.causeway.applib.services.bookmark.BookmarkService; +import org.apache.causeway.applib.services.command.CommandExecutorService; +import org.apache.causeway.applib.services.iactnlayer.InteractionService; +import org.apache.causeway.applib.services.repository.RepositoryService; import org.apache.causeway.core.config.util.SpringProfileUtil; import org.apache.causeway.extensions.commandlog.applib.app.CommandLogMenu; import org.apache.causeway.extensions.commandlog.applib.contributions.HasInteractionId_commandLogEntry; @@ -30,19 +29,31 @@ import org.apache.causeway.extensions.commandlog.applib.contributions.Object_recentCommands; import org.apache.causeway.extensions.commandlog.applib.dom.BackgroundService; import org.apache.causeway.extensions.commandlog.applib.dom.CommandLogEntry; +import org.apache.causeway.extensions.commandlog.applib.dom.CommandLogEntryRepository; import org.apache.causeway.extensions.commandlog.applib.dom.mixins.CommandLogEntry_childCommands; import org.apache.causeway.extensions.commandlog.applib.dom.mixins.CommandLogEntry_openResultObject; import org.apache.causeway.extensions.commandlog.applib.dom.mixins.CommandLogEntry_siblingCommands; +import org.apache.causeway.extensions.commandlog.applib.dom.replay.CommandExportManager; +import org.apache.causeway.extensions.commandlog.applib.dom.replay.CommandReplayManager; +import org.apache.causeway.extensions.commandlog.applib.dom.replay.ReplayContext; import org.apache.causeway.extensions.commandlog.applib.fakescheduler.FakeScheduler; import org.apache.causeway.extensions.commandlog.applib.job.BackgroundCommandsJobControl; import org.apache.causeway.extensions.commandlog.applib.job.RunBackgroundCommandsJob; +import org.apache.causeway.extensions.commandlog.applib.spi.RunBackgroundCommandsJobListener; import org.apache.causeway.extensions.commandlog.applib.subscriber.CommandSubscriberForCommandLog; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; -@Configuration +@Configuration(proxyBeanMethods = false) @Import({ // @DomainService's CommandLogMenu.class, + // viewmodels + CommandExportManager.class, + CommandReplayManager.class, + // mixins HasInteractionId_commandLogEntry.class, HasUsername_recentCommandsByUser.class, @@ -108,4 +119,14 @@ public static void honorSystemEnvironment() { } } + @Bean ReplayContext replayContext( + final BookmarkService bookmarkService, + final RepositoryService repositoryService, + final InteractionService interactionService, + final CommandLogEntryRepository commandLogEntryRepository, + final CommandExecutorService commandExecutorService) { + return new ReplayContext(bookmarkService, repositoryService, interactionService, + commandLogEntryRepository, commandExecutorService); + } + } diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/app/CommandLogMenu.java b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/app/CommandLogMenu.java index 6f2be45e9ac..607bd8e4583 100644 --- a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/app/CommandLogMenu.java +++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/app/CommandLogMenu.java @@ -25,8 +25,6 @@ import javax.inject.Inject; import javax.inject.Named; -import org.springframework.lang.Nullable; - import org.apache.causeway.applib.annotation.Action; import org.apache.causeway.applib.annotation.ActionLayout; import org.apache.causeway.applib.annotation.DomainService; @@ -40,6 +38,10 @@ import org.apache.causeway.extensions.commandlog.applib.CausewayModuleExtCommandLogApplib; import org.apache.causeway.extensions.commandlog.applib.dom.CommandLogEntry; import org.apache.causeway.extensions.commandlog.applib.dom.CommandLogEntryRepository; +import org.apache.causeway.extensions.commandlog.applib.dom.replay.CommandExportManager; +import org.apache.causeway.extensions.commandlog.applib.dom.replay.CommandReplayManager; +import org.apache.causeway.extensions.commandlog.applib.dom.replay.ReplayContext; +import org.springframework.lang.Nullable; import lombok.RequiredArgsConstructor; @@ -68,7 +70,7 @@ public static abstract class ActionDomainEvent final CommandLogEntryRepository commandLogEntryRepository; final ClockService clockService; - + final ReplayContext replayContext; @Action( commandPublishing = Publishing.DISABLED, @@ -147,8 +149,37 @@ public class DomainEvent extends ActionDomainEvent { } } } + @Action( + commandPublishing = Publishing.DISABLED, + domainEvent = exportManager.DomainEvent.class, + executionPublishing = Publishing.DISABLED, + restrictTo = RestrictTo.PROTOTYPING, + semantics = SemanticsOf.SAFE + ) + @ActionLayout(cssClassFa = "solid share-from-square", sequence="50") + public class exportManager { + public class DomainEvent extends ActionDomainEvent { } + @MemberSupport public CommandExportManager act() { + return new CommandExportManager(null, replayContext); + } + } + @Action( + commandPublishing = Publishing.DISABLED, + domainEvent = replayManager.DomainEvent.class, + executionPublishing = Publishing.DISABLED, + restrictTo = RestrictTo.PROTOTYPING, + semantics = SemanticsOf.SAFE + ) + @ActionLayout(cssClassFa = "solid circle-play", sequence="51") + public class replayManager { + public class DomainEvent extends ActionDomainEvent { } + + @MemberSupport public CommandReplayManager act() { + return new CommandReplayManager(null, replayContext); + } + } private LocalDate now() { return clockService.getClock().nowAsLocalDate(ZoneId.systemDefault()); diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/CommandLogEntry.java b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/CommandLogEntry.java index 328fd600797..e3ca1d877b7 100644 --- a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/CommandLogEntry.java +++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/CommandLogEntry.java @@ -26,6 +26,7 @@ import java.util.Arrays; import java.util.List; import java.util.Objects; +import java.util.Optional; import java.util.UUID; import java.util.function.Consumer; @@ -170,8 +171,6 @@ public void sync(final Command command) { setResult(command.getResult()); setException(command.getException()); - - setReplayState(org.apache.causeway.extensions.commandlog.applib.dom.ReplayState.UNDEFINED); } @@ -182,7 +181,8 @@ public void sync(final Command command) { * @param replayState - controls whether this is to be replayed * @param targetIndex - if the command represents a bulk action, then it is flattened out when replayed; this indicates which target to execute against. */ - public CommandLogEntry( + @Programmatic + protected void init( final CommandDto commandDto, final org.apache.causeway.extensions.commandlog.applib.dom.ReplayState replayState, final int targetIndex) { @@ -198,8 +198,11 @@ public CommandLogEntry( // the hierarchy of commands calling other commands is only available on the primary system. setParentInteractionId(null); - setStartedAt(JavaSqlXMLGregorianCalendarMarshalling.toTimestamp(commandDto.getTimings().getStartedAt())); - setCompletedAt(JavaSqlXMLGregorianCalendarMarshalling.toTimestamp(commandDto.getTimings().getCompletedAt())); + Optional.ofNullable(commandDto.getTimings()) + .ifPresent(timings->{ + setStartedAt(JavaSqlXMLGregorianCalendarMarshalling.toTimestamp(timings.getStartedAt())); + setCompletedAt(JavaSqlXMLGregorianCalendarMarshalling.toTimestamp(timings.getCompletedAt())); + }); copyOver(commandDto, UserDataKeys.RESULT, value -> this.setResult(Bookmark.parse(value).orElse(null))); copyOver(commandDto, UserDataKeys.EXCEPTION, this::setException); @@ -210,23 +213,25 @@ public CommandLogEntry( static void copyOver( final CommandDto commandDto, final String key, final Consumer consume) { - commandDto.getUserData().getEntry() - .stream() - .filter(x -> Objects.equals(x.getKey(), key)) - .map(MapDto.Entry::getValue) - .filter(Objects::nonNull) - .filter(x -> x.length() > 0) - .findFirst() - .ifPresent(consume); - } - - - private static final DateTimeFormatter formatter = + Optional.ofNullable(commandDto.getUserData()) + .ifPresent(userdata->{ + userdata.getEntry() + .stream() + .filter(x -> Objects.equals(x.getKey(), key)) + .map(MapDto.Entry::getValue) + .filter(Objects::nonNull) + .filter(x -> x.length() > 0) + .findFirst() + .ifPresent(consume); + }); + } + + static final DateTimeFormatter DATETIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"); @ObjectSupport public String title() { return new TitleBuffer() - .append(formatter.format(getTimestamp().toLocalDateTime())) + .append(DATETIME_FORMATTER.format(getTimestamp().toLocalDateTime())) .append(" ") .append(getLogicalMemberIdentifier()) .toString(); diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/CommandLogEntryRepository.java b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/CommandLogEntryRepository.java index 2c4b14bf0ed..bf8401c6e8c 100644 --- a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/CommandLogEntryRepository.java +++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/CommandLogEntryRepository.java @@ -19,17 +19,19 @@ package org.apache.causeway.extensions.commandlog.applib.dom; import java.time.LocalDate; +import java.util.Collections; import java.util.List; import java.util.Optional; import java.util.UUID; - -import org.springframework.lang.Nullable; +import java.util.stream.Collectors; import org.apache.causeway.applib.exceptions.RecoverableException; import org.apache.causeway.applib.services.bookmark.Bookmark; import org.apache.causeway.applib.services.command.Command; +import org.apache.causeway.commons.internal.base._NullSafe; import org.apache.causeway.schema.cmd.v2.CommandDto; import org.apache.causeway.schema.cmd.v2.CommandsDto; +import org.springframework.lang.Nullable; import lombok.Getter; @@ -158,13 +160,29 @@ List findByTargetAndFromAndTo( */ Optional findMostRecentCompleted(); - List findNotYetReplayed(); - + /** + * Command Replay feature: Can replay or retry. + */ + List findReplayPendingOrFailed(); + /** + * Command Replay feature: Cannot replay or retry. + */ + List findReplaySucceededOrExcluded(); CommandLogEntry saveForReplay(final CommandDto dto); + default List saveForReplay(@Nullable final List commandDtoList) { + return _NullSafe.stream(commandDtoList) + .map(this::saveForReplay) + .collect(Collectors.toList()); + } - List saveForReplay(final CommandsDto commandsDto); + default List saveForReplay(@Nullable final CommandsDto commandsDto) { + var commandDtoList = Optional.ofNullable(commandsDto) + .map(CommandsDto::getCommandDto) + .orElseGet(Collections::emptyList); + return saveForReplay(commandDtoList); + } void persist(final CommandLogEntry commandLogEntry); @@ -190,5 +208,4 @@ List findCommandsOnPrimaryElseFail( */ void removeAll(); - } diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/CommandLogEntryRepositoryAbstract.java b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/CommandLogEntryRepositoryAbstract.java index 8f62da00d6f..fdbb8b23379 100644 --- a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/CommandLogEntryRepositoryAbstract.java +++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/CommandLogEntryRepositoryAbstract.java @@ -22,7 +22,6 @@ import java.time.Instant; import java.time.LocalDate; import java.time.ZoneId; -import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Optional; @@ -31,24 +30,16 @@ import javax.inject.Inject; import javax.inject.Provider; -import org.springframework.lang.Nullable; - -import org.apache.causeway.applib.jaxb.JavaSqlXMLGregorianCalendarMarshalling; import org.apache.causeway.applib.query.Query; import org.apache.causeway.applib.query.QueryRange; import org.apache.causeway.applib.services.bookmark.Bookmark; import org.apache.causeway.applib.services.command.Command; import org.apache.causeway.applib.services.factory.FactoryService; import org.apache.causeway.applib.services.repository.RepositoryService; -import org.apache.causeway.applib.util.schema.CommandDtoUtils; import org.apache.causeway.commons.internal.base._Casts; import org.apache.causeway.core.config.environment.CausewaySystemEnvironment; import org.apache.causeway.schema.cmd.v2.CommandDto; -import org.apache.causeway.schema.cmd.v2.CommandsDto; -import org.apache.causeway.schema.cmd.v2.MapDto; -import org.apache.causeway.schema.common.v2.InteractionType; - -import lombok.val; +import org.springframework.lang.Nullable; /** * Provides supporting functionality for querying {@link CommandLogEntry command log entry} entities. @@ -63,7 +54,6 @@ public abstract class CommandLogEntryRepositoryAbstract commandLogEntryClass; - protected CommandLogEntryRepositoryAbstract(final Class commandLogEntryClass) { this.commandLogEntryClass = commandLogEntryClass; } @@ -72,10 +62,12 @@ public Class getEntityClass() { return commandLogEntryClass; } + @Override public C createEntryAndPersist( final Command command, final UUID parentInteractionIdIfAny, final ExecuteIn executeIn) { C c = factoryService.detachedEntity(commandLogEntryClass); c.sync(command); + c.setReplayState(ReplayState.UNDEFINED); c.setParentInteractionId(parentInteractionIdIfAny); c.setExecuteIn(executeIn); persist(c); @@ -148,14 +140,14 @@ public List findCompleted() { ); } - + @Override public List findByTargetAndFromAndTo( final Bookmark target, final @Nullable LocalDate from, final @Nullable LocalDate to) { - val fromTs = toTimestampStartOfDayWithOffset(from, 0); - val toTs = toTimestampStartOfDayWithOffset(to, 1); + var fromTs = toTimestampStartOfDayWithOffset(from, 0); + var toTs = toTimestampStartOfDayWithOffset(to, 1); final Query query; if(from != null) { @@ -182,7 +174,6 @@ public List findByTargetAndFromAndTo( return _Casts.uncheckedCast(repositoryService().allMatches(query)); } - @Override public List findMostRecent() { return findMostRecent(100); @@ -196,7 +187,6 @@ public List findMostRecent(final int limit) { ); } - @Override public List findRecentByUsername(final String username) { return _Casts.uncheckedCast( @@ -207,8 +197,7 @@ public List findRecentByUsername(final String username) { ); } - - + @Override public List findRecentByTarget(final Bookmark target) { return _Casts.uncheckedCast( repositoryService().allMatches( @@ -219,6 +208,7 @@ public List findRecentByTarget(final Bookmark target) { ); } + @Override public List findRecentByTargetOrResult(final Bookmark targetOrResult) { return _Casts.uncheckedCast( repositoryService().allMatches( @@ -229,7 +219,6 @@ public List findRecentByTargetOrResult(final Bookmark targetOrR ); } - /** * Intended to support the replay of commands on a secondary instance of * the application. @@ -298,6 +287,7 @@ public List findBackgroundAndNotYetStarted() { Query.named(commandLogEntryClass, CommandLogEntry.Nq.FIND_BACKGROUND_AND_NOT_YET_STARTED))); } + @Override public List findRecentBackgroundByTarget(final Bookmark target) { return _Casts.uncheckedCast( repositoryService().allMatches( @@ -308,7 +298,6 @@ public List findRecentBackgroundByTarget(final Bookmark target) ); } - /** * The most recent replayed command previously replicated from primary to * secondary. @@ -344,58 +333,50 @@ public Optional findMostRecentCompleted() { Query.named(commandLogEntryClass, CommandLogEntry.Nq.FIND_MOST_RECENT_COMPLETED)) ); } - @Override - public List findNotYetReplayed() { + public List findReplayPendingOrFailed() { return _Casts.uncheckedCast( repositoryService().allMatches( Query.named(commandLogEntryClass, CommandLogEntry.Nq.FIND_BY_REPLAY_STATE) - .withParameter("replayState", ReplayState.PENDING) - .withLimit(10)) + .withParameter("replayState1", ReplayState.PENDING) + .withParameter("replayState2", ReplayState.FAILED)) + ); + } + /** + * Command Replay feature: Cannot replay or retry. + */ + @Override + public List findReplaySucceededOrExcluded() { + return _Casts.uncheckedCast( + repositoryService().allMatches( + Query.named(commandLogEntryClass, CommandLogEntry.Nq.FIND_BY_REPLAY_STATE) + .withParameter("replayState1", ReplayState.OK) + .withParameter("replayState2", ReplayState.EXCLUDED)) ); } + @Override + public C saveForReplay(final CommandDto commandToReplay) { - public C saveForReplay(final CommandDto dto) { - - if(dto.getMember().getInteractionType() == InteractionType.ACTION_INVOCATION) { - final MapDto userData = dto.getUserData(); - if (userData == null ) { - throw new IllegalStateException(String.format( - "Can only persist action DTOs with additional userData; got: \n%s", - CommandDtoUtils.dtoMapper().toString(dto))); - } - } - - final C commandJdo = factoryService.detachedEntity(commandLogEntryClass); - - commandJdo.setInteractionId(UUID.fromString(dto.getInteractionId())); - commandJdo.setTimestamp(JavaSqlXMLGregorianCalendarMarshalling.toTimestamp(dto.getTimestamp())); - commandJdo.setUsername(dto.getUsername()); - - commandJdo.setReplayState(ReplayState.PENDING); - - val firstTargetOidDto = dto.getTargets().getOid().get(0); - commandJdo.setTarget(Bookmark.forOidDto(firstTargetOidDto)); - commandJdo.setCommandDto(dto); - commandJdo.setLogicalMemberIdentifier(dto.getMember().getLogicalMemberIdentifier()); - - persist(commandJdo); +//TODO why? +// if(commandToReplay.getMember().getInteractionType() == InteractionType.ACTION_INVOCATION) { +// final MapDto userData = commandToReplay.getUserData(); +// if (userData == null ) +// throw new IllegalStateException(String.format( +// "Can only persist action DTOs with additional userData; got: \n%s", +// CommandDtoUtils.dtoMapper().toString(commandToReplay))); +// } - return commandJdo; - } + final C entity = factoryService.detachedEntity(commandLogEntryClass); + entity.init(commandToReplay, ReplayState.PENDING, 0); + entity.setParentInteractionId(null); // n/a for replay + entity.setExecuteIn(null); // to be specified later depending on user action + persist(entity); - public List saveForReplay(final CommandsDto commandsDto) { - val commandDtos = commandsDto.getCommandDto(); - val commands = new ArrayList(); - for (val dto : commandDtos) { - commands.add(saveForReplay(dto)); - } - return commands; + return entity; } - @Override public void persist(final CommandLogEntry commandLogEntry) { repositoryService().persistAndFlush(commandLogEntry); @@ -408,7 +389,6 @@ public void truncateLog() { // -- - @Override public List findCommandsOnPrimaryElseFail( final @Nullable UUID interactionId, @@ -421,10 +401,8 @@ public List findCommandsOnPrimaryElseFail( return commands; } - - private C findByInteractionIdElseNull(final UUID interactionId) { - val q = Query.named(commandLogEntryClass, CommandLogEntry.Nq.FIND_BY_INTERACTION_ID) + var q = Query.named(commandLogEntryClass, CommandLogEntry.Nq.FIND_BY_INTERACTION_ID) .withParameter("interactionId", interactionId); return repositoryService().uniqueMatch(q).orElse(null); } @@ -435,9 +413,9 @@ private List findSince( // DN generates incorrect SQL for SQL Server if count set to 1; so we set to 2 and then trim // XXX that's a historic workaround, should rather be fixed upstream - val needsTrimFix = batchSize != null && batchSize == 1; + var needsTrimFix = batchSize != null && batchSize == 1; - val q = Query.named(commandLogEntryClass, CommandLogEntry.Nq.FIND_SINCE) + var q = Query.named(commandLogEntryClass, CommandLogEntry.Nq.FIND_SINCE) .withParameter("timestamp", timestamp) .withRange(QueryRange.limit( needsTrimFix ? 2L : batchSize @@ -449,10 +427,6 @@ private List findSince( : commandJdos; } - - - - private RepositoryService repositoryService() { return repositoryServiceProvider.get(); } @@ -479,7 +453,6 @@ public List findAll() { return _Casts.uncheckedCast(repositoryService().allInstances(commandLogEntryClass)); } - /** * intended for testing purposes only */ @@ -491,5 +464,4 @@ public void removeAll() { repositoryService().removeAll(commandLogEntryClass); } - } diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/ReplayState.java b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/ReplayState.java index 30a137e1aa3..e644cd240d6 100644 --- a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/ReplayState.java +++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/ReplayState.java @@ -18,20 +18,22 @@ */ package org.apache.causeway.extensions.commandlog.applib.dom; +import org.springframework.lang.Nullable; + /** - * Curently unused. - * - *

- * This enum to support the (incubating) Command Replay extension. - *

+ * Introduced in support of the Command Replay Feature. * * @since 2.x {@index} */ public enum ReplayState { /** - * As used on primary system. + * As used on primary system, indicating an initial state. */ UNDEFINED, + /** + * Marks a {@link CommandLogEntry} as exported, such that consecutive export actions will skip those. + */ + EXPORTED, /** * For use on secondary system, indicates that the command has not yet been replayed. */ @@ -47,8 +49,39 @@ public enum ReplayState { /** * For use on secondary system, indicates that the command should not be replayed. */ - EXCLUDED, - ; + EXCLUDED; + + public boolean isExported() { return this == EXPORTED; } + public boolean isFailed() { return this == FAILED; } + + public boolean canExport() { + return this == ReplayState.UNDEFINED; + } + + public boolean canReplayOrRetryOrMarkForExclusion() { + return this == ReplayState.PENDING + || this == ReplayState.FAILED; + } + + // -- NULL SAFE + + public static boolean canExport(final @Nullable ReplayState replayState) { + return replayState!=null + ? replayState.canExport() + : true; + } + + public static boolean canReplayOrRetryOrMarkForExclusion(final @Nullable ReplayState replayState) { + return replayState!=null + ? replayState.canReplayOrRetryOrMarkForExclusion() + : false; + } + + public static boolean isExported(final ReplayState replayState) { + return replayState!=null + ? replayState.isExported() + : false; + } - public boolean isFailed() { return this == FAILED;} } + diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandExportManager.java b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandExportManager.java new file mode 100644 index 00000000000..f8d878143c3 --- /dev/null +++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandExportManager.java @@ -0,0 +1,185 @@ +/* + * 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.causeway.extensions.commandlog.applib.dom.replay; + +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +import javax.inject.Inject; +import javax.inject.Named; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.experimental.Accessors; + +import org.apache.causeway.applib.ViewModel; +import org.apache.causeway.applib.annotation.Action; +import org.apache.causeway.applib.annotation.ActionLayout; +import org.apache.causeway.applib.annotation.Collection; +import org.apache.causeway.applib.annotation.CollectionLayout; +import org.apache.causeway.applib.annotation.DomainObject; +import org.apache.causeway.applib.annotation.DomainObjectLayout; +import org.apache.causeway.applib.annotation.Introspection; +import org.apache.causeway.applib.annotation.ObjectSupport; +import org.apache.causeway.applib.annotation.SemanticsOf; +import org.apache.causeway.applib.util.schema.CommandDtoUtils; +import org.apache.causeway.applib.value.Blob; +import org.apache.causeway.applib.value.Clob; +import org.apache.causeway.applib.value.NamedWithMimeType.CommonMimeType; +import org.apache.causeway.extensions.commandlog.applib.CausewayModuleExtCommandLogApplib; +import org.apache.causeway.extensions.commandlog.applib.dom.CommandLogEntry; +import org.apache.causeway.extensions.commandlog.applib.dom.CommandLogEntryRepository; +import org.apache.causeway.extensions.commandlog.applib.dom.ReplayState; + +@DomainObject(introspection = Introspection.ANNOTATION_REQUIRED) +@DomainObjectLayout(cssClassFa = "solid share-from-square") +@Named(CommandExportManager.LOGICAL_TYPE_NAME) +@AllArgsConstructor +public final class CommandExportManager implements ViewModel { + + public static final String LOGICAL_TYPE_NAME = CausewayModuleExtCommandLogApplib.NAMESPACE + ".CommandExportManager"; + + @Getter @Accessors(fluent = true) + private final ReplayContext replayContext; + + @Inject + public CommandExportManager( + final String memento, + final ReplayContext replayContext) { + this(replayContext); + } + + @ObjectSupport public String title() { + return "Command Export Manager"; + } + + @Action(semantics = SemanticsOf.IDEMPOTENT_ARE_YOU_SURE) + @ActionLayout( + sequence = "0.1", + describedAs = "Deletes all commands, regardless of state (cannot be undone)") + public CommandExportManager deleteAll() { + commandLogEntryRepository().removeAll(); + return this; + } + + // -- NOT YET EXPORTED + + @Collection + @CollectionLayout( + describedAs = "Commands that can be exported") + public List getNotYetExported() { + return commandLogEntryRepository().findAll().stream() + .filter(entry->ReplayState.canExport(entry.getReplayState())) + .map(entry->new ReplayableCommand( + replayContext.bookmarkService().bookmarkFor(entry).get().getIdentifier(), + replayContext)) + .collect(Collectors.toList()); + } + + @Action(choicesFrom = "notYetExported", semantics = SemanticsOf.NON_IDEMPOTENT) + @ActionLayout(associateWith = "notYetExported", + sequence = "1.1", + cssClassFa = "solid share-from-square", + cssClass = "btn-primary", + describedAs = "Exports selected Commands as zipped DTOs for import later. " + + "(You need to refresh the page to see changed states.)") + public Blob exportSelected( + final List selected) { + + var selectedCommandLogEntries = selected.stream() + .map(ReplayableCommand::commandLogEntry) + .filter(Optional::isPresent) + .map(Optional::get) + .sorted() + .collect(Collectors.toList()); + + var yaml = CommandDtoUtils.toYaml( + selectedCommandLogEntries.stream() + .filter(entry->ReplayState.isExported(entry.getReplayState())) + .map(CommandLogEntry::getCommandDto) + .collect(Collectors.toList())); + + var blob = Clob.of("commands.yaml", CommonMimeType.YAML, yaml) + .toBlobUtf8() + .zip(); + + // do this last once we have successfully created the Clob + selectedCommandLogEntries.forEach(c->c.setReplayState(ReplayState.EXPORTED)); + + return blob; + } + + @Action(choicesFrom = "notYetExported") + @ActionLayout(associateWith = "notYetExported", sequence = "1.2", + describedAs = "Deletes selected Commands (cannot be undone)") + public CommandExportManager deleteSelected(final List selected) { + selected.stream() + .forEach(ReplayableCommand::delete); // filtered on its own responsibility + return this; + } + + // -- EXPORTED + + @Collection + @CollectionLayout( + describedAs = "Commands that were exported") + public List getExported() { + return commandLogEntryRepository().findAll().stream() + .filter(entry->ReplayState.isExported(entry.getReplayState())) + .map(entry->new ReplayableCommand( + replayContext.bookmarkService().bookmarkFor(entry).get().getIdentifier(), + replayContext)) + .collect(Collectors.toList()); + } + + @Action(choicesFrom = "exported") + @ActionLayout(associateWith = "exported", sequence = "2.1", + describedAs = "Makes selected Commands exportable (again)") + public CommandExportManager makeSelectedExportable(final List selected) { + selected.stream() + .forEach(ReplayableCommand::makeExportable); // filtered on its own responsibility + return this; + } + + @Action(choicesFrom = "exported") + @ActionLayout(associateWith = "exported", sequence = "2.2", + named = "Delete Selected", + describedAs = "Deletes selected Commands (cannot be undone)") + public CommandExportManager deleteSelected2(final List selected) { + selected.stream() + .forEach(ReplayableCommand::delete); // filtered on its own responsibility + return this; + } + + // -- VM STATE + + @Override + public String viewModelMemento() { + // TODO could use to store filter state + return null; + } + + // -- HELPER + + private CommandLogEntryRepository commandLogEntryRepository() { + return replayContext.commandLogEntryRepository(); + } + +} diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandExportManager.layout.fallback.xml b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandExportManager.layout.fallback.xml new file mode 100644 index 00000000000..df20c9b73aa --- /dev/null +++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandExportManager.layout.fallback.xml @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandReplayManager.java b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandReplayManager.java new file mode 100644 index 00000000000..ba592c5c7f8 --- /dev/null +++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandReplayManager.java @@ -0,0 +1,187 @@ +/* + * 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.causeway.extensions.commandlog.applib.dom.replay; + +import java.util.List; +import java.util.stream.Collectors; + +import javax.inject.Inject; +import javax.inject.Named; + +import org.apache.causeway.applib.ViewModel; +import org.apache.causeway.applib.annotation.Action; +import org.apache.causeway.applib.annotation.ActionLayout; +import org.apache.causeway.applib.annotation.Collection; +import org.apache.causeway.applib.annotation.CollectionLayout; +import org.apache.causeway.applib.annotation.DomainObject; +import org.apache.causeway.applib.annotation.DomainObjectLayout; +import org.apache.causeway.applib.annotation.Introspection; +import org.apache.causeway.applib.annotation.ObjectSupport; +import org.apache.causeway.applib.annotation.Parameter; +import org.apache.causeway.applib.annotation.SemanticsOf; +import org.apache.causeway.applib.util.schema.CommandDtoUtils; +import org.apache.causeway.applib.value.Blob; +import org.apache.causeway.applib.value.NamedWithMimeType.CommonMimeType; +import org.apache.causeway.extensions.commandlog.applib.CausewayModuleExtCommandLogApplib; +import org.apache.causeway.extensions.commandlog.applib.dom.CommandLogEntryRepository; +import org.apache.causeway.schema.cmd.v2.CommandDto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.experimental.Accessors; + +@DomainObject(introspection = Introspection.ANNOTATION_REQUIRED) +@DomainObjectLayout(cssClassFa = "solid circle-play") +@Named(CommandReplayManager.LOGICAL_TYPE_NAME) +@AllArgsConstructor +public final class CommandReplayManager implements ViewModel { + + public static final String LOGICAL_TYPE_NAME = CausewayModuleExtCommandLogApplib.NAMESPACE + ".CommandReplayManager"; + + @Getter @Accessors(fluent = true) + private final ReplayContext replayContext; + + @Inject + public CommandReplayManager( + final String memento, + final ReplayContext replayContext) { + this(replayContext); + } + + @ObjectSupport public String title() { + return "Command Replay Manager"; + } + + @Action + @ActionLayout( + sequence = "0.1", + cssClass = "btn-primary", + describedAs = "Imports commands from a zipped yaml, then persists them with replayState=PENDING.") + public CommandReplayManager importCommands( + @Parameter(fileAccept = ".zip") + final Blob zippedCommandsYaml) { + + var yamlDs = zippedCommandsYaml.unZip(CommonMimeType.YAML).asDataSource(); + + final List commandDtos = CommandDtoUtils.fromYaml(yamlDs); + commandDtos.forEach(commandLogEntryRepository()::saveForReplay); + + return this; + } + + @Action(semantics = SemanticsOf.IDEMPOTENT_ARE_YOU_SURE) + @ActionLayout( + sequence = "0.2", + describedAs = "Deletes all commands, regardless of state (cannot be undone)") + public CommandReplayManager deleteAll() { + commandLogEntryRepository().removeAll(); + return this; + } + + // -- PENDING OR FAILED + + @Collection + @CollectionLayout( + describedAs = "Imported Commands that can be either replayed (replayState=PENDING) or retried (when replayState=FAILED)") + public List getPendingOrFailed() { + return commandLogEntryRepository().findReplayPendingOrFailed().stream() + .map(entry->new ReplayableCommand( + replayContext.bookmarkService().bookmarkFor(entry).get().getIdentifier(), + replayContext)) + .collect(Collectors.toList()); + } + + @Action(choicesFrom = "pendingOrFailed") + @ActionLayout(associateWith = "pendingOrFailed", + sequence = "1.1", + cssClassFa = "solid circle-play", + cssClass = "btn-primary", + describedAs = "Executes the list of commands in sequence, after having sorted them by their timestamp. " + + "If any of the given commands fails, " + + "the surrounding transaction is rolled back and any successful commands are undone). " + + "The command, that caused the failure, gets marked as FAILED.") + public CommandReplayManager replayOrRetrySelected(final List selected) { + var replayables = selected.stream() + .sorted() + .collect(Collectors.toList()); + for(var replayableCommand : replayables) { + var tryReplayOrRetry = replayableCommand.tryReplayOrRetry(); // filtered on its own responsibility + if(tryReplayOrRetry.isFailure()) + return this; // stop further execution + } + return this; + } + + @Action(choicesFrom = "pendingOrFailed") + @ActionLayout(associateWith = "pendingOrFailed", sequence = "1.2", + describedAs = "Marks selected Commands to be EXCLUDED from replay") + public CommandReplayManager excludeSelectedFromReplay(final List selected) { + selected.stream() + .forEach(ReplayableCommand::excludeFromReplay); // filtered on its own responsibility + return this; + } + + @Action(choicesFrom = "pendingOrFailed") + @ActionLayout(associateWith = "pendingOrFailed", sequence = "1.3", + describedAs = "Deletes selected Commands (cannot be undone)") + public CommandReplayManager deleteSelected(final List selected) { + selected.stream() + .forEach(ReplayableCommand::delete); // filtered on its own responsibility + return this; + } + + // -- OK OR EXCLUDE + + @Collection + @CollectionLayout( + describedAs = "Imported Commands that were either replayed with success (replayState=OK) " + + "or marked to be excluded from replay (replayState=EXCLUDE)") + public List getSucceededOrExcluded() { + return commandLogEntryRepository().findReplaySucceededOrExcluded().stream() + .map(entry->new ReplayableCommand( + replayContext.bookmarkService().bookmarkFor(entry).get().getIdentifier(), + replayContext)) + .collect(Collectors.toList()); + } + + @Action(choicesFrom = "succeededOrExcluded") + @ActionLayout(associateWith = "succeededOrExcluded", + named = "Delete Selected", + describedAs = "Deletes selected Commands (cannot be undone)") + public CommandReplayManager deleteSelected2(final List selected) { + selected.stream() + .forEach(ReplayableCommand::delete); // filtered on its own responsibility + return this; + } + + // -- VM STATE + + @Override + public String viewModelMemento() { + // TODO could use to store filter state + return null; + } + + // -- HELPER + + private CommandLogEntryRepository commandLogEntryRepository() { + return replayContext.commandLogEntryRepository(); + } + +} diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandReplayManager.layout.fallback.xml b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandReplayManager.layout.fallback.xml new file mode 100644 index 00000000000..30489ca2158 --- /dev/null +++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandReplayManager.layout.fallback.xml @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayContext.java b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayContext.java new file mode 100644 index 00000000000..fee5baf5ef5 --- /dev/null +++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayContext.java @@ -0,0 +1,40 @@ +/* + * 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.causeway.extensions.commandlog.applib.dom.replay; + +import org.apache.causeway.applib.services.bookmark.BookmarkService; +import org.apache.causeway.applib.services.command.CommandExecutorService; +import org.apache.causeway.applib.services.iactnlayer.InteractionService; +import org.apache.causeway.applib.services.repository.RepositoryService; +import org.apache.causeway.extensions.commandlog.applib.dom.CommandLogEntryRepository; + +import lombok.Value; +import lombok.experimental.Accessors; + +/** + * Bundles dependencies for the replay logic. + */ +@Value @Accessors(fluent = true) +public final class ReplayContext { + BookmarkService bookmarkService; + RepositoryService repositoryService; + InteractionService interactionService; + CommandLogEntryRepository commandLogEntryRepository; + CommandExecutorService commandExecutorService; +} diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand.java b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand.java new file mode 100644 index 00000000000..7530a94eb05 --- /dev/null +++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand.java @@ -0,0 +1,406 @@ +/* + * 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.causeway.extensions.commandlog.applib.dom.replay; + +import java.time.ZonedDateTime; +import java.util.Comparator; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.Executors; + +import javax.inject.Inject; +import javax.inject.Named; + +import org.apache.causeway.applib.ViewModel; +import org.apache.causeway.applib.annotation.Action; +import org.apache.causeway.applib.annotation.ActionLayout; +import org.apache.causeway.applib.annotation.DomainObject; +import org.apache.causeway.applib.annotation.DomainObjectLayout; +import org.apache.causeway.applib.annotation.Introspection; +import org.apache.causeway.applib.annotation.LabelPosition; +import org.apache.causeway.applib.annotation.MemberSupport; +import org.apache.causeway.applib.annotation.ObjectSupport; +import org.apache.causeway.applib.annotation.Property; +import org.apache.causeway.applib.annotation.PropertyLayout; +import org.apache.causeway.applib.annotation.Where; +import org.apache.causeway.applib.jaxb.JavaTimeXMLGregorianCalendarMarshalling; +import org.apache.causeway.applib.services.bookmark.Bookmark; +import org.apache.causeway.applib.services.command.CommandExecutorService.InteractionContextPolicy; +import org.apache.causeway.commons.functional.Try; +import org.apache.causeway.commons.internal.base._Refs.ObjectReference; +import org.apache.causeway.commons.internal.base._Strings; +import org.apache.causeway.commons.internal.exceptions._Exceptions; +import org.apache.causeway.commons.io.JsonUtils; +import org.apache.causeway.commons.io.TextUtils; +import org.apache.causeway.commons.io.YamlUtils; +import org.apache.causeway.extensions.commandlog.applib.CausewayModuleExtCommandLogApplib; +import org.apache.causeway.extensions.commandlog.applib.dom.CommandLogEntry; +import org.apache.causeway.extensions.commandlog.applib.dom.ExecuteIn; +import org.apache.causeway.extensions.commandlog.applib.dom.ReplayState; +import org.apache.causeway.schema.cmd.v2.CommandDto; +import org.apache.causeway.schema.cmd.v2.MemberDto; +import org.apache.causeway.schema.common.v2.OidDto; +import org.apache.causeway.valuetypes.asciidoc.applib.value.AsciiDoc; +import org.apache.causeway.valuetypes.asciidoc.builder.AsciiDocBuilder; +import org.apache.causeway.valuetypes.asciidoc.builder.AsciiDocFactory; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.Value; +import lombok.experimental.Accessors; + +/** + * Viewmodel that wraps a {@link CommandLogEntry}. + */ +@DomainObject(introspection = Introspection.ENCAPSULATION_ENABLED) +@DomainObjectLayout(cssClassFa = "terminal") +@Named(ReplayableCommand.LOGICAL_TYPE_NAME) +@AllArgsConstructor +public final class ReplayableCommand implements ViewModel, Comparable { + + @Getter @Accessors(fluent = true) + private final String commandLogEntryId; + + @Getter @Accessors(fluent = true) + private final ReplayContext replayContext; + + @Getter @Accessors(fluent = true) + private final ObjectReference recordRef; + + public static final String LOGICAL_TYPE_NAME = CausewayModuleExtCommandLogApplib.NAMESPACE + ".ReplayableCommand"; + + // decoupled from the underlying entity + @Value @Accessors(fluent = true) + final static class CommandRecord { + + final CommandDto commandDto; + final ReplayState replayState; + + boolean canReplayOrRetryOrMarkForExclusion() { + return replayState.canReplayOrRetryOrMarkForExclusion(); + } + public String faQuickIcon() { + switch(replayState) { + case UNDEFINED: return "solid terminal .col-indigo"; + case EXPORTED: return "solid terminal .col-indigo, solid circle-arrow-right .ov-size-80 .ov-right-45 .ov-bottom-45 .col-dodgerblue"; + case PENDING: return "solid terminal .col-indigo, solid circle-pause .ov-size-80 .ov-right-45 .ov-bottom-45 .col-gold"; + case OK: return "solid terminal .col-indigo, solid circle-check .ov-size-80 .ov-right-45 .ov-bottom-45 .col-green"; + case FAILED: return "solid terminal .col-indigo, solid circle-exclamation .ov-size-80 .ov-right-45 .ov-bottom-45 .col-red"; + case EXCLUDED: return "solid terminal .col-indigo, solid circle-xmark .ov-size-80 .ov-right-45 .ov-bottom-45 .col-grey"; + }; + return null; + } + } + + @Inject + public ReplayableCommand( + final String memento, + final ReplayContext replayContext) { + this(memento, replayContext, new ObjectReference<>(null)); + } + + @ObjectSupport public String title() { + return "Replayable Command"; + } + +//requires v4 +// @ObjectSupport public ObjectSupport.IconResource icon(final ObjectSupport.IconSize iconSize) { +// return commandRecord() +// .map(CommandRecord::faQuickIcon) +// .map(FontAwesomeLayers::fromQuickNotation) +// .map(ObjectSupport.FontAwesomeIconResource::new) +// .orElse(null); +// } + + @Property + @PropertyLayout( + sequence = "1.1", + fieldSetId = "details", + describedAs = "UUID of the original (replayabel) Command") + public UUID getInteractionId() { + return commandRecord() + .map(CommandRecord::commandDto) + .map(CommandDto::getInteractionId) + .map(UUID::fromString) + .orElse(null); + } + + @Property + @PropertyLayout( + sequence = "1.2", + fieldSetId = "details", + describedAs = "Timestamp of the original (replayabel) Command") + public ZonedDateTime getTimestamp() { + return commandRecord() + .map(CommandRecord::commandDto) + .map(CommandDto::getTimestamp) + .map(JavaTimeXMLGregorianCalendarMarshalling::toZonedDateTime) + .orElse(null); + } + + @Property + @PropertyLayout( + sequence = "2.1", + fieldSetId = "details", + describedAs = "Target Type of the original (replayabel) Command") + public String getTargetType() { + return commandRecord() + .map(CommandRecord::commandDto) + .map(commandDto->commandDto.getTargets().getOid().get(0)) + .map(OidDto::getType) + .orElse(null); + } + + @Property + @PropertyLayout( + sequence = "2.2", + fieldSetId = "details", + describedAs = "Target ID of the original (replayabel) Command") + public String getTargetId() { + return commandRecord() + .map(CommandRecord::commandDto) + .map(commandDto->commandDto.getTargets().getOid().get(0)) + .map(OidDto::getId) + .map(id->_Strings.ellipsifyAtEnd(id, 10, "...")) + .orElse(null); + } + + @Property + @PropertyLayout( + sequence = "3.1", + fieldSetId = "details", + describedAs = "Replayabel Action or Property, that was executed as captured by the original Command") + public String getMember() { + return commandRecord() + .map(CommandRecord::commandDto) + .map(CommandDto::getMember) + .map(MemberDto::getLogicalMemberIdentifier) + .map(TextUtils::cutter) + .map(cutter->cutter.keepAfter("#").getValue()) + .orElse(null); + } + + @Property + @PropertyLayout( + sequence = "4", + fieldSetId = "details", + describedAs = "Replay State of the original (replayabel) Command. " + + "When imported initially is PENDING. " + + "Then after replay its either OK or FAILED. " + + "Can be manually set to EXCLUDED, which marks it to be ignored for replay.") + public ReplayState getReplayState() { + return commandRecord() + .map(CommandRecord::replayState) + .orElse(null); + } + + @Property + @PropertyLayout( + sequence = "9", + fieldSetId = "dto", + hidden = Where.ALL_TABLES, + labelPosition = LabelPosition.NONE, + describedAs = "DTO of the original (replayabel) Command") + public AsciiDoc getDto() { + return commandRecord() + .map(CommandRecord::commandDto) + .map(commandDto->YamlUtils.toStringUtf8(commandDto, + JsonUtils::onlyIncludeNonNull)) + .map(yaml->new AsciiDocBuilder() + .append(doc->AsciiDocFactory.sourceBlock(doc, "yaml", yaml)) + .buildAsValue()) + .orElseGet(()->new AsciiDoc("empty")); + } + + // -- ACTIONS + + @Action + @ActionLayout( + sequence = "0.2", + describedAs = "Opens the associated Command Log Entry") + public CommandLogEntry openCommandLogEntry() { + return commandLogEntry() + .orElse(null); + } + + @Action + @ActionLayout( + sequence = "0.1", + cssClassFa = "solid circle-play", + cssClass = "btn-primary") + //hidden = Where.NOWHERE) // show in tables //TODO NPE bug + public ReplayableCommand replayOrRetry() { + tryReplayOrRetry(); + return this; + } + @MemberSupport private String disableReplayOrRetry() { + return commandRecord() + .map(CommandRecord::canReplayOrRetryOrMarkForExclusion) + .orElse(false) + ? null + : "Cannot replay, if neither PENDING nor FAILED"; + } + + @Action + @ActionLayout( + //hidden = Where.NOWHERE, // show in tables //TODO NPE bug + sequence = "2.1", + associateWith = "replayState", + describedAs = "Makes Command exportable (again)") + public ReplayableCommand makeExportable() { + if(disableMakeExportable()!=null) + return this; // safe guard when called programmatically + commandLogEntry() + .filter(commandLogEntry->ReplayState.isExported(commandLogEntry.getReplayState())) + .ifPresent(commandLogEntry->{ + commandLogEntry.setReplayState(ReplayState.UNDEFINED); + invalidateCachedRecord(); + }); + return this; + } + @MemberSupport private String disableMakeExportable() { + return commandRecord() + .map(rec->ReplayState.isExported(rec.replayState())) + .orElse(false) + ? null + : "Cannot make exportable, if not EXPORTED"; + } + + @Action + @ActionLayout( + //hidden = Where.NOWHERE, // show in tables //TODO NPE bug + sequence = "2.2", + associateWith = "replayState", + describedAs = "Marks Command to be EXCLUDED from replay.") + public ReplayableCommand excludeFromReplay() { + if(disableExcludeFromReplay()!=null) + return this; // safe guard when called programmatically + commandLogEntry() + .filter(ReplayableCommand::canReplayOrRetryOrMarkForExclusion) + .ifPresent(commandLogEntry->{ + commandLogEntry.setReplayState(ReplayState.EXCLUDED); + invalidateCachedRecord(); + }); + return this; + } + @MemberSupport private String disableExcludeFromReplay() { + return commandRecord() + .map(CommandRecord::canReplayOrRetryOrMarkForExclusion) + .orElse(false) + ? null + : "Cannot mark for exclusion, if neither PENDING nor FAILED"; + } + + @Action + @ActionLayout( + sequence = "0.3", + //hidden = Where.NOWHERE, // show in tables //TODO NPE bug + describedAs = "Deletes the associated Command Log Entry (cannot be undone)") + public void delete() { + commandLogEntry() + .ifPresent(commandLogEntry->{ + replayContext.repositoryService().remove(commandLogEntry); + invalidateCachedRecord(); + }); + } + + // -- EXECUTION ORDER GOVERNED BY TIMESTAMP + + private static final Comparator TIMESTAMP_COMPARATOR = + Comparator.nullsLast(Comparator.comparing(ReplayableCommand::getTimestamp)); + + @Override + public int compareTo(final ReplayableCommand other) { + return TIMESTAMP_COMPARATOR.compare(this, other); + } + + // -- VM STATE + + @Override + public String viewModelMemento() { + return commandLogEntryId; + } + + // -- UTIL + + Try tryReplayOrRetry() { + if(disableReplayOrRetry()!=null) + return Try.success(null); // guard against disallowed invocation + return commandLogEntry() + .filter(ReplayableCommand::canReplayOrRetryOrMarkForExclusion) + .map(commandLogEntry->{ + commandLogEntry.setExecuteIn(ExecuteIn.FOREGROUND); + var tryResultBookmark = replayContext.commandExecutorService().executeCommand( + InteractionContextPolicy.SWITCH_USER_AND_TIME, + commandLogEntry.getCommandDto()); + + // handle the replay outcome + tryResultBookmark.accept( + ex->onReplayError(commandLogEntry.getInteractionId(), ex), + bookmarkOpt->commandLogEntry.setReplayState(ReplayState.OK)); + + invalidateCachedRecord(); + + return tryResultBookmark + .mapSuccessAsNullable(__->this); + }) + .orElseGet(()->Try.success(null)); + } + + // -- HELPER + + private void invalidateCachedRecord() { + recordRef.update(__->null); // invalidate cache + } + + private Optional commandRecord() { + return Optional.ofNullable(recordRef.computeIfAbsent(()-> + commandLogEntry() + .filter(commandLogEntry->commandLogEntry.getCommandDto()!=null) + .filter(commandLogEntry->commandLogEntry.getReplayState()!=null) + .map(commandLogEntry->new CommandRecord( + commandLogEntry.getCommandDto(), + commandLogEntry.getReplayState())) + .orElse(null))); + } + + Optional commandLogEntry() { + return replayContext.bookmarkService().lookup(commandLogEntryBookmark(), CommandLogEntry.class); + } + + private Bookmark commandLogEntryBookmark() { + return replayContext.bookmarkService() + .bookmarkFor(CommandLogEntry.class, commandLogEntryId) + .orElseThrow(()->_Exceptions.unrecoverable( + "framework error: cannot create bookmark for CommandLogEntry using id '%s'", + commandLogEntryId)); + } + + private static boolean canReplayOrRetryOrMarkForExclusion(final CommandLogEntry commandLogEntry) { + return ReplayState.canReplayOrRetryOrMarkForExclusion(commandLogEntry.getReplayState()); + } + + private void onReplayError(final UUID interactionId, final Throwable ex) { + Executors.newSingleThreadExecutor() + .submit(()->replayContext.interactionService().runAnonymous(()-> + replayContext.commandLogEntryRepository().findByInteractionId(interactionId) + .ifPresent(entry->entry.saveAnalysis(ex.toString())))); + } + +} diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand.layout.fallback.xml b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand.layout.fallback.xml new file mode 100644 index 00000000000..0b6c5fee4d5 --- /dev/null +++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand.layout.fallback.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/integtest/CommandLog_IntegTestAbstract.java b/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/integtest/CommandLog_IntegTestAbstract.java index 0b69e6cc622..f1644491016 100644 --- a/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/integtest/CommandLog_IntegTestAbstract.java +++ b/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/integtest/CommandLog_IntegTestAbstract.java @@ -369,7 +369,7 @@ void test_all_the_repository_methods() { val to = from.plusDays(1); // when - List notYetReplayed = commandLogEntryRepository.findNotYetReplayed(); + List notYetReplayed = commandLogEntryRepository.findReplayPendingOrFailed(); // then Assertions.assertThat(notYetReplayed).isEmpty(); @@ -382,7 +382,7 @@ void test_all_the_repository_methods() { commandTarget1User1.setReplayState(ReplayState.PENDING); // when - List notYetReplayed2 = commandLogEntryRepository.findNotYetReplayed(); + List notYetReplayed2 = commandLogEntryRepository.findReplayPendingOrFailed(); // then Assertions.assertThat(notYetReplayed2).hasSize(1); diff --git a/extensions/core/commandlog/persistence-jdo/src/main/java/org/apache/causeway/extensions/commandlog/jdo/dom/CommandLogEntry.java b/extensions/core/commandlog/persistence-jdo/src/main/java/org/apache/causeway/extensions/commandlog/jdo/dom/CommandLogEntry.java index 04a0b51f2e9..f13dda3e615 100644 --- a/extensions/core/commandlog/persistence-jdo/src/main/java/org/apache/causeway/extensions/commandlog/jdo/dom/CommandLogEntry.java +++ b/extensions/core/commandlog/persistence-jdo/src/main/java/org/apache/causeway/extensions/commandlog/jdo/dom/CommandLogEntry.java @@ -237,7 +237,7 @@ public CommandLogEntry( final CommandDto commandDto, final org.apache.causeway.extensions.commandlog.applib.dom.ReplayState replayState, final int targetIndex) { - super(commandDto, replayState, targetIndex); + init(commandDto, replayState, targetIndex); } @PrimaryKey diff --git a/extensions/core/commandlog/persistence-jpa/src/main/java/org/apache/causeway/extensions/commandlog/jpa/dom/CommandLogEntry.java b/extensions/core/commandlog/persistence-jpa/src/main/java/org/apache/causeway/extensions/commandlog/jpa/dom/CommandLogEntry.java index de36b0f1ca6..2e58da4f90d 100644 --- a/extensions/core/commandlog/persistence-jpa/src/main/java/org/apache/causeway/extensions/commandlog/jpa/dom/CommandLogEntry.java +++ b/extensions/core/commandlog/persistence-jpa/src/main/java/org/apache/causeway/extensions/commandlog/jpa/dom/CommandLogEntry.java @@ -235,7 +235,7 @@ public CommandLogEntry( final CommandDto commandDto, final org.apache.causeway.extensions.commandlog.applib.dom.ReplayState replayState, final int targetIndex) { - super(commandDto, replayState, targetIndex); + init(commandDto, replayState, targetIndex); } diff --git a/incubator/extensions/core/commandreplay/secondary/src/main/java/org/apache/causeway/extensions/commandreplay/secondary/jobcallables/ReplicateAndRunCommands.java b/incubator/extensions/core/commandreplay/secondary/src/main/java/org/apache/causeway/extensions/commandreplay/secondary/jobcallables/ReplicateAndRunCommands.java index a01bf730d18..63df7dfcba1 100644 --- a/incubator/extensions/core/commandreplay/secondary/src/main/java/org/apache/causeway/extensions/commandreplay/secondary/jobcallables/ReplicateAndRunCommands.java +++ b/incubator/extensions/core/commandreplay/secondary/src/main/java/org/apache/causeway/extensions/commandreplay/secondary/jobcallables/ReplicateAndRunCommands.java @@ -85,7 +85,7 @@ private void doCall() throws StatusException { // is there a pending command already? // (we fetch several at a time, so we may not have processed them all yet) - commandsToReplay = commandLogEntryRepository.findNotYetReplayed(); + commandsToReplay = commandLogEntryRepository.findReplayPendingOrFailed(); if(commandsToReplay.isEmpty()) { diff --git a/incubator/extensions/core/commandreplay/secondary/src/main/java/org/apache/causeway/extensions/commandreplay/secondary/mixins/CommandLogEntry_replayQueue.java b/incubator/extensions/core/commandreplay/secondary/src/main/java/org/apache/causeway/extensions/commandreplay/secondary/mixins/CommandLogEntry_replayQueue.java index a9078f9f7cb..683b4edb441 100644 --- a/incubator/extensions/core/commandreplay/secondary/src/main/java/org/apache/causeway/extensions/commandreplay/secondary/mixins/CommandLogEntry_replayQueue.java +++ b/incubator/extensions/core/commandreplay/secondary/src/main/java/org/apache/causeway/extensions/commandreplay/secondary/mixins/CommandLogEntry_replayQueue.java @@ -45,7 +45,7 @@ public static class CollectionDomainEvent final CommandLogEntry commandLogEntry; public List coll() { - return commandLogEntryRepository.findNotYetReplayed(); + return commandLogEntryRepository.findReplayPendingOrFailed(); } public boolean hideColl() { return !secondaryConfig.isConfigured(); From fecb25bd98603dd88d7fb50652ee9abe475a63c5 Mon Sep 17 00:00:00 2001 From: andi-huber Date: Sat, 18 Apr 2026 05:32:41 +0200 Subject: [PATCH 2/8] CAUSEWAY-3989: [v2] fix flipped logic in export filter --- .../commandlog/applib/dom/replay/CommandExportManager.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandExportManager.java b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandExportManager.java index f8d878143c3..4ba1ae084c4 100644 --- a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandExportManager.java +++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandExportManager.java @@ -112,7 +112,7 @@ public Blob exportSelected( var yaml = CommandDtoUtils.toYaml( selectedCommandLogEntries.stream() - .filter(entry->ReplayState.isExported(entry.getReplayState())) + .filter(entry->!ReplayState.isExported(entry.getReplayState())) .map(CommandLogEntry::getCommandDto) .collect(Collectors.toList())); From f97bdafde7e274141f1bd16cba13537ac6f70258 Mon Sep 17 00:00:00 2001 From: andi-huber Date: Sat, 18 Apr 2026 06:11:06 +0200 Subject: [PATCH 3/8] CAUSEWAY-3989: [v2] backports missing named queries --- .../applib/dom/replay/ReplayableCommand.java | 6 ++--- .../commandlog/jdo/dom/CommandLogEntry.java | 5 ++-- .../commandlog/jpa/dom/CommandLogEntry.java | 24 ++++--------------- 3 files changed, 10 insertions(+), 25 deletions(-) diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand.java b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand.java index 7530a94eb05..323d08ba94b 100644 --- a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand.java +++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand.java @@ -68,7 +68,7 @@ /** * Viewmodel that wraps a {@link CommandLogEntry}. */ -@DomainObject(introspection = Introspection.ENCAPSULATION_ENABLED) +@DomainObject(introspection = Introspection.ANNOTATION_REQUIRED) @DomainObjectLayout(cssClassFa = "terminal") @Named(ReplayableCommand.LOGICAL_TYPE_NAME) @AllArgsConstructor @@ -249,7 +249,7 @@ public ReplayableCommand replayOrRetry() { tryReplayOrRetry(); return this; } - @MemberSupport private String disableReplayOrRetry() { + @MemberSupport public String disableReplayOrRetry() { return commandRecord() .map(CommandRecord::canReplayOrRetryOrMarkForExclusion) .orElse(false) @@ -274,7 +274,7 @@ public ReplayableCommand makeExportable() { }); return this; } - @MemberSupport private String disableMakeExportable() { + @MemberSupport public String disableMakeExportable() { return commandRecord() .map(rec->ReplayState.isExported(rec.replayState())) .orElse(false) diff --git a/extensions/core/commandlog/persistence-jdo/src/main/java/org/apache/causeway/extensions/commandlog/jdo/dom/CommandLogEntry.java b/extensions/core/commandlog/persistence-jdo/src/main/java/org/apache/causeway/extensions/commandlog/jdo/dom/CommandLogEntry.java index f13dda3e615..520b5cb37d9 100644 --- a/extensions/core/commandlog/persistence-jdo/src/main/java/org/apache/causeway/extensions/commandlog/jdo/dom/CommandLogEntry.java +++ b/extensions/core/commandlog/persistence-jdo/src/main/java/org/apache/causeway/extensions/commandlog/jdo/dom/CommandLogEntry.java @@ -209,9 +209,8 @@ name = Nq.FIND_BY_REPLAY_STATE, value = "SELECT " + " FROM " + CommandLogEntry.FQCN + " " - + " WHERE replayState == :replayState " - + " ORDER BY timestamp ASC " - + " RANGE 0,10"), // same as batch size + + " WHERE replayState == :replayState1 || replayState == :replayState2 " + + " ORDER BY timestamp ASC"), }) @Named(CommandLogEntry.LOGICAL_TYPE_NAME) @DomainObject( diff --git a/extensions/core/commandlog/persistence-jpa/src/main/java/org/apache/causeway/extensions/commandlog/jpa/dom/CommandLogEntry.java b/extensions/core/commandlog/persistence-jpa/src/main/java/org/apache/causeway/extensions/commandlog/jpa/dom/CommandLogEntry.java index 2e58da4f90d..ffc6b5ade4a 100644 --- a/extensions/core/commandlog/persistence-jpa/src/main/java/org/apache/causeway/extensions/commandlog/jpa/dom/CommandLogEntry.java +++ b/extensions/core/commandlog/persistence-jpa/src/main/java/org/apache/causeway/extensions/commandlog/jpa/dom/CommandLogEntry.java @@ -198,7 +198,8 @@ name = Nq.FIND_MOST_RECENT_REPLAYED, query = "SELECT cl " + " FROM CommandLogEntry cl " - + " WHERE (cl.replayState = org.apache.causeway.extensions.commandlog.applib.dom.ReplayState.OK OR cl.replayState = org.apache.causeway.extensions.commandlog.applib.dom.ReplayState.FAILED) " + + " WHERE (cl.replayState = org.apache.causeway.extensions.commandlog.applib.dom.ReplayState.OK" + + " OR cl.replayState = org.apache.causeway.extensions.commandlog.applib.dom.ReplayState.FAILED) " + " ORDER BY cl.timestamp DESC"), // programmatic LIMIT 1 @NamedQuery( name = Nq.FIND_MOST_RECENT_COMPLETED, @@ -210,9 +211,9 @@ @NamedQuery( name = Nq.FIND_BY_REPLAY_STATE, query = "SELECT cl " - + " FROM CommandLogEntry cl " - + " WHERE cl.replayState = :replayState " - + " ORDER BY cl.timestamp ASC"), // programmatic LIMIT 10 + + " FROM CommandLogEntry cl " + + " WHERE (cl.replayState = :replayState1 OR cl.replayState = :replayState2) " + + " ORDER BY cl.timestamp ASC"), }) @Named(CommandLogEntry.LOGICAL_TYPE_NAME) @DomainObject( @@ -238,11 +239,9 @@ public CommandLogEntry( init(commandDto, replayState, targetIndex); } - @EmbeddedId private CommandLogEntryPK pk; - @Transient @InteractionId @Override @@ -255,46 +254,39 @@ public void setInteractionId(final UUID interactionId) { this.pk = new CommandLogEntryPK(interactionId); } - @Column(nullable = Username.NULLABLE, length = Username.MAX_LENGTH) @Username @Getter @Setter private String username; - @Column(nullable = Timestamp.NULLABLE) @Timestamp @Getter @Setter private java.sql.Timestamp timestamp; - @Convert(converter = CausewayBookmarkConverter.class) @Column(nullable = Target.NULLABLE, length = Target.MAX_LENGTH) @Target @Getter @Setter private Bookmark target; - @Column(nullable = ExecuteIn.NULLABLE, length = ExecuteIn.MAX_LENGTH) @Enumerated(EnumType.STRING) @ExecuteIn @Getter @Setter private org.apache.causeway.extensions.commandlog.applib.dom.ExecuteIn executeIn; - @Convert(converter = JavaUtilUuidConverter.class) @Domain.Exclude @Column(nullable = Parent.NULLABLE, length = InteractionId.MAX_LENGTH) @Getter @Setter private UUID parentInteractionId; - @Column(nullable = LogicalMemberIdentifier.NULLABLE, length = LogicalMemberIdentifier.MAX_LENGTH) @LogicalMemberIdentifier @Getter @Setter private String logicalMemberIdentifier; - @Convert(converter = CausewayCommandDtoConverter.class) @Lob @Basic(fetch = FetchType.LAZY) @Column(nullable = CommandDtoAnnot.NULLABLE, columnDefinition = "CLOB") @@ -302,40 +294,34 @@ public void setInteractionId(final UUID interactionId) { @Getter @Setter private CommandDto commandDto; - @Column(nullable = StartedAt.NULLABLE) @StartedAt @Getter @Setter private java.sql.Timestamp startedAt; - @Column(nullable = CompletedAt.NULLABLE) @CompletedAt @Getter @Setter private java.sql.Timestamp completedAt; - @Convert(converter = CausewayBookmarkConverter.class) @Column(nullable = Result.NULLABLE, length = Result.MAX_LENGTH) @Result @Getter @Setter private Bookmark result; - @Lob @Basic(fetch = FetchType.LAZY) @Column(nullable = Exception.NULLABLE, columnDefinition = "CLOB") @Exception @Getter @Setter private String exception; - @Column(nullable = ReplayState.NULLABLE, length = ReplayState.MAX_LENGTH) @Enumerated(EnumType.STRING) @ReplayState @Getter @Setter private org.apache.causeway.extensions.commandlog.applib.dom.ReplayState replayState; - @Column(nullable = ReplayStateFailureReason.NULLABLE, length = ReplayStateFailureReason.MAX_LENGTH) @ReplayStateFailureReason @Getter @Setter From bee1e1ffff5ae03b7984eba681871aa98260fb0a Mon Sep 17 00:00:00 2001 From: andi-huber Date: Sat, 18 Apr 2026 07:16:39 +0200 Subject: [PATCH 4/8] CAUSEWAY-3989: [v2] backport simplification (no bookmark service) - should fix issues with JDO --- .../CausewayModuleExtCommandLogApplib.java | 4 +-- .../dom/replay/CommandExportManager.java | 4 +-- .../dom/replay/CommandReplayManager.java | 4 +-- .../applib/dom/replay/ReplayContext.java | 2 -- .../applib/dom/replay/ReplayableCommand.java | 30 +++++++------------ 5 files changed, 16 insertions(+), 28 deletions(-) diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/CausewayModuleExtCommandLogApplib.java b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/CausewayModuleExtCommandLogApplib.java index d8837822808..f26ba35f88c 100644 --- a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/CausewayModuleExtCommandLogApplib.java +++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/CausewayModuleExtCommandLogApplib.java @@ -18,7 +18,6 @@ */ package org.apache.causeway.extensions.commandlog.applib; -import org.apache.causeway.applib.services.bookmark.BookmarkService; import org.apache.causeway.applib.services.command.CommandExecutorService; import org.apache.causeway.applib.services.iactnlayer.InteractionService; import org.apache.causeway.applib.services.repository.RepositoryService; @@ -120,12 +119,11 @@ public static void honorSystemEnvironment() { } @Bean ReplayContext replayContext( - final BookmarkService bookmarkService, final RepositoryService repositoryService, final InteractionService interactionService, final CommandLogEntryRepository commandLogEntryRepository, final CommandExecutorService commandExecutorService) { - return new ReplayContext(bookmarkService, repositoryService, interactionService, + return new ReplayContext(repositoryService, interactionService, commandLogEntryRepository, commandExecutorService); } diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandExportManager.java b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandExportManager.java index 4ba1ae084c4..8f963454dda 100644 --- a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandExportManager.java +++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandExportManager.java @@ -88,7 +88,7 @@ public List getNotYetExported() { return commandLogEntryRepository().findAll().stream() .filter(entry->ReplayState.canExport(entry.getReplayState())) .map(entry->new ReplayableCommand( - replayContext.bookmarkService().bookmarkFor(entry).get().getIdentifier(), + entry.getInteractionId(), replayContext)) .collect(Collectors.toList()); } @@ -144,7 +144,7 @@ public List getExported() { return commandLogEntryRepository().findAll().stream() .filter(entry->ReplayState.isExported(entry.getReplayState())) .map(entry->new ReplayableCommand( - replayContext.bookmarkService().bookmarkFor(entry).get().getIdentifier(), + entry.getInteractionId(), replayContext)) .collect(Collectors.toList()); } diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandReplayManager.java b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandReplayManager.java index ba592c5c7f8..e6405bb37b0 100644 --- a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandReplayManager.java +++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandReplayManager.java @@ -102,7 +102,7 @@ public CommandReplayManager deleteAll() { public List getPendingOrFailed() { return commandLogEntryRepository().findReplayPendingOrFailed().stream() .map(entry->new ReplayableCommand( - replayContext.bookmarkService().bookmarkFor(entry).get().getIdentifier(), + entry.getInteractionId(), replayContext)) .collect(Collectors.toList()); } @@ -155,7 +155,7 @@ public CommandReplayManager deleteSelected(final List selecte public List getSucceededOrExcluded() { return commandLogEntryRepository().findReplaySucceededOrExcluded().stream() .map(entry->new ReplayableCommand( - replayContext.bookmarkService().bookmarkFor(entry).get().getIdentifier(), + entry.getInteractionId(), replayContext)) .collect(Collectors.toList()); } diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayContext.java b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayContext.java index fee5baf5ef5..60d47e0a15f 100644 --- a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayContext.java +++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayContext.java @@ -18,7 +18,6 @@ */ package org.apache.causeway.extensions.commandlog.applib.dom.replay; -import org.apache.causeway.applib.services.bookmark.BookmarkService; import org.apache.causeway.applib.services.command.CommandExecutorService; import org.apache.causeway.applib.services.iactnlayer.InteractionService; import org.apache.causeway.applib.services.repository.RepositoryService; @@ -32,7 +31,6 @@ */ @Value @Accessors(fluent = true) public final class ReplayContext { - BookmarkService bookmarkService; RepositoryService repositoryService; InteractionService interactionService; CommandLogEntryRepository commandLogEntryRepository; diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand.java b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand.java index 323d08ba94b..316e050f100 100644 --- a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand.java +++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand.java @@ -40,12 +40,10 @@ import org.apache.causeway.applib.annotation.PropertyLayout; import org.apache.causeway.applib.annotation.Where; import org.apache.causeway.applib.jaxb.JavaTimeXMLGregorianCalendarMarshalling; -import org.apache.causeway.applib.services.bookmark.Bookmark; import org.apache.causeway.applib.services.command.CommandExecutorService.InteractionContextPolicy; import org.apache.causeway.commons.functional.Try; import org.apache.causeway.commons.internal.base._Refs.ObjectReference; import org.apache.causeway.commons.internal.base._Strings; -import org.apache.causeway.commons.internal.exceptions._Exceptions; import org.apache.causeway.commons.io.JsonUtils; import org.apache.causeway.commons.io.TextUtils; import org.apache.causeway.commons.io.YamlUtils; @@ -75,7 +73,7 @@ public final class ReplayableCommand implements ViewModel, Comparable { @Getter @Accessors(fluent = true) - private final String commandLogEntryId; + private final UUID interactionId; @Getter @Accessors(fluent = true) private final ReplayContext replayContext; @@ -112,7 +110,13 @@ public String faQuickIcon() { public ReplayableCommand( final String memento, final ReplayContext replayContext) { - this(memento, replayContext, new ObjectReference<>(null)); + this(UUID.fromString(memento), replayContext); + } + + ReplayableCommand( + final UUID interactionId, + final ReplayContext replayContext) { + this(interactionId, replayContext, new ObjectReference<>(null)); } @ObjectSupport public String title() { @@ -134,11 +138,7 @@ public ReplayableCommand( fieldSetId = "details", describedAs = "UUID of the original (replayabel) Command") public UUID getInteractionId() { - return commandRecord() - .map(CommandRecord::commandDto) - .map(CommandDto::getInteractionId) - .map(UUID::fromString) - .orElse(null); + return interactionId; } @Property @@ -334,7 +334,7 @@ public int compareTo(final ReplayableCommand other) { @Override public String viewModelMemento() { - return commandLogEntryId; + return interactionId.toString(); } // -- UTIL @@ -381,15 +381,7 @@ private Optional commandRecord() { } Optional commandLogEntry() { - return replayContext.bookmarkService().lookup(commandLogEntryBookmark(), CommandLogEntry.class); - } - - private Bookmark commandLogEntryBookmark() { - return replayContext.bookmarkService() - .bookmarkFor(CommandLogEntry.class, commandLogEntryId) - .orElseThrow(()->_Exceptions.unrecoverable( - "framework error: cannot create bookmark for CommandLogEntry using id '%s'", - commandLogEntryId)); + return replayContext.commandLogEntryRepository().findByInteractionId(interactionId()); } private static boolean canReplayOrRetryOrMarkForExclusion(final CommandLogEntry commandLogEntry) { From 6a551daa8ce6ea47568a0f42da17b5e2cd303f8a Mon Sep 17 00:00:00 2001 From: andi-huber Date: Sun, 19 Apr 2026 20:52:39 +0200 Subject: [PATCH 5/8] CAUSEWAY-3989: [v2] using png icon screenshots from v4 --- .../dom/replay/ReplayableCommand-excluded.png | Bin 0 -> 1349 bytes .../dom/replay/ReplayableCommand-exported.png | Bin 0 -> 1154 bytes .../dom/replay/ReplayableCommand-failed.png | Bin 0 -> 1105 bytes .../dom/replay/ReplayableCommand-ok.png | Bin 0 -> 1381 bytes .../dom/replay/ReplayableCommand-pending.png | Bin 0 -> 1136 bytes .../applib/dom/replay/ReplayableCommand.java | 19 +++++++++++++++++- .../applib/dom/replay/ReplayableCommand.png | Bin 0 -> 561 bytes 7 files changed, 18 insertions(+), 1 deletion(-) create mode 100644 extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand-excluded.png create mode 100644 extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand-exported.png create mode 100644 extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand-failed.png create mode 100644 extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand-ok.png create mode 100644 extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand-pending.png create mode 100644 extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand.png diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand-excluded.png b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand-excluded.png new file mode 100644 index 0000000000000000000000000000000000000000..9a1d6cd8e3029a662baed644c6104b043c25b483 GIT binary patch literal 1349 zcmV-L1-kl)P)+G{9l?82q^P82q^P82q^P=z#y) zXF=WBHq@PMgR$Fm5=@dr99%gsWwv5tWos`f1Kz$-fwIfbod}$mM6JTmz~_xW!0_e$ z3#=T>yXb0%l7hN&5}*7t#-gD!}Y z2`dT?03&y1(OTCB+W(*|8sI}NK*blI#k;p(g-lKaV$on5Nh{||7qqxaMHhTXs|3sV zwF#fR-V55tw#~J>)p+r>XC(FT`~9%n?QlAsaJgJiC=^IcOhjsGDq>?}C4hMPFx!vl@-w*KA#U3iv<>o1!H4lC@U*NPEL+seOOi*{bmjyU%bb%$`e^h z_~6GHJei-)sUII7$I#Fa4ttPLR8)l0(o)`>WN>5mfBp`7EP?>u?>mp@E1u@m_xJZ> zYHI3Gz`-I(5_-L!6PNT<`T6c2ywF!}R`5bkPEJMy8URct6NZO}Iq{I-BX?$T{f+N< zq4^e|)zwuD3=I4~+5;LL9mV3}A}b~eK1|Kv(@WB!0l;7|9NN=jG8hc3&w}7T-RAC7 zLP1-tRxB+o2@al-k%83IRNh>2axyYAGX<};*=(4bn-d2g>YPT~FM036JLK0xUt)W&))oAGW5c4tN49z&wpmKS#VId z+YOh?1%e<@RaF&$SF6>it*wPzF30xvHXIIzV2}CvdHDT)e()Pt2gAD4-+uTD?8{DU zd%U89`g}fgb#-BDYbyY+)9F~y6h*<~@d)2&ggzx72qPMZT zMx&7xo0yo81PvhYi-fi0i)mwH1MThYA?t!Yy)TAg(9_cclPUB$PD4Wjva+%wcaoKu zm>9Aq7~1RgqPMp#(vy%m% zoGiT4Oh`z8QmKqAH~?rg8qqvW)9`papePE<%gX>BOjxJW37V!w_u+YYd4YVyJPrVB zYisD}=n%}u$H#-FY3%Oqa&mIH9C2}RqL0J+`g&w%X9I}v9apJT$j{Fg%zM3FUT9$N zl}-#=tyTx1fg?C(E-5KNTwGj4d&=c<=yW<(EaKowr4o9*KIA)NII5_qfJ&ue#g61) zKQ}iQ<>loOfiEsDMnORVCmzwsc~JB7^XTd6f!FH|``Vb87*tkPa(0sYI#O@|a5x+o z85zO!^t9v`43Z>KT3U*floY|5BM0A?-EN1;WP;gjhRtSUeF=gfkd~GPjYb2KBsqTO z)3JdEi(weJTrRlXZYUHABqSt2CJS%6od|d&^yvIu_^{|-#ehxr7jel!00000NkvXX Hu0mjfI{t_+ literal 0 HcmV?d00001 diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand-exported.png b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand-exported.png new file mode 100644 index 0000000000000000000000000000000000000000..60452341123d1594c7809d7832cb68a7a224df58 GIT binary patch literal 1154 zcmV-|1bzF7P)$l$vOqu+4GU2)e8Nm%N|+I2d|3vlQ8dc}I%60EoMBin3dzP`%#BSgdtqD0He6fU z)em>=+T7drGK%d7{z-Fl&iUV`zn*)~bDkcB!{Klc;tC5kfE?Y243zFe21+8xwu@c- z{BfIX)-kT@kaSZpqN%w)5{2xD3EdP_e$Rvf0! zI1~&n4|zZ?UiUCj@e!_e=bxsK0QG>>&d^X&QxI~XqJ@*Sa0(?S))So&I-rs%C~HA# zNP&9IlbQNd-mNVpMx%E11q;+?GS8TwLP_oVpo1#plxnRPy4bn$ONRT+uD+BE4g1a( z5uX%wPXR$DB#1%P(cx^^olD*u3%%YmVuQL4INl0ZdZBw5%r;1l0bLyAPKQN?<^I(R7?w&sAh zhawyDi=C%kfB@PV31lyONaX7qfz8$M+jV(y?GE^=0jgWzlQpnts@Nl|3Oe-mDZbl% z#?=mwP_q2xY?7xZc=)=8A@7KMKz9w8p`Z$WY?%P$p&ciw+*|L__x$!J$jP7S(Py^7 z#vkCH!8-?Z%I<*T*EycS}yXI8N|USB8wef|4DeS{X$l} zSxeRXzls1il|3!;b`HbAi}D~8K!V!0=h^^#)944XeA@}C_Q?YTA)v)B5Awij_O`cZkug@kO$?;I0ULN*z+X3INQ6g%>=C`3CMHbU**^b z=llW!)K^`myZt6ZRv}T#RcSpOZ5AGT=}LIv5%1pfM&as_Cp%mFWdQ;huk;bCiNu_o zC^~rW-t0oFlo4ozkz# zpri2y)?_J_DXLJ1Z5?D{v_LpCFJ?Tp(Gb}v_ zat}Si6v7Jkr4OG0XL%5inbfG^wFUD1{s14H40&$#OTZ4SHqVD8X?GtdJ_0^kEu%xr#!DDY2G4xtPe;T-djY3f%v87TifCxXhc2@qo$AZGc_t|` zp?yBqGV{|S7TWJ~czs?PHEnxHFvodfkpQi_ab)DKrMhi*L_tUCIdR;(HA>a%-|=u_ z&J$akVdluScQ8F`3@hN1(zP|en3L*mkJXEnc8oma*B|bxzj0;;nwntmUf8q=)~tb5 zs~{^2wrzuB$6#nEFk%Tn-@Mx*WgL<+4$1z!25D=&?b|p%FLM02NJ@$bsE^oKk%|hD z(NSFty1)E=H$uX@GaJn$N? zY|rvG&d$P)9WXc;FsnySr@P;8-t=ox1wQpr8|S~c<`vt%e+#=lEmE2f9)zwgbs(b= z)~!?b%+5l2xf|Yx`~si)u#KiKe^bD|_f@IVJTwGHkE(+h3@|X@9#*Tmx33RQp41QA z@WEx8j_8C1sIP~)Ip41>Xu}3bNr9LcDDrWKo|K;gD1htVZ{=d$bxqiR=3UIsJ-%%h zE@%Thc(8aqIjQaM?S-Bmb>JI6_0jzG4NX{pt4)6}HaO`9>F(ABa5#KltEsb75%M}B zjoh2i1q)#7v6E}~sIX12@xy!ZK3KJ9R-Ua#c1+q2!R9~O=_Pa2uszfeb5{Zk`=RbT{kuG@P>1mOu zDE-iZjMJwT>4N_u#b|{5e0|=E3aG1tHr3VyWgj$MT(2HfzO@|Be00RDo00)BqV72a&tvot}p{Tog!9i@EL24>kEbfzYX(`0V>s~JWz#-PM`^(^8 XKDVGJJRDfm00000NkvXXu0mjfa5xu+ literal 0 HcmV?d00001 diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand-ok.png b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand-ok.png new file mode 100644 index 0000000000000000000000000000000000000000..1578312869f889f3796edb98bd54c6f4394ecd77 GIT binary patch literal 1381 zcmV-r1)BPaP)cVbMvcQzE>0sRb29^PL4z8H zmO3svomns$%M2`%=ob^rrt>yK<^?wt1r!xDx<*#XI(Jiah|+=Ll<264h|+qYv7CKi zU~5m?(^>%+{~zDyInVj!Isfy%?|F|*5CnmjO!A%v@IIll=(_QgQUox&o;2g=RJ%o1Hy6hNb-&J-O zk0jYaFZ+51GON+v2yO>GB~eLw?wno?E^3cyi=+5#ckczadl9X5YHt;r7MCKlTV2o# zUQVab0Ok8GvMIHccFWVWo(6ZhxWs%I!RLGCA@`AWjrFuuB&~?=FA8Vn-rfW5(jL~* z6;-n#=?LvlY^{=6@B!~F43ku&&C*8gomv`<4KzJ$BCt;&iUA5jUk|14Ykeiv@RbS- zFh!+AE|>AuqQhO_LOYc0u5=15AW%|M!v2f<(bQ=$nN4;%{=WW9A2l6SxQfK^M0{jE z4(oYk!!&HvV23eXy^iRe(abNJPqDsO6d2&~lgAvpe2k>SNsRk;9GW_f!Aoh}#!OmNp9kd-qEAw65?!KU+hwUG z)M}eCj~16sV6nyJyx~02JEO4(7Oy~esw}yTX``kAE;@Z`ZxtI;N+bgVWR_(<7x08P zCJ^8sU^kvomeJ*JhtS$R_WKT3z`A-J+G|>mfivF8pkjFiC5a_=vC3PO9MT_h4E^Kx z7udYSJz(hYpQoBMd=lAHvjH%e3{LCYLF)>u*|PMgWZ=4U2L3$xHvmU#k9q_=$Zrq@ z2?hAdeYySMHt9d4i{@$SG?*=Bd+1#&PrB9N;xB)p`5zkW^fx5sa1&7J-WX1S+)&ccIncYMb5Iv$6&^Tg<47)fml2$@P;WlK?kAx1=cf zNHY0V`A%~tDkfqy8>zWdBf0L((KFE`bxem|hpxY0Pee|HTOD*IKOaBNEc%T?#Ka{-#scL6vZrPRW|+=Q@24|%OY54G&R!vsF?u&0EYD+ nhOb=uxlxZmKd)ZOUvmEfCMrG+BE<%O00000NkvXXu0mjf7krtD literal 0 HcmV?d00001 diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand-pending.png b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand-pending.png new file mode 100644 index 0000000000000000000000000000000000000000..e83e889a6828e57e4d71723180e035c51372d08c GIT binary patch literal 1136 zcmV-$1dscPP)M-dDLgDes$7aCwLT7pfLmS9t*CD>GH zal`gq9pc=HPCV|Zh2WV{>d4@?3j^%0X@Ve_N{zgAb~kxu{X(%xB%0Z-OIG;r-Tt%Z{`KmONl(Rc0DmcaoWte!#M& ztL$!5S^~$cv!#_L4z}$gD_0xx78-cQmqN+QVJNUxL&twDhh-pVGPgksC|r|zUZ8JU~mH5UxIxP{8<6lS3ehwg)$8)fj3{wp`QYZK&|?8>9k`CeD6c7!cJz%3e){_2Mx&wwmsmzEZaIGY~#yqoIcbp@@n^0v*Y!R(#|8Vf%n&WV}=y;fNeYA zlQhMIZG5SX=68P)*tjuRAw#6hoo zoACu${R3$43VVv>P}l^f&p~OBALsl~VyUBzBhR<+^#>iZg8HWe1pEOsS~a-8lE0zS z2rJ$JR1k21^$EexdK9vE3)kakz||-nJJlupmW7`U(ad&y(;XKmz{Td@8SZyM;1*0? zlZRCq1W*8#F~W~=9jys*RT073d!2EC0$BfWFf!l*pDp&y5>-@g5$xt&cT!euX1RVD z+g`1Sy+(n`M@n$m-1s~*!9`2GLKG%` zZlE>+iiFe1=QfMN#9^r#e1}N0R7E(?b6`&Dsl;YZa&nmwIde;cGG+8p80K+zsm7KFKRUYKk zCnXgC+Vx;~Qer1(W&!XEyZ`al#L_5pq4*cjlt`Nr%N!{cm`{rL0CSa6538DGK_S^k zp2{33IuH8C=RQ_rfueTMZHk?K{$w$v&RZStpS`~_8}bi=rE}K)NnEor { @@ -104,6 +104,18 @@ public String faQuickIcon() { }; return null; } + //v2 backport, using png screenshots from v4 + public String iconSuffix() { + switch(replayState) { + case UNDEFINED: return ""; + case EXPORTED: return "exported"; + case PENDING: return "pending"; + case OK: return "ok"; + case FAILED: return "failed"; + case EXCLUDED: return "excluded"; + }; + return null; + } } @Inject @@ -131,6 +143,11 @@ public ReplayableCommand( // .map(ObjectSupport.FontAwesomeIconResource::new) // .orElse(null); // } + @ObjectSupport public String iconName() { + return commandRecord() + .map(CommandRecord::iconSuffix) + .orElse(null); + } @Property @PropertyLayout( diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand.png b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand.png new file mode 100644 index 0000000000000000000000000000000000000000..ac153a4102e47dbffba915834486b6c71f32a21d GIT binary patch literal 561 zcmV-10?z%3P)UC& zc^*X&18soP=uowzL)DHBRXZZ+dF&o;HUu{tLL!=^VIqdfZuotcAYNbI(~AaRY{7vh zPYo5EFp{O`Ibe2hAMvHjLV0pV1RV@CgAqK{3RVQQxvUtEv}z7ZEPZ2tH6|9dg%uqp zVUE|X5$unF=L9cMI$)Ae061B{LC_ro_xZDp23EG=H*{BFaQWUB9~ z#MByHtl|+`*I5bgdOHlIVr(HM*so^2t-}i6hEs%%c;l+WvQxa55~-YR-C??G8{CJW zQ82)Oze#PED5Xj_CZb9Bdv-F~6c}JU;(*O%{R>-06hTinZxQI-B_a#eOJ$*?hF@qT zAOj_hP#Q=;;xHYpY6l7AfF3SiA>`2x5+Ia55lv!dU{5jf;@|;uLkCDbz2yZlvt<|! z+fi$;Ag)XPGsz(Vz|Dq`dd?3i#I8I}5Z9y|KIL)?BZ3akx4=|xgsI#J--c5bITE(R zY$?kdVz#79pDVH#XRYLaUP@?2hpHVNs&>8r*nH9iOylNr00000NkvXXu0mjftPknP literal 0 HcmV?d00001 From 4f624297612ed51b30f84ee6d1a0395053624651 Mon Sep 17 00:00:00 2001 From: Dan Haywood Date: Mon, 20 Apr 2026 10:45:35 +0100 Subject: [PATCH 6/8] CAUSEWAY-3989: [v2] ensures ReplayContext is not picked up as a MM class from ReplayableCommand CAUSEWAY-3989: [v2] prevent ReplayContext from leaking into MM in a couple more places CAUSEWAY-3989: [v2] adds baseline to CommandExportManager and removes actions to delete commands CAUSEWAY-3989: [v2] fixes compile error CAUSEWAY-3989: [v2] adds mixins CAUSEWAY-3989: [v2] adds tests CAUSEWAY-3989: [v2] reworks CAUSEWAY-3989: [v2] tests now pass CAUSEWAY-3989: [v2] adds additional assertions CAUSEWAY-3989: [v2] polishing CAUSEWAY-3989: [v2] saveForReplay sets executeIn to FOREGROUND always CAUSEWAY-3989: [v2] adds a 'since' to CommandReplayManager; fixes for previous/next and layouts CAUSEWAY-3989: [v2] fixes marshalling and other small stuff CAUSEWAY-3989: [v2] fixes MM CAUSEWAY-3989: [v2] uses 'since' in CommandReplayManager. Resurrects (?) openCommandLogEntry CAUSEWAY-3989: [v2] fixes (?) query for CommandLogEntry CAUSEWAY-3989: [v2] declares mixins CAUSEWAY-3989: [v2] fixes (?) names queries CAUSEWAY-3989: [v2] adds choices to hopefully avoid MM validation issue --- .../applib/util/schema/CommandDtoUtils.java | 16 +- .../util/schema/CommandDtoUtils_Test.java | 10 + ...l_Test.commands-with-collection-param.yaml | 92 +++++ ...Yaml_Test.commands-with-scalar-params.yaml | 32 ++ .../schema/CommandDtoUtils_fromYaml_Test.java | 133 +++++++ .../CausewayModuleExtCommandLogApplib.java | 29 +- .../commandlog/applib/app/CommandLogMenu.java | 38 +- .../applib/dom/CommandLogEntry.java | 5 +- .../applib/dom/CommandLogEntryRepository.java | 25 +- .../CommandLogEntryRepositoryAbstract.java | 64 ++-- .../commandlog/applib/dom/ReplayState.java | 24 +- ...tManager#exported.columnOrder.fallback.txt | 6 + ...er#notYetExported.columnOrder.fallback.txt | 6 + .../dom/replay/CommandExportManager.java | 290 ++++++++++---- .../CommandExportManager.layout.fallback.xml | 12 +- ...r#pendingOrFailed.columnOrder.fallback.txt | 6 + ...cceededOrExcluded.columnOrder.fallback.txt | 6 + .../dom/replay/CommandReplayManager.java | 355 ++++++++++++++---- .../CommandReplayManager.layout.fallback.xml | 12 +- .../applib/dom/replay/ReplayContext.java | 2 + ...ReplayableCommand.columnOrder.fallback.txt | 6 + .../applib/dom/replay/ReplayableCommand.java | 188 +++++----- .../ReplayableCommand.layout.fallback.xml | 36 +- .../dom/replay/ReplayableCommand_delete.java | 49 +++ .../ReplayableCommand_excludeFromReplay.java | 54 +++ .../ReplayableCommand_makeExportable.java | 55 +++ ...ReplayableCommand_openCommandLogEntry.java | 54 +++ .../ReplayableCommand_replayOrRetry.java | 57 +++ .../dom/replay/TimestampMarshallUtil.java | 48 +++ .../CommandLog_IntegTestAbstract.java | 4 +- .../commandlog/jdo/dom/CommandLogEntry.java | 14 +- .../commandlog/jpa/dom/CommandLogEntry.java | 14 +- 32 files changed, 1420 insertions(+), 322 deletions(-) create mode 100644 api/applib/src/test/java/org/apache/causeway/applib/util/schema/CommandDtoUtils_fromYaml_Test.commands-with-collection-param.yaml create mode 100644 api/applib/src/test/java/org/apache/causeway/applib/util/schema/CommandDtoUtils_fromYaml_Test.commands-with-scalar-params.yaml create mode 100644 api/applib/src/test/java/org/apache/causeway/applib/util/schema/CommandDtoUtils_fromYaml_Test.java create mode 100644 extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandExportManager#exported.columnOrder.fallback.txt create mode 100644 extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandExportManager#notYetExported.columnOrder.fallback.txt create mode 100644 extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandReplayManager#pendingOrFailed.columnOrder.fallback.txt create mode 100644 extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandReplayManager#succeededOrExcluded.columnOrder.fallback.txt create mode 100644 extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand.columnOrder.fallback.txt create mode 100644 extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand_delete.java create mode 100644 extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand_excludeFromReplay.java create mode 100644 extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand_makeExportable.java create mode 100644 extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand_openCommandLogEntry.java create mode 100644 extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand_replayOrRetry.java create mode 100644 extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/TimestampMarshallUtil.java diff --git a/api/applib/src/main/java/org/apache/causeway/applib/util/schema/CommandDtoUtils.java b/api/applib/src/main/java/org/apache/causeway/applib/util/schema/CommandDtoUtils.java index 83372393da9..8fec5928b9d 100644 --- a/api/applib/src/main/java/org/apache/causeway/applib/util/schema/CommandDtoUtils.java +++ b/api/applib/src/main/java/org/apache/causeway/applib/util/schema/CommandDtoUtils.java @@ -40,7 +40,9 @@ import org.apache.causeway.schema.cmd.v2.PropertyDto; import org.apache.causeway.schema.common.v2.OidsDto; import org.apache.causeway.schema.common.v2.PeriodDto; +import org.apache.causeway.schema.common.v2.ValueDto; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonTypeInfo; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.jsontype.NamedType; @@ -140,10 +142,11 @@ public String toYaml(final Iterable commandDtos) { public ObjectMapper apply(ObjectMapper mapper) { JsonUtils.jaxbAnnotationSupport(mapper); CommandDtoUtils.memberDtoSupport(mapper); + CommandDtoUtils.valueDtoSupport(mapper); JsonUtils.onlyIncludeNonNull(mapper); return mapper; } - }; + }; return YamlUtils.toStringUtf8( _NullSafe.stream(commandDtos) .collect(Collectors.toList()), @@ -156,6 +159,7 @@ public List fromYaml(final DataSource commandDtosYaml) { public ObjectMapper apply(ObjectMapper mapper) { JsonUtils.jaxbAnnotationSupport(mapper); CommandDtoUtils.memberDtoSupport(mapper); + CommandDtoUtils.valueDtoSupport(mapper); return mapper; } }; @@ -172,6 +176,14 @@ public ObjectMapper apply(ObjectMapper mapper) { property = "type") private abstract class AbstractDtoMixIn {} + // Mix-in to ignore unknown properties for ValueDto + @JsonIgnoreProperties(ignoreUnknown = true) + private abstract class AbstractValueDtoMixIn {} + + private void valueDtoSupport(final ObjectMapper mb) { + mb.addMixIn(ValueDto.class, AbstractValueDtoMixIn.class); + } + private void memberDtoSupport(final ObjectMapper mb) { // add mix-in so MemberDto carries @JsonTypeInfo without modifying source mb.addMixIn(MemberDto.class, AbstractDtoMixIn.class); @@ -179,5 +191,5 @@ private void memberDtoSupport(final ObjectMapper mb) { mb.registerSubtypes(new NamedType(ActionDto.class, "ACT")); mb.registerSubtypes(new NamedType(PropertyDto.class, "PROP")); } - + } diff --git a/api/applib/src/test/java/org/apache/causeway/applib/util/schema/CommandDtoUtils_Test.java b/api/applib/src/test/java/org/apache/causeway/applib/util/schema/CommandDtoUtils_Test.java index e346f6780c9..5f81ec5116a 100644 --- a/api/applib/src/test/java/org/apache/causeway/applib/util/schema/CommandDtoUtils_Test.java +++ b/api/applib/src/test/java/org/apache/causeway/applib/util/schema/CommandDtoUtils_Test.java @@ -18,6 +18,13 @@ */ package org.apache.causeway.applib.util.schema; +import java.io.IOException; +import java.io.InputStream; + +import org.apache.causeway.applib.value.Blob; +import org.apache.causeway.applib.value.NamedWithMimeType; + +import org.assertj.core.api.Assertions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -28,6 +35,8 @@ import org.apache.causeway.schema.cmd.v2.CommandDto; import org.apache.causeway.schema.cmd.v2.MapDto; +import org.springframework.util.StreamUtils; + public class CommandDtoUtils_Test { CommandDto dto; @@ -76,4 +85,5 @@ public void clearUserData() { // then assertThat(CommandDtoUtils.getUserData(dto, "someKey"), is(nullValue())); } + } \ No newline at end of file diff --git a/api/applib/src/test/java/org/apache/causeway/applib/util/schema/CommandDtoUtils_fromYaml_Test.commands-with-collection-param.yaml b/api/applib/src/test/java/org/apache/causeway/applib/util/schema/CommandDtoUtils_fromYaml_Test.commands-with-collection-param.yaml new file mode 100644 index 00000000000..f2b7b6b3b5f --- /dev/null +++ b/api/applib/src/test/java/org/apache/causeway/applib/util/schema/CommandDtoUtils_fromYaml_Test.commands-with-collection-param.yaml @@ -0,0 +1,92 @@ +- majorVersion: "2" + minorVersion: "0" + interactionId: "87ded048-530d-41b4-a431-59df0a77899d" + timestamp: "2026-04-21T09:09:06.882+00:00" + username: "estatio-admin" + targets: + oid: + - type: "outgoing.lease.Lease" + id: "32846" + member: ! + parameters: + parameter: + - enum: + enumType: "org.estatio.module.invoice.dom.InvoiceRunType" + enumName: "NORMAL_RUN" + type: "enum" + name: "Run Type" + - collection: + value: + - enum: + enumType: "org.estatio.module.lease.dom.LeaseItemType" + enumName: "RENT" + type: "enum" + - enum: + enumType: "org.estatio.module.lease.dom.LeaseItemType" + enumName: "RENT_FIXED" + type: "enum" + - enum: + enumType: "org.estatio.module.lease.dom.LeaseItemType" + enumName: "RENT_DISCOUNT" + type: "enum" + - enum: + enumType: "org.estatio.module.lease.dom.LeaseItemType" + enumName: "RENT_DISCOUNT_FIXED" + type: "enum" + - enum: + enumType: "org.estatio.module.lease.dom.LeaseItemType" + enumName: "SERVICE_CHARGE" + type: "enum" + - enum: + enumType: "org.estatio.module.lease.dom.LeaseItemType" + enumName: "SERVICE_CHARGE_INDEXABLE" + type: "enum" + - enum: + enumType: "org.estatio.module.lease.dom.LeaseItemType" + enumName: "SERVICE_CHARGE_DISCOUNT_FIXED" + type: "enum" + - enum: + enumType: "org.estatio.module.lease.dom.LeaseItemType" + enumName: "DEPOSIT" + type: "enum" + - enum: + enumType: "org.estatio.module.lease.dom.LeaseItemType" + enumName: "TAX" + type: "enum" + - enum: + enumType: "org.estatio.module.lease.dom.LeaseItemType" + enumName: "MARKETING" + type: "enum" + - enum: + enumType: "org.estatio.module.lease.dom.LeaseItemType" + enumName: "PROPERTY_TAX" + type: "enum" + - enum: + enumType: "org.estatio.module.lease.dom.LeaseItemType" + enumName: "OFFICE_TAX" + type: "enum" + - enum: + enumType: "org.estatio.module.lease.dom.LeaseItemType" + enumName: "RETAIL_TAX" + type: "enum" + type: "enum" + type: "collection" + "null": false + name: "Lease Item Types" + - localDate: "2026-06-30T22:00:00.000+00:00" + type: "localDate" + name: "Invoice Due Date" + - localDate: "2026-06-30T22:00:00.000+00:00" + type: "localDate" + name: "Start Due Date" + - localDate: "2026-07-01T22:00:00.000+00:00" + type: "localDate" + name: "Next Due Date" + - type: "string" + "null": true + name: "Tag Name" + - string: "JDOJPA-T1" + type: "string" + name: "New Tag Name" + logicalMemberIdentifier: "outgoing.lease.Lease#calculate" + interactionType: "action_invocation" \ No newline at end of file diff --git a/api/applib/src/test/java/org/apache/causeway/applib/util/schema/CommandDtoUtils_fromYaml_Test.commands-with-scalar-params.yaml b/api/applib/src/test/java/org/apache/causeway/applib/util/schema/CommandDtoUtils_fromYaml_Test.commands-with-scalar-params.yaml new file mode 100644 index 00000000000..d88b314b5ec --- /dev/null +++ b/api/applib/src/test/java/org/apache/causeway/applib/util/schema/CommandDtoUtils_fromYaml_Test.commands-with-scalar-params.yaml @@ -0,0 +1,32 @@ +- majorVersion: "2" + minorVersion: "0" + interactionId: "08c210e1-55c0-4c45-83db-491f6581e621" + timestamp: "2026-04-21T09:43:58.169+00:00" + username: "estatio-admin" + targets: + oid: + - type: "outgoing.invoiceforlease.InvoiceForLease" + id: "419264" + member: ! + logicalMemberIdentifier: "outgoing.invoiceforlease.InvoiceForLease#approve" + interactionType: "action_invocation" +- majorVersion: "2" + minorVersion: "0" + interactionId: "0e2ad15e-c1d8-48c5-8b63-b8cdde673715" + timestamp: "2026-04-21T09:45:07.409+00:00" + username: "estatio-admin" + targets: + oid: + - type: "outgoing.invoiceforlease.InvoiceForLease" + id: "419264" + member: ! + parameters: + parameter: + - localDate: "2026-04-20T22:00:00.000+00:00" + type: "localDate" + name: "Invoice Date" + - boolean: false + type: "boolean" + name: "Allow Invoice Date In Future" + logicalMemberIdentifier: "outgoing.invoiceforlease.InvoiceForLease#invoice" + interactionType: "action_invocation" diff --git a/api/applib/src/test/java/org/apache/causeway/applib/util/schema/CommandDtoUtils_fromYaml_Test.java b/api/applib/src/test/java/org/apache/causeway/applib/util/schema/CommandDtoUtils_fromYaml_Test.java new file mode 100644 index 00000000000..2608b975d59 --- /dev/null +++ b/api/applib/src/test/java/org/apache/causeway/applib/util/schema/CommandDtoUtils_fromYaml_Test.java @@ -0,0 +1,133 @@ +/* + * 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.causeway.applib.util.schema; + +import java.io.IOException; +import java.io.InputStream; +import java.util.List; + +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.Test; + +import org.springframework.util.StreamUtils; + +import org.apache.causeway.applib.value.Blob; +import org.apache.causeway.applib.value.NamedWithMimeType; +import org.apache.causeway.schema.cmd.v2.ActionDto; +import org.apache.causeway.schema.cmd.v2.CommandDto; +import org.apache.causeway.schema.cmd.v2.ParamDto; +import org.apache.causeway.schema.common.v2.InteractionType; +import org.apache.causeway.schema.common.v2.ValueType; + +public class CommandDtoUtils_fromYaml_Test { + + @Test + public void scalarValues() throws IOException { + var commandDtos = loadCommands("commands-with-scalar-params.yaml"); + + Assertions.assertThat(commandDtos).hasSize(2); + + ActionDto firstAction = (ActionDto) commandDtos.get(0).getMember(); + Assertions.assertThat(firstAction.getLogicalMemberIdentifier()) + .isEqualTo("outgoing.invoiceforlease.InvoiceForLease#approve"); + Assertions.assertThat(firstAction.getInteractionType()) + .isEqualTo(InteractionType.ACTION_INVOCATION); + Assertions.assertThat(commandDtos.get(0).getTargets().getOid()) + .singleElement() + .satisfies(oid -> { + Assertions.assertThat(oid.getType()).isEqualTo("outgoing.invoiceforlease.InvoiceForLease"); + Assertions.assertThat(oid.getId()).isEqualTo("419264"); + }); + + ActionDto secondAction = (ActionDto) commandDtos.get(1).getMember(); + Assertions.assertThat(secondAction.getLogicalMemberIdentifier()) + .isEqualTo("outgoing.invoiceforlease.InvoiceForLease#invoice"); + Assertions.assertThat(secondAction.getInteractionType()) + .isEqualTo(InteractionType.ACTION_INVOCATION); + + List scalarParams = secondAction.getParameters().getParameter(); + Assertions.assertThat(scalarParams).hasSize(2); + + ParamDto invoiceDate = scalarParams.get(0); + Assertions.assertThat(invoiceDate.getName()).isEqualTo("Invoice Date"); + Assertions.assertThat(invoiceDate.getType()).isEqualTo(ValueType.LOCAL_DATE); + Assertions.assertThat(invoiceDate.getLocalDate()).isNotNull(); + + ParamDto allowFuture = scalarParams.get(1); + Assertions.assertThat(allowFuture.getName()).isEqualTo("Allow Invoice Date In Future"); + Assertions.assertThat(allowFuture.getType()).isEqualTo(ValueType.BOOLEAN); + Assertions.assertThat(allowFuture.isBoolean()).isFalse(); + } + + @Test + public void collectionValues() throws IOException { + var commandDtos = loadCommands("commands-with-collection-param.yaml"); + + Assertions.assertThat(commandDtos).hasSize(1); + + ActionDto action = (ActionDto) commandDtos.get(0).getMember(); + Assertions.assertThat(action.getLogicalMemberIdentifier()) + .isEqualTo("outgoing.lease.Lease#calculate"); + Assertions.assertThat(action.getInteractionType()) + .isEqualTo(InteractionType.ACTION_INVOCATION); + + List params = action.getParameters().getParameter(); + Assertions.assertThat(params).hasSize(7); + + ParamDto leaseItemTypes = params.get(1); + Assertions.assertThat(leaseItemTypes.getName()).isEqualTo("Lease Item Types"); + Assertions.assertThat(leaseItemTypes.getType()).isEqualTo(ValueType.COLLECTION); + Assertions.assertThat(leaseItemTypes.isNull()).isFalse(); + Assertions.assertThat(leaseItemTypes.getCollection()).isNotNull(); + Assertions.assertThat(leaseItemTypes.getCollection().getType()).isEqualTo(ValueType.ENUM); + + var items = leaseItemTypes.getCollection().getValue(); + Assertions.assertThat(items).hasSize(13); + Assertions.assertThat(items).allSatisfy(item -> Assertions.assertThat(item.getEnum()).isNotNull()); + + Assertions.assertThat(items.get(0).getEnum().getEnumType()) + .isEqualTo("org.estatio.module.lease.dom.LeaseItemType"); + Assertions.assertThat(items.get(0).getEnum().getEnumName()).isEqualTo("RENT"); + + Assertions.assertThat(items.get(6).getEnum().getEnumName()).isEqualTo("SERVICE_CHARGE_DISCOUNT_FIXED"); + Assertions.assertThat(items.get(12).getEnum().getEnumName()).isEqualTo("RETAIL_TAX"); + + ParamDto nullableTagName = params.get(5); + Assertions.assertThat(nullableTagName.getName()).isEqualTo("Tag Name"); + Assertions.assertThat(nullableTagName.getType()).isEqualTo(ValueType.STRING); + Assertions.assertThat(nullableTagName.isNull()).isTrue(); + + ParamDto newTagName = params.get(6); + Assertions.assertThat(newTagName.getName()).isEqualTo("New Tag Name"); + Assertions.assertThat(newTagName.getType()).isEqualTo(ValueType.STRING); + Assertions.assertThat(newTagName.getString()).isEqualTo("JDOJPA-T1"); + } + + private List loadCommands(final String yamlFileName) throws IOException { + InputStream resourceAsStream = getClass().getResourceAsStream(getClass().getSimpleName() + "." + yamlFileName); + byte[] bytes = StreamUtils.copyToByteArray(resourceAsStream); + + Blob commandsYaml = new Blob( + yamlFileName, + NamedWithMimeType.CommonMimeType.YAML.getMimeType(), + bytes); + + return CommandDtoUtils.fromYaml(commandsYaml.asDataSource()); + } +} \ No newline at end of file diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/CausewayModuleExtCommandLogApplib.java b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/CausewayModuleExtCommandLogApplib.java index f26ba35f88c..4f83a6a5f1f 100644 --- a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/CausewayModuleExtCommandLogApplib.java +++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/CausewayModuleExtCommandLogApplib.java @@ -18,6 +18,7 @@ */ package org.apache.causeway.extensions.commandlog.applib; +import org.apache.causeway.applib.services.clock.ClockService; import org.apache.causeway.applib.services.command.CommandExecutorService; import org.apache.causeway.applib.services.iactnlayer.InteractionService; import org.apache.causeway.applib.services.repository.RepositoryService; @@ -35,6 +36,11 @@ import org.apache.causeway.extensions.commandlog.applib.dom.replay.CommandExportManager; import org.apache.causeway.extensions.commandlog.applib.dom.replay.CommandReplayManager; import org.apache.causeway.extensions.commandlog.applib.dom.replay.ReplayContext; +import org.apache.causeway.extensions.commandlog.applib.dom.replay.ReplayableCommand_delete; +import org.apache.causeway.extensions.commandlog.applib.dom.replay.ReplayableCommand_excludeFromReplay; +import org.apache.causeway.extensions.commandlog.applib.dom.replay.ReplayableCommand_makeExportable; +import org.apache.causeway.extensions.commandlog.applib.dom.replay.ReplayableCommand_openCommandLogEntry; +import org.apache.causeway.extensions.commandlog.applib.dom.replay.ReplayableCommand_replayOrRetry; import org.apache.causeway.extensions.commandlog.applib.fakescheduler.FakeScheduler; import org.apache.causeway.extensions.commandlog.applib.job.BackgroundCommandsJobControl; import org.apache.causeway.extensions.commandlog.applib.job.RunBackgroundCommandsJob; @@ -60,6 +66,24 @@ CommandLogEntry_childCommands.class, CommandLogEntry_openResultObject.class, CommandLogEntry_siblingCommands.class, + ReplayableCommand_makeExportable.class, + ReplayableCommand_openCommandLogEntry.class, + ReplayableCommand_replayOrRetry.class, + ReplayableCommand_excludeFromReplay.class, + ReplayableCommand_delete.class, + CommandExportManager.changeSince.class, + CommandExportManager.previousHour.class, + CommandExportManager.nextHour.class, + CommandExportManager.exportSelected.class, + CommandExportManager.makeSelectedExportable.class, + CommandReplayManager.changeSince.class, + CommandExportManager.previousHour.class, + CommandReplayManager.nextHour.class, + CommandReplayManager.importCommands.class, + CommandReplayManager.replayOrRetrySelected.class, + CommandReplayManager.excludeSelectedFromReplay.class, + CommandReplayManager.deleteSelectedSucceededOrExcluded.class, + CommandReplayManager.deleteSelectedPendingOrFailed.class, // @Component's RunBackgroundCommandsJob.class, @@ -122,9 +146,10 @@ public static void honorSystemEnvironment() { final RepositoryService repositoryService, final InteractionService interactionService, final CommandLogEntryRepository commandLogEntryRepository, - final CommandExecutorService commandExecutorService) { + final CommandExecutorService commandExecutorService, + final ClockService clockService) { return new ReplayContext(repositoryService, interactionService, - commandLogEntryRepository, commandExecutorService); + commandLogEntryRepository, commandExecutorService, clockService); } } diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/app/CommandLogMenu.java b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/app/CommandLogMenu.java index 607bd8e4583..70285c2f1a7 100644 --- a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/app/CommandLogMenu.java +++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/app/CommandLogMenu.java @@ -18,8 +18,13 @@ */ package org.apache.causeway.extensions.commandlog.applib.app; +import java.sql.Timestamp; +import java.time.Instant; import java.time.LocalDate; +import java.time.LocalDateTime; import java.time.ZoneId; +import java.time.temporal.ChronoUnit; +import java.time.temporal.TemporalUnit; import java.util.List; import javax.inject.Inject; @@ -30,7 +35,9 @@ import org.apache.causeway.applib.annotation.DomainService; import org.apache.causeway.applib.annotation.DomainServiceLayout; import org.apache.causeway.applib.annotation.MemberSupport; +import org.apache.causeway.applib.annotation.ParameterLayout; import org.apache.causeway.applib.annotation.PriorityPrecedence; +import org.apache.causeway.applib.annotation.PropertyLayout; import org.apache.causeway.applib.annotation.Publishing; import org.apache.causeway.applib.annotation.RestrictTo; import org.apache.causeway.applib.annotation.SemanticsOf; @@ -41,6 +48,9 @@ import org.apache.causeway.extensions.commandlog.applib.dom.replay.CommandExportManager; import org.apache.causeway.extensions.commandlog.applib.dom.replay.CommandReplayManager; import org.apache.causeway.extensions.commandlog.applib.dom.replay.ReplayContext; + +import org.jspecify.annotations.NonNull; + import org.springframework.lang.Nullable; import lombok.RequiredArgsConstructor; @@ -160,8 +170,16 @@ public class DomainEvent extends ActionDomainEvent { } public class exportManager { public class DomainEvent extends ActionDomainEvent { } - @MemberSupport public CommandExportManager act() { - return new CommandExportManager(null, replayContext); + @MemberSupport public CommandExportManager act( + @ParameterLayout(describedAs = "Limits the commands shown; only commands since this timestamp are available for export. Set to a time immediately before the commands to be replayed.") + final java.sql.Timestamp since + ) { + return new CommandExportManager(since, replayContext); + } + + @MemberSupport public java.sql.Timestamp defaultSince() { + final var now = clockService.getClock().nowAsJavaSqlTimestamp(); + return truncatedTo(now, ChronoUnit.HOURS); } } @@ -176,9 +194,21 @@ public class DomainEvent extends ActionDomainEvent { } public class replayManager { public class DomainEvent extends ActionDomainEvent { } - @MemberSupport public CommandReplayManager act() { - return new CommandReplayManager(null, replayContext); + @MemberSupport public CommandReplayManager act( + @ParameterLayout(describedAs = "Limits the commands shown; only commands since this timestamp are available for replay. Set to a time immediately before the commands to be replayed.") + final java.sql.Timestamp since + ) { + return new CommandReplayManager(since, replayContext); } + + @MemberSupport public java.sql.Timestamp defaultSince() { + final var now = clockService.getClock().nowAsJavaSqlTimestamp(); + return truncatedTo(now, ChronoUnit.HOURS); + } + } + + private static @NonNull Timestamp truncatedTo(Timestamp now, ChronoUnit chronoUnit) { + return Timestamp.from(now.toInstant().truncatedTo(chronoUnit)); } private LocalDate now() { diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/CommandLogEntry.java b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/CommandLogEntry.java index e3ca1d877b7..0636fa19e0a 100644 --- a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/CommandLogEntry.java +++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/CommandLogEntry.java @@ -149,7 +149,10 @@ public static class Nq { * primary before the production database was restored to the secondary. */ public static final String FIND_MOST_RECENT_COMPLETED = LOGICAL_TYPE_NAME + ".findMostRecentCompleted"; - public static final String FIND_BY_REPLAY_STATE = LOGICAL_TYPE_NAME + ".findNotYetReplayed"; + public static final String FIND_FOREGROUND_BY_TIMESTAMP_AFTER_AND_REPLAY_STATE + = LOGICAL_TYPE_NAME + ".findForegroundByTimestampAfterAndReplayState"; + public static final String FIND_FOREGROUND_BY_TIMESTAMP_AFTER_AND_REPLAY_STATES + = LOGICAL_TYPE_NAME + ".findForegroundByTimestampAfterAndReplayStates"; public static final String FIND_BACKGROUND_AND_NOT_YET_STARTED = LOGICAL_TYPE_NAME + ".findBackgroundAndNotYetStarted"; public static final String FIND_RECENT_BACKGROUND_BY_TARGET = LOGICAL_TYPE_NAME + ".findRecentBackgroundByTarget"; } diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/CommandLogEntryRepository.java b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/CommandLogEntryRepository.java index bf8401c6e8c..cf964950e37 100644 --- a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/CommandLogEntryRepository.java +++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/CommandLogEntryRepository.java @@ -18,6 +18,7 @@ */ package org.apache.causeway.extensions.commandlog.applib.dom; +import java.sql.Timestamp; import java.time.LocalDate; import java.util.Collections; import java.util.List; @@ -121,6 +122,22 @@ List findByTargetAndFromAndTo( */ List findSince(final UUID interactionId, final Integer batchSize); + List findForegroundSinceTimestampAndCanBeExported(final Timestamp since); + + List findForegroundSinceTimestampAndHasBeenExported(final Timestamp since); + + /** + * Command Replay feature: Can replay or retry. + */ + List findForegroundSinceTimestampAndWithReplayPendingOrFailed(Timestamp since); + + /** + * Command Replay feature: Cannot replay or retry. + */ + List findSinceAndWithReplayOkOrExcluded(Timestamp since); + + + /** * Returns any persisted commands that have not yet started. * @@ -160,14 +177,6 @@ List findByTargetAndFromAndTo( */ Optional findMostRecentCompleted(); - /** - * Command Replay feature: Can replay or retry. - */ - List findReplayPendingOrFailed(); - /** - * Command Replay feature: Cannot replay or retry. - */ - List findReplaySucceededOrExcluded(); CommandLogEntry saveForReplay(final CommandDto dto); diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/CommandLogEntryRepositoryAbstract.java b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/CommandLogEntryRepositoryAbstract.java index fdbb8b23379..bff9967e360 100644 --- a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/CommandLogEntryRepositoryAbstract.java +++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/CommandLogEntryRepositoryAbstract.java @@ -26,6 +26,7 @@ import java.util.List; import java.util.Optional; import java.util.UUID; +import java.util.stream.Collectors; import javax.inject.Inject; import javax.inject.Provider; @@ -333,27 +334,6 @@ public Optional findMostRecentCompleted() { Query.named(commandLogEntryClass, CommandLogEntry.Nq.FIND_MOST_RECENT_COMPLETED)) ); } - @Override - public List findReplayPendingOrFailed() { - return _Casts.uncheckedCast( - repositoryService().allMatches( - Query.named(commandLogEntryClass, CommandLogEntry.Nq.FIND_BY_REPLAY_STATE) - .withParameter("replayState1", ReplayState.PENDING) - .withParameter("replayState2", ReplayState.FAILED)) - ); - } - /** - * Command Replay feature: Cannot replay or retry. - */ - @Override - public List findReplaySucceededOrExcluded() { - return _Casts.uncheckedCast( - repositoryService().allMatches( - Query.named(commandLogEntryClass, CommandLogEntry.Nq.FIND_BY_REPLAY_STATE) - .withParameter("replayState1", ReplayState.OK) - .withParameter("replayState2", ReplayState.EXCLUDED)) - ); - } @Override public C saveForReplay(final CommandDto commandToReplay) { @@ -370,7 +350,7 @@ public C saveForReplay(final CommandDto commandToReplay) { final C entity = factoryService.detachedEntity(commandLogEntryClass); entity.init(commandToReplay, ReplayState.PENDING, 0); entity.setParentInteractionId(null); // n/a for replay - entity.setExecuteIn(null); // to be specified later depending on user action + entity.setExecuteIn(ExecuteIn.FOREGROUND); // only ever replay foreground commands. persist(entity); @@ -427,6 +407,46 @@ private List findSince( : commandJdos; } + @Override + public List findForegroundSinceTimestampAndCanBeExported(final Timestamp since) { + return findForegroundSinceTimestampWithState(since, ReplayState.UNDEFINED); + } + + @Override + public List findForegroundSinceTimestampAndHasBeenExported(final Timestamp since) { + return findForegroundSinceTimestampWithState(since, ReplayState.EXPORTED); + } + + @Override + public List findForegroundSinceTimestampAndWithReplayPendingOrFailed(final Timestamp since) { + return findForegroundSinceTimestampWithStates(since, ReplayState.PENDING, ReplayState.FAILED); + } + + /** + * Command Replay feature: Cannot replay or retry. + */ + @Override + public List findSinceAndWithReplayOkOrExcluded(final Timestamp since) { + return findForegroundSinceTimestampWithStates(since, ReplayState.OK, ReplayState.EXCLUDED); + } + + private List findForegroundSinceTimestampWithState(Timestamp from, ReplayState replayState) { + return _Casts.uncheckedCast( + repositoryService().allMatches( + Query.named(commandLogEntryClass, CommandLogEntry.Nq.FIND_FOREGROUND_BY_TIMESTAMP_AFTER_AND_REPLAY_STATE) + .withParameter("from", from) + .withParameter("replayState", replayState))); + } + + private List findForegroundSinceTimestampWithStates(Timestamp from, ReplayState replayState1, ReplayState replayState2) { + return _Casts.uncheckedCast( + repositoryService().allMatches( + Query.named(commandLogEntryClass, CommandLogEntry.Nq.FIND_FOREGROUND_BY_TIMESTAMP_AFTER_AND_REPLAY_STATES) + .withParameter("from", from) + .withParameter("replayState1", replayState1) + .withParameter("replayState2", replayState2))); + } + private RepositoryService repositoryService() { return repositoryServiceProvider.get(); } diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/ReplayState.java b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/ReplayState.java index e644cd240d6..93ed4f68660 100644 --- a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/ReplayState.java +++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/ReplayState.java @@ -54,33 +54,31 @@ public enum ReplayState { public boolean isExported() { return this == EXPORTED; } public boolean isFailed() { return this == FAILED; } - public boolean canExport() { + public boolean isExportable() { return this == ReplayState.UNDEFINED; } - public boolean canReplayOrRetryOrMarkForExclusion() { + public boolean isPendingOrFailed() { return this == ReplayState.PENDING || this == ReplayState.FAILED; } // -- NULL SAFE - public static boolean canExport(final @Nullable ReplayState replayState) { - return replayState!=null - ? replayState.canExport() - : true; + public static boolean isExportable(final @Nullable ReplayState replayState) { + return replayState == null || replayState.isExportable(); } - public static boolean canReplayOrRetryOrMarkForExclusion(final @Nullable ReplayState replayState) { - return replayState!=null - ? replayState.canReplayOrRetryOrMarkForExclusion() - : false; + public static boolean isPendingOrFailed(final @Nullable ReplayState replayState) { + return replayState != null && replayState.isPendingOrFailed(); + } + + public static boolean isOkOrExcluded(ReplayState replayState) { + return replayState == ReplayState.OK || replayState == ReplayState.EXCLUDED; } public static boolean isExported(final ReplayState replayState) { - return replayState!=null - ? replayState.isExported() - : false; + return replayState != null && replayState.isExported(); } } diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandExportManager#exported.columnOrder.fallback.txt b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandExportManager#exported.columnOrder.fallback.txt new file mode 100644 index 00000000000..bde91a6f91d --- /dev/null +++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandExportManager#exported.columnOrder.fallback.txt @@ -0,0 +1,6 @@ +#interactionId +timestamp +targetType +targetId +member +replayState \ No newline at end of file diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandExportManager#notYetExported.columnOrder.fallback.txt b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandExportManager#notYetExported.columnOrder.fallback.txt new file mode 100644 index 00000000000..bde91a6f91d --- /dev/null +++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandExportManager#notYetExported.columnOrder.fallback.txt @@ -0,0 +1,6 @@ +#interactionId +timestamp +targetType +targetId +member +replayState \ No newline at end of file diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandExportManager.java b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandExportManager.java index 8f963454dda..f0243676053 100644 --- a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandExportManager.java +++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandExportManager.java @@ -18,6 +18,11 @@ */ package org.apache.causeway.extensions.commandlog.applib.dom.replay; +import lombok.Getter; + +import java.sql.Timestamp; +import java.time.Instant; +import java.time.chrono.ChronoZonedDateTime; import java.util.List; import java.util.Optional; import java.util.stream.Collectors; @@ -25,10 +30,6 @@ import javax.inject.Inject; import javax.inject.Named; -import lombok.AllArgsConstructor; -import lombok.Getter; -import lombok.experimental.Accessors; - import org.apache.causeway.applib.ViewModel; import org.apache.causeway.applib.annotation.Action; import org.apache.causeway.applib.annotation.ActionLayout; @@ -37,7 +38,12 @@ import org.apache.causeway.applib.annotation.DomainObject; import org.apache.causeway.applib.annotation.DomainObjectLayout; import org.apache.causeway.applib.annotation.Introspection; +import org.apache.causeway.applib.annotation.MemberSupport; import org.apache.causeway.applib.annotation.ObjectSupport; +import org.apache.causeway.applib.annotation.Property; +import org.apache.causeway.applib.annotation.PropertyLayout; +import org.apache.causeway.applib.annotation.Publishing; +import org.apache.causeway.applib.annotation.RestrictTo; import org.apache.causeway.applib.annotation.SemanticsOf; import org.apache.causeway.applib.util.schema.CommandDtoUtils; import org.apache.causeway.applib.value.Blob; @@ -48,138 +54,266 @@ import org.apache.causeway.extensions.commandlog.applib.dom.CommandLogEntryRepository; import org.apache.causeway.extensions.commandlog.applib.dom.ReplayState; +import static org.apache.causeway.extensions.commandlog.applib.dom.replay.TimestampMarshallUtil.fromString; + @DomainObject(introspection = Introspection.ANNOTATION_REQUIRED) @DomainObjectLayout(cssClassFa = "solid share-from-square") @Named(CommandExportManager.LOGICAL_TYPE_NAME) -@AllArgsConstructor public final class CommandExportManager implements ViewModel { - + public static final String LOGICAL_TYPE_NAME = CausewayModuleExtCommandLogApplib.NAMESPACE + ".CommandExportManager"; - - @Getter @Accessors(fluent = true) - private final ReplayContext replayContext; + + public static abstract class ActionDomainEvent + extends CausewayModuleExtCommandLogApplib.ActionDomainEvent { } + + private ReplayContext replayContext; @Inject public CommandExportManager( final String memento, final ReplayContext replayContext) { - this(replayContext); + this(fromString(memento, replayContext.clockService().getClock().nowAsJavaSqlTimestamp()), replayContext); + } + + public CommandExportManager( + final java.sql.Timestamp since, + final ReplayContext replayContext) { + this.since = since; + this.replayContext = replayContext; } @ObjectSupport public String title() { return "Command Export Manager"; } - @Action(semantics = SemanticsOf.IDEMPOTENT_ARE_YOU_SURE) + + @Property + @PropertyLayout(describedAs = "Only commands since this timestamp are available for export") + @Getter + private java.sql.Timestamp since; + + @Action( + semantics = SemanticsOf.SAFE, + commandPublishing = Publishing.DISABLED, + domainEvent = previousHour.DomainEvent.class, + executionPublishing = Publishing.DISABLED + ) + @ActionLayout( + associateWith = "since", sequence = "1", + named = "Previous", + position = ActionLayout.Position.PANEL, + describedAs = "Move back one hour" + ) + public class previousHour { + public class DomainEvent extends ActionDomainEvent { } + + @MemberSupport public CommandExportManager act() { + return new CommandExportManager(addSeconds(since, -3600), replayContext); + } + } + + @Action( + semantics = SemanticsOf.SAFE, + commandPublishing = Publishing.DISABLED, + domainEvent = nextHour.DomainEvent.class, + executionPublishing = Publishing.DISABLED + ) @ActionLayout( - sequence = "0.1", - describedAs = "Deletes all commands, regardless of state (cannot be undone)") - public CommandExportManager deleteAll() { - commandLogEntryRepository().removeAll(); - return this; + associateWith = "since", sequence = "3", + named = "Next", + position = ActionLayout.Position.PANEL, + describedAs = "Move forward one hour" + ) + public class nextHour { + public class DomainEvent extends ActionDomainEvent { } + @MemberSupport public CommandExportManager act() { + return new CommandExportManager(addSeconds(since, +3600), replayContext); + } + } + + @Action( + restrictTo = RestrictTo.PROTOTYPING, + semantics = SemanticsOf.SAFE, + commandPublishing = Publishing.DISABLED, + domainEvent = changeSince.DomainEvent.class, + executionPublishing = Publishing.DISABLED + ) + @ActionLayout( + associateWith = "since", sequence = "2", + named = "Change", + position = ActionLayout.Position.PANEL + ) + public class changeSince { + public class DomainEvent extends ActionDomainEvent { } + @MemberSupport public CommandExportManager act(final java.sql.Timestamp since) { + return new CommandExportManager(since, replayContext); + } + @MemberSupport public java.sql.Timestamp defaultSince() { + return CommandExportManager.this.since; + } + } + + private static Timestamp addSeconds(Timestamp since, int secondsToAdd) { + return Timestamp.from(since.toInstant().plusSeconds(secondsToAdd)); } + // -- NOT YET EXPORTED @Collection @CollectionLayout( - describedAs = "Commands that can be exported") + describedAs = "Commands that can be exported" + ) public List getNotYetExported() { - return commandLogEntryRepository().findAll().stream() - .filter(entry->ReplayState.canExport(entry.getReplayState())) + return commandLogEntryRepository().findForegroundSinceTimestampAndCanBeExported(since).stream() .map(entry->new ReplayableCommand( entry.getInteractionId(), replayContext)) .collect(Collectors.toList()); } - @Action(choicesFrom = "notYetExported", semantics = SemanticsOf.NON_IDEMPOTENT) - @ActionLayout(associateWith = "notYetExported", - sequence = "1.1", - cssClassFa = "solid share-from-square", - cssClass = "btn-primary", - describedAs = "Exports selected Commands as zipped DTOs for import later. " - + "(You need to refresh the page to see changed states.)") - public Blob exportSelected( - final List selected) { - - var selectedCommandLogEntries = selected.stream() - .map(ReplayableCommand::commandLogEntry) - .filter(Optional::isPresent) - .map(Optional::get) - .sorted() - .collect(Collectors.toList()); + @Action( + restrictTo = RestrictTo.PROTOTYPING, + choicesFrom = "notYetExported", + semantics = SemanticsOf.NON_IDEMPOTENT, + commandPublishing = Publishing.DISABLED, + domainEvent = exportSelected.DomainEvent.class, + executionPublishing = Publishing.DISABLED + ) + @ActionLayout( + associateWith = "notYetExported", sequence = "1.1", + cssClassFa = "solid share-from-square", + cssClass = "btn-primary", + describedAs = "Exports selected Commands as zipped DTOs for import later. " + + "Refresh the page to see changed states." + ) + public class exportSelected { + public class DomainEvent extends ActionDomainEvent { } - var yaml = CommandDtoUtils.toYaml( - selectedCommandLogEntries.stream() - .filter(entry->!ReplayState.isExported(entry.getReplayState())) - .map(CommandLogEntry::getCommandDto) - .collect(Collectors.toList())); + @MemberSupport public Blob act( + final List selected, + final String filenamePrefix, + final boolean filenameTimestamp + ) { - var blob = Clob.of("commands.yaml", CommonMimeType.YAML, yaml) - .toBlobUtf8() - .zip(); + var selectedCommandLogEntries = selected.stream() + .map(ReplayableCommand::commandLogEntry) + .filter(Optional::isPresent) + .map(Optional::get) + .sorted() + .collect(Collectors.toList()); - // do this last once we have successfully created the Clob - selectedCommandLogEntries.forEach(c->c.setReplayState(ReplayState.EXPORTED)); + var yaml = CommandDtoUtils.toYaml( + selectedCommandLogEntries.stream() + .filter(entry->!ReplayState.isExported(entry.getReplayState())) // shouldn't be necessary unless a race condition + .map(CommandLogEntry::getCommandDto) + .collect(Collectors.toList())); - return blob; - } + final var replayableCommand = selected.get(0); // validate ensures there is at least one command + final var timestamp = filenameTimestamp + ? replayableCommand.getTimestampIfAny() + .map(ChronoZonedDateTime::toInstant) + .map(Instant::toString) + .map(x -> "." + x.replaceAll("[^A-Za-z0-9._-]", "_")) // make safe within filename + .orElse("") + : ""; + final var filename = filenamePrefix + timestamp; + + var blob = Clob.of(filename, CommonMimeType.YAML, yaml) + .toBlobUtf8() + .zip(); + + // do this last once we have successfully created the Clob + selectedCommandLogEntries.forEach(c->c.setReplayState(ReplayState.EXPORTED)); + + return blob; + } + + @MemberSupport public String disableAct() { + return getNotYetExported().isEmpty() ? "No commands in collection" : null; + } + + @MemberSupport public String defaultFilenamePrefix() { + return "commands"; + } + + @MemberSupport public boolean defaultFilenameTimestamp() { + return true; + } + + @MemberSupport public String validateSelected(final List selected) { + return selected != null && selected.isEmpty() ? "Select at least one command to export" : null; + } - @Action(choicesFrom = "notYetExported") - @ActionLayout(associateWith = "notYetExported", sequence = "1.2", - describedAs = "Deletes selected Commands (cannot be undone)") - public CommandExportManager deleteSelected(final List selected) { - selected.stream() - .forEach(ReplayableCommand::delete); // filtered on its own responsibility - return this; + // TODO: shouldn't be required because of 'choicesFrom', but in v2 there seems to be a MM validation error due to a missing choicesFacet + @MemberSupport + public List choicesSelected() { + return getNotYetExported(); + } } + // -- EXPORTED @Collection - @CollectionLayout( - describedAs = "Commands that were exported") + @CollectionLayout(describedAs = "Commands that have been exported") public List getExported() { - return commandLogEntryRepository().findAll().stream() - .filter(entry->ReplayState.isExported(entry.getReplayState())) + return commandLogEntryRepository().findForegroundSinceTimestampAndHasBeenExported(since).stream() .map(entry->new ReplayableCommand( entry.getInteractionId(), replayContext)) .collect(Collectors.toList()); } - @Action(choicesFrom = "exported") - @ActionLayout(associateWith = "exported", sequence = "2.1", - describedAs = "Makes selected Commands exportable (again)") - public CommandExportManager makeSelectedExportable(final List selected) { - selected.stream() - .forEach(ReplayableCommand::makeExportable); // filtered on its own responsibility - return this; - } - @Action(choicesFrom = "exported") - @ActionLayout(associateWith = "exported", sequence = "2.2", - named = "Delete Selected", - describedAs = "Deletes selected Commands (cannot be undone)") - public CommandExportManager deleteSelected2(final List selected) { - selected.stream() - .forEach(ReplayableCommand::delete); // filtered on its own responsibility - return this; + @Action( + restrictTo = RestrictTo.PROTOTYPING, + choicesFrom = "exported", + commandPublishing = Publishing.DISABLED, + semantics = SemanticsOf.IDEMPOTENT, + domainEvent = makeSelectedExportable.DomainEvent.class, + executionPublishing = Publishing.DISABLED + ) + @ActionLayout( + associateWith = "exported", sequence = "2.1", + describedAs = "Makes selected Commands exportable (again)" + ) + public class makeSelectedExportable { + public class DomainEvent extends ActionDomainEvent { } + + @MemberSupport + public CommandExportManager act(final List selected) { + selected.forEach(ReplayableCommand::makeExportable); // filtered on its own responsibility + return CommandExportManager.this; + } + + @MemberSupport + public String disableAct() { + return getExported().isEmpty() ? "No commands in collection" : null; + } + + @MemberSupport + public String validateSelected(final List selected) { + return selected != null && selected.isEmpty() ? "Select at least one command" : null; + } + + // TODO: shouldn't be required because of 'choicesFrom', but in v2 there seems to be a MM validation error due to a missing choicesFacet + @MemberSupport + public List choicesSelected() { + return getExported(); + } } + // -- VM STATE @Override public String viewModelMemento() { - // TODO could use to store filter state - return null; + return TimestampMarshallUtil.toString(this.since); } // -- HELPER - private CommandLogEntryRepository commandLogEntryRepository() { return replayContext.commandLogEntryRepository(); } - } diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandExportManager.layout.fallback.xml b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandExportManager.layout.fallback.xml index df20c9b73aa..ba0d2da0ef7 100644 --- a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandExportManager.layout.fallback.xml +++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandExportManager.layout.fallback.xml @@ -1,9 +1,17 @@ - + - + + + + + + + + + diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandReplayManager#pendingOrFailed.columnOrder.fallback.txt b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandReplayManager#pendingOrFailed.columnOrder.fallback.txt new file mode 100644 index 00000000000..bde91a6f91d --- /dev/null +++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandReplayManager#pendingOrFailed.columnOrder.fallback.txt @@ -0,0 +1,6 @@ +#interactionId +timestamp +targetType +targetId +member +replayState \ No newline at end of file diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandReplayManager#succeededOrExcluded.columnOrder.fallback.txt b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandReplayManager#succeededOrExcluded.columnOrder.fallback.txt new file mode 100644 index 00000000000..bde91a6f91d --- /dev/null +++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandReplayManager#succeededOrExcluded.columnOrder.fallback.txt @@ -0,0 +1,6 @@ +#interactionId +timestamp +targetType +targetId +member +replayState \ No newline at end of file diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandReplayManager.java b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandReplayManager.java index e6405bb37b0..b326e4c4be2 100644 --- a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandReplayManager.java +++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandReplayManager.java @@ -18,6 +18,7 @@ */ package org.apache.causeway.extensions.commandlog.applib.dom.replay; +import java.sql.Timestamp; import java.util.List; import java.util.stream.Collectors; @@ -32,8 +33,13 @@ import org.apache.causeway.applib.annotation.DomainObject; import org.apache.causeway.applib.annotation.DomainObjectLayout; import org.apache.causeway.applib.annotation.Introspection; +import org.apache.causeway.applib.annotation.MemberSupport; import org.apache.causeway.applib.annotation.ObjectSupport; import org.apache.causeway.applib.annotation.Parameter; +import org.apache.causeway.applib.annotation.Property; +import org.apache.causeway.applib.annotation.PropertyLayout; +import org.apache.causeway.applib.annotation.Publishing; +import org.apache.causeway.applib.annotation.RestrictTo; import org.apache.causeway.applib.annotation.SemanticsOf; import org.apache.causeway.applib.util.schema.CommandDtoUtils; import org.apache.causeway.applib.value.Blob; @@ -42,140 +48,348 @@ import org.apache.causeway.extensions.commandlog.applib.dom.CommandLogEntryRepository; import org.apache.causeway.schema.cmd.v2.CommandDto; -import lombok.AllArgsConstructor; import lombok.Getter; -import lombok.experimental.Accessors; + +import static org.apache.causeway.extensions.commandlog.applib.dom.replay.TimestampMarshallUtil.fromString; @DomainObject(introspection = Introspection.ANNOTATION_REQUIRED) @DomainObjectLayout(cssClassFa = "solid circle-play") @Named(CommandReplayManager.LOGICAL_TYPE_NAME) -@AllArgsConstructor public final class CommandReplayManager implements ViewModel { - + public static final String LOGICAL_TYPE_NAME = CausewayModuleExtCommandLogApplib.NAMESPACE + ".CommandReplayManager"; - - @Getter @Accessors(fluent = true) - private final ReplayContext replayContext; + + public static abstract class ActionDomainEvent + extends CausewayModuleExtCommandLogApplib.ActionDomainEvent { } + + private ReplayContext replayContext; @Inject public CommandReplayManager( final String memento, final ReplayContext replayContext) { - this(replayContext); + this(fromString(memento, replayContext.clockService().getClock().nowAsJavaSqlTimestamp()), replayContext); + } + + public CommandReplayManager( + final java.sql.Timestamp since, + final ReplayContext replayContext) { + this.since = since; + this.replayContext = replayContext; } @ObjectSupport public String title() { return "Command Replay Manager"; } - @Action + + @Property + @PropertyLayout(describedAs = "Only commands since this timestamp are available for export") + @Getter + private java.sql.Timestamp since; + + @Action( + semantics = SemanticsOf.SAFE, + commandPublishing = Publishing.DISABLED, + domainEvent = previousHour.DomainEvent.class, + executionPublishing = Publishing.DISABLED + ) @ActionLayout( - sequence = "0.1", - cssClass = "btn-primary", - describedAs = "Imports commands from a zipped yaml, then persists them with replayState=PENDING.") - public CommandReplayManager importCommands( - @Parameter(fileAccept = ".zip") - final Blob zippedCommandsYaml) { + associateWith = "since", sequence = "1", + named = "Previous", + position = ActionLayout.Position.PANEL, + describedAs = "Move back one hour" + ) + public class previousHour { + public class DomainEvent extends ActionDomainEvent { } - var yamlDs = zippedCommandsYaml.unZip(CommonMimeType.YAML).asDataSource(); + @MemberSupport public CommandReplayManager act() { + return new CommandReplayManager(addSeconds(since, -3600), replayContext); + } + } - final List commandDtos = CommandDtoUtils.fromYaml(yamlDs); - commandDtos.forEach(commandLogEntryRepository()::saveForReplay); + @Action( + semantics = SemanticsOf.SAFE, + commandPublishing = Publishing.DISABLED, + domainEvent = nextHour.DomainEvent.class, + executionPublishing = Publishing.DISABLED + ) + @ActionLayout( + associateWith = "since", sequence = "3", + named = "Next", + position = ActionLayout.Position.PANEL, + describedAs = "Move forward one hour" + ) + public class nextHour { + public class DomainEvent extends ActionDomainEvent { } + @MemberSupport public CommandReplayManager act() { + return new CommandReplayManager(addSeconds(since, +3600), replayContext); + } + } + + @Action( + restrictTo = RestrictTo.PROTOTYPING, + semantics = SemanticsOf.SAFE, + commandPublishing = Publishing.DISABLED, + domainEvent = changeSince.DomainEvent.class, + executionPublishing = Publishing.DISABLED + ) + @ActionLayout( + associateWith = "since", sequence = "2", + named = "Change", + position = ActionLayout.Position.PANEL + ) + public class changeSince { + public class DomainEvent extends ActionDomainEvent { } + @MemberSupport public CommandReplayManager act(final java.sql.Timestamp since) { + return new CommandReplayManager(since, replayContext); + } + @MemberSupport public java.sql.Timestamp defaultSince() { + return CommandReplayManager.this.since; + } + } - return this; + private static Timestamp addSeconds(Timestamp since, int secondsToAdd) { + return Timestamp.from(since.toInstant().plusSeconds(secondsToAdd)); } - @Action(semantics = SemanticsOf.IDEMPOTENT_ARE_YOU_SURE) + @Action( + restrictTo = RestrictTo.PROTOTYPING, + semantics = SemanticsOf.IDEMPOTENT, + commandPublishing = Publishing.DISABLED, + domainEvent = importCommands.DomainEvent.class, + executionPublishing = Publishing.DISABLED + ) @ActionLayout( - sequence = "0.2", - describedAs = "Deletes all commands, regardless of state (cannot be undone)") - public CommandReplayManager deleteAll() { - commandLogEntryRepository().removeAll(); - return this; + sequence = "0.1", + associateWith = "pendingOrFailed", + cssClass = "btn-primary", + describedAs = "Imports commands from a zipped yaml, then persists them with replayState=PENDING." + ) + public class importCommands { + public class DomainEvent extends ActionDomainEvent { } + public CommandReplayManager act( + @Parameter(fileAccept = ".zip") + final Blob zippedCommandsYaml) { + + var yamlDs = zippedCommandsYaml.unZip(CommonMimeType.YAML).asDataSource(); + + final List commandDtos = CommandDtoUtils.fromYaml(yamlDs); + commandDtos.forEach(commandLogEntryRepository()::saveForReplay); + + return CommandReplayManager.this; + } } + // -- PENDING OR FAILED @Collection @CollectionLayout( - describedAs = "Imported Commands that can be either replayed (replayState=PENDING) or retried (when replayState=FAILED)") + describedAs = "Imported Commands that can be either replayed (replayState=PENDING) or retried (when replayState=FAILED)" + ) public List getPendingOrFailed() { - return commandLogEntryRepository().findReplayPendingOrFailed().stream() + return commandLogEntryRepository().findForegroundSinceTimestampAndWithReplayPendingOrFailed(since).stream() .map(entry->new ReplayableCommand( entry.getInteractionId(), replayContext)) .collect(Collectors.toList()); } - @Action(choicesFrom = "pendingOrFailed") - @ActionLayout(associateWith = "pendingOrFailed", - sequence = "1.1", - cssClassFa = "solid circle-play", - cssClass = "btn-primary", - describedAs = "Executes the list of commands in sequence, after having sorted them by their timestamp. " - + "If any of the given commands fails, " - + "the surrounding transaction is rolled back and any successful commands are undone). " - + "The command, that caused the failure, gets marked as FAILED.") - public CommandReplayManager replayOrRetrySelected(final List selected) { - var replayables = selected.stream() - .sorted() - .collect(Collectors.toList()); - for(var replayableCommand : replayables) { - var tryReplayOrRetry = replayableCommand.tryReplayOrRetry(); // filtered on its own responsibility - if(tryReplayOrRetry.isFailure()) - return this; // stop further execution + + @Action( + restrictTo = RestrictTo.PROTOTYPING, + choicesFrom = "pendingOrFailed", + semantics = SemanticsOf.NON_IDEMPOTENT, + commandPublishing = Publishing.DISABLED, + domainEvent = replayOrRetrySelected.DomainEvent.class, + executionPublishing = Publishing.DISABLED + ) + @ActionLayout( + associateWith = "pendingOrFailed", sequence = "1.1", + cssClassFa = "solid circle-play", + cssClass = "btn-primary", + describedAs = "Executes the list of commands in sequence, after having sorted them by their timestamp. " + + "If any of the given commands fails, " + + "the surrounding transaction is rolled back and any successful commands are undone). " + + "The command, that caused the failure, gets marked as FAILED." + ) + public class replayOrRetrySelected { + public class DomainEvent extends ActionDomainEvent { } + @MemberSupport public CommandReplayManager act(final List selected) { + var replayables = selected.stream() + .sorted() + .collect(Collectors.toList()); + for(var replayableCommand : replayables) { + var tryReplayOrRetry = replayableCommand.tryReplayOrRetry(); // filtered on its own responsibility + if(tryReplayOrRetry.isFailure()) { + return CommandReplayManager.this; // stop further execution + } + } + return CommandReplayManager.this; + } + + + @MemberSupport + public String disableAct() { + return getPendingOrFailed().isEmpty() ? "No commands in collection" : null; + } + + @MemberSupport + public String validateSelected(final List selected) { + return selected != null && selected.isEmpty() ? "Select at least one command" : null; + } + + // TODO: shouldn't be required because of 'choicesFrom', but in v2 there seems to be a MM validation error due to a missing choicesFacet + @MemberSupport + public List choicesSelected() { + return getPendingOrFailed(); } - return this; } - @Action(choicesFrom = "pendingOrFailed") - @ActionLayout(associateWith = "pendingOrFailed", sequence = "1.2", - describedAs = "Marks selected Commands to be EXCLUDED from replay") - public CommandReplayManager excludeSelectedFromReplay(final List selected) { - selected.stream() - .forEach(ReplayableCommand::excludeFromReplay); // filtered on its own responsibility - return this; + + + @Action( + restrictTo = RestrictTo.PROTOTYPING, + choicesFrom = "pendingOrFailed", + semantics = SemanticsOf.NON_IDEMPOTENT, + commandPublishing = Publishing.DISABLED, + domainEvent = excludeSelectedFromReplay.DomainEvent.class, + executionPublishing = Publishing.DISABLED + ) + @ActionLayout( + associateWith = "pendingOrFailed", sequence = "1.2", + describedAs = "Marks selected Commands to be EXCLUDED from replay" + ) + public class excludeSelectedFromReplay { + public class DomainEvent extends ActionDomainEvent { } + @MemberSupport + public CommandReplayManager act(final List selected) { + selected.stream() + .forEach(ReplayableCommand::excludeFromReplay); // filtered on its own responsibility + return CommandReplayManager.this; + } + + @MemberSupport + public String disableAct() { + return getPendingOrFailed().isEmpty() ? "No commands in collection" : null; + } + + @MemberSupport + public String validateSelected(final List selected) { + return selected != null && selected.isEmpty() ? "Select at least one command" : null; + } + + // TODO: shouldn't be required because of 'choicesFrom', but in v2 there seems to be a MM validation error due to a missing choicesFacet + @MemberSupport + public List choicesSelected() { + return getPendingOrFailed(); + } + } - @Action(choicesFrom = "pendingOrFailed") - @ActionLayout(associateWith = "pendingOrFailed", sequence = "1.3", - describedAs = "Deletes selected Commands (cannot be undone)") - public CommandReplayManager deleteSelected(final List selected) { - selected.stream() - .forEach(ReplayableCommand::delete); // filtered on its own responsibility - return this; + + + @Action( + restrictTo = RestrictTo.PROTOTYPING, + choicesFrom = "pendingOrFailed", + semantics = SemanticsOf.NON_IDEMPOTENT, + commandPublishing = Publishing.DISABLED, + domainEvent = deleteSelectedPendingOrFailed.DomainEvent.class, + executionPublishing = Publishing.DISABLED + ) + @ActionLayout( + associateWith = "pendingOrFailed", sequence = "1.3", + describedAs = "Deletes selected Commands (cannot be undone)" + ) + public class deleteSelectedPendingOrFailed { + public class DomainEvent extends ActionDomainEvent { } + public CommandReplayManager act(final List selected) { + selected.stream() + .forEach(ReplayableCommand::deleteObj); // filtered on its own responsibility + return CommandReplayManager.this; + } + + @MemberSupport + public String disableAct() { + return getPendingOrFailed().isEmpty() ? "No commands in collection" : null; + } + + @MemberSupport + public String validateSelected(final List selected) { + return selected != null && selected.isEmpty() ? "Select at least one command" : null; + } + + // TODO: shouldn't be required because of 'choicesFrom', but in v2 there seems to be a MM validation error due to a missing choicesFacet + @MemberSupport + public List choicesSelected() { + return getPendingOrFailed(); + } + } + + // -- OK OR EXCLUDE @Collection @CollectionLayout( describedAs = "Imported Commands that were either replayed with success (replayState=OK) " - + "or marked to be excluded from replay (replayState=EXCLUDE)") + + "or marked to be excluded from replay (replayState=EXCLUDE)" + ) public List getSucceededOrExcluded() { - return commandLogEntryRepository().findReplaySucceededOrExcluded().stream() + return commandLogEntryRepository().findSinceAndWithReplayOkOrExcluded(since).stream() .map(entry->new ReplayableCommand( entry.getInteractionId(), replayContext)) .collect(Collectors.toList()); } - @Action(choicesFrom = "succeededOrExcluded") - @ActionLayout(associateWith = "succeededOrExcluded", + + @Action( + restrictTo = RestrictTo.PROTOTYPING, + choicesFrom = "succeededOrExcluded", + semantics = SemanticsOf.IDEMPOTENT, + domainEvent = deleteSelectedSucceededOrExcluded.DomainEvent.class, + executionPublishing = Publishing.DISABLED + ) + @ActionLayout( + associateWith = "succeededOrExcluded", named = "Delete Selected", - describedAs = "Deletes selected Commands (cannot be undone)") - public CommandReplayManager deleteSelected2(final List selected) { - selected.stream() - .forEach(ReplayableCommand::delete); // filtered on its own responsibility - return this; + describedAs = "Deletes selected Commands (cannot be undone)" + ) + public class deleteSelectedSucceededOrExcluded { + public class DomainEvent extends ActionDomainEvent { } + public CommandReplayManager act(final List selected) { + selected.stream() + .forEach(ReplayableCommand::deleteObj); // filtered on its own responsibility + return CommandReplayManager.this; + } + + @MemberSupport + public String disableAct() { + return getSucceededOrExcluded().isEmpty() ? "No commands in collection" : null; + } + + @MemberSupport + public String validateSelected(final List selected) { + return selected != null && selected.isEmpty() ? "Select at least one command" : null; + } + + // TODO: shouldn't be required because of 'choicesFrom', but in v2 there seems to be a MM validation error due to a missing choicesFacet + @MemberSupport + public List choicesSelected() { + return getSucceededOrExcluded(); + } } + + // -- VM STATE @Override public String viewModelMemento() { - // TODO could use to store filter state - return null; + return TimestampMarshallUtil.toString(this.since); } // -- HELPER @@ -183,5 +397,4 @@ public String viewModelMemento() { private CommandLogEntryRepository commandLogEntryRepository() { return replayContext.commandLogEntryRepository(); } - } diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandReplayManager.layout.fallback.xml b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandReplayManager.layout.fallback.xml index 30489ca2158..c7aed354e9f 100644 --- a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandReplayManager.layout.fallback.xml +++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandReplayManager.layout.fallback.xml @@ -1,9 +1,17 @@ - + - + + + + + + + + + diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayContext.java b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayContext.java index 60d47e0a15f..580e2f05ae6 100644 --- a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayContext.java +++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayContext.java @@ -18,6 +18,7 @@ */ package org.apache.causeway.extensions.commandlog.applib.dom.replay; +import org.apache.causeway.applib.services.clock.ClockService; import org.apache.causeway.applib.services.command.CommandExecutorService; import org.apache.causeway.applib.services.iactnlayer.InteractionService; import org.apache.causeway.applib.services.repository.RepositoryService; @@ -35,4 +36,5 @@ public final class ReplayContext { InteractionService interactionService; CommandLogEntryRepository commandLogEntryRepository; CommandExecutorService commandExecutorService; + ClockService clockService; } diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand.columnOrder.fallback.txt b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand.columnOrder.fallback.txt new file mode 100644 index 00000000000..bde91a6f91d --- /dev/null +++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand.columnOrder.fallback.txt @@ -0,0 +1,6 @@ +#interactionId +timestamp +targetType +targetId +member +replayState \ No newline at end of file diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand.java b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand.java index 573651b2fde..e2ea51718b0 100644 --- a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand.java +++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand.java @@ -18,7 +18,9 @@ */ package org.apache.causeway.extensions.commandlog.applib.dom.replay; +import java.time.Instant; import java.time.ZonedDateTime; +import java.time.chrono.ChronoZonedDateTime; import java.util.Comparator; import java.util.Optional; import java.util.UUID; @@ -36,8 +38,12 @@ import org.apache.causeway.applib.annotation.LabelPosition; import org.apache.causeway.applib.annotation.MemberSupport; import org.apache.causeway.applib.annotation.ObjectSupport; +import org.apache.causeway.applib.annotation.Programmatic; import org.apache.causeway.applib.annotation.Property; import org.apache.causeway.applib.annotation.PropertyLayout; +import org.apache.causeway.applib.annotation.Publishing; +import org.apache.causeway.applib.annotation.RestrictTo; +import org.apache.causeway.applib.annotation.SemanticsOf; import org.apache.causeway.applib.annotation.Where; import org.apache.causeway.applib.jaxb.JavaTimeXMLGregorianCalendarMarshalling; import org.apache.causeway.applib.services.command.CommandExecutorService.InteractionContextPolicy; @@ -49,7 +55,6 @@ import org.apache.causeway.commons.io.YamlUtils; import org.apache.causeway.extensions.commandlog.applib.CausewayModuleExtCommandLogApplib; import org.apache.causeway.extensions.commandlog.applib.dom.CommandLogEntry; -import org.apache.causeway.extensions.commandlog.applib.dom.ExecuteIn; import org.apache.causeway.extensions.commandlog.applib.dom.ReplayState; import org.apache.causeway.schema.cmd.v2.CommandDto; import org.apache.causeway.schema.cmd.v2.MemberDto; @@ -59,7 +64,6 @@ import org.apache.causeway.valuetypes.asciidoc.builder.AsciiDocFactory; import lombok.AllArgsConstructor; -import lombok.Getter; import lombok.Value; import lombok.experimental.Accessors; @@ -71,27 +75,34 @@ @Named(ReplayableCommand.LOGICAL_TYPE_NAME) @AllArgsConstructor public final class ReplayableCommand implements ViewModel, Comparable { - - @Getter @Accessors(fluent = true) - private final UUID interactionId; - - @Getter @Accessors(fluent = true) + + public static abstract class ActionDomainEvent + extends CausewayModuleExtCommandLogApplib.ActionDomainEvent { } + + private final UUID interactionId; + @Programmatic + public UUID interactionId() { return interactionId; } + private final ReplayContext replayContext; - - @Getter @Accessors(fluent = true) + @Programmatic + public ReplayContext replayContext() { return replayContext; } + + private final ObjectReference recordRef; + @Programmatic + public ObjectReference recordRef() { return recordRef; } public static final String LOGICAL_TYPE_NAME = CausewayModuleExtCommandLogApplib.NAMESPACE + ".ReplayableCommand"; // decoupled from the underlying entity @Value @Accessors(fluent = true) final static class CommandRecord { - + final CommandDto commandDto; final ReplayState replayState; - + boolean canReplayOrRetryOrMarkForExclusion() { - return replayState.canReplayOrRetryOrMarkForExclusion(); + return replayState.isPendingOrFailed(); } public String faQuickIcon() { switch(replayState) { @@ -132,10 +143,11 @@ public ReplayableCommand( } @ObjectSupport public String title() { - return "Replayable Command"; + final var timestamp = getTimestampIfAny().map(ChronoZonedDateTime::toInstant).map(Instant::toString).map(x -> " @ " + x).orElse(""); + return getTargetType() + ":" + getTargetId() + " #" + getMember() + timestamp; } -//requires v4 +//requires v4 // @ObjectSupport public ObjectSupport.IconResource icon(final ObjectSupport.IconSize iconSize) { // return commandRecord() // .map(CommandRecord::faQuickIcon) @@ -153,7 +165,7 @@ public ReplayableCommand( @PropertyLayout( sequence = "1.1", fieldSetId = "details", - describedAs = "UUID of the original (replayabel) Command") + describedAs = "UUID of the original (replayable) Command") public UUID getInteractionId() { return interactionId; } @@ -162,20 +174,25 @@ public UUID getInteractionId() { @PropertyLayout( sequence = "1.2", fieldSetId = "details", - describedAs = "Timestamp of the original (replayabel) Command") + describedAs = "Timestamp of the original (replayable) Command") public ZonedDateTime getTimestamp() { - return commandRecord() - .map(CommandRecord::commandDto) - .map(CommandDto::getTimestamp) - .map(JavaTimeXMLGregorianCalendarMarshalling::toZonedDateTime) + return getTimestampIfAny() .orElse(null); } + @Programmatic + public Optional getTimestampIfAny() { + return commandRecord() + .map(CommandRecord::commandDto) + .map(CommandDto::getTimestamp) + .map(JavaTimeXMLGregorianCalendarMarshalling::toZonedDateTime); + } + @Property @PropertyLayout( sequence = "2.1", fieldSetId = "details", - describedAs = "Target Type of the original (replayabel) Command") + describedAs = "Target Type of the original (replayable) Command") public String getTargetType() { return commandRecord() .map(CommandRecord::commandDto) @@ -188,7 +205,7 @@ public String getTargetType() { @PropertyLayout( sequence = "2.2", fieldSetId = "details", - describedAs = "Target ID of the original (replayabel) Command") + describedAs = "Target ID of the original (replayable) Command") public String getTargetId() { return commandRecord() .map(CommandRecord::commandDto) @@ -202,7 +219,7 @@ public String getTargetId() { @PropertyLayout( sequence = "3.1", fieldSetId = "details", - describedAs = "Replayabel Action or Property, that was executed as captured by the original Command") + describedAs = "Replayable Action or Property, that was executed as captured by the original Command") public String getMember() { return commandRecord() .map(CommandRecord::commandDto) @@ -217,7 +234,7 @@ public String getMember() { @PropertyLayout( sequence = "4", fieldSetId = "details", - describedAs = "Replay State of the original (replayabel) Command. " + describedAs = "Replay State of the original (replayable) Command. " + "When imported initially is PENDING. " + "Then after replay its either OK or FAILED. " + "Can be manually set to EXCLUDED, which marks it to be ignored for replay.") @@ -233,7 +250,7 @@ public ReplayState getReplayState() { fieldSetId = "dto", hidden = Where.ALL_TABLES, labelPosition = LabelPosition.NONE, - describedAs = "DTO of the original (replayabel) Command") + describedAs = "DTO of the original (replayable) Command") public AsciiDoc getDto() { return commandRecord() .map(CommandRecord::commandDto) @@ -247,96 +264,59 @@ public AsciiDoc getDto() { // -- ACTIONS - @Action - @ActionLayout( - sequence = "0.2", - describedAs = "Opens the associated Command Log Entry") - public CommandLogEntry openCommandLogEntry() { - return commandLogEntry() - .orElse(null); - } - @Action - @ActionLayout( - sequence = "0.1", - cssClassFa = "solid circle-play", - cssClass = "btn-primary") - //hidden = Where.NOWHERE) // show in tables //TODO NPE bug - public ReplayableCommand replayOrRetry() { - tryReplayOrRetry(); - return this; - } - @MemberSupport public String disableReplayOrRetry() { - return commandRecord() - .map(CommandRecord::canReplayOrRetryOrMarkForExclusion) - .orElse(false) - ? null - : "Cannot replay, if neither PENDING nor FAILED"; - } - - @Action - @ActionLayout( - //hidden = Where.NOWHERE, // show in tables //TODO NPE bug - sequence = "2.1", - associateWith = "replayState", - describedAs = "Makes Command exportable (again)") - public ReplayableCommand makeExportable() { - if(disableMakeExportable()!=null) - return this; // safe guard when called programmatically + ReplayableCommand makeExportable() { + if(disableMakeExportable()!=null) { + return this; // safeguard when called programmatically + } commandLogEntry() - .filter(commandLogEntry->ReplayState.isExported(commandLogEntry.getReplayState())) - .ifPresent(commandLogEntry->{ - commandLogEntry.setReplayState(ReplayState.UNDEFINED); - invalidateCachedRecord(); - }); + .filter(commandLogEntry->ReplayState.isExported(commandLogEntry.getReplayState())) + .ifPresent(commandLogEntry->{ + commandLogEntry.setReplayState(ReplayState.UNDEFINED); + invalidateCachedRecord(); + }); return this; } - @MemberSupport public String disableMakeExportable() { + String disableMakeExportable() { return commandRecord() - .map(rec->ReplayState.isExported(rec.replayState())) - .orElse(false) + .map(rec->ReplayState.isExported(rec.replayState())) + .orElse(false) ? null : "Cannot make exportable, if not EXPORTED"; } - @Action - @ActionLayout( - //hidden = Where.NOWHERE, // show in tables //TODO NPE bug - sequence = "2.2", - associateWith = "replayState", - describedAs = "Marks Command to be EXCLUDED from replay.") - public ReplayableCommand excludeFromReplay() { - if(disableExcludeFromReplay()!=null) - return this; // safe guard when called programmatically + + ReplayableCommand excludeFromReplay() { + if(disableExcludeFromReplay()!=null) { + return ReplayableCommand.this; // safeguard when called programmatically + } commandLogEntry() - .filter(ReplayableCommand::canReplayOrRetryOrMarkForExclusion) - .ifPresent(commandLogEntry->{ - commandLogEntry.setReplayState(ReplayState.EXCLUDED); - invalidateCachedRecord(); - }); - return this; + .filter(ReplayableCommand::canReplayOrRetryOrMarkForExclusion) + .ifPresent(commandLogEntry->{ + commandLogEntry.setReplayState(ReplayState.EXCLUDED); + invalidateCachedRecord(); + }); + return ReplayableCommand.this; } - @MemberSupport private String disableExcludeFromReplay() { + String disableExcludeFromReplay() { return commandRecord() - .map(CommandRecord::canReplayOrRetryOrMarkForExclusion) - .orElse(false) + .map(CommandRecord::canReplayOrRetryOrMarkForExclusion) + .orElse(false) ? null : "Cannot mark for exclusion, if neither PENDING nor FAILED"; } - @Action - @ActionLayout( - sequence = "0.3", - //hidden = Where.NOWHERE, // show in tables //TODO NPE bug - describedAs = "Deletes the associated Command Log Entry (cannot be undone)") - public void delete() { + + @Programmatic + void deleteObj() { commandLogEntry() - .ifPresent(commandLogEntry->{ - replayContext.repositoryService().remove(commandLogEntry); - invalidateCachedRecord(); - }); + .ifPresent(commandLogEntry->{ + replayContext.repositoryService().remove(commandLogEntry); + invalidateCachedRecord(); + }); } + // -- EXECUTION ORDER GOVERNED BY TIMESTAMP private static final Comparator TIMESTAMP_COMPARATOR = @@ -357,12 +337,12 @@ public String viewModelMemento() { // -- UTIL Try tryReplayOrRetry() { - if(disableReplayOrRetry()!=null) + if(disableReplayOrRetry()!=null) { return Try.success(null); // guard against disallowed invocation + } return commandLogEntry() .filter(ReplayableCommand::canReplayOrRetryOrMarkForExclusion) .map(commandLogEntry->{ - commandLogEntry.setExecuteIn(ExecuteIn.FOREGROUND); var tryResultBookmark = replayContext.commandExecutorService().executeCommand( InteractionContextPolicy.SWITCH_USER_AND_TIME, commandLogEntry.getCommandDto()); @@ -379,6 +359,13 @@ Try tryReplayOrRetry() { }) .orElseGet(()->Try.success(null)); } + String disableReplayOrRetry() { + return commandRecord() + .map(CommandRecord::canReplayOrRetryOrMarkForExclusion) + .orElse(false) + ? null + : "Cannot replay, if neither PENDING nor FAILED"; + } // -- HELPER @@ -402,7 +389,7 @@ Optional commandLogEntry() { } private static boolean canReplayOrRetryOrMarkForExclusion(final CommandLogEntry commandLogEntry) { - return ReplayState.canReplayOrRetryOrMarkForExclusion(commandLogEntry.getReplayState()); + return ReplayState.isPendingOrFailed(commandLogEntry.getReplayState()); } private void onReplayError(final UUID interactionId, final Throwable ex) { @@ -411,5 +398,4 @@ private void onReplayError(final UUID interactionId, final Throwable ex) { replayContext.commandLogEntryRepository().findByInteractionId(interactionId) .ifPresent(entry->entry.saveAnalysis(ex.toString())))); } - -} +} \ No newline at end of file diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand.layout.fallback.xml b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand.layout.fallback.xml index 0b6c5fee4d5..331644bba1a 100644 --- a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand.layout.fallback.xml +++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand.layout.fallback.xml @@ -1,14 +1,20 @@ - + - + + + - + + + + + @@ -22,19 +28,29 @@ - + + + + - - - - - + + + + + + + + + - + + + + diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand_delete.java b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand_delete.java new file mode 100644 index 00000000000..6289ab9647e --- /dev/null +++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand_delete.java @@ -0,0 +1,49 @@ +/* + * 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.causeway.extensions.commandlog.applib.dom.replay; + +import lombok.RequiredArgsConstructor; + +import org.apache.causeway.applib.annotation.*; + +@Action( + restrictTo = RestrictTo.PROTOTYPING, + semantics = SemanticsOf.NON_IDEMPOTENT, + commandPublishing = Publishing.DISABLED, + domainEvent = ReplayableCommand_delete.DomainEvent.class, + executionPublishing = Publishing.DISABLED +) +@ActionLayout( + sequence = "0.3", + //hidden = Where.NOWHERE, // show in tables //TODO NPE bug + describedAs = "Deletes the associated Command Log Entry (cannot be undone)" +) +@RequiredArgsConstructor +public class ReplayableCommand_delete { + + public static class DomainEvent extends ReplayableCommand.ActionDomainEvent { + } + + private final ReplayableCommand replayableCommand; + + @MemberSupport + public void act() { + replayableCommand.deleteObj(); + } +} diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand_excludeFromReplay.java b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand_excludeFromReplay.java new file mode 100644 index 00000000000..955c7ef4c2d --- /dev/null +++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand_excludeFromReplay.java @@ -0,0 +1,54 @@ +/* + * 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.causeway.extensions.commandlog.applib.dom.replay; + +import lombok.RequiredArgsConstructor; + +import org.apache.causeway.applib.annotation.*; + +@Action( + restrictTo = RestrictTo.PROTOTYPING, + commandPublishing = Publishing.DISABLED, + domainEvent = ReplayableCommand_excludeFromReplay.DomainEvent.class, + executionPublishing = Publishing.DISABLED +) +@ActionLayout( + //hidden = Where.NOWHERE, // show in tables //TODO NPE bug + sequence = "2.2", + associateWith = "replayState", + describedAs = "Marks Command to be EXCLUDED from replay." +) +@RequiredArgsConstructor +public class ReplayableCommand_excludeFromReplay { + + public static class DomainEvent extends ReplayableCommand.ActionDomainEvent { + } + + private final ReplayableCommand replayableCommand; + + @MemberSupport + public ReplayableCommand act() { + return replayableCommand.excludeFromReplay(); + } + + @MemberSupport + private String disableAct() { + return replayableCommand.disableExcludeFromReplay(); + } +} diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand_makeExportable.java b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand_makeExportable.java new file mode 100644 index 00000000000..591746fa0c9 --- /dev/null +++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand_makeExportable.java @@ -0,0 +1,55 @@ +/* + * 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.causeway.extensions.commandlog.applib.dom.replay; + +import lombok.RequiredArgsConstructor; + +import org.apache.causeway.applib.annotation.*; + +@Action( + restrictTo = RestrictTo.PROTOTYPING, + semantics = SemanticsOf.IDEMPOTENT, + commandPublishing = Publishing.DISABLED, + domainEvent = ReplayableCommand_makeExportable.DomainEvent.class, + executionPublishing = Publishing.DISABLED +) +@ActionLayout( + //hidden = Where.NOWHERE, // show in tables //TODO NPE bug + associateWith = "replayState", + sequence = "2.1", + describedAs = "Makes Command exportable (again)" +) +@RequiredArgsConstructor +public class ReplayableCommand_makeExportable { + + public static class DomainEvent extends ReplayableCommand.ActionDomainEvent { + } + + private final ReplayableCommand replayableCommand; + + @MemberSupport + public ReplayableCommand act() { + return replayableCommand.makeExportable(); + } + + @MemberSupport + public String disableAct() { + return replayableCommand.disableMakeExportable(); + } +} diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand_openCommandLogEntry.java b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand_openCommandLogEntry.java new file mode 100644 index 00000000000..600744eab9c --- /dev/null +++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand_openCommandLogEntry.java @@ -0,0 +1,54 @@ +/* + * 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.causeway.extensions.commandlog.applib.dom.replay; + +import lombok.RequiredArgsConstructor; + +import org.apache.causeway.applib.annotation.*; +import org.apache.causeway.extensions.commandlog.applib.dom.CommandLogEntry; + +@Action( + semantics = SemanticsOf.SAFE, + commandPublishing = Publishing.DISABLED, + domainEvent = ReplayableCommand_openCommandLogEntry.DomainEvent.class, + executionPublishing = Publishing.DISABLED +) +@ActionLayout( + sequence = "0.2", + describedAs = "Opens the underlying Command Log Entry" +) +@RequiredArgsConstructor +public class ReplayableCommand_openCommandLogEntry { + + public static class DomainEvent extends ReplayableCommand.ActionDomainEvent { + } + + private final ReplayableCommand replayableCommand; + + @MemberSupport + public CommandLogEntry act() { + return replayableCommand.commandLogEntry() + .orElse(null); + } + + @MemberSupport + public String disableAct() { + return replayableCommand.commandLogEntry().isEmpty() ? "No corresponding CommandLogEntry" : null; + } +} diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand_replayOrRetry.java b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand_replayOrRetry.java new file mode 100644 index 00000000000..e2847773951 --- /dev/null +++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand_replayOrRetry.java @@ -0,0 +1,57 @@ +/* + * 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.causeway.extensions.commandlog.applib.dom.replay; + +import lombok.RequiredArgsConstructor; + +import org.apache.causeway.applib.annotation.*; + +@Action( + restrictTo = RestrictTo.PROTOTYPING, + semantics = SemanticsOf.NON_IDEMPOTENT, + commandPublishing = Publishing.DISABLED, + domainEvent = ReplayableCommand_replayOrRetry.DomainEvent.class, + executionPublishing = Publishing.DISABLED +) +@ActionLayout( + sequence = "0.1", + cssClassFa = "solid circle-play", + cssClass = "btn-primary" + //hidden = Where.NOWHERE // show in tables //TODO NPE bug +) +@RequiredArgsConstructor +public class ReplayableCommand_replayOrRetry { + + public static class DomainEvent extends ReplayableCommand.ActionDomainEvent { + } + + private final ReplayableCommand replayableCommand; + + + @MemberSupport + public ReplayableCommand act() { + replayableCommand.tryReplayOrRetry(); + return replayableCommand; + } + + @MemberSupport + public String disableAct() { + return replayableCommand.disableReplayOrRetry(); + } +} diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/TimestampMarshallUtil.java b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/TimestampMarshallUtil.java new file mode 100644 index 00000000000..ba8eaab85a4 --- /dev/null +++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/TimestampMarshallUtil.java @@ -0,0 +1,48 @@ +/* + * 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.causeway.extensions.commandlog.applib.dom.replay; + +import lombok.experimental.UtilityClass; + +import java.sql.Timestamp; +import java.time.Instant; + +@UtilityClass +class TimestampMarshallUtil { + private static final java.time.format.DateTimeFormatter VM_MEMENTO_FORMATTER = + java.time.format.DateTimeFormatter + .ofPattern("uuuu-MM-dd'T'HH-mm-ss.SSSX") + .withZone(java.time.ZoneOffset.UTC); + + static String toString(Timestamp ts) { + // Human-readable and URL-friendly (no ':' or '~'). + return VM_MEMENTO_FORMATTER.format(ts.toInstant()); // e.g. 2026-04-22T02-00-00.000Z + } + + static Timestamp fromString(String s, Timestamp fallback) { + if (s == null || s.isBlank()) { + return fallback; + } + try { + return Timestamp.from(Instant.from(VM_MEMENTO_FORMATTER.parse(s))); + } catch (Exception e) { + return fallback; + } + } +} diff --git a/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/integtest/CommandLog_IntegTestAbstract.java b/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/integtest/CommandLog_IntegTestAbstract.java index f1644491016..449cef754ec 100644 --- a/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/integtest/CommandLog_IntegTestAbstract.java +++ b/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/integtest/CommandLog_IntegTestAbstract.java @@ -369,7 +369,7 @@ void test_all_the_repository_methods() { val to = from.plusDays(1); // when - List notYetReplayed = commandLogEntryRepository.findReplayPendingOrFailed(); + List notYetReplayed = commandLogEntryRepository.findForegroundSinceTimestampAndWithReplayPendingOrFailed(baseline); // then Assertions.assertThat(notYetReplayed).isEmpty(); @@ -382,7 +382,7 @@ void test_all_the_repository_methods() { commandTarget1User1.setReplayState(ReplayState.PENDING); // when - List notYetReplayed2 = commandLogEntryRepository.findReplayPendingOrFailed(); + List notYetReplayed2 = commandLogEntryRepository.findForegroundSinceTimestampAndWithReplayPendingOrFailed(baseline); // then Assertions.assertThat(notYetReplayed2).hasSize(1); diff --git a/extensions/core/commandlog/persistence-jdo/src/main/java/org/apache/causeway/extensions/commandlog/jdo/dom/CommandLogEntry.java b/extensions/core/commandlog/persistence-jdo/src/main/java/org/apache/causeway/extensions/commandlog/jdo/dom/CommandLogEntry.java index 520b5cb37d9..49513f3859d 100644 --- a/extensions/core/commandlog/persistence-jdo/src/main/java/org/apache/causeway/extensions/commandlog/jdo/dom/CommandLogEntry.java +++ b/extensions/core/commandlog/persistence-jdo/src/main/java/org/apache/causeway/extensions/commandlog/jdo/dom/CommandLogEntry.java @@ -206,10 +206,20 @@ + " RANGE 0,2"), // this should be RANGE 0,1 but results in DataNucleus submitting "FETCH NEXT ROW ONLY" // which SQL Server doesn't understand. However, as workaround, SQL Server *does* understand FETCH NEXT 2 ROWS ONLY @Query( - name = Nq.FIND_BY_REPLAY_STATE, + name = Nq.FIND_FOREGROUND_BY_TIMESTAMP_AFTER_AND_REPLAY_STATE, value = "SELECT " + " FROM " + CommandLogEntry.FQCN + " " - + " WHERE replayState == :replayState1 || replayState == :replayState2 " + + " WHERE executeIn == 'FOREGROUND' " + + " && timestamp >= :from " + + " && replayState == :replayState " + + " ORDER BY timestamp ASC"), + @Query( + name = Nq.FIND_FOREGROUND_BY_TIMESTAMP_AFTER_AND_REPLAY_STATES, + value = "SELECT " + + " FROM " + CommandLogEntry.FQCN + " " + + " WHERE executeIn == 'FOREGROUND' " + + " && timestamp >= :from " + + " && (replayState == :replayState1 || replayState == :replayState2) " + " ORDER BY timestamp ASC"), }) @Named(CommandLogEntry.LOGICAL_TYPE_NAME) diff --git a/extensions/core/commandlog/persistence-jpa/src/main/java/org/apache/causeway/extensions/commandlog/jpa/dom/CommandLogEntry.java b/extensions/core/commandlog/persistence-jpa/src/main/java/org/apache/causeway/extensions/commandlog/jpa/dom/CommandLogEntry.java index ffc6b5ade4a..8cc67652432 100644 --- a/extensions/core/commandlog/persistence-jpa/src/main/java/org/apache/causeway/extensions/commandlog/jpa/dom/CommandLogEntry.java +++ b/extensions/core/commandlog/persistence-jpa/src/main/java/org/apache/causeway/extensions/commandlog/jpa/dom/CommandLogEntry.java @@ -209,10 +209,20 @@ + " AND cl.completedAt is not null " + " ORDER BY cl.timestamp DESC"), // programmatic LIMIT 1 @NamedQuery( - name = Nq.FIND_BY_REPLAY_STATE, + name = Nq.FIND_FOREGROUND_BY_TIMESTAMP_AFTER_AND_REPLAY_STATE, query = "SELECT cl " + " FROM CommandLogEntry cl " - + " WHERE (cl.replayState = :replayState1 OR cl.replayState = :replayState2) " + + " WHERE cl.executeIn = org.apache.causeway.extensions.commandlog.applib.dom.ExecuteIn.FOREGROUND " + + " AND cl.timestamp >= :from " + + " AND cl.replayState = :replayState " + + " ORDER BY cl.timestamp ASC"), + @NamedQuery( + name = Nq.FIND_FOREGROUND_BY_TIMESTAMP_AFTER_AND_REPLAY_STATES, + query = "SELECT cl " + + " FROM CommandLogEntry cl " + + " WHERE cl.executeIn = org.apache.causeway.extensions.commandlog.applib.dom.ExecuteIn.FOREGROUND " + + " AND cl.timestamp >= :from " + + " AND (cl.replayState = :replayState1 OR cl.replayState = :replayState2) " + " ORDER BY cl.timestamp ASC"), }) @Named(CommandLogEntry.LOGICAL_TYPE_NAME) From feb7dfc7f5d606079903da611df2f2f62cb051ba Mon Sep 17 00:00:00 2001 From: Dan Haywood Date: Fri, 24 Apr 2026 10:42:46 +0100 Subject: [PATCH 7/8] CAUSEWAY-3989 : fixes compile issue --- .../applib/integtest/CommandLog_IntegTestAbstract.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/integtest/CommandLog_IntegTestAbstract.java b/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/integtest/CommandLog_IntegTestAbstract.java index 449cef754ec..727d5042d98 100644 --- a/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/integtest/CommandLog_IntegTestAbstract.java +++ b/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/integtest/CommandLog_IntegTestAbstract.java @@ -18,6 +18,7 @@ */ package org.apache.causeway.extensions.commandlog.applib.integtest; +import java.sql.Timestamp; import java.util.List; import java.util.Optional; import java.util.UUID; @@ -367,9 +368,10 @@ void test_all_the_repository_methods() { val username1 = commandTarget1User1.getUsername(); val from = commandTarget1User1.getStartedAt().toLocalDateTime().toLocalDate(); val to = from.plusDays(1); + final var timestamp = commandTarget1User1.getTimestamp(); // when - List notYetReplayed = commandLogEntryRepository.findForegroundSinceTimestampAndWithReplayPendingOrFailed(baseline); + List notYetReplayed = commandLogEntryRepository.findForegroundSinceTimestampAndWithReplayPendingOrFailed(timestamp); // then Assertions.assertThat(notYetReplayed).isEmpty(); @@ -382,7 +384,7 @@ void test_all_the_repository_methods() { commandTarget1User1.setReplayState(ReplayState.PENDING); // when - List notYetReplayed2 = commandLogEntryRepository.findForegroundSinceTimestampAndWithReplayPendingOrFailed(baseline); + List notYetReplayed2 = commandLogEntryRepository.findForegroundSinceTimestampAndWithReplayPendingOrFailed(timestamp); // then Assertions.assertThat(notYetReplayed2).hasSize(1); From 187f9ca14e62be08b7355328377e0187dde32bc1 Mon Sep 17 00:00:00 2001 From: Dan Haywood Date: Wed, 22 Apr 2026 11:28:32 +0100 Subject: [PATCH 8/8] CAUSEWAY-3989: [v2] adds test to demo bug CAUSEWAY-3989: [v2] fixes localdate marshalling CAUSEWAY-3989: [v2] adds approval test to check marshalling of all date/time datatypes CAUSEWAY-3989: [v2] fixes marshalling of all LocalDateTime and LocalTime datatypes CAUSEWAY-3989: [v2] adds unit test for fromYaml for all datatypes CAUSEWAY-3989: [v2] minor refactoring CAUSEWAY-3989: [v2] fixes compile issue CAUSEWAY-3989: [v2] fixes existing unit tests CAUSEWAY-3989: [v2] improves unit test --- api/applib/pom.xml | 12 ++ .../applib/util/schema/CommandDtoUtils.java | 183 +++++++++++++++++- ...ommandDtoUtils_fromYaml_Approval_Test.java | 105 ++++++++++ ...l_Test.commands-with-collection-param.yaml | 6 +- ...Yaml_Test.commands-with-scalar-params.yaml | 2 +- .../schema/CommandDtoUtils_fromYaml_Test.java | 16 ++ .../CommandDtoUtils_toYaml_Approval_Test.java | 165 ++++++++++++++++ ...shals_all_date_time_datatypes.approved.txt | 31 +++ .../CommandDtoUtils_toYaml_fromYaml_Test.java | 132 +++++++++++++ .../CommandLog_IntegTestAbstract.java | 3 + 10 files changed, 650 insertions(+), 5 deletions(-) create mode 100644 api/applib/src/test/java/org/apache/causeway/applib/util/schema/CommandDtoUtils_fromYaml_Approval_Test.java create mode 100644 api/applib/src/test/java/org/apache/causeway/applib/util/schema/CommandDtoUtils_toYaml_Approval_Test.java create mode 100644 api/applib/src/test/java/org/apache/causeway/applib/util/schema/CommandDtoUtils_toYaml_Approval_Test.marshals_all_date_time_datatypes.approved.txt create mode 100644 api/applib/src/test/java/org/apache/causeway/applib/util/schema/CommandDtoUtils_toYaml_fromYaml_Test.java diff --git a/api/applib/pom.xml b/api/applib/pom.xml index fad7c62b386..483863a6218 100644 --- a/api/applib/pom.xml +++ b/api/applib/pom.xml @@ -107,6 +107,18 @@ test
+ + com.approvaltests + approvaltests + test + + + + com.google.code.gson + gson + test + + diff --git a/api/applib/src/main/java/org/apache/causeway/applib/util/schema/CommandDtoUtils.java b/api/applib/src/main/java/org/apache/causeway/applib/util/schema/CommandDtoUtils.java index 8fec5928b9d..997a100625a 100644 --- a/api/applib/src/main/java/org/apache/causeway/applib/util/schema/CommandDtoUtils.java +++ b/api/applib/src/main/java/org/apache/causeway/applib/util/schema/CommandDtoUtils.java @@ -18,10 +18,15 @@ */ package org.apache.causeway.applib.util.schema; +import java.io.IOException; import java.util.Collections; import java.util.List; import java.util.stream.Collectors; +import javax.xml.datatype.DatatypeConstants; +import javax.xml.datatype.DatatypeFactory; +import javax.xml.datatype.XMLGregorianCalendar; + import org.apache.causeway.applib.services.bookmark.Bookmark; import org.apache.causeway.commons.internal.base._Lazy; import org.apache.causeway.commons.internal.base._NullSafe; @@ -44,7 +49,15 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonTypeInfo; +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.JsonSerializer; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; import com.fasterxml.jackson.databind.jsontype.NamedType; import lombok.experimental.UtilityClass; @@ -178,12 +191,180 @@ private abstract class AbstractDtoMixIn {} // Mix-in to ignore unknown properties for ValueDto @JsonIgnoreProperties(ignoreUnknown = true) - private abstract class AbstractValueDtoMixIn {} + private abstract class AbstractValueDtoMixIn { + @JsonSerialize(using = LocalDateXmlGregorianCalendarSerializer.class) + abstract XMLGregorianCalendar getLocalDate(); + + @JsonDeserialize(using = LocalDateXmlGregorianCalendarDeserializer.class) + abstract void setLocalDate(XMLGregorianCalendar localDate); + + @JsonSerialize(using = LocalDateTimeXmlGregorianCalendarSerializer.class) + abstract XMLGregorianCalendar getLocalDateTime(); + + @JsonDeserialize(using = LocalDateTimeXmlGregorianCalendarDeserializer.class) + abstract void setLocalDateTime(XMLGregorianCalendar localDateTime); + + @JsonSerialize(using = LocalTimeXmlGregorianCalendarSerializer.class) + abstract XMLGregorianCalendar getLocalTime(); + + @JsonDeserialize(using = LocalTimeXmlGregorianCalendarDeserializer.class) + abstract void setLocalTime(XMLGregorianCalendar localTime); + } private void valueDtoSupport(final ObjectMapper mb) { mb.addMixIn(ValueDto.class, AbstractValueDtoMixIn.class); } + private static final DatatypeFactory DATATYPE_FACTORY = datatypeFactory(); + + private static DatatypeFactory datatypeFactory() { + try { + return DatatypeFactory.newInstance(); + } catch (Exception ex) { + throw new RuntimeException("Failed to initialize DatatypeFactory", ex); + } + } + + private static final class LocalDateXmlGregorianCalendarSerializer + extends JsonSerializer { + + @Override + public void serialize( + final XMLGregorianCalendar value, + final JsonGenerator gen, + final SerializerProvider serializers) throws IOException { + if (value == null) { + gen.writeNull(); + return; + } + final XMLGregorianCalendar dateOnly = DATATYPE_FACTORY.newXMLGregorianCalendarDate( + value.getYear(), + value.getMonth(), + value.getDay(), + DatatypeConstants.FIELD_UNDEFINED); + gen.writeString(dateOnly.toXMLFormat()); + } + } + + private static final class LocalDateXmlGregorianCalendarDeserializer + extends JsonDeserializer { + + @Override + public XMLGregorianCalendar deserialize( + final JsonParser p, + final DeserializationContext ctxt) throws IOException { + final String text = p.getValueAsString(); + if (_Strings.isNullOrEmpty(text)) { + return null; + } + final XMLGregorianCalendar parsed = DATATYPE_FACTORY.newXMLGregorianCalendar(text); + return DATATYPE_FACTORY.newXMLGregorianCalendarDate( + parsed.getYear(), + parsed.getMonth(), + parsed.getDay(), + DatatypeConstants.FIELD_UNDEFINED); + } + } + + private static final class LocalDateTimeXmlGregorianCalendarSerializer + extends JsonSerializer { + + @Override + public void serialize( + final XMLGregorianCalendar value, + final JsonGenerator gen, + final SerializerProvider serializers) throws IOException { + if (value == null) { + gen.writeNull(); + return; + } + final XMLGregorianCalendar localDateTime = DATATYPE_FACTORY.newXMLGregorianCalendar( + value.getYear(), + value.getMonth(), + value.getDay(), + value.getHour(), + value.getMinute(), + value.getSecond(), + millisecondsOf(value), + DatatypeConstants.FIELD_UNDEFINED); + gen.writeString(localDateTime.toXMLFormat()); + } + } + + private static final class LocalDateTimeXmlGregorianCalendarDeserializer + extends JsonDeserializer { + + @Override + public XMLGregorianCalendar deserialize( + final JsonParser p, + final DeserializationContext ctxt) throws IOException { + final String text = p.getValueAsString(); + if (_Strings.isNullOrEmpty(text)) { + return null; + } + final XMLGregorianCalendar parsed = DATATYPE_FACTORY.newXMLGregorianCalendar(text); + return DATATYPE_FACTORY.newXMLGregorianCalendar( + parsed.getYear(), + parsed.getMonth(), + parsed.getDay(), + parsed.getHour(), + parsed.getMinute(), + parsed.getSecond(), + millisecondsOf(parsed), + DatatypeConstants.FIELD_UNDEFINED); + } + } + + private static final class LocalTimeXmlGregorianCalendarSerializer + extends JsonSerializer { + + @Override + public void serialize( + final XMLGregorianCalendar value, + final JsonGenerator gen, + final SerializerProvider serializers) throws IOException { + if (value == null) { + gen.writeNull(); + return; + } + final XMLGregorianCalendar localTime = DATATYPE_FACTORY.newXMLGregorianCalendarTime( + value.getHour(), + value.getMinute(), + value.getSecond(), + millisecondsOf(value), + DatatypeConstants.FIELD_UNDEFINED); + gen.writeString(localTime.toXMLFormat()); + } + } + + private static final class LocalTimeXmlGregorianCalendarDeserializer + extends JsonDeserializer { + + @Override + public XMLGregorianCalendar deserialize( + final JsonParser p, + final DeserializationContext ctxt) throws IOException { + final String text = p.getValueAsString(); + if (_Strings.isNullOrEmpty(text)) { + return null; + } + final XMLGregorianCalendar parsed = DATATYPE_FACTORY.newXMLGregorianCalendar(text); + return DATATYPE_FACTORY.newXMLGregorianCalendarTime( + parsed.getHour(), + parsed.getMinute(), + parsed.getSecond(), + millisecondsOf(parsed), + DatatypeConstants.FIELD_UNDEFINED); + } + } + + private static int millisecondsOf(final XMLGregorianCalendar value) { + final int millis = value.getMillisecond(); + return millis == DatatypeConstants.FIELD_UNDEFINED + ? DatatypeConstants.FIELD_UNDEFINED + : millis; + } + private void memberDtoSupport(final ObjectMapper mb) { // add mix-in so MemberDto carries @JsonTypeInfo without modifying source mb.addMixIn(MemberDto.class, AbstractDtoMixIn.class); diff --git a/api/applib/src/test/java/org/apache/causeway/applib/util/schema/CommandDtoUtils_fromYaml_Approval_Test.java b/api/applib/src/test/java/org/apache/causeway/applib/util/schema/CommandDtoUtils_fromYaml_Approval_Test.java new file mode 100644 index 00000000000..f3f8bded4c8 --- /dev/null +++ b/api/applib/src/test/java/org/apache/causeway/applib/util/schema/CommandDtoUtils_fromYaml_Approval_Test.java @@ -0,0 +1,105 @@ +/* + * 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.causeway.applib.util.schema; + +import java.io.IOException; +import java.io.InputStream; +import java.util.List; + +import javax.xml.datatype.DatatypeConstants; + +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.Test; + +import org.springframework.util.StreamUtils; + +import org.apache.causeway.commons.io.DataSource; +import org.apache.causeway.schema.cmd.v2.ActionDto; +import org.apache.causeway.schema.cmd.v2.CommandDto; +import org.apache.causeway.schema.cmd.v2.ParamDto; +import org.apache.causeway.schema.common.v2.ValueType; + +class CommandDtoUtils_fromYaml_Approval_Test { + + @Test + void unmarshals_all_date_time_datatypes_from_approved_toYaml_snapshot() throws IOException { + String yaml = readApprovalSnapshot(); + + List commands = CommandDtoUtils.fromYaml(DataSource.ofStringUtf8(yaml)); + + Assertions.assertThat(commands).singleElement().satisfies(command -> { + Assertions.assertThat(command.getInteractionId()).isEqualTo("approval-datetime-marshalling"); + + ActionDto action = (ActionDto) command.getMember(); + Assertions.assertThat(action.getLogicalMemberIdentifier()) + .isEqualTo("demo.Customer#allDateTimeTypes"); + + List params = action.getParameters().getParameter(); + Assertions.assertThat(params).hasSize(6); + + ParamDto localDate = params.get(0); + Assertions.assertThat(localDate.getType()).isEqualTo(ValueType.LOCAL_DATE); + Assertions.assertThat(localDate.getLocalDate().toXMLFormat()).isEqualTo("2026-07-01"); + + ParamDto localDateTime = params.get(1); + Assertions.assertThat(localDateTime.getType()).isEqualTo(ValueType.LOCAL_DATE_TIME); + Assertions.assertThat(localDateTime.getLocalDateTime().toXMLFormat()).isEqualTo("2026-07-01T10:15:30"); + + ParamDto localTime = params.get(2); + Assertions.assertThat(localTime.getType()).isEqualTo(ValueType.LOCAL_TIME); + Assertions.assertThat(localTime.getLocalTime().toXMLFormat()).isEqualTo("10:15:30"); + + ParamDto offsetDateTime = params.get(3); + Assertions.assertThat(offsetDateTime.getType()).isEqualTo(ValueType.OFFSET_DATE_TIME); + Assertions.assertThat(offsetDateTime.getOffsetDateTime().getYear()).isEqualTo(2026); + Assertions.assertThat(offsetDateTime.getOffsetDateTime().getMonth()).isEqualTo(7); + Assertions.assertThat(offsetDateTime.getOffsetDateTime().getDay()).isEqualTo(1); + Assertions.assertThat(offsetDateTime.getOffsetDateTime().getHour()).isEqualTo(8); + Assertions.assertThat(offsetDateTime.getOffsetDateTime().getMinute()).isEqualTo(15); + Assertions.assertThat(offsetDateTime.getOffsetDateTime().getSecond()).isEqualTo(30); + Assertions.assertThat(offsetDateTime.getOffsetDateTime().getTimezone()).isEqualTo(0); + + ParamDto offsetTime = params.get(4); + Assertions.assertThat(offsetTime.getType()).isEqualTo(ValueType.OFFSET_TIME); + Assertions.assertThat(offsetTime.getOffsetTime()).isNotNull(); + Assertions.assertThat(offsetTime.getOffsetTime().getHour()).isEqualTo(8); + Assertions.assertThat(offsetTime.getOffsetTime().getMinute()).isEqualTo(15); + Assertions.assertThat(offsetTime.getOffsetTime().getSecond()).isEqualTo(30); + Assertions.assertThat(offsetTime.getOffsetTime().getTimezone()).isEqualTo(0); + + ParamDto zonedDateTime = params.get(5); + Assertions.assertThat(zonedDateTime.getType()).isEqualTo(ValueType.ZONED_DATE_TIME); + Assertions.assertThat(zonedDateTime.getZonedDateTime().getYear()).isEqualTo(2026); + Assertions.assertThat(zonedDateTime.getZonedDateTime().getMonth()).isEqualTo(7); + Assertions.assertThat(zonedDateTime.getZonedDateTime().getDay()).isEqualTo(1); + Assertions.assertThat(zonedDateTime.getZonedDateTime().getHour()).isEqualTo(8); + Assertions.assertThat(zonedDateTime.getZonedDateTime().getMinute()).isEqualTo(15); + Assertions.assertThat(zonedDateTime.getZonedDateTime().getSecond()).isEqualTo(30); + Assertions.assertThat(zonedDateTime.getZonedDateTime().getTimezone()) + .isEqualTo(0); + }); + } + + private String readApprovalSnapshot() throws IOException { + String path = CommandDtoUtils_toYaml_Approval_Test.class.getSimpleName() + ".marshals_all_date_time_datatypes.approved.txt"; + InputStream stream = CommandDtoUtils_toYaml_Approval_Test.class.getResourceAsStream(path); + return StreamUtils.copyToString(stream, java.nio.charset.StandardCharsets.UTF_8); + } +} + diff --git a/api/applib/src/test/java/org/apache/causeway/applib/util/schema/CommandDtoUtils_fromYaml_Test.commands-with-collection-param.yaml b/api/applib/src/test/java/org/apache/causeway/applib/util/schema/CommandDtoUtils_fromYaml_Test.commands-with-collection-param.yaml index f2b7b6b3b5f..4175c718be9 100644 --- a/api/applib/src/test/java/org/apache/causeway/applib/util/schema/CommandDtoUtils_fromYaml_Test.commands-with-collection-param.yaml +++ b/api/applib/src/test/java/org/apache/causeway/applib/util/schema/CommandDtoUtils_fromYaml_Test.commands-with-collection-param.yaml @@ -73,13 +73,13 @@ type: "collection" "null": false name: "Lease Item Types" - - localDate: "2026-06-30T22:00:00.000+00:00" + - localDate: "2026-06-30" type: "localDate" name: "Invoice Due Date" - - localDate: "2026-06-30T22:00:00.000+00:00" + - localDate: "2026-06-30" type: "localDate" name: "Start Due Date" - - localDate: "2026-07-01T22:00:00.000+00:00" + - localDate: "2026-07-01" type: "localDate" name: "Next Due Date" - type: "string" diff --git a/api/applib/src/test/java/org/apache/causeway/applib/util/schema/CommandDtoUtils_fromYaml_Test.commands-with-scalar-params.yaml b/api/applib/src/test/java/org/apache/causeway/applib/util/schema/CommandDtoUtils_fromYaml_Test.commands-with-scalar-params.yaml index d88b314b5ec..81222d72ad7 100644 --- a/api/applib/src/test/java/org/apache/causeway/applib/util/schema/CommandDtoUtils_fromYaml_Test.commands-with-scalar-params.yaml +++ b/api/applib/src/test/java/org/apache/causeway/applib/util/schema/CommandDtoUtils_fromYaml_Test.commands-with-scalar-params.yaml @@ -22,7 +22,7 @@ member: ! parameters: parameter: - - localDate: "2026-04-20T22:00:00.000+00:00" + - localDate: "2026-04-20" type: "localDate" name: "Invoice Date" - boolean: false diff --git a/api/applib/src/test/java/org/apache/causeway/applib/util/schema/CommandDtoUtils_fromYaml_Test.java b/api/applib/src/test/java/org/apache/causeway/applib/util/schema/CommandDtoUtils_fromYaml_Test.java index 2608b975d59..bf46b304166 100644 --- a/api/applib/src/test/java/org/apache/causeway/applib/util/schema/CommandDtoUtils_fromYaml_Test.java +++ b/api/applib/src/test/java/org/apache/causeway/applib/util/schema/CommandDtoUtils_fromYaml_Test.java @@ -68,6 +68,7 @@ public void scalarValues() throws IOException { Assertions.assertThat(invoiceDate.getName()).isEqualTo("Invoice Date"); Assertions.assertThat(invoiceDate.getType()).isEqualTo(ValueType.LOCAL_DATE); Assertions.assertThat(invoiceDate.getLocalDate()).isNotNull(); + Assertions.assertThat(invoiceDate.getLocalDate().toXMLFormat()).isEqualTo("2026-04-20"); ParamDto allowFuture = scalarParams.get(1); Assertions.assertThat(allowFuture.getName()).isEqualTo("Allow Invoice Date In Future"); @@ -113,6 +114,21 @@ public void collectionValues() throws IOException { Assertions.assertThat(nullableTagName.getType()).isEqualTo(ValueType.STRING); Assertions.assertThat(nullableTagName.isNull()).isTrue(); + ParamDto invoiceDueDate = params.get(2); + Assertions.assertThat(invoiceDueDate.getName()).isEqualTo("Invoice Due Date"); + Assertions.assertThat(invoiceDueDate.getType()).isEqualTo(ValueType.LOCAL_DATE); + Assertions.assertThat(invoiceDueDate.getLocalDate().toXMLFormat()).isEqualTo("2026-06-30"); + + ParamDto startDueDate = params.get(3); + Assertions.assertThat(startDueDate.getName()).isEqualTo("Start Due Date"); + Assertions.assertThat(startDueDate.getType()).isEqualTo(ValueType.LOCAL_DATE); + Assertions.assertThat(startDueDate.getLocalDate().toXMLFormat()).isEqualTo("2026-06-30"); + + ParamDto nextDueDate = params.get(4); + Assertions.assertThat(nextDueDate.getName()).isEqualTo("Next Due Date"); + Assertions.assertThat(nextDueDate.getType()).isEqualTo(ValueType.LOCAL_DATE); + Assertions.assertThat(nextDueDate.getLocalDate().toXMLFormat()).isEqualTo("2026-07-01"); + ParamDto newTagName = params.get(6); Assertions.assertThat(newTagName.getName()).isEqualTo("New Tag Name"); Assertions.assertThat(newTagName.getType()).isEqualTo(ValueType.STRING); diff --git a/api/applib/src/test/java/org/apache/causeway/applib/util/schema/CommandDtoUtils_toYaml_Approval_Test.java b/api/applib/src/test/java/org/apache/causeway/applib/util/schema/CommandDtoUtils_toYaml_Approval_Test.java new file mode 100644 index 00000000000..72e35d59914 --- /dev/null +++ b/api/applib/src/test/java/org/apache/causeway/applib/util/schema/CommandDtoUtils_toYaml_Approval_Test.java @@ -0,0 +1,165 @@ +/* + * 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.causeway.applib.util.schema; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.TimeZone; + +import javax.xml.datatype.DatatypeFactory; +import javax.xml.datatype.XMLGregorianCalendar; + +import org.approvaltests.Approvals; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.Test; + +import org.apache.causeway.schema.cmd.v2.ActionDto; +import org.apache.causeway.schema.cmd.v2.CommandDto; +import org.apache.causeway.schema.cmd.v2.ParamDto; +import org.apache.causeway.schema.cmd.v2.ParamsDto; +import org.apache.causeway.schema.common.v2.InteractionType; +import org.apache.causeway.schema.common.v2.OidDto; +import org.apache.causeway.schema.common.v2.OidsDto; +import org.apache.causeway.schema.common.v2.ValueType; +import org.springframework.util.StreamUtils; + +class CommandDtoUtils_toYaml_Approval_Test { + + private static final DatatypeFactory DATATYPE_FACTORY = datatypeFactory(); + + @Test + void marshals_all_date_time_datatypes() { + withDefaultTimeZone("UTC", () -> { + String yaml = CommandDtoUtils.toYaml(List.of(commandWithAllDateTimeParams())); + Approvals.verify(yaml); + }); + } + + @Test + void marshals_all_date_time_datatypes_when_default_timezone_is_cest() { + withDefaultTimeZone("Europe/Paris", () -> { + String yaml = CommandDtoUtils.toYaml(List.of(commandWithAllDateTimeParams())); + try { + Assertions.assertThat(yaml).isEqualTo(readApprovalSnapshot()); + } catch (IOException ex) { + throw new RuntimeException(ex); + } + }); + } + + private static CommandDto commandWithAllDateTimeParams() { + CommandDto command = new CommandDto(); + command.setMajorVersion("2"); + command.setMinorVersion("0"); + command.setInteractionId("approval-datetime-marshalling"); + command.setUsername("approval-user"); + command.setTargets(targets("demo.Customer", "123")); + command.setMember(actionWithAllDateTimeParams()); + return command; + } + + private static void withDefaultTimeZone(final String zoneId, final _Runnable runnable) { + TimeZone originalDefault = TimeZone.getDefault(); + TimeZone.setDefault(TimeZone.getTimeZone(zoneId)); + try { + runnable.run(); + } finally { + TimeZone.setDefault(originalDefault); + } + } + + @FunctionalInterface + private interface _Runnable { + void run(); + } + + private String readApprovalSnapshot() throws IOException { + String path = getClass().getSimpleName() + ".marshals_all_date_time_datatypes.approved.txt"; + InputStream stream = getClass().getResourceAsStream(path); + return StreamUtils.copyToString(stream, StandardCharsets.UTF_8); + } + + private static ActionDto actionWithAllDateTimeParams() { + ActionDto action = new ActionDto(); + action.setLogicalMemberIdentifier("demo.Customer#allDateTimeTypes"); + action.setInteractionType(InteractionType.ACTION_INVOCATION); + + ParamsDto params = new ParamsDto(); + params.getParameter().add(param("Local Date", ValueType.LOCAL_DATE, "2026-07-01")); + params.getParameter().add(param("Local Date Time", ValueType.LOCAL_DATE_TIME, "2026-07-01T10:15:30")); + params.getParameter().add(param("Local Time", ValueType.LOCAL_TIME, "10:15:30")); + params.getParameter().add(param("Offset Date Time", ValueType.OFFSET_DATE_TIME, "2026-07-01T10:15:30+02:00")); + params.getParameter().add(param("Offset Time", ValueType.OFFSET_TIME, "10:15:30+02:00")); + params.getParameter().add(param("Zoned Date Time", ValueType.ZONED_DATE_TIME, "2026-07-01T10:15:30+02:00")); + action.setParameters(params); + return action; + } + + private static ParamDto param(final String name, final ValueType type, final String lexicalValue) { + ParamDto param = new ParamDto(); + param.setName(name); + param.setType(type); + + XMLGregorianCalendar value = DATATYPE_FACTORY.newXMLGregorianCalendar(lexicalValue); + switch (type) { + case LOCAL_DATE: + param.setLocalDate(value); + break; + case LOCAL_DATE_TIME: + param.setLocalDateTime(value); + break; + case LOCAL_TIME: + param.setLocalTime(value); + break; + case OFFSET_DATE_TIME: + param.setOffsetDateTime(value); + break; + case OFFSET_TIME: + param.setOffsetTime(value); + break; + case ZONED_DATE_TIME: + param.setZonedDateTime(value); + break; + default: + throw new IllegalArgumentException("Unsupported type: " + type); + } + return param; + } + + private static OidsDto targets(final String type, final String id) { + OidDto oid = new OidDto(); + oid.setType(type); + oid.setId(id); + + OidsDto targets = new OidsDto(); + targets.getOid().add(oid); + return targets; + } + + private static DatatypeFactory datatypeFactory() { + try { + return DatatypeFactory.newInstance(); + } catch (Exception ex) { + throw new RuntimeException("Failed to initialize DatatypeFactory", ex); + } + } +} + diff --git a/api/applib/src/test/java/org/apache/causeway/applib/util/schema/CommandDtoUtils_toYaml_Approval_Test.marshals_all_date_time_datatypes.approved.txt b/api/applib/src/test/java/org/apache/causeway/applib/util/schema/CommandDtoUtils_toYaml_Approval_Test.marshals_all_date_time_datatypes.approved.txt new file mode 100644 index 00000000000..22481b6fa3c --- /dev/null +++ b/api/applib/src/test/java/org/apache/causeway/applib/util/schema/CommandDtoUtils_toYaml_Approval_Test.marshals_all_date_time_datatypes.approved.txt @@ -0,0 +1,31 @@ +- majorVersion: "2" + minorVersion: "0" + interactionId: "approval-datetime-marshalling" + username: "approval-user" + targets: + oid: + - type: "demo.Customer" + id: "123" + member: ! + parameters: + parameter: + - localDate: "2026-07-01" + type: "localDate" + name: "Local Date" + - localDateTime: "2026-07-01T10:15:30" + type: "localDateTime" + name: "Local Date Time" + - localTime: "10:15:30" + type: "localTime" + name: "Local Time" + - offsetDateTime: "2026-07-01T08:15:30.000+00:00" + type: "offsetDateTime" + name: "Offset Date Time" + - offsetTime: "1970-01-01T08:15:30.000+00:00" + type: "offsetTime" + name: "Offset Time" + - zonedDateTime: "2026-07-01T08:15:30.000+00:00" + type: "zonedDateTime" + name: "Zoned Date Time" + logicalMemberIdentifier: "demo.Customer#allDateTimeTypes" + interactionType: "action_invocation" diff --git a/api/applib/src/test/java/org/apache/causeway/applib/util/schema/CommandDtoUtils_toYaml_fromYaml_Test.java b/api/applib/src/test/java/org/apache/causeway/applib/util/schema/CommandDtoUtils_toYaml_fromYaml_Test.java new file mode 100644 index 00000000000..6661093eb53 --- /dev/null +++ b/api/applib/src/test/java/org/apache/causeway/applib/util/schema/CommandDtoUtils_toYaml_fromYaml_Test.java @@ -0,0 +1,132 @@ +/* + * 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.causeway.applib.util.schema; + +import java.util.List; +import java.util.TimeZone; + +import javax.xml.datatype.DatatypeConstants; +import javax.xml.datatype.DatatypeFactory; +import javax.xml.datatype.XMLGregorianCalendar; + +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.Test; + +import org.apache.causeway.commons.io.DataSource; +import org.apache.causeway.schema.cmd.v2.ActionDto; +import org.apache.causeway.schema.cmd.v2.CommandDto; +import org.apache.causeway.schema.cmd.v2.ParamDto; +import org.apache.causeway.schema.cmd.v2.ParamsDto; +import org.apache.causeway.schema.common.v2.InteractionType; +import org.apache.causeway.schema.common.v2.OidDto; +import org.apache.causeway.schema.common.v2.OidsDto; +import org.apache.causeway.schema.common.v2.ValueType; + +class CommandDtoUtils_toYaml_fromYaml_Test { + + @Test + void localDate_roundtrips_as_date_only_without_timezone() throws Exception { + TimeZone originalDefault = TimeZone.getDefault(); + TimeZone.setDefault(TimeZone.getTimeZone("Europe/Amsterdam")); + try { + DatatypeFactory datatypeFactory = DatatypeFactory.newInstance(); + XMLGregorianCalendar originalDate = datatypeFactory + .newXMLGregorianCalendarDate(2026, 7, 1, DatatypeConstants.FIELD_UNDEFINED); + XMLGregorianCalendar originalDateTime = datatypeFactory + .newXMLGregorianCalendar("2026-07-01T10:15:30"); + XMLGregorianCalendar originalTime = datatypeFactory + .newXMLGregorianCalendar("10:15:30"); + + CommandDto command = new CommandDto(); + command.setMajorVersion("2"); + command.setMinorVersion("0"); + command.setInteractionId("localdate-roundtrip-bug"); + command.setUsername("sven"); + + OidDto oid = new OidDto(); + oid.setType("demo.Customer"); + oid.setId("123"); + OidsDto targets = new OidsDto(); + targets.getOid().add(oid); + command.setTargets(targets); + + ParamDto localDateParam = new ParamDto(); + localDateParam.setName("Invoice Date"); + localDateParam.setType(ValueType.LOCAL_DATE); + localDateParam.setLocalDate(originalDate); + + ParamDto localDateTimeParam = new ParamDto(); + localDateTimeParam.setName("Invoice Date Time"); + localDateTimeParam.setType(ValueType.LOCAL_DATE_TIME); + localDateTimeParam.setLocalDateTime(originalDateTime); + + ParamDto localTimeParam = new ParamDto(); + localTimeParam.setName("Invoice Time"); + localTimeParam.setType(ValueType.LOCAL_TIME); + localTimeParam.setLocalTime(originalTime); + + ParamsDto params = new ParamsDto(); + params.getParameter().add(localDateParam); + params.getParameter().add(localDateTimeParam); + params.getParameter().add(localTimeParam); + + ActionDto action = new ActionDto(); + action.setLogicalMemberIdentifier("demo.Customer#invoice"); + action.setInteractionType(InteractionType.ACTION_INVOCATION); + action.setParameters(params); + command.setMember(action); + + String yaml = CommandDtoUtils.toYaml(List.of(command)); + List roundtripped = CommandDtoUtils.fromYaml(DataSource.ofStringUtf8(yaml)); + + XMLGregorianCalendar roundtrippedDate = ((ActionDto) roundtripped.get(0).getMember()) + .getParameters() + .getParameter() + .get(0) + .getLocalDate(); + XMLGregorianCalendar roundtrippedDateTime = ((ActionDto) roundtripped.get(0).getMember()) + .getParameters() + .getParameter() + .get(1) + .getLocalDateTime(); + XMLGregorianCalendar roundtrippedTime = ((ActionDto) roundtripped.get(0).getMember()) + .getParameters() + .getParameter() + .get(2) + .getLocalTime(); + + // Verify fixed behavior: local date/time values are emitted and roundtripped without timezone. + Assertions.assertThat(yaml) + .contains("localDate: \"2026-07-01\"") + .contains("localDateTime: \"2026-07-01T10:15:30\"") + .contains("localTime: \"10:15:30\"") + .doesNotContain("localDate: \"2026-06-30T22:00:00.000+00:00\"") + .doesNotContain("localDateTime: \"2026-07-01T10:15:30.000+00:00\"") + .doesNotContain("localTime: \"1970-01-01T10:15:30.000+00:00\""); + Assertions.assertThat(roundtrippedDate.toXMLFormat()) + .isEqualTo(originalDate.toXMLFormat()); + Assertions.assertThat(roundtrippedDateTime.toXMLFormat()) + .isEqualTo(originalDateTime.toXMLFormat()); + Assertions.assertThat(roundtrippedTime.toXMLFormat()) + .isEqualTo(originalTime.toXMLFormat()); + } finally { + TimeZone.setDefault(originalDefault); + } + } +} diff --git a/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/integtest/CommandLog_IntegTestAbstract.java b/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/integtest/CommandLog_IntegTestAbstract.java index 727d5042d98..c33dbb87666 100644 --- a/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/integtest/CommandLog_IntegTestAbstract.java +++ b/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/integtest/CommandLog_IntegTestAbstract.java @@ -277,6 +277,9 @@ void roundtrip_CLE_bookmarks() { void test_all_the_repository_methods() { // given + final var baseline = clockService.getClock().nowAsJavaSqlTimestamp(); + + sudoService.run(InteractionContext.switchUser(UserMemento.builder().name("user-1").build()), () -> { wrapperFactory.wrapMixin(Counter_bumpUsingMixin.class, counter1).act(); });