diff --git a/.github/workflows/full-test-suite.yml b/.github/workflows/full-test-suite.yml index 864d87846..7007a4d7c 100644 --- a/.github/workflows/full-test-suite.yml +++ b/.github/workflows/full-test-suite.yml @@ -2,8 +2,26 @@ name: dice-test-suite on: push: branches: [master] + paths-ignore: + - "**.md" + - "docs/**" + - ".github/**" + - "**.txt" + - "**.json" + - "**.yaml" + - "**.yml" + - "LICENSE" pull_request: branches: [master] + paths-ignore: + - "**.md" + - "docs/**" + - ".github/**" + - "**.txt" + - "**.json" + - "**.yaml" + - "**.yml" + - "LICENSE" jobs: build: @@ -22,4 +40,4 @@ jobs: - name: Run Unit tests run: make unittest - name: Run Integration tests - run: make test \ No newline at end of file + run: make test diff --git a/.gitignore b/.gitignore index fb0f6a316..e7a3fff46 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,8 @@ dicedb venv __pycache__ .idea/ +./dice +*.rdb dice # build output diff --git a/config/config.go b/config/config.go index 12a9f872f..83c7f25b6 100644 --- a/config/config.go +++ b/config/config.go @@ -24,6 +24,8 @@ const ( EvictAllKeysRandom = "allkeys-random" EvictAllKeysLRU = "allkeys-lru" EvictAllKeysLFU = "allkeys-lfu" + + DefaultKeysLimit int = 200000000 ) var ( @@ -44,6 +46,8 @@ var ( FileLocation = utils.EmptyStr InitConfigCmd = false + + KeysLimit = DefaultKeysLimit ) type Config struct { @@ -114,7 +118,7 @@ var baseConfig = Config{ MaxMemory: 0, EvictionPolicy: EvictAllKeysLFU, EvictionRatio: 0.9, - KeysLimit: 200000000, + KeysLimit: DefaultKeysLimit, AOFFile: "./dice-master.aof", PersistenceEnabled: true, WriteAOFOnCleanup: false, @@ -293,6 +297,10 @@ func mergeFlagsWithConfig() { if Port != DefaultPort { DiceConfig.Server.Port = Port } + + if KeysLimit != DefaultKeysLimit { + DiceConfig.Server.KeysLimit = KeysLimit + } } // This function checks if the config file is present or not at ConfigFileLocation diff --git a/docs/src/content/docs/commands/GETDEL.md b/docs/src/content/docs/commands/GETDEL.md index 5258729b4..7cdd54ad5 100644 --- a/docs/src/content/docs/commands/GETDEL.md +++ b/docs/src/content/docs/commands/GETDEL.md @@ -1,93 +1,93 @@ --- title: GETDEL -description: Documentation for the DiceDB command GETDEL +description: The `GETDEL` command in DiceDB is used to retrieve the value of a specified key and then delete the key from the database. This command is useful when you need to fetch a value and ensure that it is removed from the database in a single atomic operation. --- The `GETDEL` command in DiceDB is used to retrieve the value of a specified key and then delete the key from the database. This command is useful when you need to fetch a value and ensure that it is removed from the database in a single atomic operation. ## Syntax -```plaintext +``` GETDEL key ``` ## Parameters -- `key`: The key whose value you want to retrieve and delete. This parameter is a string and must be a valid key in the DiceDB database. +| Parameter | Description | Type | Required | +|-----------|---------------------------------------------------------------------------|---------|----------| +| `key` | The key whose value you want to retrieve and delete. | String | Yes | -## Return Value +## Return values -- `String`: If the key exists, the command returns the value associated with the key. -- `nil`: If the key does not exist, the command returns `nil`. +| Condition | Return Value | +|----------------------|------------------------------------------------------------------| +| Key exists | `String`: The command returns the value associated with the key. | +| Key does not exist | `nil`: The command returns `nil`. | ## Behaviour When the `GETDEL` command is executed, the following steps occur: + 1. The command checks if the specified key exists in the DiceDB database. + 2. If the key exists, the value associated with the key is retrieved. + 3. The key is then deleted from the database. + 4. The retrieved value is returned to the client. + 5. If the key does not exist, `nil` is returned, and no deletion occurs. -1. The command checks if the specified key exists in the DiceDB database. -2. If the key exists, the value associated with the key is retrieved. -3. The key is then deleted from the database. -4. The retrieved value is returned to the client. -5. If the key does not exist, `nil` is returned, and no deletion occurs. - -## Error Handling +## Errors The `GETDEL` command can raise errors in the following scenarios: -1. `Wrong Type Error`: If the key exists but is not a string (e.g., it is a list, set, hash, etc.), a `WRONGTYPE` error will be raised. -2. `Syntax Error`: If the command is called without the required parameter, a syntax error will be raised. +1. `Wrong Type Error`: -## Example Usage + - Error Message: `ERROR WRONGTYPE Operation against a key holding the wrong kind of value` + - Occurs if the key exists but is not a string (e.g., it is a list, set, hash, etc.). -### Example 1: Key Exists +2. `Syntax Error`: -```plaintext -SET mykey "Hello, World!" -GETDEL mykey -``` + - Error Message: `ERROR wrong number of arguments for 'getdel' command` + - Occurs if the command is called without the required parameter. -`Output:` +## Examples -```plaintext +### Example with Existent key + +```bash +127.0.0.1:7379> SET mykey "Hello, World!" +OK +127.0.0.1:7379> GETDEL mykey "Hello, World!" +127.0.0.1:7379> GET mykey +(nil) ``` -`Explanation:` +`Explanation:` - The key `mykey` is set with the value `"Hello, World!"`. - The `GETDEL` command retrieves the value `"Hello, World!"` and deletes the key `mykey` from the database. +- The `GET` command attempts to retrieve the value associated with the key `mykey` and returns `nil` as the key no longer exists. -### Example 2: Key Does Not Exist +### Example with a Non-Existent Key -```plaintext -GETDEL nonexistingkey -``` - -`Output:` - -```plaintext +```bash +127.0.0.1:7379> GETDEL nonexistingkey (nil) ``` -`Explanation:` +`Explanation:` - The key `nonexistingkey` does not exist in the database. - The `GETDEL` command returns `nil` since the key is not found. -### Example 3: Key of Wrong Type - -```plaintext -LPUSH mylist "item1" -GETDEL mylist -``` - -`Output:` +### Example with a Wrong Type of Key -```plaintext -(error) WRONGTYPE Operation against a key holding the wrong kind of value +```bash +127.0.0.1:7379> LPUSH mylist "item1" +(integer) 1 +127.0.0.1:7379> GETDEL mylist +ERROR WRONGTYPE Operation against a key holding the wrong kind of value ``` -`Explanation:` +`Explanation:` - The key `mylist` is a list, not a string. -- The `GETDEL` command raises a `WRONGTYPE` error because it expects the key to be a string. +- The `GETDEL` command raises a `WRONGTYPE` error because it expects the key to be a string. \ No newline at end of file diff --git a/docs/src/content/docs/commands/JSON.CLEAR.md b/docs/src/content/docs/commands/JSON.CLEAR.md index 253bdbfb4..a3c46e41f 100644 --- a/docs/src/content/docs/commands/JSON.CLEAR.md +++ b/docs/src/content/docs/commands/JSON.CLEAR.md @@ -3,16 +3,27 @@ title: JSON.CLEAR description: Documentation for the DiceDB command JSON.CLEAR --- -The `JSON.CLEAR` command is part of the DiceDBJSON module, which allows you to manipulate JSON data stored in DiceDB. This command is used to clear the value at a specified path in a JSON document, effectively setting it to an empty state. This can be particularly useful when you want to reset a part of your JSON document without removing the key itself. +The `JSON.CLEAR` command allows you to manipulate JSON data stored in DiceDB. This command is used to clear the value at a specified path in a JSON document, effectively setting it to an empty state. This can be particularly useful when you want to reset a part of your JSON document without removing the key itself. + +## Syntax + +``` +JSON.CLEAR key [path] +``` ## Parameters -- `key`: (String) The key under which the JSON document is stored. -- `path`: (String) The path within the JSON document that you want to clear. The path should be specified in JSONPath format. If the path is omitted, the root path (`$`) is assumed. +| Parameter | Description | Type | Required | +|-----------|---------------------------------------------------------------------------|---------|----------| +| `key` | (String) The key under which the JSON document is stored. | String | Yes | +| `path` | (String) The path within the JSON document that you want to clear. The path should be specified in JSONPath format. If the path is omitted, the root path (`$`) is assumed. | String | No | -## Return Value +## Return values -- `Integer`: The number of paths that were cleared. +| Condition | Return Value | +|------------------------------------------------|---------------------------------------------------| +| Command is successful | `Integer` (The number of paths that were cleared) | +| Syntax or specified constraints are invalid | error | ## Behaviour @@ -20,99 +31,65 @@ When the `JSON.CLEAR` command is executed, it traverses the JSON document stored - For objects, it removes all key-value pairs. - For arrays, it removes all elements. -- For strings, it sets the value to an empty string. +- For strings, the value remaines unchanged. - For numbers, it sets the value to `0`. -- For booleans, it sets the value to `false`. +- For booleans, the value remaines unchanged. If the specified path does not exist, the command does nothing and returns `0`. -## Error Handling +## Errors The `JSON.CLEAR` command can raise the following errors: - `(error) ERR wrong number of arguments for 'json.clear' command`: This error is raised if the command is called with an incorrect number of arguments. -- `(error) ERR key does not exist`: This error is raised if the specified key does not exist in the DiceDB database. -- `(error) ERR path is not a valid JSONPath`: This error is raised if the specified path is not a valid JSONPath expression. -- `(error) ERR path does not exist`: This error is raised if the specified path does not exist within the JSON document. - -## Example Usage +- `(error) ERR could not perform this operation on a key that doesn't exist`: This error is raised if the specified key does not exist in the DiceDB database. +- `(error) ERR invalid JSONPath`: This error is raised if the specified path is not a valid JSONPath expression. +- `(error) ERR Existing key has wrong Dice type`: This error is raised if the key exists but the value is not of the expected JSON type and encoding. -### Example 1: Clearing a JSON Object - -Suppose you have a JSON document stored under the key `user:1001`: - -```json -{ - "name": "John Doe", - "age": 30, - "address": { - "street": "123 Main St", - "city": "Anytown" - } -} -``` - -To clear the `address` object, you would use the following command: - -```sh -JSON.CLEAR user:1001 $.address -``` +Note: If the specified path does not exist within the JSON document, the command will not raise an error but will simply not modify anything. -After executing this command, the JSON document would be: - -```json -{ - "name": "John Doe", - "age": 30, - "address": {} -} -``` - -### Example 2: Clearing an Array - -Suppose you have a JSON document stored under the key `user:1002`: - -```json -{ - "name": "Jane Doe", - "hobbies": ["reading", "swimming", "hiking"] -} -``` - -To clear the `hobbies` array, you would use the following command: - -```sh -JSON.CLEAR user:1002 $.hobbies -``` +## Example Usage -After executing this command, the JSON document would be: +### Clearing a JSON Object -```json -{ - "name": "Jane Doe", - "hobbies": [] -} +```bash +127.0.0.1:7379> JSON.SET user:1001 $ '{"name": "John Doe", "age": 30, "address": {"street": "123 Main St", "city": "Anytown"}}' +OK +127.0.0.1:7379> JSON.CLEAR user:1001 $.address +(integer) 1 +127.0.0.1:7379> JSON.GET user:1001 +"{\"name\":\"John Doe\",\"age\":30,\"address\":{}}" ``` -### Example 3: Clearing the Root Path - -Suppose you have a JSON document stored under the key `user:1003`: +### Clearing a Number -```json -{ - "name": "Alice", - "age": 25 -} +```bash +127.0.0.1:7379> JSON.SET user:1001 $ '{"name": "John Doe", "age": 30, "address": {"street": "123 Main St", "city": "Anytown"}}' +OK +127.0.0.1:7379> JSON.CLEAR user:1001 $.age +(integer) 1 +127.0.0.1:7379> JSON.GET user:1001 +"{\"name\":\"John Doe\",\"age\":0,\"address\":{\"street\":\"123 Main St\",\"city\":\"Anytown\"}}" ``` -To clear the entire JSON document, you would use the following command: +### Clearing an Array -```sh -JSON.CLEAR user:1003 +```bash +127.0.0.1:7379> JSON.SET user:1002 $ '{"name": "Jane Doe", "hobbies": ["reading", "swimming", "hiking"]}' +OK +127.0.0.1:7379> JSON.CLEAR user:1002 $.hobbies +(integer) 1 +127.0.0.1:7379> JSON.GET user:1002 +"{\"name\":\"Jane Doe\",\"hobbies\":[]}" ``` -After executing this command, the JSON document would be: +### Clearing the Root Path -```json -{} +```bash +127.0.0.1:7379> JSON.SET user:1003 $ '{"name": "Alice", "age": 25}' +OK +127.0.0.1:7379> JSON.CLEAR user:1003 +(integer) 1 +127.0.0.1:7379> JSON.GET user:1003 +"{}" ``` diff --git a/docs/src/content/docs/commands/JSON.FORGET.md b/docs/src/content/docs/commands/JSON.FORGET.md index 516f6ea46..6e77463f4 100644 --- a/docs/src/content/docs/commands/JSON.FORGET.md +++ b/docs/src/content/docs/commands/JSON.FORGET.md @@ -3,90 +3,80 @@ title: JSON.FORGET description: Documentation for the DiceDB command JSON.FORGET --- -The `JSON.FORGET` command is part of the DiceDBJSON module, which allows you to manipulate JSON data stored in DiceDB. This command is used to delete a specified path from a JSON document stored at a given key. If the path leads to an array element, the element is removed, and the array is reindexed. +The `JSON.FORGET` command in DiceDB is used to delete a specified path from a JSON document stored at a given key. If the path leads to an array element, the element is removed, and the array is reindexed. This is useful for modifying and updating portions of a JSON document. + +## Syntax + +``` +JSON.FORGET key path +``` ## Parameters -- `key`: (String) The key under which the JSON document is stored. -- `path`: (String) The JSONPath expression specifying the part of the JSON document to be deleted. The path must be a valid JSONPath expression. +| Parameter | Description | Type | Required | +|-----------|------------------------------------------------------------------------------|---------|----------| +| `key` | The name of the key under which the JSON document is stored. | String | Yes | +| `path` | The JSONPath expression specifying the part of the JSON document to delete. | String | Yes | -## Return Value +## Return values -- `Integer`: The number of paths that were deleted. If the specified path does not exist, the command returns `0`. +| Condition | Return Value | +|--------------------------------------|-----------------------------------------| +| Command successfully deletes a path | `The number of paths deleted (Integer)` | +| Path does not exist | `0` | +| Invalid key or path | error | ## Behaviour -When the `JSON.FORGET` command is executed, the following actions occur: +- The command locates the JSON document stored at the specified key. +- It evaluates the provided JSONPath expression to identify the part of the document to be deleted. +- If the specified path exists, the targeted part is deleted. +- If the path leads to an array element, the element is removed, and the array is reindexed. +- The command returns the number of paths that were successfully deleted. -1. The command locates the JSON document stored at the specified key. -2. It evaluates the provided JSONPath expression to identify the part of the document to be deleted. -3. If the path is valid and exists, the specified part of the JSON document is removed. -4. If the path leads to an array element, the element is removed, and the array is reindexed. -5. The command returns the number of paths that were successfully deleted. +## Errors -## Error Handling +1. `Wrong number of arguments`: -The `JSON.FORGET` command can raise the following errors: + - Error Message: `(error) ERR wrong number of arguments for 'JSON.FORGET' command` + - Occurs when the command is called with an incorrect number of arguments. -- `(error) ERR wrong number of arguments for 'JSON.FORGET' command`: This error occurs if the command is called with an incorrect number of arguments. -- `(error) ERR key does not exist`: This error occurs if the specified key does not exist in the DiceDB database. -- `(error) ERR invalid path`: This error occurs if the provided JSONPath expression is invalid or malformed. -- `(error) ERR path does not exist`: This error occurs if the specified path does not exist within the JSON document. +2. `Key does not exist`: -## Example Usage + - Error Message: `(error) ERR key does not exist` + - Occurs if the specified key is not present in the database. -### Example 1: Deleting a Field from a JSON Document +3. `Invalid JSONPath expression`: -Suppose we have a JSON document stored at the key `user:1001`: + - Error Message: `(error) ERR invalid path` + - Occurs when the provided JSONPath expression is invalid or malformed. -```json -{ - "name": "John Doe", - "age": 30, - "address": { - "street": "123 Main St", - "city": "Anytown" - } -} -``` - -To delete the `age` field from this document, you would use the following command: +4. `Path does not exist`: -```sh -JSON.FORGET user:1001 $.age -``` - -`Expected Output:` + - Error Message: `(error) ERR path does not exist` + - Occurs if the specified path does not exist within the JSON document. -```sh -(integer) 1 -``` +## Example Usage -### Example 2: Deleting an Element from a JSON Array +### Basic Usage -Suppose we have a JSON document stored at the key `user:1002`: +Deleting the `age` field from a JSON document stored at key `user:1001`: -```json -{ - "name": "Jane Doe", - "hobbies": ["reading", "swimming", "hiking"] -} +```bash +127.0.0.1:7379> JSON.FORGET user:1001 $.age +(integer) 1 ``` -To delete the second element (`"swimming"`) from the `hobbies` array, you would use the following command: +### Deleting an Element from a JSON Array -```sh -JSON.FORGET user:1002 $.hobbies[1] -``` - -`Expected Output:` +Deleting the second element (`"swimming"`) from the `hobbies` array in the JSON document stored at key `user:1002`: -```sh +```bash +127.0.0.1:7379> JSON.FORGET user:1002 $.hobbies[1] (integer) 1 ``` -The updated JSON document would be: - +#### The updated document: ```json { "name": "Jane Doe", @@ -94,16 +84,11 @@ The updated JSON document would be: } ``` -### Example 3: Deleting a Non-Existent Path - -Suppose we have the same JSON document as in Example 1. If you attempt to delete a non-existent path: - -```sh -JSON.FORGET user:1001 $.nonexistent -``` +### Deleting a Non-Existent Path -`Expected Output:` +Attempting to delete a non-existent path in the document at key `user:1001`: -```sh +```bash +127.0.0.1:7379> JSON.FORGET user:1001 $.nonexistent (integer) 0 ``` diff --git a/docs/src/content/docs/commands/LPUSH.md b/docs/src/content/docs/commands/LPUSH.md index c20bb4ecb..6592e2e2f 100644 --- a/docs/src/content/docs/commands/LPUSH.md +++ b/docs/src/content/docs/commands/LPUSH.md @@ -1,6 +1,6 @@ --- title: LPUSH -description: Documentation for the DiceDB command LPUSH +description: The `LPUSH` command is used to insert one or multiple values at the head (left) of a list stored at a specified key. If the key does not exist, a new list is created before performing the push operations. If the key exists but is not a list, an error is returned. --- The `LPUSH` command is used to insert one or multiple values at the head (left) of a list stored at a specified key. If the key does not exist, a new list is created before performing the push operations. If the key exists but is not a list, an error is returned. @@ -13,64 +13,78 @@ LPUSH key value [value ...] ## Parameters -- `key`: The name of the list where the values will be inserted. If the list does not exist, it will be created. -- `value`: One or more values to be inserted at the head of the list. Multiple values can be specified, and they will be inserted in the order they are provided, from left to right. +| Parameter | Description | Type | Required | +| ------------------ | ----------------------------------------------------------------------------------------- | ------ | -------- | +| `key` | The name of the list where values are inserted. If it does not exist, it will be created. | String | Yes | +| `value [value...]` | One or more values to be inserted at the head of the list. | String | Yes | ## Return Value -The command returns an integer representing the length of the list after the push operations. +| Condition | Return Value | +| ------------------------------------------- | ---------------------------------------------- | +| Command is successful | `Integer` - length of the list after execution | +| Syntax or specified constraints are invalid | error | ## Behaviour -When the `LPUSH` command is executed, the specified values are inserted at the head of the list. If multiple values are provided, they are inserted in the order they are given, with the leftmost value being the first to be inserted. If the key does not exist, a new list is created. If the key exists but is not a list, an error is returned. +- The specified values are inserted at the head of the list provided by the key. +- If multiple values are provided, they are inserted in the order they are given, with the leftmost value being the first to be inserted. +- If the key does not exist, a new list is created. +- If the key exists but is not a list, an error is returned. -## Error Handling +## Errors -- `WRONGTYPE Operation against a key holding the wrong kind of value`: This error is returned if the key exists and is not a list. DiceDB expects the key to either be non-existent or to hold a list data type. +1. `Wrong Number of Arguments` + + - Error Message: `(error) ERR wrong number of arguments for 'lpush' command` + - Occurs if the key parameters is not provided or at least one value is not provided. + +2. `Wrong Type of Key or Value`: + + - Error Message: `(error) WRONGTYPE Operation against a key holding the wrong kind of value` + - Occurs if the key exists and is not a list. DiceDB expects the key to either be non-existent or to hold a list data type. ## Example Usage ### Single Value Insertion +Insert the value `world` at the head of the list stored at key `mylist`. If `mylist` does not exist, a new list is created. + ```shell -LPUSH mylist "world" +127.0.0.1:7379> LPUSH mylist "world" +(integer) 1 ``` -`Description`: Inserts the value "world" at the head of the list stored at key `mylist`. If `mylist` does not exist, a new list is created. - -`Return Value`: `1` (since the list now contains one element) - ### Multiple Values Insertion +Insert the value `hello` and `world` at the head of the list stored at key `mylist`. After execution, `world` will be the first element, followed by `hello`. + + ```shell -LPUSH mylist "hello" "world" +127.0.0.1:7379> LPUSH mylist "hello" "world" +(integer) 2 ``` -`Description`: Inserts the values "hello" and "world" at the head of the list stored at key `mylist`. "hello" will be the first element, followed by "world". - -`Return Value`: `3` (assuming `mylist` already contained one element before the operation) - ### Creating a New List +Create a new list with the key `newlist` and inserts the value `first` at the head. + ```shell -LPUSH newlist "first" +127.0.0.1:7379> LPUSH newlist "first" +(integer) 1 ``` -`Description`: Creates a new list with the key `newlist` and inserts the value "first" at the head. +### Error Case - Wrong Type -`Return Value`: `1` (since the list now contains one element) - -### Error Case +Insert the value `value` at the head of the key `mystring`, which stores a string, not a list. ```shell -SET mystring "not a list" -LPUSH mystring "value" +127.0.0.1:7379> SET mystring "not a list" +OK +127.0.0.1:7379> LPUSH mystring "value" +(error) WRONGTYPE Operation against a key holding the wrong kind of value ``` -`Description`: Attempts to insert the value "value" at the head of the key `mystring`, which is not a list but a string. - -`Error`: `WRONGTYPE Operation against a key holding the wrong kind of value` - ## Notes - The `LPUSH` command is often used in conjunction with the `RPUSH` command, which inserts values at the tail (right) of the list. diff --git a/docs/src/content/docs/commands/MSET.md b/docs/src/content/docs/commands/MSET.md index dd4bcbf6d..01730929a 100644 --- a/docs/src/content/docs/commands/MSET.md +++ b/docs/src/content/docs/commands/MSET.md @@ -13,26 +13,37 @@ MSET key1 value1 [key2 value2 ...] ## Parameters -- `key1, key2, ...`: The keys to be set. Each key must be a unique string. -- `value1, value2, ...`: The values to be associated with the respective keys. Each value can be any string. +| Parameter | Description | Type | Required | +|-----------------|--------------------------------------------------|---------|----------| +| `key1, key2, ...` | The keys to be set. | String | Yes | +| `value1, value2, ...` | The values to be associated with the respective keys. | String | Yes | -## Return Value -- `Simple String reply`: The command returns `OK` if the operation is successful. +## Return values + +| Condition | Return Value | +|------------------------------------------------|---------------------------------------------------| +| The command returns `OK` if the operation is successful. | A string value | + ## Behaviour -When the `MSET` command is executed, DiceDB sets the specified keys to their respective values. This operation is atomic, meaning that either all the keys are set, or none of them are. This ensures data consistency and integrity. +When the `MSET` command is executed: +- DiceDB sets the specified keys to their respective values. +- This operation is atomic, meaning that either all the keys are set, or none of them are. +- This ensures data consistency and integrity. +- Any pre-existing keys are overwritten and their respective TTL (if set) are reset. -## Error Handling +## Errors +The `MSET` command can raise errors in the following scenarios: - `Wrong number of arguments`: If the number of arguments is not even (i.e., there is a key without a corresponding value), DiceDB will return an error: ```bash - (error) ERR wrong number of arguments for 'mset' command + (error) ERROR wrong number of arguments for 'mset' command ``` - `Non-string keys or values`: If any of the keys or values are not strings, DiceDB will return an error: ```bash - (error) ERR value is not a valid string + (error) ERROR value is not a valid string ``` ## Example Usage @@ -67,17 +78,5 @@ Attempting to set an odd number of arguments: ```sh 127.0.0.1:7379> MSET key1 "value1" key2 -(error) ERR wrong number of arguments for 'mset' command -``` - -## Notes - -- The `MSET` command does not perform any type checking on the values. All values are stored as strings. -- If any of the keys already exist, their values will be overwritten without any warning. -- The `MSET` command is more efficient than issuing multiple `SET` commands because it reduces the number of network round-trips. - -## Best Practices - -- Use `MSET` when you need to set multiple keys to improve performance and ensure atomicity. -- Ensure that you always provide an even number of arguments to avoid errors. -- Be cautious when using `MSET` to overwrite existing keys, as this operation does not provide any warnings or confirmations. +(error) ERROR wrong number of arguments for 'mset' command +``` \ No newline at end of file diff --git a/docs/src/content/docs/commands/PFADD.md b/docs/src/content/docs/commands/PFADD.md index 8753a42cf..c670c2fd5 100644 --- a/docs/src/content/docs/commands/PFADD.md +++ b/docs/src/content/docs/commands/PFADD.md @@ -1,9 +1,9 @@ --- -title: PFADD -description: Documentation for the DiceDB command PFADD +title: PFADD +description: The `PFADD` command in DiceDB is used to add elements to a HyperLogLog data structure. HyperLogLog is a probabilistic data structure used for estimating the cardinality of a set, i.e., the number of unique elements in a dataset. --- -The `PFADD` command in DiceDB is used to add elements to a HyperLogLog data structure. HyperLogLog is a probabilistic data structure used for estimating the cardinality of a set, i.e., the number of unique elements in a dataset. The `PFADD` command helps in maintaining this data structure by adding new elements to it. +The `PFADD` command in DiceDB is used to add elements to a HyperLogLog data structure. HyperLogLog is a probabilistic data structure used for estimating the cardinality of a set, i.e., the number of unique elements in a dataset. ## Syntax @@ -13,61 +13,64 @@ PFADD key element [element ...] ## Parameters -- `key`: The name of the HyperLogLog data structure to which the elements will be added. If the key does not exist, a new HyperLogLog structure is created. -- `element`: One or more elements to be added to the HyperLogLog. Multiple elements can be specified, separated by spaces. +| Parameter | Description | Type | Required | +|------------|----------------------------------------------------------------------------------------------------------|---------|----------| +| `key` | The name of the HyperLogLog data structure. If it does not exist, a new one is created. | String | Yes | +| `element` | One or more elements to add to the HyperLogLog. Multiple elements can be specified, separated by spaces. | String | Yes | -## Return Value +## Return values -- `Integer reply`: The command returns `1` if at least one internal register was altered, `0` otherwise. This indicates whether the HyperLogLog was modified by the addition of the new elements. +| Condition | Return Value | +|-----------------------------------------------------|-----------------| +| At least one internal register was altered | `1` | +| No internal register was altered | `0` | ## Behaviour -When the `PFADD` command is executed, the following steps occur: +- The command first checks if the specified key exists. +- If the key does not exist, a new HyperLogLog data structure is created. +- If the key exists but is not a HyperLogLog, an error is returned. +- The specified elements are added to the HyperLogLog, and the internal registers are updated based on the hash values of these elements. +- The HyperLogLog maintains an estimate of the cardinality of the set using these updated registers. -1. `Key Existence Check`: DiceDB checks if the specified key exists. - - If the key does not exist, a new HyperLogLog data structure is created. - - If the key exists but is not a HyperLogLog, an error is returned. -2. `Element Addition`: The specified elements are added to the HyperLogLog. -3. `Register Update`: The internal registers of the HyperLogLog are updated based on the hash values of the added elements. -4. `Cardinality Estimation`: The HyperLogLog uses the updated registers to maintain an estimate of the cardinality of the set. +## Errors -## Error Handling +1. `Wrong type error`: -- `Wrong Type Error`: If the key exists but is not a HyperLogLog, DiceDB will return an error: - ``` - (error) WRONGTYPE Operation against a key holding the wrong kind of value - ``` -- `Syntax Error`: If the command is not used with the correct syntax, DiceDB will return a syntax error: - ``` - (error) ERR wrong number of arguments for 'pfadd' command - ``` + - Error Message: `(error) ERROR WRONGTYPE Key is not a valid HyperLogLog string value.` + - Occurs when trying to use the command on a key that is not a HyperLogLog. -## Example Usage +2. `Syntax error`: + + - Error Message: `(error) ERROR wrong number of arguments for 'pfadd' command` + - Occurs when the command syntax is incorrect or missing required parameters. + +## Examples ### Basic Example -Add a single element to a HyperLogLog: +Adding a single element to a HyperLogLog: -```shell -> PFADD myhyperloglog "element1" +```bash +127.0.0.1:7379> PFADD myhyperloglog "element1" (integer) 1 ``` ### Adding Multiple Elements -Add multiple elements to a HyperLogLog: +Adding multiple elements to a HyperLogLog: -```shell -> PFADD myhyperloglog "element1" "element2" "element3" +```bash +127.0.0.1:7379> PFADD myhyperloglog "element1" "element2" "element3" (integer) 1 ``` ### Checking if the HyperLogLog was Modified -If the elements are already present and do not alter the internal registers: +If the elements do not alter the internal registers: -```shell -> PFADD myhyperloglog "element1" +```bash +127.0.0.1:7379> PFADD myhyperloglog "element1" (integer) 0 ``` @@ -75,18 +78,11 @@ If the elements are already present and do not alter the internal registers: Attempting to add elements to a key that is not a HyperLogLog: -```shell -> SET mykey "notahyperloglog" +```bash +127.0.0.1:7379> SET mykey "notahyperloglog" OK -> PFADD mykey "element1" -(error) WRONGTYPE Operation against a key holding the wrong kind of value +127.0.0.1:7379> PFADD mykey "element1" +(error) ERROR WRONGTYPE Key is not a valid HyperLogLog string value. ``` -## Notes - -- The `PFADD` command is part of the HyperLogLog family of commands in DiceDB, which also includes `PFCOUNT` and `PFMERGE`. -- HyperLogLog is a probabilistic data structure, so it provides an approximate count of unique elements with a standard error of 0.81%. -- The `PFADD` command is useful for applications that need to count unique items in a large dataset efficiently, such as unique visitor counts, unique search queries, etc. - -By understanding and using the `PFADD` command effectively, you can leverage DiceDB's powerful HyperLogLog data structure to manage and estimate the cardinality of large sets with minimal memory usage. - +--- \ No newline at end of file diff --git a/docs/src/content/docs/commands/RPOP.md b/docs/src/content/docs/commands/RPOP.md index 074e21cb7..7f464d78f 100644 --- a/docs/src/content/docs/commands/RPOP.md +++ b/docs/src/content/docs/commands/RPOP.md @@ -1,9 +1,9 @@ --- title: RPOP -description: Documentation for the DiceDB command RPOP +description: The `RPOP` command in DiceDB removes and returns the last element of a list. It is commonly used for processing elements in Last-In-First-Out (LIFO) order. --- -The `RPOP` command in DiceDB is used to remove and return the last element of a list. This command is particularly useful when you need to process elements in a Last-In-First-Out (LIFO) order. +The `RPOP` command in DiceDB is used to remove and return the last element of a list. This command is useful when processing elements in a Last-In-First-Out (LIFO) order. ## Syntax @@ -13,79 +13,73 @@ RPOP key ## Parameters -- `key`: The key of the list from which the last element will be removed and returned. The key must be of type list. If the key does not exist, it is treated as an empty list and the command returns `nil`. +| Parameter | Description | Type | Required | +|-----------|----------------------------------------------------------------------|--------|----------| +| `key` | The key of the list from which the last element will be removed. | String | Yes | -## Return Value -- `String`: The value of the last element in the list, after removing it. -- `nil`: If the key does not exist or the list is empty. +## Return values + +| Condition | Return Value | +|------------------------------------------------|------------------------------------------------| +| The command is successful | The value of the last element in the list | +| The list is empty or the key does not exist | `nil` | +| The key is of the wrong type | Error: `WRONGTYPE Operation against a key holding the wrong kind of value` | + ## Behaviour -When the `RPOP` command is executed, the following steps occur: +- The `RPOP` command checks if the key exists and whether it contains a list. +- If the key does not exist, the command treats it as an empty list and returns `nil`. +- If the key exists but the list is empty, `nil` is returned. +- If the list has elements, the last element is removed and returned. +- If the key exists but is not of type list, an error is raised. -1. DiceDB checks if the key exists and is of type list. -2. If the key does not exist, the command returns `nil`. -3. If the key exists but the list is empty, the command returns `nil`. -4. If the key exists and the list is not empty, the last element of the list is removed and returned. +## Errors -## Error Handling +1. **Wrong type of value or key**: + - Error Message: `(error) WRONGTYPE Operation against a key holding the wrong kind of value` + - Occurs when attempting to run `RPOP` on a key that is not a list. -- `WRONGTYPE Operation against a key holding the wrong kind of value`: This error is raised if the key exists but is not of type list. -- `(nil)`: This is returned if the key does not exist or the list is empty. +2. **Non-existent or empty list**: + - Returns `nil` when the key does not exist or the list is empty. ## Example Usage ### Example 1: Basic Usage -```shell -# Add elements to the list -LPUSH mylist "one" -LPUSH mylist "two" -LPUSH mylist "three" - -# Current state of 'mylist': ["three", "two", "one"] - -# Remove and return the last element -RPOP mylist -# Output: "one" - -# Current state of 'mylist': ["three", "two"] +```bash +127.0.0.1:7379> LPUSH mylist "one" "two" "three" +(integer) 3 +127.0.0.1:7379> RPOP mylist +"one" ``` ### Example 2: Empty List -```shell -# Create an empty list -LPUSH emptylist - -# Remove and return the last element from an empty list -RPOP emptylist -# Output: (nil) +```bash +127.0.0.1:7379> RPOP emptylist +(nil) ``` ### Example 3: Non-List Key -```shell -# Set a string key -SET mystring "Hello" - -# Attempt to RPOP from a string key -RPOP mystring -# Output: (error) WRONGTYPE Operation against a key holding the wrong kind of value +```bash +127.0.0.1:7379> SET mystring "Hello" +OK +127.0.0.1:7379> RPOP mystring +(error) WRONGTYPE Operation against a key holding the wrong kind of value ``` ## Notes - The `RPOP` command is atomic, meaning it is safe to use in concurrent environments. - If you need to remove and return the first element of the list, use the `LPOP` command instead. -- For blocking behavior, consider using `BRPOP`. ## Related Commands - `LPUSH`: Insert all the specified values at the head of the list stored at key. - `LPOP`: Removes and returns the first element of the list stored at key. -- `BRPOP`: Removes and returns the last element of the list stored at key, or blocks until one is available. By understanding the `RPOP` command, you can effectively manage lists in DiceDB, ensuring that you can retrieve and process elements in a LIFO order. diff --git a/docs/src/content/docs/commands/SINTER.md b/docs/src/content/docs/commands/SINTER.md index f4072b52e..46a4f3e78 100644 --- a/docs/src/content/docs/commands/SINTER.md +++ b/docs/src/content/docs/commands/SINTER.md @@ -1,17 +1,28 @@ --- title: SINTER -description: Documentation for the DiceDB command SINTER +description: The `SINTER` command in DiceDB is used to compute the intersection of multiple sets. This command returns the members that are common to all the specified sets. If any of the sets do not exist, they are considered to be empty sets. The result of the intersection will be an empty set if at least one of the sets is empty. --- The `SINTER` command in DiceDB is used to compute the intersection of multiple sets. This command returns the members that are common to all the specified sets. If any of the sets do not exist, they are considered to be empty sets. The result of the intersection will be an empty set if at least one of the sets is empty. +## Syntax + +``` +SINTER key [key ...] +``` ## Parameters -- `key [key ...]`: One or more keys corresponding to the sets you want to intersect. At least one key must be provided. +| Parameter | Description | Type | Required | +|----------------|-----------------------------------------------------------------------------------------------|---------|----------| +| `key [key ...]`| One or more identifier keys representing sets to intersect. At least one key must be provided.| String | Yes | -## Return Value +## Return Values -- `Array of elements`: The command returns an array of elements that are present in all the specified sets. If no common elements are found, an empty array is returned. +| Condition | Return Value | +|------------------------------------------------|---------------------------------------------------------------------------| +| Common elements exist | array of elements (as strings) that are present in all the specified sets | +| No common elements exist | `(empty array)` | +| Invalid syntax or no specified keys | error | ## Behaviour @@ -36,17 +47,15 @@ If any of the specified keys do not exist, they are treated as empty sets. The i ```shell # Add elements to sets -SADD set1 "a" "b" "c" -SADD set2 "b" "c" "d" -SADD set3 "c" "d" "e" +127.0.0.1:7379> SADD set1 "a" "b" "c" +(integer) 3 +127.0.0.1:7379> SADD set2 "b" "c" "d" +(integer) 3 +127.0.0.1:7379> SADD set3 "c" "d" "e" +(integer) 3 # Compute intersection -SINTER set1 set2 set3 -``` - -`Expected Output:` - -```shell +127.0.0.1:7379> SINTER set1 set2 set3 1) "c" ``` @@ -54,67 +63,36 @@ SINTER set1 set2 set3 ```shell # Add elements to sets -SADD set1 "a" "b" "c" -SADD set2 "b" "c" "d" +127.0.0.1:7379> SADD set1 "a" "b" "c" +(integer) 3 +127.0.0.1:7379> SADD set2 "b" "c" "d" +(integer) 3 # Compute intersection with a non-existent set -SINTER set1 set2 set3 -``` - -`Expected Output:` - -```shell +127.0.0.1:7379> SINTER set1 set2 set3 (empty array) ``` +Note: By default, non-existent keys (such as set3 in the example above) are treated like empty sets. There's no built-in way to create an empty set. -### Example 3: Intersection with Empty Set +### Example 3: Error Handling - Wrong Type ```shell # Add elements to sets -SADD set1 "a" "b" "c" -SADD set2 "b" "c" "d" - -# Create an empty set -SADD set3 - -# Compute intersection -SINTER set1 set2 set3 -``` - -`Expected Output:` - -```shell -(empty array) -``` - -### Example 4: Error Handling - Wrong Type - -```shell -# Add elements to sets -SADD set1 "a" "b" "c" - +127.0.0.1:7379> SADD set1 "a" "b" "c" +(integer) 3 # Create a string key -SET stringKey "value" +127.0.0.1:7379> SET stringKey "value" +OK # Attempt to compute intersection with a non-set key SINTER set1 stringKey -``` - -`Expected Output:` - -```shell (error) WRONGTYPE Operation against a key holding the wrong kind of value ``` -### Example 5: Error Handling - No Keys Provided +### Example 4: Error Handling - No Keys Provided ```shell # Attempt to compute intersection without providing any keys -SINTER -``` - -`Expected Output:` - -```shell +127.0.0.1:7379> SINTER (error) ERR wrong number of arguments for 'sinter' command ``` diff --git a/go.mod b/go.mod index b79cd31dc..38e68ce3e 100644 --- a/go.mod +++ b/go.mod @@ -27,7 +27,6 @@ require ( github.com/spf13/pflag v1.0.5 // indirect github.com/subosito/gotenv v1.6.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect - go.uber.org/atomic v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect golang.org/x/arch v0.10.0 // indirect golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 // indirect @@ -47,6 +46,7 @@ require ( github.com/google/btree v1.1.3 github.com/google/go-cmp v0.6.0 github.com/gorilla/websocket v1.5.3 + github.com/mmcloughlin/geohash v0.10.0 github.com/ohler55/ojg v1.24.0 github.com/pelletier/go-toml/v2 v2.2.3 github.com/rs/xid v1.6.0 diff --git a/go.sum b/go.sum index 1a1d21646..aaaed3a20 100644 --- a/go.sum +++ b/go.sum @@ -6,15 +6,11 @@ github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= -github.com/bytedance/sonic v1.12.1 h1:jWl5Qz1fy7X1ioY74WqO0KjAMtAGQs4sYnjiEBiyX24= -github.com/bytedance/sonic v1.12.1/go.mod h1:B8Gt/XvtZ3Fqj+iSKMypzymZxw/FVwgIGKzMzT9r/rk= github.com/bytedance/sonic v1.12.3 h1:W2MGa7RCU1QTeYRTPE3+88mVC0yXmsRQRChiyVocVjU= github.com/bytedance/sonic v1.12.3/go.mod h1:B8Gt/XvtZ3Fqj+iSKMypzymZxw/FVwgIGKzMzT9r/rk= github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= github.com/bytedance/sonic/loader v0.2.0 h1:zNprn+lsIP06C/IqCHs3gPQIvnvpKbbxyXQP1iU4kWM= github.com/bytedance/sonic/loader v0.2.0/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= -github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= -github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= @@ -28,12 +24,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13 h1:fAjc9m62+UWV/WAFKLNi6ZS0675eEUC9y3AlwSbQu1Y= -github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw= github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da h1:aIftn67I1fkbMa512G+w+Pxci9hJPB8oMnkcP3iZF38= github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw= -github.com/dgryski/go-metro v0.0.0-20180109044635-280f6062b5bc h1:8WFBn63wegobsYAX0YjD+8suexZDga5CctH4CCTx2+8= -github.com/dgryski/go-metro v0.0.0-20180109044635-280f6062b5bc/go.mod h1:c9O8+fpSOX1DM8cPNSkX/qsBWdkD4yd2dpciOWQjpBw= github.com/dgryski/go-metro v0.0.0-20211217172704-adc40b04c140 h1:y7y0Oa6UawqTFPCDw9JG6pdKt4F9pAhHv0B7FMGaGD0= github.com/dgryski/go-metro v0.0.0-20211217172704-adc40b04c140/go.mod h1:c9O8+fpSOX1DM8cPNSkX/qsBWdkD4yd2dpciOWQjpBw= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= @@ -53,7 +45,6 @@ github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aN github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= -github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.2.8 h1:+StwCXwm9PdpiEkPyzBXIy+M9KUb4ODm0Zarf1kS5BM= github.com/klauspost/cpuid/v2 v2.2.8/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= @@ -64,21 +55,18 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= -github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= -github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= -github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/mmcloughlin/geohash v0.10.0 h1:9w1HchfDfdeLc+jFEf/04D27KP7E2QmpDu52wPbJWRE= +github.com/mmcloughlin/geohash v0.10.0/go.mod h1:oNZxQo5yWJh0eMQEP/8hwQuVx9Z9tjwFUqcTB1SmG0c= github.com/ohler55/ojg v1.24.0 h1:y2AVez6fPTszK/jPhaAYMCAzAoSleConMqSDD5wJKJg= github.com/ohler55/ojg v1.24.0/go.mod h1:gQhDVpQLqrmnd2eqGAvJtn+NfKoYJbe/A4Sj3/Vro4o= -github.com/ohler55/ojg v1.24.1 h1:PaVLelrNgT5/0ppPaUtey54tOVp245z33fkhL2jljjY= -github.com/ohler55/ojg v1.24.1/go.mod h1:gQhDVpQLqrmnd2eqGAvJtn+NfKoYJbe/A4Sj3/Vro4o= github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= @@ -90,12 +78,8 @@ github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/f github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= -github.com/rs/zerolog v1.30.0 h1:SymVODrcRsaRaSInD9yQtKbtWqwsfoPcRff/oRXLj4c= -github.com/rs/zerolog v1.30.0/go.mod h1:/tk+P47gFdPXq4QYjvCmT5/Gsug2nagsFWBWhAiSi1w= github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8= github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= -github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= -github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= github.com/sagikazarmark/locafero v0.6.0 h1:ON7AQg37yzcRPU69mt7gwhFEBwxI6P9T4Qu3N51bwOk= github.com/sagikazarmark/locafero v0.6.0/go.mod h1:77OmuIc6VTraTXKXIs/uvUxKGUXjE1GbemJYHqdNjX0= github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= @@ -104,8 +88,6 @@ github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9yS github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= -github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= -github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w= github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= @@ -115,7 +97,6 @@ github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= -github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= @@ -130,38 +111,20 @@ github.com/twmb/murmur3 v1.1.8 h1:8Yt9taO/WN3l08xErzjeschgZU2QSrwm1kclYq+0aRg= github.com/twmb/murmur3 v1.1.8/go.mod h1:Qq/R7NUyOfr65zD+6Q5IHKsJLwP7exErjN6lyyq3OSQ= github.com/xwb1989/sqlparser v0.0.0-20180606152119-120387863bf2 h1:zzrxE1FKn5ryBNl9eKOeqQ58Y/Qpo3Q9QNxKHX5uzzQ= github.com/xwb1989/sqlparser v0.0.0-20180606152119-120387863bf2/go.mod h1:hzfGeIUDq/j97IG+FhNqkowIyEcD88LrW6fyU3K3WqY= -go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= -go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= -go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= -go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= -go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= -go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -golang.org/x/arch v0.0.0-20210923205945-b76863e36670 h1:18EFjUmQOcUvxNYSkA6jO9VAiXCnxFY6NyDX0bHDmkU= -golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/arch v0.10.0 h1:S3huipmSclq3PJMNe76NGwkBR504WFkQ5dhzWzP8ZW8= golang.org/x/arch v0.10.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= -golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30= -golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M= golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A= golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70= -golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= -golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 h1:e66Fs6Z+fZTbFBAxKfP3PALWBtpfqks2bwGcexMxgtk= golang.org/x/exp v0.0.0-20240909161429-701f63a606c0/go.mod h1:2TbTHSBQa924w8M6Xs1QcRcFwyucIwBGpK1p2f1YFFY= -golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= -golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= -golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224= golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/integration_tests/commands/async/bitfield_test.go b/integration_tests/commands/async/bitfield_test.go new file mode 100644 index 000000000..10df4e548 --- /dev/null +++ b/integration_tests/commands/async/bitfield_test.go @@ -0,0 +1,256 @@ +package async + +import ( + "testing" + "time" + + testifyAssert "github.com/stretchr/testify/assert" +) + +func TestBitfield(t *testing.T) { + conn := getLocalConnection() + defer conn.Close() + + FireCommand(conn, "FLUSHDB") + defer FireCommand(conn, "FLUSHDB") // clean up after all test cases + syntaxErrMsg := "ERR syntax error" + bitFieldTypeErrMsg := "ERR Invalid bitfield type. Use something like i16 u8. Note that u64 is not supported but i64 is." + integerErrMsg := "ERR value is not an integer or out of range" + overflowErrMsg := "ERR Invalid OVERFLOW type specified" + + testCases := []struct { + Name string + Commands []string + Expected []interface{} + Delay []time.Duration + CleanUp []string + }{ + { + Name: "BITFIELD Arity Check", + Commands: []string{"bitfield"}, + Expected: []interface{}{"ERR wrong number of arguments for 'bitfield' command"}, + Delay: []time.Duration{0}, + CleanUp: []string{}, + }, + { + Name: "BITFIELD on unsupported type of SET", + Commands: []string{"SADD bits a b c", "bitfield bits"}, + Expected: []interface{}{int64(3), "WRONGTYPE Operation against a key holding the wrong kind of value"}, + Delay: []time.Duration{0, 0}, + CleanUp: []string{"DEL bits"}, + }, + { + Name: "BITFIELD on unsupported type of JSON", + Commands: []string{"json.set bits $ 1", "bitfield bits"}, + Expected: []interface{}{"OK", "WRONGTYPE Operation against a key holding the wrong kind of value"}, + Delay: []time.Duration{0, 0}, + CleanUp: []string{"DEL bits"}, + }, + { + Name: "BITFIELD on unsupported type of HSET", + Commands: []string{"HSET bits a 1", "bitfield bits"}, + Expected: []interface{}{int64(1), "WRONGTYPE Operation against a key holding the wrong kind of value"}, + Delay: []time.Duration{0, 0}, + CleanUp: []string{"DEL bits"}, + }, + { + Name: "BITFIELD with syntax errors", + Commands: []string{ + "bitfield bits set u8 0 255 incrby u8 0 100 get u8", + "bitfield bits set a8 0 255 incrby u8 0 100 get u8", + "bitfield bits set u8 a 255 incrby u8 0 100 get u8", + "bitfield bits set u8 0 255 incrby u8 0 100 overflow wraap", + "bitfield bits set u8 0 incrby u8 0 100 get u8 288", + }, + Expected: []interface{}{ + syntaxErrMsg, + bitFieldTypeErrMsg, + "ERR bit offset is not an integer or out of range", + overflowErrMsg, + integerErrMsg, + }, + Delay: []time.Duration{0, 0, 0, 0, 0}, + CleanUp: []string{"Del bits"}, + }, + { + Name: "BITFIELD signed SET and GET basics", + Commands: []string{"bitfield bits set i8 0 -100", "bitfield bits set i8 0 101", "bitfield bits get i8 0"}, + Expected: []interface{}{[]interface{}{int64(0)}, []interface{}{int64(-100)}, []interface{}{int64(101)}}, + Delay: []time.Duration{0, 0, 0}, + CleanUp: []string{"DEL bits"}, + }, + { + Name: "BITFIELD unsigned SET and GET basics", + Commands: []string{"bitfield bits set u8 0 255", "bitfield bits set u8 0 100", "bitfield bits get u8 0"}, + Expected: []interface{}{[]interface{}{int64(0)}, []interface{}{int64(255)}, []interface{}{int64(100)}}, + Delay: []time.Duration{0, 0, 0}, + CleanUp: []string{"DEL bits"}, + }, + { + Name: "BITFIELD signed SET and GET together", + Commands: []string{"bitfield bits set i8 0 255 set i8 0 100 get i8 0"}, + Expected: []interface{}{[]interface{}{int64(0), int64(-1), int64(100)}}, + Delay: []time.Duration{0}, + CleanUp: []string{"DEL bits"}, + }, + { + Name: "BITFIELD unsigned with SET, GET and INCRBY arguments", + Commands: []string{"bitfield bits set u8 0 255 incrby u8 0 100 get u8 0"}, + Expected: []interface{}{[]interface{}{int64(0), int64(99), int64(99)}}, + Delay: []time.Duration{0}, + CleanUp: []string{"DEL bits"}, + }, + { + Name: "BITFIELD with only key as argument", + Commands: []string{"bitfield bits"}, + Expected: []interface{}{[]interface{}{}}, + Delay: []time.Duration{0}, + CleanUp: []string{"DEL bits"}, + }, + { + Name: "BITFIELD # form", + Commands: []string{ + "bitfield bits set u8 #0 65", + "bitfield bits set u8 #1 66", + "bitfield bits set u8 #2 67", + "get bits", + }, + Expected: []interface{}{[]interface{}{int64(0)}, []interface{}{int64(0)}, []interface{}{int64(0)}, "ABC"}, + Delay: []time.Duration{0, 0, 0, 0}, + CleanUp: []string{"DEL bits"}, + }, + { + Name: "BITFIELD basic INCRBY form", + Commands: []string{ + "bitfield bits set u8 #0 10", + "bitfield bits incrby u8 #0 100", + "bitfield bits incrby u8 #0 100", + }, + Expected: []interface{}{[]interface{}{int64(0)}, []interface{}{int64(110)}, []interface{}{int64(210)}}, + Delay: []time.Duration{0, 0, 0}, + CleanUp: []string{"DEL bits"}, + }, + { + Name: "BITFIELD chaining of multiple commands", + Commands: []string{ + "bitfield bits set u8 #0 10", + "bitfield bits incrby u8 #0 100 incrby u8 #0 100", + }, + Expected: []interface{}{[]interface{}{int64(0)}, []interface{}{int64(110), int64(210)}}, + Delay: []time.Duration{0, 0}, + CleanUp: []string{"DEL bits"}, + }, + { + Name: "BITFIELD unsigned overflow wrap", + Commands: []string{ + "bitfield bits set u8 #0 100", + "bitfield bits overflow wrap incrby u8 #0 257", + "bitfield bits get u8 #0", + "bitfield bits overflow wrap incrby u8 #0 255", + "bitfield bits get u8 #0", + }, + Expected: []interface{}{ + []interface{}{int64(0)}, + []interface{}{int64(101)}, + []interface{}{int64(101)}, + []interface{}{int64(100)}, + []interface{}{int64(100)}, + }, + Delay: []time.Duration{0, 0, 0, 0, 0}, + CleanUp: []string{"DEL bits"}, + }, + { + Name: "BITFIELD unsigned overflow sat", + Commands: []string{ + "bitfield bits set u8 #0 100", + "bitfield bits overflow sat incrby u8 #0 257", + "bitfield bits get u8 #0", + "bitfield bits overflow sat incrby u8 #0 -255", + "bitfield bits get u8 #0", + }, + Expected: []interface{}{ + []interface{}{int64(0)}, + []interface{}{int64(255)}, + []interface{}{int64(255)}, + []interface{}{int64(0)}, + []interface{}{int64(0)}, + }, + Delay: []time.Duration{0, 0, 0, 0, 0}, + CleanUp: []string{"DEL bits"}, + }, + { + Name: "BITFIELD signed overflow wrap", + Commands: []string{ + "bitfield bits set i8 #0 100", + "bitfield bits overflow wrap incrby i8 #0 257", + "bitfield bits get i8 #0", + "bitfield bits overflow wrap incrby i8 #0 255", + "bitfield bits get i8 #0", + }, + Expected: []interface{}{ + []interface{}{int64(0)}, + []interface{}{int64(101)}, + []interface{}{int64(101)}, + []interface{}{int64(100)}, + []interface{}{int64(100)}, + }, + Delay: []time.Duration{0, 0, 0, 0, 0}, + CleanUp: []string{"DEL bits"}, + }, + { + Name: "BITFIELD signed overflow sat", + Commands: []string{ + "bitfield bits set u8 #0 100", + "bitfield bits overflow sat incrby i8 #0 257", + "bitfield bits get i8 #0", + "bitfield bits overflow sat incrby i8 #0 -255", + "bitfield bits get i8 #0", + }, + Expected: []interface{}{ + []interface{}{int64(0)}, + []interface{}{int64(127)}, + []interface{}{int64(127)}, + []interface{}{int64(-128)}, + []interface{}{int64(-128)}, + }, + Delay: []time.Duration{0, 0, 0, 0, 0}, + CleanUp: []string{"DEL bits"}, + }, + { + Name: "BITFIELD regression 1", + Commands: []string{"set bits 1", "bitfield bits get u1 0"}, + Expected: []interface{}{"OK", []interface{}{int64(0)}}, + Delay: []time.Duration{0, 0}, + CleanUp: []string{"DEL bits"}, + }, + { + Name: "BITFIELD regression 2", + Commands: []string{ + "bitfield mystring set i8 0 10", + "bitfield mystring set i8 64 10", + "bitfield mystring incrby i8 10 99900", + }, + Expected: []interface{}{[]interface{}{int64(0)}, []interface{}{int64(0)}, []interface{}{int64(60)}}, + Delay: []time.Duration{0, 0, 0}, + CleanUp: []string{"DEL mystring"}, + }, + } + + for _, tc := range testCases { + t.Run(tc.Name, func(t *testing.T) { + + for i := 0; i < len(tc.Commands); i++ { + if tc.Delay[i] > 0 { + time.Sleep(tc.Delay[i]) + } + result := FireCommand(conn, tc.Commands[i]) + expected := tc.Expected[i] + testifyAssert.Equal(t, expected, result) + } + + for _, cmd := range tc.CleanUp { + FireCommand(conn, cmd) + } + }) + } +} diff --git a/integration_tests/commands/async/hmget_test.go b/integration_tests/commands/async/hmget_test.go new file mode 100644 index 000000000..72cb9804d --- /dev/null +++ b/integration_tests/commands/async/hmget_test.go @@ -0,0 +1,57 @@ +package async + +import ( + "testing" + + "gotest.tools/v3/assert" +) + +func TestHMGET(t *testing.T) { + conn := getLocalConnection() + defer conn.Close() + defer FireCommand(conn, "DEL key_hmGet key_hmGet1") + + testCases := []TestCase{ + { + name: "hmget existing keys and fields", + commands: []string{"HSET key_hmGet field value", "HSET key_hmGet field2 value_new", "HMGET key_hmGet field field2"}, + expected: []interface{}{ONE, ONE, []interface{}{"value", "value_new"}}, + }, + { + name: "hmget key does not exist", + commands: []string{"HMGET doesntexist field"}, + expected: []interface{}{[]interface{}{"(nil)"}}, + }, + { + name: "hmget field does not exist", + commands: []string{"HMGET key_hmGet field3"}, + expected: []interface{}{[]interface{}{"(nil)"}}, + }, + { + name: "hmget some fields do not exist", + commands: []string{"HMGET key_hmGet field field2 field3 field3"}, + expected: []interface{}{[]interface{}{"value", "value_new", "(nil)", "(nil)"}}, + }, + { + name: "hmget with wrongtype", + commands: []string{"SET key_hmGet1 field", "HMGET key_hmGet1 field"}, + expected: []interface{}{"OK", "WRONGTYPE Operation against a key holding the wrong kind of value"}, + }, + { + name: "wrong number of arguments", + commands: []string{"HMGET key_hmGet", "HMGET"}, + expected: []interface{}{"ERR wrong number of arguments for 'hmget' command", + "ERR wrong number of arguments for 'hmget' command"}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + for i, cmd := range tc.commands { + // Fire the command and get the result + result := FireCommand(conn, cmd) + assert.DeepEqual(t, result, tc.expected[i]) + } + }) + } +} diff --git a/integration_tests/commands/async/hyperloglog_test.go b/integration_tests/commands/async/hyperloglog_test.go index 450fa70ae..321077d36 100644 --- a/integration_tests/commands/async/hyperloglog_test.go +++ b/integration_tests/commands/async/hyperloglog_test.go @@ -51,12 +51,6 @@ func TestHyperLogLogCommands(t *testing.T) { "PFMERGE NON_EXISTING_SRC_KEY", "PFCOUNT NON_EXISTING_SRC_KEY"}, expected: []interface{}{"OK", int64(0)}, }, - { - name: "PFMERGE with srcKey non-existing", - commands: []string{ - "PFMERGE NON_EXISTING_SRC_KEY", "PFCOUNT NON_EXISTING_SRC_KEY"}, - expected: []interface{}{"OK", int64(0)}, - }, { name: "PFMERGE with destKey non-existing", commands: []string{ diff --git a/integration_tests/commands/http/check_type_test.go b/integration_tests/commands/http/check_type_test.go new file mode 100644 index 000000000..75f623a72 --- /dev/null +++ b/integration_tests/commands/http/check_type_test.go @@ -0,0 +1,121 @@ +package http + +import ( + "testing" + "time" + + "gotest.tools/v3/assert" +) + +// This file may contain test cases for checking error messages accross all commands +func TestErrorsForSetData(t *testing.T) { + exec := NewHTTPCommandExecutor() + setErrorMsg := "WRONGTYPE Operation against a key holding the wrong kind of value" + testCases := []struct { + name string + commands []HTTPCommand + expected []interface{} + delays []time.Duration + assertType []string + }{ + { + name: "GET a key holding a set", + commands: []HTTPCommand{ + {Command: "SADD", Body: map[string]interface{}{"key": "foo", "member": "bar"}}, + {Command: "GET", Body: map[string]interface{}{"key": "foo"}}, + }, + expected: []interface{}{float64(1), setErrorMsg}, + assertType: []string{"equal", "equal"}, + delays: []time.Duration{0, 0}, + }, + { + name: "GETDEL a key holding a set", + commands: []HTTPCommand{ + {Command: "SADD", Body: map[string]interface{}{"key": "foo", "member": "bar"}}, + {Command: "GETDEL", Body: map[string]interface{}{"key": "foo"}}, + }, + expected: []interface{}{float64(1), setErrorMsg}, + assertType: []string{"equal", "equal"}, + delays: []time.Duration{0, 0}, + }, + { + name: "INCR a key holding a set", + commands: []HTTPCommand{ + {Command: "SADD", Body: map[string]interface{}{"key": "foo", "member": "bar"}}, + {Command: "INCR", Body: map[string]interface{}{"key": "foo"}}, + }, + expected: []interface{}{float64(1), setErrorMsg}, + assertType: []string{"equal", "equal"}, + delays: []time.Duration{0, 0}, + }, + { + name: "DECR a key holding a set", + commands: []HTTPCommand{ + {Command: "SADD", Body: map[string]interface{}{"key": "foo", "member": "bar"}}, + {Command: "DECR", Body: map[string]interface{}{"key": "foo"}}, + }, + expected: []interface{}{float64(1), setErrorMsg}, + assertType: []string{"equal", "equal"}, + delays: []time.Duration{0, 0}, + }, + { + name: "BIT operations on a key holding a set", + commands: []HTTPCommand{ + {Command: "SADD", Body: map[string]interface{}{"key": "foo", "member": "bar"}}, + {Command: "GETBIT", Body: map[string]interface{}{"key": "foo", "offset": 1}}, + {Command: "BITCOUNT", Body: map[string]interface{}{"key": "foo"}}, + }, + expected: []interface{}{float64(1), setErrorMsg, setErrorMsg}, + assertType: []string{"equal", "equal", "equal"}, + delays: []time.Duration{0, 0, 0}, + }, + { + name: "GETEX a key holding a set", + commands: []HTTPCommand{ + {Command: "SADD", Body: map[string]interface{}{"key": "foo", "member": "bar"}}, + {Command: "GETEX", Body: map[string]interface{}{"key": "foo"}}, + }, + expected: []interface{}{float64(1), setErrorMsg}, + assertType: []string{"equal", "equal"}, + delays: []time.Duration{0, 0}, + }, + { + name: "GETSET a key holding a set", + commands: []HTTPCommand{ + {Command: "SADD", Body: map[string]interface{}{"key": "foo", "member": "bar"}}, + {Command: "GETSET", Body: map[string]interface{}{"key": "foo", "value": "bar"}}, + }, + expected: []interface{}{float64(1), setErrorMsg}, + assertType: []string{"equal", "equal"}, + delays: []time.Duration{0, 0}, + }, + { + name: "LPUSH, LPOP, RPUSH, RPOP a key holding a set", + commands: []HTTPCommand{ + {Command: "SADD", Body: map[string]interface{}{"key": "foo", "member": "bar"}}, + {Command: "LPUSH", Body: map[string]interface{}{"key": "foo", "value": "bar"}}, + {Command: "LPOP", Body: map[string]interface{}{"key": "foo"}}, + {Command: "RPUSH", Body: map[string]interface{}{"key": "foo", "value": "bar"}}, + {Command: "RPOP", Body: map[string]interface{}{"key": "foo"}}, + }, + expected: []interface{}{float64(1), setErrorMsg, setErrorMsg, setErrorMsg, setErrorMsg}, + assertType: []string{"equal", "equal", "equal", "equal", "equal"}, + delays: []time.Duration{0, 0, 0, 0, 0}, + }, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + exec.FireCommand(HTTPCommand{Command: "DEL", Body: map[string]interface{}{"key": "foo"}}) + for i, cmd := range tc.commands { + if tc.delays[i] > 0 { + time.Sleep(tc.delays[i]) + } + result, _ := exec.FireCommand(cmd) + switch tc.assertType[i] { + case "equal": + assert.Equal(t, tc.expected[i], result, "Value mismatch for cmd %s", cmd) + } + } + }) + } +} diff --git a/integration_tests/commands/http/command_getkeys_test.go b/integration_tests/commands/http/command_getkeys_test.go new file mode 100644 index 000000000..8ddc0e8c4 --- /dev/null +++ b/integration_tests/commands/http/command_getkeys_test.go @@ -0,0 +1,107 @@ +package http + +import ( + "testing" + + "gotest.tools/v3/assert" +) + +func TestCommandGetKeys(t *testing.T) { + exec := NewHTTPCommandExecutor() + + testCases := []TestCase{ + { + name: "Set command", + commands: []HTTPCommand{ + {Command: "COMMAND/GETKEYS", Body: map[string]interface{}{"key": "SET", "keys": []interface{}{"1", "2"}, "values": []interface{}{"2", "3"}}}, + }, + expected: []interface{}{[]interface{}{"1"}}, + }, + { + name: "Get command", + commands: []HTTPCommand{ + {Command: "COMMAND/GETKEYS", Body: map[string]interface{}{"key": "GET", "field": "key"}}, + }, + expected: []interface{}{[]interface{}{"key"}}, + }, + { + name: "TTL command", + commands: []HTTPCommand{ + {Command: "COMMAND/GETKEYS", Body: map[string]interface{}{"key": "TTL", "field": "key"}}, + }, + expected: []interface{}{[]interface{}{"key"}}, + }, + { + name: "Del command", + commands: []HTTPCommand{ + {Command: "COMMAND/GETKEYS", Body: map[string]interface{}{"key": "DEL", "field": "1 2 3 4 5 6 7"}}, + }, + expected: []interface{}{[]interface{}{"1 2 3 4 5 6 7"}}, + }, + { + name: "MSET command", + commands: []HTTPCommand{ + {Command: "COMMAND/GETKEYS", Body: map[string]interface{}{"key": "MSET", "keys": []interface{}{"key1 key2"}, "values": []interface{}{" val1 val2"}}}, + }, + expected: []interface{}{[]interface{}{"key1 key2"}}, + }, + { + name: "Expire command", + commands: []HTTPCommand{ + {Command: "COMMAND/GETKEYS", Body: map[string]interface{}{"key": "EXPIRE", "field": "key", "values": []interface{}{"time", "extra"}}}, + }, + expected: []interface{}{[]interface{}{"key"}}, + }, + { + name: "BFINIT command", + commands: []HTTPCommand{ + {Command: "COMMAND/GETKEYS", Body: map[string]interface{}{"key": "BFINIT", "field": "bloom", "values": []interface{}{"some", "parameters"}}}, + }, + expected: []interface{}{[]interface{}{"bloom"}}, + }, + { + name: "PING command", + commands: []HTTPCommand{ + {Command: "COMMAND/GETKEYS", Body: map[string]interface{}{"key": "PING"}}, + }, + expected: []interface{}{"ERR the command has no key arguments"}, + }, + { + name: "Invalid Get command", + commands: []HTTPCommand{ + {Command: "COMMAND/GETKEYS", Body: map[string]interface{}{"key": "GET"}}, + }, + expected: []interface{}{"ERR invalid number of arguments specified for command"}, + }, + { + name: "Abort command", + commands: []HTTPCommand{ + {Command: "COMMAND/GETKEYS", Body: map[string]interface{}{"key": "ABORT"}}, + }, + expected: []interface{}{"ERR the command has no key arguments"}, + }, + { + name: "Invalid command", + commands: []HTTPCommand{ + {Command: "COMMAND/GETKEYS", Body: map[string]interface{}{"key": "NotValidCommand"}}, + }, + expected: []interface{}{"ERR invalid command specified"}, + }, + { + name: "Wrong number of arguments", + commands: []HTTPCommand{ + {Command: "COMMAND/GETKEYS", Body: map[string]interface{}{"key": ""}}, + }, + expected: []interface{}{"ERR invalid command specified"}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + for i, cmd := range tc.commands { + result, _ := exec.FireCommand(cmd) + assert.DeepEqual(t, tc.expected[i], result) + } + }) + } +} diff --git a/integration_tests/commands/http/command_info_test.go b/integration_tests/commands/http/command_info_test.go new file mode 100644 index 000000000..76090c2dc --- /dev/null +++ b/integration_tests/commands/http/command_info_test.go @@ -0,0 +1,71 @@ +package http + +import ( + "testing" + + "gotest.tools/v3/assert" +) + +func TestCommandInfo(t *testing.T) { + exec := NewHTTPCommandExecutor() + + testCases := []TestCase{ + { + name: "Set command", + commands: []HTTPCommand{ + {Command: "COMMAND/INFO", Body: map[string]interface{}{"key": "SET"}}, + }, + expected: []interface{}{[]interface{}{[]interface{}{"SET", float64(-3), float64(1), float64(0), float64(0)}}}, + }, + { + name: "Get command", + commands: []HTTPCommand{ + {Command: "COMMAND/INFO", Body: map[string]interface{}{"key": "GET"}}, + }, + expected: []interface{}{[]interface{}{[]interface{}{"GET", float64(2), float64(1), float64(0), float64(0)}}}, + }, + { + name: "PING command", + commands: []HTTPCommand{ + {Command: "COMMAND/INFO", Body: map[string]interface{}{"key": "PING"}}, + }, + expected: []interface{}{[]interface{}{[]interface{}{"PING", float64(-1), float64(0), float64(0), float64(0)}}}, + }, + { + name: "Invalid command", + commands: []HTTPCommand{ + {Command: "COMMAND/INFO", Body: map[string]interface{}{"key": "INVALID_CMD"}}, + }, + expected: []interface{}{[]interface{}{"(nil)"}}, + }, + { + name: "Combination of valid and Invalid command", + commands: []HTTPCommand{ + {Command: "COMMAND/INFO", Body: map[string]interface{}{"keys": []interface{}{"SET", "INVALID_CMD"}}}, + }, + expected: []interface{}{[]interface{}{ + []interface{}{"SET", float64(-3), float64(1), float64(0), float64(0)}, + "(nil)", + }}, + }, + { + name: "Combination of multiple valid commands", + commands: []HTTPCommand{ + {Command: "COMMAND/INFO", Body: map[string]interface{}{"keys": []interface{}{"SET", "GET"}}}, + }, + expected: []interface{}{[]interface{}{ + []interface{}{"SET", float64(-3), float64(1), float64(0), float64(0)}, + []interface{}{"GET", float64(2), float64(1), float64(0), float64(0)}, + }}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + for i, cmd := range tc.commands { + result, _ := exec.FireCommand(cmd) + assert.DeepEqual(t, tc.expected[i], result) + } + }) + } +} diff --git a/integration_tests/commands/http/command_list_test.go b/integration_tests/commands/http/command_list_test.go new file mode 100644 index 000000000..c5d0c95bf --- /dev/null +++ b/integration_tests/commands/http/command_list_test.go @@ -0,0 +1,37 @@ +package http + +import ( + "fmt" + "testing" + + "gotest.tools/v3/assert" +) + +func TestCommandList(t *testing.T) { + exec := NewHTTPCommandExecutor() + + testCases := []TestCase{ + { + name: "Command list should not be empty", + commands: []HTTPCommand{ + {Command: "COMMAND/LIST", Body: map[string]interface{}{"key": ""}}, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + for _, cmd := range tc.commands { + result, _ := exec.FireCommand(cmd) + var commandList []string + for _, v := range result.([]interface{}) { + commandList = append(commandList, v.(string)) + } + + assert.Assert(t, len(commandList) > 0, + fmt.Sprintf("Unexpected number of CLI commands found. expected greater than 0, %d found", len(commandList))) + } + + }) + } +} diff --git a/integration_tests/commands/http/command_rename_test.go b/integration_tests/commands/http/command_rename_test.go new file mode 100644 index 000000000..9ebd38b07 --- /dev/null +++ b/integration_tests/commands/http/command_rename_test.go @@ -0,0 +1,69 @@ +package http + +import ( + "testing" + + "gotest.tools/v3/assert" +) + +func TestCommandRename(t *testing.T) { + exec := NewHTTPCommandExecutor() + + testCases := []TestCase{ + { + name: "Set key and Rename key", + commands: []HTTPCommand{ + {Command: "SET", Body: map[string]interface{}{"key": "sourceKey", "value": "hello"}}, + {Command: "GET", Body: map[string]interface{}{"key": "sourceKey"}}, + {Command: "RENAME", Body: map[string]interface{}{"keys": []interface{}{"sourceKey", "destKey"}}}, + {Command: "GET", Body: map[string]interface{}{"key": "destKey"}}, + {Command: "GET", Body: map[string]interface{}{"key": "sourceKey"}}, + }, + expected: []interface{}{"OK", "hello", "OK", "hello", "(nil)"}, + }, + { + name: "same key for source and destination on Rename", + commands: []HTTPCommand{ + {Command: "SET", Body: map[string]interface{}{"key": "Key", "value": "hello"}}, + {Command: "GET", Body: map[string]interface{}{"key": "Key"}}, + {Command: "RENAME", Body: map[string]interface{}{"keys": []interface{}{"Key", "Key"}}}, + {Command: "GET", Body: map[string]interface{}{"key": "Key"}}, + }, + expected: []interface{}{"OK", "hello", "OK", "hello"}, + }, + { + name: "If source key doesn't exists", + commands: []HTTPCommand{ + {Command: "RENAME", Body: map[string]interface{}{"keys": []interface{}{"unknownKey", "Key"}}}, + }, + expected: []interface{}{"ERR no such key"}, + }, + { + name: "If source key doesn't exists and renaming the same key to the same key", + commands: []HTTPCommand{ + {Command: "RENAME", Body: map[string]interface{}{"keys": []interface{}{"unknownKey", "unknownKey"}}}, + }, + expected: []interface{}{"ERR no such key"}, + }, + { + name: "If destination Key already presents", + commands: []HTTPCommand{ + {Command: "SET", Body: map[string]interface{}{"key": "destinationKey", "value": "world"}}, + {Command: "SET", Body: map[string]interface{}{"key": "newKey", "value": "hello"}}, + {Command: "RENAME", Body: map[string]interface{}{"keys": []interface{}{"newKey", "destinationKey"}}}, + {Command: "GET", Body: map[string]interface{}{"key": "newKey"}}, + {Command: "GET", Body: map[string]interface{}{"key": "destinationKey"}}, + }, + expected: []interface{}{"OK", "OK", "OK", "(nil)", "hello"}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + for i, cmd := range tc.commands { + result, _ := exec.FireCommand(cmd) + assert.DeepEqual(t, tc.expected[i], result) + } + }) + } +} diff --git a/integration_tests/commands/http/copy_test.go b/integration_tests/commands/http/copy_test.go new file mode 100644 index 000000000..3ef92f047 --- /dev/null +++ b/integration_tests/commands/http/copy_test.go @@ -0,0 +1,125 @@ +package http + +import ( + "github.com/dicedb/dice/testutils" + "testing" + + testifyAssert "github.com/stretchr/testify/assert" +) + +func TestCopy(t *testing.T) { + exec := NewHTTPCommandExecutor() + simpleJSON := `{"name":"John","age":30}` + + testCases := []TestCase{ + { + name: "COPY when source key doesn't exist", + commands: []HTTPCommand{ + {Command: "COPY", Body: map[string]interface{}{"keys": []interface{}{"k1", "k2"}}}, + }, + expected: []interface{}{float64(0)}, + }, + { + name: "COPY with no REPLACE", + commands: []HTTPCommand{ + {Command: "SET", Body: map[string]interface{}{"key": "k1", "value": "v1"}}, + {Command: "COPY", Body: map[string]interface{}{"keys": []interface{}{"k1", "k2"}}}, + {Command: "GET", Body: map[string]interface{}{"key": "k1"}}, + {Command: "GET", Body: map[string]interface{}{"key": "k2"}}, + }, + expected: []interface{}{"OK", float64(1), "v1", "v1"}, + }, + { + name: "COPY with REPLACE", + commands: []HTTPCommand{ + {Command: "SET", Body: map[string]interface{}{"key": "k1", "value": "v1"}}, + {Command: "SET", Body: map[string]interface{}{"key": "k2", "value": "v2"}}, + {Command: "GET", Body: map[string]interface{}{"key": "k2"}}, + {Command: "COPY", Body: map[string]interface{}{"keys": []interface{}{"k1", "k2"}, "value": "REPLACE"}}, + {Command: "GET", Body: map[string]interface{}{"key": "k2"}}, + }, + expected: []interface{}{"OK", "OK", "v2", float64(1), "v1"}, + }, + { + name: "COPY with JSON integer", + commands: []HTTPCommand{ + {Command: "JSON.SET", Body: map[string]interface{}{"key": "k1", "path": "$", "value": "2"}}, + {Command: "COPY", Body: map[string]interface{}{"keys": []interface{}{"k1", "k2"}}}, + {Command: "JSON.GET", Body: map[string]interface{}{"key": "k2"}}, + }, + expected: []interface{}{"OK", float64(1), "2"}, + }, + { + name: "COPY with JSON boolean", + commands: []HTTPCommand{ + {Command: "JSON.SET", Body: map[string]interface{}{"key": "k1", "path": "$", "value": "true"}}, + {Command: "COPY", Body: map[string]interface{}{"keys": []interface{}{"k1", "k2"}}}, + {Command: "JSON.GET", Body: map[string]interface{}{"key": "k2"}}, + }, + expected: []interface{}{"OK", float64(1), "true"}, + }, + { + name: "COPY with JSON array", + commands: []HTTPCommand{ + {Command: "JSON.SET", Body: map[string]interface{}{"key": "k1", "path": "$", "value": "[1,2,3]"}}, + {Command: "COPY", Body: map[string]interface{}{"keys": []interface{}{"k1", "k2"}}}, + {Command: "JSON.GET", Body: map[string]interface{}{"key": "k2"}}, + }, + expected: []interface{}{"OK", float64(1), "[1,2,3]"}, + }, + { + name: "COPY with JSON simple JSON", + commands: []HTTPCommand{ + {Command: "JSON.SET", Body: map[string]interface{}{"key": "k1", "path": "$", "value": simpleJSON}}, + {Command: "COPY", Body: map[string]interface{}{"keys": []interface{}{"k1", "k2"}}}, + {Command: "JSON.GET", Body: map[string]interface{}{"key": "k2"}}, + }, + expected: []interface{}{"OK", float64(1), simpleJSON}, + }, + { + name: "COPY with no expiry", + commands: []HTTPCommand{ + {Command: "SET", Body: map[string]interface{}{"key": "k1", "value": "v1"}}, + {Command: "COPY", Body: map[string]interface{}{"keys": []interface{}{"k1", "k2"}}}, + {Command: "TTL", Body: map[string]interface{}{"key": "k1"}}, + {Command: "TTL", Body: map[string]interface{}{"key": "k2"}}, + }, + expected: []interface{}{"OK", float64(1), float64(-1), float64(-1)}, + }, + { + name: "COPY with expiry making sure copy expires", + commands: []HTTPCommand{ + {Command: "SET", Body: map[string]interface{}{"key": "k1", "value": "v1", "ex": 5}}, + {Command: "COPY", Body: map[string]interface{}{"keys": []interface{}{"k1", "k2"}}}, + {Command: "GET", Body: map[string]interface{}{"key": "k1"}}, + {Command: "GET", Body: map[string]interface{}{"key": "k2"}}, + {Command: "SLEEP", Body: map[string]interface{}{"key": 7}}, + {Command: "GET", Body: map[string]interface{}{"key": "k1"}}, + {Command: "GET", Body: map[string]interface{}{"key": "k2"}}, + }, + expected: []interface{}{"OK", float64(1), "v1", "v1", "OK", "(nil)", "(nil)"}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + exec.FireCommand(HTTPCommand{Command: "DEL", Body: map[string]interface{}{"keys": []interface{}{"k1"}}}) + exec.FireCommand(HTTPCommand{Command: "DEL", Body: map[string]interface{}{"keys": []interface{}{"k2"}}}) + for i, cmd := range tc.commands { + result, _ := exec.FireCommand(cmd) + _, ok := result.(float64) + if ok { + testifyAssert.Equal(t, tc.expected[i], result) + continue + } + + if testutils.IsJSONResponse(result.(string)) { + testifyAssert.JSONEq(t, tc.expected[i].(string), result.(string)) + } else { + testifyAssert.Equal(t, tc.expected[i], result) + } + } + }) + } + +} diff --git a/integration_tests/commands/http/hsetnx_test.go b/integration_tests/commands/http/hsetnx_test.go new file mode 100644 index 000000000..f4925f26e --- /dev/null +++ b/integration_tests/commands/http/hsetnx_test.go @@ -0,0 +1,73 @@ +package http + +import ( + "testing" + "time" + + "gotest.tools/v3/assert" +) + +func TestHSetNX(t *testing.T) { + exec := NewHTTPCommandExecutor() + + testCases := []struct { + name string + commands []HTTPCommand + expected []interface{} + delays []time.Duration + }{ + { + name: "HSetNX returns 0 when field is already set", + commands: []HTTPCommand{ + {Command: "HSETNX", Body: map[string]interface{}{"key": "key_nx_t1", "field": "field", "value": "value"}}, + {Command: "HSETNX", Body: map[string]interface{}{"key": "key_nx_t1", "field": "field", "value": "value_new"}}, + }, + expected: []interface{}{float64(1), float64(0)}, + delays: []time.Duration{0, 0}, + }, + { + name: "HSetNX with new field", + commands: []HTTPCommand{ + {Command: "HSETNX", Body: map[string]interface{}{"key": "key_nx_t2", "field": "field", "value": "value"}}, + }, + expected: []interface{}{float64(1)}, + delays: []time.Duration{0}, + }, + { + name: "HSetNX with wrong number of arguments", + commands: []HTTPCommand{ + {Command: "HSETNX", Body: map[string]interface{}{"key": "key_nx_t3", "field": "field", "value": "value"}}, + {Command: "HSETNX", Body: map[string]interface{}{"key": "key_nx_t3", "field": "field", "value": "value_new"}}, + {Command: "HSETNX", Body: map[string]interface{}{"key": "key_nx_t3"}}, + }, + expected: []interface{}{float64(1), float64(0), "ERR wrong number of arguments for 'hsetnx' command"}, + delays: []time.Duration{0, 0, 0}, + }, + { + name: "HSetNX with wrong type", + commands: []HTTPCommand{ + {Command: "SET", Body: map[string]interface{}{"key": "key_nx_t4", "value": "v"}}, + {Command: "HSETNX", Body: map[string]interface{}{"key": "key_nx_t4", "field": "f", "value": "v_new"}}, + }, + expected: []interface{}{"OK", "WRONGTYPE Operation against a key holding the wrong kind of value"}, + delays: []time.Duration{0, 0}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + for i, cmd := range tc.commands { + if tc.delays[i] > 0 { + time.Sleep(tc.delays[i]) + } + result, err := exec.FireCommand(cmd) + if err != nil { + // Check if the error message matches the expected result + assert.Equal(t, tc.expected[i], err.Error(), "Error message mismatch for cmd %s", cmd) + } else { + assert.Equal(t, tc.expected[i], result, "Value mismatch for cmd %s, expected %v, got %v", cmd, tc.expected[i], result) + } + } + }) + } +} diff --git a/integration_tests/commands/http/hstrlen_test.go b/integration_tests/commands/http/hstrlen_test.go new file mode 100644 index 000000000..f91d53b0b --- /dev/null +++ b/integration_tests/commands/http/hstrlen_test.go @@ -0,0 +1,88 @@ +package http + +import ( + "log" + "testing" + "time" + + "gotest.tools/v3/assert" +) + +func TestHStrLen(t *testing.T) { + exec := NewHTTPCommandExecutor() + + testCases := []struct { + name string + commands []HTTPCommand + expected []interface{} + delays []time.Duration + }{ + { + name: "HSTRLEN with wrong number of arguments", + commands: []HTTPCommand{ + {Command: "HSTRLEN", Body: map[string]interface{}{"key": "KEY"}}, + {Command: "HSTRLEN", Body: map[string]interface{}{"key": "KEY", "field": "field", "another_field": "another_field"}}, + }, + expected: []interface{}{ + "ERR wrong number of arguments for 'hstrlen' command", + "ERR wrong number of arguments for 'hstrlen' command"}, + delays: []time.Duration{0, 0}, + }, + { + name: "HSTRLEN with wrong key", + commands: []HTTPCommand{ + {Command: "HSET", Body: map[string]interface{}{"key": "key_hStrLen1", "field": "field", "value": "value"}}, + {Command: "HSTRLEN", Body: map[string]interface{}{"key": "wrong_key_hStrLen", "field": "field"}}, + }, + expected: []interface{}{float64(1), float64(0)}, + delays: []time.Duration{0, 0}, + }, + { + name: "HSTRLEN with wrong field", + commands: []HTTPCommand{ + {Command: "HSET", Body: map[string]interface{}{"key": "key_hStrLen2", "field": "field", "value": "value"}}, + {Command: "HSTRLEN", Body: map[string]interface{}{"key": "key_hStrLen2", "field": "wrong_field"}}, + }, + expected: []interface{}{float64(1), float64(0)}, + delays: []time.Duration{0, 0}, + }, + { + name: "HSTRLEN", + commands: []HTTPCommand{ + {Command: "HSET", Body: map[string]interface{}{"key": "key_hStrLen3", "field": "field", "value": "HelloWorld"}}, + {Command: "HSTRLEN", Body: map[string]interface{}{"key": "key_hStrLen3", "field": "field"}}, + }, + expected: []interface{}{float64(1), float64(10)}, + delays: []time.Duration{0, 0}, + }, + { + name: "HSTRLEN with wrong type", + commands: []HTTPCommand{ + {Command: "SET", Body: map[string]interface{}{"key": "key", "value": "value"}}, + {Command: "HSTRLEN", Body: map[string]interface{}{"key": "key", "field": "field"}}, + }, + expected: []interface{}{"OK", "WRONGTYPE Operation against a key holding the wrong kind of value"}, + delays: []time.Duration{0, 0}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + defer exec.FireCommand(HTTPCommand{Command: "DEL", Body: map[string]interface{}{"keys": [...]string{"KEY", "key"}}}) + + for i, cmd := range tc.commands { + if tc.delays[i] > 0 { + time.Sleep(tc.delays[i]) + } + result, err := exec.FireCommand(cmd) + if err != nil { + // Check if the error message matches the expected result + log.Println(tc.expected[i]) + assert.Equal(t, tc.expected[i], err.Error(), "Error message mismatch for cmd %s", cmd) + } else { + assert.Equal(t, tc.expected[i], result, "Value mismatch for cmd %s, expected %v, got %v", cmd, tc.expected[i], result) + } + } + }) + } +} diff --git a/integration_tests/commands/http/hyperloglog_test.go b/integration_tests/commands/http/hyperloglog_test.go new file mode 100644 index 000000000..d29ec7ce8 --- /dev/null +++ b/integration_tests/commands/http/hyperloglog_test.go @@ -0,0 +1,150 @@ +package http + +import ( + "testing" + "time" + + "gotest.tools/v3/assert" +) + +func TestHyperLogLogCommands(t *testing.T) { + exec := NewHTTPCommandExecutor() + + testCases := []struct { + name string + commands []HTTPCommand + expected []interface{} + delays []time.Duration + }{ + { + name: "PFADD with one key-value pair", + commands: []HTTPCommand{ + {Command: "PFADD", Body: map[string]interface{}{"key": "hll0", "value": "v1"}}, + {Command: "PFCOUNT", Body: map[string]interface{}{"key": "hll0"}}, + }, + expected: []interface{}{float64(1), float64(1)}, + delays: []time.Duration{0, 0}, + }, + { + name: "PFADD with multiple key-value pair", + commands: []HTTPCommand{ + {Command: "PFADD", Body: map[string]interface{}{"key": "hll", "values": [...]string{"a", "b", "c", "d", "e", "f"}}}, + {Command: "PFCOUNT", Body: map[string]interface{}{"key": "hll"}}, + }, + expected: []interface{}{float64(1), float64(6)}, + delays: []time.Duration{0, 0}, + }, + { + name: "PFADD with duplicate key-value pairs", + commands: []HTTPCommand{ + {Command: "PFADD", Body: map[string]interface{}{"key": "hll1", "values": [...]string{"foo", "bar", "zap"}}}, + {Command: "PFADD", Body: map[string]interface{}{"key": "hll1", "values": [...]string{"zap", "zap", "zap"}}}, + {Command: "PFADD", Body: map[string]interface{}{"key": "hll1", "values": [...]string{"foo", "bar"}}}, + {Command: "PFCOUNT", Body: map[string]interface{}{"key": "hll1"}}, + }, + expected: []interface{}{float64(1), float64(0), float64(0), float64(3)}, + delays: []time.Duration{0, 0, 0, 0}, + }, + { + name: "PFADD with multiple keys", + commands: []HTTPCommand{ + {Command: "PFADD", Body: map[string]interface{}{"key": "hll2", "values": [...]string{"foo", "bar", "zap"}}}, + {Command: "PFADD", Body: map[string]interface{}{"key": "hll2", "values": [...]string{"zap", "zap", "zap"}}}, + {Command: "PFCOUNT", Body: map[string]interface{}{"key": "hll2"}}, + {Command: "PFADD", Body: map[string]interface{}{"key": "some-other-hll", "values": [...]string{"1", "2", "3"}}}, + {Command: "PFCOUNT", Body: map[string]interface{}{"keys": [...]string{"hll2", "some-other-hll"}}}, + }, + expected: []interface{}{float64(1), float64(0), float64(3), float64(1), float64(6)}, + delays: []time.Duration{0, 0, 0, 0, 0}, + }, + { + name: "PFADD with non-existing key", + commands: []HTTPCommand{ + {Command: "PFADD", Body: map[string]interface{}{"key": "hll3", "values": [...]string{"foo", "bar", "zap"}}}, + {Command: "PFADD", Body: map[string]interface{}{"key": "hll3", "values": [...]string{"zap", "zap", "zap"}}}, + {Command: "PFCOUNT", Body: map[string]interface{}{"key": "hll3"}}, + {Command: "PFCOUNT", Body: map[string]interface{}{"keys": [...]string{"hll3", "non-exist-hll"}}}, + {Command: "PFADD", Body: map[string]interface{}{"key": "some-new-hll", "value": "abc"}}, + {Command: "PFCOUNT", Body: map[string]interface{}{"keys": [...]string{"hll3", "non-exist-hll", "some-new-hll"}}}, + }, + expected: []interface{}{float64(1), float64(0), float64(3), float64(3), float64(1), float64(4)}, + delays: []time.Duration{0, 0, 0, 0, 0, 0}, + }, + { + name: "PFMERGE with srcKey non-existing", + commands: []HTTPCommand{ + {Command: "PFMERGE", Body: map[string]interface{}{"key": "NON_EXISTING_SRC_KEY"}}, + {Command: "PFCOUNT", Body: map[string]interface{}{"key": "NON_EXISTING_SRC_KEY"}}, + }, + expected: []interface{}{"OK", float64(0)}, + delays: []time.Duration{0, 0}, + }, + { + name: "PFMERGE with destKey non-existing", + commands: []HTTPCommand{ + {Command: "PFMERGE", Body: map[string]interface{}{"keys": []string{"EXISTING_SRC_KEY", "NON_EXISTING_SRC_KEY"}}}, + {Command: "PFCOUNT", Body: map[string]interface{}{"key": "EXISTING_SRC_KEY"}}, + }, + expected: []interface{}{"OK", float64(0)}, + delays: []time.Duration{0, 0}, + }, + { + name: "PFMERGE with destKey existing", + commands: []HTTPCommand{ + {Command: "PFADD", Body: map[string]interface{}{"key": "DEST_KEY_1", "values": [...]string{"foo", "bar", "zap", "a"}}}, + {Command: "PFADD", Body: map[string]interface{}{"key": "DEST_KEY_2", "values": [...]string{"a", "b", "c", "foo"}}}, + {Command: "PFMERGE", Body: map[string]interface{}{"keys": [...]string{"SRC_KEY_1", "DEST_KEY_1", "DEST_KEY_2"}}}, + {Command: "PFCOUNT", Body: map[string]interface{}{"key": "SRC_KEY_1"}}, + }, + expected: []interface{}{float64(1), float64(1), "OK", float64(6)}, + delays: []time.Duration{0, 0, 0, 0}, + }, + { + name: "PFMERGE with only one destKey existing", + commands: []HTTPCommand{ + {Command: "PFADD", Body: map[string]interface{}{"key": "DEST_KEY_3", "values": [...]string{"foo", "bar", "zap", "a"}}}, + {Command: "PFMERGE", Body: map[string]interface{}{"keys": [...]string{"SRC_KEY_2", "DEST_KEY_3", "NON_EXISTING_DEST_KEY"}}}, + {Command: "PFCOUNT", Body: map[string]interface{}{"key": "SRC_KEY_2"}}, + }, + expected: []interface{}{float64(1), "OK", float64(4)}, + delays: []time.Duration{0, 0, 0}, + }, + { + name: "PFMERGE with invalid object", + commands: []HTTPCommand{ + {Command: "PFADD", Body: map[string]interface{}{"key": "INVALID_HLL", "values": [...]string{"a", "b", "c"}}}, + {Command: "SET", Body: map[string]interface{}{"key": "INVALID_HLL", "value": "1"}}, + {Command: "PFMERGE", Body: map[string]interface{}{"key": "INVALID_HLL"}}, + }, + expected: []interface{}{float64(1), "OK", "WRONGTYPE Key is not a valid HyperLogLog string value."}, + delays: []time.Duration{0, 0, 0}, + }, + { + name: "PFMERGE with invalid src object", + commands: []HTTPCommand{ + {Command: "PFADD", Body: map[string]interface{}{"key": "INVALID_SRC_HLL", "values": [...]string{"a", "b", "c"}}}, + {Command: "SET", Body: map[string]interface{}{"key": "INVALID_SRC_HLL", "value": "1"}}, + {Command: "PFMERGE", Body: map[string]interface{}{"keys": [...]string{"HLL", "INVALID_SRC_HLL"}}}, + }, + expected: []interface{}{float64(1), "OK", "WRONGTYPE Key is not a valid HyperLogLog string value."}, + delays: []time.Duration{0, 0, 0}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + for i, cmd := range tc.commands { + if tc.delays[i] > 0 { + time.Sleep(tc.delays[i]) + } + result, err := exec.FireCommand(cmd) + if err != nil { + // Check if the error message matches the expected result + assert.Equal(t, tc.expected[i], err.Error(), "Error message mismatch for cmd %s", cmd) + } else { + assert.Equal(t, tc.expected[i], result, "Value mismatch for cmd %s, expected %v, got %v", cmd, tc.expected[i], result) + } + } + }) + } +} diff --git a/integration_tests/commands/http/incr_by_float_test.go b/integration_tests/commands/http/incr_by_float_test.go new file mode 100644 index 000000000..d218d8619 --- /dev/null +++ b/integration_tests/commands/http/incr_by_float_test.go @@ -0,0 +1,132 @@ +package http + +import ( + "testing" + "time" + + "gotest.tools/v3/assert" +) + +func TestINCRBYFLOAT(t *testing.T) { + exec := NewHTTPCommandExecutor() + + invalidArgMessage := "ERR wrong number of arguments for 'incrbyfloat' command" + invalidValueTypeMessage := "WRONGTYPE Operation against a key holding the wrong kind of value" + invalidIncrTypeMessage := "ERR value is not an integer or a float" + valueOutOfRangeMessage := "ERR value is out of range" + + testCases := []struct { + name string + commands []HTTPCommand + expected []interface{} + delays []time.Duration + }{ + { + name: "Invalid number of arguments", + commands: []HTTPCommand{ + {Command: "INCRBYFLOAT", Body: map[string]interface{}{"key": nil}}, + {Command: "INCRBYFLOAT", Body: map[string]interface{}{"key": "foo"}}, + }, + expected: []interface{}{invalidArgMessage, invalidArgMessage}, + delays: []time.Duration{0, 0}, + }, + { + name: "Increment a non existing key", + commands: []HTTPCommand{ + {Command: "INCRBYFLOAT", Body: map[string]interface{}{"key": "foo", "value": 0.1}}, + {Command: "GET", Body: map[string]interface{}{"key": "foo"}}, + }, + expected: []interface{}{"0.1", "0.1"}, + delays: []time.Duration{0, 0}, + }, + { + name: "Increment a key with an integer value", + commands: []HTTPCommand{ + {Command: "SET", Body: map[string]interface{}{"key": "foo", "value": "1"}}, + {Command: "INCRBYFLOAT", Body: map[string]interface{}{"key": "foo", "value": 0.1}}, + {Command: "GET", Body: map[string]interface{}{"key": "foo"}}, + }, + expected: []interface{}{"OK", "1.1", "1.1"}, + delays: []time.Duration{0, 0, 0}, + }, + { + name: "Increment and then decrement a key with the same value", + commands: []HTTPCommand{ + {Command: "SET", Body: map[string]interface{}{"key": "foo", "value": "1"}}, + {Command: "INCRBYFLOAT", Body: map[string]interface{}{"key": "foo", "value": 0.1}}, + {Command: "GET", Body: map[string]interface{}{"key": "foo"}}, + {Command: "INCRBYFLOAT", Body: map[string]interface{}{"key": "foo", "value": -0.1}}, + {Command: "GET", Body: map[string]interface{}{"key": "foo"}}, + }, + expected: []interface{}{"OK", "1.1", "1.1", "1", "1"}, + delays: []time.Duration{0, 0, 0, 0, 0}, + }, + { + name: "Increment a non numeric value", + commands: []HTTPCommand{ + {Command: "SET", Body: map[string]interface{}{"key": "foo", "value": "bar"}}, + {Command: "INCRBYFLOAT", Body: map[string]interface{}{"key": "foo", "value": 0.1}}, + }, + expected: []interface{}{"OK", invalidValueTypeMessage}, + delays: []time.Duration{0, 0}, + }, + { + name: "Increment by a non numeric value", + commands: []HTTPCommand{ + {Command: "SET", Body: map[string]interface{}{"key": "foo", "value": "1"}}, + {Command: "INCRBYFLOAT", Body: map[string]interface{}{"key": "foo", "value": "bar"}}, + }, + expected: []interface{}{"OK", invalidIncrTypeMessage}, + delays: []time.Duration{0, 0}, + }, + { + name: "Increment by both integer and float", + commands: []HTTPCommand{ + {Command: "SET", Body: map[string]interface{}{"key": "foo", "value": "1"}}, + {Command: "INCRBYFLOAT", Body: map[string]interface{}{"key": "foo", "value": 1}}, + {Command: "INCRBYFLOAT", Body: map[string]interface{}{"key": "foo", "value": 0.1}}, + }, + expected: []interface{}{"OK", "2", "2.1"}, + delays: []time.Duration{0, 0, 0}, + }, + { + name: "Increment that would make the value Inf", + commands: []HTTPCommand{ + {Command: "SET", Body: map[string]interface{}{"key": "foo", "value": "1e308"}}, + {Command: "INCRBYFLOAT", Body: map[string]interface{}{"key": "foo", "value": 1e308}}, + {Command: "INCRBYFLOAT", Body: map[string]interface{}{"key": "foo", "value": -1e308}}, + }, + expected: []interface{}{"OK", valueOutOfRangeMessage, "0"}, + delays: []time.Duration{0, 0, 0}, + }, + { + name: "Increment that would make the value -Inf", + commands: []HTTPCommand{ + {Command: "SET", Body: map[string]interface{}{"key": "foo", "value": "-1e308"}}, + {Command: "INCRBYFLOAT", Body: map[string]interface{}{"key": "foo", "value": -1e308}}, + {Command: "INCRBYFLOAT", Body: map[string]interface{}{"key": "foo", "value": 1e308}}, + }, + expected: []interface{}{"OK", valueOutOfRangeMessage, "0"}, + delays: []time.Duration{0, 0, 0}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + defer exec.FireCommand(HTTPCommand{Command: "DEL", Body: map[string]interface{}{"key": "foo"}}) + + for i, cmd := range tc.commands { + if tc.delays[i] > 0 { + time.Sleep(tc.delays[i]) + } + result, err := exec.FireCommand(cmd) + if err != nil { + // Check if the error message matches the expected result + assert.Equal(t, tc.expected[i], err.Error(), "Error message mismatch for cmd %s", cmd) + } else { + assert.Equal(t, tc.expected[i], result, "Value mismatch for cmd %s, expected %v, got %v", cmd, tc.expected[i], result) + } + } + }) + } +} diff --git a/integration_tests/commands/http/incr_test.go b/integration_tests/commands/http/incr_test.go new file mode 100644 index 000000000..6a6ca3704 --- /dev/null +++ b/integration_tests/commands/http/incr_test.go @@ -0,0 +1,222 @@ +package http + +import ( + "math" + "strconv" + "testing" + "time" + + "gotest.tools/v3/assert" +) + +func TestINCR(t *testing.T) { + exec := NewHTTPCommandExecutor() + + exec.FireCommand(HTTPCommand{Command: "DEL", Body: map[string]interface{}{"keys": [...]string{"key1", "key2"}}}) + + testCases := []struct { + name string + commands []HTTPCommand + expected []interface{} + delays []time.Duration + }{ + { + name: "Increment multiple keys", + commands: []HTTPCommand{ + {Command: "SET", Body: map[string]interface{}{"key": "key1", "value": 0}}, + {Command: "INCR", Body: map[string]interface{}{"key": "key1"}}, + {Command: "INCR", Body: map[string]interface{}{"key": "key1"}}, + {Command: "INCR", Body: map[string]interface{}{"key": "key2"}}, + {Command: "GET", Body: map[string]interface{}{"key": "key1"}}, + {Command: "GET", Body: map[string]interface{}{"key": "key2"}}, + }, + expected: []interface{}{"OK", float64(1), float64(2), float64(1), float64(2), float64(1)}, + delays: []time.Duration{0, 0, 0, 0, 0, 0}, + }, + { + name: "Increment to and from max int64", + commands: []HTTPCommand{ + {Command: "SET", Body: map[string]interface{}{"key": "max_int", "value": strconv.Itoa(math.MaxInt64 - 1)}}, + {Command: "INCR", Body: map[string]interface{}{"key": "max_int"}}, + {Command: "INCR", Body: map[string]interface{}{"key": "max_int"}}, + {Command: "SET", Body: map[string]interface{}{"key": "max_int", "value": strconv.Itoa(math.MaxInt64)}}, + {Command: "INCR", Body: map[string]interface{}{"key": "max_int"}}, + }, + expected: []interface{}{"OK", float64(math.MaxInt64), "ERR increment or decrement would overflow", "OK", "ERR increment or decrement would overflow"}, + delays: []time.Duration{0, 0, 0, 0, 0}, + }, + { + name: "Increment from min int64", + commands: []HTTPCommand{ + {Command: "SET", Body: map[string]interface{}{"key": "min_int", "value": strconv.Itoa(math.MinInt64)}}, + {Command: "INCR", Body: map[string]interface{}{"key": "min_int"}}, + {Command: "INCR", Body: map[string]interface{}{"key": "min_int"}}, + }, + expected: []interface{}{"OK", float64(math.MinInt64 + 1), float64(math.MinInt64 + 2)}, + delays: []time.Duration{0, 0, 0}, + }, + { + name: "Increment non-integer values", + commands: []HTTPCommand{ + {Command: "SET", Body: map[string]interface{}{"key": "float_key", "value": "3.14"}}, + {Command: "INCR", Body: map[string]interface{}{"key": "float_key"}}, + {Command: "SET", Body: map[string]interface{}{"key": "string_key", "value": "hello"}}, + {Command: "INCR", Body: map[string]interface{}{"key": "string_key"}}, + {Command: "SET", Body: map[string]interface{}{"key": "bool_key", "value": "true"}}, + {Command: "INCR", Body: map[string]interface{}{"key": "bool_key"}}, + }, + expected: []interface{}{"OK", "ERR value is not an integer or out of range", "OK", "ERR value is not an integer or out of range", "OK", "ERR value is not an integer or out of range"}, + delays: []time.Duration{0, 0, 0, 0, 0, 0}, + }, + { + name: "Increment non-existent key", + commands: []HTTPCommand{ + {Command: "INCR", Body: map[string]interface{}{"key": "non_existent"}}, + {Command: "GET", Body: map[string]interface{}{"key": "non_existent"}}, + {Command: "INCR", Body: map[string]interface{}{"key": "non_existent"}}, + }, + expected: []interface{}{float64(1), float64(1), float64(2)}, + delays: []time.Duration{0, 0, 0}, + }, + { + name: "Increment string representing integers", + commands: []HTTPCommand{ + {Command: "SET", Body: map[string]interface{}{"key": "str_int1", "value": "42"}}, + {Command: "INCR", Body: map[string]interface{}{"key": "str_int1"}}, + {Command: "SET", Body: map[string]interface{}{"key": "str_int2", "value": "-10"}}, + {Command: "INCR", Body: map[string]interface{}{"key": "str_int2"}}, + {Command: "SET", Body: map[string]interface{}{"key": "str_int3", "value": "0"}}, + {Command: "INCR", Body: map[string]interface{}{"key": "str_int3"}}, + }, + expected: []interface{}{"OK", float64(43), "OK", float64(-9), "OK", float64(1)}, + delays: []time.Duration{0, 0, 0, 0, 0, 0}, + }, + { + name: "Increment with expiry", + commands: []HTTPCommand{ + {Command: "SET", Body: map[string]interface{}{"key": "expiry_key", "value": 0, "ex": 1}}, + {Command: "INCR", Body: map[string]interface{}{"key": "expiry_key"}}, + {Command: "INCR", Body: map[string]interface{}{"key": "expiry_key"}}, + {Command: "INCR", Body: map[string]interface{}{"key": "expiry_key"}}, + }, + expected: []interface{}{"OK", float64(1), float64(2), float64(1)}, + delays: []time.Duration{0, 0, 0, 1 * time.Second}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + defer exec.FireCommand(HTTPCommand{Command: "DEL", Body: map[string]interface{}{"keys": [...]string{"key1", "key2", "expiry_key", "max_int", "min_int", "float_key", "string_key", "bool_key"}}}) + + for i, cmd := range tc.commands { + if tc.delays[i] > 0 { + time.Sleep(tc.delays[i]) + } + result, err := exec.FireCommand(cmd) + if err != nil { + // Check if the error message matches the expected result + assert.Equal(t, tc.expected[i], err.Error(), "Error message mismatch for cmd %s", cmd) + } else { + assert.Equal(t, tc.expected[i], result, "Value mismatch for cmd %s, expected %v, got %v", cmd, tc.expected[i], result) + } + } + }) + } +} + +func TestINCRBY(t *testing.T) { + exec := NewHTTPCommandExecutor() + + testCases := []struct { + name string + commands []HTTPCommand + expected []interface{} + delays []time.Duration + }{ + { + name: "INCRBY with postive increment", + commands: []HTTPCommand{ + {Command: "SET", Body: map[string]interface{}{"key": "key", "value": 3}}, + {Command: "INCRBY", Body: map[string]interface{}{"key": "key", "value": 2}}, + {Command: "INCRBY", Body: map[string]interface{}{"key": "key", "value": 1}}, + {Command: "GET", Body: map[string]interface{}{"key": "key"}}, + }, + expected: []interface{}{"OK", float64(5), float64(6), float64(6)}, + delays: []time.Duration{0, 0, 0, 0}, + }, + { + name: "INCRBY with negative increment", + commands: []HTTPCommand{ + {Command: "SET", Body: map[string]interface{}{"key": "key", "value": 100}}, + {Command: "INCRBY", Body: map[string]interface{}{"key": "key", "value": -2}}, + {Command: "INCRBY", Body: map[string]interface{}{"key": "key", "value": -10}}, + {Command: "INCRBY", Body: map[string]interface{}{"key": "key", "value": -88}}, + {Command: "INCRBY", Body: map[string]interface{}{"key": "key", "value": -100}}, + {Command: "GET", Body: map[string]interface{}{"key": "key"}}, + }, + expected: []interface{}{"OK", float64(98), float64(88), float64(0), float64(-100), float64(-100)}, + delays: []time.Duration{0, 0, 0, 0, 0, 0}, + }, + { + name: "INCRBY with unset key", + commands: []HTTPCommand{ + {Command: "SET", Body: map[string]interface{}{"key": "key", "value": 3}}, + {Command: "INCRBY", Body: map[string]interface{}{"key": "unsetKey", "value": 2}}, + {Command: "GET", Body: map[string]interface{}{"key": "key"}}, + {Command: "GET", Body: map[string]interface{}{"key": "unsetKey"}}, + }, + expected: []interface{}{"OK", float64(2), float64(3), float64(2)}, + delays: []time.Duration{0, 0, 0, 0}, + }, + { + name: "edge case with maximum int value", + commands: []HTTPCommand{ + {Command: "SET", Body: map[string]interface{}{"key": "key", "value": strconv.Itoa(math.MaxInt64 - 1)}}, + {Command: "INCRBY", Body: map[string]interface{}{"key": "key", "value": 1}}, + {Command: "INCRBY", Body: map[string]interface{}{"key": "key", "value": 1}}, + {Command: "GET", Body: map[string]interface{}{"key": "key"}}, + }, + expected: []interface{}{"OK", float64(math.MaxInt64), "ERR increment or decrement would overflow", float64(math.MaxInt64)}, + delays: []time.Duration{0, 0, 0, 0}, + }, + { + name: "edge case with minimum int value", + commands: []HTTPCommand{ + {Command: "SET", Body: map[string]interface{}{"key": "key", "value": strconv.Itoa(math.MinInt64 + 1)}}, + {Command: "INCRBY", Body: map[string]interface{}{"key": "key", "value": -1}}, + {Command: "INCRBY", Body: map[string]interface{}{"key": "key", "value": -1}}, + {Command: "GET", Body: map[string]interface{}{"key": "key"}}, + }, + expected: []interface{}{"OK", float64(math.MinInt64), "ERR increment or decrement would overflow", float64(math.MinInt64)}, + delays: []time.Duration{0, 0, 0, 0}, + }, + { + name: "edge case with string values", + commands: []HTTPCommand{ + {Command: "SET", Body: map[string]interface{}{"key": "key", "value": 1}}, + {Command: "INCRBY", Body: map[string]interface{}{"key": "stringKey", "value": "abc"}}, + }, + expected: []interface{}{"OK", "ERR value is not an integer or out of range"}, + delays: []time.Duration{0, 0}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + defer exec.FireCommand(HTTPCommand{Command: "DEL", Body: map[string]interface{}{"keys": [...]string{"key", "unsetKey", "stringkey"}}}) + + for i, cmd := range tc.commands { + if tc.delays[i] > 0 { + time.Sleep(tc.delays[i]) + } + result, err := exec.FireCommand(cmd) + if err != nil { + // Check if the error message matches the expected result + assert.Equal(t, tc.expected[i], err.Error(), "Error message mismatch for cmd %s", cmd) + } else { + assert.Equal(t, tc.expected[i], result, "Value mismatch for cmd %s, expected %v, got %v", cmd, tc.expected[i], result) + } + } + }) + } +} diff --git a/integration_tests/commands/http/set_data_cmd_test.go b/integration_tests/commands/http/set_data_cmd_test.go index f16511972..0dac37c02 100644 --- a/integration_tests/commands/http/set_data_cmd_test.go +++ b/integration_tests/commands/http/set_data_cmd_test.go @@ -263,6 +263,16 @@ func TestSetDataCmd(t *testing.T) { assert_type: []string{"equal", "equal", "equal", "equal"}, expected: []interface{}{float64(1), float64(1), "OK", "WRONGTYPE Operation against a key holding the wrong kind of value"}, }, + { + name: "SADD & SINTER with single key", + commands: []HTTPCommand{ + {Command: "SADD", Body: map[string]interface{}{"key": "foo", "value": "bar"}}, + {Command: "SADD", Body: map[string]interface{}{"key": "foo", "value": "baz"}}, + {Command: "SINTER", Body: map[string]interface{}{"values": []interface{}{"foo"}}}, + }, + assert_type: []string{"equal", "equal", "unordered_equal"}, + expected: []interface{}{float64(1), float64(1), []any{string("bar"), string("baz")}}, + }, } defer exec.FireCommand(HTTPCommand{ diff --git a/integration_tests/commands/http/set_test.go b/integration_tests/commands/http/set_test.go index ed367a1fd..42e170903 100644 --- a/integration_tests/commands/http/set_test.go +++ b/integration_tests/commands/http/set_test.go @@ -33,7 +33,7 @@ func TestSet(t *testing.T) { {Command: "SET", Body: map[string]interface{}{"key": "k", "value": 123456789}}, {Command: "GET", Body: map[string]interface{}{"key": "k"}}, }, - expected: []interface{}{"OK", "1.23456789e+08"}, + expected: []interface{}{"OK", 1.23456789e+08}, }, { name: "Overwrite Existing Key", diff --git a/internal/clientio/requestparser/parser.go b/internal/clientio/requestparser/parser.go index 691a277a3..07af168ce 100644 --- a/internal/clientio/requestparser/parser.go +++ b/internal/clientio/requestparser/parser.go @@ -5,5 +5,5 @@ import ( ) type Parser interface { - Parse(data []byte) ([]*cmd.RedisCmd, error) + Parse(data []byte) ([]*cmd.DiceDBCmd, error) } diff --git a/internal/clientio/requestparser/resp/respparser.go b/internal/clientio/requestparser/resp/respparser.go index 624542b39..fcf237fa4 100644 --- a/internal/clientio/requestparser/resp/respparser.go +++ b/internal/clientio/requestparser/resp/respparser.go @@ -52,10 +52,10 @@ func (p *Parser) SetData(data []byte) { p.pos = 0 } -// Parse parses the entire input and returns a slice of RedisCmd -func (p *Parser) Parse(data []byte) ([]*cmd.RedisCmd, error) { +// Parse parses the entire input and returns a slice of DiceDBCmd +func (p *Parser) Parse(data []byte) ([]*cmd.DiceDBCmd, error) { p.SetData(data) - var commands []*cmd.RedisCmd + var commands []*cmd.DiceDBCmd for p.pos < len(p.data) { c, err := p.parseCommand() if err != nil { @@ -68,7 +68,7 @@ func (p *Parser) Parse(data []byte) ([]*cmd.RedisCmd, error) { return commands, nil } -func (p *Parser) parseCommand() (*cmd.RedisCmd, error) { +func (p *Parser) parseCommand() (*cmd.DiceDBCmd, error) { if p.pos >= len(p.data) { return nil, ErrUnexpectedEOF } @@ -84,7 +84,7 @@ func (p *Parser) parseCommand() (*cmd.RedisCmd, error) { return nil, fmt.Errorf("error while parsing command, empty command") } - return &cmd.RedisCmd{ + return &cmd.DiceDBCmd{ Cmd: strings.ToUpper(elements[0]), Args: elements[1:], }, nil diff --git a/internal/clientio/requestparser/resp/respparser_test.go b/internal/clientio/requestparser/resp/respparser_test.go index fd98a731a..7d1178d69 100644 --- a/internal/clientio/requestparser/resp/respparser_test.go +++ b/internal/clientio/requestparser/resp/respparser_test.go @@ -1,11 +1,12 @@ package respparser import ( - "github.com/dicedb/dice/mocks" "log/slog" "reflect" "testing" + "github.com/dicedb/dice/mocks" + "github.com/dicedb/dice/internal/cmd" ) @@ -13,27 +14,27 @@ func TestParser_Parse(t *testing.T) { tests := []struct { name string input string - want []*cmd.RedisCmd + want []*cmd.DiceDBCmd wantErr bool }{ { name: "Simple SET command", input: "*3\r\n$3\r\nSET\r\n$3\r\nkey\r\n$5\r\nvalue\r\n", - want: []*cmd.RedisCmd{ + want: []*cmd.DiceDBCmd{ {Cmd: "SET", Args: []string{"key", "value"}}, }, }, { name: "GET command", input: "*2\r\n$3\r\nGET\r\n$3\r\nkey\r\n", - want: []*cmd.RedisCmd{ + want: []*cmd.DiceDBCmd{ {Cmd: "GET", Args: []string{"key"}}, }, }, { name: "Multiple commands", input: "*2\r\n$4\r\nPING\r\n$4\r\nPONG\r\n*3\r\n$3\r\nSET\r\n$3\r\nkey\r\n$5\r\nvalue\r\n", - want: []*cmd.RedisCmd{ + want: []*cmd.DiceDBCmd{ {Cmd: "PING", Args: []string{"PONG"}}, {Cmd: "SET", Args: []string{"key", "value"}}, }, @@ -41,7 +42,7 @@ func TestParser_Parse(t *testing.T) { { name: "Command with integer argument", input: "*3\r\n$6\r\nEXPIRE\r\n$3\r\nkey\r\n:60\r\n", - want: []*cmd.RedisCmd{ + want: []*cmd.DiceDBCmd{ {Cmd: "EXPIRE", Args: []string{"key", "60"}}, }, }, @@ -58,28 +59,28 @@ func TestParser_Parse(t *testing.T) { { name: "Command with null bulk string argument", input: "*3\r\n$3\r\nSET\r\n$3\r\nkey\r\n$-1\r\n", - want: []*cmd.RedisCmd{ + want: []*cmd.DiceDBCmd{ {Cmd: "SET", Args: []string{"key", "(nil)"}}, }, }, { name: "Command with Simple String argument", input: "*3\r\n$3\r\nSET\r\n$3\r\nkey\r\n+OK\r\n", - want: []*cmd.RedisCmd{ + want: []*cmd.DiceDBCmd{ {Cmd: "SET", Args: []string{"key", "OK"}}, }, }, { name: "Command with Error argument", input: "*3\r\n$3\r\nSET\r\n$3\r\nkey\r\n-ERR Invalid argument\r\n", - want: []*cmd.RedisCmd{ + want: []*cmd.DiceDBCmd{ {Cmd: "SET", Args: []string{"key", "ERR Invalid argument"}}, }, }, { name: "Command with mixed argument types", input: "*5\r\n$4\r\nMSET\r\n$3\r\nkey\r\n$5\r\nvalue\r\n:1000\r\n+OK\r\n", - want: []*cmd.RedisCmd{ + want: []*cmd.DiceDBCmd{ {Cmd: "MSET", Args: []string{"key", "value", "1000", "OK"}}, }, }, @@ -96,7 +97,7 @@ func TestParser_Parse(t *testing.T) { { name: "Command with empty bulk string", input: "*3\r\n$3\r\nSET\r\n$3\r\nkey\r\n$0\r\n\r\n", - want: []*cmd.RedisCmd{ + want: []*cmd.DiceDBCmd{ {Cmd: "SET", Args: []string{"key", ""}}, }, }, @@ -113,7 +114,7 @@ func TestParser_Parse(t *testing.T) { { name: "Large bulk string", input: "*2\r\n$4\r\nECHO\r\n$1000\r\n" + string(make([]byte, 1000)) + "\r\n", - want: []*cmd.RedisCmd{ + want: []*cmd.DiceDBCmd{ {Cmd: "ECHO", Args: []string{string(make([]byte, 1000))}}, }, }, diff --git a/internal/cmd/cmds.go b/internal/cmd/cmds.go index d677e685f..fd830c71e 100644 --- a/internal/cmd/cmds.go +++ b/internal/cmd/cmds.go @@ -6,19 +6,19 @@ import ( "strings" ) -type RedisCmd struct { +type DiceDBCmd struct { RequestID uint32 Cmd string Args []string } type RedisCmds struct { - Cmds []*RedisCmd + Cmds []*DiceDBCmd RequestID uint32 } // GetFingerprint returns a 32-bit fingerprint of the command and its arguments. -func (cmd *RedisCmd) GetFingerprint() uint32 { +func (cmd *DiceDBCmd) GetFingerprint() uint32 { return farm.Fingerprint32([]byte(fmt.Sprintf("%s-%s", cmd.Cmd, strings.Join(cmd.Args, " ")))) } @@ -27,6 +27,6 @@ func (cmd *RedisCmd) GetFingerprint() uint32 { // TODO: This is a naive implementation which assumes that the first argument is the key. // This is not true for all commands, however, for now this is only used by the watch manager, // which as of now only supports a small subset of commands (all of which fit this implementation). -func (cmd *RedisCmd) GetKey() string { +func (cmd *DiceDBCmd) GetKey() string { return cmd.Args[0] } diff --git a/internal/comm/client.go b/internal/comm/client.go index 1cadc533e..c24736c77 100644 --- a/internal/comm/client.go +++ b/internal/comm/client.go @@ -43,16 +43,16 @@ func (c *Client) TxnBegin() { } func (c *Client) TxnDiscard() { - c.Cqueue.Cmds = make([]*cmd.RedisCmd, 0) + c.Cqueue.Cmds = make([]*cmd.DiceDBCmd, 0) c.IsTxn = false } -func (c *Client) TxnQueue(redisCmd *cmd.RedisCmd) { - c.Cqueue.Cmds = append(c.Cqueue.Cmds, redisCmd) +func (c *Client) TxnQueue(diceDBCmd *cmd.DiceDBCmd) { + c.Cqueue.Cmds = append(c.Cqueue.Cmds, diceDBCmd) } func NewClient(fd int) *Client { - cmds := make([]*cmd.RedisCmd, 0) + cmds := make([]*cmd.DiceDBCmd, 0) return &Client{ Fd: fd, Cqueue: cmd.RedisCmds{ @@ -63,7 +63,7 @@ func NewClient(fd int) *Client { } func NewHTTPQwatchClient(qwatchResponseChan chan QwatchResponse, clientIdentifierID uint32) *Client { - cmds := make([]*cmd.RedisCmd, 0) + cmds := make([]*cmd.DiceDBCmd, 0) return &Client{ Cqueue: cmd.RedisCmds{Cmds: cmds}, Session: auth.NewSession(), diff --git a/internal/errors/errors.go b/internal/errors/errors.go index 17f0b7681..f182e4242 100644 --- a/internal/errors/errors.go +++ b/internal/errors/errors.go @@ -25,6 +25,9 @@ const ( InternalServerError = "-ERR: Internal server error, unable to process command" InvalidFloatErr = "-ERR value is not a valid float" InvalidIntErr = "-ERR value is not a valid integer" + InvalidBitfieldType = "-ERR Invalid bitfield type. Use something like i16 u8. Note that u64 is not supported but i64 is." + BitfieldOffsetErr = "-ERR bit offset is not an integer or out of range" + OverflowTypeErr = "-ERR Invalid OVERFLOW type specified" ) type DiceError struct { diff --git a/internal/errors/migrated_errors.go b/internal/errors/migrated_errors.go index 8f63183cc..cc62014bb 100644 --- a/internal/errors/migrated_errors.go +++ b/internal/errors/migrated_errors.go @@ -6,18 +6,18 @@ import ( ) // Package errors provides error definitions and utility functions for handling -// common Redis error scenarios within the application. This package centralizes -// error messages to ensure consistency and clarity when interacting with Redis +// common DiceDB error scenarios within the application. This package centralizes +// error messages to ensure consistency and clarity when interacting with DiceDB // commands and responses. -// Standard error variables for various Redis-related error conditions. +// Standard error variables for various DiceDB-related error conditions. var ( ErrAuthFailed = errors.New("AUTH failed") // Indicates authentication failure. ErrIntegerOutOfRange = errors.New("ERR value is not an integer or out of range") // Represents a value that is either not an integer or is out of allowed range. ErrInvalidNumberFormat = errors.New("ERR value is not an integer or a float") // Signals that a value provided is not in a valid integer or float format. ErrValueOutOfRange = errors.New("ERR value is out of range") // Indicates that a value is beyond the permissible range. ErrOverflow = errors.New("ERR increment or decrement would overflow") // Signifies that an increment or decrement operation would exceed the limits. - ErrSyntax = errors.New("ERR syntax error") // Represents a syntax error in a Redis command. + ErrSyntax = errors.New("ERR syntax error") // Represents a syntax error in a DiceDB command. ErrKeyNotFound = errors.New("ERR no such key") // Indicates that the specified key does not exist. ErrWrongTypeOperation = errors.New("WRONGTYPE Operation against a key holding the wrong kind of value") // Signals an operation attempted on a key with an incompatible type. ErrInvalidHyperLogLogKey = errors.New("WRONGTYPE Key is not a valid HyperLogLog string value") // Indicates that a key is not a valid HyperLogLog value. diff --git a/internal/eval/bytearray.go b/internal/eval/bytearray.go index 6cdb98e0f..ff612364b 100644 --- a/internal/eval/bytearray.go +++ b/internal/eval/bytearray.go @@ -210,6 +210,91 @@ func (b *ByteArray) DeepCopy() *ByteArray { return copyArray } +func (b *ByteArray) getBits(offset, width int, signed bool) int64 { + extraBits := 0 + if offset+width > int(b.Length)*8 { + // If bits exceed the current data size, we will pad the result with zeros for the missing bits. + extraBits = offset + width - int(b.Length)*8 + } + var value int64 + for i := 0; i < width-extraBits; i++ { + value <<= 1 + byteIndex := (offset + i) / 8 + bitIndex := 7 - ((offset + i) % 8) + if b.data[byteIndex]&(1< int(b.Length)*8 { + newSize := (offset + width + 7) / 8 + b.IncreaseSize(newSize) + } + for i := 0; i < width; i++ { + byteIndex := (offset + i) / 8 + bitIndex := (offset + i) % 8 + if value&(1< int(b.Length)*8 { + newSize := (offset + width + 7) / 8 + b.IncreaseSize(newSize) + } + + value := b.getBits(offset, width, signed) + newValue := value + increment + + var maxVal, minVal int64 + if signed { + maxVal = int64(1<<(width-1) - 1) + minVal = int64(-1 << (width - 1)) + } else { + maxVal = int64(1< maxVal { + newValue = maxVal + } else if newValue < minVal { + newValue = minVal + } + case FAIL: + // Handle failure on overflow + if newValue > maxVal || newValue < minVal { + return value, errors.New("overflow detected") + } + default: + return value, errors.New("invalid overflow type") + } + + b.setBits(offset, width, newValue) + return newValue, nil +} + // population counting, counts the number of set bits in a byte // Using: https://en.wikipedia.org/wiki/Hamming_weight func popcount(x byte) byte { diff --git a/internal/eval/commands.go b/internal/eval/commands.go index 0c31dd6d2..e0f18544b 100644 --- a/internal/eval/commands.go +++ b/internal/eval/commands.go @@ -647,6 +647,13 @@ var ( Arity: -3, KeySpecs: KeySpecs{BeginIndex: 1}, } + hmgetCmdMeta = DiceCmdMeta{ + Name: "HMGET", + Info: `Returns the values associated with the specified fields in the hash stored at key.`, + Eval: evalHMGET, + Arity: -2, + KeySpecs: KeySpecs{BeginIndex: 1}, + } hgetAllCmdMeta = DiceCmdMeta{ Name: "HGETALL", Info: `Returns all fields and values of the hash stored at key. In the returned value, @@ -1008,9 +1015,33 @@ var ( Arity: -4, KeySpecs: KeySpecs{BeginIndex: 1}, } + bitfieldCmdMeta = DiceCmdMeta{ + Name: "BITFIELD", + Info: `The command treats a string as an array of bits as well as bytearray data structure, + and is capable of addressing specific integer fields of varying bit widths + and arbitrary non (necessary) aligned offset. + In practical terms using this command you can set, for example, + a signed 5 bits integer at bit offset 1234 to a specific value, + retrieve a 31 bit unsigned integer from offset 4567. + Similarly the command handles increments and decrements of the + specified integers, providing guaranteed and well specified overflow + and underflow behavior that the user can configure. + The following is the list of supported commands. + GET -- Returns the specified bit field. + SET -- Set the specified bit field + and returns its old value. + INCRBY -- Increments or decrements + (if a negative increment is given) the specified bit field and returns the new value. + There is another subcommand that only changes the behavior of successive + INCRBY and SET subcommands calls by setting the overflow behavior: + OVERFLOW [WRAP|SAT|FAIL]`, + Arity: -1, + KeySpecs: KeySpecs{BeginIndex: 1}, + Eval: evalBITFIELD, + } hincrbyFloatCmdMeta = DiceCmdMeta{ Name: "HINCRBYFLOAT", - Info: `HINCRBYFLOAT increments the specified field of a hash stored at the key, + Info: `HINCRBYFLOAT increments the specified field of a hash stored at the key, and representing a floating point number, by the specified increment. If the field does not exist, it is set to 0 before performing the operation. If the field contains a value of wrong type or specified increment @@ -1030,6 +1061,20 @@ var ( KeySpecs: KeySpecs{BeginIndex: 1}, CmdEquivalent: "GET", } + geoAddCmdMeta = DiceCmdMeta{ + Name: "GEOADD", + Info: `Adds one or more members to a geospatial index. The key is created if it doesn't exist.`, + Arity: -5, + Eval: evalGEOADD, + KeySpecs: KeySpecs{BeginIndex: 1}, + } + geoDistCmdMeta = DiceCmdMeta{ + Name: "GEODIST", + Info: `Returns the distance between two members in the geospatial index.`, + Arity: -4, + Eval: evalGEODIST, + KeySpecs: KeySpecs{BeginIndex: 1}, + } ) func init() { @@ -1124,6 +1169,7 @@ func init() { DiceCmds["PFADD"] = pfAddCmdMeta DiceCmds["PFCOUNT"] = pfCountCmdMeta DiceCmds["HGET"] = hgetCmdMeta + DiceCmds["HMGET"] = hmgetCmdMeta DiceCmds["HSTRLEN"] = hstrLenCmdMeta DiceCmds["PFMERGE"] = pfMergeCmdMeta DiceCmds["JSON.STRLEN"] = jsonStrlenCmdMeta @@ -1142,8 +1188,11 @@ func init() { DiceCmds["APPEND"] = appendCmdMeta DiceCmds["ZADD"] = zaddCmdMeta DiceCmds["ZRANGE"] = zrangeCmdMeta + DiceCmds["BITFIELD"] = bitfieldCmdMeta DiceCmds["HINCRBYFLOAT"] = hincrbyFloatCmdMeta DiceCmds["HEXISTS"] = hexistsCmdMeta + DiceCmds["GEOADD"] = geoAddCmdMeta + DiceCmds["GEODIST"] = geoDistCmdMeta } // Function to convert DiceCmdMeta to []interface{} diff --git a/internal/eval/constants.go b/internal/eval/constants.go index c208a755b..a9dacabb8 100644 --- a/internal/eval/constants.go +++ b/internal/eval/constants.go @@ -30,4 +30,13 @@ const ( WithValues string = "WITHVALUES" WithScores string = "WITHSCORES" REV string = "REV" + GET string = "GET" + SET string = "SET" + INCRBY string = "INCRBY" + OVERFLOW string = "OVERFLOW" + WRAP string = "WRAP" + SAT string = "SAT" + FAIL string = "FAIL" + SIGNED string = "SIGNED" + UNSIGNED string = "UNSIGNED" ) diff --git a/internal/eval/dump_restore.go b/internal/eval/dump_restore.go index 1dedad034..6b09ae329 100644 --- a/internal/eval/dump_restore.go +++ b/internal/eval/dump_restore.go @@ -15,21 +15,21 @@ import ( ) func evalDUMP(args []string, store *dstore.Store) []byte { - if len(args) < 1 { - return diceerrors.NewErrArity("DUMP") - } - key := args[0] - obj := store.Get(key) - if obj == nil { - return diceerrors.NewErrWithFormattedMessage("nil") - } - - serializedValue, err := rdbSerialize(obj) - if err != nil { - return diceerrors.NewErrWithMessage("serialization failed") - } + if len(args) < 1 { + return diceerrors.NewErrArity("DUMP") + } + key := args[0] + obj := store.Get(key) + if obj == nil { + return diceerrors.NewErrWithFormattedMessage("nil") + } + + serializedValue, err := rdbSerialize(obj) + if err != nil { + return diceerrors.NewErrWithMessage("serialization failed") + } encodedResult := base64.StdEncoding.EncodeToString(serializedValue) - return clientio.Encode(encodedResult, false) + return clientio.Encode(encodedResult, false) } func evalRestore(args []string, store *dstore.Store) []byte { @@ -38,9 +38,9 @@ func evalRestore(args []string, store *dstore.Store) []byte { } key := args[0] - ttlStr:=args[1] + ttlStr := args[1] ttl, _ := strconv.ParseInt(ttlStr, 10, 64) - + encodedValue := args[2] serializedData, err := base64.StdEncoding.DecodeString(encodedValue) if err != nil { @@ -52,15 +52,15 @@ func evalRestore(args []string, store *dstore.Store) []byte { return diceerrors.NewErrWithMessage("deserialization failed: " + err.Error()) } - newobj:=store.NewObj(obj.Value,ttl,obj.TypeEncoding,obj.TypeEncoding) - var keepttl=true + newobj := store.NewObj(obj.Value, ttl, obj.TypeEncoding, obj.TypeEncoding) + var keepttl = true - if(ttl>0){ + if ttl > 0 { store.Put(key, newobj, dstore.WithKeepTTL(keepttl)) - }else{ - store.Put(key,obj) + } else { + store.Put(key, obj) } - + return clientio.RespOK } @@ -68,7 +68,7 @@ func rdbDeserialize(data []byte) (*object.Obj, error) { if len(data) < 3 { return nil, errors.New("insufficient data for deserialization") } - objType := data[1] + objType := data[1] switch objType { case 0x00: return readString(data[2:]) @@ -104,55 +104,55 @@ func readInt(data []byte) (*object.Obj, error) { } func rdbSerialize(obj *object.Obj) ([]byte, error) { - var buf bytes.Buffer - buf.WriteByte(0x09) - - switch object.GetType(obj.TypeEncoding) { - case object.ObjTypeString: - str, ok := obj.Value.(string) - if !ok { - return nil, errors.New("invalid string value") - } - buf.WriteByte(0x00) - if err := writeString(&buf, str); err != nil { - return nil, err - } - - case object.ObjTypeInt: - intVal, ok := obj.Value.(int64) - if !ok { - return nil, errors.New("invalid integer value") - } - buf.WriteByte(0xC0) - writeInt(&buf, intVal); - - default: - return nil, errors.New("unsupported object type") - } - - buf.WriteByte(0xFF) // End marker - - return appendChecksum(buf.Bytes()), nil + var buf bytes.Buffer + buf.WriteByte(0x09) + + switch object.GetType(obj.TypeEncoding) { + case object.ObjTypeString: + str, ok := obj.Value.(string) + if !ok { + return nil, errors.New("invalid string value") + } + buf.WriteByte(0x00) + if err := writeString(&buf, str); err != nil { + return nil, err + } + + case object.ObjTypeInt: + intVal, ok := obj.Value.(int64) + if !ok { + return nil, errors.New("invalid integer value") + } + buf.WriteByte(0xC0) + writeInt(&buf, intVal) + + default: + return nil, errors.New("unsupported object type") + } + + buf.WriteByte(0xFF) // End marker + + return appendChecksum(buf.Bytes()), nil } func writeString(buf *bytes.Buffer, str string) error { - strLen := uint32(len(str)) - if err := binary.Write(buf, binary.BigEndian, strLen); err != nil { - return err - } - buf.WriteString(str) - return nil + strLen := uint32(len(str)) + if err := binary.Write(buf, binary.BigEndian, strLen); err != nil { + return err + } + buf.WriteString(str) + return nil } -func writeInt(buf *bytes.Buffer, intVal int64){ - tempBuf := make([]byte, 8) - binary.BigEndian.PutUint64(tempBuf, uint64(intVal)) - buf.Write(tempBuf) +func writeInt(buf *bytes.Buffer, intVal int64) { + tempBuf := make([]byte, 8) + binary.BigEndian.PutUint64(tempBuf, uint64(intVal)) + buf.Write(tempBuf) } func appendChecksum(data []byte) []byte { - checksum := crc64.Checksum(data, crc64.MakeTable(crc64.ECMA)) - checksumBuf := make([]byte, 8) - binary.BigEndian.PutUint64(checksumBuf, checksum) - return append(data, checksumBuf...) + checksum := crc64.Checksum(data, crc64.MakeTable(crc64.ECMA)) + checksumBuf := make([]byte, 8) + binary.BigEndian.PutUint64(checksumBuf, checksum) + return append(data, checksumBuf...) } diff --git a/internal/eval/eval.go b/internal/eval/eval.go index 7f99fabe3..635669ec1 100644 --- a/internal/eval/eval.go +++ b/internal/eval/eval.go @@ -21,8 +21,8 @@ import ( "unicode" "unsafe" - "github.com/google/btree" - + "github.com/dicedb/dice/internal/eval/geo" + "github.com/dicedb/dice/internal/eval/sortedset" "github.com/dicedb/dice/internal/object" "github.com/rs/xid" @@ -3171,6 +3171,40 @@ func evalHGET(args []string, store *dstore.Store) []byte { return val } +// evalHMGET returns an array of values associated with the given fields, +// in the same order as they are requested. +// If a field does not exist, returns a corresponding nil value in the array. +// If the key does not exist, returns an array of nil values. +func evalHMGET(args []string, store *dstore.Store) []byte { + if len(args) < 2 { + return diceerrors.NewErrArity("HMGET") + } + key := args[0] + + obj := store.Get(key) + + results := make([]interface{}, len(args[1:])) + if obj == nil { + return clientio.Encode(results, false) + } + if err := object.AssertTypeAndEncoding(obj.TypeEncoding, object.ObjTypeHashMap, object.ObjEncodingHashMap); err != nil { + return diceerrors.NewErrWithMessage(diceerrors.WrongTypeErr) + } + + hashMap := obj.Value.(HashMap) + + for i, hmKey := range args[1:] { + hmValue, ok := hashMap.Get(hmKey) + if ok { + results[i] = *hmValue + } else { + results[i] = clientio.RespNIL + } + } + + return clientio.Encode(results, false) +} + func evalHDEL(args []string, store *dstore.Store) []byte { if len(args) < 2 { return diceerrors.NewErrArity("HDEL") @@ -3731,7 +3765,7 @@ func evalSDIFF(args []string, store *dstore.Store) []byte { } func evalSINTER(args []string, store *dstore.Store) []byte { - if len(args) < 2 { + if len(args) < 1 { return diceerrors.NewErrArity("SINTER") } @@ -4547,22 +4581,16 @@ func evalZADD(args []string, store *dstore.Store) []byte { key := args[0] obj := store.Get(key) - var tree *btree.BTree - var memberMap map[string]float64 + var ss *sortedset.Set if obj != nil { - if err := object.AssertTypeAndEncoding(obj.TypeEncoding, object.ObjTypeSortedSet, object.ObjEncodingBTree); err != nil { + var err []byte + ss, err = sortedset.FromObject(obj) + if err != nil { return err } - valueSlice, ok := obj.Value.([]interface{}) - if !ok || len(valueSlice) != 2 { - return diceerrors.NewErrWithMessage("Invalid sorted set object") - } - tree = valueSlice[0].(*btree.BTree) - memberMap = valueSlice[1].(map[string]float64) } else { - tree = btree.New(2) - memberMap = make(map[string]float64) + ss = sortedset.New() } added := 0 @@ -4575,24 +4603,14 @@ func evalZADD(args []string, store *dstore.Store) []byte { return diceerrors.NewErrWithMessage(diceerrors.InvalidFloatErr) } - existingScore, exists := memberMap[member] - if exists { - // Remove the existing item from the B-tree - oldItem := &SortedSetItem{Score: existingScore, Member: member} - tree.Delete(oldItem) - } else { - added++ - } - - // Insert the new item into the B-tree - newItem := &SortedSetItem{Score: score, Member: member} - tree.ReplaceOrInsert(newItem) + wasInserted := ss.Upsert(score, member) - // Update the member map - memberMap[member] = score + if wasInserted { + added += 1 + } } - obj = store.NewObj([]interface{}{tree, memberMap}, -1, object.ObjTypeSortedSet, object.ObjEncodingBTree) + obj = store.NewObj(ss, -1, object.ObjTypeSortedSet, object.ObjEncodingBTree) store.Put(key, obj) return clientio.Encode(added, false) @@ -4637,62 +4655,217 @@ func evalZRANGE(args []string, store *dstore.Store) []byte { return clientio.Encode([]string{}, false) } - if err := object.AssertTypeAndEncoding(obj.TypeEncoding, object.ObjTypeSortedSet, object.ObjEncodingBTree); err != nil { + ss, errMsg := sortedset.FromObject(obj) + + if errMsg != nil { return diceerrors.NewErrWithMessage(diceerrors.WrongTypeErr) } - valueSlice, ok := obj.Value.([]interface{}) - if !ok || len(valueSlice) != 2 { - return diceerrors.NewErrWithMessage("Invalid sorted set object") + result := ss.GetRange(start, stop, withScores, reverse) + + return clientio.Encode(result, false) +} + +// parseEncodingAndOffet function parses offset and encoding type for bitfield commands +// as this part is common to all subcommands +func parseEncodingAndOffset(args []string) (eType, eVal, offset interface{}, err error) { + encodingRaw := args[0] + offsetRaw := args[1] + switch encodingRaw[0] { + case 'i': + eType = SIGNED + eVal, err = strconv.ParseInt(encodingRaw[1:], 10, 64) + if err != nil { + err = diceerrors.NewErr(diceerrors.InvalidBitfieldType) + return eType, eVal, offset, err + } + if eVal.(int64) <= 0 || eVal.(int64) > 64 { + err = diceerrors.NewErr(diceerrors.InvalidBitfieldType) + return eType, eVal, offset, err + } + case 'u': + eType = UNSIGNED + eVal, err = strconv.ParseInt(encodingRaw[1:], 10, 64) + if err != nil { + err = diceerrors.NewErr(diceerrors.InvalidBitfieldType) + return eType, eVal, offset, err + } + if eVal.(int64) <= 0 || eVal.(int64) >= 64 { + err = diceerrors.NewErr(diceerrors.InvalidBitfieldType) + return eType, eVal, offset, err + } + default: + err = diceerrors.NewErr(diceerrors.InvalidBitfieldType) + return eType, eVal, offset, err } - tree := valueSlice[0].(*btree.BTree) - length := tree.Len() - // Handle negative indices - if start < 0 { - start += length + switch offsetRaw[0] { + case '#': + offset, err = strconv.ParseInt(offsetRaw[1:], 10, 64) + if err != nil { + err = diceerrors.NewErr(diceerrors.BitfieldOffsetErr) + return eType, eVal, offset, err + } + offset = offset.(int64) * eVal.(int64) + default: + offset, err = strconv.ParseInt(offsetRaw, 10, 64) + if err != nil { + err = diceerrors.NewErr(diceerrors.BitfieldOffsetErr) + return eType, eVal, offset, err + } } - if stop < 0 { - stop += length + return eType, eVal, offset, err +} + +// evalBITFIELD evaluates BITFIELD operations on a key store string, int or bytearray types +// it returns an array of results depending on the subcommands +// it allows mutation using SET and INCRBY commands +// returns arity error, offset type error, overflow type error, encoding type error, integer error, syntax error +// GET -- Returns the specified bit field. +// SET -- Set the specified bit field +// and returns its old value. +// INCRBY -- Increments or decrements +// (if a negative increment is given) the specified bit field and returns the new value. +// There is another subcommand that only changes the behavior of successive +// INCRBY and SET subcommands calls by setting the overflow behavior: +// OVERFLOW [WRAP|SAT|FAIL]` +func evalBITFIELD(args []string, store *dstore.Store) []byte { + if len(args) < 1 { + return diceerrors.NewErrArity("BITFIELD") } - if start < 0 { - start = 0 + var overflowType string = WRAP // Default overflow type + + type BitFieldOp struct { + Kind string + EType string + EVal int64 + Offset int64 + Value int64 } - if stop >= length { - stop = length - 1 + var ops []BitFieldOp + + for i := 1; i < len(args); { + switch strings.ToUpper(args[i]) { + case GET: + if len(args) <= i+2 { + return diceerrors.NewErrWithMessage(diceerrors.SyntaxErr) + } + eType, eVal, offset, err := parseEncodingAndOffset(args[i+1 : i+3]) + if err != nil { + return diceerrors.NewErrWithFormattedMessage(err.Error()) + } + ops = append(ops, BitFieldOp{ + Kind: GET, + EType: eType.(string), + EVal: eVal.(int64), + Offset: offset.(int64), + Value: int64(-1), + }) + i += 3 + case SET: + if len(args) <= i+3 { + return diceerrors.NewErrWithMessage(diceerrors.SyntaxErr) + } + eType, eVal, offset, err := parseEncodingAndOffset(args[i+1 : i+3]) + if err != nil { + return diceerrors.NewErrWithFormattedMessage(err.Error()) + } + value, err1 := strconv.ParseInt(args[i+3], 10, 64) + if err1 != nil { + return diceerrors.NewErrWithMessage(diceerrors.IntOrOutOfRangeErr) + } + ops = append(ops, BitFieldOp{ + Kind: SET, + EType: eType.(string), + EVal: eVal.(int64), + Offset: offset.(int64), + Value: value, + }) + i += 4 + case INCRBY: + if len(args) <= i+3 { + return diceerrors.NewErrWithMessage(diceerrors.SyntaxErr) + } + eType, eVal, offset, err := parseEncodingAndOffset(args[i+1 : i+3]) + if err != nil { + return diceerrors.NewErrWithFormattedMessage(err.Error()) + } + value, err1 := strconv.ParseInt(args[i+3], 10, 64) + if err1 != nil { + return diceerrors.NewErrWithMessage(diceerrors.IntOrOutOfRangeErr) + } + ops = append(ops, BitFieldOp{ + Kind: INCRBY, + EType: eType.(string), + EVal: eVal.(int64), + Offset: offset.(int64), + Value: value, + }) + i += 4 + case OVERFLOW: + if len(args) <= i+1 { + return diceerrors.NewErrWithMessage(diceerrors.SyntaxErr) + } + switch strings.ToUpper(args[i+1]) { + case WRAP, FAIL, SAT: + overflowType = strings.ToUpper(args[i+1]) + default: + return diceerrors.NewErrWithFormattedMessage(diceerrors.OverflowTypeErr) + } + ops = append(ops, BitFieldOp{ + Kind: OVERFLOW, + EType: overflowType, + EVal: int64(-1), + Offset: int64(-1), + Value: int64(-1), + }) + i += 2 + default: + return diceerrors.NewErrWithMessage(diceerrors.SyntaxErr) + } + } + key := args[0] + obj := store.Get(key) + if obj == nil { + obj = store.NewObj(NewByteArray(1), -1, object.ObjTypeByteArray, object.ObjEncodingByteArray) + store.Put(args[0], obj) } + var value *ByteArray + var err error - if start > stop || start >= length { - return clientio.Encode([]string{}, false) + switch oType, _ := object.ExtractTypeEncoding(obj); oType { + case object.ObjTypeByteArray: + value = obj.Value.(*ByteArray) + case object.ObjTypeString, object.ObjTypeInt: + value, err = NewByteArrayFromObj(obj) + if err != nil { + return diceerrors.NewErrWithMessage("value is not a valid byte array") + } + default: + return diceerrors.NewErrWithFormattedMessage(diceerrors.WrongTypeErr) } var result []interface{} - index := 0 - - // iterFunc is the function that will be called for each item in the B-tree. It will append the item to the result if it is within the specified range. - // It will return false if the specified range has been reached. - iterFunc := func(item btree.Item) bool { - if index > stop { - return false - } - if index >= start { - ssi := item.(*SortedSetItem) - result = append(result, ssi.Member) - if withScores { - // Use 'g' format to match Redis's float formatting - scoreStr := strings.ToLower(strconv.FormatFloat(ssi.Score, 'g', -1, 64)) - result = append(result, scoreStr) + for _, op := range ops { + switch op.Kind { + case GET: + res := value.getBits(int(op.Offset), int(op.EVal), op.EType == SIGNED) + result = append(result, res) + case SET: + prevValue := value.getBits(int(op.Offset), int(op.EVal), op.EType == SIGNED) + value.setBits(int(op.Offset), int(op.EVal), op.Value) + result = append(result, prevValue) + case INCRBY: + res, err := value.incrByBits(int(op.Offset), int(op.EVal), op.Value, overflowType, op.EType == SIGNED) + if err != nil { + result = append(result, nil) + } else { + result = append(result, res) } + case OVERFLOW: + overflowType = op.EType } - index++ - return true - } - - if !reverse { - tree.Ascend(iterFunc) - } else { - tree.Descend(iterFunc) } return clientio.Encode(result, false) @@ -4734,3 +4907,135 @@ func evalHINCRBYFLOAT(args []string, store *dstore.Store) []byte { return clientio.Encode(numkey, false) } + +func evalGEOADD(args []string, store *dstore.Store) []byte { + if len(args) < 4 { + return diceerrors.NewErrArity("GEOADD") + } + + key := args[0] + var nx, xx bool + startIdx := 1 + + // Parse options + for startIdx < len(args) { + option := strings.ToUpper(args[startIdx]) + if option == "NX" { + nx = true + startIdx++ + } else if option == "XX" { + xx = true + startIdx++ + } else { + break + } + } + + // Check if we have the correct number of arguments after parsing options + if (len(args)-startIdx)%3 != 0 { + return diceerrors.NewErrArity("GEOADD") + } + + if xx && nx { + return diceerrors.NewErrWithMessage("ERR XX and NX options at the same time are not compatible") + } + + // Get or create sorted set + obj := store.Get(key) + var ss *sortedset.Set + if obj != nil { + var err []byte + ss, err = sortedset.FromObject(obj) + if err != nil { + return err + } + } else { + ss = sortedset.New() + } + + added := 0 + for i := startIdx; i < len(args); i += 3 { + longitude, err := strconv.ParseFloat(args[i], 64) + if err != nil || math.IsNaN(longitude) || longitude < -180 || longitude > 180 { + return diceerrors.NewErrWithMessage("ERR invalid longitude") + } + + latitude, err := strconv.ParseFloat(args[i+1], 64) + if err != nil || math.IsNaN(latitude) || latitude < -85.05112878 || latitude > 85.05112878 { + return diceerrors.NewErrWithMessage("ERR invalid latitude") + } + + member := args[i+2] + _, exists := ss.Get(member) + + // Handle XX option: Only update existing elements + if xx && !exists { + continue + } + + // Handle NX option: Only add new elements + if nx && exists { + continue + } + + hash := geo.EncodeHash(latitude, longitude) + + wasInserted := ss.Upsert(hash, member) + if wasInserted { + added++ + } + } + + obj = store.NewObj(ss, -1, object.ObjTypeSortedSet, object.ObjEncodingBTree) + store.Put(key, obj) + + return clientio.Encode(added, false) +} + +func evalGEODIST(args []string, store *dstore.Store) []byte { + if len(args) < 3 || len(args) > 4 { + return diceerrors.NewErrArity("GEODIST") + } + + key := args[0] + member1 := args[1] + member2 := args[2] + unit := "m" + if len(args) == 4 { + unit = strings.ToLower(args[3]) + } + + // Get the sorted set + obj := store.Get(key) + if obj == nil { + return clientio.RespNIL + } + + ss, err := sortedset.FromObject(obj) + if err != nil { + return err + } + + // Get the scores (geohashes) for both members + score1, ok := ss.Get(member1) + if !ok { + return clientio.RespNIL + } + score2, ok := ss.Get(member2) + if !ok { + return clientio.RespNIL + } + + lat1, lon1 := geo.DecodeHash(score1) + lat2, lon2 := geo.DecodeHash(score2) + + distance := geo.GetDistance(lon1, lat1, lon2, lat2) + + result, err := geo.ConvertDistance(distance, unit) + + if err != nil { + return err + } + + return clientio.Encode(utils.RoundToDecimals(result, 4), false) +} diff --git a/internal/eval/eval_test.go b/internal/eval/eval_test.go index 198ebaf5e..c99455823 100644 --- a/internal/eval/eval_test.go +++ b/internal/eval/eval_test.go @@ -12,14 +12,16 @@ import ( "testing" "time" - "github.com/axiomhq/hyperloglog" + "github.com/dicedb/dice/internal/server/utils" + "github.com/bytedance/sonic" + "github.com/ohler55/ojg/jp" + + "github.com/axiomhq/hyperloglog" "github.com/dicedb/dice/internal/clientio" diceerrors "github.com/dicedb/dice/internal/errors" "github.com/dicedb/dice/internal/object" - "github.com/dicedb/dice/internal/server/utils" dstore "github.com/dicedb/dice/internal/store" - "github.com/ohler55/ojg/jp" testifyAssert "github.com/stretchr/testify/assert" "gotest.tools/v3/assert" ) @@ -76,6 +78,7 @@ func TestEval(t *testing.T) { testEvalPFADD(t, store) testEvalPFCOUNT(t, store) testEvalHGET(t, store) + testEvalHMGET(t, store) testEvalHSTRLEN(t, store) testEvalHEXISTS(t, store) testEvalHDEL(t, store) @@ -104,7 +107,11 @@ func TestEval(t *testing.T) { testEvalZADD(t, store) testEvalZRANGE(t, store) testEvalHVALS(t, store) + testEvalBitField(t, store) testEvalHINCRBYFLOAT(t, store) + testEvalGEOADD(t, store) + testEvalGEODIST(t, store) + testEvalSINTER(t, store) } func testEvalPING(t *testing.T, store *dstore.Store) { @@ -2159,6 +2166,82 @@ func testEvalHGET(t *testing.T, store *dstore.Store) { runEvalTests(t, tests, evalHGET, store) } +func testEvalHMGET(t *testing.T, store *dstore.Store) { + tests := map[string]evalTestCase{ + "wrong number of args passed": { + setup: func() {}, + input: nil, + output: []byte("-ERR wrong number of arguments for 'hmget' command\r\n"), + }, + "only key passed": { + setup: func() {}, + input: []string{"KEY"}, + output: []byte("-ERR wrong number of arguments for 'hmget' command\r\n"), + }, + "key doesn't exists": { + setup: func() {}, + input: []string{"KEY", "field_name"}, + output: clientio.Encode([]interface{}{nil}, false), + }, + "key exists but field_name doesn't exists": { + setup: func() { + key := "KEY_MOCK" + field := "mock_field_name" + newMap := make(HashMap) + newMap[field] = "mock_field_value" + + obj := &object.Obj{ + TypeEncoding: object.ObjTypeHashMap | object.ObjEncodingHashMap, + Value: newMap, + LastAccessedAt: uint32(time.Now().Unix()), + } + + store.Put(key, obj) + }, + input: []string{"KEY_MOCK", "non_existent_key"}, + output: clientio.Encode([]interface{}{nil}, false), + }, + "both key and field_name exists": { + setup: func() { + key := "KEY_MOCK" + field := "mock_field_name" + newMap := make(HashMap) + newMap[field] = "mock_field_value" + + obj := &object.Obj{ + TypeEncoding: object.ObjTypeHashMap | object.ObjEncodingHashMap, + Value: newMap, + LastAccessedAt: uint32(time.Now().Unix()), + } + + store.Put(key, obj) + }, + input: []string{"KEY_MOCK", "mock_field_name"}, + output: clientio.Encode([]interface{}{"mock_field_value"}, false), + }, + "some fields exist some do not": { + setup: func() { + key := "KEY_MOCK" + newMap := HashMap{ + "field1": "value1", + "field2": "value2", + } + obj := &object.Obj{ + TypeEncoding: object.ObjTypeHashMap | object.ObjEncodingHashMap, + Value: newMap, + LastAccessedAt: uint32(time.Now().Unix()), + } + + store.Put(key, obj) + }, + input: []string{"KEY_MOCK", "field1", "field2", "field3", "field4"}, + output: clientio.Encode([]interface{}{"value1", "value2", nil, nil}, false), + }, + } + + runEvalTests(t, tests, evalHMGET, store) +} + func testEvalHVALS(t *testing.T, store *dstore.Store) { tests := map[string]evalTestCase{ "wrong number of args passed": { @@ -4976,6 +5059,55 @@ func testEvalZRANGE(t *testing.T, store *dstore.Store) { runEvalTests(t, tests, evalZRANGE, store) } +func testEvalBitField(t *testing.T, store *dstore.Store) { + testCases := map[string]evalTestCase{ + "BITFIELD signed SET": { + input: []string{"bits", "set", "i8", "0", "-100"}, + output: clientio.Encode([]int64{0}, false), + }, + "BITFIELD GET": { + setup: func() { + args := []string{"bits", "set", "u8", "0", "255"} + evalBITFIELD(args, store) + }, + input: []string{"bits", "get", "u8", "0"}, + output: clientio.Encode([]int64{255}, false), + }, + "BITFIELD INCRBY": { + setup: func() { + args := []string{"bits", "set", "u8", "0", "255"} + evalBITFIELD(args, store) + }, + input: []string{"bits", "incrby", "u8", "0", "100"}, + output: clientio.Encode([]int64{99}, false), + }, + "BITFIELD Arity": { + input: []string{}, + output: diceerrors.NewErrArity("BITFIELD"), + }, + "BITFIELD invalid combination of commands in a single operation": { + input: []string{"bits", "SET", "u8", "0", "255", "INCRBY", "u8", "0", "100", "GET", "u8"}, + output: []byte("-ERR syntax error\r\n"), + }, + "BITFIELD invalid bitfield type": { + input: []string{"bits", "SET", "a8", "0", "255", "INCRBY", "u8", "0", "100", "GET", "u8"}, + output: []byte("-ERR Invalid bitfield type. Use something like i16 u8. Note that u64 is not supported but i64 is.\r\n"), + }, + "BITFIELD invalid bit offset": { + input: []string{"bits", "SET", "u8", "a", "255", "INCRBY", "u8", "0", "100", "GET", "u8"}, + output: []byte("-ERR bit offset is not an integer or out of range\r\n"), + }, + "BITFIELD invalid overflow type": { + input: []string{"bits", "SET", "u8", "0", "255", "INCRBY", "u8", "0", "100", "OVERFLOW", "wraap"}, + output: []byte("-ERR Invalid OVERFLOW type specified\r\n"), + }, + "BITFIELD missing arguments in SET": { + input: []string{"bits", "SET", "u8", "0", "INCRBY", "u8", "0", "100", "GET", "u8", "288"}, + output: []byte("-ERR value is not an integer or out of range\r\n"), + }, + } + runEvalTests(t, testCases, evalBITFIELD, store) +} func testEvalHINCRBYFLOAT(t *testing.T, store *dstore.Store) { tests := map[string]evalTestCase{ "HINCRBYFLOAT on a non-existing key and field": { @@ -5211,3 +5343,167 @@ func testEvalDUMP(t *testing.T, store *dstore.Store) { runEvalTests(t, tests, evalDUMP, store) } + +func testEvalGEOADD(t *testing.T, store *dstore.Store) { + tests := map[string]evalTestCase{ + "GEOADD with wrong number of arguments": { + input: []string{"mygeo", "1", "2"}, + output: diceerrors.NewErrArity("GEOADD"), + }, + "GEOADD with non-numeric longitude": { + input: []string{"mygeo", "long", "40.7128", "NewYork"}, + output: diceerrors.NewErrWithMessage("ERR invalid longitude"), + }, + "GEOADD with non-numeric latitude": { + input: []string{"mygeo", "-74.0060", "lat", "NewYork"}, + output: diceerrors.NewErrWithMessage("ERR invalid latitude"), + }, + "GEOADD new member to non-existing key": { + setup: func() {}, + input: []string{"mygeo", "-74.0060", "40.7128", "NewYork"}, + output: clientio.Encode(int64(1), false), + }, + "GEOADD existing member with updated coordinates": { + setup: func() { + evalGEOADD([]string{"mygeo", "-74.0060", "40.7128", "NewYork"}, store) + }, + input: []string{"mygeo", "-73.9352", "40.7304", "NewYork"}, + output: clientio.Encode(int64(0), false), + }, + "GEOADD multiple members": { + setup: func() { + evalGEOADD([]string{"mygeo", "-74.0060", "40.7128", "NewYork"}, store) + }, + input: []string{"mygeo", "-118.2437", "34.0522", "LosAngeles", "-87.6298", "41.8781", "Chicago"}, + output: clientio.Encode(int64(2), false), + }, + "GEOADD with NX option (new member)": { + input: []string{"mygeo", "NX", "-122.4194", "37.7749", "SanFrancisco"}, + output: clientio.Encode(int64(1), false), + }, + "GEOADD with NX option (existing member)": { + setup: func() { + evalGEOADD([]string{"mygeo", "-74.0060", "40.7128", "NewYork"}, store) + }, + input: []string{"mygeo", "NX", "-73.9352", "40.7304", "NewYork"}, + output: clientio.Encode(int64(0), false), + }, + "GEOADD with XX option (new member)": { + input: []string{"mygeo", "XX", "-71.0589", "42.3601", "Boston"}, + output: clientio.Encode(int64(0), false), + }, + "GEOADD with XX option (existing member)": { + setup: func() { + evalGEOADD([]string{"mygeo", "-74.0060", "40.7128", "NewYork"}, store) + }, + input: []string{"mygeo", "XX", "-73.9352", "40.7304", "NewYork"}, + output: clientio.Encode(int64(0), false), + }, + "GEOADD with both NX and XX options": { + input: []string{"mygeo", "NX", "XX", "-74.0060", "40.7128", "NewYork"}, + output: diceerrors.NewErrWithMessage("ERR XX and NX options at the same time are not compatible"), + }, + "GEOADD with invalid option": { + input: []string{"mygeo", "INVALID", "-74.0060", "40.7128", "NewYork"}, + output: diceerrors.NewErrArity("GEOADD"), + }, + "GEOADD to a key of wrong type": { + setup: func() { + store.Put("mygeo", store.NewObj("string_value", -1, object.ObjTypeString, object.ObjEncodingRaw)) + }, + input: []string{"mygeo", "-74.0060", "40.7128", "NewYork"}, + output: []byte("-ERR Existing key has wrong Dice type\r\n"), + }, + "GEOADD with longitude out of range": { + input: []string{"mygeo", "181.0", "40.7128", "Invalid"}, + output: diceerrors.NewErrWithMessage("ERR invalid longitude"), + }, + "GEOADD with latitude out of range": { + input: []string{"mygeo", "-74.0060", "91.0", "Invalid"}, + output: diceerrors.NewErrWithMessage("ERR invalid latitude"), + }, + } + + runEvalTests(t, tests, evalGEOADD, store) +} + +func testEvalGEODIST(t *testing.T, store *dstore.Store) { + tests := map[string]evalTestCase{ + "GEODIST between existing points": { + setup: func() { + evalGEOADD([]string{"points", "13.361389", "38.115556", "Palermo"}, store) + evalGEOADD([]string{"points", "15.087269", "37.502669", "Catania"}, store) + }, + input: []string{"points", "Palermo", "Catania"}, + output: clientio.Encode(float64(166274.1440), false), // Example value + }, + "GEODIST with units (km)": { + setup: func() { + evalGEOADD([]string{"points", "13.361389", "38.115556", "Palermo"}, store) + evalGEOADD([]string{"points", "15.087269", "37.502669", "Catania"}, store) + }, + input: []string{"points", "Palermo", "Catania", "km"}, + output: clientio.Encode(float64(166.2741), false), // Example value + }, + "GEODIST to same point": { + setup: func() { + evalGEOADD([]string{"points", "13.361389", "38.115556", "Palermo"}, store) + }, + input: []string{"points", "Palermo", "Palermo"}, + output: clientio.Encode(float64(0.0000), false), // Expecting distance 0 formatted to 4 decimals + }, + // Add other test cases here... + } + + runEvalTests(t, tests, evalGEODIST, store) +} + +func testEvalSINTER(t *testing.T, store *dstore.Store) { + tests := map[string]evalTestCase{ + "intersection of two sets": { + setup: func() { + evalSADD([]string{"set1", "a", "b", "c"}, store) + evalSADD([]string{"set2", "c", "d", "e"}, store) + }, + input: []string{"set1", "set2"}, + output: clientio.Encode([]string{"c"}, false), + }, + "intersection of three sets": { + setup: func() { + evalSADD([]string{"set1", "a", "b", "c"}, store) + evalSADD([]string{"set2", "b", "c", "d"}, store) + evalSADD([]string{"set3", "c", "d", "e"}, store) + }, + input: []string{"set1", "set2", "set3"}, + output: clientio.Encode([]string{"c"}, false), + }, + "intersection with single set": { + setup: func() { + evalSADD([]string{"set1", "a"}, store) + }, + input: []string{"set1"}, + output: clientio.Encode([]string{"a"}, false), + }, + "intersection with a non-existent key": { + setup: func() { + evalSADD([]string{"set1", "a", "b", "c"}, store) + }, + input: []string{"set1", "nonexistent"}, + output: clientio.Encode([]string{}, false), + }, + "intersection with wrong type": { + setup: func() { + evalSADD([]string{"set1", "a", "b", "c"}, store) + store.Put("string", &object.Obj{Value: "string", TypeEncoding: object.ObjTypeString}) + }, + input: []string{"set1", "string"}, + output: []byte("-WRONGTYPE Operation against a key holding the wrong kind of value\r\n"), + }, + "no arguments": { + input: []string{}, + output: diceerrors.NewErrArity("SINTER"), + }, + } + + runEvalTests(t, tests, evalSINTER, store) +} diff --git a/internal/eval/execute.go b/internal/eval/execute.go index 14d9642af..e2ed1802c 100644 --- a/internal/eval/execute.go +++ b/internal/eval/execute.go @@ -11,7 +11,7 @@ import ( dstore "github.com/dicedb/dice/internal/store" ) -func ExecuteCommand(c *cmd.RedisCmd, client *comm.Client, store *dstore.Store, httpOp, websocketOp bool) *EvalResponse { +func ExecuteCommand(c *cmd.DiceDBCmd, client *comm.Client, store *dstore.Store, httpOp, websocketOp bool) *EvalResponse { diceCmd, ok := DiceCmds[c.Cmd] if !ok { return &EvalResponse{Result: diceerrors.NewErrWithFormattedMessage("unknown command '%s', with args beginning with: %s", c.Cmd, strings.Join(c.Args, " ")), Error: nil} diff --git a/internal/eval/geo/geo.go b/internal/eval/geo/geo.go new file mode 100644 index 000000000..6db40eaf8 --- /dev/null +++ b/internal/eval/geo/geo.go @@ -0,0 +1,86 @@ +package geo + +import ( + "math" + + "github.com/dicedb/dice/internal/errors" + "github.com/mmcloughlin/geohash" +) + +// Earth's radius in meters +const earthRadius float64 = 6372797.560856 + +// Bit precision for geohash - picked up to match redis +const bitPrecision = 52 + +func DegToRad(deg float64) float64 { + return math.Pi * deg / 180.0 +} + +func RadToDeg(rad float64) float64 { + return 180.0 * rad / math.Pi +} + +func GetDistance( + lon1, + lat1, + lon2, + lat2 float64, +) float64 { + lon1r := DegToRad(lon1) + lon2r := DegToRad(lon2) + v := math.Sin((lon2r - lon1r) / 2) + // if v == 0 we can avoid doing expensive math when lons are practically the same + if v == 0.0 { + return GetLatDistance(lat1, lat2) + } + + lat1r := DegToRad(lat1) + lat2r := DegToRad(lat2) + u := math.Sin((lat2r - lat1r) / 2) + + a := u*u + math.Cos(lat1r)*math.Cos(lat2r)*v*v + + return 2.0 * earthRadius * math.Asin(math.Sqrt(a)) +} + +func GetLatDistance(lat1, lat2 float64) float64 { + return earthRadius * math.Abs(DegToRad(lat2)-DegToRad(lat1)) +} + +// EncodeHash returns a geo hash for a given coordinate, and returns it in float64 so it can be used as score in a zset +func EncodeHash( + latitude, + longitude float64, +) float64 { + h := geohash.EncodeIntWithPrecision(latitude, longitude, bitPrecision) + + return float64(h) +} + +// DecodeHash returns the latitude and longitude from a geo hash +// The hash should be a float64, as it is used as score in a zset +func DecodeHash(hash float64) (lat, lon float64) { + lat, lon = geohash.DecodeIntWithPrecision(uint64(hash), bitPrecision) + + return lat, lon +} + +// ConvertDistance converts a distance from meters to the desired unit +func ConvertDistance( + distance float64, + unit string, +) (converted float64, err []byte) { + switch unit { + case "m": + return distance, nil + case "km": + return distance / 1000, nil + case "mi": + return distance / 1609.34, nil + case "ft": + return distance / 0.3048, nil + default: + return 0, errors.NewErrWithMessage("ERR unsupported unit provided. please use m, km, ft, mi") + } +} diff --git a/internal/eval/hmap.go b/internal/eval/hmap.go index 67ac0df72..fa711c00c 100644 --- a/internal/eval/hmap.go +++ b/internal/eval/hmap.go @@ -121,7 +121,7 @@ func (h HashMap) incrementFloatValue(field string, incr float64) (string, error) return "-1", diceerrors.NewErr(diceerrors.IntOrFloatErr) } - if (i > 0 && incr > 0 && i > math.MaxFloat64-incr) || (i < 0 && incr < 0 && i < -math.MaxFloat64-incr) { + if math.IsInf(i+incr, 1) || math.IsInf(i+incr, -1) { return "-1", diceerrors.NewErr(diceerrors.IncrDecrOverflowErr) } diff --git a/internal/eval/hmap_test.go b/internal/eval/hmap_test.go index faea3ee31..07bfea205 100644 --- a/internal/eval/hmap_test.go +++ b/internal/eval/hmap_test.go @@ -116,4 +116,14 @@ func TestHashMapIncrementFloatValue(t *testing.T) { val, err = hmap.incrementFloatValue("field2", 1.0) assert.NotNil(t, err, "Expected error when incrementing a non-float value") assert.Equal(t, errors.IntOrFloatErr, err.Error(), "Expected int or float error") + + inf := math.MaxFloat64 + + val, err = hmap.incrementFloatValue("field1", inf+float64(1e308)) + assert.NotNil(t, err, "Expected error when incrementing a overflowing value") + assert.Equal(t, errors.IncrDecrOverflowErr, err.Error(), "Expected overflow to be detected") + + val, err = hmap.incrementFloatValue("field1", -inf-float64(1e308)) + assert.NotNil(t, err, "Expected error when incrementing a overflowing value") + assert.Equal(t, errors.IncrDecrOverflowErr, err.Error(), "Expected overflow to be detected") } diff --git a/internal/eval/sorted_set.go b/internal/eval/sorted_set.go deleted file mode 100644 index 6dfdc3740..000000000 --- a/internal/eval/sorted_set.go +++ /dev/null @@ -1,19 +0,0 @@ -package eval - -import "github.com/google/btree" - -// SortedSetItem represents a member of a sorted set. It includes a score and a member. -type SortedSetItem struct { - btree.Item - Score float64 - Member string -} - -// Less compares two SortedSetItems. Required by the btree.Item interface. -func (a *SortedSetItem) Less(b btree.Item) bool { - other := b.(*SortedSetItem) - if a.Score != other.Score { - return a.Score < other.Score - } - return a.Member < other.Member -} diff --git a/internal/eval/sortedset/sorted_set.go b/internal/eval/sortedset/sorted_set.go new file mode 100644 index 000000000..dac62be9d --- /dev/null +++ b/internal/eval/sortedset/sorted_set.go @@ -0,0 +1,148 @@ +package sortedset + +import ( + "strconv" + "strings" + + diceerrors "github.com/dicedb/dice/internal/errors" + "github.com/dicedb/dice/internal/object" + "github.com/google/btree" +) + +// Item represents a member of a sorted set. It includes a score and a member. +type Item struct { + btree.Item + Score float64 + Member string +} + +// Less compares two Items. Required by the btree.Item interface. +func (a *Item) Less(b btree.Item) bool { + other := b.(*Item) + if a.Score != other.Score { + return a.Score < other.Score + } + return a.Member < other.Member +} + +// is a sorted set data structure that stores members with associated scores. +type Set struct { + // tree is a btree that stores Items. + tree *btree.BTree + // memberMap is a map that stores members and their scores. + memberMap map[string]float64 +} + +// New creates a new . +func New() *Set { + return &Set{ + tree: btree.New(2), + memberMap: make(map[string]float64), + } +} + +func FromObject(obj *object.Obj) (value *Set, err []byte) { + if err := object.AssertTypeAndEncoding(obj.TypeEncoding, object.ObjTypeSortedSet, object.ObjEncodingBTree); err != nil { + return nil, err + } + value, ok := obj.Value.(*Set) + if !ok { + return nil, diceerrors.NewErrWithMessage("Invalid sorted set object") + } + return value, nil +} + +// Add adds a member with a score to the and returns true if the member was added, false if it already existed. +func (ss *Set) Upsert(score float64, member string) bool { + existingScore, exists := ss.memberMap[member] + + if exists { + oldItem := &Item{Score: existingScore, Member: member} + ss.tree.Delete(oldItem) + } + + item := &Item{Score: score, Member: member} + ss.tree.ReplaceOrInsert(item) + ss.memberMap[member] = score + + return !exists +} + +// Remove removes a member from the and returns true if the member was removed, false if it did not exist. +func (ss *Set) Remove(member string) bool { + score, exists := ss.memberMap[member] + if !exists { + return false + } + + item := &Item{Score: score, Member: member} + ss.tree.Delete(item) + delete(ss.memberMap, member) + + return true +} + +// GetRange returns a slice of members with scores between min and max, inclusive. +// it returns the members in ascending order if reverse is false, and descending order if reverse is true. +// If withScores is true, the members will be returned with their scores. +func (ss *Set) GetRange( + start, stop int, + withScores bool, + reverse bool, +) []string { + length := ss.tree.Len() + if start < 0 { + start += length + } + if stop < 0 { + stop += length + } + + if start < 0 { + start = 0 + } + if stop >= length { + stop = length - 1 + } + + if start > stop || start >= length { + return []string{} + } + + var result []string + + index := 0 + + // iterFunc is the function that will be called for each item in the B-tree. It will append the item to the result if it is within the specified range. + // It will return false if the specified range has been reached. + iterFunc := func(item btree.Item) bool { + if index > stop { + return false + } + + if index >= start { + ssi := item.(*Item) + result = append(result, ssi.Member) + if withScores { + // Use 'g' format to match Redis's float formatting + scoreStr := strings.ToLower(strconv.FormatFloat(ssi.Score, 'g', -1, 64)) + result = append(result, scoreStr) + } + } + index++ + return true + } + + if reverse { + ss.tree.Descend(iterFunc) + } else { + ss.tree.Ascend(iterFunc) + } + + return result +} + +func (ss *Set) Get(member string) (float64, bool) { + score, exists := ss.memberMap[member] + return score, exists +} diff --git a/internal/eval/worker_eval.go b/internal/eval/worker_eval.go index 21bf226c9..d6f854c08 100644 --- a/internal/eval/worker_eval.go +++ b/internal/eval/worker_eval.go @@ -1,9 +1,8 @@ package eval -// This file contains functions required by worker nodes to -// evaluate specific Redis-like commands (e.g., INFO, PING). -// These evaluation functions are exposed to the worker, -// allowing them to process commands and return appropriate responses. +// These evaluation functions are exposed to the worker, without +// making any contact with shards allowing them to process +// commands and return appropriate responses. import ( "github.com/dicedb/dice/internal/clientio" diff --git a/internal/object/object.go b/internal/object/object.go index c6e8b38af..697e961b1 100644 --- a/internal/object/object.go +++ b/internal/object/object.go @@ -2,7 +2,7 @@ package object type Obj struct { TypeEncoding uint8 - // Redis allots 24 bits to these bits, but we will use 32 bits because + // Redis allocates 24 bits to these bits, but we will use 32 bits because // golang does not support bitfields, and we need not make this super-complicated // by merging TypeEncoding + LastAccessedAt in one 32-bit integer. // But nonetheless, we can benchmark and see how that fares. diff --git a/internal/ops/store_op.go b/internal/ops/store_op.go index b2d7fb8ec..30be24276 100644 --- a/internal/ops/store_op.go +++ b/internal/ops/store_op.go @@ -7,14 +7,14 @@ import ( ) type StoreOp struct { - SeqID uint8 // SeqID is the sequence id of the operation within a single request (optional, may be used for ordering) - RequestID uint32 // RequestID identifies the request that this StoreOp belongs to - Cmd *cmd.RedisCmd // Cmd is the atomic Store command (e.g., GET, SET) - ShardID uint8 // ShardID of the shard on which the Store command will be executed - WorkerID string // WorkerID is the ID of the worker that sent this Store operation - Client *comm.Client // Client that sent this Store operation. TODO: This can potentially replace the WorkerID in the future - HTTPOp bool // HTTPOp is true if this Store operation is an HTTP operation - WebsocketOp bool // WebsocketOp is true if this Store operation is a Websocket operation + SeqID uint8 // SeqID is the sequence id of the operation within a single request (optional, may be used for ordering) + RequestID uint32 // RequestID identifies the request that this StoreOp belongs to + Cmd *cmd.DiceDBCmd // Cmd is the atomic Store command (e.g., GET, SET) + ShardID uint8 // ShardID of the shard on which the Store command will be executed + WorkerID string // WorkerID is the ID of the worker that sent this Store operation + Client *comm.Client // Client that sent this Store operation. TODO: This can potentially replace the WorkerID in the future + HTTPOp bool // HTTPOp is true if this Store operation is an HTTP operation + WebsocketOp bool // WebsocketOp is true if this Store operation is a Websocket operation } // StoreResponse represents the response of a Store operation. diff --git a/internal/querymanager/query_manager.go b/internal/querymanager/query_manager.go index b7fb9a5ce..41bfc58c0 100644 --- a/internal/querymanager/query_manager.go +++ b/internal/querymanager/query_manager.go @@ -23,7 +23,7 @@ import ( ) type ( - cacheStore common.ITable[string, *object.Obj] + CacheStore common.ITable[string, *object.Obj] // QuerySubscription represents a subscription to watch a query. QuerySubscription struct { @@ -54,7 +54,7 @@ type ( // Manager watches for changes in keys and notifies clients. Manager struct { WatchList sync.Map // WatchList is a map of query string to their respective clients, type: map[string]*sync.Map[int]struct{} - QueryCache common.ITable[string, cacheStore] // QueryCache is a map of fingerprints to their respective data caches + QueryCache common.ITable[string, CacheStore] // QueryCache is a map of fingerprints to their respective data caches QueryCacheMu sync.RWMutex logger *slog.Logger } @@ -81,23 +81,23 @@ func NewClientIdentifier(clientIdentifierID int, isHTTPClient bool) clientio.Cli } } -func NewQueryCacheStoreRegMap() common.ITable[string, cacheStore] { - return &common.RegMap[string, cacheStore]{ - M: make(map[string]cacheStore), +func NewQueryCacheStoreRegMap() common.ITable[string, CacheStore] { + return &common.RegMap[string, CacheStore]{ + M: make(map[string]CacheStore), } } -func NewQueryCacheStore() common.ITable[string, cacheStore] { +func NewQueryCacheStore() common.ITable[string, CacheStore] { return NewQueryCacheStoreRegMap() } -func NewCacheStoreRegMap() cacheStore { +func NewCacheStoreRegMap() CacheStore { return &common.RegMap[string, *object.Obj]{ M: make(map[string]*object.Obj), } } -func NewCacheStore() cacheStore { +func NewCacheStore() CacheStore { return NewCacheStoreRegMap() } @@ -216,7 +216,7 @@ func (m *Manager) updateQueryCache(queryFingerprint string, event dstore.QueryWa store, ok := m.QueryCache.Get(queryFingerprint) if !ok { - m.logger.Warn("Fingerprint not found in cacheStore", slog.String("fingerprint", queryFingerprint)) + m.logger.Warn("Fingerprint not found in CacheStore", slog.String("fingerprint", queryFingerprint)) return } diff --git a/internal/server/cmd_meta.go b/internal/server/cmd_meta.go index e7f2fe87a..c69cdd16e 100644 --- a/internal/server/cmd_meta.go +++ b/internal/server/cmd_meta.go @@ -7,7 +7,7 @@ import ( "github.com/dicedb/dice/internal/shard" ) -// CmdType defines the type of Redis command based on how it interacts with shards. +// CmdType defines the type of DiceDB command based on how it interacts with shards. // It uses an integer value to represent different command types. type CmdType int @@ -21,15 +21,15 @@ const ( Custom // Custom commands involve direct client communication. ) -// CmdsMeta stores metadata about Redis commands, including how they are processed across shards. +// CmdsMeta stores metadata about DiceDB commands, including how they are processed across shards. // CmdType indicates how the command should be handled, while Breakup and Gather provide logic // for breaking up multishard commands and gathering their responses. type CmdsMeta struct { - Cmd string // Command name. - Breakup func(mgr *shard.ShardManager, redisCmd *cmd.RedisCmd, c *comm.Client) []cmd.RedisCmd // Function to break up multishard commands. - Gather func(responses ...eval.EvalResponse) []byte // Function to gather responses from shards. - RespNoShards func(args []string) []byte // Function for commands that don't interact with shards. - CmdType // Enum indicating the command type. + Cmd string // Command name. + Breakup func(mgr *shard.ShardManager, DiceDBCmd *cmd.DiceDBCmd, c *comm.Client) []cmd.DiceDBCmd // Function to break up multishard commands. + Gather func(responses ...eval.EvalResponse) []byte // Function to gather responses from shards. + RespNoShards func(args []string) []byte // Function for commands that don't interact with shards. + CmdType // Enum indicating the command type. } // WorkerCmdsMeta is a map that associates command names with their corresponding metadata. diff --git a/internal/server/httpServer.go b/internal/server/httpServer.go index e70752b4f..0257b24b1 100644 --- a/internal/server/httpServer.go +++ b/internal/server/httpServer.go @@ -124,23 +124,23 @@ func (s *HTTPServer) Run(ctx context.Context) error { func (s *HTTPServer) DiceHTTPHandler(writer http.ResponseWriter, request *http.Request) { // convert to REDIS cmd - redisCmd, err := utils.ParseHTTPRequest(request) + diceDBCmd, err := utils.ParseHTTPRequest(request) if err != nil { http.Error(writer, "Error parsing HTTP request", http.StatusBadRequest) s.logger.Error("Error parsing HTTP request", slog.Any("error", err)) return } - if redisCmd.Cmd == Abort { + if diceDBCmd.Cmd == Abort { s.logger.Debug("ABORT command received") s.logger.Debug("Shutting down HTTP Server") close(s.shutdownChan) return } - if unimplementedCommands[redisCmd.Cmd] { + if unimplementedCommands[diceDBCmd.Cmd] { http.Error(writer, "Command is not implemented with HTTP", http.StatusBadRequest) - s.logger.Error("Command %s is not implemented", slog.String("cmd", redisCmd.Cmd)) + s.logger.Error("Command %s is not implemented", slog.String("cmd", diceDBCmd.Cmd)) _, err := writer.Write([]byte("Command is not implemented with HTTP")) if err != nil { s.logger.Error("Error writing response", slog.Any("error", err)) @@ -151,7 +151,7 @@ func (s *HTTPServer) DiceHTTPHandler(writer http.ResponseWriter, request *http.R // send request to Shard Manager s.shardManager.GetShard(0).ReqChan <- &ops.StoreOp{ - Cmd: redisCmd, + Cmd: diceDBCmd, WorkerID: "httpServer", ShardID: 0, HTTPOp: true, @@ -160,19 +160,19 @@ func (s *HTTPServer) DiceHTTPHandler(writer http.ResponseWriter, request *http.R // Wait for response resp := <-s.ioChan - s.writeResponse(writer, resp, redisCmd) + s.writeResponse(writer, resp, diceDBCmd) } func (s *HTTPServer) DiceHTTPQwatchHandler(writer http.ResponseWriter, request *http.Request) { // convert to REDIS cmd - redisCmd, err := utils.ParseHTTPRequest(request) + diceDBCmd, err := utils.ParseHTTPRequest(request) if err != nil { http.Error(writer, "Error parsing HTTP request", http.StatusBadRequest) s.logger.Error("Error parsing HTTP request", slog.Any("error", err)) return } - if len(redisCmd.Args) < 1 { + if len(diceDBCmd.Args) < 1 { s.logger.Error("Invalid request for QWATCH") http.Error(writer, "Invalid request for QWATCH", http.StatusBadRequest) return @@ -198,11 +198,11 @@ func (s *HTTPServer) DiceHTTPQwatchHandler(writer http.ResponseWriter, request * writer.WriteHeader(http.StatusOK) // We're a generating a unique client id, to keep track in core of requests from registered clients clientIdentifierID := generateUniqueInt32(request) - qwatchQuery := redisCmd.Args[0] + qwatchQuery := diceDBCmd.Args[0] qwatchClient := comm.NewHTTPQwatchClient(s.qwatchResponseChan, clientIdentifierID) // Prepare the store operation storeOp := &ops.StoreOp{ - Cmd: redisCmd, + Cmd: diceDBCmd, WorkerID: "httpServer", ShardID: 0, Client: qwatchClient, @@ -231,14 +231,14 @@ func (s *HTTPServer) DiceHTTPQwatchHandler(writer http.ResponseWriter, request * case <-doneChan: // Client disconnected or request finished s.logger.Info("Client disconnected") - unWatchCmd := &cmd.RedisCmd{ + unWatchCmd := &cmd.DiceDBCmd{ Cmd: "QUNWATCH", Args: []string{qwatchQuery}, } storeOp.Cmd = unWatchCmd s.shardManager.GetShard(0).ReqChan <- storeOp resp := <-s.ioChan - s.writeResponse(writer, resp, redisCmd) + s.writeResponse(writer, resp, diceDBCmd) return } } @@ -316,8 +316,8 @@ func (s *HTTPServer) writeQWatchResponse(writer http.ResponseWriter, response in flusher.Flush() // Flush the response to send it to the client } -func (s *HTTPServer) writeResponse(writer http.ResponseWriter, result *ops.StoreResponse, redisCmd *cmd.RedisCmd) { - _, ok := WorkerCmdsMeta[redisCmd.Cmd] +func (s *HTTPServer) writeResponse(writer http.ResponseWriter, result *ops.StoreResponse, diceDBCmd *cmd.DiceDBCmd) { + _, ok := WorkerCmdsMeta[diceDBCmd.Cmd] var rp *clientio.RESPParser var responseValue interface{} diff --git a/internal/server/server.go b/internal/server/server.go index 273b0823a..8ee6a0453 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -297,9 +297,9 @@ func handleMigratedResp(resp interface{}, buf *bytes.Buffer) { buf.Write(r) } -func (s *AsyncServer) executeCommandToBuffer(redisCmd *cmd.RedisCmd, buf *bytes.Buffer, c *comm.Client) { +func (s *AsyncServer) executeCommandToBuffer(diceDBCmd *cmd.DiceDBCmd, buf *bytes.Buffer, c *comm.Client) { s.shardManager.GetShard(0).ReqChan <- &ops.StoreOp{ - Cmd: redisCmd, + Cmd: diceDBCmd, WorkerID: "server", ShardID: 0, Client: c, @@ -307,14 +307,14 @@ func (s *AsyncServer) executeCommandToBuffer(redisCmd *cmd.RedisCmd, buf *bytes. resp := <-s.ioChan - val, ok := WorkerCmdsMeta[redisCmd.Cmd] + val, ok := WorkerCmdsMeta[diceDBCmd.Cmd] // TODO: Remove this conditional check and if (true) condition when all commands are migrated if !ok { buf.Write(resp.EvalResponse.Result.([]byte)) } else { // If command type is Global then return the worker eval if val.CmdType == Global { - buf.Write(val.RespNoShards(redisCmd.Args)) + buf.Write(val.RespNoShards(diceDBCmd.Args)) return } // Handle error case independently @@ -334,7 +334,7 @@ func readCommands(c io.ReadWriter) (*cmd.RedisCmds, bool, error) { return nil, false, err } - var cmds = make([]*cmd.RedisCmd, 0) + var cmds = make([]*cmd.DiceDBCmd, 0) for _, value := range values { arrayValue, ok := value.([]interface{}) if !ok { @@ -351,7 +351,7 @@ func readCommands(c io.ReadWriter) (*cmd.RedisCmds, bool, error) { } command := strings.ToUpper(tokens[0]) - cmds = append(cmds, &cmd.RedisCmd{ + cmds = append(cmds, &cmd.DiceDBCmd{ Cmd: command, Args: tokens[1:], }) @@ -383,32 +383,32 @@ func (s *AsyncServer) EvalAndRespond(cmds *cmd.RedisCmds, c *comm.Client) { var resp []byte buf := bytes.NewBuffer(resp) - for _, redisCmd := range cmds.Cmds { - if !s.isAuthenticated(redisCmd, c, buf) { + for _, diceDBCmd := range cmds.Cmds { + if !s.isAuthenticated(diceDBCmd, c, buf) { continue } if c.IsTxn { - s.handleTransactionCommand(redisCmd, c, buf) + s.handleTransactionCommand(diceDBCmd, c, buf) } else { - s.handleNonTransactionCommand(redisCmd, c, buf) + s.handleNonTransactionCommand(diceDBCmd, c, buf) } } s.writeResponse(c, buf) } -func (s *AsyncServer) isAuthenticated(redisCmd *cmd.RedisCmd, c *comm.Client, buf *bytes.Buffer) bool { - if redisCmd.Cmd != auth.Cmd && !c.Session.IsActive() { +func (s *AsyncServer) isAuthenticated(diceDBCmd *cmd.DiceDBCmd, c *comm.Client, buf *bytes.Buffer) bool { + if diceDBCmd.Cmd != auth.Cmd && !c.Session.IsActive() { buf.Write(clientio.Encode(errors.New("NOAUTH Authentication required"), false)) return false } return true } -func (s *AsyncServer) handleTransactionCommand(redisCmd *cmd.RedisCmd, c *comm.Client, buf *bytes.Buffer) { - if eval.TxnCommands[redisCmd.Cmd] { - switch redisCmd.Cmd { +func (s *AsyncServer) handleTransactionCommand(diceDBCmd *cmd.DiceDBCmd, c *comm.Client, buf *bytes.Buffer) { + if eval.TxnCommands[diceDBCmd.Cmd] { + switch diceDBCmd.Cmd { case eval.ExecCmdMeta.Name: s.executeTransaction(c, buf) case eval.DiscardCmdMeta.Name: @@ -416,17 +416,17 @@ func (s *AsyncServer) handleTransactionCommand(redisCmd *cmd.RedisCmd, c *comm.C default: s.logger.Error( "Unhandled transaction command", - slog.String("command", redisCmd.Cmd), + slog.String("command", diceDBCmd.Cmd), ) } } else { - c.TxnQueue(redisCmd) + c.TxnQueue(diceDBCmd) buf.Write(clientio.RespQueued) } } -func (s *AsyncServer) handleNonTransactionCommand(redisCmd *cmd.RedisCmd, c *comm.Client, buf *bytes.Buffer) { - switch redisCmd.Cmd { +func (s *AsyncServer) handleNonTransactionCommand(diceDBCmd *cmd.DiceDBCmd, c *comm.Client, buf *bytes.Buffer) { + switch diceDBCmd.Cmd { case eval.MultiCmdMeta.Name: c.TxnBegin() buf.Write(clientio.RespOK) @@ -435,7 +435,7 @@ func (s *AsyncServer) handleNonTransactionCommand(redisCmd *cmd.RedisCmd, c *com case eval.DiscardCmdMeta.Name: buf.Write(diceerrors.NewErrWithMessage("DISCARD without MULTI")) default: - s.executeCommandToBuffer(redisCmd, buf, c) + s.executeCommandToBuffer(diceDBCmd, buf, c) } } @@ -451,7 +451,7 @@ func (s *AsyncServer) executeTransaction(c *comm.Client, buf *bytes.Buffer) { s.executeCommandToBuffer(cmd, buf, c) } - c.Cqueue.Cmds = make([]*cmd.RedisCmd, 0) + c.Cqueue.Cmds = make([]*cmd.DiceDBCmd, 0) c.IsTxn = false } diff --git a/internal/server/utils/redisCmdAdapter.go b/internal/server/utils/redisCmdAdapter.go index d4a471f3a..6edb7d945 100644 --- a/internal/server/utils/redisCmdAdapter.go +++ b/internal/server/utils/redisCmdAdapter.go @@ -33,15 +33,26 @@ const ( JSON = "json" ) -func ParseHTTPRequest(r *http.Request) (*cmd.RedisCmd, error) { - command := strings.TrimPrefix(r.URL.Path, "/") - if command == "" { +func ParseHTTPRequest(r *http.Request) (*cmd.DiceDBCmd, error) { + commandParts := strings.Split(strings.TrimPrefix(r.URL.Path, "/"), "/") + if len(commandParts) == 0 { return nil, errors.New("invalid command") } - command = strings.ToUpper(command) + command := strings.ToUpper(commandParts[0]) + + var subcommand string + if len(commandParts) > 1 { + subcommand = strings.ToUpper(commandParts[1]) + } + var args []string + // Handle subcommand and multiple arguments + if subcommand != "" { + args = append(args, subcommand) + } + // Extract query parameters queryParams := r.URL.Query() keyPrefix := queryParams.Get(KeyPrefix) @@ -68,69 +79,7 @@ func ParseHTTPRequest(r *http.Request) (*cmd.RedisCmd, error) { // Define keys to exclude and process their values first // Update as we support more commands - var priorityKeys = []string{ - Key, - Keys, - Field, - Path, - JSON, - Index, - Value, - Values, - Seconds, - User, - Password, - KeyValues, - QwatchQuery, - Offset, - Member, - Members, - } - for _, key := range priorityKeys { - if val, exists := jsonBody[key]; exists { - if key == Keys { - for _, v := range val.([]interface{}) { - args = append(args, fmt.Sprintf("%v", v)) - } - delete(jsonBody, key) - continue - } - if key == JSON { - jsonValue, err := json.Marshal(val) - if err != nil { - return nil, err - } - args = append(args, string(jsonValue)) - delete(jsonBody, key) - continue - } - if key == Values { - for _, v := range val.([]interface{}) { - args = append(args, fmt.Sprintf("%v", v)) - } - delete(jsonBody, key) - continue - } - // MultiKey operations - if key == KeyValues { - // Handle KeyValues separately - for k, v := range val.(map[string]interface{}) { - args = append(args, k, fmt.Sprintf("%v", v)) - } - delete(jsonBody, key) - continue - } - if key == Members { - for _, v := range val.([]interface{}) { - args = append(args, fmt.Sprintf("%v", v)) - } - delete(jsonBody, key) - continue - } - args = append(args, fmt.Sprintf("%v", val)) - delete(jsonBody, key) - } - } + processPriorityKeys(jsonBody, &args) // Process remaining keys in the JSON body for key, val := range jsonBody { @@ -160,14 +109,14 @@ func ParseHTTPRequest(r *http.Request) (*cmd.RedisCmd, error) { } } - // Step 2: Return the constructed Redis command - return &cmd.RedisCmd{ + // Step 2: Return the constructed DiceDB command + return &cmd.DiceDBCmd{ Cmd: command, Args: args, }, nil } -func ParseWebsocketMessage(msg []byte) (*cmd.RedisCmd, error) { +func ParseWebsocketMessage(msg []byte) (*cmd.DiceDBCmd, error) { cmdStr := string(msg) cmdStr = strings.TrimSpace(cmdStr) @@ -185,8 +134,54 @@ func ParseWebsocketMessage(msg []byte) (*cmd.RedisCmd, error) { cmdArr = append([]string{""}, cmdArr...) } - return &cmd.RedisCmd{ + return &cmd.DiceDBCmd{ Cmd: command, Args: cmdArr, }, nil } + +func processPriorityKeys(jsonBody map[string]interface{}, args *[]string) { + for _, key := range getPriorityKeys() { + if val, exists := jsonBody[key]; exists { + switch key { + case Keys, Members: + for _, v := range val.([]interface{}) { + *args = append(*args, fmt.Sprintf("%v", v)) + } + case JSON: + jsonValue, _ := json.Marshal(val) + *args = append(*args, string(jsonValue)) + case KeyValues: + for k, v := range val.(map[string]interface{}) { + *args = append(*args, k, fmt.Sprintf("%v", v)) + } + case Value: + *args = append(*args, formatValue(val)) + case Values: + for _, v := range val.([]interface{}) { + *args = append(*args, fmt.Sprintf("%v", v)) + } + default: + *args = append(*args, fmt.Sprintf("%v", val)) + } + delete(jsonBody, key) + } + } +} + +func getPriorityKeys() []string { + return []string{ + Key, Keys, Field, Path, JSON, Index, Value, Values, Seconds, User, Password, + KeyValues, QwatchQuery, Offset, Member, Members, + } +} + +func formatValue(val interface{}) string { + switch v := val.(type) { + case string: + return v + default: + jsonBytes, _ := json.Marshal(v) + return string(jsonBytes) + } +} diff --git a/internal/server/utils/redisCmdAdapter_test.go b/internal/server/utils/redisCmdAdapter_test.go index 0d04e9181..d85bcacfe 100644 --- a/internal/server/utils/redisCmdAdapter_test.go +++ b/internal/server/utils/redisCmdAdapter_test.go @@ -26,6 +26,46 @@ func TestParseHTTPRequest(t *testing.T) { expectedCmd: "SET", expectedArgs: []string{"k1", "v1", "nx"}, }, + { + name: "Test SET command with value as a map", + method: "POST", + url: "/set", + body: `{"key": "k1", "value": {"subKey": "subValue"}, "nx": "true"}`, + expectedCmd: "SET", + expectedArgs: []string{"k1", `{"subKey":"subValue"}`, "nx"}, + }, + { + name: "Test SET command with value as an array", + method: "POST", + url: "/set", + body: `{"key": "k1", "value": ["item1", "item2", "item3"], "nx": "true"}`, + expectedCmd: "SET", + expectedArgs: []string{"k1", `["item1","item2","item3"]`, "nx"}, + }, + { + name: "Test SET command with value as a map containing an array", + method: "POST", + url: "/set", + body: `{"key": "k1", "value": {"subKey": ["item1", "item2"]}, "nx": "true"}`, + expectedCmd: "SET", + expectedArgs: []string{"k1", `{"subKey":["item1","item2"]}`, "nx"}, + }, + { + name: "Test SET command with value as a deeply nested map", + method: "POST", + url: "/set", + body: `{"key": "k1", "value": {"subKey": {"subSubKey": {"deepKey": "deepValue"}}}, "nx": "true"}`, + expectedCmd: "SET", + expectedArgs: []string{"k1", `{"subKey":{"subSubKey":{"deepKey":"deepValue"}}}`, "nx"}, + }, + { + name: "Test SET command with value as an array of maps", + method: "POST", + url: "/set", + body: `{"key": "k1", "value": [{"subKey1": "value1"}, {"subKey2": "value2"}], "nx": "true"}`, + expectedCmd: "SET", + expectedArgs: []string{"k1", `[{"subKey1":"value1"},{"subKey2":"value2"}]`, "nx"}, + }, { name: "Test GET command", method: "POST", @@ -177,19 +217,19 @@ func TestParseHTTPRequest(t *testing.T) { req := httptest.NewRequest(tc.method, tc.url, strings.NewReader(tc.body)) req.Header.Set("Content-Type", "application/json") - redisCmd, err := ParseHTTPRequest(req) + diceDBCmd, err := ParseHTTPRequest(req) assert.NoError(t, err) - expectedCmd := &cmd.RedisCmd{ + expectedCmd := &cmd.DiceDBCmd{ Cmd: tc.expectedCmd, Args: tc.expectedArgs, } // Check command match - assert.Equal(t, expectedCmd.Cmd, redisCmd.Cmd) + assert.Equal(t, expectedCmd.Cmd, diceDBCmd.Cmd) // Check arguments match, regardless of order - assert.ElementsMatch(t, expectedCmd.Args, redisCmd.Args, "The parsed arguments should match the expected arguments, ignoring order") + assert.ElementsMatch(t, expectedCmd.Args, diceDBCmd.Args, "The parsed arguments should match the expected arguments, ignoring order") }) } @@ -249,19 +289,19 @@ func TestParseWebsocketMessage(t *testing.T) { for _, tc := range commands { t.Run(tc.name, func(t *testing.T) { // parse websocket message - redisCmd, err := ParseWebsocketMessage([]byte(tc.message)) + diceDBCmd, err := ParseWebsocketMessage([]byte(tc.message)) assert.NoError(t, err) - expectedCmd := &cmd.RedisCmd{ + expectedCmd := &cmd.DiceDBCmd{ Cmd: tc.expectedCmd, Args: tc.expectedArgs, } // Check command match - assert.Equal(t, expectedCmd.Cmd, redisCmd.Cmd) + assert.Equal(t, expectedCmd.Cmd, diceDBCmd.Cmd) // Check arguments match, regardless of order - assert.ElementsMatch(t, expectedCmd.Args, redisCmd.Args, "The parsed arguments should match the expected arguments, ignoring order") + assert.ElementsMatch(t, expectedCmd.Args, diceDBCmd.Args, "The parsed arguments should match the expected arguments, ignoring order") }) } } diff --git a/internal/server/utils/round.go b/internal/server/utils/round.go new file mode 100644 index 000000000..467c01eab --- /dev/null +++ b/internal/server/utils/round.go @@ -0,0 +1,9 @@ +package utils + +import "math" + +// RoundToDecimals rounds a float64 or float32 to a specified number of decimal places. +func RoundToDecimals[T float32 | float64](num T, decimals int) T { + pow := math.Pow(10, float64(decimals)) + return T(math.Round(float64(num)*pow) / pow) +} diff --git a/internal/server/websocketServer.go b/internal/server/websocketServer.go index 5d72ffa13..dd8ef8805 100644 --- a/internal/server/websocketServer.go +++ b/internal/server/websocketServer.go @@ -135,7 +135,7 @@ func (s *WebsocketServer) WebsocketHandler(w http.ResponseWriter, r *http.Reques } // parse message to dice command - redisCmd, err := utils.ParseWebsocketMessage(msg) + diceDBCmd, err := utils.ParseWebsocketMessage(msg) if errors.Is(err, diceerrors.ErrEmptyCommand) { continue } else if err != nil { @@ -143,19 +143,19 @@ func (s *WebsocketServer) WebsocketHandler(w http.ResponseWriter, r *http.Reques continue } - if redisCmd.Cmd == Abort { + if diceDBCmd.Cmd == Abort { close(s.shutdownChan) break } - if unimplementedCommandsWebsocket[redisCmd.Cmd] { + if unimplementedCommandsWebsocket[diceDBCmd.Cmd] { writeResponse(conn, []byte("Command is not implemented with Websocket")) continue } // send request to Shard Manager s.shardManager.GetShard(0).ReqChan <- &ops.StoreOp{ - Cmd: redisCmd, + Cmd: diceDBCmd, WorkerID: "wsServer", ShardID: 0, WebsocketOp: true, @@ -164,7 +164,7 @@ func (s *WebsocketServer) WebsocketHandler(w http.ResponseWriter, r *http.Reques // Wait for response resp := <-s.ioChan - _, ok := WorkerCmdsMeta[redisCmd.Cmd] + _, ok := WorkerCmdsMeta[diceDBCmd.Cmd] respArr := []string{ "(nil)", // Represents a RESP Nil Bulk String, which indicates a null value. "OK", // Represents a RESP Simple String with value "OK". diff --git a/internal/sql/dsql.go b/internal/sql/dsql.go index 31aa3a8fb..133ab3a41 100644 --- a/internal/sql/dsql.go +++ b/internal/sql/dsql.go @@ -5,8 +5,6 @@ import ( "strconv" "strings" - hash "github.com/dgryski/go-farm" - "github.com/xwb1989/sqlparser" ) @@ -275,9 +273,3 @@ func parseWhere(selectStmt *sqlparser.Select) sqlparser.Expr { } return selectStmt.Where.Expr } - -func generateFingerprint(where sqlparser.Expr) string { - // Generate a unique fingerprint for the query - // TODO: Add logic to ensure that logically equivalent WHERE clause expressions generate the same fingerprint. - return fmt.Sprintf("f_%d", hash.Hash64([]byte(sqlparser.String(where)))) -} diff --git a/internal/sql/fingerprint.go b/internal/sql/fingerprint.go new file mode 100644 index 000000000..5bcf5391a --- /dev/null +++ b/internal/sql/fingerprint.go @@ -0,0 +1,105 @@ +package sql + +import ( + "fmt" + "sort" + "strings" + + hash "github.com/dgryski/go-farm" + "github.com/xwb1989/sqlparser" +) + +// OR terms containing AND expressions +type expression [][]string + +func (expr expression) String() string { + var orTerms []string + for _, andTerm := range expr { + // Sort AND terms within OR + sort.Strings(andTerm) + orTerms = append(orTerms, strings.Join(andTerm, " AND ")) + } + // Sort the OR terms + sort.Strings(orTerms) + return strings.Join(orTerms, " OR ") +} + +func generateFingerprint(where sqlparser.Expr) string { + expr := parseAstExpression(where) + return fmt.Sprintf("f_%d", hash.Hash64([]byte(expr.String()))) +} + +func parseAstExpression(expr sqlparser.Expr) expression { + switch expr := expr.(type) { + case *sqlparser.AndExpr: + leftExpr := parseAstExpression(expr.Left) + rightExpr := parseAstExpression(expr.Right) + return combineAnd(leftExpr, rightExpr) + case *sqlparser.OrExpr: + leftExpr := parseAstExpression(expr.Left) + rightExpr := parseAstExpression(expr.Right) + return combineOr(leftExpr, rightExpr) + case *sqlparser.ParenExpr: + return parseAstExpression(expr.Expr) + case *sqlparser.ComparisonExpr: + return expression([][]string{{expr.Operator + sqlparser.String(expr.Left) + sqlparser.String(expr.Right)}}) + default: + return expression{} + } +} + +func combineAnd(a, b expression) expression { + result := make(expression, 0, len(a)+len(b)) + for _, termA := range a { + for _, termB := range b { + combined := make([]string, len(termA), len(termA)+len(termB)) + copy(combined, termA) + combined = append(combined, termB...) + uniqueCombined := removeDuplicates(combined) + sort.Strings(uniqueCombined) + result = append(result, uniqueCombined) + } + } + return result +} + +func combineOr(a, b expression) expression { + result := make(expression, 0, len(a)+len(b)) + uniqueTerms := make(map[string]bool) + + // Helper function to add unique terms + addUnique := func(terms []string) { + // Sort the terms for consistent ordering + sort.Strings(terms) + key := strings.Join(terms, ",") + if !uniqueTerms[key] { + result = append(result, terms) + uniqueTerms[key] = true + } + } + + // Add unique terms from a + for _, terms := range a { + addUnique(append([]string(nil), terms...)) + } + + // Add unique terms from b + for _, terms := range b { + addUnique(append([]string(nil), terms...)) + } + + return result +} + +// helper +func removeDuplicates(input []string) []string { + seen := make(map[string]struct{}) + var result []string + for _, v := range input { + if _, exists := seen[v]; !exists { + seen[v] = struct{}{} + result = append(result, v) + } + } + return result +} diff --git a/internal/sql/fingerprint_test.go b/internal/sql/fingerprint_test.go new file mode 100644 index 000000000..38fecf40c --- /dev/null +++ b/internal/sql/fingerprint_test.go @@ -0,0 +1,402 @@ +package sql + +import ( + "testing" + + "github.com/xwb1989/sqlparser" + "gotest.tools/v3/assert" +) + +func TestExpressionString(t *testing.T) { + tests := []struct { + name string + expr expression + expected string + }{ + { + name: "Single AND term", + expr: expression{{"_key LIKE 'match:1:*'", "_value > 10"}}, + expected: "_key LIKE 'match:1:*' AND _value > 10", + }, + { + name: "Multiple AND terms in a single OR term", + expr: expression{{"_key LIKE 'match:1:*'", "_value > 10", "_value < 5"}}, + expected: "_key LIKE 'match:1:*' AND _value < 5 AND _value > 10", + }, + { + name: "Multiple OR terms with single AND terms", + expr: expression{{"_key LIKE 'match:1:*'"}, {"_value < 5"}, {"_value > 10"}}, + expected: "_key LIKE 'match:1:*' OR _value < 5 OR _value > 10", + }, + { + name: "Multiple OR terms with AND combinations", + expr: expression{{"_key LIKE 'match:1:*'", "_value > 10"}, {"_value < 5", "_value > 0"}}, + expected: "_key LIKE 'match:1:*' AND _value > 10 OR _value < 5 AND _value > 0", + }, + { + name: "Unordered terms", + expr: expression{{"_value > 10", "_key LIKE 'match:1:*'"}, {"_value > 0", "_value < 5"}}, + expected: "_key LIKE 'match:1:*' AND _value > 10 OR _value < 5 AND _value > 0", + }, + { + name: "Nested AND and OR terms with duplicates", + expr: expression{{"_key LIKE 'match:1:*'", "_value < 5"}, {"_key LIKE 'match:1:*'", "_value < 5", "_value > 10"}}, + expected: "_key LIKE 'match:1:*' AND _value < 5 OR _key LIKE 'match:1:*' AND _value < 5 AND _value > 10", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + assert.Equal(t, test.expected, test.expr.String()) + }) + } +} + +func TestCombineOr(t *testing.T) { + tests := []struct { + name string + a expression + b expression + expected expression + }{ + { + name: "Combining two empty expressions", + a: expression([][]string{}), + b: expression([][]string{}), + expected: expression([][]string{}), + }, + { + name: "Identity law", + a: expression([][]string{{"_value > 10"}}), + b: expression([][]string{}), // equivalent to 0 + expected: expression([][]string{{"_value > 10"}}), + }, + { + name: "Idempotent law", + a: expression([][]string{{"_value > 10"}}), + b: expression([][]string{{"_value > 10"}}), + expected: expression([][]string{{"_value > 10"}}), + }, + { + name: "Simple OR combination with non-overlapping terms", + a: expression([][]string{{"_key LIKE 'test:*'"}}), + b: expression([][]string{{"_value > 10"}}), + expected: expression([][]string{ + {"_key LIKE 'test:*'"}, {"_value > 10"}, + }), + }, + { + name: "Complex OR combination with multiple AND terms", + a: expression([][]string{{"_key LIKE 'test:*'", "_value > 10"}}), + b: expression([][]string{{"_key LIKE 'example:*'", "_value < 5"}}), + expected: expression([][]string{ + {"_key LIKE 'test:*'", "_value > 10"}, {"_key LIKE 'example:*'", "_value < 5"}, + }), + }, + { + name: "Combining overlapping AND terms", + a: expression([][]string{{"_key LIKE 'test:*'", "_value > 10"}}), + b: expression([][]string{{"_value > 10", "_key LIKE 'test:*'"}}), + expected: expression([][]string{ + {"_key LIKE 'test:*'", "_value > 10"}, + }), + }, + { + name: "Combining overlapping AND terms in reverse order", + a: expression([][]string{{"_value > 10", "_key LIKE 'test:*'"}}), + b: expression([][]string{{"_key LIKE 'test:*'", "_value > 10"}}), + expected: expression([][]string{ + {"_key LIKE 'test:*'", "_value > 10"}, + }), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.DeepEqual(t, tt.expected, combineOr(tt.a, tt.b)) + }) + } +} + +func TestCombineAnd(t *testing.T) { + tests := []struct { + name string + a expression + b expression + expected expression + }{ + { + name: "Combining two empty expressions", + a: expression([][]string{}), + b: expression([][]string{}), + expected: expression([][]string{}), + }, + { + name: "Annulment law", + a: expression([][]string{{"_value > 10"}}), + b: expression([][]string{}), // equivalent to 0 + expected: expression([][]string{}), + }, + { + name: "Identity law", + a: expression([][]string{{"_value > 10"}}), + b: expression([][]string{{}}), // equivalent to 1 + expected: expression([][]string{{"_value > 10"}}), + }, + { + name: "Idempotent law", + a: expression([][]string{{"_value > 10"}}), + b: expression([][]string{{"_value > 10"}}), + expected: expression([][]string{{"_value > 10"}}), + }, + { + name: "Multiple AND terms, no duplicates", + a: expression([][]string{{"_value > 10"}}), + b: expression([][]string{{"_key LIKE 'test:*'", "_value < 5"}}), + expected: expression([][]string{{"_key LIKE 'test:*'", "_value < 5", "_value > 10"}}), + }, + { + name: "Multiple terms in both expressions with duplicates", + a: expression([][]string{{"_value > 10", "_key LIKE 'test:*'"}}), + b: expression([][]string{{"_key LIKE 'test:*'", "_value < 5"}}), + expected: expression([][]string{ + {"_key LIKE 'test:*'", "_value < 5", "_value > 10"}, + }), + }, + { + name: "Terms in different order, no duplicates", + a: expression([][]string{{"_key LIKE 'test:*'", "_value > 10"}}), + b: expression([][]string{{"_value < 5"}}), + expected: expression([][]string{ + {"_key LIKE 'test:*'", "_value < 5", "_value > 10"}, + }), + }, + { + name: "Terms in different order with duplicates", + a: expression([][]string{{"_value > 10", "_key LIKE 'test:*'"}}), + b: expression([][]string{{"_key LIKE 'test:*'", "_value < 5"}}), + expected: expression([][]string{ + {"_key LIKE 'test:*'", "_value < 5", "_value > 10"}, + }), + }, + { + name: "Partial duplicates across expressions", + a: expression([][]string{{"_value > 10", "_key LIKE 'test:*'"}}), + b: expression([][]string{{"_key LIKE 'test:*'", "_key = 'abc'"}}), + expected: expression([][]string{ + {"_key = 'abc'", "_key LIKE 'test:*'", "_value > 10"}, + }), + }, + { + name: "Nested AND groups", + a: expression([][]string{{"_key LIKE 'test:*'", "_value > 10"}}), + b: expression([][]string{{"_key LIKE 'test:*'", "_value < 5"}, {"_value = 7"}}), + expected: expression([][]string{ + {"_key LIKE 'test:*'", "_value < 5", "_value > 10"}, + {"_key LIKE 'test:*'", "_value = 7", "_value > 10"}, + }), + }, + { + name: "Same terms but in different AND groups", + a: expression([][]string{{"_key LIKE 'test:*'"}}), + b: expression([][]string{{"_key LIKE 'test:*'", "_value < 5"}, {"_key LIKE 'test:*'"}}), + expected: expression([][]string{ + {"_key LIKE 'test:*'", "_value < 5"}, + {"_key LIKE 'test:*'"}, + }), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.DeepEqual(t, tt.expected, combineAnd(tt.a, tt.b)) + }) + } +} + +func TestGenerateFingerprintAndParseAstExpression(t *testing.T) { + tests := []struct { + name string + similarExpr []string // logically same where expressions + expression string + fingerprint string + }{ + { + name: "Terms in different order, OR operator", + similarExpr: []string{ + "_value > 10 OR _value < 5", + "_value < 5 OR _value > 10", + }, + expression: "<_value5 OR >_value10", + fingerprint: "f_5731466836575684070", + }, + { + name: "Terms in different order, AND operator", + similarExpr: []string{ + "_value > 10 AND _value < 5", + "_value < 5 AND _value > 10", + }, + expression: "<_value5 AND >_value10", + fingerprint: "f_8696580727138087340", + }, + { + // ideally this and below test should spit same output + name: "Simple comparison operator (comparison value in backticks)", + similarExpr: []string{ + "_key like `match:1:*`", + }, + expression: "like_key`match:1:*`", + fingerprint: "f_15929225480754059748", + }, + { + name: "Simple comparison operator (comparison value in single quotes)", + similarExpr: []string{ + "_key like 'match:1:*'", + }, + expression: "like_key'match:1:*'", + fingerprint: "f_5313097907453016110", + }, + { + name: "Simple comparison operator with multiple redundant parentheses", + similarExpr: []string{ + "_key like 'match:1:*'", + "(_key like 'match:1:*')", + "((_key like 'match:1:*'))", + "(((_key like 'match:1:*')))", + }, + expression: "like_key'match:1:*'", + fingerprint: "f_5313097907453016110", + }, + { + name: "Expression with duplicate terms (or Idempotent law)", + similarExpr: []string{ + "_key like 'match:1:*' AND _key like 'match:1:*'", + "_key like 'match:1:*'", + }, + expression: "like_key'match:1:*'", + fingerprint: "f_5313097907453016110", + }, + { + name: "expression with exactly 1 term, multiple AND OR (Idempotent law)", + similarExpr: []string{ + "_value > 10 AND _value > 10 OR _value > 10 AND _value > 10", + "_value > 10", + }, + expression: ">_value10", + fingerprint: "f_11845737393789912467", + }, + { + name: "Expression in form 'A AND (B OR C)' which can reduce to 'A AND B OR A AND C' etc (or Distributive law)", + similarExpr: []string{ + "(_key LIKE 'test:*') AND (_value > 10 OR _value < 5)", + "(_value > 10 OR _value < 5) AND (_key LIKE 'test:*')", + "(_value < 5 OR _value > 10) AND (_key LIKE 'test:*')", + "(_key LIKE 'test:*' AND _value > 10) OR (_key LIKE 'test:*' AND _value < 5)", + "((_key LIKE 'test:*') AND _value > 10) OR ((_key LIKE 'test:*') AND _value < 5)", + "(_key LIKE 'test:*') AND ((_value > 10) OR (_value < 5))", + "(_value > 10 AND _key LIKE 'test:*') OR (_value < 5 AND _key LIKE 'test:*')", + "(_value < 5 AND _key LIKE 'test:*') OR (_value > 10 AND _key LIKE 'test:*')", + }, + expression: "<_value5 AND like_key'test:*' OR >_value10 AND like_key'test:*'", + fingerprint: "f_6936111135456499050", + }, + { + // ideally this and below test should spit same output + // but our algorithm is not sophisticated enough yet + name: "Expression in form 'A OR (B AND C)' which can reduce to 'A OR B AND A OR C' etc (or Distributive law)", + similarExpr: []string{ + "_key LIKE 'test:*' OR _value > 10 AND _value < 5", + "(_key LIKE 'test:*') OR (_value > 10 AND _value < 5)", + "(_value > 10 AND _value < 5) OR (_key LIKE 'test:*')", + "(_value < 5 AND _value > 10) OR (_key LIKE 'test:*')", + // "(_key LIKE 'test:*' OR _value > 10) AND (_key LIKE 'test:*' OR _value < 5)", + // "((_key LIKE 'test:*') OR (_value > 10)) AND ((_key LIKE 'test:*') OR (_value < 5))", + }, + expression: "<_value5 AND >_value10 OR like_key'test:*'", + fingerprint: "f_655732287561200780", + }, + { + name: "Complex expression with multiple redundant parentheses", + similarExpr: []string{ + "(_key LIKE 'test:*' OR _value > 10) AND (_key LIKE 'test:*' OR _value < 5)", + "((_key LIKE 'test:*') OR (_value > 10)) AND ((_key LIKE 'test:*') OR (_value < 5))", + }, + expression: "<_value5 AND >_value10 OR <_value5 AND like_key'test:*' OR >_value10 AND like_key'test:*' OR like_key'test:*'", + fingerprint: "f_1509117529358989129", + }, + { + name: "Test Precedence: AND before OR with LIKE and Value Comparison", + similarExpr: []string{ + "_key LIKE 'test:*' AND _value > 10 OR _value < 5", + "(_key LIKE 'test:*' AND _value > 10) OR _value < 5", + }, + expression: "<_value5 OR >_value10 AND like_key'test:*'", + fingerprint: "f_8791273852316817684", + }, + { + name: "Simple JSON expression", + similarExpr: []string{ + "'_value.age' > 30 and _key like 'user:*' and '_value.age' > 30", + "'_value.age' > 30 and _key like 'user:*'", + "_key like 'user:*' and '_value.age' > 30 ", + }, + expression: ">'_value.age'30 AND like_key'user:*'", + fingerprint: "f_5016002712062179335", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + for _, query := range tt.similarExpr { + where, err := parseSQL("SELECT * WHERE " + query) + if err != nil { + t.Fail() + } + assert.DeepEqual(t, tt.expression, parseAstExpression(where).String()) + assert.DeepEqual(t, tt.fingerprint, generateFingerprint(where)) + } + }) + } +} + +// Benchmark for generateFingerprint function +func BenchmarkGenerateFingerprint(b *testing.B) { + queries := []struct { + name string + query string + }{ + {"Simple", "SELECT * WHERE _key LIKE 'match:1:*'"}, + {"OrExpression", "SELECT * WHERE _key LIKE 'match:1:*' OR _value > 10"}, + {"AndExpression", "SELECT * WHERE _key LIKE 'match:1:*' AND _value > 10"}, + {"NestedOrAnd", "SELECT * WHERE _key LIKE 'match:1:*' OR (_value > 10 AND _value < 5)"}, + {"DeepNested", "SELECT * FROM table WHERE _key LIKE 'match:1:*' OR (_value > 10 AND (_value < 5 OR '_value.age' > 18))"}, + } + + for _, tt := range queries { + expr, err := parseSQL(tt.query) + if err != nil { + b.Fail() + } + + b.Run(tt.name, func(b *testing.B) { + b.ResetTimer() + b.ReportAllocs() + for i := 0; i < b.N; i++ { + generateFingerprint(expr) + } + }) + } +} + +// helper +func parseSQL(query string) (sqlparser.Expr, error) { + stmt, err := sqlparser.Parse(query) + if err != nil { + return nil, err + } + + selectStmt, ok := stmt.(*sqlparser.Select) + if !ok { + return nil, err + } + + return selectStmt.Where.Expr, nil +} diff --git a/internal/worker/cmd_meta.go b/internal/worker/cmd_meta.go index 8fe6c8ae7..84ab08018 100644 --- a/internal/worker/cmd_meta.go +++ b/internal/worker/cmd_meta.go @@ -38,9 +38,9 @@ type CmdMeta struct { Cmd string WorkerCommandHandler func([]string) []byte - // decomposeCommand is a function that takes a Redis command and breaks it down into smaller, - // manageable Redis commands for each shard processing. It returns a slice of Redis commands. - decomposeCommand func(redisCmd *cmd.RedisCmd) []*cmd.RedisCmd + // decomposeCommand is a function that takes a DiceDB command and breaks it down into smaller, + // manageable DiceDB commands for each shard processing. It returns a slice of DiceDB commands. + decomposeCommand func(DiceDBCmd *cmd.DiceDBCmd) []*cmd.DiceDBCmd // composeResponse is a function that combines multiple responses from the execution of commands // into a single response object. It accepts a variadic parameter of EvalResponse objects diff --git a/internal/worker/worker.go b/internal/worker/worker.go index 2c6471160..fc7ae0203 100644 --- a/internal/worker/worker.go +++ b/internal/worker/worker.go @@ -158,38 +158,38 @@ func (w *BaseWorker) executeCommandHandler(errChan chan error, err error, execCt } } -func (w *BaseWorker) executeCommand(ctx context.Context, redisCmd *cmd.RedisCmd, isWatchNotification bool) error { +func (w *BaseWorker) executeCommand(ctx context.Context, diceDBCmd *cmd.DiceDBCmd, isWatchNotification bool) error { // Break down the single command into multiple commands if multisharding is supported. // The length of cmdList helps determine how many shards to wait for responses. - cmdList := make([]*cmd.RedisCmd, 0) + cmdList := make([]*cmd.DiceDBCmd, 0) localErrChan := make(chan error, 1) // Retrieve metadata for the command to determine if multisharding is supported. - meta, ok := CommandsMeta[redisCmd.Cmd] + meta, ok := CommandsMeta[diceDBCmd.Cmd] if !ok { // If no metadata exists, treat it as a single command and not migrated - cmdList = append(cmdList, redisCmd) + cmdList = append(cmdList, diceDBCmd) } else { // Depending on the command type, decide how to handle it. switch meta.CmdType { case Global: // If it's a global command, process it immediately without involving any shards. - err := w.ioHandler.Write(ctx, meta.WorkerCommandHandler(redisCmd.Args)) + err := w.ioHandler.Write(ctx, meta.WorkerCommandHandler(diceDBCmd.Args)) w.logger.Debug("Error executing for worker", slog.String("workerID", w.id), slog.Any("error", err)) return err case SingleShard: // For single-shard or custom commands, process them without breaking up. - cmdList = append(cmdList, redisCmd) + cmdList = append(cmdList, diceDBCmd) case MultiShard: // If the command supports multisharding, break it down into multiple commands. - cmdList = meta.decomposeCommand(redisCmd) + cmdList = meta.decomposeCommand(diceDBCmd) case Custom: - switch redisCmd.Cmd { + switch diceDBCmd.Cmd { case CmdAuth: - err := w.ioHandler.Write(ctx, w.RespAuth(redisCmd.Args)) + err := w.ioHandler.Write(ctx, w.RespAuth(diceDBCmd.Args)) if err != nil { w.logger.Error("Error sending auth response to worker", slog.String("workerID", w.id), slog.Any("error", err)) } @@ -203,14 +203,14 @@ func (w *BaseWorker) executeCommand(ctx context.Context, redisCmd *cmd.RedisCmd, w.globalErrorChan <- diceerrors.ErrAborted return err default: - cmdList = append(cmdList, redisCmd) + cmdList = append(cmdList, diceDBCmd) } case Watch: // Generate the Cmd being watched. All we need to do is remove the .WATCH suffix from the command and pass // it along as is. - watchCmd := &cmd.RedisCmd{ - Cmd: redisCmd.Cmd[:len(redisCmd.Cmd)-6], // Remove the .WATCH suffix - Args: redisCmd.Args, + watchCmd := &cmd.DiceDBCmd{ + Cmd: diceDBCmd.Cmd[:len(diceDBCmd.Cmd)-6], // Remove the .WATCH suffix + Args: diceDBCmd.Args, } cmdList = append(cmdList, watchCmd) @@ -240,9 +240,9 @@ func (w *BaseWorker) executeCommand(ctx context.Context, redisCmd *cmd.RedisCmd, if isWatchNotification { cmdType = Watch } - + // Gather the responses from the shards and write them to the buffer. - err = w.gather(ctx, redisCmd.Cmd, len(cmdList), cmdType) + err = w.gather(ctx, diceDBCmd.Cmd, len(cmdList), cmdType) if err != nil { localErrChan <- err return err @@ -252,9 +252,9 @@ func (w *BaseWorker) executeCommand(ctx context.Context, redisCmd *cmd.RedisCmd, return nil } -// scatter distributes the Redis commands to the respective shards based on the key. +// scatter distributes the DiceDB commands to the respective shards based on the key. // For each command, it calculates the shard ID and sends the command to the shard's request channel for processing. -func (w *BaseWorker) scatter(ctx context.Context, cmds []*cmd.RedisCmd) error { +func (w *BaseWorker) scatter(ctx context.Context, cmds []*cmd.DiceDBCmd) error { // Otherwise check for the shard based on the key using hash // and send it to the particular shard select { @@ -384,8 +384,8 @@ func (w *BaseWorker) gather(ctx context.Context, c string, numCmds int, ct CmdTy return nil } -func (w *BaseWorker) isAuthenticated(redisCmd *cmd.RedisCmd) error { - if redisCmd.Cmd != auth.Cmd && !w.Session.IsActive() { +func (w *BaseWorker) isAuthenticated(diceDBCmd *cmd.DiceDBCmd) error { + if diceDBCmd.Cmd != auth.Cmd && !w.Session.IsActive() { return errors.New("NOAUTH Authentication required") } diff --git a/main.go b/main.go index d4a5fe612..0fae87dc8 100644 --- a/main.go +++ b/main.go @@ -32,6 +32,9 @@ func init() { flag.StringVar(&config.CustomConfigFilePath, "o", config.CustomConfigFilePath, "dir path to create the config file") flag.StringVar(&config.FileLocation, "c", config.FileLocation, "file path of the config file") flag.BoolVar(&config.InitConfigCmd, "init-config", false, "initialize a new config file") + flag.IntVar(&config.KeysLimit, "keys-limit", config.KeysLimit, "keys limit for the dice server. "+ + "This flag controls the number of keys each shard holds at startup. You can multiply this number with the "+ + "total number of shard threads to estimate how much memory will be required at system start up.") flag.Parse() config.SetupConfig()