Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions config-generators/mssql-commands.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
18 changes: 18 additions & 0 deletions src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -44,6 +45,23 @@ public MsSqlMetadataProvider(
_runtimeConfigProvider = runtimeConfigProvider;
}

/// <summary>
/// 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.
/// </summary>
private static readonly ImmutableHashSet<string> _unsupportedColumnDataTypes =
ImmutableHashSet.Create(
StringComparer.OrdinalIgnoreCase,
"geometry",
"geography",
"hierarchyid");

/// <inheritdoc/>
protected override ImmutableHashSet<string> UnsupportedColumnDataTypes => _unsupportedColumnDataTypes;

public override string GetDefaultSchemaName()
{
return "dbo";
Expand Down
88 changes: 87 additions & 1 deletion src/Core/Services/MetadataProviders/SqlMetadataProvider.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -69,6 +70,17 @@ public abstract class SqlMetadataProvider<ConnectionT, DataAdapterT, CommandT> :

protected const int NUMBER_OF_RESTRICTIONS = 4;

/// <summary>
/// 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
/// <see cref="DbDataAdapter.FillSchema(DataSet, SchemaType)"/> 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.
/// </summary>
protected virtual ImmutableHashSet<string> UnsupportedColumnDataTypes => ImmutableHashSet<string>.Empty;

protected string ConnectionString { get; init; }

protected IQueryBuilder SqlQueryBuilder { get; init; }
Expand Down Expand Up @@ -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()!;
Expand Down Expand Up @@ -1753,6 +1766,9 @@ private async Task ValidateDatabaseConnection()
/// <summary>
/// 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 <see cref="UnsupportedColumnDataTypes"/>.
/// </summary>
private async Task<DataTable> FillSchemaForTableAsync(
string schemaName,
Expand Down Expand Up @@ -1802,14 +1818,84 @@ private async Task<DataTable> FillSchemaForTableAsync(
};

string tableNameWithSchemaPrefix = GetTableNameWithSchemaPrefix(schemaName, tableName);

string projection = await BuildSchemaProjectionAsync(schemaName, tableName);
Comment thread
joymaxnascimento marked this conversation as resolved.

selectCommand.CommandText
= $"SELECT * FROM {tableNameWithSchemaPrefix}";
= $"SELECT {projection} FROM {tableNameWithSchemaPrefix}";
adapterForTable.SelectCommand = selectCommand;

DataTable[] dataTable = adapterForTable.FillSchema(EntitiesDataSet, SchemaType.Source, tableNameWithSchemaPrefix);
return dataTable[0];
}

/// <summary>
/// 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.
/// </summary>
private async Task<string> BuildSchemaProjectionAsync(string schemaName, string tableName)
{
if (UnsupportedColumnDataTypes.Count == 0)
{
return "*";
}

List<string> readableColumns = new();
List<string> 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)));
}

/// <summary>
/// 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
Expand Down
14 changes: 14 additions & 0 deletions src/Service.Tests/DatabaseSchema-MsSql.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
33 changes: 33 additions & 0 deletions src/Service.Tests/UnitTests/SqlMetadataProviderUnitTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -396,6 +396,39 @@ public async Task ValidateInferredRelationshipInfoForMsSql()
ValidateInferredRelationshipInfoForTables();
}

/// <summary>
/// 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.
/// </summary>
[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();
}

/// <summary>
/// Test to validate successful inference of relationship data based on data provided in the config and the metadata
/// collected from the MySql database.
Expand Down
26 changes: 26 additions & 0 deletions src/Service.Tests/dab-config.MsSql.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down