From 76575f29e964f3487684b84e279444279296f5c2 Mon Sep 17 00:00:00 2001 From: Charles Gagnon Date: Wed, 31 Aug 2022 14:18:18 -0700 Subject: [PATCH 1/8] Fix localization/serialization issues (#329) * Fix localization issues * split tests & cleanup * Replace file copy with itemgroup --- src/SqlAsyncCollector.cs | 8 +++- src/SqlBindingUtilities.cs | 4 +- test/Common/ProductColumnTypes.cs | 16 +++++++ test/Database/Tables/ProductsColumnTypes.sql | 5 +++ test/GlobalSuppressions.cs | 2 + .../SqlInputBindingIntegrationTests.cs | 40 ++++++++++++++++++ .../SqlOutputBindingIntegrationTests.cs | 21 ++++++++++ .../test-csharp/AddProductColumnTypes.cs | 35 ++++++++++++++++ .../GetProductColumnTypesSerialization.cs | 40 ++++++++++++++++++ ...olumnTypesSerializationDifferentCulture.cs | 42 +++++++++++++++++++ .../AddProductColumnTypes/function.json | 27 ++++++++++++ .../test-js/AddProductColumnTypes/index.js | 18 ++++++++ .../function.json | 28 +++++++++++++ .../index.js | 11 +++++ ....Azure.WebJobs.Extensions.Sql.Tests.csproj | 19 ++++----- 15 files changed, 302 insertions(+), 14 deletions(-) create mode 100644 test/Common/ProductColumnTypes.cs create mode 100644 test/Database/Tables/ProductsColumnTypes.sql create mode 100644 test/Integration/test-csharp/AddProductColumnTypes.cs create mode 100644 test/Integration/test-csharp/GetProductColumnTypesSerialization.cs create mode 100644 test/Integration/test-csharp/GetProductColumnTypesSerializationDifferentCulture.cs create mode 100644 test/Integration/test-js/AddProductColumnTypes/function.json create mode 100644 test/Integration/test-js/AddProductColumnTypes/index.js create mode 100644 test/Integration/test-js/GetProductsColumnTypesSerialization/function.json create mode 100644 test/Integration/test-js/GetProductsColumnTypesSerialization/index.js diff --git a/src/SqlAsyncCollector.cs b/src/SqlAsyncCollector.cs index e4fb6459f..bdb6ee0d8 100644 --- a/src/SqlAsyncCollector.cs +++ b/src/SqlAsyncCollector.cs @@ -349,6 +349,8 @@ private static void GenerateDataQueryForMerge(TableInformation table, IEnumerabl public class TableInformation { + private const string ISO_8061_DATETIME_FORMAT = "yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff"; + public IEnumerable PrimaryKeys { get; } /// @@ -391,9 +393,13 @@ public TableInformation(IEnumerable primaryKeys, IDictionary /// Used to determine the columns of the table as well as the next SQL row to process /// The built dictionary - public static IReadOnlyDictionary BuildDictionaryFromSqlRow(SqlDataReader reader) + public static IReadOnlyDictionary BuildDictionaryFromSqlRow(SqlDataReader reader) { - return Enumerable.Range(0, reader.FieldCount).ToDictionary(reader.GetName, i => reader.GetValue(i).ToString()); + return Enumerable.Range(0, reader.FieldCount).ToDictionary(reader.GetName, i => reader.GetValue(i)); } /// diff --git a/test/Common/ProductColumnTypes.cs b/test/Common/ProductColumnTypes.cs new file mode 100644 index 000000000..97e36eb0b --- /dev/null +++ b/test/Common/ProductColumnTypes.cs @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System; + +namespace Microsoft.Azure.WebJobs.Extensions.Sql.Tests.Common +{ + public class ProductColumnTypes + { + public int ProductID { get; set; } + + public DateTime Datetime { get; set; } + + public DateTime Datetime2 { get; set; } + } +} diff --git a/test/Database/Tables/ProductsColumnTypes.sql b/test/Database/Tables/ProductsColumnTypes.sql new file mode 100644 index 000000000..a552b9db1 --- /dev/null +++ b/test/Database/Tables/ProductsColumnTypes.sql @@ -0,0 +1,5 @@ +CREATE TABLE [ProductsColumnTypes] ( + [ProductId] [int] NOT NULL PRIMARY KEY, + [Datetime] [datetime], + [Datetime2] [datetime2] +) \ No newline at end of file diff --git a/test/GlobalSuppressions.cs b/test/GlobalSuppressions.cs index 8048b8be3..b6bb5b43d 100644 --- a/test/GlobalSuppressions.cs +++ b/test/GlobalSuppressions.cs @@ -13,3 +13,5 @@ [assembly: SuppressMessage("Style", "IDE0060:Remove unused parameter", Justification = "Unused parameter is required by functions binding", Scope = "member", Target = "~M:Microsoft.Azure.WebJobs.Extensions.Sql.Tests.Integration.AddProductMissingColumns.Run(Microsoft.AspNetCore.Http.HttpRequest,Microsoft.Azure.WebJobs.Extensions.Sql.Tests.Common.ProductMissingColumns@)~Microsoft.AspNetCore.Mvc.IActionResult")] [assembly: SuppressMessage("Style", "IDE0060:Remove unused parameter", Justification = "Unused parameter is required by functions binding", Scope = "member", Target = "~M:Microsoft.Azure.WebJobs.Extensions.Sql.Tests.Integration.AddProductMissingColumnsExceptionFunction.Run(Microsoft.AspNetCore.Http.HttpRequest,Microsoft.Azure.WebJobs.Extensions.Sql.Tests.Common.ProductMissingColumns@)~Microsoft.AspNetCore.Mvc.IActionResult")] [assembly: SuppressMessage("Style", "IDE0060:Remove unused parameter", Justification = "Unused parameter is required by functions binding", Scope = "member", Target = "~M:Microsoft.Azure.WebJobs.Extensions.Sql.Tests.Integration.AddProductsNoPartialUpsert.Run(Microsoft.AspNetCore.Http.HttpRequest,Microsoft.Azure.WebJobs.ICollector{Microsoft.Azure.WebJobs.Extensions.Sql.Samples.Common.Product})~Microsoft.AspNetCore.Mvc.IActionResult")] +[assembly: SuppressMessage("Style", "IDE0060:Remove unused parameter", Justification = "", Scope = "member", Target = "~M:Microsoft.Azure.WebJobs.Extensions.Sql.Samples.InputBindingSamples.GetProductsColumnTypesSerializationDifferentCulture.Run(Microsoft.AspNetCore.Http.HttpRequest,System.Collections.Generic.IAsyncEnumerable{Microsoft.Azure.WebJobs.Extensions.Sql.Tests.Common.ProductColumnTypes},Microsoft.Extensions.Logging.ILogger)~System.Threading.Tasks.Task{Microsoft.AspNetCore.Mvc.IActionResult}")] +[assembly: SuppressMessage("Style", "IDE0060:Remove unused parameter", Justification = "", Scope = "member", Target = "~M:Microsoft.Azure.WebJobs.Extensions.Sql.Samples.InputBindingSamples.GetProductsColumnTypesSerialization.Run(Microsoft.AspNetCore.Http.HttpRequest,System.Collections.Generic.IAsyncEnumerable{Microsoft.Azure.WebJobs.Extensions.Sql.Tests.Common.ProductColumnTypes},Microsoft.Extensions.Logging.ILogger)~System.Threading.Tasks.Task{Microsoft.AspNetCore.Mvc.IActionResult}")] diff --git a/test/Integration/SqlInputBindingIntegrationTests.cs b/test/Integration/SqlInputBindingIntegrationTests.cs index 9dc6532b9..7b63908a1 100644 --- a/test/Integration/SqlInputBindingIntegrationTests.cs +++ b/test/Integration/SqlInputBindingIntegrationTests.cs @@ -130,5 +130,45 @@ public async void GetProductNamesViewTest(SupportedLanguages lang) Assert.Equal(expectedResponse, TestUtils.CleanJsonString(actualResponse), StringComparer.OrdinalIgnoreCase); } + + /// + /// Verifies that serializing an item with various data types works when the language is + /// set to a non-enUS language. + /// + [Theory] + [SqlInlineData()] + [UnsupportedLanguages(SupportedLanguages.JavaScript)] // Javascript doesn't have the concept of a runtime language used during serialization + public async void GetProductsColumnTypesSerializationDifferentCultureTest(SupportedLanguages lang) + { + this.StartFunctionHost(nameof(GetProductsColumnTypesSerializationDifferentCulture), lang, true); + + this.ExecuteNonQuery("INSERT INTO [dbo].[ProductsColumnTypes] VALUES (" + + "999, " + // ProductId + "GETDATE(), " + // Datetime field + "GETDATE())"); // Datetime2 field + + await this.SendInputRequest("getproducts-columntypesserializationdifferentculture"); + + // If we get here the test has succeeded - it'll throw an exception if serialization fails + } + + /// + /// Verifies that serializing an item with various data types works as expected + /// + [Theory] + [SqlInlineData()] + public async void GetProductsColumnTypesSerializationTest(SupportedLanguages lang) + { + this.StartFunctionHost(nameof(GetProductsColumnTypesSerialization), lang, true); + + this.ExecuteNonQuery("INSERT INTO [dbo].[ProductsColumnTypes] VALUES (" + + "999, " + // ProductId + "GETDATE(), " + // Datetime field + "GETDATE())"); // Datetime2 field + + await this.SendInputRequest("getproducts-columntypesserialization"); + + // If we get here the test has succeeded - it'll throw an exception if serialization fails + } } } diff --git a/test/Integration/SqlOutputBindingIntegrationTests.cs b/test/Integration/SqlOutputBindingIntegrationTests.cs index 8f2f693c7..5b2d17d63 100644 --- a/test/Integration/SqlOutputBindingIntegrationTests.cs +++ b/test/Integration/SqlOutputBindingIntegrationTests.cs @@ -99,6 +99,27 @@ public void AddProductArrayTest(SupportedLanguages lang) Assert.Equal(2, this.ExecuteScalar("SELECT ProductId FROM Products WHERE Cost = 12")); } + /// + /// Test compatability with converting various data types to their respective + /// SQL server types. + /// + /// The language to run the test against + [Theory] + [SqlInlineData()] + public void AddProductColumnTypesTest(SupportedLanguages lang) + { + this.StartFunctionHost(nameof(AddProductColumnTypes), lang, true); + + var queryParameters = new Dictionary() + { + { "productId", "999" } + }; + + this.SendOutputGetRequest("addproduct-columntypes", queryParameters).Wait(); + + // If we get here then the test is successful - an exception will be thrown if there were any problems + } + [Theory] [SqlInlineData()] [UnsupportedLanguages(SupportedLanguages.JavaScript)] // Collectors are only available in C# diff --git a/test/Integration/test-csharp/AddProductColumnTypes.cs b/test/Integration/test-csharp/AddProductColumnTypes.cs new file mode 100644 index 000000000..180290ab1 --- /dev/null +++ b/test/Integration/test-csharp/AddProductColumnTypes.cs @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Azure.WebJobs.Extensions.Http; +using Microsoft.Azure.WebJobs.Extensions.Sql.Tests.Common; + +namespace Microsoft.Azure.WebJobs.Extensions.Sql.Tests.Integration +{ + public static class AddProductColumnTypes + { + /// + /// This function is used to test compatability with converting various data types to their respective + /// SQL server types. + /// + [FunctionName(nameof(AddProductColumnTypes))] + public static IActionResult Run( + [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "addproduct-columntypes")] HttpRequest req, + [Sql("dbo.ProductsColumnTypes", ConnectionStringSetting = "SqlConnectionString")] out ProductColumnTypes product) + { + product = new ProductColumnTypes() + { + ProductID = int.Parse(req.Query["productId"]), + Datetime = DateTime.UtcNow, + Datetime2 = DateTime.UtcNow + }; + + // Items were inserted successfully so return success, an exception would be thrown if there + // was any issues + return new OkObjectResult("Success!"); + } + } +} diff --git a/test/Integration/test-csharp/GetProductColumnTypesSerialization.cs b/test/Integration/test-csharp/GetProductColumnTypesSerialization.cs new file mode 100644 index 000000000..836c305ca --- /dev/null +++ b/test/Integration/test-csharp/GetProductColumnTypesSerialization.cs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System.Collections.Generic; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Azure.WebJobs.Extensions.Http; +using Microsoft.Azure.WebJobs.Extensions.Sql.Tests.Common; +using Microsoft.Extensions.Logging; + +namespace Microsoft.Azure.WebJobs.Extensions.Sql.Samples.InputBindingSamples +{ + public static class GetProductsColumnTypesSerialization + { + /// + /// This function verifies that serializing an item with various data types + /// works as expected. + /// Note this uses IAsyncEnumerable because IEnumerable serializes the entire table directly, + /// instead of each item one by one (which is where issues can occur) + /// + [FunctionName(nameof(GetProductsColumnTypesSerialization))] + public static async Task Run( + [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "getproducts-columntypesserialization")] + HttpRequest req, + [Sql("SELECT * FROM [dbo].[ProductsColumnTypes]", + CommandType = System.Data.CommandType.Text, + ConnectionStringSetting = "SqlConnectionString")] + IAsyncEnumerable products, + ILogger log) + { + await foreach (ProductColumnTypes item in products) + { + log.LogInformation(JsonSerializer.Serialize(item)); + } + return new OkObjectResult(products); + } + } +} diff --git a/test/Integration/test-csharp/GetProductColumnTypesSerializationDifferentCulture.cs b/test/Integration/test-csharp/GetProductColumnTypesSerializationDifferentCulture.cs new file mode 100644 index 000000000..9417ba46a --- /dev/null +++ b/test/Integration/test-csharp/GetProductColumnTypesSerializationDifferentCulture.cs @@ -0,0 +1,42 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System.Collections.Generic; +using System.Globalization; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Azure.WebJobs.Extensions.Http; +using Microsoft.Azure.WebJobs.Extensions.Sql.Tests.Common; +using Microsoft.Extensions.Logging; + +namespace Microsoft.Azure.WebJobs.Extensions.Sql.Samples.InputBindingSamples +{ + public static class GetProductsColumnTypesSerializationDifferentCulture + { + /// + /// This function verifies that serializing an item with various data types + /// works when the language is set to a non-enUS language. + /// Note this uses IAsyncEnumerable because IEnumerable serializes the entire table directly, + /// instead of each item one by one (which is where issues can occur) + /// + [FunctionName(nameof(GetProductsColumnTypesSerializationDifferentCulture))] + public static async Task Run( + [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "getproducts-columntypesserializationdifferentculture")] + HttpRequest req, + [Sql("SELECT * FROM [dbo].[ProductsColumnTypes]", + CommandType = System.Data.CommandType.Text, + ConnectionStringSetting = "SqlConnectionString")] + IAsyncEnumerable products, + ILogger log) + { + CultureInfo.CurrentCulture = new CultureInfo("it-IT", false); + await foreach (ProductColumnTypes item in products) + { + log.LogInformation(JsonSerializer.Serialize(item)); + } + return new OkObjectResult(products); + } + } +} diff --git a/test/Integration/test-js/AddProductColumnTypes/function.json b/test/Integration/test-js/AddProductColumnTypes/function.json new file mode 100644 index 000000000..3b69ac6ae --- /dev/null +++ b/test/Integration/test-js/AddProductColumnTypes/function.json @@ -0,0 +1,27 @@ +{ + "bindings": [ + { + "authLevel": "function", + "name": "req", + "direction": "in", + "type": "httpTrigger", + "methods": [ + "get" + ], + "route": "addproduct-columntypes" + }, + { + "name": "$return", + "type": "http", + "direction": "out" + }, + { + "name": "product", + "type": "sql", + "direction": "out", + "commandText": "[dbo].[ProductsColumnTypes]", + "connectionStringSetting": "SqlConnectionString" + } + ], + "disabled": false +} \ No newline at end of file diff --git a/test/Integration/test-js/AddProductColumnTypes/index.js b/test/Integration/test-js/AddProductColumnTypes/index.js new file mode 100644 index 000000000..8d83ac300 --- /dev/null +++ b/test/Integration/test-js/AddProductColumnTypes/index.js @@ -0,0 +1,18 @@ +/** + * This function is used to test compatability with converting various data types to their respective + * SQL server types. + */ +module.exports = async function (context, req) { + const product = { + "productId": req.query.productId, + "datetime": Date.now(), + "datetime2": Date.now() + }; + + context.bindings.product = JSON.stringify(product); + + return { + status: 201, + body: product + }; +} \ No newline at end of file diff --git a/test/Integration/test-js/GetProductsColumnTypesSerialization/function.json b/test/Integration/test-js/GetProductsColumnTypesSerialization/function.json new file mode 100644 index 000000000..0554a9f7d --- /dev/null +++ b/test/Integration/test-js/GetProductsColumnTypesSerialization/function.json @@ -0,0 +1,28 @@ +{ + "bindings": [ + { + "authLevel": "function", + "name": "req", + "type": "httpTrigger", + "direction": "in", + "methods": [ + "get" + ], + "route": "getproducts-columntypesserialization" + }, + { + "name": "$return", + "type": "http", + "direction": "out" + }, + { + "name": "products", + "type": "sql", + "direction": "in", + "commandText": "SELECT * FROM [dbo].[ProductsColumnTypes]", + "commandType": "Text", + "connectionStringSetting": "SqlConnectionString" + } + ], + "disabled": false +} \ No newline at end of file diff --git a/test/Integration/test-js/GetProductsColumnTypesSerialization/index.js b/test/Integration/test-js/GetProductsColumnTypesSerialization/index.js new file mode 100644 index 000000000..732f2c8af --- /dev/null +++ b/test/Integration/test-js/GetProductsColumnTypesSerialization/index.js @@ -0,0 +1,11 @@ +/** + * This function verifies that serializing an item with various data types + * works as expected. + */ +module.exports = async function (context, req, products) { + context.log(JSON.stringify(products)); + return { + status: 200, + body: products + }; +} \ No newline at end of file diff --git a/test/Microsoft.Azure.WebJobs.Extensions.Sql.Tests.csproj b/test/Microsoft.Azure.WebJobs.Extensions.Sql.Tests.csproj index 54c504783..cad70ffbc 100644 --- a/test/Microsoft.Azure.WebJobs.Extensions.Sql.Tests.csproj +++ b/test/Microsoft.Azure.WebJobs.Extensions.Sql.Tests.csproj @@ -15,8 +15,14 @@ - - + + + + + + + Always + @@ -32,13 +38,4 @@ - - - - <_CSharpTestSqlFiles Include="Integration\test-csharp\Database\**\*.*" /> - - - - - From 337fb6468fdbb1839da908d25546f0740aedcf64 Mon Sep 17 00:00:00 2001 From: Charles Gagnon Date: Thu, 1 Sep 2022 11:36:39 -0700 Subject: [PATCH 2/8] Add policheck and var for uploading to TSA (#332) * Add policheck * Upload policheck to TSA codebase * Try globbing files * Scan all * Only upload when var set * post analysis * Only on windows * break on warn * testing * re-enable and break on error * Update notification alias --- builds/TSAConfig.gdntsa | 5 +++-- .../template-steps-build-test.yml | 16 +++++++++++++++- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/builds/TSAConfig.gdntsa b/builds/TSAConfig.gdntsa index 56186440c..11b8aeb1b 100644 --- a/builds/TSAConfig.gdntsa +++ b/builds/TSAConfig.gdntsa @@ -1,7 +1,7 @@ { "codebaseName": "Sql Bindings", "notificationAliases": [ - "sqltools@service.microsoft.com" + "sqlbindings@microsoft.com" ], "codebaseAdmins": [ "REDMOND\\chlafren", @@ -14,6 +14,7 @@ "tools": [ "BinSkim", "RoslynAnalyzers", - "CredScan" + "CredScan", + "Policheck" ] } \ No newline at end of file diff --git a/builds/azure-pipelines/template-steps-build-test.yml b/builds/azure-pipelines/template-steps-build-test.yml index 050f244ef..dfc9f6778 100644 --- a/builds/azure-pipelines/template-steps-build-test.yml +++ b/builds/azure-pipelines/template-steps-build-test.yml @@ -12,6 +12,14 @@ steps: inputs: useGlobalJson: true +# Run Policheck early to avoid scanning dependency folders +- task: securedevelopmentteam.vss-secure-development-tools.build-task-policheck.PoliCheck@2 + displayName: 'Run PoliCheck' + inputs: + targetType: F + result: PoliCheck.xml + condition: and(succeeded(), eq(variables['Agent.OS'], 'Windows_NT')) + - script: npm install -g azure-functions-core-tools displayName: 'Install Azure Functions Core Tools' @@ -135,7 +143,7 @@ steps: inputs: GdnPublishTsaOnboard: true GdnPublishTsaConfigFile: '$(Build.SourcesDirectory)\builds\TSAConfig.gdntsa' - condition: and(succeeded(), eq(variables['Agent.OS'], 'Windows_NT'), ne(variables['Build.Reason'], 'PullRequest')) + condition: and(succeeded(), eq(variables['Agent.OS'], 'Windows_NT'), eq(variables['TSA_UPLOAD'], 'true')) # 5.0 isn't supported on Mac yet - task: UseDotNet@2 @@ -253,3 +261,9 @@ steps: displayName: 'Component Detection' inputs: failOnAlert: true + +- task: securedevelopmentteam.vss-secure-development-tools.build-task-postanalysis.PostAnalysis@2 + displayName: 'Post Analysis' + inputs: + GdnBreakPolicyMinSev: Error + condition: and(succeeded(), eq(variables['Agent.OS'], 'Windows_NT')) From c5a746f7e70de3ed050289d3ab2651a39667abda Mon Sep 17 00:00:00 2001 From: Charles Gagnon Date: Fri, 2 Sep 2022 13:48:02 -0700 Subject: [PATCH 3/8] Fix local build errors (#334) * Fix local build errors * Update comments --- src/SqlAsyncCollector.cs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/SqlAsyncCollector.cs b/src/SqlAsyncCollector.cs index bdb6ee0d8..34c732da3 100644 --- a/src/SqlAsyncCollector.cs +++ b/src/SqlAsyncCollector.cs @@ -305,7 +305,7 @@ private static void GenerateDataQueryForMerge(TableInformation table, IEnumerabl { // SQL Server allows 900 bytes per primary key, so use that as a baseline var combinedPrimaryKey = new StringBuilder(900 * table.PrimaryKeys.Count()); - // Look up primary key of T. Because we're going in the same order of fields every time, + // Look up primary key of T. Because we're going in the same order of properties every time, // we can assume that if two rows with the same primary key are in the list, they will collide foreach (PropertyInfo primaryKey in table.PrimaryKeys) { @@ -351,7 +351,7 @@ public class TableInformation { private const string ISO_8061_DATETIME_FORMAT = "yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff"; - public IEnumerable PrimaryKeys { get; } + public IEnumerable PrimaryKeys { get; } /// /// All of the columns, along with their data types, for SQL to use to turn JSON into a table @@ -385,7 +385,7 @@ public class TableInformation /// public JsonSerializerSettings JsonSerializerSettings { get; } - public TableInformation(IEnumerable primaryKeys, IDictionary columns, StringComparer comparer, string query, bool hasIdentityColumnPrimaryKeys) + public TableInformation(IEnumerable primaryKeys, IDictionary columns, StringComparer comparer, string query, bool hasIdentityColumnPrimaryKeys) { this.PrimaryKeys = primaryKeys; this.Columns = columns; @@ -622,9 +622,9 @@ public static async Task RetrieveTableInformationAsync(SqlConn throw ex; } - // Match SQL Primary Key column names to POCO field/property objects. Ensure none are missing. + // Match SQL Primary Key column names to POCO property objects. Ensure none are missing. StringComparison comparison = caseSensitive ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase; - IEnumerable primaryKeyFields = typeof(T).GetMembers().Where(f => primaryKeys.Any(k => string.Equals(k.Name, f.Name, comparison))); + IEnumerable primaryKeyProperties = typeof(T).GetProperties().Where(f => primaryKeys.Any(k => string.Equals(k.Name, f.Name, comparison))); IEnumerable primaryKeysFromObject = columnNames.Where(f => primaryKeys.Any(k => string.Equals(k.Name, f, comparison))); IEnumerable missingPrimaryKeysFromItem = primaryKeys .Where(k => !primaryKeysFromObject.Contains(k.Name, comparer)); @@ -654,8 +654,8 @@ public static async Task RetrieveTableInformationAsync(SqlConn sqlConnProps.Add(TelemetryPropertyName.QueryType, usingInsertQuery ? "insert" : "merge"); sqlConnProps.Add(TelemetryPropertyName.HasIdentityColumn, hasIdentityColumnPrimaryKeys.ToString()); TelemetryInstance.TrackDuration(TelemetryEventName.GetTableInfoEnd, tableInfoSw.ElapsedMilliseconds, sqlConnProps, durations); - logger.LogDebugWithThreadId($"END RetrieveTableInformationAsync Duration={tableInfoSw.ElapsedMilliseconds}ms DB and Table: {sqlConnection.Database}.{fullName}. Primary keys: [{string.Join(",", primaryKeyFields.Select(pk => pk.Name))}]. SQL Column and Definitions: [{string.Join(",", columnDefinitionsFromSQL)}]"); - return new TableInformation(primaryKeyFields, columnDefinitionsFromSQL, comparer, query, hasIdentityColumnPrimaryKeys); + logger.LogDebugWithThreadId($"END RetrieveTableInformationAsync Duration={tableInfoSw.ElapsedMilliseconds}ms DB and Table: {sqlConnection.Database}.{fullName}. Primary keys: [{string.Join(",", primaryKeyProperties.Select(pk => pk.Name))}]. SQL Column and Definitions: [{string.Join(",", columnDefinitionsFromSQL)}]"); + return new TableInformation(primaryKeyProperties, columnDefinitionsFromSQL, comparer, query, hasIdentityColumnPrimaryKeys); } } From a916d353b0f04dad8f66cbcc1e8944bf512fc8c2 Mon Sep 17 00:00:00 2001 From: Charles Gagnon Date: Fri, 2 Sep 2022 13:48:12 -0700 Subject: [PATCH 4/8] Fix log error not printing out primary key name (#335) --- src/SqlAsyncCollector.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/SqlAsyncCollector.cs b/src/SqlAsyncCollector.cs index 34c732da3..508f82138 100644 --- a/src/SqlAsyncCollector.cs +++ b/src/SqlAsyncCollector.cs @@ -35,6 +35,11 @@ public PrimaryKey(string name, bool isIdentity) this.Name = name; this.IsIdentity = isIdentity; } + + public override string ToString() + { + return this.Name; + } } /// A user-defined POCO that represents a row of the user's table From b680ca888c2eb9706f2b6d614cf5ba62192cdfc8 Mon Sep 17 00:00:00 2001 From: Charles Gagnon Date: Tue, 6 Sep 2022 11:21:26 -0700 Subject: [PATCH 5/8] Don't run Roslyn analyzers on PRs (#336) * Don't run Roslyn analyzers on PRs * fix --- builds/azure-pipelines/template-steps-build-test.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/builds/azure-pipelines/template-steps-build-test.yml b/builds/azure-pipelines/template-steps-build-test.yml index dfc9f6778..40b6b9165 100644 --- a/builds/azure-pipelines/template-steps-build-test.yml +++ b/builds/azure-pipelines/template-steps-build-test.yml @@ -92,12 +92,13 @@ steps: arguments: 'analyze $(Build.SourcesDirectory)\src\bin\${{ parameters.configuration }}\* --recurse --verbose' condition: and(succeeded(), eq(variables['Agent.OS'], 'Windows_NT')) +# Don't run for PRs since this currently breaks on runs from forks. We run this daily ourselves anyways. - task: securedevelopmentteam.vss-secure-development-tools.build-task-roslynanalyzers.RoslynAnalyzers@3 inputs: userProvideBuildInfo: 'autoMsBuildInfo' env: SYSTEM_ACCESSTOKEN: $(System.AccessToken) - condition: and(succeeded(), eq(variables['Agent.OS'], 'Windows_NT')) + condition: and(succeeded(), eq(variables['Agent.OS'], 'Windows_NT'), ne(variables['Build.Reason'], 'PullRequest')) - task: securedevelopmentteam.vss-secure-development-tools.build-task-credscan.CredScan@2 inputs: From 3a92c76819cc5bf7bb931258c18bc0051dd8df55 Mon Sep 17 00:00:00 2001 From: Charles Gagnon Date: Fri, 9 Sep 2022 11:24:38 -0700 Subject: [PATCH 6/8] Fix README link (#342) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index c2d9b1b80..c34c154ba 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ Azure SQL bindings for Azure Functions are supported for: - [Stored Procedure](#stored-procedure) - [IAsyncEnumerable](#iasyncenumerable) - [Output Binding](#output-binding) - - [ICollector/IAsyncCollector](#icollectortiasynccollectort) + - [ICollector/IAsyncCollector](#icollectoriasynccollector) - [Array](#array) - [Single Row](#single-row) - [Primary Keys and Identity Columns](#primary-keys-and-identity-columns) From 4da4c388079c8b03723d30b4c9586c5409c724ac Mon Sep 17 00:00:00 2001 From: Jatin Sanghvi <20547963+JatinSanghvi@users.noreply.github.com> Date: Tue, 13 Sep 2022 20:12:15 +0530 Subject: [PATCH 7/8] Escape special characters in Readme file (#346) --- README.md | 27 +++++++++++++-------------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index c34c154ba..be424d058 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ Azure SQL bindings for Azure Functions are supported for: - [Stored Procedure](#stored-procedure) - [IAsyncEnumerable](#iasyncenumerable) - [Output Binding](#output-binding) - - [ICollector/IAsyncCollector](#icollectoriasynccollector) + - [ICollector<T>/IAsyncCollector<T>](#icollectortiasynccollectort) - [Array](#array) - [Single Row](#single-row) - [Primary Keys and Identity Columns](#primary-keys-and-identity-columns) @@ -94,7 +94,7 @@ ALTER TABLE ['{table_name}'] ADD CONSTRAINT PKey PRIMARY KEY CLUSTERED (['{prima ### Create a Function App -Now you will need a a Function App to add the binding to. If you have one created already you can skip this step. +Now you will need a Function App to add the binding to. If you have one created already you can skip this step. These steps can be done in the Terminal/CLI or with PowerShell. @@ -132,7 +132,6 @@ These steps can be done in the Terminal/CLI or with PowerShell. func init --worker-runtime python ``` - 3. Enable SQL bindings on the function app. More information can be found [in Microsoft Docs](https://docs.microsoft.com/azure/azure-functions/functions-bindings-azure-sql). **.NET:** Install the extension. @@ -149,7 +148,7 @@ These steps can be done in the Terminal/CLI or with PowerShell. ``` **Python:** - + Update the `host.json` file to the preview extension bundle. ```json "extensionBundle": { @@ -229,7 +228,7 @@ Note: This tutorial requires that a SQL database is setup as shown in [Create a - Open your app that you created in [Create a Function App](#create-a-function-app) in VSCode - Press 'F1' and search for 'Azure Functions: Create Function' - Choose HttpTrigger -> (Provide a function name) -> Company.namespace -> anonymous -- In the file that opens, replace the 'public static async Task< IActionResult > Run' block with the below code. +- In the file that opens, replace the `public static async Task Run` block with the below code. ```csharp public static async Task Run( @@ -277,7 +276,7 @@ Note: This tutorial requires that a SQL database is setup as shown in [Create a - Open your app in VSCode - Press 'F1' and search for 'Azure Functions: Create Function' - Choose HttpTrigger -> (Provide a function name) -> Company.namespace is fine -> anonymous -- In the file that opens, replace the 'public static async Task Run' block with the below code +- In the file that opens, replace the `public static async Task Run` block with the below code ```csharp public static IActionResult Run( @@ -326,7 +325,7 @@ Note: This tutorial requires that a SQL database is setup as shown in [Create a - Open your app that you created in [Create a Function App](#create-a-function-app) in VSCode - Press 'F1' and search for 'Azure Functions: Create Function' - Choose HttpTrigger -> (Provide a function name) -> anonymous -- In the file that opens (index.js), replace the 'module.exports = async function (context, req)' block with the below code. +- In the file that opens (`index.js`), replace the `module.exports = async function (context, req)` block with the below code. ```javascript module.exports = async function (context, req, employee) { @@ -366,7 +365,7 @@ Note: This tutorial requires that a SQL database is setup as shown in [Create a - Open your app in VSCode - Press 'F1' and search for 'Azure Functions: Create Function' - Choose HttpTrigger -> (Provide a function name) -> anonymous -- In the file that opens (index.js), replace the 'module.exports = async function (context, req)' block with the below code. +- In the file that opens (`index.js`), replace the `module.exports = async function (context, req)` block with the below code. ```javascript module.exports = async function (context, req) { @@ -421,7 +420,7 @@ Note: This tutorial requires that a SQL database is setup as shown in [Create a - Open your app that you created in [Create a Function App](#create-a-function-app) in VSCode - Press 'F1' and search for 'Azure Functions: Create Function' - Choose HttpTrigger -> (Provide a function name) -> anonymous -- In the file that opens (__init__.py), replace the 'def main(req: func.HttpRequest) -> func.HttpResponse:' block with the below code. +- In the file that opens (`__init__.py`), replace the `def main(req: func.HttpRequest) -> func.HttpResponse:` block with the below code. ```python def main(req: func.HttpRequest, employee: func.SqlRowList) -> func.HttpResponse: @@ -464,7 +463,7 @@ Note: This tutorial requires that a SQL database is setup as shown in [Create a - Open your app in VSCode - Press 'F1' and search for 'Azure Functions: Create Function' - Choose HttpTrigger -> (Provide a function name) -> anonymous -- In the file that opens (__init__.py), replace the 'def main(req: func.HttpRequest) -> func.HttpResponse:' block with the below code. +- In the file that opens (`__init__.py`), replace the `def main(req: func.HttpRequest) -> func.HttpResponse:` block with the below code. ```python def main(req: func.HttpRequest, employee: func.Out[func.SqlRow]) -> func.HttpResponse: @@ -516,8 +515,8 @@ The input binding takes four [arguments](https://github.com/Azure/azure-function The following are valid binding types for the result of the query/stored procedure execution: -- **IEnumerable**: Each element is a row of the result represented by `T`, where `T` is a user-defined POCO, or Plain Old C# Object. `T` should follow the structure of a row in the queried table. See the [Query String](#query-string) section for an example of what `T` should look like. -- **IAsyncEnumerable**: Each element is again a row of the result represented by `T`, but the rows are retrieved "lazily". A row of the result is only retrieved when `MoveNextAsync` is called on the enumerator. This is useful in the case that the query can return a very large amount of rows. +- **IEnumerable<T>**: Each element is a row of the result represented by `T`, where `T` is a user-defined POCO, or Plain Old C# Object. `T` should follow the structure of a row in the queried table. See the [Query String](#query-string) section for an example of what `T` should look like. +- **IAsyncEnumerable<T>**: Each element is again a row of the result represented by `T`, but the rows are retrieved "lazily". A row of the result is only retrieved when `MoveNextAsync` is called on the enumerator. This is useful in the case that the query can return a very large amount of rows. - **String**: A JSON string representation of the rows of the result (an example is provided [here](https://github.com/Azure/azure-functions-sql-extension/blob/main/samples/samples-csharp/InputBindingSamples/GetProductsString.cs)). - **SqlCommand**: The SqlCommand is populated with the appropriate query and parameters, but the associated connection is not opened. It is the responsiblity of the user to execute the command and read in the results. This is useful in the case that the user wants more control over how the results are read in. An example is provided [here](https://github.com/Azure/azure-functions-sql-extension/blob/main/samples/samples-csharp/InputBindingSamples/GetProductsSqlCommand.cs). @@ -657,13 +656,13 @@ The output binding takes two [arguments](https://github.com/Azure/azure-function The following are valid binding types for the rows to be upserted into the table: -- **ICollector/IAsyncCollector**: Each element is a row represented by `T`, where `T` is a user-defined POCO, or Plain Old C# Object. `T` should follow the structure of a row in the queried table. See the [Query String](#query-string) for an example of what `T` should look like. +- **ICollector<T>/IAsyncCollector<T>**: Each element is a row represented by `T`, where `T` is a user-defined POCO, or Plain Old C# Object. `T` should follow the structure of a row in the queried table. See the [Query String](#query-string) for an example of what `T` should look like. - **T**: Used when just one row is to be upserted into the table. - **T[]**: Each element is again a row of the result represented by `T`. This output binding type requires manual instantiation of the array in the function. The repo contains examples of each of these binding types [here](https://github.com/Azure/azure-functions-sql-extension/tree/main/samples/samples-csharp/OutputBindingSamples). A few examples are also included below. -#### ICollector/IAsyncCollector +#### ICollector<T>/IAsyncCollector<T> When using an `ICollector`, it is not necessary to instantiate it. The function can add rows to the `ICollector` directly, and its contents are automatically upserted once the function exits. From dc40a5f124712f5d25cba0ad50243882e70e05cc Mon Sep 17 00:00:00 2001 From: Maddy <12754347+MaddyDev@users.noreply.github.com> Date: Wed, 14 Sep 2022 11:29:13 -0700 Subject: [PATCH 8/8] sql version telemetry data from input bindings (#337) * connection info telemetry data frm input bindings * capture convert event after collecting connection * remove event * first collect telemetry * fix tests * make convert type optional * update xml * add telemetry service * clean up * remove ITelemetryService * undo changes * merge conflict * missed merge conflict * extra line * add null check * capture telemetry after opening connection * correct connection * move telemtery after connection.OpenAsync * open connection on initialize * add xml * address comments * refactor test * add xml comment * update xml * add check back * add connection state check * remove explicit checks * add isDisposed * add comments * revert isDisposed changes * add comment with github issue link --- src/SqlAsyncEnumerable.cs | 38 +++++++++++++++---------------- src/SqlConverters.cs | 22 ++++++++++-------- test/Unit/SqlInputBindingTests.cs | 20 ++++++++-------- 3 files changed, 43 insertions(+), 37 deletions(-) diff --git a/src/SqlAsyncEnumerable.cs b/src/SqlAsyncEnumerable.cs index 86519f39a..9b10eab81 100644 --- a/src/SqlAsyncEnumerable.cs +++ b/src/SqlAsyncEnumerable.cs @@ -7,13 +7,12 @@ using System.Threading.Tasks; using Microsoft.Data.SqlClient; using Newtonsoft.Json; - namespace Microsoft.Azure.WebJobs.Extensions.Sql { /// A user-defined POCO that represents a row of the user's table internal class SqlAsyncEnumerable : IAsyncEnumerable { - private readonly SqlConnection _connection; + public SqlConnection Connection { get; private set; } private readonly SqlAttribute _attribute; /// @@ -26,8 +25,9 @@ internal class SqlAsyncEnumerable : IAsyncEnumerable /// public SqlAsyncEnumerable(SqlConnection connection, SqlAttribute attribute) { - this._connection = connection ?? throw new ArgumentNullException(nameof(connection)); + this.Connection = connection ?? throw new ArgumentNullException(nameof(connection)); this._attribute = attribute ?? throw new ArgumentNullException(nameof(attribute)); + this.Connection.Open(); } /// /// Returns the enumerator associated with this enumerable. The enumerator will execute the query specified @@ -38,7 +38,7 @@ public SqlAsyncEnumerable(SqlConnection connection, SqlAttribute attribute) /// The enumerator public IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = default) { - return new SqlAsyncEnumerator(this._connection, this._attribute); + return new SqlAsyncEnumerator(this.Connection, this._attribute); } @@ -47,7 +47,6 @@ private class SqlAsyncEnumerator : IAsyncEnumerator private readonly SqlConnection _connection; private readonly SqlAttribute _attribute; private SqlDataReader _reader; - /// /// Initializes a new instance of the "/> class. /// @@ -77,7 +76,7 @@ public SqlAsyncEnumerator(SqlConnection connection, SqlAttribute attribute) public ValueTask DisposeAsync() { // Doesn't seem like there's an async version of closing the reader/connection - this._reader.Close(); + this._reader?.Close(); this._connection.Close(); return new ValueTask(Task.CompletedTask); } @@ -101,23 +100,24 @@ public ValueTask MoveNextAsync() /// private async Task GetNextRowAsync() { - if (this._reader == null) + // check connection state before trying to access the reader + // if DisposeAsync has already closed it due to the issue described here https://github.com/Azure/azure-functions-sql-extension/issues/350 + if (this._connection.State != System.Data.ConnectionState.Closed) { - using (SqlCommand command = SqlBindingUtilities.BuildCommand(this._attribute, this._connection)) + if (this._reader == null) { - await command.Connection.OpenAsync(); - this._reader = await command.ExecuteReaderAsync(); + using (SqlCommand command = SqlBindingUtilities.BuildCommand(this._attribute, this._connection)) + { + this._reader = await command.ExecuteReaderAsync(); + } + } + if (await this._reader.ReadAsync()) + { + this.Current = JsonConvert.DeserializeObject(this.SerializeRow()); + return true; } } - if (await this._reader.ReadAsync()) - { - this.Current = JsonConvert.DeserializeObject(this.SerializeRow()); - return true; - } - else - { - return false; - } + return false; } /// diff --git a/src/SqlConverters.cs b/src/SqlConverters.cs index 3b58f1e3e..628647dfa 100644 --- a/src/SqlConverters.cs +++ b/src/SqlConverters.cs @@ -105,12 +105,11 @@ public SqlGenericsConverter(IConfiguration configuration, ILogger logger) /// An IEnumerable containing the rows read from the user's database in the form of the user-defined POCO public async Task> ConvertAsync(SqlAttribute attribute, CancellationToken cancellationToken) { - TelemetryInstance.TrackConvert(ConvertType.IEnumerable); this._logger.LogDebugWithThreadId("BEGIN ConvertAsync (IEnumerable)"); var sw = Stopwatch.StartNew(); try { - string json = await this.BuildItemFromAttributeAsync(attribute); + string json = await this.BuildItemFromAttributeAsync(attribute, ConvertType.IEnumerable); IEnumerable result = JsonConvert.DeserializeObject>(json); this._logger.LogDebugWithThreadId($"END ConvertAsync (IEnumerable) Duration={sw.ElapsedMilliseconds}ms"); return result; @@ -140,12 +139,11 @@ public async Task> ConvertAsync(SqlAttribute attribute, Cancellat /// async Task IAsyncConverter.ConvertAsync(SqlAttribute attribute, CancellationToken cancellationToken) { - TelemetryInstance.TrackConvert(ConvertType.Json); this._logger.LogDebugWithThreadId("BEGIN ConvertAsync (Json)"); var sw = Stopwatch.StartNew(); try { - string result = await this.BuildItemFromAttributeAsync(attribute); + string result = await this.BuildItemFromAttributeAsync(attribute, ConvertType.Json); this._logger.LogDebugWithThreadId($"END ConvertAsync (Json) Duration={sw.ElapsedMilliseconds}ms"); return result; } @@ -167,8 +165,11 @@ async Task IAsyncConverter.ConvertAsync(SqlAttribu /// /// The binding attribute that contains the name of the connection string app setting and query. /// + /// + /// The type of conversion being performed by the input binding. + /// /// - public virtual async Task BuildItemFromAttributeAsync(SqlAttribute attribute) + public virtual async Task BuildItemFromAttributeAsync(SqlAttribute attribute, ConvertType type) { using (SqlConnection connection = SqlBindingUtilities.BuildConnection(attribute.ConnectionStringSetting, this._configuration)) // Ideally, we would like to move away from using SqlDataAdapter both here and in the @@ -178,6 +179,8 @@ public virtual async Task BuildItemFromAttributeAsync(SqlAttribute attri { adapter.SelectCommand = command; await connection.OpenAsync(); + Dictionary props = connection.AsConnectionProps(); + TelemetryInstance.TrackConvert(type, props); var dataTable = new DataTable(); adapter.Fill(dataTable); this._logger.LogInformation($"{dataTable.Rows.Count} row(s) queried from database: {connection.Database} using Command: {command.CommandText}"); @@ -188,10 +191,12 @@ public virtual async Task BuildItemFromAttributeAsync(SqlAttribute attri IAsyncEnumerable IConverter>.Convert(SqlAttribute attribute) { - TelemetryInstance.TrackConvert(ConvertType.IAsyncEnumerable); try { - return new SqlAsyncEnumerable(SqlBindingUtilities.BuildConnection(attribute.ConnectionStringSetting, this._configuration), attribute); + var asyncEnumerable = new SqlAsyncEnumerable(SqlBindingUtilities.BuildConnection(attribute.ConnectionStringSetting, this._configuration), attribute); + Dictionary props = asyncEnumerable.Connection.AsConnectionProps(); + TelemetryInstance.TrackConvert(ConvertType.IAsyncEnumerable, props); + return asyncEnumerable; } catch (Exception ex) { @@ -214,10 +219,9 @@ IAsyncEnumerable IConverter>.Convert(SqlAtt /// JArray containing the rows read from the user's database in the form of the user-defined POCO async Task IAsyncConverter.ConvertAsync(SqlAttribute attribute, CancellationToken cancellationToken) { - TelemetryInstance.TrackConvert(ConvertType.JArray); try { - string json = await this.BuildItemFromAttributeAsync(attribute); + string json = await this.BuildItemFromAttributeAsync(attribute, ConvertType.JArray); return JArray.Parse(json); } catch (Exception ex) diff --git a/test/Unit/SqlInputBindingTests.cs b/test/Unit/SqlInputBindingTests.cs index c0fb6bac5..85a30e29b 100644 --- a/test/Unit/SqlInputBindingTests.cs +++ b/test/Unit/SqlInputBindingTests.cs @@ -12,6 +12,7 @@ using Moq; using Xunit; using Microsoft.Azure.WebJobs.Extensions.Sql.Tests.Common; +using Microsoft.Azure.WebJobs.Extensions.Sql.Telemetry; namespace Microsoft.Azure.WebJobs.Extensions.Sql.Tests.Unit { @@ -60,17 +61,18 @@ public void TestNullCommand() [Fact] public void TestNullArgumentsSqlAsyncEnumerableConstructor() { - Assert.Throws(() => new SqlAsyncEnumerable(connection, null)); Assert.Throws(() => new SqlAsyncEnumerable(null, new SqlAttribute(""))); } + /// + /// SqlAsyncEnumerable should throw InvalidOperationExcepion when invoked with an invalid connection + /// string setting and It should fail here since we're passing an empty connection string. + /// [Fact] - public void TestNullCurrentValueEnumerator() + public void TestInvalidOperationSqlAsyncEnumerableConstructor() { - var enumerable = new SqlAsyncEnumerable(connection, new SqlAttribute("")); - IAsyncEnumerator enumerator = enumerable.GetAsyncEnumerator(); - Assert.Null(enumerator.Current); + Assert.Throws(() => new SqlAsyncEnumerable(connection, new SqlAttribute(""))); } [Fact] @@ -230,7 +232,7 @@ public async void TestWellformedDeserialization() var converter = new Mock>(config.Object, logger.Object); string json = "[{ \"ID\":1,\"Name\":\"Broom\",\"Cost\":32.5,\"Timestamp\":\"2019-11-22T06:32:15\"},{ \"ID\":2,\"Name\":\"Brush\",\"Cost\":12.3," + "\"Timestamp\":\"2017-01-27T03:13:11\"},{ \"ID\":3,\"Name\":\"Comb\",\"Cost\":100.12,\"Timestamp\":\"1997-05-03T10:11:56\"}]"; - converter.Setup(_ => _.BuildItemFromAttributeAsync(arg)).ReturnsAsync(json); + converter.Setup(_ => _.BuildItemFromAttributeAsync(arg, ConvertType.IEnumerable)).ReturnsAsync(json); var list = new List(); var data1 = new TestData { @@ -268,7 +270,7 @@ public async void TestMalformedDeserialization() // SQL data is missing a field string json = "[{ \"ID\":1,\"Name\":\"Broom\",\"Timestamp\":\"2019-11-22T06:32:15\"}]"; - converter.Setup(_ => _.BuildItemFromAttributeAsync(arg)).ReturnsAsync(json); + converter.Setup(_ => _.BuildItemFromAttributeAsync(arg, ConvertType.IEnumerable)).ReturnsAsync(json); var list = new List(); var data = new TestData { @@ -283,7 +285,7 @@ public async void TestMalformedDeserialization() // SQL data's columns are named differently than the POCO's fields json = "[{ \"ID\":1,\"Product Name\":\"Broom\",\"Price\":32.5,\"Timessstamp\":\"2019-11-22T06:32:15\"}]"; - converter.Setup(_ => _.BuildItemFromAttributeAsync(arg)).ReturnsAsync(json); + converter.Setup(_ => _.BuildItemFromAttributeAsync(arg, ConvertType.IEnumerable)).ReturnsAsync(json); list = new List(); data = new TestData { @@ -297,7 +299,7 @@ public async void TestMalformedDeserialization() // Confirm that the JSON fields are case-insensitive (technically malformed string, but still works) json = "[{ \"id\":1,\"nAme\":\"Broom\",\"coSt\":32.5,\"TimEStamp\":\"2019-11-22T06:32:15\"}]"; - converter.Setup(_ => _.BuildItemFromAttributeAsync(arg)).ReturnsAsync(json); + converter.Setup(_ => _.BuildItemFromAttributeAsync(arg, ConvertType.IEnumerable)).ReturnsAsync(json); list = new List(); data = new TestData {