From 69d507a572a984e233090467fd960a7d6c47bb64 Mon Sep 17 00:00:00 2001 From: "Bobby I." Date: Thu, 27 Feb 2025 10:56:58 +0200 Subject: [PATCH] refactor!: redis 8 compatibility improvements and test infrastructure updates (#2893) * churn(test): use redislabs/client-libs-test for testing This switches our testing infrastructure from redis/redis-stack to redislabs/client-libs-test Docker image across all packages. This change also updates the default Docker version from 7.4.0-v1 to 8.0-M04-pre. * churn(test): verify CONFIG SET / GET compatibility with Redis 8 - Add tests for Redis 8 search configuration settings - Deprecate Redis Search CONFIG commands in favor of standard CONFIG - Test read-only config restrictions for Redis 8 * churn(test): handle Redis 8 coordinate precision in GEOPOS - Update GEOPOS tests to handle increased precision in Redis 8 (17 decimal places vs 14) - Add precision-aware coordinate comparison helper - Add comprehensive test suite for coordinate comparison function * test(search): adapt SUGGET tests for Redis 8 empty results - Update tests to expect empty array ([]) instead of null for SUGGET variants - Affects sugGet, sugGetWithPayloads, sugGetWithScores, and sugGetWithScoresWithPayloads * test(search): support Redis 8 INFO indexes_all field - Add indexes_all field introduced in Redis 8 to index definition test * refactor!(search): simplify PROFILE commands to return raw response - BREAKING CHANGE: FT.PROFILE now returns raw response, letting users implement their own parsing * test: improve version-specific test coverage - Add `testWithClientIfVersionWithinRange` method to run tests for specific Redis versions - Refactor TestUtils to handle version comparisons more accurately - Update test utilities across Redis modules to run tests against multiple versions, and not against latest only --- .github/workflows/tests.yml | 4 +- package.json | 1 + packages/bloom/lib/test-utils.ts | 8 +- .../client/lib/commands/CONFIG_GET.spec.ts | 30 +- .../client/lib/commands/CONFIG_SET.spec.ts | 9 + packages/client/lib/commands/GEOPOS.spec.ts | 124 +++++- packages/client/lib/test-utils.ts | 6 +- packages/entraid/lib/test-utils.ts | 6 +- packages/graph/lib/test-utils.ts | 9 +- packages/json/lib/test-utils.ts | 8 +- .../search/lib/commands/CONFIG_SET.spec.ts | 36 ++ packages/search/lib/commands/INFO.spec.ts | 396 +++++++++++++----- .../lib/commands/PROFILE_AGGREGATE.spec.ts | 137 ++++-- .../search/lib/commands/PROFILE_AGGREGATE.ts | 60 ++- .../lib/commands/PROFILE_SEARCH.spec.ts | 117 ++++-- .../search/lib/commands/PROFILE_SEARCH.ts | 133 +----- packages/search/lib/commands/SUGGET.spec.ts | 16 +- .../lib/commands/SUGGET_WITHPAYLOADS.spec.ts | 21 +- .../lib/commands/SUGGET_WITHSCORES.spec.ts | 9 +- .../SUGGET_WITHSCORES_WITHPAYLOADS.spec.ts | 8 +- packages/search/lib/commands/index.ts | 12 + packages/search/lib/test-utils.ts | 35 +- packages/search/package.json | 3 +- packages/test-utils/lib/dockers.ts | 51 ++- packages/test-utils/lib/index.spec.ts | 106 +++++ packages/test-utils/lib/index.ts | 149 +++++-- packages/test-utils/package.json | 3 + packages/time-series/lib/test-utils.ts | 8 +- 28 files changed, 1083 insertions(+), 422 deletions(-) create mode 100644 packages/test-utils/lib/index.spec.ts diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 9decd26898a..7bcc72e5408 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -21,8 +21,8 @@ jobs: strategy: fail-fast: false matrix: - node-version: ['18', '20', '22'] - redis-version: ['6.2.6-v17', '7.2.0-v13', '7.4.0-v1'] + node-version: [ '18', '20', '22' ] + redis-version: [ 'rs-7.2.0-v13', 'rs-7.4.0-v1', '8.0-M04-pre' ] steps: - uses: actions/checkout@v4 with: diff --git a/package.json b/package.json index 7ab2a557ff2..0a29c71f831 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "./packages/*" ], "scripts": { + "test-single": "TS_NODE_PROJECT='./packages/test-utils/tsconfig.json' mocha --require ts-node/register/transpile-only ", "test": "npm run test -ws --if-present", "build": "tsc --build", "documentation": "typedoc --out ./documentation", diff --git a/packages/bloom/lib/test-utils.ts b/packages/bloom/lib/test-utils.ts index 1291054e802..7996d61e44e 100644 --- a/packages/bloom/lib/test-utils.ts +++ b/packages/bloom/lib/test-utils.ts @@ -1,10 +1,10 @@ import TestUtils from '@redis/test-utils'; import RedisBloomModules from '.'; -export default new TestUtils({ - dockerImageName: 'redis/redis-stack', - dockerImageVersionArgument: 'redisbloom-version', - defaultDockerVersion: '7.4.0-v1' +export default TestUtils.createFromConfig({ + dockerImageName: 'redislabs/client-libs-test', + dockerImageVersionArgument: 'redis-version', + defaultDockerVersion: '8.0-M04-pre' }); export const GLOBAL = { diff --git a/packages/client/lib/commands/CONFIG_GET.spec.ts b/packages/client/lib/commands/CONFIG_GET.spec.ts index 411b2ddf472..c3f0eac76dd 100644 --- a/packages/client/lib/commands/CONFIG_GET.spec.ts +++ b/packages/client/lib/commands/CONFIG_GET.spec.ts @@ -19,7 +19,6 @@ describe('CONFIG GET', () => { ); }); }); - testUtils.testWithClient('client.configGet', async client => { const config = await client.configGet('*'); @@ -29,4 +28,33 @@ describe('CONFIG GET', () => { assert.equal(typeof value, 'string'); } }, GLOBAL.SERVERS.OPEN); + + testUtils.testWithClient('client.configSet.getSearchConfigSettingTest | Redis >= 8', async client => { + assert.ok( + await client.configGet('search-timeout'), + 'OK' + ); + }, GLOBAL.SERVERS.OPEN); + + testUtils.testWithClient('client.configSet.getTSConfigSettingTest | Redis >= 8', async client => { + assert.ok( + await client.configGet('ts-retention-policy'), + 'OK' + ); + }, GLOBAL.SERVERS.OPEN); + + testUtils.testWithClient('client.configSet.getBFConfigSettingTest | Redis >= 8', async client => { + assert.ok( + await client.configGet('bf-error-rate'), + 'OK' + ); + }, GLOBAL.SERVERS.OPEN); + + testUtils.testWithClient('client.configSet.getCFConfigSettingTest | Redis >= 8', async client => { + assert.ok( + await client.configGet('cf-initial-size'), + 'OK' + ); + }, GLOBAL.SERVERS.OPEN); + }); diff --git a/packages/client/lib/commands/CONFIG_SET.spec.ts b/packages/client/lib/commands/CONFIG_SET.spec.ts index 56bed2ac46a..f9f34dec937 100644 --- a/packages/client/lib/commands/CONFIG_SET.spec.ts +++ b/packages/client/lib/commands/CONFIG_SET.spec.ts @@ -30,4 +30,13 @@ describe('CONFIG SET', () => { 'OK' ); }, GLOBAL.SERVERS.OPEN); + + testUtils.testWithClient('client.configSet.setReadOnlySearchConfigTest | Redis >= 8', + async client => { + assert.rejects( + client.configSet('search-max-doctablesize', '0'), + new Error('ERR CONFIG SET failed (possibly related to argument \'search-max-doctablesize\') - can\'t set immutable config') + ); + }, GLOBAL.SERVERS.OPEN); + }); diff --git a/packages/client/lib/commands/GEOPOS.spec.ts b/packages/client/lib/commands/GEOPOS.spec.ts index 247dd91d222..002d16d0256 100644 --- a/packages/client/lib/commands/GEOPOS.spec.ts +++ b/packages/client/lib/commands/GEOPOS.spec.ts @@ -1,4 +1,4 @@ -import { strict as assert } from 'node:assert'; +import { strict as assert, fail } from 'node:assert'; import testUtils, { GLOBAL } from '../test-utils'; import GEOPOS from './GEOPOS'; import { parseArgs } from './generic-transformers'; @@ -41,12 +41,126 @@ describe('GEOPOS', () => { ...coordinates }); - assert.deepEqual( - await client.geoPos('key', 'member'), - [coordinates] - ); + const result = await client.geoPos('key', 'member'); + + /** + * - Redis < 8: Returns coordinates with 14 decimal places (e.g., "-122.06429868936539") + * - Redis 8+: Returns coordinates with 17 decimal places (e.g., "-122.06429868936538696") + * + */ + const PRECISION = 13; // Number of decimal places to compare + + if (result && result.length === 1 && result[0] != null) { + const { longitude, latitude } = result[0]; + + assert.ok( + compareWithPrecision(longitude, coordinates.longitude, PRECISION), + `Longitude mismatch: ${longitude} vs ${coordinates.longitude}` + ); + assert.ok( + compareWithPrecision(latitude, coordinates.latitude, PRECISION), + `Latitude mismatch: ${latitude} vs ${coordinates.latitude}` + ); + + } else { + assert.fail('Expected a valid result'); + } + + + }, { client: GLOBAL.SERVERS.OPEN, cluster: GLOBAL.CLUSTERS.OPEN }); }); + +describe('compareWithPrecision', () => { + it('should match exact same numbers', () => { + assert.strictEqual( + compareWithPrecision('123.456789', '123.456789', 6), + true + ); + }); + + it('should match when actual has more precision than needed', () => { + assert.strictEqual( + compareWithPrecision('123.456789123456', '123.456789', 6), + true + ); + }); + + it('should match when expected has more precision than needed', () => { + assert.strictEqual( + compareWithPrecision('123.456789', '123.456789123456', 6), + true + ); + }); + + it('should fail when decimals differ within precision', () => { + assert.strictEqual( + compareWithPrecision('123.456689', '123.456789', 6), + false + ); + }); + + it('should handle negative numbers', () => { + assert.strictEqual( + compareWithPrecision('-122.06429868936538', '-122.06429868936539', 13), + true + ); + }); + + it('should fail when integer parts differ', () => { + assert.strictEqual( + compareWithPrecision('124.456789', '123.456789', 6), + false + ); + }); + + it('should handle zero decimal places', () => { + assert.strictEqual( + compareWithPrecision('123.456789', '123.456789', 0), + true + ); + }); + + it('should handle numbers without decimal points', () => { + assert.strictEqual( + compareWithPrecision('123', '123', 6), + true + ); + }); + + it('should handle one number without decimal point', () => { + assert.strictEqual( + compareWithPrecision('123', '123.000', 3), + true + ); + }); + + it('should match Redis coordinates with different precision', () => { + assert.strictEqual( + compareWithPrecision( + '-122.06429868936538696', + '-122.06429868936539', + 13 + ), + true + ); + }); + + it('should match Redis latitude with different precision', () => { + assert.strictEqual( + compareWithPrecision( + '37.37749628831998194', + '37.37749628831998', + 14 + ), + true + ); + }); +}); + +export const compareWithPrecision = (actual: string, expected: string, decimals: number): boolean => { + return Math.abs(Number(actual) - Number(expected)) < Math.pow(10, -decimals); +}; diff --git a/packages/client/lib/test-utils.ts b/packages/client/lib/test-utils.ts index 2d561dd2e20..dce1f97d88a 100644 --- a/packages/client/lib/test-utils.ts +++ b/packages/client/lib/test-utils.ts @@ -5,10 +5,10 @@ import { CredentialsProvider } from './authx'; import { Command } from './RESP/types'; import { BasicCommandParser } from './client/parser'; -const utils = new TestUtils({ - dockerImageName: 'redis/redis-stack', +const utils = TestUtils.createFromConfig({ + dockerImageName: 'redislabs/client-libs-test', dockerImageVersionArgument: 'redis-version', - defaultDockerVersion: '7.4.0-v1' + defaultDockerVersion: '8.0-M04-pre' }); export default utils; diff --git a/packages/entraid/lib/test-utils.ts b/packages/entraid/lib/test-utils.ts index eecec2d4d6d..970637a1225 100644 --- a/packages/entraid/lib/test-utils.ts +++ b/packages/entraid/lib/test-utils.ts @@ -3,10 +3,10 @@ import { IdentityProvider, StreamingCredentialsProvider, TokenManager, TokenResp import TestUtils from '@redis/test-utils'; import { EntraidCredentialsProvider } from './entraid-credentials-provider'; -export const testUtils = new TestUtils({ - dockerImageName: 'redis/redis-stack', +export const testUtils = TestUtils.createFromConfig({ + dockerImageName: 'redislabs/client-libs-test', dockerImageVersionArgument: 'redis-version', - defaultDockerVersion: '7.4.0-v1' + defaultDockerVersion: '8.0-M04-pre' }); const DEBUG_MODE_ARGS = testUtils.isVersionGreaterThan([7]) ? diff --git a/packages/graph/lib/test-utils.ts b/packages/graph/lib/test-utils.ts index 2aa9384dbe6..16c44582061 100644 --- a/packages/graph/lib/test-utils.ts +++ b/packages/graph/lib/test-utils.ts @@ -1,10 +1,11 @@ import TestUtils from '@redis/test-utils'; import RedisGraph from '.'; -export default new TestUtils({ - dockerImageName: 'redis/redis-stack', - dockerImageVersionArgument: 'redisgraph-version', - defaultDockerVersion: '7.4.0-v1' + +export default TestUtils.createFromConfig({ + dockerImageName: 'redislabs/client-libs-test', + dockerImageVersionArgument: 'redis-version', + defaultDockerVersion: '8.0-M04-pre' }); export const GLOBAL = { diff --git a/packages/json/lib/test-utils.ts b/packages/json/lib/test-utils.ts index 0ac30c521b2..caa1c3049af 100644 --- a/packages/json/lib/test-utils.ts +++ b/packages/json/lib/test-utils.ts @@ -1,10 +1,10 @@ import TestUtils from '@redis/test-utils'; import RedisJSON from '.'; -export default new TestUtils({ - dockerImageName: 'redis/redis-stack', - dockerImageVersionArgument: 'redisgraph-version', - defaultDockerVersion: '7.4.0-v1' +export default TestUtils.createFromConfig({ + dockerImageName: 'redislabs/client-libs-test', + dockerImageVersionArgument: 'redis-version', + defaultDockerVersion: '8.0-M04-pre' }); export const GLOBAL = { diff --git a/packages/search/lib/commands/CONFIG_SET.spec.ts b/packages/search/lib/commands/CONFIG_SET.spec.ts index 71a4e69f26f..c5922a28756 100644 --- a/packages/search/lib/commands/CONFIG_SET.spec.ts +++ b/packages/search/lib/commands/CONFIG_SET.spec.ts @@ -17,4 +17,40 @@ describe('FT.CONFIG SET', () => { 'OK' ); }, GLOBAL.SERVERS.OPEN); + + testUtils.testWithClientIfVersionWithinRange([[8], 'LATEST'], 'setSearchConfigGloballyTest', async client => { + + const normalizeObject = obj => JSON.parse(JSON.stringify(obj)); + assert.equal(await client.configSet('search-default-dialect', '3'), + 'OK', 'CONFIG SET should return OK'); + + assert.deepEqual( + normalizeObject(await client.configGet('search-default-dialect')), + { 'search-default-dialect': '3' }, + 'CONFIG GET should return 3' + ); + + assert.deepEqual( + normalizeObject(await client.ft.configGet('DEFAULT_DIALECT')), + { 'DEFAULT_DIALECT': '3' }, + 'FT.CONFIG GET should return 3' + ); + + const ftConfigSetResult = await client.ft.configSet('DEFAULT_DIALECT', '2'); + assert.equal(normalizeObject(ftConfigSetResult), 'OK', 'FT.CONFIG SET should return OK'); + + assert.deepEqual( + normalizeObject(await client.ft.configGet('DEFAULT_DIALECT')), + { 'DEFAULT_DIALECT': '2' }, + 'FT.CONFIG GET should return 2' + ); + + assert.deepEqual( + normalizeObject(await client.configGet('search-default-dialect')), + { 'search-default-dialect': '2' }, + 'CONFIG GET should return 22' + ); + + }, GLOBAL.SERVERS.OPEN); + }); diff --git a/packages/search/lib/commands/INFO.spec.ts b/packages/search/lib/commands/INFO.spec.ts index cbb4ea91677..caf311e07e9 100644 --- a/packages/search/lib/commands/INFO.spec.ts +++ b/packages/search/lib/commands/INFO.spec.ts @@ -5,105 +5,305 @@ import { SCHEMA_FIELD_TYPE } from './CREATE'; import { parseArgs } from '@redis/client/lib/commands/generic-transformers'; describe('INFO', () => { - it('transformArguments', () => { - assert.deepEqual( - parseArgs(INFO, 'index'), - ['FT.INFO', 'index'] - ); + it('transformArguments', () => { + assert.deepEqual( + parseArgs(INFO, 'index'), + ['FT.INFO', 'index'] + ); + }); + + testUtils.testWithClientIfVersionWithinRange([[8], 'LATEST'], 'client.ft.info', async client => { + + await client.ft.create('index', { + field: SCHEMA_FIELD_TYPE.TEXT }); + const ret = await client.ft.info('index'); + // effectively testing that stopwords_list is not in ret + assert.deepEqual( + ret, + { + index_name: 'index', + index_options: [], + index_definition: Object.create(null, { + + indexes_all: { + value: 'false', + configurable: true, + enumerable: true + }, + + default_score: { + value: '1', + configurable: true, + enumerable: true + }, + key_type: { + value: 'HASH', + configurable: true, + enumerable: true + }, + prefixes: { + value: [''], + configurable: true, + enumerable: true + } + }), + attributes: [Object.create(null, { + identifier: { + value: 'field', + configurable: true, + enumerable: true + }, + attribute: { + value: 'field', + configurable: true, + enumerable: true + }, + type: { + value: 'TEXT', + configurable: true, + enumerable: true + }, + WEIGHT: { + value: '1', + configurable: true, + enumerable: true + } + })], + num_docs: 0, + max_doc_id: 0, + num_terms: 0, + num_records: 0, + inverted_sz_mb: 0, + vector_index_sz_mb: 0, + total_inverted_index_blocks: 0, + offset_vectors_sz_mb: 0, + doc_table_size_mb: 0, + sortable_values_size_mb: 0, + key_table_size_mb: 0, + records_per_doc_avg: NaN, + bytes_per_record_avg: NaN, + cleaning: 0, + offsets_per_term_avg: NaN, + offset_bits_per_record_avg: NaN, + geoshapes_sz_mb: 0, + hash_indexing_failures: 0, + indexing: 0, + percent_indexed: 1, + number_of_uses: 1, + tag_overhead_sz_mb: 0, + text_overhead_sz_mb: 0, + total_index_memory_sz_mb: 0, + total_indexing_time: 0, + gc_stats: { + bytes_collected: 0, + total_ms_run: 0, + total_cycles: 0, + average_cycle_time_ms: NaN, + last_run_time_ms: 0, + gc_numeric_trees_missed: 0, + gc_blocks_denied: 0 + }, + cursor_stats: { + global_idle: 0, + global_total: 0, + index_capacity: 128, + index_total: 0 + }, + } + ); + + }, GLOBAL.SERVERS.OPEN); + + testUtils.testWithClientIfVersionWithinRange([[7, 4, 2], [7, 4, 2]], 'client.ft.info', async client => { - testUtils.testWithClient('client.ft.info', async client => { - await client.ft.create('index', { - field: SCHEMA_FIELD_TYPE.TEXT - }); - const ret = await client.ft.info('index'); - // effectively testing that stopwords_list is not in ret - assert.deepEqual( - ret, - { - index_name: 'index', - index_options: [], - index_definition: Object.create(null, { - default_score: { - value: '1', - configurable: true, - enumerable: true - }, - key_type: { - value: 'HASH', - configurable: true, - enumerable: true - }, - prefixes: { - value: [''], - configurable: true, - enumerable: true - } - }), - attributes: [Object.create(null, { - identifier: { - value: 'field', - configurable: true, - enumerable: true - }, - attribute: { - value: 'field', - configurable: true, - enumerable: true - }, - type: { - value: 'TEXT', - configurable: true, - enumerable: true - }, - WEIGHT: { - value: '1', - configurable: true, - enumerable: true - } - })], - num_docs: 0, - max_doc_id: 0, - num_terms: 0, - num_records: 0, - inverted_sz_mb: 0, - vector_index_sz_mb: 0, - total_inverted_index_blocks: 0, - offset_vectors_sz_mb: 0, - doc_table_size_mb: 0, - sortable_values_size_mb: 0, - key_table_size_mb: 0, - records_per_doc_avg: NaN, - bytes_per_record_avg: NaN, - cleaning: 0, - offsets_per_term_avg: NaN, - offset_bits_per_record_avg: NaN, - geoshapes_sz_mb: 0, - hash_indexing_failures: 0, - indexing: 0, - percent_indexed: 1, - number_of_uses: 1, - tag_overhead_sz_mb: 0, - text_overhead_sz_mb: 0, - total_index_memory_sz_mb: 0, - total_indexing_time: 0, - gc_stats: { - bytes_collected: 0, - total_ms_run: 0, - total_cycles: 0, - average_cycle_time_ms: NaN, - last_run_time_ms: 0, - gc_numeric_trees_missed: 0, - gc_blocks_denied: 0 - }, - cursor_stats: { - global_idle: 0, - global_total: 0, - index_capacity: 128, - index_total: 0 - }, - } - ); + await client.ft.create('index', { + field: SCHEMA_FIELD_TYPE.TEXT + }); + const ret = await client.ft.info('index'); + // effectively testing that stopwords_list is not in ret + assert.deepEqual( + ret, + { + index_name: 'index', + index_options: [], + index_definition: Object.create(null, { + default_score: { + value: '1', + configurable: true, + enumerable: true + }, + key_type: { + value: 'HASH', + configurable: true, + enumerable: true + }, + prefixes: { + value: [''], + configurable: true, + enumerable: true + } + }), + attributes: [Object.create(null, { + identifier: { + value: 'field', + configurable: true, + enumerable: true + }, + attribute: { + value: 'field', + configurable: true, + enumerable: true + }, + type: { + value: 'TEXT', + configurable: true, + enumerable: true + }, + WEIGHT: { + value: '1', + configurable: true, + enumerable: true + } + })], + num_docs: 0, + max_doc_id: 0, + num_terms: 0, + num_records: 0, + inverted_sz_mb: 0, + vector_index_sz_mb: 0, + total_inverted_index_blocks: 0, + offset_vectors_sz_mb: 0, + doc_table_size_mb: 0, + sortable_values_size_mb: 0, + key_table_size_mb: 0, + records_per_doc_avg: NaN, + bytes_per_record_avg: NaN, + cleaning: 0, + offsets_per_term_avg: NaN, + offset_bits_per_record_avg: NaN, + geoshapes_sz_mb: 0, + hash_indexing_failures: 0, + indexing: 0, + percent_indexed: 1, + number_of_uses: 1, + tag_overhead_sz_mb: 0, + text_overhead_sz_mb: 0, + total_index_memory_sz_mb: 0, + total_indexing_time: 0, + gc_stats: { + bytes_collected: 0, + total_ms_run: 0, + total_cycles: 0, + average_cycle_time_ms: NaN, + last_run_time_ms: 0, + gc_numeric_trees_missed: 0, + gc_blocks_denied: 0 + }, + cursor_stats: { + global_idle: 0, + global_total: 0, + index_capacity: 128, + index_total: 0 + }, + } + ); + + }, GLOBAL.SERVERS.OPEN); + + testUtils.testWithClientIfVersionWithinRange([[7, 2, 0], [7, 2, 0]], 'client.ft.info', async client => { + + await client.ft.create('index', { + field: SCHEMA_FIELD_TYPE.TEXT + }); + const ret = await client.ft.info('index'); + // effectively testing that stopwords_list is not in ret + assert.deepEqual( + ret, + { + index_name: 'index', + index_options: [], + index_definition: Object.create(null, { + default_score: { + value: '1', + configurable: true, + enumerable: true + }, + key_type: { + value: 'HASH', + configurable: true, + enumerable: true + }, + prefixes: { + value: [''], + configurable: true, + enumerable: true + } + }), + attributes: [Object.create(null, { + identifier: { + value: 'field', + configurable: true, + enumerable: true + }, + attribute: { + value: 'field', + configurable: true, + enumerable: true + }, + type: { + value: 'TEXT', + configurable: true, + enumerable: true + }, + WEIGHT: { + value: '1', + configurable: true, + enumerable: true + } + })], + num_docs: "0", + max_doc_id: "0", + num_terms: "0", + num_records: "0", + inverted_sz_mb: 0, + vector_index_sz_mb: 0, + total_inverted_index_blocks: "0", + offset_vectors_sz_mb: 0, + doc_table_size_mb: 0, + sortable_values_size_mb: 0, + key_table_size_mb: 0, + records_per_doc_avg: NaN, + bytes_per_record_avg: NaN, + cleaning: 0, + offsets_per_term_avg: NaN, + offset_bits_per_record_avg: NaN, + geoshapes_sz_mb: 0, + hash_indexing_failures: "0", + indexing: "0", + percent_indexed: 1, + number_of_uses: 1, + tag_overhead_sz_mb: 0, + text_overhead_sz_mb: 0, + total_index_memory_sz_mb: 0, + total_indexing_time: 0, + gc_stats: { + bytes_collected: 0, + total_ms_run: 0, + total_cycles: 0, + average_cycle_time_ms: NaN, + last_run_time_ms: 0, + gc_numeric_trees_missed: 0, + gc_blocks_denied: 0 + }, + cursor_stats: { + global_idle: 0, + global_total: 0, + index_capacity: 128, + index_total: 0 + }, + } + ); - }, GLOBAL.SERVERS.OPEN); + }, GLOBAL.SERVERS.OPEN); }); diff --git a/packages/search/lib/commands/PROFILE_AGGREGATE.spec.ts b/packages/search/lib/commands/PROFILE_AGGREGATE.spec.ts index 5cfa0500a0a..bdf452c16ea 100644 --- a/packages/search/lib/commands/PROFILE_AGGREGATE.spec.ts +++ b/packages/search/lib/commands/PROFILE_AGGREGATE.spec.ts @@ -7,43 +7,106 @@ import { parseArgs } from '@redis/client/lib/commands/generic-transformers'; import { DEFAULT_DIALECT } from '../dialect/default'; describe('PROFILE AGGREGATE', () => { - describe('transformArguments', () => { - it('without options', () => { - assert.deepEqual( - parseArgs(PROFILE_AGGREGATE, 'index', 'query'), - ['FT.PROFILE', 'index', 'AGGREGATE', 'QUERY', 'query', 'DIALECT', DEFAULT_DIALECT] - ); - }); - - it('with options', () => { - assert.deepEqual( - parseArgs(PROFILE_AGGREGATE, 'index', 'query', { - LIMITED: true, - VERBATIM: true, - STEPS: [{ - type: FT_AGGREGATE_STEPS.SORTBY, - BY: '@by' - }] - }), - ['FT.PROFILE', 'index', 'AGGREGATE', 'LIMITED', 'QUERY', 'query', - 'VERBATIM', 'SORTBY', '1', '@by', 'DIALECT', DEFAULT_DIALECT] - ); - }); + describe('transformArguments', () => { + it('without options', () => { + assert.deepEqual( + parseArgs(PROFILE_AGGREGATE, 'index', 'query'), + ['FT.PROFILE', 'index', 'AGGREGATE', 'QUERY', 'query', 'DIALECT', DEFAULT_DIALECT] + ); }); - testUtils.testWithClient('client.ft.search', async client => { - await Promise.all([ - client.ft.create('index', { - field: SCHEMA_FIELD_TYPE.NUMERIC - }), - client.hSet('1', 'field', '1'), - client.hSet('2', 'field', '2') - ]); - - const res = await client.ft.profileAggregate('index', '*'); - assert.deepEqual('None', res.profile.warning); - assert.ok(typeof res.profile.iteratorsProfile.counter === 'number'); - assert.ok(typeof res.profile.parsingTime === 'string'); - assert.ok(res.results.total == 1); - }, GLOBAL.SERVERS.OPEN); + it('with options', () => { + assert.deepEqual( + parseArgs(PROFILE_AGGREGATE, 'index', 'query', { + LIMITED: true, + VERBATIM: true, + STEPS: [{ + type: FT_AGGREGATE_STEPS.SORTBY, + BY: '@by' + }] + }), + ['FT.PROFILE', 'index', 'AGGREGATE', 'LIMITED', 'QUERY', 'query', + 'VERBATIM', 'SORTBY', '1', '@by', 'DIALECT', DEFAULT_DIALECT] + ); + }); + }); + + testUtils.testWithClientIfVersionWithinRange([[8], 'LATEST'], 'client.ft.search', async client => { + await Promise.all([ + client.ft.create('index', { + field: SCHEMA_FIELD_TYPE.NUMERIC + }), + client.hSet('1', 'field', '1'), + client.hSet('2', 'field', '2') + ]); + + + const normalizeObject = obj => JSON.parse(JSON.stringify(obj)); + const res = await client.ft.profileAggregate('index', '*'); + + const normalizedRes = normalizeObject(res); + assert.equal(normalizedRes.results.total, 1); + + assert.ok(normalizedRes.profile[0] === 'Shards'); + assert.ok(Array.isArray(normalizedRes.profile[1])); + assert.ok(normalizedRes.profile[2] === 'Coordinator'); + assert.ok(Array.isArray(normalizedRes.profile[3])); + + const shardProfile = normalizedRes.profile[1][0]; + assert.ok(shardProfile.includes('Total profile time')); + assert.ok(shardProfile.includes('Parsing time')); + assert.ok(shardProfile.includes('Pipeline creation time')); + assert.ok(shardProfile.includes('Warning')); + assert.ok(shardProfile.includes('Iterators profile')); + + }, GLOBAL.SERVERS.OPEN); + + testUtils.testWithClientIfVersionWithinRange([[7, 2, 0], [7, 4, 0]], 'client.ft.search', async client => { + await Promise.all([ + client.ft.create('index', { + field: SCHEMA_FIELD_TYPE.NUMERIC + }), + client.hSet('1', 'field', '1'), + client.hSet('2', 'field', '2') + ]); + + const normalizeObject = obj => JSON.parse(JSON.stringify(obj)); + const res = await client.ft.profileAggregate('index', '*'); + const normalizedRes = normalizeObject(res); + assert.equal(normalizedRes.results.total, 1); + + assert.ok(Array.isArray(normalizedRes.profile)); + assert.equal(normalizedRes.profile[0][0], 'Total profile time'); + assert.equal(normalizedRes.profile[1][0], 'Parsing time'); + assert.equal(normalizedRes.profile[2][0], 'Pipeline creation time'); + assert.equal(normalizedRes.profile[3][0], 'Warning'); + assert.equal(normalizedRes.profile[4][0], 'Iterators profile'); + assert.equal(normalizedRes.profile[5][0], 'Result processors profile'); + + const iteratorsProfile = normalizedRes.profile[4][1]; + assert.equal(iteratorsProfile[0], 'Type'); + assert.equal(iteratorsProfile[1], 'WILDCARD'); + assert.equal(iteratorsProfile[2], 'Time'); + assert.equal(iteratorsProfile[4], 'Counter'); + }, GLOBAL.SERVERS.OPEN); + + testUtils.testWithClientIfVersionWithinRange([[8], 'LATEST'], '[RESP3] client.ft.search', async client => { + await Promise.all([ + client.ft.create('index', { + field: SCHEMA_FIELD_TYPE.NUMERIC + }), + client.hSet('1', 'field', '1'), + client.hSet('2', 'field', '2') + ]); + + + const normalizeObject = obj => JSON.parse(JSON.stringify(obj)); + const res = await client.ft.profileAggregate('index', '*'); + + const normalizedRes = normalizeObject(res); + assert.equal(normalizedRes.Results.total_results, 1); + assert.ok(normalizedRes.Profile.Shards); + + }, GLOBAL.SERVERS.OPEN_3) + }); diff --git a/packages/search/lib/commands/PROFILE_AGGREGATE.ts b/packages/search/lib/commands/PROFILE_AGGREGATE.ts index ad671c9eb45..94bb6984afa 100644 --- a/packages/search/lib/commands/PROFILE_AGGREGATE.ts +++ b/packages/search/lib/commands/PROFILE_AGGREGATE.ts @@ -1,37 +1,35 @@ import { CommandParser } from '@redis/client/dist/lib/client/parser'; -import { Command, ReplyUnion } from "@redis/client/dist/lib/RESP/types"; -import AGGREGATE, { AggregateRawReply, FtAggregateOptions, parseAggregateOptions } from "./AGGREGATE"; -import { ProfileOptions, ProfileRawReply, ProfileReply, transformProfile } from "./PROFILE_SEARCH"; +import { Command, ReplyUnion, UnwrapReply } from '@redis/client/dist/lib/RESP/types'; +import AGGREGATE, { AggregateRawReply, FtAggregateOptions, parseAggregateOptions } from './AGGREGATE'; +import { ProfileOptions, ProfileRawReplyResp2, ProfileReplyResp2, } from './PROFILE_SEARCH'; export default { NOT_KEYED_COMMAND: true, - IS_READ_ONLY: true, - parseCommand( - parser: CommandParser, - index: string, - query: string, - options?: ProfileOptions & FtAggregateOptions - ) { - parser.push('FT.PROFILE', index, 'AGGREGATE'); - - if (options?.LIMITED) { - parser.push('LIMITED'); - } - - parser.push('QUERY', query); + IS_READ_ONLY: true, + parseCommand( + parser: CommandParser, + index: string, + query: string, + options?: ProfileOptions & FtAggregateOptions + ) { + parser.push('FT.PROFILE', index, 'AGGREGATE'); - parseAggregateOptions(parser, options) - }, - transformReply: { - 2: (reply: ProfileAggeregateRawReply): ProfileReply => { - return { - results: AGGREGATE.transformReply[2](reply[0]), - profile: transformProfile(reply[1]) - } - }, - 3: undefined as unknown as () => ReplyUnion - }, - unstableResp3: true - } as const satisfies Command; + if (options?.LIMITED) { + parser.push('LIMITED'); + } - type ProfileAggeregateRawReply = ProfileRawReply; \ No newline at end of file + parser.push('QUERY', query); + + parseAggregateOptions(parser, options) + }, + transformReply: { + 2: (reply: UnwrapReply>): ProfileReplyResp2 => { + return { + results: AGGREGATE.transformReply[2](reply[0]), + profile: reply[1] + } + }, + 3: (reply: ReplyUnion): ReplyUnion => reply + }, + unstableResp3: true +} as const satisfies Command; diff --git a/packages/search/lib/commands/PROFILE_SEARCH.spec.ts b/packages/search/lib/commands/PROFILE_SEARCH.spec.ts index 60f1e8b7474..419b879d00a 100644 --- a/packages/search/lib/commands/PROFILE_SEARCH.spec.ts +++ b/packages/search/lib/commands/PROFILE_SEARCH.spec.ts @@ -6,39 +6,90 @@ import { parseArgs } from '@redis/client/lib/commands/generic-transformers'; import { DEFAULT_DIALECT } from '../dialect/default'; describe('PROFILE SEARCH', () => { - describe('transformArguments', () => { - it('without options', () => { - assert.deepEqual( - parseArgs(PROFILE_SEARCH, 'index', 'query'), - ['FT.PROFILE', 'index', 'SEARCH', 'QUERY', 'query', 'DIALECT', DEFAULT_DIALECT] - ); - }); - - it('with options', () => { - assert.deepEqual( - parseArgs(PROFILE_SEARCH, 'index', 'query', { - LIMITED: true, - VERBATIM: true, - INKEYS: 'key' - }), - ['FT.PROFILE', 'index', 'SEARCH', 'LIMITED', 'QUERY', 'query', - 'VERBATIM', 'INKEYS', '1', 'key', 'DIALECT', DEFAULT_DIALECT] - ); - }); + describe('transformArguments', () => { + it('without options', () => { + assert.deepEqual( + parseArgs(PROFILE_SEARCH, 'index', 'query'), + ['FT.PROFILE', 'index', 'SEARCH', 'QUERY', 'query', 'DIALECT', DEFAULT_DIALECT] + ); }); - testUtils.testWithClient('client.ft.search', async client => { - await Promise.all([ - client.ft.create('index', { - field: SCHEMA_FIELD_TYPE.NUMERIC - }), - client.hSet('1', 'field', '1') - ]); - - const res = await client.ft.profileSearch('index', '*'); - assert.strictEqual('None', res.profile.warning); - assert.ok(typeof res.profile.iteratorsProfile.counter === 'number'); - assert.ok(typeof res.profile.parsingTime === 'string'); - assert.ok(res.results.total == 1); - }, GLOBAL.SERVERS.OPEN); + it('with options', () => { + assert.deepEqual( + parseArgs(PROFILE_SEARCH, 'index', 'query', { + LIMITED: true, + VERBATIM: true, + INKEYS: 'key' + }), + ['FT.PROFILE', 'index', 'SEARCH', 'LIMITED', 'QUERY', 'query', + 'VERBATIM', 'INKEYS', '1', 'key', 'DIALECT', DEFAULT_DIALECT] + ); + }); + }); + + testUtils.testWithClientIfVersionWithinRange([[8], 'LATEST'], 'client.ft.search', async client => { + await Promise.all([ + client.ft.create('index', { + field: SCHEMA_FIELD_TYPE.NUMERIC + }), + client.hSet('1', 'field', '1') + ]); + + const normalizeObject = obj => JSON.parse(JSON.stringify(obj)); + + const res = await client.ft.profileSearch('index', '*'); + + const normalizedRes = normalizeObject(res); + assert.equal(normalizedRes.results.total, 1); + + assert.ok(normalizedRes.profile[0] === 'Shards'); + assert.ok(Array.isArray(normalizedRes.profile[1])); + assert.ok(normalizedRes.profile[2] === 'Coordinator'); + assert.ok(Array.isArray(normalizedRes.profile[3])); + + const shardProfile = normalizedRes.profile[1][0]; + assert.ok(shardProfile.includes('Total profile time')); + assert.ok(shardProfile.includes('Parsing time')); + assert.ok(shardProfile.includes('Pipeline creation time')); + assert.ok(shardProfile.includes('Warning')); + assert.ok(shardProfile.includes('Iterators profile')); + ; + + }, GLOBAL.SERVERS.OPEN); + + + + + + testUtils.testWithClientIfVersionWithinRange([[7, 2, 0], [7, 4, 0]], 'client.ft.search', async client => { + await Promise.all([ + client.ft.create('index', { + field: SCHEMA_FIELD_TYPE.NUMERIC + }), + client.hSet('1', 'field', '1') + ]); + + const normalizeObject = obj => JSON.parse(JSON.stringify(obj)); + + const res = await client.ft.profileSearch('index', '*'); + + const normalizedRes = normalizeObject(res); + assert.equal(normalizedRes.results.total, 1); + + assert.ok(Array.isArray(normalizedRes.profile)); + assert.equal(normalizedRes.profile[0][0], 'Total profile time'); + assert.equal(normalizedRes.profile[1][0], 'Parsing time'); + assert.equal(normalizedRes.profile[2][0], 'Pipeline creation time'); + assert.equal(normalizedRes.profile[3][0], 'Warning'); + assert.equal(normalizedRes.profile[4][0], 'Iterators profile'); + assert.equal(normalizedRes.profile[5][0], 'Result processors profile'); + + const iteratorsProfile = normalizedRes.profile[4][1]; + assert.equal(iteratorsProfile[0], 'Type'); + assert.equal(iteratorsProfile[1], 'WILDCARD'); + assert.equal(iteratorsProfile[2], 'Time'); + assert.equal(iteratorsProfile[4], 'Counter'); + + }, GLOBAL.SERVERS.OPEN); + }); diff --git a/packages/search/lib/commands/PROFILE_SEARCH.ts b/packages/search/lib/commands/PROFILE_SEARCH.ts index 86ce85ba7f1..b13dbebe996 100644 --- a/packages/search/lib/commands/PROFILE_SEARCH.ts +++ b/packages/search/lib/commands/PROFILE_SEARCH.ts @@ -1,23 +1,19 @@ import { CommandParser } from '@redis/client/dist/lib/client/parser'; -import { Command, RedisArgument, ReplyUnion } from "@redis/client/dist/lib/RESP/types"; -import { AggregateReply } from "./AGGREGATE"; -import SEARCH, { FtSearchOptions, SearchRawReply, SearchReply, parseSearchOptions } from "./SEARCH"; +import { ArrayReply, Command, RedisArgument, ReplyUnion, TuplesReply, UnwrapReply } from '@redis/client/dist/lib/RESP/types'; +import { AggregateReply } from './AGGREGATE'; +import SEARCH, { FtSearchOptions, SearchRawReply, SearchReply, parseSearchOptions } from './SEARCH'; -export type ProfileRawReply = [ - results: T, - profile: [ - _: string, - TotalProfileTime: string, - _: string, - ParsingTime: string, - _: string, - PipelineCreationTime: string, - _: string, - IteratorsProfile: Array - ] -]; +export type ProfileRawReplyResp2 = TuplesReply<[ + T, + ArrayReply +]>; -type ProfileSearchRawReply = ProfileRawReply; +type ProfileSearchResponseResp2 = ProfileRawReplyResp2; + +export interface ProfileReplyResp2 { + results: SearchReply | AggregateReply; + profile: ReplyUnion; +} export interface ProfileOptions { LIMITED?: true; @@ -43,108 +39,13 @@ export default { parseSearchOptions(parser, options); }, transformReply: { - 2: (reply: ProfileSearchRawReply, withoutDocuments: boolean): ProfileReply => { + 2: (reply: UnwrapReply): ProfileReplyResp2 => { return { results: SEARCH.transformReply[2](reply[0]), - profile: transformProfile(reply[1]) - } + profile: reply[1] + }; }, - 3: undefined as unknown as () => ReplyUnion + 3: (reply: ReplyUnion): ReplyUnion => reply }, unstableResp3: true } as const satisfies Command; - -export interface ProfileReply { - results: SearchReply | AggregateReply; - profile: ProfileData; -} - -interface ChildIterator { - type?: string, - counter?: number, - term?: string, - size?: number, - time?: string, - childIterators?: Array -} - -interface IteratorsProfile { - type?: string, - counter?: number, - queryType?: string, - time?: string, - childIterators?: Array -} - -interface ProfileData { - totalProfileTime: string, - parsingTime: string, - pipelineCreationTime: string, - warning: string, - iteratorsProfile: IteratorsProfile -} - -export function transformProfile(reply: Array): ProfileData{ - return { - totalProfileTime: reply[0][1], - parsingTime: reply[1][1], - pipelineCreationTime: reply[2][1], - warning: reply[3][1] ? reply[3][1] : 'None', - iteratorsProfile: transformIterators(reply[4][1]) - }; -} - -function transformIterators(IteratorsProfile: Array): IteratorsProfile { - var res: IteratorsProfile = {}; - for (let i = 0; i < IteratorsProfile.length; i += 2) { - const value = IteratorsProfile[i+1]; - switch (IteratorsProfile[i]) { - case 'Type': - res.type = value; - break; - case 'Counter': - res.counter = value; - break; - case 'Time': - res.time = value; - break; - case 'Query type': - res.queryType = value; - break; - case 'Child iterators': - res.childIterators = value.map(transformChildIterators); - break; - } - } - - return res; -} - -function transformChildIterators(IteratorsProfile: Array): ChildIterator { - var res: ChildIterator = {}; - for (let i = 1; i < IteratorsProfile.length; i += 2) { - const value = IteratorsProfile[i+1]; - switch (IteratorsProfile[i]) { - case 'Type': - res.type = value; - break; - case 'Counter': - res.counter = value; - break; - case 'Time': - res.time = value; - break; - case 'Size': - res.size = value; - break; - case 'Term': - res.term = value; - break; - case 'Child iterators': - res.childIterators = value.map(transformChildIterators); - break; - } - } - - return res; -} \ No newline at end of file diff --git a/packages/search/lib/commands/SUGGET.spec.ts b/packages/search/lib/commands/SUGGET.spec.ts index e30c62afd67..b82ea547782 100644 --- a/packages/search/lib/commands/SUGGET.spec.ts +++ b/packages/search/lib/commands/SUGGET.spec.ts @@ -28,13 +28,23 @@ describe('FT.SUGGET', () => { }); describe('client.ft.sugGet', () => { - testUtils.testWithClient('null', async client => { - assert.equal( + + testUtils.testWithClientIfVersionWithinRange([[8], 'LATEST'], 'null', async client => { + assert.deepStrictEqual( await client.ft.sugGet('key', 'prefix'), - null + [] ); }, GLOBAL.SERVERS.OPEN); + + + testUtils.testWithClientIfVersionWithinRange([[6, 2, 0], [7, 4, 0]], 'null', async client => { + assert.deepStrictEqual( + await client.ft.sugGet('key', 'prefix'), + null + ); + }, GLOBAL.SERVERS.OPEN) + testUtils.testWithClient('with suggestions', async client => { const [, reply] = await Promise.all([ client.ft.sugAdd('key', 'string', 1), diff --git a/packages/search/lib/commands/SUGGET_WITHPAYLOADS.spec.ts b/packages/search/lib/commands/SUGGET_WITHPAYLOADS.spec.ts index 160d7e3eb7c..c01b87e2892 100644 --- a/packages/search/lib/commands/SUGGET_WITHPAYLOADS.spec.ts +++ b/packages/search/lib/commands/SUGGET_WITHPAYLOADS.spec.ts @@ -11,14 +11,21 @@ describe('FT.SUGGET WITHPAYLOADS', () => { ); }); - describe('client.ft.sugGetWithPayloads', () => { - testUtils.testWithClient('null', async client => { - assert.equal( - await client.ft.sugGetWithPayloads('key', 'prefix'), - null - ); - }, GLOBAL.SERVERS.OPEN); + testUtils.testWithClientIfVersionWithinRange([[8], 'LATEST'], 'null', async client => { + assert.deepStrictEqual( + await client.ft.sugGetWithPayloads('key', 'prefix'), + [] + ); + }, GLOBAL.SERVERS.OPEN); + + testUtils.testWithClientIfVersionWithinRange([[6], [7, 4, 0]], 'null', async client => { + assert.deepStrictEqual( + await client.ft.sugGetWithPayloads('key', 'prefix'), + null + ); + }, GLOBAL.SERVERS.OPEN); + describe('with suggestions', () => { testUtils.testWithClient('with suggestions', async client => { const [, reply] = await Promise.all([ client.ft.sugAdd('key', 'string', 1, { diff --git a/packages/search/lib/commands/SUGGET_WITHSCORES.spec.ts b/packages/search/lib/commands/SUGGET_WITHSCORES.spec.ts index 262defb7933..50db89ffe99 100644 --- a/packages/search/lib/commands/SUGGET_WITHSCORES.spec.ts +++ b/packages/search/lib/commands/SUGGET_WITHSCORES.spec.ts @@ -12,14 +12,15 @@ describe('FT.SUGGET WITHSCORES', () => { }); describe('client.ft.sugGetWithScores', () => { - testUtils.testWithClient('null', async client => { - assert.equal( + + testUtils.testWithClientIfVersionWithinRange([[8],'LATEST'], 'null', async client => { + assert.deepStrictEqual( await client.ft.sugGetWithScores('key', 'prefix'), - null + [] ); }, GLOBAL.SERVERS.OPEN); - testUtils.testWithClient('with suggestions', async client => { + testUtils.testWithClientIfVersionWithinRange([[8],'LATEST'],'with suggestions', async client => { const [, reply] = await Promise.all([ client.ft.sugAdd('key', 'string', 1), client.ft.sugGetWithScores('key', 's') diff --git a/packages/search/lib/commands/SUGGET_WITHSCORES_WITHPAYLOADS.spec.ts b/packages/search/lib/commands/SUGGET_WITHSCORES_WITHPAYLOADS.spec.ts index 573708f689e..96eb473159f 100644 --- a/packages/search/lib/commands/SUGGET_WITHSCORES_WITHPAYLOADS.spec.ts +++ b/packages/search/lib/commands/SUGGET_WITHSCORES_WITHPAYLOADS.spec.ts @@ -12,14 +12,14 @@ describe('FT.SUGGET WITHSCORES WITHPAYLOADS', () => { }); describe('client.ft.sugGetWithScoresWithPayloads', () => { - testUtils.testWithClient('null', async client => { - assert.equal( + testUtils.testWithClientIfVersionWithinRange([[8], 'LATEST'], 'null', async client => { + assert.deepStrictEqual( await client.ft.sugGetWithScoresWithPayloads('key', 'prefix'), - null + [] ); }, GLOBAL.SERVERS.OPEN); - testUtils.testWithClient('with suggestions', async client => { + testUtils.testWithClientIfVersionWithinRange([[8], 'LATEST'], 'with suggestions', async client => { const [, reply] = await Promise.all([ client.ft.sugAdd('key', 'string', 1, { PAYLOAD: 'payload' diff --git a/packages/search/lib/commands/index.ts b/packages/search/lib/commands/index.ts index 00706a70c2e..7aa3f061bf7 100644 --- a/packages/search/lib/commands/index.ts +++ b/packages/search/lib/commands/index.ts @@ -48,9 +48,21 @@ export default { aliasDel: ALIASDEL, ALIASUPDATE, aliasUpdate: ALIASUPDATE, + /** + * @deprecated Redis >=8 uses the standard CONFIG command + */ CONFIG_GET, + /** + * @deprecated Redis >=8 uses the standard CONFIG command + */ configGet: CONFIG_GET, + /** + * @deprecated Redis >=8 uses the standard CONFIG command + */ CONFIG_SET, + /** + * @deprecated Redis >=8 uses the standard CONFIG command + */ configSet: CONFIG_SET, CREATE, create: CREATE, diff --git a/packages/search/lib/test-utils.ts b/packages/search/lib/test-utils.ts index ce43a37bc21..1318676042e 100644 --- a/packages/search/lib/test-utils.ts +++ b/packages/search/lib/test-utils.ts @@ -1,21 +1,32 @@ import TestUtils from '@redis/test-utils'; import RediSearch from '.'; +import { RespVersions } from '@redis/client'; -export default new TestUtils({ - dockerImageName: 'redis/redis-stack', - dockerImageVersionArgument: 'redisearch-version', - defaultDockerVersion: '7.4.0-v1' +export default TestUtils.createFromConfig({ + dockerImageName: 'redislabs/client-libs-test', + dockerImageVersionArgument: 'redis-version', + defaultDockerVersion: '8.0-M04-pre' }); export const GLOBAL = { - SERVERS: { - OPEN: { - serverArguments: [], - clientOptions: { - modules: { - ft: RediSearch - } - } + SERVERS: { + OPEN: { + serverArguments: [], + clientOptions: { + modules: { + ft: RediSearch } + } + }, + OPEN_3: { + serverArguments: [], + clientOptions: { + RESP: 3 as RespVersions, + unstableResp3:true, + modules: { + ft: RediSearch + } + } } + } }; diff --git a/packages/search/package.json b/packages/search/package.json index 26cbbaf5ad9..985356cd230 100644 --- a/packages/search/package.json +++ b/packages/search/package.json @@ -9,7 +9,8 @@ "!dist/tsconfig.tsbuildinfo" ], "scripts": { - "test": "nyc -r text-summary -r lcov mocha -r tsx './lib/**/*.spec.ts'" + "test": "nyc -r text-summary -r lcov mocha -r tsx './lib/**/*.spec.ts'", + "test-sourcemap": "mocha -r ts-node/register/transpile-only './lib/**/*.spec.ts'" }, "peerDependencies": { "@redis/client": "^5.0.0-next.6" diff --git a/packages/test-utils/lib/dockers.ts b/packages/test-utils/lib/dockers.ts index bfb66603750..e3ff5edc38b 100644 --- a/packages/test-utils/lib/dockers.ts +++ b/packages/test-utils/lib/dockers.ts @@ -4,9 +4,11 @@ import { once } from 'node:events'; import { createClient } from '@redis/client/index'; import { setTimeout } from 'node:timers/promises'; // import { ClusterSlotsReply } from '@redis/client/dist/lib/commands/CLUSTER_SLOTS'; + +import { execFile as execFileCallback } from 'node:child_process'; import { promisify } from 'node:util'; -import { exec } from 'node:child_process'; -const execAsync = promisify(exec); + +const execAsync = promisify(execFileCallback); interface ErrorWithCode extends Error { code: string; @@ -46,11 +48,29 @@ export interface RedisServerDocker { dockerId: string; } -async function spawnRedisServerDocker({ image, version }: RedisServerDockerConfig, serverArguments: Array): Promise { - const port = (await portIterator.next()).value, - { stdout, stderr } = await execAsync( - `docker run -e REDIS_ARGS="--port ${port.toString()} ${serverArguments.join(' ')}" -d --network host ${image}:${version}` - ); +async function spawnRedisServerDocker({ + image, + version +}: RedisServerDockerConfig, serverArguments: Array): Promise { + const port = (await portIterator.next()).value; + const portStr = port.toString(); + + const dockerArgs = [ + 'run', + '-e', `PORT=${portStr}`, + '-d', + '--network', 'host', + `${image}:${version}`, + '--port', portStr + ]; + + if (serverArguments.length > 0) { + dockerArgs.push(...serverArguments); + } + + console.log(`[Docker] Spawning Redis container - Image: ${image}:${version}, Port: ${port}`); + + const { stdout, stderr } = await execAsync('docker', dockerArgs); if (!stdout) { throw new Error(`docker run error - ${stderr}`); @@ -65,7 +85,6 @@ async function spawnRedisServerDocker({ image, version }: RedisServerDockerConfi dockerId: stdout.trim() }; } - const RUNNING_SERVERS = new Map, ReturnType>(); export function spawnRedisServer(dockerConfig: RedisServerDockerConfig, serverArguments: Array): Promise { @@ -80,7 +99,7 @@ export function spawnRedisServer(dockerConfig: RedisServerDockerConfig, serverAr } async function dockerRemove(dockerId: string): Promise { - const { stderr } = await execAsync(`docker rm -f ${dockerId}`); + const { stderr } = await execAsync('docker', ['rm', '-f', dockerId]); if (stderr) { throw new Error(`docker rm error - ${stderr}`); } @@ -132,15 +151,15 @@ async function spawnRedisClusterNodeDockers( '5000' ], clientConfig).then(async replica => { - const requirePassIndex = serverArguments.findIndex((x)=>x==='--requirepass'); - if(requirePassIndex!==-1) { - const password = serverArguments[requirePassIndex+1]; - await replica.client.configSet({'masterauth': password}) + const requirePassIndex = serverArguments.findIndex((x) => x === '--requirepass'); + if (requirePassIndex !== -1) { + const password = serverArguments[requirePassIndex + 1]; + await replica.client.configSet({ 'masterauth': password }) } await replica.client.clusterMeet('127.0.0.1', master.docker.port); while ((await replica.client.clusterSlots()).length === 0) { - await setTimeout(50); + await setTimeout(25); } await replica.client.clusterReplicate( @@ -224,7 +243,7 @@ async function spawnRedisClusterDockers( while ( totalNodes(await client.clusterSlots()) !== nodes.length || !(await client.sendCommand(['CLUSTER', 'INFO'])).startsWith('cluster_state:ok') // TODO - ) { + ) { await setTimeout(50); } @@ -257,7 +276,7 @@ export function spawnRedisCluster( return runningCluster; } - const dockersPromise = spawnRedisClusterDockers(dockersConfig, serverArguments,clientConfig); + const dockersPromise = spawnRedisClusterDockers(dockersConfig, serverArguments, clientConfig); RUNNING_CLUSTERS.set(serverArguments, dockersPromise); return dockersPromise; diff --git a/packages/test-utils/lib/index.spec.ts b/packages/test-utils/lib/index.spec.ts new file mode 100644 index 00000000000..0f1e7552284 --- /dev/null +++ b/packages/test-utils/lib/index.spec.ts @@ -0,0 +1,106 @@ +import { strict as assert } from 'node:assert'; +import TestUtils from './index'; + +describe('TestUtils', () => { + describe('parseVersionNumber', () => { + it('should handle special versions', () => { + assert.deepStrictEqual(TestUtils.parseVersionNumber('latest'), [Infinity]); + assert.deepStrictEqual(TestUtils.parseVersionNumber('edge'), [Infinity]); + }); + + it('should parse simple version numbers', () => { + assert.deepStrictEqual(TestUtils.parseVersionNumber('7.4.0'), [7, 4, 0]); + }); + + it('should handle versions with multiple dashes and prefixes', () => { + assert.deepStrictEqual(TestUtils.parseVersionNumber('rs-7.4.0-v2'), [7, 4, 0]); + assert.deepStrictEqual(TestUtils.parseVersionNumber('rs-7.4.0'), [7, 4, 0]); + assert.deepStrictEqual(TestUtils.parseVersionNumber('7.4.0-v2'), [7, 4, 0]); + }); + + it('should handle various version number formats', () => { + assert.deepStrictEqual(TestUtils.parseVersionNumber('10.5'), [10, 5]); + assert.deepStrictEqual(TestUtils.parseVersionNumber('8.0.0'), [8, 0, 0]); + assert.deepStrictEqual(TestUtils.parseVersionNumber('rs-6.2.4-v1'), [6, 2, 4]); + }); + + it('should throw TypeError for invalid version strings', () => { + ['', 'invalid', 'rs-', 'v2', 'rs-invalid-v2'].forEach(version => { + assert.throws( + () => TestUtils.parseVersionNumber(version), + TypeError, + `Expected TypeError for version string: ${version}` + ); + }); + }); + }); +}); + + + +describe('Version Comparison', () => { + it('should correctly compare versions', () => { + const tests: [Array, Array, -1 | 0 | 1][] = [ + [[1, 0, 0], [1, 0, 0], 0], + [[2, 0, 0], [1, 9, 9], 1], + [[1, 9, 9], [2, 0, 0], -1], + [[1, 2, 3], [1, 2], 1], + [[1, 2], [1, 2, 3], -1], + [[1, 2, 0], [1, 2, 1], -1], + [[1], [1, 0, 0], 0], + [[2], [1, 9, 9], 1], + ]; + + tests.forEach(([a, b, expected]) => { + + assert.equal( + TestUtils.compareVersions(a, b), + expected, + `Failed comparing ${a.join('.')} with ${b.join('.')}: expected ${expected}` + ); + }); + }); + + it('should correctly compare versions', () => { + const tests: [Array, Array, -1 | 0 | 1][] = [ + [[1, 0, 0], [1, 0, 0], 0], + [[2, 0, 0], [1, 9, 9], 1], + [[1, 9, 9], [2, 0, 0], -1], + [[1, 2, 3], [1, 2], 1], + [[1, 2], [1, 2, 3], -1], + [[1, 2, 0], [1, 2, 1], -1], + [[1], [1, 0, 0], 0], + [[2], [1, 9, 9], 1], + ]; + + tests.forEach(([a, b, expected]) => { + + assert.equal( + TestUtils.compareVersions(a, b), + expected, + `Failed comparing ${a.join('.')} with ${b.join('.')}: expected ${expected}` + ); + }); + }) + it('isVersionInRange should work correctly', () => { + const tests: [Array, Array, Array, boolean][] = [ + [[7, 0, 0], [7, 0, 0], [7, 0, 0], true], + [[7, 0, 1], [7, 0, 0], [7, 0, 2], true], + [[7, 0, 0], [7, 0, 1], [7, 0, 2], false], + [[7, 0, 3], [7, 0, 1], [7, 0, 2], false], + [[7], [6, 0, 0], [8, 0, 0], true], + [[7, 1, 1], [7, 1, 0], [7, 1, 2], true], + [[6, 0, 0], [7, 0, 0], [8, 0, 0], false], + [[9, 0, 0], [7, 0, 0], [8, 0, 0], false] + ]; + + tests.forEach(([version, min, max, expected]) => { + const testUtils = new TestUtils({ string: version.join('.'), numbers: version }, "test") + assert.equal( + testUtils.isVersionInRange(min, max), + expected, + `Failed checking if ${version.join('.')} is between ${min.join('.')} and ${max.join('.')}: expected ${expected}` + ); + }); + }) +}); diff --git a/packages/test-utils/lib/index.ts b/packages/test-utils/lib/index.ts index 9dee350e31e..b48f11b02c7 100644 --- a/packages/test-utils/lib/index.ts +++ b/packages/test-utils/lib/index.ts @@ -19,12 +19,38 @@ import { RedisServerDockerConfig, spawnRedisServer, spawnRedisCluster } from './ import yargs from 'yargs'; import { hideBin } from 'yargs/helpers'; + interface TestUtilsConfig { + /** + * The name of the Docker image to use for spawning Redis test instances. + * This should be a valid Docker image name that contains a Redis server. + * + * @example 'redislabs/client-libs-test' + */ dockerImageName: string; + + /** + * The command-line argument name used to specify the Redis version. + * This argument can be passed when running tests / GH actions. + * + * @example + * If set to 'redis-version', you can run tests with: + * ```bash + * npm test -- --redis-version="6.2" + * ``` + */ dockerImageVersionArgument: string; + + /** + * The default Redis version to use if no version is specified via command-line arguments. + * Can be a specific version number (e.g., '6.2'), 'latest', or 'edge'. + * If not provided, defaults to 'latest'. + * + * @optional + * @default 'latest' + */ defaultDockerVersion?: string; } - interface CommonTestOptions { serverArguments: Array; minimumDockerVersion?: Array; @@ -83,22 +109,27 @@ interface Version { } export default class TestUtils { - static #parseVersionNumber(version: string): Array { + static parseVersionNumber(version: string): Array { if (version === 'latest' || version === 'edge') return [Infinity]; - const dashIndex = version.indexOf('-'); - return (dashIndex === -1 ? version : version.substring(0, dashIndex)) - .split('.') - .map(x => { - const value = Number(x); - if (Number.isNaN(value)) { - throw new TypeError(`${version} is not a valid redis version`); - } - return value; - }); - } + // Match complete version number patterns + const versionMatch = version.match(/(^|\-)\d+(\.\d+)*($|\-)/); + if (!versionMatch) { + throw new TypeError(`${version} is not a valid redis version`); + } + // Extract just the numbers and dots between first and last dash (or start/end) + const versionNumbers = versionMatch[0].replace(/^\-|\-$/g, ''); + + return versionNumbers.split('.').map(x => { + const value = Number(x); + if (Number.isNaN(value)) { + throw new TypeError(`${version} is not a valid redis version`); + } + return value; + }); + } static #getVersion(argumentName: string, defaultVersion = 'latest'): Version { return yargs(hideBin(process.argv)) .option(argumentName, { @@ -108,7 +139,7 @@ export default class TestUtils { .coerce(argumentName, (version: string) => { return { string: version, - numbers: TestUtils.#parseVersionNumber(version) + numbers: TestUtils.parseVersionNumber(version) }; }) .demandOption(argumentName) @@ -118,39 +149,76 @@ export default class TestUtils { readonly #VERSION_NUMBERS: Array; readonly #DOCKER_IMAGE: RedisServerDockerConfig; - constructor(config: TestUtilsConfig) { - const { string, numbers } = TestUtils.#getVersion(config.dockerImageVersionArgument, config.defaultDockerVersion); + constructor({ string, numbers }: Version, dockerImageName: string) { this.#VERSION_NUMBERS = numbers; this.#DOCKER_IMAGE = { - image: config.dockerImageName, + image: dockerImageName, version: string }; } + /** + * Creates a new TestUtils instance from a configuration object. + * + * @param config - Configuration object containing Docker image and version settings + * @param config.dockerImageName - The name of the Docker image to use for tests + * @param config.dockerImageVersionArgument - The command-line argument name for specifying Redis version + * @param config.defaultDockerVersion - Optional default Redis version if not specified via arguments + * @returns A new TestUtils instance configured with the provided settings + */ + public static createFromConfig(config: TestUtilsConfig) { + return new TestUtils( + TestUtils.#getVersion(config.dockerImageVersionArgument, + config.defaultDockerVersion), config.dockerImageName); + } + isVersionGreaterThan(minimumVersion: Array | undefined): boolean { if (minimumVersion === undefined) return true; - - const lastIndex = Math.min(this.#VERSION_NUMBERS.length, minimumVersion.length) - 1; - for (let i = 0; i < lastIndex; i++) { - if (this.#VERSION_NUMBERS[i] > minimumVersion[i]) { - return true; - } else if (minimumVersion[i] > this.#VERSION_NUMBERS[i]) { - return false; - } - } - - return this.#VERSION_NUMBERS[lastIndex] >= minimumVersion[lastIndex]; + return TestUtils.compareVersions(this.#VERSION_NUMBERS, minimumVersion) >= 0; } isVersionGreaterThanHook(minimumVersion: Array | undefined): void { - const isVersionGreaterThan = this.isVersionGreaterThan.bind(this); + + const isVersionGreaterThanHook = this.isVersionGreaterThan.bind(this); + const versionNumber = this.#VERSION_NUMBERS.join('.'); + const minimumVersionString = minimumVersion?.join('.'); before(function () { - if (!isVersionGreaterThan(minimumVersion)) { + if (!isVersionGreaterThanHook(minimumVersion)) { + console.warn(`TestUtils: Version ${versionNumber} is less than minimum version ${minimumVersionString}, skipping test`); return this.skip(); } }); } + isVersionInRange(minVersion: Array, maxVersion: Array): boolean { + return TestUtils.compareVersions(this.#VERSION_NUMBERS, minVersion) >= 0 && + TestUtils.compareVersions(this.#VERSION_NUMBERS, maxVersion) <= 0 + } + + /** + * Compares two semantic version arrays and returns: + * -1 if version a is less than version b + * 0 if version a equals version b + * 1 if version a is greater than version b + * + * @param a First version array + * @param b Second version array + * @returns -1 | 0 | 1 + */ + static compareVersions(a: Array, b: Array): -1 | 0 | 1 { + const maxLength = Math.max(a.length, b.length); + + const paddedA = [...a, ...Array(maxLength - a.length).fill(0)]; + const paddedB = [...b, ...Array(maxLength - b.length).fill(0)]; + + for (let i = 0; i < maxLength; i++) { + if (paddedA[i] > paddedB[i]) return 1; + if (paddedA[i] < paddedB[i]) return -1; + } + + return 0; + } + testWithClient< M extends RedisModules = {}, F extends RedisFunctions = {}, @@ -204,6 +272,27 @@ export default class TestUtils { }); } + testWithClientIfVersionWithinRange< + M extends RedisModules = {}, + F extends RedisFunctions = {}, + S extends RedisScripts = {}, + RESP extends RespVersions = 2, + TYPE_MAPPING extends TypeMapping = {} + >( + range: ([minVersion: Array, maxVersion: Array] | [minVersion: Array, 'LATEST']), + title: string, + fn: (client: RedisClientType) => unknown, + options: ClientTestOptions + ): void { + + if (this.isVersionInRange(range[0], range[1] === 'LATEST' ? [Infinity, Infinity, Infinity] : range[1])) { + return this.testWithClient(`${title} [${range[0].join('.')}] - [${(range[1] === 'LATEST') ? range[1] : range[1].join(".")}] `, fn, options) + } else { + console.warn(`Skipping test ${title} because server version ${this.#VERSION_NUMBERS.join('.')} is not within range ${range[0].join(".")} - ${range[1] !== 'LATEST' ? range[1].join(".") : 'LATEST'}`) + } + + } + testWithClientPool< M extends RedisModules = {}, F extends RedisFunctions = {}, diff --git a/packages/test-utils/package.json b/packages/test-utils/package.json index 5e291211b6c..f7373f6add1 100644 --- a/packages/test-utils/package.json +++ b/packages/test-utils/package.json @@ -3,6 +3,9 @@ "private": true, "main": "./dist/lib/index.js", "types": "./dist/lib/index.d.ts", + "scripts": { + "test": "nyc -r text-summary -r lcov mocha -r tsx './lib/**/*.spec.ts'" + }, "peerDependencies": { "@redis/client": "*" }, diff --git a/packages/time-series/lib/test-utils.ts b/packages/time-series/lib/test-utils.ts index 1cb5c8ed97b..0b7e940788f 100644 --- a/packages/time-series/lib/test-utils.ts +++ b/packages/time-series/lib/test-utils.ts @@ -1,10 +1,10 @@ import TestUtils from '@redis/test-utils'; import TimeSeries from '.'; -export default new TestUtils({ - dockerImageName: 'redis/redis-stack', - dockerImageVersionArgument: 'timeseries-version', - defaultDockerVersion: '7.4.0-v1' +export default TestUtils.createFromConfig({ + dockerImageName: 'redislabs/client-libs-test', + dockerImageVersionArgument: 'redis-version', + defaultDockerVersion: '8.0-M04-pre' }); export const GLOBAL = {