Skip to content

Add support for "near" filtering, closes #41 #42

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Nov 16, 2015
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions mocha.start.js
Original file line number Diff line number Diff line change
@@ -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;
});
118 changes: 89 additions & 29 deletions src/index.js
Original file line number Diff line number Diff line change
@@ -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 === '|===') {
2 changes: 1 addition & 1 deletion test/create_trx.spec.js
Original file line number Diff line number Diff line change
@@ -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* () {
174 changes: 167 additions & 7 deletions test/findAll.spec.js
Original file line number Diff line number Diff line change
@@ -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: '[email protected]' });
var user1 = yield adapter.create(User, {name: 'John', profileId: profile1.id});
let profile1 = yield adapter.create(Profile, { email: '[email protected]' });
let user1 = yield adapter.create(User, {name: 'John', profileId: profile1.id});

var users = yield adapter.findAll(User, {'profile.email': '[email protected]'});
let users = yield adapter.findAll(User, {'profile.email': '[email protected]'});
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: '[email protected]' });
var user1 = yield adapter.create(User, {name: 'John', profileId: profile1.id});
let profile1 = yield adapter.create(Profile, { email: '[email protected]' });
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': '[email protected]'});
let users = yield adapter.findAll(User, {'user.id': user1.id, 'profile.email': '[email protected]'});
assert.equal(users.length, 1);
assert.equal(users[0].profileId, profile1.id);
});
});

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')
})

})
});
19 changes: 18 additions & 1 deletion test/setup.sql
Original file line number Diff line number Diff line change
@@ -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` (
8 changes: 4 additions & 4 deletions test/update_trx.spec.js
Original file line number Diff line number Diff line change
@@ -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});
});
});