How to selectively copy properties from a deep nested data structure?

205 views Asked by At

I would like to copy a object from an existing object.

But I only need the field I want below

Since whiteList approach is required, I couldn't simply copy the whole object and then use a delete approach to remove unwanted fields

So, my approach right now is like below

const copiedData = {
    productId: data.productId,
    time: data.time,
    lastModifiedDate: data.lastModifiedDate
    plan: {
        planDetails: optionallySelProperties({
            data: data.plan.planDetails,
            dependencies: planDetailsDependencies
        }), //--XXXXXXXXXXXXXXXX problem comes in
         
        analysis: optionallySelProperties({
            data: data.analysis,
            dependencies: ......})
        products: .......,
        }
}

if they properties I need is a object. I would wrap it by a function that support optionally to select properties.

const planDetailsDependencies = [
  'planName',
  'status',
  'customproductAllocations',
]


const optionallySelProperties = ({ data, dependencies }: IOptionallySelProperties) => {
  return Object.entries(pick(data, dependencies) || {}).reduce((ret, cur) => {
    return {
      ...ret,
      [cur[0]]: cur[1],
    }
  }, {})
}

PS: pick is lodash function

rn, if the data that is passed into optionallySelProperties contains nested objects and I also need to optionally select properties. I couldnt achieve this in my function.

Is there a way to achieve this?

Here is the data I wanted to copy

const data = {
  "abcId": "b27e21f7",
  "productId": "G0221837387", //----- field I want to take 
  "time": 1698303900879, //----- field I want to take
  "member": { //----- field I want to take
    "teamMembers": [{
      "roles": [],
      "type": "Team Member",
      "name": "Me",
    }],
  },
  "plan": { //----- field I want to take
    "id": 86, //----- field I want to take
    "lastModifiedDate": "2023-10-25T01:37:58.648146", //----- field I want to take
    "planDetails": { //----- field I want to take
      "planName": "20230202",
      "options": [{
        "value": 1,
        "text": "Pineapple",
      }],
      "status": "DRAFT", //----- field I want to take
      "customproductAllocations": [{ //----- field I want to take
        "id": 24744,
        "allocationDetail": { //----- field I want to take 
          "name": "Created on 17 August 2023",
          "dollar": "USD", //----- field I want to take
          "allocations": [{
            "id": "1005",
            "name": "chocolatePreferred", //----- field I want to take
          }, {
            "id": "1007",
            "name": "chocolate Large Cap", //----- field I want to take
          }],
        }],
      },
      "products": { //----- field I want to take
        "inital": 169000000, //----- field I want to take
        "externalproducts": [{ //----- field I want to take
          "id": 659,
          "name": "Additional", //----- field I want to take
        }],
        "productAllocation": { //----- field I want to take
          "productAllocation": [{
            "id": "1005",
            "category": "Card", //----- field I want to take     
          }, {
            "id": "1007",
            "category": "Fruit", //----- field I want to take
          }],
        },
      },
      "analysis": { //----- field I want to take
        "analysisA": { //----- field I want to take
          "id": 50443,
          "key": "Liq", //----- field I want to take
        },
        "analysisB": { //----- field I want to take
          "id": 50443,
          "key": "dity", //----- field I want to take
        },
      },
    },
  },
};
3

There are 3 answers

0
Danny On

You could define the shape of the desired result with another object.
And use that object to build the new one. For example

const data = {
  required1: 'data',
  required2: true,
  notRequired1: 'value',
  required3: 9,
  required4: {
    notRequiredNested1: 3,
    requiredNested1: [
      {
        required1: '___',
        notRequired1: {}
      },
      {
        required1: 'string',
        notRequired1: {}
      }
    ]
  }
}

const requiredKeys = {
  required1: undefined,
  required2: undefined,
  required3: undefined,
  required4: {
    requiredNested1: [
      {
        required1: undefined
      }
    ]
  },
}

const clonedData = copyRequiredProperties(data, requiredKeys)

console.log('Data', data)
console.log('Cloned Data', clonedData)

function copyRequiredProperties(obj, requiredKeys) {
  const clonedObj = {}

  for (const [key, value] of Object.entries(requiredKeys)) {
    if (value === undefined) {
      clonedObj[key] = obj[key]

      continue
    }

    if (Array.isArray(value)) {
      clonedObj[key] = []

      if (typeof value[0] === 'object') {
        for (const item of obj[key]) {
          const requiredKeysOfArrayItems = value[0]
          const clonedItem = copyRequiredProperties(item, requiredKeysOfArrayItems)
          clonedObj[key].push(clonedItem)
        }
      }
      else {
        for (const item of obj[key]) {
          clonedObj[key].push(item)
        }
      }

      continue
    }

    if (typeof value === 'object') {
      const requiredKeysOfNestedObject = value
      clonedObj[key] = copyRequiredProperties(obj[key], requiredKeysOfNestedObject)

      continue
    }
  }

  return clonedObj
}

