Search code examples
genericsmetaprogrammingnim-lang

How to map seq[type] to seq[type.field] with a generic fieldname?


I am building a web application and using an ORM called norm. As such, I have an SQL database with a bunch of tables, all of which correspond to various Models that I have in nim. Often enough, they are in many-to-many relationships such as this:

type 
  A = ref object of Model
    name: string
  B = ref object of Model
    anotherName: string
  C = ref object of Model
    myA: A
    myB: B

Due to the way norm is currently implemented, I have to query C if I want all entries B for a given A or all entries A for a given B.

That leaves me with a seq[C] when I actually wanted seq[A] or seq[B].

I can, of course, for a specific set of A, B and C iterate over the seq with the mapIt function of sequtils:

import sequtils
let myASeq: seq[A] = myCSeq.mapit(it.myA)

But given that I have around 20 or so of these relationships and I'd like to not implement the same thing 20 times there. I'd prefer a generic way, where I can just type in the fieldName (e.g. myA) in some way and resolve it like that on the fly. How can I achieve this?


Solution

  • Thanks to the incredibly helpful folks on the nim discord server (shoutout to ElegantBeef), that educated me on this. There is 2 ways you can go about this:

    1. If you know the field name when writing the code, you can use a template

    This is preferred, as templates are simpler to understand than macros.
    import norm/[model, pragmas]
    import std/[sequtils, typetraits, macros, json] 
    
    template mapModel[T: Model](mySeq: seq[T], field: untyped): seq[untyped] = mySeq.mapIt(it.field)
    

    Example usage:

    type
        A = ref object of Model
            name: string
        D = ref object of Model
            myothernameid: string
            myDA: A
    
    var myDSeq: seq[D] = @[]
    let anA: A = A(name: "this is an A")
    myDSeq.add(D(myothernameid: "la", myDA: anA))
    myDSeq.add(D(myothernameid: "le", myDA: anA))
    
    echo %*myDSeq # [{"myothernameid":"la","myDA":{"name":"this is an A","id":0},"id":0},{"myothernameid":"le","myDA":{"name":"this is an A","id":0},"id":0}]
    
    let myASeq: seq[A] = mapModel(myDSeq, "myDA")
    
    echo %*myASeq # [{"name":"this is an A","id":0},{"name":"this is an A","id":0}]
    

    2. If you only know the name of specific field at compile time as a static string, you can use a macro

    This one is useful if you only have access to the field name at compile time and not the field itself, because that might be different depending on a when condition.

    import norm/[model, pragmas]
    import std/[sequtils, typetraits, macros, json] 
    
    macro mapModel[T: Model](mySeq: seq[T], field: static string): untyped =
        newCall(bindSym"mapIt", mySeq, nnkDotExpr.newTree(ident"it", ident field))
    

    Example usage:

    var myDSeq: seq[D] = @[]
    let anA: A = A(name: "this is an A")
    myDSeq.add(D(myothernameid: "la", myDA: anA))
    myDSeq.add(D(myothernameid: "le", myDA: anA))
    
    echo %*myDSeq # As Before
    
    let myASeq: seq[A] = mapModel(myDSeq, "myDA")
    
    echo %*myASeq # As Before
    

    For an explanation what the macro does, I do not have a full understanding of it myself, but wrote down what I got out of my chat with ElegantBeef:

    newCall --> You're about to receive something to execute which I'll refer to as callable, do that

    bindSym"mapIt" --> Call a function that is bound to the symbol "mapIt", and do so with the following arguments

    mySeq --> the first argument to pass to the function of the "mapIt" symbol

    nnkDotExpr.newTree --> This is going to be an execution tree or whatever they're called this resolves into a callable, such as a proc or a func. Specifically a callable that is a dot expression.

    ident"it" --> The value of the variable that has the symbol "it", this is supplied by the "mapIt" function

    ident field --> The value of the variable that has the symbol contained within the field variable.

    nnkDotExpr.newTree(ident"it", ident field) --> Call a dot expression on it to grab the value of its field that goes by the name contained within field. This is equivalent to it.la if field contains "la"