UNPKG

@sap/cds-compiler

Version:

CDS (Core Data Services) compiler and backends

1,185 lines (1,094 loc) 75.4 kB
'use strict'; const { isEdmPropertyRendered, transformAnnotationExpression } = require('../../model/csnUtils'); const { isBuiltinType, isMagicVariable } = require('../../base/builtins'); const edmUtils = require('../edmUtils.js'); const oDataDictionary = require('../../gen/Dictionary.json'); const preprocessAnnotations = require('./preprocessAnnotations.js'); const { forEachDefinition } = require('../../model/csnUtils'); const { isBetaEnabled, setProp } = require('../../base/model.js'); const { xpr2edmJson, getEdmJsonHandler } = require('./edmJson.js'); const { vocabularyDefinitions } = require('./vocabularyDefinitions.js'); const { EdmPathTypeMap } = require('../EdmPrimitiveTypeDefinitions.js'); /** ************************************************************************************************ * csn2annotationEdm * * options: * v - array with two boolean entries, first is for v2, second is for v4 * dictReplacement: for test purposes, replaces the standard oDataDictionary */ function csn2annotationEdm( reqDefs, reqDefsUtils, csnVocabularies, serviceName, Edm, options, messageFunctions, mergedVocDefs = vocabularyDefinitions ) { const gAnnosArray = []; // global variable where we store all the generated annotations const usedExperimentalTerms = {}; // take note of all experimental annos that have been used const usedDeprecatedTerms = {}; // take note of all deprecated annos that have been used const { v } = options; const { message, error } = messageFunctions; const { handleEdmJson } = getEdmJsonHandler(Edm, options, messageFunctions, handleTerm); const [ userDefinedTermDict, allKnownVocabularies ] = createUserDefinedTermDictionary(); allKnownVocabularies.push(...Object.keys(mergedVocDefs)); allKnownVocabularies.sort((a, b) => b.length - a.length); const whatsMyTermNamespace = anno => allKnownVocabularies.reduce((rc, ns) => (!rc && anno && anno.startsWith(`@${ ns }.`) ? ns : rc), undefined); // annotation preprocessing preprocessAnnotations.preprocessAnnotations(reqDefs, serviceName, options, messageFunctions); // we take note of which vocabularies are actually used in a service in order to avoid // producing useless references; reset everything to "unused" for (const n in mergedVocDefs) { mergedVocDefs[n].used = false; delete mergedVocDefs[n].$ignore; } // These vocabularies are always added for the runtimes mergedVocDefs.Common.used = true; mergedVocDefs.Core.used = true; const vocDef = mergedVocDefs[serviceName]; if (vocDef && vocDef.$optVocRef) { setProp(vocDef, '$ignore', true); message('odata-anno-vocref', null, { name: serviceName, '#': 'service' } ); } forEachDefinition(reqDefs, (def, defName) => { if (defName.startsWith(`${ serviceName }.`)) assignParameterAnnotations(def); }); // Crawl over the csn and trigger the annotation translation for all kinds // of annotated things. // Note: only works for single service // Note: we assume that all objects lie flat in the service, i.e. objName always // looks like <service name, can contain dots>.<id> forEachDefinition(reqDefs, (def, defName) => { if (defName === serviceName || defName.startsWith(`${ serviceName }.`)) { const location = [ 'definitions', defName ]; // the <objName> is not the carrier name for <objName>Type // and sometimes the object.name doesn't have a service prefix if (def.name && def.name.startsWith(`${ serviceName }.`)) defName = def.name; if (def.kind === 'action' || def.kind === 'function') handleAction(defName, def, null, location); else handleDefinition(defName, def, location); } }); // filter out empty <Annotations...> elements // add references for the used vocabularies return { annos: gAnnosArray, usedVocabularies: Object.values(mergedVocDefs).filter(voc => voc.used), xrefs: Object.values(userDefinedTermDict.xrefs).filter(voc => voc.used).map(voc => voc.$myServiceRoot), }; //------------------------------------------------------------------------------------------------- //------------------------------------------------------------------------------------------------- //------------------------------------------------------------------------------------------------- // helper to determine the OData version // TODO: improve option handling function isV2() { return v && v[0]; } function assignParameterAnnotations( def ) { // Copy annotations from origin to parameter entity if it's // qualified with #$parameters or if its applicable to an EntitySet or Singleton const scopeCheck = { ref: (elemref, prop, xpr, path) => { if (scopeCheck.scope === 'param' && !isMagicVariable(xpr[0]) && (!elemref.param || (xpr[0].id || xpr[0]) === '$self' && !def.elements.$self)) { error('odata-anno-xpr-ref', path, { anno: scopeCheck.anno, elemref, '#': 'notaparam' }); // don't try to resolve those paths later on delete elemref[prop]; } if (scopeCheck.scope === 'type' && elemref.param) { error('odata-anno-xpr-ref', path, { anno: scopeCheck.anno, elemref, '#': 'notaneelement' }); delete elemref[prop]; } if (scopeCheck.scope === 'param' && elemref.param) { // make sure that path is resolvable as element path later on delete elemref.param; const head = xpr[0].id || xpr[0]; if (head[0] === '$') xpr.unshift('$self'); } }, }; const checkDict = (dict, scope) => { if (dict) { scopeCheck.scope = scope; Object.values(dict).forEach((carrier) => { const knownAnnos = filterKnownAnnotations(carrier); knownAnnos.forEach((pn) => { scopeCheck.anno = pn; transformAnnotationExpression(carrier, pn, scopeCheck, carrier.$path); }); }); } }; const checkObj = (obj, scope) => { scopeCheck.scope = scope; const knownAnnos = filterKnownAnnotations(obj); knownAnnos.forEach((pn) => { scopeCheck.anno = pn; transformAnnotationExpression(obj, pn, scopeCheck, obj.$path); }); }; if (def.$isParamEntity && def._origin) { // check for correct paths if (def._origin.$paramsAnnoProxies) { def.$elementsAnnoProxies = def._origin.$paramsAnnoProxies; checkDict(def.$elementsAnnoProxies, 'param'); } checkDict(def._origin.$elementsAnnoProxies, 'type'); checkDict(def.elements, 'param'); checkDict(def._origin.elements, 'type'); scopeCheck.scope = 'param'; Object.keys(def._origin).forEach((attr) => { if (attr[0] === '@') { scopeCheck.anno = attr; const [ prefix, innerAnnotation ] = attr.split('.@'); const ns = whatsMyTermNamespace(prefix); if (ns) { const steps = prefix.replace(`@${ ns }.`, '').split('.'); const paramAnnoParts = steps[0].split('#$parameters'); const dictTerm = getDictTerm(`${ ns }.${ paramAnnoParts[0] }`, options); if (paramAnnoParts.length > 1 || [ 'Singleton', 'EntitySet' ].some(y => dictTerm?.AppliesTo?.includes(y))) { steps[0] = `@${ ns }.${ paramAnnoParts.join('') }`; let newAnno = steps.join('.'); if (innerAnnotation) newAnno += `.@${ innerAnnotation }`; edmUtils.assignAnnotation(def, newAnno, def._origin[attr]); transformAnnotationExpression(def._origin, attr, scopeCheck, def._origin.$path); if (paramAnnoParts.length > 1) delete def._origin[attr]; } } } }); checkObj(def._origin, 'type'); } } /* Mapping annotated thing in cds/csn => annotated thing in edmx: carrier: the annotated thing in cds, can be: service, entity, structured type, element of entity or structured type, action/function, parameter of action/function target: the annotated thing in OData In the edmx, all annotations for a OData thing are put into an element <Annotations Target="..."> where Target is the full name of the target There is one exception (Schema), see below carrier = service the target is the EntityContainer, unless the annotation has an "AppliesTo" where only Schema is given, but not EntityContainer then the <Annotation ...> is directly put into <Schema ...> without an enclosing <Annotations ...> carrier = entity (incl. view/projection) the target is the corresponding EntityType, unless the annotation has an "AppliesTo" where only EntitySet is given, but not EntityType then the target is the corresponding EntitySet carrier = structured type the target is the corresponding ComplexType carrier = element of entity or structured type the target is the corresponding Property of the EntityType/ComplexType: Target = <entity/type>/<element> carrier = action/function v2, unbound: Target = <service>.EntityContainer/<action/function> v2, bound: Target = <service>.EntityContainer/<entity>_<action/function> v4, unbound action: Target = <service>.<action>() v4, bound action: Target = <service>.<action>(<service>.<entity>) v4, unbound function: Target = <service>.<function>(<1st param type>, <2nd param type>, ...) v4, bound function: Target = <service>.<function>(<service>.<entity>, <1st param type>, <2nd param type>, ...) carrier = parameter of action/function like above, but append "/<parameter" to the Target */ function handleDefinition( defName, def, location ) { // definition bound annotations handleAnnotations(defName, def, { location }); // definition bound element annotations if (def.$elementsAnnoProxies) { Object.entries(def.$elementsAnnoProxies).forEach(([ elemPath, element ]) => { const edmTargetName = `${ defName }/${ elemPath }`; handleAnnotations(edmTargetName, element, { location: element.$path, csnPath: [ ...location, '$elementsAnnoProxies', elemPath ], }); }); } // element bound annotations if (def.elements) { Object.entries(def.elements).forEach(([ elemName, element ]) => { const edmTargetName = `${ defName }/${ elemName }`; const eLocation = [ ...location, 'elements', elemName ]; handleAnnotations(edmTargetName, element, { location: eLocation }); }); } // bound actions if (def.actions) handleBoundActions(defName, def, location); } // Annotations for actions and functions (and their parameters) // v2, unbound: Target = <service>.EntityContainer/<action/function> // v2, bound: Target = <service>.EntityContainer/<entity>_<action/function> // v4, unbound action: Target = <service>.<action>() // v4, bound action: Target = <service>.<action>(<service>.<entity>) // v4, unbound function: Target = <service>.<function>(<1st param type>, <2nd param type>, ...) // v4, bound function: Target = <service>.<function>(<service>.<entity>, <1st param type>, <2nd param type>, ...) // handle the annotations of cObject's (an entity) bound actions/functions and their parameters // in: cObjectname : qualified name of the object that holds the actions // cObject : the object itself function handleBoundActions( cObjectname, cObject, location ) { // get service name: remove last part of the object name // only works if all objects ly flat in the service const nameParts = cObjectname.split('.'); const entityName = nameParts.pop(); Object.entries(cObject.actions).forEach(([ n, action ]) => { setProp(action, '$isBound', true); const v2entity = isV2() ? `${ entityName }_` : ''; const actionName = `${ serviceName }.${ v2entity }${ n }`; handleAction(actionName, action, cObjectname, [ ...location, 'actions', n ]); }); } // handle the annotations of an action and its parameters // called by handleBoundActions and directly for unbound actions/functions // in: cActionName : qualified name of the action // cAction : the action object // entityNameIfBound : qualified name of entity if bound action/function function handleAction( cActionName, cAction, entityNameIfBound, location ) { let actionName = cActionName; if (isV2()) { // Replace up to last dot with <serviceName>.EntityContainer const lastDotIndex = actionName.lastIndexOf('.'); if (lastDotIndex > -1) actionName = `${ serviceName }.EntityContainer/${ actionName.substring(lastDotIndex + 1) }`; } else { // add parameter type list actionName += relParList(); } handleAnnotations(actionName, cAction, { location, cAction }); if (cAction.params) { if (cAction.$paramsAnnoProxies) { Object.entries(cAction.$paramsAnnoProxies).forEach(([ paramPath, param ]) => { // skip explicit binding parameter in V2 if (!(options.isV2() && param.type === '$self' && paramPath === cAction.$bindingParam?.name)) { const edmTargetName = `${ actionName }/${ paramPath }`; handleAnnotations(edmTargetName, param, { location: param.$path, csnPath: [ ...location, '$paramsAnnoProxies', paramPath ], cAction, }); } }); } // explicit binding parameter is removed from params in V2 during // createActionV2(), no need to check ;) Object.entries(cAction.params).forEach(([ n, p ]) => { const edmTargetName = `${ actionName }/${ n }`; handleAnnotations(edmTargetName, p, { action: true, location: [ ...location, 'params', n ], cAction, }); }); } if (cAction.returns) { if (cAction.$returnsAnnoProxies) { Object.entries(cAction.$returnsAnnoProxies).forEach(([ returnsPath, returns ]) => { const edmTargetName = `${ actionName }/${ returnsPath }`; handleAnnotations(edmTargetName, returns, { location: returns.$path, csnPath: [ ...location, '$returnsAnnoProxies', returnsPath ], cAction, }); }); } const edmTargetName = `${ actionName }/$ReturnType`; setProp(cAction.returns, '$appliesToReturnType', true); handleAnnotations(edmTargetName, cAction.returns, { location: [ ...location, 'returns' ], cAction, }); delete cAction.returns.$appliesToReturnType; } function relParList() { // we rely on the order of params in the csn being the correct one const params = []; if (entityNameIfBound) { // If this is an action and has an explicit binding parameter add it here if (cAction.$bindingParam && cAction.kind === 'action' || cAction.$bindingParam?.viaAnno) { params.push( cAction.$bindingParam.items ? `Collection(${ entityNameIfBound })` : entityNameIfBound ); } } // In case this is a function the explicit binding parameter is part of // the functions params dictionary. Only for functions all parameters must // be listed in the annotation target if (cAction.kind === 'function' && cAction.params) { Object.values(cAction.params).forEach((p) => { const isArrayType = !p.type && p.items && p.items.type; params.push(isArrayType ? `Collection(${ mapType(p.items) })` : mapType(p)); }); } return `(${ params.join(',') })`; function mapType( p ) { if (isBuiltinType(p.type)) { return edmUtils.mapCdsToEdmType(p, messageFunctions, options /* is only called for v4 */); } else if (options.whatsMySchemaName) { const schemaName = options.whatsMySchemaName(p._edmType || p.type); // strip the service namespace of from a parameter type if (schemaName && schemaName !== options.serviceName) return (p._edmType || p.type).replace(`${ options.serviceName }.`, ''); } return p._edmType || p.type; } } } // handle all the annotations for a given cds thing, here called carrier // edmTargetName : string, name of the target in edm // carrier: object, the annotated cds thing, contains all the annotations // as properties with names starting with @ // ctx: locations and other information that is required to write the // annotations function handleAnnotations( edmTargetName, carrier, ctx ) { // collect the names of the carrier's annotation properties // keep only those annotations that - start with a known vocabulary name // - have a value other than null // if the carrier is an element that is not rendered or // if the carrier is a derived type of a primitive type which is not rendered in V2 // if the carrier is a media stream element in V2 // do nothing if (!isEdmPropertyRendered(carrier, options) || (isV2() && (edmUtils.isDerivedType(carrier)))) return; if (ctx.location == null) throw Error('location required'); // Filter unknown toplevel annotations // Final filtering of all annotations is done in handleTerm let knownAnnos = filterKnownAnnotations(carrier); if (knownAnnos.length === 0) return; if (rewriteInnerAnnotations()) { knownAnnos = filterKnownAnnotations(carrier); if (knownAnnos.length === 0) return; } knownAnnos.forEach((knownAnno) => { if (knownAnno.search(/\.\$edmJson\./g) < 0) { transformAnnotationExpression(carrier, knownAnno, { ref: (elemref, prop, xpr, csnPath) => { if (options.isV2() && elemref.$bparam) { error('odata-anno-xpr-ref', ctx.location, { elemref, anno: knownAnno, version: '2.0', '#': 'bparam_v2_expl', }); return; } const { links, scope } = reqDefsUtils.inspectRef(csnPath); let i = scope === '$self' ? 1 : 0; if (scope === '$magic') { error('odata-anno-xpr-ref', ctx.location, { elemref, anno: knownAnno, '#': 'magic', }); return; } let stop = false; for (; i < links.length && !stop; i++) { if (!isEdmPropertyRendered(links[i].art, csnPath)) { error('odata-anno-xpr-ref', ctx.location, { count: i + 1, elemref, anno: knownAnno, '#': 'notrendered', }); stop = true; } if (links[i].art?._target?.$proxy && i < links.length - 1) { const proxy = links[i].art?._target; const eltName = links[i + 1].art?.name; if (!proxy.elements[eltName]) { error('odata-anno-xpr-ref', ctx.location, { count: i + 2, elemref, anno: knownAnno, '#': 'notrendered', }); stop = true; } } } if (!stop && ctx.cAction?.$isBound && scope === '$self') { if (options.isV2()) { error('odata-anno-xpr-ref', ctx.location, { elemref, anno: knownAnno, version: '2.0', '#': 'bparam_v2_impl', }); } else { xpr[0] = ctx.cAction.$bindingParam.name; elemref.param = true; } } }, }, ctx.csnPath || ctx.location); xpr2edmJson(carrier, knownAnno, ctx.location, options, messageFunctions, { oDataDictionary, getDictTerm, getDictType, }); } }); const prefixTree = createPrefixTree(); // usually, for a given carrier there is one target // for some carriers (service, entity), there can be an alternative target (usually the EntitySet) // alternativeEdmTargetName: name of alternative target // which one to choose depends on the "AppliesTo" message of the single annotations, so we have // to defer this decision; this is why we here construct a function that can make the decision // later when looking at single annotations const [ stdEdmTargetName, // either the schema path or the EntityContainer itself hasAlternativeCarrier, // is the alternative annotation target available in the EDM? alternativeEdmTargetName, // EntitySet path name testToStandardEdmTarget, // if true, assign to standard Edm Target testToAlternativeEdmTarget, // if true, assign to alternative Edm Target ] = initCarrierControlVars(); // collect produced Edm.Annotation nodes for various carriers const serviceAnnotations = []; const stdAnnotations = []; const alternativeAnnotations = []; // now create annotation objects for all the annotations of carrier handleAnno2(addAnnotation, prefixTree, ctx.location); // Produce Edm.Annotations and attach collected Edm.Annotation(s) to the // envelope (or directly to the Schema) if (serviceAnnotations.length) gAnnosArray.push(...serviceAnnotations.filter(a => a)); if (stdAnnotations.length) { const annotations = new Edm.Annotations(v, stdEdmTargetName); // used in closure annotations.append(...stdAnnotations); gAnnosArray.push(annotations); } if (alternativeAnnotations.length) { const annotations = new Edm.Annotations(v, alternativeEdmTargetName); annotations.append(...alternativeAnnotations); gAnnosArray.push(annotations); } // construct a function that is used to add an <Annotation ...> to the // respective collector array // this function is specific to the actual carrier, following the mapping rules given above function addAnnotation( annotation, appliesTo ) { let rc = false; if (testToAlternativeEdmTarget && appliesTo && testToAlternativeEdmTarget(appliesTo)) { if (carrier.kind === 'service') { if (isV2()) { // there is no enclosing <Annotations ...>, so for v2 the namespace needs to be mentioned here annotation.setXml( { xmlns: 'http://docs.oasis-open.org/odata/ns/edm' } ); } serviceAnnotations.push(annotation); // for target Schema: no <Annotations> element } else if (hasAlternativeCarrier) { alternativeAnnotations.push(annotation); } rc = true; } if (testToStandardEdmTarget(appliesTo)) { stdAnnotations.push(annotation); rc = true; } // Another crazy hack due to this crazy function: // If carrier is a managed association (has keys) and rc is false (annotation was not applicable) // return true to NOT trigger 'unapplicable' message message if (rc === false && carrier.target && carrier.keys && appliesTo.includes('Property')) rc = true; return rc; } function initCarrierControlVars() { let testToStandardEdmTargetP = () => true; // if true, assign to standard Edm Target let stdEdmTargetNameP = edmTargetName; let alternativeEdmTargetNameP = null; let hasAlternativeCarrierP = false; // is the alternative annotation target available in the EDM? let testToAlternativeEdmTargetP = null; // if true, assign to alternative Edm Target if (carrier.kind === 'entity') { // If AppliesTo=[EntitySet/Singleton/Collection, EntityType], EntitySet/Singleton/Collection has precedence testToAlternativeEdmTargetP = ((x) => { if (x) { if (options.isV2()) return [ 'Singleton', 'EntitySet', 'Collection' ].some(y => x.includes(y)); return edmUtils.isSingleton(carrier) ? x.includes('Singleton') : [ 'EntitySet', 'Collection' ].some(y => x.includes(y)); } return true; }); testToStandardEdmTargetP = (x => (x ? x.includes('EntityType') : true)); // if carrier has an alternate 'entitySetName' use this instead of EdmTargetName // (see edmPreprocessor.initializeParameterizedEntityOrView(), where parameterized artifacts // are split into *Parameter and *Type entities and their respective EntitySets are eventually // renamed. // (which is the definition key in the CSN and usually the name of the EntityType) // Replace up to last dot with <serviceName>.EntityContainer/ alternativeEdmTargetNameP = carrier.$entitySetName || edmTargetName; const lastDotIndex = alternativeEdmTargetNameP.lastIndexOf('.'); if (lastDotIndex > -1) alternativeEdmTargetNameP = `${ serviceName }.EntityContainer/${ alternativeEdmTargetNameP.substring(lastDotIndex + 1) }`; hasAlternativeCarrierP = carrier.$hasEntitySet; } else if (carrier.kind === 'type') { testToStandardEdmTargetP = (x => (x ? x.includes(carrier.elements ? 'ComplexType' : 'TypeDefinition') : true)); } else if (carrier.kind === 'action' || carrier.kind === 'function') { const type = carrier.kind === 'action' ? 'Action' : 'Function'; const container = carrier.kind === 'action' ? 'ActionImport' : 'FunctionImport'; if (options.isV4()) { testToStandardEdmTargetP = (x => (x ? x.includes(type) : true)); // Unbound actions/functions are Action/FunctionImports and are bound to container target testToAlternativeEdmTargetP = (x => (x ? x.includes(container) && !carrier.$isBound : true)); const lastDotIndex = carrier.name.lastIndexOf('.'); alternativeEdmTargetNameP = lastDotIndex > -1 ? `${ serviceName }.EntityContainer/${ carrier.name.substring(lastDotIndex + 1) }` : `${ serviceName }.EntityContainer/${ carrier.name }`; hasAlternativeCarrierP = true; } if (options.isV2()) // same as in V4 but everything goes to standard target testToStandardEdmTargetP = (x => (x ? x.includes(type) || (x.includes(container) && !carrier.$isBound) : true)); } else if (carrier.kind === 'service') { // if annotated object is a service, annotation goes to EntityContainer, // except if AppliesTo contains Schema but not EntityContainer, then annotation goes to Schema testToAlternativeEdmTargetP = (x => x.includes('Schema') && !x.includes('EntityContainer')); testToStandardEdmTargetP = ( x => (x ? ( // either only AppliesTo=[EntityContainer] (!x.includes('Schema') && x.includes('EntityContainer')) || // or AppliesTo=[Schema, EntityContainer] (x.includes('Schema') && x.includes('EntityContainer'))) : true) ); stdEdmTargetNameP = `${ edmTargetName }.EntityContainer`; alternativeEdmTargetNameP = edmTargetName; hasAlternativeCarrierP = true; // EntityContainer is always available } // element => decide if navprop or normal property else if (!carrier.kind) { // if appliesTo is undefined, return true if (carrier.target) { testToStandardEdmTargetP = (x => (x ? x.includes('NavigationProperty') || carrier.cardinality && carrier.cardinality.max === '*' && x.includes('Collection') : true)); } else if (carrier.$appliesToReturnType) { testToStandardEdmTargetP = (x => (x ? x.includes('ReturnType') : true)); } else { // this might be more precise if handleAnnotation would know more about the carrier testToStandardEdmTargetP = (x => (x ? [ 'Parameter', 'Property' ].some(y => x.includes(y) || carrier.$isCollection && x.includes('Collection')) : true)); } } return [ stdEdmTargetNameP, hasAlternativeCarrierP, alternativeEdmTargetNameP, testToStandardEdmTargetP, testToAlternativeEdmTargetP, ]; /* all AppliesTo entries: "Action", "ActionImport", "Annotation", "Collection", "ComplexType", "EntityContainer", "EntitySet", "EntityType", "Function", "FunctionImport", "Include", "NavigationProperty", "Parameter", "Property", "PropertyValue", "Record", "Reference", "ReturnType", "Schema", "Singleton", "Term", "TypeDefinition" */ } function rewriteInnerAnnotations() { let rc = false; for (const a of knownAnnos) { const [ prefix, innerAnnotation ] = a.split('.@'); /* New inner annotation (de-)structuring of the core compiler to make $value arrays extendable via ellipsis @anno: { $value: [ ... ], @innerAnno: ... } is now cracked up by the core compiler into: @anno: [ ...] @anno.@innerAnno: ... Conflict handling if $value is present: @anno @anno.$value @anno.@innerAnno @anno has precedence (as it was before this change) but now @anno.$value is overwritten with @anno and the inner annotations are applied. Trigger is always the inner annotation, if no inner annotation is available, @anno has precedence. Insert $value into $edmJson with inner annotation as well. */ if (innerAnnotation) { // != null => also != undefined if (carrier[prefix] != null) { const valPrefix = `${ prefix }.$value`; carrier[valPrefix] = carrier[prefix]; delete carrier[prefix]; rc = true; } const edmJsonPrefix = `${ prefix }.$edmJson`; if (carrier[edmJsonPrefix] != null) { const valPrefix = `${ prefix }.$value.$edmJson`; carrier[valPrefix] = carrier[edmJsonPrefix]; delete carrier[edmJsonPrefix]; rc = true; } } } return rc; } function createPrefixTree() { // in csn, all annotations are flattened // => values can be - primitive values (string, number) // - pseudo-records with "#" or "=" // - arrays // in OData, there are "structured" annotations -> we first need to regroup the cds annotations // by building a "prefix tree" for the annotations attached to the carrier // see example at definition of function mergePathStepsIntoPrefixTree const prefixTreeP = {}; for (const a of knownAnnos) { // remove leading @ and split at "." // stop splitting at ".@" (used for nested annotations) // Inline JSON EDM allows to add annotations to record members // by prefixing the annotation with the record member 'foo@Common.Label' // The splitter should leave such annotations alone, handleEdmJson // takes care of assigning these annotations to the record members const [ prefix, innerAnnotation ] = a.split('.@'); const ns = whatsMyTermNamespace(prefix); const steps = prefix.replace(`@${ ns }.`, '').split('.'); steps.splice(0, 0, ns); let i = steps.lastIndexOf('$edmJson'); if (i > -1) { i = steps.findIndex(s => s.includes('@'), i + 1); if (i > -1) steps.splice(i, steps.length - i, steps.slice(i).join('.')); } if (innerAnnotation) { // A voc annotation has two steps (Namespace+Name), // any further steps need to be rendered separately if (innerAnnotation.startsWith('sap.')) { steps.push(`@${ innerAnnotation }`); } else { const innerAnnoSteps = innerAnnotation.split('.'); const tailSteps = innerAnnoSteps.splice(2, innerAnnoSteps.length - 2); // prepend annotation prefix (path) to tail steps tailSteps.splice(0, 0, `@${ innerAnnoSteps.join('.') }`); steps.push(...tailSteps); } } mergePathStepsIntoPrefixTree(prefixTreeP, steps, 0); } return prefixTreeP; // tree: object where to put the next level of names // path: the parts of the annotation name // index: index into that array pointing to the next name to be processed // 0 : vocabulary // 1 : term // 2+ : record properties // // example: // @v.t1 // @v.t2.p1 // @v.t2.p2 // @v.t3#x.q1 // @v.t3#x.q2 // @v.t3#y.q1 // @v.t3#y.q2 // // { v : { t1 : ..., // t2 : { p1 : ..., // p2 : ... }, // t3#x : { q1 : ..., // q2 : ... } // t3#y : { q1 : ..., // q2 : ... } } } function mergePathStepsIntoPrefixTree( tree, pathSteps, index ) { // TODO check nesting level > 3 const name = pathSteps[index]; if (index + 1 < pathSteps.length ) { if (!tree[name]) tree[name] = {}; mergePathStepsIntoPrefixTree(tree[name], pathSteps, index + 1); } else if (typeof tree === 'object' ) { tree[name] = carrier[`@${ pathSteps.join('.') }`]; } } } } // handle all the annotations for a given carrier // addAnnotationFunc: a function that adds the <Annotation ...> tags created here into the // correct parent tag (see handleAnnotations()) // prefixTree: the annotations function handleAnno2( addAnnotationFunc, prefixTree, location ) { // first level names of prefix tree are the vocabulary names // second level names are the term names // create an annotation tag <Annotation ...> for each term for (const voc of Object.keys(prefixTree)) { for (const term of Object.keys(prefixTree[voc])) { const fullTermName = `${ voc }.${ term }`; // msg is "semantic" location message used for messages const msg = { fullTermName, stack: [], location: [ ...location, `@${ fullTermName }` ], }; msg.anno = () => msg.fullTermName + msg.stack.join(''); // anno is the full <Annotation Term=...> const anno = handleTerm(fullTermName, prefixTree[voc][term], msg); if (!anno?.$isInvalid) { // addAnnotationFunc needs AppliesTo message from dictionary to decide where to put the anno const termName = fullTermName.replace(/#(\w+)$/g, ''); // remove qualifier const dictTerm = getDictTerm(termName, msg); // message for unknown term was already issued in handleTerm if (!addAnnotationFunc(anno, dictTerm && dictTerm.AppliesTo)) { if (dictTerm && dictTerm.AppliesTo) { message('odata-anno-def', location, { anno: termName, rawvalues: dictTerm.AppliesTo, '#': 'notapplied' }); } } } } } } // annoValue : the annotation value from the csn // if the csn contains flattened out elements of a structured annotation, // they are regrouped here // msg : for messages // return : object that represents the annotation in the result edmx function handleTerm( termName, annoValue, msg ) { /** * create the <Annotation ...> tag * @type {object} * */ let newAnno; const omissions = { 'Aggregation.default': 1 }; const nullList = { 'Core.OperationAvailable': 1, 'Core.OptionalParameter': 1 }; if (annoValue != null && !omissions[termName] || nullList[termName]) { // termName may contain a qualifier: @UI.FieldGroup#shippingStatus // -> remove qualifier from termName and set Qualifier attribute in newAnno const i = termName.indexOf('#'); const termNameWithoutQualifiers = i > 0 ? termName.substring(0, i) : termName; const qualifier = i >= 0 ? termName.substring(i + 1) : undefined; termNameWithoutQualifiers.split('.').forEach((id) => { if (!edmUtils.isODataSimpleIdentifier(id)) message('odata-invalid-name', msg.location, { id }); }); newAnno = new Edm.Annotation(v, termNameWithoutQualifiers); if (qualifier?.length) { if (!edmUtils.isODataSimpleIdentifier(qualifier)) message('odata-invalid-qualifier', msg.location, { id: qualifier }); newAnno.setEdmAttribute('Term', termNameWithoutQualifiers); newAnno.setEdmAttribute('Qualifier', qualifier); } // get the type of the term from the dictionary let termTypeName = null; const dictTerm = getDictTerm(termNameWithoutQualifiers, msg); if (dictTerm) { termTypeName = dictTerm.Type; } else { // message if term is completely unknown or if vocabulary is unchecked const myVocDef = mergedVocDefs[whatsMyTermNamespace(`@${ termNameWithoutQualifiers }`)]; if ((myVocDef?.int && myVocDef?.int?.filename) || !myVocDef) message('odata-anno-def', msg.location, { anno: termNameWithoutQualifiers }); } // handle the annotation value and put the result into the <Annotation ...> tag just created above handleValue(annoValue, newAnno, termNameWithoutQualifiers, termTypeName, msg); } return newAnno; } // handle an annotation value // cAnnoValue: the annotation value (c : csn) // oTarget: the result object (o: odata) // oTermName: current term // dTypeNameArg: expected type of cAnnoValue according to dictionary, may be null (d: dictionary) function handleValue( cAnnoValue, oTarget, oTermName, dTypeNameArg, msg ) { // this function basically only figures out what kind of annotation value we have // (can be: array, expression, enum, pseudo-record, record, simple value), // then calls a more specific function to deal with it and puts // the result into the oTarget object const [ dTypeName, dTypeIsACollection ] = stripCollection(dTypeNameArg); if (Array.isArray(cAnnoValue)) { if (isEnumType(dTypeName)) { // if we find an array although we expect an enum, this may be a "flag enum" checkMultiEnumValue(); oTarget.setJSON({ EnumMember: generateMultiEnumValue(false), 'EnumMember@odata.type': `#${ dTypeName }` }); oTarget.setXml( { EnumMember: generateMultiEnumValue(true) }); } else { oTarget.append(generateCollection(cAnnoValue, oTermName, dTypeName, dTypeIsACollection, msg)); } } else if (cAnnoValue && typeof cAnnoValue === 'object') { // an empty record is rendered as <Record/> if ('=' in cAnnoValue) { if (dTypeIsACollection) { message('odata-anno-value', msg.location, { anno: msg.anno(), str: 'path', '#': 'incompval' }); } // expression const res = handleExpression(cAnnoValue['='], dTypeName); oTarget.setXml({ [res.name]: res.value }); oTarget.setJSON({ [res.name]: res.value }); } else if (cAnnoValue['#'] !== undefined) { const enumSymbol = cAnnoValue['#']; // enum if (dTypeName) { const typeDef = getDictType(dTypeName); if (typeDef && typeDef.$Allowed && !typeDef.Members) { const allowedValue = typeDef.$Allowed.Symbols[enumSymbol]; if (!allowedValue) { message('odata-anno-value', msg.location, { anno: msg.anno(), type: dTypeName, value: `"#${ enumSymbol }"`, rawvalues: Object.keys(typeDef.$Allowed.Symbols).map(m => `#${ m }`), '#': 'enum', }); } else { oTarget.setXml( { [typeDef.UnderlyingType?.replace('Edm.', '') || 'String']: allowedValue.Value || enumSymbol }); } } else if (checkEnumValue(enumSymbol)) { oTarget.setXml( { EnumMember: `${ dTypeName }/${ enumSymbol }` }); } else { oTarget.setXml( { String: enumSymbol }); } } else { oTarget.setXml( { EnumMember: `${ oTermName }Type/${ enumSymbol }` }); } oTarget.setJSON({ 'Edm.String': enumSymbol }); } else if (cAnnoValue.$value !== undefined) { // "pseudo-structure" used for annotating scalar annotations handleValue(cAnnoValue.$value, oTarget, oTermName, dTypeNameArg, msg); const k = Object.keys(cAnnoValue).filter( x => x[0] === '@'); if (!k || k.length === 0) { message('odata-anno-value', msg.location, { anno: msg.anno(), str: 'nested', '#': 'nested' }); } for (const nestedAnnoName of k) { const nestedAnno = handleTerm(nestedAnnoName.slice(1), cAnnoValue[nestedAnnoName], msg); oTarget.append(nestedAnno); } } else if (cAnnoValue.$edmJson) { // "pseudo-structure" used for embedding a piece of JSON that represents "OData CSDL, JSON Representation" const edmNode = handleEdmJson(cAnnoValue.$edmJson, msg); if (edmNode && edmNode._kind === 'Path' && typeof edmNode._value === 'string' && !edmNode._children.length) oTarget.setXml({ [edmNode._kind]: edmNode._value }); else oTarget.append(edmNode); } else if (Object.keys(cAnnoValue).filter( x => x[0] !== '@' ).length === 0) { // object consists only of properties starting with "@", no $value setProp(oTarget, '$isInvalid', true); message('odata-anno-value', msg.location, { anno: msg.anno(), str: 'base', '#': 'nested' } ); } else { // regular record if (dTypeIsACollection) { message('odata-anno-value', msg.location, { anno: msg.anno(), str: 'structured', type: dTypeName, '#': 'incompval', }); } oTarget.append(generateRecord(cAnnoValue, oTermName, dTypeName, dTypeIsACollection, msg)); } } else { const res = handleSimpleValue(cAnnoValue, dTypeName, msg); if (((oTermName === 'Core.OperationAvailable' && dTypeName === 'Edm.Boolean') || (oTermName === 'Core.OptionalParameter' && dTypeName === 'Edm.String') || (oTermName === 'Validation.AllowedValues' && dTypeName === 'Edm.PrimitiveType')) && cAnnoValue === null) { oTarget.append(new Edm.ValueThing(v, 'Null')); oTarget._ignoreChildren = true; } else { oTarget.setXml( { [res.name]: res.value }); } oTarget.setJSON( { [res.jsonName]: res.value }); } // found an enum value ("#"), check whether this fits // the expected type "dTypeName" function checkEnumValue( value ) { let rc = true; const expectedType = getDictType(dTypeName); if (!expectedType && !isPrimitiveType(dTypeName)) { message('odata-anno-dict', msg.location, { anno: msg.anno(), type: dTypeName }); } else if (isComplexType(dTypeName) || isPrimitiveType(dTypeName) || expectedType.$kind !== 'EnumType') { message('odata-anno-value', msg.location, { anno: msg.anno(), type: dTypeName, value: `"#${ value }"`, }); rc = false; } else if (!expectedType.Members.includes(value)) { message('odata-anno-value', msg.location, { anno: msg.anno(), type: dTypeName, value: `"#${ value }"`, rawvalues: expectedType.Members.map(m => `#${ m }`), '#': 'enum', }); } return rc; } // cAnnoValue: array // dTypeName: expected type, already identified as enum type // array is expected to contain enum values function checkMultiEnumValue( ) { // we know that dTypeName is not null const type = getDictType(dTypeName); if (!type || type.IsFlags !== 'true') { message('odata-anno-value', msg.location, { anno: msg.anno(), str: 'collection', type: dTypeName, '#': 'incompval', }); } let index = 0; for (const value of cAnnoValue) { msg.stack.push(`[${ index }]`); index++; if (value['#']) { checkEnumValue(value['#']); } else { message('odata-anno-value', msg.location, { anno: msg.anno(), type: dTypeName, value: value['='] || value, rawvalues: type.Members.map(m => `#${ m }`), '#': 'enum', }); } msg.stack.pop(); } } function generateMultiEnumValue( forXml ) { // remove all invalid entries (warnining message has already been issued) // replace short enum name by the full name // concatenate all the enums to a string, separated by spaces return cAnnoValue.filter( x => x['#']).map( x => (forXml ? `${ dTypeName }/` : '') + x['#'] ).join(forXml ? ' ' : ','); } } // found an expression value ("=") "expr" // expected type is dTypeName // note: expr can also be provided if an enum/complex type/collection is expected function handleExpression( value, dTypeName ) { let typeName = 'Path'; if ( EdmPathTypeMap[dTypeName] ) { if (dTypeName === 'Edm.AnyPropertyPath') typeName = 'PropertyPath'; else typeName = dTypeName.split('.')[1]; } if (typeof value === 'string') { // replace all occurrences of '.' by '/' up to first '@' value = value.split('@').map((o, i) => (i === 0 ? o.replace(/\./g, '/') : o)).join('@'); } return { name: typeName, value, }; } // found a simple value "val" // expected type is dTypeName // mapping rule for values: // if expected type is ... the expression to be generated is ... // floating point type except Edm.Decimal -> Float // Edm.Decimal -> Decimal // integer type -> Int function handleSimpleValue( value, dTypeName, msg ) { // these types must be represented as "String" values in XML: const castToXmlString = [ 'Edm.PrimitiveType', 'Edm.Stream', 'Edm.Untyped' ]; // caller already made sure that val is neither object nor array // check if type has allowed values const typeDef = getDictType(dTypeName); const Allowed = typeDef?.$Allowed; let resolvedType = resolveTypeDefinition(dTypeName); if (isEnumType(resolvedType)) { const type = getDictType(resolvedType); const expected = type.Members.map(m => `#${ m }`); message('odata-anno-value', msg.location, { anno: msg.anno(), value, rawvalues: expected, type: resolvedType, '#': 'enum', }); } let typeName = 'String'; if (Allowed && !Allowed.Values[value]) { message('odata-anno-value', msg.location, { anno: msg.anno(), value, rawvalues: Object.keys(Allowed.Values), type: resolvedType, '#': 'enum', }); } if (typeof value === 'string') { if (resolvedType === 'Edm.Boolean') { typeName = 'Bool'; if (value !== 'true' && value !== 'false') { message('odata-anno-value', msg.location, { anno: msg.anno(), value, type: resolvedType }); } } else if (resolvedType === 'Edm.Decimal') { typeName = 'Decimal'; // eslint-disable-next-line no-restricted-globals if (isNaN(Number(value)) || isNaN(parseFloat(value))) { message('odata-anno-value', msg.location, { anno: msg.anno(), value, type: resolvedType }); } } else if (resolvedType === 'Edm.Double' || resolvedType === 'Edm.Single') { typeName = 'Float'; // eslint-disable-next-line no-restricted-globals if (isNaN(Number(value)) || isNaN(parseFloat(value))) { message('odata-anno-value', msg.location, { anno: msg.anno(), value, type: resolvedType }); } } else if (isComplexType(resolvedType)) { message('odata-anno-value', msg.location, { anno: msg.anno(), value, type: resolvedType }); } else if (isEnumType(resolvedType)) { message('odata-anno-value', msg.location, { anno: msg.anno(), value, type: resolvedType }); typeName = 'EnumMember'; } else if (resolvedType && resolvedType.startsWith('Edm.') &&