@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
JavaScript
'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;