Skip to content

Commit f4e0c7b

Browse files
committed
Support querying through a hasMany relation. Fixes #63
1 parent 8751f4f commit f4e0c7b

File tree

2 files changed

+192
-163
lines changed

2 files changed

+192
-163
lines changed

src/index.js

Lines changed: 167 additions & 162 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,63 @@ function getTable (resourceConfig) {
2020
return resourceConfig.table || underscore(resourceConfig.name)
2121
}
2222

23+
function processRelationField (resourceConfig, query, field, criteria, options, joinedTables) {
24+
let fieldParts = field.split('.')
25+
let localResourceConfig = resourceConfig
26+
let relationPath = []
27+
let relationName = null;
28+
29+
while (fieldParts.length >= 2) {
30+
relationName = fieldParts.shift()
31+
let [relation] = localResourceConfig.relationList.filter(r => r.relation === relationName || r.localField === relationName)
32+
33+
if (relation) {
34+
let relationResourceConfig = resourceConfig.getResource(relation.relation)
35+
relationPath.push(relation.relation)
36+
37+
if (relation.type === 'belongsTo' || relation.type === 'hasOne') {
38+
// Apply table join for belongsTo/hasOne property (if not done already)
39+
if (!joinedTables.some(t => t === relationPath.join('.'))) {
40+
let table = getTable(localResourceConfig)
41+
let localId = `${table}.${relation.localKey}`
42+
43+
let relationTable = getTable(relationResourceConfig)
44+
let foreignId = `${relationTable}.${relationResourceConfig.idAttribute}`
45+
46+
query.join(relationTable, localId, foreignId)
47+
joinedTables.push(relationPath.join('.'))
48+
}
49+
} else if (relation.type === 'hasMany') {
50+
// Perform `WHERE EXISTS` subquery for hasMany property
51+
let existsParams = {
52+
[`${relationName}.${fieldParts.splice(0).join('.')}`]: criteria // remaining field(s) handled by EXISTS subquery
53+
};
54+
let subQueryTable = getTable(relationResourceConfig);
55+
let subQueryOptions = deepMixIn({ query: knex(this.defaults).select(`${subQueryTable}.*`).from(subQueryTable) }, options)
56+
let subQuery = this.filterQuery(relationResourceConfig, existsParams, subQueryOptions)
57+
.whereRaw('??.??=??.??', [
58+
getTable(relationResourceConfig),
59+
relation.foreignKey,
60+
getTable(localResourceConfig),
61+
localResourceConfig.idAttribute
62+
])
63+
if (Object.keys(criteria).some(k => k.indexOf('|') > -1)) {
64+
query.orWhereExists(subQuery);
65+
} else {
66+
query.whereExists(subQuery);
67+
}
68+
}
69+
70+
localResourceConfig = relationResourceConfig
71+
} else {
72+
// hopefully a qualified local column
73+
}
74+
}
75+
relationName = fieldParts.shift();
76+
77+
return relationName ? `${getTable(localResourceConfig)}.${relationName}` : null;
78+
}
79+
2380
function loadWithRelations (items, resourceConfig, options) {
2481
let tasks = []
2582
let instance = Array.isArray(items) ? null : items
@@ -297,179 +354,127 @@ class DSSqlAdapter {
297354
}
298355
}
299356

