diff --git a/config-generators/mssql-commands.txt b/config-generators/mssql-commands.txt index 277b9878f0..53674ef59c 100644 --- a/config-generators/mssql-commands.txt +++ b/config-generators/mssql-commands.txt @@ -22,6 +22,7 @@ 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 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 stocks_price --config "dab-config.MsSql.json" --source stocks_price --permissions "authenticated:create,read,update,delete" diff --git a/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs b/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs index 20de74a96d..9176b60566 100644 --- a/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs +++ b/src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System.Collections.Immutable; using System.Data; using System.Data.Common; using System.Net; @@ -44,6 +45,23 @@ public MsSqlMetadataProvider( _runtimeConfigProvider = runtimeConfigProvider; } + /// + /// SQL Server CLR user-defined types. Microsoft.Data.SqlClient resolves their CLR type + /// through the Microsoft.SqlServer.Types assembly, which Data API builder does not + /// reference, so the reader reports no type for the column and the data adapter fails. + /// Deliberately limited to the types that cannot be read at all: timestamp, xml and vector + /// columns do resolve to a CLR type and are left untouched. + /// + private static readonly ImmutableHashSet _unsupportedColumnDataTypes = + ImmutableHashSet.Create( + StringComparer.OrdinalIgnoreCase, + "geometry", + "geography", + "hierarchyid"); + + /// + protected override ImmutableHashSet UnsupportedColumnDataTypes => _unsupportedColumnDataTypes; + public override string GetDefaultSchemaName() { return "dbo"; diff --git a/src/Core/Services/MetadataProviders/SqlMetadataProvider.cs b/src/Core/Services/MetadataProviders/SqlMetadataProvider.cs index 9517c41781..d801290044 100644 --- a/src/Core/Services/MetadataProviders/SqlMetadataProvider.cs +++ b/src/Core/Services/MetadataProviders/SqlMetadataProvider.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System.Collections.Immutable; using System.Collections.ObjectModel; using System.Data; using System.Data.Common; @@ -69,6 +70,17 @@ public abstract class SqlMetadataProvider : protected const int NUMBER_OF_RESTRICTIONS = 4; + /// + /// Column data types, as reported by the "Columns" schema collection, that the data + /// provider cannot map to a CLR type. Reading such a column makes + /// fail with + /// "DataReader.GetFieldType(N) returned null", which takes down the whole database object + /// even when the column itself is never exposed. They are therefore left out of the + /// projection used for schema discovery. + /// Empty by default: a provider only lists a type here when it genuinely cannot resolve it. + /// + protected virtual ImmutableHashSet UnsupportedColumnDataTypes => ImmutableHashSet.Empty; + protected string ConnectionString { get; init; } protected IQueryBuilder SqlQueryBuilder { get; init; } @@ -1528,6 +1540,7 @@ private async Task PopulateSourceDefinitionAsync( using DataTableReader reader = new(dataTable); DataTable schemaTable = reader.GetSchemaTable(); RuntimeConfig runtimeConfig = _runtimeConfigProvider.GetConfig(); + foreach (DataRow columnInfoFromAdapter in schemaTable.Rows) { string columnName = columnInfoFromAdapter["ColumnName"].ToString()!; @@ -1753,6 +1766,9 @@ private async Task ValidateDatabaseConnection() /// /// Using a data adapter, obtains the schema of the given table name /// and adds the corresponding DataTable to the entities data set. + /// Columns whose data type the data provider cannot map to a CLR type are left out of the + /// projection, because the data adapter refuses to build a schema mapping for them and the + /// whole object would otherwise be unreachable. See . /// private async Task FillSchemaForTableAsync( string schemaName, @@ -1802,14 +1818,84 @@ private async Task FillSchemaForTableAsync( }; string tableNameWithSchemaPrefix = GetTableNameWithSchemaPrefix(schemaName, tableName); + + string projection = await BuildSchemaProjectionAsync(schemaName, tableName); + selectCommand.CommandText - = $"SELECT * FROM {tableNameWithSchemaPrefix}"; + = $"SELECT {projection} FROM {tableNameWithSchemaPrefix}"; adapterForTable.SelectCommand = selectCommand; DataTable[] dataTable = adapterForTable.FillSchema(EntitiesDataSet, SchemaType.Source, tableNameWithSchemaPrefix); return dataTable[0]; } + /// + /// Builds the projection used to read the schema of a database object. Returns "*" unless + /// the object holds columns whose data type this provider cannot map to a CLR type, in + /// which case those columns are named out of the projection so the rest stays reachable. + /// The column list comes from the "Columns" schema collection, which reads catalog metadata + /// only and therefore never has to materialize the offending type. + /// + private async Task BuildSchemaProjectionAsync(string schemaName, string tableName) + { + if (UnsupportedColumnDataTypes.Count == 0) + { + return "*"; + } + + List readableColumns = new(); + List skippedColumns = new(); + + try + { + DataTable columnsInTable = await GetColumnsAsync(schemaName, tableName); + + foreach (DataRow columnInfo in columnsInTable.Rows) + { + if (columnInfo["COLUMN_NAME"] is not string columnName) + { + continue; + } + + string? dataType = columnInfo["DATA_TYPE"] as string; + + if (dataType is not null && UnsupportedColumnDataTypes.Contains(dataType)) + { + skippedColumns.Add($"{columnName} ({dataType})"); + } + else + { + readableColumns.Add(columnName); + } + } + } + catch (Exception ex) when (ex is not DataApiBuilderException) + { + // The column list is a best-effort optimization: without it the read below behaves + // exactly as it did before, failing loudly if an unsupported type is present. + _logger.LogDebug( + "Unable to enumerate the columns of {schemaName}.{tableName}: {message}", + schemaName, + tableName, + ex.Message); + return "*"; + } + + if (skippedColumns.Count == 0 || readableColumns.Count == 0) + { + return "*"; + } + + _logger.LogWarning( + "Skipping column(s) of {schemaName}.{tableName} whose data type is not supported: {skippedColumns}. " + + "They are not exposed through REST, GraphQL or MCP.", + schemaName, + tableName, + string.Join(", ", skippedColumns)); + + return string.Join(", ", readableColumns.Select(column => SqlQueryBuilder.QuoteIdentifier(column))); + } + /// /// 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/DatabaseSchema-MsSql.sql b/src/Service.Tests/DatabaseSchema-MsSql.sql index 587edb29c3..ce63144076 100644 --- a/src/Service.Tests/DatabaseSchema-MsSql.sql +++ b/src/Service.Tests/DatabaseSchema-MsSql.sql @@ -44,6 +44,7 @@ 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 profiles; DROP TABLE IF EXISTS trees; DROP TABLE IF EXISTS fungi; @@ -252,6 +253,12 @@ 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 +); + CREATE TABLE profiles( id int IDENTITY(5001, 1) PRIMARY KEY, metadata json NULL @@ -656,6 +663,13 @@ 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 + SET IDENTITY_INSERT profiles ON INSERT INTO profiles(id, metadata) VALUES diff --git a/src/Service.Tests/Snapshots/ConfigurationTests.TestReadingRuntimeConfigForMsSql.verified.txt b/src/Service.Tests/Snapshots/ConfigurationTests.TestReadingRuntimeConfigForMsSql.verified.txt index 3c3b8224e4..b0ce94e022 100644 --- a/src/Service.Tests/Snapshots/ConfigurationTests.TestReadingRuntimeConfigForMsSql.verified.txt +++ b/src/Service.Tests/Snapshots/ConfigurationTests.TestReadingRuntimeConfigForMsSql.verified.txt @@ -1910,6 +1910,32 @@ } } }, + { + GeometryType: { + Source: { + Object: geometry_type_table, + Type: Table + }, + GraphQL: { + Singular: GeometryType, + Plural: GeometryTypes, + Enabled: true + }, + Rest: { + Enabled: true + }, + Permissions: [ + { + Role: anonymous, + Actions: [ + { + Action: Read + } + ] + } + ] + } + }, { Profile: { Source: { diff --git a/src/Service.Tests/UnitTests/SqlMetadataProviderUnitTests.cs b/src/Service.Tests/UnitTests/SqlMetadataProviderUnitTests.cs index dd6ad7d27e..c7dbe7d518 100644 --- a/src/Service.Tests/UnitTests/SqlMetadataProviderUnitTests.cs +++ b/src/Service.Tests/UnitTests/SqlMetadataProviderUnitTests.cs @@ -396,6 +396,39 @@ public async Task ValidateInferredRelationshipInfoForMsSql() ValidateInferredRelationshipInfoForTables(); } + /// + /// Test to validate that a table holding a column whose data type the data provider cannot + /// map to a CLR type - here a geometry column - is still usable: metadata inference must + /// succeed and the unsupported column must be absent from the inferred source definition, + /// so it never reaches the OData or GraphQL type maps. + /// The entity places no field restriction, so this covers the column being skipped on the + /// strength of its type alone. + /// + [TestMethod, TestCategory(TestCategory.MSSQL)] + public async Task ValidateUnsupportedColumnTypeIsNotInferred() + { + DatabaseEngine = TestCategory.MSSQL; + await SetupTestFixtureAndInferMetadata(); + + Assert.IsTrue( + _sqlMetadataProvider.GetEntityNamesAndDbObjects().TryGetValue("GeometryType", out DatabaseObject databaseObject), + message: "Metadata inference failed for the entity backed by a table with a geometry column."); + + SourceDefinition sourceDefinition = databaseObject.SourceDefinition; + + Assert.IsTrue( + sourceDefinition.Columns.ContainsKey("id"), + message: "The primary key column is expected in the source definition."); + Assert.IsTrue( + sourceDefinition.Columns.ContainsKey("name"), + message: "A column with a supported data type is expected in the source definition."); + Assert.IsFalse( + sourceDefinition.Columns.ContainsKey("geom"), + message: "A column whose data type cannot be mapped is not expected in the source definition."); + + TestHelper.UnsetAllDABEnvironmentVariables(); + } + /// /// Test to validate successful inference of relationship data based on data provided in the config and the metadata /// collected from the MySql database. diff --git a/src/Service.Tests/dab-config.MsSql.json b/src/Service.Tests/dab-config.MsSql.json index 670f390d4c..fc614d0e86 100644 --- a/src/Service.Tests/dab-config.MsSql.json +++ b/src/Service.Tests/dab-config.MsSql.json @@ -1990,6 +1990,32 @@ } } }, + "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" + } + ] + } + ] + }, "Profile": { "source": { "object": "profiles",