diff --git a/config-generators/mssql-commands.txt b/config-generators/mssql-commands.txt index ac9c9f3aaa..8f5f4df7fc 100644 --- a/config-generators/mssql-commands.txt +++ b/config-generators/mssql-commands.txt @@ -1,14 +1,19 @@ init --config "dab-config.MsSql.json" --database-type mssql --set-session-context true --connection-string "Server=tcp:127.0.0.1,1433;Persist Security Info=False;User ID=sa;Password=REPLACEME;MultipleActiveResultSets=False;Connection Timeout=5;" --host-mode Development --cors-origin "http://localhost:5000" --graphql.multiple-create.enabled true add Publisher --config "dab-config.MsSql.json" --source publishers --permissions "anonymous:read" +add Publisher_MM --config "dab-config.MsSql.json" --source publishers_mm --graphql "Publisher_MM:Publishers_MM" --permissions "anonymous:*" add Stock --config "dab-config.MsSql.json" --source stocks --permissions "anonymous:create,read,update,delete" add Book --config "dab-config.MsSql.json" --source books --permissions "anonymous:create,read,update,delete" --graphql "book:books" +add Book_MM --config "dab-config.MsSql.json" --source books_mm --permissions "anonymous:*" --graphql "book_mm:books_mm" add BookWebsitePlacement --config "dab-config.MsSql.json" --source book_website_placements --permissions "anonymous:read" add Author --config "dab-config.MsSql.json" --source authors --permissions "anonymous:read" +add Author_MM --config "dab-config.MsSql.json" --source authors_mm --graphql "author_mm:authors_mm" --permissions "anonymous:*" add Revenue --config "dab-config.MsSql.json" --source revenues --permissions "anonymous:*" add Review --config "dab-config.MsSql.json" --source reviews --permissions "anonymous:create,read,update" --rest true --graphql "review:reviews" +add Review_MM --config "dab-config.MsSql.json" --source reviews_mm --permissions "anonymous:*" --rest true --graphql "review_mm:reviews_mm" add Comic --config "dab-config.MsSql.json" --source comics --permissions "anonymous:create,read,update" add Broker --config "dab-config.MsSql.json" --source brokers --permissions "anonymous:read" add WebsiteUser --config "dab-config.MsSql.json" --source website_users --permissions "anonymous:create,read,delete,update" +add WebsiteUser_MM --config "dab-config.MsSql.json" --source website_users_mm --graphql "websiteuser_mm:websiteusers_mm" --permissions "anonymous:*" add SupportedType --config "dab-config.MsSql.json" --source type_table --permissions "anonymous:create,read,delete,update" add stocks_price --config "dab-config.MsSql.json" --source stocks_price --permissions "authenticated:create,read,update,delete" update stocks_price --config "dab-config.MsSql.json" --permissions "anonymous:read" @@ -71,6 +76,9 @@ update Publisher --config "dab-config.MsSql.json" --permissions "policy_tester_0 update Publisher --config "dab-config.MsSql.json" --permissions "database_policy_tester:read" --policy-database "@item.id ne 1234 or @item.id gt 1940" update Publisher --config "dab-config.MsSql.json" --permissions "database_policy_tester:update" --policy-database "@item.id ne 1234" update Publisher --config "dab-config.MsSql.json" --permissions "database_policy_tester:create" --policy-database "@item.name ne 'New publisher'" +update Publisher --config "dab-config.MsSql.json" --permissions "role_multiple_create_policy_tester:create" --policy-database "@item.name ne 'Test'" +update Publisher --config "dab-config.MsSql.json" --permissions "role_multiple_create_policy_tester:read,update,delete" +update Publisher_MM --config "dab-config.MsSql.json" --permissions "authenticated:*" --relationship books_mm --relationship.fields "id:publisher_id" --target.entity Book_MM --cardinality many update Stock --config "dab-config.MsSql.json" --permissions "authenticated:create,read,update,delete" update Stock --config "dab-config.MsSql.json" --permissions "test_role_with_excluded_fields_on_create:read,update,delete" update Stock --config "dab-config.MsSql.json" --permissions "test_role_with_excluded_fields_on_create:create" --fields.exclude "piecesAvailable" @@ -112,12 +120,27 @@ update Book --config "dab-config.MsSql.json" --permissions "test_role_with_exclu update Book --config "dab-config.MsSql.json" --permissions "test_role_with_excluded_fields:read" --fields.exclude "publisher_id" update Book --config "dab-config.MsSql.json" --permissions "test_role_with_policy_excluded_fields:create,update,delete" update Book --config "dab-config.MsSql.json" --permissions "test_role_with_policy_excluded_fields:read" --fields.exclude "publisher_id" --policy-database "@item.title ne 'Test'" +update Book --config "dab-config.MsSql.json" --permissions "role_multiple_create_policy_tester:read" --policy-database "@item.publisher_id ne 1234" +update Book --config "dab-config.MsSql.json" --permissions "role_multiple_create_policy_tester:create" --policy-database "@item.title ne 'Test'" +update Book --config "dab-config.MsSql.json" --permissions "role_multiple_create_policy_tester:update,delete" +update Book_MM --config "dab-config.MsSql.json" --permissions "authenticated:*" +update Book_MM --config "dab-config.MsSql.json" --relationship publishers --target.entity Publisher_MM --cardinality one --relationship.fields "publisher_id:id" +update Book_MM --config "dab-config.MsSql.json" --relationship reviews --target.entity Review_MM --cardinality many --relationship.fields "id:book_id" +update Book_MM --config "dab-config.MsSql.json" --relationship authors --relationship.fields "id:id" --target.entity Author_MM --cardinality many --linking.object book_author_link_mm --linking.source.fields "book_id" --linking.target.fields "author_id" update Review --config "dab-config.MsSql.json" --permissions "authenticated:create,read,update,delete" update Review --config "dab-config.MsSql.json" --relationship books --target.entity Book --cardinality one +update Review --config "dab-config.MsSql.json" --relationship website_users --target.entity WebsiteUser --cardinality one --relationship.fields "websiteuser_id:id" +update Review --config "dab-config.MsSql.json" --permissions "role_multiple_create_policy_tester:read" --policy-database "@item.websiteuser_id ne 1" +update Review --config "dab-config.MsSql.json" --permissions "role_multiple_create_policy_tester:create" --policy-database "@item.content ne 'Great'" +update Review --config "dab-config.MsSql.json" --permissions "role_multiple_create_policy_tester:update,delete" +update Review_MM --config "dab-config.MsSql.json" --permissions "authenticated:*" --relationship books --relationship.fields "book_id:id" --target.entity Book_MM --cardinality one +update Review_MM --config "dab-config.MsSql.json" --relationship website_users --target.entity WebsiteUser_MM --cardinality one --relationship.fields "websiteuser_id:id" update BookWebsitePlacement --config "dab-config.MsSql.json" --permissions "authenticated:create,update" --rest true --graphql true update BookWebsitePlacement --config "dab-config.MsSql.json" --permissions "authenticated:delete" --fields.include "*" --policy-database "@claims.userId eq @item.id" update Author --config "dab-config.MsSql.json" --permissions "authenticated:create,read,update,delete" --rest true --graphql true update WebsiteUser --config "dab-config.MsSql.json" --permissions "authenticated:create,read,delete,update" --rest false --graphql "websiteUser:websiteUsers" +update WebsiteUser -c "dab-config.MsSql.json" --relationship reviews --target.entity Review --cardinality many --relationship.fields "id:websiteuser_id" +update WebsiteUser_MM --config "dab-config.MsSql.json" --source website_users_mm --permissions "authenticated:*" --relationship reviews --relationship.fields "id:websiteuser_id" --target.entity Review_MM --cardinality many update Revenue --config "dab-config.MsSql.json" --permissions "database_policy_tester:create" --policy-database "@item.revenue gt 1000" update Comic --config "dab-config.MsSql.json" --permissions "authenticated:create,read,update,delete" --rest true --graphql true --relationship myseries --target.entity series --cardinality one update series --config "dab-config.MsSql.json" --relationship comics --target.entity Comic --cardinality many @@ -134,6 +157,7 @@ update books_view_with_mapping --config "dab-config.MsSql.json" --map "id:book_i update BookWebsitePlacement --config "dab-config.MsSql.json" --relationship books --target.entity Book --cardinality one update SupportedType --config "dab-config.MsSql.json" --map "id:typeid" --permissions "authenticated:create,read,delete,update" update Author --config "dab-config.MsSql.json" --relationship books --target.entity Book --cardinality many --linking.object book_author_link +update Author_MM --config "dab-config.MsSql.json" --permissions "authenticated:*" --relationship books --relationship.fields "id:id" --target.entity Book_MM --cardinality many --linking.object book_author_link_mm --linking.source.fields "author_id" --linking.target.fields "book_id" update Notebook --config "dab-config.MsSql.json" --permissions "anonymous:create,update,delete" update Empty --config "dab-config.MsSql.json" --permissions "anonymous:read" update Journal --config "dab-config.MsSql.json" --permissions "policy_tester_noupdate:update" --fields.include "*" --policy-database "@item.id ne 1" diff --git a/src/Config/DataApiBuilderException.cs b/src/Config/DataApiBuilderException.cs index 77b489c84a..239e51c4d6 100644 --- a/src/Config/DataApiBuilderException.cs +++ b/src/Config/DataApiBuilderException.cs @@ -113,7 +113,11 @@ public enum SubStatusCodes /// /// Invalid PK field(s) specified in the request. /// - InvalidIdentifierField + InvalidIdentifierField, + /// + /// Relationship Field's value not found + /// + RelationshipFieldNotFound } public HttpStatusCode StatusCode { get; } diff --git a/src/Core/Resolvers/BaseSqlQueryBuilder.cs b/src/Core/Resolvers/BaseSqlQueryBuilder.cs index 0df7ab935a..150c103183 100644 --- a/src/Core/Resolvers/BaseSqlQueryBuilder.cs +++ b/src/Core/Resolvers/BaseSqlQueryBuilder.cs @@ -318,8 +318,16 @@ protected virtual string Build(Predicate? predicate) /// /// Build and join predicates with separator (" AND " by default) /// - protected string Build(List predicates, string separator = " AND ") + /// List of predicates to be added + /// Operator to be used with the list of predicates. Default value: AND + /// Indicates whether the predicates are being formed for a multiple create operation. Default value: false. + protected string Build(List predicates, string separator = " AND ", bool isMultipleCreateOperation = false) { + if (isMultipleCreateOperation) + { + return "(" + string.Join(separator, predicates.Select(p => Build(p))) + ")"; + } + return string.Join(separator, predicates.Select(p => Build(p))); } diff --git a/src/Core/Resolvers/IQueryBuilder.cs b/src/Core/Resolvers/IQueryBuilder.cs index 66743e5682..f86dd26d2e 100644 --- a/src/Core/Resolvers/IQueryBuilder.cs +++ b/src/Core/Resolvers/IQueryBuilder.cs @@ -16,6 +16,7 @@ public interface IQueryBuilder /// query. /// public string Build(SqlQueryStructure structure); + /// /// Builds the query specific to the target database for the given /// SqlInsertStructure object which holds the major components of the diff --git a/src/Core/Resolvers/IQueryEngine.cs b/src/Core/Resolvers/IQueryEngine.cs index 72d93db0f5..0350b3efd2 100644 --- a/src/Core/Resolvers/IQueryEngine.cs +++ b/src/Core/Resolvers/IQueryEngine.cs @@ -22,6 +22,20 @@ public interface IQueryEngine /// public Task> ExecuteAsync(IMiddlewareContext context, IDictionary parameters, string dataSourceName); + /// + /// Executes the given IMiddlewareContext of the GraphQL query and expects a list of JsonDocument objects back. + /// This method accepts a list of PKs for which to construct and return the response. + /// + /// IMiddleware context of the GraphQL query + /// List of PKs for which the response Json have to be computed and returned. + /// Each Pk is represented by a dictionary where (key, value) as (column name, column value). + /// Primary keys can be of composite and be of any type. Hence, the decision to represent + /// a PK as Dictionary + /// + /// DataSource name + /// Returns the json result and metadata object for the given list of PKs + public Task> ExecuteMultipleCreateFollowUpQueryAsync(IMiddlewareContext context, List> parameters, string dataSourceName) => throw new NotImplementedException(); + /// /// Executes the given IMiddlewareContext of the GraphQL and expecting a /// list of Jsons back. diff --git a/src/Core/Resolvers/IQueryExecutor.cs b/src/Core/Resolvers/IQueryExecutor.cs index 8110004444..2eac7242de 100644 --- a/src/Core/Resolvers/IQueryExecutor.cs +++ b/src/Core/Resolvers/IQueryExecutor.cs @@ -34,6 +34,28 @@ public interface IQueryExecutor HttpContext? httpContext = null, List? args = null); + /// + /// Executes sql text with the given parameters and + /// uses the function dataReaderHandler to process + /// the results from the DbDataReader and return into an object of type TResult. + /// This method is synchronous. It does not make use of async/await. + /// + /// SQL text to be executed. + /// The parameters used to execute the SQL text. + /// The function to invoke to handle the results + /// in the DbDataReader obtained after executing the query. + /// Current request httpContext. + /// List of string arguments to the DbDataReader handler. + /// dataSourceName against which to run query. Can specify null or empty to run against default db. + /// An object formed using the results of the query as returned by the given handler. + public TResult? ExecuteQuery( + string sqltext, + IDictionary parameters, + Func?, TResult>? dataReaderHandler, + HttpContext? httpContext = null, + List? args = null, + string dataSourceName = ""); + /// /// Extracts the rows from the given DbDataReader to populate /// the JsonArray to be returned. @@ -63,10 +85,20 @@ public Task GetJsonArrayAsync( /// A DbDataReader /// List of columns to extract. Extracts all if unspecified. /// Current Result Set in the DbDataReader. - public Task ExtractResultSetFromDbDataReader( + public Task ExtractResultSetFromDbDataReaderAsync( DbDataReader dbDataReader, List? args = null); + /// + /// Extracts the current Result Set of DbDataReader and format it + /// so it can be used as a parameter to query execution. + /// This method is synchronous. This does not make use of async await operations. + /// + /// A DbDataReader + /// List of columns to extract. Extracts all if unspecified. + /// Current Result Set in the DbDataReader. + public DbResultSet ExtractResultSetFromDbDataReader(DbDataReader dbDataReader, List? args = null); + /// /// Extracts the result set corresponding to the operation (update/insert) being executed. /// For PgSql,MySql, returns the first result set (among the two for update/insert) having non-zero affected rows. @@ -87,7 +119,18 @@ public Task GetMultipleResultSetsIfAnyAsync( /// A DbDataReader. /// List of string arguments if any. /// A dictionary of properties of the DbDataReader like RecordsAffected, HasRows. - public Task> GetResultProperties( + public Task> GetResultPropertiesAsync( + DbDataReader dbDataReader, + List? args = null); + + /// + /// Gets the result properties like RecordsAffected, HasRows in a dictionary. + /// This is a synchronous method. It does not make use of async/await. + /// + /// A DbDataReader. + /// List of string arguments if any. + /// A dictionary of properties of the DbDataReader like RecordsAffected, HasRows. + public Dictionary GetResultProperties( DbDataReader dbDataReader, List? args = null); @@ -98,6 +141,14 @@ public Task> GetResultProperties( /// public Task ReadAsync(DbDataReader reader); + /// + /// Wrapper for DbDataReader.Read(). + /// This will catch certain db errors and throw an exception which can + /// be reported to the user. + /// This method is synchronous. It does not make use of async/await. + /// + public bool Read(DbDataReader reader); + /// /// Modified the properties of the supplied connection to support managed identity access. /// diff --git a/src/Core/Resolvers/MsSqlQueryBuilder.cs b/src/Core/Resolvers/MsSqlQueryBuilder.cs index e401074780..493b7bc51d 100644 --- a/src/Core/Resolvers/MsSqlQueryBuilder.cs +++ b/src/Core/Resolvers/MsSqlQueryBuilder.cs @@ -42,11 +42,24 @@ public string Build(SqlQueryStructure structure) structure.JoinQueries.Select( x => $" OUTER APPLY ({Build(x.Value)}) AS {QuoteIdentifier(x.Key)}({dataIdent})")); - string predicates = JoinPredicateStrings( + string predicates; + + if (structure.IsMultipleCreateOperation) + { + predicates = JoinPredicateStrings( + structure.GetDbPolicyForOperation(EntityActionOperation.Read), + structure.FilterPredicates, + Build(structure.Predicates, " OR ", isMultipleCreateOperation: true), + Build(structure.PaginationMetadata.PaginationPredicate)); + } + else + { + predicates = JoinPredicateStrings( structure.GetDbPolicyForOperation(EntityActionOperation.Read), structure.FilterPredicates, Build(structure.Predicates), Build(structure.PaginationMetadata.PaginationPredicate)); + } string query = $"SELECT TOP {structure.Limit()} {WrappedColumns(structure)}" + $" FROM {fromSql}" diff --git a/src/Core/Resolvers/MsSqlQueryExecutor.cs b/src/Core/Resolvers/MsSqlQueryExecutor.cs index ae756b2b59..96f82cfa25 100644 --- a/src/Core/Resolvers/MsSqlQueryExecutor.cs +++ b/src/Core/Resolvers/MsSqlQueryExecutor.cs @@ -231,7 +231,7 @@ public override async Task GetMultipleResultSetsIfAnyAsync( DbDataReader dbDataReader, List? args = null) { // From the first result set, we get the count(0/1) of records with given PK. - DbResultSet resultSetWithCountOfRowsWithGivenPk = await ExtractResultSetFromDbDataReader(dbDataReader); + DbResultSet resultSetWithCountOfRowsWithGivenPk = await ExtractResultSetFromDbDataReaderAsync(dbDataReader); DbResultSetRow? resultSetRowWithCountOfRowsWithGivenPk = resultSetWithCountOfRowsWithGivenPk.Rows.FirstOrDefault(); int numOfRecordsWithGivenPK; @@ -249,7 +249,7 @@ public override async Task GetMultipleResultSetsIfAnyAsync( } // The second result set holds the records returned as a result of the executed update/insert operation. - DbResultSet? dbResultSet = await dbDataReader.NextResultAsync() ? await ExtractResultSetFromDbDataReader(dbDataReader) : null; + DbResultSet? dbResultSet = await dbDataReader.NextResultAsync() ? await ExtractResultSetFromDbDataReaderAsync(dbDataReader) : null; if (dbResultSet is null) { diff --git a/src/Core/Resolvers/QueryExecutor.cs b/src/Core/Resolvers/QueryExecutor.cs index 33a6d19644..3fe8041ec4 100644 --- a/src/Core/Resolvers/QueryExecutor.cs +++ b/src/Core/Resolvers/QueryExecutor.cs @@ -32,7 +32,9 @@ public class QueryExecutor : IQueryExecutor // So to say in case of transient exceptions, the query will be executed (_maxRetryCount + 1) times at max. private static int _maxRetryCount = 5; - private AsyncRetryPolicy _retryPolicy; + private AsyncRetryPolicy _retryPolicyAsync; + + private RetryPolicy _retryPolicy; /// /// Dictionary that stores dataSourceName to its corresponding connection string builder. @@ -49,15 +51,98 @@ public QueryExecutor(DbExceptionParser dbExceptionParser, ConnectionStringBuilders = new Dictionary(); ConfigProvider = configProvider; HttpContextAccessor = httpContextAccessor; + _retryPolicyAsync = Policy + .Handle(DbExceptionParser.IsTransientException) + .WaitAndRetryAsync( + retryCount: _maxRetryCount, + sleepDurationProvider: (attempt) => TimeSpan.FromSeconds(Math.Pow(2, attempt)), + onRetry: (exception, backOffTime) => + { + QueryExecutorLogger.LogError(exception: exception, message: "Error during query execution, retrying."); + }); + _retryPolicy = Policy - .Handle(DbExceptionParser.IsTransientException) - .WaitAndRetryAsync( - retryCount: _maxRetryCount, - sleepDurationProvider: (attempt) => TimeSpan.FromSeconds(Math.Pow(2, attempt)), - onRetry: (exception, backOffTime) => + .Handle(DbExceptionParser.IsTransientException) + .WaitAndRetry( + retryCount: _maxRetryCount, + sleepDurationProvider: (attempt) => TimeSpan.FromSeconds(Math.Pow(2, attempt)), + onRetry: (exception, backOffTime) => + { + QueryExecutorLogger.LogError(exception: exception, message: "Error during query execution, retrying."); + }); + } + + /// + public virtual TResult? ExecuteQuery( + string sqltext, + IDictionary parameters, + Func?, TResult>? dataReaderHandler, + HttpContext? httpContext = null, + List? args = null, + string dataSourceName = "") + { + if (string.IsNullOrEmpty(dataSourceName)) + { + dataSourceName = ConfigProvider.GetConfig().DefaultDataSourceName; + } + + if (!ConnectionStringBuilders.ContainsKey(dataSourceName)) + { + throw new DataApiBuilderException("Query execution failed. Could not find datasource to execute query against", HttpStatusCode.BadRequest, DataApiBuilderException.SubStatusCodes.DataSourceNotFound); + } + + using TConnection conn = new() + { + ConnectionString = ConnectionStringBuilders[dataSourceName].ConnectionString, + }; + + int retryAttempt = 0; + + SetManagedIdentityAccessTokenIfAny(conn, dataSourceName); + + return _retryPolicy.Execute(() => + { + retryAttempt++; + try { - QueryExecutorLogger.LogError(exception: exception, message: "Error during query execution, retrying."); - }); + // When IsLateConfigured is true we are in a hosted scenario and do not reveal query information. + if (!ConfigProvider.IsLateConfigured) + { + string correlationId = HttpContextExtensions.GetLoggerCorrelationId(httpContext); + QueryExecutorLogger.LogDebug("{correlationId} Executing query: {queryText}", correlationId, sqltext); + } + + TResult? result = ExecuteQueryAgainstDb(conn, sqltext, parameters, dataReaderHandler, httpContext, dataSourceName, args); + + if (retryAttempt > 1) + { + string correlationId = HttpContextExtensions.GetLoggerCorrelationId(httpContext); + int maxRetries = _maxRetryCount + 1; + // This implies that the request got successfully executed during one of retry attempts. + QueryExecutorLogger.LogInformation("{correlationId} Request executed successfully in {retryAttempt} attempt of {maxRetries} available attempts.", correlationId, retryAttempt, maxRetries); + } + + return result; + } + catch (DbException e) + { + if (DbExceptionParser.IsTransientException((DbException)e) && retryAttempt < _maxRetryCount + 1) + { + throw e; + } + else + { + QueryExecutorLogger.LogError( + exception: e, + message: "{correlationId} Query execution error due to:\n{errorMessage}", + HttpContextExtensions.GetLoggerCorrelationId(httpContext), + e.Message); + + // Throw custom DABException + throw DbExceptionParser.Parse(e); + } + } + }); } /// @@ -87,7 +172,7 @@ public QueryExecutor(DbExceptionParser dbExceptionParser, await SetManagedIdentityAccessTokenIfAnyAsync(conn, dataSourceName); - return await _retryPolicy.ExecuteAsync(async () => + return await _retryPolicyAsync.ExecuteAsync(async () => { retryAttempt++; try @@ -218,6 +303,43 @@ public virtual DbCommand PrepareDbCommand( return cmd; } + /// + public virtual TResult? ExecuteQueryAgainstDb( + TConnection conn, + string sqltext, + IDictionary parameters, + Func?, TResult>? dataReaderHandler, + HttpContext? httpContext, + string dataSourceName, + List? args = null) + { + conn.Open(); + DbCommand cmd = PrepareDbCommand(conn, sqltext, parameters, httpContext, dataSourceName); + + try + { + using DbDataReader dbDataReader = cmd.ExecuteReader(CommandBehavior.CloseConnection); + if (dataReaderHandler is not null && dbDataReader is not null) + { + return dataReaderHandler(dbDataReader, args); + } + else + { + return default(TResult); + } + } + catch (DbException e) + { + string correlationId = HttpContextExtensions.GetLoggerCorrelationId(httpContext); + QueryExecutorLogger.LogError( + exception: e, + message: "{correlationId} Query execution error due to:\n{errorMessage}", + correlationId, + e.Message); + throw DbExceptionParser.Parse(e); + } + } + /// public virtual string GetSessionParamsQuery(HttpContext? httpContext, IDictionary parameters, string dataSourceName = "") { @@ -238,6 +360,12 @@ public virtual async Task SetManagedIdentityAccessTokenIfAnyAsync(DbConnection c await Task.Yield(); } + public virtual void SetManagedIdentityAccessTokenIfAny(DbConnection conn, string dataSourceName = "") + { + // no-op in the base class. + Task.Yield(); + } + /// public async Task ReadAsync(DbDataReader reader) { @@ -255,11 +383,28 @@ public async Task ReadAsync(DbDataReader reader) } } + /// + public bool Read(DbDataReader reader) + { + try + { + return reader.Read(); + } + catch (DbException e) + { + QueryExecutorLogger.LogError( + exception: e, + message: "Query execution error due to:\n{errorMessage}", + e.Message); + throw DbExceptionParser.Parse(e); + } + } + /// public async Task - ExtractResultSetFromDbDataReader(DbDataReader dbDataReader, List? args = null) + ExtractResultSetFromDbDataReaderAsync(DbDataReader dbDataReader, List? args = null) { - DbResultSet dbResultSet = new(resultProperties: GetResultProperties(dbDataReader).Result ?? new()); + DbResultSet dbResultSet = new(resultProperties: GetResultPropertiesAsync(dbDataReader).Result ?? new()); while (await ReadAsync(dbDataReader)) { @@ -298,6 +443,49 @@ public async Task return dbResultSet; } + /// + public DbResultSet + ExtractResultSetFromDbDataReader(DbDataReader dbDataReader, List? args = null) + { + DbResultSet dbResultSet = new(resultProperties: GetResultProperties(dbDataReader) ?? new()); + + while (Read(dbDataReader)) + { + if (dbDataReader.HasRows) + { + DbResultSetRow dbResultSetRow = new(); + DataTable? schemaTable = dbDataReader.GetSchemaTable(); + + if (schemaTable is not null) + { + foreach (DataRow schemaRow in schemaTable.Rows) + { + string columnName = (string)schemaRow["ColumnName"]; + + if (args is not null && !args.Contains(columnName)) + { + continue; + } + + int colIndex = dbDataReader.GetOrdinal(columnName); + if (!dbDataReader.IsDBNull(colIndex)) + { + dbResultSetRow.Columns.Add(columnName, dbDataReader[columnName]); + } + else + { + dbResultSetRow.Columns.Add(columnName, value: null); + } + } + } + + dbResultSet.Rows.Add(dbResultSetRow); + } + } + + return dbResultSet; + } + /// /// This function is a DbDataReader handler of type Func?, Task> /// The parameter args is not used but is added to conform to the signature of the DbDataReader handler @@ -306,7 +494,7 @@ public async Task GetJsonArrayAsync( DbDataReader dbDataReader, List? args = null) { - DbResultSet dbResultSet = await ExtractResultSetFromDbDataReader(dbDataReader); + DbResultSet dbResultSet = await ExtractResultSetFromDbDataReaderAsync(dbDataReader); JsonArray resultArray = new(); foreach (DbResultSetRow dbResultSetRow in dbResultSet.Rows) @@ -361,7 +549,7 @@ public virtual async Task GetMultipleResultSetsIfAnyAsync( DbDataReader dbDataReader, List? args = null) { DbResultSet dbResultSet - = await ExtractResultSetFromDbDataReader(dbDataReader); + = await ExtractResultSetFromDbDataReaderAsync(dbDataReader); /// Processes a second result set from DbDataReader if it exists. /// In MsSQL upsert: @@ -375,7 +563,7 @@ DbResultSet dbResultSet else if (await dbDataReader.NextResultAsync()) { // Since no first result set exists, we return the second result set. - return await ExtractResultSetFromDbDataReader(dbDataReader); + return await ExtractResultSetFromDbDataReaderAsync(dbDataReader); } else { @@ -401,7 +589,7 @@ DbResultSet dbResultSet /// /// This function is a DbDataReader handler of type /// Func?, Task> - public Task> GetResultProperties( + public Task> GetResultPropertiesAsync( DbDataReader dbDataReader, List? columnNames = null) { @@ -413,6 +601,21 @@ public Task> GetResultProperties( return Task.FromResult(resultProperties); } + /// + /// This function is a DbDataReader handler of type + /// Func?, TResult?> + public Dictionary GetResultProperties( + DbDataReader dbDataReader, + List? columnNames = null) + { + Dictionary resultProperties = new() + { + { nameof(dbDataReader.RecordsAffected), dbDataReader.RecordsAffected }, + { nameof(dbDataReader.HasRows), dbDataReader.HasRows } + }; + return resultProperties; + } + private async Task GetJsonStringFromDbReader(DbDataReader dbDataReader) { StringBuilder jsonString = new(); diff --git a/src/Core/Resolvers/Sql Query Structures/BaseSqlQueryStructure.cs b/src/Core/Resolvers/Sql Query Structures/BaseSqlQueryStructure.cs index 16e13fd3bc..e553078fd3 100644 --- a/src/Core/Resolvers/Sql Query Structures/BaseSqlQueryStructure.cs +++ b/src/Core/Resolvers/Sql Query Structures/BaseSqlQueryStructure.cs @@ -58,7 +58,8 @@ public BaseSqlQueryStructure( string entityName = "", IncrementingInteger? counter = null, HttpContext? httpContext = null, - EntityActionOperation operationType = EntityActionOperation.None + EntityActionOperation operationType = EntityActionOperation.None, + bool isLinkingEntity = false ) : base(metadataProvider, authorizationResolver, gQLFilterParser, predicates, entityName, counter) { @@ -67,7 +68,11 @@ public BaseSqlQueryStructure( // For GraphQL read operation, we are deliberately not passing httpContext to this point // and hence it will take its default value i.e. null here. // For GraphQL read operation, the database policy predicates are added later in the Sql{*}QueryStructure classes. - if (httpContext is not null) + // Linking entities are not configured by the users through the config file. + // DAB interprets the database metadata for linking tables and creates an Entity objects for them. + // This is done because linking entity field information are needed for successfully + // generating the schema when multiple create feature is enabled. + if (httpContext is not null && !isLinkingEntity) { AuthorizationPolicyHelpers.ProcessAuthorizationPolicies( operationType, diff --git a/src/Core/Resolvers/Sql Query Structures/MultipleCreateStructure.cs b/src/Core/Resolvers/Sql Query Structures/MultipleCreateStructure.cs new file mode 100644 index 0000000000..cdbf178bce --- /dev/null +++ b/src/Core/Resolvers/Sql Query Structures/MultipleCreateStructure.cs @@ -0,0 +1,88 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.DataApiBuilder.Core.Resolvers.Sql_Query_Structures +{ + /// + /// Wrapper class for the current entity to help with multiple create operation. + /// + internal class MultipleCreateStructure + { + /// + /// Field to indicate whehter a record needs to created in the linking table after + /// creating a record in the table backing the current entity. + /// Linking table and consequently this field is applicable only for M:N relationship type. + /// + public bool IsLinkingTableInsertionRequired; + + /// + /// Relationships that need to be processed before the current entity. Current entity references these entites + /// and needs the values of referenced columns to construct its INSERT SQL statement. + /// + public List> ReferencedRelationships; + + /// + /// Relationships that need to be processed after the current entity. Current entity is referenced by these entities + /// and the values of referenced columns needs to be passed to + /// these entities to construct the INSERT SQL statement. + /// + public List> ReferencingRelationships; + + /// + /// Fields belonging to the current entity. + /// + public Dictionary CurrentEntityParams; + + /// + /// Fields belonging to the linking table. + /// + public Dictionary LinkingTableParams; + + /// + /// Values in the record created in the table backing the current entity. + /// + public Dictionary? CurrentEntityCreatedValues; + + /// + /// Entity name for which this wrapper is created. + /// + public string EntityName; + + /// + /// Name of the immediate higher level entity. + /// + public string ParentEntityName; + + /// + /// Input parameters parsed from the graphQL mutation operation. + /// The parsed input parameters of the multiple create mutation result will be + /// assigned to this field. + /// Type of the object assigned depends on the type of the multiple create operation. + /// 1. Point multiple create - Dictionary + /// 2. Many multiple create - List> + /// + public object? InputMutParams; + + public MultipleCreateStructure( + string entityName, + string parentEntityName, + object? inputMutParams = null, + bool isLinkingTableInsertionRequired = false) + { + EntityName = entityName; + InputMutParams = inputMutParams; + ParentEntityName = parentEntityName; + + ReferencedRelationships = new(); + ReferencingRelationships = new(); + CurrentEntityParams = new Dictionary(); + LinkingTableParams = new Dictionary(); + + IsLinkingTableInsertionRequired = isLinkingTableInsertionRequired; + if (IsLinkingTableInsertionRequired) + { + LinkingTableParams = new Dictionary(); + } + } + } +} diff --git a/src/Core/Resolvers/Sql Query Structures/SqlInsertQueryStructure.cs b/src/Core/Resolvers/Sql Query Structures/SqlInsertQueryStructure.cs index 2f4689a72a..17a59531b6 100644 --- a/src/Core/Resolvers/Sql Query Structures/SqlInsertQueryStructure.cs +++ b/src/Core/Resolvers/Sql Query Structures/SqlInsertQueryStructure.cs @@ -41,14 +41,16 @@ public SqlInsertStructure( IAuthorizationResolver authorizationResolver, GQLFilterParser gQLFilterParser, IDictionary mutationParams, - HttpContext httpContext + HttpContext httpContext, + bool isLinkingEntity = false ) : this( entityName, sqlMetadataProvider, authorizationResolver, gQLFilterParser, - GQLMutArgumentToDictParams(context, MutationBuilder.ITEM_INPUT_ARGUMENT_NAME, mutationParams), - httpContext) + GQLMutArgumentToDictParams(context, CreateMutationBuilder.INPUT_ARGUMENT_NAME, mutationParams), + httpContext, + isLinkingEntity) { } public SqlInsertStructure( @@ -57,7 +59,8 @@ public SqlInsertStructure( IAuthorizationResolver authorizationResolver, GQLFilterParser gQLFilterParser, IDictionary mutationParams, - HttpContext httpContext + HttpContext httpContext, + bool isLinkingEntity = false ) : base( metadataProvider: sqlMetadataProvider, @@ -65,7 +68,8 @@ HttpContext httpContext gQLFilterParser: gQLFilterParser, entityName: entityName, httpContext: httpContext, - operationType: EntityActionOperation.Create) + operationType: EntityActionOperation.Create, + isLinkingEntity: isLinkingEntity) { InsertColumns = new(); Values = new(); diff --git a/src/Core/Resolvers/Sql Query Structures/SqlQueryStructure.cs b/src/Core/Resolvers/Sql Query Structures/SqlQueryStructure.cs index 46e6aaf93d..daa9c18861 100644 --- a/src/Core/Resolvers/Sql Query Structures/SqlQueryStructure.cs +++ b/src/Core/Resolvers/Sql Query Structures/SqlQueryStructure.cs @@ -80,6 +80,12 @@ public class SqlQueryStructure : BaseSqlQueryStructure /// private List? _primaryKeyAsOrderByColumns; + /// + /// Indicates whether the SqlQueryStructure is constructed for + /// a multiple create mutation operation. + /// + public bool IsMultipleCreateOperation; + /// /// Generate the structure for a SQL query based on GraphQL query /// information. @@ -116,6 +122,119 @@ public SqlQueryStructure( } } + /// + /// Generate the structure for a SQL query based on GraphQL query + /// information. This is used to construct the follow-up query + /// for a many-type multiple create mutation. + /// This constructor accepts a list of query parameters as opposed to a single query parameter + /// like the other constructors for SqlQueryStructure. + /// For constructing the follow-up query of a many-type multiple create mutation, the primary keys + /// of the created items in the top level entity will be passed as the query parameters. + /// + public SqlQueryStructure( + IMiddlewareContext ctx, + List> queryParams, + ISqlMetadataProvider sqlMetadataProvider, + IAuthorizationResolver authorizationResolver, + RuntimeConfigProvider runtimeConfigProvider, + GQLFilterParser gQLFilterParser, + IncrementingInteger counter, + string entityName = "", + bool isMultipleCreateOperation = false) + : this(sqlMetadataProvider, + authorizationResolver, + gQLFilterParser, + predicates: null, + entityName: entityName, + counter: counter) + { + _ctx = ctx; + IsMultipleCreateOperation = isMultipleCreateOperation; + + IObjectField schemaField = _ctx.Selection.Field; + FieldNode? queryField = _ctx.Selection.SyntaxNode; + + IOutputType outputType = schemaField.Type; + _underlyingFieldType = GraphQLUtils.UnderlyingGraphQLEntityType(outputType); + + PaginationMetadata.IsPaginated = QueryBuilder.IsPaginationType(_underlyingFieldType); + + if (PaginationMetadata.IsPaginated) + { + if (queryField != null && queryField.SelectionSet != null) + { + // process pagination fields without overriding them + ProcessPaginationFields(queryField.SelectionSet.Selections); + + // override schemaField and queryField with the schemaField and queryField of *Connection.items + queryField = ExtractItemsQueryField(queryField); + } + + schemaField = ExtractItemsSchemaField(schemaField); + + outputType = schemaField.Type; + _underlyingFieldType = GraphQLUtils.UnderlyingGraphQLEntityType(outputType); + + // this is required to correctly keep track of which pagination metadata + // refers to what section of the json + // for a paginationless chain: + // getbooks > publisher > books > publisher + // each new entry in the chain corresponds to a subquery so there will be + // a matching pagination metadata object chain + // for a chain with pagination: + // books > items > publisher > books > publisher + // items do not have a matching subquery so the line of code below is + // required to build a pagination metadata chain matching the json result + PaginationMetadata.Subqueries.Add(QueryBuilder.PAGINATION_FIELD_NAME, PaginationMetadata.MakeEmptyPaginationMetadata()); + } + + EntityName = _underlyingFieldType.Name; + + if (GraphQLUtils.TryExtractGraphQLFieldModelName(_underlyingFieldType.Directives, out string? modelName)) + { + EntityName = modelName; + } + + DatabaseObject.SchemaName = sqlMetadataProvider.GetSchemaName(EntityName); + DatabaseObject.Name = sqlMetadataProvider.GetDatabaseObjectName(EntityName); + SourceAlias = CreateTableAlias(); + + // support identification of entities by primary key when query is non list type nor paginated + // only perform this action for the outermost query as subqueries shouldn't provide primary key search + AddPrimaryKeyPredicates(queryParams); + + // SelectionSet will not be null when a field is not a leaf. + // There may be another entity to resolve as a sub-query. + if (queryField != null && queryField.SelectionSet != null) + { + AddGraphQLFields(queryField.SelectionSet.Selections, runtimeConfigProvider); + } + + HttpContext httpContext = GraphQLFilterParser.GetHttpContextFromMiddlewareContext(ctx); + // Process Authorization Policy of the entity being processed. + AuthorizationPolicyHelpers.ProcessAuthorizationPolicies(EntityActionOperation.Read, queryStructure: this, httpContext, authorizationResolver, sqlMetadataProvider); + + if (outputType.IsNonNullType()) + { + IsListQuery = outputType.InnerType().IsListType(); + } + else + { + IsListQuery = outputType.IsListType(); + } + + OrderByColumns = PrimaryKeyAsOrderByColumns(); + + // If there are no columns, add the primary key column + // to prevent failures when executing the database query. + if (!Columns.Any()) + { + AddColumn(PrimaryKey()[0]); + } + + ParametrizeColumns(); + } + /// /// Generate the structure for a SQL query based on FindRequestContext, /// which is created by a FindById or FindMany REST request. @@ -426,10 +545,21 @@ private SqlQueryStructure( OrderByColumns = new(); } + /// + /// Adds predicates for the primary keys in the parameters of the GraphQL query + /// + private void AddPrimaryKeyPredicates(List> queryParams) + { + foreach (IDictionary queryParam in queryParams) + { + AddPrimaryKeyPredicates(queryParam, isMultipleCreateOperation: true); + } + } + /// /// Adds predicates for the primary keys in the parameters of the GraphQL query /// - private void AddPrimaryKeyPredicates(IDictionary queryParams) + private void AddPrimaryKeyPredicates(IDictionary queryParams, bool isMultipleCreateOperation = false) { foreach (KeyValuePair parameter in queryParams) { @@ -447,7 +577,8 @@ private void AddPrimaryKeyPredicates(IDictionary queryParams) columnName: columnName, tableAlias: SourceAlias)), PredicateOperation.Equal, - new PredicateOperand($"{MakeDbConnectionParam(parameter.Value, columnName)}") + new PredicateOperand($"{MakeDbConnectionParam(parameter.Value, columnName)}"), + addParenthesis: isMultipleCreateOperation )); } } diff --git a/src/Core/Resolvers/SqlMutationEngine.cs b/src/Core/Resolvers/SqlMutationEngine.cs index 051a2e4a5b..73377bfd2e 100644 --- a/src/Core/Resolvers/SqlMutationEngine.cs +++ b/src/Core/Resolvers/SqlMutationEngine.cs @@ -14,6 +14,7 @@ using Azure.DataApiBuilder.Core.Configurations; using Azure.DataApiBuilder.Core.Models; using Azure.DataApiBuilder.Core.Resolvers.Factories; +using Azure.DataApiBuilder.Core.Resolvers.Sql_Query_Structures; using Azure.DataApiBuilder.Core.Services; using Azure.DataApiBuilder.Core.Services.MetadataProviders; using Azure.DataApiBuilder.Service.Exceptions; @@ -25,6 +26,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Extensions; using Microsoft.AspNetCore.Mvc; +using Microsoft.IdentityModel.Tokens; namespace Azure.DataApiBuilder.Core.Resolvers { @@ -42,6 +44,8 @@ public class SqlMutationEngine : IMutationEngine private readonly RuntimeConfigProvider _runtimeConfigProvider; public const string IS_UPDATE_RESULT_SET = "IsUpdateResultSet"; private const string TRANSACTION_EXCEPTION_ERROR_MSG = "An unexpected error occurred during the transaction execution"; + public const string SINGLE_INPUT_ARGUEMENT_NAME = "item"; + public const string MULTIPLE_INPUT_ARGUEMENT_NAME = "items"; private static DataApiBuilderException _dabExceptionWithTransactionErrorMessage = new(message: TRANSACTION_EXCEPTION_ERROR_MSG, statusCode: HttpStatusCode.InternalServerError, @@ -175,6 +179,41 @@ await PerformDeleteOperation( result = GetDbOperationResultJsonDocument("success"); } } + // This code block contains logic for handling multiple create mutation operations. + else if (mutationOperation is EntityActionOperation.Create && _runtimeConfigProvider.GetConfig().IsMultipleCreateOperationEnabled()) + { + bool isPointMutation = IsPointMutation(context); + + List> primaryKeysOfCreatedItems = PerformMultipleCreateOperation( + entityName, + context, + parameters, + sqlMetadataProvider, + !isPointMutation); + + // For point create multiple mutation operation, a single item is created in the + // table backing the top level entity. So, the PK of the created item is fetched and + // used when calling the query engine to process the selection set. + // For many type multiple create operation, one or more than one item are created + // in the table backing the top level entity. So, the PKs of the created items are + // fetched and used when calling the query engine to process the selection set. + // Point multiple create mutation and many type multiple create mutation are calling different + // overloaded method ("ExecuteAsync") of the query engine to process the selection set. + if (isPointMutation) + { + result = await queryEngine.ExecuteAsync( + context, + primaryKeysOfCreatedItems[0], + dataSourceName); + } + else + { + result = await queryEngine.ExecuteMultipleCreateFollowUpQueryAsync( + context, + primaryKeysOfCreatedItems, + dataSourceName); + } + } else { DbResultSetRow? mutationResultRow = @@ -210,6 +249,7 @@ await PerformMutationOperation( transactionScope.Complete(); } + } // All the exceptions that can be thrown by .Complete() and .Dispose() methods of transactionScope // derive from TransactionException. Hence, TransactionException acts as a catch-all. @@ -814,6 +854,7 @@ private async Task GetHttpContext()); queryString = queryBuilder.Build(insertQueryStruct); queryParameters = insertQueryStruct.Parameters; + break; case EntityActionOperation.Update: SqlUpdateStructure updateStructure = new( @@ -889,7 +930,7 @@ private async Task await queryExecutor.ExecuteQueryAsync( queryString, queryParameters, - queryExecutor.ExtractResultSetFromDbDataReader, + queryExecutor.ExtractResultSetFromDbDataReaderAsync, dataSourceName, GetHttpContext(), primaryKeyExposedColumnNames.Count > 0 ? primaryKeyExposedColumnNames : sourceDefinition.PrimaryKey); @@ -933,7 +974,7 @@ await queryExecutor.ExecuteQueryAsync( await queryExecutor.ExecuteQueryAsync( sqltext: queryString, parameters: queryParameters, - dataReaderHandler: queryExecutor.ExtractResultSetFromDbDataReader, + dataReaderHandler: queryExecutor.ExtractResultSetFromDbDataReaderAsync, httpContext: GetHttpContext(), dataSourceName: dataSourceName); dbResultSetRow = dbResultSet is not null ? (dbResultSet.Rows.FirstOrDefault() ?? new()) : null; @@ -942,6 +983,892 @@ await queryExecutor.ExecuteQueryAsync( return dbResultSetRow; } + /// + /// Performs the given GraphQL create mutation operation. + /// + /// Name of the top level entity + /// Multiple Create mutation's input parameters retrieved from GraphQL context + /// SqlMetadaprovider + /// Hotchocolate's context for the graphQL request. + /// Boolean indicating whether the create operation is for multiple items. + /// Primary keys of the created records (in the top level entity). + /// + private List> PerformMultipleCreateOperation( + string entityName, + IMiddlewareContext context, + IDictionary mutationInputParamsFromGQLContext, + ISqlMetadataProvider sqlMetadataProvider, + bool isMultipleInputType = false) + { + // rootFieldName can be either "item" or "items" depending on whether the operation + // is point multiple create or many-type multiple create. + string rootFieldName = isMultipleInputType ? MULTIPLE_INPUT_ARGUEMENT_NAME : SINGLE_INPUT_ARGUEMENT_NAME; + + // Parse the hotchocolate input parameters into .net object types + object? parsedInputParams = GQLMultipleCreateArgumentToDictParams(context, rootFieldName, mutationInputParamsFromGQLContext); + + if (parsedInputParams is null) + { + throw new DataApiBuilderException( + message: "The input for multiple create mutation operation cannot be null", + statusCode: HttpStatusCode.BadRequest, + subStatusCode: DataApiBuilderException.SubStatusCodes.BadRequest); + } + + // List of Primary keys of the created records in the top level entity. + // Each dictionary in the list corresponds to the PKs of a single record. + // For point multiple create operation, only one entry will be present. + List> primaryKeysOfCreatedItemsInTopLevelEntity = new(); + + if (!mutationInputParamsFromGQLContext.TryGetValue(rootFieldName, out object? unparsedInputFieldsForRootField) + || unparsedInputFieldsForRootField is null) + { + throw new DataApiBuilderException( + message: $"Mutation Request should contain the expected argument: {rootFieldName} in the input", + statusCode: HttpStatusCode.BadRequest, + subStatusCode: DataApiBuilderException.SubStatusCodes.BadRequest); + } + + if (isMultipleInputType) + { + // For a many type multiple create operation, after parsing the hotchocolate input parameters, the resultant data structure is a list of dictionaries. + // Each entry in the list corresponds to the input parameters for a single input item. + // The fields belonging to the inputobjecttype are converted to + // 1. Scalar input fields: Key - Value pair of field name and field value. + // 2. Object type input fields: Key - Value pair of relationship name and a dictionary of parameters (takes place for 1:1, N:1 relationship types) + // 3. List type input fields: key - Value pair of relationship name and a list of dictionary of parameters (takes place for 1:N, M:N relationship types) + List> parsedMutationInputFields = (List>)parsedInputParams; + + // For many type multiple create operation, the "parameters" dictionary is a key pair of <"items", List>. + // Ideally, the input provided for "items" field should not be any other type than List + // as HotChocolate will detect and throw errors before the execution flow reaches here. + // However, this acts as a guard to ensure that the right input type for "items" field is used. + if (unparsedInputFieldsForRootField is not List unparsedInputForRootField) + { + throw new DataApiBuilderException( + message: $"Unsupported type used with {rootFieldName} in the create mutation input", + statusCode: HttpStatusCode.BadRequest, + subStatusCode: DataApiBuilderException.SubStatusCodes.BadRequest); + } + + // In the following loop, the input elements in "parsedInputList" are iterated and processed. + // idx tracks the index number to fetch the corresponding unparsed hotchocolate input parameters from "paramList". + // Both parsed and unparsed input parameters are necessary for successfully determing the order of insertion + // among the entities involved in the multiple create mutation request. + int itemsIndex = 0; + + // Consider a mutation request such as the following + // mutation{ + // createbooks(items: [ + // { + // title: "Harry Potter and the Chamber of Secrets", + // publishers: { name: "Bloomsbury" } + // }, + // { + // title: "Educated", + // publishers: { name: "Random House"} + // } + // ]){ + // items{ + // id + // title + // publisher_id + // } + // } + // } + // In the above mutation, each element in the 'items' array forms the 'parsedInputList'. + // items[itemsIndex].Key -> field(s) in the input such as 'title' and 'publishers' (type: string) + // items[itemsIndex].Value -> field value(s) for each corresponding field (type: object?) + // items[0] -> object with title 'Harry Potter and the Chamber of Secrets' + // items[1] -> object with title 'Educated' + // The processing logic is distinctly executed for each object in `items'. + foreach (IDictionary parsedMutationInputField in parsedMutationInputFields) + { + MultipleCreateStructure multipleCreateStructure = new( + entityName: entityName, + parentEntityName: string.Empty, + inputMutParams: parsedMutationInputField); + + Dictionary> primaryKeysOfCreatedItem = new(); + + IValueNode? unparsedFieldNodeForCurrentItem = unparsedInputForRootField[itemsIndex]; + if (unparsedFieldNodeForCurrentItem is null) + { + throw new DataApiBuilderException( + message: "Error when processing the mutation request", + statusCode: HttpStatusCode.BadRequest, + subStatusCode: DataApiBuilderException.SubStatusCodes.BadRequest); + } + + ProcessMultipleCreateInputField(context, unparsedFieldNodeForCurrentItem.Value, sqlMetadataProvider, multipleCreateStructure, nestingLevel: 0); + + // Ideally the CurrentEntityCreatedValues should not be null. CurrentEntityCreatedValues being null indicates that the create operation + // has failed and that will result in an exception being thrown. + // This condition acts as a guard against having to deal with null values during selection set resolution. + if (multipleCreateStructure.CurrentEntityCreatedValues is not null) + { + primaryKeysOfCreatedItemsInTopLevelEntity.Add(FetchPrimaryKeyFieldValues(sqlMetadataProvider, entityName, multipleCreateStructure.CurrentEntityCreatedValues)); + } + + itemsIndex++; + } + } + else + { + // Consider a mutation request such as the following + // mutation{ + // createbook(item:{ + // title: "Harry Potter and the Chamber of Secrets", + // publishers: { + // name: "Bloomsbury" + // }}) + // { + // id + // title + // publisher_id + // } + // For the above mutation request, the parsedInputParams will be a dictionary with the following key value pairs + // + // Key Value + // title Harry Potter and the Chamber of Secrets + // publishers Dictionary + IDictionary parsedInputFields = (IDictionary)parsedInputParams; + + // For point multiple create operation, the "parameters" dictionary is a key pair of <"item", List>. + // The value field retrieved using the key "item" cannot be of any other type. + // Ideally, this condition should never be hit, because such cases should be caught by Hotchocolate but acts as a guard against using any other types with "item" field + if (unparsedInputFieldsForRootField is not List unparsedInputFields) + { + throw new DataApiBuilderException( + message: $"Unsupported type used with {rootFieldName} in the create mutation input", + statusCode: HttpStatusCode.BadRequest, + subStatusCode: DataApiBuilderException.SubStatusCodes.BadRequest); + } + + MultipleCreateStructure multipleCreateStructure = new( + entityName: entityName, + parentEntityName: entityName, + inputMutParams: parsedInputFields); + + ProcessMultipleCreateInputField(context, unparsedInputFields, sqlMetadataProvider, multipleCreateStructure, nestingLevel: 0); + + if (multipleCreateStructure.CurrentEntityCreatedValues is not null) + { + primaryKeysOfCreatedItemsInTopLevelEntity.Add(FetchPrimaryKeyFieldValues(sqlMetadataProvider, entityName, multipleCreateStructure.CurrentEntityCreatedValues)); + } + } + + return primaryKeysOfCreatedItemsInTopLevelEntity; + } + + /// + /// 1. Identifies the order of insertion into tables involved in the create mutation request. + /// 2. Builds and executes the necessary database queries to insert all the data into appropriate tables. + /// + /// Hotchocolate's context for the graphQL request. + /// Mutation input parameter from GQL Context for the current item being processed + /// SqlMetadataprovider for the given database type. + /// Wrapper object for the current entity for performing the multiple create mutation operation + /// Current depth of nesting in the multiple-create request + private void ProcessMultipleCreateInputField( + IMiddlewareContext context, + object? unparsedInputFields, + ISqlMetadataProvider sqlMetadataProvider, + MultipleCreateStructure multipleCreateStructure, + int nestingLevel) + { + + if (multipleCreateStructure.InputMutParams is null || unparsedInputFields is null) + { + throw new DataApiBuilderException( + message: "The input for a multiple create mutation operation cannot be null.", + statusCode: HttpStatusCode.BadRequest, + subStatusCode: DataApiBuilderException.SubStatusCodes.BadRequest); + } + + // For One - Many and Many - Many relationship types, processing logic is distinctly executed for each + // object in the input list. + // So, when the input parameters is of list type, we iterate over the list + // and call the same method for each element. + if (multipleCreateStructure.InputMutParams.GetType().GetGenericTypeDefinition() == typeof(List<>)) + { + List> parsedInputItems = (List>)multipleCreateStructure.InputMutParams; + List unparsedInputFieldList = (List)unparsedInputFields; + int parsedInputItemIndex = 0; + + foreach (IDictionary parsedInputItem in parsedInputItems) + { + MultipleCreateStructure multipleCreateStructureForCurrentItem = new( + entityName: multipleCreateStructure.EntityName, + parentEntityName: multipleCreateStructure.ParentEntityName, + inputMutParams: parsedInputItem, + isLinkingTableInsertionRequired: multipleCreateStructure.IsLinkingTableInsertionRequired) + { + CurrentEntityParams = multipleCreateStructure.CurrentEntityParams, + LinkingTableParams = multipleCreateStructure.LinkingTableParams + }; + + Dictionary> primaryKeysOfCreatedItems = new(); + IValueNode? nodeForCurrentInput = unparsedInputFieldList[parsedInputItemIndex]; + if (nodeForCurrentInput is null) + { + throw new DataApiBuilderException( + message: "Error when processing the mutation request", + statusCode: HttpStatusCode.BadRequest, + subStatusCode: DataApiBuilderException.SubStatusCodes.BadRequest); + } + + ProcessMultipleCreateInputField(context, nodeForCurrentInput.Value, sqlMetadataProvider, multipleCreateStructureForCurrentItem, nestingLevel); + parsedInputItemIndex++; + } + } + else + { + if (unparsedInputFields is not List parameterNodes) + { + throw new DataApiBuilderException( + message: "Error occurred while processing the mutation request", + statusCode: HttpStatusCode.BadRequest, + subStatusCode: DataApiBuilderException.SubStatusCodes.BadRequest); + } + + string entityName = multipleCreateStructure.EntityName; + Entity entity = _runtimeConfigProvider.GetConfig().Entities[entityName]; + + // Classifiy the relationship fields (if present in the input request) into referencing and referenced relationships and + // populate multipleCreateStructure.ReferencingRelationships and multipleCreateStructure.ReferencedRelationships respectively. + DetermineReferencedAndReferencingRelationships(context, multipleCreateStructure, sqlMetadataProvider, entity.Relationships, parameterNodes); + PopulateCurrentAndLinkingEntityParams(multipleCreateStructure, sqlMetadataProvider, entity.Relationships); + + SourceDefinition currentEntitySourceDefinition = sqlMetadataProvider.GetSourceDefinition(entityName); + currentEntitySourceDefinition.SourceEntityRelationshipMap.TryGetValue(entityName, out RelationshipMetadata? currentEntityRelationshipMetadata); + + // Process referenced relationships + foreach ((string relationshipName, object? relationshipFieldValue) in multipleCreateStructure.ReferencedRelationships) + { + string relatedEntityName = GraphQLUtils.GetRelationshipTargetEntityName(entity, entityName, relationshipName); + MultipleCreateStructure referencedRelationshipMultipleCreateStructure = new(entityName: relatedEntityName, parentEntityName: entityName, inputMutParams: relationshipFieldValue); + IValueNode node = GraphQLUtils.GetFieldNodeForGivenFieldName(parameterNodes, relationshipName); + ProcessMultipleCreateInputField(context, node.Value, sqlMetadataProvider, referencedRelationshipMultipleCreateStructure, nestingLevel + 1); + + if (sqlMetadataProvider.TryGetFKDefinition( + sourceEntityName: entityName, + targetEntityName: relatedEntityName, + referencingEntityName: entityName, + referencedEntityName: relatedEntityName, + out ForeignKeyDefinition? foreignKeyDefinition, + isMToNRelationship: false)) + { + PopulateReferencingFields( + sqlMetadataProvider: sqlMetadataProvider, + multipleCreateStructure: multipleCreateStructure, + fkDefinition: foreignKeyDefinition, + computedRelationshipFields: referencedRelationshipMultipleCreateStructure.CurrentEntityCreatedValues, + isLinkingTable: false, + entityName: relatedEntityName); + } + } + + multipleCreateStructure.CurrentEntityCreatedValues = BuildAndExecuteInsertDbQueries( + sqlMetadataProvider: sqlMetadataProvider, + entityName: entityName, + parentEntityName: entityName, + parameters: multipleCreateStructure.CurrentEntityParams!, + sourceDefinition: currentEntitySourceDefinition, + isLinkingEntity: false, + nestingLevel: nestingLevel); + + //Perform an insertion in the linking table if required + if (multipleCreateStructure.IsLinkingTableInsertionRequired) + { + if (multipleCreateStructure.LinkingTableParams is null) + { + multipleCreateStructure.LinkingTableParams = new Dictionary(); + } + + // Consider the mutation request: + // mutation{ + // createbook(item: { + // title: "Book Title", + // publisher_id: 1234, + // authors: [ + // {...} , + // {...} + // ] + // }) { + // ... + // } + // There exists two relationships for a linking table. + // 1. Relationship between the parent entity (Book) and the linking table. + // 2. Relationship between the current entity (Author) and the linking table. + // To construct the insert database query for the linking table, relationship fields from both the + // relationships are required. + + // Populate Current entity's relationship fields + List foreignKeyDefinitions = currentEntityRelationshipMetadata!.TargetEntityToFkDefinitionMap[multipleCreateStructure.ParentEntityName]; + ForeignKeyDefinition fkDefinition = foreignKeyDefinitions[0]; + PopulateReferencingFields(sqlMetadataProvider, multipleCreateStructure, fkDefinition, multipleCreateStructure.CurrentEntityCreatedValues, isLinkingTable: true); + + string linkingEntityName = GraphQLUtils.GenerateLinkingEntityName(multipleCreateStructure.ParentEntityName, entityName); + SourceDefinition linkingTableSourceDefinition = sqlMetadataProvider.GetSourceDefinition(linkingEntityName); + + _ = BuildAndExecuteInsertDbQueries( + sqlMetadataProvider: sqlMetadataProvider, + entityName: linkingEntityName, + parentEntityName: entityName, + parameters: multipleCreateStructure.LinkingTableParams!, + sourceDefinition: linkingTableSourceDefinition, + isLinkingEntity: true, + nestingLevel: nestingLevel); + } + + // Process referencing relationships + foreach ((string relationshipFieldName, object? relationshipFieldValue) in multipleCreateStructure.ReferencingRelationships) + { + string relatedEntityName = GraphQLUtils.GetRelationshipTargetEntityName(entity, entityName, relationshipFieldName); + MultipleCreateStructure referencingRelationshipMultipleCreateStructure = new(entityName: relatedEntityName, + parentEntityName: entityName, + inputMutParams: relationshipFieldValue, + isLinkingTableInsertionRequired: GraphQLUtils.IsMToNRelationship(entity, relationshipFieldName)); + IValueNode node = GraphQLUtils.GetFieldNodeForGivenFieldName(parameterNodes, relationshipFieldName); + + // Many-Many relationships are marked as Referencing relationships + // because the linking table insertion can happen only + // when records have been successfully created in both the entities involved in the relationship. + // The entities involved do not derive any fields from each other. Only the linking table derives the + // primary key fields from the entities involved in the relationship. + // For a M:N relationships, the referencing fields are populated in LinkingTableParams whereas for + // a 1:N relationship, referencing fields will be populated in CurrentEntityParams. + if (sqlMetadataProvider.TryGetFKDefinition( + sourceEntityName: entityName, + targetEntityName: relatedEntityName, + referencingEntityName: relatedEntityName, + referencedEntityName: entityName, + out ForeignKeyDefinition? referencingEntityFKDefinition, + isMToNRelationship: referencingRelationshipMultipleCreateStructure.IsLinkingTableInsertionRequired)) + { + PopulateReferencingFields( + sqlMetadataProvider: sqlMetadataProvider, + multipleCreateStructure: referencingRelationshipMultipleCreateStructure, + fkDefinition: referencingEntityFKDefinition, + computedRelationshipFields: multipleCreateStructure.CurrentEntityCreatedValues, + isLinkingTable: referencingRelationshipMultipleCreateStructure.IsLinkingTableInsertionRequired, + entityName: entityName); + } + + ProcessMultipleCreateInputField(context, node.Value, sqlMetadataProvider, referencingRelationshipMultipleCreateStructure, nestingLevel + 1); + } + } + } + + /// + /// Builds and executes the insert database query necessary for creating an item in the table + /// the entity. + /// + /// SqlMetadaProvider object for the given database + /// Current entity name + /// Parent entity name + /// Dictionary containing the data ncessary to create a record in the table + /// Entity's source definition object + /// Indicates whether the entity is a linking entity + /// Current depth of nesting in the multiple-create request + /// Created record in the database as a dictionary + private Dictionary BuildAndExecuteInsertDbQueries(ISqlMetadataProvider sqlMetadataProvider, + string entityName, + string parentEntityName, + IDictionary parameters, + SourceDefinition sourceDefinition, + bool isLinkingEntity, + int nestingLevel) + { + SqlInsertStructure sqlInsertStructure = new( + entityName: entityName, + sqlMetadataProvider: sqlMetadataProvider, + authorizationResolver: _authorizationResolver, + gQLFilterParser: _gQLFilterParser, + mutationParams: parameters, + httpContext: GetHttpContext(), + isLinkingEntity: isLinkingEntity); + + IQueryBuilder queryBuilder = _queryManagerFactory.GetQueryBuilder(sqlMetadataProvider.GetDatabaseType()); + IQueryExecutor queryExecutor = _queryManagerFactory.GetQueryExecutor(sqlMetadataProvider.GetDatabaseType()); + + // When the entity is a linking entity, the parent entity's name is used to get the + // datasource name. Otherwise, the entity's name is used. + string dataSourceName = isLinkingEntity ? _runtimeConfigProvider.GetConfig().GetDataSourceNameFromEntityName(parentEntityName) + : _runtimeConfigProvider.GetConfig().GetDataSourceNameFromEntityName(entityName); + string queryString = queryBuilder.Build(sqlInsertStructure); + Dictionary queryParameters = sqlInsertStructure.Parameters; + + List exposedColumnNames = new(); + if (sqlMetadataProvider.TryGetExposedFieldToBackingFieldMap(entityName, out IReadOnlyDictionary? exposedFieldToBackingFieldMap)) + { + exposedColumnNames = exposedFieldToBackingFieldMap.Keys.ToList(); + } + + DbResultSet? dbResultSet; + DbResultSetRow? dbResultSetRow; + dbResultSet = queryExecutor.ExecuteQuery( + queryString, + queryParameters, + queryExecutor.ExtractResultSetFromDbDataReader, + GetHttpContext(), + exposedColumnNames.IsNullOrEmpty() ? sourceDefinition.Columns.Keys.ToList() : exposedColumnNames, + dataSourceName); + + dbResultSetRow = dbResultSet is not null ? (dbResultSet.Rows.FirstOrDefault() ?? new DbResultSetRow()) : null; + if (dbResultSetRow is null || dbResultSetRow.Columns.Count == 0) + { + if (isLinkingEntity) + { + throw new DataApiBuilderException( + message: $"Could not insert row with given values in the linking table joining entities: {entityName} and {parentEntityName} at nesting level : {nestingLevel}", + statusCode: HttpStatusCode.InternalServerError, + subStatusCode: DataApiBuilderException.SubStatusCodes.DatabaseOperationFailed); + } + else + { + if (dbResultSetRow is null) + { + throw new DataApiBuilderException( + message: "No data returned back from database.", + statusCode: HttpStatusCode.InternalServerError, + subStatusCode: DataApiBuilderException.SubStatusCodes.DatabaseOperationFailed); + } + else + { + throw new DataApiBuilderException( + message: $"Could not insert row with given values for entity: {entityName} at nesting level : {nestingLevel}", + statusCode: HttpStatusCode.Forbidden, + subStatusCode: DataApiBuilderException.SubStatusCodes.DatabasePolicyFailure); + } + } + } + + return dbResultSetRow.Columns; + } + + /// + /// Helper method to extract the primary key fields from all the fields of the entity. + /// + /// SqlMetadaProvider object for the given database + /// Name of the entity + /// Field::Value dictionary of entity created in the database. + /// Primary Key fields + private static Dictionary FetchPrimaryKeyFieldValues(ISqlMetadataProvider sqlMetadataProvider, string entityName, Dictionary createdValuesForEntityItem) + { + Dictionary pkFields = new(); + SourceDefinition sourceDefinition = sqlMetadataProvider.GetSourceDefinition(entityName); + foreach (string primaryKey in sourceDefinition.PrimaryKey) + { + if (sqlMetadataProvider.TryGetExposedColumnName(entityName, primaryKey, out string? name) + && createdValuesForEntityItem.TryGetValue(name, out object? value) + && value != null) + { + pkFields.Add(primaryKey, value); + } + else + { + throw new DataApiBuilderException(message: $"Primary key field {name} has null value but it is expected to have a non-null value", + statusCode: HttpStatusCode.InternalServerError, + subStatusCode: DataApiBuilderException.SubStatusCodes.UnexpectedError); + } + } + + return pkFields; + } + + /// + /// Helper method to populate the referencing fields in LinkingEntityParams or CurrentEntityParams depending on whether the current entity is a linking entity or not. + /// + /// SqlMetadaProvider object for the given database. + /// Foreign Key metadata constructed during engine start-up. + /// Wrapper object assisting with the multiple create operation. + /// Relationship fields obtained as a result of creation of current or parent entity item. + /// Indicates whether referencing fields are populated for a linking entity. + /// Name of the entity. + private static void PopulateReferencingFields(ISqlMetadataProvider sqlMetadataProvider, MultipleCreateStructure multipleCreateStructure, ForeignKeyDefinition fkDefinition, Dictionary? computedRelationshipFields, bool isLinkingTable, string? entityName = null) + { + if (computedRelationshipFields is null) + { + return; + } + + for (int i = 0; i < fkDefinition.ReferencingColumns.Count; i++) + { + string referencingColumnName = fkDefinition.ReferencingColumns[i]; + string referencedColumnName = fkDefinition.ReferencedColumns[i]; + string exposedReferencedColumnName; + if (isLinkingTable) + { + multipleCreateStructure.LinkingTableParams![referencingColumnName] = computedRelationshipFields[referencedColumnName]; + } + else + { + if (entityName is not null + && sqlMetadataProvider.TryGetExposedColumnName(entityName, referencedColumnName, out string? exposedColumnName)) + { + exposedReferencedColumnName = exposedColumnName; + } + else + { + exposedReferencedColumnName = referencedColumnName; + } + + multipleCreateStructure.CurrentEntityParams![referencingColumnName] = computedRelationshipFields[exposedReferencedColumnName]; + } + } + } + + /// + /// Helper method that looks at the input fields of a given entity and + /// identifies, classifies the related entities into referenced and referencing entities. + /// + /// Hotchocolate context + /// Wrapper object for the current entity for performing + /// the multiple create mutation operation + /// SqlMetadaProvider object for the given database + /// Relationship metadata of the source entity + /// Field object nodes of the source entity + private static void DetermineReferencedAndReferencingRelationships( + IMiddlewareContext context, + MultipleCreateStructure multipleCreateStructure, + ISqlMetadataProvider sqlMetadataProvider, + Dictionary? topLevelEntityRelationships, + List sourceEntityFields) + { + + if (topLevelEntityRelationships is null) + { + return; + } + + // Ideally, this condition should not become true. + // The input parameters being null should be caught earlier in the flow. + // Nevertheless, this check is added as a guard against cases where the input parameters are null + // and is not caught. + if (multipleCreateStructure.InputMutParams is null) + { + throw new DataApiBuilderException( + message: "The mutation parameters cannot be null.", + statusCode: HttpStatusCode.BadRequest, + subStatusCode: DataApiBuilderException.SubStatusCodes.BadRequest); + } + + foreach ((string relationshipName, object? relationshipFieldValues) in (Dictionary)multipleCreateStructure.InputMutParams) + { + if (topLevelEntityRelationships.TryGetValue(relationshipName, out EntityRelationship? entityRelationship) + && entityRelationship is not null) + { + // The linking object not being null indicates that the relationship is a many-to-many relationship. + // For M:N realtionship, new item(s) have to be created in the linking table + // in addition to the source and target tables. + // Creation of item(s) in the linking table is handled when processing the target entity. + // To be able to create item(s) in the linking table, PKs of the source and target items are required. + // Indirectly, the target entity depends on the PKs of the source entity. + // Hence, the target entity is added as a referencing entity. + if (!string.IsNullOrWhiteSpace(entityRelationship.LinkingObject)) + { + multipleCreateStructure.ReferencingRelationships.Add(new Tuple(relationshipName, relationshipFieldValues) { }); + continue; + } + + string targetEntityName = entityRelationship.TargetEntity; + Dictionary columnDataInSourceBody = MultipleCreateOrderHelper.GetBackingColumnDataFromFields(context, multipleCreateStructure.EntityName, sourceEntityFields, sqlMetadataProvider); + IValueNode? targetNode = GraphQLUtils.GetFieldNodeForGivenFieldName(objectFieldNodes: sourceEntityFields, fieldName: relationshipName); + + // In this function call, nestingLevel parameter is set as 0 which might not be accurate. + // However, it is irrelevant because nestingLevel is used only for logging error messages + // and we do not expect any errors to occur here. + // All errors are expected to be caught during request validation. + string referencingEntityName = MultipleCreateOrderHelper.GetReferencingEntityName( + context: context, + sourceEntityName: multipleCreateStructure.EntityName, + targetEntityName: targetEntityName, + relationshipName: relationshipName, + metadataProvider: sqlMetadataProvider, + nestingLevel: 0, + columnDataInSourceBody: columnDataInSourceBody, + targetNodeValue: targetNode); + + if (string.Equals(multipleCreateStructure.EntityName, referencingEntityName, StringComparison.OrdinalIgnoreCase)) + { + multipleCreateStructure.ReferencedRelationships.Add(new Tuple(relationshipName, relationshipFieldValues) { }); + } + else + { + multipleCreateStructure.ReferencingRelationships.Add(new Tuple(relationshipName, relationshipFieldValues) { }); + } + } + } + } + + /// + /// Helper method which traverses the input fields for a given record and populates the fields/values into the appropriate data structures + /// storing the field/values belonging to the current entity and the linking entity. + /// Consider the below multiple create mutation request + /// mutation{ + /// createbook(item: { + /// title: "Harry Potter and the Goblet of Fire", + /// publishers:{ + /// name: "Bloomsbury" + /// } + /// authors:[ + /// { + /// name: "J.K Rowling", + /// birthdate: "1965-07-31", + /// royalty_percentage: 100.0 + /// } + /// ]}) + /// { + /// ... + /// } + /// The mutation request consists of fields belonging to the + /// 1. Top Level Entity - Book: + /// a) Title + /// 2. Related Entity - Publisher, Author + /// In M:N relationship, the field(s)(e.g. royalty_percentage) belonging to the + /// linking entity(book_author_link) is a property of the related entity's input object. + /// So, this method identifies and populates + /// 1. multipleCreateStructure.CurrentEntityParams with the current entity's fields. + /// 2. multipleCreateStructure.LinkingEntityParams with the linking entity's fields. + /// + /// Wrapper object for the current entity for performing the multiple create mutation operation + /// SqlMetadaProvider object for the given database + /// Relationship metadata of the source entity + private static void PopulateCurrentAndLinkingEntityParams( + MultipleCreateStructure multipleCreateStructure, + ISqlMetadataProvider sqlMetadataProvider, + Dictionary? topLevelEntityRelationships) + { + + if (multipleCreateStructure.InputMutParams is null) + { + return; + } + + foreach ((string fieldName, object? fieldValue) in (Dictionary)multipleCreateStructure.InputMutParams) + { + if (topLevelEntityRelationships is not null && topLevelEntityRelationships.ContainsKey(fieldName)) + { + continue; + } + + if (sqlMetadataProvider.TryGetBackingColumn(multipleCreateStructure.EntityName, fieldName, out _)) + { + multipleCreateStructure.CurrentEntityParams[fieldName] = fieldValue; + } + else + { + multipleCreateStructure.LinkingTableParams[fieldName] = fieldValue; + } + } + } + + /// + /// Parse the mutation parameters from Hotchocolate input types to Dictionary of field names and values. + /// + /// GQL middleware context used to resolve the values of arguments + /// GQL field from which to extract the parameters. It is either "item" or "items". + /// Dictionary of mutation parameters + /// Parsed input mutation parameters. + internal static object? GQLMultipleCreateArgumentToDictParams( + IMiddlewareContext context, + string rootFieldName, + IDictionary mutationParameters) + { + if (mutationParameters.TryGetValue(rootFieldName, out object? inputParameters)) + { + IObjectField fieldSchema = context.Selection.Field; + IInputField itemsArgumentSchema = fieldSchema.Arguments[rootFieldName]; + InputObjectType inputObjectType = ExecutionHelper.InputObjectTypeFromIInputField(itemsArgumentSchema); + return GQLMultipleCreateArgumentToDictParamsHelper(context, inputObjectType, inputParameters); + } + else + { + throw new DataApiBuilderException( + message: $"Expected root mutation input field: '{rootFieldName}'.", + subStatusCode: DataApiBuilderException.SubStatusCodes.BadRequest, + statusCode: HttpStatusCode.BadRequest); + } + } + + /// + /// Helper function to parse the mutation parameters from Hotchocolate input types to + /// Dictionary of field names and values. The parsed input types will not contain + /// any hotchocolate types such as IValueNode, ObjectFieldNode, etc. + /// For multiple create mutation, the input types of a field can be a scalar, object or list type. + /// This function recursively parses each input type. + /// Consider the following multiple create mutation requests: + /// 1. mutation pointMultipleCreateExample{ + /// createbook( + /// item: { + /// title: "Harry Potter and the Goblet of Fire", + /// publishers: { name: "Bloomsbury" }, + /// authors: [{ name: "J.K Rowling", birthdate: "1965-07-31", royalty_percentage: 100.0 }], + /// reviews: [ {content: "Great book" }, {content: "Wonderful read"}] + /// }) + /// { + /// //selection set (not relevant in this function) + /// } + /// } + /// + /// 2. mutation manyMultipleCreateExample{ + /// createbooks( + /// items:[{ fieldName0: "fieldValue0"},{fieldNameN: "fieldValueN"}]){ + /// //selection set (not relevant in this function) + /// } + /// } + /// + /// GQL middleware context used to resolve the values of arguments. + /// Type of the input object field. + /// Mutation input parameters retrieved from IMiddleware context + /// Parsed mutation parameters as either + /// 1. Dictionary or + /// 2. List> + /// + internal static object? GQLMultipleCreateArgumentToDictParamsHelper( + IMiddlewareContext context, + InputObjectType inputObjectType, + object? inputParameters) + { + // This condition is met for input types that accept an array of values + // where the mutation input field is 'items' such as + // 1. Many-type multiple create operation ---> createbooks, createBookmarks_Multiple: + // For the mutation manyMultipleCreateExample (outlined in the method summary), + // the following conditions will evalaute to true for root field 'items'. + // 2. Input types for 1:N and M:N relationships: + // For the mutation pointMultipleCreateExample (outlined in the method summary), + // the following condition will evaluate to true for fields 'authors' and 'reviews'. + // For both the cases, each element in the input object can be a combination of + // scalar and relationship fields. + // The parsing logic is run distinctly for each element by recursively calling the same function. + // Each parsed input result is stored in a list and finally this list is returned. + if (inputParameters is List inputFields) + { + List> parsedInputFieldItems = new(); + foreach (IValueNode inputField in inputFields) + { + object? parsedInputFieldItem = GQLMultipleCreateArgumentToDictParamsHelper( + context: context, + inputObjectType: inputObjectType, + inputParameters: inputField.Value); + if (parsedInputFieldItem is not null) + { + parsedInputFieldItems.Add((IDictionary)parsedInputFieldItem); + } + } + + return parsedInputFieldItems; + } + + // This condition is met when the mutation input is a single item where the + // mutation input field is 'item' such as + // 1. Point multiple create operation --> createbook. + // For the mutation pointMultipleCreateExample (outlined in the method summary), + // the following condition will evaluate to true for root field 'item'. + // The inputParameters will contain ObjectFieldNode objects for + // fields : ['title', 'publishers', 'authors', 'reviews'] + // 2. Relationship fields that are of object type: + // For the mutation pointMultipleCreateExample (outlined in the method summary), + // when processing the field 'publishers'. For 'publishers' field, + // inputParameters will contain ObjectFieldNode objects for fields: ['name'] + else if (inputParameters is List inputFieldNodes) + { + Dictionary parsedInputFields = new(); + foreach (ObjectFieldNode inputFieldNode in inputFieldNodes) + { + string fieldName = inputFieldNode.Name.Value; + // For the mutation pointMultipleCreateExample (outlined in the method summary), + // the following condition will evaluate to true for fields 'authors' and 'reviews'. + // Fields 'authors'/'reviews' can again consist of combination of scalar and relationship fields. + // So, the input object type for 'authors'/'reviews' is fetched and the same function is + // invoked with the fetched input object type again to parse the input fields of 'authors'/'reviews'. + if (inputFieldNode.Value.Kind == SyntaxKind.ListValue) + { + parsedInputFields.Add( + fieldName, + GQLMultipleCreateArgumentToDictParamsHelper( + context, + GetInputObjectTypeForAField(fieldName, inputObjectType.Fields), + inputFieldNode.Value.Value)); + } + // For the mutation pointMultipleCreateExample (outlined in the method summary), + // the following condition will evaluate to true for fields 'publishers'. + // Field 'publishers' can again consist of combination of scalar and relationship fields. + // So, the input object type for 'publishers' is fetched and the same function is + // invoked with the fetched input object type again to parse the input fields of 'publishers'. + else if (inputFieldNode.Value.Kind == SyntaxKind.ObjectValue) + { + parsedInputFields.Add( + fieldName, + GQLMultipleCreateArgumentToDictParamsHelper( + context, + GetInputObjectTypeForAField(fieldName, inputObjectType.Fields), + inputFieldNode.Value.Value)); + } + // The flow enters this block for all scalar input fields. + else + { + object? fieldValue = ExecutionHelper.ExtractValueFromIValueNode( + value: inputFieldNode.Value, + argumentSchema: inputObjectType.Fields[fieldName], + variables: context.Variables); + + parsedInputFields.Add(fieldName, fieldValue); + } + } + + return parsedInputFields; + } + else + { + throw new DataApiBuilderException( + message: "Unsupported input type found in the mutation request", + statusCode: HttpStatusCode.BadRequest, + subStatusCode: DataApiBuilderException.SubStatusCodes.BadRequest); + } + } + + /// + /// Extracts the InputObjectType for a given field. + /// Consider the following multiple create mutation + /// mutation multipleCreateExample{ + /// createbook( + /// item: { + /// title: "Harry Potter and the Goblet of Fire", + /// publishers: { name: "Bloomsbury" }, + /// authors: [{ name: "J.K Rowling", birthdate: "1965-07-31", royalty_percentage: 100.0 }]}){ + /// selection set (not relevant in this function) + /// } + /// } + /// } + /// When parsing this mutation request, the flow will reach this function two times. + /// 1. For the field 'publishers'. + /// - The function will get invoked with params + /// fieldName: 'publishers', + /// fields: All the fields present in CreateBookInput input object + /// - The function will return `CreatePublisherInput` + /// 2. For the field 'authors'. + /// - The function will get invoked with params + /// fieldName: 'authors', + /// fields: All the fields present in CreateBookInput input object + /// - The function will return `CreateAuthorInput` + /// + /// Field name for which the input object type is to be extracted. + /// Fields present in the input object type. + /// The input object type for the given field. + /// + private static InputObjectType GetInputObjectTypeForAField(string fieldName, FieldCollection fields) + { + if (fields.TryGetField(fieldName, out IInputField? field)) + { + return ExecutionHelper.InputObjectTypeFromIInputField(field); + } + + throw new ArgumentException($"Field {fieldName} not found in the list of fields provided."); + } + /// /// Perform the DELETE operation on the given entity. /// To determine the correct response, uses QueryExecutor's GetResultProperties handler for @@ -976,7 +1903,7 @@ private async Task?> resultProperties = await queryExecutor.ExecuteQueryAsync( sqltext: queryString, parameters: queryParameters, - dataReaderHandler: queryExecutor.GetResultProperties, + dataReaderHandler: queryExecutor.GetResultPropertiesAsync, httpContext: GetHttpContext(), dataSourceName: dataSourceName); diff --git a/src/Core/Resolvers/SqlQueryEngine.cs b/src/Core/Resolvers/SqlQueryEngine.cs index 9f3b24345e..2050660c29 100644 --- a/src/Core/Resolvers/SqlQueryEngine.cs +++ b/src/Core/Resolvers/SqlQueryEngine.cs @@ -12,6 +12,7 @@ using Azure.DataApiBuilder.Core.Services; using Azure.DataApiBuilder.Core.Services.Cache; using Azure.DataApiBuilder.Core.Services.MetadataProviders; +using Azure.DataApiBuilder.Service.GraphQLBuilder; using Azure.DataApiBuilder.Service.GraphQLBuilder.Queries; using HotChocolate.Resolvers; using Microsoft.AspNetCore.Http; @@ -90,6 +91,44 @@ await ExecuteAsync(structure, dataSourceName), } } + /// + /// Executes the given IMiddlewareContext of the GraphQL query and + /// expecting a single Json and its related pagination metadata back. + /// This method is used for the selection set resolution of multiple create mutation operation. + /// + /// HotChocolate Request Pipeline context containing request metadata + /// PKs of the created items + /// Name of datasource for which to set access token. Default dbName taken from config if empty + public async Task> ExecuteMultipleCreateFollowUpQueryAsync(IMiddlewareContext context, List> parameters, string dataSourceName) + { + + string entityName = GraphQLUtils.GetEntityNameFromContext(context); + + SqlQueryStructure structure = new( + context, + parameters, + _sqlMetadataProviderFactory.GetMetadataProvider(dataSourceName), + _authorizationResolver, + _runtimeConfigProvider, + _gQLFilterParser, + new IncrementingInteger(), + entityName, + isMultipleCreateOperation: true); + + if (structure.PaginationMetadata.IsPaginated) + { + return new Tuple( + SqlPaginationUtil.CreatePaginationConnectionFromJsonDocument(await ExecuteAsync(structure, dataSourceName, isMultipleCreateOperation: true), structure.PaginationMetadata), + structure.PaginationMetadata); + } + else + { + return new Tuple( + await ExecuteAsync(structure, dataSourceName, isMultipleCreateOperation: true), + structure.PaginationMetadata); + } + } + /// /// Executes the given IMiddlewareContext of the GraphQL and expecting result of stored-procedure execution as /// list of Jsons and the relevant pagination metadata back. @@ -254,15 +293,25 @@ public object ResolveList(JsonElement array, IObjectField fieldSchema, ref IMeta // // Given the SqlQueryStructure structure, obtains the query text and executes it against the backend. // - private async Task ExecuteAsync(SqlQueryStructure structure, string dataSourceName) + private async Task ExecuteAsync(SqlQueryStructure structure, string dataSourceName, bool isMultipleCreateOperation = false) { RuntimeConfig runtimeConfig = _runtimeConfigProvider.GetConfig(); DatabaseType databaseType = runtimeConfig.GetDataSourceFromDataSourceName(dataSourceName).DatabaseType; IQueryBuilder queryBuilder = _queryFactory.GetQueryBuilder(databaseType); IQueryExecutor queryExecutor = _queryFactory.GetQueryExecutor(databaseType); + string queryString; + // Open connection and execute query using _queryExecutor - string queryString = queryBuilder.Build(structure); + if (isMultipleCreateOperation) + { + structure.IsMultipleCreateOperation = true; + queryString = queryBuilder.Build(structure); + } + else + { + queryString = queryBuilder.Build(structure); + } // Global Cache enablement check if (runtimeConfig.CanUseCache()) diff --git a/src/Core/Services/MetadataProviders/ISqlMetadataProvider.cs b/src/Core/Services/MetadataProviders/ISqlMetadataProvider.cs index 97128ba10c..7d0f3b8ad0 100644 --- a/src/Core/Services/MetadataProviders/ISqlMetadataProvider.cs +++ b/src/Core/Services/MetadataProviders/ISqlMetadataProvider.cs @@ -219,6 +219,7 @@ void InitializeAsync( /// Referenced entity name. /// Referencing entity name. /// Stores the required foreign key definition from the referencing to referenced entity. + /// Indicates whether the relationship type is M:N /// true when the foreign key definition is successfully determined. /// /// For a 1:N relationship between Publisher: Book entity defined in Publisher entity's config: @@ -232,6 +233,7 @@ public bool TryGetFKDefinition( string targetEntityName, string referencingEntityName, string referencedEntityName, - [NotNullWhen(true)] out ForeignKeyDefinition? foreignKeyDefinition) => throw new NotImplementedException(); + [NotNullWhen(true)] out ForeignKeyDefinition? foreignKeyDefinition, + bool isMToNRelationship) => throw new NotImplementedException(); } } diff --git a/src/Core/Services/MetadataProviders/SqlMetadataProvider.cs b/src/Core/Services/MetadataProviders/SqlMetadataProvider.cs index fd4a763b69..d40b7ed107 100644 --- a/src/Core/Services/MetadataProviders/SqlMetadataProvider.cs +++ b/src/Core/Services/MetadataProviders/SqlMetadataProvider.cs @@ -1130,31 +1130,53 @@ private static Dictionary GetQueryParams( /// In the future, mappings for SPs could be used for parameter renaming. /// We also handle logging the primary key information here since this is when we first have /// the exposed names suitable for logging. + /// As part of building the database query, when generating the output columns, + /// EntityBackingColumnsToExposedNames is looked at. + /// But, when linking entity details are not populated, the flow will fail + /// when generating the output columns. + /// Hence, mappings of exposed names to backing columns + /// and of backing columns to exposed names + /// are generated for linking entities as well. /// private void GenerateExposedToBackingColumnMapsForEntities() { foreach ((string entityName, Entity _) in _entities) { - try + GenerateExposedToBackingColumnMapUtil(entityName); + } + + foreach ((string entityName, Entity _) in _linkingEntities) + { + GenerateExposedToBackingColumnMapUtil(entityName); + } + } + + /// + /// Helper method to generate the mappings of exposed names to + /// backing columns, and of backing columns to exposed names. + /// + /// Name of the entity + private void GenerateExposedToBackingColumnMapUtil(string entityName) + { + try + { + // For StoredProcedures, result set definitions become the column definition. + Dictionary? mapping = GetMappingForEntity(entityName); + EntityBackingColumnsToExposedNames[entityName] = mapping is not null ? mapping : new(); + EntityExposedNamesToBackingColumnNames[entityName] = EntityBackingColumnsToExposedNames[entityName].ToDictionary(x => x.Value, x => x.Key); + SourceDefinition sourceDefinition = GetSourceDefinition(entityName); + foreach (string columnName in sourceDefinition.Columns.Keys) { - // For StoredProcedures, result set definitions become the column definition. - Dictionary? mapping = GetMappingForEntity(entityName); - EntityBackingColumnsToExposedNames[entityName] = mapping is not null ? mapping : new(); - EntityExposedNamesToBackingColumnNames[entityName] = EntityBackingColumnsToExposedNames[entityName].ToDictionary(x => x.Value, x => x.Key); - SourceDefinition sourceDefinition = GetSourceDefinition(entityName); - foreach (string columnName in sourceDefinition.Columns.Keys) + if (!EntityExposedNamesToBackingColumnNames[entityName].ContainsKey(columnName) && !EntityBackingColumnsToExposedNames[entityName].ContainsKey(columnName)) { - if (!EntityExposedNamesToBackingColumnNames[entityName].ContainsKey(columnName) && !EntityBackingColumnsToExposedNames[entityName].ContainsKey(columnName)) - { - EntityBackingColumnsToExposedNames[entityName].Add(columnName, columnName); - EntityExposedNamesToBackingColumnNames[entityName].Add(columnName, columnName); - } + EntityBackingColumnsToExposedNames[entityName].Add(columnName, columnName); + EntityExposedNamesToBackingColumnNames[entityName].Add(columnName, columnName); } } - catch (Exception e) - { - HandleOrRecordException(e); - } + } + catch (Exception e) + { + HandleOrRecordException(e); } } @@ -1675,6 +1697,7 @@ private void ValidateAllFkHaveBeenInferred( IEnumerable> foreignKeys = relationshipData.TargetEntityToFkDefinitionMap.Values; // If none of the inferred foreign keys have the referencing columns, // it means metadata is still missing fail the bootstrap. + if (!foreignKeys.Any(fkList => fkList.Any(fk => fk.ReferencingColumns.Count() != 0))) { HandleOrRecordException(new NotSupportedException($"Some of the relationship information missing and could not be inferred for {sourceEntityName}.")); @@ -1699,7 +1722,7 @@ private async Task?> { // Extract all the rows in the current Result Set of DbDataReader. DbResultSet foreignKeysInfoWithProperties = - await QueryExecutor.ExtractResultSetFromDbDataReader(reader); + await QueryExecutor.ExtractResultSetFromDbDataReaderAsync(reader); Dictionary pairToFkDefinition = new(); @@ -1748,7 +1771,7 @@ private async Task> { // Extract all the rows in the current Result Set of DbDataReader. DbResultSet readOnlyFieldRowsWithProperties = - await QueryExecutor.ExtractResultSetFromDbDataReader(reader); + await QueryExecutor.ExtractResultSetFromDbDataReaderAsync(reader); List readOnlyFields = new(); @@ -1918,7 +1941,8 @@ public bool TryGetFKDefinition( string targetEntityName, string referencingEntityName, string referencedEntityName, - [NotNullWhen(true)] out ForeignKeyDefinition? foreignKeyDefinition) + [NotNullWhen(true)] out ForeignKeyDefinition? foreignKeyDefinition, + bool isMToNRelationship = false) { if (GetEntityNamesAndDbObjects().TryGetValue(sourceEntityName, out DatabaseObject? sourceDbObject) && GetEntityNamesAndDbObjects().TryGetValue(referencingEntityName, out DatabaseObject? referencingDbObject) && @@ -1927,16 +1951,29 @@ public bool TryGetFKDefinition( DatabaseTable referencingDbTable = (DatabaseTable)referencingDbObject; DatabaseTable referencedDbTable = (DatabaseTable)referencedDbObject; SourceDefinition sourceDefinition = sourceDbObject.SourceDefinition; - RelationShipPair referencingReferencedPair = new(referencingDbTable, referencedDbTable); + RelationShipPair referencingReferencedPair; List fKDefinitions = sourceDefinition.SourceEntityRelationshipMap[sourceEntityName].TargetEntityToFkDefinitionMap[targetEntityName]; - // At this point, DAB guarantees that a valid foreign key definition exists between the the referencing entity - // and the referenced entity. That's because DAB validates that all foreign key metadata - // was inferred for each relationship during startup. - foreignKeyDefinition = fKDefinitions.FirstOrDefault( - fk => fk.Pair.Equals(referencingReferencedPair) && - fk.ReferencingColumns.Count > 0 - && fk.ReferencedColumns.Count > 0)!; + // At this point, we are sure that a valid foreign key definition would exist from the referencing entity + // to the referenced entity because we validate it during the startup that the Foreign key information + // has been inferred for all the relationships. + if (isMToNRelationship) + { + + foreignKeyDefinition = fKDefinitions.FirstOrDefault( + fk => string.Equals(referencedDbTable.FullName, fk.Pair.ReferencedDbTable.FullName, StringComparison.OrdinalIgnoreCase) + && fk.ReferencingColumns.Count > 0 + && fk.ReferencedColumns.Count > 0)!; + } + else + { + referencingReferencedPair = new(referencingDbTable, referencedDbTable); + foreignKeyDefinition = fKDefinitions.FirstOrDefault( + fk => fk.Pair.Equals(referencingReferencedPair) && + fk.ReferencingColumns.Count > 0 + && fk.ReferencedColumns.Count > 0)!; + } + return true; } diff --git a/src/Core/Services/MultipleMutationInputValidator.cs b/src/Core/Services/MultipleMutationInputValidator.cs index 1696418351..1811fa39bf 100644 --- a/src/Core/Services/MultipleMutationInputValidator.cs +++ b/src/Core/Services/MultipleMutationInputValidator.cs @@ -375,7 +375,8 @@ private void ProcessRelationshipField( targetEntityName: targetEntityName, referencingEntityName: referencingEntityName, referencedEntityName: referencedEntityName, - foreignKeyDefinition: out ForeignKeyDefinition? fkDefinition)) + foreignKeyDefinition: out ForeignKeyDefinition? fkDefinition, + isMToNRelationship: false)) { // This should not be hit ideally. throw new DataApiBuilderException( diff --git a/src/Service.GraphQLBuilder/GraphQLUtils.cs b/src/Service.GraphQLBuilder/GraphQLUtils.cs index 88a7464007..36cbb3fd65 100644 --- a/src/Service.GraphQLBuilder/GraphQLUtils.cs +++ b/src/Service.GraphQLBuilder/GraphQLUtils.cs @@ -396,5 +396,64 @@ public static Tuple GetSourceAndTargetEntityNameFromLinkingEntit return new(sourceTargetEntityNames[1], sourceTargetEntityNames[2]); } + + /// + /// Helper method to extract a hotchocolate field node object with the specified name from all the field node objects belonging to an input type object. + /// + /// List of field node objects belonging to an input type object + /// Name of the field node object to extract from the list of all field node objects + /// + public static IValueNode GetFieldNodeForGivenFieldName(List objectFieldNodes, string fieldName) + { + ObjectFieldNode? requiredFieldNode = objectFieldNodes.Where(fieldNode => fieldNode.Name.Value.Equals(fieldName)).FirstOrDefault(); + if (requiredFieldNode != null) + { + return requiredFieldNode.Value; + } + + throw new ArgumentException($"The provided field {fieldName} does not exist."); + } + + /// + /// Helper method to determine if the relationship defined between the source entity and a particular target entity is an M:N relationship. + /// + /// Source entity. + /// Relationship name. + /// true if the relationship between source and target entities has a cardinality of M:N. + public static bool IsMToNRelationship(Entity sourceEntity, string relationshipName) + { + return sourceEntity.Relationships is not null && + sourceEntity.Relationships.TryGetValue(relationshipName, out EntityRelationship? relationshipInfo) && + !string.IsNullOrWhiteSpace(relationshipInfo.LinkingObject); + } + + /// + /// Helper method to get the name of the related entity for a given relationship name. + /// + /// Entity object + /// Name of the entity + /// Name of the relationship + /// Name of the related entity + public static string GetRelationshipTargetEntityName(Entity entity, string entityName, string relationshipName) + { + if (entity.Relationships is null) + { + throw new DataApiBuilderException(message: $"Entity {entityName} has no relationships defined", + statusCode: HttpStatusCode.InternalServerError, + subStatusCode: DataApiBuilderException.SubStatusCodes.UnexpectedError); + } + + if (entity.Relationships.TryGetValue(relationshipName, out EntityRelationship? entityRelationship) + && entityRelationship is not null) + { + return entityRelationship.TargetEntity; + } + else + { + throw new DataApiBuilderException(message: $"Entity {entityName} does not have a relationship named {relationshipName}", + statusCode: HttpStatusCode.InternalServerError, + subStatusCode: DataApiBuilderException.SubStatusCodes.RelationshipNotFound); + } + } } } diff --git a/src/Service.GraphQLBuilder/Mutations/CreateMutationBuilder.cs b/src/Service.GraphQLBuilder/Mutations/CreateMutationBuilder.cs index 7581663b9e..422bf3b790 100644 --- a/src/Service.GraphQLBuilder/Mutations/CreateMutationBuilder.cs +++ b/src/Service.GraphQLBuilder/Mutations/CreateMutationBuilder.cs @@ -441,19 +441,6 @@ private static InputValueDefinitionNode GetComplexInputType( ); } - /// - /// Helper method to determine if the relationship defined between the source entity and a particular target entity is an M:N relationship. - /// - /// Source entity. - /// Relationship name. - /// true if the relationship between source and target entities has a cardinality of M:N. - private static bool IsMToNRelationship(Entity sourceEntity, string relationshipName) - { - return sourceEntity.Relationships is not null && - sourceEntity.Relationships.TryGetValue(relationshipName, out EntityRelationship? relationshipInfo) && - !string.IsNullOrWhiteSpace(relationshipInfo.LinkingObject); - } - private static ITypeNode GenerateListType(ITypeNode type, ITypeNode fieldType) { // Look at the inner type of the list type, eg: [Bar]'s inner type is Bar diff --git a/src/Service.Tests/Authorization/GraphQL/CreateMutationAuthorizationTests.cs b/src/Service.Tests/Authorization/GraphQL/CreateMutationAuthorizationTests.cs index 1cc0e9f1b4..fc741a2745 100644 --- a/src/Service.Tests/Authorization/GraphQL/CreateMutationAuthorizationTests.cs +++ b/src/Service.Tests/Authorization/GraphQL/CreateMutationAuthorizationTests.cs @@ -94,7 +94,6 @@ await ValidateRequestIsUnauthorized( /// for all the entities involved in the mutation. /// [TestMethod] - [Ignore] public async Task ValidateAuthZCheckOnEntitiesForCreateOneMultipleMutations() { string createBookMutationName = "createbook"; @@ -117,12 +116,14 @@ await ValidateRequestIsUnauthorized( ); // The authenticated role has create permissions on both the Book and Publisher entities. - // Hence the authorization checks will pass. + // So, no errors are expected during authorization checks. + // Hence, passing an empty string as the expected error message. await ValidateRequestIsAuthorized( graphQLMutationName: createBookMutationName, graphQLMutation: createOneBookMutation, isAuthenticated: true, - clientRoleHeader: "authenticated" + clientRoleHeader: "authenticated", + expectedErrorMessage: "" ); } @@ -131,7 +132,6 @@ await ValidateRequestIsAuthorized( /// for all the entities involved in the mutation. /// [TestMethod] - [Ignore] public async Task ValidateAuthZCheckOnEntitiesForCreateMultipleMutations() { string createMultipleBooksMutationName = "createbooks"; @@ -156,13 +156,14 @@ await ValidateRequestIsUnauthorized( clientRoleHeader: "anonymous"); // The authenticated role has create permissions on both the Book and Publisher entities. - // Hence the authorization checks will pass. + // So, no errors are expected during authorization checks. + // Hence, passing an empty string as the expected error message. await ValidateRequestIsAuthorized( graphQLMutationName: createMultipleBooksMutationName, graphQLMutation: createMultipleBookMutation, isAuthenticated: true, clientRoleHeader: "authenticated", - expectedResult: "Expected item argument in mutation arguments." + expectedErrorMessage: "" ); } @@ -174,7 +175,6 @@ await ValidateRequestIsAuthorized( /// multiple-create mutation, the request will fail during authorization check. /// [TestMethod] - [Ignore] public async Task ValidateAuthZCheckOnColumnsForCreateOneMultipleMutations() { string createOneStockMutationName = "createStock"; @@ -233,12 +233,13 @@ await ValidateRequestIsUnauthorized( // Since the field stocks.piecesAvailable is not included in the mutation, // the authorization check should pass. + // Hence, passing an empty string as the expected error message. await ValidateRequestIsAuthorized( graphQLMutationName: createOneStockMutationName, graphQLMutation: createOneStockWithoutPiecesAvailable, isAuthenticated: true, clientRoleHeader: "test_role_with_excluded_fields_on_create", - expectedResult: ""); + expectedErrorMessage: ""); // Executing a similar mutation request but with stocks_price as top-level entity. // This validates that the recursive logic to do authorization on fields belonging to related entities @@ -300,12 +301,13 @@ await ValidateRequestIsUnauthorized( // Since the field stocks.piecesAvailable is not included in the mutation, // the authorization check should pass. + // Hence, passing an empty string as the expected error message. await ValidateRequestIsAuthorized( graphQLMutationName: createOneStockMutationName, graphQLMutation: createOneStocksPriceWithoutPiecesAvailable, isAuthenticated: true, clientRoleHeader: "test_role_with_excluded_fields_on_create", - expectedResult: ""); + expectedErrorMessage: ""); } /// @@ -316,7 +318,6 @@ await ValidateRequestIsAuthorized( /// multiple-create mutation, the request will fail during authorization check. /// [TestMethod] - [Ignore] public async Task ValidateAuthZCheckOnColumnsForCreateMultipleMutations() { string createMultipleStockMutationName = "createStocks"; @@ -382,12 +383,13 @@ await ValidateRequestIsUnauthorized( // Since the field stocks.piecesAvailable is not included in the mutation, // the authorization check should pass. + // Hence, passing an empty string as the expected error message. await ValidateRequestIsAuthorized( graphQLMutationName: createMultipleStockMutationName, graphQLMutation: createMultipleStocksWithoutPiecesAvailable, isAuthenticated: true, clientRoleHeader: "test_role_with_excluded_fields_on_create", - expectedResult: ""); + expectedErrorMessage: ""); } #endregion @@ -431,13 +433,13 @@ private async Task ValidateRequestIsUnauthorized( /// /// Name of the mutation. /// Request body of the mutation. - /// Expected result. + /// Expected error message. /// Boolean indicating whether the request should be treated as authenticated or not. /// Value of X-MS-API-ROLE client role header. private async Task ValidateRequestIsAuthorized( string graphQLMutationName, string graphQLMutation, - string expectedResult = "Value cannot be null", + string expectedErrorMessage = "Value cannot be null", bool isAuthenticated = false, string clientRoleHeader = "anonymous") { @@ -451,7 +453,7 @@ private async Task ValidateRequestIsAuthorized( SqlTestHelper.TestForErrorInGraphQLResponse( actual.ToString(), - message: expectedResult + message: expectedErrorMessage ); } diff --git a/src/Service.Tests/DatabaseSchema-MsSql.sql b/src/Service.Tests/DatabaseSchema-MsSql.sql index 5684afb5e3..1e74e8a590 100644 --- a/src/Service.Tests/DatabaseSchema-MsSql.sql +++ b/src/Service.Tests/DatabaseSchema-MsSql.sql @@ -19,14 +19,20 @@ DROP PROCEDURE IF EXISTS update_book_title; DROP PROCEDURE IF EXISTS get_authors_history_by_first_name; DROP PROCEDURE IF EXISTS insert_and_display_all_books_for_given_publisher; DROP TABLE IF EXISTS book_author_link; +DROP TABLE IF EXISTS book_author_link_mm; DROP TABLE IF EXISTS reviews; +DROP TABLE IF EXISTS reviews_mm; DROP TABLE IF EXISTS authors; +DROP TABLE IF EXISTS authors_mm; DROP TABLE IF EXISTS book_website_placements; DROP TABLE IF EXISTS website_users; +DROP TABLE IF EXISTS website_users_mm; DROP TABLE IF EXISTS books; +DROP TABLE IF EXISTS books_mm; DROP TABLE IF EXISTS players; DROP TABLE IF EXISTS clubs; DROP TABLE IF EXISTS publishers; +DROP TABLE IF EXISTS publishers_mm; DROP TABLE IF EXISTS [foo].[magazines]; DROP TABLE IF EXISTS [bar].[magazines]; DROP TABLE IF EXISTS stocks_price; @@ -66,12 +72,23 @@ CREATE TABLE publishers( name varchar(max) NOT NULL ); +CREATE TABLE publishers_mm( + id int IDENTITY(5001, 1) PRIMARY KEY, + name varchar(max) NOT NULL +); + CREATE TABLE books( id int IDENTITY(5001, 1) PRIMARY KEY, title varchar(max) NOT NULL, publisher_id int NOT NULL ); +CREATE TABLE books_mm( + id int IDENTITY(5001, 1) PRIMARY KEY, + title varchar(max) NOT NULL, + publisher_id int NOT NULL +); + CREATE TABLE players( id int IDENTITY(5001, 1) PRIMARY KEY, [name] varchar(max) NOT NULL, @@ -95,16 +112,36 @@ CREATE TABLE website_users( username text NULL ); +CREATE TABLE website_users_mm( + id int PRIMARY KEY, + username text NULL +); + CREATE TABLE authors( id int IDENTITY(5001, 1) PRIMARY KEY, name varchar(max) NOT NULL, birthdate varchar(max) NOT NULL ); +CREATE TABLE authors_mm( + id int IDENTITY(5001, 1) PRIMARY KEY, + name varchar(max) NOT NULL, + birthdate varchar(max) NOT NULL +); + CREATE TABLE reviews( book_id int, id int IDENTITY(5001, 1), content varchar(max) DEFAULT('Its a classic') NOT NULL, + websiteuser_id INT DEFAULT 1, + PRIMARY KEY(book_id, id) +); + +CREATE TABLE reviews_mm( + book_id int, + id int IDENTITY(5001, 1), + content varchar(max) DEFAULT('Its a classic') NOT NULL, + websiteuser_id INT DEFAULT 1, PRIMARY KEY(book_id, id) ); @@ -115,6 +152,13 @@ CREATE TABLE book_author_link( PRIMARY KEY(book_id, author_id) ); +CREATE TABLE book_author_link_mm( + book_id int NOT NULL, + author_id int NOT NULL, + royalty_percentage float DEFAULT 0 NULL, + PRIMARY KEY(book_id, author_id) +); + EXEC('CREATE SCHEMA [foo]'); CREATE TABLE [foo].[magazines]( @@ -389,6 +433,10 @@ SET IDENTITY_INSERT publishers ON INSERT INTO publishers(id, name) VALUES (1234, 'Big Company'), (2345, 'Small Town Publisher'), (2323, 'TBD Publishing One'), (2324, 'TBD Publishing Two Ltd'), (1940, 'Policy Publisher 01'), (1941, 'Policy Publisher 02'), (1156, 'The First Publisher'); SET IDENTITY_INSERT publishers OFF +SET IDENTITY_INSERT publishers_mm ON +INSERT INTO publishers_mm(id, name) VALUES (1234, 'Big Company'), (2345, 'Small Town Publisher'), (2323, 'TBD Publishing One'), (2324, 'TBD Publishing Two Ltd'), (1940, 'Policy Publisher 01'), (1941, 'Policy Publisher 02'), (1156, 'The First Publisher'); +SET IDENTITY_INSERT publishers_mm OFF + SET IDENTITY_INSERT clubs ON INSERT INTO clubs(id, name) VALUES (1111, 'Manchester United'), (1112, 'FC Barcelona'), (1113, 'Real Madrid'); SET IDENTITY_INSERT clubs OFF @@ -397,6 +445,10 @@ SET IDENTITY_INSERT authors ON INSERT INTO authors(id, name, birthdate) VALUES (123, 'Jelte', '2001-01-01'), (124, 'Aniruddh', '2002-02-02'), (125, 'Aniruddh', '2001-01-01'), (126, 'Aaron', '2001-01-01'); SET IDENTITY_INSERT authors OFF +SET IDENTITY_INSERT authors_mm ON +INSERT INTO authors_mm(id, name, birthdate) VALUES (123, 'Jelte', '2001-01-01'), (124, 'Aniruddh', '2002-02-02'), (125, 'Aniruddh', '2001-01-01'), (126, 'Aaron', '2001-01-01'); +SET IDENTITY_INSERT authors_mm OFF + INSERT INTO GQLmappings(__column1, __column2, column3) VALUES (1, 'Incompatible GraphQL Name', 'Compatible GraphQL Name'); INSERT INTO GQLmappings(__column1, __column2, column3) VALUES (3, 'Old Value', 'Record to be Updated'); INSERT INTO GQLmappings(__column1, __column2, column3) VALUES (4, 'Lost Record', 'Record to be Deleted'); @@ -450,6 +502,24 @@ VALUES (1, 'Awesome book', 1234), (14, 'Before Sunset', 1234); SET IDENTITY_INSERT books OFF +SET IDENTITY_INSERT books_mm ON +INSERT INTO books_mm(id, title, publisher_id) +VALUES (1, 'Awesome book', 1234), +(2, 'Also Awesome book', 1234), +(3, 'Great wall of china explained', 2345), +(4, 'US history in a nutshell', 2345), +(5, 'Chernobyl Diaries', 2323), +(6, 'The Palace Door', 2324), +(7, 'The Groovy Bar', 2324), +(8, 'Time to Eat', 2324), +(9, 'Policy-Test-01', 1940), +(10, 'Policy-Test-02', 1940), +(11, 'Policy-Test-04', 1941), +(12, 'Time to Eat 2', 1941), +(13, 'Before Sunrise', 1234), +(14, 'Before Sunset', 1234); +SET IDENTITY_INSERT books_mm OFF + SET IDENTITY_INSERT players ON INSERT INTO players(id, [name], current_club_id, new_club_id) VALUES (1, 'Cristiano Ronaldo', 1113, 1111), @@ -461,11 +531,19 @@ INSERT INTO book_website_placements(id, book_id, price) VALUES (1, 1, 100), (2, SET IDENTITY_INSERT book_website_placements OFF INSERT INTO book_author_link(book_id, author_id) VALUES (1, 123), (2, 124), (3, 123), (3, 124), (4, 123), (4, 124), (5, 126); +INSERT INTO book_author_link_mm(book_id, author_id) VALUES (1, 123), (2, 124), (3, 123), (3, 124), (4, 123), (4, 124), (5, 126); + +INSERT INTO website_users(id, username) VALUES (1, 'George'), (2, NULL), (3, ''), (4, 'book_lover_95'), (5, 'null'); +INSERT INTO website_users_mm(id, username) VALUES (1, 'George'), (2, NULL), (3, ''), (4, 'book_lover_95'), (5, 'null'); SET IDENTITY_INSERT reviews ON INSERT INTO reviews(id, book_id, content) VALUES (567, 1, 'Indeed a great book'), (568, 1, 'I loved it'), (569, 1, 'best book I read in years'); SET IDENTITY_INSERT reviews OFF +SET IDENTITY_INSERT reviews_mm ON +INSERT INTO reviews_mm(id, book_id, content) VALUES (567, 1, 'Indeed a great book'), (568, 1, 'I loved it'), (569, 1, 'best book I read in years'); +SET IDENTITY_INSERT reviews_mm OFF + SET IDENTITY_INSERT type_table ON INSERT INTO type_table(id, byte_types, short_types, int_types, long_types, @@ -506,7 +584,7 @@ VALUES (6, 'Journal6', 'green', null), (7, 'Journal7', null, null); -INSERT INTO website_users(id, username) VALUES (1, 'George'), (2, NULL), (3, ''), (4, 'book_lover_95'), (5, 'null'); + INSERT INTO [foo].[magazines](id, title, issue_number) VALUES (1, 'Vogue', 1234), (11, 'Sports Illustrated', NULL), (3, 'Fitness', NULL); INSERT INTO [bar].[magazines](upc, comic_name, issue) VALUES (0, 'NotVogue', 0); INSERT INTO brokers([ID Number], [First Name], [Last Name]) VALUES (1, 'Michael', 'Burry'), (2, 'Jordan', 'Belfort'); diff --git a/src/Service.Tests/Snapshots/ConfigurationTests.TestReadingRuntimeConfigForMsSql.verified.txt b/src/Service.Tests/Snapshots/ConfigurationTests.TestReadingRuntimeConfigForMsSql.verified.txt index 1c4e25eba7..88cacecfd3 100644 --- a/src/Service.Tests/Snapshots/ConfigurationTests.TestReadingRuntimeConfigForMsSql.verified.txt +++ b/src/Service.Tests/Snapshots/ConfigurationTests.TestReadingRuntimeConfigForMsSql.verified.txt @@ -246,6 +246,26 @@ } } ] + }, + { + Role: role_multiple_create_policy_tester, + Actions: [ + { + Action: Read + }, + { + Action: Update + }, + { + Action: Delete + }, + { + Action: Create, + Policy: { + Database: @item.name ne 'Test' + } + } + ] } ], Relationships: { @@ -256,6 +276,52 @@ } } }, + { + Publisher_MM: { + Source: { + Object: publishers_mm, + Type: Table + }, + GraphQL: { + Singular: Publisher_MM, + Plural: Publishers_MM, + Enabled: true + }, + Rest: { + Enabled: true + }, + Permissions: [ + { + Role: anonymous, + Actions: [ + { + Action: * + } + ] + }, + { + Role: authenticated, + Actions: [ + { + Action: * + } + ] + } + ], + Relationships: { + books_mm: { + Cardinality: Many, + TargetEntity: Book_MM, + SourceFields: [ + id + ], + TargetFields: [ + publisher_id + ] + } + } + } + }, { Stock: { Source: { @@ -798,6 +864,29 @@ Action: Delete } ] + }, + { + Role: role_multiple_create_policy_tester, + Actions: [ + { + Action: Update + }, + { + Action: Delete + }, + { + Action: Create, + Policy: { + Database: @item.title ne 'Test' + } + }, + { + Action: Read, + Policy: { + Database: @item.publisher_id ne 1234 + } + } + ] } ], Mappings: { @@ -835,6 +924,78 @@ } } }, + { + Book_MM: { + Source: { + Object: books_mm, + Type: Table + }, + GraphQL: { + Singular: book_mm, + Plural: books_mm, + Enabled: true + }, + Rest: { + Enabled: true + }, + Permissions: [ + { + Role: anonymous, + Actions: [ + { + Action: * + } + ] + }, + { + Role: authenticated, + Actions: [ + { + Action: * + } + ] + } + ], + Relationships: { + authors: { + Cardinality: Many, + TargetEntity: Author_MM, + SourceFields: [ + id + ], + TargetFields: [ + id + ], + LinkingObject: book_author_link_mm, + LinkingSourceFields: [ + book_id + ], + LinkingTargetFields: [ + author_id + ] + }, + publishers: { + TargetEntity: Publisher_MM, + SourceFields: [ + publisher_id + ], + TargetFields: [ + id + ] + }, + reviews: { + Cardinality: Many, + TargetEntity: Review_MM, + SourceFields: [ + id + ], + TargetFields: [ + book_id + ] + } + } + } + }, { BookWebsitePlacement: { Source: { @@ -938,6 +1099,59 @@ } } }, + { + Author_MM: { + Source: { + Object: authors_mm, + Type: Table + }, + GraphQL: { + Singular: author_mm, + Plural: authors_mm, + Enabled: true + }, + Rest: { + Enabled: true + }, + Permissions: [ + { + Role: anonymous, + Actions: [ + { + Action: * + } + ] + }, + { + Role: authenticated, + Actions: [ + { + Action: * + } + ] + } + ], + Relationships: { + books: { + Cardinality: Many, + TargetEntity: Book_MM, + SourceFields: [ + id + ], + TargetFields: [ + id + ], + LinkingObject: book_author_link_mm, + LinkingSourceFields: [ + author_id + ], + LinkingTargetFields: [ + book_id + ] + } + } + } + }, { Revenue: { Source: { @@ -1020,11 +1234,97 @@ Action: Delete } ] + }, + { + Role: role_multiple_create_policy_tester, + Actions: [ + { + Action: Update + }, + { + Action: Delete + }, + { + Action: Create, + Policy: { + Database: @item.content ne 'Great' + } + }, + { + Action: Read, + Policy: { + Database: @item.websiteuser_id ne 1 + } + } + ] } ], Relationships: { books: { TargetEntity: Book + }, + website_users: { + TargetEntity: WebsiteUser, + SourceFields: [ + websiteuser_id + ], + TargetFields: [ + id + ] + } + } + } + }, + { + Review_MM: { + Source: { + Object: reviews_mm, + Type: Table + }, + GraphQL: { + Singular: review_mm, + Plural: reviews_mm, + Enabled: true + }, + Rest: { + Enabled: true + }, + Permissions: [ + { + Role: anonymous, + Actions: [ + { + Action: * + } + ] + }, + { + Role: authenticated, + Actions: [ + { + Action: * + } + ] + } + ], + Relationships: { + books: { + TargetEntity: Book_MM, + SourceFields: [ + book_id + ], + TargetFields: [ + id + ] + }, + website_users: { + TargetEntity: WebsiteUser_MM, + SourceFields: [ + websiteuser_id + ], + TargetFields: [ + id + ] } } } @@ -1218,7 +1518,65 @@ } ] } - ] + ], + Relationships: { + reviews: { + Cardinality: Many, + TargetEntity: Review, + SourceFields: [ + id + ], + TargetFields: [ + websiteuser_id + ] + } + } + } + }, + { + WebsiteUser_MM: { + Source: { + Object: website_users_mm, + Type: Table + }, + GraphQL: { + Singular: websiteuser_mm, + Plural: websiteusers_mm, + Enabled: true + }, + Rest: { + Enabled: true + }, + Permissions: [ + { + Role: anonymous, + Actions: [ + { + Action: * + } + ] + }, + { + Role: authenticated, + Actions: [ + { + Action: * + } + ] + } + ], + Relationships: { + reviews: { + Cardinality: Many, + TargetEntity: Review_MM, + SourceFields: [ + id + ], + TargetFields: [ + websiteuser_id + ] + } + } } }, { diff --git a/src/Service.Tests/SqlTests/GraphQLMutationTests/MsSqlGraphQLMutationTests.cs b/src/Service.Tests/SqlTests/GraphQLMutationTests/MsSqlGraphQLMutationTests.cs index f2a9d04a9d..5aadef172c 100644 --- a/src/Service.Tests/SqlTests/GraphQLMutationTests/MsSqlGraphQLMutationTests.cs +++ b/src/Service.Tests/SqlTests/GraphQLMutationTests/MsSqlGraphQLMutationTests.cs @@ -86,7 +86,7 @@ ORDER BY [id] asc [TestMethod] public async Task InsertMutationFailingDatabasePolicy() { - string errorMessage = "Could not insert row with given values."; + string errorMessage = "Could not insert row with given values for entity: Publisher"; string msSqlQuery = @" SELECT count(*) as count FROM [publishers] diff --git a/src/Service.Tests/SqlTests/GraphQLMutationTests/MultipleCreateMutationTests/MsSqlMultipleCreateMutationTests.cs b/src/Service.Tests/SqlTests/GraphQLMutationTests/MultipleCreateMutationTests/MsSqlMultipleCreateMutationTests.cs new file mode 100644 index 0000000000..25fe16d1de --- /dev/null +++ b/src/Service.Tests/SqlTests/GraphQLMutationTests/MultipleCreateMutationTests/MsSqlMultipleCreateMutationTests.cs @@ -0,0 +1,629 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Threading.Tasks; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Azure.DataApiBuilder.Service.Tests.SqlTests.GraphQLMutationTests.MultipleCreateMutationTests +{ + /// + /// Test class for GraphQL Multiple Create Mutation tests against MsSQL database type. + /// + + [TestClass, TestCategory(TestCategory.MSSQL)] + public class MsSqlMultipleCreateMutationTests : MultipleCreateMutationTestBase + { + + #region Test Fixture Setup + /// + /// Set the database engine for the tests + /// + [ClassInitialize] + public static async Task SetupAsync(TestContext context) + { + DatabaseEngine = TestCategory.MSSQL; + await InitializeTestFixture(); + } + + /// + /// Runs after every test to reset the database state + /// + [TestCleanup] + public async Task TestCleanup() + { + await ResetDbStateAsync(); + } + + #endregion + + [TestMethod] + public async Task MultipleCreateMutationWithManyToOneRelationship() + { + string dbQuery = @"SELECT TOP 1 [table0].[id] AS [id], [table0].[title] AS [title], [table0].[publisher_id] AS [publisher_id], + JSON_QUERY ([table1_subq].[data]) AS [publishers] FROM [dbo].[books] AS [table0] + OUTER APPLY (SELECT TOP 1 [table1].[id] AS [id], [table1].[name] AS [name] FROM [dbo].[publishers] AS [table1] + WHERE [table0].[publisher_id] = [table1].[id] + ORDER BY [table1].[id] ASC FOR JSON PATH, INCLUDE_NULL_VALUES,WITHOUT_ARRAY_WRAPPER) + AS [table1_subq]([data]) + WHERE [table0].[id] = 5001 AND [table0].[title] = 'Book #1' + ORDER BY [table0].[id] ASC FOR JSON PATH, INCLUDE_NULL_VALUES,WITHOUT_ARRAY_WRAPPER"; + + await MultipleCreateMutationWithManyToOneRelationship(dbQuery); + } + + [TestMethod] + public async Task MultipleCreateMutationWithOneToManyRelationship() + { + string expectedResponse = @"{ + ""id"": 5001, + ""title"": ""Book #1"", + ""publisher_id"": 1234, + ""reviews"": { + ""items"": [ + { + ""book_id"": 5001, + ""id"": 5001, + ""content"": ""Book #1 - Review #1"" + }, + { + ""book_id"": 5001, + ""id"": 5002, + ""content"": ""Book #1 - Review #2"" + } + ] + } + }"; + + await MultipleCreateMutationWithOneToManyRelationship(expectedResponse); + } + + [TestMethod] + public async Task MultipleCreateMutationWithManyToManyRelationship() + { + string expectedResponse = @"{ + ""id"": 5001, + ""title"": ""Book #1"", + ""publisher_id"": 1234, + ""authors"": { + ""items"": [ + { + ""id"": 5001, + ""name"": ""Author #1"", + ""birthdate"": ""2000-01-01"" + }, + { + ""id"": 5002, + ""name"": ""Author #2"", + ""birthdate"": ""2000-02-03"" + } + ] + } + }"; + + string linkingTableDbValidationQuery = @"SELECT [book_id], [author_id], [royalty_percentage] + FROM [dbo].[book_author_link] + WHERE [dbo].[book_author_link].[book_id] = 5001 AND ([dbo].[book_author_link].[author_id] = 5001 OR [dbo].[book_author_link].[author_id] = 5002) + ORDER BY [dbo].[book_author_link].[book_id], [dbo].[book_author_link].[author_id] ASC FOR JSON PATH, INCLUDE_NULL_VALUES;"; + + string expectedResponseFromLinkingTable = @"[{""book_id"":5001,""author_id"":5001,""royalty_percentage"":50.0},{""book_id"":5001,""author_id"":5002,""royalty_percentage"":50.0}]"; + + await MultipleCreateMutationWithManyToManyRelationship(expectedResponse, linkingTableDbValidationQuery, expectedResponseFromLinkingTable); + } + + [TestMethod] + public async Task MultipleCreateMutationWithOneToOneRelationship() + { + string expectedResponse = @" { + ""categoryid"": 101, + ""pieceid"": 101, + ""categoryName"": ""SciFi"", + ""piecesAvailable"": 100, + ""piecesRequired"": 50, + ""stocks_price"": { + ""categoryid"": 101, + ""pieceid"": 101, + ""instant"": ""2024-04-02"", + ""price"": 75, + ""is_wholesale_price"": true + } + }"; + + await MultipleCreateMutationWithOneToOneRelationship(expectedResponse); + } + + [TestMethod] + public async Task MultipleCreateMutationWithAllRelationshipTypes() + { + string expectedResponse = @"{ + ""id"": 5001, + ""title"": ""Book #1"", + ""publishers"": { + ""id"": 5001, + ""name"": ""Publisher #1"" + }, + ""reviews"": { + ""items"": [ + { + ""book_id"": 5001, + ""id"": 5001, + ""content"": ""Book #1 - Review #1"", + ""website_users"": { + ""id"": 5001, + ""username"": ""WebsiteUser #1"" + } + }, + { + ""book_id"": 5001, + ""id"": 5002, + ""content"": ""Book #1 - Review #2"", + ""website_users"": { + ""id"": 1, + ""username"": ""George"" + } + } + ] + }, + ""authors"": { + ""items"": [ + { + ""id"": 5001, + ""name"": ""Author #1"", + ""birthdate"": ""2000-02-01"" + }, + { + ""id"": 5002, + ""name"": ""Author #2"", + ""birthdate"": ""2000-01-02"" + } + ] + } + }"; + + string linkingTableDbValidationQuery = @"SELECT [book_id], [author_id], [royalty_percentage] + FROM [dbo].[book_author_link] + WHERE [dbo].[book_author_link].[book_id] = 5001 AND ([dbo].[book_author_link].[author_id] = 5001 OR [dbo].[book_author_link].[author_id] = 5002) + ORDER BY [dbo].[book_author_link].[book_id], [dbo].[book_author_link].[author_id] ASC FOR JSON PATH, INCLUDE_NULL_VALUES;"; + + string expectedResponseFromLinkingTable = @"[{""book_id"":5001,""author_id"":5001,""royalty_percentage"":50.0},{""book_id"":5001,""author_id"":5002,""royalty_percentage"":50.0}]"; + + await MultipleCreateMutationWithAllRelationshipTypes(expectedResponse, linkingTableDbValidationQuery, expectedResponseFromLinkingTable); + } + + [TestMethod] + public async Task ManyTypeMultipleCreateMutationOperation() + { + string expectedResponse = @"{ + ""items"": [ + { + ""id"": 5001, + ""title"": ""Book #1"", + ""publisher_id"": 5001, + ""publishers"": { + ""id"": 5001, + ""name"": ""Publisher #1"" + }, + ""reviews"": { + ""items"": [ + { + ""book_id"": 5001, + ""id"": 5001, + ""content"": ""Book #1 - Review #1"", + ""website_users"": { + ""id"": 5001, + ""username"": ""Website user #1"" + } + }, + { + ""book_id"": 5001, + ""id"": 5002, + ""content"": ""Book #1 - Review #2"", + ""website_users"": { + ""id"": 4, + ""username"": ""book_lover_95"" + } + } + ] + }, + ""authors"": { + ""items"": [ + { + ""id"": 5001, + ""name"": ""Author #1"", + ""birthdate"": ""2000-01-02"" + }, + { + ""id"": 5002, + ""name"": ""Author #2"", + ""birthdate"": ""2001-02-03"" + } + ] + } + }, + { + ""id"": 5002, + ""title"": ""Book #2"", + ""publisher_id"": 1234, + ""publishers"": { + ""id"": 1234, + ""name"": ""Big Company"" + }, + ""reviews"": { + ""items"": [] + }, + ""authors"": { + ""items"": [ + { + ""id"": 5003, + ""name"": ""Author #3"", + ""birthdate"": ""2000-01-02"" + }, + { + ""id"": 5004, + ""name"": ""Author #4"", + ""birthdate"": ""2001-02-03"" + } + ] + } + } + ] + }"; + + string linkingTableDbValidationQuery = @"SELECT [book_id], [author_id], [royalty_percentage] FROM [dbo].[book_author_link] + WHERE ( [dbo].[book_author_link].[book_id] = 5001 AND ([dbo].[book_author_link].[author_id] = 5001 OR [dbo].[book_author_link].[author_id] = 5002)) + OR ([dbo].[book_author_link].[book_id] = 5002 AND ([dbo].[book_author_link].[author_id] = 5003 OR [dbo].[book_author_link].[author_id] = 5004)) + ORDER BY [dbo].[book_author_link].[book_id] ASC FOR JSON PATH, INCLUDE_NULL_VALUES;"; + + string expectedResponseFromLinkingTable = @"[{""book_id"":5001,""author_id"":5001,""royalty_percentage"":50.0},{""book_id"":5001,""author_id"":5002,""royalty_percentage"":50.0},{""book_id"":5002,""author_id"":5003,""royalty_percentage"":65.0},{""book_id"":5002,""author_id"":5004,""royalty_percentage"":35.0}]"; + + await ManyTypeMultipleCreateMutationOperation(expectedResponse, linkingTableDbValidationQuery, expectedResponseFromLinkingTable); + } + + [TestMethod] + public async Task PointMultipleCreateFailsDueToCreatePolicyViolationAtTopLevelEntity() + { + + string expectedErrorMessage = "Could not insert row with given values for entity: Book at nesting level : 0"; + + // Validate that no book item is created + string bookDbQuery = @" + SELECT * + FROM [books] AS [table0] + WHERE [table0].[id] = 5001 + ORDER BY [id] asc + FOR JSON PATH, INCLUDE_NULL_VALUES"; + + // Validate that no publisher item is created + string publisherDbQuery = @" + SELECT * + FROM [publishers] AS [table0] + WHERE [table0].[id] = 5001 + ORDER BY [id] asc + FOR JSON PATH, INCLUDE_NULL_VALUES"; + + await PointMultipleCreateFailsDueToCreatePolicyViolationAtTopLevelEntity(expectedErrorMessage, bookDbQuery, publisherDbQuery); + } + + [TestMethod] + public async Task PointMultipleCreateFailsDueToCreatePolicyViolationAtRelatedEntity() + { + + string expectedErrorMessage = "Could not insert row with given values for entity: Publisher at nesting level : 1"; + + string bookDbQuery = @" + SELECT * + FROM [books] AS [table0] + WHERE [table0].[id] = 5001 + ORDER BY [id] asc + FOR JSON PATH, INCLUDE_NULL_VALUES"; + + string publisherDbQuery = @" + SELECT * + FROM [publishers] AS [table0] + WHERE [table0].[id] = 5001 + ORDER BY [id] asc + FOR JSON PATH, INCLUDE_NULL_VALUES"; + + await PointMultipleCreateFailsDueToCreatePolicyViolationAtRelatedEntity(expectedErrorMessage, bookDbQuery, publisherDbQuery); + } + + [TestMethod] + public async Task ManyTypeMultipleCreateFailsDueToCreatePolicyFailure() + { + + string expectedErrorMessage = "Could not insert row with given values for entity: Book at nesting level : 0"; + + string bookDbQuery = @" + SELECT * + FROM [books] AS [table0] + WHERE [table0].[id] >= 5001 + ORDER BY [id] asc + FOR JSON PATH, + INCLUDE_NULL_VALUES"; + + string publisherDbQuery = @" + SELECT * + FROM [publishers] AS [table0] + WHERE [table0].[id] >= 5001 + ORDER BY [id] asc + FOR JSON PATH, + INCLUDE_NULL_VALUES"; + + await ManyTypeMultipleCreateFailsDueToCreatePolicyFailure(expectedErrorMessage, bookDbQuery, publisherDbQuery); + } + + [TestMethod] + public async Task PointMultipleCreateMutationWithReadPolicyViolationAtRelatedEntity() + { + + string expectedResponse = @"{ + ""id"": 5001, + ""title"": ""Book #1"", + ""publisher_id"": 2345, + ""reviews"": { + ""items"": [ + { + ""book_id"": 5001, + ""id"": 5001, + ""content"": ""Review #1"", + ""websiteuser_id"": 4 + } + ] + } + }"; + + await PointMultipleCreateMutationWithReadPolicyViolationAtRelatedEntity(expectedResponse); + } + + [TestMethod] + public async Task MultipleCreateMutationWithOneToOneRelationshipDefinedInConfigFile() + { + string expectedResponse1 = @"{ + ""userid"": 3, + ""username"": ""DAB"", + ""email"": ""dab@microsoft.com"", + ""UserProfile_NonAutogenRelationshipColumn"": { + ""profileid"": 3, + ""userid"": 10, + ""username"": ""DAB"", + ""profilepictureurl"": ""dab/profilepicture"" + } + }"; + + string expectedResponse2 = @"{ + ""userid"": 4, + ""username"": ""DAB2"", + ""email"": ""dab@microsoft.com"", + ""UserProfile_NonAutogenRelationshipColumn"": { + ""profileid"": 4, + ""userid"": 10, + ""username"": ""DAB2"", + ""profilepictureurl"": ""dab/profilepicture"" + } + }"; + + await MultipleCreateMutationWithOneToOneRelationshipDefinedInConfigFile(expectedResponse1, expectedResponse2); + } + + [TestMethod] + public async Task MultipleCreateMutationWithManyToOneRelationshipDefinedInConfigFile() + { + string expectedResponse = @"{ + ""id"": 5001, + ""title"": ""Book #1"", + ""publisher_id"": 5001, + ""publishers"": { + ""id"": 5001, + ""name"": ""Publisher #1"" + } + }"; + + await MultipleCreateMutationWithManyToOneRelationshipDefinedInConfigFile(expectedResponse); + } + + [TestMethod] + public async Task MultipleCreateMutationWithOneToManyRelationshipDefinedInConfigFile() + { + string expectedResponse = @"{ + ""id"": 5001, + ""title"": ""Book #1"", + ""publisher_id"": 1234, + ""reviews"": { + ""items"": [ + { + ""book_id"": 5001, + ""id"": 5001, + ""content"": ""Book #1 - Review #1"" + }, + { + ""book_id"": 5001, + ""id"": 5002, + ""content"": ""Book #1 - Review #2"" + } + ] + } + }"; + + await MultipleCreateMutationWithOneToManyRelationshipDefinedInConfigFile(expectedResponse); + } + + [TestMethod] + public async Task MultipleCreateMutationWithManyToManyRelationshipDefinedInConfigFile() + { + string expectedResponse = @"{ + ""id"": 5001, + ""title"": ""Book #1"", + ""publisher_id"": 1234, + ""authors"": { + ""items"": [ + { + ""id"": 5001, + ""name"": ""Author #1"", + ""birthdate"": ""2000-01-01"" + }, + { + ""id"": 5002, + ""name"": ""Author #2"", + ""birthdate"": ""2000-02-03"" + } + ] + } + }"; + + string linkingTableDbValidationQuery = @"SELECT [book_id], [author_id], [royalty_percentage] + FROM [dbo].[book_author_link_mm] + WHERE [dbo].[book_author_link_mm].[book_id] = 5001 AND ([dbo].[book_author_link_mm].[author_id] = 5001 OR [dbo].[book_author_link_mm].[author_id] = 5002) + ORDER BY [dbo].[book_author_link_mm].[book_id], [dbo].[book_author_link_mm].[author_id] ASC FOR JSON PATH, INCLUDE_NULL_VALUES;"; + + string expectedResponseFromLinkingTable = @"[{""book_id"":5001,""author_id"":5001,""royalty_percentage"":50.0},{""book_id"":5001,""author_id"":5002,""royalty_percentage"":50.0}]"; + + await MultipleCreateMutationWithManyToManyRelationshipDefinedInConfigFile(expectedResponse, linkingTableDbValidationQuery, expectedResponseFromLinkingTable); + } + + [TestMethod] + public async Task MultipleCreateMutationWithAllRelationshipTypesDefinedInConfigFile() + { + string expectedResponse = @"{ + ""id"": 5001, + ""title"": ""Book #1"", + ""publishers"": { + ""id"": 5001, + ""name"": ""Publisher #1"" + }, + ""reviews"": { + ""items"": [ + { + ""book_id"": 5001, + ""id"": 5001, + ""content"": ""Book #1 - Review #1"", + ""website_users"": { + ""id"": 5001, + ""username"": ""WebsiteUser #1"" + } + }, + { + ""book_id"": 5001, + ""id"": 5002, + ""content"": ""Book #1 - Review #2"", + ""website_users"": { + ""id"": 1, + ""username"": ""George"" + } + } + ] + }, + ""authors"": { + ""items"": [ + { + ""id"": 5001, + ""name"": ""Author #1"", + ""birthdate"": ""2000-02-01"" + }, + { + ""id"": 5002, + ""name"": ""Author #2"", + ""birthdate"": ""2000-01-02"" + } + ] + } + }"; + + string linkingTableDbValidationQuery = @"SELECT [book_id], [author_id], [royalty_percentage] + FROM [dbo].[book_author_link_mm] + WHERE [dbo].[book_author_link_mm].[book_id] = 5001 AND ([dbo].[book_author_link_mm].[author_id] = 5001 OR [dbo].[book_author_link_mm].[author_id] = 5002) + ORDER BY [dbo].[book_author_link_mm].[book_id], [dbo].[book_author_link_mm].[author_id] ASC FOR JSON PATH, INCLUDE_NULL_VALUES;"; + + string expectedResponseFromLinkingTable = @"[{""book_id"":5001,""author_id"":5001,""royalty_percentage"":50.0},{""book_id"":5001,""author_id"":5002,""royalty_percentage"":50.0}]"; + + await MultipleCreateMutationWithAllRelationshipTypesDefinedInConfigFile(expectedResponse, linkingTableDbValidationQuery, expectedResponseFromLinkingTable); + } + + [TestMethod] + public async Task ManyTypeMultipleCreateMutationOperationRelationshipsDefinedInConfig() + { + string expectedResponse = @"{ + ""items"": [ + { + ""id"": 5001, + ""title"": ""Book #1"", + ""publisher_id"": 5001, + ""publishers"": { + ""id"": 5001, + ""name"": ""Publisher #1"" + }, + ""reviews"": { + ""items"": [ + { + ""book_id"": 5001, + ""id"": 5001, + ""content"": ""Book #1 - Review #1"", + ""website_users"": { + ""id"": 5001, + ""username"": ""Website user #1"" + } + }, + { + ""book_id"": 5001, + ""id"": 5002, + ""content"": ""Book #1 - Review #2"", + ""website_users"": { + ""id"": 4, + ""username"": ""book_lover_95"" + } + } + ] + }, + ""authors"": { + ""items"": [ + { + ""id"": 5001, + ""name"": ""Author #1"", + ""birthdate"": ""2000-01-02"" + }, + { + ""id"": 5002, + ""name"": ""Author #2"", + ""birthdate"": ""2001-02-03"" + } + ] + } + }, + { + ""id"": 5002, + ""title"": ""Book #2"", + ""publisher_id"": 1234, + ""publishers"": { + ""id"": 1234, + ""name"": ""Big Company"" + }, + ""reviews"": { + ""items"": [] + }, + ""authors"": { + ""items"": [ + { + ""id"": 5003, + ""name"": ""Author #3"", + ""birthdate"": ""2000-01-02"" + }, + { + ""id"": 5004, + ""name"": ""Author #4"", + ""birthdate"": ""2001-02-03"" + } + ] + } + } + ] + }"; + + string linkingTableDbValidationQuery = @"SELECT [book_id], [author_id], [royalty_percentage] FROM [dbo].[book_author_link_mm] + WHERE ( [dbo].[book_author_link_mm].[book_id] = 5001 AND ([dbo].[book_author_link_mm].[author_id] = 5001 OR [dbo].[book_author_link_mm].[author_id] = 5002)) + OR ([dbo].[book_author_link_mm].[book_id] = 5002 AND ([dbo].[book_author_link_mm].[author_id] = 5003 OR [dbo].[book_author_link_mm].[author_id] = 5004)) + ORDER BY [dbo].[book_author_link_mm].[book_id] ASC FOR JSON PATH, INCLUDE_NULL_VALUES;"; + + string expectedResponseFromLinkingTable = @"[{""book_id"":5001,""author_id"":5001,""royalty_percentage"":50.0},{""book_id"":5001,""author_id"":5002,""royalty_percentage"":50.0},{""book_id"":5002,""author_id"":5003,""royalty_percentage"":65.0},{""book_id"":5002,""author_id"":5004,""royalty_percentage"":35.0}]"; + + await ManyTypeMultipleCreateMutationOperationRelationshipsDefinedInConfig(expectedResponse, linkingTableDbValidationQuery, expectedResponseFromLinkingTable); + } + } +} diff --git a/src/Service.Tests/SqlTests/GraphQLMutationTests/MultipleCreateMutationTests/MultipleCreateMutationTestBase.cs b/src/Service.Tests/SqlTests/GraphQLMutationTests/MultipleCreateMutationTests/MultipleCreateMutationTestBase.cs new file mode 100644 index 0000000000..2131bf3f17 --- /dev/null +++ b/src/Service.Tests/SqlTests/GraphQLMutationTests/MultipleCreateMutationTests/MultipleCreateMutationTestBase.cs @@ -0,0 +1,921 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +using System.Text.Json; +using System.Threading.Tasks; +using Azure.DataApiBuilder.Service.Exceptions; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Azure.DataApiBuilder.Service.Tests.SqlTests.GraphQLMutationTests.MultipleCreateMutationTests +{ + /// + /// Base class for GraphQL Multiple Create Mutation tests. + /// + [TestClass] + public abstract class MultipleCreateMutationTestBase : SqlTestBase + { + + #region Relationships defined through database metadata + + /// + /// Do: Point create mutation with entities related through a N:1 relationship. + /// Relationship is defined in the database layer using FK constraints. + /// Check: Publisher item is successfully created first in the database. + /// Then, Book item is created where book.publisher_id is populated with the previously created + /// Book record's id. + /// + public async Task MultipleCreateMutationWithManyToOneRelationship(string dbQuery) + { + string graphQLMutationName = "createbook"; + string graphQLMutation = @" + mutation { + createbook(item: { title: ""Book #1"", publishers: { name: ""Publisher #1"" } }) { + id + title + publisher_id + publishers{ + id + name + } + } + } + "; + + JsonElement actual = await ExecuteGraphQLRequestAsync(graphQLMutation, graphQLMutationName, isAuthenticated: true); + string expected = await GetDatabaseResultAsync(dbQuery); + + SqlTestHelper.PerformTestEqualJsonStrings(expected, actual.ToString()); + } + + /// + /// Do: Point create mutation with entities related through a 1:N relationship. + /// Relationship is defined in the database layer using FK constraints. + /// Check: Book item is successfully created first in the database. + /// Then, Review items are created where review.book_id is populated with the previously + /// created Book record's id. + /// + public async Task MultipleCreateMutationWithOneToManyRelationship(string expectedResponse) + { + string graphQLMutationName = "createbook"; + string graphQLMutation = @" + mutation { + createbook( + item: { + title: ""Book #1"" + publisher_id: 1234 + reviews: [ + { content: ""Book #1 - Review #1"" } + { content: ""Book #1 - Review #2"" } + ] + } + ) { + id + title + publisher_id + reviews { + items { + book_id + id + content + } + } + } + } + "; + + JsonElement actual = await ExecuteGraphQLRequestAsync(graphQLMutation, graphQLMutationName, isAuthenticated: true); + SqlTestHelper.PerformTestEqualJsonStrings(expectedResponse, actual.ToString()); + } + + /// + /// Do: Point create mutation with entities related through a M:N relationship. + /// Relationship is defined in the database layer using FK constraints. + /// Check: Book item is successfully created in the database. + /// Author items are successfully created in the database. + /// Then, the newly created Book and Author ID fields are inserted into the linking table. + /// Linking table contents are verified with follow-up database query looking for + /// (book.id, author.id) record. + /// + public async Task MultipleCreateMutationWithManyToManyRelationship(string expectedResponse, string linkingTableDbValidationQuery, string expectedResponseFromLinkingTable) + { + string graphQLMutationName = "createbook"; + string graphQLMutation = @" + mutation { + createbook( + item: { + title: ""Book #1"" + publisher_id: 1234 + authors: [ + { birthdate: ""2000-01-01"", name: ""Author #1"", royalty_percentage: 50.0 } + { birthdate: ""2000-02-03"", name: ""Author #2"", royalty_percentage: 50.0 } + ] + } + ) { + id + title + publisher_id + authors { + items { + id + name + birthdate + } + } + } + }"; + + JsonElement actual = await ExecuteGraphQLRequestAsync(graphQLMutation, graphQLMutationName, isAuthenticated: true); + SqlTestHelper.PerformTestEqualJsonStrings(expectedResponse, actual.ToString()); + + // Book - Author entities are related through a M:N relationship. + // After successful creation of Book and Author items, a record will be created in the linking table + // with the newly created Book and Author record's id. + // The following database query validates that two records exist in the linking table book_author_link + // with (book_id, author_id) : (5001, 5001) and (5001, 5002) + // These two records are also validated to ensure that they are created with the right + // value in royalty_percentage column. + string actualResponseFromLinkingTable = await GetDatabaseResultAsync(linkingTableDbValidationQuery); + SqlTestHelper.PerformTestEqualJsonStrings(expectedResponseFromLinkingTable, actualResponseFromLinkingTable); + } + + /// + /// Do: Point create mutation with entities related through a 1:1 relationship. + /// The goal with this mutation request is to create a Stock item, Stocks_Price item + /// and link the Stocks_Price item with the Stock item. Since, the idea is to link the Stocks_Price + /// item with the Stock item that is being created in the same mutation request, the + /// mutation input for stocks_price will not contain the fields categoryid and pieceid. + /// Check: Stock item is successfully created first in the database. + /// Then, the Stocks_Price item is created where stocks_price.categoryid and stocks_price.pieceid + /// are populated with the previously created Stock record's categoryid and pieceid. + /// + public async Task MultipleCreateMutationWithOneToOneRelationship(string expectedResponse) + { + string graphQLMutationName = "createStock"; + string graphQLMutation = @" + mutation { + createStock( + item: { + categoryid: 101 + pieceid: 101 + categoryName: ""SciFi"" + piecesAvailable: 100 + piecesRequired: 50 + stocks_price: { + is_wholesale_price: true, + price: 75.00, + instant: ""2024-04-02"" + } + } + ) { + categoryid + pieceid + categoryName + piecesAvailable + piecesRequired + stocks_price { + categoryid + pieceid + instant + price + is_wholesale_price + } + } + }"; + + JsonElement actual = await ExecuteGraphQLRequestAsync(graphQLMutation, graphQLMutationName, isAuthenticated: true); + SqlTestHelper.PerformTestEqualJsonStrings(expectedResponse, actual.ToString()); + } + + /// + /// Do: Point multiple create mutation with entities related through + /// 1:1, N:1, 1:N and M:N relationships, all in a single mutation request. + /// Relationships involved in the create mutation request are both + /// defined at the database layer and through the config file. + /// 1. a) 1:1 relationship between Review - WebsiteUser entity is defined through the config file. + /// b) Other relationships are defined through FK constraints + /// 2. Depth of this create mutation request = 2. Book --> Review --> WebsiteUser. + /// Check: Records are successfully created in all the related entities. + /// The created items are related as intended in the mutation request. + /// The right order of insertion is as follows: + /// 1. Publisher item is successfully created in the database. + /// 2. Book item is created with books.publisher_id populated with the Publisher record's id. + /// 3. WebsiteUser item is successfully created in the database. + /// 4. The first Review item is created with reviews.website_userid + /// populated with the WebsiteUser record's id. + /// 5. Second Review item is created. reviews.website_userid is populated with + /// the value present in the input request. + /// 6. Author item is successfully created in the database. + /// 7. A record in the linking table is created with the newly created Book and Author record's id. + /// + public async Task MultipleCreateMutationWithAllRelationshipTypes(string expectedResponse, string linkingTableDbValidationQuery, string expectedResponseFromLinkingTable) + { + string graphQLMutationName = "createbook"; + string graphQLMutation = @"mutation { + createbook( + item: { + title: ""Book #1"" + publishers: { name: ""Publisher #1"" } + reviews: [ + { + content: ""Book #1 - Review #1"" + website_users: { id: 5001, username: ""WebsiteUser #1"" } + } + { content: ""Book #1 - Review #2"", websiteuser_id: 1 } + ] + authors: [ + { birthdate: ""2000-02-01"", name: ""Author #1"", royalty_percentage: 50.0 } + { birthdate: ""2000-01-02"", name: ""Author #2"", royalty_percentage: 50.0 } + ] + } + ) { + id + title + publishers { + id + name + } + reviews { + items { + book_id + id + content + website_users { + id + username + } + } + } + authors { + items { + id + name + birthdate + } + } + } + }"; + + JsonElement actual = await ExecuteGraphQLRequestAsync(graphQLMutation, graphQLMutationName, isAuthenticated: true); + SqlTestHelper.PerformTestEqualJsonStrings(expectedResponse, actual.ToString()); + + // Book - Author entities are related through a M:N relationship. + // After successful creation of Book and Author items, a record will be created in the linking table + // with the newly created Book and Author record's id. + // The following database query validates that two records exist in the linking table book_author_link + // with (book_id, author_id) : (5001, 5001) and (5001, 5002) + // These two records are also validated to ensure that they are created with the right + // value in royalty_percentage column. + string actualResponseFromLinkingTable = await GetDatabaseResultAsync(linkingTableDbValidationQuery); + SqlTestHelper.PerformTestEqualJsonStrings(expectedResponseFromLinkingTable, actualResponseFromLinkingTable); + } + + /// + /// Do : Many type multiple create mutation request with entities related through + /// 1:1, N:1, 1:N and M:N relationships, all in a single mutation request.This also a + /// combination relationships defined at the database layer and through the config file. + /// 1. a) 1:1 relationship between Review - WebsiteUser entity is defined through the config file. + /// b) Other relationships are defined through FK constraints. + /// 2. Depth of this create mutation request = 2. Book --> Review --> WebsiteUser. + /// Check : Records are successfully created in all the related entities. + /// The created items are related as intended in the mutation request. + /// Correct linking of the newly created items are validated by querying all the relationship fields + /// in the selection set and validating it against the expected response. + /// + public async Task ManyTypeMultipleCreateMutationOperation(string expectedResponse, string linkingTableDbValidationQuery, string expectedResponseFromLinkingTable) + { + string graphQLMutationName = "createbooks"; + string graphQLMutation = @"mutation { + createbooks( + items: [ + { + title: ""Book #1"" + publishers: { name: ""Publisher #1"" } + reviews: [ + { + content: ""Book #1 - Review #1"" + website_users: { id: 5001, username: ""Website user #1"" } + } + { content: ""Book #1 - Review #2"", websiteuser_id: 4 } + ] + authors: [ + { + name: ""Author #1"" + birthdate: ""2000-01-02"" + royalty_percentage: 50.0 + } + { + name: ""Author #2"" + birthdate: ""2001-02-03"" + royalty_percentage: 50.0 + } + ] + } + { + title: ""Book #2"" + publisher_id: 1234 + authors: [ + { + name: ""Author #3"" + birthdate: ""2000-01-02"" + royalty_percentage: 65.0 + } + { + name: ""Author #4"" + birthdate: ""2001-02-03"" + royalty_percentage: 35.0 + } + ] + } + ] + ) { + items { + id + title + publisher_id + publishers { + id + name + } + reviews { + items { + book_id + id + content + website_users { + id + username + } + } + } + authors { + items { + id + name + birthdate + } + } + } + } + } + "; + + JsonElement actual = await ExecuteGraphQLRequestAsync(graphQLMutation, graphQLMutationName, isAuthenticated: true); + SqlTestHelper.PerformTestEqualJsonStrings(expectedResponse, actual.ToString()); + + // Validate that the records are created in the linking table + string actualResponseFromLinkingTable = await GetDatabaseResultAsync(linkingTableDbValidationQuery); + SqlTestHelper.PerformTestEqualJsonStrings(expectedResponseFromLinkingTable, actualResponseFromLinkingTable); + } + + #endregion + + #region Relationships defined through config file + + /// + /// Do: Point create mutation with entities related through a 1:1 relationship + /// through User_NonAutogenRelationshipColumn.username and + /// UserProfile_NonAutogenRelationshipColumn.username fields + /// Relationship is defined through the config file. + /// Check: User_NonAutogenRelationshipColumn and UserProfile_NonAutogenRelationshipColumn items are + /// successfully created in the database. UserProfile_NonAutogenRelationshipColumn item is created + /// and linked in the database. + /// + public async Task MultipleCreateMutationWithOneToOneRelationshipDefinedInConfigFile(string expectedResponse1, string expectedResponse2) + { + // Point create mutation request with the related entity(UserProfile_NonAutogenRelationshipColumn) + // acting as referencing entity. + // First, User_NonAutogenRelationshipColumn item is created in the database. + // Then, the UserProfile_NonAutogenRelationshipColumn item is created in the database + // with username populated using User_NonAutogenRelationshipColumn.username field's value. + string graphQLMutationName = "createUser_NonAutogenRelationshipColumn"; + string graphQLMutation1 = @"mutation { + createUser_NonAutogenRelationshipColumn( + item: { + username: ""DAB"" + email: ""dab@microsoft.com"" + UserProfile_NonAutogenRelationshipColumn: { + profilepictureurl: ""dab/profilepicture"" + userid: 10 + } + } + ) { + userid + username + email + UserProfile_NonAutogenRelationshipColumn { + profileid + userid + username + profilepictureurl + } + } + }"; + + JsonElement actualResponse1 = await ExecuteGraphQLRequestAsync(graphQLMutation1, graphQLMutationName, isAuthenticated: true); + SqlTestHelper.PerformTestEqualJsonStrings(expectedResponse1, actualResponse1.ToString()); + + // Point create mutation request with the top level entity(User_NonAutogenRelationshipColumn) + // acting as referencing entity. + // First, UserProfile_NonAutogenRelationshipColumn item is created in the database. + // Then, the User_NonAutogenRelationshipColumn item is created in the database + // with username populated using UserProfile_NonAutogenRelationshipColumn.username field's value. + string graphQLMutation2 = @"mutation{ + createUser_NonAutogenRelationshipColumn(item: { + email: ""dab@microsoft.com"", + UserProfile_NonAutogenRelationshipColumn: { + profilepictureurl: ""dab/profilepicture"", + userid: 10, + username: ""DAB2"" + } + }){ + userid + username + email + UserProfile_NonAutogenRelationshipColumn{ + profileid + username + userid + profilepictureurl + } + } + }"; + + JsonElement actualResponse2 = await ExecuteGraphQLRequestAsync(graphQLMutation2, graphQLMutationName, isAuthenticated: true); + SqlTestHelper.PerformTestEqualJsonStrings(expectedResponse2, actualResponse2.ToString()); + } + + /// + /// Do: Point create mutation with entities related through a N:1 relationship. + /// Relationship is defined through the config file. + /// Check: Publisher_MM item is successfully created first in the database. + /// Then, Book_MM item is created where book_mm.publisher_id is populated with the previously created + /// Book_MM record's id. + /// + public async Task MultipleCreateMutationWithManyToOneRelationshipDefinedInConfigFile(string expectedResponse) + { + string graphQLMutationName = "createbook_mm"; + string graphQLMutation = @"mutation { + createbook_mm( + item: { title: ""Book #1"", publishers: { name: ""Publisher #1"" } }) { + id + title + publisher_id + publishers { + id + name + } + } + }"; + + JsonElement actual = await ExecuteGraphQLRequestAsync(graphQLMutation, graphQLMutationName, isAuthenticated: true); + SqlTestHelper.PerformTestEqualJsonStrings(expectedResponse, actual.ToString()); + } + + /// + /// Do: Point create mutation with entities related through a 1:N relationship. + /// Relationship is defined through the config file. + /// Check: Book_MM item is successfully created first in the database. + /// Then, Review_MM items are created where review_mm.book_id is populated with the previously + /// created Book_MM record's id. + /// + public async Task MultipleCreateMutationWithOneToManyRelationshipDefinedInConfigFile(string expectedResponse) + { + string graphQLMutationName = "createbook_mm"; + string graphQLMutation = @" + mutation { + createbook_mm( + item: { + title: ""Book #1"" + publisher_id: 1234 + reviews: [ + { content: ""Book #1 - Review #1"" } + { content: ""Book #1 - Review #2"" } + ] + } + ) { + id + title + publisher_id + reviews { + items { + book_id + id + content + } + } + } + } + "; + + JsonElement actual = await ExecuteGraphQLRequestAsync(graphQLMutation, graphQLMutationName, isAuthenticated: true); + SqlTestHelper.PerformTestEqualJsonStrings(expectedResponse, actual.ToString()); + } + + /// + /// Do: Point create mutation with entities related through a M:N relationship. + /// Relationship is defined through the config file. + /// Check: Book_MM item is successfully created in the database. + /// Author_MM items are successfully created in the database. + /// Then, the newly created Book_MM and Author_MM ID fields are inserted + /// into the linking table book_author_link_mm. + /// Linking table contents are verified with follow-up database query looking for + /// (book.id, author.id) record. + /// + public async Task MultipleCreateMutationWithManyToManyRelationshipDefinedInConfigFile(string expectedResponse, string linkingTableDbValidationQuery, string expectedResponseFromLinkingTable) + { + string graphQLMutationName = "createbook_mm"; + string graphQLMutation = @" + mutation { + createbook_mm( + item: { + title: ""Book #1"" + publisher_id: 1234 + authors: [ + { birthdate: ""2000-01-01"", name: ""Author #1"", royalty_percentage: 50.0 } + { birthdate: ""2000-02-03"", name: ""Author #2"", royalty_percentage: 50.0 } + ] + } + ) { + id + title + publisher_id + authors { + items { + id + name + birthdate + } + } + } + }"; + + JsonElement actual = await ExecuteGraphQLRequestAsync(graphQLMutation, graphQLMutationName, isAuthenticated: true); + SqlTestHelper.PerformTestEqualJsonStrings(expectedResponse, actual.ToString()); + + // After successful creation of Book_MM and Author_MM items, a record will be created in the linking table + // with the newly created Book and Author record's id. + // The following database query validates that two records exist in the linking table book_author_link + // with (book_id, author_id) : (5001, 5001) and (5001, 5002) + // These two records are also validated to ensure that they are created with the right + // value in royalty_percentage column. + string actualResponseFromLinkingTable = await GetDatabaseResultAsync(linkingTableDbValidationQuery); + SqlTestHelper.PerformTestEqualJsonStrings(expectedResponseFromLinkingTable, actualResponseFromLinkingTable); + } + + /// + /// Do: Point multiple create mutation with entities related + /// through 1:1, N:1, 1:N and M:N relationships, all in a single mutation request. + /// All the relationships are defined through the config file. + /// Also, the depth of this create mutation request = 2. Book_MM --> Review_MM --> WebsiteUser_MM. + /// Check: Records are successfully created in all the related entities. + /// The created items are related as intended in the mutation request. + /// The right order of insertion is as follows: + /// 1. Publisher_MM item is successfully created in the database. + /// 2. Book_MM item is created with books_mm.publisher_id populated with the Publisher_MM record's id. + /// 3. WebsiteUser_MM item is successfully created in the database. + /// 4. The first Review_MM item is created with reviews_mm.website_userid + /// populated with the WebsiteUser_MM record's id. + /// 5. Second Review_MM item is created. reviews_mm.website_userid is populated with + /// the value present in the input request. + /// 6. Author_MM item is successfully created in the database. + /// 7. A record in the linking table is created with the newly created Book_MM and Author_MM record's id. + /// + public async Task MultipleCreateMutationWithAllRelationshipTypesDefinedInConfigFile(string expectedResponse, string linkingTableDbValidationQuery, string expectedResponseFromLinkingTable) + { + string graphQLMutationName = "createbook_mm"; + string graphQLMutation = @"mutation { + createbook_mm( + item: { + title: ""Book #1"" + publishers: { name: ""Publisher #1"" } + reviews: [ + { + content: ""Book #1 - Review #1"" + website_users: { id: 5001, username: ""WebsiteUser #1"" } + } + { content: ""Book #1 - Review #2"", websiteuser_id: 1 } + ] + authors: [ + { birthdate: ""2000-02-01"", name: ""Author #1"", royalty_percentage: 50.0 } + { birthdate: ""2000-01-02"", name: ""Author #2"", royalty_percentage: 50.0 } + ] + } + ) { + id + title + publishers { + id + name + } + reviews { + items { + book_id + id + content + website_users { + id + username + } + } + } + authors { + items { + id + name + birthdate + } + } + } + }"; + + JsonElement actual = await ExecuteGraphQLRequestAsync(graphQLMutation, graphQLMutationName, isAuthenticated: true); + SqlTestHelper.PerformTestEqualJsonStrings(expectedResponse, actual.ToString()); + + // Book_MM - Author_MM entities are related through a M:N relationship. + // After successful creation of Book_MM and Author_MM items, a record will be created in the linking table + // with the newly created Book_MM and Author_MM record's id. + // The following database query validates that two records exist in the linking table book_author_link_mm + // with (book_id, author_id) : (5001, 5001) and (5001, 5002) + // These two records are also validated to ensure that they are created with the right + // value in royalty_percentage column. + string actualResponseFromLinkingTable = await GetDatabaseResultAsync(linkingTableDbValidationQuery); + SqlTestHelper.PerformTestEqualJsonStrings(expectedResponseFromLinkingTable, actualResponseFromLinkingTable); + } + + /// + /// Do : Many type multiple create mutation request with entities related through + /// 1:1, N:1, 1:N and M:N relationships, all in a single mutation request. + /// All the relationships are defined through the config file. + /// Also, depth of this create mutation request = 2. Book_MM --> Review_MM --> WebsiteUser_MM. + /// Check : Records are successfully created in all the related entities. The created items are related as intended in the mutation request. + /// Correct linking of the newly created items are validated by querying all the relationship fields in the selection set and validating it against the expected response. + /// + public async Task ManyTypeMultipleCreateMutationOperationRelationshipsDefinedInConfig(string expectedResponse, string linkingTableDbValidationQuery, string expectedResponseFromLinkingTable) + { + string graphQLMutationName = "createbooks_mm"; + string graphQLMutation = @"mutation { + createbooks_mm( + items: [ + { + title: ""Book #1"" + publishers: { name: ""Publisher #1"" } + reviews: [ + { + content: ""Book #1 - Review #1"" + website_users: { id: 5001, username: ""Website user #1"" } + } + { content: ""Book #1 - Review #2"", websiteuser_id: 4 } + ] + authors: [ + { + name: ""Author #1"" + birthdate: ""2000-01-02"" + royalty_percentage: 50.0 + } + { + name: ""Author #2"" + birthdate: ""2001-02-03"" + royalty_percentage: 50.0 + } + ] + } + { + title: ""Book #2"" + publisher_id: 1234 + authors: [ + { + name: ""Author #3"" + birthdate: ""2000-01-02"" + royalty_percentage: 65.0 + } + { + name: ""Author #4"" + birthdate: ""2001-02-03"" + royalty_percentage: 35.0 + } + ] + } + ] + ) { + items { + id + title + publisher_id + publishers { + id + name + } + reviews { + items { + book_id + id + content + website_users { + id + username + } + } + } + authors { + items { + id + name + birthdate + } + } + } + } + } + "; + + JsonElement actual = await ExecuteGraphQLRequestAsync(graphQLMutation, graphQLMutationName, isAuthenticated: true); + SqlTestHelper.PerformTestEqualJsonStrings(expectedResponse, actual.ToString()); + + // Validate that the records are created in the linking table + string actualResponseFromLinkingTable = await GetDatabaseResultAsync(linkingTableDbValidationQuery); + SqlTestHelper.PerformTestEqualJsonStrings(expectedResponseFromLinkingTable, actualResponseFromLinkingTable); + } + + #endregion + + #region Policy tests + + /// + /// Point multiple create mutation request is executed with the role: role_multiple_create_policy_tester. + /// This role has the following create policy defined on "Book" entity: "@item.title ne 'Test'". + /// Since this mutation tries to create a book with title "Test", it is expected + /// to fail with a database policy violation error. + /// The error message and status code are validated for accuracy. + /// + public async Task PointMultipleCreateFailsDueToCreatePolicyViolationAtTopLevelEntity(string expectedErrorMessage, string bookDbQuery, string publisherDbQuery) + { + string graphQLMutationName = "createbook"; + string graphQLMutation = @"mutation{ + createbook(item:{ + title: ""Test"", + publishers:{ + name: ""Publisher #1"" + } + }){ + id + title + publishers{ + id + name + } + } + }"; + + JsonElement actual = await ExecuteGraphQLRequestAsync(graphQLMutation, graphQLMutationName, isAuthenticated: true, clientRoleHeader: "role_multiple_create_policy_tester"); + + SqlTestHelper.TestForErrorInGraphQLResponse( + response: actual.ToString(), + message: expectedErrorMessage, + statusCode: $"{DataApiBuilderException.SubStatusCodes.DatabasePolicyFailure}" + ); + + // Validate that no book item is created + string dbResponse = await GetDatabaseResultAsync(bookDbQuery); + Assert.AreEqual("[]", dbResponse); + + // Validate that no publisher item is created + dbResponse = await GetDatabaseResultAsync(publisherDbQuery); + Assert.AreEqual("[]", dbResponse); + } + + /// + /// Point multiple create mutation request is executed with the role: role_multiple_create_policy_tester. + /// This role has the following create policy defined on "Publisher" entity: "@item.name ne 'Test'" + /// Since, this mutation tries to create a publisher with title "Test" (along with creating a book item), + /// it is expected to fail with a database policy violation error. + /// As a result of this mutation, no Book and Publisher items should be created. + /// The error message and status code are validated for accuracy. + /// Also, the database is queried to ensure that no new record got created. + /// + public async Task PointMultipleCreateFailsDueToCreatePolicyViolationAtRelatedEntity(string expectedErrorMessage, string bookDbQuery, string publisherDbQuery) + { + string graphQLMutationName = "createbook"; + string graphQLMutation = @"mutation{ + createbook(item:{ + title: ""Book #1"", + publishers:{ + name: ""Test"" + } + }){ + id + title + publishers{ + id + name + } + } + }"; + + JsonElement actual = await ExecuteGraphQLRequestAsync(graphQLMutation, graphQLMutationName, isAuthenticated: true, clientRoleHeader: "role_multiple_create_policy_tester"); + + SqlTestHelper.TestForErrorInGraphQLResponse( + response: actual.ToString(), + message: expectedErrorMessage, + statusCode: $"{DataApiBuilderException.SubStatusCodes.DatabasePolicyFailure}"); + + // Validate that no book item is created + string dbResponse = await GetDatabaseResultAsync(bookDbQuery); + Assert.AreEqual("[]", dbResponse); + + // Validate that no publisher item is created + dbResponse = await GetDatabaseResultAsync(publisherDbQuery); + Assert.AreEqual("[]", dbResponse); + } + + /// + /// Many type multiple create mutation request is executed with the role: role_multiple_create_policy_tester. + /// This role has the following create policy defined on "Book" entity: "@item.title ne 'Test'" + /// In this request, the second Book item in the input violates the create policy defined. + /// Processing of that input item is expected to result in database policy violation error. + /// All the items created successfully prior to this faulty input will also be rolled back. + /// So, the end result is that no new items should be created. + /// + public async Task ManyTypeMultipleCreateFailsDueToCreatePolicyFailure(string expectedErrorMessage, string bookDbQuery, string publisherDbQuery) + { + string graphQLMutationName = "createbooks"; + string graphQLMutation = @"mutation { + createbooks( + items: [ + { title: ""Book #1"", publisher_id: 2345 } + { title: ""Test"", publisher_id: 2345 } + ] + ) { + items { + id + title + publishers { + id + name + } + } + } + }"; + + JsonElement actual = await ExecuteGraphQLRequestAsync(graphQLMutation, graphQLMutationName, isAuthenticated: true, clientRoleHeader: "role_multiple_create_policy_tester"); + + SqlTestHelper.TestForErrorInGraphQLResponse( + response: actual.ToString(), + message: expectedErrorMessage, + statusCode: $"{DataApiBuilderException.SubStatusCodes.DatabasePolicyFailure}"); + + // Validate that no book item is created + string dbResponse = await GetDatabaseResultAsync(bookDbQuery); + Assert.AreEqual("[]", dbResponse); + + // Validate that no publisher item is created + dbResponse = await GetDatabaseResultAsync(publisherDbQuery); + Assert.AreEqual("[]", dbResponse); + } + + /// + /// This test validates that read policies are honored when constructing the response. + /// Point multiple create mutation request is executed with the role: role_multiple_create_policy_tester. + /// This role has the following read policy defined on "Reviews" entity: "@item.websiteuser_id ne 1". + /// The second Review item in the input violates the read policy defined. + /// Hence, it is not expected to be returned in the response. + /// The returned response is validated against an expected response for correctness. + /// + public async Task PointMultipleCreateMutationWithReadPolicyViolationAtRelatedEntity(string expectedResponse) + { + string graphQLMutationName = "createbook"; + string graphQLMutation = @"mutation { + createbook( + item: { + title: ""Book #1"" + publisher_id: 2345 + reviews: [ + { + content: ""Review #1"", + websiteuser_id: 4 + } + { content: ""Review #2"", + websiteuser_id: 1 + } + ] + } + ) { + id + title + publisher_id + reviews { + items { + book_id + id + content + websiteuser_id + } + } + } + }"; + + JsonElement actual = await ExecuteGraphQLRequestAsync(graphQLMutation, graphQLMutationName, isAuthenticated: true, clientRoleHeader: "role_multiple_create_policy_tester"); + SqlTestHelper.PerformTestEqualJsonStrings(expectedResponse, actual.ToString()); + } + + #endregion + } +} diff --git a/src/Service.Tests/SqlTests/RestApiTests/Insert/MsSqlInsertApiTests.cs b/src/Service.Tests/SqlTests/RestApiTests/Insert/MsSqlInsertApiTests.cs index dc3135daf4..953575c86a 100644 --- a/src/Service.Tests/SqlTests/RestApiTests/Insert/MsSqlInsertApiTests.cs +++ b/src/Service.Tests/SqlTests/RestApiTests/Insert/MsSqlInsertApiTests.cs @@ -92,13 +92,13 @@ public class MsSqlInsertApiTests : InsertApiTestBase // This query is the query for the result we get back from the database // after the insert operation. Not the query that we generate to perform // the insertion. - $"SELECT [id], [content], [book_id] FROM { _tableWithCompositePrimaryKey } " + + $"SELECT [id], [content], [book_id], [websiteuser_id] FROM { _tableWithCompositePrimaryKey } " + $"WHERE [id] = { STARTING_ID_FOR_TEST_INSERTS } AND [book_id] = 1 " + $"FOR JSON PATH, INCLUDE_NULL_VALUES, WITHOUT_ARRAY_WRAPPER" }, { "InsertOneInDefaultTestTable", - $"SELECT [id], [book_id], [content] FROM { _tableWithCompositePrimaryKey } " + + $"SELECT [id], [book_id], [content], [websiteuser_id] FROM { _tableWithCompositePrimaryKey } " + $"WHERE [id] = { STARTING_ID_FOR_TEST_INSERTS + 1} AND [book_id] = 2 AND [content] = 'Its a classic' " + $"FOR JSON PATH, INCLUDE_NULL_VALUES, WITHOUT_ARRAY_WRAPPER" }, diff --git a/src/Service.Tests/SqlTests/RestApiTests/Patch/MsSqlPatchApiTests.cs b/src/Service.Tests/SqlTests/RestApiTests/Patch/MsSqlPatchApiTests.cs index 0453955675..eeb97badc9 100644 --- a/src/Service.Tests/SqlTests/RestApiTests/Patch/MsSqlPatchApiTests.cs +++ b/src/Service.Tests/SqlTests/RestApiTests/Patch/MsSqlPatchApiTests.cs @@ -110,7 +110,7 @@ public class MsSqlPatchApiTests : PatchApiTestBase }, { "PatchOne_Update_Default_Test", - $"SELECT [id], [book_id], [content] FROM { _tableWithCompositePrimaryKey } " + + $"SELECT [id], [book_id], [content], [websiteuser_id] FROM { _tableWithCompositePrimaryKey } " + $"WHERE id = 567 AND [book_id] = 1 AND [content] = 'That''s a great book' " + $"FOR JSON PATH, INCLUDE_NULL_VALUES, WITHOUT_ARRAY_WRAPPER" }, diff --git a/src/Service.Tests/SqlTests/RestApiTests/Put/MsSqlPutApiTests.cs b/src/Service.Tests/SqlTests/RestApiTests/Put/MsSqlPutApiTests.cs index ddd65bab49..5b2745e203 100644 --- a/src/Service.Tests/SqlTests/RestApiTests/Put/MsSqlPutApiTests.cs +++ b/src/Service.Tests/SqlTests/RestApiTests/Put/MsSqlPutApiTests.cs @@ -49,7 +49,7 @@ public class MsSqlPutApiTests : PutApiTestBase }, { "PutOne_Update_Default_Test", - $"SELECT [id], [book_id], [content] FROM { _tableWithCompositePrimaryKey } " + + $"SELECT [id], [book_id], [content], [websiteuser_id] FROM { _tableWithCompositePrimaryKey } " + $"WHERE [id] = 568 AND [book_id] = 1 AND [content]='Good book to read' " + $"FOR JSON PATH, INCLUDE_NULL_VALUES, WITHOUT_ARRAY_WRAPPER" }, diff --git a/src/Service.Tests/dab-config.MsSql.json b/src/Service.Tests/dab-config.MsSql.json index 88c581d10e..aa80ef6f14 100644 --- a/src/Service.Tests/dab-config.MsSql.json +++ b/src/Service.Tests/dab-config.MsSql.json @@ -260,6 +260,26 @@ } } ] + }, + { + "role": "role_multiple_create_policy_tester", + "actions": [ + { + "action": "read" + }, + { + "action": "update" + }, + { + "action": "delete" + }, + { + "action": "create", + "policy": { + "database": "@item.name ne 'Test'" + } + } + ] } ], "relationships": { @@ -273,6 +293,54 @@ } } }, + "Publisher_MM": { + "source": { + "object": "publishers_mm", + "type": "table" + }, + "graphql": { + "enabled": true, + "type": { + "singular": "Publisher_MM", + "plural": "Publishers_MM" + } + }, + "rest": { + "enabled": true + }, + "permissions": [ + { + "role": "anonymous", + "actions": [ + { + "action": "*" + } + ] + }, + { + "role": "authenticated", + "actions": [ + { + "action": "*" + } + ] + } + ], + "relationships": { + "books_mm": { + "cardinality": "many", + "target.entity": "Book_MM", + "source.fields": [ + "id" + ], + "target.fields": [ + "publisher_id" + ], + "linking.source.fields": [], + "linking.target.fields": [] + } + } + }, "Stock": { "source": { "object": "stocks", @@ -418,28 +486,6 @@ } ] }, - { - "role": "test_role_with_excluded_fields_on_create", - "actions": [ - { - "action": "create", - "fields": { - "exclude": [ - "piecesAvailable" - ] - } - }, - { - "action": "read" - }, - { - "action": "update" - }, - { - "action": "delete" - } - ] - }, { "role": "test_role_with_policy_excluded_fields", "actions": [ @@ -861,6 +907,29 @@ "action": "delete" } ] + }, + { + "role": "role_multiple_create_policy_tester", + "actions": [ + { + "action": "update" + }, + { + "action": "delete" + }, + { + "action": "create", + "policy": { + "database": "@item.title ne 'Test'" + } + }, + { + "action": "read", + "policy": { + "database": "@item.publisher_id ne 1234" + } + } + ] } ], "mappings": { @@ -911,6 +980,83 @@ } } }, + "Book_MM": { + "source": { + "object": "books_mm", + "type": "table" + }, + "graphql": { + "enabled": true, + "type": { + "singular": "book_mm", + "plural": "books_mm" + } + }, + "rest": { + "enabled": true + }, + "permissions": [ + { + "role": "anonymous", + "actions": [ + { + "action": "*" + } + ] + }, + { + "role": "authenticated", + "actions": [ + { + "action": "*" + } + ] + } + ], + "relationships": { + "publishers": { + "cardinality": "one", + "target.entity": "Publisher_MM", + "source.fields": [ + "publisher_id" + ], + "target.fields": [ + "id" + ], + "linking.source.fields": [], + "linking.target.fields": [] + }, + "reviews": { + "cardinality": "many", + "target.entity": "Review_MM", + "source.fields": [ + "id" + ], + "target.fields": [ + "book_id" + ], + "linking.source.fields": [], + "linking.target.fields": [] + }, + "authors": { + "cardinality": "many", + "target.entity": "Author_MM", + "source.fields": [ + "id" + ], + "target.fields": [ + "id" + ], + "linking.object": "book_author_link_mm", + "linking.source.fields": [ + "book_id" + ], + "linking.target.fields": [ + "author_id" + ] + } + } + }, "BookWebsitePlacement": { "source": { "object": "book_website_placements", @@ -1024,6 +1170,59 @@ } } }, + "Author_MM": { + "source": { + "object": "authors_mm", + "type": "table" + }, + "graphql": { + "enabled": true, + "type": { + "singular": "author_mm", + "plural": "authors_mm" + } + }, + "rest": { + "enabled": true + }, + "permissions": [ + { + "role": "anonymous", + "actions": [ + { + "action": "*" + } + ] + }, + { + "role": "authenticated", + "actions": [ + { + "action": "*" + } + ] + } + ], + "relationships": { + "books": { + "cardinality": "many", + "target.entity": "Book_MM", + "source.fields": [ + "id" + ], + "target.fields": [ + "id" + ], + "linking.object": "book_author_link_mm", + "linking.source.fields": [ + "author_id" + ], + "linking.target.fields": [ + "book_id" + ] + } + } + }, "Revenue": { "source": { "object": "revenues", @@ -1107,6 +1306,29 @@ "action": "delete" } ] + }, + { + "role": "role_multiple_create_policy_tester", + "actions": [ + { + "action": "update" + }, + { + "action": "delete" + }, + { + "action": "create", + "policy": { + "database": "@item.content ne 'Great'" + } + }, + { + "action": "read", + "policy": { + "database": "@item.websiteuser_id ne 1" + } + } + ] } ], "relationships": { @@ -1117,6 +1339,78 @@ "target.fields": [], "linking.source.fields": [], "linking.target.fields": [] + }, + "website_users": { + "cardinality": "one", + "target.entity": "WebsiteUser", + "source.fields": [ + "websiteuser_id" + ], + "target.fields": [ + "id" + ], + "linking.source.fields": [], + "linking.target.fields": [] + } + } + }, + "Review_MM": { + "source": { + "object": "reviews_mm", + "type": "table" + }, + "graphql": { + "enabled": true, + "type": { + "singular": "review_mm", + "plural": "reviews_mm" + } + }, + "rest": { + "enabled": true + }, + "permissions": [ + { + "role": "anonymous", + "actions": [ + { + "action": "*" + } + ] + }, + { + "role": "authenticated", + "actions": [ + { + "action": "*" + } + ] + } + ], + "relationships": { + "books": { + "cardinality": "one", + "target.entity": "Book_MM", + "source.fields": [ + "book_id" + ], + "target.fields": [ + "id" + ], + "linking.source.fields": [], + "linking.target.fields": [] + }, + "website_users": { + "cardinality": "one", + "target.entity": "WebsiteUser_MM", + "source.fields": [ + "websiteuser_id" + ], + "target.fields": [ + "id" + ], + "linking.source.fields": [], + "linking.target.fields": [] } } }, @@ -1315,7 +1609,69 @@ } ] } - ] + ], + "relationships": { + "reviews": { + "cardinality": "many", + "target.entity": "Review", + "source.fields": [ + "id" + ], + "target.fields": [ + "websiteuser_id" + ], + "linking.source.fields": [], + "linking.target.fields": [] + } + } + }, + "WebsiteUser_MM": { + "source": { + "object": "website_users_mm", + "type": "table" + }, + "graphql": { + "enabled": true, + "type": { + "singular": "websiteuser_mm", + "plural": "websiteusers_mm" + } + }, + "rest": { + "enabled": true + }, + "permissions": [ + { + "role": "anonymous", + "actions": [ + { + "action": "*" + } + ] + }, + { + "role": "authenticated", + "actions": [ + { + "action": "*" + } + ] + } + ], + "relationships": { + "reviews": { + "cardinality": "many", + "target.entity": "Review_MM", + "source.fields": [ + "id" + ], + "target.fields": [ + "websiteuser_id" + ], + "linking.source.fields": [], + "linking.target.fields": [] + } + } }, "SupportedType": { "source": { @@ -1388,14 +1744,6 @@ "enabled": true }, "permissions": [ - { - "role": "anonymous", - "actions": [ - { - "action": "read" - } - ] - }, { "role": "authenticated", "actions": [ @@ -1413,6 +1761,14 @@ } ] }, + { + "role": "anonymous", + "actions": [ + { + "action": "read" + } + ] + }, { "role": "TestNestedFilterFieldIsNull_ColumnForbidden", "actions": [ @@ -2521,7 +2877,7 @@ "rest": { "enabled": true, "methods": [ - "get" + "post" ] }, "permissions": [ @@ -2601,7 +2957,7 @@ "rest": { "enabled": true, "methods": [ - "get" + "post" ] }, "permissions": [ @@ -2722,7 +3078,7 @@ "rest": { "enabled": true, "methods": [ - "get" + "post" ] }, "permissions": [ @@ -2974,6 +3330,47 @@ } ] }, + "DefaultBuiltInFunction": { + "source": { + "object": "default_with_function_table", + "type": "table" + }, + "graphql": { + "enabled": true, + "type": { + "singular": "DefaultBuiltInFunction", + "plural": "DefaultBuiltInFunctions" + } + }, + "rest": { + "enabled": true + }, + "permissions": [ + { + "role": "anonymous", + "actions": [ + { + "action": "create", + "fields": { + "exclude": [ + "current_date", + "next_date" + ] + } + }, + { + "action": "read" + }, + { + "action": "update" + }, + { + "action": "delete" + } + ] + } + ] + }, "PublisherNF": { "source": { "object": "publishers", @@ -3170,40 +3567,6 @@ } } }, - "DefaultBuiltInFunction": { - "source": { - "object": "default_with_function_table", - "type": "table" - }, - "graphql": { - "enabled": true - }, - "rest": { - "enabled": true - }, - "permissions": [ - { - "role": "anonymous", - "actions": [ - { - "action": "create", - "fields": { - "include": [ - "*" - ], - "exclude": [ - "current_date", - "next_date" - ] - } - }, - "read", - "update", - "delete" - ] - } - ] - }, "AuthorNF": { "source": { "object": "authors", @@ -3293,4 +3656,4 @@ } } } -} +} \ No newline at end of file