From 27306f7189aab62cb5c99fda2780cac67fed0ab3 Mon Sep 17 00:00:00 2001
From: DoneDeal0 <ap.lanoe@outlook.com>
Date: Wed, 23 Oct 2024 22:18:02 +0200
Subject: [PATCH] chore: improve getobjectdiff performance (#30)

---
 src/lib/object-diff/index.ts            | 246 +++++++-----------------
 src/lib/object-diff/object-diff.test.ts | 127 ++++++------
 2 files changed, 139 insertions(+), 234 deletions(-)

diff --git a/src/lib/object-diff/index.ts b/src/lib/object-diff/index.ts
index 524e9cd..af03b4b 100644
--- a/src/lib/object-diff/index.ts
+++ b/src/lib/object-diff/index.ts
@@ -14,21 +14,19 @@ function getLeanDiff(
   showOnly: ObjectDiffOptions["showOnly"] = DEFAULT_OBJECT_DIFF_OPTIONS.showOnly,
 ): ObjectDiff["diff"] {
   const { statuses, granularity } = showOnly;
-  return diff.reduce(
-    (acc, value) => {
-      if (granularity === GRANULARITY.DEEP && value.diff) {
-        const leanDiff = getLeanDiff(value.diff, showOnly);
-        if (leanDiff.length > 0) {
-          return [...acc, { ...value, diff: leanDiff }];
-        }
+  const res: ObjectDiff["diff"] = [];
+  for (let i = 0; i < diff.length; i++) {
+    const value = diff[i];
+    if (granularity === GRANULARITY.DEEP && value.diff) {
+      const leanDiff = getLeanDiff(value.diff, showOnly);
+      if (leanDiff.length > 0) {
+        res.push({ ...value, diff: leanDiff });
       }
-      if (statuses.includes(value.status)) {
-        return [...acc, value];
-      }
-      return acc;
-    },
-    [] as ObjectDiff["diff"],
-  );
+    } else if (statuses.includes(value.status)) {
+      res.push(value);
+    }
+  }
+  return res;
 }
 
 function getObjectStatus(diff: ObjectDiff["diff"]): OBJECT_STATUS {
@@ -50,18 +48,19 @@ function formatSingleObjectDiff(
     };
   }
   const diff: ObjectDiff["diff"] = [];
-  Object.entries(data).forEach(([property, value]) => {
+
+  for (const [property, value] of Object.entries(data)) {
     if (isObject(value)) {
       const subPropertiesDiff: Diff[] = [];
-      Object.entries(value).forEach(([subProperty, subValue]) => {
+      for (const [subProperty, subValue] of Object.entries(value)) {
         subPropertiesDiff.push({
           property: subProperty,
           previousValue: status === OBJECT_STATUS.ADDED ? undefined : subValue,
           currentValue: status === OBJECT_STATUS.ADDED ? subValue : undefined,
           status,
         });
-      });
-      return diff.push({
+      }
+      diff.push({
         property,
         previousValue:
           status === OBJECT_STATUS.ADDED ? undefined : data[property],
@@ -69,15 +68,17 @@ function formatSingleObjectDiff(
         status,
         diff: subPropertiesDiff,
       });
+    } else {
+      diff.push({
+        property,
+        previousValue:
+          status === OBJECT_STATUS.ADDED ? undefined : data[property],
+        currentValue: status === OBJECT_STATUS.ADDED ? value : undefined,
+        status,
+      });
     }
-    return diff.push({
-      property,
-      previousValue:
-        status === OBJECT_STATUS.ADDED ? undefined : data[property],
-      currentValue: status === OBJECT_STATUS.ADDED ? value : undefined,
-      status,
-    });
-  });
+  }
+
   if (options.showOnly && options.showOnly.statuses.length > 0) {
     return {
       type: "object",
@@ -92,20 +93,6 @@ function formatSingleObjectDiff(
   };
 }
 
-function getPreviousMatch(
-  previousValue: unknown | undefined,
-  nextSubProperty: unknown,
-  options?: ObjectDiffOptions,
-): unknown | undefined {
-  if (!previousValue) {
-    return undefined;
-  }
-  const previousMatch = Object.entries(previousValue).find(([subPreviousKey]) =>
-    isEqual(subPreviousKey, nextSubProperty, options),
-  );
-  return previousMatch ? previousMatch[1] : undefined;
-}
-
 function getValueStatus(
   previousValue: unknown,
   nextValue: unknown,
@@ -117,92 +104,61 @@ function getValueStatus(
   return OBJECT_STATUS.UPDATED;
 }
 
-function getPropertyStatus(subPropertiesDiff: Diff[]): OBJECT_STATUS {
-  return subPropertiesDiff.some(
-    (property) => property.status !== OBJECT_STATUS.EQUAL,
-  )
-    ? OBJECT_STATUS.UPDATED
-    : OBJECT_STATUS.EQUAL;
-}
-
-function getDeletedProperties(
-  previousValue: Record<string, unknown> | undefined,
-  nextValue: Record<string, unknown>,
-): { property: string; value: unknown }[] | undefined {
-  if (!previousValue) return undefined;
-  const prevKeys = Object.keys(previousValue);
-  const nextKeys = Object.keys(nextValue);
-  const deletedKeys = prevKeys.filter((prevKey) => !nextKeys.includes(prevKey));
-  if (deletedKeys.length > 0) {
-    return deletedKeys.map((deletedKey) => ({
-      property: deletedKey,
-      value: previousValue[deletedKey],
-    }));
-  }
-  return undefined;
-}
-
-function getSubPropertiesDiff(
-  previousValue: Record<string, unknown> | undefined,
+function getDiff(
+  previousValue: Record<string, unknown> | undefined = {},
   nextValue: Record<string, unknown>,
   options?: ObjectDiffOptions,
 ): Diff[] {
-  const subPropertiesDiff: Diff[] = [];
-  let subDiff: Diff[];
-  const deletedMainSubProperties = getDeletedProperties(
-    previousValue,
-    nextValue,
-  );
-  if (deletedMainSubProperties) {
-    deletedMainSubProperties.forEach((deletedProperty) => {
-      subPropertiesDiff.push({
-        property: deletedProperty.property,
-        previousValue: deletedProperty.value,
+  const diff: Diff[] = [];
+  const allKeys = new Set([
+    ...Object.keys(previousValue),
+    ...Object.keys(nextValue),
+  ]);
+
+  for (const property of allKeys) {
+    const prevSubValue = previousValue[property];
+    const nextSubValue = nextValue[property];
+    if (!(property in nextValue)) {
+      diff.push({
+        property,
+        previousValue: prevSubValue,
         currentValue: undefined,
         status: OBJECT_STATUS.DELETED,
       });
-    });
-  }
-  Object.entries(nextValue).forEach(([nextSubProperty, nextSubValue]) => {
-    const previousMatch = getPreviousMatch(
-      previousValue,
-      nextSubProperty,
-      options,
-    );
-    if (!previousMatch) {
-      return subPropertiesDiff.push({
-        property: nextSubProperty,
-        previousValue: previousMatch,
+      continue;
+    }
+    if (!(property in previousValue)) {
+      diff.push({
+        property,
+        previousValue: undefined,
         currentValue: nextSubValue,
-        status:
-          !previousValue || !(nextSubProperty in previousValue)
-            ? OBJECT_STATUS.ADDED
-            : previousMatch === nextSubValue
-              ? OBJECT_STATUS.EQUAL
-              : OBJECT_STATUS.UPDATED,
+        status: OBJECT_STATUS.ADDED,
       });
+      continue;
     }
-    if (isObject(nextSubValue)) {
-      const data: Diff[] = getSubPropertiesDiff(
-        previousMatch as Record<string, unknown>,
-        nextSubValue,
-        options,
+    if (isObject(nextSubValue) && isObject(prevSubValue)) {
+      const subDiff = getDiff(prevSubValue, nextSubValue, options);
+      const isUpdated = subDiff.some(
+        (entry) => entry.status !== OBJECT_STATUS.EQUAL,
       );
-      if (data && data.length > 0) {
-        subDiff = data;
-      }
-    }
-    if (previousMatch) {
-      subPropertiesDiff.push({
-        property: nextSubProperty,
-        previousValue: previousMatch,
+      diff.push({
+        property,
+        previousValue: prevSubValue,
+        currentValue: nextSubValue,
+        status: isUpdated ? OBJECT_STATUS.UPDATED : OBJECT_STATUS.EQUAL,
+        ...(isUpdated && { diff: subDiff }),
+      });
+    } else {
+      const status = getValueStatus(prevSubValue, nextSubValue, options);
+      diff.push({
+        property,
+        previousValue: prevSubValue,
         currentValue: nextSubValue,
-        status: getValueStatus(previousMatch, nextSubValue, options),
-        ...(!!subDiff && { diff: subDiff }),
+        status,
       });
     }
-  });
-  return subPropertiesDiff;
+  }
+  return diff;
 }
 
 /**
@@ -234,66 +190,12 @@ export function getObjectDiff(
   if (!nextData) {
     return formatSingleObjectDiff(prevData, OBJECT_STATUS.DELETED, options);
   }
-  const diff: ObjectDiff["diff"] = [];
-  Object.entries(nextData).forEach(([nextProperty, nextValue]) => {
-    const previousValue = prevData[nextProperty];
-    if (!previousValue) {
-      return diff.push({
-        property: nextProperty,
-        previousValue,
-        currentValue: nextValue,
-        status: !(nextProperty in prevData)
-          ? OBJECT_STATUS.ADDED
-          : previousValue === nextValue
-            ? OBJECT_STATUS.EQUAL
-            : OBJECT_STATUS.UPDATED,
-      });
-    }
-    if (isObject(nextValue)) {
-      const subPropertiesDiff: Diff[] = getSubPropertiesDiff(
-        previousValue as Record<string, unknown>,
-        nextValue,
-        options,
-      );
-      const subPropertyStatus = getPropertyStatus(subPropertiesDiff);
-      return diff.push({
-        property: nextProperty,
-        previousValue,
-        currentValue: nextValue,
-        status: subPropertyStatus,
-        ...(subPropertyStatus !== OBJECT_STATUS.EQUAL && {
-          diff: subPropertiesDiff,
-        }),
-      });
-    }
-    return diff.push({
-      property: nextProperty,
-      previousValue,
-      currentValue: nextValue,
-      status: getValueStatus(previousValue, nextValue, options),
-    });
-  });
-  const deletedProperties = getDeletedProperties(prevData, nextData);
-  if (deletedProperties) {
-    deletedProperties.forEach((deletedProperty) => {
-      diff.push({
-        property: deletedProperty.property,
-        previousValue: deletedProperty.value,
-        currentValue: undefined,
-        status: OBJECT_STATUS.DELETED,
-      });
-    });
-  }
-  if (options.showOnly && options.showOnly.statuses.length > 0) {
-    return {
-      type: "object",
-      status: getObjectStatus(diff),
-      diff: getLeanDiff(diff, options.showOnly),
-    };
-  }
+  const diff: ObjectDiff["diff"] = getDiff(prevData, nextData, options);
+  const status = getObjectStatus(diff);
+  const showLeanDiff = (options?.showOnly?.statuses?.length || 0) > 0;
   return {
     type: "object",
-    status: getObjectStatus(diff),
-    diff,
+    status,
+    diff: showLeanDiff ? getLeanDiff(diff, options.showOnly) : diff,
   };
 }
diff --git a/src/lib/object-diff/object-diff.test.ts b/src/lib/object-diff/object-diff.test.ts
index 62295c3..78fea53 100644
--- a/src/lib/object-diff/object-diff.test.ts
+++ b/src/lib/object-diff/object-diff.test.ts
@@ -40,6 +40,28 @@ describe("getObjectDiff", () => {
       ],
     });
   });
+  it("consider previous object as completely deleted if no next object is provided, and return an empty diff if showOnly doesn't require deleted values", () => {
+    expect(
+      getObjectDiff(
+        {
+          name: "joe",
+          age: 54,
+          hobbies: ["golf", "football"],
+        },
+        null,
+        {
+          showOnly: {
+            statuses: [OBJECT_STATUS.ADDED],
+            granularity: GRANULARITY.DEEP,
+          },
+        },
+      ),
+    ).toStrictEqual({
+      type: "object",
+      status: "deleted",
+      diff: [],
+    });
+  });
   it("consider next object as completely added if no previous object is provided", () => {
     expect(
       getObjectDiff(null, {
@@ -170,10 +192,10 @@ describe("getObjectDiff", () => {
           status: "equal",
         },
         {
-          property: "country",
-          previousValue: undefined,
-          currentValue: "us",
-          status: "added",
+          property: "type",
+          previousValue: "sport",
+          currentValue: undefined,
+          status: "deleted",
         },
         {
           property: "user",
@@ -191,12 +213,6 @@ describe("getObjectDiff", () => {
           },
           status: "updated",
           diff: [
-            {
-              property: "age",
-              previousValue: 66,
-              currentValue: undefined,
-              status: "deleted",
-            },
             {
               property: "name",
               previousValue: "joe",
@@ -215,6 +231,12 @@ describe("getObjectDiff", () => {
               currentValue: ["golf", "chess"],
               status: "updated",
             },
+            {
+              property: "age",
+              previousValue: 66,
+              currentValue: undefined,
+              status: "deleted",
+            },
             {
               property: "nickname",
               previousValue: undefined,
@@ -224,10 +246,10 @@ describe("getObjectDiff", () => {
           ],
         },
         {
-          property: "type",
-          previousValue: "sport",
-          currentValue: undefined,
-          status: "deleted",
+          property: "country",
+          previousValue: undefined,
+          currentValue: "us",
+          status: "added",
         },
       ],
     });
@@ -338,18 +360,18 @@ describe("getObjectDiff", () => {
                   },
                   status: "updated",
                   diff: [
-                    {
-                      property: "rugby",
-                      previousValue: ["france"],
-                      currentValue: undefined,
-                      status: "deleted",
-                    },
                     {
                       property: "football",
                       previousValue: ["psg"],
                       currentValue: ["psg", "nantes"],
                       status: "updated",
                     },
+                    {
+                      property: "rugby",
+                      previousValue: ["france"],
+                      currentValue: undefined,
+                      status: "deleted",
+                    },
                     {
                       property: "golf",
                       previousValue: undefined,
@@ -401,11 +423,12 @@ describe("getObjectDiff", () => {
           status: "equal",
         },
         {
-          property: "country",
-          previousValue: undefined,
-          currentValue: "us",
-          status: "added",
+          property: "type",
+          previousValue: "sport",
+          currentValue: undefined,
+          status: "deleted",
         },
+
         {
           property: "user",
           previousValue: {
@@ -422,12 +445,6 @@ describe("getObjectDiff", () => {
           },
           status: "updated",
           diff: [
-            {
-              property: "age",
-              previousValue: 66,
-              currentValue: undefined,
-              status: "deleted",
-            },
             {
               property: "name",
               previousValue: "joe",
@@ -446,6 +463,12 @@ describe("getObjectDiff", () => {
               currentValue: ["football", "golf"],
               status: "equal",
             },
+            {
+              property: "age",
+              previousValue: 66,
+              currentValue: undefined,
+              status: "deleted",
+            },
             {
               property: "nickname",
               previousValue: undefined,
@@ -455,10 +478,10 @@ describe("getObjectDiff", () => {
           ],
         },
         {
-          property: "type",
-          previousValue: "sport",
-          currentValue: undefined,
-          status: "deleted",
+          property: "country",
+          previousValue: undefined,
+          currentValue: "us",
+          status: "added",
         },
       ],
     });
@@ -536,10 +559,10 @@ describe("getObjectDiff", () => {
       status: "updated",
       diff: [
         {
-          property: "country",
-          previousValue: undefined,
-          currentValue: "us",
-          status: "added",
+          property: "type",
+          previousValue: "sport",
+          currentValue: undefined,
+          status: "deleted",
         },
         {
           property: "user",
@@ -572,10 +595,10 @@ describe("getObjectDiff", () => {
           ],
         },
         {
-          property: "type",
-          previousValue: "sport",
-          currentValue: undefined,
-          status: "deleted",
+          property: "country",
+          previousValue: undefined,
+          currentValue: "us",
+          status: "added",
         },
       ],
     });
@@ -822,26 +845,6 @@ describe("getObjectDiff", () => {
       diff: [],
     });
   });
-  expect(
-    getObjectDiff(
-      {
-        name: "joe",
-        age: 54,
-        hobbies: ["golf", "football"],
-      },
-      null,
-      {
-        showOnly: {
-          statuses: [OBJECT_STATUS.ADDED],
-          granularity: GRANULARITY.DEEP,
-        },
-      },
-    ),
-  ).toStrictEqual({
-    type: "object",
-    status: "deleted",
-    diff: [],
-  });
   it("returns all values if their status match the required statuses", () => {
     expect(
       getObjectDiff(