diff --git a/.github/workflows/install-redis-modules/action.yml b/.github/workflows/install-redis-modules/action.yml deleted file mode 100644 index e4e9c9453a..0000000000 --- a/.github/workflows/install-redis-modules/action.yml +++ /dev/null @@ -1,69 +0,0 @@ -name: Install Redis Modules - -inputs: - redis-version: - description: "redis version of clusters" - required: true - type: string - - modules: - description: "required redis modules to install" - required: false - type: string - default: 'all' - options: - - "all" - - "search" - - "json" - - - -runs: - using: "composite" - steps: - - name: Cache RedisJSON Dependencies - if: inputs.modules == 'all' || inputs.modules == 'json' - id: cache-dependencies-redisjson - uses: actions/cache@v3 - with: - path: | - ./cmake - ./redisjson/bin - key: ${{ runner.os }}-${{ inputs.redis-version }}-redisjson - - - - name: Install CMake - if: steps.cache-dependencies-redisearch.outputs.cache-hit != 'true' || steps.cache-dependencies-redisjson.outputs.cache-hit != 'true' - shell: bash - run: | - set -x - sudo apt-get update - sudo apt-get install -y cmake - cp /usr/bin/cmake ./cmake - - - - name: Checkout RedisJSON Repository - if: steps.cache-dependencies-redisjson.outputs.cache-hit != 'true' && (inputs.modules == 'all' || inputs.modules == 'json') - uses: actions/checkout@v4 - with: - repository: "RedisJSON/RedisJSON" - path: "./redisjson" - ref: ${{ startsWith(inputs.redis-version, '6') && 'v2.6.0' || '' }} - submodules: recursive - - - name: Build RedisJSON - if: steps.cache-dependencies-redisjson.outputs.cache-hit != 'true' && (inputs.modules == 'all' || inputs.modules == 'json') - shell: bash - working-directory: ./redisjson - run: | - set -x - echo "Building RedisJSON..." - make - - - name: Copy redisjson.so - if: inputs.modules == 'all' || inputs.modules == 'json' - shell: bash - run: | - set -x - echo "Copying RedisJSON..." - cp $GITHUB_WORKSPACE/redisjson/bin/linux-x64-release/rejson.so $GITHUB_WORKSPACE/redisjson.so diff --git a/.github/workflows/node.yml b/.github/workflows/node.yml index 4394951c67..62bab729ea 100644 --- a/.github/workflows/node.yml +++ b/.github/workflows/node.yml @@ -61,11 +61,6 @@ jobs: target: "x86_64-unknown-linux-gnu" github-token: ${{ secrets.GITHUB_TOKEN }} - - name: Install Redis Modules - uses: ./.github/workflows/install-redis-modules - with: - redis-version: ${{ matrix.redis }} - - name: test run: npm test working-directory: ./node @@ -83,10 +78,6 @@ jobs: npm ci npm run build-and-test working-directory: ./node/hybrid-node-tests/ecmascript-test - - - name: test redis modules - run: npm run test-modules -- --load-module=$GITHUB_WORKSPACE/redisjson.so - working-directory: ./node - uses: ./.github/workflows/test-benchmark with: diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index a5a6cd31d5..915937a5f9 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -99,17 +99,12 @@ jobs: pip install -r ../benchmarks/python/requirements.txt python -m mypy .. - - name: Install Redis Modules - uses: ./.github/workflows/install-redis-modules - with: - redis-version: ${{ matrix.redis }} - - name: Test with pytest working-directory: ./python run: | source .env/bin/activate cd python/tests/ - pytest --asyncio-mode=auto --override-ini=addopts= --load-module=$GITHUB_WORKSPACE/redisjson.so + pytest --asyncio-mode=auto - uses: ./.github/workflows/test-benchmark with: diff --git a/CHANGELOG.md b/CHANGELOG.md index c4d6749293..3c0c9fdeb1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ * Python: Added GETDEL command ([#1514](https://github.com/aws/glide-for-redis/pull/1514)) * Python: Added ZINTER, ZUNION commands ([#1478](https://github.com/aws/glide-for-redis/pull/1478)) * Python: Added SINTERCARD command ([#1511](https://github.com/aws/glide-for-redis/pull/1511)) +* Python: Added SORT command ([#1439](https://github.com/aws/glide-for-redis/pull/1439)) ### Breaking Changes * Node: Update XREAD to return a Map of Map ([#1494](https://github.com/aws/glide-for-redis/pull/1494)) diff --git a/glide-core/src/client/value_conversion.rs b/glide-core/src/client/value_conversion.rs index f8b0a5b48d..fd17f31b66 100644 --- a/glide-core/src/client/value_conversion.rs +++ b/glide-core/src/client/value_conversion.rs @@ -25,6 +25,8 @@ pub(crate) enum ExpectedReturnType<'a> { Lolwut, ArrayOfStringAndArrays, ArrayOfArraysOfDoubleOrNull, + ArrayOfMaps(&'a ExpectedReturnType<'a>), + StringOrSet, ArrayOfPairs, ArrayOfMemberScorePairs, ZMPopReturnType, @@ -279,24 +281,11 @@ pub(crate) fn convert_to_expected_type( ExpectedReturnType::Lolwut => { match value { // cluster (multi-node) response - go recursive - Value::Map(map) => { - let result = map - .into_iter() - .map(|(key, inner_value)| { - let converted_key = convert_to_expected_type( - key, - Some(ExpectedReturnType::BulkString), - )?; - let converted_value = convert_to_expected_type( - inner_value, - Some(ExpectedReturnType::Lolwut), - )?; - Ok((converted_key, converted_value)) - }) - .collect::>(); - - result.map(Value::Map) - } + Value::Map(map) => convert_map_entries( + map, + Some(ExpectedReturnType::BulkString), + Some(ExpectedReturnType::Lolwut), + ), // RESP 2 response Value::BulkString(bytes) => { let text = std::str::from_utf8(&bytes).unwrap(); @@ -383,9 +372,98 @@ pub(crate) fn convert_to_expected_type( ) .into()), }, + // `FUNCTION LIST` returns an array of maps with nested list of maps. + // In RESP2 these maps are represented by arrays - we're going to convert them. + /* RESP2 response + 1) 1) "library_name" + 2) "mylib1" + 3) "engine" + 4) "LUA" + 5) "functions" + 6) 1) 1) "name" + 2) "myfunc1" + 3) "description" + 4) (nil) + 5) "flags" + 6) (empty array) + 2) 1) "name" + ... + 2) 1) "library_name" + ... + + RESP3 response + 1) 1# "library_name" => "mylib1" + 2# "engine" => "LUA" + 3# "functions" => + 1) 1# "name" => "myfunc1" + 2# "description" => (nil) + 3# "flags" => (empty set) + 2) 1# "name" => "myfunc2" + ... + 2) 1# "library_name" => "mylib2" + ... + */ + ExpectedReturnType::ArrayOfMaps(type_of_map_values) => match value { + // empty array, or it is already contains a map (RESP3 response) - no conversion needed + Value::Array(ref array) if array.is_empty() || matches!(array[0], Value::Map(_)) => { + Ok(value) + } + Value::Array(array) => convert_array_of_flat_maps(array, Some(*type_of_map_values)), + // cluster (multi-node) response - go recursive + Value::Map(map) => convert_map_entries( + map, + Some(ExpectedReturnType::BulkString), + Some(ExpectedReturnType::ArrayOfMaps(type_of_map_values)), + ), + // Due to recursion, this will convert every map value, including simple strings, which we do nothing with + Value::BulkString(_) | Value::SimpleString(_) | Value::VerbatimString { .. } => { + Ok(value) + } + _ => Err(( + ErrorKind::TypeError, + "Response couldn't be converted", + format!("(response was {:?})", get_value_type(&value)), + ) + .into()), + }, + // Not used for a command, but used as a helper for `FUNCTION LIST` to process the inner map. + // It may contain a string (name, description) or set (flags), or nil (description). + // The set is stored as array in RESP2. See example for `ArrayOfMaps` above. + ExpectedReturnType::StringOrSet => match value { + Value::Array(_) => convert_to_expected_type(value, Some(ExpectedReturnType::Set)), + Value::Nil + | Value::BulkString(_) + | Value::SimpleString(_) + | Value::VerbatimString { .. } => Ok(value), + _ => Err(( + ErrorKind::TypeError, + "Response couldn't be converted", + format!("(response was {:?})", get_value_type(&value)), + ) + .into()), + }, } } +/// Similar to [`convert_array_to_map_by_type`], but converts keys and values to the given types inside the map. +/// The input data is [`Value::Map`] payload, the output is the new [`Value::Map`]. +fn convert_map_entries( + map: Vec<(Value, Value)>, + key_type: Option, + value_type: Option, +) -> RedisResult { + let result = map + .into_iter() + .map(|(key, inner_value)| { + let converted_key = convert_to_expected_type(key, key_type)?; + let converted_value = convert_to_expected_type(inner_value, value_type)?; + Ok((converted_key, converted_value)) + }) + .collect::>(); + + result.map(Value::Map) +} + /// Convert string returned by `LOLWUT` command. /// The input string is shell-friendly and contains color codes and escape sequences. /// The output string is user-friendly, colored whitespaces replaced with corresponding symbols. @@ -415,6 +493,48 @@ fn convert_array_elements( Ok(Value::Array(converted_array)) } +/// Converts an array of flat maps into an array of maps. +/// Input: +/// ```text +/// 1) 1) "map 1 key 1" +/// 2) "map 1 value 1" +/// 3) "map 1 key 2" +/// 4) "map 1 value 2" +/// ... +/// 2) 1) "map 2 key 1" +/// 2) "map 2 value 1" +/// ... +/// ``` +/// Output: +/// ```text +/// 1) 1# "map 1 key 1" => "map 1 value 1" +/// 2# "map 1 key 2" => "map 1 value 2" +/// ... +/// 2) 1# "map 2 key 1" => "map 2 value 1" +/// ... +/// ``` +/// +/// `array` is an array of arrays, where each inner array represents data for a map. The inner arrays contain map keys at even-positioned elements and map values at odd-positioned elements. +/// `value_expected_return_type` is the desired type for the map values. +fn convert_array_of_flat_maps( + array: Vec, + value_expected_return_type: Option, +) -> RedisResult { + let mut result: Vec = Vec::with_capacity(array.len()); + for entry in array { + let Value::Array(entry_as_array) = entry else { + return Err((ErrorKind::TypeError, "Incorrect value type received").into()); + }; + let map = convert_array_to_map_by_type( + entry_as_array, + Some(ExpectedReturnType::BulkString), + value_expected_return_type, + )?; + result.push(map); + } + Ok(Value::Array(result)) +} + /// Converts key-value elements in a given map using the specified types. /// /// `map` A vector of key-values. @@ -615,6 +735,9 @@ pub(crate) fn expected_type_for_cmd(cmd: &Cmd) -> Option { } } b"LOLWUT" => Some(ExpectedReturnType::Lolwut), + b"FUNCTION LIST" => Some(ExpectedReturnType::ArrayOfMaps( + &ExpectedReturnType::ArrayOfMaps(&ExpectedReturnType::StringOrSet), + )), _ => None, } } @@ -644,6 +767,162 @@ pub(crate) fn get_value_type<'a>(value: &Value) -> &'a str { mod tests { use super::*; + #[test] + fn convert_function_list() { + let command = &mut redis::cmd("FUNCTION"); + command.arg("LIST"); + let expected_type = expected_type_for_cmd(command); + + assert!(matches!( + expected_type, + Some(ExpectedReturnType::ArrayOfMaps(_)) + )); + + let resp2_response = Value::Array(vec![ + Value::Array(vec![ + Value::BulkString("library_name".to_string().into_bytes()), + Value::BulkString("mylib1".to_string().into_bytes()), + Value::BulkString("engine".to_string().into_bytes()), + Value::BulkString("LUA".to_string().into_bytes()), + Value::BulkString("functions".to_string().into_bytes()), + Value::Array(vec![ + Value::Array(vec![ + Value::BulkString("name".to_string().into_bytes()), + Value::BulkString("myfunc1".to_string().into_bytes()), + Value::BulkString("description".to_string().into_bytes()), + Value::Nil, + Value::BulkString("flags".to_string().into_bytes()), + Value::Array(vec![ + Value::BulkString("read".to_string().into_bytes()), + Value::BulkString("write".to_string().into_bytes()), + ]), + ]), + Value::Array(vec![ + Value::BulkString("name".to_string().into_bytes()), + Value::BulkString("myfunc2".to_string().into_bytes()), + Value::BulkString("description".to_string().into_bytes()), + Value::BulkString("blahblah".to_string().into_bytes()), + Value::BulkString("flags".to_string().into_bytes()), + Value::Array(vec![]), + ]), + ]), + ]), + Value::Array(vec![ + Value::BulkString("library_name".to_string().into_bytes()), + Value::BulkString("mylib2".to_string().into_bytes()), + Value::BulkString("engine".to_string().into_bytes()), + Value::BulkString("LUA".to_string().into_bytes()), + Value::BulkString("functions".to_string().into_bytes()), + Value::Array(vec![]), + Value::BulkString("library_code".to_string().into_bytes()), + Value::BulkString("".to_string().into_bytes()), + ]), + ]); + + let resp3_response = Value::Array(vec![ + Value::Map(vec![ + ( + Value::BulkString("library_name".to_string().into_bytes()), + Value::BulkString("mylib1".to_string().into_bytes()), + ), + ( + Value::BulkString("engine".to_string().into_bytes()), + Value::BulkString("LUA".to_string().into_bytes()), + ), + ( + Value::BulkString("functions".to_string().into_bytes()), + Value::Array(vec![ + Value::Map(vec![ + ( + Value::BulkString("name".to_string().into_bytes()), + Value::BulkString("myfunc1".to_string().into_bytes()), + ), + ( + Value::BulkString("description".to_string().into_bytes()), + Value::Nil, + ), + ( + Value::BulkString("flags".to_string().into_bytes()), + Value::Set(vec![ + Value::BulkString("read".to_string().into_bytes()), + Value::BulkString("write".to_string().into_bytes()), + ]), + ), + ]), + Value::Map(vec![ + ( + Value::BulkString("name".to_string().into_bytes()), + Value::BulkString("myfunc2".to_string().into_bytes()), + ), + ( + Value::BulkString("description".to_string().into_bytes()), + Value::BulkString("blahblah".to_string().into_bytes()), + ), + ( + Value::BulkString("flags".to_string().into_bytes()), + Value::Set(vec![]), + ), + ]), + ]), + ), + ]), + Value::Map(vec![ + ( + Value::BulkString("library_name".to_string().into_bytes()), + Value::BulkString("mylib2".to_string().into_bytes()), + ), + ( + Value::BulkString("engine".to_string().into_bytes()), + Value::BulkString("LUA".to_string().into_bytes()), + ), + ( + Value::BulkString("functions".to_string().into_bytes()), + Value::Array(vec![]), + ), + ( + Value::BulkString("library_code".to_string().into_bytes()), + Value::BulkString("".to_string().into_bytes()), + ), + ]), + ]); + + let resp2_cluster_response = Value::Map(vec![ + (Value::BulkString("node1".into()), resp2_response.clone()), + (Value::BulkString("node2".into()), resp2_response.clone()), + (Value::BulkString("node3".into()), resp2_response.clone()), + ]); + + let resp3_cluster_response = Value::Map(vec![ + (Value::BulkString("node1".into()), resp3_response.clone()), + (Value::BulkString("node2".into()), resp3_response.clone()), + (Value::BulkString("node3".into()), resp3_response.clone()), + ]); + + // convert RESP2 -> RESP3 + assert_eq!( + convert_to_expected_type(resp2_response.clone(), expected_type).unwrap(), + resp3_response.clone() + ); + + // convert RESP3 -> RESP3 + assert_eq!( + convert_to_expected_type(resp3_response.clone(), expected_type).unwrap(), + resp3_response.clone() + ); + + // convert cluster RESP2 -> RESP3 + assert_eq!( + convert_to_expected_type(resp2_cluster_response.clone(), expected_type).unwrap(), + resp3_cluster_response.clone() + ); + + // convert cluster RESP3 -> RESP3 + assert_eq!( + convert_to_expected_type(resp3_cluster_response.clone(), expected_type).unwrap(), + resp3_cluster_response.clone() + ); + } + #[test] fn convert_lolwut() { assert!(matches!( diff --git a/glide-core/src/protobuf/redis_request.proto b/glide-core/src/protobuf/redis_request.proto index d77274e3cf..8ac9e38f0b 100644 --- a/glide-core/src/protobuf/redis_request.proto +++ b/glide-core/src/protobuf/redis_request.proto @@ -190,11 +190,13 @@ enum RequestType { BitOp = 148; HStrlen = 149; FunctionLoad = 150; + FunctionList = 151; LMPop = 155; ExpireTime = 156; PExpireTime = 157; BLMPop = 158; XLen = 159; + Sort = 160; LSet = 165; XDel = 166; XRange = 167; diff --git a/glide-core/src/request_type.rs b/glide-core/src/request_type.rs index d3c8a7ca49..ac864dbdfa 100644 --- a/glide-core/src/request_type.rs +++ b/glide-core/src/request_type.rs @@ -160,11 +160,13 @@ pub enum RequestType { BitOp = 148, HStrlen = 149, FunctionLoad = 150, + FunctionList = 151, LMPop = 155, ExpireTime = 156, PExpireTime = 157, BLMPop = 158, XLen = 159, + Sort = 160, LSet = 165, XDel = 166, XRange = 167, @@ -339,6 +341,7 @@ impl From<::protobuf::EnumOrUnknown> for RequestType { ProtobufRequestType::GetBit => RequestType::GetBit, ProtobufRequestType::ZInter => RequestType::ZInter, ProtobufRequestType::FunctionLoad => RequestType::FunctionLoad, + ProtobufRequestType::FunctionList => RequestType::FunctionList, ProtobufRequestType::BitPos => RequestType::BitPos, ProtobufRequestType::BitOp => RequestType::BitOp, ProtobufRequestType::HStrlen => RequestType::HStrlen, @@ -356,6 +359,7 @@ impl From<::protobuf::EnumOrUnknown> for RequestType { ProtobufRequestType::BitFieldReadOnly => RequestType::BitFieldReadOnly, ProtobufRequestType::Move => RequestType::Move, ProtobufRequestType::SInterCard => RequestType::SInterCard, + ProtobufRequestType::Sort => RequestType::Sort, ProtobufRequestType::XRevRange => RequestType::XRevRange, } } @@ -515,6 +519,7 @@ impl RequestType { RequestType::GetBit => Some(cmd("GETBIT")), RequestType::ZInter => Some(cmd("ZINTER")), RequestType::FunctionLoad => Some(get_two_word_command("FUNCTION", "LOAD")), + RequestType::FunctionList => Some(get_two_word_command("FUNCTION", "LIST")), RequestType::BitPos => Some(cmd("BITPOS")), RequestType::BitOp => Some(cmd("BITOP")), RequestType::HStrlen => Some(cmd("HSTRLEN")), @@ -532,6 +537,7 @@ impl RequestType { RequestType::BitFieldReadOnly => Some(cmd("BITFIELD_RO")), RequestType::Move => Some(cmd("MOVE")), RequestType::SInterCard => Some(cmd("SINTERCARD")), + RequestType::Sort => Some(cmd("SORT")), RequestType::XRevRange => Some(cmd("XREVRANGE")), } } diff --git a/java/client/src/main/java/glide/api/BaseClient.java b/java/client/src/main/java/glide/api/BaseClient.java index 1a773b47de..16cd0e6ef0 100644 --- a/java/client/src/main/java/glide/api/BaseClient.java +++ b/java/client/src/main/java/glide/api/BaseClient.java @@ -99,6 +99,7 @@ import static redis_request.RedisRequestOuterClass.RequestType.SMIsMember; import static redis_request.RedisRequestOuterClass.RequestType.SMembers; import static redis_request.RedisRequestOuterClass.RequestType.SMove; +import static redis_request.RedisRequestOuterClass.RequestType.SPop; import static redis_request.RedisRequestOuterClass.RequestType.SRandMember; import static redis_request.RedisRequestOuterClass.RequestType.SRem; import static redis_request.RedisRequestOuterClass.RequestType.SUnionStore; @@ -187,6 +188,8 @@ import glide.managers.BaseCommandResponseResolver; import glide.managers.CommandManager; import glide.managers.ConnectionManager; +import java.util.Arrays; +import java.util.EnumSet; import java.util.List; import java.util.Map; import java.util.Set; @@ -245,7 +248,8 @@ protected static CompletableFuture CreateClient( .connectToRedis(config) .thenApply(ignore -> constructor.apply(connectionManager, commandManager)); } catch (InterruptedException e) { - // Something bad happened while we were establishing netty connection to UDS + // Something bad happened while we were establishing netty connection to + // UDS var future = new CompletableFuture(); future.completeExceptionally(e); return future; @@ -264,7 +268,8 @@ public void close() throws ExecutionException { try { connectionManager.closeConnection().get(); } catch (InterruptedException e) { - // suppressing the interrupted exception - it is already suppressed in the future + // suppressing the interrupted exception - it is already suppressed in the + // future throw new RuntimeException(e); } } @@ -296,10 +301,15 @@ protected static CommandManager buildCommandManager(ChannelHandler channelHandle * @throws RedisException On a type mismatch. */ @SuppressWarnings("unchecked") - protected T handleRedisResponse(Class classType, boolean isNullable, Response response) - throws RedisException { + protected T handleRedisResponse( + Class classType, EnumSet flags, Response response) throws RedisException { + boolean encodingUtf8 = flags.contains(ResponseFlags.ENCODING_UTF8); + boolean isNullable = flags.contains(ResponseFlags.IS_NULLABLE); Object value = - new BaseCommandResponseResolver(RedisValueResolver::valueFromPointer).apply(response); + encodingUtf8 + ? new BaseCommandResponseResolver(RedisValueResolver::valueFromPointer).apply(response) + : new BaseCommandResponseResolver(RedisValueResolver::valueFromPointerBinary) + .apply(response); if (isNullable && (value == null)) { return null; } @@ -315,43 +325,52 @@ protected T handleRedisResponse(Class classType, boolean isNullable, Resp } protected Object handleObjectOrNullResponse(Response response) throws RedisException { - return handleRedisResponse(Object.class, true, response); + return handleRedisResponse( + Object.class, EnumSet.of(ResponseFlags.IS_NULLABLE, ResponseFlags.ENCODING_UTF8), response); } protected String handleStringResponse(Response response) throws RedisException { - return handleRedisResponse(String.class, false, response); + return handleRedisResponse(String.class, EnumSet.of(ResponseFlags.ENCODING_UTF8), response); } protected String handleStringOrNullResponse(Response response) throws RedisException { - return handleRedisResponse(String.class, true, response); + return handleRedisResponse( + String.class, EnumSet.of(ResponseFlags.IS_NULLABLE, ResponseFlags.ENCODING_UTF8), response); + } + + protected byte[] handleBytesOrNullResponse(Response response) throws RedisException { + return handleRedisResponse(byte[].class, EnumSet.of(ResponseFlags.IS_NULLABLE), response); } protected Boolean handleBooleanResponse(Response response) throws RedisException { - return handleRedisResponse(Boolean.class, false, response); + return handleRedisResponse(Boolean.class, EnumSet.noneOf(ResponseFlags.class), response); } protected Long handleLongResponse(Response response) throws RedisException { - return handleRedisResponse(Long.class, false, response); + return handleRedisResponse(Long.class, EnumSet.noneOf(ResponseFlags.class), response); } protected Long handleLongOrNullResponse(Response response) throws RedisException { - return handleRedisResponse(Long.class, true, response); + return handleRedisResponse(Long.class, EnumSet.of(ResponseFlags.IS_NULLABLE), response); } protected Double handleDoubleResponse(Response response) throws RedisException { - return handleRedisResponse(Double.class, false, response); + return handleRedisResponse(Double.class, EnumSet.noneOf(ResponseFlags.class), response); } protected Double handleDoubleOrNullResponse(Response response) throws RedisException { - return handleRedisResponse(Double.class, true, response); + return handleRedisResponse(Double.class, EnumSet.of(ResponseFlags.IS_NULLABLE), response); } protected Object[] handleArrayResponse(Response response) throws RedisException { - return handleRedisResponse(Object[].class, false, response); + return handleRedisResponse(Object[].class, EnumSet.of(ResponseFlags.ENCODING_UTF8), response); } protected Object[] handleArrayOrNullResponse(Response response) throws RedisException { - return handleRedisResponse(Object[].class, true, response); + return handleRedisResponse( + Object[].class, + EnumSet.of(ResponseFlags.IS_NULLABLE, ResponseFlags.ENCODING_UTF8), + response); } /** @@ -361,7 +380,7 @@ protected Object[] handleArrayOrNullResponse(Response response) throws RedisExce */ @SuppressWarnings("unchecked") // raw Map cast to Map protected Map handleMapResponse(Response response) throws RedisException { - return handleRedisResponse(Map.class, false, response); + return handleRedisResponse(Map.class, EnumSet.of(ResponseFlags.ENCODING_UTF8), response); } /** @@ -371,12 +390,25 @@ protected Map handleMapResponse(Response response) throws RedisEx */ @SuppressWarnings("unchecked") // raw Map cast to Map protected Map handleMapOrNullResponse(Response response) throws RedisException { - return handleRedisResponse(Map.class, true, response); + return handleRedisResponse( + Map.class, EnumSet.of(ResponseFlags.IS_NULLABLE, ResponseFlags.ENCODING_UTF8), response); } @SuppressWarnings("unchecked") // raw Set cast to Set protected Set handleSetResponse(Response response) throws RedisException { - return handleRedisResponse(Set.class, false, response); + return handleRedisResponse(Set.class, EnumSet.of(ResponseFlags.ENCODING_UTF8), response); + } + + /** Process a FUNCTION LIST standalone response. */ + @SuppressWarnings("unchecked") + protected Map[] handleFunctionListResponse(Object[] response) { + Map[] data = castArray(response, Map.class); + for (Map libraryInfo : data) { + Object[] functions = (Object[]) libraryInfo.get("functions"); + var functionInfo = castArray(functions, Map.class); + libraryInfo.put("functions", functionInfo); + } + return data; } @Override @@ -390,12 +422,24 @@ public CompletableFuture get(@NonNull String key) { Get, new String[] {key}, this::handleStringOrNullResponse); } + @Override + public CompletableFuture get(@NonNull byte[] key) { + return commandManager.submitNewCommand( + Get, Arrays.asList(key), this::handleBytesOrNullResponse); + } + @Override public CompletableFuture getdel(@NonNull String key) { return commandManager.submitNewCommand( GetDel, new String[] {key}, this::handleStringOrNullResponse); } + @Override + public CompletableFuture set(@NonNull byte[] key, @NonNull byte[] value) { + return commandManager.submitNewCommand( + Set, Arrays.asList(key, value), this::handleStringResponse); + } + @Override public CompletableFuture set(@NonNull String key, @NonNull String value) { return commandManager.submitNewCommand( @@ -1665,6 +1709,18 @@ public CompletableFuture srandmember(@NonNull String key, long count) SRandMember, arguments, response -> castArray(handleArrayResponse(response), String.class)); } + @Override + public CompletableFuture spop(@NonNull String key) { + String[] arguments = new String[] {key}; + return commandManager.submitNewCommand(SPop, arguments, this::handleStringOrNullResponse); + } + + @Override + public CompletableFuture> spopCount(@NonNull String key, long count) { + String[] arguments = new String[] {key, Long.toString(count)}; + return commandManager.submitNewCommand(SPop, arguments, this::handleSetResponse); + } + @Override public CompletableFuture bitfield( @NonNull String key, @NonNull BitFieldSubCommands[] subCommands) { diff --git a/java/client/src/main/java/glide/api/RedisClient.java b/java/client/src/main/java/glide/api/RedisClient.java index e090dcd2e4..8a77bbc9e6 100644 --- a/java/client/src/main/java/glide/api/RedisClient.java +++ b/java/client/src/main/java/glide/api/RedisClient.java @@ -1,6 +1,8 @@ /** Copyright GLIDE-for-Redis Project Contributors - SPDX Identifier: Apache-2.0 */ package glide.api; +import static glide.api.models.commands.function.FunctionListOptions.LIBRARY_NAME_REDIS_API; +import static glide.api.models.commands.function.FunctionListOptions.WITH_CODE_REDIS_API; import static glide.api.models.commands.function.FunctionLoadOptions.REPLACE; import static glide.utils.ArrayTransformUtils.castArray; import static glide.utils.ArrayTransformUtils.concatenateArrays; @@ -14,6 +16,7 @@ import static redis_request.RedisRequestOuterClass.RequestType.CustomCommand; import static redis_request.RedisRequestOuterClass.RequestType.Echo; import static redis_request.RedisRequestOuterClass.RequestType.FlushAll; +import static redis_request.RedisRequestOuterClass.RequestType.FunctionList; import static redis_request.RedisRequestOuterClass.RequestType.FunctionLoad; import static redis_request.RedisRequestOuterClass.RequestType.Info; import static redis_request.RedisRequestOuterClass.RequestType.LastSave; @@ -203,4 +206,23 @@ public CompletableFuture move(@NonNull String key, long dbIndex) { return commandManager.submitNewCommand( Move, new String[] {key, Long.toString(dbIndex)}, this::handleBooleanResponse); } + + @Override + public CompletableFuture[]> functionList(boolean withCode) { + return commandManager.submitNewCommand( + FunctionList, + withCode ? new String[] {WITH_CODE_REDIS_API} : new String[0], + response -> handleFunctionListResponse(handleArrayResponse(response))); + } + + @Override + public CompletableFuture[]> functionList( + @NonNull String libNamePattern, boolean withCode) { + return commandManager.submitNewCommand( + FunctionList, + withCode + ? new String[] {LIBRARY_NAME_REDIS_API, libNamePattern, WITH_CODE_REDIS_API} + : new String[] {LIBRARY_NAME_REDIS_API, libNamePattern}, + response -> handleFunctionListResponse(handleArrayResponse(response))); + } } diff --git a/java/client/src/main/java/glide/api/RedisClusterClient.java b/java/client/src/main/java/glide/api/RedisClusterClient.java index f3eef93797..f758ecd8eb 100644 --- a/java/client/src/main/java/glide/api/RedisClusterClient.java +++ b/java/client/src/main/java/glide/api/RedisClusterClient.java @@ -2,6 +2,8 @@ package glide.api; import static glide.api.commands.ServerManagementCommands.VERSION_REDIS_API; +import static glide.api.models.commands.function.FunctionListOptions.LIBRARY_NAME_REDIS_API; +import static glide.api.models.commands.function.FunctionListOptions.WITH_CODE_REDIS_API; import static glide.api.models.commands.function.FunctionLoadOptions.REPLACE; import static glide.utils.ArrayTransformUtils.castArray; import static glide.utils.ArrayTransformUtils.castMapOfArrays; @@ -16,6 +18,7 @@ import static redis_request.RedisRequestOuterClass.RequestType.CustomCommand; import static redis_request.RedisRequestOuterClass.RequestType.Echo; import static redis_request.RedisRequestOuterClass.RequestType.FlushAll; +import static redis_request.RedisRequestOuterClass.RequestType.FunctionList; import static redis_request.RedisRequestOuterClass.RequestType.FunctionLoad; import static redis_request.RedisRequestOuterClass.RequestType.Info; import static redis_request.RedisRequestOuterClass.RequestType.LastSave; @@ -37,6 +40,7 @@ import glide.managers.CommandManager; import glide.managers.ConnectionManager; import java.util.Arrays; +import java.util.HashMap; import java.util.Map; import java.util.Optional; import java.util.concurrent.CompletableFuture; @@ -432,4 +436,62 @@ public CompletableFuture functionLoad( return commandManager.submitNewCommand( FunctionLoad, arguments, route, this::handleStringResponse); } + + /** Process a FUNCTION LIST cluster response. */ + protected ClusterValue[]> handleFunctionListResponse( + Response response, Route route) { + if (route instanceof SingleNodeRoute) { + Map[] data = handleFunctionListResponse(handleArrayResponse(response)); + return ClusterValue.ofSingleValue(data); + } else { + // each `Object` is a `Map[]` actually + Map info = handleMapResponse(response); + Map[]> data = new HashMap<>(); + for (var nodeInfo : info.entrySet()) { + data.put(nodeInfo.getKey(), handleFunctionListResponse((Object[]) nodeInfo.getValue())); + } + return ClusterValue.ofMultiValue(data); + } + } + + @Override + public CompletableFuture[]> functionList(boolean withCode) { + return commandManager.submitNewCommand( + FunctionList, + withCode ? new String[] {WITH_CODE_REDIS_API} : new String[0], + response -> handleFunctionListResponse(handleArrayResponse(response))); + } + + @Override + public CompletableFuture[]> functionList( + @NonNull String libNamePattern, boolean withCode) { + return commandManager.submitNewCommand( + FunctionList, + withCode + ? new String[] {LIBRARY_NAME_REDIS_API, libNamePattern, WITH_CODE_REDIS_API} + : new String[] {LIBRARY_NAME_REDIS_API, libNamePattern}, + response -> handleFunctionListResponse(handleArrayResponse(response))); + } + + @Override + public CompletableFuture[]>> functionList( + boolean withCode, @NonNull Route route) { + return commandManager.submitNewCommand( + FunctionList, + withCode ? new String[] {WITH_CODE_REDIS_API} : new String[0], + route, + response -> handleFunctionListResponse(response, route)); + } + + @Override + public CompletableFuture[]>> functionList( + @NonNull String libNamePattern, boolean withCode, @NonNull Route route) { + return commandManager.submitNewCommand( + FunctionList, + withCode + ? new String[] {LIBRARY_NAME_REDIS_API, libNamePattern, WITH_CODE_REDIS_API} + : new String[] {LIBRARY_NAME_REDIS_API, libNamePattern}, + route, + response -> handleFunctionListResponse(response, route)); + } } diff --git a/java/client/src/main/java/glide/api/ResponseFlags.java b/java/client/src/main/java/glide/api/ResponseFlags.java new file mode 100644 index 0000000000..690a9ca00a --- /dev/null +++ b/java/client/src/main/java/glide/api/ResponseFlags.java @@ -0,0 +1,9 @@ +/** Copyright GLIDE-for-Redis Project Contributors - SPDX Identifier: Apache-2.0 */ +package glide.api; + +public enum ResponseFlags { + /** Strings in the response are UTF-8 encoded */ + ENCODING_UTF8, + /** Null is a valid response */ + IS_NULLABLE, +} diff --git a/java/client/src/main/java/glide/api/commands/ScriptingAndFunctionsClusterCommands.java b/java/client/src/main/java/glide/api/commands/ScriptingAndFunctionsClusterCommands.java index 0df7077f4f..baa38ab057 100644 --- a/java/client/src/main/java/glide/api/commands/ScriptingAndFunctionsClusterCommands.java +++ b/java/client/src/main/java/glide/api/commands/ScriptingAndFunctionsClusterCommands.java @@ -1,7 +1,9 @@ /** Copyright GLIDE-for-Redis Project Contributors - SPDX Identifier: Apache-2.0 */ package glide.api.commands; +import glide.api.models.ClusterValue; import glide.api.models.configuration.RequestRoutingConfiguration.Route; +import java.util.Map; import java.util.concurrent.CompletableFuture; /** @@ -51,4 +53,118 @@ public interface ScriptingAndFunctionsClusterCommands { * } */ CompletableFuture functionLoad(String libraryCode, boolean replace, Route route); + + /** + * Returns information about the functions and libraries.
+ * The command will be routed to a random node. + * + * @since Redis 7.0 and above. + * @see redis.io for details. + * @param withCode Specifies whether to request the library code from the server or not. + * @return Info about all libraries and their functions. + * @example + *
{@code
+     * Map[] response = client.functionList(true).get();
+     * for (Map libraryInfo : response) {
+     *     System.out.printf("Server has library '%s' which runs on %s engine%n",
+     *         libraryInfo.get("library_name"), libraryInfo.get("engine"));
+     *     Map[] functions = (Map[]) libraryInfo.get("functions");
+     *     for (Map function : functions) {
+     *         Set flags = (Set) function.get("flags");
+     *         System.out.printf("Library has function '%s' with flags '%s' described as %s%n",
+     *             function.get("name"), String.join(", ", flags), function.get("description"));
+     *     }
+     *     System.out.printf("Library code:%n%s%n", libraryInfo.get("library_code"));
+     * }
+     * }
+ */ + CompletableFuture[]> functionList(boolean withCode); + + /** + * Returns information about the functions and libraries.
+ * The command will be routed to a random node. + * + * @since Redis 7.0 and above. + * @see redis.io for details. + * @param libNamePattern A wildcard pattern for matching library names. + * @param withCode Specifies whether to request the library code from the server or not. + * @return Info about queried libraries and their functions. + * @example + *
{@code
+     * Map[] response = client.functionList("myLib?_backup", true).get();
+     * for (Map libraryInfo : response) {
+     *     System.out.printf("Server has library '%s' which runs on %s engine%n",
+     *         libraryInfo.get("library_name"), libraryInfo.get("engine"));
+     *     Map[] functions = (Map[]) libraryInfo.get("functions");
+     *     for (Map function : functions) {
+     *         Set flags = (Set) function.get("flags");
+     *         System.out.printf("Library has function '%s' with flags '%s' described as %s%n",
+     *             function.get("name"), String.join(", ", flags), function.get("description"));
+     *     }
+     *     System.out.printf("Library code:%n%s%n", libraryInfo.get("library_code"));
+     * }
+     * }
+ */ + CompletableFuture[]> functionList(String libNamePattern, boolean withCode); + + /** + * Returns information about the functions and libraries. + * + * @since Redis 7.0 and above. + * @see redis.io for details. + * @param withCode Specifies whether to request the library code from the server or not. + * @param route Specifies the routing configuration for the command. The client will route the + * command to the nodes defined by route. + * @return Info about all libraries and their functions. + * @example + *
{@code
+     * ClusterValue[]> response = client.functionList(true, ALL_NODES).get();
+     * for (String node : response.getMultiValue().keySet()) {
+     *   for (Map libraryInfo : response.getMultiValue().get(node)) {
+     *     System.out.printf("Node '%s' has library '%s' which runs on %s engine%n",
+     *         node, libraryInfo.get("library_name"), libraryInfo.get("engine"));
+     *     Map[] functions = (Map[]) libraryInfo.get("functions");
+     *     for (Map function : functions) {
+     *         Set flags = (Set) function.get("flags");
+     *         System.out.printf("Library has function '%s' with flags '%s' described as %s%n",
+     *             function.get("name"), String.join(", ", flags), function.get("description"));
+     *     }
+     *     System.out.printf("Library code:%n%s%n", libraryInfo.get("library_code"));
+     *   }
+     * }
+     * }
+ */ + CompletableFuture[]>> functionList( + boolean withCode, Route route); + + /** + * Returns information about the functions and libraries. + * + * @since Redis 7.0 and above. + * @see redis.io for details. + * @param libNamePattern A wildcard pattern for matching library names. + * @param withCode Specifies whether to request the library code from the server or not. + * @param route Specifies the routing configuration for the command. The client will route the + * command to the nodes defined by route. + * @return Info about queried libraries and their functions. + * @example + *
{@code
+     * ClusterValue[]> response = client.functionList("myLib?_backup", true, ALL_NODES).get();
+     * for (String node : response.getMultiValue().keySet()) {
+     *   for (Map libraryInfo : response.getMultiValue().get(node)) {
+     *     System.out.printf("Node '%s' has library '%s' which runs on %s engine%n",
+     *         node, libraryInfo.get("library_name"), libraryInfo.get("engine"));
+     *     Map[] functions = (Map[]) libraryInfo.get("functions");
+     *     for (Map function : functions) {
+     *         Set flags = (Set) function.get("flags");
+     *         System.out.printf("Library has function '%s' with flags '%s' described as %s%n",
+     *             function.get("name"), String.join(", ", flags), function.get("description"));
+     *     }
+     *     System.out.printf("Library code:%n%s%n", libraryInfo.get("library_code"));
+     *   }
+     * }
+     * }
+ */ + CompletableFuture[]>> functionList( + String libNamePattern, boolean withCode, Route route); } diff --git a/java/client/src/main/java/glide/api/commands/ScriptingAndFunctionsCommands.java b/java/client/src/main/java/glide/api/commands/ScriptingAndFunctionsCommands.java index 3da638d998..baffdf2e21 100644 --- a/java/client/src/main/java/glide/api/commands/ScriptingAndFunctionsCommands.java +++ b/java/client/src/main/java/glide/api/commands/ScriptingAndFunctionsCommands.java @@ -1,6 +1,7 @@ /** Copyright GLIDE-for-Redis Project Contributors - SPDX Identifier: Apache-2.0 */ package glide.api.commands; +import java.util.Map; import java.util.concurrent.CompletableFuture; /** @@ -29,4 +30,55 @@ public interface ScriptingAndFunctionsCommands { * } */ CompletableFuture functionLoad(String libraryCode, boolean replace); + + /** + * Returns information about the functions and libraries. + * + * @since Redis 7.0 and above. + * @see redis.io for details. + * @param withCode Specifies whether to request the library code from the server or not. + * @return Info about all libraries and their functions. + * @example + *
{@code
+     * Map[] response = client.functionList(true).get();
+     * for (Map libraryInfo : response) {
+     *     System.out.printf("Server has library '%s' which runs on %s engine%n",
+     *         libraryInfo.get("library_name"), libraryInfo.get("engine"));
+     *     Map[] functions = (Map[]) libraryInfo.get("functions");
+     *     for (Map function : functions) {
+     *         Set flags = (Set) function.get("flags");
+     *         System.out.printf("Library has function '%s' with flags '%s' described as %s%n",
+     *             function.get("name"), String. join(", ", flags), function.get("description"));
+     *     }
+     *     System.out.printf("Library code:%n%s%n", libraryInfo.get("library_code"));
+     * }
+     * }
+ */ + CompletableFuture[]> functionList(boolean withCode); + + /** + * Returns information about the functions and libraries. + * + * @since Redis 7.0 and above. + * @see redis.io for details. + * @param libNamePattern A wildcard pattern for matching library names. + * @param withCode Specifies whether to request the library code from the server or not. + * @return Info about queried libraries and their functions. + * @example + *
{@code
+     * Map[] response = client.functionList("myLib?_backup", true).get();
+     * for (Map libraryInfo : response) {
+     *     System.out.printf("Server has library '%s' which runs on %s engine%n",
+     *         libraryInfo.get("library_name"), libraryInfo.get("engine"));
+     *     Map[] functions = (Map[]) libraryInfo.get("functions");
+     *     for (Map function : functions) {
+     *         Set flags = (Set) function.get("flags");
+     *         System.out.printf("Library has function '%s' with flags '%s' described as %s%n",
+     *             function.get("name"), String. join(", ", flags), function.get("description"));
+     *     }
+     *     System.out.printf("Library code:%n%s%n", libraryInfo.get("library_code"));
+     * }
+     * }
+ */ + CompletableFuture[]> functionList(String libNamePattern, boolean withCode); } diff --git a/java/client/src/main/java/glide/api/commands/SetBaseCommands.java b/java/client/src/main/java/glide/api/commands/SetBaseCommands.java index f4abbf623d..cb17baf6ad 100644 --- a/java/client/src/main/java/glide/api/commands/SetBaseCommands.java +++ b/java/client/src/main/java/glide/api/commands/SetBaseCommands.java @@ -304,4 +304,42 @@ public interface SetBaseCommands { * } */ CompletableFuture srandmember(String key, long count); + + /** + * Removes and returns one random member from the set stored at key. + * + * @see redis.io for details. + * @param key The key of the set. + * @return The value of the popped member.
+ * If key does not exist, null will be returned. + * @example + *
{@code
+     * String value1 = client.spop("mySet").get();
+     * assert value1.equals("value1");
+     *
+     * String value2 = client.spop("nonExistingSet").get();
+     * assert value2.equals(null);
+     * }
+ */ + CompletableFuture spop(String key); + + /** + * Removes and returns up to count random members from the set stored at key + * , depending on the set's length. + * + * @see redis.io for details. + * @param key The key of the set. + * @param count The count of the elements to pop from the set. + * @return A set of popped elements will be returned depending on the set's length.
+ * If key does not exist, an empty Set will be returned. + * @example + *
{@code
+     * Set values1 = client.spopCount("mySet", 2).get();
+     * assert values1.equals(new String[] {"value1", "value2"});
+     *
+     * Set values2 = client.spopCount("nonExistingSet", 2).get();
+     * assert values2.size() == 0;
+     * }
+ */ + CompletableFuture> spopCount(String key, long count); } diff --git a/java/client/src/main/java/glide/api/commands/StringBaseCommands.java b/java/client/src/main/java/glide/api/commands/StringBaseCommands.java index 6eaa4067c9..45c46a1e90 100644 --- a/java/client/src/main/java/glide/api/commands/StringBaseCommands.java +++ b/java/client/src/main/java/glide/api/commands/StringBaseCommands.java @@ -34,6 +34,25 @@ public interface StringBaseCommands { */ CompletableFuture get(String key); + /** + * Gets the value associated with the given key, or null if no such + * value exists. + * + * @see redis.io for details. + * @param key The key to retrieve from the database. + * @return Response from Redis. If key exists, returns the value of + * key as a String. Otherwise, return null. + * @example + *
{@code
+     * byte[] value = client.get("key").get();
+     * assert Arrays.equals(value, "value".getBytes());
+     *
+     * String value = client.get("non_existing_key").get();
+     * assert value.equals(null);
+     * }
+ */ + CompletableFuture get(byte[] key); + /** * Gets a string value associated with the given key and deletes the key. * @@ -67,6 +86,21 @@ public interface StringBaseCommands { */ CompletableFuture set(String key, String value); + /** + * Sets the given key with the given value. + * + * @see redis.io for details. + * @param key The key to store. + * @param value The value to store with the given key. + * @return Response from Redis containing "OK". + * @example + *
{@code
+     * String value = client.set("key".getBytes(), "value".getBytes()).get();
+     * assert value.equals("OK");
+     * }
+ */ + CompletableFuture set(byte[] key, byte[] value); + /** * Sets the given key with the given value. Return value is dependent on the passed options. * diff --git a/java/client/src/main/java/glide/api/models/BaseTransaction.java b/java/client/src/main/java/glide/api/models/BaseTransaction.java index 313fc640fe..5b2159a483 100644 --- a/java/client/src/main/java/glide/api/models/BaseTransaction.java +++ b/java/client/src/main/java/glide/api/models/BaseTransaction.java @@ -11,6 +11,8 @@ import static glide.api.commands.SortedSetBaseCommands.WITH_SCORE_REDIS_API; import static glide.api.models.commands.RangeOptions.createZRangeArgs; import static glide.api.models.commands.bitmap.BitFieldOptions.createBitFieldArgs; +import static glide.api.models.commands.function.FunctionListOptions.LIBRARY_NAME_REDIS_API; +import static glide.api.models.commands.function.FunctionListOptions.WITH_CODE_REDIS_API; import static glide.api.models.commands.function.FunctionLoadOptions.REPLACE; import static glide.utils.ArrayTransformUtils.concatenateArrays; import static glide.utils.ArrayTransformUtils.convertMapToKeyValueStringArray; @@ -45,6 +47,7 @@ import static redis_request.RedisRequestOuterClass.RequestType.ExpireAt; import static redis_request.RedisRequestOuterClass.RequestType.ExpireTime; import static redis_request.RedisRequestOuterClass.RequestType.FlushAll; +import static redis_request.RedisRequestOuterClass.RequestType.FunctionList; import static redis_request.RedisRequestOuterClass.RequestType.FunctionLoad; import static redis_request.RedisRequestOuterClass.RequestType.GeoAdd; import static redis_request.RedisRequestOuterClass.RequestType.GeoDist; @@ -117,6 +120,7 @@ import static redis_request.RedisRequestOuterClass.RequestType.SMIsMember; import static redis_request.RedisRequestOuterClass.RequestType.SMembers; import static redis_request.RedisRequestOuterClass.RequestType.SMove; +import static redis_request.RedisRequestOuterClass.RequestType.SPop; import static redis_request.RedisRequestOuterClass.RequestType.SRandMember; import static redis_request.RedisRequestOuterClass.RequestType.SRem; import static redis_request.RedisRequestOuterClass.RequestType.SUnionStore; @@ -3706,6 +3710,38 @@ public T functionLoad(@NonNull String libraryCode, boolean replace) { return getThis(); } + /** + * Returns information about the functions and libraries. + * + * @since Redis 7.0 and above. + * @see redis.io for details. + * @param withCode Specifies whether to request the library code from the server or not. + * @return Command Response - Info about all libraries and their functions. + */ + public T functionList(boolean withCode) { + ArgsArray commandArgs = withCode ? buildArgs(WITH_CODE_REDIS_API) : buildArgs(); + protobufTransaction.addCommands(buildCommand(FunctionList, commandArgs)); + return getThis(); + } + + /** + * Returns information about the functions and libraries. + * + * @since Redis 7.0 and above. + * @see redis.io for details. + * @param libNamePattern A wildcard pattern for matching library names. + * @param withCode Specifies whether to request the library code from the server or not. + * @return Command Response - Info about queried libraries and their functions. + */ + public T functionList(@NonNull String libNamePattern, boolean withCode) { + ArgsArray commandArgs = + withCode + ? buildArgs(LIBRARY_NAME_REDIS_API, libNamePattern, WITH_CODE_REDIS_API) + : buildArgs(LIBRARY_NAME_REDIS_API, libNamePattern); + protobufTransaction.addCommands(buildCommand(FunctionList, commandArgs)); + return getThis(); + } + /** * Sets or clears the bit at offset in the string value stored at key. * The offset is a zero-based index, with 0 being the first element of @@ -4088,6 +4124,37 @@ public T srandmember(@NonNull String key, long count) { return getThis(); } + /** + * Removes and returns one random member from the set stored at key. + * + * @see redis.io for details. + * @param key The key of the set. + * @return Command Response - The value of the popped member.
+ * If key does not exist, null will be returned. + */ + public T spop(@NonNull String key) { + ArgsArray commandArgs = buildArgs(key); + protobufTransaction.addCommands(buildCommand(SPop, commandArgs)); + return getThis(); + } + + /** + * Removes and returns up to count random members from the set stored at key + * , depending on the set's length. + * + * @see redis.io for details. + * @param key The key of the set. + * @param count The count of the elements to pop from the set. + * @return Command Response - A set of popped elements will be returned depending on the set's + * length.
+ * If key does not exist, an empty Set will be returned. + */ + public T spopCount(@NonNull String key, long count) { + ArgsArray commandArgs = buildArgs(key, Long.toString(count)); + protobufTransaction.addCommands(buildCommand(SPop, commandArgs)); + return getThis(); + } + /** * Reads or modifies the array of bits representing the string that is held at key * based on the specified subCommands. diff --git a/java/client/src/main/java/glide/api/models/commands/function/FunctionListOptions.java b/java/client/src/main/java/glide/api/models/commands/function/FunctionListOptions.java new file mode 100644 index 0000000000..6cac32fbac --- /dev/null +++ b/java/client/src/main/java/glide/api/models/commands/function/FunctionListOptions.java @@ -0,0 +1,19 @@ +/** Copyright GLIDE-for-Redis Project Contributors - SPDX Identifier: Apache-2.0 */ +package glide.api.models.commands.function; + +import glide.api.commands.ScriptingAndFunctionsClusterCommands; +import glide.api.commands.ScriptingAndFunctionsCommands; + +/** + * Option for {@link ScriptingAndFunctionsCommands#functionList()} and {@link + * ScriptingAndFunctionsClusterCommands#functionList()} command. + * + * @see redis.io + */ +public class FunctionListOptions { + /** Causes the server to include the libraries source implementation in the reply. */ + public static final String WITH_CODE_REDIS_API = "WITHCODE"; + + /** REDIS API keyword followed by library name pattern. */ + public static final String LIBRARY_NAME_REDIS_API = "LIBRARYNAME"; +} diff --git a/java/client/src/main/java/glide/ffi/resolvers/RedisValueResolver.java b/java/client/src/main/java/glide/ffi/resolvers/RedisValueResolver.java index e1693078c8..4aaa4a3123 100644 --- a/java/client/src/main/java/glide/ffi/resolvers/RedisValueResolver.java +++ b/java/client/src/main/java/glide/ffi/resolvers/RedisValueResolver.java @@ -17,4 +17,13 @@ public class RedisValueResolver { * @return A RESP3 value */ public static native Object valueFromPointer(long pointer); + + /** + * Resolve a value received from Redis using given C-style pointer. This method does not assume + * that strings are valid UTF-8 encoded strings + * + * @param pointer A memory pointer from {@link Response} + * @return A RESP3 value + */ + public static native Object valueFromPointerBinary(long pointer); } diff --git a/java/client/src/main/java/glide/managers/CommandManager.java b/java/client/src/main/java/glide/managers/CommandManager.java index 2e2abfae15..214a819016 100644 --- a/java/client/src/main/java/glide/managers/CommandManager.java +++ b/java/client/src/main/java/glide/managers/CommandManager.java @@ -57,6 +57,23 @@ public CompletableFuture submitNewCommand( return submitCommandToChannel(command, responseHandler); } + /** + * Build a command and send. + * + * @param requestType Redis command type + * @param arguments Redis command arguments + * @param responseHandler The handler for the response object + * @return A result promise of type T + */ + public CompletableFuture submitNewCommand( + RequestType requestType, + List arguments, + RedisExceptionCheckedFunction responseHandler) { + + RedisRequest.Builder command = prepareRedisRequest(requestType, arguments); + return submitCommandToChannel(command, responseHandler); + } + /** * Build a command and send. * @@ -76,6 +93,25 @@ public CompletableFuture submitNewCommand( return submitCommandToChannel(command, responseHandler); } + /** + * Build a command and send. + * + * @param requestType Redis command type + * @param arguments Redis command arguments + * @param route Command routing parameters + * @param responseHandler The handler for the response object + * @return A result promise of type T + */ + public CompletableFuture submitNewCommand( + RequestType requestType, + List arguments, + Route route, + RedisExceptionCheckedFunction responseHandler) { + + RedisRequest.Builder command = prepareRedisRequest(requestType, arguments, route); + return submitCommandToChannel(command, responseHandler); + } + /** * Build a Transaction and send. * @@ -177,6 +213,33 @@ protected RedisRequest.Builder prepareRedisRequest( return prepareRedisRequestRoute(builder, route); } + /** + * Build a protobuf command request object with routing options. + * + * @param requestType Redis command type + * @param arguments Redis command arguments + * @param route Command routing parameters + * @return An incomplete request. {@link CallbackDispatcher} is responsible to complete it by + * adding a callback id. + */ + protected RedisRequest.Builder prepareRedisRequest( + RequestType requestType, List arguments, Route route) { + ArgsArray.Builder commandArgs = ArgsArray.newBuilder(); + for (var arg : arguments) { + commandArgs.addArgs(ByteString.copyFrom(arg)); + } + + var builder = + RedisRequest.newBuilder() + .setSingleCommand( + Command.newBuilder() + .setRequestType(requestType) + .setArgsArray(commandArgs.build()) + .build()); + + return prepareRedisRequestRoute(builder, route); + } + /** * Build a protobuf transaction request object with routing options. * @@ -247,6 +310,29 @@ protected RedisRequest.Builder prepareRedisRequest(RequestType requestType, Stri .build()); } + /** + * Build a protobuf command request object. + * + * @param requestType Redis command type + * @param arguments Redis command arguments + * @return An uncompleted request. {@link CallbackDispatcher} is responsible to complete it by + * adding a callback id. + */ + protected RedisRequest.Builder prepareRedisRequest( + RequestType requestType, List arguments) { + ArgsArray.Builder commandArgs = ArgsArray.newBuilder(); + for (var arg : arguments) { + commandArgs.addArgs(ByteString.copyFrom(arg)); + } + + return RedisRequest.newBuilder() + .setSingleCommand( + Command.newBuilder() + .setRequestType(requestType) + .setArgsArray(commandArgs.build()) + .build()); + } + private RedisRequest.Builder prepareRedisRequestRoute(RedisRequest.Builder builder, Route route) { if (route instanceof SimpleMultiNodeRoute) { diff --git a/java/client/src/test/java/glide/api/RedisClientTest.java b/java/client/src/test/java/glide/api/RedisClientTest.java index 58cf23cf36..6b321388e7 100644 --- a/java/client/src/test/java/glide/api/RedisClientTest.java +++ b/java/client/src/test/java/glide/api/RedisClientTest.java @@ -20,6 +20,8 @@ import static glide.api.models.commands.bitmap.BitFieldOptions.INCRBY_COMMAND_STRING; import static glide.api.models.commands.bitmap.BitFieldOptions.OVERFLOW_COMMAND_STRING; import static glide.api.models.commands.bitmap.BitFieldOptions.SET_COMMAND_STRING; +import static glide.api.models.commands.function.FunctionListOptions.LIBRARY_NAME_REDIS_API; +import static glide.api.models.commands.function.FunctionListOptions.WITH_CODE_REDIS_API; import static glide.api.models.commands.geospatial.GeoAddOptions.CHANGED_REDIS_API; import static glide.api.models.commands.stream.StreamAddOptions.NO_MAKE_STREAM_REDIS_API; import static glide.api.models.commands.stream.StreamRange.MAXIMUM_RANGE_REDIS_API; @@ -72,6 +74,7 @@ import static redis_request.RedisRequestOuterClass.RequestType.ExpireAt; import static redis_request.RedisRequestOuterClass.RequestType.ExpireTime; import static redis_request.RedisRequestOuterClass.RequestType.FlushAll; +import static redis_request.RedisRequestOuterClass.RequestType.FunctionList; import static redis_request.RedisRequestOuterClass.RequestType.FunctionLoad; import static redis_request.RedisRequestOuterClass.RequestType.GeoAdd; import static redis_request.RedisRequestOuterClass.RequestType.GeoDist; @@ -145,6 +148,7 @@ import static redis_request.RedisRequestOuterClass.RequestType.SMIsMember; import static redis_request.RedisRequestOuterClass.RequestType.SMembers; import static redis_request.RedisRequestOuterClass.RequestType.SMove; +import static redis_request.RedisRequestOuterClass.RequestType.SPop; import static redis_request.RedisRequestOuterClass.RequestType.SRandMember; import static redis_request.RedisRequestOuterClass.RequestType.SRem; import static redis_request.RedisRequestOuterClass.RequestType.SUnionStore; @@ -4982,6 +4986,53 @@ public void functionLoad_with_replace_returns_success() { assertEquals(value, payload); } + @SneakyThrows + @Test + public void functionList_returns_success() { + // setup + String[] args = new String[0]; + @SuppressWarnings("unchecked") + Map[] value = new Map[0]; + CompletableFuture[]> testResponse = new CompletableFuture<>(); + testResponse.complete(value); + + // match on protobuf request + when(commandManager.[]>submitNewCommand(eq(FunctionList), eq(args), any())) + .thenReturn(testResponse); + + // exercise + CompletableFuture[]> response = service.functionList(false); + Map[] payload = response.get(); + + // verify + assertEquals(testResponse, response); + assertEquals(value, payload); + } + + @SneakyThrows + @Test + public void functionList_with_pattern_returns_success() { + // setup + String pattern = "*"; + String[] args = new String[] {LIBRARY_NAME_REDIS_API, pattern, WITH_CODE_REDIS_API}; + @SuppressWarnings("unchecked") + Map[] value = new Map[0]; + CompletableFuture[]> testResponse = new CompletableFuture<>(); + testResponse.complete(value); + + // match on protobuf request + when(commandManager.[]>submitNewCommand(eq(FunctionList), eq(args), any())) + .thenReturn(testResponse); + + // exercise + CompletableFuture[]> response = service.functionList(pattern, true); + Map[] payload = response.get(); + + // verify + assertEquals(testResponse, response); + assertEquals(value, payload); + } + @SneakyThrows @Test public void bitcount_returns_success() { @@ -5535,6 +5586,55 @@ public void srandmember_with_count_returns_success() { assertArrayEquals(value, payload); } + @SneakyThrows + @Test + public void spop_returns_success() { + // setup + String key = "testKey"; + String[] arguments = new String[] {key}; + String value = "value"; + + CompletableFuture testResponse = new CompletableFuture<>(); + testResponse.complete(value); + + // match on protobuf request + when(commandManager.submitNewCommand(eq(SPop), eq(arguments), any())) + .thenReturn(testResponse); + + // exercise + CompletableFuture response = service.spop(key); + String payload = response.get(); + + // verify + assertEquals(testResponse, response); + assertEquals(value, payload); + } + + @SneakyThrows + @Test + public void spopCount_returns_success() { + // setup + String key = "testKey"; + long count = 2; + String[] arguments = new String[] {key, Long.toString(count)}; + Set value = Set.of("one", "two"); + + CompletableFuture> testResponse = new CompletableFuture<>(); + testResponse.complete(value); + + // match on protobuf request + when(commandManager.>submitNewCommand(eq(SPop), eq(arguments), any())) + .thenReturn(testResponse); + + // exercise + CompletableFuture> response = service.spopCount(key, count); + Set payload = response.get(); + + // verify + assertEquals(testResponse, response); + assertEquals(value, payload); + } + @SneakyThrows @Test public void bitfieldReadOnly_returns_success() { diff --git a/java/client/src/test/java/glide/api/RedisClusterClientTest.java b/java/client/src/test/java/glide/api/RedisClusterClientTest.java index 0af49287f8..94bdb36c48 100644 --- a/java/client/src/test/java/glide/api/RedisClusterClientTest.java +++ b/java/client/src/test/java/glide/api/RedisClusterClientTest.java @@ -4,6 +4,8 @@ import static glide.api.BaseClient.OK; import static glide.api.commands.ServerManagementCommands.VERSION_REDIS_API; import static glide.api.models.commands.FlushMode.SYNC; +import static glide.api.models.commands.function.FunctionListOptions.LIBRARY_NAME_REDIS_API; +import static glide.api.models.commands.function.FunctionListOptions.WITH_CODE_REDIS_API; import static glide.api.models.configuration.RequestRoutingConfiguration.SimpleMultiNodeRoute.ALL_NODES; import static glide.api.models.configuration.RequestRoutingConfiguration.SimpleMultiNodeRoute.ALL_PRIMARIES; import static glide.api.models.configuration.RequestRoutingConfiguration.SimpleSingleNodeRoute.RANDOM; @@ -23,6 +25,7 @@ import static redis_request.RedisRequestOuterClass.RequestType.ConfigSet; import static redis_request.RedisRequestOuterClass.RequestType.Echo; import static redis_request.RedisRequestOuterClass.RequestType.FlushAll; +import static redis_request.RedisRequestOuterClass.RequestType.FunctionList; import static redis_request.RedisRequestOuterClass.RequestType.FunctionLoad; import static redis_request.RedisRequestOuterClass.RequestType.Info; import static redis_request.RedisRequestOuterClass.RequestType.LastSave; @@ -39,6 +42,7 @@ import glide.managers.CommandManager; import glide.managers.ConnectionManager; import glide.managers.RedisExceptionCheckedFunction; +import java.util.EnumSet; import java.util.HashMap; import java.util.Map; import java.util.Optional; @@ -138,7 +142,8 @@ public TestClient(CommandManager commandManager, Object objectToReturn) { } @Override - protected T handleRedisResponse(Class classType, boolean isNullable, Response response) { + protected T handleRedisResponse( + Class classType, EnumSet flags, Response response) { @SuppressWarnings("unchecked") T returnValue = (T) object; return returnValue; @@ -1169,4 +1174,102 @@ public void functionLoad_with_replace_with_route_returns_success() { assertEquals(testResponse, response); assertEquals(value, payload); } + + @SneakyThrows + @Test + public void functionList_returns_success() { + // setup + String[] args = new String[0]; + @SuppressWarnings("unchecked") + Map[] value = new Map[0]; + CompletableFuture[]> testResponse = new CompletableFuture<>(); + testResponse.complete(value); + + // match on protobuf request + when(commandManager.[]>submitNewCommand(eq(FunctionList), eq(args), any())) + .thenReturn(testResponse); + + // exercise + CompletableFuture[]> response = service.functionList(false); + Map[] payload = response.get(); + + // verify + assertEquals(testResponse, response); + assertEquals(value, payload); + } + + @SneakyThrows + @Test + public void functionList_with_pattern_returns_success() { + // setup + String pattern = "*"; + String[] args = new String[] {LIBRARY_NAME_REDIS_API, pattern, WITH_CODE_REDIS_API}; + @SuppressWarnings("unchecked") + Map[] value = new Map[0]; + CompletableFuture[]> testResponse = new CompletableFuture<>(); + testResponse.complete(value); + + // match on protobuf request + when(commandManager.[]>submitNewCommand(eq(FunctionList), eq(args), any())) + .thenReturn(testResponse); + + // exercise + CompletableFuture[]> response = service.functionList(pattern, true); + Map[] payload = response.get(); + + // verify + assertEquals(testResponse, response); + assertEquals(value, payload); + } + + @SneakyThrows + @Test + public void functionList_with_route_returns_success() { + // setup + String[] args = new String[] {WITH_CODE_REDIS_API}; + @SuppressWarnings("unchecked") + Map[] value = new Map[0]; + CompletableFuture[]>> testResponse = new CompletableFuture<>(); + testResponse.complete(ClusterValue.ofSingleValue(value)); + + // match on protobuf request + when(commandManager.[]>>submitNewCommand( + eq(FunctionList), eq(args), eq(RANDOM), any())) + .thenReturn(testResponse); + + // exercise + CompletableFuture[]>> response = + service.functionList(true, RANDOM); + ClusterValue[]> payload = response.get(); + + // verify + assertEquals(testResponse, response); + assertEquals(value, payload.getSingleValue()); + } + + @SneakyThrows + @Test + public void functionList_with_pattern_and_route_returns_success() { + // setup + String pattern = "*"; + String[] args = new String[] {LIBRARY_NAME_REDIS_API, pattern}; + @SuppressWarnings("unchecked") + Map[] value = new Map[0]; + CompletableFuture[]>> testResponse = new CompletableFuture<>(); + testResponse.complete(ClusterValue.ofSingleValue(value)); + + // match on protobuf request + when(commandManager.[]>>submitNewCommand( + eq(FunctionList), eq(args), eq(RANDOM), any())) + .thenReturn(testResponse); + + // exercise + CompletableFuture[]>> response = + service.functionList(pattern, false, RANDOM); + ClusterValue[]> payload = response.get(); + + // verify + assertEquals(testResponse, response); + assertEquals(value, payload.getSingleValue()); + } } diff --git a/java/client/src/test/java/glide/api/models/TransactionTests.java b/java/client/src/test/java/glide/api/models/TransactionTests.java index 7b68590608..9bf44d68d3 100644 --- a/java/client/src/test/java/glide/api/models/TransactionTests.java +++ b/java/client/src/test/java/glide/api/models/TransactionTests.java @@ -20,6 +20,8 @@ import static glide.api.models.commands.WeightAggregateOptions.AGGREGATE_REDIS_API; import static glide.api.models.commands.WeightAggregateOptions.WEIGHTS_REDIS_API; import static glide.api.models.commands.ZAddOptions.UpdateOptions.SCORE_LESS_THAN_CURRENT; +import static glide.api.models.commands.function.FunctionListOptions.LIBRARY_NAME_REDIS_API; +import static glide.api.models.commands.function.FunctionListOptions.WITH_CODE_REDIS_API; import static glide.api.models.commands.geospatial.GeoAddOptions.CHANGED_REDIS_API; import static glide.api.models.commands.stream.StreamRange.MAXIMUM_RANGE_REDIS_API; import static glide.api.models.commands.stream.StreamRange.MINIMUM_RANGE_REDIS_API; @@ -55,6 +57,7 @@ import static redis_request.RedisRequestOuterClass.RequestType.ExpireAt; import static redis_request.RedisRequestOuterClass.RequestType.ExpireTime; import static redis_request.RedisRequestOuterClass.RequestType.FlushAll; +import static redis_request.RedisRequestOuterClass.RequestType.FunctionList; import static redis_request.RedisRequestOuterClass.RequestType.FunctionLoad; import static redis_request.RedisRequestOuterClass.RequestType.GeoAdd; import static redis_request.RedisRequestOuterClass.RequestType.GeoDist; @@ -127,6 +130,7 @@ import static redis_request.RedisRequestOuterClass.RequestType.SMIsMember; import static redis_request.RedisRequestOuterClass.RequestType.SMembers; import static redis_request.RedisRequestOuterClass.RequestType.SMove; +import static redis_request.RedisRequestOuterClass.RequestType.SPop; import static redis_request.RedisRequestOuterClass.RequestType.SRandMember; import static redis_request.RedisRequestOuterClass.RequestType.SRem; import static redis_request.RedisRequestOuterClass.RequestType.SUnionStore; @@ -855,6 +859,10 @@ InfScoreBound.NEGATIVE_INFINITY, new ScoreBoundary(3, false), new Limit(1, 2)), results.add(Pair.of(FunctionLoad, buildArgs("pewpew"))); results.add(Pair.of(FunctionLoad, buildArgs("REPLACE", "ololo"))); + transaction.functionList(true).functionList("*", false); + results.add(Pair.of(FunctionList, buildArgs(WITH_CODE_REDIS_API))); + results.add(Pair.of(FunctionList, buildArgs(LIBRARY_NAME_REDIS_API, "*"))); + transaction.geodist("key", "Place", "Place2"); results.add(Pair.of(GeoDist, buildArgs("key", "Place", "Place2"))); transaction.geodist("key", "Place", "Place2", GeoUnit.KILOMETERS); @@ -912,6 +920,12 @@ InfScoreBound.NEGATIVE_INFINITY, new ScoreBoundary(3, false), new Limit(1, 2)), transaction.srandmember("key", 1); results.add(Pair.of(SRandMember, buildArgs("key", "1"))); + transaction.spop("key"); + results.add(Pair.of(SPop, buildArgs("key"))); + + transaction.spopCount("key", 1); + results.add(Pair.of(SPop, buildArgs("key", "1"))); + transaction.bitfieldReadOnly( "key", new BitFieldReadOnlySubCommands[] {new BitFieldGet(new SignedEncoding(5), new Offset(3))}); diff --git a/java/integTest/src/test/java/glide/SharedCommandTests.java b/java/integTest/src/test/java/glide/SharedCommandTests.java index 24ad6b11ff..edbd06a3b3 100644 --- a/java/integTest/src/test/java/glide/SharedCommandTests.java +++ b/java/integTest/src/test/java/glide/SharedCommandTests.java @@ -260,7 +260,7 @@ public void set_requires_a_key(BaseClient client) { @ParameterizedTest(autoCloseArguments = false) @MethodSource("getClients") public void get_requires_a_key(BaseClient client) { - assertThrows(NullPointerException.class, () -> client.get(null)); + assertThrows(NullPointerException.class, () -> client.get((String) null)); } @SneakyThrows @@ -319,6 +319,17 @@ public void set_only_if_does_not_exists_missing_key(BaseClient client) { assertEquals(ANOTHER_VALUE, data); } + @SneakyThrows + @ParameterizedTest(autoCloseArguments = false) + @MethodSource("getClients") + public void set_get_binary_data(BaseClient client) { + byte[] key = "set_get_binary_data_key".getBytes(); + byte[] value = {(byte) 0x01, (byte) 0x00, (byte) 0x01, (byte) 0x00, (byte) 0x02}; + assert client.set(key, value).get().equals("OK"); + byte[] data = client.get(key).get(); + assert Arrays.equals(data, value); + } + @SneakyThrows @ParameterizedTest(autoCloseArguments = false) @MethodSource("getClients") @@ -4638,6 +4649,45 @@ public void srandmember(BaseClient client) { assertInstanceOf(RequestException.class, executionExceptionWithCount.getCause()); } + @SneakyThrows + @ParameterizedTest(autoCloseArguments = false) + @MethodSource("getClients") + public void spop_spopCount(BaseClient client) { + String key = UUID.randomUUID().toString(); + String stringKey = UUID.randomUUID().toString(); + String nonExistingKey = UUID.randomUUID().toString(); + String member1 = UUID.randomUUID().toString(); + String member2 = UUID.randomUUID().toString(); + String member3 = UUID.randomUUID().toString(); + + assertEquals(1, client.sadd(key, new String[] {member1}).get()); + assertEquals(member1, client.spop(key).get()); + + assertEquals(3, client.sadd(key, new String[] {member1, member2, member3}).get()); + // Pop with count value greater than the size of the set + assertEquals(Set.of(member1, member2, member3), client.spopCount(key, 4).get()); + assertEquals(0, client.scard(key).get()); + + assertEquals(3, client.sadd(key, new String[] {member1, member2, member3}).get()); + assertEquals(Set.of(), client.spopCount(key, 0).get()); + + assertNull(client.spop(nonExistingKey).get()); + assertEquals(Set.of(), client.spopCount(nonExistingKey, 3).get()); + + // invalid argument - count must be positive + ExecutionException executionException = + assertThrows(ExecutionException.class, () -> client.spopCount(key, -1).get()); + assertInstanceOf(RequestException.class, executionException.getCause()); + + // key exists but is not a set + assertEquals(OK, client.set(stringKey, "foo").get()); + executionException = assertThrows(ExecutionException.class, () -> client.spop(stringKey).get()); + assertInstanceOf(RequestException.class, executionException.getCause()); + executionException = + assertThrows(ExecutionException.class, () -> client.spopCount(stringKey, 3).get()); + assertInstanceOf(RequestException.class, executionException.getCause()); + } + @SneakyThrows @ParameterizedTest(autoCloseArguments = false) @MethodSource("getClients") diff --git a/java/integTest/src/test/java/glide/TestUtilities.java b/java/integTest/src/test/java/glide/TestUtilities.java index 4f5f910b0c..3b77497fcd 100644 --- a/java/integTest/src/test/java/glide/TestUtilities.java +++ b/java/integTest/src/test/java/glide/TestUtilities.java @@ -15,6 +15,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; import lombok.experimental.UtilityClass; @@ -123,4 +124,43 @@ public static void assertDeepEquals(Object expected, Object actual) { assertEquals(expected, actual); } } + + /** + * Validate whether `FUNCTION LIST` response contains required info. + * + * @param response The response from redis. + * @param libName Expected library name. + * @param functionDescriptions Expected function descriptions. Key - function name, value - + * description. + * @param functionFlags Expected function flags. Key - function name, value - flags set. + * @param libCode Expected library to check if given. + */ + @SuppressWarnings("unchecked") + public static void checkFunctionListResponse( + Map[] response, + String libName, + Map functionDescriptions, + Map> functionFlags, + Optional libCode) { + assertTrue(response.length > 0); + boolean hasLib = false; + for (var lib : response) { + hasLib = lib.containsValue(libName); + if (hasLib) { + var functions = (Object[]) lib.get("functions"); + assertEquals(functionDescriptions.size(), functions.length); + for (var functionInfo : functions) { + var function = (Map) functionInfo; + var functionName = (String) function.get("name"); + assertEquals(functionDescriptions.get(functionName), function.get("description")); + assertEquals(functionFlags.get(functionName), function.get("flags")); + } + if (libCode.isPresent()) { + assertEquals(libCode.get(), lib.get("library_code")); + } + break; + } + } + assertTrue(hasLib); + } } diff --git a/java/integTest/src/test/java/glide/TransactionTestUtilities.java b/java/integTest/src/test/java/glide/TransactionTestUtilities.java index 32bd952ff2..522f081380 100644 --- a/java/integTest/src/test/java/glide/TransactionTestUtilities.java +++ b/java/integTest/src/test/java/glide/TransactionTestUtilities.java @@ -35,6 +35,7 @@ import glide.api.models.commands.stream.StreamAddOptions; import glide.api.models.commands.stream.StreamRange.IdBound; import glide.api.models.commands.stream.StreamTrimOptions.MinId; +import java.util.HashMap; import java.util.Map; import java.util.Set; import java.util.UUID; @@ -395,7 +396,9 @@ private static Object[] setCommands(BaseTransaction transaction) { .sadd(setKey4, new String[] {"foo"}) .srandmember(setKey4) .srandmember(setKey4, 2) - .srandmember(setKey4, -2); + .srandmember(setKey4, -2) + .spop(setKey4) + .spopCount(setKey4, 3); // setKey4 is now empty if (REDIS_VERSION.isGreaterThanOrEqualTo("7.0.0")) { transaction @@ -424,6 +427,8 @@ private static Object[] setCommands(BaseTransaction transaction) { "foo", // srandmember(setKey4) new String[] {"foo"}, // srandmember(setKey4, 2) new String[] {"foo", "foo"}, // srandmember(setKey4, -2)}; + "foo", // spop(setKey4) + Set.of(), // spopCount(setKey4, 3) }; if (REDIS_VERSION.isGreaterThanOrEqualTo("7.0.0")) { expectedResults = @@ -678,12 +683,47 @@ private static Object[] scriptingAndFunctionsCommands(BaseTransaction transac final String code = "#!lua name=mylib1T \n" + " redis.register_function('myfunc1T', function(keys, args) return args[1] end)"; + var expectedFuncData = + new HashMap() { + { + put("name", "myfunc1T"); + put("description", null); + put("flags", Set.of()); + } + }; + + var expectedLibData = + new Map[] { + Map.of( + "library_name", + "mylib1T", + "engine", + "LUA", + "functions", + new Object[] {expectedFuncData}, + "library_code", + code) + }; - transaction.functionLoad(code, false).functionLoad(code, true); + transaction + .customCommand(new String[] {"function", "flush", "sync"}) + .functionList(false) + .functionList(true) + .functionLoad(code, false) + .functionLoad(code, true) + .functionList("otherLib", false) + .functionList("mylib1T", true) + .customCommand(new String[] {"function", "flush", "sync"}); return new Object[] { + OK, // customCommand("function", "flush", "sync") + new Map[0], // functionList(false) + new Map[0], // functionList(true) "mylib1T", // functionLoad(code, false) "mylib1T", // functionLoad(code, true) + new Map[0], // functionList("otherLib", false) + expectedLibData, // functionList("mylib1T", true) + OK, // customCommand("function", "flush", "sync") }; } diff --git a/java/integTest/src/test/java/glide/cluster/CommandTests.java b/java/integTest/src/test/java/glide/cluster/CommandTests.java index 0998a7a3bd..4d2779b8bc 100644 --- a/java/integTest/src/test/java/glide/cluster/CommandTests.java +++ b/java/integTest/src/test/java/glide/cluster/CommandTests.java @@ -3,6 +3,7 @@ import static glide.TestConfiguration.CLUSTER_PORTS; import static glide.TestConfiguration.REDIS_VERSION; +import static glide.TestUtilities.checkFunctionListResponse; import static glide.TestUtilities.getFirstEntryFromMultiValue; import static glide.TestUtilities.getValueFromInfo; import static glide.TestUtilities.parseInfoResponseToMap; @@ -49,8 +50,11 @@ import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.Arrays; +import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Optional; +import java.util.Set; import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutionException; @@ -779,53 +783,192 @@ public void flushall() { } @SneakyThrows - @ParameterizedTest + @ParameterizedTest(name = "functionLoad: singleNodeRoute = {0}") @ValueSource(booleans = {true, false}) - public void functionLoad(boolean withRoute) { + public void functionLoad_and_functionList(boolean singleNodeRoute) { assumeTrue(REDIS_VERSION.isGreaterThanOrEqualTo("7.0.0"), "This feature added in redis 7"); - String libName = "mylib1C" + withRoute; + + // TODO use FUNCTION FLUSH + assertEquals( + OK, + clusterClient + .customCommand(new String[] {"FUNCTION", "FLUSH", "SYNC"}) + .get() + .getSingleValue()); + + String libName = "mylib1c_" + singleNodeRoute; + String funcName = "myfunc1c_" + singleNodeRoute; String code = "#!lua name=" + libName - + " \n redis.register_function('myfunc1c" - + withRoute - + "', function(keys, args) return args[1] end)"; - Route route = new SlotKeyRoute("1", PRIMARY); - - var promise = - withRoute - ? clusterClient.functionLoad(code, false, route) - : clusterClient.functionLoad(code, false); - assertEquals(libName, promise.get()); + + " \n redis.register_function('" + + funcName + + "', function(keys, args) return args[1] end)"; // function returns first argument + Route route = singleNodeRoute ? new SlotKeyRoute("1", PRIMARY) : ALL_PRIMARIES; + + assertEquals(libName, clusterClient.functionLoad(code, false, route).get()); // TODO test function with FCALL when fixed in redis-rs and implemented - // TODO test with FUNCTION LIST + + var expectedDescription = + new HashMap() { + { + put(funcName, null); + } + }; + var expectedFlags = + new HashMap>() { + { + put(funcName, Set.of()); + } + }; + + var response = clusterClient.functionList(false, route).get(); + if (singleNodeRoute) { + var flist = response.getSingleValue(); + checkFunctionListResponse( + flist, libName, expectedDescription, expectedFlags, Optional.empty()); + } else { + for (var flist : response.getMultiValue().values()) { + checkFunctionListResponse( + flist, libName, expectedDescription, expectedFlags, Optional.empty()); + } + } + + response = clusterClient.functionList(true, route).get(); + if (singleNodeRoute) { + var flist = response.getSingleValue(); + checkFunctionListResponse( + flist, libName, expectedDescription, expectedFlags, Optional.of(code)); + } else { + for (var flist : response.getMultiValue().values()) { + checkFunctionListResponse( + flist, libName, expectedDescription, expectedFlags, Optional.of(code)); + } + } // re-load library without overwriting - promise = - withRoute - ? clusterClient.functionLoad(code, false, route) - : clusterClient.functionLoad(code, false); - var executionException = assertThrows(ExecutionException.class, promise::get); + var executionException = + assertThrows( + ExecutionException.class, () -> clusterClient.functionLoad(code, false, route).get()); assertInstanceOf(RequestException.class, executionException.getCause()); assertTrue( executionException.getMessage().contains("Library '" + libName + "' already exists")); // re-load library with overwriting - var promise2 = - withRoute - ? clusterClient.functionLoad(code, true, route) - : clusterClient.functionLoad(code, true); - assertEquals(libName, promise2.get()); + assertEquals(libName, clusterClient.functionLoad(code, true, route).get()); + String newFuncName = "myfunc2c_" + singleNodeRoute; String newCode = code - + "\n redis.register_function('myfunc2c" - + withRoute - + "', function(keys, args) return #args end)"; - promise2 = - withRoute - ? clusterClient.functionLoad(newCode, true, route) - : clusterClient.functionLoad(newCode, true); - assertEquals(libName, promise2.get()); + + "\n redis.register_function('" + + newFuncName + + "', function(keys, args) return #args end)"; // function returns argument count + + assertEquals(libName, clusterClient.functionLoad(newCode, true, route).get()); + + expectedDescription.put(newFuncName, null); + expectedFlags.put(newFuncName, Set.of()); + + response = clusterClient.functionList(false, route).get(); + if (singleNodeRoute) { + var flist = response.getSingleValue(); + checkFunctionListResponse( + flist, libName, expectedDescription, expectedFlags, Optional.empty()); + } else { + for (var flist : response.getMultiValue().values()) { + checkFunctionListResponse( + flist, libName, expectedDescription, expectedFlags, Optional.empty()); + } + } + + response = clusterClient.functionList(true, route).get(); + if (singleNodeRoute) { + var flist = response.getSingleValue(); + checkFunctionListResponse( + flist, libName, expectedDescription, expectedFlags, Optional.of(newCode)); + } else { + for (var flist : response.getMultiValue().values()) { + checkFunctionListResponse( + flist, libName, expectedDescription, expectedFlags, Optional.of(newCode)); + } + } + // TODO test with FCALL + + // TODO FUNCTION FLUSH at the end + } + + @SneakyThrows + @Test + public void functionLoad_and_functionList_without_route() { + assumeTrue(REDIS_VERSION.isGreaterThanOrEqualTo("7.0.0"), "This feature added in redis 7"); + + // TODO use FUNCTION FLUSH + assertEquals( + OK, + clusterClient + .customCommand(new String[] {"FUNCTION", "FLUSH", "SYNC"}) + .get() + .getSingleValue()); + + String libName = "mylib1c"; + String funcName = "myfunc1c"; + String code = + "#!lua name=" + + libName + + " \n redis.register_function('" + + funcName + + "', function(keys, args) return args[1] end)"; // function returns first argument + + assertEquals(libName, clusterClient.functionLoad(code, false).get()); + // TODO test function with FCALL when fixed in redis-rs and implemented + + var flist = clusterClient.functionList(false).get(); + var expectedDescription = + new HashMap() { + { + put(funcName, null); + } + }; + var expectedFlags = + new HashMap>() { + { + put(funcName, Set.of()); + } + }; + checkFunctionListResponse(flist, libName, expectedDescription, expectedFlags, Optional.empty()); + + flist = clusterClient.functionList(true).get(); + checkFunctionListResponse( + flist, libName, expectedDescription, expectedFlags, Optional.of(code)); + + // re-load library without overwriting + var executionException = + assertThrows(ExecutionException.class, () -> clusterClient.functionLoad(code, false).get()); + assertInstanceOf(RequestException.class, executionException.getCause()); + assertTrue( + executionException.getMessage().contains("Library '" + libName + "' already exists")); + + // re-load library with overwriting + assertEquals(libName, clusterClient.functionLoad(code, true).get()); + String newFuncName = "myfunc2c"; + String newCode = + code + + "\n redis.register_function('" + + newFuncName + + "', function(keys, args) return #args end)"; // function returns argument count + assertEquals(libName, clusterClient.functionLoad(newCode, true).get()); + + flist = clusterClient.functionList(libName, false).get(); + expectedDescription.put(newFuncName, null); + expectedFlags.put(newFuncName, Set.of()); + checkFunctionListResponse(flist, libName, expectedDescription, expectedFlags, Optional.empty()); + + flist = clusterClient.functionList(libName, true).get(); + checkFunctionListResponse( + flist, libName, expectedDescription, expectedFlags, Optional.of(newCode)); + + // TODO test with FCALL + + // TODO FUNCTION FLUSH at the end } } diff --git a/java/integTest/src/test/java/glide/standalone/CommandTests.java b/java/integTest/src/test/java/glide/standalone/CommandTests.java index 19fbf1e107..adafed5406 100644 --- a/java/integTest/src/test/java/glide/standalone/CommandTests.java +++ b/java/integTest/src/test/java/glide/standalone/CommandTests.java @@ -3,6 +3,7 @@ import static glide.TestConfiguration.REDIS_VERSION; import static glide.TestConfiguration.STANDALONE_PORTS; +import static glide.TestUtilities.checkFunctionListResponse; import static glide.TestUtilities.getValueFromInfo; import static glide.TestUtilities.parseInfoResponseToMap; import static glide.api.BaseClient.OK; @@ -30,7 +31,10 @@ import glide.api.models.exceptions.RequestException; import java.time.Instant; import java.time.temporal.ChronoUnit; +import java.util.HashMap; import java.util.Map; +import java.util.Optional; +import java.util.Set; import java.util.UUID; import java.util.concurrent.ExecutionException; import lombok.SneakyThrows; @@ -369,16 +373,41 @@ public void flushall() { @SneakyThrows @Test - public void functionLoad() { + public void functionLoad_and_functionList() { assumeTrue(REDIS_VERSION.isGreaterThanOrEqualTo("7.0.0"), "This feature added in redis 7"); - String libName = "mylib1C"; + + // TODO use FUNCTION FLUSH + assertEquals(OK, regularClient.customCommand(new String[] {"FUNCTION", "FLUSH", "SYNC"}).get()); + + String libName = "mylib1c"; + String funcName = "myfunc1c"; String code = "#!lua name=" + libName - + " \n redis.register_function('myfunc1c', function(keys, args) return args[1] end)"; + + " \n redis.register_function('" + + funcName + + "', function(keys, args) return args[1] end)"; // function returns first argument assertEquals(libName, regularClient.functionLoad(code, false).get()); // TODO test function with FCALL when fixed in redis-rs and implemented - // TODO test with FUNCTION LIST + + var flist = regularClient.functionList(false).get(); + var expectedDescription = + new HashMap() { + { + put(funcName, null); + } + }; + var expectedFlags = + new HashMap>() { + { + put(funcName, Set.of()); + } + }; + checkFunctionListResponse(flist, libName, expectedDescription, expectedFlags, Optional.empty()); + + flist = regularClient.functionList(true).get(); + checkFunctionListResponse( + flist, libName, expectedDescription, expectedFlags, Optional.of(code)); // re-load library without overwriting var executionException = @@ -389,9 +418,24 @@ public void functionLoad() { // re-load library with overwriting assertEquals(libName, regularClient.functionLoad(code, true).get()); + String newFuncName = "myfunc2c"; String newCode = - code + "\n redis.register_function('myfunc2c', function(keys, args) return #args end)"; + code + + "\n redis.register_function('" + + newFuncName + + "', function(keys, args) return #args end)"; // function returns argument count assertEquals(libName, regularClient.functionLoad(newCode, true).get()); + + flist = regularClient.functionList(libName, false).get(); + expectedDescription.put(newFuncName, null); + expectedFlags.put(newFuncName, Set.of()); + checkFunctionListResponse(flist, libName, expectedDescription, expectedFlags, Optional.empty()); + + flist = regularClient.functionList(libName, true).get(); + checkFunctionListResponse( + flist, libName, expectedDescription, expectedFlags, Optional.of(newCode)); + // TODO test with FCALL + // TODO FUNCTION FLUSH at the end } } diff --git a/java/src/lib.rs b/java/src/lib.rs index eb81b165f1..9d42b8e298 100644 --- a/java/src/lib.rs +++ b/java/src/lib.rs @@ -16,7 +16,11 @@ mod ffi_test; pub use ffi_test::*; // TODO: Consider caching method IDs here in a static variable (might need RwLock to mutate) -fn redis_value_to_java<'local>(env: &mut JNIEnv<'local>, val: Value) -> JObject<'local> { +fn redis_value_to_java<'local>( + env: &mut JNIEnv<'local>, + val: Value, + encoding_utf8: bool, +) -> JObject<'local> { match val { Value::Nil => JObject::null(), Value::SimpleString(str) => JObject::from(env.new_string(str).unwrap()), @@ -24,20 +28,37 @@ fn redis_value_to_java<'local>(env: &mut JNIEnv<'local>, val: Value) -> JObject< Value::Int(num) => env .new_object("java/lang/Long", "(J)V", &[num.into()]) .unwrap(), - Value::BulkString(data) => match std::str::from_utf8(data.as_ref()) { - Ok(val) => JObject::from(env.new_string(val).unwrap()), - Err(_err) => { - let _ = env.throw("Error decoding Unicode data"); - JObject::null() + Value::BulkString(data) => { + if encoding_utf8 { + let Ok(utf8_str) = String::from_utf8(data) else { + let _ = env.throw("Failed to construct UTF-8 string"); + return JObject::null(); + }; + match env.new_string(utf8_str) { + Ok(string) => JObject::from(string), + Err(e) => { + let _ = env.throw(format!( + "Failed to construct Java UTF-8 string from Rust UTF-8 string. {:?}", + e + )); + JObject::null() + } + } + } else { + let Ok(bytearr) = env.byte_array_from_slice(data.as_ref()) else { + let _ = env.throw("Failed to allocate byte array"); + return JObject::null(); + }; + bytearr.into() } - }, + } Value::Array(array) => { let items: JObjectArray = env .new_object_array(array.len() as i32, "java/lang/Object", JObject::null()) .unwrap(); for (i, item) in array.into_iter().enumerate() { - let java_value = redis_value_to_java(env, item); + let java_value = redis_value_to_java(env, item, encoding_utf8); env.set_object_array_element(&items, i as i32, java_value) .unwrap(); } @@ -50,8 +71,8 @@ fn redis_value_to_java<'local>(env: &mut JNIEnv<'local>, val: Value) -> JObject< .unwrap(); for (key, value) in map { - let java_key = redis_value_to_java(env, key); - let java_value = redis_value_to_java(env, value); + let java_key = redis_value_to_java(env, key, encoding_utf8); + let java_value = redis_value_to_java(env, value, encoding_utf8); env.call_method( &linked_hash_map, "put", @@ -75,7 +96,7 @@ fn redis_value_to_java<'local>(env: &mut JNIEnv<'local>, val: Value) -> JObject< let set = env.new_object("java/util/HashSet", "()V", &[]).unwrap(); for elem in array { - let java_value = redis_value_to_java(env, elem); + let java_value = redis_value_to_java(env, elem, encoding_utf8); env.call_method( &set, "add", @@ -102,7 +123,19 @@ pub extern "system" fn Java_glide_ffi_resolvers_RedisValueResolver_valueFromPoin pointer: jlong, ) -> JObject<'local> { let value = unsafe { Box::from_raw(pointer as *mut Value) }; - redis_value_to_java(&mut env, *value) + redis_value_to_java(&mut env, *value, true) +} + +#[no_mangle] +pub extern "system" fn Java_glide_ffi_resolvers_RedisValueResolver_valueFromPointerBinary< + 'local, +>( + mut env: JNIEnv<'local>, + _class: JClass<'local>, + pointer: jlong, +) -> JObject<'local> { + let value = unsafe { Box::from_raw(pointer as *mut Value) }; + redis_value_to_java(&mut env, *value, false) } #[no_mangle] diff --git a/node/.prettierignore b/node/.prettierignore index 086fea31e1..6ced842400 100644 --- a/node/.prettierignore +++ b/node/.prettierignore @@ -1,5 +1,6 @@ # ignore that dir, because there are a lot of files which we don't manage, e.g. json files in cargo crates rust-client/* +*.md # unignore specific files !rust-client/package.json !rust-client/tsconfig.json diff --git a/node/THIRD_PARTY_LICENSES_NODE b/node/THIRD_PARTY_LICENSES_NODE index 002dfc0ce7..f3279da7de 100644 --- a/node/THIRD_PARTY_LICENSES_NODE +++ b/node/THIRD_PARTY_LICENSES_NODE @@ -36860,7 +36860,7 @@ THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ---- -Package: @types:node:20.14.0 +Package: @types:node:20.14.1 The following copyrights and licenses were found in the source code of this package: diff --git a/node/package.json b/node/package.json index 581a5ce986..7cecbb624b 100644 --- a/node/package.json +++ b/node/package.json @@ -36,7 +36,6 @@ "build-test-utils": "cd ../utils && npm i && npm run build", "lint": "eslint -f unix \"src/**/*.{ts,tsx}\"", "prepack": "npmignore --auto", - "test-modules": "jest --verbose --runInBand 'tests/RedisModules.test.ts'", "prettier:check:ci": "./node_modules/.bin/prettier --check . --ignore-unknown '!**/*.{js,d.ts}'", "prettier:format": "./node_modules/.bin/prettier --write . --ignore-unknown '!**/*.{js,d.ts}'" }, diff --git a/node/tests/RedisModules.test.ts b/node/tests/RedisModules.test.ts deleted file mode 100644 index 67643885b0..0000000000 --- a/node/tests/RedisModules.test.ts +++ /dev/null @@ -1,99 +0,0 @@ -/** - * Copyright GLIDE-for-Redis Project Contributors - SPDX Identifier: Apache-2.0 - */ - -import { - afterAll, - afterEach, - beforeAll, - describe, - expect, - it, -} from "@jest/globals"; -import { - BaseClientConfiguration, - InfoOptions, - RedisClusterClient, - parseInfoResponse, -} from "../"; -import { RedisCluster } from "../../utils/TestUtils.js"; -import { runBaseTests } from "./SharedTests"; -import { flushallOnPort, getFirstResult } from "./TestUtilities"; - -type Context = { - client: RedisClusterClient; -}; - -const TIMEOUT = 10000; - -describe("RedisModules", () => { - let testsFailed = 0; - let cluster: RedisCluster; - beforeAll(async () => { - const args = process.argv.slice(2); - const loadModuleArgs = args.filter((arg) => - arg.startsWith("--load-module="), - ); - const loadModuleValues = loadModuleArgs.map((arg) => arg.split("=")[1]); - cluster = await RedisCluster.createCluster( - true, - 3, - 0, - loadModuleValues, - ); - }, 20000); - - afterEach(async () => { - await Promise.all(cluster.ports().map((port) => flushallOnPort(port))); - }); - - afterAll(async () => { - if (testsFailed === 0) { - await cluster.close(); - } - }); - - const getOptions = (ports: number[]): BaseClientConfiguration => { - return { - addresses: ports.map((port) => ({ - host: "localhost", - port, - })), - }; - }; - - runBaseTests({ - init: async (protocol, clientName) => { - const options = getOptions(cluster.ports()); - options.protocol = protocol; - options.clientName = clientName; - testsFailed += 1; - const client = await RedisClusterClient.createClient(options); - return { - context: { - client, - }, - client, - }; - }, - close: (context: Context, testSucceeded: boolean) => { - if (testSucceeded) { - testsFailed -= 1; - } - - context.client.close(); - }, - timeout: TIMEOUT, - }); - - it("simple json test", async () => { - const client = await RedisClusterClient.createClient( - getOptions(cluster.ports()), - ); - const info = parseInfoResponse( - getFirstResult(await client.info([InfoOptions.Modules])).toString(), - )["module"]; - expect(info).toEqual(expect.stringContaining("ReJSON")); - client.close(); - }); -}); diff --git a/python/THIRD_PARTY_LICENSES_PYTHON b/python/THIRD_PARTY_LICENSES_PYTHON index d570bbe26b..a3fd27ffd3 100644 --- a/python/THIRD_PARTY_LICENSES_PYTHON +++ b/python/THIRD_PARTY_LICENSES_PYTHON @@ -37724,7 +37724,7 @@ The following copyrights and licenses were found in the source code of this pack ---- -Package: googleapis-common-protos:1.63.0 +Package: googleapis-common-protos:1.63.1 The following copyrights and licenses were found in the source code of this package: diff --git a/python/python/glide/__init__.py b/python/python/glide/__init__.py index 8928249303..ebd86eca1c 100644 --- a/python/python/glide/__init__.py +++ b/python/python/glide/__init__.py @@ -1,5 +1,6 @@ # Copyright GLIDE-for-Redis Project Contributors - SPDX Identifier: Apache-2.0 +from glide.async_commands.command_args import Limit, OrderBy from glide.async_commands.core import ( ConditionalChange, ExpireOptions, @@ -20,7 +21,6 @@ AggregationType, InfBound, LexBoundary, - Limit, RangeByIndex, RangeByLex, RangeByScore, @@ -102,6 +102,7 @@ "RangeByLex", "RangeByScore", "ScoreFilter", + "OrderBy", "StreamAddOptions", "StreamTrimOptions", "TrimByMaxLen", diff --git a/python/python/glide/async_commands/cluster_commands.py b/python/python/glide/async_commands/cluster_commands.py index 872b4d12a4..e010f1f54b 100644 --- a/python/python/glide/async_commands/cluster_commands.py +++ b/python/python/glide/async_commands/cluster_commands.py @@ -4,7 +4,8 @@ from typing import Dict, List, Mapping, Optional, cast -from glide.async_commands.core import CoreCommands, InfoSection +from glide.async_commands.command_args import Limit, OrderBy +from glide.async_commands.core import CoreCommands, InfoSection, _build_sort_args from glide.async_commands.transaction import BaseTransaction, ClusterTransaction from glide.constants import TOK, TClusterResponse, TResult, TSingleNodeRoute from glide.protobuf.redis_request_pb2 import RequestType @@ -367,3 +368,87 @@ async def lastsave(self, route: Optional[Route] = None) -> TClusterResponse[int] TClusterResponse[int], await self._execute_command(RequestType.LastSave, [], route), ) + + async def sort( + self, + key: str, + limit: Optional[Limit] = None, + order: Optional[OrderBy] = None, + alpha: Optional[bool] = None, + ) -> List[str]: + """ + Sorts the elements in the list, set, or sorted set at `key` and returns the result. + To store the result into a new key, see `sort_store`. + + By default, sorting is numeric, and elements are compared by their value interpreted as double precision floating point numbers. + + See https://valkey.io/commands/sort for more details. + + Args: + key (str): The key of the list, set, or sorted set to be sorted. + limit (Optional[Limit]): Limiting the range of the query by setting offset and result count. See `Limit` class for more information. + order (Optional[OrderBy]): Specifies the order to sort the elements. + Can be `OrderBy.ASC` (ascending) or `OrderBy.DESC` (descending). + alpha (Optional[bool]): When `True`, sorts elements lexicographically. When `False` (default), sorts elements numerically. + Use this when the list, set, or sorted set contains string values that cannot be converted into double precision floating point numbers. + + Returns: + List[str]: A list of sorted elements. + + Examples: + >>> await client.lpush("mylist", '3', '1', '2') + >>> await client.sort("mylist") + ['1', '2', '3'] + + >>> await client.sort("mylist", order=OrderBy.DESC) + ['3', '2', '1'] + + >>> await client.lpush("mylist", '2', '1', '2', '3', '3', '1') + >>> await client.sort("mylist", limit=Limit(2, 3)) + ['1', '2', '2'] + + >>> await client.lpush("mylist", "a", "b", "c", "d") + >>> await client.sort("mylist", limit=Limit(2, 2), order=OrderBy.DESC, alpha=True) + ['b', 'a'] + """ + args = _build_sort_args(key, None, limit, None, order, alpha) + result = await self._execute_command(RequestType.Sort, args) + return cast(List[str], result) + + async def sort_store( + self, + key: str, + destination: str, + limit: Optional[Limit] = None, + order: Optional[OrderBy] = None, + alpha: Optional[bool] = None, + ) -> int: + """ + Sorts the elements in the list, set, or sorted set at `key` and stores the result in `store`. + When in cluster mode, `key` and `store` must map to the same hash slot. + To get the sort result without storing it into a key, see `sort`. + + See https://valkey.io/commands/sort for more details. + + Args: + key (str): The key of the list, set, or sorted set to be sorted. + destination (str): The key where the sorted result will be stored. + limit (Optional[Limit]): Limiting the range of the query by setting offset and result count. See `Limit` class for more information. + order (Optional[OrderBy]): Specifies the order to sort the elements. + Can be `OrderBy.ASC` (ascending) or `OrderBy.DESC` (descending). + alpha (Optional[bool]): When `True`, sorts elements lexicographically. When `False` (default), sorts elements numerically. + Use this when the list, set, or sorted set contains string values that cannot be converted into double precision floating point numbers. + + Returns: + int: The number of elements in the sorted key stored at `store`. + + Examples: + >>> await client.lpush("mylist", 3, 1, 2) + >>> await client.sort_store("mylist", "sorted_list") + 3 # Indicates that the sorted list "sorted_list" contains three elements. + >>> await client.lrange("sorted_list", 0, -1) + ['1', '2', '3'] + """ + args = _build_sort_args(key, None, limit, None, order, alpha, store=destination) + result = await self._execute_command(RequestType.Sort, args) + return cast(int, result) diff --git a/python/python/glide/async_commands/command_args.py b/python/python/glide/async_commands/command_args.py new file mode 100644 index 0000000000..d308ca9ed7 --- /dev/null +++ b/python/python/glide/async_commands/command_args.py @@ -0,0 +1,45 @@ +# Copyright GLIDE-for-Redis Project Contributors - SPDX Identifier: Apache-2.0 + +from enum import Enum +from typing import List, Optional, Union + + +class Limit: + """ + Represents a limit argument for range queries in various Redis commands. + + The `LIMIT` argument is commonly used to specify a subset of results from the matching elements, + similar to the `LIMIT` clause in SQL (e.g., `SELECT LIMIT offset, count`). + + This class can be utilized in multiple Redis commands that support limit options, + such as [ZRANGE](https://valkey.io/commands/zrange), [SORT](https://valkey.io/commands/sort/), and others. + + Args: + offset (int): The starting position of the range, zero based. + count (int): The maximum number of elements to include in the range. + A negative count returns all elements from the offset. + + Examples: + >>> limit = Limit(0, 10) # Fetch the first 10 elements + >>> limit = Limit(5, -1) # Fetch all elements starting from the 5th element + """ + + def __init__(self, offset: int, count: int): + self.offset = offset + self.count = count + + +class OrderBy(Enum): + """ + SORT order options: options for sorting elements. + """ + + ASC = "ASC" + """ + ASC: Sort in ascending order. + """ + + DESC = "DESC" + """ + DESC: Sort in descending order. + """ diff --git a/python/python/glide/async_commands/core.py b/python/python/glide/async_commands/core.py index 5ea9aa1a6d..57d41fbb17 100644 --- a/python/python/glide/async_commands/core.py +++ b/python/python/glide/async_commands/core.py @@ -16,6 +16,7 @@ get_args, ) +from glide.async_commands.command_args import Limit, OrderBy from glide.async_commands.sorted_set import ( AggregationType, InfBound, @@ -361,6 +362,39 @@ class InsertPosition(Enum): AFTER = "AFTER" +def _build_sort_args( + key: str, + by_pattern: Optional[str] = None, + limit: Optional[Limit] = None, + get_patterns: Optional[List[str]] = None, + order: Optional[OrderBy] = None, + alpha: Optional[bool] = None, + store: Optional[str] = None, +) -> List[str]: + args = [key] + + if by_pattern: + args.extend(["BY", by_pattern]) + + if limit: + args.extend(["LIMIT", str(limit.offset), str(limit.count)]) + + if get_patterns: + for pattern in get_patterns: + args.extend(["GET", pattern]) + + if order: + args.append(order.value) + + if alpha: + args.append("ALPHA") + + if store: + args.extend(["STORE", store]) + + return args + + class CoreCommands(Protocol): async def _execute_command( self, diff --git a/python/python/glide/async_commands/sorted_set.py b/python/python/glide/async_commands/sorted_set.py index 7ac92f1e99..5e99da8631 100644 --- a/python/python/glide/async_commands/sorted_set.py +++ b/python/python/glide/async_commands/sorted_set.py @@ -3,6 +3,8 @@ from enum import Enum from typing import List, Optional, Tuple, Union +from glide.async_commands.command_args import Limit + class InfBound(Enum): """ @@ -88,23 +90,6 @@ def __init__(self, value: str, is_inclusive: bool = True): self.value = f"[{value}" if is_inclusive else f"({value}" -class Limit: - """ - Represents a limit argument for a range query in a sorted set to be used in [ZRANGE](https://redis.io/commands/zrange) command. - - The optional LIMIT argument can be used to obtain a sub-range from the matching elements - (similar to SELECT LIMIT offset, count in SQL). - Args: - offset (int): The offset from the start of the range. - count (int): The number of elements to include in the range. - A negative count returns all elements from the offset. - """ - - def __init__(self, offset: int, count: int): - self.offset = offset - self.count = count - - class RangeByIndex: """ Represents a range by index (rank) in a sorted set. diff --git a/python/python/glide/async_commands/standalone_commands.py b/python/python/glide/async_commands/standalone_commands.py index 83a99a2b8a..2b21fc9f1c 100644 --- a/python/python/glide/async_commands/standalone_commands.py +++ b/python/python/glide/async_commands/standalone_commands.py @@ -4,7 +4,8 @@ from typing import Dict, List, Mapping, Optional, cast -from glide.async_commands.core import CoreCommands, InfoSection +from glide.async_commands.command_args import Limit, OrderBy +from glide.async_commands.core import CoreCommands, InfoSection, _build_sort_args from glide.async_commands.transaction import BaseTransaction, Transaction from glide.constants import TOK, TResult from glide.protobuf.redis_request_pb2 import RequestType @@ -264,3 +265,125 @@ async def lastsave(self) -> int: int, await self._execute_command(RequestType.LastSave, []), ) + + async def sort( + self, + key: str, + by_pattern: Optional[str] = None, + limit: Optional[Limit] = None, + get_patterns: Optional[List[str]] = None, + order: Optional[OrderBy] = None, + alpha: Optional[bool] = None, + ) -> List[Optional[str]]: + """ + Sorts the elements in the list, set, or sorted set at `key` and returns the result. + The `sort` command can be used to sort elements based on different criteria and apply transformations on sorted elements. + To store the result into a new key, see `sort_store`. + + See https://valkey.io/commands/sort for more details. + + Args: + key (str): The key of the list, set, or sorted set to be sorted. + by_pattern (Optional[str]): A pattern to sort by external keys instead of by the elements stored at the key themselves. + The pattern should contain an asterisk (*) as a placeholder for the element values, where the value + from the key replaces the asterisk to create the key name. For example, if `key` contains IDs of objects, + `by_pattern` can be used to sort these IDs based on an attribute of the objects, like their weights or + timestamps. + E.g., if `by_pattern` is `weight_*`, the command will sort the elements by the values of the + keys `weight_`. + If not provided, elements are sorted by their value. + limit (Optional[Limit]): Limiting the range of the query by setting offset and result count. See `Limit` class for more information. + get_pattern (Optional[str]): A pattern used to retrieve external keys' values, instead of the elements at `key`. + The pattern should contain an asterisk (*) as a placeholder for the element values, where the value + from `key` replaces the asterisk to create the key name. This allows the sorted elements to be + transformed based on the related keys values. For example, if `key` contains IDs of users, `get_pattern` + can be used to retrieve specific attributes of these users, such as their names or email addresses. + E.g., if `get_pattern` is `name_*`, the command will return the values of the keys `name_` + for each sorted element. Multiple `get_pattern` arguments can be provided to retrieve multiple attributes. + The special value `#` can be used to include the actual element from `key` being sorted. + If not provided, only the sorted elements themselves are returned. + order (Optional[OrderBy]): Specifies the order to sort the elements. + Can be `OrderBy.ASC` (ascending) or `OrderBy.DESC` (descending). + alpha (Optional[bool]): When `True`, sorts elements lexicographically. When `False` (default), sorts elements numerically. + Use this when the list, set, or sorted set contains string values that cannot be converted into double precision floating point + + Returns: + List[Optional[str]]: Returns a list of sorted elements. + + Examples: + >>> await client.lpush("mylist", 3, 1, 2) + >>> await client.sort("mylist") + ['1', '2', '3'] + >>> await client.sort("mylist", order=OrderBy.DESC) + ['3', '2', '1'] + >>> await client.lpush("mylist2", 2, 1, 2, 3, 3, 1) + >>> await client.sort("mylist2", limit=Limit(2, 3)) + ['2', '2', '3'] + >>> await client.hset("user:1", "name", "Alice", "age", 30) + >>> await client.hset("user:2", "name", "Bob", "age", 25) + >>> await client.lpush("user_ids", 2, 1) + >>> await client.sort("user_ids", by_pattern="user:*->age", get_patterns=["user:*->name"]) + ['Bob', 'Alice'] + """ + args = _build_sort_args(key, by_pattern, limit, get_patterns, order, alpha) + result = await self._execute_command(RequestType.Sort, args) + return cast(List[Optional[str]], result) + + async def sort_store( + self, + key: str, + destination: str, + by_pattern: Optional[str] = None, + limit: Optional[Limit] = None, + get_patterns: Optional[List[str]] = None, + order: Optional[OrderBy] = None, + alpha: Optional[bool] = None, + ) -> int: + """ + Sorts the elements in the list, set, or sorted set at `key` and stores the result in `store`. + The `sort` command can be used to sort elements based on different criteria, apply transformations on sorted elements, and store the result in a new key. + To get the sort result without storing it into a key, see `sort`. + + See https://valkey.io/commands/sort for more details. + + Args: + key (str): The key of the list, set, or sorted set to be sorted. + destination (str): The key where the sorted result will be stored. + by_pattern (Optional[str]): A pattern to sort by external keys instead of by the elements stored at the key themselves. + The pattern should contain an asterisk (*) as a placeholder for the element values, where the value + from the key replaces the asterisk to create the key name. For example, if `key` contains IDs of objects, + `by_pattern` can be used to sort these IDs based on an attribute of the objects, like their weights or + timestamps. + E.g., if `by_pattern` is `weight_*`, the command will sort the elements by the values of the + keys `weight_`. + If not provided, elements are sorted by their value. + limit (Optional[Limit]): Limiting the range of the query by setting offset and result count. See `Limit` class for more information. + get_pattern (Optional[str]): A pattern used to retrieve external keys' values, instead of the elements at `key`. + The pattern should contain an asterisk (*) as a placeholder for the element values, where the value + from `key` replaces the asterisk to create the key name. This allows the sorted elements to be + transformed based on the related keys values. For example, if `key` contains IDs of users, `get_pattern` + can be used to retrieve specific attributes of these users, such as their names or email addresses. + E.g., if `get_pattern` is `name_*`, the command will return the values of the keys `name_` + for each sorted element. Multiple `get_pattern` arguments can be provided to retrieve multiple attributes. + The special value `#` can be used to include the actual element from `key` being sorted. + If not provided, only the sorted elements themselves are returned. + order (Optional[OrderBy]): Specifies the order to sort the elements. + Can be `OrderBy.ASC` (ascending) or `OrderBy.DESC` (descending). + alpha (Optional[bool]): When `True`, sorts elements lexicographically. When `False` (default), sorts elements numerically. + Use this when the list, set, or sorted set contains string values that cannot be converted into double precision floating point + + Returns: + int: The number of elements in the sorted key stored at `store`. + + Examples: + >>> await client.lpush("mylist", 3, 1, 2) + >>> await client.sort_store("mylist", "sorted_list") + 3 # Indicates that the sorted list "sorted_list" contains three elements. + >>> await client.lrange("sorted_list", 0, -1) + ['1', '2', '3'] + """ + args = _build_sort_args( + key, by_pattern, limit, get_patterns, order, alpha, store=destination + ) + result = await self._execute_command(RequestType.Sort, args) + return cast(int, result) diff --git a/python/python/glide/async_commands/transaction.py b/python/python/glide/async_commands/transaction.py index c5d4e3949e..c33497e7ed 100644 --- a/python/python/glide/async_commands/transaction.py +++ b/python/python/glide/async_commands/transaction.py @@ -3,6 +3,7 @@ import threading from typing import List, Mapping, Optional, Tuple, TypeVar, Union +from glide.async_commands.command_args import Limit, OrderBy from glide.async_commands.core import ( ConditionalChange, ExpireOptions, @@ -14,6 +15,7 @@ StreamAddOptions, StreamTrimOptions, UpdateOptions, + _build_sort_args, ) from glide.async_commands.sorted_set import ( AggregationType, @@ -2745,6 +2747,104 @@ def select(self, index: int) -> "Transaction": """ return self.append_command(RequestType.Select, [str(index)]) + def sort( + self: TTransaction, + key: str, + by_pattern: Optional[str] = None, + limit: Optional[Limit] = None, + get_patterns: Optional[List[str]] = None, + order: Optional[OrderBy] = None, + alpha: Optional[bool] = None, + ) -> TTransaction: + """ + Sorts the elements in the list, set, or sorted set at `key` and returns the result. + The `sort` command can be used to sort elements based on different criteria and apply transformations on sorted elements. + To store the result into a new key, see `sort_store`. + + See https://valkey.io/commands/sort for more details. + + Args: + key (str): The key of the list, set, or sorted set to be sorted. + by_pattern (Optional[str]): A pattern to sort by external keys instead of by the elements stored at the key themselves. + The pattern should contain an asterisk (*) as a placeholder for the element values, where the value + from the key replaces the asterisk to create the key name. For example, if `key` contains IDs of objects, + `by_pattern` can be used to sort these IDs based on an attribute of the objects, like their weights or + timestamps. + E.g., if `by_pattern` is `weight_*`, the command will sort the elements by the values of the + keys `weight_`. + If not provided, elements are sorted by their value. + limit (Optional[Limit]): Limiting the range of the query by setting offset and result count. See `Limit` class for more information. + get_pattern (Optional[str]): A pattern used to retrieve external keys' values, instead of the elements at `key`. + The pattern should contain an asterisk (*) as a placeholder for the element values, where the value + from `key` replaces the asterisk to create the key name. This allows the sorted elements to be + transformed based on the related keys values. For example, if `key` contains IDs of users, `get_pattern` + can be used to retrieve specific attributes of these users, such as their names or email addresses. + E.g., if `get_pattern` is `name_*`, the command will return the values of the keys `name_` + for each sorted element. Multiple `get_pattern` arguments can be provided to retrieve multiple attributes. + The special value `#` can be used to include the actual element from `key` being sorted. + If not provided, only the sorted elements themselves are returned. + order (Optional[OrderBy]): Specifies the order to sort the elements. + Can be `OrderBy.ASC` (ascending) or `OrderBy.DESC` (descending). + alpha (Optional[bool]): When `True`, sorts elements lexicographically. When `False` (default), sorts elements numerically. + Use this when the list, set, or sorted set contains string values that cannot be converted into double precision floating point numbers. + + Command response: + List[Optional[str]]: Returns a list of sorted elements. + """ + args = _build_sort_args(key, by_pattern, limit, get_patterns, order, alpha) + return self.append_command(RequestType.Sort, args) + + def sort_store( + self: TTransaction, + key: str, + destination: str, + by_pattern: Optional[str] = None, + limit: Optional[Limit] = None, + get_patterns: Optional[List[str]] = None, + order: Optional[OrderBy] = None, + alpha: Optional[bool] = None, + ) -> TTransaction: + """ + Sorts the elements in the list, set, or sorted set at `key` and stores the result in `store`. + The `sort` command can be used to sort elements based on different criteria, apply transformations on sorted elements, and store the result in a new key. + To get the sort result without storing it into a key, see `sort`. + + See https://valkey.io/commands/sort for more details. + + Args: + key (str): The key of the list, set, or sorted set to be sorted. + destination (str): The key where the sorted result will be stored. + by_pattern (Optional[str]): A pattern to sort by external keys instead of by the elements stored at the key themselves. + The pattern should contain an asterisk (*) as a placeholder for the element values, where the value + from the key replaces the asterisk to create the key name. For example, if `key` contains IDs of objects, + `by_pattern` can be used to sort these IDs based on an attribute of the objects, like their weights or + timestamps. + E.g., if `by_pattern` is `weight_*`, the command will sort the elements by the values of the + keys `weight_`. + If not provided, elements are sorted by their value. + limit (Optional[Limit]): Limiting the range of the query by setting offset and result count. See `Limit` class for more information. + get_pattern (Optional[str]): A pattern used to retrieve external keys' values, instead of the elements at `key`. + The pattern should contain an asterisk (*) as a placeholder for the element values, where the value + from `key` replaces the asterisk to create the key name. This allows the sorted elements to be + transformed based on the related keys values. For example, if `key` contains IDs of users, `get_pattern` + can be used to retrieve specific attributes of these users, such as their names or email addresses. + E.g., if `get_pattern` is `name_*`, the command will return the values of the keys `name_` + for each sorted element. Multiple `get_pattern` arguments can be provided to retrieve multiple attributes. + The special value `#` can be used to include the actual element from `key` being sorted. + If not provided, only the sorted elements themselves are returned. + order (Optional[OrderBy]): Specifies the order to sort the elements. + Can be `OrderBy.ASC` (ascending) or `OrderBy.DESC` (descending). + alpha (Optional[bool]): When `True`, sorts elements lexicographically. When `False` (default), sorts elements numerically. + Use this when the list, set, or sorted set contains string values that cannot be converted into double precision floating point numbers. + + Command response: + int: The number of elements in the sorted key stored at `store`. + """ + args = _build_sort_args( + key, by_pattern, limit, get_patterns, order, alpha, store=destination + ) + return self.append_command(RequestType.Sort, args) + class ClusterTransaction(BaseTransaction): """ @@ -2755,5 +2855,61 @@ class ClusterTransaction(BaseTransaction): are documented alongside each method. """ + def sort( + self: TTransaction, + key: str, + limit: Optional[Limit] = None, + order: Optional[OrderBy] = None, + alpha: Optional[bool] = None, + ) -> TTransaction: + """ + Sorts the elements in the list, set, or sorted set at `key` and returns the result. + To store the result into a new key, see `sort_store`. + + See https://valkey.io/commands/sort for more details. + + Args: + key (str): The key of the list, set, or sorted set to be sorted. + limit (Optional[Limit]): Limiting the range of the query by setting offset and result count. See `Limit` class for more information. + order (Optional[OrderBy]): Specifies the order to sort the elements. + Can be `OrderBy.ASC` (ascending) or `OrderBy.DESC` (descending). + alpha (Optional[bool]): When `True`, sorts elements lexicographically. When `False` (default), sorts elements numerically. + Use this when the list, set, or sorted set contains string values that cannot be converted into double precision floating point numbers. + + Command response: + List[str]: A list of sorted elements. + """ + args = _build_sort_args(key, None, limit, None, order, alpha) + return self.append_command(RequestType.Sort, args) + + def sort_store( + self: TTransaction, + key: str, + destination: str, + limit: Optional[Limit] = None, + order: Optional[OrderBy] = None, + alpha: Optional[bool] = None, + ) -> TTransaction: + """ + Sorts the elements in the list, set, or sorted set at `key` and stores the result in `store`. + When in cluster mode, `key` and `store` must map to the same hash slot. + To get the sort result without storing it into a key, see `sort`. + + See https://valkey.io/commands/sort for more details. + + Args: + key (str): The key of the list, set, or sorted set to be sorted. + destination (str): The key where the sorted result will be stored. + limit (Optional[Limit]): Limiting the range of the query by setting offset and result count. See `Limit` class for more information. + order (Optional[OrderBy]): Specifies the order to sort the elements. + Can be `OrderBy.ASC` (ascending) or `OrderBy.DESC` (descending). + alpha (Optional[bool]): When `True`, sorts elements lexicographically. When `False` (default), sorts elements numerically. + Use this when the list, set, or sorted set contains string values that cannot be converted into double precision floating point numbers. + + Command response: + int: The number of elements in the sorted key stored at `store`. + """ + args = _build_sort_args(key, None, limit, None, order, alpha, store=destination) + return self.append_command(RequestType.Sort, args) + # TODO: add all CLUSTER commands - pass diff --git a/python/python/tests/test_async_client.py b/python/python/tests/test_async_client.py index bd1a51366a..48abd147fa 100644 --- a/python/python/tests/test_async_client.py +++ b/python/python/tests/test_async_client.py @@ -11,6 +11,7 @@ import pytest from glide import ClosingError, RequestError, Script +from glide.async_commands.command_args import Limit, OrderBy from glide.async_commands.core import ( ConditionalChange, ExpireOptions, @@ -3416,6 +3417,146 @@ async def test_type(self, redis_client: TRedisClient): assert (await redis_client.type(key)).lower() == "none" + @pytest.mark.parametrize("cluster_mode", [False]) + @pytest.mark.parametrize("protocol", [ProtocolVersion.RESP2, ProtocolVersion.RESP3]) + async def test_sort_and_sort_store_with_get_or_by_args( + self, redis_client: RedisClient + ): + key = "{SameSlotKey}" + get_random_string(10) + store = "{SameSlotKey}" + get_random_string(10) + user_key1, user_key2, user_key3, user_key4, user_key5 = ( + "user:1", + "user:2", + "user:3", + "user:4", + "user:5", + ) + + # Prepare some data + assert await redis_client.hset(user_key1, {"name": "Alice", "age": "30"}) == 2 + assert await redis_client.hset(user_key2, {"name": "Bob", "age": "25"}) == 2 + assert await redis_client.hset(user_key3, {"name": "Charlie", "age": "35"}) == 2 + assert await redis_client.hset(user_key4, {"name": "Dave", "age": "20"}) == 2 + assert await redis_client.hset(user_key5, {"name": "Eve", "age": "40"}) == 2 + assert await redis_client.lpush("user_ids", ["5", "4", "3", "2", "1"]) == 5 + + # Test sort with all arguments + assert await redis_client.lpush(key, ["3", "1", "2"]) == 3 + result = await redis_client.sort( + key, + limit=Limit(0, 2), + get_patterns=["user:*->name"], + order=OrderBy.ASC, + alpha=True, + ) + assert result == ["Alice", "Bob"] + + # Test sort_store with all arguments + sort_store_result = await redis_client.sort_store( + key, + store, + limit=Limit(0, 2), + get_patterns=["user:*->name"], + order=OrderBy.ASC, + alpha=True, + ) + assert sort_store_result == 2 + sorted_list = await redis_client.lrange(store, 0, -1) + assert sorted_list == ["Alice", "Bob"] + + # Test sort with `by` argument + result = await redis_client.sort( + "user_ids", + by_pattern="user:*->age", + get_patterns=["user:*->name"], + alpha=True, + ) + assert result == ["Dave", "Bob", "Alice", "Charlie", "Eve"] + + # Test sort with `by` argument with missing keys to sort by + assert await redis_client.lpush("user_ids", ["a"]) == 6 + result = await redis_client.sort( + "user_ids", + by_pattern="user:*->age", + get_patterns=["user:*->name"], + alpha=True, + ) + assert result == [None, "Dave", "Bob", "Alice", "Charlie", "Eve"] + + # Test sort with `by` argument with missing keys to sort by + result = await redis_client.sort( + "user_ids", + by_pattern="user:*->name", + get_patterns=["user:*->age"], + alpha=True, + ) + assert result == [None, "30", "25", "35", "20", "40"] + + # Test Limit with count 0 + result = await redis_client.sort( + "user_ids", + limit=Limit(0, 0), + alpha=True, + ) + assert result == [] + + @pytest.mark.parametrize("cluster_mode", [True, False]) + @pytest.mark.parametrize("protocol", [ProtocolVersion.RESP2, ProtocolVersion.RESP3]) + async def test_sort_and_sort_store_without_get_or_by_args( + self, redis_client: TRedisClient + ): + key = "{SameSlotKey}" + get_random_string(10) + store = "{SameSlotKey}" + get_random_string(10) + + # Test sort with non-existing key + result = await redis_client.sort("non_existing_key") + assert result == [] + + # Test sort_store with non-existing key + sort_store_result = await redis_client.sort_store( + "{SameSlotKey}:non_existing_key", store + ) + assert sort_store_result == 0 + + # Test each argument separately + assert await redis_client.lpush(key, ["5", "2", "4", "1", "3"]) == 5 + + # Test w/o flags + result = await redis_client.sort(key) + assert result == ["1", "2", "3", "4", "5"] + + # limit argument + result = await redis_client.sort(key, limit=Limit(1, 3)) + assert result == ["2", "3", "4"] + + # order argument + result = await redis_client.sort(key, order=OrderBy.DESC) + assert result == ["5", "4", "3", "2", "1"] + + assert await redis_client.lpush(key, ["a"]) == 6 + + with pytest.raises(RequestError) as e: + await redis_client.sort(key) + assert "can't be converted into double" in str(e).lower() + + # alpha argument + result = await redis_client.sort(key, alpha=True) + assert result == ["1", "2", "3", "4", "5", "a"] + + # Combining multiple arguments + result = await redis_client.sort( + key, limit=Limit(1, 3), order=OrderBy.DESC, alpha=True + ) + assert result == ["5", "4", "3"] + + # Test sort_store with combined arguments + sort_store_result = await redis_client.sort_store( + key, store, limit=Limit(1, 3), order=OrderBy.DESC, alpha=True + ) + assert sort_store_result == 3 + sorted_list = await redis_client.lrange(store, 0, -1) + assert sorted_list == ["5", "4", "3"] + @pytest.mark.parametrize("cluster_mode", [True, False]) @pytest.mark.parametrize("protocol", [ProtocolVersion.RESP2, ProtocolVersion.RESP3]) async def test_echo(self, redis_client: TRedisClient): @@ -3791,6 +3932,7 @@ async def test_multi_key_command_returns_cross_slot_error( redis_client.zinter_withscores(["def", "ghi"]), redis_client.zunion(["def", "ghi"]), redis_client.zunion_withscores(["def", "ghi"]), + redis_client.sort_store("abc", "zxy"), ] if not await check_if_server_version_lt(redis_client, "7.0.0"): diff --git a/python/python/tests/test_transaction.py b/python/python/tests/test_transaction.py index 3891c97b28..3ce0a3acf5 100644 --- a/python/python/tests/test_transaction.py +++ b/python/python/tests/test_transaction.py @@ -6,6 +6,7 @@ import pytest from glide import RequestError +from glide.async_commands.command_args import Limit, OrderBy from glide.async_commands.core import ( GeospatialData, InsertPosition, @@ -53,6 +54,8 @@ async def transaction_test( key14 = "{{{}}}:{}".format(keyslot, get_random_string(3)) # sorted set key15 = "{{{}}}:{}".format(keyslot, get_random_string(3)) # sorted set key16 = "{{{}}}:{}".format(keyslot, get_random_string(3)) # sorted set + key17 = "{{{}}}:{}".format(keyslot, get_random_string(3)) # sort + key18 = "{{{}}}:{}".format(keyslot, get_random_string(3)) # sort value = datetime.now(timezone.utc).strftime("%m/%d/%Y, %H:%M:%S") value2 = get_random_string(5) @@ -362,6 +365,24 @@ async def transaction_test( transaction.xtrim(key11, TrimByMinId(threshold="0-2", exact=True)) args.append(1) + transaction.lpush(key17, ["2", "1", "4", "3", "a"]) + args.append(5) + transaction.sort( + key17, + limit=Limit(1, 4), + order=OrderBy.ASC, + alpha=True, + ) + args.append(["2", "3", "4", "a"]) + transaction.sort_store( + key17, + key18, + limit=Limit(1, 4), + order=OrderBy.ASC, + alpha=True, + ) + args.append(4) + min_version = "7.0.0" if not await check_if_server_version_lt(redis_client, min_version): transaction.zadd(key16, {"a": 1, "b": 2, "c": 3, "d": 4}) @@ -496,12 +517,31 @@ async def test_standalone_transaction(self, redis_client: RedisClient): assert await redis_client.custom_command(["FLUSHALL"]) == OK keyslot = get_random_string(3) key = "{{{}}}:{}".format(keyslot, get_random_string(3)) # to get the same slot + key1 = "{{{}}}:{}".format(keyslot, get_random_string(3)) # to get the same slot value = get_random_string(5) transaction = Transaction() transaction.info() transaction.select(1) transaction.set(key, value) transaction.get(key) + transaction.hset("user:1", {"name": "Alice", "age": "30"}) + transaction.hset("user:2", {"name": "Bob", "age": "25"}) + transaction.lpush(key1, ["2", "1"]) + transaction.sort( + key1, + by_pattern="user:*->age", + get_patterns=["user:*->name"], + order=OrderBy.ASC, + alpha=True, + ) + transaction.sort_store( + key1, + "newSortedKey", + by_pattern="user:*->age", + get_patterns=["user:*->name"], + order=OrderBy.ASC, + alpha=True, + ) transaction.select(0) transaction.get(key) expected = await transaction_test(transaction, keyslot, redis_client) @@ -509,8 +549,9 @@ async def test_standalone_transaction(self, redis_client: RedisClient): assert isinstance(result, list) assert isinstance(result[0], str) assert "# Memory" in result[0] - assert result[1:6] == [OK, OK, value, OK, None] - assert result[6:] == expected + assert result[1:4] == [OK, OK, value] + assert result[4:11] == [2, 2, 2, ["Bob", "Alice"], 2, OK, None] + assert result[11:] == expected def test_transaction_clear(self): transaction = Transaction() diff --git a/submodules/redis-rs b/submodules/redis-rs index e28a97877f..3cf4a69c7c 160000 --- a/submodules/redis-rs +++ b/submodules/redis-rs @@ -1 +1 @@ -Subproject commit e28a97877fc353497b35a0cd033bf47cb199b47e +Subproject commit 3cf4a69c7c6ff5bd7990cb42122d7d09c2124967