diff --git a/.eslintrc b/.eslintrc index fdc9887d..267f7f7b 100644 --- a/.eslintrc +++ b/.eslintrc @@ -1,5 +1,8 @@ { "extends": "loopback", + "parserOptions": { + "ecmaVersion": 2017 + }, "rules": { "max-len": ["error", 110, 4, { "ignoreComments": true, @@ -9,6 +12,12 @@ // NOTE(bajtos) we should eventually remove this override // and fix all of those 100+ violations "one-var": "off", - "no-unused-expressions": "off" + "no-unused-expressions": "off", + // TODO(bajtos) move this to eslint-config-loopback + "space-before-function-paren": ["error", { + "anonymous": "never", + "named": "never", + "asyncArrow": "always" + }], } } diff --git a/lib/datasource.js b/lib/datasource.js index bc144aa0..f97afd1d 100644 --- a/lib/datasource.js +++ b/lib/datasource.js @@ -2594,6 +2594,54 @@ DataSource.prototype.ping = function(cb) { return cb.promise; }; +/** + * Execute an arbitrary command. The commands are connector specific, + * please refer to the documentation of your connector for more details. + * + * @param command String|Object The command to execute, e.g. an SQL query. + * @param [args] Array Parameters values to set in the command. + * @param [options] Object Additional options, e.g. the transaction to use. + * @returns Promise A promise of the result + */ +DataSource.prototype.execute = function(command, args = [], options = {}) { + assert(typeof command === 'string' || typeof command === 'object', + '"command" must be a string or an object.'); + assert(typeof args === 'object', + '"args" must be an object, an array or undefined.'); + assert(typeof options === 'object', + '"options" must be an object or undefined.'); + + if (!this.connector) { + return Promise.reject(errorNotImplemented( + `DataSource "${this.name}" is missing a connector to execute the command.` + )); + } + + if (!this.connector.execute) { + return Promise.reject(new errorNotImplemented( + `The connector "${this.connector.name}" used by dataSource "${this.name}" ` + + 'does not implement "execute()" API.' + )); + } + + return new Promise((resolve, reject) => { + this.connector.execute(command, args, options, onExecuted); + function onExecuted(err, result) { + if (err) return reject(err); + if (arguments.length > 2) { + result = Array.prototype.slice.call(arguments, 1); + } + resolve(result); + } + }); + + function errorNotImplemented(msg) { + const err = new Error(msg); + err.code = 'NOT_IMPLEMENTED'; + return err; + } +}; + /*! The hidden property call is too expensive so it is not used that much */ /** diff --git a/test/datasource.test.js b/test/datasource.test.js index f37b33ed..cfd883ff 100644 --- a/test/datasource.test.js +++ b/test/datasource.test.js @@ -352,4 +352,100 @@ describe('DataSource', function() { .should.not.containEql('TestModel'); }); }); + + describe('execute', () => { + let ds; + beforeEach(() => ds = new DataSource('ds', {connector: 'memory'})); + + it('calls connnector to execute the command', async () => { + let called = 'not called'; + ds.connector.execute = function(command, args, options, callback) { + called = {command, args, options}; + callback(null, 'a-result'); + }; + + const result = await ds.execute( + 'command', + ['arg1', 'arg2'], + {'a-flag': 'a-value'} + ); + + result.should.be.equal('a-result'); + called.should.be.eql({ + command: 'command', + args: ['arg1', 'arg2'], + options: {'a-flag': 'a-value'}, + }); + }); + + it('supports shorthand version (cmd)', async () => { + let called = 'not called'; + ds.connector.execute = function(command, args, options, callback) { + called = {command, args, options}; + callback(null, 'a-result'); + }; + + const result = await ds.execute('command'); + result.should.be.equal('a-result'); + called.should.be.eql({ + command: 'command', + args: [], + options: {}, + }); + }); + + it('supports shorthand version (cmd, args)', async () => { + let called = 'not called'; + ds.connector.execute = function(command, args, options, callback) { + called = {command, args, options}; + callback(null, 'a-result'); + }; + + await ds.execute('command', ['arg1', 'arg2']); + called.should.be.eql({ + command: 'command', + args: ['arg1', 'arg2'], + options: {}, + }); + }); + + it('converts multiple callbacks arguments into a promise resolved with an array', async () => { + ds.connector.execute = function(command, args, options, callback) { + callback(null, 'result1', 'result2'); + }; + const result = await ds.execute('command'); + result.should.eql(['result1', 'result2']); + }); + + it('allows args as object', async () => { + let called = 'not called'; + ds.connector.execute = function(command, args, options, callback) { + called = {command, args, options}; + callback(); + }; + + // See https://www.npmjs.com/package/loopback-connector-neo4j-graph + const command = 'MATCH (u:User {email: {email}}) RETURN u'; + await ds.execute(command, {email: 'alice@example.com'}); + called.should.be.eql({ + command, + args: {email: 'alice@example.com'}, + options: {}, + }); + }); + + it('throws NOT_IMPLEMENTED when no connector is provided', () => { + ds.connector = undefined; + return ds.execute('command').should.be.rejectedWith({ + code: 'NOT_IMPLEMENTED', + }); + }); + + it('throws NOT_IMPLEMENTED for connectors not implementing execute', () => { + ds.connector.execute = undefined; + return ds.execute('command').should.be.rejectedWith({ + code: 'NOT_IMPLEMENTED', + }); + }); + }); }); diff --git a/types/datasource.d.ts b/types/datasource.d.ts index 6f311a13..2609405b 100644 --- a/types/datasource.d.ts +++ b/types/datasource.d.ts @@ -178,4 +178,11 @@ export declare class DataSource extends EventEmitter { connect(callback?: Callback): PromiseOrVoid; disconnect(callback?: Callback): PromiseOrVoid; ping(callback?: Callback): PromiseOrVoid; + + // Only promise variant, callback is intentionally not supported. + execute( + command: string | object, + args?: any[] | object, + options?: Options + ): Promise; }