UNPKG

hana-cli

Version:
471 lines (427 loc) 13.8 kB
// @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 = 'referentialCheck' export const aliases = ['refcheck', 'checkReferential', 'fkcheck'] export const describe = baseLite.bundle.getText("referentialCheck") export const builder = (yargs) => yargs.options(baseLite.getBuilder({ table: { alias: ['t'], type: 'string', desc: baseLite.bundle.getText("referentialCheckTable") }, schema: { alias: ['s'], type: 'string', default: '**CURRENT_SCHEMA**', desc: baseLite.bundle.getText("referentialCheckSchema") }, constraints: { alias: ['c'], type: 'string', desc: baseLite.bundle.getText("referentialCheckConstraints") }, mode: { alias: ['m'], choices: ["check", "report", "repair", "detailed"], default: "check", type: 'string', desc: baseLite.bundle.getText("referentialCheckMode") }, output: { alias: ['o'], type: 'string', desc: baseLite.bundle.getText("referentialCheckOutput") }, format: { alias: ['f'], choices: ["json", "csv", "summary"], default: "summary", type: 'string', desc: baseLite.bundle.getText("referentialCheckFormat") }, limit: { alias: ['l'], type: 'number', default: 10000, desc: baseLite.bundle.getText("referentialCheckLimit") }, timeout: { alias: ['to'], type: 'number', default: 3600, desc: baseLite.bundle.getText("referentialCheckTimeout") }, profile: { alias: ['p'], type: 'string', desc: baseLite.bundle.getText("profile") } })).wrap(160).example('hana-cli referentialCheck --table myTable --mode check', baseLite.bundle.getText("referentialCheckExample")).wrap(160).epilog(buildDocEpilogue('referentialCheck', 'analysis-tools', ['tables', 'compareData', 'dataValidator'])) export let inputPrompts = { table: { description: baseLite.bundle.getText("referentialCheckTable"), type: 'string', required: true }, schema: { description: baseLite.bundle.getText("referentialCheckSchema"), type: 'string', required: false }, constraints: { description: baseLite.bundle.getText("referentialCheckConstraints"), type: 'string', required: false, ask: () => false }, mode: { description: baseLite.bundle.getText("referentialCheckMode"), type: 'string', required: false, ask: () => false }, output: { description: baseLite.bundle.getText("referentialCheckOutput"), type: 'string', required: false, ask: () => false }, format: { description: baseLite.bundle.getText("referentialCheckFormat"), type: 'string', required: false, ask: () => false }, limit: { description: baseLite.bundle.getText("referentialCheckLimit"), type: 'number', required: false, default: 10000, ask: () => false }, timeout: { description: baseLite.bundle.getText("referentialCheckTimeout"), 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') base.promptHandler(argv, referentialCheckMain, inputPrompts) } /** * Verify referential integrity * @param {object} prompts - User prompts * @returns {Promise<void>} */ export async function referentialCheckMain(prompts) { const base = await import('../utils/base.js') base.debug('referentialCheckMain') 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 schema if not provided let schema = prompts.schema if (!schema && dbKind !== 'sqlite') { schema = await getCurrentSchema(dbClient, dbKind) } const table = prompts.table console.log(baseLite.bundle.getText("info.startingReferentialCheck", [table])) // Get foreign keys const foreignKeys = await getForeignKeys(dbClient, schema, table, dbKind) if (foreignKeys.length === 0) { console.log(baseLite.bundle.getText("info.noForeignKeys", [table])) await dbClient.disconnect() if (timeoutHandle) clearTimeout(timeoutHandle) return } // Check referential integrity const results = await checkReferentialIntegrity( dbClient, schema, table, foreignKeys, prompts.limit, dbKind, prompts.mode ) // Output results if (prompts.output) { await outputResults(prompts.output, results, prompts.format) } else { displayResults(results, prompts.format) } console.log(baseLite.bundle.getText("success.referentialCheckComplete", [ results.totalForeignKeys, results.validConstraints, results.violatedConstraints, results.totalOrphanedRecords ])) await dbClient.disconnect() if (timeoutHandle) clearTimeout(timeoutHandle) } catch (error) { console.error(baseLite.bundle.getText("error.referentialCheck", [error.message])) base.debug(error) throw error } } /** * Get current schema * @param {object} dbClient - Database client * @param {string} dbKind - Database kind * @returns {Promise<string>} */ async function getCurrentSchema(dbClient, dbKind) { if (dbKind === 'hana') { const result = await dbClient.execSQL('SELECT CURRENT_SCHEMA FROM DUMMY') return result[0]?.CURRENT_SCHEMA || 'PUBLIC' } else if (dbKind === 'postgres') { const result = await dbClient.execSQL('SELECT current_schema()') return result[0]?.current_schema || 'public' } return 'public' } /** * Get foreign keys for a table * @param {object} dbClient - Database client * @param {string|null} schema - Schema name * @param {string} table - Table name * @param {string} dbKind - Database kind * @returns {Promise<Array<object>>} */ async function getForeignKeys(dbClient, schema, table, dbKind) { const foreignKeys = [] if (dbKind === 'hana') { const query = ` SELECT CONSTRAINT_NAME, COLUMN_NAME, REFERENCED_SCHEMA_NAME, REFERENCED_TABLE_NAME, REFERENCED_COLUMN_NAME FROM SYS.REFERENTIAL_CONSTRAINTS WHERE SCHEMA_NAME = ? AND TABLE_NAME = ? ` try { const results = await dbClient.execSQL(query, [schema || 'PUBLIC', table.toUpperCase()]) return results.map(r => ({ constraintName: r.CONSTRAINT_NAME, column: r.COLUMN_NAME, refSchema: r.REFERENCED_SCHEMA_NAME, refTable: r.REFERENCED_TABLE_NAME, refColumn: r.REFERENCED_COLUMN_NAME })) } catch (err) { baseLite.debug(`Error getting foreign keys: ${err.message}`) return [] } } else if (dbKind === 'postgres') { const query = ` SELECT tc.constraint_name, kcu.column_name, ccu.table_schema AS referenced_schema, ccu.table_name AS referenced_table, ccu.column_name AS referenced_column FROM information_schema.table_constraints AS tc JOIN information_schema.key_column_usage AS kcu ON tc.constraint_name = kcu.constraint_name JOIN information_schema.constraint_column_usage AS ccu ON ccu.constraint_name = tc.constraint_name WHERE tc.constraint_type = 'FOREIGN KEY' AND tc.table_schema = ? AND tc.table_name = ? ` try { const results = await dbClient.execSQL(query, [schema || 'public', table.toLowerCase()]) return results.map(r => ({ constraintName: r.constraint_name, column: r.column_name, refSchema: r.referenced_schema, refTable: r.referenced_table, refColumn: r.referenced_column })) } catch (err) { baseLite.debug(`Error getting foreign keys: ${err.message}`) return [] } } return foreignKeys } /** * Check referential integrity * @param {object} dbClient - Database client * @param {string|null} schema - Schema name * @param {string} table - Table name * @param {Array<object>} foreignKeys - Foreign keys * @param {number} limit - Row limit * @param {string} dbKind - Database kind * @param {string} mode - Check mode * @returns {Promise<object>} */ async function checkReferentialIntegrity(dbClient, schema, table, foreignKeys, limit, dbKind, mode) { const results = { totalForeignKeys: foreignKeys.length, validConstraints: 0, violatedConstraints: 0, totalOrphanedRecords: 0, violations: [], details: [] } for (const fk of foreignKeys) { try { // Query to find orphaned records const colName = fk.column const refSchema = fk.refSchema const refTable = fk.refTable const refCol = fk.refColumn const tableName = formatQualifiedName(schema, table) const refTableName = formatQualifiedName(refSchema, refTable) let query = ` SELECT t."${colName}", COUNT(*) as count FROM ${tableName} t LEFT JOIN ${refTableName} r ON t."${colName}" = r."${refCol}" WHERE t."${colName}" IS NOT NULL AND r."${refCol}" IS NULL GROUP BY t."${colName}" ` if (limit > 0) { query += ` LIMIT ${limit}` } const orphaned = await dbClient.execSQL(query) if (orphaned && orphaned.length > 0) { results.violatedConstraints++ results.totalOrphanedRecords += orphaned.reduce((sum, row) => sum + (row.count || 1), 0) results.violations.push({ constraintName: fk.constraintName, column: fk.column, referencesTable: refTable, orphanedRecords: orphaned, status: 'VIOLATED' }) if (mode === 'repair') { // In repair mode, could delete or flag orphaned records baseLite.debug(`Repair mode: Would handle orphaned records for ${fk.constraintName}`) } } else { results.validConstraints++ results.details.push({ constraintName: fk.constraintName, column: fk.column, referencesTable: refTable, status: 'OK' }) } } catch (err) { baseLite.debug(`Error checking constraint ${fk.constraintName}: ${err.message}`) results.violations.push({ constraintName: fk.constraintName, error: err.message, status: 'ERROR' }) } } return results } /** * Format qualified table name * @param {string|null} schema - Schema name * @param {string} table - Table name * @returns {string} */ function formatQualifiedName(schema, table) { if (schema) { return `"${schema}"."${table}"` } return `"${table}"` } /** * Output results to file * @param {string} filePath - Output file path * @param {object} results - Check results * @param {string} format - Output format * @returns {Promise<void>} */ async function outputResults(filePath, results, format) { const fsModule = await import('fs') const fs = fsModule.promises let content if (format === 'json') { content = JSON.stringify(results, null, 2) } else if (format === 'csv') { content = 'Constraint,Column,RefTable,Status,OrphanedCount\n' for (const violation of results.violations) { const orphanCount = violation.orphanedRecords ? violation.orphanedRecords.reduce((sum, r) => sum + (r.count || 1), 0) : 0 content += `"${violation.constraintName || 'N/A'}","${violation.column || 'N/A'}","${violation.referencesTable || 'N/A'}","${violation.status}","${orphanCount}"\n` } } else { content = formatSummaryReport(results) } await fs.writeFile(filePath, content) } /** * Format summary report * @param {object} results - Check results * @returns {string} */ function formatSummaryReport(results) { let report = 'Referential Integrity Check Report\n' report += '===================================\n\n' report += `Total Foreign Keys: ${results.totalForeignKeys}\n` report += `Valid Constraints: ${results.validConstraints}\n` report += `Violated Constraints: ${results.violatedConstraints}\n` report += `Total Orphaned Records: ${results.totalOrphanedRecords}\n\n` if (results.violations.length > 0) { report += 'Violations:\n' for (const v of results.violations) { report += ` ${v.constraintName}: ${v.status}\n` if (v.error) { report += ` Error: ${v.error}\n` } else if (v.orphanedRecords) { report += ` Orphaned Records: ${v.orphanedRecords.length}\n` } } } return report } /** * Display results in console * @param {object} results - Check results * @param {string} format - Display format * @returns {void} */ function displayResults(results, format) { if (format === 'json') { console.log(JSON.stringify(results, null, 2)) } else if (format === 'csv') { console.log('Constraint,Column,RefTable,Status,OrphanedCount') for (const violation of results.violations) { const orphanCount = violation.orphanedRecords ? violation.orphanedRecords.reduce((sum, r) => sum + (r.count || 1), 0) : 0 console.log(`"${violation.constraintName || 'N/A'}","${violation.column || 'N/A'}","${violation.referencesTable || 'N/A'}","${violation.status}","${orphanCount}"`) } } else { console.log(formatSummaryReport(results)) } }