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
JavaScript
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