diff --git a/.travis.yml b/.travis.yml index f32177201..62282cafb 100644 --- a/.travis.yml +++ b/.travis.yml @@ -4,8 +4,6 @@ jdk: - oraclejdk8 env: matrix: - - PROFILE=ci - - PROFILE=spring41-next - PROFILE=spring42 - PROFILE=spring42-next - PROFILE=spring43-next diff --git a/pom.xml b/pom.xml index fdfcb2ad0..89593f87d 100644 --- a/pom.xml +++ b/pom.xml @@ -17,7 +17,7 @@ DATAREDIS - 1.12.0.BUILD-SNAPSHOT + 1.1.0.BUILD-SNAPSHOT 1.1 1.9.2 1.4.8 @@ -53,8 +53,8 @@ org.springframework.data - spring-data-commons - ${springdata.commons} + spring-data-keyvalue + ${springdata.keyvalue} diff --git a/src/asciidoc/reference/redis-repositories.adoc b/src/asciidoc/reference/redis-repositories.adoc new file mode 100644 index 000000000..78bfa084b --- /dev/null +++ b/src/asciidoc/reference/redis-repositories.adoc @@ -0,0 +1,521 @@ +[[redis.repositories]] += Redis Repositories + +Working with Redis Repositories allows to seamlessly convert and store domain objects in Redis Hashes, apply custom mapping strategies and make use of secondary indexes. + +WARNING: Redis Repositories requires at least Redis Server version 2.8.0. + +[[redis.repositories.usage]] +== Usage + +To access domain entities stored in a Redis you can leverage repository support that eases implementing those quite significantly. + +.Sample Person Entity +==== +[source,java] +---- +@RedisHash("persons"); +public class Person { + + @Id String id; + String firstname; + String lastname; + Address address; +} +---- +==== + +We have a pretty simple domain object here. Note that it has a property named `id` annotated with `org.springframework.data.annotation.Id` and a `@RedisHash` annotation on its type. +Those two are responsible for creating the actual key used to persist the hash. + +To now actually have a component responsible for storage and retrieval we need to define a repository interface. + +.Basic Repository Interface To Persist Person Entities +==== +[source,java] +---- +public interface PersonRepository extends CrudRepository { + +} +---- +==== + +As our repository extends `CrudRepository` it provides basic CRUD and finder operations. The thing we need in between to glue things together is the according Spring configuration. + +.JavaConfig for Redis Repositories +==== +[source,java] +---- +@Configuration +@EnableRedisRepositories +public class ApplicationConfig { + + @Bean + public RedisConnectionFactory connectionFactory() { + return new JedisConnectionFactory(); + } + + @Bean + public RedisTemplate redisTemplate() { + + RedisTemplate template = new RedisTemplate(); + return template; + } +} +---- +==== + +Given the setup above we can go on and inject `PersonRepository` into our components. + +.Access to Person Entities +==== +[source,java] +---- +@Autowired PersonRepository repo; + +public void basicCrudOperations() { + + Person rand = new Person("rand", "al'thor"); + rand.setAddress(new Address("emond's field", "andor")); + + repo.save(rand); <1> + + repo.findOne(rand.getId()); <2> + + repo.count(); <3> + + repo.delete(rand); <4> +} +---- +<1> Generates a new id if current value is `null` or reuses an already set id value and stores properties of type `Person` inside the Redis Hash with key with pattern `keyspace:id` in this case eg. `persons:5d67b7e1-8640-4475-beeb-c666fab4c0e5`. +<2> Uses the provided id to retrieve the object stored at `keyspace:id`. +<3> Counts the total number of entities available within the keyspace _persons_ defined by `@RedisHash` on `Person`. +<4> Removes the key for the given object from Redis. +==== + +[[redis.repositories.mapping]] +== Object to Hash Mapping +The Redis Repository support persists Objects in Hashes. This requires an Object to Hash conversion which is done by a `RedisConverter`. The default implementation uses `Converter` for mapping property values to and from Redis native `byte[]`. + +Given the `Person` type from the previous sections the default mapping looks like the following: + +==== +[source,text] +---- +_class = org.example.Person <1> +id = e2c7dcee-b8cd-4424-883e-736ce564363e +firstname = rand <2> +lastname = al’thor +address.city = emond's field <3> +address.country = andor +---- +<1> The `_class` attribute is included on root level as well as on any nested interface or abstract types. +<2> Simple property values are mapped by path. +<3> Properties of complex types are mapped by their dot path. +==== + +[cols="1,2,3", options="header"] +.Default Mapping Rules +|=== +| Type +| Sample +| Mapped Value + +| Simple Type + +(eg. String) +| String firstname = "rand"; +| firstname = "rand" + +| Complex Type + +(eg. Address) +| Address adress = new Address("emond's field"); +| address.city = "emond's field" + +| List + +of Simple Type +| List nicknames = asList("dragon reborn", "lews therin"); +| nicknames.[0] = "dragon reborn", + +nicknames.[1] = "lews therin" + +| Map + +of Simple Type +| Map atts = asMap({"eye-color", "grey"}, {"... +| atts.[eye-color] = "grey", + +atts.[hair-color] = "... + +| List + +of Complex Type +| List
addresses = asList(new Address("em... +| addresses.[0].city = "emond's field", + +addresses.[1].city = "... + +| Map + +of Complex Type +| Map addresses = asMap({"home", new Address("em... +| addresses.[home].city = "emond's field", + +addresses.[work].city = "... +|=== + +Mapping behavior can be customized by registering the according `Converter` in `CustomConversions`. Those converters can take care of converting from/to a single `byte[]` as well as `Map` whereas the first one is suitable for eg. converting one complex type to eg. a binary JSON representation that still uses the default mappings hash structure, whereas the second options offers full control over the resulting hash. + +.Sample byte[] Converters +==== +[source,java] +---- +@WritingConverter +public class AddressToBytesConverter implements Converter { + + private final Jackson2JsonRedisSerializer
serializer; + + public AddressToBytesConverter() { + + serializer = new Jackson2JsonRedisSerializer
(Address.class); + serializer.setObjectMapper(new ObjectMapper()); + } + + @Override + public byte[] convert(Address value) { + return serializer.serialize(value); + } +} + +@ReadingConverter +public class BytesToAddressConverter implements Converter { + + private final Jackson2JsonRedisSerializer
serializer; + + public BytesToAddressConverter() { + + serializer = new Jackson2JsonRedisSerializer
(Address.class); + serializer.setObjectMapper(new ObjectMapper()); + } + + @Override + public Address convert(byte[] value) { + return serializer.deserialize(value); + } +} +---- +==== + +Using the above byte[] `Converter` produces eg. +==== +[source,text] +---- +_class = org.example.Person +id = e2c7dcee-b8cd-4424-883e-736ce564363e +firstname = rand +lastname = al’thor +address = { city : "emond's field", country : "andor" } +---- +==== + + +.Sample Map Converters +==== +[source,java] +---- +@WritingConverter +public class AddressToMapConverter implements Converter> { + + @Override + public Map convert(Address source) { + return singletonMap("ciudad", source.getCity().getBytes()); + } +} + +@ReadingConverter +public class MapToAddressConverter implements Converter> { + + @Override + public Address convert(Map source) { + return new Address(new String(source.get("ciudad"))); + } +} +---- +==== + +Using the above Map `Converter` produces eg. + +==== +[source,text] +---- +_class = org.example.Person +id = e2c7dcee-b8cd-4424-883e-736ce564363e +firstname = rand +lastname = al’thor +ciudad = "emond's field" +---- +==== + +NOTE: Custom conversions have no effect on index resolution. <> will still be created even for custom converted types. + +[[redis.repositories.keyspaces]] +== Keyspaces +Keyspaces define prefixes used to create the actual _key_ for the Redis Hash. +By default the prefix is set to `getClass().getName()`. This default can be altered via `@RedisHash` on aggregate root level or by setting up a programmatic configuration. However, the annotated keyspace supersedes any other configuration. + +.Keyspace Setup via @EnableRedisRepositories +==== +[source,java] +---- +@Configuration +@EnableRedisRepositories(keyspaceConfiguration = MyKeyspaceConfiguration.class) +public class ApplicationConfig { + + //... RedisConnectionFactory and RedisTemplate Bean definitions omitted + + public static class MyKeyspaceConfiguration extends KeyspaceConfiguration { + + @Override + protected Iterable initialConfiguration() { + return Collections.singleton(new KeyspaceSettings(Person.class, "persons")); + } + } +} +---- +==== + +.Programmatic Keyspace setup +==== +[source,java] +---- +@Configuration +@EnableRedisRepositories +public class ApplicationConfig { + + //... RedisConnectionFactory and RedisTemplate Bean definitions omitted + + @Bean + public RedisMappingContext keyValueMappingContext() { + return new RedisMappingContext( + new MappingConfiguration( + new MyKeyspaceConfiguration(), new IndexConfiguration())); + } + + public static class MyKeyspaceConfiguration extends KeyspaceConfiguration { + + @Override + protected Iterable initialConfiguration() { + return Collections.singleton(new KeyspaceSettings(Person.class, "persons")); + } + } +} +---- +==== + +[[redis.repositories.indexes]] +== Secondary Indexes +http://redis.io/topics/indexes[Secondary indexes] are used to enable lookup operations based on native redis structures. Values are written to the according indexes on every save and are removed when objects are deleted or <>. + +Given the sample `Person` entity we can create an index for _firstname_ by annotating the property with `@Indexed`. + +.Annotation driven indexing +==== +[source,java] +---- +@RedisHash("persons"); +public class Person { + + @Id String id; + @Indexed String firstname; + String lastname; + Address address; +} +---- +==== + +Indexes are built up for actual property values. Saving two Persons eg. "rand" and "aviendha" results in setting up indexes like below. + +==== +[source,text] +---- +SADD persons:firstname:rand e2c7dcee-b8cd-4424-883e-736ce564363e +SADD persons:firstname:aviendha a9d4b3a0-50d3-4538-a2fc-f7fc2581ee56 +---- +==== + +It is also possible to have indexes on nested elements. Assume `Address` has a _city_ property that is annotated with `@Indexed`. In that case, once `person.address.city` is not `null`, we have Sets for each city. + +==== +[source,text] +---- +SADD persons:address.city:tear e2c7dcee-b8cd-4424-883e-736ce564363e +---- +==== + +Further more the programmatic setup allows to define indexes on map keys and list properties. + +==== +[source,java] +---- +@RedisHash("persons"); +public class Person { + + // ... other properties omitted + + Map attributes; <1> + Map relatives; <2> + List
addresses; <3> +} +---- +<1> `SADD persons:attributes.map-key:map-value e2c7dcee-b8cd-4424-883e-736ce564363e` +<2> `SADD persons:relatives.map-key.firstname:tam e2c7dcee-b8cd-4424-883e-736ce564363e` +<3> `SADD persons:addresses.city:tear e2c7dcee-b8cd-4424-883e-736ce564363e` +==== + +WARNING: Indexes will not be resolved on <>. + +Same as with _keyspaces_ it is possible to configure indexes without the need of annotating the actual domain type. + +.Index Setup via @EnableRedisRepositories +==== +[source,java] +---- +@Configuration +@EnableRedisRepositories(indexConfiguration = MyIndexConfiguration.class) +public class ApplicationConfig { + + //... RedisConnectionFactory and RedisTemplate Bean definitions omitted + + public static class MyIndexConfiguration extends IndexConfiguration { + + @Override + protected Iterable initialConfiguration() { + return Collections.singleton(new RedisIndexSetting("persons", "firstname")); + } + } +} +---- +==== + +.Programmatic Index setup +==== +[source,java] +---- +@Configuration +@EnableRedisRepositories +public class ApplicationConfig { + + //... RedisConnectionFactory and RedisTemplate Bean definitions omitted + + @Bean + public RedisMappingContext keyValueMappingContext() { + return new RedisMappingContext( + new MappingConfiguration( + new KeyspaceConfiguration(), new MyIndexConfiguration())); + } + + public static class MyIndexConfiguration extends IndexConfiguration { + + @Override + protected Iterable initialConfiguration() { + return Collections.singleton(new RedisIndexSetting("persons", "firstname")); + } + } +} +---- +==== + + +[[redis.repositories.expirations]] +== Time To Live +Objects stored in Redis may only be valid for a certain amount of time. This is especially useful for persisting short lived objects in Redis without having to remove them manually when they reached their end of life. +The expiration time in seconds can be set via `@RedisHash(timeToLive=...)` as well as via `KeyspaceSettings` (see <>). + +More flexible expiration times can be set by using the `@TimeToLive` annotation on either a numeric property or method. However do not apply `@TimeToLive` on both a method and a property within the same class. + +.Expirations +==== +[source,java] +---- +public class TimeToLiveOnProperty { + + @Id + private String id; + + @TimeToLive + private Long expiration; +} + +public class TimeToLiveOnMethod { + + @Id + private String id; + + @TimeToLive + public long getTimeToLive() { + return new Random().nextLong(); + } +} +---- +==== + + +The repository implementation ensures subscription to http://redis.io/topics/notifications[Redis keyspace notifications] via `RedisMessageListenerContainer`. + +When the expiration is set to a positive value the according `EXPIRE` command is executed. +Additionally to persisting the original, a _phantom_ copy is persisted in Redis and set to expire 5 minutes after the original one. This done to enable the Repository support to publish `RedisKeyExpiredEvent` holding the expired value via Springs `ApplicationEventPublisher` whenever a key expires even though the original values have already been gone. + +The `RedisKeyExpiredEvent` will hold a copy of the actually expired domain object as well as the key. + +NOTE: The keyspace notification message listener will alter `notify-keyspace-events` settings in Redis if those are not already set. Existing settings will not be overridden so it is left to the user to set those up correctly when not leaving them empty. + +[[redis.repositories.references]] +== Persisting References +Marking properties with `@Reference` allows to store a simple key reference instead of copying the all values into the hash itself. +On loading from Redis references are resolved automatically and mapped back into the object. + +.Sample Property Reference +==== +[source,text] +---- +_class = org.example.Person +id = e2c7dcee-b8cd-4424-883e-736ce564363e +firstname = rand +lastname = al’thor +mother = persons:a9d4b3a0-50d3-4538-a2fc-f7fc2581ee56 <1> +---- +<1> Reference stores the whole key (`keyspace:id`) of the referenced object. +==== + +WARNING: Referenced Objects are not subject of persisting changes when saving the referencing object. Please make sure to persist changes on referenced objects separately, since only the reference will be stored. +Indexes set on properties of referenced types will not be resolved. + +[[redis.repositories.queries]] +== Queries and Query Methods +Query methods allow automatic derivation of simple finder queries from the method name. + +.Sample Repository finder Method +==== +[source,java] +---- +public interface PersonRepository extends CrudRepository { + + List findByFirstname(String firstname); +} +---- +==== + +NOTE: Please make sure properties used in finder methods are set up for indexing. + +Using derived finder methods might not always be sufficient to model the queries to execute. `RedisCallback` offers more control over the actual matching of index structures or even customly added ones. All it takes is providing a `RedisCallback` that returns a single or `Iterable` set of _id_ values. + +.Sample finder using RedisCallback +==== +[source,java] +---- +String user = //... + +List sessionsByUser = template.find(new RedisCallback>() { + + public Set doInRedis(RedisConnection connection) throws DataAccessException { + return connection + .sMembers("sessions:securityContext.authentication.principal.username:" + user); + }}, RedisSession.class); +---- +==== + + + + diff --git a/src/main/asciidoc/index.adoc b/src/main/asciidoc/index.adoc index 53ebd3bbd..4fd3f8ac0 100644 --- a/src/main/asciidoc/index.adoc +++ b/src/main/asciidoc/index.adoc @@ -34,6 +34,7 @@ include::introduction/getting-started.adoc[] include::reference/introduction.adoc[] include::reference/redis.adoc[] include::reference/redis-cluster.adoc[] +include::reference/redis-repositories.adoc[] :leveloffset: -1 [[appendixes]] diff --git a/src/main/java/org/springframework/data/redis/connection/lettuce/LettuceConverters.java b/src/main/java/org/springframework/data/redis/connection/lettuce/LettuceConverters.java index 675568dad..9d57b17c2 100644 --- a/src/main/java/org/springframework/data/redis/connection/lettuce/LettuceConverters.java +++ b/src/main/java/org/springframework/data/redis/connection/lettuce/LettuceConverters.java @@ -150,7 +150,7 @@ abstract public class LettuceConverters extends Converters { public Map convert(final List source) { if (CollectionUtils.isEmpty(source)) { - Collections.emptyMap(); + return Collections.emptyMap(); } Map target = new LinkedHashMap(); diff --git a/src/main/java/org/springframework/data/redis/core/IndexWriter.java b/src/main/java/org/springframework/data/redis/core/IndexWriter.java new file mode 100644 index 000000000..ad7e4a61f --- /dev/null +++ b/src/main/java/org/springframework/data/redis/core/IndexWriter.java @@ -0,0 +1,183 @@ +/* + * Copyright 2015 the original author or authors. + * + * Licensed 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.springframework.data.redis.core; + +import java.util.Set; + +import org.springframework.data.redis.connection.RedisConnection; +import org.springframework.data.redis.core.convert.IndexedData; +import org.springframework.data.redis.core.convert.RedisConverter; +import org.springframework.data.redis.core.convert.SimpleIndexedPropertyValue; +import org.springframework.data.redis.util.ByteUtils; +import org.springframework.util.Assert; +import org.springframework.util.CollectionUtils; + +/** + * {@link IndexWriter} takes care of writing secondary index structures to + * Redis. Depending on the type of {@link IndexedData} it uses eg. Sets with specific names to add actually referenced + * keys to. While doing so {@link IndexWriter} also keeps track of all indexes associated with the root types key, which + * allows to remove the root key from all indexes in case of deletion. + * + * @author Christoph Strobl + * @since 1.7 + */ +class IndexWriter { + + private final RedisConnection connection; + private final RedisConverter converter; + + /** + * Creates new {@link IndexWriter}. + * + * @param keyspace The key space to write index values to. Must not be {@literal null}. + * @param connection must not be {@literal null}. + * @param converter must not be {@literal null}. + */ + public IndexWriter(RedisConnection connection, RedisConverter converter) { + + Assert.notNull(connection, "RedisConnection cannot be null!"); + Assert.notNull(converter, "RedisConverter cannot be null!"); + + this.connection = connection; + this.converter = converter; + } + + /** + * Updates indexes by first removing key from existing one and then persisting new index data. + * + * @param key must not be {@literal null}. + * @param indexValues can be {@literal null}. + */ + public void updateIndexes(Object key, Iterable indexValues) { + + Assert.notNull(key, "Key must not be null!"); + if (indexValues == null) { + return; + } + + byte[] binKey = toBytes(key); + + removeKeyFromExistingIndexes(binKey, indexValues); + addKeyToIndexes(binKey, indexValues); + } + + /** + * Removes a key from all available indexes. + * + * @param key must not be {@literal null}. + */ + public void removeKeyFromIndexes(String keyspace, Object key) { + + Assert.notNull(key, "Key must not be null!"); + + byte[] binKey = toBytes(key); + byte[] indexHelperKey = ByteUtils.concatAll(toBytes(keyspace + ":"), binKey, toBytes(":idx")); + + for (byte[] indexKey : connection.sMembers(indexHelperKey)) { + connection.sRem(indexKey, binKey); + } + + connection.del(indexHelperKey); + } + + /** + * Removes all indexes. + */ + public void removeAllIndexes(String keyspace) { + + Set potentialIndex = connection.keys(toBytes(keyspace + ":*")); + + if (!potentialIndex.isEmpty()) { + connection.del(potentialIndex.toArray(new byte[potentialIndex.size()][])); + } + } + + private void removeKeyFromExistingIndexes(byte[] key, Iterable indexValues) { + + for (IndexedData indexData : indexValues) { + removeKeyFromExistingIndexes(key, indexData); + } + } + + /** + * Remove given key from all indexes matching {@link IndexedData#getPath()}: + * + * @param key + * @param indexedData + */ + protected void removeKeyFromExistingIndexes(byte[] key, IndexedData indexedData) { + + Assert.notNull(indexedData, "IndexedData must not be null!"); + Set existingKeys = connection.keys(toBytes(indexedData.getKeySpace() + ":" + indexedData.getPath() + ":*")); + + if (!CollectionUtils.isEmpty(existingKeys)) { + for (byte[] existingKey : existingKeys) { + connection.sRem(existingKey, key); + } + } + } + + private void addKeyToIndexes(byte[] key, Iterable indexValues) { + + for (IndexedData indexData : indexValues) { + addKeyToIndex(key, indexData); + } + } + + /** + * Adds a given key to the index for {@link IndexedData#getPath()}. + * + * @param key must not be {@literal null}. + * @param indexedData must not be {@literal null}. + */ + protected void addKeyToIndex(byte[] key, IndexedData indexedData) { + + Assert.notNull(key, "Key must not be null!"); + Assert.notNull(indexedData, "IndexedData must not be null!"); + + if (indexedData instanceof SimpleIndexedPropertyValue) { + + Object value = ((SimpleIndexedPropertyValue) indexedData).getValue(); + + if (value == null) { + return; + } + + byte[] indexKey = toBytes(indexedData.getKeySpace() + ":" + indexedData.getPath() + ":"); + indexKey = ByteUtils.concat(indexKey, toBytes(value)); + connection.sAdd(indexKey, key); + + // keep track of indexes used for the object + connection.sAdd(ByteUtils.concatAll(toBytes(indexedData.getKeySpace() + ":"), key, toBytes(":idx")), indexKey); + } else { + throw new IllegalArgumentException(String.format("Cannot write index data for unknown index type %s", + indexedData.getClass())); + } + } + + private byte[] toBytes(Object source) { + + if (source == null) { + return new byte[] {}; + } + + if (source instanceof byte[]) { + return (byte[]) source; + } + + return converter.getConversionService().convert(source, byte[].class); + } +} diff --git a/src/main/java/org/springframework/data/redis/core/RedisHash.java b/src/main/java/org/springframework/data/redis/core/RedisHash.java new file mode 100644 index 000000000..2bf460065 --- /dev/null +++ b/src/main/java/org/springframework/data/redis/core/RedisHash.java @@ -0,0 +1,59 @@ +/* + * Copyright 2015 the original author or authors. + * + * Licensed 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.springframework.data.redis.core; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.springframework.core.annotation.AliasFor; +import org.springframework.data.annotation.Persistent; +import org.springframework.data.keyvalue.annotation.KeySpace; + +/** + * {@link RedisHash} marks Objects as aggregate roots to be stored in a Redis hash. + * + * @author Christoph Strobl + * @since 1.7 + */ +@Persistent +@Documented +@Inherited +@Retention(RetentionPolicy.RUNTIME) +@Target(value = { ElementType.TYPE }) +@KeySpace +public @interface RedisHash { + + /** + * The prefix to distinguish between domain types. + * + * @return + * @see KeySpace + */ + @AliasFor(annotation = KeySpace.class, attribute = "value") + String value() default ""; + + /** + * Time before expire in seconds. Superseded by {@link TimeToLive}. + * + * @return positive number when expiration should be applied. + */ + long timeToLive() default -1L; + +} diff --git a/src/main/java/org/springframework/data/redis/core/RedisKeyExpiredEvent.java b/src/main/java/org/springframework/data/redis/core/RedisKeyExpiredEvent.java new file mode 100644 index 000000000..4e629c700 --- /dev/null +++ b/src/main/java/org/springframework/data/redis/core/RedisKeyExpiredEvent.java @@ -0,0 +1,98 @@ +/* + * Copyright 2015 the original author or authors. + * + * Licensed 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.springframework.data.redis.core; + +import java.nio.charset.Charset; + +import org.springframework.context.ApplicationEvent; +import org.springframework.data.redis.util.ByteUtils; + +/** + * {@link RedisKeyExpiredEvent} is Redis specific {@link ApplicationEvent} published when a specific key in Redis + * expires. It might but must not hold the expired value itself next to the key. + * + * @author Christoph Strobl + * @since 1.7 + */ +public class RedisKeyExpiredEvent extends RedisKeyspaceEvent { + + private final byte[][] args; + private final Object value; + + /** + * Creates new {@link RedisKeyExpiredEvent}. + * + * @param key + */ + public RedisKeyExpiredEvent(byte[] key) { + this(key, null); + } + + /** + * Creates new {@link RedisKeyExpiredEvent} + * + * @param key + * @param value + */ + public RedisKeyExpiredEvent(byte[] key, Object value) { + super(key); + + args = ByteUtils.split(key, ':'); + this.value = value; + } + + /** + * Gets the keyspace in which the expiration occured. + * + * @return {@literal null} if it could not be determined. + */ + public String getKeyspace() { + + if (args.length >= 2) { + return new String(args[0], Charset.forName("UTF-8")); + } + + return null; + } + + /** + * Get the expired objects id; + * + * @return + */ + public byte[] getId() { + return args.length == 2 ? args[1] : args[0]; + } + + /** + * Get the expired Object + * + * @return {@literal null} if not present. + */ + public Object getValue() { + return value; + } + + /* + * (non-Javadoc) + * @see java.util.EventObject#toString() + */ + @Override + public String toString() { + return "RedisKeyExpiredEvent [keyspace=" + getKeyspace() + ", id=" + getId() + "]"; + } + +} diff --git a/src/main/java/org/springframework/data/redis/core/RedisKeyValueAdapter.java b/src/main/java/org/springframework/data/redis/core/RedisKeyValueAdapter.java new file mode 100644 index 000000000..e8ec920b2 --- /dev/null +++ b/src/main/java/org/springframework/data/redis/core/RedisKeyValueAdapter.java @@ -0,0 +1,608 @@ +/* + * Copyright 2015 the original author or authors. + * + * Licensed 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.springframework.data.redis.core; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Set; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.DisposableBean; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationContextAware; +import org.springframework.context.ApplicationListener; +import org.springframework.core.convert.ConversionService; +import org.springframework.core.convert.ConverterNotFoundException; +import org.springframework.dao.DataAccessException; +import org.springframework.data.keyvalue.core.AbstractKeyValueAdapter; +import org.springframework.data.keyvalue.core.KeyValueAdapter; +import org.springframework.data.keyvalue.core.mapping.KeyValuePersistentProperty; +import org.springframework.data.redis.connection.Message; +import org.springframework.data.redis.connection.MessageListener; +import org.springframework.data.redis.connection.RedisConnection; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.convert.CustomConversions; +import org.springframework.data.redis.core.convert.IndexResolverImpl; +import org.springframework.data.redis.core.convert.KeyspaceConfiguration; +import org.springframework.data.redis.core.convert.MappingRedisConverter; +import org.springframework.data.redis.core.convert.RedisConverter; +import org.springframework.data.redis.core.convert.RedisData; +import org.springframework.data.redis.core.convert.ReferenceResolver; +import org.springframework.data.redis.core.mapping.RedisMappingContext; +import org.springframework.data.redis.listener.KeyExpirationEventMessageListener; +import org.springframework.data.redis.listener.RedisMessageListenerContainer; +import org.springframework.data.redis.util.ByteUtils; +import org.springframework.data.util.CloseableIterator; +import org.springframework.util.Assert; + +/** + * Redis specific {@link KeyValueAdapter} implementation. Uses binary codec to read/write data from/to Redis. Objects + * are stored in a Redis Hash using the value of {@link RedisHash}, the {@link KeyspaceConfiguration} or just + * {@link Class#getName()} as a prefix.
+ * Example + * + *
+ * 
+ * @RedisHash("persons")
+ * class Person {
+ *   @Id String id;
+ *   String name;
+ * }
+ * 
+ * 
+ *         prefix              ID
+ *           |                 |
+ *           V                 V
+ * hgetall persons:5d67b7e1-8640-4475-beeb-c666fab4c0e5
+ * 1) id
+ * 2) 5d67b7e1-8640-4475-beeb-c666fab4c0e5
+ * 3) name
+ * 4) Rand al'Thor
+ * 
+ * 
+ * + *
+ * The {@link KeyValueAdapter} is not intended to store simple types such as {@link String} values. + * Please use {@link RedisTemplate} for this purpose. + * + * @author Christoph Strobl + * @since 1.7 + */ +public class RedisKeyValueAdapter extends AbstractKeyValueAdapter implements ApplicationContextAware, + ApplicationListener { + + private static final Logger LOGGER = LoggerFactory.getLogger(RedisKeyValueAdapter.class); + + private RedisOperations redisOps; + private RedisConverter converter; + private RedisMessageListenerContainer messageListenerContainer; + private KeyExpirationEventMessageListener expirationListener; + + /** + * Creates new {@link RedisKeyValueAdapter} with default {@link RedisMappingContext} and default + * {@link CustomConversions}. + * + * @param redisOps must not be {@literal null}. + */ + public RedisKeyValueAdapter(RedisOperations redisOps) { + this(redisOps, new RedisMappingContext()); + } + + /** + * Creates new {@link RedisKeyValueAdapter} with default {@link CustomConversions}. + * + * @param redisOps must not be {@literal null}. + * @param mappingContext must not be {@literal null}. + */ + public RedisKeyValueAdapter(RedisOperations redisOps, RedisMappingContext mappingContext) { + this(redisOps, mappingContext, new CustomConversions()); + } + + /** + * Creates new {@link RedisKeyValueAdapter}. + * + * @param redisOps must not be {@literal null}. + * @param mappingContext must not be {@literal null}. + * @param customConversions can be {@literal null}. + */ + public RedisKeyValueAdapter(RedisOperations redisOps, RedisMappingContext mappingContext, + CustomConversions customConversions) { + + super(new RedisQueryEngine()); + + Assert.notNull(redisOps, "RedisOperations must not be null!"); + Assert.notNull(mappingContext, "RedisMappingContext must not be null!"); + + MappingRedisConverter mappingConverter = new MappingRedisConverter(mappingContext, new IndexResolverImpl( + mappingContext), new ReferenceResolverImpl(this)); + mappingConverter.setCustomConversions(customConversions == null ? new CustomConversions() : customConversions); + mappingConverter.afterPropertiesSet(); + + converter = mappingConverter; + this.redisOps = redisOps; + + initKeyExpirationListener(); + } + + /** + * Creates new {@link RedisKeyValueAdapter} with specific {@link RedisConverter}. + * + * @param redisOps must not be {@literal null}. + * @param mappingContext must not be {@literal null}. + */ + public RedisKeyValueAdapter(RedisOperations redisOps, RedisConverter redisConverter) { + + super(new RedisQueryEngine()); + + Assert.notNull(redisOps, "RedisOperations must not be null!"); + + converter = redisConverter; + this.redisOps = redisOps; + + initKeyExpirationListener(); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.keyvalue.core.KeyValueAdapter#put(java.io.Serializable, java.lang.Object, java.io.Serializable) + */ + public Object put(final Serializable id, final Object item, final Serializable keyspace) { + + final RedisData rdo = item instanceof RedisData ? (RedisData) item : new RedisData(); + if (!(item instanceof RedisData)) { + converter.write(item, rdo); + } + + if (rdo.getId() == null) { + + rdo.setId(id); + + if (!(item instanceof RedisData)) { + KeyValuePersistentProperty idProperty = converter.getMappingContext().getPersistentEntity(item.getClass()) + .getIdProperty(); + converter.getMappingContext().getPersistentEntity(item.getClass()).getPropertyAccessor(item) + .setProperty(idProperty, id); + } + } + + redisOps.execute(new RedisCallback() { + + @Override + public Object doInRedis(RedisConnection connection) throws DataAccessException { + + byte[] key = toBytes(rdo.getId()); + byte[] objectKey = createKey(rdo.getKeyspace(), rdo.getId()); + + connection.del(objectKey); + + connection.hMSet(objectKey, rdo.getBucket().rawMap()); + + if (rdo.getTimeToLive() != null && rdo.getTimeToLive().longValue() > 0) { + + connection.expire(objectKey, rdo.getTimeToLive().longValue()); + + // add phantom key so values can be restored + byte[] phantomKey = ByteUtils.concat(objectKey, toBytes(":phantom")); + connection.del(phantomKey); + connection.hMSet(phantomKey, rdo.getBucket().rawMap()); + connection.expire(phantomKey, rdo.getTimeToLive().longValue() + 300); + } + + connection.sAdd(toBytes(rdo.getKeyspace()), key); + + new IndexWriter(connection, converter).updateIndexes(key, rdo.getIndexedData()); + return null; + } + }); + + return item; + } + + /* + * (non-Javadoc) + * @see org.springframework.data.keyvalue.core.KeyValueAdapter#contains(java.io.Serializable, java.io.Serializable) + */ + public boolean contains(final Serializable id, final Serializable keyspace) { + + Boolean exists = redisOps.execute(new RedisCallback() { + + @Override + public Boolean doInRedis(RedisConnection connection) throws DataAccessException { + return connection.sIsMember(toBytes(keyspace), toBytes(id)); + } + }); + + return exists != null ? exists.booleanValue() : false; + } + + /* + * (non-Javadoc) + * @see org.springframework.data.keyvalue.core.KeyValueAdapter#get(java.io.Serializable, java.io.Serializable) + */ + public Object get(Serializable id, Serializable keyspace) { + return get(id, keyspace, Object.class); + } + + /** + * @param id + * @param keyspace + * @param type + * @return + */ + public T get(Serializable id, Serializable keyspace, Class type) { + + final byte[] binId = createKey(keyspace, id); + + Map raw = redisOps.execute(new RedisCallback>() { + + @Override + public Map doInRedis(RedisConnection connection) throws DataAccessException { + return connection.hGetAll(binId); + } + }); + + RedisData data = new RedisData(raw); + data.setId(id); + data.setKeyspace(keyspace.toString()); + + return converter.read(type, data); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.keyvalue.core.KeyValueAdapter#delete(java.io.Serializable, java.io.Serializable) + */ + public Object delete(final Serializable id, final Serializable keyspace) { + return delete(id, keyspace, Object.class); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.keyvalue.core.AbstractKeyValueAdapter#delete(java.io.Serializable, java.io.Serializable, java.lang.Class) + */ + public T delete(final Serializable id, final Serializable keyspace, final Class type) { + + final byte[] binId = toBytes(id); + final byte[] binKeyspace = toBytes(keyspace); + + T o = get(id, keyspace, type); + + if (o != null) { + + redisOps.execute(new RedisCallback() { + + @Override + public Void doInRedis(RedisConnection connection) throws DataAccessException { + + connection.del(createKey(keyspace, id)); + connection.sRem(binKeyspace, binId); + + new IndexWriter(connection, converter).removeKeyFromIndexes(keyspace.toString(), binId); + return null; + } + }); + + } + return o; + } + + /* + * (non-Javadoc) + * @see org.springframework.data.keyvalue.core.KeyValueAdapter#getAllOf(java.io.Serializable) + */ + public List getAllOf(final Serializable keyspace) { + + final byte[] binKeyspace = toBytes(keyspace); + + List> raw = redisOps.execute(new RedisCallback>>() { + + @Override + public List> doInRedis(RedisConnection connection) throws DataAccessException { + + final List> rawData = new ArrayList>(); + + Set members = connection.sMembers(binKeyspace); + + for (byte[] id : members) { + rawData.add(connection.hGetAll(createKey(binKeyspace, id))); + } + + return rawData; + } + }); + + List result = new ArrayList(raw.size()); + for (Map rawData : raw) { + result.add(converter.read(Object.class, new RedisData(rawData))); + } + + return result; + } + + /* + * (non-Javadoc) + * @see org.springframework.data.keyvalue.core.KeyValueAdapter#deleteAllOf(java.io.Serializable) + */ + public void deleteAllOf(final Serializable keyspace) { + + redisOps.execute(new RedisCallback() { + + @Override + public Void doInRedis(RedisConnection connection) throws DataAccessException { + + connection.del(toBytes(keyspace)); + new IndexWriter(connection, converter).removeAllIndexes(keyspace.toString()); + return null; + } + }); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.keyvalue.core.KeyValueAdapter#entries(java.io.Serializable) + */ + public CloseableIterator> entries(Serializable keyspace) { + throw new UnsupportedOperationException("Not yet implemented"); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.keyvalue.core.KeyValueAdapter#count(java.io.Serializable) + */ + public long count(final Serializable keyspace) { + + Long count = redisOps.execute(new RedisCallback() { + + @Override + public Long doInRedis(RedisConnection connection) throws DataAccessException { + return connection.sCard(toBytes(keyspace)); + } + }); + + return count != null ? count.longValue() : 0; + } + + /** + * Execute {@link RedisCallback} via underlying {@link RedisOperations}. + * + * @param callback must not be {@literal null}. + * @see RedisOperations#execute(RedisCallback) + * @return + */ + public T execute(RedisCallback callback) { + return redisOps.execute(callback); + } + + /** + * Get the {@link RedisConverter} in use. + * + * @return never {@literal null}. + */ + public RedisConverter getConverter() { + return this.converter; + } + + public void clear() { + // nothing to do + } + + public byte[] createKey(Serializable keyspace, Serializable id) { + return toBytes(keyspace + ":" + id); + } + + /** + * Convert given source to binary representation using the underlying {@link ConversionService}. + * + * @param source + * @return + * @throws ConverterNotFoundException + */ + public byte[] toBytes(Object source) { + + if (source instanceof byte[]) { + return (byte[]) source; + } + + return converter.getConversionService().convert(source, byte[].class); + } + + /* + * (non-Javadoc) + * @see org.springframework.beans.factory.DisposableBean#destroy() + */ + public void destroy() throws Exception { + + if (redisOps instanceof RedisTemplate) { + RedisConnectionFactory connectionFactory = ((RedisTemplate) redisOps).getConnectionFactory(); + if (connectionFactory instanceof DisposableBean) { + ((DisposableBean) connectionFactory).destroy(); + } + } + + this.expirationListener.destroy(); + this.messageListenerContainer.destroy(); + } + + /* + * (non-Javadoc) + * @see org.springframework.context.ApplicationListener#onApplicationEvent(org.springframework.context.ApplicationEvent) + */ + @Override + public void onApplicationEvent(RedisKeyspaceEvent event) { + + LOGGER.debug("Received %s .", event); + + if (event instanceof RedisKeyExpiredEvent) { + + final RedisKeyExpiredEvent expiredEvent = (RedisKeyExpiredEvent) event; + + redisOps.execute(new RedisCallback() { + + @Override + public Void doInRedis(RedisConnection connection) throws DataAccessException { + + LOGGER.debug("Cleaning up expired key '%s' data structures in keyspace '%s'.", expiredEvent.getSource(), + expiredEvent.getKeyspace()); + + connection.sRem(toBytes(expiredEvent.getKeyspace()), expiredEvent.getId()); + new IndexWriter(connection, converter).removeKeyFromIndexes(expiredEvent.getKeyspace(), expiredEvent.getId()); + return null; + } + }); + + } + } + + /* + * (non-Javadoc) + * @see org.springframework.context.ApplicationContextAware#setApplicationContext(org.springframework.context.ApplicationContext) + */ + @Override + public void setApplicationContext(ApplicationContext applicationContext) throws BeansException { + this.expirationListener.setApplicationEventPublisher(applicationContext); + } + + private void initKeyExpirationListener() { + + this.messageListenerContainer = new RedisMessageListenerContainer(); + messageListenerContainer.setConnectionFactory(((RedisTemplate) redisOps).getConnectionFactory()); + messageListenerContainer.afterPropertiesSet(); + messageListenerContainer.start(); + + this.expirationListener = new MappingExpirationListener(this.messageListenerContainer, this.redisOps, + this.converter); + this.expirationListener.init(); + } + + /** + * {@link MessageListener} implementation used to capture Redis keypspace notifications. Tries to read a previously + * created phantom key {@code keyspace:id:phantom} to provide the expired object as part of the published + * {@link RedisKeyExpiredEvent}. + * + * @author Christoph Strobl + * @since 1.7 + */ + static class MappingExpirationListener extends KeyExpirationEventMessageListener { + + private final RedisOperations ops; + private final RedisConverter converter; + + /** + * Creates new {@link MappingExpirationListener}. + * + * @param listenerContainer + * @param ops + * @param converter + */ + public MappingExpirationListener(RedisMessageListenerContainer listenerContainer, RedisOperations ops, + RedisConverter converter) { + + super(listenerContainer); + this.ops = ops; + this.converter = converter; + } + + /* + * (non-Javadoc) + * @see org.springframework.data.redis.listener.KeyspaceEventMessageListener#onMessage(org.springframework.data.redis.connection.Message, byte[]) + */ + @Override + public void onMessage(Message message, byte[] pattern) { + + if (!isKeyExpirationMessage(message)) { + return; + } + + byte[] key = message.getBody(); + + final byte[] phantomKey = ByteUtils.concat(key, converter.getConversionService() + .convert(":phantom", byte[].class)); + + Map hash = ops.execute(new RedisCallback>() { + + @Override + public Map doInRedis(RedisConnection connection) throws DataAccessException { + + Map hash = connection.hGetAll(phantomKey); + + if (!org.springframework.util.CollectionUtils.isEmpty(hash)) { + connection.del(phantomKey); + } + return hash; + } + }); + + Object value = converter.read(Object.class, new RedisData(hash)); + publishEvent(new RedisKeyExpiredEvent(key, value)); + } + + private boolean isKeyExpirationMessage(Message message) { + + if (message == null || message.getChannel() == null || message.getBody() == null) { + return false; + } + + byte[][] args = ByteUtils.split(message.getBody(), ':'); + if (args.length != 2) { + return false; + } + + return true; + } + } + + /** + * {@link ReferenceResolver} using {@link RedisKeyValueAdapter} to read and convert referenced entities. + * + * @author Christoph Strobl + * @since 1.7 + */ + static class ReferenceResolverImpl implements ReferenceResolver { + + private RedisKeyValueAdapter adapter; + + ReferenceResolverImpl() {} + + /** + * @param adapter must not be {@literal null}. + */ + public ReferenceResolverImpl(RedisKeyValueAdapter adapter) { + this.adapter = adapter; + } + + /** + * @param adapter + */ + public void setAdapter(RedisKeyValueAdapter adapter) { + this.adapter = adapter; + } + + /* + * (non-Javadoc) + * @see org.springframework.data.redis.core.convert.ReferenceResolver#resolveReference(java.io.Serializable, java.io.Serializable, java.lang.Class) + */ + @Override + public T resolveReference(Serializable id, Serializable keyspace, Class type) { + return (T) adapter.get(id, keyspace, type); + } + } + +} diff --git a/src/main/java/org/springframework/data/redis/core/RedisKeyValueTemplate.java b/src/main/java/org/springframework/data/redis/core/RedisKeyValueTemplate.java new file mode 100644 index 000000000..2f1d1f057 --- /dev/null +++ b/src/main/java/org/springframework/data/redis/core/RedisKeyValueTemplate.java @@ -0,0 +1,134 @@ +/* + * Copyright 2015 the original author or authors. + * + * Licensed 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.springframework.data.redis.core; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import org.springframework.data.keyvalue.core.KeyValueAdapter; +import org.springframework.data.keyvalue.core.KeyValueCallback; +import org.springframework.data.keyvalue.core.KeyValueTemplate; +import org.springframework.data.redis.core.mapping.RedisMappingContext; +import org.springframework.util.Assert; +import org.springframework.util.ClassUtils; + +/** + * Redis specific implementation of {@link KeyValueTemplate}. + * + * @author Christoph Strobl + * @since 1.7 + */ +public class RedisKeyValueTemplate extends KeyValueTemplate { + + /** + * Create new {@link RedisKeyValueTemplate}. + * + * @param adapter must not be {@literal null}. + * @param mappingContext must not be {@literal null}. + */ + public RedisKeyValueTemplate(RedisKeyValueAdapter adapter, RedisMappingContext mappingContext) { + super(adapter, mappingContext); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.keyvalue.core.KeyValueTemplate#getMappingContext() + */ + @Override + public RedisMappingContext getMappingContext() { + return (RedisMappingContext) super.getMappingContext(); + } + + /** + * Retrieve entities by resolving their {@literal id}s and converting them into required type.
+ * The callback provides either a single {@literal id} or an {@link Iterable} of {@literal id}s, used for retrieving + * the actual domain types and shortcuts manual retrieval and conversion of {@literal id}s via {@link RedisTemplate}. + * + *
+	 * 
+	 * List<RedisSession> sessions = template.find(new RedisCallback<Set<byte[]>>() {
+	 *   public Set<byte[]< doInRedis(RedisConnection connection) throws DataAccessException {
+	 *     return connection
+	 *       .sMembers("spring:session:sessions:securityContext.authentication.principal.username:user"
+	 *         .getBytes());
+	 *   }
+	 * }, RedisSession.class);
+	 * 
+	 * 
+	 * 
+	 * @param callback provides the to retrieve entity ids. Must not be {@literal null}.
+	 * @param type must not be {@literal null}.
+	 * @return empty list if not elements found.
+	 */
+	public  List find(final RedisCallback callback, final Class type) {
+
+		Assert.notNull(callback, "Callback must not be null.");
+
+		return execute(new RedisKeyValueCallback>() {
+
+			@Override
+			public List doInRedis(RedisKeyValueAdapter adapter) {
+
+				Object callbackResult = adapter.execute(callback);
+
+				if (callbackResult == null) {
+					return Collections.emptyList();
+				}
+
+				Iterable ids = ClassUtils.isAssignable(Iterable.class, callbackResult.getClass()) ? (Iterable) callbackResult
+						: Collections.singleton(callbackResult);
+
+				List result = new ArrayList();
+				for (Object id : ids) {
+
+					String idToUse = adapter.getConverter().getConversionService().canConvert(id.getClass(), String.class) ? adapter
+							.getConverter().getConversionService().convert(id, String.class)
+							: id.toString();
+
+					T candidate = findById(idToUse, type);
+					if (candidate != null) {
+						result.add(candidate);
+					}
+				}
+
+				return result;
+			}
+		});
+	}
+
+	/**
+	 * Redis specific {@link KeyValueCallback}.
+	 * 
+	 * @author Christoph Strobl
+	 * @param 
+	 * @since 1.7
+	 */
+	public static abstract class RedisKeyValueCallback implements KeyValueCallback {
+
+		/*
+		 * (non-Javadoc)
+		 * @see org.springframework.data.keyvalue.core.KeyValueCallback#doInKeyValue(org.springframework.data.keyvalue.core.KeyValueAdapter)
+		 */
+		@Override
+		public T doInKeyValue(KeyValueAdapter adapter) {
+			return doInRedis((RedisKeyValueAdapter) adapter);
+		}
+
+		public abstract T doInRedis(RedisKeyValueAdapter adapter);
+	}
+
+}
diff --git a/src/main/java/org/springframework/data/redis/core/RedisKeyspaceEvent.java b/src/main/java/org/springframework/data/redis/core/RedisKeyspaceEvent.java
new file mode 100644
index 000000000..503825903
--- /dev/null
+++ b/src/main/java/org/springframework/data/redis/core/RedisKeyspaceEvent.java
@@ -0,0 +1,45 @@
+/*
+ * Copyright 2015 the original author or authors.
+ *
+ * Licensed 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.springframework.data.redis.core;
+
+import org.springframework.context.ApplicationEvent;
+
+/**
+ * Redis specific {@link ApplicationEvent} published when a key expires in Redis.
+ * 
+ * @author Christoph Strobl
+ * @since 1.7
+ */
+public class RedisKeyspaceEvent extends ApplicationEvent {
+
+	/**
+	 * Creates new {@link RedisKeyspaceEvent}.
+	 * 
+	 * @param key The key that expired. Must not be {@literal null}.
+	 */
+	public RedisKeyspaceEvent(byte[] key) {
+		super(key);
+	}
+
+	/*
+	 * (non-Javadoc)
+	 * @see java.util.EventObject#getSource()
+	 */
+	public byte[] getSource() {
+		return (byte[]) super.getSource();
+	}
+
+}
diff --git a/src/main/java/org/springframework/data/redis/core/RedisQueryEngine.java b/src/main/java/org/springframework/data/redis/core/RedisQueryEngine.java
new file mode 100644
index 000000000..82c250e29
--- /dev/null
+++ b/src/main/java/org/springframework/data/redis/core/RedisQueryEngine.java
@@ -0,0 +1,165 @@
+/*
+ * Copyright 2015 the original author or authors.
+ *
+ * Licensed 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.springframework.data.redis.core;
+
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Comparator;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import org.springframework.dao.DataAccessException;
+import org.springframework.data.keyvalue.core.CriteriaAccessor;
+import org.springframework.data.keyvalue.core.QueryEngine;
+import org.springframework.data.keyvalue.core.SortAccessor;
+import org.springframework.data.keyvalue.core.query.KeyValueQuery;
+import org.springframework.data.redis.connection.RedisConnection;
+import org.springframework.data.redis.core.convert.RedisData;
+import org.springframework.data.redis.repository.query.RedisOperationChain;
+import org.springframework.data.redis.util.ByteUtils;
+
+/**
+ * Redis specific {@link QueryEngine} implementation.
+ * 
+ * @author Christoph Strobl
+ * @since 1.7
+ */
+class RedisQueryEngine extends QueryEngine> {
+
+	/**
+	 * Creates new {@link RedisQueryEngine} with defaults.
+	 */
+	public RedisQueryEngine() {
+		this(new RedisCriteriaAccessor(), null);
+	}
+
+	/**
+	 * Creates new {@link RedisQueryEngine}.
+	 * 
+	 * @param criteriaAccessor
+	 * @param sortAccessor
+	 * @see QueryEngine#QueryEngine(CriteriaAccessor, SortAccessor)
+	 */
+	public RedisQueryEngine(CriteriaAccessor criteriaAccessor,
+			SortAccessor> sortAccessor) {
+		super(criteriaAccessor, sortAccessor);
+	}
+
+	/*
+	 * (non-Javadoc)
+	 * @see org.springframework.data.keyvalue.core.QueryEngine#execute(java.lang.Object, java.lang.Object, int, int, java.io.Serializable, java.lang.Class)
+	 */
+	public  Collection execute(final RedisOperationChain criteria, final Comparator sort, int offset, int rows,
+			final Serializable keyspace, Class type) {
+
+		RedisCallback>> callback = new RedisCallback>>() {
+
+			@Override
+			public Map> doInRedis(RedisConnection connection) throws DataAccessException {
+
+				String key = keyspace + ":";
+				byte[][] keys = new byte[criteria.getSismember().size()][];
+				int i = 0;
+				for (Object o : criteria.getSismember()) {
+					keys[i] = getAdapter().getConverter().getConversionService().convert(key + o, byte[].class);
+					i++;
+				}
+
+				Set allKeys = connection.sInter(keys);
+
+				byte[] keyspaceBin = getAdapter().getConverter().getConversionService().convert(keyspace + ":", byte[].class);
+
+				final Map> rawData = new LinkedHashMap>();
+
+				for (byte[] id : allKeys) {
+
+					byte[] singleKey = ByteUtils.concat(keyspaceBin, id);
+					rawData.put(id, connection.hGetAll(singleKey));
+				}
+
+				return rawData;
+
+			}
+		};
+
+		Map> raw = this.getAdapter().execute(callback);
+
+		List result = new ArrayList(raw.size());
+		for (Map.Entry> entry : raw.entrySet()) {
+
+			RedisData data = new RedisData(entry.getValue());
+			data.setId(getAdapter().getConverter().getConversionService().convert(entry.getKey(), String.class));
+			data.setKeyspace(keyspace.toString());
+
+			T converted = this.getAdapter().getConverter().read(type, data);
+
+			if (converted != null) {
+				result.add(converted);
+			}
+		}
+		return result;
+	}
+
+	/*
+	 * (non-Javadoc)
+	 * @see org.springframework.data.keyvalue.core.QueryEngine#execute(java.lang.Object, java.lang.Object, int, int, java.io.Serializable)
+	 */
+	@Override
+	public Collection execute(final RedisOperationChain criteria, Comparator sort, int offset, int rows,
+			final Serializable keyspace) {
+		return execute(criteria, sort, offset, rows, keyspace, Object.class);
+	}
+
+	/*
+	 * (non-Javadoc)
+	 * @see org.springframework.data.keyvalue.core.QueryEngine#count(java.lang.Object, java.io.Serializable)
+	 */
+	@Override
+	public long count(final RedisOperationChain criteria, final Serializable keyspace) {
+
+		return this.getAdapter().execute(new RedisCallback() {
+
+			@Override
+			public Long doInRedis(RedisConnection connection) throws DataAccessException {
+
+				String key = keyspace + ":";
+				byte[][] keys = new byte[criteria.getSismember().size()][];
+				int i = 0;
+				for (Object o : criteria.getSismember()) {
+					keys[i] = getAdapter().getConverter().getConversionService().convert(key + o, byte[].class);
+				}
+
+				return (long) connection.sInter(keys).size();
+			}
+		});
+	}
+
+	/**
+	 * @author Christoph Strobl
+	 * @since 1.7
+	 */
+	static class RedisCriteriaAccessor implements CriteriaAccessor {
+
+		@Override
+		public RedisOperationChain resolve(KeyValueQuery query) {
+			return (RedisOperationChain) query.getCritieria();
+		}
+	}
+
+}
diff --git a/src/main/java/org/springframework/data/redis/core/TimeToLive.java b/src/main/java/org/springframework/data/redis/core/TimeToLive.java
new file mode 100644
index 000000000..d8cc8f0e8
--- /dev/null
+++ b/src/main/java/org/springframework/data/redis/core/TimeToLive.java
@@ -0,0 +1,57 @@
+/*
+ * Copyright 2015 the original author or authors.
+ *
+ * Licensed 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.springframework.data.redis.core;
+
+import java.lang.annotation.Documented;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+import java.util.concurrent.TimeUnit;
+
+import org.springframework.data.annotation.ReadOnlyProperty;
+
+/**
+ * {@link TimeToLive} marks a single numeric property on aggregate root to be used for setting expirations in Redis. The
+ * annotated property supersedes any other timeout configuration.
+ * 
+ * 
+ * 
+ * @RedisHash
+ * class Person {
+ *   @Id String id;
+ *   String name;
+ *   @TimeToLive Long timeout;
+ * }
+ * 
+ * 
+ * + * @author Christoph Strobl + * @since 1.7 + */ +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Target(value = { ElementType.FIELD, ElementType.METHOD }) +@ReadOnlyProperty +public @interface TimeToLive { + + /** + * {@link TimeUnit} unit to use. + * + * @return {@link TimeUnit#SECONDS} by default. + */ + TimeUnit unit() default TimeUnit.SECONDS; +} diff --git a/src/main/java/org/springframework/data/redis/core/TimeToLiveAccessor.java b/src/main/java/org/springframework/data/redis/core/TimeToLiveAccessor.java new file mode 100644 index 000000000..b8ea4091e --- /dev/null +++ b/src/main/java/org/springframework/data/redis/core/TimeToLiveAccessor.java @@ -0,0 +1,31 @@ +/* + * Copyright 2015 the original author or authors. + * + * Licensed 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.springframework.data.redis.core; + +/** + * {@link TimeToLiveAccessor} extracts the objects time to live used for {@code EXPIRE}. + * + * @author Christoph Strobl + * @since 1.7 + */ +public interface TimeToLiveAccessor { + + /** + * @param source must not be {@literal null}. + * @return {@literal null} if not configured. + */ + Long getTimeToLive(Object source); +} diff --git a/src/main/java/org/springframework/data/redis/core/convert/BinaryConverters.java b/src/main/java/org/springframework/data/redis/core/convert/BinaryConverters.java new file mode 100644 index 000000000..54a59dde7 --- /dev/null +++ b/src/main/java/org/springframework/data/redis/core/convert/BinaryConverters.java @@ -0,0 +1,295 @@ +/* + * Copyright 2015 the original author or authors. + * + * Licensed 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.springframework.data.redis.core.convert; + +import java.nio.charset.Charset; +import java.text.DateFormat; +import java.text.ParseException; +import java.util.Date; + +import org.springframework.core.convert.converter.Converter; +import org.springframework.core.convert.converter.ConverterFactory; +import org.springframework.data.convert.ReadingConverter; +import org.springframework.data.convert.WritingConverter; +import org.springframework.util.NumberUtils; + +/** + * Set of {@link ReadingConverter} and {@link WritingConverter} used to convert Objects into binary format. + * + * @author Christoph Strobl + * @since 1.7 + */ +final class BinaryConverters { + + /** + * Use {@literal UTF-8} as default charset. + */ + public static final Charset CHARSET = Charset.forName("UTF-8"); + + private BinaryConverters() {} + + /** + * @author Christoph Strobl + * @since 1.7 + */ + private static class StringBasedConverter { + + byte[] fromString(String source) { + + if (source == null) { + return new byte[] {}; + } + + return source.getBytes(CHARSET); + } + + String toString(byte[] source) { + return new String(source, CHARSET); + } + } + + /** + * @author Christoph Strobl + * @since 1.7 + */ + @WritingConverter + static class StringToBytesConverter extends StringBasedConverter implements Converter { + + @Override + public byte[] convert(String source) { + return fromString(source); + } + } + + /** + * @author Christoph Strobl + * @since 1.7 + */ + @ReadingConverter + static class BytesToStringConverter extends StringBasedConverter implements Converter { + + @Override + public String convert(byte[] source) { + return toString(source); + } + + } + + /** + * @author Christoph Strobl + * @since 1.7 + */ + @WritingConverter + static class NumberToBytesConverter extends StringBasedConverter implements Converter { + + @Override + public byte[] convert(Number source) { + + if (source == null) { + return new byte[] {}; + } + + return fromString(source.toString()); + } + } + + /** + * @author Christoph Strobl + * @since 1.7 + */ + @WritingConverter + static class EnumToBytesConverter extends StringBasedConverter implements Converter, byte[]> { + + @Override + public byte[] convert(Enum source) { + + if (source == null) { + return new byte[] {}; + } + + return fromString(source.toString()); + } + + } + + /** + * @author Christoph Strobl + * @since 1.7 + */ + @ReadingConverter + static final class BytesToEnumConverterFactory implements ConverterFactory> { + + @Override + @SuppressWarnings({ "unchecked", "rawtypes" }) + public > Converter getConverter(Class targetType) { + + Class enumType = targetType; + while (enumType != null && !enumType.isEnum()) { + enumType = enumType.getSuperclass(); + } + if (enumType == null) { + throw new IllegalArgumentException("The target type " + targetType.getName() + " does not refer to an enum"); + } + return new BytesToEnum(enumType); + } + + /** + * @author Christoph Strobl + * @since 1.7 + */ + private class BytesToEnum> extends StringBasedConverter implements Converter { + + private final Class enumType; + + public BytesToEnum(Class enumType) { + this.enumType = enumType; + } + + @Override + public T convert(byte[] source) { + + String value = toString(source); + + if (value == null || value.length() == 0) { + return null; + } + return (T) Enum.valueOf(this.enumType, value.trim()); + } + } + } + + /** + * @author Christoph Strobl + * @since 1.7 + */ + @ReadingConverter + static class BytesToNumberConverterFactory implements ConverterFactory { + + @Override + public Converter getConverter(Class targetType) { + return new BytesToNumberConverter(targetType); + } + + private static final class BytesToNumberConverter extends StringBasedConverter implements + Converter { + + private final Class targetType; + + public BytesToNumberConverter(Class targetType) { + this.targetType = targetType; + } + + @Override + public T convert(byte[] source) { + + if (source == null || source.length == 0) { + return null; + } + + return NumberUtils.parseNumber(toString(source), targetType); + } + } + } + + /** + * @author Christoph Strobl + * @since 1.7 + */ + @WritingConverter + static class BooleanToBytesConverter extends StringBasedConverter implements Converter { + + final byte[] _true = fromString("1"); + final byte[] _false = fromString("0"); + + @Override + public byte[] convert(Boolean source) { + + if (source == null) { + return new byte[] {}; + } + + return source.booleanValue() ? _true : _false; + } + } + + /** + * @author Christoph Strobl + * @since 1.7 + */ + @ReadingConverter + static class BytesToBooleanConverter extends StringBasedConverter implements Converter { + + @Override + public Boolean convert(byte[] source) { + + if (source == null || source.length == 0) { + return null; + } + + String value = toString(source); + return ("1".equals(value) || "true".equalsIgnoreCase(value)) ? Boolean.TRUE : Boolean.FALSE; + } + } + + /** + * @author Christoph Strobl + * @since 1.7 + */ + @WritingConverter + static class DateToBytesConverter extends StringBasedConverter implements Converter { + + @Override + public byte[] convert(Date source) { + + if (source == null) { + return new byte[] {}; + } + + return fromString(Long.toString(source.getTime())); + } + } + + /** + * @author Christoph Strobl + * @since 1.7 + */ + @ReadingConverter + static class BytesToDateConverter extends StringBasedConverter implements Converter { + + @Override + public Date convert(byte[] source) { + + if (source == null || source.length == 0) { + return null; + } + + String value = toString(source); + try { + return new Date(NumberUtils.parseNumber(value, Long.class)); + } catch (NumberFormatException nfe) { + // ignore + } + + try { + return DateFormat.getInstance().parse(value); + } catch (ParseException e) { + // ignore + } + + throw new IllegalArgumentException("Cannot parse date out of " + source); + } + } +} diff --git a/src/main/java/org/springframework/data/redis/core/convert/Bucket.java b/src/main/java/org/springframework/data/redis/core/convert/Bucket.java new file mode 100644 index 000000000..e79cf3391 --- /dev/null +++ b/src/main/java/org/springframework/data/redis/core/convert/Bucket.java @@ -0,0 +1,250 @@ +/* + * Copyright 2015 the original author or authors. + * + * Licensed 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.springframework.data.redis.core.convert; + +import java.nio.charset.Charset; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; + +/** + * Bucket is the data bag for Redis hash structures to be used with {@link RedisData}. + * + * @author Christoph Strobl + * @since 1.7 + */ +public class Bucket { + + /** + * Encoding used for converting {@link Byte} to and from {@link String}. + */ + public static final Charset CHARSET = Charset.forName("UTF-8"); + + private final Map data; + + /** + * Creates new empty bucket + */ + public Bucket() { + data = new LinkedHashMap(); + } + + Bucket(Map data) { + + Assert.notNull(data, "Inital data must not be null!"); + this.data = new LinkedHashMap(data.size()); + this.data.putAll(data); + } + + /** + * Add {@link String} representation of property dot path with given value. + * + * @param path must not be {@literal null} or {@link String#isEmpty()}. + * @param value can be {@literal null}. + */ + public void put(String path, byte[] value) { + + Assert.hasText(path, "Path to property must not be null or empty."); + data.put(path, value); + } + + /** + * Get value assigned with path. + * + * @param path path must not be {@literal null} or {@link String#isEmpty()}. + * @return {@literal null} if not set. + */ + public byte[] get(String path) { + + Assert.hasText(path, "Path to property must not be null or empty."); + return data.get(path); + } + + /** + * A set view of the mappings contained in this bucket. + * + * @return never {@literal null}. + */ + public Set> entrySet() { + return data.entrySet(); + } + + /** + * @return {@literal true} when no data present in {@link Bucket}. + */ + public boolean isEmpty() { + return data.isEmpty(); + } + + /** + * @return never {@literal null}. + */ + public Collection values() { + return data.values(); + } + + /** + * @return never {@literal null}. + */ + public Set keySet() { + return data.keySet(); + } + + /** + * Key/value pairs contained in the {@link Bucket}. + * + * @return never {@literal null}. + */ + public Map asMap() { + return Collections.unmodifiableMap(this.data); + } + + public Bucket extract(String prefix) { + + Bucket partial = new Bucket(); + for (Map.Entry entry : data.entrySet()) { + if (entry.getKey().startsWith(prefix)) { + partial.put(entry.getKey(), entry.getValue()); + } + } + + return partial; + } + + /** + * Get all the keys matching a given path. + * + * @param path the path to look for. Can be {@literal null}. + * @return all keys if path is {@null} or empty. + */ + public Set extractAllKeysFor(String path) { + + if (!StringUtils.hasText(path)) { + return keySet(); + } + + Pattern pattern = Pattern.compile("(" + Pattern.quote(path) + ")\\.\\[.*?\\]"); + + Set keys = new LinkedHashSet(); + for (Map.Entry entry : data.entrySet()) { + + Matcher matcher = pattern.matcher(entry.getKey()); + if (matcher.find()) { + keys.add(matcher.group()); + } + } + + return keys; + } + + /** + * Get keys and values in binary format. + * + * @return never {@literal null}. + */ + public Map rawMap() { + + Map raw = new LinkedHashMap(data.size()); + for (Map.Entry entry : data.entrySet()) { + if (entry.getValue() != null) { + raw.put(entry.getKey().getBytes(CHARSET), entry.getValue()); + } + } + return raw; + } + + /** + * Creates a new Bucket from a given raw map. + * + * @param source can be {@literal null}. + * @return never {@literal null}. + */ + public static Bucket newBucketFromRawMap(Map source) { + + Bucket bucket = new Bucket(); + if (source == null) { + return bucket; + } + + for (Map.Entry entry : source.entrySet()) { + bucket.put(new String(entry.getKey(), CHARSET), entry.getValue()); + } + return bucket; + } + + /** + * Creates a new Bucket from a given {@link String} map. + * + * @param source can be {@literal null}. + * @return never {@literal null}. + */ + public static Bucket newBucketFromStringMap(Map source) { + + Bucket bucket = new Bucket(); + if (source == null) { + return bucket; + } + + for (Map.Entry entry : source.entrySet()) { + bucket.put(entry.getKey(), StringUtils.hasText(entry.getValue()) ? entry.getValue().getBytes(CHARSET) + : new byte[] {}); + } + return bucket; + } + + /* + * (non-Javadoc) + * @see java.lang.Object#toString() + */ + @Override + public String toString() { + return "Bucket [data=" + safeToString() + "]"; + } + + private String safeToString() { + + Map serialized = new LinkedHashMap(); + for (Map.Entry entry : data.entrySet()) { + if (entry.getValue() != null) { + serialized.put(entry.getKey(), toUtf8String(entry.getValue())); + } else { + serialized.put(entry.getKey(), null); + } + } + return serialized.toString(); + + } + + private String toUtf8String(byte[] raw) { + + try { + return new String(raw, CHARSET); + } catch (Exception e) { + // Ignore this one + } + return null; + } + +} diff --git a/src/main/java/org/springframework/data/redis/core/convert/CustomConversions.java b/src/main/java/org/springframework/data/redis/core/convert/CustomConversions.java new file mode 100644 index 000000000..129fea5cc --- /dev/null +++ b/src/main/java/org/springframework/data/redis/core/convert/CustomConversions.java @@ -0,0 +1,485 @@ +/* + * Copyright 2011-2015 the original author or authors. + * + * Licensed 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.springframework.data.redis.core.convert; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.core.GenericTypeResolver; +import org.springframework.core.convert.converter.Converter; +import org.springframework.core.convert.converter.ConverterFactory; +import org.springframework.core.convert.converter.GenericConverter; +import org.springframework.core.convert.converter.GenericConverter.ConvertiblePair; +import org.springframework.core.convert.support.GenericConversionService; +import org.springframework.data.convert.ReadingConverter; +import org.springframework.data.convert.WritingConverter; +import org.springframework.data.mapping.model.SimpleTypeHolder; +import org.springframework.data.util.CacheValue; +import org.springframework.util.Assert; + +/** + * Value object to capture custom conversion. That is essentially a {@link List} of converters and some additional logic + * around them. + * + * @author Oliver Gierke + * @author Thomas Darimont + * @author Christoph Strobl + * @since 1.7 + */ +public class CustomConversions { + + private static final Logger LOG = LoggerFactory.getLogger(CustomConversions.class); + private static final String READ_CONVERTER_NOT_SIMPLE = "Registering converter from %s to %s as reading converter although it doesn't convert from a Redis supported type! You might wanna check you annotation setup at the converter implementation."; + private static final String WRITE_CONVERTER_NOT_SIMPLE = "Registering converter from %s to %s as writing converter although it doesn't convert to a Redis supported type! You might wanna check you annotation setup at the converter implementation."; + + private final Set readingPairs; + private final Set writingPairs; + private final Set> customSimpleTypes; + private final SimpleTypeHolder simpleTypeHolder; + + private final List converters; + + private final Map>> customReadTargetTypes; + private final Map>> customWriteTargetTypes; + private final Map, CacheValue>> rawWriteTargetTypes; + + /** + * Creates an empty {@link CustomConversions} object. + */ + public CustomConversions() { + this(new ArrayList()); + } + + /** + * Creates a new {@link CustomConversions} instance registering the given converters. + * + * @param converters + */ + public CustomConversions(List converters) { + + Assert.notNull(converters); + + this.readingPairs = new LinkedHashSet(); + this.writingPairs = new LinkedHashSet(); + this.customSimpleTypes = new HashSet>(); + this.customReadTargetTypes = new ConcurrentHashMap>>(); + this.customWriteTargetTypes = new ConcurrentHashMap>>(); + this.rawWriteTargetTypes = new ConcurrentHashMap, CacheValue>>(); + + List toRegister = new ArrayList(); + + // Add user provided converters to make sure they can override the defaults + toRegister.addAll(converters); + toRegister.add(new BinaryConverters.StringToBytesConverter()); + toRegister.add(new BinaryConverters.BytesToStringConverter()); + toRegister.add(new BinaryConverters.NumberToBytesConverter()); + toRegister.add(new BinaryConverters.BytesToNumberConverterFactory()); + toRegister.add(new BinaryConverters.EnumToBytesConverter()); + toRegister.add(new BinaryConverters.BytesToEnumConverterFactory()); + toRegister.add(new BinaryConverters.BooleanToBytesConverter()); + toRegister.add(new BinaryConverters.BytesToBooleanConverter()); + toRegister.add(new BinaryConverters.DateToBytesConverter()); + toRegister.add(new BinaryConverters.BytesToDateConverter()); + + for (Object c : toRegister) { + registerConversion(c); + } + + Collections.reverse(toRegister); + + this.converters = Collections.unmodifiableList(toRegister); + this.simpleTypeHolder = new SimpleTypeHolder(customSimpleTypes, true); + } + + /** + * Returns the underlying {@link SimpleTypeHolder}. + * + * @return + */ + public SimpleTypeHolder getSimpleTypeHolder() { + return simpleTypeHolder; + } + + /** + * Returns whether the given type is considered to be simple. That means it's either a general simple type or we have + * a writing {@link Converter} registered for a particular type. + * + * @see SimpleTypeHolder#isSimpleType(Class) + * @param type + * @return + */ + public boolean isSimpleType(Class type) { + return simpleTypeHolder.isSimpleType(type); + } + + /** + * Populates the given {@link GenericConversionService} with the convertes registered. + * + * @param conversionService + */ + public void registerConvertersIn(GenericConversionService conversionService) { + + for (Object converter : converters) { + + boolean added = false; + + if (converter instanceof Converter) { + conversionService.addConverter((Converter) converter); + added = true; + } + + if (converter instanceof ConverterFactory) { + conversionService.addConverterFactory((ConverterFactory) converter); + added = true; + } + + if (converter instanceof GenericConverter) { + conversionService.addConverter((GenericConverter) converter); + added = true; + } + + if (!added) { + throw new IllegalArgumentException("Given set contains element that is neither Converter nor ConverterFactory!"); + } + } + } + + /** + * Registers a conversion for the given converter. Inspects either generics or the {@link ConvertiblePair}s returned + * by a {@link GenericConverter}. + * + * @param converter + */ + private void registerConversion(Object converter) { + + Class type = converter.getClass(); + boolean isWriting = type.isAnnotationPresent(WritingConverter.class); + boolean isReading = type.isAnnotationPresent(ReadingConverter.class); + + if (converter instanceof GenericConverter) { + GenericConverter genericConverter = (GenericConverter) converter; + for (ConvertiblePair pair : genericConverter.getConvertibleTypes()) { + register(new ConverterRegistration(pair, isReading, isWriting)); + } + } else if (converter instanceof Converter) { + Class[] arguments = GenericTypeResolver.resolveTypeArguments(converter.getClass(), Converter.class); + register(new ConverterRegistration(new ConvertiblePair(arguments[0], arguments[1]), isReading, isWriting)); + } else if (converter instanceof ConverterFactory) { + + Class[] arguments = GenericTypeResolver.resolveTypeArguments(converter.getClass(), ConverterFactory.class); + register(new ConverterRegistration(new ConvertiblePair(arguments[0], arguments[1]), isReading, isWriting)); + } + + else { + throw new IllegalArgumentException("Unsupported Converter type!"); + } + } + + /** + * Registers the given {@link ConvertiblePair} as reading or writing pair depending on the type sides being basic + * Redis types. + * + * @param pair + */ + private void register(ConverterRegistration converterRegistration) { + + ConvertiblePair pair = converterRegistration.getConvertiblePair(); + + if (converterRegistration.isReading()) { + + readingPairs.add(pair); + + if (LOG.isWarnEnabled() && !converterRegistration.isSimpleSourceType()) { + LOG.warn(String.format(READ_CONVERTER_NOT_SIMPLE, pair.getSourceType(), pair.getTargetType())); + } + } + + if (converterRegistration.isWriting()) { + + writingPairs.add(pair); + customSimpleTypes.add(pair.getSourceType()); + + if (LOG.isWarnEnabled() && !converterRegistration.isSimpleTargetType()) { + LOG.warn(String.format(WRITE_CONVERTER_NOT_SIMPLE, pair.getSourceType(), pair.getTargetType())); + } + } + } + + /** + * Returns the target type to convert to in case we have a custom conversion registered to convert the given source + * type into a Redis native one. + * + * @param sourceType must not be {@literal null} + * @return + */ + public Class getCustomWriteTarget(final Class sourceType) { + + return getOrCreateAndCache(sourceType, rawWriteTargetTypes, new Producer() { + + @Override + public Class get() { + return getCustomTarget(sourceType, null, writingPairs); + } + }); + } + + /** + * Returns the target type we can readTargetWriteLocl an inject of the given source type to. The returned type might + * be a subclass of the given expected type though. If {@code expectedTargetType} is {@literal null} we will simply + * return the first target type matching or {@literal null} if no conversion can be found. + * + * @param sourceType must not be {@literal null} + * @param requestedTargetType + * @return + */ + public Class getCustomWriteTarget(final Class sourceType, final Class requestedTargetType) { + + if (requestedTargetType == null) { + return getCustomWriteTarget(sourceType); + } + + return getOrCreateAndCache(new ConvertiblePair(sourceType, requestedTargetType), customWriteTargetTypes, + new Producer() { + + @Override + public Class get() { + return getCustomTarget(sourceType, requestedTargetType, writingPairs); + } + }); + } + + /** + * Returns whether we have a custom conversion registered to readTargetWriteLocl into a Redis native type. The + * returned type might be a subclass of the given expected type though. + * + * @param sourceType must not be {@literal null} + * @return + */ + public boolean hasCustomWriteTarget(Class sourceType) { + return hasCustomWriteTarget(sourceType, null); + } + + /** + * Returns whether we have a custom conversion registered to readTargetWriteLocl an object of the given source type + * into an object of the given Redis native target type. + * + * @param sourceType must not be {@literal null}. + * @param requestedTargetType + * @return + */ + public boolean hasCustomWriteTarget(Class sourceType, Class requestedTargetType) { + return getCustomWriteTarget(sourceType, requestedTargetType) != null; + } + + /** + * Returns whether we have a custom conversion registered to readTargetReadLock the given source into the given target + * type. + * + * @param sourceType must not be {@literal null} + * @param requestedTargetType must not be {@literal null} + * @return + */ + public boolean hasCustomReadTarget(Class sourceType, Class requestedTargetType) { + return getCustomReadTarget(sourceType, requestedTargetType) != null; + } + + /** + * Returns the actual target type for the given {@code sourceType} and {@code requestedTargetType}. Note that the + * returned {@link Class} could be an assignable type to the given {@code requestedTargetType}. + * + * @param sourceType must not be {@literal null}. + * @param requestedTargetType can be {@literal null}. + * @return + */ + private Class getCustomReadTarget(final Class sourceType, final Class requestedTargetType) { + + if (requestedTargetType == null) { + return null; + } + + return getOrCreateAndCache(new ConvertiblePair(sourceType, requestedTargetType), customReadTargetTypes, + new Producer() { + + @Override + public Class get() { + return getCustomTarget(sourceType, requestedTargetType, readingPairs); + } + }); + } + + /** + * Inspects the given {@link ConvertiblePair}s for ones that have a source compatible type as source. Additionally + * checks assignability of the target type if one is given. + * + * @param sourceType must not be {@literal null}. + * @param requestedTargetType can be {@literal null}. + * @param pairs must not be {@literal null}. + * @return + */ + private static Class getCustomTarget(Class sourceType, Class requestedTargetType, + Collection pairs) { + + Assert.notNull(sourceType); + Assert.notNull(pairs); + + if (requestedTargetType != null && pairs.contains(new ConvertiblePair(sourceType, requestedTargetType))) { + return requestedTargetType; + } + + for (ConvertiblePair typePair : pairs) { + if (typePair.getSourceType().isAssignableFrom(sourceType)) { + Class targetType = typePair.getTargetType(); + if (requestedTargetType == null || targetType.isAssignableFrom(requestedTargetType)) { + return targetType; + } + } + } + + return null; + } + + /** + * Will try to find a value for the given key in the given cache or produce one using the given {@link Producer} and + * store it in the cache. + * + * @param key the key to lookup a potentially existing value, must not be {@literal null}. + * @param cache the cache to find the value in, must not be {@literal null}. + * @param producer the {@link Producer} to create values to cache, must not be {@literal null}. + * @return + */ + private static Class getOrCreateAndCache(T key, Map>> cache, Producer producer) { + + CacheValue> cacheValue = cache.get(key); + + if (cacheValue != null) { + return cacheValue.getValue(); + } + + Class type = producer.get(); + cache.put(key, CacheValue.> ofNullable(type)); + + return type; + } + + private interface Producer { + + Class get(); + } + + /** + * Conversion registration information. + * + * @author Oliver Gierke + */ + static class ConverterRegistration { + + private final ConvertiblePair convertiblePair; + private final boolean reading; + private final boolean writing; + + /** + * Creates a new {@link ConverterRegistration}. + * + * @param convertiblePair must not be {@literal null}. + * @param isReading whether to force to consider the converter for reading. + * @param isWritingwhether to force to consider the converter for reading. + */ + public ConverterRegistration(ConvertiblePair convertiblePair, boolean isReading, boolean isWriting) { + + Assert.notNull(convertiblePair); + + this.convertiblePair = convertiblePair; + this.reading = isReading; + this.writing = isWriting; + } + + /** + * Creates a new {@link ConverterRegistration} from the given source and target type and read/write flags. + * + * @param source the source type to be converted from, must not be {@literal null}. + * @param target the target type to be converted to, must not be {@literal null}. + * @param isReading whether to force to consider the converter for reading. + * @param isWriting whether to force to consider the converter for writing. + */ + public ConverterRegistration(Class source, Class target, boolean isReading, boolean isWriting) { + this(new ConvertiblePair(source, target), isReading, isWriting); + } + + /** + * Returns whether the converter shall be used for writing. + * + * @return + */ + public boolean isWriting() { + return writing == true || (!reading && isSimpleTargetType()); + } + + /** + * Returns whether the converter shall be used for reading. + * + * @return + */ + public boolean isReading() { + return reading == true || (!writing && isSimpleSourceType()); + } + + /** + * Returns the actual conversion pair. + * + * @return + */ + public ConvertiblePair getConvertiblePair() { + return convertiblePair; + } + + /** + * Returns whether the source type is a Redis simple one. + * + * @return + */ + public boolean isSimpleSourceType() { + return isRedisBasicType(convertiblePair.getSourceType()); + } + + /** + * Returns whether the target type is a Redis simple one. + * + * @return + */ + public boolean isSimpleTargetType() { + return isRedisBasicType(convertiblePair.getTargetType()); + } + + /** + * Returns whether the given type is a type that Redis can handle basically. + * + * @param type + * @return + */ + private static boolean isRedisBasicType(Class type) { + return (byte[].class.equals(type) || Map.class.equals(type)); + } + } +} diff --git a/src/main/java/org/springframework/data/redis/core/convert/IndexResolver.java b/src/main/java/org/springframework/data/redis/core/convert/IndexResolver.java new file mode 100644 index 000000000..a90e376f7 --- /dev/null +++ b/src/main/java/org/springframework/data/redis/core/convert/IndexResolver.java @@ -0,0 +1,41 @@ +/* + * Copyright 2015 the original author or authors. + * + * Licensed 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.springframework.data.redis.core.convert; + +import java.util.Set; + +import org.springframework.data.mapping.PersistentProperty; +import org.springframework.data.util.TypeInformation; + +/** + * {@link IndexResolver} extracts secondary index structures to be applied on a given path, {@link PersistentProperty} + * and value. + * + * @author Christoph Strobl + * @since 1.7 + */ +public interface IndexResolver { + + /** + * Resolves all indexes for given type information / value combination. + * + * @param typeInformation must not be {@literal null}. + * @param value the actual value. Can be {@literal null}. + * @return never {@literal null}. + */ + Set resolveIndexesFor(TypeInformation typeInformation, Object value); + +} diff --git a/src/main/java/org/springframework/data/redis/core/convert/IndexResolverImpl.java b/src/main/java/org/springframework/data/redis/core/convert/IndexResolverImpl.java new file mode 100644 index 000000000..f3eba6f5c --- /dev/null +++ b/src/main/java/org/springframework/data/redis/core/convert/IndexResolverImpl.java @@ -0,0 +1,204 @@ +/* + * Copyright 2015 the original author or authors. + * + * Licensed 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.springframework.data.redis.core.convert; + +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Set; + +import org.springframework.data.keyvalue.core.mapping.KeyValuePersistentProperty; +import org.springframework.data.mapping.PersistentProperty; +import org.springframework.data.mapping.PersistentPropertyAccessor; +import org.springframework.data.mapping.PropertyHandler; +import org.springframework.data.redis.core.index.IndexConfiguration; +import org.springframework.data.redis.core.index.IndexConfiguration.RedisIndexSetting; +import org.springframework.data.redis.core.index.Indexed; +import org.springframework.data.redis.core.mapping.RedisMappingContext; +import org.springframework.data.redis.core.mapping.RedisPersistentEntity; +import org.springframework.data.util.ClassTypeInformation; +import org.springframework.data.util.TypeInformation; +import org.springframework.util.Assert; + +/** + * {@link IndexResolver} implementation considering properties annotated with {@link Indexed} or paths set up in + * {@link IndexConfiguration}. + * + * @author Christoph Strobl + * @since 1.7 + */ +public class IndexResolverImpl implements IndexResolver { + + private IndexConfiguration indexConfiguration; + private RedisMappingContext mappingContext; + + /** + * Creates new {@link IndexResolverImpl} with empty {@link IndexConfiguration}. + */ + public IndexResolverImpl() { + this(new RedisMappingContext()); + } + + /** + * Creates new {@link IndexResolverImpl} with given {@link IndexConfiguration}. + * + * @param mapppingContext must not be {@literal null}. + */ + public IndexResolverImpl(RedisMappingContext mappingContext) { + + Assert.notNull(mappingContext, "MappingContext must not be null!"); + this.mappingContext = mappingContext; + this.indexConfiguration = mappingContext.getMappingConfiguration().getIndexConfiguration(); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.redis.core.convert.IndexResolver#resolveIndexesFor(org.springframework.data.util.TypeInformation, java.lang.Object) + */ + public Set resolveIndexesFor(TypeInformation typeInformation, Object value) { + return doResolveIndexesFor(mappingContext.getPersistentEntity(typeInformation).getKeySpace(), "", typeInformation, + value); + } + + private Set doResolveIndexesFor(final String keyspace, final String path, + TypeInformation typeInformation, Object value) { + + RedisPersistentEntity entity = mappingContext.getPersistentEntity(typeInformation); + + if (entity == null) { + + IndexedData index = resolveIndex(keyspace, path, null, value); + if (index != null) { + return Collections.singleton(index); + } + return Collections.emptySet(); + } + + final PersistentPropertyAccessor accessor = entity.getPropertyAccessor(value); + final Set indexes = new LinkedHashSet(); + + entity.doWithProperties(new PropertyHandler() { + + @Override + public void doWithPersistentProperty(KeyValuePersistentProperty persistentProperty) { + + String currentPath = !path.isEmpty() ? path + "." + persistentProperty.getName() : persistentProperty.getName(); + + Object propertyValue = accessor.getProperty(persistentProperty); + + if (propertyValue != null) { + + TypeInformation typeHint = persistentProperty.isMap() ? persistentProperty.getTypeInformation() + .getMapValueType() : persistentProperty.getTypeInformation().getActualType(); + + IndexedData index = resolveIndex(keyspace, currentPath, persistentProperty, propertyValue); + + if (index != null) { + indexes.add(index); + } + + if (persistentProperty.isMap()) { + + for (Entry entry : ((Map) propertyValue).entrySet()) { + + TypeInformation typeToUse = updateTypeHintForActualValue(typeHint, entry.getValue()); + indexes.addAll(doResolveIndexesFor(keyspace, currentPath + "." + entry.getKey(), + typeToUse.getActualType(), entry.getValue())); + } + + } else if (persistentProperty.isCollectionLike()) { + + for (Object listValue : (Iterable) propertyValue) { + + TypeInformation typeToUse = updateTypeHintForActualValue(typeHint, listValue); + indexes.addAll(doResolveIndexesFor(keyspace, currentPath, typeToUse.getActualType(), listValue)); + } + } + + else if (persistentProperty.isEntity() + || persistentProperty.getTypeInformation().getActualType().equals(ClassTypeInformation.OBJECT)) { + + typeHint = updateTypeHintForActualValue(typeHint, propertyValue); + indexes.addAll(doResolveIndexesFor(keyspace, currentPath, typeHint.getActualType(), propertyValue)); + } + } + + } + + private TypeInformation updateTypeHintForActualValue(TypeInformation typeHint, Object propertyValue) { + + if (typeHint.equals(ClassTypeInformation.OBJECT) || typeHint.getClass().isInterface()) { + try { + typeHint = mappingContext.getPersistentEntity(propertyValue.getClass()).getTypeInformation(); + } catch (Exception e) { + // ignore for cases where property value cannot be resolved as an entity, in that case the provided type + // hint has to be sufficient + } + } + return typeHint; + } + + }); + + return indexes; + } + + protected IndexedData resolveIndex(String keyspace, String propertyPath, PersistentProperty property, Object value) { + + if (value == null) { + return null; + } + + String path = normalizeIndexPath(propertyPath, property); + + if (indexConfiguration.hasIndexFor(keyspace, path)) { + return new SimpleIndexedPropertyValue(keyspace, path, value); + } + + else if (property != null && property.isAnnotationPresent(Indexed.class)) { + + Indexed indexed = property.findAnnotation(Indexed.class); + + indexConfiguration.addIndexDefinition(new RedisIndexSetting(keyspace, path, indexed.type())); + + switch (indexed.type()) { + case SIMPLE: + return new SimpleIndexedPropertyValue(keyspace, path, value); + default: + throw new IllegalArgumentException(String.format("Unsupported index type '%s' for path '%s'.", + indexed.type(), path)); + } + } + return null; + } + + private String normalizeIndexPath(String path, PersistentProperty property) { + + if (property == null) { + return path; + } + + if (property.isMap()) { + return path.replaceAll("\\[", "").replaceAll("\\]", ""); + } + if (property.isCollectionLike()) { + return path.replaceAll("\\[(\\p{Digit})*\\]", "").replaceAll("\\.\\.", "."); + } + + return path; + } +} diff --git a/src/main/java/org/springframework/data/redis/core/convert/IndexedData.java b/src/main/java/org/springframework/data/redis/core/convert/IndexedData.java new file mode 100644 index 000000000..04e17f511 --- /dev/null +++ b/src/main/java/org/springframework/data/redis/core/convert/IndexedData.java @@ -0,0 +1,40 @@ +/* + * Copyright 2015 the original author or authors. + * + * Licensed 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.springframework.data.redis.core.convert; + +/** + * {@link IndexedData} represents a secondary index for a property path in a given keyspace. + * + * @author Christoph Strobl + * @since 1.7 + */ +public interface IndexedData { + + /** + * Get the {@link String} representation of the dot path to the referenced property. + * + * @return never {@literal null}. + */ + String getPath(); + + /** + * Get the associated keyspace the index resides in. + * + * @return + */ + String getKeySpace(); + +} diff --git a/src/main/java/org/springframework/data/redis/core/convert/KeyspaceConfiguration.java b/src/main/java/org/springframework/data/redis/core/convert/KeyspaceConfiguration.java new file mode 100644 index 000000000..f0f8b3bfa --- /dev/null +++ b/src/main/java/org/springframework/data/redis/core/convert/KeyspaceConfiguration.java @@ -0,0 +1,186 @@ +/* + * Copyright 2015 the original author or authors. + * + * Licensed 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.springframework.data.redis.core.convert; + +import java.util.Collections; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import org.springframework.data.redis.core.RedisHash; +import org.springframework.data.redis.core.TimeToLive; +import org.springframework.util.Assert; +import org.springframework.util.ClassUtils; + +/** + * {@link KeyspaceConfiguration} allows programmatic setup of keyspaces and time to live options for certain types. This + * is suitable for cases where there is no option to use the equivalent {@link RedisHash} or {@link TimeToLive} + * annotations. + * + * @author Christoph Strobl + * @since 1.7 + */ +public class KeyspaceConfiguration { + + private Map, KeyspaceSettings> settingsMap; + + public KeyspaceConfiguration() { + + this.settingsMap = new ConcurrentHashMap, KeyspaceSettings>(); + for (KeyspaceSettings initial : initialConfiguration()) { + settingsMap.put(initial.type, initial); + } + } + + /** + * Check if specific {@link KeyspaceSettings} are available for given type. + * + * @param type must not be {@literal null}. + * @return true if settings exist. + */ + public boolean hasSettingsFor(Class type) { + + Assert.notNull(type, "Type to lookup must not be null!"); + + if (settingsMap.containsKey(type)) { + + if (settingsMap.get(type) instanceof DefaultKeyspaceSetting) { + return false; + } + + return true; + } + + for (KeyspaceSettings assignment : settingsMap.values()) { + if (assignment.inherit) { + if (ClassUtils.isAssignable(assignment.type, type)) { + settingsMap.put(type, assignment.cloneFor(type)); + return true; + } + } + } + + settingsMap.put(type, new DefaultKeyspaceSetting(type)); + return false; + } + + /** + * Get the {@link KeyspaceSettings} for given type. + * + * @param type must not be {@literal null} + * @return {@literal null} if no settings configured. + */ + public KeyspaceSettings getKeyspaceSettings(Class type) { + + if (!hasSettingsFor(type)) { + return null; + } + + KeyspaceSettings settings = settingsMap.get(type); + if (settings == null || settings instanceof DefaultKeyspaceSetting) { + return null; + } + + return settings; + } + + /** + * Customization hook. + * + * @return must not return {@literal null}. + */ + protected Iterable initialConfiguration() { + return Collections.emptySet(); + } + + /** + * Add {@link KeyspaceSettings} for type. + * + * @param keyspaceSettings must not be {@literal null}. + */ + public void addKeyspaceSettings(KeyspaceSettings keyspaceSettings) { + + Assert.notNull(keyspaceSettings); + this.settingsMap.put(keyspaceSettings.getType(), keyspaceSettings); + } + + /** + * @author Christoph Strobl + * @since 1.7 + */ + public static class KeyspaceSettings { + + private final String keyspace; + private final Class type; + private final boolean inherit; + private Long timeToLive; + private String timeToLivePropertyName; + + public KeyspaceSettings(Class type, String keyspace) { + this(type, keyspace, true); + } + + public KeyspaceSettings(Class type, String keyspace, boolean inherit) { + + this.type = type; + this.keyspace = keyspace; + this.inherit = inherit; + } + + KeyspaceSettings cloneFor(Class type) { + return new KeyspaceSettings(type, this.keyspace, false); + } + + public String getKeyspace() { + return keyspace; + } + + public Class getType() { + return type; + } + + public void setTimeToLive(Long timeToLive) { + this.timeToLive = timeToLive; + } + + public Long getTimeToLive() { + return timeToLive; + } + + public void setTimeToLivePropertyName(String propertyName) { + timeToLivePropertyName = propertyName; + } + + public String getTimeToLivePropertyName() { + return timeToLivePropertyName; + } + + } + + /** + * Marker class indicating no settings defined. + * + * @author Christoph Strobl + * @since 1.7 + */ + private static class DefaultKeyspaceSetting extends KeyspaceSettings { + + public DefaultKeyspaceSetting(Class type) { + super(type, "#default#", false); + } + + } + +} diff --git a/src/main/java/org/springframework/data/redis/core/convert/MappingConfiguration.java b/src/main/java/org/springframework/data/redis/core/convert/MappingConfiguration.java new file mode 100644 index 000000000..1eaced36b --- /dev/null +++ b/src/main/java/org/springframework/data/redis/core/convert/MappingConfiguration.java @@ -0,0 +1,57 @@ +/* + * Copyright 2015 the original author or authors. + * + * Licensed 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.springframework.data.redis.core.convert; + +import org.springframework.data.redis.core.index.IndexConfiguration; + +/** + * {@link MappingConfiguration} is used for programmatic configuration of secondary indexes, key prefixes, expirations + * and the such. + * + * @author Christoph Strobl + * @since 1.7 + */ +public class MappingConfiguration { + + private final IndexConfiguration indexConfiguration; + private final KeyspaceConfiguration keyspaceConfiguration; + + /** + * Creates new {@link MappingConfiguration}. + * + * @param indexConfiguration must not be {@literal null}. + * @param keyspaceConfiguration must not be {@literal null}. + */ + public MappingConfiguration(IndexConfiguration indexConfiguration, KeyspaceConfiguration keyspaceConfiguration) { + + this.indexConfiguration = indexConfiguration; + this.keyspaceConfiguration = keyspaceConfiguration; + } + + /** + * @return never {@literal null}. + */ + public IndexConfiguration getIndexConfiguration() { + return indexConfiguration; + } + + /** + * @return never {@literal null}. + */ + public KeyspaceConfiguration getKeyspaceConfiguration() { + return keyspaceConfiguration; + } +} diff --git a/src/main/java/org/springframework/data/redis/core/convert/MappingRedisConverter.java b/src/main/java/org/springframework/data/redis/core/convert/MappingRedisConverter.java new file mode 100644 index 000000000..1af07f488 --- /dev/null +++ b/src/main/java/org/springframework/data/redis/core/convert/MappingRedisConverter.java @@ -0,0 +1,815 @@ +/* + * Copyright 2015 the original author or authors. + * + * Licensed 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.springframework.data.redis.core.convert; + +import java.io.Serializable; +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.springframework.beans.factory.InitializingBean; +import org.springframework.core.CollectionFactory; +import org.springframework.core.convert.ConversionService; +import org.springframework.core.convert.ConverterNotFoundException; +import org.springframework.core.convert.support.DefaultConversionService; +import org.springframework.core.convert.support.GenericConversionService; +import org.springframework.data.convert.DefaultTypeMapper; +import org.springframework.data.convert.EntityInstantiator; +import org.springframework.data.convert.EntityInstantiators; +import org.springframework.data.convert.TypeAliasAccessor; +import org.springframework.data.convert.TypeMapper; +import org.springframework.data.keyvalue.core.mapping.KeySpaceResolver; +import org.springframework.data.keyvalue.core.mapping.KeyValuePersistentEntity; +import org.springframework.data.keyvalue.core.mapping.KeyValuePersistentProperty; +import org.springframework.data.mapping.Association; +import org.springframework.data.mapping.AssociationHandler; +import org.springframework.data.mapping.PersistentPropertyAccessor; +import org.springframework.data.mapping.PreferredConstructor; +import org.springframework.data.mapping.PropertyHandler; +import org.springframework.data.mapping.model.PersistentEntityParameterValueProvider; +import org.springframework.data.mapping.model.PropertyValueProvider; +import org.springframework.data.redis.core.index.Indexed; +import org.springframework.data.redis.core.mapping.RedisMappingContext; +import org.springframework.data.redis.core.mapping.RedisPersistentEntity; +import org.springframework.data.util.ClassTypeInformation; +import org.springframework.data.util.TypeInformation; +import org.springframework.util.Assert; +import org.springframework.util.ClassUtils; +import org.springframework.util.CollectionUtils; +import org.springframework.util.StringUtils; + +/** + * {@link RedisConverter} implementation creating flat binary map structure out of a given domain type. Considers + * {@link Indexed} annotation for enabling helper structures for finder operations.
+ *
+ * NOTE {@link MappingRedisConverter} is an {@link InitializingBean} and requires + * {@link MappingRedisConverter#afterPropertiesSet()} to be called. + * + *
+ * 
+ * @RedisHash("persons")
+ * class Person {
+ * 
+ *   @Id String id;
+ *   String firstname;
+ * 
+ *   List nicknames;
+ *   List coworkers;
+ * 
+ *   Address address;
+ *   @Reference Country nationality;
+ * }
+ * 
+ * 
+ * + * The above is represented as: + * + *
+ * 
+ * _class=org.example.Person
+ * id=1
+ * firstname=rand
+ * lastname=al'thor
+ * coworkers.[0].firstname=mat
+ * coworkers.[0].nicknames.[0]=prince of the ravens
+ * coworkers.[1].firstname=perrin
+ * coworkers.[1].address.city=two rivers
+ * nationality=nationality:andora
+ * 
+ * 
+ * + * @author Christoph Strobl + * @since 1.7 + */ +public class MappingRedisConverter implements RedisConverter, InitializingBean { + + private final RedisMappingContext mappingContext; + private final GenericConversionService conversionService; + private final EntityInstantiators entityInstantiators; + private final TypeMapper typeMapper; + private ReferenceResolver referenceResolver; + private IndexResolver indexResolver; + private CustomConversions customConversions; + + /** + * Creates new {@link MappingRedisConverter}. + * + * @param context can be {@literal null}. + */ + MappingRedisConverter(RedisMappingContext context) { + this(context, null, null); + } + + /** + * Creates new {@link MappingRedisConverter} and defaults {@link RedisMappingContext} when {@literal null}. + * + * @param mappingContext can be {@literal null}. + * @param indexResolver can be {@literal null}. + * @param referenceResolver must not be {@literal null}. + */ + public MappingRedisConverter(RedisMappingContext mappingContext, IndexResolver indexResolver, + ReferenceResolver referenceResolver) { + + this.mappingContext = mappingContext != null ? mappingContext : new RedisMappingContext(); + + entityInstantiators = new EntityInstantiators(); + + this.conversionService = new DefaultConversionService(); + this.customConversions = new CustomConversions(); + + typeMapper = new DefaultTypeMapper(new RedisTypeAliasAccessor(this.conversionService)); + + this.referenceResolver = referenceResolver; + + this.indexResolver = indexResolver != null ? indexResolver : new IndexResolverImpl(this.mappingContext); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.convert.EntityReader#read(java.lang.Class, java.lang.Object) + */ + public R read(Class type, final RedisData source) { + return readInternal("", type, source); + } + + @SuppressWarnings("unchecked") + private R readInternal(final String path, Class type, final RedisData source) { + + if (source.getBucket() == null || source.getBucket().isEmpty()) { + return null; + } + + TypeInformation readType = typeMapper.readType(source); + TypeInformation typeToUse = readType != null ? readType : ClassTypeInformation.from(type); + final RedisPersistentEntity entity = mappingContext.getPersistentEntity(typeToUse); + + if (customConversions.hasCustomReadTarget(Map.class, typeToUse.getType())) { + + Map partial = new HashMap(); + + if (!path.isEmpty()) { + + for (Entry entry : source.getBucket().extract(path + ".").entrySet()) { + partial.put(entry.getKey().substring(path.length() + 1), entry.getValue()); + } + + } else { + partial.putAll(source.getBucket().asMap()); + } + R instance = (R) conversionService.convert(partial, typeToUse.getType()); + + if (entity.hasIdProperty()) { + entity.getPropertyAccessor(instance).setProperty(entity.getIdProperty(), source.getId()); + } + return instance; + } + + if (conversionService.canConvert(byte[].class, typeToUse.getType())) { + return (R) conversionService.convert(source.getBucket().get(StringUtils.hasText(path) ? path : "_raw"), + typeToUse.getType()); + } + + EntityInstantiator instantiator = entityInstantiators.getInstantiatorFor(entity); + + Object instance = instantiator.createInstance((RedisPersistentEntity) entity, + new PersistentEntityParameterValueProvider(entity, + new ConverterAwareParameterValueProvider(source, conversionService), null)); + + final PersistentPropertyAccessor accessor = entity.getPropertyAccessor(instance); + + entity.doWithProperties(new PropertyHandler() { + + @Override + public void doWithPersistentProperty(KeyValuePersistentProperty persistentProperty) { + + String currentPath = !path.isEmpty() ? path + "." + persistentProperty.getName() : persistentProperty.getName(); + + PreferredConstructor constructor = entity.getPersistenceConstructor(); + + if (constructor.isConstructorParameter(persistentProperty)) { + return; + } + + if (persistentProperty.isMap()) { + + if (conversionService.canConvert(byte[].class, persistentProperty.getMapValueType())) { + accessor.setProperty( + persistentProperty, + readMapOfSimpleTypes(currentPath, persistentProperty.getType(), persistentProperty.getComponentType(), + persistentProperty.getMapValueType(), source)); + } else { + accessor.setProperty( + persistentProperty, + readMapOfComplexTypes(currentPath, persistentProperty.getType(), persistentProperty.getComponentType(), + persistentProperty.getMapValueType(), source)); + } + } + + else if (persistentProperty.isCollectionLike()) { + + if (conversionService.canConvert(byte[].class, persistentProperty.getComponentType())) { + accessor.setProperty( + persistentProperty, + readCollectionOfSimpleTypes(currentPath, persistentProperty.getType(), persistentProperty + .getTypeInformation().getComponentType().getActualType().getType(), source)); + } else { + accessor.setProperty( + persistentProperty, + readCollectionOfComplexTypes(currentPath, persistentProperty.getType(), persistentProperty + .getTypeInformation().getComponentType().getActualType().getType(), source.getBucket())); + } + + } else if (persistentProperty.isEntity() + && !conversionService.canConvert(byte[].class, persistentProperty.getTypeInformation().getActualType() + .getType())) { + + Class targetType = persistentProperty.getTypeInformation().getActualType().getType(); + + Bucket bucket = source.getBucket().extract(currentPath + "."); + + RedisData source = new RedisData(bucket); + + byte[] type = bucket.get(currentPath + "._class"); + if (type != null && type.length > 0) { + source.getBucket().put("_class", type); + } + + accessor.setProperty(persistentProperty, readInternal(currentPath, targetType, source)); + } else { + + if (persistentProperty.isIdProperty() && StringUtils.isEmpty(path.isEmpty())) { + + if (source.getBucket().get(currentPath) == null) { + accessor.setProperty(persistentProperty, + fromBytes(source.getBucket().get(currentPath), persistentProperty.getActualType())); + } else { + accessor.setProperty(persistentProperty, source.getId()); + } + } + + accessor.setProperty(persistentProperty, + fromBytes(source.getBucket().get(currentPath), persistentProperty.getActualType())); + } + } + + }); + + readAssociation(path, source, entity, accessor); + + return (R) instance; + } + + private void readAssociation(final String path, final RedisData source, final KeyValuePersistentEntity entity, + final PersistentPropertyAccessor accessor) { + + entity.doWithAssociations(new AssociationHandler() { + + @Override + public void doWithAssociation(Association association) { + + String currentPath = !path.isEmpty() ? path + "." + association.getInverse().getName() : association + .getInverse().getName(); + + if (association.getInverse().isCollectionLike()) { + + Collection target = CollectionFactory.createCollection(association.getInverse().getType(), + association.getInverse().getComponentType(), 10); + + Bucket bucket = source.getBucket().extract(currentPath + ".["); + + for (Entry entry : bucket.entrySet()) { + + String referenceKey = fromBytes(entry.getValue(), String.class); + String[] args = referenceKey.split(":"); + + Object loadedObject = referenceResolver.resolveReference(args[1], args[0], association.getInverse() + .getActualType()); + + if (loadedObject != null) { + target.add(loadedObject); + } + } + + accessor.setProperty(association.getInverse(), target); + + } else { + + byte[] binKey = source.getBucket().get(currentPath); + if (binKey == null || binKey.length == 0) { + return; + } + + String key = fromBytes(binKey, String.class); + + String[] args = key.split(":"); + Object loadedObject = referenceResolver.resolveReference(args[1], args[0], association.getInverse() + .getActualType()); + + if (loadedObject != null) { + accessor.setProperty(association.getInverse(), loadedObject); + } + } + } + }); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.convert.EntityWriter#write(java.lang.Object, java.lang.Object) + */ + @SuppressWarnings({ "rawtypes" }) + public void write(Object source, final RedisData sink) { + + final RedisPersistentEntity entity = mappingContext.getPersistentEntity(source.getClass()); + + if (!customConversions.hasCustomWriteTarget(source.getClass())) { + typeMapper.writeType(ClassUtils.getUserClass(source), sink); + } + sink.setKeyspace(entity.getKeySpace()); + + writeInternal(entity.getKeySpace(), "", source, entity.getTypeInformation(), sink); + sink.setId((Serializable) entity.getIdentifierAccessor(source).getIdentifier()); + + Long ttl = entity.getTimeToLiveAccessor().getTimeToLive(source); + if (ttl != null && ttl > 0) { + sink.setTimeToLive(ttl); + } + + for (IndexedData indexeData : indexResolver.resolveIndexesFor(entity.getTypeInformation(), source)) { + sink.addIndexedData(indexeData); + } + + } + + /** + * @param keyspace + * @param path + * @param value + * @param typeHint + * @param sink + */ + private void writeInternal(final String keyspace, final String path, final Object value, TypeInformation typeHint, + final RedisData sink) { + + if (value == null) { + return; + } + + if (customConversions.hasCustomWriteTarget(value.getClass())) { + + if (customConversions.getCustomWriteTarget(value.getClass()).equals(byte[].class)) { + sink.getBucket().put(StringUtils.hasText(path) ? path : "_raw", conversionService.convert(value, byte[].class)); + } else { + writeToBucket(path, value, sink); + } + return; + } + + if (value.getClass() != typeHint.getType()) { + sink.getBucket().put((!path.isEmpty() ? path + "._class" : "_class"), toBytes(value.getClass().getName())); + } + + final KeyValuePersistentEntity entity = mappingContext.getPersistentEntity(value.getClass()); + final PersistentPropertyAccessor accessor = entity.getPropertyAccessor(value); + + entity.doWithProperties(new PropertyHandler() { + + @Override + public void doWithPersistentProperty(KeyValuePersistentProperty persistentProperty) { + + String propertyStringPath = (!path.isEmpty() ? path + "." : "") + persistentProperty.getName(); + + if (persistentProperty.isIdProperty()) { + + sink.getBucket().put(propertyStringPath, toBytes(accessor.getProperty(persistentProperty))); + return; + } + + if (persistentProperty.isMap()) { + writeMap(keyspace, propertyStringPath, persistentProperty.getMapValueType(), + (Map) accessor.getProperty(persistentProperty), sink); + } else if (persistentProperty.isCollectionLike()) { + writeCollection(keyspace, propertyStringPath, (Collection) accessor.getProperty(persistentProperty), + persistentProperty.getTypeInformation().getComponentType(), sink); + } else if (persistentProperty.isEntity()) { + writeInternal(keyspace, propertyStringPath, accessor.getProperty(persistentProperty), persistentProperty + .getTypeInformation().getActualType(), sink); + } else { + + Object propertyValue = accessor.getProperty(persistentProperty); + sink.getBucket().put(propertyStringPath, toBytes(propertyValue)); + } + } + }); + + writeAssiciation(keyspace, path, entity, value, sink); + } + + private void writeAssiciation(final String keyspace, final String path, final KeyValuePersistentEntity entity, + final Object value, final RedisData sink) { + + if (value == null) { + return; + } + + final PersistentPropertyAccessor accessor = entity.getPropertyAccessor(value); + + entity.doWithAssociations(new AssociationHandler() { + + @Override + public void doWithAssociation(Association association) { + + Object refObject = accessor.getProperty(association.getInverse()); + if (refObject == null) { + return; + } + + if (association.getInverse().isCollectionLike()) { + + KeyValuePersistentEntity ref = mappingContext.getPersistentEntity(association.getInverse() + .getTypeInformation().getComponentType().getActualType()); + + String keyspace = ref.getKeySpace(); + String propertyStringPath = (!path.isEmpty() ? path + "." : "") + association.getInverse().getName(); + + int i = 0; + for (Object o : (Collection) refObject) { + + Object refId = ref.getPropertyAccessor(o).getProperty(ref.getIdProperty()); + sink.getBucket().put(propertyStringPath + ".[" + i + "]", toBytes(keyspace + ":" + refId)); + i++; + } + + } else { + + KeyValuePersistentEntity ref = mappingContext.getPersistentEntity(association.getInverse() + .getTypeInformation()); + String keyspace = ref.getKeySpace(); + + Object refId = ref.getPropertyAccessor(refObject).getProperty(ref.getIdProperty()); + + String propertyStringPath = (!path.isEmpty() ? path + "." : "") + association.getInverse().getName(); + sink.getBucket().put(propertyStringPath, toBytes(keyspace + ":" + refId)); + } + } + }); + } + + /** + * @param keyspace + * @param path + * @param values + * @param typeHint + * @param sink + */ + private void writeCollection(String keyspace, String path, Collection values, TypeInformation typeHint, + RedisData sink) { + + if (values == null) { + return; + } + + int i = 0; + for (Object value : values) { + + String currentPath = path + ".[" + i + "]"; + + if (customConversions.hasCustomWriteTarget(value.getClass())) { + writeToBucket(currentPath, value, sink); + } else { + writeInternal(keyspace, currentPath, value, typeHint, sink); + } + i++; + } + } + + private void writeToBucket(String path, Object value, RedisData sink) { + + if (value == null) { + return; + } + + if (customConversions.hasCustomWriteTarget(value.getClass())) { + Class targetType = customConversions.getCustomWriteTarget(value.getClass()); + + if (ClassUtils.isAssignable(Map.class, targetType)) { + + Map map = (Map) conversionService.convert(value, targetType); + for (Map.Entry entry : map.entrySet()) { + sink.getBucket().put(path + (StringUtils.hasText(path) ? "." : "") + entry.getKey(), + toBytes(entry.getValue())); + } + } else if (ClassUtils.isAssignable(byte[].class, targetType)) { + sink.getBucket().put(path, toBytes(value)); + } else { + throw new IllegalArgumentException("converter must not fool me!!"); + } + + } + + } + + /** + * @param path + * @param collectionType + * @param valueType + * @param source + * @return + */ + private Collection readCollectionOfSimpleTypes(String path, Class collectionType, Class valueType, + RedisData source) { + + Collection target = CollectionFactory.createCollection(collectionType, valueType, 10); + + Bucket partial = source.getBucket().extract(path + ".["); + + for (byte[] value : partial.values()) { + target.add(fromBytes(value, valueType)); + } + return target; + } + + /** + * @param path + * @param collectionType + * @param valueType + * @param source + * @return + */ + private Collection readCollectionOfComplexTypes(String path, Class collectionType, Class valueType, + Bucket source) { + + Collection target = CollectionFactory.createCollection(collectionType, valueType, 10); + + Set keys = source.extractAllKeysFor(path); + + for (String key : keys) { + + Bucket elementData = source.extract(key); + + byte[] typeInfo = elementData.get(key + "._class"); + if (typeInfo != null && typeInfo.length > 0) { + elementData.put("_class", typeInfo); + } + + Object o = readInternal(key, valueType, new RedisData(elementData)); + target.add(o); + } + + return target; + } + + /** + * @param keyspace + * @param path + * @param mapValueType + * @param source + * @param sink + */ + private void writeMap(String keyspace, String path, Class mapValueType, Map source, RedisData sink) { + if (CollectionUtils.isEmpty(source)) { + return; + } + + for (Map.Entry entry : source.entrySet()) { + + if (entry.getValue() == null || entry.getKey() == null) { + continue; + } + + String currentPath = path + ".[" + entry.getKey() + "]"; + + if (customConversions.hasCustomWriteTarget(entry.getValue().getClass())) { + writeToBucket(currentPath, entry.getValue(), sink); + } else { + writeInternal(keyspace, currentPath, entry.getValue(), ClassTypeInformation.from(mapValueType), sink); + } + } + } + + /** + * @param path + * @param mapType + * @param keyType + * @param valueType + * @param source + * @return + */ + private Map readMapOfSimpleTypes(String path, Class mapType, Class keyType, Class valueType, + RedisData source) { + + Map target = CollectionFactory.createMap(mapType, 10); + + Bucket partial = source.getBucket().extract(path + ".["); + + for (Entry entry : partial.entrySet()) { + + String regex = "^(" + Pattern.quote(path) + "\\.\\[)(.*?)(\\])"; + Pattern pattern = Pattern.compile(regex); + + Matcher matcher = pattern.matcher(entry.getKey()); + if (!matcher.find()) { + throw new RuntimeException("baähhh"); + } + String key = matcher.group(2); + target.put(key, fromBytes(entry.getValue(), valueType)); + } + + return target; + } + + /** + * @param path + * @param mapType + * @param keyType + * @param valueType + * @param source + * @return + */ + private Map readMapOfComplexTypes(String path, Class mapType, Class keyType, Class valueType, + RedisData source) { + + Map target = CollectionFactory.createMap(mapType, 10); + + Set keys = source.getBucket().extractAllKeysFor(path); + + for (String key : keys) { + + String regex = "^(" + Pattern.quote(path) + "\\.\\[)(.*?)(\\])"; + Pattern pattern = Pattern.compile(regex); + + Matcher matcher = pattern.matcher(key); + if (!matcher.find()) { + throw new RuntimeException("baähhh"); + } + String mapKey = matcher.group(2); + + Bucket partial = source.getBucket().extract(key); + + byte[] typeInfo = partial.get(key + "._class"); + if (typeInfo != null && typeInfo.length > 0) { + partial.put("_class", typeInfo); + } + + Object o = readInternal(key, valueType, new RedisData(partial)); + target.put(mapKey, o); + } + + return target; + } + + /** + * Convert given source to binary representation using the underlying {@link ConversionService}. + * + * @param source + * @return + * @throws ConverterNotFoundException + */ + public byte[] toBytes(Object source) { + + if (source instanceof byte[]) { + return (byte[]) source; + } + + return conversionService.convert(source, byte[].class); + } + + /** + * Convert given binary representation to desired target type using the underlying {@link ConversionService}. + * + * @param source + * @param type + * @return + * @throws ConverterNotFoundException + */ + public T fromBytes(byte[] source, Class type) { + return conversionService.convert(source, type); + } + + /** + * Set {@link CustomConversions} to be applied. + * + * @param customConversions + */ + public void setCustomConversions(CustomConversions customConversions) { + this.customConversions = customConversions != null ? customConversions : new CustomConversions(); + } + + public void setReferenceResolver(ReferenceResolver referenceResolver) { + this.referenceResolver = referenceResolver; + } + + public void setIndexResolver(IndexResolver indexResolver) { + this.indexResolver = indexResolver; + } + + /* + * (non-Javadoc) + * @see org.springframework.data.convert.EntityConverter#getMappingContext() + */ + public RedisMappingContext getMappingContext() { + return this.mappingContext; + } + + /* + * (non-Javadoc) + * @see org.springframework.data.convert.EntityConverter#getConversionService() + */ + public ConversionService getConversionService() { + return this.conversionService; + } + + @Override + public void afterPropertiesSet() { + this.initializeConverters(); + } + + private void initializeConverters() { + customConversions.registerConvertersIn(conversionService); + } + + /** + * @author Christoph Strobl + */ + private static class ConverterAwareParameterValueProvider implements + PropertyValueProvider { + + private final RedisData source; + private final ConversionService conversionService; + + public ConverterAwareParameterValueProvider(RedisData source, ConversionService conversionService) { + this.source = source; + this.conversionService = conversionService; + } + + @Override + @SuppressWarnings("unchecked") + public T getPropertyValue(KeyValuePersistentProperty property) { + return (T) conversionService.convert(source.getBucket().get(property.getName()), property.getActualType()); + } + } + + /** + * @author Christoph Strobl + */ + private static class RedisTypeAliasAccessor implements TypeAliasAccessor { + + private final String typeKey; + + private final ConversionService conversionService; + + RedisTypeAliasAccessor(ConversionService conversionService) { + this(conversionService, "_class"); + } + + RedisTypeAliasAccessor(ConversionService conversionService, String typeKey) { + + this.conversionService = conversionService; + this.typeKey = typeKey; + } + + @Override + public Object readAliasFrom(RedisData source) { + return conversionService.convert(source.getBucket().get(typeKey), String.class); + } + + @Override + public void writeTypeTo(RedisData sink, Object alias) { + sink.getBucket().put(typeKey, conversionService.convert(alias, byte[].class)); + } + } + + enum ClassNameKeySpaceResolver implements KeySpaceResolver { + + INSTANCE; + + /* + * (non-Javadoc) + * @see org.springframework.data.keyvalue.core.KeySpaceResolver#resolveKeySpace(java.lang.Class) + */ + @Override + public String resolveKeySpace(Class type) { + + Assert.notNull(type, "Type must not be null!"); + return ClassUtils.getUserClass(type).getName(); + } + } + +} diff --git a/src/main/java/org/springframework/data/redis/core/convert/RedisConverter.java b/src/main/java/org/springframework/data/redis/core/convert/RedisConverter.java new file mode 100644 index 000000000..556d2ab72 --- /dev/null +++ b/src/main/java/org/springframework/data/redis/core/convert/RedisConverter.java @@ -0,0 +1,31 @@ +/* + * Copyright 2015 the original author or authors. + * + * Licensed 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.springframework.data.redis.core.convert; + +import org.springframework.data.convert.EntityConverter; +import org.springframework.data.keyvalue.core.mapping.KeyValuePersistentEntity; +import org.springframework.data.keyvalue.core.mapping.KeyValuePersistentProperty; + +/** + * Redis sepcific {@link EntityConverter}. + * + * @author Christoph Strobl + * @since 1.7 + */ +public interface RedisConverter extends + EntityConverter, KeyValuePersistentProperty, Object, RedisData> { + +} diff --git a/src/main/java/org/springframework/data/redis/core/convert/RedisData.java b/src/main/java/org/springframework/data/redis/core/convert/RedisData.java new file mode 100644 index 000000000..975fc7cad --- /dev/null +++ b/src/main/java/org/springframework/data/redis/core/convert/RedisData.java @@ -0,0 +1,166 @@ +/* + * Copyright 2015 the original author or authors. + * + * Licensed 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.springframework.data.redis.core.convert; + +import java.io.Serializable; +import java.util.Collections; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.TimeUnit; + +import org.springframework.util.Assert; + +/** + * Data object holding {@link Bucket} representing the domain object to be stored in a Redis hash. Index information + * points to additional structures holding the objects is for searching. + * + * @author Christoph Strobl + * @since 1.7 + */ +public class RedisData { + + private String keyspace; + private Serializable id; + + private Bucket bucket; + private Set indexedData; + + private Long timeToLive; + + /** + * Creates new {@link RedisData} with empty {@link Bucket}. + */ + public RedisData() { + this(Collections. emptyMap()); + } + + /** + * Creates new {@link RedisData} with {@link Bucket} holding provided values. + * + * @param raw should not be {@literal null}. + */ + public RedisData(Map raw) { + this(Bucket.newBucketFromRawMap(raw)); + } + + /** + * Creates new {@link RedisData} with {@link Bucket} + * + * @param bucket must not be {@literal null}. + */ + public RedisData(Bucket bucket) { + + Assert.notNull(bucket, "Bucket must not be null!"); + this.bucket = bucket; + this.indexedData = new HashSet(); + } + + /** + * Set the id to be used as part of the key. + * + * @param id + */ + public void setId(Serializable id) { + this.id = id; + } + + /** + * @return + */ + public Serializable getId() { + return this.id; + } + + /** + * Get the time before expiration in seconds. + * + * @return {@literal null} if not set. + */ + public Long getTimeToLive() { + return timeToLive; + } + + /** + * @param index + */ + public void addIndexedData(IndexedData index) { + + Assert.notNull(index, "IndexedData to add must not be null!"); + this.indexedData.add(index); + } + + /** + * @return never {@literal null}. + */ + public Set getIndexedData() { + return Collections.unmodifiableSet(this.indexedData); + } + + /** + * @return + */ + public String getKeyspace() { + return keyspace; + } + + /** + * @param keyspace + */ + public void setKeyspace(String keyspace) { + this.keyspace = keyspace; + } + + /** + * @return + */ + public Bucket getBucket() { + return bucket; + } + + /** + * Set the time before expiration in {@link TimeUnit#SECONDS}. + * + * @param timeToLive can be {@literal null}. + */ + public void setTimeToLive(Long timeToLive) { + this.timeToLive = timeToLive; + } + + /** + * Set the time before expiration converting the given arguments to {@link TimeUnit#SECONDS}. + * + * @param timeToLive must not be {@literal null} + * @param timeUnit must not be {@literal null} + */ + public void setTimeToLive(Long timeToLive, TimeUnit timeUnit) { + + Assert.notNull(timeToLive, "TimeToLive must not be null when used with TimeUnit!"); + Assert.notNull(timeToLive, "TimeUnit must not be null!"); + + setTimeToLive(TimeUnit.SECONDS.convert(timeToLive, timeUnit)); + } + + /* + * (non-Javadoc) + * @see java.lang.Object#toString() + */ + @Override + public String toString() { + return "RedisDataObject [key=" + keyspace + ":" + id + ", hash=" + bucket + "]"; + } + +} diff --git a/src/main/java/org/springframework/data/redis/core/convert/ReferenceResolver.java b/src/main/java/org/springframework/data/redis/core/convert/ReferenceResolver.java new file mode 100644 index 000000000..5d18b20f1 --- /dev/null +++ b/src/main/java/org/springframework/data/redis/core/convert/ReferenceResolver.java @@ -0,0 +1,37 @@ +/* + * Copyright 2015 the original author or authors. + * + * Licensed 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.springframework.data.redis.core.convert; + +import java.io.Serializable; + +import org.springframework.data.annotation.Reference; + +/** + * {@link ReferenceResolver} retrieves Objects marked with {@link Reference} from Redis. + * + * @author Christoph Strobl + * @since 1.7 + */ +public interface ReferenceResolver { + + /** + * @param id must not be {@literal null}. + * @param keyspace must not be {@literal null}. + * @param type must not be {@literal null}. + * @return {@literal null} if referenced object does not exist. + */ + T resolveReference(Serializable id, Serializable keyspace, Class type); +} diff --git a/src/main/java/org/springframework/data/redis/core/convert/SimpleIndexedPropertyValue.java b/src/main/java/org/springframework/data/redis/core/convert/SimpleIndexedPropertyValue.java new file mode 100644 index 000000000..84f9b3204 --- /dev/null +++ b/src/main/java/org/springframework/data/redis/core/convert/SimpleIndexedPropertyValue.java @@ -0,0 +1,123 @@ +/* + * Copyright 2015 the original author or authors. + * + * Licensed 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.springframework.data.redis.core.convert; + +import org.springframework.util.ObjectUtils; + +/** + * {@link IndexedData} implementation indicating storage of data within a Redis Set. + * + * @author Christoph Strobl + * @since 1.7 + */ +public class SimpleIndexedPropertyValue implements IndexedData { + + private final String keyspace; + private final String path; + private final Object value; + + /** + * Creates new {@link SimpleIndexedPropertyValue}. + * + * @param keyspace must not be {@literal null}. + * @param path must not be {@literal null}. + * @param value can be {@literal null}. + */ + public SimpleIndexedPropertyValue(String keyspace, String path, Object value) { + + this.keyspace = keyspace; + this.path = path; + this.value = value; + } + + /** + * Get the value to index. + * + * @return can be {@literal null}. + */ + public Object getValue() { + return value; + } + + /* + * (non-Javadoc) + * @see org.springframework.data.redis.core.convert.IndexedData#getPath() + */ + @Override + public String getPath() { + return path; + } + + /* + * (non-Javadoc) + * @see org.springframework.data.redis.core.convert.IndexedData#getKeySpace() + */ + @Override + public String getKeySpace() { + return this.keyspace; + } + + /* + * (non-Javadoc) + * @see java.lang.Object#hashCode() + */ + @Override + public int hashCode() { + + int result = 1; + result += ObjectUtils.nullSafeHashCode(keyspace); + result += ObjectUtils.nullSafeHashCode(path); + result += ObjectUtils.nullSafeHashCode(value); + return result; + } + + /* + * (non-Javadoc) + * @see java.lang.Object#equals(java.lang.Object) + */ + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null) { + return false; + } + if (!(obj instanceof SimpleIndexedPropertyValue)) { + return false; + } + + SimpleIndexedPropertyValue that = (SimpleIndexedPropertyValue) obj; + + if (!ObjectUtils.nullSafeEquals(this.keyspace, that.keyspace)) { + return false; + } + if (!ObjectUtils.nullSafeEquals(this.path, that.path)) { + return false; + } + return ObjectUtils.nullSafeEquals(this.value, that.value); + } + + /* + * (non-Javadoc) + * @see java.lang.Object#toString() + */ + @Override + public String toString() { + return "SimpleIndexedPropertyValue [keyspace=" + keyspace + ", path=" + path + ", value=" + value + "]"; + } + +} diff --git a/src/main/java/org/springframework/data/redis/core/index/IndexConfiguration.java b/src/main/java/org/springframework/data/redis/core/index/IndexConfiguration.java new file mode 100644 index 000000000..43a29981b --- /dev/null +++ b/src/main/java/org/springframework/data/redis/core/index/IndexConfiguration.java @@ -0,0 +1,189 @@ +/* + * Copyright 2015 the original author or authors. + * + * Licensed 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.springframework.data.redis.core.index; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Set; +import java.util.concurrent.CopyOnWriteArraySet; + +import org.springframework.util.Assert; +import org.springframework.util.ObjectUtils; + +/** + * {@link IndexConfiguration} allows programmatic setup of indexes. This is suitable for cases where there is no option + * to use the equivalent {@link Indexed} annotation. + * + * @author Christoph Strobl + * @since 1.7 + */ +public class IndexConfiguration { + + private final Set definitions; + + /** + * Creates new empty {@link IndexConfiguration}. + */ + public IndexConfiguration() { + this.definitions = new CopyOnWriteArraySet(); + for (RedisIndexSetting initial : initialConfiguration()) { + addIndexDefinition(initial); + } + } + + /** + * Checks if an index is defined for a given keyspace and property path. + * + * @param keyspace + * @param path + * @return true if index is defined. + */ + public boolean hasIndexFor(Serializable keyspace, String path) { + + for (IndexType type : IndexType.values()) { + RedisIndexSetting def = getIndexDefinition(keyspace, path, type); + if (def != null) { + return true; + } + } + return false; + } + + /** + * Get the list of {@link RedisIndexSetting} for a given keyspace and property path. + * + * @param keyspace + * @param path + * @return never {@literal null}. + */ + public List getIndexDefinitionsFor(Serializable keyspace, String path) { + + List indexDefinitions = new ArrayList(); + for (IndexType type : IndexType.values()) { + RedisIndexSetting def = getIndexDefinition(keyspace, path, type); + if (def != null) { + indexDefinitions.add(def); + } + } + + return indexDefinitions; + } + + /** + * Add given {@link RedisIndexSetting}. + * + * @param indexDefinition must not be {@literal null}. + */ + public void addIndexDefinition(RedisIndexSetting indexDefinition) { + + Assert.notNull(indexDefinition, "RedisIndexDefinition must not be null in order to be added."); + this.definitions.add(indexDefinition); + } + + private RedisIndexSetting getIndexDefinition(Serializable keyspace, String path, IndexType type) { + + for (RedisIndexSetting indexDef : definitions) { + if (indexDef.getKeyspace().equals(keyspace) && indexDef.getPath().equals(path) && indexDef.getType().equals(type)) { + return indexDef; + } + } + + return null; + } + + /** + * Customization hook. + * + * @return must not return {@literal null}. + */ + protected Iterable initialConfiguration() { + return Collections.emptySet(); + } + + /** + * @author Christoph Strobl + * @since 1.7 + */ + public static class RedisIndexSetting { + + private final Serializable keyspace; + private final String path; + private final IndexType type; + + public RedisIndexSetting(Serializable keyspace, String path) { + this(keyspace, path, null); + } + + public RedisIndexSetting(Serializable keyspace, String path, IndexType type) { + + this.keyspace = keyspace; + this.path = path; + this.type = type == null ? IndexType.SIMPLE : type; + } + + public Serializable getKeyspace() { + return keyspace; + } + + public String getPath() { + return path; + } + + public IndexType getType() { + return type; + } + + @Override + public int hashCode() { + + int result = ObjectUtils.nullSafeHashCode(keyspace); + result += ObjectUtils.nullSafeHashCode(path); + result += ObjectUtils.nullSafeHashCode(type); + return result; + } + + @Override + public boolean equals(Object obj) { + + if (this == obj) { + return true; + } + if (obj == null) { + return false; + } + if (!(obj instanceof RedisIndexSetting)) { + return false; + } + + RedisIndexSetting that = (RedisIndexSetting) obj; + + if (!ObjectUtils.nullSafeEquals(this.keyspace, that.keyspace)) { + return false; + } + if (!ObjectUtils.nullSafeEquals(this.path, that.path)) { + return false; + } + if (!ObjectUtils.nullSafeEquals(this.type, that.type)) { + return false; + } + return true; + } + + } + +} diff --git a/src/main/java/org/springframework/data/redis/core/index/IndexType.java b/src/main/java/org/springframework/data/redis/core/index/IndexType.java new file mode 100644 index 000000000..5e35bdd99 --- /dev/null +++ b/src/main/java/org/springframework/data/redis/core/index/IndexType.java @@ -0,0 +1,30 @@ +/* + * Copyright 2015 the original author or authors. + * + * Licensed 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.springframework.data.redis.core.index; + +/** + * {@link IndexType} indicates structure of index. + * + * @author Christoph Strobl + * @since 1.7 + */ +public enum IndexType { + + /** + * Simple indicates usage of Redis Set for persisting data. + */ + SIMPLE +} diff --git a/src/main/java/org/springframework/data/redis/core/index/Indexed.java b/src/main/java/org/springframework/data/redis/core/index/Indexed.java new file mode 100644 index 000000000..0bfa454f1 --- /dev/null +++ b/src/main/java/org/springframework/data/redis/core/index/Indexed.java @@ -0,0 +1,42 @@ +/* + * Copyright 2015 the original author or authors. + * + * Licensed 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.springframework.data.redis.core.index; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Mark property to be included in a secondary index. + * + * @author Christoph Strobl + * @since 1.7 + */ +@Documented +@Retention(RetentionPolicy.RUNTIME) +@Target({ ElementType.FIELD, ElementType.ANNOTATION_TYPE }) +public @interface Indexed { + + /** + * Type of index to use. + * + * @return + */ + IndexType type() default IndexType.SIMPLE; + +} diff --git a/src/main/java/org/springframework/data/redis/core/mapping/BasicRedisPersistentEntity.java b/src/main/java/org/springframework/data/redis/core/mapping/BasicRedisPersistentEntity.java new file mode 100644 index 000000000..1508b16f2 --- /dev/null +++ b/src/main/java/org/springframework/data/redis/core/mapping/BasicRedisPersistentEntity.java @@ -0,0 +1,58 @@ +/* + * Copyright 2015 the original author or authors. + * + * Licensed 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.springframework.data.redis.core.mapping; + +import org.springframework.data.keyvalue.core.mapping.BasicKeyValuePersistentEntity; +import org.springframework.data.keyvalue.core.mapping.KeySpaceResolver; +import org.springframework.data.redis.core.TimeToLiveAccessor; +import org.springframework.data.util.TypeInformation; +import org.springframework.util.Assert; + +/** + * {@link RedisPersistentEntity} implementation. + * + * @author Christoph Strobl + * @param + */ +public class BasicRedisPersistentEntity extends BasicKeyValuePersistentEntity implements RedisPersistentEntity { + + private TimeToLiveAccessor timeToLiveAccessor; + + /** + * Creates new {@link BasicRedisPersistentEntity}. + * + * @param information must not be {@literal null}. + * @param fallbackKeySpaceResolver can be {@literal null}. + * @param timeToLiveResolver can be {@literal null}. + */ + public BasicRedisPersistentEntity(TypeInformation information, KeySpaceResolver fallbackKeySpaceResolver, + TimeToLiveAccessor timeToLiveAccessor) { + super(information, fallbackKeySpaceResolver); + + Assert.notNull(timeToLiveAccessor, "TimeToLiveAccessor must not be null"); + this.timeToLiveAccessor = timeToLiveAccessor; + } + + /* + * (non-Javadoc) + * @see org.springframework.data.redis.core.mapping.RedisPersistentEntity#getTimeToLiveAccessor() + */ + @Override + public TimeToLiveAccessor getTimeToLiveAccessor() { + return this.timeToLiveAccessor; + } + +} diff --git a/src/main/java/org/springframework/data/redis/core/mapping/RedisMappingContext.java b/src/main/java/org/springframework/data/redis/core/mapping/RedisMappingContext.java new file mode 100644 index 000000000..be6b0dd61 --- /dev/null +++ b/src/main/java/org/springframework/data/redis/core/mapping/RedisMappingContext.java @@ -0,0 +1,369 @@ +/* + * Copyright 2015 the original author or authors. + * + * Licensed 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.springframework.data.redis.core.mapping; + +import java.beans.PropertyDescriptor; +import java.lang.reflect.Field; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +import org.springframework.core.annotation.AnnotationUtils; +import org.springframework.data.keyvalue.annotation.KeySpace; +import org.springframework.data.keyvalue.core.mapping.KeySpaceResolver; +import org.springframework.data.keyvalue.core.mapping.KeyValuePersistentEntity; +import org.springframework.data.keyvalue.core.mapping.KeyValuePersistentProperty; +import org.springframework.data.keyvalue.core.mapping.context.KeyValueMappingContext; +import org.springframework.data.mapping.PersistentProperty; +import org.springframework.data.mapping.context.MappingContext; +import org.springframework.data.mapping.model.SimpleTypeHolder; +import org.springframework.data.redis.core.RedisHash; +import org.springframework.data.redis.core.TimeToLive; +import org.springframework.data.redis.core.TimeToLiveAccessor; +import org.springframework.data.redis.core.convert.KeyspaceConfiguration; +import org.springframework.data.redis.core.convert.KeyspaceConfiguration.KeyspaceSettings; +import org.springframework.data.redis.core.convert.MappingConfiguration; +import org.springframework.data.redis.core.index.IndexConfiguration; +import org.springframework.data.util.TypeInformation; +import org.springframework.util.Assert; +import org.springframework.util.ClassUtils; +import org.springframework.util.ReflectionUtils; +import org.springframework.util.ReflectionUtils.MethodCallback; +import org.springframework.util.ReflectionUtils.MethodFilter; +import org.springframework.util.StringUtils; + +/** + * Redis specific {@link MappingContext}. + * + * @author Christoph Strobl + * @since 1.7 + */ +public class RedisMappingContext extends KeyValueMappingContext { + + private MappingConfiguration mappingConfiguration; + private KeySpaceResolver fallbackKeySpaceResolver; + private TimeToLiveAccessor timeToLiveAccessor; + + /** + * Creates new {@link RedisMappingContext} with empty {@link MappingConfiguration}. + */ + public RedisMappingContext() { + this(new MappingConfiguration(new IndexConfiguration(), new KeyspaceConfiguration())); + } + + /** + * Creates new {@link RedisMappingContext}. + * + * @param mappingConfiguration can be {@literal null}. + */ + public RedisMappingContext(MappingConfiguration mappingConfiguration) { + + this.mappingConfiguration = mappingConfiguration != null ? mappingConfiguration : new MappingConfiguration( + new IndexConfiguration(), new KeyspaceConfiguration()); + + setFallbackKeySpaceResolver(new ConfigAwareKeySpaceResolver(this.mappingConfiguration.getKeyspaceConfiguration())); + this.timeToLiveAccessor = new ConfigAwareTimeToLiveAccessor(this.mappingConfiguration.getKeyspaceConfiguration(), + this); + } + + /** + * Configures the {@link KeySpaceResolver} to be used if not explicit key space is annotated to the domain type. + * + * @param fallbackKeySpaceResolver can be {@literal null}. + */ + public void setFallbackKeySpaceResolver(KeySpaceResolver fallbackKeySpaceResolver) { + this.fallbackKeySpaceResolver = fallbackKeySpaceResolver; + } + + /* + * (non-Javadoc) + * @see org.springframework.data.mapping.context.AbstractMappingContext#createPersistentEntity(org.springframework.data.util.TypeInformation) + */ + @Override + protected RedisPersistentEntity createPersistentEntity(TypeInformation typeInformation) { + return new BasicRedisPersistentEntity(typeInformation, fallbackKeySpaceResolver, timeToLiveAccessor); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.mapping.context.AbstractMappingContext#getPersistentEntity(java.lang.Class) + */ + @Override + public RedisPersistentEntity getPersistentEntity(Class type) { + return (RedisPersistentEntity) super.getPersistentEntity(type); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.mapping.context.AbstractMappingContext#getPersistentEntity(org.springframework.data.mapping.PersistentProperty) + */ + @Override + public RedisPersistentEntity getPersistentEntity(KeyValuePersistentProperty persistentProperty) { + return (RedisPersistentEntity) super.getPersistentEntity(persistentProperty); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.mapping.context.AbstractMappingContext#getPersistentEntity(org.springframework.data.util.TypeInformation) + */ + @Override + public RedisPersistentEntity getPersistentEntity(TypeInformation type) { + return (RedisPersistentEntity) super.getPersistentEntity(type); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.keyvalue.core.mapping.context.KeyValueMappingContext#createPersistentProperty(java.lang.reflect.Field, java.beans.PropertyDescriptor, org.springframework.data.keyvalue.core.mapping.KeyValuePersistentEntity, org.springframework.data.mapping.model.SimpleTypeHolder) + */ + @Override + protected KeyValuePersistentProperty createPersistentProperty(Field field, PropertyDescriptor descriptor, + KeyValuePersistentEntity owner, SimpleTypeHolder simpleTypeHolder) { + return new RedisPersistentProperty(field, descriptor, owner, simpleTypeHolder); + } + + /** + * Get the {@link MappingConfiguration} used. + * + * @return never {@literal null}. + */ + public MappingConfiguration getMappingConfiguration() { + return mappingConfiguration; + } + + /** + * {@link KeySpaceResolver} implementation considering {@link KeySpace} and {@link KeyspaceConfiguration}. + * + * @author Christoph Strobl + * @since 1.7 + */ + static class ConfigAwareKeySpaceResolver implements KeySpaceResolver { + + private final KeyspaceConfiguration keyspaceConfig; + + public ConfigAwareKeySpaceResolver(KeyspaceConfiguration keyspaceConfig) { + + this.keyspaceConfig = keyspaceConfig; + } + + /* + * (non-Javadoc) + * @see org.springframework.data.keyvalue.core.mapping.KeySpaceResolver#resolveKeySpace(java.lang.Class) + */ + @Override + public String resolveKeySpace(Class type) { + + Assert.notNull(type, "Type must not be null!"); + if (keyspaceConfig.hasSettingsFor(type)) { + + String value = keyspaceConfig.getKeyspaceSettings(type).getKeyspace(); + if (StringUtils.hasText(value)) { + return value; + } + } + + return ClassNameKeySpaceResolver.INSTANCE.resolveKeySpace(type); + } + } + + /** + * {@link KeySpaceResolver} implementation considering {@link KeySpace}. + * + * @author Christoph Strobl + * @since 1.7 + */ + enum ClassNameKeySpaceResolver implements KeySpaceResolver { + + INSTANCE; + + /* + * (non-Javadoc) + * @see org.springframework.data.keyvalue.core.KeySpaceResolver#resolveKeySpace(java.lang.Class) + */ + @Override + public String resolveKeySpace(Class type) { + + Assert.notNull(type, "Type must not be null!"); + return ClassUtils.getUserClass(type).getName(); + } + } + + /** + * {@link TimeToLiveAccessor} implementation considering {@link KeyspaceConfiguration}. + * + * @author Christoph Strobl + * @since 1.7 + */ + static class ConfigAwareTimeToLiveAccessor implements TimeToLiveAccessor { + + private final Map, Long> defaultTimeouts; + private final Map, PersistentProperty> timeoutProperties; + private final Map, Method> timeoutMethods; + private final KeyspaceConfiguration keyspaceConfig; + private final RedisMappingContext mappingContext; + + /** + * Creates new {@link ConfigAwareTimeToLiveAccessor} + * + * @param keyspaceConfig must not be {@literal null}. + * @param mappingContext must not be {@literal null}. + */ + ConfigAwareTimeToLiveAccessor(KeyspaceConfiguration keyspaceConfig, RedisMappingContext mappingContext) { + + Assert.notNull(keyspaceConfig, "KeyspaceConfiguration must not be null!"); + Assert.notNull(mappingContext, "MappingContext must not be null!"); + + this.defaultTimeouts = new HashMap, Long>(); + this.timeoutProperties = new HashMap, PersistentProperty>(); + this.timeoutMethods = new HashMap, Method>(); + this.keyspaceConfig = keyspaceConfig; + this.mappingContext = mappingContext; + } + + /* + * (non-Javadoc) + * @see org.springframework.data.redis.core.TimeToLiveResolver#resolveTimeToLive(java.lang.Object) + */ + @Override + @SuppressWarnings({ "rawtypes" }) + public Long getTimeToLive(final Object source) { + + Assert.notNull(source, "Source must not be null!"); + Class type = source instanceof Class ? (Class) source : source.getClass(); + + Long defaultTimeout = resolveDefaultTimeOut(type); + TimeUnit unit = TimeUnit.SECONDS; + + PersistentProperty ttlProperty = resolveTtlProperty(type); + + if (ttlProperty != null) { + + if (ttlProperty.findAnnotation(TimeToLive.class) != null) { + unit = ttlProperty.findAnnotation(TimeToLive.class).unit(); + } + + RedisPersistentEntity entity = mappingContext.getPersistentEntity(type); + Long timeout = (Long) entity.getPropertyAccessor(source).getProperty(ttlProperty); + if (timeout != null) { + return TimeUnit.SECONDS.convert(timeout, unit); + } + } else { + + Method timeoutMethod = resolveTimeMethod(type); + if (timeoutMethod != null) { + + TimeToLive ttl = AnnotationUtils.findAnnotation(timeoutMethod, TimeToLive.class); + try { + Number timeout = (Number) timeoutMethod.invoke(source); + if (timeout != null) { + return TimeUnit.SECONDS.convert(timeout.longValue(), ttl.unit()); + } + } catch (IllegalAccessException e) { + throw new IllegalStateException("Not allowed to access method '" + timeoutMethod.getName() + "': " + + e.getMessage(), e); + } catch (IllegalArgumentException e) { + throw new IllegalStateException("Cannot invoke method '" + timeoutMethod.getName() + + " without arguments': " + e.getMessage(), e); + } catch (InvocationTargetException e) { + throw new IllegalStateException( + "Cannot access method '" + timeoutMethod.getName() + "': " + e.getMessage(), e); + } + } + } + + return defaultTimeout; + } + + private Long resolveDefaultTimeOut(Class type) { + + if (this.defaultTimeouts.containsKey(type)) { + return defaultTimeouts.get(type); + } + + Long defaultTimeout = null; + + if (keyspaceConfig.hasSettingsFor(type)) { + defaultTimeout = keyspaceConfig.getKeyspaceSettings(type).getTimeToLive(); + } + + RedisHash hash = mappingContext.getPersistentEntity(type).findAnnotation(RedisHash.class); + if (hash != null && hash.timeToLive() > 0) { + defaultTimeout = hash.timeToLive(); + } + + defaultTimeouts.put(type, defaultTimeout); + return defaultTimeout; + } + + @SuppressWarnings({ "rawtypes", "unchecked" }) + private PersistentProperty resolveTtlProperty(Class type) { + + if (timeoutProperties.containsKey(type)) { + return timeoutProperties.get(type); + } + + RedisPersistentEntity entity = mappingContext.getPersistentEntity(type); + PersistentProperty ttlProperty = entity.getPersistentProperty(TimeToLive.class); + + if (ttlProperty != null) { + + timeoutProperties.put(type, ttlProperty); + return ttlProperty; + } + + if (keyspaceConfig.hasSettingsFor(type)) { + + KeyspaceSettings settings = keyspaceConfig.getKeyspaceSettings(type); + if (StringUtils.hasText(settings.getTimeToLivePropertyName())) { + + ttlProperty = entity.getPersistentProperty(settings.getTimeToLivePropertyName()); + timeoutProperties.put(type, ttlProperty); + return ttlProperty; + } + } + + timeoutProperties.put(type, null); + return null; + } + + private Method resolveTimeMethod(final Class type) { + + if (timeoutMethods.containsKey(type)) { + return timeoutMethods.get(type); + } + + timeoutMethods.put(type, null); + ReflectionUtils.doWithMethods(type, new MethodCallback() { + + @Override + public void doWith(Method method) throws IllegalArgumentException, IllegalAccessException { + timeoutMethods.put(type, method); + } + }, new MethodFilter() { + + @Override + public boolean matches(Method method) { + return ClassUtils.isAssignable(Number.class, method.getReturnType()) + && AnnotationUtils.findAnnotation(method, TimeToLive.class) != null; + } + }); + + return timeoutMethods.get(type); + } + } + +} diff --git a/src/main/java/org/springframework/data/redis/core/mapping/RedisPersistentEntity.java b/src/main/java/org/springframework/data/redis/core/mapping/RedisPersistentEntity.java new file mode 100644 index 000000000..14c6470a4 --- /dev/null +++ b/src/main/java/org/springframework/data/redis/core/mapping/RedisPersistentEntity.java @@ -0,0 +1,38 @@ +/* + * Copyright 2015 the original author or authors. + * + * Licensed 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.springframework.data.redis.core.mapping; + +import org.springframework.data.keyvalue.core.mapping.KeyValuePersistentEntity; +import org.springframework.data.mapping.PersistentEntity; +import org.springframework.data.redis.core.TimeToLiveAccessor; + +/** + * Redis specific {@link PersistentEntity}. + * + * @author Christoph Strobl + * @param + * @since 1.7 + */ +public interface RedisPersistentEntity extends KeyValuePersistentEntity { + + /** + * Get the {@link TimeToLiveAccessor} associated with the entity. + * + * @return never {@literal null}. + */ + TimeToLiveAccessor getTimeToLiveAccessor(); + +} diff --git a/src/main/java/org/springframework/data/redis/core/mapping/RedisPersistentProperty.java b/src/main/java/org/springframework/data/redis/core/mapping/RedisPersistentProperty.java new file mode 100644 index 000000000..ec032fcff --- /dev/null +++ b/src/main/java/org/springframework/data/redis/core/mapping/RedisPersistentProperty.java @@ -0,0 +1,47 @@ +/* + * Copyright 2015 the original author or authors. + * + * Licensed 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.springframework.data.redis.core.mapping; + +import java.beans.PropertyDescriptor; +import java.lang.reflect.Field; + +import org.springframework.data.keyvalue.core.mapping.KeyValuePersistentProperty; +import org.springframework.data.mapping.PersistentEntity; +import org.springframework.data.mapping.PersistentProperty; +import org.springframework.data.mapping.model.SimpleTypeHolder; + +/** + * Redis specific {@link PersistentProperty} implementation. + * + * @author Christoph Strobl + * @since 1.7 + */ +public class RedisPersistentProperty extends KeyValuePersistentProperty { + + /** + * Creates new {@link RedisPersistentProperty}. + * + * @param field + * @param propertyDescriptor + * @param owner + * @param simpleTypeHolder + */ + public RedisPersistentProperty(Field field, PropertyDescriptor propertyDescriptor, + PersistentEntity owner, SimpleTypeHolder simpleTypeHolder) { + super(field, propertyDescriptor, owner, simpleTypeHolder); + } + +} diff --git a/src/main/java/org/springframework/data/redis/listener/KeyExpirationEventMessageListener.java b/src/main/java/org/springframework/data/redis/listener/KeyExpirationEventMessageListener.java new file mode 100644 index 000000000..3c4b63066 --- /dev/null +++ b/src/main/java/org/springframework/data/redis/listener/KeyExpirationEventMessageListener.java @@ -0,0 +1,84 @@ +/* + * Copyright 2015 the original author or authors. + * + * Licensed 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.springframework.data.redis.listener; + +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.ApplicationEventPublisherAware; +import org.springframework.data.redis.connection.Message; +import org.springframework.data.redis.connection.MessageListener; +import org.springframework.data.redis.core.RedisKeyExpiredEvent; + +/** + * {@link MessageListener} publishing {@link RedisKeyExpiredEvent}s via {@link ApplicationEventPublisher} by listening + * to Redis keyspace notifications for key expirations. + * + * @author Christoph Strobl + * @since 1.7 + */ +public class KeyExpirationEventMessageListener extends KeyspaceEventMessageListener implements + ApplicationEventPublisherAware { + + private ApplicationEventPublisher publisher; + private static final Topic KEYEVENT_EXPIRED_TOPIC = new PatternTopic("__keyevent@*__:expired"); + + /** + * Creates new {@link MessageListener} for {@code __keyevent@*__:expired} messages. + * + * @param listenerContainer must not be {@literal null}. + */ + public KeyExpirationEventMessageListener(RedisMessageListenerContainer listenerContainer) { + super(listenerContainer); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.redis.listener.KeyspaceEventMessageListener#doRegister(org.springframework.data.redis.listener.RedisMessageListenerContainer) + */ + @Override + protected void doRegister(RedisMessageListenerContainer listenerContainer) { + listenerContainer.addMessageListener(this, KEYEVENT_EXPIRED_TOPIC); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.redis.listener.KeyspaceEventMessageListener#doHandleMessage(org.springframework.data.redis.connection.Message) + */ + @Override + protected void doHandleMessage(Message message) { + publishEvent(new RedisKeyExpiredEvent(message.getBody())); + } + + /** + * Publish the event in case an {@link ApplicationEventPublisher} is set. + * + * @param event can be {@literal null}. + */ + protected void publishEvent(RedisKeyExpiredEvent event) { + + if (publisher != null && event != null) { + this.publisher.publishEvent(event); + } + } + + /* + * (non-Javadoc) + * @see org.springframework.context.ApplicationEventPublisherAware#setApplicationEventPublisher(org.springframework.context.ApplicationEventPublisher) + */ + @Override + public void setApplicationEventPublisher(ApplicationEventPublisher applicationEventPublisher) { + this.publisher = applicationEventPublisher; + } +} diff --git a/src/main/java/org/springframework/data/redis/listener/KeyspaceEventMessageListener.java b/src/main/java/org/springframework/data/redis/listener/KeyspaceEventMessageListener.java new file mode 100644 index 000000000..ff73b0148 --- /dev/null +++ b/src/main/java/org/springframework/data/redis/listener/KeyspaceEventMessageListener.java @@ -0,0 +1,119 @@ +/* + * Copyright 2015 the original author or authors. + * + * Licensed 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.springframework.data.redis.listener; + +import java.util.List; + +import org.springframework.beans.factory.DisposableBean; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.data.redis.connection.Message; +import org.springframework.data.redis.connection.MessageListener; +import org.springframework.data.redis.connection.RedisConnection; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; + +/** + * Base {@link MessageListener} implementation for listening to Redis keyspace notifications. + * + * @author Christoph Strobl + * @since 1.7 + */ +public abstract class KeyspaceEventMessageListener implements MessageListener, InitializingBean, DisposableBean { + + private final RedisMessageListenerContainer listenerContainer; + private static final Topic TOPIC_ALL_KEYEVENTS = new PatternTopic("__keyevent@*"); + + /** + * Creates new {@link KeyspaceEventMessageListener}. + * + * @param listenerContainer must not be {@literal null}. + */ + public KeyspaceEventMessageListener(RedisMessageListenerContainer listenerContainer) { + + Assert.notNull(listenerContainer, "RedisMessageListenerContainer to run in must not be null!"); + this.listenerContainer = listenerContainer; + } + + /* + * (non-Javadoc) + * @see org.springframework.data.redis.connection.MessageListener#onMessage(org.springframework.data.redis.connection.Message, byte[]) + */ + @Override + public void onMessage(Message message, byte[] pattern) { + + if (message == null || message.getChannel() == null || message.getBody() == null) { + return; + } + + doHandleMessage(message); + } + + /** + * Handle the actual message + * + * @param message never {@literal null}. + */ + protected abstract void doHandleMessage(Message message); + + /** + * Initialize the message listener by writing requried redis config for {@literal notify-keyspace-events} and + * registering the listener within the container. + */ + public void init() { + + RedisConnection connection = listenerContainer.getConnectionFactory().getConnection(); + List config = connection.getConfig("notify-keyspace-events"); + + if (config.size() == 2) { + + if (!StringUtils.hasText(config.get(1))) { + + // TODO more fine grained reaction on event configuration + connection.setConfig("notify-keyspace-events", "KEA"); + } + } + connection.close(); + + doRegister(listenerContainer); + } + + /** + * Register instance within the container. + * + * @param container never {@literal null}. + */ + protected void doRegister(RedisMessageListenerContainer container) { + listenerContainer.addMessageListener(this, TOPIC_ALL_KEYEVENTS); + } + + /* + * (non-Javadoc) + * @see org.springframework.beans.factory.DisposableBean#destroy() + */ + @Override + public void destroy() throws Exception { + listenerContainer.removeMessageListener(this); + } + + /* + * (non-Javadoc) + * @see org.springframework.beans.factory.InitializingBean#afterPropertiesSet() + */ + @Override + public void afterPropertiesSet() throws Exception { + init(); + } +} diff --git a/src/main/java/org/springframework/data/redis/repository/configuration/EnableRedisRepositories.java b/src/main/java/org/springframework/data/redis/repository/configuration/EnableRedisRepositories.java new file mode 100644 index 000000000..9ea095651 --- /dev/null +++ b/src/main/java/org/springframework/data/redis/repository/configuration/EnableRedisRepositories.java @@ -0,0 +1,157 @@ +/* + * Copyright 2015 the original author or authors. + * + * Licensed 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.springframework.data.redis.repository.configuration; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.springframework.beans.factory.FactoryBean; +import org.springframework.context.annotation.ComponentScan.Filter; +import org.springframework.context.annotation.Import; +import org.springframework.data.keyvalue.core.KeyValueOperations; +import org.springframework.data.keyvalue.repository.config.QueryCreatorType; +import org.springframework.data.redis.core.RedisOperations; +import org.springframework.data.redis.core.convert.KeyspaceConfiguration; +import org.springframework.data.redis.core.index.IndexConfiguration; +import org.springframework.data.redis.repository.query.RedisQueryCreator; +import org.springframework.data.redis.repository.support.RedisRepositoryFactoryBean; +import org.springframework.data.repository.config.DefaultRepositoryBaseClass; +import org.springframework.data.repository.query.QueryLookupStrategy; +import org.springframework.data.repository.query.QueryLookupStrategy.Key; + +/** + * Annotation to activate Redis repositories. If no base package is configured through either {@link #value()}, + * {@link #basePackages()} or {@link #basePackageClasses()} it will trigger scanning of the package of annotated class. + * + * @author Christoph Strobl + * @since 1.7 + */ +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Inherited +@Import(RedisRepositoriesRegistrar.class) +@QueryCreatorType(RedisQueryCreator.class) +public @interface EnableRedisRepositories { + + /** + * Alias for the {@link #basePackages()} attribute. Allows for more concise annotation declarations e.g.: + * {@code @EnableJpaRepositories("org.my.pkg")} instead of {@code @EnableJpaRepositories(basePackages="org.my.pkg")}. + */ + String[] value() default {}; + + /** + * Base packages to scan for annotated components. {@link #value()} is an alias for (and mutually exclusive with) this + * attribute. Use {@link #basePackageClasses()} for a type-safe alternative to String-based package names. + */ + String[] basePackages() default {}; + + /** + * Type-safe alternative to {@link #basePackages()} for specifying the packages to scan for annotated components. The + * package of each class specified will be scanned. Consider creating a special no-op marker class or interface in + * each package that serves no purpose other than being referenced by this attribute. + */ + Class[] basePackageClasses() default {}; + + /** + * Specifies which types are not eligible for component scanning. + */ + Filter[] excludeFilters() default {}; + + /** + * Specifies which types are eligible for component scanning. Further narrows the set of candidate components from + * everything in {@link #basePackages()} to everything in the base packages that matches the given filter or filters. + */ + Filter[] includeFilters() default {}; + + /** + * Returns the postfix to be used when looking up custom repository implementations. Defaults to {@literal Impl}. So + * for a repository named {@code PersonRepository} the corresponding implementation class will be looked up scanning + * for {@code PersonRepositoryImpl}. + * + * @return + */ + String repositoryImplementationPostfix() default "Impl"; + + /** + * Configures the location of where to find the Spring Data named queries properties file. + * + * @return + */ + String namedQueriesLocation() default ""; + + /** + * Returns the key of the {@link QueryLookupStrategy} to be used for lookup queries for query methods. Defaults to + * {@link Key#CREATE_IF_NOT_FOUND}. + * + * @return + */ + Key queryLookupStrategy() default Key.CREATE_IF_NOT_FOUND; + + /** + * Returns the {@link FactoryBean} class to be used for each repository instance. Defaults to + * {@link RedisRepositoryFactoryBean}. + * + * @return + */ + Class repositoryFactoryBeanClass() default RedisRepositoryFactoryBean.class; + + /** + * Configure the repository base class to be used to create repository proxies for this particular configuration. + * + * @return + */ + Class repositoryBaseClass() default DefaultRepositoryBaseClass.class; + + /** + * Configures the name of the {@link KeyValueOperations} bean to be used with the repositories detected. + * + * @return + */ + String keyValueTemplateRef() default "redisKeyValueTemplate"; + + /** + * Configures whether nested repository-interfaces (e.g. defined as inner classes) should be discovered by the + * repositories infrastructure. + */ + boolean considerNestedRepositories() default false; + + /** + * Configures the bean name of the {@link RedisOperations} to be used. Defaulted to {@literal redisTemplate}. + * + * @return + */ + String redisTemplateRef() default "redisTemplate"; + + /** + * Set up index patterns using simple configuration class. + * + * @return + */ + Class indexConfiguration() default IndexConfiguration.class; + + /** + * Set up keyspaces for specific types. + * + * @return + */ + Class keyspaceConfiguration() default KeyspaceConfiguration.class; + +} diff --git a/src/main/java/org/springframework/data/redis/repository/configuration/RedisRepositoriesRegistrar.java b/src/main/java/org/springframework/data/redis/repository/configuration/RedisRepositoriesRegistrar.java new file mode 100644 index 000000000..701c03465 --- /dev/null +++ b/src/main/java/org/springframework/data/redis/repository/configuration/RedisRepositoriesRegistrar.java @@ -0,0 +1,49 @@ +/* + * Copyright 2015 the original author or authors. + * + * Licensed 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.springframework.data.redis.repository.configuration; + +import java.lang.annotation.Annotation; + +import org.springframework.context.annotation.ImportBeanDefinitionRegistrar; +import org.springframework.data.repository.config.RepositoryBeanDefinitionRegistrarSupport; +import org.springframework.data.repository.config.RepositoryConfigurationExtension; + +/** + * Redis specific {@link ImportBeanDefinitionRegistrar}. + * + * @author Christoph Strobl + * @since 1.7 + */ +public class RedisRepositoriesRegistrar extends RepositoryBeanDefinitionRegistrarSupport { + + /* + * (non-Javadoc) + * @see org.springframework.data.repository.config.RepositoryBeanDefinitionRegistrarSupport#getAnnotation() + */ + @Override + protected Class getAnnotation() { + return EnableRedisRepositories.class; + } + + /* + * (non-Javadoc) + * @see org.springframework.data.repository.config.RepositoryBeanDefinitionRegistrarSupport#getExtension() + */ + @Override + protected RepositoryConfigurationExtension getExtension() { + return new RedisRepositoryConfigurationExtension(); + } +} diff --git a/src/main/java/org/springframework/data/redis/repository/configuration/RedisRepositoryConfigurationExtension.java b/src/main/java/org/springframework/data/redis/repository/configuration/RedisRepositoryConfigurationExtension.java new file mode 100644 index 000000000..fd673462d --- /dev/null +++ b/src/main/java/org/springframework/data/redis/repository/configuration/RedisRepositoryConfigurationExtension.java @@ -0,0 +1,208 @@ +/* + * Copyright 2015 the original author or authors. + * + * Licensed 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.springframework.data.redis.repository.configuration; + +import org.springframework.beans.DirectFieldAccessor; +import org.springframework.beans.MutablePropertyValues; +import org.springframework.beans.factory.config.BeanDefinition; +import org.springframework.beans.factory.config.ConstructorArgumentValues; +import org.springframework.beans.factory.config.RuntimeBeanReference; +import org.springframework.beans.factory.support.AbstractBeanDefinition; +import org.springframework.beans.factory.support.BeanDefinitionRegistry; +import org.springframework.beans.factory.support.GenericBeanDefinition; +import org.springframework.beans.factory.support.RootBeanDefinition; +import org.springframework.core.annotation.AnnotationAttributes; +import org.springframework.data.keyvalue.repository.config.KeyValueRepositoryConfigurationExtension; +import org.springframework.data.redis.core.RedisKeyValueAdapter; +import org.springframework.data.redis.core.RedisKeyValueTemplate; +import org.springframework.data.redis.core.convert.CustomConversions; +import org.springframework.data.redis.core.convert.MappingConfiguration; +import org.springframework.data.redis.core.convert.MappingRedisConverter; +import org.springframework.data.redis.core.mapping.RedisMappingContext; +import org.springframework.data.repository.config.RepositoryConfigurationExtension; +import org.springframework.data.repository.config.RepositoryConfigurationSource; +import org.springframework.util.StringUtils; + +/** + * {@link RepositoryConfigurationExtension} for Redis. + * + * @author Christoph Strobl + * @since 1.7 + */ +public class RedisRepositoryConfigurationExtension extends KeyValueRepositoryConfigurationExtension { + + private static final String REDIS_CONVERTER_BEAN_NAME = "redisConverter"; + private static final String REDIS_REFERENCE_RESOLVER_BEAN_NAME = "redisReferenceResolver"; + private static final String REDIS_ADAPTER_BEAN_NAME = "redisKeyValueAdapter"; + private static final String REDIS_CUSTOM_CONVERSIONS_BEAN_NAME = "redisCustomConversions"; + + /* + * (non-Javadoc) + * @see org.springframework.data.keyvalue.repository.config.KeyValueRepositoryConfigurationExtension#getModuleName() + */ + @Override + public String getModuleName() { + return "Redis"; + } + + /* + * (non-Javadoc) + * @see org.springframework.data.keyvalue.repository.config.KeyValueRepositoryConfigurationExtension#getModulePrefix() + */ + @Override + protected String getModulePrefix() { + return "redis"; + } + + /* + * (non-Javadoc) + * @see org.springframework.data.keyvalue.repository.config.KeyValueRepositoryConfigurationExtension#getDefaultKeyValueTemplateRef() + */ + @Override + protected String getDefaultKeyValueTemplateRef() { + return "redisKeyValueTemplate"; + } + + @Override + public void registerBeansForRoot(BeanDefinitionRegistry registry, RepositoryConfigurationSource configurationSource) { + + RootBeanDefinition mappingContextDefinition = createRedisMappingContext(configurationSource); + mappingContextDefinition.setSource(configurationSource.getSource()); + + registerIfNotAlreadyRegistered(mappingContextDefinition, registry, MAPPING_CONTEXT_BEAN_NAME, configurationSource); + + // register coustom conversions + RootBeanDefinition customConversions = new RootBeanDefinition(CustomConversions.class); + registerIfNotAlreadyRegistered(customConversions, registry, REDIS_CUSTOM_CONVERSIONS_BEAN_NAME, configurationSource); + + // Register referenceResolver + RootBeanDefinition redisReferenceResolver = createRedisReferenceResolverDefinition(); + redisReferenceResolver.setSource(configurationSource.getSource()); + registerIfNotAlreadyRegistered(redisReferenceResolver, registry, REDIS_REFERENCE_RESOLVER_BEAN_NAME, + configurationSource); + + // Register converter + RootBeanDefinition redisConverterDefinition = createRedisConverterDefinition(); + redisConverterDefinition.setSource(configurationSource.getSource()); + + registerIfNotAlreadyRegistered(redisConverterDefinition, registry, REDIS_CONVERTER_BEAN_NAME, configurationSource); + + // register Adapter + RootBeanDefinition redisKeyValueAdapterDefinition = new RootBeanDefinition(RedisKeyValueAdapter.class); + + ConstructorArgumentValues constructorArgumentValuesForRedisKeyValueAdapter = new ConstructorArgumentValues(); + + String redisTemplateRef = configurationSource.getAttribute("redisTemplateRef"); + if (StringUtils.hasText(redisTemplateRef)) { + + constructorArgumentValuesForRedisKeyValueAdapter.addIndexedArgumentValue(0, new RuntimeBeanReference( + redisTemplateRef)); + } + + constructorArgumentValuesForRedisKeyValueAdapter.addIndexedArgumentValue(1, new RuntimeBeanReference( + REDIS_CONVERTER_BEAN_NAME)); + + redisKeyValueAdapterDefinition.setConstructorArgumentValues(constructorArgumentValuesForRedisKeyValueAdapter); + registerIfNotAlreadyRegistered(redisKeyValueAdapterDefinition, registry, REDIS_ADAPTER_BEAN_NAME, + configurationSource); + + super.registerBeansForRoot(registry, configurationSource); + } + + private RootBeanDefinition createRedisReferenceResolverDefinition() { + + RootBeanDefinition beanDef = new RootBeanDefinition(); + beanDef.setBeanClassName("org.springframework.data.redis.core.RedisKeyValueAdapter.ReferenceResolverImpl"); + + MutablePropertyValues props = new MutablePropertyValues(); + props.add("adapter", new RuntimeBeanReference(REDIS_ADAPTER_BEAN_NAME)); + beanDef.setPropertyValues(props); + + return beanDef; + } + + private RootBeanDefinition createRedisMappingContext(RepositoryConfigurationSource configurationSource) { + + ConstructorArgumentValues mappingContextArgs = new ConstructorArgumentValues(); + mappingContextArgs.addIndexedArgumentValue(0, createMappingConfigBeanDef(configurationSource)); + + RootBeanDefinition mappingContextBeanDef = new RootBeanDefinition(RedisMappingContext.class); + mappingContextBeanDef.setConstructorArgumentValues(mappingContextArgs); + + return mappingContextBeanDef; + } + + private BeanDefinition createMappingConfigBeanDef(RepositoryConfigurationSource configurationSource) { + + DirectFieldAccessor dfa = new DirectFieldAccessor(configurationSource); + AnnotationAttributes aa = (AnnotationAttributes) dfa.getPropertyValue("attributes"); + + GenericBeanDefinition indexConfiguration = new GenericBeanDefinition(); + indexConfiguration.setBeanClass(aa.getClass("indexConfiguration")); + + GenericBeanDefinition keyspaceConfig = new GenericBeanDefinition(); + keyspaceConfig.setBeanClass(aa.getClass("keyspaceConfiguration")); + + ConstructorArgumentValues mappingConfigArgs = new ConstructorArgumentValues(); + mappingConfigArgs.addIndexedArgumentValue(0, indexConfiguration); + mappingConfigArgs.addIndexedArgumentValue(1, keyspaceConfig); + + GenericBeanDefinition mappingConfigBeanDef = new GenericBeanDefinition(); + mappingConfigBeanDef.setBeanClass(MappingConfiguration.class); + mappingConfigBeanDef.setConstructorArgumentValues(mappingConfigArgs); + + return mappingConfigBeanDef; + } + + /* + * (non-Javadoc) + * @see org.springframework.data.keyvalue.repository.config.KeyValueRepositoryConfigurationExtension#getDefaultKeyValueTemplateBeanDefinition(org.springframework.data.repository.config.RepositoryConfigurationSource) + */ + @Override + protected AbstractBeanDefinition getDefaultKeyValueTemplateBeanDefinition( + RepositoryConfigurationSource configurationSource) { + + RootBeanDefinition keyValueTemplateDefinition = new RootBeanDefinition(RedisKeyValueTemplate.class); + + ConstructorArgumentValues constructorArgumentValuesForKeyValueTemplate = new ConstructorArgumentValues(); + constructorArgumentValuesForKeyValueTemplate.addIndexedArgumentValue(0, new RuntimeBeanReference( + REDIS_ADAPTER_BEAN_NAME)); + constructorArgumentValuesForKeyValueTemplate.addIndexedArgumentValue(1, new RuntimeBeanReference( + MAPPING_CONTEXT_BEAN_NAME)); + + keyValueTemplateDefinition.setConstructorArgumentValues(constructorArgumentValuesForKeyValueTemplate); + + return keyValueTemplateDefinition; + } + + private RootBeanDefinition createRedisConverterDefinition() { + + RootBeanDefinition beanDef = new RootBeanDefinition(); + beanDef.setBeanClass(MappingRedisConverter.class); + + ConstructorArgumentValues args = new ConstructorArgumentValues(); + args.addIndexedArgumentValue(0, new RuntimeBeanReference(MAPPING_CONTEXT_BEAN_NAME)); + beanDef.setConstructorArgumentValues(args); + + MutablePropertyValues props = new MutablePropertyValues(); + props.add("referenceResolver", new RuntimeBeanReference(REDIS_REFERENCE_RESOLVER_BEAN_NAME)); + props.add("customConversions", new RuntimeBeanReference(REDIS_CUSTOM_CONVERSIONS_BEAN_NAME)); + beanDef.setPropertyValues(props); + + return beanDef; + } + +} diff --git a/src/main/java/org/springframework/data/redis/repository/query/RedisOperationChain.java b/src/main/java/org/springframework/data/redis/repository/query/RedisOperationChain.java new file mode 100644 index 000000000..4c4ecca97 --- /dev/null +++ b/src/main/java/org/springframework/data/redis/repository/query/RedisOperationChain.java @@ -0,0 +1,53 @@ +/* + * Copyright 2015 the original author or authors. + * + * Licensed 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.springframework.data.redis.repository.query; + +import java.util.Collection; +import java.util.LinkedHashSet; +import java.util.Set; + +/** + * Simple set of operations requried to run queries against Redis. + * + * @author Christoph Strobl + * @since 1.7 + */ +public class RedisOperationChain { + + Set sismember = new LinkedHashSet(); + Set orSismember = new LinkedHashSet(); + + public void sismember(Object next) { + sismember.add(next); + } + + public Set getSismember() { + return sismember; + } + + public void orSismember(Object next) { + orSismember.add(next); + } + + public void orSismember(Collection next) { + orSismember.addAll(next); + } + + public Set getOrSismember() { + return orSismember; + } + +} diff --git a/src/main/java/org/springframework/data/redis/repository/query/RedisQueryCreator.java b/src/main/java/org/springframework/data/redis/repository/query/RedisQueryCreator.java new file mode 100644 index 000000000..7c12fdb03 --- /dev/null +++ b/src/main/java/org/springframework/data/redis/repository/query/RedisQueryCreator.java @@ -0,0 +1,97 @@ +/* + * Copyright 2015 the original author or authors. + * + * Licensed 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.springframework.data.redis.repository.query; + +import java.util.Iterator; + +import org.springframework.data.domain.Sort; +import org.springframework.data.keyvalue.core.query.KeyValueQuery; +import org.springframework.data.repository.query.ParameterAccessor; +import org.springframework.data.repository.query.parser.AbstractQueryCreator; +import org.springframework.data.repository.query.parser.Part; +import org.springframework.data.repository.query.parser.PartTree; + +/** + * Redis specific query creator. + * + * @author Christoph Strobl + * @since 1.7 + */ +public class RedisQueryCreator extends AbstractQueryCreator, RedisOperationChain> { + + public RedisQueryCreator(PartTree tree, ParameterAccessor parameters) { + super(tree, parameters); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.repository.query.parser.AbstractQueryCreator#create(org.springframework.data.repository.query.parser.Part, java.util.Iterator) + */ + @Override + protected RedisOperationChain create(Part part, Iterator iterator) { + return from(part, iterator, new RedisOperationChain()); + } + + private RedisOperationChain from(Part part, Iterator iterator, RedisOperationChain sink) { + + switch (part.getType()) { + case SIMPLE_PROPERTY: + sink.sismember(part.getProperty().toDotPath() + ":" + iterator.next()); + break; + default: + throw new IllegalArgumentException(part.getType() + "is not supported for redis query derivation"); + } + + return sink; + + } + + /* + * (non-Javadoc) + * @see org.springframework.data.repository.query.parser.AbstractQueryCreator#and(org.springframework.data.repository.query.parser.Part, java.lang.Object, java.util.Iterator) + */ + @Override + protected RedisOperationChain and(Part part, RedisOperationChain base, Iterator iterator) { + return from(part, iterator, base); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.repository.query.parser.AbstractQueryCreator#or(java.lang.Object, java.lang.Object) + */ + @Override + protected RedisOperationChain or(RedisOperationChain base, RedisOperationChain criteria) { + base.orSismember(criteria.sismember); + return base; + } + + /* + * (non-Javadoc) + * @see org.springframework.data.repository.query.parser.AbstractQueryCreator#complete(java.lang.Object, org.springframework.data.domain.Sort) + */ + @Override + protected KeyValueQuery complete(final RedisOperationChain criteria, Sort sort) { + + KeyValueQuery query = new KeyValueQuery(criteria); + + if (sort != null) { + query.setSort(sort); + } + + return query; + } + +} diff --git a/src/main/java/org/springframework/data/redis/repository/support/RedisRepositoryFactory.java b/src/main/java/org/springframework/data/redis/repository/support/RedisRepositoryFactory.java new file mode 100644 index 000000000..66c4b81a5 --- /dev/null +++ b/src/main/java/org/springframework/data/redis/repository/support/RedisRepositoryFactory.java @@ -0,0 +1,120 @@ +/* + * Copyright 2015 the original author or authors. + * + * Licensed 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.springframework.data.redis.repository.support; + +import java.lang.reflect.Method; + +import org.springframework.data.keyvalue.core.KeyValueOperations; +import org.springframework.data.keyvalue.repository.query.KeyValuePartTreeQuery; +import org.springframework.data.keyvalue.repository.query.KeyValuePartTreeQuery.QueryInitialization; +import org.springframework.data.keyvalue.repository.support.KeyValueRepositoryFactory; +import org.springframework.data.projection.ProjectionFactory; +import org.springframework.data.repository.core.NamedQueries; +import org.springframework.data.repository.core.RepositoryMetadata; +import org.springframework.data.repository.core.support.RepositoryFactorySupport; +import org.springframework.data.repository.query.EvaluationContextProvider; +import org.springframework.data.repository.query.QueryLookupStrategy; +import org.springframework.data.repository.query.QueryLookupStrategy.Key; +import org.springframework.data.repository.query.QueryMethod; +import org.springframework.data.repository.query.RepositoryQuery; +import org.springframework.data.repository.query.parser.AbstractQueryCreator; +import org.springframework.util.Assert; + +/** + * {@link RepositoryFactorySupport} specific of handing Redis + * {@link org.springframework.data.keyvalue.repository.KeyValueRepository}. + * + * @author Christoph Strobl + * @since 1.7 + */ +public class RedisRepositoryFactory extends KeyValueRepositoryFactory { + + /** + * @param keyValueOperations + * @see KeyValueRepositoryFactory#KeyValueRepositoryFactory(KeyValueOperations) + */ + public RedisRepositoryFactory(KeyValueOperations keyValueOperations) { + super(keyValueOperations); + } + + /** + * @param keyValueOperations + * @param queryCreator + * @see KeyValueRepositoryFactory#KeyValueRepositoryFactory(KeyValueOperations, Class) + */ + public RedisRepositoryFactory(KeyValueOperations keyValueOperations, + Class> queryCreator) { + super(keyValueOperations, queryCreator); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.keyvalue.repository.support.KeyValueRepositoryFactory#getQueryLookupStrategy(org.springframework.data.repository.query.QueryLookupStrategy.Key, org.springframework.data.repository.query.EvaluationContextProvider) + */ + @Override + protected QueryLookupStrategy getQueryLookupStrategy(Key key, EvaluationContextProvider evaluationContextProvider) { + return new RedisQueryLookupStrategy(key, evaluationContextProvider, getKeyValueOperations(), getQueryCreator()); + } + + /** + * @author Christoph Strobl + * @since 1.7 + */ + private static class RedisQueryLookupStrategy implements QueryLookupStrategy { + + private EvaluationContextProvider evaluationContextProvider; + private KeyValueOperations keyValueOperations; + + private Class> queryCreator; + + /** + * Creates a new {@link RedisQueryLookupStrategy} for the given {@link Key}, {@link EvaluationContextProvider}, + * {@link KeyValueOperations} and query creator type. + *

+ * + * @param key + * @param evaluationContextProvider must not be {@literal null}. + * @param keyValueOperations must not be {@literal null}. + * @param queryCreator must not be {@literal null}. + */ + public RedisQueryLookupStrategy(Key key, EvaluationContextProvider evaluationContextProvider, + KeyValueOperations keyValueOperations, Class> queryCreator) { + + Assert.notNull(evaluationContextProvider, "EvaluationContextProvider must not be null!"); + Assert.notNull(keyValueOperations, "KeyValueOperations must not be null!"); + Assert.notNull(queryCreator, "Query creator type must not be null!"); + + this.evaluationContextProvider = evaluationContextProvider; + this.keyValueOperations = keyValueOperations; + this.queryCreator = queryCreator; + } + + /* + * (non-Javadoc) + * @see org.springframework.data.repository.query.QueryLookupStrategy#resolveQuery(java.lang.reflect.Method, org.springframework.data.repository.core.RepositoryMetadata, org.springframework.data.repository.core.NamedQueries) + */ + @Override + public RepositoryQuery resolveQuery(Method method, RepositoryMetadata metadata, ProjectionFactory factory, + NamedQueries namedQueries) { + + QueryMethod queryMethod = new QueryMethod(method, metadata, factory); + KeyValuePartTreeQuery partTreeQuery = new KeyValuePartTreeQuery(queryMethod, evaluationContextProvider, + this.keyValueOperations, this.queryCreator); + partTreeQuery.setQueryIntialization(QueryInitialization.NEW); + return partTreeQuery; + } + } +} diff --git a/src/main/java/org/springframework/data/redis/repository/support/RedisRepositoryFactoryBean.java b/src/main/java/org/springframework/data/redis/repository/support/RedisRepositoryFactoryBean.java new file mode 100644 index 000000000..e867c831d --- /dev/null +++ b/src/main/java/org/springframework/data/redis/repository/support/RedisRepositoryFactoryBean.java @@ -0,0 +1,46 @@ +/* + * Copyright 2015 the original author or authors. + * + * Licensed 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.springframework.data.redis.repository.support; + +import java.io.Serializable; + +import org.springframework.beans.factory.FactoryBean; +import org.springframework.data.keyvalue.repository.support.KeyValueRepositoryFactoryBean; +import org.springframework.data.repository.Repository; +import org.springframework.data.repository.core.support.RepositoryFactorySupport; + +/** + * Adapter for Springs {@link FactoryBean} interface to allow easy setup of {@link RedisRepositoryFactory} via Spring + * configuration. + * + * @author Christoph Strobl + * @param The repository type. + * @param The repository domain type. + * @param The repository id type. + * @since 1.7 + */ +public class RedisRepositoryFactoryBean, S, ID extends Serializable> extends + KeyValueRepositoryFactoryBean { + + /* + * (non-Javadoc) + * @see org.springframework.data.repository.core.support.RepositoryFactoryBeanSupport#createRepositoryFactory() + */ + @Override + protected RepositoryFactorySupport createRepositoryFactory() { + return new RedisRepositoryFactory(getOperations(), getQueryCreator()); + } +} diff --git a/src/main/java/org/springframework/data/redis/util/ByteUtils.java b/src/main/java/org/springframework/data/redis/util/ByteUtils.java new file mode 100644 index 000000000..437320a23 --- /dev/null +++ b/src/main/java/org/springframework/data/redis/util/ByteUtils.java @@ -0,0 +1,79 @@ +/* + * Copyright 2015 the original author or authors. + * + * Licensed 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.springframework.data.redis.util; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +/** + * Some handy methods for dealing with byte arrays. + * + * @author Christoph Strobl + * @since 1.7 + */ +public final class ByteUtils { + + private ByteUtils() {} + + public static byte[] concat(byte[] arg1, byte[] arg2) { + + byte[] result = Arrays.copyOf(arg1, arg1.length + arg2.length); + System.arraycopy(arg2, 0, result, arg1.length, arg2.length); + + return result; + } + + public static byte[] concatAll(byte[]... args) { + + if (args.length == 0) { + return new byte[] {}; + } + if (args.length == 1) { + return args[0]; + } + + byte[] cur = concat(args[0], args[1]); + for (int i = 2; i < args.length; i++) { + cur = concat(cur, args[i]); + } + return cur; + } + + public static byte[][] split(byte[] source, int c) { + + if (source == null || source.length == 0) { + return new byte[][] {}; + } + + List bytes = new ArrayList(); + int offset = 0; + for (int i = 0; i <= source.length; i++) { + + if (i == source.length) { + + bytes.add(Arrays.copyOfRange(source, offset, i)); + break; + } + + if (source[i] == c) { + bytes.add(Arrays.copyOfRange(source, offset, i)); + offset = i + 1; + } + } + return bytes.toArray(new byte[bytes.size()][]); + } +} diff --git a/src/test/java/org/springframework/data/redis/core/IndexWriterUnitTests.java b/src/test/java/org/springframework/data/redis/core/IndexWriterUnitTests.java new file mode 100644 index 000000000..c995b992a --- /dev/null +++ b/src/test/java/org/springframework/data/redis/core/IndexWriterUnitTests.java @@ -0,0 +1,158 @@ +/* + * Copyright 2015 the original author or authors. + * + * Licensed 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.springframework.data.redis.core; + +import static org.hamcrest.core.IsCollectionContaining.*; +import static org.junit.Assert.*; +import static org.mockito.Matchers.*; +import static org.mockito.Mockito.*; + +import java.nio.charset.Charset; +import java.util.Arrays; +import java.util.LinkedHashSet; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.runners.MockitoJUnitRunner; +import org.springframework.data.redis.connection.RedisConnection; +import org.springframework.data.redis.core.convert.IndexResolverImpl; +import org.springframework.data.redis.core.convert.IndexedData; +import org.springframework.data.redis.core.convert.MappingRedisConverter; +import org.springframework.data.redis.core.convert.ReferenceResolver; +import org.springframework.data.redis.core.convert.SimpleIndexedPropertyValue; +import org.springframework.data.redis.core.mapping.RedisMappingContext; + +/** + * @author Christoph Strobl + */ +@RunWith(MockitoJUnitRunner.class) +public class IndexWriterUnitTests { + + private static final Charset CHARSET = Charset.forName("UTF-8"); + private static final String KEYSPACE = "persons"; + private static final String KEY = "key-1"; + private static final byte[] KEY_BIN = KEY.getBytes(CHARSET); + IndexWriter writer; + + @Mock RedisConnection connectionMock; + @Mock ReferenceResolver referenceResolverMock; + + @Before + public void setUp() { + + MappingRedisConverter converter = new MappingRedisConverter(new RedisMappingContext(), new IndexResolverImpl(), + referenceResolverMock); + converter.afterPropertiesSet(); + + writer = new IndexWriter(connectionMock, converter); + } + + /** + * @see DATAREDIS-425 + */ + @Test + public void addKeyToIndexShouldInvokeSaddCorrectly() { + + writer.addKeyToIndex(KEY_BIN, new SimpleIndexedPropertyValue(KEYSPACE, "firstname", "Rand")); + + verify(connectionMock).sAdd(eq("persons:firstname:Rand".getBytes(CHARSET)), eq(KEY_BIN)); + verify(connectionMock).sAdd(eq("persons:key-1:idx".getBytes(CHARSET)), + eq("persons:firstname:Rand".getBytes(CHARSET))); + } + + /** + * @see DATAREDIS-425 + */ + @Test(expected = IllegalArgumentException.class) + public void addKeyToIndexShouldThrowErrorWhenIndexedDataIsNull() { + writer.addKeyToIndex(KEY_BIN, null); + } + + /** + * @see DATAREDIS-425 + */ + @Test + public void removeKeyFromExistingIndexesShouldCheckForExistingIndexesForPath() { + + writer.removeKeyFromExistingIndexes(KEY_BIN, new StubIndxedData()); + + verify(connectionMock).keys(eq(("persons:address.city:*").getBytes(CHARSET))); + verifyNoMoreInteractions(connectionMock); + } + + /** + * @see DATAREDIS-425 + */ + @Test + public void removeKeyFromExistingIndexesShouldRemoveKeyFromAllExistingIndexesForPath() { + + byte[] indexKey1 = "persons:firstname:rand".getBytes(CHARSET); + byte[] indexKey2 = "persons:firstname:mat".getBytes(CHARSET); + + when(connectionMock.keys(any(byte[].class))).thenReturn( + new LinkedHashSet(Arrays.asList(indexKey1, indexKey2))); + + writer.removeKeyFromExistingIndexes(KEY_BIN, new StubIndxedData()); + + verify(connectionMock).sRem(indexKey1, KEY_BIN); + verify(connectionMock).sRem(indexKey2, KEY_BIN); + } + + /** + * @see DATAREDIS-425 + */ + @Test(expected = IllegalArgumentException.class) + public void removeKeyFromExistingIndexesShouldThrowExecptionForNullIndexedData() { + writer.removeKeyFromExistingIndexes(KEY_BIN, null); + } + + /** + * @see DATAREDIS-425 + */ + @Test + public void removeAllIndexesShouldDeleteAllIndexKeys() { + + byte[] indexKey1 = "persons:firstname:rand".getBytes(CHARSET); + byte[] indexKey2 = "persons:firstname:mat".getBytes(CHARSET); + + when(connectionMock.keys(any(byte[].class))).thenReturn( + new LinkedHashSet(Arrays.asList(indexKey1, indexKey2))); + + writer.removeAllIndexes(KEYSPACE); + + ArgumentCaptor captor = ArgumentCaptor.forClass(byte[].class); + + verify(connectionMock, times(1)).del(captor.capture()); + assertThat(captor.getAllValues(), hasItems(indexKey1, indexKey2)); + } + + static class StubIndxedData implements IndexedData { + + @Override + public String getPath() { + return "address.city"; + } + + @Override + public String getKeySpace() { + return KEYSPACE; + } + + } +} diff --git a/src/test/java/org/springframework/data/redis/core/RedisKeyValueAdapterTests.java b/src/test/java/org/springframework/data/redis/core/RedisKeyValueAdapterTests.java new file mode 100644 index 000000000..4e39be81a --- /dev/null +++ b/src/test/java/org/springframework/data/redis/core/RedisKeyValueAdapterTests.java @@ -0,0 +1,318 @@ +/* + * Copyright 2015 the original author or authors. + * + * Licensed 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.springframework.data.redis.core; + +import static org.hamcrest.core.Is.*; +import static org.hamcrest.core.IsCollectionContaining.*; +import static org.hamcrest.core.IsInstanceOf.*; +import static org.hamcrest.core.IsNot.*; +import static org.junit.Assert.*; + +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.springframework.dao.DataAccessException; +import org.springframework.data.annotation.Id; +import org.springframework.data.annotation.Reference; +import org.springframework.data.keyvalue.annotation.KeySpace; +import org.springframework.data.redis.connection.RedisConnection; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.jedis.JedisConnectionFactory; +import org.springframework.data.redis.core.convert.Bucket; +import org.springframework.data.redis.core.convert.KeyspaceConfiguration; +import org.springframework.data.redis.core.convert.MappingConfiguration; +import org.springframework.data.redis.core.index.IndexConfiguration; +import org.springframework.data.redis.core.index.Indexed; +import org.springframework.data.redis.core.mapping.RedisMappingContext; + +/** + * @author Christoph Strobl + */ +public class RedisKeyValueAdapterTests { + + RedisKeyValueAdapter adapter; + StringRedisTemplate template; + RedisConnectionFactory connectionFactory; + + @Before + public void setUp() { + + JedisConnectionFactory jedisConnectionFactory = new JedisConnectionFactory(); + jedisConnectionFactory.afterPropertiesSet(); + connectionFactory = jedisConnectionFactory; + + template = new StringRedisTemplate(connectionFactory); + template.afterPropertiesSet(); + + RedisMappingContext mappingContext = new RedisMappingContext(new MappingConfiguration(new IndexConfiguration(), + new KeyspaceConfiguration())); + mappingContext.afterPropertiesSet(); + + adapter = new RedisKeyValueAdapter(template, mappingContext); + + template.execute(new RedisCallback() { + + @Override + public Void doInRedis(RedisConnection connection) throws DataAccessException { + connection.flushDb(); + return null; + } + }); + } + + @After + public void tearDown() { + + try { + adapter.destroy(); + } catch (Exception e) { + // ignore + } + } + + /** + * @see DATAREDIS-425 + */ + @Test + public void putWritesDataCorrectly() { + + Person rand = new Person(); + rand.age = 24; + + adapter.put("1", rand, "persons"); + + assertThat(template.keys("persons*"), hasItems("persons", "persons:1")); + assertThat(template.opsForSet().size("persons"), is(1L)); + assertThat(template.opsForSet().members("persons"), hasItems("1")); + assertThat(template.opsForHash().entries("persons:1").size(), is(2)); + } + + /** + * @see DATAREDIS-425 + */ + @Test + public void putWritesSimpleIndexDataCorrectly() { + + Person rand = new Person(); + rand.firstname = "rand"; + + adapter.put("1", rand, "persons"); + + assertThat(template.keys("persons*"), hasItem("persons:firstname:rand")); + assertThat(template.opsForSet().members("persons:firstname:rand"), hasItems("1")); + } + + /** + * @see DATAREDIS-425 + */ + @Test + public void putWritesNestedDataCorrectly() { + + Person rand = new Person(); + rand.address = new Address(); + rand.address.city = "Emond's Field"; + + adapter.put("1", rand, "persons"); + + assertThat(template.keys("persons*"), hasItems("persons", "persons:1")); + assertThat(template.opsForHash().entries("persons:1").size(), is(2)); + } + + /** + * @see DATAREDIS-425 + */ + @Test + public void putWritesSimpleNestedIndexValuesCorrectly() { + + Person rand = new Person(); + rand.address = new Address(); + rand.address.country = "Andor"; + + adapter.put("1", rand, "persons"); + + assertThat(template.keys("persons*"), hasItem("persons:address.country:Andor")); + assertThat(template.opsForSet().members("persons:address.country:Andor"), hasItems("1")); + } + + /** + * @see DATAREDIS-425 + */ + @Test + public void getShouldReadSimpleObjectCorrectly() { + + Map map = new LinkedHashMap(); + map.put("_class", Person.class.getName()); + map.put("age", "24"); + template.opsForHash().putAll("persons:load-1", map); + + Object loaded = adapter.get("load-1", "persons"); + + assertThat(loaded, instanceOf(Person.class)); + assertThat(((Person) loaded).age, is(24)); + } + + /** + * @see DATAREDIS-425 + */ + @Test + public void getShouldReadNestedObjectCorrectly() { + + Map map = new LinkedHashMap(); + map.put("_class", Person.class.getName()); + map.put("address.country", "Andor"); + template.opsForHash().putAll("persons:load-1", map); + + Object loaded = adapter.get("load-1", "persons"); + + assertThat(loaded, instanceOf(Person.class)); + assertThat(((Person) loaded).address.country, is("Andor")); + } + + /** + * @see DATAREDIS-425 + */ + @Test + public void couldReadsKeyspaceSizeCorrectly() { + + Map map = new LinkedHashMap(); + map.put("_class", Person.class.getName()); + map.put("address.country", "Andor"); + template.opsForHash().putAll("persons:load-1", map); + + template.opsForSet().add("persons", "1", "2", "3"); + + assertThat(adapter.count("persons"), is(3L)); + } + + /** + * @see DATAREDIS-425 + */ + @Test + public void deleteRemovesEntriesCorrectly() { + + Map map = new LinkedHashMap(); + map.put("_class", Person.class.getName()); + map.put("address.country", "Andor"); + template.opsForHash().putAll("persons:1", map); + template.opsForSet().add("persons", "1"); + + adapter.delete("1", "persons"); + + assertThat(template.opsForSet().members("persons"), not(hasItem("1"))); + assertThat(template.hasKey("persons:1"), is(false)); + } + + /** + * @see DATAREDIS-425 + */ + @Test + public void deleteCleansIndexedDataCorrectly() { + + Map map = new LinkedHashMap(); + map.put("_class", Person.class.getName()); + map.put("firstname", "rand"); + map.put("address.country", "Andor"); + template.opsForHash().putAll("persons:1", map); + template.opsForSet().add("persons", "1"); + template.opsForSet().add("persons:1:idx", "persons:firstname:rand"); + template.opsForSet().add("persons:firstname:rand", "1"); + + adapter.delete("1", "persons"); + + assertThat(template.opsForSet().members("persons:firstname:rand"), not(hasItem("1"))); + } + + /** + * @see DATAREDIS-425 + */ + @Test + public void keyExpiredEventShouldRemoveHelperStructures() { + + Map map = new LinkedHashMap(); + map.put("_class", Person.class.getName()); + map.put("firstname", "rand"); + map.put("address.country", "Andor"); + + template.opsForSet().add("persons", "1"); + template.opsForSet().add("persons:firstname:rand", "1"); + template.opsForSet().add("persons:1:idx", "persons:firstname:rand"); + + adapter.onApplicationEvent(new RedisKeyExpiredEvent("persons:1".getBytes(Bucket.CHARSET))); + + assertThat(template.hasKey("persons:firstname:rand"), is(false)); + assertThat(template.hasKey("persons:1:idx"), is(false)); + assertThat(template.opsForSet().members("persons"), not(hasItem("1"))); + } + + @KeySpace("persons") + static class Person { + + @Id String id; + @Indexed String firstname; + Gender gender; + + List nicknames; + List coworkers; + Integer age; + Boolean alive; + Date birthdate; + + Address address; + + Map physicalAttributes; + Map relatives; + + @Reference Location location; + @Reference List visited; + } + + static class Address { + + String city; + @Indexed String country; + } + + static class AddressWithId extends Address { + + @Id String id; + } + + static enum Gender { + MALE, FEMALE + } + + static class AddressWithPostcode extends Address { + + String postcode; + } + + static class TaVeren extends Person { + + } + + @KeySpace("locations") + static class Location { + + @Id String id; + String name; + } + +} diff --git a/src/test/java/org/springframework/data/redis/core/RedisKeyValueTemplateTests.java b/src/test/java/org/springframework/data/redis/core/RedisKeyValueTemplateTests.java new file mode 100644 index 000000000..88d69c567 --- /dev/null +++ b/src/test/java/org/springframework/data/redis/core/RedisKeyValueTemplateTests.java @@ -0,0 +1,235 @@ +/* + * Copyright 2015 the original author or authors. + * + * Licensed 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.springframework.data.redis.core; + +import static org.hamcrest.core.Is.*; +import static org.hamcrest.core.IsCollectionContaining.*; +import static org.junit.Assert.*; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import org.junit.After; +import org.junit.AfterClass; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import org.junit.runners.Parameterized.Parameters; +import org.springframework.dao.DataAccessException; +import org.springframework.data.annotation.Id; +import org.springframework.data.redis.ConnectionFactoryTracker; +import org.springframework.data.redis.connection.RedisConnection; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.jedis.JedisConnectionFactory; +import org.springframework.data.redis.core.mapping.RedisMappingContext; +import org.springframework.util.ObjectUtils; + +/** + * @author Christoph Strobl + */ +@RunWith(Parameterized.class) +public class RedisKeyValueTemplateTests { + + RedisConnectionFactory connectionFactory; + RedisKeyValueTemplate template; + RedisTemplate nativeTemplate; + + public RedisKeyValueTemplateTests(RedisConnectionFactory connectionFactory) { + + this.connectionFactory = connectionFactory; + ConnectionFactoryTracker.add(connectionFactory); + } + + @Parameters + public static List params() { + + JedisConnectionFactory jedis = new JedisConnectionFactory(); + jedis.afterPropertiesSet(); + + return Collections. singletonList(jedis); + } + + @AfterClass + public static void cleanUp() { + ConnectionFactoryTracker.cleanUp(); + } + + @Before + public void setUp() { + + nativeTemplate = new RedisTemplate(); + nativeTemplate.setConnectionFactory(connectionFactory); + nativeTemplate.afterPropertiesSet(); + + RedisMappingContext context = new RedisMappingContext(); + + RedisKeyValueAdapter adapter = new RedisKeyValueAdapter(nativeTemplate, context); + template = new RedisKeyValueTemplate(adapter, context); + } + + @After + public void tearDown() { + + nativeTemplate.execute(new RedisCallback() { + + @Override + public Void doInRedis(RedisConnection connection) throws DataAccessException { + + connection.flushDb(); + return null; + } + }); + } + + /** + * @see DATAREDIS-425 + */ + @Test + public void savesObjectCorrectly() { + + final Person rand = new Person(); + rand.firstname = "rand"; + + template.insert(rand); + + nativeTemplate.execute(new RedisCallback() { + + @Override + public Void doInRedis(RedisConnection connection) throws DataAccessException { + + assertThat(connection.exists(("template-test-person:" + rand.id).getBytes()), is(true)); + return null; + } + }); + } + + /** + * @see DATAREDIS-425 + */ + @Test + public void findProcessesCallbackReturningSingleIdCorrectly() { + + Person rand = new Person(); + rand.firstname = "rand"; + + final Person mat = new Person(); + mat.firstname = "mat"; + + template.insert(rand); + template.insert(mat); + + List result = template.find(new RedisCallback() { + + @Override + public byte[] doInRedis(RedisConnection connection) throws DataAccessException { + return mat.id.getBytes(); + } + }, Person.class); + + assertThat(result.size(), is(1)); + assertThat(result, hasItems(mat)); + } + + /** + * @see DATAREDIS-425 + */ + @Test + public void findProcessesCallbackReturningMultipleIdsCorrectly() { + + final Person rand = new Person(); + rand.firstname = "rand"; + + final Person mat = new Person(); + mat.firstname = "mat"; + + template.insert(rand); + template.insert(mat); + + List result = template.find(new RedisCallback>() { + + @Override + public List doInRedis(RedisConnection connection) throws DataAccessException { + return Arrays.asList(rand.id.getBytes(), mat.id.getBytes()); + } + }, Person.class); + + assertThat(result.size(), is(2)); + assertThat(result, hasItems(rand, mat)); + } + + /** + * @see DATAREDIS-425 + */ + @Test + public void findProcessesCallbackReturningNullCorrectly() { + + Person rand = new Person(); + rand.firstname = "rand"; + + Person mat = new Person(); + mat.firstname = "mat"; + + template.insert(rand); + template.insert(mat); + + List result = template.find(new RedisCallback>() { + + @Override + public List doInRedis(RedisConnection connection) throws DataAccessException { + return null; + } + }, Person.class); + + assertThat(result.size(), is(0)); + } + + @RedisHash("template-test-person") + static class Person { + + @Id String id; + String firstname; + + @Override + public int hashCode() { + + int result = ObjectUtils.nullSafeHashCode(firstname); + return result + ObjectUtils.nullSafeHashCode(id); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null) { + return false; + } + if (!(obj instanceof Person)) { + return false; + } + Person that = (Person) obj; + + if (!ObjectUtils.nullSafeEquals(this.firstname, that.firstname)) { + return false; + } + + return ObjectUtils.nullSafeEquals(this.id, that.id); + } + + } +} diff --git a/src/test/java/org/springframework/data/redis/core/convert/ConversionTestEntities.java b/src/test/java/org/springframework/data/redis/core/convert/ConversionTestEntities.java new file mode 100644 index 000000000..95915f2ff --- /dev/null +++ b/src/test/java/org/springframework/data/redis/core/convert/ConversionTestEntities.java @@ -0,0 +1,142 @@ +/* + * Copyright 2015 the original author or authors. + * + * Licensed 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.springframework.data.redis.core.convert; + +import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +import org.springframework.data.annotation.Id; +import org.springframework.data.annotation.Reference; +import org.springframework.data.redis.core.RedisHash; +import org.springframework.data.redis.core.TimeToLive; +import org.springframework.data.redis.core.index.Indexed; + +/** + * @author Christoph Strobl + */ +class ConversionTestEntities { + + static final String KEYSPACE_PERSON = "persons"; + static final String KEYSPACE_TWOT = "twot"; + static final String KEYSPACE_LOCATION = "locations"; + + @RedisHash(KEYSPACE_PERSON) + static class Person { + + @Id String id; + String firstname; + Gender gender; + + List nicknames; + List coworkers; + Integer age; + Boolean alive; + Date birthdate; + + Address address; + + Map physicalAttributes; + Map relatives; + + @Reference Location location; + @Reference List visited; + + Species species; + } + + static class PersonWithAddressReference extends Person { + + @Reference AddressWithId addressRef; + } + + static class Address { + + String city; + @Indexed String country; + } + + static class AddressWithId extends Address { + + @Id String id; + } + + static enum Gender { + MALE, FEMALE + } + + static class AddressWithPostcode extends Address { + + String postcode; + } + + static class TaVeren extends Person { + + Object feature; + Map characteristics; + List items; + } + + @RedisHash(KEYSPACE_LOCATION) + static class Location { + + @Id String id; + String name; + Address address; + } + + @RedisHash(timeToLive = 5) + static class ExpiringPerson { + + @Id String id; + String name; + } + + static class ExipringPersonWithExplicitProperty extends ExpiringPerson { + + @TimeToLive(unit = TimeUnit.MINUTES) Long ttl; + } + + static class Species { + + String name; + List alsoKnownAs; + } + + @RedisHash(KEYSPACE_TWOT) + static class TheWheelOfTime { + + List mainCharacters; + List species; + Map places; + } + + static class Item { + + @Indexed String type; + String description; + Size size; + } + + static class Size { + + int width; + int height; + int length; + } + +} diff --git a/src/test/java/org/springframework/data/redis/core/convert/IndexResolverImplUnitTests.java b/src/test/java/org/springframework/data/redis/core/convert/IndexResolverImplUnitTests.java new file mode 100644 index 000000000..4147c4dd4 --- /dev/null +++ b/src/test/java/org/springframework/data/redis/core/convert/IndexResolverImplUnitTests.java @@ -0,0 +1,427 @@ +/* + * Copyright 2015 the original author or authors. + * + * Licensed 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.springframework.data.redis.core.convert; + +import static org.hamcrest.core.Is.*; +import static org.hamcrest.core.IsCollectionContaining.*; +import static org.hamcrest.core.IsNull.*; +import static org.junit.Assert.*; +import static org.mockito.Matchers.*; +import static org.mockito.Mockito.*; +import static org.springframework.data.redis.core.convert.ConversionTestEntities.*; + +import java.lang.annotation.Annotation; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.Set; + +import org.hamcrest.core.IsCollectionContaining; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.runners.MockitoJUnitRunner; +import org.springframework.data.mapping.PersistentProperty; +import org.springframework.data.redis.core.convert.ConversionTestEntities.Address; +import org.springframework.data.redis.core.convert.ConversionTestEntities.AddressWithId; +import org.springframework.data.redis.core.convert.ConversionTestEntities.Item; +import org.springframework.data.redis.core.convert.ConversionTestEntities.Location; +import org.springframework.data.redis.core.convert.ConversionTestEntities.Person; +import org.springframework.data.redis.core.convert.ConversionTestEntities.PersonWithAddressReference; +import org.springframework.data.redis.core.convert.ConversionTestEntities.TaVeren; +import org.springframework.data.redis.core.convert.ConversionTestEntities.TheWheelOfTime; +import org.springframework.data.redis.core.index.IndexConfiguration; +import org.springframework.data.redis.core.index.IndexConfiguration.RedisIndexSetting; +import org.springframework.data.redis.core.index.IndexType; +import org.springframework.data.redis.core.index.Indexed; +import org.springframework.data.redis.core.mapping.RedisMappingContext; +import org.springframework.data.util.ClassTypeInformation; + +/** + * @author Christoph Strobl + */ +@RunWith(MockitoJUnitRunner.class) +public class IndexResolverImplUnitTests { + + IndexConfiguration indexConfig; + IndexResolverImpl indexResolver; + + @Mock PersistentProperty propertyMock; + + @Before + public void setUp() { + + indexConfig = new IndexConfiguration(); + this.indexResolver = new IndexResolverImpl(new RedisMappingContext(new MappingConfiguration(indexConfig, + new KeyspaceConfiguration()))); + } + + /** + * @see DATAREDIS-425 + */ + @Test(expected = IllegalArgumentException.class) + public void shouldThrowExceptionOnNullMappingContext() { + new IndexResolverImpl(null); + } + + /** + * @see DATAREDIS-425 + */ + @Test + public void shouldResolveAnnotatedIndexOnRootWhenValueIsNotNull() { + + Address address = new Address(); + address.country = "andor"; + + Set indexes = indexResolver.resolveIndexesFor(ClassTypeInformation.from(Address.class), address); + + assertThat(indexes.size(), is(1)); + assertThat(indexes, hasItem(new SimpleIndexedPropertyValue(Address.class.getName(), "country", "andor"))); + } + + /** + * @see DATAREDIS-425 + */ + @Test + public void shouldNotResolveAnnotatedIndexOnRootWhenValueIsNull() { + + Address address = new Address(); + address.country = null; + + Set indexes = indexResolver.resolveIndexesFor(ClassTypeInformation.from(Address.class), address); + + assertThat(indexes.size(), is(0)); + } + + /** + * @see DATAREDIS-425 + */ + @Test + public void shouldResolveAnnotatedIndexOnNestedObjectWhenValueIsNotNull() { + + Person person = new Person(); + person.address = new Address(); + person.address.country = "andor"; + + Set indexes = indexResolver.resolveIndexesFor(ClassTypeInformation.from(Person.class), person); + + assertThat(indexes.size(), is(1)); + assertThat(indexes, hasItem(new SimpleIndexedPropertyValue(KEYSPACE_PERSON, "address.country", "andor"))); + } + + /** + * @see DATAREDIS-425 + */ + @Test + public void shouldResolveMultipleAnnotatedIndexesInLists() { + + TheWheelOfTime twot = new TheWheelOfTime(); + twot.mainCharacters = new ArrayList(); + + Person rand = new Person(); + rand.address = new Address(); + rand.address.country = "andor"; + + Person zarine = new Person(); + zarine.address = new Address(); + zarine.address.country = "saldaea"; + + twot.mainCharacters.add(rand); + twot.mainCharacters.add(zarine); + + Set indexes = indexResolver.resolveIndexesFor(ClassTypeInformation.from(TheWheelOfTime.class), twot); + + assertThat(indexes.size(), is(2)); + assertThat(indexes, IsCollectionContaining. hasItems(new SimpleIndexedPropertyValue(KEYSPACE_TWOT, + "mainCharacters.address.country", "andor"), new SimpleIndexedPropertyValue(KEYSPACE_TWOT, + "mainCharacters.address.country", "saldaea"))); + } + + /** + * @see DATAREDIS-425 + */ + @Test + public void shouldResolveAnnotatedIndexesInMap() { + + TheWheelOfTime twot = new TheWheelOfTime(); + twot.places = new LinkedHashMap(); + + Location stoneOfTear = new Location(); + stoneOfTear.name = "Stone of Tear"; + stoneOfTear.address = new Address(); + stoneOfTear.address.city = "tear"; + stoneOfTear.address.country = "illian"; + + twot.places.put("stone-of-tear", stoneOfTear); + + Set indexes = indexResolver.resolveIndexesFor(ClassTypeInformation.from(TheWheelOfTime.class), twot); + + assertThat(indexes.size(), is(1)); + assertThat(indexes, hasItem(new SimpleIndexedPropertyValue(KEYSPACE_TWOT, "places.stone-of-tear.address.country", + "illian"))); + } + + /** + * @see DATAREDIS-425 + */ + @Test + public void shouldResolveConfiguredIndexesInMapOfSimpleTypes() { + + indexConfig.addIndexDefinition(new RedisIndexSetting(KEYSPACE_PERSON, "physicalAttributes.eye-color")); + + Person rand = new Person(); + rand.physicalAttributes = new LinkedHashMap(); + rand.physicalAttributes.put("eye-color", "grey"); + + Set indexes = indexResolver.resolveIndexesFor(ClassTypeInformation.from(Person.class), rand); + + assertThat(indexes.size(), is(1)); + assertThat(indexes, + hasItem(new SimpleIndexedPropertyValue(KEYSPACE_PERSON, "physicalAttributes.eye-color", "grey"))); + } + + /** + * @see DATAREDIS-425 + */ + @Test + public void shouldResolveConfiguredIndexesInMapOfComplexTypes() { + + indexConfig.addIndexDefinition(new RedisIndexSetting(KEYSPACE_PERSON, "relatives.father.firstname")); + + Person rand = new Person(); + rand.relatives = new LinkedHashMap(); + + Person janduin = new Person(); + janduin.firstname = "janduin"; + + rand.relatives.put("father", janduin); + + Set indexes = indexResolver.resolveIndexesFor(ClassTypeInformation.from(Person.class), rand); + + assertThat(indexes.size(), is(1)); + assertThat(indexes, + hasItem(new SimpleIndexedPropertyValue(KEYSPACE_PERSON, "relatives.father.firstname", "janduin"))); + } + + /** + * @see DATAREDIS-425 + */ + @Test + public void shouldIgnoreConfiguredIndexesInMapWhenValueIsNull() { + + indexConfig.addIndexDefinition(new RedisIndexSetting(KEYSPACE_PERSON, "physicalAttributes.eye-color")); + + Person rand = new Person(); + rand.physicalAttributes = new LinkedHashMap(); + rand.physicalAttributes.put("eye-color", null); + + Set indexes = indexResolver.resolveIndexesFor(ClassTypeInformation.from(Person.class), rand); + + assertThat(indexes.size(), is(0)); + } + + /** + * @see DATAREDIS-425 + */ + @Test + public void shouldNotResolveIndexOnReferencedEntity() { + + PersonWithAddressReference rand = new PersonWithAddressReference(); + rand.addressRef = new AddressWithId(); + rand.addressRef.id = "emond_s_field"; + rand.addressRef.country = "andor"; + + Set indexes = indexResolver.resolveIndexesFor( + ClassTypeInformation.from(PersonWithAddressReference.class), rand); + + assertThat(indexes.size(), is(0)); + } + + /** + * @see DATAREDIS-425 + */ + @Test + public void resolveIndexShouldReturnNullWhenNoIndexConfigured() { + + when(propertyMock.isAnnotationPresent(eq(Indexed.class))).thenReturn(false); + assertThat(resolve("foo", "rand"), nullValue()); + } + + /** + * @see DATAREDIS-425 + */ + @Test + public void resolveIndexShouldReturnDataWhenIndexConfigured() { + + when(propertyMock.isAnnotationPresent(eq(Indexed.class))).thenReturn(false); + indexConfig.addIndexDefinition(new RedisIndexSetting(KEYSPACE_PERSON, "foo")); + + assertThat(resolve("foo", "rand"), notNullValue()); + } + + /** + * @see DATAREDIS-425 + */ + @Test + public void resolveIndexShouldReturnDataWhenNoIndexConfiguredButPropertyAnnotated() { + + when(propertyMock.isAnnotationPresent(eq(Indexed.class))).thenReturn(true); + when(propertyMock.findAnnotation(eq(Indexed.class))).thenReturn(createIndexedInstance(IndexType.SIMPLE)); + + assertThat(resolve("foo", "rand"), notNullValue()); + } + + /** + * @see DATAREDIS-425 + */ + @Test + public void resolveIndexShouldRemovePositionIndicatorForValuesInLists() { + + when(propertyMock.isCollectionLike()).thenReturn(true); + when(propertyMock.isAnnotationPresent(eq(Indexed.class))).thenReturn(true); + when(propertyMock.findAnnotation(eq(Indexed.class))).thenReturn(createIndexedInstance(IndexType.SIMPLE)); + + IndexedData index = resolve("list.[0].name", "rand"); + + assertThat(index.getPath(), is("list.name")); + } + + /** + * @see DATAREDIS-425 + */ + @Test + public void resolveIndexShouldRemoveKeyIndicatorForValuesInMap() { + + when(propertyMock.isMap()).thenReturn(true); + when(propertyMock.isAnnotationPresent(eq(Indexed.class))).thenReturn(true); + when(propertyMock.findAnnotation(eq(Indexed.class))).thenReturn(createIndexedInstance(IndexType.SIMPLE)); + + IndexedData index = resolve("map.[foo].name", "rand"); + + assertThat(index.getPath(), is("map.foo.name")); + } + + /** + * @see DATAREDIS-425 + */ + @Test + public void resolveIndexShouldKeepNumericalKeyForValuesInMap() { + + when(propertyMock.isMap()).thenReturn(true); + when(propertyMock.isAnnotationPresent(eq(Indexed.class))).thenReturn(true); + when(propertyMock.findAnnotation(eq(Indexed.class))).thenReturn(createIndexedInstance(IndexType.SIMPLE)); + + IndexedData index = resolve("map.[0].name", "rand"); + + assertThat(index.getPath(), is("map.0.name")); + } + + /** + * @see DATAREDIS-425 + */ + @Test + public void resolveIndexShouldInspectObjectTypeProperties() { + + Item hat = new Item(); + hat.type = "hat"; + + TaVeren mat = new TaVeren(); + mat.feature = hat; + + Set indexes = indexResolver.resolveIndexesFor(ClassTypeInformation.from(TaVeren.class), mat); + + assertThat(indexes.size(), is(1)); + assertThat(indexes, hasItem(new SimpleIndexedPropertyValue(KEYSPACE_PERSON, "feature.type", "hat"))); + } + + /** + * @see DATAREDIS-425 + */ + @Test + public void resolveIndexShouldInspectObjectTypePropertiesButIgnoreNullValues() { + + Item hat = new Item(); + hat.description = "wide brimmed hat"; + + TaVeren mat = new TaVeren(); + mat.feature = hat; + + Set indexes = indexResolver.resolveIndexesFor(ClassTypeInformation.from(TaVeren.class), mat); + + assertThat(indexes.size(), is(0)); + } + + /** + * @see DATAREDIS-425 + */ + @Test + public void resolveIndexShouldInspectObjectTypeValuesInMapProperties() { + + Item hat = new Item(); + hat.type = "hat"; + + TaVeren mat = new TaVeren(); + mat.characteristics = new LinkedHashMap(2); + mat.characteristics.put("clothing", hat); + mat.characteristics.put("gambling", "owns the dark one's luck"); + + Set indexes = indexResolver.resolveIndexesFor(ClassTypeInformation.from(TaVeren.class), mat); + + assertThat(indexes.size(), is(1)); + assertThat(indexes, + hasItem(new SimpleIndexedPropertyValue(KEYSPACE_PERSON, "characteristics.clothing.type", "hat"))); + } + + /** + * @see DATAREDIS-425 + */ + @Test + public void resolveIndexShouldInspectObjectTypeValuesInListProperties() { + + Item hat = new Item(); + hat.type = "hat"; + + TaVeren mat = new TaVeren(); + mat.items = new ArrayList(2); + mat.items.add(hat); + mat.items.add("foxhead medallion"); + + Set indexes = indexResolver.resolveIndexesFor(ClassTypeInformation.from(TaVeren.class), mat); + + assertThat(indexes.size(), is(1)); + assertThat(indexes, hasItem(new SimpleIndexedPropertyValue(KEYSPACE_PERSON, "items.type", "hat"))); + } + + private IndexedData resolve(String path, Object value) { + return indexResolver.resolveIndex(KEYSPACE_PERSON, path, propertyMock, value); + } + + private Indexed createIndexedInstance(final IndexType type) { + + return new Indexed() { + + @Override + public Class annotationType() { + return Indexed.class; + } + + @Override + public IndexType type() { + return type == null ? IndexType.SIMPLE : type; + } + }; + } +} diff --git a/src/test/java/org/springframework/data/redis/core/convert/MappingRedisConverterUnitTests.java b/src/test/java/org/springframework/data/redis/core/convert/MappingRedisConverterUnitTests.java new file mode 100644 index 000000000..a0a44fa1e --- /dev/null +++ b/src/test/java/org/springframework/data/redis/core/convert/MappingRedisConverterUnitTests.java @@ -0,0 +1,1157 @@ +/* + * Copyright 2015 the original author or authors. + * + * Licensed 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.springframework.data.redis.core.convert; + +import static org.hamcrest.core.Is.*; +import static org.hamcrest.core.IsCollectionContaining.*; +import static org.hamcrest.core.IsInstanceOf.*; +import static org.hamcrest.core.IsNull.*; +import static org.junit.Assert.*; +import static org.mockito.Matchers.*; +import static org.mockito.Mockito.*; +import static org.springframework.data.redis.core.convert.ConversionTestEntities.*; +import static org.springframework.data.redis.test.util.IsBucketMatcher.*; + +import java.io.Serializable; +import java.nio.charset.Charset; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Calendar; +import java.util.Collections; +import java.util.Date; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.UUID; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.runners.MockitoJUnitRunner; +import org.springframework.core.convert.converter.Converter; +import org.springframework.data.convert.ReadingConverter; +import org.springframework.data.convert.WritingConverter; +import org.springframework.data.redis.core.convert.ConversionTestEntities.Address; +import org.springframework.data.redis.core.convert.ConversionTestEntities.AddressWithId; +import org.springframework.data.redis.core.convert.ConversionTestEntities.AddressWithPostcode; +import org.springframework.data.redis.core.convert.ConversionTestEntities.ExipringPersonWithExplicitProperty; +import org.springframework.data.redis.core.convert.ConversionTestEntities.ExpiringPerson; +import org.springframework.data.redis.core.convert.ConversionTestEntities.Gender; +import org.springframework.data.redis.core.convert.ConversionTestEntities.Location; +import org.springframework.data.redis.core.convert.ConversionTestEntities.Person; +import org.springframework.data.redis.core.convert.ConversionTestEntities.Species; +import org.springframework.data.redis.core.convert.ConversionTestEntities.TaVeren; +import org.springframework.data.redis.core.convert.ConversionTestEntities.TheWheelOfTime; +import org.springframework.data.redis.core.convert.KeyspaceConfiguration.KeyspaceSettings; +import org.springframework.data.redis.core.mapping.RedisMappingContext; +import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer; +import org.springframework.util.StringUtils; + +import com.fasterxml.jackson.annotation.JsonAutoDetect.Visibility; +import com.fasterxml.jackson.databind.ObjectMapper; + +/** + * @author Christoph Strobl + */ +@RunWith(MockitoJUnitRunner.class) +public class MappingRedisConverterUnitTests { + + @Mock ReferenceResolver resolverMock; + MappingRedisConverter converter; + Person rand; + + @Before + public void setUp() { + + converter = new MappingRedisConverter(new RedisMappingContext(), null, resolverMock); + converter.afterPropertiesSet(); + + rand = new Person(); + + } + + /** + * @see DATAREDIS-425 + */ + @Test + public void writeAppendsTypeHintForRootCorrectly() { + assertThat(write(rand).getBucket(), isBucket().containingTypeHint("_class", Person.class)); + } + + /** + * @see DATAREDIS-425 + */ + @Test + public void writeAppendsKeyCorrectly() { + + rand.id = "1"; + + assertThat(write(rand).getId(), is((Serializable) "1")); + } + + /** + * @see DATAREDIS-425 + */ + @Test + public void writeAppendsKeyCorrectlyWhenThereIsAnAdditionalIdFieldInNestedElement() { + + AddressWithId address = new AddressWithId(); + address.id = "tear"; + address.city = "Tear"; + + rand.id = "1"; + rand.address = address; + + RedisData data = write(rand); + + assertThat(data.getId(), is((Serializable) "1")); + assertThat(data.getBucket(), isBucket().containingUtf8String("address.id", "tear")); + } + + /** + * @see DATAREDIS-425 + */ + @Test + public void writeDoesNotAppendPropertiesWithNullValues() { + + rand.firstname = "rand"; + + assertThat(write(rand).getBucket(), isBucket().without("lastname")); + } + + /** + * @see DATAREDIS-425 + */ + @Test + public void writeDoesNotAppendPropertiesWithEmtpyCollections() { + + rand.firstname = "rand"; + + assertThat(write(rand).getBucket(), isBucket().without("nicknames")); + } + + /** + * @see DATAREDIS-425 + */ + @Test + public void writeAppendsSimpleRootPropertyCorrectly() { + + rand.firstname = "nynaeve"; + + assertThat(write(rand).getBucket(), isBucket().containingUtf8String("firstname", "nynaeve")); + } + + /** + * @see DATAREDIS-425 + */ + @Test + public void writeAppendsListOfSimplePropertiesCorrectly() { + + rand.nicknames = Arrays.asList("dragon reborn", "lews therin"); + + RedisData target = write(rand); + + assertThat(target.getBucket(), isBucket().containingUtf8String("nicknames.[0]", "dragon reborn") + .containingUtf8String("nicknames.[1]", "lews therin")); + } + + /** + * @see DATAREDIS-425 + */ + @Test + public void writeAppendsComplexObjectCorrectly() { + + Address address = new Address(); + address.city = "two rivers"; + address.country = "andora"; + rand.address = address; + + RedisData target = write(rand); + + assertThat(target.getBucket(), + isBucket().containingUtf8String("address.city", "two rivers").containingUtf8String("address.country", "andora")); + } + + /** + * @see DATAREDIS-425 + */ + @Test + public void writeAppendsListOfComplexObjectsCorrectly() { + + Person mat = new Person(); + mat.firstname = "mat"; + mat.nicknames = Arrays.asList("prince of the ravens"); + + Person perrin = new Person(); + perrin.firstname = "perrin"; + perrin.address = new Address(); + perrin.address.city = "two rivers"; + + rand.coworkers = Arrays.asList(mat, perrin); + rand.id = UUID.randomUUID().toString(); + rand.firstname = "rand"; + + RedisData target = write(rand); + + assertThat(target.getBucket(), isBucket().containingUtf8String("coworkers.[0].firstname", "mat") // + .containingUtf8String("coworkers.[0].nicknames.[0]", "prince of the ravens") // + .containingUtf8String("coworkers.[1].firstname", "perrin") // + .containingUtf8String("coworkers.[1].address.city", "two rivers")); + } + + /** + * @see DATAREDIS-425 + */ + @Test + public void writeDoesNotAddClassTypeInformationCorrectlyForMatchingTypes() { + + Address address = new Address(); + address.city = "two rivers"; + + rand.address = address; + + RedisData target = write(rand); + + assertThat(target.getBucket(), isBucket().without("address._class")); + } + + /** + * @see DATAREDIS-425 + */ + @Test + public void writeAddsClassTypeInformationCorrectlyForNonMatchingTypes() { + + AddressWithPostcode address = new AddressWithPostcode(); + address.city = "two rivers"; + address.postcode = "1234"; + + rand.address = address; + + RedisData target = write(rand); + + assertThat(target.getBucket(), isBucket().containingTypeHint("address._class", AddressWithPostcode.class)); + } + + /** + * @see DATAREDIS-425 + */ + @Test + public void readConsidersClassTypeInformationCorrectlyForNonMatchingTypes() { + + Map map = new HashMap(); + map.put("address._class", AddressWithPostcode.class.getName()); + map.put("address.postcode", "1234"); + + Person target = converter.read(Person.class, new RedisData(Bucket.newBucketFromStringMap(map))); + + assertThat(target.address, instanceOf(AddressWithPostcode.class)); + } + + /** + * @see DATAREDIS-425 + */ + @Test + public void writeAddsClassTypeInformationCorrectlyForNonMatchingTypesInCollections() { + + Person mat = new TaVeren(); + mat.firstname = "mat"; + + rand.coworkers = Arrays.asList(mat); + + RedisData target = write(rand); + + assertThat(target.getBucket(), isBucket().containingTypeHint("coworkers.[0]._class", TaVeren.class)); + } + + /** + * @see DATAREDIS-425 + */ + @Test + public void readConvertsSimplePropertiesCorrectly() { + + RedisData rdo = new RedisData(Bucket.newBucketFromStringMap(Collections.singletonMap("firstname", "rand"))); + + assertThat(converter.read(Person.class, rdo).firstname, is("rand")); + } + + /** + * @see DATAREDIS-425 + */ + @Test + public void readConvertsListOfSimplePropertiesCorrectly() { + + Map map = new LinkedHashMap(); + map.put("nicknames.[0]", "dragon reborn"); + map.put("nicknames.[1]", "lews therin"); + RedisData rdo = new RedisData(Bucket.newBucketFromStringMap(map)); + + assertThat(converter.read(Person.class, rdo).nicknames, hasItems("dragon reborn", "lews therin")); + } + + /** + * @see DATAREDIS-425 + */ + @Test + public void readComplexPropertyCorrectly() { + + Map map = new LinkedHashMap(); + map.put("address.city", "two rivers"); + map.put("address.country", "andor"); + RedisData rdo = new RedisData(Bucket.newBucketFromStringMap(map)); + + Person target = converter.read(Person.class, rdo); + + assertThat(target.address, notNullValue()); + assertThat(target.address.city, is("two rivers")); + assertThat(target.address.country, is("andor")); + } + + /** + * @see DATAREDIS-425 + */ + @Test + public void readListComplexPropertyCorrectly() { + + Map map = new LinkedHashMap(); + map.put("coworkers.[0].firstname", "mat"); + map.put("coworkers.[0].nicknames.[0]", "prince of the ravens"); + map.put("coworkers.[1].firstname", "perrin"); + map.put("coworkers.[1].address.city", "two rivers"); + RedisData rdo = new RedisData(Bucket.newBucketFromStringMap(map)); + + Person target = converter.read(Person.class, rdo); + + assertThat(target.coworkers, notNullValue()); + assertThat(target.coworkers.get(0).firstname, is("mat")); + assertThat(target.coworkers.get(0).nicknames, notNullValue()); + assertThat(target.coworkers.get(0).nicknames.get(0), is("prince of the ravens")); + + assertThat(target.coworkers.get(1).firstname, is("perrin")); + assertThat(target.coworkers.get(1).address.city, is("two rivers")); + } + + /** + * @see DATAREDIS-425 + */ + @Test + public void readListComplexPropertyCorrectlyAndConsidersClassTypeInformation() { + + Map map = new LinkedHashMap(); + map.put("coworkers.[0]._class", TaVeren.class.getName()); + map.put("coworkers.[0].firstname", "mat"); + + RedisData rdo = new RedisData(Bucket.newBucketFromStringMap(map)); + + Person target = converter.read(Person.class, rdo); + + assertThat(target.coworkers, notNullValue()); + assertThat(target.coworkers.get(0), instanceOf(TaVeren.class)); + assertThat(target.coworkers.get(0).firstname, is("mat")); + } + + /** + * @see DATAREDIS-425 + */ + @Test + public void writeAppendsMapWithSimpleKeyCorrectly() { + + Map map = new LinkedHashMap(); + map.put("hair-color", "red"); + map.put("eye-color", "grey"); + + rand.physicalAttributes = map; + + RedisData target = write(rand); + + assertThat(target.getBucket(), isBucket().containingUtf8String("physicalAttributes.[hair-color]", "red") // + .containingUtf8String("physicalAttributes.[eye-color]", "grey")); + } + + /** + * @see DATAREDIS-425 + */ + @Test + public void writeAppendsMapWithSimpleKeyOnNestedObjectCorrectly() { + + Map map = new LinkedHashMap(); + map.put("hair-color", "red"); + map.put("eye-color", "grey"); + + rand.coworkers = new ArrayList(); + rand.coworkers.add(new Person()); + rand.coworkers.get(0).physicalAttributes = map; + + RedisData target = write(rand); + + assertThat(target.getBucket(), + isBucket().containingUtf8String("coworkers.[0].physicalAttributes.[hair-color]", "red") // + .containingUtf8String("coworkers.[0].physicalAttributes.[eye-color]", "grey")); + } + + /** + * @see DATAREDIS-425 + */ + @Test + public void readSimpleMapValuesCorrectly() { + + Map map = new LinkedHashMap(); + map.put("physicalAttributes.[hair-color]", "red"); + map.put("physicalAttributes.[eye-color]", "grey"); + + RedisData rdo = new RedisData(Bucket.newBucketFromStringMap(map)); + + Person target = converter.read(Person.class, rdo); + + assertThat(target.physicalAttributes, notNullValue()); + assertThat(target.physicalAttributes.get("hair-color"), is("red")); + assertThat(target.physicalAttributes.get("eye-color"), is("grey")); + } + + /** + * @see DATAREDIS-425 + */ + @Test + public void writeAppendsMapWithComplexObjectsCorrectly() { + + Map map = new LinkedHashMap(); + Person janduin = new Person(); + janduin.firstname = "janduin"; + map.put("father", janduin); + Person tam = new Person(); + tam.firstname = "tam"; + map.put("step-father", tam); + + rand.relatives = map; + + RedisData target = write(rand); + + assertThat(target.getBucket(), isBucket().containingUtf8String("relatives.[father].firstname", "janduin") // + .containingUtf8String("relatives.[step-father].firstname", "tam")); + } + + /** + * @see DATAREDIS-425 + */ + @Test + public void readMapWithComplexObjectsCorrectly() { + + Map map = new LinkedHashMap(); + map.put("relatives.[father].firstname", "janduin"); + map.put("relatives.[step-father].firstname", "tam"); + + Person target = converter.read(Person.class, new RedisData(Bucket.newBucketFromStringMap(map))); + + assertThat(target.relatives, notNullValue()); + assertThat(target.relatives.get("father"), notNullValue()); + assertThat(target.relatives.get("father").firstname, is("janduin")); + assertThat(target.relatives.get("step-father"), notNullValue()); + assertThat(target.relatives.get("step-father").firstname, is("tam")); + } + + /** + * @see DATAREDIS-425 + */ + @Test + public void writeAppendsClassTypeInformationCorrectlyForMapWithComplexObjects() { + + Map map = new LinkedHashMap(); + Person lews = new TaVeren(); + lews.firstname = "lews"; + map.put("previous-incarnation", lews); + + rand.relatives = map; + + RedisData target = write(rand); + + assertThat(target.getBucket(), + isBucket().containingTypeHint("relatives.[previous-incarnation]._class", TaVeren.class)); + } + + /** + * @see DATAREDIS-425 + */ + @Test + public void readConsidersClassTypeInformationCorrectlyForMapWithComplexObjects() { + + Map map = new LinkedHashMap(); + map.put("relatives.[previous-incarnation]._class", TaVeren.class.getName()); + map.put("relatives.[previous-incarnation].firstname", "lews"); + + Person target = converter.read(Person.class, new RedisData(Bucket.newBucketFromStringMap(map))); + + assertThat(target.relatives.get("previous-incarnation"), notNullValue()); + assertThat(target.relatives.get("previous-incarnation"), instanceOf(TaVeren.class)); + assertThat(target.relatives.get("previous-incarnation").firstname, is("lews")); + } + + /** + * @see DATAREDIS-425 + */ + @Test + public void writesIntegerValuesCorrectly() { + + rand.age = 20; + + assertThat(write(rand).getBucket(), isBucket().containingUtf8String("age", "20")); + } + + /** + * @see DATAREDIS-425 + */ + @Test + public void writesEnumValuesCorrectly() { + + rand.gender = Gender.MALE; + + assertThat(write(rand).getBucket(), isBucket().containingUtf8String("gender", "MALE")); + } + + /** + * @see DATAREDIS-425 + */ + @Test + public void readsEnumValuesCorrectly() { + + Person target = converter.read(Person.class, + new RedisData(Bucket.newBucketFromStringMap(Collections.singletonMap("gender", "MALE")))); + + assertThat(target.gender, is(Gender.MALE)); + } + + /** + * @see DATAREDIS-425 + */ + @Test + public void writesBooleanValuesCorrectly() { + + rand.alive = Boolean.TRUE; + + assertThat(write(rand).getBucket(), isBucket().containingUtf8String("alive", "1")); + } + + /** + * @see DATAREDIS-425 + */ + @Test + public void readsBooleanValuesCorrectly() { + + Person target = converter.read(Person.class, + new RedisData(Bucket.newBucketFromStringMap(Collections.singletonMap("alive", "1")))); + + assertThat(target.alive, is(Boolean.TRUE)); + } + + /** + * @see DATAREDIS-425 + */ + @Test + public void readsStringBooleanValuesCorrectly() { + + Person target = converter.read(Person.class, + new RedisData(Bucket.newBucketFromStringMap(Collections.singletonMap("alive", "true")))); + + assertThat(target.alive, is(Boolean.TRUE)); + } + + /** + * @see DATAREDIS-425 + */ + @Test + public void writesDateValuesCorrectly() { + + Calendar cal = Calendar.getInstance(); + cal.set(1978, 10, 25); + + rand.birthdate = cal.getTime(); + + assertThat(write(rand).getBucket(), isBucket().containingDateAsMsec("birthdate", rand.birthdate)); + } + + /** + * @see DATAREDIS-425 + */ + @Test + public void readsDateValuesCorrectly() { + + Calendar cal = Calendar.getInstance(); + cal.set(1978, 10, 25); + + Date date = cal.getTime(); + + Person target = converter.read( + Person.class, + new RedisData(Bucket.newBucketFromStringMap(Collections.singletonMap("birthdate", Long.valueOf(date.getTime()) + .toString())))); + + assertThat(target.birthdate, is(date)); + } + + /** + * @see DATAREDIS-425 + */ + @Test + public void writeSingleReferenceOnRootCorrectly() { + + Location location = new Location(); + location.id = "1"; + location.name = "tar valon"; + + rand.location = location; + + RedisData target = write(rand); + + assertThat(target.getBucket(), isBucket().containingUtf8String("location", "locations:1") // + .without("location.id") // + .without("location.name")); + } + + /** + * @see DATAREDIS-425 + */ + @Test + public void readLoadsReferenceDataOnRootCorrectly() { + + Location location = new Location(); + location.id = "1"; + location.name = "tar valon"; + + when(resolverMock. resolveReference(eq("1"), eq("locations"), eq(Location.class))).thenReturn(location); + + Map map = new LinkedHashMap(); + map.put("location", "locations:1"); + + Person target = converter.read(Person.class, new RedisData(Bucket.newBucketFromStringMap(map))); + + assertThat(target.location, is(location)); + } + + /** + * @see DATAREDIS-425 + */ + @Test + public void writeSingleReferenceOnNestedElementCorrectly() { + + Location location = new Location(); + location.id = "1"; + location.name = "tar valon"; + + Person egwene = new Person(); + egwene.location = location; + + rand.coworkers = Collections.singletonList(egwene); + + RedisData target = write(rand); + + assertThat(target.getBucket(), isBucket().containingUtf8String("coworkers.[0].location", "locations:1") // + .without("coworkers.[0].location.id") // + .without("coworkers.[0].location.name")); + } + + /** + * @see DATAREDIS-425 + */ + @Test + public void readLoadsReferenceDataOnNestedElementCorrectly() { + + Location location = new Location(); + location.id = "1"; + location.name = "tar valon"; + + when(resolverMock. resolveReference(eq("1"), eq("locations"), eq(Location.class))).thenReturn(location); + + Map map = new LinkedHashMap(); + map.put("coworkers.[0].location", "locations:1"); + + Person target = converter.read(Person.class, new RedisData(Bucket.newBucketFromStringMap(map))); + + assertThat(target.coworkers.get(0).location, is(location)); + } + + /** + * @see DATAREDIS-425 + */ + @Test + public void writeListOfReferencesOnRootCorrectly() { + + Location tarValon = new Location(); + tarValon.id = "1"; + tarValon.name = "tar valon"; + + Location falme = new Location(); + falme.id = "2"; + falme.name = "falme"; + + Location tear = new Location(); + tear.id = "3"; + tear.name = "city of tear"; + + rand.visited = Arrays.asList(tarValon, falme, tear); + + RedisData target = write(rand); + + assertThat(target.getBucket(), isBucket().containingUtf8String("visited.[0]", "locations:1") // + .containingUtf8String("visited.[1]", "locations:2") // + .containingUtf8String("visited.[2]", "locations:3")); + } + + /** + * @see DATAREDIS-425 + */ + @Test + public void readLoadsListOfReferencesOnRootCorrectly() { + + Location tarValon = new Location(); + tarValon.id = "1"; + tarValon.name = "tar valon"; + + Location falme = new Location(); + falme.id = "2"; + falme.name = "falme"; + + Location tear = new Location(); + tear.id = "3"; + tear.name = "city of tear"; + + when(resolverMock. resolveReference(eq("1"), eq("locations"), eq(Location.class))).thenReturn(tarValon); + when(resolverMock. resolveReference(eq("2"), eq("locations"), eq(Location.class))).thenReturn(falme); + when(resolverMock. resolveReference(eq("3"), eq("locations"), eq(Location.class))).thenReturn(tear); + + Map map = new LinkedHashMap(); + map.put("visited.[0]", "locations:1"); + map.put("visited.[1]", "locations:2"); + map.put("visited.[2]", "locations:3"); + + Person target = converter.read(Person.class, new RedisData(Bucket.newBucketFromStringMap(map))); + + assertThat(target.visited.get(0), is(tarValon)); + assertThat(target.visited.get(1), is(falme)); + assertThat(target.visited.get(2), is(tear)); + } + + /** + * @see DATAREDIS-425 + */ + @Test + public void writeSetsAnnotatedTimeToLiveCorrectly() { + + ExpiringPerson birgitte = new ExpiringPerson(); + birgitte.id = "birgitte"; + birgitte.name = "Birgitte Silverbow"; + + assertThat(write(birgitte).getTimeToLive(), is(5L)); + } + + /** + * @see DATAREDIS-425 + */ + @Test + public void writeDoesNotTTLWhenNotPresent() { + + Location tear = new Location(); + tear.id = "tear"; + tear.name = "Tear"; + + assertThat(write(tear).getTimeToLive(), nullValue()); + } + + /** + * @see DATAREDIS-425 + */ + @Test + public void writeShouldConsiderKeyspaceConfiguration() { + + this.converter.getMappingContext().getMappingConfiguration().getKeyspaceConfiguration() + .addKeyspaceSettings(new KeyspaceSettings(Address.class, "o_O")); + + Address address = new Address(); + address.city = "Tear"; + + assertThat(write(address).getKeyspace(), is("o_O")); + } + + /** + * @see DATAREDIS-425 + */ + @Test + public void writeShouldConsiderTimeToLiveConfiguration() { + + KeyspaceSettings assignment = new KeyspaceSettings(Address.class, "o_O"); + assignment.setTimeToLive(5L); + + this.converter.getMappingContext().getMappingConfiguration().getKeyspaceConfiguration() + .addKeyspaceSettings(assignment); + + Address address = new Address(); + address.city = "Tear"; + + assertThat(write(address).getTimeToLive(), is(5L)); + } + + /** + * @see DATAREDIS-425 + */ + @Test + public void writeShouldHonorCustomConversionOnRootType() { + + this.converter = new MappingRedisConverter(null, null, resolverMock); + this.converter + .setCustomConversions(new CustomConversions(Collections.singletonList(new AddressToBytesConverter()))); + this.converter.afterPropertiesSet(); + + Address address = new Address(); + address.country = "Tel'aran'rhiod"; + address.city = "unknown"; + + assertThat(write(address).getBucket(), + isBucket().containingUtf8String("_raw", "{\"city\":\"unknown\",\"country\":\"Tel'aran'rhiod\"}")); + } + + /** + * @see DATAREDIS-425 + */ + @Test + public void writeShouldHonorCustomConversionOnNestedType() { + + this.converter = new MappingRedisConverter(null, null, resolverMock); + this.converter + .setCustomConversions(new CustomConversions(Collections.singletonList(new AddressToBytesConverter()))); + this.converter.afterPropertiesSet(); + + Address address = new Address(); + address.country = "Tel'aran'rhiod"; + address.city = "unknown"; + rand.address = address; + + assertThat(write(rand).getBucket(), + isBucket().containingUtf8String("address", "{\"city\":\"unknown\",\"country\":\"Tel'aran'rhiod\"}")); + } + + /** + * @see DATAREDIS-425 + */ + @Test + public void writeShouldHonorIndexOnCustomConversionForNestedType() { + + this.converter = new MappingRedisConverter(null, null, resolverMock); + this.converter + .setCustomConversions(new CustomConversions(Collections.singletonList(new AddressToBytesConverter()))); + this.converter.afterPropertiesSet(); + + Address address = new Address(); + address.country = "andor"; + rand.address = address; + + assertThat(write(rand).getIndexedData(), hasItem(new SimpleIndexedPropertyValue(KEYSPACE_PERSON, "address.country", + "andor"))); + } + + /** + * @see DATAREDIS-425 + */ + @Test + public void writeShouldHonorIndexAnnotationsOnWhenCustomConversionOnNestedype() { + + this.converter = new MappingRedisConverter(new RedisMappingContext(), null, resolverMock); + this.converter + .setCustomConversions(new CustomConversions(Collections.singletonList(new AddressToBytesConverter()))); + this.converter.afterPropertiesSet(); + + Address address = new Address(); + address.country = "Tel'aran'rhiod"; + address.city = "unknown"; + rand.address = address; + + assertThat(write(rand).getIndexedData().isEmpty(), is(false)); + } + + /** + * @see DATAREDIS-425 + */ + @Test + public void readShouldHonorCustomConversionOnRootType() { + + this.converter = new MappingRedisConverter(null, null, resolverMock); + this.converter + .setCustomConversions(new CustomConversions(Collections.singletonList(new BytesToAddressConverter()))); + this.converter.afterPropertiesSet(); + + Map map = new LinkedHashMap(); + map.put("_raw", "{\"city\":\"unknown\",\"country\":\"Tel'aran'rhiod\"}"); + + Address target = converter.read(Address.class, new RedisData(Bucket.newBucketFromStringMap(map))); + + assertThat(target.city, is("unknown")); + assertThat(target.country, is("Tel'aran'rhiod")); + } + + /** + * @see DATAREDIS-425 + */ + @Test + public void readShouldHonorCustomConversionOnNestedType() { + + this.converter = new MappingRedisConverter(new RedisMappingContext(), null, resolverMock); + this.converter + .setCustomConversions(new CustomConversions(Collections.singletonList(new BytesToAddressConverter()))); + this.converter.afterPropertiesSet(); + + Map map = new LinkedHashMap(); + map.put("address", "{\"city\":\"unknown\",\"country\":\"Tel'aran'rhiod\"}"); + + Person target = converter.read(Person.class, new RedisData(Bucket.newBucketFromStringMap(map))); + + assertThat(target.address, notNullValue()); + assertThat(target.address.city, is("unknown")); + assertThat(target.address.country, is("Tel'aran'rhiod")); + } + + /** + * @see DATAREDIS-425 + */ + @Test + public void writeShouldPickUpTimeToLiveFromPropertyIfPresent() { + + ExipringPersonWithExplicitProperty aviendha = new ExipringPersonWithExplicitProperty(); + aviendha.id = "aviendha"; + aviendha.ttl = 2L; + + assertThat(write(aviendha).getTimeToLive(), is(120L)); + } + + /** + * @see DATAREDIS-425 + */ + @Test + public void writeShouldUseDefaultTimeToLiveIfPropertyIsPresentButNull() { + + ExipringPersonWithExplicitProperty aviendha = new ExipringPersonWithExplicitProperty(); + aviendha.id = "aviendha"; + + assertThat(write(aviendha).getTimeToLive(), is(5L)); + } + + /** + * @see DATAREDIS-425 + */ + @Test + public void writeShouldConsiderMapConvertersForRootType() { + + this.converter = new MappingRedisConverter(new RedisMappingContext(), null, resolverMock); + this.converter.setCustomConversions(new CustomConversions(Collections.singletonList(new SpeciesToMapConverter()))); + this.converter.afterPropertiesSet(); + + Species myrddraal = new Species(); + myrddraal.name = "myrddraal"; + myrddraal.alsoKnownAs = Arrays.asList("halfmen", "fades", "neverborn"); + + assertThat(write(myrddraal).getBucket(), isBucket().containingUtf8String("species-name", "myrddraal") + .containingUtf8String("species-nicknames", "halfmen,fades,neverborn")); + } + + /** + * @see DATAREDIS-425 + */ + @Test + public void writeShouldConsiderMapConvertersForNestedType() { + + this.converter = new MappingRedisConverter(null, null, resolverMock); + this.converter.setCustomConversions(new CustomConversions(Collections.singletonList(new SpeciesToMapConverter()))); + this.converter.afterPropertiesSet(); + + rand.species = new Species(); + rand.species.name = "human"; + + assertThat(write(rand).getBucket(), isBucket().containingUtf8String("species.species-name", "human")); + } + + /** + * @see DATAREDIS-425 + */ + @Test + public void readShouldConsiderMapConvertersForRootType() { + + this.converter = new MappingRedisConverter(new RedisMappingContext(), null, resolverMock); + this.converter.setCustomConversions(new CustomConversions(Collections.singletonList(new MapToSpeciesConverter()))); + this.converter.afterPropertiesSet(); + Map map = new LinkedHashMap(); + map.put("species-name", "trolloc"); + + Species target = converter.read(Species.class, new RedisData(Bucket.newBucketFromStringMap(map))); + + assertThat(target, notNullValue()); + assertThat(target.name, is("trolloc")); + } + + /** + * @see DATAREDIS-425 + */ + @Test + public void readShouldConsiderMapConvertersForNestedType() { + + this.converter = new MappingRedisConverter(null, null, resolverMock); + this.converter.setCustomConversions(new CustomConversions(Collections.singletonList(new MapToSpeciesConverter()))); + this.converter.afterPropertiesSet(); + + Map map = new LinkedHashMap(); + map.put("species.species-name", "trolloc"); + + Person target = converter.read(Person.class, new RedisData(Bucket.newBucketFromStringMap(map))); + + assertThat(target, notNullValue()); + assertThat(target.species.name, is("trolloc")); + } + + /** + * @see DATAREDIS-425 + */ + @Test + public void writeShouldConsiderMapConvertersInsideLists() { + + this.converter = new MappingRedisConverter(new RedisMappingContext(), null, resolverMock); + this.converter.setCustomConversions(new CustomConversions(Collections.singletonList(new SpeciesToMapConverter()))); + this.converter.afterPropertiesSet(); + + TheWheelOfTime twot = new TheWheelOfTime(); + twot.species = new ArrayList(); + + Species myrddraal = new Species(); + myrddraal.name = "myrddraal"; + myrddraal.alsoKnownAs = Arrays.asList("halfmen", "fades", "neverborn"); + twot.species.add(myrddraal); + + assertThat(write(twot).getBucket(), isBucket().containingUtf8String("species.[0].species-name", "myrddraal") + .containingUtf8String("species.[0].species-nicknames", "halfmen,fades,neverborn")); + } + + /** + * @see DATAREDIS-425 + */ + @Test + public void readShouldConsiderMapConvertersForValuesInList() { + + this.converter = new MappingRedisConverter(null, null, resolverMock); + this.converter.setCustomConversions(new CustomConversions(Collections.singletonList(new MapToSpeciesConverter()))); + this.converter.afterPropertiesSet(); + + Map map = new LinkedHashMap(); + map.put("species.[0].species-name", "trolloc"); + + TheWheelOfTime target = converter.read(TheWheelOfTime.class, new RedisData(Bucket.newBucketFromStringMap(map))); + + assertThat(target, notNullValue()); + assertThat(target.species, notNullValue()); + assertThat(target.species.get(0), notNullValue()); + assertThat(target.species.get(0).name, is("trolloc")); + } + + private RedisData write(Object source) { + + RedisData rdo = new RedisData(); + converter.write(source, rdo); + return rdo; + } + + @WritingConverter + static class AddressToBytesConverter implements Converter { + + private final ObjectMapper mapper; + private final Jackson2JsonRedisSerializer
serializer; + + AddressToBytesConverter() { + + mapper = new ObjectMapper(); + mapper.setVisibility(mapper.getSerializationConfig().getDefaultVisibilityChecker() + .withFieldVisibility(Visibility.ANY).withGetterVisibility(Visibility.NONE) + .withSetterVisibility(Visibility.NONE).withCreatorVisibility(Visibility.NONE)); + + serializer = new Jackson2JsonRedisSerializer
(Address.class); + serializer.setObjectMapper(mapper); + } + + @Override + public byte[] convert(Address value) { + return serializer.serialize(value); + } + } + + @WritingConverter + static class SpeciesToMapConverter implements Converter> { + + @Override + public Map convert(Species source) { + + if (source == null) { + return null; + } + + Map map = new LinkedHashMap(); + if (source.name != null) { + map.put("species-name", source.name.getBytes(Charset.forName("UTF-8"))); + } + map.put("species-nicknames", + StringUtils.collectionToCommaDelimitedString(source.alsoKnownAs).getBytes(Charset.forName("UTF-8"))); + return map; + } + } + + @ReadingConverter + static class MapToSpeciesConverter implements Converter, Species> { + + @Override + public Species convert(Map source) { + + if (source == null || source.isEmpty()) { + return null; + } + + Species species = new Species(); + + if (source.containsKey("species-name")) { + species.name = new String(source.get("species-name"), Charset.forName("UTF-8")); + } + if (source.containsKey("species-nicknames")) { + species.alsoKnownAs = Arrays.asList(StringUtils.commaDelimitedListToStringArray(new String(source + .get("species-nicknames"), Charset.forName("UTF-8")))); + } + return species; + } + } + + @ReadingConverter + static class BytesToAddressConverter implements Converter { + + private final ObjectMapper mapper; + private final Jackson2JsonRedisSerializer
serializer; + + BytesToAddressConverter() { + + mapper = new ObjectMapper(); + mapper.setVisibility(mapper.getSerializationConfig().getDefaultVisibilityChecker() + .withFieldVisibility(Visibility.ANY).withGetterVisibility(Visibility.NONE) + .withSetterVisibility(Visibility.NONE).withCreatorVisibility(Visibility.NONE)); + + serializer = new Jackson2JsonRedisSerializer
(Address.class); + serializer.setObjectMapper(mapper); + } + + @Override + public Address convert(byte[] value) { + return serializer.deserialize(value); + } + } + +} diff --git a/src/test/java/org/springframework/data/redis/core/mapping/ConfigAwareKeySpaceResolverUnitTests.java b/src/test/java/org/springframework/data/redis/core/mapping/ConfigAwareKeySpaceResolverUnitTests.java new file mode 100644 index 000000000..a0aab535a --- /dev/null +++ b/src/test/java/org/springframework/data/redis/core/mapping/ConfigAwareKeySpaceResolverUnitTests.java @@ -0,0 +1,71 @@ +/* + * Copyright 2015 the original author or authors. + * + * Licensed 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.springframework.data.redis.core.mapping; + +import static org.hamcrest.core.Is.*; +import static org.junit.Assert.*; + +import org.junit.Before; +import org.junit.Test; +import org.springframework.data.redis.core.convert.KeyspaceConfiguration; +import org.springframework.data.redis.core.convert.KeyspaceConfiguration.KeyspaceSettings; +import org.springframework.data.redis.core.mapping.RedisMappingContext.ConfigAwareKeySpaceResolver; + +/** + * @author Christoph Strobl + */ +public class ConfigAwareKeySpaceResolverUnitTests { + + static final String CUSTOM_KEYSPACE = "car'a'carn"; + KeyspaceConfiguration config = new KeyspaceConfiguration(); + ConfigAwareKeySpaceResolver resolver; + + @Before + public void setUp() { + this.resolver = new ConfigAwareKeySpaceResolver(config); + } + + /** + * @see DATAREDIS-425 + */ + @Test(expected = IllegalArgumentException.class) + public void resolveShouldThrowExceptionWhenTypeIsNull() { + resolver.resolveKeySpace(null); + } + + /** + * @see DATAREDIS-425 + */ + @Test + public void resolveShouldUseClassNameAsDefaultKeyspace() { + assertThat(resolver.resolveKeySpace(TypeWithoutAnySettings.class), is(TypeWithoutAnySettings.class.getName())); + } + + /** + * @see DATAREDIS-425 + */ + @Test + public void resolveShouldFavorConfiguredNameOverClassName() { + + config.addKeyspaceSettings(new KeyspaceSettings(TypeWithoutAnySettings.class, "ji'e'toh")); + assertThat(resolver.resolveKeySpace(TypeWithoutAnySettings.class), is("ji'e'toh")); + } + + static class TypeWithoutAnySettings { + + } + +} diff --git a/src/test/java/org/springframework/data/redis/core/mapping/ConfigAwareTimeToLiveAccessorUnitTests.java b/src/test/java/org/springframework/data/redis/core/mapping/ConfigAwareTimeToLiveAccessorUnitTests.java new file mode 100644 index 000000000..8e85f85d4 --- /dev/null +++ b/src/test/java/org/springframework/data/redis/core/mapping/ConfigAwareTimeToLiveAccessorUnitTests.java @@ -0,0 +1,220 @@ +/* + * Copyright 2015 the original author or authors. + * + * Licensed 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.springframework.data.redis.core.mapping; + +import static org.hamcrest.core.Is.*; +import static org.hamcrest.core.IsNull.*; +import static org.junit.Assert.*; + +import org.junit.Before; +import org.junit.Test; +import org.springframework.data.redis.core.RedisHash; +import org.springframework.data.redis.core.TimeToLive; +import org.springframework.data.redis.core.convert.KeyspaceConfiguration; +import org.springframework.data.redis.core.convert.KeyspaceConfiguration.KeyspaceSettings; +import org.springframework.data.redis.core.mapping.RedisMappingContext.ConfigAwareTimeToLiveAccessor; + +/** + * @author Christoph Strobl + */ +public class ConfigAwareTimeToLiveAccessorUnitTests { + + ConfigAwareTimeToLiveAccessor accessor; + KeyspaceConfiguration config; + + @Before + public void setUp() { + + config = new KeyspaceConfiguration(); + accessor = new ConfigAwareTimeToLiveAccessor(config, new RedisMappingContext()); + } + + /** + * @see DATAREDIS-425 + */ + @Test(expected = IllegalArgumentException.class) + public void getTimeToLiveShouldThrowExceptionWhenSourceObjectIsNull() { + accessor.getTimeToLive(null); + } + + /** + * @see DATAREDIS-425 + */ + @Test + public void getTimeToLiveShouldReturnNullIfNothingConfiguredOrAnnotated() { + assertThat(accessor.getTimeToLive(new SimpleType()), nullValue()); + } + + /** + * @see DATAREDIS-425 + */ + @Test + public void getTimeToLiveShouldReturnConfiguredValueForSimpleType() { + + KeyspaceSettings setting = new KeyspaceSettings(SimpleType.class, null); + setting.setTimeToLive(10L); + config.addKeyspaceSettings(setting); + + assertThat(accessor.getTimeToLive(new SimpleType()), is(10L)); + } + + /** + * @see DATAREDIS-425 + */ + @Test + public void getTimeToLiveShouldReturnValueWhenTypeIsAnnotated() { + assertThat(accessor.getTimeToLive(new TypeWithRedisHashAnnotation()), is(5L)); + } + + /** + * @see DATAREDIS-425 + */ + @Test + public void getTimeToLiveConsidersAnnotationOverConfig() { + + KeyspaceSettings setting = new KeyspaceSettings(TypeWithRedisHashAnnotation.class, null); + setting.setTimeToLive(10L); + config.addKeyspaceSettings(setting); + + assertThat(accessor.getTimeToLive(new TypeWithRedisHashAnnotation()), is(5L)); + } + + /** + * @see DATAREDIS-425 + */ + @Test + public void getTimeToLiveShouldReturnValueWhenPropertyIsAnnotatedAndHasValue() { + assertThat(accessor.getTimeToLive(new TypeWithRedisHashAnnotationAndTTLProperty(20L)), is(20L)); + } + + /** + * @see DATAREDIS-425 + */ + @Test + public void getTimeToLiveShouldReturnValueFromTypeAnnotationWhenPropertyIsAnnotatedAndHasNullValue() { + assertThat(accessor.getTimeToLive(new TypeWithRedisHashAnnotationAndTTLProperty()), is(10L)); + } + + /** + * @see DATAREDIS-425 + */ + @Test + public void getTimeToLiveShouldReturnNullWhenPropertyIsAnnotatedAndHasNullValue() { + assertThat(accessor.getTimeToLive(new SimpleTypeWithTTLProperty()), nullValue()); + } + + /** + * @see DATAREDIS-425 + */ + @Test + public void getTimeToLiveShouldReturnConfiguredValueWhenPropertyIsAnnotatedAndHasNullValue() { + + KeyspaceSettings setting = new KeyspaceSettings(SimpleTypeWithTTLProperty.class, null); + setting.setTimeToLive(10L); + config.addKeyspaceSettings(setting); + + assertThat(accessor.getTimeToLive(new SimpleTypeWithTTLProperty()), is(10L)); + } + + /** + * @see DATAREDIS-425 + */ + @Test + public void getTimeToLiveShouldFavorAnnotatedNotNullPropertyValueOverConfiguredOne() { + + KeyspaceSettings setting = new KeyspaceSettings(SimpleTypeWithTTLProperty.class, null); + setting.setTimeToLive(10L); + config.addKeyspaceSettings(setting); + + assertThat(accessor.getTimeToLive(new SimpleTypeWithTTLProperty(25L)), is(25L)); + } + + /** + * @see DATAREDIS-425 + */ + @Test + public void getTimeToLiveShouldReturnMethodLevelTimeToLiveIfPresent() { + assertThat(accessor.getTimeToLive(new TypeWithTtlOnMethod(10L)), is(10L)); + } + + /** + * @see DATAREDIS-425 + */ + @Test + public void getTimeToLiveShouldReturnConfiguredValueWhenMethodLevelTimeToLiveIfPresentButHasNullValue() { + + KeyspaceSettings setting = new KeyspaceSettings(TypeWithTtlOnMethod.class, null); + setting.setTimeToLive(10L); + config.addKeyspaceSettings(setting); + + assertThat(accessor.getTimeToLive(new TypeWithTtlOnMethod(null)), is(10L)); + } + + /** + * @see DATAREDIS-425 + */ + @Test + public void getTimeToLiveShouldReturnValueWhenMethodLevelTimeToLiveIfPresentAlthoughConfiguredValuePresent() { + + KeyspaceSettings setting = new KeyspaceSettings(TypeWithTtlOnMethod.class, null); + setting.setTimeToLive(10L); + config.addKeyspaceSettings(setting); + + assertThat(accessor.getTimeToLive(new TypeWithTtlOnMethod(100L)), is(100L)); + } + + static class SimpleType {} + + static class SimpleTypeWithTTLProperty { + + @TimeToLive Long ttl; + + SimpleTypeWithTTLProperty() {} + + SimpleTypeWithTTLProperty(Long ttl) { + this.ttl = ttl; + } + } + + @RedisHash(timeToLive = 5) + static class TypeWithRedisHashAnnotation {} + + @RedisHash(timeToLive = 10) + static class TypeWithRedisHashAnnotationAndTTLProperty { + + @TimeToLive Long ttl; + + TypeWithRedisHashAnnotationAndTTLProperty() {} + + TypeWithRedisHashAnnotationAndTTLProperty(Long ttl) { + this.ttl = ttl; + } + } + + static class TypeWithTtlOnMethod { + + Long value; + + public TypeWithTtlOnMethod(Long value) { + this.value = value; + } + + @TimeToLive + Long getTimeToLive() { + return value; + } + } +} diff --git a/src/test/java/org/springframework/data/redis/listener/KeyExpirationEventMessageListenerTests.java b/src/test/java/org/springframework/data/redis/listener/KeyExpirationEventMessageListenerTests.java new file mode 100644 index 000000000..fc3a2d1cf --- /dev/null +++ b/src/test/java/org/springframework/data/redis/listener/KeyExpirationEventMessageListenerTests.java @@ -0,0 +1,135 @@ +/* + * Copyright 2015 the original author or authors. + * + * Licensed 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.springframework.data.redis.listener; + +import static org.hamcrest.core.Is.*; +import static org.junit.Assert.*; +import static org.mockito.Mockito.*; + +import java.util.UUID; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.runners.MockitoJUnitRunner; +import org.springframework.beans.factory.DisposableBean; +import org.springframework.context.ApplicationEvent; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.data.redis.connection.RedisConnection; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.jedis.JedisConnectionFactory; + +/** + * @author Christoph Strobl + */ +@RunWith(MockitoJUnitRunner.class) +public class KeyExpirationEventMessageListenerTests { + + RedisMessageListenerContainer container; + RedisConnectionFactory connectionFactory; + KeyExpirationEventMessageListener listener; + + @Mock ApplicationEventPublisher publisherMock; + + @Before + public void setUp() { + + JedisConnectionFactory connectionFactory = new JedisConnectionFactory(); + connectionFactory.afterPropertiesSet(); + this.connectionFactory = connectionFactory; + + container = new RedisMessageListenerContainer(); + container.setConnectionFactory(connectionFactory); + container.afterPropertiesSet(); + container.start(); + + listener = new KeyExpirationEventMessageListener(container); + listener.setApplicationEventPublisher(publisherMock); + listener.init(); + } + + @After + public void tearDown() throws Exception { + + RedisConnection connection = connectionFactory.getConnection(); + try { + connection.flushAll(); + } finally { + connection.close(); + } + + listener.destroy(); + container.destroy(); + if (connectionFactory instanceof DisposableBean) { + ((DisposableBean) connectionFactory).destroy(); + } + } + + /** + * @see DATAREDIS-425 + */ + @Test + public void listenerShouldPublishEventCorrectly() throws InterruptedException { + + byte[] key = ("to-expire:" + UUID.randomUUID().toString()).getBytes(); + + RedisConnection connection = connectionFactory.getConnection(); + try { + connection.setEx(key, 2, "foo".getBytes()); + + int iteration = 0; + while (connection.get(key) != null || iteration >= 3) { + + Thread.sleep(2000); + iteration++; + } + } finally { + connection.close(); + } + + Thread.sleep(2000); + ArgumentCaptor captor = ArgumentCaptor.forClass(ApplicationEvent.class); + + verify(publisherMock, times(1)).publishEvent(captor.capture()); + assertThat((byte[]) captor.getValue().getSource(), is(key)); + } + + /** + * @see DATAREDIS-425 + */ + @Test + public void listenerShouldNotReactToDeleteEvents() throws InterruptedException { + + byte[] key = ("to-delete:" + UUID.randomUUID().toString()).getBytes(); + + RedisConnection connection = connectionFactory.getConnection(); + try { + + connection.setEx(key, 10, "foo".getBytes()); + Thread.sleep(2000); + connection.del(key); + Thread.sleep(2000); + } finally { + connection.close(); + } + + Thread.sleep(2000); + verifyZeroInteractions(publisherMock); + } +} diff --git a/src/test/java/org/springframework/data/redis/listener/KeyExpirationEventMessageListenerUnitTests.java b/src/test/java/org/springframework/data/redis/listener/KeyExpirationEventMessageListenerUnitTests.java new file mode 100644 index 000000000..eb5604af5 --- /dev/null +++ b/src/test/java/org/springframework/data/redis/listener/KeyExpirationEventMessageListenerUnitTests.java @@ -0,0 +1,92 @@ +/* + * Copyright 2015 the original author or authors. + * + * Licensed 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.springframework.data.redis.listener; + +import static org.hamcrest.core.Is.*; +import static org.hamcrest.core.IsInstanceOf.*; +import static org.junit.Assert.*; +import static org.mockito.Mockito.*; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.runners.MockitoJUnitRunner; +import org.springframework.context.ApplicationEvent; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.data.redis.connection.DefaultMessage; +import org.springframework.data.redis.connection.Message; +import org.springframework.data.redis.core.RedisKeyExpiredEvent; + +/** + * @author Christoph Strobl + */ +@RunWith(MockitoJUnitRunner.class) +public class KeyExpirationEventMessageListenerUnitTests { + + private static final String MESSAGE_CHANNEL = "channel"; + private static final String MESSAGE_BODY = "body"; + private static final Message MESSAGE = new DefaultMessage(MESSAGE_CHANNEL.getBytes(), MESSAGE_BODY.getBytes()); + + @Mock RedisMessageListenerContainer containerMock; + @Mock ApplicationEventPublisher publisherMock; + KeyExpirationEventMessageListener listener; + + @Before + public void setUp() { + + listener = new KeyExpirationEventMessageListener(containerMock); + listener.setApplicationEventPublisher(publisherMock); + } + + /** + * @see DATAREDIS-425 + */ + @Test + public void handleMessageShouldPublishKeyExpiredEvent() { + + listener.onMessage(MESSAGE, "*".getBytes()); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ApplicationEvent.class); + + verify(publisherMock, times(1)).publishEvent(captor.capture()); + assertThat(captor.getValue(), instanceOf(RedisKeyExpiredEvent.class)); + assertThat((byte[]) captor.getValue().getSource(), is(MESSAGE_BODY.getBytes())); + } + + /** + * @see DATAREDIS-425 + */ + @Test + public void handleMessageShouldNotRespondToNullMessage() { + + listener.onMessage(null, "*".getBytes()); + + verifyZeroInteractions(publisherMock); + } + + /** + * @see DATAREDIS-425 + */ + @Test + public void handleMessageShouldNotRespondToEmptyMessage() { + + listener.onMessage(new DefaultMessage(null, null), "*".getBytes()); + + verifyZeroInteractions(publisherMock); + } +} diff --git a/src/test/java/org/springframework/data/redis/repository/RedisRepositoryIntegrationTests.java b/src/test/java/org/springframework/data/redis/repository/RedisRepositoryIntegrationTests.java new file mode 100644 index 000000000..1dc7120d5 --- /dev/null +++ b/src/test/java/org/springframework/data/redis/repository/RedisRepositoryIntegrationTests.java @@ -0,0 +1,331 @@ +/* + * Copyright 2015 the original author or authors. + * + * Licensed 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.springframework.data.redis.repository; + +import static org.hamcrest.core.Is.*; +import static org.hamcrest.core.IsCollectionContaining.*; +import static org.junit.Assert.*; + +import java.io.Serializable; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import org.hamcrest.core.IsNull; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.annotation.Id; +import org.springframework.data.annotation.Reference; +import org.springframework.data.keyvalue.core.KeyValueTemplate; +import org.springframework.data.redis.connection.jedis.JedisConnectionFactory; +import org.springframework.data.redis.core.RedisHash; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.convert.KeyspaceConfiguration; +import org.springframework.data.redis.core.index.IndexConfiguration; +import org.springframework.data.redis.core.index.Indexed; +import org.springframework.data.redis.repository.configuration.EnableRedisRepositories; +import org.springframework.data.repository.CrudRepository; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; + +/** + * @author Christoph Strobl + */ +@RunWith(SpringJUnit4ClassRunner.class) +@ContextConfiguration +public class RedisRepositoryIntegrationTests { + + @Configuration + @EnableRedisRepositories(considerNestedRepositories = true, indexConfiguration = MyIndexConfiguration.class, + keyspaceConfiguration = MyKeyspaceConfiguration.class) + static class Config { + + @Bean + RedisTemplate redisTemplate() { + + JedisConnectionFactory connectionFactory = new JedisConnectionFactory(); + connectionFactory.afterPropertiesSet(); + + RedisTemplate template = new RedisTemplate(); + template.setConnectionFactory(connectionFactory); + + return template; + } + + } + + @Autowired PersonRepository repo; + @Autowired KeyValueTemplate kvTemplate; + + @Before + public void setUp() { + + // flush keyspaces + kvTemplate.delete(Person.class); + kvTemplate.delete(City.class); + } + + /** + * @see DATAREDIS-425 + */ + @Test + public void simpleFindSouldReturnEntitiesCorrectly() { + + Person rand = new Person(); + rand.firstname = "rand"; + rand.lastname = "al'thor"; + + Person egwene = new Person(); + egwene.firstname = "egwene"; + + repo.save(Arrays.asList(rand, egwene)); + + assertThat(repo.count(), is(2L)); + + assertThat(repo.findOne(rand.id), is(rand)); + assertThat(repo.findOne(egwene.id), is(egwene)); + + assertThat(repo.findByFirstname("rand").size(), is(1)); + assertThat(repo.findByFirstname("rand"), hasItem(rand)); + + assertThat(repo.findByLastname("al'thor"), hasItem(rand)); + } + + /** + * @see DATAREDIS-425 + */ + @Test + public void simpleFindByMultipleProperties() { + + Person egwene = new Person(); + egwene.firstname = "egwene"; + egwene.lastname = "al'vere"; + + Person marin = new Person(); + marin.firstname = "marin"; + marin.lastname = "al'vere"; + + repo.save(Arrays.asList(egwene, marin)); + + assertThat(repo.findByLastname("al'vere").size(), is(2)); + + assertThat(repo.findByFirstnameAndLastname("egwene", "al'vere").size(), is(1)); + assertThat(repo.findByFirstnameAndLastname("egwene", "al'vere").get(0), is(egwene)); + } + + /** + * @see DATAREDIS-425 + */ + @Test + public void findReturnsReferenceDataCorrectly() { + + // Prepare referenced data entry + City tarValon = new City(); + tarValon.id = "1"; + tarValon.name = "tar valon"; + + kvTemplate.insert(tarValon); + + // Prepare domain entity + Person moiraine = new Person(); + moiraine.firstname = "moiraine"; + moiraine.city = tarValon; // reference data + + // save domain entity + repo.save(moiraine); + + // find and assert current location set correctly + Person loaded = repo.findOne(moiraine.getId()); + assertThat(loaded.city, is(tarValon)); + + // remove reference location data + kvTemplate.delete("1", City.class); + + // find and assert the location is gone + Person reLoaded = repo.findOne(moiraine.getId()); + assertThat(reLoaded.city, IsNull.nullValue()); + + } + + public static interface PersonRepository extends CrudRepository { + + List findByFirstname(String firstname); + + List findByLastname(String lastname); + + List findByFirstnameAndLastname(String firstname, String lastname); + } + + /** + * Custom Redis {@link IndexConfiguration} forcing index of {@link Person#lastname}. + * + * @author Christoph Strobl + */ + static class MyIndexConfiguration extends IndexConfiguration { + + @Override + protected Iterable initialConfiguration() { + return Collections.singleton(new RedisIndexSetting("persons", "lastname")); + } + } + + /** + * Custom Redis {@link IndexConfiguration} forcing index of {@link Person#lastname}. + * + * @author Christoph Strobl + */ + static class MyKeyspaceConfiguration extends KeyspaceConfiguration { + + @Override + protected Iterable initialConfiguration() { + return Collections.singleton(new KeyspaceSettings(City.class, "cities")); + } + } + + @RedisHash("persons") + @SuppressWarnings("serial") + public static class Person implements Serializable { + + @Id String id; + @Indexed String firstname; + String lastname; + @Reference City city; + + public City getCity() { + return city; + } + + public void setCity(City city) { + this.city = city; + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getFirstname() { + return firstname; + } + + public void setFirstname(String firstname) { + this.firstname = firstname; + } + + public void setLastname(String lastname) { + this.lastname = lastname; + } + + public String getLastname() { + return lastname; + } + + @Override + public String toString() { + return "Person [id=" + id + ", firstname=" + firstname + "]"; + } + + @Override + public int hashCode() { + final int prime = 31; + int result = 1; + result = prime * result + ((firstname == null) ? 0 : firstname.hashCode()); + result = prime * result + ((id == null) ? 0 : id.hashCode()); + return result; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null) { + return false; + } + if (!(obj instanceof Person)) { + return false; + } + Person other = (Person) obj; + if (firstname == null) { + if (other.firstname != null) { + return false; + } + } else if (!firstname.equals(other.firstname)) { + return false; + } + if (id == null) { + if (other.id != null) { + return false; + } + } else if (!id.equals(other.id)) { + return false; + } + return true; + } + + } + + public static class City { + @Id String id; + String name; + + @Override + public int hashCode() { + final int prime = 31; + int result = 1; + result = prime * result + ((id == null) ? 0 : id.hashCode()); + result = prime * result + ((name == null) ? 0 : name.hashCode()); + return result; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null) { + return false; + } + if (!(obj instanceof City)) { + return false; + } + City other = (City) obj; + if (id == null) { + if (other.id != null) { + return false; + } + } else if (!id.equals(other.id)) { + return false; + } + if (name == null) { + if (other.name != null) { + return false; + } + } else if (!name.equals(other.name)) { + return false; + } + return true; + } + } + +} diff --git a/src/test/java/org/springframework/data/redis/test/util/IsBucketMatcher.java b/src/test/java/org/springframework/data/redis/test/util/IsBucketMatcher.java new file mode 100644 index 000000000..1598b196c --- /dev/null +++ b/src/test/java/org/springframework/data/redis/test/util/IsBucketMatcher.java @@ -0,0 +1,201 @@ +/* + * Copyright 2015 the original author or authors. + * + * Licensed 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.springframework.data.redis.test.util; + +import java.util.Arrays; +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; + +import org.hamcrest.Description; +import org.hamcrest.Matcher; +import org.hamcrest.TypeSafeMatcher; +import org.springframework.data.redis.core.convert.Bucket; + +/** + * {@link TypeSafeMatcher} implementation for checking contents of {@link Bucket}. + * + * @author Christoph Strobl + * @since 1.7 + */ +public class IsBucketMatcher extends TypeSafeMatcher { + + Map expected = new LinkedHashMap(); + Set without = new LinkedHashSet(); + + /* + * (non-Javadoc) + * @see org.hamcrest.SelfDescribing#describeTo(org.hamcrest.Description) + */ + @Override + public void describeTo(Description description) { + + if (!expected.isEmpty()) { + description.appendValueList("Expected Bucket content [{", "},{", "}].", expected.entrySet()); + } + if (!without.isEmpty()) { + description.appendValueList("Expected Bucket to not include [", ",", "].", without); + } + + } + + /* + * (non-Javadoc) + * @see org.hamcrest.TypeSafeMatcher#matchesSafely(java.lang.Object) + */ + @Override + protected boolean matchesSafely(Bucket bucket) { + + if (bucket == null) { + return false; + } + + if (bucket.isEmpty() && expected.isEmpty()) { + return true; + } + + for (String notContained : without) { + byte[] value = bucket.get(notContained); + if (value != null || (value != null && value.length > 0)) { + return false; + } + } + + for (Map.Entry entry : expected.entrySet()) { + + byte[] actualValue = bucket.get(entry.getKey()); + Object expectedValue = entry.getValue(); + if (expectedValue == null && actualValue != null) { + return false; + } + + if (expectedValue != null && actualValue == null) { + return false; + } + + if (expectedValue instanceof byte[]) { + if (!Arrays.equals((byte[]) expectedValue, actualValue)) { + return false; + } + } else if (expectedValue instanceof String) { + if (!((String) expectedValue).equals(new String(actualValue, Bucket.CHARSET))) { + return false; + } + } else if (expectedValue instanceof Class) { + if (!((Class) expectedValue).getName().equals(new String(actualValue, Bucket.CHARSET))) { + return false; + } + } else if (expectedValue instanceof Date) { + if (((Date) expectedValue).getTime() != Long.valueOf(new String(actualValue, Bucket.CHARSET)).longValue()) { + return false; + } + } + + else if (expectedValue instanceof Matcher) { + if (!((Matcher) expectedValue).matches(actualValue)) { + return false; + } + } else { + if (!(expectedValue.toString()).equals(new String(actualValue, Bucket.CHARSET))) { + return false; + } + } + } + + return true; + } + + /** + * Creates new {@link IsBucketMatcher}. + * + * @return + */ + public static IsBucketMatcher isBucket() { + return new IsBucketMatcher(); + } + + /** + * Checks for presence of type hint at given path. + * + * @param path + * @param type + * @return + */ + public IsBucketMatcher containingTypeHint(String path, Class type) { + + this.expected.put(path, type); + return this; + } + + /** + * Checks for presence of equivalent String value at path. + * + * @param path + * @param value + * @return + */ + public IsBucketMatcher containingUtf8String(String path, String value) { + + this.expected.put(path, value); + return this; + } + + /** + * Checks for presence of given value at path. + * + * @param path + * @param value + * @return + */ + public IsBucketMatcher containing(String path, byte[] value) { + + this.expected.put(path, value); + return this; + } + + public IsBucketMatcher matchingPath(String path, Matcher matcher) { + + this.expected.put(path, matcher); + return this; + } + + /** + * Checks for presence of equivalent time in msec value at path. + * + * @param path + * @param date + * @return + */ + public IsBucketMatcher containingDateAsMsec(String path, Date date) { + + this.expected.put(path, date); + return this; + } + + /** + * Checks given path is not present. + * + * @param path + * @return + */ + public IsBucketMatcher without(String path) { + this.without.add(path); + return this; + } + +} diff --git a/template.mf b/template.mf index 984704fab..649a5a353 100644 --- a/template.mf +++ b/template.mf @@ -21,6 +21,9 @@ Import-Template: org.springframework.cglib.*;version="${spring:[=.=.=.=,+1.1.0)}", org.springframework.oxm.*;resolution:="optional";version="${spring:[=.=.=.=,+1.1.0)}", org.springframework.transaction.support.*;version="${spring:[=.=.=.=,+1.1.0)}", + org.springframework.expression.*;version="${spring:[=.=.=.=,+1.1.0)}", + org.springframework.data.*;version="0", + org.slf4j.*;version="[1.7.12, 1.7.12]", org.aopalliance.*;version="[1.0.0, 2.0.0)";resolution:=optional, org.apache.commons.logging.*;version="[1.1.1, 2.0.0)", org.w3c.dom.*;version="0",