diff --git a/build.gradle b/build.gradle index bb5dfaa4c..b6cf1abbb 100644 --- a/build.gradle +++ b/build.gradle @@ -40,6 +40,7 @@ dependencies { exclude module: "commons-logging" } compile "org.springframework:spring-context-support:$springVersion" + compile "org.springframework:spring-context:$springVersion" compile "org.springframework:spring-tx:$springVersion" compile("org.springframework:spring-oxm:$springVersion", optional) diff --git a/src/main/java/org/springframework/data/redis/connection/ReturnType.java b/src/main/java/org/springframework/data/redis/connection/ReturnType.java index 1c734d130..710d1465e 100644 --- a/src/main/java/org/springframework/data/redis/connection/ReturnType.java +++ b/src/main/java/org/springframework/data/redis/connection/ReturnType.java @@ -15,6 +15,8 @@ */ package org.springframework.data.redis.connection; +import java.util.List; + /** * Represents a data type returned from Redis, currently used to denote the * expected return type of Redis scripting commands @@ -37,5 +39,21 @@ public enum ReturnType { STATUS, /** Returned as byte[] **/ - VALUE + VALUE; + + public static ReturnType fromJavaType(Class javaType) { + if(javaType == null) { + return ReturnType.STATUS; + } + if(javaType.isAssignableFrom(List.class)) { + return ReturnType.MULTI; + } + if(javaType.isAssignableFrom(Boolean.class)) { + return ReturnType.BOOLEAN; + } + if(javaType.isAssignableFrom(Long.class)) { + return ReturnType.INTEGER; + } + return ReturnType.VALUE; + } } diff --git a/src/main/java/org/springframework/data/redis/core/RedisOperations.java b/src/main/java/org/springframework/data/redis/core/RedisOperations.java index 635f5f34e..819ee745c 100644 --- a/src/main/java/org/springframework/data/redis/core/RedisOperations.java +++ b/src/main/java/org/springframework/data/redis/core/RedisOperations.java @@ -23,6 +23,7 @@ import java.util.concurrent.TimeUnit; import org.springframework.data.redis.connection.DataType; import org.springframework.data.redis.core.query.SortQuery; +import org.springframework.data.redis.core.script.RedisScript; import org.springframework.data.redis.serializer.RedisSerializer; @@ -104,6 +105,40 @@ public interface RedisOperations { */ List executePipelined(final SessionCallback session, final RedisSerializer resultSerializer); + /** + * Executes the given {@link RedisScript} + * + * @param script + * The script to execute + * @param keys + * Any keys that need to be passed to the script + * @param args + * Any args that need to be passed to the script + * @return The return value of the script or null if {@link RedisScript#getResultType()} is + * null, likely indicating a throw-away status reply (i.e. "OK") + */ + T execute(RedisScript script, List keys, Object... args); + + /** + * Executes the given {@link RedisScript}, using the provided {@link RedisSerializer}s to + * serialize the script arguments and result. + * + * @param script + * The script to execute + * @param argsSerializer + * The {@link RedisSerializer} to use for serializing args + * @param resultSerializer + * The {@link RedisSerializer} to use for serializing the script return value + * @param keys + * Any keys that need to be passed to the script + * @param args + * Any args that need to be passed to the script + * @return The return value of the script or null if {@link RedisScript#getResultType()} is + * null, likely indicating a throw-away status reply (i.e. "OK") + */ + T execute(RedisScript script, RedisSerializer argsSerializer, RedisSerializer resultSerializer, + List keys, Object... args); + Boolean hasKey(K key); void delete(K key); diff --git a/src/main/java/org/springframework/data/redis/core/RedisTemplate.java b/src/main/java/org/springframework/data/redis/core/RedisTemplate.java index d2a5527ca..4491d7f0f 100644 --- a/src/main/java/org/springframework/data/redis/core/RedisTemplate.java +++ b/src/main/java/org/springframework/data/redis/core/RedisTemplate.java @@ -37,6 +37,9 @@ import org.springframework.data.redis.connection.SortParameters; import org.springframework.data.redis.core.ZSetOperations.TypedTuple; import org.springframework.data.redis.core.query.QueryUtils; import org.springframework.data.redis.core.query.SortQuery; +import org.springframework.data.redis.core.script.DefaultScriptExecutor; +import org.springframework.data.redis.core.script.RedisScript; +import org.springframework.data.redis.core.script.ScriptExecutor; import org.springframework.data.redis.serializer.JdkSerializationRedisSerializer; import org.springframework.data.redis.serializer.RedisSerializer; import org.springframework.data.redis.serializer.SerializationUtils; @@ -83,6 +86,8 @@ public class RedisTemplate extends RedisAccessor implements RedisOperation private RedisSerializer hashValueSerializer = null; private RedisSerializer stringSerializer = new StringRedisSerializer(); + private ScriptExecutor scriptExecutor; + // cache singleton objects (where possible) private ValueOperations valueOps; private ListOperations listOps; @@ -124,6 +129,10 @@ public class RedisTemplate extends RedisAccessor implements RedisOperation Assert.notNull(defaultSerializer, "default serializer null and not all serializers initialized"); } + if(scriptExecutor == null) { + this.scriptExecutor = new DefaultScriptExecutor(this); + } + initialized = true; } @@ -269,6 +278,15 @@ public class RedisTemplate extends RedisAccessor implements RedisOperation }); } + public T execute(RedisScript script, List keys, Object... args) { + return scriptExecutor.execute(script, keys, args); + } + + public T execute(RedisScript script, RedisSerializer argsSerializer, RedisSerializer resultSerializer, + List keys, Object... args) { + return scriptExecutor.execute(script, argsSerializer, resultSerializer, keys, args); + } + private Object executeSession(SessionCallback session) { return session.execute(this); } @@ -441,6 +459,14 @@ public class RedisTemplate extends RedisAccessor implements RedisOperation this.stringSerializer = stringSerializer; } + /** + * + * @param scriptExecutor The {@link ScriptExecutor} to use for executing Redis scripts + */ + public void setScriptExecutor(ScriptExecutor scriptExecutor) { + this.scriptExecutor = scriptExecutor; + } + @SuppressWarnings("unchecked") private byte[] rawKey(Object key) { Assert.notNull(key, "non null key required"); diff --git a/src/main/java/org/springframework/data/redis/core/script/DefaultRedisScript.java b/src/main/java/org/springframework/data/redis/core/script/DefaultRedisScript.java new file mode 100644 index 000000000..bad421b27 --- /dev/null +++ b/src/main/java/org/springframework/data/redis/core/script/DefaultRedisScript.java @@ -0,0 +1,101 @@ +/* + * Copyright 2013 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.script; + +import java.io.IOException; + +import org.springframework.core.io.Resource; +import org.springframework.scripting.ScriptSource; +import org.springframework.scripting.support.ResourceScriptSource; +import org.springframework.scripting.support.StaticScriptSource; + +/** + * Default implementation of {@link RedisScript}. Delegates to an underlying {@link ScriptSource} to + * retrieve script text and detect if script has been modified (and thus should have SHA1 + * re-calculated). This class is best used as a Singleton to avoid re-calculation of SHA1 on every + * script execution. + * + * @author Jennifer Hickey + * + * @param + * The script result type. Should be one of Long, Boolean, List, or deserialized value + * type. Can be null if the script returns a throw-away status (i.e "OK") + */ +public class DefaultRedisScript implements RedisScript { + + private ScriptSource scriptSource; + + private String sha1; + + private Class resultType; + + private final Object shaModifiedMonitor = new Object(); + + /** + * + * @param scriptLocation + * The location of the script + * @param resultType + * The Script result type + */ + public DefaultRedisScript(Resource scriptLocation, Class resultType) { + this(new ResourceScriptSource(scriptLocation), resultType); + } + + /** + * + * @param script + * The script + * @param resultType + * The Script result type + */ + public DefaultRedisScript(String script, Class resultType) { + this(new StaticScriptSource(script), resultType); + } + + /** + * + * @param scriptSource + * The {@link ScriptSource} of the script + * @param resultType + * The Script result type + */ + public DefaultRedisScript(ScriptSource scriptSource, Class resultType) { + this.scriptSource = scriptSource; + this.resultType = resultType; + } + + public String getSha1() { + synchronized (shaModifiedMonitor) { + if (sha1 == null || scriptSource.isModified()) { + this.sha1 = DigestUtils.sha1DigestAsHex(getScriptAsString()); + } + return sha1; + } + } + + public Class getResultType() { + return this.resultType; + } + + public String getScriptAsString() { + try { + return scriptSource.getScriptAsString(); + } catch (IOException e) { + throw new ScriptingException("Error reading script text", e); + } + } +} diff --git a/src/main/java/org/springframework/data/redis/core/script/DefaultScriptExecutor.java b/src/main/java/org/springframework/data/redis/core/script/DefaultScriptExecutor.java new file mode 100644 index 000000000..19f37e9f4 --- /dev/null +++ b/src/main/java/org/springframework/data/redis/core/script/DefaultScriptExecutor.java @@ -0,0 +1,140 @@ +/* + * Copyright 2013 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.script; + +import java.util.ArrayList; +import java.util.List; + +import org.springframework.dao.DataAccessException; +import org.springframework.data.redis.connection.RedisConnection; +import org.springframework.data.redis.connection.ReturnType; +import org.springframework.data.redis.core.RedisCallback; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.RedisSerializer; + +/** + * Default implementation of {@link ScriptExecutor}. Optimizes performance by attempting to execute + * script first using evalsha, then falling back to eval if Redis has not yet cached the script. + * Evalsha is not attempted if the script is executed in a pipeline or transaction. + * + * @author Jennifer Hickey + * + * @param + * The type of keys that may be passed during script execution + */ +public class DefaultScriptExecutor implements ScriptExecutor { + + private RedisTemplate template; + + /** + * + * @param template The {@link RedisTemplate} to use + */ + public DefaultScriptExecutor(RedisTemplate template) { + this.template = template; + } + + @SuppressWarnings("unchecked") + public T execute(final RedisScript script, final List keys, final Object... args) { + // use the Template's value serializer for args and result + return execute(script, template.getValueSerializer(), + (RedisSerializer) template.getValueSerializer(), keys, args); + } + + public T execute(final RedisScript script, final RedisSerializer argsSerializer, final RedisSerializer resultSerializer, + final List keys, final Object... args) { + return template.execute(new RedisCallback() { + public T doInRedis(RedisConnection connection) throws DataAccessException { + final ReturnType returnType = ReturnType.fromJavaType(script.getResultType()); + final byte[][] keysAndArgs = keysAndArgs(argsSerializer, keys, args); + final int keySize = keys != null ? keys.size() : 0; + if (connection.isPipelined() || connection.isQueueing()) { + // We could script load first and then do evalsha to ensure sha is present, + // but this adds a sha1 to exec/closePipeline results. Instead, just eval + connection.eval(scriptBytes(script), returnType, keySize, keysAndArgs); + return null; + } + return eval(connection, script, returnType, keySize, keysAndArgs, + resultSerializer); + } + }); + } + + protected T eval(RedisConnection connection, RedisScript script, ReturnType returnType, + int numKeys, byte[][] keysAndArgs, RedisSerializer resultSerializer) { + Object result; + try { + result = connection.evalSha(script.getSha1(), returnType, numKeys, keysAndArgs); + } catch (Exception e) { + result = connection.eval(scriptBytes(script), returnType, numKeys, keysAndArgs); + } + if (script.getResultType() == null) { + return null; + } + return deserializeResult(resultSerializer, result); + } + + @SuppressWarnings({ "unchecked", "rawtypes" }) + protected byte[][] keysAndArgs(RedisSerializer argsSerializer, List keys, Object[] args) { + final int keySize = keys != null ? keys.size() : 0; + byte[][] keysAndArgs = new byte[args.length + keySize][]; + int i = 0; + if(keys != null) { + for (K key : keys) { + if (keySerializer() == null && key instanceof byte[]) { + keysAndArgs[i++] = (byte[]) key; + } else { + keysAndArgs[i++] = keySerializer().serialize(key); + } + } + } + for (Object arg : args) { + if (argsSerializer == null && arg instanceof byte[]) { + keysAndArgs[i++] = (byte[]) arg; + } else { + keysAndArgs[i++] = argsSerializer.serialize(arg); + } + } + return keysAndArgs; + } + + protected byte[] scriptBytes(RedisScript script) { + return template.getStringSerializer().serialize(script.getScriptAsString()); + } + + @SuppressWarnings({ "rawtypes", "unchecked" }) + protected T deserializeResult(RedisSerializer resultSerializer, Object result) { + if (result instanceof byte[]) { + if (resultSerializer == null) { + return (T) result; + } + return resultSerializer.deserialize((byte[]) result); + } + if (result instanceof List) { + List results = new ArrayList(); + for (Object obj : (List) result) { + results.add(deserializeResult(resultSerializer, obj)); + } + return (T) results; + } + return (T) result; + } + + @SuppressWarnings("rawtypes") + protected RedisSerializer keySerializer() { + return template.getKeySerializer(); + } +} diff --git a/src/main/java/org/springframework/data/redis/core/script/DigestUtils.java b/src/main/java/org/springframework/data/redis/core/script/DigestUtils.java new file mode 100644 index 000000000..de4f9c9c6 --- /dev/null +++ b/src/main/java/org/springframework/data/redis/core/script/DigestUtils.java @@ -0,0 +1,69 @@ +/* + * Copyright 2013 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.script; + +import java.nio.charset.Charset; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; + +/** + * Utilties for working with {@link MessageDigest} + * + * @author Jennifer Hickey + * + */ +abstract public class DigestUtils { + + private static final char[] HEX_CHARS = { '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', + 'a', 'b', 'c', 'd', 'e', 'f' }; + + private static final Charset UTF8_CHARSET = Charset.forName("UTF8"); + + /** + * Returns the SHA1 of the provided data + * + * @param data + * The data to calculate, such as the contents of a file + * @return The human-readable SHA1 + */ + public static String sha1DigestAsHex(String data) { + byte[] dataBytes = getDigest("SHA").digest(data.getBytes(UTF8_CHARSET)); + return new String(encodeHex(dataBytes)); + } + + private static char[] encodeHex(byte[] data) { + int l = data.length; + char[] out = new char[l << 1]; + for (int i = 0, j = 0; i < l; i++) { + out[j++] = HEX_CHARS[(0xF0 & data[i]) >>> 4]; + out[j++] = HEX_CHARS[0x0F & data[i]]; + } + return out; + } + + /** + * Creates a new {@link MessageDigest} with the given algorithm. Necessary because + * {@code MessageDigest} is not thread-safe. + */ + private static MessageDigest getDigest(String algorithm) { + try { + return MessageDigest.getInstance(algorithm); + } catch (NoSuchAlgorithmException ex) { + throw new IllegalStateException("Could not find MessageDigest with algorithm \"" + + algorithm + "\"", ex); + } + } +} diff --git a/src/main/java/org/springframework/data/redis/core/script/RedisScript.java b/src/main/java/org/springframework/data/redis/core/script/RedisScript.java new file mode 100644 index 000000000..069206756 --- /dev/null +++ b/src/main/java/org/springframework/data/redis/core/script/RedisScript.java @@ -0,0 +1,49 @@ +/* + * Copyright 2013 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.script; + +/** + * A script to be executed using the Redis scripting + * support available as of version 2.6 + * + * @author Jennifer Hickey + * + * @param + * The script result type. Should be one of Long, Boolean, List, or deserialized value + * type. Can be null if the script returns a throw-away status (i.e "OK") + */ +public interface RedisScript { + + /** + * + * @return The SHA1 of the script, used for executing Redis evalsha command + */ + String getSha1(); + + /** + * + * @return The script result type. Should be one of Long, Boolean, List, or deserialized value + * type. Can be null if the script returns a throw-away status (i.e "OK") + */ + Class getResultType(); + + /** + * + * @return The script contents + */ + String getScriptAsString(); + +} diff --git a/src/main/java/org/springframework/data/redis/core/script/ScriptExecutor.java b/src/main/java/org/springframework/data/redis/core/script/ScriptExecutor.java new file mode 100644 index 000000000..d28f3554e --- /dev/null +++ b/src/main/java/org/springframework/data/redis/core/script/ScriptExecutor.java @@ -0,0 +1,66 @@ +/* + * Copyright 2013 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.script; + +import java.util.List; + +import org.springframework.data.redis.serializer.RedisSerializer; + +/** + * Executes {@link RedisScript}s + * + * @author Jennifer Hickey + * + * @param + * The type of keys that may be passed during script execution + */ +public interface ScriptExecutor { + + /** + * Executes the given {@link RedisScript} + * + * @param script + * The script to execute + * @param keys + * Any keys that need to be passed to the script + * @param args + * Any args that need to be passed to the script + * @return The return value of the script or null if {@link RedisScript#getResultType()} is + * null, likely indicating a throw-away status reply (i.e. "OK") + */ + T execute(RedisScript script, List keys, Object... args); + + /** + * Executes the given {@link RedisScript}, using the provided {@link RedisSerializer}s to + * serialize the script arguments and result. + * + * @param script + * The script to execute + * @param argsSerializer + * The {@link RedisSerializer} to use for serializing args + * @param resultSerializer + * The {@link RedisSerializer} to use for serializing the script return value + * @param keys + * Any keys that need to be passed to the script + * @param args + * Any args that need to be passed to the script + * @return The return value of the script or null if {@link RedisScript#getResultType()} is + * null, likely indicating a throw-away status reply (i.e. "OK") + */ + T execute(RedisScript script, RedisSerializer argsSerializer, RedisSerializer resultSerializer, + List keys, Object... args); + +} diff --git a/src/main/java/org/springframework/data/redis/core/script/ScriptingException.java b/src/main/java/org/springframework/data/redis/core/script/ScriptingException.java new file mode 100644 index 000000000..c21c564fa --- /dev/null +++ b/src/main/java/org/springframework/data/redis/core/script/ScriptingException.java @@ -0,0 +1,47 @@ +/* + * Copyright 2013 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.script; + +import org.springframework.core.NestedRuntimeException; + +/** + * {@link RuntimeException} thrown when issues occur with {@link RedisScript}s + * + * @author Jennifer Hickey + * + */ +@SuppressWarnings("serial") +public class ScriptingException extends NestedRuntimeException { + + /** + * Constructs a new ScriptingException instance. + * + * @param msg + * @param cause + */ + public ScriptingException(String msg, Throwable cause) { + super(msg, cause); + } + + /** + * Constructs a new ScriptingException instance. + * + * @param msg + */ + public ScriptingException(String msg) { + super(msg); + } +} diff --git a/src/test/java/org/springframework/data/redis/core/RedisTemplateTests.java b/src/test/java/org/springframework/data/redis/core/RedisTemplateTests.java index fd2ff26f0..4e9c24c4e 100644 --- a/src/test/java/org/springframework/data/redis/core/RedisTemplateTests.java +++ b/src/test/java/org/springframework/data/redis/core/RedisTemplateTests.java @@ -56,8 +56,12 @@ import org.springframework.data.redis.connection.StringRedisConnection; import org.springframework.data.redis.connection.srp.SrpConnectionFactory; import org.springframework.data.redis.core.ZSetOperations.TypedTuple; import org.springframework.data.redis.core.query.SortQueryBuilder; +import org.springframework.data.redis.core.script.DefaultRedisScript; +import org.springframework.data.redis.core.script.RedisScript; import org.springframework.data.redis.serializer.GenericToStringSerializer; import org.springframework.data.redis.serializer.RedisSerializer; +import org.springframework.data.redis.serializer.StringRedisSerializer; +import org.springframework.scripting.support.StaticScriptSource; /** * @@ -608,6 +612,16 @@ public class RedisTemplateTests { redisTemplate.convertAndSend("Channel", value1); } + @Test + public void testExecuteScriptCustomSerializers() { + assumeTrue(RedisTestProfileValueSource.matches("redisVersion", "2.6")); + K key1 = keyFactory.instance(); + final RedisScript script = new DefaultRedisScript(new StaticScriptSource( + "return 'Hey'"), String.class); + assertEquals("Hey", redisTemplate.execute(script, redisTemplate.getValueSerializer(), new StringRedisSerializer(), + Collections.singletonList(key1))); + } + @SuppressWarnings({ "unchecked", "rawtypes" }) private byte[] serialize(Object value, RedisSerializer serializer) { if(serializer == null && value instanceof byte[]) { diff --git a/src/test/java/org/springframework/data/redis/core/script/DefaultRedisScriptTests.java b/src/test/java/org/springframework/data/redis/core/script/DefaultRedisScriptTests.java new file mode 100644 index 000000000..7e5c9cc9c --- /dev/null +++ b/src/test/java/org/springframework/data/redis/core/script/DefaultRedisScriptTests.java @@ -0,0 +1,59 @@ +/* + * Copyright 2013 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.script; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; + +import org.junit.Test; +import org.springframework.core.io.ClassPathResource; +import org.springframework.scripting.support.ResourceScriptSource; +import org.springframework.scripting.support.StaticScriptSource; + +/** + * Test of {@link DefaultRedisScript} + * + * @author Jennifer Hickey + * + */ +public class DefaultRedisScriptTests { + + @Test + public void testGetSha1() { + StaticScriptSource script = new StaticScriptSource("return KEYS[1]"); + RedisScript redisScript = new DefaultRedisScript(script, String.class); + String sha1 = redisScript.getSha1(); + // Ensure multiple calls return same sha + assertEquals(sha1, redisScript.getSha1()); + script.setScript("return KEYS[2]"); + // Sha should now be different as script text has changed + assertFalse(sha1.equals(redisScript.getSha1())); + } + + @Test + public void testGetScriptAsString() { + RedisScript redisScript = new DefaultRedisScript("return ARGS[1]", + String.class); + assertEquals("return ARGS[1]", redisScript.getScriptAsString()); + } + + @Test(expected = ScriptingException.class) + public void testGetScriptAsStringError() { + RedisScript redisScript = new DefaultRedisScript(new ResourceScriptSource( + new ClassPathResource("nonexistent")), Long.class); + redisScript.getScriptAsString(); + } +} diff --git a/src/test/java/org/springframework/data/redis/core/script/DefaultScriptExecutorTests.java b/src/test/java/org/springframework/data/redis/core/script/DefaultScriptExecutorTests.java new file mode 100644 index 000000000..0f04baea1 --- /dev/null +++ b/src/test/java/org/springframework/data/redis/core/script/DefaultScriptExecutorTests.java @@ -0,0 +1,248 @@ +/* + * Copyright 2013 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.script; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import org.junit.After; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.core.io.ClassPathResource; +import org.springframework.dao.DataAccessException; +import org.springframework.data.redis.Person; +import org.springframework.data.redis.RedisTestProfileValueSource; +import org.springframework.data.redis.connection.RedisConnection; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.RedisCallback; +import org.springframework.data.redis.core.RedisOperations; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.SessionCallback; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.serializer.GenericToStringSerializer; +import org.springframework.data.redis.serializer.JacksonJsonRedisSerializer; +import org.springframework.data.redis.serializer.StringRedisSerializer; +import org.springframework.scripting.support.StaticScriptSource; +import org.springframework.test.annotation.IfProfileValue; +import org.springframework.test.annotation.ProfileValueSourceConfiguration; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; + +/** + * Integration test of {@link DefaultScriptExecutor} + * + * @author Jennifer Hickey + * + */ +@RunWith(SpringJUnit4ClassRunner.class) +@ContextConfiguration +@ProfileValueSourceConfiguration(RedisTestProfileValueSource.class) +@IfProfileValue(name = "redisVersion", value = "2.6") +public class DefaultScriptExecutorTests { + + @Autowired + private RedisConnectionFactory connFactory; + + @SuppressWarnings("rawtypes") + private RedisTemplate template; + + @SuppressWarnings("unchecked") + @After + public void tearDown() { + template.execute(new RedisCallback() { + public Object doInRedis(RedisConnection connection) { + connection.flushDb(); + connection.scriptFlush(); + return null; + } + }); + } + + @SuppressWarnings("unchecked") + @Test + public void testExecuteLongResult() { + this.template = new StringRedisTemplate(); + template.setConnectionFactory(connFactory); + template.afterPropertiesSet(); + RedisScript script = new DefaultRedisScript(new ClassPathResource( + "org/springframework/data/redis/core/script/increment.lua"), Long.class); + ScriptExecutor scriptExecutor = new DefaultScriptExecutor(template); + Long result = scriptExecutor.execute(script, Collections.singletonList("mykey")); + assertNull(result); + template.boundValueOps("mykey").set("2"); + assertEquals(Long.valueOf(3), + scriptExecutor.execute(script, Collections.singletonList("mykey"))); + } + + @SuppressWarnings("unchecked") + @Test + public void testExecuteBooleanResult() { + this.template = new RedisTemplate(); + template.setKeySerializer(new StringRedisSerializer()); + template.setValueSerializer(new GenericToStringSerializer(Long.class)); + template.setConnectionFactory(connFactory); + template.afterPropertiesSet(); + RedisScript script = new DefaultRedisScript(new ClassPathResource( + "org/springframework/data/redis/core/script/cas.lua"), Boolean.class); + ScriptExecutor scriptExecutor = new DefaultScriptExecutor(template); + template.boundValueOps("counter").set(0l); + Boolean valueSet = scriptExecutor.execute(script, Collections.singletonList("counter"), 0, + 3); + assertTrue(valueSet); + assertFalse(scriptExecutor.execute(script, Collections.singletonList("counter"), 0, 3)); + } + + @SuppressWarnings({ "unchecked", "rawtypes" }) + @Test + public void testExecuteListResultCustomArgsSerializer() { + this.template = new StringRedisTemplate(); + template.setConnectionFactory(connFactory); + template.afterPropertiesSet(); + template.boundListOps("mylist").leftPushAll("a", "b", "c", "d"); + RedisScript> script = new DefaultRedisScript(new ClassPathResource( + "org/springframework/data/redis/core/script/bulkpop.lua"), List.class); + ScriptExecutor scriptExecutor = new DefaultScriptExecutor(template); + List result = scriptExecutor + .execute(script, new GenericToStringSerializer(Long.class), + template.getValueSerializer(), Collections.singletonList("mylist"), 1l); + assertEquals(Collections.singletonList("a"), result); + } + + @SuppressWarnings({ "unchecked", "rawtypes" }) + @Test + public void testExecuteMixedListResult() { + this.template = new StringRedisTemplate(); + template.setConnectionFactory(connFactory); + template.afterPropertiesSet(); + RedisScript> script = new DefaultRedisScript(new ClassPathResource( + "org/springframework/data/redis/core/script/popandlength.lua"), List.class); + ScriptExecutor scriptExecutor = new DefaultScriptExecutor(template); + List results = scriptExecutor.execute(script, Collections.singletonList("mylist")); + assertEquals(Arrays.asList(new Object[] { null, 0l }), results); + template.boundListOps("mylist").leftPushAll("a", "b"); + assertEquals(Arrays.asList(new Object[] { "a", 1l }), + scriptExecutor.execute(script, Collections.singletonList("mylist"))); + } + + @SuppressWarnings("unchecked") + @Test + public void testExecuteValueResult() { + this.template = new StringRedisTemplate(); + template.setConnectionFactory(connFactory); + template.afterPropertiesSet(); + RedisScript script = new DefaultRedisScript(new StaticScriptSource( + "return redis.call('GET',KEYS[1])"), String.class); + template.opsForValue().set("foo", "bar"); + ScriptExecutor scriptExecutor = new DefaultScriptExecutor(template); + assertEquals("bar", scriptExecutor.execute(script, Collections.singletonList("foo"))); + } + + @SuppressWarnings({ "unchecked", "rawtypes" }) + @Test + public void testExecuteStatusResult() { + this.template = new RedisTemplate(); + template.setKeySerializer(new StringRedisSerializer()); + template.setValueSerializer(new GenericToStringSerializer(Long.class)); + template.setConnectionFactory(connFactory); + template.afterPropertiesSet(); + RedisScript script = new DefaultRedisScript(new StaticScriptSource( + "return redis.call('SET',KEYS[1], ARGV[1])"), null); + ScriptExecutor scriptExecutor = new DefaultScriptExecutor(template); + assertNull(scriptExecutor.execute(script, Collections.singletonList("foo"), 3l)); + assertEquals(Long.valueOf(3), template.opsForValue().get("foo")); + } + + @SuppressWarnings("unchecked") + @Test + public void testExecuteCustomResultSerializer() { + JacksonJsonRedisSerializer personSerializer = new JacksonJsonRedisSerializer( + Person.class); + this.template = new RedisTemplate(); + template.setKeySerializer(new StringRedisSerializer()); + template.setValueSerializer(personSerializer); + template.setConnectionFactory(connFactory); + template.afterPropertiesSet(); + RedisScript script = new DefaultRedisScript(new StaticScriptSource( + "redis.call('SET',KEYS[1], ARGV[1])\nreturn 'FOO'"), String.class); + ScriptExecutor scriptExecutor = new DefaultScriptExecutor(template); + Person joe = new Person("Joe", "Schmoe", 23); + String result = scriptExecutor.execute(script, personSerializer, + new StringRedisSerializer(), Collections.singletonList("bar"), joe); + assertEquals("FOO", result); + assertEquals(joe, template.boundValueOps("bar").get()); + } + + @SuppressWarnings("unchecked") + @Test + public void testExecutePipelined() { + this.template = new StringRedisTemplate(); + template.setConnectionFactory(connFactory); + template.afterPropertiesSet(); + final RedisScript script = new DefaultRedisScript(new StaticScriptSource( + "return KEYS[1]"), String.class); + List results = template.executePipelined(new SessionCallback() { + @SuppressWarnings("rawtypes") + public String execute(RedisOperations operations) throws DataAccessException { + return (String) operations.execute(script, Collections.singletonList("foo")); + } + + }); + // Result is deserialized by RedisTemplate as part of executePipelined + assertEquals(Collections.singletonList("foo"), results); + } + + @SuppressWarnings("unchecked") + @Test + public void testExecuteTx() { + this.template = new StringRedisTemplate(); + template.setConnectionFactory(connFactory); + template.afterPropertiesSet(); + final RedisScript script = new DefaultRedisScript(new StaticScriptSource( + "return 'bar'..KEYS[1]"), String.class); + List results = (List) template.execute(new SessionCallback>() { + @SuppressWarnings("rawtypes") + public List execute(RedisOperations operations) throws DataAccessException { + operations.multi(); + operations.execute(script, Collections.singletonList("foo")); + return operations.exec(); + } + + }); + // Result is deserialized by RedisTemplate as part of exec + assertEquals(Collections.singletonList("barfoo"), results); + } + + @SuppressWarnings("unchecked") + @Test + public void testExecuteCachedNullKeys() { + this.template = new StringRedisTemplate(); + template.setConnectionFactory(connFactory); + template.afterPropertiesSet(); + final RedisScript script = new DefaultRedisScript(new StaticScriptSource( + "return 'HELLO'"), String.class); + ScriptExecutor scriptExecutor = new DefaultScriptExecutor(template); + // Execute script twice, second time should be from cache + assertEquals("HELLO", scriptExecutor.execute(script, null)); + assertEquals("HELLO", scriptExecutor.execute(script, null)); + } +} diff --git a/src/test/resources/org/springframework/data/redis/core/script/DefaultScriptExecutorTests-context.xml b/src/test/resources/org/springframework/data/redis/core/script/DefaultScriptExecutorTests-context.xml new file mode 100644 index 000000000..75da6c57d --- /dev/null +++ b/src/test/resources/org/springframework/data/redis/core/script/DefaultScriptExecutorTests-context.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/test/resources/org/springframework/data/redis/core/script/bulkpop.lua b/src/test/resources/org/springframework/data/redis/core/script/bulkpop.lua new file mode 100644 index 000000000..13f523e95 --- /dev/null +++ b/src/test/resources/org/springframework/data/redis/core/script/bulkpop.lua @@ -0,0 +1,7 @@ +local size=redis.call('LLEN',KEYS[1]) +local max = math.min(size,ARGV[1]) +local t={} +for variable = 1, max, 1 do + table.insert(t,redis.call('RPOP',KEYS[1])) +end +return t \ No newline at end of file diff --git a/src/test/resources/org/springframework/data/redis/core/script/cas.lua b/src/test/resources/org/springframework/data/redis/core/script/cas.lua new file mode 100644 index 000000000..1bcf32d82 --- /dev/null +++ b/src/test/resources/org/springframework/data/redis/core/script/cas.lua @@ -0,0 +1,7 @@ +local current = redis.call('GET', KEYS[1]) +if current == ARGV[1] +then + redis.call('SET', KEYS[1], ARGV[2]) + return true +end +return false \ No newline at end of file diff --git a/src/test/resources/org/springframework/data/redis/core/script/increment.lua b/src/test/resources/org/springframework/data/redis/core/script/increment.lua new file mode 100644 index 000000000..8d5473bb3 --- /dev/null +++ b/src/test/resources/org/springframework/data/redis/core/script/increment.lua @@ -0,0 +1,5 @@ +if redis.call("EXISTS",KEYS[1]) == 1 then + return redis.call("INCR",KEYS[1]) +else + return nil +end \ No newline at end of file diff --git a/src/test/resources/org/springframework/data/redis/core/script/popandlength.lua b/src/test/resources/org/springframework/data/redis/core/script/popandlength.lua new file mode 100644 index 000000000..24d33ef1d --- /dev/null +++ b/src/test/resources/org/springframework/data/redis/core/script/popandlength.lua @@ -0,0 +1,4 @@ +local t={} +table.insert(t,redis.call('RPOP',KEYS[1])) +table.insert(t,redis.call('LLEN',KEYS[1])) +return t \ No newline at end of file diff --git a/template.mf b/template.mf index cb7b75119..01a5766f7 100644 --- a/template.mf +++ b/template.mf @@ -11,6 +11,7 @@ Import-Template: org.springframework.core.*;version="[3.1.0, 4.0.0)", org.springframework.dao.*;version="[3.1.0, 4.0.0)", org.springframework.scheduling.*;resolution:="optional";version="[3.1.0, 4.0.0)", + org.springframework.scripting.*;resolution:="optional";version="[3.1.0, 4.0.0)", org.springframework.util.*;version="[3.1.0, 4.0.0)", org.springframework.oxm.*;resolution:="optional";version="[3.1.0, 4.0.0)", org.springframework.transaction.support.*;version="[3.1.0, 4.0.0)",