hana-cli
Version:
HANA Developer Command Line Interface
668 lines (607 loc) • 21.5 kB
JavaScript
import * as baseLite from '../utils/base-lite.js'
import * as dbInspect from '../utils/dbInspect.js'
import { createRequire } from 'module'
// @ts-ignore
import cds from '@sap/cds'
import { buildDocEpilogue } from '../utils/doc-linker.js'
global.__xRef = []
export const command = 'cds [schema] [table]'
export const aliases = ['cdsPreview']
export const describe = baseLite.bundle.getText("cds")
const t = (key, params = []) => baseLite.bundle.getText(key, params)
export const builder = (yargs) => yargs.options(baseLite.getBuilder({
table: {
alias: ['t'],
type: 'string',
desc: baseLite.bundle.getText("table")
},
schema: {
alias: ['s'],
type: 'string',
default: '**CURRENT_SCHEMA**',
desc: baseLite.bundle.getText("schema")
},
view: {
alias: ['v'],
type: 'boolean',
default: false,
desc: baseLite.bundle.getText("viewOpt")
},
useHanaTypes: {
alias: ['hana'],
type: 'boolean',
default: false,
desc: baseLite.bundle.getText("useHanaTypes")
},
useQuoted: {
alias: ['q', 'quoted'],
desc: baseLite.bundle.getText("gui.useQuoted"),
type: 'boolean',
default: false
},
port: {
alias: ['p'],
type: 'number',
default: false,
desc: baseLite.bundle.getText("port")
},
profile: {
alias: ['pr'],
type: 'string',
desc: baseLite.bundle.getText("profile")
}
})).wrap(160).example('hana-cli cds --table myTable --schema MYSCHEMA', baseLite.bundle.getText("cdsExample")).wrap(160).epilog(buildDocEpilogue('cds', 'developer-tools', ['activateHDI', 'generateDocs', 'codeTemplate']))
export async function handler(argv) {
const base = await import('../utils/base.js')
base.promptHandler(argv, cdsBuild, {
table: {
description: base.bundle.getText("table"),
type: 'string',
required: true,
ask: () => {
return false
}
},
schema: {
description: base.bundle.getText("schema"),
type: 'string',
required: true
},
view: {
description: base.bundle.getText("viewOpt"),
type: 'boolean',
required: true,
ask: () => {
return false
}
},
useHanaTypes: {
description: base.bundle.getText("useHanaTypes"),
type: 'boolean'
},
useQuoted: {
description: base.bundle.getText("gui.useQuoted"),
type: 'boolean'
},
port: {
description: base.bundle.getText("port"),
required: false,
ask: () => {
return false
}
}
})
}
export async function cdsBuild(prompts) {
const base = await import('../utils/base.js')
base.debug('cds')
try {
base.setPrompts(prompts)
const db = await base.createDBConnection()
let schema = await base.dbClass.schemaCalc(prompts, db)
let object, fields, constraints, cdsSource
dbInspect.options.useHanaTypes = prompts.useHanaTypes
dbInspect.options.useQuoted = prompts.useQuoted
dbInspect.options.noColons = true
if (!prompts.view) {
object = await dbInspect.getTable(db, schema, prompts.table)
fields = await dbInspect.getTableFields(db, object[0].TABLE_OID)
constraints = await dbInspect.getConstraints(db, object)
} else {
object = await dbInspect.getView(db, schema, prompts.table)
fields = await dbInspect.getViewFields(db, object[0].VIEW_OID)
}
cdsSource =
//
`
@protocol: ['odata-v4', 'graphql']
service HanaCli { `
if (process.env.VCAP_SERVICES) {
let vcap = JSON.parse(process.env.VCAP_SERVICES)
vcap.hana[0].credentials.schema = object[0].SCHEMA_NAME
vcap.hana.splice(1, 100)
process.env.VCAP_SERVICES = JSON.stringify(vcap)
}
if (!cds.env.requires.db?.credentials) {
// CDS 9.x may not resolve cds.env.requires.db when running from a subdirectory
// without its own CDS project config – initialize it if missing
if (!cds.env.requires.db) {
cds.env.requires.db = { kind: 'hana' }
}
const settings = db.client?._settings
if (settings) {
let out = {}
out.schema = object[0].SCHEMA_NAME
out.url = settings.url
out.certificate = settings.certificate
out.database_id = settings.database_id
out.driver = settings.driver
out.hdi_user = settings.hdi_user
out.hdi_password = settings.hdi_password
out.host = settings.host
out.user = settings.user
out.password = settings.password
out.port = settings.port
out.pooling = settings.pooling
out.encrypt = true
out.sslValidateCertificate = false
out.useTLS = true
cds.env.requires.db.credentials = out
}
}
cdsSource +=
`@(Capabilities: {
InsertRestrictions: {Insertable: true},
UpdateRestrictions: {Updatable: true},
DeleteRestrictions: {Deletable: true}
},
HeaderInfo: {
TypeName: '${prompts.table}',
Title:'${prompts.table}'
},
UI: {
LineItem: [ \n`;
for (let field of fields) {
if (prompts.useQuoted) {
cdsSource += `{$Type: 'UI.DataField', Value: ![${field.COLUMN_NAME}], ![@UI.Importance]:#High}, \n`
} else {
cdsSource += `{$Type: 'UI.DataField', Value: ${field.COLUMN_NAME}, ![@UI.Importance]:#High}, \n`
}
}
cdsSource +=
`], \n`
cdsSource +=
` Facets: [
{$Type: 'UI.ReferenceFacet', Target: '@UI.FieldGroup#Main', ![@UI.Importance]: #High}
],
FieldGroup#Main: { \n
Data: [ \n`
for (let field of fields) {
cdsSource += `{$Type: 'UI.DataField', Value: ${field.COLUMN_NAME}, ![@UI.Importance]:#High}, \n`
}
cdsSource +=
`] },\n`
cdsSource += ` SelectionFields: [ `;
for (let field of fields) {
cdsSource += `${field.COLUMN_NAME}, \n`
}
cdsSource +=
`] \n`
cdsSource += `} )\n`
if (!prompts.view) {
console.log(t("cds.log.schemaTable", [schema, prompts.table]))
object = await dbInspect.getTable(db, schema, prompts.table)
fields = await dbInspect.getTableFields(db, object[0].TABLE_OID)
constraints = await dbInspect.getConstraints(db, object)
let tableSource = await dbInspect.formatCDS(db, object, fields, constraints, "table", schema, "preview")
cdsSource +=
`@cds.persistence.skip\n ${tableSource} \n }`
} else {
console.log(t("cds.log.schemaView", [schema, prompts.table]))
object = await dbInspect.getView(db, schema, prompts.table)
fields = await dbInspect.getViewFields(db, object[0].VIEW_OID)
let viewSource = await dbInspect.formatCDS(db, object, fields, null, "view", schema, "preview")
cdsSource +=
`${viewSource} \n }`
}
await cdsServerSetup(prompts, cdsSource)
// Don't call base.end() - let the CDS server keep running
} catch (error) {
await base.error(error)
}
}
async function cdsServerSetup(prompts, cdsSource) {
const base = await import('../utils/base.js')
base.debug('cdsServerSetup')
const { default: Server } = await import('http')
const port = process.env.PORT || prompts.port || 3010
if (!(/^[1-9]\d*$/.test(port) && 1 <= 1 * port && 1 * port <= 65535)) {
return base.error(`${port} ${baseLite.bundle.getText("errPort")}`)
}
const server = Server.createServer()
const express = base.require('express')
var app = express()
// Assign Express app to CDS so plugins can use it
cds.app = app
// Load @sap/cds-fiori plugin manually since auto-loading isn't working
// This bridges the ES module / CommonJS gap
try {
const require = createRequire(import.meta.url)
console.log(t("cds.plugin.loading"))
// Get the plugin's preview functionality
const preview = require('@sap/cds-fiori/app/preview')
console.log(t("cds.plugin.loaded"))
console.log(t("cds.plugin.previewModule"), typeof preview)
} catch (pluginErr) {
console.warn(t("cds.plugin.loadFailed", [pluginErr.message]))
}
// Handle server-level errors gracefully
server.on('error', (err) => {
base.debug(baseLite.bundle.getText("debug.cds.serverError", [err.message]))
console.error(t("cds.server.error", [err.message]))
})
// Handle any unhandled promise rejections in the server context
process.on('unhandledRejection', (reason, promise) => {
base.debug(baseLite.bundle.getText("debug.cds.unhandledRejection", [reason]))
console.error(t("cds.unhandledRejection"), reason)
})
//CDS OData Service
let vcap = ''
let options = {
"db": {
kind: "hana",
logLevel: "error"
}
}
if (process.env.VCAP_SERVICES) {
vcap = JSON.parse(process.env.VCAP_SERVICES)
// @ts-ignore
options.db.credentials = vcap.hana[0].credentials
}
// Configure CDS Fiori plugin for preview BEFORE cds.serve()
if (!cds.env.fiori) {
cds.env.fiori = {}
}
cds.env.fiori.preview = true
console.log(t("cds.preview.enabled"), cds.env.fiori.preview)
// Listen for the 'served' event to verify it fires
cds.on('served', (services) => {
console.log(t("cds.served.fired"))
console.log(t("cds.served.services"), Object.keys(services))
console.log(t("cds.served.fiori"), JSON.stringify(cds.env.fiori, null, 2))
// Check if Fiori preview routes were registered
if (app._router && app._router.stack) {
const fioriRoutes = app._router.stack.filter(layer =>
layer.route && layer.route.path && layer.route.path.includes('$fiori-preview')
)
console.log(t("cds.preview.routesFound", [fioriRoutes.length]))
if (fioriRoutes.length > 0) {
console.log(t("cds.preview.routePaths"), fioriRoutes.map(r => r.route.path))
} else {
console.warn(t("cds.preview.routesMissing"))
}
}
})
// @ts-ignore
cds.connect()
let entity = prompts.table
base.debug(baseLite.bundle.getText("debug.cds.entityBefore", [entity]))
entity = entity.replace(/\./g, "_")
entity = entity.replace(/::/g, "_")
let graphQLEntity = entity.replace(/_/g, ".")
base.debug(baseLite.bundle.getText("debug.cds.graphqlEntityAfter", [graphQLEntity]))
base.debug(baseLite.bundle.getText("debug.cds.sourceLabel"))
base.debug(cdsSource)
console.log(t("cds.entity.routes", [entity]))
console.log(t("cds.source.preview"))
console.log(cdsSource.substring(0, 500) + '...')
// @ts-ignore
let compiledModel
let odataURL = "/odata/v4/HanaCli"
try {
// Parse and compile the CDS source to get proper CSN model
const parsedModel = await cds.parse(cdsSource)
compiledModel = cds.compile(parsedModel)
// Setup Swagger/OpenAPI documentation
try {
console.log("✓ Setting up API documentation...")
// Generate basic OpenAPI spec manually since cds-dk OpenAPI compiler may not be available
const openAPISpec = {
openapi: '3.0.0',
info: {
title: 'HANA CLI CDS Service',
version: '1.0.0',
description: `CDS OData v4 service for ${entity}`
},
servers: [
{
url: `http://localhost:${port}`,
description: 'Development server'
}
],
paths: {
[odataURL]: {
get: {
summary: 'Service Document',
description: 'Returns the OData service document',
responses: {
'200': {
description: 'Service document',
content: {
'application/json': {
schema: {
type: 'object'
}
}
}
}
}
}
},
[`${odataURL}/${entity}`]: {
get: {
summary: `Get ${entity} entities`,
description: `Retrieve entities from ${entity}`,
responses: {
'200': {
description: 'List of entities',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
value: {
type: 'array',
items: {
type: 'object'
}
}
}
}
}
}
}
}
}
},
[`${odataURL}/$metadata`]: {
get: {
summary: 'Service Metadata',
description: 'Returns the OData service metadata (EDMX)',
responses: {
'200': {
description: 'Service metadata',
content: {
'application/xml': {
schema: {
type: 'string'
}
}
}
}
}
}
}
}
}
const swaggerUi = await import('swagger-ui-express')
console.log("✓ Swagger UI module loaded")
// Mount Swagger UI using correct pattern
app.use('/api-docs', swaggerUi.serve)
app.get('/api-docs', swaggerUi.setup(openAPISpec, {
explorer: true,
customSiteTitle: 'HANA CLI CDS API Documentation'
}))
console.log("✓ API documentation configured at /api-docs")
} catch (swaggerErr) {
base.debug(baseLite.bundle.getText("debug.cds.swaggerSkipped", [swaggerErr.message]))
console.warn(`Warning: API documentation setup failed: ${swaggerErr.message}`)
}
// Debug: show what's in the compiled model
console.log(t("cds.model.compiledDefinitions"))
Object.keys(compiledModel.definitions || {}).forEach(key => {
console.log(t("cds.listItemPrefix") + key)
})
// Ensure model is properly registered in CDS
if (!cds.model) {
cds.model = compiledModel
}
// Use CDS's built-in Fiori UI generation (powered by @sap/cds-fiori internally)
// No need to maintain local manifest/fiori HTML copies
let actualEntityName = entity // Will be updated from service
const services = await cds.serve('all').from(compiledModel, {
crashOnError: false
})
.at(odataURL)
.in(app)
.with(srv => {
// Log the actual entity names in the service for debugging
const entityNames = Object.keys(srv.entities || {})
base.debug(baseLite.bundle.getText("debug.cds.serviceEntities", [entityNames.join(', ')]))
console.log(t("cds.service.entities", [entityNames.join(', ')]))
// Use the actual entity name from the service (first entity)
// This ensures we match what CDS actually registered
actualEntityName = entityNames.length > 0 ? entityNames[0] : entity
console.log(t("cds.service.entityUsing", [actualEntityName]))
// @ts-ignore
srv.on(['READ'], [actualEntityName], async (req) => {
base.debug(baseLite.bundle.getText("debug.cds.readExit", [prompts.table]))
// Build a new query targeting the actual HANA table
let query1 = await cds.parse.cql(`SELECT from ${prompts.table}`)
// Copy important properties from the original query
// @ts-ignore
query1.SELECT.one = req.query.SELECT.one
// @ts-ignore
query1.SELECT.limit = req.query.SELECT.limit
// @ts-ignore
query1.SELECT.columns = req.query.SELECT.columns
// @ts-ignore
query1.SELECT.orderBy = req.query.SELECT.orderBy
// Handle WHERE clause - check if it's in SELECT.where or in from.ref[0].where
// @ts-ignore
if (req.query.SELECT.where) {
// @ts-ignore
query1.SELECT.where = req.query.SELECT.where
}
// @ts-ignore
else if (req.query.SELECT.from && req.query.SELECT.from.ref &&
// @ts-ignore
req.query.SELECT.from.ref[0] && req.query.SELECT.from.ref[0].where) {
// WHERE clause is nested in from.ref[0].where (common for single entity reads)
// @ts-ignore
query1.SELECT.where = req.query.SELECT.from.ref[0].where
}
// Apply xref transformations for columns
// @ts-ignore
if (query1.SELECT.columns) {
// @ts-ignore
for (let column of query1.SELECT.columns) {
for (let xref of global.__xRef) {
if (column.ref && column.ref[0] === xref.after) {
column.ref[0] = xref.after
}
}
}
}
// Apply xref transformations for WHERE clause
// @ts-ignore
if (query1.SELECT.where) {
// @ts-ignore
for (let where of query1.SELECT.where) {
if (where.ref) {
for (let xref of global.__xRef) {
if (where.ref[0] === xref.after) {
where.ref[0] = xref.after
}
}
}
}
}
// Apply xref transformations for ORDER BY
// @ts-ignore
if (query1.SELECT.orderBy) {
// @ts-ignore
for (let orderBy of query1.SELECT.orderBy) {
for (let xref of global.__xRef) {
if (orderBy.ref && orderBy.ref[0] === xref.after) {
orderBy.ref[0] = xref.after
}
}
}
}
base.debug(query1)
req.reply(await cds.tx(req).run(query1))
})
})
// Manually emit the 'served' event since it's not firing automatically
// The @sap/cds-fiori plugin listens for this event to register its routes
console.log(t("cds.served.emit"))
cds.emit('served', services)
// CDS OData - add homepage for preview
// Serve homepage with links to available endpoints
app.get('/', (_, res) => res.send(getIndex(odataURL, actualEntityName)))
// Add error handling middleware
app.use((err, req, res, next) => {
base.debug(baseLite.bundle.getText("debug.cds.expressErrorHandler", [err.message]))
console.error(t("cds.request.error", [err.message]))
if (!res.headersSent) {
res.status(500).json({ error: baseLite.bundle.getText("error.internalServerError"), message: err.message })
}
})
//Start the Server
server.on("request", app)
server.listen(port, async () => {
// @ts-ignore
let serverAddr = `http://localhost:${server.address().port}`
console.info(t("server.httpServer", [serverAddr]))
const { default: open } = await import('open')
await open(serverAddr, {wait: true})
})
}
catch (err) {
base.debug(baseLite.bundle.getText("debug.cds.setupError", [err.code, err.message]))
console.log(err)
if (err.code === 'MODULE_NOT_FOUND') {
throw baseLite.bundle.getText("cds-dk")
}
process.exit(1)
}
}
export function getIndex(odataURL, entity) {
// base.debug('getIndex') // Removed: base is not in scope here
return `
<html>
<head>
<meta name="color-scheme" content="dark light">
<style>
body {
font-family: Avenir Next, sans-serif;
margin: 44px;
line-height: 1.5em;
}
h1 {
margin-bottom: 0
}
h1 + .subtitle {
margin: .2em;
font-weight: 300;
}
h1, h2, h3 {
font-weight: 400;
}
h1, a {
text-decoration: none;
}
a.preview {
font-size: 90%;
}
footer {
border-top: .5px solid;
margin-top: 44px;
padding-top: 22px;
width: 400px;
font-size: 90%;
}
@media (prefers-color-scheme: dark) {
body {
background:#001119;
color: #789;
}
h1 + .subtitle {
color:#fb0;
}
h1, a {
color:#fb0;
}
h2, h3 {
color:#89a;
}
a.preview {
color: #678;
}
footer {
border-top: .5px solid #456;
color: #567;
}
}
</style>
</head>
<body>
<h1>${baseLite.bundle.getText("cdsIndex")}</h1>
<p class="subtitle"> ${baseLite.bundle.getText("cds.index.subtitle")}
<h2> ${baseLite.bundle.getText("cds.index.webApps")} </h2>
<h3><a href="/api-docs/">${baseLite.bundle.getText("cds.index.swaggerUi")}</a></h3>
<h3><a href="/$fiori-preview/HanaCli/${entity}">${baseLite.bundle.getText("cds.index.fioriPreview")}</a></h3>
<h2> ${baseLite.bundle.getText("cds.index.serviceEndpoints")} </h2>
<h3>
<a href="${odataURL}">${odataURL}</a> /
<a href="${odataURL}/$metadata">$metadata</a>
</h3>
<ul>
<li>
<a href="${odataURL}/${entity}">${entity}</a>
</li>
</ul>
</body>
</html>`
}