diff --git a/.github/workflows/pull-requests.yml b/.github/workflows/pull-requests.yml
index 31d47ece3..9769c5425 100644
--- a/.github/workflows/pull-requests.yml
+++ b/.github/workflows/pull-requests.yml
@@ -13,10 +13,10 @@ jobs:
timeout-minutes: 10
steps:
- uses: actions/checkout@v6
- - name: Setup .NET 10
+ - name: Setup .NET
uses: actions/setup-dotnet@v5
with:
- dotnet-version: 10.0.201
+ dotnet-version-file: global.json
- name: Cache NuGet packages
uses: actions/cache@v5
with:
@@ -37,10 +37,10 @@ jobs:
timeout-minutes: 40
steps:
- uses: actions/checkout@v6
- - name: Setup .NET 10
+ - name: Setup .NET
uses: actions/setup-dotnet@v5
with:
- dotnet-version: 10.0.201
+ dotnet-version-file: global.json
- name: Cache NuGet packages
uses: actions/cache@v5
with:
@@ -63,10 +63,10 @@ jobs:
timeout-minutes: 40
steps:
- uses: actions/checkout@v6
- - name: Setup .NET 10
+ - name: Setup .NET
uses: actions/setup-dotnet@v5
with:
- dotnet-version: 10.0.201
+ dotnet-version-file: global.json
- name: Cache NuGet packages
uses: actions/cache@v5
with:
diff --git a/.github/workflows/push-master.yml b/.github/workflows/push-master.yml
index bdad12347..64e097f2d 100644
--- a/.github/workflows/push-master.yml
+++ b/.github/workflows/push-master.yml
@@ -17,10 +17,10 @@ jobs:
steps:
- uses: actions/checkout@v6
- - name: Setup .NET 10
+ - name: Setup .NET
uses: actions/setup-dotnet@v5
with:
- dotnet-version: 10.0.201
+ dotnet-version-file: global.json
- name: Cache NuGet packages
uses: actions/cache@v5
with:
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 1341f156a..dc1bf9cd1 100644
--- a/tests/FSharp.Data.Core.Tests/FSharp.Data.Core.Tests.fsproj
+++ b/tests/FSharp.Data.Core.Tests/FSharp.Data.Core.Tests.fsproj
@@ -44,6 +44,7 @@
+
diff --git a/tests/FSharp.Data.Core.Tests/StructuralInference.fs b/tests/FSharp.Data.Core.Tests/StructuralInference.fs
new file mode 100644
index 000000000..a9567ebf6
--- /dev/null
+++ b/tests/FSharp.Data.Core.Tests/StructuralInference.fs
@@ -0,0 +1,354 @@
+module FSharp.Data.Tests.StructuralInference
+
+open System
+open System.Globalization
+open NUnit.Framework
+open FsUnit
+open FSharp.Data
+open FSharp.Data.Runtime.StructuralTypes
+open FSharp.Data.Runtime.StructuralInference
+
+// Helpers
+
+let private culture = CultureInfo.InvariantCulture
+let private uomProvider = defaultUnitsOfMeasureProvider
+let private valuesOnly = InferenceMode'.ValuesOnly
+let private noInference = InferenceMode'.NoInference
+
+let private prim typ = InferedType.Primitive(typ, None, false, false)
+let private primOpt typ = InferedType.Primitive(typ, None, true, false)
+
+let private inferStr value =
+ getInferedTypeFromString uomProvider valuesOnly culture value None
+
+// ─── typeTag ────────────────────────────────────────────────────────────────
+
+[]
+let ``typeTag returns Number for numeric primitives`` () =
+ typeTag (prim typeof) |> should equal InferedTypeTag.Number
+ typeTag (prim typeof) |> should equal InferedTypeTag.Number
+ typeTag (prim typeof) |> should equal InferedTypeTag.Number
+ typeTag (prim typeof) |> should equal InferedTypeTag.Number
+ typeTag (prim typeof) |> should equal InferedTypeTag.Number
+ typeTag (prim typeof) |> should equal InferedTypeTag.Number
+ typeTag (prim typeof) |> should equal InferedTypeTag.Number
+
+[]
+let ``typeTag returns correct tag for other primitive types`` () =
+ typeTag (prim typeof) |> should equal InferedTypeTag.Boolean
+ typeTag (prim typeof) |> should equal InferedTypeTag.String
+ typeTag (prim typeof) |> should equal InferedTypeTag.DateTime
+ typeTag (prim typeof) |> should equal InferedTypeTag.DateTime
+ typeTag (prim typeof) |> should equal InferedTypeTag.TimeSpan
+ typeTag (prim typeof) |> should equal InferedTypeTag.Guid
+
+[]
+let ``typeTag returns Null for Null and Top`` () =
+ typeTag InferedType.Null |> should equal InferedTypeTag.Null
+ typeTag InferedType.Top |> should equal InferedTypeTag.Null
+
+[]
+let ``typeTag returns correct tag for record, collection, heterogeneous, json`` () =
+ typeTag (InferedType.Record(Some "Foo", [], false)) |> should equal (InferedTypeTag.Record(Some "Foo"))
+ typeTag (InferedType.Record(None, [], false)) |> should equal (InferedTypeTag.Record None)
+ typeTag (InferedType.Collection([], Map.empty)) |> should equal InferedTypeTag.Collection
+ typeTag (InferedType.Heterogeneous(Map.empty, false)) |> should equal InferedTypeTag.Heterogeneous
+ typeTag (InferedType.Json(prim typeof, false)) |> should equal InferedTypeTag.Json
+
+#if NET6_0_OR_GREATER
+[]
+let ``typeTag returns DateOnly and TimeOnly on NET6+`` () =
+ typeTag (prim typeof) |> should equal InferedTypeTag.DateOnly
+ typeTag (prim typeof) |> should equal InferedTypeTag.TimeOnly
+#endif
+
+// ─── subtypeInfered: Top and Null ───────────────────────────────────────────
+
+[]
+let ``subtypeInfered: Top merged with any type returns that type`` () =
+ subtypeInfered false InferedType.Top (prim typeof) |> should equal (prim typeof)
+ subtypeInfered false (prim typeof) InferedType.Top |> should equal (prim typeof)
+ subtypeInfered false InferedType.Top InferedType.Null |> should equal InferedType.Null
+ subtypeInfered false InferedType.Top InferedType.Top |> should equal InferedType.Top
+
+[]
+let ``subtypeInfered: Null merged with string makes it optional`` () =
+ // string can have empty values so with allowEmptyValues=true, string stays non-optional
+ let result = subtypeInfered true InferedType.Null (prim typeof)
+
+ match result with
+ | InferedType.Primitive(typ, None, false, false) when typ = typeof -> () // stays non-optional
+ | _ -> failwithf "Unexpected result: %A" result
+
+[]
+let ``subtypeInfered: Null merged with int makes it optional`` () =
+ // int cannot have empty values so it becomes optional regardless of allowEmptyValues
+ let result = subtypeInfered false InferedType.Null (prim typeof)
+
+ match result with
+ | InferedType.Primitive(typ, None, true, false) when typ = typeof -> ()
+ | _ -> failwithf "Unexpected result: %A" result
+
+// ─── subtypeInfered: primitives ─────────────────────────────────────────────
+
+[]
+let ``subtypeInfered: identical primitives return same primitive`` () =
+ subtypeInfered false (prim typeof) (prim typeof) |> should equal (prim typeof)
+ subtypeInfered false (prim typeof) (prim typeof) |> should equal (prim typeof)
+ subtypeInfered false (prim typeof) (prim typeof) |> should equal (prim typeof)
+
+[]
+let ``subtypeInfered: numeric widening Bit0 + Bit1 → Bit`` () =
+ let result = subtypeInfered false (prim typeof) (prim typeof)
+
+ match result with
+ | InferedType.Primitive(typ, None, false, false) when typ = typeof -> ()
+ | _ -> failwithf "Unexpected result: %A" result
+
+[]
+let ``subtypeInfered: numeric widening int + int64 → int64`` () =
+ let result = subtypeInfered false (prim typeof) (prim typeof)
+
+ match result with
+ | InferedType.Primitive(typ, None, false, false) when typ = typeof -> ()
+ | _ -> failwithf "Unexpected result: %A" result
+
+[]
+let ``subtypeInfered: numeric widening int + float → float`` () =
+ let result = subtypeInfered false (prim typeof) (prim typeof)
+
+ match result with
+ | InferedType.Primitive(typ, None, false, false) when typ = typeof -> ()
+ | _ -> failwithf "Unexpected result: %A" result
+
+[]
+let ``subtypeInfered: numeric widening decimal + float → float`` () =
+ let result = subtypeInfered false (prim typeof) (prim typeof)
+
+ match result with
+ | InferedType.Primitive(typ, None, false, false) when typ = typeof -> ()
+ | _ -> failwithf "Unexpected result: %A" result
+
+[]
+let ``subtypeInfered: Bit0 + bool → bool`` () =
+ // bool ⊇ Bit0 (bool has Bit0 in conversion table)
+ let result = subtypeInfered false (prim typeof) (prim typeof)
+
+ match result with
+ | InferedType.Primitive(typ, None, false, false) when typ = typeof -> ()
+ | _ -> failwithf "Unexpected result: %A" result
+
+[]
+let ``subtypeInfered: incompatible types create Heterogeneous`` () =
+ let result = subtypeInfered false (prim typeof) (prim typeof)
+
+ match result with
+ | InferedType.Heterogeneous(map, false) ->
+ map |> Map.containsKey InferedTypeTag.String |> should equal true
+ map |> Map.containsKey InferedTypeTag.Number |> should equal true
+ | _ -> failwithf "Unexpected result: %A" result
+
+[]
+let ``subtypeInfered: Guid + string creates Heterogeneous`` () =
+ let result = subtypeInfered false (prim typeof) (prim typeof)
+
+ match result with
+ | InferedType.Heterogeneous(map, false) ->
+ map |> Map.containsKey InferedTypeTag.Guid |> should equal true
+ map |> Map.containsKey InferedTypeTag.String |> should equal true
+ | _ -> failwithf "Unexpected result: %A" result
+
+[]
+let ``subtypeInfered: optionality is preserved when merging`` () =
+ // Required int merged with required int → required int
+ subtypeInfered false (prim typeof) (prim typeof) |> should equal (prim typeof)
+
+ // Optional int merged with required int → optional int
+ let result = subtypeInfered false (primOpt typeof) (prim typeof)
+
+ match result with
+ | InferedType.Primitive(typ, None, true, false) when typ = typeof -> ()
+ | _ -> failwithf "Unexpected result: %A" result
+
+// ─── subtypeInfered: records ────────────────────────────────────────────────
+
+[]
+let ``subtypeInfered: records with same name union their fields`` () =
+ let record1 =
+ InferedType.Record(
+ Some "R",
+ [ { Name = "a"; Type = prim typeof }
+ { Name = "b"; Type = prim typeof } ],
+ false
+ )
+
+ let record2 =
+ InferedType.Record(
+ Some "R",
+ [ { Name = "a"; Type = prim typeof }
+ { Name = "c"; Type = prim typeof } ],
+ false
+ )
+
+ let result = subtypeInfered false record1 record2
+
+ match result with
+ | InferedType.Record(Some "R", fields, false) ->
+ fields |> List.map (fun f -> f.Name) |> List.sort |> should equal [ "a"; "b"; "c" ]
+ // 'b' is in record1 only → optional in result
+ let bField = fields |> List.find (fun f -> f.Name = "b")
+
+ match bField.Type with
+ | InferedType.Primitive(_, _, true, _) -> () // optional
+ | _ -> failwithf "'b' should be optional, got %A" bField.Type
+ | _ -> failwithf "Unexpected result: %A" result
+
+[]
+let ``subtypeInfered: records with different names become Heterogeneous`` () =
+ let record1 = InferedType.Record(Some "R1", [], false)
+ let record2 = InferedType.Record(Some "R2", [], false)
+ let result = subtypeInfered false record1 record2
+
+ match result with
+ | InferedType.Heterogeneous _ -> ()
+ | _ -> failwithf "Expected Heterogeneous, got %A" result
+
+// ─── inferCollectionType ────────────────────────────────────────────────────
+
+[]
+let ``inferCollectionType with single int type gives Collection with Number`` () =
+ let result = inferCollectionType false [ prim typeof ]
+
+ match result with
+ | InferedType.Collection(order, map) ->
+ order |> should contain InferedTypeTag.Number
+ map |> Map.containsKey InferedTypeTag.Number |> should equal true
+ | _ -> failwithf "Unexpected result: %A" result
+
+[]
+let ``inferCollectionType with mixed numeric types widens to float`` () =
+ let result =
+ inferCollectionType false [ prim typeof; prim typeof; prim typeof ]
+
+ match result with
+ | InferedType.Collection(_, map) ->
+ let _, elemType = map |> Map.find InferedTypeTag.Number
+
+ match elemType with
+ | InferedType.Primitive(typ, None, false, false) when typ = typeof -> ()
+ | _ -> failwithf "Expected float element type, got %A" elemType
+ | _ -> failwithf "Unexpected result: %A" result
+
+[]
+let ``inferCollectionType with multiple items of same type gives Multiple multiplicity`` () =
+ let result =
+ inferCollectionType false [ prim typeof; prim typeof; prim typeof ]
+
+ match result with
+ | InferedType.Collection(_, map) ->
+ let mult, _ = map |> Map.find InferedTypeTag.Number
+ mult |> should equal InferedMultiplicity.Multiple
+ | _ -> failwithf "Unexpected result: %A" result
+
+[]
+let ``inferCollectionType with single item gives Single multiplicity`` () =
+ let result = inferCollectionType false [ prim typeof ]
+
+ match result with
+ | InferedType.Collection(_, map) ->
+ let mult, _ = map |> Map.find InferedTypeTag.Number
+ mult |> should equal InferedMultiplicity.Single
+ | _ -> failwithf "Unexpected result: %A" result
+
+// ─── getInferedTypeFromString ────────────────────────────────────────────────
+
+[]
+let ``getInferedTypeFromString: empty string returns Null`` () =
+ inferStr "" |> should equal InferedType.Null
+
+[]
+let ``getInferedTypeFromString: "0" and "1" infer Bit types`` () =
+ inferStr "0" |> should equal (prim typeof)
+ inferStr "1" |> should equal (prim typeof)
+
+[]
+let ``getInferedTypeFromString: boolean strings infer bool`` () =
+ inferStr "true" |> should equal (prim typeof)
+ inferStr "false" |> should equal (prim typeof)
+ inferStr "True" |> should equal (prim typeof)
+ inferStr "yes" |> should equal (prim typeof)
+ inferStr "no" |> should equal (prim typeof)
+
+[]
+let ``getInferedTypeFromString: integer strings infer int`` () =
+ inferStr "42" |> should equal (prim typeof)
+ inferStr "-123" |> should equal (prim typeof)
+ inferStr "2147483647" |> should equal (prim typeof) // Int32.MaxValue
+
+[]
+let ``getInferedTypeFromString: large integer strings infer int64`` () =
+ inferStr "2147483648" |> should equal (prim typeof) // Int32.MaxValue + 1
+ inferStr "9999999999" |> should equal (prim typeof)
+
+[]
+let ``getInferedTypeFromString: decimal strings infer decimal`` () =
+ inferStr "3.14" |> should equal (prim typeof)
+ inferStr "-0.5" |> should equal (prim typeof)
+
+[]
+let ``getInferedTypeFromString: unrecognised strings fall back to string`` () =
+ inferStr "hello" |> should equal (prim typeof)
+ inferStr "not-a-number" |> should equal (prim typeof)
+ inferStr "abc123" |> should equal (prim typeof)
+
+[]
+let ``getInferedTypeFromString: GUID strings infer Guid`` () =
+ inferStr "6F9619FF-8B86-D011-B42D-00C04FC964FF" |> should equal (prim typeof)
+
+[]
+let ``getInferedTypeFromString: NoInference mode always returns string`` () =
+ let inferNoInference value =
+ getInferedTypeFromString uomProvider noInference culture value None
+
+ inferNoInference "42" |> should equal (prim typeof)
+ inferNoInference "true" |> should equal (prim typeof)
+ inferNoInference "3.14" |> should equal (prim typeof)
+
+// ─── InferenceMode' ─────────────────────────────────────────────────────────
+
+[]
+let ``InferenceMode FromPublicApi BackwardCompatible with legacyInferTypesFromValues=true → ValuesOnly`` () =
+ InferenceMode'.FromPublicApi(InferenceMode.BackwardCompatible, true)
+ |> should equal InferenceMode'.ValuesOnly
+
+[]
+let ``InferenceMode FromPublicApi BackwardCompatible with legacyInferTypesFromValues=false → NoInference`` () =
+ InferenceMode'.FromPublicApi(InferenceMode.BackwardCompatible, false)
+ |> should equal InferenceMode'.NoInference
+
+[]
+let ``InferenceMode FromPublicApi explicit modes map correctly`` () =
+ InferenceMode'.FromPublicApi(InferenceMode.NoInference) |> should equal InferenceMode'.NoInference
+ InferenceMode'.FromPublicApi(InferenceMode.ValuesOnly) |> should equal InferenceMode'.ValuesOnly
+
+ InferenceMode'.FromPublicApi(InferenceMode.ValuesAndInlineSchemasHints)
+ |> should equal InferenceMode'.ValuesAndInlineSchemasHints
+
+ InferenceMode'.FromPublicApi(InferenceMode.ValuesAndInlineSchemasOverrides)
+ |> should equal InferenceMode'.ValuesAndInlineSchemasOverrides
+
+// ─── supportsUnitsOfMeasure ──────────────────────────────────────────────────
+
+[]
+let ``supportsUnitsOfMeasure returns true for numeric types`` () =
+ supportsUnitsOfMeasure typeof |> should equal true
+ supportsUnitsOfMeasure typeof |> should equal true
+ supportsUnitsOfMeasure typeof |> should equal true
+ supportsUnitsOfMeasure typeof |> should equal true
+
+[]
+let ``supportsUnitsOfMeasure returns false for non-numeric types`` () =
+ supportsUnitsOfMeasure typeof |> should equal false
+ supportsUnitsOfMeasure typeof |> should equal false
+ supportsUnitsOfMeasure typeof |> should equal false
+ supportsUnitsOfMeasure typeof |> should equal false