Skip to content

Commit 48eaa9d

Browse files
dantovskaclaude
andcommitted
fix(api): match key type when finding indexes covering a key
The key-indexes endpoint matched indexes by prefix only, so a JSON key matched FT.CREATE ... ON HASH indexes with the same prefix and the key details panel wrongly offered "View index". Resolve the key's type via TYPE and require it to match the index definition's key_type; keys of unsearchable types return no indexes. Fixes #RI-8316 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent ab9ad28 commit 48eaa9d

2 files changed

Lines changed: 90 additions & 4 deletions

File tree

redisinsight/api/src/modules/browser/redisearch/key-indexes.service.spec.ts

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
import { buildIndexInfoRaw } from 'src/__mocks__/redisearch';
1212
import { DatabaseClientFactory } from 'src/modules/database/providers/database.client.factory';
1313
import { KeyIndexesService } from 'src/modules/browser/redisearch/key-indexes.service';
14+
import { RedisDataType } from 'src/modules/browser/keys/dto';
1415

1516
const mockMovieInfoRaw = buildIndexInfoRaw({
1617
indexName: 'idx:movie',
@@ -43,6 +44,15 @@ describe('KeyIndexesService', () => {
4344
const clusterClient = mockClusterRedisClient;
4445
let service: KeyIndexesService;
4546

47+
const mockKeyType = (
48+
key: string | Buffer,
49+
type: string = RedisDataType.Hash,
50+
) => {
51+
when(standaloneClient.sendCommand)
52+
.calledWith(['TYPE', key], expect.anything())
53+
.mockResolvedValue(type);
54+
};
55+
4656
beforeEach(async () => {
4757
const module: TestingModule = await Test.createTestingModule({
4858
providers: [
@@ -66,6 +76,7 @@ describe('KeyIndexesService', () => {
6676

6777
describe('getKeyIndexes', () => {
6878
it('should return matching index when key matches a prefix', async () => {
79+
mockKeyType('movie:1');
6980
when(standaloneClient.sendCommand)
7081
.calledWith(['FT._LIST'])
7182
.mockResolvedValue([Buffer.from('idx:movie')]);
@@ -84,6 +95,7 @@ describe('KeyIndexesService', () => {
8495
});
8596

8697
it('should return empty array when key matches no prefix', async () => {
98+
mockKeyType('session:abc');
8799
when(standaloneClient.sendCommand)
88100
.calledWith(['FT._LIST'])
89101
.mockResolvedValue([Buffer.from('idx:movie')]);
@@ -99,6 +111,7 @@ describe('KeyIndexesService', () => {
99111
});
100112

101113
it('should return multiple indexes when key matches several', async () => {
114+
mockKeyType('user:42');
102115
when(standaloneClient.sendCommand)
103116
.calledWith(['FT._LIST'])
104117
.mockResolvedValue([
@@ -123,6 +136,7 @@ describe('KeyIndexesService', () => {
123136
});
124137

125138
it('should match index with empty prefixes to any key', async () => {
139+
mockKeyType('anything:here');
126140
when(standaloneClient.sendCommand)
127141
.calledWith(['FT._LIST'])
128142
.mockResolvedValue([Buffer.from('idx:global')]);
@@ -139,6 +153,7 @@ describe('KeyIndexesService', () => {
139153
});
140154

141155
it('should match key against index with multiple prefixes', async () => {
156+
mockKeyType('item:99', RedisDataType.JSON);
142157
when(standaloneClient.sendCommand)
143158
.calledWith(['FT._LIST'])
144159
.mockResolvedValue([Buffer.from('idx:multi')]);
@@ -156,6 +171,7 @@ describe('KeyIndexesService', () => {
156171
});
157172

158173
it('should return empty when no indexes exist', async () => {
174+
mockKeyType('movie:1');
159175
when(standaloneClient.sendCommand)
160176
.calledWith(['FT._LIST'])
161177
.mockResolvedValue([]);
@@ -168,6 +184,7 @@ describe('KeyIndexesService', () => {
168184
});
169185

170186
it('should skip indexes whose FT.INFO fails', async () => {
187+
mockKeyType('movie:1');
171188
when(standaloneClient.sendCommand)
172189
.calledWith(['FT._LIST'])
173190
.mockResolvedValue([
@@ -190,13 +207,14 @@ describe('KeyIndexesService', () => {
190207
});
191208

192209
it('should deduplicate index names from cluster shards', async () => {
210+
mockKeyType('movie:1');
193211
when(clusterClient.sendCommand)
194212
.calledWith(['FT._LIST'])
195213
.mockResolvedValue([Buffer.from('idx:movie')]);
196214
when(standaloneClient.sendCommand)
197215
.calledWith(['FT._LIST'])
198216
.mockResolvedValue([Buffer.from('idx:movie')]);
199-
when(clusterClient.sendCommand)
217+
when(standaloneClient.sendCommand)
200218
.calledWith(['FT.INFO', 'idx:movie'], expect.anything())
201219
.mockResolvedValue(mockMovieInfoRaw);
202220

@@ -216,6 +234,7 @@ describe('KeyIndexesService', () => {
216234
});
217235

218236
it('should handle Buffer keys', async () => {
237+
mockKeyType(Buffer.from('movie:1'));
219238
when(standaloneClient.sendCommand)
220239
.calledWith(['FT._LIST'])
221240
.mockResolvedValue([Buffer.from('idx:movie')]);
@@ -230,5 +249,50 @@ describe('KeyIndexesService', () => {
230249
expect(result.indexes).toHaveLength(1);
231250
expect(result.indexes[0].name).toBe('idx:movie');
232251
});
252+
253+
it('should not match an index of a different key type', async () => {
254+
mockKeyType('movie:1', RedisDataType.JSON);
255+
when(standaloneClient.sendCommand)
256+
.calledWith(['FT._LIST'])
257+
.mockResolvedValue([Buffer.from('idx:movie')]);
258+
when(standaloneClient.sendCommand)
259+
.calledWith(['FT.INFO', 'idx:movie'], expect.anything())
260+
.mockResolvedValue(mockMovieInfoRaw);
261+
262+
const result = await service.getKeyIndexes(mockBrowserClientMetadata, {
263+
key: 'movie:1',
264+
});
265+
266+
expect(result.indexes).toHaveLength(0);
267+
});
268+
269+
it('should respect key type for indexes with empty prefixes', async () => {
270+
mockKeyType('anything:here', RedisDataType.JSON);
271+
when(standaloneClient.sendCommand)
272+
.calledWith(['FT._LIST'])
273+
.mockResolvedValue([Buffer.from('idx:global')]);
274+
when(standaloneClient.sendCommand)
275+
.calledWith(['FT.INFO', 'idx:global'], expect.anything())
276+
.mockResolvedValue(mockGlobalInfoRaw);
277+
278+
const result = await service.getKeyIndexes(mockBrowserClientMetadata, {
279+
key: 'anything:here',
280+
});
281+
282+
expect(result.indexes).toHaveLength(0);
283+
});
284+
285+
it('should return empty for unsupported key types without listing indexes', async () => {
286+
mockKeyType('mylist', RedisDataType.List);
287+
288+
const result = await service.getKeyIndexes(mockBrowserClientMetadata, {
289+
key: 'mylist',
290+
});
291+
292+
expect(result.indexes).toHaveLength(0);
293+
expect(standaloneClient.sendCommand).not.toHaveBeenCalledWith([
294+
'FT._LIST',
295+
]);
296+
});
233297
});
234298
});

redisinsight/api/src/modules/browser/redisearch/key-indexes.service.ts

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { ClientMetadata } from 'src/common/models';
55
import { plainToInstance } from 'class-transformer';
66
import { DatabaseClientFactory } from 'src/modules/database/providers/database.client.factory';
77
import { RedisClient } from 'src/modules/redis/client';
8+
import { RedisDataType } from 'src/modules/browser/keys/dto';
89
import {
910
IndexInfoDto,
1011
IndexSummaryDto,
@@ -19,14 +20,20 @@ interface IndexEntry {
1920
info: IndexInfoDto;
2021
}
2122

23+
/** TYPE command reply → key_type reported by FT.INFO index_definition */
24+
const REDIS_TYPE_TO_INDEX_KEY_TYPE: Record<string, string> = {
25+
[RedisDataType.Hash]: 'HASH',
26+
[RedisDataType.JSON]: 'JSON',
27+
};
28+
2229
@Injectable()
2330
export class KeyIndexesService {
2431
private logger = new Logger('KeyIndexesService');
2532

2633
constructor(private databaseClientFactory: DatabaseClientFactory) {}
2734

2835
/**
29-
* Find all indexes whose prefixes cover the given key.
36+
* Find all indexes whose key_type and prefixes cover the given key.
3037
* An index with no prefixes matches all keys of its key_type.
3138
*/
3239
public async getKeyIndexes(
@@ -42,9 +49,22 @@ export class KeyIndexesService {
4249
const client: RedisClient =
4350
await this.databaseClientFactory.getOrCreateClient(clientMetadata);
4451

52+
const keyType = (await client.sendCommand(['TYPE', key], {
53+
replyEncoding: 'utf8',
54+
})) as string;
55+
const indexKeyType = REDIS_TYPE_TO_INDEX_KEY_TYPE[keyType];
56+
57+
if (!indexKeyType) {
58+
return plainToInstance(KeyIndexesResponse, { indexes: [] });
59+
}
60+
4561
const indexNames = await this.listIndexNames(client);
4662
const entries = await this.fetchIndexesInfo(client, indexNames);
47-
const matchingIndexes = this.findMatchingIndexes(keyStr, entries);
63+
const matchingIndexes = this.findMatchingIndexes(
64+
keyStr,
65+
indexKeyType,
66+
entries,
67+
);
4868

4969
return plainToInstance(KeyIndexesResponse, { indexes: matchingIndexes });
5070
} catch (e) {
@@ -93,6 +113,7 @@ export class KeyIndexesService {
93113

94114
private findMatchingIndexes(
95115
keyStr: string,
116+
indexKeyType: string,
96117
entries: IndexEntry[],
97118
): IndexSummaryDto[] {
98119
const matching: IndexSummaryDto[] = [];
@@ -107,7 +128,8 @@ export class KeyIndexesService {
107128
const { prefixes = [], key_type: keyType = '' } = definition;
108129

109130
const isMatch =
110-
prefixes.length === 0 || prefixes.some((p) => keyStr.startsWith(p));
131+
keyType.toUpperCase() === indexKeyType &&
132+
(prefixes.length === 0 || prefixes.some((p) => keyStr.startsWith(p)));
111133

112134
if (isMatch) {
113135
matching.push(

0 commit comments

Comments
 (0)