From 5d99718ca23e155e945f63bb45d0710fcf770aed Mon Sep 17 00:00:00 2001 From: Repo Assist Date: Sun, 22 Feb 2026 11:01:26 +0000 Subject: [PATCH 01/10] Add DateOnly/TimeOnly inference support (closes #1461) - Multi-target library projects to netstandard2.0;net8.0 to enable NET6_0_OR_GREATER conditional compilation - Add AsDateOnly/AsTimeOnly to TextConversions with guards against datetime strings being accepted by DateOnly/TimeOnly parsers - Add DateOnly to automatic type inference (matchValue); TimeOnly is NOT auto-inferred (ambiguous with TimeSpan) but available via explicit schema annotation (dateonly?/timeonly? in CSV headers) - Add DateOnly as subtype of DateTime in conversionTable; columns with mixed DateOnly+DateTime values unify to DateTime - Add ConvertDateOnly/ConvertTimeOnly to TextRuntime and JsonRuntime - Add DateOnly/TimeOnly cases to StructuralTypes, ConversionsGenerator, JsonConversionsGenerator, JsonConversions, JsonRuntime, CsvInference - Update tests for new DateOnly inference behaviour Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/FSharp.Data.Csv.Core/CsvInference.fs | 9 +++++- .../FSharp.Data.Csv.Core.fsproj | 2 +- .../ConversionsGenerator.fs | 12 +++++++ .../FSharp.Data.DesignTime.fsproj | 2 +- .../Json/JsonConversionsGenerator.fs | 6 ++++ .../FSharp.Data.Json.Core.fsproj | 2 +- src/FSharp.Data.Json.Core/JsonConversions.fs | 12 +++++++ src/FSharp.Data.Json.Core/JsonRuntime.fs | 14 +++++++++ .../FSharp.Data.Runtime.Utilities.fsproj | 2 +- src/FSharp.Data.Runtime.Utilities/IO.fs | 2 +- .../StructuralInference.fs | 31 +++++++++++++++++-- .../StructuralTypes.fs | 12 +++++++ .../TextConversions.fs | 30 ++++++++++++++++-- .../TextRuntime.fs | 22 +++++++++++++ src/FSharp.Data/FSharp.Data.fsproj | 2 +- .../FSharp.Data.Core.Tests/TextConversions.fs | 27 ++++++++++++++++ tests/FSharp.Data.Tests/CsvProvider.fs | 16 +++++----- tests/FSharp.Data.Tests/HtmlProvider.fs | 4 +-- tests/FSharp.Data.Tests/HtmlProviderList.fs | 4 +-- tests/FSharp.Data.Tests/JsonProvider.fs | 16 +++++----- 20 files changed, 196 insertions(+), 31 deletions(-) diff --git a/src/FSharp.Data.Csv.Core/CsvInference.fs b/src/FSharp.Data.Csv.Core/CsvInference.fs index e862b0a67..911bf3eae 100644 --- a/src/FSharp.Data.Csv.Core/CsvInference.fs +++ b/src/FSharp.Data.Csv.Core/CsvInference.fs @@ -32,7 +32,14 @@ let private nameToTypeForCsv = "datetimeoffset option", (typeof, TypeWrapper.Option) "timespan option", (typeof, TypeWrapper.Option) "guid option", (typeof, TypeWrapper.Option) - "string option", (typeof, TypeWrapper.Option) ] + "string option", (typeof, TypeWrapper.Option) +#if NET6_0_OR_GREATER + "dateonly?", (typeof, TypeWrapper.Nullable) + "timeonly?", (typeof, TypeWrapper.Nullable) + "dateonly option", (typeof, TypeWrapper.Option) + "timeonly option", (typeof, TypeWrapper.Option) +#endif + ] |> dict let private nameAndTypeRegex = diff --git a/src/FSharp.Data.Csv.Core/FSharp.Data.Csv.Core.fsproj b/src/FSharp.Data.Csv.Core/FSharp.Data.Csv.Core.fsproj index d6f065cab..214e21279 100644 --- a/src/FSharp.Data.Csv.Core/FSharp.Data.Csv.Core.fsproj +++ b/src/FSharp.Data.Csv.Core/FSharp.Data.Csv.Core.fsproj @@ -2,7 +2,7 @@ Library - netstandard2.0 + netstandard2.0;net8.0 $(OtherFlags) --warnon:1182 --nowarn:10001 --nowarn:44 true false diff --git a/src/FSharp.Data.DesignTime/CommonProviderImplementation/ConversionsGenerator.fs b/src/FSharp.Data.DesignTime/CommonProviderImplementation/ConversionsGenerator.fs index 71090a64b..007350767 100644 --- a/src/FSharp.Data.DesignTime/CommonProviderImplementation/ConversionsGenerator.fs +++ b/src/FSharp.Data.DesignTime/CommonProviderImplementation/ConversionsGenerator.fs @@ -32,6 +32,12 @@ let getConversionQuotation missingValuesStr cultureStr typ (value: Expr elif typ = typeof then <@@ TextRuntime.ConvertTimeSpan(cultureStr, %value) @@> +#if NET6_0_OR_GREATER + elif typ = typeof then + <@@ TextRuntime.ConvertDateOnly(cultureStr, %value) @@> + elif typ = typeof then + <@@ TextRuntime.ConvertTimeOnly(cultureStr, %value) @@> +#endif elif typ = typeof then <@@ TextRuntime.ConvertGuid(%value) @@> else @@ -58,6 +64,12 @@ let getBackConversionQuotation missingValuesStr cultureStr typ value : Expr elif typ = typeof then <@ TextRuntime.ConvertTimeSpanBack(cultureStr, %%value) @> +#if NET6_0_OR_GREATER + elif typ = typeof then + <@ TextRuntime.ConvertDateOnlyBack(cultureStr, %%value) @> + elif typ = typeof then + <@ TextRuntime.ConvertTimeOnlyBack(cultureStr, %%value) @> +#endif else failwith "getBackConversionQuotation: Unsupported primitive type" diff --git a/src/FSharp.Data.DesignTime/FSharp.Data.DesignTime.fsproj b/src/FSharp.Data.DesignTime/FSharp.Data.DesignTime.fsproj index 3a6aceff3..ab6324947 100755 --- a/src/FSharp.Data.DesignTime/FSharp.Data.DesignTime.fsproj +++ b/src/FSharp.Data.DesignTime/FSharp.Data.DesignTime.fsproj @@ -2,7 +2,7 @@ false - netstandard2.0 + netstandard2.0;net8.0 IS_DESIGNTIME;NO_GENERATIVE;$(DefineConstants) $(OtherFlags) --warnon:1182 --nowarn:44 false diff --git a/src/FSharp.Data.DesignTime/Json/JsonConversionsGenerator.fs b/src/FSharp.Data.DesignTime/Json/JsonConversionsGenerator.fs index 7255167a0..a6e0f3480 100644 --- a/src/FSharp.Data.DesignTime/Json/JsonConversionsGenerator.fs +++ b/src/FSharp.Data.DesignTime/Json/JsonConversionsGenerator.fs @@ -34,6 +34,12 @@ let getConversionQuotation missingValuesStr cultureStr typ (value: Expr elif typ = typeof then <@@ JsonRuntime.ConvertTimeSpan(cultureStr, %value) @@> +#if NET6_0_OR_GREATER + elif typ = typeof then + <@@ JsonRuntime.ConvertDateOnly(cultureStr, %value) @@> + elif typ = typeof then + <@@ JsonRuntime.ConvertTimeOnly(cultureStr, %value) @@> +#endif elif typ = typeof then <@@ JsonRuntime.ConvertGuid(%value) @@> else diff --git a/src/FSharp.Data.Json.Core/FSharp.Data.Json.Core.fsproj b/src/FSharp.Data.Json.Core/FSharp.Data.Json.Core.fsproj index 5b02f1a24..b015d2d04 100644 --- a/src/FSharp.Data.Json.Core/FSharp.Data.Json.Core.fsproj +++ b/src/FSharp.Data.Json.Core/FSharp.Data.Json.Core.fsproj @@ -2,7 +2,7 @@ Library - netstandard2.0 + netstandard2.0;net8.0 $(OtherFlags) --warnon:1182 --nowarn:10001 --nowarn:44 true false diff --git a/src/FSharp.Data.Json.Core/JsonConversions.fs b/src/FSharp.Data.Json.Core/JsonConversions.fs index 93e3f08dc..651400145 100644 --- a/src/FSharp.Data.Json.Core/JsonConversions.fs +++ b/src/FSharp.Data.Json.Core/JsonConversions.fs @@ -85,6 +85,18 @@ type JsonConversions = | JsonValue.String s -> TextConversions.AsTimeSpan cultureInfo s | _ -> None +#if NET6_0_OR_GREATER + static member AsDateOnly cultureInfo = + function + | JsonValue.String s -> TextConversions.AsDateOnly cultureInfo s + | _ -> None + + static member AsTimeOnly cultureInfo = + function + | JsonValue.String s -> TextConversions.AsTimeOnly cultureInfo s + | _ -> None +#endif + static member AsGuid = function | JsonValue.String s -> TextConversions.AsGuid s diff --git a/src/FSharp.Data.Json.Core/JsonRuntime.fs b/src/FSharp.Data.Json.Core/JsonRuntime.fs index 7211737b2..c2c2931a5 100644 --- a/src/FSharp.Data.Json.Core/JsonRuntime.fs +++ b/src/FSharp.Data.Json.Core/JsonRuntime.fs @@ -62,6 +62,16 @@ type JsonRuntime = json |> Option.bind (JsonConversions.AsTimeSpan(TextRuntime.GetCulture cultureStr)) +#if NET6_0_OR_GREATER + static member ConvertDateOnly(cultureStr, json) = + json + |> Option.bind (JsonConversions.AsDateOnly(TextRuntime.GetCulture cultureStr)) + + static member ConvertTimeOnly(cultureStr, json) = + json + |> Option.bind (JsonConversions.AsTimeOnly(TextRuntime.GetCulture cultureStr)) +#endif + static member ConvertGuid(json) = json |> Option.bind JsonConversions.AsGuid @@ -229,6 +239,10 @@ type JsonRuntime = fun json -> (JsonConversions.AsDateTimeOffset cultureInfo json).IsSome | InferedTypeTag.TimeSpan -> JsonConversions.AsTimeSpan(TextRuntime.GetCulture cultureStr) >> Option.isSome | InferedTypeTag.Guid -> JsonConversions.AsGuid >> Option.isSome +#if NET6_0_OR_GREATER + | InferedTypeTag.DateOnly -> JsonConversions.AsDateOnly(TextRuntime.GetCulture cultureStr) >> Option.isSome + | InferedTypeTag.TimeOnly -> JsonConversions.AsTimeOnly(TextRuntime.GetCulture cultureStr) >> Option.isSome +#endif | InferedTypeTag.Collection -> function | JsonValue.Array _ -> true diff --git a/src/FSharp.Data.Runtime.Utilities/FSharp.Data.Runtime.Utilities.fsproj b/src/FSharp.Data.Runtime.Utilities/FSharp.Data.Runtime.Utilities.fsproj index dd959164a..b0004f66c 100644 --- a/src/FSharp.Data.Runtime.Utilities/FSharp.Data.Runtime.Utilities.fsproj +++ b/src/FSharp.Data.Runtime.Utilities/FSharp.Data.Runtime.Utilities.fsproj @@ -2,7 +2,7 @@ Library - netstandard2.0 + netstandard2.0;net8.0 $(OtherFlags) --warnon:1182 --nowarn:10001 --nowarn:44 true false diff --git a/src/FSharp.Data.Runtime.Utilities/IO.fs b/src/FSharp.Data.Runtime.Utilities/IO.fs index c8a650c59..5e9097dc3 100644 --- a/src/FSharp.Data.Runtime.Utilities/IO.fs +++ b/src/FSharp.Data.Runtime.Utilities/IO.fs @@ -133,7 +133,7 @@ let internal logTime (_: string) (_: string) = dummyDisposable #endif -type private FileWatcher(path) = +type private FileWatcher(path: string) = let subscriptions = Dictionary unit>() diff --git a/src/FSharp.Data.Runtime.Utilities/StructuralInference.fs b/src/FSharp.Data.Runtime.Utilities/StructuralInference.fs index acebf69bb..0ca9a763a 100644 --- a/src/FSharp.Data.Runtime.Utilities/StructuralInference.fs +++ b/src/FSharp.Data.Runtime.Utilities/StructuralInference.fs @@ -82,6 +82,9 @@ let private primitiveTypes = typeof typeof ] @ numericTypes +#if NET6_0_OR_GREATER + @ [ typeof; typeof ] +#endif /// Checks whether a type supports unit of measure [] @@ -110,6 +113,12 @@ let typeTag inferredType = InferedTypeTag.TimeSpan elif typ = typeof then InferedTypeTag.Guid +#if NET6_0_OR_GREATER + elif typ = typeof then + InferedTypeTag.DateOnly + elif typ = typeof then + InferedTypeTag.TimeOnly +#endif else failwith "typeTag: Unknown primitive type" | InferedType.Json _ -> InferedTypeTag.Json @@ -138,7 +147,16 @@ let private conversionTable = typeof typeof typeof ] - typeof, [ typeof ] ] + typeof, + [ typeof +#if NET6_0_OR_GREATER + typeof +#endif + ] +#if NET6_0_OR_GREATER + typeof, [ typeof ] +#endif + ] let private subtypePrimitives typ1 typ2 = Debug.Assert(List.exists ((=) typ1) primitiveTypes) @@ -425,7 +443,12 @@ let nameToType = "datetimeoffset", (typeof, TypeWrapper.None) "timespan", (typeof, TypeWrapper.None) "guid", (typeof, TypeWrapper.None) - "string", (typeof, TypeWrapper.None) ] + "string", (typeof, TypeWrapper.None) +#if NET6_0_OR_GREATER + "dateonly", (typeof, TypeWrapper.None) + "timeonly", (typeof, TypeWrapper.None) +#endif + ] |> dict // type is valid while it shouldn't, but well... @@ -545,6 +568,10 @@ let inferPrimitiveType | Parse TextConversions.AsTimeSpan _ -> makePrimitive typeof | Parse TextConversions.AsDateTimeOffset dateTimeOffset when not (isFakeDate dateTimeOffset.UtcDateTime value) -> makePrimitive typeof +#if NET6_0_OR_GREATER + | Parse TextConversions.AsDateOnly dateOnly when not (isFakeDate (dateOnly.ToDateTime(TimeOnly.MinValue)) value) -> + makePrimitive typeof +#endif | Parse TextConversions.AsDateTime date when not (isFakeDate date value) -> makePrimitive typeof | Parse TextConversions.AsDecimal _ -> makePrimitive typeof | Parse (TextConversions.AsFloat [||] false) _ -> makePrimitive typeof diff --git a/src/FSharp.Data.Runtime.Utilities/StructuralTypes.fs b/src/FSharp.Data.Runtime.Utilities/StructuralTypes.fs index 7dee61c58..e8b1df393 100644 --- a/src/FSharp.Data.Runtime.Utilities/StructuralTypes.fs +++ b/src/FSharp.Data.Runtime.Utilities/StructuralTypes.fs @@ -45,6 +45,10 @@ type InferedTypeTag = | TimeSpan | DateTimeOffset | Guid +#if NET6_0_OR_GREATER + | DateOnly + | TimeOnly +#endif // Collections and sum types | Collection | Heterogeneous @@ -160,6 +164,10 @@ type internal InferedTypeTag with | Record None -> "Record" | Record(Some name) -> NameUtils.nicePascalName name | Json -> "Json" +#if NET6_0_OR_GREATER + | DateOnly -> "DateOnly" + | TimeOnly -> "TimeOnly" +#endif /// Converts tag to string code that can be passed to generated code member x.Code = @@ -182,6 +190,10 @@ type internal InferedTypeTag with | "Guid" -> Guid | "Array" -> Collection | "Choice" -> Heterogeneous +#if NET6_0_OR_GREATER + | "DateOnly" -> DateOnly + | "TimeOnly" -> TimeOnly +#endif | "Null" -> failwith "Null nodes should be skipped" | _ -> failwith "Invalid InferredTypeTag code" diff --git a/src/FSharp.Data.Runtime.Utilities/TextConversions.fs b/src/FSharp.Data.Runtime.Utilities/TextConversions.fs index e5ff9a7c4..ee7779266 100644 --- a/src/FSharp.Data.Runtime.Utilities/TextConversions.fs +++ b/src/FSharp.Data.Runtime.Utilities/TextConversions.fs @@ -41,7 +41,7 @@ module private Helpers = let dateTimeStyles = DateTimeStyles.AllowWhiteSpaces ||| DateTimeStyles.RoundtripKind - let ParseISO8601FormattedDateTime text cultureInfo = + let ParseISO8601FormattedDateTime (text: string) cultureInfo = match DateTime.TryParse(text, cultureInfo, dateTimeStyles) with | true, d -> d |> ValueSome | false, _ -> ValueNone @@ -174,7 +174,7 @@ type TextConversions private () = let min = (hourMin % 100) |> float |> TimeSpan.FromMinutes hr.Add min - let offset str = + let offset (str: string) = match Int32.TryParse str with | true, v -> getTimeSpanFromHourMin v |> ValueSome | false, _ -> ValueNone @@ -208,6 +208,32 @@ type TextConversions private () = | true, t -> Some t | _ -> None +#if NET6_0_OR_GREATER + static member AsDateOnly (cultureInfo: CultureInfo) (text: string) = + let mutable d = DateOnly.MinValue + + if DateOnly.TryParse(text.Trim(), cultureInfo, Globalization.DateTimeStyles.AllowWhiteSpaces, &d) then + // Reject strings that also parse as DateTime with a non-zero time component, + // e.g. "2022-06-12T01:02:03" should be DateTime, not DateOnly. + match DateTime.TryParse(text.Trim(), cultureInfo, Globalization.DateTimeStyles.AllowWhiteSpaces) with + | true, dt when dt.TimeOfDay <> TimeSpan.Zero -> None + | _ -> Some d + else + None + + static member AsTimeOnly (cultureInfo: CultureInfo) (text: string) = + let mutable t = TimeOnly.MinValue + + if TimeOnly.TryParse(text.Trim(), cultureInfo, Globalization.DateTimeStyles.AllowWhiteSpaces, &t) then + // Reject strings that also parse as DateTime with a specific real date + // (not today's date used as fill-in), e.g. "2016-10-05T04:05:03" is DateTime, not TimeOnly. + match DateTime.TryParse(text.Trim(), cultureInfo, Globalization.DateTimeStyles.AllowWhiteSpaces) with + | true, dt when dt.Date <> DateTime.Today -> None + | _ -> Some t + else + None +#endif + static member AsGuid(text: string) = Guid.TryParse(text.Trim()) |> asOption module internal UnicodeHelper = diff --git a/src/FSharp.Data.Runtime.Utilities/TextRuntime.fs b/src/FSharp.Data.Runtime.Utilities/TextRuntime.fs index 2f3b98ade..f6d860dc8 100644 --- a/src/FSharp.Data.Runtime.Utilities/TextRuntime.fs +++ b/src/FSharp.Data.Runtime.Utilities/TextRuntime.fs @@ -78,6 +78,16 @@ type TextRuntime = text |> Option.bind (TextConversions.AsTimeSpan(TextRuntime.GetCulture cultureStr)) +#if NET6_0_OR_GREATER + static member ConvertDateOnly(cultureStr, text) = + text + |> Option.bind (TextConversions.AsDateOnly(TextRuntime.GetCulture cultureStr)) + + static member ConvertTimeOnly(cultureStr, text) = + text + |> Option.bind (TextConversions.AsTimeOnly(TextRuntime.GetCulture cultureStr)) +#endif + static member ConvertGuid(text) = text |> Option.bind TextConversions.AsGuid @@ -136,6 +146,18 @@ type TextRuntime = | Some value -> value.ToString("g", TextRuntime.GetCulture cultureStr) | None -> "" +#if NET6_0_OR_GREATER + static member ConvertDateOnlyBack(cultureStr, value: DateOnly option) = + match value with + | Some value -> value.ToString("O", TextRuntime.GetCulture cultureStr) + | None -> "" + + static member ConvertTimeOnlyBack(cultureStr, value: TimeOnly option) = + match value with + | Some value -> value.ToString("O", TextRuntime.GetCulture cultureStr) + | None -> "" +#endif + static member ConvertGuidBack(value: Guid option) = match value with | Some value -> value.ToString() diff --git a/src/FSharp.Data/FSharp.Data.fsproj b/src/FSharp.Data/FSharp.Data.fsproj index 3d18d69b2..d2cc69492 100755 --- a/src/FSharp.Data/FSharp.Data.fsproj +++ b/src/FSharp.Data/FSharp.Data.fsproj @@ -2,7 +2,7 @@ Library - netstandard2.0 + netstandard2.0;net8.0 $(OtherFlags) --warnon:1182 --nowarn:10001 true false diff --git a/tests/FSharp.Data.Core.Tests/TextConversions.fs b/tests/FSharp.Data.Core.Tests/TextConversions.fs index 0c7bb599a..8ae3d0041 100644 --- a/tests/FSharp.Data.Core.Tests/TextConversions.fs +++ b/tests/FSharp.Data.Core.Tests/TextConversions.fs @@ -209,6 +209,33 @@ let ``TimeSpan conversions``() = TextConversions.AsTimeSpan culture "invalid:00:00" |> should equal None TextConversions.AsTimeSpan culture "" |> should equal None +#if NET6_0_OR_GREATER +[] +let ``DateOnly conversions`` () = + let culture = CultureInfo.InvariantCulture + + TextConversions.AsDateOnly culture "2023-01-15" |> should equal (Some (DateOnly(2023, 1, 15))) + TextConversions.AsDateOnly culture "2000-12-31" |> should equal (Some (DateOnly(2000, 12, 31))) + + // A datetime string should NOT match (DateOnly can't parse time components) + TextConversions.AsDateOnly culture "2023-01-15T10:30:00" |> should equal None + TextConversions.AsDateOnly culture "invalid" |> should equal None + TextConversions.AsDateOnly culture "" |> should equal None + +[] +let ``TimeOnly conversions`` () = + let culture = CultureInfo.InvariantCulture + + TextConversions.AsTimeOnly culture "10:30:45" |> should equal (Some (TimeOnly(10, 30, 45))) + TextConversions.AsTimeOnly culture "00:00:00" |> should equal (Some TimeOnly.MinValue) + TextConversions.AsTimeOnly culture "23:59:59" |> should equal (Some (TimeOnly(23, 59, 59))) + + // A date string should NOT match + TextConversions.AsTimeOnly culture "2023-01-15" |> should equal None + TextConversions.AsTimeOnly culture "invalid" |> should equal None + TextConversions.AsTimeOnly culture "" |> should equal None +#endif + [] let ``Guid conversions``() = let validGuid = Guid.NewGuid() diff --git a/tests/FSharp.Data.Tests/CsvProvider.fs b/tests/FSharp.Data.Tests/CsvProvider.fs index 80ee2eb2a..3008bc24c 100644 --- a/tests/FSharp.Data.Tests/CsvProvider.fs +++ b/tests/FSharp.Data.Tests/CsvProvider.fs @@ -56,8 +56,8 @@ let ``Inference of german dates`` () = let row = rows.[1] - let d1:DateTime = row.Gueltig_seit - d1 |> should equal (DateTime(2006,02,01)) + let d1:DateOnly = row.Gueltig_seit + d1 |> should equal (DateOnly(2006,02,01)) let [] csvWithEmptyValues = """ Float1,Float2,Float3,Float4,Int,Float5,Float6,Date @@ -79,19 +79,19 @@ let ``Inference of numbers with empty values`` () = let _i:Nullable = row.Int let _f5:float = row.Float5 let _f6:float = row.Float6 - let _d:option = row.Date + let _d:option = row.Date - let expected = 1.0, 1.0, 1.0, 1.0, Nullable(), Double.NaN, Double.NaN, (None: DateTime option) + let expected = 1.0, 1.0, 1.0, 1.0, Nullable(), Double.NaN, Double.NaN, (None: DateOnly option) let actual = row.Float1, row.Float2, row.Float3, row.Float4, row.Int, row.Float5, row.Float6, row.Date actual |> should equal expected let row = rows.[1] - let expected = 2.0, Double.NaN, Double.NaN, 1.0, Nullable 1, 1.0, Double.NaN, Some(new DateTime(2010, 01,10)) + let expected = 2.0, Double.NaN, Double.NaN, 1.0, Nullable 1, 1.0, Double.NaN, Some(DateOnly(2010, 01,10)) let actual = row.Float1, row.Float2, row.Float3, row.Float4, row.Int, row.Float5, row.Float6, row.Date actual |> should equal expected let row = rows.[2] - let expected = Double.NaN, Double.NaN, 2.0, Double.NaN, Nullable 1, Double.NaN, 2.0, (None: DateTime option) + let expected = Double.NaN, Double.NaN, 2.0, Double.NaN, Nullable 1, Double.NaN, 2.0, (None: DateOnly option) let actual = row.Float1, row.Float2, row.Float3, row.Float4, row.Int, row.Float5, row.Float6, row.Date actual |> should equal expected @@ -105,7 +105,7 @@ let ``Can infer DateTime and DateTimeOffset types correctly`` () = let firstRow = csv.Rows |> Seq.head let secondRow = csv.Rows |> Seq.item 1 - firstRow.DateOnly.GetType() |> should equal typeof + firstRow.DateOnly.GetType() |> should equal typeof firstRow.DateWithOffset.GetType() |> should equal typeof firstRow.MixedDate.GetType() |> should equal typeof firstRow.OffsetOption.GetType() |> should equal typeof @@ -155,7 +155,7 @@ let ``Does not treat invariant culture number such as 3.14 as a date in cultures targetCulture.NumberFormat.NumberDecimalSeparator |> should equal "," let csv = CsvProvider<"Data/DnbHistoriskeKurser.csv", ",", 10, Culture=norwayCultureName>.GetSample() let row = csv.Rows |> Seq.head - (row.Dato, row.USD) |> should equal (DateTime(2013, 2, 7), "5.4970") + (row.Dato, row.USD) |> should equal (DateOnly(2013, 2, 7), "5.4970") [] let ``Empty lines are skipped and don't make everything optional`` () = diff --git a/tests/FSharp.Data.Tests/HtmlProvider.fs b/tests/FSharp.Data.Tests/HtmlProvider.fs index b3c7ebfd8..884003f91 100644 --- a/tests/FSharp.Data.Tests/HtmlProvider.fs +++ b/tests/FSharp.Data.Tests/HtmlProvider.fs @@ -56,7 +56,7 @@ type MarketDepth = HtmlProvider<"Data/MarketDepth.htm"> [] let ``Can infer tables out of the market depth file``() = let table = MarketDepth().Tables.Table1 - table.Rows.[0].``Settlement Day`` |> should equal (DateTime(2014, 1, 14, 0, 0,0)) + table.Rows.[0].``Settlement Day`` |> should equal (DateOnly(2014, 1, 14)) table.Rows.[0].Period |> should equal 1 [] @@ -369,6 +369,6 @@ let ``Can infer DateTime and DateTimeOffset types correctly`` () = """>.GetSample() let table = html.Tables.Table1 - table.Rows.[0].DateOnly.GetType() |> should equal typeof + table.Rows.[0].DateOnly.GetType() |> should equal typeof table.Rows.[0].MixedDate.GetType() |> should equal typeof table.Rows.[0].DateWithOffset.GetType() |> should equal typeof diff --git a/tests/FSharp.Data.Tests/HtmlProviderList.fs b/tests/FSharp.Data.Tests/HtmlProviderList.fs index 723c56d1a..9a92b5ec7 100644 --- a/tests/FSharp.Data.Tests/HtmlProviderList.fs +++ b/tests/FSharp.Data.Tests/HtmlProviderList.fs @@ -74,7 +74,7 @@ let ``Simple List infers date type correctly ``() = """, PreferOptionals=true>.GetSample().Lists.List1 - list.Values |> should equal [|DateTime(2013,1,1);DateTime(2013,2,2);DateTime(2013,3,3)|] + list.Values |> should equal [|DateOnly(2013,1,1);DateOnly(2013,2,2);DateOnly(2013,3,3)|] [] let ``Simple List infers hetergenous list as string type correctly ``() = @@ -146,7 +146,7 @@ let ``Handles simple definition list``() = """, PreferOptionals=true>.GetSample().DefinitionLists.DefinitionList1 list.Count.Values |> should equal [|1;2|] - list.Dates.Values |> should equal [|DateTime(2014,1,1); DateTime(2014, 2,2)|] + list.Dates.Values |> should equal [|DateOnly(2014,1,1); DateOnly(2014, 2,2)|] list.Decimals.Values |> should equal [|1.23M; 2.23M|] list.Missing.Values |> should equal [|Some "Foobar"; None|] diff --git a/tests/FSharp.Data.Tests/JsonProvider.fs b/tests/FSharp.Data.Tests/JsonProvider.fs index af153ea0e..5b176b424 100644 --- a/tests/FSharp.Data.Tests/JsonProvider.fs +++ b/tests/FSharp.Data.Tests/JsonProvider.fs @@ -265,7 +265,7 @@ let ``Heterogeneous types with Nulls, Missing, and "" should return None on all j.[0].A.Boolean |> should equal None j.[0].A.Number |> should equal None j.[0].A.Array |> should equal None - j.[0].B.DateTime |> should equal None + j.[0].B.DateOnly |> should equal None j.[0].B.TimeSpan |> should equal None j.[0].B.Number |> should equal None j.[0].B.Array |> should equal None @@ -276,7 +276,7 @@ let ``Heterogeneous types with Nulls, Missing, and "" should return None on all j.[1].A.Boolean |> should equal None j.[1].A.Number |> should equal (Some 2) j.[1].A.Array |> should equal None - j.[1].B.DateTime |> should equal (Some (DateTime(DateTime.Today.Year,3,4))) + j.[1].B.DateOnly |> should equal (Some (DateOnly(DateTime.Today.Year,3,4))) j.[1].B.TimeSpan |> should equal None j.[1].B.Number |> should equal (Some 3.4m) j.[1].B.Array |> should equal None @@ -287,7 +287,7 @@ let ``Heterogeneous types with Nulls, Missing, and "" should return None on all j.[2].A.Boolean |> should equal (Some false) j.[2].A.Number |> should equal None j.[2].A.Array |> should equal None - j.[2].B.DateTime |> should equal (Some (DateTime(2002,10,10))) + j.[2].B.DateOnly |> should equal (Some (DateOnly(2002,10,10))) j.[2].B.TimeSpan |> should equal None j.[2].B.Number |> should equal None j.[2].B.Array |> should equal None @@ -298,7 +298,7 @@ let ``Heterogeneous types with Nulls, Missing, and "" should return None on all j.[3].A.Boolean |> should equal None j.[3].A.Number |> should equal None j.[3].A.Array |> should equal (Some (Array.zeroCreate 0)) - j.[3].B.DateTime |> should equal None + j.[3].B.DateOnly |> should equal None j.[3].B.TimeSpan |> should equal None j.[3].B.Number |> should equal None j.[3].B.Array |> should equal (Some [|1|]) @@ -636,15 +636,15 @@ let ``Can parse time span in different culture``() = [] let ``Parsing of values wrapped in quotes should work on heterogenous values``() = let objs = JsonProvider<"""[{"a": "01/02/2000"}, {"a" : "3"}]""">.GetSamples() - objs.[0].A.DateTime |> should equal (Some (DateTime(2000,01,02))) + objs.[0].A.DateOnly |> should equal (Some (DateOnly(2000,01,02))) objs.[0].A.Number |> should equal None - objs.[1].A.DateTime |> should equal None + objs.[1].A.DateOnly |> should equal None objs.[1].A.Number |> should equal (Some 3) [] let ``Parsing of values wrapped in quotes should work on arrays``() = let objs = JsonProvider<"""["01/02/2000", "02/02/2001", "3", 4]""">.GetSample() - objs.DateTimes |> should equal [| DateTime(2000,01,02); DateTime(2001,02,02) |] + objs.DateOnlies |> should equal [| DateOnly(2000,01,02); DateOnly(2001,02,02) |] objs.Numbers |> should equal [| 3; 4 |] [] @@ -855,7 +855,7 @@ let ``Can load different nested payloads`` () = payload1.Y |> should equal 12345 payload2.User |> should equal "alice" payload2.Role |> should equal "admin" - payload2.RegisteredSince |> should equal (DateTime(2021, 11, 1)) + payload2.RegisteredSince |> should equal (DateOnly(2021, 11, 1)) [] From d840033357956ec158110284c8ed3c3e47152ada Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 22 Feb 2026 11:43:10 +0000 Subject: [PATCH 02/10] Initial plan From b14073a0bd7f1541f750cb720d3ce8b77b20bc09 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 22 Feb 2026 11:48:22 +0000 Subject: [PATCH 03/10] Update documentation and samples for DateOnly/TimeOnly inference Co-authored-by: dsyme <7204669+dsyme@users.noreply.github.com> --- docs/library/CsvProvider.fsx | 39 ++++++++++++++++++++++++++++++----- docs/library/JsonProvider.fsx | 7 +++++++ 2 files changed, 41 insertions(+), 5 deletions(-) diff --git a/docs/library/CsvProvider.fsx b/docs/library/CsvProvider.fsx index 26895df50..e0c43755a 100644 --- a/docs/library/CsvProvider.fsx +++ b/docs/library/CsvProvider.fsx @@ -89,7 +89,8 @@ The following sample calls the `Load` method with an URL that points to a live C let msft = Stocks.Load(__SOURCE_DIRECTORY__ + "/../data/MSFT.csv").Cache() // Look at the most recent row. Note the 'Date' property -// is of type 'DateTime' and 'Open' has a type 'decimal' +// is of type 'DateOnly' (on .NET 6+) or 'DateTime' (on older targets) +// and 'Open' has a type 'decimal' let firstRow = msft.Rows |> Seq.head let lastDate = firstRow.Date let lastOpen = firstRow.Open @@ -107,8 +108,9 @@ collection of rows. We iterate over the rows using a `for` loop. As you can see to the columns in the CSV file. As you can see, the type provider also infers types of individual rows. The `Date` -property is inferred to be a `DateTime` (because the values in the sample file can all -be parsed as dates) while HLOC prices are inferred as `decimal`. +property is inferred to be a `DateOnly` on .NET 6 and later (because the values in the sample +file are date-only strings without a time component), or `DateTime` on older targets. HLOC +prices are inferred as `decimal`. ## Using units of measure @@ -269,8 +271,13 @@ it by specifying the `InferRows` static parameter of `CsvProvider`. If you speci Columns with only `0`, `1`, `Yes`, `No`, `True`, or `False` will be set to `bool`. Columns with numerical values will be set to either `int`, `int64`, `decimal`, or `float`, in that order of preference. -If a value is missing in any row, by default the CSV type provider will infer a nullable (for `int` and `int64`) or an optional -(for `bool`, `DateTime` and `Guid`). When a `decimal` would be inferred but there are missing values, we will infer a +On .NET 6 and later, columns whose values are all date-only strings (without a time component, e.g. `2023-01-15`) +are automatically inferred as `DateOnly`. If a column mixes `DateOnly` and `DateTime` values, it is unified to `DateTime`. +Note that `TimeOnly` is **not** auto-inferred because it is ambiguous with `TimeSpan`; use an explicit schema annotation +(see the list of valid types below) to get a `TimeOnly` column. + +If a value is missing in any row, by default the CSV type provider will infer a nullable (for `int`, `int64`, and `DateOnly`) or an optional +(for `bool`, `DateTime`, `DateTimeOffset`, and `Guid`). When a `decimal` would be inferred but there are missing values, we will infer a `float` instead, and use `Double.NaN` to represent those missing values. The `string` type is already inherently nullable, so by default, we won't generate a `string option`. If you prefer to use optionals in all cases, you can set the static parameter `PreferOptionals` to `true`. In that case, you'll never get an empty string or a `Double.NaN` and will always get a `None` instead. @@ -303,6 +310,12 @@ specify the units of measure. This will override both `AssumeMissingValues` and * `guid` * `guid?` * `guid option` +* `dateonly` (.NET 6+ only) +* `dateonly?` (.NET 6+ only) +* `dateonly option` (.NET 6+ only) +* `timeonly` (.NET 6+ only) +* `timeonly?` (.NET 6+ only) +* `timeonly option` (.NET 6+ only) * `string` * `string option`. @@ -373,6 +386,22 @@ for row in titanic2.Rows |> Seq.truncate 10 do You can even mix and match the two syntaxes like this `Schema="int64,DidSurvive,PClass->Passenger Class=string"` +### DateOnly and TimeOnly (on .NET 6+) + +On .NET 6 and later, date-only strings are automatically inferred as `DateOnly`. For example, a column +like `EventDate` containing values such as `2023-06-01` will be given the type `DateOnly`. + +You can also explicitly request a `DateOnly` or `TimeOnly` column using schema annotations: + + [lang=text] + EventDate,Duration (timeonly?) + 2023-06-01,08:30:00 + 2023-06-02, + +In the example above, `EventDate` is auto-inferred as `DateOnly` and `Duration` is explicitly +annotated as `timeonly?` (a nullable `TimeOnly`). Note that `TimeOnly` is never auto-inferred +(to avoid confusion with `TimeSpan`), so the schema annotation is required to get a `TimeOnly` column. + ## Transforming CSV files In addition to reading, `CsvProvider` also has support for transforming the row collection of CSV files. The operations diff --git a/docs/library/JsonProvider.fsx b/docs/library/JsonProvider.fsx index e753d2b01..61b068239 100644 --- a/docs/library/JsonProvider.fsx +++ b/docs/library/JsonProvider.fsx @@ -119,6 +119,13 @@ type-safe access to the values, but not in the original order (if order matters, you can use the `mixed.JsonValue` property to get the underlying `JsonValue` and process it dynamically as described in [the documentation for `JsonValue`](JsonValue.html). +### Inferring date types + +String values in JSON that look like dates are inferred as `DateTime` or `DateTimeOffset`. +On .NET 6 and later, strings that represent a date without a time component (e.g. `"2023-01-15"`) +are inferred as `DateOnly`. Full datetime strings (e.g. `"2023-01-15T10:30:00"`) continue to be +inferred as `DateTime`. + ### Inferring record types Now let's look at a sample JSON document that contains a list of records. The From 89be82106ec21801ec97f6cd96096f850437a0bf Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 22 Feb 2026 12:10:58 +0000 Subject: [PATCH 04/10] Initial plan From 2063b647727690c67da2073425d3741d68da909b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 22 Feb 2026 13:00:19 +0000 Subject: [PATCH 05/10] Fix DateOnly design-time type resolution error for netstandard2.0 targets Co-authored-by: dsyme <7204669+dsyme@users.noreply.github.com> --- .../CommonProviderImplementation/Helpers.fs | 11 ++++ src/FSharp.Data.DesignTime/Csv/CsvProvider.fs | 27 +++++---- .../Html/HtmlGenerator.fs | 42 ++++++++++--- .../Html/HtmlProvider.fs | 8 ++- .../Json/JsonProvider.fs | 51 +++++++++------- src/FSharp.Data.DesignTime/Xml/XmlProvider.fs | 31 +++++++--- .../StructuralInference.fs | 60 +++++++++++++++++++ .../FSharp.Data.Xml.Core.fsproj | 2 +- src/FSharp.Data.Xml.Core/XsdInference.fs | 4 ++ .../InferenceTests.fs | 24 ++++++++ 10 files changed, 209 insertions(+), 51 deletions(-) diff --git a/src/FSharp.Data.DesignTime/CommonProviderImplementation/Helpers.fs b/src/FSharp.Data.DesignTime/CommonProviderImplementation/Helpers.fs index 958fc6980..0bb778c95 100644 --- a/src/FSharp.Data.DesignTime/CommonProviderImplementation/Helpers.fs +++ b/src/FSharp.Data.DesignTime/CommonProviderImplementation/Helpers.fs @@ -150,6 +150,17 @@ module internal ProviderHelpers = member x.Inverse(denominator) : Type = ProvidedMeasureBuilder.Inverse(denominator) } +#if NET6_0_OR_GREATER + /// Returns true when the target runtime assembly is a .NET 6+ build and therefore + /// supports System.DateOnly / System.TimeOnly in generated types. + let runtimeSupportsNet6Types (runtimeAssemblyPath: string) = + // The assembly path contains the TFM, e.g. "…/net8.0/FSharp.Data.dll". + // Anything matching "/net." where N ≥ 6 is a net6+ target. + let path = runtimeAssemblyPath.Replace('\\', '/').ToLowerInvariant() + let m = System.Text.RegularExpressions.Regex.Match(path, @"/net(\d+)\.") + m.Success && (int m.Groups.[1].Value) >= 6 +#endif + let asyncMap (resultType: Type) (valueAsync: Expr>) (body: Expr<'T> -> Expr) = let (?) = QuotationBuilder.(?) let convFunc = ReflectionHelpers.makeDelegate (Expr.Cast >> body) typeof<'T> diff --git a/src/FSharp.Data.DesignTime/Csv/CsvProvider.fs b/src/FSharp.Data.DesignTime/Csv/CsvProvider.fs index daf5ea51b..802ca013e 100644 --- a/src/FSharp.Data.DesignTime/Csv/CsvProvider.fs +++ b/src/FSharp.Data.DesignTime/Csv/CsvProvider.fs @@ -103,16 +103,23 @@ type public CsvProvider(cfg: TypeProviderConfig) as this = let inferredFields = use _holder = IO.logTime "Inference" sample - sampleCsv.InferColumnTypes( - inferRows, - TextRuntime.GetMissingValues missingValuesStr, - inferenceMode, - TextRuntime.GetCulture cultureStr, - schema, - assumeMissingValues, - preferOptionals, - unitsOfMeasureProvider - ) + let fields = + sampleCsv.InferColumnTypes( + inferRows, + TextRuntime.GetMissingValues missingValuesStr, + inferenceMode, + TextRuntime.GetCulture cultureStr, + schema, + assumeMissingValues, + preferOptionals, + unitsOfMeasureProvider + ) +#if NET6_0_OR_GREATER + if ProviderHelpers.runtimeSupportsNet6Types cfg.RuntimeAssembly then fields + else fields |> List.map StructuralInference.downgradeNet6PrimitiveProperty +#else + fields +#endif use _holder = IO.logTime "TypeGeneration" sample diff --git a/src/FSharp.Data.DesignTime/Html/HtmlGenerator.fs b/src/FSharp.Data.DesignTime/Html/HtmlGenerator.fs index 3d3625d56..edd0db0c6 100644 --- a/src/FSharp.Data.DesignTime/Html/HtmlGenerator.fs +++ b/src/FSharp.Data.DesignTime/Html/HtmlGenerator.fs @@ -37,10 +37,11 @@ module internal HtmlGenerator = let private createTableType getTableTypeName (inferenceParameters, missingValuesStr, cultureStr) + supportsNet6Types (table: HtmlTable) = - let columns = + let rawColumns = match table.InferedProperties with | Some inferedProperties -> inferedProperties | None -> @@ -52,6 +53,14 @@ module internal HtmlGenerator = else table.Rows) + let columns = +#if NET6_0_OR_GREATER + if supportsNet6Types then rawColumns + else rawColumns |> List.map StructuralInference.downgradeNet6PrimitiveProperty +#else + rawColumns +#endif + let fields = columns |> List.mapi (fun index field -> @@ -128,9 +137,17 @@ module internal HtmlGenerator = create, tableType - let private createListType getListTypeName (inferenceParameters, missingValuesStr, cultureStr) (list: HtmlList) = + let private createListType getListTypeName (inferenceParameters, missingValuesStr, cultureStr) supportsNet6Types (list: HtmlList) = - let columns = HtmlInference.inferListType inferenceParameters list.Values + let rawColumns = HtmlInference.inferListType inferenceParameters list.Values + + let columns = +#if NET6_0_OR_GREATER + if supportsNet6Types then rawColumns + else StructuralInference.downgradeNet6Types rawColumns +#else + rawColumns +#endif let listItemType, conv = match columns with @@ -185,6 +202,7 @@ module internal HtmlGenerator = let private createDefinitionListType getDefinitionListTypeName (inferenceParameters, missingValuesStr, cultureStr) + supportsNet6Types (definitionList: HtmlDefinitionList) = @@ -192,7 +210,15 @@ module internal HtmlGenerator = let createListType index (list: HtmlList) = - let columns = HtmlInference.inferListType inferenceParameters list.Values + let rawColumns = HtmlInference.inferListType inferenceParameters list.Values + + let columns = +#if NET6_0_OR_GREATER + if supportsNet6Types then rawColumns + else StructuralInference.downgradeNet6Types rawColumns +#else + rawColumns +#endif let listItemType, conv = match columns with @@ -264,7 +290,7 @@ module internal HtmlGenerator = definitionListType - let generateTypes asm ns typeName parameters htmlObjects = + let generateTypes asm ns typeName parameters supportsNet6Types htmlObjects = let htmlType = ProvidedTypeDefinition( @@ -303,7 +329,7 @@ module internal HtmlGenerator = match htmlObj with | Table table -> let containerType = getOrCreateContainer "Tables" - let create, tableType = createTableType getTypeName parameters table + let create, tableType = createTableType getTypeName parameters supportsNet6Types table htmlType.AddMember tableType containerType.AddMember @@ -315,7 +341,7 @@ module internal HtmlGenerator = | List list -> let containerType = getOrCreateContainer "Lists" - let create, tableType = createListType getTypeName parameters list + let create, tableType = createListType getTypeName parameters supportsNet6Types list htmlType.AddMember tableType containerType.AddMember @@ -326,7 +352,7 @@ module internal HtmlGenerator = ) | DefinitionList definitionList -> let containerType = getOrCreateContainer "DefinitionLists" - let tableType = createDefinitionListType getTypeName parameters definitionList + let tableType = createDefinitionListType getTypeName parameters supportsNet6Types definitionList htmlType.AddMember tableType containerType.AddMember diff --git a/src/FSharp.Data.DesignTime/Html/HtmlProvider.fs b/src/FSharp.Data.DesignTime/Html/HtmlProvider.fs index 62828d91e..7cb3c17fd 100644 --- a/src/FSharp.Data.DesignTime/Html/HtmlProvider.fs +++ b/src/FSharp.Data.DesignTime/Html/HtmlProvider.fs @@ -62,9 +62,15 @@ type public HtmlProvider(cfg: TypeProviderConfig) as this = PreferOptionals = preferOptionals InferenceMode = inferenceMode } +#if NET6_0_OR_GREATER + let supportsNet6Types = ProviderHelpers.runtimeSupportsNet6Types cfg.RuntimeAssembly +#else + let supportsNet6Types = false +#endif + doc |> HtmlRuntime.getHtmlObjects (Some inferenceParameters) includeLayoutTables - |> HtmlGenerator.generateTypes asm ns typeName (inferenceParameters, missingValuesStr, cultureStr) + |> HtmlGenerator.generateTypes asm ns typeName (inferenceParameters, missingValuesStr, cultureStr) supportsNet6Types use _holder = IO.logTime "TypeGeneration" sample diff --git a/src/FSharp.Data.DesignTime/Json/JsonProvider.fs b/src/FSharp.Data.DesignTime/Json/JsonProvider.fs index de6c2f5e4..c9278ea11 100644 --- a/src/FSharp.Data.DesignTime/Json/JsonProvider.fs +++ b/src/FSharp.Data.DesignTime/Json/JsonProvider.fs @@ -76,28 +76,35 @@ type public JsonProvider(cfg: TypeProviderConfig) as this = let inferedType = use _holder = IO.logTime "Inference" (if schema <> "" then schema else sample) - if schema <> "" then - // Use the JSON Schema for type inference - use _holder = IO.logTime "SchemaInference" schema - - let schemaValue = JsonValue.Parse(value) - let jsonSchema = JsonSchema.parseSchema schemaValue - JsonSchema.schemaToInferedType unitsOfMeasureProvider jsonSchema - else - // Use sample-based inference - let samples = - use _holder = IO.logTime "Parsing" sample - - if sampleIsList then - JsonDocument.CreateList(new StringReader(value)) - |> Array.map (fun doc -> doc.JsonValue) - else - [| JsonValue.Parse(value) |] - - samples - |> Array.map (fun sampleJson -> - JsonInference.inferType unitsOfMeasureProvider inferenceMode cultureInfo "" sampleJson) - |> Array.fold (StructuralInference.subtypeInfered false) InferedType.Top + let rawInfered = + if schema <> "" then + // Use the JSON Schema for type inference + use _holder = IO.logTime "SchemaInference" schema + + let schemaValue = JsonValue.Parse(value) + let jsonSchema = JsonSchema.parseSchema schemaValue + JsonSchema.schemaToInferedType unitsOfMeasureProvider jsonSchema + else + // Use sample-based inference + let samples = + use _holder = IO.logTime "Parsing" sample + + if sampleIsList then + JsonDocument.CreateList(new StringReader(value)) + |> Array.map (fun doc -> doc.JsonValue) + else + [| JsonValue.Parse(value) |] + + samples + |> Array.map (fun sampleJson -> + JsonInference.inferType unitsOfMeasureProvider inferenceMode cultureInfo "" sampleJson) + |> Array.fold (StructuralInference.subtypeInfered false) InferedType.Top +#if NET6_0_OR_GREATER + if ProviderHelpers.runtimeSupportsNet6Types cfg.RuntimeAssembly then rawInfered + else StructuralInference.downgradeNet6Types rawInfered +#else + rawInfered +#endif use _holder = IO.logTime "TypeGeneration" (if schema <> "" then schema else sample) diff --git a/src/FSharp.Data.DesignTime/Xml/XmlProvider.fs b/src/FSharp.Data.DesignTime/Xml/XmlProvider.fs index dfbf2e766..30039d633 100644 --- a/src/FSharp.Data.DesignTime/Xml/XmlProvider.fs +++ b/src/FSharp.Data.DesignTime/Xml/XmlProvider.fs @@ -74,7 +74,13 @@ type public XmlProvider(cfg: TypeProviderConfig) as this = let inferedType = use _holder = IO.logTime "Inference" sample - schemaSet |> XsdParsing.getElements |> List.ofSeq |> XsdInference.inferElements + let t = schemaSet |> XsdParsing.getElements |> List.ofSeq |> XsdInference.inferElements +#if NET6_0_OR_GREATER + if ProviderHelpers.runtimeSupportsNet6Types cfg.RuntimeAssembly then t + else StructuralInference.downgradeNet6Types t +#else + t +#endif use _holder = IO.logTime "TypeGeneration" sample @@ -113,14 +119,21 @@ type public XmlProvider(cfg: TypeProviderConfig) as this = let inferedType = use _holder = IO.logTime "Inference" sample - samples - |> XmlInference.inferType - unitsOfMeasureProvider - inferenceMode - (TextRuntime.GetCulture cultureStr) - false - globalInference - |> Array.fold (StructuralInference.subtypeInfered false) InferedType.Top + let t = + samples + |> XmlInference.inferType + unitsOfMeasureProvider + inferenceMode + (TextRuntime.GetCulture cultureStr) + false + globalInference + |> Array.fold (StructuralInference.subtypeInfered false) InferedType.Top +#if NET6_0_OR_GREATER + if ProviderHelpers.runtimeSupportsNet6Types cfg.RuntimeAssembly then t + else StructuralInference.downgradeNet6Types t +#else + t +#endif use _holder = IO.logTime "TypeGeneration" sample diff --git a/src/FSharp.Data.Runtime.Utilities/StructuralInference.fs b/src/FSharp.Data.Runtime.Utilities/StructuralInference.fs index 0ca9a763a..df70c6139 100644 --- a/src/FSharp.Data.Runtime.Utilities/StructuralInference.fs +++ b/src/FSharp.Data.Runtime.Utilities/StructuralInference.fs @@ -623,3 +623,63 @@ let inferPrimitiveType [] let getInferedTypeFromString unitsOfMeasureProvider inferenceMode cultureInfo value unit = inferPrimitiveType unitsOfMeasureProvider inferenceMode cultureInfo value unit + +#if NET6_0_OR_GREATER +/// Replaces DateOnly → DateTime and TimeOnly → TimeSpan throughout an InferedType tree. +/// Used in design-time code when the target framework does not support these .NET 6+ types. +let internal downgradeNet6Types (inferedType: InferedType) : InferedType = + let downgradeTag tag = + match tag with + | InferedTypeTag.DateOnly -> InferedTypeTag.DateTime + | InferedTypeTag.TimeOnly -> InferedTypeTag.TimeSpan + | _ -> tag + + let downgradeType (typ: Type) = + if typ = typeof then typeof + elif typ = typeof then typeof + else typ + + // Use reference-equality-based visited set to handle cyclic InferedType graphs + // (e.g. recursive XML schemas). When a cycle is detected we return the original node. + let visited = + System.Collections.Generic.HashSet( + { new System.Collections.Generic.IEqualityComparer with + member _.Equals(x, y) = obj.ReferenceEquals(x, y) + member _.GetHashCode(x) = System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(x) }) + + let rec convert infType = + if not (visited.Add(infType)) then + infType // cycle detected – return original to avoid infinite recursion + else + let result = + match infType with + | InferedType.Primitive(typ, unit, optional, overrideOnMerge) -> + InferedType.Primitive(downgradeType typ, unit, optional, overrideOnMerge) + | InferedType.Record(name, props, optional) -> + InferedType.Record(name, props |> List.map (fun p -> { p with Type = convert p.Type }), optional) + | InferedType.Collection(order, types) -> + InferedType.Collection( + order |> List.map downgradeTag, + types |> Map.toSeq |> Seq.map (fun (k, (m, t)) -> downgradeTag k, (m, convert t)) |> Map.ofSeq) + | InferedType.Heterogeneous(types, containsOptional) -> + InferedType.Heterogeneous( + types |> Map.toSeq |> Seq.map (fun (k, t) -> downgradeTag k, convert t) |> Map.ofSeq, + containsOptional) + | InferedType.Json(innerType, optional) -> InferedType.Json(convert innerType, optional) + | _ -> infType + result + + convert inferedType + +/// Replaces DateOnly → DateTime and TimeOnly → TimeSpan in a PrimitiveInferedProperty. +/// Used in design-time code when the target framework does not support these .NET 6+ types. +let internal downgradeNet6PrimitiveProperty (field: StructuralTypes.PrimitiveInferedProperty) = + let v = field.Value + + if v.InferedType = typeof then + { field with Value = { v with InferedType = typeof; RuntimeType = typeof } } + elif v.InferedType = typeof then + { field with Value = { v with InferedType = typeof; RuntimeType = typeof } } + else + field +#endif diff --git a/src/FSharp.Data.Xml.Core/FSharp.Data.Xml.Core.fsproj b/src/FSharp.Data.Xml.Core/FSharp.Data.Xml.Core.fsproj index ecc8f9f67..be6b0aba0 100644 --- a/src/FSharp.Data.Xml.Core/FSharp.Data.Xml.Core.fsproj +++ b/src/FSharp.Data.Xml.Core/FSharp.Data.Xml.Core.fsproj @@ -2,7 +2,7 @@ Library - netstandard2.0 + netstandard2.0;net8.0 $(OtherFlags) --warnon:1182 --nowarn:10001 --nowarn:44 true false diff --git a/src/FSharp.Data.Xml.Core/XsdInference.fs b/src/FSharp.Data.Xml.Core/XsdInference.fs index 9fe3a7899..a25f6b787 100644 --- a/src/FSharp.Data.Xml.Core/XsdInference.fs +++ b/src/FSharp.Data.Xml.Core/XsdInference.fs @@ -214,7 +214,11 @@ module internal XsdInference = function | XmlTypeCode.Int -> typeof | XmlTypeCode.Long -> typeof +#if NET6_0_OR_GREATER + | XmlTypeCode.Date -> typeof +#else | XmlTypeCode.Date -> typeof +#endif | XmlTypeCode.DateTime -> typeof | XmlTypeCode.Boolean -> typeof | XmlTypeCode.Decimal -> typeof diff --git a/tests/FSharp.Data.DesignTime.Tests/InferenceTests.fs b/tests/FSharp.Data.DesignTime.Tests/InferenceTests.fs index 539f539e0..7b2795697 100644 --- a/tests/FSharp.Data.DesignTime.Tests/InferenceTests.fs +++ b/tests/FSharp.Data.DesignTime.Tests/InferenceTests.fs @@ -202,7 +202,11 @@ let ``Inference of multiple nulls works``() = let ``Inference of DateTime``() = let source = CsvFile.Parse("date,int,float\n2012-12-19,2,3.0\n2012-12-12,4,5.0\n2012-12-1,6,10.0") let actual, _ = inferType source Int32.MaxValue [||] culture "" false false +#if NET6_0_OR_GREATER + let propDate = { Name = "date"; Type = InferedType.Primitive(typeof, None, false, false) } +#else let propDate = { Name = "date"; Type = InferedType.Primitive(typeof, None, false, false) } +#endif let propInt = { Name = "int"; Type = InferedType.Primitive(typeof, None, false, false) } let propFloat = { Name = "float"; Type = InferedType.Primitive(typeof, None, false, false) } let expected = toRecord [ propDate ; propInt ; propFloat ] @@ -212,7 +216,11 @@ let ``Inference of DateTime``() = let ``Inference of DateTime with timestamp``() = let source = CsvFile.Parse("date,timestamp\n2012-12-19,2012-12-19 12:00\n2012-12-12,2012-12-12 00:00\n2012-12-1,2012-12-1 07:00") let actual, _ = inferType source Int32.MaxValue [||] culture "" false false +#if NET6_0_OR_GREATER + let propDate = { Name = "date"; Type = InferedType.Primitive(typeof, None, false, false) } +#else let propDate = { Name = "date"; Type = InferedType.Primitive(typeof, None, false, false) } +#endif let propTimestamp = { Name = "timestamp"; Type = InferedType.Primitive(typeof, None, false, false) } let expected = toRecord [ propDate ; propTimestamp ] actual |> should equal expected @@ -221,7 +229,11 @@ let ``Inference of DateTime with timestamp``() = let ``Inference of DateTime with timestamp non default separator``() = let source = CsvFile.Parse("date;timestamp\n2012-12-19;2012-12-19 12:00\n2012-12-12;2012-12-12 00:00\n2012-12-1;2012-12-1 07:00", ";") let actual, _ = inferType source Int32.MaxValue [||] culture "" false false +#if NET6_0_OR_GREATER + let propDate = { Name = "date"; Type = InferedType.Primitive(typeof, None, false, false) } +#else let propDate = { Name = "date"; Type = InferedType.Primitive(typeof, None, false, false) } +#endif let propTimestamp = { Name = "timestamp"; Type = InferedType.Primitive(typeof, None, false, false) } let expected = toRecord [ propDate ; propTimestamp ] actual |> should equal expected @@ -249,7 +261,11 @@ let ``Inference of numbers with empty values``() = let propInt = { Name = "int"; Type = InferedType.Primitive(typeof, None, true, false) } let propFloat5 = { Name = "float5"; Type = InferedType.Primitive(typeof, None, false, false) } let propFloat6 = { Name = "float6"; Type = InferedType.Primitive(typeof, None, true, false) } +#if NET6_0_OR_GREATER + let propDate = { Name = "date"; Type = InferedType.Primitive(typeof, None, true, false) } +#else let propDate = { Name = "date"; Type = InferedType.Primitive(typeof, None, true, false) } +#endif let propBool = { Name = "bool"; Type = InferedType.Primitive(typeof, None, true, false) } let propInt64 = { Name = "int64"; Type = InferedType.Primitive(typeof, None, true, false) } let expected = toRecord [ propFloat1; propFloat2; propFloat3; propFloat4; propInt; propFloat5; propFloat6; propDate; propBool; propInt64 ] @@ -265,7 +281,11 @@ let ``Inference of numbers with empty values``() = let propInt = field "int" TypeWrapper.Nullable typeof let propFloat5 = field "float5" TypeWrapper.None typeof let propFloat6 = field "float6" TypeWrapper.None typeof +#if NET6_0_OR_GREATER + let propDate = field "date" TypeWrapper.Option typeof +#else let propDate = field "date" TypeWrapper.Option typeof +#endif let propBool = field "bool" TypeWrapper.Option typeof let propInt64 = field "int64" TypeWrapper.Nullable typeof let expected = [ propFloat1; propFloat2; propFloat3; propFloat4; propInt; propFloat5; propFloat6; propDate; propBool; propInt64 ] @@ -303,7 +323,11 @@ let ``Infers units of measure correctly``() = let propString = "String(metre)" , typeof , "string" let propFloat = "Float" , typeof , "float" +#if NET6_0_OR_GREATER + let propDate = "Date (second)" , typeof, "DateOnly" +#else let propDate = "Date (second)" , typeof, "date" +#endif let propInt = "Int" , typeof , "int" let propDecimal = "Decimal" , typeof , "decimal" let propBool = "Bool(N)" , typeof , "bool" From 82b2baf44c83213fb433a515f56d5c16f65ca0ab Mon Sep 17 00:00:00 2001 From: Don Syme Date: Sun, 22 Feb 2026 13:12:31 +0000 Subject: [PATCH 06/10] apply fantomas formatting --- src/FSharp.Data.DesignTime/Csv/CsvProvider.fs | 6 ++-- .../Html/HtmlGenerator.fs | 35 ++++++++++++++----- .../Html/HtmlProvider.fs | 7 +++- .../Json/JsonProvider.fs | 6 ++-- src/FSharp.Data.DesignTime/Xml/XmlProvider.fs | 15 +++++--- .../StructuralInference.fs | 32 +++++++++++++---- 6 files changed, 76 insertions(+), 25 deletions(-) diff --git a/src/FSharp.Data.DesignTime/Csv/CsvProvider.fs b/src/FSharp.Data.DesignTime/Csv/CsvProvider.fs index 802ca013e..049b966e1 100644 --- a/src/FSharp.Data.DesignTime/Csv/CsvProvider.fs +++ b/src/FSharp.Data.DesignTime/Csv/CsvProvider.fs @@ -115,8 +115,10 @@ type public CsvProvider(cfg: TypeProviderConfig) as this = unitsOfMeasureProvider ) #if NET6_0_OR_GREATER - if ProviderHelpers.runtimeSupportsNet6Types cfg.RuntimeAssembly then fields - else fields |> List.map StructuralInference.downgradeNet6PrimitiveProperty + if ProviderHelpers.runtimeSupportsNet6Types cfg.RuntimeAssembly then + fields + else + fields |> List.map StructuralInference.downgradeNet6PrimitiveProperty #else fields #endif diff --git a/src/FSharp.Data.DesignTime/Html/HtmlGenerator.fs b/src/FSharp.Data.DesignTime/Html/HtmlGenerator.fs index edd0db0c6..f3362b694 100644 --- a/src/FSharp.Data.DesignTime/Html/HtmlGenerator.fs +++ b/src/FSharp.Data.DesignTime/Html/HtmlGenerator.fs @@ -55,8 +55,10 @@ module internal HtmlGenerator = let columns = #if NET6_0_OR_GREATER - if supportsNet6Types then rawColumns - else rawColumns |> List.map StructuralInference.downgradeNet6PrimitiveProperty + if supportsNet6Types then + rawColumns + else + rawColumns |> List.map StructuralInference.downgradeNet6PrimitiveProperty #else rawColumns #endif @@ -137,14 +139,21 @@ module internal HtmlGenerator = create, tableType - let private createListType getListTypeName (inferenceParameters, missingValuesStr, cultureStr) supportsNet6Types (list: HtmlList) = + let private createListType + getListTypeName + (inferenceParameters, missingValuesStr, cultureStr) + supportsNet6Types + (list: HtmlList) + = let rawColumns = HtmlInference.inferListType inferenceParameters list.Values let columns = #if NET6_0_OR_GREATER - if supportsNet6Types then rawColumns - else StructuralInference.downgradeNet6Types rawColumns + if supportsNet6Types then + rawColumns + else + StructuralInference.downgradeNet6Types rawColumns #else rawColumns #endif @@ -214,8 +223,10 @@ module internal HtmlGenerator = let columns = #if NET6_0_OR_GREATER - if supportsNet6Types then rawColumns - else StructuralInference.downgradeNet6Types rawColumns + if supportsNet6Types then + rawColumns + else + StructuralInference.downgradeNet6Types rawColumns #else rawColumns #endif @@ -329,7 +340,10 @@ module internal HtmlGenerator = match htmlObj with | Table table -> let containerType = getOrCreateContainer "Tables" - let create, tableType = createTableType getTypeName parameters supportsNet6Types table + + let create, tableType = + createTableType getTypeName parameters supportsNet6Types table + htmlType.AddMember tableType containerType.AddMember @@ -352,7 +366,10 @@ module internal HtmlGenerator = ) | DefinitionList definitionList -> let containerType = getOrCreateContainer "DefinitionLists" - let tableType = createDefinitionListType getTypeName parameters supportsNet6Types definitionList + + let tableType = + createDefinitionListType getTypeName parameters supportsNet6Types definitionList + htmlType.AddMember tableType containerType.AddMember diff --git a/src/FSharp.Data.DesignTime/Html/HtmlProvider.fs b/src/FSharp.Data.DesignTime/Html/HtmlProvider.fs index 7cb3c17fd..562fc6c7d 100644 --- a/src/FSharp.Data.DesignTime/Html/HtmlProvider.fs +++ b/src/FSharp.Data.DesignTime/Html/HtmlProvider.fs @@ -70,7 +70,12 @@ type public HtmlProvider(cfg: TypeProviderConfig) as this = doc |> HtmlRuntime.getHtmlObjects (Some inferenceParameters) includeLayoutTables - |> HtmlGenerator.generateTypes asm ns typeName (inferenceParameters, missingValuesStr, cultureStr) supportsNet6Types + |> HtmlGenerator.generateTypes + asm + ns + typeName + (inferenceParameters, missingValuesStr, cultureStr) + supportsNet6Types use _holder = IO.logTime "TypeGeneration" sample diff --git a/src/FSharp.Data.DesignTime/Json/JsonProvider.fs b/src/FSharp.Data.DesignTime/Json/JsonProvider.fs index c9278ea11..af2887e5d 100644 --- a/src/FSharp.Data.DesignTime/Json/JsonProvider.fs +++ b/src/FSharp.Data.DesignTime/Json/JsonProvider.fs @@ -100,8 +100,10 @@ type public JsonProvider(cfg: TypeProviderConfig) as this = JsonInference.inferType unitsOfMeasureProvider inferenceMode cultureInfo "" sampleJson) |> Array.fold (StructuralInference.subtypeInfered false) InferedType.Top #if NET6_0_OR_GREATER - if ProviderHelpers.runtimeSupportsNet6Types cfg.RuntimeAssembly then rawInfered - else StructuralInference.downgradeNet6Types rawInfered + if ProviderHelpers.runtimeSupportsNet6Types cfg.RuntimeAssembly then + rawInfered + else + StructuralInference.downgradeNet6Types rawInfered #else rawInfered #endif diff --git a/src/FSharp.Data.DesignTime/Xml/XmlProvider.fs b/src/FSharp.Data.DesignTime/Xml/XmlProvider.fs index 30039d633..18be71e67 100644 --- a/src/FSharp.Data.DesignTime/Xml/XmlProvider.fs +++ b/src/FSharp.Data.DesignTime/Xml/XmlProvider.fs @@ -74,10 +74,13 @@ type public XmlProvider(cfg: TypeProviderConfig) as this = let inferedType = use _holder = IO.logTime "Inference" sample - let t = schemaSet |> XsdParsing.getElements |> List.ofSeq |> XsdInference.inferElements + let t = + schemaSet |> XsdParsing.getElements |> List.ofSeq |> XsdInference.inferElements #if NET6_0_OR_GREATER - if ProviderHelpers.runtimeSupportsNet6Types cfg.RuntimeAssembly then t - else StructuralInference.downgradeNet6Types t + if ProviderHelpers.runtimeSupportsNet6Types cfg.RuntimeAssembly then + t + else + StructuralInference.downgradeNet6Types t #else t #endif @@ -129,8 +132,10 @@ type public XmlProvider(cfg: TypeProviderConfig) as this = globalInference |> Array.fold (StructuralInference.subtypeInfered false) InferedType.Top #if NET6_0_OR_GREATER - if ProviderHelpers.runtimeSupportsNet6Types cfg.RuntimeAssembly then t - else StructuralInference.downgradeNet6Types t + if ProviderHelpers.runtimeSupportsNet6Types cfg.RuntimeAssembly then + t + else + StructuralInference.downgradeNet6Types t #else t #endif diff --git a/src/FSharp.Data.Runtime.Utilities/StructuralInference.fs b/src/FSharp.Data.Runtime.Utilities/StructuralInference.fs index df70c6139..71a6b4df8 100644 --- a/src/FSharp.Data.Runtime.Utilities/StructuralInference.fs +++ b/src/FSharp.Data.Runtime.Utilities/StructuralInference.fs @@ -645,7 +645,10 @@ let internal downgradeNet6Types (inferedType: InferedType) : InferedType = System.Collections.Generic.HashSet( { new System.Collections.Generic.IEqualityComparer with member _.Equals(x, y) = obj.ReferenceEquals(x, y) - member _.GetHashCode(x) = System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(x) }) + + member _.GetHashCode(x) = + System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(x) } + ) let rec convert infType = if not (visited.Add(infType)) then @@ -660,13 +663,22 @@ let internal downgradeNet6Types (inferedType: InferedType) : InferedType = | InferedType.Collection(order, types) -> InferedType.Collection( order |> List.map downgradeTag, - types |> Map.toSeq |> Seq.map (fun (k, (m, t)) -> downgradeTag k, (m, convert t)) |> Map.ofSeq) + types + |> Map.toSeq + |> Seq.map (fun (k, (m, t)) -> downgradeTag k, (m, convert t)) + |> Map.ofSeq + ) | InferedType.Heterogeneous(types, containsOptional) -> InferedType.Heterogeneous( - types |> Map.toSeq |> Seq.map (fun (k, t) -> downgradeTag k, convert t) |> Map.ofSeq, - containsOptional) + types + |> Map.toSeq + |> Seq.map (fun (k, t) -> downgradeTag k, convert t) + |> Map.ofSeq, + containsOptional + ) | InferedType.Json(innerType, optional) -> InferedType.Json(convert innerType, optional) | _ -> infType + result convert inferedType @@ -677,9 +689,17 @@ let internal downgradeNet6PrimitiveProperty (field: StructuralTypes.PrimitiveInf let v = field.Value if v.InferedType = typeof then - { field with Value = { v with InferedType = typeof; RuntimeType = typeof } } + { field with + Value = + { v with + InferedType = typeof + RuntimeType = typeof } } elif v.InferedType = typeof then - { field with Value = { v with InferedType = typeof; RuntimeType = typeof } } + { field with + Value = + { v with + InferedType = typeof + RuntimeType = typeof } } else field #endif From 787cbac70f04876fa683299003ef642cd3e42a91 Mon Sep 17 00:00:00 2001 From: Don Syme Date: Sun, 22 Feb 2026 15:20:31 +0000 Subject: [PATCH 07/10] make optional --- docs/library/CsvProvider.fsx | 29 ++++++++++--------- docs/library/JsonProvider.fsx | 6 ++-- src/FSharp.Data.DesignTime/Csv/CsvProvider.fs | 9 ++++-- .../Html/HtmlProvider.fs | 8 +++-- .../Json/JsonProvider.fs | 9 ++++-- src/FSharp.Data.DesignTime/Xml/XmlProvider.fs | 11 ++++--- tests/FSharp.Data.Tests/CsvProvider.fs | 8 ++--- tests/FSharp.Data.Tests/HtmlProviderList.fs | 4 +-- tests/FSharp.Data.Tests/JsonProvider.fs | 6 ++-- tests/FSharp.Data.Tests/XmlProvider.fs | 19 ++++-------- 10 files changed, 58 insertions(+), 51 deletions(-) diff --git a/docs/library/CsvProvider.fsx b/docs/library/CsvProvider.fsx index e0c43755a..95e728047 100644 --- a/docs/library/CsvProvider.fsx +++ b/docs/library/CsvProvider.fsx @@ -89,7 +89,8 @@ The following sample calls the `Load` method with an URL that points to a live C let msft = Stocks.Load(__SOURCE_DIRECTORY__ + "/../data/MSFT.csv").Cache() // Look at the most recent row. Note the 'Date' property -// is of type 'DateOnly' (on .NET 6+) or 'DateTime' (on older targets) +// is of type 'DateTime' by default. Set PreferDateOnly = true +// to use 'DateOnly' on .NET 6+ // and 'Open' has a type 'decimal' let firstRow = msft.Rows |> Seq.head let lastDate = firstRow.Date @@ -108,9 +109,9 @@ collection of rows. We iterate over the rows using a `for` loop. As you can see to the columns in the CSV file. As you can see, the type provider also infers types of individual rows. The `Date` -property is inferred to be a `DateOnly` on .NET 6 and later (because the values in the sample -file are date-only strings without a time component), or `DateTime` on older targets. HLOC -prices are inferred as `decimal`. +property is inferred as `DateTime` by default. When you set `PreferDateOnly = true` +on .NET 6 and later, date-only strings (without a time component) are inferred as `DateOnly`. +HLOC prices are inferred as `decimal`. ## Using units of measure @@ -271,10 +272,9 @@ it by specifying the `InferRows` static parameter of `CsvProvider`. If you speci Columns with only `0`, `1`, `Yes`, `No`, `True`, or `False` will be set to `bool`. Columns with numerical values will be set to either `int`, `int64`, `decimal`, or `float`, in that order of preference. -On .NET 6 and later, columns whose values are all date-only strings (without a time component, e.g. `2023-01-15`) -are automatically inferred as `DateOnly`. If a column mixes `DateOnly` and `DateTime` values, it is unified to `DateTime`. -Note that `TimeOnly` is **not** auto-inferred because it is ambiguous with `TimeSpan`; use an explicit schema annotation -(see the list of valid types below) to get a `TimeOnly` column. +On .NET 6 and later, when you set `PreferDateOnly = true`, columns whose values are all date-only strings (without a time component, e.g. `2023-01-15`) +are inferred as `DateOnly`. Time-only strings are inferred as `TimeOnly`. If a column mixes `DateOnly` and `DateTime` values, it is unified to `DateTime`. +By default (`PreferDateOnly = false`), all date values are inferred as `DateTime` for backward compatibility. If a value is missing in any row, by default the CSV type provider will infer a nullable (for `int`, `int64`, and `DateOnly`) or an optional (for `bool`, `DateTime`, `DateTimeOffset`, and `Guid`). When a `decimal` would be inferred but there are missing values, we will infer a @@ -388,19 +388,20 @@ You can even mix and match the two syntaxes like this `Schema="int64,DidSurvive, ### DateOnly and TimeOnly (on .NET 6+) -On .NET 6 and later, date-only strings are automatically inferred as `DateOnly`. For example, a column -like `EventDate` containing values such as `2023-06-01` will be given the type `DateOnly`. +On .NET 6 and later, when you set `PreferDateOnly = true`, date-only strings are inferred as `DateOnly` +and time-only strings as `TimeOnly`. For example, a column like `EventDate` containing values such as +`2023-06-01` will be given the type `DateOnly`. By default (`PreferDateOnly = false`), dates are +inferred as `DateTime` for backward compatibility. You can also explicitly request a `DateOnly` or `TimeOnly` column using schema annotations: [lang=text] - EventDate,Duration (timeonly?) + EventDate (dateonly),Duration (timeonly?) 2023-06-01,08:30:00 2023-06-02, -In the example above, `EventDate` is auto-inferred as `DateOnly` and `Duration` is explicitly -annotated as `timeonly?` (a nullable `TimeOnly`). Note that `TimeOnly` is never auto-inferred -(to avoid confusion with `TimeSpan`), so the schema annotation is required to get a `TimeOnly` column. +In the example above, `EventDate` is explicitly annotated as `dateonly` and `Duration` is explicitly +annotated as `timeonly?` (a nullable `TimeOnly`). ## Transforming CSV files diff --git a/docs/library/JsonProvider.fsx b/docs/library/JsonProvider.fsx index 61b068239..51bb97d90 100644 --- a/docs/library/JsonProvider.fsx +++ b/docs/library/JsonProvider.fsx @@ -122,9 +122,9 @@ process it dynamically as described in [the documentation for `JsonValue`](JsonV ### Inferring date types String values in JSON that look like dates are inferred as `DateTime` or `DateTimeOffset`. -On .NET 6 and later, strings that represent a date without a time component (e.g. `"2023-01-15"`) -are inferred as `DateOnly`. Full datetime strings (e.g. `"2023-01-15T10:30:00"`) continue to be -inferred as `DateTime`. +On .NET 6 and later, when you set `PreferDateOnly = true`, strings that represent a date without a time component (e.g. `"2023-01-15"`) +are inferred as `DateOnly`, and time-only strings are inferred as `TimeOnly`. By default (`PreferDateOnly = false`), +all dates are inferred as `DateTime` for backward compatibility. ### Inferring record types diff --git a/src/FSharp.Data.DesignTime/Csv/CsvProvider.fs b/src/FSharp.Data.DesignTime/Csv/CsvProvider.fs index 049b966e1..12066e7a3 100644 --- a/src/FSharp.Data.DesignTime/Csv/CsvProvider.fs +++ b/src/FSharp.Data.DesignTime/Csv/CsvProvider.fs @@ -55,6 +55,7 @@ type public CsvProvider(cfg: TypeProviderConfig) as this = let encodingStr = args.[13] :?> string let resolutionFolder = args.[14] :?> string let resource = args.[15] :?> string + let preferDateOnly = args.[16] :?> bool // This provider already has a schema mechanism, so let's disable inline schemas. let inferenceMode = InferenceMode'.ValuesOnly @@ -115,7 +116,7 @@ type public CsvProvider(cfg: TypeProviderConfig) as this = unitsOfMeasureProvider ) #if NET6_0_OR_GREATER - if ProviderHelpers.runtimeSupportsNet6Types cfg.RuntimeAssembly then + if preferDateOnly && ProviderHelpers.runtimeSupportsNet6Types cfg.RuntimeAssembly then fields else fields |> List.map StructuralInference.downgradeNet6PrimitiveProperty @@ -232,7 +233,8 @@ type public CsvProvider(cfg: TypeProviderConfig) as this = ProvidedStaticParameter("Culture", typeof, parameterDefaultValue = "") ProvidedStaticParameter("Encoding", typeof, parameterDefaultValue = "") ProvidedStaticParameter("ResolutionFolder", typeof, parameterDefaultValue = "") - ProvidedStaticParameter("EmbeddedResource", typeof, parameterDefaultValue = "") ] + ProvidedStaticParameter("EmbeddedResource", typeof, parameterDefaultValue = "") + ProvidedStaticParameter("PreferDateOnly", typeof, parameterDefaultValue = false) ] let helpText = """Typed representation of a CSV file. @@ -255,7 +257,8 @@ type public CsvProvider(cfg: TypeProviderConfig) as this = The encoding used to read the sample. You can specify either the character set name or the codepage number. Defaults to UTF8 for files, and to ISO-8859-1 the for HTTP requests, unless charset is specified in the Content-Type response header. A directory that is used when resolving relative file references (at design time and in hosted execution). When specified, the type provider first attempts to load the sample from the specified resource - (e.g. 'MyCompany.MyAssembly, resource_name.csv'). This is useful when exposing types generated by the type provider.""" + (e.g. 'MyCompany.MyAssembly, resource_name.csv'). This is useful when exposing types generated by the type provider. + When true on .NET 6+, date-only strings are inferred as DateOnly and time-only strings as TimeOnly. Defaults to false for backward compatibility.""" do csvProvTy.AddXmlDoc helpText do csvProvTy.DefineStaticParameters(parameters, buildTypes) diff --git a/src/FSharp.Data.DesignTime/Html/HtmlProvider.fs b/src/FSharp.Data.DesignTime/Html/HtmlProvider.fs index 562fc6c7d..bedb1a34b 100644 --- a/src/FSharp.Data.DesignTime/Html/HtmlProvider.fs +++ b/src/FSharp.Data.DesignTime/Html/HtmlProvider.fs @@ -41,6 +41,7 @@ type public HtmlProvider(cfg: TypeProviderConfig) as this = let encodingStr = args.[5] :?> string let resolutionFolder = args.[6] :?> string let resource = args.[7] :?> string + let preferDateOnly = args.[8] :?> bool // Allowing inline schemas does not seem very valuable for this provider. // Let's stick to the default values for now. @@ -63,7 +64,8 @@ type public HtmlProvider(cfg: TypeProviderConfig) as this = InferenceMode = inferenceMode } #if NET6_0_OR_GREATER - let supportsNet6Types = ProviderHelpers.runtimeSupportsNet6Types cfg.RuntimeAssembly + let supportsNet6Types = + preferDateOnly && ProviderHelpers.runtimeSupportsNet6Types cfg.RuntimeAssembly #else let supportsNet6Types = false #endif @@ -97,7 +99,8 @@ type public HtmlProvider(cfg: TypeProviderConfig) as this = ProvidedStaticParameter("Culture", typeof, parameterDefaultValue = "") ProvidedStaticParameter("Encoding", typeof, parameterDefaultValue = "") ProvidedStaticParameter("ResolutionFolder", typeof, parameterDefaultValue = "") - ProvidedStaticParameter("EmbeddedResource", typeof, parameterDefaultValue = "") ] + ProvidedStaticParameter("EmbeddedResource", typeof, parameterDefaultValue = "") + ProvidedStaticParameter("PreferDateOnly", typeof, parameterDefaultValue = false) ] let helpText = """Typed representation of an HTML file. @@ -112,6 +115,7 @@ type public HtmlProvider(cfg: TypeProviderConfig) as this = A directory that is used when resolving relative file references (at design time and in hosted execution). When specified, the type provider first attempts to load the sample from the specified resource (e.g. 'MyCompany.MyAssembly, resource_name.html'). This is useful when exposing types generated by the type provider. + When true on .NET 6+, date-only strings are inferred as DateOnly and time-only strings as TimeOnly. Defaults to false for backward compatibility. """ do htmlProvTy.AddXmlDoc helpText diff --git a/src/FSharp.Data.DesignTime/Json/JsonProvider.fs b/src/FSharp.Data.DesignTime/Json/JsonProvider.fs index af2887e5d..70272d63e 100644 --- a/src/FSharp.Data.DesignTime/Json/JsonProvider.fs +++ b/src/FSharp.Data.DesignTime/Json/JsonProvider.fs @@ -57,6 +57,7 @@ type public JsonProvider(cfg: TypeProviderConfig) as this = let preferDictionaries = args.[8] :?> bool let inferenceMode = args.[9] :?> InferenceMode let schema = args.[10] :?> string + let preferDateOnly = args.[11] :?> bool let inferenceMode = InferenceMode'.FromPublicApi(inferenceMode, inferTypesFromValues) @@ -100,7 +101,7 @@ type public JsonProvider(cfg: TypeProviderConfig) as this = JsonInference.inferType unitsOfMeasureProvider inferenceMode cultureInfo "" sampleJson) |> Array.fold (StructuralInference.subtypeInfered false) InferedType.Top #if NET6_0_OR_GREATER - if ProviderHelpers.runtimeSupportsNet6Types cfg.RuntimeAssembly then + if preferDateOnly && ProviderHelpers.runtimeSupportsNet6Types cfg.RuntimeAssembly then rawInfered else StructuralInference.downgradeNet6Types rawInfered @@ -162,7 +163,8 @@ type public JsonProvider(cfg: TypeProviderConfig) as this = typeof, parameterDefaultValue = InferenceMode.BackwardCompatible ) - ProvidedStaticParameter("Schema", typeof, parameterDefaultValue = "") ] + ProvidedStaticParameter("Schema", typeof, parameterDefaultValue = "") + ProvidedStaticParameter("PreferDateOnly", typeof, parameterDefaultValue = false) ] let helpText = """Typed representation of a JSON document. @@ -185,7 +187,8 @@ type public JsonProvider(cfg: TypeProviderConfig) as this = | ValuesAndInlineSchemasHints -> Types of values are inferred from both values and inline schemas. Inline schemas are special string values that can define a type and/or unit of measure. Supported syntax: typeof<type> or typeof{type} or typeof<type<measure>> or typeof{type{measure}}. Valid measures are the default SI units, and valid types are int, int64, bool, float, decimal, date, datetimeoffset, timespan, guid and string. | ValuesAndInlineSchemasOverrides -> Same as ValuesAndInlineSchemasHints, but value inferred types are ignored when an inline schema is present. - Location of a JSON Schema file or a string containing a JSON Schema document. When specified, Sample and SampleIsList must not be used.""" + Location of a JSON Schema file or a string containing a JSON Schema document. When specified, Sample and SampleIsList must not be used. + When true on .NET 6+, date-only strings (e.g. "2023-01-15") are inferred as DateOnly and time-only strings as TimeOnly. Defaults to false for backward compatibility.""" do jsonProvTy.AddXmlDoc helpText do jsonProvTy.DefineStaticParameters(parameters, buildTypes) diff --git a/src/FSharp.Data.DesignTime/Xml/XmlProvider.fs b/src/FSharp.Data.DesignTime/Xml/XmlProvider.fs index 18be71e67..7b5766806 100644 --- a/src/FSharp.Data.DesignTime/Xml/XmlProvider.fs +++ b/src/FSharp.Data.DesignTime/Xml/XmlProvider.fs @@ -50,6 +50,7 @@ type public XmlProvider(cfg: TypeProviderConfig) as this = let inferTypesFromValues = args.[7] :?> bool let schema = args.[8] :?> string let inferenceMode = args.[9] :?> InferenceMode + let preferDateOnly = args.[10] :?> bool let inferenceMode = InferenceMode'.FromPublicApi(inferenceMode, inferTypesFromValues) @@ -77,7 +78,7 @@ type public XmlProvider(cfg: TypeProviderConfig) as this = let t = schemaSet |> XsdParsing.getElements |> List.ofSeq |> XsdInference.inferElements #if NET6_0_OR_GREATER - if ProviderHelpers.runtimeSupportsNet6Types cfg.RuntimeAssembly then + if preferDateOnly && ProviderHelpers.runtimeSupportsNet6Types cfg.RuntimeAssembly then t else StructuralInference.downgradeNet6Types t @@ -132,7 +133,7 @@ type public XmlProvider(cfg: TypeProviderConfig) as this = globalInference |> Array.fold (StructuralInference.subtypeInfered false) InferedType.Top #if NET6_0_OR_GREATER - if ProviderHelpers.runtimeSupportsNet6Types cfg.RuntimeAssembly then + if preferDateOnly && ProviderHelpers.runtimeSupportsNet6Types cfg.RuntimeAssembly then t else StructuralInference.downgradeNet6Types t @@ -193,7 +194,8 @@ type public XmlProvider(cfg: TypeProviderConfig) as this = "InferenceMode", typeof, parameterDefaultValue = InferenceMode.BackwardCompatible - ) ] + ) + ProvidedStaticParameter("PreferDateOnly", typeof, parameterDefaultValue = false) ] let helpText = """Typed representation of a XML file. @@ -216,7 +218,8 @@ type public XmlProvider(cfg: TypeProviderConfig) as this = | ValuesAndInlineSchemasHints -> Types of values are inferred from both values and inline schemas. Inline schemas are special string values that can define a type and/or unit of measure. Supported syntax: typeof<type> or typeof{type} or typeof<type<measure>> or typeof{type{measure}}. Valid measures are the default SI units, and valid types are int, int64, bool, float, decimal, date, datetimeoffset, timespan, guid and string. | ValuesAndInlineSchemasOverrides -> Same as ValuesAndInlineSchemasHints, but value inferred types are ignored when an inline schema is present. Note inline schemas are not used from Xsd documents. - """ + + When true on .NET 6+, date-only strings are inferred as DateOnly and time-only strings as TimeOnly. Defaults to false for backward compatibility.""" do xmlProvTy.AddXmlDoc helpText diff --git a/tests/FSharp.Data.Tests/CsvProvider.fs b/tests/FSharp.Data.Tests/CsvProvider.fs index 3008bc24c..ec67c88a5 100644 --- a/tests/FSharp.Data.Tests/CsvProvider.fs +++ b/tests/FSharp.Data.Tests/CsvProvider.fs @@ -51,7 +51,7 @@ let [] csvWithGermanDate = """Preisregelung_ID;Messgebiet_Nr;gueltig_se [] let ``Inference of german dates`` () = - let csv = CsvProvider.GetSample() + let csv = CsvProvider.GetSample() let rows = csv.Rows |> Seq.toArray let row = rows.[1] @@ -67,7 +67,7 @@ Float1,Float2,Float3,Float4,Int,Float5,Float6,Date [] let ``Inference of numbers with empty values`` () = - let csv = CsvProvider.GetSample() + let csv = CsvProvider.GetSample() let rows = csv.Rows |> Seq.toArray let row = rows.[0] @@ -101,7 +101,7 @@ let [] csvData = """DateOnly,DateWithOffset,MixedDate,OffsetOption (dat [] let ``Can infer DateTime and DateTimeOffset types correctly`` () = - let csv = CsvProvider.GetSample() + let csv = CsvProvider.GetSample() let firstRow = csv.Rows |> Seq.head let secondRow = csv.Rows |> Seq.item 1 @@ -153,7 +153,7 @@ let ``Does not treat invariant culture number such as 3.14 as a date in cultures targetCulture.DateTimeFormat.DateSeparator |> should equal "." targetCulture.DateTimeFormat.TimeSeparator |> should equal ":" // See https://github.com/fsprojects/FSharp.Data/issues/767 targetCulture.NumberFormat.NumberDecimalSeparator |> should equal "," - let csv = CsvProvider<"Data/DnbHistoriskeKurser.csv", ",", 10, Culture=norwayCultureName>.GetSample() + let csv = CsvProvider<"Data/DnbHistoriskeKurser.csv", ",", 10, Culture=norwayCultureName, PreferDateOnly = true>.GetSample() let row = csv.Rows |> Seq.head (row.Dato, row.USD) |> should equal (DateOnly(2013, 2, 7), "5.4970") diff --git a/tests/FSharp.Data.Tests/HtmlProviderList.fs b/tests/FSharp.Data.Tests/HtmlProviderList.fs index 9a92b5ec7..ac8620e05 100644 --- a/tests/FSharp.Data.Tests/HtmlProviderList.fs +++ b/tests/FSharp.Data.Tests/HtmlProviderList.fs @@ -73,7 +73,7 @@ let ``Simple List infers date type correctly ``() =
  • 03/03/2013
  • - """, PreferOptionals=true>.GetSample().Lists.List1 + """, PreferOptionals=true, PreferDateOnly=true>.GetSample().Lists.List1 list.Values |> should equal [|DateOnly(2013,1,1);DateOnly(2013,2,2);DateOnly(2013,3,3)|] [] @@ -144,7 +144,7 @@ let ``Handles simple definition list``() =
    - """, PreferOptionals=true>.GetSample().DefinitionLists.DefinitionList1 + """, PreferOptionals=true, PreferDateOnly=true>.GetSample().DefinitionLists.DefinitionList1 list.Count.Values |> should equal [|1;2|] list.Dates.Values |> should equal [|DateOnly(2014,1,1); DateOnly(2014, 2,2)|] list.Decimals.Values |> should equal [|1.23M; 2.23M|] diff --git a/tests/FSharp.Data.Tests/JsonProvider.fs b/tests/FSharp.Data.Tests/JsonProvider.fs index 5b176b424..c08cc9f01 100644 --- a/tests/FSharp.Data.Tests/JsonProvider.fs +++ b/tests/FSharp.Data.Tests/JsonProvider.fs @@ -261,7 +261,7 @@ let ``Nulls, Missing, and "" should make the type optional``() = [] let ``Heterogeneous types with Nulls, Missing, and "" should return None on all choices``() = - let j = JsonProvider<"""[{"a":"","b":null},{"a":2,"b":"3.4","c":"true"},{"a":false,"b":"2002/10/10","c":"2"},{"a":[],"b":[1],"c":{"z":1}},{"b":"00:30:00"}]""">.GetSamples() + let j = JsonProvider<"""[{"a":"","b":null},{"a":2,"b":"3.4","c":"true"},{"a":false,"b":"2002/10/10","c":"2"},{"a":[],"b":[1],"c":{"z":1}},{"b":"00:30:00"}]""", PreferDateOnly = true>.GetSamples() j.[0].A.Boolean |> should equal None j.[0].A.Number |> should equal None j.[0].A.Array |> should equal None @@ -635,7 +635,7 @@ let ``Can parse time span in different culture``() = [] let ``Parsing of values wrapped in quotes should work on heterogenous values``() = - let objs = JsonProvider<"""[{"a": "01/02/2000"}, {"a" : "3"}]""">.GetSamples() + let objs = JsonProvider<"""[{"a": "01/02/2000"}, {"a" : "3"}]""", PreferDateOnly = true>.GetSamples() objs.[0].A.DateOnly |> should equal (Some (DateOnly(2000,01,02))) objs.[0].A.Number |> should equal None objs.[1].A.DateOnly |> should equal None @@ -643,7 +643,7 @@ let ``Parsing of values wrapped in quotes should work on heterogenous values``() [] let ``Parsing of values wrapped in quotes should work on arrays``() = - let objs = JsonProvider<"""["01/02/2000", "02/02/2001", "3", 4]""">.GetSample() + let objs = JsonProvider<"""["01/02/2000", "02/02/2001", "3", 4]""", PreferDateOnly = true>.GetSample() objs.DateOnlies |> should equal [| DateOnly(2000,01,02); DateOnly(2001,02,02) |] objs.Numbers |> should equal [| 3; 4 |] diff --git a/tests/FSharp.Data.Tests/XmlProvider.fs b/tests/FSharp.Data.Tests/XmlProvider.fs index c8ee18695..7e34da43a 100644 --- a/tests/FSharp.Data.Tests/XmlProvider.fs +++ b/tests/FSharp.Data.Tests/XmlProvider.fs @@ -1140,7 +1140,7 @@ let SimpleTypesXsd = """ """ -type SimpleTypes = XmlProvider +type SimpleTypes = XmlProvider open System.Xml.Schema @@ -1161,7 +1161,7 @@ let ``simple types are formatted properly``() = SimpleTypes.A( int = 0, long = 0L, - date = System.DateTime.Today, + date = System.DateOnly.FromDateTime(System.DateTime.Today), dateTime = System.DateTimeOffset.Now, boolean = false, decimal = 0M, @@ -1171,7 +1171,7 @@ let ``simple types are formatted properly``() = SimpleTypes.A( int = System.Int32.MinValue, long = System.Int64.MinValue, - date = System.DateTime.MinValue.Date, + date = System.DateOnly.MinValue, dateTime = System.DateTimeOffset.MinValue, boolean = false, decimal = System.Decimal.MinValue, @@ -1181,7 +1181,7 @@ let ``simple types are formatted properly``() = SimpleTypes.A( int = System.Int32.MaxValue, long = System.Int64.MaxValue, - date = System.DateTime.MaxValue.Date, + date = System.DateOnly.MaxValue, dateTime = System.DateTimeOffset.MaxValue, boolean = true, decimal = System.Decimal.MaxValue, @@ -1194,7 +1194,7 @@ let ``simple types are formatted properly``() = isValid maxValues.XElement |> should equal true [] -let ``time is omitted when zero``() = +let ``date is formatted properly``() = let schema = SimpleTypes.GetSchema() let simpleValues date = SimpleTypes.A( @@ -1207,19 +1207,12 @@ let ``time is omitted when zero``() = double = System.Double.NaN) let isValidWithMsg = isValid schema true - // Don't display the error message each time when we expect to see it: - let isValidWithoutMsg = isValid schema false - let validXml = System.DateTime(2018, 8, 29) |> simpleValues + let validXml = System.DateOnly(2018, 8, 29) |> simpleValues isValidWithMsg validXml.XElement |> should equal true validXml.XElement.Attribute(XName.Get "date").Value |> should equal "2018-08-29" - let invalidXml = System.DateTime(2018, 8, 29, 5, 30, 56) |> simpleValues - isValidWithoutMsg invalidXml.XElement |> should equal false - invalidXml.XElement.Attribute(XName.Get "date").Value - |> should equal "2018-08-29T05:30:56.0000000" - type TimeSpanXML = XmlProvider<"Data/TimeSpans.xml"> [] From 1ee08bf69226ffc05c5d256cc39a72351c26e6eb Mon Sep 17 00:00:00 2001 From: Don Syme Date: Sun, 22 Feb 2026 15:37:20 +0000 Subject: [PATCH 08/10] fix tests --- .../TypeProviderInstantiation.fs | 39 ++++++++++++------- 1 file changed, 26 insertions(+), 13 deletions(-) diff --git a/tests/FSharp.Data.DesignTime.Tests/TypeProviderInstantiation.fs b/tests/FSharp.Data.DesignTime.Tests/TypeProviderInstantiation.fs index 6162da3ae..e2714bcc1 100644 --- a/tests/FSharp.Data.DesignTime.Tests/TypeProviderInstantiation.fs +++ b/tests/FSharp.Data.DesignTime.Tests/TypeProviderInstantiation.fs @@ -25,7 +25,8 @@ type internal CsvProviderArgs = Culture : string Encoding : string ResolutionFolder : string - EmbeddedResource : string } + EmbeddedResource : string + PreferDateOnly : bool } type internal XmlProviderArgs = { Sample : string @@ -37,7 +38,8 @@ type internal XmlProviderArgs = EmbeddedResource : string InferTypesFromValues : bool Schema : string - InferenceMode: InferenceMode } + InferenceMode: InferenceMode + PreferDateOnly : bool } type internal JsonProviderArgs = { Sample : string @@ -50,7 +52,8 @@ type internal JsonProviderArgs = InferTypesFromValues : bool PreferDictionaries : bool InferenceMode: InferenceMode - Schema: string } + Schema: string + PreferDateOnly : bool } type internal HtmlProviderArgs = { Sample : string @@ -60,7 +63,8 @@ type internal HtmlProviderArgs = Culture : string Encoding : string ResolutionFolder : string - EmbeddedResource : string } + EmbeddedResource : string + PreferDateOnly : bool } type internal WorldBankProviderArgs = { Sources : string @@ -93,7 +97,8 @@ type internal TypeProviderInstantiation = box x.Culture box x.Encoding box x.ResolutionFolder - box x.EmbeddedResource |] + box x.EmbeddedResource + box x.PreferDateOnly |] | Xml x -> (fun cfg -> new XmlProvider(cfg) :> TypeProviderForNamespaces), [| box x.Sample @@ -105,7 +110,8 @@ type internal TypeProviderInstantiation = box x.EmbeddedResource box x.InferTypesFromValues box x.Schema - box x.InferenceMode |] + box x.InferenceMode + box x.PreferDateOnly |] | Json x -> (fun cfg -> new JsonProvider(cfg) :> TypeProviderForNamespaces), [| box x.Sample @@ -118,7 +124,8 @@ type internal TypeProviderInstantiation = box x.InferTypesFromValues box x.PreferDictionaries box x.InferenceMode - box x.Schema |] + box x.Schema + box x.PreferDateOnly |] | Html x -> (fun cfg -> new HtmlProvider(cfg) :> TypeProviderForNamespaces), [| box x.Sample @@ -128,7 +135,8 @@ type internal TypeProviderInstantiation = box x.Culture box x.Encoding box x.ResolutionFolder - box x.EmbeddedResource |] + box x.EmbeddedResource + box x.PreferDateOnly |] | WorldBank x -> (fun cfg -> new WorldBankProvider(cfg) :> TypeProviderForNamespaces), [| box x.Sources @@ -223,7 +231,8 @@ type internal TypeProviderInstantiation = Encoding = args.[9] CacheRows = false ResolutionFolder = "" - EmbeddedResource = "" } + EmbeddedResource = "" + PreferDateOnly = false } | "Xml" -> Xml { Sample = args.[1] SampleIsList = args.[2] |> bool.Parse @@ -234,7 +243,8 @@ type internal TypeProviderInstantiation = EmbeddedResource = "" InferTypesFromValues = args.[5] |> bool.Parse Schema = args.[6] - InferenceMode = args.[7] |> InferenceMode.Parse } + InferenceMode = args.[7] |> InferenceMode.Parse + PreferDateOnly = false } | "Json" -> // Handle special case for Schema.json tests where some fields might be empty if args.Length > 5 && not (String.IsNullOrEmpty(args.[5])) then @@ -248,7 +258,8 @@ type internal TypeProviderInstantiation = InferTypesFromValues = args.[5] |> bool.Parse PreferDictionaries = args.[6] |> bool.Parse InferenceMode = args.[7] |> InferenceMode.Parse - Schema = if args.Length > 8 then args.[8] else "" } + Schema = if args.Length > 8 then args.[8] else "" + PreferDateOnly = false } else // This is for schema-based tests in the format "Json,,,,,true,false,BackwardCompatible,SimpleSchema.json" Json { Sample = args.[1] @@ -261,7 +272,8 @@ type internal TypeProviderInstantiation = InferTypesFromValues = true PreferDictionaries = false InferenceMode = InferenceMode.Parse "BackwardCompatible" - Schema = if args.Length > 8 then args.[8] else "" } + Schema = if args.Length > 8 then args.[8] else "" + PreferDateOnly = false } | "Html" -> Html { Sample = args.[1] PreferOptionals = args.[2] |> bool.Parse @@ -270,7 +282,8 @@ type internal TypeProviderInstantiation = Culture = args.[4] Encoding = "" ResolutionFolder = "" - EmbeddedResource = "" } + EmbeddedResource = "" + PreferDateOnly = false } | "WorldBank" -> WorldBank { Sources = args.[1] Asynchronous = args.[2] |> bool.Parse } From 511afbe773ad6e764ec85dfc5ec0594320f75904 Mon Sep 17 00:00:00 2001 From: Don Syme Date: Sun, 22 Feb 2026 15:41:53 +0000 Subject: [PATCH 09/10] fix tests --- src/FSharp.Data.Xml.Core/XmlRuntime.fs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/FSharp.Data.Xml.Core/XmlRuntime.fs b/src/FSharp.Data.Xml.Core/XmlRuntime.fs index dbabc040d..537a4d6af 100644 --- a/src/FSharp.Data.Xml.Core/XmlRuntime.fs +++ b/src/FSharp.Data.Xml.Core/XmlRuntime.fs @@ -227,6 +227,10 @@ type XmlRuntime = v.ToString("O", cultureInfo) | :? DateTimeOffset as v -> v.ToString("O", cultureInfo) | :? TimeSpan as v -> v.ToString("g", cultureInfo) +#if NET6_0_OR_GREATER + | :? DateOnly as v -> v.ToString("yyyy-MM-dd") + | :? TimeOnly as v -> v.ToString("HH:mm:ss", cultureInfo) +#endif | :? int as v -> strWithCulture v | :? int64 as v -> strWithCulture v | :? float as v -> strWithCulture v @@ -249,6 +253,10 @@ type XmlRuntime = | :? option as v -> optionToArray serialize v | :? option as v -> optionToArray serialize v | :? option as v -> optionToArray serialize v +#if NET6_0_OR_GREATER + | :? option as v -> optionToArray serialize v + | :? option as v -> optionToArray serialize v +#endif | :? option as v -> optionToArray serialize v | :? option as v -> optionToArray serialize v | :? option as v -> optionToArray serialize v From 237300b8ae344dca024e5c3fcc6fb449584388a7 Mon Sep 17 00:00:00 2001 From: Don Syme Date: Sun, 22 Feb 2026 15:48:26 +0000 Subject: [PATCH 10/10] fix tests --- tests/FSharp.Data.Tests/HtmlProvider.fs | 4 ++-- tests/FSharp.Data.Tests/JsonProvider.fs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/FSharp.Data.Tests/HtmlProvider.fs b/tests/FSharp.Data.Tests/HtmlProvider.fs index 884003f91..95b33ee7d 100644 --- a/tests/FSharp.Data.Tests/HtmlProvider.fs +++ b/tests/FSharp.Data.Tests/HtmlProvider.fs @@ -51,7 +51,7 @@ let ``Can create type for simple table``() = let table = SimpleHtml().Tables.Table table.Rows.[0].``Column 1`` |> should equal 1 -type MarketDepth = HtmlProvider<"Data/MarketDepth.htm"> +type MarketDepth = HtmlProvider<"Data/MarketDepth.htm", PreferDateOnly = true> [] let ``Can infer tables out of the market depth file``() = @@ -367,7 +367,7 @@ let ``Can infer DateTime and DateTimeOffset types correctly`` () = - """>.GetSample() + """, PreferDateOnly = true>.GetSample() let table = html.Tables.Table1 table.Rows.[0].DateOnly.GetType() |> should equal typeof table.Rows.[0].MixedDate.GetType() |> should equal typeof diff --git a/tests/FSharp.Data.Tests/JsonProvider.fs b/tests/FSharp.Data.Tests/JsonProvider.fs index c08cc9f01..5bc372124 100644 --- a/tests/FSharp.Data.Tests/JsonProvider.fs +++ b/tests/FSharp.Data.Tests/JsonProvider.fs @@ -837,7 +837,7 @@ type ServiceResponse = JsonProvider<"""[ """, SampleIsList = true> type FirstPayload = JsonProvider<"""{ "x" : 0.500, "y" : 0.000 }"""> -type SecondPayload = JsonProvider<"""{"user": "alice", "role": "admin", "registeredSince": "2021-11-01"}"""> +type SecondPayload = JsonProvider<"""{ "user": "alice", "role": "admin", "registeredSince": "2021-11-01" }""", PreferDateOnly = true> [] let ``Can re-load JsonValue`` () =