Skip to content

Commit 9d03ad3

Browse files
authored
Fix OpenAPI format for date/datetime/time/uuid/byte types across all data sources
- Add GetOpenApiFormatFromSystemType() to TypeHelper: maps CLR types + DbType to OpenAPI format strings (date-time, date, time, uuid, byte) per OAS 3.0 spec - Update CreateComponentSchema and CreateSpRequestComponentSchema in OpenApiDocumentor to populate the 'format' field using the new helper - Add DateOnly to _systemTypeToJsonDataTypeMap and _systemTypeToDbTypeMap so that PostgreSQL (Npgsql 8) and MySQL (MySqlConnector 2+) date columns work correctly - Add DateOnly to GetEdmPrimitiveTypeFromSystemType for OData $filter support - Add DateOnly to SchemaConverter.GetGraphQLTypeFromSystemType to prevent startup failures when GraphQL is enabled and date columns are present - Add unit tests covering all per-database CLR type / DbType combinations
1 parent e03f621 commit 9d03ad3

4 files changed

Lines changed: 122 additions & 0 deletions

File tree

src/Core/Services/OpenAPI/OpenApiDocumentor.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1414,10 +1414,12 @@ private static OpenApiSchema CreateSpRequestComponentSchema(Dictionary<string, P
14141414
string parameter = kvp.Key;
14151415
ParameterDefinition def = kvp.Value;
14161416
string typeMetadata = TypeHelper.GetJsonDataTypeFromSystemType(def.SystemType).ToString().ToLower();
1417+
string? formatMetadata = TypeHelper.GetOpenApiFormatFromSystemType(def.SystemType, def.DbType);
14171418

14181419
properties.Add(parameter, new OpenApiSchema()
14191420
{
14201421
Type = typeMetadata,
1422+
Format = formatMetadata,
14211423
Description = def.Description,
14221424
Default = def.Default is not null ? new OpenApiString(def.Default) : null
14231425
});
@@ -1490,6 +1492,7 @@ private static OpenApiSchema CreateComponentSchema(
14901492
if (dbObject.SourceDefinition.Columns.TryGetValue(backingColumnValue, out ColumnDefinition? columnDef))
14911493
{
14921494
typeMetadata = TypeHelper.GetJsonDataTypeFromSystemType(columnDef.SystemType).ToString().ToLower();
1495+
formatMetadata = TypeHelper.GetOpenApiFormatFromSystemType(columnDef.SystemType, columnDef.DbType) ?? string.Empty;
14931496
}
14941497

14951498
if (entityConfig?.Fields != null)

src/Core/Services/TypeHelper.cs

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ public static class TypeHelper
4646
[typeof(byte[])] = DbType.Binary,
4747
[typeof(TimeOnly)] = DbType.Time,
4848
[typeof(TimeSpan)] = DbType.Time,
49+
[typeof(DateOnly)] = DbType.Date,
4950
[typeof(object)] = DbType.Object
5051
};
5152

@@ -77,6 +78,7 @@ public static class TypeHelper
7778
[typeof(TimeOnly)] = JsonDataType.String,
7879
[typeof(object)] = JsonDataType.Object,
7980
[typeof(DateTime)] = JsonDataType.String,
81+
[typeof(DateOnly)] = JsonDataType.String,
8082
[typeof(DateTimeOffset)] = JsonDataType.String
8183
};
8284

