UNPKG

@sap/xsodata

Version:

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

1,000 lines (908 loc) 34.5 kB
'use strict'; const logMetadata = process.env.XSODATA_METADATA >= 1; /** * Add <__metadata> and <__schema> to each entity in <context.xsodata> * Uses <context.xsodata> * @type {exports} */ const utils = require('./../utils/utils'); const async = require('async'); const Rwlock = require('rwlock'); const db = require('./../db/connect'); const locks = new Rwlock(); const model = require('./model'); const InternalError = require('./../utils/errors/internalError'); const NotFound = require('./../utils/errors/http/notFound'); const SqlError = require('../utils/errors/sqlError'); const EntityType = require('./entityType'); const Association = require('./association'); const _ = require('lodash'); const Measurement = require('./../utils/measurement'); const dataCollector2 = require('./../sql/dataCollector2'); function loadKeysForTable(context, entityType, cb) { context.logger.silly( 'model', 'loadKeysForTable for entity ' + entityType.name ); const processRows = function (err, rows) { if (logMetadata) { context.logger.silly( 'model', 'rows\n' + JSON.stringify(rows, null, 4) ); } if (err) { return cb(new SqlError(context, err)); } if (rows.length === 0) { return cb( new InternalError( 'Table object for entity ' + entityType.name + ' does not have a key. Add a primary key to the table or use a generated key.', context, null ), context ); } entityType.setKeyNames( rows.map(function toKeyNames(row) { return row.COLUMN_NAME; }) ); return cb(null, entityType); }; let sql = ''; if (entityType._isVirtual) { sql = 'select * from ' + (entityType._remoteSource ? '"' + entityType._remoteSource + '"."SYS"."CONSTRAINTS"' : '"SYS"."CONSTRAINTS" ') + "WHERE SCHEMA_NAME = '" + entityType._virtualObjectSchema + "' AND TABLE_NAME = '" + entityType._virtualObjectName + "' AND IS_PRIMARY_KEY = 'TRUE' ORDER BY position"; } else { let schema = entityType.schema || context.defaultSchema; sql = "SELECT * from CONSTRAINTS WHERE SCHEMA_NAME = '" + schema + "' AND TABLE_NAME = '" + entityType.tableName + "' AND IS_PRIMARY_KEY = 'TRUE' ORDER BY position"; } dataCollector2.executeSqlDirectly(context, sql, processRows); } function loadTableInfo(context, entityType, tableStoreType, cb) { context.logger.silly('model', 'loadTableInfo'); const processRows = function (err, rows) { try { if (logMetadata) { context.logger.silly( 'model', 'rows\n' + JSON.stringify(rows, null, 4) ); } if (err) { return cb(new SqlError(context, err)); } if (rows.length === 0) { return cb( new NotFound( 'No data found for ' + entityType.tableName + '.', context, '' ) ); } context.logger.silly( 'model', 'create Metadata for entity ' + entityType.name + ' with ' + rows.length + ' rows' ); entityType.init( new model.Metadata(rows), rows[0].KIND, tableStoreType, context.logger ); if (entityType.kind === model.entityKind.table) { if (entityType.keys.names.length > 0) { return cb( new InternalError( 'Keys cannot be specified for source as it is a table object.', context, null ), context ); } else { return loadKeysForTable(context, entityType, cb); } } else { const genKey = entityType.keys.generatedKey; if (!genKey && entityType.keys.names.length === 0) { return cb( new InternalError( 'Missing specification of keys for view.', context, null ), context ); } else { if (genKey) { const overwrittenColumn = entityType.propertiesMap[ entityType.keys.generatedKey ]; if (overwrittenColumn) { // Provided generated key overwrites an existing column let msg = "Invalid generated key property '" + genKey + "'"; msg += " in definition '" + overwrittenColumn.TABLE_NAME + "'."; msg += ' The generated key would overwrite an existing property'; return cb( new InternalError(msg, context, null), context ); } } return cb(null, entityType); } } } catch (exception) { return cb(new InternalError(exception.message)); } }; let schema = ''; let parameters = []; // entityType.table is either a real table or a SQL view; // so we need to lookup both: sys.table_columns and sys.view_columns (if set: check via "remote source"); // sys.columns is not usable due to access restrictions let sql = 'SELECT ' + ' TO_BIGINT(' + model.entityKind.table + ') kind, ' + ' table_name, ' + ' column_name, ' + ' position, ' + ' data_type_name, ' + ' is_nullable, ' + ' length, ' + ' scale, ' + ' default_value ' + 'FROM ' + (entityType._remoteSource ? '"' + entityType._remoteSource + '"' + '."SYS"."TABLE_COLUMNS"' : 'TABLE_COLUMNS ') + 'WHERE schema_name = ? AND ' + ' table_name = ? ' + 'UNION ALL ' + 'SELECT ' + ' TO_BIGINT(' + model.entityKind.view + ') kind, ' + ' view_name as table_name, ' + ' column_name, ' + ' position, ' + ' data_type_name, ' + ' is_nullable, ' + ' length, ' + ' scale, ' + ' default_value ' + 'FROM ' + (entityType._remoteSource ? '"' + entityType._remoteSource + '"' + '."SYS"."VIEW_COLUMNS"' : 'VIEW_COLUMNS ') + 'WHERE schema_name = ? AND ' + ' view_name = ? ' + 'ORDER BY position'; if (entityType._isVirtual) { parameters = [ entityType._virtualObjectSchema, entityType._virtualObjectName, entityType._virtualObjectSchema, entityType._virtualObjectName, ]; } else { schema = entityType.schema || context.defaultSchema; parameters = [ schema, entityType.tableName, schema, entityType.tableName, ]; } context.logger.silly('model', 'sql loadTableInfo: ' + sql); context.logger.silly('model', 'sql parameters: ' + parameters); dataCollector2.executeSqlAsPreparedStatement( context, sql, parameters, processRows ); } function loadCalcViewInfo(context, entityType, cb) { /* Remark : it is not possible(or at least i was not able to) to execute a JOIN in a MDX SELECT */ let schema = entityType.schema || context.defaultSchema; let sql = 'select ' + 'SCHEMA_NAME,' + 'QUALIFIED_NAME,' + 'CATALOG_NAME,' + 'CUBE_NAME,' + 'COLUMN_NAME,' + // JOIN ON 'MEASURE_AGGREGATOR,' + // 1, 2, 3, 4, 5 in sync with entityType.CV_AGGREGATE_FUNCTIONS 'MEASURE_AGGREGATABLE, ' + 'SEMANTIC_TYPE, ' + 'COLUMN_CAPTION, ' + 'DIMENSION_TYPE, ' + 'UNIT_COLUMN_NAME, ' + 'DESC_NAME, ' + '"ORDER" ' + 'from ' + (entityType._remoteSource ? '"' + entityType._remoteSource + '"' + '."_SYS_BI"."BIMC_DIMENSION_VIEW_HDI"' : '_SYS_BI.BIMC_DIMENSION_VIEW_HDI ') + 'where SCHEMA_NAME = ? AND QUALIFIED_NAME = ? ' + 'order by "ORDER"'; const processRows = function (err, rows) { if (logMetadata) { context.logger.silly( 'metadataReader', 'rows\n' + JSON.stringify(rows, null, 4) ); } if (err) { return cb(new SqlError(context, err)); } if (!rows.length) { return cb(null, null); } const cubeName = rows[0].CUBE_NAME; entityType.setCalculationViewDimensionData( rows, { cube: cubeName }, context.logger ); // e.g. sap:label is fill with calculation view dimension data entityType.resolveAggregates(); entityType.properties.forEach((property) => { // suppressing annotation "sap:aggregation-role" depends on xsodata-file "setting" if ( entityType._settings.noDimensionAnnoOnTextProperty && entityType._settings.noDimensionAnnoOnTextProperty === 'true' ) { if ( property.DESC_NAME && property.DESC_NAME !== property.COLUMN_NAME ) { for (const entityProperty of entityType.properties) { if (entityProperty.COLUMN_NAME === property.DESC_NAME) { entityProperty.suppressAnnotationDimension = true; } } } } }); const parameters = entityType.getParameters(); if (!parameters) { // entity not exposed as calcview return cb(null, entityType); } entityType.makeCalcview(); loadInputParameters( parameters, entityType, context, function (errInp /*, paramEntity*/) { if (errInp) { return cb(errInp); } return cb(null, entityType); } ); }; /* Example mapping : calc view === sap.hana.democontent.epm.models::BUYER SCHEMA_NAME === "container schema name". QUALIFIED_NAME === "...::BUYER" */ /* WARNING WARNING The current HANA version has a bug, and prepared statements are not working in mdx select. Hence i replaced prepared statement with direct sql statement. */ let parameters = []; if (entityType._isVirtual) { // CV information must be read from the remote database; // no such concept of "virtual" CV data in local database as it is the case for tables parameters = [ entityType._virtualObjectSchema, entityType._virtualObjectName, ]; } else { parameters = [schema, entityType.tableName]; } dataCollector2.executeSqlAsPreparedStatement( context, sql, parameters, processRows ); } function loadInputParameters(parameters, entityType, context, cb) { let sql = 'select distinct ' + 'VARIABLE_NAME, ' + 'COLUMN_TYPE_D, ' + 'COLUMN_SQL_TYPE, ' + 'MANDATORY, ' + 'DESCRIPTION, ' + 'DEFAULT_VALUE, ' + 'SEMANTIC_TYPE, ' + 'SELECTION_TYPE, ' + 'MULTILINE, ' + '"ORDER" ' + 'from ' + (entityType._remoteSource ? '"' + entityType._remoteSource + '"' + '"_SYS_BI"."BIMC_VARIABLE_VIEW_HDI"' : '_SYS_BI.BIMC_VARIABLE_VIEW_HDI ') + 'where QUALIFIED_NAME = ? order by "ORDER"'; context.logger.debug( 'metadataReader', 'loadInputParameters: ' + entityType.tableName ); context.logger.debug('metadataReader', 'sql loadInputParameters: ' + sql); dataCollector2.executeSqlAsPreparedStatement( context, sql, [entityType.tableName], (err, rows) => { if (logMetadata) { context.logger.silly( 'metadataReader', 'rows\n' + JSON.stringify(rows, null, 4) ); } if (err) { return cb(new SqlError(context, err)); } entityType.inputParameters = {}; // if rows length==0 a calcview without inputparameter is exposed // this is ok if used like // "xsodata.test.calcview::calcview_without_input" as "CalcviewWithoutAsCalcview" // keys generate local "CVID" parameters via entity "CalcviewWithoutAsCalcviewParam" // results property "Results"; // and access url /calcview_without_input.xsodata/CalcviewWithoutAsCalcviewParam()/Results rows.forEach(function (row) { entityType.inputParameters[row.VARIABLE_NAME] = row; }); try { const paramEntityType = loadInputParametersEntity( parameters, entityType, context, rows ); if (parameters.viaKey) { /*this is called */ loadInputParametersViaKey(entityType, paramEntityType); } } catch (err_process) { err = err_process; } return cb(err); } ); } function _getLength(columnSqlType) { const n = columnSqlType.match(/^\w+\((\d+)\)$/); if (n) { return Number.parseInt(n[1]); } return null; } exports._getLength = _getLength; function _getType(type, defaultType = null) { if (type) { const i = type.indexOf('('); if (i > -1) { return type.substr(0, i); } else { return type; } } return defaultType || 'NVARCHAR'; } exports._getType = _getType; function loadInputParametersEntity(parameters, entityType, context, rows) { let paramEntityName = parameters.entity; if (!paramEntityName || paramEntityName.trim() === '') { //No name, use default naming paramEntityName = entityType.name + 'Parameters'; } const resultsProp = parameters.resultProperty || 'Results'; let assocName = paramEntityName + '_to_' + entityType.name; const _et = { name: paramEntityName, properties: {}, navigates: {}, keys: rows.map(function (row) { return row.VARIABLE_NAME; }), }; _et.navigates[resultsProp] = { name: resultsProp, association: assocName, from: { principal: true, }, }; const paramEntityType = new EntityType(_et); const mrows = rows.map(function (row) { const type = _getType(row.COLUMN_SQL_TYPE, row.COLUMN_TYPE_D); const metadata = { KIND: model.entityKind.inputParameters, TABLE_NAME: null, COLUMN_NAME: row.VARIABLE_NAME, POSITION: row.ORDER, DATA_TYPE_NAME: type, IS_NULLABLE: true, LENGTH: _getLength(row.COLUMN_SQL_TYPE), SCALE: null, SEMANTIC_TYPE: row.SEMANTIC_TYPE, MANDATORY: row.MANDATORY, DESCRIPTION: row.DESCRIPTION, SELECTION_TYPE: row.SELECTION_TYPE, MULTILINE: row.MULTILINE, }; return metadata; }); assocName = paramEntityName + '_to_' + entityType.name; const association = new Association({ name: assocName, principal: { type: paramEntityName, multiplicity: '1', }, dependent: { type: entityType.name, multiplicity: '*', }, referentialConstraint: false, over: null, isInputParametersAssociation: true, }); paramEntityType.init( new model.Metadata(mrows), model.entityKind.inputParameters, null, context.logger ); context.gModel.addEntity(paramEntityType); context.gModel.addAssociation(association); return paramEntityType; } function loadInputParametersViaKey(entityType, paramEntityType) { const pvalues = _.values(paramEntityType.propertiesMap); pvalues.forEach(function (pv) { entityType.addProperty(pv, true); }); } function loadEntityType(context, entityType, cbErr) { determineStoreType(context, entityType, function (error, tableStoreType) { if (error) { return cbErr(error); } return loadTableInfo( context, entityType, tableStoreType, function (err, entityType1) { if (err) { return cbErr(err, entityType1); } return loadCalcViewInfo( context, entityType1, function (errCv, entityTypeCV) { if (errCv) { return cbErr(errCv); } try { entityType1.buildConcurrentPropertyList(); } catch (exception) { return cbErr(new InternalError(exception.message)); } return cbErr(null, entityTypeCV); } ); } ); }); } /** * Determines HANA store type ("column" or "row") for the table / view, which is used for the specified * <code>entityType</code>. * * @param {Object} context - OData context * @param {Object} entityType - entity type, for which table the store type should be determined * @param {Function} callback - callback function, which is called when the operation is completed. */ function determineStoreType(context, entityType, callback) { let schema = entityType.schema || context.defaultSchema, sql = 'select IS_COLUMN_TABLE as "IS_COLUMN_TYPE" from TABLES where SCHEMA_NAME = ? AND TABLE_NAME = ? ' + 'union select IS_COLUMN_VIEW as "IS_COLUMN_TYPE" from VIEWS where SCHEMA_NAME = ? AND VIEW_NAME = ?', tableName = entityType.tableName, parameters = [schema, tableName, schema, tableName]; context.logger.debug('model', 'sql determineStoreType: ' + sql); context.logger.debug( 'model', 'sql determineStoreType parameters: ' + parameters ); dataCollector2.executeSqlAsPreparedStatement( context, sql, parameters, (error, rows) => { if (error) { return callback(new SqlError(context, error)); } // if no error and no rows are returned: check for public synonym; // if a virtual table is used (includes remote tables and remote views / CVs), we got a row back here! if (rows.length === 0) { context.logger.debug( 'model', `No table/view information found for ${tableName}. Try public synonym.` ); processSynonymTableType( context, entityType, tableName, (synonymErr, synonymFound, tableStoreType) => { if (synonymErr) { return callback(synonymErr); } if (synonymFound) { return callback(null, tableStoreType); } return callback( new NotFound( `No table/view or synonym data found for table ${tableName}.`, context ) ); } ); } else { // check for virtual table; add VT data for entity type, if existing let virtualTableSQL = 'select * from SYS.VIRTUAL_TABLES where schema_name = ? and table_name = ?'; let virtualTableParameters = [schema, tableName]; context.logger.info( 'model', `Check for virtual table for ${tableName}` ); dataCollector2.executeSqlAsPreparedStatement( context, virtualTableSQL, virtualTableParameters, (sqlError, virtualTableRows) => { if (sqlError) { context.logger.error( 'model', `Failed read data for virtual table: ${tableName}` + sqlError ); return callback(new SqlError(context, sqlError)); } if (virtualTableRows.length !== 0) { entityType.setVirtualInfo( virtualTableRows[0].REMOTE_OWNER_NAME, virtualTableRows[0].REMOTE_OBJECT_NAME, virtualTableRows[0].REMOTE_SOURCE_NAME ); context.logger.info( 'model', `(Remote) Virtual table information for table ${tableName}` ); context.logger.info( 'model', `Remote schema: ${virtualTableRows[0].REMOTE_OWNER_NAME}` ); context.logger.info( 'model', `Remote object: ${virtualTableRows[0].REMOTE_OBJECT_NAME}` ); context.logger.info( 'model', `Remote source: ${virtualTableRows[0].REMOTE_SOURCE_NAME}` ); } else { context.logger.info( 'model', `No virtual table information exists for ${tableName}` ); } // use data already found from TABLES / VIEWS for setting store type return processTableType( error, rows, context, tableName, callback ); } ); } } ); } function processSynonymTableType(context, entityType, tableName, callback) { const synonymSql = `select * from SYNONYMS where SCHEMA_NAME = 'PUBLIC' AND SYNONYM_NAME = '${tableName}'`; dataCollector2.executeSqlDirectly( context, synonymSql, (synonymErr, synonymRows) => { if (synonymErr) { context.logger.error( 'model', 'Failed to read synonym: ' + synonymErr ); return callback(new SqlError(context, synonymErr), false, null); } if (synonymRows.length === 0) { return callback(null, false, null); // not a synonym } if (synonymRows.length !== 1) { context.logger.error( 'model', 'No (unique) synonym data found for ' + tableName + ' table.' ); return callback( new NotFound( 'No (unique) synonym data found for ' + tableName + ' table.', context ), false, null ); } // check for local or remote synonym if (synonymRows[0].OBJECT_SCHEMA === '_SYS_LDB') { // remote synonym const virtualTableName = synonymRows[0].OBJECT_NAME; const virtualTableSql = `select * from VIRTUAL_TABLES where SCHEMA_NAME = '_SYS_LDB' and TABLE_NAME = '${virtualTableName}'`; context.logger.info( 'model', `Checking virtual tables for remote synonym information for table ${virtualTableName}` ); // get remote source dataCollector2.executeSqlDirectly( context, virtualTableSql, (virtualTableErr, virtualTableRows) => { if (virtualTableErr) { context.logger.error( 'model', `Failed to read virtual table ${virtualTableName} for synonym ${tableName}: ${virtualTableErr}` ); return callback( new SqlError(context, virtualTableErr), false, null ); } context.logger.info( 'model', `(Remote) Virtual table information for synonym ${tableName} via table ${virtualTableName}` ); context.logger.info( 'model', `Remote schema: ${virtualTableRows[0].REMOTE_OWNER_NAME}` ); context.logger.info( 'model', `Remote object: ${virtualTableRows[0].REMOTE_OBJECT_NAME}` ); context.logger.info( 'model', `Remote source: ${virtualTableRows[0].REMOTE_SOURCE_NAME}` ); entityType.setVirtualInfo( virtualTableRows[0].REMOTE_OWNER_NAME, // remote schema name virtualTableRows[0].REMOTE_OBJECT_NAME, // remote object name virtualTableRows[0].REMOTE_SOURCE_NAME ); // remote source // in SYNONYMS, each remote object has always the setting IS_COLUMN_OBJECT === FALSE (keep it as is) let isColumnStoreType = synonymRows[0].IS_COLUMN_OBJECT; let tableStoreType = isColumnStoreType === 'TRUE' ? 'column' : 'row'; return callback(null, true, tableStoreType); } ); } else { // local synonym context.logger.info( 'model', `(Local) Synonym information for ${synonymRows[0].SYNONYM_NAME}` ); context.logger.info( 'model', `Local schema: ${synonymRows[0].OBJECT_SCHEMA}` ); context.logger.info( 'model', `Local object: ${synonymRows[0].OBJECT_NAME}` ); entityType.setVirtualInfo( synonymRows[0].OBJECT_SCHEMA, // local schema name synonymRows[0].OBJECT_NAME ); // local object name // in SYNONYMS, a local object has the "correct" store type setting let isColumnObjectType = synonymRows[0].IS_COLUMN_OBJECT; let tableStoreType = isColumnObjectType === 'TRUE' ? 'column' : 'row'; return callback(null, true, tableStoreType); } } ); } /** * Callback, which is used to handle values, produced by determineStoreType function. * * @param {Object} error - error, produced by determineStoreType function * @param {Object[]} dbRows - database entries, produced by determineStoreType function * @param {Object} context - OData context * @param {string} tableName - DB table name, for which store type should be determined * @param {Function} callback - callback function, which is called when the operation is completed. */ function processTableType(error, dbRows, context, tableName, callback) { let isColumnStoreType, tableStoreType; if (error) { return error instanceof SqlError ? callback(error) : callback(new SqlError(context, error)); } if (dbRows.length === 0) { return callback( new NotFound('No data found for ' + tableName + ' table.', context) ); } if (logMetadata) { context.logger.silly( 'processTableType', 'rows:\n' + JSON.stringify(dbRows, null, 4) ); } // if found table is a virtual (remote) table, the IS_COLUMN_TYPE is always set to FALSE, // but this setting does not affect SQL processing (joins on temp. tables) isColumnStoreType = dbRows[0].IS_COLUMN_TYPE; // HANA returns "TRUE"/"FALSE" as a string and not as a boolean value tableStoreType = isColumnStoreType === 'TRUE' ? 'column' : 'row'; return callback(null, tableStoreType); } exports._processTableType = processTableType; function createLockeEntityID(context, entityType) { return (context.uriTree.xsoFile || '') + '_' + entityType.name; } function loadEntityTypeOnce(context, entityType, cbErr) { const lockEntityID = createLockeEntityID(context, entityType); if (entityType.isInitialised) { //access to __metadata is writelooked, so __metadata is definitively filled here return cbErr(); } context.logger.silly('model', 'entitytype load: ' + lockEntityID); locks.writeLock(lockEntityID, function (release) { context.logger.silly('model', 'got lock for: ' + lockEntityID); //if loaded in the meantime if (entityType.isInitialised) { context.logger.silly( 'model', 'type already initialized: ' + lockEntityID ); release(); return cbErr(); } const load = function (err, context) { if (err) { release(); return cbErr(err); } return loadEntityType(context, entityType, function (err) { release(); return cbErr(err); }); }; try { if (Measurement.isActive()) { const callConnect = utils.tryAndMeasure( db.connect, 'dbConnect' ); return callConnect(context, load); } else { return db.connect(context, load); } } catch (err) { release(); return cbErr(err); } }); } exports.loadModelMetadata = function (context, asyncDone) { context.logger.silly('model', 'load metadata'); const entityTypes = context.gModel.getEntityTypes(); let parent; if (Measurement.isActive()) { // perform an explicit measurement parent = Measurement.getRunningMeasurement(); } async.mapSeries( entityTypes, function (entityType, cbMapLimit) { let child; try { if (Measurement.isActive() && !context.batchContext) { child = parent.newChild(entityType.name); child.counterStart(); } return loadEntityTypeOnce(context, entityType, function (err) { //stop measurement if (Measurement.isActive() && !context.batchContext) { child.counterStop(); } return cbMapLimit(err); }); } catch (ex) { //stop measurement if (Measurement.isActive() && !context.batchContext) { child.counterStop(); } return cbMapLimit(ex); } }, function (err) { if (err) { context.logger.error( 'model', `Error on loadEntityTypes for ${context.uriTree.xsoFile}:` + err ); } context.logger.silly( 'model', 'loadEntityTypes finished for ' + context.uriTree.xsoFile ); return asyncDone(err, context); } ); };