Skip to content

Commit 1f22e56

Browse files
authored
Allow columns with default values to be excluded from the POCO (#388)
* allow missing default columns in poco * add test * query format * add default column section to readme * fix wording
1 parent 8bdf54a commit 1f22e56

File tree

8 files changed

+143
-19
lines changed

8 files changed

+143
-19
lines changed

README.md

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ Azure SQL bindings for Azure Functions are supported for:
4343
- [ICollector<T>/IAsyncCollector<T>](#icollectortiasynccollectort)
4444
- [Array](#array)
4545
- [Single Row](#single-row)
46-
- [Primary Keys and Identity Columns](#primary-keys-and-identity-columns)
46+
- [Primary Key Special Cases](#primary-key-special-cases)
4747
- [Known Issues](#known-issues)
4848
- [Telemetry](#telemetry)
4949
- [Trademarks](#trademarks)
@@ -756,20 +756,26 @@ public static IActionResult Run(
756756
}
757757
```
758758
759-
#### Primary Keys and Identity Columns
759+
#### Primary Key Special Cases
760760
761761
Normally Output Bindings require two things :
762762
763763
1. The table being upserted to contains a Primary Key constraint (composed of one or more columns)
764764
2. Each of those columns must be present in the POCO object used in the attribute
765765
766-
If either of these are false then an error will be thrown.
766+
Normally if either of these are false then an error will be thrown. Below are the situations in which this is not the case :
767767
768-
This changes if one of the primary key columns is an identity column though. In that case there are two options based on how the function defines the output object:
768+
##### Identity Columns
769+
In the case where one of the primary key columns is an identity column, there are two options based on how the function defines the output object:
769770
770771
1. If the identity column isn't included in the output object then a straight insert is always performed with the other column values. See [AddProductWithIdentityColumn](./samples/samples-csharp/OutputBindingSamples/AddProductWithIdentityColumn.cs) for an example.
771772
2. If the identity column is included (even if it's an optional nullable value) then a merge is performed similar to what happens when no identity column is present. This merge will either insert a new row or update an existing row based on the existence of a row that matches the primary keys (including the identity column). See [AddProductWithIdentityColumnIncluded](./samples/samples-csharp/OutputBindingSamples/AddProductWithIdentityColumnIncluded.cs) for an example.
772773
774+
##### Columns with Default Values
775+
In the case where one of the primary key columns has a default value, there are also two options based on how the function defines the output object:
776+
1. If the column with a default value is not included in the output object, then a straight insert is always performed with the other values. See [AddProductWithDefaultPK](./samples/samples-csharp/OutputBindingSamples/AddProductWithDefaultPK.cs) for an example.
777+
2. If the column with a default value is included then a merge is performed similar to what happens when no default column is present. If there is a nullable column with a default value, then the provided column value in the output object will be upserted even if it is null.
778+
773779
## Known Issues
774780
775781
- Output bindings against tables with columns of data types `NTEXT`, `TEXT`, or `IMAGE` are not supported and data upserts will fail. These types [will be removed](https://docs.microsoft.com/sql/t-sql/data-types/ntext-text-and-image-transact-sql) in a future version of SQL Server and are not compatible with the `OPENJSON` function used by this Azure Functions binding.

samples/samples-csharp/Common/Product.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,4 +25,11 @@ public class ProductName
2525
{
2626
public string Name { get; set; }
2727
}
28+
29+
public class ProductWithDefaultPK
30+
{
31+
public string Name { get; set; }
32+
33+
public int Cost { get; set; }
34+
}
2835
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
CREATE TABLE [ProductsWithDefaultPK] (
2+
[ProductGuid] [uniqueidentifier] PRIMARY KEY NOT NULL DEFAULT(newsequentialid()),
3+
[Name] [nvarchar](100) NULL,
4+
[Cost] [int] NULL
5+
)
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License. See License.txt in the project root for license information.
3+
4+
using Microsoft.AspNetCore.Mvc;
5+
using Microsoft.Azure.WebJobs.Extensions.Http;
6+
using Microsoft.Azure.WebJobs.Extensions.Sql.Samples.Common;
7+
8+
namespace Microsoft.Azure.WebJobs.Extensions.Sql.Samples.OutputBindingSamples
9+
{
10+
11+
public static class AddProductWithDefaultPK
12+
{
13+
/// <summary>
14+
/// This shows an example of a SQL Output binding where the target table has a default primary key
15+
/// of type uniqueidentifier and the column is not included in the output object. A new row will
16+
/// be inserted and the uniqueidentifier will be generated by the engine.
17+
/// </summary>
18+
/// <param name="req">The original request that triggered the function</param>
19+
/// <param name="product">The created ProductWithDefaultPK object</param>
20+
/// <returns>The CreatedResult containing the new object that was inserted</returns>
21+
[FunctionName(nameof(AddProductWithDefaultPK))]
22+
public static IActionResult Run(
23+
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "addproductwithdefaultpk")]
24+
[FromBody] ProductWithDefaultPK product,
25+
[Sql("dbo.ProductsWithDefaultPK", ConnectionStringSetting = "SqlConnectionString")] out ProductWithDefaultPK output)
26+
{
27+
output = product;
28+
return new CreatedResult($"/api/addproductwithdefaultpk", output);
29+
}
30+
}
31+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
{
2+
"bindings": [
3+
{
4+
"authLevel": "function",
5+
"name": "req",
6+
"direction": "in",
7+
"type": "httpTrigger",
8+
"methods": [
9+
"post"
10+
],
11+
"route": "addproductwithdefaultpk"
12+
},
13+
{
14+
"name": "$return",
15+
"type": "http",
16+
"direction": "out"
17+
},
18+
{
19+
"name": "products",
20+
"type": "sql",
21+
"direction": "out",
22+
"commandText": "[dbo].[ProductsWithDefaultPK]",
23+
"connectionStringSetting": "SqlConnectionString"
24+
}
25+
],
26+
"disabled": false
27+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License. See License.txt in the project root for license information.
3+
4+
// This shows an example of a SQL Output binding where the target table has a default primary key
5+
// of type uniqueidentifier and the column is not included in the output object. A new row will
6+
// be inserted and the uniqueidentifier will be generated by the engine.
7+
module.exports = async function (context, req) {
8+
// Note that this expects the body to be a JSON object
9+
// matching each of the columns in the table to upsert to.
10+
context.bindings.products = req.body;
11+
12+
return {
13+
status: 201,
14+
body: req.body
15+
};
16+
}

src/SqlAsyncCollector.cs

Lines changed: 27 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,13 @@ internal class PrimaryKey
3030

3131
public readonly bool IsIdentity;
3232

33-
public PrimaryKey(string name, bool isIdentity)
33+
public readonly bool HasDefault;
34+
35+
public PrimaryKey(string name, bool isIdentity, bool hasDefault)
3436
{
3537
this.Name = name;
3638
this.IsIdentity = isIdentity;
39+
this.HasDefault = hasDefault;
3740
}
3841

3942
public override string ToString()
@@ -49,6 +52,7 @@ internal class SqlAsyncCollector<T> : IAsyncCollector<T>, IDisposable
4952
private const string ColumnName = "COLUMN_NAME";
5053
private const string ColumnDefinition = "COLUMN_DEFINITION";
5154

55+
private const string HasDefault = "has_default";
5256
private const string IsIdentity = "is_identity";
5357
private const string CteName = "cte";
5458

@@ -170,7 +174,7 @@ private async Task UpsertRowsAsync(IEnumerable<T> rows, SqlAttribute attribute,
170174
if (tableInfo == null)
171175
{
172176
TelemetryInstance.TrackEvent(TelemetryEventName.TableInfoCacheMiss, props);
173-
// set the columnNames for supporting T as JObject since it doesn't have columns in the memeber info.
177+
// set the columnNames for supporting T as JObject since it doesn't have columns in the member info.
174178
tableInfo = await TableInformation.RetrieveTableInformationAsync(connection, fullTableName, this._logger, GetColumnNamesFromItem(rows.First()));
175179
var policy = new CacheItemPolicy
176180
{
@@ -426,13 +430,20 @@ public static string GetPrimaryKeysQuery(SqlObject table)
426430
{
427431
return $@"
428432
SELECT
429-
{ColumnName}, c.is_identity
433+
ccu.{ColumnName},
434+
c.is_identity,
435+
case
436+
when isc.COLUMN_DEFAULT = NULL then 'false'
437+
else 'true'
438+
end as {HasDefault}
430439
FROM
431440
INFORMATION_SCHEMA.TABLE_CONSTRAINTS tc
432441
INNER JOIN
433442
INFORMATION_SCHEMA.CONSTRAINT_COLUMN_USAGE ccu ON ccu.CONSTRAINT_NAME = tc.CONSTRAINT_NAME AND ccu.TABLE_NAME = tc.TABLE_NAME
434443
INNER JOIN
435444
sys.columns c ON c.object_id = OBJECT_ID({table.QuotedFullName}) AND c.name = ccu.COLUMN_NAME
445+
INNER JOIN
446+
INFORMATION_SCHEMA.COLUMNS isc ON isc.TABLE_NAME = {table.QuotedName} AND isc.COLUMN_NAME = ccu.COLUMN_NAME
436447
WHERE
437448
tc.CONSTRAINT_TYPE = 'PRIMARY KEY'
438449
and
@@ -464,15 +475,15 @@ INFORMATION_SCHEMA.COLUMNS c
464475
c.TABLE_SCHEMA = {table.QuotedSchema}";
465476
}
466477

467-
public static string GetInsertQuery(SqlObject table)
478+
public static string GetInsertQuery(SqlObject table, IEnumerable<string> bracketedColumnNamesFromItem)
468479
{
469-
return $"INSERT INTO {table.BracketQuotedFullName} SELECT * FROM {CteName}";
480+
return $"INSERT INTO {table.BracketQuotedFullName} ({string.Join(",", bracketedColumnNamesFromItem)}) SELECT * FROM {CteName}";
470481
}
471482

472483
/// <summary>
473484
/// Generates reusable SQL query that will be part of every upsert command.
474485
/// </summary>
475-
public static string GetMergeQuery(IList<PrimaryKey> primaryKeys, SqlObject table, StringComparison comparison, IEnumerable<string> columnNames)
486+
public static string GetMergeQuery(IList<PrimaryKey> primaryKeys, SqlObject table, IEnumerable<string> bracketedColumnNamesFromItem)
476487
{
477488
IList<string> bracketedPrimaryKeys = primaryKeys.Select(p => p.Name.AsBracketQuotedString()).ToList();
478489
// Generate the ON part of the merge query (compares new data against existing data)
@@ -483,9 +494,6 @@ public static string GetMergeQuery(IList<PrimaryKey> primaryKeys, SqlObject tabl
483494
}
484495

485496
// Generate the UPDATE part of the merge query (all columns that should be updated)
486-
IEnumerable<string> bracketedColumnNamesFromItem = columnNames
487-
.Where(prop => !primaryKeys.Any(k => k.IsIdentity && string.Equals(k.Name, prop, comparison))) // Skip any identity columns, those should never be updated
488-
.Select(prop => prop.AsBracketQuotedString());
489497
var columnMatchingQueryBuilder = new StringBuilder();
490498
foreach (string column in bracketedColumnNamesFromItem)
491499
{
@@ -604,7 +612,7 @@ public static async Task<TableInformation> RetrieveTableInformationAsync(SqlConn
604612
while (await rdr.ReadAsync())
605613
{
606614
string columnName = caseSensitive ? rdr[ColumnName].ToString() : rdr[ColumnName].ToString().ToLowerInvariant();
607-
primaryKeys.Add(new PrimaryKey(columnName, bool.Parse(rdr[IsIdentity].ToString())));
615+
primaryKeys.Add(new PrimaryKey(columnName, bool.Parse(rdr[IsIdentity].ToString()), bool.Parse(rdr[HasDefault].ToString())));
608616
}
609617
primaryKeysSw.Stop();
610618
TelemetryInstance.TrackDuration(TelemetryEventName.GetPrimaryKeys, primaryKeysSw.ElapsedMilliseconds, sqlConnProps);
@@ -634,20 +642,24 @@ public static async Task<TableInformation> RetrieveTableInformationAsync(SqlConn
634642
IEnumerable<PrimaryKey> missingPrimaryKeysFromItem = primaryKeys
635643
.Where(k => !primaryKeysFromObject.Contains(k.Name, comparer));
636644
bool hasIdentityColumnPrimaryKeys = primaryKeys.Any(k => k.IsIdentity);
637-
// If none of the primary keys are an identity column then we require that all primary keys be present in the POCO so we can
645+
bool hasDefaultColumnPrimaryKeys = primaryKeys.Any(k => k.HasDefault);
646+
// If none of the primary keys are an identity column or have a default value then we require that all primary keys be present in the POCO so we can
638647
// generate the MERGE statement correctly
639-
if (!hasIdentityColumnPrimaryKeys && missingPrimaryKeysFromItem.Any())
648+
if (!hasIdentityColumnPrimaryKeys && !hasDefaultColumnPrimaryKeys && missingPrimaryKeysFromItem.Any())
640649
{
641650
string message = $"All primary keys for SQL table {table} need to be found in '{typeof(T)}.' Missing primary keys: [{string.Join(",", missingPrimaryKeysFromItem)}]";
642651
var ex = new InvalidOperationException(message);
643652
TelemetryInstance.TrackException(TelemetryErrorName.MissingPrimaryKeys, ex, sqlConnProps);
644653
throw ex;
645654
}
646655

647-
// If any identity columns aren't included in the object then we have to generate a basic insert since the merge statement expects all primary key
656+
// If any identity columns or columns with default values aren't included in the object then we have to generate a basic insert since the merge statement expects all primary key
648657
// columns to exist. (the merge statement can handle nullable columns though if those exist)
649-
bool usingInsertQuery = hasIdentityColumnPrimaryKeys && missingPrimaryKeysFromItem.Any();
650-
string query = usingInsertQuery ? GetInsertQuery(table) : GetMergeQuery(primaryKeys, table, comparison, columnNames);
658+
bool usingInsertQuery = (hasIdentityColumnPrimaryKeys || hasDefaultColumnPrimaryKeys) && missingPrimaryKeysFromItem.Any();
659+
IEnumerable<string> bracketedColumnNamesFromItem = columnNames
660+
.Where(prop => !primaryKeys.Any(k => k.IsIdentity && string.Equals(k.Name, prop, comparison))) // Skip any identity columns, those should never be updated
661+
.Select(prop => prop.AsBracketQuotedString());
662+
string query = usingInsertQuery ? GetInsertQuery(table, bracketedColumnNamesFromItem) : GetMergeQuery(primaryKeys, table, bracketedColumnNamesFromItem);
651663

652664
tableInfoSw.Stop();
653665
var durations = new Dictionary<TelemetryMeasureName, double>()

test/Integration/SqlOutputBindingIntegrationTests.cs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -377,5 +377,25 @@ public void AddProductCaseSensitiveTest(SupportedLanguages lang)
377377
Assert.Equal("test", this.ExecuteScalar($"select Name from Products where ProductId={1}"));
378378
Assert.Equal(100, this.ExecuteScalar($"select cost from Products where ProductId={1}"));
379379
}
380+
381+
/// <summary>
382+
/// Tests that a row is inserted successfully when the object is missing
383+
/// the primary key column with a default value.
384+
/// </summary>
385+
[Theory]
386+
[SqlInlineData()]
387+
public void AddProductWithDefaultPKTest(SupportedLanguages lang)
388+
{
389+
this.StartFunctionHost(nameof(AddProductWithDefaultPK), lang);
390+
var product = new Dictionary<string, string>()
391+
{
392+
{ "name", "MyProduct" },
393+
{ "cost", "1" }
394+
};
395+
Assert.Equal(0, this.ExecuteScalar("SELECT COUNT(*) FROM dbo.ProductsWithDefaultPK"));
396+
this.SendOutputPostRequest("addproductwithdefaultpk", JsonConvert.SerializeObject(product)).Wait();
397+
this.SendOutputPostRequest("addproductwithdefaultpk", JsonConvert.SerializeObject(product)).Wait();
398+
Assert.Equal(2, this.ExecuteScalar("SELECT COUNT(*) FROM dbo.ProductsWithDefaultPK"));
399+
}
380400
}
381401
}

0 commit comments

Comments
 (0)