Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 2 additions & 8 deletions __tests__/db-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
describe("getCompletions (/db 参数补全)", () => {
it("expands second-level subcommands for 'relations'", async () => {
const result = await getCompletions("relations", stubWs());
expect(result?.map((c) => c.label).sort()).toEqual(["add", "discover", "er-diagram", "remove"]);
expect(result?.map((c) => c.label).sort()).toEqual(["add", "discover", "remove"]);

Check warning on line 17 in __tests__/db-command.test.ts

View workflow job for this annotation

GitHub Actions / checks

unicorn(no-array-sort)

Use `Array#toSorted()` instead of `Array#sort()`.
expect(result?.[0].value).toBe("relations add ");
});

Expand All @@ -25,7 +25,7 @@

it("handles trailing space after 'relations' (Tab-completed state)", async () => {
const result = await getCompletions("relations ", stubWs());
expect(result?.length).toBe(4);
expect(result?.length).toBe(3);
});

it("filters sub-subcommands by partial input", async () => {
Expand All @@ -41,7 +41,7 @@
it("completes table names for 'schema' and 'query'", async () => {
const ws = stubWs({ tables: ["t_orders", "t_customers", "t_products"] });
const schemaResult = await getCompletions("schema t_", ws);
expect(schemaResult?.map((c) => c.value).sort()).toEqual([

Check warning on line 44 in __tests__/db-command.test.ts

View workflow job for this annotation

GitHub Actions / checks

unicorn(no-array-sort)

Use `Array#toSorted()` instead of `Array#sort()`.
"schema t_customers",
"schema t_orders",
"schema t_products",
Expand All @@ -54,15 +54,9 @@
it("is case-insensitive for table filtering", async () => {
const ws = stubWs({ tables: ["T_ORDERS", "t_customers"] });
const result = await getCompletions("query t_", ws);
expect(result?.map((c) => c.label).sort()).toEqual(["T_ORDERS", "t_customers"]);

Check warning on line 57 in __tests__/db-command.test.ts

View workflow job for this annotation

GitHub Actions / checks

unicorn(no-array-sort)

Use `Array#toSorted()` instead of `Array#sort()`.
});

it("completes table names for 'relations er-diagram'", async () => {
const ws = stubWs({ tables: ["t_orders"] });
const result = await getCompletions("relations er-diagram ", ws);
expect(result?.map((c) => c.value)).toEqual(["relations er-diagram t_orders"]);
});

it("falls back to subcommand prefix matching for partial first word", async () => {
const result = await getCompletions("s", stubWs());
expect(result?.map((c) => c.label)).toContain("schema");
Expand Down
54 changes: 27 additions & 27 deletions __tests__/schema-table.test.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,31 @@
import { describe, it, expect } from "vitest";
import { formatSchemaMarkdown } from "../formatting/schema-table";
import type { SqlRow } from "../types";
import type { SchemaColumn, SchemaIndex } from "../types";

const columns: SqlRow[] = [
const columns: SchemaColumn[] = [
{
COLUMN_NAME: "id",
COLUMN_TYPE: "bigint(20)",
IS_NULLABLE: "NO",
COLUMN_KEY: "PRI",
COLUMN_DEFAULT: null,
EXTRA: "auto_increment",
COLUMN_COMMENT: "",
name: "id",
type: "bigint(20)",
nullable: false,
key: "PRI",
default: null,
extra: "auto_increment",
comment: "",
},
{
COLUMN_NAME: "user_id",
COLUMN_TYPE: "bigint(20)",
IS_NULLABLE: "YES",
COLUMN_KEY: "MUL",
COLUMN_DEFAULT: null,
EXTRA: "",
COLUMN_COMMENT: "下单用户",
name: "user_id",
type: "bigint(20)",
nullable: true,
key: "MUL",
default: null,
extra: "",
comment: "下单用户",
},
];

const indexes: SqlRow[] = [
{ INDEX_NAME: "PRIMARY", COLUMN_NAME: "id", NON_UNIQUE: 0, SEQ_IN_INDEX: 1 },
{ INDEX_NAME: "idx_user", COLUMN_NAME: "user_id", NON_UNIQUE: 1, SEQ_IN_INDEX: 1 },
const indexes: SchemaIndex[] = [
{ name: "PRIMARY", columns: ["id"], unique: true },
{ name: "idx_user", columns: ["user_id"], unique: false },
];

describe("formatSchemaMarkdown", () => {
Expand All @@ -42,15 +42,15 @@ describe("formatSchemaMarkdown", () => {
});

it("escapes pipe characters in comments so the table doesn't break", () => {
const cols: SqlRow[] = [
const cols: SchemaColumn[] = [
{
COLUMN_NAME: "type",
COLUMN_TYPE: "varchar(10)",
IS_NULLABLE: "YES",
COLUMN_KEY: "",
COLUMN_DEFAULT: null,
EXTRA: "",
COLUMN_COMMENT: "a|b",
name: "type",
type: "varchar(10)",
nullable: true,
key: "",
default: null,
extra: "",
comment: "a|b",
},
];
const out = formatSchemaMarkdown("t", "db", cols, []);
Expand Down
10 changes: 4 additions & 6 deletions commands/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@
if (!ctx.hasUI) return;

const ws = getWorkspace();
const [sub, ...rest] = args.trim().split(/\s+/).filter(Boolean);

Check warning on line 96 in commands/db.ts

View workflow job for this annotation

GitHub Actions / checks

unicorn(prefer-array-find)

Prefer `find` over filtering and accessing the first result.

switch (sub) {
case undefined: {
Expand Down Expand Up @@ -176,7 +176,7 @@

const subSubs: Record<string, string[]> = {
favorite: ["add"],
relations: ["add", "remove", "discover", "er-diagram"],
relations: ["add", "remove", "discover"],
};

// 当第一个词与拥有子子命令的子命令完全匹配时,立即显示第二层。
Expand All @@ -190,14 +190,12 @@

// 表名参数(schema、query)——在第一级部分匹配之前触发,
// 避免精确子命令匹配自引用。
const takesTable =
sub === "schema" || sub === "query" || (sub === "relations" && parts[1] === "er-diagram");
const takesTable = sub === "schema" || sub === "query";
if (takesTable && ws.isReady) {
try {
const tables = await ws.getTables();
const tablePartial =
sub === "relations" ? (hasTrailingSpace ? "" : (parts[2] ?? "")) : partial;
const valuePrefix = sub === "relations" ? "relations er-diagram " : `${sub} `;
const tablePartial = partial;
const valuePrefix = `${sub} `;
return tables
.filter((t) => t.toLowerCase().startsWith(tablePartial.toLowerCase()))
.map((t) => ({ value: `${valuePrefix}${t}`, label: t }));
Expand Down
119 changes: 12 additions & 107 deletions commands/relations.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
/**
* /db relations —— 表关系管理。
*
* 子命令:add、remove、discover(FK 同步)、er-diagram
* 子命令:add、remove、discover(FK 同步)。
*/

import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
import type { DatabaseWorkspaceService } from "../state/workspace";
import type { SqlRow, StoredRelation } from "../types";
import type { StoredRelation } from "../types";
import { pickTableFuzzy, withLoader } from "./utils";

// ── 列表格式化 ─────────────────────────────────────────────
Expand Down Expand Up @@ -43,8 +43,6 @@
return await handleRelationsRemove(ctx, ws, pi);
case "discover":
return await handleRelationsDiscover(ctx, ws, pi);
case "er-diagram":
return await handleRelationsERDiagram(ctx, ws, pi, rest[1]);
default:
return await handleRelationsList(ctx, ws, pi);
}
Expand Down Expand Up @@ -115,7 +113,7 @@
let srcColumns: string[] = [];
try {
const schemaInfo = await ws.getTableSchema(srcTable);
srcColumns = schemaInfo.columns.map((c: SqlRow) => c.COLUMN_NAME as string);
srcColumns = schemaInfo.columns.map((c) => c.name);
} catch {
ctx.ui.notify(`无法获取 ${srcTable} 的列信息`, "error");
return;
Expand All @@ -130,7 +128,7 @@
let refColumns: string[] = [];
try {
const schemaInfo = await ws.getTableSchema(refTable);
refColumns = schemaInfo.columns.map((c: SqlRow) => c.COLUMN_NAME as string);
refColumns = schemaInfo.columns.map((c) => c.name);
} catch {
ctx.ui.notify(`无法获取 ${refTable} 的列信息`, "error");
return;
Expand Down Expand Up @@ -235,39 +233,33 @@
}

if (tables.length > 0) {
const erLines: string[] = ["erDiagram"];
const MAX_TABLES = 30;
const sampleTables = tables.slice(0, MAX_TABLES);

const schemaBlocks: string[] = [];
for (const t of sampleTables) {
try {
const info = await ws.getTableSchema(t);

Check warning on line 242 in commands/relations.ts

View workflow job for this annotation

GitHub Actions / checks

eslint(no-await-in-loop)

Unexpected `await` inside a loop.
erLines.push(` "${t}" {`);
for (const col of info.columns) {
const colName = col.COLUMN_NAME as string;
const colType = col.COLUMN_TYPE as string;
const comment = col.COLUMN_COMMENT ? ` "${col.COLUMN_COMMENT}"` : "";
erLines.push(` ${colType} ${colName}${comment}`);
}
erLines.push(` }`);
const cols = info.columns.map((c) => `- ${c.name} (${c.type})`).join("\n");
schemaBlocks.push(`### ${t}\n${cols}`);
} catch {
// 跳过
}
}

if (tables.length > MAX_TABLES) {
erLines.push(` "…还有${tables.length - MAX_TABLES}张表" {}`);
schemaBlocks.push(`…还有 ${tables.length - MAX_TABLES} 张表未展示`);
}

const erDiagram = erLines.join("\n");
const schemaText = schemaBlocks.join("\n\n");

ctx.ui.notify("正在通过 AI 分析表关系…", "info");

pi.sendMessage(
{
customType: "db-relation-discover",
content: [
`请分析以下数据库 ${schema} 的 mermaid ER 图,找出表之间可能的关联关系。`,
`请分析以下数据库 ${schema} 的表结构,找出表之间可能的关联关系。`,
``,
`规则:`,
`1. 根据列名匹配(如 users.id ↔ orders.user_id, dept_no ↔ dept_no)`,
Expand All @@ -280,10 +272,8 @@
`仅当 db_tools 也不可用时,再以 JSON 数组格式输出,每个元素:`,
`{"table":"源表","column":"源列","refTable":"目标表","refColumn":"目标列","relationType":"MANY_TO_ONE","condition":""}`,
``,
`ER 图:`,
"```mermaid",
erDiagram,
"```",
`表结构:`,
schemaText,
].join("\n"),
display: true,
},
Expand All @@ -294,88 +284,3 @@

ctx.ui.notify(parts.join("\n"), "info");
}

// ── ER 图 ──────────────────────────────────────────────────

async function handleRelationsERDiagram(
ctx: ExtensionCommandContext,
ws: DatabaseWorkspaceService,
pi: ExtensionAPI,
table?: string,
): Promise<void> {
if (!ws.isReady) {
ctx.ui.notify("未选择数据库,请先执行 /db switch", "warning");
return;
}

if (!table) {
const picked = await pickTableFuzzy(ctx, ws, "选择表");
if (!picked) return;
table = picked;
}

let tableColumns: SqlRow[] = [];
try {
const info = await ws.getTableSchema(table);
tableColumns = info.columns;
} catch {
ctx.ui.notify(`无法获取 ${table} 的表结构`, "error");
return;
}

const relations = ws.listRelations(table);
const relatedTableNames = new Set<string>();
for (const r of relations) {
relatedTableNames.add(r.refTable);
relatedTableNames.add(r.table);
}

const allColumns = new Map<string, SqlRow[]>();
allColumns.set(table, tableColumns);
for (const relatedTable of relatedTableNames) {
if (relatedTable === table) continue;
try {
const info = await ws.getTableSchema(relatedTable);
allColumns.set(relatedTable, info.columns);
} catch {
// 跳过
}
}

const lines: string[] = ["erDiagram"];

for (const r of relations) {
const label = r.condition
? `${r.column} → ${r.refColumn} [${r.condition}]`
: `${r.column} → ${r.refColumn}`;
lines.push(` "${r.table}" ||--o{ "${r.refTable}" : "${label}"`);
}

const drawn = new Set<string>();
for (const [tbl, cols] of allColumns) {
if (drawn.has(tbl)) continue;
drawn.add(tbl);
lines.push(` "${tbl}" {`);
for (const col of cols) {
const colName = col.COLUMN_NAME as string;
const colType = col.COLUMN_TYPE as string;
const comment = col.COLUMN_COMMENT ? ` "${col.COLUMN_COMMENT}"` : "";
lines.push(` ${colType} ${colName}${comment}`);
}
lines.push(` }`);
}

const erDiagram = lines.join("\n");

// display: true → 在聊天中持久显示;默认 markdown 渲染器
// 把 mermaid 源码显示为代码块,LLM 也能读取。
// deliverAs "followUp" 在 agent 空闲时立即提交。
pi.sendMessage(
{
customType: "db-er-diagram",
content: [`## ER 图 — ${table}`, "", "```mermaid", erDiagram, "```"].join("\n"),
display: true,
},
{ deliverAs: "followUp", triggerTurn: false },
);
}
4 changes: 2 additions & 2 deletions commands/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
import type { DatabaseWorkspaceService } from "../state/workspace";
import type { SqlRow } from "../types";
import type { TableSchema } from "../types";
import { formatSchemaMarkdown } from "../formatting/schema-table";
import { pickTableFuzzy } from "./utils";

Expand All @@ -25,7 +25,7 @@ export async function handleSchema(
table = picked;
}

let result: { columns: SqlRow[]; indexes: SqlRow[] };
let result: TableSchema;
try {
result = await ws.getTableSchema(table);
} catch (err: any) {
Expand Down
2 changes: 1 addition & 1 deletion commands/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import { createFilterReducer } from "./filter-input";
* @param extraItems —— 前置在表项之前(如 "✏️ 输入 SQL…")
* 用于合并的查询入口)。
*
* 由 schema、query、relations-add 和 relations-er-diagram 处理器共享。
* 由 schema、query、relations-add 处理器共享。
*/
export async function pickTableFuzzy(
ctx: ExtensionCommandContext,
Expand Down
27 changes: 25 additions & 2 deletions connection/db-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import mysql, { type Pool, type RowDataPacket, type ResultSetHeader } from "mysql2/promise";
import type { ResolvedConnectionConfig } from "./db-config";
import { prepareReadOnlyQuery, DEFAULT_QUERY_LIMIT } from "./sql-policy";
import type { SchemaIndex, TableSchema } from "../types";

export interface QueryOptions {
limit?: number; // row cap for SELECTs without trailing LIMIT (default: connection's queryLimit)
Expand Down Expand Up @@ -85,7 +86,7 @@
async getDatabases(connectionId: string): Promise<string[]> {
const pool = this.getPool(connectionId);
const [rows] = await pool.query<RowDataPacket[]>("SHOW DATABASES");
return rows.map((r) => r.Database as string).sort();

Check warning on line 89 in connection/db-manager.ts

View workflow job for this annotation

GitHub Actions / checks

unicorn(no-array-sort)

Use `Array#toSorted()` instead of `Array#sort()`.
}

/**
Expand All @@ -107,7 +108,7 @@
connectionId: string,
database: string,
table: string,
): Promise<{ columns: RowDataPacket[]; indexes: RowDataPacket[] }> {
): Promise<TableSchema> {
const pool = this.getPool(connectionId);

const [columns] = await pool.query<RowDataPacket[]>(
Expand All @@ -126,7 +127,29 @@
[database, table],
);

return { columns, indexes };
return {
columns: columns.map((c) => ({
name: c.COLUMN_NAME as string,
type: c.COLUMN_TYPE as string,
nullable: c.IS_NULLABLE === "YES",
key: (c.COLUMN_KEY as string) ?? "",
default: (c.COLUMN_DEFAULT as string | null) ?? null,
extra: (c.EXTRA as string) ?? "",
comment: (c.COLUMN_COMMENT as string) ?? "",
})),
indexes: this.aggregateIndexes(indexes),
};
}

/** 将 information_schema.STATISTICS 行按索引名聚合成 SchemaIndex[]。 */
private aggregateIndexes(rows: RowDataPacket[]): SchemaIndex[] {
const map = new Map<string, { cols: string[]; unique: boolean }>();
for (const idx of rows) {
const name = idx.INDEX_NAME as string;
if (!map.has(name)) map.set(name, { cols: [], unique: idx.NON_UNIQUE === 0 });
map.get(name)!.cols.push(idx.COLUMN_NAME as string);
}
return [...map.entries()].map(([name, { cols, unique }]) => ({ name, columns: cols, unique }));
}

/**
Expand Down
Loading
Loading