diff --git a/src/Core/Services/OpenAPI/OpenApiDocumentor.cs b/src/Core/Services/OpenAPI/OpenApiDocumentor.cs index 979da52eb6..11b0345eb4 100644 --- a/src/Core/Services/OpenAPI/OpenApiDocumentor.cs +++ b/src/Core/Services/OpenAPI/OpenApiDocumentor.cs @@ -1414,10 +1414,12 @@ private static OpenApiSchema CreateSpRequestComponentSchema(Dictionary EdmPrimitiveTypeKind.DateTimeOffset, "DateTimeOffset" => EdmPrimitiveTypeKind.DateTimeOffset, "Date" => EdmPrimitiveTypeKind.Date, + "DateOnly" => EdmPrimitiveTypeKind.Date, "TimeOnly" => EdmPrimitiveTypeKind.TimeOfDay, "TimeSpan" => EdmPrimitiveTypeKind.TimeOfDay, _ => throw new ArgumentException($"Column type" + @@ -203,6 +206,60 @@ public static EdmPrimitiveTypeKind GetEdmPrimitiveTypeFromITypeNode(ITypeNode co return type; } + /// + /// Returns the OpenAPI format string for the given system type and optional DbType. + /// The format provides additional semantic information for string-typed fields in the OpenAPI schema. + /// + /// CLR type + /// Optional DbType used to distinguish date-only (DbType.Date) from date-time types. + /// + /// OpenAPI format string (e.g. "date-time", "date", "time", "uuid", "byte"), + /// or null if no standard format applies to the type. + public static string? GetOpenApiFormatFromSystemType(Type type, DbType? dbType = null) + { + // Resolve underlying type for nullable value types (e.g. DateTime? → DateTime). + Type? nullableUnderlyingType = Nullable.GetUnderlyingType(type); + if (nullableUnderlyingType is not null) + { + type = nullableUnderlyingType; + } + + if (type == typeof(DateTime)) + { + // DbType.Date corresponds to a date-only SQL type (e.g. SQL Server "date"). + return dbType == DbType.Date ? "date" : "date-time"; + } + + if (type == typeof(DateOnly)) + { + // DateOnly is reported by Npgsql 8 (PostgreSQL "date") and MySqlConnector 2+ + // (MySQL "DATE") as the native CLR type for date-only columns. + return "date"; + } + + if (type == typeof(DateTimeOffset)) + { + return "date-time"; + } + + if (type == typeof(TimeOnly) || type == typeof(TimeSpan)) + { + return "time"; + } + + if (type == typeof(Guid)) + { + return "uuid"; + } + + if (type == typeof(byte[])) + { + return "byte"; + } + + return null; + } + /// /// Converts the .NET Framework (System/CLR) type to JsonDataType. /// Primitive data types in the OpenAPI standard (OAS) are based on the types supported diff --git a/src/Service.GraphQLBuilder/Sql/SchemaConverter.cs b/src/Service.GraphQLBuilder/Sql/SchemaConverter.cs index 622376dc13..847a8b1522 100644 --- a/src/Service.GraphQLBuilder/Sql/SchemaConverter.cs +++ b/src/Service.GraphQLBuilder/Sql/SchemaConverter.cs @@ -573,6 +573,7 @@ public static string GetGraphQLTypeFromSystemType(Type type) "Decimal" => DECIMAL_TYPE, "Boolean" => BOOLEAN_TYPE, "DateTime" => DATETIME_TYPE, + "DateOnly" => DATETIME_TYPE, "DateTimeOffset" => DATETIME_TYPE, "Byte[]" => BYTEARRAY_TYPE, "TimeOnly" => LOCALTIME_TYPE, diff --git a/src/Service.Tests/OpenApiDocumentor/CLRtoJsonValueTypeUnitTests.cs b/src/Service.Tests/OpenApiDocumentor/CLRtoJsonValueTypeUnitTests.cs index c54fe6db3d..b3dd145b5e 100644 --- a/src/Service.Tests/OpenApiDocumentor/CLRtoJsonValueTypeUnitTests.cs +++ b/src/Service.Tests/OpenApiDocumentor/CLRtoJsonValueTypeUnitTests.cs @@ -3,6 +3,7 @@ #nullable enable using System; using System.Collections.Generic; +using System.Data; using Azure.DataApiBuilder.Core.Services; using Azure.DataApiBuilder.Core.Services.OpenAPI; using Azure.DataApiBuilder.Service.Exceptions; @@ -105,10 +106,70 @@ private static IEnumerable GetTestData_SupportedSystemTypesMapToJsonVa [DataRow(typeof(Guid?))] [DataRow(typeof(TimeOnly?))] [DataRow(typeof(TimeSpan?))] + [DataRow(typeof(DateOnly?))] [DataTestMethod] public void ResolveUnderlyingTypeForNullableValueType(Type nullableType) { Assert.AreNotEqual(notExpected: JsonDataType.Undefined, actual: TypeHelper.GetJsonDataTypeFromSystemType(nullableType)); Assert.IsNotNull(TypeHelper.GetDbTypeFromSystemType(nullableType)); } + + /// + /// Validates that TypeHelper.GetOpenApiFormatFromSystemType returns the expected + /// OpenAPI format string for each supported CLR type and DbType combination. + /// This covers all supported DAB data sources (MSSQL, PostgreSQL, MySQL): + /// - MSSQL: uses DbType.Date to distinguish SQL date from datetime/datetime2 + /// - PostgreSQL (Npgsql 8): date columns report DateOnly as their CLR type + /// - MySQL (MySqlConnector 2+): date columns report DateOnly as their CLR type + /// + [DataTestMethod] + // date-time: DateTime without DbType (PostgreSQL timestamp, MySQL datetime/timestamp) + [DataRow(typeof(DateTime), null, "date-time", + DisplayName = "DateTime with no DbType → date-time (PostgreSQL timestamp, MySQL datetime)")] + // date-time: DateTime with DateTime DbType (MSSQL datetime, smalldatetime) + [DataRow(typeof(DateTime), DbType.DateTime, "date-time", + DisplayName = "DateTime with DbType.DateTime → date-time (MSSQL datetime/smalldatetime)")] + // date-time: DateTime with DateTime2 DbType (MSSQL datetime2) + [DataRow(typeof(DateTime), DbType.DateTime2, "date-time", + DisplayName = "DateTime with DbType.DateTime2 → date-time (MSSQL datetime2)")] + // date: DateTime with Date DbType (MSSQL date type) + [DataRow(typeof(DateTime), DbType.Date, "date", + DisplayName = "DateTime with DbType.Date → date (MSSQL date)")] + // date: DateOnly (PostgreSQL date via Npgsql 8, MySQL DATE via MySqlConnector 2+) + [DataRow(typeof(DateOnly), null, "date", + DisplayName = "DateOnly with no DbType → date (PostgreSQL date/Npgsql 8, MySQL DATE/MySqlConnector 2+)")] + // date: nullable DateOnly? resolves to DateOnly + [DataRow(typeof(DateOnly?), null, "date", + DisplayName = "DateOnly? → date (nullable date column)")] + // date-time: DateTimeOffset (MSSQL datetimeoffset, PostgreSQL timestamptz) + [DataRow(typeof(DateTimeOffset), null, "date-time", + DisplayName = "DateTimeOffset → date-time (MSSQL datetimeoffset, PostgreSQL timestamptz)")] + // date-time: nullable DateTime? resolves to DateTime + [DataRow(typeof(DateTime?), DbType.DateTime, "date-time", + DisplayName = "DateTime? → date-time (nullable datetime column)")] + // time: TimeOnly (MSSQL time, PostgreSQL time, MySQL time) + [DataRow(typeof(TimeOnly), null, "time", + DisplayName = "TimeOnly → time (MSSQL/PostgreSQL/MySQL time columns)")] + // time: TimeSpan maps to time as well + [DataRow(typeof(TimeSpan), null, "time", + DisplayName = "TimeSpan → time")] + // uuid: Guid (MSSQL uniqueidentifier, PostgreSQL uuid, MySQL char(36)) + [DataRow(typeof(Guid), null, "uuid", + DisplayName = "Guid → uuid (MSSQL uniqueidentifier, PostgreSQL uuid)")] + // byte: byte[] (MSSQL binary/varbinary/image/timestamp, PostgreSQL bytea, MySQL binary/varbinary/blob) + [DataRow(typeof(byte[]), null, "byte", + DisplayName = "byte[] → byte (binary/bytea columns)")] + // No format for plain string, int, bool, etc. + [DataRow(typeof(string), null, null, + DisplayName = "string → null (no format)")] + [DataRow(typeof(int), null, null, + DisplayName = "int → null (no format)")] + [DataRow(typeof(bool), null, null, + DisplayName = "bool → null (no format)")] + public void GetOpenApiFormatFromSystemType_ReturnsExpectedFormat(Type type, DbType? dbType, string? expectedFormat) + { + string? actualFormat = TypeHelper.GetOpenApiFormatFromSystemType(type, dbType); + Assert.AreEqual(expectedFormat, actualFormat, + $"Expected OpenAPI format '{expectedFormat ?? "null"}' for type {type.Name} with DbType {dbType?.ToString() ?? "null"}."); + } }