From be43c77a495b2f4176bfc7fca4a6f0202fed5dd8 Mon Sep 17 00:00:00 2001 From: Repo Assist Date: Wed, 25 Feb 2026 03:13:03 +0000 Subject: [PATCH 01/18] Add YamlProvider type provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements a YamlProvider that enables typed access to YAML documents, reusing the existing JSON inference and code generation infrastructure. Architecture: - New FSharp.Data.Yaml.Core project: contains YamlDocument (implements IJsonDocument) and YamlConversions (YAML → JsonValue via YamlDotNet) - YamlProvider in FSharp.Data.DesignTime: reuses JsonInference and JsonGenerator; parses YAML samples to JsonValue at design time - YamlDotNet 16.3 added as dependency for robust YAML 1.2 parsing YAML-to-JsonValue mapping: - Mappings → JsonValue.Record - Sequences → JsonValue.Array - Quoted scalars → JsonValue.String (preserves string intent) - Plain scalars: null/~/bool/int/float auto-detected per YAML core schema Supported static parameters: Sample, SampleIsList, RootName, Culture, Encoding, ResolutionFolder, EmbeddedResource, InferTypesFromValues, PreferDictionaries, InferenceMode, PreferDateOnly, UseOriginalNames Closes #1645 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- FSharp.Data.sln | 6 + paket.dependencies | 1 + paket.lock | 7 +- src/AssemblyInfo.Yaml.Core.fs | 27 +++ .../FSharp.Data.DesignTime.fsproj | 2 + .../Yaml/YamlProvider.fs | 173 +++++++++++++++++ src/FSharp.Data.DesignTime/paket.references | 3 +- .../FSharp.Data.Yaml.Core.fsproj | 23 +++ src/FSharp.Data.Yaml.Core/YamlDocument.fs | 182 ++++++++++++++++++ src/FSharp.Data.Yaml.Core/paket.references | 3 + src/FSharp.Data/FSharp.Data.fsproj | 1 + .../SignatureTestCases.config | 1 + .../TypeProviderInstantiation.fs | 52 +++++ ...ot,,True,False,BackwardCompatible.expected | 124 ++++++++++++ tests/FSharp.Data.Tests/Data/SimpleYaml.yaml | 12 ++ 15 files changed, 613 insertions(+), 4 deletions(-) create mode 100644 src/AssemblyInfo.Yaml.Core.fs create mode 100644 src/FSharp.Data.DesignTime/Yaml/YamlProvider.fs create mode 100644 src/FSharp.Data.Yaml.Core/FSharp.Data.Yaml.Core.fsproj create mode 100644 src/FSharp.Data.Yaml.Core/YamlDocument.fs create mode 100644 src/FSharp.Data.Yaml.Core/paket.references create mode 100644 tests/FSharp.Data.DesignTime.Tests/expected/Yaml,SimpleYaml.yaml,False,Root,,True,False,BackwardCompatible.expected create mode 100644 tests/FSharp.Data.Tests/Data/SimpleYaml.yaml diff --git a/FSharp.Data.sln b/FSharp.Data.sln index 292580c60..bb81b4e6f 100755 --- a/FSharp.Data.sln +++ b/FSharp.Data.sln @@ -36,6 +36,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "FSharp.Data.Core.Tests.CSha EndProject Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "FSharp.Data.Json.Core", "src\FSharp.Data.Json.Core\FSharp.Data.Json.Core.fsproj", "{DAEBFBCF-84CD-40BB-B8F6-99B1A9C4641F}" EndProject +Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "FSharp.Data.Yaml.Core", "src\FSharp.Data.Yaml.Core\FSharp.Data.Yaml.Core.fsproj", "{B2C3D4E5-6F7A-8901-BCDE-F12345678901}" +EndProject Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "FSharp.Data.Tests", "tests\FSharp.Data.Tests\FSharp.Data.Tests.fsproj", "{750148EC-6A05-421D-96A4-E5AC9D18AF58}" EndProject Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "FSharp.Data.Benchmarks", "tests\FSharp.Data.Benchmarks\FSharp.Data.Benchmarks.fsproj", "{A1B2C3D4-5E6F-7890-ABCD-EF1234567890}" @@ -128,6 +130,10 @@ Global {DAEBFBCF-84CD-40BB-B8F6-99B1A9C4641F}.Debug|Any CPU.Build.0 = Debug|Any CPU {DAEBFBCF-84CD-40BB-B8F6-99B1A9C4641F}.Release|Any CPU.ActiveCfg = Release|Any CPU {DAEBFBCF-84CD-40BB-B8F6-99B1A9C4641F}.Release|Any CPU.Build.0 = Release|Any CPU + {B2C3D4E5-6F7A-8901-BCDE-F12345678901}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B2C3D4E5-6F7A-8901-BCDE-F12345678901}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B2C3D4E5-6F7A-8901-BCDE-F12345678901}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B2C3D4E5-6F7A-8901-BCDE-F12345678901}.Release|Any CPU.Build.0 = Release|Any CPU {750148EC-6A05-421D-96A4-E5AC9D18AF58}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {750148EC-6A05-421D-96A4-E5AC9D18AF58}.Debug|Any CPU.Build.0 = Debug|Any CPU {750148EC-6A05-421D-96A4-E5AC9D18AF58}.Release|Any CPU.ActiveCfg = Release|Any CPU diff --git a/paket.dependencies b/paket.dependencies index 7eccdc0ed..dd7460c9d 100644 --- a/paket.dependencies +++ b/paket.dependencies @@ -12,6 +12,7 @@ github fsprojects/FSharp.TypeProviders.SDK src/ProvidedTypes.fs github fsprojects/FSharp.TypeProviders.SDK tests/ProvidedTypesTesting.fs nuget FSharp.Core >= 6.0.1 lowest_matching: true +nuget YamlDotNet >= 13.0.0 nuget Microsoft.SourceLink.GitHub 1.0 copy_local: true nuget Microsoft.SourceLink.Common 1.0 copy_local: true nuget Microsoft.Build.Tasks.Git 1.0 copy_local: true diff --git a/paket.lock b/paket.lock index c28241431..6ae87edef 100644 --- a/paket.lock +++ b/paket.lock @@ -22,11 +22,12 @@ NUGET NETStandard.Library (2.0.3) - restriction: || (== net8.0) (&& (== netstandard2.0) (>= netcoreapp2.2)) Microsoft.NETCore.Platforms (>= 1.1) NETStandard.Library.NETFramework (2.0.0-preview2-25405-01) + YamlDotNet (16.3) GITHUB remote: fsprojects/FSharp.TypeProviders.SDK - src/ProvidedTypes.fs (ce34c1cc71096857b8342f1dedf93391addc9df6) - src/ProvidedTypes.fsi (ce34c1cc71096857b8342f1dedf93391addc9df6) - tests/ProvidedTypesTesting.fs (ce34c1cc71096857b8342f1dedf93391addc9df6) + src/ProvidedTypes.fs (90e8e3532960bffc71198fc12a01761a1f39624d) + src/ProvidedTypes.fsi (90e8e3532960bffc71198fc12a01761a1f39624d) + tests/ProvidedTypesTesting.fs (90e8e3532960bffc71198fc12a01761a1f39624d) GROUP Benchmarks RESTRICTION: == net8.0 NUGET diff --git a/src/AssemblyInfo.Yaml.Core.fs b/src/AssemblyInfo.Yaml.Core.fs new file mode 100644 index 000000000..ad63c0fcb --- /dev/null +++ b/src/AssemblyInfo.Yaml.Core.fs @@ -0,0 +1,27 @@ +// Auto-Generated by FAKE; do not edit +namespace System + +open System.Reflection + +[] +[] +[] +[] +[] +do () + +module internal AssemblyVersionInformation = + [] + let AssemblyTitle = "FSharp.Data.Yaml.Core" + + [] + let AssemblyProduct = "FSharp.Data" + + [] + let AssemblyDescription = "Library of F# type providers and data access tools" + + [] + let AssemblyVersion = "6.6.0.0" + + [] + let AssemblyFileVersion = "6.6.0.0" diff --git a/src/FSharp.Data.DesignTime/FSharp.Data.DesignTime.fsproj b/src/FSharp.Data.DesignTime/FSharp.Data.DesignTime.fsproj index 0d698e53f..fc3148aa6 100755 --- a/src/FSharp.Data.DesignTime/FSharp.Data.DesignTime.fsproj +++ b/src/FSharp.Data.DesignTime/FSharp.Data.DesignTime.fsproj @@ -32,12 +32,14 @@ + + diff --git a/src/FSharp.Data.DesignTime/Yaml/YamlProvider.fs b/src/FSharp.Data.DesignTime/Yaml/YamlProvider.fs new file mode 100644 index 000000000..58e47a8bb --- /dev/null +++ b/src/FSharp.Data.DesignTime/Yaml/YamlProvider.fs @@ -0,0 +1,173 @@ +namespace ProviderImplementation + +open System +open System.IO +open FSharp.Core.CompilerServices +open ProviderImplementation +open ProviderImplementation.ProvidedTypes +open ProviderImplementation.ProviderHelpers +open FSharp.Data +open FSharp.Data.Runtime +open FSharp.Data.Runtime.BaseTypes +open FSharp.Data.Runtime.StructuralTypes +open FSharp.Data.Runtime.StructuralInference +open System.Net + +// ---------------------------------------------------------------------------------------------- + +#nowarn "10001" + +[] +type public YamlProvider(cfg: TypeProviderConfig) as this = + inherit + DisposableTypeProviderForNamespaces(cfg, assemblyReplacementMap = [ "FSharp.Data.DesignTime", "FSharp.Data" ]) + + // Generate namespace and type 'FSharp.Data.YamlProvider' + do AssemblyResolver.init () + let asm = System.Reflection.Assembly.GetExecutingAssembly() + let ns = "FSharp.Data" + + let yamlProvTy = + ProvidedTypeDefinition(asm, ns, "YamlProvider", None, hideObjectMethods = true, nonNullable = true) + + let buildTypes (typeName: string) (args: obj[]) = + + // Enable TLS 1.2 for samples requested through https. + ServicePointManager.SecurityProtocol <- ServicePointManager.SecurityProtocol ||| SecurityProtocolType.Tls12 + + // Generate the required type + let tpType = + ProvidedTypeDefinition(asm, ns, typeName, None, hideObjectMethods = true, nonNullable = true) + + let sample = args.[0] :?> string + let sampleIsList = args.[1] :?> bool + let rootName = args.[2] :?> string + + let rootName = + if String.IsNullOrWhiteSpace rootName then + "Root" + else + NameUtils.singularize rootName + + let cultureStr = args.[3] :?> string + let encodingStr = args.[4] :?> string + let resolutionFolder = args.[5] :?> string + let resource = args.[6] :?> string + let inferTypesFromValues = args.[7] :?> bool + let preferDictionaries = args.[8] :?> bool + let inferenceMode = args.[9] :?> InferenceMode + let preferDateOnly = args.[10] :?> bool + let useOriginalNames = args.[11] :?> bool + + let inferenceMode = + InferenceMode'.FromPublicApi(inferenceMode, inferTypesFromValues) + + let cultureInfo = TextRuntime.GetCulture cultureStr + let unitsOfMeasureProvider = ProviderHelpers.unitsOfMeasureProvider + + let getSpec _ value = + + let inferedType = + use _holder = IO.logTime "Inference" sample + + // Parse YAML sample into JsonValue, then use JSON inference + let rawInfered = + let samples = + use _holder = IO.logTime "Parsing" sample + + if sampleIsList then + // If SampleIsList, parse as a YAML sequence or multiple documents + match YamlDocument.ParseToJsonValue value with + | JsonValue.Array items -> items + | single -> [| single |] + else + [| YamlDocument.ParseToJsonValue 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 preferDateOnly && ProviderHelpers.runtimeSupportsNet6Types cfg.RuntimeAssembly then + rawInfered + else + StructuralInference.downgradeNet6Types rawInfered +#else + rawInfered +#endif + + use _holder = IO.logTime "TypeGeneration" sample + + let ctx = + JsonGenerationContext.Create( + cultureStr, + tpType, + unitsOfMeasureProvider, + inferenceMode, + ?preferDictionaries = Some preferDictionaries, + ?useOriginalNames = Some useOriginalNames + ) + + let result = JsonTypeBuilder.generateJsonType ctx false false rootName inferedType + + { GeneratedType = tpType + RepresentationType = result.ConvertedType + CreateFromTextReader = fun reader -> result.Convert <@@ YamlDocument.Create(%reader) @@> + CreateListFromTextReader = Some(fun reader -> result.Convert <@@ YamlDocument.CreateList(%reader) @@>) + CreateFromTextReaderForSampleList = fun reader -> result.Convert <@@ YamlDocument.CreateList(%reader) @@> + CreateFromValue = + Some(typeof, (fun value -> result.Convert <@@ YamlDocument.Create(%value, "") @@>)) } + + let source = if sampleIsList then SampleList sample else Sample sample + + generateType "YAML" source getSpec this cfg encodingStr resolutionFolder resource typeName None + + // Add static parameter that specifies the API we want to get (compile-time) + let parameters = + [ ProvidedStaticParameter("Sample", typeof, parameterDefaultValue = "") + ProvidedStaticParameter("SampleIsList", typeof, parameterDefaultValue = false) + ProvidedStaticParameter("RootName", typeof, parameterDefaultValue = "Root") + ProvidedStaticParameter("Culture", typeof, parameterDefaultValue = "") + ProvidedStaticParameter("Encoding", typeof, parameterDefaultValue = "") + ProvidedStaticParameter("ResolutionFolder", typeof, parameterDefaultValue = "") + ProvidedStaticParameter("EmbeddedResource", typeof, parameterDefaultValue = "") + ProvidedStaticParameter("InferTypesFromValues", typeof, parameterDefaultValue = true) + ProvidedStaticParameter("PreferDictionaries", typeof, parameterDefaultValue = false) + ProvidedStaticParameter( + "InferenceMode", + typeof, + parameterDefaultValue = InferenceMode.BackwardCompatible + ) + ProvidedStaticParameter("PreferDateOnly", typeof, parameterDefaultValue = false) + ProvidedStaticParameter("UseOriginalNames", typeof, parameterDefaultValue = false) ] + + let helpText = + """Typed representation of a YAML document. + Location of a YAML sample file or a string containing a sample YAML document. + If true, the sample should be a YAML sequence (list) and each element is used as an individual sample for type inference. + The name to be used for the root type. Defaults to `Root`. + The culture used for parsing numbers and dates. Defaults to the invariant culture. + 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 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.yaml'). This is useful when exposing types generated by the type provider. + + This parameter is deprecated. Please use InferenceMode instead. + If true, turns on additional type inference from values. + (e.g. type inference infers string values such as "123" as ints and values constrained to 0 and 1 as booleans.) + If true, YAML mappings are interpreted as dictionaries when the names of all the fields are inferred (by type inference rules) into the same non-string primitive type. + Possible values: + | NoInference -> Inference is disabled. All values are inferred as the most basic type permitted for the value (i.e. string or number or bool). + | ValuesOnly -> Types of values are inferred from the Sample. This is the default. + | 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. + | ValuesAndInlineSchemasOverrides -> Same as ValuesAndInlineSchemasHints, but value inferred types are ignored when an inline schema is present. + + 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. + When true, YAML key names are used as-is for generated property names instead of being normalized to PascalCase. Defaults to false.""" + + do yamlProvTy.AddXmlDoc helpText + do yamlProvTy.DefineStaticParameters(parameters, buildTypes) + + // Register the main type with F# compiler + do this.AddNamespace(ns, [ yamlProvTy ]) diff --git a/src/FSharp.Data.DesignTime/paket.references b/src/FSharp.Data.DesignTime/paket.references index 202e6f980..068d2f645 100644 --- a/src/FSharp.Data.DesignTime/paket.references +++ b/src/FSharp.Data.DesignTime/paket.references @@ -1,2 +1,3 @@ Microsoft.SourceLink.GitHub -FSharp.Core \ No newline at end of file +FSharp.Core +YamlDotNet \ No newline at end of file diff --git a/src/FSharp.Data.Yaml.Core/FSharp.Data.Yaml.Core.fsproj b/src/FSharp.Data.Yaml.Core/FSharp.Data.Yaml.Core.fsproj new file mode 100644 index 000000000..1e8383a3b --- /dev/null +++ b/src/FSharp.Data.Yaml.Core/FSharp.Data.Yaml.Core.fsproj @@ -0,0 +1,23 @@ + + + + Library + netstandard2.0;net8.0 + $(OtherFlags) --warnon:1182 --nowarn:10001 --nowarn:44 + true + false + logo.png + true + + + + + + + + + + + + + diff --git a/src/FSharp.Data.Yaml.Core/YamlDocument.fs b/src/FSharp.Data.Yaml.Core/YamlDocument.fs new file mode 100644 index 000000000..52b6c313b --- /dev/null +++ b/src/FSharp.Data.Yaml.Core/YamlDocument.fs @@ -0,0 +1,182 @@ +// -------------------------------------------------------------------------------------- +// YAML type provider - runtime support +// -------------------------------------------------------------------------------------- +namespace FSharp.Data.Runtime.BaseTypes + +open System +open System.ComponentModel +open System.IO +open System.Globalization +open FSharp.Data +open YamlDotNet.RepresentationModel + +#nowarn "10001" + +// -------------------------------------------------------------------------------------- +// Conversion from YAML nodes to JsonValue +// -------------------------------------------------------------------------------------- + +module internal YamlConversions = + + let rec yamlNodeToJsonValue (node: YamlNode) : JsonValue = + match node with + | :? YamlMappingNode as mapping -> + let props = + [| for kvp in mapping.Children do + let key = + match kvp.Key with + | :? YamlScalarNode as s -> s.Value + | other -> other.ToString() + + yield (key, yamlNodeToJsonValue kvp.Value) |] + + JsonValue.Record props + + | :? YamlSequenceNode as sequence -> + let elements = [| for item in sequence.Children -> yamlNodeToJsonValue item |] + JsonValue.Array elements + + | :? YamlScalarNode as scalar -> + let value = scalar.Value + + if value = null then + JsonValue.Null + else + match scalar.Style with + | YamlDotNet.Core.ScalarStyle.SingleQuoted + | YamlDotNet.Core.ScalarStyle.DoubleQuoted + | YamlDotNet.Core.ScalarStyle.Literal + | YamlDotNet.Core.ScalarStyle.Folded -> + // Quoted/block scalars are always strings + JsonValue.String value + | _ -> + // Plain scalars: auto-detect type (YAML core schema) + if value = "null" || value = "~" || value = "" then + JsonValue.Null + elif value = "true" || value = "True" || value = "TRUE" then + JsonValue.Boolean true + elif value = "false" || value = "False" || value = "FALSE" then + JsonValue.Boolean false + elif value = ".inf" || value = ".Inf" || value = ".INF" || value = "+.inf" then + JsonValue.Float Double.PositiveInfinity + elif value = "-.inf" || value = "-.Inf" || value = "-.INF" then + JsonValue.Float Double.NegativeInfinity + elif value = ".nan" || value = ".NaN" || value = ".NAN" then + JsonValue.Float Double.NaN + else + // Try decimal first (preserves precision for integers and most decimals) + match Decimal.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture) with + | true, d -> JsonValue.Number d + | false, _ -> + match Double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture) with + | true, f -> JsonValue.Float f + | false, _ -> JsonValue.String value + + | _ -> JsonValue.Null + + let parseYaml (text: string) : JsonValue = + let yaml = YamlStream() + use reader = new StringReader(text) + yaml.Load(reader) + + if yaml.Documents.Count = 0 then + JsonValue.Null + else + yamlNodeToJsonValue yaml.Documents.[0].RootNode + + let parseYamlDocuments (text: string) : JsonValue[] = + let yaml = YamlStream() + use reader = new StringReader(text) + yaml.Load(reader) + [| for doc in yaml.Documents -> yamlNodeToJsonValue doc.RootNode |] + + +// -------------------------------------------------------------------------------------- +// YamlDocument - implements IJsonDocument so it can be used with JSON-based generated code +// -------------------------------------------------------------------------------------- + +/// Underlying representation of types generated by YamlProvider +[] +type YamlDocument = + + private + { + /// + Json: JsonValue + /// + Path: string + } + + interface IJsonDocument with + member x.JsonValue = x.Json + member x.Path() = x.Path + + member x.CreateNew(value, pathIncrement) = + YamlDocument.Create(value, x.Path + pathIncrement) + + /// The underlying JsonValue representation of the YAML document + member x.JsonValue = x.Json + + /// + [] + [] + override x.ToString() = x.JsonValue.ToString() + + /// + [] + [] + static member ParseToJsonValue(text: string) : JsonValue = YamlConversions.parseYaml text + + /// + [] + [] + static member ParseToJsonValueArray(text: string) : JsonValue[] = YamlConversions.parseYamlDocuments text + + /// + [] + [] + static member Create(value, path) = + { Json = value; Path = path } :> IJsonDocument + + /// + [] + [] + static member Create(reader: TextReader) = + use reader = reader + let text = reader.ReadToEnd() + let value = YamlConversions.parseYaml text + YamlDocument.Create(value, "") + + /// + [] + [] + static member CreateList(reader: TextReader) = + use reader = reader + let text = reader.ReadToEnd() + + // If the top-level YAML document is a sequence, treat each element as an item + match YamlConversions.parseYaml text with + | JsonValue.Array items -> + items + |> Array.mapi (fun i value -> YamlDocument.Create(value, "[" + string i + "]")) + | single -> + // Otherwise, treat the whole document as one item + [| YamlDocument.Create(single, "") |] diff --git a/src/FSharp.Data.Yaml.Core/paket.references b/src/FSharp.Data.Yaml.Core/paket.references new file mode 100644 index 000000000..e985bc7ec --- /dev/null +++ b/src/FSharp.Data.Yaml.Core/paket.references @@ -0,0 +1,3 @@ +Microsoft.SourceLink.GitHub +FSharp.Core +YamlDotNet diff --git a/src/FSharp.Data/FSharp.Data.fsproj b/src/FSharp.Data/FSharp.Data.fsproj index 597933cfb..00ddaa549 100755 --- a/src/FSharp.Data/FSharp.Data.fsproj +++ b/src/FSharp.Data/FSharp.Data.fsproj @@ -25,6 +25,7 @@ all + diff --git a/tests/FSharp.Data.DesignTime.Tests/SignatureTestCases.config b/tests/FSharp.Data.DesignTime.Tests/SignatureTestCases.config index 1bc1ca96c..0abfaa2d0 100644 --- a/tests/FSharp.Data.DesignTime.Tests/SignatureTestCases.config +++ b/tests/FSharp.Data.DesignTime.Tests/SignatureTestCases.config @@ -217,3 +217,4 @@ Html,SimpleHtmlLists.html,false,false, Html,EmptyDefinitionLists.html,false,false, Html,zoopla.html,false,false, Html,zoopla2.html,false,false, +Yaml,SimpleYaml.yaml,false,Root,,true,false,BackwardCompatible diff --git a/tests/FSharp.Data.DesignTime.Tests/TypeProviderInstantiation.fs b/tests/FSharp.Data.DesignTime.Tests/TypeProviderInstantiation.fs index c42b50876..01b99ba81 100644 --- a/tests/FSharp.Data.DesignTime.Tests/TypeProviderInstantiation.fs +++ b/tests/FSharp.Data.DesignTime.Tests/TypeProviderInstantiation.fs @@ -72,6 +72,20 @@ type internal HtmlProviderArgs = EmbeddedResource : string PreferDateOnly : bool } +type internal YamlProviderArgs = + { Sample : string + SampleIsList : bool + RootName : string + Culture : string + Encoding : string + ResolutionFolder : string + EmbeddedResource : string + InferTypesFromValues : bool + PreferDictionaries : bool + InferenceMode: InferenceMode + PreferDateOnly : bool + UseOriginalNames : bool } + type internal WorldBankProviderArgs = { Sources : string Asynchronous : bool } @@ -81,6 +95,7 @@ type internal TypeProviderInstantiation = | Xml of XmlProviderArgs | Json of JsonProviderArgs | Html of HtmlProviderArgs + | Yaml of YamlProviderArgs | WorldBank of WorldBankProviderArgs member x.GenerateType resolutionFolder runtimeAssembly runtimeAssemblyRefs = @@ -149,6 +164,20 @@ type internal TypeProviderInstantiation = box x.ResolutionFolder box x.EmbeddedResource box x.PreferDateOnly |] + | Yaml x -> + (fun cfg -> new YamlProvider(cfg) :> TypeProviderForNamespaces), + [| box x.Sample + box x.SampleIsList + box x.RootName + box x.Culture + box x.Encoding + box x.ResolutionFolder + box x.EmbeddedResource + box x.InferTypesFromValues + box x.PreferDictionaries + box x.InferenceMode + box x.PreferDateOnly + box x.UseOriginalNames |] | WorldBank x -> (fun cfg -> new WorldBankProvider(cfg) :> TypeProviderForNamespaces), [| box x.Sources @@ -194,6 +223,15 @@ type internal TypeProviderInstantiation = x.PreferOptionals.ToString() x.IncludeLayoutTables.ToString() x.Culture ] + | Yaml x -> + ["Yaml" + x.Sample + x.SampleIsList.ToString() + x.RootName + x.Culture + x.InferTypesFromValues.ToString() + x.PreferDictionaries.ToString() + x.InferenceMode.ToString() ] | WorldBank x -> ["WorldBank" x.Sources @@ -304,6 +342,19 @@ type internal TypeProviderInstantiation = ResolutionFolder = "" EmbeddedResource = "" PreferDateOnly = false } + | "Yaml" -> + Yaml { Sample = args.[1] + SampleIsList = args.[2] |> bool.Parse + RootName = args.[3] + Culture = args.[4] + Encoding = "" + ResolutionFolder = "" + EmbeddedResource = "" + InferTypesFromValues = args.[5] |> bool.Parse + PreferDictionaries = args.[6] |> bool.Parse + InferenceMode = args.[7] |> InferenceMode.Parse + PreferDateOnly = false + UseOriginalNames = false } | "WorldBank" -> WorldBank { Sources = args.[1] Asynchronous = args.[2] |> bool.Parse } @@ -324,6 +375,7 @@ type internal TypeProviderInstantiation = "FSharp.Data.Html.Core" "FSharp.Data.Xml.Core" "FSharp.Data.Json.Core" + "FSharp.Data.Yaml.Core" "FSharp.Data.WorldBank.Core" ] let extraRefs = [ for j in extraDlls do diff --git a/tests/FSharp.Data.DesignTime.Tests/expected/Yaml,SimpleYaml.yaml,False,Root,,True,False,BackwardCompatible.expected b/tests/FSharp.Data.DesignTime.Tests/expected/Yaml,SimpleYaml.yaml,False,Root,,True,False,BackwardCompatible.expected new file mode 100644 index 000000000..2c0adbd28 --- /dev/null +++ b/tests/FSharp.Data.DesignTime.Tests/expected/Yaml,SimpleYaml.yaml,False,Root,,True,False,BackwardCompatible.expected @@ -0,0 +1,124 @@ +class YamlProvider : obj + static member AsyncGetSample: () -> YamlProvider+Root async + let f = new Func<_,_>(fun (t:TextReader) -> YamlDocument.Create(t)) + TextRuntime.AsyncMap((IO.asyncReadTextAtRuntimeWithDesignTimeRules "" "" "YAML" "" "SimpleYaml.yaml"), f) + + static member AsyncLoad: uri:string -> YamlProvider+Root async + let f = new Func<_,_>(fun (t:TextReader) -> YamlDocument.Create(t)) + TextRuntime.AsyncMap((IO.asyncReadTextAtRuntime false "" "" "YAML" "" uri), f) + + static member GetSample: () -> YamlProvider+Root + YamlDocument.Create(FSharpAsync.RunSynchronously((IO.asyncReadTextAtRuntimeWithDesignTimeRules "" "" "YAML" "" "SimpleYaml.yaml"))) + + static member Load: stream:System.IO.Stream -> YamlProvider+Root + YamlDocument.Create(((new StreamReader(stream)) :> TextReader)) + + static member Load: reader:System.IO.TextReader -> YamlProvider+Root + YamlDocument.Create(reader) + + static member Load: uri:string -> YamlProvider+Root + YamlDocument.Create(FSharpAsync.RunSynchronously((IO.asyncReadTextAtRuntime false "" "" "YAML" "" uri))) + + static member Load: value:JsonValue -> YamlProvider+Root + YamlDocument.Create(value, "") + + static member Parse: text:string -> YamlProvider+Root + YamlDocument.Create(((new StringReader(text)) :> TextReader)) + + static member ParseList: text:string -> YamlProvider+YamlProvider+Root[] + YamlDocument.CreateList(((new StringReader(text)) :> TextReader)) + + +class YamlProvider+Root : FDR.BaseTypes.IJsonDocument + new : name:string -> age:int -> score:decimal -> active:bool -> address:YamlProvider+Address -> tags:string[] -> YamlProvider+Root + JsonRuntime.CreateRecord([| ("name", + (name :> obj)) + ("age", + (age :> obj)) + ("score", + (score :> obj)) + ("active", + (active :> obj)) + ("address", + (address :> obj)) + ("tags", + (tags :> obj)) |], "") + + new : jsonValue:JsonValue -> YamlProvider+Root + JsonDocument.Create(jsonValue, "") + + member Active: bool with get + let value = JsonRuntime.TryGetPropertyUnpackedWithPath(this, "active") + JsonRuntime.GetNonOptionalValue(value.Path, JsonRuntime.ConvertBoolean(value.JsonOpt), value.JsonOpt) + + member Address: YamlProvider+Address with get + JsonRuntime.GetPropertyPacked(this, "address") + + member Age: int with get + let value = JsonRuntime.TryGetPropertyUnpackedWithPath(this, "age") + JsonRuntime.GetNonOptionalValue(value.Path, JsonRuntime.ConvertInteger("", value.JsonOpt), value.JsonOpt) + + member Name: string with get + let value = JsonRuntime.TryGetPropertyUnpackedWithPath(this, "name") + JsonRuntime.GetNonOptionalValue(value.Path, JsonRuntime.ConvertString("", value.JsonOpt), value.JsonOpt) + + member Score: decimal with get + let value = JsonRuntime.TryGetPropertyUnpackedWithPath(this, "score") + JsonRuntime.GetNonOptionalValue(value.Path, JsonRuntime.ConvertDecimal("", value.JsonOpt), value.JsonOpt) + + member Tags: string[] with get + JsonRuntime.ConvertArray(JsonRuntime.GetPropertyPackedOrNull(this, "tags"), new Func<_,_>(fun (t:IJsonDocument) -> JsonRuntime.GetNonOptionalValue(t.Path(), JsonRuntime.ConvertString("", Some t.JsonValue), Some t.JsonValue))) + + member WithActive: active:bool -> YamlProvider+Root + JsonRuntime.WithRecordProperty(this, "active", (active :> obj), "") + + member WithAddress: address:YamlProvider+Address -> YamlProvider+Root + JsonRuntime.WithRecordProperty(this, "address", (address :> obj), "") + + member WithAge: age:int -> YamlProvider+Root + JsonRuntime.WithRecordProperty(this, "age", (age :> obj), "") + + member WithName: name:string -> YamlProvider+Root + JsonRuntime.WithRecordProperty(this, "name", (name :> obj), "") + + member WithScore: score:decimal -> YamlProvider+Root + JsonRuntime.WithRecordProperty(this, "score", (score :> obj), "") + + member WithTags: tags:string[] -> YamlProvider+Root + JsonRuntime.WithRecordProperty(this, "tags", (tags :> obj), "") + + +class YamlProvider+Address : FDR.BaseTypes.IJsonDocument + new : street:string -> city:string -> zip:int -> YamlProvider+Address + JsonRuntime.CreateRecord([| ("street", + (street :> obj)) + ("city", + (city :> obj)) + ("zip", + (zip :> obj)) |], "") + + new : jsonValue:JsonValue -> YamlProvider+Address + JsonDocument.Create(jsonValue, "") + + member City: string with get + let value = JsonRuntime.TryGetPropertyUnpackedWithPath(this, "city") + JsonRuntime.GetNonOptionalValue(value.Path, JsonRuntime.ConvertString("", value.JsonOpt), value.JsonOpt) + + member Street: string with get + let value = JsonRuntime.TryGetPropertyUnpackedWithPath(this, "street") + JsonRuntime.GetNonOptionalValue(value.Path, JsonRuntime.ConvertString("", value.JsonOpt), value.JsonOpt) + + member WithCity: city:string -> YamlProvider+Address + JsonRuntime.WithRecordProperty(this, "city", (city :> obj), "") + + member WithStreet: street:string -> YamlProvider+Address + JsonRuntime.WithRecordProperty(this, "street", (street :> obj), "") + + member WithZip: zip:int -> YamlProvider+Address + JsonRuntime.WithRecordProperty(this, "zip", (zip :> obj), "") + + member Zip: int with get + let value = JsonRuntime.TryGetPropertyUnpackedWithPath(this, "zip") + JsonRuntime.GetNonOptionalValue(value.Path, JsonRuntime.ConvertInteger("", value.JsonOpt), value.JsonOpt) + + diff --git a/tests/FSharp.Data.Tests/Data/SimpleYaml.yaml b/tests/FSharp.Data.Tests/Data/SimpleYaml.yaml new file mode 100644 index 000000000..ed99cdd6c --- /dev/null +++ b/tests/FSharp.Data.Tests/Data/SimpleYaml.yaml @@ -0,0 +1,12 @@ +name: John +age: 30 +score: 9.5 +active: true +address: + street: 123 Main St + city: Springfield + zip: "01234" +tags: + - fsharp + - dotnet + - yaml From 42f6d41c674da051e861534eb172a9fa01e4588a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 25 Feb 2026 03:15:49 +0000 Subject: [PATCH 02/18] ci: trigger CI checks From 5ddba51f547fd37bce544f5729b3406e615970ac Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 25 Feb 2026 17:42:23 +0000 Subject: [PATCH 03/18] YamlProvider: preserve quoted scalar strings during type inference In YAML, quoted scalars are explicitly strings by spec. Previously, with InferTypesFromValues=true (the default), JsonInference would re-infer values like "01234" as int even when they were explicitly quoted as strings in the YAML source. Fix: add a design-time parsing function (ParseToJsonValueForInference) that substitutes quoted scalars with a non-numeric sentinel string, so inference always returns string for them. Runtime parsing is unchanged; only the design-time inference path is affected. Update the expected signature for SimpleYaml.yaml to reflect that zip: "01234" is now correctly inferred as string instead of int. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Yaml/YamlProvider.fs | 4 +- src/FSharp.Data.Yaml.Core/YamlDocument.fs | 67 +++++++++++++++++++ ...ot,,True,False,BackwardCompatible.expected | 8 +-- 3 files changed, 73 insertions(+), 6 deletions(-) diff --git a/src/FSharp.Data.DesignTime/Yaml/YamlProvider.fs b/src/FSharp.Data.DesignTime/Yaml/YamlProvider.fs index 58e47a8bb..2c3262d62 100644 --- a/src/FSharp.Data.DesignTime/Yaml/YamlProvider.fs +++ b/src/FSharp.Data.DesignTime/Yaml/YamlProvider.fs @@ -77,11 +77,11 @@ type public YamlProvider(cfg: TypeProviderConfig) as this = if sampleIsList then // If SampleIsList, parse as a YAML sequence or multiple documents - match YamlDocument.ParseToJsonValue value with + match YamlDocument.ParseToJsonValueForInference value with | JsonValue.Array items -> items | single -> [| single |] else - [| YamlDocument.ParseToJsonValue value |] + [| YamlDocument.ParseToJsonValueForInference value |] samples |> Array.map (fun sampleJson -> diff --git a/src/FSharp.Data.Yaml.Core/YamlDocument.fs b/src/FSharp.Data.Yaml.Core/YamlDocument.fs index 52b6c313b..abfa68669 100644 --- a/src/FSharp.Data.Yaml.Core/YamlDocument.fs +++ b/src/FSharp.Data.Yaml.Core/YamlDocument.fs @@ -90,6 +90,62 @@ module internal YamlConversions = yaml.Load(reader) [| for doc in yaml.Documents -> yamlNodeToJsonValue doc.RootNode |] + // Like yamlNodeToJsonValue, but for design-time type inference only. + // In YAML, quoted scalars are always strings by spec (the quoting is an explicit type annotation). + // When InferTypesFromValues=true, JsonInference would otherwise re-infer "01234" as int. + // We prevent this by substituting a guaranteed non-numeric sentinel for quoted scalars, + // so inference always returns string for them. Runtime parsing still uses yamlNodeToJsonValue. + let rec yamlNodeToJsonValueForInference (node: YamlNode) : JsonValue = + match node with + | :? YamlMappingNode as mapping -> + let props = + [| for kvp in mapping.Children do + let key = + match kvp.Key with + | :? YamlScalarNode as s -> s.Value + | other -> other.ToString() + + yield (key, yamlNodeToJsonValueForInference kvp.Value) |] + + JsonValue.Record props + + | :? YamlSequenceNode as sequence -> + let elements = + [| for item in sequence.Children -> yamlNodeToJsonValueForInference item |] + + JsonValue.Array elements + + | :? YamlScalarNode as scalar -> + let value = scalar.Value + + if value = null then + JsonValue.Null + else + match scalar.Style with + | YamlDotNet.Core.ScalarStyle.SingleQuoted + | YamlDotNet.Core.ScalarStyle.DoubleQuoted + | YamlDotNet.Core.ScalarStyle.Literal + | YamlDotNet.Core.ScalarStyle.Folded -> + // Quoted scalar: YAML spec says this is always a string. + // Use a non-numeric sentinel so InferTypesFromValues does not re-infer + // the value content as int/bool/date/etc. + JsonValue.String "quoted-string" + | _ -> + // Plain scalars: same as runtime conversion + yamlNodeToJsonValue node + + | _ -> JsonValue.Null + + let parseYamlForInference (text: string) : JsonValue = + let yaml = YamlStream() + use reader = new StringReader(text) + yaml.Load(reader) + + if yaml.Documents.Count = 0 then + JsonValue.Null + else + yamlNodeToJsonValueForInference yaml.Documents.[0].RootNode + // -------------------------------------------------------------------------------------- // YamlDocument - implements IJsonDocument so it can be used with JSON-based generated code @@ -141,6 +197,17 @@ type YamlDocument = IsError = false)>] static member ParseToJsonValueArray(text: string) : JsonValue[] = YamlConversions.parseYamlDocuments text + /// + /// Design-time only: like ParseToJsonValue but quoted scalars are represented with a + /// non-numeric sentinel string so that InferTypesFromValues does not re-infer them. + [] + [] + static member ParseToJsonValueForInference(text: string) : JsonValue = + YamlConversions.parseYamlForInference text + /// [] [ city:string -> zip:int -> YamlProvider+Address + new : street:string -> city:string -> zip:string -> YamlProvider+Address JsonRuntime.CreateRecord([| ("street", (street :> obj)) ("city", @@ -114,11 +114,11 @@ class YamlProvider+Address : FDR.BaseTypes.IJsonDocument member WithStreet: street:string -> YamlProvider+Address JsonRuntime.WithRecordProperty(this, "street", (street :> obj), "") - member WithZip: zip:int -> YamlProvider+Address + member WithZip: zip:string -> YamlProvider+Address JsonRuntime.WithRecordProperty(this, "zip", (zip :> obj), "") - member Zip: int with get + member Zip: string with get let value = JsonRuntime.TryGetPropertyUnpackedWithPath(this, "zip") - JsonRuntime.GetNonOptionalValue(value.Path, JsonRuntime.ConvertInteger("", value.JsonOpt), value.JsonOpt) + JsonRuntime.GetNonOptionalValue(value.Path, JsonRuntime.ConvertString("", value.JsonOpt), value.JsonOpt) From 6d14e2011c9ffc15e2420d180f2db192660f981d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 25 Feb 2026 17:44:08 +0000 Subject: [PATCH 04/18] ci: trigger CI checks From ee95202834098c24dfeaa6c1163f4343f93206a9 Mon Sep 17 00:00:00 2001 From: Repo Assist Date: Wed, 25 Feb 2026 17:57:51 +0000 Subject: [PATCH 05/18] YamlProvider: quoted YAML scalars always infer as string even when InferTypesFromValues=true YAML's quoting style is semantically meaningful: zip: "01234" explicitly marks the value as a string (to preserve the leading zero), even though the bare value looks numeric. Add yamlNodeToJsonValueForInference / parseYamlForInference which substitutes a plain-letter sentinel value for any quoted scalar that would otherwise be re-inferred as a numeric/date/bool type by JsonInference. The runtime parseYaml path is unchanged, so the actual value is preserved. Addresses @dsyme's request on PR #1646. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/FSharp.Data.Yaml.Core/YamlDocument.fs | 118 ++++++++++++++++++++++ 1 file changed, 118 insertions(+) diff --git a/src/FSharp.Data.Yaml.Core/YamlDocument.fs b/src/FSharp.Data.Yaml.Core/YamlDocument.fs index abfa68669..0ec662ffc 100644 --- a/src/FSharp.Data.Yaml.Core/YamlDocument.fs +++ b/src/FSharp.Data.Yaml.Core/YamlDocument.fs @@ -18,6 +18,110 @@ open YamlDotNet.RepresentationModel module internal YamlConversions = + // For design-time type inference: quoted YAML scalars should always be typed as + // strings, even when InferTypesFromValues=true. We use a non-numeric sentinel value + // so that JsonInference does not re-infer "01234" as int, etc. + let rec yamlNodeToJsonValueForInference (node: YamlNode) : JsonValue = + match node with + | :? YamlMappingNode as mapping -> + let props = + [| for kvp in mapping.Children do + let key = + match kvp.Key with + | :? YamlScalarNode as s -> s.Value + | other -> other.ToString() + + yield (key, yamlNodeToJsonValueForInference kvp.Value) |] + + JsonValue.Record props + + | :? YamlSequenceNode as sequence -> + let elements = + [| for item in sequence.Children -> yamlNodeToJsonValueForInference item |] + + JsonValue.Array elements + + | :? YamlScalarNode as scalar -> + let value = scalar.Value + + if value = null then + JsonValue.Null + else + match scalar.Style with + | YamlDotNet.Core.ScalarStyle.SingleQuoted + | YamlDotNet.Core.ScalarStyle.DoubleQuoted + | YamlDotNet.Core.ScalarStyle.Literal + | YamlDotNet.Core.ScalarStyle.Folded -> + // Explicitly quoted scalars are always strings in YAML. + // Use the original value if it is clearly non-numeric; otherwise substitute + // a plain-letter sentinel so that value-based type inference sees a string. + let sentinel = + match + System.Decimal.TryParse( + value, + System.Globalization.NumberStyles.Float, + System.Globalization.CultureInfo.InvariantCulture + ) + with + | true, _ -> "s" + | false, _ -> + match + System.Double.TryParse( + value, + System.Globalization.NumberStyles.Float, + System.Globalization.CultureInfo.InvariantCulture + ) + with + | true, _ -> "s" + | false, _ -> value + + JsonValue.String sentinel + | _ -> + // Plain scalars: use the same type-aware conversion as runtime + if value = "null" || value = "~" || value = "" then + JsonValue.Null + elif value = "true" || value = "True" || value = "TRUE" then + JsonValue.Boolean true + elif value = "false" || value = "False" || value = "FALSE" then + JsonValue.Boolean false + elif value = ".inf" || value = ".Inf" || value = ".INF" || value = "+.inf" then + JsonValue.Float System.Double.PositiveInfinity + elif value = "-.inf" || value = "-.Inf" || value = "-.INF" then + JsonValue.Float System.Double.NegativeInfinity + elif value = ".nan" || value = ".NaN" || value = ".NAN" then + JsonValue.Float System.Double.NaN + else + match + System.Decimal.TryParse( + value, + System.Globalization.NumberStyles.Float, + System.Globalization.CultureInfo.InvariantCulture + ) + with + | true, d -> JsonValue.Number d + | false, _ -> + match + System.Double.TryParse( + value, + System.Globalization.NumberStyles.Float, + System.Globalization.CultureInfo.InvariantCulture + ) + with + | true, f -> JsonValue.Float f + | false, _ -> JsonValue.String value + + | _ -> JsonValue.Null + + let parseYamlForInference (text: string) : JsonValue = + let yaml = YamlStream() + use reader = new StringReader(text) + yaml.Load(reader) + + if yaml.Documents.Count = 0 then + JsonValue.Null + else + yamlNodeToJsonValueForInference yaml.Documents.[0].RootNode + let rec yamlNodeToJsonValue (node: YamlNode) : JsonValue = match node with | :? YamlMappingNode as mapping -> @@ -189,6 +293,20 @@ type YamlDocument = IsError = false)>] static member ParseToJsonValue(text: string) : JsonValue = YamlConversions.parseYaml text + /// + /// Like ParseToJsonValue but for design-time type inference: explicitly quoted YAML scalars + /// are represented as string sentinels so that InferTypesFromValues does not re-infer + /// "01234" as int, etc. + /// + /// + [] + [] + static member ParseToJsonValueForInference(text: string) : JsonValue = + YamlConversions.parseYamlForInference text + /// [] [ Date: Wed, 25 Feb 2026 18:01:58 +0000 Subject: [PATCH 06/18] ci: trigger CI checks From fad496881bd74355a361fcd8b69ac3de0b97dea7 Mon Sep 17 00:00:00 2001 From: Repo Assist Date: Wed, 25 Feb 2026 19:38:49 +0000 Subject: [PATCH 07/18] Fix duplicate method definitions in YamlDocument.fs Remove duplicate definitions of yamlNodeToJsonValueForInference, parseYamlForInference, and ParseToJsonValueForInference that caused FS0037/FS0438 build errors. The first definition of each (with the sentinel-based quoted-scalar inference logic) is kept. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/FSharp.Data.Yaml.Core/YamlDocument.fs | 67 ----------------------- 1 file changed, 67 deletions(-) diff --git a/src/FSharp.Data.Yaml.Core/YamlDocument.fs b/src/FSharp.Data.Yaml.Core/YamlDocument.fs index 0ec662ffc..63fd88da2 100644 --- a/src/FSharp.Data.Yaml.Core/YamlDocument.fs +++ b/src/FSharp.Data.Yaml.Core/YamlDocument.fs @@ -194,62 +194,6 @@ module internal YamlConversions = yaml.Load(reader) [| for doc in yaml.Documents -> yamlNodeToJsonValue doc.RootNode |] - // Like yamlNodeToJsonValue, but for design-time type inference only. - // In YAML, quoted scalars are always strings by spec (the quoting is an explicit type annotation). - // When InferTypesFromValues=true, JsonInference would otherwise re-infer "01234" as int. - // We prevent this by substituting a guaranteed non-numeric sentinel for quoted scalars, - // so inference always returns string for them. Runtime parsing still uses yamlNodeToJsonValue. - let rec yamlNodeToJsonValueForInference (node: YamlNode) : JsonValue = - match node with - | :? YamlMappingNode as mapping -> - let props = - [| for kvp in mapping.Children do - let key = - match kvp.Key with - | :? YamlScalarNode as s -> s.Value - | other -> other.ToString() - - yield (key, yamlNodeToJsonValueForInference kvp.Value) |] - - JsonValue.Record props - - | :? YamlSequenceNode as sequence -> - let elements = - [| for item in sequence.Children -> yamlNodeToJsonValueForInference item |] - - JsonValue.Array elements - - | :? YamlScalarNode as scalar -> - let value = scalar.Value - - if value = null then - JsonValue.Null - else - match scalar.Style with - | YamlDotNet.Core.ScalarStyle.SingleQuoted - | YamlDotNet.Core.ScalarStyle.DoubleQuoted - | YamlDotNet.Core.ScalarStyle.Literal - | YamlDotNet.Core.ScalarStyle.Folded -> - // Quoted scalar: YAML spec says this is always a string. - // Use a non-numeric sentinel so InferTypesFromValues does not re-infer - // the value content as int/bool/date/etc. - JsonValue.String "quoted-string" - | _ -> - // Plain scalars: same as runtime conversion - yamlNodeToJsonValue node - - | _ -> JsonValue.Null - - let parseYamlForInference (text: string) : JsonValue = - let yaml = YamlStream() - use reader = new StringReader(text) - yaml.Load(reader) - - if yaml.Documents.Count = 0 then - JsonValue.Null - else - yamlNodeToJsonValueForInference yaml.Documents.[0].RootNode - // -------------------------------------------------------------------------------------- // YamlDocument - implements IJsonDocument so it can be used with JSON-based generated code @@ -315,17 +259,6 @@ type YamlDocument = IsError = false)>] static member ParseToJsonValueArray(text: string) : JsonValue[] = YamlConversions.parseYamlDocuments text - /// - /// Design-time only: like ParseToJsonValue but quoted scalars are represented with a - /// non-numeric sentinel string so that InferTypesFromValues does not re-infer them. - [] - [] - static member ParseToJsonValueForInference(text: string) : JsonValue = - YamlConversions.parseYamlForInference text - /// [] [ Date: Wed, 25 Feb 2026 19:42:20 +0000 Subject: [PATCH 08/18] ci: trigger CI checks From 0cf56259bc9ae5e56ef516fd301430da28b4ad8d Mon Sep 17 00:00:00 2001 From: Don Syme Date: Wed, 25 Feb 2026 20:13:34 +0000 Subject: [PATCH 09/18] Speed up PR CI: use RunTests target, add NuGet cache, upgrade actions to v4 (#1656) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Change build-windows from '-t All' to '-t RunTests': skips doc generation and pack steps that aren't needed for PR validation (~4-5 min saved) - Remove redundant 'Build (Debug)' step from both jobs (~2 min combined) - Add NuGet package caching via actions/cache@v4 (~30-40s saved on cache hits) - Upgrade actions/checkout v1 → v4 and actions/setup-dotnet v1 → v4 These changes should reduce total PR CI time from ~12 min to ~6-7 min. Closes #1649 Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/pull-requests.yml | 28 +++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/.github/workflows/pull-requests.yml b/.github/workflows/pull-requests.yml index 2163b6ae8..2c7e5b6f5 100644 --- a/.github/workflows/pull-requests.yml +++ b/.github/workflows/pull-requests.yml @@ -11,11 +11,18 @@ jobs: runs-on: windows-latest steps: - - uses: actions/checkout@v1 + - uses: actions/checkout@v4 - name: Setup .NET Core 8 - uses: actions/setup-dotnet@v1 + uses: actions/setup-dotnet@v4 with: dotnet-version: 8.0.400 + - name: Cache NuGet packages + uses: actions/cache@v4 + with: + path: ~/.nuget/packages + key: ${{ runner.os }}-nuget-${{ hashFiles('**/paket.lock') }} + restore-keys: | + ${{ runner.os }}-nuget- - name: Restore .NET local tools run: dotnet tool restore - name: Restore packages @@ -23,24 +30,27 @@ jobs: - name: Build and test (Release) env: FAKE_DETAILED_ERRORS: true - run: dotnet run --project build/build.fsproj -- -t All - - name: Build (Debug) - run: dotnet build -c Debug -v n + run: dotnet run --project build/build.fsproj -- -t RunTests build-ubuntu: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v1 + - uses: actions/checkout@v4 - name: Setup .NET Core 8 - uses: actions/setup-dotnet@v1 + uses: actions/setup-dotnet@v4 with: dotnet-version: 8.0.400 + - name: Cache NuGet packages + uses: actions/cache@v4 + with: + path: ~/.nuget/packages + key: ${{ runner.os }}-nuget-${{ hashFiles('**/paket.lock') }} + restore-keys: | + ${{ runner.os }}-nuget- - name: Restore .NET local tools run: dotnet tool restore - name: Restore packages run: dotnet paket restore - name: Build and test run: dotnet run --project build/build.fsproj -- -t RunTests - - name: Build (Debug) - run: dotnet build -c Debug -v n From 6861d8000670c7a80d2d3fb3d99dcdeeda540c80 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 25 Feb 2026 20:23:56 +0000 Subject: [PATCH 10/18] Add PreferFloats static parameter to CsvProvider (#1655) * Add PreferFloats static parameter to CsvProvider Adds a new boolean static parameter `PreferFloats` (default: `false`) to `CsvProvider` that causes the type inference to use `float` instead of `decimal` when a column's values can be parsed as either. This addresses the long-standing feature request in issue #838. The existing behaviour (inferring `decimal`) is preserved by default; set `PreferFloats = true` to opt in to `float` inference. Implementation: - `StructuralInference.fs`: add `preferFloats: bool` parameter to `inferPrimitiveType`; guard the decimal match arm with `when not preferFloats`; add internal helper `getInferedTypeFromStringPreferFloats`. - `CsvInference.fs`: thread `preferFloats` through `inferCellType`, `inferType`, `inferColumnTypes`, and `CsvFile.InferColumnTypes`. - `HtmlInference.fs`: pass `false` for the new parameter (HTML provider is unaffected). - `CsvProvider.fs`: register the new static parameter at index 19. - `TypeProviderInstantiation.fs`: update `CsvProviderArgs` record and args array for the design-time test harness. - `CsvProvider.fs` (tests): add two tests covering the new parameter. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * ci: trigger CI checks --------- Co-authored-by: Repo Assist Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: github-actions[bot] Co-authored-by: Don Syme --- src/FSharp.Data.Csv.Core/CsvInference.fs | 19 +++++++++++++++++-- src/FSharp.Data.DesignTime/Csv/CsvProvider.fs | 7 +++++-- src/FSharp.Data.Html.Core/HtmlInference.fs | 1 + .../StructuralInference.fs | 9 +++++++-- .../InferenceTests.fs | 4 ++-- .../TypeProviderInstantiation.fs | 9 ++++++--- tests/FSharp.Data.Tests/CsvProvider.fs | 18 ++++++++++++++++++ 7 files changed, 56 insertions(+), 11 deletions(-) diff --git a/src/FSharp.Data.Csv.Core/CsvInference.fs b/src/FSharp.Data.Csv.Core/CsvInference.fs index 14c4cb488..b15804e46 100644 --- a/src/FSharp.Data.Csv.Core/CsvInference.fs +++ b/src/FSharp.Data.Csv.Core/CsvInference.fs @@ -125,6 +125,7 @@ let internal inferCellType missingValues inferenceMode strictBooleans + preferFloats cultureInfo unit (value: string) @@ -140,7 +141,15 @@ let internal inferCellType InferedType.Null else let inferedType = - StructuralInference.getInferedTypeFromString unitsOfMeasureProvider inferenceMode cultureInfo value unit + if preferFloats then + StructuralInference.getInferedTypeFromStringPreferFloats + unitsOfMeasureProvider + inferenceMode + cultureInfo + value + unit + else + StructuralInference.getInferedTypeFromString unitsOfMeasureProvider inferenceMode cultureInfo value unit if strictBooleans then // With StrictBooleans=true, only "true"/"false" trigger bool inference. @@ -305,6 +314,7 @@ let internal inferType missingValues inferenceMode strictBooleans + preferFloats cultureInfo assumeMissingValues preferOptionals @@ -358,6 +368,7 @@ let internal inferType missingValues inferenceMode strictBooleans + preferFloats cultureInfo unit value @@ -456,6 +467,7 @@ let internal inferColumnTypes missingValues inferenceMode strictBooleans + preferFloats cultureInfo assumeMissingValues preferOptionals @@ -469,6 +481,7 @@ let internal inferColumnTypes missingValues inferenceMode strictBooleans + preferFloats cultureInfo assumeMissingValues preferOptionals @@ -497,7 +510,8 @@ type CsvFile with assumeMissingValues, preferOptionals, unitsOfMeasureProvider, - ?strictBooleans + ?strictBooleans, + ?preferFloats ) = let headerNamesAndUnits, schema = @@ -511,6 +525,7 @@ type CsvFile with missingValues inferenceMode (defaultArg strictBooleans false) + (defaultArg preferFloats false) cultureInfo assumeMissingValues preferOptionals diff --git a/src/FSharp.Data.DesignTime/Csv/CsvProvider.fs b/src/FSharp.Data.DesignTime/Csv/CsvProvider.fs index 85f700c4c..05c59fa9b 100644 --- a/src/FSharp.Data.DesignTime/Csv/CsvProvider.fs +++ b/src/FSharp.Data.DesignTime/Csv/CsvProvider.fs @@ -58,6 +58,7 @@ type public CsvProvider(cfg: TypeProviderConfig) as this = let preferDateOnly = args.[16] :?> bool let strictBooleans = args.[17] :?> bool let useOriginalNames = args.[18] :?> bool + let preferFloats = args.[19] :?> bool // This provider already has a schema mechanism, so let's disable inline schemas. let inferenceMode = InferenceMode'.ValuesOnly @@ -116,7 +117,8 @@ type public CsvProvider(cfg: TypeProviderConfig) as this = assumeMissingValues, preferOptionals, unitsOfMeasureProvider, - strictBooleans + strictBooleans, + preferFloats ) #if NET6_0_OR_GREATER if preferDateOnly && ProviderHelpers.runtimeSupportsNet6Types cfg.RuntimeAssembly then @@ -241,7 +243,8 @@ type public CsvProvider(cfg: TypeProviderConfig) as this = ProvidedStaticParameter("EmbeddedResource", typeof, parameterDefaultValue = "") ProvidedStaticParameter("PreferDateOnly", typeof, parameterDefaultValue = false) ProvidedStaticParameter("StrictBooleans", typeof, parameterDefaultValue = false) - ProvidedStaticParameter("UseOriginalNames", typeof, parameterDefaultValue = false) ] + ProvidedStaticParameter("UseOriginalNames", typeof, parameterDefaultValue = false) + ProvidedStaticParameter("PreferFloats", typeof, parameterDefaultValue = false) ] let helpText = """Typed representation of a CSV file. diff --git a/src/FSharp.Data.Html.Core/HtmlInference.fs b/src/FSharp.Data.Html.Core/HtmlInference.fs index 43e45375c..c56f8f46f 100644 --- a/src/FSharp.Data.Html.Core/HtmlInference.fs +++ b/src/FSharp.Data.Html.Core/HtmlInference.fs @@ -28,6 +28,7 @@ let internal inferColumns parameters (headerNamesAndUnits: _[]) rows = parameters.MissingValues parameters.InferenceMode false + false parameters.CultureInfo assumeMissingValues parameters.PreferOptionals diff --git a/src/FSharp.Data.Runtime.Utilities/StructuralInference.fs b/src/FSharp.Data.Runtime.Utilities/StructuralInference.fs index 71a6b4df8..ad7a68efa 100644 --- a/src/FSharp.Data.Runtime.Utilities/StructuralInference.fs +++ b/src/FSharp.Data.Runtime.Utilities/StructuralInference.fs @@ -525,6 +525,7 @@ let inferPrimitiveType (unitsOfMeasureProvider: IUnitsOfMeasureProvider) (inferenceMode: InferenceMode') (cultureInfo: CultureInfo) + (preferFloats: bool) (value: string) (desiredUnit: Type option) = @@ -573,7 +574,7 @@ let inferPrimitiveType makePrimitive typeof #endif | Parse TextConversions.AsDateTime date when not (isFakeDate date value) -> makePrimitive typeof - | Parse TextConversions.AsDecimal _ -> makePrimitive typeof + | Parse TextConversions.AsDecimal _ when not preferFloats -> makePrimitive typeof | Parse (TextConversions.AsFloat [||] false) _ -> makePrimitive typeof | Parse asGuid _ -> makePrimitive typeof | _ -> None @@ -622,7 +623,11 @@ let inferPrimitiveType /// Infers the type of a simple string value [] let getInferedTypeFromString unitsOfMeasureProvider inferenceMode cultureInfo value unit = - inferPrimitiveType unitsOfMeasureProvider inferenceMode cultureInfo value unit + inferPrimitiveType unitsOfMeasureProvider inferenceMode cultureInfo false value unit + +/// Infers the type of a simple string value, preferring float over decimal +let internal getInferedTypeFromStringPreferFloats unitsOfMeasureProvider inferenceMode cultureInfo value unit = + inferPrimitiveType unitsOfMeasureProvider inferenceMode cultureInfo true value unit #if NET6_0_OR_GREATER /// Replaces DateOnly → DateTime and TimeOnly → TimeSpan throughout an InferedType tree. diff --git a/tests/FSharp.Data.DesignTime.Tests/InferenceTests.fs b/tests/FSharp.Data.DesignTime.Tests/InferenceTests.fs index 414c82ec3..cdbdd4132 100644 --- a/tests/FSharp.Data.DesignTime.Tests/InferenceTests.fs +++ b/tests/FSharp.Data.DesignTime.Tests/InferenceTests.fs @@ -24,7 +24,7 @@ let internal unitsOfMeasureProvider = ProviderHelpers.unitsOfMeasureProvider let internal inferType (csv:CsvFile) inferRows missingValues cultureInfo schema assumeMissingValues preferOptionals = let headerNamesAndUnits, schema = parseHeaders csv.Headers csv.NumberOfColumns schema unitsOfMeasureProvider - inferType headerNamesAndUnits schema (csv.Rows |> Seq.map (fun x -> x.Columns)) inferRows missingValues inferenceMode false cultureInfo assumeMissingValues preferOptionals unitsOfMeasureProvider + inferType headerNamesAndUnits schema (csv.Rows |> Seq.map (fun x -> x.Columns)) inferRows missingValues inferenceMode false false cultureInfo assumeMissingValues preferOptionals unitsOfMeasureProvider let internal toRecord fields = InferedType.Record(None, fields, false) @@ -411,7 +411,7 @@ let ``Doesn't infer 12-002 as a date``() = [] let ``Doesn't infer ad3mar as a date``() = - StructuralInference.inferPrimitiveType unitsOfMeasureProvider inferenceMode CultureInfo.InvariantCulture "ad3mar" None + StructuralInference.inferPrimitiveType unitsOfMeasureProvider inferenceMode CultureInfo.InvariantCulture false "ad3mar" None |> should equal (InferedType.Primitive(typeof, None, false, false)) [] diff --git a/tests/FSharp.Data.DesignTime.Tests/TypeProviderInstantiation.fs b/tests/FSharp.Data.DesignTime.Tests/TypeProviderInstantiation.fs index 01b99ba81..c07f3f985 100644 --- a/tests/FSharp.Data.DesignTime.Tests/TypeProviderInstantiation.fs +++ b/tests/FSharp.Data.DesignTime.Tests/TypeProviderInstantiation.fs @@ -28,7 +28,8 @@ type internal CsvProviderArgs = EmbeddedResource : string PreferDateOnly : bool StrictBooleans : bool - UseOriginalNames : bool } + UseOriginalNames : bool + PreferFloats : bool } type internal XmlProviderArgs = { Sample : string @@ -121,7 +122,8 @@ type internal TypeProviderInstantiation = box x.EmbeddedResource box x.PreferDateOnly box x.StrictBooleans - box x.UseOriginalNames |] + box x.UseOriginalNames + box x.PreferFloats |] | Xml x -> (fun cfg -> new XmlProvider(cfg) :> TypeProviderForNamespaces), [| box x.Sample @@ -284,7 +286,8 @@ type internal TypeProviderInstantiation = EmbeddedResource = "" PreferDateOnly = false StrictBooleans = false - UseOriginalNames = false } + UseOriginalNames = false + PreferFloats = false } | "Xml" -> Xml { Sample = args.[1] SampleIsList = args.[2] |> bool.Parse diff --git a/tests/FSharp.Data.Tests/CsvProvider.fs b/tests/FSharp.Data.Tests/CsvProvider.fs index 7d24802a3..4187bc3d7 100644 --- a/tests/FSharp.Data.Tests/CsvProvider.fs +++ b/tests/FSharp.Data.Tests/CsvProvider.fs @@ -766,3 +766,21 @@ let ``CsvProvider Row With* methods do not mutate the original row`` () = let row = csv.Rows |> Seq.head let _ = row.WithName("Charlie") row.Name |> should equal "Alice" + +// Tests for PreferFloats static parameter (issue #838) +let [] csvWithDecimals = "Name,Price,Rate\nAlice,9.99,1.5\nBob,12.50,2.7" + +type CsvPreferFloats = CsvProvider +type CsvDefaultDecimal = CsvProvider + +[] +let ``CsvProvider PreferFloats=true infers float instead of decimal`` () = + let row = CsvPreferFloats.GetSample().Rows |> Seq.head + row.Price |> should equal 9.99 + row.Rate |> should equal 1.5 + +[] +let ``CsvProvider PreferFloats=false (default) infers decimal for decimal values`` () = + let row = CsvDefaultDecimal.GetSample().Rows |> Seq.head + row.Price |> should equal 9.99m + row.Rate |> should equal 1.5m From 86467c56892729955fe8947c410ac68a00c9838e Mon Sep 17 00:00:00 2001 From: Don Syme Date: Wed, 25 Feb 2026 20:26:31 +0000 Subject: [PATCH 11/18] AGENTS.md --- AGENTS.md | 71 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..816f73020 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,71 @@ +# Coding Agent Guidelines + +## Testing + +Add comprehensive tests for all new features. Tests are located in `tests/`: + +- `FSharp.Data.Core.Tests/` - Core functionality tests (CSV, JSON, HTML, XML parsers) +- `FSharp.Data.DesignTime.Tests/` - Type provider design-time tests +- `FSharp.Data.Tests/` - Integration and end-to-end tests +- `FSharp.Data.Reference.Tests/` - Reference/signature tests + +Match test style and naming conventions of existing tests in the appropriate project. + +## Code Formatting + +Run Fantomas before committing: + +```bash +dotnet run --project build/build.fsproj -t Format +``` + +To check formatting without modifying files: + +```bash +dotnet run --project build/build.fsproj -t CheckFormat +``` + +## Build and Test + +Build the solution: + +```bash +./build.sh +# or +dotnet run --project build/build.fsproj -t Build +``` + +Run all tests: + +```bash +dotnet run --project build/build.fsproj -t RunTests +``` + +Run everything (build, test, docs, pack): + +```bash +dotnet run --project build/build.fsproj -t All +``` + +## Documentation + +Documentation lives in `docs/`. Update relevant docs when adding features: + +- `docs/library/` - API and provider documentation +- `docs/tutorials/` - Tutorial content + +Generate and preview docs: + +```bash +dotnet run --project build/build.fsproj -t GenerateDocs +``` + +## Release Notes + +Update `RELEASE_NOTES.md` at the top of the file for any user-facing changes. Follow the existing format: + +```markdown +## X.Y.Z - Date + +- Description of change by @author in #PR +``` From edee98eb3367db851d22c08b293960b1d5a2764a Mon Sep 17 00:00:00 2001 From: Don Syme Date: Wed, 25 Feb 2026 20:45:43 +0000 Subject: [PATCH 12/18] try dsyme as user --- .github/workflows/push-master.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/push-master.yml b/.github/workflows/push-master.yml index 9335af15c..6b9449666 100644 --- a/.github/workflows/push-master.yml +++ b/.github/workflows/push-master.yml @@ -40,7 +40,7 @@ jobs: uses: NuGet/login@d22cc5f58ff5b88bf9bd452535b4335137e24544 id: login with: - user: fsprojects + user: dsyme - name: Publish NuGets (if this version not published before) run: dotnet nuget push bin\FSharp.Data.*.nupkg -s https://www.nuget.org/api/v2/package -k ${{ steps.nuget-login.outputs.NUGET_API_KEY }} --skip-duplicate From 7d31b13c46798756166516c6b147588bcc64cc41 Mon Sep 17 00:00:00 2001 From: Don Syme Date: Wed, 25 Feb 2026 20:49:53 +0000 Subject: [PATCH 13/18] fix build --- build/build.fs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/build/build.fs b/build/build.fs index 970e2e0e1..18307430e 100644 --- a/build/build.fs +++ b/build/build.fs @@ -249,8 +249,6 @@ let buildscript () = "Build" ==> "RunTests" ==> "All" "Build" ==> "RunBenchmarks" - Target.runOrDefaultWithArguments "Help" - [] let main argv = argv @@ -260,5 +258,5 @@ let main argv = |> Context.setExecutionContext buildscript () - Target.runOrDefaultWithArguments "build" + Target.runOrDefaultWithArguments "Help" 0 From 759a1ee16acf64d935396c6ef7997875055b14c3 Mon Sep 17 00:00:00 2001 From: Don Syme Date: Wed, 25 Feb 2026 21:00:20 +0000 Subject: [PATCH 14/18] updat CI --- .github/workflows/push-master.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/push-master.yml b/.github/workflows/push-master.yml index 6b9449666..bdef0bcde 100644 --- a/.github/workflows/push-master.yml +++ b/.github/workflows/push-master.yml @@ -42,7 +42,7 @@ jobs: with: user: dsyme - name: Publish NuGets (if this version not published before) - run: dotnet nuget push bin\FSharp.Data.*.nupkg -s https://www.nuget.org/api/v2/package -k ${{ steps.nuget-login.outputs.NUGET_API_KEY }} --skip-duplicate + run: dotnet nuget push bin\FSharp.Data.*.nupkg -s https://www.nuget.org/api/v2/package -k ${{ steps.login.outputs.NUGET_API_KEY }} --skip-duplicate # Trusted publishing uses NuGet OIDC # See: https://learn.microsoft.com/nuget/nuget-org/publish-a-package#trusted-publishing From acba729d44c8e36cf7ae9249484dc3ecfdbc6e48 Mon Sep 17 00:00:00 2001 From: Don Syme Date: Wed, 25 Feb 2026 21:41:25 +0000 Subject: [PATCH 15/18] update release notes --- RELEASE_NOTES.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 7a481e7fb..23c7552c8 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,5 +1,16 @@ # Release Notes +## 8.0.0 - Feb 25 2026 + +- Add PreferFloats static parameter to CsvProvider (#1655) +- Add With* methods to CsvProvider Row and JsonProvider record types (closes #1431) (#1639) +- Add DtdProcessing static parameter to XmlProvider (closes #1632) (#1635) +- Add OmitNullFields static parameter to JsonProvider (closes #1245) (#1638) +- Add UseOriginalNames parameter to XmlProvider (#1629) +- Fix HTML parser dropping whitespace between inline elements (issue #1330) (#1630) +- Fix HtmlNode.ToString: preserve whitespace in elements nested inside
 (closes #1509)
+- Fix CSV schema parsing: column names with parentheses no longer corrupt type annotation (fixes #946)
+
 ## 7.0.1
 
 - Revert "fix potential XXE vulnerability in XML parsing by @Thorium in #1596"

From eba109f120820faf2800f55765e3ce474b75a874 Mon Sep 17 00:00:00 2001
From: Don Syme 
Date: Wed, 25 Feb 2026 21:44:28 +0000
Subject: [PATCH 16/18] update versions

---
 src/AssemblyInfo.Csv.Core.fs          | 24 +++++++-----------------
 src/AssemblyInfo.DesignTime.fs        | 24 +++++++-----------------
 src/AssemblyInfo.Html.Core.fs         | 24 +++++++-----------------
 src/AssemblyInfo.Http.fs              | 24 +++++++-----------------
 src/AssemblyInfo.Json.Core.fs         | 24 +++++++-----------------
 src/AssemblyInfo.Runtime.Utilities.fs | 24 +++++++-----------------
 src/AssemblyInfo.WorldBank.Core.fs    | 24 +++++++-----------------
 src/AssemblyInfo.Xml.Core.fs          | 24 +++++++-----------------
 src/AssemblyInfo.fs                   | 24 +++++++-----------------
 9 files changed, 63 insertions(+), 153 deletions(-)

diff --git a/src/AssemblyInfo.Csv.Core.fs b/src/AssemblyInfo.Csv.Core.fs
index 888e73cc7..340e876f7 100644
--- a/src/AssemblyInfo.Csv.Core.fs
+++ b/src/AssemblyInfo.Csv.Core.fs
@@ -1,27 +1,17 @@
 // Auto-Generated by FAKE; do not edit
 namespace System
-
 open System.Reflection
 
 []
 []
 []
-[]
-[]
+[]
+[]
 do ()
 
 module internal AssemblyVersionInformation =
-    []
-    let AssemblyTitle = "FSharp.Data.Csv.Core"
-
-    []
-    let AssemblyProduct = "FSharp.Data"
-
-    []
-    let AssemblyDescription = "Library of F# type providers and data access tools"
-
-    []
-    let AssemblyVersion = "6.6.0.0"
-
-    []
-    let AssemblyFileVersion = "6.6.0.0"
+    let [] AssemblyTitle = "FSharp.Data.Csv.Core"
+    let [] AssemblyProduct = "FSharp.Data"
+    let [] AssemblyDescription = "Library of F# type providers and data access tools"
+    let [] AssemblyVersion = "8.0.0.0"
+    let [] AssemblyFileVersion = "8.0.0.0"
diff --git a/src/AssemblyInfo.DesignTime.fs b/src/AssemblyInfo.DesignTime.fs
index 3aad23dfc..5b960f5e6 100644
--- a/src/AssemblyInfo.DesignTime.fs
+++ b/src/AssemblyInfo.DesignTime.fs
@@ -1,27 +1,17 @@
 // Auto-Generated by FAKE; do not edit
 namespace System
-
 open System.Reflection
 
 []
 []
 []
-[]
-[]
+[]
+[]
 do ()
 
 module internal AssemblyVersionInformation =
-    []
-    let AssemblyTitle = "FSharp.Data.DesignTime"
-
-    []
-    let AssemblyProduct = "FSharp.Data"
-
-    []
-    let AssemblyDescription = "Library of F# type providers and data access tools"
-
-    []
-    let AssemblyVersion = "6.6.0.0"
-
-    []
-    let AssemblyFileVersion = "6.6.0.0"
+    let [] AssemblyTitle = "FSharp.Data.DesignTime"
+    let [] AssemblyProduct = "FSharp.Data"
+    let [] AssemblyDescription = "Library of F# type providers and data access tools"
+    let [] AssemblyVersion = "8.0.0.0"
+    let [] AssemblyFileVersion = "8.0.0.0"
diff --git a/src/AssemblyInfo.Html.Core.fs b/src/AssemblyInfo.Html.Core.fs
index c5af74d43..fb1e70bcd 100644
--- a/src/AssemblyInfo.Html.Core.fs
+++ b/src/AssemblyInfo.Html.Core.fs
@@ -1,27 +1,17 @@
 // Auto-Generated by FAKE; do not edit
 namespace System
-
 open System.Reflection
 
 []
 []
 []
-[]
-[]
+[]
+[]
 do ()
 
 module internal AssemblyVersionInformation =
-    []
-    let AssemblyTitle = "FSharp.Data.Html.Core"
-
-    []
-    let AssemblyProduct = "FSharp.Data"
-
-    []
-    let AssemblyDescription = "Library of F# type providers and data access tools"
-
-    []
-    let AssemblyVersion = "6.6.0.0"
-
-    []
-    let AssemblyFileVersion = "6.6.0.0"
+    let [] AssemblyTitle = "FSharp.Data.Html.Core"
+    let [] AssemblyProduct = "FSharp.Data"
+    let [] AssemblyDescription = "Library of F# type providers and data access tools"
+    let [] AssemblyVersion = "8.0.0.0"
+    let [] AssemblyFileVersion = "8.0.0.0"
diff --git a/src/AssemblyInfo.Http.fs b/src/AssemblyInfo.Http.fs
index d622309cb..5cbf31d26 100644
--- a/src/AssemblyInfo.Http.fs
+++ b/src/AssemblyInfo.Http.fs
@@ -1,27 +1,17 @@
 // Auto-Generated by FAKE; do not edit
 namespace System
-
 open System.Reflection
 
 []
 []
 []
-[]
-[]
+[]
+[]
 do ()
 
 module internal AssemblyVersionInformation =
-    []
-    let AssemblyTitle = "FSharp.Data.Http"
-
-    []
-    let AssemblyProduct = "FSharp.Data"
-
-    []
-    let AssemblyDescription = "Library of F# type providers and data access tools"
-
-    []
-    let AssemblyVersion = "6.6.0.0"
-
-    []
-    let AssemblyFileVersion = "6.6.0.0"
+    let [] AssemblyTitle = "FSharp.Data.Http"
+    let [] AssemblyProduct = "FSharp.Data"
+    let [] AssemblyDescription = "Library of F# type providers and data access tools"
+    let [] AssemblyVersion = "8.0.0.0"
+    let [] AssemblyFileVersion = "8.0.0.0"
diff --git a/src/AssemblyInfo.Json.Core.fs b/src/AssemblyInfo.Json.Core.fs
index b9c0ebccb..1711c441c 100644
--- a/src/AssemblyInfo.Json.Core.fs
+++ b/src/AssemblyInfo.Json.Core.fs
@@ -1,27 +1,17 @@
 // Auto-Generated by FAKE; do not edit
 namespace System
-
 open System.Reflection
 
 []
 []
 []
-[]
-[]
+[]
+[]
 do ()
 
 module internal AssemblyVersionInformation =
-    []
-    let AssemblyTitle = "FSharp.Data.Json.Core"
-
-    []
-    let AssemblyProduct = "FSharp.Data"
-
-    []
-    let AssemblyDescription = "Library of F# type providers and data access tools"
-
-    []
-    let AssemblyVersion = "6.6.0.0"
-
-    []
-    let AssemblyFileVersion = "6.6.0.0"
+    let [] AssemblyTitle = "FSharp.Data.Json.Core"
+    let [] AssemblyProduct = "FSharp.Data"
+    let [] AssemblyDescription = "Library of F# type providers and data access tools"
+    let [] AssemblyVersion = "8.0.0.0"
+    let [] AssemblyFileVersion = "8.0.0.0"
diff --git a/src/AssemblyInfo.Runtime.Utilities.fs b/src/AssemblyInfo.Runtime.Utilities.fs
index 6369e3d64..a96b7de15 100644
--- a/src/AssemblyInfo.Runtime.Utilities.fs
+++ b/src/AssemblyInfo.Runtime.Utilities.fs
@@ -1,27 +1,17 @@
 // Auto-Generated by FAKE; do not edit
 namespace System
-
 open System.Reflection
 
 []
 []
 []
-[]
-[]
+[]
+[]
 do ()
 
 module internal AssemblyVersionInformation =
-    []
-    let AssemblyTitle = "FSharp.Data.Runtime.Utilities"
-
-    []
-    let AssemblyProduct = "FSharp.Data"
-
-    []
-    let AssemblyDescription = "Library of F# type providers and data access tools"
-
-    []
-    let AssemblyVersion = "6.6.0.0"
-
-    []
-    let AssemblyFileVersion = "6.6.0.0"
+    let [] AssemblyTitle = "FSharp.Data.Runtime.Utilities"
+    let [] AssemblyProduct = "FSharp.Data"
+    let [] AssemblyDescription = "Library of F# type providers and data access tools"
+    let [] AssemblyVersion = "8.0.0.0"
+    let [] AssemblyFileVersion = "8.0.0.0"
diff --git a/src/AssemblyInfo.WorldBank.Core.fs b/src/AssemblyInfo.WorldBank.Core.fs
index 17eaa1171..b188e1637 100644
--- a/src/AssemblyInfo.WorldBank.Core.fs
+++ b/src/AssemblyInfo.WorldBank.Core.fs
@@ -1,27 +1,17 @@
 // Auto-Generated by FAKE; do not edit
 namespace System
-
 open System.Reflection
 
 []
 []
 []
-[]
-[]
+[]
+[]
 do ()
 
 module internal AssemblyVersionInformation =
-    []
-    let AssemblyTitle = "FSharp.Data.WorldBank.Core"
-
-    []
-    let AssemblyProduct = "FSharp.Data"
-
-    []
-    let AssemblyDescription = "Library of F# type providers and data access tools"
-
-    []
-    let AssemblyVersion = "6.6.0.0"
-
-    []
-    let AssemblyFileVersion = "6.6.0.0"
+    let [] AssemblyTitle = "FSharp.Data.WorldBank.Core"
+    let [] AssemblyProduct = "FSharp.Data"
+    let [] AssemblyDescription = "Library of F# type providers and data access tools"
+    let [] AssemblyVersion = "8.0.0.0"
+    let [] AssemblyFileVersion = "8.0.0.0"
diff --git a/src/AssemblyInfo.Xml.Core.fs b/src/AssemblyInfo.Xml.Core.fs
index 8f71377c6..dcaf811e6 100644
--- a/src/AssemblyInfo.Xml.Core.fs
+++ b/src/AssemblyInfo.Xml.Core.fs
@@ -1,27 +1,17 @@
 // Auto-Generated by FAKE; do not edit
 namespace System
-
 open System.Reflection
 
 []
 []
 []
-[]
-[]
+[]
+[]
 do ()
 
 module internal AssemblyVersionInformation =
-    []
-    let AssemblyTitle = "FSharp.Data.Xml.Core"
-
-    []
-    let AssemblyProduct = "FSharp.Data"
-
-    []
-    let AssemblyDescription = "Library of F# type providers and data access tools"
-
-    []
-    let AssemblyVersion = "6.6.0.0"
-
-    []
-    let AssemblyFileVersion = "6.6.0.0"
+    let [] AssemblyTitle = "FSharp.Data.Xml.Core"
+    let [] AssemblyProduct = "FSharp.Data"
+    let [] AssemblyDescription = "Library of F# type providers and data access tools"
+    let [] AssemblyVersion = "8.0.0.0"
+    let [] AssemblyFileVersion = "8.0.0.0"
diff --git a/src/AssemblyInfo.fs b/src/AssemblyInfo.fs
index 6f77a7564..f962f7f78 100644
--- a/src/AssemblyInfo.fs
+++ b/src/AssemblyInfo.fs
@@ -1,27 +1,17 @@
 // Auto-Generated by FAKE; do not edit
 namespace System
-
 open System.Reflection
 
 []
 []
 []
-[]
-[]
+[]
+[]
 do ()
 
 module internal AssemblyVersionInformation =
-    []
-    let AssemblyTitle = "FSharp.Data"
-
-    []
-    let AssemblyProduct = "FSharp.Data"
-
-    []
-    let AssemblyDescription = "Library of F# type providers and data access tools"
-
-    []
-    let AssemblyVersion = "6.6.0.0"
-
-    []
-    let AssemblyFileVersion = "6.6.0.0"
+    let [] AssemblyTitle = "FSharp.Data"
+    let [] AssemblyProduct = "FSharp.Data"
+    let [] AssemblyDescription = "Library of F# type providers and data access tools"
+    let [] AssemblyVersion = "8.0.0.0"
+    let [] AssemblyFileVersion = "8.0.0.0"

From bcbb4aaeda6d6c864ccda63049624c13f934622e Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
 <41898282+github-actions[bot]@users.noreply.github.com>
Date: Wed, 25 Feb 2026 22:07:25 +0000
Subject: [PATCH 17/18] Add PreferOptionals parameter to XmlProvider and
 JsonProvider (#1660)

* Add PreferOptionals parameter to XmlProvider and JsonProvider

Implements the PreferOptionals static parameter for XmlProvider and
JsonProvider, following the same pattern already used by CsvProvider
and HtmlProvider.

- JsonProvider: defaults to true (preserving existing behavior of
  using option types). When set to false, missing/null string fields
  use empty string and missing/null float fields use NaN.
- XmlProvider: defaults to true (preserving existing behavior).
  When set to false, absent string attributes/elements use empty
  string and absent float attributes/elements use NaN.

Also adds allowEmptyValues parameter to JsonInference.inferType so
it is threaded through to inferCollectionType for JSON arrays.

Closes #649

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* ci: trigger CI checks

* update build

* update build

---------

Co-authored-by: Repo Assist 
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: github-actions[bot] 
Co-authored-by: Don Syme 
---
 RELEASE_NOTES.md                              |  4 +++
 src/AssemblyInfo.Csv.Core.fs                  |  8 ++---
 src/AssemblyInfo.DesignTime.fs                |  8 ++---
 src/AssemblyInfo.Html.Core.fs                 |  8 ++---
 src/AssemblyInfo.Http.fs                      |  8 ++---
 src/AssemblyInfo.Json.Core.fs                 |  8 ++---
 src/AssemblyInfo.Runtime.Utilities.fs         |  8 ++---
 src/AssemblyInfo.WorldBank.Core.fs            |  8 ++---
 src/AssemblyInfo.Xml.Core.fs                  |  8 ++---
 src/AssemblyInfo.fs                           |  8 ++---
 .../Json/JsonProvider.fs                      | 17 ++++++++---
 src/FSharp.Data.DesignTime/Xml/XmlProvider.fs | 11 ++++---
 src/FSharp.Data.Json.Core/JsonInference.fs    | 17 ++++++++---
 src/FSharp.Data.Xml.Core/XmlInference.fs      |  1 +
 .../InferenceTests.fs                         | 30 +++++++++----------
 .../TypeProviderInstantiation.fs              | 21 ++++++++-----
 tests/FSharp.Data.Tests/JsonProvider.fs       | 20 +++++++++++++
 tests/FSharp.Data.Tests/XmlProvider.fs        | 19 ++++++++++++
 18 files changed, 142 insertions(+), 70 deletions(-)

diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md
index 23c7552c8..b69a921b9 100644
--- a/RELEASE_NOTES.md
+++ b/RELEASE_NOTES.md
@@ -1,5 +1,9 @@
 # Release Notes
 
+## 8.1.0-beta
+
+- Add `PreferOptionals` parameter to `JsonProvider` and `XmlProvider` (defaults to `true` to match existing behavior; set to `false` to use empty string or `NaN` for missing values, like the CsvProvider default) (closes #649)
+
 ## 8.0.0 - Feb 25 2026
 
 - Add PreferFloats static parameter to CsvProvider (#1655)
diff --git a/src/AssemblyInfo.Csv.Core.fs b/src/AssemblyInfo.Csv.Core.fs
index 340e876f7..88d047b19 100644
--- a/src/AssemblyInfo.Csv.Core.fs
+++ b/src/AssemblyInfo.Csv.Core.fs
@@ -5,13 +5,13 @@ open System.Reflection
 []
 []
 []
-[]
-[]
+[]
+[]
 do ()
 
 module internal AssemblyVersionInformation =
     let [] AssemblyTitle = "FSharp.Data.Csv.Core"
     let [] AssemblyProduct = "FSharp.Data"
     let [] AssemblyDescription = "Library of F# type providers and data access tools"
-    let [] AssemblyVersion = "8.0.0.0"
-    let [] AssemblyFileVersion = "8.0.0.0"
+    let [] AssemblyVersion = "8.1.0.0"
+    let [] AssemblyFileVersion = "8.1.0.0"
diff --git a/src/AssemblyInfo.DesignTime.fs b/src/AssemblyInfo.DesignTime.fs
index 5b960f5e6..6a0e77278 100644
--- a/src/AssemblyInfo.DesignTime.fs
+++ b/src/AssemblyInfo.DesignTime.fs
@@ -5,13 +5,13 @@ open System.Reflection
 []
 []
 []
-[]
-[]
+[]
+[]
 do ()
 
 module internal AssemblyVersionInformation =
     let [] AssemblyTitle = "FSharp.Data.DesignTime"
     let [] AssemblyProduct = "FSharp.Data"
     let [] AssemblyDescription = "Library of F# type providers and data access tools"
-    let [] AssemblyVersion = "8.0.0.0"
-    let [] AssemblyFileVersion = "8.0.0.0"
+    let [] AssemblyVersion = "8.1.0.0"
+    let [] AssemblyFileVersion = "8.1.0.0"
diff --git a/src/AssemblyInfo.Html.Core.fs b/src/AssemblyInfo.Html.Core.fs
index fb1e70bcd..3828c310f 100644
--- a/src/AssemblyInfo.Html.Core.fs
+++ b/src/AssemblyInfo.Html.Core.fs
@@ -5,13 +5,13 @@ open System.Reflection
 []
 []
 []
-[]
-[]
+[]
+[]
 do ()
 
 module internal AssemblyVersionInformation =
     let [] AssemblyTitle = "FSharp.Data.Html.Core"
     let [] AssemblyProduct = "FSharp.Data"
     let [] AssemblyDescription = "Library of F# type providers and data access tools"
-    let [] AssemblyVersion = "8.0.0.0"
-    let [] AssemblyFileVersion = "8.0.0.0"
+    let [] AssemblyVersion = "8.1.0.0"
+    let [] AssemblyFileVersion = "8.1.0.0"
diff --git a/src/AssemblyInfo.Http.fs b/src/AssemblyInfo.Http.fs
index 5cbf31d26..c0607906b 100644
--- a/src/AssemblyInfo.Http.fs
+++ b/src/AssemblyInfo.Http.fs
@@ -5,13 +5,13 @@ open System.Reflection
 []
 []
 []
-[]
-[]
+[]
+[]
 do ()
 
 module internal AssemblyVersionInformation =
     let [] AssemblyTitle = "FSharp.Data.Http"
     let [] AssemblyProduct = "FSharp.Data"
     let [] AssemblyDescription = "Library of F# type providers and data access tools"
-    let [] AssemblyVersion = "8.0.0.0"
-    let [] AssemblyFileVersion = "8.0.0.0"
+    let [] AssemblyVersion = "8.1.0.0"
+    let [] AssemblyFileVersion = "8.1.0.0"
diff --git a/src/AssemblyInfo.Json.Core.fs b/src/AssemblyInfo.Json.Core.fs
index 1711c441c..182d28a81 100644
--- a/src/AssemblyInfo.Json.Core.fs
+++ b/src/AssemblyInfo.Json.Core.fs
@@ -5,13 +5,13 @@ open System.Reflection
 []
 []
 []
-[]
-[]
+[]
+[]
 do ()
 
 module internal AssemblyVersionInformation =
     let [] AssemblyTitle = "FSharp.Data.Json.Core"
     let [] AssemblyProduct = "FSharp.Data"
     let [] AssemblyDescription = "Library of F# type providers and data access tools"
-    let [] AssemblyVersion = "8.0.0.0"
-    let [] AssemblyFileVersion = "8.0.0.0"
+    let [] AssemblyVersion = "8.1.0.0"
+    let [] AssemblyFileVersion = "8.1.0.0"
diff --git a/src/AssemblyInfo.Runtime.Utilities.fs b/src/AssemblyInfo.Runtime.Utilities.fs
index a96b7de15..2507b7785 100644
--- a/src/AssemblyInfo.Runtime.Utilities.fs
+++ b/src/AssemblyInfo.Runtime.Utilities.fs
@@ -5,13 +5,13 @@ open System.Reflection
 []
 []
 []
-[]
-[]
+[]
+[]
 do ()
 
 module internal AssemblyVersionInformation =
     let [] AssemblyTitle = "FSharp.Data.Runtime.Utilities"
     let [] AssemblyProduct = "FSharp.Data"
     let [] AssemblyDescription = "Library of F# type providers and data access tools"
-    let [] AssemblyVersion = "8.0.0.0"
-    let [] AssemblyFileVersion = "8.0.0.0"
+    let [] AssemblyVersion = "8.1.0.0"
+    let [] AssemblyFileVersion = "8.1.0.0"
diff --git a/src/AssemblyInfo.WorldBank.Core.fs b/src/AssemblyInfo.WorldBank.Core.fs
index b188e1637..a4c92e260 100644
--- a/src/AssemblyInfo.WorldBank.Core.fs
+++ b/src/AssemblyInfo.WorldBank.Core.fs
@@ -5,13 +5,13 @@ open System.Reflection
 []
 []
 []
-[]
-[]
+[]
+[]
 do ()
 
 module internal AssemblyVersionInformation =
     let [] AssemblyTitle = "FSharp.Data.WorldBank.Core"
     let [] AssemblyProduct = "FSharp.Data"
     let [] AssemblyDescription = "Library of F# type providers and data access tools"
-    let [] AssemblyVersion = "8.0.0.0"
-    let [] AssemblyFileVersion = "8.0.0.0"
+    let [] AssemblyVersion = "8.1.0.0"
+    let [] AssemblyFileVersion = "8.1.0.0"
diff --git a/src/AssemblyInfo.Xml.Core.fs b/src/AssemblyInfo.Xml.Core.fs
index dcaf811e6..986e467f8 100644
--- a/src/AssemblyInfo.Xml.Core.fs
+++ b/src/AssemblyInfo.Xml.Core.fs
@@ -5,13 +5,13 @@ open System.Reflection
 []
 []
 []
-[]
-[]
+[]
+[]
 do ()
 
 module internal AssemblyVersionInformation =
     let [] AssemblyTitle = "FSharp.Data.Xml.Core"
     let [] AssemblyProduct = "FSharp.Data"
     let [] AssemblyDescription = "Library of F# type providers and data access tools"
-    let [] AssemblyVersion = "8.0.0.0"
-    let [] AssemblyFileVersion = "8.0.0.0"
+    let [] AssemblyVersion = "8.1.0.0"
+    let [] AssemblyFileVersion = "8.1.0.0"
diff --git a/src/AssemblyInfo.fs b/src/AssemblyInfo.fs
index f962f7f78..5ce024ff6 100644
--- a/src/AssemblyInfo.fs
+++ b/src/AssemblyInfo.fs
@@ -5,13 +5,13 @@ open System.Reflection
 []
 []
 []
-[]
-[]
+[]
+[]
 do ()
 
 module internal AssemblyVersionInformation =
     let [] AssemblyTitle = "FSharp.Data"
     let [] AssemblyProduct = "FSharp.Data"
     let [] AssemblyDescription = "Library of F# type providers and data access tools"
-    let [] AssemblyVersion = "8.0.0.0"
-    let [] AssemblyFileVersion = "8.0.0.0"
+    let [] AssemblyVersion = "8.1.0.0"
+    let [] AssemblyFileVersion = "8.1.0.0"
diff --git a/src/FSharp.Data.DesignTime/Json/JsonProvider.fs b/src/FSharp.Data.DesignTime/Json/JsonProvider.fs
index 0969bbe62..fbd2688a0 100644
--- a/src/FSharp.Data.DesignTime/Json/JsonProvider.fs
+++ b/src/FSharp.Data.DesignTime/Json/JsonProvider.fs
@@ -60,6 +60,7 @@ type public JsonProvider(cfg: TypeProviderConfig) as this =
         let preferDateOnly = args.[11] :?> bool
         let useOriginalNames = args.[12] :?> bool
         let omitNullFields = args.[13] :?> bool
+        let preferOptionals = args.[14] :?> bool
 
         let inferenceMode =
             InferenceMode'.FromPublicApi(inferenceMode, inferTypesFromValues)
@@ -100,8 +101,14 @@ type public JsonProvider(cfg: TypeProviderConfig) as this =
 
                         samples
                         |> Array.map (fun sampleJson ->
-                            JsonInference.inferType unitsOfMeasureProvider inferenceMode cultureInfo "" sampleJson)
-                        |> Array.fold (StructuralInference.subtypeInfered false) InferedType.Top
+                            JsonInference.inferType
+                                unitsOfMeasureProvider
+                                inferenceMode
+                                cultureInfo
+                                (not preferOptionals)
+                                ""
+                                sampleJson)
+                        |> Array.fold (StructuralInference.subtypeInfered (not preferOptionals)) InferedType.Top
 #if NET6_0_OR_GREATER
                 if preferDateOnly && ProviderHelpers.runtimeSupportsNet6Types cfg.RuntimeAssembly then
                     rawInfered
@@ -170,7 +177,8 @@ type public JsonProvider(cfg: TypeProviderConfig) as this =
           ProvidedStaticParameter("Schema", typeof, parameterDefaultValue = "")
           ProvidedStaticParameter("PreferDateOnly", typeof, parameterDefaultValue = false)
           ProvidedStaticParameter("UseOriginalNames", typeof, parameterDefaultValue = false)
-          ProvidedStaticParameter("OmitNullFields", typeof, parameterDefaultValue = false) ]
+          ProvidedStaticParameter("OmitNullFields", typeof, parameterDefaultValue = false)
+          ProvidedStaticParameter("PreferOptionals", typeof, parameterDefaultValue = true) ]
 
     let helpText =
         """Typed representation of a JSON document.
@@ -196,7 +204,8 @@ type public JsonProvider(cfg: TypeProviderConfig) as this =
            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.
            When true, JSON property names are used as-is for generated property names instead of being normalized to PascalCase. Defaults to false.
-           When true, optional fields with value None are omitted from the generated JSON rather than serialized as null. Defaults to false."""
+           When true, optional fields with value None are omitted from the generated JSON rather than serialized as null. Defaults to false.
+           When set to true (default), inference will use the option type for missing or null values. When false, inference will prefer to use empty string or double.NaN for missing values where possible, matching the default CsvProvider behavior."""
 
     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 c6604a7dc..bb7b9cdbf 100644
--- a/src/FSharp.Data.DesignTime/Xml/XmlProvider.fs
+++ b/src/FSharp.Data.DesignTime/Xml/XmlProvider.fs
@@ -53,6 +53,7 @@ type public XmlProvider(cfg: TypeProviderConfig) as this =
         let preferDateOnly = args.[10] :?> bool
         let dtdProcessing = args.[11] :?> string
         let useOriginalNames = args.[12] :?> bool
+        let preferOptionals = args.[13] :?> bool
 
         let inferenceMode =
             InferenceMode'.FromPublicApi(inferenceMode, inferTypesFromValues)
@@ -133,9 +134,9 @@ type public XmlProvider(cfg: TypeProviderConfig) as this =
                             unitsOfMeasureProvider
                             inferenceMode
                             (TextRuntime.GetCulture cultureStr)
-                            false
+                            (not preferOptionals)
                             globalInference
-                        |> Array.fold (StructuralInference.subtypeInfered false) InferedType.Top
+                        |> Array.fold (StructuralInference.subtypeInfered (not preferOptionals)) InferedType.Top
 #if NET6_0_OR_GREATER
                     if preferDateOnly && ProviderHelpers.runtimeSupportsNet6Types cfg.RuntimeAssembly then
                         t
@@ -203,7 +204,8 @@ type public XmlProvider(cfg: TypeProviderConfig) as this =
           )
           ProvidedStaticParameter("PreferDateOnly", typeof, parameterDefaultValue = false)
           ProvidedStaticParameter("DtdProcessing", typeof, parameterDefaultValue = "Ignore")
-          ProvidedStaticParameter("UseOriginalNames", typeof, parameterDefaultValue = false) ]
+          ProvidedStaticParameter("UseOriginalNames", typeof, parameterDefaultValue = false)
+          ProvidedStaticParameter("PreferOptionals", typeof, parameterDefaultValue = true) ]
 
     let helpText =
         """Typed representation of a XML file.
@@ -229,7 +231,8 @@ type public XmlProvider(cfg: TypeProviderConfig) as this =
            
            When true on .NET 6+, date-only strings are inferred as DateOnly and time-only strings as TimeOnly. Defaults to false for backward compatibility.
            Controls how DTD declarations in the XML are handled. Accepted values: "Ignore" (default, silently skips DTD processing, safe for most cases), "Prohibit" (throws on any DTD declaration), "Parse" (enables full DTD processing including entity expansion, use with caution).
-           When true, XML element and attribute names are used as-is for generated property names instead of being normalized to PascalCase. Defaults to false."""
+           When true, XML element and attribute names are used as-is for generated property names instead of being normalized to PascalCase. Defaults to false.
+           When set to true (default), inference will use the option type for missing or absent values. When false, inference will prefer to use empty string or double.NaN for missing values where possible, matching the default CsvProvider behavior."""
 
 
     do xmlProvTy.AddXmlDoc helpText
diff --git a/src/FSharp.Data.Json.Core/JsonInference.fs b/src/FSharp.Data.Json.Core/JsonInference.fs
index 1699e66b9..0d80b636f 100644
--- a/src/FSharp.Data.Json.Core/JsonInference.fs
+++ b/src/FSharp.Data.Json.Core/JsonInference.fs
@@ -14,7 +14,7 @@ open FSharp.Data.Runtime.StructuralInference
 /// functionality is handled in `StructureInference` (most notably, by
 /// `inferCollectionType` and various functions to find common subtype), so
 /// here we just need to infer types of primitive JSON values.
-let rec internal inferType unitsOfMeasureProvider inferenceMode cultureInfo parentName json =
+let rec internal inferType unitsOfMeasureProvider inferenceMode cultureInfo allowEmptyValues parentName json =
     let inline inRangeDecimal lo hi (v: decimal) : bool = (v >= decimal lo) && (v <= decimal hi)
     let inline inRangeFloat lo hi (v: float) : bool = (v >= float lo) && (v <= float hi)
     let inline isIntegerDecimal (v: decimal) : bool = Math.Round v = v
@@ -65,8 +65,15 @@ let rec internal inferType unitsOfMeasureProvider inferenceMode cultureInfo pare
     // More interesting types
     | JsonValue.Array ar ->
         StructuralInference.inferCollectionType
-            false
-            (Seq.map (inferType unitsOfMeasureProvider inferenceMode cultureInfo (NameUtils.singularize parentName)) ar)
+            allowEmptyValues
+            (Seq.map
+                (inferType
+                    unitsOfMeasureProvider
+                    inferenceMode
+                    cultureInfo
+                    allowEmptyValues
+                    (NameUtils.singularize parentName))
+                ar)
     | JsonValue.Record properties ->
         let name =
             if String.IsNullOrEmpty parentName then
@@ -76,7 +83,9 @@ let rec internal inferType unitsOfMeasureProvider inferenceMode cultureInfo pare
 
         let props =
             [ for propName, value in properties ->
-                  let t = inferType unitsOfMeasureProvider inferenceMode cultureInfo propName value
+                  let t =
+                      inferType unitsOfMeasureProvider inferenceMode cultureInfo allowEmptyValues propName value
+
                   { Name = propName; Type = t } ]
 
         InferedType.Record(name, props, false)
diff --git a/src/FSharp.Data.Xml.Core/XmlInference.fs b/src/FSharp.Data.Xml.Core/XmlInference.fs
index f2cb6c3aa..b4374a563 100644
--- a/src/FSharp.Data.Xml.Core/XmlInference.fs
+++ b/src/FSharp.Data.Xml.Core/XmlInference.fs
@@ -56,6 +56,7 @@ let getInferedTypeFromValue unitsOfMeasureProvider inferenceMode cultureInfo (el
                             unitsOfMeasureProvider
                             inferenceMode
                             cultureInfo
+                            false
                             element.Name.LocalName
 
                     InferedType.Json(jsonType, optional)
diff --git a/tests/FSharp.Data.DesignTime.Tests/InferenceTests.fs b/tests/FSharp.Data.DesignTime.Tests/InferenceTests.fs
index cdbdd4132..d97e6461e 100644
--- a/tests/FSharp.Data.DesignTime.Tests/InferenceTests.fs
+++ b/tests/FSharp.Data.DesignTime.Tests/InferenceTests.fs
@@ -49,14 +49,14 @@ let ``List.pairBy helper function preserves order``() =
 let ``Finds common subtype of numeric types (decimal)``() =
   let source = JsonValue.Parse """[ 10, 10.23 ]"""
   let expected = SimpleCollection(InferedType.Primitive(typeof, None, false, false))
-  let actual = JsonInference.inferType unitsOfMeasureProvider inferenceMode culture "" source
+  let actual = JsonInference.inferType unitsOfMeasureProvider inferenceMode culture false "" source
   actual |> should equal expected
 
 []
 let ``Finds common subtype of numeric types (int64)``() =
   let source = JsonValue.Parse """[ 10, 2147483648 ]"""
   let expected = SimpleCollection(InferedType.Primitive(typeof, None, false, false))
-  let actual = JsonInference.inferType unitsOfMeasureProvider inferenceMode culture "" source
+  let actual = JsonInference.inferType unitsOfMeasureProvider inferenceMode culture false "" source
   actual |> should equal expected
 
 []
@@ -67,7 +67,7 @@ let ``Infers heterogeneous type of InferedType.Primitives``() =
         ([ InferedTypeTag.Number; InferedTypeTag.Boolean ],
          [ InferedTypeTag.Number, (Single, InferedType.Primitive(typeof, None, false, false))
            InferedTypeTag.Boolean, (Single, InferedType.Primitive(typeof, None, false, false)) ] |> Map.ofList)
-  let actual = JsonInference.inferType unitsOfMeasureProvider inferenceMode culture "" source
+  let actual = JsonInference.inferType unitsOfMeasureProvider inferenceMode culture false "" source
   actual |> should equal expected
 
 []
@@ -79,14 +79,14 @@ let ``Infers heterogeneous type of InferedType.Primitives and nulls``() =
          [ InferedTypeTag.Null, (Single, InferedType.Null)
            InferedTypeTag.Number, (Single, InferedType.Primitive(typeof, None, false, false))
            InferedTypeTag.Boolean, (Single, InferedType.Primitive(typeof, None, false, false)) ] |> Map.ofList)
-  let actual = JsonInference.inferType unitsOfMeasureProvider inferenceMode culture "" source
+  let actual = JsonInference.inferType unitsOfMeasureProvider inferenceMode culture false "" source
   actual |> should equal expected
 
 []
 let ``Finds common subtype of numeric types (float)``() =
   let source = JsonValue.Parse """[ 10, 10.23, 79228162514264337593543950336 ]"""
   let expected = SimpleCollection(InferedType.Primitive(typeof, None, false, false))
-  let actual = JsonInference.inferType unitsOfMeasureProvider inferenceMode culture "" source
+  let actual = JsonInference.inferType unitsOfMeasureProvider inferenceMode culture false "" source
   actual |> should equal expected
 
 []
@@ -98,7 +98,7 @@ let ``Infers heterogeneous type of InferedType.Primitives and records``() =
          [ InferedTypeTag.Number, (Multiple, InferedType.Primitive(typeof, None, false, false))
            InferedTypeTag.Record None,
              (Single, toRecord [ { Name="a"; Type=InferedType.Primitive(typeof, None, false, false) } ]) ] |> Map.ofList)
-  let actual = JsonInference.inferType unitsOfMeasureProvider inferenceMode culture "" source
+  let actual = JsonInference.inferType unitsOfMeasureProvider inferenceMode culture false "" source
   actual |> should equal expected
 
 []
@@ -111,7 +111,7 @@ let ``Merges types in a collection of collections``() =
     |> toRecord
     |> SimpleCollection
     |> SimpleCollection
-  let actual = JsonInference.inferType unitsOfMeasureProvider inferenceMode culture "" source
+  let actual = JsonInference.inferType unitsOfMeasureProvider inferenceMode culture false "" source
   actual |> should equal expected
 
 []
@@ -123,7 +123,7 @@ let ``Unions properties of records in a collection``() =
       { Name = "c"; Type = InferedType.Primitive(typeof, None, true, false) } ]
     |> toRecord
     |> SimpleCollection
