Skip to content

Commit

Permalink
Add @properties annotation to map properties dynamically
Browse files Browse the repository at this point in the history
  • Loading branch information
frant-hartm committed May 18, 2017
1 parent fd49369 commit e7b7b51
Show file tree
Hide file tree
Showing 13 changed files with 936 additions and 4 deletions.
57 changes: 57 additions & 0 deletions core/src/main/java/org/neo4j/ogm/annotation/Properties.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/*
* Copyright (c) 2002-2017 "Neo Technology,"
* Network Engine for Objects in Lund AB [http://neotechnology.com]
*
* This product is licensed to you under the Apache License, Version 2.0 (the "License").
* You may not use this product except in compliance with the License.
*
* This product may include a number of subcomponents with
* separate copyright notices and license terms. Your use of the source
* code for these subcomponents is subject to the terms and
* conditions of the subcomponent's license, as noted in the LICENSE file.
*/

package org.neo4j.ogm.annotation;

import java.lang.annotation.*;

/**
* Tells OGM to map values of a Map field in a node or relationship entity to properties of a node or a relationship
* in the graph.
* <p>
* The property names are derived from field name or {@link #prefix()}, delimiter and keys in the Map. If the delimiter,
* prefix or keys conflict with other field names in the class the behaviour is not defined.
* <p>
* Supported types for keys in the Map are String and Enum.
* <p>
* The values in the Map can be of any Java type equivalent to Cypher types. If full type information is provided other
* Java types are also supported.
* <p>
* If {@link #allowCast()} is set to true then types that can be cast to corresponding Cypher types are allowed as well.
* Note that the original type cannot be deduced and the value will be deserialized to corresponding type - e.g.
* when Integer instance is put to {@code Map<String, Object>} it will be deserialized as Long.
*
* @author Frantisek Hartman
* @since 3.0
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
@Inherited
public @interface Properties {

/**
* Prefix for mapped properties, if not set the field name is used
*/
String prefix() default "";

/**
* Delimiter to use in the property names
*/
String delimiter() default ".";


/**
* If values in the Map that do not have supported Cypher type should be allowed to be cast to Cypher types
*/
boolean allowCast() default false;
}
20 changes: 20 additions & 0 deletions core/src/main/java/org/neo4j/ogm/metadata/FieldInfo.java
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,20 @@

import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;
import java.util.Map;

import org.neo4j.ogm.annotation.Index;
import org.neo4j.ogm.annotation.Labels;
import org.neo4j.ogm.annotation.Property;
import org.neo4j.ogm.annotation.Properties;
import org.neo4j.ogm.annotation.Relationship;
import org.neo4j.ogm.exception.MappingException;
import org.neo4j.ogm.session.Utils;
import org.neo4j.ogm.typeconversion.AttributeConverter;
import org.neo4j.ogm.typeconversion.CompositeAttributeConverter;
import org.neo4j.ogm.typeconversion.MapCompositeConverter;
import org.neo4j.ogm.utils.ClassUtils;
import org.neo4j.ogm.utils.RelationshipUtils;

