hana-cli
Version:
HANA Developer Command Line Interface
488 lines (436 loc) • 14.1 kB
JavaScript
// @ts-check
import * as baseLite from '../utils/base-lite.js'
import dbClientClass from "../utils/database/index.js"
import { buildDocEpilogue } from '../utils/doc-linker.js'
export const command = 'compareData'
export const aliases = ['cmpdata', 'compardata', 'dataCompare']
export const describe = baseLite.bundle.getText("compareData")
export const builder = (yargs) => yargs.options(baseLite.getBuilder({
sourceTable: {
alias: ['st'],
type: 'string',
desc: baseLite.bundle.getText("compareDataSourceTable")
},
sourceSchema: {
alias: ['ss'],
type: 'string',
default: '**CURRENT_SCHEMA**',
desc: baseLite.bundle.getText("compareDataSourceSchema")
},
targetTable: {
alias: ['tt'],
type: 'string',
desc: baseLite.bundle.getText("compareDataTargetTable")
},
targetSchema: {
alias: ['ts'],
type: 'string',
default: '**CURRENT_SCHEMA**',
desc: baseLite.bundle.getText("compareDataTargetSchema")
},
keyColumns: {
alias: ['k'],
type: 'string',
desc: baseLite.bundle.getText("compareDataKeyColumns")
},
output: {
alias: ['o'],
type: 'string',
desc: baseLite.bundle.getText("compareDataOutput")
},
columns: {
alias: ['c'],
type: 'string',
desc: baseLite.bundle.getText("compareDataColumns")
},
showMatches: {
alias: ['sm'],
type: 'boolean',
default: false,
desc: baseLite.bundle.getText("compareDataShowMatches")
},
limit: {
alias: ['l'],
type: 'number',
default: 1000,
desc: baseLite.bundle.getText("compareDataLimit")
},
timeout: {
alias: ['to'],
type: 'number',
default: 3600,
desc: baseLite.bundle.getText("compareDataTimeout")
},
profile: {
alias: ['p'],
type: 'string',
desc: baseLite.bundle.getText("profile")
}
})).wrap(160).example(
'hana-cli compareData --sourceTable table1 --targetTable table2',
baseLite.bundle.getText("compareDataExample")
).epilog(buildDocEpilogue('compareData', 'data-tools', ['compareSchema', 'dataDiff']))
export let inputPrompts = {
sourceTable: {
description: baseLite.bundle.getText("compareDataSourceTable"),
type: 'string',
required: true
},
sourceSchema: {
description: baseLite.bundle.getText("compareDataSourceSchema"),
type: 'string',
required: false
},
targetTable: {
description: baseLite.bundle.getText("compareDataTargetTable"),
type: 'string',
required: true
},
targetSchema: {
description: baseLite.bundle.getText("compareDataTargetSchema"),
type: 'string',
required: false
},
keyColumns: {
description: baseLite.bundle.getText("compareDataKeyColumns"),
type: 'string',
required: true
},
output: {
description: baseLite.bundle.getText("compareDataOutput"),
type: 'string',
required: false,
ask: () => false
},
columns: {
description: baseLite.bundle.getText("compareDataColumns"),
type: 'string',
required: false,
ask: () => false
},
showMatches: {
description: baseLite.bundle.getText("compareDataShowMatches"),
type: 'boolean',
required: false,
ask: () => false
},
limit: {
description: baseLite.bundle.getText("compareDataLimit"),
type: 'number',
required: false,
default: 1000,
ask: () => false
},
timeout: {
description: baseLite.bundle.getText("compareDataTimeout"),
type: 'number',
required: false,
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')
base.promptHandler(argv, compareDataMain, inputPrompts)
}
/**
* Compare data between two tables
* @param {object} prompts - User prompts with table and comparison options
* @returns {Promise<void>}
*/
export async function compareDataMain(prompts) {
const base = await import('../utils/base.js')
base.debug('compareDataMain')
try {
base.setPrompts(prompts)
// Set operation timeout
const timeoutHandle = prompts.timeout > 0
? setTimeout(() => process.exit(1), prompts.timeout * 1000)
: null
// Connect to database
const dbClient = await dbClientClass.getNewClient(prompts)
await dbClient.connect()
const dbKind = (dbClient.getKind() || 'hana').toLowerCase()
// Get schemas if not provided
let sourceSchema = prompts.sourceSchema
let targetSchema = prompts.targetSchema
if (sourceSchema === '**CURRENT_SCHEMA**') {
sourceSchema = null
}
if (targetSchema === '**CURRENT_SCHEMA**') {
targetSchema = null
}
if (!sourceSchema && dbKind !== 'sqlite') {
sourceSchema = await getCurrentSchema(dbClient, dbKind)
}
if (!targetSchema && dbKind !== 'sqlite') {
targetSchema = sourceSchema
}
const sourceTable = prompts.sourceTable
const targetTable = prompts.targetTable
const keyColumns = prompts.keyColumns.split(',').map(c => c.trim()).filter(c => c)
if (keyColumns.length === 0) {
throw new Error(baseLite.bundle.getText("errNoKeyColumns"))
}
console.log(baseLite.bundle.getText("info.startingDataComparison", [sourceTable, targetTable]))
// Get column lists
let sourceColumns = await getTableColumns(dbClient, sourceSchema, sourceTable, dbKind)
let targetColumns = await getTableColumns(dbClient, targetSchema, targetTable, dbKind)
// Filter columns if specified
if (prompts.columns) {
const selectedCols = prompts.columns.split(',').map(c => c.trim()).filter(c => c)
sourceColumns = sourceColumns.filter(c => selectedCols.includes(c))
targetColumns = targetColumns.filter(c => selectedCols.includes(c))
}
// Compare rows
const comparison = await compareTables(
dbClient,
sourceSchema,
sourceTable,
targetSchema,
targetTable,
keyColumns,
sourceColumns,
targetColumns,
prompts.limit,
dbKind
)
// Output results
if (prompts.output) {
await outputComparisonResults(prompts.output, comparison)
} else {
displayComparisonResults(comparison, prompts.showMatches)
}
console.log(baseLite.bundle.getText("success.comparisonComplete", [
comparison.sourceCount,
comparison.targetCount,
comparison.matches,
comparison.differences,
comparison.sourceOnly,
comparison.targetOnly
]))
await dbClient.disconnect()
if (timeoutHandle) clearTimeout(timeoutHandle)
} catch (error) {
console.error(baseLite.bundle.getText("error.compareData", [error.message]))
base.debug(error)
throw error
}
}
/**
* Get table columns
* @param {object} dbClient - Database client
* @param {string|null} schema - Schema name
* @param {string} table - Table name
* @param {string} dbKind - Database kind
* @returns {Promise<Array<string>>}
*/
async function getTableColumns(dbClient, schema, table, dbKind) {
let query
if (dbKind === 'hana') {
query = `SELECT COLUMN_NAME FROM SYS.TABLE_COLUMNS
WHERE SCHEMA_NAME = ? AND TABLE_NAME = ?
ORDER BY POSITION`
const result = await dbClient.execSQL(query, [schema || 'PUBLIC', table.toUpperCase()])
return result.map(r => r.COLUMN_NAME)
} else if (dbKind === 'postgres') {
query = `SELECT column_name FROM information_schema.columns
WHERE table_schema = ? AND table_name = ?
ORDER BY ordinal_position`
const result = await dbClient.execSQL(query, [schema || 'public', table.toLowerCase()])
return result.map(r => r.column_name)
}
return []
}
/**
* Compare two tables
* @param {object} dbClient - Database client
* @param {string|null} sourceSchema - Source schema
* @param {string} sourceTable - Source table
* @param {string|null} targetSchema - Target schema
* @param {string} targetTable - Target table
* @param {Array<string>} keyColumns - Key columns for comparison
* @param {Array<string>} sourceColumns - Columns to compare
* @param {Array<string>} targetColumns - Columns to compare
* @param {number} limit - Maximum rows to compare
* @param {string} dbKind - Database kind
* @returns {Promise<object>}
*/
async function compareTables(dbClient, sourceSchema, sourceTable, targetSchema, targetTable, keyColumns, sourceColumns, targetColumns, limit, dbKind) {
const comparison = {
sourceCount: 0,
targetCount: 0,
matches: 0,
differences: [],
sourceOnly: [],
targetOnly: []
}
try {
// Get all rows from source
const sourceQualified = formatQualifiedName(sourceSchema, sourceTable)
const targetQualified = formatQualifiedName(targetSchema, targetTable)
const keyList = keyColumns.map(k => `"${k}"`).join(',')
const colList = sourceColumns.map(c => `"${c}"`).join(',')
const sourceQuery = `SELECT ${keyList}, ${colList} FROM ${sourceQualified} LIMIT ${limit}`
const sourceRows = await dbClient.execSQL(sourceQuery)
comparison.sourceCount = sourceRows.length
// Build target map for faster lookup
const targetQuery = `SELECT ${keyList}, ${colList} FROM ${targetQualified} LIMIT ${limit}`
const targetRows = await dbClient.execSQL(targetQuery)
comparison.targetCount = targetRows.length
const targetMap = new Map()
for (const row of targetRows) {
const keyValue = keyColumns.map(k => row[k]).join('|')
targetMap.set(keyValue, row)
}
// Compare rows
for (const sourceRow of sourceRows) {
const keyValue = keyColumns.map(k => sourceRow[k]).join('|')
if (targetMap.has(keyValue)) {
const targetRow = targetMap.get(keyValue)
const diff = findDifferences(sourceRow, targetRow, sourceColumns)
if (diff.length > 0) {
comparison.differences.push({
key: keyValue,
differences: diff
})
} else {
comparison.matches++
}
targetMap.delete(keyValue)
} else {
comparison.sourceOnly.push({
key: keyValue,
row: sourceRow
})
}
}
// Remaining in target are targetOnly
for (const [keyValue, row] of targetMap) {
comparison.targetOnly.push({
key: keyValue,
row: row
})
}
} catch (error) {
baseLite.debug(`Error comparing tables: ${error.message}`)
throw error
}
return comparison
}
/**
* Find differences between two rows
* @param {object} sourceRow - Source row
* @param {object} targetRow - Target row
* @param {Array<string>} columns - Columns to check
* @returns {Array<object>}
*/
function findDifferences(sourceRow, targetRow, columns) {
const differences = []
for (const col of columns) {
const sourceValue = sourceRow[col]
const targetValue = targetRow[col]
if (JSON.stringify(sourceValue) !== JSON.stringify(targetValue)) {
differences.push({
column: col,
sourceValue,
targetValue
})
}
}
return differences
}
/**
* Display comparison results in console
* @param {object} comparison - Comparison results
* @param {boolean} showMatches - Whether to show matching rows
* @returns {void}
*/
function displayComparisonResults(comparison, showMatches = false) {
if (showMatches && comparison.matches > 0) {
console.log(`\n${baseLite.colors.cyan('Matching rows:')} ${comparison.matches}`)
}
if (comparison.differences.length > 0) {
console.log(`\n${baseLite.colors.yellow('Differences found:')} ${comparison.differences.length}`)
comparison.differences.slice(0, 10).forEach(diff => {
console.log(` Key: ${diff.key}`)
diff.differences.forEach(d => {
console.log(` ${d.column}: ${d.sourceValue} -> ${d.targetValue}`)
})
})
if (comparison.differences.length > 10) {
console.log(` ... and ${comparison.differences.length - 10} more`)
}
}
if (comparison.sourceOnly.length > 0) {
console.log(`\n${baseLite.colors.red('Source-only rows:')} ${comparison.sourceOnly.length}`)
}
if (comparison.targetOnly.length > 0) {
console.log(`\n${baseLite.colors.green('Target-only rows:')} ${comparison.targetOnly.length}`)
}
}
/**
* Output comparison results to file
* @param {string} filePath - Output file path
* @param {object} comparison - Comparison results
* @returns {Promise<void>}
*/
async function outputComparisonResults(filePath, comparison) {
const fs = await import('fs')
const report = {
summary: {
sourceCount: comparison.sourceCount,
targetCount: comparison.targetCount,
matches: comparison.matches,
differences: comparison.differences.length,
sourceOnly: comparison.sourceOnly.length,
targetOnly: comparison.targetOnly.length
},
differences: comparison.differences,
sourceOnly: comparison.sourceOnly,
targetOnly: comparison.targetOnly
}
await fs.promises.writeFile(filePath, JSON.stringify(report, null, 2), 'utf8')
}
/**
* 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}"`
}