1
Peter Seliger On

The obvious solution to the OP's problem is to implement a single function which recursively clones any provided data-structure, but not entirely since there are exceptions.

Note ... The OP's request is better described by a ignore-list than a required-list.

Besides its first parameter, which is the to be cloned value/data, this function also accepts a Set instance which features the key-names of properties which are supposed to not to be cloned (hence a ignore list).

A first rough solution, though it does not fully cover the OP's use case of precisely targeting specific key-paths, would work with such a key-ignore list and does look like follows ...

function cloneDataAndIgnoreKeys(
  dataSource, ignoredKeys = new Set, dataTarget = {}
) {
  if (Array.isArray(dataSource)) {

    dataTarget = dataSource
      .map(item =>
        cloneDataAndIgnoreKeys(item, ignoredKeys)
      );
  } else if (!!dataSource && (typeof dataSource === 'object')) {

    dataTarget = Object
      .entries(dataSource)
      .reduce((target, [key, value]) => {

        if (!ignoredKeys.has(key)) {
          target[key] =
            cloneDataAndIgnoreKeys(value, ignoredKeys);
        }
        return target;

      }, dataTarget);

  } else {
    dataTarget = dataSource;
  }
  return dataTarget;
}

const sampleData = {
  "abcId": "b27e21f7",
  "productId": "G0221837387", //----- field I want to take 
  "time": 1698303900879, //----- field I want to take
  "member": { //----- field I want to take
    "teamMembers": [{
      "roles": [],
      "type": "Team Member",
      "name": "Me",
    }],
  },
  "plan": { //----- field I want to take
    "id": 86, //----- field I want to take
    "lastModifiedDate": "2023-10-25T01:37:58.648146", //----- field I want to take
    "planDetails": { //----- field I want to take
      "planName": "20230202",
      "options": [{
        "value": 1,
        "text": "Pineapple",
      }],
      "status": "DRAFT", //----- field I want to take
      "customproductAllocations": [{ //----- field I want to take
        "id": 24744,
        "allocationDetail": { //----- field I want to take 
          "name": "Created on 17 August 2023",
          "dollar": "USD", //----- field I want to take
          "allocations": [{
            "id": "1005",
            "name": "chocolatePreferred", //----- field I want to take
          }, {
            "id": "1007",
            "name": "chocolate Large Cap", //----- field I want to take
          }],
        }
      }],
      "products": { //----- field I want to take
        "inital": 169000000, //----- field I want to take
        "externalproducts": [{ //----- field I want to take
          "id": 659,
          "name": "Additional", //----- field I want to take
        }],
        "productAllocation": { //----- field I want to take
          "productAllocation": [{
            "id": "1005",
            "category": "Card", //----- field I want to take     
          }, {
            "id": "1007",
            "category": "Fruit", //----- field I want to take
          }],
        },
      },
      "analysis": { //----- field I want to take
        "analysisA": { //----- field I want to take
          "id": 50443,
          "key": "Liq", //----- field I want to take
        },
        "analysisB": { //----- field I want to take
          "id": 50443,
          "key": "dity", //----- field I want to take
        },
      },
    },
  },
};

console.log(
  cloneDataAndIgnoreKeys(
    sampleData,
    new Set(['abcId', 'id']),
  )
);
.as-console-wrapper { min-height: 100%!important; top: 0; }

The above first approach can be refined, in order to fully cover the OP's specific use case.

The Set instance now would feature keypath values instead of the not precise enough property names, whereas the recursive function would aggregate and pass to itself the current scope's keypath upon which the ignoring decision is going to be made.

The keypath based approach also allows notations which ...

  • either can target any array-item within the path regardless of its exact array index

    'planDetails.customproductAllocations[n].allocationDetail.allocations[n].id'
    
  • or do precisely target a specific member of the data-structure.

    'planDetails.customproductAllocations[0].allocationDetail.allocations[3].id'
    

function cloneDataAndIgnoreKeypaths(
  dataSource, ignoredKeypaths = new Set, keypath = '', dataTarget = {},
) {
  if (Array.isArray(dataSource)) {

    dataTarget = dataSource
      .map((item, idx) =>

        cloneDataAndIgnoreKeypaths(
          item, ignoredKeypaths, `${ keypath }[${ idx }]`,
        )
      );
  } else if (!!dataSource && (typeof dataSource === 'object')) {

    dataTarget = Object
      .entries(dataSource)
      .reduce((target, [key, value]) => {

        const currentKeypath = (

          // - handling the root-path case.
          (!keypath && key) ||

          // - handling concatenation to an object member.
          `${ keypath }.${ key }`
        );
        const generalizedArrayItemPath = currentKeypath
          .replace((/\[\d+\]/g), '[n]')

        // look for both matches ...
        if (
          // - the exact match of a keypath,
          !ignoredKeypaths.has(currentKeypath) &&
          // - the match of any array-item within the
          //   path regardless of its exact array index.
          !ignoredKeypaths.has(generalizedArrayItemPath)
        ) {
          target[key] =

            cloneDataAndIgnoreKeypaths(
              value, ignoredKeypaths, currentKeypath,
            )
        }
        return target;

      }, dataTarget);

  } else {
    dataTarget = dataSource;
  }
  return dataTarget;
}

