Skip to content

Commit 8ab6c18

Browse files
authored
Introducing parameters' DbType before passing on to the database - MsSql (#1442)
## Why make this change? Fixes #475 Most column types will accept a string null and the db will automatically infer our intention, but for varbinary in MsSql the db complains that it cannot convert varchar to varbinary. For more details about the issue refer: https://stackoverflow.com/questions/29254690/why-does-dbnull-value-require-a-proper-sqldbtype ## What is this change? Changing how we form the `parameters` as we pass them to the database. Previously we used to only pass the parameter's value. Now, we would also pass the parameter's `DbType` along with the value as suggested in the stack overflow article. **Notes:** 1. We would only be passing the DbType for MsSql. 2. Usually, we haven't seen any need to pass the `DbType` for Sql Server data types other than `varbinary`. However, we will still be supplying the DbType for all other parameter's for which we are confirmed about the 1:1 mapping from SystemType to DbType. Sql server datatypes like `datetime`, `datetime2`, `datetimeoffset` are all treated as `datetime` by graphql, so there is no 1:1 mapping for datetime datatypes. So we are skipping supplying the DbType for these. 3. As explained in this stack overflow article [here](https://stackoverflow.com/questions/6868300/net-guid-uppercase-string-format), the GUID when converted to string, is output in lowercase. That's why when the query to verify the MsSql test `InsertOneInSupportedTypes` is written, we have explicitly cast `guid_types` to lower case. ## Additional change 1. The `real` sql server datatype was incorrectly mapped to `long` dotnet type. It has been correctly mapped to `decimal` now. ## Issue with DateTime/DateTimeOffset Sql Server data types For a mutation request like this, we get a response: <img width="873" alt="image" src="https://user-images.githubusercontent.com/34566234/235473783-87b810b1-bf10-44a6-8671-c54b74e44dac.png"> and the parameters are generated as: <img width="510" alt="image" src="https://user-images.githubusercontent.com/34566234/235470342-5331c755-abdc-4e05-9f9c-467e406d0038.png"> the parameter generated is of type `DateTimeOffset` (as evident from the value highlighted) but the underlying Sql Server datatype for the `datetime_types` field is `datetime`. Even though we supplied a value `1999-01-08 09:20:00` in the mutation, it became `08-01-1999 09:20:00 +05:30`, because that's how graphql treats datetime/datetimoffset/date types. Then this implicit conversion from a value of type DateTimeOffset to DateTime cannot happen throwing an exception with message : `Failed to convert parameter value from a DateTimeOffset to a DateTime.` Hence we cannot add a mapping from `typeof(DateTime) = DbType.DateTime`. Another option that came to my mind and would probably come to your mind as well would be to have a mapping from`typeof(DateTime) = DbType.DateTimeOffset`, since then there can be implicit conversion from value of `DateTimeOffset` type to `DbType.DateTimeOffset`. But that has its own flaws. Lets look at the below screenshots: Consider this mutation: <img width="885" alt="image" src="https://user-images.githubusercontent.com/34566234/235477733-19c18d73-7eaa-42ba-9484-52984c62baad.png"> and the parameters generated for the query (mutation ran successfully because the default value for the `instant` PK column was inserted into the table): <img width="425" alt="image" src="https://user-images.githubusercontent.com/34566234/235478676-6c2b5bc7-80bc-497d-a53d-e3ecff80538e.png"> The parameter value is of sql server type datetime but the DbType is populated as `DbType.DateTimeOffset`. This upcasting cannot happen correctly and so we are returned with 0 records. So, this approach won't work as well. **Conclusion:** We won't populate DbType for these sql server types as it is not required and would prevent data loss and any unforeseen exceptions. ## How was this tested? - [x] Integration Tests - [x] Unit Tests - The unit tests that were skipped before saying that bytearray datatype is not supported are now running successfully for MsSql. ## Sample Request(s) Request method: POST Request URL: https://localhost:5001/api/SupportedType/ Request: ```json { "bytearray_types": null } ``` Response: ```json { "value": [ { "typeid": 5001, "byte_types": null, "short_types": null, "int_types": null, "long_types": null, "string_types": null, "single_types": null, "float_types": null, "decimal_types": null, "boolean_types": null, "date_types": null, "datetime_types": null, "datetime2_types": null, "datetimeoffset_types": null, "smalldatetime_types": null, "bytearray_types": null, "guid_types": "27742da6-99f2-4e58-9fd8-4f143aea46b0" } ] } ```
1 parent 0cc0ee9 commit 8ab6c18

