diff --git a/README.md b/README.md index 9b7df07a..c3f42ec3 100644 --- a/README.md +++ b/README.md @@ -259,7 +259,7 @@ Functions in WHERE statements require explicit definition and can't use a comput select `Address.City` as City,abs(`id`) as absId from `customers` where `First Name` like 'm%' and abs(`id`) > 1 order by absId --Won't work -select `Address.City` as City from `customers` where `First Name` like 'm%' and absId > 1 +select `Address.City` as City,abs(`id`) as absId from `customers` where `First Name` like 'm%' and absId > 1 ``` ORDER BY requires field to be part of select diff --git a/lib/canQuery.js b/lib/canQuery.js index 9d3283d3..da339313 100644 --- a/lib/canQuery.js +++ b/lib/canQuery.js @@ -2,16 +2,65 @@ const _allowableFunctions = require('./MongoFunctions'); const {isSelectAll: checkIfIsSelectAll} = require('./isSelectAll'); const {parseSQLtoAST} = require('./parseSQLtoAST'); +/** + * Checks whether the expression is null or its type is null + * + * @param {any} val - the expression value to check + * @returns {boolean} - whether it is null or not + * @private + */ function _checkNullOrEmptyType(val) { return !val || (val && !val.type); } +/** + * Returns a function to check whether the column supports a function + * + * @param {import('./types').Column} column - the column to check + * @returns {()=>*} - the function to check the column type + */ +function findFnFromColumnType(column) { + return (fn) => + fn.name === column.expr.name.toLowerCase() && + (!fn.type || fn.type === column.expr.type) && + fn.allowQuery; +} + +/** + * Checks whether the column contains an allowed function + * + * @param {import('./types').Column} column - the column to check + * @returns {boolean} - whether the column contains an allowed query function + */ +function checkIfContainsAllowedFunctions(column) { + return ( + column.expr.type === 'function' && + !_allowableFunctions.functionMappings.find(findFnFromColumnType(column)) + ); +} + +/** + * Checks whether the column contains an allowed aggregate function + * + * @param {import('./types').Column} column - the column to check + * @returns {boolean} - whether the column contains an allowed aggregate function + */ +function checkIfContainsAllowedAggregateFunctions(column) { + if (column.expr.type !== 'aggr_func') { + return false; + } + const someValue = _allowableFunctions.functionMappings.find( + findFnFromColumnType(column) + ); + return !someValue; +} + /** * Checks whether a mongo query can be performed or an aggregate is required * * @param {import('./types').ParserInput} sqlOrAST - the SQL statement or AST to parse * @param {import('./types').ParserOptions} [options] - the parser options - * @returns {boolean} + * @returns {boolean} - if the sql or ast can be executed as a query * @throws */ function canQuery(sqlOrAST, options = {isArray: false}) { @@ -21,9 +70,11 @@ function canQuery(sqlOrAST, options = {isArray: false}) { const isSelectAll = checkIfIsSelectAll(ast.columns); /** @type{import('./types').Column[]} */ const columns = typeof ast.columns === 'string' ? null : ast.columns; + const asColumns = isSelectAll ? [] : columns.map((c) => c.as).filter((c) => !!c); + const checkAsUsedInWhere = (expr) => { if (!expr) { return false; @@ -62,6 +113,8 @@ function canQuery(sqlOrAST, options = {isArray: false}) { asColumns.length > 0 && checkAsUsedInWhere(ast.where); const fromHasExpr = ast.from.findIndex((f) => !!f.expr) > -1; const hasUnion = !!ast.union; + const hasTableAlias = !!(ast.from && ast.from[0] && ast.from[0].as); + const isAggregate = moreThanOneFrom || hasNoTable || @@ -74,52 +127,16 @@ function canQuery(sqlOrAST, options = {isArray: false}) { fromHasExpr || asColumnsUsedInWhere || whereContainsOtherTable || - hasUnion; + hasUnion || + hasTableAlias; return !isAggregate; } -/** - * - * @param {import('./types').Column} column - * @returns {boolean} - */ -function checkIfContainsAllowedFunctions(column) { - return ( - column.expr.type === 'function' && - !_allowableFunctions.functionMappings.find(findFnFromColumnType(column)) - ); -} - -/** - * - * @param {import('./types').Column} column - * @returns {boolean} - */ -function checkIfContainsAllowedAggregateFunctions(column) { - if (column.expr.type !== 'aggr_func') { - return false; - } - const someValue = _allowableFunctions.functionMappings.find( - findFnFromColumnType(column) - ); - return !someValue; -} - -/** - * - * @param {import('./types').Column} column - * @returns {()=>*} - */ -function findFnFromColumnType(column) { - return (fn) => - fn.name === column.expr.name.toLowerCase() && - (!fn.type || fn.type === column.expr.type) && - fn.allowQuery; -} /** + * Checks whether the expression statement contains other tables to execute a sub select * - * @param {import('./types').Expression} expr - * @returns {boolean} + * @param {import('./types').Expression} expr - the expressions to check + * @returns {boolean} - whether the expression contains other tables */ function checkWhereContainsOtherTable(expr) { if (!expr) { diff --git a/lib/make/filter-queries.js b/lib/make/filter-queries.js index 9a9143a0..626fbdb4 100644 --- a/lib/make/filter-queries.js +++ b/lib/make/filter-queries.js @@ -1,5 +1,5 @@ /** - * Finds all the queries in a an AST where statement that are AST's themselves + * Finds all the queries in an AST where statement that are AST's themselves * * @param {import('../types').Expression} where * @returns {{column:string,ast:import('../types').AST[]}[]} diff --git a/lib/make/makeAggregatePipeline.js b/lib/make/makeAggregatePipeline.js index 8ec44de7..f3104ada 100644 --- a/lib/make/makeAggregatePipeline.js +++ b/lib/make/makeAggregatePipeline.js @@ -14,7 +14,15 @@ const _allowableFunctions = require('../MongoFunctions'); exports.makeAggregatePipeline = makeAggregatePipeline; -function forceGroupBy(ast) { +/** + * + * Checks whether the query needs to force a group by + * + * @param {import('../types').AST} ast - the ast to check if a group by needs to be forced + * @returns {boolean} - whether a group by needs to be forced + * @private + */ +function _forceGroupBy(ast) { if (ast.groupby) { return false; } @@ -34,7 +42,14 @@ function forceGroupBy(ast) { ); } -function hasIdCol(columns) { +/** + * Checks whether an _id column is specified + * + * @param {Array} columns - the columns to check + * @returns {boolean} - whether an _id column is specified + * @private + */ +function _hasIdCol(columns) { if (!columns || columns.length === 0) { return false; } @@ -132,7 +147,8 @@ function makeAggregatePipeline(ast, options = {}) { ast.where, false, [], - false + false, + ast.from && ast.from[0] ? ast.from[0].as : null ), }; } @@ -145,13 +161,19 @@ function makeAggregatePipeline(ast, options = {}) { const pipeLineJoin = makeJoinForPipelineModule.makeJoinForPipeline(ast); if (pipeLineJoin.length > 0) { pipeline = pipeline.concat(pipeLineJoin); + // todo check where this gets inserted + if (wherePiece) { + pipeline.push(wherePiece); + wherePiece = null; + } + } else { if (wherePiece) { pipeline.push(wherePiece); wherePiece = null; } } - const checkForceGroupBy = forceGroupBy(ast); + const checkForceGroupBy = _forceGroupBy(ast); if (ast.groupby || checkForceGroupBy) { if (isSelectAll(ast.columns)) { @@ -196,7 +218,11 @@ function makeAggregatePipeline(ast, options = {}) { // @ts-ignore const columns = ast.columns; columns.forEach((column) => { - projectColumnParserModule.projectColumnParser(column, result); + projectColumnParserModule.projectColumnParser( + column, + result, + ast.from && ast.from[0] ? ast.from[0].as : null + ); }); if (result.count.length > 0) { result.count.forEach((countStep) => pipeline.push(countStep)); @@ -242,9 +268,9 @@ function makeAggregatePipeline(ast, options = {}) { } } - if (wherePiece) { - pipeline.unshift(wherePiece); - } + // if (wherePiece) { + // pipeline.unshift(wherePiece); + // } // for if initial query is subquery if (!ast.from[0].table && ast.from[0].expr && ast.from[0].expr.ast) { @@ -281,7 +307,7 @@ function makeAggregatePipeline(ast, options = {}) { if ( options.unsetId && !isSelectAll(ast.columns) && - !hasIdCol(ast.columns) + !_hasIdCol(ast.columns) ) { pipeline.push({$unset: '_id'}); } diff --git a/lib/make/makeFilterCondition.js b/lib/make/makeFilterCondition.js index 63df3188..cf467e76 100644 --- a/lib/make/makeFilterCondition.js +++ b/lib/make/makeFilterCondition.js @@ -12,6 +12,8 @@ const operatorMap = { '!=': '$ne', AND: '$and', OR: '$or', + IS: '$eq', + 'IS NOT': '$ne', }; /** * Creates a filter expression from a query part @@ -128,6 +130,10 @@ function makeFilterCondition( return queryPart.value; } + if (queryPart.type === 'null') { + return null; + } + throw new Error( `invalid expression type for array sub select:${queryPart.type}` ); diff --git a/lib/make/makeQueryPart.js b/lib/make/makeQueryPart.js index 7caacfdb..100844c0 100644 --- a/lib/make/makeQueryPart.js +++ b/lib/make/makeQueryPart.js @@ -11,13 +11,15 @@ exports.makeQueryPart = makeQueryPart; * @param {boolean} [ignorePrefix] - Ignore the table prefix * @param {Array} [allowedTypes] - Expression types to allow * @param {boolean} [includeThis] - include $$this in expresions + * @param {string} [tableAlias] - a table alias to check if it hasn't been specified * @returns {any} - the mongo query/match */ function makeQueryPart( queryPart, ignorePrefix, allowedTypes = [], - includeThis = false + includeThis = false, + tableAlias = '' ) { if (allowedTypes.length > 0 && !allowedTypes.includes(queryPart.type)) { throw new Error(`Type not allowed for query:${queryPart.type}`); @@ -29,11 +31,12 @@ function makeQueryPart( queryPartToUse = queryPart.left; } + const table = queryPartToUse.table || tableAlias; if (queryPartToUse.column) { return ( (includeThis ? '$$this.' : '') + - (queryPartToUse.table && !ignorePrefix - ? `${queryPartToUse.table}.${queryPartToUse.column}` + (table && !ignorePrefix + ? `${table}.${queryPartToUse.column}` : queryPartToUse.column) ); } else { @@ -46,13 +49,15 @@ function makeQueryPart( queryPart.left, ignorePrefix, allowedTypes, - includeThis + includeThis, + tableAlias ); const right = makeQueryPart( queryPart.right, ignorePrefix, allowedTypes, - includeThis + includeThis, + tableAlias ); if ($check.string(left) && !left.startsWith('$')) { return {[left]: {[op]: right}}; @@ -75,13 +80,15 @@ function makeQueryPart( queryPart.left, ignorePrefix, allowedTypes, - includeThis + includeThis, + tableAlias ), makeQueryPart( queryPart.right, ignorePrefix, allowedTypes, - includeThis + includeThis, + tableAlias ), ], }; @@ -93,13 +100,15 @@ function makeQueryPart( queryPart.left, ignorePrefix, allowedTypes, - includeThis + includeThis, + tableAlias ), makeQueryPart( queryPart.right, ignorePrefix, allowedTypes, - includeThis + includeThis, + tableAlias ), ], }; diff --git a/lib/make/projectColumnParser.js b/lib/make/projectColumnParser.js index dcb1c930..27b5840b 100644 --- a/lib/make/projectColumnParser.js +++ b/lib/make/projectColumnParser.js @@ -11,10 +11,13 @@ exports.projectColumnParser = projectColumnParser; /** * @param {import('../types').Column} column The column to parse * @param {import('../types').ColumnParseResult} result the result object + * @param {string} [tableAlias] - a table alias to check if it hasn't been specified * @returns {void} */ -function projectColumnParser(column, result) { +function projectColumnParser(column, result, tableAlias = '') { if (column.expr.type === 'column_ref') { + const columnTable = column.expr.table || tableAlias; + if (column.as && column.as.toUpperCase() === '$$ROOT') { result.replaceRoot = { $replaceRoot: {newRoot: `$${column.expr.column}`}, @@ -32,7 +35,7 @@ function projectColumnParser(column, result) { return; } result.parsedProject.$project[column.as || column.expr.column] = `$${ - column.expr.table ? column.expr.table + '.' : '' + columnTable ? columnTable + '.' : '' }${column.expr.column}`; return; } diff --git a/package.json b/package.json index 718e9e40..1bc775e8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@synatic/sql-to-mongo", - "version": "1.1.4", + "version": "1.1.5", "description": "Convert SQL to mongo queries or aggregates", "main": "index.js", "files": [ diff --git a/test/aggregateTests/aggregateTests.json b/test/aggregateTests/aggregateTests.json index e0a10bdb..81c43c1d 100644 --- a/test/aggregateTests/aggregateTests.json +++ b/test/aggregateTests/aggregateTests.json @@ -1676,5 +1676,95 @@ ], "collections": ["customers"] } + }, + { + "name": "Aggregate: case is null", + "query": "select filmId,\n case\n when col1 is not null\n then col1\n else 0\n end as \"t0_0\",\n case\n when col2 is null\n then 0\n else 1\n end as \"t1_0\"\n from\n customers\n", + "type": "aggregate", + "output": { + "pipeline": [ + { + "$project": { + "filmId": "$filmId", + "t0_0": { + "$switch": { + "branches": [ + { + "case": { + "$ne": [ + "$col1", + null + ] + }, + "then": "$col1" + } + ], + "default": { + "$literal": 0 + } + } + }, + "t1_0": { + "$switch": { + "branches": [ + { + "case": { + "$eq": [ + "$col2", + null + ] + }, + "then": 0 + } + ], + "default": { + "$literal": 1 + } + } + } + } + } + ], + "collections": ["customers"] + } + }, + { + "name": "Query:fix alias", + "query": "select f.Title,Description from films f where f.Name = \"Test\" and `Replacement Value` > 10", + "type": "aggregate", + "output": { + "pipeline": [ + { + "$project": { + "f": "$$ROOT" + } + }, + { + "$match": { + "$and": [ + { + "f.Name": { + "$eq": "Test" + } + }, + { + "f.Replacement Value": { + "$gt": 10 + } + } + ] + } + }, + { + "$project": { + "Title": "$f.Title", + "Description": "$f.Description" + } + } + ], + "collections": [ + "films" + ] + } } ] diff --git a/test/queryTests/queryTests.json b/test/queryTests/queryTests.json index d04c89fa..a3359cdb 100644 --- a/test/queryTests/queryTests.json +++ b/test/queryTests/queryTests.json @@ -188,5 +188,15 @@ "name": "Query Error: aggregation in where", "query": "select * from customers where min(col1) >0", "error": "Aggregate function not allowed in where:MIN" + }, + { + "name": "Query Error: count on query", + "query": "select count(1) as s from customers", + "error": "Query cannot cross multiple collections, have an aggregate function, contain functions in where clauses or have $$ROOT AS" + }, + { + "name": "Query Error: count on query", + "query": "select count(*) as s from customers", + "error": "Query cannot cross multiple collections, have an aggregate function, contain functions in where clauses or have $$ROOT AS" } ] diff --git a/test/sql-parser.test.js b/test/sql-parser.test.js index bc8c4c85..2e83bc4c 100644 --- a/test/sql-parser.test.js +++ b/test/sql-parser.test.js @@ -206,6 +206,13 @@ describe('SQL Parser', function () { ); }); + it('should not allow sub from', function () { + assert( + !canQuery('select * from (select * from `films`) f'), + 'Invalid can query' + ); + }); + it('should nt allow *,function ', function () { assert( !canQuery( @@ -215,6 +222,10 @@ describe('SQL Parser', function () { ); }); + it('should test as ', function () { + assert(!canQuery('select * from `films` f'), 'Invalid can query'); + }); + it('should not allow with where on sub query ', function () { assert( !canQuery(