diff --git a/ConfigGenerators/MsSqlCommands.txt b/ConfigGenerators/MsSqlCommands.txt index 5cd91e09a6..fc653d4814 100644 --- a/ConfigGenerators/MsSqlCommands.txt +++ b/ConfigGenerators/MsSqlCommands.txt @@ -22,7 +22,7 @@ add books_publishers_view_composite_insertable --config "dab-config.MsSql.json" add Empty --config "dab-config.MsSql.json" --source "empty_table" --permissions "authenticated:create,read,update,delete" --rest true add Notebook --config "dab-config.MsSql.json" --source "notebooks" --permissions "anonymous:read" --rest true --graphql true --fields.include "*" --policy-database "@item ne 1" add Journal --config "dab-config.MsSql.json" --source "journals" --rest true --graphql true --permissions "policy_tester_noupdate:create,delete" -add ArtOfWar --config "dab-config.MsSql.json" --source "aow" --rest true --permissions "anonymous:*" +add ArtOfWar --config "dab-config.MsSql.json" --source "aow" --rest true --graphql false --permissions "anonymous:*" add series --config "dab-config.MsSql.json" --source "series" --permissions "anonymous:*" add Sales --config "dab-config.MsSql.json" --source "sales" --permissions "anonymous:*" --rest true --graphql true add GetBooks --config "dab-config.MsSql.json" --source "get_books" --source.type "stored-procedure" --permissions "anonymous:read" --rest true --graphql true @@ -34,6 +34,8 @@ add DeleteLastInsertedBook --config "dab-config.MsSql.json" --source "delete_las add UpdateBookTitle --config "dab-config.MsSql.json" --source "update_book_title" --source.type "stored-procedure" --source.params "id:1,title:Testing Tonight" --permissions "anonymous:update" --rest true --graphql true add GetAuthorsHistoryByFirstName --config "dab-config.MsSql.json" --source "get_authors_history_by_first_name" --source.type "stored-procedure" --source.params "firstName:Aaron" --permissions "anonymous:read" --rest true --graphql SearchAuthorByFirstName add InsertAndDisplayAllBooksUnderGivenPublisher --config "dab-config.MsSql.json" --source "insert_and_display_all_books_for_given_publisher" --source.type "stored-procedure" --source.params "title:MyTitle,publisher_name:MyPublisher" --permissions "anonymous:create" --rest true --graphql true +add GQLmappings --config "dab-config.MsSql.json" --source "GQLmappings" --permissions "anonymous:*" --rest true --graphql true +update GQLmappings --config "dab-config.MsSql.json" --map "__column1:column1,__column2:column2" --permissions "authenticated:*" update Publisher --config "dab-config.MsSql.json" --permissions "authenticated:create,read,update,delete" --rest true --graphql true --relationship books --target.entity Book --cardinality many update Publisher --config "dab-config.MsSql.json" --permissions "policy_tester_01:create,delete" update Publisher --config "dab-config.MsSql.json" --permissions "policy_tester_01:update" --fields.include "*" @@ -93,7 +95,7 @@ update stocks_price --config "dab-config.MsSql.json" --permissions "authenticate 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 update Broker --config "dab-config.MsSql.json" --permissions "authenticated:create,update,read,delete" --graphql false -update Tree --config "dab-config.MsSql.json" --rest true --permissions "authenticated:create,read,update,delete" --map "species:Scientific Name,region:United State's Region" +update Tree --config "dab-config.MsSql.json" --rest true --graphql false --permissions "authenticated:create,read,update,delete" --map "species:Scientific Name,region:United State's Region" update Shrub --config "dab-config.MsSql.json" --permissions "authenticated:create,read,update,delete" --map "species:fancyName" update Fungus --config "dab-config.MsSql.json" --permissions "authenticated:create,read,update,delete" --map "spores:hazards" --rest true update Fungus --config "dab-config.MsSql.json" --permissions "policy_tester_01:read" --fields.include "*" --policy-database "@item.region ne 'northeast'" diff --git a/ConfigGenerators/MySqlCommands.txt b/ConfigGenerators/MySqlCommands.txt index f70257cbee..3d8121bfdd 100644 --- a/ConfigGenerators/MySqlCommands.txt +++ b/ConfigGenerators/MySqlCommands.txt @@ -21,9 +21,11 @@ add books_publishers_view_composite_insertable --config "dab-config.MySql.json" add Empty --config "dab-config.MySql.json" --source "empty_table" --permissions "authenticated:create,read,update,delete" --rest true add Notebook --config "dab-config.MySql.json" --source "notebooks" --permissions "anonymous:read" --rest true --graphql true --fields.include "*" --policy-database "@item ne 1" add Journal --config "dab-config.MySql.json" --source "journals" --rest true --graphql true --permissions "policy_tester_noupdate:create,delete" -add ArtOfWar --config "dab-config.MySql.json" --source "aow" --rest true --permissions "anonymous:*" +add ArtOfWar --config "dab-config.MySql.json" --source "aow" --rest true --graphql false --permissions "anonymous:*" add series --config "dab-config.MySql.json" --source "series" --permissions "anonymous:*" add Sales --config "dab-config.MySql.json" --source "sales" --permissions "anonymous:*" --rest true --graphql true +add GQLmappings --config "dab-config.MySql.json" --source "GQLmappings" --permissions "anonymous:*" --rest true --graphql true +update GQLmappings --config "dab-config.MySql.json" --map "__column1:column1,__column2:column2" --permissions "authenticated:*" update Publisher --config "dab-config.MySql.json" --permissions "authenticated:create,read,update,delete" --rest true --graphql true --relationship books --target.entity Book --cardinality many update Publisher --config "dab-config.MySql.json" --permissions "policy_tester_01:create,delete" update Publisher --config "dab-config.MySql.json" --permissions "policy_tester_01:update" --fields.include "*" @@ -84,7 +86,7 @@ update stocks_price --config "dab-config.MySql.json" --permissions "authenticate update Comic --config "dab-config.MySql.json" --permissions "authenticated:create,read,update,delete" --rest true --graphql true --relationship myseries --target.entity series --cardinality one update series --config "dab-config.MySql.json" --relationship comics --target.entity Comic --cardinality many update Broker --config "dab-config.MySql.json" --permissions "authenticated:create,update,read,delete" --graphql false -update Tree --config "dab-config.MySql.json" --rest true --permissions "authenticated:create,read,update,delete" --map "species:Scientific Name,region:United State's Region" +update Tree --config "dab-config.MySql.json" --rest true --graphql false --permissions "authenticated:create,read,update,delete" --map "species:Scientific Name,region:United State's Region" update Shrub --config "dab-config.MySql.json" --permissions "authenticated:create,read,update,delete" --map "species:fancyName" update Fungus --config "dab-config.MySql.json" --permissions "authenticated:create,read,update,delete" --map "spores:hazards" --rest true update Fungus --config "dab-config.MySql.json" --permissions "policy_tester_01:read" --fields.include "*" --policy-database "@item.region ne 'northeast'" diff --git a/ConfigGenerators/PostgreSqlCommands.txt b/ConfigGenerators/PostgreSqlCommands.txt index dde9e47bba..50c705f546 100644 --- a/ConfigGenerators/PostgreSqlCommands.txt +++ b/ConfigGenerators/PostgreSqlCommands.txt @@ -21,9 +21,11 @@ add books_publishers_view_composite_insertable --config "dab-config.PostgreSql.j add Empty --config "dab-config.PostgreSql.json" --source "empty_table" --permissions "authenticated:create,read,update,delete" --rest true add Notebook --config "dab-config.PostgreSql.json" --source "notebooks" --permissions "anonymous:read" --rest true --graphql true --fields.include "*" --policy-database "@item ne 1" add Journal --config "dab-config.PostgreSql.json" --source "journals" --rest true --graphql true --permissions "policy_tester_noupdate:create,delete" -add ArtOfWar --config "dab-config.PostgreSql.json" --source "aow" --rest true --permissions "anonymous:*" +add ArtOfWar --config "dab-config.PostgreSql.json" --source "aow" --rest true --graphql false --permissions "anonymous:*" add series --config "dab-config.PostgreSql.json" --source "series" --permissions "anonymous:*" add Sales --config "dab-config.PostgreSql.json" --source "sales" --permissions "anonymous:*" --rest true --graphql true +add GQLmappings --config "dab-config.PostgreSql.json" --source "gqlmappings" --permissions "anonymous:*" --rest true --graphql true +update GQLmappings --config "dab-config.PostgreSql.json" --map "__column1:column1,__column2:column2" --permissions "authenticated:*" update Publisher --config "dab-config.PostgreSql.json" --permissions "authenticated:create,read,update,delete" --rest true --graphql true --relationship books --target.entity Book --cardinality many update Publisher --config "dab-config.PostgreSql.json" --permissions "policy_tester_01:create,delete" update Publisher --config "dab-config.PostgreSql.json" --permissions "policy_tester_01:update" --fields.include "*" @@ -84,7 +86,7 @@ update stocks_price --config "dab-config.PostgreSql.json" --permissions "authent update Comic --config "dab-config.PostgreSql.json" --permissions "authenticated:create,read,update,delete" --rest true --graphql true --relationship myseries --target.entity series --cardinality one update series --config "dab-config.PostgreSql.json" --relationship comics --target.entity Comic --cardinality many update Broker --config "dab-config.PostgreSql.json" --permissions "authenticated:create,update,read,delete" --graphql false -update Tree --config "dab-config.PostgreSql.json" --rest true --permissions "authenticated:create,read,update,delete" --map "species:Scientific Name,region:United State's Region" +update Tree --config "dab-config.PostgreSql.json" --rest true --graphql false --permissions "authenticated:create,read,update,delete" --map "species:Scientific Name,region:United State's Region" update Shrub --config "dab-config.PostgreSql.json" --permissions "authenticated:create,read,update,delete" --map "species:fancyName" update Fungus --config "dab-config.PostgreSql.json" --permissions "authenticated:create,read,update,delete" --map "spores:hazards" --rest true update Fungus --config "dab-config.PostgreSql.json" --permissions "policy_tester_01:read" --fields.include "*" --policy-database "@item.region ne 'northeast'" diff --git a/ConfigGenerators/dab-config.mssql.reference.json b/ConfigGenerators/dab-config.mssql.reference.json index 71bc494ebb..dba3754a6c 100644 --- a/ConfigGenerators/dab-config.mssql.reference.json +++ b/ConfigGenerators/dab-config.mssql.reference.json @@ -773,7 +773,7 @@ "source": { "type": "view", "object": "books_view_with_mapping", - "key-fields": ["id"] + "key-fields": [ "id" ] }, "rest": true, "permissions": [ @@ -1092,6 +1092,28 @@ } ], "graphql": true + }, + "GQLmappings": { + "source": { + "type": "table", + "object": "GQLmappings", + "key-fields": [] + }, + "graphql": true, + "mappings": { + "__column1": "column1", + "__column2": "column2" + }, + "permissions": [ + { + "role": "anonymous", + "actions": [ "*" ] + }, + { + "role": "authenticated", + "actions": [ "*" ] + } + ] } } } diff --git a/ConfigGenerators/dab-config.sql.reference.json b/ConfigGenerators/dab-config.sql.reference.json index 00d957fbac..c30b52a800 100644 --- a/ConfigGenerators/dab-config.sql.reference.json +++ b/ConfigGenerators/dab-config.sql.reference.json @@ -1089,6 +1089,24 @@ } ], "graphql": true + }, + "GQLmappings": { + "source": "GQLmappings", + "graphql": true, + "mappings": { + "__column1": "column1", + "__column2": "column2" + }, + "permissions": [ + { + "role": "anonymous", + "actions": [ "*" ] + }, + { + "role": "authenticated", + "actions": [ "*" ] + } + ] } } } diff --git a/src/Service.GraphQLBuilder/Sql/SchemaConverter.cs b/src/Service.GraphQLBuilder/Sql/SchemaConverter.cs index 7a43749211..919b087d4f 100644 --- a/src/Service.GraphQLBuilder/Sql/SchemaConverter.cs +++ b/src/Service.GraphQLBuilder/Sql/SchemaConverter.cs @@ -105,10 +105,16 @@ public static ObjectTypeDefinitionNode FromDatabaseObject( directives.Add(authZDirective!); } + string exposedColumnName = columnName; + if (configEntity.Mappings is not null && configEntity.Mappings.TryGetValue(key: columnName, out string? columnAlias)) + { + exposedColumnName = columnAlias; + } + NamedTypeNode fieldType = new(GetGraphQLTypeForColumnType(column.SystemType)); FieldDefinitionNode field = new( location: null, - new(columnName), + new(exposedColumnName), description: null, new List(), column.IsNullable ? fieldType : new NonNullTypeNode(fieldType), diff --git a/src/Service.Tests/GraphQLBuilder/Sql/SchemaConverterTests.cs b/src/Service.Tests/GraphQLBuilder/Sql/SchemaConverterTests.cs index f041db1ef9..ff9e18a8c4 100644 --- a/src/Service.Tests/GraphQLBuilder/Sql/SchemaConverterTests.cs +++ b/src/Service.Tests/GraphQLBuilder/Sql/SchemaConverterTests.cs @@ -78,6 +78,55 @@ public void ColumnNameBecomesFieldName(string columnName, string expected) Assert.AreEqual(expected, od.Fields[0].Name.Value); } + /// + /// Tests that an Entity object's mapping configuration is utilized in the schema generator + /// by checking that mapped column values are used for field names instead of backing column names. + /// + /// Whether to add mapping entries to the mappings collection. + /// Name of database column. + /// Configured alternative (mapped) name of column to be used in REST/GraphQL endpoints. + /// Whether GraphQL object field name should equal the mapped column name provided. + [DataTestMethod] + [DataRow(true, "__typename", "typename", true, DisplayName = "Mapped column name fixes GraphQL introspection naming violation. ")] + [DataRow(false, "typename", "mappedtypename", false, DisplayName = "Mapped column name ")] + public void FieldNameMatchesMappedValue(bool setMappings, string backingColumnName, string mappedName, bool expectMappedName) + { + Dictionary mappings = new(); + + if (setMappings) + { + mappings.Add(backingColumnName, mappedName); + } + + SourceDefinition table = new(); + table.Columns.Add(backingColumnName, new ColumnDefinition + { + SystemType = typeof(string) + }); + + DatabaseObject dbObject = new DatabaseTable() { TableDefinition = table }; + + Entity configEntity = GenerateEmptyEntity() with { Mappings = mappings }; + + ObjectTypeDefinitionNode od = SchemaConverter.FromDatabaseObject( + "table", + dbObject, + configEntity, + entities: new(), + rolesAllowedForEntity: GetRolesAllowedForEntity(), + rolesAllowedForFields: GetFieldToRolesMap(columnName: table.Columns.First().Key)); + + string errorMessage = "Object field representing database column has an unexpected name value."; + if (expectMappedName) + { + Assert.AreEqual(mappedName, od.Fields[0].Name.Value, message: errorMessage); + } + else + { + Assert.AreEqual(backingColumnName, od.Fields[0].Name.Value, message: errorMessage); + } + } + [TestMethod] public void PrimaryKeyColumnHasAppropriateDirective() { diff --git a/src/Service.Tests/MsSqlBooks.sql b/src/Service.Tests/MsSqlBooks.sql index 40df3286e0..f3cbe29c4f 100644 --- a/src/Service.Tests/MsSqlBooks.sql +++ b/src/Service.Tests/MsSqlBooks.sql @@ -39,6 +39,7 @@ DROP TABLE IF EXISTS sales; DROP TABLE IF EXISTS authors_history; DROP TABLE IF EXISTS revenues; DROP TABLE IF EXISTS graphql_incompatible; +DROP TABLE IF EXISTS GQLmappings; DROP SCHEMA IF EXISTS [foo]; COMMIT; @@ -212,6 +213,12 @@ CREATE TABLE graphql_incompatible ( conformingName varchar(12) ); +CREATE TABLE GQLmappings ( + __column1 int PRIMARY KEY, + __column2 varchar(max), + column3 varchar(max) +) + ALTER TABLE books ADD CONSTRAINT book_publisher_fk FOREIGN KEY (publisher_id) @@ -271,6 +278,11 @@ 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 +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'); +INSERT INTO GQLmappings(__column1, __column2, column3) VALUES (5, 'Filtered Record', 'Record to be Filtered on Find'); + SET IDENTITY_INSERT books ON INSERT INTO books(id, title, publisher_id) VALUES (1, 'Awesome book', 1234), diff --git a/src/Service.Tests/MySqlBooks.sql b/src/Service.Tests/MySqlBooks.sql index 6cb8f9a0d4..a25722db99 100644 --- a/src/Service.Tests/MySqlBooks.sql +++ b/src/Service.Tests/MySqlBooks.sql @@ -25,7 +25,7 @@ DROP TABLE IF EXISTS aow; DROP TABLE IF EXISTS series; DROP TABLE IF EXISTS sales; DROP TABLE IF EXISTS graphql_incompatible; - +DROP TABLE IF EXISTS GQLmappings; CREATE TABLE publishers( id int AUTO_INCREMENT PRIMARY KEY, @@ -177,6 +177,12 @@ CREATE TABLE graphql_incompatible ( conformingName text ); +CREATE TABLE GQLmappings ( + __column1 int PRIMARY KEY, + __column2 text, + column3 text +); + ALTER TABLE books ADD CONSTRAINT book_publisher_fk FOREIGN KEY (publisher_id) @@ -225,6 +231,10 @@ FOREIGN KEY (series_id) REFERENCES series(id) ON DELETE CASCADE; +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'); +INSERT INTO GQLmappings(__column1, __column2, column3) VALUES (5, 'Filtered Record', 'Record to be Filtered on Find'); 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'); 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'); INSERT INTO books(id, title, publisher_id) diff --git a/src/Service.Tests/PostgreSqlBooks.sql b/src/Service.Tests/PostgreSqlBooks.sql index 01a207e350..0d7fcb5b68 100644 --- a/src/Service.Tests/PostgreSqlBooks.sql +++ b/src/Service.Tests/PostgreSqlBooks.sql @@ -25,6 +25,7 @@ DROP TABLE IF EXISTS journals; DROP TABLE IF EXISTS series; DROP TABLE IF EXISTS sales; DROP TABLE IF EXISTS graphql_incompatible; +DROP TABLE IF EXISTS GQLmappings; DROP FUNCTION IF EXISTS insertCompositeView; DROP SCHEMA IF EXISTS foo; @@ -181,6 +182,12 @@ CREATE TABLE graphql_incompatible ( conformingName text ); +CREATE TABLE GQLmappings ( + __column1 int PRIMARY KEY, + __column2 text, + column3 text +); + ALTER TABLE books ADD CONSTRAINT book_publisher_fk FOREIGN KEY (publisher_id) @@ -229,6 +236,10 @@ FOREIGN KEY (series_id) REFERENCES series(id) ON DELETE CASCADE; +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'); +INSERT INTO GQLmappings(__column1, __column2, column3) VALUES (5, 'Filtered Record', 'Record to be Filtered on Find'); 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'); 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'); INSERT INTO books(id, title, publisher_id) diff --git a/src/Service.Tests/SqlTests/GraphQLFilterTests/GraphQLFilterTestBase.cs b/src/Service.Tests/SqlTests/GraphQLFilterTests/GraphQLFilterTestBase.cs index b4e5f04ead..2cdab12d7b 100644 --- a/src/Service.Tests/SqlTests/GraphQLFilterTests/GraphQLFilterTestBase.cs +++ b/src/Service.Tests/SqlTests/GraphQLFilterTests/GraphQLFilterTestBase.cs @@ -46,6 +46,28 @@ public async Task TestStringFiltersEq() SqlTestHelper.PerformTestEqualJsonStrings(expected, actual.ToString()); } + /// + /// Tests eq of StringFilterInput when mappings are configured for GraphQL entity. + /// + [TestMethod] + public async Task TestStringFiltersEqWithMappings(string dbQuery) + { + string graphQLQueryName = "gQLmappings"; + string gqlQuery = @"{ + gQLmappings( " + QueryBuilder.FILTER_FIELD_NAME + @" : {column2: {eq: ""Filtered Record""}}) + { + items { + column1 + column2 + } + } + }"; + + JsonElement actual = await ExecuteGraphQLRequestAsync(gqlQuery, graphQLQueryName, isAuthenticated: false); + string expected = await GetDatabaseResultAsync(dbQuery); + SqlTestHelper.PerformTestEqualJsonStrings(expected, actual.ToString()); + } + /// /// Tests neq of StringFilterInput /// diff --git a/src/Service.Tests/SqlTests/GraphQLFilterTests/MsSqlGQLFilterTests.cs b/src/Service.Tests/SqlTests/GraphQLFilterTests/MsSqlGQLFilterTests.cs index 98330c0899..6c50bf26e6 100644 --- a/src/Service.Tests/SqlTests/GraphQLFilterTests/MsSqlGQLFilterTests.cs +++ b/src/Service.Tests/SqlTests/GraphQLFilterTests/MsSqlGQLFilterTests.cs @@ -67,6 +67,19 @@ public async Task TestNestedFilterManyOne() SqlTestHelper.PerformTestEqualJsonStrings(expected, actual.ToString()); } + [TestMethod] + public async Task TestStringFiltersEqWithMappings() + { + string msSqlQuery = @" + SELECT [__column1] AS [column1], [__column2] AS [column2] + FROM GQLMappings + WHERE [__column2] = 'Filtered Record' + ORDER BY [__column1] asc + FOR JSON PATH, INCLUDE_NULL_VALUES"; + + await TestStringFiltersEqWithMappings(msSqlQuery); + } + /// /// Test Nested Filter for One-Many relationship /// diff --git a/src/Service.Tests/SqlTests/GraphQLFilterTests/MySqlGQLFilterTests.cs b/src/Service.Tests/SqlTests/GraphQLFilterTests/MySqlGQLFilterTests.cs index caf540d7a0..9ec7deff5a 100644 --- a/src/Service.Tests/SqlTests/GraphQLFilterTests/MySqlGQLFilterTests.cs +++ b/src/Service.Tests/SqlTests/GraphQLFilterTests/MySqlGQLFilterTests.cs @@ -70,6 +70,22 @@ public void TestNestedFilterWithOr() throw new System.NotImplementedException("Nested Filtering for MySQL is not yet implemented."); } + [TestMethod] + public async Task TestStringFiltersEqWithMappings() + { + string mySqlQuery = @" + SELECT COALESCE(JSON_ARRAYAGG(JSON_OBJECT('column1', `subq1`.`column1`, 'column2', `subq1`.`column2`)), '[]') AS `data` + FROM + (SELECT `table0`.`__column1` AS `column1`, + `table0`.`__column2` AS `column2` + FROM `GQLmappings` AS `table0` + WHERE `table0`.`__column2` = 'Filtered Record' + ORDER BY `table0`.`__column1` asc + LIMIT 100) AS `subq1`"; + + await TestStringFiltersEqWithMappings(mySqlQuery); + } + /// /// Gets the default schema for /// MySql. diff --git a/src/Service.Tests/SqlTests/GraphQLFilterTests/PostgreSqlGQLFilterTests.cs b/src/Service.Tests/SqlTests/GraphQLFilterTests/PostgreSqlGQLFilterTests.cs index dd7fd35779..d0a100576e 100644 --- a/src/Service.Tests/SqlTests/GraphQLFilterTests/PostgreSqlGQLFilterTests.cs +++ b/src/Service.Tests/SqlTests/GraphQLFilterTests/PostgreSqlGQLFilterTests.cs @@ -70,6 +70,14 @@ public void TestNestedFilterWithOr() throw new System.NotImplementedException("Nested Filtering for PostgreSQL is not yet implemented."); } + [TestMethod] + public async Task TestStringFiltersEqWithMappings() + { + string postgresQuery = $"SELECT json_agg(to_jsonb(table0)) FROM (SELECT __column1 AS column1, __column2 AS column2 FROM GQLMappings WHERE __column2 = 'Filtered Record' ORDER BY __column1 asc LIMIT 100) as table0"; + + await TestStringFiltersEqWithMappings(postgresQuery); + } + /// /// Gets the default schema for /// PostgreSql. diff --git a/src/Service.Tests/SqlTests/GraphQLMutationTests/GraphQLMutationTestBase.cs b/src/Service.Tests/SqlTests/GraphQLMutationTests/GraphQLMutationTestBase.cs index 417d2996b0..00d3e08c62 100644 --- a/src/Service.Tests/SqlTests/GraphQLMutationTests/GraphQLMutationTestBase.cs +++ b/src/Service.Tests/SqlTests/GraphQLMutationTests/GraphQLMutationTestBase.cs @@ -570,6 +570,76 @@ public async Task InsertIntoInsertableComplexView(string dbQuery) SqlTestHelper.PerformTestEqualJsonStrings(expected, actual.ToString()); } + /// + /// Demonstrates that using mapped column names for fields within the GraphQL mutatation results in successful engine processing. + /// + public async Task InsertMutationWithVariablesAndMappings(string dbQuery) + { + string graphQLMutationName = "createGQLmappings"; + string graphQLMutation = @" + mutation($id: Int!, $col2Value: String) { + createGQLmappings(item: { column1: $id, column2: $col2Value }) { + column1 + column2 + } + } + "; + + JsonElement actual = await ExecuteGraphQLRequestAsync(graphQLMutation, graphQLMutationName, isAuthenticated: true, new() { { "id", 2 }, { "col2Value", "My New Value" } }); + string expected = await GetDatabaseResultAsync(dbQuery); + + SqlTestHelper.PerformTestEqualJsonStrings(expected, actual.ToString()); + } + + /// + /// Demonstrates that using mapped column names for fields within the GraphQL mutatation results in successful engine processing + /// of the column2 value update for the record where column1 = $id. + /// + public async Task UpdateMutationWithVariablesAndMappings(string dbQuery) + { + string graphQLMutationName = "updateGQLmappings"; + string graphQLMutation = @" + mutation($id: Int!, $col2Value: String) { + updateGQLmappings(column1: $id, item: { column2: $col2Value }) { + column1 + column2 + } + } + "; + + JsonElement actual = await ExecuteGraphQLRequestAsync(graphQLMutation, graphQLMutationName, isAuthenticated: true, new() { { "id", 3 }, { "col2Value", "Updated Value of Mapped Column" } }); + string expected = await GetDatabaseResultAsync(dbQuery); + + SqlTestHelper.PerformTestEqualJsonStrings(expected, actual.ToString()); + } + + /// + /// Demonstrates that using mapped column names for fields within the GraphQL mutatation results in successful engine processing + /// of removal of the record where column1 = $id and the returned object representing the deleting record utilizes the mapped column values. + /// + public async Task DeleteMutationWithVariablesAndMappings(string dbQuery, string dbQueryToVerifyDeletion) + { + string graphQLMutationName = "deleteGQLmappings"; + string graphQLMutation = @" + mutation($id: Int!) { + deleteGQLmappings(column1: $id) { + column1 + column2 + } + } + "; + + string expected = await GetDatabaseResultAsync(dbQuery); + JsonElement actual = await ExecuteGraphQLRequestAsync(graphQLMutation, graphQLMutationName, isAuthenticated: true, new() { { "id", 4 } }); + + SqlTestHelper.PerformTestEqualJsonStrings(expected, actual.ToString()); + + string dbResponse = await GetDatabaseResultAsync(dbQueryToVerifyDeletion); + + using JsonDocument result = JsonDocument.Parse(dbResponse); + Assert.AreEqual(result.RootElement.GetProperty("count").GetInt64(), 0); + } + #endregion #region Negative Tests diff --git a/src/Service.Tests/SqlTests/GraphQLMutationTests/MsSqlGraphQLMutationTests.cs b/src/Service.Tests/SqlTests/GraphQLMutationTests/MsSqlGraphQLMutationTests.cs index 4fabcc8b4b..18575ec71b 100644 --- a/src/Service.Tests/SqlTests/GraphQLMutationTests/MsSqlGraphQLMutationTests.cs +++ b/src/Service.Tests/SqlTests/GraphQLMutationTests/MsSqlGraphQLMutationTests.cs @@ -528,6 +528,72 @@ ORDER BY [id] await UpdateSimpleView(msSqlQuery); } + [TestMethod] + public async Task InsertMutationWithVariablesAndMappings() + { + string msSqlQuery = @" + SELECT TOP 1 [__column1] AS [column1], [__column2] AS [column2] + FROM [GQLmappings] + WHERE [__column1] = 2 + ORDER BY [__column1] + FOR JSON PATH, + INCLUDE_NULL_VALUES, + WITHOUT_ARRAY_WRAPPER + "; + + await InsertMutationWithVariablesAndMappings(msSqlQuery); + } + + /// + /// Demonstrates that using mapped column names for fields within the GraphQL mutatation results in successful engine processing + /// of the column2 value update for the record where column1 = $id. + /// + [TestMethod] + public async Task UpdateMutationWithVariablesAndMappings() + { + string msSqlQuery = @" + SELECT TOP 1 [__column1] AS [column1], [__column2] AS [column2] + FROM [GQLmappings] + WHERE [GQLmappings].[__column1] = 3 + AND [GQLmappings].[__column2] = 'Updated Value of Mapped Column' + ORDER BY [__column1] + FOR JSON PATH, + INCLUDE_NULL_VALUES, + WITHOUT_ARRAY_WRAPPER + "; + + await UpdateMutationWithVariablesAndMappings(msSqlQuery); + } + + /// + /// Demonstrates that using mapped column names for fields within the GraphQL mutatation results in successful engine processing + /// of removal of the record where column1 = $id and the returned object representing the deleting record utilizes the mapped column values. + /// + [TestMethod] + public async Task DeleteMutationWithVariablesAndMappings() + { + string msSqlQueryToVerifyDeletion = @" + SELECT COUNT(*) AS count + FROM [GQLmappings] + WHERE [__column1] = 4 + FOR JSON PATH, + INCLUDE_NULL_VALUES, + WITHOUT_ARRAY_WRAPPER + "; + + string msSqlQueryForResult = @" + SELECT TOP 1 [__column1] AS [column1], [__column2] AS [column2] + FROM [GQLmappings] + WHERE [__column1] = 4 + ORDER BY [__column1] + FOR JSON PATH, + INCLUDE_NULL_VALUES, + WITHOUT_ARRAY_WRAPPER + "; + + await DeleteMutationWithVariablesAndMappings(msSqlQueryForResult, msSqlQueryToVerifyDeletion); + } + /// /// Do: Delete an entry from a simple view /// Check: if the mutation returned result is as expected and if the entry that id has been deleted diff --git a/src/Service.Tests/SqlTests/GraphQLMutationTests/MySqlGraphQLMutationTests.cs b/src/Service.Tests/SqlTests/GraphQLMutationTests/MySqlGraphQLMutationTests.cs index 43e05ac392..0df8978d6c 100644 --- a/src/Service.Tests/SqlTests/GraphQLMutationTests/MySqlGraphQLMutationTests.cs +++ b/src/Service.Tests/SqlTests/GraphQLMutationTests/MySqlGraphQLMutationTests.cs @@ -117,6 +117,26 @@ ORDER BY `id` asc LIMIT 1 await InsertMutationWithVariables(mySqlQuery); } + /// + /// Demonstrates that using mapped column names for fields within the GraphQL mutatation results in successful engine processing. + /// + [TestMethod] + public async Task InsertMutationWithVariablesAndMappings() + { + string mySqlQuery = @" + SELECT JSON_OBJECT('column1', `subq`.`column1`, 'column2', `subq`.`column2`) AS `data` + FROM ( + SELECT `table0`.`__column1` AS `column1`, + `table0`.`__column2` AS `column2` + FROM `GQLmappings` AS `table0` + WHERE `table0`.`__column1` = 2 + ORDER BY `table0`.`__column1` asc LIMIT 1 + ) AS `subq` + "; + + await InsertMutationWithVariablesAndMappings(mySqlQuery); + } + /// /// Do: Inserts new review with default content for a Review and return its id and content /// Check: If book with the given id is present in the database then @@ -212,6 +232,58 @@ ORDER BY `id` asc LIMIT 1 await UpdateMutationForComputedColumns(mySqlQuery); } + /// + /// Demonstrates that using mapped column names for fields within the GraphQL mutatation results in successful engine processing + /// of the column2 value update for the record where column1 = $id. + /// + [TestMethod] + public async Task UpdateMutationWithVariablesAndMappings() + { + string mySqlQuery = @" + SELECT JSON_OBJECT('column1', `subq2`.`column1`, 'column2', `subq2`.`column2`) AS `data` + FROM ( + SELECT `table0`.`__column1` AS `column1`, + `table0`.`__column2` AS `column2` + FROM `GQLmappings` AS `table0` + WHERE `table0`.`__column1` = 3 + AND `table0`.`__column2` = 'Updated Value of Mapped Column' + ORDER BY `table0`.`__column1` asc LIMIT 1 + ) AS `subq2` + "; + + await UpdateMutationWithVariablesAndMappings(mySqlQuery); + } + + /// + /// Demonstrates that using mapped column names for fields within the GraphQL mutatation results in successful engine processing + /// of removal of the record where column1 = $id and the returned object representing the deleting record utilizes the mapped column values. + /// + [TestMethod] + public async Task DeleteMutationWithVariablesAndMappings() + { + string mySqlQueryForResult = @" + SELECT JSON_OBJECT('column1', `subq2`.`column1`, 'column2', `subq2`.`column2`) AS `data` + FROM ( + SELECT `table0`.`__column1` AS `column1`, + `table0`.`__column2` AS `column2` + FROM `GQLmappings` AS `table0` + WHERE `table0`.`__column1` = 4 + ORDER BY `table0`.`__column1` asc LIMIT 1 + ) AS `subq2` + "; + + string mySqlQueryToVerifyDeletion = @" + SELECT JSON_OBJECT('count', `subq`.`count`) AS `data` + FROM ( + SELECT COUNT(*) AS `count` + FROM `GQLmappings` AS `table0` + WHERE `__column1` = 4 + ) AS `subq` + "; + + await DeleteMutationWithVariablesAndMappings(mySqlQueryForResult, mySqlQueryToVerifyDeletion); + } + /// /// Do: Delete book by id /// Check: if the mutation returned result is as expected and if book by that id has been deleted diff --git a/src/Service.Tests/SqlTests/GraphQLMutationTests/PostgreSqlGraphQLMutationTests.cs b/src/Service.Tests/SqlTests/GraphQLMutationTests/PostgreSqlGraphQLMutationTests.cs index de2b212ea9..870e865cd2 100644 --- a/src/Service.Tests/SqlTests/GraphQLMutationTests/PostgreSqlGraphQLMutationTests.cs +++ b/src/Service.Tests/SqlTests/GraphQLMutationTests/PostgreSqlGraphQLMutationTests.cs @@ -59,6 +59,26 @@ ORDER BY id asc await InsertMutation(postgresQuery); } + /// + /// Demonstrates that using mapped column names for fields within the GraphQL mutatation results in successful engine processing. + /// + [TestMethod] + public async Task InsertMutationWithVariablesAndMappings() + { + string postgresQuery = @" + SELECT to_jsonb(subq) AS DATA + FROM + (SELECT table0.__column1 AS column1, + table0.__column2 AS column2 + FROM GQLmappings AS table0 + WHERE __column1 = 2 + ORDER BY __column1 asc + LIMIT 1) AS subq + "; + + await InsertMutationWithVariablesAndMappings(postgresQuery); + } + /// /// Do: Inserts new sale item into sales table that automatically calculates the total price /// based on subtotal and tax. @@ -209,6 +229,57 @@ ORDER BY id asc await UpdateMutationForComputedColumns(postgresQuery); } + /// + /// Demonstrates that using mapped column names for fields within the GraphQL mutatation results in successful engine processing + /// of the column2 value update for the record where column1 = $id. + /// + [TestMethod] + public async Task UpdateMutationWithVariablesAndMappings() + { + string postgresQuery = @" + SELECT to_jsonb(subq) AS DATA + FROM + (SELECT table0.__column1 AS column1, + table0.__column2 AS column2 + FROM GQLmappings AS table0 + WHERE __column1 = 3 + AND __column2 = 'Updated Value of Mapped Column' + ORDER BY __column1 asc + LIMIT 1) AS subq + "; + + await UpdateMutationWithVariablesAndMappings(postgresQuery); + } + + /// + /// Demonstrates that using mapped column names for fields within the GraphQL mutatation results in successful engine processing + /// of removal of the record where column1 = $id and the returned object representing the deleting record utilizes the mapped column values. + /// + [TestMethod] + public async Task DeleteMutationWithVariablesAndMappings() + { + string postgresQueryForResult = @" + SELECT to_jsonb(subq) AS DATA + FROM + (SELECT table0.__column1 AS column1, + table0.__column2 AS column2 + FROM GQLmappings AS table0 + WHERE __column1 = 4 + ORDER BY __column1 asc + LIMIT 1) AS subq + "; + + string postgresQueryToVerifyDeletion = @" + SELECT to_jsonb(subq) AS DATA + FROM + (SELECT COUNT(*) AS COUNT + FROM GQLmappings AS table0 + WHERE __column1 = 4) AS subq + "; + + await DeleteMutationWithVariablesAndMappings(postgresQueryForResult, postgresQueryToVerifyDeletion); + } + /// /// Do: Delete book by id /// Check: if the mutation returned result is as expected and if book by that id has been deleted diff --git a/src/Service.Tests/SqlTests/GraphQLQueryTests/GraphQLQueryTestBase.cs b/src/Service.Tests/SqlTests/GraphQLQueryTests/GraphQLQueryTestBase.cs index 9ad72a9639..fc89917d80 100644 --- a/src/Service.Tests/SqlTests/GraphQLQueryTests/GraphQLQueryTestBase.cs +++ b/src/Service.Tests/SqlTests/GraphQLQueryTests/GraphQLQueryTestBase.cs @@ -54,6 +54,33 @@ public async Task MultipleResultQueryWithVariables(string dbQuery) SqlTestHelper.PerformTestEqualJsonStrings(expected, actual.GetProperty("items").ToString()); } + /// + /// Tests that the following "Find Many" query is properly handled by the engine given that it references + /// mapped column names "column1" and "column2" and NOT "__column1" nor "__column2" + /// and that the two mapped names are present in the result set. + /// + /// + [TestMethod] + public async Task MultipleResultQueryWithMappings(string dbQuery) + { + string graphQLQueryName = "gQLmappings"; + + // "4" references the number of records in the GQLmappings table. + string graphQLQuery = @"{ + gQLmappings(first: 4) { + items { + column1 + column2 + } + } + }"; + + JsonElement actual = await ExecuteGraphQLRequestAsync(graphQLQuery, graphQLQueryName, isAuthenticated: false); + string expected = await GetDatabaseResultAsync(dbQuery); + + SqlTestHelper.PerformTestEqualJsonStrings(expected, actual.GetProperty("items").ToString()); + } + /// /// Gets array of results for querying a table containing computed columns. /// @@ -506,6 +533,23 @@ public async Task QueryWithSingleColumnPrimaryKey(string dbQuery) SqlTestHelper.PerformTestEqualJsonStrings(expected, actual.ToString()); } + [TestMethod] + public async Task QueryWithSingleColumnPrimaryKeyAndMappings(string dbQuery) + { + string graphQLQueryName = "gQLmappings_by_pk"; + string graphQLQuery = @"{ + gQLmappings_by_pk(column1: 1) { + column1 + } + }"; + + JsonElement actual = await base.ExecuteGraphQLRequestAsync( + graphQLQuery, graphQLQueryName, isAuthenticated: false); + string expected = await GetDatabaseResultAsync(dbQuery); + + SqlTestHelper.PerformTestEqualJsonStrings(expected, actual.ToString()); + } + [TestMethod] public async Task QueryWithMultipleColumnPrimaryKey(string dbQuery) { diff --git a/src/Service.Tests/SqlTests/GraphQLQueryTests/MsSqlGraphQLQueryTests.cs b/src/Service.Tests/SqlTests/GraphQLQueryTests/MsSqlGraphQLQueryTests.cs index 681a9a6968..c54a86c510 100644 --- a/src/Service.Tests/SqlTests/GraphQLQueryTests/MsSqlGraphQLQueryTests.cs +++ b/src/Service.Tests/SqlTests/GraphQLQueryTests/MsSqlGraphQLQueryTests.cs @@ -58,6 +58,18 @@ public async Task MultipleResultQueryWithVariables() await MultipleResultQueryWithVariables(msSqlQuery); } + [TestMethod] + public async Task MultipleResultQueryWithMappings() + { + string msSqlQuery = @" + SELECT [__column1] AS [column1], [__column2] AS [column2] + FROM GQLmappings + ORDER BY [__column1] asc + FOR JSON PATH, INCLUDE_NULL_VALUES"; + + await MultipleResultQueryWithMappings(msSqlQuery); + } + /// /// Test One-To-One relationship both directions /// (book -> website placement, website placememnt -> book) @@ -118,6 +130,17 @@ SELECT title FROM books await QueryWithSingleColumnPrimaryKey(msSqlQuery); } + [TestMethod] + public async Task QueryWithSingleColumnPrimaryKeyAndMappings() + { + string msSqlQuery = @" + SELECT [__column1] AS [column1] FROM GQLMappings + WHERE [__column1] = 1 FOR JSON PATH, INCLUDE_NULL_VALUES, WITHOUT_ARRAY_WRAPPER + "; + + await QueryWithSingleColumnPrimaryKeyAndMappings(msSqlQuery); + } + [TestMethod] public async Task QueryWithMultipleColumnPrimaryKey() { diff --git a/src/Service.Tests/SqlTests/GraphQLQueryTests/MySqlGraphQLQueryTests.cs b/src/Service.Tests/SqlTests/GraphQLQueryTests/MySqlGraphQLQueryTests.cs index c0c6d58c22..6b4f786468 100644 --- a/src/Service.Tests/SqlTests/GraphQLQueryTests/MySqlGraphQLQueryTests.cs +++ b/src/Service.Tests/SqlTests/GraphQLQueryTests/MySqlGraphQLQueryTests.cs @@ -34,6 +34,22 @@ ORDER BY `table0`.`id` asc await MultipleResultQuery(mySqlQuery); } + [TestMethod] + public async Task MultipleResultQueryWithMappings() + { + string mySqlQuery = @" + SELECT COALESCE(JSON_ARRAYAGG(JSON_OBJECT('column1', `subq1`.`column1`, 'column2', `subq1`.`column2`)), '[]') AS `data` + FROM + (SELECT `table0`.`__column1` AS `column1`, + `table0`.`__column2` AS `column2` + FROM `GQLmappings` AS `table0` + WHERE 1 = 1 + ORDER BY `table0`.`__column1` asc + LIMIT 100) AS `subq1`"; + + await MultipleResultQueryWithMappings(mySqlQuery); + } + /// /// Gets array of results for querying a table containing computed columns. /// @@ -150,6 +166,23 @@ SELECT JSON_OBJECT('content', `subq3`.`content`) AS `data` await QueryWithMultipleColumnPrimaryKey(mySqlQuery); } + [TestMethod] + public async Task QueryWithSingleColumnPrimaryKeyAndMappings() + { + string mySqlQuery = @" + SELECT JSON_OBJECT('column1', `subq3`.`column1`) AS `data` + FROM ( + SELECT `table0`.`__column1` AS `column1` + FROM `GQLmappings` AS `table0` + WHERE `table0`.`__column1` = 1 + ORDER BY `table0`.`__column1` asc + LIMIT 1 + ) AS `subq3` + "; + + await QueryWithSingleColumnPrimaryKeyAndMappings(mySqlQuery); + } + [TestMethod] public async Task QueryWithNullableForeignKey() { diff --git a/src/Service.Tests/SqlTests/GraphQLQueryTests/PostgreSqlGraphQLQueryTests.cs b/src/Service.Tests/SqlTests/GraphQLQueryTests/PostgreSqlGraphQLQueryTests.cs index f304e5e2db..8f98d88e2c 100644 --- a/src/Service.Tests/SqlTests/GraphQLQueryTests/PostgreSqlGraphQLQueryTests.cs +++ b/src/Service.Tests/SqlTests/GraphQLQueryTests/PostgreSqlGraphQLQueryTests.cs @@ -25,6 +25,13 @@ public async Task MultipleResultQuery() await MultipleResultQuery(postgresQuery); } + [TestMethod] + public async Task MultipleResultQueryWithMappings() + { + string postgresQuery = $"SELECT json_agg(to_jsonb(table0)) FROM (SELECT __column1 AS column1, __column2 AS column2 FROM GQLMappings ORDER BY __column1 asc LIMIT 100) as table0"; + await MultipleResultQueryWithMappings(postgresQuery); + } + /// /// Gets array of results for querying a table containing computed columns. /// @@ -132,6 +139,23 @@ LIMIT 1 await QueryWithSingleColumnPrimaryKey(postgresQuery); } + [TestMethod] + public async Task QueryWithSingleColumnPrimaryKeyAndMappings() + { + string postgresQuery = @" + SELECT to_jsonb(subq) AS data + FROM ( + SELECT table0.__column1 AS column1 + FROM GQLMappings AS table0 + WHERE __column1 = 1 + ORDER BY __column1 asc + LIMIT 1 + ) AS subq + "; + + await QueryWithSingleColumnPrimaryKeyAndMappings(postgresQuery); + } + [TestMethod] public async Task QueryWithMultipleColumnPrimaryKey() { diff --git a/src/Service.Tests/SqlTests/SqlTestHelper.cs b/src/Service.Tests/SqlTests/SqlTestHelper.cs index 25efe2cee1..ff4964f4a1 100644 --- a/src/Service.Tests/SqlTests/SqlTestHelper.cs +++ b/src/Service.Tests/SqlTests/SqlTestHelper.cs @@ -32,7 +32,7 @@ public static void RemoveAllRelationshipBetweenEntities(RuntimeConfig runtimeCon { Entity updatedEntity = new(entity.Source, entity.Rest, entity.GraphQL, entity.Permissions, - Relationships: null, Mappings: null); + Relationships: null, Mappings: entity.Mappings); runtimeConfig.Entities.Remove(entityName); runtimeConfig.Entities.Add(entityName, updatedEntity); } diff --git a/src/Service/Models/GraphQLFilterParsers.cs b/src/Service/Models/GraphQLFilterParsers.cs index 08fc6626e6..2d167c4737 100644 --- a/src/Service/Models/GraphQLFilterParsers.cs +++ b/src/Service/Models/GraphQLFilterParsers.cs @@ -108,6 +108,14 @@ public Predicate Parse( { List subfields = (List)fieldValue; + // Preserve the name value present in the filter. + string backingColumnName = name; + _metadataProvider.TryGetBackingColumn(queryStructure.EntityName, field: name, out string? resolvedBackingColumnName); + if (!string.IsNullOrWhiteSpace(resolvedBackingColumnName)) + { + backingColumnName = resolvedBackingColumnName; + } + if (!StandardQueryInputs.IsStandardInputType(filterInputObjectType.Name)) { if (sourceDefinition.PrimaryKey.Count != 0) @@ -124,8 +132,8 @@ public Predicate Parse( } else { - queryStructure.DatabaseObject.Name = sourceName + "." + name; - queryStructure.SourceAlias = sourceName + "." + name; + queryStructure.DatabaseObject.Name = sourceName + "." + backingColumnName; + queryStructure.SourceAlias = sourceName + "." + backingColumnName; predicates.Push(new PredicateOperand(Parse(ctx, filterArgumentObject.Fields[name], subfields, @@ -141,7 +149,7 @@ public Predicate Parse( ParseScalarType( ctx, argumentSchema: filterArgumentObject.Fields[name], - name, + backingColumnName, subfields, schemaName, sourceName, diff --git a/src/Service/Resolvers/Sql Query Structures/SqlQueryStructure.cs b/src/Service/Resolvers/Sql Query Structures/SqlQueryStructure.cs index 185f86d349..38018076f9 100644 --- a/src/Service/Resolvers/Sql Query Structures/SqlQueryStructure.cs +++ b/src/Service/Resolvers/Sql Query Structures/SqlQueryStructure.cs @@ -466,16 +466,24 @@ private SqlQueryStructure( } /// - /// Adds predicates for the primary keys in the paramters of the graphql query + /// Adds predicates for the primary keys in the parameters of the GraphQL query /// private void AddPrimaryKeyPredicates(IDictionary queryParams) { foreach (KeyValuePair parameter in queryParams) { + string columnName = parameter.Key; + + MetadataProvider.TryGetBackingColumn(base.EntityName, parameter.Key, out string? backingColumnName); + if (!string.IsNullOrWhiteSpace(backingColumnName)) + { + columnName = backingColumnName; + } + Predicates.Add(new Predicate( new PredicateOperand(new Column(tableSchema: DatabaseObject.SchemaName, tableName: DatabaseObject.Name, - columnName: parameter.Key, + columnName: columnName, tableAlias: SourceAlias)), PredicateOperation.Equal, new PredicateOperand($"@{MakeParamWithValue(parameter.Value)}") @@ -610,7 +618,15 @@ private void AddGraphQLFields(IReadOnlyList selections, RuntimeC } else if (field.SelectionSet is null) { - AddColumn(fieldName); + if (MetadataProvider.TryGetBackingColumn(EntityName, fieldName, out string? name) + && !string.IsNullOrWhiteSpace(name)) + { + AddColumn(columnName: name, labelName: fieldName); + } + else + { + AddColumn(fieldName); + } } else { diff --git a/src/Service/Resolvers/Sql Query Structures/SqlUpdateQueryStructure.cs b/src/Service/Resolvers/Sql Query Structures/SqlUpdateQueryStructure.cs index 8ace3c70a5..e0e9683cc4 100644 --- a/src/Service/Resolvers/Sql Query Structures/SqlUpdateQueryStructure.cs +++ b/src/Service/Resolvers/Sql Query Structures/SqlUpdateQueryStructure.cs @@ -94,9 +94,15 @@ public SqlUpdateStructure( foreach (KeyValuePair param in mutationParams) { // primary keys used as predicates - if (sourceDefinition.PrimaryKey.Contains(param.Key)) + string pkBackingColumn = param.Key; + if (sqlMetadataProvider.TryGetBackingColumn(entityName, param.Key, out string? name) && !string.IsNullOrWhiteSpace(name)) { - Predicates.Add(CreatePredicateForParam(param)); + pkBackingColumn = name; + } + + if (sourceDefinition.PrimaryKey.Contains(pkBackingColumn)) + { + Predicates.Add(CreatePredicateForParam(new KeyValuePair(pkBackingColumn, param.Value))); } else // Unpack the input argument type as columns to update if (param.Key == UpdateMutationBuilder.INPUT_ARGUMENT_NAME) @@ -106,9 +112,16 @@ public SqlUpdateStructure( foreach (KeyValuePair field in updateFields) { - if (columns.Contains(field.Key)) + string fieldBackingColumn = field.Key; + if (sqlMetadataProvider.TryGetBackingColumn(entityName, field.Key, out string? resolvedBackingColumn) + && !string.IsNullOrWhiteSpace(resolvedBackingColumn)) + { + fieldBackingColumn = resolvedBackingColumn; + } + + if (columns.Contains(fieldBackingColumn)) { - UpdateOperations.Add(CreatePredicateForParam(field)); + UpdateOperations.Add(CreatePredicateForParam(new KeyValuePair(key: fieldBackingColumn, field.Value))); } } } diff --git a/src/Service/Resolvers/SqlMutationEngine.cs b/src/Service/Resolvers/SqlMutationEngine.cs index f53b56dfd6..189f82336a 100644 --- a/src/Service/Resolvers/SqlMutationEngine.cs +++ b/src/Service/Resolvers/SqlMutationEngine.cs @@ -97,7 +97,7 @@ public async Task> ExecuteAsync(IMiddlewareContex { // compute the mutation result before removing the element, // since typical GraphQL delete mutations return the metadata of the deleted item. - result = await _queryEngine.ExecuteAsync(context, parameters); + result = await _queryEngine.ExecuteAsync(context, GetBackingColumnsFromCollection(entityName, parameters)); Dictionary? resultProperties = await PerformDeleteOperation( @@ -129,9 +129,13 @@ await PerformMutationOperation( if (resultRowAndProperties is not null && resultRowAndProperties.Item1 is not null && !context.Selection.Type.IsScalarType()) { + // Because the GraphQL mutation result set columns were exposed (mapped) column names, + // the column names must be converted to backing (source) column names so the + // PrimaryKeyPredicates created in the SqlQueryStructure created by the query engine + // represent database column names. result = await _queryEngine.ExecuteAsync( context, - resultRowAndProperties.Item1); + GetBackingColumnsFromCollection(entityName, resultRowAndProperties.Item1)); } } @@ -146,6 +150,33 @@ await PerformMutationOperation( return result; } + /// + /// Converts exposed column names from the parameters provided to backing column names. + /// parameters.Value is not modified. + /// + /// Name of Entity + /// Key/Value collection where only the key is converted. + /// Dictionary where the keys now represent backing column names. + public Dictionary GetBackingColumnsFromCollection(string entityName, IDictionary parameters) + { + Dictionary backingRowParams = new(); + + foreach (KeyValuePair resultEntry in parameters) + { + _sqlMetadataProvider.TryGetBackingColumn(entityName, resultEntry.Key, out string? name); + if (!string.IsNullOrWhiteSpace(name)) + { + backingRowParams.Add(name, resultEntry.Value); + } + else + { + backingRowParams.Add(resultEntry.Key, resultEntry.Value); + } + } + + return backingRowParams; + } + /// /// Executes the REST mutation query and returns IActionResult asynchronously. /// Result error cases differ for Stored Procedure requests than normal mutation requests @@ -473,22 +504,44 @@ private static OkObjectResult OkMutationResponse(JsonElement jsonResult) { SourceDefinition sourceDefinition = _sqlMetadataProvider.GetSourceDefinition(entityName); - // only extract pk columns - // since non pk columns can be null + // To support GraphQL field mappings (DB column aliases), convert the sourceDefinition + // primary key column names (backing columns) to the exposed (mapped) column names to + // identify primary key column names in the mutation result set. + List primaryKeyExposedColumnNames = new(); + foreach (string primaryKey in sourceDefinition.PrimaryKey) + { + if (_sqlMetadataProvider.TryGetExposedColumnName(entityName, primaryKey, out string? name) && !string.IsNullOrWhiteSpace(name)) + { + primaryKeyExposedColumnNames.Add(name); + } + } + + // Only extract pk columns since non pk columns can be null // and the subsequent query would search with: // nullParamName = NULL // which would fail to get the mutated entry from the db + // When no exposed column names were resolved, it is safe to provide + // backing column names (sourceDefinition.Primary) as a list of arguments. resultRecord = await _queryExecutor.ExecuteQueryAsync( queryString, queryParameters, _queryExecutor.ExtractRowFromDbDataReader, _httpContextAccessor.HttpContext!, - sourceDefinition.PrimaryKey); + primaryKeyExposedColumnNames.Count > 0 ? primaryKeyExposedColumnNames : sourceDefinition.PrimaryKey); if (resultRecord is not null && resultRecord.Item1 is null) { - string searchedPK = '<' + string.Join(", ", sourceDefinition.PrimaryKey.Select(pk => $"{pk}: {parameters[pk]}")) + '>'; + string searchedPK; + if (primaryKeyExposedColumnNames.Count > 0) + { + searchedPK = '<' + string.Join(", ", primaryKeyExposedColumnNames.Select(pk => $"{pk}: {parameters[pk]}")) + '>'; + } + else + { + searchedPK = '<' + string.Join(", ", sourceDefinition.PrimaryKey.Select(pk => $"{pk}: {parameters[pk]}")) + '>'; + } + throw new DataApiBuilderException( message: $"Could not find entity with {searchedPK}", statusCode: HttpStatusCode.NotFound, diff --git a/src/Service/Services/MetadataProviders/CosmosSqlMetadataProvider.cs b/src/Service/Services/MetadataProviders/CosmosSqlMetadataProvider.cs index 728e378739..bb59585986 100644 --- a/src/Service/Services/MetadataProviders/CosmosSqlMetadataProvider.cs +++ b/src/Service/Services/MetadataProviders/CosmosSqlMetadataProvider.cs @@ -166,9 +166,18 @@ public bool TryGetExposedColumnName(string entityName, string field, out string? throw new NotImplementedException(); } + /// + /// Mapped column are not yet supported for Cosmos. + /// Returns the value of the field provided. + /// + /// Name of the entity. + /// Name of the database field. + /// Mapped name, which for CosmosDB is the value provided for field." + /// True, with out variable set as the value of the input "field" value. public bool TryGetBackingColumn(string entityName, string field, out string? name) { - throw new NotImplementedException(); + name = field; + return true; } public IDictionary GetEntityNamesAndDbObjects()