300-
let processRelationField = (field) => {
301-
let parts = field.split('.')
302-
let localResourceConfig = resourceConfig
303-
let relationPath = []
304-
305-
while (parts.length >= 2) {
306-
let relationName = parts.shift()
307-
let [relation] = localResourceConfig.relationList.filter(r => r.relation === relationName || r.localField === relationName)
308-
309-
if (relation) {
310-
let relationResourceConfig = resourceConfig.getResource(relation.relation)
311-
relationPath.push(relation.relation)
312-
313-
if (relation.type === 'belongsTo' || relation.type === 'hasOne') {
314-
// Apply table join for belongsTo/hasOne property (if not done already)
315-
if (!joinedTables.some(t => t === relationPath.join('.'))) {
316-
let table = getTable(localResourceConfig)
317-
let localId = `${table}.${relation.localKey}`
318-
319-
let relationTable = getTable(relationResourceConfig)
320-
let foreignId = `${relationTable}.${relationResourceConfig.idAttribute}`
321-
322-
query.join(relationTable, localId, foreignId)
323-
joinedTables.push(relationPath.join('.'))
324-
}
325-
} else if (relation.type === 'hasMany') {
326-
// Perform `WHERE EXISTS` subquery for hasMany property
327-
let existsParams = {
328-
[parts[0]]: criteria
329-
};
330-
let subQuery = this.filterQuery(relationResourceConfig, existsParams, options)
331-
.whereRaw('??.??=??.??', [
332-
getTable(relationResourceConfig),
333-
relation.foreignKey,
334-
getTable(localResourceConfig),
335-
localResourceConfig.idAttribute
336-
])
337-
if (Object.keys(criteria).some(k => k.indexOf('|') > -1)) {
338-
query.orWhereExists(subQuery);
339-
} else {
340-
query.whereExists(subQuery);
341-
}
342-
criteria = null; // criteria handled by EXISTS subquery
343-
}
344-
345-
localResourceConfig = relationResourceConfig
346-
} else {
347-
// hopefully a qualified local column
348-
}
349-
}
350-
351-
return `${getTable(localResourceConfig)}.${parts[0]}`
352-
}
353-
354357
if (contains(field, '.')) {
355358
if (contains(field, ',')) {
356359
let splitFields = field.split(',').map(c => c.trim())
357-
field = splitFields.map(splitField => processRelationField(splitField)).join(',')
360+
field = splitFields.map(splitField => processRelationField.call(this, resourceConfig, query, splitField, criteria, options, joinedTables)).join(',')
358361
} else {
359-
field = processRelationField(field, query, resourceConfig, joinedTables)
362+
field = processRelationField.call(this, resourceConfig, query, field, criteria, options, joinedTables)
360363
}
361364
}
362365

