@elyracode/db-tools 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,62 @@
1
+ # @elyracode/db-tools
2
+
3
+ Database tools for Elyra -- query MySQL and ClickHouse with schema awareness and safety guardrails.
4
+
5
+ ## Install
6
+
7
+ ```
8
+ elyra install npm:@elyracode/db-tools
9
+ ```
10
+
11
+ ## Configuration
12
+
13
+ Set environment variables for your database connections:
14
+
15
+ ### MySQL
16
+
17
+ ```
18
+ export ELYRA_MYSQL_HOST=localhost
19
+ export ELYRA_MYSQL_PORT=3306
20
+ export ELYRA_MYSQL_USER=readonly_user
21
+ export ELYRA_MYSQL_PASSWORD=secret
22
+ export ELYRA_MYSQL_DATABASE=myapp
23
+ ```
24
+
25
+ ### ClickHouse
26
+ ```
27
+ export ELYRA_CLICKHOUSE_HOST=localhost
28
+ export ELYRA_CLICKHOUSE_PORT=8123
29
+ export ELYRA_CLICKHOUSE_DATABASE=mydb
30
+ export ELYRA_CLICKHOUSE_USERNAME=default
31
+ export ELYRA_CLICKHOUSE_PASSWORD=secret
32
+ export ELYRA_CLICKHOUSE_HTTPS=false
33
+ export ELYRA_CLICKHOUSE_TIMEOUT_CONNECT=10000
34
+ export ELYRA_CLICKHOUSE_TIMEOUT_QUERY=30000
35
+ ```
36
+
37
+ ## Tools
38
+
39
+ | Tool | Description |
40
+ |------|-------------|
41
+ | `query_mysql` | Execute SQL against MySQL. Results returned as JSON. |
42
+ | `query_clickhouse` | Execute SQL against ClickHouse. Optimized for analytical queries on large datasets. |
43
+ | `get_database_schema` | Discover tables, columns, types, and indexes. The agent calls this before writing queries. |
44
+
45
+ ## Security
46
+
47
+ - **Read-only by default**: Only SELECT, SHOW, DESCRIBE, and EXPLAIN queries are allowed.
48
+ - **Write access**: Set `ELYRA_DB_ALLOW_WRITES=true` to enable INSERT, UPDATE, DELETE.
49
+ - **Recommendation**: Use a database user with minimal privileges (SELECT only).
50
+
51
+ ## Usage
52
+
53
+ Once installed, just ask Elyra about your data:
54
+
55
+ ```
56
+ > How many users registered this week?
57
+ > Show me the top 10 products by revenue
58
+ > What's the average response time from the logs table?
59
+ > Describe the schema of the orders table
60
+ ```
61
+
62
+ The agent will automatically use `get_database_schema` to understand your database structure, then write and execute the appropriate SQL.
@@ -0,0 +1,412 @@
1
+ import type { ExtensionAPI } from "@elyracode/coding-agent";
2
+ import { createClient } from "@clickhouse/client";
3
+ import mysql2 from "mysql2/promise";
4
+ import { Type } from "typebox";
5
+
6
+ export default function (elyra: ExtensionAPI) {
7
+ // ── Configuration ──
8
+ // Connection details come from environment variables.
9
+ // MySQL: ELYRA_MYSQL_HOST, ELYRA_MYSQL_PORT, ELYRA_MYSQL_USER, ELYRA_MYSQL_PASSWORD, ELYRA_MYSQL_DATABASE
10
+ // ClickHouse: ELYRA_CLICKHOUSE_HOST, ELYRA_CLICKHOUSE_PORT, ELYRA_CLICKHOUSE_DATABASE,
11
+ // ELYRA_CLICKHOUSE_USERNAME, ELYRA_CLICKHOUSE_PASSWORD, ELYRA_CLICKHOUSE_HTTPS,
12
+ // ELYRA_CLICKHOUSE_TIMEOUT_CONNECT, ELYRA_CLICKHOUSE_TIMEOUT_QUERY
13
+
14
+ // ── Tool: query_mysql ──
15
+ elyra.registerTool({
16
+ name: "query_mysql",
17
+ label: "Query MySQL",
18
+ description:
19
+ "Execute a SQL query against the configured MySQL database. Returns results as JSON. " +
20
+ "Use get_database_schema first to understand the database structure. " +
21
+ "By default, only SELECT queries are allowed. The user must approve each query before execution.",
22
+ parameters: Type.Object({
23
+ sql: Type.String({ description: "The SQL query to execute" }),
24
+ database: Type.Optional(
25
+ Type.String({ description: "Override the default database (optional)" }),
26
+ ),
27
+ }),
28
+ execute: async (toolCallId, params, ctx) => {
29
+ const config = getMysqlConfig(params.database);
30
+ if (!config) {
31
+ return {
32
+ content: [
33
+ {
34
+ type: "text",
35
+ text: "MySQL not configured. Set environment variables: ELYRA_MYSQL_HOST, ELYRA_MYSQL_USER, ELYRA_MYSQL_PASSWORD, ELYRA_MYSQL_DATABASE",
36
+ },
37
+ ],
38
+ details: {},
39
+ };
40
+ }
41
+
42
+ // Security: block non-SELECT queries unless ELYRA_DB_ALLOW_WRITES=true
43
+ if (!isReadOnly(params.sql) && !isWriteEnabled()) {
44
+ return {
45
+ content: [
46
+ {
47
+ type: "text",
48
+ text: `Blocked: Only SELECT queries are allowed in read-only mode. Set ELYRA_DB_ALLOW_WRITES=true to enable writes.\n\nQuery: ${params.sql}`,
49
+ },
50
+ ],
51
+ details: {},
52
+ };
53
+ }
54
+
55
+ try {
56
+ const connection = await mysql2.createConnection({
57
+ host: config.host,
58
+ port: config.port,
59
+ user: config.user,
60
+ password: config.password,
61
+ database: config.database,
62
+ connectTimeout: 10000,
63
+ });
64
+
65
+ try {
66
+ const [rows] = await connection.execute(params.sql);
67
+ const resultText = JSON.stringify(rows, null, 2);
68
+ const truncated =
69
+ resultText.length > 50000
70
+ ? `${resultText.slice(0, 50000)}\n\n... (truncated, ${resultText.length} chars total)`
71
+ : resultText;
72
+
73
+ return {
74
+ content: [{ type: "text", text: truncated }],
75
+ details: { rowCount: Array.isArray(rows) ? rows.length : 0 },
76
+ };
77
+ } finally {
78
+ await connection.end();
79
+ }
80
+ } catch (error) {
81
+ const msg = error instanceof Error ? error.message : String(error);
82
+ return {
83
+ content: [{ type: "text", text: `MySQL error: ${msg}` }],
84
+ details: {},
85
+ };
86
+ }
87
+ },
88
+ });
89
+
90
+ // ── Tool: query_clickhouse ──
91
+ elyra.registerTool({
92
+ name: "query_clickhouse",
93
+ label: "Query ClickHouse",
94
+ description:
95
+ "Execute a SQL query against the configured ClickHouse database. Returns results as JSON. " +
96
+ "ClickHouse is optimized for analytical queries on large datasets. " +
97
+ "Use get_database_schema first to understand the database structure. The user must approve each query before execution.",
98
+ parameters: Type.Object({
99
+ sql: Type.String({ description: "The SQL query to execute" }),
100
+ database: Type.Optional(
101
+ Type.String({ description: "Override the default database (optional)" }),
102
+ ),
103
+ }),
104
+ execute: async (toolCallId, params, ctx) => {
105
+ const config = getClickHouseConfig(params.database);
106
+ if (!config) {
107
+ return {
108
+ content: [
109
+ {
110
+ type: "text",
111
+ text: "ClickHouse not configured. Set environment variables: ELYRA_CLICKHOUSE_HOST, ELYRA_CLICKHOUSE_DATABASE",
112
+ },
113
+ ],
114
+ details: {},
115
+ };
116
+ }
117
+
118
+ // Security: block non-SELECT queries unless writes enabled
119
+ if (!isReadOnly(params.sql) && !isWriteEnabled()) {
120
+ return {
121
+ content: [
122
+ {
123
+ type: "text",
124
+ text: `Blocked: Only SELECT queries are allowed in read-only mode. Set ELYRA_DB_ALLOW_WRITES=true to enable writes.\n\nQuery: ${params.sql}`,
125
+ },
126
+ ],
127
+ details: {},
128
+ };
129
+ }
130
+
131
+ try {
132
+ const protocol = config.https ? "https" : "http";
133
+ const client = createClient({
134
+ url: `${protocol}://${config.host}:${config.port}`,
135
+ username: config.user,
136
+ password: config.password,
137
+ database: config.database,
138
+ connect_timeout: config.timeoutConnect,
139
+ request_timeout: config.timeoutQuery,
140
+ });
141
+
142
+ try {
143
+ const result = await client.query({
144
+ query: params.sql,
145
+ format: "JSONEachRow",
146
+ });
147
+ const rows = await result.json();
148
+ const resultText = JSON.stringify(rows, null, 2);
149
+ const truncated =
150
+ resultText.length > 50000
151
+ ? `${resultText.slice(0, 50000)}\n\n... (truncated, ${resultText.length} chars total)`
152
+ : resultText;
153
+
154
+ return {
155
+ content: [{ type: "text", text: truncated }],
156
+ details: { rowCount: Array.isArray(rows) ? rows.length : 0 },
157
+ };
158
+ } finally {
159
+ await client.close();
160
+ }
161
+ } catch (error) {
162
+ const msg = error instanceof Error ? error.message : String(error);
163
+ return {
164
+ content: [{ type: "text", text: `ClickHouse error: ${msg}` }],
165
+ details: {},
166
+ };
167
+ }
168
+ },
169
+ });
170
+
171
+ // ── Tool: get_database_schema ──
172
+ elyra.registerTool({
173
+ name: "get_database_schema",
174
+ label: "Get Database Schema",
175
+ description:
176
+ "Discover the database schema (tables, columns, types, indexes). " +
177
+ "Call this before writing queries to understand the database structure. " +
178
+ "Supports both MySQL and ClickHouse.",
179
+ parameters: Type.Object({
180
+ engine: Type.Union([Type.Literal("mysql"), Type.Literal("clickhouse")], {
181
+ description: "Which database engine to query",
182
+ }),
183
+ database: Type.Optional(
184
+ Type.String({ description: "Override the default database (optional)" }),
185
+ ),
186
+ table: Type.Optional(
187
+ Type.String({
188
+ description:
189
+ "Get detailed schema for a specific table (optional, omit for overview of all tables)",
190
+ }),
191
+ ),
192
+ }),
193
+ execute: async (toolCallId, params, ctx) => {
194
+ if (params.engine === "mysql") {
195
+ return getMysqlSchema(params.database, params.table);
196
+ }
197
+ return getClickHouseSchema(params.database, params.table);
198
+ },
199
+ });
200
+ }
201
+
202
+ // ── Config Helpers ──
203
+
204
+ interface MysqlConfig {
205
+ host: string;
206
+ port: number;
207
+ user: string;
208
+ password: string;
209
+ database: string;
210
+ }
211
+
212
+ interface ClickHouseConfig {
213
+ host: string;
214
+ port: number;
215
+ https: boolean;
216
+ user: string;
217
+ password: string;
218
+ database: string;
219
+ timeoutConnect: number;
220
+ timeoutQuery: number;
221
+ }
222
+
223
+ function getMysqlConfig(databaseOverride?: string): MysqlConfig | undefined {
224
+ const host = process.env.ELYRA_MYSQL_HOST;
225
+ const user = process.env.ELYRA_MYSQL_USER;
226
+ const password = process.env.ELYRA_MYSQL_PASSWORD;
227
+ const database = databaseOverride || process.env.ELYRA_MYSQL_DATABASE;
228
+ if (!host || !user || !database) return undefined;
229
+ return {
230
+ host,
231
+ port: Number.parseInt(process.env.ELYRA_MYSQL_PORT || "3306", 10),
232
+ user,
233
+ password: password || "",
234
+ database,
235
+ };
236
+ }
237
+
238
+ function getClickHouseConfig(databaseOverride?: string): ClickHouseConfig | undefined {
239
+ const host = process.env.ELYRA_CLICKHOUSE_HOST;
240
+ const database = databaseOverride || process.env.ELYRA_CLICKHOUSE_DATABASE;
241
+ if (!host || !database) return undefined;
242
+ const port = Number.parseInt(process.env.ELYRA_CLICKHOUSE_PORT || "8123", 10);
243
+ const https = process.env.ELYRA_CLICKHOUSE_HTTPS === "true";
244
+ const timeoutConnect = Number.parseInt(process.env.ELYRA_CLICKHOUSE_TIMEOUT_CONNECT || "10000", 10);
245
+ const timeoutQuery = Number.parseInt(process.env.ELYRA_CLICKHOUSE_TIMEOUT_QUERY || "30000", 10);
246
+ return {
247
+ host,
248
+ port,
249
+ https,
250
+ user: process.env.ELYRA_CLICKHOUSE_USERNAME || "default",
251
+ password: process.env.ELYRA_CLICKHOUSE_PASSWORD || "",
252
+ database,
253
+ timeoutConnect,
254
+ timeoutQuery,
255
+ };
256
+ }
257
+
258
+ function isWriteEnabled(): boolean {
259
+ const v = process.env.ELYRA_DB_ALLOW_WRITES;
260
+ return v === "true" || v === "1" || v === "yes";
261
+ }
262
+
263
+ function isReadOnly(sql: string): boolean {
264
+ const trimmed = sql.trim().toUpperCase();
265
+ // Allow SELECT, SHOW, DESCRIBE, EXPLAIN, WITH (CTEs start with WITH...SELECT)
266
+ return /^(SELECT|SHOW|DESCRIBE|DESC|EXPLAIN|WITH)\b/.test(trimmed);
267
+ }
268
+
269
+ // ── Schema Discovery ──
270
+
271
+ async function getMysqlSchema(
272
+ databaseOverride?: string,
273
+ table?: string,
274
+ ): Promise<{ content: Array<{ type: "text"; text: string }>; details: Record<string, unknown> }> {
275
+ const config = getMysqlConfig(databaseOverride);
276
+ if (!config) {
277
+ return {
278
+ content: [{ type: "text", text: "MySQL not configured." }],
279
+ details: {},
280
+ };
281
+ }
282
+
283
+ try {
284
+ const connection = await mysql2.createConnection({
285
+ host: config.host,
286
+ port: config.port,
287
+ user: config.user,
288
+ password: config.password,
289
+ database: config.database,
290
+ connectTimeout: 10000,
291
+ });
292
+
293
+ try {
294
+ if (table) {
295
+ // Detailed schema for a specific table
296
+ const [columns] = await connection.execute(
297
+ `SELECT COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE, COLUMN_KEY, COLUMN_DEFAULT, EXTRA
298
+ FROM INFORMATION_SCHEMA.COLUMNS
299
+ WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?
300
+ ORDER BY ORDINAL_POSITION`,
301
+ [config.database, table],
302
+ );
303
+ const [indexes] = await connection.execute(`SHOW INDEX FROM \`${table}\``);
304
+ return {
305
+ content: [
306
+ {
307
+ type: "text",
308
+ text: `Table: ${table}\n\nColumns:\n${JSON.stringify(columns, null, 2)}\n\nIndexes:\n${JSON.stringify(indexes, null, 2)}`,
309
+ },
310
+ ],
311
+ details: {},
312
+ };
313
+ }
314
+
315
+ // Overview: all tables with row counts
316
+ const [tables] = await connection.execute(
317
+ `SELECT TABLE_NAME, TABLE_ROWS, DATA_LENGTH, TABLE_COMMENT
318
+ FROM INFORMATION_SCHEMA.TABLES
319
+ WHERE TABLE_SCHEMA = ?
320
+ ORDER BY TABLE_NAME`,
321
+ [config.database],
322
+ );
323
+ return {
324
+ content: [
325
+ {
326
+ type: "text",
327
+ text: `Database: ${config.database}\n\nTables:\n${JSON.stringify(tables, null, 2)}`,
328
+ },
329
+ ],
330
+ details: { tableCount: Array.isArray(tables) ? tables.length : 0 },
331
+ };
332
+ } finally {
333
+ await connection.end();
334
+ }
335
+ } catch (error) {
336
+ const msg = error instanceof Error ? error.message : String(error);
337
+ return {
338
+ content: [{ type: "text", text: `MySQL schema error: ${msg}` }],
339
+ details: {},
340
+ };
341
+ }
342
+ }
343
+
344
+ async function getClickHouseSchema(
345
+ databaseOverride?: string,
346
+ table?: string,
347
+ ): Promise<{ content: Array<{ type: "text"; text: string }>; details: Record<string, unknown> }> {
348
+ const config = getClickHouseConfig(databaseOverride);
349
+ if (!config) {
350
+ return {
351
+ content: [{ type: "text", text: "ClickHouse not configured." }],
352
+ details: {},
353
+ };
354
+ }
355
+
356
+ try {
357
+ const protocol = config.https ? "https" : "http";
358
+ const client = createClient({
359
+ url: `${protocol}://${config.host}:${config.port}`,
360
+ username: config.user,
361
+ password: config.password,
362
+ database: config.database,
363
+ connect_timeout: config.timeoutConnect,
364
+ request_timeout: config.timeoutQuery,
365
+ });
366
+
367
+ try {
368
+ if (table) {
369
+ const result = await client.query({
370
+ query: `DESCRIBE TABLE ${table}`,
371
+ format: "JSONEachRow",
372
+ });
373
+ const columns = await result.json();
374
+ return {
375
+ content: [
376
+ {
377
+ type: "text",
378
+ text: `Table: ${table}\n\nColumns:\n${JSON.stringify(columns, null, 2)}`,
379
+ },
380
+ ],
381
+ details: {},
382
+ };
383
+ }
384
+
385
+ const result = await client.query({
386
+ query: `SELECT name, engine, total_rows, total_bytes
387
+ FROM system.tables
388
+ WHERE database = '${config.database}'
389
+ ORDER BY name`,
390
+ format: "JSONEachRow",
391
+ });
392
+ const tables = await result.json();
393
+ return {
394
+ content: [
395
+ {
396
+ type: "text",
397
+ text: `Database: ${config.database}\n\nTables:\n${JSON.stringify(tables, null, 2)}`,
398
+ },
399
+ ],
400
+ details: { tableCount: Array.isArray(tables) ? tables.length : 0 },
401
+ };
402
+ } finally {
403
+ await client.close();
404
+ }
405
+ } catch (error) {
406
+ const msg = error instanceof Error ? error.message : String(error);
407
+ return {
408
+ content: [{ type: "text", text: `ClickHouse schema error: ${msg}` }],
409
+ details: {},
410
+ };
411
+ }
412
+ }
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "@elyracode/db-tools",
3
+ "version": "0.3.1",
4
+ "description": "Database tools for Elyra - query MySQL and ClickHouse with schema awareness and safety guardrails",
5
+ "type": "module",
6
+ "keywords": ["elyra-package", "database", "mysql", "clickhouse", "sql"],
7
+ "license": "MIT",
8
+ "author": "Knut W. Horne",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/kwhorne/elyra.git",
12
+ "directory": "packages/db-tools"
13
+ },
14
+ "elyra": {
15
+ "extensions": ["./extensions/index.ts"]
16
+ },
17
+ "dependencies": {
18
+ "mysql2": "^3.14.0",
19
+ "@clickhouse/client": "^1.8.0"
20
+ },
21
+ "peerDependencies": {
22
+ "@elyracode/coding-agent": "*",
23
+ "typebox": "*"
24
+ },
25
+ "scripts": {
26
+ "clean": "echo 'nothing to clean'",
27
+ "build": "echo 'nothing to build'",
28
+ "check": "echo 'nothing to check'"
29
+ }
30
+ }