30 files changed

+362
-86
lines changed

src/Config/DatabaseObject.cs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
// Copyright (c) Microsoft Corporation.
22
// Licensed under the MIT License.
33

4+
using System.Data;
5+
46
namespace Azure.DataApiBuilder.Config
57
{
68
/// <summary>
@@ -105,11 +107,23 @@ public class StoredProcedureDefinition : SourceDefinition
105107
/// Key: parameter name, Value: ParameterDefinition object
106108
/// </summary>
107109
public Dictionary<string, ParameterDefinition> Parameters { get; set; } = new();
110+
111+
/// <inheritdoc/>
112+
public override DbType? GetDbTypeForParam(string paramName)
113+
{
114+
if (Parameters.TryGetValue(paramName, out ParameterDefinition? paramDefinition))
115+
{
116+
return paramDefinition.DbType;
117+
}
118+
119+
return null;
120+
}
108121
}
109122

110123
public class ParameterDefinition
111124
{
112125
public Type SystemType { get; set; } = null!;
126+
public DbType? DbType { get; set; }
113127
public bool HasConfigDefault { get; set; }
114128
public object? ConfigDefaultValue { get; set; }
115129
}
@@ -153,6 +167,24 @@ public bool IsAnyColumnNullable(List<string> columnsToCheck)
153167
.Where(isNullable => isNullable == true)
154168
.Any();
155169
}
170+
171+
/// <summary>
172+
/// Method to get the DbType for:
173+
/// 1. column for table/view,
174+
/// 2. parameter for stored procedure.
175+
/// </summary>
176+
/// <param name="paramName">The parameter whose DbType is to be determined.
177+
/// For table/view paramName refers to the backingColumnName if aliases are used.</param>
178+
/// <returns>DbType for the parameter.</returns>
179+
public virtual DbType? GetDbTypeForParam(string paramName)
180+
{
181+
if (Columns.TryGetValue(paramName, out ColumnDefinition? columnDefinition))
182+
{
183+
return columnDefinition.DbType;
184+
}
185+
186+
return null;
187+
}
156188
}
157189

158190
/// <summary>
@@ -178,6 +210,7 @@ public class ColumnDefinition
178210
/// The database type of this column mapped to the SystemType.
179211
/// </summary>
180212
public Type SystemType { get; set; } = typeof(object);
213+
public DbType? DbType { get; set; }
181214
public bool HasDefault { get; set; }
182215
public bool IsAutoGenerated { get; set; }
183216
public bool IsNullable { get; set; }

src/Service.Tests/SqlTests/GraphQLSupportedTypesTests/MsSqlGQLSupportedTypesTests.cs

Lines changed: 0 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
using System.Collections.Generic;
55
using System.Threading.Tasks;
66
using Microsoft.VisualStudio.TestTools.UnitTesting;
7-
using static Azure.DataApiBuilder.Service.GraphQLBuilder.GraphQLTypes.SupportedTypes;
87

98
namespace Azure.DataApiBuilder.Service.Tests.SqlTests.GraphQLSupportedTypesTests
109
{
@@ -34,19 +33,5 @@ ORDER BY id asc
3433
INCLUDE_NULL_VALUES
3534
";
3635
}
37-
38-
/// <summary>
39-
/// Explicitly declaring a parameter for a bytearray type is not possible due to:
40-
/// https://stackoverflow.com/questions/29254690/why-does-dbnull-value-require-a-proper-sqldbtype
41-
/// </summary>
42-
protected override bool IsSupportedType(string type)
43-
{
44-
if (type.Equals(BYTEARRAY_TYPE))
45-
{
46-
return false;
47-
}
48-
49-
return true;
50-
}
5136
}
5237
}

