From 0f6c979268ecbdcea243891c1eab0df1c70ca4be Mon Sep 17 00:00:00 2001 From: sunghyunkang1111 <114709653+sunghyunkang1111@users.noreply.github.com> Date: Thu, 3 Apr 2025 11:10:40 -0500 Subject: [PATCH 01/14] Update cleanupDBs.js (#2093) --- utils/cleanupDBs.js | 4 ++++ 1 file changed, 4 insertions(+) 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) { From e3c3a8b1b792a8ba69d480540e318ad4666c3818 Mon Sep 17 00:00:00 2001 From: bogercraig <124094535+bogercraig@users.noreply.github.com> Date: Thu, 3 Apr 2025 17:19:28 -0700 Subject: [PATCH 02/14] Hide Keys and Connection Strings in Connect Tab (#2095) * Hide connection strings and keys by default. Move URI above pivot since common across tabs. Matches frontend. Need to add scrolling of keys when window is small. Possibly reduce URI width. * Add vertical scrolling when window size reduces. * Adding missing semicolon at end of connection strings. --- src/Explorer/Tabs/ConnectTab.tsx | 143 ++++++++++++++++++++++--------- 1 file changed, 103 insertions(+), 40 deletions(-) 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)} /> From af0a890516b6302b60710b4f54e58b68d88b1857 Mon Sep 17 00:00:00 2001 From: sunghyunkang1111 <114709653+sunghyunkang1111@users.noreply.github.com> Date: Mon, 7 Apr 2025 10:45:29 -0500 Subject: [PATCH 03/14] Pk missing fix (#2094) * fix partition key missing not being able to load the document * Implement E2E tests for documents with different partitionkeys * Implement E2E tests for documents with different partitionkeys * Implement E2E tests for documents with different partitionkeys * Updated snapshot --- playwright.config.ts | 1 - .../Tabs/DocumentsTabV2/DocumentsTabV2.tsx | 17 +++- .../DocumentsTabV2.test.tsx.snap | 4 + src/Utils/QueryUtils.test.ts | 78 +++++++++++++--- src/Utils/QueryUtils.ts | 14 ++- test/fx.ts | 33 ++++++- test/sql/document.spec.ts | 45 ++++++++++ test/sql/testCases.ts | 88 +++++++++++++++++++ 8 files changed, 259 insertions(+), 21 deletions(-) create mode 100644 test/sql/document.spec.ts create mode 100644 test/sql/testCases.ts diff --git a/playwright.config.ts b/playwright.config.ts index 4c5ad3c14..5279a0660 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. */ diff --git a/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2.tsx b/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2.tsx index c24ac08e8..5ef058f3f 100644 --- a/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2.tsx +++ b/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2.tsx @@ -1037,6 +1037,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); @@ -2083,8 +2088,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%" >
{ 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/test/fx.ts b/test/fx.ts index 661d55897..14ad0acbb 100644 --- a/test/fx.ts +++ b/test/fx.ts @@ -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; } @@ -37,6 +37,7 @@ export enum TestAccount { Mongo = "Mongo", Mongo32 = "Mongo32", SQL = "SQL", + SQLReadOnly = "SQLReadOnly", } export const defaultAccounts: Record = { @@ -46,6 +47,7 @@ export const defaultAccounts: Record = { [TestAccount.Mongo]: "github-e2etests-mongo", [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 +216,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 +253,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. @@ -294,6 +321,10 @@ export class DataExplorer { return await this.waitForNode(`${databaseId}/${containerId}`); } + async waitForContainerItemsNode(databaseId: string, containerId: string): Promise { + return await this.waitForNode(`${databaseId}/${containerId}/Items`); + } + /** 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/sql/document.spec.ts b/test/sql/document.spec.ts new file mode 100644 index 000000000..10877702a --- /dev/null +++ b/test/sql/document.spec.ts @@ -0,0 +1,45 @@ +import { expect, test } from "@playwright/test"; + +import { DataExplorer, DocumentsTab, TestAccount } from "../fx"; +import { documentTestCases } from "./testCases"; + +let explorer: DataExplorer = null!; +let documentsTab: DocumentsTab = null!; + +for (const { name, databaseId, containerId, expectedDocumentIds } of documentTestCases) { + test.describe(`Test 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 docId of expectedDocumentIds) { + 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); + }); + }); + } + }); +} diff --git a/test/sql/testCases.ts b/test/sql/testCases.ts new file mode 100644 index 000000000..0f18bb183 --- /dev/null +++ b/test/sql/testCases.ts @@ -0,0 +1,88 @@ +type ContainerTestCase = { + name: string; + databaseId: string; + containerId: string; + expectedDocumentIds: string[]; +}; + +export const documentTestCases: ContainerTestCase[] = [ + { + name: "System Partition Key", + databaseId: "e2etests-sql-readonly", + containerId: "systemPartitionKey", + expectedDocumentIds: ["systempartition"], + }, + { + name: "Single Partition Key", + databaseId: "e2etests-sql-readonly", + containerId: "singlePartitionKey", + expectedDocumentIds: [ + "singlePartitionKey", + "singlePartitionKey_empty_string", + "singlePartitionKey_null", + "singlePartitionKey_missing", + ], + }, + { + name: "Single Nested Partition Key", + databaseId: "e2etests-sql-readonly", + containerId: "singleNestedPartitionKey", + expectedDocumentIds: [ + "singlePartitionKey_nested", + "singlePartitionKey_nested_empty_string", + "singlePartitionKey_nested_null", + "singlePartitionKey_nested_missing", + ], + }, + { + name: "2-Level Hierarchical Partition Key", + databaseId: "e2etests-sql-readonly", + containerId: "twoLevelPartitionKey", + expectedDocumentIds: [ + "twoLevelPartitionKey_value_empty", + "twoLevelPartitionKey_value_null", + "twoLevelPartitionKey_value_missing", + "twoLevelPartitionKey_empty_null", + "twoLevelPartitionKey_null_missing", + "twoLevelPartitionKey_missing_value", + ], + }, + { + name: "2-Level Hierarchical Nested Partition Key", + databaseId: "e2etests-sql-readonly", + containerId: "twoLevelNestedPartitionKey", + expectedDocumentIds: [ + "twoLevelNestedPartitionKey_nested_value_empty", + "twoLevelNestedPartitionKey_nested_null_missing", + "twoLevelNestedPartitionKey_nested_missing_value", + ], + }, + { + name: "3-Level Hierarchical Partition Key", + databaseId: "e2etests-sql-readonly", + containerId: "threeLevelPartitionKey", + expectedDocumentIds: [ + "threeLevelPartitionKey_value_empty_null", + "threeLevelPartitionKey_value_null_missing", + "threeLevelPartitionKey_value_missing_null", + "threeLevelPartitionKey_null_empty_value", + "threeLevelPartitionKey_missing_value_value", + "threeLevelPartitionKey_empty_value_missing", + ], + }, + { + name: "3-Level Nested Hierarchical Partition Key", + databaseId: "e2etests-sql-readonly", + containerId: "threeLevelNestedPartitionKey", + expectedDocumentIds: [ + "threeLevelNestedPartitionKey_nested_empty_value_null", + "threeLevelNestedPartitionKey_nested_null_value_missing", + "threeLevelNestedPartitionKey_nested_missing_value_null", + "threeLevelNestedPartitionKey_nested_null_empty_missing", + "threeLevelNestedPartitionKey_nested_value_missing_empty", + "threeLevelNestedPartitionKey_nested_missing_null_empty", + "threeLevelNestedPartitionKey_nested_empty_null_value", + "threeLevelNestedPartitionKey_nested_value_null_empty", + ], + }, +]; From 9bb1d0baceeb134725b08b695a4cc10d2113d541 Mon Sep 17 00:00:00 2001 From: bogercraig <124094535+bogercraig@users.noreply.github.com> Date: Mon, 7 Apr 2025 09:29:11 -0700 Subject: [PATCH 04/14] Manual Region Selection (#2037) * Add standin region selection to settings menu. * Retrieve read and write regions from user context and populate dropdown menu. Update local storage value. Need to now connect with updating read region of primary cosmos client. * Change to only selecting region for cosmos client. Not setting up separate read and write clients. * Add read and write endpoint logging to cosmos client. * Pass changing endpoint from settings menu to client. Encountered token issues using new endpoint in client. * Rough implementation of region selection of endpoint for cosmos client. Still need to: 1 - Use separate context var to track selected region. Directly updating database account context throws off token generation by acquireMSALTokenForAccount 2 - Remove href overrides in acquireMSALTokenForAccount. * Update region selection to include global endpoint and generate a unique list of read and write endpoints. Need to continue with clearing out selected endpoint when global is selected again. Write operations stall when read region is selected even though 403 returned when region rejects operation. Need to limit feature availablility to nosql, table, gremlin (maybe). * Update cosmos client to fix bug. Clients continuously generate after changing RBAC setting. * Swapping back to default endpoint value. * Rebase on client refresh bug fix. * Enable region selection for NoSql, Table, Gremlin * Add logic to reset regional endpoint when global is selected. * Fix state changing when selecting region or resetting to global. * Rough implementation of configuring regional endpoint when DE is loaded in portal or hosted with AAD/Entra auth. * Ininitial attempt at adding error handling, but still having issues with errors caught at proxy plugin. * Added rough error handling in local requestPlugin used in local environments. Passes new error to calling code. Might need to add specific error handling for request plugin to the handleError class. * Change how request plugin returns error so existing error handling utility can process and present error. * Only enable region selection for nosql accounts. * Limit region selection to portal and hosted AAD auth. SQL accounts only. Could possibly enable on table and gremlin later. * Update error handling to account for generic error code. * Refactor error code extraction. * Update test snapshots and remove unneeded logging. * Change error handling to use only the message rather than casting to any. * Clean up debug logging in cosmos client. * Remove unused storage keys. * Use endpoint instead of region name to track selected region. Prevents having to do endpoint lookups. * Add initial button state update depending on region selection. Need to update with the API and react to user context changes. * Disable CRUD buttons when read region selected. * Default to write enabled in react. * Disable query saving when read region is selected. * Patch clientWidth error on conflicts tab. * Resolve merge conflicts from rebase. * Make sure proxy endpoints return in all cases. * Remove excess client logging and match main for ConflictsTab. * Cleaning up logging and fixing endpoint discovery bug. * Fix formatting. * Reformatting if statements with preferred formatting. * Migrate region selection to local persistence. Fixes account swapping bug. TODO: Inspect better way to reset interface elements when deleteAllStates is called. Need to react to regional endpoint being reset. * Relocate resetting interface context to helper function. * Remove legacy state storage for regional endpoint selection. * Laurent suggestion updates. --- src/Common/CosmosClient.ts | 7 +- .../Panes/SettingsPane/SettingsPane.tsx | 168 ++++++++++++++++-- .../__snapshots__/SettingsPane.test.tsx.snap | 20 +-- .../Tabs/DocumentsTabV2/DocumentsTabV2.tsx | 13 +- .../Tabs/QueryTab/QueryTabComponent.tsx | 14 +- src/Shared/AppStatePersistenceUtility.ts | 1 + src/UserContext.ts | 2 + src/hooks/useClientWriteEnabled.ts | 10 ++ src/hooks/useKnockoutExplorer.ts | 46 +++++ 9 files changed, 256 insertions(+), 25 deletions(-) create mode 100644 src/hooks/useClientWriteEnabled.ts 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/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`] = `
    ; 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, @@ -865,6 +873,7 @@ export const DocumentsTabComponent: React.FunctionComponent 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/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/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/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..71818f572 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"; @@ -345,6 +349,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 +713,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 +835,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 && From 0666e11d893b54e68cafd5396f3ad380bb784c90 Mon Sep 17 00:00:00 2001 From: Ajay Parulekar <79739098+ajparule@users.noreply.github.com> Date: Wed, 9 Apr 2025 23:02:35 +0530 Subject: [PATCH 05/14] Renaming Materialized views builder blade text to Global secondary indexes for NoSql API (#1991) * GSI changes * GSI changes * GSI changes * updating GlobalSecondaryIndexesBuilder.json * Changes * Update cost text keys based on user context API type * Refactor Materialized Views Builder code for improved readability and consistency in API type checks * Update links in Materialized Views Builder for consistency and accuracy * Update Global Secondary Indexes links and descriptions for clarity and accuracy based on API type * Update portal notification message keys based on user context API type for Materialized Views Builder * Update src/Localization/en/MaterializedViewsBuilder.json Co-authored-by: Justine Cocchi * Update src/Localization/en/MaterializedViewsBuilder.json Co-authored-by: Justine Cocchi * Update src/Localization/en/MaterializedViewsBuilder.json Co-authored-by: Justine Cocchi * Update src/Localization/en/MaterializedViewsBuilder.json Co-authored-by: Justine Cocchi * Update src/Localization/en/MaterializedViewsBuilder.json Co-authored-by: Justine Cocchi * Update src/Localization/en/MaterializedViewsBuilder.json Co-authored-by: Justine Cocchi * Update src/Localization/en/MaterializedViewsBuilder.json Co-authored-by: Justine Cocchi * Update src/Localization/en/MaterializedViewsBuilder.json Co-authored-by: Justine Cocchi * Update src/Localization/en/MaterializedViewsBuilder.json Co-authored-by: Justine Cocchi * Update src/Localization/en/MaterializedViewsBuilder.json Co-authored-by: Justine Cocchi * Update src/Localization/en/MaterializedViewsBuilder.json Co-authored-by: Justine Cocchi * Update src/Localization/en/MaterializedViewsBuilder.json Co-authored-by: Justine Cocchi * Update src/Localization/en/MaterializedViewsBuilder.json Co-authored-by: Justine Cocchi * Update src/Localization/en/MaterializedViewsBuilder.json Co-authored-by: Justine Cocchi * Update src/Localization/en/MaterializedViewsBuilder.json Co-authored-by: Justine Cocchi * Update src/Localization/en/MaterializedViewsBuilder.json Co-authored-by: Justine Cocchi * Update src/Localization/en/MaterializedViewsBuilder.json Co-authored-by: Justine Cocchi * Update src/Localization/en/MaterializedViewsBuilder.json Co-authored-by: Justine Cocchi * Update src/Localization/en/MaterializedViewsBuilder.json Co-authored-by: Justine Cocchi * Update src/Localization/en/MaterializedViewsBuilder.json Co-authored-by: Justine Cocchi * Update src/Localization/en/MaterializedViewsBuilder.json Co-authored-by: Justine Cocchi * Fix capitalization and wording inconsistencies in Materialized Views Builder localization strings * Fix capitalization and wording inconsistencies in localization strings for Materialized Views Builder * Update src/Localization/en/MaterializedViewsBuilder.json Co-authored-by: Justine Cocchi * Update src/Localization/en/MaterializedViewsBuilder.json Co-authored-by: Justine Cocchi * Update src/Localization/en/MaterializedViewsBuilder.json Co-authored-by: Justine Cocchi * Update src/Localization/en/MaterializedViewsBuilder.json Co-authored-by: Justine Cocchi * Update src/Localization/en/MaterializedViewsBuilder.json Co-authored-by: Justine Cocchi * Update src/Localization/en/MaterializedViewsBuilder.json Co-authored-by: Justine Cocchi * Update src/Localization/en/MaterializedViewsBuilder.json Co-authored-by: Justine Cocchi * Update src/Localization/en/MaterializedViewsBuilder.json Co-authored-by: Justine Cocchi * Update src/Localization/en/MaterializedViewsBuilder.json Co-authored-by: Justine Cocchi * Update src/Localization/en/MaterializedViewsBuilder.json Co-authored-by: Justine Cocchi * Update src/Localization/en/MaterializedViewsBuilder.json Co-authored-by: Justine Cocchi * Update src/Localization/en/MaterializedViewsBuilder.json Co-authored-by: Justine Cocchi * Update src/Localization/en/MaterializedViewsBuilder.json Co-authored-by: Justine Cocchi * Update src/Localization/en/MaterializedViewsBuilder.json Co-authored-by: Justine Cocchi * Update src/Localization/en/MaterializedViewsBuilder.json Co-authored-by: Justine Cocchi * Update src/Localization/en/MaterializedViewsBuilder.json Co-authored-by: Justine Cocchi * Update src/Localization/en/MaterializedViewsBuilder.json Co-authored-by: Justine Cocchi * Update src/Localization/en/MaterializedViewsBuilder.json Co-authored-by: Justine Cocchi --------- Co-authored-by: Justine Cocchi --- .../en/MaterializedViewsBuilder.json | 69 ++++++++++----- .../MaterializedViewsBuilder.rp.ts | 20 ++++- .../MaterializedViewsBuilder.tsx | 84 +++++++++++++------ 3 files changed, 121 insertions(+), 52 deletions(-) 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", }) From a3bfc89318f6c819cec4eb3d2b38aa8a179e285c Mon Sep 17 00:00:00 2001 From: sunghyunkang1111 <114709653+sunghyunkang1111@users.noreply.github.com> Date: Wed, 9 Apr 2025 13:04:44 -0500 Subject: [PATCH 06/14] Revert "Pk missing fix (#2094)" (#2099) This reverts commit af0a890516b6302b60710b4f54e58b68d88b1857. --- playwright.config.ts | 1 + .../Tabs/DocumentsTabV2/DocumentsTabV2.tsx | 17 +--- .../DocumentsTabV2.test.tsx.snap | 4 - src/Utils/QueryUtils.test.ts | 78 +++------------- src/Utils/QueryUtils.ts | 14 +-- test/fx.ts | 33 +------ test/sql/document.spec.ts | 45 ---------- test/sql/testCases.ts | 88 ------------------- 8 files changed, 21 insertions(+), 259 deletions(-) delete mode 100644 test/sql/document.spec.ts delete mode 100644 test/sql/testCases.ts diff --git a/playwright.config.ts b/playwright.config.ts index 5279a0660..4c5ad3c14 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,4 +1,5 @@ import { defineConfig, devices } from "@playwright/test"; + /** * See https://playwright.dev/docs/test-configuration. */ diff --git a/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2.tsx b/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2.tsx index 65424d881..cef323b0e 100644 --- a/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2.tsx +++ b/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2.tsx @@ -1046,7 +1046,6 @@ 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); @@ -2099,8 +2094,8 @@ export const DocumentsTabComponent: React.FunctionComponent -
    -
    +
    +
    {!isPreferredApiMongoDB && SELECT * FROM c } -
    +
    -
    +
    {isTabActive && selectedDocumentContent && selectedRows.size <= 1 && (
    SELECT * FROM c @@ -67,7 +65,6 @@ exports[`Documents tab (noSql API) when rendered should render the page 1`] = ` preferredSize="35%" >
    { 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", () => { @@ -96,18 +89,6 @@ 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()", () => { @@ -220,6 +201,18 @@ 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, @@ -232,52 +225,5 @@ 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 07822a422..f0b39e4e2 100644 --- a/src/Utils/QueryUtils.ts +++ b/src/Utils/QueryUtils.ts @@ -47,7 +47,6 @@ 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) => { @@ -62,13 +61,8 @@ export function buildDocumentsQueryPartitionProjections( projectedProperty += `[${projection}]`; } }); - const fullAccess = `${collectionAlias}${projectedProperty}`; - if (!isSystemPartitionKey) { - const wrappedProjection = `IIF(IS_DEFINED(${fullAccess}), ${fullAccess}, {})`; - projections.push(wrappedProjection); - } else { - projections.push(fullAccess); - } + + projections.push(`${collectionAlias}${projectedProperty}`); } return projections.join(","); @@ -124,7 +118,7 @@ export const extractPartitionKeyValues = ( documentContent: any, partitionKeyDefinition: PartitionKeyDefinition, ): PartitionKey[] => { - if (!partitionKeyDefinition.paths || partitionKeyDefinition.paths.length === 0 || partitionKeyDefinition.systemKey) { + if (!partitionKeyDefinition.paths || partitionKeyDefinition.paths.length === 0) { return undefined; } @@ -136,8 +130,6 @@ export const extractPartitionKeyValues = ( if (value !== undefined) { partitionKeyValues.push(value); - } else { - partitionKeyValues.push({}); } }); diff --git a/test/fx.ts b/test/fx.ts index 14ad0acbb..661d55897 100644 --- a/test/fx.ts +++ b/test/fx.ts @@ -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; } @@ -37,7 +37,6 @@ export enum TestAccount { Mongo = "Mongo", Mongo32 = "Mongo32", SQL = "SQL", - SQLReadOnly = "SQLReadOnly", } export const defaultAccounts: Record = { @@ -47,7 +46,6 @@ export const defaultAccounts: Record = { [TestAccount.Mongo]: "github-e2etests-mongo", [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"; @@ -216,25 +214,6 @@ 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; }; @@ -253,12 +232,6 @@ 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. @@ -321,10 +294,6 @@ export class DataExplorer { return await this.waitForNode(`${databaseId}/${containerId}`); } - async waitForContainerItemsNode(databaseId: string, containerId: string): Promise { - return await this.waitForNode(`${databaseId}/${containerId}/Items`); - } - /** 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/sql/document.spec.ts b/test/sql/document.spec.ts deleted file mode 100644 index 10877702a..000000000 --- a/test/sql/document.spec.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { expect, test } from "@playwright/test"; - -import { DataExplorer, DocumentsTab, TestAccount } from "../fx"; -import { documentTestCases } from "./testCases"; - -let explorer: DataExplorer = null!; -let documentsTab: DocumentsTab = null!; - -for (const { name, databaseId, containerId, expectedDocumentIds } of documentTestCases) { - test.describe(`Test 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 docId of expectedDocumentIds) { - 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); - }); - }); - } - }); -} diff --git a/test/sql/testCases.ts b/test/sql/testCases.ts deleted file mode 100644 index 0f18bb183..000000000 --- a/test/sql/testCases.ts +++ /dev/null @@ -1,88 +0,0 @@ -type ContainerTestCase = { - name: string; - databaseId: string; - containerId: string; - expectedDocumentIds: string[]; -}; - -export const documentTestCases: ContainerTestCase[] = [ - { - name: "System Partition Key", - databaseId: "e2etests-sql-readonly", - containerId: "systemPartitionKey", - expectedDocumentIds: ["systempartition"], - }, - { - name: "Single Partition Key", - databaseId: "e2etests-sql-readonly", - containerId: "singlePartitionKey", - expectedDocumentIds: [ - "singlePartitionKey", - "singlePartitionKey_empty_string", - "singlePartitionKey_null", - "singlePartitionKey_missing", - ], - }, - { - name: "Single Nested Partition Key", - databaseId: "e2etests-sql-readonly", - containerId: "singleNestedPartitionKey", - expectedDocumentIds: [ - "singlePartitionKey_nested", - "singlePartitionKey_nested_empty_string", - "singlePartitionKey_nested_null", - "singlePartitionKey_nested_missing", - ], - }, - { - name: "2-Level Hierarchical Partition Key", - databaseId: "e2etests-sql-readonly", - containerId: "twoLevelPartitionKey", - expectedDocumentIds: [ - "twoLevelPartitionKey_value_empty", - "twoLevelPartitionKey_value_null", - "twoLevelPartitionKey_value_missing", - "twoLevelPartitionKey_empty_null", - "twoLevelPartitionKey_null_missing", - "twoLevelPartitionKey_missing_value", - ], - }, - { - name: "2-Level Hierarchical Nested Partition Key", - databaseId: "e2etests-sql-readonly", - containerId: "twoLevelNestedPartitionKey", - expectedDocumentIds: [ - "twoLevelNestedPartitionKey_nested_value_empty", - "twoLevelNestedPartitionKey_nested_null_missing", - "twoLevelNestedPartitionKey_nested_missing_value", - ], - }, - { - name: "3-Level Hierarchical Partition Key", - databaseId: "e2etests-sql-readonly", - containerId: "threeLevelPartitionKey", - expectedDocumentIds: [ - "threeLevelPartitionKey_value_empty_null", - "threeLevelPartitionKey_value_null_missing", - "threeLevelPartitionKey_value_missing_null", - "threeLevelPartitionKey_null_empty_value", - "threeLevelPartitionKey_missing_value_value", - "threeLevelPartitionKey_empty_value_missing", - ], - }, - { - name: "3-Level Nested Hierarchical Partition Key", - databaseId: "e2etests-sql-readonly", - containerId: "threeLevelNestedPartitionKey", - expectedDocumentIds: [ - "threeLevelNestedPartitionKey_nested_empty_value_null", - "threeLevelNestedPartitionKey_nested_null_value_missing", - "threeLevelNestedPartitionKey_nested_missing_value_null", - "threeLevelNestedPartitionKey_nested_null_empty_missing", - "threeLevelNestedPartitionKey_nested_value_missing_empty", - "threeLevelNestedPartitionKey_nested_missing_null_empty", - "threeLevelNestedPartitionKey_nested_empty_null_value", - "threeLevelNestedPartitionKey_nested_value_null_empty", - ], - }, -]; From 2cff0fc3ff1206a28c4e9e6c802ab5c8e3861286 Mon Sep 17 00:00:00 2001 From: asier-isayas Date: Fri, 11 Apr 2025 10:39:32 -0400 Subject: [PATCH 07/14] Global Secondary Index (#2071) * add Materialized Views feature flag * fetch MV properties from RP API and capture them in our data models * AddMaterializedViewPanel * undefined check * subpartition keys * Partition Key, Throughput, Unique Keys * All views associated with a container (#2063) and Materialized View Target Container (#2065) Identified Source container and Target container Created tabs in Scale and Settings respectively Changed the Icon of target container * Add MV Panel * format * format * styling * add tests * tests * test files (#2074) Co-authored-by: nishthaAhujaa * fix type error * fix tests * merge conflict * Panel Integration (#2075) * integrated panel * edited header text --------- Co-authored-by: nishthaAhujaa Co-authored-by: Asier Isayas * updated tests (#2077) Co-authored-by: nishthaAhujaa * fix tests * update treeNodeUtil test snap * update settings component test snap * fixed source container in global "New Materialized View" * source container check (#2079) Co-authored-by: nishthaAhujaa * renamed Materialized Views to Global Secondary Index * more renaming * fix import * fix typo * disable materialized views for Fabric * updated input validation --------- Co-authored-by: Asier Isayas Co-authored-by: Nishtha Ahuja <45535788+nishthaAhujaa@users.noreply.github.com> Co-authored-by: nishthaAhujaa --- src/Common/Constants.ts | 3 + src/Common/DatabaseAccountUtility.ts | 7 + .../dataAccess/createMaterializedView.ts | 74 +++ src/Common/dataAccess/readCollections.ts | 9 +- src/Contracts/DataModels.ts | 28 +- src/Contracts/ViewModels.ts | 2 + src/Explorer/ContextMenuButtonFactory.tsx | 24 + .../Controls/Settings/SettingsComponent.tsx | 19 + .../GlobalSecondaryIndexComponent.test.tsx | 46 ++ .../GlobalSecondaryIndexComponent.tsx | 41 ++ ...obalSecondaryIndexSourceComponent.test.tsx | 42 ++ .../GlobalSecondaryIndexSourceComponent.tsx | 114 +++++ ...obalSecondaryIndexTargetComponent.test.tsx | 32 ++ .../GlobalSecondaryIndexTargetComponent.tsx | 45 ++ .../Controls/Settings/SettingsUtils.tsx | 3 + src/Explorer/Controls/Settings/TestUtils.tsx | 9 + .../SettingsComponent.test.tsx.snap | 101 ++++ src/Explorer/Explorer.tsx | 2 +- .../AddCollectionPanel.test.tsx | 2 +- .../AddCollectionPanel.tsx | 280 +++--------- .../AddCollectionPanelUtility.tsx | 217 +++++++++ .../AddCollectionPanel.test.tsx.snap | 0 .../AddGlobalSecondaryIndexPanel.test.tsx | 28 ++ .../AddGlobalSecondaryIndexPanel.tsx | 431 ++++++++++++++++++ .../AddGlobalSecondaryIndexPanelStyles.ts | 15 + .../Components/AdvancedComponent.tsx | 54 +++ .../Components/AnalyticalStoreComponent.tsx | 99 ++++ .../Components/FullTextSearchComponent.tsx | 45 ++ .../Components/PartitionKeyComponent.tsx | 132 ++++++ .../Components/ThroughputComponent.tsx | 71 +++ .../Components/UniqueKeysComponent.tsx | 78 ++++ .../Components/VectorSearchComponent.tsx | 58 +++ ...AddGlobalSecondaryIndexPanel.test.tsx.snap | 190 ++++++++ src/Explorer/QueryCopilot/CopilotCarousel.tsx | 2 +- src/Explorer/Sidebar.tsx | 25 + src/Explorer/Tree/Collection.ts | 4 + src/Explorer/Tree/Database.tsx | 2 +- src/Explorer/Tree/ResourceTree.tsx | 3 +- .../__snapshots__/treeNodeUtil.test.ts.snap | 50 +- src/Explorer/Tree/treeNodeUtil.test.ts | 10 + src/Explorer/Tree/treeNodeUtil.tsx | 9 +- src/Shared/Telemetry/TelemetryConstants.ts | 2 + 42 files changed, 2145 insertions(+), 263 deletions(-) create mode 100644 src/Common/dataAccess/createMaterializedView.ts create mode 100644 src/Explorer/Controls/Settings/SettingsSubComponents/GlobalSecondaryIndexComponent.test.tsx create mode 100644 src/Explorer/Controls/Settings/SettingsSubComponents/GlobalSecondaryIndexComponent.tsx create mode 100644 src/Explorer/Controls/Settings/SettingsSubComponents/GlobalSecondaryIndexSourceComponent.test.tsx create mode 100644 src/Explorer/Controls/Settings/SettingsSubComponents/GlobalSecondaryIndexSourceComponent.tsx create mode 100644 src/Explorer/Controls/Settings/SettingsSubComponents/GlobalSecondaryIndexTargetComponent.test.tsx create mode 100644 src/Explorer/Controls/Settings/SettingsSubComponents/GlobalSecondaryIndexTargetComponent.tsx rename src/Explorer/Panes/{ => AddCollectionPanel}/AddCollectionPanel.test.tsx (90%) rename src/Explorer/Panes/{ => AddCollectionPanel}/AddCollectionPanel.tsx (87%) create mode 100644 src/Explorer/Panes/AddCollectionPanel/AddCollectionPanelUtility.tsx rename src/Explorer/Panes/{ => AddCollectionPanel}/__snapshots__/AddCollectionPanel.test.tsx.snap (100%) create mode 100644 src/Explorer/Panes/AddGlobalSecondaryIndexPanel/AddGlobalSecondaryIndexPanel.test.tsx create mode 100644 src/Explorer/Panes/AddGlobalSecondaryIndexPanel/AddGlobalSecondaryIndexPanel.tsx create mode 100644 src/Explorer/Panes/AddGlobalSecondaryIndexPanel/AddGlobalSecondaryIndexPanelStyles.ts create mode 100644 src/Explorer/Panes/AddGlobalSecondaryIndexPanel/Components/AdvancedComponent.tsx create mode 100644 src/Explorer/Panes/AddGlobalSecondaryIndexPanel/Components/AnalyticalStoreComponent.tsx create mode 100644 src/Explorer/Panes/AddGlobalSecondaryIndexPanel/Components/FullTextSearchComponent.tsx create mode 100644 src/Explorer/Panes/AddGlobalSecondaryIndexPanel/Components/PartitionKeyComponent.tsx create mode 100644 src/Explorer/Panes/AddGlobalSecondaryIndexPanel/Components/ThroughputComponent.tsx create mode 100644 src/Explorer/Panes/AddGlobalSecondaryIndexPanel/Components/UniqueKeysComponent.tsx create mode 100644 src/Explorer/Panes/AddGlobalSecondaryIndexPanel/Components/VectorSearchComponent.tsx create mode 100644 src/Explorer/Panes/AddGlobalSecondaryIndexPanel/__snapshots__/AddGlobalSecondaryIndexPanel.test.tsx.snap 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/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/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/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 3b3ab5027..58e412b76 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/ViewModels.ts b/src/Contracts/ViewModels.ts index 83afa9ddb..a66d83b86 100644 --- a/src/Contracts/ViewModels.ts +++ b/src/Contracts/ViewModels.ts @@ -143,6 +143,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[]; 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/Settings/SettingsComponent.tsx b/src/Explorer/Controls/Settings/SettingsComponent.tsx index 720bef874..beec14495 100644 --- a/src/Explorer/Controls/Settings/SettingsComponent.tsx +++ b/src/Explorer/Controls/Settings/SettingsComponent.tsx @@ -44,6 +44,10 @@ import { ConflictResolutionComponent, ConflictResolutionComponentProps, } from "./SettingsSubComponents/ConflictResolutionComponent"; +import { + GlobalSecondaryIndexComponent, + GlobalSecondaryIndexComponentProps, +} from "./SettingsSubComponents/GlobalSecondaryIndexComponent"; import { IndexingPolicyComponent, IndexingPolicyComponentProps } from "./SettingsSubComponents/IndexingPolicyComponent"; import { MongoIndexingPolicyComponent, @@ -162,6 +166,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/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..34f6dec6d 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,8 @@ exports[`SettingsComponent renders 1`] = ` "getDatabase": [Function], "id": [Function], "indexingPolicy": [Function], + "materializedViewDefinition": [Function], + "materializedViews": [Function], "offer": [Function], "partitionKey": { "kind": "hash", @@ -336,6 +342,101 @@ exports[`SettingsComponent renders 1`] = ` shouldDiscardComputedProperties={false} /> + + +
    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..ddd1648d4 --- /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/QueryCopilot/CopilotCarousel.tsx b/src/Explorer/QueryCopilot/CopilotCarousel.tsx index 4a73cacb8..a1273c910 100644 --- a/src/Explorer/QueryCopilot/CopilotCarousel.tsx +++ b/src/Explorer/QueryCopilot/CopilotCarousel.tsx @@ -18,7 +18,7 @@ import { createCollection } from "Common/dataAccess/createCollection"; import * as DataModels from "Contracts/DataModels"; import { ContainerSampleGenerator } from "Explorer/DataSamples/ContainerSampleGenerator"; import Explorer from "Explorer/Explorer"; -import { AllPropertiesIndexed } from "Explorer/Panes/AddCollectionPanel"; +import { AllPropertiesIndexed } from "Explorer/Panes/AddCollectionPanel/AddCollectionPanelUtility"; import { PromptCard } from "Explorer/QueryCopilot/PromptCard"; import { useDatabases } from "Explorer/useDatabases"; import { useCarousel } from "hooks/useCarousel"; diff --git a/src/Explorer/Sidebar.tsx b/src/Explorer/Sidebar.tsx index 01fd8a6d1..229518f9f 100644 --- a/src/Explorer/Sidebar.tsx +++ b/src/Explorer/Sidebar.tsx @@ -13,9 +13,15 @@ import { SplitButton, } from "@fluentui/react-components"; import { Add16Regular, ArrowSync12Regular, ChevronLeft12Regular, ChevronRight12Regular } from "@fluentui/react-icons"; +import { GlobalSecondaryIndexLabels } from "Common/Constants"; +import { isGlobalSecondaryIndexEnabled } from "Common/DatabaseAccountUtility"; import { configContext, Platform } from "ConfigContext"; import Explorer from "Explorer/Explorer"; import { AddDatabasePanel } from "Explorer/Panes/AddDatabasePanel/AddDatabasePanel"; +import { + AddGlobalSecondaryIndexPanel, + AddGlobalSecondaryIndexPanelProps, +} from "Explorer/Panes/AddGlobalSecondaryIndexPanel/AddGlobalSecondaryIndexPanel"; import { Tabs } from "Explorer/Tabs/Tabs"; import { CosmosFluentProvider, cosmosShorthands, tokens } from "Explorer/Theme/ThemeUtil"; import { ResourceTree } from "Explorer/Tree/ResourceTree"; @@ -162,6 +168,25 @@ const GlobalCommands: React.FC = ({ 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]); diff --git a/src/Explorer/Tree/Collection.ts b/src/Explorer/Tree/Collection.ts index c692d0747..bc3af39f1 100644 --- a/src/Explorer/Tree/Collection.ts +++ b/src/Explorer/Tree/Collection.ts @@ -58,6 +58,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 +126,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) => { 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/ResourceTree.tsx b/src/Explorer/Tree/ResourceTree.tsx index 10b8316c0..60e67ecda 100644 --- a/src/Explorer/Tree/ResourceTree.tsx +++ b/src/Explorer/Tree/ResourceTree.tsx @@ -1,6 +1,7 @@ import { Tree, TreeItemValue, TreeOpenChangeData, TreeOpenChangeEvent } from "@fluentui/react-components"; import { Home16Regular } from "@fluentui/react-icons"; import { AuthType } from "AuthType"; +import { Collection } from "Contracts/ViewModels"; import { useTreeStyles } from "Explorer/Controls/TreeComponent/Styles"; import { TreeNode, TreeNodeComponent } from "Explorer/Controls/TreeComponent/TreeNodeComponent"; import { @@ -60,7 +61,7 @@ export const ResourceTree: React.FC = ({ explorer }: Resource const databaseTreeNodes = useMemo(() => { return userContext.authType === AuthType.ResourceToken - ? createResourceTokenTreeNodes(resourceTokenCollection) + ? createResourceTokenTreeNodes(resourceTokenCollection as Collection) : createDatabaseTreeNodes(explorer, isNotebookEnabled, databases, refreshActiveTab); }, [resourceTokenCollection, databases, isNotebookEnabled, refreshActiveTab]); diff --git a/src/Explorer/Tree/__snapshots__/treeNodeUtil.test.ts.snap b/src/Explorer/Tree/__snapshots__/treeNodeUtil.test.ts.snap index 9814a72fb..3515c1257 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, @@ -760,7 +760,7 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ "styleClass": "deleteCollectionMenuItem", }, ], - "iconSrc": , "isExpanded": true, @@ -787,7 +787,7 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ "styleClass": "deleteCollectionMenuItem", }, ], - "iconSrc": , "isExpanded": true, @@ -841,7 +841,7 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ "styleClass": "deleteCollectionMenuItem", }, ], - "iconSrc": , "isExpanded": true, @@ -895,7 +895,7 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ "styleClass": "deleteCollectionMenuItem", }, ], - "iconSrc": , "isExpanded": true, @@ -953,7 +953,7 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ "onClick": [Function], }, ], - "iconSrc": , "isExpanded": true, @@ -974,7 +974,7 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ "onClick": [Function], }, ], - "iconSrc": , "isExpanded": true, @@ -1010,7 +1010,7 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ "onClick": [Function], }, ], - "iconSrc": , "isExpanded": true, @@ -1046,7 +1046,7 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ "onClick": [Function], }, ], - "iconSrc": , "isExpanded": true, @@ -1208,7 +1208,7 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ "styleClass": "deleteCollectionMenuItem", }, ], - "iconSrc": , "isExpanded": true, @@ -1311,7 +1311,7 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ "styleClass": "deleteCollectionMenuItem", }, ], - "iconSrc": , "isExpanded": true, @@ -1445,7 +1445,7 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ "styleClass": "deleteCollectionMenuItem", }, ], - "iconSrc": , "isExpanded": true, @@ -1625,7 +1625,7 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ "styleClass": "deleteCollectionMenuItem", }, ], - "iconSrc": , "isExpanded": true, @@ -1799,7 +1799,7 @@ exports[`createDatabaseTreeNodes using NoSQL API on Hosted Platform creates expe "styleClass": "deleteCollectionMenuItem", }, ], - "iconSrc": , "isExpanded": true, @@ -1897,7 +1897,7 @@ exports[`createDatabaseTreeNodes using NoSQL API on Hosted Platform creates expe "styleClass": "deleteCollectionMenuItem", }, ], - "iconSrc": , "isExpanded": true, @@ -2031,7 +2031,7 @@ exports[`createDatabaseTreeNodes using NoSQL API on Hosted Platform creates expe "styleClass": "deleteCollectionMenuItem", }, ], - "iconSrc": , "isExpanded": true, @@ -2211,7 +2211,7 @@ exports[`createDatabaseTreeNodes using NoSQL API on Hosted Platform creates expe "styleClass": "deleteCollectionMenuItem", }, ], - "iconSrc": , "isExpanded": true, @@ -2266,7 +2266,7 @@ exports[`createResourceTokenTreeNodes creates the expected tree nodes 1`] = ` }, ], "className": "collectionNode", - "iconSrc": , "isExpanded": true, diff --git a/src/Explorer/Tree/treeNodeUtil.test.ts b/src/Explorer/Tree/treeNodeUtil.test.ts index f9298a428..8172ade3a 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([]), diff --git a/src/Explorer/Tree/treeNodeUtil.tsx b/src/Explorer/Tree/treeNodeUtil.tsx index 60fe9079b..a9bf32247 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"; @@ -29,6 +29,7 @@ export const shouldShowScriptNodes = (): boolean => { const TreeDatabaseIcon = ; const TreeSettingsIcon = ; const TreeCollectionIcon = ; +const GlobalSecondaryIndexCollectionIcon = ; //check icon export const createSampleDataTreeNodes = (sampleDataResourceTokenCollection: ViewModels.CollectionBase): TreeNode[] => { const updatedSampleTree: TreeNode = { @@ -80,7 +81,7 @@ export const createSampleDataTreeNodes = (sampleDataResourceTokenCollection: Vie return [updatedSampleTree]; }; -export const createResourceTokenTreeNodes = (collection: ViewModels.CollectionBase): TreeNode[] => { +export const createResourceTokenTreeNodes = (collection: ViewModels.Collection): TreeNode[] => { if (!collection) { return [ { @@ -110,7 +111,7 @@ export const createResourceTokenTreeNodes = (collection: ViewModels.CollectionBa isExpanded: true, children, className: "collectionNode", - iconSrc: TreeCollectionIcon, + iconSrc: collection.materializedViewDefinition() ? GlobalSecondaryIndexCollectionIcon : TreeCollectionIcon, onClick: () => { // Rewritten version of expandCollapseCollection useSelectedNode.getState().setSelectedNode(collection); @@ -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(); diff --git a/src/Shared/Telemetry/TelemetryConstants.ts b/src/Shared/Telemetry/TelemetryConstants.ts index 1fae132ad..e0f1691be 100644 --- a/src/Shared/Telemetry/TelemetryConstants.ts +++ b/src/Shared/Telemetry/TelemetryConstants.ts @@ -2,6 +2,7 @@ export enum Action { CollapseTreeNode, CreateCollection, + CreateGlobalSecondaryIndex, CreateDocument, CreateStoredProcedure, CreateTrigger, @@ -119,6 +120,7 @@ export enum Action { NotebooksGalleryPublishedCount, SelfServe, ExpandAddCollectionPaneAdvancedSection, + ExpandAddGlobalSecondaryIndexPaneAdvancedSection, SchemaAnalyzerClickAnalyze, SelfServeComponent, LaunchQuickstart, From afdbefe36c3b833b428331a0a8e37097a4894233 Mon Sep 17 00:00:00 2001 From: Laurent Nguyen Date: Tue, 15 Apr 2025 17:48:44 +0200 Subject: [PATCH 08/14] Implement refreshResourceTree message and hide refresh button above resource tree for Fabric native (#2102) --- src/Contracts/FabricMessagesContract.ts | 7 +++++++ src/Explorer/Sidebar.tsx | 22 ++++++++++++---------- src/hooks/useKnockoutExplorer.ts | 4 ++++ 3 files changed, 23 insertions(+), 10 deletions(-) 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/Explorer/Sidebar.tsx b/src/Explorer/Sidebar.tsx index 229518f9f..f3db58d8a 100644 --- a/src/Explorer/Sidebar.tsx +++ b/src/Explorer/Sidebar.tsx @@ -340,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/Tree/Collection.ts b/src/Explorer/Tree/Collection.ts index 096b9e087..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"; @@ -1086,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, @@ -1098,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++;