@@ -157,6 +159,7 @@ public static EdmPrimitiveTypeKind GetEdmPrimitiveTypeFromSystemType(Type column
157159
"DateTime" => EdmPrimitiveTypeKind.DateTimeOffset,
158160
"DateTimeOffset" => EdmPrimitiveTypeKind.DateTimeOffset,
159161
"Date" => EdmPrimitiveTypeKind.Date,
162+
"DateOnly" => EdmPrimitiveTypeKind.Date,
160163
"TimeOnly" => EdmPrimitiveTypeKind.TimeOfDay,
161164
"TimeSpan" => EdmPrimitiveTypeKind.TimeOfDay,
162165
_ => throw new ArgumentException($"Column type" +
@@ -203,6 +206,60 @@ public static EdmPrimitiveTypeKind GetEdmPrimitiveTypeFromITypeNode(ITypeNode co
203206
return type;
204207
}
205208

209+
/// <summary>
210+
/// Returns the OpenAPI format string for the given system type and optional DbType.
211+
/// The format provides additional semantic information for string-typed fields in the OpenAPI schema.
212+
/// </summary>
213+
/// <param name="type">CLR type</param>
214+
/// <param name="dbType">Optional DbType used to distinguish date-only (DbType.Date) from date-time types.</param>
215+
/// <seealso cref="https://spec.openapis.org/oas/v3.0.1#data-types"/>
216+
/// <returns>OpenAPI format string (e.g. "date-time", "date", "time", "uuid", "byte"),
217+
/// or null if no standard format applies to the type.</returns>
218+
public static string? GetOpenApiFormatFromSystemType(Type type, DbType? dbType = null)
219+
{
220+
// Resolve underlying type for nullable value types (e.g. DateTime? → DateTime).
221+
Type? nullableUnderlyingType = Nullable.GetUnderlyingType(type);
222+
if (nullableUnderlyingType is not null)
223+
{
224+
type = nullableUnderlyingType;
225+
}
226+
227+
if (type == typeof(DateTime))
228+
{
229+
// DbType.Date corresponds to a date-only SQL type (e.g. SQL Server "date").
230+
return dbType == DbType.Date ? "date" : "date-time";
231+
}
232+
233+
if (type == typeof(DateOnly))
234+
{
235+
// DateOnly is reported by Npgsql 8 (PostgreSQL "date") and MySqlConnector 2+
236+
// (MySQL "DATE") as the native CLR type for date-only columns.
237+
return "date";
238+
}
239+
240+
if (type == typeof(DateTimeOffset))
241+
{
242+
return "date-time";
243+
}
244+
245+
if (type == typeof(TimeOnly) || type == typeof(TimeSpan))
246+
{
247+
return "time";
248+
}
249+
250+
if (type == typeof(Guid))
251+
{
252+
return "uuid";
253+
}
254+
255+
if (type == typeof(byte[]))
256+
{
257+
return "byte";
258+
}
259+
260+
return null;
261+
}
262+
206263
/// <summary>
207264
/// Converts the .NET Framework (System/CLR) type to JsonDataType.
208265
/// Primitive data types in the OpenAPI standard (OAS) are based on the types supported

src/Service.GraphQLBuilder/Sql/SchemaConverter.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -573,6 +573,7 @@ public static string GetGraphQLTypeFromSystemType(Type type)
573573
"Decimal" => DECIMAL_TYPE,
574574
"Boolean" => BOOLEAN_TYPE,
575575
"DateTime" => DATETIME_TYPE,
576+
"DateOnly" => DATETIME_TYPE,
576577
"DateTimeOffset" => DATETIME_TYPE,
577578
"Byte[]" => BYTEARRAY_TYPE,
578579
"TimeOnly" => LOCALTIME_TYPE,

src/Service.Tests/OpenApiDocumentor/CLRtoJsonValueTypeUnitTests.cs

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
#nullable enable
44
using System;
55
using System.Collections.Generic;
6+
using System.Data;
67
using Azure.DataApiBuilder.Core.Services;
78
using Azure.DataApiBuilder.Core.Services.OpenAPI;
89
using Azure.DataApiBuilder.Service.Exceptions;
@@ -105,10 +106,70 @@ private static IEnumerable<object[]> GetTestData_SupportedSystemTypesMapToJsonVa
105106
[DataRow(typeof(Guid?))]
106107
[DataRow(typeof(TimeOnly?))]
107108
[DataRow(typeof(TimeSpan?))]
109+
[DataRow(typeof(DateOnly?))]
108110
[DataTestMethod]
109111
public void ResolveUnderlyingTypeForNullableValueType(Type nullableType)
110112
{
111113
Assert.AreNotEqual(notExpected: JsonDataType.Undefined, actual: TypeHelper.GetJsonDataTypeFromSystemType(nullableType));
112114
Assert.IsNotNull(TypeHelper.GetDbTypeFromSystemType(nullableType));
113115
}
116+
117+
/// <summary>
118+
/// Validates that TypeHelper.GetOpenApiFormatFromSystemType returns the expected
119+
/// OpenAPI format string for each supported CLR type and DbType combination.
120+
/// This covers all supported DAB data sources (MSSQL, PostgreSQL, MySQL):
121+
/// - MSSQL: uses DbType.Date to distinguish SQL date from datetime/datetime2
122+
/// - PostgreSQL (Npgsql 8): date columns report DateOnly as their CLR type
123+
/// - MySQL (MySqlConnector 2+): date columns report DateOnly as their CLR type
124+
/// </summary>
125+
[DataTestMethod]
126+
// date-time: DateTime without DbType (PostgreSQL timestamp, MySQL datetime/timestamp)
127+
[DataRow(typeof(DateTime), null, "date-time",
128+
DisplayName = "DateTime with no DbType → date-time (PostgreSQL timestamp, MySQL datetime)")]
129+
// date-time: DateTime with DateTime DbType (MSSQL datetime, smalldatetime)
130+
[DataRow(typeof(DateTime), DbType.DateTime, "date-time",
131+
DisplayName = "DateTime with DbType.DateTime → date-time (MSSQL datetime/smalldatetime)")]
132+
// date-time: DateTime with DateTime2 DbType (MSSQL datetime2)
133+
[DataRow(typeof(DateTime), DbType.DateTime2, "date-time",
134+
DisplayName = "DateTime with DbType.DateTime2 → date-time (MSSQL datetime2)")]
135+
// date: DateTime with Date DbType (MSSQL date type)
136+
[DataRow(typeof(DateTime), DbType.Date, "date",
137+
DisplayName = "DateTime with DbType.Date → date (MSSQL date)")]
138+
// date: DateOnly (PostgreSQL date via Npgsql 8, MySQL DATE via MySqlConnector 2+)
139+
[DataRow(typeof(DateOnly), null, "date",
140+
DisplayName = "DateOnly with no DbType → date (PostgreSQL date/Npgsql 8, MySQL DATE/MySqlConnector 2+)")]
141+
// date: nullable DateOnly? resolves to DateOnly
142+
[DataRow(typeof(DateOnly?), null, "date",
143+
DisplayName = "DateOnly? → date (nullable date column)")]
144+
// date-time: DateTimeOffset (MSSQL datetimeoffset, PostgreSQL timestamptz)
145+
[DataRow(typeof(DateTimeOffset), null, "date-time",
146+
DisplayName = "DateTimeOffset → date-time (MSSQL datetimeoffset, PostgreSQL timestamptz)")]
147+
// date-time: nullable DateTime? resolves to DateTime
148+
[DataRow(typeof(DateTime?), DbType.DateTime, "date-time",
149+
DisplayName = "DateTime? → date-time (nullable datetime column)")]
150+
// time: TimeOnly (MSSQL time, PostgreSQL time, MySQL time)
151+
[DataRow(typeof(TimeOnly), null, "time",
152+
DisplayName = "TimeOnly → time (MSSQL/PostgreSQL/MySQL time columns)")]
153+
// time: TimeSpan maps to time as well
154+
[DataRow(typeof(TimeSpan), null, "time",
155+
DisplayName = "TimeSpan → time")]
156+
// uuid: Guid (MSSQL uniqueidentifier, PostgreSQL uuid, MySQL char(36))
157+
[DataRow(typeof(Guid), null, "uuid",
158+
DisplayName = "Guid → uuid (MSSQL uniqueidentifier, PostgreSQL uuid)")]
159+
// byte: byte[] (MSSQL binary/varbinary/image/timestamp, PostgreSQL bytea, MySQL binary/varbinary/blob)
160+
[DataRow(typeof(byte[]), null, "byte",
161+
DisplayName = "byte[] → byte (binary/bytea columns)")]
162+
// No format for plain string, int, bool, etc.
163+
[DataRow(typeof(string), null, null,
164+
DisplayName = "string → null (no format)")]
165+
[DataRow(typeof(int), null, null,
166+
DisplayName = "int → null (no format)")]
167+
[DataRow(typeof(bool), null, null,
168+
DisplayName = "bool → null (no format)")]
169+
public void GetOpenApiFormatFromSystemType_ReturnsExpectedFormat(Type type, DbType? dbType, string? expectedFormat)
170+
{
171+
string? actualFormat = TypeHelper.GetOpenApiFormatFromSystemType(type, dbType);
172+
Assert.AreEqual(expectedFormat, actualFormat,
173+
$"Expected OpenAPI format '{expectedFormat ?? "null"}' for type {type.Name} with DbType {dbType?.ToString() ?? "null"}.");
174+
}
114175
}

0 commit comments

Comments
 (0)