diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 881c709..2f005d9 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -1,25 +1,65 @@ -name: .NET +name: Build & Release (.NET) on: push: branches: [ main ] + tags: + - "v*.*.*" # Release tags like v1.2.3 pull_request: branches: [ main ] jobs: build: - runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - name: Setup .NET - uses: actions/setup-dotnet@v1 + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup .NET 10 (preview) + uses: actions/setup-dotnet@v4 with: - dotnet-version: '5.0.x' - - name: Restore dependencies + dotnet-version: "10.0.x" + + - name: Restore run: dotnet restore + - name: Build - run: dotnet build --no-restore + run: dotnet build --configuration Release --no-restore + - name: Test - run: dotnet test --no-build --verbosity normal + run: dotnet test --configuration Release --no-build --verbosity normal + + release: + needs: build + if: startsWith(github.ref, 'refs/tags/v') + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup .NET 10 (preview) + uses: actions/setup-dotnet@v4 + with: + dotnet-version: "10.0.x" + + - name: Extract version from tag + id: version + run: | + VERSION="${GITHUB_REF#refs/tags/v}" + echo "version=$VERSION" >> $GITHUB_OUTPUT + + - name: Pack NuGet package + run: dotnet pack \ + --configuration Release \ + -p:PackageVersion=${{ steps.version.outputs.version }} \ + -o ./artifacts + + - name: Publish GitHub Release + uses: softprops/action-gh-release@v2 + with: + files: artifacts/*.nupkg + env: + GITHUB_TOKEN: ${{ secrets.FSHARP_DATA_MUTATOR }} + diff --git a/README.md b/README.md index 17b22fc..3cd87dc 100644 --- a/README.md +++ b/README.md @@ -3,14 +3,14 @@ Enables to create copies (similar to lenses) to generated FSharp.Data types (json only for now), -The library now depends both on FSharp.Data and Newtonsoft.Json as dependencies, but can be improved. +The library now depends both on FSharp.Data and System.Text.Json as dependencies, but can be improved. A [medium article](https://jkone27-3876.medium.com/fsharp-data-mutator-66550bb6a2cc) about it. ## Usage ```fsharp -#r "nuget:FSharp.Data.Mutator,0.1.0-beta" +#r "nuget:FSharp.Data.Mutator,0.2.0" open FSharp.Data open FSharp.Data.Mutator @@ -62,3 +62,12 @@ val it : JsonProvider<...>.Root = ``` Have fun! + +## Mantainers + +to create a release, just create a new tag with the version number and push it to the repository, the release will be automatically created and published on nuget.org. + +```cli +git tag v0.2.0 +git push origin v0.2.0 +``` diff --git a/dotnet-tools.json b/dotnet-tools.json new file mode 100644 index 0000000..0ee28ae --- /dev/null +++ b/dotnet-tools.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "dotnet-outdated-tool": { + "version": "4.7.0", + "commands": [ + "dotnet-outdated" + ], + "rollForward": false + } + } +} \ No newline at end of file diff --git a/src/FSharp.Data.Mutator.fsproj b/src/FSharp.Data.Mutator.fsproj index ecc18a3..5f8841d 100644 --- a/src/FSharp.Data.Mutator.fsproj +++ b/src/FSharp.Data.Mutator.fsproj @@ -1,7 +1,7 @@  - netstandard2.1;net5.0 + netstandard2.1;net8.0;net10.0 true 3390;$(WarnOn) jkone27 @@ -29,8 +29,8 @@ - - + + diff --git a/src/JsonMutator.fs b/src/JsonMutator.fs index 368dc35..757adf0 100644 --- a/src/JsonMutator.fs +++ b/src/JsonMutator.fs @@ -1,138 +1,254 @@ namespace FSharp.Data.Mutator -open Newtonsoft.Json.Linq -open Microsoft.FSharp.Quotations -open FSharp.Data +open System +open System.Text.Json +open System.Text.Json.Nodes open System.Text.RegularExpressions open System.Linq.Expressions open Microsoft.FSharp.Linq.RuntimeHelpers +open Microsoft.FSharp.Quotations +open Microsoft.FSharp.Reflection open System.Collections.Generic -open System -open FSharp.Data.Runtime.BaseTypes open System.Runtime.CompilerServices - +open FSharp.Data +open FSharp.Data.Runtime.BaseTypes [] module JsonMutator = - - type JToken with + + // ----------------------------- + // JsonNode <-> FSharp.Data.JsonValue + // ----------------------------- + + type JsonNode with member this.JsonValue() = - this.ToString() |> JsonValue.Parse - - type JToken with - member this.With (mutatorFunc: JToken -> 'a) = - this |> fun y -> mutatorFunc(y) |> ignore; y - - type JsonValue with - member this.JToken() = + this.ToJsonString() |> JsonValue.Parse + + type JsonValue with + member this.JsonNode() = this.ToString() - |> JToken.Parse - - type JsonValue with - member this.With (mutatorFunc: JToken -> 'a) = - this.JToken().With(mutatorFunc).JsonValue() + |> JsonNode.Parse + + // ----------------------------- + // "With" helpers (mutation) + // ----------------------------- + + type JsonNode with + member this.With (mutatorFunc: JsonNode -> 'a) = + this |> fun n -> mutatorFunc n |> ignore; n + + type JsonValue with + member this.With (mutatorFunc: JsonNode -> 'a) = + this.JsonNode().With(mutatorFunc).JsonValue() + + // ----------------------------- + // Expression helpers + // ----------------------------- let getLR expr = - let rec getLeftRight (expr : Expression) r = + let rec getLeftRight (expr: Expression) r = match expr with - | :? MethodCallExpression as mc when mc.Arguments.Count > 0 -> + | :? MethodCallExpression as mc when mc.Arguments.Count > 0 -> getLeftRight (mc.Arguments.[0]) r - | :? LambdaExpression as l -> + | :? LambdaExpression as l -> getLeftRight l.Body r - | :? BinaryExpression as be -> - getLeftRight be.Right [be.Left; be.Right] - |_ -> r + | :? BinaryExpression as be -> + getLeftRight be.Right [ be.Left; be.Right ] + | _ -> r getLeftRight expr [] - - let inline UpdateLeaf<'a when 'a :> IJsonDocument>(updateAction: Expr<('a -> bool)>) (jsonValue: JsonValue) = - - let expression = - updateAction - |> LeafExpressionConverter.QuotationToExpression - - let binomialResult = - expression - |> getLR - - // todo if not primitive, turn to JToken - let jtoken = - match binomialResult with - [l;r] -> - let t = r.Type.Name.ToLower() - match t with - | "jsonvalue" -> + + // ----------------------------- + // JsonPath-like selection for JsonNode + // ----------------------------- + + let private trySelectPath (root: JsonNode) (path: string) : JsonNode option = + if String.IsNullOrWhiteSpace path then + Some root + else + let parts = path.Split('.', StringSplitOptions.RemoveEmptyEntries) + + let parsePart (p: string) = + let idxStart = p.IndexOf('[') + if idxStart >= 0 then + let idxEnd = p.IndexOf(']', idxStart + 1) + let name = p.Substring(0, idxStart) + let idx = p.Substring(idxStart + 1, idxEnd - idxStart - 1) |> int + name, Some idx + else + p, None + + let rec loop (node: JsonNode) i = + if i = parts.Length then + Some node + else + let name, idxOpt = parsePart parts.[i] + + match node : JsonNode with + | :? JsonObject as o -> + let mutable outNode: JsonNode = null + match o.TryGetPropertyValue(name, &outNode) with + | true -> + match idxOpt, outNode with + | Some idx, (:? JsonArray as arr) when idx >= 0 && idx < arr.Count -> + loop arr.[idx] (i + 1) + | None, _ -> + loop outNode (i + 1) + | _ -> + None + | false -> + None + + | :? JsonArray as arr -> + match idxOpt with + | Some idx when idx >= 0 && idx < arr.Count -> + loop arr.[idx] (i + 1) + | _ -> + None + + | _ -> + None + + loop root 0 + + // ----------------------------- + // Option detection and conversion + // ----------------------------- + + let private isOptionType (t: Type) = + t.IsGenericType && t.GetGenericTypeDefinition() = typedefof> + + let private toJsonNodeFromObj (o: obj) : JsonNode = + match o with + | null -> + JsonValue.Create(null) :> JsonNode + + | _ when isOptionType (o.GetType()) -> + let case, fields = FSharpValue.GetUnionFields(o, o.GetType()) + match case.Name, fields with + | "Some", [| v |] -> + JsonValue.Create(v) :> JsonNode + | "None", _ -> + JsonValue.Create(null) :> JsonNode + | _ -> + JsonValue.Create(null) :> JsonNode + + | _ -> + JsonValue.Create(o) :> JsonNode + + // ----------------------------- + // UpdateLeaf using System.Text.Json.Nodes + // ----------------------------- + + let UpdateLeaf<'a when 'a :> IJsonDocument> + (updateAction: Expr<'a -> bool>) + (jsonValue: JsonValue) + = + let expression = + updateAction + |> LeafExpressionConverter.QuotationToExpression + + let binomialResult = + expression + |> getLR + + let jsonNodeToSet: JsonNode = + match binomialResult with + | [ _l; r ] -> + let tName = r.Type.Name.ToLower() + match tName with + | "jsonvalue" -> let lambda = r.Reduce() - let r = Expression.Lambda(lambda).Compile().DynamicInvoke() - (r :?> JsonValue).JToken() - | "ijsondocument" -> + let rVal = Expression.Lambda(lambda).Compile().DynamicInvoke() + (rVal :?> JsonValue).JsonNode() + + | "ijsondocument" -> let lambda = r.Reduce() - let r = Expression.Lambda(lambda).Compile().DynamicInvoke() - (r :?> IJsonDocument).JsonValue.JToken() - | "ijsondocument[]" -> + let rVal = Expression.Lambda(lambda).Compile().DynamicInvoke() + (rVal :?> IJsonDocument).JsonValue.JsonNode() + + | "ijsondocument[]" -> let lambda = r.Reduce() - let r = Expression.Lambda(lambda).Compile().DynamicInvoke() - let stringList = - (r :?> IJsonDocument[]) - |> Array.map (fun x -> x.JsonValue.JToken()) - - let jarrayString = String.Join(",", stringList) - - $"[{jarrayString}]" - |> JArray.Parse - :> JToken - |_ -> - let tOption = typeof>.GetGenericTypeDefinition() + let rVal = Expression.Lambda(lambda).Compile().DynamicInvoke() + let nodes = + (rVal :?> IJsonDocument[]) + |> Array.map (fun x -> x.JsonValue.JsonNode()) + let arr = JsonArray() + nodes |> Array.iter arr.Add + arr :> JsonNode + + | _ -> let lambda = r.Reduce() - let r = Expression.Lambda(lambda).Compile().DynamicInvoke() - match r with - | null -> JValue.CreateNull() :> JToken - |_ as o when r.GetType().IsGenericType && r.GetType().GetGenericTypeDefinition() = tOption -> - let strOption = Newtonsoft.Json.JsonConvert.SerializeObject(o) - let opt = Newtonsoft.Json.JsonConvert.DeserializeObject>(strOption) - match opt with - |Some(v) -> - JValue(v) :> JToken - |None -> - JValue.CreateNull() :> JToken - - |_ -> JValue(r) :> JToken - - let left = - match binomialResult with - |[l;r] -> l - |_ -> expression - - let cleanedStr = Regex.Replace(left.ToString(), @"\t|\n|\r", "") - - let invertedCalls = - Regex.Matches(cleanedStr, "\,\s+(?(\")?(\w|\$)+(?\")?)\)") - |> Seq.map (fun m -> (m.Groups.["Prop"].Value.Replace("\"",""), String.IsNullOrEmpty(m.Groups.["IsDigit"].Value))) - |> Seq.fold (fun acc next -> - let prop,isDigit = next - match isDigit with - |true -> - let q = acc |> Seq.ofList |> Queue - let p = q.Dequeue() - $"{p}.[{prop}]" :: (q |> List.ofSeq) - |false -> - prop :: acc - ) [] - - - let key = invertedCalls.[0] - let jsonPath = System.String.Join(".", invertedCalls |> Seq.skip 1 |> Seq.rev) - - jsonValue.With(fun x -> x.SelectToken(jsonPath).[key] <- jtoken) - - let inline Change<'a when 'a :> IJsonDocument>(updateAction: Expr<('a -> bool)>) (jsonDocument : 'a) = + let rVal = Expression.Lambda(lambda).Compile().DynamicInvoke() + toJsonNodeFromObj rVal + + | _ -> + JsonValue.Create(null) :> JsonNode + + let left = + match binomialResult with + | [ l; _r ] -> l + | _ -> expression + + let cleanedStr = Regex.Replace(left.ToString(), @"\t|\n|\r", "") + + let invertedCalls = + Regex.Matches(cleanedStr, "\,\s+(?(\")?(\w|\$)+(?\")?)\)") + |> Seq.map (fun m -> + let prop = m.Groups.["Prop"].Value.Replace("\"", "") + let isDigit = String.IsNullOrEmpty(m.Groups.["IsDigit"].Value) + prop, isDigit) + |> Seq.fold + (fun acc (prop, isDigit) -> + match isDigit with + | true -> + let q = acc |> Seq.ofList |> Queue + let p = q.Dequeue() + $"{p}.[{prop}]" :: (q |> List.ofSeq) + | false -> + prop :: acc) + [] + |> List.ofSeq + + let key = invertedCalls.[0] + let jsonPath = String.Join(".", invertedCalls |> Seq.skip 1 |> Seq.rev) + + jsonValue.With(fun root -> + match trySelectPath root jsonPath with + | Some target -> + match target : JsonNode with + | :? JsonObject as o -> + o.[key] <- jsonNodeToSet + + | :? JsonArray as arr -> + match Int32.TryParse key with + | true, idx when idx >= 0 && idx < arr.Count -> + arr.[idx] <- jsonNodeToSet + | _ -> () + + | _ -> () + + | None -> () + ) + + // ----------------------------- + // Change helpers (public API) + // ----------------------------- + + let Change<'a when 'a :> IJsonDocument> + (updateAction: Expr<'a -> bool>) + (jsonDocument: 'a) + = UpdateLeaf updateAction jsonDocument.JsonValue - |> fun x -> JsonDocument.Create(x,"") :?> 'a + |> fun x -> JsonDocument.Create(x, "") :?> 'a [] type ExtensionMethod() = [] - static member inline Change<'a when 'a :> IJsonDocument>(this: 'a,[] updateAction: Expr<('a -> bool)>) = + static member Change<'a when 'a :> IJsonDocument> + ( + this: 'a, + [] updateAction: Expr<'a -> bool> + ) = this.JsonValue - |> UpdateLeaf updateAction - |> fun x -> JsonDocument.Create(x,"") :?> 'a - + |> UpdateLeaf updateAction + |> fun x -> JsonDocument.Create(x, "") :?> 'a diff --git a/test/FSharp.Data.Mutator.Tests.fsproj b/test/FSharp.Data.Mutator.Tests.fsproj index 08fb256..f3daf41 100644 --- a/test/FSharp.Data.Mutator.Tests.fsproj +++ b/test/FSharp.Data.Mutator.Tests.fsproj @@ -1,7 +1,7 @@ - net5.0 + net10.0 false false @@ -13,13 +13,13 @@ - - - + + + runtime; build; native; contentfiles; analyzers; buildtransitive all - + runtime; build; native; contentfiles; analyzers; buildtransitive all