diff --git a/extensions/dacpac/package.json b/extensions/dacpac/package.json index 92d15eb420..219693f18e 100644 --- a/extensions/dacpac/package.json +++ b/extensions/dacpac/package.json @@ -7,7 +7,7 @@ "preview": false, "engines": { "vscode": "^1.25.0", - "azdata": "*" + "azdata": ">=1.15.0" }, "license": "https://raw.githubusercontent.com/Microsoft/azuredatastudio/master/LICENSE.txt", "icon": "images/sqlserver.png", diff --git a/extensions/dacpac/src/wizard/api/basePage.ts b/extensions/dacpac/src/wizard/api/basePage.ts index a4857f1303..57e7b5fd93 100644 --- a/extensions/dacpac/src/wizard/api/basePage.ts +++ b/extensions/dacpac/src/wizard/api/basePage.ts @@ -14,6 +14,7 @@ export abstract class BasePage { protected readonly wizardPage: azdata.window.WizardPage; protected readonly model: DacFxDataModel; protected readonly view: azdata.ModelView; + protected databaseValues: string[]; /** * This method constructs all the elements of the page. @@ -105,30 +106,27 @@ export abstract class BasePage { return values; } - protected async getDatabaseValues(): Promise<{ displayName: string, name: string }[]> { + protected async getDatabaseValues(): Promise { let idx = -1; let count = -1; - let values = (await azdata.connection.listDatabases(this.model.server.connectionId)).map(db => { + this.databaseValues = (await azdata.connection.listDatabases(this.model.server.connectionId)).map(db => { count++; if (this.model.database && db === this.model.database) { idx = count; } - return { - displayName: db, - name: db - }; + return db; }); if (idx >= 0) { - let tmp = values[0]; - values[0] = values[idx]; - values[idx] = tmp; + let tmp = this.databaseValues[0]; + this.databaseValues[0] = this.databaseValues[idx]; + this.databaseValues[idx] = tmp; } else { this.deleteDatabaseValues(); } - return values; + return this.databaseValues; } protected deleteServerValues() { diff --git a/extensions/dacpac/src/wizard/api/dacFxConfigPage.ts b/extensions/dacpac/src/wizard/api/dacFxConfigPage.ts index b7f272c1fa..80921bc592 100644 --- a/extensions/dacpac/src/wizard/api/dacFxConfigPage.ts +++ b/extensions/dacpac/src/wizard/api/dacFxConfigPage.ts @@ -11,7 +11,7 @@ import * as path from 'path'; import { DataTierApplicationWizard, Operation } from '../dataTierApplicationWizard'; import { DacFxDataModel } from './models'; import { BasePage } from './basePage'; -import { sanitizeStringForFilename, isValidBasename } from './utils'; +import { sanitizeStringForFilename, isValidBasename, isValidBasenameErrorMessage } from './utils'; const localize = nls.loadMessageBundle(); @@ -54,7 +54,11 @@ export abstract class DacFxConfigPage extends BasePage { this.serverDropdown.onValueChanged(async () => { this.model.server = (this.serverDropdown.value as ConnectionDropdownValue).connection; this.model.serverName = (this.serverDropdown.value as ConnectionDropdownValue).displayName; - await this.populateDatabaseDropdown(); + if (this.databaseDropdown) { + await this.populateDatabaseDropdown(); + } else { + await this.getDatabaseValues(); + } }); return { @@ -79,9 +83,12 @@ export abstract class DacFxConfigPage extends BasePage { } protected async createDatabaseTextBox(title: string): Promise { - this.databaseTextBox = this.view.modelBuilder.inputBox().withProperties({ - required: true - }).component(); + this.databaseTextBox = this.view.modelBuilder.inputBox() + .withValidation(component => !this.databaseNameExists(component.value)) + .withProperties({ + required: true, + validationErrorMessage: localize('dacfx.databaseNameExistsErrorMessage', "A database with the same name already exists on the instance of SQL Server") + }).component(); this.databaseTextBox.ariaLabel = title; this.databaseTextBox.onTextChanged(async () => { @@ -130,10 +137,10 @@ export abstract class DacFxConfigPage extends BasePage { let values = await this.getDatabaseValues(); // only update values and regenerate filepath if this is the first time and database isn't set yet - if (this.model.database !== values[0].name) { + if (this.model.database !== values[0]) { // db should only get set to the dropdown value if it isn't deploy with create database if (!(this.instance.selectedOperation === Operation.deploy && !this.model.upgradeExisting)) { - this.model.database = values[0].name; + this.model.database = values[0]; } // filename shouldn't change for deploy because the file exists and isn't being generated as for extract and export if (this.instance.selectedOperation !== Operation.deploy) { @@ -159,6 +166,14 @@ export abstract class DacFxConfigPage extends BasePage { ariaLive: 'polite' }).component(); + // Set validation error message if file name is invalid + this.fileTextBox.onTextChanged(text => { + const errorMessage = isValidBasenameErrorMessage(text); + if (errorMessage) { + this.fileTextBox.updateProperty('validationErrorMessage', errorMessage); + } + }); + this.fileTextBox.ariaLabel = localize('dacfx.fileLocationAriaLabel', "File Location"); this.fileButton = this.view.modelBuilder.button().withProperties({ label: '•••', @@ -192,6 +207,18 @@ export abstract class DacFxConfigPage extends BasePage { this.fileTextBox.value = this.model.filePath; } } + + // Compares database name with existing databases on the server + protected databaseNameExists(n: string): boolean { + for (let i = 0; i < this.databaseValues.length; ++i) { + if (this.databaseValues[i].toLowerCase() === n.toLowerCase()) { + // database name exists + return true; + } + } + + return false; + } } interface ConnectionDropdownValue extends azdata.CategoryValue { diff --git a/extensions/dacpac/src/wizard/api/utils.ts b/extensions/dacpac/src/wizard/api/utils.ts index 525bf174d9..c38b30c60c 100644 --- a/extensions/dacpac/src/wizard/api/utils.ts +++ b/extensions/dacpac/src/wizard/api/utils.ts @@ -4,11 +4,12 @@ *--------------------------------------------------------------------------------------------*/ import * as os from 'os'; import * as path from 'path'; - +import * as nls from 'vscode-nls'; const WINDOWS_INVALID_FILE_CHARS = /[\\/:\*\?"<>\|]/g; const UNIX_INVALID_FILE_CHARS = /[\\/]/g; const isWindows = os.platform() === 'win32'; const WINDOWS_FORBIDDEN_NAMES = /^(con|prn|aux|clock\$|nul|lpt[0-9]|com[0-9])$/i; +const localize = nls.loadMessageBundle(); /** * Determines if a given character is a valid filename character @@ -88,4 +89,49 @@ export function isValidBasename(name: string | null | undefined): boolean { } return true; -} \ No newline at end of file +} + +/** + * Returns specific error message if file name is invalid + * Logic is copied from src\vs\base\common\extpath.ts + * @param name filename to check + */ +export function isValidBasenameErrorMessage(name: string | null | undefined): string { + const invalidFileChars = isWindows ? WINDOWS_INVALID_FILE_CHARS : UNIX_INVALID_FILE_CHARS; + if (!name) { + return localize('dacfx.undefinedFileNameErrorMessage', "Undefined name"); + } + + if (isWindows && name[name.length - 1] === '.') { + return localize('dacfx.fileNameEndingInPeriodErrorMessage', "File name cannot end with a period"); // Windows: file cannot end with a "." + } + + let basename = path.parse(name).name; + if (!basename || basename.length === 0 || /^\s+$/.test(basename)) { + return localize('dacfx.whitespaceFilenameErrorMessage', "File name cannot be whitespace"); // require a name that is not just whitespace + } + + invalidFileChars.lastIndex = 0; + if (invalidFileChars.test(basename)) { + return localize('dacfx.invalidFileCharsErrorMessage', "Invalid file characters"); // check for certain invalid file characters + } + + if (isWindows && WINDOWS_FORBIDDEN_NAMES.test(basename)) { + console.error('here'); + return localize('dacfx.reservedWindowsFileNameErrorMessage', "This file name is reserved for use by Windows. Choose another name and try again"); // check for certain invalid file names + } + + if (basename === '.' || basename === '..') { + return localize('dacfx.reservedValueErrorMessage', "Reserved file name. Choose another name and try again"); // check for reserved values + } + + if (isWindows && basename.length !== basename.trim().length) { + return localize('dacfx.trailingWhitespaceErrorMessage', "File name cannot end with a whitespace"); // Windows: file cannot end with a whitespace + } + + if (basename.length > 255) { + return localize('dacfx.tooLongFileNameErrorMessage', "File name is over 255 characters"); // most file systems do not allow files > 255 length + } + + return ''; +} diff --git a/extensions/dacpac/src/wizard/pages/deployConfigPage.ts b/extensions/dacpac/src/wizard/pages/deployConfigPage.ts index 9470107f64..f84ef26b6b 100644 --- a/extensions/dacpac/src/wizard/pages/deployConfigPage.ts +++ b/extensions/dacpac/src/wizard/pages/deployConfigPage.ts @@ -57,6 +57,8 @@ export class DeployConfigPage extends DacFxConfigPage { async onPageEnter(): Promise { let r1 = await this.populateServerDropdown(); let r2 = await this.populateDeployDatabaseDropdown(); + // get existing database values to verify if new database name is valid + await this.getDatabaseValues(); return r1 && r2; } @@ -191,7 +193,7 @@ export class DeployConfigPage extends DacFxConfigPage { //set the database to the first dropdown value if upgrading, otherwise it should get set to the textbox value if (this.model.upgradeExisting) { - this.model.database = values[0].name; + this.model.database = values[0]; } this.databaseDropdown.updateProperties({ diff --git a/extensions/dacpac/src/wizard/pages/importConfigPage.ts b/extensions/dacpac/src/wizard/pages/importConfigPage.ts index 98e74e09e5..8adfc86aab 100644 --- a/extensions/dacpac/src/wizard/pages/importConfigPage.ts +++ b/extensions/dacpac/src/wizard/pages/importConfigPage.ts @@ -48,6 +48,8 @@ export class ImportConfigPage extends DacFxConfigPage { async onPageEnter(): Promise { let r1 = await this.populateServerDropdown(); + // get existing database values to verify if new database name is valid + await this.getDatabaseValues(); return r1; } diff --git a/src/sql/azdata.proposed.d.ts b/src/sql/azdata.proposed.d.ts index f8324a6d7b..fdcaf86b59 100644 --- a/src/sql/azdata.proposed.d.ts +++ b/src/sql/azdata.proposed.d.ts @@ -194,4 +194,8 @@ declare module 'azdata' { export interface ImageComponentProperties extends ComponentProperties, ComponentWithIconProperties { } + + export interface InputBoxProperties extends ComponentProperties { + validationErrorMessage?: string; + } } diff --git a/src/sql/workbench/browser/modelComponents/inputbox.component.ts b/src/sql/workbench/browser/modelComponents/inputbox.component.ts index 0c6a5b0fe3..1247069345 100644 --- a/src/sql/workbench/browser/modelComponents/inputbox.component.ts +++ b/src/sql/workbench/browser/modelComponents/inputbox.component.ts @@ -158,12 +158,16 @@ export default class InputBoxComponent extends ComponentBase implements ICompone public validate(): Thenable { return super.validate().then(valid => { + const otherErrorMsg = valid || this.inputElement.value === '' ? undefined : this.validationErrorMessage; valid = valid && this.inputElement.validate(); // set aria label based on validity of input if (valid) { this.inputElement.setAriaLabel(this.ariaLabel); } else { + if (otherErrorMsg) { + this.inputElement.showMessage({ type: MessageType.ERROR, content: otherErrorMsg }, true); + } if (this.ariaLabel) { this.inputElement.setAriaLabel(nls.localize('period', "{0}. {1}", this.ariaLabel, this.inputElement.inputElement.validationMessage)); } else { @@ -329,4 +333,12 @@ export default class InputBoxComponent extends ComponentBase implements ICompone public focus(): void { this.inputElement.focus(); } + + public get validationErrorMessage(): string { + return this.getPropertyOrDefault((props) => props.validationErrorMessage, ''); + } + + public set validationErrorMessage(newValue: string) { + this.setPropertyFromUI((props, value) => props.validationErrorMessage = value, newValue); + } }