From f46e6d1b9aed5858f2d72b10b18635de6ed1f1e0 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Thu, 13 Jan 2022 17:21:34 +0100 Subject: [PATCH] [FEATURE] Introduce Readers "Filter" and "Transformer" (#331) --- index.js | 15 ++++ lib/AbstractReader.js | 33 ++++++++ lib/ResourceTagCollection.js | 50 +++++++++--- lib/readers/Filter.js | 73 +++++++++++++++++ lib/readers/Transformer.js | 98 ++++++++++++++++++++++ test/lib/ResourceTagCollection.js | 130 +++++++++++++++++++++++++++--- test/lib/readers/Filter.js | 66 +++++++++++++++ test/lib/readers/Transformer.js | 87 ++++++++++++++++++++ 8 files changed, 528 insertions(+), 24 deletions(-) create mode 100644 lib/readers/Filter.js create mode 100644 lib/readers/Transformer.js create mode 100644 test/lib/readers/Filter.js create mode 100644 test/lib/readers/Transformer.js diff --git a/index.js b/index.js index b1b1d920..21360bc5 100644 --- a/index.js +++ b/index.js @@ -22,6 +22,21 @@ module.exports = { */ Memory: "./lib/adapters/Memory" }, + /** + * @public + * @alias module:@ui5/fs.readers + * @namespace + */ + readers: { + /** + * @type {typeof import('./lib/readers/Filter')} + */ + Filter: "./lib/readers/Filter", + /** + * @type {typeof import('./lib/readers/Transformer')} + */ + Transformer: "./lib/readers/Transformer", + }, /** * @type {typeof import('./lib/AbstractReader')} */ diff --git a/lib/AbstractReader.js b/lib/AbstractReader.js index 059ad4d1..b0a6aa00 100644 --- a/lib/AbstractReader.js +++ b/lib/AbstractReader.js @@ -70,6 +70,39 @@ class AbstractReader { }); } + + /** + * Create a [Filter-Reader]{@link module:@ui5/fs.readers.Filter} from the current reader + * + * @public + * @param {module:@ui5/fs.readers.Filter~callback} callback + * Filter function. Will be called for every resource passed through this reader. + * @returns {module:@ui5/fs.reader.Filter} Filter instance + */ + filter(callback) { + const Filter = require("./readers/Filter"); + return new Filter({ + reader: this, + callback + }); + } + + /** + * Create a [Transform-Reader]{@link module:@ui5/fs.readers.Transform} from the current reader + * + * @public + * @param {module:@ui5/fs.readers.Transformer~callback} callback + * Callback to check and eventually transform any resource passed through the reader + * @returns {module:@ui5/fs.reader.Transformer} Transformer instance + */ + transformer(callback) { + const Transformer = require("./readers/Transformer"); + return new Transformer({ + reader: this, + callback + }); + } + /** * Locates resources by one or more glob patterns. * diff --git a/lib/ResourceTagCollection.js b/lib/ResourceTagCollection.js index e12ec958..182939eb 100644 --- a/lib/ResourceTagCollection.js +++ b/lib/ResourceTagCollection.js @@ -2,53 +2,77 @@ const tagNamespaceRegExp = new RegExp("^[a-z][a-z0-9]*$"); // part before the co const tagNameRegExp = new RegExp("^[A-Z][A-Za-z0-9]+$"); // part after the colon class ResourceTagCollection { - constructor({allowedTags}) { + constructor({allowedTags, superCollection}) { if (!allowedTags || !allowedTags.length) { throw new Error(`Missing parameter 'allowedTags'`); } + + if (superCollection) { + this._superCollection = superCollection; + this._superTags = this._superCollection.getAcceptedTags(); + } else { + this._superTags = []; + } + // No validation of tag names here since we might remove/ignore // this parameter in the future and generally allow all tags this._allowedTags = Object.freeze(allowedTags); this._pathTags = {}; } - setTag(resource, tag, value = true) { - this._validateResource(resource); + setTag(resourcePath, tag, value = true) { + if (this._superTags.includes(tag)) { + return this._superCollection.setTag(resourcePath, tag, value); + } + + resourcePath = this._getPath(resourcePath); this._validateTag(tag); this._validateValue(value); - const resourcePath = resource.getPath(); if (!this._pathTags[resourcePath]) { this._pathTags[resourcePath] = {}; } this._pathTags[resourcePath][tag] = value; } - clearTag(resource, tag) { - this._validateResource(resource); + clearTag(resourcePath, tag) { + if (this._superTags.includes(tag)) { + return this._superCollection.clearTag(resourcePath, tag); + } + + resourcePath = this._getPath(resourcePath); this._validateTag(tag); - const resourcePath = resource.getPath(); if (this._pathTags[resourcePath]) { this._pathTags[resourcePath][tag] = undefined; } } - getTag(resource, tag) { - this._validateResource(resource); + getTag(resourcePath, tag) { + if (this._superTags.includes(tag)) { + return this._superCollection.getTag(resourcePath, tag); + } + + resourcePath = this._getPath(resourcePath); this._validateTag(tag); - const resourcePath = resource.getPath(); if (this._pathTags[resourcePath]) { return this._pathTags[resourcePath][tag]; } } - _validateResource(resource) { - const path = resource.getPath(); - if (!path) { + getAcceptedTags() { + return [...this._allowedTags, ...this._superTags]; + } + + _getPath(resourcePath) { + if (typeof resourcePath !== "string") { + resourcePath = resourcePath.getPath(); + } + if (!resourcePath) { throw new Error(`Invalid Resource: Resource path must not be empty`); } + return resourcePath; } _validateTag(tag) { diff --git a/lib/readers/Filter.js b/lib/readers/Filter.js new file mode 100644 index 00000000..4108ede7 --- /dev/null +++ b/lib/readers/Filter.js @@ -0,0 +1,73 @@ +const AbstractReader = require("../AbstractReader"); + +/** + * A reader that allows dynamic filtering of resources passed through it + * + * @public + * @memberof module:@ui5/fs + * @augments module:@ui5/fs.AbstractReader + */ +class Filter extends AbstractReader { + /** + * Filter callback + * + * @public + * @callback module:@ui5/fs.readers.Filter~callback + * @param {module:@ui5/fs.Resource} resource Resource to test + * @returns {boolean} Whether to keep the resource + */ + + /** + * Constructor + * + * @param {object} parameters Parameters + * @param {module:@ui5/fs.AbstractReader} parameters.reader The resource reader to wrap + * @param {module:@ui5/fs.readers.Filter~callback} parameters.callback + * Filter function. Will be called for every resource read through this reader. + */ + constructor({reader, callback}) { + super(); + if (!reader) { + throw new Error(`Missing parameter "reader"`); + } + if (!callback) { + throw new Error(`Missing parameter "callback"`); + } + this._reader = reader; + this._callback = callback; + } + + /** + * Locates resources by glob. + * + * @private + * @param {string|string[]} pattern glob pattern as string or an array of + * glob patterns for virtual directory structure + * @param {object} options glob options + * @param {module:@ui5/fs.tracing.Trace} trace Trace instance + * @returns {Promise} Promise resolving to list of resources + */ + async _byGlob(pattern, options, trace) { + const result = await this._reader._byGlob(pattern, options, trace); + return result.filter(this._callback); + } + + /** + * Locates resources by path. + * + * @private + * @param {string} virPath Virtual path + * @param {object} options Options + * @param {module:@ui5/fs.tracing.Trace} trace Trace instance + * @returns {Promise} Promise resolving to a single resource + */ + async _byPath(virPath, options, trace) { + const result = await this._reader._byPath(virPath, options, trace); + if (result && !this._callback(result)) { + return null; + } + return result; + } +} + +module.exports = Filter; diff --git a/lib/readers/Transformer.js b/lib/readers/Transformer.js new file mode 100644 index 00000000..cc19d6f1 --- /dev/null +++ b/lib/readers/Transformer.js @@ -0,0 +1,98 @@ +const AbstractReader = require("../AbstractReader"); + +/** + * A reader that allows modification of all resources passed through it. + * + * @public + * @memberof module:@ui5/fs.readers + * @augments module:@ui5/fs.AbstractReader + */ +class Transformer extends AbstractReader { + /** + * Callback to check and eventually transform a resource + * + * @public + * @callback module:@ui5/fs.readers.Transformer~callback + * @param {module:@ui5/fs.Resource} resourcePath Path of the resource to process. + * This can be used to decide whether the resource should be transformed + * @param {module:@ui5/fs.readers.Transformer~getResource} + * Function to retrieve the given resource instance in order to transform it + * @returns {Promise} Promise resolving once the transformation is done + */ + + /** + * Callback to retrieve a resource for modification. This will create a clone of the original + * resource which then takes its place in the result set of the reader + * + * @public + * @callback module:@ui5/fs.readers.Transformer~getResource + * @returns {Promise} Promise resolving to the resource + */ + + /** + * Constructor + * + * @param {object} parameters Parameters + * @param {module:@ui5/fs.AbstractReader} parameters.reader The resource reader to wrap + * @param {module:@ui5/fs.readers.Transformer~callback} parameters.callback + * Filter function. Will be called for every resource read through this reader. + */ + constructor({reader, callback}) { + super(); + if (!reader) { + throw new Error(`Missing parameter "reader"`); + } + if (!callback) { + throw new Error(`Missing parameter "callback"`); + } + this._reader = reader; + this._callback = callback; + } + + /** + * Locates resources by glob. + * + * @private + * @param {string|string[]} pattern glob pattern as string or an array of + * glob patterns for virtual directory structure + * @param {object} options glob options + * @param {module:@ui5/fs.tracing.Trace} trace Trace instance + * @returns {Promise} Promise resolving to list of resources + */ + async _byGlob(pattern, options, trace) { + const result = await this._reader._byGlob(pattern, options, trace); + return Promise.all(result.map(async (resource) => { + let resourceClone; + await this._callback(resource.getPath(), async function() { + // Make sure to only clone once + resourceClone = resourceClone || await resource.clone(); + return resourceClone; + }); + return resourceClone || resource; + })); + } + + /** + * Locates resources by path. + * + * @private + * @param {string} virPath Virtual path + * @param {object} options Options + * @param {module:@ui5/fs.tracing.Trace} trace Trace instance + * @returns {Promise} Promise resolving to a single resource + */ + async _byPath(virPath, options, trace) { + const resource = await this._reader._byPath(virPath, options, trace); + let resourceClone; + if (resource) { + await this._callback(resource.getPath(), async function() { + // Make sure to only clone once + resourceClone = resourceClone || await resource.clone(); + return resourceClone; + }); + } + return resourceClone || resource; + } +} + +module.exports = Transformer; diff --git a/test/lib/ResourceTagCollection.js b/test/lib/ResourceTagCollection.js index b7d1617a..43c9646d 100644 --- a/test/lib/ResourceTagCollection.js +++ b/test/lib/ResourceTagCollection.js @@ -25,7 +25,7 @@ test("setTag", (t) => { allowedTags: ["abc:MyTag"] }); - const validateResourceSpy = sinon.spy(tagCollection, "_validateResource"); + const validateResourceSpy = sinon.spy(tagCollection, "_getPath"); const validateTagSpy = sinon.spy(tagCollection, "_validateTag"); const validateValueSpy = sinon.spy(tagCollection, "_validateValue"); @@ -37,9 +37,9 @@ test("setTag", (t) => { } }, "Tag correctly stored"); - t.is(validateResourceSpy.callCount, 1, "_validateResource called once"); + t.is(validateResourceSpy.callCount, 1, "_getPath called once"); t.is(validateResourceSpy.getCall(0).args[0], resource, - "_validateResource called with correct arguments"); + "_getPath called with correct arguments"); t.is(validateTagSpy.callCount, 1, "_validateTag called once"); t.is(validateTagSpy.getCall(0).args[0], "abc:MyTag", @@ -75,16 +75,16 @@ test("getTag", (t) => { }); tagCollection.setTag(resource, "abc:MyTag", 123); - const validateResourceSpy = sinon.spy(tagCollection, "_validateResource"); + const validateResourceSpy = sinon.spy(tagCollection, "_getPath"); const validateTagSpy = sinon.spy(tagCollection, "_validateTag"); const value = tagCollection.getTag(resource, "abc:MyTag"); t.is(value, 123, "Got correct tag value"); - t.is(validateResourceSpy.callCount, 1, "_validateResource called once"); + t.is(validateResourceSpy.callCount, 1, "_getPath called once"); t.is(validateResourceSpy.getCall(0).args[0], resource, - "_validateResource called with correct arguments"); + "_getPath called with correct arguments"); t.is(validateTagSpy.callCount, 1, "_validateTag called once"); t.is(validateTagSpy.getCall(0).args[0], "abc:MyTag", @@ -101,7 +101,7 @@ test("clearTag", (t) => { tagCollection.setTag(resource, "abc:MyTag", 123); - const validateResourceSpy = sinon.spy(tagCollection, "_validateResource"); + const validateResourceSpy = sinon.spy(tagCollection, "_getPath"); const validateTagSpy = sinon.spy(tagCollection, "_validateTag"); tagCollection.clearTag(resource, "abc:MyTag"); @@ -112,15 +112,123 @@ test("clearTag", (t) => { } }, "Tag value set to undefined"); - t.is(validateResourceSpy.callCount, 1, "_validateResource called once"); + t.is(validateResourceSpy.callCount, 1, "_getPath called once"); t.is(validateResourceSpy.getCall(0).args[0], resource, - "_validateResource called with correct arguments"); + "_getPath called with correct arguments"); t.is(validateTagSpy.callCount, 1, "_validateTag called once"); t.is(validateTagSpy.getCall(0).args[0], "abc:MyTag", "_validateTag called with correct arguments"); }); +test("superCollection: setTag", (t) => { + const resource = new Resource({ + path: "/some/path" + }); + const superTagCollection = new ResourceTagCollection({ + allowedTags: ["abc:MySuperTag"], + }); + const tagCollection = new ResourceTagCollection({ + allowedTags: ["abc:MyTag"], + superCollection: superTagCollection + }); + + const validateResourceSpy = sinon.spy(superTagCollection, "_getPath"); + const validateTagSpy = sinon.spy(superTagCollection, "_validateTag"); + const validateValueSpy = sinon.spy(superTagCollection, "_validateValue"); + + tagCollection.setTag(resource, "abc:MySuperTag", "my super value"); + tagCollection.setTag(resource, "abc:MyTag", "my value"); + + t.deepEqual(superTagCollection._pathTags, { + "/some/path": { + "abc:MySuperTag": "my super value" + } + }, "Super tag correctly stored"); + t.deepEqual(tagCollection._pathTags, { + "/some/path": { + "abc:MyTag": "my value" + } + }, "Non-super tag correctly stored"); + + t.is(validateResourceSpy.callCount, 1, "_getPath called once"); + t.is(validateResourceSpy.getCall(0).args[0], resource, + "_getPath called with correct arguments"); + + t.is(validateTagSpy.callCount, 1, "_validateTag called once"); + t.is(validateTagSpy.getCall(0).args[0], "abc:MySuperTag", + "_validateTag called with correct arguments"); + + t.is(validateValueSpy.callCount, 1, "_validateValue called once"); + t.is(validateValueSpy.getCall(0).args[0], "my super value", + "_validateValue called with correct arguments"); +}); + +test("superCollection: getTag", (t) => { + const resource = new Resource({ + path: "/some/path" + }); + const superTagCollection = new ResourceTagCollection({ + allowedTags: ["abc:MySuperTag"], + }); + const tagCollection = new ResourceTagCollection({ + allowedTags: ["abc:MyTag"], + superCollection: superTagCollection + }); + + tagCollection.setTag(resource, "abc:MySuperTag", 456); + tagCollection.setTag(resource, "abc:MyTag", 123); + + const validateResourceSpy = sinon.spy(superTagCollection, "_getPath"); + const validateTagSpy = sinon.spy(superTagCollection, "_validateTag"); + + const value = tagCollection.getTag(resource, "abc:MySuperTag"); + + t.is(value, 456, "Got correct tag value"); + + t.is(validateResourceSpy.callCount, 1, "_getPath called once"); + t.is(validateResourceSpy.getCall(0).args[0], resource, + "_getPath called with correct arguments"); + + t.is(validateTagSpy.callCount, 1, "_validateTag called once"); + t.is(validateTagSpy.getCall(0).args[0], "abc:MySuperTag", + "_validateTag called with correct arguments"); +}); + +test("superCollection: clearTag", (t) => { + const resource = new Resource({ + path: "/some/path" + }); + const superTagCollection = new ResourceTagCollection({ + allowedTags: ["abc:MySuperTag"], + }); + const tagCollection = new ResourceTagCollection({ + allowedTags: ["abc:MyTag"], + superCollection: superTagCollection + }); + + tagCollection.setTag(resource, "abc:MySuperTag", 123); + + const validateResourceSpy = sinon.spy(superTagCollection, "_getPath"); + const validateTagSpy = sinon.spy(superTagCollection, "_validateTag"); + + tagCollection.clearTag(resource, "abc:MySuperTag"); + + t.deepEqual(superTagCollection._pathTags, { + "/some/path": { + "abc:MySuperTag": undefined + } + }, "Tag value set to undefined"); + + t.is(validateResourceSpy.callCount, 1, "_getPath called once"); + t.is(validateResourceSpy.getCall(0).args[0], resource, + "_getPath called with correct arguments"); + + t.is(validateTagSpy.callCount, 1, "_validateTag called once"); + t.is(validateTagSpy.getCall(0).args[0], "abc:MySuperTag", + "_validateTag called with correct arguments"); +}); + test("_validateTag: Not in list of allowed tags", (t) => { const tagCollection = new ResourceTagCollection({ allowedTags: ["abc:MyTag"] @@ -279,12 +387,12 @@ test("_validateValue: Invalid value null", (t) => { }); }); -test("_validateResource: Empty path", (t) => { +test("_getPath: Empty path", (t) => { const tagCollection = new ResourceTagCollection({ allowedTags: ["abc:MyTag"] }); t.throws(() => { - tagCollection._validateResource({ + tagCollection._getPath({ getPath: () => "" }); }, { diff --git a/test/lib/readers/Filter.js b/test/lib/readers/Filter.js new file mode 100644 index 00000000..f3487bdc --- /dev/null +++ b/test/lib/readers/Filter.js @@ -0,0 +1,66 @@ +const test = require("ava"); +const sinon = require("sinon"); +const Filter = require("../../../lib/readers/Filter"); + +test("_byGlob: Basic filter", async (t) => { + const abstractReader = { + _byGlob: sinon.stub().returns(Promise.resolve(["resource a", "resource b"])) + }; + const trace = { + collection: sinon.spy() + }; + const readerCollection = new Filter({ + reader: abstractReader, + callback: function(resource) { + if (resource === "resource a") { + return false; + } + return true; + } + }); + + const resources = await readerCollection._byGlob("anyPattern", {}, trace); + t.deepEqual(resources, ["resource b"], "Correct resource in result"); +}); + +test("_byPath: Negative filter", async (t) => { + const abstractReader = { + _byPath: sinon.stub().returns(Promise.resolve("resource a")) + }; + const trace = { + collection: sinon.spy() + }; + const readerCollection = new Filter({ + reader: abstractReader, + callback: function(resource) { + if (resource === "resource a") { + return false; + } + return true; + } + }); + + const resource = await readerCollection._byPath("anyPattern", {}, trace); + t.deepEqual(resource, null, "Correct empty result"); +}); + +test("_byPath: Positive filter", async (t) => { + const abstractReader = { + _byPath: sinon.stub().returns(Promise.resolve("resource b")) + }; + const trace = { + collection: sinon.spy() + }; + const readerCollection = new Filter({ + reader: abstractReader, + callback: function(resource) { + if (resource === "resource a") { + return false; + } + return true; + } + }); + + const resource = await readerCollection._byPath("anyPattern", {}, trace); + t.deepEqual(resource, "resource b", "Correct resource in result"); +}); diff --git a/test/lib/readers/Transformer.js b/test/lib/readers/Transformer.js new file mode 100644 index 00000000..6111f587 --- /dev/null +++ b/test/lib/readers/Transformer.js @@ -0,0 +1,87 @@ +const test = require("ava"); +const sinon = require("sinon"); +const Transformer = require("../../../lib/readers/Transformer"); + +function getDummyResource(name) { + return { + name, // arbitrary attribute to change + getPath: function() { + return `/resources/${name}`; + }, + clone: function() { + return getDummyResource(name); + } + }; +} + +test("_byGlob: Basic transformation", async (t) => { + const resourceA = getDummyResource("resource.a"); + const resourceB = getDummyResource("resource.b"); + const abstractReader = { + _byGlob: sinon.stub().returns(Promise.resolve([resourceA, resourceB])) + }; + const trace = { + collection: sinon.spy() + }; + const readerCollection = new Transformer({ + reader: abstractReader, + callback: async function(resourcePath, getResource) { + if (resourcePath === "/resources/resource.a") { + const resource = await getResource(); + resource.name = "transformed resource.a"; + await getResource(); // additional call should not lead to additional clone + } + } + }); + + const resources = await readerCollection._byGlob("anyPattern", {}, trace); + t.deepEqual(resources.length, 2, "Still two resources in result set"); + t.deepEqual(resources[0].name, "transformed resource.a", "resource.a has been transformed in result"); + t.deepEqual(resources[1].name, "resource.b", "resource.b has not been transformed"); + t.is(resources[1], resourceB, "resource.b instance has not been cloned"); + t.deepEqual(resourceA.name, "resource.a", "Original resource.a has not been transformed"); +}); + +test("_byPath: Basic transformation", async (t) => { + const resourceA = getDummyResource("resource.a"); + const abstractReader = { + _byPath: sinon.stub().returns(resourceA) + }; + const trace = { + collection: sinon.spy() + }; + const readerCollection = new Transformer({ + reader: abstractReader, + callback: async function(resourcePath, getResource) { + const resource = await getResource(); + resource.name = "transformed resource.a"; + + await getResource(); // additional call should not lead to additional clone + } + }); + + const resource = await readerCollection._byPath("anyPattern", {}, trace); + + t.deepEqual(resource.name, "transformed resource.a", "resource.a has been transformed in result"); + t.deepEqual(resourceA.name, "resource.a", "Original resource.a has not been transformed"); +}); + +test("_byPath: No transformation", async (t) => { + const resourceB = getDummyResource("resource.b"); + const abstractReader = { + _byPath: sinon.stub().returns(resourceB) + }; + const trace = { + collection: sinon.spy() + }; + const readerCollection = new Transformer({ + reader: abstractReader, + callback: async function(resourcePath, getResource) { + return; + } + }); + + const resource = await readerCollection._byPath("anyPattern", {}, trace); + t.deepEqual(resource.name, "resource.b", "Correct resource in result"); + t.is(resource, resourceB, "resource.b instance has not been cloned"); +});