Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions packages/search/lib/commands/SEARCH.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,15 @@ describe('FT.SEARCH', () => {
);
});

it('with WITHSCORES',() => {
assert.deepEqual(
parseArgs(SEARCH,'index','query',{
WITHSCORES:true
}),
['FT.SEARCH','index','query','WITHSCORES','DIALECT',DEFAULT_DIALECT]
)
})

it('with INKEYS', () => {
assert.deepEqual(
parseArgs(SEARCH, 'index', 'query', {
Expand Down Expand Up @@ -364,6 +373,23 @@ describe('FT.SEARCH', () => {
);
}, GLOBAL.SERVERS.OPEN);

testUtils.testWithClient('withscores', async client => {
await Promise.all([
client.ft.create('index', {
field: 'TEXT'
}),
client.hSet('1', 'field', '1')
]);

const res = await client.ft.search('index','*',{WITHSCORES: true});

assert.strictEqual(res.total,1);
assert.strictEqual(res.documents.length,1);
assert.strictEqual(res.documents[0].id,'1');
assert.strictEqual(typeof res.documents[0].score,'number');

}, GLOBAL.SERVERS.OPEN);

testUtils.testWithClient('with data', async client => {
await Promise.all([
client.ft.create('index', {
Expand Down
18 changes: 18 additions & 0 deletions packages/search/lib/commands/SEARCH.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export interface FtSearchOptions {
VERBATIM?: boolean;
NOSTOPWORDS?: boolean;
INKEYS?: RedisVariadicArgument;
WITHSCORES?: boolean;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Handle scores in the no-content search variant

SEARCH_NOCONTENT.parseCommand reuses Parameters<typeof SEARCH.parseCommand>, so adding WITHSCORES here also exposes and serializes it for ft.searchNoContent. That command's separate RESP2 transformer returns reply.slice(1), treating every score as another document ID, while its RESP3 transformer maps the main result back to IDs and silently discards every score. Callers can therefore request scores through the public type but cannot receive a valid no-content result; either exclude this option from that variant or preserve ID/score pairs in its reply contract.

Useful? React with 👍 / 👎.

INFIELDS?: RedisVariadicArgument;
RETURN?: RedisVariadicArgument;
SUMMARIZE?: boolean | {
Expand Down Expand Up @@ -72,6 +73,10 @@ export function parseSearchOptions(parser: CommandParser, options?: FtSearchOpti
parser.push('NOSTOPWORDS');
}

if (options?.WITHSCORES) {
parser.push('WITHSCORES');
}
Comment thread
cursor[bot] marked this conversation as resolved.

parseOptionalVariadicArgument(parser, 'INKEYS', options?.INKEYS);
parseOptionalVariadicArgument(parser, 'INFIELDS', options?.INFIELDS);
parseOptionalVariadicArgument(parser, 'RETURN', options?.RETURN);
Expand Down Expand Up @@ -171,8 +176,14 @@ function transformSearchReplyResp2(
const documents: SearchReply['documents'] = [];
let i = 1;
while (i < reply.length) {
let score: number | undefined;

if(typeof reply[i] === 'number' || (typeof reply[i] === 'string' && !isNaN(Number(reply[i])) && Array.isArray(reply[i + 1]))){
score = Number(reply[i++]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Parse RESP2 scores after the document ID

For RESP2, FT.SEARCH ... WITHSCORES returns each row as id, score, fields, but this checks for and consumes a score before consuming the ID. A reply such as [1, 'doc', '1', ['field', 'value']] is therefore parsed with '1' as the document value and then produces a spurious row. Moreover, without WITHSCORES, a numeric document ID in [1, '1', ['field', 'value']] is mistaken for a score, corrupting ordinary RESP2 searches as well; consume the ID first and determine score presence from the requested reply layout rather than the ID's numeric shape.

Useful? React with 👍 / 👎.

}
documents.push({
id: reply[i++] as string,
...(score !== undefined ? {score} : {}),
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
value: (withoutDocuments ? {} : documentValue(reply[i++])) as SearchDocumentValue
});
}
Expand Down Expand Up @@ -203,9 +214,15 @@ function transformSearchReplyResp3(
);

const documents: SearchReply['documents'] = results.map(result => {
const resultMap = mapLikeToObject(result);
const { id, value } = parseSearchResultRow(result);

const rawScore = getMapValue(resultMap,['score']);
const score = rawScore !== undefined ? Number(rawScore) : undefined;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve configured double type mappings for scores

When a caller uses withTypeMapping({ [RESP_TYPES.DOUBLE]: String }), the RESP3 decoder supplies the score as a string, but this unconditional Number conversion changes it back to a number, so the newly exposed field ignores the client's requested reply mapping. Scored commands normally pass RESP3 doubles through and use transformDoubleReply[2] for RESP2; this field should follow the same pattern and use a DoubleReply-compatible type so the runtime value and inferred type remain consistent.

Useful? React with 👍 / 👎.


return {
id: String((id as { toString?(): string })?.toString?.() ?? id ?? ''),
...(score !== undefined && !isNaN(score) ? {score} : {}),
value: value as SearchDocumentValue
};
});
Expand Down Expand Up @@ -243,6 +260,7 @@ export interface SearchReply {
total: number;
documents: Array<{
id: string;
score?: number;
value: SearchDocumentValue;
}>;
/**
Expand Down