-  let actual = JsonInference.inferType unitsOfMeasureProvider inferenceMode culture "" source
+  let actual = JsonInference.inferType unitsOfMeasureProvider inferenceMode culture false "" source
   actual |> should equal expected
 
 []
@@ -133,7 +133,7 @@ let ``Null should make string optional``() =
     [ { Name = "a"; Type = InferedType.Primitive(typeof, None, true, false) } ]
     |> toRecord
     |> SimpleCollection
-  let actual = JsonInference.inferType unitsOfMeasureProvider inferenceMode culture "" source
+  let actual = JsonInference.inferType unitsOfMeasureProvider inferenceMode culture false "" source
   actual |> should equal expected
 
 []
@@ -150,7 +150,7 @@ let ``Infers mixed fields of a a record as heterogeneous type with nulls (1.)``(
     [ { Name = "a"; Type = InferedType.Primitive(typeof, None, true, false) } ]
     |> toRecord
     |> SimpleCollection
-  let actual = JsonInference.inferType unitsOfMeasureProvider inferenceMode culture "" source
+  let actual = JsonInference.inferType unitsOfMeasureProvider inferenceMode culture false "" source
   actual |> should equal expected
 
 []
@@ -160,7 +160,7 @@ let ``Null makes a record optional``() =
     [ { Name = "a"; Type = InferedType.Record(Some "a", [{ Name = "b"; Type = InferedType.Primitive(typeof, None, false, false) }], true) } ]
     |> toRecord
     |> SimpleCollection
-  let actual = JsonInference.inferType unitsOfMeasureProvider inferenceMode culture "" source
+  let actual = JsonInference.inferType unitsOfMeasureProvider inferenceMode culture false "" source
   actual |> should equal expected
 
 []
@@ -173,7 +173,7 @@ let ``Infers mixed fields of a record as heterogeneous type``() =
     [ { Name = "a"; Type = InferedType.Heterogeneous (cases, false) }]
     |> toRecord
     |> SimpleCollection
-  let actual = JsonInference.inferType unitsOfMeasureProvider inferenceMode culture "" source
+  let actual = JsonInference.inferType unitsOfMeasureProvider inferenceMode culture false "" source
   actual |> should equal expected
 
 []
@@ -183,7 +183,7 @@ let ``Infers mixed fields of a record as heterogeneous type with nulls (2.)``()
     [ { Name = "a"; Type = InferedType.Primitive(typeof, None, true, false) }]
     |> toRecord
     |> SimpleCollection
-  let actual = JsonInference.inferType unitsOfMeasureProvider inferenceMode culture "" source
+  let actual = JsonInference.inferType unitsOfMeasureProvider inferenceMode culture false "" source
   actual |> should equal expected
 
 []
@@ -195,7 +195,7 @@ let ``Inference of multiple nulls works``() =
         ([ InferedTypeTag.Number; InferedTypeTag.Collection ],
          [ InferedTypeTag.Collection, (Single, SimpleCollection(toRecord [prop]))
            InferedTypeTag.Number, (Single, InferedType.Primitive(typeof, None, false, false)) ] |> Map.ofList)
-  let actual = JsonInference.inferType unitsOfMeasureProvider inferenceMode culture "" source
+  let actual = JsonInference.inferType unitsOfMeasureProvider inferenceMode culture false "" source
   actual |> should equal expected
 
 []
@@ -406,7 +406,7 @@ let ``Doesn't infer 12-002 as a date``() =
         ([ InferedTypeTag.String; InferedTypeTag.Number],
          [ InferedTypeTag.String, (Multiple, InferedType.Primitive(typeof, None, false, false))
            InferedTypeTag.Number, (Single, InferedType.Primitive(typeof, None, false, false)) ] |> Map.ofList)
-  let actual = JsonInference.inferType unitsOfMeasureProvider inferenceMode culture "" source
+  let actual = JsonInference.inferType unitsOfMeasureProvider inferenceMode culture false "" source
   actual |> should equal expected
 
 []
diff --git a/tests/FSharp.Data.DesignTime.Tests/TypeProviderInstantiation.fs b/tests/FSharp.Data.DesignTime.Tests/TypeProviderInstantiation.fs
index c07f3f985..d4a7179ff 100644
--- a/tests/FSharp.Data.DesignTime.Tests/TypeProviderInstantiation.fs
+++ b/tests/FSharp.Data.DesignTime.Tests/TypeProviderInstantiation.fs
@@ -44,7 +44,8 @@ type internal XmlProviderArgs =
       InferenceMode: InferenceMode
       PreferDateOnly : bool
       DtdProcessing : string
-      UseOriginalNames : bool }
+      UseOriginalNames : bool
+      PreferOptionals : bool }
 
 type internal JsonProviderArgs =
     { Sample : string
@@ -60,7 +61,8 @@ type internal JsonProviderArgs =
       Schema: string
       PreferDateOnly : bool
       UseOriginalNames : bool
-      OmitNullFields : bool }
+      OmitNullFields : bool
+      PreferOptionals : bool }
 
 type internal HtmlProviderArgs =
     { Sample : string
@@ -138,7 +140,8 @@ type internal TypeProviderInstantiation =
                    box x.InferenceMode
                    box x.PreferDateOnly
                    box x.DtdProcessing
-                   box x.UseOriginalNames |] 
+                   box x.UseOriginalNames
+                   box x.PreferOptionals |]
             | Json x -> 
                 (fun cfg -> new JsonProvider(cfg) :> TypeProviderForNamespaces),
                 [| box x.Sample
@@ -154,7 +157,8 @@ type internal TypeProviderInstantiation =
                    box x.Schema
                    box x.PreferDateOnly
                    box x.UseOriginalNames
-                   box x.OmitNullFields |]
+                   box x.OmitNullFields
+                   box x.PreferOptionals |]
             | Html x ->
                 (fun cfg -> new HtmlProvider(cfg) :> TypeProviderForNamespaces),
                 [| box x.Sample
@@ -301,7 +305,8 @@ type internal TypeProviderInstantiation =
                   InferenceMode = args.[7] |> InferenceMode.Parse
                   PreferDateOnly = false
                   DtdProcessing = "Ignore"
-                  UseOriginalNames = false }
+                  UseOriginalNames = false
+                  PreferOptionals = true }
         | "Json" ->
             // Handle special case for Schema.json tests where some fields might be empty
             if args.Length > 5 && not (String.IsNullOrEmpty(args.[5])) then
