Repository: streamich/memfs
Pull request #535 (2020-05-13 22:31:15)

feat: add support for nested objects to `fromJSON()`
This adds support for nested object (literals) to `fromJSON()`. It reads much nicer and makes tests look cleaner (at least that is my personal opinion). One of the features I miss most not being able to use `mock-fs`.

I added this functionality to `fromJSON()` itself, one could argue having an additional method like `fromNestedJSON()` might be better regarding to backward compatibility. I don't think this is a problem at all, but the rather exotic corner case of calling `fromJSON()` with something like `{'dir1': {'dir2'}}` just created `dir1` so far, but would also create `dir2` from now on...

Feedback on this is highly appreciated!

Additionally this fixes a bug (at least I think it's one): so far `fromJSON()` only took the `cwd` parameter into account when creating files, not when creating directories.


Patch:
diff --git a/src/volume.ts b/src/volume.ts
index 8849d71c..e1134a00 100644
--- a/src/volume.ts
+++ b/src/volume.ts
@@ -31,17 +31,7 @@ const {
   COPYFILE_FICLONE_FORCE,
 } = constants;
 
-let sep;
-let relative;
-if (pathModule.posix) {
-  const { posix } = pathModule;
-
-  sep = posix.sep;
-  relative = posix.relative;
-} else {
-  sep = pathModule.sep;
-  relative = pathModule.relative;
-}
+const { sep, relative, join, dirname } = pathModule.posix ? pathModule.posix : pathModule;
 
 const isWin = process.platform === 'win32';
 
@@ -507,8 +497,42 @@ function validateGid(gid: number) {
 }
 
 // ---------------------------------------- Volume
+type DirectoryContent = string | null;
+
+export interface DirectoryJSON {
+  [key: string]: DirectoryContent;
+}
+export interface NestedDirectoryJSON {
+  [key: string]: DirectoryContent | NestedDirectoryJSON;
+}
+
+function flattenJSON(nestedJSON: NestedDirectoryJSON): DirectoryJSON {
+  const flatJSON: DirectoryJSON = {};
+
+  function flatten(pathPrefix: string, node: NestedDirectoryJSON) {
+    for (const path in node) {
+      const contentOrNode = node[path];
+
+      const joinedPath = join(pathPrefix, path);
+
+      if (typeof contentOrNode === 'string') {
+        flatJSON[joinedPath] = contentOrNode;
+      } else if (typeof contentOrNode === 'object' && contentOrNode !== null && Object.keys(contentOrNode).length > 0) {
+        // empty directories need an explicit entry and therefore get handled in `else`, non-empty ones are implicitly considered
+
+        flatten(joinedPath, contentOrNode);
+      } else {
+        // without this branch null, empty-object or non-object entries would not be handled in the same way
+        // by both fromJSON() and fromNestedJSON()
+        flatJSON[joinedPath] = null;
+      }
+    }
+  }
+
+  flatten('', nestedJSON);
 
-export type DirectoryJSON = Record<string, string | null>;
+  return flatJSON;
+}
 
 /**
  * `Volume` represents a file system.
@@ -520,6 +544,12 @@ export class Volume {
     return vol;
   }
 
+  static fromNestedJSON(json: NestedDirectoryJSON, cwd?: string): Volume {
+    const vol = new Volume();
+    vol.fromNestedJSON(json, cwd);
+    return vol;
+  }
+
   /**
    * Global file descriptor counter. UNIX file descriptors start from 0 and go sequentially
    * up, so here, in order not to conflict with them, we choose some big number and descrease
@@ -863,18 +893,16 @@ export class Volume {
     return json;
   }
 
-  // fromJSON(json: {[filename: string]: string}, cwd: string = '/') {
   fromJSON(json: DirectoryJSON, cwd: string = process.cwd()) {
     for (let filename in json) {
       const data = json[filename];
 
+      filename = resolve(filename, cwd);
+
       if (typeof data === 'string') {
-        filename = resolve(filename, cwd);
-        const steps = filenameToSteps(filename);
-        if (steps.length > 1) {
-          const dirname = sep + steps.slice(0, steps.length - 1).join(sep);
-          this.mkdirpBase(dirname, MODE.DIR);
-        }
+        const dir = dirname(filename);
+        this.mkdirpBase(dir, MODE.DIR);
+
         this.writeFileSync(filename, data);
       } else {
         this.mkdirpBase(filename, MODE.DIR);
@@ -882,6 +910,10 @@ export class Volume {
     }
   }
 
+  fromNestedJSON(json: NestedDirectoryJSON, cwd?: string) {
+    this.fromJSON(flattenJSON(json), cwd);
+  }
+
   reset() {
     this.ino = 0;
     this.inodes = {};


Test patch:
diff --git a/src/__tests__/volume.test.ts b/src/__tests__/volume.test.ts
index 4572fe8b..d83b629c 100644
--- a/src/__tests__/volume.test.ts
+++ b/src/__tests__/volume.test.ts
@@ -197,16 +197,18 @@ describe('volume', () => {
         expect(vol.toJSON()).toEqual(json);
       });
 
-      it('Files at root with relative paths', () => {
+      it('Files and directories at root with relative paths', () => {
         const vol = new Volume();
         const json = {
           hello: 'world',
           'app.js': 'console.log(123)',
+          dir: null,
         };
         vol.fromJSON(json, '/');
         expect(vol.toJSON()).toEqual({
           '/hello': 'world',
           '/app.js': 'console.log(123)',
+          '/dir': null,
         });
       });
 
@@ -252,7 +254,7 @@ describe('volume', () => {
         }
       });
 
-      it('creates a folder if values is not a string', () => {
+      it('creates a folder if value is not a string', () => {
         const vol = Volume.fromJSON({
           '/dir': null,
         });
@@ -263,6 +265,46 @@ describe('volume', () => {
       });
     });
 
+    describe('.fromNestedJSON(nestedJSON[, cwd])', () => {
+      it('Accept a nested dict as input because its nicer to read', () => {
+        const vol1 = new Volume();
+        const vol2 = new Volume();
+
+        const jsonFlat = {
+          '/dir/file': '...',
+          '/emptyDir': null,
+          '/anotherEmptyDir': null,
+          '/oneMoreEmptyDir': null,
+          '/dir/dir/dir2/hello.sh': 'world',
+          '/hello.js': 'console.log(123)',
+          '/dir/dir/test.txt': 'File with leading slash',
+        };
+        const jsonNested = {
+          '/dir/': {
+            file: '...',
+            dir: {
+              dir2: {
+                'hello.sh': 'world',
+              },
+              '/test.txt': 'File with leading slash',
+            },
+          },
+          '/emptyDir': {},
+          '/anotherEmptyDir': null,
+          '/oneMoreEmptyDir': {
+            '': null, // this could be considered a glitch, but "" is not a valid filename anyway
+            // (same as 'file/name' is invalid and would lead to problems)
+          },
+          '/hello.js': 'console.log(123)',
+        };
+
+        vol1.fromJSON(jsonFlat);
+        vol2.fromNestedJSON(jsonNested);
+
+        expect(vol1.toJSON()).toEqual(vol2.toJSON());
+      });
+    });
+
     describe('.reset()', () => {
       it('Remove all files', () => {
         const vol = new Volume();