Skip to content

Commit f02fd79

Browse files
VasuBhogalanrenmsftAditya Bist
authored
Create Azure Function from Table (#17217)
* wip * working create azure function functionality * fully working add sql binding from table * add more clear comments and more optimized code * breaking change that will cause sqlbinding to be undefined Co-authored-by: Alan Ren <[email protected]> Co-authored-by: Aditya Bist <[email protected]>
1 parent c301778 commit f02fd79

11 files changed

+506
-1
lines changed

localization/xliff/enu/constants/localizedConstants.enu.xlf

+5
Original file line numberDiff line numberDiff line change
@@ -521,6 +521,11 @@
521521
<trans-unit id="azureSignInToAzureCloudDescription">
522522
<source xml:lang="en">Sign in to your Azure subscription in one of the sovereign clouds.</source>
523523
</trans-unit>
524+
<trans-unit id="azureFunctionsExtensionNotInstalled">
525+
<source xml:lang="en">Azure Functions extension must be installed in order to use this feature.</source>
526+
</trans-unit>
527+
<trans-unit id="azureFunctionsProjectMustBeOpened">
528+
<source xml:lang="en">A C# Azure Functions project must be present in order to create a new Azure Function for this table.</source>
524529
<trans-unit id="taskStatusWithName">
525530
<source xml:lang="en">{0}: {1}</source>
526531
</trans-unit>

package.json

+11-1
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@
7676
"@types/jqueryui": "^1.12.7",
7777
"@types/keytar": "^4.4.2",
7878
"@types/mocha": "^5.2.7",
79+
"@types/node": "^14.14.16",
7980
"@types/tmp": "0.0.28",
8081
"@types/underscore": "1.8.3",
8182
"@types/vscode": "1.57.0",
@@ -116,7 +117,6 @@
116117
"yargs": "https://registry.npmjs.org/yargs/-/yargs-3.32.0.tgz"
117118
},
118119
"dependencies": {
119-
"@types/node": "^14.14.16",
120120
"@microsoft/ads-adal-library": "1.0.13",
121121
"core-js": "^2.4.1",
122122
"decompress-zip": "^0.2.2",
@@ -299,6 +299,11 @@
299299
"command": "mssql.deleteQueryHistory",
300300
"when": "view == queryHistory && viewItem == queryHistoryNode",
301301
"group": "MS_SQL@3"
302+
},
303+
{
304+
"command": "mssql.createAzureFunction",
305+
"when": "view == objectExplorer && viewItem == Table",
306+
"group": "MS_SQL@6"
302307
}
303308
],
304309
"commandPalette": [
@@ -520,6 +525,11 @@
520525
"command": "mssql.removeAadAccount",
521526
"title": "%mssql.removeAadAccount%",
522527
"category": "MS SQL"
528+
},
529+
{
530+
"command": "mssql.createAzureFunction",
531+
"title": "%mssql.createAzureFunction%",
532+
"category": "MS SQL"
523533
}
524534
],
525535
"keybindings": [

package.nls.json

+1
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@
9494
"mssql.intelliSense.lowerCaseSuggestions":"Should IntelliSense suggestions be lowercase",
9595
"mssql.persistQueryResultTabs":"Should query result selections and scroll positions be saved when switching tabs (may impact performance)",
9696
"mssql.queryHistoryLimit":"Number of query history entries to show in the Query History view",
97+
"mssql.createAzureFunction":"Create Azure Function with SQL input binding",
9798
"mssql.query.maxXmlCharsToStore":"Number of XML characters to store after running a query",
9899
"mssql.tracingLevel":"[Optional] Log level for backend services. Azure Data Studio generates a file name every time it starts and if the file already exists the logs entries are appended to that file. For cleanup of old log files see logRetentionMinutes and logFilesRemovalLimit settings. The default tracingLevel does not log much. Changing verbosity could lead to extensive logging and disk space requirements for the logs. Error includes Critical, Warning includes Error, Information includes Warning and Verbose includes Information",
99100
"mssql.logRetentionMinutes":"Number of minutes to retain log files for backend services. Default is 1 week.",
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
* Licensed under the MIT License. See License.txt in the project root for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
import * as vscode from 'vscode';
6+
import * as mssql from 'vscode-mssql';
7+
import * as LocalizedConstants from '../constants/localizedConstants';
8+
import { AzureFunctionsService } from '../services/azureFunctionsService';
9+
import * as azureFunctionUtils from '../azureFunction/azureFunctionUtils';
10+
import * as constants from '../constants/constants';
11+
import { generateQuotedFullName } from '../utils/utils';
12+
13+
export class AzureFunctionProjectService {
14+
15+
constructor(private azureFunctionsService: AzureFunctionsService) {
16+
}
17+
18+
public async createAzureFunction(connectionString: string, schema: string, table: string): Promise<void> {
19+
const azureFunctionApi = await azureFunctionUtils.getAzureFunctionsExtensionApi();
20+
if (!azureFunctionApi) {
21+
return;
22+
}
23+
let projectFile = await azureFunctionUtils.getAzureFunctionProject();
24+
if (!projectFile) {
25+
vscode.window.showErrorMessage(LocalizedConstants.azureFunctionsProjectMustBeOpened);
26+
return;
27+
}
28+
29+
// because of an AF extension API issue, we have to get the newly created file by adding
30+
// a watcher: https://github.com/microsoft/vscode-azurefunctions/issues/2908
31+
const newFilePromise = azureFunctionUtils.waitForNewFunctionFile(projectFile);
32+
33+
// get function name from user
34+
const functionName = await vscode.window.showInputBox({
35+
title: constants.functionNameTitle,
36+
value: table,
37+
ignoreFocusOut: true
38+
});
39+
if (!functionName) {
40+
return;
41+
}
42+
43+
// create C# HttpTrigger
44+
await azureFunctionApi.createFunction({
45+
language: 'C#',
46+
templateId: 'HttpTrigger',
47+
functionName: functionName,
48+
folderPath: projectFile
49+
});
50+
51+
await azureFunctionUtils.addNugetReferenceToProjectFile(projectFile);
52+
await azureFunctionUtils.addConnectionStringToConfig(connectionString, projectFile);
53+
const functionFile = await newFilePromise;
54+
55+
let objectName = generateQuotedFullName(schema, table);
56+
await this.azureFunctionsService.addSqlBinding(
57+
mssql.BindingType.input,
58+
functionFile,
59+
functionName,
60+
objectName,
61+
constants.sqlConnectionString
62+
);
63+
64+
azureFunctionUtils.overwriteAzureFunctionMethodBody(functionFile);
65+
}
66+
}
+215
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
* Licensed under the Source EULA. See License.txt in the project root for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
import * as os from 'os';
6+
import * as fs from 'fs';
7+
import * as path from 'path';
8+
import * as vscode from 'vscode';
9+
import * as constants from '../constants/constants';
10+
import * as LocalizedConstants from '../constants/localizedConstants';
11+
// https://github.com/microsoft/vscode-azurefunctions/blob/main/src/vscode-azurefunctions.api.d.ts
12+
import { AzureFunctionsExtensionApi } from '../../typings/vscode-azurefunctions.api';
13+
// https://github.com/microsoft/vscode-azuretools/blob/main/ui/api.d.ts
14+
import { AzureExtensionApiProvider } from '../../typings/vscode-azuretools.api';
15+
import { executeCommand } from '../utils/utils';
16+
17+
/**
18+
* Represents the settings in an Azure function project's local.settings.json file
19+
*/
20+
export interface ILocalSettingsJson {
21+
IsEncrypted?: boolean;
22+
Values?: { [key: string]: string };
23+
Host?: { [key: string]: string };
24+
ConnectionStrings?: { [key: string]: string };
25+
}
26+
27+
/**
28+
* copied and modified from vscode-azurefunctions extension
29+
* https://github.com/microsoft/vscode-azurefunctions/blob/main/src/funcConfig/local.settings.ts
30+
* @param localSettingsPath full path to local.settings.json
31+
* @returns settings in local.settings.json. If no settings are found, returns default "empty" settings
32+
*/
33+
export async function getLocalSettingsJson(localSettingsPath: string): Promise<ILocalSettingsJson> {
34+
if (await fs.existsSync(localSettingsPath)) {
35+
const data: string = (fs.readFileSync(localSettingsPath)).toString();
36+
try {
37+
return JSON.parse(data);
38+
} catch (error) {
39+
console.log(error);
40+
throw new Error(constants.failedToParse(error.message));
41+
}
42+
}
43+
44+
return {
45+
IsEncrypted: false // Include this by default otherwise the func cli assumes settings are encrypted and fails to run
46+
};
47+
}
48+
49+
/**
50+
* Adds a new setting to a project's local.settings.json file
51+
* modified from setLocalAppSetting code from vscode-azurefunctions extension
52+
* @param projectFolder full path to project folder
53+
* @param key Key of the new setting
54+
* @param value Value of the new setting
55+
* @returns true if successful adding the new setting, false if unsuccessful
56+
*/
57+
export async function setLocalAppSetting(projectFolder: string, key: string, value: string): Promise<boolean> {
58+
const localSettingsPath: string = path.join(projectFolder, constants.azureFunctionLocalSettingsFileName);
59+
const settings: ILocalSettingsJson = await getLocalSettingsJson(localSettingsPath);
60+
61+
settings.Values = settings.Values || {};
62+
if (settings.Values[key] === value) {
63+
// don't do anything if it's the same as the existing value
64+
return true;
65+
} else if (settings.Values[key]) {
66+
const result = await vscode.window.showWarningMessage(constants.settingAlreadyExists(key), { modal: true }, constants.yesString);
67+
if (result !== constants.yesString) {
68+
// key already exists and user doesn't want to overwrite it
69+
return false;
70+
}
71+
}
72+
73+
settings.Values[key] = value;
74+
fs.promises.writeFile(localSettingsPath, JSON.stringify(settings, undefined, 2));
75+
76+
return true;
77+
}
78+
79+
export async function getAzureFunctionsExtensionApi(): Promise<AzureFunctionsExtensionApi | undefined> {
80+
const apiProvider = await vscode.extensions.getExtension(constants.azureFunctionsExtensionName)?.activate() as AzureExtensionApiProvider;
81+
const azureFunctionApi = apiProvider.getApi<AzureFunctionsExtensionApi>('*');
82+
if (azureFunctionApi) {
83+
return azureFunctionApi;
84+
} else {
85+
vscode.window.showErrorMessage(LocalizedConstants.azureFunctionsExtensionNotInstalled);
86+
return undefined;
87+
}
88+
}
89+
90+
/**
91+
* Overwrites the Azure function methods body to work with the binding
92+
* @param filePath is the path for the function file (.cs for C# functions)
93+
*/
94+
export function overwriteAzureFunctionMethodBody(filePath: string): void {
95+
let defaultBindedFunctionText = fs.readFileSync(filePath, 'utf-8');
96+
// Replace default binding text
97+
let newValueLines = defaultBindedFunctionText.split(os.EOL);
98+
const defaultFunctionTextToSkip = new Set(constants.defaultSqlBindingTextLines);
99+
let replacedValueLines = [];
100+
for (let defaultLine of newValueLines) {
101+
// Skipped lines
102+
if (defaultFunctionTextToSkip.has(defaultLine.trimStart())) {
103+
continue;
104+
} else if (defaultLine.trimStart() === constants.defaultBindingResult) { // Result change
105+
replacedValueLines.push(defaultLine.replace(constants.defaultBindingResult, constants.sqlBindingResult));
106+
} else {
107+
// Normal lines to be included
108+
replacedValueLines.push(defaultLine);
109+
}
110+
}
111+
defaultBindedFunctionText = replacedValueLines.join(os.EOL);
112+
fs.writeFileSync(filePath, defaultBindedFunctionText, 'utf-8');
113+
}
114+
115+
/**
116+
* Gets the azure function project for the user to choose from a list of projects files
117+
* If only one project is found that project is used to add the binding to
118+
* if no project is found, user is informed there needs to be a C# Azure Functions project
119+
* @returns the selected project file path
120+
*/
121+
export async function getAzureFunctionProject(): Promise<string | undefined> {
122+
let selectedProjectFile: string | undefined = '';
123+
if (vscode.workspace.workspaceFolders === undefined || vscode.workspace.workspaceFolders.length === 0) {
124+
return selectedProjectFile;
125+
} else {
126+
const projectFiles = await getAzureFunctionProjectFiles();
127+
if (projectFiles !== undefined) {
128+
if (projectFiles.length > 1) {
129+
// select project to add azure function to
130+
selectedProjectFile = (await vscode.window.showQuickPick(projectFiles, {
131+
canPickMany: false,
132+
title: constants.selectProject,
133+
ignoreFocusOut: true
134+
}));
135+
return selectedProjectFile;
136+
} else if (projectFiles.length === 1) {
137+
// only one azure function project found
138+
return projectFiles[0];
139+
}
140+
}
141+
return undefined;
142+
}
143+
}
144+
145+
/**
146+
* Gets the azure function project files based on the host file found in the same folder
147+
* @returns the azure function project files paths
148+
*/
149+
export async function getAzureFunctionProjectFiles(): Promise<string[] | undefined> {
150+
let projFiles: string[] = [];
151+
const hostFiles = await getHostFiles();
152+
for (let host of hostFiles) {
153+
let projectFile = await vscode.workspace.findFiles('*.csproj', path.dirname(host));
154+
projectFile.filter(file => path.dirname(file.fsPath) === path.dirname(host) ? projFiles.push(file?.fsPath) : projFiles);
155+
}
156+
return projFiles.length > 0 ? projFiles : undefined;
157+
}
158+
159+
/**
160+
* Gets the host files from the workspace
161+
* @returns the host file paths
162+
*/
163+
export async function getHostFiles(): Promise<string[] | undefined> {
164+
const hostUris = await vscode.workspace.findFiles('**/host.json');
165+
const hostFiles = hostUris.map(uri => uri.fsPath);
166+
return hostFiles.length > 0 ? hostFiles : undefined;
167+
}
168+
169+
/**
170+
* Gets the local.settings.json file path
171+
* @param projectFile path of the azure function project
172+
* @returns the local.settings.json file path
173+
*/
174+
export async function getSettingsFile(projectFile: string): Promise<string | undefined> {
175+
return path.join(path.dirname(projectFile), 'local.settings.json');
176+
}
177+
178+
/**
179+
* Retrieves the new function file once the file is created
180+
* @param projectFile is the path to the project file
181+
* @returns the function file path once created
182+
*/
183+
export function waitForNewFunctionFile(projectFile: string): Promise<string> {
184+
return new Promise((resolve, reject) => {
185+
const watcher = vscode.workspace.createFileSystemWatcher((
186+
path.dirname(projectFile), '**/*.cs'), false, true, true);
187+
const timeout = setTimeout(async () => {
188+
reject(new Error(constants.timeoutError));
189+
watcher.dispose();
190+
}, 10000);
191+
watcher.onDidCreate((e) => {
192+
resolve(e.fsPath);
193+
watcher.dispose();
194+
clearTimeout(timeout);
195+
});
196+
});
197+
}
198+
199+
/**
200+
* Adds the required nuget package to the project
201+
* @param selectedProjectFile is the users selected project file path
202+
*/
203+
export async function addNugetReferenceToProjectFile(selectedProjectFile: string): Promise<void> {
204+
await executeCommand(`dotnet add ${selectedProjectFile} package ${constants.sqlExtensionPackageName} --prerelease`);
205+
}
206+
207+
/**
208+
* Adds the Sql Connection String to the local.settings.json
209+
* @param connectionString of the SQL Server connection that was chosen by the user
210+
*/
211+
export async function addConnectionStringToConfig(connectionString: string, projectFile: string): Promise<void> {
212+
const settingsFile = await getSettingsFile(projectFile);
213+
await setLocalAppSetting(path.dirname(settingsFile), constants.sqlConnectionString, connectionString);
214+
}
215+

src/constants/constants.ts

+31
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
* Copyright (c) Microsoft Corporation. All rights reserved.
33
* Licensed under the MIT License. See License.txt in the project root for license information.
44
*--------------------------------------------------------------------------------------------*/
5+
import * as nls from 'vscode-nls';
6+
const localize = nls.loadMessageBundle();
57

68
// Collection of Non-localizable Constants
79
export const languageId = 'sql';
@@ -59,6 +61,7 @@ export const cmdAzureSignIn = 'azure-account.login';
5961
export const cmdAzureSignInWithDeviceCode = 'azure-account.loginWithDeviceCode';
6062
export const cmdAzureSignInToCloud = 'azure-account.loginToCloud';
6163
export const cmdAadRemoveAccount = 'mssql.removeAadAccount';
64+
export const cmdCreateAzureFunction = 'mssql.createAzureFunction';
6265
export const sqlDbPrefix = '.database.windows.net';
6366
export const defaultConnectionTimeout = 15;
6467
export const azureSqlDbConnectionTimeout = 30;
@@ -151,3 +154,31 @@ export const tenantDisplayName = 'Microsoft';
151154
export const firewallErrorMessage = 'To enable access, use the Windows Azure Management Portal or run sp_set_firewall_rule on the master database to create a firewall rule for this IP address or address range.';
152155
export const windowsResourceClientPath = 'SqlToolsResourceProviderService.exe';
153156
export const unixResourceClientPath = 'SqlToolsResourceProviderService';
157+
158+
// Azure Functions
159+
export const azureFunctionsExtensionName = 'ms-azuretools.vscode-azurefunctions';
160+
export const sqlConnectionString = 'SqlConnectionString';
161+
export const defaultSqlBindingTextLines =
162+
[
163+
'log.LogInformation(\"C# HTTP trigger function processed a request.\");',
164+
'string name = req.Query[\"name\"];',
165+
'string requestBody = await new StreamReader(req.Body).ReadToEndAsync();',
166+
'dynamic data = JsonConvert.DeserializeObject(requestBody);',
167+
'name = name ?? data?.name;',
168+
'string responseMessage = string.IsNullOrEmpty(name) ? \"This HTTP triggered function executed successfully. Pass a name in the query string or in the request body for a personalized response.\" : $\"Hello, {name}. This HTTP triggered function executed successfully.\";'
169+
];
170+
export const defaultBindingResult = 'return new OkObjectResult(responseMessage);';
171+
export const sqlBindingResult = `return new OkObjectResult(result);`;
172+
export const azureFunctionLocalSettingsFileName = 'local.settings.json';
173+
export const sqlExtensionPackageName = 'Microsoft.Azure.WebJobs.Extensions.Sql';
174+
export function failedToParse(errorMessage: string): string {
175+
return localize('failedToParse', 'Failed to parse "{0}": {1}.',
176+
azureFunctionLocalSettingsFileName, errorMessage);
177+
}
178+
export function settingAlreadyExists(settingName: string): string {
179+
return localize('SettingAlreadyExists', 'Local app setting \'{0}\' already exists. Overwrite?', settingName);
180+
}
181+
export const yesString = localize('yesString', 'Yes');
182+
export const functionNameTitle = localize('functionNameTitle', 'Function Name');
183+
export const selectProject = localize('selectProject', 'Select the Azure Function project for the SQL Binding');
184+
export const timeoutError = localize('timeoutError', 'Timed out waiting for azure function file creation');

0 commit comments

Comments
 (0)