UNPKG

@sap/cds-compiler

Version:

CDS (Core Data Services) compiler and backends

633 lines (549 loc) 28.1 kB
'use strict'; const { isBetaEnabled } = require('../base/model'); const transformUtils = require('./transformUtils'); const { forEachDefinition, forEachMemberRecursively, applyTransformationsOnNonDictionary, getArtifactDatabaseNameOf, getElementDatabaseNameOf, getServiceNames, forEachGeneric, cardinality2str, getUtils } = require('../model/csnUtils'); const { checkCSNVersion } = require('../json/csnVersion'); const validate = require('../checks/validator'); const { isArtifactInSomeService, isLocalizedArtifactInService } = require('./odata/utils'); const expandToFinalBaseType = require('./odata/toFinalBaseType'); const { timetrace } = require('../utils/timetrace'); const enrichUniversalCsn = require('./universalCsn/universalCsnEnricher'); const flattening = require('./odata/flattening'); const createForeignKeyElements = require('./odata/createForeignKeys'); const associations = require('./db/associations') const expansion = require('./db/expansion'); const generateDrafts = require('./draft/odata'); const { addTenantFields } = require('./addTenantFields'); const { addLocalizationViews } = require('./localized'); const { cloneFullCsn } = require('../model/cloneCsn'); const { csnRefs } = require('../model/csnRefs'); const replaceForeignKeyRefsInExpressionAnnotations = require('./odata/foreignKeyRefsInXprAnnos'); const { isAnnotationExpression, xprInAnnoProperties } = require('../base/builtins'); // Transformation for ODATA. Expects a CSN 'inputModel', processes it for ODATA. // The result should be suitable for consumption by EDMX processors (annotations and metadata) // and also as a final CSN output for the ODATA runtime. // Performs the following: // - Validate the input model. (forODataNew Candidate) // - Unravel derived types for elements, actions, action parameters, types and // annotations (propagating annotations). // (EdmPreproc Candidate, don't know if flatten step depends on it) // - If we execute in flat mode, flatten: // -- structured elements // -- all the references in the model // -- foreign keys of managed associations (cover also the case when the foreign key is // pointing to keys that are themselves managed associations) // (long term EdmPreproc Candidate when RTs are able to map to flat) // - Generate foreign keys for all the managed associations in the model as siblings to the association // where ever the association is located (toplevel in flat or deep structured). (forODataNew Candidate) // - Tackle on-conditions in unmanaged associations. In case of flat mode - flatten the // on-condition, in structured mode - normalize it. (forODataNew Candidate) // - Generate artificial draft fields if requested. (forODataNew Candidate) // - Check associations for: // TODO: move to validator (Is this really required here? // EdmPreproc cuts off assocs or adds proxies/xrefs) // -- exposed associations do not point to non-exposed targets // -- structured types must not contain associations for OData V2 // - Element must not be an 'array of' for OData V2 TODO: move to the validator // (Linter Candidate, move as hard error into EdmPreproc on V2 generation) // - Perform checks for exposed non-abstract entities and views - check media type and // key-ness (requires that containers have been identified) (Linter candidate, scenario check) // Annotations related: // - Annotate artifacts, elements, foreign keys, parameters etc with their DB names if requested // (must remain in CSN => ForODataNewCandidate) // - Mark fields with @odata.on.insert/update as @Core.Computed // (EdmPreproc candidate, check with RT if @Core.Computed required by them) // - Rename shorthand annotations according to a builtin list (EdmPreproc Candidate) // e.g. @label -> @Common.Label // - If the association target is annotated with @cds.odata.valuelist, annotate the // association with @Common.ValueList.viaAssociation (EdmPreproc Candidate) // - Check for @Analytics.Measure and @Aggregation.default (Linter check candidate, remove) // - Check annotations. If annotation starts with '@sap...' it must have a string or boolean value // (Linter check candidate) module.exports = { transform4odataWithCsn }; function transform4odataWithCsn(inputModel, options, messageFunctions) { timetrace.start('OData transformation'); // copy the model as we don't want to change the input model const csn = cloneFullCsn(inputModel, options); messageFunctions.setModel(csn); const { message, error, warning, info, throwWithAnyError } = messageFunctions; throwWithAnyError(); // the new transformer works only with new CSN checkCSNVersion(csn, options); const transformers = transformUtils.getTransformers(csn, options, messageFunctions, '_'); const { addDefaultTypeFacets, checkMultipleAssignments, recurseElements, setAnnotation, renameAnnotation, expandStructsInExpression, csnUtils, } = transformers; const { getCsnDef, getServiceName, isAssocOrComposition, isAssociation, inspectRef, artifactRef, effectiveType, getFinalTypeInfo, dropDefinitionCache, initDefinition, } = csnUtils; // are we working with structured OData or not const structuredOData = options.odataFormat === 'structured' && options.odataVersion === 'v4'; // collect all declared non-abstract services from the model // use the array when there is a need to identify if an artifact is in a service or not const services = getServiceNames(csn); // @ts-ignore const externalServices = services.filter(serviceName => csn.definitions[serviceName]['@cds.external']); // @ts-ignore const isExternalServiceMember = (art, name) => { return !!(externalServices.includes(getServiceName(name)) || (art && art['@cds.external'])) } if (options.csnFlavor === 'universal' && isBetaEnabled(options, 'enableUniversalCsn')) enrichUniversalCsn(csn, options); // - Generate artificial draft fields on a structured CSN if requested, flattening and struct // expansion do their magic including foreign key generation and annotation propagation. // Tenantenizer has to decorate the DraftAdministrativeData, so draft decoration must be done before. generateDrafts(csn, options, services, messageFunctions); if (options.tenantDiscriminator) addTenantFields(csn, options); function acceptLocalizedView(_name, parent) { csn.definitions[parent].$localized = true; return false; // don't keep the views } addLocalizationViews(csn, options, { acceptLocalizedView, ignoreUnknownExtensions: true }); // replace all type refs to builtin types with direct type transformUtils.rewriteBuiltinTypeRef(csn); // Rewrite paths in annotations only if beta modes are set options.enrichAnnotations = true; const cleanup = validate.forOdata(csn, { message, error, warning, info, inspectRef, effectiveType, getFinalTypeInfo, artifactRef, options, csnUtils, services, isExternalServiceMember, recurseElements, checkMultipleAssignments, csn, }); // Throw exception in case of errors throwWithAnyError(); // TODO: Refactor out the following logic forEachDefinition(csn, [ (def) => { // Convert a projection into a query for internal processing will be re-converted // at the end of the OData processing // TODO: handle artifact.projection instead of artifact.query correctly in future V2 if (def.kind === 'entity' && def.projection) { def.query = { SELECT: def.projection }; dropDefinitionCache(def); initDefinition(def); } }], { skipArtifact: isExternalServiceMember } ); // All type refs must be resolved, including external APIs. // OData has no 'type of' so 'real' imported OData APIs marked @cds.external are safe. // If in the future 'other' APIs that might support type refs are imported, these refs must be // resolved here, as this is the OData transformation and sets the foundation for subsequent EDM // rendering which may has to publish external definitions expandToFinalBaseType(csn, transformers, csnUtils, services, options, error); // Check if structured elements and managed associations are compared in an expression // and expand these structured elements. This tuple expansion allows all other // subsequent procession steps (especially a2j) to see plain paths in expressions. // If errors are detected, throwWithAnyError() will return from further processing expandStructsInExpression(csn, { skipArtifact: isExternalServiceMember, drillRef: true }); // do expansion before Fk creation because of messages reporting if (!structuredOData) { expansion.expandStructureReferences(csn, options, '_', { error, info, throwWithAnyError }, csnUtils, { skipArtifact: isExternalServiceMember, keepKeysOrigin: true }); } createForeignKeyElements(csn, options, messageFunctions, csnUtils, { skipArtifact: isExternalServiceMember }); // needs to be performed after creating foreign keys for the entire model, // because of multiple managed associations in refs replaceForeignKeyRefsInExpressionAnnotations(csn, options, messageFunctions, csnUtils, { skipArtifact: isExternalServiceMember }); bindCsnReferenceOnly(); if (!structuredOData) { const resolved = new WeakMap(); const { inspectRef, effectiveType } = csnRefs(csn); const { getFinalTypeInfo } = getUtils(csn); const { adaptRefs, transformer: refFlattener } = flattening.getStructRefFlatteningTransformer(csn, inspectRef, effectiveType, options, resolved, '_'); const allMgdAssocDefs = flattening.allInOneFlattening(csn, refFlattener, adaptRefs, inspectRef, getFinalTypeInfo, isExternalServiceMember, error, csnUtils, options); flattening.flattenAllStructStepsInRefs(csn, refFlattener, adaptRefs, inspectRef, effectiveType, csnUtils, error, options, { //skip: ['action', 'aspect', 'event', 'function', 'type'], skipArtifact: isExternalServiceMember, }); flattening.replaceManagedAssocsAsKeys(allMgdAssocDefs, csnUtils); // replace structured with flat dictionaries that contain // rewritten path expressions forEachDefinition(csn, (def) => { ['elements', 'params'].forEach(dictName => { if(def[`$flat${dictName}`]) def[dictName] = def[`$flat${dictName}`]; }) if(def.$flatAnnotations) { Object.entries(def.$flatAnnotations).forEach(([an, av]) => { def[an] = av; }) } if(def.actions) { Object.values(def.actions).forEach((action) => { if(action.$flatAnnotations) { Object.entries(action.$flatAnnotations).forEach(([an, av]) => { action[an] = av; }); } }); } }); } bindCsnReferenceOnly(); // Allow using managed associations as steps in on-conditions to access their fks // To be done after handleManagedAssociationsAndCreateForeignKeys, // since then the foreign keys of the managed assocs are part of the elements if(!structuredOData) { forEachDefinition(csn, associations.getFKAccessFinalizer(csn, csnUtils, '_')); } // structure flattener reports errors, further processing is not safe -> throw exception in case of errors throwWithAnyError(); // Apply default type facets as set by options // Flatten on-conditions in unmanaged associations /* FIXME (HJB): Is this comment still correct? processOnCond only strips $self We should not remove $self prefixes in structured OData to not interfere with path resolution */ // This must be done before all the draft logic as all // composition targets are annotated with @odata.draft.enabled in this step forEachDefinition(csn, [ setDefaultTypeFacets, processOnCond ], { skipArtifact: isExternalServiceMember }); // Now all artificially generated things are in place // TODO: should be done by the compiler - Check associations for valid foreign keys // TODO: check if needed at all: Remove '$projection' from paths in the element's ON-condition // - Check associations for: // - exposed associations do not point to non-exposed targets // - structured types must not contain associations for OData V2 // - Element must not be an 'array of' for OData V2 TODO: move to the validator // - Perform checks for exposed non-abstract entities and views - check media type and key-ness // Deal with all kind of annotations manipulations here const skipPersNameKinds = {'service':1, 'context':1, 'namespace':1, 'annotation':1, 'action':1, 'function':1}; forEachDefinition(csn, (def, defName) => { // Resolve annotation shorthands for entities, types, annotations, ... renameShorthandAnnotations(def); // Annotate artifacts with their DB names if requested. // Skip artifacts that have no DB equivalent anyway if (options.sqlMapping && !(def.kind in skipPersNameKinds)) // hana to allow naming mode "hdbcds" def['@cds.persistence.name'] = getArtifactDatabaseNameOf(defName, options.sqlMapping, csn, 'hana'); forEachMemberRecursively(def, (member, memberName, propertyName) => { // Annotate elements, foreign keys, parameters, etc. with their DB names if requested // Only these are actually required and don't annotate virtual elements in entities or types // as they have no DB representation (although in views) if (options.sqlMapping && typeof member === 'object' && !(member.kind === 'action' || member.kind === 'function') && !(propertyName === 'enum' || propertyName === 'returns') && (!member.virtual || def.query)) { // If we have a 'preserved dotted name' (i.e. we are a result of flattening), use that for the @cds.persistence.name annotation member['@cds.persistence.name'] = getElementDatabaseNameOf((!member['@odata.foreignKey4'] && member.$defPath?.slice(1).join('.')) || memberName, options.sqlMapping, 'hana'); // hana to allow "hdbcds" } processDynamicFieldControlAnnotations(member); // Mark fields with @odata.on.insert/update as @Core.Computed annotateCoreComputed(member); // Resolve annotation shorthands for elements, actions, action parameters renameShorthandAnnotations(member); // If an association was modelled as not null, like so: // <associationName>: Association to <target> not null; // a cardinality property is set to the association member // with the value { "min": 1 }; setCardinalityToNotNullAssociations(member); // - If the association target is annotated with @cds.odata.valuelist, annotate the // association with @Common.ValueList.viaAssociation // - Check for @Analytics.Measure and @Aggregation.default // @ts-ignore if (isArtifactInSomeService(defName, services) || isLocalizedArtifactInService(defName, services)) { // If the member is an association and the target is annotated with @cds.odata.valuelist, // annotate the association with @Common.ValueList.viaAssociation (but only for service member artifacts // to avoid CSN bloating). The propagation of the @Common.ValueList.viaAssociation annotation // to the foreign keys is done very late in edmPreprocessor.initializeAssociation() addCommonValueListviaAssociation(member, memberName); } }, ['definitions', defName]); // Convert a query back into a projection for CSN compliance as // the very last conversion step of the OData transformation if (def.kind === 'entity' && def.query && def.projection) { delete def.query; } }, { skipArtifact: isExternalServiceMember }) if(isBetaEnabled(options, 'odataTerms')) { forEachGeneric(csn, 'vocabularies', renameShorthandAnnotations); } cleanup(); // Throw exception in case of errors throwWithAnyError(); timetrace.stop('OData transformation'); return csn; //-------------------------------------------------------------------- // HELPER SECTION STARTS HERE // Transform @readonly/@mandatory/@disabled into @Common.FieldControl annotation // with a when/then/else expression consisting of the input from the annotations. function processDynamicFieldControlAnnotations(node) { if (node['@Common.FieldControl']) return; // TODO (SO): factor this out into a constant so we don't create a fresh array all the time? if (['@readonly', '@mandatory', '@disabled'].some(key => typeof node[key] === 'boolean')) { return; } const definedAnnotations = ['@disabled', '@readonly', '@mandatory'] .filter(key => node[key] && isAnnotationExpression(node[key])); if (definedAnnotations.length === 0) return; const values = { '@disabled': { val: 0 }, '@readonly': { val: 1 }, '@mandatory': { val: 7 }, }; const fieldControl = { '=': true, xpr: createFieldControlExpression(definedAnnotations), }; setAnnotation(node, '@Common.FieldControl', fieldControl); function createFieldControlExpression(annotations) { let nestedExpression = null; for (let i = annotations.length - 1; i >= 0; i--) { const annotation = annotations[i]; const xprInAnnoValue = getXprFromAnno(node[annotation]); const annotationVal = values[annotation]; // Build the current annotation's expression const currentExpression = [ 'case', 'when', ...(Array.isArray(xprInAnnoValue) ? xprInAnnoValue : [xprInAnnoValue]), 'then', annotationVal, 'else', // Use the previous nested expression or default value. Note that annotations // are looped backwards nestedExpression ? { xpr: nestedExpression } : { val: 3 }, 'end', ]; // Update the nested expression nestedExpression = currentExpression; } return nestedExpression; } function getXprFromAnno(anno) { const xprProp = xprInAnnoProperties.find(prop => anno[prop] !== undefined); const constructResult = { 'ref': () => { const result = { ref: anno.ref }; if (anno.cast) result.cast = anno.cast; return result; }, 'xpr': () => anno.xpr, 'list': () => ({ list: anno.list }), 'literal': () => constructResult['val'](), 'val': () => { const result = { val: anno.val }; if (anno.literal) result.literal = anno.literal; return result; }, '#': () => ({ '#': anno['#'] }), 'func': () => ({ func: anno.func }), 'args': () => ({ args: anno.args }), 'SELECT': () => ({ SELECT: anno.SELECT }), 'SET': () => ({ SET: anno.SET }), 'cast': () => constructResult['ref']() } return constructResult[xprProp](); } } // Mark elements that are annotated with @odata.on.insert/update with the annotation @Core.Computed. function annotateCoreComputed(node) { // If @Core.Computed is explicitly set, don't overwrite it! if (node['@Core.Computed'] !== undefined) return; // For @odata.on.insert/update, also add @Core.Computed // @odata.on is deprecated, use @cds.on {update|insert} instead if(['@odata.on.insert', '@odata.on.update', '@cds.on.insert', '@cds.on.update'].some(a => node[a])) node['@Core.Computed'] = true; } // Rename shorthand annotations within artifact or element 'node' according to a builtin list function renameShorthandAnnotations(node) { const setMappings = { '@label': '@Common.Label', '@title': '@Common.Label', '@description': '@Core.Description', }; const renameMappings = { '@ValueList.entity': { val: '@Common.ValueList', op: 'entity' }, '@ValueList.type': { val: '@Common.ValueList', op: 'type' }, '@Capabilities.Deletable': { val: '@Capabilities.DeleteRestrictions', op: 'Deletable' }, '@Capabilities.Insertable': { val: '@Capabilities.InsertRestrictions', op: 'Insertable' }, '@Capabilities.Updatable': { val: '@Capabilities.UpdateRestrictions', op: 'Updatable' }, '@Capabilities.Readable': { val: '@Capabilities.ReadRestrictions', op: 'Readable' } }; const setShortCuts = Object.keys(setMappings); const renameShortCuts = Object.keys(renameMappings); // Capabilities shortcuts have precedence over @readonly/@insertonly Object.keys(node).forEach( name => { if (!name.startsWith('@')) return; // Rename according to map above const renamePrefix = (name in renameMappings) ? name : renameShortCuts.find(p => name.startsWith(p + '.')); if(renamePrefix) { const mapping = renameMappings[renamePrefix]; renameAnnotation(node, name, name.replace(renamePrefix, `${mapping.val}.${mapping.op}`)); } else { // The two mappings have no overlap, so no need to check for second map if first matched. // Rename according to map above const setPrefix = (name in setMappings) ? name : setShortCuts.find(p => name.startsWith(p + '.') || name.startsWith(p + '#')); if(setPrefix) { setAnnotation(node, name.replace(setPrefix, setMappings[setPrefix]), node[name]); } } }); // Special case: '@readonly' becomes a triplet of capability restrictions for entities, // but '@Core.Computed' for everything else. // only if not both readonly/insertonly are true do the mapping if(!(node['@readonly'] && node['@insertonly'])) { if(node['@readonly']) { const setRO = (qualifier) => { if (node.kind === 'entity' || node.kind === 'aspect') { setAnnotation(node, `@Capabilities.DeleteRestrictions${ qualifier ? '#' + qualifier : ''}.Deletable`, false); setAnnotation(node, `@Capabilities.InsertRestrictions${ qualifier ? '#' + qualifier : ''}.Insertable`, false); setAnnotation(node, `@Capabilities.UpdateRestrictions${ qualifier ? '#' + qualifier : ''}.Updatable`, false); } else if (!isAnnotationExpression(node['@readonly'])) { // add @Core.Computed only for non-xpr values of @readonly setAnnotation(node, '@Core.Computed', true); } }; setRO(undefined); } // @insertonly is effective on entities/queries only if (node['@insertonly'] && (node.kind === 'entity' || node.kind === 'aspect')) { const setIO = (qualifier) => { setAnnotation(node, `@Capabilities.DeleteRestrictions${ qualifier ? '#' + qualifier : ''}.Deletable`, false); setAnnotation(node, `@Capabilities.ReadRestrictions${ qualifier ? '#' + qualifier : ''}.Readable`, false); setAnnotation(node, `@Capabilities.UpdateRestrictions${ qualifier ? '#' + qualifier : ''}.Updatable`, false); } setIO(undefined); } } // @Validation.Pattern is applicable to "Term" => node.kind === annotation if (node['@assert.format'] != null) setAnnotation(node, '@Validation.Pattern', node['@assert.format']); // Only on element level if(node.kind == null) { if (node['@mandatory'] && !isAnnotationExpression(node['@mandatory']) && !Object.entries(node).some(([k,v]) => k === '@Common.FieldControl' || k.startsWith('@Common.FieldControl.') && v != null)) { setAnnotation(node, '@Common.FieldControl', { '#': 'Mandatory' }); } if (node['@assert.range'] != null) setAssertRangeAnnotation(node); } } function setAssertRangeAnnotation(node) { const range = node['@assert.range']; if (!Array.isArray(range) || range.length !== 2) return; // TODO: Warning for wrong format? const min = range[0]; const max = range[1]; const minVal = min?.val ?? min; const maxVal = max?.val ?? max; // CAP Node 8.5 introduced "exclusive" ranges using the annotation expression // syntax. Hence, the compiler uses the same. It also introduced "infinity" // via `@assert.range: [ _, _ ]`. // For `_`, minVal is an object and this function returns false, which is ok, // since we don't render the annotation for "infinite" values. const shouldSet = (val) => (typeof val !== 'object' && val !== undefined && val !== null); if (shouldSet(minVal)) { setAnnotation(node, '@Validation.Minimum', minVal); if (min['='] !== undefined) setAnnotation(node, '@Validation.Minimum.@Validation.Exclusive', true); } if (shouldSet(maxVal)) { setAnnotation(node, '@Validation.Maximum', maxVal); if (max['='] !== undefined) setAnnotation(node, '@Validation.Maximum.@Validation.Exclusive', true); } } // If an association was modelled as not null, like so: // <associationName>: Association to <target> not null; // a cardinality property is set to the association member // with the value { "min": 1 }; function setCardinalityToNotNullAssociations(member) { if (member.target && !member.on) { if (member.notNull) { if (member.cardinality === undefined) member.cardinality = {}; // min=0 is falsy => check for undefined if (member.cardinality.min === undefined) { member.cardinality.min = 1; } else if (member.cardinality.min === 0) { warning(null, member.$path, { value: cardinality2str(member, false), code: 'not null' }, 'Expected target cardinality $(VALUE) and $(CODE) to match'); } } } } // Apply default type facets to each type definition and every member // But do not apply default string length (as in DB) function setDefaultTypeFacets(def) { addDefaultTypeFacets(def.items || def, null) forEachMemberRecursively(def, m=>addDefaultTypeFacets(m.items || m, null)); if(def.returns) addDefaultTypeFacets(def.returns.items || def.returns, null); } // Handles on-conditions in unmanaged associations function processOnCond(def) { forEachMemberRecursively(def, (member) => { if (member.on && isAssocOrComposition(member)) { removeLeadingDollarSelfInOnCondition(member); } }); // removes leading $self in on-conditions's references function removeLeadingDollarSelfInOnCondition(assoc) { if (!assoc.on) return; // nothing to do // TODO: Shouldn't this only run on the on-condition and not the whole assoc-node? applyTransformationsOnNonDictionary({ assoc }, 'assoc', { ref: (node, prop, ref) => { // remove leading $self when at the beginning of a ref if (ref.length > 1 && ref[0] === '$self') node.ref.splice(0, 1); } }); } } // (4.5) If the member is an association whose target has @cds.odata.valuelist annotate it // with @Common.ValueList.viaAssociation. // Do this only if the association is navigable(@odata.navigable) and the enclosing artifact is // a service member (don't pollute the CSN with unnecessary annotations, that is ensured by the caller // of this function). function addCommonValueListviaAssociation(member, memberName) { const vlAnno = '@Common.ValueList.viaAssociation'; if (isAssociation(member)) { const navigable = member['@odata.navigable'] !== false; // navigable disabled only if explicitly set to false const targetDef = getCsnDef(member.target); if (navigable && targetDef['@cds.odata.valuelist'] && !member[vlAnno]) { setAnnotation(member, vlAnno, { '=': memberName }); } } } function bindCsnReferenceOnly() { // invalidate caches for CSN ref API const csnRefApi = csnRefs(csn); Object.assign(csnUtils, csnRefApi); } } // transform4odataWithCsn