Skip to content

Commit 1381df1

Browse files
authored
Fix schema parse error when using reserved keys as table names (#881)
* bracketed name for reserved words as table name * add test * add tests * fix csx test * enable test only for csharp * fix TableNotPresentTest * revert GetUserTableIdAsync change * refactor GetUserTableIdAsync * comment out Java test * refactor code to use SqlObject
1 parent a310eec commit 1381df1

File tree

20 files changed

+335
-16
lines changed

20 files changed

+335
-16
lines changed

src/TriggerBinding/SqlTriggerListener.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -82,8 +82,8 @@ public SqlTriggerListener(string connectionString, string tableName, string user
8282
}
8383
this._hasConfiguredMaxChangesPerWorker = configuredMaxChangesPerWorker != null;
8484

85-
this._scaleMonitor = new SqlTriggerScaleMonitor(this._userFunctionId, this._userTable.FullName, this._connectionString, this._maxChangesPerWorker, this._logger);
86-
this._targetScaler = new SqlTriggerTargetScaler(this._userFunctionId, this._userTable.FullName, this._connectionString, this._maxChangesPerWorker, this._logger);
85+
this._scaleMonitor = new SqlTriggerScaleMonitor(this._userFunctionId, this._userTable, this._connectionString, this._maxChangesPerWorker, this._logger);
86+
this._targetScaler = new SqlTriggerTargetScaler(this._userFunctionId, this._userTable, this._connectionString, this._maxChangesPerWorker, this._logger);
8787
}
8888

8989
public void Cancel()
@@ -119,7 +119,7 @@ public async Task StartAsync(CancellationToken cancellationToken)
119119

120120
await VerifyDatabaseSupported(connection, this._logger, cancellationToken);
121121

122-
int userTableId = await GetUserTableIdAsync(connection, this._userTable.FullName, this._logger, cancellationToken);
122+
int userTableId = await GetUserTableIdAsync(connection, this._userTable, this._logger, cancellationToken);
123123
IReadOnlyList<(string name, string type)> primaryKeyColumns = await GetPrimaryKeyColumnsAsync(connection, userTableId, this._logger, this._userTable.FullName, cancellationToken);
124124
IReadOnlyList<string> userTableColumns = await this.GetUserTableColumnsAsync(connection, userTableId, cancellationToken);
125125

