hana-cli
Version:
HANA Developer Command Line Interface
532 lines (477 loc) • 14.6 kB
JavaScript
// @ts-check
import * as baseLite from '../utils/base-lite.js'
import dbClientClass from "../utils/database/index.js"
import ExcelJS from 'exceljs'
import { buildDocEpilogue } from '../utils/doc-linker.js'
export const command = 'export'
export const aliases = ['exp', 'downloadData', 'downloaddata']
export const describe = baseLite.bundle.getText("export")
export const builder = (yargs) => yargs.options(baseLite.getBuilder({
table: {
alias: ['t'],
type: 'string',
desc: baseLite.bundle.getText("exportTable")
},
schema: {
alias: ['s'],
type: 'string',
default: '**CURRENT_SCHEMA**',
desc: baseLite.bundle.getText("exportSchema")
},
output: {
alias: ['o'],
type: 'string',
desc: baseLite.bundle.getText("exportOutput")
},
format: {
alias: ['f'],
choices: ["csv", "excel", "json"],
default: "csv",
type: 'string',
desc: baseLite.bundle.getText("exportFormat")
},
where: {
alias: ['w'],
type: 'string',
desc: baseLite.bundle.getText("exportWhere")
},
limit: {
alias: ['l'],
type: 'number',
desc: baseLite.bundle.getText("exportLimit")
},
orderby: {
alias: ['ob'],
type: 'string',
desc: baseLite.bundle.getText("exportOrderBy")
},
columns: {
alias: ['c'],
type: 'string',
desc: baseLite.bundle.getText("exportColumns")
},
delimiter: {
alias: ['d'],
type: 'string',
default: ',',
desc: baseLite.bundle.getText("exportDelimiter")
},
includeHeaders: {
alias: ['ih'],
type: 'boolean',
default: true,
desc: baseLite.bundle.getText("exportIncludeHeaders")
},
nullValue: {
alias: ['nv'],
type: 'string',
default: '',
desc: baseLite.bundle.getText("exportNullValue")
},
maxRows: {
alias: ['mr'],
type: 'number',
default: 1000000,
desc: baseLite.bundle.getText("exportMaxRows")
},
timeout: {
alias: ['to'],
type: 'number',
default: 3600,
desc: baseLite.bundle.getText("exportTimeout")
},
profile: {
alias: ['p'],
type: 'string',
desc: baseLite.bundle.getText("profile")
}
})).wrap(160).example(
'hana-cli export --table myTable --format csv',
baseLite.bundle.getText("exportExample")
).epilog(buildDocEpilogue('export', 'data-tools', ['import', 'massExport']))
export let inputPrompts = {
table: {
description: baseLite.bundle.getText("exportTable"),
type: 'string',
required: true
},
schema: {
description: baseLite.bundle.getText("exportSchema"),
type: 'string',
required: false
},
output: {
description: baseLite.bundle.getText("exportOutput"),
type: 'string',
required: true
},
format: {
description: baseLite.bundle.getText("exportFormat"),
type: 'string',
required: true
},
where: {
description: baseLite.bundle.getText("exportWhere"),
type: 'string',
required: false,
ask: () => false
},
limit: {
description: baseLite.bundle.getText("exportLimit"),
type: 'number',
required: false,
ask: () => false
},
orderby: {
description: baseLite.bundle.getText("exportOrderBy"),
type: 'string',
required: false,
ask: () => false
},
columns: {
description: baseLite.bundle.getText("exportColumns"),
type: 'string',
required: false,
ask: () => false
},
delimiter: {
description: baseLite.bundle.getText("exportDelimiter"),
type: 'string',
required: false,
ask: () => false
},
includeHeaders: {
description: baseLite.bundle.getText("exportIncludeHeaders"),
type: 'boolean',
required: false,
ask: () => false
},
nullValue: {
description: baseLite.bundle.getText("exportNullValue"),
type: 'string',
required: false,
ask: () => false
},
maxRows: {
description: baseLite.bundle.getText("exportMaxRows"),
type: 'number',
required: false,
default: 1000000,
ask: () => false
},
timeout: {
description: baseLite.bundle.getText("exportTimeout"),
type: 'number',
required: false,
default: 3600,
ask: () => false
},
profile: {
description: baseLite.bundle.getText("profile"),
type: 'string',
required: false,
ask: () => { }
}
}
/**
* Command handler function
* @param {object} argv - Command line arguments from yargs
* @returns {Promise<void>}
*/
export async function handler(argv) {
const base = await import('../utils/base.js')
await base.promptHandler(argv, exportData, inputPrompts, true, false)
}
/**
* Export data from table to file
* @param {object} prompts - User prompts with table, output, and format options
* @returns {Promise<void>}
*/
export async function exportData(prompts) {
const base = await import('../utils/base.js')
base.debug('exportData')
let dbClient = null
let timeoutHandle = null
try {
base.setPrompts(prompts)
// Set operation timeout
const abortController = new AbortController()
timeoutHandle = prompts.timeout > 0
? setTimeout(() => abortController.abort(), prompts.timeout * 1000)
: null
// Connect to database
dbClient = await dbClientClass.getNewClient(prompts)
await dbClient.connect()
const dbKind = (dbClient.getKind() || 'hana').toLowerCase()
// Get schema if not provided
let schema = prompts.schema
// Handle the **CURRENT_SCHEMA** placeholder
if (!schema || schema === '**CURRENT_SCHEMA**') {
if (dbKind !== 'sqlite') {
schema = await getCurrentSchema(dbClient, dbKind)
if (!schema && dbKind === 'hana') {
throw new Error('No schema specified')
}
}
}
const table = prompts.table
if (!table) {
throw new Error(`Table not found: ${table}`)
}
// Parse column list if provided
let columnList = '*'
if (prompts.columns && prompts.columns.trim()) {
columnList = prompts.columns
}
// Build SELECT query
let query = `SELECT ${columnList} FROM ${formatQualifiedName(schema, table)}`
// Add WHERE clause if provided
if (prompts.where && prompts.where.trim()) {
query += ` WHERE ${prompts.where}`
}
// Add ORDER BY clause if provided
if (prompts.orderby && prompts.orderby.trim()) {
query += ` ORDER BY ${prompts.orderby}`
}
// Add LIMIT clause if provided or max rows
const limit = prompts.limit || prompts.maxRows || 1000000
if (limit > 0) {
query += ` LIMIT ${limit}`
}
base.debug(`Export query: ${query}`)
console.log(`Starting export for table: ${table}`)
// Execute query
const startTime = Date.now()
const rows = await dbClient.execSQL(query)
if (!rows || rows.length === 0) {
console.log('No data to export')
await dbClient.disconnect()
if (timeoutHandle) clearTimeout(timeoutHandle)
return
}
// Determine output filename if not provided
let outputFile = prompts.output
if (!outputFile) {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5)
const ext = prompts.format === 'excel' ? 'xlsx' : prompts.format === 'json' ? 'json' : 'csv'
outputFile = `${table}_${timestamp}.${ext}`
}
base.debug(`Output file: ${outputFile}`)
// Check if output file already exists and prompt for confirmation
const fs = await import('fs')
if (fs.existsSync(outputFile)) {
console.log(`File ${outputFile} already exists.`)
const confirmOverwrite = await promptForConfirmation('Do you want to overwrite it? (yes/no): ')
if (!confirmOverwrite) {
console.log('Export cancelled.')
await dbClient.disconnect()
if (timeoutHandle) clearTimeout(timeoutHandle)
return
}
}
// Export based on format
if (prompts.format === 'json') {
await exportJSON(outputFile, rows, prompts.nullValue)
} else if (prompts.format === 'excel') {
await exportExcel(outputFile, table, rows, prompts.nullValue)
} else {
await exportCSV(outputFile, rows, prompts.delimiter, prompts.includeHeaders, prompts.nullValue)
}
const elapsed = Date.now() - startTime
const elapsedSeconds = (elapsed / 1000).toFixed(2)
console.log(`Export complete: ${rows.length} rows exported to ${outputFile} (${elapsedSeconds}s)`)
await dbClient.disconnect()
if (timeoutHandle) clearTimeout(timeoutHandle)
} catch (error) {
const errorMsg = `Export error: ${error.message}`
console.error(errorMsg)
base.debug(error)
if (timeoutHandle) clearTimeout(timeoutHandle)
if (dbClient) {
try {
await dbClient.disconnect()
} catch (e) {
// Ignore disconnect errors
}
}
process.exit(1)
}
}
/**
* Export rows to CSV file
* @param {string} filePath - Output file path
* @param {Array<object>} rows - Data rows to export
* @param {string} delimiter - CSV delimiter
* @param {boolean} includeHeaders - Whether to include header row
* @param {string} nullValue - Value to use for NULL cells
* @returns {Promise<void>}
*/
async function exportCSV(filePath, rows, delimiter = ',', includeHeaders = true, nullValue = '') {
const fs = await import('fs')
const base = await import('../utils/base.js')
if (!rows || rows.length === 0) {
base.debug('No rows to export')
return
}
try {
const headers = Object.keys(rows[0])
let csvContent = ''
// Add headers
if (includeHeaders) {
csvContent += headers.map(h => escapeCSVField(String(h))).join(delimiter) + '\n'
}
// Add rows
for (const row of rows) {
const values = headers.map(header => {
const value = row[header]
const stringValue = value === null || value === undefined ? nullValue : String(value)
return escapeCSVField(stringValue)
})
csvContent += values.join(delimiter) + '\n'
}
await fs.promises.writeFile(filePath, csvContent, 'utf8')
base.debug(`CSV file written: ${filePath}`)
} catch (error) {
throw new Error(`File write error for ${filePath}: ${error.message}`)
}
}
/**
* Escape CSV field value
* @param {string} field - Field value
* @returns {string}
*/
function escapeCSVField(field) {
if (!field) return ''
if (field.includes(',') || field.includes('"') || field.includes('\n')) {
return `"${field.replace(/"/g, '""')}"` // Escape quotes by doubling them
}
return field
}
/**
* Export rows to Excel file
* @param {string} filePath - Output file path
* @param {string} sheetName - Worksheet name
* @param {Array<object>} rows - Data rows to export
* @param {string} nullValue - Value to use for NULL cells
* @returns {Promise<void>}
*/
async function exportExcel(filePath, sheetName, rows, nullValue = '') {
const base = await import('../utils/base.js')
if (!rows || rows.length === 0) {
base.debug('No rows to export')
return
}
try {
const workbook = new ExcelJS.Workbook()
const worksheet = workbook.addWorksheet(sheetName.substring(0, 31)) // Excel sheet name limit
const headers = Object.keys(rows[0])
// Add headers
const headerRow = worksheet.addRow(headers)
headerRow.font = { bold: true }
headerRow.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFD3D3D3' } }
// Add rows
for (const row of rows) {
const values = headers.map(header => {
const value = row[header]
return value === null || value === undefined ? nullValue : value
})
worksheet.addRow(values)
}
// Auto-fit columns
headers.forEach((header, index) => {
const column = worksheet.getColumn(index + 1)
let maxLength = header.length
for (const row of worksheet.getSheetValues().slice(1)) {
if (row[index + 1]) {
maxLength = Math.max(maxLength, String(row[index + 1]).length)
}
}
column.width = Math.min(maxLength + 2, 50)
})
await workbook.xlsx.writeFile(filePath)
base.debug(`Excel file written: ${filePath}`)
} catch (error) {
throw new Error(`File write error for ${filePath}: ${error.message}`)
}
}
/**
* Export rows to JSON file
* @param {string} filePath - Output file path
* @param {Array<object>} rows - Data rows to export
* @param {string} nullValue - Value to use for NULL cells (not used for JSON)
* @returns {Promise<void>}
*/
async function exportJSON(filePath, rows, nullValue = '') {
const fs = await import('fs')
const base = await import('../utils/base.js')
if (!rows || rows.length === 0) {
base.debug('No rows to export')
return
}
try {
// Convert null values
const processedRows = rows.map(row => {
const processed = {}
for (const [key, value] of Object.entries(row)) {
processed[key] = value === null || value === undefined ? null : value
}
return processed
})
const jsonContent = JSON.stringify(processedRows, null, 2)
await fs.promises.writeFile(filePath, jsonContent, 'utf8')
base.debug(`JSON file written: ${filePath}`)
} catch (error) {
throw new Error(`File write error for ${filePath}: ${error.message}`)
}
}
/**
* Prompt user for confirmation (yes/no)
* @param {string} message - Prompt message
* @returns {Promise<boolean>}
*/
async function promptForConfirmation(message) {
const readline = await import('readline')
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
})
return new Promise((resolve) => {
rl.question(message, (answer) => {
rl.close()
const normalized = (answer || '').toLowerCase().trim()
resolve(normalized === 'yes' || normalized === 'y')
})
})
}
/**
* Get current schema
* @param {object} dbClient - Database client
* @param {string} dbKind - Database kind
* @returns {Promise<string|null>}
*/
async function getCurrentSchema(dbClient, dbKind) {
try {
if (dbKind === 'hana') {
const result = await dbClient.execSQL("SELECT CURRENT_SCHEMA FROM DUMMY")
return result?.[0]?.CURRENT_SCHEMA || null
} else if (dbKind === 'postgres') {
const result = await dbClient.execSQL("SELECT current_schema()")
return result?.[0]?.current_schema || null
}
} catch (error) {
baseLite.debug(`Error getting current schema: ${error.message}`)
}
return null
}
/**
* Format qualified table name (schema.table)
* @param {string|null} schema - Schema name
* @param {string} table - Table name
* @returns {string}
*/
function formatQualifiedName(schema, table) {
if (schema) {
return `"${schema}"."${table}"`
}
return `"${table}"`
}