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/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..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,17 +18,47 @@ */ 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; 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 org.apache.causeway.schema.common.v2.ValueDto; + +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; @@ -117,4 +147,230 @@ 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); + CommandDtoUtils.valueDtoSupport(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); + CommandDtoUtils.valueDtoSupport(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 {} + + // Mix-in to ignore unknown properties for ValueDto + @JsonIgnoreProperties(ignoreUnknown = true) + 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); + // register concrete sub-types with logical names + 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_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 new file mode 100644 index 00000000000..4175c718be9 --- /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-30" + type: "localDate" + name: "Invoice Due Date" + - localDate: "2026-06-30" + type: "localDate" + name: "Start Due Date" + - localDate: "2026-07-01" + 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..81222d72ad7 --- /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-20" + 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..bf46b304166 --- /dev/null +++ b/api/applib/src/test/java/org/apache/causeway/applib/util/schema/CommandDtoUtils_fromYaml_Test.java @@ -0,0 +1,149 @@ +/* + * 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(); + Assertions.assertThat(invoiceDate.getLocalDate().toXMLFormat()).isEqualTo("2026-04-20"); + + 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 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); + 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/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/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..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,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.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; 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,36 @@ 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.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; +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, @@ -50,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, @@ -108,4 +142,14 @@ public static void honorSystemEnvironment() { } } + @Bean ReplayContext replayContext( + final RepositoryService repositoryService, + final InteractionService interactionService, + final CommandLogEntryRepository commandLogEntryRepository, + final CommandExecutorService commandExecutorService, + final ClockService clockService) { + return new ReplayContext(repositoryService, interactionService, + 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 6f2be45e9ac..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,21 +18,26 @@ */ 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; 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; 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; @@ -40,6 +45,13 @@ 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.jspecify.annotations.NonNull; + +import org.springframework.lang.Nullable; import lombok.RequiredArgsConstructor; @@ -68,7 +80,7 @@ public static abstract class ActionDomainEvent final CommandLogEntryRepository commandLogEntryRepository; final ClockService clockService; - + final ReplayContext replayContext; @Action( commandPublishing = Publishing.DISABLED, @@ -147,8 +159,57 @@ 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( + @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); + } + } + + @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( + @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() { 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..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 @@ -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; @@ -148,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"; } @@ -170,8 +174,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 +184,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 +201,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 +216,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..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,18 +18,21 @@ */ package org.apache.causeway.extensions.commandlog.applib.dom; +import java.sql.Timestamp; 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; @@ -119,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. * @@ -158,13 +177,21 @@ List findByTargetAndFromAndTo( */ Optional findMostRecentCompleted(); - List findNotYetReplayed(); - 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 +217,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..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 @@ -22,33 +22,25 @@ 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; import java.util.UUID; +import java.util.stream.Collectors; 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 +55,6 @@ public abstract class CommandLogEntryRepositoryAbstract commandLogEntryClass; - protected CommandLogEntryRepositoryAbstract(final Class commandLogEntryClass) { this.commandLogEntryClass = commandLogEntryClass; } @@ -72,10 +63,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 +141,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 +175,6 @@ public List findByTargetAndFromAndTo( return _Casts.uncheckedCast(repositoryService().allMatches(query)); } - @Override public List findMostRecent() { return findMostRecent(100); @@ -196,7 +188,6 @@ public List findMostRecent(final int limit) { ); } - @Override public List findRecentByUsername(final String username) { return _Casts.uncheckedCast( @@ -207,8 +198,7 @@ public List findRecentByUsername(final String username) { ); } - - + @Override public List findRecentByTarget(final Bookmark target) { return _Casts.uncheckedCast( repositoryService().allMatches( @@ -219,6 +209,7 @@ public List findRecentByTarget(final Bookmark target) { ); } + @Override public List findRecentByTargetOrResult(final Bookmark targetOrResult) { return _Casts.uncheckedCast( repositoryService().allMatches( @@ -229,7 +220,6 @@ public List findRecentByTargetOrResult(final Bookmark targetOrR ); } - /** * Intended to support the replay of commands on a secondary instance of * the application. @@ -298,6 +288,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 +299,6 @@ public List findRecentBackgroundByTarget(final Bookmark target) ); } - /** * The most recent replayed command previously replicated from primary to * secondary. @@ -346,56 +336,27 @@ public Optional findMostRecentCompleted() { } @Override - public List findNotYetReplayed() { - return _Casts.uncheckedCast( - repositoryService().allMatches( - Query.named(commandLogEntryClass, CommandLogEntry.Nq.FIND_BY_REPLAY_STATE) - .withParameter("replayState", ReplayState.PENDING) - .withLimit(10)) - ); - } - + public C saveForReplay(final CommandDto commandToReplay) { - public C saveForReplay(final CommandDto dto) { +//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))); +// } - 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()); + final C entity = factoryService.detachedEntity(commandLogEntryClass); + entity.init(commandToReplay, ReplayState.PENDING, 0); + entity.setParentInteractionId(null); // n/a for replay + entity.setExecuteIn(ExecuteIn.FOREGROUND); // only ever replay foreground commands. - commandJdo.setReplayState(ReplayState.PENDING); + persist(entity); - val firstTargetOidDto = dto.getTargets().getOid().get(0); - commandJdo.setTarget(Bookmark.forOidDto(firstTargetOidDto)); - commandJdo.setCommandDto(dto); - commandJdo.setLogicalMemberIdentifier(dto.getMember().getLogicalMemberIdentifier()); - - persist(commandJdo); - - return commandJdo; + return 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; - } - - @Override public void persist(final CommandLogEntry commandLogEntry) { repositoryService().persistAndFlush(commandLogEntry); @@ -408,7 +369,6 @@ public void truncateLog() { // -- - @Override public List findCommandsOnPrimaryElseFail( final @Nullable UUID interactionId, @@ -421,10 +381,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 +393,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,9 +407,45 @@ 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(); @@ -479,7 +473,6 @@ public List findAll() { return _Casts.uncheckedCast(repositoryService().allInstances(commandLogEntryClass)); } - /** * intended for testing purposes only */ @@ -491,5 +484,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..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 @@ -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,37 @@ 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 isExportable() { + return this == ReplayState.UNDEFINED; + } + + public boolean isPendingOrFailed() { + return this == ReplayState.PENDING + || this == ReplayState.FAILED; + } + + // -- NULL SAFE + + public static boolean isExportable(final @Nullable ReplayState replayState) { + return replayState == null || replayState.isExportable(); + } + + 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(); + } - 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#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 new file mode 100644 index 00000000000..f0243676053 --- /dev/null +++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandExportManager.java @@ -0,0 +1,319 @@ +/* + * 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.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; + +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.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; +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; + +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) +public final class CommandExportManager implements ViewModel { + + public static final String LOGICAL_TYPE_NAME = CausewayModuleExtCommandLogApplib.NAMESPACE + ".CommandExportManager"; + + public static abstract class ActionDomainEvent + extends CausewayModuleExtCommandLogApplib.ActionDomainEvent { } + + private ReplayContext replayContext; + + @Inject + public CommandExportManager( + final String memento, + final ReplayContext 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"; + } + + + @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( + 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" + ) + public List getNotYetExported() { + return commandLogEntryRepository().findForegroundSinceTimestampAndCanBeExported(since).stream() + .map(entry->new ReplayableCommand( + entry.getInteractionId(), + replayContext)) + .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 { } + + @MemberSupport public Blob act( + final List selected, + final String filenamePrefix, + final boolean filenameTimestamp + ) { + + 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())) // shouldn't be necessary unless a race condition + .map(CommandLogEntry::getCommandDto) + .collect(Collectors.toList())); + + 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; + } + + // 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 have been exported") + public List getExported() { + return commandLogEntryRepository().findForegroundSinceTimestampAndHasBeenExported(since).stream() + .map(entry->new ReplayableCommand( + entry.getInteractionId(), + replayContext)) + .collect(Collectors.toList()); + } + + + @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() { + 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 new file mode 100644 index 00000000000..ba0d2da0ef7 --- /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,60 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 new file mode 100644 index 00000000000..b326e4c4be2 --- /dev/null +++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandReplayManager.java @@ -0,0 +1,400 @@ +/* + * 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.sql.Timestamp; +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.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; +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.Getter; + +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) +public final class CommandReplayManager implements ViewModel { + + public static final String LOGICAL_TYPE_NAME = CausewayModuleExtCommandLogApplib.NAMESPACE + ".CommandReplayManager"; + + public static abstract class ActionDomainEvent + extends CausewayModuleExtCommandLogApplib.ActionDomainEvent { } + + private ReplayContext replayContext; + + @Inject + public CommandReplayManager( + final String memento, + final ReplayContext 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"; + } + + + @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 CommandReplayManager act() { + return new CommandReplayManager(addSeconds(since, -3600), replayContext); + } + } + + @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; + } + } + + private static Timestamp addSeconds(Timestamp since, int secondsToAdd) { + return Timestamp.from(since.toInstant().plusSeconds(secondsToAdd)); + } + + @Action( + restrictTo = RestrictTo.PROTOTYPING, + semantics = SemanticsOf.IDEMPOTENT, + commandPublishing = Publishing.DISABLED, + domainEvent = importCommands.DomainEvent.class, + executionPublishing = Publishing.DISABLED + ) + @ActionLayout( + 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)" + ) + public List getPendingOrFailed() { + return commandLogEntryRepository().findForegroundSinceTimestampAndWithReplayPendingOrFailed(since).stream() + .map(entry->new ReplayableCommand( + entry.getInteractionId(), + replayContext)) + .collect(Collectors.toList()); + } + + + @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(); + } + } + + + + @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( + 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)" + ) + public List getSucceededOrExcluded() { + return commandLogEntryRepository().findSinceAndWithReplayOkOrExcluded(since).stream() + .map(entry->new ReplayableCommand( + entry.getInteractionId(), + replayContext)) + .collect(Collectors.toList()); + } + + + @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 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() { + 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/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..c7aed354e9f --- /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,60 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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..580e2f05ae6 --- /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.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; +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 { + RepositoryService repositoryService; + 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-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 00000000000..9a1d6cd8e30 Binary files /dev/null and b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand-excluded.png differ 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 00000000000..60452341123 Binary files /dev/null and b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand-exported.png differ diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand-failed.png b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand-failed.png new file mode 100644 index 00000000000..24a95966c1b Binary files /dev/null and b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand-failed.png differ 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 00000000000..1578312869f Binary files /dev/null and b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand-ok.png differ 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 00000000000..e83e889a682 Binary files /dev/null and b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand-pending.png differ 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 new file mode 100644 index 00000000000..e2ea51718b0 --- /dev/null +++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand.java @@ -0,0 +1,401 @@ +/* + * 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.Instant; +import java.time.ZonedDateTime; +import java.time.chrono.ChronoZonedDateTime; +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.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; +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.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.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.Value; +import lombok.experimental.Accessors; + +/** + * Viewmodel that wraps a {@link CommandLogEntry}. + */ +@DomainObject(introspection = Introspection.ANNOTATION_REQUIRED) +@DomainObjectLayout//(cssClassFa = "terminal") +@Named(ReplayableCommand.LOGICAL_TYPE_NAME) +@AllArgsConstructor +public final class ReplayableCommand implements ViewModel, Comparable { + + public static abstract class ActionDomainEvent + extends CausewayModuleExtCommandLogApplib.ActionDomainEvent { } + + private final UUID interactionId; + @Programmatic + public UUID interactionId() { return interactionId; } + + private final ReplayContext replayContext; + @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.isPendingOrFailed(); + } + 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; + } + //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 + public ReplayableCommand( + final String memento, + final ReplayContext replayContext) { + this(UUID.fromString(memento), replayContext); + } + + ReplayableCommand( + final UUID interactionId, + final ReplayContext replayContext) { + this(interactionId, replayContext, new ObjectReference<>(null)); + } + + @ObjectSupport public String title() { + final var timestamp = getTimestampIfAny().map(ChronoZonedDateTime::toInstant).map(Instant::toString).map(x -> " @ " + x).orElse(""); + return getTargetType() + ":" + getTargetId() + " #" + getMember() + timestamp; + } + +//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); +// } + @ObjectSupport public String iconName() { + return commandRecord() + .map(CommandRecord::iconSuffix) + .orElse(null); + } + + @Property + @PropertyLayout( + sequence = "1.1", + fieldSetId = "details", + describedAs = "UUID of the original (replayable) Command") + public UUID getInteractionId() { + return interactionId; + } + + @Property + @PropertyLayout( + sequence = "1.2", + fieldSetId = "details", + describedAs = "Timestamp of the original (replayable) Command") + public ZonedDateTime getTimestamp() { + 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 (replayable) 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 (replayable) 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 = "Replayable 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 (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.") + 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 (replayable) 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 + + + 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(); + }); + return this; + } + String disableMakeExportable() { + return commandRecord() + .map(rec->ReplayState.isExported(rec.replayState())) + .orElse(false) + ? null + : "Cannot make exportable, if not EXPORTED"; + } + + + ReplayableCommand excludeFromReplay() { + if(disableExcludeFromReplay()!=null) { + return ReplayableCommand.this; // safeguard when called programmatically + } + commandLogEntry() + .filter(ReplayableCommand::canReplayOrRetryOrMarkForExclusion) + .ifPresent(commandLogEntry->{ + commandLogEntry.setReplayState(ReplayState.EXCLUDED); + invalidateCachedRecord(); + }); + return ReplayableCommand.this; + } + String disableExcludeFromReplay() { + return commandRecord() + .map(CommandRecord::canReplayOrRetryOrMarkForExclusion) + .orElse(false) + ? null + : "Cannot mark for exclusion, if neither PENDING nor FAILED"; + } + + + @Programmatic + void deleteObj() { + 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 interactionId.toString(); + } + + // -- UTIL + + Try tryReplayOrRetry() { + if(disableReplayOrRetry()!=null) { + return Try.success(null); // guard against disallowed invocation + } + return commandLogEntry() + .filter(ReplayableCommand::canReplayOrRetryOrMarkForExclusion) + .map(commandLogEntry->{ + 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)); + } + String disableReplayOrRetry() { + return commandRecord() + .map(CommandRecord::canReplayOrRetryOrMarkForExclusion) + .orElse(false) + ? null + : "Cannot replay, if neither PENDING nor FAILED"; + } + + // -- 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.commandLogEntryRepository().findByInteractionId(interactionId()); + } + + private static boolean canReplayOrRetryOrMarkForExclusion(final CommandLogEntry commandLogEntry) { + return ReplayState.isPendingOrFailed(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())))); + } +} \ 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 new file mode 100644 index 00000000000..331644bba1a --- /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,61 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 00000000000..ac153a4102e Binary files /dev/null and b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand.png differ 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 0b69e6cc622..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 @@ -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; @@ -276,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(); }); @@ -367,9 +371,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.findNotYetReplayed(); + List notYetReplayed = commandLogEntryRepository.findForegroundSinceTimestampAndWithReplayPendingOrFailed(timestamp); // then Assertions.assertThat(notYetReplayed).isEmpty(); @@ -382,7 +387,7 @@ void test_all_the_repository_methods() { commandTarget1User1.setReplayState(ReplayState.PENDING); // when - List notYetReplayed2 = commandLogEntryRepository.findNotYetReplayed(); + List notYetReplayed2 = commandLogEntryRepository.findForegroundSinceTimestampAndWithReplayPendingOrFailed(timestamp); // 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..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,12 +206,21 @@ + " 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 == :replayState " - + " ORDER BY timestamp ASC " - + " RANGE 0,10"), // same as batch size + + " 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) @DomainObject( @@ -237,7 +246,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..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 @@ -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, @@ -208,11 +209,21 @@ + " 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 = :replayState " - + " ORDER BY cl.timestamp ASC"), // programmatic LIMIT 10 + + " FROM CommandLogEntry cl " + + " 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) @DomainObject( @@ -235,14 +246,12 @@ 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); } - @EmbeddedId private CommandLogEntryPK pk; - @Transient @InteractionId @Override @@ -255,46 +264,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 +304,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 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();