UNPKG

@sap/xsodata

Version:

Expose data from a HANA database as OData V2 service with help of .xsodata files.

626 lines (535 loc) 26.2 kB
'use strict'; const dbSegments = require('./../db/dbSegment'); const sel = require('./expandSelectTreeBuilder'); const Http404_NotFound = require('./../utils/errors/http/notFound'); const Http400_BadRequest = require('./../utils/errors/http/badRequest'); const associationUtils = require('./../utils/associations'); const model = require('../model/model.js'); const EntityType = require('../model/entityType.js'); /** * Class for reading a OData resource path URI * * @param context * @param {Array} segments Uri Segments * @constructor */ var ResourcePathReader = function (context, segments) { this.context = context; this.gModel = context.gModel; // used to moved segments as they are beeing processed this.reduceSegments = segments.slice(); this.firstDBSegment = null; this.lastDBSegment = null; //processing data this.tableCount = 1; }; /** * Consume and evaluates the first resource path segment * * @returns {null|dbSegments.DbSegment} */ ResourcePathReader.prototype.consumeFirstSegment = function consumeFirstSegment() { var segment = this.reduceSegments.shift(); //of type uriSegmentTypes.js#ResourceSegment //load entity from xsodata var entityType = this.gModel.getEntityType(segment.identifier); if (entityType === undefined) { throw new Http404_NotFound("Resource not found for the segment '" + segment.identifier + "' at position " + segment.position + "."); } // check if this segments is used to supply input parameters for the following calcview segment var inputParameters = null; var keys = null; var isCollection = null; if (entityType.kind === model.entityKind.inputParameters || entityType.kind === model.entityKind.calculationView) { if (entityType._entityType.parameters && entityType._entityType.parameters.viaKey === true) { // e.g. FromCalcView(IN_INTEGER=888,IN_NVARCHAR='AAA',KEY_1=1)/nav(IN_INTEGER=999,IN_NVARCHAR='BBB',KEY_2=38) // Inputparamters and are keys, both are provides as keys, however for the SQL select we must separate them. inputParameters = []; if (segment.keys) { keys = []; segment.keys.forEach(function (kv) { if (entityType.inputParameters[kv.name]) { inputParameters.push(kv); } else if (entityType.keys.names.includes(kv.name)) { keys.push(kv); } else { throw new Http400_BadRequest('Inputparameter/Key ' + kv.name + ' is unknown'); } }, this); isCollection = keys.length === 0 ? true : false; } else { keys = null; isCollection = true; } } else { // ### entityType is FromCalcviewParameters inputParameters = segment.keys; keys = null; // consume calc view segment segment = this.reduceSegments.shift(); //of type uriSegmentTypes.js#ResourceSegment if (segment) { // validate calc view segment and adopt entityType for next segments var navigation = entityType.getNavigation(segment.identifier); var association = this.gModel.getAssociationByName(navigation.association); // ### association is FromCalcViewParameters_to_FromCalcView var endTo = this.getTargetEnd(navigation, association, entityType); //var endFrom = this.getFromEnd(navigation, association, entityType); entityType = this.gModel.getEntityType(endTo.type); if (!entityType) { throw new Http404_NotFound("Resource not found for the segment '" + segment.identifier + "' at position " + segment.position + "."); } keys = segment.keys; isCollection = segment.keys ? false : true; } else { if (!entityType._entityType.parameters || entityType._entityType.parameters.viaKey !== true) { // check if its a calcview defined with "via key" throw new Http400_BadRequest('Navigation to calc view expected after an Input path segment 1'); } } } } else { inputParameters = null; keys = segment.keys; isCollection = segment.keys ? false : true; } // Create a dbSegment this.firstDBSegment = new dbSegments.DbSegment(dbSegments.DBS_Entity, entityType, this.tableCount++); this.firstDBSegment._urisegment = segment; // Set input parameters in case of a calcview if (inputParameters && (this.context.request.method === 'GET' || inputParameters.length > 0)) { inputParameters.forEach(function (kv) { this.firstDBSegment.addInputParameter(this.context, kv.name, kv.value); }, this); this.firstDBSegment.validateInputParameters(); } // Set the key predicates of the first segments or the calcview segments if (keys) { this.firstDBSegment.setKeyValues(segment.keys, entityType.kind === model.entityKind.calculationView && this.context.request.method === 'POST'); // set key array oder single key value } this.firstDBSegment.isCollection = isCollection; // ### dbsegment._InputParams is [IN_INTEGER, IN_NVARCHAR] // ### dbsegment._keyValues is [name: KEY_1, value: ... 1] // ### entityType FromCalcView return this.firstDBSegment; }; ResourcePathReader.prototype.consumeFollowerSegment = function consumeFollowerSegment(prevDbSegment) { var segment = this.reduceSegments.shift(); // ### segment is nav // ### prevDbSegment.kind is "entity" if (prevDbSegment.kind === dbSegments.DBS_Property && segment.identifier !== '$value') { throw new Http400_BadRequest("The segment '" + segment.identifier + "' in the request URI is not valid. Since the previous segment refers to a primitive type property, the only supported value for the next segment is '$value'."); } if (prevDbSegment.restriction.onlyValue === true) { throw new Http400_BadRequest("The segment '" + segment.identifier + "' in the request URI is not valid. $batch, $value, $metadata or a service operation which returns void must be the last segment."); } if (prevDbSegment.restriction.onlyCount === true) { //E.g. Teams('2')/Name/$count/invalid throw new Http400_BadRequest("The segment '" + prevDbSegment._urisegment.identifier + "'in the request URI is not valid. $batch, $value, $metadata or a service operation which returns void must be the last segment."); } if (segment.identifier === "$count") { if (prevDbSegment.kind === dbSegments.DBS_Property) { //E.g. Teams('2')/Name/$count throw new Http400_BadRequest("The segment '$count' in the request URI is not valid. Since the previous segment refers to a primitive type property, the only supported value for the next segment is '$value'."); } //allowed in XS2 and should return 1 (in XS1 what wrongly 404 returned) //if (prevDbSegment.isCollection === false) { //throw new Http404_NotFound("$count only allowed on collections"); prevDbSegment.restriction.onlyCount = true; return prevDbSegment; } if (segment.identifier === "$links") { return this.consumeLinksSegment(prevDbSegment); } if (segment.identifier === "$value") { if (prevDbSegment.kind === dbSegments.DBS_Property) { prevDbSegment.restriction.onlyValue = true; return prevDbSegment; } else { throw new Http400_BadRequest("Resource not found for the segment '$value'"); } } if (prevDbSegment.isCollection === true) { //E.g. /Teams/invalid throw new Http400_BadRequest(`The segment '${segment.identifier}' in the request URI is not valid. Since the previous segment refers to a collection, the only supported value for the next segment is '$count'.`); } //check for primitive property // ### segement.identifier is nav var property = prevDbSegment.entityType.__metadata.getProperty(segment.identifier); if (property) { // segment is a valid property in path prevDbSegment.kind = dbSegments.DBS_Property; prevDbSegment.singleProperty = property.COLUMN_NAME; return prevDbSegment; } var navigation = prevDbSegment.entityType.getNavigation(segment.identifier); // ### association is first_to_second if (!navigation) { //E.g. /Teams('1')/$links/invalid throw new Http400_BadRequest("Resource not found for the segment '" + segment.identifier + "'"); } var association = this.getAssociation(navigation); // ### association is first_to_second //var navigation = entityType.getNavigation(segment.identifier); //if (!association) { // // check for generated association between calcview paramters and calcview result // association = this.gModel.getAssociationByName(navigation.association); //} var endFrom = this.getFromEnd(navigation, association, prevDbSegment.entityType); var endTo = this.getTargetEnd(navigation, association, prevDbSegment.entityType); var targetEntity = this.gModel.getEntityType(endTo.type); // ### targetEntity is ToCalcView var dbSegment = new dbSegments.DbSegment(dbSegments.DBS_ResourceNavigation, targetEntity, this.tableCount++); prevDbSegment.nextDBSegment = dbSegment; dbSegment.previousDBSegment = prevDbSegment; dbSegment.association = association; dbSegment._urisegment = segment; //set used association ends dbSegment.setFrom(endFrom); dbSegment.setOver(association.over); dbSegment.setTo(endTo); if (targetEntity.kind === EntityType.entityKind.calculationView) { if (targetEntity._entityType.parameters && targetEntity._entityType.parameters.viaKey === true) { // e.g. FromCalcView(IN_INTEGER=888,IN_NVARCHAR='AAA',KEY_1=1)/nav(IN_INTEGER=999,IN_NVARCHAR='BBB',KEY_2=38) // Inputparamters and are keys, both are provides as keys, however for the SQL select we must separate them. let keys = []; let inputs = []; if (!segment.keys) { throw new Http400_BadRequest('Navigation to calc view: Input parameters for target calc view are missing'); } segment.keys.forEach(function (kv) { if (targetEntity.inputParameters[kv.name]) { inputs.push(kv); } else { keys.push(kv); } }, this); inputs.forEach(function (kv) { dbSegment.addInputParameter(this.context, kv.name, kv.value); }, this); //if (keys.length > 0) { dbSegment.setKeyValues(keys); //} dbSegment.isCollection = keys.length === 0; } else { // FromCalcViewParameters(IN_INTEGER=888,IN_NVARCHAR='AAA')/Results(KEY_1=1)/nav(IN_INTEGER=999,IN_NVARCHAR='BBB')/Results // the Results must be consumed segment.keys.forEach(function (kv) { dbSegment.addInputParameter(this.context, kv.name, kv.value); }, this); segment = this.reduceSegments.shift(); //of type uriSegmentTypes.js#ResourceSegment targetEntity = this.gModel.getEntityType(endTo.type + 'Parameters'); if (!segment) { throw new Http400_BadRequest('Navigation to calc view expected after an Input path segment 2'); } // validate calc view segment and adopt entityType for next segments var navigation2 = targetEntity.getNavigation(segment.identifier); var association2 = this.gModel.getAssociationByName(navigation2.association); // ### association is FromCalcViewParameters_to_FromCalcView endTo = this.getTargetEnd(navigation, association2, targetEntity); //var endFrom = this.getFromEnd(navigation, association2, entityType); var entityType2 = this.gModel.getEntityType(endTo.type); // ### entityType is FromCalcview if (!entityType2) { throw new Http404_NotFound("Resource not found for the segment '" + segment.identifier + "' at position " + segment.position + "."); } // endTo MUST not be the end of a calcviewparam calcview association if (endTo.multiplicity === '*') { if (segment.keys) { dbSegment.isCollection = false; dbSegment.setKeyValues(segment.keys); } else { dbSegment.isCollection = true; } } else { dbSegment.isCollection = false; } } } else { // NOT a calcview // endTo MUST not be the end of a calcviewparam calcview association if (endTo.multiplicity === '*') { if (segment.keys) { dbSegment.isCollection = false; dbSegment.setKeyValues(segment.keys); } else { dbSegment.isCollection = true; } } else { dbSegment.isCollection = false; } } return dbSegment; }; /** * Consume and the segments behind the "$links" segment * * @returns {null|dbSegments.DbSegment} */ ResourcePathReader.prototype.consumeLinksSegment = function (prevDbSegment) { var segment = this.reduceSegments.shift(); if (!segment || !segment.identifier) { // E.g. /Teams('1')/$links throw new Http400_BadRequest("The request URI is not valid. There must be a segment specified after the '$links' segment and the segment must refer to a entity resource."); } if (this.reduceSegments.length !== 0) { // E.g. /Teams('1')/$links/ne_Manager/nm_Employees throw new Http400_BadRequest("The request URI is not valid. There must be only one segment after the '$links' segment."); } // A key for the last segment is only allowed for GET & DELETE requests if (segment.keys && this.context.request.method !== "GET" && this.context.request.method !== "DELETE") { // E.g. /Teams('1')/$links/np_Players('2') throw new Http400_BadRequest("The request URI is not valid. The segment specified after the '$links' segment must have no key."); } if (prevDbSegment.kind === dbSegments.DBS_Property) { // E.g. /Teams('1')/Name/$links throw new Http400_BadRequest("The segment '$links' in the request URI is not valid. Since the previous segment refers to a primitive type property, the only supported value for the next segment is '$value'."); } if (prevDbSegment.isCollection === true) { // E.g. /Teams/$links throw new Http400_BadRequest("The segment '$links' at position 6 in the request URI is not valid. Since the previous segment refers to a collection, the only supported value for the next segment is '$count'."); } // If this request is not a GET request, then prevent multiple navigation properties before the $links request if (this.context.request.method !== "GET" && prevDbSegment.previousDBSegment) { throw new Http400_BadRequest("Multiple navigation properties are not supported for " + this.context.request.method + " requests"); } // Check for navigation property var navigation = prevDbSegment.entityType.getNavigation(segment.identifier); if (!navigation) { //E.g. /Teams('1')/$links/invalid throw new Http404_NotFound("The request URI is not valid. The segment '" + segment.identifier + "' must refer to a navigation property since the previous segment identifier is '$links'."); } var association = this.getAssociation(navigation); var endTo = this.getTargetEnd(navigation, association, prevDbSegment.entityType); var endFrom = this.getFromEnd(navigation, association, prevDbSegment.entityType); var targetEntity = this.gModel.getEntityType(endTo.type); var dbSegment = new dbSegments.DbSegment(dbSegments.DBS_ResourceNavigation, targetEntity, this.tableCount++); prevDbSegment.nextDBSegment = dbSegment; dbSegment.previousDBSegment = prevDbSegment; dbSegment.association = association; prevDbSegment.setLinks(); // set $links=true to the 1st DBSeg dbSegment.restriction.onlyRefs = true; //show only links on this navigation property dbSegment._urisegment = segment; // Set the key predicates of the segment if (segment.keys) { dbSegment.setKeyValues(segment.keys); } if (endTo.multiplicity === '*') { if (segment.keys) { dbSegment.isCollection = false; } else { dbSegment.isCollection = true; } dbSegment.multiplicity = '*'; } else { dbSegment.isCollection = false; dbSegment.multiplicity = '1'; } //set used association ends dbSegment.setFrom(endFrom); dbSegment.setTo(endTo); if (association.over) { dbSegment.setOver(association.over); prevDbSegment.setOver(association.over); } //this.dbSegments.push(dbSegment); return dbSegment; }; ResourcePathReader.prototype.processResourcePath = function () { var lastDBSegment = null; while (this.reduceSegments.length > 0) { if (lastDBSegment === null) { lastDBSegment = this.consumeFirstSegment(); } else { //the previousDBSegment may not change, e.g. in case if the next segment is a property segment lastDBSegment = this.consumeFollowerSegment(lastDBSegment); } } return lastDBSegment; }; /** * Add a new navigation dbSegment using the expanded navigation property and attach it as child to the * root segment (dbSeg parameter). * Then use the expandInformation to process nested expands * * @param dbSeg Which is the root of the new segment * @param expandNavName Name of expanded the navigation property * @param expandInformation Expand information containing nested expands */ ResourcePathReader.prototype.addExpand = function (dbSeg, expandNavName, expandInformation) { if (dbSeg.isLinks) { return; } var dbSegNew = this._createNavigationSegment(dbSeg, expandNavName); dbSeg.addExpandDbSegment(expandNavName, dbSegNew); // recurse this.addSelExpTree(dbSegNew, expandInformation); }; /** * Create a new navigation dbSegment based on the provided dbSegment and the navigation property name * * @param dbSeg * @param expandNavName * @returns {*|DbSegment} * @private */ ResourcePathReader.prototype._createNavigationSegment = function (dbSeg, expandNavName) { var navigation = dbSeg.entityType.getNavigation(expandNavName); if (!navigation) { throw new Http400_BadRequest(`Unknown navigation property '${expandNavName}' in entity type '${dbSeg.entityType.name}'`); } var association = this.getAssociation(navigation); var endTo = this.getTargetEnd(navigation, association, dbSeg); var endFrom = this.getFromEnd(navigation, association, dbSeg); var targetEntity = this.gModel.getEntityType(endTo.type); var dbSegNew = new dbSegments.DbSegment(dbSegments.DBS_Navigation, targetEntity, this.tableCount++); dbSegNew.previousDBSegment = dbSeg; dbSegNew.setFrom(endFrom); dbSegNew.setTo(endTo); dbSegNew.setOver(association.over); dbSegNew.isCollection = endTo.multiplicity === '*'; return dbSegNew; }; /** * Starts traversing the raw select-expand tree from the expandSelectTreeBuilder and adds * new dbSegments into the dbSegment expand tree. * * @param dbSeg * @param tree Tree with expand information (see {@link test/test_select_expand_tree.js} for samples) */ ResourcePathReader.prototype.addSelExpTree = function (dbSeg, tree) { //first add all known expanded navigation properties if (tree.expandAllAvailExpands === true) { for (var expandNavName in tree.availableExpands) { if (tree.availableExpands.hasOwnProperty(expandNavName)) { this.addExpand(dbSeg, expandNavName, tree.availableExpands[expandNavName]); } } } else { for (var i = 0; i < tree.expandNavigations.length; i++) { var name = tree.expandNavigations[i]; this.addExpand(dbSeg, name, tree.availableExpands[name]); } } //now add properties if (tree.isStar || tree.showAllProperties) { this.checkOtherProperties(dbSeg, tree.properties); dbSeg.addAllProperties(); } else { for (var i1 = 0; i1 < tree.properties.length; i1++) { var selectedProperty = tree.properties[i1]; dbSeg.addSelectProperties(selectedProperty); if (dbSeg.isNavigationProperty(selectedProperty) && !dbSeg.getRelevantNavigationSegments()[selectedProperty]) { var relevantNavigationSegment = this._createNavigationSegment(dbSeg, selectedProperty); dbSeg.addRelevantNavigationSegment(selectedProperty, relevantNavigationSegment); } } } }; ResourcePathReader.prototype.checkOtherProperties = function (dbSeg, properties) { var entityType = dbSeg.entityType; for (var i1 = 0; i1 < properties.length; i1++) { var propertyName = properties[i1]; if (!(entityType.propertiesMap[propertyName] || entityType.navPropertiesMap[propertyName]) && entityType.keys.generatedKey !== propertyName) { throw(new Http400_BadRequest('property "' + propertyName + '" not allowed')); } } }; ResourcePathReader.prototype.buildODataTreeTrunk = function () { this.lastDBSegment = this.processResourcePath(); }; ResourcePathReader.prototype.insertODataNavTreeCrone = function (queryParameters) { //first check for the $expend and $select parameters. var expands = queryParameters.expand; var selects = queryParameters.select; var expSelTree = sel.processExpandSelectTree(expands, selects); this.addSelExpTree(this.lastDBSegment, expSelTree); if (this.firstDBSegment.isLinks) { this.addSelExpTree(this.firstDBSegment, expSelTree); } }; ResourcePathReader.prototype.insertQueryParameters = function (queryParameters) { //first check for the $expend and $select parameters. for (var name in queryParameters) { if (queryParameters.hasOwnProperty(name)) { //do checks here this.lastDBSegment.systemQueryParameter[name] = queryParameters[name]; } } }; exports.parseResourcePath = function (context, odata) { var resourcePathReader = new ResourcePathReader(context, odata.segments); // Creates the dbSegment line from the URI up to the "?" resourcePathReader.buildODataTreeTrunk(); // Evaluate the $select and $expand system query parameter and build a tree resourcePathReader.insertODataNavTreeCrone(odata.systemQueryParameters); resourcePathReader.insertQueryParameters(odata.systemQueryParameters); odata.dbSegment = resourcePathReader.firstDBSegment; odata.dbSegmentLast = resourcePathReader.lastDBSegment; // process only if the uri is in the pattern segment/$links/segment if (odata.dbSegment.isLinks === true && odata.dbSegmentLast.restriction.onlyRefs === true) { //if (odata.dbSegmentLast.isCollection) { // can not use isCollection for the case of: DELETE Managers('3')/$links/nm_Employees('1') if (odata.dbSegmentLast.multiplicity === '*') { odata.links = { //e.g. /Manager(1)/$links/nm_Employees // Uri : Employees(2) //only POST/Create is allowed (vid_Manager is changed in employee record set) toBeUpdated: odata.dbSegmentLast, keySource: odata.dbSegment }; } else { odata.links = { //e.g. /Employees(1)/$links/nm_Manager // Uri : Manager(1) //only Put/Update is allowed (vid_Manager is changed in employee record set) toBeUpdated: odata.dbSegment, keySource: odata.dbSegmentLast }; } // The corresponding association for the given request will be needed many times for custom exits processing var association = odata.dbSegmentLast.association; odata.links.association = association; if (odata.dbSegmentLast.entityType.name === association.principal.type) { odata.links.principal = odata.dbSegmentLast; odata.links.dependent = odata.dbSegment; } else if (odata.dbSegmentLast.entityType.name === association.dependent.type) { odata.links.principal = odata.dbSegment; odata.links.dependent = odata.dbSegmentLast; } } }; /** * Calculates the target end depending on navigation, association an source entityType * * @param navigation * @param association * @param entityType * @returns {*} */ ResourcePathReader.prototype.getTargetEnd = function (navigation, association, entityType) { return associationUtils.getTargetEnd(navigation, association, entityType); }; /** * Calculates the from end depending on navigation, association an source entityType * * @param navigation * @param association * @param entityType * @returns {*} */ ResourcePathReader.prototype.getFromEnd = function (navigation, association, entityType) { return associationUtils.getFromEnd(navigation, association, entityType); }; /** * Load a association object from the model * * @param navigation Navigation whose association should be loaded * @returns {*} */ ResourcePathReader.prototype.getAssociation = function (navigation) { return this.gModel.getAssociation(navigation.association); }; exports.ResourcePathReader = ResourcePathReader;