Skip to content

Commit 2c0abe4

Browse files
committed
provide a way to not use containment operators
when using a btree index, the use of containment operators prevents the query from hitting the index. * added options to convert function and all the functions it's calling * if options.disableContainmentQuery is true then containment query is not used. Signed-off-by: Danny Zaken <dannyzaken@gmail.com>
1 parent 703d910 commit 2c0abe4

File tree

2 files changed

+38
-23
lines changed

2 files changed

+38
-23
lines changed

index.js

+31-23
Original file line numberDiff line numberDiff line change
@@ -34,14 +34,14 @@ function getMatchingArrayPath(op, arrayPaths) {
3434
* @param arrayPathStr
3535
* @returns {string|string|*}
3636
*/
37-
function createElementOrArrayQuery(path, op, value, parent, arrayPathStr) {
37+
function createElementOrArrayQuery(path, op, value, parent, arrayPathStr, options) {
3838
const arrayPath = arrayPathStr.split('.')
3939
const deeperPath = op.split('.').slice(arrayPath.length)
4040
const innerPath = ['value', ...deeperPath]
4141
const pathToMaybeArray = path.concat(arrayPath)
4242

4343
// TODO: nested array paths are not yet supported.
44-
const singleElementQuery = convertOp(path, op, value, parent, [])
44+
const singleElementQuery = convertOp(path, op, value, parent, [], options)
4545

4646
const text = util.pathToText(pathToMaybeArray, false)
4747
const safeArray = `jsonb_typeof(${text})='array' AND`
@@ -52,30 +52,30 @@ function createElementOrArrayQuery(path, op, value, parent, arrayPathStr) {
5252
if (typeof value['$size'] !== 'undefined') {
5353
// size does not support array element based matching
5454
} else if (value['$elemMatch']) {
55-
const sub = convert(innerPath, value['$elemMatch'], [], false)
55+
const sub = convert(innerPath, value['$elemMatch'], [], false, options)
5656
arrayQuery = `EXISTS (SELECT * FROM jsonb_array_elements(${text}) WHERE ${safeArray} ${sub})`
5757
return arrayQuery
5858
} else if (value['$in']) {
59-
const sub = convert(innerPath, value, [], true)
59+
const sub = convert(innerPath, value, [], true, options)
6060
arrayQuery = `EXISTS (SELECT * FROM jsonb_array_elements(${text}) WHERE ${safeArray} ${sub})`
6161
} else if (value['$all']) {
6262
const cleanedValue = value['$all'].filter((v) => (v !== null && typeof v !== 'undefined'))
6363
arrayQuery = '(' + cleanedValue.map(function (subquery) {
64-
const sub = convert(innerPath, subquery, [], false)
64+
const sub = convert(innerPath, subquery, [], false, options)
6565
return `EXISTS (SELECT * FROM jsonb_array_elements(${text}) WHERE ${safeArray} ${sub})`
6666
}).join(' AND ') + ')'
6767
} else if (specialKeys.length === 0) {
68-
const sub = convert(innerPath, value, [], true)
68+
const sub = convert(innerPath, value, [], true, options)
6969
arrayQuery = `EXISTS (SELECT * FROM jsonb_array_elements(${text}) WHERE ${safeArray} ${sub})`
7070
} else {
7171
const params = value
7272
arrayQuery = '(' + Object.keys(params).map(function (subKey) {
73-
const sub = convert(innerPath, { [subKey]: params[subKey] }, [], true)
73+
const sub = convert(innerPath, { [subKey]: params[subKey] }, [], true, options)
7474
return `EXISTS (SELECT * FROM jsonb_array_elements(${text}) WHERE ${safeArray} ${sub})`
7575
}).join(' AND ') + ')'
7676
}
7777
} else {
78-
const sub = convert(innerPath, value, [], true)
78+
const sub = convert(innerPath, value, [], true, options)
7979
arrayQuery = `EXISTS (SELECT * FROM jsonb_array_elements(${text}) WHERE ${safeArray} ${sub})`
8080
}
8181
if (!arrayQuery || arrayQuery === '()') {
@@ -91,25 +91,25 @@ function createElementOrArrayQuery(path, op, value, parent, arrayPathStr) {
9191
* @param parent {mixed} parent[path] = value
9292
* @param arrayPaths {Array} List of dotted paths that possibly need to be handled as arrays.
9393
*/
94-
function convertOp(path, op, value, parent, arrayPaths) {
94+
function convertOp(path, op, value, parent, arrayPaths, options) {
9595
const arrayPath = getMatchingArrayPath(op, arrayPaths)
9696
// It seems like direct matches shouldn't be array fields, but 2D arrays are possible in MongoDB
9797
// I will need to do more testing to see if we should handle this case differently.
9898
// const arrayDirectMatch = !isSpecialOp(op) && Array.isArray(value)
9999
if (arrayPath) {
100-
return createElementOrArrayQuery(path, op, value, parent, arrayPath)
100+
return createElementOrArrayQuery(path, op, value, parent, arrayPath, options)
101101
}
102102
switch(op) {
103103
case '$not':
104-
return '(NOT ' + convert(path, value) + ')'
104+
return '(NOT ' + convert(path, value, undefined, false, options) + ')'
105105
case '$nor': {
106106
for (const v of value) {
107107
if (typeof v !== 'object') {
108108
throw new Error('$or/$and/$nor entries need to be full objects')
109109
}
110110
}
111111
const notted = value.map((e) => ({ $not: e }))
112-
return convertOp(path, '$and', notted, value, arrayPaths)
112+
return convertOp(path, '$and', notted, value, arrayPaths, options)
113113
}
114114
case '$or':
115115
case '$and':
@@ -124,19 +124,19 @@ function convertOp(path, op, value, parent, arrayPaths) {
124124
throw new Error('$or/$and/$nor entries need to be full objects')
125125
}
126126
}
127-
return '(' + value.map((subquery) => convert(path, subquery, arrayPaths)).join(op === '$or' ? ' OR ' : ' AND ') + ')'
127+
return '(' + value.map((subquery) => convert(path, subquery, arrayPaths, false, options)).join(op === '$or' ? ' OR ' : ' AND ') + ')'
128128
}
129129
// TODO (make sure this handles multiple elements correctly)
130130
case '$elemMatch':
131-
return convert(path, value, arrayPaths)
131+
return convert(path, value, arrayPaths, false, options)
132132
//return util.pathToText(path, false) + ' @> \'' + util.stringEscape(JSON.stringify(value)) + '\'::jsonb'
133133
case '$in':
134134
case '$nin': {
135135
if (value.length === 0) {
136136
return 'FALSE'
137137
}
138138
if (value.length === 1) {
139-
return convert(path, value[0], arrayPaths)
139+
return convert(path, value[0], arrayPaths, false, options)
140140
}
141141
const cleanedValue = value.filter((v) => (v !== null && typeof v !== 'undefined'))
142142
let partial = util.pathToText(path, typeof value[0] == 'string') + (op == '$nin' ? ' NOT' : '') + ' IN (' + cleanedValue.map(util.quote).join(', ') + ')'
@@ -172,7 +172,7 @@ function convertOp(path, op, value, parent, arrayPaths) {
172172
case '$eq': {
173173
const isSimpleComparision = (op === '$eq' || op === '$ne')
174174
const pathContainsArrayAccess = path.some((key) => /^\d+$/.test(key))
175-
if (isSimpleComparision && !pathContainsArrayAccess) {
175+
if (isSimpleComparision && !pathContainsArrayAccess && !options.disableContainmentQuery) {
176176
// create containment query since these can use GIN indexes
177177
// See docs here, https://www.postgresql.org/docs/9.4/datatype-json.html#JSON-INDEXING
178178
const [head, ...tail] = path
@@ -213,7 +213,7 @@ function convertOp(path, op, value, parent, arrayPaths) {
213213
}
214214
default:
215215
// this is likely a top level field, recurse
216-
return convert(path.concat(op.split('.')), value)
216+
return convert(path.concat(op.split('.')), value, undefined, false, options)
217217
}
218218
}
219219

@@ -236,9 +236,9 @@ function getSpecialKeys(path, query, forceExact) {
236236
* @param forceExact {Boolean} When true, an exact match will be required.
237237
* @returns The corresponding PSQL expression
238238
*/
239-
var convert = function (path, query, arrayPaths, forceExact=false) {
239+
var convert = function (path, query, arrayPaths, forceExact, options) {
240240
if (typeof query === 'string' || typeof query === 'boolean' || typeof query == 'number' || Array.isArray(query)) {
241-
return convertOp(path, '$eq', query, {}, arrayPaths)
241+
return convertOp(path, '$eq', query, {}, arrayPaths, options)
242242
}
243243
if (query === null) {
244244
const text = util.pathToText(path, false)
@@ -261,18 +261,26 @@ var convert = function (path, query, arrayPaths, forceExact=false) {
261261
}
262262
case 1: {
263263
const key = specialKeys[0]
264-
return convertOp(path, key, query[key], query, arrayPaths)
264+
return convertOp(path, key, query[key], query, arrayPaths, options)
265265
}
266266
default:
267267
return '(' + specialKeys.map(function (key) {
268-
return convertOp(path, key, query[key], query, arrayPaths)
268+
return convertOp(path, key, query[key], query, arrayPaths, options)
269269
}).join(' and ') + ')'
270270
}
271271
}
272272
}
273273

274-
module.exports = function (fieldName, query, arrays) {
275-
return convert([fieldName], query, arrays || [])
274+
module.exports = function (fieldName, query, arraysOrOptions) {
275+
let arrays
276+
let options = {}
277+
if (arraysOrOptions && Array.isArray(arraysOrOptions)) {
278+
arrays = arraysOrOptions
279+
} else if (typeof arraysOrOptions === 'object') {
280+
arrays = arraysOrOptions.arrays || []
281+
options = arraysOrOptions
282+
}
283+
return convert([fieldName], query, arrays || [], false, options)
276284
}
277285
module.exports.convertDotNotation = util.convertDotNotation
278286
module.exports.pathToText = util.pathToText

test/filter.js

+7
Original file line numberDiff line numberDiff line change
@@ -238,3 +238,10 @@ describe('special cases', function () {
238238
assert.equal('TRUE', convert('data', {}))
239239
})
240240
})
241+
242+
describe('options.disableContainmentQuery', function () {
243+
it('should use ->> operator instead of containment when options.disableContainmentQuery is passed', function (){
244+
assert.equal('(data->>\'a\'=\'1111\' and data->\'b\'=\'123\'::jsonb)',
245+
convert('data', { a: '1111', b: 123 }, {disableContainmentQuery: true}))
246+
})
247+
})

0 commit comments

Comments
 (0)