Skip to content

Commit e169eca

Browse files
authored
RI-7193: Introduce useCreateIndex() (#4767)
* BE: bulk import vector index data
1 parent 4cb651a commit e169eca

File tree

15 files changed

+874
-5
lines changed

15 files changed

+874
-5
lines changed

redisinsight/api/data/vector-collections/bikes

Lines changed: 111 additions & 0 deletions
Large diffs are not rendered by default.

redisinsight/api/src/modules/bulk-actions/bulk-import.controller.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,10 @@ import { BulkImportService } from 'src/modules/bulk-actions/bulk-import.service'
1818
import { ClientMetadataParam } from 'src/common/decorators';
1919
import { ClientMetadata } from 'src/common/models';
2020
import { IBulkActionOverview } from 'src/modules/bulk-actions/interfaces/bulk-action-overview.interface';
21-
import { UploadImportFileByPathDto } from 'src/modules/bulk-actions/dto/upload-import-file-by-path.dto';
21+
import {
22+
UploadImportFileByPathDto,
23+
ImportVectorCollectionDto,
24+
} from 'src/modules/bulk-actions/dto/upload-import-file-by-path.dto';
2225

2326
@UsePipes(new ValidationPipe({ transform: true }))
2427
@UseInterceptors(ClassSerializerInterceptor)
@@ -85,4 +88,21 @@ export class BulkImportController {
8588
): Promise<IBulkActionOverview> {
8689
return this.service.importDefaultData(clientMetadata);
8790
}
91+
92+
@Post('/vector-collection')
93+
@HttpCode(200)
94+
@ApiEndpoint({
95+
description: 'Import vector collection data',
96+
responses: [
97+
{
98+
type: Object,
99+
},
100+
],
101+
})
102+
async importVectorCollection(
103+
@Body() dto: ImportVectorCollectionDto,
104+
@ClientMetadataParam() clientMetadata: ClientMetadata,
105+
): Promise<IBulkActionOverview> {
106+
return this.service.importVectorCollection(clientMetadata, dto);
107+
}
88108
}

redisinsight/api/src/modules/bulk-actions/bulk-import.service.spec.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Test, TestingModule } from '@nestjs/testing';
22
import { BulkImportService } from 'src/modules/bulk-actions/bulk-import.service';
33
import {
44
mockBulkActionsAnalytics,
5+
mockBulkActionOverviewMatcher,
56
mockClientMetadata,
67
mockClusterRedisClient,
78
mockCombinedStream,
@@ -541,4 +542,81 @@ describe('BulkImportService', () => {
541542
}
542543
});
543544
});
545+
546+
describe('importVectorCollection', () => {
547+
afterEach(() => {
548+
jest.clearAllMocks();
549+
});
550+
551+
beforeEach(() => {
552+
(mockedFs.pathExists as jest.Mock).mockReset();
553+
(mockedFs.createReadStream as jest.Mock).mockReset();
554+
});
555+
556+
it('should import vector collection successfully', async () => {
557+
const spy = jest.spyOn(service, 'import');
558+
spy.mockResolvedValue(mockBulkActionOverviewMatcher);
559+
560+
(mockedFs.pathExists as jest.Mock).mockResolvedValue(true);
561+
(mockedFs.createReadStream as jest.Mock).mockReturnValue(new Readable());
562+
563+
const result = await service.importVectorCollection(mockClientMetadata, {
564+
collectionName: 'bikes',
565+
});
566+
567+
expect(mockedFs.pathExists).toHaveBeenCalledWith(
568+
expect.stringContaining('vector-collections/bikes'),
569+
);
570+
expect(mockedFs.createReadStream).toHaveBeenCalledWith(
571+
expect.stringContaining('vector-collections/bikes'),
572+
);
573+
expect(spy).toHaveBeenCalledWith(
574+
mockClientMetadata,
575+
expect.any(Readable),
576+
);
577+
expect(result).toEqual(mockBulkActionOverviewMatcher);
578+
});
579+
580+
it('should throw BadRequestException when collectionName file does not exist', async () => {
581+
(mockedFs.pathExists as jest.Mock).mockResolvedValue(false);
582+
583+
await expect(
584+
service.importVectorCollection(mockClientMetadata, {
585+
collectionName: 'bikes',
586+
}),
587+
).rejects.toThrow('No data file found for collection: bikes');
588+
589+
expect(mockedFs.pathExists).toHaveBeenCalledWith(
590+
expect.stringContaining('vector-collections/bikes'),
591+
);
592+
});
593+
594+
it('should handle import errors', async () => {
595+
const spy = jest.spyOn(service, 'import');
596+
const importError = new Error('Import failed');
597+
spy.mockRejectedValue(importError);
598+
599+
(mockedFs.pathExists as jest.Mock).mockResolvedValue(true);
600+
(mockedFs.createReadStream as jest.Mock).mockReturnValue(new Readable());
601+
602+
await expect(
603+
service.importVectorCollection(mockClientMetadata, {
604+
collectionName: 'bikes',
605+
}),
606+
).rejects.toThrow('Import failed');
607+
608+
expect(spy).toHaveBeenCalledWith(
609+
mockClientMetadata,
610+
expect.any(Readable),
611+
);
612+
});
613+
614+
it('should throw BadRequestException when collectionName is not in allowed list', async () => {
615+
await expect(
616+
service.importVectorCollection(mockClientMetadata, {
617+
collectionName: '../../etc/passwd', // malicious input
618+
}),
619+
).rejects.toThrow('Invalid collection name');
620+
});
621+
});
544622
});

