Skip to content

Commit

Permalink
Support mapping as nested objects
Browse files Browse the repository at this point in the history
  • Loading branch information
kjellmorten committed Jul 16, 2018
1 parent adb5d03 commit be2d3df
Show file tree
Hide file tree
Showing 7 changed files with 263 additions and 38 deletions.
56 changes: 50 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,15 @@ Let's look at a simple example:
```javascript
import mapTransform from 'map-transform'

const mapping = {
const def = {
mapping: {
'title': 'content.headline',
'author': 'meta.writer.username'
},
path: 'sections[0].articles'
}

const mapper = mapTransform(mapping)
const mapper = mapTransform(def)

// `mapper` is now a function that will always map as defined by `mapping`

Expand Down Expand Up @@ -169,12 +169,11 @@ First of all, if there's the `path` property on the root object is set, it is
used to retrieve an object or an array of objects from the source data. When
this `path` is not set, the source data is just used as is.

Then the `path` property of each field is used to retrieve field values from
the object(s) returned from the root `path`, using the value of `default` when
the path does not match a value in the source data.
Then the `path` property of each object on `mapping`, is used to retrieve field values from the object(s) returned from the root `path`, using the value of
`default` when the path does not match a value in the source data.

Next, the path string used as keys for the object in `mapping`, is used to set
each field value on the target object(s).
each value on the target object(s).

Finally, if a `pathTo` is set on the root object, the object or array of objects
we have at this point is set at this path on an empty object and returned.
Expand Down Expand Up @@ -219,6 +218,51 @@ result. When several filter functions are set, all of them must return true for
the item to be included. The `filter` is used on reverse mapping as well,
directly after the items are extracted from any `pathTo`.

The `mapping` object defines the shape of the target item(s) to map _to_, with
options for how values _from_ the source object should be mapped to it. Another
way of specifying this shape, is simply supplying it as nested objects. In the following example, `def1` and `def2` are two ways of defining the exact same
mapping, it's simply a matter of taste:

```
const def1 = {
mapping: {
'attributes.title': {path: 'headline', default: 'Untitled'},
'attributes.text': 'content.text'
}
}
const def2 = {
mapping: {
attributes: {
title: {path: 'headline', default: 'Untitled'},
text: 'content.text'
}
}
}
```

The only difference is that `path` is a reserved property name in mapping
definitions unless it is set directly on the `mapping` object, so to map to an
object with a `path` property, you will have to use the dot notation.

```
// NOT okay:
const def3 = {
mapping: {
attributes: {
path: { path: 'meta.path'}
}
}
}
// Okay:
const def4 = {
mapping: {
'attributes.path': { path: 'meta.path' }
}
}
```

### Running the tests

The tests can be run with `npm test`.
Expand Down
10 changes: 6 additions & 4 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,17 @@ import {
MapperFunctionWithRev
} from './utils/createMapper'
import { PathString } from './utils/lensPath'
import { MappingDefinition } from './utils/createFieldMapper'
import { MappingDef } from './utils/normalizeMapping'
import { TransformPipeline } from './utils/transformPipeline'
import { FilterPipeline } from './utils/filterPipeline'

namespace mapTransform {
export interface Shape {
[key: string]: PathString | MappingDef | Shape | null
}

export interface Definition {
mapping?: {
[key: string]: PathString | MappingDefinition | null
},
mapping?: Shape,
path?: PathString | null,
pathRev?: PathString | null,
pathTo?: PathString | null,
Expand Down
34 changes: 34 additions & 0 deletions src/tests/shape-test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import test from 'ava'

import mapTransform = require('..')

test('should map with object shape', (t) => {
const def = {
mapping: {
attributes: {
title: 'content.heading',
text: 'content.copy'
},
relationships: {
author: 'meta.writer.username'
}
}
}
const data = {
content: { heading: 'The heading', copy: 'A long text' },
meta: { writer: { username: 'johnf' } }
}
const expected = {
attributes: {
title: 'The heading',
text: 'A long text'
},
relationships: {
author: 'johnf'
}
}

const ret = mapTransform(def)(data)

t.deepEqual(ret, expected)
})
37 changes: 10 additions & 27 deletions src/utils/createFieldMapper.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,14 @@
import * as R from 'ramda'
import { Data } from '..'
import { lensPath, PathString } from './lensPath'
import { pipeTransform, pipeTransformRev, TransformFunction, TransformPipeline } from './transformPipeline'

export interface MappingDefinition {
path: PathString | null,
transform?: TransformPipeline,
transformRev?: TransformPipeline,
default?: any,
defaultRev?: any
}
import { lensPath } from './lensPath'
import { pipeTransform, pipeTransformRev, TransformFunction } from './transformPipeline'
import { MappingDefNormalized } from './normalizeMapping'

export interface FieldMapperFunction {
(target: Data, data: Data): Data
}
type GetFieldMapperFunction = (isRev: boolean) => FieldMapperFunction

type CreateFieldArgTuple = [string, PathString | MappingDefinition | null]

// String | b -> b
const normalizeFieldMapping = (fieldMapping: PathString | MappingDefinition | null): MappingDefinition =>
(!fieldMapping || typeof fieldMapping === 'string')
? { path: fieldMapping }
: fieldMapping

// Lens -> Lens -> (a -> b) -> (c -> (d -> c | d)) => (e -> e -> e)
const setFieldValue = (
fromLens: R.Lens,
Expand All @@ -47,15 +32,13 @@ const setFieldValue = (
* @returns {function} A function that returns a default or reverse mapper when
* called with `false` or `true`
*/
export const createFieldMapper = ([fieldId, fieldMapping]: CreateFieldArgTuple): GetFieldMapperFunction => {
const { path, default: def, defaultRev, transform, transformRev }
= normalizeFieldMapping(fieldMapping)
const fromLens = lensPath(path)
const toLens = lensPath(fieldId)
const setDefault = R.defaultTo(def)
const setDefaultRev = R.defaultTo(defaultRev)
const transformFn = pipeTransform(transform)
const transformRevFn = pipeTransformRev(transformRev, transform)
export const createFieldMapper = (def: MappingDefNormalized): GetFieldMapperFunction => {
const fromLens = lensPath(def.path)
const toLens = lensPath(def.pathTo)
const setDefault = R.defaultTo(def.default)
const setDefaultRev = R.defaultTo(def.defaultRev)
const transformFn = pipeTransform(def.transform)
const transformRevFn = pipeTransformRev(def.transformRev, def.transform)

return (isRev) => (isRev)
? setFieldValue(toLens, fromLens, transformRevFn, setDefaultRev)
Expand Down
3 changes: 2 additions & 1 deletion src/utils/createMapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { lensPath } from './lensPath'
import { createFieldMapper, FieldMapperFunction } from './createFieldMapper'
import { pipeTransform, pipeTransformRev } from './transformPipeline'
import { pipeFilter, FilterFunction } from './filterPipeline'
import { normalizeMapping } from './normalizeMapping'

interface BaseMapperFunction {
(data: Data | null): Data | null
Expand Down Expand Up @@ -62,7 +63,7 @@ export const createMapper = (def: Definition): MapperFunctionWithRev => {
const pathRevLens = (typeof pathRev !== 'undefined') ? lensPath(pathRev) : pathLens
const pathToRevLens = (typeof pathToRev !== 'undefined') ? lensPath(pathToRev) : pathToLens

const fieldMappers = (mapping) ? R.toPairs(mapping).map(createFieldMapper) : []
const fieldMappers = (mapping) ? normalizeMapping(mapping).map(createFieldMapper) : []
const objectMapper = createObjectMapper(fieldMappers)
const revObjectMapper = createRevObjectMapper(fieldMappers)
const transformFn = pipeTransform(transform)
Expand Down
114 changes: 114 additions & 0 deletions src/utils/normalizeMapping-test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import test from 'ava'

import { normalizeMapping } from './normalizeMapping'

test('should normalize to mapping objects', (t) => {
const mapping = {
title: { path: 'content.heading' },
author: { path: 'meta.writer.username' }
}
const expected = [
{ pathTo: 'title', path: 'content.heading' },
{ pathTo: 'author', path: 'meta.writer.username' }
]

const ret = normalizeMapping(mapping)

t.deepEqual(ret, expected)
})

test('should normalize from path shortcut', (t) => {
const mapping = {
title: 'content.heading',
author: 'meta.writer.username'
}
const expected = [
{ pathTo: 'title', path: 'content.heading' },
{ pathTo: 'author', path: 'meta.writer.username' }
]

const ret = normalizeMapping(mapping)

t.deepEqual(ret, expected)
})

test('should normalize with null as shortcut', (t) => {
const mapping = {
title: null
}
const expected = [
{ pathTo: 'title', path: null }
]

const ret = normalizeMapping(mapping)

t.deepEqual(ret, expected)
})

test('should normalize from object shape', (t) => {
const mapping = {
attributes: {
title: 'content.heading',
text: 'content.copy',
deeper: {
'with.path': 'id'
}
},
relationships: {
author: 'meta.writer.username'
}
}
const expected = [
{ pathTo: 'attributes.title', path: 'content.heading' },
{ pathTo: 'attributes.text', path: 'content.copy' },
{ pathTo: 'attributes.deeper.with.path', path: 'id' },
{ pathTo: 'relationships.author', path: 'meta.writer.username' }
]

const ret = normalizeMapping(mapping)

t.deepEqual(ret, expected)
})

test('should normalize array of mapping objects', (t) => {
const mapping = [
{ pathTo: 'title', path: 'content.heading' },
{ pathTo: 'author', path: 'meta.writer.username' }
]
const expected = [
{ pathTo: 'title', path: 'content.heading' },
{ pathTo: 'author', path: 'meta.writer.username' }
]

const ret = normalizeMapping(mapping)

t.deepEqual(ret, expected)
})

test('should normalize mapping object', (t) => {
const transform = (value: string) => value + ' norm'
const transformRev = (value: string) => value + ' rev'
const mapping = [
{
path: '',
transform,
transformRev,
default: 'Untitled',
defaultRev: 'Titled after all'
}
]
const expected = [
{
pathTo: null,
path: null,
transform,
transformRev,
default: 'Untitled',
defaultRev: 'Titled after all'
}
]

const ret = normalizeMapping(mapping)

t.deepEqual(ret, expected)
})
47 changes: 47 additions & 0 deletions src/utils/normalizeMapping.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { Shape } from '..'
import { PathString } from './lensPath'
import { TransformPipeline } from './transformPipeline'

interface MappingDefBase {
path: PathString | null,
transform?: TransformPipeline,
transformRev?: TransformPipeline,
default?: any,
defaultRev?: any
}

export interface MappingDefNormalized extends MappingDefBase {
pathTo: PathString | null,
}

export interface MappingDef extends MappingDefBase {
pathTo?: PathString | null,
}

const normalize = (def: MappingDef): MappingDefNormalized =>
({ ...def, path: def.path || null, pathTo: def.pathTo || null })

const normalizeDef = (pathArr: PathString[], def: MappingDef | PathString):
MappingDefNormalized =>
(typeof def === 'string')
? normalize({ path: def, pathTo: pathArr.join('.') })
: normalize({ ...def, pathTo: pathArr.join('.') })

const isMappingDef = (obj: Shape | PathString | MappingDef | null) =>
obj && typeof obj === 'object' && typeof (obj as MappingDef).path !== 'string'

const normalizeShape = (mapping: Shape, pathTo: string[] = []):
MappingDefNormalized[] =>
Object.keys(mapping).reduce((arr: MappingDefNormalized[], key: PathString) =>
(isMappingDef(mapping[key]))
? [ ...arr, ...normalizeShape(mapping[key] as Shape, [...pathTo, key]) ]
: [ ...arr, normalizeDef([...pathTo, key], mapping[key] as MappingDef)]
, []
)

export function normalizeMapping (mapping: Shape | MappingDef[]):
MappingDefNormalized[] {
return (Array.isArray(mapping))
? mapping.map(normalize)
: normalizeShape(mapping)
}

0 comments on commit be2d3df

Please sign in to comment.