simplemdg-dev-cli 2.0.4 → 2.4.4
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 +63 -354
- package/USER_GUIDE.md +55 -378
- package/dist/commands/cds.command.js +69 -60
- package/dist/commands/cds.command.js.map +1 -1
- package/dist/commands/cf-db.command.d.ts +2 -0
- package/dist/commands/cf-db.command.js +606 -0
- package/dist/commands/cf-db.command.js.map +1 -0
- package/dist/commands/cf.command.js +291 -280
- package/dist/commands/cf.command.js.map +1 -1
- package/dist/commands/gitlab.command.d.ts +2 -0
- package/dist/commands/gitlab.command.js +351 -0
- package/dist/commands/gitlab.command.js.map +1 -0
- package/dist/commands/npmrc.command.js +50 -44
- package/dist/commands/npmrc.command.js.map +1 -1
- package/dist/core/cache.d.ts +1 -1
- package/dist/core/cache.js +58 -31
- package/dist/core/cache.js.map +1 -1
- package/dist/core/cds.js +32 -22
- package/dist/core/cds.js.map +1 -1
- package/dist/core/cf-env-parser.d.ts +1 -1
- package/dist/core/cf-env-parser.js +4 -1
- package/dist/core/cf-env-parser.js.map +1 -1
- package/dist/core/cf.d.ts +1 -1
- package/dist/core/cf.js +46 -31
- package/dist/core/cf.js.map +1 -1
- package/dist/core/db/db-btp.d.ts +48 -0
- package/dist/core/db/db-btp.js +162 -0
- package/dist/core/db/db-btp.js.map +1 -0
- package/dist/core/db/db-cache.d.ts +35 -0
- package/dist/core/db/db-cache.js +164 -0
- package/dist/core/db/db-cache.js.map +1 -0
- package/dist/core/db/db-connection.d.ts +22 -0
- package/dist/core/db/db-connection.js +73 -0
- package/dist/core/db/db-connection.js.map +1 -0
- package/dist/core/db/db-crypto.d.ts +3 -0
- package/dist/core/db/db-crypto.js +54 -0
- package/dist/core/db/db-crypto.js.map +1 -0
- package/dist/core/db/db-hana-adapter.d.ts +32 -0
- package/dist/core/db/db-hana-adapter.js +243 -0
- package/dist/core/db/db-hana-adapter.js.map +1 -0
- package/dist/core/db/db-metadata.d.ts +25 -0
- package/dist/core/db/db-metadata.js +150 -0
- package/dist/core/db/db-metadata.js.map +1 -0
- package/dist/core/db/db-postgres-adapter.d.ts +30 -0
- package/dist/core/db/db-postgres-adapter.js +245 -0
- package/dist/core/db/db-postgres-adapter.js.map +1 -0
- package/dist/core/db/db-query-files.d.ts +20 -0
- package/dist/core/db/db-query-files.js +106 -0
- package/dist/core/db/db-query-files.js.map +1 -0
- package/dist/core/db/db-query-history.d.ts +5 -0
- package/dist/core/db/db-query-history.js +49 -0
- package/dist/core/db/db-query-history.js.map +1 -0
- package/dist/core/db/db-row.d.ts +22 -0
- package/dist/core/db/db-row.js +70 -0
- package/dist/core/db/db-row.js.map +1 -0
- package/dist/core/db/db-studio-html.d.ts +4 -0
- package/dist/core/db/db-studio-html.js +437 -0
- package/dist/core/db/db-studio-html.js.map +1 -0
- package/dist/core/db/db-studio-server.d.ts +11 -0
- package/dist/core/db/db-studio-server.js +465 -0
- package/dist/core/db/db-studio-server.js.map +1 -0
- package/dist/core/db/db-types.d.ts +174 -0
- package/dist/core/db/db-types.js +3 -0
- package/dist/core/db/db-types.js.map +1 -0
- package/dist/core/db/db-vcap-parser.d.ts +7 -0
- package/dist/core/db/db-vcap-parser.js +137 -0
- package/dist/core/db/db-vcap-parser.js.map +1 -0
- package/dist/core/doctor.d.ts +1 -1
- package/dist/core/doctor.js +14 -8
- package/dist/core/doctor.js.map +1 -1
- package/dist/core/guide.js +31 -26
- package/dist/core/guide.js.map +1 -1
- package/dist/core/install.d.ts +1 -1
- package/dist/core/install.js +17 -11
- package/dist/core/install.js.map +1 -1
- package/dist/core/navigator.d.ts +17 -0
- package/dist/core/navigator.js +140 -0
- package/dist/core/navigator.js.map +1 -0
- package/dist/core/npmrc.js +29 -16
- package/dist/core/npmrc.js.map +1 -1
- package/dist/core/process.js +11 -6
- package/dist/core/process.js.map +1 -1
- package/dist/core/prompts.js +16 -8
- package/dist/core/prompts.js.map +1 -1
- package/dist/core/repository.d.ts +1 -1
- package/dist/core/repository.js +16 -9
- package/dist/core/repository.js.map +1 -1
- package/dist/core/scanner.d.ts +1 -1
- package/dist/core/scanner.js +13 -7
- package/dist/core/scanner.js.map +1 -1
- package/dist/core/tooling.d.ts +28 -0
- package/dist/core/tooling.js +168 -0
- package/dist/core/tooling.js.map +1 -0
- package/dist/core/types.js +2 -1
- package/dist/core/version-conflict.d.ts +2 -2
- package/dist/core/version-conflict.js +11 -6
- package/dist/core/version-conflict.js.map +1 -1
- package/dist/index.js +65 -48
- package/dist/index.js.map +1 -1
- package/dist/types-local.js +2 -1
- package/package.json +12 -6
- package/src/commands/cds.command.ts +529 -0
- package/src/commands/cf-db.command.ts +636 -0
- package/src/commands/cf.command.ts +3345 -0
- package/src/commands/gitlab.command.ts +373 -0
- package/src/commands/npmrc.command.ts +581 -0
- package/src/core/cache.ts +332 -0
- package/src/core/cds.ts +278 -0
- package/src/core/cf-env-parser.ts +131 -0
- package/src/core/cf.ts +271 -0
- package/src/core/db/db-btp.ts +207 -0
- package/src/core/db/db-cache.ts +215 -0
- package/src/core/db/db-connection.ts +79 -0
- package/src/core/db/db-crypto.ts +53 -0
- package/src/core/db/db-hana-adapter.ts +294 -0
- package/src/core/db/db-metadata.ts +174 -0
- package/src/core/db/db-postgres-adapter.ts +275 -0
- package/src/core/db/db-query-files.ts +130 -0
- package/src/core/db/db-query-history.ts +53 -0
- package/src/core/db/db-row.ts +93 -0
- package/src/core/db/db-studio-html.ts +439 -0
- package/src/core/db/db-studio-server.ts +559 -0
- package/src/core/db/db-types.ts +195 -0
- package/src/core/db/db-vcap-parser.ts +182 -0
- package/src/core/doctor.ts +70 -0
- package/src/core/guide.ts +261 -0
- package/src/core/install.ts +91 -0
- package/src/core/navigator.ts +164 -0
- package/src/core/npmrc.ts +171 -0
- package/src/core/process.ts +75 -0
- package/src/core/prompts.ts +225 -0
- package/src/core/repository.ts +36 -0
- package/src/core/scanner.ts +41 -0
- package/src/core/tooling.ts +207 -0
- package/src/core/types.ts +152 -0
- package/src/core/version-conflict.ts +46 -0
- package/src/index.ts +460 -0
- package/src/types/external.d.ts +3 -0
- package/src/types-local.ts +11 -0
- package/tsconfig.json +17 -0
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import type { TDatabaseColumn, TDatabaseType, TSqlSafetyAnalysis } from "./db-types";
|
|
2
|
+
|
|
3
|
+
// Keywords blocked when the studio runs in read-only mode.
|
|
4
|
+
const READ_ONLY_BLOCKED_KEYWORDS = [
|
|
5
|
+
"INSERT",
|
|
6
|
+
"UPDATE",
|
|
7
|
+
"DELETE",
|
|
8
|
+
"DROP",
|
|
9
|
+
"TRUNCATE",
|
|
10
|
+
"ALTER",
|
|
11
|
+
"CREATE",
|
|
12
|
+
"REPLACE",
|
|
13
|
+
"GRANT",
|
|
14
|
+
"REVOKE",
|
|
15
|
+
"MERGE",
|
|
16
|
+
"UPSERT",
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
// Statements that always warrant an explicit confirmation before running.
|
|
20
|
+
const DESTRUCTIVE_KEYWORDS = ["DROP", "TRUNCATE", "ALTER", "GRANT", "REVOKE"];
|
|
21
|
+
|
|
22
|
+
export function quoteIdentifier(_type: TDatabaseType, identifier: string): string {
|
|
23
|
+
// Both HANA and PostgreSQL use double quotes for delimited identifiers and
|
|
24
|
+
// escape an embedded double quote by doubling it.
|
|
25
|
+
return `"${identifier.replace(/"/g, '""')}"`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function buildQualifiedName(type: TDatabaseType, schema: string, name: string): string {
|
|
29
|
+
if (!schema) {
|
|
30
|
+
return quoteIdentifier(type, name);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return `${quoteIdentifier(type, schema)}.${quoteIdentifier(type, name)}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function generateSelectSql(type: TDatabaseType, schema: string, table: string, limit = 100): string {
|
|
37
|
+
return `SELECT * FROM ${buildQualifiedName(type, schema, table)} LIMIT ${limit}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function generateCountSql(type: TDatabaseType, schema: string, table: string): string {
|
|
41
|
+
return `SELECT COUNT(*) AS ROW_COUNT FROM ${buildQualifiedName(type, schema, table)}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function formatColumnType(column: TDatabaseColumn): string {
|
|
45
|
+
const dataType = column.dataType.toUpperCase();
|
|
46
|
+
|
|
47
|
+
if (column.length && /CHAR|VARCHAR|NVARCHAR|VARBINARY|BINARY/.test(dataType)) {
|
|
48
|
+
return `${column.dataType}(${column.length})`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (column.scale != null && /DECIMAL|NUMERIC/.test(dataType)) {
|
|
52
|
+
return `${column.dataType}(${column.length ?? 38},${column.scale})`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return column.dataType;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Best-effort CREATE TABLE statement reconstructed from column metadata. Useful
|
|
60
|
+
* as a starting point for editing in the SQL console.
|
|
61
|
+
*/
|
|
62
|
+
export function generateCreateTableDdl(
|
|
63
|
+
type: TDatabaseType,
|
|
64
|
+
schema: string,
|
|
65
|
+
table: string,
|
|
66
|
+
columns: TDatabaseColumn[],
|
|
67
|
+
): string {
|
|
68
|
+
const columnLines = columns.map((column) => {
|
|
69
|
+
const nullable = column.nullable ? "" : " NOT NULL";
|
|
70
|
+
const defaultValue = column.defaultValue ? ` DEFAULT ${column.defaultValue}` : "";
|
|
71
|
+
return ` ${quoteIdentifier(type, column.name)} ${formatColumnType(column)}${nullable}${defaultValue}`;
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const primaryKeyColumns = columns.filter((column) => column.isPrimaryKey).map((column) => quoteIdentifier(type, column.name));
|
|
75
|
+
|
|
76
|
+
if (primaryKeyColumns.length > 0) {
|
|
77
|
+
columnLines.push(` PRIMARY KEY (${primaryKeyColumns.join(", ")})`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return `CREATE TABLE ${buildQualifiedName(type, schema, table)} (\n${columnLines.join(",\n")}\n);`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function stripSqlComments(sql: string): string {
|
|
84
|
+
return sql
|
|
85
|
+
.replace(/--[^\n]*/g, " ")
|
|
86
|
+
.replace(/\/\*[\s\S]*?\*\//g, " ");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function getLeadingStatementKeyword(sql: string): string {
|
|
90
|
+
const cleaned = stripSqlComments(sql).trim();
|
|
91
|
+
const match = cleaned.match(/^([a-z]+)/i);
|
|
92
|
+
return match ? match[1].toUpperCase() : "";
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function isSingleSelectStatement(sql: string): boolean {
|
|
96
|
+
const cleaned = stripSqlComments(sql).trim().replace(/;\s*$/, "");
|
|
97
|
+
if (cleaned.includes(";")) {
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const keyword = getLeadingStatementKeyword(cleaned);
|
|
102
|
+
return keyword === "SELECT" || keyword === "WITH";
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Append a row limit to a single SELECT statement when one is not already
|
|
107
|
+
* present, so accidental full-table scans stay bounded.
|
|
108
|
+
*/
|
|
109
|
+
export function appendSafeLimit(_type: TDatabaseType, sql: string, limit: number): string {
|
|
110
|
+
if (limit <= 0) {
|
|
111
|
+
return sql;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (!isSingleSelectStatement(sql)) {
|
|
115
|
+
return sql;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const cleaned = stripSqlComments(sql);
|
|
119
|
+
|
|
120
|
+
if (/\blimit\s+\d+/i.test(cleaned) || /\btop\s+\d+/i.test(cleaned) || /\bfetch\s+first\b/i.test(cleaned)) {
|
|
121
|
+
return sql;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const trimmed = sql.replace(/;\s*$/, "").trimEnd();
|
|
125
|
+
return `${trimmed} LIMIT ${limit}`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function analyzeSqlSafety(sql: string, options: { readOnly: boolean }): TSqlSafetyAnalysis {
|
|
129
|
+
const cleaned = stripSqlComments(sql).trim();
|
|
130
|
+
const upper = cleaned.toUpperCase();
|
|
131
|
+
const matchedKeywords: string[] = [];
|
|
132
|
+
|
|
133
|
+
for (const keyword of READ_ONLY_BLOCKED_KEYWORDS) {
|
|
134
|
+
if (new RegExp(`\\b${keyword}\\b`).test(upper)) {
|
|
135
|
+
matchedKeywords.push(keyword);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const hasDestructiveKeyword = DESTRUCTIVE_KEYWORDS.some((keyword) => new RegExp(`\\b${keyword}\\b`).test(upper));
|
|
140
|
+
const leadingKeyword = getLeadingStatementKeyword(cleaned);
|
|
141
|
+
|
|
142
|
+
const isDeleteWithoutWhere = leadingKeyword === "DELETE" && !/\bWHERE\b/.test(upper);
|
|
143
|
+
const isUpdateWithoutWhere = leadingKeyword === "UPDATE" && !/\bWHERE\b/.test(upper);
|
|
144
|
+
|
|
145
|
+
const isDestructive = hasDestructiveKeyword || isDeleteWithoutWhere || isUpdateWithoutWhere;
|
|
146
|
+
const isReadOnly = matchedKeywords.length === 0;
|
|
147
|
+
const blockedByReadOnly = options.readOnly && matchedKeywords.length > 0;
|
|
148
|
+
|
|
149
|
+
let reason: string | undefined;
|
|
150
|
+
|
|
151
|
+
if (isDeleteWithoutWhere) {
|
|
152
|
+
reason = "DELETE without a WHERE clause affects every row.";
|
|
153
|
+
} else if (isUpdateWithoutWhere) {
|
|
154
|
+
reason = "UPDATE without a WHERE clause affects every row.";
|
|
155
|
+
} else if (hasDestructiveKeyword) {
|
|
156
|
+
reason = `Statement contains a destructive keyword: ${matchedKeywords.filter((keyword) => DESTRUCTIVE_KEYWORDS.includes(keyword)).join(", ")}.`;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
isDestructive,
|
|
161
|
+
isReadOnly,
|
|
162
|
+
blockedByReadOnly,
|
|
163
|
+
matchedKeywords,
|
|
164
|
+
reason,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Heuristic: an org or app name that looks production-like, used to warn the
|
|
170
|
+
* developer before they connect.
|
|
171
|
+
*/
|
|
172
|
+
export function looksLikeProduction(...values: Array<string | undefined>): boolean {
|
|
173
|
+
return values.some((value) => value && /\b(prod|production|prd|live)\b/i.test(value));
|
|
174
|
+
}
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import type { Client as PgClient, QueryResult } from "pg";
|
|
2
|
+
import { buildQualifiedName, quoteIdentifier } from "./db-metadata";
|
|
3
|
+
import type {
|
|
4
|
+
IDatabaseAdapter,
|
|
5
|
+
TConnectionTestResult,
|
|
6
|
+
TDatabaseColumn,
|
|
7
|
+
TDatabaseIndex,
|
|
8
|
+
TDatabaseObject,
|
|
9
|
+
TDatabaseQueryResult,
|
|
10
|
+
TDatabaseSchema,
|
|
11
|
+
TDatabaseType,
|
|
12
|
+
TListObjectsOptions,
|
|
13
|
+
TResolvedDatabaseConnection,
|
|
14
|
+
TTableDataOptions,
|
|
15
|
+
} from "./db-types";
|
|
16
|
+
|
|
17
|
+
const SYSTEM_SCHEMAS = new Set(["pg_catalog", "information_schema", "pg_toast"]);
|
|
18
|
+
|
|
19
|
+
type TPgRow = Record<string, unknown>;
|
|
20
|
+
|
|
21
|
+
export class PostgresAdapter implements IDatabaseAdapter {
|
|
22
|
+
public readonly type: TDatabaseType = "postgresql";
|
|
23
|
+
|
|
24
|
+
private client: PgClient | undefined;
|
|
25
|
+
|
|
26
|
+
private readonly queryTimeoutMs: number;
|
|
27
|
+
|
|
28
|
+
constructor(
|
|
29
|
+
private readonly connection: TResolvedDatabaseConnection,
|
|
30
|
+
options?: { queryTimeoutMs?: number },
|
|
31
|
+
) {
|
|
32
|
+
this.queryTimeoutMs = options?.queryTimeoutMs ?? 30000;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
public async connect(): Promise<void> {
|
|
36
|
+
if (this.client) {
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
let pgModule: typeof import("pg");
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
pgModule = await import("pg");
|
|
44
|
+
} catch {
|
|
45
|
+
throw new Error("PostgreSQL driver 'pg' is not installed. Run: npm install pg");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const PgClientCtor = pgModule.Client ?? (pgModule as unknown as { default: typeof import("pg") }).default.Client;
|
|
49
|
+
const client = new PgClientCtor({
|
|
50
|
+
host: this.connection.host,
|
|
51
|
+
port: this.connection.port,
|
|
52
|
+
user: this.connection.username,
|
|
53
|
+
password: this.connection.password,
|
|
54
|
+
database: this.connection.database,
|
|
55
|
+
ssl: this.connection.ssl ? { rejectUnauthorized: this.connection.sslValidateCertificate ?? false } : undefined,
|
|
56
|
+
statement_timeout: this.queryTimeoutMs,
|
|
57
|
+
connectionTimeoutMillis: 15000,
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
await client.connect();
|
|
61
|
+
this.client = client;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
public async disconnect(): Promise<void> {
|
|
65
|
+
if (this.client) {
|
|
66
|
+
await this.client.end().catch(() => undefined);
|
|
67
|
+
this.client = undefined;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
private async getClient(): Promise<PgClient> {
|
|
72
|
+
if (!this.client) {
|
|
73
|
+
await this.connect();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (!this.client) {
|
|
77
|
+
throw new Error("PostgreSQL connection is not established");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return this.client;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
public async testConnection(): Promise<TConnectionTestResult> {
|
|
84
|
+
const startedAt = Date.now();
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
const client = await this.getClient();
|
|
88
|
+
const result = await client.query("SELECT version() AS version");
|
|
89
|
+
const version = String((result.rows[0] as TPgRow | undefined)?.version ?? "PostgreSQL");
|
|
90
|
+
return { success: true, message: "Connection successful", serverVersion: version, durationMs: Date.now() - startedAt };
|
|
91
|
+
} catch (error) {
|
|
92
|
+
return {
|
|
93
|
+
success: false,
|
|
94
|
+
message: error instanceof Error ? error.message : String(error),
|
|
95
|
+
durationMs: Date.now() - startedAt,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
private toQueryResult(result: QueryResult, durationMs: number, maxRows?: number): TDatabaseQueryResult {
|
|
101
|
+
const fields = (result.fields ?? []).map((field) => field.name);
|
|
102
|
+
const allRows = (result.rows ?? []) as TPgRow[];
|
|
103
|
+
const limit = maxRows ?? 0;
|
|
104
|
+
const truncated = limit > 0 && allRows.length > limit;
|
|
105
|
+
const rows = truncated ? allRows.slice(0, limit) : allRows;
|
|
106
|
+
const command = result.command;
|
|
107
|
+
const isSelect = command === "SELECT" || command === "SHOW";
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
fields: fields.length > 0 ? fields : rows.length > 0 ? Object.keys(rows[0]) : [],
|
|
111
|
+
rows,
|
|
112
|
+
rowCount: rows.length,
|
|
113
|
+
affectedRows: isSelect ? undefined : result.rowCount ?? undefined,
|
|
114
|
+
command,
|
|
115
|
+
durationMs,
|
|
116
|
+
truncated,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
public async runQuery(sql: string, options?: { maxRows?: number }): Promise<TDatabaseQueryResult> {
|
|
121
|
+
const client = await this.getClient();
|
|
122
|
+
const startedAt = Date.now();
|
|
123
|
+
const rawResult = await client.query(sql);
|
|
124
|
+
const result = Array.isArray(rawResult) ? rawResult[rawResult.length - 1] as QueryResult : rawResult as QueryResult;
|
|
125
|
+
return this.toQueryResult(result, Date.now() - startedAt, options?.maxRows);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
public async runParameterized(sql: string, params: unknown[], options?: { maxRows?: number }): Promise<TDatabaseQueryResult> {
|
|
129
|
+
const client = await this.getClient();
|
|
130
|
+
const startedAt = Date.now();
|
|
131
|
+
const result = await client.query(sql, params) as QueryResult;
|
|
132
|
+
return this.toQueryResult(result, Date.now() - startedAt, options?.maxRows);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
public placeholder(index: number): string {
|
|
136
|
+
return `$${index}`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
public async listSchemas(): Promise<TDatabaseSchema[]> {
|
|
140
|
+
const client = await this.getClient();
|
|
141
|
+
const result = await client.query("SELECT schema_name FROM information_schema.schemata ORDER BY schema_name");
|
|
142
|
+
return (result.rows as TPgRow[]).map((row) => {
|
|
143
|
+
const name = String(row.schema_name);
|
|
144
|
+
return { name, isSystem: SYSTEM_SCHEMAS.has(name) || name.startsWith("pg_") };
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
public async listObjects(options: TListObjectsOptions): Promise<TDatabaseObject[]> {
|
|
149
|
+
const client = await this.getClient();
|
|
150
|
+
const schema = options.schema;
|
|
151
|
+
const kinds = options.kinds;
|
|
152
|
+
const objects: TDatabaseObject[] = [];
|
|
153
|
+
|
|
154
|
+
if (!schema) {
|
|
155
|
+
return objects;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const wantsTable = !kinds || kinds.includes("table");
|
|
159
|
+
const wantsView = !kinds || kinds.includes("view");
|
|
160
|
+
const wantsFunction = !kinds || kinds.includes("function") || kinds.includes("procedure");
|
|
161
|
+
|
|
162
|
+
if (wantsTable || wantsView) {
|
|
163
|
+
const tables = await client.query(
|
|
164
|
+
"SELECT table_name, table_type FROM information_schema.tables WHERE table_schema = $1 ORDER BY table_name",
|
|
165
|
+
[schema],
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
for (const row of tables.rows as TPgRow[]) {
|
|
169
|
+
const isView = String(row.table_type) === "VIEW";
|
|
170
|
+
if (isView && !wantsView) continue;
|
|
171
|
+
if (!isView && !wantsTable) continue;
|
|
172
|
+
objects.push({ schema, name: String(row.table_name), kind: isView ? "view" : "table", type: String(row.table_type) });
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (wantsFunction) {
|
|
177
|
+
const routines = await client.query(
|
|
178
|
+
"SELECT routine_name, routine_type FROM information_schema.routines WHERE routine_schema = $1 ORDER BY routine_name",
|
|
179
|
+
[schema],
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
for (const row of routines.rows as TPgRow[]) {
|
|
183
|
+
const isProcedure = String(row.routine_type) === "PROCEDURE";
|
|
184
|
+
objects.push({ schema, name: String(row.routine_name), kind: isProcedure ? "procedure" : "function", type: String(row.routine_type) });
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const search = options.search?.trim().toLowerCase();
|
|
189
|
+
return search ? objects.filter((object) => object.name.toLowerCase().includes(search)) : objects;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
public async listColumns(schema: string, table: string): Promise<TDatabaseColumn[]> {
|
|
193
|
+
const client = await this.getClient();
|
|
194
|
+
const columnsResult = await client.query(
|
|
195
|
+
`SELECT c.column_name, c.data_type, c.character_maximum_length, c.numeric_precision, c.numeric_scale, c.is_nullable, c.column_default, c.ordinal_position,
|
|
196
|
+
(SELECT pgd.description
|
|
197
|
+
FROM pg_catalog.pg_description pgd
|
|
198
|
+
JOIN pg_catalog.pg_class cls ON cls.oid = pgd.objoid
|
|
199
|
+
JOIN pg_catalog.pg_namespace ns ON ns.oid = cls.relnamespace
|
|
200
|
+
WHERE ns.nspname = c.table_schema AND cls.relname = c.table_name AND pgd.objsubid = c.ordinal_position) AS column_comment
|
|
201
|
+
FROM information_schema.columns c
|
|
202
|
+
WHERE c.table_schema = $1 AND c.table_name = $2
|
|
203
|
+
ORDER BY c.ordinal_position`,
|
|
204
|
+
[schema, table],
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
const primaryKeyResult = await client.query(
|
|
208
|
+
`SELECT kcu.column_name
|
|
209
|
+
FROM information_schema.table_constraints tc
|
|
210
|
+
JOIN information_schema.key_column_usage kcu
|
|
211
|
+
ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema
|
|
212
|
+
WHERE tc.constraint_type = 'PRIMARY KEY' AND tc.table_schema = $1 AND tc.table_name = $2`,
|
|
213
|
+
[schema, table],
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
const primaryKeyColumns = new Set((primaryKeyResult.rows as TPgRow[]).map((row) => String(row.column_name)));
|
|
217
|
+
|
|
218
|
+
return (columnsResult.rows as TPgRow[]).map((row) => ({
|
|
219
|
+
name: String(row.column_name),
|
|
220
|
+
dataType: String(row.data_type),
|
|
221
|
+
length: row.character_maximum_length === null ? undefined : Number(row.character_maximum_length),
|
|
222
|
+
scale: row.numeric_scale === null ? undefined : Number(row.numeric_scale),
|
|
223
|
+
nullable: String(row.is_nullable).toUpperCase() === "YES",
|
|
224
|
+
defaultValue: row.column_default === null ? undefined : String(row.column_default),
|
|
225
|
+
isPrimaryKey: primaryKeyColumns.has(String(row.column_name)),
|
|
226
|
+
comment: row.column_comment === null || row.column_comment === undefined ? undefined : String(row.column_comment),
|
|
227
|
+
position: Number(row.ordinal_position),
|
|
228
|
+
}));
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
public async listIndexes(schema: string, table: string): Promise<TDatabaseIndex[]> {
|
|
232
|
+
const client = await this.getClient();
|
|
233
|
+
const result = await client.query(
|
|
234
|
+
"SELECT indexname, indexdef FROM pg_indexes WHERE schemaname = $1 AND tablename = $2",
|
|
235
|
+
[schema, table],
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
return (result.rows as TPgRow[]).map((row) => {
|
|
239
|
+
const name = String(row.indexname);
|
|
240
|
+
const definition = String(row.indexdef);
|
|
241
|
+
const columnMatch = definition.match(/\(([^)]+)\)/);
|
|
242
|
+
const columns = columnMatch ? columnMatch[1].split(",").map((column) => column.trim().replace(/"/g, "")) : [];
|
|
243
|
+
return {
|
|
244
|
+
name,
|
|
245
|
+
columns,
|
|
246
|
+
isUnique: /\bUNIQUE\b/i.test(definition),
|
|
247
|
+
isPrimaryKey: name.endsWith("_pkey"),
|
|
248
|
+
};
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
public async countRows(schema: string, table: string): Promise<number> {
|
|
253
|
+
const client = await this.getClient();
|
|
254
|
+
const result = await client.query(`SELECT COUNT(*) AS count FROM ${buildQualifiedName(this.type, schema, table)}`);
|
|
255
|
+
return Number((result.rows[0] as TPgRow | undefined)?.count ?? 0);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
public async getTableData(options: TTableDataOptions): Promise<TDatabaseQueryResult> {
|
|
259
|
+
const qualifiedName = buildQualifiedName(this.type, options.schema, options.table);
|
|
260
|
+
const whereClause = options.where?.trim() ? ` WHERE ${options.where.trim()}` : "";
|
|
261
|
+
const orderClause = options.orderBy?.trim()
|
|
262
|
+
? ` ORDER BY ${quoteIdentifier(this.type, options.orderBy.trim())} ${options.orderDirection === "desc" ? "DESC" : "ASC"}`
|
|
263
|
+
: "";
|
|
264
|
+
const sql = `SELECT * FROM ${qualifiedName}${whereClause}${orderClause} LIMIT ${options.limit} OFFSET ${options.offset}`;
|
|
265
|
+
return this.runQuery(sql, { maxRows: options.limit });
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
public quoteIdentifier(identifier: string): string {
|
|
269
|
+
return quoteIdentifier(this.type, identifier);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
public buildQualifiedName(schema: string, name: string): string {
|
|
273
|
+
return buildQualifiedName(this.type, schema, name);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import crypto from "node:crypto";
|
|
4
|
+
import fs from "fs-extra";
|
|
5
|
+
import type { TDatabaseType, TSavedQuery } from "./db-types";
|
|
6
|
+
|
|
7
|
+
const QUERIES_DIRECTORY = path.join(os.homedir(), ".simplemdg", "db-queries");
|
|
8
|
+
|
|
9
|
+
export type TSaveQueryInput = {
|
|
10
|
+
id?: string;
|
|
11
|
+
name: string;
|
|
12
|
+
sql: string;
|
|
13
|
+
connectionType?: TDatabaseType;
|
|
14
|
+
connectionId?: string;
|
|
15
|
+
tags?: string[];
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
function queryFilePath(id: string): string {
|
|
19
|
+
return path.join(QUERIES_DIRECTORY, `${id}.json`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function sanitizeFileBaseName(name: string): string {
|
|
23
|
+
return name.replace(/[^a-z0-9-_]+/gi, "-").replace(/^-+|-+$/g, "").toLowerCase() || "query";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function listSavedQueries(): Promise<TSavedQuery[]> {
|
|
27
|
+
if (!(await fs.pathExists(QUERIES_DIRECTORY))) {
|
|
28
|
+
return [];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const entries = await fs.readdir(QUERIES_DIRECTORY);
|
|
32
|
+
const queries: TSavedQuery[] = [];
|
|
33
|
+
|
|
34
|
+
for (const entry of entries) {
|
|
35
|
+
if (!entry.endsWith(".json")) {
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const parsed = await fs.readJson(path.join(QUERIES_DIRECTORY, entry)).catch(() => undefined) as TSavedQuery | undefined;
|
|
40
|
+
|
|
41
|
+
if (parsed?.id && typeof parsed.sql === "string") {
|
|
42
|
+
queries.push(parsed);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return queries.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function getSavedQuery(id: string): Promise<TSavedQuery | undefined> {
|
|
50
|
+
const filePath = queryFilePath(id);
|
|
51
|
+
|
|
52
|
+
if (!(await fs.pathExists(filePath))) {
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return await fs.readJson(filePath).catch(() => undefined) as TSavedQuery | undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function saveQuery(input: TSaveQueryInput): Promise<TSavedQuery> {
|
|
60
|
+
await fs.ensureDir(QUERIES_DIRECTORY);
|
|
61
|
+
const now = new Date().toISOString();
|
|
62
|
+
const existing = input.id ? await getSavedQuery(input.id) : undefined;
|
|
63
|
+
|
|
64
|
+
const query: TSavedQuery = {
|
|
65
|
+
id: existing?.id ?? input.id ?? crypto.randomUUID(),
|
|
66
|
+
name: input.name.trim() || "Untitled query",
|
|
67
|
+
sql: input.sql,
|
|
68
|
+
connectionType: input.connectionType ?? existing?.connectionType,
|
|
69
|
+
connectionId: input.connectionId ?? existing?.connectionId,
|
|
70
|
+
tags: input.tags ?? existing?.tags ?? [],
|
|
71
|
+
createdAt: existing?.createdAt ?? now,
|
|
72
|
+
updatedAt: now,
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
await fs.writeJson(queryFilePath(query.id), query, { spaces: 2 });
|
|
76
|
+
return query;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function renameSavedQuery(id: string, name: string): Promise<TSavedQuery> {
|
|
80
|
+
const query = await getSavedQuery(id);
|
|
81
|
+
|
|
82
|
+
if (!query) {
|
|
83
|
+
throw new Error(`Saved query not found: ${id}`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
query.name = name.trim() || query.name;
|
|
87
|
+
query.updatedAt = new Date().toISOString();
|
|
88
|
+
await fs.writeJson(queryFilePath(id), query, { spaces: 2 });
|
|
89
|
+
return query;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export async function deleteSavedQuery(id: string): Promise<boolean> {
|
|
93
|
+
const filePath = queryFilePath(id);
|
|
94
|
+
|
|
95
|
+
if (!(await fs.pathExists(filePath))) {
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
await fs.remove(filePath);
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export async function exportSavedQueryToSql(id: string, targetDirectory: string): Promise<string> {
|
|
104
|
+
const query = await getSavedQuery(id);
|
|
105
|
+
|
|
106
|
+
if (!query) {
|
|
107
|
+
throw new Error(`Saved query not found: ${id}`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
await fs.ensureDir(targetDirectory);
|
|
111
|
+
const targetPath = path.join(targetDirectory, `${sanitizeFileBaseName(query.name)}.sql`);
|
|
112
|
+
await fs.writeFile(targetPath, query.sql, "utf8");
|
|
113
|
+
return targetPath;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export async function importSqlFile(filePath: string, options?: { name?: string; tags?: string[] }): Promise<TSavedQuery> {
|
|
117
|
+
const resolvedPath = path.resolve(filePath);
|
|
118
|
+
|
|
119
|
+
if (!(await fs.pathExists(resolvedPath))) {
|
|
120
|
+
throw new Error(`SQL file not found: ${resolvedPath}`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const sql = await fs.readFile(resolvedPath, "utf8");
|
|
124
|
+
const name = options?.name?.trim() || path.basename(resolvedPath, path.extname(resolvedPath));
|
|
125
|
+
return saveQuery({ name, sql, tags: options?.tags });
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function getQueriesDirectory(): string {
|
|
129
|
+
return QUERIES_DIRECTORY;
|
|
130
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import crypto from "node:crypto";
|
|
4
|
+
import fs from "fs-extra";
|
|
5
|
+
import type { TQueryHistoryItem } from "./db-types";
|
|
6
|
+
|
|
7
|
+
const CACHE_DIRECTORY = path.join(os.homedir(), ".simplemdg");
|
|
8
|
+
const HISTORY_FILE_PATH = path.join(CACHE_DIRECTORY, "db-query-history.json");
|
|
9
|
+
const MAX_HISTORY_ITEMS = 300;
|
|
10
|
+
|
|
11
|
+
type THistoryFile = {
|
|
12
|
+
items: TQueryHistoryItem[];
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
async function readHistoryFile(): Promise<THistoryFile> {
|
|
16
|
+
if (!(await fs.pathExists(HISTORY_FILE_PATH))) {
|
|
17
|
+
return { items: [] };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const parsed = await fs.readJson(HISTORY_FILE_PATH).catch(() => ({ items: [] })) as Partial<THistoryFile>;
|
|
21
|
+
return { items: Array.isArray(parsed.items) ? parsed.items : [] };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function writeHistoryFile(file: THistoryFile): Promise<void> {
|
|
25
|
+
await fs.ensureDir(CACHE_DIRECTORY);
|
|
26
|
+
await fs.writeJson(HISTORY_FILE_PATH, file, { spaces: 2 });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function appendQueryHistory(item: Omit<TQueryHistoryItem, "id" | "timestamp">): Promise<TQueryHistoryItem> {
|
|
30
|
+
const file = await readHistoryFile();
|
|
31
|
+
const entry: TQueryHistoryItem = {
|
|
32
|
+
...item,
|
|
33
|
+
id: crypto.randomUUID(),
|
|
34
|
+
timestamp: new Date().toISOString(),
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
file.items = [entry, ...file.items].slice(0, MAX_HISTORY_ITEMS);
|
|
38
|
+
await writeHistoryFile(file);
|
|
39
|
+
return entry;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function listQueryHistory(limit = 100): Promise<TQueryHistoryItem[]> {
|
|
43
|
+
const file = await readHistoryFile();
|
|
44
|
+
return file.items.slice(0, limit);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function clearQueryHistory(): Promise<void> {
|
|
48
|
+
await writeHistoryFile({ items: [] });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function getHistoryFilePath(): string {
|
|
52
|
+
return HISTORY_FILE_PATH;
|
|
53
|
+
}
|