Skip to content

Commit 7f75f13

Browse files
DATAGRAPH-1393 - Add support for OGM's CompositeAttributeConverter.
This brings in the necessary infrastructure for decomposing single attributes of an entity into a map of properties that are all written to the node or relationship. The `CompositeProperty`-annotation lives in the `schema` package as it defines the schema from the point of the Java mapping code. `CompositeProperty` will work out of the box on maps with key of types string or enum, with configurable prefix and separator. In addition with additional converters it can be applied to attributes of custom types, too.
1 parent 5c80380 commit 7f75f13

27 files changed

+1497
-47
lines changed

src/main/asciidoc/appendix/conversions.adoc

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,3 +210,13 @@ and an optional `Neo4jPersistentPropertyConverterFactory` to construct the forme
210210
With an implementation of `Neo4jPersistentPropertyConverter` all specific conversions for a given type can be addressed.
211211

212212
We provide `@DateLong` and `@DateString` as meta-annotated annotations for backward compatibility with Neo4j-OGM schemes not using native types.
213+
214+
[[composite-properties]]
215+
==== Composite properties
216+
217+
With `@CompositeProperty`, attributes of type `Map<String, Object>` or Map<? extends Enum, Object>` can be stored as composite properties.
218+
All entries inside the map will be added as properties to the node or relationship containing the property.
219+
Either with a configured prefix or prefixed with the name of the property.
220+
While we only offer that feature for maps out of the box, you can `Neo4jPersistentPropertyToMapConverter` and configure it
221+
as the converter to use on `@CompositeProperty`. A `Neo4jPersistentPropertyToMapConverter` needs to know how a given type can
222+
be decomposed to and composed back from a map.

src/main/asciidoc/object-mapping/mapping.adoc

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,19 @@ If you do not use this annotation, your application takes a slight performance h
2222
* `@Id`: Applied at the field level to mark the field used for identity purpose.
2323
* `@GeneratedValue`: Applied at the field level together with `@Id` to specify how unique identifiers should be generated.
2424
* `@Property`: Applied at the field level to modify the mapping from attributes to properties.
25+
* `@CompositeProperty`: Applied at the field level on attributes of type Map that shall be read back as a composite. See <<composite-properties>>.
2526
* `@Relationship`: Applied at the field level to specify the details of a relationship.
2627
* `@DynamicLabels`: Applied at the field level to specify the source of dynamic labels.
28+
* `@RelationshipProperties`: Applied at the class level to indicate this class as the target for properties of a relationship.
29+
* `@TargetNode`: Applied on a field of a class annotated with `@RelationshipProperties` to mark the target of that relationship from the perspective of the other end.
30+
31+
The following annotations are used to specify conversions and ensure backwards compatibility with OGM.
32+
33+
* `@DateLong`
34+
* `@DateString`
35+
* `@ConvertWith`
36+
37+
See <<conversions>> for more information on that.
2738

2839
==== From Spring Data commons
2940

src/main/java/org/springframework/data/neo4j/core/mapping/Neo4jConversionService.java renamed to src/main/java/org/springframework/data/neo4j/core/convert/Neo4jConversionService.java

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,13 @@
1313
* See the License for the specific language governing permissions and
1414
* limitations under the License.
1515
*/
16-
package org.springframework.data.neo4j.core.mapping;
16+
package org.springframework.data.neo4j.core.convert;
1717

1818
import java.util.function.Function;
1919

2020
import org.apiguardian.api.API;
2121
import org.neo4j.driver.Value;
2222
import org.springframework.dao.TypeMismatchDataAccessException;
23-
import org.springframework.data.neo4j.core.convert.Neo4jSimpleTypes;
2423
import org.springframework.data.util.TypeInformation;
2524
import org.springframework.lang.Nullable;
2625

@@ -32,7 +31,7 @@
3231
* @soundtrack Die Ärzte - Die Nacht der Dämonen
3332
* @since 6.0
3433
*/
35-
@API(status = API.Status.INTERNAL, since = "6.0")
34+
@API(status = API.Status.STABLE, since = "6.0")
3635
public interface Neo4jConversionService {
3736

3837
/**

src/main/java/org/springframework/data/neo4j/core/convert/Neo4jPersistentPropertyConverterFactory.java

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,10 @@
2121
* This interface needs to be implemented to provide custom configuration for a {@link Neo4jPersistentPropertyConverter}. Use cases may
2222
* be specific date formats or the like. The build method will receive the whole property. It is safe to assume that at
2323
* least the {@link ConvertWith @ConvertWith} annotation is present on the property, either directly or meta-annotated.
24-
* <p>Classes implementing this interface should have a default constructor. In a normal Spring setup (not CDI), they
25-
* might declare autowired constructor parameters, too.
24+
*
25+
* <p>Classes implementing this interface should have a default constructor. In case they provide a constructor asking for
26+
* an instance of {@link Neo4jConversionService}, such service is provided. This allows for conversions delegating part
27+
* of the conversion.
2628
*
2729
* @author Michael J. Simons
2830
* @soundtrack Antilopen Gang - Abwasser
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
/*
2+
* Copyright 2011-2020 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package org.springframework.data.neo4j.core.convert;
17+
18+
import java.util.Map;
19+
20+
import org.apiguardian.api.API;
21+
import org.neo4j.driver.Value;
22+
23+
/**
24+
* You need to provide an implementation of this interface in case you want to store a property of an entity as separate
25+
* properties on a node. The entity needs to be decomposed into a map and composed from a map for that purpose.
26+
*
27+
* <p>The calling mechanism will take care of adding and removing configured prefixes and transforming keys and values into
28+
* something that Neo4j can understand.
29+
*
30+
* @param <K> The type of the keys (Only Strings and Enums are supported).
31+
* @param <P> The type of the property.
32+
* @author Michael J. Simons
33+
* @soundtrack Metallica - Helping Hands… Live & Acoustic At The Masonic
34+
* @since 6.0
35+
*/
36+
@API(status = API.Status.STABLE, since = "6.0")
37+
public interface Neo4jPersistentPropertyToMapConverter<K, P> {
38+
39+
/**
40+
* Decomposes an object into a map. A conversion service is provided in case delegation is needed.
41+
*
42+
* @param property The source property
43+
* @param neo4jConversionService The conversion service to delegate to if necessary
44+
* @return The decomposed object.
45+
*/
46+
Map<K, Value> decompose(P property, Neo4jConversionService neo4jConversionService);
47+
48+
/**
49+
* Composes the object back from the map. The map contains the raw driver values, as SDN cannot know how you want to
50+
* handle them. Therefore, the conversion service to convert driver values is provided.
51+
*
52+
* @param source The source map
53+
* @param neo4jConversionService The conversion service in case you want to delegate the work for some values in the map
54+
* @return The composed object.
55+
*/
56+
P compose(Map<K, Value> source, Neo4jConversionService neo4jConversionService);
57+
}

src/main/java/org/springframework/data/neo4j/core/mapping/Constants.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ public final class Constants {
4141
public static final String NAME_OF_STATIC_LABELS_PARAM = "__staticLabels__";
4242
public static final String NAME_OF_ENTITY_LIST_PARAM = "__entities__";
4343
public static final String NAME_OF_PATHS = "__paths__";
44+
public static final String NAME_OF_ALL_PROPERTIES = "__allProperties__";
4445

4546
public static final String FROM_ID_PARAMETER_NAME = "fromId";
4647

src/main/java/org/springframework/data/neo4j/core/mapping/CypherGenerator.java

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -434,14 +434,22 @@ private List<Object> projectNodeProperties(NodeDescription<?> nodeDescription, S
434434

435435
List<Object> nodePropertiesProjection = new ArrayList<>();
436436
Node node = anyNode(nodeName);
437-
for (GraphPropertyDescription property : nodeDescription.getGraphPropertiesInHierarchy()) {
438-
if (!includeField.test(property.getFieldName())) {
437+
boolean hasCompositeProperties = false;
438+
for (GraphPropertyDescription graphProperty : nodeDescription.getGraphPropertiesInHierarchy()) {
439+
440+
Neo4jPersistentProperty property = (Neo4jPersistentProperty) graphProperty;
441+
hasCompositeProperties = hasCompositeProperties || property.isComposite();
442+
443+
if (!includeField.test(property.getFieldName()) || property.isDynamicLabels() || property.isComposite()) {
439444
continue;
440445
}
441446

442-
if (!((Neo4jPersistentProperty) property).isDynamicLabels()) {
443-
nodePropertiesProjection.add(property.getPropertyName());
444-
}
447+
nodePropertiesProjection.add(graphProperty.getPropertyName());
448+
}
449+
450+
if (hasCompositeProperties) {
451+
nodePropertiesProjection.add(Constants.NAME_OF_ALL_PROPERTIES);
452+
nodePropertiesProjection.add(node.project(Cypher.asterisk()));
445453
}
446454

447455
nodePropertiesProjection.add(Constants.NAME_OF_LABELS);

src/main/java/org/springframework/data/neo4j/core/mapping/DefaultNeo4jConversionService.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
import org.springframework.core.convert.support.ConfigurableConversionService;
2828
import org.springframework.core.convert.support.DefaultConversionService;
2929
import org.springframework.dao.TypeMismatchDataAccessException;
30+
import org.springframework.data.neo4j.core.convert.Neo4jConversionService;
3031
import org.springframework.data.neo4j.core.convert.Neo4jConversions;
3132
import org.springframework.data.util.TypeInformation;
3233
import org.springframework.lang.Nullable;
@@ -69,6 +70,7 @@ public Object readValue(@Nullable Value source, TypeInformation<?> targetType,
6970
BiFunction<Value, Class<?>, Object> conversion = conversionOverride == null ?
7071
(v, t) -> conversionService.convert(v, t) :
7172
(v, t) -> conversionOverride.apply(v);
73+
7274
return readValueImpl(source, targetType, conversion);
7375
}
7476

src/main/java/org/springframework/data/neo4j/core/mapping/DefaultNeo4jEntityConverter.java

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
import org.springframework.data.mapping.PropertyHandler;
5151
import org.springframework.data.mapping.model.EntityInstantiators;
5252
import org.springframework.data.mapping.model.ParameterValueProvider;
53+
import org.springframework.data.neo4j.core.convert.Neo4jConversionService;
5354
import org.springframework.data.neo4j.core.schema.TargetNode;
5455
import org.springframework.data.util.TypeInformation;
5556
import org.springframework.lang.NonNull;
@@ -150,8 +151,12 @@ public void write(Object source, Map<String, Object> parameters) {
150151
return;
151152
}
152153

153-
final Object value = conversionService.writeValue(propertyAccessor.getProperty(p), p.getTypeInformation(), p.getOptionalWritingConverter());
154-
properties.put(p.getPropertyName(), value);
154+
final Value value = conversionService.writeValue(propertyAccessor.getProperty(p), p.getTypeInformation(), p.getOptionalWritingConverter());
155+
if (p.isComposite()) {
156+
value.keys().forEach(k -> properties.put(k, value.get(k)));
157+
} else {
158+
properties.put(p.getPropertyName(), value);
159+
}
155160
});
156161

157162
parameters.put(Constants.NAME_OF_PROPERTIES_PARAM, properties);
@@ -518,7 +523,6 @@ private Optional<Object> createInstanceOfRelationships(Neo4jPersistentProperty p
518523
return Optional.ofNullable(value.isEmpty() ? null : value.get(0));
519524
}
520525
}
521-
522526
}
523527

524528
private MapAccessor extractNextNodeAndAppendPath(Node possibleValueNode, List<Path> allPaths) {
@@ -534,12 +538,30 @@ private static Value extractValueOf(Neo4jPersistentProperty property, MapAccesso
534538
if (property.isInternalIdProperty()) {
535539
return propertyContainer instanceof Node ? Values.value(((Node) propertyContainer).id())
536540
: propertyContainer.get(Constants.NAME_OF_INTERNAL_ID);
541+
} else if (property.isComposite()) {
542+
String prefix = property.computePrefixWithDelimiter();
543+
544+
if (propertyContainer.containsKey(Constants.NAME_OF_ALL_PROPERTIES)) {
545+
return extractCompositePropertyValues(propertyContainer.get(Constants.NAME_OF_ALL_PROPERTIES), prefix);
546+
} else {
547+
return extractCompositePropertyValues(propertyContainer, prefix);
548+
}
537549
} else {
538550
String graphPropertyName = property.getPropertyName();
539551
return propertyContainer.get(graphPropertyName);
540552
}
541553
}
542554

555+
private static Value extractCompositePropertyValues(MapAccessor propertyContainer, String prefix) {
556+
Map<String, Value> hlp = new HashMap<>(propertyContainer.size());
557+
propertyContainer.keys().forEach(k -> {
558+
if (k.startsWith(prefix)) {
559+
hlp.put(k, propertyContainer.get(k));
560+
}
561+
});
562+
return Values.value(hlp);
563+
}
564+
543565
static class KnownObjects {
544566

545567
private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock();

src/main/java/org/springframework/data/neo4j/core/mapping/DefaultNeo4jPersistentProperty.java

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
import org.springframework.data.mapping.model.Property;
2828
import org.springframework.data.mapping.model.SimpleTypeHolder;
2929
import org.springframework.data.neo4j.core.convert.Neo4jPersistentPropertyConverter;
30+
import org.springframework.data.neo4j.core.schema.CompositeProperty;
3031
import org.springframework.data.neo4j.core.schema.Relationship;
3132
import org.springframework.data.neo4j.core.schema.RelationshipProperties;
3233
import org.springframework.data.neo4j.core.schema.TargetNode;
@@ -71,7 +72,7 @@ final class DefaultNeo4jPersistentProperty extends AnnotationBasedPersistentProp
7172

7273
Class<?> targetType = getActualType();
7374
return !(simpleTypeHolder.isSimpleType(targetType) || this.mappingContext.hasCustomWriteTarget(targetType)
74-
|| isAnnotationPresent(TargetNode.class));
75+
|| isAnnotationPresent(TargetNode.class) || isComposite());
7576
});
7677

7778
this.customConversion = Lazy.of(() -> {
@@ -182,7 +183,7 @@ public boolean isAssociation() {
182183

183184
@Override
184185
public boolean isEntity() {
185-
return super.isEntity() && isAssociation() || (super.isEntity() && isEntityInRelationshipWithProperties());
186+
return super.isEntity() && isAssociation() || (super.isEntity() && isEntityInRelationshipWithProperties() && !isComposite());
186187
}
187188

188189
private static Function<Object, Value> nullSafeWrite(Function<Object, Value> delegate) {
@@ -260,6 +261,12 @@ public boolean isRelationship() {
260261
return isAssociation();
261262
}
262263

264+
@Override
265+
public boolean isComposite() {
266+
267+
return isAnnotationPresent(CompositeProperty.class);
268+
}
269+
263270
static String deriveRelationshipType(String name) {
264271

265272
Assert.hasText(name, "The name to derive the type from is required.");

0 commit comments

Comments
 (0)