How can I determine the json path to a field within a record without actually hard coding the path?

Viewed 115

I would like to work with the following type

type RecordPath<'a,'b> = {
    Get: 'a -> 'b
    Path:string
}

It's purpose is to define a getter for going from record type 'a to some field within 'a of type 'b. It also gives the path to that field for the json representation of the record.

For example, consider the following fields.

type DateWithoutTimeBecauseWeirdlyDotnetDoesNotHaveThisConcept = {
    Year:uint
    Month:uint
    Day:uint
}

type Person = {
    FullName:string
    PassportNumber:string
    BirthDate:DateWithoutTimeBecauseWeirdlyDotnetDoesNotHaveThisConcept
}

type Team = {
    TeamName:string
    TeamMembers:Person list
}

An example RecordPath might be

let birthYearPath = {
    Get = fun (team:Team) -> team.TeamMembers |> List.map (fun p -> p.BirthDate.Year)
    Path = "$.TeamMember[*].BirthDate.Year" //using mariadb format for json path
}

Is there some way of letting a library user create this record without ever actually needing to specify the string explicitly. Ideally there is some strongly typed way of the user specifying the fields involved. Maybe some kind of clever use of reflection?

It just occurred to me that with a language that supports macros, this would be possible. But can it be done in F#?

PS: I notice that I left out the s in "TeamMembers" in the path. This is the kind of thing I want to guard against to make it easier on the user.

2 Answers

As you noted in the comments, F# has a quotation mechanism that lets you do this. You can create those explicitly using <@ ... @> notation or implicitly using a somewhat more elengant automatic quoting mechanism. The quotations are farily close representations of the F# code, so converting them to the desired path format is not going to be easy, but I think it can be done.

I tried to get this to work at least for your small example. First, I needed a helper function that does two transformations on the code and turns:

  • let x = e1 in e2 into e2[x <- e1] (using the notation e2[x <- e1] to mean a subsitution, i.e. expression e2 with all occurences of x replaced by e1)
  • e1 |> fun x -> e2 into e2[x <- e1]

This is all I needed for your example, but it's likely you'll need a few more cases:

open Microsoft.FSharp.Quotations

let rec simplify dict e = 
  let e' = simplifyOne dict e
  if e' <> e then simplify dict e' else e'

and simplifyOne dict = function 
  | Patterns.Call(None, op, [e; Patterns.Lambda(v, body)]) 
        when op.Name = "op_PipeRight" ->
      simplify (Map.add v e dict) body 
  | Patterns.Let(v, e, body) -> simplify (Map.add v e dict) body 
  | ExprShape.ShapeVar(v) when Map.containsKey v dict -> dict.[v]
  | ExprShape.ShapeVar(v) -> Expr.Var(v)
  | ExprShape.ShapeLambda(v, e) -> Expr.Lambda(v, simplify dict e)
  | ExprShape.ShapeCombination(o, es) -> 
      ExprShape.RebuildShapeCombination(o, List.map (simplify dict) es)

With this pre-processing, I managed to write an extractPath function like this:

let rec extractPath var = function
  | Patterns.Call(None, op, [Patterns.Lambda(v, body); inst]) when op.Name = "Map" ->
      extractPath var inst + "[*]." + extractPath v.Name body
  | Patterns.PropertyGet(Some(Patterns.Var v), p, []) when v.Name = var -> p.Name
  | Patterns.PropertyGet(Some e, p, []) -> extractPath var e + "." + p.Name
  | e -> failwithf "Unexpected expression: %A" e

This looks for (1) a call to map function, (2) a property access on a variable that represents the data source and (3) a property access where the instance has some more property accesses.

The following now works for your small example (but probably for nothing else!)

type Path = 
  static member Make([<ReflectedDefinition(true)>] f:Expr<'T -> 'R>) = 
    match f with
    | Patterns.WithValue(f, _, Patterns.Lambda(v, body)) ->
      { Get = f :?> 'T -> 'R
        Path = "$." + extractPath v.Name (simplify Map.empty body) }
    | _ -> failwith "Unexpected argument"

Path.Make(fun (team:Team) -> team.TeamMembers |> List.map (fun p -> p.BirthDate.Year))

The way I solved this is

let jsonPath userExpr = 
    let rec innerLoop expr state =
        match expr with
        |Patterns.Lambda(_, body) ->
            innerLoop body state
        |Patterns.PropertyGet(Some parent, propInfo, []) ->
            sprintf ".%s%s" propInfo.Name state  |> innerLoop parent
        |Patterns.Call (None, _, expr1::[Patterns.Let (v, expr2, _)]) when v.Name = "mapping"->
            let parentPath = innerLoop expr1 "[*]"
            let childPath = innerLoop expr2 ""
            parentPath + childPath
        |ExprShape.ShapeVar x ->
            state
        |_ -> 
            failwithf "Unsupported expression: %A" expr
    innerLoop userExpr "" |> sprintf "$%s"

type Path = 
  static member Make([<ReflectedDefinition(true)>] f:Expr<'T -> 'R>) = 
    match f with
    |Patterns.WithValue(f, _, expr) ->
        let path = jsonPath expr
        {
            Get = f :?> 'T -> 'R
            Path = path
        }
    | _ -> failwith "Unexpected argument"

Caveat: I don't know enough about these techniques to tell if Tomas' answer performs better in some edge cases than mine.

Related