diff --git a/lib/services.js b/lib/services.js index 033f71e..f986f77 100644 --- a/lib/services.js +++ b/lib/services.js @@ -35,7 +35,117 @@ function describeModels(app) { return; } + // The URL of prototype methods include sharedCtor parameters like ":id" + // Because all $resource methods are static (non-prototype) in ngResource, + // the sharedCtor parameters should be added to the parameters + // of prototype methods. + c.methods.forEach(function fixArgsOfPrototypeMethods(method) { + var ctor = method.restClass.ctor; + if (!ctor || method.sharedMethod.isStatic) return; + method.accepts = ctor.accepts.concat(method.accepts); + }); + result[name] = c; }); + + buildScopes(result); + return result; } + +var SCOPE_METHOD_REGEX = /^prototype.__([^_]+)__(.+)$/; + +function buildScopes(models) { + for (var modelName in models) { + buildScopesOfModel(models, modelName); + } +} + +function buildScopesOfModel(models, modelName) { + var modelClass = models[modelName]; + + modelClass.scopes = {}; + modelClass.methods.forEach(function(method) { + buildScopeMethod(models, modelName, method); + }); + + return modelClass; +} + +// reverse-engineer scope method +// defined by loopback-datasource-juggler/lib/scope.js +function buildScopeMethod(models, modelName, method) { + var modelClass = models[modelName]; + var match = method.name.match(SCOPE_METHOD_REGEX); + if (!match) return; + + var op = match[1]; + var scopeName = match[2]; + var modelPrototype = modelClass.sharedClass.ctor.prototype; + var targetClass = modelPrototype[scopeName]._targetClass; + + if (modelClass.scopes[scopeName] === undefined) { + if (!targetClass) { + console.error( + 'Warning: scope %s.%s is missing _targetClass property.' + + '\nThe Angular code for this scope won\'t be generated.' + + '\nPlease upgrade to the latest version of' + + '\nloopback-datasource-juggler to fix the problem.', + modelName, scopeName); + modelClass.scopes[scopeName] = null; + return; + } + + if (!findModelByName(models, targetClass)) { + console.error( + 'Warning: scope %s.%s targets class %j, which is not exposed ' + + '\nvia remoting. The Angular code for this scope won\'t be generated.', + modelName, scopeName, targetClass); + modelClass.scopes[scopeName] = null; + return; + } + + modelClass.scopes[scopeName] = { + methods: {}, + targetClass: targetClass + }; + } else if (modelClass.scopes[scopeName] === null) { + // Skip the scope, the warning was already reported + return; + } + + var apiName = scopeName; + if (op == 'get') { + // no-op, create the scope accessor + } else if (op == 'delete') { + apiName += '.destroyAll'; + } else { + apiName += '.' + op; + } + + method.deprecated = 'Use ' + modelName + '.' + apiName + '() instead.'; + + // build a reverse record to be used in ngResource + // Product.__find__categories -> Category.::find::product::categories + var reverseName = '::' + op + '::' + modelName + '::' + scopeName; + + var reverseMethod = Object.create(method); + reverseMethod.name = reverseName; + delete reverseMethod.deprecated; + reverseMethod.internal = 'Use ' + modelName + '.' + apiName + '() instead.'; + + var reverseModel = findModelByName(models, targetClass); + reverseModel.methods.push(reverseMethod); + + var scopeMethod = Object.create(method); + scopeMethod.name = reverseName; + delete scopeMethod.deprecated; + modelClass.scopes[scopeName].methods[apiName] = scopeMethod; +} + +function findModelByName(models, name) { + for (var n in models) { + if (n.toLowerCase() == name.toLowerCase()) + return models[n]; + } +} diff --git a/lib/services.template b/lib/services.template index 9fc1ec9..177c23a 100644 --- a/lib/services.template +++ b/lib/services.template @@ -42,8 +42,8 @@ var module = angular.module(<%-: moduleName | q %>, ['ngResource']); */ module.factory( <%-: modelName | q %>, - ['LoopBackResource', 'LoopBackAuth', function(Resource, LoopBackAuth) { - return Resource( + ['LoopBackResource', 'LoopBackAuth', '$injector', function(Resource, LoopBackAuth, $injector) { + var R = Resource( urlBase + <%-: meta.ctor.getFullPath() | q %>, <% /* Constructor arguments are hardcoded for now. @@ -54,100 +54,7 @@ module.factory( <% meta.methods.forEach(function(action) { var methodName = action.name.split('.').join('$'); -%> - /** - * @ngdoc method - * @name lbServices.<%- modelName %>#<%- methodName %> - * @methodOf lbServices.<%- modelName %> - * - * @description - * -<% if (!action.description) { -action.description = '\n' + - '(The remote method definition does not provide any description.)\n' + - ''; -} -%> - * <%-: action.description | replace:/\n/g, '\n * ' %> - * -<% -var params = action.accepts; -var postData; -if (action.getHttpMethod() == 'POST' || action.getHttpMethod() == 'PUT') { - params = params.filter(function(arg) { - return arg.http && (arg.http.source == 'query' || arg.http.source == 'path'); - }); - postData = action.accepts.filter(function(arg) { - return params.indexOf(arg) == -1; - }); -} --%> - * @param {Object=} parameters Request parameters. -<% if (params.length == 0) { -%> - * - * This method does not accept any parameters. - * Supply an empty object or omit this argument altogether. -<% } else { params.forEach(function(arg) { -%> - * - * - `<%- arg.arg %>` – `{<%- getJsDocType(arg) %>}` - <%- -(arg.description || '').replace(/\n/g, '\n * ') %> -<% }); } -%> -<% if (modelName === 'User' && methodName === 'login') { -%> - * - * - `rememberMe` - `boolean` - Whether the authentication credentials - * should be remembered in localStorage across app/browser restarts. - * Default: `true`. -<% } -%> -<% if (postData) { -%> - * - * @param {Object} postData Request data. -<% if (postData.length == 0) { -%> - * - * This method does not accept any data. Supply an empty object. -<% } else if (postData.length == 1 && postData[0].http && - postData[0].http.source == 'body') { -%> - * - * This method expects a subset of model properties as request parameters. -<% } else { -postData.forEach(function(arg) { -%> - * - * - `<%- arg.arg %>` – `{<%- getJsDocType(arg) %>}` - <%- -(arg.description || '').replace(/\n/g, '\n * ') %> -<% }); - } -} -%> - * -<% var returnType = action.isReturningArray() ? 'Array.': 'Object'; -%> - * @param {Function(<%- returnType %>, Object)=} successCb - * Success callback with two arguments: `value`, `responseHeaders`. - * - * @param {Function(Object)=} errorCb Error callback with one argument: - * `httpResponse`. - * - * @return {<%- returnType %>} An empty reference that will be - * populated with the actual data once the response is returned - * from the server. - * -<% if (!action.returns || action.returns.length == 0) { -%> - * This method returns no data. -<% } else if (action.returns[0].root) { -%> -<% if (action.returns[0].description) { -%> - * <%- action.returns[0].description -.replace(/\n/g, '\n * ').trimRight() %> -<% } else { -%> - * - * (The remote method definition does not provide any description. - * This usually means the response is a `<%- modelName %>` object.) - * -<% } -%> -<% } else { -%> - * Data properties: -<% action.returns.forEach(function(arg) { -%> - * - * - `<%- arg.arg %>` – `{<%- getJsDocType(arg) %>}` - <%- -(arg.description || '').replace(/\n/g, '\n * ') %> -<% }); - } --%> - */ +<% ngdocForMethod(modelName, methodName, action); -%> <%-: methodName | q %>: { url: urlBase + <%-: action.getFullPath() | q %>, method: <%-: action.getHttpMethod() | q %>, @@ -214,6 +121,29 @@ postData.forEach(function(arg) { -%> <% } -%> } ); + +<% for (var scopeName in meta.scopes) { + var scope = meta.scopes[scopeName]; + if (!scope) continue; + var scopeMethods = scope.methods; + // Angular names always start with a capital letter + var targetClass = scope.targetClass[0].toUpperCase() + scope.targetClass.slice(1); + + // sort the names to make sure the get method creating the scope + // is emitted first (R.categories before R.categories.create) + Object.keys(scopeMethods).sort().forEach(function(methodName) { + var action = scopeMethods[methodName]; + ngdocForMethod(modelName, methodName, action, targetClass); +-%> + R.<%- methodName %> = function() { + var TargetResource = $injector.get(<%-: targetClass | q %>); + var action = TargetResource[<%-: action.name | q %>]; + return action.apply(R, arguments); + }; +<% }); // forEach methods name -%> +<% } // for each scope -%> + + return R; }]); <% } // for modelName in models -%> @@ -300,4 +230,114 @@ function getJsDocType(arg) { if (!arg.required) type += '='; return type; } + +function ngdocForMethod(modelName, methodName, action, responseModelName) { + // always add an empty line before the ngdoc comment: +-%> + +<% + if (action.internal) { +-%> + // INTERNAL. <%- action.internal %> +<% + return; + } +-%> + /** + * @ngdoc method + * @name lbServices.<%- modelName %>#<%- methodName %> + * @methodOf lbServices.<%- modelName %> +<% if (action.deprecated) { -%> + * @deprecated <%- action.deprecated %> +<% } -%> + * + * @description + * +<% if (!action.description) { +action.description = '\n' + + '(The remote method definition does not provide any description.)\n' + + ''; +} -%> + * <%-: action.description | replace:/\n/g, '\n * ' %> + * +<% +var params = action.accepts; +var postData; +if (action.getHttpMethod() == 'POST' || action.getHttpMethod() == 'PUT') { + params = params.filter(function(arg) { + return arg.http && (arg.http.source == 'query' || arg.http.source == 'path'); + }); + postData = action.accepts.filter(function(arg) { + return params.indexOf(arg) == -1; + }); +} -%> + * @param {Object=} parameters Request parameters. +<% if (params.length == 0) { -%> + * + * This method does not accept any parameters. + * Supply an empty object or omit this argument altogether. +<% } else { params.forEach(function(arg) { -%> + * + * - `<%- arg.arg %>` – `{<%- getJsDocType(arg) %>}` - <%- +(arg.description || '').replace(/\n/g, '\n * ') %> +<% }); } -%> +<% if (modelName === 'User' && methodName === 'login') { -%> + * + * - `rememberMe` - `boolean` - Whether the authentication credentials + * should be remembered in localStorage across app/browser restarts. + * Default: `true`. +<% } -%> +<% if (postData) { -%> + * + * @param {Object} postData Request data. +<% if (postData.length == 0) { -%> + * + * This method does not accept any data. Supply an empty object. +<% } else if (postData.length == 1 && postData[0].http && + postData[0].http.source == 'body') { -%> + * + * This method expects a subset of model properties as request parameters. +<% } else { +postData.forEach(function(arg) { -%> + * + * - `<%- arg.arg %>` – `{<%- getJsDocType(arg) %>}` - <%- +(arg.description || '').replace(/\n/g, '\n * ') %> +<% }); + } +} -%> + * +<% var returnType = action.isReturningArray() ? 'Array.': 'Object'; -%> + * @param {Function(<%- returnType %>, Object)=} successCb + * Success callback with two arguments: `value`, `responseHeaders`. + * + * @param {Function(Object)=} errorCb Error callback with one argument: + * `httpResponse`. + * + * @return {<%- returnType %>} An empty reference that will be + * populated with the actual data once the response is returned + * from the server. + * +<% if (!action.returns || action.returns.length == 0) { -%> + * This method returns no data. +<% } else if (action.returns[0].root) { -%> +<% if (action.returns[0].description) { -%> + * <%- action.returns[0].description +.replace(/\n/g, '\n * ').trimRight() %> +<% } else { -%> + * + * (The remote method definition does not provide any description. + * This usually means the response is a `<%- responseModelName || modelName %>` object.) + * +<% } -%> +<% } else { -%> + * Data properties: +<% action.returns.forEach(function(arg) { -%> + * + * - `<%- arg.arg %>` – `{<%- getJsDocType(arg) %>}` - <%- +(arg.description || '').replace(/\n/g, '\n * ') %> +<% }); + } +-%> + */ +<% } // end of ngdocForMethod -%> diff --git a/test.e2e/spec/services.spec.js b/test.e2e/spec/services.spec.js index cc740d7..5b5ba25 100644 --- a/test.e2e/spec/services.spec.js +++ b/test.e2e/spec/services.spec.js @@ -258,5 +258,122 @@ define(['angular', 'given', 'util'], function(angular, given, util) { }); } }); + + describe('for models with hasAndBelongsToMany relations', function() { + var $injector, Product, Category, testData; + before(function() { + return given.servicesForLoopBackApp( + { + models: { + Product: { + properties: { name: 'string' }, + options: { + relations: { + categories: { + model: 'Category', + type: 'hasAndBelongsToMany' + } + } + } + }, + Category: { + properties: { name: 'string' }, + options: { + relations: { + products: { + model: 'Product', + type: 'hasAndBelongsToMany' + } + } + } + } + }, + setupFn: (function(app, cb) { + /*globals debug:true */ + app.models.Product.create({ name: 'p1' }, function(err, prod) { + if (err) return cb(err); + debug('Created product', prod); + + prod.categories.create({ name: 'c1' }, function(err, cat) { + if (err) return cb(err); + debug('Created category', cat); + + prod.categories(true, function(err, list) { + if (err) return cb(err); + debug('Categories of product', list); + + cb(null, { + product: prod, + category: cat + }); + }); + }); + }); + }).toString() + }) + .then(function(createInjector) { + $injector = createInjector(); + Product = $injector.get('Product'); + Category = $injector.get('Category'); + testData = $injector.get('testData'); + }); + }); + + it('provides scope methods', function() { + expect(Object.keys(Product), 'Product properties') + .to.contain('categories'); + expect(Object.keys(Product.categories), 'Product.categories properties') + .to.have.members([ + 'create', + 'destroyAll' + ]); + }); + + it('gets related models with correct prototype', function() { + var list = Product.categories({ id: testData.product.id }); + return list.$promise.then(function() { + // eql does not work for arrays with objects correctly :( + expect(list).to.have.length(1); + expect(list[0]).to.eql(new Category(testData.category)); + }); + }); + + it('creates a related model', function() { + var cat = Product.categories.create( + { id: testData.product.id }, + { name: 'another-cat' }); + return cat.$promise + .then(function() { + expect(cat).to.be.an.instanceof(Category); + expect(cat).to.have.property('name', 'another-cat'); + }) + .then(function() { + var list = Product.categories({ id: testData.product.id }); + return list.$promise.then(function() { + var names = list.map(function(c) { return c.name; }); + expect(names).to.eql([testData.category.name, cat.name]); + }); + }); + }); + + // Skipped due to strongloop/loopback-datasource-juggler#95 + it.skip('removes all related models', function() { + return Product.categories.destroyAll({ id: testData.product.id }) + .$promise + .then(function() { + var list = Product.categories({ id: testData.product.id }); + return list.$promise.then(function() { + expect(list, 'product categories').to.have.length(0); + }); + }) + .then(function() { + var all = Product.find({ filter: true }); + return all.$promise + .then(function() { + expect(all, 'all categories').to.have.length(0); + }); + }); + }); + }); }); }); diff --git a/test.e2e/test-server.js b/test.e2e/test-server.js index ec91d17..4092aa8 100644 --- a/test.e2e/test-server.js +++ b/test.e2e/test-server.js @@ -7,6 +7,7 @@ a custom LoopBack instance and generate & access lb-services.js var express = require('express'); var loopback = require('loopback'); var generator = require('..'); +var extend = require('util')._extend; var port = process.env.PORT || 3838; var baseUrl; @@ -20,6 +21,10 @@ var servicesScript; // for tests using the built-in User model loopback.User.settings.saltWorkFactor = 4; +// Save the pre-build models so that we can restore them before every test +var initialModels = loopback.Model.modelBuilder.models; +var initialDefinitions = loopback.Model.modelBuilder.definitions; + // Enable all domains to access our server via AJAX // This way the script running in Karma page can // talk to our service running on a different host/port. @@ -42,13 +47,23 @@ Sample request } } // other model objects - } - */ + }, + setupFn: (function(app, cb) { + Customer.create( + { name: 'a-customer' }, + function(err, customer) { + if (err) return cb(err); + cb(null, { customer: customer }); + }); + }).toString() +} +*/ masterApp.post('/setup', function(req, res, next) { var opts = req.body; var name = opts.name; var models = opts.models; var enableAuth = opts.enableAuth; + var setupFn = compileSetupFn(name, opts.setupFn); if (!name) return next(new Error('"name" is a required parameter')); @@ -56,6 +71,10 @@ masterApp.post('/setup', function(req, res, next) { if (!models || typeof models !== 'object') return next(new Error('"models" must be a valid object')); + // hack: clear the static model registry populated by previous test apps + loopback.Model.modelBuilder.models = extend({}, initialModels); + loopback.Model.modelBuilder.definitions = extend({}, initialDefinitions); + lbApp = loopback(); lbApp.dataSource('db', { connector: 'memory', defaultForType: 'db' }); @@ -74,16 +93,37 @@ masterApp.post('/setup', function(req, res, next) { lbApp.set('restApiRoot', '/'); lbApp.installMiddleware(); - try { - servicesScript = generator.services(lbApp, name, apiUrl); - } catch (err) { - console.error('Cannot generate services script:', err.stack); - servicesScript = 'throw new Error("Error generating services script.");'; - } + setupFn(lbApp, function(err, data) { + if (err) { + console.error('app setup function failed', err); + res.send(500, err); + return; + } + + try { + servicesScript = generator.services(lbApp, name, apiUrl); + } catch (err) { + console.error('Cannot generate services script:', err.stack); + servicesScript = 'throw new Error("Error generating services script.");'; + } + + servicesScript += '\nmodule.value("testData", ' + + JSON.stringify(data, null, 2) + ');\n'; + + res.send(200, { servicesUrl: baseUrl + 'services?' + name }); + }.bind(this)); - res.send(200, { servicesUrl: baseUrl + 'services?' + name }); }); +function compileSetupFn(name, source) { + if (!source) + return function(app, cb) { cb(); }; + + var debug = require('debug')('test:' + name); + /*jshint evil:true */ + return eval('(' + source + ')'); +} + masterApp.get('/services', function(req, res, next) { res.set('Content-Type', 'application/javascript'); res.send(200, servicesScript);