I had recently at work gotten into using MapStruct for mapping data-classes of Type A to Type B in a simple manner.
And I thought to myself "Huh, I have to do a lot of mapping of type A to B for webdev to hide certain fields or do minor transformations/renames on some of them. This seems like it would be a nice small utility". And so I decided to reimplement the simple mapping of A to B that MapStruct made possible. And because I am creative at naming I named it mapster.
The concept is pretty simple:
Define a proc/func that takes in parameters that may include types with fields and outputs a type with fields, then annotate it with the {.map.} pragma. Mapster will insert assignment status of the variety result.<fieldName> = parameter.<fieldName> for every field where name and type match. You can define your own assignments of result.myField = 5 or the like as you want in the proc body, it's just a normal proc after all that inserts a few lines of code before your code body.
So an example:
import std/times
type A = object
str: string
num: int
floatNum: float
dateTime: DateTime
boolean: bool
type B = object
str: string
num: int
floatNum: float
dateTime: DateTime
boolean: bool
let a = A(
str: "str,
num: 5,
floatNum: 2.5,
dateTime: now(),
boolean: true
)
proc myMapProc(x: A): B {.map.} = discard # Will transfer all fields of A to B
let myB: B = myMapProc(a)
proc myMapProcTripleNum(x: A): B {.map.} =
# Perfectly valid, it's just a proc with extra stuff added before the proc-body!
result.num = x.num * 3
echo "Assigned triple num!"
let myB2: B = myMapProcTripleNum(a)
It was a nice little project, particularly about learning more about macros and I likely will make use of it in my web-projects a fair bit as those have a lot of mapping-steps from data-models that interact with the database to data-models that can get JSON-serialized and sent to the outside. And a lot of those are quite annoying to deal with, having the simple assignments dealt with so I only need to write out the ones that matter will be quite nice.
Shoutout here to hugogranstrom and elegantbeef for the support in discord and the forum (in threads like this )
As an aside: Nim's AST manipulation made this obscenely simple. I was legitimately in awe at how something that seemed like it took an arm and a leg to implement in Java was doable in nim in ~100 lines of code (though tbf this does not 100% reimplement everything MapStruct does but basically everything that I've ever needed from it). I was already expecting nim to be an order of magnitude simpler to do this in, but this was more than just 1 order of magnitude.
I didn't originally write the package with that in mind, but mapping object Variant --> object type works without issue. See here 2 unit-tests I just added to test that:
test """
GIVEN an object variant A and an object type B that share some fields on the instance
WHEN an instance of A is mapped to an instance of B
THEN it should create an instance of B with all fields having the value of their name counterparts from A
""":
# Given
type Kind = enum
str, num
type A = object
case kind: Kind
of str: str: string
of num: num: int
type B = object
str: string
num: int
proc map(x: A): B {.map.} = discard
let a = A(
kind: str,
str: "str"
)
# When
let result: B = map(a)
# Then
let expected = B(str: "str")
check result == expected
test """
GIVEN an object type A and an object variant type B that share some fields on the instance
WHEN two instances of A are mapped to an instance of B
THEN it should create an instance of B with all fields having the value of their name counterparts from A
""":
# Given
type Kind = enum
str, num
type A = object
case kind: Kind
of str: str: string
of num: num: int
type B = object
str: string
num: int
proc map(x: A, y: A): B {.map.} = discard
let a1 = A(
kind: str,
str: "str"
)
let a2 = A(
kind: num,
num: 5
)
# When
let result: B = map(a1, a2)
# Then
let expected = B(str: "str", num: 5)
check result == expected
Mapping anything => object Variant is not yet possible. Syntactically because the compiler stops you with Error: parallel 'fields' iterator does not work for 'case' objects as well as Conceptually because the only way I could even contemplate what kind of the output object variant you'd want out is if you passed in a "kind" parameter explicitly (or you had multiple variants that share an enum but that's such a special case I don't feel like it's worth handling separately).
I think I'd need to introduce a new pragma mapVariant or so for that to handle that explicitly and separately from all other usecases.
Object variants are actually pretty interesting here.
I need to basically define a set of fields that assignments are allowed to for each kind an object variant can be. Based on that set of fields I can then generate assignments ala result.<fieldName> = source.<fieldName> and insert them at the start of the proc-body.
I only have the fieldName available at runtime though, so I basically have to generate a switch-case-statement that tries to transfer over all the fields it can from object type A to variant type B for every possible kind that B can be.
Oooff that sounds not that easy. Particularly first figuring out the given list of fields (or rather field-names) for each kind. But maybe there's a way to do this via proc and I generate a call to that proc...
Yep, it's feasible!
For the most part I just need to change the proc that I call so that it ignores assigning to the discriminator-field of an object-variant, so that I can make sure that one only gets assigned once explicitly.
So basically:
template getIterator(a: typed): untyped =
when a is ref:
a[].fieldPairs
else:
a.fieldPairs
proc mapToVariant*(source: auto, target: var auto, ignoreFields: SomeSet[string] = initHashSet[string]()) =
when source is ref:
if source == nil:
return
for sourceName, sourceField in source.getIterator():
for targetName, targetField in target.getIterator():
when sourceName.eqIdent(targetName) and sourceField is typeof(targetField):
if targetName notin ignoreFields:
targetField = sourceField
And then for a given annotated proc definition such as this (where the parameter to the pragma defines which parameter in the proc-definition contains the kind to be used):
proc myMapProc(a: A, myKind: Kind): B {.mapVariant: "myKind".} = discard
Will get turned into a proc such as this:
proc myMapProc(a: A; myKind: Kind): B =
result = B()
result.kind = myKind
a.mapToVariant(result, "kind")
discard
I've got the code down, I'll need to take some time to write the necessary tests around it etc., but likely by sunday or so we'll have version 0.2. with this added.
Mapster does support mapping to object variants now with v0.2.0!
Though only to single-variants, if you are the kind of monster to have double-variants or more like this:
type A = enum
a,b
type B = object
case kind1: A
of a: str1: string
of b: str2: string
case kind2: A
of a: str3: string
of b: str4: string
Then there's no amount of help I can provide v.v Forgot to write it here as well, but essentially I now implemented a compile-time checker that you fully assign to every field in your mapping procedure. It essentially keeps a list of all fields it can automatically map from the parameters to the fields of the desired object and then checks for the assignments that happen within the proc. The assignments can be as complex as they want, I recursively check for all nnkAsgn nodes, which should get all of them regardless of what happens.
If the combination of those two lists is the entire set of fields on the desired object, you pass, else you get a compiler-error. No more "Oh, I forgot to assign here so now that field is 0/""/@[]/nil". For the most part anyway.
There are 2 edge-cases where I don't quite get how to solve them without it being insanely complicated:
1) How to ensure that you assign to every field of an object when if/case statements are involved. The problem here is you might have field a on the desired object type A and you only assign to it if A.b > 5, but you forgot to assign something if A.b < 5. How I could catch that in a simple way I don't know.
2) object variants For object variants, when they are used for mapping it counts all their fields, including their variant fields, for mapping. So if your result-type A has the field a but the object variant you use as parameter has a only in 1 of its variants and you don't assign to A.a when your variant is of a kind that does not have a, then I can't catch that. I guess I should just not allow object variant fields to count to force the user to have explicit assignments for those fields?
As for when the result-type is an object variant - There you basically need to have fields and/or assignments to satisfy every field of an object variant, to make sure you can construct every kind of an object variant with all fields of it assigned to. I guess that works out well enough (?).
Heyho everybody, mapster essentially got bumped to version 1.2.
The notable new feature includes: