sdmpr
Version:
Simple Data Mapper
362 lines (306 loc) • 9.66 kB
text/typescript
import { CaseStyle, ICaseStyleOptions } from "./CaseStyle";
/**
* Simple Data Mapper
*
* Transform any data easily from one shape to another.
*
*/
export class SimpleDataMapper {
private reportEnabled = false
private maps: { from: string, to: string, cb?: Function }[] = []
private collects: { fields: string[], to?: string | Function, cb?: Function }[] = []
private extras: { fieldName: string, data: any }[] = []
private caseStyle = CaseStyle.ASIS
private caseStyleOptions: ICaseStyleOptions | undefined
constructor(reportEnabled = false) {
this.init(reportEnabled)
}
map(from: string, to?: string, cb?: Function) {
to = to ? to : from
this.maps.push({ from, to, cb })
return this
}
collect(fields: string[], to?: string | Function, cb?: Function) {
if (typeof to === "function") {
cb = to
to = undefined
}
else {
to = to ? to : fields.join("_")
}
this.collects.push({ fields, to, cb })
return this
}
add(fieldName: string, data: any) {
this.extras.push({ fieldName, data })
return this
}
/**
* This will transform source data to the destination.
*
* @param srcData
*/
transform(srcData: any) {
const maps = this.maps
let transformedData: any = {}
const transformed: string[] = []
const skipped: string[] = []
const collected: string[] = []
const uncollected: string[] = []
const getNestedData = (nestedData: string) => {
const paths = nestedData.split(".")
let currentData = { ...srcData }
paths.forEach((p: string) => {
if (currentData) {
// If we have array notation (somearray[n]) then capture groups
const matches = p.match(/(.*)\[(.*)\]/)
if (matches) {
const g1 = matches[1]
const g2 = matches[2]
currentData = currentData[g1] ? currentData[g1][g2] : undefined
}
else {
currentData = currentData[p]
}
}
})
return currentData
}
const getTargetData = (transformedData: any, to: string) => {
let target = transformedData
const fieldNames = to.split(".")
const lastFieldName = fieldNames[fieldNames.length - 1]
for (let i = 0; i < fieldNames.length - 1; i++) {
const fieldName = fieldNames[i]
target[fieldName] = target[fieldName] ? target[fieldName] : {}
target = target[fieldName]
}
return {
target,
lastFieldName
}
}
maps.forEach(({ from, to, cb }) => {
const nestedData = getNestedData(from)
if (nestedData) {
const targetData = getTargetData(transformedData, to)
targetData.target[targetData.lastFieldName] = cb ? cb(nestedData) : nestedData
transformed.push(`${from} -> ${to}`)
}
else {
skipped.push(from)
}
})
this.collects.forEach(({ fields, to, cb }) => {
const vals = fields.reduce((acc: any[], cur: string) => {
const nestedData = getNestedData(cur)
if (nestedData) {
acc.push(nestedData)
collected.push(cur)
}
else {
uncollected.push(cur)
}
return acc
}, [])
const info = cb ? cb(vals) : vals.join(" ")
if (info) {
if (to && typeof to === "string") {
const targetData = getTargetData(transformedData, to)
targetData.target[targetData.lastFieldName] = info
}
else {
transformedData = { ...transformedData, ...info }
}
}
})
// Add extra data
this.extras.forEach(extra => {
let extraData: any = {}
extraData[extra.fieldName] = extra.data
transformedData = { ...transformedData, ...extraData }
})
if (this.caseStyle != CaseStyle.ASIS) {
// If transformedData is empty (no mapping applied) then use the srcData here directly!
if (Object.keys(transformedData).length === 0) {
transformedData = { ...srcData }
}
transformedData = this.doChangeCase(transformedData, this.caseStyle)
}
// Show report if enabled
if (this.reportEnabled) {
const dataKeys = Object.keys(srcData)
const untransformed: any[] = []
dataKeys.forEach(key => {
const found = this.maps.findIndex(m => m.from === key)
if (found === -1) {
if (key !== "__reports__") {
untransformed.push(key)
}
}
})
transformed.sort()
untransformed.sort()
skipped.sort()
collected.sort()
uncollected.sort()
transformedData.__reports__ = {
transformation: {
transformed,
untransformed,
skipped,
},
collection: {
collected,
uncollected
}
}
}
return transformedData
}
reset() {
this.init()
return this
}
report(enabled: boolean) {
this.reportEnabled = enabled
return this
}
mapToCamelCase(options?: ICaseStyleOptions) {
this.caseStyle = CaseStyle.CAMEL
this.caseStyleOptions = options
return this
}
mapToSnakeCase(options?: ICaseStyleOptions) {
this.caseStyle = CaseStyle.SNAKE
this.caseStyleOptions = options
return this
}
mapToLowerCase(options?: ICaseStyleOptions) {
this.caseStyle = CaseStyle.LOWER
this.caseStyleOptions = options
return this
}
mapToUpperCase(options?: ICaseStyleOptions) {
this.caseStyle = CaseStyle.UPPER
this.caseStyleOptions = options
return this
}
// --- Static Methods ---
static create(reportEnabled = false) {
return new SimpleDataMapper(reportEnabled)
}
static changeCase(obj: any, caseStyle: CaseStyle, options?: ICaseStyleOptions) {
const mapper = SimpleDataMapper.create()
mapper.caseStyle = caseStyle
mapper.caseStyleOptions = options
return mapper.transform(obj)
}
static toCamelCase(obj: any, options?: ICaseStyleOptions) {
return SimpleDataMapper.create().mapToCamelCase(options).transform(obj)
}
static toSnakeCase(obj: any, options?: ICaseStyleOptions) {
return SimpleDataMapper.create().mapToSnakeCase(options).transform(obj)
}
static toLowerCase(obj: any, options?: ICaseStyleOptions) {
return SimpleDataMapper.create().mapToLowerCase(options).transform(obj)
}
static toUpperCase(obj: any, options?: ICaseStyleOptions) {
return SimpleDataMapper.create().mapToUpperCase(options).transform(obj)
}
// --- Private Methods ---
private init(reportEnabled = false) {
this.reportEnabled = reportEnabled
this.maps = []
this.collects = []
this.extras = []
this.caseStyle = CaseStyle.ASIS
return this
}
private doChangeCase(obj: any, caseStyle: CaseStyle) {
const keep = (str: string) => {
if (this.caseStyleOptions) {
if (this.caseStyleOptions.keep && this.caseStyleOptions.keep.includes(str))
return true
}
return false
}
const keepChildNodes = (str: string) => {
if (keep(str)) {
if (this.caseStyleOptions && this.caseStyleOptions.keepChildNodes) {
return true
}
}
return false
}
const toCamelCase = (str: string) => {
if (keep(str)) return str
return str.replace(/([-_][a-z])/ig, ($1) => {
return $1.toUpperCase()
.replace("-", "")
.replace("_", "")
})
}
const toSnakeCase = (str: string) => {
if (keep(str)) return str
return str.split(/(?=[A-Z])/).join("_").toLowerCase()
}
const toLowerCase = (str: string) => {
if (keep(str)) return str
return str.toLowerCase()
}
const toUpperCase = (str: string) => {
if (keep(str)) return str
return str.toUpperCase()
}
const getBaseFunction = (caseStyle: CaseStyle) => {
let fn
switch (caseStyle) {
case CaseStyle.CAMEL:
fn = toCamelCase
break;
case CaseStyle.SNAKE:
fn = toSnakeCase
break;
case CaseStyle.LOWER:
fn = toLowerCase
break;
case CaseStyle.UPPER:
fn = toUpperCase
break;
default:
break;
}
return fn
}
const startProcess = (obj: any, caseStyle: CaseStyle): any => {
if (typeof obj !== "object") return obj
const baseFunction = getBaseFunction(caseStyle)
if (!baseFunction) return
const fieldNames = Object.keys(obj)
return fieldNames
.map(fieldName => {
const val = obj[fieldName]
if (!keepChildNodes(fieldName)) {
if (Array.isArray(val)) {
return {
[baseFunction(fieldName)]: val.map(item => {
return startProcess(item, caseStyle)
})
}
}
else if (typeof val === "object") {
return { [baseFunction(fieldName)]: startProcess(val, caseStyle) }
}
}
return { [baseFunction(fieldName)]: val }
})
.reduce((acc, cur) => {
const key = Object.keys(cur)[0]
acc[key] = cur[key]
return acc
}, {})
}
return startProcess(obj, caseStyle)
}
}