diff --git a/playwright.config.ts b/playwright.config.ts index 4c5ad3c14..80ba367bf 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,5 +1,4 @@ import { defineConfig, devices } from "@playwright/test"; - /** * See https://playwright.dev/docs/test-configuration. */ @@ -29,7 +28,12 @@ export default defineConfig({ projects: [ { name: "chromium", - use: { ...devices["Desktop Chrome"] }, + use: { + ...devices["Desktop Chrome"], + launchOptions: { + args: ["--disable-web-security", "--disable-features=IsolateOrigins,site-per-process"], + }, + }, }, { name: "firefox", diff --git a/src/Common/Constants.ts b/src/Common/Constants.ts index 94ca16c27..37243de72 100644 --- a/src/Common/Constants.ts +++ b/src/Common/Constants.ts @@ -530,6 +530,9 @@ export class ariaLabelForLearnMoreLink { public static readonly AzureSynapseLink = "Learn more about Azure Synapse Link."; } +export class GlobalSecondaryIndexLabels { + public static readonly NewGlobalSecondaryIndex: string = "New Global Secondary Index"; +} export class FeedbackLabels { public static readonly provideFeedback: string = "Provide feedback"; } diff --git a/src/Common/CosmosClient.ts b/src/Common/CosmosClient.ts index cf34b2279..1ecd94944 100644 --- a/src/Common/CosmosClient.ts +++ b/src/Common/CosmosClient.ts @@ -125,7 +125,11 @@ export const endpoint = () => { const location = _global.parent ? _global.parent.location : _global.location; return configContext.EMULATOR_ENDPOINT || location.origin; } - return userContext.endpoint || userContext?.databaseAccount?.properties?.documentEndpoint; + return ( + userContext.selectedRegionalEndpoint || + userContext.endpoint || + userContext?.databaseAccount?.properties?.documentEndpoint + ); }; export async function getTokenFromAuthService( @@ -203,6 +207,7 @@ export function client(): Cosmos.CosmosClient { userAgentSuffix: "Azure Portal", defaultHeaders: _defaultHeaders, connectionPolicy: { + enableEndpointDiscovery: !userContext.selectedRegionalEndpoint, retryOptions: { maxRetryAttemptCount: LocalStorageUtility.getEntryNumber(StorageKey.RetryAttempts), fixedRetryIntervalInMilliseconds: LocalStorageUtility.getEntryNumber(StorageKey.RetryInterval), diff --git a/src/Common/DatabaseAccountUtility.ts b/src/Common/DatabaseAccountUtility.ts index c72d3baf6..50ec0064a 100644 --- a/src/Common/DatabaseAccountUtility.ts +++ b/src/Common/DatabaseAccountUtility.ts @@ -1,5 +1,6 @@ import { TagNames, WorkloadType } from "Common/Constants"; import { Tags } from "Contracts/DataModels"; +import { isFabric } from "Platform/Fabric/FabricUtil"; import { userContext } from "../UserContext"; function isVirtualNetworkFilterEnabled() { @@ -26,3 +27,9 @@ export function getWorkloadType(): WorkloadType { } return workloadType; } + +export function isGlobalSecondaryIndexEnabled(): boolean { + return ( + !isFabric() && userContext.apiType === "SQL" && userContext.databaseAccount?.properties?.enableMaterializedViews + ); +} diff --git a/src/Common/IteratorUtilities.ts b/src/Common/IteratorUtilities.ts index 6283488b8..0cbe8ea63 100644 --- a/src/Common/IteratorUtilities.ts +++ b/src/Common/IteratorUtilities.ts @@ -1,5 +1,8 @@ import { QueryOperationOptions } from "@azure/cosmos"; +import { Action } from "Shared/Telemetry/TelemetryConstants"; +import * as Constants from "../Common/Constants"; import { QueryResults } from "../Contracts/ViewModels"; +import * as TelemetryProcessor from "../Shared/Telemetry/TelemetryProcessor"; interface QueryResponse { // [Todo] remove any @@ -21,7 +24,9 @@ export function nextPage( firstItemIndex: number, queryOperationOptions?: QueryOperationOptions, ): Promise { + TelemetryProcessor.traceStart(Action.ExecuteQuery); return documentsIterator.fetchNext(queryOperationOptions).then((response) => { + TelemetryProcessor.traceSuccess(Action.ExecuteQuery, { dataExplorerArea: Constants.Areas.Tab }); const documents = response.resources; // eslint-disable-next-line @typescript-eslint/no-explicit-any const headers = (response as any).headers || {}; // TODO this is a private key. Remove any diff --git a/src/Common/dataAccess/createMaterializedView.ts b/src/Common/dataAccess/createMaterializedView.ts new file mode 100644 index 000000000..659da9c14 --- /dev/null +++ b/src/Common/dataAccess/createMaterializedView.ts @@ -0,0 +1,74 @@ +import { constructRpOptions } from "Common/dataAccess/createCollection"; +import { handleError } from "Common/ErrorHandlingUtils"; +import { Collection, CreateMaterializedViewsParams as CreateGlobalSecondaryIndexParams } from "Contracts/DataModels"; +import { userContext } from "UserContext"; +import { createUpdateSqlContainer } from "Utils/arm/generatedClients/cosmos/sqlResources"; +import { + CreateUpdateOptions, + SqlContainerResource, + SqlDatabaseCreateUpdateParameters, +} from "Utils/arm/generatedClients/cosmos/types"; +import { logConsoleInfo, logConsoleProgress } from "Utils/NotificationConsoleUtils"; + +export const createGlobalSecondaryIndex = async (params: CreateGlobalSecondaryIndexParams): Promise => { + const clearMessage = logConsoleProgress( + `Creating a new global secondary index ${params.materializedViewId} for database ${params.databaseId}`, + ); + + const options: CreateUpdateOptions = constructRpOptions(params); + + const resource: SqlContainerResource = { + id: params.materializedViewId, + }; + if (params.materializedViewDefinition) { + resource.materializedViewDefinition = params.materializedViewDefinition; + } + if (params.analyticalStorageTtl) { + resource.analyticalStorageTtl = params.analyticalStorageTtl; + } + if (params.indexingPolicy) { + resource.indexingPolicy = params.indexingPolicy; + } + if (params.partitionKey) { + resource.partitionKey = params.partitionKey; + } + if (params.uniqueKeyPolicy) { + resource.uniqueKeyPolicy = params.uniqueKeyPolicy; + } + if (params.vectorEmbeddingPolicy) { + resource.vectorEmbeddingPolicy = params.vectorEmbeddingPolicy; + } + if (params.fullTextPolicy) { + resource.fullTextPolicy = params.fullTextPolicy; + } + + const rpPayload: SqlDatabaseCreateUpdateParameters = { + properties: { + resource, + options, + }, + }; + + try { + const createResponse = await createUpdateSqlContainer( + userContext.subscriptionId, + userContext.resourceGroup, + userContext.databaseAccount.name, + params.databaseId, + params.materializedViewId, + rpPayload, + ); + logConsoleInfo(`Successfully created global secondary index ${params.materializedViewId}`); + + return createResponse && (createResponse.properties.resource as Collection); + } catch (error) { + handleError( + error, + "CreateGlobalSecondaryIndex", + `Error while creating global secondary index ${params.materializedViewId}`, + ); + throw error; + } finally { + clearMessage(); + } +}; diff --git a/src/Common/dataAccess/readCollectionOffer.ts b/src/Common/dataAccess/readCollectionOffer.ts index 6fb6e9e4b..d3c8e25cd 100644 --- a/src/Common/dataAccess/readCollectionOffer.ts +++ b/src/Common/dataAccess/readCollectionOffer.ts @@ -1,3 +1,4 @@ +import { isFabric } from "Platform/Fabric/FabricUtil"; import { AuthType } from "../../AuthType"; import { Offer, ReadCollectionOfferParams } from "../../Contracts/DataModels"; import { userContext } from "../../UserContext"; @@ -13,6 +14,11 @@ import { readOfferWithSDK } from "./readOfferWithSDK"; export const readCollectionOffer = async (params: ReadCollectionOfferParams): Promise => { const clearMessage = logConsoleProgress(`Querying offer for collection ${params.collectionId}`); + if (isFabric()) { + // Not exposing offers in Fabric + return undefined; + } + try { if ( userContext.authType === AuthType.AAD && diff --git a/src/Common/dataAccess/readCollections.ts b/src/Common/dataAccess/readCollections.ts index ecb67c876..39e241cda 100644 --- a/src/Common/dataAccess/readCollections.ts +++ b/src/Common/dataAccess/readCollections.ts @@ -126,5 +126,12 @@ async function readCollectionsWithARM(databaseId: string): Promise collection.properties?.resource as DataModels.Collection); + // TO DO: Remove when we get RP API Spec with materializedViews + /* eslint-disable @typescript-eslint/no-explicit-any */ + return rpResponse?.value?.map((collection: any) => { + const collectionDataModel: DataModels.Collection = collection.properties?.resource as DataModels.Collection; + collectionDataModel.materializedViews = collection.properties?.resource?.materializedViews; + collectionDataModel.materializedViewDefinition = collection.properties?.resource?.materializedViewDefinition; + return collectionDataModel; + }); } diff --git a/src/Contracts/DataModels.ts b/src/Contracts/DataModels.ts index 08d0ac3f8..7bf489fd6 100644 --- a/src/Contracts/DataModels.ts +++ b/src/Contracts/DataModels.ts @@ -32,6 +32,7 @@ export interface DatabaseAccountExtendedProperties { writeLocations?: DatabaseAccountResponseLocation[]; enableFreeTier?: boolean; enableAnalyticalStorage?: boolean; + enableMaterializedViews?: boolean; isVirtualNetworkFilterEnabled?: boolean; ipRules?: IpRule[]; privateEndpointConnections?: unknown[]; @@ -164,6 +165,8 @@ export interface Collection extends Resource { schema?: ISchema; requestSchema?: () => void; computedProperties?: ComputedProperties; + materializedViews?: MaterializedView[]; + materializedViewDefinition?: MaterializedViewDefinition; } export interface CollectionsWithPagination { @@ -223,6 +226,17 @@ export interface ComputedProperty { export type ComputedProperties = ComputedProperty[]; +export interface MaterializedView { + id: string; + _rid: string; +} + +export interface MaterializedViewDefinition { + definition: string; + sourceCollectionId: string; + sourceCollectionRid?: string; +} + export interface PartitionKey { paths: string[]; kind: "Hash" | "Range" | "MultiHash"; @@ -345,9 +359,7 @@ export interface CreateDatabaseParams { offerThroughput?: number; } -export interface CreateCollectionParams { - createNewDatabase: boolean; - collectionId: string; +export interface CreateCollectionParamsBase { databaseId: string; databaseLevelThroughput: boolean; offerThroughput?: number; @@ -361,6 +373,16 @@ export interface CreateCollectionParams { fullTextPolicy?: FullTextPolicy; } +export interface CreateCollectionParams extends CreateCollectionParamsBase { + createNewDatabase: boolean; + collectionId: string; +} + +export interface CreateMaterializedViewsParams extends CreateCollectionParamsBase { + materializedViewId: string; + materializedViewDefinition: MaterializedViewDefinition; +} + export interface VectorEmbeddingPolicy { vectorEmbeddings: VectorEmbedding[]; } diff --git a/src/Contracts/FabricMessagesContract.ts b/src/Contracts/FabricMessagesContract.ts index 2cc99c578..8cc198a11 100644 --- a/src/Contracts/FabricMessagesContract.ts +++ b/src/Contracts/FabricMessagesContract.ts @@ -81,6 +81,13 @@ export type FabricMessageV3 = error: string | undefined; data: { accessToken: string }; }; + } + | { + type: "refreshResourceTree"; + message: { + id: string; + error: string | undefined; + }; }; export enum CosmosDbArtifactType { diff --git a/src/Contracts/ViewModels.ts b/src/Contracts/ViewModels.ts index 83afa9ddb..5aa393cbf 100644 --- a/src/Contracts/ViewModels.ts +++ b/src/Contracts/ViewModels.ts @@ -1,4 +1,5 @@ import { + JSONObject, QueryMetrics, Resource, StoredProcedureDefinition, @@ -143,6 +144,8 @@ export interface Collection extends CollectionBase { geospatialConfig: ko.Observable; documentIds: ko.ObservableArray; computedProperties: ko.Observable; + materializedViews: ko.Observable; + materializedViewDefinition: ko.Observable; cassandraKeys: CassandraTableKeys; cassandraSchema: CassandraTableKey[]; @@ -204,6 +207,12 @@ export interface Collection extends CollectionBase { onDragOver(source: Collection, event: { originalEvent: DragEvent }): void; onDrop(source: Collection, event: { originalEvent: DragEvent }): void; uploadFiles(fileList: FileList): Promise<{ data: UploadDetailsRecord[] }>; + bulkInsertDocuments(documents: JSONObject[]): Promise<{ + numSucceeded: number; + numFailed: number; + numThrottled: number; + errors: string[]; + }>; } /** diff --git a/src/Explorer/ContextMenuButtonFactory.tsx b/src/Explorer/ContextMenuButtonFactory.tsx index 8108cbbb2..3cb4c7a80 100644 --- a/src/Explorer/ContextMenuButtonFactory.tsx +++ b/src/Explorer/ContextMenuButtonFactory.tsx @@ -1,5 +1,11 @@ +import { GlobalSecondaryIndexLabels } from "Common/Constants"; +import { isGlobalSecondaryIndexEnabled } from "Common/DatabaseAccountUtility"; import { configContext, Platform } from "ConfigContext"; import { TreeNodeMenuItem } from "Explorer/Controls/TreeComponent/TreeNodeComponent"; +import { + AddGlobalSecondaryIndexPanel, + AddGlobalSecondaryIndexPanelProps, +} from "Explorer/Panes/AddGlobalSecondaryIndexPanel/AddGlobalSecondaryIndexPanel"; import { useDatabases } from "Explorer/useDatabases"; import { isFabric, isFabricNative } from "Platform/Fabric/FabricUtil"; import { Action } from "Shared/Telemetry/TelemetryConstants"; @@ -164,6 +170,24 @@ export const createCollectionContextMenuButton = ( }); } + if (isGlobalSecondaryIndexEnabled() && !selectedCollection.materializedViewDefinition()) { + items.push({ + label: GlobalSecondaryIndexLabels.NewGlobalSecondaryIndex, + onClick: () => { + const addMaterializedViewPanelProps: AddGlobalSecondaryIndexPanelProps = { + explorer: container, + sourceContainer: selectedCollection, + }; + useSidePanel + .getState() + .openSidePanel( + GlobalSecondaryIndexLabels.NewGlobalSecondaryIndex, + , + ); + }, + }); + } + return items; }; diff --git a/src/Explorer/Controls/Dialog.tsx b/src/Explorer/Controls/Dialog.tsx index 9f69d4761..a4c50a3fd 100644 --- a/src/Explorer/Controls/Dialog.tsx +++ b/src/Explorer/Controls/Dialog.tsx @@ -214,8 +214,10 @@ export const Dialog: FC = () => { {contentHtml} {progressIndicatorProps && } - - {secondaryButtonProps && } + + {secondaryButtonProps && ( + + )} ) : ( diff --git a/src/Explorer/Controls/Settings/SettingsComponent.tsx b/src/Explorer/Controls/Settings/SettingsComponent.tsx index 720bef874..2613fe65b 100644 --- a/src/Explorer/Controls/Settings/SettingsComponent.tsx +++ b/src/Explorer/Controls/Settings/SettingsComponent.tsx @@ -12,6 +12,7 @@ import { ThroughputBucketsComponentProps, } from "Explorer/Controls/Settings/SettingsSubComponents/ThroughputInputComponents/ThroughputBucketsComponent"; import { useDatabases } from "Explorer/useDatabases"; +import { isFabricNative } from "Platform/Fabric/FabricUtil"; import { isFullTextSearchEnabled, isVectorSearchEnabled } from "Utils/CapabilityUtils"; import { isRunningOnPublicCloud } from "Utils/CloudUtils"; import * as React from "react"; @@ -44,6 +45,10 @@ import { ConflictResolutionComponent, ConflictResolutionComponentProps, } from "./SettingsSubComponents/ConflictResolutionComponent"; +import { + GlobalSecondaryIndexComponent, + GlobalSecondaryIndexComponentProps, +} from "./SettingsSubComponents/GlobalSecondaryIndexComponent"; import { IndexingPolicyComponent, IndexingPolicyComponentProps } from "./SettingsSubComponents/IndexingPolicyComponent"; import { MongoIndexingPolicyComponent, @@ -162,6 +167,7 @@ export class SettingsComponent extends React.Component, + }); + } + const pivotProps: IPivotProps = { onLinkClick: this.onPivotChange, selectedKey: SettingsV2TabTypes[this.state.selectedTab], diff --git a/src/Explorer/Controls/Settings/SettingsSubComponents/GlobalSecondaryIndexComponent.test.tsx b/src/Explorer/Controls/Settings/SettingsSubComponents/GlobalSecondaryIndexComponent.test.tsx new file mode 100644 index 000000000..ac290f93c --- /dev/null +++ b/src/Explorer/Controls/Settings/SettingsSubComponents/GlobalSecondaryIndexComponent.test.tsx @@ -0,0 +1,46 @@ +import { shallow } from "enzyme"; +import React from "react"; +import { collection, container } from "../TestUtils"; +import { GlobalSecondaryIndexComponent } from "./GlobalSecondaryIndexComponent"; +import { GlobalSecondaryIndexSourceComponent } from "./GlobalSecondaryIndexSourceComponent"; +import { GlobalSecondaryIndexTargetComponent } from "./GlobalSecondaryIndexTargetComponent"; + +describe("GlobalSecondaryIndexComponent", () => { + let testCollection: typeof collection; + let testExplorer: typeof container; + + beforeEach(() => { + testCollection = { ...collection }; + }); + + it("renders only the source component when materializedViewDefinition is missing", () => { + testCollection.materializedViews([ + { id: "view1", _rid: "rid1" }, + { id: "view2", _rid: "rid2" }, + ]); + testCollection.materializedViewDefinition(null); + const wrapper = shallow(); + expect(wrapper.find(GlobalSecondaryIndexSourceComponent).exists()).toBe(true); + expect(wrapper.find(GlobalSecondaryIndexTargetComponent).exists()).toBe(false); + }); + + it("renders only the target component when materializedViews is missing", () => { + testCollection.materializedViews(null); + testCollection.materializedViewDefinition({ + definition: "SELECT * FROM c WHERE c.id = 1", + sourceCollectionId: "source1", + sourceCollectionRid: "rid123", + }); + const wrapper = shallow(); + expect(wrapper.find(GlobalSecondaryIndexSourceComponent).exists()).toBe(false); + expect(wrapper.find(GlobalSecondaryIndexTargetComponent).exists()).toBe(true); + }); + + it("renders neither component when both are missing", () => { + testCollection.materializedViews(null); + testCollection.materializedViewDefinition(null); + const wrapper = shallow(); + expect(wrapper.find(GlobalSecondaryIndexSourceComponent).exists()).toBe(false); + expect(wrapper.find(GlobalSecondaryIndexTargetComponent).exists()).toBe(false); + }); +}); diff --git a/src/Explorer/Controls/Settings/SettingsSubComponents/GlobalSecondaryIndexComponent.tsx b/src/Explorer/Controls/Settings/SettingsSubComponents/GlobalSecondaryIndexComponent.tsx new file mode 100644 index 000000000..66aa3313a --- /dev/null +++ b/src/Explorer/Controls/Settings/SettingsSubComponents/GlobalSecondaryIndexComponent.tsx @@ -0,0 +1,41 @@ +import { FontIcon, Link, Stack, Text } from "@fluentui/react"; +import Explorer from "Explorer/Explorer"; +import React from "react"; +import * as ViewModels from "../../../../Contracts/ViewModels"; +import { GlobalSecondaryIndexSourceComponent } from "./GlobalSecondaryIndexSourceComponent"; +import { GlobalSecondaryIndexTargetComponent } from "./GlobalSecondaryIndexTargetComponent"; + +export interface GlobalSecondaryIndexComponentProps { + collection: ViewModels.Collection; + explorer: Explorer; +} + +export const GlobalSecondaryIndexComponent: React.FC = ({ + collection, + explorer, +}) => { + const isTargetContainer = !!collection?.materializedViewDefinition(); + const isSourceContainer = !!collection?.materializedViews(); + + return ( + + + {isSourceContainer && ( + This container has the following indexes defined for it. + )} + + + Learn more + + {" "} + about how to define global secondary indexes and how to use them. + + + {isSourceContainer && } + {isTargetContainer && } + + ); +}; diff --git a/src/Explorer/Controls/Settings/SettingsSubComponents/GlobalSecondaryIndexSourceComponent.test.tsx b/src/Explorer/Controls/Settings/SettingsSubComponents/GlobalSecondaryIndexSourceComponent.test.tsx new file mode 100644 index 000000000..30ac800b9 --- /dev/null +++ b/src/Explorer/Controls/Settings/SettingsSubComponents/GlobalSecondaryIndexSourceComponent.test.tsx @@ -0,0 +1,42 @@ +import { PrimaryButton } from "@fluentui/react"; +import { shallow } from "enzyme"; +import React from "react"; +import { collection, container } from "../TestUtils"; +import { GlobalSecondaryIndexSourceComponent } from "./GlobalSecondaryIndexSourceComponent"; + +describe("GlobalSecondaryIndexSourceComponent", () => { + let testCollection: typeof collection; + let testExplorer: typeof container; + + beforeEach(() => { + testCollection = { ...collection }; + }); + + it("renders without crashing", () => { + const wrapper = shallow( + , + ); + expect(wrapper.exists()).toBe(true); + }); + + it("renders the PrimaryButton", () => { + const wrapper = shallow( + , + ); + expect(wrapper.find(PrimaryButton).exists()).toBe(true); + }); + + it("updates when new global secondary indexes are provided", () => { + const wrapper = shallow( + , + ); + + // Simulating an update by modifying the observable directly + testCollection.materializedViews([{ id: "view3", _rid: "rid3" }]); + + wrapper.setProps({ collection: testCollection }); + wrapper.update(); + + expect(wrapper.find(PrimaryButton).exists()).toBe(true); + }); +}); diff --git a/src/Explorer/Controls/Settings/SettingsSubComponents/GlobalSecondaryIndexSourceComponent.tsx b/src/Explorer/Controls/Settings/SettingsSubComponents/GlobalSecondaryIndexSourceComponent.tsx new file mode 100644 index 000000000..aa0a0edae --- /dev/null +++ b/src/Explorer/Controls/Settings/SettingsSubComponents/GlobalSecondaryIndexSourceComponent.tsx @@ -0,0 +1,114 @@ +import { PrimaryButton } from "@fluentui/react"; +import { GlobalSecondaryIndexLabels } from "Common/Constants"; +import { MaterializedView } from "Contracts/DataModels"; +import Explorer from "Explorer/Explorer"; +import { loadMonaco } from "Explorer/LazyMonaco"; +import { AddGlobalSecondaryIndexPanel } from "Explorer/Panes/AddGlobalSecondaryIndexPanel/AddGlobalSecondaryIndexPanel"; +import { useDatabases } from "Explorer/useDatabases"; +import { useSidePanel } from "hooks/useSidePanel"; +import * as monaco from "monaco-editor"; +import React, { useEffect, useRef } from "react"; +import * as ViewModels from "../../../../Contracts/ViewModels"; + +export interface GlobalSecondaryIndexSourceComponentProps { + collection: ViewModels.Collection; + explorer: Explorer; +} + +export const GlobalSecondaryIndexSourceComponent: React.FC = ({ + collection, + explorer, +}) => { + const editorContainerRef = useRef(null); + const editorRef = useRef(null); + + const globalSecondaryIndexes: MaterializedView[] = collection?.materializedViews() ?? []; + + // Helper function to fetch the definition and partition key of targetContainer by traversing through all collections and matching id from MaterializedView[] with collection id. + const getViewDetails = (viewId: string): { definition: string; partitionKey: string[] } => { + let definition = ""; + let partitionKey: string[] = []; + + useDatabases.getState().databases.find((database) => { + const collection = database.collections().find((collection) => collection.id() === viewId); + if (collection) { + const globalSecondaryIndexDefinition = collection.materializedViewDefinition(); + globalSecondaryIndexDefinition && (definition = globalSecondaryIndexDefinition.definition); + collection.partitionKey?.paths && (partitionKey = collection.partitionKey.paths); + } + }); + + return { definition, partitionKey }; + }; + + //JSON value for the editor using the fetched id and definitions. + const jsonValue = JSON.stringify( + globalSecondaryIndexes.map((view) => { + const { definition, partitionKey } = getViewDetails(view.id); + return { + name: view.id, + partitionKey: partitionKey.join(", "), + definition, + }; + }), + null, + 2, + ); + + // Initialize Monaco editor with the computed JSON value. + useEffect(() => { + let disposed = false; + const initMonaco = async () => { + const monacoInstance = await loadMonaco(); + if (disposed || !editorContainerRef.current) { + return; + } + + editorRef.current = monacoInstance.editor.create(editorContainerRef.current, { + value: jsonValue, + language: "json", + ariaLabel: "Global Secondary Index JSON", + readOnly: true, + }); + }; + + initMonaco(); + return () => { + disposed = true; + editorRef.current?.dispose(); + }; + }, [jsonValue]); + + // Update the editor when the jsonValue changes. + useEffect(() => { + if (editorRef.current) { + editorRef.current.setValue(jsonValue); + } + }, [jsonValue]); + + return ( +
+
+ + useSidePanel + .getState() + .openSidePanel( + GlobalSecondaryIndexLabels.NewGlobalSecondaryIndex, + , + ) + } + /> +
+ ); +}; diff --git a/src/Explorer/Controls/Settings/SettingsSubComponents/GlobalSecondaryIndexTargetComponent.test.tsx b/src/Explorer/Controls/Settings/SettingsSubComponents/GlobalSecondaryIndexTargetComponent.test.tsx new file mode 100644 index 000000000..6296cdab7 --- /dev/null +++ b/src/Explorer/Controls/Settings/SettingsSubComponents/GlobalSecondaryIndexTargetComponent.test.tsx @@ -0,0 +1,32 @@ +import { Text } from "@fluentui/react"; +import { Collection } from "Contracts/ViewModels"; +import { shallow } from "enzyme"; +import React from "react"; +import { collection } from "../TestUtils"; +import { GlobalSecondaryIndexTargetComponent } from "./GlobalSecondaryIndexTargetComponent"; + +describe("GlobalSecondaryIndexTargetComponent", () => { + let testCollection: Collection; + + beforeEach(() => { + testCollection = { + ...collection, + materializedViewDefinition: collection.materializedViewDefinition, + }; + }); + + it("renders without crashing", () => { + const wrapper = shallow(); + expect(wrapper.exists()).toBe(true); + }); + + it("displays the source container ID", () => { + const wrapper = shallow(); + expect(wrapper.find(Text).at(2).dive().text()).toBe("source1"); + }); + + it("displays the global secondary index definition", () => { + const wrapper = shallow(); + expect(wrapper.find(Text).at(4).dive().text()).toBe("SELECT * FROM c WHERE c.id = 1"); + }); +}); diff --git a/src/Explorer/Controls/Settings/SettingsSubComponents/GlobalSecondaryIndexTargetComponent.tsx b/src/Explorer/Controls/Settings/SettingsSubComponents/GlobalSecondaryIndexTargetComponent.tsx new file mode 100644 index 000000000..8fa1171e8 --- /dev/null +++ b/src/Explorer/Controls/Settings/SettingsSubComponents/GlobalSecondaryIndexTargetComponent.tsx @@ -0,0 +1,45 @@ +import { Stack, Text } from "@fluentui/react"; +import * as React from "react"; +import * as ViewModels from "../../../../Contracts/ViewModels"; + +export interface GlobalSecondaryIndexTargetComponentProps { + collection: ViewModels.Collection; +} + +export const GlobalSecondaryIndexTargetComponent: React.FC = ({ + collection, +}) => { + const globalSecondaryIndexDefinition = collection?.materializedViewDefinition(); + + const textHeadingStyle = { + root: { fontWeight: "600", fontSize: 16 }, + }; + + const valueBoxStyle = { + root: { + backgroundColor: "#f3f3f3", + padding: "5px 10px", + borderRadius: "4px", + }, + }; + + return ( + + Global Secondary Index Settings + + + Source container + + {globalSecondaryIndexDefinition?.sourceCollectionId} + + + + + Global secondary index definition + + {globalSecondaryIndexDefinition?.definition} + + + + ); +}; diff --git a/src/Explorer/Controls/Settings/SettingsSubComponents/PartitionKeyComponent.tsx b/src/Explorer/Controls/Settings/SettingsSubComponents/PartitionKeyComponent.tsx index 7800c1109..c6a1bd9d1 100644 --- a/src/Explorer/Controls/Settings/SettingsSubComponents/PartitionKeyComponent.tsx +++ b/src/Explorer/Controls/Settings/SettingsSubComponents/PartitionKeyComponent.tsx @@ -29,16 +29,26 @@ export interface PartitionKeyComponentProps { database: ViewModels.Database; collection: ViewModels.Collection; explorer: Explorer; + isReadOnly?: boolean; // true: cannot change partition key } -export const PartitionKeyComponent: React.FC = ({ database, collection, explorer }) => { +export const PartitionKeyComponent: React.FC = ({ + database, + collection, + explorer, + isReadOnly, +}) => { const { dataTransferJobs } = useDataTransferJobs(); const [portalDataTransferJob, setPortalDataTransferJob] = React.useState(null); React.useEffect(() => { + if (isReadOnly) { + return; + } + const loadDataTransferJobs = refreshDataTransferOperations; loadDataTransferJobs(); - }, []); + }, [isReadOnly]); React.useEffect(() => { const currentJob = findPortalDataTransferJob(); @@ -163,56 +173,61 @@ export const PartitionKeyComponent: React.FC = ({ da - - To safeguard the integrity of the data being copied to the new container, ensure that no updates are made to the - source container for the entire duration of the partition key change process. - - Learn more - - - - To change the partition key, a new destination container must be created or an existing destination container - selected. Data will then be copied to the destination container. - - {configContext.platform !== Platform.Emulator && ( - - )} - {portalDataTransferJob && ( - - {partitionKeyName} change job - - - {isCurrentJobInProgress(portalDataTransferJob) && ( - cancelRunningDataTransferJob(portalDataTransferJob)} /> - )} - - + + {!isReadOnly && ( + <> + + To safeguard the integrity of the data being copied to the new container, ensure that no updates are made to + the source container for the entire duration of the partition key change process. + + Learn more + + + + To change the partition key, a new destination container must be created or an existing destination + container selected. Data will then be copied to the destination container. + + {configContext.platform !== Platform.Emulator && ( + + )} + {portalDataTransferJob && ( + + {partitionKeyName} change job + + + {isCurrentJobInProgress(portalDataTransferJob) && ( + cancelRunningDataTransferJob(portalDataTransferJob)} /> + )} + + + )} + )} ); diff --git a/src/Explorer/Controls/Settings/SettingsUtils.tsx b/src/Explorer/Controls/Settings/SettingsUtils.tsx index 900ad6ab0..448b59370 100644 --- a/src/Explorer/Controls/Settings/SettingsUtils.tsx +++ b/src/Explorer/Controls/Settings/SettingsUtils.tsx @@ -57,6 +57,7 @@ export enum SettingsV2TabTypes { ComputedPropertiesTab, ContainerVectorPolicyTab, ThroughputBucketsTab, + GlobalSecondaryIndexTab, } export enum ContainerPolicyTabTypes { @@ -171,6 +172,8 @@ export const getTabTitle = (tab: SettingsV2TabTypes): string => { return "Container Policies"; case SettingsV2TabTypes.ThroughputBucketsTab: return "Throughput Buckets"; + case SettingsV2TabTypes.GlobalSecondaryIndexTab: + return "Global Secondary Index (Preview)"; default: throw new Error(`Unknown tab ${tab}`); } diff --git a/src/Explorer/Controls/Settings/TestUtils.tsx b/src/Explorer/Controls/Settings/TestUtils.tsx index c158e5cba..71d939584 100644 --- a/src/Explorer/Controls/Settings/TestUtils.tsx +++ b/src/Explorer/Controls/Settings/TestUtils.tsx @@ -48,6 +48,15 @@ export const collection = { ]), vectorEmbeddingPolicy: ko.observable({} as DataModels.VectorEmbeddingPolicy), fullTextPolicy: ko.observable({} as DataModels.FullTextPolicy), + materializedViews: ko.observable([ + { id: "view1", _rid: "rid1" }, + { id: "view2", _rid: "rid2" }, + ]), + materializedViewDefinition: ko.observable({ + definition: "SELECT * FROM c WHERE c.id = 1", + sourceCollectionId: "source1", + sourceCollectionRid: "rid123", + }), readSettings: () => { return; }, diff --git a/src/Explorer/Controls/Settings/__snapshots__/SettingsComponent.test.tsx.snap b/src/Explorer/Controls/Settings/__snapshots__/SettingsComponent.test.tsx.snap index ea6fe2864..ac2aa4fa3 100644 --- a/src/Explorer/Controls/Settings/__snapshots__/SettingsComponent.test.tsx.snap +++ b/src/Explorer/Controls/Settings/__snapshots__/SettingsComponent.test.tsx.snap @@ -60,6 +60,8 @@ exports[`SettingsComponent renders 1`] = ` "getDatabase": [Function], "id": [Function], "indexingPolicy": [Function], + "materializedViewDefinition": [Function], + "materializedViews": [Function], "offer": [Function], "partitionKey": { "kind": "hash", @@ -139,6 +141,8 @@ exports[`SettingsComponent renders 1`] = ` "getDatabase": [Function], "id": [Function], "indexingPolicy": [Function], + "materializedViewDefinition": [Function], + "materializedViews": [Function], "offer": [Function], "partitionKey": { "kind": "hash", @@ -258,6 +262,138 @@ exports[`SettingsComponent renders 1`] = ` "getDatabase": [Function], "id": [Function], "indexingPolicy": [Function], + "materializedViewDefinition": [Function], + "materializedViews": [Function], + "offer": [Function], + "partitionKey": { + "kind": "hash", + "paths": [], + "version": 2, + }, + "partitionKeyProperties": [ + "partitionKey", + ], + "readSettings": [Function], + "uniqueKeyPolicy": {}, + "usageSizeInKB": [Function], + "vectorEmbeddingPolicy": [Function], + } + } + explorer={ + Explorer { + "_isInitializingNotebooks": false, + "isFixedCollectionWithSharedThroughputSupported": [Function], + "isTabsContentExpanded": [Function], + "onRefreshDatabasesKeyPress": [Function], + "onRefreshResourcesClick": [Function], + "phoenixClient": PhoenixClient { + "armResourceId": undefined, + "retryOptions": { + "maxTimeout": 5000, + "minTimeout": 5000, + "retries": 3, + }, + }, + "provideFeedbackEmail": [Function], + "queriesClient": QueriesClient { + "container": [Circular], + }, + "refreshNotebookList": [Function], + "resourceTree": ResourceTreeAdapter { + "container": [Circular], + "copyNotebook": [Function], + "parameters": [Function], + }, + } + } + isReadOnly={false} + /> + + + + + + - - -
diff --git a/src/Explorer/DataSamples/DataSamplesUtil.ts b/src/Explorer/DataSamples/DataSamplesUtil.ts index d28ef0426..514322fed 100644 --- a/src/Explorer/DataSamples/DataSamplesUtil.ts +++ b/src/Explorer/DataSamples/DataSamplesUtil.ts @@ -6,6 +6,7 @@ import Explorer from "../Explorer"; import { useDatabases } from "../useDatabases"; import { ContainerSampleGenerator } from "./ContainerSampleGenerator"; +// TODO: this does not seem to be used. Remove? export class DataSamplesUtil { private static readonly DialogTitle = "Create Sample Container"; constructor(private container: Explorer) {} diff --git a/src/Explorer/Explorer.tsx b/src/Explorer/Explorer.tsx index ace0aaffe..e62bfe5b3 100644 --- a/src/Explorer/Explorer.tsx +++ b/src/Explorer/Explorer.tsx @@ -55,7 +55,7 @@ import type NotebookManager from "./Notebook/NotebookManager"; import { NotebookPaneContent } from "./Notebook/NotebookManager"; import { NotebookUtil } from "./Notebook/NotebookUtil"; import { useNotebook } from "./Notebook/useNotebook"; -import { AddCollectionPanel } from "./Panes/AddCollectionPanel"; +import { AddCollectionPanel } from "./Panes/AddCollectionPanel/AddCollectionPanel"; import { CassandraAddCollectionPane } from "./Panes/CassandraAddCollectionPane/CassandraAddCollectionPane"; import { ExecuteSprocParamsPane } from "./Panes/ExecuteSprocParamsPane/ExecuteSprocParamsPane"; import { StringInputPane } from "./Panes/StringInputPane/StringInputPane"; diff --git a/src/Explorer/Panes/AddCollectionPanel.test.tsx b/src/Explorer/Panes/AddCollectionPanel/AddCollectionPanel.test.tsx similarity index 90% rename from src/Explorer/Panes/AddCollectionPanel.test.tsx rename to src/Explorer/Panes/AddCollectionPanel/AddCollectionPanel.test.tsx index 0e87c61de..f075eb828 100644 --- a/src/Explorer/Panes/AddCollectionPanel.test.tsx +++ b/src/Explorer/Panes/AddCollectionPanel/AddCollectionPanel.test.tsx @@ -1,6 +1,6 @@ import { shallow } from "enzyme"; import React from "react"; -import Explorer from "../Explorer"; +import Explorer from "../../Explorer"; import { AddCollectionPanel } from "./AddCollectionPanel"; const props = { diff --git a/src/Explorer/Panes/AddCollectionPanel.tsx b/src/Explorer/Panes/AddCollectionPanel/AddCollectionPanel.tsx similarity index 87% rename from src/Explorer/Panes/AddCollectionPanel.tsx rename to src/Explorer/Panes/AddCollectionPanel/AddCollectionPanel.tsx index 0256418d4..c92eda06b 100644 --- a/src/Explorer/Panes/AddCollectionPanel.tsx +++ b/src/Explorer/Panes/AddCollectionPanel/AddCollectionPanel.tsx @@ -21,11 +21,25 @@ import { getNewDatabaseSharedThroughputDefault } from "Common/DatabaseUtility"; import { getErrorMessage, getErrorStack } from "Common/ErrorHandlingUtils"; import { configContext, Platform } from "ConfigContext"; import * as DataModels from "Contracts/DataModels"; -import { - FullTextPoliciesComponent, - getFullTextLanguageOptions, -} from "Explorer/Controls/FullTextSeach/FullTextPoliciesComponent"; +import { FullTextPoliciesComponent } from "Explorer/Controls/FullTextSeach/FullTextPoliciesComponent"; import { VectorEmbeddingPoliciesComponent } from "Explorer/Controls/VectorSearch/VectorEmbeddingPoliciesComponent"; +import { + AllPropertiesIndexed, + AnalyticalStorageContent, + ContainerVectorPolicyTooltipContent, + FullTextPolicyDefault, + getPartitionKey, + getPartitionKeyName, + getPartitionKeyPlaceHolder, + getPartitionKeyTooltipText, + isFreeTierAccount, + isSynapseLinkEnabled, + parseUniqueKeys, + scrollToSection, + SharedDatabaseDefault, + shouldShowAnalyticalStoreOptions, + UniqueKeysHeader, +} from "Explorer/Panes/AddCollectionPanel/AddCollectionPanelUtility"; import { useSidePanel } from "hooks/useSidePanel"; import { useTeachingBubble } from "hooks/useTeachingBubble"; import { isFabricNative } from "Platform/Fabric/FabricUtil"; @@ -43,15 +57,14 @@ import { } from "Utils/CapabilityUtils"; import { getUpsellMessage } from "Utils/PricingUtils"; import { ValidCosmosDbIdDescription, ValidCosmosDbIdInputPattern } from "Utils/ValidationUtils"; -import { CollapsibleSectionComponent } from "../Controls/CollapsiblePanel/CollapsibleSectionComponent"; -import { ThroughputInput } from "../Controls/ThroughputInput/ThroughputInput"; -import "../Controls/ThroughputInput/ThroughputInput.less"; -import { ContainerSampleGenerator } from "../DataSamples/ContainerSampleGenerator"; -import Explorer from "../Explorer"; -import { useDatabases } from "../useDatabases"; -import { PanelFooterComponent } from "./PanelFooterComponent"; -import { PanelInfoErrorComponent } from "./PanelInfoErrorComponent"; -import { PanelLoadingScreen } from "./PanelLoadingScreen"; +import { CollapsibleSectionComponent } from "../../Controls/CollapsiblePanel/CollapsibleSectionComponent"; +import { ThroughputInput } from "../../Controls/ThroughputInput/ThroughputInput"; +import { ContainerSampleGenerator } from "../../DataSamples/ContainerSampleGenerator"; +import Explorer from "../../Explorer"; +import { useDatabases } from "../../useDatabases"; +import { PanelFooterComponent } from "../PanelFooterComponent"; +import { PanelInfoErrorComponent } from "../PanelInfoErrorComponent"; +import { PanelLoadingScreen } from "../PanelLoadingScreen"; export interface AddCollectionPanelProps { explorer: Explorer; @@ -59,40 +72,6 @@ export interface AddCollectionPanelProps { isQuickstart?: boolean; } -const SharedDatabaseDefault: DataModels.IndexingPolicy = { - indexingMode: "consistent", - automatic: true, - includedPaths: [], - excludedPaths: [ - { - path: "/*", - }, - ], -}; - -export const AllPropertiesIndexed: DataModels.IndexingPolicy = { - indexingMode: "consistent", - automatic: true, - includedPaths: [ - { - path: "/*", - indexes: [ - { - kind: "Range", - dataType: "Number", - precision: -1, - }, - { - kind: "Range", - dataType: "String", - precision: -1, - }, - ], - }, - ], - excludedPaths: [], -}; - export const DefaultVectorEmbeddingPolicy: DataModels.VectorEmbeddingPolicy = { vectorEmbeddings: [], }; @@ -145,7 +124,7 @@ export class AddCollectionPanel extends React.Component )} - {!this.state.errorMessage && this.isFreeTierAccount() && ( + {!this.state.errorMessage && isFreeTierAccount() && ( (this.newDatabaseThroughput = throughput)} setIsAutoscale={(isAutoscale: boolean) => (this.isNewDatabaseAutoscale = isAutoscale)} @@ -580,17 +559,14 @@ export class AddCollectionPanel extends React.Component - {this.getPartitionKeyName()} + {getPartitionKeyName()} - + @@ -604,8 +580,8 @@ export class AddCollectionPanel extends React.Component 0 ? 1 : 0} className="panelTextField" autoComplete="off" - placeholder={this.getPartitionKeyPlaceHolder(index)} - aria-label={this.getPartitionKeyName()} + placeholder={getPartitionKeyPlaceHolder(index)} + aria-label={getPartitionKeyName()} pattern={".*"} title={""} value={subPartitionKey} @@ -735,10 +711,10 @@ export class AddCollectionPanel extends React.Component (this.collectionThroughput = throughput)} setIsAutoscale={(isAutoscale: boolean) => (this.isCollectionAutoscale = isAutoscale)} @@ -753,27 +729,7 @@ export class AddCollectionPanel extends React.Component - - - Unique keys - - - - - - + {UniqueKeysHeader()} {this.state.uniqueKeys.map((uniqueKey: string, i: number): JSX.Element => { return ( @@ -821,10 +777,10 @@ export class AddCollectionPanel extends React.Component )} - {this.shouldShowAnalyticalStoreOptions() && ( + {shouldShowAnalyticalStoreOptions() && ( - {this.getAnalyticalStorageContent()} + {AnalyticalStorageContent()} @@ -832,7 +788,7 @@ export class AddCollectionPanel extends React.Component - {!this.isSynapseLinkEnabled() && ( + {!isSynapseLinkEnabled() && ( Azure Synapse Link is required for creating an analytical store{" "} @@ -891,9 +847,9 @@ export class AddCollectionPanel extends React.Component { - this.scrollToSection("collapsibleVectorPolicySectionContent"); + scrollToSection("collapsibleVectorPolicySectionContent"); }} - tooltipContent={this.getContainerVectorPolicyTooltipContent()} + tooltipContent={ContainerVectorPolicyTooltipContent()} > @@ -919,7 +875,7 @@ export class AddCollectionPanel extends React.Component { - this.scrollToSection("collapsibleFullTextPolicySectionContent"); + scrollToSection("collapsibleFullTextPolicySectionContent"); }} //TODO: uncomment when learn more text becomes available // tooltipContent={this.getContainerFullTextPolicyTooltipContent()} @@ -947,7 +903,7 @@ export class AddCollectionPanel extends React.Component { TelemetryProcessor.traceOpen(Action.ExpandAddCollectionPaneAdvancedSection); - this.scrollToSection("collapsibleAdvancedSectionContent"); + scrollToSection("collapsibleAdvancedSectionContent"); }} > @@ -1057,31 +1013,6 @@ export class AddCollectionPanel extends React.Component): void { if (event.target.checked && !this.state.createNewDatabase) { this.setState({ @@ -1169,48 +1100,12 @@ export class AddCollectionPanel extends React.Component - Enable analytical store capability to perform near real-time analytics on your operational data, without - impacting the performance of transactional workloads.{" "} - - Learn more - - - ); - } - - private getContainerVectorPolicyTooltipContent(): JSX.Element { - return ( - - Describe any properties in your data that contain vectors, so that they can be made available for similarity - queries.{" "} - - Learn more - - - ); - } - //TODO: uncomment when learn more text becomes available // private getContainerFullTextPolicyTooltipContent(): JSX.Element { // return ( @@ -1280,7 +1147,7 @@ export class AddCollectionPanel extends React.Component capability.name === Constants.CapabilityNames.EnableStorageAnalytics, - ); - } - private shouldShowVectorSearchParameters() { return isVectorSearchEnabled() && (isServerlessAccount() || this.shouldShowCollectionThroughputInput()); } @@ -1402,11 +1236,11 @@ export class AddCollectionPanel extends React.Component + + Unique keys + + + + + + ); +} + +export function shouldShowAnalyticalStoreOptions(): boolean { + if (isFabricNative() || configContext.platform === Platform.Emulator) { + return false; + } + + switch (userContext.apiType) { + case "SQL": + case "Mongo": + return true; + default: + return false; + } +} + +export function AnalyticalStorageContent(): JSX.Element { + return ( + + Enable analytical store capability to perform near real-time analytics on your operational data, without impacting + the performance of transactional workloads.{" "} + + Learn more + + + ); +} + +export function isSynapseLinkEnabled(): boolean { + if (!userContext.databaseAccount) { + return false; + } + + const { properties } = userContext.databaseAccount; + if (!properties) { + return false; + } + + if (properties.enableAnalyticalStorage) { + return true; + } + + return properties.capabilities?.some( + (capability) => capability.name === Constants.CapabilityNames.EnableStorageAnalytics, + ); +} + +export function scrollToSection(id: string): void { + document.getElementById(id)?.scrollIntoView(); +} + +export function ContainerVectorPolicyTooltipContent(): JSX.Element { + return ( + + Describe any properties in your data that contain vectors, so that they can be made available for similarity + queries.{" "} + + Learn more + + + ); +} + +export function parseUniqueKeys(uniqueKeys: string[]): DataModels.UniqueKeyPolicy { + if (uniqueKeys?.length === 0) { + return undefined; + } + + const uniqueKeyPolicy: DataModels.UniqueKeyPolicy = { uniqueKeys: [] }; + uniqueKeys.forEach((uniqueKey: string) => { + if (uniqueKey) { + const validPaths: string[] = uniqueKey.split(",")?.filter((path) => path?.length > 0); + const trimmedPaths: string[] = validPaths?.map((path) => path.trim()); + if (trimmedPaths?.length > 0) { + if (userContext.apiType === "Mongo") { + trimmedPaths.map((path) => { + const transformedPath = path.split(".").join("/"); + if (transformedPath[0] !== "/") { + return "/" + transformedPath; + } + return transformedPath; + }); + } + uniqueKeyPolicy.uniqueKeys.push({ paths: trimmedPaths }); + } + } + }); + + return uniqueKeyPolicy; +} + +export const SharedDatabaseDefault: DataModels.IndexingPolicy = { + indexingMode: "consistent", + automatic: true, + includedPaths: [], + excludedPaths: [ + { + path: "/*", + }, + ], +}; + +export const FullTextPolicyDefault: DataModels.FullTextPolicy = { + defaultLanguage: getFullTextLanguageOptions()[0].key as never, + fullTextPaths: [], +}; + +export const AllPropertiesIndexed: DataModels.IndexingPolicy = { + indexingMode: "consistent", + automatic: true, + includedPaths: [ + { + path: "/*", + indexes: [ + { + kind: "Range", + dataType: "Number", + precision: -1, + }, + { + kind: "Range", + dataType: "String", + precision: -1, + }, + ], + }, + ], + excludedPaths: [], +}; diff --git a/src/Explorer/Panes/__snapshots__/AddCollectionPanel.test.tsx.snap b/src/Explorer/Panes/AddCollectionPanel/__snapshots__/AddCollectionPanel.test.tsx.snap similarity index 100% rename from src/Explorer/Panes/__snapshots__/AddCollectionPanel.test.tsx.snap rename to src/Explorer/Panes/AddCollectionPanel/__snapshots__/AddCollectionPanel.test.tsx.snap diff --git a/src/Explorer/Panes/AddGlobalSecondaryIndexPanel/AddGlobalSecondaryIndexPanel.test.tsx b/src/Explorer/Panes/AddGlobalSecondaryIndexPanel/AddGlobalSecondaryIndexPanel.test.tsx new file mode 100644 index 000000000..05eec133d --- /dev/null +++ b/src/Explorer/Panes/AddGlobalSecondaryIndexPanel/AddGlobalSecondaryIndexPanel.test.tsx @@ -0,0 +1,28 @@ +import { shallow, ShallowWrapper } from "enzyme"; +import Explorer from "Explorer/Explorer"; +import { + AddGlobalSecondaryIndexPanel, + AddGlobalSecondaryIndexPanelProps, +} from "Explorer/Panes/AddGlobalSecondaryIndexPanel/AddGlobalSecondaryIndexPanel"; +import React, { Component } from "react"; + +const props: AddGlobalSecondaryIndexPanelProps = { + explorer: new Explorer(), +}; + +describe("AddGlobalSecondaryIndexPanel", () => { + it("render default panel", () => { + const wrapper: ShallowWrapper = shallow( + , + ); + expect(wrapper).toMatchSnapshot(); + }); + + it("should render form", () => { + const wrapper: ShallowWrapper = shallow( + , + ); + const form = wrapper.find("form").first(); + expect(form).toBeDefined(); + }); +}); diff --git a/src/Explorer/Panes/AddGlobalSecondaryIndexPanel/AddGlobalSecondaryIndexPanel.tsx b/src/Explorer/Panes/AddGlobalSecondaryIndexPanel/AddGlobalSecondaryIndexPanel.tsx new file mode 100644 index 000000000..313c72b4c --- /dev/null +++ b/src/Explorer/Panes/AddGlobalSecondaryIndexPanel/AddGlobalSecondaryIndexPanel.tsx @@ -0,0 +1,431 @@ +import { + DirectionalHint, + Dropdown, + DropdownMenuItemType, + Icon, + IDropdownOption, + Link, + Separator, + Stack, + Text, + TooltipHost, +} from "@fluentui/react"; +import * as Constants from "Common/Constants"; +import { createGlobalSecondaryIndex } from "Common/dataAccess/createMaterializedView"; +import { getErrorMessage, getErrorStack } from "Common/ErrorHandlingUtils"; +import * as DataModels from "Contracts/DataModels"; +import { FullTextIndex, FullTextPolicy, VectorEmbedding, VectorIndex } from "Contracts/DataModels"; +import { Collection, Database } from "Contracts/ViewModels"; +import Explorer from "Explorer/Explorer"; +import { + AllPropertiesIndexed, + FullTextPolicyDefault, + getPartitionKey, + isSynapseLinkEnabled, + parseUniqueKeys, + scrollToSection, + shouldShowAnalyticalStoreOptions, +} from "Explorer/Panes/AddCollectionPanel/AddCollectionPanelUtility"; +import { + chooseSourceContainerStyle, + chooseSourceContainerStyles, +} from "Explorer/Panes/AddGlobalSecondaryIndexPanel/AddGlobalSecondaryIndexPanelStyles"; +import { AdvancedComponent } from "Explorer/Panes/AddGlobalSecondaryIndexPanel/Components/AdvancedComponent"; +import { AnalyticalStoreComponent } from "Explorer/Panes/AddGlobalSecondaryIndexPanel/Components/AnalyticalStoreComponent"; +import { FullTextSearchComponent } from "Explorer/Panes/AddGlobalSecondaryIndexPanel/Components/FullTextSearchComponent"; +import { PartitionKeyComponent } from "Explorer/Panes/AddGlobalSecondaryIndexPanel/Components/PartitionKeyComponent"; +import { ThroughputComponent } from "Explorer/Panes/AddGlobalSecondaryIndexPanel/Components/ThroughputComponent"; +import { UniqueKeysComponent } from "Explorer/Panes/AddGlobalSecondaryIndexPanel/Components/UniqueKeysComponent"; +import { VectorSearchComponent } from "Explorer/Panes/AddGlobalSecondaryIndexPanel/Components/VectorSearchComponent"; +import { PanelFooterComponent } from "Explorer/Panes/PanelFooterComponent"; +import { PanelInfoErrorComponent } from "Explorer/Panes/PanelInfoErrorComponent"; +import { PanelLoadingScreen } from "Explorer/Panes/PanelLoadingScreen"; +import { useDatabases } from "Explorer/useDatabases"; +import { useSidePanel } from "hooks/useSidePanel"; +import React, { useEffect, useState } from "react"; +import { CollectionCreation } from "Shared/Constants"; +import { Action } from "Shared/Telemetry/TelemetryConstants"; +import * as TelemetryProcessor from "Shared/Telemetry/TelemetryProcessor"; +import { userContext } from "UserContext"; +import { isFullTextSearchEnabled, isServerlessAccount, isVectorSearchEnabled } from "Utils/CapabilityUtils"; +import { ValidCosmosDbIdDescription, ValidCosmosDbIdInputPattern } from "Utils/ValidationUtils"; + +export interface AddGlobalSecondaryIndexPanelProps { + explorer: Explorer; + sourceContainer?: Collection; +} +export const AddGlobalSecondaryIndexPanel = (props: AddGlobalSecondaryIndexPanelProps): JSX.Element => { + const { explorer, sourceContainer } = props; + + const [sourceContainerOptions, setSourceContainerOptions] = useState(); + const [selectedSourceContainer, setSelectedSourceContainer] = useState(sourceContainer); + const [globalSecondaryIndexId, setGlobalSecondaryIndexId] = useState(); + const [definition, setDefinition] = useState(); + const [partitionKey, setPartitionKey] = useState(getPartitionKey()); + const [subPartitionKeys, setSubPartitionKeys] = useState([]); + const [useHashV1, setUseHashV1] = useState(); + const [enableDedicatedThroughput, setEnabledDedicatedThroughput] = useState(); + const [isThroughputCapExceeded, setIsThroughputCapExceeded] = useState(); + const [uniqueKeys, setUniqueKeys] = useState([]); + const [enableAnalyticalStore, setEnableAnalyticalStore] = useState(); + const [vectorEmbeddingPolicy, setVectorEmbeddingPolicy] = useState(); + const [vectorIndexingPolicy, setVectorIndexingPolicy] = useState(); + const [vectorPolicyValidated, setVectorPolicyValidated] = useState(); + const [fullTextPolicy, setFullTextPolicy] = useState(FullTextPolicyDefault); + const [fullTextIndexes, setFullTextIndexes] = useState(); + const [fullTextPolicyValidated, setFullTextPolicyValidated] = useState(); + const [errorMessage, setErrorMessage] = useState(); + const [showErrorDetails, setShowErrorDetails] = useState(); + const [isExecuting, setIsExecuting] = useState(); + + useEffect(() => { + const sourceContainerOptions: IDropdownOption[] = []; + useDatabases.getState().databases.forEach((database: Database) => { + sourceContainerOptions.push({ + key: database.rid, + text: database.id(), + itemType: DropdownMenuItemType.Header, + }); + + database.collections().forEach((collection: Collection) => { + const isGlobalSecondaryIndex: boolean = !!collection.materializedViewDefinition(); + sourceContainerOptions.push({ + key: collection.rid, + text: collection.id(), + disabled: isGlobalSecondaryIndex, + ...(isGlobalSecondaryIndex && { + title: "This is a global secondary index.", + }), + data: collection, + }); + }); + }); + + setSourceContainerOptions(sourceContainerOptions); + }, []); + + useEffect(() => { + scrollToSection("panelContainer"); + }, [errorMessage]); + + let globalSecondaryIndexThroughput: number; + let isGlobalSecondaryIndexAutoscale: boolean; + let isCostAcknowledged: boolean; + + const globalSecondaryIndexThroughputOnChange = (globalSecondaryIndexThroughputValue: number): void => { + globalSecondaryIndexThroughput = globalSecondaryIndexThroughputValue; + }; + + const isGlobalSecondaryIndexAutoscaleOnChange = (isGlobalSecondaryIndexAutoscaleValue: boolean): void => { + isGlobalSecondaryIndexAutoscale = isGlobalSecondaryIndexAutoscaleValue; + }; + + const isCostAknowledgedOnChange = (isCostAcknowledgedValue: boolean): void => { + isCostAcknowledged = isCostAcknowledgedValue; + }; + + const isSelectedSourceContainerSharedThroughput = (): boolean => { + if (!selectedSourceContainer) { + return false; + } + + return !!selectedSourceContainer.getDatabase().offer(); + }; + + const showCollectionThroughputInput = (): boolean => { + if (isServerlessAccount()) { + return false; + } + + if (enableDedicatedThroughput) { + return true; + } + + return !!selectedSourceContainer && !isSelectedSourceContainerSharedThroughput(); + }; + + const showVectorSearchParameters = (): boolean => { + return isVectorSearchEnabled() && (isServerlessAccount() || showCollectionThroughputInput()); + }; + + const showFullTextSearchParameters = (): boolean => { + return isFullTextSearchEnabled() && (isServerlessAccount() || showCollectionThroughputInput()); + }; + + const getAnalyticalStorageTtl = (): number => { + if (!isSynapseLinkEnabled()) { + return undefined; + } + + if (!shouldShowAnalyticalStoreOptions()) { + return undefined; + } + + if (enableAnalyticalStore) { + // TODO: always default to 90 days once the backend hotfix is deployed + return userContext.features.ttl90Days + ? Constants.AnalyticalStorageTtl.Days90 + : Constants.AnalyticalStorageTtl.Infinite; + } + + return Constants.AnalyticalStorageTtl.Disabled; + }; + + const validateInputs = (): boolean => { + if (!selectedSourceContainer) { + setErrorMessage("Please select a source container"); + return false; + } + + if (globalSecondaryIndexThroughput > CollectionCreation.DefaultCollectionRUs100K && !isCostAcknowledged) { + const errorMessage = isGlobalSecondaryIndexAutoscale + ? "Please acknowledge the estimated monthly spend." + : "Please acknowledge the estimated daily spend."; + setErrorMessage(errorMessage); + return false; + } + + if (globalSecondaryIndexThroughput > CollectionCreation.MaxRUPerPartition) { + setErrorMessage("Unsharded collections support up to 10,000 RUs"); + return false; + } + + if (showVectorSearchParameters()) { + if (!vectorPolicyValidated) { + setErrorMessage("Please fix errors in container vector policy"); + return false; + } + + if (!fullTextPolicyValidated) { + setErrorMessage("Please fix errors in container full text search policy"); + return false; + } + } + + return true; + }; + + const submit = async (event?: React.FormEvent): Promise => { + event?.preventDefault(); + + if (!validateInputs()) { + return; + } + + const globalSecondaryIdTrimmed: string = globalSecondaryIndexId.trim(); + + const globalSecondaryIndexDefinition: DataModels.MaterializedViewDefinition = { + sourceCollectionId: selectedSourceContainer.id(), + definition: definition, + }; + + const partitionKeyTrimmed: string = partitionKey.trim(); + + const uniqueKeyPolicy: DataModels.UniqueKeyPolicy = parseUniqueKeys(uniqueKeys); + const partitionKeyVersion = useHashV1 ? undefined : 2; + const partitionKeyPaths: DataModels.PartitionKey = partitionKeyTrimmed + ? { + paths: [ + partitionKeyTrimmed, + ...(userContext.apiType === "SQL" && subPartitionKeys.length > 0 ? subPartitionKeys : []), + ], + kind: userContext.apiType === "SQL" && subPartitionKeys.length > 0 ? "MultiHash" : "Hash", + version: partitionKeyVersion, + } + : undefined; + + const indexingPolicy: DataModels.IndexingPolicy = AllPropertiesIndexed; + let vectorEmbeddingPolicyFinal: DataModels.VectorEmbeddingPolicy; + + if (showVectorSearchParameters()) { + indexingPolicy.vectorIndexes = vectorIndexingPolicy; + vectorEmbeddingPolicyFinal = { + vectorEmbeddings: vectorEmbeddingPolicy, + }; + } + + if (showFullTextSearchParameters()) { + indexingPolicy.fullTextIndexes = fullTextIndexes; + } + + const telemetryData: TelemetryProcessor.TelemetryData = { + database: { + id: selectedSourceContainer.databaseId, + shared: isSelectedSourceContainerSharedThroughput(), + }, + collection: { + id: globalSecondaryIdTrimmed, + throughput: globalSecondaryIndexThroughput, + isAutoscale: isGlobalSecondaryIndexAutoscale, + partitionKeyPaths, + uniqueKeyPolicy, + collectionWithDedicatedThroughput: enableDedicatedThroughput, + }, + subscriptionQuotaId: userContext.quotaId, + dataExplorerArea: Constants.Areas.ContextualPane, + }; + + const startKey: number = TelemetryProcessor.traceStart(Action.CreateCollection, telemetryData); + const databaseLevelThroughput: boolean = isSelectedSourceContainerSharedThroughput() && !enableDedicatedThroughput; + + let offerThroughput: number; + let autoPilotMaxThroughput: number; + + if (!databaseLevelThroughput) { + if (isGlobalSecondaryIndexAutoscale) { + autoPilotMaxThroughput = globalSecondaryIndexThroughput; + } else { + offerThroughput = globalSecondaryIndexThroughput; + } + } + + const createGlobalSecondaryIndexParams: DataModels.CreateMaterializedViewsParams = { + materializedViewId: globalSecondaryIdTrimmed, + materializedViewDefinition: globalSecondaryIndexDefinition, + databaseId: selectedSourceContainer.databaseId, + databaseLevelThroughput: databaseLevelThroughput, + offerThroughput: offerThroughput, + autoPilotMaxThroughput: autoPilotMaxThroughput, + analyticalStorageTtl: getAnalyticalStorageTtl(), + indexingPolicy: indexingPolicy, + partitionKey: partitionKeyPaths, + uniqueKeyPolicy: uniqueKeyPolicy, + vectorEmbeddingPolicy: vectorEmbeddingPolicyFinal, + fullTextPolicy: fullTextPolicy, + }; + + setIsExecuting(true); + + try { + await createGlobalSecondaryIndex(createGlobalSecondaryIndexParams); + await explorer.refreshAllDatabases(); + TelemetryProcessor.traceSuccess(Action.CreateGlobalSecondaryIndex, telemetryData, startKey); + useSidePanel.getState().closeSidePanel(); + } catch (error) { + const errorMessage: string = getErrorMessage(error); + setErrorMessage(errorMessage); + setShowErrorDetails(true); + const failureTelemetryData = { ...telemetryData, error: errorMessage, errorStack: getErrorStack(error) }; + TelemetryProcessor.traceFailure(Action.CreateGlobalSecondaryIndex, failureTelemetryData, startKey); + } finally { + setIsExecuting(false); + } + }; + + return ( +
+ {errorMessage && ( + + )} +
+ + + + + Source container id + + + setSelectedSourceContainer(options.data as Collection)} + /> + + + + + Global secondary index container id + + + ) => setGlobalSecondaryIndexId(event.target.value)} + /> + + + + Global secondary index definition + + + Learn more about defining global secondary indexes. + + } + > + + + + ) => setDefinition(event.target.value)} + /> + + + + {shouldShowAnalyticalStoreOptions() && ( + + )} + {showVectorSearchParameters() && ( + + )} + {showFullTextSearchParameters() && ( + + )} + + +
+ + {isExecuting && } + + ); +}; diff --git a/src/Explorer/Panes/AddGlobalSecondaryIndexPanel/AddGlobalSecondaryIndexPanelStyles.ts b/src/Explorer/Panes/AddGlobalSecondaryIndexPanel/AddGlobalSecondaryIndexPanelStyles.ts new file mode 100644 index 000000000..cfb6da846 --- /dev/null +++ b/src/Explorer/Panes/AddGlobalSecondaryIndexPanel/AddGlobalSecondaryIndexPanelStyles.ts @@ -0,0 +1,15 @@ +import { IDropdownStyleProps, IDropdownStyles, IStyleFunctionOrObject } from "@fluentui/react"; +import { CSSProperties } from "react"; + +export function chooseSourceContainerStyles(): IStyleFunctionOrObject { + return { + title: { height: 27, lineHeight: 27 }, + dropdownItem: { fontSize: 12 }, + dropdownItemDisabled: { fontSize: 12 }, + dropdownItemSelected: { fontSize: 12 }, + }; +} + +export function chooseSourceContainerStyle(): CSSProperties { + return { width: 300, fontSize: 12 }; +} diff --git a/src/Explorer/Panes/AddGlobalSecondaryIndexPanel/Components/AdvancedComponent.tsx b/src/Explorer/Panes/AddGlobalSecondaryIndexPanel/Components/AdvancedComponent.tsx new file mode 100644 index 000000000..17d1cb303 --- /dev/null +++ b/src/Explorer/Panes/AddGlobalSecondaryIndexPanel/Components/AdvancedComponent.tsx @@ -0,0 +1,54 @@ +import { Checkbox, Icon, Link, Stack, Text } from "@fluentui/react"; +import { CollapsibleSectionComponent } from "Explorer/Controls/CollapsiblePanel/CollapsibleSectionComponent"; +import { scrollToSection } from "Explorer/Panes/AddCollectionPanel/AddCollectionPanelUtility"; +import React from "react"; +import { Action } from "Shared/Telemetry/TelemetryConstants"; +import * as TelemetryProcessor from "Shared/Telemetry/TelemetryProcessor"; + +export interface AdvancedComponentProps { + useHashV1: boolean; + setUseHashV1: React.Dispatch>; + setSubPartitionKeys: React.Dispatch>; +} +export const AdvancedComponent = (props: AdvancedComponentProps): JSX.Element => { + const { useHashV1, setUseHashV1, setSubPartitionKeys } = props; + + const useHashV1CheckboxOnChange = (isChecked: boolean): void => { + setUseHashV1(isChecked); + setSubPartitionKeys([]); + }; + + return ( + { + TelemetryProcessor.traceOpen(Action.ExpandAddGlobalSecondaryIndexPaneAdvancedSection); + scrollToSection("collapsibleAdvancedSectionContent"); + }} + > + + , isChecked: boolean) => { + useHashV1CheckboxOnChange(isChecked); + }} + /> + + To ensure compatibility with older SDKs, the created + container will use a legacy partitioning scheme that supports partition key values of size only up to 101 + bytes. If this is enabled, you will not be able to use hierarchical partition keys.{" "} + + Learn more + + + + + ); +}; diff --git a/src/Explorer/Panes/AddGlobalSecondaryIndexPanel/Components/AnalyticalStoreComponent.tsx b/src/Explorer/Panes/AddGlobalSecondaryIndexPanel/Components/AnalyticalStoreComponent.tsx new file mode 100644 index 000000000..46c28c6d8 --- /dev/null +++ b/src/Explorer/Panes/AddGlobalSecondaryIndexPanel/Components/AnalyticalStoreComponent.tsx @@ -0,0 +1,99 @@ +import { DefaultButton, Link, Stack, Text } from "@fluentui/react"; +import * as Constants from "Common/Constants"; +import Explorer from "Explorer/Explorer"; +import { + AnalyticalStorageContent, + isSynapseLinkEnabled, +} from "Explorer/Panes/AddCollectionPanel/AddCollectionPanelUtility"; +import React from "react"; +import { getCollectionName } from "Utils/APITypeUtils"; + +export interface AnalyticalStoreComponentProps { + explorer: Explorer; + enableAnalyticalStore: boolean; + setEnableAnalyticalStore: React.Dispatch>; +} +export const AnalyticalStoreComponent = (props: AnalyticalStoreComponentProps): JSX.Element => { + const { explorer, enableAnalyticalStore, setEnableAnalyticalStore } = props; + + const onEnableAnalyticalStoreRadioButtonChange = (checked: boolean): void => { + if (checked && !enableAnalyticalStore) { + setEnableAnalyticalStore(true); + } + }; + + const onDisableAnalyticalStoreRadioButtonnChange = (checked: boolean): void => { + if (checked && enableAnalyticalStore) { + setEnableAnalyticalStore(false); + } + }; + + return ( + + + {AnalyticalStorageContent()} + + + +
+ ) => { + onEnableAnalyticalStoreRadioButtonChange(event.target.checked); + }} + /> + On + + ) => { + onDisableAnalyticalStoreRadioButtonnChange(event.target.checked); + }} + /> + Off +
+
+ + {!isSynapseLinkEnabled() && ( + + + Azure Synapse Link is required for creating an analytical store {getCollectionName().toLocaleLowerCase()}. + Enable Synapse Link for this Cosmos DB account.{" "} + + Learn more + + + explorer.openEnableSynapseLinkDialog()} + style={{ height: 27, width: 80 }} + styles={{ label: { fontSize: 12 } }} + /> + + )} +
+ ); +}; diff --git a/src/Explorer/Panes/AddGlobalSecondaryIndexPanel/Components/FullTextSearchComponent.tsx b/src/Explorer/Panes/AddGlobalSecondaryIndexPanel/Components/FullTextSearchComponent.tsx new file mode 100644 index 000000000..e02bce6ab --- /dev/null +++ b/src/Explorer/Panes/AddGlobalSecondaryIndexPanel/Components/FullTextSearchComponent.tsx @@ -0,0 +1,45 @@ +import { Stack } from "@fluentui/react"; +import { FullTextIndex, FullTextPolicy } from "Contracts/DataModels"; +import { CollapsibleSectionComponent } from "Explorer/Controls/CollapsiblePanel/CollapsibleSectionComponent"; +import { FullTextPoliciesComponent } from "Explorer/Controls/FullTextSeach/FullTextPoliciesComponent"; +import { scrollToSection } from "Explorer/Panes/AddCollectionPanel/AddCollectionPanelUtility"; +import React from "react"; + +export interface FullTextSearchComponentProps { + fullTextPolicy: FullTextPolicy; + setFullTextPolicy: React.Dispatch>; + setFullTextIndexes: React.Dispatch>; + setFullTextPolicyValidated: React.Dispatch>; +} +export const FullTextSearchComponent = (props: FullTextSearchComponentProps): JSX.Element => { + const { fullTextPolicy, setFullTextPolicy, setFullTextIndexes, setFullTextPolicyValidated } = props; + + return ( + + { + scrollToSection("collapsibleFullTextPolicySectionContent"); + }} + > + + + { + setFullTextPolicy(fullTextPolicy); + setFullTextIndexes(fullTextIndexes); + setFullTextPolicyValidated(fullTextPolicyValidated); + }} + /> + + + + + ); +}; diff --git a/src/Explorer/Panes/AddGlobalSecondaryIndexPanel/Components/PartitionKeyComponent.tsx b/src/Explorer/Panes/AddGlobalSecondaryIndexPanel/Components/PartitionKeyComponent.tsx new file mode 100644 index 000000000..f8f01c24b --- /dev/null +++ b/src/Explorer/Panes/AddGlobalSecondaryIndexPanel/Components/PartitionKeyComponent.tsx @@ -0,0 +1,132 @@ +import { DefaultButton, DirectionalHint, Icon, IconButton, Link, Stack, Text, TooltipHost } from "@fluentui/react"; +import * as Constants from "Common/Constants"; +import { + getPartitionKeyName, + getPartitionKeyPlaceHolder, + getPartitionKeyTooltipText, +} from "Explorer/Panes/AddCollectionPanel/AddCollectionPanelUtility"; +import React from "react"; + +export interface PartitionKeyComponentProps { + partitionKey?: string; + setPartitionKey: React.Dispatch>; + subPartitionKeys: string[]; + setSubPartitionKeys: React.Dispatch>; + useHashV1: boolean; +} + +export const PartitionKeyComponent = (props: PartitionKeyComponentProps): JSX.Element => { + const { partitionKey, setPartitionKey, subPartitionKeys, setSubPartitionKeys, useHashV1 } = props; + + const partitionKeyValueOnChange = (value: string): void => { + if (!partitionKey && !value.startsWith("/")) { + setPartitionKey("/" + value); + } else { + setPartitionKey(value); + } + }; + + const subPartitionKeysValueOnChange = (value: string, index: number): void => { + const updatedSubPartitionKeys: string[] = [...subPartitionKeys]; + if (!updatedSubPartitionKeys[index] && !value.startsWith("/")) { + updatedSubPartitionKeys[index] = "/" + value.trim(); + } else { + updatedSubPartitionKeys[index] = value.trim(); + } + setSubPartitionKeys(updatedSubPartitionKeys); + }; + + return ( + + + + + Partition key + + + + + + + ) => { + partitionKeyValueOnChange(event.target.value); + }} + /> + {subPartitionKeys.map((subPartitionKey: string, subPartitionKeyIndex: number) => { + return ( + +
+ 0 ? 1 : 0} + className="panelTextField" + autoComplete="off" + placeholder={getPartitionKeyPlaceHolder(subPartitionKeyIndex)} + aria-label={getPartitionKeyName()} + pattern={".*"} + title={""} + value={subPartitionKey} + onChange={(event: React.ChangeEvent) => { + subPartitionKeysValueOnChange(event.target.value, subPartitionKeyIndex); + }} + /> + { + const updatedSubPartitionKeys = subPartitionKeys.filter( + (_, subPartitionKeyIndexToRemove) => subPartitionKeyIndex !== subPartitionKeyIndexToRemove, + ); + setSubPartitionKeys(updatedSubPartitionKeys); + }} + /> +
+ ); + })} + + + {subPartitionKeys.length > 0 && ( + + This feature allows you to partition your + data with up to three levels of keys for better data distribution. Requires .NET V3, Java V4 SDK, or preview + JavaScript V3 SDK.{" "} + + Learn more + + + )} + +
+ ); +}; diff --git a/src/Explorer/Panes/AddGlobalSecondaryIndexPanel/Components/ThroughputComponent.tsx b/src/Explorer/Panes/AddGlobalSecondaryIndexPanel/Components/ThroughputComponent.tsx new file mode 100644 index 000000000..07669906f --- /dev/null +++ b/src/Explorer/Panes/AddGlobalSecondaryIndexPanel/Components/ThroughputComponent.tsx @@ -0,0 +1,71 @@ +import { Checkbox, Stack } from "@fluentui/react"; +import { ThroughputInput } from "Explorer/Controls/ThroughputInput/ThroughputInput"; +import { isFreeTierAccount } from "Explorer/Panes/AddCollectionPanel/AddCollectionPanelUtility"; +import { useDatabases } from "Explorer/useDatabases"; +import React from "react"; +import { getCollectionName } from "Utils/APITypeUtils"; +import { isServerlessAccount } from "Utils/CapabilityUtils"; + +export interface ThroughputComponentProps { + enableDedicatedThroughput: boolean; + setEnabledDedicatedThroughput: React.Dispatch>; + isSelectedSourceContainerSharedThroughput: () => boolean; + showCollectionThroughputInput: () => boolean; + globalSecondaryIndexThroughputOnChange: (globalSecondaryIndexThroughputValue: number) => void; + isGlobalSecondaryIndexAutoscaleOnChange: (isGlobalSecondaryIndexAutoscaleValue: boolean) => void; + setIsThroughputCapExceeded: React.Dispatch>; + isCostAknowledgedOnChange: (isCostAknowledgedValue: boolean) => void; +} + +export const ThroughputComponent = (props: ThroughputComponentProps): JSX.Element => { + const { + enableDedicatedThroughput, + setEnabledDedicatedThroughput, + isSelectedSourceContainerSharedThroughput, + showCollectionThroughputInput, + globalSecondaryIndexThroughputOnChange, + isGlobalSecondaryIndexAutoscaleOnChange, + setIsThroughputCapExceeded, + isCostAknowledgedOnChange, + } = props; + + return ( + + {!isServerlessAccount() && isSelectedSourceContainerSharedThroughput() && ( + + setEnabledDedicatedThroughput(isChecked)} + /> + + )} + {showCollectionThroughputInput() && ( + { + globalSecondaryIndexThroughputOnChange(throughput); + }} + setIsAutoscale={(isAutoscale: boolean) => { + isGlobalSecondaryIndexAutoscaleOnChange(isAutoscale); + }} + setIsThroughputCapExceeded={(isThroughputCapExceeded: boolean) => { + setIsThroughputCapExceeded(isThroughputCapExceeded); + }} + onCostAcknowledgeChange={(isAcknowledged: boolean) => { + isCostAknowledgedOnChange(isAcknowledged); + }} + /> + )} + + ); +}; diff --git a/src/Explorer/Panes/AddGlobalSecondaryIndexPanel/Components/UniqueKeysComponent.tsx b/src/Explorer/Panes/AddGlobalSecondaryIndexPanel/Components/UniqueKeysComponent.tsx new file mode 100644 index 000000000..ab46c8d41 --- /dev/null +++ b/src/Explorer/Panes/AddGlobalSecondaryIndexPanel/Components/UniqueKeysComponent.tsx @@ -0,0 +1,78 @@ +import { ActionButton, IconButton, Stack } from "@fluentui/react"; +import { UniqueKeysHeader } from "Explorer/Panes/AddCollectionPanel/AddCollectionPanelUtility"; +import React from "react"; +import { userContext } from "UserContext"; + +export interface UniqueKeysComponentProps { + uniqueKeys: string[]; + setUniqueKeys: React.Dispatch>; +} + +export const UniqueKeysComponent = (props: UniqueKeysComponentProps): JSX.Element => { + const { uniqueKeys, setUniqueKeys } = props; + + const updateUniqueKeysOnChange = (value: string, uniqueKeyToReplaceIndex: number): void => { + const updatedUniqueKeys = uniqueKeys.map((uniqueKey: string, uniqueKeyIndex: number) => { + if (uniqueKeyToReplaceIndex === uniqueKeyIndex) { + return value; + } + return uniqueKey; + }); + setUniqueKeys(updatedUniqueKeys); + }; + + const deleteUniqueKeyOnClick = (uniqueKeyToDeleteIndex: number): void => { + const updatedUniqueKeys = uniqueKeys.filter((_, uniqueKeyIndex) => uniqueKeyToDeleteIndex !== uniqueKeyIndex); + setUniqueKeys(updatedUniqueKeys); + }; + + const addUniqueKeyOnClick = (): void => { + setUniqueKeys([...uniqueKeys, ""]); + }; + + return ( + + {UniqueKeysHeader()} + + {uniqueKeys.map((uniqueKey: string, uniqueKeyIndex: number): JSX.Element => { + return ( + + ) => { + updateUniqueKeysOnChange(event.target.value, uniqueKeyIndex); + }} + /> + + { + deleteUniqueKeyOnClick(uniqueKeyIndex); + }} + /> + + ); + })} + + { + addUniqueKeyOnClick(); + }} + > + Add unique key + + + ); +}; diff --git a/src/Explorer/Panes/AddGlobalSecondaryIndexPanel/Components/VectorSearchComponent.tsx b/src/Explorer/Panes/AddGlobalSecondaryIndexPanel/Components/VectorSearchComponent.tsx new file mode 100644 index 000000000..440d51ea4 --- /dev/null +++ b/src/Explorer/Panes/AddGlobalSecondaryIndexPanel/Components/VectorSearchComponent.tsx @@ -0,0 +1,58 @@ +import { Stack } from "@fluentui/react"; +import { VectorEmbedding, VectorIndex } from "Contracts/DataModels"; +import { CollapsibleSectionComponent } from "Explorer/Controls/CollapsiblePanel/CollapsibleSectionComponent"; +import { VectorEmbeddingPoliciesComponent } from "Explorer/Controls/VectorSearch/VectorEmbeddingPoliciesComponent"; +import { + ContainerVectorPolicyTooltipContent, + scrollToSection, +} from "Explorer/Panes/AddCollectionPanel/AddCollectionPanelUtility"; +import React from "react"; + +export interface VectorSearchComponentProps { + vectorEmbeddingPolicy: VectorEmbedding[]; + setVectorEmbeddingPolicy: React.Dispatch>; + vectorIndexingPolicy: VectorIndex[]; + setVectorIndexingPolicy: React.Dispatch>; + setVectorPolicyValidated: React.Dispatch>; +} + +export const VectorSearchComponent = (props: VectorSearchComponentProps): JSX.Element => { + const { + vectorEmbeddingPolicy, + setVectorEmbeddingPolicy, + vectorIndexingPolicy, + setVectorIndexingPolicy, + setVectorPolicyValidated, + } = props; + + return ( + + { + scrollToSection("collapsibleVectorPolicySectionContent"); + }} + tooltipContent={ContainerVectorPolicyTooltipContent()} + > + + + { + setVectorEmbeddingPolicy(vectorEmbeddingPolicy); + setVectorIndexingPolicy(vectorIndexingPolicy); + setVectorPolicyValidated(vectorPolicyValidated); + }} + /> + + + + + ); +}; diff --git a/src/Explorer/Panes/AddGlobalSecondaryIndexPanel/__snapshots__/AddGlobalSecondaryIndexPanel.test.tsx.snap b/src/Explorer/Panes/AddGlobalSecondaryIndexPanel/__snapshots__/AddGlobalSecondaryIndexPanel.test.tsx.snap new file mode 100644 index 000000000..143c1f804 --- /dev/null +++ b/src/Explorer/Panes/AddGlobalSecondaryIndexPanel/__snapshots__/AddGlobalSecondaryIndexPanel.test.tsx.snap @@ -0,0 +1,190 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`AddGlobalSecondaryIndexPanel render default panel 1`] = ` +
+
+ + + + *  + + + Source container id + + + + + + + *  + + + Global secondary index container id + + + + + + *  + + + Global secondary index definition + + + Learn more about defining global secondary indexes. + + } + directionalHint={4} + > + + + + + + + + + + +
+ + +`; diff --git a/src/Explorer/Panes/SettingsPane/SettingsPane.tsx b/src/Explorer/Panes/SettingsPane/SettingsPane.tsx index 1626f09d1..a40f4da99 100644 --- a/src/Explorer/Panes/SettingsPane/SettingsPane.tsx +++ b/src/Explorer/Panes/SettingsPane/SettingsPane.tsx @@ -6,7 +6,9 @@ import { Checkbox, ChoiceGroup, DefaultButton, + Dropdown, IChoiceGroupOption, + IDropdownOption, ISpinButtonStyles, IToggleStyles, Position, @@ -21,7 +23,15 @@ import { InfoTooltip } from "Common/Tooltip/InfoTooltip"; import { Platform, configContext } from "ConfigContext"; import { useDialog } from "Explorer/Controls/Dialog"; import { useDatabases } from "Explorer/useDatabases"; -import { deleteAllStates } from "Shared/AppStatePersistenceUtility"; +import { isFabric } from "Platform/Fabric/FabricUtil"; +import { + AppStateComponentNames, + deleteAllStates, + deleteState, + hasState, + loadState, + saveState, +} from "Shared/AppStatePersistenceUtility"; import { DefaultRUThreshold, LocalStorageUtility, @@ -37,6 +47,7 @@ import { acquireMsalTokenForAccount } from "Utils/AuthorizationUtils"; import { logConsoleError, logConsoleInfo } from "Utils/NotificationConsoleUtils"; import * as PriorityBasedExecutionUtils from "Utils/PriorityBasedExecutionUtils"; import { getReadOnlyKeys, listKeys } from "Utils/arm/generatedClients/cosmos/databaseAccounts"; +import { useClientWriteEnabled } from "hooks/useClientWriteEnabled"; import { useQueryCopilot } from "hooks/useQueryCopilot"; import { useSidePanel } from "hooks/useSidePanel"; import React, { FunctionComponent, useState } from "react"; @@ -143,6 +154,17 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({ ? LocalStorageUtility.getEntryString(StorageKey.IsGraphAutoVizDisabled) : "false", ); + const [selectedRegionalEndpoint, setSelectedRegionalEndpoint] = useState( + hasState({ + componentName: AppStateComponentNames.SelectedRegionalEndpoint, + globalAccountName: userContext.databaseAccount?.name, + }) + ? (loadState({ + componentName: AppStateComponentNames.SelectedRegionalEndpoint, + globalAccountName: userContext.databaseAccount?.name, + }) as string) + : undefined, + ); const [retryAttempts, setRetryAttempts] = useState( LocalStorageUtility.hasItem(StorageKey.RetryAttempts) ? LocalStorageUtility.getEntryNumber(StorageKey.RetryAttempts) @@ -189,6 +211,44 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({ configContext.platform !== Platform.Fabric && !isEmulator; const shouldShowPriorityLevelOption = PriorityBasedExecutionUtils.isFeatureEnabled() && !isEmulator; + + const uniqueAccountRegions = new Set(); + const regionOptions: IDropdownOption[] = []; + regionOptions.push({ + key: userContext?.databaseAccount?.properties?.documentEndpoint, + text: `Global (Default)`, + data: { + isGlobal: true, + writeEnabled: true, + }, + }); + userContext?.databaseAccount?.properties?.writeLocations?.forEach((loc) => { + if (!uniqueAccountRegions.has(loc.locationName)) { + uniqueAccountRegions.add(loc.locationName); + regionOptions.push({ + key: loc.documentEndpoint, + text: `${loc.locationName} (Read/Write)`, + data: { + isGlobal: false, + writeEnabled: true, + }, + }); + } + }); + userContext?.databaseAccount?.properties?.readLocations?.forEach((loc) => { + if (!uniqueAccountRegions.has(loc.locationName)) { + uniqueAccountRegions.add(loc.locationName); + regionOptions.push({ + key: loc.documentEndpoint, + text: `${loc.locationName} (Read)`, + data: { + isGlobal: false, + writeEnabled: false, + }, + }); + } + }); + const shouldShowCopilotSampleDBOption = userContext.apiType === "SQL" && useQueryCopilot.getState().copilotEnabled && @@ -274,6 +334,46 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({ } } + const storedRegionalEndpoint = loadState({ + componentName: AppStateComponentNames.SelectedRegionalEndpoint, + globalAccountName: userContext.databaseAccount?.name, + }) as string; + const selectedRegionIsGlobal = + selectedRegionalEndpoint === userContext?.databaseAccount?.properties?.documentEndpoint; + if (selectedRegionIsGlobal && storedRegionalEndpoint) { + deleteState({ + componentName: AppStateComponentNames.SelectedRegionalEndpoint, + globalAccountName: userContext.databaseAccount?.name, + }); + updateUserContext({ + selectedRegionalEndpoint: undefined, + writeEnabledInSelectedRegion: true, + refreshCosmosClient: true, + }); + useClientWriteEnabled.setState({ clientWriteEnabled: true }); + } else if ( + selectedRegionalEndpoint && + !selectedRegionIsGlobal && + selectedRegionalEndpoint !== storedRegionalEndpoint + ) { + saveState( + { + componentName: AppStateComponentNames.SelectedRegionalEndpoint, + globalAccountName: userContext.databaseAccount?.name, + }, + selectedRegionalEndpoint, + ); + const validWriteEndpoint = userContext.databaseAccount?.properties?.writeLocations?.find( + (loc) => loc.documentEndpoint === selectedRegionalEndpoint, + ); + updateUserContext({ + selectedRegionalEndpoint: selectedRegionalEndpoint, + writeEnabledInSelectedRegion: !!validWriteEndpoint, + refreshCosmosClient: true, + }); + useClientWriteEnabled.setState({ clientWriteEnabled: !!validWriteEndpoint }); + } + LocalStorageUtility.setEntryBoolean(StorageKey.RUThresholdEnabled, ruThresholdEnabled); LocalStorageUtility.setEntryBoolean(StorageKey.QueryTimeoutEnabled, queryTimeoutEnabled); LocalStorageUtility.setEntryNumber(StorageKey.RetryAttempts, retryAttempts); @@ -423,6 +523,10 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({ setDefaultQueryResultsView(option.key as SplitterDirection); }; + const handleOnSelectedRegionOptionChange = (ev: React.FormEvent, option: IDropdownOption): void => { + setSelectedRegionalEndpoint(option.key as string); + }; + const handleOnQueryRetryAttemptsSpinButtonChange = (ev: React.MouseEvent, newValue?: string): void => { const retryAttempts = Number(newValue); if (!isNaN(retryAttempts)) { @@ -583,9 +687,39 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({ )} + {userContext.apiType === "SQL" && userContext.authType === AuthType.AAD && !isFabric() && ( + + +
Region Selection
+
+ +
+
+ Changes region the Cosmos Client uses to access account. +
+
+ Select Region + + Changes the account endpoint used to perform client operations. + +
+ option.key === selectedRegionalEndpoint)?.text + : regionOptions[0]?.text + } + onChange={handleOnSelectedRegionOptionChange} + options={regionOptions} + styles={{ root: { marginBottom: "10px" } }} + /> +
+
+
+ )} {userContext.apiType === "SQL" && !isEmulator && ( <> - +
Query Timeout
@@ -626,7 +760,7 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
- +
RU Limit
@@ -660,7 +794,7 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
- +
Default Query Results View
@@ -681,8 +815,9 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
)} + {showRetrySettings && ( - +
Retry Settings
@@ -755,7 +890,7 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
)} {!isEmulator && ( - +
Enable container pagination
@@ -779,7 +914,7 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
)} {shouldShowCrossPartitionOption && ( - +
Enable cross-partition query
@@ -804,7 +939,7 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
)} {shouldShowParallelismOption && ( - +
Max degree of parallelism
@@ -837,7 +972,7 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
)} {shouldShowPriorityLevelOption && ( - +
Priority Level
@@ -860,7 +995,7 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
)} {shouldShowGraphAutoVizOption && ( - +
Display Gremlin query results as: 
@@ -881,7 +1016,7 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
)} {shouldShowCopilotSampleDBOption && ( - +
Enable sample database
@@ -916,7 +1051,15 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({ "Clear History", undefined, "Are you sure you want to proceed?", - () => deleteAllStates(), + () => { + deleteAllStates(); + updateUserContext({ + selectedRegionalEndpoint: undefined, + writeEnabledInSelectedRegion: true, + refreshCosmosClient: true, + }); + useClientWriteEnabled.setState({ clientWriteEnabled: true }); + }, "Cancel", undefined, <> @@ -927,6 +1070,7 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
  • Reset your customized tab layout, including the splitter positions
  • Erase your table column preferences, including any custom columns
  • Clear your filter history
  • +
  • Reset region selection to global
  • , ); diff --git a/src/Explorer/Panes/SettingsPane/__snapshots__/SettingsPane.test.tsx.snap b/src/Explorer/Panes/SettingsPane/__snapshots__/SettingsPane.test.tsx.snap index e89ee345b..577b6de5b 100644 --- a/src/Explorer/Panes/SettingsPane/__snapshots__/SettingsPane.test.tsx.snap +++ b/src/Explorer/Panes/SettingsPane/__snapshots__/SettingsPane.test.tsx.snap @@ -107,7 +107,7 @@ exports[`Settings Pane should render Default properly 1`] = `
    = ({ explorer }) => { }); } + if (isGlobalSecondaryIndexEnabled()) { + const addMaterializedViewPanelProps: AddGlobalSecondaryIndexPanelProps = { + explorer, + }; + + actions.push({ + id: "new_materialized_view", + label: GlobalSecondaryIndexLabels.NewGlobalSecondaryIndex, + icon: , + onClick: () => + useSidePanel + .getState() + .openSidePanel( + GlobalSecondaryIndexLabels.NewGlobalSecondaryIndex, + , + ), + }); + } + return actions; }, [explorer]); @@ -315,16 +340,18 @@ export const SidebarContainer: React.FC = ({ explorer }) => { <>
    - + {!isFabricNative() && ( + + )} + + + + + ); +}; diff --git a/src/Explorer/SplashScreen/SampleUtil.ts b/src/Explorer/SplashScreen/SampleUtil.ts new file mode 100644 index 000000000..837227c8f --- /dev/null +++ b/src/Explorer/SplashScreen/SampleUtil.ts @@ -0,0 +1,56 @@ +import { createCollection } from "Common/dataAccess/createCollection"; +import Explorer from "Explorer/Explorer"; +import { useDatabases } from "Explorer/useDatabases"; +import * as DataModels from "../../Contracts/DataModels"; +import * as ViewModels from "../../Contracts/ViewModels"; + +/** + * Public for unit tests + * @param databaseName + * @param containerName + * @param containerDatabases + */ +const hasContainer = ( + databaseName: string, + containerName: string, + containerDatabases: ViewModels.Database[], +): boolean => { + const filteredDatabases = containerDatabases.filter((database) => database.id() === databaseName); + return ( + filteredDatabases.length > 0 && + filteredDatabases[0].collections().filter((collection) => collection.id() === containerName).length > 0 + ); +}; + +export const checkContainerExists = (databaseName: string, containerName: string) => + hasContainer(databaseName, containerName, useDatabases.getState().databases); + +export const createContainer = async ( + databaseName: string, + containerName: string, + explorer: Explorer, +): Promise => { + const createRequest: DataModels.CreateCollectionParams = { + createNewDatabase: false, + collectionId: containerName, + databaseId: databaseName, + databaseLevelThroughput: false, + }; + await createCollection(createRequest); + await explorer.refreshAllDatabases(); + const database = useDatabases.getState().findDatabaseWithId(databaseName); + if (!database) { + return undefined; + } + await database.loadCollections(); + const newCollection = database.findCollectionWithId(containerName); + return newCollection; +}; + +export const importData = async (collection: ViewModels.Collection): Promise => { + // TODO: keep same chunk as ContainerSampleGenerator + const dataFileContent = await import( + /* webpackChunkName: "queryCopilotSampleData" */ "../../../sampleData/queryCopilotSampleData.json" + ); + await collection.bulkInsertDocuments(dataFileContent.data); +}; diff --git a/src/Explorer/Tabs/ConnectTab.tsx b/src/Explorer/Tabs/ConnectTab.tsx index 3c334fbc1..62f190996 100644 --- a/src/Explorer/Tabs/ConnectTab.tsx +++ b/src/Explorer/Tabs/ConnectTab.tsx @@ -16,10 +16,20 @@ export const ConnectTab: React.FC = (): JSX.Element => { const [primaryReadonlyMasterKey, setPrimaryReadonlyMasterKey] = useState(""); const [secondaryReadonlyMasterKey, setSecondaryReadonlyMasterKey] = useState(""); const uri: string = userContext.databaseAccount.properties?.documentEndpoint; - const primaryConnectionStr = `AccountEndpoint=${uri};AccountKey=${primaryMasterKey}`; - const secondaryConnectionStr = `AccountEndpoint=${uri};AccountKey=${secondaryMasterKey}`; - const primaryReadonlyConnectionStr = `AccountEndpoint=${uri};AccountKey=${primaryReadonlyMasterKey}`; - const secondaryReadonlyConnectionStr = `AccountEndpoint=${uri};AccountKey=${secondaryReadonlyMasterKey}`; + const primaryConnectionStr = `AccountEndpoint=${uri};AccountKey=${primaryMasterKey};`; + const secondaryConnectionStr = `AccountEndpoint=${uri};AccountKey=${secondaryMasterKey};`; + const primaryReadonlyConnectionStr = `AccountEndpoint=${uri};AccountKey=${primaryReadonlyMasterKey};`; + const secondaryReadonlyConnectionStr = `AccountEndpoint=${uri};AccountKey=${secondaryReadonlyMasterKey};`; + const maskedValue: string = + "*********************************************************************************************************************************"; + const [showPrimaryMasterKey, setShowPrimaryMasterKey] = useState(false); + const [showSecondaryMasterKey, setShowSecondaryMasterKey] = useState(false); + const [showPrimaryReadonlyMasterKey, setShowPrimaryReadonlyMasterKey] = useState(false); + const [showSecondaryReadonlyMasterKey, setShowSecondaryReadonlyMasterKey] = useState(false); + const [showPrimaryConnectionStr, setShowPrimaryConnectionStr] = useState(false); + const [showSecondaryConnectionStr, setShowSecondaryConnectionStr] = useState(false); + const [showPrimaryReadonlyConnectionStr, setShowPrimaryReadonlyConnectionStr] = useState(false); + const [showSecondaryReadonlyConnectionStr, setShowSecondaryReadonlyConnectionStr] = useState(false); useEffect(() => { fetchKeys(); @@ -62,55 +72,97 @@ export const ConnectTab: React.FC = (): JSX.Element => { root: { width: "100%" }, field: { backgroundColor: "rgb(230, 230, 230)" }, fieldGroup: { borderColor: "rgb(138, 136, 134)" }, + suffix: { + backgroundColor: "rgb(230, 230, 230)", + margin: 0, + padding: 0, + }, }; + const renderCopyButton = (selector: string) => ( + onCopyBtnClicked(selector)} + styles={{ + root: { + height: "100%", + backgroundColor: "rgb(230, 230, 230)", + border: "none", + }, + rootHovered: { + backgroundColor: "rgb(220, 220, 220)", + }, + rootPressed: { + backgroundColor: "rgb(210, 210, 210)", + }, + }} + /> + ); + return (
    + + renderCopyButton("#uriTextfield")} + /> +
    +
    + {userContext.hasWriteAccess && ( - - - - onCopyBtnClicked("#uriTextfield")} /> - - + renderCopyButton("#primaryKeyTextfield"), + })} + /> + setShowPrimaryMasterKey(!showPrimaryMasterKey)} /> - onCopyBtnClicked("#primaryKeyTextfield")} /> - renderCopyButton("#secondaryKeyTextfield"), + })} /> onCopyBtnClicked("#secondaryKeyTextfield")} + iconProps={{ iconName: showSecondaryMasterKey ? "Hide3" : "View" }} + onClick={() => setShowSecondaryMasterKey(!showSecondaryMasterKey)} /> - renderCopyButton("#primaryConStrTextfield"), + })} /> onCopyBtnClicked("#primaryConStrTextfield")} + iconProps={{ iconName: showPrimaryConnectionStr ? "Hide3" : "View" }} + onClick={() => setShowPrimaryConnectionStr(!showPrimaryConnectionStr)} /> @@ -118,34 +170,36 @@ export const ConnectTab: React.FC = (): JSX.Element => { label="SECONDARY CONNECTION STRING" id="secondaryConStrTextfield" readOnly - value={secondaryConnectionStr} + value={showSecondaryConnectionStr ? secondaryConnectionStr : maskedValue} styles={textfieldStyles} + {...(showSecondaryConnectionStr && { + onRenderSuffix: () => renderCopyButton("#secondaryConStrTextfield"), + })} /> onCopyBtnClicked("#secondaryConStrTextfield")} + iconProps={{ iconName: showSecondaryConnectionStr ? "Hide3" : "View" }} + onClick={() => setShowSecondaryConnectionStr(!showSecondaryConnectionStr)} /> )} - - - - onCopyBtnClicked("#uriReadOnlyTextfield")} /> - + renderCopyButton("#primaryReadonlyKeyTextfield"), + })} /> onCopyBtnClicked("#primaryReadonlyKeyTextfield")} + iconProps={{ iconName: showPrimaryReadonlyMasterKey ? "Hide3" : "View" }} + onClick={() => setShowPrimaryReadonlyMasterKey(!showPrimaryReadonlyMasterKey)} /> @@ -153,12 +207,15 @@ export const ConnectTab: React.FC = (): JSX.Element => { label="SECONDARY READ-ONLY KEY" id="secondaryReadonlyKeyTextfield" readOnly - value={secondaryReadonlyMasterKey} + value={showSecondaryReadonlyMasterKey ? secondaryReadonlyMasterKey : maskedValue} styles={textfieldStyles} + {...(showSecondaryReadonlyMasterKey && { + onRenderSuffix: () => renderCopyButton("#secondaryReadonlyKeyTextfield"), + })} /> onCopyBtnClicked("#secondaryReadonlyKeyTextfield")} + iconProps={{ iconName: showSecondaryReadonlyMasterKey ? "Hide3" : "View" }} + onClick={() => setShowSecondaryReadonlyMasterKey(!showSecondaryReadonlyMasterKey)} /> @@ -166,25 +223,31 @@ export const ConnectTab: React.FC = (): JSX.Element => { label="PRIMARY READ-ONLY CONNECTION STRING" id="primaryReadonlyConStrTextfield" readOnly - value={primaryReadonlyConnectionStr} + value={showPrimaryReadonlyConnectionStr ? primaryReadonlyConnectionStr : maskedValue} styles={textfieldStyles} + {...(showPrimaryReadonlyConnectionStr && { + onRenderSuffix: () => renderCopyButton("#primaryReadonlyConStrTextfield"), + })} /> onCopyBtnClicked("#primaryReadonlyConStrTextfield")} + iconProps={{ iconName: showPrimaryReadonlyConnectionStr ? "Hide3" : "View" }} + onClick={() => setShowPrimaryReadonlyConnectionStr(!showPrimaryReadonlyConnectionStr)} /> renderCopyButton("#secondaryReadonlyConStrTextfield"), + })} /> onCopyBtnClicked("#secondaryReadonlyConStrTextfield")} + iconProps={{ iconName: showSecondaryReadonlyConnectionStr ? "Hide3" : "View" }} + onClick={() => setShowSecondaryReadonlyConnectionStr(!showSecondaryReadonlyConnectionStr)} /> diff --git a/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2.tsx b/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2.tsx index c24ac08e8..4c96064c0 100644 --- a/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2.tsx +++ b/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2.tsx @@ -49,6 +49,7 @@ import { Action } from "Shared/Telemetry/TelemetryConstants"; import { userContext } from "UserContext"; import { logConsoleError, logConsoleInfo } from "Utils/NotificationConsoleUtils"; import { Allotment } from "allotment"; +import { useClientWriteEnabled } from "hooks/useClientWriteEnabled"; import React, { KeyboardEventHandler, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { format } from "react-string-format"; import DeleteDocumentIcon from "../../../../images/DeleteDocument.svg"; @@ -305,6 +306,7 @@ export type ButtonsDependencies = { selectedRows: Set; editorState: ViewModels.DocumentExplorerState; isPreferredApiMongoDB: boolean; + clientWriteEnabled: boolean; onNewDocumentClick: UiKeyboardEvent; onSaveNewDocumentClick: UiKeyboardEvent; onRevertNewDocumentClick: UiKeyboardEvent; @@ -328,6 +330,7 @@ const createUploadButton = (container: Explorer): CommandButtonComponentProps => hasPopup: true, disabled: useSelectedNode.getState().isDatabaseNodeOrNoneSelected() || + !useClientWriteEnabled.getState().clientWriteEnabled || useSelectedNode.getState().isQueryCopilotCollectionSelected(), }; }; @@ -346,6 +349,7 @@ export const getTabsButtons = ({ selectedRows, editorState, isPreferredApiMongoDB, + clientWriteEnabled, onNewDocumentClick, onSaveNewDocumentClick, onRevertNewDocumentClick, @@ -371,6 +375,7 @@ export const getTabsButtons = ({ hasPopup: false, disabled: !getNewDocumentButtonState(editorState).enabled || + !clientWriteEnabled || useSelectedNode.getState().isQueryCopilotCollectionSelected(), id: NEW_DOCUMENT_BUTTON_ID, }); @@ -388,6 +393,7 @@ export const getTabsButtons = ({ hasPopup: false, disabled: !getSaveNewDocumentButtonState(editorState).enabled || + !clientWriteEnabled || useSelectedNode.getState().isQueryCopilotCollectionSelected(), id: SAVE_BUTTON_ID, }); @@ -422,6 +428,7 @@ export const getTabsButtons = ({ hasPopup: false, disabled: !getSaveExistingDocumentButtonState(editorState).enabled || + !clientWriteEnabled || useSelectedNode.getState().isQueryCopilotCollectionSelected(), id: UPDATE_BUTTON_ID, }); @@ -454,7 +461,7 @@ export const getTabsButtons = ({ commandButtonLabel: label, ariaLabel: label, hasPopup: false, - disabled: useSelectedNode.getState().isQueryCopilotCollectionSelected(), + disabled: useSelectedNode.getState().isQueryCopilotCollectionSelected() || !clientWriteEnabled, id: DELETE_BUTTON_ID, }); } @@ -628,6 +635,7 @@ export const DocumentsTabComponent: React.FunctionComponent state.clientWriteEnabled); const [tabStateData, setTabStateData] = useState(() => readDocumentsTabSubComponentState(SubComponentName.MainTabDivider, _collection, { leftPaneWidthPercent: 35, @@ -765,16 +773,14 @@ export const DocumentsTabComponent: React.FunctionComponent _collection?.partitionKeyPropertyHeaders || partitionKey?.paths, - [_collection?.partitionKeyPropertyHeaders, partitionKey?.paths], - ); - let partitionKeyProperties = useMemo( - () => - partitionKeyPropertyHeaders?.map((partitionKeyPropertyHeader) => - partitionKeyPropertyHeader.replace(/[/]+/g, ".").substring(1).replace(/[']+/g, ""), - ), - [partitionKeyPropertyHeaders], + () => (partitionKey?.systemKey ? [] : _collection?.partitionKeyPropertyHeaders || partitionKey?.paths), + [_collection?.partitionKeyPropertyHeaders, partitionKey?.paths, partitionKey?.systemKey], ); + let partitionKeyProperties = useMemo(() => { + return partitionKeyPropertyHeaders?.map((partitionKeyPropertyHeader) => + partitionKeyPropertyHeader.replace(/[/]+/g, ".").substring(1).replace(/[']+/g, ""), + ); + }, [partitionKeyPropertyHeaders]); const getInitialColumnSelection = () => { const defaultColumnsIds = ["id"]; @@ -865,6 +871,7 @@ export const DocumentsTabComponent: React.FunctionComponent { + // in case of any kind of failures of accidently changing partition key, restore the original + // so that when user navigates away from current document and comes back, + // it doesnt fail to load due to using the invalid partition keys + selectedDocumentId.partitionKeyValue = originalPartitionKeyValue; onExecutionErrorChange(true); const errorMessage = getErrorMessage(error); useDialog.getState().showOkModalDialog("Update document failed", errorMessage); @@ -1279,6 +1291,7 @@ export const DocumentsTabComponent: React.FunctionComponent MongoUtility.tojson(value, null, false); const _hasShardKeySpecified = (document: unknown): boolean => { - return Boolean(extractPartitionKeyValues(document, _getPartitionKeyDefinition() as PartitionKeyDefinition)); + const partitionKeyDefinition: PartitionKeyDefinition = _getPartitionKeyDefinition() as PartitionKeyDefinition; + return partitionKeyDefinition.systemKey || Boolean(extractPartitionKeyValues(document, partitionKeyDefinition)); }; const _getPartitionKeyDefinition = (): DataModels.PartitionKey => { @@ -1733,7 +1748,7 @@ export const DocumentsTabComponent: React.FunctionComponent { + partitionKeyProperties = partitionKeyProperties.map((partitionKeyProperty, i) => { if (partitionKeyProperty && ~partitionKeyProperty.indexOf(`"`)) { partitionKeyProperty = partitionKeyProperty.replace(/["]+/g, ""); } @@ -2083,8 +2098,8 @@ export const DocumentsTabComponent: React.FunctionComponent -
    -
    +
    +
    {!isPreferredApiMongoDB && SELECT * FROM c } -
    +
    -
    +
    {isTabActive && selectedDocumentContent && selectedRows.size <= 1 && (
    SELECT * FROM c @@ -65,6 +67,7 @@ exports[`Documents tab (noSql API) when rendered should render the page 1`] = ` preferredSize="35%" >
    void; + private unsubscribeClientWriteEnabled: () => void; componentDidMount(): void { useTabs.subscribe((state: TabsState) => { @@ -712,10 +717,17 @@ class QueryTabComponentImpl extends React.Component { + useCommandBar.getState().setContextButtons(this.getTabsButtons()); + }); } componentWillUnmount(): void { document.removeEventListener("keydown", this.handleCopilotKeyDown); + if (this.unsubscribeClientWriteEnabled) { + this.unsubscribeClientWriteEnabled(); + } } private getEditorAndQueryResult(): JSX.Element { diff --git a/src/Explorer/Tree/Collection.ts b/src/Explorer/Tree/Collection.ts index c692d0747..d44c99ac3 100644 --- a/src/Explorer/Tree/Collection.ts +++ b/src/Explorer/Tree/Collection.ts @@ -1,4 +1,10 @@ -import { Resource, StoredProcedureDefinition, TriggerDefinition, UserDefinedFunctionDefinition } from "@azure/cosmos"; +import { + JSONObject, + Resource, + StoredProcedureDefinition, + TriggerDefinition, + UserDefinedFunctionDefinition, +} from "@azure/cosmos"; import { useNotebook } from "Explorer/Notebook/useNotebook"; import { DocumentsTabV2 } from "Explorer/Tabs/DocumentsTabV2/DocumentsTabV2"; import { isFabricMirrored } from "Platform/Fabric/FabricUtil"; @@ -58,6 +64,8 @@ export default class Collection implements ViewModels.Collection { public uniqueKeyPolicy: DataModels.UniqueKeyPolicy; public usageSizeInKB: ko.Observable; public computedProperties: ko.Observable; + public materializedViews: ko.Observable; + public materializedViewDefinition: ko.Observable; public offer: ko.Observable; public conflictResolutionPolicy: ko.Observable; @@ -124,6 +132,8 @@ export default class Collection implements ViewModels.Collection { this.requestSchema = data.requestSchema; this.geospatialConfig = ko.observable(data.geospatialConfig); this.computedProperties = ko.observable(data.computedProperties); + this.materializedViews = ko.observable(data.materializedViews); + this.materializedViewDefinition = ko.observable(data.materializedViewDefinition); this.partitionKeyPropertyHeaders = this.partitionKey?.paths; this.partitionKeyProperties = this.partitionKeyPropertyHeaders?.map((partitionKeyPropertyHeader, i) => { @@ -1040,9 +1050,22 @@ export default class Collection implements ViewModels.Collection { } public async uploadFiles(files: FileList): Promise<{ data: UploadDetailsRecord[] }> { - const data = await Promise.all(Array.from(files).map((file) => this.uploadFile(file))); - - return { data }; + try { + TelemetryProcessor.trace(Action.UploadDocuments, ActionModifiers.Start, { + nbFiles: files.length, + }); + const data = await Promise.all(Array.from(files).map((file) => this.uploadFile(file))); + TelemetryProcessor.trace(Action.UploadDocuments, ActionModifiers.Success, { + nbFiles: files.length, + }); + return { data }; + } catch (error) { + TelemetryProcessor.trace(Action.UploadDocuments, ActionModifiers.Failed, { + error: getErrorMessage(error), + errorStack: getErrorStack(error), + }); + throw error; + } } private uploadFile(file: File): Promise { @@ -1069,6 +1092,56 @@ export default class Collection implements ViewModels.Collection { }); } + public async bulkInsertDocuments(documents: JSONObject[]): Promise<{ + numSucceeded: number; + numFailed: number; + numThrottled: number; + errors: string[]; + }> { + const stats = { + numSucceeded: 0, + numFailed: 0, + numThrottled: 0, + errors: [] as string[], + }; + + const chunkSize = 100; // 100 is the max # of bulk operations the SDK currently accepts + const chunkedContent = Array.from({ length: Math.ceil(documents.length / chunkSize) }, (_, index) => + documents.slice(index * chunkSize, index * chunkSize + chunkSize), + ); + for (const chunk of chunkedContent) { + let retryAttempts = 0; + let chunkComplete = false; + let documentsToAttempt = chunk; + while (retryAttempts < 10 && !chunkComplete) { + const responses = await bulkCreateDocument(this, documentsToAttempt); + const attemptedDocuments = [...documentsToAttempt]; + documentsToAttempt = []; + responses.forEach((response, index) => { + if (response.statusCode === 201) { + stats.numSucceeded++; + } else if (response.statusCode === 429) { + documentsToAttempt.push(attemptedDocuments[index]); + } else { + stats.numFailed++; + stats.errors.push(JSON.stringify(response.resourceBody)); + } + }); + if (documentsToAttempt.length === 0) { + chunkComplete = true; + break; + } + logConsoleInfo( + `${documentsToAttempt.length} document creations were throttled. Waiting ${retryAttempts} seconds and retrying throttled documents`, + ); + retryAttempts++; + await sleep(retryAttempts); + } + } + + return stats; + } + private async _createDocumentsFromFile(fileName: string, documentContent: string): Promise { const record: UploadDetailsRecord = { fileName: fileName, @@ -1081,38 +1154,11 @@ export default class Collection implements ViewModels.Collection { try { const parsedContent = JSON.parse(documentContent); if (Array.isArray(parsedContent)) { - const chunkSize = 100; // 100 is the max # of bulk operations the SDK currently accepts - const chunkedContent = Array.from({ length: Math.ceil(parsedContent.length / chunkSize) }, (_, index) => - parsedContent.slice(index * chunkSize, index * chunkSize + chunkSize), - ); - for (const chunk of chunkedContent) { - let retryAttempts = 0; - let chunkComplete = false; - let documentsToAttempt = chunk; - while (retryAttempts < 10 && !chunkComplete) { - const responses = await bulkCreateDocument(this, documentsToAttempt); - const attemptedDocuments = [...documentsToAttempt]; - documentsToAttempt = []; - responses.forEach((response, index) => { - if (response.statusCode === 201) { - record.numSucceeded++; - } else if (response.statusCode === 429) { - documentsToAttempt.push(attemptedDocuments[index]); - } else { - record.numFailed++; - } - }); - if (documentsToAttempt.length === 0) { - chunkComplete = true; - break; - } - logConsoleInfo( - `${documentsToAttempt.length} document creations were throttled. Waiting ${retryAttempts} seconds and retrying throttled documents`, - ); - retryAttempts++; - await sleep(retryAttempts); - } - } + const { numSucceeded, numFailed, numThrottled, errors } = await this.bulkInsertDocuments(parsedContent); + record.numSucceeded = numSucceeded; + record.numFailed = numFailed; + record.numThrottled = numThrottled; + record.errors = errors; } else { await createDocument(this, parsedContent); record.numSucceeded++; diff --git a/src/Explorer/Tree/Database.tsx b/src/Explorer/Tree/Database.tsx index 0ed11cb5d..5ad2769d8 100644 --- a/src/Explorer/Tree/Database.tsx +++ b/src/Explorer/Tree/Database.tsx @@ -19,7 +19,7 @@ import { logConsoleError } from "../../Utils/NotificationConsoleUtils"; import { useSidePanel } from "../../hooks/useSidePanel"; import { useTabs } from "../../hooks/useTabs"; import Explorer from "../Explorer"; -import { AddCollectionPanel } from "../Panes/AddCollectionPanel"; +import { AddCollectionPanel } from "../Panes/AddCollectionPanel/AddCollectionPanel"; import { DatabaseSettingsTabV2 } from "../Tabs/SettingsTabV2"; import { useDatabases } from "../useDatabases"; import { useSelectedNode } from "../useSelectedNode"; diff --git a/src/Explorer/Tree/__snapshots__/treeNodeUtil.test.ts.snap b/src/Explorer/Tree/__snapshots__/treeNodeUtil.test.ts.snap index 9814a72fb..8b7336600 100644 --- a/src/Explorer/Tree/__snapshots__/treeNodeUtil.test.ts.snap +++ b/src/Explorer/Tree/__snapshots__/treeNodeUtil.test.ts.snap @@ -30,7 +30,7 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the Ca "styleClass": "deleteCollectionMenuItem", }, ], - "iconSrc": , "isExpanded": true, @@ -72,7 +72,7 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the Ca "styleClass": "deleteCollectionMenuItem", }, ], - "iconSrc": , "isExpanded": true, @@ -145,7 +145,7 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the Ca "styleClass": "deleteCollectionMenuItem", }, ], - "iconSrc": , "isExpanded": true, @@ -264,7 +264,7 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the Ca "styleClass": "deleteCollectionMenuItem", }, ], - "iconSrc": , "isExpanded": true, @@ -369,7 +369,7 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the Mo "styleClass": "deleteCollectionMenuItem", }, ], - "iconSrc": , "isExpanded": true, @@ -442,7 +442,7 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the Mo "styleClass": "deleteCollectionMenuItem", }, ], - "iconSrc": , "isExpanded": true, @@ -546,7 +546,7 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the Mo "styleClass": "deleteCollectionMenuItem", }, ], - "iconSrc": , "isExpanded": true, @@ -696,7 +696,7 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the Mo "styleClass": "deleteCollectionMenuItem", }, ], - "iconSrc": , "isExpanded": true, @@ -740,12 +740,38 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the Mo ] `; -exports[`createDatabaseTreeNodes generates the correct tree structure for the SQL API, on Fabric non read-only 1`] = ` +exports[`createDatabaseTreeNodes generates the correct tree structure for the SQL API, on Fabric non read-only (native) 1`] = ` [ { "children": [ { - "children": undefined, + "children": [ + { + "contextMenu": [ + { + "iconSrc": {}, + "label": "New SQL Query", + "onClick": [Function], + }, + { + "iconSrc": {}, + "label": "Delete Container", + "onClick": [Function], + "styleClass": "deleteCollectionMenuItem", + }, + ], + "id": "", + "isSelected": [Function], + "label": "Items", + "onClick": [Function], + }, + { + "id": "", + "isSelected": [Function], + "label": "Settings", + "onClick": [Function], + }, + ], "className": "collectionNode", "contextMenu": [ { @@ -760,7 +786,7 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ "styleClass": "deleteCollectionMenuItem", }, ], - "iconSrc": , "isExpanded": true, @@ -772,7 +798,38 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ "onExpanded": [Function], }, { - "children": undefined, + "children": [ + { + "contextMenu": [ + { + "iconSrc": {}, + "label": "New SQL Query", + "onClick": [Function], + }, + { + "iconSrc": {}, + "label": "Delete Container", + "onClick": [Function], + "styleClass": "deleteCollectionMenuItem", + }, + ], + "id": "", + "isSelected": [Function], + "label": "Items", + "onClick": [Function], + }, + { + "id": "", + "isSelected": [Function], + "label": "Settings", + "onClick": [Function], + }, + { + "isSelected": [Function], + "label": "Conflicts", + "onClick": [Function], + }, + ], "className": "collectionNode", "contextMenu": [ { @@ -787,7 +844,7 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ "styleClass": "deleteCollectionMenuItem", }, ], - "iconSrc": , "isExpanded": true, @@ -806,12 +863,6 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ "label": "New Container", "onClick": [Function], }, - { - "iconSrc": {}, - "label": "Delete Database", - "onClick": [Function], - "styleClass": "deleteDatabaseMenuItem", - }, ], "iconSrc": , "isExpanded": true, @@ -860,12 +937,6 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ "label": "New Container", "onClick": [Function], }, - { - "iconSrc": {}, - "label": "Delete Database", - "onClick": [Function], - "styleClass": "deleteDatabaseMenuItem", - }, ], "iconSrc": , "isExpanded": true, @@ -919,12 +1071,6 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ "label": "New Container", "onClick": [Function], }, - { - "iconSrc": {}, - "label": "Delete Database", - "onClick": [Function], - "styleClass": "deleteDatabaseMenuItem", - }, ], "iconSrc": , "isExpanded": true, @@ -974,7 +1120,7 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ "onClick": [Function], }, ], - "iconSrc": , "isExpanded": true, @@ -1010,7 +1156,7 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ "onClick": [Function], }, ], - "iconSrc": , "isExpanded": true, @@ -1046,7 +1192,7 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ "onClick": [Function], }, ], - "iconSrc": , "isExpanded": true, @@ -1208,7 +1354,7 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ "styleClass": "deleteCollectionMenuItem", }, ], - "iconSrc": , "isExpanded": true, @@ -1311,7 +1457,7 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ "styleClass": "deleteCollectionMenuItem", }, ], - "iconSrc": , "isExpanded": true, @@ -1445,7 +1591,7 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ "styleClass": "deleteCollectionMenuItem", }, ], - "iconSrc": , "isExpanded": true, @@ -1625,7 +1771,7 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ "styleClass": "deleteCollectionMenuItem", }, ], - "iconSrc": , "isExpanded": true, @@ -1799,7 +1945,7 @@ exports[`createDatabaseTreeNodes using NoSQL API on Hosted Platform creates expe "styleClass": "deleteCollectionMenuItem", }, ], - "iconSrc": , "isExpanded": true, @@ -1897,7 +2043,7 @@ exports[`createDatabaseTreeNodes using NoSQL API on Hosted Platform creates expe "styleClass": "deleteCollectionMenuItem", }, ], - "iconSrc": , "isExpanded": true, @@ -2031,7 +2177,7 @@ exports[`createDatabaseTreeNodes using NoSQL API on Hosted Platform creates expe "styleClass": "deleteCollectionMenuItem", }, ], - "iconSrc": , "isExpanded": true, @@ -2211,7 +2357,7 @@ exports[`createDatabaseTreeNodes using NoSQL API on Hosted Platform creates expe "styleClass": "deleteCollectionMenuItem", }, ], - "iconSrc": , "isExpanded": true, diff --git a/src/Explorer/Tree/treeNodeUtil.test.ts b/src/Explorer/Tree/treeNodeUtil.test.ts index f9298a428..64cc3a6c2 100644 --- a/src/Explorer/Tree/treeNodeUtil.test.ts +++ b/src/Explorer/Tree/treeNodeUtil.test.ts @@ -82,6 +82,7 @@ jest.mock("Explorer/Tree/Trigger", () => { jest.mock("Common/DatabaseAccountUtility", () => { return { isPublicInternetAccessAllowed: () => true, + isGlobalSecondaryIndexEnabled: () => false, }; }); @@ -134,6 +135,15 @@ const baseCollection = { kind: "hash", version: 2, }, + materializedViews: ko.observable([ + { id: "view1", _rid: "rid1" }, + { id: "view2", _rid: "rid2" }, + ]), + materializedViewDefinition: ko.observable({ + definition: "SELECT * FROM c WHERE c.id = 1", + sourceCollectionId: "source1", + sourceCollectionRid: "rid123", + }), storedProcedures: ko.observableArray([]), userDefinedFunctions: ko.observableArray([]), triggers: ko.observableArray([]), @@ -363,18 +373,28 @@ describe("createDatabaseTreeNodes", () => { it.each<[string, Platform, boolean, Partial, Partial]>([ [ - "the SQL API, on Fabric read-only", + "the SQL API, on Fabric read-only (mirrored)", Platform.Fabric, false, { capabilities: [], enableMultipleWriteLocations: true }, - { fabricContext: { isReadOnly: true } as FabricContext }, + { + fabricContext: { + isReadOnly: true, + artifactType: CosmosDbArtifactType.MIRRORED_KEY, + } as FabricContext, + }, ], [ - "the SQL API, on Fabric non read-only", + "the SQL API, on Fabric non read-only (native)", Platform.Fabric, false, { capabilities: [], enableMultipleWriteLocations: true }, - { fabricContext: { isReadOnly: false } as FabricContext }, + { + fabricContext: { + isReadOnly: false, + artifactType: CosmosDbArtifactType.NATIVE, + } as FabricContext, + }, ], [ "the SQL API, on Portal", diff --git a/src/Explorer/Tree/treeNodeUtil.tsx b/src/Explorer/Tree/treeNodeUtil.tsx index 60fe9079b..f7f7a764c 100644 --- a/src/Explorer/Tree/treeNodeUtil.tsx +++ b/src/Explorer/Tree/treeNodeUtil.tsx @@ -1,4 +1,4 @@ -import { DatabaseRegular, DocumentMultipleRegular, SettingsRegular } from "@fluentui/react-icons"; +import { DatabaseRegular, DocumentMultipleRegular, EyeRegular, SettingsRegular } from "@fluentui/react-icons"; import { TreeNode } from "Explorer/Controls/TreeComponent/TreeNodeComponent"; import { collectionWasOpened } from "Explorer/MostRecentActivity/MostRecentActivity"; import TabsBase from "Explorer/Tabs/TabsBase"; @@ -6,7 +6,7 @@ import StoredProcedure from "Explorer/Tree/StoredProcedure"; import Trigger from "Explorer/Tree/Trigger"; import UserDefinedFunction from "Explorer/Tree/UserDefinedFunction"; import { useDatabases } from "Explorer/useDatabases"; -import { isFabricMirrored } from "Platform/Fabric/FabricUtil"; +import { isFabric, isFabricMirrored, isFabricNative } from "Platform/Fabric/FabricUtil"; import { getItemName } from "Utils/APITypeUtils"; import { isServerlessAccount } from "Utils/CapabilityUtils"; import { useTabs } from "hooks/useTabs"; @@ -23,12 +23,13 @@ import { useNotebook } from "../Notebook/useNotebook"; import { useSelectedNode } from "../useSelectedNode"; export const shouldShowScriptNodes = (): boolean => { - return !isFabricMirrored() && (userContext.apiType === "SQL" || userContext.apiType === "Gremlin"); + return !isFabric() && (userContext.apiType === "SQL" || userContext.apiType === "Gremlin"); }; const TreeDatabaseIcon = ; const TreeSettingsIcon = ; const TreeCollectionIcon = ; +const GlobalSecondaryIndexCollectionIcon = ; //check icon export const createSampleDataTreeNodes = (sampleDataResourceTokenCollection: ViewModels.CollectionBase): TreeNode[] => { const updatedSampleTree: TreeNode = { @@ -219,7 +220,7 @@ export const buildCollectionNode = ( ): TreeNode => { let children: TreeNode[]; // Flat Tree for Fabric - if (configContext.platform !== Platform.Fabric) { + if (!isFabricMirrored()) { children = buildCollectionNodeChildren(database, collection, isNotebookEnabled, container, refreshActiveTab); } @@ -228,7 +229,7 @@ export const buildCollectionNode = ( children: children, className: "collectionNode", contextMenu: ResourceTreeContextMenuButtonFactory.createCollectionContextMenuButton(container, collection), - iconSrc: TreeCollectionIcon, + iconSrc: collection.materializedViewDefinition() ? GlobalSecondaryIndexCollectionIcon : TreeCollectionIcon, onClick: () => { useSelectedNode.getState().setSelectedNode(collection); collection.openTab(); @@ -317,7 +318,7 @@ const buildCollectionNodeChildren = ( children.push({ id, - label: database.isDatabaseShared() || isServerlessAccount() ? "Settings" : "Scale & Settings", + label: database.isDatabaseShared() || isServerlessAccount() || isFabricNative() ? "Settings" : "Scale & Settings", onClick: collection.onSettingsClick.bind(collection), isSelected: () => useSelectedNode diff --git a/src/Localization/en/MaterializedViewsBuilder.json b/src/Localization/en/MaterializedViewsBuilder.json index 7c3aaa032..0400122d9 100644 --- a/src/Localization/en/MaterializedViewsBuilder.json +++ b/src/Localization/en/MaterializedViewsBuilder.json @@ -1,11 +1,11 @@ { - "MaterializedViewsBuilderDescription": "Provision a Materializedviews builder cluster for your Azure Cosmos DB account. Materializedviews builder is compute in your account that performs read operations on source collection for any updates and applies them on materialized views as per the materializedview definition.", - "MaterializedViewsBuilder": "Materializedviews Builder", + "MaterializedViewsBuilderDescription": "Provision a materialized views builder for your Azure Cosmos DB account. Materialized views builder is compute in your account that performs read operations on source collection for any updates and applies them on materialized views as per the materialized view definition.", + "MaterializedViewsBuilder": "Materialized views builder", "Provisioned": "Provisioned", "Deprovisioned": "Deprovisioned", - "LearnAboutMaterializedViews": "Learn more about materializedviews.", - "DeprovisioningDetailsText": "Learn more about materializedviews.", - "MaterializedviewsBuilderPricing": "Learn more about materializedviews pricing.", + "LearnAboutMaterializedViews": "Learn more about materialized views.", + "DeprovisioningDetailsText": "Learn more about materialized views.", + "MaterializedviewsBuilderPricing": "Learn more about materialized views pricing.", "SKUs": "SKUs", "SKUsPlaceHolder": "Select SKUs", "NumberOfInstances": "Number of instances", @@ -14,35 +14,58 @@ "CosmosD8s": "Cosmos.D8s (General Purpose Cosmos Compute with 8 vCPUs, 32 GB Memory)", "CosmosD16s": "Cosmos.D16s (General Purpose Cosmos Compute with 16 vCPUs, 64 GB Memory)", "CosmosD32s": "Cosmos.D32s (General Purpose Cosmos Compute with 32 vCPUs, 128 GB Memory)", - "CreateMessage": "MaterializedViewsBuilder resource is being created.", + "CreateMessage": "Materialized views builder resource is being created.", "CreateInitializeTitle": "Provisioning resource", - "CreateInitializeMessage": "Materializedviews Builder resource will be provisioned.", + "CreateInitializeMessage": "Materialized views builder resource will be provisioned.", "CreateSuccessTitle": "Resource provisioned", - "CreateSuccesseMessage": "Materializedviews Builder resource provisioned.", + "CreateSuccesseMessage": "Materialized views builder resource provisioned.", "CreateFailureTitle": "Failed to provision resource", - "CreateFailureMessage": "Materializedviews Builder resource provisioning failed.", - "UpdateMessage": "MaterializedViewsBuilder resource is being updated.", + "CreateFailureMessage": "Materialized views builder resource provisioning failed.", + "UpdateMessage": "Materialized views builder resource is being updated.", "UpdateInitializeTitle": "Updating resource", - "UpdateInitializeMessage": "Materializedviews Builder resource will be updated.", + "UpdateInitializeMessage": "Materialized views builder resource will be updated.", "UpdateSuccessTitle": "Resource updated", - "UpdateSuccesseMessage": "Materializedviews Builder resource updated.", + "UpdateSuccesseMessage": "Materialized views builder resource updated.", "UpdateFailureTitle": "Failed to update resource", - "UpdateFailureMessage": "Materializedviews Builder resource updation failed.", - "DeleteMessage": "MaterializedViewsBuilder resource is being deleted.", + "UpdateFailureMessage": "Materialized views builder resource update failed.", + "DeleteMessage": "Materialized views builder resource is being deleted.", "DeleteInitializeTitle": "Deleting resource", - "DeleteInitializeMessage": "Materializedviews Builder resource will be deleted.", + "DeleteInitializeMessage": "Materialized views builder resource will be deleted.", "DeleteSuccessTitle": "Resource deleted", - "DeleteSuccesseMessage": "Materializedviews Builder resource deleted.", + "DeleteSuccesseMessage": "Materialized views builder resource deleted.", "DeleteFailureTitle": "Failed to delete resource", - "DeleteFailureMessage": "Materializedviews Builder resource deletion failed.", + "DeleteFailureMessage": "Materialized views builder resource deletion failed.", "ApproximateCost": "Approximate Cost Per Hour", - "CostText": "Hourly cost of the Materializedviews Builder resource depends on the SKU selection, number of instances per region, and number of regions.", + "CostText": "Hourly cost of the materialized views builder resource depends on the SKU selection, number of instances per region, and number of regions.", "MetricsString": "Metrics", - "MetricsText": "Monitor the CPU and memory usage for the Materializedviews Builder instances in ", + "MetricsText": "Monitor the CPU and memory usage for the materialized views builder instances in ", "MetricsBlade": "the metrics blade.", "MonitorUsage": "Monitor Usage", - "ResizingDecisionText": "To understand if the Materializedviews Builder is the right size, ", - "ResizingDecisionLink": "learn more about Materializedviews Builder sizing.", - "WarningBannerOnUpdate": "Adding or modifying Materializedviews Builder instances may affect your bill.", - "WarningBannerOnDelete": "After deprovisioning the Materializedviews Builder, your materializedviews will not be updated with new source changes anymore. Materializedviews builder is compute in your account that performs read operations on source collection for any updates and applies them on materialized views as per the materializedview definition." + "ResizingDecisionText": "To understand if the materialized views builder is the right size, ", + "ResizingDecisionLink": "learn more about materialized views builder sizing.", + "WarningBannerOnUpdate": "Adding or modifying materialized views builder instances may affect your bill.", + "WarningBannerOnDelete": "After deprovisioning the materialized views builder, your materialized views will not be updated with new source changes anymore. Materialized views builder is compute in your account that performs read operations on source containers for any updates and applies them on materialized views as per the materialized view definition.", + "GlobalsecondaryindexesBuilderDescription": "Provision a global secondary indexes builder for your Azure Cosmos DB account. The global secondary indexes builder is compute in your account that performs read operations on source collections for any updates and populates the global secondary indexes as per their definition.", + "GlobalsecondaryindexesBuilder": "Global secondary indexes builder", + "LearnAboutGlobalSecondaryIndexes": "Learn more about global secondary indexes.", + "GlobalsecondaryindexesDeprovisioningDetailsText": "Learn more about global secondary indexes.", + "GlobalsecondaryindexesBuilderPricing": "Learn more about global secondary indexes pricing.", + "GlobalsecondaryindexesCreateMessage": "Global secondary indexes builder resource is being created.", + "GlobalsecondaryindexesCreateInitializeMessage": "Global secondary indexes builder resource will be provisioned.", + "GlobalsecondaryindexesCreateSuccesseMessage": "Global secondary indexes builder resource provisioned.", + "GlobalsecondaryindexesCreateFailureMessage": "Global secondary indexes builder resource provisioning failed.", + "GlobalsecondaryindexesUpdateMessage": "Global secondary indexes builder resource is being updated.", + "GlobalsecondaryindexesUpdateInitializeMessage": "Global secondary indexes builder resource will be updated.", + "GlobalsecondaryindexesUpdateSuccesseMessage": "Global secondary indexes builder resource updated.", + "GlobalsecondaryindexesUpdateFailureMessage": "Global secondary indexes builder resource update failed.", + "GlobalsecondaryindexesDeleteMessage": "Global secondary indexes builder resource is being deleted.", + "GlobalsecondaryindexesDeleteInitializeMessage": "Global secondary indexes builder resource will be deleted.", + "GlobalsecondaryindexesDeleteSuccesseMessage": "Global secondary indexes builder resource deleted.", + "GlobalsecondaryindexesDeleteFailureMessage": "Global secondary indexes builder resource deletion failed.", + "GlobalsecondaryindexesCostText": "Hourly cost of the global secondary indexes builder resource depends on the SKU selection, number of instances per region, and number of regions.", + "GlobalsecondaryindexesMetricsText": "Monitor the CPU and memory usage for the global secondary indexes builder instances in ", + "GlobalsecondaryindexesResizingDecisionText": "To understand if the global secondary indexes builder is the right size, ", + "GlobalsecondaryindexesesizingDecisionLink": "learn more about global secondary indexes builder sizing.", + "GlobalsecondaryindexesWarningBannerOnUpdate": "Adding or modifying global secondary indexes builder instances may affect your bill.", + "GlobalsecondaryindexesWarningBannerOnDelete": "After deprovisioning the global secondary indexes builder, your global secondary indexes will no longer be updated with new source changes. Global secondary indexes builder is compute in your account that performs read operations on source containers for any updates and applies them on global secondary indexes as per their definition." } \ No newline at end of file diff --git a/src/SelfServe/MaterializedViewsBuilder/MaterializedViewsBuilder.rp.ts b/src/SelfServe/MaterializedViewsBuilder/MaterializedViewsBuilder.rp.ts index d5fae8d0d..59833e912 100644 --- a/src/SelfServe/MaterializedViewsBuilder/MaterializedViewsBuilder.rp.ts +++ b/src/SelfServe/MaterializedViewsBuilder/MaterializedViewsBuilder.rp.ts @@ -6,9 +6,9 @@ import { RefreshResult } from "../SelfServeTypes"; import MaterializedViewsBuilder from "./MaterializedViewsBuilder"; import { FetchPricesResponse, + MaterializedViewsBuilderServiceResource, PriceMapAndCurrencyCode, RegionsResponse, - MaterializedViewsBuilderServiceResource, UpdateMaterializedViewsBuilderRequestParameters, } from "./MaterializedViewsBuilderTypes"; @@ -123,11 +123,23 @@ export const refreshMaterializedViewsBuilderProvisioning = async (): Promise => { }; const NumberOfInstancesDropdownInfo: Info = { - messageTKey: "ResizingDecisionText", + messageTKey: userContext.apiType === "SQL" ? "GlobalsecondaryindexesResizingDecisionText" : "ResizingDecisionText", link: { href: "https://aka.ms/cosmos-db-materializedviewsbuilder-size", - textTKey: "ResizingDecisionLink", + textTKey: userContext.apiType === "SQL" ? "GlobalsecondaryindexesesizingDecisionLink" : "ResizingDecisionLink", }, }; const ApproximateCostDropDownInfo: Info = { - messageTKey: "CostText", + messageTKey: userContext.apiType === "SQL" ? "GlobalsecondaryindexesCostText" : "CostText", link: { href: "https://aka.ms/cosmos-db-materializedviewsbuilder-pricing", - textTKey: "MaterializedviewsBuilderPricing", + textTKey: + userContext.apiType === "SQL" ? "GlobalsecondaryindexesBuilderPricing" : "MaterializedviewsBuilderPricing", }, }; @@ -268,15 +281,20 @@ export default class MaterializedViewsBuilder extends SelfServeBaseClass { portalNotification: { initialize: { titleTKey: "DeleteInitializeTitle", - messageTKey: "DeleteInitializeMessage", + messageTKey: + userContext.apiType === "SQL" + ? "GlobalsecondaryindexesDeleteInitializeMessage" + : "DeleteInitializeMessage", }, success: { titleTKey: "DeleteSuccessTitle", - messageTKey: "DeleteSuccesseMessage", + messageTKey: + userContext.apiType === "SQL" ? "GlobalsecondaryindexesDeleteSuccesseMessage" : "DeleteSuccesseMessage", }, failure: { titleTKey: "DeleteFailureTitle", - messageTKey: "DeleteFailureMessage", + messageTKey: + userContext.apiType === "SQL" ? "GlobalsecondaryindexesDeleteFailureMessage" : "DeleteFailureMessage", }, }, }; @@ -289,15 +307,20 @@ export default class MaterializedViewsBuilder extends SelfServeBaseClass { portalNotification: { initialize: { titleTKey: "UpdateInitializeTitle", - messageTKey: "UpdateInitializeMessage", + messageTKey: + userContext.apiType === "SQL" + ? "GlobalsecondaryindexesUpdateInitializeMessage" + : "UpdateInitializeMessage", }, success: { titleTKey: "UpdateSuccessTitle", - messageTKey: "UpdateSuccesseMessage", + messageTKey: + userContext.apiType === "SQL" ? "GlobalsecondaryindexesUpdateSuccesseMessage" : "UpdateSuccesseMessage", }, failure: { titleTKey: "UpdateFailureTitle", - messageTKey: "UpdateFailureMessage", + messageTKey: + userContext.apiType === "SQL" ? "GlobalsecondaryindexesUpdateFailureMessage" : "UpdateFailureMessage", }, }, }; @@ -311,15 +334,20 @@ export default class MaterializedViewsBuilder extends SelfServeBaseClass { portalNotification: { initialize: { titleTKey: "CreateInitializeTitle", - messageTKey: "CreateInitializeMessage", + messageTKey: + userContext.apiType === "SQL" + ? "GlobalsecondaryindexesCreateInitializeMessage" + : "CreateInitializeMessage", }, success: { titleTKey: "CreateSuccessTitle", - messageTKey: "CreateSuccesseMessage", + messageTKey: + userContext.apiType === "SQL" ? "GlobalsecondaryindexesCreateSuccesseMessage" : "CreateSuccesseMessage", }, failure: { titleTKey: "CreateFailureTitle", - messageTKey: "CreateFailureMessage", + messageTKey: + userContext.apiType === "SQL" ? "GlobalsecondaryindexesCreateFailureMessage" : "CreateFailureMessage", }, }, }; @@ -366,11 +394,17 @@ export default class MaterializedViewsBuilder extends SelfServeBaseClass { @Values({ description: { - textTKey: "MaterializedViewsBuilderDescription", + textTKey: + userContext.apiType === "SQL" + ? "GlobalsecondaryindexesBuilderDescription" + : "MaterializedViewsBuilderDescription", type: DescriptionType.Text, link: { - href: "https://aka.ms/cosmos-db-materializedviews", - textTKey: "LearnAboutMaterializedViews", + href: + userContext.apiType === "SQL" + ? "https://learn.microsoft.com/en-us/azure/cosmos-db/nosql/materialized-views" + : "https://learn.microsoft.com/en-us/azure/cosmos-db/cassandra/materialized-views", + textTKey: userContext.apiType === "SQL" ? "LearnAboutGlobalSecondaryIndexes" : "LearnAboutMaterializedViews", }, }, }) @@ -378,7 +412,7 @@ export default class MaterializedViewsBuilder extends SelfServeBaseClass { @OnChange(onEnableMaterializedViewsBuilderChange) @Values({ - labelTKey: "MaterializedViewsBuilder", + labelTKey: userContext.apiType === "SQL" ? "GlobalSecondaryIndexesBuilder" : "MaterializedViewsBuilder", trueLabelTKey: "Provisioned", falseLabelTKey: "Deprovisioned", }) diff --git a/src/Shared/AppStatePersistenceUtility.ts b/src/Shared/AppStatePersistenceUtility.ts index 58a02c3b3..ed354ef06 100644 --- a/src/Shared/AppStatePersistenceUtility.ts +++ b/src/Shared/AppStatePersistenceUtility.ts @@ -10,6 +10,7 @@ export enum AppStateComponentNames { MostRecentActivity = "MostRecentActivity", QueryCopilot = "QueryCopilot", DataExplorerAction = "DataExplorerAction", + SelectedRegionalEndpoint = "SelectedRegionalEndpoint", } // Subcomponent for DataExplorerAction diff --git a/src/Shared/Telemetry/TelemetryConstants.ts b/src/Shared/Telemetry/TelemetryConstants.ts index 1fae132ad..d61a2cf8f 100644 --- a/src/Shared/Telemetry/TelemetryConstants.ts +++ b/src/Shared/Telemetry/TelemetryConstants.ts @@ -1,16 +1,18 @@ // Data Explorer specific actions. No need to keep this in sync with the one in Portal. +// Some of the enums names are used in Fabric. Please do not rename them. export enum Action { CollapseTreeNode, - CreateCollection, - CreateDocument, + CreateCollection, // Used in Fabric. Please do not rename. + CreateGlobalSecondaryIndex, + CreateDocument, // Used in Fabric. Please do not rename. CreateStoredProcedure, CreateTrigger, CreateUDF, - DeleteCollection, + DeleteCollection, // Used in Fabric. Please do not rename. DeleteDatabase, DeleteDocument, ExpandTreeNode, - ExecuteQuery, + ExecuteQuery, // Used in Fabric. Please do not rename. HasFeature, GetVNETServices, InitializeAccountLocationFromResourceGroup, @@ -119,6 +121,7 @@ export enum Action { NotebooksGalleryPublishedCount, SelfServe, ExpandAddCollectionPaneAdvancedSection, + ExpandAddGlobalSecondaryIndexPaneAdvancedSection, SchemaAnalyzerClickAnalyze, SelfServeComponent, LaunchQuickstart, @@ -142,6 +145,7 @@ export enum Action { ReadPersistedTabState, SavePersistedTabState, DeletePersistedTabState, + UploadDocuments, // Used in Fabric. Please do not rename. } export const ActionModifiers = { diff --git a/src/UserContext.ts b/src/UserContext.ts index 6569d5e18..8a880f498 100644 --- a/src/UserContext.ts +++ b/src/UserContext.ts @@ -111,6 +111,8 @@ export interface UserContext { readonly isReplica?: boolean; collectionCreationDefaults: CollectionCreationDefaults; sampleDataConnectionInfo?: ParsedResourceTokenConnectionString; + readonly selectedRegionalEndpoint?: string; + readonly writeEnabledInSelectedRegion?: boolean; readonly vcoreMongoConnectionParams?: VCoreMongoConnectionParams; readonly feedbackPolicies?: AdminFeedbackPolicySettings; readonly dataPlaneRbacEnabled?: boolean; diff --git a/src/Utils/QueryUtils.test.ts b/src/Utils/QueryUtils.test.ts index ab2b2a8b1..7da3d381c 100644 --- a/src/Utils/QueryUtils.test.ts +++ b/src/Utils/QueryUtils.test.ts @@ -35,6 +35,13 @@ describe("Query Utils", () => { version: 2, }; }; + const generatePartitionKeysForPaths = (paths: string[]): DataModels.PartitionKey => { + return { + paths: paths, + kind: "Hash", + version: 2, + }; + }; describe("buildDocumentsQueryPartitionProjections()", () => { it("should return empty string if partition key is undefined", () => { @@ -89,6 +96,18 @@ describe("Query Utils", () => { expect(query).toContain("c.id"); }); + + it("should always include {} for any missing partition keys", () => { + const query = QueryUtils.buildDocumentsQuery( + "", + ["a", "b", "c"], + generatePartitionKeysForPaths(["/a", "/b", "/c"]), + [], + ); + expect(query).toContain('IIF(IS_DEFINED(c["a"]), c["a"], {})'); + expect(query).toContain('IIF(IS_DEFINED(c["b"]), c["b"], {})'); + expect(query).toContain('IIF(IS_DEFINED(c["c"]), c["c"], {})'); + }); }); describe("queryPagesUntilContentPresent()", () => { @@ -201,18 +220,6 @@ describe("Query Utils", () => { expect(expectedPartitionKeyValues).toContain(documentContent["Category"]); }); - it("should extract no partition key values in the case nested partition key", () => { - const singlePartitionKeyDefinition: PartitionKeyDefinition = { - kind: PartitionKeyKind.Hash, - paths: ["/Location.type"], - }; - const partitionKeyValues: PartitionKey[] = extractPartitionKeyValues( - documentContent, - singlePartitionKeyDefinition, - ); - expect(partitionKeyValues.length).toBe(0); - }); - it("should extract all partition key values for hierarchical and nested partition keys", () => { const mixedPartitionKeyDefinition: PartitionKeyDefinition = { kind: PartitionKeyKind.MultiHash, @@ -225,5 +232,52 @@ describe("Query Utils", () => { expect(partitionKeyValues.length).toBe(2); expect(partitionKeyValues).toEqual(["United States", "Point"]); }); + + it("if any partition key is null or empty string, the partitionKeyValues shall match", () => { + const newDocumentContent = { + ...documentContent, + ...{ + Country: null, + Location: { + type: "", + coordinates: [-121.49, 46.206], + }, + }, + }; + + const mixedPartitionKeyDefinition: PartitionKeyDefinition = { + kind: PartitionKeyKind.MultiHash, + paths: ["/Country", "/Location/type"], + }; + const partitionKeyValues: PartitionKey[] = extractPartitionKeyValues( + newDocumentContent, + mixedPartitionKeyDefinition, + ); + expect(partitionKeyValues.length).toBe(2); + expect(partitionKeyValues).toEqual([null, ""]); + }); + + it("if any partition key doesn't exist, it should still set partitionkey value as {}", () => { + const newDocumentContent = { + ...documentContent, + ...{ + Country: null, + Location: { + coordinates: [-121.49, 46.206], + }, + }, + }; + + const mixedPartitionKeyDefinition: PartitionKeyDefinition = { + kind: PartitionKeyKind.MultiHash, + paths: ["/Country", "/Location/type"], + }; + const partitionKeyValues: PartitionKey[] = extractPartitionKeyValues( + newDocumentContent, + mixedPartitionKeyDefinition, + ); + expect(partitionKeyValues.length).toBe(2); + expect(partitionKeyValues).toEqual([null, {}]); + }); }); }); diff --git a/src/Utils/QueryUtils.ts b/src/Utils/QueryUtils.ts index f0b39e4e2..07822a422 100644 --- a/src/Utils/QueryUtils.ts +++ b/src/Utils/QueryUtils.ts @@ -47,6 +47,7 @@ export function buildDocumentsQueryPartitionProjections( for (const index in partitionKey.paths) { // TODO: Handle "/" in partition key definitions const projectedProperties: string[] = partitionKey.paths[index].split("/").slice(1); + const isSystemPartitionKey: boolean = partitionKey.systemKey || false; let projectedProperty = ""; projectedProperties.forEach((property: string) => { @@ -61,8 +62,13 @@ export function buildDocumentsQueryPartitionProjections( projectedProperty += `[${projection}]`; } }); - - projections.push(`${collectionAlias}${projectedProperty}`); + const fullAccess = `${collectionAlias}${projectedProperty}`; + if (!isSystemPartitionKey) { + const wrappedProjection = `IIF(IS_DEFINED(${fullAccess}), ${fullAccess}, {})`; + projections.push(wrappedProjection); + } else { + projections.push(fullAccess); + } } return projections.join(","); @@ -118,7 +124,7 @@ export const extractPartitionKeyValues = ( documentContent: any, partitionKeyDefinition: PartitionKeyDefinition, ): PartitionKey[] => { - if (!partitionKeyDefinition.paths || partitionKeyDefinition.paths.length === 0) { + if (!partitionKeyDefinition.paths || partitionKeyDefinition.paths.length === 0 || partitionKeyDefinition.systemKey) { return undefined; } @@ -130,6 +136,8 @@ export const extractPartitionKeyValues = ( if (value !== undefined) { partitionKeyValues.push(value); + } else { + partitionKeyValues.push({}); } }); diff --git a/src/hooks/useClientWriteEnabled.ts b/src/hooks/useClientWriteEnabled.ts new file mode 100644 index 000000000..7d9d29c2e --- /dev/null +++ b/src/hooks/useClientWriteEnabled.ts @@ -0,0 +1,10 @@ +import create, { UseStore } from "zustand"; +interface ClientWriteEnabledState { + clientWriteEnabled: boolean; + setClientWriteEnabled: (writeEnabled: boolean) => void; +} + +export const useClientWriteEnabled: UseStore = create((set) => ({ + clientWriteEnabled: true, + setClientWriteEnabled: (clientWriteEnabled: boolean) => set({ clientWriteEnabled }), +})); diff --git a/src/hooks/useKnockoutExplorer.ts b/src/hooks/useKnockoutExplorer.ts index 2e29d1363..bfa8a95b2 100644 --- a/src/hooks/useKnockoutExplorer.ts +++ b/src/hooks/useKnockoutExplorer.ts @@ -17,12 +17,16 @@ import { useSelectedNode } from "Explorer/useSelectedNode"; import { isFabricMirroredKey, scheduleRefreshFabricToken } from "Platform/Fabric/FabricUtil"; import { AppStateComponentNames, + deleteState, + hasState, + loadState, OPEN_TABS_SUBCOMPONENT_NAME, readSubComponentState, } from "Shared/AppStatePersistenceUtility"; import { LocalStorageUtility, StorageKey } from "Shared/StorageUtility"; import { isDataplaneRbacSupported } from "Utils/APITypeUtils"; import { logConsoleError } from "Utils/NotificationConsoleUtils"; +import { useClientWriteEnabled } from "hooks/useClientWriteEnabled"; import { useQueryCopilot } from "hooks/useQueryCopilot"; import { ReactTabKind, useTabs } from "hooks/useTabs"; import { useEffect, useState } from "react"; @@ -211,6 +215,10 @@ async function configureFabric(): Promise { } break; } + case "refreshResourceTree": { + explorer.onRefreshResourcesClick(); + break; + } default: console.error(`Unknown Fabric message type: ${JSON.stringify(data)}`); break; @@ -345,6 +353,9 @@ async function configureHostedWithAAD(config: AAD): Promise { `Configuring Data Explorer for ${userContext.apiType} account ${account.name}`, "Explorer/configureHostedWithAAD", ); + if (userContext.apiType === "SQL") { + checkAndUpdateSelectedRegionalEndpoint(); + } if (!userContext.features.enableAadDataPlane) { Logger.logInfo(`AAD Feature flag is not enabled for account ${account.name}`, "Explorer/configureHostedWithAAD"); if (isDataplaneRbacSupported(userContext.apiType)) { @@ -706,6 +717,10 @@ async function configurePortal(): Promise { const { databaseAccount: account, subscriptionId, resourceGroup } = userContext; + if (userContext.apiType === "SQL") { + checkAndUpdateSelectedRegionalEndpoint(); + } + let dataPlaneRbacEnabled; if (isDataplaneRbacSupported(userContext.apiType)) { if (LocalStorageUtility.hasItem(StorageKey.DataPlaneRbacEnabled)) { @@ -824,6 +839,41 @@ function updateAADEndpoints(portalEnv: PortalEnv) { } } +function checkAndUpdateSelectedRegionalEndpoint() { + const accountName = userContext.databaseAccount?.name; + if (hasState({ componentName: AppStateComponentNames.SelectedRegionalEndpoint, globalAccountName: accountName })) { + const storedRegionalEndpoint = loadState({ + componentName: AppStateComponentNames.SelectedRegionalEndpoint, + globalAccountName: accountName, + }) as string; + const validEndpoint = userContext.databaseAccount?.properties?.readLocations?.find( + (loc) => loc.documentEndpoint === storedRegionalEndpoint, + ); + const validWriteEndpoint = userContext.databaseAccount?.properties?.writeLocations?.find( + (loc) => loc.documentEndpoint === storedRegionalEndpoint, + ); + if (validEndpoint) { + updateUserContext({ + selectedRegionalEndpoint: storedRegionalEndpoint, + writeEnabledInSelectedRegion: !!validWriteEndpoint, + refreshCosmosClient: true, + }); + useClientWriteEnabled.setState({ clientWriteEnabled: !!validWriteEndpoint }); + } else { + deleteState({ componentName: AppStateComponentNames.SelectedRegionalEndpoint, globalAccountName: accountName }); + updateUserContext({ + writeEnabledInSelectedRegion: true, + }); + useClientWriteEnabled.setState({ clientWriteEnabled: true }); + } + } else { + updateUserContext({ + writeEnabledInSelectedRegion: true, + }); + useClientWriteEnabled.setState({ clientWriteEnabled: true }); + } +} + function updateContextsFromPortalMessage(inputs: DataExplorerInputsFrame) { if ( configContext.PORTAL_BACKEND_ENDPOINT && diff --git a/src/hooks/useTabs.ts b/src/hooks/useTabs.ts index 8b7051a52..22f4979c5 100644 --- a/src/hooks/useTabs.ts +++ b/src/hooks/useTabs.ts @@ -115,7 +115,7 @@ export const useTabs: UseStore = create((set, get) => ({ set({ activeTab: undefined, activeReactTab: undefined }); } - if (tab.tabId === activeTab.tabId && tabIndex !== -1) { + if (tab.tabId === activeTab?.tabId && tabIndex !== -1) { const tabToTheRight = updatedTabs[tabIndex]; const lastOpenTab = updatedTabs[updatedTabs.length - 1]; const newActiveTab = tabToTheRight ?? lastOpenTab; diff --git a/test/fx.ts b/test/fx.ts index 661d55897..30c73e397 100644 --- a/test/fx.ts +++ b/test/fx.ts @@ -1,5 +1,5 @@ import { AzureCliCredential } from "@azure/identity"; -import { expect, Frame, Locator, Page } from "@playwright/test"; +import { Frame, Locator, Page, expect } from "@playwright/test"; import crypto from "crypto"; const RETRY_COUNT = 3; @@ -26,7 +26,7 @@ export function getAzureCLICredentials(): AzureCliCredential { export async function getAzureCLICredentialsToken(): Promise { const credentials = getAzureCLICredentials(); - const token = (await credentials.getToken("https://management.core.windows.net//.default")).token; + const token = (await credentials.getToken("https://management.core.windows.net//.default"))?.token || ""; return token; } @@ -35,8 +35,10 @@ export enum TestAccount { Cassandra = "Cassandra", Gremlin = "Gremlin", Mongo = "Mongo", + MongoReadonly = "MongoReadOnly", Mongo32 = "Mongo32", SQL = "SQL", + SQLReadOnly = "SQLReadOnly", } export const defaultAccounts: Record = { @@ -44,8 +46,10 @@ export const defaultAccounts: Record = { [TestAccount.Cassandra]: "github-e2etests-cassandra", [TestAccount.Gremlin]: "github-e2etests-gremlin", [TestAccount.Mongo]: "github-e2etests-mongo", + [TestAccount.MongoReadonly]: "github-e2etests-mongo-readonly", [TestAccount.Mongo32]: "github-e2etests-mongo32", [TestAccount.SQL]: "github-e2etests-sql", + [TestAccount.SQLReadOnly]: "github-e2etests-sql-readonly", }; export const resourceGroupName = process.env.DE_TEST_RESOURCE_GROUP ?? "de-e2e-tests"; @@ -214,6 +218,25 @@ export class QueryTab { } } +export class DocumentsTab { + documentsFilter: Locator; + documentsListPane: Locator; + documentResultsPane: Locator; + resultsEditor: Editor; + + constructor( + public frame: Frame, + public tabId: string, + public tab: Locator, + public locator: Locator, + ) { + this.documentsFilter = this.locator.getByTestId("DocumentsTab/Filter"); + this.documentsListPane = this.locator.getByTestId("DocumentsTab/DocumentsPane"); + this.documentResultsPane = this.locator.getByTestId("DocumentsTab/ResultsPane"); + this.resultsEditor = new Editor(this.frame, this.documentResultsPane.getByTestId("EditorReact/Host/Loaded")); + } +} + type PanelOpenOptions = { closeTimeout?: number; }; @@ -232,6 +255,12 @@ export class DataExplorer { return new QueryTab(this.frame, tabId, tab, queryTab); } + documentsTab(tabId: string): DocumentsTab { + const tab = this.tab(tabId); + const documentsTab = tab.getByTestId("DocumentsTab"); + return new DocumentsTab(this.frame, tabId, tab, documentsTab); + } + /** Select the primary global command button. * * There's only a single "primary" button, but we still require you to pass the label to confirm you're selecting the right button. @@ -245,6 +274,10 @@ export class DataExplorer { return this.frame.getByTestId(`CommandBar/Button:${label}`).and(this.frame.locator("css=button")); } + dialogButton(label: string): Locator { + return this.frame.getByTestId(`DialogButton:${label}`).and(this.frame.locator("css=button")); + } + /** Select the side panel with the specified title */ panel(title: string): Locator { return this.frame.getByTestId(`Panel:${title}`); @@ -294,6 +327,26 @@ export class DataExplorer { return await this.waitForNode(`${databaseId}/${containerId}`); } + async waitForContainerItemsNode(databaseId: string, containerId: string): Promise { + return await this.waitForNode(`${databaseId}/${containerId}/Items`); + } + + async waitForContainerDocumentsNode(databaseId: string, containerId: string): Promise { + return await this.waitForNode(`${databaseId}/${containerId}/Documents`); + } + + async waitForCommandBarButton(label: string, timeout?: number): Promise { + const commandBar = this.commandBarButton(label); + await commandBar.waitFor({ state: "visible", timeout }); + return commandBar; + } + + async waitForDialogButton(label: string, timeout?: number): Promise { + const dialogButton = this.dialogButton(label); + await dialogButton.waitFor({ timeout }); + return dialogButton; + } + /** Select the tree node with the specified id */ treeNode(id: string): TreeNode { return new TreeNode(this.frame.getByTestId(`TreeNode:${id}`), this.frame, id); diff --git a/test/mongo/document.spec.ts b/test/mongo/document.spec.ts new file mode 100644 index 000000000..3030d5259 --- /dev/null +++ b/test/mongo/document.spec.ts @@ -0,0 +1,89 @@ +import { expect, test } from "@playwright/test"; + +import { DataExplorer, DocumentsTab, TestAccount } from "../fx"; +import { retry, serializeMongoToJson, setPartitionKeys } from "../testData"; +import { documentTestCases } from "./testCases"; + +let explorer: DataExplorer = null!; +let documentsTab: DocumentsTab = null!; + +for (const { name, databaseId, containerId, documents } of documentTestCases) { + test.describe(`Test MongoRU Documents with ${name}`, () => { + test.beforeEach("Open documents tab", async ({ page }) => { + explorer = await DataExplorer.open(page, TestAccount.MongoReadonly); + + const containerNode = await explorer.waitForContainerNode(databaseId, containerId); + await containerNode.expand(); + + const containerMenuNode = await explorer.waitForContainerDocumentsNode(databaseId, containerId); + await containerMenuNode.element.click(); + + documentsTab = explorer.documentsTab("tab0"); + + await documentsTab.documentsFilter.waitFor(); + await documentsTab.documentsListPane.waitFor(); + await expect(documentsTab.resultsEditor.locator).toBeAttached({ timeout: 60 * 1000 }); + }); + + for (const document of documents) { + const { documentId: docId, partitionKeys } = document; + test.describe(`Document ID: ${docId}`, () => { + test(`should load and view document ${docId}`, async () => { + const span = documentsTab.documentsListPane.getByText(docId, { exact: true }).nth(0); + await span.waitFor(); + await expect(span).toBeVisible(); + + await span.click(); + await expect(documentsTab.resultsEditor.locator).toBeAttached({ timeout: 60 * 1000 }); + + const resultText = await documentsTab.resultsEditor.text(); + const resultData = serializeMongoToJson(resultText!); + expect(resultText).not.toBeNull(); + expect(resultData?._id).not.toBeNull(); + expect(resultData?._id).toEqual(docId); + }); + test(`should be able to create and delete new document from ${docId}`, async () => { + const span = documentsTab.documentsListPane.getByText(docId, { exact: true }).nth(0); + await span.waitFor(); + await expect(span).toBeVisible(); + + await span.click(); + let newDocumentId; + await retry(async () => { + const newDocumentButton = await explorer.waitForCommandBarButton("New Document", 5000); + await expect(newDocumentButton).toBeVisible(); + await expect(newDocumentButton).toBeEnabled(); + await newDocumentButton.click(); + + await expect(documentsTab.resultsEditor.locator).toBeAttached({ timeout: 60 * 1000 }); + + newDocumentId = `${Date.now().toString()}-delete`; + + const newDocument = { + _id: newDocumentId, + ...setPartitionKeys(partitionKeys || []), + }; + + await documentsTab.resultsEditor.setText(JSON.stringify(newDocument)); + const saveButton = await explorer.waitForCommandBarButton("Save", 5000); + await saveButton.click({ timeout: 5000 }); + }, 3); + + const newSpan = documentsTab.documentsListPane.getByText(newDocumentId, { exact: true }).nth(0); + await newSpan.waitFor(); + await newSpan.click(); + await expect(documentsTab.resultsEditor.locator).toBeAttached({ timeout: 60 * 1000 }); + + const deleteButton = await explorer.waitForCommandBarButton("Delete", 5000); + await deleteButton.click(); + + const deleteDialogButton = await explorer.waitForDialogButton("Delete", 5000); + await deleteDialogButton.click(); + + const deletedSpan = documentsTab.documentsListPane.getByText(newDocumentId, { exact: true }).nth(0); + await expect(deletedSpan).toHaveCount(0); + }); + }); + } + }); +} diff --git a/test/mongo/testCases.ts b/test/mongo/testCases.ts new file mode 100644 index 000000000..c3f862610 --- /dev/null +++ b/test/mongo/testCases.ts @@ -0,0 +1,31 @@ +import { DocumentTestCase } from "../testData"; + +export const documentTestCases: DocumentTestCase[] = [ + { + name: "Unsharded Collection", + databaseId: "e2etests-mongo-readonly", + containerId: "unsharded", + documents: [ + { + documentId: "unsharded", + partitionKeys: [], + }, + ], + }, + { + name: "Sharded Collection", + databaseId: "e2etests-mongo-readonly", + containerId: "sharded", + documents: [ + { + documentId: "sharded", + partitionKeys: [ + { + key: "/shardKey", + value: "shardKey", + }, + ], + }, + ], + }, +]; diff --git a/test/sql/document.spec.ts b/test/sql/document.spec.ts new file mode 100644 index 000000000..74e3f5da1 --- /dev/null +++ b/test/sql/document.spec.ts @@ -0,0 +1,93 @@ +import { expect, test } from "@playwright/test"; + +import { DataExplorer, DocumentsTab, TestAccount } from "../fx"; +import { retry, setPartitionKeys } from "../testData"; +import { documentTestCases } from "./testCases"; + +let explorer: DataExplorer = null!; +let documentsTab: DocumentsTab = null!; + +for (const { name, databaseId, containerId, documents } of documentTestCases) { + test.describe(`Test SQL Documents with ${name}`, () => { + test.beforeEach("Open documents tab", async ({ page }) => { + explorer = await DataExplorer.open(page, TestAccount.SQLReadOnly); + + const containerNode = await explorer.waitForContainerNode(databaseId, containerId); + await containerNode.expand(); + + const containerMenuNode = await explorer.waitForContainerItemsNode(databaseId, containerId); + await containerMenuNode.element.click(); + + documentsTab = explorer.documentsTab("tab0"); + + await documentsTab.documentsFilter.waitFor(); + await documentsTab.documentsListPane.waitFor(); + await expect(documentsTab.resultsEditor.locator).toBeAttached({ timeout: 60 * 1000 }); + }); + + for (const document of documents) { + const { documentId: docId, partitionKeys } = document; + test.describe(`Document ID: ${docId}`, () => { + test(`should load and view document ${docId}`, async () => { + const span = documentsTab.documentsListPane.getByText(docId, { exact: true }).nth(0); + await span.waitFor(); + await expect(span).toBeVisible(); + + await span.click(); + await expect(documentsTab.resultsEditor.locator).toBeAttached({ timeout: 60 * 1000 }); + + const resultText = await documentsTab.resultsEditor.text(); + const resultData = JSON.parse(resultText!); + expect(resultText).not.toBeNull(); + expect(resultData?.id).toEqual(docId); + }); + test(`should be able to create and delete new document from ${docId}`, async ({ page }) => { + const span = documentsTab.documentsListPane.getByText(docId, { exact: true }).nth(0); + await span.waitFor(); + await expect(span).toBeVisible(); + + await span.click(); + let newDocumentId; + await page.waitForTimeout(5000); + await retry(async () => { + // const discardButton = await explorer.waitForCommandBarButton("Discard", 5000); + // if (await discardButton.isEnabled()) { + // await discardButton.click(); + // } + const newDocumentButton = await explorer.waitForCommandBarButton("New Item", 5000); + await expect(newDocumentButton).toBeVisible(); + await expect(newDocumentButton).toBeEnabled(); + await newDocumentButton.click(); + + await expect(documentsTab.resultsEditor.locator).toBeAttached({ timeout: 60 * 1000 }); + + newDocumentId = `${Date.now().toString()}-delete`; + + const newDocument = { + id: newDocumentId, + ...setPartitionKeys(partitionKeys || []), + }; + + await documentsTab.resultsEditor.setText(JSON.stringify(newDocument)); + const saveButton = await explorer.waitForCommandBarButton("Save", 5000); + await saveButton.click({ timeout: 5000 }); + }, 3); + + const newSpan = documentsTab.documentsListPane.getByText(newDocumentId, { exact: true }).nth(0); + await newSpan.waitFor(); + await newSpan.click(); + await expect(documentsTab.resultsEditor.locator).toBeAttached({ timeout: 60 * 1000 }); + + const deleteButton = await explorer.waitForCommandBarButton("Delete", 5000); + await deleteButton.click(); + + const deleteDialogButton = await explorer.waitForDialogButton("Delete", 5000); + await deleteDialogButton.click(); + + const deletedSpan = documentsTab.documentsListPane.getByText(newDocumentId, { exact: true }).nth(0); + await expect(deletedSpan).toHaveCount(0); + }); + }); + } + }); +} diff --git a/test/sql/testCases.ts b/test/sql/testCases.ts new file mode 100644 index 000000000..8c4c3178f --- /dev/null +++ b/test/sql/testCases.ts @@ -0,0 +1,235 @@ +import { DocumentTestCase } from "../testData"; + +export const documentTestCases: DocumentTestCase[] = [ + { + name: "System Partition Key", + databaseId: "e2etests-sql-readonly", + containerId: "systemPartitionKey", + documents: [{ documentId: "systempartition", partitionKeys: [] }], + }, + { + name: "Single Partition Key", + databaseId: "e2etests-sql-readonly", + containerId: "singlePartitionKey", + documents: [ + { + documentId: "singlePartitionKey", + partitionKeys: [{ key: "/singlePartitionKey", value: "singlePartitionKey" }], + }, + { + documentId: "singlePartitionKey_empty_string", + partitionKeys: [{ key: "/singlePartitionKey", value: "" }], + }, + { + documentId: "singlePartitionKey_null", + partitionKeys: [{ key: "/singlePartitionKey", value: null }], + }, + { + documentId: "singlePartitionKey_missing", + partitionKeys: [], + }, + ], + }, + { + name: "Single Nested Partition Key", + databaseId: "e2etests-sql-readonly", + containerId: "singleNestedPartitionKey", + documents: [ + { + documentId: "singlePartitionKey_nested", + partitionKeys: [{ key: "/singlePartitionKey/nested", value: "nestedValue" }], + }, + { + documentId: "singlePartitionKey_nested_empty_string", + partitionKeys: [{ key: "/singlePartitionKey/nested", value: "" }], + }, + { + documentId: "singlePartitionKey_nested_null", + partitionKeys: [{ key: "/singlePartitionKey/nested", value: null }], + }, + { + documentId: "singlePartitionKey_nested_missing", + partitionKeys: [], + }, + ], + }, + { + name: "2-Level Hierarchical Partition Key", + databaseId: "e2etests-sql-readonly", + containerId: "twoLevelPartitionKey", + documents: [ + { + documentId: "twoLevelPartitionKey_value_empty", + partitionKeys: [ + { key: "/twoLevelPartitionKey_1", value: "value" }, + { key: "/twoLevelPartitionKey_2", value: "" }, + ], + }, + { + documentId: "twoLevelPartitionKey_value_null", + partitionKeys: [ + { key: "/twoLevelPartitionKey_1", value: "value" }, + { key: "/twoLevelPartitionKey_2", value: null }, + ], + }, + { + documentId: "twoLevelPartitionKey_value_missing", + partitionKeys: [{ key: "/twoLevelPartitionKey_1", value: "value" }], + }, + { + documentId: "twoLevelPartitionKey_empty_null", + partitionKeys: [ + { key: "/twoLevelPartitionKey_1", value: "" }, + { key: "/twoLevelPartitionKey_2", value: null }, + ], + }, + { + documentId: "twoLevelPartitionKey_null_missing", + partitionKeys: [{ key: "/twoLevelPartitionKey_1", value: null }], + }, + { + documentId: "twoLevelPartitionKey_missing_value", + partitionKeys: [{ key: "/twoLevelPartitionKey_2", value: "value" }], + }, + ], + }, + { + name: "2-Level Hierarchical Nested Partition Key", + databaseId: "e2etests-sql-readonly", + containerId: "twoLevelNestedPartitionKey", + documents: [ + { + documentId: "twoLevelNestedPartitionKey_nested_value_empty", + partitionKeys: [ + { key: "/twoLevelNestedPartitionKey/nested", value: "value" }, + { key: "/twoLevelNestedPartitionKey/nested_value/nested", value: "" }, + ], + }, + { + documentId: "twoLevelNestedPartitionKey_nested_null_missing", + partitionKeys: [{ key: "/twoLevelNestedPartitionKey/nested", value: null }], + }, + { + documentId: "twoLevelNestedPartitionKey_nested_missing_value", + partitionKeys: [{ key: "/twoLevelNestedPartitionKey/nested_value/nested", value: "value" }], + }, + ], + }, + { + name: "3-Level Hierarchical Partition Key", + databaseId: "e2etests-sql-readonly", + containerId: "threeLevelPartitionKey", + documents: [ + { + documentId: "threeLevelPartitionKey_value_empty_null", + partitionKeys: [ + { key: "/threeLevelPartitionKey_1", value: "value" }, + { key: "/threeLevelPartitionKey_2", value: "" }, + { key: "/threeLevelPartitionKey_3", value: null }, + ], + }, + { + documentId: "threeLevelPartitionKey_value_null_missing", + partitionKeys: [ + { key: "/threeLevelPartitionKey_1", value: "value" }, + { key: "/threeLevelPartitionKey_2", value: null }, + ], + }, + { + documentId: "threeLevelPartitionKey_value_missing_null", + partitionKeys: [ + { key: "/threeLevelPartitionKey_1", value: "value" }, + { key: "/threeLevelPartitionKey_3", value: null }, + ], + }, + { + documentId: "threeLevelPartitionKey_null_empty_value", + partitionKeys: [ + { key: "/threeLevelPartitionKey_1", value: null }, + { key: "/threeLevelPartitionKey_2", value: "" }, + { key: "/threeLevelPartitionKey_3", value: "value" }, + ], + }, + { + documentId: "threeLevelPartitionKey_missing_value_value", + partitionKeys: [ + { key: "/threeLevelPartitionKey_2", value: "value" }, + { key: "/threeLevelPartitionKey_3", value: "value" }, + ], + }, + { + documentId: "threeLevelPartitionKey_empty_value_missing", + partitionKeys: [ + { key: "/threeLevelPartitionKey_1", value: "" }, + { key: "/threeLevelPartitionKey_2", value: "value" }, + ], + }, + ], + }, + { + name: "3-Level Nested Hierarchical Partition Key", + databaseId: "e2etests-sql-readonly", + containerId: "threeLevelNestedPartitionKey", + documents: [ + { + documentId: "threeLevelNestedPartitionKey_nested_empty_value_null", + partitionKeys: [ + { key: "/threeLevelPartitionKey_1/nested/nested_key", value: "" }, + { key: "/threeLevelPartitionKey_1/nested/nested_2/nested_key", value: "value" }, + { key: "/threeLevelPartitionKey_1/nested/nested_2/nested_3/nested_key", value: null }, + ], + }, + { + documentId: "threeLevelNestedPartitionKey_nested_null_value_missing", + partitionKeys: [ + { key: "/threeLevelPartitionKey_1/nested/nested_key", value: null }, + { key: "/threeLevelPartitionKey_1/nested/nested_2/nested_key", value: "value" }, + ], + }, + { + documentId: "threeLevelNestedPartitionKey_nested_missing_value_null", + partitionKeys: [ + { key: "/threeLevelPartitionKey_1/nested/nested_2/nested_key", value: "value" }, + { key: "/threeLevelPartitionKey_1/nested/nested_2/nested_3/nested_key", value: null }, + ], + }, + { + documentId: "threeLevelNestedPartitionKey_nested_null_empty_missing", + partitionKeys: [ + { key: "/threeLevelPartitionKey_1/nested/nested_key", value: null }, + { key: "/threeLevelPartitionKey_1/nested/nested_2/nested_key", value: "" }, + ], + }, + { + documentId: "threeLevelNestedPartitionKey_nested_value_missing_empty", + partitionKeys: [ + { key: "/threeLevelPartitionKey_1/nested/nested_key", value: "value" }, + { key: "/threeLevelPartitionKey_1/nested/nested_2/nested_3/nested_key", value: "" }, + ], + }, + { + documentId: "threeLevelNestedPartitionKey_nested_missing_null_empty", + partitionKeys: [ + { key: "/threeLevelPartitionKey_1/nested/nested_2/nested_key", value: null }, + { key: "/threeLevelPartitionKey_1/nested/nested_2/nested_3/nested_key", value: "" }, + ], + }, + { + documentId: "threeLevelNestedPartitionKey_nested_empty_null_value", + partitionKeys: [ + { key: "/threeLevelPartitionKey_1/nested/nested_key", value: "" }, + { key: "/threeLevelPartitionKey_1/nested/nested_2/nested_key", value: null }, + { key: "/threeLevelPartitionKey_1/nested/nested_2/nested_3/nested_key", value: "value" }, + ], + }, + { + documentId: "threeLevelNestedPartitionKey_nested_value_null_empty", + partitionKeys: [ + { key: "/threeLevelPartitionKey_1/nested/nested_key", value: "value" }, + { key: "/threeLevelPartitionKey_1/nested/nested_2/nested_key", value: null }, + { key: "/threeLevelPartitionKey_1/nested/nested_2/nested_3/nested_key", value: "" }, + ], + }, + ], + }, +]; diff --git a/test/testData.ts b/test/testData.ts index 543796894..3af3c903c 100644 --- a/test/testData.ts +++ b/test/testData.ts @@ -16,6 +16,23 @@ export interface TestItem { randomData: string; } +export interface DocumentTestCase { + name: string; + databaseId: string; + containerId: string; + documents: TestDocument[]; +} + +export interface TestDocument { + documentId: string; + partitionKeys?: PartitionKey[]; +} + +export interface PartitionKey { + key: string; + value: string | null; +} + const partitionCount = 4; // If we increase this number, we need to split bulk creates into multiple batches. @@ -93,3 +110,46 @@ export async function createTestSQLContainer(includeTestData?: boolean) { throw e; } } + +export const setPartitionKeys = (partitionKeys: PartitionKey[]) => { + const result = {}; + + partitionKeys.forEach((partitionKey) => { + const { key: keyPath, value: keyValue } = partitionKey; + const cleanPath = keyPath.startsWith("/") ? keyPath.slice(1) : keyPath; + const keys = cleanPath.split("/"); + let current = result; + + keys.forEach((key, index) => { + if (index === keys.length - 1) { + current[key] = keyValue; + } else { + current[key] = current[key] || {}; + current = current[key]; + } + }); + }); + + return result; +}; + +export const serializeMongoToJson = (text: string) => { + const normalized = text.replace(/ObjectId\("([0-9a-fA-F]{24})"\)/g, '"$1"'); + return JSON.parse(normalized); +}; + +export async function retry(fn: () => Promise, retries = 3, delayMs = 1000): Promise { + let lastError: unknown; + for (let i = 0; i < retries; i++) { + try { + return await fn(); + } catch (error) { + lastError = error; + console.warn(`Retry ${i + 1}/${retries} failed: ${(error as Error).message}`); + if (i < retries - 1) { + await new Promise((res) => setTimeout(res, delayMs)); + } + } + } + throw lastError; +} diff --git a/utils/cleanupDBs.js b/utils/cleanupDBs.js index 3c89a8c5c..723ec1c73 100644 --- a/utils/cleanupDBs.js +++ b/utils/cleanupDBs.js @@ -20,6 +20,10 @@ async function main() { const client = new CosmosDBManagementClient(credentials, subscriptionId); const accounts = await client.databaseAccounts.list(resourceGroupName); for (const account of accounts) { + if (account.name.endsWith("-readonly")) { + console.log(`SKIPPED: ${account.name}`); + continue; + } if (account.kind === "MongoDB") { const mongoDatabases = await client.mongoDBResources.listMongoDBDatabases(resourceGroupName, account.name); for (const database of mongoDatabases) {