@@ -318,7 +323,8 @@ type internal TypeProviderInstantiation =
                        Schema = if args.Length > 8 then args.[8] else ""
                        PreferDateOnly = false
                        UseOriginalNames = false
-                       OmitNullFields = false }
+                       OmitNullFields = false
+                       PreferOptionals = true }
             else
                 // This is for schema-based tests in the format "Json,,,,,true,false,BackwardCompatible,SimpleSchema.json"
                 Json { Sample = args.[1]
@@ -334,7 +340,8 @@ type internal TypeProviderInstantiation =
                        Schema = if args.Length > 8 then args.[8] else ""
                        PreferDateOnly = false
                        UseOriginalNames = false
-                       OmitNullFields = false }
+                       OmitNullFields = false
+                       PreferOptionals = true }
         | "Html" ->
             Html { Sample = args.[1]
                    PreferOptionals = args.[2] |> bool.Parse
diff --git a/tests/FSharp.Data.Tests/JsonProvider.fs b/tests/FSharp.Data.Tests/JsonProvider.fs
index c6b455665..21afc3d2e 100644
--- a/tests/FSharp.Data.Tests/JsonProvider.fs
+++ b/tests/FSharp.Data.Tests/JsonProvider.fs
@@ -1019,3 +1019,23 @@ let ``JsonProvider OmitNullFields=true includes non-None fields`` () =
     let json = value.ToString()
     json |> should contain "42"
     json |> should contain "Blue"
+
+// Tests for PreferOptionals parameter on JsonProvider (issue #649)
+type JsonPreferOptionalsFalse =
+    JsonProvider<"""[{"name": "Alice", "tag": "x"}, {"name": "Bob"}]""", SampleIsList = true, PreferOptionals = false>
+
+type JsonPreferOptionalsTrue =
+    JsonProvider<"""[{"name": "Alice", "tag": "x"}, {"name": "Bob"}]""", SampleIsList = true, PreferOptionals = true>
+
+[]
+let ``JsonProvider PreferOptionals=true (default) uses option type for missing string fields`` () =
+    let doc = JsonPreferOptionalsTrue.Parse("""{"name": "Alice", "tag": "x"}""")
+    doc.Tag.GetType() |> should equal typeof
+    let docMissing = JsonPreferOptionalsTrue.Parse("""{"name": "Bob"}""")
+    docMissing.Tag |> should equal None
+
+[]
+let ``JsonProvider PreferOptionals=false uses empty string for missing string fields`` () =
+    let doc = JsonPreferOptionalsFalse.Parse("""{"name": "Bob"}""")
+    doc.Tag.GetType() |> should equal typeof
+    doc.Tag |> should equal ""
diff --git a/tests/FSharp.Data.Tests/XmlProvider.fs b/tests/FSharp.Data.Tests/XmlProvider.fs
index ed5574276..2c551eeba 100644
--- a/tests/FSharp.Data.Tests/XmlProvider.fs
+++ b/tests/FSharp.Data.Tests/XmlProvider.fs
@@ -1340,3 +1340,22 @@ let ``XmlProvider UseOriginalNames=true preserves attribute names as-is`` () =
 let ``XmlProvider default normalizes attribute names to PascalCase`` () =
     let root = XmlNormalizedNames.Parse("")
     root.FaultCode |> should equal "world"
+
+// Tests for PreferOptionals parameter on XmlProvider (issue #649)
+[]
+let xmlOptionalAttr = """"""
+
+type XmlPreferOptionalsFalse = XmlProvider
+type XmlPreferOptionalsTrue = XmlProvider
+
+[]
+let ``XmlProvider PreferOptionals=true (default) uses option type for optional attributes`` () =
+    let root = XmlPreferOptionalsTrue.Parse("""""")
+    root.Items.[0].Tag.GetType() |> should equal typeof
+    root.Items.[1].Tag |> should equal None
+
+[]
+let ``XmlProvider PreferOptionals=false uses empty string for missing string attributes`` () =
+    let root = XmlPreferOptionalsFalse.Parse("""""")
+    root.Items.[0].Tag.GetType() |> should equal typeof
+    root.Items.[0].Tag |> should equal ""

From 0543c8b7cd9f5a75bfae23803d4465dee3334ad2 Mon Sep 17 00:00:00 2001
From: Repo Assist 
Date: Thu, 26 Feb 2026 11:14:50 +0000
Subject: [PATCH 18/18] Add core library tests for YamlDocument, add
 PreferOptionals param, update release notes

- Add 20 unit tests in FSharp.Data.Core.Tests/YamlDocument.fs covering:
  - All scalar types (string, int, float, bool, null, tilde)
  - Nested mappings and sequences
  - Quoted scalar inference (design-time): quoted numeric strings always typed as string
  - YamlDocument.Create and CreateList runtime methods
- Add PreferOptionals static parameter to YamlProvider (default true,
  consistent with JsonProvider/XmlProvider)
- Fix YamlProvider.fs to use updated inferType/subtypeInfered API
  (allowEmptyValues parameter added when merging PreferOptionals support)
- Update TypeProviderInstantiation.fs with PreferOptionals in YamlProviderArgs
- Merge latest main (PreferOptionals, AGENTS.md, etc.)
- Update RELEASE_NOTES.md to list YamlProvider in 8.1.0-beta

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
 RELEASE_NOTES.md                              |   1 +
 .../Yaml/YamlProvider.fs                      |  17 +-
 .../FSharp.Data.Core.Tests.fsproj             |   2 +
 tests/FSharp.Data.Core.Tests/YamlDocument.fs  | 158 ++++++++++++++++++
 .../TypeProviderInstantiation.fs              |  12 +-
 ...rue,False,BackwardCompatible,True.expected | 124 ++++++++++++++
 6 files changed, 306 insertions(+), 8 deletions(-)
 create mode 100644 tests/FSharp.Data.Core.Tests/YamlDocument.fs
 create mode 100644 tests/FSharp.Data.DesignTime.Tests/expected/Yaml,SimpleYaml.yaml,False,Root,,True,False,BackwardCompatible,True.expected

diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md
index b69a921b9..b8752d0bb 100644
--- a/RELEASE_NOTES.md
+++ b/RELEASE_NOTES.md
@@ -2,6 +2,7 @@
 
 ## 8.1.0-beta
 
+- Add `YamlProvider` type provider for strongly-typed access to YAML documents (closes #1645)
 - Add `PreferOptionals` parameter to `JsonProvider` and `XmlProvider` (defaults to `true` to match existing behavior; set to `false` to use empty string or `NaN` for missing values, like the CsvProvider default) (closes #649)
 
 ## 8.0.0 - Feb 25 2026
diff --git a/src/FSharp.Data.DesignTime/Yaml/YamlProvider.fs b/src/FSharp.Data.DesignTime/Yaml/YamlProvider.fs
index 2c3262d62..d38221894 100644
--- a/src/FSharp.Data.DesignTime/Yaml/YamlProvider.fs
+++ b/src/FSharp.Data.DesignTime/Yaml/YamlProvider.fs
@@ -58,6 +58,7 @@ type public YamlProvider(cfg: TypeProviderConfig) as this =
         let inferenceMode = args.[9] :?> InferenceMode
         let preferDateOnly = args.[10] :?> bool
         let useOriginalNames = args.[11] :?> bool
+        let preferOptionals = args.[12] :?> bool
 
         let inferenceMode =
             InferenceMode'.FromPublicApi(inferenceMode, inferTypesFromValues)
@@ -85,8 +86,14 @@ type public YamlProvider(cfg: TypeProviderConfig) as this =
 
                     samples
                     |> Array.map (fun sampleJson ->
-                        JsonInference.inferType unitsOfMeasureProvider inferenceMode cultureInfo "" sampleJson)
-                    |> Array.fold (StructuralInference.subtypeInfered false) InferedType.Top
+                        JsonInference.inferType
+                            unitsOfMeasureProvider
+                            inferenceMode
+                            cultureInfo
+                            (not preferOptionals)
+                            ""
+                            sampleJson)
+                    |> Array.fold (StructuralInference.subtypeInfered (not preferOptionals)) InferedType.Top
 
 #if NET6_0_OR_GREATER
                 if preferDateOnly && ProviderHelpers.runtimeSupportsNet6Types cfg.RuntimeAssembly then
@@ -140,7 +147,8 @@ type public YamlProvider(cfg: TypeProviderConfig) as this =
               parameterDefaultValue = InferenceMode.BackwardCompatible
           )
           ProvidedStaticParameter("PreferDateOnly", typeof, parameterDefaultValue = false)
-          ProvidedStaticParameter("UseOriginalNames", typeof, parameterDefaultValue = false) ]
+          ProvidedStaticParameter("UseOriginalNames", typeof, parameterDefaultValue = false)
+          ProvidedStaticParameter("PreferOptionals", typeof, parameterDefaultValue = true) ]
 
     let helpText =
         """Typed representation of a YAML document.
@@ -164,7 +172,8 @@ type public YamlProvider(cfg: TypeProviderConfig) as this =
               | ValuesAndInlineSchemasOverrides -> Same as ValuesAndInlineSchemasHints, but value inferred types are ignored when an inline schema is present.
            
            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.
-           When true, YAML key names are used as-is for generated property names instead of being normalized to PascalCase. Defaults to false."""
+           When true, YAML key names are used as-is for generated property names instead of being normalized to PascalCase. Defaults to false.
+           When set to false, optional YAML fields are represented as empty string or NaN instead of option types. Defaults to true."""
 
     do yamlProvTy.AddXmlDoc helpText
     do yamlProvTy.DefineStaticParameters(parameters, buildTypes)
diff --git a/tests/FSharp.Data.Core.Tests/FSharp.Data.Core.Tests.fsproj b/tests/FSharp.Data.Core.Tests/FSharp.Data.Core.Tests.fsproj
index 090a43f82..ab7c1c245 100644
--- a/tests/FSharp.Data.Core.Tests/FSharp.Data.Core.Tests.fsproj
+++ b/tests/FSharp.Data.Core.Tests/FSharp.Data.Core.Tests.fsproj
@@ -51,6 +51,7 @@
     
     
     
+    
     
   
   
@@ -64,6 +65,7 @@
     
     
     
+    
     
   
   
diff --git a/tests/FSharp.Data.Core.Tests/YamlDocument.fs b/tests/FSharp.Data.Core.Tests/YamlDocument.fs
new file mode 100644
index 000000000..55a13d61f
--- /dev/null
+++ b/tests/FSharp.Data.Core.Tests/YamlDocument.fs
@@ -0,0 +1,158 @@
+module FSharp.Data.Tests.YamlDocument
+
+open NUnit.Framework
+open FsUnit
+open FSharp.Data
+open FSharp.Data.Runtime.BaseTypes
+open System.IO
+open System.Reflection
+
+// Access the "generated code only" methods via reflection
+let private parseToJsonValue (text: string) : JsonValue =
+    let m = typeof.GetMethod("ParseToJsonValue", [| typeof |])
+    m.Invoke(null, [| text |]) :?> JsonValue
+
+let private parseToJsonValueForInference (text: string) : JsonValue =
+    let m = typeof.GetMethod("ParseToJsonValueForInference", [| typeof |])
+    m.Invoke(null, [| text |]) :?> JsonValue
+
+let private createFromReader (reader: TextReader) : IJsonDocument =
+    let m = typeof.GetMethod("Create", [| typeof |])
+    m.Invoke(null, [| reader |]) :?> IJsonDocument
+
+let private createListFromReader (reader: TextReader) : IJsonDocument[] =
+    let m = typeof.GetMethod("CreateList", [| typeof |])
+    m.Invoke(null, [| reader |]) :?> IJsonDocument[]
+
+// ── ParseToJsonValue (runtime) ────────────────────────────────────────────────
+
+[]
+let ``YamlDocument parses plain string scalar`` () =
+    let v = parseToJsonValue "name: Alice"
+    v.["name"].AsString() |> should equal "Alice"
+
+[]
+let ``YamlDocument parses integer scalar`` () =
+    let v = parseToJsonValue "age: 42"
+    v.["age"].AsInteger() |> should equal 42
+
+[]
+let ``YamlDocument parses float scalar`` () =
+    let v = parseToJsonValue "score: 3.14"
+    v.["score"].AsFloat() |> should (equalWithin 0.001) 3.14
+
+[]
+let ``YamlDocument parses boolean true`` () =
+    let v = parseToJsonValue "active: true"
+    v.["active"].AsBoolean() |> should equal true
+
+[]
+let ``YamlDocument parses boolean false`` () =
+    let v = parseToJsonValue "enabled: false"
+    v.["enabled"].AsBoolean() |> should equal false
+
+[]
+let ``YamlDocument parses null scalar`` () =
+    let v = parseToJsonValue "value: null"
+    v.["value"] |> should equal JsonValue.Null
+
+[]
+let ``YamlDocument parses tilde as null`` () =
+    let v = parseToJsonValue "value: ~"
+    v.["value"] |> should equal JsonValue.Null
+
+[]
+let ``YamlDocument parses sequence as array`` () =
+    let v = parseToJsonValue "tags:\n  - fsharp\n  - dotnet"
+    let tags = v.["tags"].AsArray()
+    tags |> should haveLength 2
+    tags.[0].AsString() |> should equal "fsharp"
+    tags.[1].AsString() |> should equal "dotnet"
+
+[]
+let ``YamlDocument parses nested mapping`` () =
+    let yaml = "address:\n  city: Springfield\n  zip: 01234"
+    let v = parseToJsonValue yaml
+    v.["address"].["city"].AsString() |> should equal "Springfield"
+
+[]
+let ``YamlDocument runtime: quoted string is returned as-is`` () =
+    // At runtime, a quoted "01234" should be the original string value
+    let v = parseToJsonValue "zip: \"01234\""
+    v.["zip"].AsString() |> should equal "01234"
+
+[]
+let ``YamlDocument parses empty document as Null`` () =
+    let v = parseToJsonValue ""
+    v |> should equal JsonValue.Null
+
+// ── ParseToJsonValueForInference (design-time) ────────────────────────────────
+
+[]
+let ``YamlDocument inference: quoted numeric string is inferred as string sentinel`` () =
+    // "01234" is quoted in YAML → must be typed as string, not int
+    let v = parseToJsonValueForInference "zip: \"01234\""
+    // The sentinel value "s" is returned; it must parse as string
+    match v.["zip"] with
+    | JsonValue.String _ -> () // pass
+    | other -> failwithf "Expected JsonValue.String but got %A" other
+
+[]
+let ``YamlDocument inference: plain integer is inferred as number`` () =
+    let v = parseToJsonValueForInference "age: 30"
+    match v.["age"] with
+    | JsonValue.Number _ -> () // pass
+    | other -> failwithf "Expected JsonValue.Number but got %A" other
+
+[]
+let ``YamlDocument inference: single-quoted numeric string is inferred as string`` () =
+    let v = parseToJsonValueForInference "code: '007'"
+    match v.["code"] with
+    | JsonValue.String _ -> () // pass
+    | other -> failwithf "Expected JsonValue.String but got %A" other
+
+[]
+let ``YamlDocument inference: quoted non-numeric string passes through unchanged`` () =
+    let v = parseToJsonValueForInference "name: \"Alice\""
+    v.["name"].AsString() |> should equal "Alice"
+
+[]
+let ``YamlDocument inference: plain boolean is inferred as boolean`` () =
+    let v = parseToJsonValueForInference "active: true"
+    match v.["active"] with
+    | JsonValue.Boolean true -> () // pass
+    | other -> failwithf "Expected JsonValue.Boolean true but got %A" other
+
+// ── Create / CreateList ───────────────────────────────────────────────────────
+
+[]
+let ``YamlDocument.Create from TextReader returns IJsonDocument`` () =
+    use reader = new StringReader("name: Bob\nage: 25")
+    let doc = createFromReader reader
+    doc |> should not' (be null)
+    doc.JsonValue.["name"].AsString() |> should equal "Bob"
+    doc.JsonValue.["age"].AsInteger() |> should equal 25
+
+[]
+let ``YamlDocument.CreateList returns one document per YAML sequence item`` () =
+    let yaml = "- id: 1\n- id: 2"
+    use reader = new StringReader(yaml)
+    let docs = createListFromReader reader
+    docs |> should haveLength 2
+    docs.[0].JsonValue.["id"].AsInteger() |> should equal 1
+    docs.[1].JsonValue.["id"].AsInteger() |> should equal 2
+
+[]
+let ``YamlDocument.CreateList wraps single mapping in array`` () =
+    use reader = new StringReader("id: 1")
+    let docs = createListFromReader reader
+    docs |> should haveLength 1
+    docs.[0].JsonValue.["id"].AsInteger() |> should equal 1
+
+[]
+let ``YamlDocument.CreateList unwraps top-level YAML sequence`` () =
+    let yaml = "- id: 1\n- id: 2\n- id: 3"
+    use reader = new StringReader(yaml)
+    let docs = createListFromReader reader
+    docs |> should haveLength 3
+    docs.[2].JsonValue.["id"].AsInteger() |> should equal 3
diff --git a/tests/FSharp.Data.DesignTime.Tests/TypeProviderInstantiation.fs b/tests/FSharp.Data.DesignTime.Tests/TypeProviderInstantiation.fs
index d4a7179ff..ea52bb9d9 100644
--- a/tests/FSharp.Data.DesignTime.Tests/TypeProviderInstantiation.fs
+++ b/tests/FSharp.Data.DesignTime.Tests/TypeProviderInstantiation.fs
@@ -87,7 +87,8 @@ type internal YamlProviderArgs =
       PreferDictionaries : bool
       InferenceMode: InferenceMode
       PreferDateOnly : bool
-      UseOriginalNames : bool }
+      UseOriginalNames : bool
+      PreferOptionals : bool }
 
 type internal WorldBankProviderArgs =
     { Sources : string
@@ -183,7 +184,8 @@ type internal TypeProviderInstantiation =
                    box x.PreferDictionaries
                    box x.InferenceMode
                    box x.PreferDateOnly
-                   box x.UseOriginalNames |]
+                   box x.UseOriginalNames
+                   box x.PreferOptionals |]
             | WorldBank x ->
                 (fun cfg -> new WorldBankProvider(cfg) :> TypeProviderForNamespaces),
                 [| box x.Sources
@@ -237,7 +239,8 @@ type internal TypeProviderInstantiation =
              x.Culture
              x.InferTypesFromValues.ToString()
              x.PreferDictionaries.ToString()
-             x.InferenceMode.ToString() ]
+             x.InferenceMode.ToString()
+             x.PreferOptionals.ToString() ]
         | WorldBank x ->
             ["WorldBank"
              x.Sources
@@ -364,7 +367,8 @@ type internal TypeProviderInstantiation =
                    PreferDictionaries = args.[6] |> bool.Parse
                    InferenceMode = args.[7] |> InferenceMode.Parse
                    PreferDateOnly = false
-                   UseOriginalNames = false }
+                   UseOriginalNames = false
+                   PreferOptionals = if args.Length > 8 then args.[8] |> bool.Parse else true }
         | "WorldBank" ->
             WorldBank { Sources = args.[1]
                         Asynchronous = args.[2] |> bool.Parse }
diff --git a/tests/FSharp.Data.DesignTime.Tests/expected/Yaml,SimpleYaml.yaml,False,Root,,True,False,BackwardCompatible,True.expected b/tests/FSharp.Data.DesignTime.Tests/expected/Yaml,SimpleYaml.yaml,False,Root,,True,False,BackwardCompatible,True.expected
new file mode 100644
index 000000000..9e3896484
--- /dev/null
+++ b/tests/FSharp.Data.DesignTime.Tests/expected/Yaml,SimpleYaml.yaml,False,Root,,True,False,BackwardCompatible,True.expected
@@ -0,0 +1,124 @@
+class YamlProvider : obj
+    static member AsyncGetSample: () -> YamlProvider+Root async
+    let f = new Func<_,_>(fun (t:TextReader) -> YamlDocument.Create(t))
+    TextRuntime.AsyncMap((IO.asyncReadTextAtRuntimeWithDesignTimeRules "" "" "YAML" "" "SimpleYaml.yaml"), f)
+
+    static member AsyncLoad: uri:string -> YamlProvider+Root async
+    let f = new Func<_,_>(fun (t:TextReader) -> YamlDocument.Create(t))
+    TextRuntime.AsyncMap((IO.asyncReadTextAtRuntime false "" "" "YAML" "" uri), f)
+
+    static member GetSample: () -> YamlProvider+Root
+    YamlDocument.Create(FSharpAsync.RunSynchronously((IO.asyncReadTextAtRuntimeWithDesignTimeRules "" "" "YAML" "" "SimpleYaml.yaml")))
+
+    static member Load: stream:System.IO.Stream -> YamlProvider+Root
+    YamlDocument.Create(((new StreamReader(stream)) :> TextReader))
+
+    static member Load: reader:System.IO.TextReader -> YamlProvider+Root
+    YamlDocument.Create(reader)
+
+    static member Load: uri:string -> YamlProvider+Root
+    YamlDocument.Create(FSharpAsync.RunSynchronously((IO.asyncReadTextAtRuntime false "" "" "YAML" "" uri)))
+
+    static member Load: value:JsonValue -> YamlProvider+Root
+    YamlDocument.Create(value, "")
+
+    static member Parse: text:string -> YamlProvider+Root
+    YamlDocument.Create(((new StringReader(text)) :> TextReader))
+
+    static member ParseList: text:string -> YamlProvider+YamlProvider+Root[]
+    YamlDocument.CreateList(((new StringReader(text)) :> TextReader))
+
+
+class YamlProvider+Root : FDR.BaseTypes.IJsonDocument
+    new : name:string -> age:int -> score:decimal -> active:bool -> address:YamlProvider+Address -> tags:string[] -> YamlProvider+Root
+    JsonRuntime.CreateRecord([| ("name",
+                                 (name :> obj))
+                                ("age",
+                                 (age :> obj))
+                                ("score",
+                                 (score :> obj))
+                                ("active",
+                                 (active :> obj))
+                                ("address",
+                                 (address :> obj))
+                                ("tags",
+                                 (tags :> obj)) |], "")
+
+    new : jsonValue:JsonValue -> YamlProvider+Root
+    JsonDocument.Create(jsonValue, "")
+
+    member Active: bool with get
+    let value = JsonRuntime.TryGetPropertyUnpackedWithPath(this, "active")
+    JsonRuntime.GetNonOptionalValue(value.Path, JsonRuntime.ConvertBoolean(value.JsonOpt), value.JsonOpt)
+
+    member Address: YamlProvider+Address with get
+    JsonRuntime.GetPropertyPacked(this, "address")
+
+    member Age: int with get
+    let value = JsonRuntime.TryGetPropertyUnpackedWithPath(this, "age")
+    JsonRuntime.GetNonOptionalValue(value.Path, JsonRuntime.ConvertInteger("", value.JsonOpt), value.JsonOpt)
+
+    member Name: string with get
+    let value = JsonRuntime.TryGetPropertyUnpackedWithPath(this, "name")
+    JsonRuntime.GetNonOptionalValue(value.Path, JsonRuntime.ConvertString("", value.JsonOpt), value.JsonOpt)
+
+    member Score: decimal with get
+    let value = JsonRuntime.TryGetPropertyUnpackedWithPath(this, "score")
+    JsonRuntime.GetNonOptionalValue(value.Path, JsonRuntime.ConvertDecimal("", value.JsonOpt), value.JsonOpt)
+
+    member Tags: string[] with get
+    JsonRuntime.ConvertArray(JsonRuntime.GetPropertyPackedOrNull(this, "tags"), new Func<_,_>(fun (t:IJsonDocument) -> JsonRuntime.GetNonOptionalValue(t.Path(), JsonRuntime.ConvertString("", Some t.JsonValue), Some t.JsonValue)))
+
+    member WithActive: active:bool -> YamlProvider+Root
+    JsonRuntime.WithRecordProperty(this, "active", (active :> obj), "")
+
+    member WithAddress: address:YamlProvider+Address -> YamlProvider+Root
+    JsonRuntime.WithRecordProperty(this, "address", (address :> obj), "")
+
+    member WithAge: age:int -> YamlProvider+Root
+    JsonRuntime.WithRecordProperty(this, "age", (age :> obj), "")
+
+    member WithName: name:string -> YamlProvider+Root
+    JsonRuntime.WithRecordProperty(this, "name", (name :> obj), "")
+
+    member WithScore: score:decimal -> YamlProvider+Root
+    JsonRuntime.WithRecordProperty(this, "score", (score :> obj), "")
+
+    member WithTags: tags:string[] -> YamlProvider+Root
+    JsonRuntime.WithRecordProperty(this, "tags", (tags :> obj), "")
+
+
+class YamlProvider+Address : FDR.BaseTypes.IJsonDocument
+    new : street:string -> city:string -> zip:string -> YamlProvider+Address
+    JsonRuntime.CreateRecord([| ("street",
+                                 (street :> obj))
+                                ("city",
+                                 (city :> obj))
+                                ("zip",
+                                 (zip :> obj)) |], "")
+
+    new : jsonValue:JsonValue -> YamlProvider+Address
+    JsonDocument.Create(jsonValue, "")
+
+    member City: string with get
+    let value = JsonRuntime.TryGetPropertyUnpackedWithPath(this, "city")
+    JsonRuntime.GetNonOptionalValue(value.Path, JsonRuntime.ConvertString("", value.JsonOpt), value.JsonOpt)
+
+    member Street: string with get
+    let value = JsonRuntime.TryGetPropertyUnpackedWithPath(this, "street")
+    JsonRuntime.GetNonOptionalValue(value.Path, JsonRuntime.ConvertString("", value.JsonOpt), value.JsonOpt)
+
+    member WithCity: city:string -> YamlProvider+Address
+    JsonRuntime.WithRecordProperty(this, "city", (city :> obj), "")
+
+    member WithStreet: street:string -> YamlProvider+Address
+    JsonRuntime.WithRecordProperty(this, "street", (street :> obj), "")
+
+    member WithZip: zip:string -> YamlProvider+Address
+    JsonRuntime.WithRecordProperty(this, "zip", (zip :> obj), "")
+
+    member Zip: string with get
+    let value = JsonRuntime.TryGetPropertyUnpackedWithPath(this, "zip")
+    JsonRuntime.GetNonOptionalValue(value.Path, JsonRuntime.ConvertString("", value.JsonOpt), value.JsonOpt)
+
+