src/TriggerBinding/SqlTriggerMetricsProvider.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ private async Task<long> GetUnprocessedChangeCountAsync()
5454
{
5555
await connection.OpenAsync();
5656

57-
int userTableId = await GetUserTableIdAsync(connection, this._userTable.FullName, this._logger, CancellationToken.None);
57+
int userTableId = await GetUserTableIdAsync(connection, this._userTable, this._logger, CancellationToken.None);
5858
IReadOnlyList<(string name, string type)> primaryKeyColumns = await GetPrimaryKeyColumnsAsync(connection, userTableId, this._logger, this._userTable.FullName, CancellationToken.None);
5959

6060
// Use a transaction to automatically release the app lock when we're done executing the query

src/TriggerBinding/SqlTriggerScaleMonitor.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,11 @@ internal sealed class SqlTriggerScaleMonitor : IScaleMonitor<SqlTriggerMetrics>
2525
private readonly IDictionary<TelemetryPropertyName, string> _telemetryProps = new Dictionary<TelemetryPropertyName, string>();
2626
private readonly int _maxChangesPerWorker;
2727

28-
public SqlTriggerScaleMonitor(string userFunctionId, string userTableName, string connectionString, int maxChangesPerWorker, ILogger logger)
28+
public SqlTriggerScaleMonitor(string userFunctionId, SqlObject userTable, string connectionString, int maxChangesPerWorker, ILogger logger)
2929
{
3030
_ = !string.IsNullOrEmpty(userFunctionId) ? true : throw new ArgumentNullException(userFunctionId);
31-
_ = !string.IsNullOrEmpty(userTableName) ? true : throw new ArgumentNullException(userTableName);
32-
this._userTable = new SqlObject(userTableName);
31+
_ = userTable != null ? true : throw new ArgumentNullException(nameof(userTable));
32+
this._userTable = userTable;
3333
// Do not convert the scale-monitor ID to lower-case string since SQL table names can be case-sensitive
3434
// depending on the collation of the current database.
3535
this.Descriptor = new ScaleMonitorDescriptor($"{userFunctionId}-SqlTrigger-{this._userTable.FullName}", userFunctionId);

src/TriggerBinding/SqlTriggerTargetScaler.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,9 @@ internal sealed class SqlTriggerTargetScaler : ITargetScaler
1515
private readonly SqlTriggerMetricsProvider _metricsProvider;
1616
private readonly int _maxChangesPerWorker;
1717

18-
public SqlTriggerTargetScaler(string userFunctionId, string userTableName, string connectionString, int maxChangesPerWorker, ILogger logger)
18+
public SqlTriggerTargetScaler(string userFunctionId, SqlObject userTable, string connectionString, int maxChangesPerWorker, ILogger logger)
1919
{
20-
this._metricsProvider = new SqlTriggerMetricsProvider(connectionString, logger, new SqlObject(userTableName), userFunctionId);
20+
this._metricsProvider = new SqlTriggerMetricsProvider(connectionString, logger, userTable, userFunctionId);
2121
this.TargetScalerDescriptor = new TargetScalerDescriptor(userFunctionId);
2222
this._maxChangesPerWorker = maxChangesPerWorker;
2323
}

src/TriggerBinding/SqlTriggerUtils.cs

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -85,28 +85,27 @@ FROM sys.indexes AS i
8585
/// Returns the object ID of the user table.
8686
/// </summary>
8787
/// <param name="connection">SQL connection used to connect to user database</param>
88-
/// <param name="userTableName">Name of the user table</param>
88+
/// <param name="userTable">SqlObject user table</param>
8989
/// <param name="logger">Facilitates logging of messages</param>
9090
/// <param name="cancellationToken">Cancellation token to pass to the command</param>
9191
/// <exception cref="InvalidOperationException">Thrown in case of error when querying the object ID for the user table</exception>
92-
public static async Task<int> GetUserTableIdAsync(SqlConnection connection, string userTableName, ILogger logger, CancellationToken cancellationToken)
92+
internal static async Task<int> GetUserTableIdAsync(SqlConnection connection, SqlObject userTable, ILogger logger, CancellationToken cancellationToken)
9393
{
94-
var userTable = new SqlObject(userTableName);
9594
string getObjectIdQuery = $"SELECT OBJECT_ID(N{userTable.QuotedFullName}, 'U');";
9695

9796
using (var getObjectIdCommand = new SqlCommand(getObjectIdQuery, connection))
9897
using (SqlDataReader reader = await getObjectIdCommand.ExecuteReaderAsyncWithLogging(logger, cancellationToken))
9998
{
10099
if (!await reader.ReadAsync(cancellationToken))
101100
{
102-
throw new InvalidOperationException($"Received empty response when querying the object ID for table: '{userTableName}'.");
101+
throw new InvalidOperationException($"Received empty response when querying the object ID for table: '{userTable.FullName}'.");
103102
}
104103

105104
object userTableId = reader.GetValue(0);
106105

107106
if (userTableId is DBNull)
108107
{
109-
throw new InvalidOperationException($"Could not find table: '{userTableName}'.");
108+
throw new InvalidOperationException($"Could not find table: '{userTable.FullName}'.");
110109
}
111110
logger.LogDebug($"GetUserTableId TableId={userTableId}");
112111
return (int)userTableId;
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
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 System.Collections.Generic;
5+
using Microsoft.Extensions.Logging;
6+
using Microsoft.Azure.Functions.Worker;
7+
using Microsoft.Azure.Functions.Worker.Extensions.Sql;
8+
9+
namespace DotnetIsolatedTests
10+
{
11+
public static class ReservedTableNameTrigger
12+
{
13+
/// <summary>
14+
/// Used in verification of the trigger function execution on table with reserved keys as name.
15+
/// </summary>
16+
[Function(nameof(ReservedTableNameTrigger))]
17+
public static void Run(
18+
[SqlTrigger("[dbo].[User]", "SqlConnectionString")]
19+
IReadOnlyList<SqlChange<User>> changes,
20+
FunctionContext context)
21+
{
22+
ILogger logger = context.GetLogger("ReservedTableNameTrigger");
23+
logger.LogInformation("SQL Changes: " + Utils.JsonSerializeObject(changes));
24+
}
25+
}
26+
27+
public class User
28+
{
29+
public string UserName { get; set; }
30+
public int UserId { get; set; }
31+
public string FullName { get; set; }
32+
33+
public override bool Equals(object obj)
34+
{
35+
if (obj is User)
36+
{
37+
var that = obj as User;
38+
return this.UserId == that.UserId && this.UserName == that.UserName && this.FullName == that.FullName;
39+
}
40+
41+
return false;
42+
}
43+
}
44+
}

test/Database/Tables/User.sql

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
DROP TABLE IF EXISTS [dbo].[User];
2+
3+
CREATE TABLE [dbo].[User] (
4+
[UserId] [int] NOT NULL PRIMARY KEY,
5+
[UserName] [nvarchar](50) NOT NULL,
6+
[FullName] [nvarchar](max) NULL
7+
)

test/Integration/SqlTriggerBindingIntegrationTests.cs

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -656,6 +656,71 @@ JOIN sys.columns c
656656
Assert.True(1 == (int)this.ExecuteScalar("SELECT 1 FROM sys.columns WHERE Name = N'LastAccessTime' AND Object_ID = Object_ID(N'[az_func].[GlobalState]')"), $"{GlobalStateTableName} should have {LastAccessTimeColumnName} column after restarting the listener.");
657657
}
658658

659+
/// <summary>
660+
/// Tests that trigger function executes on table whose name is a reserved word (User).
661+
/// </summary>
662+
[Theory]
663+
[SqlInlineData()]
664+
[UnsupportedLanguages(SupportedLanguages.Java)] // test timing out for Java
665+
public async void ReservedTableNameTest(SupportedLanguages lang)
666+
{
667+
this.SetChangeTrackingForTable("User");
668+
this.StartFunctionHost(nameof(ReservedTableNameTrigger), lang, true);
669+
User expectedResponse = Utils.JsonDeserializeObject<User>(/*lang=json,strict*/ "{\"UserId\":999,\"UserName\":\"test\",\"FullName\":\"Testy Test\"}");
670+
int index = 0;
671+
string messagePrefix = "SQL Changes: ";
672+
673+
var taskCompletion = new TaskCompletionSource<bool>();
674+
675+
void MonitorOutputData(object sender, DataReceivedEventArgs e)
676+
{
677+
if (e.Data != null && (index = e.Data.IndexOf(messagePrefix, StringComparison.Ordinal)) >= 0)
678+
{
679+
string json = e.Data[(index + messagePrefix.Length)..];
680+
// Sometimes we'll get messages that have extra logging content on the same line - so to prevent that from breaking
681+
// the deserialization we look for the end of the changes array and only use that.
682+
// (This is fine since we control what content is in the array so know that none of the items have a ] in them)
683+
json = json[..(json.IndexOf(']') + 1)];
684+
IReadOnlyList<SqlChange<User>> changes;
685+
try
686+
{
687+
changes = Utils.JsonDeserializeObject<IReadOnlyList<SqlChange<User>>>(json);
688+
}
689+
catch (Exception ex)
690+
{
691+
throw new InvalidOperationException($"Exception deserializing JSON content. Error={ex.Message} Json=\"{json}\"", ex);
692+
}
693+
Assert.Equal(SqlChangeOperation.Insert, changes[0].Operation); // Expected change operation
694+
User user = changes[0].Item;
695+
Assert.NotNull(user); // user deserialized correctly
696+
Assert.Equal(expectedResponse, user); // user has the expected values
697+
taskCompletion.SetResult(true);
698+
}
699+
};
700+
701+
// Set up listener for the changes coming in
702+
foreach (Process functionHost in this.FunctionHostList)
703+
{
704+
functionHost.OutputDataReceived += MonitorOutputData;
705+
}
706+
707+
// Now that we've set up our listener trigger the actions to monitor
708+
this.ExecuteNonQuery("INSERT INTO [dbo].[User] VALUES (" +
709+
"999, " + // UserId,
710+
"'test', " + // UserName
711+
"'Testy Test')"); // FullName
712+
713+
// Now wait until either we timeout or we've gotten all the expected changes, whichever comes first
714+
this.LogOutput($"[{DateTime.UtcNow:u}] Waiting for Insert changes (10000ms)");
715+
await taskCompletion.Task.TimeoutAfter(TimeSpan.FromMilliseconds(10000), $"Timed out waiting for Insert changes.");
716+
717+
// Unhook handler since we're done monitoring these changes so we aren't checking other changes done later
718+
foreach (Process functionHost in this.FunctionHostList)
719+
{
720+
functionHost.OutputDataReceived -= MonitorOutputData;
721+
}
722+
}
723+
659724
/// <summary>
660725
/// Ensures that all column types are serialized correctly.
661726
/// </summary>
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
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 System.Collections.Generic;
5+
using Microsoft.Extensions.Logging;
6+
7+
namespace Microsoft.Azure.WebJobs.Extensions.Sql.Tests.Integration
8+
{
9+
public static class ReservedTableNameTrigger
10+
{
11+
/// <summary>
12+
/// Used in verification of the trigger function execution on table with reserved keys as name.
13+
/// </summary>
14+
[FunctionName(nameof(ReservedTableNameTrigger))]
15+
public static void Run(
16+
[SqlTrigger("[dbo].[User]", "SqlConnectionString")]
17+
IReadOnlyList<SqlChange<User>> changes,
18+
ILogger logger)
19+
{
20+
// The output is used to inspect the trigger binding parameter in test methods.
21+
logger.LogInformation("SQL Changes: " + Utils.JsonSerializeObject(changes));
22+
}
23+
}
24+
25+
public class User
26+
{
27+
public string UserName { get; set; }
28+
public int UserId { get; set; }
29+
public string FullName { get; set; }
30+
31+
public override bool Equals(object obj)
32+
{
33+
if (obj is User)
34+
{
35+
var that = obj as User;
36+
return this.UserId == that.UserId && this.UserName == that.UserName && this.FullName == that.FullName;
37+
}
38+
39+
return false;
40+
}
41+
}
42+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"bindings": [
3+
{
4+
"name": "changes",
5+
"type": "sqlTrigger",
6+
"direction": "in",
7+
"tableName": "[dbo].[User]",
8+
"connectionStringSetting": "SqlConnectionString"
9+
}
10+
],
11+
"disabled": false
12+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
#r "Newtonsoft.Json"
2+
#r "Microsoft.Azure.WebJobs.Extensions.Sql"
3+
4+
using System.Net;
5+
using Microsoft.AspNetCore.Mvc;
6+
using Microsoft.Extensions.Primitives;
7+
using Newtonsoft.Json;
8+
using Microsoft.Azure.WebJobs.Extensions.Sql;
9+
10+
public static void Run(IReadOnlyList<SqlChange<User>> changes, ILogger log)
11+
{
12+
// The output is used to inspect the trigger binding parameter in test methods.
13+
log.LogInformation("SQL Changes: " + Microsoft.Azure.WebJobs.Extensions.Sql.Utils.JsonSerializeObject(changes));
14+
}
15+
16+
public class User
17+
{
18+
public string UserName { get; set; }
19+
public int UserId { get; set; }
20+
public string FullName { get; set; }
21+
22+
public override bool Equals(object obj)
23+
{
24+
if (obj is User)
25+
{
26+
var that = obj as User;
27+
return this.UserId == that.UserId && this.UserName == that.UserName && this.FullName == that.FullName;
28+
}
29+
30+
return false;
31+
}
32+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/**
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
* Licensed under the MIT License. See License.txt in the project root for
4+
* license information.
5+
*/
6+
7+
package com.function.Common;
8+
9+
public class User {
10+
private int UserId;
11+
private String UserName;
12+
private String FullName;
13+
14+
public User(int userId, String userName, String fullName) {
15+
UserId = userId;
16+
UserName = userName;
17+
FullName = fullName;
18+
}
19+
20+
public int getUserId() {
21+
return UserId;
22+
}
23+
24+
public String getUserName() {
25+
return UserName;
26+
}
27+
28+
public String getFullName() {
29+
return FullName;
30+
}
31+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
/**
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
* Licensed under the MIT License. See License.txt in the project root for
4+
* license information.
5+
*/
6+
7+
package com.function;
8+
9+
import com.function.Common.User;
10+
import com.google.gson.Gson;
11+
import com.microsoft.azure.functions.ExecutionContext;
12+
import com.microsoft.azure.functions.annotation.FunctionName;
13+
import com.microsoft.azure.functions.sql.annotation.SQLTrigger;
14+
15+
import java.util.logging.Level;
16+
17+
public class ReservedTableNameTrigger {
18+
@FunctionName("ReservedTableNameTrigger")
19+
public void run(
20+
@SQLTrigger(
21+
name = "changes",
22+
tableName = "[dbo].[User]",
23+
connectionStringSetting = "SqlConnectionString")
24+
User[] changes,
25+
ExecutionContext context) throws Exception {
26+
27+
context.getLogger().log(Level.INFO, "SQL Changes: " + new Gson().toJson(changes));
28+
}
29+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"bindings": [
3+
{
4+
"name": "changes",
5+
"type": "sqlTrigger",
6+
"direction": "in",
7+
"tableName": "[dbo].[User]",
8+
"connectionStringSetting": "SqlConnectionString"
9+
}
10+
],
11+
"disabled": false
12+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
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+
module.exports = async function (context, changes) {
5+
context.log(`SQL Changes: ${JSON.stringify(changes)}`)
6+
}

0 commit comments

Comments
 (0)