const sampleData = {
  "abcId": "b27e21f7",
  "productId": "G0221837387", //----- field I want to take 
  "time": 1698303900879, //----- field I want to take
  "member": { //----- field I want to take
    "teamMembers": [{
      "roles": [],
      "type": "Team Member",
      "name": "Me",
    }],
  },
  "plan": { //----- field I want to take
    "id": 86, //----- field I want to take
    "lastModifiedDate": "2023-10-25T01:37:58.648146", //----- field I want to take
    "planDetails": { //----- field I want to take
      "planName": "20230202",
      "options": [{
        "value": 1,
        "text": "Pineapple",
      }],
      "status": "DRAFT", //----- field I want to take
      "customproductAllocations": [{ //----- field I want to take
        "id": 24744,
        "allocationDetail": { //----- field I want to take 
          "name": "Created on 17 August 2023",
          "dollar": "USD", //----- field I want to take
          "allocations": [{
            "id": "1005",
            "name": "chocolatePreferred", //----- field I want to take
          }, {
            "id": "1007",
            "name": "chocolate Large Cap", //----- field I want to take
          }],
        }
      }],
      "products": { //----- field I want to take
        "inital": 169000000, //----- field I want to take
        "externalproducts": [{ //----- field I want to take
          "id": 659,
          "name": "Additional", //----- field I want to take
        }],
        "productAllocation": { //----- field I want to take
          "productAllocation": [{
            "id": "1005",
            "category": "Card", //----- field I want to take     
          }, {
            "id": "1007",
            "category": "Fruit", //----- field I want to take
          }],
        },
      },
      "analysis": { //----- field I want to take
        "analysisA": { //----- field I want to take
          "id": 50443,
          "key": "Liq", //----- field I want to take
        },
        "analysisB": { //----- field I want to take
          "id": 50443,
          "key": "dity", //----- field I want to take
        },
      },
    },
  },
};

console.log(
  cloneDataAndIgnoreKeypaths(
    sampleData,
    new Set([
      'abcId',
      'plan.planDetails.customproductAllocations[n].id',
      'plan.planDetails.customproductAllocations[n].allocationDetail.name',
      'plan.planDetails.customproductAllocations[n].allocationDetail.allocations[n].id',
      'plan.planDetails.products.externalproducts[n].id',
      'plan.planDetails.products.productAllocation.productAllocation[n].id',
      'plan.planDetails.analysis.analysisA.id',
      'plan.planDetails.analysis.analysisB.id',
    ]),
  )
);
.as-console-wrapper { min-height: 100%!important; top: 0; }

0
Dimava On

Here's a simple picker inspired by ArkType syntax
It's made to work with arrays, records and optional keys

https://tsplay.dev/wjpebm has typedefs for autocompletion

function mapObject(obj, mapper) {
    return Object.fromEntries(Object.entries(obj)
        .map(([k, v]) => mapper(k, v))
        .filter((e) => e?.length === 2));
}
function arkPick(obj, schema) {
    if (Array.isArray(obj))
        return obj.map(e => arkPick(e, schema));
    if (Object.keys(schema)[0] === '__record')
        return mapObject(obj, (k, v) => [k, arkPick(v, schema.__record)]);
    return mapObject(schema, (k, v) => {
        let opt = k.endsWith('?');
        if (opt)
            k = k.slice(0, -1);
        if (!(k in obj)) {
            if (opt)
                return [];
            else
                throw new Error(`missign property ${k}`);
        }
        if (v === 'any' || v === true)
            return [k, obj[k]];
        if (typeof v === 'string') {
            if (typeof obj[k] === v)
                return [k, obj[k]];
            else
                throw new Error(`incorrect type of property ${k}`);
        }
        return [k, arkPick(obj[k], v)];
    });
}
console.log(arkPick(data, {
  productId: 'string',
  time: 'number',
  'member?': 'object', // optional
  'ZZZmissingZZZ?': 'any', // ignored
  plan: {
    id: 'number',
    lastModifiedDate: 'string',
    planDetails: {
      customproductAllocations: {
        allocationDetail: { // array
          name: 'string'
        }
      }
    },
    analysis: {
      __record: { // record
        key: 'string'
      }
    }
  }
}))