Skip to content
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

Feature generic query builder #71

Closed
wants to merge 21 commits into from
Closed
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
b530258
Install deps for tape test runner
danpaz Oct 25, 2016
7bf8fac
Introduce QueryBuilder as generic api for building query clauses
danpaz Oct 25, 2016
01b6ca8
WIP on query builder
msanguineti Oct 25, 2016
d6f174d
Combine aggregation and filter builders into QueryBuilder so we can n…
danpaz Oct 25, 2016
ded95da
Merge branch 'feature-generic-query-builder' of https://github.com/da…
msanguineti Oct 27, 2016
24e7ec8
fixed recurring nested queries issue
msanguineti Oct 27, 2016
1112908
ops... forgot a 'console.log()' statement... my bad
msanguineti Oct 27, 2016
c1ff27e
Merge pull request #72 from msanguineti/feature-generic-query-builder
danpaz Oct 27, 2016
aea60de
Improve object merge with Object.assign and dont overwrite field
danpaz Oct 27, 2016
56d6afb
Remove commented lines
danpaz Oct 27, 2016
79a88d8
Use _.find to get clause
danpaz Oct 27, 2016
27fa0bd
split into individual builders
johannes-scharlach Nov 6, 2016
48b7ee6
fix wrongly named variables
johannes-scharlach Nov 6, 2016
fca9377
add has convenience methods
johannes-scharlach Nov 7, 2016
6d64f96
add script for new testing strategy
johannes-scharlach Nov 7, 2016
85e5703
Merge pull request #77 from ShareIQ/feature-generic-builders
danpaz Nov 11, 2016
6c70156
Split builders into separate files
danpaz Nov 11, 2016
f980dc9
Add build method
danpaz Nov 11, 2016
695a1e9
Add build v2
danpaz Nov 11, 2016
a51fc62
Add size, from, sort, rawOption apis
danpaz Nov 11, 2016
916ab47
Remove node 0.10 and 0.12 from travis
danpaz Nov 11, 2016
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
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,14 @@
"babel-core": "6.5.1",
"babel-plugin-lodash": "2.0.1",
"babel-preset-es2015": "6.5.0",
"babel-register": "6.18.0",
"babel-tape-runner": "2.0.1",
"chai": "3.2.0",
"documentation": "4.0.0-beta10",
"eslint": "1.10.2",
"mocha": "2.4.5",
"tap-spec": "4.1.1",
"tape": "4.6.2",
"webpack": "1.12.13"
},
"dependencies": {
Expand Down
186 changes: 186 additions & 0 deletions src/query-builder.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
import _ from 'lodash'
import {boolMerge} from './utils'

/**
* QueryBuilder class to build query clauses.
*
* @private
*/
export default class QueryBuilder {

constructor() {
this._queries = {}
this._filters = {}
this._aggs = {}
}

get queries() {
return _.cloneDeep(this._queries)
}

/**
* Apply a query of a given type providing all the necessary arguments,
* passing these arguments directly to the specified query builder. Merges
* existing query(s) with the new query.
*
* @param {String} boolType Name of the query type.
* @param {...args} args Arguments passed to query builder.
* @returns {QueryBuilder} Builder class.
*/
_makeQuery(boolType, ...args) {
let nested = _.last(args)

let newQuery = this._buildQuery(...args)

if (_.isFunction(nested)) {
let clause = newQuery[_.findKey(newQuery)]
let builder = new QueryBuilder()
let recursiveResult = nested(builder)
if (!_.isEmpty(recursiveResult._aggs)) {clause.aggs = recursiveResult._aggs}
if (!_.isEmpty(recursiveResult._queries)) {clause.query = recursiveResult._queries}
if (!_.isEmpty(recursiveResult._filters)) {clause.filter = recursiveResult._filters}

return newQuery
}

return boolMerge(newQuery, this._queries, boolType)
}

_buildQuery(type, field, value, opts) {
let clause = {}

if (field && value && opts) {
clause = _.merge({[field]: value}, opts)
} else if (field && value) {
clause = {[field]: value}
} else if (field) {
clause = {field}
}

return {[type]: clause}
}

_makeFilter(boolType, ...args) {
let nested = _.last(args)

let newFilter = this._buildFilter(...args)

if (_.isFunction(nested)) {
let clause = newFilter[_.findKey(newFilter)]
let builder = new QueryBuilder()
let recursiveResult = nested(builder)

if (!_.isEmpty(recursiveResult._aggs)) {clause.aggs = recursiveResult._aggs}
if (!_.isEmpty(recursiveResult._queries)) {clause.query = recursiveResult._queries}
if (!_.isEmpty(recursiveResult._filters)) {clause.filter = recursiveResult._filters}

return newFilter
}

return boolMerge(newFilter, this._filters, boolType)
}

_buildFilter(type, field, value, opts) {
let clause = {}

if (field && value && opts) {
clause = _.merge({[field]: value}, opts)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also here the Object.assign({}, opts, {[field]: value}) approach?

} else if (field && value) {
clause = {[field]: value}
} else if (field) {
clause = {field}
}

return {[type]: clause}
}

_makeAggregation(...args) {
let nested = _.last(args)

let newAggregation = this._buildAggregation(...args)

if (_.isFunction(nested)) {
let clause = newAggregation[_.findKey(newAggregation)]
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

btw: This is the same as

const clause = _.find(newAggregation)

let builder = new QueryBuilder()
let recursiveResult = nested(builder)
if (!_.isEmpty(recursiveResult._aggs)) {clause.aggs = recursiveResult._aggs}
if (!_.isEmpty(recursiveResult._queries)) {clause.query = recursiveResult._queries}
if (!_.isEmpty(recursiveResult._filters)) {clause.filter = recursiveResult._filters}
return newAggregation
}

return newAggregation
}

_buildAggregation(type, field, name, opts) {
if (_.isObject(name)) {
let tmp = opts
opts = name
name = tmp
}

name = name || `agg_${type}_${field}`

return {
[name]: {
[type]: (() => _.assign({field}, opts))()
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

in this case field can be overwritten by opts. Would rather expect

Object.assign({}, opts, {field})

(I also think I understood that _.assign is deprecated in favour of Object.assign, but I might be mistaken)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@johannes-scharlach (No, you are correct. It is.)

}
}
}

query(...args) {
this._queries = this._makeQuery('and', ...args)
return this
}

andQuery(...args) {
this._queries = this._makeQuery('and', ...args)
return this
}

addQuery(...args) {
this._queries = this._makeQuery('and', ...args)
return this
}

orQuery(...args) {
this._queries = this._makeQuery('or', ...args)
return this
}

notQuery(...args) {
this._queries = this._makeQuery('not', ...args)
return this
}

filter(...args) {
this._filters = this._makeFilter('and', ...args)
return this
}

andFilter(...args) {
this._filters = this._makeFilter('and', ...args)
return this
}

orFilter(...args) {
this._filters = this._makeFilter('or', ...args)
return this
}

notFilter(...args) {
this._filters = this._makeFilter('not', ...args)
return this
}

aggregation(...args) {
this._aggs = this._makeAggregation(...args)
return this
}

agg(...args) {
this._aggs = this.aggregation(...args)
return this
}

}
131 changes: 131 additions & 0 deletions test/query-builder.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import test from 'tape'
import QueryBuilder from '../src/query-builder'

test('QueryBuilder should build query with no field', (t) => {
t.plan(1)

const result = new QueryBuilder().query('match_all')

t.deepEqual(result._queries, {
match_all: {}
})
})

test('QueryBuilder should build query with field but no value', (t) => {
t.plan(1)

const result = new QueryBuilder().query('exists', 'user')

t.deepEqual(result._queries, {
exists: {
field: 'user'
}
})
})

test('QueryBuilder should build query with field and value', (t) => {
t.plan(1)

const result = new QueryBuilder().query('term', 'user', 'kimchy')

t.deepEqual(result._queries, {
term: {
user: 'kimchy'
}
})
})

test('QueryBuilder should build query with field and object value', (t) => {
t.plan(1)

const result = new QueryBuilder().query('range', 'date', {gt: 'now-1d'})

t.deepEqual(result._queries, {
range: {
date: {gt: 'now-1d'}
}
})
})

test('QueryBuilder should build query with more options', (t) => {
t.plan(1)

const result = new QueryBuilder().query('geo_distance', 'point', {lat: 40, lon: 20}, {distance: '12km'})

t.deepEqual(result._queries, {
geo_distance: {
distance: '12km',
point: {
lat: 40,
lon: 20
}
}
})
})

test('QueryBuilder should build nested queries', (t) => {
t.plan(1)

const result = new QueryBuilder().query('nested', 'path', 'obj1', (q) => q.query('match', 'obj1.color', 'blue'))

t.deepEqual(result._queries, {
nested: {
path: 'obj1',
query: {
match: {
'obj1.color': 'blue'
}
}
}
})
})

test('QueryBuilder should nest bool-merged queries', (t) => {
t.plan(1)

const result = new QueryBuilder().query('nested', 'path', 'obj1', {score_mode: 'avg'}, (q) => {
return q.query('match', 'obj1.name', 'blue').query('range', 'obj1.count', {gt: 5})
})

t.deepEqual(result._queries, {
nested: {
path: 'obj1',
score_mode: 'avg',
query: {
bool: {
must: [
{
match: {'obj1.name': 'blue'}
},
{
range: {'obj1.count': {gt: 5}}
}
]
}
}
}
})
})

test('QueryBuilder should make filter aggregations', (t) => {
t.plan(1)

const result = new QueryBuilder().aggregation('filter', null, 'red_products', (b) => {
return b.filter('term', 'color', 'red').aggregation('avg', 'price', 'avg_price')
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hmmm.... b.filter makes sense for filters and filter aggregations, but otherwise not. Should that really be available by default?

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My thought was to be as flexible as possible, so this approach allows combining queries, filters and aggregations arbitrarily. Here are a few more examples where this type of composition is needed:

Is your concern that this approach opens the possibility for someone to build an invalid query?

})

t.deepEqual(result._aggs, {
red_products: {
filter: {
term: {
color: 'red'
}
},
aggs: {
avg_price: {
avg: {field: 'price'}
}
}
}
})
})