src/Service.Tests/SqlTests/RestApiTests/Insert/InsertApiTestBase.cs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,32 @@ await SetupAndRunRestApiTest(
6060
);
6161
}
6262

63+
/// <summary>
64+
/// Perform insert test with bytearray column as NULL. This ensures that even though implicit conversion
65+
/// between varchar to varbinary is not possible for MsSql (but it is possible for MySql & PgSql),
66+
/// but since we are passing the DbType for the parameter, the database can explicitly convert it into varbinary.
67+
/// </summary>
68+
[TestMethod]
69+
public virtual async Task InsertOneWithByteArrayTypeAsNull()
70+
{
71+
string requestBody = @"
72+
{
73+
""bytearray_types"": null
74+
}";
75+
76+
string expectedLocationHeader = $"typeid/{STARTING_ID_FOR_TEST_INSERTS}";
77+
await SetupAndRunRestApiTest(
78+
primaryKeyRoute: null,
79+
queryString: null,
80+
entityNameOrPath: _integrationTypeEntity,
81+
sqlQuery: GetQuery("InsertOneInSupportedTypes"),
82+
operationType: Config.Operation.Insert,
83+
requestBody: requestBody,
84+
expectedStatusCode: HttpStatusCode.Created,
85+
expectedLocationHeader: expectedLocationHeader
86+
);
87+
}
88+
6389
/// <summary>
6490
/// Tests insertion on simple/composite views.
6591
/// </summary>

src/Service.Tests/SqlTests/RestApiTests/Insert/MsSqlInsertApiTests.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,14 @@ public class MsSqlInsertApiTests : InsertApiTestBase
2727
$"AND [publisher_id] = 1234 " +
2828
$"FOR JSON PATH, INCLUDE_NULL_VALUES, WITHOUT_ARRAY_WRAPPER"
2929
},
30+
{
31+
"InsertOneInSupportedTypes",
32+
$"SELECT [id] as [typeid], [byte_types], [short_types], [int_types], [long_types],string_types, [single_types], [float_types], " +
33+
$"[decimal_types], [boolean_types], [date_types], [datetime_types], [datetime2_types], [datetimeoffset_types], [smalldatetime_types], " +
34+
$"[bytearray_types], LOWER([guid_types]) as [guid_types] FROM { _integrationTypeTable } " +
35+
$"WHERE [id] = { STARTING_ID_FOR_TEST_INSERTS } AND [bytearray_types] is NULL " +
36+
$"FOR JSON PATH, INCLUDE_NULL_VALUES, WITHOUT_ARRAY_WRAPPER"
37+
},
3038
{
3139
"InsertOneInBooksViewAll",
3240
$"SELECT [id], [title], [publisher_id] FROM { _simple_all_books } " +

src/Service.Tests/SqlTests/RestApiTests/Insert/MySqlInsertApiTests.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,17 @@ SELECT JSON_OBJECT('id', id, 'title', title, 'publisher_id', publisher_id) AS da
2626
) AS subq
2727
"
2828
},
29+
{
30+
"InsertOneInSupportedTypes",
31+
@"
32+
SELECT JSON_OBJECT('typeid', typeid,'bytearray_types', bytearray_types) AS data
33+
FROM (
34+
SELECT id as typeid, bytearray_types
35+
FROM " + _integrationTypeTable + @"
36+
WHERE id = 5001 AND bytearray_types is NULL
37+
) AS subq
38+
"
39+
},
2940
{
3041
"InsertOneUniqueCharactersTest",
3142
@"

src/Service.Tests/SqlTests/RestApiTests/Insert/PostgreSqlInsertApiTests.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,18 @@ SELECT to_jsonb(subq) AS data
2727
) AS subq
2828
"
2929
},
30+
{
31+
"InsertOneInSupportedTypes",
32+
@"
33+
SELECT to_jsonb(subq) AS data
34+
FROM (
35+
SELECT id as typeid, short_types, int_types, long_types, string_types, single_types,
36+
float_types, decimal_types, boolean_types, datetime_types, bytearray_types, guid_types
37+
FROM " + _integrationTypeTable + @"
38+
WHERE id = " + STARTING_ID_FOR_TEST_INSERTS + @"
39+
) AS subq
40+
"
41+
},
3042
{
3143
"InsertOneUniqueCharactersTest",
3244
@"

src/Service.Tests/Unittests/ODataASTVisitorUnitTests.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -346,7 +346,8 @@ private static ODataASTVisitor CreateVisitor(
346346
authorizationResolver,
347347
_runtimeConfigProvider,
348348
new GQLFilterParser(_sqlMetadataProvider),
349-
null); // setting httpContext as null for the tests.
349+
null) // setting httpContext as null for the tests.
350+
{ CallBase = true }; // setting CallBase = true enables calling the actual method on the mocked object without needing to mock the method behavior.
350351
return new ODataASTVisitor(structure.Object, _sqlMetadataProvider);
351352
}
352353

