diff --git a/config-generators/mssql-commands.txt b/config-generators/mssql-commands.txt index 054e6ef208..6a41d751e9 100644 --- a/config-generators/mssql-commands.txt +++ b/config-generators/mssql-commands.txt @@ -22,6 +22,8 @@ add VectorType --config "dab-config.MsSql.json" --source vector_type_table --res update VectorType --config "dab-config.MsSql.json" --permissions "authenticated:create,read,delete,update" update VectorOwner --config "dab-config.MsSql.json" --relationship vectors --target.entity VectorType --cardinality many --relationship.fields "id:owner_id" update VectorType --config "dab-config.MsSql.json" --relationship owner --target.entity VectorOwner --cardinality one --relationship.fields "owner_id:id" +add GeometryType --config "dab-config.MsSql.json" --source geometry_type_table --rest true --graphql true --permissions "anonymous:read" +add TemporalGeometryType --config "dab-config.MsSql.json" --source temporal_geometry_type_table --rest true --graphql true --permissions "anonymous:create,read,update" add Profile --config "dab-config.MsSql.json" --source profiles --rest true --graphql true --permissions "anonymous:create,read,delete,update" update Profile --config "dab-config.MsSql.json" --permissions "authenticated:create,read,delete,update" add XmlProfile --config "dab-config.MsSql.json" --source xml_profiles --rest true --graphql true --permissions "anonymous:create,read,delete,update" diff --git a/src/Core/Authorization/AuthorizationResolver.cs b/src/Core/Authorization/AuthorizationResolver.cs index 4c9fa1d3c6..6cae9cae1b 100644 --- a/src/Core/Authorization/AuthorizationResolver.cs +++ b/src/Core/Authorization/AuthorizationResolver.cs @@ -810,6 +810,62 @@ private static ResolvedDatabasePolicy GetPolicyWithClaimValues(string policy, Di return new ResolvedDatabasePolicy(processedPolicy, claimValues); } + /// + /// Returns the field names a database policy references through "@item.", skipping single-quoted + /// literals, in which a doubled quote is an escaped quote. + /// + internal static IEnumerable GetFieldReferencesInDatabasePolicy(string? policy) + { + if (string.IsNullOrEmpty(policy)) + { + yield break; + } + + int index = 0; + while (index < policy.Length) + { + if (policy[index] == '\'') + { + index++; + while (index < policy.Length) + { + if (policy[index++] != '\'') + { + continue; + } + + if (index < policy.Length && policy[index] == '\'') + { + index++; + continue; + } + + break; + } + + continue; + } + + if (string.CompareOrdinal(policy, index, FIELD_PREFIX, 0, FIELD_PREFIX.Length) != 0) + { + index++; + continue; + } + + int fieldStart = index + FIELD_PREFIX.Length; + index = fieldStart; + while (index < policy.Length && (char.IsAsciiLetterOrDigit(policy[index]) || policy[index] == '_')) + { + index++; + } + + if (index > fieldStart) + { + yield return policy[fieldStart..index]; + } + } + } + /// /// Emits a structured audit signal when a configured (non-empty) database authorization /// policy resolves to an empty predicate, which silently removes the intended row/column diff --git a/src/Core/Resolvers/MsSqlQueryBuilder.cs b/src/Core/Resolvers/MsSqlQueryBuilder.cs index de2b7071ca..885f030b4f 100644 --- a/src/Core/Resolvers/MsSqlQueryBuilder.cs +++ b/src/Core/Resolvers/MsSqlQueryBuilder.cs @@ -686,6 +686,30 @@ END AS is_system_object FROM all_tables AS o JOIN sys.objects AS so ON so.object_id = o.object_id + ), + tables_with_invalid_types AS + ( + SELECT c.object_id + FROM sys.columns AS c + JOIN sys.types AS ty + ON c.user_type_id = ty.user_type_id + WHERE ty.name IN (N'sql_variant', N'rowversion') + -- Columns of CLR types such as geometry are skipped, which only fails when they are in the primary key. + OR + ( + ty.is_assembly_type = 1 + AND EXISTS + ( + SELECT 1 + FROM sys.index_columns AS ic + JOIN sys.indexes AS i + ON i.object_id = ic.object_id + AND i.index_id = ic.index_id + WHERE ic.object_id = c.object_id + AND ic.column_id = c.column_id + AND i.is_primary_key = 1 + ) + ) ) SELECT a.schema_name AS [schema], @@ -698,22 +722,7 @@ ELSE REPLACE( ) END AS entity_name, CASE - WHEN EXISTS - ( - SELECT 1 - FROM sys.columns AS c - JOIN sys.types AS ty - ON c.user_type_id = ty.user_type_id - WHERE c.object_id = a.object_id - AND ty.name IN - ( - N'geography', - N'geometry', - N'hierarchyid', - N'sql_variant', - N'rowversion' - ) - ) THEN 1 + WHEN a.object_id IN (SELECT object_id FROM tables_with_invalid_types) THEN 1 ELSE 0 END AS contains_invalid_types FROM eligible_tables AS a @@ -742,22 +751,7 @@ WHERE a.full_name LIKE ip.pattern COLLATE DATABASE_DEFAULT ESCAPE '\' AND ( @exclude_invalid_types = 0 - OR NOT EXISTS - ( - SELECT 1 - FROM sys.columns AS c - JOIN sys.types AS ty - ON c.user_type_id = ty.user_type_id - WHERE c.object_id = a.object_id - AND ty.name IN - ( - N'geography', - N'geometry', - N'hierarchyid', - N'sql_variant', - N'rowversion' - ) - ) + OR a.object_id NOT IN (SELECT object_id FROM tables_with_invalid_types) ) ORDER BY a.schema_name, diff --git a/src/Core/Services/MetadataProviders/SqlMetadataProvider.cs b/src/Core/Services/MetadataProviders/SqlMetadataProvider.cs index d7999deb24..1fe5c32eb6 100644 --- a/src/Core/Services/MetadataProviders/SqlMetadataProvider.cs +++ b/src/Core/Services/MetadataProviders/SqlMetadataProvider.cs @@ -12,6 +12,7 @@ using System.Text.Json.Nodes; using Azure.DataApiBuilder.Config.DatabasePrimitives; using Azure.DataApiBuilder.Config.ObjectModel; +using Azure.DataApiBuilder.Core.Authorization; using Azure.DataApiBuilder.Core.Configurations; using Azure.DataApiBuilder.Core.Models; using Azure.DataApiBuilder.Core.Parsers; @@ -83,6 +84,9 @@ public abstract class SqlMetadataProvider : private Dictionary> EntityExposedNamesToBackingColumnNames { get; } = new(); + // Keyed by schema table, which entities over the same database object share. + private readonly Dictionary _unreadableColumnsBySchemaTable = new(); + protected IAbstractQueryManagerFactory QueryManagerFactory { get; init; } /// @@ -350,6 +354,7 @@ public async Task InitializeAsync(CancellationToken cancellationToken) await PopulateObjectDefinitionForEntities(cancellationToken); cancellationToken.ThrowIfCancellationRequested(); GenerateExposedToBackingColumnMapsForEntities(); + ValidateReferencesToUnreadableColumns(); // When IsLateConfigured is true we are in a hosted scenario and do not reveal primary key information. if (!_runtimeConfigProvider.IsLateConfigured) @@ -1613,6 +1618,17 @@ private async Task PopulateSourceDefinitionAsync( if (sourceDefinition.PrimaryKey.Count == 0) { + UnreadableColumns? unreadableColumns = GetUnreadableColumns(schemaName, tableName); + if (unreadableColumns is not null && unreadableColumns.KeyColumns.Count > 0) + { + throw new DataApiBuilderException( + message: $"Entity '{entityName}' ({schemaName}.{tableName}) has no usable primary key: key column(s) " + + $"{FormatColumns(unreadableColumns.TypeNameByColumn.Where(column => unreadableColumns.KeyColumns.Contains(column.Key)))} " + + "have a data type that is not supported. Configure source.key-fields with supported columns that identify a row.", + statusCode: HttpStatusCode.ServiceUnavailable, + subStatusCode: DataApiBuilderException.SubStatusCodes.ErrorInInitialization); + } + throw new DataApiBuilderException( message: $"Primary key not configured on the given database object {tableName}", statusCode: HttpStatusCode.ServiceUnavailable, @@ -1635,6 +1651,12 @@ await PopulateTriggerMetadataForTable( schemaName, tableName, cancellationToken); + + if (_unreadableColumnsBySchemaTable.TryGetValue(dataTable, out UnreadableColumns? skippedColumns)) + { + ThrowIfReferencesUnreadableColumn(entityName, skippedColumns, sourceDefinition.PrimaryKey, "the primary key"); + } + using DataTableReader reader = new(dataTable); DataTable schemaTable = reader.GetSchemaTable(); RuntimeConfig runtimeConfig = _runtimeConfigProvider.GetConfig(); @@ -1809,6 +1831,7 @@ private async Task GetTableWithSchemaFromDataSetAsync( try { dataTable = await FillSchemaForTableAsync( + entityName, schemaName, tableName, cancellationToken); @@ -1893,6 +1916,7 @@ await QueryExecutor.SetManagedIdentityAccessTokenIfAnyAsync( /// and adds the corresponding DataTable to the entities data set. /// private async Task FillSchemaForTableAsync( + string entityName, string schemaName, string tableName, CancellationToken cancellationToken) @@ -1984,11 +2008,360 @@ await QueryExecutor.SetManagedIdentityAccessTokenIfAnyAsync( ex, cancellationToken); } + catch (InvalidOperationException) + { + // The data adapter rejects the whole object when the provider reports no CLR type for a column. + DataTable? schemaWithoutUnreadableColumns = await FillSchemaWithoutUnreadableColumnsAsync( + entityName, + schemaName, + tableName, + selectCommand, + cancellationToken); + + if (schemaWithoutUnreadableColumns is null) + { + throw; + } + + return schemaWithoutUnreadableColumns; + } cancellationToken.ThrowIfCancellationRequested(); return dataTable[0]; } + /// + /// Builds the schema the data adapter would have built, minus the columns the provider reports no CLR + /// type for, such as SQL Server's geometry, geography and hierarchyid. Returns null when there are none, + /// so the caller rethrows the adapter's error. + /// + private async Task FillSchemaWithoutUnreadableColumnsAsync( + string entityName, + string schemaName, + string tableName, + DbCommand selectCommand, + CancellationToken cancellationToken) + { + DataTable? readerSchema; + try + { + using DbDataReader reader = await selectCommand.ExecuteReaderAsync( + CommandBehavior.SchemaOnly | CommandBehavior.KeyInfo, + cancellationToken); + readerSchema = await reader.GetSchemaTableAsync(cancellationToken); + } + catch (Exception ex) when (cancellationToken.IsCancellationRequested) + { + throw new OperationCanceledException( + "Schema discovery was canceled during shutdown.", + ex, + cancellationToken); + } + + DataRow[] columns = readerSchema?.Rows.Cast() + .OrderBy(column => Convert.ToInt32(column[SchemaTableColumn.ColumnOrdinal], System.Globalization.CultureInfo.InvariantCulture)) + .ToArray() ?? []; + + if (!columns.Any(column => column[SchemaTableColumn.DataType] is not Type)) + { + return null; + } + + DataTable schemaTable = new(GetTableNameWithSchemaPrefix(schemaName, tableName)); + Dictionary unreadableColumns = new(StringComparer.OrdinalIgnoreCase); + + foreach (DataRow column in columns.Where(column => !IsSchemaFlagSet(column, SchemaTableOptionalColumn.IsHidden))) + { + string columnName = (string)column[SchemaTableColumn.ColumnName]; + if (column[SchemaTableColumn.DataType] is Type dataType) + { + schemaTable.Columns.Add(CreateColumnAsDataAdapterWould(column, columnName, dataType)); + } + else + { + unreadableColumns[columnName] = GetDatabaseTypeName(column); + } + } + + if (schemaTable.Columns.Count == 0) + { + throw new DataApiBuilderException( + message: $"Entity '{entityName}' ({schemaName}.{tableName}) cannot be exposed: every column has a data type " + + $"that is not supported: {FormatColumns(unreadableColumns)}.", + statusCode: HttpStatusCode.ServiceUnavailable, + subStatusCode: DataApiBuilderException.SubStatusCodes.ErrorInInitialization); + } + + List keyRows = GetKeyAsDataAdapterWould(columns); + List unreadableKeyColumns = keyRows + .Where(column => column[SchemaTableColumn.DataType] is not Type) + .Select(column => (string)column[SchemaTableColumn.ColumnName]) + .ToList(); + + if (keyRows.Count > 0 && unreadableKeyColumns.Count == 0) + { + DataColumn[] keyColumns = keyRows + .Select(column => schemaTable.Columns[(string)column[SchemaTableColumn.ColumnName]]!) + .ToArray(); + + // Like the data adapter, only a key without nullable columns becomes the primary key. + if (keyColumns.All(keyColumn => !keyColumn.AllowDBNull)) + { + schemaTable.PrimaryKey = keyColumns; + } + } + + EntitiesDataSet.Tables.Add(schemaTable); + _unreadableColumnsBySchemaTable[schemaTable] = new UnreadableColumns(unreadableColumns, unreadableKeyColumns); + + if (unreadableColumns.Count > 0) + { + // Hosted deployments don't log schema details; see LogPrimaryKeys. + if (_runtimeConfigProvider.IsLateConfigured) + { + _logger.LogWarning( + "Skipped {columnCount} column(s) whose data type is not supported. They are not exposed through REST, GraphQL or MCP.", + unreadableColumns.Count); + } + else + { + _logger.LogWarning( + "Skipped column(s) of {databaseObject} whose data type is not supported: {columns}. They are not exposed through REST, GraphQL or MCP.", + $"{schemaName}.{tableName}", + FormatColumns(unreadableColumns)); + } + } + + return schemaTable; + } + + /// + /// Returns the key columns the data adapter would infer: the visible key columns, unless a hidden key + /// column of the same object shows that the key is not fully selected. + /// + private static List GetKeyAsDataAdapterWould(IEnumerable columns) + { + List keyColumns = new(); + string? keyBaseTable = null; + bool keySpansBaseTables = false; + + foreach (DataRow column in columns) + { + if (!IsSchemaFlagSet(column, SchemaTableColumn.IsKey)) + { + continue; + } + + string? baseTable = column[SchemaTableColumn.BaseTableName] as string; + + if (IsSchemaFlagSet(column, SchemaTableOptionalColumn.IsHidden)) + { + if (keySpansBaseTables || baseTable == keyBaseTable) + { + return new List(); + } + + continue; + } + + if (baseTable != keyBaseTable) + { + if (keyBaseTable is null) + { + keyBaseTable = baseTable; + } + else + { + keySpansBaseTables = true; + } + } + + keyColumns.Add(column); + } + + return keyColumns; + } + + /// + /// Creates a column with the nullability and auto-increment flags the data adapter derives from a schema row. + /// + private static DataColumn CreateColumnAsDataAdapterWould(DataRow column, string columnName, Type dataType) + { + bool isKey = IsSchemaFlagSet(column, SchemaTableColumn.IsKey); + bool isAutoIncrement = IsSchemaFlagSet(column, SchemaTableOptionalColumn.IsAutoIncrement) && IsAutoIncrementType(dataType); + bool isUniqueNonKey = IsSchemaFlagSet(column, SchemaTableColumn.IsUnique) && !isKey && !dataType.IsArray; + bool isReadOnly = IsSchemaFlagSet(column, SchemaTableOptionalColumn.IsReadOnly); + bool allowsNull = !column.Table.Columns.Contains(SchemaTableColumn.AllowDBNull) + || column[SchemaTableColumn.AllowDBNull] is not bool allowDbNull + || allowDbNull; + + return new DataColumn(columnName, dataType) + { + AutoIncrement = isAutoIncrement, + AllowDBNull = allowsNull || !(isAutoIncrement || !isReadOnly || isKey || isUniqueNonKey) + }; + } + + // Mirrors the internal DataColumn.IsAutoIncrementType; the data adapter marks only these types auto-increment. + private static bool IsAutoIncrementType(Type dataType) => + dataType == typeof(int) || dataType == typeof(long) || dataType == typeof(short) || dataType == typeof(decimal) + || dataType == typeof(System.Numerics.BigInteger) || dataType == typeof(System.Data.SqlTypes.SqlInt32) + || dataType == typeof(System.Data.SqlTypes.SqlInt64) || dataType == typeof(System.Data.SqlTypes.SqlInt16) + || dataType == typeof(System.Data.SqlTypes.SqlDecimal); + + private static bool IsSchemaFlagSet(DataRow column, string flag) => + column.Table.Columns.Contains(flag) && column[flag] is bool isSet && isSet; + + // SQL Server qualifies a CLR type's name with its database and schema, e.g. "master.sys.geometry". + private static string GetDatabaseTypeName(DataRow column) + { + string? typeName = column.Table.Columns.Contains("DataTypeName") ? column["DataTypeName"] as string : null; + return string.IsNullOrWhiteSpace(typeName) ? "unknown" : typeName[(typeName.LastIndexOf('.') + 1)..]; + } + + private static string FormatColumns(IEnumerable> typeNameByColumn) => + string.Join(", ", typeNameByColumn.Select(column => $"{column.Key} ({column.Value})")); + + private UnreadableColumns? GetUnreadableColumns(string schemaName, string tableName) + { + DataTable? schemaTable = EntitiesDataSet.Tables[GetTableNameWithSchemaPrefix(schemaName, tableName)]; + return schemaTable is not null && _unreadableColumnsBySchemaTable.TryGetValue(schemaTable, out UnreadableColumns? columns) + ? columns + : null; + } + + private UnreadableColumns? GetUnreadableColumnsForEntity(string entityName) => + EntityToDatabaseObject.TryGetValue(entityName, out DatabaseObject? databaseObject) + && databaseObject is not DatabaseStoredProcedure + ? GetUnreadableColumns(databaseObject.SchemaName, databaseObject.Name) + : null; + + /// + /// Fails initialization when configuration names a column that schema discovery skipped, instead of + /// letting every request that uses the reference fail. + /// + private void ValidateReferencesToUnreadableColumns() + { + if (_unreadableColumnsBySchemaTable.Count == 0) + { + return; + } + + foreach ((string entityName, Entity entity) in Entities) + { + try + { + ValidateReferencesToUnreadableColumns(entityName, entity); + } + catch (Exception e) + { + HandleOrRecordException(e); + } + } + } + + private void ValidateReferencesToUnreadableColumns(string entityName, Entity entity) + { + UnreadableColumns? unreadableColumns = GetUnreadableColumnsForEntity(entityName); + + if (unreadableColumns is not null) + { + ThrowIfReferencesUnreadableColumn(entityName, unreadableColumns, entity.Mappings?.Keys, "mappings"); + ThrowIfReferencesUnreadableColumn(entityName, unreadableColumns, entity.Fields?.Select(field => field.Name), "fields"); + + foreach (EntityPermission permission in entity.Permissions) + { + foreach (EntityAction action in permission.Actions) + { + ThrowIfReferencesUnreadableColumn( + entityName, + unreadableColumns, + action.Fields?.Include, + $"fields.include of role '{permission.Role}'"); + + // Policies name exposed fields, like the OData model they are parsed against. + IEnumerable policyColumns = AuthorizationResolver + .GetFieldReferencesInDatabasePolicy(action.Policy?.Database) + .Select(field => TryGetBackingColumn(entityName, field, out string? backingColumn) ? backingColumn : field); + ThrowIfReferencesUnreadableColumn( + entityName, + unreadableColumns, + policyColumns, + $"the database policy of role '{permission.Role}'"); + } + } + } + + if (entity.Relationships is null) + { + return; + } + + // Relationship fields name backing columns, so they are compared as configured. + foreach ((string relationshipName, EntityRelationship relationship) in entity.Relationships) + { + ThrowIfReferencesUnreadableColumn( + entityName, + unreadableColumns, + relationship.SourceFields, + $"source.fields of relationship '{relationshipName}'"); + ThrowIfReferencesUnreadableColumn( + entityName, + GetUnreadableColumnsForEntity(relationship.TargetEntity), + relationship.TargetFields, + $"target.fields of relationship '{relationshipName}'"); + + if (!string.IsNullOrWhiteSpace(relationship.LinkingObject)) + { + (string linkingSchemaName, string linkingTableName) = ParseSchemaAndDbTableName(relationship.LinkingObject); + UnreadableColumns? linkingColumns = GetUnreadableColumns(linkingSchemaName, linkingTableName); + ThrowIfReferencesUnreadableColumn( + entityName, + linkingColumns, + relationship.LinkingSourceFields, + $"linking.source.fields of relationship '{relationshipName}'"); + ThrowIfReferencesUnreadableColumn( + entityName, + linkingColumns, + relationship.LinkingTargetFields, + $"linking.target.fields of relationship '{relationshipName}'"); + } + } + } + + private static void ThrowIfReferencesUnreadableColumn( + string entityName, + UnreadableColumns? unreadableColumns, + IEnumerable? columnNames, + string configurationSection) + { + if (unreadableColumns is null || columnNames is null) + { + return; + } + + foreach (string columnName in columnNames) + { + if (unreadableColumns.TypeNameByColumn.TryGetValue(columnName, out string? typeName)) + { + throw new DataApiBuilderException( + message: $"Entity '{entityName}': {configurationSection} references column '{columnName}', whose data type " + + $"'{typeName}' is not supported. Remove the reference or use a supported column.", + statusCode: HttpStatusCode.ServiceUnavailable, + subStatusCode: DataApiBuilderException.SubStatusCodes.ErrorInInitialization); + } + } + } + + /// + /// Columns a schema read left out because the data provider reports no CLR type for them. + /// + /// Each skipped column with its database type name. + /// Skipped columns that belong to the key the data adapter would have inferred. + private sealed record UnreadableColumns( + IReadOnlyDictionary TypeNameByColumn, + IReadOnlyList KeyColumns); + /// /// Gets the correctly formatted table name with schema as prefix, if one exists. /// A schema prefix is simply the correctly formatted and prefixed schema name that diff --git a/src/Service.Tests/Authorization/AuthorizationResolverUnitTests.cs b/src/Service.Tests/Authorization/AuthorizationResolverUnitTests.cs index b01d095fa1..f541155234 100644 --- a/src/Service.Tests/Authorization/AuthorizationResolverUnitTests.cs +++ b/src/Service.Tests/Authorization/AuthorizationResolverUnitTests.cs @@ -1982,6 +1982,16 @@ public void ValidateUnauthenticatedUserClaimsAreNotResolvedWhenProcessingUserCla bool didResolveUnauthenticatedOidClaim = resolvedClaims["oid"] == "1337"; Assert.AreEqual(expected: false, actual: didResolveUnauthenticatedRoleClaim, message: "Unauthenticated oid claim erroneously resolved."); } + + [DataTestMethod] + [DataRow(null, "", DisplayName = "No policy")] + [DataRow("@item.id eq @claims.userId and @item.owner_id ne 1", "id,owner_id", DisplayName = "Fields next to a claim")] + [DataRow("@item.name eq '@item.geom'", "name", DisplayName = "Reference inside a string literal")] + [DataRow("@item.name ne 'it''s @item.geom' or @item.id gt 1", "name,id", DisplayName = "Escaped quote inside a string literal")] + public void GetFieldReferencesInDatabasePolicySkipsStringLiterals(string policy, string expectedFields) + { + Assert.AreEqual(expectedFields, string.Join(",", AuthorizationResolver.GetFieldReferencesInDatabasePolicy(policy))); + } #endregion #region Helpers diff --git a/src/Service.Tests/Configuration/ConfigurationTests.cs b/src/Service.Tests/Configuration/ConfigurationTests.cs index 63ad1551dd..0c2c9e6016 100644 --- a/src/Service.Tests/Configuration/ConfigurationTests.cs +++ b/src/Service.Tests/Configuration/ConfigurationTests.cs @@ -6158,6 +6158,109 @@ public async Task TestAutoentitiesAreGeneratedIntoEntities(bool useEntities, int } } + /// + /// A table with a geometry column, which schema discovery skips, is served through an autoentity and through + /// entities whose field permissions leave the column out. + /// + [TestCategory(TestCategory.MSSQL)] + [DataTestMethod] + [DataRow("autoentity", DisplayName = "Autoentity")] + [DataRow("fields.exclude", DisplayName = "Entity whose permissions exclude the column")] + [DataRow("fields.include", DisplayName = "Entity whose permissions include only the other columns")] + public async Task TestTableWithUnsupportedColumnTypeIsServed(string entityKind) + { + // Arrange + const string ENTITY_NAME = "dbo_geometry_type_table"; + EntityActionFields fields = entityKind switch + { + "fields.exclude" => new(Exclude: new() { "geom" }), + "fields.include" => new(Exclude: new(), Include: new() { "id", "name" }), + _ => null + }; + EntityPermission[] permissions = new[] + { + new EntityPermission(AuthorizationResolver.ROLE_ANONYMOUS, new[] { new EntityAction(EntityActionOperation.Read, fields, new()) }) + }; + + Dictionary entityMap = new(); + Dictionary autoentityMap = new(); + if (entityKind == "autoentity") + { + autoentityMap.Add("GeometryAutoEntity", new Autoentity( + Patterns: new AutoentityPatterns(Include: new[] { "dbo.geometry_type_table" }, Exclude: null, Name: null), + Template: new AutoentityTemplate( + Rest: new EntityRestOptions(Enabled: true), + GraphQL: new EntityGraphQLOptions(Singular: string.Empty, Plural: string.Empty, Enabled: true), + Health: null, + Cache: null), + Permissions: permissions)); + } + else + { + entityMap.Add(ENTITY_NAME, new Entity( + Source: new("dbo.geometry_type_table", EntitySourceType.Table, null, null), + Fields: null, + Rest: new(Enabled: true), + GraphQL: new(Singular: string.Empty, Plural: string.Empty), + Permissions: permissions, + Relationships: null, + Mappings: null)); + } + + DataSource dataSource = new(DatabaseType.MSSQL, + GetConnectionStringFromEnvironmentConfig(environment: TestCategory.MSSQL), Options: null); + + RuntimeConfig configuration = new( + Schema: "TestUnsupportedColumnTypeSchema", + DataSource: dataSource, + Runtime: new( + Rest: new(Enabled: true), + GraphQL: new(Enabled: true), + Mcp: new(Enabled: false), + Host: new( + Cors: null, + Authentication: new Config.ObjectModel.AuthenticationOptions( + Provider: nameof(EasyAuthType.StaticWebApps), + Jwt: null + ) + ) + ), + Entities: new(entityMap), + Autoentities: autoentityMap.Count > 0 ? new RuntimeAutoentities(autoentityMap) : null + ); + + File.WriteAllText(CUSTOM_CONFIG_FILENAME, configuration.ToJson()); + + string[] args = new[] { $"--ConfigFileName={CUSTOM_CONFIG_FILENAME}" }; + + using (TestServer server = new(Program.CreateWebHostBuilder(args))) + using (HttpClient client = server.CreateClient()) + { + // Act + using HttpRequestMessage restRequest = new(HttpMethod.Get, $"/api/{ENTITY_NAME}"); + using HttpResponseMessage restResponse = await client.SendAsync(restRequest); + + object graphqlPayload = new { query = $"{{ {ENTITY_NAME}s {{ items {{ id name }} }} }}" }; + using HttpRequestMessage graphqlRequest = new(HttpMethod.Post, "/graphql") + { + Content = JsonContent.Create(graphqlPayload) + }; + using HttpResponseMessage graphqlResponse = await client.SendAsync(graphqlRequest); + + // Assert + const string EXPECTED_ROW = @"{""id"":1,""name"":""point""}"; + string restResponseBody = await restResponse.Content.ReadAsStringAsync(); + Assert.AreEqual(HttpStatusCode.OK, restResponse.StatusCode, restResponseBody); + StringAssert.Contains(restResponseBody, EXPECTED_ROW); + Assert.IsFalse(restResponseBody.Contains(@"""geom"""), "The skipped column should not be returned."); + + string graphqlResponseBody = await graphqlResponse.Content.ReadAsStringAsync(); + Assert.AreEqual(HttpStatusCode.OK, graphqlResponse.StatusCode, graphqlResponseBody); + Assert.IsFalse(graphqlResponseBody.Contains("errors"), graphqlResponseBody); + StringAssert.Contains(graphqlResponseBody, EXPECTED_ROW); + } + } + /// /// This test validates that multiple autoentities with the same object name /// but different schemas can be generated and accessed properly with the diff --git a/src/Service.Tests/DatabaseSchema-MsSql.sql b/src/Service.Tests/DatabaseSchema-MsSql.sql index 1fe9373324..d0bf3e1f72 100644 --- a/src/Service.Tests/DatabaseSchema-MsSql.sql +++ b/src/Service.Tests/DatabaseSchema-MsSql.sql @@ -11,6 +11,12 @@ DROP VIEW IF EXISTS books_view_with_mapping; DROP VIEW IF EXISTS stocks_view_selected; DROP VIEW IF EXISTS books_publishers_view_composite; DROP VIEW IF EXISTS books_publishers_view_composite_insertable; +DROP VIEW IF EXISTS geometry_only_view; +DROP VIEW IF EXISTS geometry_all_view; +DROP VIEW IF EXISTS hierarchyid_composite_view; +DROP VIEW IF EXISTS unique_without_pk_view; +DROP VIEW IF EXISTS join_geometry_view; +DROP VIEW IF EXISTS self_join_geometry_view; DROP PROCEDURE IF EXISTS get_books; DROP PROCEDURE IF EXISTS echo_xml; DROP PROCEDURE IF EXISTS get_book_by_id; @@ -45,6 +51,20 @@ DROP TABLE IF EXISTS brokers; DROP TABLE IF EXISTS type_table; DROP TABLE IF EXISTS vector_type_table; DROP TABLE IF EXISTS vector_owners; +DROP TABLE IF EXISTS geometry_type_table; +DROP TABLE IF EXISTS hierarchyid_pk_table; +DROP TABLE IF EXISTS hierarchyid_composite_pk_table; +DROP TABLE IF EXISTS hierarchyid_unique_table; +DROP TABLE IF EXISTS unique_key_geometry_table; +DROP TABLE IF EXISTS decimal_identity_geometry_table; +DROP TABLE IF EXISTS pk_and_unique_geometry_table; +DROP TABLE IF EXISTS geometry_linking_table; +-- System versioning has to be released before the temporal table can be dropped. +IF OBJECT_ID('dbo.temporal_geometry_type_table', 'U') IS NOT NULL + AND OBJECTPROPERTY(OBJECT_ID('dbo.temporal_geometry_type_table'), 'TableTemporalType') = 2 + ALTER TABLE temporal_geometry_type_table SET (SYSTEM_VERSIONING = OFF); +DROP TABLE IF EXISTS temporal_geometry_type_table; +DROP TABLE IF EXISTS temporal_geometry_type_table_history; DROP TABLE IF EXISTS profiles; DROP TABLE IF EXISTS xml_profiles; DROP TABLE IF EXISTS trees; @@ -254,6 +274,77 @@ CREATE TABLE vector_type_table( CONSTRAINT FK_vector_type_table_owner FOREIGN KEY (owner_id) REFERENCES vector_owners(id) ON DELETE CASCADE ); +CREATE TABLE geometry_type_table( + id int IDENTITY(5001, 1) PRIMARY KEY, + name varchar(100) NOT NULL, + geom geometry NULL +); + +-- The period columns are HIDDEN, so SELECT * doesn't return them and they must stay out of the exposed columns. +CREATE TABLE temporal_geometry_type_table( + id int NOT NULL PRIMARY KEY, + name varchar(100) NOT NULL, + geom geometry NULL, + valid_from datetime2 GENERATED ALWAYS AS ROW START HIDDEN NOT NULL, + valid_to datetime2 GENERATED ALWAYS AS ROW END HIDDEN NOT NULL, + PERIOD FOR SYSTEM_TIME (valid_from, valid_to) +) +WITH (SYSTEM_VERSIONING = ON (HISTORY_TABLE = dbo.temporal_geometry_type_table_history)); + +-- The primary key has an unsupported type, so the object needs source.key-fields to be exposed. +CREATE TABLE hierarchyid_pk_table( + node hierarchyid NOT NULL PRIMARY KEY, + name varchar(100) NOT NULL +); + +-- Same, with the unsupported column as one member of a composite key. +CREATE TABLE hierarchyid_composite_pk_table( + tenant_id int NOT NULL, + node hierarchyid NOT NULL, + name varchar(100) NOT NULL, + CONSTRAINT PK_hierarchyid_composite_pk_table PRIMARY KEY (tenant_id, node) +); + +-- No primary key, and the only unique key has an unsupported type, so the object needs source.key-fields. +CREATE TABLE hierarchyid_unique_table( + id int NOT NULL, + node hierarchyid NOT NULL, + name varchar(100) NOT NULL, + CONSTRAINT UQ_hierarchyid_unique_table_node UNIQUE (node) +); + +-- No primary key, but a unique key over a supported non-null column, which is inferred as the key. +CREATE TABLE unique_key_geometry_table( + code varchar(20) NOT NULL, + name varchar(100) NOT NULL, + geom geometry NULL, + CONSTRAINT UQ_unique_key_geometry_table_code UNIQUE (code) +); + +-- A primary key and a separate unique key, for a view that selects only the unique one. +CREATE TABLE pk_and_unique_geometry_table( + id int IDENTITY(1, 1) NOT NULL PRIMARY KEY, + code varchar(20) NOT NULL, + name varchar(100) NOT NULL, + geom geometry NULL, + CONSTRAINT UQ_pk_and_unique_geometry_table_code UNIQUE (code) +); + +-- A decimal identity column, whose type and identity must survive skipping the geometry column. +CREATE TABLE decimal_identity_geometry_table( + id decimal(18, 0) IDENTITY(1, 1) NOT NULL PRIMARY KEY, + name varchar(100) NOT NULL, + geom geometry NULL +); + +-- A linking table with an unsupported column, for relationships that go through it. +CREATE TABLE geometry_linking_table( + geometry_id int NOT NULL, + code varchar(20) NOT NULL, + geom geometry NULL, + CONSTRAINT PK_geometry_linking_table PRIMARY KEY (geometry_id, code) +); + CREATE TABLE profiles( id int IDENTITY(5001, 1) PRIMARY KEY, metadata json NULL @@ -663,6 +754,42 @@ VALUES (7, CAST('[' + ( ) + ']' AS vector(1998))); SET IDENTITY_INSERT vector_type_table OFF +SET IDENTITY_INSERT geometry_type_table ON +INSERT INTO geometry_type_table(id, name, geom) +VALUES + (1, 'point', geometry::STGeomFromText('POINT(1 2)', 0)), + (2, 'null geometry', NULL); +SET IDENTITY_INSERT geometry_type_table OFF + +INSERT INTO temporal_geometry_type_table(id, name, geom) +VALUES + (1, 'point', geometry::STGeomFromText('POINT(1 2)', 0)), + (2, 'null geometry', NULL); + +INSERT INTO hierarchyid_pk_table(node, name) +VALUES (hierarchyid::Parse('/1/'), 'root'); + +INSERT INTO hierarchyid_composite_pk_table(tenant_id, node, name) +VALUES (1, hierarchyid::Parse('/1/'), 'root'); + +INSERT INTO hierarchyid_unique_table(id, node, name) +VALUES (1, hierarchyid::Parse('/1/'), 'root'); + +INSERT INTO unique_key_geometry_table(code, name, geom) +VALUES + ('CAR-001', 'point', geometry::STGeomFromText('POINT(1 2)', 0)), + ('CAR-002', 'null geometry', NULL); + +INSERT INTO pk_and_unique_geometry_table(code, name, geom) +VALUES + ('CAR-101', 'point', geometry::STGeomFromText('POINT(1 2)', 0)), + ('CAR-102', 'null geometry', NULL); + +INSERT INTO decimal_identity_geometry_table(name, geom) +VALUES + ('point', geometry::STGeomFromText('POINT(1 2)', 0)), + ('null geometry', NULL); + SET IDENTITY_INSERT profiles ON INSERT INTO profiles(id, metadata) VALUES @@ -772,6 +899,17 @@ EXEC('CREATE VIEW books_publishers_view_composite_insertable as SELECT books.id, books.title, publishers.name, books.publisher_id FROM dbo.books,dbo.publishers where publishers.id = books.publisher_id'); +EXEC('CREATE VIEW geometry_only_view AS SELECT geom FROM dbo.geometry_type_table'); +-- A view's key is inferred from the underlying table. +EXEC('CREATE VIEW geometry_all_view AS SELECT id, name, geom FROM dbo.geometry_type_table'); +-- One member of the underlying composite key has an unsupported type, and the other alone isn't a key. +EXEC('CREATE VIEW hierarchyid_composite_view AS SELECT tenant_id, name, node FROM dbo.hierarchyid_composite_pk_table'); +-- Omits the underlying primary key, so no key is inferred even though the unique key is selected. +EXEC('CREATE VIEW unique_without_pk_view AS SELECT code, name, geom FROM dbo.pk_and_unique_geometry_table'); +-- A join whose second table contributes only the unsupported column. +EXEC('CREATE VIEW join_geometry_view AS SELECT a.id, a.name, b.geom FROM dbo.geometry_type_table a JOIN dbo.unique_key_geometry_table b ON b.name = a.name'); +-- A self-join, which selects the key of only one of the two instances. +EXEC('CREATE VIEW self_join_geometry_view AS SELECT a.id, a.name, b.geom FROM dbo.geometry_type_table a JOIN dbo.geometry_type_table b ON b.name = a.name'); EXEC('CREATE PROCEDURE get_book_by_id @id int AS SELECT * FROM dbo.books WHERE id = @id'); diff --git a/src/Service.Tests/Snapshots/ConfigurationTests.TestReadingRuntimeConfigForMsSql.verified.txt b/src/Service.Tests/Snapshots/ConfigurationTests.TestReadingRuntimeConfigForMsSql.verified.txt index 2538b016ff..839b7faee1 100644 --- a/src/Service.Tests/Snapshots/ConfigurationTests.TestReadingRuntimeConfigForMsSql.verified.txt +++ b/src/Service.Tests/Snapshots/ConfigurationTests.TestReadingRuntimeConfigForMsSql.verified.txt @@ -1910,6 +1910,64 @@ } } }, + { + GeometryType: { + Source: { + Object: geometry_type_table, + Type: Table + }, + GraphQL: { + Singular: GeometryType, + Plural: GeometryTypes, + Enabled: true + }, + Rest: { + Enabled: true + }, + Permissions: [ + { + Role: anonymous, + Actions: [ + { + Action: Read + } + ] + } + ] + } + }, + { + TemporalGeometryType: { + Source: { + Object: temporal_geometry_type_table, + Type: Table + }, + GraphQL: { + Singular: TemporalGeometryType, + Plural: TemporalGeometryTypes, + Enabled: true + }, + Rest: { + Enabled: true + }, + Permissions: [ + { + Role: anonymous, + Actions: [ + { + Action: Create + }, + { + Action: Read + }, + { + Action: Update + } + ] + } + ] + } + }, { Profile: { Source: { diff --git a/src/Service.Tests/SqlTests/GraphQLQueryTests/MsSqlGraphQLQueryTests.cs b/src/Service.Tests/SqlTests/GraphQLQueryTests/MsSqlGraphQLQueryTests.cs index a9578137b7..87da704545 100644 --- a/src/Service.Tests/SqlTests/GraphQLQueryTests/MsSqlGraphQLQueryTests.cs +++ b/src/Service.Tests/SqlTests/GraphQLQueryTests/MsSqlGraphQLQueryTests.cs @@ -807,6 +807,38 @@ public async Task TestStoredProcedureQueryWithNoDefaultInConfig() SqlTestHelper.TestForErrorInGraphQLResponse(result.ToString(), message: "The argument `id` is required."); } + /// + /// A table with a geometry column, which the data provider can't read, is queryable without that column. + /// + [TestMethod] + public async Task QueryTableWithUnsupportedColumnType() + { + string graphQLQuery = @"{ + geometryTypes(orderBy: { id: ASC }) { + items { + id + name + } + } + }"; + + JsonElement actual = await ExecuteGraphQLRequestAsync(graphQLQuery, "geometryTypes", isAuthenticated: false); + string expected = await GetDatabaseResultAsync( + "SELECT [id], [name] FROM geometry_type_table ORDER BY [id] FOR JSON PATH, INCLUDE_NULL_VALUES"); + SqlTestHelper.PerformTestEqualJsonStrings(expected, actual.GetProperty("items").ToString()); + + graphQLQuery = @"{ + geometryType_by_pk(id: 1) { + geom + } + }"; + + JsonElement error = await ExecuteGraphQLRequestAsync(graphQLQuery, "geometryType_by_pk", isAuthenticated: false); + SqlTestHelper.TestForErrorInGraphQLResponse( + error.ToString(), + message: "The field `geom` does not exist on the type `GeometryType`."); + } + /// /// Test to check GraphQL support for aggregations with aliases. /// This test verifies that the SQL query results are correctly mapped to the expected GraphQL format. diff --git a/src/Service.Tests/SqlTests/RestApiTests/Find/MsSqlFindApiTests.cs b/src/Service.Tests/SqlTests/RestApiTests/Find/MsSqlFindApiTests.cs index 651bfe812a..c284930757 100644 --- a/src/Service.Tests/SqlTests/RestApiTests/Find/MsSqlFindApiTests.cs +++ b/src/Service.Tests/SqlTests/RestApiTests/Find/MsSqlFindApiTests.cs @@ -591,6 +591,16 @@ public class MsSqlFindApiTests : FindApiTestBase $"SELECT * FROM { _integrationBrokenMappingTable } " + $"WHERE habitat = 'forestland' " + $"FOR JSON PATH, INCLUDE_NULL_VALUES" + }, + { + "FindOnTableWithUnsupportedColumnType", + $"SELECT [id], [name] FROM geometry_type_table " + + $"ORDER BY [id] FOR JSON PATH, INCLUDE_NULL_VALUES" + }, + { + "FindByIdOnTableWithUnsupportedColumnType", + $"SELECT [id], [name] FROM geometry_type_table " + + $"WHERE [id] = 1 FOR JSON PATH, INCLUDE_NULL_VALUES, WITHOUT_ARRAY_WRAPPER" } }; #region Test Fixture Setup @@ -672,6 +682,34 @@ await SetupAndRunRestApiTest( #region RestApiTests Outliers + /// + /// A table with a geometry column, which the data provider can't read, is served without that column. + /// + [TestMethod] + public async Task FindOnTableWithUnsupportedColumnType() + { + await SetupAndRunRestApiTest( + primaryKeyRoute: string.Empty, + queryString: string.Empty, + entityNameOrPath: "GeometryType", + sqlQuery: GetQuery(nameof(FindOnTableWithUnsupportedColumnType))); + + await SetupAndRunRestApiTest( + primaryKeyRoute: "id/1", + queryString: string.Empty, + entityNameOrPath: "GeometryType", + sqlQuery: GetQuery("FindByIdOnTableWithUnsupportedColumnType")); + + await SetupAndRunRestApiTest( + primaryKeyRoute: "id/1", + queryString: "?$select=geom", + entityNameOrPath: "GeometryType", + sqlQuery: string.Empty, + exceptionExpected: true, + expectedErrorMessage: "Invalid field to be returned requested: geom", + expectedStatusCode: HttpStatusCode.BadRequest); + } + /// /// Tests we ensure that an invalid query in the EasyAuth header /// retruns a successful request without executing the invalid query. diff --git a/src/Service.Tests/SqlTests/RestApiTests/Put/MsSqlPutApiTests.cs b/src/Service.Tests/SqlTests/RestApiTests/Put/MsSqlPutApiTests.cs index e9ea04a971..8f27d9ef0e 100644 --- a/src/Service.Tests/SqlTests/RestApiTests/Put/MsSqlPutApiTests.cs +++ b/src/Service.Tests/SqlTests/RestApiTests/Put/MsSqlPutApiTests.cs @@ -125,6 +125,12 @@ public class MsSqlPutApiTests : PutApiTestBase $"AND [copies_sold] = 100 AND [last_sold_on] is NULL AND [last_sold_on_date] is NULL AND [row_version] is NOT NULL " + $"FOR JSON PATH, INCLUDE_NULL_VALUES, WITHOUT_ARRAY_WRAPPER" }, + { + "PutOneUpdateWithHiddenPeriodColumns", + $"SELECT [id], [name] FROM temporal_geometry_type_table " + + $"WHERE [id] = 1 AND [name] = 'Renamed collar' " + + $"FOR JSON PATH, INCLUDE_NULL_VALUES, WITHOUT_ARRAY_WRAPPER" + }, { "PutOneInsertWithRowversionFieldMissingFromRequestBody", $"SELECT * FROM {_tableWithReadOnlyFields } WHERE [id] = 2 AND [book_name] = 'Best seller' " + @@ -327,9 +333,32 @@ public async Task PutOneInViewBadRequest() await base.PutOneInViewBadRequest(expectedErrorMessage); } + /// + /// PUT nulls the writable columns a request leaves out, so on this temporal table, which also has a skipped + /// geometry column, it succeeds only if the HIDDEN period columns stay out of the entity's columns. + /// + [TestMethod] + public async Task PutOneWithHiddenPeriodColumnsInTemporalTable() + { + string requestBody = @" + { + ""name"": ""Renamed collar"" + }"; + + await SetupAndRunRestApiTest( + primaryKeyRoute: "id/1", + queryString: null, + entityNameOrPath: "TemporalGeometryType", + sqlQuery: GetQuery("PutOneUpdateWithHiddenPeriodColumns"), + operationType: EntityActionOperation.Upsert, + requestBody: requestBody, + expectedStatusCode: HttpStatusCode.OK + ); + } + /// /// Test to validate successful execution of a request when a rowversion field is missing from the request body. - /// In such a case, we don't attempt to NULL out rowversion field (as per PUT semantics) but instead skip updating/inserting the field. + /// In such a case, we don't attempt to NULL out rowversion field (as per PUT semantics) but instead skip updating/inserting the field. /// [TestMethod] public async Task PutOneWithRowversionFieldMissingFromRequestBody() diff --git a/src/Service.Tests/UnitTests/SqlMetadataProviderUnsupportedColumnTypeTests.cs b/src/Service.Tests/UnitTests/SqlMetadataProviderUnsupportedColumnTypeTests.cs new file mode 100644 index 0000000000..ade450b92b --- /dev/null +++ b/src/Service.Tests/UnitTests/SqlMetadataProviderUnsupportedColumnTypeTests.cs @@ -0,0 +1,367 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Text.Json.Nodes; +using System.Threading.Tasks; +using Azure.DataApiBuilder.Config.DatabasePrimitives; +using Azure.DataApiBuilder.Config.ObjectModel; +using Azure.DataApiBuilder.Core.Services; +using Azure.DataApiBuilder.Service.Exceptions; +using Azure.DataApiBuilder.Service.Tests.SqlTests; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Azure.DataApiBuilder.Service.Tests.UnitTests +{ + /// + /// Schema discovery over MSSQL objects with columns the data provider has no CLR type for, such as geometry + /// and hierarchyid. Those columns are skipped and the rest of the object is inferred as the data adapter would. + /// Entities are declared in memory because several fixtures in DatabaseSchema-MsSql.sql fail by design. + /// + [TestClass, TestCategory(TestCategory.MSSQL)] + public class SqlMetadataProviderUnsupportedColumnTypeTests : SqlTestBase + { + private const string ENTITY_NAME = "TestEntity"; + private const string RELATED_ENTITY_NAME = "Related"; + + [ClassInitialize] + public static async Task SetupAsync(TestContext context) + { + DatabaseEngine = TestCategory.MSSQL; + TestHelper.SetupDatabaseEnvironment(DatabaseEngine); + SetUpSQLMetadataProvider(TestHelper.GenerateInMemoryRuntimeConfigProvider(SqlTestHelper.SetupRuntimeConfig())); + await ResetDbStateAsync(); + } + + [TestInitialize] + public void TestInitialize() + { + DatabaseEngine = TestCategory.MSSQL; + TestHelper.SetupDatabaseEnvironment(DatabaseEngine); + } + + [TestCleanup] + public void TestCleanup() + { + TestHelper.UnsetAllDABEnvironmentVariables(); + } + + [TestMethod] + public async Task UnsupportedColumnIsSkipped() + { + SourceDefinition definition = await InferSourceDefinitionAsync(CreateEntity("dbo.geometry_type_table", EntitySourceType.Table)); + + CollectionAssert.AreEqual(new[] { "id", "name" }, definition.Columns.Keys.ToArray()); + CollectionAssert.AreEqual(new[] { "id" }, definition.PrimaryKey); + Assert.IsTrue(definition.Columns["id"].IsAutoGenerated, "The identity column should stay auto-generated."); + Assert.IsFalse(definition.Columns["name"].IsNullable, "Nullability should match the database."); + } + + [TestMethod] + public async Task IdentityColumnKeepsItsTypeWhenColumnsAreSkipped() + { + SourceDefinition definition = await InferSourceDefinitionAsync( + CreateEntity("dbo.decimal_identity_geometry_table", EntitySourceType.Table)); + + Assert.AreEqual(typeof(decimal), definition.Columns["id"].SystemType); + Assert.IsTrue(definition.Columns["id"].IsAutoGenerated); + } + + [TestMethod] + public async Task HiddenPeriodColumnsAreNotInferred() + { + SourceDefinition definition = await InferSourceDefinitionAsync( + CreateEntity("dbo.temporal_geometry_type_table", EntitySourceType.Table)); + + CollectionAssert.AreEqual(new[] { "id", "name" }, definition.Columns.Keys.ToArray()); + } + + [TestMethod] + public async Task ColumnReadableUnderLegacyTypeSystemIsKept() + { + SourceDefinition definition = await InferSourceDefinitionAsync( + CreateEntity("dbo.geometry_type_table", EntitySourceType.Table), + connectionStringOptions: "Type System Version=SQL Server 2005;"); + + Assert.AreEqual(typeof(byte[]), definition.Columns["geom"].SystemType); + } + + [DataTestMethod] + [DataRow("dbo.unique_key_geometry_table", EntitySourceType.Table, "code", DisplayName = "Unique key of a table without a primary key")] + [DataRow("dbo.geometry_all_view", EntitySourceType.View, "id", DisplayName = "View that selects the underlying primary key")] + [DataRow("dbo.join_geometry_view", EntitySourceType.View, "id", DisplayName = "View over a join")] + public async Task KeyIsInferredAsTheDataAdapterWould(string databaseObject, EntitySourceType sourceType, string expectedKey) + { + SourceDefinition definition = await InferSourceDefinitionAsync(CreateEntity(databaseObject, sourceType)); + + CollectionAssert.AreEqual(new[] { expectedKey }, definition.PrimaryKey); + } + + [DataTestMethod] + [DataRow("dbo.hierarchyid_unique_table", EntitySourceType.Table, "id", DisplayName = "Table whose unique key is unsupported")] + [DataRow("dbo.hierarchyid_composite_view", EntitySourceType.View, "tenant_id", DisplayName = "View whose key is partly unsupported")] + [DataRow("dbo.unique_without_pk_view", EntitySourceType.View, "code", DisplayName = "View that omits the underlying primary key")] + public async Task ConfiguredKeyIsUsed(string databaseObject, EntitySourceType sourceType, string keyField) + { + SourceDefinition definition = await InferSourceDefinitionAsync( + CreateEntity(databaseObject, sourceType, keyFields: new[] { keyField })); + + CollectionAssert.AreEqual(new[] { keyField }, definition.PrimaryKey); + } + + [DataTestMethod] + [DataRow("dbo.hierarchyid_pk_table", EntitySourceType.Table, DisplayName = "Primary key")] + [DataRow("dbo.hierarchyid_composite_pk_table", EntitySourceType.Table, DisplayName = "Member of a composite primary key")] + [DataRow("dbo.hierarchyid_unique_table", EntitySourceType.Table, DisplayName = "Unique key of a table without a primary key")] + [DataRow("dbo.hierarchyid_composite_view", EntitySourceType.View, DisplayName = "Member of a view's key")] + public async Task UnsupportedKeyColumnFailsInitialization(string databaseObject, EntitySourceType sourceType) + { + DataApiBuilderException exception = await InitializeExpectingFailureAsync(CreateEntity(databaseObject, sourceType)); + + StringAssert.Contains(exception.Message, "has no usable primary key: key column(s) node (hierarchyid)"); + } + + [DataTestMethod] + [DataRow("dbo.unique_without_pk_view", DisplayName = "View that omits the underlying primary key")] + [DataRow("dbo.self_join_geometry_view", DisplayName = "View over a self-join")] + public async Task ViewWithoutInferableKeyRequiresKeyFields(string databaseObject) + { + DataApiBuilderException exception = await InitializeExpectingFailureAsync(CreateEntity(databaseObject, EntitySourceType.View)); + + StringAssert.Contains(exception.Message, "Primary key not configured"); + } + + [TestMethod] + public async Task ObjectWithOnlyUnsupportedColumnsFailsInitialization() + { + DataApiBuilderException exception = await InitializeExpectingFailureAsync( + CreateEntity("dbo.geometry_only_view", EntitySourceType.View, keyFields: new[] { "geom" })); + + StringAssert.Contains(exception.Message, "every column has a data type that is not supported: geom (geometry)"); + } + + [DataTestMethod] + [DataRow("source.key-fields", "the primary key")] + [DataRow("mappings", "mappings")] + [DataRow("fields", "fields")] + [DataRow("fields.include", "fields.include of role 'anonymous'")] + [DataRow("database policy", "the database policy of role 'anonymous'")] + [DataRow("source.fields", "source.fields of relationship 'related'")] + [DataRow("source.fields with a matching alias", "source.fields of relationship 'related'")] + [DataRow("target.fields", "target.fields of relationship 'related'")] + [DataRow("linking.source.fields", "linking.source.fields of relationship 'related'")] + [DataRow("linking.target.fields", "linking.target.fields of relationship 'related'")] + public async Task ReferenceToUnsupportedColumnFailsInitialization(string reference, string expectedSection) + { + Entity entity = CreateEntity("dbo.geometry_type_table", EntitySourceType.Table); + entity = reference switch + { + "source.key-fields" => entity with { Source = entity.Source with { KeyFields = new[] { "geom" } } }, + "mappings" => entity with { Mappings = new() { { "geom", "location" } } }, + "fields" => entity with { Fields = new() { new FieldMetadata { Name = "geom", Alias = "location" } } }, + "fields.include" => entity with { Permissions = CreatePermissions(fields: new(Exclude: new(), Include: new() { "id", "geom" })) }, + "database policy" => entity with { Permissions = CreatePermissions(policy: new(Database: "@item.geom ne null")) }, + "source.fields" => entity with { Relationships = CreateRelationship(sourceField: "geom", targetField: "code") }, + // Relationship fields name backing columns, so an alias with the same name doesn't make the reference valid. + "source.fields with a matching alias" => entity with + { + Fields = new() { new FieldMetadata { Name = "name", Alias = "geom" } }, + Relationships = CreateRelationship(sourceField: "geom", targetField: "code") + }, + "target.fields" => entity with { Relationships = CreateRelationship(sourceField: "name", targetField: "geom") }, + "linking.source.fields" => entity with { Relationships = CreateLinkingRelationship(linkingSourceField: "geom", linkingTargetField: "code") }, + "linking.target.fields" => entity with { Relationships = CreateLinkingRelationship(linkingSourceField: "geometry_id", linkingTargetField: "geom") }, + _ => throw new ArgumentOutOfRangeException(nameof(reference)) + }; + + DataApiBuilderException exception = await InitializeEntitiesExpectingFailureAsync(new() + { + { ENTITY_NAME, entity }, + { RELATED_ENTITY_NAME, CreateEntity("dbo.unique_key_geometry_table", EntitySourceType.Table, name: RELATED_ENTITY_NAME) } + }); + + StringAssert.Contains( + exception.Message, + $"Entity '{ENTITY_NAME}': {expectedSection} references column 'geom', whose data type 'geometry' is not supported."); + } + + [DataTestMethod] + [DataRow("@item.name ne '@item.geom'", null, DisplayName = "Reference inside a string literal")] + [DataRow("@item.name ne 'it''s @item.geom'", null, DisplayName = "Reference after an escaped quote inside a string literal")] + [DataRow("@item.geom ne 'x'", "geom", DisplayName = "Alias of a supported column")] + public async Task PolicyNotReferencingUnsupportedColumnIsAccepted(string policy, string nameAlias) + { + Entity entity = CreateEntity("dbo.geometry_type_table", EntitySourceType.Table) with + { + Fields = nameAlias is null ? null : new() { new FieldMetadata { Name = "name", Alias = nameAlias } }, + Permissions = CreatePermissions(policy: new(Database: policy)) + }; + + SourceDefinition definition = await InferSourceDefinitionAsync(entity); + + Assert.IsFalse(definition.Columns.ContainsKey("geom")); + } + + [TestMethod] + public async Task EntitiesNamingTheSameObjectInDifferentCaseShareItsMetadata() + { + Entity lower = CreateEntity("dbo.geometry_type_table", EntitySourceType.Table, name: "Lower"); + Entity upper = CreateEntity("dbo.GEOMETRY_TYPE_TABLE", EntitySourceType.Table, name: "Upper"); + + await InitializeMetadataProviderAsync(new() { { "Lower", lower }, { "Upper", upper } }); + Assert.IsTrue(_sqlMetadataProvider.GetSourceDefinition("Upper").Columns["id"].IsAutoGenerated); + + DataApiBuilderException exception = await InitializeEntitiesExpectingFailureAsync(new() + { + { "Lower", lower }, + { "Upper", upper with { Mappings = new() { { "geom", "location" } } } } + }); + StringAssert.Contains(exception.Message, "Entity 'Upper': mappings references column 'geom'"); + } + + [TestMethod] + public async Task LinkingObjectWithUnsupportedColumnIsLoaded() + { + Entity entity = CreateEntity("dbo.geometry_type_table", EntitySourceType.Table) with + { + Relationships = CreateLinkingRelationship(linkingSourceField: "geometry_id", linkingTargetField: "code") + }; + + await InitializeMetadataProviderAsync(new() + { + { ENTITY_NAME, entity }, + { RELATED_ENTITY_NAME, CreateEntity("dbo.unique_key_geometry_table", EntitySourceType.Table, name: RELATED_ENTITY_NAME) } + }); + + string linkingEntityName = _sqlMetadataProvider.GetLinkingEntities().Keys.Single(); + CollectionAssert.AreEqual( + new[] { "geometry_id", "code" }, + _sqlMetadataProvider.GetSourceDefinition(linkingEntityName).Columns.Keys.ToArray()); + } + + [TestMethod] + public async Task AutoentitiesExcludeOnlyTablesWithAnUnsupportedPrimaryKeyColumn() + { + Autoentity autoentity = new( + new AutoentityPatterns( + Include: new[] + { + "dbo.geometry_type_table", + "dbo.decimal_identity_geometry_table", + "dbo.hierarchyid_pk_table", + "dbo.hierarchyid_composite_pk_table" + }, + Exclude: null, + Name: "{object}"), + Template: null, + Permissions: null); + SetUpSQLMetadataProvider(TestHelper.GenerateInMemoryRuntimeConfigProvider(SqlTestHelper.SetupRuntimeConfig())); + + JsonArray tables = await ((MsSqlMetadataProvider)_sqlMetadataProvider).QueryAutoentitiesAsync("autoentity", autoentity); + + CollectionAssert.AreEqual( + new[] { "decimal_identity_geometry_table", "geometry_type_table" }, + tables.Select(table => table["object"].ToString()).ToArray()); + } + + private static Entity CreateEntity( + string databaseObject, + EntitySourceType sourceType, + string[] keyFields = null, + string name = ENTITY_NAME) + { + return new( + Source: new(databaseObject, sourceType, Parameters: null, KeyFields: keyFields), + GraphQL: new(name, $"{name}s"), + Fields: null, + Rest: new(), + Permissions: CreatePermissions(), + Mappings: null, + Relationships: null); + } + + private static EntityPermission[] CreatePermissions(EntityActionFields fields = null, EntityActionPolicy policy = null) + { + return new[] { new EntityPermission("anonymous", new[] { new EntityAction(EntityActionOperation.Read, fields, policy) }) }; + } + + private static Dictionary CreateRelationship(string sourceField, string targetField) + { + return new() + { + { + "related", + new EntityRelationship( + Cardinality: Cardinality.One, + TargetEntity: RELATED_ENTITY_NAME, + SourceFields: new[] { sourceField }, + TargetFields: new[] { targetField }, + LinkingObject: null, + LinkingSourceFields: Array.Empty(), + LinkingTargetFields: Array.Empty()) + } + }; + } + + private static Dictionary CreateLinkingRelationship(string linkingSourceField, string linkingTargetField) + { + return new() + { + { + "related", + new EntityRelationship( + Cardinality: Cardinality.Many, + TargetEntity: RELATED_ENTITY_NAME, + SourceFields: new[] { "id" }, + TargetFields: new[] { "code" }, + LinkingObject: "dbo.geometry_linking_table", + LinkingSourceFields: new[] { linkingSourceField }, + LinkingTargetFields: new[] { linkingTargetField }) + } + }; + } + + private static async Task InferSourceDefinitionAsync(Entity entity, string connectionStringOptions = null) + { + await InitializeMetadataProviderAsync(new() { { ENTITY_NAME, entity } }, connectionStringOptions); + return _sqlMetadataProvider.GetSourceDefinition(ENTITY_NAME); + } + + private static Task InitializeExpectingFailureAsync(Entity entity) + { + return InitializeEntitiesExpectingFailureAsync(new() { { ENTITY_NAME, entity } }); + } + + private static async Task InitializeEntitiesExpectingFailureAsync(Dictionary entities) + { + DataApiBuilderException exception = await Assert.ThrowsExceptionAsync( + () => InitializeMetadataProviderAsync(entities)); + + Assert.AreEqual(HttpStatusCode.ServiceUnavailable, exception.StatusCode); + Assert.AreEqual(DataApiBuilderException.SubStatusCodes.ErrorInInitialization, exception.SubStatusCode); + return exception; + } + + private static async Task InitializeMetadataProviderAsync(Dictionary entities, string connectionStringOptions = null) + { + RuntimeConfig runtimeConfig = SqlTestHelper.SetupRuntimeConfig(); + string connectionString = runtimeConfig.DataSource.ConnectionString; + if (connectionStringOptions is not null) + { + connectionString = $"{connectionString.TrimEnd().TrimEnd(';')};{connectionStringOptions}"; + } + + runtimeConfig = runtimeConfig with + { + DataSource = runtimeConfig.DataSource with { ConnectionString = connectionString }, + Entities = new RuntimeEntities(entities) + }; + + SetUpSQLMetadataProvider(TestHelper.GenerateInMemoryRuntimeConfigProvider(runtimeConfig)); + await _sqlMetadataProvider.InitializeAsync(); + } + } +} diff --git a/src/Service.Tests/dab-config.MsSql.json b/src/Service.Tests/dab-config.MsSql.json index 034f5f87bf..9baa7b5479 100644 --- a/src/Service.Tests/dab-config.MsSql.json +++ b/src/Service.Tests/dab-config.MsSql.json @@ -1990,6 +1990,64 @@ } } }, + "GeometryType": { + "source": { + "object": "geometry_type_table", + "type": "table" + }, + "graphql": { + "enabled": true, + "type": { + "singular": "GeometryType", + "plural": "GeometryTypes" + } + }, + "rest": { + "enabled": true + }, + "permissions": [ + { + "role": "anonymous", + "actions": [ + { + "action": "read" + } + ] + } + ] + }, + "TemporalGeometryType": { + "source": { + "object": "temporal_geometry_type_table", + "type": "table" + }, + "graphql": { + "enabled": true, + "type": { + "singular": "TemporalGeometryType", + "plural": "TemporalGeometryTypes" + } + }, + "rest": { + "enabled": true + }, + "permissions": [ + { + "role": "anonymous", + "actions": [ + { + "action": "create" + }, + { + "action": "read" + }, + { + "action": "update" + } + ] + } + ] + }, "Profile": { "source": { "object": "profiles",