gwerren.com

Programming notes and tools...

JSON Data Mapper

Fri, 24 Apr 2020
Related Posts
Overview Spec

Direct Mapping

Target = Source

Example:

Applicant.Age = Applicants.App1.Age

Multiple Mapping Definitions

Multiple mapping definitions can be specified, one per line, e.g.:

Applicant.Age = Applicant.Age
Applicant.PostCode = Applicant.Address.PostCode

Using this definition against the source JSON:

{
    Applicant: {
        Age: 23,
        Address: {
            HouseNameNumber: "Lime House",
            Postcode: "AB12 3CD"
        }
    }
}

Would produce the following target JSON:

{
    Applicant: {
        Age: 23,
        PostCode: "AB12 3CD"
    }
}

Mapping definitions are applied in order so if we had the following mapping the application of the second line would overwrite the first, ending up with the target Address property containing the source HouseNameNumber value.

Applicant.Address = Applicant.Address.PostCode
Applicant.Address = Applicant.Address.HouseNameNumber

Source From or Target To Root

We may want to map the entire source object onto a property in the target (or map onto the entire target). This is done by leaving the source or target empty (since the path to root is empty).

For example:

Special Characters

Looking at a target or source definition we can see that each is a dot-separated list of path segments or nodes. e.g. in the definition, Applicant.Address there are two segments, Applicant and Address.

A segment can be surrounded in double quotes and must be if it contains special characters, e.g. Applicant.Address."First Name".

Special characters are any that are not the following:

If a segment needs to contain a double quote then use a second double quote as an escape character, e.g. "My ""Fun"" Name" would be the correctly quoted and escaped representation of the segment name My "Fun" Name.

Variables

Variables are used to hold simple data (e.g. a string value or value that can be simply converted to a string) from a source JSON object. The variables are then used in the target definition in place of a hard-coded string.

Variables are always of the form $(name) where name is a valid variable name.

A variable name must only contain alphanumeric characters or the following special characters:

Property Capture

A variable can be used to capture all property names on a source object and then use those property names on the target object.

So the following mapping:

Addresses[$(applicantId)] = Applicants[$(applicantId)].Address

Given the following source object:

{
    Applicants: {
        App1: {
            Age: 23,
            Address: {
                HouseNameNumber: "Lime House",
                Postcode: "AB12 3CD"
            }
        },
        App2: {
            Age: 23,
            Address: {
                HouseNameNumber: "23",
                Postcode: "WX89 0YZ"
            }
        }
    }
}

Would produce the following target object:

{
    Addresses: {
        App1: {
            HouseNameNumber: "Lime House",
            Postcode: "AB12 3CD"
        },
        App2: {
            HouseNameNumber: "23",
            Postcode: "WX89 0YZ"
        }
    }
}

Filtering Property Capture

Variable values assigned through property capture can be filtered to restrict the set of values they capture.

Filters must use the following rules:

Examples:

Filters are applied to variables by specifying them inside curly brackets directly after the variable name in the source specification. In the following example a filter to exclude App1 is being applied to the applicantId variable:

Addresses[$(applicantId)] = Applicants[$(applicantId){!App1}].Address

Property Value Capture

By default property capture will assign the properties name to the variable, it is also possible to use the properties value (or some sub-value).

This is done by specifying following the variable with a path specification in brackets.

A path specification consists of a colon followed by the path to the desired property. A colon with no path indicates that the current properties value should be used.

So using the following mapping:

Applicants[$(applicantId)] = Applicants[$(applicantId)(:Id)]

With the following source JSON:

{
    Applicants: {
        A: {
            Id: 'App1',
            Age: 23
        },
        B: {
            Id: 'App2',
            Age: 27
        }
    }
}

Would produce the following target JSON:

{
    Applicants: {
        App1: {
            Id: 'App1',
            Age: 23
        },
        App2: {
            Id: 'App2',
            Age: 27
        }
    }
}

This approach can also be used with source arrays so using the above mapping, the following source object would produce the same target object as above.

{
    Applicants: [
        {
            Id: 'App1',
            Age: 23
        },
        {
            Id: 'App2',
            Age: 27
        }
    ]
}

If using value capture and filtering, the value capture is specified first, e.g. Applicants[$(applicantId)(:Id){!App1}].

Multi-Capture

It is possible to capture multiple variables at the same position if required. To do this simply comma separate the variable definitions.

