diff --git a/mocha.start.js b/mocha.start.js index 362df27..9c42f3f 100644 --- a/mocha.start.js +++ b/mocha.start.js @@ -50,6 +50,7 @@ beforeEach(function () { globals.store = global.store = this.$$store; globals.User = global.User = this.$$User; globals.Profile = global.Profile = this.$$Profile; + globals.Address = global.Address = this.$$Address; globals.Post = global.Post = this.$$Post; globals.Comment = global.Comment = this.$$Comment; }); diff --git a/src/index.js b/src/index.js index 37df697..efff30b 100644 --- a/src/index.js +++ b/src/index.js @@ -18,6 +18,50 @@ function getTable (resourceConfig) { return resourceConfig.table || underscore(resourceConfig.name) } +/** + * Lookup and apply table joins to query if field contains a `.` + * @param {string} field - Field defined in where filter + * @param {object} query - knex query to modify + * @param {object} resourceConfig - Resource of primary query/table + * @param {string[]} existingJoins - Array of fully qualitifed field names for + * any existing table joins for query + * @returns {string} - field updated to perspective of applied joins + */ +function applyTableJoins (field, query, resourceConfig, existingJoins) { + if (DSUtils.contains(field, '.')) { + let parts = field.split('.') + let localResourceConfig = resourceConfig + + let relationPath = [] + while (parts.length >= 2) { + let relationName = parts.shift() + let relationResourceConfig = resourceConfig.getResource(relationName) + relationPath.push(relationName) + + if (!existingJoins.some(t => t === relationPath.join('.'))) { + let [relation] = localResourceConfig.relationList.filter(r => r.relation === relationName) + if (relation) { + let table = getTable(localResourceConfig) + let localId = `${table}.${relation.localKey}` + + let relationTable = getTable(relationResourceConfig) + let foreignId = `${relationTable}.${relationResourceConfig.idAttribute}` + + query.join(relationTable, localId, foreignId) + existingJoins.push(relationPath.join('.')) + } else { + // hopefully a qualified local column + } + } + localResourceConfig = relationResourceConfig + } + + field = `${getTable(localResourceConfig)}.${parts[0]}` + } + + return field; +} + function loadWithRelations (items, resourceConfig, options) { let tasks = [] let instance = Array.isArray(items) ? null : items @@ -283,35 +327,12 @@ class DSSqlAdapter { } DSUtils.forOwn(criteria, (v, op) => { - if (DSUtils.contains(field, '.')) { - let parts = field.split('.') - let localResourceConfig = resourceConfig - - let relationPath = [] - while (parts.length >= 2) { - let relationName = parts.shift() - let relationResourceConfig = resourceConfig.getResource(relationName) - relationPath.push(relationName) - - if (!joinedTables.some(t => t === relationPath.join('.'))) { - let [relation] = localResourceConfig.relationList.filter(r => r.relation === relationName) - if (relation) { - let table = getTable(localResourceConfig) - let localId = `${table}.${relation.localKey}` - - let relationTable = getTable(relationResourceConfig) - let foreignId = `${relationTable}.${relationResourceConfig.idAttribute}` - - query = query.join(relationTable, localId, foreignId) - joinedTables.push(relationPath.join('.')) - } else { - // local column - } - } - localResourceConfig = relationResourceConfig - } - - field = `${getTable(localResourceConfig)}.${parts[0]}` + // Apply table joins (if needed) + if (DSUtils.contains(field, ',')) { + let splitFields = field.split(',').map(c => c.trim()) + field = splitFields.map(splitField => applyTableJoins(splitField, query, resourceConfig, joinedTables)).join(','); + } else { + field = applyTableJoins(field, query, resourceConfig, joinedTables); } if (op === '==' || op === '===') { @@ -334,6 +355,45 @@ class DSSqlAdapter { query = query.where(field, 'in', v) } else if (op === 'notIn') { query = query.whereNotIn(field, v) + } else if (op === 'near') { + const milesRegex = /(\d+(\.\d+)?)\s*(m|M)iles$/; + const kilometersRegex = /(\d+(\.\d+)?)\s*(k|K)$/; + + let radius; + let unitsPerDegree; + if (typeof v.radius === 'number' || milesRegex.test(v.radius)) { + radius = typeof v.radius === 'number' ? v.radius : v.radius.match(milesRegex)[1] + unitsPerDegree = 69.0; // miles per degree + } else if (kilometersRegex.test(v.radius)) { + radius = v.radius.match(kilometersRegex)[1] + unitsPerDegree = 111.045; // kilometers per degree; + } else { + throw new Error('Unknown radius distance units') + } + + let [latitudeColumn, longitudeColumn] = field.split(',').map(c => c.trim()) + let [latitude, longitude] = v.center; + + // Uses indexes on `latitudeColumn` / `longitudeColumn` if available + query = query + .whereBetween(latitudeColumn, [ + latitude - (radius / unitsPerDegree), + latitude + (radius / unitsPerDegree) + ]) + .whereBetween(longitudeColumn, [ + longitude - (radius / (unitsPerDegree * Math.cos(latitude * (Math.PI / 180)))), + longitude + (radius / (unitsPerDegree * Math.cos(latitude * (Math.PI / 180)))) + ]) + + if (v.calculateDistance) { + let distanceColumn = (typeof v.calculateDistance === 'string') ? v.calculateDistance : 'distance' + query = query.select(knex.raw(` + ${unitsPerDegree} * DEGREES(ACOS( + COS(RADIANS(?)) * COS(RADIANS(${latitudeColumn})) * + COS(RADIANS(${longitudeColumn}) - RADIANS(?)) + + SIN(RADIANS(?)) * SIN(RADIANS(${latitudeColumn})) + )) AS ${distanceColumn}`, [latitude, longitude, latitude])) + } } else if (op === 'like') { query = query.where(field, 'like', v) } else if (op === '|==' || op === '|===') { diff --git a/test/create_trx.spec.js b/test/create_trx.spec.js index 7808b79..957dedd 100644 --- a/test/create_trx.spec.js +++ b/test/create_trx.spec.js @@ -13,7 +13,7 @@ describe('DSSqlAdapter#create + transaction', function () { assert.isObject(findUser, 'user committed to database'); assert.equal(findUser.name, 'Jane'); assert.isDefined(findUser.id); - assert.equalObjects(findUser, {id: id, name: 'Jane', age: null, profileId: null}); + assert.equalObjects(findUser, {id: id, name: 'Jane', age: null, profileId: null, addressId: null}); }); it('rollback should not persist created user in a sql db', function* () { diff --git a/test/findAll.spec.js b/test/findAll.spec.js index 58882a1..7e68253 100644 --- a/test/findAll.spec.js +++ b/test/findAll.spec.js @@ -1,21 +1,181 @@ +'use strict'; + describe('DSSqlAdapter#findAll', function () { it('should not return relation columns on parent', function* () { - var profile1 = yield adapter.create(Profile, { email: 'foo@test.com' }); - var user1 = yield adapter.create(User, {name: 'John', profileId: profile1.id}); + let profile1 = yield adapter.create(Profile, { email: 'foo@test.com' }); + let user1 = yield adapter.create(User, {name: 'John', profileId: profile1.id}); - var users = yield adapter.findAll(User, {'profile.email': 'foo@test.com'}); + let users = yield adapter.findAll(User, {'profile.email': 'foo@test.com'}); assert.equal(users.length, 1); assert.equal(users[0].profileId, profile1.id); assert.isUndefined(users[0].email); }); it('should filter when relations have same column if column is qualified', function* () { - var profile1 = yield adapter.create(Profile, { email: 'foo@test.com' }); - var user1 = yield adapter.create(User, {name: 'John', profileId: profile1.id}); + let profile1 = yield adapter.create(Profile, { email: 'foo@test.com' }); + let user1 = yield adapter.create(User, {name: 'John', profileId: profile1.id}); // `id` column must be qualified with `user.` - var users = yield adapter.findAll(User, {'user.id': user1.id, 'profile.email': 'foo@test.com'}); + let users = yield adapter.findAll(User, {'user.id': user1.id, 'profile.email': 'foo@test.com'}); assert.equal(users.length, 1); assert.equal(users[0].profileId, profile1.id); }); -}); \ No newline at end of file + + describe('near', function () { + beforeEach(function * () { + this.googleAddress = yield adapter.create(Address, { name : 'Google', latitude: 37.4219999, longitude: -122.0862515 }); + this.appleAddress = yield adapter.create(Address, { name : 'Apple', latitude: 37.331852, longitude: -122.029599 }); + this.microsoftAddress = yield adapter.create(Address, { name : 'Microsoft', latitude: 47.639649, longitude: -122.128255 }); + this.amazonAddress = yield adapter.create(Address, { name : 'Amazon', latitude: 47.622915, longitude: -122.336384 }); + }) + + it('should filter using "near"', function* () { + let addresses = yield adapter.findAll(Address, { + where: { + 'latitude,longitude': { + 'near': { + center: [37.41, -122.06], + radius: 10 + } + } + } + }); + assert.equal(addresses.length, 2); + assert.equal(addresses[0].name, 'Google'); + assert.equal(addresses[1].name, 'Apple'); + }) + + it('should not contain distance column by default', function* () { + let addresses = yield adapter.findAll(Address, { + where: { + 'latitude,longitude': { + 'near': { + center: [37.41, -122.06], + radius: 5 + } + } + } + }); + assert.equal(addresses.length, 1); + assert.equal(addresses[0].name, 'Google'); + assert.equal(addresses[0].distance, undefined); + }) + + it('should contain distance column if "calculateDistance" is truthy', function* () { + let addresses = yield adapter.findAll(Address, { + where: { + 'latitude,longitude': { + 'near': { + center: [37.41, -122.06], + radius: 10, + calculateDistance: true + } + } + } + }); + assert.equal(addresses.length, 2); + + assert.equal(addresses[0].name, 'Google'); + assert.isNotNull(addresses[0].distance); + assert.equal(Math.round(addresses[0].distance), 2); + + assert.equal(addresses[1].name, 'Apple'); + assert.isNotNull(addresses[1].distance); + assert.equal(Math.round(addresses[1].distance), 6); + }) + + it('should contain custom distance column if "calculateDistance" is string', function* () { + let addresses = yield adapter.findAll(Address, { + where: { + 'latitude,longitude': { + 'near': { + center: [37.41, -122.06], + radius: 10, + calculateDistance: 'howfar' + } + } + } + }); + assert.equal(addresses.length, 2); + + assert.equal(addresses[0].name, 'Google'); + assert.equal(addresses[0].distance, undefined); + assert.isNotNull(addresses[0].howfar); + assert.equal(Math.round(addresses[0].howfar), 2); + + assert.equal(addresses[1].name, 'Apple'); + assert.equal(addresses[1].distance, undefined); + assert.isNotNull(addresses[1].howfar); + assert.equal(Math.round(addresses[1].howfar), 6); + }) + + it('should use kilometers instead of miles if radius ends with "k"', function* () { + let addresses = yield adapter.findAll(Address, { + where: { + 'latitude,longitude': { + 'near': { + center: [37.41, -122.06], + radius: '10k', + calculateDistance: true + } + } + } + }); + assert.equal(addresses.length, 2); + + assert.equal(addresses[0].name, 'Google'); + assert.isNotNull(addresses[0].distance); + assert.equal(Math.round(addresses[0].distance), 3); // in kilometers + + assert.equal(addresses[1].name, 'Apple'); + assert.isNotNull(addresses[1].distance); + assert.equal(Math.round(addresses[1].distance), 9); // in kilometers + }) + + it('should filter through relationships', function* () { + let user1 = yield adapter.create(User, { name : 'Larry Page', addressId: this.googleAddress.id }); + let user2 = yield adapter.create(User, { name : 'Tim Cook', addressId: this.appleAddress.id }); + + let users = yield adapter.findAll(User, { + where: { + 'address.latitude, address.longitude': { + 'near': { + center: [37.41, -122.06], + radius: 10, + calculateDistance: 'howfar' + } + } + } + }); + assert.equal(users.length, 2); + assert.equal(users[0].name, 'Larry Page'); + assert.equal(users[1].name, 'Tim Cook'); + }) + + it('should filter through multiple hasOne/belongsTo relations', function * () { + let user1 = yield adapter.create(User, { name : 'Larry Page', addressId: this.googleAddress.id }); + var post1 = yield adapter.create(Post, {content: 'foo', userId: user1.id}) + yield adapter.create(Comment, {content: 'test1', postId: post1.id, userId: post1.userId}) + + var user2 = yield adapter.create(User, {name: 'Tim Cook', addressId: this.appleAddress.id}) + var post2 = yield adapter.create(Post, {content: 'bar', userId: user2.id}) + yield adapter.create(Comment, {content: 'test2', postId: post2.id, userId: post2.userId}) + + let comments = yield adapter.findAll(Comment, { + where: { + 'user.address.latitude, user.address.longitude': { + 'near': { + center: [37.41, -122.06], + radius: 5 + } + } + } + }); + + assert.equal(comments.length, 1) + assert.equal(comments[0].userId, user1.id) + assert.equal(comments[0].content, 'test1') + }) + + }) +}); diff --git a/test/setup.sql b/test/setup.sql index cbf6ef3..51b6003 100644 --- a/test/setup.sql +++ b/test/setup.sql @@ -1,17 +1,34 @@ +DROP TABLE IF EXISTS `comment`; +DROP TABLE IF EXISTS `post`; +DROP TABLE IF EXISTS `user`; +DROP TABLE IF EXISTS `address`; +DROP TABLE IF EXISTS `profile`; + CREATE TABLE `profile` ( `id` int(11) unsigned NOT NULL AUTO_INCREMENT, `email` varchar(255) NOT NULL DEFAULT '', PRIMARY KEY (`id`) ) ENGINE=InnoDB AUTO_INCREMENT=45 DEFAULT CHARSET=latin1; +CREATE TABLE `address` ( + `id` int(11) unsigned NOT NULL AUTO_INCREMENT, + `name` varchar(255) NOT NULL DEFAULT '', + `latitude` Decimal(10,7) DEFAULT NULL, + `longitude` Decimal(10,7) DEFAULT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB AUTO_INCREMENT=45 DEFAULT CHARSET=latin1; + CREATE TABLE `user` ( `id` int(11) unsigned NOT NULL AUTO_INCREMENT, `name` varchar(255) NOT NULL DEFAULT '', `age` int(11) unsigned DEFAULT NULL, `profileId` int(11) unsigned DEFAULT NULL, + `addressId` int(11) unsigned DEFAULT NULL, PRIMARY KEY (`id`), KEY `fk-user-profile` (`profileId`), - CONSTRAINT `fk-user-profile` FOREIGN KEY (`profileId`) REFERENCES `profile` (`id`) ON DELETE NO ACTION ON UPDATE NO ACTION + KEY `fk-user-address` (`addressId`), + CONSTRAINT `fk-user-profile` FOREIGN KEY (`profileId`) REFERENCES `profile` (`id`) ON DELETE NO ACTION ON UPDATE NO ACTION, + CONSTRAINT `fk-user-address` FOREIGN KEY (`addressId`) REFERENCES `address` (`id`) ON DELETE NO ACTION ON UPDATE NO ACTION ) ENGINE=InnoDB AUTO_INCREMENT=45 DEFAULT CHARSET=latin1; CREATE TABLE `post` ( diff --git a/test/update_trx.spec.js b/test/update_trx.spec.js index fe16b0c..db25567 100644 --- a/test/update_trx.spec.js +++ b/test/update_trx.spec.js @@ -9,13 +9,13 @@ describe('DSSqlAdapter#update + transaction', function () { var updatedUser = yield adapter.update(User, id, {name: 'Johnny'}, {transaction: trx}); assert.equal(updatedUser.name, 'Johnny'); assert.isDefined(updatedUser.id); - assert.equalObjects(updatedUser, {id: id, name: 'Johnny', age: null, profileId: null}); + assert.equalObjects(updatedUser, {id: id, name: 'Johnny', age: null, profileId: null, addressId: null}); })); var foundUser = yield adapter.find(User, id); assert.equal(foundUser.name, 'Johnny'); assert.isDefined(foundUser.id); - assert.equalObjects(foundUser, {id: id, name: 'Johnny', age: null, profileId: null}); + assert.equalObjects(foundUser, {id: id, name: 'Johnny', age: null, profileId: null, addressId: null}); }); it('rollback should not update a user in a Sql db', function* () { @@ -29,7 +29,7 @@ describe('DSSqlAdapter#update + transaction', function () { var updatedUser = yield adapter.update(User, id, {name: 'Johnny'}, {transaction: trx}); assert.equal(updatedUser.name, 'Johnny'); assert.isDefined(updatedUser.id); - assert.equalObjects(updatedUser, {id: id, name: 'Johnny', age: null, profileId: null}); + assert.equalObjects(updatedUser, {id: id, name: 'Johnny', age: null, profileId: null, addressId: null}); throw new Error('rollback'); })); @@ -40,6 +40,6 @@ describe('DSSqlAdapter#update + transaction', function () { var foundUser = yield adapter.find(User, id); assert.equal(foundUser.name, 'John'); assert.isDefined(foundUser.id); - assert.equalObjects(foundUser, {id: id, name: 'John', age: null, profileId: null}); + assert.equalObjects(foundUser, {id: id, name: 'John', age: null, profileId: null, addressId: null}); }); });