Skip to content

Commit 9b37b5d

Browse files
committed
refactor: move sql dialect conversion tool
1 parent f57b296 commit 9b37b5d

2 files changed

Lines changed: 152 additions & 128 deletions

File tree

src/mcp/server.ts

Lines changed: 2 additions & 128 deletions
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,6 @@ import {
9898
} from "./search-results.js";
9999
import { explainGeneratedQuery, runExplainCheck } from "./sql-policy-explain.js";
100100
import {
101-
compatibilityModeSchema,
102101
entityDiagramOutputFormatSchema,
103102
erDocumentFormatSchema,
104103
erExportStrategySchema,
@@ -145,6 +144,7 @@ import { registerQuerySessionTools } from "./tools/query-sessions.js";
145144
import { registerQueryTemplateTools } from "./tools/query-templates.js";
146145
import { registerRecentRowsTools } from "./tools/recent-rows.js";
147146
import { registerSearchCatalogTools } from "./tools/search-catalog.js";
147+
import { registerSqlConversionTools } from "./tools/sql-conversion.js";
148148
import { registerSqlUsageTools } from "./tools/sql-usages.js";
149149
import { registerTableContextTools } from "./tools/table-context.js";
150150
import {
@@ -222,6 +222,7 @@ export function createObMcpServer(
222222
registerQueryTemplateTools(registerTool, runtime, context);
223223
registerRecentRowsTools(registerTool, runtime, context);
224224
registerSearchCatalogTools(registerTool, runtime, context);
225+
registerSqlConversionTools(registerTool, runtime, context);
225226
registerSqlUsageTools(registerTool, runtime, context);
226227

227228
registerObjectMetadataTools(registerTool, runtime, context);
@@ -670,25 +671,6 @@ export function createObMcpServer(
670671
}
671672
);
672673

673-
registerTool(
674-
"ob_convert_sql_dialect",
675-
{
676-
title: "Convert SQL Dialect",
677-
description: "Convert read-only SQL between OceanBase MySQL and Oracle modes with documented limitations.",
678-
inputSchema: {
679-
...profileInput,
680-
sql: z.string().min(1),
681-
targetMode: compatibilityModeSchema,
682-
sourceMode: compatibilityModeSchema.optional()
683-
}
684-
},
685-
async ({ profile, sql, targetMode, sourceMode }) => {
686-
const selected = selectAuthorizedProfile(runtime, profile, context);
687-
const converted = convertSqlDialect(selected.config, sql, sourceMode ?? selected.config.compatibility, targetMode);
688-
return toLimitedResult(converted, selected.config);
689-
}
690-
);
691-
692674
registerTool(
693675
"ob_rewrite_sql",
694676
{
@@ -3409,114 +3391,6 @@ function fallbackErrorSearchTerm(errorMessage: string): string | undefined {
34093391
return /['"`]([A-Za-z_][\w$]*)['"`]/.exec(errorMessage)?.[1];
34103392
}
34113393

3412-
function convertSqlDialect(
3413-
config: ObConfig,
3414-
sql: string,
3415-
sourceMode: ObConfig["compatibility"],
3416-
targetMode: ObConfig["compatibility"]
3417-
) {
3418-
const checks = [
3419-
runExplainCheck("input_limits", () => assertSqlInputLimits(sql, [], config)),
3420-
runExplainCheck("read_only_sql", () => assertReadOnlySql(sql))
3421-
];
3422-
if (!checks.every((check) => check.ok)) {
3423-
return {
3424-
sql,
3425-
convertedSql: null,
3426-
sourceMode,
3427-
targetMode,
3428-
changed: false,
3429-
checks,
3430-
warnings: ["SQL was not converted because it did not pass read-only input checks."],
3431-
limitations: []
3432-
};
3433-
}
3434-
if (sourceMode === targetMode) {
3435-
return {
3436-
sql,
3437-
convertedSql: trimRewriteSql(sql),
3438-
sourceMode,
3439-
targetMode,
3440-
changed: false,
3441-
checks,
3442-
policy: explainSqlPolicy(sql, policyContext(config)),
3443-
warnings: ["Source and target modes are the same; SQL was normalized only."],
3444-
limitations: []
3445-
};
3446-
}
3447-
3448-
const warnings = [
3449-
"Review converted SQL before use; function behavior and date arithmetic may not be fully equivalent."
3450-
];
3451-
const limitations: string[] = [];
3452-
let convertedSql = trimRewriteSql(sql);
3453-
if (sourceMode === "mysql" && targetMode === "oracle") {
3454-
convertedSql = convertedSql.replace(/`([^`]+)`/g, (_match, name: string) => `"${name.replace(/"/g, "\"\"").toUpperCase()}"`);
3455-
convertedSql = convertedSql.replace(/\bIFNULL\s*\(/gi, "NVL(");
3456-
convertedSql = convertedSql.replace(/\bNOW\s*\(\s*\)/gi, "SYSTIMESTAMP");
3457-
convertedSql = convertedSql.replace(/\bCURDATE\s*\(\s*\)/gi, "TRUNC(SYSDATE)");
3458-
convertedSql = convertLimitToOracle(convertedSql);
3459-
if (/\bdate_format\s*\(/i.test(sql)) {
3460-
limitations.push("DATE_FORMAT was not converted; rewrite it manually with TO_CHAR.");
3461-
}
3462-
} else {
3463-
convertedSql = convertedSql.replace(/"([A-Za-z_][\w$]*)"/g, (_match, name: string) => `\`${name}\``);
3464-
convertedSql = convertedSql.replace(/\bNVL\s*\(/gi, "IFNULL(");
3465-
convertedSql = convertedSql.replace(/\bSYSTIMESTAMP\b/gi, "NOW()");
3466-
convertedSql = convertedSql.replace(/\bSYSDATE\b/gi, "NOW()");
3467-
convertedSql = convertFetchToMysql(convertedSql);
3468-
convertedSql = convertSimpleRownumToMysql(convertedSql, limitations);
3469-
if (/\bto_char\s*\(/i.test(sql)) {
3470-
limitations.push("TO_CHAR was not converted; rewrite it manually with DATE_FORMAT if needed.");
3471-
}
3472-
}
3473-
const outputChecks = [
3474-
runExplainCheck("converted_read_only_sql", () => assertReadOnlySql(convertedSql))
3475-
];
3476-
const policy = explainSqlPolicy(convertedSql, policyContext(config));
3477-
return {
3478-
sql,
3479-
convertedSql,
3480-
sourceMode,
3481-
targetMode,
3482-
changed: convertedSql !== trimRewriteSql(sql),
3483-
checks: [...checks, ...outputChecks],
3484-
policy,
3485-
allowed: outputChecks.every((check) => check.ok) && policy.allowed,
3486-
warnings,
3487-
limitations,
3488-
executed: false
3489-
};
3490-
}
3491-
3492-
function convertLimitToOracle(sql: string): string {
3493-
let output = sql.replace(/\s+limit\s+(\d+)\s+offset\s+(\d+)\s*$/i, (_match, limit: string, offset: string) =>
3494-
` OFFSET ${offset} ROWS FETCH NEXT ${limit} ROWS ONLY`
3495-
);
3496-
output = output.replace(/\s+limit\s+(\d+)\s*$/i, (_match, limit: string) =>
3497-
` FETCH FIRST ${limit} ROWS ONLY`
3498-
);
3499-
return output;
3500-
}
3501-
3502-
function convertFetchToMysql(sql: string): string {
3503-
let output = sql.replace(/\s+offset\s+(\d+)\s+rows\s+fetch\s+next\s+(\d+)\s+rows\s+only\s*$/i, (_match, offset: string, limit: string) =>
3504-
` LIMIT ${limit} OFFSET ${offset}`
3505-
);
3506-
output = output.replace(/\s+fetch\s+first\s+(\d+)\s+rows\s+only\s*$/i, (_match, limit: string) =>
3507-
` LIMIT ${limit}`
3508-
);
3509-
return output;
3510-
}
3511-
3512-
function convertSimpleRownumToMysql(sql: string, limitations: string[]): string {
3513-
const converted = sql.replace(/\s+where\s+rownum\s*<=\s*(\d+)\s*$/i, (_match, limit: string) => ` LIMIT ${limit}`);
3514-
if (converted === sql && /\brownum\b/i.test(sql)) {
3515-
limitations.push("ROWNUM predicates were not fully converted; use LIMIT/OFFSET manually.");
3516-
}
3517-
return converted;
3518-
}
3519-
35203394
async function rewriteReadOnlySql(
35213395
selected: ReturnType<typeof selectRuntimeProfile>,
35223396
sql: string,

src/mcp/tools/sql-conversion.ts

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
import { z } from "zod";
2+
3+
import type { ObConfig } from "../../config.js";
4+
import { explainSqlPolicy } from "../../policy.js";
5+
import { assertReadOnlySql, assertSqlInputLimits } from "../../sql.js";
6+
import { selectAuthorizedProfile } from "../access.js";
7+
import type { McpRequestContext } from "../context.js";
8+
import { policyContext } from "../policy-context.js";
9+
import { compatibilityModeSchema, profileInput } from "../schemas.js";
10+
import { runExplainCheck } from "../sql-policy-explain.js";
11+
import { toLimitedResult } from "../tool-runtime.js";
12+
import type { ToolRegistrar } from "../tool-registry.js";
13+
import type { GatewayRuntime } from "../../runtime.js";
14+
15+
export function registerSqlConversionTools(
16+
registerTool: ToolRegistrar,
17+
runtime: GatewayRuntime,
18+
context: McpRequestContext
19+
): void {
20+
registerTool(
21+
"ob_convert_sql_dialect",
22+
{
23+
title: "Convert SQL Dialect",
24+
description: "Convert read-only SQL between OceanBase MySQL and Oracle modes with documented limitations.",
25+
inputSchema: {
26+
...profileInput,
27+
sql: z.string().min(1),
28+
targetMode: compatibilityModeSchema,
29+
sourceMode: compatibilityModeSchema.optional()
30+
}
31+
},
32+
async ({ profile, sql, targetMode, sourceMode }) => {
33+
const selected = selectAuthorizedProfile(runtime, profile, context);
34+
const converted = convertSqlDialect(selected.config, sql, sourceMode ?? selected.config.compatibility, targetMode);
35+
return toLimitedResult(converted, selected.config);
36+
}
37+
);
38+
}
39+
40+
function convertSqlDialect(
41+
config: ObConfig,
42+
sql: string,
43+
sourceMode: ObConfig["compatibility"],
44+
targetMode: ObConfig["compatibility"]
45+
) {
46+
const checks = [
47+
runExplainCheck("input_limits", () => assertSqlInputLimits(sql, [], config)),
48+
runExplainCheck("read_only_sql", () => assertReadOnlySql(sql))
49+
];
50+
if (!checks.every((check) => check.ok)) {
51+
return {
52+
sql,
53+
convertedSql: null,
54+
sourceMode,
55+
targetMode,
56+
changed: false,
57+
checks,
58+
warnings: ["SQL was not converted because it did not pass read-only input checks."],
59+
limitations: []
60+
};
61+
}
62+
if (sourceMode === targetMode) {
63+
return {
64+
sql,
65+
convertedSql: trimConversionSql(sql),
66+
sourceMode,
67+
targetMode,
68+
changed: false,
69+
checks,
70+
policy: explainSqlPolicy(sql, policyContext(config)),
71+
warnings: ["Source and target modes are the same; SQL was normalized only."],
72+
limitations: []
73+
};
74+
}
75+
76+
const warnings = [
77+
"Review converted SQL before use; function behavior and date arithmetic may not be fully equivalent."
78+
];
79+
const limitations: string[] = [];
80+
let convertedSql = trimConversionSql(sql);
81+
if (sourceMode === "mysql" && targetMode === "oracle") {
82+
convertedSql = convertedSql.replace(/`([^`]+)`/g, (_match, name: string) => `"${name.replace(/"/g, "\"\"").toUpperCase()}"`);
83+
convertedSql = convertedSql.replace(/\bIFNULL\s*\(/gi, "NVL(");
84+
convertedSql = convertedSql.replace(/\bNOW\s*\(\s*\)/gi, "SYSTIMESTAMP");
85+
convertedSql = convertedSql.replace(/\bCURDATE\s*\(\s*\)/gi, "TRUNC(SYSDATE)");
86+
convertedSql = convertLimitToOracle(convertedSql);
87+
if (/\bdate_format\s*\(/i.test(sql)) {
88+
limitations.push("DATE_FORMAT was not converted; rewrite it manually with TO_CHAR.");
89+
}
90+
} else {
91+
convertedSql = convertedSql.replace(/"([A-Za-z_][\w$]*)"/g, (_match, name: string) => `\`${name}\``);
92+
convertedSql = convertedSql.replace(/\bNVL\s*\(/gi, "IFNULL(");
93+
convertedSql = convertedSql.replace(/\bSYSTIMESTAMP\b/gi, "NOW()");
94+
convertedSql = convertedSql.replace(/\bSYSDATE\b/gi, "NOW()");
95+
convertedSql = convertFetchToMysql(convertedSql);
96+
convertedSql = convertSimpleRownumToMysql(convertedSql, limitations);
97+
if (/\bto_char\s*\(/i.test(sql)) {
98+
limitations.push("TO_CHAR was not converted; rewrite it manually with DATE_FORMAT if needed.");
99+
}
100+
}
101+
const outputChecks = [
102+
runExplainCheck("converted_read_only_sql", () => assertReadOnlySql(convertedSql))
103+
];
104+
const policy = explainSqlPolicy(convertedSql, policyContext(config));
105+
return {
106+
sql,
107+
convertedSql,
108+
sourceMode,
109+
targetMode,
110+
changed: convertedSql !== trimConversionSql(sql),
111+
checks: [...checks, ...outputChecks],
112+
policy,
113+
allowed: outputChecks.every((check) => check.ok) && policy.allowed,
114+
warnings,
115+
limitations,
116+
executed: false
117+
};
118+
}
119+
120+
function convertLimitToOracle(sql: string): string {
121+
let output = sql.replace(/\s+limit\s+(\d+)\s+offset\s+(\d+)\s*$/i, (_match, limit: string, offset: string) =>
122+
` OFFSET ${offset} ROWS FETCH NEXT ${limit} ROWS ONLY`
123+
);
124+
output = output.replace(/\s+limit\s+(\d+)\s*$/i, (_match, limit: string) =>
125+
` FETCH FIRST ${limit} ROWS ONLY`
126+
);
127+
return output;
128+
}
129+
130+
function convertFetchToMysql(sql: string): string {
131+
let output = sql.replace(/\s+offset\s+(\d+)\s+rows\s+fetch\s+next\s+(\d+)\s+rows\s+only\s*$/i, (_match, offset: string, limit: string) =>
132+
` LIMIT ${limit} OFFSET ${offset}`
133+
);
134+
output = output.replace(/\s+fetch\s+first\s+(\d+)\s+rows\s+only\s*$/i, (_match, limit: string) =>
135+
` LIMIT ${limit}`
136+
);
137+
return output;
138+
}
139+
140+
function convertSimpleRownumToMysql(sql: string, limitations: string[]): string {
141+
const converted = sql.replace(/\s+where\s+rownum\s*<=\s*(\d+)\s*$/i, (_match, limit: string) => ` LIMIT ${limit}`);
142+
if (converted === sql && /\brownum\b/i.test(sql)) {
143+
limitations.push("ROWNUM predicates were not fully converted; use LIMIT/OFFSET manually.");
144+
}
145+
return converted;
146+
}
147+
148+
function trimConversionSql(sql: string): string {
149+
return sql.trim().replace(/;+$/, "").trimEnd();
150+
}

0 commit comments

Comments
 (0)