diff --git a/src/commands/zrange-command.common.js b/src/commands/zrange-command.common.js index 3cea884ad..5829b1822 100644 --- a/src/commands/zrange-command.common.js +++ b/src/commands/zrange-command.common.js @@ -1,3 +1,5 @@ +import orderBy from 'lodash.orderby'; + export function slice(arr, start, end) { return arr.slice(start, end === -1 ? undefined : end + 1) } @@ -49,29 +51,143 @@ export function filterPredicate(min, max) { } } -// this emulates the way Redis handles parsing these arguments -// see /~https://github.com/antirez/redis/blob/06d490342f51cff316588a7a45124cc410b7d050/src/t_zset.c#L2556 -export function getWithScoresAndLimit(args) { - let remaining = args.length - let pos = 0 - let withScores = false - let limit = null - let offset = null - - while (remaining > 0) { - if (remaining >= 1 && args[pos].toUpperCase() === 'WITHSCORES') { - withScores = true - pos += 1 - remaining -= 1 - } else if (remaining >= 3 && args[pos].toUpperCase() === 'LIMIT') { - offset = parseInt(args[pos + 1], 10) - limit = parseInt(args[pos + 2], 10) - pos += 3 - remaining -= 3 +function streq(a, b) { + return a.toString().toLowerCase() === b.toString().toLowerCase() +} + +export const DIRECTION_REVERSE = 'reverse'; +const DIRECTION_FORWARD = 'forward'; + +export const RANGE_RANK = 'rank'; +export const RANGE_LEX = 'lex'; +export const RANGE_SCORE = 'score'; + +/** /~https://github.com/redis/redis/blob/f651708a19b4fc8137eec13180fcea39e68fb284/src/t_zset.c#L3589 */ +export function zrangeBaseCommand(args, argsStart = 0, store = false, inputRange = null, inputDirection = null) { + const key = args[argsStart]; + const map = this.data.get(key) + if (!map) { + return [] + } + + // @TODO investigate a more stable way to detect sorted lists + if (this.data.has(key) && !(this.data.get(key) instanceof Map)) { + return [] + } + + let withScores = false; + let offset = 0; + let limit = null; + let direction = inputDirection || DIRECTION_FORWARD; + let range = inputRange || RANGE_RANK; + let start; + let end; + + let minIdx = argsStart + 1; + let maxIdx = argsStart + 2; + + /* Step 1: Skip the args and parse remaining optional arguments. */ + for (let j = argsStart + 3; j < args.length; j++) { + const leftargs = args.length - j - 1; + + if (!store && streq(args[j], 'withscores')) { + withScores = 1; + } else if (streq(args[j], 'limit') && leftargs >= 2) { + offset = parseInt(args[j + 1], 10); + limit = parseInt(args[j + 2], 10); + if (Number.isNaN(offset) || Number.isNaN(limit)) { + throw new Error('ERR syntax error') + } + + j += 2; + } else if (!inputDirection && streq(args[j], 'rev')) { + direction = DIRECTION_REVERSE; + } else if (!inputRange && streq(args[j] , 'bylex')) { + range = RANGE_LEX; + } else if (!inputRange && streq(args[j], 'byscore')) { + range = RANGE_SCORE; } else { - throw new Error('ERR syntax error') + throw new Error('ERR syntax error'); } } - return { withScores, limit, offset } + if (limit !== null && range === RANGE_RANK) { + throw new Error('ERR syntax error, LIMIT is only supported in combination with either BYSCORE or BYLEX'); + } + + if (withScores && range === RANGE_LEX) { + throw new Error('ERR syntax error, WITHSCORES not supported in combination with BYLEX'); + } + + if (direction === DIRECTION_REVERSE && (range === RANGE_SCORE || range === RANGE_LEX)) { + /* Range is given as [max,min] */ + const tmp = maxIdx; + maxIdx = minIdx; + minIdx = tmp; + } + + + /* Step 2: Parse the range. */ + switch (range) { + case RANGE_RANK: + /* Z[REV]RANGE, ZRANGESTORE [REV]RANGE */ + start = parseInt(args[minIdx], 10); + end = parseInt(args[maxIdx], 10); + + if (Number.isNaN(start) || Number.isNaN(end)) { + throw new Error('ERR syntax error '); + } + break; + + case RANGE_SCORE: + /* Z[REV]RANGEBYSCORE, ZRANGESTORE [REV]RANGEBYSCORE */ + start = parseLimit(args[minIdx]); + end = parseLimit(args[maxIdx]); + + break; + // FIXME: handle RANGE_LEX + // case ZRANGE_LEX: + // /* Z[REV]RANGEBYLEX, ZRANGESTORE [REV]RANGEBYLEX */ + // if (zslParseLexRange(c->argv[minidx], c->argv[maxidx], &lexrange) != C_OK) { + // addReplyError(c, "min or max not valid string range item"); + // return; + // } + // break; + // } + default: + throw new Error('ERR syntax error'); + } + + /* Step 3: Lookup the key and get the range. */ + let sort; + if (direction === DIRECTION_REVERSE) { + sort = ['desc', 'desc'] + } + + let ordered; + if (range === RANGE_SCORE) { + const filteredArray = Array.from(map.values()).filter( + filterPredicate(start, end) + ) + + ordered = orderBy(filteredArray, ['score', 'value'], sort); + } + // FIXME: handle RANGE_LEX + else { + ordered = slice( + orderBy(Array.from(map.values()), ['score', 'value'], sort), + start, + end + ) + } + + if (limit !== null) { + ordered = offsetAndLimit(ordered, offset, limit) + } + + if (withScores) { + return ordered.flatMap(it => [it.value, `${it.score}`]) + } + + return ordered.map(it => it.value); } diff --git a/src/commands/zrange.js b/src/commands/zrange.js index 66b7dbb9f..5f8b55a36 100644 --- a/src/commands/zrange.js +++ b/src/commands/zrange.js @@ -1,36 +1,8 @@ -import orderBy from 'lodash.orderby' - import { convertStringToBuffer } from '../commands-utils/convertStringToBuffer' -import { slice } from './zrange-command.common' - -export function zrange(key, s, e, withScores) { - const map = this.data.get(key) - if (!map) { - return [] - } - - // @TODO investigate a more stable way to detect sorted lists - if (this.data.has(key) && !(this.data.get(key) instanceof Map)) { - return [] - } - - const start = parseInt(s, 10) - const end = parseInt(e, 10) - - const ordered = slice( - orderBy(Array.from(map.values()), ['score', 'value']), - start, - end - ) - - if ( - typeof withScores === 'string' && - withScores.toUpperCase() === 'WITHSCORES' - ) { - return ordered.flatMap(it => [it.value, `${it.score}`]) - } +import { zrangeBaseCommand } from './zrange-command.common'; - return ordered.map(it => it.value) +export function zrange(...args) { + return zrangeBaseCommand.call(this, args); } export function zrangeBuffer(...args) { diff --git a/src/commands/zrangebyscore.js b/src/commands/zrangebyscore.js index a1ec1cb88..01e772a19 100644 --- a/src/commands/zrangebyscore.js +++ b/src/commands/zrangebyscore.js @@ -1,45 +1,10 @@ -import orderBy from 'lodash.orderby' - import { convertStringToBuffer } from '../commands-utils/convertStringToBuffer' import { - filterPredicate, - getWithScoresAndLimit, - offsetAndLimit, - parseLimit, + RANGE_SCORE, zrangeBaseCommand, } from './zrange-command.common' -export function zrangebyscore(key, inputMin, inputMax, ...args) { - const map = this.data.get(key) - if (!map) { - return [] - } - - if (this.data.has(key) && !(this.data.get(key) instanceof Map)) { - return [] - } - - const { withScores, limit, offset } = getWithScoresAndLimit(args) - - const min = parseLimit(inputMin) - const max = parseLimit(inputMax) - const filteredArray = Array.from(map.values()).filter( - filterPredicate(min, max) - ) - - let ordered = orderBy(filteredArray, ['score', 'value']) - if (withScores) { - if (limit !== null) { - ordered = offsetAndLimit(ordered, offset, limit) - } - - return ordered.flatMap(it => [it.value, it.score]) - } - - const results = ordered.map(it => it.value) - if (limit !== null) { - return offsetAndLimit(results, offset, limit) - } - return results +export function zrangebyscore(...args) { + return zrangeBaseCommand.call(this, args, 0, false, RANGE_SCORE); } export function zrangebyscoreBuffer(...args) { diff --git a/src/commands/zrevrange.js b/src/commands/zrevrange.js index 5514b9fc8..8b4aafcd5 100644 --- a/src/commands/zrevrange.js +++ b/src/commands/zrevrange.js @@ -1,37 +1,8 @@ -import orderBy from 'lodash.orderby' - import { convertStringToBuffer } from '../commands-utils/convertStringToBuffer' -import { slice } from './zrange-command.common' - -export function zrevrange(key, s, e, w) { - const map = this.data.get(key) - if (!map) { - return [] - } - - // @TODO investigate a more stable way to detect sorted lists - if (this.data.has(key) && !(this.data.get(key) instanceof Map)) { - return [] - } - - const start = parseInt(s, 10) - const end = parseInt(e, 10) - - let val = orderBy( - Array.from(map.values()), - ['score', 'value'], - ['desc', 'desc'] - ).map(it => { - if (w) { - return [it.value, it.score] - } - - return [it.value] - }) - - val = slice(val, start, end) +import { DIRECTION_REVERSE, zrangeBaseCommand } from './zrange-command.common' - return [].concat(...val) +export function zrevrange(...args) { + return zrangeBaseCommand.call(this, args, 0, false, null, DIRECTION_REVERSE); } export function zrevrangeBuffer(...args) { diff --git a/src/commands/zrevrangebyscore.js b/src/commands/zrevrangebyscore.js index a064f1ef2..5a1b1c51c 100644 --- a/src/commands/zrevrangebyscore.js +++ b/src/commands/zrevrangebyscore.js @@ -1,45 +1,9 @@ -import orderBy from 'lodash.orderby' - import { convertStringToBuffer } from '../commands-utils/convertStringToBuffer' -import { - filterPredicate, - getWithScoresAndLimit, - offsetAndLimit, - parseLimit, -} from './zrange-command.common' - -export function zrevrangebyscore(key, inputMax, inputMin, ...args) { - const map = this.data.get(key) - if (!map) { - return [] - } - - if (this.data.has(key) && !(this.data.get(key) instanceof Map)) { - return [] - } - - const { withScores, limit, offset } = getWithScoresAndLimit(args) - - const min = parseLimit(inputMin) - const max = parseLimit(inputMax) - const filteredArray = Array.from(map.values()).filter( - filterPredicate(min, max) - ) - - let ordered = orderBy(filteredArray, ['score', 'value'], ['desc', 'desc']) - if (withScores) { - if (limit !== null) { - ordered = offsetAndLimit(ordered, offset, limit) - } +import { DIRECTION_REVERSE, RANGE_SCORE, zrangeBaseCommand } from './zrange-command.common'; - return ordered.flatMap(it => [it.value, it.score]) - } - const results = ordered.map(it => it.value) - if (limit !== null) { - return offsetAndLimit(results, offset, limit) - } - return results +export function zrevrangebyscore(...args) { + return zrangeBaseCommand.call(this, args, 0, false, RANGE_SCORE, DIRECTION_REVERSE); } export function zrevrangebyscoreBuffer(...args) { diff --git a/test/integration/commands/zrange.js b/test/integration/commands/zrange.js index bbb299e1c..c6196fb0f 100644 --- a/test/integration/commands/zrange.js +++ b/test/integration/commands/zrange.js @@ -98,4 +98,76 @@ describe('zrange', () => { .zrange('foo', 0, 100) .then(res => expect(res).toEqual(['bbb', 'ccc', 'ddd', 'aaa'])) }) + + it('should handle REV', () => { + const redis = new Redis({ data }) + + return redis.zrange('foo', 0, 2, 'REV').then(res => { + expect(res).toEqual(['fifth', 'fourth', 'third']) + }) + }) + it('should handle REV WITHSCORES', () => { + const redis = new Redis({ data }) + + return redis.zrange('foo', 0, 2, 'REV', 'WITHSCORES').then(res => { + expect(res).toEqual(['fifth', '5', 'fourth', '4', 'third', '3']) + }) + }) + it('should handle BYSCORE', () => { + const redis = new Redis({ data }) + + return redis + .zrange('foo', 2, '(4', 'BYSCORE') + .then(res => expect(res).toEqual(['second', 'third'])) + }) + + it('should handle BYSCORE with LIMIT', () => { + const redis = new Redis({ data }) + + return redis + .zrange('foo', 2, '(4', 'BYSCORE', 'LIMIT', 1, 2) + .then(res => expect(res).toEqual(['third'])) + }) + + it('should handle BYSCORE with LIMIT WITHSCORES', () => { + const redis = new Redis({ data }) + + return redis + .zrange('foo', 2, '(4', 'BYSCORE', 'LIMIT', 1, 2, 'WITHSCORES') + .then(res => expect(res).toEqual(['third', '3'])) + }) + + it('should handle BYSCORE REV', () => { + const redis = new Redis({ data }) + + return redis + .zrange('foo', '3', '1', 'BYSCORE', 'REV') + .then(res => expect(res).toEqual(['third', 'second', 'first'])) + }) + + it('should handle BYSCORE REV WITHSCORES', () => { + const redis = new Redis({ data }) + + return redis + .zrange('foo', '3', '1', 'BYSCORE', 'REV', 'WITHSCORES') + .then(res => + expect(res).toEqual(['third', '3', 'second', '2', 'first', '1']) + ) + }) + + it('should handle BYSCORE REV with LIMIT', () => { + const redis = new Redis({ data }) + + return redis + .zrange('foo', '3', '1', 'BYSCORE', 'REV', 'LIMIT', 0, 1) + .then(res => expect(res).toEqual(['third'])) + }) + + it('should handle BYSCORE REV with LIMIT WITHSCORES', () => { + const redis = new Redis({ data }) + + return redis + .zrange('foo', '3', '1', 'BYSCORE', 'REV', 'LIMIT', 0, 1, 'WITHSCORES') + .then(res => expect(res).toEqual(['third', '3'])) + }) }) diff --git a/test/integration/commands/zrangebyscore.js b/test/integration/commands/zrangebyscore.js index 620b910f4..b19207d24 100644 --- a/test/integration/commands/zrangebyscore.js +++ b/test/integration/commands/zrangebyscore.js @@ -66,7 +66,7 @@ describe('zrangebyscore', () => { const redis = new Redis({ data }) return redis .zrangebyscore('foo', 1, 3, 'WITHSCORES') - .then(res => expect(res).toEqual(['first', 1, 'second', 2, 'third', 3])) + .then(res => expect(res).toEqual(['first', '1', 'second', '2', 'third', '3'])) }) it('should sort items with the same score lexicographically', () => { @@ -84,7 +84,7 @@ describe('zrangebyscore', () => { return redis .zrangebyscore('foo', '-inf', '+inf', 'WITHSCORES') .then(res => - expect(res).toEqual(['bbb', 4, 'ccc', 4, 'ddd', 4, 'aaa', 5]) + expect(res).toEqual(['bbb', '4', 'ccc', '4', 'ddd', '4', 'aaa', '5']) ) }) it('should handle offset and limit (0,1)', () => { @@ -103,13 +103,13 @@ describe('zrangebyscore', () => { const redis = new Redis({ data }) return redis .zrangebyscore('foo', 1, 3, 'WITHSCORES', 'LIMIT', 1, 1) - .then(res => expect(res).toEqual(['second', 2])) + .then(res => expect(res).toEqual(['second', '2'])) }) it('should handle LIMIT specified before WITHSCORES', () => { const redis = new Redis({ data }) return redis .zrangebyscore('foo', 1, 3, 'LIMIT', 1, 1, 'WITHSCORES') - .then(res => expect(res).toEqual(['second', 2])) + .then(res => expect(res).toEqual(['second', '2'])) }) it('should handle LIMIT of -1', () => { const redis = new Redis({ data }) @@ -121,7 +121,7 @@ describe('zrangebyscore', () => { const redis = new Redis({ data }) return redis .zrangebyscore('foo', '-inf', '+inf', 'LIMIT', 1, -1, 'WITHSCORES') - .then(res => expect(res).toEqual(['second', 2, 'third', 3, 'fourth', 4])) + .then(res => expect(res).toEqual(['second', '2', 'third', '3', 'fourth', '4'])) }) it('should handle LIMIT of -2', () => { const redis = new Redis({ data }) diff --git a/test/integration/commands/zrevrangebyscore.js b/test/integration/commands/zrevrangebyscore.js index c4389bbb6..1fa83f840 100644 --- a/test/integration/commands/zrevrangebyscore.js +++ b/test/integration/commands/zrevrangebyscore.js @@ -68,7 +68,7 @@ describe('zrevrangebyscore', () => { const redis = new Redis({ data }) return redis .zrevrangebyscore('foo', 3, 1, 'WITHSCORES') - .then(res => expect(res).toEqual(['third', 3, 'second', 2, 'first', 1])) + .then(res => expect(res).toEqual(['third', '3', 'second', '2', 'first', '1'])) }) it('should sort items with the same score in reverse lexicographical order', () => { @@ -86,7 +86,7 @@ describe('zrevrangebyscore', () => { return redis .zrevrangebyscore('foo', '+inf', '-inf', 'WITHSCORES') .then(res => - expect(res).toEqual(['aaa', 5, 'ddd', 4, 'ccc', 4, 'bbb', 4]) + expect(res).toEqual(['aaa', '5', 'ddd', '4', 'ccc', '4', 'bbb', '4']) ) }) @@ -106,13 +106,13 @@ describe('zrevrangebyscore', () => { const redis = new Redis({ data }) return redis .zrevrangebyscore('foo', 3, 1, 'WITHSCORES', 'LIMIT', 1, 1) - .then(res => expect(res).toEqual(['second', 2])) + .then(res => expect(res).toEqual(['second', '2'])) }) it('should handle LIMIT specified before WITHSCORES', () => { const redis = new Redis({ data }) return redis .zrevrangebyscore('foo', 3, 1, 'LIMIT', 1, 1, 'WITHSCORES') - .then(res => expect(res).toEqual(['second', 2])) + .then(res => expect(res).toEqual(['second', '2'])) }) it('should handle LIMIT of -1', () => { const redis = new Redis({ data }) @@ -124,7 +124,7 @@ describe('zrevrangebyscore', () => { const redis = new Redis({ data }) return redis .zrevrangebyscore('foo', '+inf', '-inf', 'LIMIT', 1, -1, 'WITHSCORES') - .then(res => expect(res).toEqual(['fourth', 4, 'third', 3, 'second', 2])) + .then(res => expect(res).toEqual(['fourth', '4', 'third', '3', 'second', '2'])) }) it('should handle LIMIT of -2', () => { const redis = new Redis({ data })