363-
forOwn(criteria, (v, op) => {
364-
if (op in (this.queryOperators || {})) {
365-
// Custom or overridden operator
366-
query = this.queryOperators[op](query, field, v)
367-
} else {
368-
// Builtin operators
369-
if (op === '==' || op === '===') {
370-
if (v === null) {
371-
query = query.whereNull(field)
372-
} else {
373-
query = query.where(field, v)
374-
}
375-
} else if (op === '!=' || op === '!==') {
376-
if (v === null) {
377-
query = query.whereNotNull(field)
378-
} else {
379-
query = query.where(field, '!=', v)
380-
}
381-
} else if (op === '>') {
382-
query = query.where(field, '>', v)
383-
} else if (op === '>=') {
384-
query = query.where(field, '>=', v)
385-
} else if (op === '<') {
386-
query = query.where(field, '<', v)
387-
} else if (op === '<=') {
388-
query = query.where(field, '<=', v)
389-
// } else if (op === 'isectEmpty') {
390-
// subQuery = subQuery ? subQuery.and(row(field).default([]).setIntersection(r.expr(v).default([])).count().eq(0)) : row(field).default([]).setIntersection(r.expr(v).default([])).count().eq(0)
391-
// } else if (op === 'isectNotEmpty') {
392-
// subQuery = subQuery ? subQuery.and(row(field).default([]).setIntersection(r.expr(v).default([])).count().ne(0)) : row(field).default([]).setIntersection(r.expr(v).default([])).count().ne(0)
393-
} else if (op === 'in') {
394-
query = query.where(field, 'in', v)
395-
} else if (op === 'notIn') {
396-
query = query.whereNotIn(field, v)
397-
} else if (op === 'near') {
398-
const milesRegex = /(\d+(\.\d+)?)\s*(m|M)iles$/
399-
const kilometersRegex = /(\d+(\.\d+)?)\s*(k|K)$/
400-
401-
let radius
402-
let unitsPerDegree
403-
if (typeof v.radius === 'number' || milesRegex.test(v.radius)) {
404-
radius = typeof v.radius === 'number' ? v.radius : v.radius.match(milesRegex)[1]
405-
unitsPerDegree = 69.0 // miles per degree
406-
} else if (kilometersRegex.test(v.radius)) {
407-
radius = v.radius.match(kilometersRegex)[1]
408-
unitsPerDegree = 111.045 // kilometers per degree;
409-
} else {
410-
throw new Error('Unknown radius distance units')
411-
}
366+
if (field) {
367+
forOwn(criteria, (v, op) => {
368+
if (op in (this.queryOperators || {})) {
369+
// Custom or overridden operator
370+
query = this.queryOperators[op](query, field, v)
371+
} else {
372+
// Builtin operators
373+
if (op === '==' || op === '===') {
374+
if (v === null) {
375+
query = query.whereNull(field)
376+
} else {
377+
query = query.where(field, v)
378+
}
379+
} else if (op === '!=' || op === '!==') {
380+
if (v === null) {
381+
query = query.whereNotNull(field)
382+
} else {
383+
query = query.where(field, '!=', v)
384+
}
385+
} else if (op === '>') {
386+
query = query.where(field, '>', v)
387+
} else if (op === '>=') {
388+
query = query.where(field, '>=', v)
389+
} else if (op === '<') {
390+
query = query.where(field, '<', v)
391+
} else if (op === '<=') {
392+
query = query.where(field, '<=', v)
393+
// } else if (op === 'isectEmpty') {
394+
// subQuery = subQuery ? subQuery.and(row(field).default([]).setIntersection(r.expr(v).default([])).count().eq(0)) : row(field).default([]).setIntersection(r.expr(v).default([])).count().eq(0)
395+
// } else if (op === 'isectNotEmpty') {
396+
// subQuery = subQuery ? subQuery.and(row(field).default([]).setIntersection(r.expr(v).default([])).count().ne(0)) : row(field).default([]).setIntersection(r.expr(v).default([])).count().ne(0)
397+
} else if (op === 'in') {
398+
query = query.where(field, 'in', v)
399+
} else if (op === 'notIn') {
400+
query = query.whereNotIn(field, v)
401+
} else if (op === 'near') {
402+
const milesRegex = /(\d+(\.\d+)?)\s*(m|M)iles$/
403+
const kilometersRegex = /(\d+(\.\d+)?)\s*(k|K)$/
404+
405+
let radius
406+
let unitsPerDegree
407+
if (typeof v.radius === 'number' || milesRegex.test(v.radius)) {
408+
radius = typeof v.radius === 'number' ? v.radius : v.radius.match(milesRegex)[1]
409+
unitsPerDegree = 69.0 // miles per degree
410+
} else if (kilometersRegex.test(v.radius)) {
411+
radius = v.radius.match(kilometersRegex)[1]
412+
unitsPerDegree = 111.045 // kilometers per degree;
413+
} else {
414+
throw new Error('Unknown radius distance units')
415+
}
412416

413-
let [latitudeColumn, longitudeColumn] = field.split(',').map(c => c.trim())
414-
let [latitude, longitude] = v.center
415-
416-
// Uses indexes on `latitudeColumn` / `longitudeColumn` if available
417-
query = query
418-
.whereBetween(latitudeColumn, [
419-
latitude - (radius / unitsPerDegree),
420-
latitude + (radius / unitsPerDegree)
421-
])
422-
.whereBetween(longitudeColumn, [
423-
longitude - (radius / (unitsPerDegree * Math.cos(latitude * (Math.PI / 180)))),
424-
longitude + (radius / (unitsPerDegree * Math.cos(latitude * (Math.PI / 180))))
425-
])
426-
427-
if (v.calculateDistance) {
428-
let distanceColumn = (typeof v.calculateDistance === 'string') ? v.calculateDistance : 'distance'
429-
query = query.select(knex.raw(`
430-
${unitsPerDegree} * DEGREES(ACOS(
431-
COS(RADIANS(?)) * COS(RADIANS(${latitudeColumn})) *
432-
COS(RADIANS(${longitudeColumn}) - RADIANS(?)) +
433-
SIN(RADIANS(?)) * SIN(RADIANS(${latitudeColumn}))
434-
)) AS ${distanceColumn}`, [latitude, longitude, latitude]))
435-
}
436-
} else if (op === 'like') {
437-
query = query.where(field, 'like', v)
438-
} else if (op === '|like') {
439-
query = query.orWhere(field, 'like', v)
440-
} else if (op === '|==' || op === '|===') {
441-
if (v === null) {
442-
query = query.orWhereNull(field)
443-
} else {
444-
query = query.orWhere(field, v)
445-
}
446-
} else if (op === '|!=' || op === '|!==') {
447-
if (v === null) {
448-
query = query.orWhereNotNull(field)
417+
let [latitudeColumn, longitudeColumn] = field.split(',').map(c => c.trim())
418+
let [latitude, longitude] = v.center
419+
420+
// Uses indexes on `latitudeColumn` / `longitudeColumn` if available
421+
query = query
422+
.whereBetween(latitudeColumn, [
423+
latitude - (radius / unitsPerDegree),
424+
latitude + (radius / unitsPerDegree)
425+
])
426+
.whereBetween(longitudeColumn, [
427+
longitude - (radius / (unitsPerDegree * Math.cos(latitude * (Math.PI / 180)))),
428+
longitude + (radius / (unitsPerDegree * Math.cos(latitude * (Math.PI / 180))))
429+
])
430+
431+
if (v.calculateDistance) {
432+
let distanceColumn = (typeof v.calculateDistance === 'string') ? v.calculateDistance : 'distance'
433+
query = query.select(knex.raw(`
434+
${unitsPerDegree} * DEGREES(ACOS(
435+
COS(RADIANS(?)) * COS(RADIANS(${latitudeColumn})) *
436+
COS(RADIANS(${longitudeColumn}) - RADIANS(?)) +
437+
SIN(RADIANS(?)) * SIN(RADIANS(${latitudeColumn}))
438+
)) AS ${distanceColumn}`, [latitude, longitude, latitude]))
439+
}
440+
} else if (op === 'like') {
441+
query = query.where(field, 'like', v)
442+
} else if (op === '|like') {
443+
query = query.orWhere(field, 'like', v)
444+
} else if (op === '|==' || op === '|===') {
445+
if (v === null) {
446+
query = query.orWhereNull(field)
447+
} else {
448+
query = query.orWhere(field, v)
449+
}
450+
} else if (op === '|!=' || op === '|!==') {
451+
if (v === null) {
452+
query = query.orWhereNotNull(field)
453+
} else {
454+
query = query.orWhere(field, '!=', v)
455+
}
456+
} else if (op === '|>') {
457+
query = query.orWhere(field, '>', v)
458+
} else if (op === '|>=') {
459+
query = query.orWhere(field, '>=', v)
460+
} else if (op === '|<') {
461+
query = query.orWhere(field, '<', v)
462+
} else if (op === '|<=') {
463+
query = query.orWhere(field, '<=', v)
464+
// } else if (op === '|isectEmpty') {
465+
// subQuery = subQuery ? subQuery.or(row(field).default([]).setIntersection(r.expr(v).default([])).count().eq(0)) : row(field).default([]).setIntersection(r.expr(v).default([])).count().eq(0)
466+
// } else if (op === '|isectNotEmpty') {
467+
// subQuery = subQuery ? subQuery.or(row(field).default([]).setIntersection(r.expr(v).default([])).count().ne(0)) : row(field).default([]).setIntersection(r.expr(v).default([])).count().ne(0)
468+
} else if (op === '|in') {
469+
query = query.orWhere(field, 'in', v)
470+
} else if (op === '|notIn') {
471+
query = query.orWhereNotIn(field, v)
449472
} else {
450-
query = query.orWhere(field, '!=', v)
473+
throw new Error('Operator not found')
451474
}
452-
} else if (op === '|>') {
453-
query = query.orWhere(field, '>', v)
454-
} else if (op === '|>=') {
455-
query = query.orWhere(field, '>=', v)
456-
} else if (op === '|<') {
457-
query = query.orWhere(field, '<', v)
458-
} else if (op === '|<=') {
459-
query = query.orWhere(field, '<=', v)
460-
// } else if (op === '|isectEmpty') {
461-
// subQuery = subQuery ? subQuery.or(row(field).default([]).setIntersection(r.expr(v).default([])).count().eq(0)) : row(field).default([]).setIntersection(r.expr(v).default([])).count().eq(0)
462-
// } else if (op === '|isectNotEmpty') {
463-
// subQuery = subQuery ? subQuery.or(row(field).default([]).setIntersection(r.expr(v).default([])).count().ne(0)) : row(field).default([]).setIntersection(r.expr(v).default([])).count().ne(0)
464-
} else if (op === '|in') {
465-
query = query.orWhere(field, 'in', v)
466-
} else if (op === '|notIn') {
467-
query = query.orWhereNotIn(field, v)
468-
} else {
469-
throw new Error('Operator not found')
470475
}
471-
}
472-
})
476+
})
477+
}
473478
})
474479
}
475480

