diff --git a/common/constants/data_sources.ts b/common/constants/data_sources.ts index 7bfc5eee46..63ff345ec2 100644 --- a/common/constants/data_sources.ts +++ b/common/constants/data_sources.ts @@ -24,3 +24,53 @@ export enum DATA_SOURCE_TYPES { export const ASYNC_POLLING_INTERVAL = 2000; export const CATALOG_CACHE_VERSION = '1.0'; +export const ACCELERATION_DEFUALT_SKIPPING_INDEX_NAME = 'skipping'; +export const ACCELERATION_TIME_INTERVAL = [ + { text: 'millisecond(s)', value: 'millisecond' }, + { text: 'second(s)', value: 'second' }, + { text: 'minutes(s)', value: 'minute' }, + { text: 'hour(s)', value: 'hour' }, + { text: 'day(s)', value: 'day' }, + { text: 'week(s)', value: 'week' }, +]; + +export const ACCELERATION_ADD_FIELDS_TEXT = '(add fields here)'; +export const ACCELERATION_INDEX_NAME_REGEX = /^[a-z][a-z_]*$/; +export const ACCELERATION_S3_URL_REGEX = /^(s3|s3a):\/\/[a-zA-Z0-9.\-]+/; + +export const ACCELERATION_INDEX_TYPES = [ + { label: 'Skipping Index', value: 'skipping' }, + { label: 'Covering Index', value: 'covering' }, + { label: 'Materialized View', value: 'materialized' }, +]; + +export const ACC_INDEX_TYPE_DOCUMENTATION_URL = + 'https://github.com/opensearch-project/opensearch-spark/blob/main/docs/index.md'; + +export const ACCELERATION_INDEX_NAME_INFO = `All OpenSearch acceleration indices have a naming format of pattern: \`prefix__suffix\`. They share a common prefix structure, which is \`flint____\`. Additionally, they may have a suffix that varies based on the index type. +##### Skipping Index +- For 'Skipping' indices, a fixed index name 'skipping' is used, and this name cannot be modified by the user. The suffix added to this type is \`_index\`. + - An example of a 'Skipping' index name would be: \`flint_mydatasource_mydb_mytable_skipping_index\`. +##### Covering Index +- 'Covering' indices allow users to specify their index name. The suffix added to this type is \`_index\`. + - For instance, a 'Covering' index name could be: \`flint_mydatasource_mydb_mytable_myindexname_index\`. +##### Materialized View Index +- 'Materialized View' indices also enable users to define their index name, but they do not have a suffix. + - An example of a 'Materialized View' index name might look like: \`flint_mydatasource_mydb_mytable_myindexname\`. +##### Note: +- All user given index names must be in lowercase letters. Index name cannot begin with underscores. Spaces, commas, and characters -, :, ", *, +, /, \, |, ?, #, >, or < are not allowed. + `; + +export const SKIPPING_INDEX_ACCELERATION_METHODS = [ + { value: 'PARTITION', text: 'Partition' }, + { value: 'VALUE_SET', text: 'Value Set' }, + { value: 'MIN_MAX', text: 'Min Max' }, +]; + +export const ACCELERATION_AGGREGRATION_FUNCTIONS = [ + { label: 'count' }, + { label: 'sum' }, + { label: 'avg' }, + { label: 'max' }, + { label: 'min' }, +]; diff --git a/common/types/data_connections.ts b/common/types/data_connections.ts index be1f663c3b..ebe48afc59 100644 --- a/common/types/data_connections.ts +++ b/common/types/data_connections.ts @@ -128,3 +128,103 @@ export interface PollingSuccessResult { } export type AsyncPollingResult = PollingSuccessResult | null; + +export interface CreateAccelerationForm { + dataSource: string; + database: string; + dataTable: string; + dataTableFields: DataTableFieldsType[]; + accelerationIndexType: AccelerationIndexType; + skippingIndexQueryData: SkippingIndexRowType[]; + coveringIndexQueryData: string[]; + materializedViewQueryData: MaterializedViewQueryType; + accelerationIndexName: string; + primaryShardsCount: number; + replicaShardsCount: number; + refreshType: AccelerationRefreshType; + checkpointLocation: string | undefined; + watermarkDelay: WatermarkDelayType; + refreshIntervalOptions: RefreshIntervalType; + formErrors: FormErrorsType; +} + +export type AggregationFunctionType = 'count' | 'sum' | 'avg' | 'max' | 'min'; + +export interface MaterializedViewColumn { + id: string; + functionName: AggregationFunctionType; + functionParam: string; + fieldAlias?: string; +} + +export type SkippingIndexAccMethodType = 'PARTITION' | 'VALUE_SET' | 'MIN_MAX'; + +export interface SkippingIndexRowType { + id: string; + fieldName: string; + dataType: string; + accelerationMethod: SkippingIndexAccMethodType; +} + +export interface DataTableFieldsType { + id: string; + fieldName: string; + dataType: string; +} + +export interface RefreshIntervalType { + refreshWindow: number; + refreshInterval: string; +} + +export interface WatermarkDelayType { + delayWindow: number; + delayInterval: string; +} + +export interface GroupByTumbleType { + timeField: string; + tumbleWindow: number; + tumbleInterval: string; +} + +export interface MaterializedViewQueryType { + columnsValues: MaterializedViewColumn[]; + groupByTumbleValue: GroupByTumbleType; +} + +export interface FormErrorsType { + dataSourceError: string[]; + databaseError: string[]; + dataTableError: string[]; + skippingIndexError: string[]; + coveringIndexError: string[]; + materializedViewError: string[]; + indexNameError: string[]; + primaryShardsError: string[]; + replicaShardsError: string[]; + refreshIntervalError: string[]; + checkpointLocationError: string[]; + watermarkDelayError: string[]; +} + +export type AccelerationRefreshType = 'auto' | 'interval' | 'manual'; + +export interface CreateAccelerationForm { + dataSource: string; + database: string; + dataTable: string; + dataTableFields: DataTableFieldsType[]; + accelerationIndexType: AccelerationIndexType; + skippingIndexQueryData: SkippingIndexRowType[]; + coveringIndexQueryData: string[]; + materializedViewQueryData: MaterializedViewQueryType; + accelerationIndexName: string; + primaryShardsCount: number; + replicaShardsCount: number; + refreshType: AccelerationRefreshType; + checkpointLocation: string | undefined; + watermarkDelay: WatermarkDelayType; + refreshIntervalOptions: RefreshIntervalType; + formErrors: FormErrorsType; +} diff --git a/public/components/datasources/components/manage/accelerations/create/__tests__/__snapshots__/create_acceleration.test.tsx.snap b/public/components/datasources/components/manage/accelerations/create/__tests__/__snapshots__/create_acceleration.test.tsx.snap new file mode 100644 index 0000000000..951139f2f0 --- /dev/null +++ b/public/components/datasources/components/manage/accelerations/create/__tests__/__snapshots__/create_acceleration.test.tsx.snap @@ -0,0 +1,2519 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Create acceleration flyout components renders acceleration flyout component with default options 1`] = ` +Array [ + "", + +
+ + + + + + + + + + + + + +
+
+ + + Field name + + + + + + Datatype + + + + + + Acceleration method + + + + + + Delete + + +
+
+ + No items found + +
+
+ + +
+
+ +
+
+ +
+
+ + + + +
+
+
+ +
+
+ +
+
+
+ + + + } + > + Array [ + Array [ +
, + "", +
+
+ +
+
+
+
+

+ Accelerate data +

+
+
+
+
+
+ Create OpenSearch Indexes from external data connections for better performance. + + + Learn more + + + + + (opens in a new tab or window) + + +
+
+
+
+
+
+
+
+
+ Array [ +
+

+ Select data source +

+
, +
, +
+
+ Select the data source to accelerate data from. External data sources may take time to load. +
+
, +
, +
+
+ +
+
+
+
+
+
+

+ Select a data source +

+
+ +
+
+
+
+ +
+
+
+
+
+ A data source has to be configured and active to be able to select it and index data from. +
+
+
, +
+
+ +
+
+
+
+
+
+

+ Select a database +

+
+ +
+
+
+
+ +
+
+
+
+
+ Select the database that contains the tables you'd like to use. +
+
+
, +
+
+ +
+
+
+
+
+
+

+ Select a table +

+
+ +
+
+
+
+ +
+
+
+
+
+ Select the Spark table that has the data you would like to index. +
+
+
, + ] +
+ Array [ +
+

+ Index settings +

+
, +
, +
+ +
+
+
+
+
+ + Skipping Index + +
+ +
+
+
+
+ +
+
+
+
+
+ Select the type of index you want to create. Each index type has benefits and costs. +
+
+
, +
+
+ +
+
+
+
+ +
+
+
+ Specify the number of primary shards for the index. The number of primary shards cannot be changed after the index is created. +
+
+
, +
+
+ +
+
+
+
+ +
+
+
+ Specify the number of replicas each primary shard should have. +
+
+
, +
+
+ +
+
+
+
+ +
+ +
+
+ +
+ +
+
+ +
+ +
+
+
+ Specify how often the index should refresh, which publishes the most recent changes and make them available for search. +
+
+
, +
+
+ +
+
+
+
+ +
+
+
+ The HDFS compatible file system location path for incremental refresh job checkpoint. +
+
+
, + ] +
+ Array [ +
+

+ Index settings +

+
, +
, +
+
+ + +
+ +
+
+
+
+ + + + + + +
+ +
+ +
+
+ Must be in lowercase letters. Cannot begin with underscores. Spaces, commas, and characters -, :, ", *, +, /, \\, |, ?, #, >, or < are not allowed. Prefix and suffix are added to the name of generated OpenSearch index. +
+
+
, + "", + ] +
+ Array [ +
, + Array [ +
+

+ Skipping index definition +

+
, +
, +
+
+
+
+
+
+
+
+ + + + + + + + + + + + + + +
+
+ + + Field name + + + + + + Datatype + + + + + + Acceleration method + + + + + + Delete + + +
+
+ + No items found + +
+
+
+
, +
+
+ +
+
+ +
+
, + ], + ] +
+
+
+
+
+
+
+ +
+
+ +
+
+
+
+
, +
, + ], + "", + ] + , +] +`; diff --git a/public/components/datasources/components/manage/accelerations/create/__tests__/__snapshots__/create_acceleration_header.test.tsx.snap b/public/components/datasources/components/manage/accelerations/create/__tests__/__snapshots__/create_acceleration_header.test.tsx.snap new file mode 100644 index 0000000000..dd53edbac0 --- /dev/null +++ b/public/components/datasources/components/manage/accelerations/create/__tests__/__snapshots__/create_acceleration_header.test.tsx.snap @@ -0,0 +1,62 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Acceleration header renders acceleration flyout header 1`] = ` +
+
+
+

+ Accelerate data +

+
+
+
+
+
+ Create OpenSearch Indexes from external data connections for better performance. + + + Learn more + + + + + (opens in a new tab or window) + + +
+
+
+`; diff --git a/public/components/datasources/components/manage/accelerations/create/__tests__/create_acceleration.test.tsx b/public/components/datasources/components/manage/accelerations/create/__tests__/create_acceleration.test.tsx new file mode 100644 index 0000000000..49cd168a3f --- /dev/null +++ b/public/components/datasources/components/manage/accelerations/create/__tests__/create_acceleration.test.tsx @@ -0,0 +1,46 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { EuiComboBoxOptionOption } from '@elastic/eui'; +import { waitFor } from '@testing-library/dom'; +import { configure, mount } from 'enzyme'; +import Adapter from 'enzyme-adapter-react-16'; +import toJson from 'enzyme-to-json'; +import React from 'react'; +import { coreMock } from '../../../../../../../../../../src/core/public/mocks'; +import { mockDatasourcesQuery } from '../../../../../../../../test/accelerations'; +import { CreateAcceleration } from '../create_acceleration'; + +const coreStartMock = coreMock.createStart(); + +describe('Create acceleration flyout components', () => { + configure({ adapter: new Adapter() }); + + it('renders acceleration flyout component with default options', async () => { + const selectedDatasource: EuiComboBoxOptionOption[] = []; + const resetFlyout = jest.fn(); + const updateQueries = jest.fn(); + const client = coreStartMock.http; + client.get = jest.fn().mockResolvedValue(mockDatasourcesQuery); + + const wrapper = mount( + + ); + wrapper.update(); + await waitFor(() => { + expect( + toJson(wrapper, { + noKey: false, + mode: 'deep', + }) + ).toMatchSnapshot(); + }); + }); +}); diff --git a/public/components/datasources/components/manage/accelerations/create/__tests__/create_acceleration_header.test.tsx b/public/components/datasources/components/manage/accelerations/create/__tests__/create_acceleration_header.test.tsx new file mode 100644 index 0000000000..8f2c42a142 --- /dev/null +++ b/public/components/datasources/components/manage/accelerations/create/__tests__/create_acceleration_header.test.tsx @@ -0,0 +1,28 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { waitFor } from '@testing-library/dom'; +import { configure, mount } from 'enzyme'; +import Adapter from 'enzyme-adapter-react-16'; +import toJson from 'enzyme-to-json'; +import React from 'react'; +import { CreateAccelerationHeader } from '../create_acceleration_header'; + +describe('Acceleration header', () => { + configure({ adapter: new Adapter() }); + + it('renders acceleration flyout header', async () => { + const wrapper = mount(); + wrapper.update(); + await waitFor(() => { + expect( + toJson(wrapper, { + noKey: false, + mode: 'deep', + }) + ).toMatchSnapshot(); + }); + }); +}); diff --git a/public/components/datasources/components/manage/accelerations/create/__tests__/utils.test.tsx b/public/components/datasources/components/manage/accelerations/create/__tests__/utils.test.tsx new file mode 100644 index 0000000000..17f073c0e6 --- /dev/null +++ b/public/components/datasources/components/manage/accelerations/create/__tests__/utils.test.tsx @@ -0,0 +1,261 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + ACCELERATION_INDEX_NAME_REGEX, + ACCELERATION_S3_URL_REGEX, +} from '../../../../../../../../common/constants/data_sources'; +import { + coveringIndexDataMock, + materializedViewEmptyDataMock, + materializedViewEmptyTumbleDataMock, + materializedViewStaleDataMock, + materializedViewValidDataMock, + skippingIndexDataMock, +} from '../../../../../../../../test/accelerations'; +import { + pluralizeTime, + validateCheckpointLocation, + validateCoveringIndexData, + validateDataSource, + validateDataTable, + validateDatabase, + validateIndexName, + validateMaterializedViewData, + validatePrimaryShardCount, + validateRefreshInterval, + validateReplicaCount, + validateSkippingIndexData, +} from '../utils'; + +describe('pluralizeTime', () => { + it('should return "s" for a time window greater than 1', () => { + expect(pluralizeTime(2)).toBe('s'); + expect(pluralizeTime(10)).toBe('s'); + expect(pluralizeTime(100)).toBe('s'); + }); + + it('should return an empty string for a time window of 1/0', () => { + expect(pluralizeTime(1)).toBe(''); + expect(pluralizeTime(0)).toBe(''); // form throws validation error, doesn't allow user to proceed + }); +}); + +describe('validateDataSource', () => { + it('should return an array with an error message when the dataSource is empty', () => { + expect(validateDataSource('')).toEqual(['Select a valid data source']); + expect(validateDataSource(' ')).toEqual(['Select a valid data source']); + }); + + it('should return an empty array when the dataSource is not empty', () => { + expect(validateDataSource('Some_valid_data_source')).toEqual([]); + expect(validateDataSource(' Some_valid_data_source ')).toEqual([]); + }); +}); + +describe('validateDatabase', () => { + it('should return an array with an error message when the database is empty', () => { + expect(validateDatabase('')).toEqual(['Select a valid database']); + expect(validateDatabase(' ')).toEqual(['Select a valid database']); + }); + + it('should return an empty array when the database is not empty', () => { + expect(validateDatabase('Some_valid_database')).toEqual([]); + expect(validateDatabase(' Some_valid_database ')).toEqual([]); + }); +}); + +describe('validateDataTable', () => { + it('should return an array with an error message when the dataTable is empty', () => { + expect(validateDataTable('')).toEqual(['Select a valid table']); + expect(validateDataTable(' ')).toEqual(['Select a valid table']); + }); + + it('should return an empty array when the dataTable is not empty', () => { + expect(validateDataTable('Some_valid_table')).toEqual([]); + expect(validateDataTable(' Some_valid_table ')).toEqual([]); + }); +}); + +describe('validatePrimaryShardCount', () => { + it('should return an array with an error message when primaryShardCount is less than 1', () => { + expect(validatePrimaryShardCount(0)).toEqual(['Primary shards count should be greater than 0']); + expect(validatePrimaryShardCount(-1)).toEqual([ + 'Primary shards count should be greater than 0', + ]); // form throws validation error, doesn't allow user to proceed + }); + + it('should return an empty array when primaryShardCount is greater than or equal to 1', () => { + expect(validatePrimaryShardCount(1)).toEqual([]); + expect(validatePrimaryShardCount(5)).toEqual([]); + expect(validatePrimaryShardCount(100)).toEqual([]); + }); +}); + +describe('validateReplicaCount', () => { + it('should return an array with an error message when replicaCount is less than 1', () => { + expect(validateReplicaCount(-1)).toEqual(['Replica count should be equal or greater than 0']); // form throws validation error, doesn't allow user to proceed + }); + + it('should return an empty array when replicaCount is greater than or equal to 1', () => { + expect(validateReplicaCount(0)).toEqual([]); + expect(validateReplicaCount(1)).toEqual([]); + expect(validateReplicaCount(5)).toEqual([]); + expect(validateReplicaCount(100)).toEqual([]); + }); +}); + +describe('validateRefreshInterval', () => { + it('should return an array with an error message when refreshType is "interval" and refreshWindow is less than 1', () => { + expect(validateRefreshInterval('interval', 0)).toEqual([ + 'refresh window should be greater than 0', + ]); + expect(validateRefreshInterval('interval', -1)).toEqual([ + 'refresh window should be greater than 0', + ]); + expect(validateRefreshInterval('interval', -10)).toEqual([ + 'refresh window should be greater than 0', + ]); + }); + + it('should return an empty array when refreshType is not "interval" or when refreshWindow is greater than or equal to 1', () => { + expect(validateRefreshInterval('auto', 0)).toEqual([]); + expect(validateRefreshInterval('auto', 1)).toEqual([]); + expect(validateRefreshInterval('interval', 1)).toEqual([]); + expect(validateRefreshInterval('auto', 5)).toEqual([]); + }); +}); + +describe('validateIndexName', () => { + it('should return an array with an error message when the index name is invalid', () => { + expect(validateIndexName('_invalid')).toEqual(['Enter a valid index name']); + expect(validateIndexName('-invalid')).toEqual(['Enter a valid index name']); + expect(validateIndexName('InVal1d')).toEqual(['Enter a valid index name']); + expect(validateIndexName('invalid_with spaces')).toEqual(['Enter a valid index name']); + expect(validateIndexName('another-valid-name')).toEqual(['Enter a valid index name']); + }); + + it('should return an empty array when the index name is valid', () => { + expect(validateIndexName('valid')).toEqual([]); + expect(validateIndexName('valid_name')).toEqual([]); + expect(validateIndexName('valid_name_index')).toEqual([]); + }); + + it('should use the ACCELERATION_INDEX_NAME_REGEX pattern to validate the index name', () => { + expect(ACCELERATION_INDEX_NAME_REGEX.test('valid_name')).toBe(true); + expect(ACCELERATION_INDEX_NAME_REGEX.test('invalid name')).toBe(false); + expect(ACCELERATION_INDEX_NAME_REGEX.test('-invalid')).toBe(false); + expect(ACCELERATION_INDEX_NAME_REGEX.test('_invalid')).toBe(false); + expect(ACCELERATION_INDEX_NAME_REGEX.test('invalid.')).toBe(false); + expect(ACCELERATION_INDEX_NAME_REGEX.test('invalid<')).toBe(false); + expect(ACCELERATION_INDEX_NAME_REGEX.test('invalid*')).toBe(false); + }); +}); + +describe('validateCheckpointLocation', () => { + it('should return an array with an error message when using auto refresh without a checkpoint location', () => { + const materializedError = validateCheckpointLocation('auto', undefined); + expect(materializedError).toEqual(['Checkpoint location is mandatory for auto refresh']); + }); + + it('should return an array with an error message when using auto refresh with empty a checkpoint location', () => { + const materializedError = validateCheckpointLocation('auto', ''); + expect(materializedError).toEqual(['Checkpoint location is mandatory for auto refresh']); + }); + + it('should return an array with an error message when the checkpoint location is not a valid S3 URL', () => { + const invalidCheckpoint = validateCheckpointLocation('auto', 'not_a_valid_s3_url'); + expect(invalidCheckpoint).toEqual(['Enter a valid checkpoint location']); + }); + + it('should return an empty array when the checkpoint location is a valid S3 URL', () => { + const validCheckpoint = validateCheckpointLocation( + 'interval', + 's3://valid-s3-bucket/path/to/checkpoint' + ); + expect(validCheckpoint).toEqual([]); + }); + + it('should return an empty array when the checkpoint location is a valid S3A URL', () => { + const validCheckpoint = validateCheckpointLocation( + 'auto', + 's3a://valid-s3-bucket/path/to/checkpoint' + ); + expect(validCheckpoint).toEqual([]); + }); + + it('should return an empty array when the checkpoint location is a valid S3A URL with just bucket in checkpoint', () => { + const validCheckpoint = validateCheckpointLocation('auto', 's3a://valid-s3-bucket'); + expect(validCheckpoint).toEqual([]); + }); + + it('should return an empty array when using manual refresh with no checkpoint location', () => { + const validMaterializedCheckpoint = validateCheckpointLocation('manual', ''); + expect(validMaterializedCheckpoint).toEqual([]); + }); + + it('should use the ACCELERATION_S3_URL_REGEX pattern to validate the checkpoint location', () => { + expect(ACCELERATION_S3_URL_REGEX.test('s3://valid-s3-bucket/path/to/checkpoint')).toBe(true); + expect(ACCELERATION_S3_URL_REGEX.test('s3a://valid-s3-bucket/path/to/checkpoint')).toBe(true); + expect(ACCELERATION_S3_URL_REGEX.test('https://amazon.com')).toBe(false); + expect(ACCELERATION_S3_URL_REGEX.test('http://www.amazon.com')).toBe(false); + }); +}); + +describe('validateSkippingIndexData', () => { + it('should return an array with an error message when accelerationIndexType is "skipping" and no skipping index data is provided', () => { + const error = validateSkippingIndexData('skipping', []); + expect(error).toEqual(['Add fields to the skipping index definition']); + }); + + it('should return an empty array when accelerationIndexType is not "skipping"', () => { + const noError = validateSkippingIndexData('covering', []); + expect(noError).toEqual([]); + }); + + it('should return an empty array when accelerationIndexType is "skipping" and skipping index data is provided', () => { + const noError = validateSkippingIndexData('skipping', skippingIndexDataMock); + expect(noError).toEqual([]); + }); +}); + +describe('validateCoveringIndexData', () => { + it('should return an array with an error message when accelerationIndexType is "covering" and no covering index data is provided', () => { + const error = validateCoveringIndexData('covering', []); + expect(error).toEqual(['Add fields to covering index definition']); + }); + + it('should return an empty array when accelerationIndexType is not "covering"', () => { + const noError = validateCoveringIndexData('skipping', []); + expect(noError).toEqual([]); + }); + + it('should return an empty array when accelerationIndexType is "covering" and covering index data is provided', () => { + const noError = validateCoveringIndexData('covering', coveringIndexDataMock); + expect(noError).toEqual([]); + }); +}); + +describe('validateMaterializedViewData', () => { + it('should return an array with an error message when accelerationIndexType is "materialized" and no materialized view data is provided', () => { + const error = validateMaterializedViewData('materialized', materializedViewEmptyDataMock); + expect(error).toEqual(['Add columns to materialized view definition']); + }); + + it('should return an array with an error message when accelerationIndexType is "materialized" and groupByTumbleValue is incomplete', () => { + const error = validateMaterializedViewData('materialized', materializedViewEmptyTumbleDataMock); + expect(error).toEqual(['Add a time field to tumble function in materialized view definition']); + }); + + it('should return an empty array when accelerationIndexType is not "materialized"', () => { + const noError = validateMaterializedViewData('covering', materializedViewStaleDataMock); + expect(noError).toEqual([]); + }); + + it('should return an empty array when accelerationIndexType is "materialized" and materialized view data is complete', () => { + const noError = validateMaterializedViewData('materialized', materializedViewValidDataMock); + expect(noError).toEqual([]); + }); +}); diff --git a/public/components/datasources/components/manage/accelerations/create/create_acceleration.tsx b/public/components/datasources/components/manage/accelerations/create/create_acceleration.tsx new file mode 100644 index 0000000000..6ab2805940 --- /dev/null +++ b/public/components/datasources/components/manage/accelerations/create/create_acceleration.tsx @@ -0,0 +1,156 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + EuiButton, + EuiButtonEmpty, + EuiComboBoxOptionOption, + EuiFlexGroup, + EuiFlexItem, + EuiFlyout, + EuiFlyoutBody, + EuiFlyoutFooter, + EuiFlyoutHeader, + EuiForm, + EuiSpacer, +} from '@elastic/eui'; +import React, { useState } from 'react'; +import { CoreStart } from '../../../../../../../../../src/core/public'; +import { + ACCELERATION_DEFUALT_SKIPPING_INDEX_NAME, + ACCELERATION_TIME_INTERVAL, +} from '../../../../../../../common/constants/data_sources'; +import { CreateAccelerationForm } from '../../../../../../../common/types/data_connections'; +import { DefineIndexOptions } from '../selectors/define_index_options'; +import { IndexSettingOptions } from '../selectors/index_setting_options'; +import { AccelerationDataSourceSelector } from '../selectors/source_selector'; +import { accelerationQueryBuilder } from '../visual_editors/query_builder'; +import { QueryVisualEditor } from '../visual_editors/query_visual_editor'; +import { CreateAccelerationHeader } from './create_acceleration_header'; +import { formValidator, hasError } from './utils'; + +export interface CreateAccelerationProps { + http: CoreStart['http']; + selectedDatasource: EuiComboBoxOptionOption[]; + resetFlyout: () => void; + updateQueries: (query: string) => void; +} + +export const CreateAcceleration = ({ + http, + selectedDatasource, + resetFlyout, + updateQueries, +}: CreateAccelerationProps) => { + const [accelerationFormData, setAccelerationFormData] = useState({ + dataSource: selectedDatasource.length > 0 ? selectedDatasource[0].label : '', + dataTable: '', + database: '', + dataTableFields: [], + accelerationIndexType: 'skipping', + skippingIndexQueryData: [], + coveringIndexQueryData: [], + materializedViewQueryData: { + columnsValues: [], + groupByTumbleValue: { + timeField: '', + tumbleWindow: 0, + tumbleInterval: '', + }, + }, + accelerationIndexName: ACCELERATION_DEFUALT_SKIPPING_INDEX_NAME, + primaryShardsCount: 5, + replicaShardsCount: 1, + refreshType: 'auto', + checkpointLocation: undefined, + watermarkDelay: { + delayWindow: 1, + delayInterval: ACCELERATION_TIME_INTERVAL[1].value, + }, + refreshIntervalOptions: { + refreshWindow: 1, + refreshInterval: ACCELERATION_TIME_INTERVAL[1].value, + }, + formErrors: { + dataSourceError: [], + databaseError: [], + dataTableError: [], + skippingIndexError: [], + coveringIndexError: [], + materializedViewError: [], + indexNameError: [], + primaryShardsError: [], + replicaShardsError: [], + refreshIntervalError: [], + checkpointLocationError: [], + watermarkDelayError: [], + }, + }); + + const copyToEditor = () => { + const errors = formValidator(accelerationFormData); + if (hasError(errors)) { + setAccelerationFormData({ ...accelerationFormData, formErrors: errors }); + return; + } + updateQueries(accelerationQueryBuilder(accelerationFormData)); + resetFlyout(); + }; + + return ( + <> + + + + + + + + + + + + + + + + + + + + + Close + + + + + Copy Query to Editor + + + + + + + ); +}; diff --git a/public/components/datasources/components/manage/accelerations/create/create_acceleration_header.tsx b/public/components/datasources/components/manage/accelerations/create/create_acceleration_header.tsx new file mode 100644 index 0000000000..03338aa596 --- /dev/null +++ b/public/components/datasources/components/manage/accelerations/create/create_acceleration_header.tsx @@ -0,0 +1,36 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + EuiLink, + EuiPageHeader, + EuiPageHeaderSection, + EuiSpacer, + EuiText, + EuiTitle, +} from '@elastic/eui'; +import React from 'react'; +import { OPENSEARCH_ACC_DOCUMENTATION_URL } from '../../../../../../../common/constants/data_connections'; + +export const CreateAccelerationHeader = () => { + return ( +
+ + + +

Accelerate data

+
+
+
+ + + Create OpenSearch Indexes from external data connections for better performance.{' '} + + Learn more + + +
+ ); +}; diff --git a/public/components/datasources/components/manage/accelerations/create/utils.tsx b/public/components/datasources/components/manage/accelerations/create/utils.tsx new file mode 100644 index 0000000000..98f2b5aae6 --- /dev/null +++ b/public/components/datasources/components/manage/accelerations/create/utils.tsx @@ -0,0 +1,161 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + ACCELERATION_INDEX_NAME_REGEX, + ACCELERATION_S3_URL_REGEX, +} from '../../../../../../../common/constants/data_sources'; +import { + AccelerationIndexType, + AccelerationRefreshType, + CreateAccelerationForm, + FormErrorsType, + MaterializedViewQueryType, + SkippingIndexRowType, +} from '../../../../../../../common/types/data_connections'; + +export const pluralizeTime = (timeWindow: number) => { + return timeWindow > 1 ? 's' : ''; +}; + +export const hasError = (formErrors: FormErrorsType, key?: keyof FormErrorsType) => { + if (!key) return Object.values(formErrors).some((e) => !!e.length); + return !!formErrors[key]?.length; +}; + +export const validateDataSource = (dataSource: string) => { + return dataSource.trim().length === 0 ? ['Select a valid data source'] : []; +}; + +export const validateDatabase = (database: string) => { + return database.trim().length === 0 ? ['Select a valid database'] : []; +}; + +export const validateDataTable = (dataTable: string) => { + return dataTable.trim().length === 0 ? ['Select a valid table'] : []; +}; + +export const validatePrimaryShardCount = (primaryShardCount: number) => { + return primaryShardCount < 1 ? ['Primary shards count should be greater than 0'] : []; +}; + +export const validateReplicaCount = (replicaCount: number) => { + return replicaCount < 0 ? ['Replica count should be equal or greater than 0'] : []; +}; + +export const validateRefreshInterval = (refreshType: string, refreshWindow: number) => { + return refreshType === 'interval' && refreshWindow < 1 + ? ['refresh window should be greater than 0'] + : []; +}; + +export const validateWatermarkDelay = ( + accelerationIndexType: AccelerationIndexType, + delayWindow: number +) => { + return accelerationIndexType === 'materialized' && delayWindow < 1 + ? ['delay window should be greater than 0'] + : []; +}; + +export const validateIndexName = (value: string) => { + // Check if the value does not begin with underscores or hyphens and all characters are lower case + return !ACCELERATION_INDEX_NAME_REGEX.test(value) ? ['Enter a valid index name'] : []; +}; + +export const validateCheckpointLocation = ( + refreshType: AccelerationRefreshType, + checkpointLocation: string | undefined +) => { + if (refreshType !== 'manual' && !checkpointLocation) { + return ['Checkpoint location is mandatory for auto refresh']; + } + + if (checkpointLocation && !ACCELERATION_S3_URL_REGEX.test(checkpointLocation)) + return ['Enter a valid checkpoint location']; + + return []; +}; + +export const validateSkippingIndexData = ( + accelerationIndexType: AccelerationIndexType, + skippingIndexQueryData: SkippingIndexRowType[] +) => { + // TODO: Validate dataType match with supported acceleration method type + if (accelerationIndexType !== 'skipping') return []; + + if (skippingIndexQueryData.length < 1) return ['Add fields to the skipping index definition']; + + return []; +}; + +export const validateCoveringIndexData = ( + accelerationIndexType: AccelerationIndexType, + coveringIndexQueryData: string[] +) => { + if (accelerationIndexType !== 'covering') return []; + + if (coveringIndexQueryData.length < 1) return ['Add fields to covering index definition']; + return []; +}; + +export const validateMaterializedViewData = ( + accelerationIndexType: AccelerationIndexType, + materializedViewQueryData: MaterializedViewQueryType +) => { + if (accelerationIndexType !== 'materialized') return []; + + if (materializedViewQueryData.columnsValues.length < 1) + return ['Add columns to materialized view definition']; + + if (materializedViewQueryData.groupByTumbleValue.timeField === '') + return ['Add a time field to tumble function in materialized view definition']; + + if (materializedViewQueryData.groupByTumbleValue.tumbleWindow < 1) + return ['Add a valid time window to tumble function in materialized view definition']; + return []; +}; + +export const formValidator = (accelerationformData: CreateAccelerationForm) => { + const { + dataSource, + database, + dataTable, + accelerationIndexType, + skippingIndexQueryData, + coveringIndexQueryData, + materializedViewQueryData, + accelerationIndexName, + primaryShardsCount, + replicaShardsCount, + refreshType, + checkpointLocation, + watermarkDelay, + refreshIntervalOptions, + } = accelerationformData; + + const accelerationFormErrors: FormErrorsType = { + dataSourceError: validateDataSource(dataSource), + databaseError: validateDatabase(database), + dataTableError: validateDataTable(dataTable), + primaryShardsError: validatePrimaryShardCount(primaryShardsCount), + replicaShardsError: validateReplicaCount(replicaShardsCount), + refreshIntervalError: validateRefreshInterval( + refreshType, + refreshIntervalOptions.refreshWindow + ), + checkpointLocationError: validateCheckpointLocation(refreshType, checkpointLocation), + watermarkDelayError: validateWatermarkDelay(accelerationIndexType, watermarkDelay.delayWindow), + indexNameError: validateIndexName(accelerationIndexName), + skippingIndexError: validateSkippingIndexData(accelerationIndexType, skippingIndexQueryData), + coveringIndexError: validateCoveringIndexData(accelerationIndexType, coveringIndexQueryData), + materializedViewError: validateMaterializedViewData( + accelerationIndexType, + materializedViewQueryData + ), + }; + + return accelerationFormErrors; +}; diff --git a/public/components/datasources/components/manage/accelerations/selectors/__tests__/__snapshots__/define_index_options.test.tsx.snap b/public/components/datasources/components/manage/accelerations/selectors/__tests__/__snapshots__/define_index_options.test.tsx.snap new file mode 100644 index 0000000000..c9f121c45b --- /dev/null +++ b/public/components/datasources/components/manage/accelerations/selectors/__tests__/__snapshots__/define_index_options.test.tsx.snap @@ -0,0 +1,340 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Index options acceleration components renders acceleration index options with covering index options 1`] = ` +Array [ +
+

+ Index settings +

+
, +
, +
+
+ + +
+ +
+
+
+
+ + + + + + +
+ +
+ +
+
+ Must be in lowercase letters. Cannot begin with underscores. Spaces, commas, and characters -, :, ", *, +, /, \\, |, ?, #, >, or < are not allowed. Prefix and suffix are added to the name of generated OpenSearch index. +
+
+
, + "", +] +`; + +exports[`Index options acceleration components renders acceleration index options with default options 1`] = ` +Array [ +
+

+ Index settings +

+
, +
, +
+
+ + +
+ +
+
+
+
+ + + + + + +
+ +
+ +
+
+ Must be in lowercase letters. Cannot begin with underscores. Spaces, commas, and characters -, :, ", *, +, /, \\, |, ?, #, >, or < are not allowed. Prefix and suffix are added to the name of generated OpenSearch index. +
+
+
, + "", +] +`; + +exports[`Index options acceleration components renders acceleration index options with materialized index options 1`] = ` +Array [ +
+

+ Index settings +

+
, +
, +
+
+ + +
+ +
+
+
+
+ + + + + + +
+ +
+
+
+ Must be in lowercase letters. Cannot begin with underscores. Spaces, commas, and characters -, :, ", *, +, /, \\, |, ?, #, >, or < are not allowed. Prefix and suffix are added to the name of generated OpenSearch index. +
+
+
, + "", +] +`; diff --git a/public/components/datasources/components/manage/accelerations/selectors/__tests__/__snapshots__/index_setting_options.test.tsx.snap b/public/components/datasources/components/manage/accelerations/selectors/__tests__/__snapshots__/index_setting_options.test.tsx.snap new file mode 100644 index 0000000000..57a22ffafa --- /dev/null +++ b/public/components/datasources/components/manage/accelerations/selectors/__tests__/__snapshots__/index_setting_options.test.tsx.snap @@ -0,0 +1,1225 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Index settings acceleration components renders acceleration index settings with default options 1`] = ` +Array [ +
+

+ Index settings +

+
, +
, +
+ +
+
+
+
+
+ + Skipping Index + +
+ +
+
+
+
+ +
+
+
+
+
+ Select the type of index you want to create. Each index type has benefits and costs. +
+
+
, +
+
+ +
+
+
+
+ +
+
+
+ Specify the number of primary shards for the index. The number of primary shards cannot be changed after the index is created. +
+
+
, +
+
+ +
+
+
+
+ +
+
+
+ Specify the number of replicas each primary shard should have. +
+
+
, +
+
+ +
+
+
+
+ +
+ +
+
+ +
+ +
+
+ +
+ +
+
+
+ Specify how often the index should refresh, which publishes the most recent changes and make them available for search. +
+
+
, +
+
+ +
+
+
+
+ +
+
+
+ The HDFS compatible file system location path for incremental refresh job checkpoint. +
+
+
, +] +`; + +exports[`Index settings acceleration components renders acceleration index settings with different options1 1`] = ` +Array [ +
+

+ Index settings +

+
, +
, +
+ +
+
+
+
+
+ + Skipping Index + +
+ +
+
+
+
+ +
+
+
+
+
+ Select the type of index you want to create. Each index type has benefits and costs. +
+
+
, +
+
+ +
+
+
+
+ +
+
+
+ Specify the number of primary shards for the index. The number of primary shards cannot be changed after the index is created. +
+
+
, +
+
+ +
+
+
+
+ +
+
+
+ Specify the number of replicas each primary shard should have. +
+
+
, +
+
+ +
+
+
+
+ +
+ +
+
+ +
+ +
+
+ +
+ +
+
+
+ Specify how often the index should refresh, which publishes the most recent changes and make them available for search. +
+
+
, +
+
+ +
+
+
+
+ +
+
+
+ The HDFS compatible file system location path for incremental refresh job checkpoint. +
+
+
, +] +`; + +exports[`Index settings acceleration components renders acceleration index settings with different options2 1`] = ` +Array [ +
+

+ Index settings +

+
, +
, +
+ +
+
+
+
+
+ + Skipping Index + +
+ +
+
+
+
+ +
+
+
+
+
+ Select the type of index you want to create. Each index type has benefits and costs. +
+
+
, +
+
+ +
+
+
+
+ +
+
+
+ Specify the number of primary shards for the index. The number of primary shards cannot be changed after the index is created. +
+
+
, +
+
+ +
+
+
+
+ +
+
+
+ Specify the number of replicas each primary shard should have. +
+
+
, +
+
+ +
+
+
+
+ +
+ +
+
+ +
+ +
+
+ +
+ +
+
+
+ Specify how often the index should refresh, which publishes the most recent changes and make them available for search. +
+
+
, +
+
+ +
+
+
+
+ +
+
+
+ The HDFS compatible file system location path for incremental refresh job checkpoint. +
+
+
, +] +`; diff --git a/public/components/datasources/components/manage/accelerations/selectors/__tests__/__snapshots__/index_type_selector.test.tsx.snap b/public/components/datasources/components/manage/accelerations/selectors/__tests__/__snapshots__/index_type_selector.test.tsx.snap new file mode 100644 index 0000000000..3df2886c04 --- /dev/null +++ b/public/components/datasources/components/manage/accelerations/selectors/__tests__/__snapshots__/index_type_selector.test.tsx.snap @@ -0,0 +1,317 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Index type selector components renders type selector with default options 1`] = ` +
+ +
+
+
+
+
+ + Skipping Index + +
+ +
+
+
+
+ +
+
+
+
+
+ Select the type of index you want to create. Each index type has benefits and costs. +
+
+
+`; + +exports[`Index type selector components renders type selector with different options 1`] = ` +
+ +
+
+
+
+
+ + Skipping Index + +
+ +
+
+
+
+ +
+
+
+
+
+ Select the type of index you want to create. Each index type has benefits and costs. +
+
+
+`; diff --git a/public/components/datasources/components/manage/accelerations/selectors/__tests__/__snapshots__/source_selector.test.tsx.snap b/public/components/datasources/components/manage/accelerations/selectors/__tests__/__snapshots__/source_selector.test.tsx.snap new file mode 100644 index 0000000000..ffb1a8eb3a --- /dev/null +++ b/public/components/datasources/components/manage/accelerations/selectors/__tests__/__snapshots__/source_selector.test.tsx.snap @@ -0,0 +1,786 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Source selector components renders source selector with default options 1`] = ` +Array [ +
+

+ Select data source +

+
, +
, +
+
+ Select the data source to accelerate data from. External data sources may take time to load. +
+
, +
, +
+
+ +
+
+
+
+
+
+

+ Select a data source +

+
+ +
+
+
+
+ +
+
+
+
+
+ A data source has to be configured and active to be able to select it and index data from. +
+
+
, +
+
+ +
+
+
+
+
+
+

+ Select a database +

+
+ +
+
+
+
+ +
+
+
+
+
+ Select the database that contains the tables you'd like to use. +
+
+
, +
+
+ +
+
+
+
+
+
+

+ Select a table +

+
+ +
+
+
+
+ +
+
+
+
+
+ Select the Spark table that has the data you would like to index. +
+
+
, +] +`; + +exports[`Source selector components renders source selector with different options 1`] = ` +Array [ +
+

+ Select data source +

+
, +
, +
+
+ Select the data source to accelerate data from. External data sources may take time to load. +
+
, +
, +
+
+ +
+
+
+
+
+
+ + ds + +
+ +
+
+
+
+ +
+
+
+
+
+ A data source has to be configured and active to be able to select it and index data from. +
+
+
, +
+
+ +
+
+
+
+
+
+

+ Select a database +

+
+ +
+
+
+
+ +
+
+
+
+
+ Select the database that contains the tables you'd like to use. +
+
+
, +
+
+ +
+
+
+
+
+
+

+ Select a table +

+
+ +
+
+
+
+ +
+
+
+
+
+ Select the Spark table that has the data you would like to index. +
+
+
, +] +`; diff --git a/public/components/datasources/components/manage/accelerations/selectors/__tests__/define_index_options.test.tsx b/public/components/datasources/components/manage/accelerations/selectors/__tests__/define_index_options.test.tsx new file mode 100644 index 0000000000..d597358776 --- /dev/null +++ b/public/components/datasources/components/manage/accelerations/selectors/__tests__/define_index_options.test.tsx @@ -0,0 +1,85 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { waitFor } from '@testing-library/dom'; +import { configure, mount } from 'enzyme'; +import Adapter from 'enzyme-adapter-react-16'; +import toJson from 'enzyme-to-json'; +import React from 'react'; +import { CreateAccelerationForm } from '../../../../../../../../common/types/data_connections'; +import { createAccelerationEmptyDataMock } from '../../../../../../../../test/accelerations'; +import { DefineIndexOptions } from '../define_index_options'; + +describe('Index options acceleration components', () => { + configure({ adapter: new Adapter() }); + + it('renders acceleration index options with default options', async () => { + const accelerationFormData = createAccelerationEmptyDataMock; + const setAccelerationFormData = jest.fn(); + const wrapper = mount( + + ); + wrapper.update(); + await waitFor(() => { + expect( + toJson(wrapper, { + noKey: false, + mode: 'deep', + }) + ).toMatchSnapshot(); + }); + }); + + it('renders acceleration index options with covering index options', async () => { + const accelerationFormData: CreateAccelerationForm = { + ...createAccelerationEmptyDataMock, + accelerationIndexType: 'covering', + accelerationIndexName: 'covering-idx', + }; + const setAccelerationFormData = jest.fn(); + const wrapper = mount( + + ); + wrapper.update(); + await waitFor(() => { + expect( + toJson(wrapper, { + noKey: false, + mode: 'deep', + }) + ).toMatchSnapshot(); + }); + }); + + it('renders acceleration index options with materialized index options', async () => { + const accelerationFormData: CreateAccelerationForm = { + ...createAccelerationEmptyDataMock, + accelerationIndexType: 'materialized', + accelerationIndexName: 'mv_metrics', + }; + const setAccelerationFormData = jest.fn(); + const wrapper = mount( + + ); + wrapper.update(); + await waitFor(() => { + expect( + toJson(wrapper, { + noKey: false, + mode: 'deep', + }) + ).toMatchSnapshot(); + }); + }); +}); diff --git a/public/components/datasources/components/manage/accelerations/selectors/__tests__/index_setting_options.test.tsx b/public/components/datasources/components/manage/accelerations/selectors/__tests__/index_setting_options.test.tsx new file mode 100644 index 0000000000..7aca48380c --- /dev/null +++ b/public/components/datasources/components/manage/accelerations/selectors/__tests__/index_setting_options.test.tsx @@ -0,0 +1,89 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { waitFor } from '@testing-library/dom'; +import { configure, mount } from 'enzyme'; +import Adapter from 'enzyme-adapter-react-16'; +import toJson from 'enzyme-to-json'; +import React from 'react'; +import { CreateAccelerationForm } from '../../../../../../../../common/types/data_connections'; +import { createAccelerationEmptyDataMock } from '../../../../../../../../test/accelerations'; +import { IndexSettingOptions } from '../index_setting_options'; + +describe('Index settings acceleration components', () => { + configure({ adapter: new Adapter() }); + + it('renders acceleration index settings with default options', async () => { + const accelerationFormData = createAccelerationEmptyDataMock; + const setAccelerationFormData = jest.fn(); + const wrapper = mount( + + ); + wrapper.update(); + await waitFor(() => { + expect( + toJson(wrapper, { + noKey: false, + mode: 'deep', + }) + ).toMatchSnapshot(); + }); + }); + + it('renders acceleration index settings with different options1', async () => { + const accelerationFormData: CreateAccelerationForm = { + ...createAccelerationEmptyDataMock, + primaryShardsCount: 1, + replicaShardsCount: 5, + refreshType: 'auto', + }; + const setAccelerationFormData = jest.fn(); + const wrapper = mount( + + ); + wrapper.update(); + await waitFor(() => { + expect( + toJson(wrapper, { + noKey: false, + mode: 'deep', + }) + ).toMatchSnapshot(); + }); + }); + + it('renders acceleration index settings with different options2', async () => { + const accelerationFormData: CreateAccelerationForm = { + ...createAccelerationEmptyDataMock, + primaryShardsCount: 5, + replicaShardsCount: 1, + refreshType: 'interval', + refreshIntervalOptions: { refreshWindow: 1, refreshInterval: 'second' }, + checkpointLocation: 's3://test/url', + }; + const setAccelerationFormData = jest.fn(); + const wrapper = mount( + + ); + wrapper.update(); + await waitFor(() => { + expect( + toJson(wrapper, { + noKey: false, + mode: 'deep', + }) + ).toMatchSnapshot(); + }); + }); +}); diff --git a/public/components/datasources/components/manage/accelerations/selectors/__tests__/index_type_selector.test.tsx b/public/components/datasources/components/manage/accelerations/selectors/__tests__/index_type_selector.test.tsx new file mode 100644 index 0000000000..7cf7bd51ba --- /dev/null +++ b/public/components/datasources/components/manage/accelerations/selectors/__tests__/index_type_selector.test.tsx @@ -0,0 +1,60 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { waitFor } from '@testing-library/dom'; +import { configure, mount } from 'enzyme'; +import Adapter from 'enzyme-adapter-react-16'; +import toJson from 'enzyme-to-json'; +import React from 'react'; +import { CreateAccelerationForm } from '../../../../../../../../common/types/data_connections'; +import { createAccelerationEmptyDataMock } from '../../../../../../../../test/accelerations'; +import { IndexTypeSelector } from '../index_type_selector'; + +describe('Index type selector components', () => { + configure({ adapter: new Adapter() }); + + it('renders type selector with default options', async () => { + const accelerationFormData = createAccelerationEmptyDataMock; + const setAccelerationFormData = jest.fn(); + const wrapper = mount( + + ); + wrapper.update(); + await waitFor(() => { + expect( + toJson(wrapper, { + noKey: false, + mode: 'deep', + }) + ).toMatchSnapshot(); + }); + }); + + it('renders type selector with different options', async () => { + const accelerationFormData: CreateAccelerationForm = { + ...createAccelerationEmptyDataMock, + accelerationIndexType: 'covering', + }; + const setAccelerationFormData = jest.fn(); + const wrapper = mount( + + ); + wrapper.update(); + await waitFor(() => { + expect( + toJson(wrapper, { + noKey: false, + mode: 'deep', + }) + ).toMatchSnapshot(); + }); + }); +}); diff --git a/public/components/datasources/components/manage/accelerations/selectors/__tests__/source_selector.test.tsx b/public/components/datasources/components/manage/accelerations/selectors/__tests__/source_selector.test.tsx new file mode 100644 index 0000000000..92eb3d4731 --- /dev/null +++ b/public/components/datasources/components/manage/accelerations/selectors/__tests__/source_selector.test.tsx @@ -0,0 +1,81 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { EuiComboBoxOptionOption } from '@elastic/eui'; +import { waitFor } from '@testing-library/dom'; +import { configure, mount } from 'enzyme'; +import Adapter from 'enzyme-adapter-react-16'; +import toJson from 'enzyme-to-json'; +import React from 'react'; +import { coreMock } from '../../../../../../../../../../src/core/public/mocks'; +import { CreateAccelerationForm } from '../../../../../../../../common/types/data_connections'; +import { + createAccelerationEmptyDataMock, + mockDatasourcesQuery, +} from '../../../../../../../../test/accelerations'; +import { AccelerationDataSourceSelector } from '../source_selector'; + +const coreStartMock = coreMock.createStart(); + +describe('Source selector components', () => { + configure({ adapter: new Adapter() }); + + it('renders source selector with default options', async () => { + const accelerationFormData = createAccelerationEmptyDataMock; + const selectedDatasource: EuiComboBoxOptionOption[] = []; + const setAccelerationFormData = jest.fn(); + const client = coreStartMock.http; + client.get = jest.fn().mockResolvedValue(mockDatasourcesQuery); + + const wrapper = mount( + + ); + wrapper.update(); + await waitFor(() => { + expect( + toJson(wrapper, { + noKey: false, + mode: 'deep', + }) + ).toMatchSnapshot(); + }); + }); + + it('renders source selector with different options', async () => { + const selectedDatasource: EuiComboBoxOptionOption[] = [{ label: 'ds' }]; + const accelerationFormData: CreateAccelerationForm = { + ...createAccelerationEmptyDataMock, + dataSource: 'ds', + database: 'db', + dataTable: 'tb', + }; + const setAccelerationFormData = jest.fn(); + const client = coreStartMock.http; + client.get = jest.fn().mockResolvedValue(mockDatasourcesQuery); + client.post = jest.fn().mockResolvedValue([]); + const wrapper = mount( + + ); + wrapper.update(); + await waitFor(() => { + expect( + toJson(wrapper, { + noKey: false, + mode: 'deep', + }) + ).toMatchSnapshot(); + }); + }); +}); diff --git a/public/components/datasources/components/manage/accelerations/selectors/define_index_options.tsx b/public/components/datasources/components/manage/accelerations/selectors/define_index_options.tsx new file mode 100644 index 0000000000..fbc127a4dc --- /dev/null +++ b/public/components/datasources/components/manage/accelerations/selectors/define_index_options.tsx @@ -0,0 +1,129 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + EuiButton, + EuiFieldText, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiIconTip, + EuiLink, + EuiMarkdownFormat, + EuiModal, + EuiModalBody, + EuiModalFooter, + EuiModalHeader, + EuiModalHeaderTitle, + EuiSpacer, + EuiText, +} from '@elastic/eui'; +import producer from 'immer'; +import React, { ChangeEvent, useState } from 'react'; +import { ACCELERATION_INDEX_NAME_INFO } from '../../../../../../../common/constants/data_sources'; +import { CreateAccelerationForm } from '../../../../../../../common/types/data_connections'; +import { hasError, validateIndexName } from '../create/utils'; + +interface DefineIndexOptionsProps { + accelerationFormData: CreateAccelerationForm; + setAccelerationFormData: React.Dispatch>; +} + +export const DefineIndexOptions = ({ + accelerationFormData, + setAccelerationFormData, +}: DefineIndexOptionsProps) => { + const [modalComponent, setModalComponent] = useState(<>); + + const modalValue = ( + setModalComponent(<>)}> + + +

Acceleration index naming

+
+
+ + + + {ACCELERATION_INDEX_NAME_INFO} + + + + + setModalComponent(<>)} fill> + Close + + +
+ ); + + const onChangeIndexName = (e: ChangeEvent) => { + setAccelerationFormData({ ...accelerationFormData, accelerationIndexName: e.target.value }); + }; + + const getPreprend = () => { + const dataSource = + accelerationFormData.dataSource !== '' + ? accelerationFormData.dataSource + : '{Datasource Name}'; + const database = + accelerationFormData.database !== '' ? accelerationFormData.database : '{Database Name}'; + const dataTable = + accelerationFormData.dataTable !== '' ? accelerationFormData.dataTable : '{Table Name}'; + const prependValue = + accelerationFormData.accelerationIndexType === 'materialized' + ? `flint_${dataSource}_${database}_` + : `flint_${dataSource}_${database}_${dataTable}_`; + return [ + prependValue, + , + ]; + }; + + const getAppend = () => { + const appendValue = + accelerationFormData.accelerationIndexType === 'materialized' ? '' : '_index'; + return appendValue; + }; + + return ( + <> + +

Index settings

+
+ + + setModalComponent(modalValue)}>Help + + } + > + { + setAccelerationFormData( + producer((accData) => { + accData.formErrors.indexNameError = validateIndexName(e.target.value); + }) + ); + }} + /> + + {modalComponent} + + ); +}; diff --git a/public/components/datasources/components/manage/accelerations/selectors/index_setting_options.tsx b/public/components/datasources/components/manage/accelerations/selectors/index_setting_options.tsx new file mode 100644 index 0000000000..a4af61ecee --- /dev/null +++ b/public/components/datasources/components/manage/accelerations/selectors/index_setting_options.tsx @@ -0,0 +1,313 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + EuiFieldNumber, + EuiFieldText, + EuiFormRow, + EuiRadioGroup, + EuiSelect, + EuiSpacer, + EuiText, +} from '@elastic/eui'; +import producer from 'immer'; +import React, { ChangeEvent, useState } from 'react'; +import { ACCELERATION_TIME_INTERVAL } from '../../../../../../../common/constants/data_sources'; +import { + AccelerationRefreshType, + CreateAccelerationForm, +} from '../../../../../../../common/types/data_connections'; +import { + hasError, + validateCheckpointLocation, + validatePrimaryShardCount, + validateRefreshInterval, + validateReplicaCount, + validateWatermarkDelay, +} from '../create/utils'; +import { IndexTypeSelector } from './index_type_selector'; + +interface IndexSettingOptionsProps { + accelerationFormData: CreateAccelerationForm; + setAccelerationFormData: React.Dispatch>; +} + +export const IndexSettingOptions = ({ + accelerationFormData, + setAccelerationFormData, +}: IndexSettingOptionsProps) => { + const autoRefreshId = 'refresh-option-1'; + const intervalRefreshId = 'refresh-option-2'; + const manualRefreshId = 'refresh-option-3'; + const refreshOptions = [ + { + id: autoRefreshId, + label: 'Auto refresh', + }, + { + id: intervalRefreshId, + label: 'Auto refresh by interval', + }, + { + id: manualRefreshId, + label: 'Manual refresh', + }, + ]; + + const [primaryShards, setPrimaryShards] = useState(5); + const [replicaCount, setReplicaCount] = useState(1); + const [refreshTypeSelected, setRefreshTypeSelected] = useState(autoRefreshId); + const [refreshWindow, setRefreshWindow] = useState(1); + const [refreshInterval, setRefreshInterval] = useState(ACCELERATION_TIME_INTERVAL[1].value); + const [delayWindow, setDelayWindow] = useState(1); + const [delayInterval, setDelayInterval] = useState(ACCELERATION_TIME_INTERVAL[1].value); + const [checkpoint, setCheckpoint] = useState(''); + + const onChangePrimaryShards = (e: ChangeEvent) => { + const countPrimaryShards = parseInt(e.target.value, 10); + setAccelerationFormData({ ...accelerationFormData, primaryShardsCount: countPrimaryShards }); + setPrimaryShards(countPrimaryShards); + }; + + const onChangeReplicaCount = (e: ChangeEvent) => { + const parsedReplicaCount = parseInt(e.target.value, 10); + setAccelerationFormData({ ...accelerationFormData, replicaShardsCount: parsedReplicaCount }); + setReplicaCount(parsedReplicaCount); + }; + + const onChangeRefreshType = (optionId: React.SetStateAction) => { + let refreshOption: AccelerationRefreshType = 'auto'; + switch (optionId) { + case autoRefreshId: + refreshOption = 'auto'; + break; + case intervalRefreshId: + refreshOption = 'interval'; + break; + case manualRefreshId: + refreshOption = 'manual'; + break; + } + setAccelerationFormData({ + ...accelerationFormData, + refreshType: refreshOption, + }); + setRefreshTypeSelected(optionId); + }; + + const onChangeRefreshWindow = (e: ChangeEvent) => { + const windowCount = parseInt(e.target.value, 10); + setAccelerationFormData( + producer((accData) => { + accData.refreshIntervalOptions.refreshWindow = windowCount; + }) + ); + setRefreshWindow(windowCount); + }; + + const onChangeDelayWindow = (e: ChangeEvent) => { + const windowCount = parseInt(e.target.value, 10); + setAccelerationFormData( + producer((accData) => { + accData.watermarkDelay.delayWindow = windowCount; + }) + ); + setDelayWindow(windowCount); + }; + + const onChangeRefreshInterval = (e: React.ChangeEvent) => { + const refreshIntervalValue = e.target.value; + setAccelerationFormData( + producer((accData) => { + accData.refreshIntervalOptions.refreshInterval = refreshIntervalValue; + }) + ); + setRefreshInterval(refreshIntervalValue); + }; + + const onChangeDelayInterval = (e: React.ChangeEvent) => { + const delayIntervalValue = e.target.value; + setAccelerationFormData( + producer((accData) => { + accData.watermarkDelay.delayInterval = delayIntervalValue; + }) + ); + setDelayInterval(delayIntervalValue); + }; + + const onChangeCheckpoint = (e: ChangeEvent) => { + const checkpointLocation = e.target.value; + setAccelerationFormData({ ...accelerationFormData, checkpointLocation }); + setCheckpoint(checkpointLocation); + }; + + return ( + <> + +

Index settings

+
+ + + + { + setAccelerationFormData( + producer((accData) => { + accData.formErrors.primaryShardsError = validatePrimaryShardCount( + parseInt(e.target.value, 10) + ); + }) + ); + }} + isInvalid={hasError(accelerationFormData.formErrors, 'primaryShardsError')} + /> + + + { + setAccelerationFormData( + producer((accData) => { + accData.formErrors.replicaShardsError = validateReplicaCount( + parseInt(e.target.value, 10) + ); + }) + ); + }} + isInvalid={hasError(accelerationFormData.formErrors, 'replicaShardsError')} + /> + + + + + {refreshTypeSelected === intervalRefreshId && ( + + { + setAccelerationFormData( + producer((accData) => { + accData.formErrors.refreshIntervalError = validateRefreshInterval( + refreshTypeSelected, + parseInt(e.target.value, 10) + ); + }) + ); + }} + append={ + + } + /> + + )} + {refreshTypeSelected !== manualRefreshId && ( + + { + setAccelerationFormData( + producer((accData) => { + accData.formErrors.checkpointLocationError = validateCheckpointLocation( + accData.refreshType, + e.target.value + ); + }) + ); + }} + /> + + )} + {accelerationFormData.accelerationIndexType === 'materialized' && ( + + { + setAccelerationFormData( + producer((accData) => { + accData.formErrors.watermarkDelayError = validateWatermarkDelay( + accelerationFormData.accelerationIndexType, + parseInt(e.target.value, 10) + ); + }) + ); + }} + append={ + + } + /> + + )} + + ); +}; diff --git a/public/components/datasources/components/manage/accelerations/selectors/index_type_selector.tsx b/public/components/datasources/components/manage/accelerations/selectors/index_type_selector.tsx new file mode 100644 index 0000000000..1ebd110a68 --- /dev/null +++ b/public/components/datasources/components/manage/accelerations/selectors/index_type_selector.tsx @@ -0,0 +1,107 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { EuiComboBox, EuiComboBoxOptionOption, EuiFormRow, EuiLink, EuiText } from '@elastic/eui'; +import React, { useState } from 'react'; +import { + ACCELERATION_DEFUALT_SKIPPING_INDEX_NAME, + ACCELERATION_INDEX_TYPES, + ACC_INDEX_TYPE_DOCUMENTATION_URL, +} from '../../../../../../../common/constants/data_sources'; +import { + AccelerationIndexType, + CreateAccelerationForm, +} from '../../../../../../../common/types/data_connections'; +// import { executeAsyncQuery } from '../../../../common/utils/async_query_helpers'; + +interface IndexTypeSelectorProps { + accelerationFormData: CreateAccelerationForm; + setAccelerationFormData: React.Dispatch>; +} + +export const IndexTypeSelector = ({ + accelerationFormData, + setAccelerationFormData, +}: IndexTypeSelectorProps) => { + const [selectedIndexType, setSelectedIndexType] = useState< + Array> + >([ACCELERATION_INDEX_TYPES[0]]); + const [loading, _setLoading] = useState(false); + + // useEffect(() => { + // if (accelerationFormData.dataTable !== '') { + // setLoading(true); + // const idPrefix = htmlIdGenerator()(); + // const query = { + // lang: 'sql', + // query: `DESC \`${accelerationFormData.dataSource}\`.\`${accelerationFormData.database}\`.\`${accelerationFormData.dataTable}\``, + // datasource: accelerationFormData.dataSource, + // }; + + // executeAsyncQuery( + // accelerationFormData.dataSource, + // query, + // (response: AsyncApiResponse) => { + // const status = response.data.resp.status.toLowerCase(); + // if (status === AsyncQueryStatus.Success) { + // const dataTableFields: DataTableFieldsType[] = response.data.resp.datarows + // .filter((row) => !row[0].startsWith('#')) + // .map((row, index) => ({ + // id: `${idPrefix}${index + 1}`, + // fieldName: row[0], + // dataType: row[1], + // })); + // setAccelerationFormData({ + // ...accelerationFormData, + // dataTableFields, + // }); + // setLoading(false); + // } + // if (status === AsyncQueryStatus.Failed || status === AsyncQueryStatus.Cancelled) { + // setLoading(false); + // } + // }, + // () => setLoading(false) + // ); + // } + // }, [accelerationFormData.dataTable]); + + const onChangeIndexType = (indexTypeOption: Array>) => { + const indexType = indexTypeOption[0].value as AccelerationIndexType; + setAccelerationFormData({ + ...accelerationFormData, + accelerationIndexType: indexType, + accelerationIndexName: + indexType === 'skipping' ? ACCELERATION_DEFUALT_SKIPPING_INDEX_NAME : '', + }); + setSelectedIndexType(indexTypeOption); + }; + return ( + <> + + + Help + + + } + > + + + + ); +}; diff --git a/public/components/datasources/components/manage/accelerations/selectors/source_selector.tsx b/public/components/datasources/components/manage/accelerations/selectors/source_selector.tsx new file mode 100644 index 0000000000..ea3859285b --- /dev/null +++ b/public/components/datasources/components/manage/accelerations/selectors/source_selector.tsx @@ -0,0 +1,235 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { EuiComboBox, EuiComboBoxOptionOption, EuiFormRow, EuiSpacer, EuiText } from '@elastic/eui'; +import producer from 'immer'; +import React, { useEffect, useState } from 'react'; +// import { executeAsyncQuery } from '../../../../common/utils/async_query_helpers'; +import { CoreStart } from '../../../../../../../../../src/core/public'; +import { CreateAccelerationForm } from '../../../../../../../common/types/data_connections'; +import { useToast } from '../../../../../common/toast'; +import { hasError, validateDataSource } from '../create/utils'; + +interface AccelerationDataSourceSelectorProps { + http: CoreStart['http']; + accelerationFormData: CreateAccelerationForm; + setAccelerationFormData: React.Dispatch>; + selectedDatasource: EuiComboBoxOptionOption[]; +} + +export const AccelerationDataSourceSelector = ({ + http, + accelerationFormData, + setAccelerationFormData, + selectedDatasource, +}: AccelerationDataSourceSelectorProps) => { + const { setToast } = useToast(); + const [dataConnections, setDataConnections] = useState>>( + [] + ); + const [selectedDataConnection, setSelectedDataConnection] = useState< + Array> + >(selectedDatasource.length > 0 ? [{ label: selectedDatasource[0].label }] : []); + const [databases, _setDatabases] = useState>>([]); + const [selectedDatabase, setSelectedDatabase] = useState>>( + [] + ); + const [tables, _setTables] = useState>>([]); + const [selectedTable, setSelectedTable] = useState>>([]); + const [loadingComboBoxes, setLoadingComboBoxes] = useState({ + dataSource: false, + database: false, + dataTable: false, + }); + + const loadDataSource = () => { + setLoadingComboBoxes({ ...loadingComboBoxes, dataSource: true }); + http + .get(`/api/get_datasources`) + .then((res) => { + const data = res.data.resp; + setDataConnections( + data + .filter((connection: any) => connection.connector.toUpperCase() === 'S3GLUE') + .map((connection: any) => ({ label: connection.name })) + ); + }) + .catch((err) => { + console.error(err); + setToast(`ERROR: failed to load datasources`, 'danger'); + }); + setLoadingComboBoxes({ ...loadingComboBoxes, dataSource: false }); + }; + + const loadDatabases = () => { + // setLoadingComboBoxes({ ...loadingComboBoxes, database: true }); + // const query = { + // lang: 'sql', + // query: `SHOW SCHEMAS IN \`${accelerationFormData.dataSource}\``, + // datasource: accelerationFormData.dataSource, + // }; + // executeAsyncQuery( + // accelerationFormData.dataSource, + // query, + // (response: AsyncApiResponse) => { + // const status = response.data.resp.status.toLowerCase(); + // if (status === AsyncQueryStatus.Success) { + // let databaseOptions: Array> = []; + // if (response.data.resp.datarows.length > 0) + // databaseOptions = response.data.resp.datarows.map((subArray: any[]) => ({ + // label: subArray[0], + // })); + // setDatabases(databaseOptions); + // setLoadingComboBoxes({ ...loadingComboBoxes, database: false }); + // } + // if (status === AsyncQueryStatus.Failed || status === AsyncQueryStatus.Cancelled) { + // setLoadingComboBoxes({ ...loadingComboBoxes, database: false }); + // } + // }, + // () => setLoadingComboBoxes({ ...loadingComboBoxes, database: false }) + // ); + }; + + const loadTables = () => { + // setLoadingComboBoxes({ ...loadingComboBoxes, dataTable: true }); + // const query = { + // lang: 'sql', + // query: `SHOW TABLES IN \`${accelerationFormData.dataSource}\`.\`${accelerationFormData.database}\``, + // datasource: accelerationFormData.dataSource, + // }; + // executeAsyncQuery( + // accelerationFormData.dataSource, + // query, + // (response: AsyncApiResponse) => { + // const status = response.data.resp.status.toLowerCase(); + // if (status === AsyncQueryStatus.Success) { + // let dataTableOptions: Array> = []; + // if (response.data.resp.datarows.length > 0) + // dataTableOptions = response.data.resp.datarows.map((subArray) => ({ + // label: subArray[1], + // })); + // setTables(dataTableOptions); + // setLoadingComboBoxes({ ...loadingComboBoxes, dataTable: false }); + // } + // if (status === AsyncQueryStatus.Failed || status === AsyncQueryStatus.Cancelled) { + // setLoadingComboBoxes({ ...loadingComboBoxes, dataTable: false }); + // } + // }, + // () => setLoadingComboBoxes({ ...loadingComboBoxes, dataTable: false }) + // ); + }; + + useEffect(() => { + loadDataSource(); + }, []); + + useEffect(() => { + if (accelerationFormData.dataSource !== '') { + loadDatabases(); + } + }, [accelerationFormData.dataSource]); + + useEffect(() => { + if (accelerationFormData.database !== '') { + loadTables(); + } + }, [accelerationFormData.database]); + + return ( + <> + +

