From fd97e3bda9c6959fc48b953766c25ee5611214f2 Mon Sep 17 00:00:00 2001 From: Daniel Date: Fri, 29 Jan 2021 18:37:13 +0100 Subject: [PATCH] Moved to main repo --- odata/lib/odata2cqn.js | 12 + odata/lib/odata2cqn.pegjs | 589 +++++++++++++++++++++++++++++++++++ odata/package.json | 11 + odata/test/odata2cqn.test.js | 109 +++++++ package.json | 1 + 5 files changed, 722 insertions(+) create mode 100644 odata/lib/odata2cqn.js create mode 100644 odata/lib/odata2cqn.pegjs create mode 100644 odata/package.json create mode 100644 odata/test/odata2cqn.test.js diff --git a/odata/lib/odata2cqn.js b/odata/lib/odata2cqn.js new file mode 100644 index 00000000..d7e98d3d --- /dev/null +++ b/odata/lib/odata2cqn.js @@ -0,0 +1,12 @@ +const fs = require("fs"); +const path = require("path"); +const peg = require("pegjs"); +const pegGrammarPath = path.join(__dirname, "/odata2cqn.pegjs"); + +const odataPegGrammar = fs.readFileSync(pegGrammarPath, { + encoding: "utf8", + flag: "r", +}); +const parser = peg.generate(odataPegGrammar); + +module.exports = { parser }; diff --git a/odata/lib/odata2cqn.pegjs b/odata/lib/odata2cqn.pegjs new file mode 100644 index 00000000..608d0732 --- /dev/null +++ b/odata/lib/odata2cqn.pegjs @@ -0,0 +1,589 @@ +/** + * Odata Spec http://docs.oasis-open.org/odata/odata/v4.0/errata03/os/complete/abnf/odata-abnf-construction-rules.txt + * Future test cases http://docs.oasis-open.org/odata/odata/v4.0/errata03/os/complete/abnf/odata-abnf-testcases.xml + * + * Limitations: Type, Geo functions are not supported, + * maxdatetime, mindatetime, fractionalseconds, + * totaloffsetminutes, date, totalseconds, + * floor, ceiling also are not supported by CAP + * + * Examples: + * Books + * Books/201 + * Books?$select=ID,title&$expand=author($select=name)&$filter=stock gt 1&$orderby=title + */ +{ + const $=Object.assign + const SELECT={}; + const stack=[]; + let columns=[]; + let filterExpr; + + const select = (col) => { + if (!SELECT.columns) SELECT.columns = columns + columns.push(col) + } + const expand = (col) => { + select (col) + stack.push (columns) + columns = col.expand = [] + } + const end = ()=> columns = stack.pop() + const compOperators = { + eq: '=', + ne: '!=', + lt: '<', + gt: '>', + le: '<=', + ge: '>=', + } + + function FilterExpr() { + let parsedWhereClause = []; + + function appendWhereClause(body) { + if(!parsedWhereClause) { + parsedWhereClause = body; + } else { + parsedWhereClause = [...parsedWhereClause, ...body]; + } + } + function getParsedWhereClause() { + return parsedWhereClause; + } + + return { + appendWhereClause, + getParsedWhereClause, + } + } +} + +start = ODataRelativeURI + +ODataRelativeURI // Note: case-sensitive! + = (p:ref { SELECT.from = p }) ( o"?"o QueryOptions )? + {return { SELECT }} + +QueryOptions = ( + "$expand=" expand / + "$select=" select / + "$top=" top / + "$skip=" skip / + "$count=" count / + "$orderby=" orderby / + (beforeFilter FilterExprSequence aflterFilter) +)( o'&'o QueryOptions )? + + +// ---------- Grouped $filter expression ---------- +// ---------- +beforeFilter = "$filter=" { + console.log('starting $filter'); + filterExpr = new FilterExpr(); +} +aflterFilter = "" { + console.log('end of $filter'); + SELECT.where = filterExpr.getParsedWhereClause(); +} + +FilterExprSequence = (Expr (SP logicalOperator SP Expr)*) +GroupedExpr = (startGroup FilterExprSequence closeGroup) +Expr = ( + (notOperator SP)? ( boolFunc / GroupedExpr ) + ) / ( commonExp ) +startGroup + = OPEN + { filterExpr.appendWhereClause(['(']); } +closeGroup + = CLOSE + { filterExpr.appendWhereClause([')']); } + + +// ---------- Function expressions ---------- +// ---------- +commonExp = val:( + timeExpr / + secondExpr / + minuteExpr / + hourExpr / + dayExpr / + monthExpr / + yearExpr / + compStrExpr / + compareNumExpr + ) + { + const res = val.filter(cur => cur !== ' '); + filterExpr.appendWhereClause([...res]); + } + +compStrExpr + = firstArgObj:strArg + SP operatorVal:eqOperator SP + secondArgObj:strArg +compareNumExpr + = firstArgObj:numberArg + SP operatorVal:( eqOperator / numCompOperator ) SP + secondArgObj:numberArg +dayExpr + = firstArg:(dayFunc / dayVal) + SP operatorVal:( eqOperator / numCompOperator ) SP + secondArg:(dayFunc / dayVal) +hourExpr + = firstArg:(hourFunc / hourVal) + SP operatorVal:( eqOperator / numCompOperator ) SP + secondArg:(hourFunc / hourVal) +minuteExpr + = firstArg:( minuteFunc / minuteVal ) + SP operatorVal:( eqOperator / numCompOperator ) SP + secondArg:( minuteFunc / minuteVal ) +monthExpr + = firstArg:(monthFunc / monthVal) + SP operatorVal:( eqOperator / numCompOperator ) SP + secondArg:(monthFunc / monthVal) +secondExpr + = firstArg:(secondFunc / secondVal) + SP operatorVal:( eqOperator / numCompOperator ) SP + secondArg:(secondFunc / secondVal) +yearExpr + = firstArg:(yearFunc / yearVal) + SP operatorVal:( eqOperator / numCompOperator ) SP + secondArg:(yearFunc / yearVal) +timeExpr + = firstArg:(timeFunc / timeOfDayValue) + SP operatorVal:( numCompOperator / eqOperator ) SP + secondArg:(timeFunc / timeOfDayValue) + +strArg = ( + substringFunc / + tolowerFunc / + toupperFunc / + trimFunc / + concatFunc / + strLiteral / + field +) +numberArg = ( + lengthFunc / + indexofFunc / + roundFunc / + number / + field +) + +// ---------- Functions ---------- +// +// ---------- "contains" / "endswith" / "startswith" ---------- +boolFunc + = funcName:( "contains" / "endswith" / "startswith" ) + OPEN + fieldRef:strArg COMMA + containsStrArg:strArg + CLOSE + { + function getLikeArgs (value) { + const funcArgs = { + contains: [ "'%'", value, "'%'" ], + endswith: [ "'%'", value ], + startswith: [ value, "'%'" ] + }; + return funcArgs[funcName]; + }; + filterExpr.appendWhereClause([ + fieldRef, + 'like', + { + func: 'concat', + args: getLikeArgs(containsStrArg) + }, + 'escape', + "'^'" + ]); + } +// ---------- "length" ---------- +lengthFunc + = "length" + OPEN + fieldRef:strArg + CLOSE + { + return { + func: 'length', + args: [ fieldRef ] + } + } +// ---------- "indexof" ---------- +indexofFunc + = "indexof" + OPEN + fieldRef:strArg COMMA + strArgVal:strArg + CLOSE + { + return { + func: 'locate', + args: [ fieldRef, strArgVal ] + } + } +// ---------- "substring" ---------- +substringFunc + = "substring" + OPEN + fieldRef:strArg COMMA + arg:( + (numberArg COMMA numberArg) / + numberArg + ) + CLOSE + { + const args = Array.isArray(arg) ? + [ + fieldRef, + ...arg.filter(cur => cur !== ',') + ] : + [fieldRef, arg]; + return { + func: 'substring', + args: args + } + } +// ---------- "tolower" ---------- +tolowerFunc + = "tolower" + OPEN + fieldRef:strArg + CLOSE + { + return { + func: 'lower', + args: [ fieldRef ] + } + } +// ---------- "toupper" ---------- +toupperFunc + = "toupper" + OPEN + fieldRef:strArg + CLOSE + { + return { + func: 'upper', + args: [ fieldRef ] + } + } +// ---------- "trim" ---------- +trimFunc + = "trim" + OPEN + fieldRef:strArg + CLOSE + { + return { + func: 'trim', + args: [ fieldRef ] + } + } +// ---------- "concat" ---------- +concatFunc + = "concat" + OPEN + fieldRef:strArg COMMA + strArgVal:strArg + CLOSE + { + return { + func: 'concat', + args: [ fieldRef, strArgVal ] + } + } +// ---------- "day" ---------- +dayFunc + = "day" + OPEN + fieldRef:(dateTimeOffsetValue / dateValue / field) + CLOSE + { + return { + func: 'dayofmonth', + args: [ fieldRef ] + } + } + +// ---------- "hour" ---------- +hourFunc + = "hour" + OPEN + fieldRef:(dateTimeOffsetValue / timeOfDayValue / field) + CLOSE + { + return { + func: 'hour', + args: [ fieldRef ] + } + } + +// ---------- "minute" ---------- +minuteFunc + = "minute" + OPEN + fieldRef:(dateTimeOffsetValue / timeOfDayValue / field) + CLOSE + { + return { + func: 'minute', + args: [ fieldRef ] + } + } + +// ---------- "month" ---------- +monthFunc + = "month" + OPEN + fieldRef:(dateTimeOffsetValue / dateValue / field) + CLOSE + { + return { + func: 'month', + args: [ fieldRef ] + } + } + +// ---------- "second" ---------- +secondFunc + = "second" + OPEN + fieldRef:(dateTimeOffsetValue / timeOfDayValue / field) + CLOSE + { + return { + func: 'second', + args: [ fieldRef ] + } + } + +// ---------- "year" ---------- +yearFunc + = "year" + OPEN + fieldRef:(dateTimeOffsetValue / dateValue / field) + CLOSE + { + return { + func: 'year', + args: [ fieldRef ] + } + } + +// ---------- "time" ---------- +timeFunc + = "time" + OPEN + fieldRef:(dateTimeOffsetValue / field) + CLOSE + { + return { + func: 'to_time', + args: [ fieldRef ] + } + } + +// ---------- "round" ---------- +roundFunc + = "round" + OPEN + fieldRef:(dateTimeOffsetValue / field) + CLOSE + { + return { + func: 'round', + args: [ fieldRef ] + } + } + + +expand + = ((c:ref {expand(c)}) (o'('o QueryOptions o')'o)? {end()}) + (o','o expand)? + +select + = (c:ref {select(c)}) + (o','o select)? + +top + = n:$[0-9]+ + { (SELECT.limit || (SELECT.limit={})).rows = { val: parseInt(n) } } + +skip + = n:$[0-9]+ + { (SELECT.limit || (SELECT.limit={})).offset = { val: parseInt(n) } } + +other "other query options" + = o:$([^=]+) "=" x:todo + { SELECT[o.slice(1)] = x; console.log('another option was called') } + +ref "a reference" + = p:$[^,?&()]+ { + const reffs = p.split('/'); + if(reffs.length === 2 && reffs[reffs.length-1] === '$count') { + SELECT.count = true; + } + return { ref: [reffs[0]] }; + } + +todo = $[^,?&]+ + +count = c:$[^,?&()]+ + { if(c === 'true') SELECT.count = true } + +orderby = o:$[^,?&()]+ + { + const matches = o.match(/\w+\s(asc|desc)/g); + if(!!matches) { + const out = matches[0].split(/\s/g); + SELECT.orderby = [ + { ref: [out[0]], sort: out[1] } + ] + } + } + + +// Primitive literals +// +// date +dateValue "Edm.Date" = val:$( year "-" month "-" day ) + { return { val } } +year = "-"? ( "0" DIGIT DIGIT DIGIT / oneToNine DIGIT DIGIT DIGIT ) +yearVal = val:$year { return { val } } +month = "0" oneToNine + / "1" ( "0" / "1" / "2" ) +monthVal = val:$month { return { val } } +day = "0" oneToNine + / ( "1" / "2" ) DIGIT + / "3" ( "0" / "1" ) +dayVal = val:$day { return { val } } +oneToNine = "1" / "2" / "3" / "4" / "5" / "6" / "7" / "8" / "9" + +// datetime offset +dateTimeOffsetValue "Edm.DateTimeOffset" = val:$( year "-" month "-" day "T" hour ":" minute ( ":" second ( "." fractionalSeconds )? )? ( "Z" / SIGN hour ":" minute )) + { return { val: (new Date(val)).toISOString() } } +hour = ( "0" / "1" ) DIGIT + / "2" ( "0" / "1" / "2" / "3" ) +hourVal = val:$hour { return { val } } +minute = zeroToFiftyNine +minuteVal = val:$minute { return { val } } +second = zeroToFiftyNine +secondVal = val:$second { return { val: parseInt(val).toFixed(3) } } +zeroToFiftyNine = ( "0" / "1" / "2" / "3" / "4" / "5" ) DIGIT +fractionalSeconds = DIGIT DIGIT? DIGIT? DIGIT? + DIGIT? DIGIT? DIGIT? DIGIT? + DIGIT? DIGIT? DIGIT? DIGIT? + +// time of day value +timeOfDayValue "Edm.TimeOfDay" = val:$( hour ":" minute ( ":" second ( "." fractionalSeconds )? )? ) + { return { val } } + +// string +strLiteral "Edm.String" = SQUOTE strArgVal:strEntry SQUOTE + { return strArgVal; } +strEntry = symbols:( ( SQUOTEInString / pcharNoSQUOTE )* ) + { return { val: symbols.join('') }; } + +// field name +field "field name" + = field:$([a-zA-Z] [_a-zA-Z0-9]*) + { return { ref: [field] }; } + +// number +number = val:$( + doubleValue / + singleValue / + decimalValue / + int64Value / + int32Value / + int16Value + ) { + console.log('number', val) + return { val: Number(val) }; + } + +// IEEE 754 binary64 floating-point number (15-17 decimal digits) +doubleValue "Edm.Double" = decimalValue ( "e" SIGN? DIGIT+ )? / nanInfinity +decimalValue "Edm.Decimal" = SIGN? DIGIT+ ("." DIGIT+)? +// IEEE 754 binary32 floating-point number (6-9 decimal digits) +singleValue "Edm.Single" = doubleValue +// numbers in the range from -9223372036854775808 to 9223372036854775807 +// contains 1-19 digits +int64Value "Edm.Int64" = SIGN? DIGIT DIGIT? DIGIT? DIGIT? DIGIT? + DIGIT? DIGIT? DIGIT? DIGIT? DIGIT? + DIGIT? DIGIT? DIGIT? DIGIT? DIGIT? + DIGIT? DIGIT? DIGIT? DIGIT? +// numbers in the range from -2147483648 to 2147483647 +// contains 1-15 digits +int32Value "Emd.Int32" + = val:(SIGN? DIGIT DIGIT? DIGIT? DIGIT? DIGIT? + DIGIT? DIGIT? DIGIT? DIGIT? DIGIT? + DIGIT? DIGIT? DIGIT? DIGIT? DIGIT?) + { return parseInt(val); } +// numbers in the range from -32768 to 32767 +// contains 1-5 digits +int16Value "Edm.Int16" = SIGN? DIGIT DIGIT? DIGIT? DIGIT? DIGIT? +nanInfinity = 'NaN' / '-INF' / 'INF' + + +// ---------- URI sintax ---------- +// +otherDelims = "!" / "(" / ")" / "*" / "+" / "," / ";" +unreserved = [A-Za-z] / [0-9] / " " / "-" / "." / "_" / "~" +pcharNoSQUOTE = val:$(unreserved / otherDelims / "$" / "&" / "=" / ":" / "@") + { return val; } +SQUOTEInString = SQUOTE SQUOTE // two consecutive single quotes represent one within a string literal + { return "'"; } // convert double quotation mark to single like in current CAP implementaion + +// ---------- Punctuation ---------- +// +WS "whitespace" = ( SP / HTAB )* + +AT = "@" +COLON = ":" +COMMA = "," +EQ = "=" +SIGN = "+" / "-" +SEMI = ";" +STAR = "*" +SQUOTE = "'" +OPEN = "(" +CLOSE = ")" + +// ---------- Operators ---------- +// +eqOperator + = operatorVal:("eq" / "ne") + { return compOperators[operatorVal]; } +numCompOperator + = operatorVal:("lt" / "gt" / "le" / "ge") + { return compOperators[operatorVal]; } +logicalOperator = operator:$("and" / "or") + { filterExpr.appendWhereClause([operator]); } +notOperator + = notOperator:$("not") + { filterExpr.appendWhereClause([notOperator]); } + + +// +// ---------- ABNF core definitions ---------- +// +ALPHA "letter" = [A-Za-z] +AtoF "A to F" = "A" / "B" / "C" / "D" / "E" / "F" +DIGIT "digit" = [0-9] +HEXDIG "hexing" = DIGIT / AtoF +DQUOTE "double quote" = '"' +SP "space" = ' ' +HTAB "horizontal tab" = ' ' +WSP "white space" = SP / HTAB +BIT = "0" / "1" +RWS = ( SP / HTAB )+ // "required" whitespace + + +//-- Whitespaces +o "optional whitespaces" = $[ \t\n]* \ No newline at end of file diff --git a/odata/package.json b/odata/package.json new file mode 100644 index 00000000..bea51578 --- /dev/null +++ b/odata/package.json @@ -0,0 +1,11 @@ +{ + "name": "@sap/cds-odata", + "version": "1.0.0", + "dependencies": { + "@capire/bookshop": "*" + }, + "devDependencies": { + "pegjs": "^0.10.0" + }, + "private": true +} diff --git a/odata/test/odata2cqn.test.js b/odata/test/odata2cqn.test.js new file mode 100644 index 00000000..9eecfdaa --- /dev/null +++ b/odata/test/odata2cqn.test.js @@ -0,0 +1,109 @@ +const { parse:{cql:CQL}, test:{expect}} = require("@sap/cds/lib") +const { parser:{parse:OData} } = require("../lib/odata2cqn") + +describe("$filter", () => { + // logger can be omitted + // afterEach(function () { + // logParserResult(newParserRes, `${this.currentTest.title}-new`); + // logParserResult(currentParserRes, `${this.currentTest.title}-cur`); + // }); + + describe("comparing expressions", () => { + + const types = { + strings: "'some string'", + 'safe integers': 11, + 'unsafe integers': 2e53, + decimals: 0.99, + // ... + } + it.each(Object.keys(types))("should support expressions with %s", (t) => { + expect (OData(`Foo?$filter=bar eq ${types[t]}`)) + .to.eql (CQL(`SELECT from Foo where bar = ${types[t]}`)) + }) + + const operators = { + eq: '=', + lt: '<', + le: '<=', + gt: '>', + ge: '>=', + ne: '!=', + // ... + } + it.each(Object.keys(operators))("should support comparison operator '%s'", (op) => { + expect (OData(`Foo?$filter=bar ${op} 11`)) + .to.eql (CQL(`SELECT from Foo where bar ${operators[op]} 11`)) + }) + + }); + + describe("logical expressions", () => { + + it.each(['and','or'])("should support '%s'", (t) => { + expect (OData(`Foo?$filter=bar lt 11 and name eq 'some name'`)) + .to.eql (CQL(`SELECT from Foo where bar < 11 and name = 'some name'`)) + }) + + it("should support 'not'", () => { + // REVISIT: We need to check with the Node.js team why they translated that to the equivalent of: + // not name like concat('%','sunny','%') escape '^' + expect (OData(`Foo?$filter = not contains(name,'sunny')`)) + .to.eql (CQL(`SELECT from Foo where not name like '%sunny%'`)) + }); + + it("should support group expr", () => { + expect (OData(`Foo?$filter = (unitPrice lt 11 and length(name) eq 12) or name eq 'Restless and Wild'`)) + .to.eql (CQL(`SELECT from Foo where (unitPrice < 11 and length(name) = 12) or name = 'Restless and Wild'`)) + }); + }); + + describe("function expressions", () => { + + it("should support contains", () => { + expect (OData(`Foo?$filter = contains(name,'sunny')`)) + .to.eql (CQL(`SELECT from Foo where name like '%sunny%'`)) + }); + + it("should support startswith", () => { + expect (OData(`Foo?$filter = startswith(name,'sunny')`)) + .to.eql (CQL(`SELECT from Foo where name like 'sunny%'`)) + }); + + it("should support endswith", () => { + expect (OData(`Foo?$filter = endswith(name,'sunny')`)) + .to.eql (CQL(`SELECT from Foo where name like '%sunny'`)) + }); + + it("should support length", () => { + expect (OData(`Foo?$filter = length(name) lt 11`)) + .to.eql (CQL(`SELECT from Foo where length(name) < 11`)) + }); + + it("should support indexof", () => { + expect (OData(`Foo?$filter = indexof(name,'x') eq 11`)) + .to.eql (CQL(`SELECT from Foo where locate(name,'x') = 12`)) + }); + + it("should support substring", () => { + expect (OData(`Foo?$filter = substring(name,1) eq 'foo'`)) + .to.eql (CQL(`SELECT from Foo where substring(name,2) = 'foo'`)) + }); + + it.each(['tolower','toupper','trim'])("should support '%s'", (fn) => { + expect (OData(`Foo?$filter = ${fn}(name) eq 'foo'`)) + .to.eql (CQL(`SELECT from Foo where ${fn.replace(/^to/,'')}(name) = 'foo'`)) + }); + + it("should support 'day'", () => { + expect (OData(`Foo?$filter = day(name) eq 11`)) + .to.eql (CQL(`SELECT from Foo where dayofmonth(name) = 11`)) + }); + + it("should support concat", () => { + expect (OData(`Foo?$filter = concat(name,'o') eq 'foo'`)) + .to.eql (CQL(`SELECT from Foo where concat(name,'o') = 'foo'`)) + }); + + }); +}); diff --git a/package.json b/package.json index 21fbea51..3e8d099e 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "@capire/reviews": "./reviews" }, "devDependencies": { + "pegjs": "^0.10.0", "chai": "^4.2.0", "chai-as-promised": "^7.1.1", "chai-subset": "^1.6.0",