From 7b8798a6fa7870c98648cae5a494eb761638a208 Mon Sep 17 00:00:00 2001 From: Hannah Wolfe Date: Wed, 2 Mar 2022 09:44:57 +0000 Subject: [PATCH] Added support for $regex & $not ops to mongo-knex ref: https://github.com/TryGhost/NQL/issues/22 - NQL lang will parse ~ ~^ and ~$ into $regex and $not - This adds support in the convertor for $regex and $not in these simple cases - As we are moving into SQL, we have to de-escape the regex and convert it back into a string but regexp.source makes this fairly easy - Had to add regexEnabledJSONStringify as a side effect because JSON.stringify doesn't expect or handle regex --- packages/mongo-knex/lib/convertor.js | 64 ++++++++++++++++--- .../mongo-knex/test/unit/convertor.test.js | 15 +++++ 2 files changed, 71 insertions(+), 8 deletions(-) diff --git a/packages/mongo-knex/lib/convertor.js b/packages/mongo-knex/lib/convertor.js index a8add76b..86555d24 100644 --- a/packages/mongo-knex/lib/convertor.js +++ b/packages/mongo-knex/lib/convertor.js @@ -15,7 +15,9 @@ const compOps = { $lt: '<', $lte: '<=', $in: 'in', - $nin: 'not in' + $nin: 'not in', + $regex: 'like', + $not: 'not like' }; const isOp = key => key.charAt(0) === '$'; @@ -24,6 +26,39 @@ const isCompOp = key => isOp(key) && _.includes(_.keys(compOps), key); const isNegationOp = key => isOp(key) && _.includes(['$ne', '$nin'], key); const isStatementGroupOp = key => _.includes([compOps.$in, compOps.$nin], key); +/** + * JSON Stringify with RegExp support + * @param {Object} json + * @returns + */ +const stringify = (json) => { + return JSON.stringify(json, function (key, value) { + if (value instanceof RegExp) { + return value.toString(); + } + return value; + }); +}; + +const processRegExp = ({source, ignoreCase}) => { + source = source.replace(/\\([.*+?^${}()|[\]\\])/g, '$1'); + + if (ignoreCase) { + source = source.toLowerCase(); + } + + // For starts with and ends with in SQL we have to put the wildcard at the opposite end of the string to the regex symbol! + if (source.startsWith('^')) { + source = source.replace('^', '') + '%'; + } else if (source.endsWith('$')) { + source = '%' + source.replace('$', ''); + } else { + source = '%' + source + '%'; + } + + return {source, ignoreCase}; +}; + class MongoToKnex { /** * @@ -199,13 +234,13 @@ class MongoToKnex { debug(`(buildRelationQuery)`); if (debugExtended.enabled) { - debugExtended(`(buildRelationQuery) ${JSON.stringify(relations)}`); + debugExtended(`(buildRelationQuery) ${stringify(relations)}`); } const groupedRelations = this.groupRelationStatements(relations, mode); if (debugExtended.enabled) { - debugExtended(`(buildRelationQuery) grouped: ${JSON.stringify(groupedRelations)}`); + debugExtended(`(buildRelationQuery) grouped: ${stringify(groupedRelations)}`); } // CASE: {tags: [where clause, where clause], tags_123: [where clause], authors: [where clause, where clause]} @@ -389,8 +424,8 @@ class MongoToKnex { */ buildComparison(qb, mode, statement, op, value, group) { const comp = compOps[op] || '='; - const whereType = this.processWhereType(mode, op, value); const processedStatement = this.processStatement(statement, op, value); + let whereType = this.processWhereType(mode, op, value); debug(`(buildComparison) mode: ${mode}, op: ${op}, isRelation: ${processedStatement.isRelation}, group: ${group}`); @@ -418,6 +453,19 @@ class MongoToKnex { op = processedStatement.operator; value = processedStatement.value; + if (op === '$regex' || op === '$not') { + const {source, ignoreCase} = processRegExp(value); + value = source; + + // CASE: regex with i flag needs whereRaw to wrap column in lower() else fall through + if (ignoreCase) { + whereType += 'Raw'; + debug(`(buildComparison) whereType: ${whereType}, statement: ${statement}, op: ${op}, comp: ${comp}, value: ${value} (REGEX/i)`); + qb[whereType](`lower(??) ${comp} ?`, [column, value]); + return; + } + } + debug(`(buildComparison) whereType: ${whereType}, statement: ${statement}, op: ${op}, comp: ${comp}, value: ${value}`); qb[whereType](column, comp, value); } @@ -429,7 +477,7 @@ class MongoToKnex { debug(`(buildWhereClause) mode: ${mode}, statement: ${statement}`); if (debugExtended.enabled) { - debugExtended(`(buildWhereClause) ${JSON.stringify(sub)}`); + debugExtended(`(buildWhereClause) ${stringify(sub)}`); } // CASE sub is an atomic value, we use "eq" as default operator @@ -458,7 +506,7 @@ class MongoToKnex { debug(`(buildWhereGroup) mode: ${mode}, whereType: ${whereType}`); if (debugExtended.enabled) { - debugExtended(`(buildWhereGroup) ${JSON.stringify(sub)}`); + debugExtended(`(buildWhereGroup) ${stringify(sub)}`); } qb[whereType]((_qb) => { @@ -480,7 +528,7 @@ class MongoToKnex { debug(`(buildQuery) mode: ${mode}`); if (debugExtended.enabled) { - debugExtended(`(buildQuery) ${JSON.stringify(sub)}`); + debugExtended(`(buildQuery) ${stringify(sub)}`); } _.forIn(sub, (value, key) => { @@ -505,7 +553,7 @@ class MongoToKnex { // DEBUG=mongo-knex:converter,mongo-knex:converter-extended if (debugExtended.enabled) { - debugExtended(`(processJSON) ${JSON.stringify(mongoJSON)}`); + debugExtended(`(processJSON) ${stringify(mongoJSON)}`); } // 'and' is the default behaviour diff --git a/packages/mongo-knex/test/unit/convertor.test.js b/packages/mongo-knex/test/unit/convertor.test.js index 0d90ffcd..c1a29986 100644 --- a/packages/mongo-knex/test/unit/convertor.test.js +++ b/packages/mongo-knex/test/unit/convertor.test.js @@ -111,6 +111,21 @@ describe('Comparison Query Operators', function () { runQuery({tags: {$nin: ['video', 'audio']}}) .should.eql('select * from `posts` where `posts`.`tags` not in (\'video\', \'audio\')'); }); + + it('can match like', function () { + runQuery({email: {$regex: /Gmail\.com/i}}) + .should.eql('select * from `posts` where lower(`posts`.`email`) like \'%gmail.com%\''); + }); + + it('can match like with startswith', function () { + runQuery({email: {$regex: /^Gmail\.com/i}}) + .should.eql('select * from `posts` where lower(`posts`.`email`) like \'gmail.com%\''); + }); + + it('can match like with endswith', function () { + runQuery({email: {$regex: /Gmail\.com$/i}}) + .should.eql('select * from `posts` where lower(`posts`.`email`) like \'%gmail.com\''); + }); }); describe('Logical Query Operators', function () {