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",