In the following example we are capturing the applicants Id using property name capture and the applicants age using property value capture.

ApplicantsByAge[$(applicantAge)][$(applicantId)] = Applicants[$(applicantId),$(applicantAge)(:Age)].Name

Using the following source JSON:

{
    Applicants: {
        App1: {
            Age: 23,
            Name: "Frank"
        },
        App2: {
            Age: 25,
            Name: "Joe"
        },
        App3: {
            Age: 23,
            Name: "Bob"
        }
    }
}

Would produce the following target JSON:

{
    ApplicantsByAge: {
        23: {
            App1: "Frank",
            App3: "Bob"
        },
        25: {
            App2: "Joe"
        }
    }
}

When using multi-capture with filters, the filters are still specified and applied per variable, if a property is filtered out on one of the filters then that applies across the board.

ApplicantsByAge[$(applicantAge)][$(applicantId)] = Applicants[$(applicantId){!App1},$(applicantAge)(:Age){!25}].Name

Using the above mapping with the previous example source JSON would produce the following output JSON:

{
    ApplicantsByAge: {
        23: {
            App3: "Bob"
        }
    }
}

Combining Variables for Property Names

We may need to capture multiple values and combine them to produce the property name in the target JSON. Target property names are actually interpolated strings with variables being replaced so we could have the following specification:

Applicants[$(applicantFirstName)_$(applicantLastName)] = Applicants[$(applicantFirstName)(:FirstName),$(applicantLastName)(:LastName)].Age

Using the following source JSON

{
    Applicants: [
        {
            FirstName: "Frank",
            LastName: "Jones",
            Age: 25
        },
        {
            FirstName: "Billy",
            LastName: "Baker",
            Age: 34
        }
    ]
}

Would produce the following target JSON:

{
    Applicants: {
        Frank_Jones: 25,
        Billy_Baker: 34
    }
}

Target Property Values

So far we have seen how to use variables to define target property names, we can also use variables to set target property values.

This is done by defining an assignment inside curly brackets at the most nested level possible with the assignment property being relative to the definition location. In the following example we are assigning the applicantAge variable to the Age property:

Applicants[$(applicantId)]{Age:$(applicantAge)}.Name = ApplicantsByAge[$(applicantAge)][$(applicantId)]

This could also be written as follows however this is more complex (hence we always want to specify assignment at the most nested level possible).

Applicants{$(applicantId).Age:$(applicantAge)}[$(applicantId)].Name = ApplicantsByAge[$(applicantAge)][$(applicantId)]

Using the following source JSON with the above definition:

{
    ApplicantsByAge: {
        23: {
            App1: "Frank",
            App3: "Bob"
        },
        25: {
            App2: "Joe"
        }
    }
}

Would produce the following target JSON:

{
    Applicants: {
        App1: {
            Age: 23,
            Name: "Frank"
        },
        App2: {
            Age: 25,
            Name: "Joe"
        },
        App3: {
            Age: 23,
            Name: "Bob"
        }
    }
}

Targeting Property Values in Arrays

We can also target property values in arrays so we could have the following mapping definition:

Applicants[]{Age:$(applicantAge),Id:$(applicantId)}.Name = ApplicantsByAge[$(applicantAge)][$(applicantId)]

Note the use of the empty square brackets denoting an array and the comma separation for the multiple assignments

Given the following source JSON:

{
    ApplicantsByAge: {
        23: {
            App1: "Frank",
            App3: "Bob"
        },
        25: {
            App2: "Joe"
        }
    }
}

This would produce the following target JSON:

{
    Applicants: [
        {
            Age: 23,
            Id: App1,
            Name: "Frank"
        },
        {
            Age: 23,
            Id: App3,
            Name: "Bob"
        },
        {
            Age: 25,
            Id: App2,
            Name: "Joe"
        }           
    ]
}

More on Target Arrays

Any node in the target can be declared as an array, this is done as above by using the open-close square brackets (i.e. []). The open-close square brackets should come immediately after the node name definition before any modifiers (e.g. before any target property setters).

The array declaration can be used on a plain node name or on an indexed node, e.g. both the following are valid:

Names[] = Applicants[$(applicantId)].Name

ChildNames.[$(applicantId)][] = Applicants[$(applicantId)].Children[$(childId)].Name

Each matching source element will be added to the target array as a new element. This is true even if mapping onto an existing target (see below for more details), no attempt is made to match existing array elements.

