From c0793cd254ab7672de8e9471d11d612f0f5da8c4 Mon Sep 17 00:00:00 2001 From: Zhihe Li Date: Thu, 18 Jan 2024 11:39:29 -0500 Subject: [PATCH] =?UTF-8?q?test:=20=F0=9F=92=8D=20Add=20tests=20for=20MQL?= =?UTF-8?q?=20expression=20generator=20(#2096)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../addon/handlers/client-daemon-handler.js | 2 +- .../addon/utils/{mqlQuery.js => mql-query.js} | 23 +- addons/api/app/utils/mql-query.js | 1 + addons/api/tests/unit/utils/mql-query-test.js | 208 ++++++++++++++++++ 4 files changed, 222 insertions(+), 12 deletions(-) rename addons/api/addon/utils/{mqlQuery.js => mql-query.js} (81%) create mode 100644 addons/api/app/utils/mql-query.js create mode 100644 addons/api/tests/unit/utils/mql-query-test.js diff --git a/addons/api/addon/handlers/client-daemon-handler.js b/addons/api/addon/handlers/client-daemon-handler.js index 4d948454e..bef97f436 100644 --- a/addons/api/addon/handlers/client-daemon-handler.js +++ b/addons/api/addon/handlers/client-daemon-handler.js @@ -1,7 +1,7 @@ import { inject as service } from '@ember/service'; import { getOwner, setOwner } from '@ember/application'; import { pluralize } from 'ember-inflector'; -import { generateMQLExpression } from '../utils/mqlQuery'; +import { generateMQLExpression } from '../utils/mql-query'; /** * Not all types are yet supported by the client daemon so we'll diff --git a/addons/api/addon/utils/mqlQuery.js b/addons/api/addon/utils/mql-query.js similarity index 81% rename from addons/api/addon/utils/mqlQuery.js rename to addons/api/addon/utils/mql-query.js index a39f29ce7..876a1c933 100644 --- a/addons/api/addon/utils/mqlQuery.js +++ b/addons/api/addon/utils/mql-query.js @@ -9,11 +9,11 @@ * * @example * const filter = generateMQLFilterExpression({ - * name: { contains: 'search text' }, + * name: [{ contains: 'search text' }], * scope: [{ equals: 'scope1'}, { equals: 'scope2' }], * }); * - * // (name % "search text") and (scope = "scope1" or scope = "scope2") + * // (name % "search text") and (scope_id = "scope1" or scope_id = "scope2") */ export function generateMQLFilterExpression(filterObj) { if (!filterObj) { @@ -107,30 +107,31 @@ export function generateMQLExpression(obj) { const isEmpty = (input) => input === null || input === undefined || input === ''; -// Escape any double quotes or backslashes -const sanitize = (input) => input?.replace(/(["\\])/g, '\\$1'); +// Escape any double quotes or backslashes and add quotes if it's a string +const sanitize = (input) => + typeof input === 'string' ? `"${input?.replace(/(["\\])/g, '\\$1')}"` : input; // Comparison Operators const equals = (key, value) => - !isEmpty(value) ? `${key} = "${sanitize(value)}"` : null; + !isEmpty(value) ? `${key} = ${sanitize(value)}` : null; const notEquals = (key, value) => - !isEmpty(value) ? `${key} != "${sanitize(value)}"` : null; + !isEmpty(value) ? `${key} != ${sanitize(value)}` : null; const contains = (key, value) => - !isEmpty(value) ? `${key} % "${sanitize(value)}"` : null; + !isEmpty(value) ? `${key} % ${sanitize(value)}` : null; const gt = (key, value) => - !isEmpty(value) ? `${key} > "${sanitize(value)}"` : null; + !isEmpty(value) ? `${key} > ${sanitize(value)}` : null; const gte = (key, value) => - !isEmpty(value) ? `${key} >= "${sanitize(value)}"` : null; + !isEmpty(value) ? `${key} >= ${sanitize(value)}` : null; const lt = (key, value) => - !isEmpty(value) ? `${key} < "${sanitize(value)}"` : null; + !isEmpty(value) ? `${key} < ${sanitize(value)}` : null; const lte = (key, value) => - !isEmpty(value) ? `${key} <= "${sanitize(value)}"` : null; + !isEmpty(value) ? `${key} <= ${sanitize(value)}` : null; // Logical Operators const and = (clauses) => clauses.join(' and '); diff --git a/addons/api/app/utils/mql-query.js b/addons/api/app/utils/mql-query.js new file mode 100644 index 000000000..6e1eeb516 --- /dev/null +++ b/addons/api/app/utils/mql-query.js @@ -0,0 +1 @@ +export { default } from 'api/utils/mql-query'; diff --git a/addons/api/tests/unit/utils/mql-query-test.js b/addons/api/tests/unit/utils/mql-query-test.js new file mode 100644 index 000000000..49086e49c --- /dev/null +++ b/addons/api/tests/unit/utils/mql-query-test.js @@ -0,0 +1,208 @@ +import { + generateMQLSearchExpression, + generateMQLFilterExpression, + generateMQLExpression, +} from 'api/utils/mql-query'; +import { module, test } from 'qunit'; + +module('Unit | Utility | mql-query', function () { + module('generateMQLSearchExpression tests', function () { + test('it generates search correctly', function (assert) { + const result = generateMQLSearchExpression({ + text: 'search', + fields: ['id', 'name', 'description'], + }); + + assert.strictEqual( + result, + '(id % "search" or name % "search" or description % "search")', + ); + }); + + test('it sanitizes search correctly', function (assert) { + const result = generateMQLSearchExpression({ + text: String.raw`\backslash\ and "quotes"`, + fields: ['id', 'name', 'description'], + }); + + assert.strictEqual( + result, + '(id % "\\\\backslash\\\\ and \\"quotes\\"" or name % "\\\\backslash\\\\ and \\"quotes\\"" or description % "\\\\backslash\\\\ and \\"quotes\\"")', + ); + }); + + test('it generates null when search is empty', function (assert) { + const result = generateMQLSearchExpression({ + text: '', + fields: ['id', 'name', 'description'], + }); + + assert.strictEqual(result, null); + }); + }); + + module('generateMQLFilterExpression tests', function () { + test('it generates empty string when object is empty', function (assert) { + const result = generateMQLFilterExpression({}); + + assert.strictEqual(result, ''); + }); + + test('it generates conditions in filters by ORing them together', function (assert) { + const result = generateMQLFilterExpression({ + scope_id: [{ equals: 'scope1' }, { equals: 'scope2' }], + }); + + assert.strictEqual( + result, + '(scope_id = "scope1" or scope_id = "scope2")', + ); + }); + + test('it generates filters by ANDing separate filters together', function (assert) { + const result = generateMQLFilterExpression({ + name: [{ contains: 'nameFilter' }], + scope_id: [{ equals: 'scope1' }, { equals: 'scope2' }], + }); + + assert.strictEqual( + result, + '(name % "nameFilter") and (scope_id = "scope1" or scope_id = "scope2")', + ); + }); + + test('it generates each condition correctly', function (assert) { + const result = generateMQLFilterExpression({ + name: [{ contains: 'nameFilter' }, { notEquals: 'otherFilter' }], + id: [{ equals: 'woop' }], + num: [{ gt: 50 }, { gte: 60 }, { lt: 100 }, { lte: 90 }], + }); + + assert.strictEqual( + result, + '(name % "nameFilter" or name != "otherFilter") and (id = "woop") and (num > 50 or num >= 60 or num < 100 or num <= 90)', + ); + }); + + test('it generates filters with logicalOperator specified', function (assert) { + const andResult = generateMQLFilterExpression({ + id: { + logicalOperator: 'and', + values: [ + { notEquals: 'foo' }, + { notEquals: 'bar' }, + { notEquals: 'baz' }, + ], + }, + scope_id: [{ equals: 'scope1' }], + }); + const orResult = generateMQLFilterExpression({ + id: { + logicalOperator: 'or', + values: [ + { notEquals: 'foo' }, + { notEquals: 'bar' }, + { notEquals: 'baz' }, + ], + }, + scope_id: [{ equals: 'scope1' }], + }); + + assert.strictEqual( + andResult, + '(id != "foo" and id != "bar" and id != "baz") and (scope_id = "scope1")', + ); + assert.strictEqual( + orResult, + '(id != "foo" or id != "bar" or id != "baz") and (scope_id = "scope1")', + ); + }); + + test('it sanitizes filters correctly', function (assert) { + const result = generateMQLFilterExpression({ + name: [{ equals: String.raw`\backslash\ and "quotes"` }], + num: [{ gt: 50 }, { gte: 60 }, { lt: 100 }, { lte: 90 }], + }); + + assert.strictEqual( + result, + '(name = "\\\\backslash\\\\ and \\"quotes\\"") and (num > 50 or num >= 60 or num < 100 or num <= 90)', + ); + }); + }); + + module('generateMQLExpression tests', function () { + test('it generates both search and filters correctly', function (assert) { + const result = generateMQLExpression({ + filters: { + name: [{ contains: 'nameFilter' }, { notEquals: 'otherFilter' }], + id: { + logicalOperator: 'and', + values: [{ notEquals: 'foo' }, { notEquals: 'bar' }], + }, + num: [{ gt: 50 }, { gte: 60 }, { lt: 100 }, { lte: 90 }], + }, + search: { + text: 'search', + fields: ['id', 'name', 'description'], + }, + }); + + assert.strictEqual( + result, + '(id % "search" or name % "search" or description % "search") and (name % "nameFilter" or name != "otherFilter") and (id != "foo" and id != "bar") and (num > 50 or num >= 60 or num < 100 or num <= 90)', + ); + }); + + test('it generates empty string when nothing is provided', function (assert) { + const result = generateMQLExpression({ + filters: { + name: [], + id: [], + }, + search: { + text: '', + fields: ['id', 'name', 'description'], + }, + }); + + assert.strictEqual(result, ''); + }); + + test('it generates just search when filters are empty', function (assert) { + const result = generateMQLExpression({ + filters: { + name: [], + scope_id: [], + }, + search: { + text: 'search', + fields: ['id', 'name', 'description'], + }, + }); + + assert.strictEqual( + result, + '(id % "search" or name % "search" or description % "search")', + ); + }); + + test('it generates just filters when search is empty', function (assert) { + const result = generateMQLExpression({ + filters: { + name: [{ contains: 'nameFilter' }], + scope_id: [{ equals: 'scope1' }, { equals: 'scope2' }], + }, + search: { + text: '', + fields: ['id', 'name', 'description'], + }, + }); + + assert.strictEqual( + result, + '(name % "nameFilter") and (scope_id = "scope1" or scope_id = "scope2")', + ); + }); + }); +});