Expand Down Expand Up @@ -105,6 +110,21 @@ public FieldInfo(ClassInfo classInfo, Field field, String typeParameterDescripto
throw new IllegalStateException(String.format(
"The converter for field %s is neither an instance of AttributeConverter or CompositeAttributeConverter",
this.name));
} else {
AnnotationInfo properties = getAnnotations().get(Properties.class);
if (properties != null) {
if (fieldType.equals(Map.class)) {
Type fieldGenericType = field.getGenericType();
MapCompositeConverter mapCompositeConverter = new MapCompositeConverter(
properties.get("prefix", field.getName()),
properties.get("delimiter"),
Boolean.valueOf(properties.get("allowCast")),
(ParameterizedType) fieldGenericType);
setCompositeConverter(mapCompositeConverter);
} else {
throw new MappingException("@Properties annotation is allowed only on fields of type java.util.Map");
}
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
/*
* Copyright (c) 2002-2017 "Neo Technology,"
* Network Engine for Objects in Lund AB [http://neotechnology.com]
*
* This product is licensed to you under the Apache License, Version 2.0 (the "License").
* You may not use this product except in compliance with the License.
*
* This product may include a number of subcomponents with
* separate copyright notices and license terms. Your use of the source
* code for these subcomponents is subject to the terms and
* conditions of the subcomponent's license, as noted in the LICENSE file.
*/

package org.neo4j.ogm.typeconversion;

import org.neo4j.ogm.exception.MappingException;
import org.neo4j.ogm.session.Utils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;
import java.util.*;

import static java.util.Collections.emptyMap;
import static java.util.stream.Collectors.toSet;

/**
* MapCompositeConverter converts Map field into prefixed properties of node or relationship entity.
* <p>
* The prefix and delimiter is configurable.
*
* @author Frantisek Hartman
*/
public class MapCompositeConverter implements CompositeAttributeConverter<Map<?, ?>> {

private static final Logger LOGGER = LoggerFactory.getLogger(MapCompositeConverter.class);
private static final Set<Class> cypherTypes;

static {
Set<Class> types = new HashSet<>();
types.add(Boolean.class);
types.add(Long.class);
types.add(Double.class);
types.add(String.class);
types.add(List.class);
cypherTypes = Collections.unmodifiableSet(types);
}

private final String prefix;
private final String delimiter;
private final boolean allowCast;

private ParameterizedType mapFieldType;
private String firstPart;

/**
* Create MapCompositeConverter
*
* @param prefix prefix that is used for all properties
* @param delimiter delimiter that is used between prefix, properties and nested properties
* @param allowCast
* @param mapFieldType type information for the field
*/
public MapCompositeConverter(String prefix, String delimiter, boolean allowCast, ParameterizedType mapFieldType) {
this.prefix = prefix;
this.delimiter = delimiter;
this.allowCast = allowCast;
this.mapFieldType = mapFieldType;
firstPart = prefix + delimiter;
}

@Override
public Map<String, ?> toGraphProperties(Map<?, ?> fieldValue) {
if (fieldValue == null) {
return emptyMap();
}
Map<String, Object> graphProperties = new HashMap<>(fieldValue.size());
addMapToProperties(fieldValue, graphProperties, firstPart);
return graphProperties;
}

private void addMapToProperties(Map<?, ?> fieldValue, Map<String, Object> graphProperties, String prefix) {
for (Map.Entry<?, ?> entry : fieldValue.entrySet()) {
Object entryValue = entry.getValue();
if (entryValue instanceof Map) {
addMapToProperties((Map<?, ?>) entryValue, graphProperties, prefix + entry.getKey() + delimiter);
} else {
if (isCypherType(entryValue) ||
(allowCast && canCastType(entryValue))) {

graphProperties.put(prefix + entry.getKey().toString(), entryValue);
} else {
throw new MappingException("Could not map value " + entryValue + " because it is not supported type.");

This comment has been minimized.

Copy link
@nmervaillie

nmervaillie May 22, 2017

Member

add a bit more context information (type & prefix/key) ?

}
}
}
}

private boolean canCastType(Object value) {

return true;
}

private boolean isCypherType(Object entryValue) {
return cypherTypes.contains(entryValue.getClass()) || List.class.isAssignableFrom(entryValue.getClass());
}

@Override
public Map<?, ?> toEntityAttribute(Map<String, ?> value) {

Set<? extends Map.Entry<String, ?>> prefixedProperties = value.entrySet()
.stream()
.filter(entry -> entry.getKey().startsWith(firstPart))
.collect(toSet());

Map<Object, Object> result = new HashMap<>();
for (Map.Entry<String, ?> entry : prefixedProperties) {
String propertyKey = entry.getKey().substring(firstPart.length());
putToMap(result, propertyKey, entry.getValue(), mapFieldType);
}
return result;
}

private void putToMap(Map<Object, Object> result, String propertyKey, Object value, Type fieldType) {
if (propertyKey.contains(delimiter)) {
int delimiterIndex = propertyKey.indexOf(delimiter);
String key = propertyKey.substring(0, delimiterIndex);

Object keyInstance = keyInstanceFromString(key, getKeyType(fieldType));
Map<Object, Object> o = (Map<Object, Object>) result.get(key);
if (o == null) {
o = new HashMap<>();
result.put(keyInstance, o);
}
putToMap(o, propertyKey.substring(delimiterIndex + delimiter.length()), value, nestedFieldType(fieldType));
} else {
Object keyInstance = keyInstanceFromString(propertyKey, getKeyType(fieldType));

Type valueType = nestedFieldType(fieldType);
if (valueType != null) {
result.put(keyInstance, Utils.coerceTypes((Class) valueType, value));
} else {
result.put(keyInstance, value);
}
}
}

private Class<?> getKeyType(Type fieldType) {
if (fieldType instanceof ParameterizedType) {
return (Class<?>) ((ParameterizedType) fieldType).getActualTypeArguments()[0];
} else {
return null;
}
}

private Type nestedFieldType(Type keyType) {
if (keyType instanceof ParameterizedType) {
return ((ParameterizedType) keyType).getActualTypeArguments()[1];
} else {
return null;
}
}

private Object keyInstanceFromString(String propertyKey, Class<?> keyType) {
if (keyType == null) {
return propertyKey;
} else if (keyType.equals(String.class)) {
return propertyKey;
} else if (keyType.isEnum()) {
try {
return keyType.getDeclaredMethod("valueOf", String.class).invoke(keyType, propertyKey);
} catch (IllegalAccessException | InvocationTargetException | NoSuchMethodException e) {
throw new RuntimeException("Should not happen", e);
}
} else {
throw new UnsupportedOperationException("convertible keys not implemented yet");
}
}
}
35 changes: 35 additions & 0 deletions neo4j-ogm-docs/src/main/asciidoc/reference/annotations.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,41 @@ In this case, a graph similar to the following would be persisted.
While this will map successfully to the database, it's important to understand that the names of the properties and relationship types are tightly coupled to the class's member names.
Renaming any of these fields will cause parts of the graph to map incorrectly, hence the recommendation to use annotations.

[[reference:annotating-entities:node-entity:dynamic-properties]]
=== @Properties: dynamically mapping properties to graph


A `@Properties` annotation tells OGM to map values of a Map field in a node or relationship entity to properties of
a node or a relationship in the graph.

The property names are derived from field name or {@link #prefix()}, delimiter and keys in the Map.

This comment has been minimized.

Copy link
@nmervaillie

nmervaillie May 22, 2017

Member

maybe give an example here of how properties would be mapped in the db


Supported types for keys in the Map are String and Enum.

The values in the Map can be of any Java type equivalent to Cypher types. If full type information is provided other
Java types are also supported.

If {@link #allowCast()} is set to true then types that can be cast to corresponding Cypher types are allowed as well.
Note that the original type cannot be deduced and the value will be deserialized to corresponding type - e.g.
when Integer instance is put to {@code Map<String, Object>} it will be deserialized as Long.

[source, java]
----
@NodeEntity
public class Student {
@Properties
private Map<String, Integer> properties = new HashMap<>();
@Properties
private Map<String, Object> properties = new HashMap<>();
}
----




[[reference:annotating-entities:node-entity:runtime-managed-labels]]
=== Runtime managed labels

Expand Down
7 changes: 7 additions & 0 deletions test/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,13 @@
<scope>compile</scope>
</dependency>

<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.2.0</version>
<scope>compile</scope>
</dependency>

<dependency>
<groupId>org.neo4j.test</groupId>
<artifactId>neo4j-harness</artifactId>
Expand Down

0 comments on commit e7b7b51

Please sign in to comment.