Adds autorest-based SQL Project generation to SQL Database Projects extension (#17078)

* Initial changes

* checkpoint

* Constructing project with post deployment script

* Correcting to intentionally read from cached list of projects

* Adding activation event, fixing fresh workspace bug

* Convert netcoreTool and autorestHelper to share a helper class for streamed command

* Include npm package version to force update

* test checkpoint

* Unit tests

* Added contextual quickpicks for autorest dialogs

* Adding projectController test

* Added projectController test, some refactoring for testability

* Merge branch 'main' into benjin/autorest

* Fixing 'which' import

* PR feedback

* Fixing tests

* Adding additional information for when project provider tests fail

* Hopefully fixing failing tests (unable to repro locally)

* Adding Generate Project item to workspace menu

* PR feedback
This commit is contained in:
Benjin Dubishar
2021-09-16 20:38:40 -07:00
committed by GitHub
parent 0cf1abc7c2
commit 08e15bce99
18 changed files with 586 additions and 85 deletions

View File

@@ -0,0 +1,106 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the Source EULA. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as vscode from 'vscode';
import { DoNotAskAgain, Install, nodeButNotAutorestFound, nodeNotFound } from '../common/constants';
import * as utils from '../common/utils';
import * as semver from 'semver';
import { DBProjectConfigurationKey } from './netcoreTool';
import { ShellExecutionHelper } from './shellExecutionHelper';
const autorestPackageName = 'autorest-sql-testing'; // name of AutoRest.Sql package on npm
const nodejsDoNotAskAgainKey: string = 'nodejsDoNotAsk';
const autorestSqlVersionKey: string = 'autorestSqlVersion';
/**
* Helper class for dealing with Autorest generation and detection
*/
export class AutorestHelper extends ShellExecutionHelper {
constructor(_outputChannel: vscode.OutputChannel) {
super(_outputChannel);
}
/**
* Checks the workspace configuration to for an AutoRest.Sql override, otherwise latest will be used from NPM
*/
public get autorestSqlPackageVersion(): string {
let configVal: string | undefined = vscode.workspace.getConfiguration(DBProjectConfigurationKey)[autorestSqlVersionKey];
if (configVal && semver.valid(configVal.trim())) {
return configVal.trim();
} else {
return 'latest';
}
}
/**
* @returns the beginning of the command to execute autorest; 'autorest' if available, 'npx autorest' if module not installed, or undefined if neither
*/
public async detectInstallation(): Promise<string | undefined> {
const autorestCommand = 'autorest';
const npxCommand = 'npx';
if (await utils.detectCommandInstallation(autorestCommand)) {
return autorestCommand;
}
if (await utils.detectCommandInstallation(npxCommand)) {
this._outputChannel.appendLine(nodeButNotAutorestFound);
return `${npxCommand} ${autorestCommand}`;
}
return undefined;
}
/**
* Calls autorest to generate files from the spec, piping standard and error output to the host console
* @param specPath path to the OpenAPI spec file
* @param outputFolder folder in which to generate the .sql script files
* @returns console output from autorest execution
*/
public async generateAutorestFiles(specPath: string, outputFolder: string): Promise<string | undefined> {
const commandExecutable = await this.detectInstallation();
if (commandExecutable === undefined) {
// unable to find autorest or npx
if (vscode.workspace.getConfiguration(DBProjectConfigurationKey)[nodejsDoNotAskAgainKey] !== true) {
this._outputChannel.appendLine(nodeNotFound);
return; // user doesn't want to be prompted about installing it
}
// prompt user to install Node.js
const result = await vscode.window.showErrorMessage(nodeNotFound, DoNotAskAgain, Install);
if (result === Install) {
//open install link
const nodejsInstallationUrl = 'https://nodejs.dev/download';
await vscode.env.openExternal(vscode.Uri.parse(nodejsInstallationUrl));
} else if (result === DoNotAskAgain) {
const config = vscode.workspace.getConfiguration(DBProjectConfigurationKey);
await config.update(nodejsDoNotAskAgainKey, true, vscode.ConfigurationTarget.Global);
}
return;
}
const command = this.constructAutorestCommand(commandExecutable, specPath, outputFolder);
const output = await this.runStreamedCommand(command, this._outputChannel);
return output;
}
/**
*
* @param executable either "autorest" or "npx autorest", depending on whether autorest is already present in the global cache
* @param specPath path to the OpenAPI spec
* @param outputFolder folder in which to generate the files
* @returns composed command to be executed
*/
public constructAutorestCommand(executable: string, specPath: string, outputFolder: string): string {
// TODO: should --clear-output-folder be included? We should always be writing to a folder created just for this, but potentially risky
return `${executable} --use:${autorestPackageName}@${this.autorestSqlPackageVersion} --input-file="${specPath}" --output-folder="${outputFolder}" --clear-output-folder`;
}
}

View File

@@ -7,13 +7,13 @@ import * as child_process from 'child_process';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import * as cp from 'promisify-child-process';
import * as semver from 'semver';
import { isNullOrUndefined } from 'util';
import * as vscode from 'vscode';
import * as nls from 'vscode-nls';
import { DoNotAskAgain, InstallNetCore, NetCoreInstallationConfirmation, NetCoreSupportedVersionInstallationConfirmation, UpdateNetCoreLocation } from '../common/constants';
import { DoNotAskAgain, Install, NetCoreInstallationConfirmation, NetCoreSupportedVersionInstallationConfirmation, UpdateNetCoreLocation } from '../common/constants';
import * as utils from '../common/utils';
import { ShellCommandOptions, ShellExecutionHelper } from './shellExecutionHelper';
const localize = nls.loadMessageBundle();
export const DBProjectConfigurationKey: string = 'sqlDatabaseProjects';
@@ -33,13 +33,7 @@ export const enum netCoreInstallState {
const dotnet = os.platform() === 'win32' ? 'dotnet.exe' : 'dotnet';
export interface DotNetCommandOptions {
workingDirectory?: string;
additionalEnvironmentVariables?: NodeJS.ProcessEnv;
commandTitle?: string;
argument?: string;
}
export class NetCoreTool {
export class NetCoreTool extends ShellExecutionHelper {
private osPlatform: string = os.platform();
private netCoreSdkInstalledVersion: string | undefined;
@@ -61,22 +55,22 @@ export class NetCoreTool {
return true;
}
constructor(private _outputChannel: vscode.OutputChannel) {
constructor(_outputChannel: vscode.OutputChannel) {
super(_outputChannel);
}
public async showInstallDialog(): Promise<void> {
let result;
if (this.netCoreInstallState === netCoreInstallState.netCoreNotPresent) {
result = await vscode.window.showErrorMessage(NetCoreInstallationConfirmation, UpdateNetCoreLocation, InstallNetCore, DoNotAskAgain);
result = await vscode.window.showErrorMessage(NetCoreInstallationConfirmation, UpdateNetCoreLocation, Install, DoNotAskAgain);
} else {
result = await vscode.window.showErrorMessage(NetCoreSupportedVersionInstallationConfirmation(this.netCoreSdkInstalledVersion!), UpdateNetCoreLocation, InstallNetCore, DoNotAskAgain);
result = await vscode.window.showErrorMessage(NetCoreSupportedVersionInstallationConfirmation(this.netCoreSdkInstalledVersion!), UpdateNetCoreLocation, Install, DoNotAskAgain);
}
if (result === UpdateNetCoreLocation) {
//open settings
await vscode.commands.executeCommand('workbench.action.openGlobalSettings');
} else if (result === InstallNetCore) {
} else if (result === Install) {
//open install link
const dotnetcoreURL = 'https://dotnet.microsoft.com/download/dotnet-core/3.1';
await vscode.env.openExternal(vscode.Uri.parse(dotnetcoreURL));
@@ -183,7 +177,7 @@ export class NetCoreTool {
}
}
public async runDotnetCommand(options: DotNetCommandOptions): Promise<string> {
public async runDotnetCommand(options: ShellCommandOptions): Promise<string> {
if (options && options.commandTitle !== undefined && options.commandTitle !== null) {
this._outputChannel.appendLine(`\t[ ${options.commandTitle} ]`);
}
@@ -206,53 +200,6 @@ export class NetCoreTool {
throw error;
}
}
// spawns the dotnet command with arguments and redirects the error and output to ADS output channel
public async runStreamedCommand(command: string, outputChannel: vscode.OutputChannel, options?: DotNetCommandOptions): Promise<string> {
const stdoutData: string[] = [];
outputChannel.appendLine(` > ${command}`);
const spawnOptions = {
cwd: options && options.workingDirectory,
env: Object.assign({}, process.env, options && options.additionalEnvironmentVariables),
encoding: 'utf8',
maxBuffer: 10 * 1024 * 1024, // 10 Mb of output can be captured.
shell: true,
detached: false,
windowsHide: true
};
const child = cp.spawn(command, [], spawnOptions);
outputChannel.show();
// Add listeners to print stdout and stderr and exit code
void child.on('exit', (code: number | null, signal: string | null) => {
if (code !== null) {
outputChannel.appendLine(localize('sqlDatabaseProjects.RunStreamedCommand.ExitedWithCode', " >>> {0} … exited with code: {1}", command, code));
} else {
outputChannel.appendLine(localize('sqlDatabaseProjects.RunStreamedCommand.ExitedWithSignal', " >>> {0} … exited with signal: {1}", command, signal));
}
});
child.stdout!.on('data', (data: string | Buffer) => {
stdoutData.push(data.toString());
this.outputDataChunk(data, outputChannel, localize('sqlDatabaseProjects.RunCommand.stdout', " stdout: "));
});
child.stderr!.on('data', (data: string | Buffer) => {
this.outputDataChunk(data, outputChannel, localize('sqlDatabaseProjects.RunCommand.stderr', " stderr: "));
});
await child;
return stdoutData.join('');
}
private outputDataChunk(data: string | Buffer, outputChannel: vscode.OutputChannel, header: string): void {
data.toString().split(/\r?\n/)
.forEach(line => {
outputChannel.appendLine(header + line);
});
}
}
export class DotNetError extends Error {

View File

@@ -6,7 +6,8 @@ import * as vscode from 'vscode';
import * as utils from '../common/utils';
import * as azureFunctionsUtils from '../common/azureFunctionsUtils';
import * as constants from '../common/constants';
import { DotNetCommandOptions, NetCoreTool } from './netcoreTool';
import { NetCoreTool } from './netcoreTool';
import { ShellCommandOptions } from './shellExecutionHelper';
export class PackageHelper {
private netCoreTool: NetCoreTool;
@@ -40,7 +41,7 @@ export class PackageHelper {
* @param packageVersion optional version of package. If none, latest will be pulled in
*/
public async addPackage(project: string, packageName: string, packageVersion?: string): Promise<void> {
const addOptions: DotNetCommandOptions = {
const addOptions: ShellCommandOptions = {
commandTitle: constants.addPackage,
argument: this.constructAddPackageArguments(project, packageName, packageVersion)
};

View File

@@ -0,0 +1,71 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the Source EULA. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as cp from 'promisify-child-process';
import * as vscode from 'vscode';
import * as nls from 'vscode-nls';
const localize = nls.loadMessageBundle();
export interface ShellCommandOptions {
workingDirectory?: string;
additionalEnvironmentVariables?: NodeJS.ProcessEnv;
commandTitle?: string;
argument?: string;
}
export class ShellExecutionHelper {
constructor(protected _outputChannel: vscode.OutputChannel) {
}
/**
* spawns the shell command with arguments and redirects the error and output to ADS output channel
*/
public async runStreamedCommand(command: string, outputChannel: vscode.OutputChannel, options?: ShellCommandOptions): Promise<string> {
const stdoutData: string[] = [];
outputChannel.appendLine(` > ${command}`);
const spawnOptions = {
cwd: options && options.workingDirectory,
env: Object.assign({}, process.env, options && options.additionalEnvironmentVariables),
encoding: 'utf8',
maxBuffer: 10 * 1024 * 1024, // 10 Mb of output can be captured.
shell: true,
detached: false,
windowsHide: true
};
const child = cp.spawn(command, [], spawnOptions);
outputChannel.show();
// Add listeners to print stdout and stderr and exit code
void child.on('exit', (code: number | null, signal: string | null) => {
if (code !== null) {
outputChannel.appendLine(localize('sqlDatabaseProjects.RunStreamedCommand.ExitedWithCode', " >>> {0} … exited with code: {1}", command, code));
} else {
outputChannel.appendLine(localize('sqlDatabaseProjects.RunStreamedCommand.ExitedWithSignal', " >>> {0} … exited with signal: {1}", command, signal));
}
});
child.stdout!.on('data', (data: string | Buffer) => {
stdoutData.push(data.toString());
this.outputDataChunk(data, outputChannel, localize('sqlDatabaseProjects.RunCommand.stdout', " stdout: "));
});
child.stderr!.on('data', (data: string | Buffer) => {
this.outputDataChunk(data, outputChannel, localize('sqlDatabaseProjects.RunCommand.stderr', " stderr: "));
});
await child;
return stdoutData.join('');
}
private outputDataChunk(data: string | Buffer, outputChannel: vscode.OutputChannel, header: string): void {
data.toString().split(/\r?\n/)
.forEach(line => {
outputChannel.appendLine(header + line);
});
}
}