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