UNPKG

@sap/xsodata

Version:

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

866 lines (788 loc) 28.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 */ const 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() { let segment = this.reduceSegments.shift(); // of type uriSegmentTypes.js#ResourceSegment // Resolve entity type let 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 + '.' ); } // Compute initial state: input parameters, keys, collection flag; may advance segment/entityType const state = this._computeFirstSegmentState(entityType, segment); entityType = state.entityType; segment = state.segment; const inputParameters = state.inputParameters; const keys = state.keys; const isCollection = state.isCollection; // 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' ); } this.firstDBSegment.isCollection = isCollection; return this.firstDBSegment; }; ResourcePathReader.prototype._computeFirstSegmentState = function ( entityType, segment ) { if ( !( entityType.kind === model.entityKind.inputParameters || entityType.kind === model.entityKind.calculationView ) ) { return { entityType: entityType, segment: segment, inputParameters: null, keys: segment.keys, isCollection: segment.keys ? false : true, }; } if ( entityType._entityType && entityType._entityType.parameters && entityType._entityType.parameters.viaKey === true ) { const split = this._splitKeysForViaKey(entityType, segment); return { entityType: entityType, segment: segment, inputParameters: split.inputParameters, keys: split.keys, isCollection: split.isCollection, }; } return this._adoptCalcViewSegmentOrThrow(entityType, segment); }; ResourcePathReader.prototype._splitKeysForViaKey = function ( entityType, segment ) { let inputParameters = []; let keys = null; let isCollection = true; 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; } return { inputParameters: inputParameters, keys: keys, isCollection: isCollection, }; }; ResourcePathReader.prototype._adoptCalcViewSegmentOrThrow = function ( entityType, segment ) { const inputParameters = segment.keys; let keys = null; let nextSeg = this.reduceSegments.shift(); if (nextSeg) { const navigation = entityType.getNavigation(nextSeg.identifier); const association = this.gModel.getAssociationByName( navigation.association ); const endTo = this.getTargetEnd(navigation, association, entityType); const newEntityType = this.gModel.getEntityType(endTo.type); if (!newEntityType) { throw new Http404_NotFound( "Resource not found for the segment '" + nextSeg.identifier + "' at position " + nextSeg.position + '.' ); } keys = nextSeg.keys; const isCollection = nextSeg.keys ? false : true; return { entityType: newEntityType, segment: nextSeg, inputParameters: inputParameters, keys: keys, isCollection: isCollection, }; } if ( !entityType._entityType || !entityType._entityType.parameters || entityType._entityType.parameters.viaKey !== true ) { throw new Http400_BadRequest( 'Navigation to calc view expected after an Input path segment 1' ); } return { entityType: entityType, segment: segment, inputParameters: inputParameters, keys: keys, isCollection: true, }; }; ResourcePathReader.prototype.consumeFollowerSegment = function consumeFollowerSegment(prevDbSegment) { let segment = this.reduceSegments.shift(); this._validateFollowerPreconditions(prevDbSegment, segment); const special = this._handleSpecialSegments(prevDbSegment, segment); if (special !== null) { return special; } if (prevDbSegment.isCollection === true) { 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 const property = prevDbSegment.entityType.__metadata.getProperty( segment.identifier ); if (property) { prevDbSegment.kind = dbSegments.DBS_Property; prevDbSegment.singleProperty = property.COLUMN_NAME; return prevDbSegment; } const navInfo = this._resolveNavigation(prevDbSegment, segment); const dbSegment = this._createNavigationDbSegment( prevDbSegment, segment, navInfo ); return this._finalizeNavigationDbSegment(dbSegment, segment, navInfo); }; ResourcePathReader.prototype._validateFollowerPreconditions = function ( prevDbSegment, segment ) { 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) { 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." ); } }; ResourcePathReader.prototype._handleSpecialSegments = function ( prevDbSegment, segment ) { if (segment.identifier === '$count') { if (prevDbSegment.kind === dbSegments.DBS_Property) { 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'." ); } 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; } throw new Http400_BadRequest( "Resource not found for the segment '$value'" ); } return null; }; ResourcePathReader.prototype._resolveNavigation = function ( prevDbSegment, segment ) { const navigation = prevDbSegment.entityType.getNavigation( segment.identifier ); // ### association is first_to_second if (!navigation) { throw new Http400_BadRequest( "Resource not found for the segment '" + segment.identifier + "'" ); } const association = this.getAssociation(navigation); const endFrom = this.getFromEnd( navigation, association, prevDbSegment.entityType ); const endTo = this.getTargetEnd( navigation, association, prevDbSegment.entityType ); const targetEntity = this.gModel.getEntityType(endTo.type); return { navigation: navigation, association: association, endFrom: endFrom, endTo: endTo, targetEntity: targetEntity, }; }; ResourcePathReader.prototype._createNavigationDbSegment = function ( prevDbSegment, segment, navInfo ) { const dbSegment = new dbSegments.DbSegment( dbSegments.DBS_ResourceNavigation, navInfo.targetEntity, this.tableCount++ ); prevDbSegment.nextDBSegment = dbSegment; dbSegment.previousDBSegment = prevDbSegment; dbSegment.association = navInfo.association; dbSegment._urisegment = segment; dbSegment.setFrom(navInfo.endFrom); dbSegment.setOver(navInfo.association.over); dbSegment.setTo(navInfo.endTo); return dbSegment; }; ResourcePathReader.prototype._finalizeNavigationDbSegment = function ( dbSegment, segment, navInfo ) { const endTo = navInfo.endTo; const targetEntity = navInfo.targetEntity; const navigation = navInfo.navigation; if (targetEntity.kind === EntityType.entityKind.calculationView) { if ( targetEntity._entityType.parameters && targetEntity._entityType.parameters.viaKey === true ) { 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); dbSegment.setKeyValues(keys); dbSegment.isCollection = keys.length === 0; return dbSegment; } segment.keys.forEach(function (kv) { dbSegment.addInputParameter(this.context, kv.name, kv.value); }, this); segment = this.reduceSegments.shift(); const targetEntityParams = this.gModel.getEntityType( endTo.type + 'Parameters' ); if (!segment) { throw new Http400_BadRequest( 'Navigation to calc view expected after an Input path segment 2' ); } const navigation2 = targetEntityParams.getNavigation( segment.identifier ); const association2 = this.gModel.getAssociationByName( navigation2.association ); const endTo2 = this.getTargetEnd( navigation, association2, targetEntityParams ); const entityType2 = this.gModel.getEntityType(endTo2.type); if (!entityType2) { throw new Http404_NotFound( "Resource not found for the segment '" + segment.identifier + "' at position " + segment.position + '.' ); } if (endTo2.multiplicity === '*') { if (segment.keys) { dbSegment.isCollection = false; dbSegment.setKeyValues(segment.keys); } else { dbSegment.isCollection = true; } } else { dbSegment.isCollection = false; } return dbSegment; } 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) { const 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 const 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'." ); } const association = this.getAssociation(navigation); const endTo = this.getTargetEnd( navigation, association, prevDbSegment.entityType ); const endFrom = this.getFromEnd( navigation, association, prevDbSegment.entityType ); const targetEntity = this.gModel.getEntityType(endTo.type); const 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); } return dbSegment; }; ResourcePathReader.prototype.processResourcePath = function () { let 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; } const 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 ) { const navigation = dbSeg.entityType.getNavigation(expandNavName); if (!navigation) { throw new Http400_BadRequest( `Unknown navigation property '${expandNavName}' in entity type '${dbSeg.entityType.name}'` ); } const association = this.getAssociation(navigation); const endTo = this.getTargetEnd(navigation, association, dbSeg); const endFrom = this.getFromEnd(navigation, association, dbSeg); const targetEntity = this.gModel.getEntityType(endTo.type); const 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 (const expandNavName in tree.availableExpands) { if (tree.availableExpands.hasOwnProperty(expandNavName)) { this.addExpand( dbSeg, expandNavName, tree.availableExpands[expandNavName] ); } } } else { for (const name of tree.expandNavigations) { this.addExpand(dbSeg, name, tree.availableExpands[name]); } } //now add properties if (tree.isStar || tree.showAllProperties) { this.checkOtherProperties(dbSeg, tree.properties); dbSeg.addAllProperties(); } else { for (const selectedProperty of tree.properties) { dbSeg.addSelectProperties(selectedProperty); if ( dbSeg.isNavigationProperty(selectedProperty) && !dbSeg.getRelevantNavigationSegments()[selectedProperty] ) { const relevantNavigationSegment = this._createNavigationSegment( dbSeg, selectedProperty ); dbSeg.addRelevantNavigationSegment( selectedProperty, relevantNavigationSegment ); } } } }; ResourcePathReader.prototype.checkOtherProperties = function ( dbSeg, properties ) { const entityType = dbSeg.entityType; for (const propertyName of properties) { 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. const expands = queryParameters.expand; const selects = queryParameters.select; const 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 (const name in queryParameters) { if (queryParameters.hasOwnProperty(name)) { //do checks here this.lastDBSegment.systemQueryParameter[name] = queryParameters[name]; } } }; exports.parseResourcePath = function (context, odata) { const 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 const 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;