-
Notifications
You must be signed in to change notification settings - Fork 2k
feat: add COMMAND DOCS support #3362
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,154 @@ | ||
| import { strict as assert } from 'node:assert'; | ||
| import testUtils, { GLOBAL } from '../test-utils'; | ||
| import { RESP_TYPES } from '../RESP/decoder'; | ||
| import COMMAND_DOCS from './COMMAND_DOCS'; | ||
| import { parseArgs } from './generic-transformers'; | ||
|
|
||
| describe('COMMAND DOCS', () => { | ||
| testUtils.isVersionGreaterThanHook([7]); | ||
|
|
||
| describe('transformArguments', () => { | ||
| it('simple', () => { | ||
| assert.deepEqual( | ||
| parseArgs(COMMAND_DOCS), | ||
| ['COMMAND', 'DOCS'] | ||
| ); | ||
| }); | ||
|
|
||
| it('string', () => { | ||
| assert.deepEqual( | ||
| parseArgs(COMMAND_DOCS, 'GET'), | ||
| ['COMMAND', 'DOCS', 'GET'] | ||
| ); | ||
| }); | ||
|
|
||
| it('array', () => { | ||
| assert.deepEqual( | ||
| parseArgs(COMMAND_DOCS, ['GET', 'SET']), | ||
| ['COMMAND', 'DOCS', 'GET', 'SET'] | ||
| ); | ||
| }); | ||
| }); | ||
|
|
||
| describe('transformReply', () => { | ||
| it('RESP2 map with nested arguments', () => { | ||
| assert.deepEqual( | ||
| COMMAND_DOCS.transformReply[2]([ | ||
| 'get', | ||
| [ | ||
| 'summary', 'Get the value of a key', | ||
| 'since', '1.0.0', | ||
| 'group', 'string', | ||
| 'complexity', 'O(1)', | ||
| 'history', [['2.2.3', 'Added MSET']], | ||
| 'arguments', [ | ||
| [ | ||
| 'name', 'key', | ||
| 'type', 'key', | ||
| 'display_text', 'key', | ||
| 'key_spec_index', 0 | ||
| ] | ||
| ] | ||
| ], | ||
| 'command', | ||
| [ | ||
| 'summary', 'Get or set information about commands', | ||
| 'since', '2.8.13', | ||
| 'group', 'server', | ||
| 'complexity', 'O(N)', | ||
| 'subcommands', [ | ||
| 'command|docs', | ||
| [ | ||
| 'summary', 'Returns documentary information about commands', | ||
| 'since', '7.0.0', | ||
| 'group', 'server', | ||
| 'complexity', 'O(N)' | ||
| ] | ||
| ] | ||
| ] | ||
| ] as never), | ||
| { | ||
| get: { | ||
| summary: 'Get the value of a key', | ||
| since: '1.0.0', | ||
| group: 'string', | ||
| complexity: 'O(1)', | ||
| history: [['2.2.3', 'Added MSET']], | ||
| arguments: [{ | ||
| name: 'key', | ||
| type: 'key', | ||
| display_text: 'key', | ||
| key_spec_index: 0 | ||
| }] | ||
| }, | ||
| command: { | ||
| summary: 'Get or set information about commands', | ||
| since: '2.8.13', | ||
| group: 'server', | ||
| complexity: 'O(N)', | ||
| subcommands: { | ||
| 'command|docs': { | ||
| summary: 'Returns documentary information about commands', | ||
| since: '7.0.0', | ||
| group: 'server', | ||
| complexity: 'O(N)' | ||
| } | ||
| } | ||
| } | ||
| } | ||
| ); | ||
| }); | ||
| }); | ||
|
|
||
| testUtils.testWithClient('client.commandDocs', async client => { | ||
| const docs = await client.commandDocs(); | ||
| assert.equal(typeof docs, 'object'); | ||
| assert.ok(docs !== null); | ||
| assert.ok(!Array.isArray(docs)); | ||
|
|
||
| const get = docs['get']; | ||
| assert.ok(get); | ||
| assert.equal(typeof get.summary, 'string'); | ||
| assert.equal(typeof get.since, 'string'); | ||
| assert.equal(typeof get.group, 'string'); | ||
| assert.equal(typeof get.complexity, 'string'); | ||
| assert.ok(Array.isArray(get.arguments)); | ||
| assert.ok(get.arguments!.length > 0); | ||
| assert.equal(typeof get.arguments![0].name, 'string'); | ||
| assert.equal(typeof get.arguments![0].type, 'string'); | ||
| }, { | ||
| ...GLOBAL.SERVERS.OPEN, | ||
| minimumDockerVersion: [7] | ||
| }); | ||
|
|
||
| testUtils.testWithClient('client.commandDocs with RESP2 and Map type mapping', async client => { | ||
| const docs = await client | ||
| .withTypeMapping({ [RESP_TYPES.MAP]: Map }) | ||
| .commandDocs('GET'); | ||
|
|
||
| assert.ok(docs instanceof Map); | ||
| const get = docs.get('get'); | ||
| assert.ok(get instanceof Map); | ||
|
|
||
| const argumentsReply = get.get('arguments'); | ||
| assert.ok(Array.isArray(argumentsReply)); | ||
| assert.ok(argumentsReply[0] instanceof Map); | ||
| assert.equal(argumentsReply[0].get('name'), 'key'); | ||
| }, { | ||
| ...GLOBAL.SERVERS.OPEN, | ||
| clientOptions: { | ||
| RESP: 2 | ||
| }, | ||
| minimumDockerVersion: [7] | ||
| }); | ||
|
|
||
| testUtils.testWithClient('client.commandDocs with command filter', async client => { | ||
| const docs = await client.commandDocs(['GET', 'SET']); | ||
| assert.ok('get' in docs); | ||
| assert.ok('set' in docs); | ||
| assert.equal(Object.keys(docs).length, 2); | ||
| }, { | ||
| ...GLOBAL.SERVERS.OPEN, | ||
| minimumDockerVersion: [7] | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,116 @@ | ||
| import { CommandParser } from '../client/parser'; | ||
| import { ArrayReply, BlobStringReply, Command, MapReply, NumberReply, ReplyUnion, Resp2Reply, TuplesReply, TypeMapping, UnwrapReply } from '../RESP/types'; | ||
| import { RESP_TYPES } from '../RESP/decoder'; | ||
| import { RedisVariadicArgument } from './generic-transformers'; | ||
|
|
||
| export interface CommandDocsArgument { | ||
| name?: BlobStringReply; | ||
| type?: BlobStringReply; | ||
| display_text?: BlobStringReply; | ||
| key_spec_index?: NumberReply; | ||
| token?: BlobStringReply; | ||
| summary?: BlobStringReply; | ||
| since?: BlobStringReply; | ||
| deprecated_since?: BlobStringReply; | ||
| flags?: ArrayReply<BlobStringReply>; | ||
| arguments?: ArrayReply<CommandDocsArgument>; | ||
| } | ||
|
|
||
| export interface CommandDoc { | ||
| summary?: BlobStringReply; | ||
| since?: BlobStringReply; | ||
| group?: BlobStringReply; | ||
| complexity?: BlobStringReply; | ||
| module?: BlobStringReply; | ||
| doc_flags?: ArrayReply<BlobStringReply>; | ||
| deprecated_since?: BlobStringReply; | ||
| replaced_by?: BlobStringReply; | ||
| history?: ArrayReply<TuplesReply<[BlobStringReply, BlobStringReply]>>; | ||
| arguments?: ArrayReply<CommandDocsArgument>; | ||
| subcommands?: CommandDocsReply; | ||
| reply_schema?: ReplyUnion; | ||
| } | ||
|
|
||
| export type CommandDocsReply = MapReply<BlobStringReply, CommandDoc>; | ||
|
|
||
| function transformMap( | ||
| reply: unknown, | ||
| typeMapping: TypeMapping | undefined, | ||
| transformValue: (key: string, value: unknown) => unknown | ||
| ): unknown { | ||
| if (!Array.isArray(reply)) { | ||
| return reply; | ||
| } | ||
|
|
||
| const entries: Array<[string, unknown]> = []; | ||
| for (let i = 0; i < reply.length; i += 2) { | ||
| const key = (reply[i] as { toString(): string }).toString(); | ||
| entries.push([key, transformValue(key, reply[i + 1])]); | ||
| } | ||
|
|
||
| switch (typeMapping?.[RESP_TYPES.MAP]) { | ||
| case Array: | ||
| return entries.flat(); | ||
| case Map: | ||
| return new Map(entries); | ||
| default: { | ||
| const object: Record<string, unknown> = {}; | ||
| for (const [key, value] of entries) { | ||
| object[key] = value; | ||
| } | ||
| return object; | ||
| } | ||
| } | ||
| } | ||
|
|
||
| function transformArgument(reply: unknown, typeMapping?: TypeMapping): CommandDocsArgument { | ||
| return transformMap(reply, typeMapping, (key, value) => { | ||
| if (key === 'arguments' && Array.isArray(value)) { | ||
| return value.map(argument => transformArgument(argument, typeMapping)); | ||
| } | ||
| return value; | ||
| }) as CommandDocsArgument; | ||
| } | ||
|
|
||
| function transformDoc(reply: unknown, typeMapping?: TypeMapping): CommandDoc { | ||
| return transformMap(reply, typeMapping, (key, value) => { | ||
| switch (key) { | ||
| case 'arguments': | ||
| return Array.isArray(value) | ||
| ? value.map(argument => transformArgument(argument, typeMapping)) | ||
| : value; | ||
| case 'subcommands': | ||
| return transformDocsMap(value, typeMapping); | ||
| case 'reply_schema': | ||
| return value; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a Redis build includes Useful? React with 👍 / 👎. |
||
| default: | ||
| return value; | ||
| } | ||
| }) as CommandDoc; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Fresh evidence after the runtime mapping fix is that this cast still reports each transformed command document as Useful? React with 👍 / 👎. |
||
| } | ||
|
|
||
| function transformDocsMap(reply: unknown, typeMapping?: TypeMapping): CommandDocsReply { | ||
| return transformMap( | ||
| reply, | ||
| typeMapping, | ||
| (_key, value) => transformDoc(value, typeMapping) | ||
| ) as CommandDocsReply; | ||
| } | ||
|
|
||
| export default { | ||
| IS_READ_ONLY: true, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Missing
|
||
| /** | ||
| * @see https://redis.io/commands/command-docs/ | ||
| */ | ||
| parseCommand(parser: CommandParser, commands?: RedisVariadicArgument) { | ||
| parser.push('COMMAND', 'DOCS'); | ||
| if (commands !== undefined) { | ||
| parser.pushVariadic(commands); | ||
| } | ||
| }, | ||
| transformReply: { | ||
| 2: (reply: UnwrapReply<Resp2Reply<CommandDocsReply>>, _preserve?: unknown, typeMapping?: TypeMapping) => | ||
| transformDocsMap(reply, typeMapping), | ||
| 3: undefined as unknown as () => CommandDocsReply | ||
| } | ||
| } as const satisfies Command; | ||


There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When RESP3 callers opt into
withTypeMapping({ [RESP_TYPES.SET]: Set }), argument metadata such asflagsis decoded as aSet, but this declaration still advertises an array, so TypeScript consumers can writeargument.flags?.includes('optional')and then fail at runtime becauseSethas noincludes. UseSetReply<BlobStringReply>for this set-valued field and the analogous doc-level set fields so the public type follows the decoder output.Useful? React with 👍 / 👎.