-
-
Notifications
You must be signed in to change notification settings - Fork 127
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
Changes from 3 commits
b530258
7bf8fac
01b6ca8
d6f174d
ded95da
24e7ec8
1112908
c1ff27e
aea60de
56d6afb
79a88d8
27fa0bd
48b7ee6
fca9377
6d64f96
85e5703
6c70156
f980dc9
695a1e9
a51fc62
916ab47
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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) | ||
} 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)] | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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))() | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. in this case Object.assign({}, opts, {field}) (I also think I understood that There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
} | ||
|
||
} |
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') | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. hmmm.... There was a problem hiding this comment. Choose a reason for hiding this commentThe 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'} | ||
} | ||
} | ||
} | ||
}) | ||
}) |
There was a problem hiding this comment.
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?