UNPKG

sql-talk

Version:

SQL Talk - 自然言語をSQLに変換するMCPサーバー(安全性保護・SSHトンネル対応) / SQL Talk - MCP Server for Natural Language to SQL conversion with safety guards and SSH tunnel support

238 lines 9.34 kB
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs'; import { dirname, resolve } from 'path'; import { SchemaCache } from '../types/index.js'; import { connectionManager } from './connection.js'; import { logger, PerformanceLogger } from '../core/logger.js'; import { SchemaError } from '../core/errors.js'; import { configManager } from '../core/config.js'; export class SchemaCacheManager { static instance; cache = null; cachePath; constructor() { this.cachePath = resolve(process.cwd(), '.cache/schema_cache.json'); } static getInstance() { if (!SchemaCacheManager.instance) { SchemaCacheManager.instance = new SchemaCacheManager(); } return SchemaCacheManager.instance; } async initialize() { const config = configManager.getConfig(); this.cachePath = resolve(process.cwd(), config.schema_cache.path); // Ensure cache directory exists const cacheDir = dirname(this.cachePath); if (!existsSync(cacheDir)) { mkdirSync(cacheDir, { recursive: true }); } // Load existing cache or create new one if (existsSync(this.cachePath)) { try { await this.loadFromFile(); logger.info('Schema cache loaded from file'); } catch (error) { logger.warn('Failed to load schema cache, creating new:', error); await this.refresh(); } } else { await this.refresh(); } // Refresh on start if configured if (config.schema_cache.refresh_on_start) { logger.info('Refreshing schema cache on startup'); await this.refresh(); } } async refresh(scope = 'all', target) { return PerformanceLogger.measure('schema_refresh', async () => { const engine = connectionManager.getEngine(); const connection = connectionManager.getReadOnlyConnection(); logger.info(`Refreshing schema cache: scope=${scope}, target=${target}`); let newTables = []; if (scope === 'all' || scope === 'tables') { newTables = await this.fetchTables(engine, connection); } else { // Keep existing tables if only refreshing columns newTables = this.cache?.tables || []; } if (scope === 'all' || scope === 'columns') { // Update columns for all tables or specific target const tablesToUpdate = target ? newTables.filter(t => `${t.schema}.${t.table}` === target) : newTables; for (const table of tablesToUpdate) { table.columns = await this.fetchColumns(engine, connection, table.schema, table.table); } } // Calculate differences const oldTables = this.cache?.tables || []; const oldTableNames = new Set(oldTables.map(t => `${t.schema}.${t.table}`)); const newTableNames = new Set(newTables.map(t => `${t.schema}.${t.table}`)); const addedTables = newTables .filter(t => !oldTableNames.has(`${t.schema}.${t.table}`)) .map(t => `${t.schema}.${t.table}`); const removedTables = oldTables .filter(t => !newTableNames.has(`${t.schema}.${t.table}`)) .map(t => `${t.schema}.${t.table}`); let updatedColumns = 0; for (const table of newTables) { const oldTable = oldTables.find(t => t.schema === table.schema && t.table === table.table); if (oldTable) { updatedColumns += table.columns.length - oldTable.columns.length; } } // Update cache this.cache = { engine, db: 'app', // TODO: get from connection config last_updated: new Date().toISOString(), tables: newTables }; await this.saveToFile(); logger.info(`Schema refresh completed: ${addedTables.length} added, ${removedTables.length} removed, ${updatedColumns} columns updated`); return { added_tables: addedTables, removed_tables: removedTables, updated_columns: Math.max(0, updatedColumns) }; }); } async fetchTables(engine, connection) { let tables = []; if (engine === 'postgres') { const sql = ` SELECT t.table_schema, t.table_name, obj_description(c.oid) as table_comment FROM information_schema.tables t LEFT JOIN pg_class c ON c.relname = t.table_name LEFT JOIN pg_namespace n ON n.oid = c.relnamespace AND n.nspname = t.table_schema WHERE t.table_type = 'BASE TABLE' AND t.table_schema NOT IN ('information_schema', 'pg_catalog') ORDER BY t.table_schema, t.table_name `; const rows = await connection.query(sql); tables = rows.map((row) => ({ schema: row.table_schema, table: row.table_name, table_comment: row.table_comment, columns: [] // Will be populated separately })); } else if (engine === 'mysql') { const sql = ` SELECT TABLE_SCHEMA, TABLE_NAME, TABLE_COMMENT FROM information_schema.TABLES WHERE TABLE_TYPE = 'BASE TABLE' AND TABLE_SCHEMA NOT IN ('information_schema', 'mysql', 'performance_schema', 'sys') ORDER BY TABLE_SCHEMA, TABLE_NAME `; const rows = await connection.query(sql); tables = rows.map((row) => ({ schema: row.TABLE_SCHEMA, table: row.TABLE_NAME, table_comment: row.TABLE_COMMENT || null, columns: [] // Will be populated separately })); } return tables; } async fetchColumns(engine, connection, schema, table) { let columns = []; if (engine === 'postgres') { const sql = ` SELECT c.column_name, c.data_type, c.is_nullable, c.column_default, col_description(pgc.oid, c.ordinal_position) as column_comment FROM information_schema.columns c LEFT JOIN pg_class pgc ON pgc.relname = c.table_name LEFT JOIN pg_namespace pgn ON pgn.oid = pgc.relnamespace AND pgn.nspname = c.table_schema WHERE c.table_schema = $1 AND c.table_name = $2 ORDER BY c.ordinal_position `; const rows = await connection.query(sql, [schema, table]); columns = rows.map((row) => ({ name: row.column_name, type: row.data_type, nullable: row.is_nullable === 'YES', default: row.column_default, comment: row.column_comment })); } else if (engine === 'mysql') { const sql = ` SELECT COLUMN_NAME, DATA_TYPE, IS_NULLABLE, COLUMN_DEFAULT, COLUMN_COMMENT FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? ORDER BY ORDINAL_POSITION `; const rows = await connection.query(sql, [schema, table]); columns = rows.map((row) => ({ name: row.COLUMN_NAME, type: row.DATA_TYPE, nullable: row.IS_NULLABLE === 'YES', default: row.COLUMN_DEFAULT, comment: row.COLUMN_COMMENT || null })); } return columns; } getCache() { if (!this.cache) { throw new SchemaError('Schema cache not initialized'); } return this.cache; } filterTables(filter) { const cache = this.getCache(); let tables = cache.tables; if (filter?.schema) { tables = tables.filter(t => t.schema === filter.schema); } if (filter?.table_like) { const pattern = new RegExp(filter.table_like.replace(/\*/g, '.*'), 'i'); tables = tables.filter(t => pattern.test(t.table)); } if (filter?.missing_comment_only) { tables = tables.filter(t => !t.table_comment || t.columns.some(c => !c.comment)); } return tables; } async loadFromFile() { try { const content = readFileSync(this.cachePath, 'utf-8'); this.cache = SchemaCache.parse(JSON.parse(content)); } catch (error) { throw new SchemaError(`Failed to load schema cache: ${error}`); } } async saveToFile() { try { const content = JSON.stringify(this.cache, null, 2); writeFileSync(this.cachePath, content, 'utf-8'); } catch (error) { throw new SchemaError(`Failed to save schema cache: ${error}`); } } } export const schemaCacheManager = SchemaCacheManager.getInstance(); //# sourceMappingURL=schema-cache.js.map