- * This value is {@link Comparable}, the implementation of which considers
+ *
+ *
This value is {@link Comparable}, the implementation of which considers
* {@link #getSort() (feature) sort}, {@link #getNamespace() namespace},
* {@link #getTypeSimpleName() type simple name} and {@link #getLogicalMemberName() member-logical-name}.
- *
- * If the represented member is an action, then {@link #getLogicalMemberName() member-logical-name}
+ *
+ *
If the represented member is an action, then {@link #getLogicalMemberName() member-logical-name}
* must not include any parameter list or parentheses.
* Consequently method overloading is not supported.
- *
- * If there is a member name clash involving an action and an association,
+ *
+ *
If there is a member name clash involving an action and an association,
* then consequently any permissions defined automatically apply to both and one cannot separate
* these.
*
@@ -79,13 +79,16 @@ public class ApplicationFeatureId
// -- FACTORY METHODS
+ /**
+ * @apiNote the inverse conversion is available via
+ * {@link ApplicationFeatureRepository#asIdentifier(ApplicationFeatureId)}
+ */
public static ApplicationFeatureId fromIdentifier(final @NonNull Identifier identifier) {
var logicalTypeName = identifier.logicalTypeName();
- if(identifier.type().isClass()) {
+ if(identifier.type().isClass())
return newType(logicalTypeName);
- }
return newMember(logicalTypeName, identifier.memberLogicalName());
}
@@ -93,15 +96,11 @@ public static ApplicationFeatureId newFeature(
final @NonNull ApplicationFeatureSort featureSort,
final @NonNull String qualifiedLogicalName) {
- switch (featureSort) {
- case NAMESPACE:
- return newNamespace(qualifiedLogicalName);
- case TYPE:
- return newType(qualifiedLogicalName);
- case MEMBER:
- return newMember(qualifiedLogicalName);
- }
- throw _Exceptions.illegalArgument("Unknown feature sort '%s'", featureSort);
+ return switch (featureSort) {
+ case NAMESPACE -> newNamespace(qualifiedLogicalName);
+ case TYPE -> newType(qualifiedLogicalName);
+ case MEMBER -> newMember(qualifiedLogicalName);
+ };
}
public static ApplicationFeatureId newFeature(
@@ -109,13 +108,11 @@ public static ApplicationFeatureId newFeature(
final @Nullable String logicalTypeSimpleName,
final @Nullable String memberName) {
- if(logicalTypeSimpleName == null) {
+ if(logicalTypeSimpleName == null)
return newNamespace(namespace);
- }
var logicalTypeName = namespace + "." + logicalTypeSimpleName;
- if(memberName == null) {
+ if(memberName == null)
return newType(logicalTypeName);
- }
return newMember(logicalTypeName, memberName);
}
@@ -161,9 +158,8 @@ public static ApplicationFeatureId newMember(final String logicalTypeName, final
public static ApplicationFeatureId newMember(final String fullyQualifiedLogicalName) {
var featureId = new ApplicationFeatureId(ApplicationFeatureSort.MEMBER);
var i = fullyQualifiedLogicalName.lastIndexOf("#");
- if(i == -1) {
+ if(i == -1)
throw new IllegalArgumentException("Malformed, expected a '#': " + fullyQualifiedLogicalName);
- }
var logicalTypeName = fullyQualifiedLogicalName.substring(0, i);
var memberName = fullyQualifiedLogicalName.substring(i + 1);
initType(featureId, logicalTypeName);
@@ -184,10 +180,9 @@ private static void initType(final ApplicationFeatureId featureId, final String
}
// guard against empty namespace; there should be a meta-model validator that already catched that
- if(_Strings.isEmpty(featureId.namespace)) {
+ if(_Strings.isEmpty(featureId.namespace))
throw _Exceptions.illegalArgument(
"fullyQualifiedName '%s' must include a non-empty namespace", fullyQualifiedName);
- }
featureId.logicalMemberName = null;
}
@@ -197,9 +192,8 @@ private static void initMember(final ApplicationFeatureId featureId, final @Null
}
private static String stripOffParamsIfAny(final @Nullable String name) {
- if(_Strings.isEmpty(name)) {
+ if(_Strings.isEmpty(name))
return name;
- }
final int paramListStartIndex = name.indexOf('(');
return paramListStartIndex>-1
? name.substring(0, paramListStartIndex)
@@ -284,9 +278,8 @@ public String getFullyQualifiedName() {
@Programmatic
public String getLogicalTypeName() {
- if (getTypeSimpleName() == null) {
+ if (getTypeSimpleName() == null)
return null;
- }
var buf = new StringBuilder();
if(!_Strings.isNullOrEmpty(getNamespace())) {
buf.append(getNamespace()).append(".");
@@ -305,13 +298,12 @@ public ApplicationFeatureId getParentNamespaceFeatureId() {
_Assert.assertFalse(sort.isMember());
- if(sort.isType()) {
+ if(sort.isType())
return ApplicationFeatureId.newNamespace(getNamespace());
- } else {
+ else {
var namespace = getNamespace(); // eg aaa.bbb.ccc
- if(!namespace.contains(".")) {
+ if(!namespace.contains("."))
return null; // parent is root
- }
final int cutOffPos = namespace.lastIndexOf('.');
final String parentPackageName = namespace.substring(0, cutOffPos);
return newNamespace(parentPackageName);
@@ -479,15 +471,12 @@ public ApplicationFeatureId withNamespace(final @NonNull String namespace) {
* @param logicalTypeName
*/
public ApplicationFeatureId withLogicalTypeName(final @NonNull String logicalTypeName) {
- switch (getSort()) {
- case MEMBER:
- return newMember(logicalTypeName, this.getLogicalMemberName());
- case TYPE:
- return newType(logicalTypeName);
- case NAMESPACE:
- default:
- return this;
- }
+ return switch (getSort()) {
+ case MEMBER -> newMember(logicalTypeName, this.getLogicalMemberName());
+ case TYPE -> newType(logicalTypeName);
+ case NAMESPACE -> this;
+ default -> this;
+ };
}
}
diff --git a/api/applib/src/main/java/org/apache/causeway/applib/services/appfeat/ApplicationFeatureRepository.java b/api/applib/src/main/java/org/apache/causeway/applib/services/appfeat/ApplicationFeatureRepository.java
index dbcde0143c8..21eb8551a9c 100644
--- a/api/applib/src/main/java/org/apache/causeway/applib/services/appfeat/ApplicationFeatureRepository.java
+++ b/api/applib/src/main/java/org/apache/causeway/applib/services/appfeat/ApplicationFeatureRepository.java
@@ -20,8 +20,12 @@
import java.util.Collection;
import java.util.Map;
+import java.util.Optional;
import java.util.SortedSet;
+import org.jspecify.annotations.Nullable;
+
+import org.apache.causeway.applib.Identifier;
import org.apache.causeway.applib.id.LogicalType;
/**
@@ -52,4 +56,6 @@ public interface ApplicationFeatureRepository {
Collection allMembers();
SortedSet propertyIdsFor(LogicalType logicalType);
+
+ Optional asIdentifier(@Nullable ApplicationFeatureId applicationFeatureId);
}
diff --git a/api/applib/src/main/java/org/apache/causeway/applib/services/columnorder/ColumnOrderTxtFileService.java b/api/applib/src/main/java/org/apache/causeway/applib/services/columnorder/ColumnOrderTxtFileService.java
index fb8a647dc87..cd38cdbaa84 100644
--- a/api/applib/src/main/java/org/apache/causeway/applib/services/columnorder/ColumnOrderTxtFileService.java
+++ b/api/applib/src/main/java/org/apache/causeway/applib/services/columnorder/ColumnOrderTxtFileService.java
@@ -24,28 +24,24 @@
* This is a utility service to support the usage of {@link TableColumnOrderService}, providing the ability to obtain
* a zip of each of the Xxx.columnOrder.txt files for the specified domain object.
*
- *
- * The zip contains:
+ *
The zip contains:
*
*
DomainClass.columnOrder.txt
- as used for standalone collections of DomainClass itself
*
DomainClass#collection1.columnOrder.txt
- for DomainClass' collection with id collection1.
*
...
*
DomainClass#collectionN.columnOrder.txt
- for DomainClass' collection with id collectionN.
*
- *
*
- *
- * These should be unzipped and copied in the domain class' package, and then their contents updated to specify the
+ *
These should be un-zipped and copied in the domain class' package, and then their contents updated to specify the
* order in which the respective object's properties will be shown in the standalone or parented collections.
- *
*
* @see Object_downloadColumnOrderTxtFilesAsZip
+ * @see Object_patchColumnOrder
* @see TableColumnOrderService
*
* @since 2.0 {@index}
*/
public interface ColumnOrderTxtFileService {
- byte[] toZip(final Object domainObject);
-
+ byte[] toZip(Object domainObject);
}
diff --git a/api/applib/src/main/java/org/apache/causeway/applib/services/columnorder/Object_downloadColumnOrderTxtFilesAsZip.java b/api/applib/src/main/java/org/apache/causeway/applib/services/columnorder/Object_downloadColumnOrderTxtFilesAsZip.java
index ba974440f3d..24e615b39dc 100644
--- a/api/applib/src/main/java/org/apache/causeway/applib/services/columnorder/Object_downloadColumnOrderTxtFilesAsZip.java
+++ b/api/applib/src/main/java/org/apache/causeway/applib/services/columnorder/Object_downloadColumnOrderTxtFilesAsZip.java
@@ -74,6 +74,8 @@
@RequiredArgsConstructor
public class Object_downloadColumnOrderTxtFilesAsZip {
+ @Inject ColumnOrderTxtFileService columnOrderTxtFileService;
+
private final Object domainObject; // mixee
public static class ActionDomainEvent
@@ -88,6 +90,4 @@ public static class ActionDomainEvent
return String.format("%s.columnOrder.zip", domainObject.getClass().getSimpleName());
}
- @Inject ColumnOrderTxtFileService columnOrderTxtFileService;
-
}
diff --git a/api/applib/src/main/java/org/apache/causeway/applib/services/columnorder/Object_patchColumnOrder.java b/api/applib/src/main/java/org/apache/causeway/applib/services/columnorder/Object_patchColumnOrder.java
new file mode 100644
index 00000000000..118dcdb5949
--- /dev/null
+++ b/api/applib/src/main/java/org/apache/causeway/applib/services/columnorder/Object_patchColumnOrder.java
@@ -0,0 +1,164 @@
+/*
+ * 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.services.columnorder;
+
+import java.util.List;
+import java.util.function.UnaryOperator;
+import java.util.stream.Stream;
+
+import jakarta.inject.Inject;
+
+import org.jspecify.annotations.Nullable;
+
+import org.apache.causeway.applib.Identifier;
+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.Introspection;
+import org.apache.causeway.applib.annotation.MemberSupport;
+import org.apache.causeway.applib.annotation.Nature;
+import org.apache.causeway.applib.annotation.Parameter;
+import org.apache.causeway.applib.annotation.ParameterLayout;
+import org.apache.causeway.applib.annotation.PrecedingParamsPolicy;
+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.layout.LayoutConstants;
+import org.apache.causeway.applib.services.appfeat.ApplicationFeatureId;
+import org.apache.causeway.applib.services.appfeat.ApplicationFeatureRepository;
+import org.apache.causeway.applib.services.metamodel.MetaModelService;
+import org.apache.causeway.applib.services.metamodel.MetaModelService.AssociationsLookup;
+import org.apache.causeway.applib.util.Listing;
+import org.apache.causeway.applib.util.Listing.ListingHandler;
+import org.apache.causeway.applib.util.Listing.MergePolicy;
+import org.apache.causeway.commons.collections.Can;
+import org.apache.causeway.commons.internal.exceptions._Exceptions;
+
+import lombok.RequiredArgsConstructor;
+
+/**
+ * Allows uploading of column order definition, that overrules the default lookup for such information.
+ *
+ * @since 4.0 {@index}
+ */
+@Action(
+ domainEvent = Object_patchColumnOrder.ActionDomainEvent.class,
+ semantics = SemanticsOf.IDEMPOTENT,
+ commandPublishing = Publishing.DISABLED,
+ executionPublishing = Publishing.DISABLED,
+ restrictTo = RestrictTo.PROTOTYPING)
+@ActionLayout(
+ cssClassFa = "solid file-arrow-up",
+ describedAs = "Uploads table column order, to be stored in memory for this object type. "
+ + "It overrules the default column order definition lookup. "
+ + "On application restart this information is lost.",
+ fieldSetId = LayoutConstants.FieldSetId.METADATA,
+ position = ActionLayout.Position.PANEL_DROPDOWN,
+ sequence = "700.2.4"
+)
+//framework provided domain objects and mixins should explicitly specify their introspection policy
+@DomainObject(nature=Nature.MIXIN, mixinMethod = "act", introspection = Introspection.ANNOTATION_REQUIRED)
+@RequiredArgsConstructor
+public class Object_patchColumnOrder {
+
+ public static class ActionDomainEvent
+ extends org.apache.causeway.applib.CausewayModuleApplib.ActionDomainEvent {}
+
+ @Inject MetaModelService metaModelService;
+ @Inject ApplicationFeatureRepository applicationFeatureRepository;
+
+ private final Object mixee;
+
+ @MemberSupport public Object act(
+
+ @Parameter
+ @ParameterLayout(describedAs = "The 'Feature', for which the patch is to be applied (in-memory), "
+ + "that is, "
+ + "either for a one-to-many relation (a PARENTED Collection), "
+ + "or a domain-type (applies to all STANDALONE Collections of that element-type). "
+ + "The Feature either represents a particular one-to-many relation of this domain-type or "
+ + "represents the domain-type itself or one of the domain-type's super types. "
+ + "The Apache Causeway Programming Model also supports {parent-type, element-type} scoped "
+ + "column order definitions, which are not covered by patching yet.")
+ final ApplicationFeatureId featureId,
+
+ @Parameter(precedingParamsPolicy = PrecedingParamsPolicy.RESET)
+ @ParameterLayout(multiLine = 20)
+ final String columnListing) {
+
+ var identifier = applicationFeatureRepository.asIdentifier(featureId)
+ .orElseThrow(); // not found -> unexpected
+
+ var listing = listingHandler().parseListing(columnListing);
+ var columns = Can.ofStream(listing.streamEnabled());
+
+ metaModelService.patchColumnOrder(identifier, columns);
+ return mixee;
+ }
+
+ @MemberSupport public List choicesFeatureId() {
+ return Stream.concat(
+ metaModelService.streamTypeHierarchy(mixee.getClass())
+ .map(ApplicationFeatureId::fromIdentifier),
+ metaModelService.streamCollections(mixee.getClass())
+ .map(ApplicationFeatureId::fromIdentifier))
+ .toList();
+ }
+
+ @MemberSupport public String defaultColumnListing(final @Nullable ApplicationFeatureId featureId) {
+ if(featureId==null)
+ return "# no feature selected";
+
+ var identifier = applicationFeatureRepository.asIdentifier(featureId)
+ .orElseThrow(); // not found -> unexpected
+
+ if(identifier.type().isCollection())
+ return listing(
+ metaModelService.parentedAssociationsForColumnRendering(mixee, identifier, AssociationsLookup.AVAILABLE),
+ metaModelService.parentedAssociationsForColumnRendering(mixee, identifier, AssociationsLookup.ENABLED))
+ .toString();
+
+ if(identifier.type().isClass())
+ return listing(
+ metaModelService.standaloneAssociationsForColumnRendering(identifier.logicalType(), AssociationsLookup.AVAILABLE),
+ metaModelService.standaloneAssociationsForColumnRendering(identifier.logicalType(), AssociationsLookup.ENABLED))
+ .toString();
+
+ throw _Exceptions.illegalArgument("unsupported feature type %s", identifier.type());
+ }
+
+ // -- HELPER
+
+ private Listing listing(final Stream availableIds, final Stream enabledIds) {
+ // all column candidates
+ var available = listingHandler()
+ .createListing(availableIds.map(Identifier::memberLogicalName));
+
+ // all columns currently rendered
+ var enabled = listingHandler()
+ .createListing(enabledIds.map(Identifier::memberLogicalName));
+
+ return enabled.merge(MergePolicy.ADD_NEW_AS_DISABLED, available);
+ }
+
+ private final static ListingHandler listingHandler() {
+ return new ListingHandler<>(String.class, UnaryOperator.identity(), UnaryOperator.identity(), UnaryOperator.identity());
+ }
+
+}
diff --git a/api/applib/src/main/java/org/apache/causeway/applib/services/metamodel/MetaModelService.java b/api/applib/src/main/java/org/apache/causeway/applib/services/metamodel/MetaModelService.java
index 7fd76afec5a..0b7fa221059 100644
--- a/api/applib/src/main/java/org/apache/causeway/applib/services/metamodel/MetaModelService.java
+++ b/api/applib/src/main/java/org/apache/causeway/applib/services/metamodel/MetaModelService.java
@@ -20,11 +20,14 @@
import java.util.Optional;
import java.util.function.BiPredicate;
+import java.util.stream.Stream;
import jakarta.inject.Named;
+import org.jspecify.annotations.NonNull;
import org.jspecify.annotations.Nullable;
+import org.apache.causeway.applib.Identifier;
import org.apache.causeway.applib.annotation.Action;
import org.apache.causeway.applib.annotation.DomainObject;
import org.apache.causeway.applib.annotation.DomainService;
@@ -36,8 +39,6 @@
import org.apache.causeway.commons.collections.Can;
import org.apache.causeway.schema.metamodel.v2.MetamodelDto;
-import org.jspecify.annotations.NonNull;
-
/**
* This service provides a formal API into the framework's metamodel.
*
@@ -56,7 +57,7 @@ public interface MetaModelService {
* such non-abstract class registered.
* (interfaces and abstract types are never added to the lookup table).
*/
- Optional lookupLogicalTypeByName(final String logicalTypeName);
+ Optional lookupLogicalTypeByName(String logicalTypeName);
/**
* Assuming that the {@link LogicalType} passed in actually represents a domain type, then
@@ -69,7 +70,7 @@ public interface MetaModelService {
*
* @param logicalType
*/
- Can logicalTypeAndAliasesFor(final LogicalType logicalType);
+ Can logicalTypeAndAliasesFor(LogicalType logicalType);
/**
* Returns the {@link LogicalType} of a domain class' object type, corresponding to {@link Named#value()},
@@ -80,20 +81,20 @@ public interface MetaModelService {
* If there is no such domain type, then an empty {@link Can} will be returned.
*
*/
- Can logicalTypeAndAliasesFor(final String logicalTypeName);
+ Can logicalTypeAndAliasesFor(String logicalTypeName);
/**
* Provides a lookup by class of a domain class' object type,corresponding to
* {@link Named#value()} or {@link DomainService#aliased()} or
* {@link DomainObject#aliased()}.
*/
- Optional lookupLogicalTypeByClass(final Class> domainType);
+ Optional lookupLogicalTypeByClass(Class> domainType);
/**
* Invalidates and rebuilds the internal metadata for the specified domain
* type.
*/
- void rebuild(final Class> domainType);
+ void rebuild(Class> domainType);
/**
* Returns a list of representations of each of member of each domain class.
@@ -137,8 +138,7 @@ public interface MetaModelService {
* {@link org.apache.causeway.applib.services.conmap.ContentMappingService}.
*
*/
- CommandDtoProcessor commandDtoProcessorFor(
- String logicalMemberIdentifier);
+ CommandDtoProcessor commandDtoProcessorFor(String logicalMemberIdentifier);
/**
* How {@link MetaModelService#sortOf(Class, Mode)} should act if an object
@@ -166,13 +166,100 @@ enum Mode {
*
* @param config - restricts/filters to a subsets of the metamodel.
*/
- MetamodelDto exportMetaModel(final Config config);
+ MetamodelDto exportMetaModel(Config config);
/**
* Can be used to create object relation diagrams (e.g. Plantuml).
*
* @param filter by {@link BeanSort} and {@link LogicalType} what to include in the resulting graph
*/
- ObjectGraph exportObjectGraph(final @NonNull BiPredicate filter);
+ ObjectGraph exportObjectGraph(@NonNull BiPredicate filter);
+
+ /**
+ * Stream of {@link Identifier} representing the Actions of given domainType, including mixed-in ones,
+ * but excluding Actions, that are only available for PROTOTYPING.
+ * @since 4.0
+ */
+ Stream streamActions(@Nullable Class> domainType);
+
+ /**
+ * Stream of {@link Identifier} representing the Properties of given domainType, including mixed-in ones.
+ * @since 4.0
+ */
+ Stream streamProperties(@Nullable Class> domainType);
+
+ /**
+ * Stream of {@link Identifier} representing the Collections of given domainType, including mixed-in ones.
+ * @since 4.0
+ */
+ Stream streamCollections(@Nullable Class> domainType);
+
+ /**
+ * Stream of {@link Identifier} representing the type hierarchy of given domainType, starting at given domainType,
+ * going 'up' in the hierarchy, but excluding {@link Object}.
+ *
+ *
Included are all types in the hierarchy, that are recognized by the Metamodel, recognized Interfaces last.
+ *
+ * @apiNote Since 4.0, the Metamodel supports Interfaces acting as element-types for Collections.
+ * This requires explicit {@link DomainObject} annotations on those Interfaces along with the constraint, that within
+ * a type hierarchy, every type can have at most one super-type.
+ *
+ * @since 4.0
+ */
+ Stream streamTypeHierarchy(@Nullable Class> domainType);
+
+ /**
+ * Parameter to lookup associations for column rendering.
+ *
+ * @since 4.0
+ */
+ public enum AssociationsLookup {
+ /**
+ * The Column Order SPI and Column Order Patching have potential to hide otherwise visible columns.
+ * This query mode shows all columns, that are not permanently hidden while ignoring hiding from above mechanisms.
+ */
+ AVAILABLE,
+ /**
+ * This query mode shows all currently visible columns.
+ */
+ ENABLED;
+
+ public boolean isAvailable() { return this==AVAILABLE; }
+ public boolean isEnabled() { return this==ENABLED; }
+ }
+
+ /**
+ * Stream of {@link Identifier} representing the Columns for specified PARENTED collection.
+ *
+ *
Columns returned are those that are either in principle AVAILABLE or currently ENABLED, based on given {@link AssociationsLookup}.
+ *
+ *
The availability lookup returns {@link Identifier}(s) in no particular order,
+ * whereas the enablement lookup returns them in same order as rendered.
+ *
+ *
If parentDomainObject is null returns an empty {@link Stream}.
+ *
+ * @since 4.0
+ */
+ Stream parentedAssociationsForColumnRendering(Object parentDomainObject, Identifier collectionId, AssociationsLookup lookup);
+
+ /**
+ * Stream of {@link Identifier} representing the Columns for specified STANDALONE collection.
+ *
+ *
Columns returned are those that are either in principle AVAILABLE or currently ENABLED, based on given {@link AssociationsLookup}.
+ *
+ *
The availability lookup returns {@link Identifier}(s) in no particular order,
+ * whereas the enablement lookup returns them in same order as rendered.
+ *
+ * @since 4.0
+ */
+ Stream standaloneAssociationsForColumnRendering(LogicalType logicalType, AssociationsLookup lookup);
+
+ /**
+ * @param identifier either for a TYPE or a COLLECTION
+ * @param columnsInOrder to be used instead of the default order
+ *
+ * @since 4.0
+ */
+ void patchColumnOrder(Identifier identifier, Can columnsInOrder);
}
diff --git a/api/applib/src/main/java/org/apache/causeway/applib/util/Listing.java b/api/applib/src/main/java/org/apache/causeway/applib/util/Listing.java
new file mode 100644
index 00000000000..cb3b5d0ca2e
--- /dev/null
+++ b/api/applib/src/main/java/org/apache/causeway/applib/util/Listing.java
@@ -0,0 +1,315 @@
+/*
+ * 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;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.function.Function;
+import java.util.function.UnaryOperator;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
+import org.apache.causeway.applib.annotation.Programmatic;
+import org.apache.causeway.applib.annotation.ValueSemantics;
+import org.apache.causeway.commons.collections.Can;
+import org.apache.causeway.commons.functional.Try;
+import org.apache.causeway.commons.internal.base._NullSafe;
+import org.apache.causeway.commons.io.TextUtils;
+
+/**
+ * Represents a list of lines (of text),
+ * where each {@link Line} has semantics, such as being able to be commented out.
+ *
+ *
Each non-comment line can be mapped to a Java class of type {@code T}.
+ *
+ *
Typically not used as domain value, as we provide no {@link ValueSemantics},
+ * but rather as a (multi-line) {@link String} support class.
+ *
+ * @param type each non-comment line can be mapped to (successful parsing)
+ * @since 4.0
+ */
+@Programmatic
+public record Listing(
+ @NonNull ListingHandler handler,
+ @NonNull Can lines) {
+
+ public record ListingHandler (
+ @NonNull Class objectType,
+ @NonNull Function stringifier,
+ @NonNull Function destringifier,
+ @NonNull Function keyExtractor) {
+
+ // -- LISTING FACTORIES
+
+ public Listing emptyListing() {
+ return createListing(Can.empty());
+ }
+
+ public Listing createListing(@Nullable final Iterable enabledElements) {
+ return createListing(_NullSafe.stream(enabledElements));
+ }
+ public Listing createListing(@Nullable final Stream enabledElementStream) {
+ if(enabledElementStream==null) return emptyListing();
+ return new Listing<>(this, enabledElementStream
+ .map(t->new LineEnabled<>(t, stringifier().apply(t)))
+ .collect(Can.toCan()));
+ }
+
+ public Listing parseListing(@Nullable final String wholeText) {
+ if(wholeText==null) return emptyListing();
+ return parseListing(TextUtils.readLines(wholeText));
+ }
+ public Listing parseListing(@Nullable final Can textLines) {
+ if(textLines==null) return emptyListing();
+ return new Listing<>(this, textLines.map(this::parseLine));
+ }
+
+ // -- PARSING
+
+ /**
+ * Parses the whole line as is, including comments.
+ *
+ *
If a line is not a comment, but can also not be mapped to {@code T},
+ * then it is commented out.
+ */
+ public Line parseLine(final String wholeLine) {
+ final var line = wholeLine.trim();
+ if(line.isBlank())
+ return new LineComment(line);
+ if(line.startsWith("#")) {
+ var trimmed = line;
+ while(trimmed.startsWith("#")) {
+ trimmed = trimmed.substring(1).trim();
+ }
+ T object = asT(trimmed).getValue().orElse(null);
+ return object==null
+ ? new LineComment(wholeLine)
+ : (line.startsWith("REMOVED "))
+ ? new LineRemoved<>(object, trimmed.substring(8))
+ : new LineDisabled<>(object, trimmed);
+ }
+ Try asT = asT(line);
+ return asT
+ .mapSuccessWhenPresent(object->(Line)new LineEnabled(object, line))
+ .mapEmptyToFailure()
+ .mapFailureToSuccess(e->new LineComment(String.format("#ERROR cannot parse ‹%s› as %s (%s)",
+ line, objectType().getSimpleName(), e)))
+ .valueAsNonNullElseFail();
+ }
+
+ // -- HELPER
+
+ private Try asT(final String stringified) {
+ return Try.call(()->destringifier().apply(stringified));
+ }
+
+ }
+
+ public sealed interface Line
+ permits MappedLine, LineComment{
+ String format();
+ }
+ public sealed interface MappedLine
+ extends Line
+ permits LineEnabled, LineDisabled, LineRemoved {
+ T object();
+ }
+
+ /**
+ * Can be mapped to {@code T}. Not commented out.
+ */
+ public record LineEnabled(
+ @NonNull T object,
+ @NonNull String objectStringified)
+ implements MappedLine {
+ @Override public String format() {
+ return objectStringified;
+ }
+ public LineDisabled toDisabled() {
+ return new LineDisabled<>(object, objectStringified);
+ }
+ }
+ /**
+ * Can be mapped to {@code T}, but commented out.
+ */
+ public record LineDisabled(
+ @NonNull T object,
+ @NonNull String objectStringified)
+ implements MappedLine {
+ @Override public String format() {
+ return "#" + objectStringified;
+ }
+ public LineEnabled toEnabled() {
+ return new LineEnabled<>(object, objectStringified);
+ }
+ public LineRemoved toRemoved() {
+ return new LineRemoved<>(object, objectStringified);
+ }
+ }
+ /**
+ * Can be mapped to {@code T}, but was removed, hence commented out.
+ */
+ public record LineRemoved(
+ @NonNull T object,
+ @NonNull String objectStringified)
+ implements MappedLine {
+ @Override public String format() {
+ return "#REMOVED " + objectStringified;
+ }
+ public LineDisabled toDisabled() {
+ return new LineDisabled<>(object, objectStringified);
+ }
+ }
+ /**
+ * Blank line or arbitrary comment, cannot be mapped to {@code T}.
+ */
+ public record LineComment(
+ @NonNull String comment)
+ implements Line {
+ @Override public String format() {
+ return comment;
+ }
+ }
+
+ // -- STREAMS
+
+ public Stream> streamEnabledLines() {
+ return lines().stream().filter(LineEnabled.class::isInstance).map(LineEnabled.class::cast);
+ }
+ public Stream> streamDisabledLines() {
+ return lines().stream().filter(LineDisabled.class::isInstance).map(LineDisabled.class::cast);
+ }
+ public Stream streamEnabled() {
+ return streamEnabledLines().map(LineEnabled::object);
+ }
+ public Stream streamDisabled() {
+ return streamDisabledLines().map(LineDisabled::object);
+ }
+ public Stream streamComments() {
+ return lines().stream().filter(LineComment.class::isInstance).map(LineComment.class::cast);
+ }
+
+ // -- FORMAT
+
+ @Override
+ public final String toString() {
+ return lines().map(Line::format).join("\n");
+ }
+
+ // -- MERGE
+
+ public enum MergePolicy {
+ ADD_NEW_AS_ENABLED,
+ ADD_NEW_AS_DISABLED
+ }
+
+ /**
+ * Say this listing was edited by a human,
+ * but needs to be synchronized with a newer version originating from some system process,
+ * then we'd like to merge in this new information,
+ * without loosing any information that is already present in this listing such as:
+ *
+ *
comments
+ *
line ordering
+ *
+ *
+ *
We do this by adding a comment line {@code #MERGED} followed by any lines that are new.
+ *
+ *
Any lines already existing are kept as they are,
+ * unless the {@code newerVersion} no longer contains the referenced object of type {@code T},
+ * in which case, the {@link Line} will be commented out (if not already) with a marker
+ * {@code #REMOVED}.
+ *
+ *
This requires the merge algorithm to evaluate whether 2 referenced objects are equal,
+ * which it does by checking object keys as given by {@link ListingHandler#keyExtractor}
+ * for equality. We could have done the same by directly checking referenced objects for equality,
+ * but - worst case - that would involve entire objects graphs to be assembled,
+ * while for our use case its convenient to work with simple object facades.
+ */
+ public Listing merge(@NonNull final MergePolicy policy, @Nullable final Listing newerVersion) {
+ if(newerVersion==null) return this;
+ final Map