Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
import { buildIndexInfoRaw } from 'src/__mocks__/redisearch';
import { DatabaseClientFactory } from 'src/modules/database/providers/database.client.factory';
import { KeyIndexesService } from 'src/modules/browser/redisearch/key-indexes.service';
import { RedisDataType } from 'src/modules/browser/keys/dto';

const mockMovieInfoRaw = buildIndexInfoRaw({
indexName: 'idx:movie',
Expand Down Expand Up @@ -43,6 +44,15 @@ describe('KeyIndexesService', () => {
const clusterClient = mockClusterRedisClient;
let service: KeyIndexesService;

const mockKeyType = (
key: string | Buffer,
type: string = RedisDataType.Hash,
) => {
when(standaloneClient.sendCommand)
.calledWith(['TYPE', key], expect.anything())
.mockResolvedValue(type);
};

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
Expand All @@ -66,6 +76,7 @@ describe('KeyIndexesService', () => {

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

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

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

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

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

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

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

it('should deduplicate index names from cluster shards', async () => {
mockKeyType('movie:1');
when(clusterClient.sendCommand)
.calledWith(['FT._LIST'])
.mockResolvedValue([Buffer.from('idx:movie')]);
when(standaloneClient.sendCommand)
.calledWith(['FT._LIST'])
.mockResolvedValue([Buffer.from('idx:movie')]);
when(clusterClient.sendCommand)
when(standaloneClient.sendCommand)
.calledWith(['FT.INFO', 'idx:movie'], expect.anything())
.mockResolvedValue(mockMovieInfoRaw);

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

it('should handle Buffer keys', async () => {
mockKeyType(Buffer.from('movie:1'));
when(standaloneClient.sendCommand)
.calledWith(['FT._LIST'])
.mockResolvedValue([Buffer.from('idx:movie')]);
Expand All @@ -230,5 +249,50 @@ describe('KeyIndexesService', () => {
expect(result.indexes).toHaveLength(1);
expect(result.indexes[0].name).toBe('idx:movie');
});

it('should not match an index of a different key type', async () => {
mockKeyType('movie:1', RedisDataType.JSON);
when(standaloneClient.sendCommand)
.calledWith(['FT._LIST'])
.mockResolvedValue([Buffer.from('idx:movie')]);
when(standaloneClient.sendCommand)
.calledWith(['FT.INFO', 'idx:movie'], expect.anything())
.mockResolvedValue(mockMovieInfoRaw);

const result = await service.getKeyIndexes(mockBrowserClientMetadata, {
key: 'movie:1',
});

expect(result.indexes).toHaveLength(0);
});

it('should respect key type for indexes with empty prefixes', async () => {
mockKeyType('anything:here', RedisDataType.JSON);
when(standaloneClient.sendCommand)
.calledWith(['FT._LIST'])
.mockResolvedValue([Buffer.from('idx:global')]);
when(standaloneClient.sendCommand)
.calledWith(['FT.INFO', 'idx:global'], expect.anything())
.mockResolvedValue(mockGlobalInfoRaw);

const result = await service.getKeyIndexes(mockBrowserClientMetadata, {
key: 'anything:here',
});

expect(result.indexes).toHaveLength(0);
});

it('should return empty for unsupported key types without listing indexes', async () => {
mockKeyType('mylist', RedisDataType.List);

const result = await service.getKeyIndexes(mockBrowserClientMetadata, {
key: 'mylist',
});

expect(result.indexes).toHaveLength(0);
expect(standaloneClient.sendCommand).not.toHaveBeenCalledWith([
'FT._LIST',
]);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { ClientMetadata } from 'src/common/models';
import { plainToInstance } from 'class-transformer';
import { DatabaseClientFactory } from 'src/modules/database/providers/database.client.factory';
import { RedisClient } from 'src/modules/redis/client';
import { RedisDataType } from 'src/modules/browser/keys/dto';
import {
IndexInfoDto,
IndexSummaryDto,
Expand All @@ -19,14 +20,20 @@ interface IndexEntry {
info: IndexInfoDto;
}

/** TYPE command reply → key_type reported by FT.INFO index_definition */
const REDIS_TYPE_TO_INDEX_KEY_TYPE: Record<string, string> = {
[RedisDataType.Hash]: 'HASH',
[RedisDataType.JSON]: 'JSON',
};

@Injectable()
export class KeyIndexesService {
private logger = new Logger('KeyIndexesService');

constructor(private databaseClientFactory: DatabaseClientFactory) {}

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

const keyType = (await client.sendCommand(['TYPE', key], {
replyEncoding: 'utf8',
})) as string;
const indexKeyType = REDIS_TYPE_TO_INDEX_KEY_TYPE[keyType];

if (!indexKeyType) {
return plainToInstance(KeyIndexesResponse, { indexes: [] });
Comment on lines +57 to +58

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 key names that have no Redis type

When the requested key does not currently exist, TYPE returns none, so this early return skips FT._LIST/FT.INFO and reports no indexes. The existing POST /redisearch/key-indexes integration setup passes ${TEST_SEARCH_HASH_KEY_PREFIX_1}1 while generateRedisearchIndexes only creates no-prefix HASH indexes, so that endpoint now returns [] instead of TEST_SEARCH_HASH_INDEX_1 for the tested key-name lookup scenario.

Useful? React with 👍 / 👎.

}

const indexNames = await this.listIndexNames(client);
const entries = await this.fetchIndexesInfo(client, indexNames);
const matchingIndexes = this.findMatchingIndexes(keyStr, entries);
const matchingIndexes = this.findMatchingIndexes(
keyStr,
indexKeyType,
entries,
);

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

private findMatchingIndexes(
keyStr: string,
indexKeyType: string,
entries: IndexEntry[],
): IndexSummaryDto[] {
const matching: IndexSummaryDto[] = [];
Expand All @@ -107,7 +128,8 @@ export class KeyIndexesService {
const { prefixes = [], key_type: keyType = '' } = definition;

const isMatch =
prefixes.length === 0 || prefixes.some((p) => keyStr.startsWith(p));
keyType.toUpperCase() === indexKeyType &&
(prefixes.length === 0 || prefixes.some((p) => keyStr.startsWith(p)));

if (isMatch) {
matching.push(
Expand Down
Loading