Partials

If our mapping starts getting complicated and has repetitive parts we may want to define partial mappings that we can reuse. This is done using partials. A partial is defined using double angle brackets and follows the same naming rules as variables. Partials can be used in either the source or target sides of the definition and can include variable names.

Assignment to a partial is done using the double colon operator, the partial can then be used in place of the full definition.

The following mapping declares two partials and then uses them in multiple individual mapping definitions.

<<sourceApplicant>>:: Application.Main.Applicants[$(applicantId)].Details
<<targetApplicant>>:: Applicants[$(applicantId)]

<<targetApplicant>>.Name = <<sourceApplicant>>.Name
<<targetApplicant>>.PostCode = <<sourceApplicant>>.Address.PostCode
<<targetApplicant>>.Title = <<sourceApplicant>>.Title.Name

Under the Hood

When actually applying a mapping the following steps are taken:

Mapping to an Existing Object

So far we have only considered mapping from a source object to a new (empty) target object. If we want to be able to map new data into an existing object there are a few more considerations.

The most basic approach would be to map into an empty object and then simply union the existing and new objects, taking the values from the new object where there are conflicts. This is often not desired though, for instance we may not want to map some properties that don't already exist in the target object or we may have a situation where multiple objects exist in the target that we want to map the same source value to.

The set of considerations we will handle are:

For now we will not support mapping a source value to multiple matching target values, this will simply cause an error in the mapping.

Conditional Mapping

Given the above requirements for conditional mapping we need a way to specify one of the following three requirements for a target path segment:

Always map is the default behavior so the syntax used above will all use the 'always map' behavior. i.e. The following would always map Name regardless of whether that property exists in the target JSON.

Applicants[$(applicantId)].Name = Applicants[$(applicantId)(:Id)].Name

The other two behaviors require some additional syntax to apply a modifier to a path segment specifying the behavior. For this the modifier will be applied at the end of the segment it applies to (if the segment is quoted it is applied outside the quotes). The modifiers are as follows:

In the following example the name will be mapped only if the applicant with the specific Id exists in the target JSON (but we don't care whether the Name itself exists on the applicant).

Applicants[$(applicantId)]?.Name = Applicants[$(applicantId)(:Id)].Name

We can also combine these operators in a single mapping if we want. In the following example the Name will only be mapped if the applicant exists in the target JSON and does not already have a Name set.

Applicants[$(applicantId)]?.Name! = Applicants[$(applicantId)(:Id)].Name

Whilst any combination of operators like this is valid the only useful combination is as shown above with a ? element and then further along an ! element. If we have !...? then no mapping will ever happen since the requirements represent an impossibility. If we have multiple of the same operator then we only actually need the last one.

Restricting by Schema

For all the above mapping we could define a schema for the source or target objects (out of scope of this document but we can think about TypeScript interface definitions for POJO objects). We could then use those schema(s) to restrict what gets copied during mapping by ignoring anything that does not match them.

Imagine we had the following target schema definition (using TypeScript syntax):

interface ITarget {
    Applicants: {
        [id: string]: {
            Age: number
        }
    }
}

If we were to use that when applying the following mapping:

Applicants[$(applicantId)] = Applicants[$(applicantId)(:Id)]

With the following source JSON:

{
    Applicants: {
        A: {
            Id: 'App1',
            Age: 23
        },
        B: {
            Id: 'App2',
            Age: 27
        }
    }
}

It would produce the following target JSON (note that the Id properties have been stripped out leaving only the Age properties in line with the target definition):

{
    Applicants: {
        App1: {
            Age: 23
        },
        App2: {
            Age: 27
        }
    }
}

Syntax Notes

Comments

Comments can be specified in the C single line style, prefixed with //. A comment runs from the starting // to the end of the line.

Blank Lines

Blank lines are allowed and simply ignored.

Splitting Lines

In all the examples we show each mapping definition on a single line. They can be split onto multiple lines by indenting. Any line that starts with some whitespace (and is not a comment) is considered a continuation of the previous line.

So the following:

Applicant.Name = Applicant.Name
Applicant.PostCode = Applicant.Address.PostCode

Could be written as follows and mean the same thing:

// Map the applicants name
Applicant.Name = Applicant.Name

// Map the postcode directly onto the applicant
Applicant.PostCode
    = Applicant
        .Address
        // This is a comment about the postcode field
        .PostCode