test/findAll.spec.js

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,31 @@ describe('DSSqlAdapter#findAll', function () {
8585
assert.equal(users[0].name, 'Jason');
8686
assert.equal(users[1].name, 'Sean');
8787
});
88-
88+
89+
it("should filter through a hasMany relation", function* () {
90+
let user1 = yield adapter.create(User, {name: 'Sean'});
91+
let user2 = yield adapter.create(User, {name: 'Jason'});
92+
let user3 = yield adapter.create(User, {name: 'Ed'});
93+
94+
let post1 = yield adapter.create(Post, {userId: user1.id, content: 'post1'});
95+
let comment1_1 = yield adapter.create(Comment, {userId: user1.id, postId: post1.id, content: 'comment1_1'});
96+
let comment1_2 = yield adapter.create(Comment, {userId: user2.id, postId: post1.id, content: 'comment1_2'});
97+
let comment1_3 = yield adapter.create(Comment, {userId: user3.id, postId: post1.id, content: 'comment1_3'});
98+
99+
let post2 = yield adapter.create(Post, {userId: user1.id, content: 'post2'});
100+
let comment2_1 = yield adapter.create(Comment, {userId: user2.id, postId: post2.id, content: 'comment1_2'});
101+
let comment2_2 = yield adapter.create(Comment, {userId: user3.id, postId: post2.id, content: 'comment1_3'});
102+
103+
let post3 = yield adapter.create(Post, {userId: user1.id, content: 'post3'});
104+
let comment3_1 = yield adapter.create(Comment, {userId: user1.id, postId: post3.id, content: 'comment1_1'});
105+
let comment3_3 = yield adapter.create(Comment, {userId: user3.id, postId: post3.id, content: 'comment1_3'});
106+
107+
let posts = yield adapter.findAll(Post, {where: {'comments.user.name': {'==': 'Sean'} }, orderBy: 'content'});
108+
assert.equal(posts.length, 2);
109+
assert.equal(posts[0].content, 'post1');
110+
assert.equal(posts[1].content, 'post3');
111+
});
112+
89113
describe('near', function () {
90114
beforeEach(function * () {
91115
this.googleAddress = yield adapter.create(Address, { name : 'Google', latitude: 37.4219999, longitude: -122.0862515 });

0 commit comments

Comments
 (0)