UNPKG

@sap/cds-compiler

Version:

CDS (Core Data Services) compiler and backends

1,206 lines (1,078 loc) 70.8 kB
'use strict'; const { getLastPartOf, getLastPartOfRef, hasValidSkipOrExists, getNormalizedQuery, forEachDefinition, getResultingName, getVariableReplacement, pathName, implicitAs, } = require('../model/csnUtils'); const { isBuiltinType, isMagicVariable } = require('../base/builtins'); const { forEach, forEachValue, forEachKey } = require('../utils/objectUtils'); const { renderFunc, cdsToSqlTypes, getHanaComment, hasHanaComment, getSqlSnippets, createExpressionRenderer, withoutCast, variableForDialect, isVariableReplacementRequired, } = require('./utils/common'); const { getDeltaRenderer, } = require('./utils/delta'); const { renderReferentialConstraint, getIdentifierUtils, isProjectionView, } = require('./utils/sql'); const DuplicateChecker = require('./DuplicateChecker'); const { checkCSNVersion } = require('../json/csnVersion'); const { timetrace } = require('../utils/timetrace'); const { isBetaEnabled, isDeprecatedEnabled } = require('../base/model'); const sqlIdentifiers = require('../sql-identifier'); const { sortCsn } = require('../model/cloneCsn'); const { manageConstraints, manageConstraint } = require('./manageConstraints'); const { renderUniqueConstraintString, renderUniqueConstraintDrop, renderUniqueConstraintAdd } = require('./utils/unique'); const { ModelError, CompilerAssertion } = require('../base/error'); const { pathId } = require('../model/csnRefs'); const { transformExprOperators } = require('./utils/operators'); const { exprAsTree, condAsTree } = require('../model/xprAsTree'); class SqlRenderEnvironment { indent = ''; path = null; alterMode = false; changeType = null; /** Whether we're rendering a default value */ isInDefault = false; constructor(values) { Object.assign(this, values); } withIncreasedIndent() { return new SqlRenderEnvironment({ ...this, indent: ` ${ this.indent }` }); } withSubPath(path) { return new SqlRenderEnvironment({ ...this, path: [ ...this.path, ...path ] }); } cloneWith(values) { return Object.assign(new SqlRenderEnvironment(this), values); } } /** * Render the CSN model 'model' to SQL DDL statements. One statement is created * per top-level artifact into dictionaries 'hdbtable', 'hdbview', ..., without * leading CREATE, without trailing semicolon. All corresponding statements (in * proper order) are copied into dictionary 'sql', with trailing semicolon. * Also included in the result are dictionaries 'deletions' and 'migrations', * keyed by entity name, which reflect statements needed for deleting or changing * (migrating) entities. * In the case of 'deletions', each entry contains the corresponding DROP statement. * In the case of 'migrations', each entry is an array of objects representing * changes to the entity. Each change object contains one or more SQL statements * (concatenated to one string using \n) and information whether these incur * potential data loss. * * Return an object like this: * { "hdbtable": { * "foo" : "COLUMN TABLE foo ...", * }, * "hdbview": { * "bar::wiz" : "VIEW \"bar::wiz\" AS SELECT \"x\" FROM ..." * }, * "sql: { * "foo" : "CREATE TABLE foo ...;\n", * "bar::wiz" : "CREATE VIEW \"bar::wiz\" AS SELECT \"x\" FROM ...;\n" * }, * "deletions": { * "baz": "DROP TABLE baz" * }, * "migrations": { * "foo": [ * { * "drop": false, * "sql": "ALTER TABLE foo ALTER (elm DECIMAL(12, 9));" * }, * { * "drop": true, * "sql": "ALTER TABLE foo DROP (eln);" * }, * { * "drop": false, * "sql": "ALTER TABLE foo ADD (elt NVARCHAR(99));" * } * ] * } * } * * @param {CSN.Model} csn HANA transformed CSN * @param {CSN.Options} options Transformation options * @param {object} messageFunctions Message functions such as `error()`, `info()`, … * @returns {object} Dictionary of artifact-type:artifacts, where artifacts is a dictionary of name:content */ function toSqlDdl( csn, options, messageFunctions ) { timetrace.start('SQL rendering'); const { error, warning, info, throwWithAnyError, } = messageFunctions; const { quoteSqlId, prepareIdentifier, renderArtifactName } = getIdentifierUtils(csn, options); const reportedMissingReplacements = Object.create(null); const exprRenderer = createExpressionRenderer({ // FIXME: For the sake of simplicity, we should get away from all this uppercasing in toSql finalize: x => String(x).toUpperCase(), typeCast(x) { const typeRef = renderBuiltinType(x.cast.type) + renderTypeParameters(x.cast); return `CAST(${ this.renderExpr(withoutCast(x)) } AS ${ typeRef })`; }, val: renderExpressionLiteral, enum(x) { // visitExpr first checks for `#`, then `val`: if (x.val !== undefined) return renderExpressionLiteral(x); error('expr-unexpected-enum', this.env.path, 'Enum values are not yet supported for conversion to SQL'); return ''; }, ref(x) { return renderExpressionRef(x, this.env); }, windowFunction( x) { return renderWindowFunction(smartFuncId(prepareIdentifier(x.func)), x, this.env); }, func(x) { return renderFunc(smartFuncId(prepareIdentifier(x.func)), x, a => renderArgs(a, '=>', this.env, null), { messageFunctions, options, path: this.env.path }); }, xpr(x) { const env = this.env.withSubPath([ 'xpr' ]); if (this.isNestedXpr && !x.cast) return `(${ this.renderSubExpr(x.xpr, env) })`; return this.renderSubExpr(x.xpr, env); }, SELECT( x) { return `(${ renderQuery(x, this.env.withIncreasedIndent()) })`; }, SET( x) { return `(${ renderQuery(x, this.env.withIncreasedIndent()) })`; }, }); function renderExpr( x, env ) { // TODO: // Can we to structurizing upfront? // Neither condAsTree() nor exprAsTree() traverse `.args` / `.where`. // This is fine, since renderExpr() calls itself for function arguments and filters. x = Array.isArray(x) ? condAsTree(x) : exprAsTree(x); x = transformExprOperators(x, options, messageFunctions, env); return exprRenderer.renderExpr(x, env); } const render = getDeltaRenderer(options, { renderElement, renderArtifactName, renderAssociationElement, quoteSqlId, renderStringForSql, activateAlterMode, getHanaComment, renderExpr, }); // FIXME: Currently requires 'options.forHana', because it can only render HANA-ish SQL dialect if (!options.forHana && !isBetaEnabled(options, 'sqlExtensions')) throw new CompilerAssertion('to.sql can currently only be used with SAP HANA preprocessing'); checkCSNVersion(csn, options); // The final result in hdb-kind-specific form, without leading CREATE, without trailing newlines // (note that the order here is relevant for transmission into 'mainResultObj.sql' below and that // the attribute names must be the HDI plugin names for --src hdi) // The result object may have a `sql` dictionary for `toSql`. const mainResultObj = { hdbtabletype: Object.create(null), hdbtable: Object.create(null), hdbindex: Object.create(null), hdbfulltextindex: Object.create(null), hdbview: Object.create(null), hdbprojectionview: Object.create(null), hdbconstraint: Object.create(null), deletions: Object.create(null), constraintDeletions: [], migrations: Object.create(null), hdbrole: Object.create(null), hdbsynonym: Object.create(null), }; const sqlServiceEntities = Object.create(null); const dummySqlServiceEntities = Object.create(null); // Registries for artifact and element names per CSN section const definitionsDuplicateChecker = new DuplicateChecker(options.sqlMapping); const deletionsDuplicateChecker = new DuplicateChecker(); const extensionsDuplicateChecker = new DuplicateChecker(); const removeElementsDuplicateChecker = new DuplicateChecker(); const changeElementsDuplicateChecker = new DuplicateChecker(); // Render each artifact on its own forEachDefinition(sortCsnIfTestMode(csn), (artifact, artifactName) => { renderDefinitionInto(artifactName, artifact, mainResultObj, new SqlRenderEnvironment()); }); // Render each deleted artifact for (const artifactName in csn.deletions) renderArtifactDeletionInto(artifactName, csn.deletions[artifactName], mainResultObj); if (csn.changedPrimaryKeys && supportsSqlExtensions()) { csn.changedPrimaryKeys = sortCsnIfTestMode(csn.changedPrimaryKeys); csn.changedPrimaryKeys.forEach((artifactName) => { const drop = render.dropKey(artifactName); addMigration(mainResultObj, artifactName, true, render.concat(...drop)); }); } // Render each artifact extension // Only SAP HANA SQL is currently supported. // Note that extensions may contain new elements referenced in migrations, thus should be compiled first. if (csn.extensions && supportsSqlExtensions()) { csn.extensions = sortCsnIfTestMode(csn.extensions); for (let i = 0; i < csn.extensions.length; ++i) { const extension = csn.extensions[i]; if (extension.extend) { const artifactName = extension.extend; const artifact = csn.definitions[artifactName]; const env = new SqlRenderEnvironment({ path: [ 'extensions', i ] }); renderArtifactExtensionInto(artifactName, artifact, extension, mainResultObj, env); } } } // Render each artifact change // Only SAP HANA SQL is currently supported. if (csn.migrations && supportsSqlExtensions()) { csn.migrations = sortCsnIfTestMode(csn.migrations); for (const migration of csn.migrations) { if (migration.migrate) { const artifactName = migration.migrate; // There is no "migrations" property in client CSN, so for better locations, use // a path to the definition. const env = new SqlRenderEnvironment({ path: [ 'definitions', artifactName ] }); renderArtifactMigrationInto(artifactName, csn.definitions[artifactName], migration, mainResultObj, env); } } } if (csn.changedPrimaryKeys && supportsSqlExtensions()) { csn.changedPrimaryKeys = sortCsnIfTestMode(csn.changedPrimaryKeys); csn.changedPrimaryKeys.forEach((artifactName) => { const add = render.addKey(artifactName, csn.definitions[artifactName].elements); addMigration(mainResultObj, artifactName, true, render.concat(...add)); }); } // Can only happen for HDI based deployment // .hdbrole documentation: https://help.sap.com/docs/SAP_HANA_PLATFORM/3823b0f33420468ba5f1cf7f59bd6bd9/625d7733c30b4666b4a522d7fa68a550.html Object.keys(sqlServiceEntities).forEach((sqlServiceName) => { const accessRole = { role: { name: renderArtifactNameWithoutQuotes(`${ sqlServiceName }.access`), object_privileges: Object.entries(sqlServiceEntities[sqlServiceName]).map(([ name, entity ]) => ({ name: renderArtifactNameWithoutQuotes(name), type: entity.query || entity.projection ? 'VIEW' : 'TABLE', privileges: [ 'SELECT' ], privileges_with_grant_option: [], })), }, }; if (accessRole.role.object_privileges.length > 0) mainResultObj.hdbrole[`${ sqlServiceName }_access`] = JSON.stringify(accessRole, null, 2); }); // Can only happen for HDI based deployment Object.keys(dummySqlServiceEntities).forEach((sqlServiceName) => { const synonym = Object.create(null); Object.entries(dummySqlServiceEntities[sqlServiceName]).forEach(([ name ]) => { const artName = renderArtifactNameWithoutQuotes(name); const dummyArtName = renderArtifactNameWithoutQuotes(`dummy.${ name }`); synonym[artName] = { target: { object: dummyArtName, }, }; }); mainResultObj.hdbsynonym[`${ sqlServiceName }`] = JSON.stringify(synonym, null, 2); }); // trigger artifact and element name checks definitionsDuplicateChecker.check(error, options); extensionsDuplicateChecker.check(error); deletionsDuplicateChecker.check(error); // Throw exception in case of errors throwWithAnyError(); // Transfer results from hdb-specific dictionaries into 'sql' dictionary in proper order if src === 'sql' // (relying on the order of dictionaries above) // FIXME: Should consider inter-view dependencies, too const sql = Object.create(null); // Handle hdbKinds separately from alterTable case if (options.src === 'sql') adaptHdbKindsForSql(); if (useAlterTableForConstraints()) { const constraints = Object.create(null); const alterStmts = manageConstraints(csn, options); forEachKey(alterStmts, (constraintName) => { if (!csn.unchangedConstraints?.has(constraintName)) constraints[constraintName] = `${ alterStmts[constraintName] }`; }); mainResultObj.constraints = constraints; } if (options.src === 'sql') mainResultObj.sql = sql; timetrace.stop('SQL rendering'); return mainResultObj; function adaptHdbKindsForSql() { const { hdbtable, // eslint-disable-next-line no-unused-vars deletions, constraintDeletions, migrations: _, ...hdbKinds } = mainResultObj; for (const name in hdbtable) { let sourceString = hdbtable[name]; // Hack: Other than in 'hdbtable' files, in HANA SQL COLUMN is not mandatory but default. if (sourceString.startsWith('COLUMN ')) sourceString = sourceString.slice('COLUMN '.length); sql[name] = `CREATE ${ sourceString };`; } for (const hdbKind of Object.keys(hdbKinds)) { for (const name in mainResultObj[hdbKind]) sql[name] = `CREATE ${ mainResultObj[hdbKind][name] };`; delete mainResultObj[hdbKind]; } } /** * Render a definition into the appropriate dictionary of 'resultObj'. * * @param {string} artifactName Name of the artifact to render * @param {CSN.Artifact} art Artifact to render * @param {object} resultObj Result collector * @param {SqlRenderEnvironment} env Render environment */ function renderDefinitionInto( artifactName, art, resultObj, env ) { env.path = [ 'definitions', artifactName ]; // Ignore whole artifacts if forRelationalDB says so if (art.abstract || hasValidSkipOrExists(art)) { if (art.$dummyService) { // collect entities that are in an external ABAP sql service so we can render the .hdbsynonym later dummySqlServiceEntities[art.$dummyService] ??= Object.create(null); dummySqlServiceEntities[art.$dummyService][artifactName] = art; } return; } switch (art.kind) { case 'entity': if (art.$sqlService) { // collect entities that are in a sql service so we can render the .hdbrole later sqlServiceEntities[art.$sqlService] ??= Object.create(null); sqlServiceEntities[art.$sqlService][artifactName] = art; } if (art.query || art.projection) renderViewInto(artifactName, art, resultObj, env); else renderEntityInto(artifactName, art, resultObj, env); break; case 'type': case 'context': case 'service': case 'namespace': case 'annotation': case 'action': case 'function': case 'event': case 'aspect': // Ignore: not SQL-relevant return; default: throw new ModelError(`Unknown artifact kind: ${ art.kind }`); } } /** * Render the given artifactName according to the sqlMapping, but * - uppercased for plain * - without enclosing " for quoted/hdbcds * * @param {string} artifactName * @returns {string} */ function renderArtifactNameWithoutQuotes( artifactName ) { if (options.sqlMapping === 'plain') return renderArtifactName(artifactName).toUpperCase(); return renderArtifactName(artifactName).slice(1, -1); // trim leading/trailing " } /** * Render an artifact extension into the appropriate dictionary of 'resultObj'. * Only SAP HANA SQL is currently supported. * * @param {string} artifactName Name of the artifact to render * @param {CSN.Artifact} artifact The complete artifact * @param {CSN.Artifact} ext Extension to render * @param {object} resultObj Result collector * @param {SqlRenderEnvironment} env Render environment */ function renderArtifactExtensionInto( artifactName, artifact, ext, resultObj, env ) { // Property kind is always omitted for elements and can be omitted for // top-level type definitions, it does not exist for extensions. if (artifactName && !ext.query) { if (ext.constraint) renderConstraintExtendInto(artifactName, ext, resultObj); else renderExtendInto(artifactName, artifact.elements, ext.elements, resultObj, env, extensionsDuplicateChecker); } if (!artifactName) throw new ModelError(`Undefined artifact name: ${ artifactName }`); } // Render an artifact deletion into the appropriate dictionary of 'resultObj'. function renderArtifactDeletionInto( artifactName, art, resultObj ) { const tableName = renderArtifactName(artifactName); deletionsDuplicateChecker.addArtifact(tableName, art.$location, artifactName); resultObj.deletions[artifactName] = `-- [WARNING] this statement is lossy\nDROP TABLE ${ tableName }`; } // Render an artifact migration into the appropriate dictionary of 'resultObj'. // Only SAP HANA SQL is currently supported. function renderArtifactMigrationInto( artifactName, artifact, migration, resultObj, env ) { function reducesTypeSize( def ) { // HANA does not allow decreasing the value of any of those type parameters. return def.old.type === def.new.type && [ 'length', 'precision', 'scale' ].some(param => def.new[param] < def.old[param]); } function getEltStr( defVariant, eltName, changeType = 'extension' ) { return defVariant.target ? renderAssociationElement(eltName, defVariant, env) : renderElement(eltName, defVariant, null, null, activateAlterMode(env, changeType)); } function getEltStrNoProps( defVariant, eltName, ...props ) { const defNoProps = Object.assign({}, defVariant); for (const prop of props) delete defNoProps[prop]; return getEltStr(defNoProps, eltName); } function oldAnnoChangedIncompatibly( defOld, defNew ) { return typeof defOld === 'string' && defOld.trim().length && !(typeof defNew === 'string' && defNew.trim().startsWith(`${ defOld.trim() } `)); } function getUnknownSqlReason( anno, artName, defOld, defNew, eltName ) { const changeKind = defNew === undefined ? `removed (previous value: ${ JSON.stringify(defOld) })` : `changed from ${ JSON.stringify(defOld) } to ${ JSON.stringify(defNew) }`; return eltName ? `annotation ${ anno } of element ${ artName }:${ eltName } has been ${ changeKind }` : `annotation ${ anno } of artifact ${ artName } has been ${ changeKind }`; } const sqlSnippetAnnos = [ '@sql.prepend', '@sql.append' ]; const tableName = renderArtifactName(artifactName); // Change entity properties if (migration.properties) { for (const [ prop, def ] of Object.entries(migration.properties)) { if (prop === 'doc' && !options.disableHanaComments) { // def.new may be `null` const alterComment = render.alterEntityComment(artifactName, def.new); addMigration(resultObj, artifactName, false, alterComment); } else if (sqlSnippetAnnos.includes(prop)) { // NOTE: @sql.replace may be supported in the future if (oldAnnoChangedIncompatibly(def.old, def.new)) { // anno was previously set and current change is not simply an appendix → previous anno would have to be reverted → unknown SQL addMigration(resultObj, artifactName, false, null, getUnknownSqlReason(prop, artifactName, def.old, def.new)); } else { addMigration(resultObj, artifactName, false, render.alterEntitySqlSnippet(artifactName, def.new)); } } } } // Drop columns (unsupported in sqlite) if (migration.remove) { const entries = Object.entries(migration.remove); if (entries.length) { const removeCols = entries.filter(([ , value ]) => !value.target).map(([ key ]) => quoteSqlId(key)); const removeAssocs = entries.filter(([ , value ]) => value.target).map(([ key ]) => quoteSqlId(key)); removeElementsDuplicateChecker.addArtifact(tableName, undefined, artifactName); [ ...removeCols, ...removeAssocs ].forEach(element => removeElementsDuplicateChecker.addElement(quoteSqlId(element), undefined, element)); // Remove columns. if (removeCols.length) addMigration(resultObj, artifactName, true, render.dropColumns(artifactName, removeCols).map(s => (options.src !== 'hdi' ? `-- [WARNING] this statement is lossy\n${ s }` : s))); // Remove associations. removeAssocs.forEach(assoc => addMigration(resultObj, artifactName, true, render.dropAssociation(artifactName, assoc))); } } if (migration.removeConstraints) { const constraintTypes = [ 'unique', 'referential' ]; constraintTypes.forEach((constraintType) => { if (migration.removeConstraints[constraintType]) { const entries = Object.entries(migration.removeConstraints[constraintType]); const optionsWithDrop = { ...options, drop: true }; const renderer = (constraintType === 'referential') ? constraint => manageConstraint(constraint, csn, optionsWithDrop, '', quoteSqlId) : (constraint, constraintName) => renderUniqueConstraintDrop(constraint, renderArtifactName(`${ artifactName }_${ constraintName }`), tableName, quoteSqlId); entries.forEach(( [ constraintName, constraint ]) => { resultObj.constraintDeletions.push(renderer(constraint, constraintName)); }); } }); } // Change column types (unsupported in sqlite) if (migration.change) { changeElementsDuplicateChecker.addArtifact(tableName, undefined, artifactName); for (const [ eltName, def ] of Object.entries(migration.change)) { const sqlId = quoteSqlId(eltName); changeElementsDuplicateChecker.addElement(sqlId, undefined, eltName); const eltStrOld = getEltStr(def.old, eltName, 'migration'); const eltStrNew = getEltStr(def.new, eltName, 'migration'); if (eltStrNew === eltStrOld) continue; // Prevent spurious migrations, where the column DDL does not change. const annosIncompat = []; sqlSnippetAnnos .filter(anno => def.old[anno] !== def.new[anno]) .forEach((anno) => { // NOTE: @sql.replace may be supported in the future if (oldAnnoChangedIncompatibly(def.old[anno], def.new[anno])) { annosIncompat.push(anno); // anno was previously set and current change is not simply an appendix → previous anno would have to be reverted → unknown SQL addMigration(resultObj, artifactName, false, null, getUnknownSqlReason(anno, artifactName, def.old[anno], def.new[anno], eltName)); } }); if (annosIncompat.length) { const eltStrOldNoAnnos = getEltStrNoProps(def.old, eltName, ...annosIncompat); const eltStrNewNoAnnos = getEltStrNoProps(def.new, eltName, ...annosIncompat); if (eltStrOldNoAnnos === eltStrNewNoAnnos) { // only incompatibly-changed annos were modified continue; } } if (!options.disableHanaComments && def.old.doc !== def.new.doc) { const eltStrOldNoDoc = getEltStrNoProps(def.old, eltName, 'doc'); const eltStrNewNoDoc = getEltStrNoProps(def.new, eltName, 'doc'); if (eltStrOldNoDoc === eltStrNewNoDoc) { // only `doc` changed const alterComment = render.alterColumnComment(artifactName, sqlId, def.new.doc); addMigration(resultObj, artifactName, false, alterComment); continue; } } if (options.sqlChangeMode === 'drop' || def.old.target || def.new.target || (reducesTypeSize(def) && options.src === 'hdi')) { // Lossy change because either an association is removed and/or added, or the type size is reduced. // Drop old element and re-add it in its new shape. const drop = def.old.target ? render.dropAssociation(artifactName, sqlId) : render.dropColumns(artifactName, [ sqlId ]); const add = def.new.target ? render.addAssociations(artifactName, { [eltName]: def.new }, env) : render.addColumnsFromElementsObj(artifactName, { [eltName]: def.new }, env); addMigration(resultObj, artifactName, true, render.concat(...drop, ...add).map(s => (def.lossy !== undefined && options.src !== 'hdi' ? `-- [WARNING] this statement could ${ def.lossy ? 'be lossy' : 'fail' }: ${ def.details }\n${ s }` : s))); } else { // Lossless change: no associations directly affected, no size reduction. addMigration(resultObj, artifactName, false, render.alterColumns(artifactName, sqlId, def, eltStrNew, eltName, activateAlterMode(env, 'migration')).map(s => (def.lossy !== undefined ? `-- [WARNING] this statement could ${ def.lossy ? 'be lossy' : 'fail' }: ${ def.details }\n${ s }` : s))); } } } if (render.getConsolidatedAlterColumn) { const consolidated = render.getConsolidatedAlterColumn(artifactName); if (consolidated) addMigration(resultObj, artifactName, false, consolidated); } } /** * Render a (non-projection, non-view) entity (and possibly its indices) into the appropriate * dictionaries of 'resultObj'. * * @param {string} artifactName Name of the artifact to render * @param {CSN.Artifact} art Artifact to render * @param {object} resultObj Result collector * @param {SqlRenderEnvironment} env Render environment */ function renderEntityInto( artifactName, art, resultObj, env ) { const childEnv = env.withIncreasedIndent(); // tables can have @sql.prepend and @sql.append const { front, back } = getSqlSnippets(options, art); let result = front; // Only SAP HANA has row/column tables if (options.sqlDialect === 'hana') { if (art.technicalConfig?.hana?.storeType) { // Explicitly specified result += `${ art.technicalConfig.hana.storeType.toUpperCase() } `; } else if (!front) { // in 'hdbtable' files, COLUMN or ROW is mandatory, and COLUMN is the default result += 'COLUMN '; } } const tableName = renderArtifactName(artifactName); definitionsDuplicateChecker.addArtifact(art['@cds.persistence.name'], art.$location, artifactName); result += `TABLE ${ tableName }`; result += ' (\n'; result += Object.keys(art.elements) .map(eltName => renderElement(eltName, art.elements[eltName], definitionsDuplicateChecker, getFzIndex(eltName, art.technicalConfig?.hana), childEnv)) .filter(s => s !== '') .join(',\n'); const uniqueFields = Object.keys(art.elements).filter(name => art.elements[name].unique && !art.elements[name].virtual) .map(name => quoteSqlId(name)) .join(', '); if (uniqueFields !== '') result += `,\n${ childEnv.indent }UNIQUE(${ uniqueFields })`; const primaryKeys = render.primaryKey(art.elements); if (primaryKeys !== '') result += `,\n${ childEnv.indent }${ primaryKeys }`; if (!useAlterTableForConstraints() && art.$tableConstraints?.referential) { const renderReferentialConstraintsAsHdbconstraint = options.src === 'hdi'; const referentialConstraints = {}; forEach(art.$tableConstraints.referential, ( fileName, referentialConstraint ) => { referentialConstraints[fileName] = renderReferentialConstraint(referentialConstraint, childEnv.indent, false, csn, options); }); if (renderReferentialConstraintsAsHdbconstraint) { forEach(referentialConstraints, (fileName, constraint ) => { resultObj.hdbconstraint[fileName] = constraint; }); } else { forEachValue(referentialConstraints, (constraint) => { result += `,\n${ constraint }`; }); } } // Append table constraints if any // 'CONSTRAINT <name> UNIQUE (<column_list>) // OR create a unique index for HDI const uniqueConstraints = art.$tableConstraints?.unique; for (const cn in uniqueConstraints) { const constraint = renderUniqueConstraintString(uniqueConstraints[cn], renderArtifactName(`${ artifactName }_${ cn }`), tableName, quoteSqlId, options); if (options.src === 'hdi') resultObj.hdbindex[`${ artifactName }.${ cn }`] = constraint; else result += `,\n${ childEnv.indent }${ constraint }`; } result += `${ env.indent }\n)`; if (options.sqlDialect === 'hana') result += renderTechnicalConfiguration(art.technicalConfig, childEnv); if (supportsHanaAssociations()) { const associations = Object.keys(art.elements) .map(name => renderAssociationElement(name, art.elements[name], childEnv)) .filter(s => s !== '') .join(',\n'); if (associations !== '') { result += `${ env.indent } WITH ASSOCIATIONS (\n${ associations }\n`; result += `${ env.indent })`; } } // Only HANA has indices // FIXME: Really? We should provide a DB-agnostic way to specify that if (options.sqlDialect === 'hana') renderIndexesInto(art.technicalConfig?.hana?.indexes, artifactName, resultObj, env); result += renderComment(art); result += back; resultObj.hdbtable[artifactName] = result; } /** * Render an extended entity constraint into the appropriate dictionaries of 'resultObj'. * Only SAP HANA SQL is currently supported. * * @param {string} artifactName Name of the artifact to render * @param {object} ext Constraint comprising the extension * @param {object} resultObj Result collector */ function renderConstraintExtendInto( artifactName, { constraint, constraintName, constraintType }, resultObj ) { const result = constraintType === 'unique' ? renderUniqueConstraintAdd(constraint, renderArtifactName(`${ artifactName }_${ constraintName }`), renderArtifactName(constraint.parentTable), quoteSqlId, options) : manageConstraint(constraint, csn, options, '', quoteSqlId); addMigration(resultObj, artifactName, false, [ result ]); } /** * Render an extended entity into the appropriate dictionaries of 'resultObj'. * Only SAP HANA SQL is currently supported. * * @param {string} artifactName Name of the artifact to render * @param {object} artifactElements Elements comprising the artifact * @param {object} extElements Elements comprising the extension * @param {object} resultObj Result collector * @param {SqlRenderEnvironment} env Render environment * @param {DuplicateChecker} duplicateChecker */ function renderExtendInto( artifactName, artifactElements, extElements, resultObj, env, duplicateChecker ) { const tableName = renderArtifactName(artifactName); if (duplicateChecker) duplicateChecker.addArtifact(tableName, undefined, artifactName); const elements = render.addColumnsFromElementsObj(artifactName, extElements, env, duplicateChecker); const associations = render.addAssociations(artifactName, extElements, env); if (elements.length + associations.length > 0) addMigration(resultObj, artifactName, false, [ ...elements, ...associations ]); } function addMigration( resultObj, artifactName, drop, sqlArray, description ) { resultObj.migrations[artifactName] ??= []; if (sqlArray) { const migrations = sqlArray.map(migrationSql => ({ drop, sql: migrationSql })); resultObj.migrations[artifactName].push(...migrations); } else if (description) { resultObj.migrations[artifactName].push({ description }); } } /** * Retrieve the 'fzindex' (fuzzy index) property (if any) for element 'elemName' from hanaTc (if defined) * * @param {string} elemName Element to retrieve the index for * @param {object} hanaTc Technical configuration object * @returns {object} fzindex for the element */ function getFzIndex( elemName, hanaTc ) { if (!hanaTc?.fzindexes?.[elemName]) return undefined; if (Array.isArray(hanaTc.fzindexes[elemName][0])) { // FIXME: Should we allow multiple fuzzy search indices on the same column at all? // And if not, why do we wrap this into an array? return hanaTc.fzindexes[elemName][hanaTc.fzindexes[elemName].length - 1]; } return hanaTc.fzindexes[elemName]; } /** * Render an element 'elm' with name 'elementName' (of an entity or type, not of a * projection or view), optionally with corresponding fuzzy index 'fzindex' from the * technical configuration. * Ignore association elements (those are rendered later by renderAssociationElement). * Return the resulting source string (no trailing LF). * * @param {string} elementName Name of the element to render * @param {CSN.Element} elm CSN element * @param {DuplicateChecker} duplicateChecker Utility for detecting duplicates * @param {object} fzindex Fzindex object for the element * @param {SqlRenderEnvironment} env Render environment * @returns {string} Rendered element */ function renderElement( elementName, elm, duplicateChecker, fzindex, env ) { if (elm.virtual || elm.target) return ''; env = env.withSubPath([ 'elements', elementName ]); const isPostgresAlterColumn = env.alterMode && env.changeType === 'migration' && options.sqlDialect === 'postgres'; const quotedElementName = quoteSqlId(elementName); if (duplicateChecker) duplicateChecker.addElement(quotedElementName, elm.$location, elementName); let result = `${ env.indent + quotedElementName }${ isPostgresAlterColumn ? ' TYPE' : '' } ${ renderTypeReference(elm, env) }${ renderNullability(elm, true, env.alterMode) }`; // calculated elements (on write) can't have a default; ignore it env.isInDefault = true; if (elm.$default && env.alterMode && !elm.value && options.sqlDialect !== 'postgres') result += ` DEFAULT ${ renderExpr(elm.$default, env.withSubPath([ '$default' ])) }`; else if (elm.default && !elm.value) result += ` DEFAULT ${ renderExpr(elm.default, env.withSubPath([ 'default' ])) }`; env.isInDefault = false; // Only SAP HANA has fuzzy indices if (fzindex && options.sqlDialect === 'hana') result += ` ${ renderExpr(fzindex, env) }`; // (table) elements can only have a @sql.append const { back } = getSqlSnippets(options, elm); result += back; // Needs to be rendered before the COMMENT result += renderComment(elm); return result; } /** * Render an element 'elm' with name 'elementName' if it is an association, in the style required for * HANA native associations (e.g. 'MANY TO ONE JOIN "source" AS "assoc" ON (condition)'). * Return a string with one line per association element, or an empty string if the element * is not an association. * Any change to the cardinality rendering must be reflected in A2J mapAssocToJoinCardinality() as well. * * @param {string} elementName Name of the element to render * @param {CSN.Element} elm CSN element * @param {SqlRenderEnvironment} env Render environment * @returns {string} Rendered association element */ function renderAssociationElement( elementName, elm, env ) { env = env.withSubPath([ 'elements', elementName ]); let result = ''; if (elm.target) { result += env.indent; if (elm.cardinality) { if (isBetaEnabled(options, 'hanaAssocRealCardinality') && elm.cardinality.src === 1) result += 'ONE TO '; else result += 'MANY TO '; if (elm.cardinality.max === '*' || Number(elm.cardinality.max) > 1) result += 'MANY'; else result += 'ONE'; } else { result += 'MANY TO ONE'; } result += ' JOIN '; result += `${ renderArtifactName(elm.target) } AS ${ quoteSqlId(elementName) } ON (`; result += `${ renderExpr(elm.on, env.withSubPath([ 'on' ])) })`; } return result; } /** * Render the 'technical configuration { ... }' section of an entity that comes as a suffix * to the CREATE TABLE statement (includes migration, unload prio, extended storage, * auto merge, partitioning, ...). * Return the resulting source string. * * @param {object} tc Technical configuration * @param {SqlRenderEnvironment} env Render environment * @returns {string} Rendered technical configuration */ function renderTechnicalConfiguration( tc, env ) { let result = ''; if (!tc) return result; // FIXME: How to deal with non-HANA technical configurations? // This also affects renderIndexes tc = tc.hana; if (!tc) throw new ModelError('Expecting a SAP HANA technical configuration'); if (tc.tableSuffix) { // Although we could just render the whole bandwurm as one stream of tokens, the // compactor has kindly stored each part (e.g. `migration enabled` `row store`, ...) // in its own `xpr` (for the benefit of the `toCdl` renderer, which needs semicolons // between parts). We use this here for putting each one line) // This array contains technical configurations that are illegal in HANA SQL const ignore = [ 'PARTITION BY KEEPING EXISTING LAYOUT', 'ROW STORE', 'COLUMN STORE', 'MIGRATION ENABLED', 'MIGRATION DISABLED', ]; for (const xpr of tc.tableSuffix) { const clause = renderExpr(xpr, env); if (!ignore.includes(clause.toUpperCase())) result += `\n${ env.indent }${ clause }`; } } return result; } /** * Render the array `indexes` from the technical configuration of an entity 'artifactName' * * @param {object} indexes Indices to render * @param {string} artifactName Artifact to render indices for * @param {object} resultObj Result collector * @param {SqlRenderEnvironment} env Render environment */ function renderIndexesInto( indexes, artifactName, resultObj, env ) { // Indices and full-text indices for (const idxName in indexes || {}) { let result = ''; if (Array.isArray(indexes[idxName][0])) { // FIXME: Should we allow multiple indices with the same name at all? (last one wins) for (const index of indexes[idxName]) result = renderExpr(insertTableName(index), env); } else { result = renderExpr(insertTableName(indexes[idxName]), env); } // FIXME: Full text index should already be different in compact CSN if (result.startsWith('FULLTEXT')) resultObj.hdbfulltextindex[`${ artifactName }.${ idxName }`] = result; else resultObj.hdbindex[`${ artifactName }.${ idxName }`] = result; } /** * Insert 'artifactName' (quoted according to naming style) into the index * definition 'index' in two places: * CDS: unique index "foo" on (x, y) * becomes * SQL: unique index "<artifact>.foo" on "<artifact>"(x, y) * CDS does not need this because the index lives inside the artifact, but SQL does. * * @param {Array} index Index definition * @returns {Array} Index with artifact name inserted */ function insertTableName( index ) { const i = index.indexOf('index'); const j = index.indexOf('('); if (i > index.length - 2 || !index[i + 1].ref || j < i || j > index.length - 2) throw new ModelError(`Unexpected form of index: "${ index }"`); let indexName = renderArtifactName(`${ artifactName }.${ index[i + 1].ref }`); if (options.sqlMapping === 'plain') indexName = indexName.replace(/(\.|::)/g, '_'); const result = index.slice(0, i + 1); // CREATE UNIQUE INDEX result.push({ ref: [ indexName ] }); // "<artifact>.foo" result.push(...index.slice(i + 2, j)); // ON result.push({ ref: [ renderArtifactName(artifactName) ] }); // <artifact> result.push(...index.slice(j)); // (x, y) return result; } } /** * Render the source of a query, which may be a path reference, possibly with an alias, * or a sub-select, or a join operation. * * Returns the source as a string. * * @param {object} source Query source * @param {SqlRenderEnvironment} env Render environment * @returns {string} Rendered view source */ function renderQuerySource( source, env ) { // Sub-SELECT if (source.SELECT || source.SET) { let result = `(${ renderQuery(source, env.withIncreasedIndent()) })`; if (source.as) result += ` AS ${ quoteSqlId(source.as) }`; return result; } // JOIN else if (source.join) { // One join operation, possibly with ON-condition let result = `${ renderQuerySource(source.args[0], env.withSubPath([ 'args', 0 ])) }`; for (let i = 1; i < source.args.length; i++) { result = `(${ result } ${ source.join.toUpperCase() } `; if (options.sqlDialect === 'hana') result += renderJoinCardinality(source.cardinality); result += `JOIN ${ renderQuerySource(source.args[i], env.withSubPath([ 'args', i ])) }`; if (source.on) result += ` ON ${ renderExpr(source.on, env.withSubPath([ 'on' ])) }`; result += ')'; } return result; } // Ordinary path, possibly with an alias // Sanity check if (!source.ref) throw new ModelError(`Expecting ref in ${ JSON.stringify(source) }`); return renderAbsolutePathWithAlias(source, env); } /** * Render the cardinality of a join/association * * @param {object} card CSN cardinality representation * @returns {string} Rendered cardinality */ function renderJoinCardinality( card ) { let result = ''; if (card) { if (card.srcmin === 1) result += 'EXACT '; result += card.src === 1 ? 'ONE ' : 'MANY '; result += 'TO '; if (card.min === 1) result += 'EXACT '; if (card.max) result += (card.max === 1) ? 'ONE ' : 'MANY '; } return result; } /** * Render a path that starts with an absolute name (as used for the source of a query), * possibly with an alias, with plain or quoted names, depending on options. Expects an object 'path' that has a * 'ref' and (in case of an alias) an 'as'. If necessary, an artificial alias * is created to the original implicit name. * Returns the name and alias as a string. * * @param {object} path Path to render * @param {SqlRenderEnvironment} env Render environment * @returns {string} Rendered path */ function renderAbsolutePathWithAlias( path, env ) { // This actually can't happen anymore because assoc2joins should have taken care of it if (path.ref[0].where) throw new ModelError(`"At ${ JSON.stringify(env.path) }": Filters in FROM are not supported for conversion to SQL (path: ${ JSON.stringify(path) })`); // SQL needs a ':' after path.ref[0] to separate associations let result = renderAbsolutePath(path, ':', env); // Take care of aliases const implicitAlias = path.ref.length === 0 ? getLastPartOf(getResultingName(csn, options.sqlMapping, path.ref[0])) : getLastPartOfRef(path.ref); if (path.as) { // Source had an alias - render it result += ` AS ${ quoteSqlId(path.as) }`; } else { const quotedAlias = quoteSqlId(implicitAlias); if (getLastPartOf(result) !== quotedAlias) { // Render an artificial alias if the result would produce a different one result += ` AS ${ quotedAlias }`; } } return result; } /** * Render a path that starts with an absolute name (as used e.g. for the source of a query), * with plain or quoted names, depending on options. Expects an object 'path' that has a 'ref'. * Uses <separator> (typically ':': or '.') to separate the first artifact name from any * subsequent associations. * Returns the name as a string. * * @param {object} path Path to render * @param {string} sep Separator between path steps * @param {SqlRenderEnvironment} env Render environment * @returns {string} Rendered path */ function renderAbsolutePath( path, sep, env ) { // Sanity checks if (!path.ref) throw new ModelError(`Expecting ref in path: ${ JSON.stringify(path) }`); // Determine the absolute name of the first artifact on the path (before any associations or element traversals) const firstArtifactName = path.ref[0].id || path.ref[0]; let result = renderArtifactName(firstArtifactName); // store argument syntax hint in environment // $syntax is set only by A2J and only at the first path step after FROM clause rewriting const syntax = path.ref[0].$syntax; // Even the first step might have parameters and/or a filter // Render the actual parameter list. If the path has no actual parameters, // the ref is not rendered as { id: ...; args: } but as short form of ref[0] ;) // An empty actual parameter list is rendered as `()`. const ref = csn.definitions[path.ref[0].id] || csn.definitions[path.ref[0]]; if (ref?.params) { result += path.ref[0]?.args ? `(${ renderArgs(path.ref[0], '=>', env.withSubPath([ 'ref', 0 ]), syntax) })` : '()'; } else if (syntax === 'udf') { // if syntax is user defined function, render empty argument list // CV without parameters is called as simple view result += '()'; } if (path.ref[0].where) { const cardinality = path.ref[0].cardinality ? (`${ path.ref[0].cardinality.max }: `) : ''; result += `[${ cardinality }${ renderExpr(path.ref[0].where, env.withSubPath([ 'ref', 0, 'where' ])) }]`; } // Add any path steps (possibly with parameters and filters) that may follow after that if (path.ref.length > 1) result += `${ sep }${ renderTypeRef({ ref: path.ref.slice(1) }, env) }`; return result; } /** * Render function arguments or view parameters (positional if array, named if object/dict), * using 'sep' as separator for positional parameters * * @param {object} node with `args` to render * @param {string} sep Separator between args * @param {SqlRenderEnvironment} env Render environment * @param {string|null} syntax Some magic A2J parameter - for calcview parameter rendering * @returns {string} Rendered arguments * @throws Throws if args is not an array or object. */ function renderArgs( node, sep, env, syntax ) { if (!node.args) return ''; // Positional arguments if (Array.isArray(node.args)) return node.args.map((arg, i) => renderExpr(arg, env.withSubPath([ 'args', i ]))).join(', '); // Named arguments (object/dict) else if (typeof node.args === 'object') // if this is a function param which is not a reference to the model, we must not quote it return Object.keys(node.args).map(key => `${ node.func ? key : decorateParameter(key, syntax) } ${ sep } ${ renderExpr(node.args[key], env.withSubPath([ 'args', key ])) }`).join(', '); throw new ModelError(`Unknown args: ${ JSON.stringify(node.args) }`); /** * Render the given argument/parameter correctly. * * @param {string} arg Argument to render * @param {string|null} parameterSyntax Some magic A2J parameter - for calcview parameter rendering * @returns {string} Rendered argument */ function decorateParameter( arg, parameterSyntax ) { if (parameterSyntax === 'calcview') return `PLACEHOLDER."$$${ arg }$$"`; return quoteSqlId(arg); } } /** * Render a single view column 'col', as it occurs in a select list or projection list. * Return the resulting source string (one line per column item, no CR). * * @param {object} col Column to render * @param {CSN.Elements} elements of leading or subquery * @param {SqlRenderEnvironment} env Render environment * @returns {string} Rendered column */ function renderViewColumn( col, elements, env ) { let result = ''; const leaf = col.as || col.ref?.[col.ref.length - 1] || col.func; if (leaf && elements[leaf]?.virtual) { if (isDeprecatedEnabled(options, '_renderVirtualElements')) // render a virtual column 'null as <alias>' result += `${ env.indent }NULL AS ${ quoteSqlId(col.as || leaf) }`; } else { // Since magic variables are replaced, we may need to create an alias if there isn't one. if (!col.as && col.ref?.[0] && isMagicVariable(pathId(col.ref?.[0]))) col.as = implicitAs(col.ref); result = env.indent + renderExpr(withoutCast(col), env); if (col.as) result += ` AS ${ quoteSqlId(col.as) }`; else if (col.func && !col.args) // e.g. CURRENT_TIMESTAMP result += ` AS ${ quoteSqlId(col.func) }`; } return result; } /** * Render a view * * @param {string} artifactName Name of the view * @param {CSN.Artifact} art CSN view * @param {object} resultObj * @param {SqlRenderEnvironment} env Render environment * @returns {string} Rendered view */ function renderViewInto( artifactName, art, resultObj, env ) { const viewName = renderArtifactName(artifactName); definitionsDuplicateChecker.addArtifact(art['@cds.persistence.name'], art