redisinsight/api/src/modules/bulk-actions/bulk-import.service.ts

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,10 @@ import {
1919
BulkActionType,
2020
} from 'src/modules/bulk-actions/constants';
2121
import { BulkActionsAnalytics } from 'src/modules/bulk-actions/bulk-actions.analytics';
22-
import { UploadImportFileByPathDto } from 'src/modules/bulk-actions/dto/upload-import-file-by-path.dto';
22+
import {
23+
UploadImportFileByPathDto,
24+
ImportVectorCollectionDto,
25+
} from 'src/modules/bulk-actions/dto/upload-import-file-by-path.dto';
2326
import {
2427
RedisClient,
2528
RedisClientCommand,
@@ -34,6 +37,8 @@ const BATCH_LIMIT = 10_000;
3437
const PATH_CONFIG = config.get('dir_path') as Config['dir_path'];
3538
const SERVER_CONFIG = config.get('server') as Config['server'];
3639

40+
const ALLOWED_VECTOR_INDEX_COLLECTIONS = ['bikes'];
41+
3742
@Injectable()
3843
export class BulkImportService {
3944
private logger = new Logger('BulkImportService');
@@ -280,4 +285,42 @@ export class BulkImportService {
280285
);
281286
}
282287
}
288+
289+
/**
290+
* Import vector collection data
291+
* @param clientMetadata
292+
* @param dto
293+
*/
294+
public async importVectorCollection(
295+
clientMetadata: ClientMetadata,
296+
dto: ImportVectorCollectionDto,
297+
): Promise<IBulkActionOverview> {
298+
try {
299+
if (!ALLOWED_VECTOR_INDEX_COLLECTIONS.includes(dto.collectionName)) {
300+
throw new BadRequestException('Invalid collection name');
301+
}
302+
303+
const collectionFilePath = join(
304+
PATH_CONFIG.dataDir,
305+
'vector-collections',
306+
dto.collectionName,
307+
);
308+
309+
if (!(await fs.pathExists(collectionFilePath))) {
310+
throw new BadRequestException(
311+
`No data file found for collection: ${dto.collectionName}`,
312+
);
313+
}
314+
315+
const fileStream = fs.createReadStream(collectionFilePath);
316+
return this.import(clientMetadata, fileStream);
317+
} catch (e) {
318+
this.logger.error(
319+
'Unable to import vector collection data',
320+
e,
321+
clientMetadata,
322+
);
323+
throw wrapHttpError(e);
324+
}
325+
}
283326
}

redisinsight/api/src/modules/bulk-actions/dto/upload-import-file-by-path.dto.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,14 @@ export class UploadImportFileByPathDto {
1010
@IsNotEmpty()
1111
path: string;
1212
}
13+
14+
export class ImportVectorCollectionDto {
15+
@ApiProperty({
16+
type: 'string',
17+
description: 'Collection name to load vector data',
18+
example: 'bikes',
19+
})
20+
@IsString()
21+
@IsNotEmpty()
22+
collectionName: string;
23+
}

redisinsight/ui/src/constants/api.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ enum ApiEndpoints {
1313
BULK_ACTIONS_IMPORT = 'bulk-actions/import',
1414
BULK_ACTIONS_IMPORT_DEFAULT_DATA = 'bulk-actions/import/default-data',
1515
BULK_ACTIONS_IMPORT_TUTORIAL_DATA = 'bulk-actions/import/tutorial-data',
16+
BULK_ACTIONS_IMPORT_VECTOR_COLLECTION = 'bulk-actions/import/vector-collection',
1617

1718
CA_CERTIFICATES = 'certificates/ca',
1819
CLIENT_CERTIFICATES = 'certificates/client',

redisinsight/ui/src/pages/vector-search/create-index/VectorSearchCreateIndex.tsx

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
SampleDataType,
1919
SearchIndexType,
2020
} from './types'
21+
import { useCreateIndex } from './hooks/useCreateIndex'
2122