src/Service.Tests/Unittests/SqlQueryExecutorUnitTests.cs

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,15 @@
33

44
using System;
55
using System.Collections.Generic;
6+
using System.Data;
67
using System.Data.Common;
78
using System.Net;
89
using System.Text.Json;
910
using System.Threading.Tasks;
1011
using Azure.Core;
1112
using Azure.DataApiBuilder.Service.Configurations;
1213
using Azure.DataApiBuilder.Service.Exceptions;
14+
using Azure.DataApiBuilder.Service.Models;
1315
using Azure.DataApiBuilder.Service.Resolvers;
1416
using Azure.DataApiBuilder.Service.Tests.SqlTests;
1517
using Azure.Identity;
@@ -137,7 +139,7 @@ Mock<MsSqlQueryExecutor> queryExecutor
137139
queryExecutor.Setup(x => x.ExecuteQueryAgainstDbAsync(
138140
It.IsAny<SqlConnection>(),
139141
It.IsAny<string>(),
140-
It.IsAny<IDictionary<string, object>>(),
142+
It.IsAny<IDictionary<string, DbConnectionParam>>(),
141143
It.IsAny<Func<DbDataReader, List<string>, Task<object>>>(),
142144
It.IsAny<HttpContext>(),
143145
It.IsAny<List<string>>()))
@@ -146,7 +148,7 @@ Mock<MsSqlQueryExecutor> queryExecutor
146148
// Call the actual ExecuteQueryAsync method.
147149
queryExecutor.Setup(x => x.ExecuteQueryAsync(
148150
It.IsAny<string>(),
149-
It.IsAny<IDictionary<string, object>>(),
151+
It.IsAny<IDictionary<string, DbConnectionParam>>(),
150152
It.IsAny<Func<DbDataReader, List<string>, Task<object>>>(),
151153
It.IsAny<HttpContext>(),
152154
It.IsAny<List<string>>())).CallBase();
@@ -155,7 +157,7 @@ Mock<MsSqlQueryExecutor> queryExecutor
155157
{
156158
await queryExecutor.Object.ExecuteQueryAsync<object>(
157159
sqltext: string.Empty,
158-
parameters: new Dictionary<string, object>(),
160+
parameters: new Dictionary<string, DbConnectionParam>(),
159161
dataReaderHandler: null,
160162
httpContext: null,
161163
args: null);
@@ -189,7 +191,7 @@ Mock<MsSqlQueryExecutor> queryExecutor
189191
queryExecutor.SetupSequence(x => x.ExecuteQueryAgainstDbAsync(
190192
It.IsAny<SqlConnection>(),
191193
It.IsAny<string>(),
192-
It.IsAny<IDictionary<string, object>>(),
194+
It.IsAny<IDictionary<string, DbConnectionParam>>(),
193195
It.IsAny<Func<DbDataReader, List<string>, Task<object>>>(),
194196
It.IsAny<HttpContext>(),
195197
It.IsAny<List<string>>()))
@@ -200,7 +202,7 @@ Mock<MsSqlQueryExecutor> queryExecutor
200202
// Call the actual ExecuteQueryAsync method.
201203
queryExecutor.Setup(x => x.ExecuteQueryAsync(
202204
It.IsAny<string>(),
203-
It.IsAny<IDictionary<string, object>>(),
205+
It.IsAny<IDictionary<string, DbConnectionParam>>(),
204206
It.IsAny<Func<DbDataReader, List<string>, Task<object>>>(),
205207
It.IsAny<HttpContext>(),
206208
It.IsAny<List<string>>())).CallBase();
@@ -209,7 +211,7 @@ Mock<MsSqlQueryExecutor> queryExecutor
209211

210212
await queryExecutor.Object.ExecuteQueryAsync<object>(
211213
sqltext: sqltext,
212-
parameters: new Dictionary<string, object>(),
214+
parameters: new Dictionary<string, DbConnectionParam>(),
213215
dataReaderHandler: null,
214216
args: null);
215217

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
using System.Data;
5+
6+
namespace Azure.DataApiBuilder.Service.Models
7+
{
8+
/// <summary>
9+
/// Represents a single parameter created for the database connection.
10+
/// </summary>
11+
public class DbConnectionParam
12+
{
13+
public DbConnectionParam(object? value, DbType? dbType = null)
14+
{
15+
Value = value;
16+
DbType = dbType;
17+
}
18+
19+
/// <summary>
20+
/// Value of the parameter.
21+
/// </summary>
22+
public object? Value { get; set; }
23+
24+
// DbType of the parameter.
25+
// This is being made nullable because GraphQL treats Sql Server types like datetime, datetimeoffset
26+
// identically and then implicit conversion cannot happen.
27+
// For more details refer: https://github.com/Azure/data-api-builder/pull/1442.
28+
public DbType? DbType { get; set; }
29+
}
30+
}

src/Service/Models/GraphQLFilterParsers.cs

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -204,7 +204,7 @@ public Predicate Parse(
204204
schemaName,
205205
sourceName,
206206
sourceAlias,
207-
queryStructure.MakeParamWithValue)));
207+
queryStructure.MakeDbConnectionParam)));
208208
}
209209
}
210210
}
@@ -300,7 +300,7 @@ private void HandleNestedFilterForSql(
300300
predicates.Push(new PredicateOperand(existsPredicate));
301301

302302
// Add all parameters from the exists subquery to the main queryStructure.
303-
foreach ((string key, object? value) in existsQuery.Parameters)
303+
foreach ((string key, DbConnectionParam value) in existsQuery.Parameters)
304304
{
305305
queryStructure.Parameters.Add(key, value);
306306
}
@@ -346,7 +346,7 @@ private static Predicate ParseScalarType(
346346
string schemaName,
347347
string tableName,
348348
string tableAlias,
349-
Func<object, string> processLiterals)
349+
Func<object, string?, string> processLiterals)
350350
{
351351
Column column = new(schemaName, tableName, columnName: name, tableAlias);
352352

@@ -472,7 +472,7 @@ public static Predicate Parse(
472472
IInputField argumentSchema,
473473
Column column,
474474
List<ObjectFieldNode> fields,
475-
Func<object, string> processLiterals)
475+
Func<object, string?, string> processLiterals)
476476
{
477477
List<PredicateOperand> predicates = new();
478478

@@ -542,7 +542,7 @@ public static Predicate Parse(
542542
predicates.Push(new PredicateOperand(new Predicate(
543543
new PredicateOperand(column),
544544
op,
545-
new PredicateOperand(processLiteral ? $"{processLiterals(value)}" : value.ToString()))
545+
new PredicateOperand(processLiteral ? $"{processLiterals(value, column.ColumnName)}" : value.ToString()))
546546
));
547547
}
548548

0 commit comments

Comments
 (0)