Skip to content

Commit bb452b6

Browse files
authored
Merge pull request #3490 from apache/3991-record.introspect
CAUSEWAY-3991: Metamodel Introspection Policy not honored for Java Records
2 parents 2bdf117 + cc36f86 commit bb452b6

15 files changed

Lines changed: 3164 additions & 199 deletions

File tree

commons/src/main/java/org/apache/causeway/commons/semantics/AccessorSemantics.java

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,14 @@
1919
package org.apache.causeway.commons.semantics;
2020

2121
import java.beans.Introspector;
22+
import java.lang.annotation.Annotation;
2223
import java.util.Map;
2324
import java.util.function.Predicate;
2425

2526
import org.jspecify.annotations.Nullable;
2627

2728
import org.apache.causeway.commons.internal.base._Strings;
29+
import org.apache.causeway.commons.internal.reflection._Annotations;
2830
import org.apache.causeway.commons.internal.reflection._GenericResolver.ResolvedMethod;
2931

3032
import lombok.Getter;
@@ -38,10 +40,13 @@ public enum AccessorSemantics {
3840
SET("set");
3941
private final String prefix;
4042

41-
public static String associationIdentifierFor(final ResolvedMethod method) {
43+
public static <A extends Annotation> String associationIdentifierFor(
44+
final ResolvedMethod method,
45+
final Class<A> requiredAnnotationType) {
4246
return AccessorSemantics.isRecordComponentAccessor(method)
43-
? method.name()
44-
: Introspector.decapitalize(_Strings.baseName(method.name()));
47+
|| isFluentGetter(method, requiredAnnotationType)
48+
? method.name()
49+
: Introspector.decapitalize(_Strings.baseName(method.name()));
4550
}
4651

4752
public String prefix(final @Nullable String input) {
@@ -53,21 +58,28 @@ public String prefix(final @Nullable String input) {
5358
public boolean isPrefixOf(final @Nullable String input) {
5459
return input!=null
5560
? input.startsWith(prefix)
61+
&& input.length()>prefix.length()
5662
: false;
5763
}
5864

5965
// -- HIGH LEVEL PREDICATES
6066

61-
public static boolean isPropertyAccessor(final ResolvedMethod method) {
67+
public static <A extends Annotation> boolean isPropertyAccessor(
68+
final ResolvedMethod method,
69+
final Class<A> requiredAnnotationType) {
6270
return isRecordComponentAccessor(method)
6371
|| isGetter(method)
72+
|| isFluentGetter(method, requiredAnnotationType)
6473
? !hasCollectionSemantics(method.returnType())
6574
: false;
6675
}
6776

68-
public static boolean isCollectionAccessor(final ResolvedMethod method) {
77+
public static <A extends Annotation> boolean isCollectionAccessor(
78+
final ResolvedMethod method,
79+
final Class<A> requiredAnnotationType) {
6980
return isRecordComponentAccessor(method)
7081
|| isNonBooleanGetter(method)
82+
|| isFluentGetter(method, requiredAnnotationType)
7183
? hasCollectionSemantics(method.returnType())
7284
: false;
7385
}
@@ -83,6 +95,17 @@ public static boolean isRecordComponentAccessor(final ResolvedMethod method) {
8395
return false;
8496
}
8597

98+
public static <A extends Annotation> boolean isFluentGetter(
99+
final ResolvedMethod accessorMethod,
100+
final Class<A> requiredAnnotationType) {
101+
{ // restricted to Java Records (but could be enabled for all classes)
102+
var recordClass = accessorMethod.implementationClass();
103+
if(!recordClass.isRecord()) return false;
104+
}
105+
return !isGetter(accessorMethod)
106+
&& _Annotations.isPresent(accessorMethod.method(), requiredAnnotationType);
107+
}
108+
86109
public static boolean isCandidateGetterName(final @Nullable String name) {
87110
return GET.isPrefixOf(name)
88111
|| IS.isPrefixOf(name);

core/metamodel/src/main/java/org/apache/causeway/core/metamodel/facetapi/FeatureType.java

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919
package org.apache.causeway.core.metamodel.facetapi;
2020

2121
import org.apache.causeway.applib.Identifier;
22+
import org.apache.causeway.applib.annotation.Collection;
23+
import org.apache.causeway.applib.annotation.Property;
2224
import org.apache.causeway.applib.id.LogicalType;
2325
import org.apache.causeway.commons.collections.ImmutableEnumSet;
2426
import org.apache.causeway.commons.internal.reflection._MethodFacades.MethodFacade;
@@ -48,14 +50,14 @@ public Identifier identifierFor(final LogicalType typeIdentifier, final MethodFa
4850
@Override
4951
public Identifier identifierFor(final LogicalType typeIdentifier, final MethodFacade method) {
5052
return Identifier.propertyIdentifier(typeIdentifier,
51-
AccessorSemantics.associationIdentifierFor(method.asMethodElseFail())); // expected regular
53+
AccessorSemantics.associationIdentifierFor(method.asMethodElseFail(), Property.class)); // expected regular
5254
}
5355
},
5456
COLLECTION("Collection") {
5557
@Override
5658
public Identifier identifierFor(final LogicalType typeIdentifier, final MethodFacade method) {
5759
return Identifier.collectionIdentifier(typeIdentifier,
58-
AccessorSemantics.associationIdentifierFor(method.asMethodElseFail())); // expected regular
60+
AccessorSemantics.associationIdentifierFor(method.asMethodElseFail(), Collection.class)); // expected regular
5961
}
6062
},
6163
ACTION("Action") {

core/metamodel/src/main/java/org/apache/causeway/core/metamodel/facets/collections/accessor/CollectionAccessorFacetViaAccessorFactory.java

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020

2121
import jakarta.inject.Inject;
2222

23+
import org.apache.causeway.applib.annotation.Collection;
2324
import org.apache.causeway.commons.collections.Can;
2425
import org.apache.causeway.commons.internal.reflection._GenericResolver.ResolvedMethod;
2526
import org.apache.causeway.commons.semantics.AccessorSemantics;
@@ -42,12 +43,14 @@ public CollectionAccessorFacetViaAccessorFactory(final MetaModelContext mmc) {
4243

4344
@Override
4445
public boolean isAssociationAccessor(final ResolvedMethod method) {
45-
return AccessorSemantics.isCollectionAccessor(method);
46+
return AccessorSemantics.isCollectionAccessor(method, Collection.class);
4647
}
4748

4849
@Override
4950
protected PropertyOrCollectionAccessorFacet createFacet(
50-
final ObjectSpecification typeSpec, final ResolvedMethod accessorMethod, final FacetedMethod facetHolder) {
51+
final ObjectSpecification typeSpec,
52+
final ResolvedMethod accessorMethod,
53+
final FacetedMethod facetHolder) {
5154
return new CollectionAccessorFacetViaAccessor(typeSpec, accessorMethod, facetHolder);
5255
}
5356

core/metamodel/src/main/java/org/apache/causeway/core/metamodel/facets/object/ignore/annotation/RemoveAnnotatedMethodsFacetFactory.java

Lines changed: 49 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,13 @@
1818
*/
1919
package org.apache.causeway.core.metamodel.facets.object.ignore.annotation;
2020

21+
import java.util.function.Consumer;
2122
import java.util.function.Predicate;
2223

2324
import jakarta.inject.Inject;
2425

26+
import org.jspecify.annotations.NonNull;
27+
2528
import org.apache.causeway.commons.internal.functions._Predicates;
2629
import org.apache.causeway.commons.internal.reflection._ClassCache.Attribute;
2730
import org.apache.causeway.commons.internal.reflection._GenericResolver.ResolvedMethod;
@@ -31,8 +34,6 @@
3134
import org.apache.causeway.core.metamodel.facets.FacetFactoryAbstract;
3235
import org.apache.causeway.core.metamodel.spec.ObjectSpecification;
3336

34-
import org.jspecify.annotations.NonNull;
35-
3637
public class RemoveAnnotatedMethodsFacetFactory
3738
extends FacetFactoryAbstract {
3839

@@ -43,48 +44,56 @@ public RemoveAnnotatedMethodsFacetFactory(final MetaModelContext mmc) {
4344

4445
@Override
4546
public void process(final ProcessClassContext processClassContext) {
47+
switch (processClassContext.getIntrospectionPolicy()) {
48+
case ENCAPSULATION_ENABLED -> getClassCache()
49+
.streamResolvedMethods(processClassContext.getCls())
50+
/* honor exclude markers (always) */
51+
.filter(filterAndRemoveExclusions(processClassContext))
52+
/* don't throw away mixin main methods,
53+
* those we keep irrespective of IntrospectionPolicy */
54+
.filter(_Predicates.not(isMixinMainMethod(processClassContext)))
55+
.forEach(removeNonInclusions(processClassContext));
56+
case ANNOTATION_REQUIRED -> getClassCache()
57+
.streamPublicMethods(processClassContext.getCls())
58+
/* honor exclude markers (always) */
59+
.filter(filterAndRemoveExclusions(processClassContext))
60+
/* don't throw away mixin main methods,
61+
* those we keep irrespective of IntrospectionPolicy */
62+
.filter(_Predicates.not(isMixinMainMethod(processClassContext)))
63+
.forEach(removeNonInclusions(processClassContext));
64+
case ANNOTATION_OPTIONAL -> getClassCache()
65+
.streamPublicMethods(processClassContext.getCls())
66+
.forEach(removeExclusions(processClassContext));
67+
}
68+
}
4669

47-
var policy = getMetaModelContext().getConfiguration().core().metaModel().introspector().policy();
48-
switch (policy) {
49-
case ENCAPSULATION_ENABLED:
50-
getClassCache()
51-
.streamResolvedMethods(processClassContext.getCls())
52-
/* honor exclude markers (always) */
53-
.filter(method->{
54-
if(ProgrammingModelConstants.MethodExcludeMarker.anyMatchOn(method)) {
55-
processClassContext.removeMethod(method);
56-
return false; // stop processing
57-
}
58-
return true; // continue processing
59-
})
60-
/* don't throw away mixin main methods,
61-
* those we keep irrespective of IntrospectionPolicy */
62-
.filter(_Predicates.not(isMixinMainMethod(processClassContext)))
63-
.forEach(method -> {
64-
if (!ProgrammingModelConstants.MethodIncludeMarker.anyMatchOn(method)) {
65-
processClassContext.removeMethod(method);
66-
}
67-
});
68-
break;
69-
70-
case ANNOTATION_REQUIRED:
71-
// TODO: this could probably be more precise and insist on @Domain.Include for members.
72-
73-
case ANNOTATION_OPTIONAL:
70+
// -- HELPER
7471

75-
getClassCache()
76-
.streamPublicMethods(processClassContext.getCls())
77-
.forEach(method->{
78-
if(ProgrammingModelConstants.MethodExcludeMarker.anyMatchOn(method)) {
79-
processClassContext.removeMethod(method);
80-
}
81-
});
72+
private Predicate<? super ResolvedMethod> filterAndRemoveExclusions(final ProcessClassContext processClassContext) {
73+
return method->{
74+
if(ProgrammingModelConstants.MethodExcludeMarker.anyMatchOn(method)) {
75+
processClassContext.removeMethod(method);
76+
return false; // stop processing
77+
}
78+
return true; // continue processing
79+
};
80+
}
8281

83-
break;
84-
}
82+
private Consumer<? super ResolvedMethod> removeExclusions(final ProcessClassContext processClassContext) {
83+
return method->{
84+
if(ProgrammingModelConstants.MethodExcludeMarker.anyMatchOn(method)) {
85+
processClassContext.removeMethod(method);
86+
}
87+
};
8588
}
8689

87-
// -- HELPER
90+
private Consumer<? super ResolvedMethod> removeNonInclusions(final ProcessClassContext processClassContext) {
91+
return method->{
92+
if(!ProgrammingModelConstants.MethodIncludeMarker.anyMatchOn(method)) {
93+
processClassContext.removeMethod(method);
94+
}
95+
};
96+
}
8897

8998
/**
9099
* We have no MixinFacet yet, so we need to revert to low level introspection tactics.
@@ -94,9 +103,8 @@ private Predicate<ResolvedMethod> isMixinMainMethod(final @NonNull ProcessClassC
94103
// shortcut, when we already know the class is not a mixin
95104
if(processClassContext.getFacetHolder() instanceof ObjectSpecification) {
96105
var spec = (ObjectSpecification) processClassContext.getFacetHolder();
97-
if(!spec.getBeanSort().isMixin()) {
106+
if(!spec.getBeanSort().isMixin())
98107
return method->false;
99-
}
100108
}
101109
// lookup attribute from class-cache as it should have been already processed by the BeanTypeClassifier
102110
var cls = processClassContext.getCls();

core/metamodel/src/main/java/org/apache/causeway/core/metamodel/facets/object/viewmodel/ViewModelFacetForJavaRecord.java

Lines changed: 31 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -21,18 +21,17 @@
2121
import java.lang.reflect.Constructor;
2222
import java.lang.reflect.RecordComponent;
2323
import java.util.Arrays;
24+
import java.util.Objects;
2425
import java.util.Optional;
2526
import java.util.stream.Stream;
2627

2728
import org.jspecify.annotations.NonNull;
2829

29-
import org.apache.causeway.commons.collections.Can;
30-
import org.apache.causeway.core.metamodel.consent.InteractionInitiatedBy;
30+
import org.springframework.util.Assert;
31+
3132
import org.apache.causeway.core.metamodel.facetapi.FacetHolder;
3233
import org.apache.causeway.core.metamodel.object.ManagedObject;
3334
import org.apache.causeway.core.metamodel.spec.ObjectSpecification;
34-
import org.apache.causeway.core.metamodel.spec.feature.MixedIn;
35-
import org.apache.causeway.core.metamodel.spec.feature.ObjectAssociation;
3635
import org.apache.causeway.core.metamodel.util.hmac.Memento;
3736
import org.apache.causeway.core.metamodel.util.hmac.MementoHmacContext;
3837

@@ -75,14 +74,9 @@ protected Object createViewmodelPojo(
7574
// throws on de-marshalling failure
7675
var memento = mementoContext.parseTrustedMemento(trustedBookmarkIdAsBytes);
7776

78-
var recordComponentPojos = streamRecordComponents(viewmodelSpec)
79-
.map(association->{
80-
var associationPojo = association.isProperty()
81-
? memento.get(association.getId(), association.getElementType().getCorrespondingClass())
82-
//TODO collection values not yet supported by memento (as workaround use Serializable record)
83-
: null;
84-
return associationPojo;
85-
}).toArray();
77+
var recordComponentPojos = streamRecordComponents(viewmodelSpec.getCorrespondingClass())
78+
.map(recComp->memento.get(recComp.getName(), recComp.getType()))
79+
.toArray();
8680

8781
return canonicalConstructor.newInstance(recordComponentPojos);
8882
}
@@ -92,50 +86,44 @@ public byte[] encodeState(final ManagedObject viewModel) {
9286

9387
final Memento memento = mementoContext.newMemento();
9488

95-
var viewmodelSpec = viewModel.objSpec();
96-
97-
streamRecordComponents(viewmodelSpec)
98-
.forEach(association->{
99-
100-
final ManagedObject associationValue =
101-
association.get(viewModel, InteractionInitiatedBy.PASS_THROUGH);
102-
103-
if(association != null
104-
//TODO collection values not yet supported by memento (as workaround use Serializable record)
105-
&& association.isProperty()
106-
&& associationValue.getPojo()!=null) {
107-
memento.put(association.getId(), associationValue.getPojo());
108-
}
109-
});
89+
Arrays.stream(snapshotRecordComponents(viewModel.getPojo()))
90+
.forEach(arg->memento.put(arg.name(), arg.pojo()));
11091

11192
return memento.stateAsBytes();
11293
}
11394

11495
// -- HELPER
11596

116-
private Can<ObjectAssociation> recordComponentsAsAssociations;
117-
private Stream<ObjectAssociation> streamRecordComponents(
118-
final @NonNull ObjectSpecification viewmodelSpec) {
119-
if(recordComponentsAsAssociations==null) {
120-
this.recordComponentsAsAssociations = recordComponentsAsAssociations(viewmodelSpec);
121-
}
122-
return recordComponentsAsAssociations.stream();
123-
}
124-
125-
private static Can<ObjectAssociation> recordComponentsAsAssociations(
126-
final @NonNull ObjectSpecification viewmodelSpec) {
127-
return Arrays.stream(viewmodelSpec.getCorrespondingClass().getRecordComponents())
128-
.map(RecordComponent::getName)
129-
.map(memberId->viewmodelSpec.getAssociationElseFail(memberId, MixedIn.EXCLUDED))
130-
.collect(Can.toCan());
97+
@SneakyThrows
98+
private static Stream<RecordComponent> streamRecordComponents(final @NonNull Class<?> recordClass) {
99+
Assert.isTrue(recordClass.isRecord(), ()->"Illegal Argument: not a Java record");
100+
return Arrays.stream(recordClass.getRecordComponents());
131101
}
132102

133103
@SneakyThrows
134104
private static <T> Constructor<T> canonicalConstructor(final @NonNull Class<T> recordClass) {
135-
var constructorParamTypes = Arrays.stream(recordClass.getRecordComponents())
105+
var constructorParamTypes = streamRecordComponents(recordClass)
136106
.map(RecordComponent::getType)
137107
.toArray(Class[]::new);
138108
return recordClass.getDeclaredConstructor(constructorParamTypes);
139109
}
140110

111+
private record NamedArg(String name, Object pojo) {
112+
}
113+
114+
@SneakyThrows
115+
private static NamedArg[] snapshotRecordComponents(final Object recordInstance) {
116+
final @NonNull Class<?> recordClass = Objects.requireNonNull(recordInstance).getClass();
117+
Assert.isTrue(recordClass.isRecord(), ()->"Illegal Argument: not a Java record");
118+
119+
RecordComponent[] components = recordClass.getRecordComponents();
120+
NamedArg[] result = new NamedArg[components.length];
121+
for (int i = 0; i < components.length; i++) {
122+
result[i] = new NamedArg(
123+
components[i].getName(),
124+
components[i].getAccessor().invoke(recordInstance));
125+
}
126+
return result;
127+
}
128+
141129
}

0 commit comments

Comments
 (0)