Select data source

+
+ + + Select the data source to accelerate data from. External data sources may take time to load. + + + + { + if (dataConnectionOptions.length > 0) { + setAccelerationFormData( + producer((accData) => { + accData.dataSource = dataConnectionOptions[0].label; + accData.formErrors.dataSourceError = validateDataSource( + dataConnectionOptions[0].label + ); + }) + ); + setSelectedDataConnection(dataConnectionOptions); + } + }} + isClearable={false} + isInvalid={hasError(accelerationFormData.formErrors, 'dataSourceError')} + isLoading={loadingComboBoxes.dataSource} + /> + + + { + if (databaseOptions.length > 0) { + setAccelerationFormData( + producer((accData) => { + accData.database = databaseOptions[0].label; + accData.formErrors.databaseError = validateDataSource(databaseOptions[0].label); + }) + ); + setSelectedDatabase(databaseOptions); + } + }} + isClearable={false} + isInvalid={hasError(accelerationFormData.formErrors, 'databaseError')} + isLoading={loadingComboBoxes.database} + /> + + + { + if (tableOptions.length > 0) { + setAccelerationFormData( + producer((accData) => { + accData.dataTable = tableOptions[0].label; + accData.formErrors.dataTableError = validateDataSource(tableOptions[0].label); + }) + ); + setSelectedTable(tableOptions); + } + }} + isClearable={false} + isInvalid={hasError(accelerationFormData.formErrors, 'dataTableError')} + isLoading={loadingComboBoxes.dataTable} + /> + + + ); +}; diff --git a/public/components/datasources/components/manage/accelerations/visual_editors/__tests__/__snapshots__/query_visual_editor.test.tsx.snap b/public/components/datasources/components/manage/accelerations/visual_editors/__tests__/__snapshots__/query_visual_editor.test.tsx.snap new file mode 100644 index 0000000000..6b52d8222a --- /dev/null +++ b/public/components/datasources/components/manage/accelerations/visual_editors/__tests__/__snapshots__/query_visual_editor.test.tsx.snap @@ -0,0 +1,1130 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Visual builder components renders visual builder with covering index options 1`] = ` +Array [ +
, + Array [ +
+

+ Covering index definition +

+
, +
, +
+
+ + + CREATE INDEX + + + + cv-idx + + +
+
+ + + ON + + + + .. + + +
+
+ +
+
+
+
, + ], +] +`; + +exports[`Visual builder components renders visual builder with default options 1`] = ` +Array [ +
, + Array [ +
+

+ Skipping index definition +

+
, +
, +
+
+
+
+
+
+
+
+ + + + + + + + + + + + + + +
+
+ + + Field name + + + + + + Datatype + + + + + + Acceleration method + + + + + + Delete + + +
+
+ + No items found + +
+
+
+
, +
+
+ +
+
+ +
+
, + ], +] +`; + +exports[`Visual builder components renders visual builder with materialized view options 1`] = ` +Array [ +
, + Array [ +
+

+ Materialized view definition +

+
, +
, + + + CREATE MATERIALIZED VIEW + + + + ..skipping + + , +
+
+ + + AS SELECT + + + +
+
+
+
+ +
+
+
+
, +
, +
, + + + FROM + + + + .. + + , +
, +
+
+
+ +
+
+
, + ], +] +`; + +exports[`Visual builder components renders visual builder with skipping index options 1`] = ` +Array [ +
, + Array [ +
+

+ Skipping index definition +

+
, +
, +
+
+
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + Field name + + + + + + Datatype + + + + + + Acceleration method + + + + + + Delete + + +
+
+ Field name +
+
+ + field1 + +
+
+
+ Datatype +
+
+ + string + +
+
+
+ Acceleration method +
+
+
+
+ +
+ + + + + +
+
+
+
+
+
+ Delete +
+
+ +
+
+
+ Field name +
+
+ + field2 + +
+
+
+ Datatype +
+
+ + number + +
+
+
+ Acceleration method +
+
+
+
+ +
+ + + + + +
+
+
+
+
+
+ Delete +
+
+ +
+
+
+
+
+
+
+
+
+ +
+
+
+
+ +
+
+
+
, +
+
+ +
+
+ +
+
, + ], +] +`; diff --git a/public/components/datasources/components/manage/accelerations/visual_editors/__tests__/query_builder.test.tsx b/public/components/datasources/components/manage/accelerations/visual_editors/__tests__/query_builder.test.tsx new file mode 100644 index 0000000000..12084d280d --- /dev/null +++ b/public/components/datasources/components/manage/accelerations/visual_editors/__tests__/query_builder.test.tsx @@ -0,0 +1,105 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + coveringIndexBuilderMock1, + coveringIndexBuilderMock2, + coveringIndexBuilderMockResult1, + coveringIndexBuilderMockResult2, + indexOptionsMock1, + indexOptionsMock2, + indexOptionsMock3, + indexOptionsMock4, + indexOptionsMock5, + indexOptionsMock6, + indexOptionsMockResult1, + indexOptionsMockResult2, + indexOptionsMockResult3, + indexOptionsMockResult4, + indexOptionsMockResult5, + indexOptionsMockResult6, + materializedViewBuilderMock1, + materializedViewBuilderMock2, + materializedViewBuilderMockResult1, + materializedViewBuilderMockResult2, + skippingIndexBuilderMock1, + skippingIndexBuilderMock2, + skippingIndexBuilderMockResult1, + skippingIndexBuilderMockResult2, +} from '../../../../../../../../test/accelerations'; +import { + buildIndexOptions, + coveringIndexQueryBuilder, + materializedQueryViewBuilder, + skippingIndexQueryBuilder, +} from '../query_builder'; + +describe('buildIndexOptions', () => { + it('should build index options with auto refresh', () => { + const indexOptions = buildIndexOptions(indexOptionsMock1); + expect(indexOptions).toEqual(indexOptionsMockResult1); + }); + + it('should build index options with interval refresh', () => { + const indexOptions = buildIndexOptions(indexOptionsMock2); + expect(indexOptions).toEqual(indexOptionsMockResult2); + }); + + it('should build index options with checkpoint location', () => { + const indexOptions = buildIndexOptions(indexOptionsMock3); + expect(indexOptions).toEqual(indexOptionsMockResult3); + }); + + it('should build index options with manual refresh', () => { + const indexOptions = buildIndexOptions(indexOptionsMock4); + expect(indexOptions).toEqual(indexOptionsMockResult4); + }); + + it('should build index options with watermark delay', () => { + const indexOptions = buildIndexOptions(indexOptionsMock5); + expect(indexOptions).toEqual(indexOptionsMockResult5); + }); + + it('should build index options with manual refresh and checkpoint', () => { + const indexOptions = buildIndexOptions(indexOptionsMock6); + expect(indexOptions).toEqual(indexOptionsMockResult6); + }); + + describe('skippingIndexQueryBuilder', () => { + it('should build skipping index query as expected with interval refresh', () => { + const result = skippingIndexQueryBuilder(skippingIndexBuilderMock1); + expect(result).toEqual(skippingIndexBuilderMockResult1); + }); + + it('should build skipping index query as expected with auto refresh', () => { + const result = skippingIndexQueryBuilder(skippingIndexBuilderMock2); + expect(result).toEqual(skippingIndexBuilderMockResult2); + }); + }); + + describe('coveringIndexQueryBuilder', () => { + it('should build covering index query as expected with interval refresh', () => { + const result = coveringIndexQueryBuilder(coveringIndexBuilderMock1); + expect(result).toEqual(coveringIndexBuilderMockResult1); + }); + + it('should build covering index query as expected with auto refresh', () => { + const result = coveringIndexQueryBuilder(coveringIndexBuilderMock2); + expect(result).toEqual(coveringIndexBuilderMockResult2); + }); + }); + + describe('materializedQueryViewBuilder', () => { + it('should build materialized view query as expected with interval refresh', () => { + const result = materializedQueryViewBuilder(materializedViewBuilderMock1); + expect(result).toEqual(materializedViewBuilderMockResult1); + }); + + it('should build materialized view query as expected with auto refresh', () => { + const result = materializedQueryViewBuilder(materializedViewBuilderMock2); + expect(result).toEqual(materializedViewBuilderMockResult2); + }); + }); +}); diff --git a/public/components/datasources/components/manage/accelerations/visual_editors/__tests__/query_visual_editor.test.tsx b/public/components/datasources/components/manage/accelerations/visual_editors/__tests__/query_visual_editor.test.tsx new file mode 100644 index 0000000000..ee643a3a25 --- /dev/null +++ b/public/components/datasources/components/manage/accelerations/visual_editors/__tests__/query_visual_editor.test.tsx @@ -0,0 +1,116 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { waitFor } from '@testing-library/dom'; +import { configure, mount } from 'enzyme'; +import Adapter from 'enzyme-adapter-react-16'; +import toJson from 'enzyme-to-json'; +import React from 'react'; +import { CreateAccelerationForm } from '../../../../../../../../common/types/data_connections'; +import { + coveringIndexDataMock, + createAccelerationEmptyDataMock, + materializedViewValidDataMock, + skippingIndexDataMock, +} from '../../../../../../../../test/accelerations'; +import { QueryVisualEditor } from '../query_visual_editor'; + +describe('Visual builder components', () => { + configure({ adapter: new Adapter() }); + + it('renders visual builder with default options', async () => { + const accelerationFormData = createAccelerationEmptyDataMock; + const setAccelerationFormData = jest.fn(); + const wrapper = mount( + + ); + wrapper.update(); + await waitFor(() => { + expect( + toJson(wrapper, { + noKey: false, + mode: 'deep', + }) + ).toMatchSnapshot(); + }); + }); + + it('renders visual builder with skipping index options', async () => { + const accelerationFormData: CreateAccelerationForm = { + ...createAccelerationEmptyDataMock, + accelerationIndexName: 'skipping', + accelerationIndexType: 'skipping', + skippingIndexQueryData: skippingIndexDataMock, + }; + const setAccelerationFormData = jest.fn(); + const wrapper = mount( + + ); + wrapper.update(); + await waitFor(() => { + expect( + toJson(wrapper, { + noKey: false, + mode: 'deep', + }) + ).toMatchSnapshot(); + }); + }); + + it('renders visual builder with covering index options', async () => { + const accelerationFormData: CreateAccelerationForm = { + ...createAccelerationEmptyDataMock, + accelerationIndexName: 'cv-idx', + accelerationIndexType: 'covering', + coveringIndexQueryData: coveringIndexDataMock, + }; + const setAccelerationFormData = jest.fn(); + const wrapper = mount( + + ); + wrapper.update(); + await waitFor(() => { + expect( + toJson(wrapper, { + noKey: false, + mode: 'deep', + }) + ).toMatchSnapshot(); + }); + }); + + it('renders visual builder with materialized view options', async () => { + const accelerationFormData: CreateAccelerationForm = { + ...createAccelerationEmptyDataMock, + accelerationIndexType: 'materialized', + materializedViewQueryData: materializedViewValidDataMock, + }; + const setAccelerationFormData = jest.fn(); + const wrapper = mount( + + ); + wrapper.update(); + await waitFor(() => { + expect( + toJson(wrapper, { + noKey: false, + mode: 'deep', + }) + ).toMatchSnapshot(); + }); + }); +}); diff --git a/public/components/datasources/components/manage/accelerations/visual_editors/covering_index/__tests__/__snapshots__/covering_index_builder.test.tsx.snap b/public/components/datasources/components/manage/accelerations/visual_editors/covering_index/__tests__/__snapshots__/covering_index_builder.test.tsx.snap new file mode 100644 index 0000000000..24d90273a0 --- /dev/null +++ b/public/components/datasources/components/manage/accelerations/visual_editors/covering_index/__tests__/__snapshots__/covering_index_builder.test.tsx.snap @@ -0,0 +1,163 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Covering index builder components renders covering index builder with different options 1`] = ` +Array [ +
+

+ Covering index definition +

+
, +
, +
+
+ + + CREATE INDEX + + + + skipping + + +
+
+ + + ON + + + + .. + + +
+
+ +
+
+
+
, +] +`; + +exports[`Covering index builder components renders covering index builder with default options 1`] = ` +Array [ +
+

+ Covering index definition +

+
, +
, +
+
+ + + CREATE INDEX + + + + skipping + + +
+
+ + + ON + + + + .. + + +
+
+ +
+
+
+
, +] +`; diff --git a/public/components/datasources/components/manage/accelerations/visual_editors/covering_index/__tests__/covering_index_builder.test.tsx b/public/components/datasources/components/manage/accelerations/visual_editors/covering_index/__tests__/covering_index_builder.test.tsx new file mode 100644 index 0000000000..6a43e3f3c9 --- /dev/null +++ b/public/components/datasources/components/manage/accelerations/visual_editors/covering_index/__tests__/covering_index_builder.test.tsx @@ -0,0 +1,60 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { waitFor } from '@testing-library/dom'; +import { configure, mount } from 'enzyme'; +import Adapter from 'enzyme-adapter-react-16'; +import toJson from 'enzyme-to-json'; +import React from 'react'; +import { CreateAccelerationForm } from '../../../../../../../../../common/types/data_connections'; +import { createAccelerationEmptyDataMock } from '../../../../../../../../../test/accelerations'; +import { CoveringIndexBuilder } from '../covering_index_builder'; + +describe('Covering index builder components', () => { + configure({ adapter: new Adapter() }); + + it('renders covering index builder with default options', async () => { + const accelerationFormData = createAccelerationEmptyDataMock; + const setAccelerationFormData = jest.fn(); + const wrapper = mount( + + ); + wrapper.update(); + await waitFor(() => { + expect( + toJson(wrapper, { + noKey: false, + mode: 'deep', + }) + ).toMatchSnapshot(); + }); + }); + + it('renders covering index builder with different options', async () => { + const accelerationFormData: CreateAccelerationForm = { + ...createAccelerationEmptyDataMock, + coveringIndexQueryData: ['field1', 'field2', 'field3'], + }; + const setAccelerationFormData = jest.fn(); + const wrapper = mount( + + ); + wrapper.update(); + await waitFor(() => { + expect( + toJson(wrapper, { + noKey: false, + mode: 'deep', + }) + ).toMatchSnapshot(); + }); + }); +}); diff --git a/public/components/datasources/components/manage/accelerations/visual_editors/covering_index/covering_index_builder.tsx b/public/components/datasources/components/manage/accelerations/visual_editors/covering_index/covering_index_builder.tsx new file mode 100644 index 0000000000..03e973b14e --- /dev/null +++ b/public/components/datasources/components/manage/accelerations/visual_editors/covering_index/covering_index_builder.tsx @@ -0,0 +1,100 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + EuiComboBox, + EuiComboBoxOptionOption, + EuiExpression, + EuiFlexGroup, + EuiFlexItem, + EuiPopover, + EuiPopoverTitle, + EuiSpacer, + EuiText, +} from '@elastic/eui'; +import React, { useState } from 'react'; +import { ACCELERATION_ADD_FIELDS_TEXT } from '../../../../../../../../common/constants/data_sources'; +import { CreateAccelerationForm } from '../../../../../../../../common/types/data_connections'; +import { hasError } from '../../create/utils'; + +interface CoveringIndexBuilderProps { + accelerationFormData: CreateAccelerationForm; + setAccelerationFormData: React.Dispatch>; +} + +export const CoveringIndexBuilder = ({ + accelerationFormData, + setAccelerationFormData, +}: CoveringIndexBuilderProps) => { + const [isPopOverOpen, setIsPopOverOpen] = useState(false); + const [columnsValue, setColumnsValue] = useState(ACCELERATION_ADD_FIELDS_TEXT); + const [selectedOptions, setSelectedOptions] = useState([]); + + const onChange = (_selectedOptions: EuiComboBoxOptionOption[]) => { + let expressionValue = ACCELERATION_ADD_FIELDS_TEXT; + if (_selectedOptions.length > 0) { + expressionValue = `(${_selectedOptions.map((option) => option.label).join(', ')})`; + } + setAccelerationFormData({ + ...accelerationFormData, + coveringIndexQueryData: _selectedOptions.map((option) => option.label), + }); + setColumnsValue(expressionValue); + setSelectedOptions(_selectedOptions); + }; + + return ( + <> + +

Covering index definition

+
+ + + + + + + + setIsPopOverOpen(true)} + isInvalid={ + hasError(accelerationFormData.formErrors, 'coveringIndexError') && + columnsValue === ACCELERATION_ADD_FIELDS_TEXT + } + /> + } + isOpen={isPopOverOpen} + closePopover={() => setIsPopOverOpen(false)} + panelPaddingSize="s" + anchorPosition="downLeft" + > + <> + Columns + ({ label: x.fieldName }))} + selectedOptions={selectedOptions} + onChange={onChange} + /> + + + + + + ); +}; diff --git a/public/components/datasources/components/manage/accelerations/visual_editors/materialized_view/__tests__/__snapshots__/add_column_popover.test.tsx.snap b/public/components/datasources/components/manage/accelerations/visual_editors/materialized_view/__tests__/__snapshots__/add_column_popover.test.tsx.snap new file mode 100644 index 0000000000..7237444504 --- /dev/null +++ b/public/components/datasources/components/manage/accelerations/visual_editors/materialized_view/__tests__/__snapshots__/add_column_popover.test.tsx.snap @@ -0,0 +1,88 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Column popover components in materialized view renders column popover components in materialized view with default options 1`] = ` +
+
+ +
+
+`; + +exports[`Column popover components in materialized view renders column popover components in materialized view with different options 1`] = ` +
+
+ +
+
+`; diff --git a/public/components/datasources/components/manage/accelerations/visual_editors/materialized_view/__tests__/__snapshots__/column_expression.test.tsx.snap b/public/components/datasources/components/manage/accelerations/visual_editors/materialized_view/__tests__/__snapshots__/column_expression.test.tsx.snap new file mode 100644 index 0000000000..8812acbe2d --- /dev/null +++ b/public/components/datasources/components/manage/accelerations/visual_editors/materialized_view/__tests__/__snapshots__/column_expression.test.tsx.snap @@ -0,0 +1,179 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Column expression components in materialized view renders column expression components in materialized view with default options 1`] = ` +
+
+
+
+
+ +
+
+
+
+
+
+ +
+
+
+
+ +
+
+
+`; + +exports[`Column expression components in materialized view renders column expression components in materialized view with different options 1`] = ` +
+
+
+
+
+ +
+
+
+
+
+
+ +
+
+
+
+ +
+
+
+`; diff --git a/public/components/datasources/components/manage/accelerations/visual_editors/materialized_view/__tests__/__snapshots__/group_by_tumble_expression.test.tsx.snap b/public/components/datasources/components/manage/accelerations/visual_editors/materialized_view/__tests__/__snapshots__/group_by_tumble_expression.test.tsx.snap new file mode 100644 index 0000000000..235e817dfe --- /dev/null +++ b/public/components/datasources/components/manage/accelerations/visual_editors/materialized_view/__tests__/__snapshots__/group_by_tumble_expression.test.tsx.snap @@ -0,0 +1,65 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Group by components in materialized view renders group by components in materialized view with default options 1`] = ` +
+
+
+ +
+
+
+`; + +exports[`Group by components in materialized view renders group by components in materialized view with different options 1`] = ` +
+
+
+ +
+
+
+`; diff --git a/public/components/datasources/components/manage/accelerations/visual_editors/materialized_view/__tests__/__snapshots__/materialized_view_builder.test.tsx.snap b/public/components/datasources/components/manage/accelerations/visual_editors/materialized_view/__tests__/__snapshots__/materialized_view_builder.test.tsx.snap new file mode 100644 index 0000000000..629d885d07 --- /dev/null +++ b/public/components/datasources/components/manage/accelerations/visual_editors/materialized_view/__tests__/__snapshots__/materialized_view_builder.test.tsx.snap @@ -0,0 +1,294 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Builder components in materialized view renders builder components in materialized view with default options 1`] = ` +Array [ +
+

+ Materialized view definition +

+
, +
, + + + CREATE MATERIALIZED VIEW + + + + ..skipping + + , +
+
+ + + AS SELECT + + + +
+
+
+
+ +
+
+
+
, +
, +
, + + + FROM + + + + .. + + , +
, +
+
+
+ +
+
+
, +] +`; + +exports[`Builder components in materialized view renders builder components in materialized view with different options 1`] = ` +Array [ +
+

+ Materialized view definition +

+
, +
, + + + CREATE MATERIALIZED VIEW + + + + ..skipping + + , +
+
+ + + AS SELECT + + + +
+
+
+
+ +
+
+
+
, +
, +
, + + + FROM + + + + .. + + , +
, +
+
+
+ +
+
+
, +] +`; diff --git a/public/components/datasources/components/manage/accelerations/visual_editors/materialized_view/__tests__/add_column_popover.test.tsx b/public/components/datasources/components/manage/accelerations/visual_editors/materialized_view/__tests__/add_column_popover.test.tsx new file mode 100644 index 0000000000..627336bc1d --- /dev/null +++ b/public/components/datasources/components/manage/accelerations/visual_editors/materialized_view/__tests__/add_column_popover.test.tsx @@ -0,0 +1,76 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { waitFor } from '@testing-library/dom'; +import { configure, mount } from 'enzyme'; +import Adapter from 'enzyme-adapter-react-16'; +import toJson from 'enzyme-to-json'; +import React from 'react'; +import { CreateAccelerationForm } from '../../../../../../../../../common/types/data_connections'; +import { + createAccelerationEmptyDataMock, + materializedViewValidDataMock, +} from '../../../../../../../../../test/accelerations'; +import { AddColumnPopOver } from '../add_column_popover'; + +describe('Column popover components in materialized view', () => { + configure({ adapter: new Adapter() }); + + it('renders column popover components in materialized view with default options', async () => { + const accelerationFormData = createAccelerationEmptyDataMock; + const setAccelerationFormData = jest.fn(); + const setIsColumnPopOverOpen = jest.fn(); + const setColumnExpressionValues = jest.fn(); + const wrapper = mount( + + ); + wrapper.update(); + await waitFor(() => { + expect( + toJson(wrapper, { + noKey: false, + mode: 'deep', + }) + ).toMatchSnapshot(); + }); + }); + + it('renders column popover components in materialized view with different options', async () => { + const accelerationFormData: CreateAccelerationForm = { + ...createAccelerationEmptyDataMock, + accelerationIndexType: 'materialized', + materializedViewQueryData: materializedViewValidDataMock, + }; + const setAccelerationFormData = jest.fn(); + const setIsColumnPopOverOpen = jest.fn(); + const setColumnExpressionValues = jest.fn(); + const wrapper = mount( + + ); + wrapper.update(); + await waitFor(() => { + expect( + toJson(wrapper, { + noKey: false, + mode: 'deep', + }) + ).toMatchSnapshot(); + }); + }); +}); diff --git a/public/components/datasources/components/manage/accelerations/visual_editors/materialized_view/__tests__/column_expression.test.tsx b/public/components/datasources/components/manage/accelerations/visual_editors/materialized_view/__tests__/column_expression.test.tsx new file mode 100644 index 0000000000..633398026d --- /dev/null +++ b/public/components/datasources/components/manage/accelerations/visual_editors/materialized_view/__tests__/column_expression.test.tsx @@ -0,0 +1,78 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { waitFor } from '@testing-library/dom'; +import { configure, mount } from 'enzyme'; +import Adapter from 'enzyme-adapter-react-16'; +import toJson from 'enzyme-to-json'; +import React from 'react'; +import { CreateAccelerationForm } from '../../../../../../../../../common/types/data_connections'; +import { + createAccelerationEmptyDataMock, + materializedViewValidDataMock, +} from '../../../../../../../../../test/accelerations'; +import { ColumnExpression } from '../column_expression'; + +describe('Column expression components in materialized view', () => { + configure({ adapter: new Adapter() }); + + it('renders column expression components in materialized view with default options', async () => { + const accelerationFormData: CreateAccelerationForm = { + ...createAccelerationEmptyDataMock, + accelerationIndexType: 'materialized', + materializedViewQueryData: materializedViewValidDataMock, + }; + const setAccelerationFormData = jest.fn(); + const setColumnExpressionValues = jest.fn(); + const wrapper = mount( + + ); + wrapper.update(); + await waitFor(() => { + expect( + toJson(wrapper, { + noKey: false, + mode: 'deep', + }) + ).toMatchSnapshot(); + }); + }); + + it('renders column expression components in materialized view with different options', async () => { + const accelerationFormData: CreateAccelerationForm = { + ...createAccelerationEmptyDataMock, + accelerationIndexType: 'materialized', + materializedViewQueryData: materializedViewValidDataMock, + }; + const setAccelerationFormData = jest.fn(); + const setColumnExpressionValues = jest.fn(); + const wrapper = mount( + + ); + wrapper.update(); + await waitFor(() => { + expect( + toJson(wrapper, { + noKey: false, + mode: 'deep', + }) + ).toMatchSnapshot(); + }); + }); +}); diff --git a/public/components/datasources/components/manage/accelerations/visual_editors/materialized_view/__tests__/group_by_tumble_expression.test.tsx b/public/components/datasources/components/manage/accelerations/visual_editors/materialized_view/__tests__/group_by_tumble_expression.test.tsx new file mode 100644 index 0000000000..8b0188cf47 --- /dev/null +++ b/public/components/datasources/components/manage/accelerations/visual_editors/materialized_view/__tests__/group_by_tumble_expression.test.tsx @@ -0,0 +1,64 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { waitFor } from '@testing-library/dom'; +import { configure, mount } from 'enzyme'; +import Adapter from 'enzyme-adapter-react-16'; +import toJson from 'enzyme-to-json'; +import React from 'react'; +import { CreateAccelerationForm } from '../../../../../../../../../common/types/data_connections'; +import { + createAccelerationEmptyDataMock, + materializedViewValidDataMock, +} from '../../../../../../../../../test/accelerations'; +import { GroupByTumbleExpression } from '../group_by_tumble_expression'; + +describe('Group by components in materialized view', () => { + configure({ adapter: new Adapter() }); + + it('renders group by components in materialized view with default options', async () => { + const accelerationFormData = createAccelerationEmptyDataMock; + const setAccelerationFormData = jest.fn(); + const wrapper = mount( + + ); + wrapper.update(); + await waitFor(() => { + expect( + toJson(wrapper, { + noKey: false, + mode: 'deep', + }) + ).toMatchSnapshot(); + }); + }); + + it('renders group by components in materialized view with different options', async () => { + const accelerationFormData: CreateAccelerationForm = { + ...createAccelerationEmptyDataMock, + accelerationIndexType: 'materialized', + materializedViewQueryData: materializedViewValidDataMock, + }; + const setAccelerationFormData = jest.fn(); + const wrapper = mount( + + ); + wrapper.update(); + await waitFor(() => { + expect( + toJson(wrapper, { + noKey: false, + mode: 'deep', + }) + ).toMatchSnapshot(); + }); + }); +}); diff --git a/public/components/datasources/components/manage/accelerations/visual_editors/materialized_view/__tests__/materialized_view_builder.test.tsx b/public/components/datasources/components/manage/accelerations/visual_editors/materialized_view/__tests__/materialized_view_builder.test.tsx new file mode 100644 index 0000000000..514d10c9a2 --- /dev/null +++ b/public/components/datasources/components/manage/accelerations/visual_editors/materialized_view/__tests__/materialized_view_builder.test.tsx @@ -0,0 +1,64 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { waitFor } from '@testing-library/dom'; +import { configure, mount } from 'enzyme'; +import Adapter from 'enzyme-adapter-react-16'; +import toJson from 'enzyme-to-json'; +import React from 'react'; +import { CreateAccelerationForm } from '../../../../../../../../../common/types/data_connections'; +import { + createAccelerationEmptyDataMock, + materializedViewValidDataMock, +} from '../../../../../../../../../test/accelerations'; +import { MaterializedViewBuilder } from '../materialized_view_builder'; + +describe('Builder components in materialized view', () => { + configure({ adapter: new Adapter() }); + + it('renders builder components in materialized view with default options', async () => { + const accelerationFormData = createAccelerationEmptyDataMock; + const setAccelerationFormData = jest.fn(); + const wrapper = mount( + + ); + wrapper.update(); + await waitFor(() => { + expect( + toJson(wrapper, { + noKey: false, + mode: 'deep', + }) + ).toMatchSnapshot(); + }); + }); + + it('renders builder components in materialized view with different options', async () => { + const accelerationFormData: CreateAccelerationForm = { + ...createAccelerationEmptyDataMock, + accelerationIndexType: 'materialized', + materializedViewQueryData: materializedViewValidDataMock, + }; + const setAccelerationFormData = jest.fn(); + const wrapper = mount( + + ); + wrapper.update(); + await waitFor(() => { + expect( + toJson(wrapper, { + noKey: false, + mode: 'deep', + }) + ).toMatchSnapshot(); + }); + }); +}); diff --git a/public/components/datasources/components/manage/accelerations/visual_editors/materialized_view/add_column_popover.tsx b/public/components/datasources/components/manage/accelerations/visual_editors/materialized_view/add_column_popover.tsx new file mode 100644 index 0000000000..ad84431768 --- /dev/null +++ b/public/components/datasources/components/manage/accelerations/visual_editors/materialized_view/add_column_popover.tsx @@ -0,0 +1,155 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + EuiButton, + EuiButtonEmpty, + EuiComboBox, + EuiComboBoxOptionOption, + EuiFieldText, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiPopover, + EuiPopoverFooter, + EuiPopoverTitle, + EuiSpacer, + htmlIdGenerator, +} from '@elastic/eui'; +import producer from 'immer'; +import React, { ChangeEvent, useEffect, useState } from 'react'; +import { ACCELERATION_AGGREGRATION_FUNCTIONS } from '../../../../../../../../common/constants/data_sources'; +import { + AggregationFunctionType, + CreateAccelerationForm, + MaterializedViewColumn, +} from '../../../../../../../../common/types/data_connections'; + +interface AddColumnPopOverProps { + isColumnPopOverOpen: boolean; + setIsColumnPopOverOpen: React.Dispatch>; + columnExpressionValues: MaterializedViewColumn[]; + setColumnExpressionValues: React.Dispatch>; + accelerationFormData: CreateAccelerationForm; + setAccelerationFormData: React.Dispatch>; +} + +export const AddColumnPopOver = ({ + isColumnPopOverOpen, + setIsColumnPopOverOpen, + columnExpressionValues, + setColumnExpressionValues, + accelerationFormData, + setAccelerationFormData, +}: AddColumnPopOverProps) => { + const [selectedFunction, setSelectedFunction] = useState([ + ACCELERATION_AGGREGRATION_FUNCTIONS[0], + ]); + const [selectedField, setSelectedField] = useState([]); + const [selectedAlias, setSeletedAlias] = useState(''); + + const resetSelectedField = () => { + if (accelerationFormData.dataTableFields.length > 0) { + const defaultFieldName = accelerationFormData.dataTableFields[0].fieldName; + setSelectedField([{ label: defaultFieldName }]); + } + }; + + const resetValues = () => { + setSelectedFunction([ACCELERATION_AGGREGRATION_FUNCTIONS[0]]); + resetSelectedField(); + setSeletedAlias(''); + }; + + const onChangeAlias = (e: ChangeEvent) => { + setSeletedAlias(e.target.value); + }; + + const onAddExpression = () => { + const newColumnExpresionValue = [ + ...columnExpressionValues, + { + id: htmlIdGenerator()(), + functionName: selectedFunction[0].label as AggregationFunctionType, + functionParam: selectedField[0].label, + fieldAlias: selectedAlias, + }, + ]; + + setAccelerationFormData( + producer((accData) => { + accData.materializedViewQueryData.columnsValues = newColumnExpresionValue; + }) + ); + + setColumnExpressionValues(newColumnExpresionValue); + setIsColumnPopOverOpen(false); + }; + + useEffect(() => { + resetSelectedField(); + }, []); + + return ( + { + resetValues(); + setIsColumnPopOverOpen(!isColumnPopOverOpen); + }} + size="xs" + > + Add Column + + } + isOpen={isColumnPopOverOpen} + closePopover={() => setIsColumnPopOverOpen(false)} + > + Add Column + <> + + + + + + + + + ({ label: x.fieldName })), + ]} + selectedOptions={selectedField} + onChange={setSelectedField} + isClearable={false} + /> + + + + + + + + + + + Add + + + + ); +}; diff --git a/public/components/datasources/components/manage/accelerations/visual_editors/materialized_view/column_expression.tsx b/public/components/datasources/components/manage/accelerations/visual_editors/materialized_view/column_expression.tsx new file mode 100644 index 0000000000..779bc4cb5d --- /dev/null +++ b/public/components/datasources/components/manage/accelerations/visual_editors/materialized_view/column_expression.tsx @@ -0,0 +1,190 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + EuiButtonIcon, + EuiComboBox, + EuiExpression, + EuiFieldText, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiPopover, +} from '@elastic/eui'; +import producer from 'immer'; +import _ from 'lodash'; +import React, { useState } from 'react'; +import { ACCELERATION_AGGREGRATION_FUNCTIONS } from '../../../../../../../../common/constants/data_sources'; +import { + AggregationFunctionType, + CreateAccelerationForm, + MaterializedViewColumn, +} from '../../../../../../../../common/types/data_connections'; + +interface ColumnExpressionProps { + index: number; + currentColumnExpressionValue: MaterializedViewColumn; + columnExpressionValues: MaterializedViewColumn[]; + setColumnExpressionValues: React.Dispatch>; + accelerationFormData: CreateAccelerationForm; + setAccelerationFormData: React.Dispatch>; +} + +export const ColumnExpression = ({ + index, + currentColumnExpressionValue, + columnExpressionValues, + setColumnExpressionValues, + accelerationFormData, + setAccelerationFormData, +}: ColumnExpressionProps) => { + const [isFunctionPopOverOpen, setIsFunctionPopOverOpen] = useState(false); + const [isAliasPopOverOpen, setIsAliasPopOverOpen] = useState(false); + + const updateColumnExpressionValue = (newValue: MaterializedViewColumn, columnIndex: number) => { + const updatedArray = [...columnExpressionValues]; + updatedArray[columnIndex] = newValue; + setColumnExpressionValues(updatedArray); + }; + + const onDeleteColumnExpression = () => { + const newColumnExpresionValue = [ + ..._.filter(columnExpressionValues, (o) => o.id !== currentColumnExpressionValue.id), + ]; + setAccelerationFormData( + producer((accData) => { + accData.materializedViewQueryData.columnsValues = newColumnExpresionValue; + }) + ); + setColumnExpressionValues(newColumnExpresionValue); + }; + + return ( + + + + { + setIsAliasPopOverOpen(false); + setIsFunctionPopOverOpen(true); + }} + /> + } + isOpen={isFunctionPopOverOpen} + closePopover={() => setIsFunctionPopOverOpen(false)} + panelPaddingSize="s" + anchorPosition="downLeft" + > + <> + + + + + updateColumnExpressionValue( + { + ...currentColumnExpressionValue, + functionName: functionOption[0].label as AggregationFunctionType, + }, + index + ) + } + isClearable={false} + /> + + + + + ({ + label: x.fieldName, + })), + ]} + selectedOptions={[ + { + label: currentColumnExpressionValue.functionParam, + }, + ]} + onChange={(fieldOption) => + updateColumnExpressionValue( + { ...currentColumnExpressionValue, functionParam: fieldOption[0].label }, + index + ) + } + isClearable={false} + /> + + + + + + + {currentColumnExpressionValue.fieldAlias !== '' && ( + + { + setIsFunctionPopOverOpen(false); + setIsAliasPopOverOpen(true); + }} + /> + } + isOpen={isAliasPopOverOpen} + closePopover={() => setIsAliasPopOverOpen(false)} + panelPaddingSize="s" + anchorPosition="downLeft" + > + + + updateColumnExpressionValue( + { ...currentColumnExpressionValue, fieldAlias: e.target.value }, + index + ) + } + /> + + + + )} + + + + + + ); +}; diff --git a/public/components/datasources/components/manage/accelerations/visual_editors/materialized_view/group_by_tumble_expression.tsx b/public/components/datasources/components/manage/accelerations/visual_editors/materialized_view/group_by_tumble_expression.tsx new file mode 100644 index 0000000000..11486f7e60 --- /dev/null +++ b/public/components/datasources/components/manage/accelerations/visual_editors/materialized_view/group_by_tumble_expression.tsx @@ -0,0 +1,129 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + EuiComboBox, + EuiComboBoxOptionOption, + EuiExpression, + EuiFieldNumber, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiPopover, + EuiSelect, +} from '@elastic/eui'; +import producer from 'immer'; +import React, { useState } from 'react'; +import { ACCELERATION_TIME_INTERVAL } from '../../../../../../../../common/constants/data_sources'; +import { + CreateAccelerationForm, + GroupByTumbleType, +} from '../../../../../../../../common/types/data_connections'; +import { hasError, pluralizeTime } from '../../create/utils'; + +interface GroupByTumbleExpressionProps { + accelerationFormData: CreateAccelerationForm; + setAccelerationFormData: React.Dispatch>; +} + +export const GroupByTumbleExpression = ({ + accelerationFormData, + setAccelerationFormData, +}: GroupByTumbleExpressionProps) => { + const [IsGroupPopOverOpen, setIsGroupPopOverOpen] = useState(false); + const [groupbyValues, setGroupByValues] = useState({ + timeField: '', + tumbleWindow: 1, + tumbleInterval: ACCELERATION_TIME_INTERVAL[0].value, + }); + + const updateGroupByStates = (newGroupByValue: GroupByTumbleType) => { + setGroupByValues(newGroupByValue); + setAccelerationFormData( + producer((accData) => { + accData.materializedViewQueryData.groupByTumbleValue = newGroupByValue; + }) + ); + }; + + const onChangeTumbleWindow = (e: React.ChangeEvent) => { + const newGroupByValue = { ...groupbyValues, tumbleWindow: parseInt(e.target.value, 10) }; + updateGroupByStates(newGroupByValue); + }; + + const onChangeTumbleInterval = (e: React.ChangeEvent) => { + const newGroupByValue = { ...groupbyValues, tumbleInterval: e.target.value }; + updateGroupByStates(newGroupByValue); + }; + + const onChangeTimeField = (selectedOptions: EuiComboBoxOptionOption[]) => { + if (selectedOptions.length > 0) { + const newGroupByValue = { ...groupbyValues, timeField: selectedOptions[0].label }; + updateGroupByStates(newGroupByValue); + } + }; + + return ( + + setIsGroupPopOverOpen(true)} + isInvalid={ + hasError(accelerationFormData.formErrors, 'materializedViewError') && + groupbyValues.timeField === '' + } + /> + } + isOpen={IsGroupPopOverOpen} + closePopover={() => setIsGroupPopOverOpen(false)} + panelPaddingSize="s" + anchorPosition="downLeft" + > + + + + value.dataType.includes(TIMESTAMP_DATATYPE)) + .map((value) => ({ label: value.fieldName }))} + selectedOptions={[{ label: groupbyValues.timeField }]} + onChange={onChangeTimeField} + isClearable={false} + /> + + + + + + + + + + + + + + + + ); +}; diff --git a/public/components/datasources/components/manage/accelerations/visual_editors/materialized_view/materialized_view_builder.tsx b/public/components/datasources/components/manage/accelerations/visual_editors/materialized_view/materialized_view_builder.tsx new file mode 100644 index 0000000000..d7f9150508 --- /dev/null +++ b/public/components/datasources/components/manage/accelerations/visual_editors/materialized_view/materialized_view_builder.tsx @@ -0,0 +1,122 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + EuiExpression, + EuiFlexGroup, + EuiFlexItem, + EuiSpacer, + EuiText, + htmlIdGenerator, +} from '@elastic/eui'; +import producer from 'immer'; +import { map } from 'lodash'; +import React, { useEffect, useState } from 'react'; +import { + AggregationFunctionType, + CreateAccelerationForm, + MaterializedViewColumn, +} from '../../../../../../../../common/types/data_connections'; +import { hasError } from '../../create/utils'; +import { AddColumnPopOver } from './add_column_popover'; +import { ColumnExpression } from './column_expression'; +import { GroupByTumbleExpression } from './group_by_tumble_expression'; + +interface MaterializedViewBuilderProps { + accelerationFormData: CreateAccelerationForm; + setAccelerationFormData: React.Dispatch>; +} + +const newColumnExpressionId = htmlIdGenerator()(); + +export const MaterializedViewBuilder = ({ + accelerationFormData, + setAccelerationFormData, +}: MaterializedViewBuilderProps) => { + const [isColumnPopOverOpen, setIsColumnPopOverOpen] = useState(false); + const [columnExpressionValues, setColumnExpressionValues] = useState( + [] + ); + + useEffect(() => { + if (accelerationFormData.dataTableFields.length > 0) { + const newColumnExpresionValue = [ + { + id: newColumnExpressionId, + functionName: 'count' as AggregationFunctionType, + functionParam: accelerationFormData.dataTableFields[0].fieldName, + fieldAlias: 'counter1', + }, + ]; + setAccelerationFormData( + producer((accData) => { + accData.materializedViewQueryData.columnsValues = newColumnExpresionValue; + }) + ); + setColumnExpressionValues(newColumnExpresionValue); + } + }, [accelerationFormData.dataTableFields]); + + return ( + <> + +

Materialized view definition

+
+ + + + + + + + + + + + + {map(columnExpressionValues, (_, i) => { + return ( + + ); + })} + + + + + + + ); +}; diff --git a/public/components/datasources/components/manage/accelerations/visual_editors/query_builder.tsx b/public/components/datasources/components/manage/accelerations/visual_editors/query_builder.tsx new file mode 100644 index 0000000000..28a1be85ad --- /dev/null +++ b/public/components/datasources/components/manage/accelerations/visual_editors/query_builder.tsx @@ -0,0 +1,203 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + CreateAccelerationForm, + GroupByTumbleType, + MaterializedViewColumn, + SkippingIndexRowType, +} from '../../../../../../../common/types/data_connections'; +import { pluralizeTime } from '../create/utils'; + +/* Add index options to query */ +export const buildIndexOptions = (accelerationformData: CreateAccelerationForm) => { + const { + primaryShardsCount, + replicaShardsCount, + refreshType, + checkpointLocation, + accelerationIndexType, + } = accelerationformData; + const indexOptions: string[] = []; + + // Add index settings option + indexOptions.push( + `index_settings = '{"number_of_shards":${primaryShardsCount},"number_of_replicas":${replicaShardsCount}}'` + ); + + // Add auto refresh option + indexOptions.push(`auto_refresh = ${['auto', 'interval'].includes(refreshType)}`); + + // Add refresh interval option + if (refreshType === 'interval') { + const { refreshWindow, refreshInterval } = accelerationformData.refreshIntervalOptions; + indexOptions.push( + `refresh_interval = '${refreshWindow} ${refreshInterval}${pluralizeTime(refreshWindow)}'` + ); + } + + // Add watermark delay option with materialized view + if (accelerationIndexType === 'materialized') { + const { delayWindow, delayInterval } = accelerationformData.watermarkDelay; + indexOptions.push( + `watermark_delay = '${delayWindow} ${delayInterval}${pluralizeTime(delayWindow)}'` + ); + } + + if (refreshType !== 'manual' && checkpointLocation) { + // Add checkpoint location option + indexOptions.push(`checkpoint_location = '${checkpointLocation}'`); + } + + // Combine all options with commas and return as a single string + return `WITH (\n${indexOptions.join(',\n')}\n)`; +}; + +/* Add skipping index columns to query */ +const buildSkippingIndexColumns = (skippingIndexQueryData: SkippingIndexRowType[]) => { + return skippingIndexQueryData + .map((n) => ` \`${n.fieldName}\` ${n.accelerationMethod}`) + .join(', \n'); +}; + +/* + * Builds create skipping index query + * Skipping Index create query example: + * + * CREATE SKIPPING INDEX + * ON datasource.database.table ( + * `field1` VALUE_SET, + * `field2` PARTITION, + * `field3` MIN_MAX, + * ) WITH ( + * auto_refresh = true, + * refresh_interval = '1 minute', + * checkpoint_location = 's3://test/', + * index_settings = '{"number_of_shards":9,"number_of_replicas":2}' + * ) + */ +export const skippingIndexQueryBuilder = (accelerationformData: CreateAccelerationForm) => { + const { dataSource, database, dataTable, skippingIndexQueryData } = accelerationformData; + + const codeQuery = `CREATE SKIPPING INDEX +ON ${dataSource}.${database}.${dataTable} ( +${buildSkippingIndexColumns(skippingIndexQueryData)} + ) ${buildIndexOptions(accelerationformData)}`; + + return codeQuery; +}; + +/* Add covering index columns to query */ +const buildCoveringIndexColumns = (coveringIndexQueryData: string[]) => { + return coveringIndexQueryData.map((field) => ` \`${field}\``).join(', \n'); +}; + +/* + * Builds create covering index query + * Covering Index create query example: + * + * CREATE INDEX index_name + * ON datasource.database.table ( + * `field1`, + * `field2`, + * `field3`, + * ) WITH ( + * auto_refresh = true, + * refresh_interval = '1 minute', + * checkpoint_location = 's3://test/', + * index_settings = '{"number_of_shards":9,"number_of_replicas":2}' + * ) + */ +export const coveringIndexQueryBuilder = (accelerationformData: CreateAccelerationForm) => { + const { + dataSource, + database, + dataTable, + accelerationIndexName, + coveringIndexQueryData, + } = accelerationformData; + + const codeQuery = `CREATE INDEX ${accelerationIndexName} +ON ${dataSource}.${database}.${dataTable} ( +${buildCoveringIndexColumns(coveringIndexQueryData)} + ) ${buildIndexOptions(accelerationformData)}`; + + return codeQuery; +}; + +const buildMaterializedViewColumnName = (columnName: string) => { + return columnName === '*' ? columnName : `\`${columnName}\``; +}; + +const buildMaterializedViewColumns = (columnsValues: MaterializedViewColumn[]) => { + return columnsValues + .map( + (column) => + ` ${column.functionName}(${buildMaterializedViewColumnName(column.functionParam)})${ + column.fieldAlias ? ` AS \`${column.fieldAlias}\`` : `` + }` + ) + .join(', \n'); +}; + +/* Build group by tumble values */ +const buildTumbleValue = (GroupByTumbleValue: GroupByTumbleType) => { + const { timeField, tumbleWindow, tumbleInterval } = GroupByTumbleValue; + return `(\`${timeField}\`, '${tumbleWindow} ${tumbleInterval}${pluralizeTime(tumbleWindow)}')`; +}; + +/* + * Builds create materialized view query + * Materialized View create query example: + * + * CREATE MATERIALIZED VIEW datasource.database.index_name + * AS SELECT + * count(`field`) as `counter`, + * count(*) as `counter1`, + * sum(`field2`), + * avg(`field3`) as `average` + * WITH ( + * auto_refresh = true, + * refresh_interval = '1 minute', + * checkpoint_location = 's3://test/', + * index_settings = '{"number_of_shards":9,"number_of_replicas":2}' + * ) + */ +export const materializedQueryViewBuilder = (accelerationformData: CreateAccelerationForm) => { + const { + dataSource, + database, + dataTable, + accelerationIndexName, + materializedViewQueryData, + } = accelerationformData; + + const codeQuery = `CREATE MATERIALIZED VIEW ${dataSource}.${database}.${accelerationIndexName} +AS SELECT +${buildMaterializedViewColumns(materializedViewQueryData.columnsValues)} +FROM ${dataSource}.${database}.${dataTable} +GROUP BY TUMBLE ${buildTumbleValue(materializedViewQueryData.groupByTumbleValue)} + ${buildIndexOptions(accelerationformData)}`; + + return codeQuery; +}; + +/* Builds create acceleration index query */ +export const accelerationQueryBuilder = (accelerationformData: CreateAccelerationForm) => { + switch (accelerationformData.accelerationIndexType) { + case 'skipping': { + return skippingIndexQueryBuilder(accelerationformData); + } + case 'covering': { + return coveringIndexQueryBuilder(accelerationformData); + } + case 'materialized': { + return materializedQueryViewBuilder(accelerationformData); + } + default: { + return ''; + } + } +}; diff --git a/public/components/datasources/components/manage/accelerations/visual_editors/query_visual_editor.tsx b/public/components/datasources/components/manage/accelerations/visual_editors/query_visual_editor.tsx new file mode 100644 index 0000000000..15877796d5 --- /dev/null +++ b/public/components/datasources/components/manage/accelerations/visual_editors/query_visual_editor.tsx @@ -0,0 +1,45 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { EuiSpacer } from '@elastic/eui'; +import React from 'react'; +import { CreateAccelerationForm } from '../../../../../../../common/types/data_connections'; +import { CoveringIndexBuilder } from './covering_index/covering_index_builder'; +import { MaterializedViewBuilder } from './materialized_view/materialized_view_builder'; +import { SkippingIndexBuilder } from './skipping_index/skipping_index_builder'; + +interface QueryVisualEditorProps { + accelerationFormData: CreateAccelerationForm; + setAccelerationFormData: React.Dispatch>; +} + +export const QueryVisualEditor = ({ + accelerationFormData, + setAccelerationFormData, +}: QueryVisualEditorProps) => { + return ( + <> + + {accelerationFormData.accelerationIndexType === 'skipping' && ( + + )} + {accelerationFormData.accelerationIndexType === 'covering' && ( + + )} + {accelerationFormData.accelerationIndexType === 'materialized' && ( + + )} + + ); +}; diff --git a/public/components/datasources/components/manage/accelerations/visual_editors/skipping_index/__tests__/__snapshots__/add_fields_modal.test.tsx.snap b/public/components/datasources/components/manage/accelerations/visual_editors/skipping_index/__tests__/__snapshots__/add_fields_modal.test.tsx.snap new file mode 100644 index 0000000000..4ddc4bc212 --- /dev/null +++ b/public/components/datasources/components/manage/accelerations/visual_editors/skipping_index/__tests__/__snapshots__/add_fields_modal.test.tsx.snap @@ -0,0 +1,1533 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Add fields modal in skipping index renders add fields modal in skipping index with default options 1`] = ` + +