From 6ff67b6bfc44effce62e5aa58f1a8156490d3f4c Mon Sep 17 00:00:00 2001 From: Diogo Doreto Date: Fri, 16 Oct 2015 01:55:34 -0300 Subject: [PATCH 1/9] First working prototype --- lib/sql.js | 66 +++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 63 insertions(+), 3 deletions(-) diff --git a/lib/sql.js b/lib/sql.js index 948afe1..2190cc1 100644 --- a/lib/sql.js +++ b/lib/sql.js @@ -229,7 +229,7 @@ SQLConnector.prototype.tableEscaped = function(model) { * @returns {String} The escaped column name */ SQLConnector.prototype.columnEscaped = function(model, property) { - return this.escapeName(this.column(model, property)); + return this.tableEscaped(model) + '.' + this.escapeName(this.column(model, property)); }; /*! @@ -737,11 +737,17 @@ SQLConnector.prototype._buildWhere = function(model, where) { return new ParameterizedSQL(''); } var self = this; - var props = self.getModelDefinition(model).properties; + var modelDef = self.getModelDefinition(model); + var props = modelDef.properties; + var relations = modelDef.model.relations; var whereStmts = []; for (var key in where) { var stmt = new ParameterizedSQL('', []); + if (relations && key in relations) { + // relationships are handled on joins + continue; + } // Handle and/or operators if (key === 'and' || key === 'or') { var branches = []; @@ -990,11 +996,30 @@ SQLConnector.prototype.buildSelect = function(model, filter, options) { } } - var selectStmt = new ParameterizedSQL('SELECT ' + + var haveRelationFilters = false; + if (filter.where) { + var relations = this.getModelDefinition(model).model.relations; + if (relations) { + for (var key in filter.where) { + if (key in relations) { + haveRelationFilters = true; + break; + } + } + } + } + var distinct = haveRelationFilters ? 'DISTINCT ' : ''; + + var selectStmt = new ParameterizedSQL('SELECT ' + distinct + this.buildColumnNames(model, filter) + ' FROM ' + this.tableEscaped(model) ); + if (haveRelationFilters) { + var joinsStmts = this.buildJoins(model, filter.where); + selectStmt.merge(joinsStmts); + } + if (filter) { if (filter.where) { @@ -1015,6 +1040,41 @@ SQLConnector.prototype.buildSelect = function(model, filter, options) { return this.parameterize(selectStmt); }; +SQLConnector.prototype.buildJoins = function(model, where) { + var modelDef = this.getModelDefinition(model); + var relations = modelDef.model.relations; + var stmt = new ParameterizedSQL('', []); + + for (var key in where) { + if (!(key in relations)) continue; + + var rel = relations[key]; + var keyFrom = rel.keyFrom; + var modelTo = rel.modelTo.definition.name; + var modelToEscaped = this.tableEscaped(modelTo); + var keyTo = rel.keyTo; + + var innerWhere = Object.assign({}, where[key]); + var innerIdField = {}; + innerIdField[keyTo] = true; + innerWhere.fields = Object.assign({}, innerWhere.fields, innerIdField); + + var condition = this.columnEscaped(model, keyFrom) + '=' + + this.columnEscaped(modelTo, keyTo); + + var innerSelect = this.buildSelect(modelTo, innerWhere); + + stmt + .merge('INNER JOIN (') + .merge(innerSelect) + .merge(') AS ' + modelToEscaped) + .merge('ON ' + condition); + + } + + return stmt; +}; + /** * Transform the row data into a model data object * @param {string} model Model name From f05443036ee18e2d9ec753df7c218f39b6069361 Mon Sep 17 00:00:00 2001 From: Diogo Doreto Date: Sat, 17 Oct 2015 23:42:46 -0300 Subject: [PATCH 2/9] Fix parameterize bug --- lib/sql.js | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/lib/sql.js b/lib/sql.js index 2190cc1..aa19aa8 100644 --- a/lib/sql.js +++ b/lib/sql.js @@ -989,6 +989,8 @@ SQLConnector.prototype.buildColumnNames = function(model, filter) { * @returns {ParameterizedSQL} Statement object {sql: ..., params: [...]} */ SQLConnector.prototype.buildSelect = function(model, filter, options) { + options = options || {}; + if (!filter.order) { var idNames = this.idNames(model); if (idNames && idNames.length) { @@ -1037,6 +1039,11 @@ SQLConnector.prototype.buildSelect = function(model, filter, options) { } } + + if (options.skipParameterize === true) { + return selectStmt; + } + return this.parameterize(selectStmt); }; @@ -1062,7 +1069,9 @@ SQLConnector.prototype.buildJoins = function(model, where) { var condition = this.columnEscaped(model, keyFrom) + '=' + this.columnEscaped(modelTo, keyTo); - var innerSelect = this.buildSelect(modelTo, innerWhere); + var innerSelect = this.buildSelect(modelTo, innerWhere, { + skipParameterize: true + }); stmt .merge('INNER JOIN (') From 12247a3cb308845f72e7f9b4deb4aa7b5ed19669 Mon Sep 17 00:00:00 2001 From: Diogo Doreto Date: Sat, 17 Oct 2015 23:44:07 -0300 Subject: [PATCH 3/9] Tests! --- test/sql.test.js | 111 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) diff --git a/test/sql.test.js b/test/sql.test.js index dd89e1e..f5fb121 100644 --- a/test/sql.test.js +++ b/test/sql.test.js @@ -10,6 +10,8 @@ var ds = new juggler.DataSource({ }); var connector; var Customer; +var Order; +var Store; describe('sql connector', function() { before(function() { @@ -35,6 +37,28 @@ describe('sql connector', function() { address: String }, {testdb: {table: 'CUSTOMER'}}); + Order = ds.createModel('order', + { + id: { + id: true + }, + date: Date + }); + Store = ds.createModel('store', + { + id: { + id: true, + type: String + }, + state: String + }); + // Relations + Customer.hasMany(Order, {as: 'orders', foreignKey: 'customer_name'}); + Order.belongsTo(Customer, {as: 'customer', foreignKey: 'customer_name'}); + Order.belongsTo(Store, {as: 'store', foreignKey: 'store_id'}); + Store.hasMany(Order, {as: 'orders', foreignKey: 'store_id'}); + Customer.belongsTo(Store, {as: 'favorite_store', foreignKey: 'favorite_store'}); + Store.hasMany(Customer, {as: 'customers_fav', foreignKey: 'favorite_store'}); }); it('should map table name', function() { @@ -306,6 +330,93 @@ describe('sql connector', function() { }); }); + it('builds INNER JOIN', function () { + var sql = connector.buildJoins('customer', {orders: {where: {id: 10}}}); + expect(sql.toJSON()).to.eql({ + sql: 'INNER JOIN ( SELECT `ORDER`.`CUSTOMER_NAME` FROM `ORDER` WHERE ' + + '`ORDER`.`ID`=? ORDER BY `ORDER`.`ID` ) AS `ORDER` ON ' + + '`CUSTOMER`.`NAME`=`ORDER`.`CUSTOMER_NAME`', + params: [10] + }); + }); + + it('builds SELECT with INNER JOIN', function () { + var sql = connector.buildSelect('customer', { + where: { + orders: { + where: { + date: {between: ['2015-01-01', '2015-01-31']} + } + } + } + }); + + expect(sql.toJSON()).to.eql({ + sql: 'SELECT DISTINCT `CUSTOMER`.`NAME`,`CUSTOMER`.`VIP`,' + + '`CUSTOMER`.`ADDRESS`,`CUSTOMER`.`FAVORITE_STORE` FROM `CUSTOMER` ' + + 'INNER JOIN ( SELECT `ORDER`.`CUSTOMER_NAME` FROM `ORDER` WHERE ' + + '`ORDER`.`DATE` BETWEEN $1 AND $2 ORDER BY `ORDER`.`ID` ) AS `ORDER` ' + + 'ON `CUSTOMER`.`NAME`=`ORDER`.`CUSTOMER_NAME` ORDER BY `CUSTOMER`.`NAME`', + params: ['2015-01-01', '2015-01-31'] + }); + }); + + it('builds SELECT with multiple INNER JOIN', function () { + var sql = connector.buildSelect('customer', { + where: { + orders: { + where: { + date: {between: ['2015-01-01', '2015-01-31']} + } + }, + /*jshint camelcase:false */ + favorite_store: { + where: { + state: 'NY' + } + } + } + }); + + expect(sql.toJSON()).to.eql({ + sql: 'SELECT DISTINCT `CUSTOMER`.`NAME`,`CUSTOMER`.`VIP`,' + + '`CUSTOMER`.`ADDRESS`,`CUSTOMER`.`FAVORITE_STORE` FROM `CUSTOMER` ' + + 'INNER JOIN ( SELECT `ORDER`.`CUSTOMER_NAME` FROM `ORDER` WHERE ' + + '`ORDER`.`DATE` BETWEEN $1 AND $2 ORDER BY `ORDER`.`ID` ) AS `ORDER` ON ' + + '`CUSTOMER`.`NAME`=`ORDER`.`CUSTOMER_NAME` INNER JOIN ( SELECT `STORE`.`ID` ' + + 'FROM `STORE` WHERE `STORE`.`STATE`=$3 ORDER BY `STORE`.`ID` ) AS `STORE` ' + + 'ON `CUSTOMER`.`FAVORITE_STORE`=`STORE`.`ID` ORDER BY `CUSTOMER`.`NAME`', + params: ['2015-01-01', '2015-01-31', 'NY'] + }); + }); + + it('builds nested SELECTs', function () { + var sql = connector.buildSelect('customer', { + where: { + orders: { + where: { + store: { + where: { + state: 'NY' + } + } + } + } + } + }); + + expect(sql.toJSON()).to.eql({ + sql: 'SELECT DISTINCT `CUSTOMER`.`NAME`,`CUSTOMER`.`VIP`,' + + '`CUSTOMER`.`ADDRESS`,`CUSTOMER`.`FAVORITE_STORE` FROM `CUSTOMER` ' + + 'INNER JOIN ( SELECT DISTINCT `ORDER`.`CUSTOMER_NAME` FROM `ORDER` ' + + 'INNER JOIN ( SELECT `STORE`.`ID` FROM `STORE` WHERE `STORE`.`STATE`=$1 ' + + 'ORDER BY `STORE`.`ID` ) AS `STORE` ON `ORDER`.`STORE_ID`=`STORE`.`ID` ' + + 'ORDER BY `ORDER`.`ID` ) AS `ORDER` ON `CUSTOMER`.`NAME`=`ORDER`.' + + '`CUSTOMER_NAME` ORDER BY `CUSTOMER`.`NAME`', + params: ['NY'] + }); + }); + it('normalizes a SQL statement from string', function() { var sql = 'SELECT * FROM `CUSTOMER`'; var stmt = new ParameterizedSQL(sql); From ad62d8cc32fb4323cb65c6936a1d3099092b2d19 Mon Sep 17 00:00:00 2001 From: Diogo Doreto Date: Wed, 11 Nov 2015 15:08:19 -0200 Subject: [PATCH 4/9] Fix existing tests to reflect the new `table`.`column` pattern --- test/sql.test.js | 59 +++++++++++++++++++++++++----------------------- 1 file changed, 31 insertions(+), 28 deletions(-) diff --git a/test/sql.test.js b/test/sql.test.js index f5fb121..9e0f3c2 100644 --- a/test/sql.test.js +++ b/test/sql.test.js @@ -102,7 +102,7 @@ describe('sql connector', function() { it('should find escaped column name', function() { var column = connector.columnEscaped('customer', 'vip'); - expect(column).to.eql('`VIP`'); + expect(column).to.eql('`CUSTOMER`.`VIP`'); }); it('should convert to escaped id column value', function() { @@ -113,7 +113,7 @@ describe('sql connector', function() { it('builds where', function() { var where = connector.buildWhere('customer', {name: 'John'}); expect(where.toJSON()).to.eql({ - sql: 'WHERE `NAME`=?', + sql: 'WHERE `CUSTOMER`.`NAME`=?', params: ['John'] }); }); @@ -121,7 +121,7 @@ describe('sql connector', function() { it('builds where with null', function() { var where = connector.buildWhere('customer', {name: null}); expect(where.toJSON()).to.eql({ - sql: 'WHERE `NAME` IS NULL', + sql: 'WHERE `CUSTOMER`.`NAME` IS NULL', params: [] }); }); @@ -129,7 +129,7 @@ describe('sql connector', function() { it('builds where with inq', function() { var where = connector.buildWhere('customer', {name: {inq: ['John', 'Mary']}}); expect(where.toJSON()).to.eql({ - sql: 'WHERE `NAME` IN (?,?)', + sql: 'WHERE `CUSTOMER`.`NAME` IN (?,?)', params: ['John', 'Mary'] }); }); @@ -138,7 +138,7 @@ describe('sql connector', function() { var where = connector.buildWhere('customer', {or: [{name: 'John'}, {name: 'Mary'}]}); expect(where.toJSON()).to.eql({ - sql: 'WHERE (`NAME`=?) OR (`NAME`=?)', + sql: 'WHERE (`CUSTOMER`.`NAME`=?) OR (`CUSTOMER`.`NAME`=?)', params: ['John', 'Mary'] }); }); @@ -147,7 +147,7 @@ describe('sql connector', function() { var where = connector.buildWhere('customer', {and: [{name: 'John'}, {vip: true}]}); expect(where.toJSON()).to.eql({ - sql: 'WHERE (`NAME`=?) AND (`VIP`=?)', + sql: 'WHERE (`CUSTOMER`.`NAME`=?) AND (`CUSTOMER`.`VIP`=?)', params: ['John', true] }); }); @@ -159,7 +159,7 @@ describe('sql connector', function() { } }); expect(where.toJSON()).to.eql({ - sql: 'WHERE `NAME` REGEXP ?', + sql: 'WHERE `CUSTOMER`.`NAME` REGEXP ?', params: ['^J'] }); }); @@ -171,7 +171,7 @@ describe('sql connector', function() { } }); expect(where.toJSON()).to.eql({ - sql: 'WHERE `NAME` REGEXP ?', + sql: 'WHERE `CUSTOMER`.`NAME` REGEXP ?', params: ['^J/i'] }); }); @@ -183,7 +183,7 @@ describe('sql connector', function() { } }); expect(where.toJSON()).to.eql({ - sql: 'WHERE `NAME` REGEXP ?', + sql: 'WHERE `CUSTOMER`.`NAME` REGEXP ?', params: [/^J/] }); }); @@ -195,7 +195,7 @@ describe('sql connector', function() { } }); expect(where.toJSON()).to.eql({ - sql: 'WHERE `NAME` REGEXP ?', + sql: 'WHERE `CUSTOMER`.`NAME` REGEXP ?', params: [/^J/i] }); }); @@ -207,7 +207,7 @@ describe('sql connector', function() { } }); expect(where.toJSON()).to.eql({ - sql: 'WHERE `NAME` REGEXP ?', + sql: 'WHERE `CUSTOMER`.`NAME` REGEXP ?', params: [/^J/] }); }); @@ -219,7 +219,7 @@ describe('sql connector', function() { } }); expect(where.toJSON()).to.eql({ - sql: 'WHERE `NAME` REGEXP ?', + sql: 'WHERE `CUSTOMER`.`NAME` REGEXP ?', params: [new RegExp(/^J/i)] }); }); @@ -228,30 +228,31 @@ describe('sql connector', function() { var where = connector.buildWhere('customer', {and: [{name: 'John'}, {or: [{vip: true}, {address: null}]}]}); expect(where.toJSON()).to.eql({ - sql: 'WHERE (`NAME`=?) AND ((`VIP`=?) OR (`ADDRESS` IS NULL))', + sql: 'WHERE (`CUSTOMER`.`NAME`=?) AND ((`CUSTOMER`.`VIP`=?) OR ' + + '(`CUSTOMER`.`ADDRESS` IS NULL))', params: ['John', true] }); }); it('builds order by with one field', function() { var orderBy = connector.buildOrderBy('customer', 'name'); - expect(orderBy).to.eql('ORDER BY `NAME`'); + expect(orderBy).to.eql('ORDER BY `CUSTOMER`.`NAME`'); }); it('builds order by with two fields', function() { var orderBy = connector.buildOrderBy('customer', ['name', 'vip']); - expect(orderBy).to.eql('ORDER BY `NAME`,`VIP`'); + expect(orderBy).to.eql('ORDER BY `CUSTOMER`.`NAME`,`CUSTOMER`.`VIP`'); }); it('builds order by with two fields and dirs', function() { var orderBy = connector.buildOrderBy('customer', ['name ASC', 'vip DESC']); - expect(orderBy).to.eql('ORDER BY `NAME` ASC,`VIP` DESC'); + expect(orderBy).to.eql('ORDER BY `CUSTOMER`.`NAME` ASC,`CUSTOMER`.`VIP` DESC'); }); it('builds fields for columns', function() { var fields = connector.buildFields('customer', {name: 'John', vip: true, unknown: 'Random'}); - expect(fields.names).to.eql(['`NAME`', '`VIP`']); + expect(fields.names).to.eql(['`CUSTOMER`.`NAME`', '`CUSTOMER`.`VIP`']); expect(fields.columnValues[0].toJSON()).to.eql( {sql: '?', params: ['John']}); expect(fields.columnValues[1].toJSON()).to.eql( @@ -262,7 +263,7 @@ describe('sql connector', function() { var fields = connector.buildFieldsForUpdate('customer', {name: 'John', vip: true}); expect(fields.toJSON()).to.eql({ - sql: 'SET `VIP`=?', + sql: 'SET `CUSTOMER`.`VIP`=?', params: [true] }); }); @@ -271,35 +272,36 @@ describe('sql connector', function() { var fields = connector.buildFieldsForUpdate('customer', {name: 'John', vip: true}, false); expect(fields.toJSON()).to.eql({ - sql: 'SET `NAME`=?,`VIP`=?', + sql: 'SET `CUSTOMER`.`NAME`=?,`CUSTOMER`.`VIP`=?', params: ['John', true] }); }); it('builds column names for SELECT', function() { var cols = connector.buildColumnNames('customer'); - expect(cols).to.eql('`NAME`,`VIP`,`ADDRESS`'); + expect(cols).to.eql('`CUSTOMER`.`NAME`,`CUSTOMER`.`VIP`,' + + '`CUSTOMER`.`ADDRESS`,`CUSTOMER`.`FAVORITE_STORE`'); }); it('builds column names with true fields filter for SELECT', function() { var cols = connector.buildColumnNames('customer', {fields: {name: true}}); - expect(cols).to.eql('`NAME`'); + expect(cols).to.eql('`CUSTOMER`.`NAME`'); }); it('builds column names with false fields filter for SELECT', function() { var cols = connector.buildColumnNames('customer', {fields: {name: false}}); - expect(cols).to.eql('`VIP`,`ADDRESS`'); + expect(cols).to.eql('`CUSTOMER`.`VIP`,`CUSTOMER`.`ADDRESS`,`CUSTOMER`.`FAVORITE_STORE`'); }); it('builds column names with array fields filter for SELECT', function() { var cols = connector.buildColumnNames('customer', {fields: ['name']}); - expect(cols).to.eql('`NAME`'); + expect(cols).to.eql('`CUSTOMER`.`NAME`'); }); it('builds DELETE', function() { var sql = connector.buildDelete('customer', {name: 'John'}); expect(sql.toJSON()).to.eql({ - sql: 'DELETE FROM `CUSTOMER` WHERE `NAME`=$1', + sql: 'DELETE FROM `CUSTOMER` WHERE `CUSTOMER`.`NAME`=$1', params: ['John'] }); }); @@ -307,7 +309,7 @@ describe('sql connector', function() { it('builds UPDATE', function() { var sql = connector.buildUpdate('customer', {name: 'John'}, {vip: false}); expect(sql.toJSON()).to.eql({ - sql: 'UPDATE `CUSTOMER` SET `VIP`=$1 WHERE `NAME`=$2', + sql: 'UPDATE `CUSTOMER` SET `CUSTOMER`.`VIP`=$1 WHERE `CUSTOMER`.`NAME`=$2', params: [false, 'John'] }); }); @@ -316,8 +318,9 @@ describe('sql connector', function() { var sql = connector.buildSelect('customer', {order: 'name', limit: 5, where: {name: 'John'}}); expect(sql.toJSON()).to.eql({ - sql: 'SELECT `NAME`,`VIP`,`ADDRESS` FROM `CUSTOMER`' + - ' WHERE `NAME`=$1 ORDER BY `NAME` LIMIT 5', + sql: 'SELECT `CUSTOMER`.`NAME`,`CUSTOMER`.`VIP`,`CUSTOMER`.`ADDRESS`,' + + '`CUSTOMER`.`FAVORITE_STORE` FROM `CUSTOMER` WHERE `CUSTOMER`.`NAME`=$1 ' + + 'ORDER BY `CUSTOMER`.`NAME` LIMIT 5', params: ['John'] }); }); @@ -325,7 +328,7 @@ describe('sql connector', function() { it('builds INSERT', function() { var sql = connector.buildInsert('customer', {name: 'John', vip: true}); expect(sql.toJSON()).to.eql({ - sql: 'INSERT INTO `CUSTOMER`(`NAME`,`VIP`) VALUES($1,$2)', + sql: 'INSERT INTO `CUSTOMER`(`CUSTOMER`.`NAME`,`CUSTOMER`.`VIP`) VALUES($1,$2)', params: ['John', true] }); }); From 7ac15dae0ccda31335f4bf0b5a40f1f56c7e9f41 Mon Sep 17 00:00:00 2001 From: Diogo Doreto Date: Wed, 11 Nov 2015 15:38:52 -0200 Subject: [PATCH 5/9] New SQLConnector.prototype.buildCount with support to querying relations Extracted SQLConnector.prototype.buildCount from SQLConnector.prototype.count to better test the built SQL. --- lib/sql.js | 45 +++++++++++++++++++++++++++++++++++++++++---- test/sql.test.js | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 4 deletions(-) diff --git a/lib/sql.js b/lib/sql.js index aa19aa8..f1bb9a0 100644 --- a/lib/sql.js +++ b/lib/sql.js @@ -1185,6 +1185,45 @@ SQLConnector.prototype.find = function(model, id, options, cb) { // Alias to `find`. Juggler checks `findById` only. Connector.defineAliases(SQLConnector.prototype, 'find', ['findById']); +/** + * Build a SQL SELECT statement to count rows + * @param {String} model Model name + * @param {Object} where Where object + * @param {Object} options Options object + * @returns {ParameterizedSQL} Statement object {sql: ..., params: [...]} + */ +SQLConnector.prototype.buildCount = function(model, where, options) { + var haveRelationFilters = false; + if (where) { + var relations = this.getModelDefinition(model).model.relations; + if (relations) { + for (var key in where) { + if (key in relations) { + haveRelationFilters = true; + break; + } + } + } + } + + var count = 'count(*)'; + if (haveRelationFilters) { + var idColumn = this.columnEscaped(model, this.idColumn(model)); + count = 'count(DISTINCT ' + idColumn + ')'; + } + + var stmt = new ParameterizedSQL('SELECT ' + count + + ' as "cnt" FROM ' + this.tableEscaped(model)); + + if (haveRelationFilters) { + var joinsStmts = this.buildJoins(model, where); + stmt = stmt.merge(joinsStmts); + } + + stmt = stmt.merge(this.buildWhere(model, where)); + return this.parameterize(stmt); +}; + /** * Count all model instances by the where filter * @@ -1202,10 +1241,8 @@ SQLConnector.prototype.count = function(model, where, options, cb) { where = tmp; } - var stmt = new ParameterizedSQL('SELECT count(*) as "cnt" FROM ' + - this.tableEscaped(model)); - stmt = stmt.merge(this.buildWhere(model, where)); - stmt = this.parameterize(stmt); + var stmt = this.buildCount(model, where, options); + this.execute(stmt.sql, stmt.params, function(err, res) { if (err) { diff --git a/test/sql.test.js b/test/sql.test.js index 9e0f3c2..b0186cc 100644 --- a/test/sql.test.js +++ b/test/sql.test.js @@ -420,6 +420,40 @@ describe('sql connector', function() { }); }); + it('builds count', function() { + var sql = connector.buildCount('customer'); + expect(sql.toJSON()).to.eql({ + sql: 'SELECT count(*) as "cnt" FROM `CUSTOMER` ', + params: [] + }); + }); + + it('builds count with WHERE', function() { + var sql = connector.buildCount('customer', {name: 'John'}); + expect(sql.toJSON()).to.eql({ + sql: 'SELECT count(*) as "cnt" FROM `CUSTOMER` WHERE `CUSTOMER`.`NAME`=$1', + params: ['John'] + }); + }); + + it('builds count with WHERE and JOIN', function() { + var sql = connector.buildCount('customer', { + name: 'John', + orders: { + where: { + date: {between: ['2015-01-01', '2015-01-31']} + } + } + }); + expect(sql.toJSON()).to.eql({ + sql: 'SELECT count(DISTINCT `CUSTOMER`.`NAME`) as "cnt" FROM `CUSTOMER` ' + + 'INNER JOIN ( SELECT `ORDER`.`CUSTOMER_NAME` FROM `ORDER` WHERE ' + + '`ORDER`.`DATE` BETWEEN $1 AND $2 ORDER BY `ORDER`.`ID` ) AS `ORDER` ' + + 'ON `CUSTOMER`.`NAME`=`ORDER`.`CUSTOMER_NAME` WHERE `CUSTOMER`.`NAME`=$3', + params: ['2015-01-01', '2015-01-31', 'John'] + }); + }); + it('normalizes a SQL statement from string', function() { var sql = 'SELECT * FROM `CUSTOMER`'; var stmt = new ParameterizedSQL(sql); From 68264d59abf480cc316b74e805ff9108b1a6cd82 Mon Sep 17 00:00:00 2001 From: Diogo Doreto Date: Wed, 11 Nov 2015 15:47:26 -0200 Subject: [PATCH 6/9] Add documentation to SQLConnector.prototype.buildJoins --- lib/sql.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/lib/sql.js b/lib/sql.js index f1bb9a0..ceb94dc 100644 --- a/lib/sql.js +++ b/lib/sql.js @@ -1047,6 +1047,12 @@ SQLConnector.prototype.buildSelect = function(model, filter, options) { return this.parameterize(selectStmt); }; +/** + * Build the SQL INNER JOIN clauses + * @param {string} model Model name + * @param {object} where An object for the where conditions + * @returns {ParameterizedSQL} The SQL INNER JOIN clauses + */ SQLConnector.prototype.buildJoins = function(model, where) { var modelDef = this.getModelDefinition(model); var relations = modelDef.model.relations; From ec0a990a2b4552509170f0dbc02c486846732c3a Mon Sep 17 00:00:00 2001 From: Diogo Doreto Date: Fri, 13 Nov 2015 10:08:03 -0200 Subject: [PATCH 7/9] Add compatibility with Node v0.12 Node v0.12 doesn't have the Object.assign function --- lib/sql.js | 5 +++-- lib/utils.js | 18 ++++++++++++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) create mode 100644 lib/utils.js diff --git a/lib/sql.js b/lib/sql.js index ceb94dc..23f576b 100644 --- a/lib/sql.js +++ b/lib/sql.js @@ -5,6 +5,7 @@ var Connector = require('./connector'); var debug = require('debug')('loopback:connector:sql'); var ParameterizedSQL = require('./parameterized-sql'); var Transaction = require('./transaction'); +var assign = require('./utils').assign; module.exports = SQLConnector; @@ -1067,10 +1068,10 @@ SQLConnector.prototype.buildJoins = function(model, where) { var modelToEscaped = this.tableEscaped(modelTo); var keyTo = rel.keyTo; - var innerWhere = Object.assign({}, where[key]); + var innerWhere = assign({}, where[key]); var innerIdField = {}; innerIdField[keyTo] = true; - innerWhere.fields = Object.assign({}, innerWhere.fields, innerIdField); + innerWhere.fields = assign({}, innerWhere.fields, innerIdField); var condition = this.columnEscaped(model, keyFrom) + '=' + this.columnEscaped(modelTo, keyTo); diff --git a/lib/utils.js b/lib/utils.js new file mode 100644 index 0000000..37e0e57 --- /dev/null +++ b/lib/utils.js @@ -0,0 +1,18 @@ +var _hasOwnProp = Object.prototype.hasOwnProperty; + +/** + * Object.assign polyfill + */ +var assign = Object.assign || function(target) { + for (var i = 1; i < arguments.length; i++) { + var source = arguments[i]; + for (var key in source) { + if (_hasOwnProp.call(source, key)) { + target[key] = source[key]; + } + } + } + return target; +}; + +exports.assign = assign; From aa396620fa3f734833d7bbdbb9c37abe5a5a578d Mon Sep 17 00:00:00 2001 From: Diogo Doreto Date: Fri, 13 Nov 2015 11:13:29 -0200 Subject: [PATCH 8/9] Allow sorting on relations' columns --- lib/sql.js | 20 +++++++++++++++++--- test/sql.test.js | 23 +++++++++++++++++++++++ 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/lib/sql.js b/lib/sql.js index 23f576b..d3492f9 100644 --- a/lib/sql.js +++ b/lib/sql.js @@ -863,10 +863,24 @@ SQLConnector.prototype.buildOrderBy = function(model, order) { var clauses = []; for (var i = 0, n = order.length; i < n; i++) { var t = order[i].split(/[\s,]+/); - if (t.length === 1) { - clauses.push(self.columnEscaped(model, order[i])); + var colName; + if (t[0].indexOf('.') < 0) { + colName = self.columnEscaped(model, t[0]); } else { - clauses.push(self.columnEscaped(model, t[0]) + ' ' + t[1]); + // Column name is in the format: relationName.columnName + var colSplit = t[0].split('.'); + // Find the name of the relation's model ... + var modelDef = this.getModelDefinition(model); + var relation = modelDef.model.relations[colSplit[0]]; + var colModel = relation.modelTo.definition.name; + // ... and escape them + colName = self.columnEscaped(colModel, colSplit[1]); + } + + if (t.length === 1) { + clauses.push(colName); + } else { + clauses.push(colName + ' ' + t[1]); } } return 'ORDER BY ' + clauses.join(','); diff --git a/test/sql.test.js b/test/sql.test.js index b0186cc..e6859d4 100644 --- a/test/sql.test.js +++ b/test/sql.test.js @@ -364,6 +364,29 @@ describe('sql connector', function() { }); }); + it('builds SELECT with INNER JOIN and order by relation columns', function () { + var sql = connector.buildSelect('order', { + where: { + customer: { + fields: { + 'name': true, + 'vip': true + } + } + }, + order: ['customer.vip DESC', 'customer.name ASC'] + }); + + expect(sql.toJSON()).to.eql({ + sql: 'SELECT DISTINCT `ORDER`.`ID`,`ORDER`.`DATE`,`ORDER`.`CUSTOMER_NAME`,' + + '`ORDER`.`STORE_ID` FROM `ORDER` INNER JOIN ( SELECT `CUSTOMER`.`NAME`,' + + '`CUSTOMER`.`VIP` FROM `CUSTOMER` ORDER BY `CUSTOMER`.`NAME` ) AS `CUSTOMER`' + + ' ON `ORDER`.`CUSTOMER_NAME`=`CUSTOMER`.`NAME` ORDER BY ' + + '`CUSTOMER`.`VIP` DESC,`CUSTOMER`.`NAME` ASC', + params: [] + }); + }); + it('builds SELECT with multiple INNER JOIN', function () { var sql = connector.buildSelect('customer', { where: { From 7b305b4920c5f988b8a33518521d0cbe1f826650 Mon Sep 17 00:00:00 2001 From: Diogo Doreto Date: Sun, 22 Nov 2015 21:44:14 -0200 Subject: [PATCH 9/9] Add support to n:m relations --- lib/sql.js | 54 ++++++++++++++++++++++++++++++++---------------- test/sql.test.js | 30 ++++++++++++++++++++++++++- 2 files changed, 65 insertions(+), 19 deletions(-) diff --git a/lib/sql.js b/lib/sql.js index d3492f9..e7c1c06 100644 --- a/lib/sql.js +++ b/lib/sql.js @@ -1073,33 +1073,51 @@ SQLConnector.prototype.buildJoins = function(model, where) { var relations = modelDef.model.relations; var stmt = new ParameterizedSQL('', []); + var buildOneToMany = function buildOneToMany(modelFrom, keyFrom, modelTo, keyTo, filter) { + var modelToEscaped = this.tableEscaped(modelTo); + var innerFilter = assign({}, filter); + var innerIdField = {}; + innerIdField[keyTo] = true; + innerFilter.fields = assign({}, innerFilter.fields, innerIdField); + + var condition = this.columnEscaped(modelFrom, keyFrom) + '=' + + this.columnEscaped(modelTo, keyTo); + + var innerSelect = this.buildSelect(modelTo, innerFilter, { + skipParameterize: true + }); + + return new ParameterizedSQL('INNER JOIN (', []) + .merge(innerSelect) + .merge(') AS ' + modelToEscaped) + .merge('ON ' + condition); + }.bind(this); + for (var key in where) { if (!(key in relations)) continue; var rel = relations[key]; var keyFrom = rel.keyFrom; var modelTo = rel.modelTo.definition.name; - var modelToEscaped = this.tableEscaped(modelTo); var keyTo = rel.keyTo; - var innerWhere = assign({}, where[key]); - var innerIdField = {}; - innerIdField[keyTo] = true; - innerWhere.fields = assign({}, innerWhere.fields, innerIdField); - - var condition = this.columnEscaped(model, keyFrom) + '=' + - this.columnEscaped(modelTo, keyTo); - - var innerSelect = this.buildSelect(modelTo, innerWhere, { - skipParameterize: true - }); - - stmt - .merge('INNER JOIN (') - .merge(innerSelect) - .merge(') AS ' + modelToEscaped) - .merge('ON ' + condition); + var join; + if (!rel.modelThrough) { + // 1:n relation + join = buildOneToMany(model, keyFrom, modelTo, keyTo, where[key]); + } else { + // n:m relation + var modelThrough = rel.modelThrough.definition.name; + var keyThrough = rel.keyThrough; + var modelToKey = rel.modelTo.definition._ids[0].name; + var innerFilter = {fields: {}}; + innerFilter.fields[keyThrough] = true; + var joinInner = buildOneToMany(model, keyFrom, modelThrough, keyTo, innerFilter); + join = buildOneToMany(modelThrough, keyThrough, modelTo, modelToKey, where[key]); + join = joinInner.merge(join); + } + stmt.merge(join); } return stmt; diff --git a/test/sql.test.js b/test/sql.test.js index e6859d4..a7268b2 100644 --- a/test/sql.test.js +++ b/test/sql.test.js @@ -57,6 +57,12 @@ describe('sql connector', function() { Order.belongsTo(Customer, {as: 'customer', foreignKey: 'customer_name'}); Order.belongsTo(Store, {as: 'store', foreignKey: 'store_id'}); Store.hasMany(Order, {as: 'orders', foreignKey: 'store_id'}); + Store.hasMany(Customer, { + as: 'customers', + through: Order, + foreignKey: 'store_id', + keyThrough: 'customer_name' + }); Customer.belongsTo(Store, {as: 'favorite_store', foreignKey: 'favorite_store'}); Store.hasMany(Customer, {as: 'customers_fav', foreignKey: 'favorite_store'}); }); @@ -343,7 +349,7 @@ describe('sql connector', function() { }); }); - it('builds SELECT with INNER JOIN', function () { + it('builds SELECT with INNER JOIN (1:n relation)', function () { var sql = connector.buildSelect('customer', { where: { orders: { @@ -364,6 +370,28 @@ describe('sql connector', function() { }); }); + it('builds SELECT with INNER JOIN (n:n relation)', function () { + var sql = connector.buildSelect('store', { + where: { + customers: { + where: { + vip: true + } + } + } + }); + + expect(sql.toJSON()).to.eql({ + sql: 'SELECT DISTINCT `STORE`.`ID`,`STORE`.`STATE` FROM `STORE` INNER JOIN' + + ' ( SELECT `ORDER`.`CUSTOMER_NAME`,`ORDER`.`STORE_ID` FROM `ORDER` ' + + 'ORDER BY `ORDER`.`ID` ) AS `ORDER` ON `STORE`.`ID`=`ORDER`.`STORE_ID` ' + + 'INNER JOIN ( SELECT `CUSTOMER`.`NAME` FROM `CUSTOMER` WHERE ' + + '`CUSTOMER`.`VIP`=$1 ORDER BY `CUSTOMER`.`NAME` ) AS `CUSTOMER` ON ' + + '`ORDER`.`CUSTOMER_NAME`=`CUSTOMER`.`NAME` ORDER BY `STORE`.`ID`', + params: [true] + }); + }); + it('builds SELECT with INNER JOIN and order by relation columns', function () { var sql = connector.buildSelect('order', { where: {