Skip to content

Commit 542ac92

Browse files
authored
GraphQL Mutation support for datawarehouse. (#1978)
## Why make this change? Issue: #1976. GraphQL Mutations need to be supported for data warehouse. Reasons for listed changes listed in issue document. This pr does not cover REST addition for insert/patch etc. Will be done in following pr's . issue tracking: #1982 ## What is this change? 1. Adding a DbOperationResult type that will be the return type for all dw mutation operations. 2. Generating DWSQL queries for create, update, delete and upsert queries similar to that of other data sources. 3. Adding model directive to DW mutation nodes so that we can determine which db they belong to. ## How was this tested? Performed integration tests: ![image](https://github.com/Azure/data-api-builder/assets/124841904/59d896a6-b5c3-41d8-9fc6-f6a277dcfe81) - [x] Unit Tests Unit tests added for all dw scenarios.
1 parent 4d53e7e commit 542ac92

File tree

15 files changed

+888
-86
lines changed

15 files changed

+888
-86
lines changed

src/Core/Resolvers/DWSqlQueryBuilder.cs

Lines changed: 92 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ namespace Azure.DataApiBuilder.Core.Resolvers
1515
public class DwSqlQueryBuilder : BaseSqlQueryBuilder, IQueryBuilder
1616
{
1717
private static DbCommandBuilder _builder = new SqlCommandBuilder();
18+
public const string COUNT_ROWS_WITH_GIVEN_PK = "cnt_rows_to_update";
1819

1920
/// <inheritdoc />
2021
public override string QuoteIdentifier(string ident)
@@ -161,19 +162,48 @@ private static string GenerateColumnsAsJson(SqlQueryStructure structure, bool su
161162
/// <inheritdoc />
162163
public string Build(SqlInsertStructure structure)
163164
{
164-
throw new NotImplementedException("DataWarehouse Sql currently does not support inserts");
165+
string tableName = $"{QuoteIdentifier(structure.DatabaseObject.SchemaName)}.{QuoteIdentifier(structure.DatabaseObject.Name)}";
166+
167+
// Predicates by virtue of database policy for Create action.
168+
string dbPolicypredicates = JoinPredicateStrings(structure.GetDbPolicyForOperation(EntityActionOperation.Create));
169+
170+
// Columns whose values are provided in the request body - to be inserted into the record.
171+
string insertColumns = Build(structure.InsertColumns);
172+
173+
// Values to be inserted into the entity.
174+
string values = dbPolicypredicates.Equals(BASE_PREDICATE) ?
175+
$"VALUES ({string.Join(", ", structure.Values)});" : $"SELECT {insertColumns} FROM (VALUES({string.Join(", ", structure.Values)})) T({insertColumns}) WHERE {dbPolicypredicates};";
176+
177+
// Final insert query to be executed against the database.
178+
StringBuilder insertQuery = new();
179+
insertQuery.Append($"INSERT INTO {tableName} ({insertColumns}) ");
180+
insertQuery.Append(values);
181+
182+
return insertQuery.ToString();
165183
}
166184

167185
/// <inheritdoc />
168186
public string Build(SqlUpdateStructure structure)
169187
{
170-
throw new NotImplementedException("DataWarehouse sql currently does not support updates");
188+
string tableName = $"{QuoteIdentifier(structure.DatabaseObject.SchemaName)}.{QuoteIdentifier(structure.DatabaseObject.Name)}";
189+
string predicates = JoinPredicateStrings(
190+
structure.GetDbPolicyForOperation(EntityActionOperation.Update),
191+
Build(structure.Predicates));
192+
193+
StringBuilder updateQuery = new($"UPDATE {tableName} SET {Build(structure.UpdateOperations, ", ")} ");
194+
updateQuery.Append($"WHERE {predicates};");
195+
return updateQuery.ToString();
171196
}
172197

173198
/// <inheritdoc />
174199
public string Build(SqlDeleteStructure structure)
175200
{
176-
throw new NotImplementedException("DataWarehouse sql currently does not support deletes");
201+
string predicates = JoinPredicateStrings(
202+
structure.GetDbPolicyForOperation(EntityActionOperation.Delete),
203+
Build(structure.Predicates));
204+
205+
return $"DELETE FROM {QuoteIdentifier(structure.DatabaseObject.SchemaName)}.{QuoteIdentifier(structure.DatabaseObject.Name)} " +
206+
$"WHERE {predicates} ";
177207
}
178208

179209
/// <inheritdoc />
@@ -185,7 +215,65 @@ public string Build(SqlExecuteStructure structure)
185215
/// <inheritdoc />
186216
public string Build(SqlUpsertQueryStructure structure)
187217
{
188-
throw new NotImplementedException("DataWarehouse sql currently does not support updates");
218+
string tableName = $"{QuoteIdentifier(structure.DatabaseObject.SchemaName)}.{QuoteIdentifier(structure.DatabaseObject.Name)}";
219+
220+
// Predicates by virtue of PK.
221+
string pkPredicates = JoinPredicateStrings(Build(structure.Predicates));
222+
223+
string updateOperations = Build(structure.UpdateOperations, ", ");
224+
string queryToGetCountOfRecordWithPK = $"SELECT COUNT(*) as {COUNT_ROWS_WITH_GIVEN_PK} FROM {tableName} WHERE {pkPredicates}";
225+
226+
// Query to get the number of records with a given PK.
227+
string prefixQuery = $"DECLARE @ROWS_TO_UPDATE int;" +
228+
$"SET @ROWS_TO_UPDATE = ({queryToGetCountOfRecordWithPK}); " +
229+
$"{queryToGetCountOfRecordWithPK};";
230+
231+
// Final query to be executed for the given PUT/PATCH operation.
232+
StringBuilder upsertQuery = new(prefixQuery);
233+
234+
// Query to update record (if there exists one for given PK).
235+
StringBuilder updateQuery = new(
236+
$"IF @ROWS_TO_UPDATE = 1 " +
237+
$"BEGIN " +
238+
$"UPDATE {tableName} " +
239+
$"SET {updateOperations} ");
240+
241+
// End the IF block.
242+
updateQuery.Append("END ");
243+
244+
// Append the update query to upsert query.
245+
upsertQuery.Append(updateQuery);
246+
if (!structure.IsFallbackToUpdate)
247+
{
248+
// Append the conditional to check if the insert query is to be executed or not.
249+
// Insert is only attempted when no record exists corresponding to given PK.
250+
upsertQuery.Append("ELSE BEGIN ");
251+
252+
// Columns which are assigned some value in the PUT/PATCH request.
253+
string insertColumns = Build(structure.InsertColumns);
254+
255+
// Predicates added by virtue of database policy for create operation.
256+
string createPredicates = JoinPredicateStrings(structure.GetDbPolicyForOperation(EntityActionOperation.Create));
257+
258+
// Query to insert record (if there exists none for given PK).
259+
StringBuilder insertQuery = new($"INSERT INTO {tableName} ({insertColumns}) ");
260+
261+
// Query to fetch the column values to be inserted into the entity.
262+
string fetchColumnValuesQuery = BASE_PREDICATE.Equals(createPredicates) ?
263+
$"VALUES({string.Join(", ", structure.Values)});" :
264+
$"SELECT {insertColumns} FROM (VALUES({string.Join(", ", structure.Values)})) T({insertColumns}) WHERE {createPredicates};";
265+
266+
// Append the values to be inserted to the insertQuery.
267+
insertQuery.Append(fetchColumnValuesQuery);
268+
269+
// Append the insert query to the upsert query.
270+
upsertQuery.Append(insertQuery.ToString());
271+
272+
// End the ELSE block.
273+
upsertQuery.Append("END");
274+
}
275+
276+
return upsertQuery.ToString();
189277
}
190278

191279
/// <summary>

src/Core/Resolvers/Factories/MutationEngineFactory.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ public MutationEngineFactory(RuntimeConfigProvider runtimeConfigProvider,
5151
_mutationEngines.Add(DatabaseType.MySQL, mutationEngine);
5252
_mutationEngines.Add(DatabaseType.MSSQL, mutationEngine);
5353
_mutationEngines.Add(DatabaseType.PostgreSQL, mutationEngine);
54+
_mutationEngines.Add(DatabaseType.DWSQL, mutationEngine);
5455
}
5556

5657
if (config.CosmosDataSourceUsed)

src/Core/Resolvers/Sql Query Structures/SqlQueryStructure.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -302,7 +302,7 @@ private SqlQueryStructure(
302302
PaginationMetadata.Subqueries.Add(QueryBuilder.PAGINATION_FIELD_NAME, PaginationMetadata.MakeEmptyPaginationMetadata());
303303
}
304304

305-
EntityName = _underlyingFieldType.Name;
305+
EntityName = sqlMetadataProvider.GetDatabaseType() == DatabaseType.DWSQL ? GraphQLUtils.GetEntityNameFromContext(ctx) : _underlyingFieldType.Name;
306306

307307
if (GraphQLUtils.TryExtractGraphQLFieldModelName(_underlyingFieldType.Directives, out string? modelName))
308308
{

src/Core/Resolvers/SqlMutationEngine.cs

Lines changed: 63 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -83,14 +83,7 @@ public SqlMutationEngine(
8383

8484
dataSourceName = GetValidatedDataSourceName(dataSourceName);
8585
string graphqlMutationName = context.Selection.Field.Name.Value;
86-
IOutputType outputType = context.Selection.Field.Type;
87-
string entityName = outputType.TypeName();
88-
ObjectType _underlyingFieldType = GraphQLUtils.UnderlyingGraphQLEntityType(outputType);
89-
90-
if (GraphQLUtils.TryExtractGraphQLFieldModelName(_underlyingFieldType.Directives, out string? modelName))
91-
{
92-
entityName = modelName;
93-
}
86+
string entityName = GraphQLUtils.GetEntityNameFromContext(context);
9487

9588
ISqlMetadataProvider sqlMetadataProvider = _sqlMetadataProviderFactory.GetMetadataProvider(dataSourceName);
9689
IQueryEngine queryEngine = _queryEngineFactory.GetQueryEngine(sqlMetadataProvider.GetDatabaseType());
@@ -119,12 +112,17 @@ public SqlMutationEngine(
119112
// be computed only when the read permission is configured.
120113
if (isReadPermissionConfigured)
121114
{
122-
// compute the mutation result before removing the element,
123-
// since typical GraphQL delete mutations return the metadata of the deleted item.
124-
result = await queryEngine.ExecuteAsync(
125-
context,
126-
GetBackingColumnsFromCollection(entityName: entityName, parameters: parameters, sqlMetadataProvider: sqlMetadataProvider),
127-
dataSourceName);
115+
// For cases we only require a result summarizing the operation (DBOperationResult),
116+
// we can skip getting the impacted records.
117+
if (context.Selection.Type.TypeName() != GraphQLUtils.DB_OPERATION_RESULT_TYPE)
118+
{
119+
// compute the mutation result before removing the element,
120+
// since typical GraphQL delete mutations return the metadata of the deleted item.
121+
result = await queryEngine.ExecuteAsync(
122+
context,
123+
GetBackingColumnsFromCollection(entityName: entityName, parameters: parameters, sqlMetadataProvider: sqlMetadataProvider),
124+
dataSourceName);
125+
}
128126
}
129127

130128
Dictionary<string, object>? resultProperties =
@@ -134,16 +132,28 @@ await PerformDeleteOperation(
134132
sqlMetadataProvider);
135133

136134
// If the number of records affected by DELETE were zero,
137-
// and yet the result was not null previously, it indicates this DELETE lost
138-
// a concurrent request race. Hence, empty the non-null result.
139135
if (resultProperties is not null
140136
&& resultProperties.TryGetValue(nameof(DbDataReader.RecordsAffected), out object? value)
141-
&& Convert.ToInt32(value) == 0
142-
&& result is not null && result.Item1 is not null)
137+
&& Convert.ToInt32(value) == 0)
138+
{
139+
// the result was not null previously, it indicates this DELETE lost
140+
// a concurrent request race. Hence, empty the non-null result.
141+
if (result is not null && result.Item1 is not null)
142+
{
143+
144+
result = new Tuple<JsonDocument?, IMetadata?>(
145+
default(JsonDocument),
146+
PaginationMetadata.MakeEmptyPaginationMetadata());
147+
}
148+
else if (context.Selection.Type.TypeName() == GraphQLUtils.DB_OPERATION_RESULT_TYPE)
149+
{
150+
// no record affected but db call ran successfully.
151+
result = GetDbOperationResultJsonDocument("item not found");
152+
}
153+
}
154+
else if (context.Selection.Type.TypeName() == GraphQLUtils.DB_OPERATION_RESULT_TYPE)
143155
{
144-
result = new Tuple<JsonDocument?, IMetadata?>(
145-
default(JsonDocument),
146-
PaginationMetadata.MakeEmptyPaginationMetadata());
156+
result = GetDbOperationResultJsonDocument("success");
147157
}
148158
}
149159
else
@@ -158,17 +168,24 @@ await PerformMutationOperation(
158168

159169
// When read permission is not configured, an error response is returned. So, the mutation result needs to
160170
// be computed only when the read permission is configured.
161-
if (isReadPermissionConfigured && mutationResultRow is not null && mutationResultRow.Columns.Count > 0
162-
&& !context.Selection.Type.IsScalarType())
171+
if (isReadPermissionConfigured)
163172
{
164-
// Because the GraphQL mutation result set columns were exposed (mapped) column names,
165-
// the column names must be converted to backing (source) column names so the
166-
// PrimaryKeyPredicates created in the SqlQueryStructure created by the query engine
167-
// represent database column names.
168-
result = await queryEngine.ExecuteAsync(
169-
context,
170-
GetBackingColumnsFromCollection(entityName: entityName, parameters: mutationResultRow.Columns, sqlMetadataProvider: sqlMetadataProvider),
171-
dataSourceName);
173+
if (mutationResultRow is not null && mutationResultRow.Columns.Count > 0
174+
&& !context.Selection.Type.IsScalarType())
175+
{
176+
// Because the GraphQL mutation result set columns were exposed (mapped) column names,
177+
// the column names must be converted to backing (source) column names so the
178+
// PrimaryKeyPredicates created in the SqlQueryStructure created by the query engine
179+
// represent database column names.
180+
result = await queryEngine.ExecuteAsync(
181+
context,
182+
GetBackingColumnsFromCollection(entityName: entityName, parameters: mutationResultRow.Columns, sqlMetadataProvider: sqlMetadataProvider),
183+
dataSourceName);
184+
}
185+
else if (context.Selection.Type.TypeName() == GraphQLUtils.DB_OPERATION_RESULT_TYPE)
186+
{
187+
result = GetDbOperationResultJsonDocument("success");
188+
}
172189
}
173190
}
174191

@@ -829,7 +846,8 @@ await queryExecutor.ExecuteQueryAsync(
829846

830847
dbResultSetRow = dbResultSet is not null ?
831848
(dbResultSet.Rows.FirstOrDefault() ?? new DbResultSetRow()) : null;
832-
if (dbResultSetRow is not null && dbResultSetRow.Columns.Count == 0)
849+
850+
if (dbResultSetRow is not null && dbResultSetRow.Columns.Count == 0 && dbResultSet!.ResultProperties.TryGetValue("RecordsAffected", out object? recordsAffected) && (int)recordsAffected <= 0)
833851
{
834852
// For GraphQL, insert operation corresponds to Create action.
835853
if (operationType is EntityActionOperation.Create)
@@ -1178,5 +1196,18 @@ private string GetValidatedDataSourceName(string dataSourceName)
11781196
// For rest scenarios - no multiple db support. Hence to maintain backward compatibility, we will use the default db.
11791197
return string.IsNullOrEmpty(dataSourceName) ? _runtimeConfigProvider.GetConfig().GetDefaultDataSourceName() : dataSourceName;
11801198
}
1199+
1200+
/// <summary>
1201+
/// Returns DbOperationResult with required result.
1202+
/// </summary>
1203+
private static Tuple<JsonDocument?, IMetadata?> GetDbOperationResultJsonDocument(string result)
1204+
{
1205+
// Create a JSON object with one field "result" and value result
1206+
JsonObject jsonObject = new() { { "result", result } };
1207+
1208+
return new Tuple<JsonDocument?, IMetadata?>(
1209+
JsonDocument.Parse(jsonObject.ToString()),
1210+
PaginationMetadata.MakeEmptyPaginationMetadata());
1211+
}
11811212
}
11821213
}

src/Core/Services/GraphQLSchemaCreator.cs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
using Azure.DataApiBuilder.Core.Resolvers.Factories;
1212
using Azure.DataApiBuilder.Core.Services.MetadataProviders;
1313
using Azure.DataApiBuilder.Service.Exceptions;
14+
using Azure.DataApiBuilder.Service.GraphQLBuilder;
1415
using Azure.DataApiBuilder.Service.GraphQLBuilder.Directives;
1516
using Azure.DataApiBuilder.Service.GraphQLBuilder.GraphQLTypes;
1617
using Azure.DataApiBuilder.Service.GraphQLBuilder.Mutations;
@@ -232,6 +233,20 @@ private DocumentNode GenerateSqlGraphQLObjects(RuntimeEntities entities, Diction
232233
objectTypes[entityName] = QueryBuilder.AddQueryArgumentsForRelationships(node, inputObjects);
233234
}
234235

236+
Dictionary<string, FieldDefinitionNode> fields = new();
237+
NameNode nameNode = new(value: GraphQLUtils.DB_OPERATION_RESULT_TYPE);
238+
FieldDefinitionNode field = GetDbOperationResultField();
239+
240+
fields.TryAdd("result", field);
241+
242+
objectTypes.Add(GraphQLUtils.DB_OPERATION_RESULT_TYPE, new ObjectTypeDefinitionNode(
243+
location: null,
244+
name: nameNode,
245+
description: null,
246+
new List<DirectiveNode>(),
247+
new List<NamedTypeNode>(),
248+
fields.Values.ToImmutableList()));
249+
235250
List<IDefinitionNode> nodes = new(objectTypes.Values);
236251
return new DocumentNode(nodes);
237252
}
@@ -267,6 +282,21 @@ private DocumentNode GenerateCosmosGraphQLObjects(HashSet<string> dataSourceName
267282
return root;
268283
}
269284

285+
/// <summary>
286+
/// Create and return a default GraphQL result field for a mutation which doesn't
287+
/// define a result set and doesn't return any rows.
288+
/// </summary>
289+
private static FieldDefinitionNode GetDbOperationResultField()
290+
{
291+
return new(
292+
location: null,
293+
name: new("result"),
294+
description: new StringValueNode("Contains result for mutation execution"),
295+
arguments: new List<InputValueDefinitionNode>(),
296+
type: new StringType().ToTypeNode(),
297+
directives: new List<DirectiveNode>());
298+
}
299+
270300
public (DocumentNode, Dictionary<string, InputObjectTypeDefinitionNode>) GenerateGraphQLObjects()
271301
{
272302
RuntimeConfig runtimeConfig = _runtimeConfigProvider.GetConfig();

src/Service.GraphQLBuilder/Directives/ModelTypeDirective.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ protected override void Configure(IDirectiveTypeDescriptor descriptor)
1515
descriptor.Name(DirectiveName)
1616
.Description("A directive to indicate the type maps to a storable entity not a nested entity.");
1717

18-
descriptor.Location(DirectiveLocation.Object);
18+
descriptor.Location(DirectiveLocation.Object | DirectiveLocation.FieldDefinition);
1919

2020
descriptor.Argument(ModelNameArgument)
2121
.Description("Underlying name of the database entity.")

0 commit comments

Comments
 (0)