2223
const stepNextButtonTexts = [
2324
'Proceed to adding data',
@@ -45,6 +46,8 @@ export const VectorSearchCreateIndex = ({
4546
tags: [],
4647
})
4748

49+
const { run: createIndex, success, loading } = useCreateIndex()
50+
4851
const setParameters = (params: Partial<CreateSearchIndexParameters>) => {
4952
setCreateSearchIndexParameters((prev) => ({ ...prev, ...params }))
5053
}
@@ -53,9 +56,7 @@ export const VectorSearchCreateIndex = ({
5356
const onNextClick = () => {
5457
const isFinalStep = step === stepContents.length - 1
5558
if (isFinalStep) {
56-
alert(
57-
`TODO: trigger index creation for params: ${JSON.stringify(createSearchIndexParameters)}`,
58-
)
59+
createIndex(createSearchIndexParameters)
5960
return
6061
}
6162

@@ -65,6 +66,14 @@ export const VectorSearchCreateIndex = ({
6566
setStep(step - 1)
6667
}
6768

69+
if (loading) {
70+
return <>Loading...</>
71+
}
72+
73+
if (success) {
74+
return <>Success!</>
75+
}
76+
6877
return (
6978
<CreateIndexWrapper direction="column" justify="between">
7079
<CreateIndexHeader direction="row">
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import { renderHook, act } from '@testing-library/react-hooks'
2+
import { useCreateIndex } from './useCreateIndex'
3+
import {
4+
CreateSearchIndexParameters,
5+
SampleDataType,
6+
SearchIndexType,
7+
} from '../types'
8+
9+
const mockLoad = jest.fn()
10+
const mockDispatch = jest.fn()
11+
12+
jest.mock('uiSrc/services/hooks', () => ({
13+
useLoadData: () => ({
14+
load: mockLoad,
15+
}),
16+
useDispatchWbQuery: () => mockDispatch,
17+
}))
18+
19+
jest.mock('uiSrc/utils/index/generateFtCreateCommand', () => ({
20+
generateFtCreateCommand: () => 'FT.CREATE idx:bikes_vss ...',
21+
}))
22+
23+
describe('useCreateIndex', () => {
24+
beforeEach(() => {
25+
jest.clearAllMocks()
26+
})
27+
28+
const defaultParams: CreateSearchIndexParameters = {
29+
dataContent: '',
30+
usePresetVectorIndex: true,
31+
presetVectorIndexName: '',
32+
tags: [],
33+
instanceId: 'test-instance-id',
34+
searchIndexType: SearchIndexType.REDIS_QUERY_ENGINE,
35+
sampleDataType: SampleDataType.PRESET_DATA,
36+
}
37+
38+
it('should complete flow successfully', async () => {
39+
mockLoad.mockResolvedValue(undefined)
40+
mockDispatch.mockImplementation((_data, { afterAll }) => afterAll?.())
41+
42+
const { result } = renderHook(() => useCreateIndex())
43+
44+
await act(async () => {
45+
await result.current.run(defaultParams)
46+
})
47+
48+
expect(mockLoad).toHaveBeenCalledWith('test-instance-id', 'bikes')
49+
expect(mockDispatch).toHaveBeenCalled()
50+
expect(result.current.success).toBe(true)
51+
expect(result.current.error).toBeNull()
52+
expect(result.current.loading).toBe(false)
53+
})
54+
55+
it('should handle error if instanceId is missing', async () => {
56+
const { result } = renderHook(() => useCreateIndex())
57+
58+
await act(async () => {
59+
await result.current.run({ ...defaultParams, instanceId: '' })
60+
})
61+
62+
expect(result.current.success).toBe(false)
63+
expect(result.current.error?.message).toMatch(/Instance ID is required/)
64+
expect(result.current.loading).toBe(false)
65+
})
66+
67+
it('should handle failure in data loading', async () => {
68+
const error = new Error('Failed to load')
69+
mockLoad.mockRejectedValue(error)
70+
71+
const { result } = renderHook(() => useCreateIndex())
72+
73+
await act(async () => {
74+
await result.current.run(defaultParams)
75+
})
76+
77+
expect(mockLoad).toHaveBeenCalled()
78+
expect(result.current.success).toBe(false)
79+
expect(result.current.error).toBe(error)
80+
expect(result.current.loading).toBe(false)
81+
})
82+
83+
it('should handle dispatch failure', async () => {
84+
mockLoad.mockResolvedValue(undefined)
85+
mockDispatch.mockImplementation((_data, { onFail }) =>
86+
onFail?.(new Error('Dispatch failed')),
87+
)
88+
89+
const { result } = renderHook(() => useCreateIndex())
90+
91+
await act(async () => {
92+
await result.current.run(defaultParams)
93+
})
94+
95+
expect(mockDispatch).toHaveBeenCalled()
96+
expect(result.current.success).toBe(false)
97+
expect(result.current.error).toBeInstanceOf(Error)
98+
expect(result.current.error?.message).toBe('Dispatch failed')
99+
expect(result.current.loading).toBe(false)
100+
})
101+
})

0 commit comments

Comments
 (0)