mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-28 08:08:02 +00:00
chore(repo): move app tests under packages
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import test from "node:test";
|
||||
import { shouldSuppressRepeatedActivation, tryStartExclusiveActivation } from "../../src/lib/actionActivation.ts";
|
||||
|
||||
test("allows the first activation", () => {
|
||||
const guard = {};
|
||||
|
||||
assert.equal(shouldSuppressRepeatedActivation(guard, 1000), false);
|
||||
});
|
||||
|
||||
test("suppresses repeated activation inside the guard window", () => {
|
||||
const guard = {};
|
||||
|
||||
assert.equal(shouldSuppressRepeatedActivation(guard, 1000, 500), false);
|
||||
assert.equal(shouldSuppressRepeatedActivation(guard, 1200, 500), true);
|
||||
});
|
||||
|
||||
test("allows activation after the guard window", () => {
|
||||
const guard = {};
|
||||
|
||||
assert.equal(shouldSuppressRepeatedActivation(guard, 1000, 500), false);
|
||||
assert.equal(shouldSuppressRepeatedActivation(guard, 1600, 500), false);
|
||||
});
|
||||
|
||||
test("exclusive activation blocks concurrent starts until finished", () => {
|
||||
const guard = {};
|
||||
const finish = tryStartExclusiveActivation(guard);
|
||||
|
||||
assert.equal(typeof finish, "function");
|
||||
assert.equal(tryStartExclusiveActivation(guard), null);
|
||||
|
||||
finish?.();
|
||||
|
||||
assert.equal(typeof tryStartExclusiveActivation(guard), "function");
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { showAgentDriverInstallHint } from "../../src/lib/agentDriverInstallHint.ts";
|
||||
|
||||
test("hides the agent driver install hint when the selected driver is installed", () => {
|
||||
assert.equal(showAgentDriverInstallHint("informix", [{ db_type: "informix", installed: true }]), false);
|
||||
});
|
||||
|
||||
test("shows the agent driver install hint when the selected driver is missing", () => {
|
||||
assert.equal(showAgentDriverInstallHint("informix", [{ db_type: "informix", installed: false }]), true);
|
||||
});
|
||||
|
||||
test("shows the agent driver install hint for TDengine when missing", () => {
|
||||
assert.equal(showAgentDriverInstallHint("tdengine", [{ db_type: "tdengine", installed: false }]), true);
|
||||
});
|
||||
|
||||
test("shows the agent driver install hint for Access when missing", () => {
|
||||
assert.equal(showAgentDriverInstallHint("access", [{ db_type: "access", installed: false }]), true);
|
||||
});
|
||||
|
||||
test("does not show agent driver install hints for built-in database types", () => {
|
||||
assert.equal(showAgentDriverInstallHint("mysql", [{ db_type: "informix", installed: false }]), false);
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { countAvailableAgentDriverUpdates } from "../../src/lib/agentDriverUpdateBadge.ts";
|
||||
|
||||
test("returns zero when there are no available agent driver updates", () => {
|
||||
assert.equal(countAvailableAgentDriverUpdates([]), 0);
|
||||
assert.equal(countAvailableAgentDriverUpdates([{ update_available: false }, { update_available: false }]), 0);
|
||||
});
|
||||
|
||||
test("counts available agent driver updates", () => {
|
||||
assert.equal(
|
||||
countAvailableAgentDriverUpdates([
|
||||
{ update_available: true },
|
||||
{ update_available: false },
|
||||
{ update_available: true },
|
||||
]),
|
||||
2,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,193 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import test from "node:test";
|
||||
import { buildAiAgentPlan } from "../../src/lib/aiAgentPlan.ts";
|
||||
import type { AiAction, AiAssistantMode } from "../../src/lib/ai.ts";
|
||||
import type { ConnectionConfig } from "../../src/types/database.ts";
|
||||
|
||||
function conn(overrides: Partial<ConnectionConfig> = {}): ConnectionConfig {
|
||||
return {
|
||||
id: "c1",
|
||||
name: "local-pg",
|
||||
db_type: "postgres",
|
||||
host: "127.0.0.1",
|
||||
port: 5432,
|
||||
username: "postgres",
|
||||
password: "",
|
||||
database: "app_dev",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function planInput(
|
||||
overrides: {
|
||||
mode?: AiAssistantMode;
|
||||
action?: AiAction;
|
||||
instruction?: string;
|
||||
assistantContent?: string;
|
||||
connection?: ConnectionConfig;
|
||||
} = {},
|
||||
) {
|
||||
return {
|
||||
mode: overrides.mode ?? "agent",
|
||||
action: overrides.action ?? "generate",
|
||||
instruction: overrides.instruction ?? "查一下用户数量",
|
||||
assistantContent: overrides.assistantContent ?? "```sql\nSELECT count(*) FROM users\n```",
|
||||
connection: overrides.connection ?? conn(),
|
||||
};
|
||||
}
|
||||
|
||||
test("ask mode records generated SQL but skips execution", () => {
|
||||
const plan = buildAiAgentPlan(planInput({ mode: "ask" }));
|
||||
|
||||
assert.deepEqual(plan.steps, [
|
||||
{ kind: "generate_sql", status: "done", sql: "SELECT count(*) FROM users" },
|
||||
{ kind: "execute_sql", status: "skipped", reason: "ask_mode" },
|
||||
]);
|
||||
assert.equal(plan.executableSql, undefined);
|
||||
});
|
||||
|
||||
test("agent mode auto-executes read SQL when the user asks to query", () => {
|
||||
const plan = buildAiAgentPlan(planInput());
|
||||
|
||||
assert.deepEqual(plan.steps, [
|
||||
{ kind: "generate_sql", status: "done", sql: "SELECT count(*) FROM users" },
|
||||
{
|
||||
kind: "risk_check",
|
||||
status: "done",
|
||||
action: "auto_execute",
|
||||
environment: "non_production",
|
||||
category: "read",
|
||||
reasons: [],
|
||||
},
|
||||
{ kind: "execute_sql", status: "pending", sql: "SELECT count(*) FROM users" },
|
||||
]);
|
||||
assert.equal(plan.executableSql, "SELECT count(*) FROM users");
|
||||
assert.equal(plan.handoffSql, "SELECT count(*) FROM users");
|
||||
});
|
||||
|
||||
test("agent mode skips execution when the user explicitly asks not to run", () => {
|
||||
const plan = buildAiAgentPlan(planInput({ instruction: "只生成 SQL,不要执行" }));
|
||||
|
||||
assert.deepEqual(plan.steps, [
|
||||
{ kind: "generate_sql", status: "done", sql: "SELECT count(*) FROM users" },
|
||||
{ kind: "execute_sql", status: "skipped", reason: "no_execution_intent" },
|
||||
]);
|
||||
assert.equal(plan.executableSql, undefined);
|
||||
assert.equal(plan.handoffSql, undefined);
|
||||
});
|
||||
|
||||
test("non-generate actions do not execute even in agent mode", () => {
|
||||
const plan = buildAiAgentPlan(planInput({ action: "optimize", instruction: "优化这条 SQL" }));
|
||||
|
||||
assert.deepEqual(plan.steps, [
|
||||
{ kind: "generate_sql", status: "done", sql: "SELECT count(*) FROM users" },
|
||||
{ kind: "execute_sql", status: "skipped", reason: "unsupported_action" },
|
||||
]);
|
||||
assert.equal(plan.executableSql, undefined);
|
||||
assert.equal(plan.handoffSql, undefined);
|
||||
});
|
||||
|
||||
test("agent plan blocks dangerous SQL", () => {
|
||||
const plan = buildAiAgentPlan(
|
||||
planInput({
|
||||
assistantContent: "```sql\nDROP TABLE users\n```",
|
||||
}),
|
||||
);
|
||||
|
||||
assert.deepEqual(plan.steps, [
|
||||
{ kind: "generate_sql", status: "done", sql: "DROP TABLE users" },
|
||||
{
|
||||
kind: "risk_check",
|
||||
status: "done",
|
||||
action: "block",
|
||||
environment: "non_production",
|
||||
category: "dangerous",
|
||||
reasons: [],
|
||||
},
|
||||
{ kind: "execute_sql", status: "skipped", reason: "blocked_by_policy" },
|
||||
]);
|
||||
assert.equal(plan.executableSql, undefined);
|
||||
assert.equal(plan.handoffSql, "DROP TABLE users");
|
||||
});
|
||||
|
||||
test("agent plan requires confirmation for production writes", () => {
|
||||
const plan = buildAiAgentPlan(
|
||||
planInput({
|
||||
assistantContent: "```sql\nINSERT INTO users(name) VALUES ('a')\n```",
|
||||
connection: conn({ name: "prod-db", host: "10.0.0.9", database: "app_prod" }),
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(plan.steps[1]?.kind, "risk_check");
|
||||
assert.equal(plan.steps[1]?.status, "done");
|
||||
if (plan.steps[1]?.kind === "risk_check") {
|
||||
assert.equal(plan.steps[1].action, "confirm");
|
||||
assert.equal(plan.steps[1].environment, "production");
|
||||
assert.equal(plan.steps[1].category, "low_risk_write");
|
||||
}
|
||||
assert.deepEqual(plan.steps[2], {
|
||||
kind: "execute_sql",
|
||||
status: "skipped",
|
||||
reason: "requires_confirmation",
|
||||
});
|
||||
assert.equal(plan.executableSql, undefined);
|
||||
assert.equal(plan.handoffSql, "INSERT INTO users(name) VALUES ('a')");
|
||||
});
|
||||
|
||||
test("agent plan skips execution when no SQL block is present", () => {
|
||||
const plan = buildAiAgentPlan(planInput({ assistantContent: "我需要更多信息。" }));
|
||||
|
||||
assert.deepEqual(plan.steps, [
|
||||
{ kind: "generate_sql", status: "skipped", reason: "no_sql" },
|
||||
{ kind: "execute_sql", status: "skipped", reason: "no_sql" },
|
||||
]);
|
||||
assert.equal(plan.executableSql, undefined);
|
||||
});
|
||||
|
||||
test("agent plan ignores comment-only SQL blocks and executes the first real SQL block", () => {
|
||||
const plan = buildAiAgentPlan(
|
||||
planInput({
|
||||
instruction: "查一下当前数据库里有哪些表",
|
||||
assistantContent: [
|
||||
"当前数据库中的表:",
|
||||
"```sql",
|
||||
"-- 当前数据库中的表(仅从已加载的 Schema 上下文得知):",
|
||||
"-- public.ihli_data",
|
||||
"```",
|
||||
"若需查看该表数据,可执行:",
|
||||
"```sql",
|
||||
"SELECT * FROM ihli_data",
|
||||
"LIMIT 10;",
|
||||
"```",
|
||||
].join("\n"),
|
||||
}),
|
||||
);
|
||||
|
||||
assert.deepEqual(plan.steps, [
|
||||
{ kind: "generate_sql", status: "done", sql: "SELECT * FROM ihli_data\nLIMIT 10;" },
|
||||
{
|
||||
kind: "risk_check",
|
||||
status: "done",
|
||||
action: "auto_execute",
|
||||
environment: "non_production",
|
||||
category: "read",
|
||||
reasons: [],
|
||||
},
|
||||
{ kind: "execute_sql", status: "pending", sql: "SELECT * FROM ihli_data\nLIMIT 10;" },
|
||||
]);
|
||||
assert.equal(plan.handoffSql, "SELECT * FROM ihli_data\nLIMIT 10;");
|
||||
});
|
||||
|
||||
test("agent plan treats comment-only SQL responses as no SQL", () => {
|
||||
const plan = buildAiAgentPlan(
|
||||
planInput({
|
||||
assistantContent: "```sql\n-- public.ihli_data\n```",
|
||||
}),
|
||||
);
|
||||
|
||||
assert.deepEqual(plan.steps, [
|
||||
{ kind: "generate_sql", status: "skipped", reason: "no_sql" },
|
||||
{ kind: "execute_sql", status: "skipped", reason: "no_sql" },
|
||||
]);
|
||||
assert.equal(plan.handoffSql, undefined);
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import test from "node:test";
|
||||
import { buildAiAgentStepItems } from "../../src/lib/aiAgentStepPresentation.ts";
|
||||
import type { AiAgentPlan } from "../../src/lib/aiAgentPlan.ts";
|
||||
|
||||
test("presents auto-execute agent plans as completed generation, safety, and execution steps", () => {
|
||||
const plan: AiAgentPlan = {
|
||||
executableSql: "SELECT count(*) FROM users",
|
||||
handoffSql: "SELECT count(*) FROM users",
|
||||
steps: [
|
||||
{ kind: "generate_sql", status: "done", sql: "SELECT count(*) FROM users" },
|
||||
{
|
||||
kind: "risk_check",
|
||||
status: "done",
|
||||
action: "auto_execute",
|
||||
environment: "non_production",
|
||||
category: "read",
|
||||
reasons: [],
|
||||
},
|
||||
{ kind: "execute_sql", status: "pending", sql: "SELECT count(*) FROM users" },
|
||||
],
|
||||
};
|
||||
|
||||
assert.deepEqual(buildAiAgentStepItems(plan), [
|
||||
{ key: "generated", labelKey: "ai.agentSteps.generated", tone: "success" },
|
||||
{
|
||||
key: "safe",
|
||||
labelKey: "ai.agentSteps.safe",
|
||||
titleKey: "ai.agentStepTitles.riskCheck",
|
||||
titleParams: { action: "auto_execute", category: "read", environment: "non_production", reasons: "-" },
|
||||
tone: "success",
|
||||
},
|
||||
{ key: "autoExecute", labelKey: "ai.agentSteps.autoExecute", tone: "active" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("presents blocked and confirmation plans with warning tones", () => {
|
||||
const blocked: AiAgentPlan = {
|
||||
handoffSql: "DROP TABLE users",
|
||||
steps: [
|
||||
{ kind: "generate_sql", status: "done", sql: "DROP TABLE users" },
|
||||
{
|
||||
kind: "risk_check",
|
||||
status: "done",
|
||||
action: "block",
|
||||
environment: "non_production",
|
||||
category: "dangerous",
|
||||
reasons: [],
|
||||
},
|
||||
{ kind: "execute_sql", status: "skipped", reason: "blocked_by_policy" },
|
||||
],
|
||||
};
|
||||
const confirm: AiAgentPlan = {
|
||||
handoffSql: "INSERT INTO users(name) VALUES ('a')",
|
||||
steps: [
|
||||
{ kind: "generate_sql", status: "done", sql: "INSERT INTO users(name) VALUES ('a')" },
|
||||
{
|
||||
kind: "risk_check",
|
||||
status: "done",
|
||||
action: "confirm",
|
||||
environment: "production",
|
||||
category: "low_risk_write",
|
||||
reasons: [],
|
||||
},
|
||||
{ kind: "execute_sql", status: "skipped", reason: "requires_confirmation" },
|
||||
],
|
||||
};
|
||||
|
||||
assert.deepEqual(
|
||||
buildAiAgentStepItems(blocked).map((item) => [item.labelKey, item.tone, item.titleKey, item.titleParams]),
|
||||
[
|
||||
["ai.agentSteps.generated", "success", undefined, undefined],
|
||||
[
|
||||
"ai.agentSteps.blocked",
|
||||
"danger",
|
||||
"ai.agentStepTitles.riskCheck",
|
||||
{ action: "block", category: "dangerous", environment: "non_production", reasons: "-" },
|
||||
],
|
||||
["ai.agentSteps.skipped", "muted", "ai.agentStepTitles.blocked", undefined],
|
||||
],
|
||||
);
|
||||
assert.deepEqual(
|
||||
buildAiAgentStepItems(confirm).map((item) => [item.labelKey, item.tone, item.titleKey, item.titleParams]),
|
||||
[
|
||||
["ai.agentSteps.generated", "success", undefined, undefined],
|
||||
[
|
||||
"ai.agentSteps.needsConfirm",
|
||||
"warning",
|
||||
"ai.agentStepTitles.riskCheck",
|
||||
{ action: "confirm", category: "low_risk_write", environment: "production", reasons: "-" },
|
||||
],
|
||||
["ai.agentSteps.skipped", "muted", "ai.agentStepTitles.requiresConfirmation", undefined],
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("includes risk reasons in risk check titles", () => {
|
||||
const plan: AiAgentPlan = {
|
||||
handoffSql: "INSERT INTO users(name) VALUES ('a'); UPDATE users SET name='b' WHERE id=1",
|
||||
steps: [
|
||||
{
|
||||
kind: "risk_check",
|
||||
status: "done",
|
||||
action: "confirm",
|
||||
environment: "non_production",
|
||||
category: "write",
|
||||
reasons: ["multi_statement"],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
assert.deepEqual(buildAiAgentStepItems(plan)[0], {
|
||||
key: "needsConfirm",
|
||||
labelKey: "ai.agentSteps.needsConfirm",
|
||||
titleKey: "ai.agentStepTitles.riskCheck",
|
||||
titleParams: {
|
||||
action: "confirm",
|
||||
category: "write",
|
||||
environment: "non_production",
|
||||
reasons: "multi_statement",
|
||||
},
|
||||
tone: "warning",
|
||||
});
|
||||
});
|
||||
|
||||
test("presents no-sql and no-intent plans as muted skipped states", () => {
|
||||
const noSql: AiAgentPlan = {
|
||||
steps: [
|
||||
{ kind: "generate_sql", status: "skipped", reason: "no_sql" },
|
||||
{ kind: "execute_sql", status: "skipped", reason: "no_sql" },
|
||||
],
|
||||
};
|
||||
const noIntent: AiAgentPlan = {
|
||||
steps: [
|
||||
{ kind: "generate_sql", status: "done", sql: "SELECT count(*) FROM users" },
|
||||
{ kind: "execute_sql", status: "skipped", reason: "no_execution_intent" },
|
||||
],
|
||||
};
|
||||
|
||||
assert.deepEqual(
|
||||
buildAiAgentStepItems(noSql).map((item) => item.labelKey),
|
||||
["ai.agentSteps.noSql", "ai.agentSteps.skipped"],
|
||||
);
|
||||
assert.deepEqual(
|
||||
buildAiAgentStepItems(noIntent).map((item) => item.labelKey),
|
||||
["ai.agentSteps.generated", "ai.agentSteps.notRequested"],
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import test from "node:test";
|
||||
import type { AiContext } from "../../src/lib/ai.ts";
|
||||
|
||||
class MemoryStorage {
|
||||
private values = new Map<string, string>();
|
||||
|
||||
getItem(key: string): string | null {
|
||||
return this.values.get(key) ?? null;
|
||||
}
|
||||
|
||||
setItem(key: string, value: string) {
|
||||
this.values.set(key, value);
|
||||
}
|
||||
|
||||
removeItem(key: string) {
|
||||
this.values.delete(key);
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.values.clear();
|
||||
}
|
||||
}
|
||||
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
value: new MemoryStorage(),
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
const { buildSystemPrompt } = await import("../../src/lib/ai.ts");
|
||||
|
||||
function context(overrides: Partial<AiContext> = {}): AiContext {
|
||||
return {
|
||||
connectionName: "prod-analytics",
|
||||
databaseType: "postgres",
|
||||
database: "app",
|
||||
currentSql: "",
|
||||
tables: [
|
||||
{
|
||||
schema: "public",
|
||||
name: "orders",
|
||||
tableType: "TABLE",
|
||||
columns: [
|
||||
{ name: "id", data_type: "uuid", is_nullable: false, is_primary_key: true },
|
||||
{ name: "user_id", data_type: "uuid", is_nullable: false },
|
||||
{ name: "total", data_type: "numeric", is_nullable: false },
|
||||
],
|
||||
indexes: [{ name: "idx_orders_user_id", columns: ["user_id"], is_unique: false, is_primary: false }],
|
||||
foreignKeys: [{ column: "user_id", ref_table: "users", ref_column: "id" }],
|
||||
},
|
||||
],
|
||||
truncated: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test("agent mode prompt makes the first SQL block the executable recommendation", () => {
|
||||
const prompt = buildSystemPrompt("generate", context(), "agent");
|
||||
|
||||
assert.match(prompt, /Agent 模式/);
|
||||
assert.match(prompt, /第一个 ```sql 代码块只能包含最终推荐执行的 SQL/);
|
||||
assert.match(prompt, /不要把解释性 SQL、备选 SQL、危险 SQL 放在第一个代码块/);
|
||||
});
|
||||
|
||||
test("ask mode prompt forbids auto-execution assumptions", () => {
|
||||
const prompt = buildSystemPrompt("generate", context(), "ask");
|
||||
|
||||
assert.match(prompt, /Ask 模式/);
|
||||
assert.match(prompt, /只生成 SQL 和说明/);
|
||||
assert.match(prompt, /不要暗示已经执行或即将自动执行/);
|
||||
});
|
||||
|
||||
test("prompt gives explicit guidance for truncated schema context", () => {
|
||||
const prompt = buildSystemPrompt("generate", context({ truncated: true }), "ask");
|
||||
|
||||
assert.match(prompt, /Schema context is truncated/);
|
||||
assert.match(prompt, /如果请求可能涉及未出现的表或字段,不要猜测/);
|
||||
assert.match(prompt, /@table/);
|
||||
});
|
||||
|
||||
test("prompt enforces database dialect and single executable statement safety", () => {
|
||||
const prompt = buildSystemPrompt("generate", context({ databaseType: "sqlserver" }), "agent");
|
||||
|
||||
assert.match(prompt, /严格使用当前数据库方言/);
|
||||
assert.match(prompt, /分页、日期函数、字符串拼接/);
|
||||
assert.match(prompt, /不要生成多语句 SQL/);
|
||||
assert.match(prompt, /不要在同一个回答里混合 SELECT 和写操作/);
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import test from "node:test";
|
||||
import type { AiAction, AiAssistantMode, AiContext } from "../../src/lib/ai.ts";
|
||||
|
||||
class MemoryStorage {
|
||||
private values = new Map<string, string>();
|
||||
|
||||
getItem(key: string): string | null {
|
||||
return this.values.get(key) ?? null;
|
||||
}
|
||||
|
||||
setItem(key: string, value: string) {
|
||||
this.values.set(key, value);
|
||||
}
|
||||
|
||||
removeItem(key: string) {
|
||||
this.values.delete(key);
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.values.clear();
|
||||
}
|
||||
}
|
||||
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
value: new MemoryStorage(),
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
const { buildSystemPrompt } = await import("../../src/lib/ai.ts");
|
||||
|
||||
function baseContext(overrides: Partial<AiContext> = {}): AiContext {
|
||||
return {
|
||||
connectionName: "prod-analytics",
|
||||
databaseType: "postgres",
|
||||
database: "warehouse",
|
||||
currentSql: "select user_id, count(*) from public.orders group by user_id",
|
||||
lastError: undefined,
|
||||
lastResultPreview: 'user_id="u1", count=3\nuser_id="u2", count=7',
|
||||
tables: [
|
||||
{
|
||||
schema: "public",
|
||||
name: "orders",
|
||||
tableType: "TABLE",
|
||||
columns: [
|
||||
{ name: "id", data_type: "uuid", is_nullable: false, is_primary_key: true },
|
||||
{ name: "user_id", data_type: "uuid", is_nullable: false },
|
||||
{ name: "created_at", data_type: "timestamp", is_nullable: false },
|
||||
{ name: "total", data_type: "numeric", is_nullable: false },
|
||||
],
|
||||
indexes: [
|
||||
{ name: "idx_orders_user_id", columns: ["user_id"], is_unique: false, is_primary: false },
|
||||
{ name: "idx_orders_created_at", columns: ["created_at"], is_unique: false, is_primary: false },
|
||||
],
|
||||
foreignKeys: [{ column: "user_id", ref_table: "users", ref_column: "id" }],
|
||||
},
|
||||
{
|
||||
schema: "public",
|
||||
name: "users",
|
||||
tableType: "TABLE",
|
||||
columns: [
|
||||
{ name: "id", data_type: "uuid", is_nullable: false, is_primary_key: true },
|
||||
{ name: "email", data_type: "text", is_nullable: false },
|
||||
],
|
||||
},
|
||||
],
|
||||
truncated: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
interface PromptEvalCase {
|
||||
name: string;
|
||||
action: AiAction;
|
||||
mode: AiAssistantMode;
|
||||
context?: Partial<AiContext>;
|
||||
mustInclude: RegExp[];
|
||||
mustNotInclude?: RegExp[];
|
||||
}
|
||||
|
||||
const cases: PromptEvalCase[] = [
|
||||
{
|
||||
name: "agent generate keeps the first SQL block executable and read-oriented",
|
||||
action: "generate",
|
||||
mode: "agent",
|
||||
mustInclude: [
|
||||
/Agent 模式/,
|
||||
/可直接执行的只读 SQL/,
|
||||
/第一个 ```sql 代码块只能包含最终推荐执行的 SQL/,
|
||||
/不要把解释性 SQL、备选 SQL、危险 SQL 放在第一个代码块/,
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "ask generate never implies auto execution",
|
||||
action: "generate",
|
||||
mode: "ask",
|
||||
mustInclude: [/Ask 模式/, /只生成 SQL 和说明/, /不要暗示已经执行或即将自动执行/],
|
||||
},
|
||||
{
|
||||
name: "truncated schema blocks guessing and points users to table mentions",
|
||||
action: "generate",
|
||||
mode: "ask",
|
||||
context: { truncated: true },
|
||||
mustInclude: [/Schema context is truncated/, /不要猜测/, /@table/, /只读探索查询/],
|
||||
},
|
||||
{
|
||||
name: "sqlserver generation requires dialect-specific pagination and quoting",
|
||||
action: "generate",
|
||||
mode: "agent",
|
||||
context: { databaseType: "sqlserver" },
|
||||
mustInclude: [/Database type: sqlserver/, /严格使用当前数据库方言/, /LIMIT\/TOP\/OFFSET/],
|
||||
},
|
||||
{
|
||||
name: "optimization uses index evidence and calls out full scans",
|
||||
action: "optimize",
|
||||
mode: "ask",
|
||||
mustInclude: [/idx_orders_user_id/, /索引信息/, /全表扫描/, /最多 3 条说明/],
|
||||
},
|
||||
{
|
||||
name: "fixing SQL includes the backend error and a corrected SQL contract",
|
||||
action: "fix",
|
||||
mode: "ask",
|
||||
context: { lastError: 'column "userid" does not exist' },
|
||||
mustInclude: [/Last error:\ncolumn "userid" does not exist/, /修正后的 SQL/, /错误原因/, /改动说明/],
|
||||
},
|
||||
{
|
||||
name: "explain action keeps execution logic, risks, and performance in scope",
|
||||
action: "explain",
|
||||
mode: "ask",
|
||||
mustInclude: [/Current SQL:\nselect user_id/, /执行逻辑/, /风险点/, /性能注意事项/],
|
||||
},
|
||||
{
|
||||
name: "convert action requires target dialect caveats",
|
||||
action: "convert",
|
||||
mode: "ask",
|
||||
mustInclude: [/转换后的 SQL/, /目标方言/, /语法差异或不兼容点/],
|
||||
},
|
||||
{
|
||||
name: "sample data action separates mock data from real production data",
|
||||
action: "sampleData",
|
||||
mode: "ask",
|
||||
mustInclude: [/安全的示例 SQL/, /模拟数据/, /生产库写操作只给建议/],
|
||||
},
|
||||
{
|
||||
name: "base prompt keeps destructive SQL and multi-statement safety rails",
|
||||
action: "generate",
|
||||
mode: "agent",
|
||||
mustInclude: [/不要生成多语句 SQL/, /不要在同一个回答里混合 SELECT 和写操作/, /UPDATE 或 DELETE,必须带 WHERE/],
|
||||
},
|
||||
{
|
||||
name: "schema evidence includes foreign keys and indexes for join reasoning",
|
||||
action: "generate",
|
||||
mode: "ask",
|
||||
mustInclude: [/FK: user_id → users\.id/, /Index: idx_orders_user_id\(user_id\)/, /利用外键关系推断 JOIN 条件/],
|
||||
},
|
||||
];
|
||||
|
||||
for (const item of cases) {
|
||||
test(`AI prompt eval: ${item.name}`, () => {
|
||||
const prompt = buildSystemPrompt(item.action, baseContext(item.context), item.mode);
|
||||
|
||||
for (const pattern of item.mustInclude) {
|
||||
assert.match(prompt, pattern, `${item.name} should include ${pattern}`);
|
||||
}
|
||||
|
||||
for (const pattern of item.mustNotInclude ?? []) {
|
||||
assert.doesNotMatch(prompt, pattern, `${item.name} should not include ${pattern}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import test from "node:test";
|
||||
import type { AiAction } from "../../src/lib/ai.ts";
|
||||
import { AI_SKILL_DEFINITIONS, aiSkillForAction } from "../../src/lib/aiSkills.ts";
|
||||
|
||||
const actions: AiAction[] = ["generate", "explain", "optimize", "fix", "convert", "sampleData"];
|
||||
|
||||
test("defines one internal AI skill per assistant action", () => {
|
||||
assert.deepEqual(AI_SKILL_DEFINITIONS.map((skill) => skill.action).sort(), [...actions].sort());
|
||||
|
||||
for (const action of actions) {
|
||||
const skill = aiSkillForAction(action);
|
||||
|
||||
assert.equal(skill.action, action);
|
||||
assert.ok(skill.id.endsWith("_sql") || skill.id === "sample_data");
|
||||
assert.ok(skill.title.zh);
|
||||
assert.ok(skill.title.en);
|
||||
assert.ok(skill.contextNeeds.length > 0);
|
||||
assert.ok(skill.systemRules.zh.length > 0);
|
||||
assert.ok(skill.systemRules.en.length > 0);
|
||||
assert.ok(skill.userInstruction.zh.includes("SQL"));
|
||||
assert.ok(skill.userInstruction.en.includes("SQL"));
|
||||
}
|
||||
});
|
||||
|
||||
test("captures safety and output contracts for agent-ready skills", () => {
|
||||
const generate = aiSkillForAction("generate");
|
||||
assert.equal(generate.riskPolicy, "readonly_preferred");
|
||||
assert.match(generate.outputContract.zh.join("\n"), /第一个 ```sql 代码块/);
|
||||
assert.match(generate.systemRules.zh.join("\n"), /外键关系/);
|
||||
|
||||
const optimize = aiSkillForAction("optimize");
|
||||
assert.equal(optimize.riskPolicy, "readonly");
|
||||
assert.deepEqual(optimize.contextNeeds, ["currentSql", "schema", "indexes", "foreignKeys"]);
|
||||
assert.match(optimize.systemRules.zh.join("\n"), /索引信息/);
|
||||
assert.match(optimize.outputContract.zh.join("\n"), /最多 3 条说明/);
|
||||
|
||||
const fix = aiSkillForAction("fix");
|
||||
assert.ok(fix.contextNeeds.includes("lastError"));
|
||||
assert.match(fix.outputContract.zh.join("\n"), /错误原因/);
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import test from "node:test";
|
||||
import {
|
||||
classifyAiSqlExecution,
|
||||
classifyConnectionEnvironment,
|
||||
shouldAttemptAiAutoExecute,
|
||||
} from "../../src/lib/aiSqlExecutionPolicy.ts";
|
||||
import type { ConnectionConfig } from "../../src/types/database.ts";
|
||||
|
||||
function conn(overrides: Partial<ConnectionConfig> = {}): ConnectionConfig {
|
||||
return {
|
||||
id: "c1",
|
||||
name: "local-pg",
|
||||
db_type: "postgres",
|
||||
host: "127.0.0.1",
|
||||
port: 5432,
|
||||
username: "postgres",
|
||||
password: "",
|
||||
database: "app_dev",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test("classifyConnectionEnvironment treats local and dev targets as non-production", () => {
|
||||
assert.equal(classifyConnectionEnvironment(conn()), "non_production");
|
||||
assert.equal(classifyConnectionEnvironment(conn({ name: "staging-db", host: "10.0.0.8" })), "non_production");
|
||||
});
|
||||
|
||||
test("classifyConnectionEnvironment treats production signals and unknown targets as production-like", () => {
|
||||
assert.equal(classifyConnectionEnvironment(conn({ name: "prod-db", host: "10.0.0.9" })), "production");
|
||||
assert.equal(classifyConnectionEnvironment(conn({ name: "analytics", host: "10.0.0.9", database: "warehouse" })), "unknown");
|
||||
});
|
||||
|
||||
test("read SQL auto-executes on production and non-production", () => {
|
||||
assert.equal(classifyAiSqlExecution("SELECT * FROM users", conn()).action, "auto_execute");
|
||||
assert.equal(classifyAiSqlExecution("SHOW TABLES", conn({ name: "prod-db" })).action, "auto_execute");
|
||||
});
|
||||
|
||||
test("single insert auto-executes only on non-production targets", () => {
|
||||
assert.equal(classifyAiSqlExecution("INSERT INTO users(name) VALUES ('a')", conn()).action, "auto_execute");
|
||||
assert.equal(classifyAiSqlExecution("INSERT INTO users(name) VALUES ('a')", conn({ name: "prod-db" })).action, "confirm");
|
||||
});
|
||||
|
||||
test("scoped single update auto-executes only on non-production targets", () => {
|
||||
const sql = "UPDATE users SET name = 'a' WHERE id = 1";
|
||||
assert.equal(classifyAiSqlExecution(sql, conn()).action, "auto_execute");
|
||||
assert.equal(classifyAiSqlExecution(sql, conn({ name: "prod-db" })).action, "confirm");
|
||||
});
|
||||
|
||||
test("broad or destructive writes do not auto-execute", () => {
|
||||
assert.equal(classifyAiSqlExecution("UPDATE users SET name = 'a'", conn()).action, "block");
|
||||
assert.equal(classifyAiSqlExecution("UPDATE users SET name = 'a' WHERE 1=1", conn()).action, "block");
|
||||
assert.equal(classifyAiSqlExecution("DELETE FROM users WHERE id = 1", conn()).action, "confirm");
|
||||
assert.equal(classifyAiSqlExecution("DROP TABLE users", conn()).action, "block");
|
||||
});
|
||||
|
||||
test("comments and multi-statement writes do not bypass policy", () => {
|
||||
assert.equal(classifyAiSqlExecution("-- SELECT\nDROP TABLE users", conn()).action, "block");
|
||||
assert.equal(classifyAiSqlExecution("INSERT INTO users(name) VALUES ('a'); UPDATE users SET name='b' WHERE id=1", conn()).action, "confirm");
|
||||
});
|
||||
|
||||
test("AI auto-execution only attempts action-oriented generate requests", () => {
|
||||
assert.equal(shouldAttemptAiAutoExecute("查一下用户数量", "generate"), true);
|
||||
assert.equal(shouldAttemptAiAutoExecute("帮我查ihli的平均值", "generate"), true);
|
||||
assert.equal(shouldAttemptAiAutoExecute("看下 ihli 平均是多少", "generate"), true);
|
||||
assert.equal(shouldAttemptAiAutoExecute("求 ihli 的最大值", "generate"), true);
|
||||
assert.equal(shouldAttemptAiAutoExecute("计算 ihli 总数", "generate"), true);
|
||||
assert.equal(shouldAttemptAiAutoExecute("显示最近 10 条订单", "generate"), true);
|
||||
assert.equal(shouldAttemptAiAutoExecute("获取用户数量", "generate"), true);
|
||||
assert.equal(shouldAttemptAiAutoExecute("show me recent orders", "generate"), true);
|
||||
assert.equal(shouldAttemptAiAutoExecute("只生成 SQL,不要执行", "generate"), false);
|
||||
assert.equal(shouldAttemptAiAutoExecute("先别跑,帮我查一下用户数量", "generate"), false);
|
||||
assert.equal(shouldAttemptAiAutoExecute("优化这条 SQL", "optimize"), false);
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import test from "node:test";
|
||||
import { formatAiTableMention, parseAiTableMentions } from "../../src/lib/aiTableMentions.ts";
|
||||
|
||||
test("parses simple and schema-qualified AI table mentions", () => {
|
||||
assert.deepEqual(parseAiTableMentions("show @users and join @public.orders"), [
|
||||
{ raw: "@users", schema: undefined, table: "users" },
|
||||
{ raw: "@public.orders", schema: "public", table: "orders" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("parses quoted AI table mentions with spaces and dots", () => {
|
||||
assert.deepEqual(parseAiTableMentions('compare @"sales schema"."order.items" with @"audit log"'), [
|
||||
{ raw: '@"sales schema"."order.items"', schema: "sales schema", table: "order.items" },
|
||||
{ raw: '@"audit log"', schema: undefined, table: "audit log" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("deduplicates AI table mentions case-insensitively", () => {
|
||||
assert.deepEqual(parseAiTableMentions("check @public.Users then @PUBLIC.users"), [
|
||||
{ raw: "@public.Users", schema: "public", table: "Users" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("formats AI table mentions with quotes only when needed", () => {
|
||||
assert.equal(formatAiTableMention("public", "users"), "@public.users");
|
||||
assert.equal(formatAiTableMention("sales schema", "order items"), '@"sales schema"."order items"');
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { strict as assert } from "node:assert";
|
||||
import test from "node:test";
|
||||
|
||||
function exportedFunctions(path: string): string[] {
|
||||
const source = readFileSync(path, "utf8");
|
||||
return [...source.matchAll(/^export (?:async )?function (\w+)/gm)].map((match) => match[1]).sort();
|
||||
}
|
||||
|
||||
test("Tauri and HTTP backends expose the same API functions", () => {
|
||||
assert.deepEqual(exportedFunctions("src/lib/http.ts"), exportedFunctions("src/lib/tauri.ts"));
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import test from "node:test";
|
||||
import {
|
||||
getTauriThemeForMode,
|
||||
normalizeAppThemeMode,
|
||||
resolveAppThemeAppearance,
|
||||
} from "../../src/lib/appTheme.ts";
|
||||
|
||||
test("normalizes stored app theme modes", () => {
|
||||
assert.equal(normalizeAppThemeMode("dark"), "dark");
|
||||
assert.equal(normalizeAppThemeMode("light"), "light");
|
||||
assert.equal(normalizeAppThemeMode("system"), "system");
|
||||
assert.equal(normalizeAppThemeMode(null), "light");
|
||||
assert.equal(normalizeAppThemeMode("unexpected"), "light");
|
||||
});
|
||||
|
||||
test("resolves system app theme from current system preference", () => {
|
||||
assert.equal(resolveAppThemeAppearance("light", true), "light");
|
||||
assert.equal(resolveAppThemeAppearance("dark", false), "dark");
|
||||
assert.equal(resolveAppThemeAppearance("system", true), "dark");
|
||||
assert.equal(resolveAppThemeAppearance("system", false), "light");
|
||||
});
|
||||
|
||||
test("maps system app theme mode to native automatic theme", () => {
|
||||
assert.equal(getTauriThemeForMode("light"), "light");
|
||||
assert.equal(getTauriThemeForMode("dark"), "dark");
|
||||
assert.equal(getTauriThemeForMode("system"), null);
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { strict as assert } from "node:assert";
|
||||
import test from "node:test";
|
||||
|
||||
test("toolbar update button can show a red update badge", () => {
|
||||
const source = readFileSync("src/components/layout/AppToolbar.vue", "utf8");
|
||||
|
||||
assert.match(source, /hasUpdateAvailable/);
|
||||
assert.match(source, /v-if="hasUpdateAvailable"/);
|
||||
assert.match(source, /bg-red-500/);
|
||||
});
|
||||
|
||||
test("app schedules hourly silent update checks and clears the timer", () => {
|
||||
const source = readFileSync("src/App.vue", "utf8");
|
||||
|
||||
assert.match(source, /UPDATE_CHECK_INTERVAL_MS\s*=\s*60\s*\*\s*60\s*\*\s*1000/);
|
||||
assert.match(source, /setInterval\(\(\)\s*=>\s*{[\s\S]*checkUpdates\(\{\s*silent:\s*true\s*}\)/);
|
||||
assert.match(source, /clearInterval\(updateCheckTimer\)/);
|
||||
});
|
||||
|
||||
test("app passes update availability to the toolbar badge", () => {
|
||||
const source = readFileSync("src/App.vue", "utf8");
|
||||
|
||||
assert.match(source, /:has-update-available="hasUpdateAvailable"/);
|
||||
});
|
||||
|
||||
test("driver manager entry can show an update count badge", () => {
|
||||
const toolbarSource = readFileSync("src/components/layout/AppToolbar.vue", "utf8");
|
||||
const tabSource = readFileSync("src/components/layout/AppTabBar.vue", "utf8");
|
||||
const appSource = readFileSync("src/App.vue", "utf8");
|
||||
|
||||
assert.match(toolbarSource, /agentDriverUpdateCount/);
|
||||
assert.match(toolbarSource, /v-if="agentDriverUpdateCount > 0"/);
|
||||
assert.match(tabSource, /agentDriverUpdateCount/);
|
||||
assert.match(appSource, /:agent-driver-update-count="agentDriverUpdateCount"/);
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import test from "node:test";
|
||||
import { displayCellValue } from "../../src/lib/cellValue.ts";
|
||||
|
||||
test("displayCellValue returns NULL for null", () => {
|
||||
assert.equal(displayCellValue(null), "NULL");
|
||||
});
|
||||
|
||||
test("displayCellValue returns string representation for booleans", () => {
|
||||
assert.equal(displayCellValue(true), "true");
|
||||
assert.equal(displayCellValue(false), "false");
|
||||
});
|
||||
|
||||
test("displayCellValue returns String for numbers", () => {
|
||||
assert.equal(displayCellValue(42), "42");
|
||||
assert.equal(displayCellValue(-3.14), "-3.14");
|
||||
assert.equal(displayCellValue(0), "0");
|
||||
});
|
||||
|
||||
test("displayCellValue returns String for strings", () => {
|
||||
assert.equal(displayCellValue("hello"), "hello");
|
||||
assert.equal(displayCellValue(""), "");
|
||||
});
|
||||
|
||||
test("displayCellValue does not truncate long strings", () => {
|
||||
const long = "a".repeat(1000);
|
||||
assert.equal(displayCellValue(long), long);
|
||||
});
|
||||
|
||||
test("displayCellValue serializes JSON strings containing complex nested objects", () => {
|
||||
const payload = '{"metadata":{"version":"2.1.0","tags":["alpha","beta"],"flags":{"enabled":true,"nested":{"depth":3,"items":[{"id":1,"label":"a"},{"id":2,"label":"b"}]}}},"data":{"records":[{"key":"k-001","value":42},{"key":"k-002","value":-999}],"summary":{"total":3,"valid":2}}}';
|
||||
assert.equal(displayCellValue(payload), payload);
|
||||
});
|
||||
|
||||
test("displayCellValue handles strings with unicode and special characters", () => {
|
||||
const unicodeStr = "你好世界 🚀🎉 <div>test</div> Line1\nLine2\tTabbed";
|
||||
assert.equal(displayCellValue(unicodeStr), unicodeStr);
|
||||
});
|
||||
|
||||
test("displayCellValue handles large integer and scientific notation as strings", () => {
|
||||
const bigIntStr = "9007199254740991";
|
||||
const sciStr = "1e-10";
|
||||
assert.equal(displayCellValue(bigIntStr), bigIntStr);
|
||||
assert.equal(displayCellValue(sciStr), sciStr);
|
||||
});
|
||||
|
||||
test("displayCellValue handles empty JSON-like strings without alteration", () => {
|
||||
assert.equal(displayCellValue("{}"), "{}");
|
||||
assert.equal(displayCellValue("[]"), "[]");
|
||||
assert.equal(displayCellValue('{"key":"value"}'), '{"key":"value"}');
|
||||
assert.equal(displayCellValue("[1,2,3]"), "[1,2,3]");
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import test from "node:test";
|
||||
import {
|
||||
applyColumnFormatter,
|
||||
buildColumnFormatterKey,
|
||||
resolveColumnFormatter,
|
||||
normalizeColumnFormatter,
|
||||
type ColumnFormatterConfig,
|
||||
} from "../../src/lib/columnFormatter.ts";
|
||||
|
||||
test("formats unix timestamps in seconds, milliseconds, and auto mode", () => {
|
||||
assert.equal(
|
||||
applyColumnFormatter(1715758200, { kind: "datetime", unit: "seconds" }),
|
||||
new Date(1715758200 * 1000).toLocaleString(),
|
||||
);
|
||||
assert.equal(
|
||||
applyColumnFormatter(1715758200000, { kind: "datetime", unit: "milliseconds" }),
|
||||
new Date(1715758200000).toLocaleString(),
|
||||
);
|
||||
assert.equal(
|
||||
applyColumnFormatter("1715758200", { kind: "datetime", unit: "auto" }),
|
||||
new Date(1715758200 * 1000).toLocaleString(),
|
||||
);
|
||||
});
|
||||
|
||||
test("extracts simple JSON paths from object and array strings", () => {
|
||||
const payload = JSON.stringify({ user: { name: "Ada" }, items: [{ id: 7 }] });
|
||||
|
||||
assert.equal(applyColumnFormatter(payload, { kind: "json-path", path: "$.user.name" }), "Ada");
|
||||
assert.equal(applyColumnFormatter(payload, { kind: "json-path", path: "$.items[0].id" }), "7");
|
||||
assert.equal(applyColumnFormatter(payload, { kind: "json-path", path: "$.missing" }), "");
|
||||
});
|
||||
|
||||
test("masks strings while preserving prefix and suffix", () => {
|
||||
assert.equal(applyColumnFormatter("abcdef123456", { kind: "mask", prefix: 3, suffix: 2 }), "abc*******56");
|
||||
assert.equal(applyColumnFormatter("short", { kind: "mask", prefix: 3, suffix: 3 }), "*****");
|
||||
});
|
||||
|
||||
test("falls back to normal display for nulls and invalid formatter input", () => {
|
||||
assert.equal(applyColumnFormatter(null, { kind: "datetime", unit: "auto" }), "NULL");
|
||||
assert.equal(applyColumnFormatter("not json", { kind: "json-path", path: "$.a" }), "not json");
|
||||
assert.deepEqual(normalizeColumnFormatter({ kind: "datetime", unit: "invalid" }), undefined);
|
||||
});
|
||||
|
||||
test("normalizes only supported formatter configs", () => {
|
||||
const config: ColumnFormatterConfig = { kind: "mask", prefix: 2, suffix: 4 };
|
||||
|
||||
assert.deepEqual(normalizeColumnFormatter(config), config);
|
||||
assert.deepEqual(normalizeColumnFormatter({ kind: "json-path", path: "$.a[0]" }), {
|
||||
kind: "json-path",
|
||||
path: "$.a[0]",
|
||||
});
|
||||
assert.equal(normalizeColumnFormatter({ kind: "json-path", path: "a.b" }), undefined);
|
||||
assert.deepEqual(normalizeColumnFormatter({ kind: "custom-template", template: "ID-${value}" }), {
|
||||
kind: "custom-template",
|
||||
template: "ID-${value}",
|
||||
});
|
||||
assert.deepEqual(normalizeColumnFormatter({ kind: "custom-ref", formatterId: "fmt_1" }), {
|
||||
kind: "custom-ref",
|
||||
formatterId: "fmt_1",
|
||||
});
|
||||
});
|
||||
|
||||
test("builds stable formatter keys for table columns", () => {
|
||||
assert.equal(
|
||||
buildColumnFormatterKey({
|
||||
connectionId: "conn",
|
||||
database: "db",
|
||||
schema: "public",
|
||||
tableName: "users",
|
||||
column: "created_at",
|
||||
}),
|
||||
"conn::db::public::users::created_at",
|
||||
);
|
||||
});
|
||||
|
||||
test("applies safe custom formatter templates", () => {
|
||||
assert.equal(applyColumnFormatter("ada", { kind: "custom-template", template: "user:${value}" }), "user:ada");
|
||||
assert.equal(applyColumnFormatter("Ada", { kind: "custom-template", template: "${upper}" }), "ADA");
|
||||
assert.equal(applyColumnFormatter("Ada", { kind: "custom-template", template: "${lower}" }), "ada");
|
||||
assert.equal(applyColumnFormatter("Ada", { kind: "custom-template", template: "${length}" }), "3");
|
||||
assert.equal(applyColumnFormatter(null, { kind: "custom-template", template: "value=${value}" }), "value=NULL");
|
||||
});
|
||||
|
||||
test("resolves saved custom formatter references", () => {
|
||||
assert.deepEqual(
|
||||
resolveColumnFormatter(
|
||||
{ kind: "custom-ref", formatterId: "fmt_1" },
|
||||
{ fmt_1: { id: "fmt_1", name: "User label", template: "user:${value}" } },
|
||||
),
|
||||
{ kind: "custom-template", template: "user:${value}" },
|
||||
);
|
||||
assert.equal(resolveColumnFormatter({ kind: "custom-ref", formatterId: "missing" }, {}), undefined);
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import test from "node:test";
|
||||
import { encryptConfig, decryptConfig, isEncryptedConfig } from "../../src/lib/configCrypto.ts";
|
||||
|
||||
test("encrypts and decrypts config round-trip", async () => {
|
||||
const original = JSON.stringify([{ id: "1", name: "test", password: "secret123" }]);
|
||||
const encrypted = await encryptConfig(original, "my-passphrase");
|
||||
|
||||
assert.equal(encrypted.format, "dbx-encrypted");
|
||||
assert.equal(encrypted.version, 1);
|
||||
assert.ok(encrypted.salt);
|
||||
assert.ok(encrypted.iv);
|
||||
assert.ok(encrypted.data);
|
||||
|
||||
const decrypted = await decryptConfig(encrypted, "my-passphrase");
|
||||
assert.equal(decrypted, original);
|
||||
});
|
||||
|
||||
test("fails to decrypt with wrong passphrase", async () => {
|
||||
const encrypted = await encryptConfig('{"test":true}', "correct-passphrase");
|
||||
|
||||
await assert.rejects(
|
||||
() => decryptConfig(encrypted, "wrong-passphrase"),
|
||||
(err: Error) => err.message === "wrong_passphrase",
|
||||
);
|
||||
});
|
||||
|
||||
test("detects encrypted config format", () => {
|
||||
assert.equal(isEncryptedConfig({ format: "dbx-encrypted", version: 1, salt: "a", iv: "b", data: "c" }), true);
|
||||
assert.equal(isEncryptedConfig({ format: "dbx-config", version: 1, connections: [] }), false);
|
||||
assert.equal(isEncryptedConfig([{ id: "1" }]), false);
|
||||
assert.equal(isEncryptedConfig(null), false);
|
||||
assert.equal(isEncryptedConfig("string"), false);
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { staleConnectionMessage, shouldMarkDisconnected } from "../../src/lib/connectionHealth.ts";
|
||||
|
||||
test("metadata connection errors should turn off connected state", () => {
|
||||
assert.equal(shouldMarkDisconnected(new Error("connection closed by server")), true);
|
||||
assert.equal(shouldMarkDisconnected("broken pipe"), true);
|
||||
assert.equal(shouldMarkDisconnected("operation timed out"), true);
|
||||
});
|
||||
|
||||
test("non-connection metadata errors should not turn off connected state", () => {
|
||||
assert.equal(shouldMarkDisconnected(new Error("permission denied for table users")), false);
|
||||
assert.equal(shouldMarkDisconnected("syntax error near SELECT"), false);
|
||||
});
|
||||
|
||||
test("stale connection errors keep the original backend message", () => {
|
||||
assert.equal(staleConnectionMessage(new Error("connection reset by peer")), "connection reset by peer");
|
||||
assert.equal(staleConnectionMessage("closed"), "closed");
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import type { ConnectionConfig } from "../../src/types/database.ts";
|
||||
import { connectionDriverLabel, connectionEndpointLabel, connectionIconType, connectionOptionSubtitle } from "../../src/lib/connectionPresentation.ts";
|
||||
|
||||
const baseConnection: ConnectionConfig = {
|
||||
id: "conn-1",
|
||||
name: "localhost",
|
||||
db_type: "mysql",
|
||||
driver_profile: "tidb",
|
||||
driver_label: "TiDB",
|
||||
host: "127.0.0.1",
|
||||
port: 4000,
|
||||
username: "root",
|
||||
password: "",
|
||||
database: "test",
|
||||
};
|
||||
|
||||
test("uses driver profile for connection option icon identity", () => {
|
||||
assert.equal(connectionIconType(baseConnection), "tidb");
|
||||
});
|
||||
|
||||
test("builds a compact subtitle for duplicate connection names", () => {
|
||||
assert.equal(connectionDriverLabel(baseConnection), "TiDB");
|
||||
assert.equal(connectionEndpointLabel(baseConnection), "127.0.0.1:4000");
|
||||
assert.equal(connectionOptionSubtitle(baseConnection), "TiDB · 127.0.0.1:4000");
|
||||
});
|
||||
|
||||
test("uses file path as endpoint for local database connections", () => {
|
||||
const sqliteConnection: ConnectionConfig = {
|
||||
...baseConnection,
|
||||
db_type: "sqlite",
|
||||
driver_profile: "sqlite",
|
||||
driver_label: "SQLite",
|
||||
host: "/tmp/local.db",
|
||||
port: 0,
|
||||
};
|
||||
|
||||
assert.equal(connectionOptionSubtitle(sqliteConnection), "SQLite · /tmp/local.db");
|
||||
|
||||
const accessConnection: ConnectionConfig = {
|
||||
...baseConnection,
|
||||
db_type: "access",
|
||||
driver_profile: "access",
|
||||
driver_label: "Microsoft Access",
|
||||
host: "/tmp/Northwind.accdb",
|
||||
port: 0,
|
||||
};
|
||||
|
||||
assert.equal(connectionOptionSubtitle(accessConnection), "Microsoft Access · /tmp/Northwind.accdb");
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import test from "node:test";
|
||||
import { parseConnectionUrl } from "../../src/lib/connectionUrl.ts";
|
||||
|
||||
test("parses postgres connection URLs", () => {
|
||||
assert.deepEqual(parseConnectionUrl("postgresql://alice:secret@db.example.com:5433/app?sslmode=require"), {
|
||||
dbType: "postgres",
|
||||
driverProfile: "postgres",
|
||||
driverLabel: "PostgreSQL",
|
||||
host: "db.example.com",
|
||||
port: 5433,
|
||||
username: "alice",
|
||||
password: "secret",
|
||||
database: "app",
|
||||
urlParams: "sslmode=require",
|
||||
ssl: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("parses mysql URLs with encoded credentials", () => {
|
||||
const parsed = parseConnectionUrl("mysql://root:p%40ss@127.0.0.1/shop?charset=utf8mb4");
|
||||
|
||||
assert.equal(parsed.dbType, "mysql");
|
||||
assert.equal(parsed.driverProfile, "mysql");
|
||||
assert.equal(parsed.host, "127.0.0.1");
|
||||
assert.equal(parsed.port, 3306);
|
||||
assert.equal(parsed.username, "root");
|
||||
assert.equal(parsed.password, "p@ss");
|
||||
assert.equal(parsed.database, "shop");
|
||||
assert.equal(parsed.urlParams, "charset=utf8mb4");
|
||||
});
|
||||
|
||||
test("parses JDBC URLs by using the inner database URL", () => {
|
||||
const postgres = parseConnectionUrl("jdbc:postgresql://alice:secret@db.example.com:5433/app?sslmode=require");
|
||||
assert.equal(postgres.dbType, "postgres");
|
||||
assert.equal(postgres.driverProfile, "postgres");
|
||||
assert.equal(postgres.host, "db.example.com");
|
||||
assert.equal(postgres.port, 5433);
|
||||
assert.equal(postgres.username, "alice");
|
||||
assert.equal(postgres.password, "secret");
|
||||
assert.equal(postgres.database, "app");
|
||||
assert.equal(postgres.urlParams, "sslmode=require");
|
||||
|
||||
const mysql = parseConnectionUrl("jdbc:mysql://root:p%40ss@127.0.0.1:3307/shop?charset=utf8mb4");
|
||||
assert.equal(mysql.dbType, "mysql");
|
||||
assert.equal(mysql.driverProfile, "mysql");
|
||||
assert.equal(mysql.host, "127.0.0.1");
|
||||
assert.equal(mysql.port, 3307);
|
||||
assert.equal(mysql.username, "root");
|
||||
assert.equal(mysql.password, "p@ss");
|
||||
assert.equal(mysql.database, "shop");
|
||||
assert.equal(mysql.urlParams, "charset=utf8mb4");
|
||||
});
|
||||
|
||||
test("parses TDengine WebSocket JDBC URLs", () => {
|
||||
const parsed = parseConnectionUrl("jdbc:TAOS-WS://root:taosdata@td.example.com:6041/power?timezone=UTC");
|
||||
|
||||
assert.equal(parsed.dbType, "tdengine");
|
||||
assert.equal(parsed.driverProfile, "tdengine");
|
||||
assert.equal(parsed.driverLabel, "TDengine");
|
||||
assert.equal(parsed.host, "td.example.com");
|
||||
assert.equal(parsed.port, 6041);
|
||||
assert.equal(parsed.username, "root");
|
||||
assert.equal(parsed.password, "taosdata");
|
||||
assert.equal(parsed.database, "power");
|
||||
assert.equal(parsed.urlParams, "timezone=UTC");
|
||||
});
|
||||
|
||||
test("parses UCanAccess JDBC URLs as Access database files", () => {
|
||||
const parsed = parseConnectionUrl("jdbc:ucanaccess:///Users/me/data/Northwind.accdb;memory=false");
|
||||
|
||||
assert.equal(parsed.dbType, "access");
|
||||
assert.equal(parsed.driverProfile, "access");
|
||||
assert.equal(parsed.driverLabel, "Microsoft Access");
|
||||
assert.equal(parsed.host, "/Users/me/data/Northwind.accdb");
|
||||
assert.equal(parsed.port, 0);
|
||||
assert.equal(parsed.database, "Northwind.accdb");
|
||||
assert.equal(parsed.connectionString, "jdbc:ucanaccess:///Users/me/data/Northwind.accdb;memory=false");
|
||||
});
|
||||
|
||||
test("parses SQL Server JDBC URLs with semicolon properties", () => {
|
||||
const parsed = parseConnectionUrl(
|
||||
"jdbc:sqlserver://sql.example.com:1434;databaseName=erp;user=sa;password=s%40cret;encrypt=true",
|
||||
);
|
||||
|
||||
assert.equal(parsed.dbType, "sqlserver");
|
||||
assert.equal(parsed.driverProfile, "sqlserver");
|
||||
assert.equal(parsed.host, "sql.example.com");
|
||||
assert.equal(parsed.port, 1434);
|
||||
assert.equal(parsed.username, "sa");
|
||||
assert.equal(parsed.password, "s@cret");
|
||||
assert.equal(parsed.database, "erp");
|
||||
assert.equal(parsed.urlParams, "encrypt=true");
|
||||
});
|
||||
|
||||
test("keeps MongoDB URLs as connection strings", () => {
|
||||
const source = "mongodb+srv://reader:secret@cluster.example.com/app?retryWrites=true";
|
||||
const parsed = parseConnectionUrl(source);
|
||||
|
||||
assert.equal(parsed.dbType, "mongodb");
|
||||
assert.equal(parsed.driverProfile, "mongodb");
|
||||
assert.equal(parsed.host, "cluster.example.com");
|
||||
assert.equal(parsed.port, 27017);
|
||||
assert.equal(parsed.database, "app");
|
||||
assert.equal(parsed.connectionString, source);
|
||||
assert.equal(parsed.useMongoUrl, true);
|
||||
assert.equal(parsed.ssl, true);
|
||||
});
|
||||
|
||||
test("uses selected HTTP-compatible profile for HTTP URLs", () => {
|
||||
const parsed = parseConnectionUrl("https://search.example.com:9243", "elasticsearch");
|
||||
|
||||
assert.equal(parsed.dbType, "elasticsearch");
|
||||
assert.equal(parsed.driverProfile, "elasticsearch");
|
||||
assert.equal(parsed.host, "search.example.com");
|
||||
assert.equal(parsed.port, 9243);
|
||||
assert.equal(parsed.ssl, true);
|
||||
});
|
||||
|
||||
test("rejects unsupported URL schemes", () => {
|
||||
assert.throws(() => parseConnectionUrl("ftp://example.com"), /Unsupported connection URL scheme/);
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { connectionUrlPlaceholder } from "../../src/lib/connectionPresentation.ts";
|
||||
|
||||
const expected: Record<string, string> = {
|
||||
mysql: "mysql://user:password@host:port/database",
|
||||
doris: "mysql://user:password@host:port/database",
|
||||
starrocks: "mysql://user:password@host:port/database",
|
||||
postgres: "postgresql://user:password@host:port/database",
|
||||
gaussdb: "postgresql://user:password@host:port/database",
|
||||
redshift: "postgresql://user:password@host:port/database",
|
||||
redis: "redis://:password@host:port/0",
|
||||
sqlite: "sqlite:///absolute/path/to/database.db",
|
||||
duckdb: "duckdb:///absolute/path/to/database.duckdb",
|
||||
access: "jdbc:ucanaccess:///absolute/path/to/database.accdb",
|
||||
mongodb: "mongodb://user:password@host:port/database",
|
||||
clickhouse: "clickhouse://user:password@host:port/database",
|
||||
sqlserver: "mssql://user:password@host:port/database",
|
||||
oracle: "oracle://user:password@host:port/service_name",
|
||||
elasticsearch: "http://user:password@host:port",
|
||||
dameng: "dm://user:password@host:port",
|
||||
tdengine: "tdengine://user:password@host:6041/database",
|
||||
jdbc: "jdbc:mysql://host:3306/database",
|
||||
};
|
||||
|
||||
for (const [dbType, placeholder] of Object.entries(expected)) {
|
||||
test(`returns correct placeholder for ${dbType}`, () => {
|
||||
assert.equal(connectionUrlPlaceholder(dbType as any), placeholder);
|
||||
});
|
||||
}
|
||||
|
||||
test("falls back to postgresql placeholder for unknown type", () => {
|
||||
assert.equal(connectionUrlPlaceholder("unknown" as any), "postgresql://user:password@host:port/database");
|
||||
});
|
||||
|
||||
test("mysql-family types share the same placeholder", () => {
|
||||
const mysql = connectionUrlPlaceholder("mysql");
|
||||
assert.equal(connectionUrlPlaceholder("doris"), mysql);
|
||||
assert.equal(connectionUrlPlaceholder("starrocks"), mysql);
|
||||
});
|
||||
|
||||
test("postgres-family types share the same placeholder", () => {
|
||||
const postgres = connectionUrlPlaceholder("postgres");
|
||||
assert.equal(connectionUrlPlaceholder("gaussdb"), postgres);
|
||||
assert.equal(connectionUrlPlaceholder("redshift"), postgres);
|
||||
});
|
||||
|
||||
test("each placeholder starts with the expected scheme", () => {
|
||||
for (const [dbType, placeholder] of Object.entries(expected)) {
|
||||
const scheme = placeholder.split("://")[0];
|
||||
assert.ok(scheme.length > 0, `${dbType} placeholder should have a scheme`);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { buildCreateDatabaseSql, supportsCreateDatabaseCharset } from "../../src/lib/createDatabaseSql.ts";
|
||||
|
||||
test("builds MySQL create database SQL with charset and collation", () => {
|
||||
assert.equal(
|
||||
buildCreateDatabaseSql({
|
||||
databaseType: "mysql",
|
||||
driverProfile: "mysql",
|
||||
name: "app db",
|
||||
charset: "utf8mb4",
|
||||
collation: "utf8mb4_unicode_ci",
|
||||
}),
|
||||
"CREATE DATABASE `app db` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;",
|
||||
);
|
||||
});
|
||||
|
||||
test("omits charset clauses for non-MySQL database types", () => {
|
||||
assert.equal(
|
||||
buildCreateDatabaseSql({
|
||||
databaseType: "postgres",
|
||||
name: "analytics",
|
||||
charset: "utf8mb4",
|
||||
collation: "utf8mb4_unicode_ci",
|
||||
}),
|
||||
'CREATE DATABASE "analytics";',
|
||||
);
|
||||
});
|
||||
|
||||
test("recognizes MySQL-compatible driver profiles", () => {
|
||||
assert.equal(supportsCreateDatabaseCharset("mysql", "oceanbase"), true);
|
||||
assert.equal(supportsCreateDatabaseCharset("mysql", "doris"), true);
|
||||
assert.equal(supportsCreateDatabaseCharset("postgres", undefined), false);
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import test from "node:test";
|
||||
import { compareDataRows, generateDataSyncSql } from "../../src/lib/dataCompare.ts";
|
||||
|
||||
test("compares rows by primary key and reports added, removed, and modified rows", () => {
|
||||
const diff = compareDataRows({
|
||||
columns: ["id", "name", "active"],
|
||||
keyColumns: ["id"],
|
||||
sourceRows: [
|
||||
[1, "Ada", true],
|
||||
[2, "Bob", false],
|
||||
[4, "Dora", true],
|
||||
],
|
||||
targetRows: [
|
||||
[1, "Ada", true],
|
||||
[2, "Bobby", false],
|
||||
[3, "Cara", true],
|
||||
],
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
diff.added.map((row) => row.keyValues),
|
||||
[{ id: 4 }],
|
||||
);
|
||||
assert.deepEqual(
|
||||
diff.removed.map((row) => row.keyValues),
|
||||
[{ id: 3 }],
|
||||
);
|
||||
assert.deepEqual(
|
||||
diff.modified.map((row) => row.changes),
|
||||
[[{ column: "name", source: "Bob", target: "Bobby" }]],
|
||||
);
|
||||
});
|
||||
|
||||
test("generates data synchronization SQL", () => {
|
||||
const diff = compareDataRows({
|
||||
columns: ["id", "name", "active"],
|
||||
keyColumns: ["id"],
|
||||
sourceRows: [
|
||||
[1, "Ada", true],
|
||||
[2, "Bob", false],
|
||||
],
|
||||
targetRows: [
|
||||
[1, "Ada Lovelace", true],
|
||||
[3, "Cara", true],
|
||||
],
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
generateDataSyncSql({
|
||||
tableName: "users",
|
||||
schema: "public",
|
||||
columns: ["id", "name", "active"],
|
||||
keyColumns: ["id"],
|
||||
diff,
|
||||
databaseType: "postgres",
|
||||
}),
|
||||
[
|
||||
`INSERT INTO "public"."users" ("id", "name", "active") VALUES (2, 'Bob', FALSE);`,
|
||||
`UPDATE "public"."users" SET "name" = 'Ada' WHERE "id" = 1;`,
|
||||
`DELETE FROM "public"."users" WHERE "id" = 3;`,
|
||||
].join("\n"),
|
||||
);
|
||||
});
|
||||
|
||||
test("requires at least one key column", () => {
|
||||
assert.throws(
|
||||
() => compareDataRows({ columns: ["id"], keyColumns: [], sourceRows: [[1]], targetRows: [[1]] }),
|
||||
/At least one key column/,
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects duplicate row keys", () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
compareDataRows({
|
||||
columns: ["id", "name"],
|
||||
keyColumns: ["id"],
|
||||
sourceRows: [
|
||||
[1, "Ada"],
|
||||
[1, "Ada Clone"],
|
||||
],
|
||||
targetRows: [[1, "Ada"]],
|
||||
}),
|
||||
/Duplicate source key/,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import test from "node:test";
|
||||
import { calculateDataGridColumnWidth, DATA_GRID_HEADER_CONTROL_WIDTH } from "../../src/lib/dataGridColumnWidth.ts";
|
||||
|
||||
test("reserves header control space when auto-sizing a column from the header", () => {
|
||||
const width = calculateDataGridColumnWidth({
|
||||
columnName: "created_at",
|
||||
sampleValues: [],
|
||||
});
|
||||
|
||||
assert.equal(width, "created_at".length * 8 + DATA_GRID_HEADER_CONTROL_WIDTH);
|
||||
});
|
||||
|
||||
test("keeps cell content as the sizing driver when it is wider than header controls", () => {
|
||||
const width = calculateDataGridColumnWidth({
|
||||
columnName: "id",
|
||||
sampleValues: ["a long enough value to dominate"],
|
||||
});
|
||||
|
||||
assert.equal(width, "a long enough value to dominate".length * 8 + 28);
|
||||
});
|
||||
@@ -0,0 +1,272 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import test from "node:test";
|
||||
import { computed, nextTick, ref } from "vue";
|
||||
import { createPinia, setActivePinia } from "pinia";
|
||||
import { useDataGridEditor } from "../../src/composables/useDataGridEditor.ts";
|
||||
import type { ColumnInfo } from "../../src/types/database.ts";
|
||||
|
||||
type CellValue = string | number | boolean | null;
|
||||
|
||||
function installBrowserTestGlobals() {
|
||||
globalThis.document = { querySelector: () => null } as unknown as Document;
|
||||
globalThis.localStorage = {
|
||||
getItem: () => null,
|
||||
setItem: () => {},
|
||||
removeItem: () => {},
|
||||
clear: () => {},
|
||||
key: () => null,
|
||||
length: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function column(name: string, isPrimaryKey = false, extra: string | null = null): ColumnInfo {
|
||||
return {
|
||||
name,
|
||||
data_type: "VARCHAR",
|
||||
is_nullable: true,
|
||||
column_default: null,
|
||||
is_primary_key: isPrimaryKey,
|
||||
extra,
|
||||
};
|
||||
}
|
||||
|
||||
test("cloning a row copies non-generated primary key values without executing save", async () => {
|
||||
setActivePinia(createPinia());
|
||||
installBrowserTestGlobals();
|
||||
|
||||
const result = computed(() => ({
|
||||
columns: ["code", "year", "score"],
|
||||
rows: [["AFW", 1995, 35271.907090628745] as CellValue[]],
|
||||
}));
|
||||
const rowStatusFilter = ref<"all" | "changed" | "edited" | "new" | "deleted">("all");
|
||||
let saveCalls = 0;
|
||||
let editor: ReturnType<typeof useDataGridEditor>;
|
||||
|
||||
editor = useDataGridEditor({
|
||||
result,
|
||||
editable: computed(() => true),
|
||||
databaseType: computed(() => "postgres"),
|
||||
connectionId: computed(() => undefined),
|
||||
database: computed(() => undefined),
|
||||
tableMeta: computed(() => ({
|
||||
tableName: "metrics",
|
||||
columns: [column("code", true), column("year", true), column("score")],
|
||||
primaryKeys: ["code", "year"],
|
||||
})),
|
||||
onExecuteSql: computed(() => undefined),
|
||||
customSave: computed(() => async () => {
|
||||
saveCalls += 1;
|
||||
}),
|
||||
sql: computed(() => undefined),
|
||||
searchText: ref(""),
|
||||
whereFilterInput: ref(""),
|
||||
orderByInput: ref(""),
|
||||
rowStatusFilter,
|
||||
pageSize: ref(100),
|
||||
currentPage: ref(1),
|
||||
getRowItem: (rowId) => {
|
||||
if (rowId === 0) {
|
||||
return {
|
||||
id: 0,
|
||||
sourceIndex: 0,
|
||||
data: result.value.rows[0],
|
||||
isNew: false,
|
||||
isDeleted: false,
|
||||
isDirtyCol: [false, false, false],
|
||||
status: "clean",
|
||||
};
|
||||
}
|
||||
if (rowId < 0) {
|
||||
const newIndex = -rowId - 1;
|
||||
const row = editor.newRows.value[newIndex];
|
||||
if (!row) return undefined;
|
||||
return {
|
||||
id: rowId,
|
||||
newIndex,
|
||||
data: row,
|
||||
isNew: true,
|
||||
isDeleted: false,
|
||||
isDirtyCol: [false, false, false],
|
||||
status: "new",
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
emit: () => {},
|
||||
});
|
||||
|
||||
editor.cloneRow(0);
|
||||
await nextTick();
|
||||
|
||||
assert.equal(saveCalls, 0);
|
||||
assert.deepEqual(editor.newRows.value, [["AFW", 1995, 35271.907090628745]]);
|
||||
assert.equal(editor.transactionActive.value, true);
|
||||
assert.deepEqual(editor.editingCell.value, { rowId: -1, col: 0 });
|
||||
|
||||
await editor.saveChanges();
|
||||
|
||||
assert.equal(saveCalls, 1);
|
||||
assert.deepEqual(editor.newRows.value, []);
|
||||
});
|
||||
|
||||
test("cloning a row clears auto-generated key columns", async () => {
|
||||
setActivePinia(createPinia());
|
||||
installBrowserTestGlobals();
|
||||
|
||||
const result = computed(() => ({
|
||||
columns: ["id", "name"],
|
||||
rows: [[1, "Ada"] as CellValue[]],
|
||||
}));
|
||||
const rowStatusFilter = ref<"all" | "changed" | "edited" | "new" | "deleted">("all");
|
||||
let editor: ReturnType<typeof useDataGridEditor>;
|
||||
|
||||
editor = useDataGridEditor({
|
||||
result,
|
||||
editable: computed(() => true),
|
||||
databaseType: computed(() => "mysql"),
|
||||
connectionId: computed(() => undefined),
|
||||
database: computed(() => undefined),
|
||||
tableMeta: computed(() => ({
|
||||
tableName: "people",
|
||||
columns: [column("id", true, "auto_increment"), column("name")],
|
||||
primaryKeys: ["id"],
|
||||
})),
|
||||
onExecuteSql: computed(() => undefined),
|
||||
customSave: computed(() => undefined),
|
||||
sql: computed(() => undefined),
|
||||
searchText: ref(""),
|
||||
whereFilterInput: ref(""),
|
||||
orderByInput: ref(""),
|
||||
rowStatusFilter,
|
||||
pageSize: ref(100),
|
||||
currentPage: ref(1),
|
||||
getRowItem: (rowId) => {
|
||||
if (rowId !== 0) return undefined;
|
||||
return {
|
||||
id: 0,
|
||||
sourceIndex: 0,
|
||||
data: result.value.rows[0],
|
||||
isNew: false,
|
||||
isDeleted: false,
|
||||
isDirtyCol: [false, false],
|
||||
status: "clean",
|
||||
};
|
||||
},
|
||||
emit: () => {},
|
||||
});
|
||||
|
||||
editor.cloneRow(0);
|
||||
await nextTick();
|
||||
|
||||
assert.deepEqual(editor.newRows.value, [[null, "Ada"]]);
|
||||
});
|
||||
|
||||
test("saving deleted rows reloads current table data", async () => {
|
||||
setActivePinia(createPinia());
|
||||
installBrowserTestGlobals();
|
||||
|
||||
const result = computed(() => ({
|
||||
columns: ["id", "name"],
|
||||
rows: [[1, "Ada"] as CellValue[]],
|
||||
}));
|
||||
const rowStatusFilter = ref<"all" | "changed" | "edited" | "new" | "deleted">("all");
|
||||
const emitted: unknown[][] = [];
|
||||
let editor: ReturnType<typeof useDataGridEditor>;
|
||||
|
||||
editor = useDataGridEditor({
|
||||
result,
|
||||
editable: computed(() => true),
|
||||
databaseType: computed(() => "postgres"),
|
||||
connectionId: computed(() => "conn-1"),
|
||||
database: computed(() => "main"),
|
||||
tableMeta: computed(() => ({
|
||||
tableName: "people",
|
||||
columns: [column("id", true), column("name")],
|
||||
primaryKeys: ["id"],
|
||||
})),
|
||||
onExecuteSql: computed(() => undefined),
|
||||
customSave: computed(() => async () => {}),
|
||||
sql: computed(() => "SELECT id, name FROM people"),
|
||||
searchText: ref("ada"),
|
||||
whereFilterInput: ref("name ILIKE '%a%'"),
|
||||
orderByInput: ref("id DESC"),
|
||||
rowStatusFilter,
|
||||
pageSize: ref(50),
|
||||
currentPage: ref(3),
|
||||
getRowItem: (rowId) => {
|
||||
if (rowId !== 0) return undefined;
|
||||
return {
|
||||
id: 0,
|
||||
sourceIndex: 0,
|
||||
data: result.value.rows[0],
|
||||
isNew: false,
|
||||
isDeleted: false,
|
||||
isDirtyCol: [false, false],
|
||||
status: "clean",
|
||||
};
|
||||
},
|
||||
emit: (...args) => {
|
||||
emitted.push(args);
|
||||
},
|
||||
});
|
||||
|
||||
editor.applyDeleteRow(0);
|
||||
await editor.saveChanges();
|
||||
|
||||
assert.deepEqual(emitted, [["reload", "SELECT id, name FROM people", "ada", "name ILIKE '%a%'", "id DESC", 50, 100]]);
|
||||
});
|
||||
|
||||
test("saving edited rows without deletes does not reload table data", async () => {
|
||||
setActivePinia(createPinia());
|
||||
installBrowserTestGlobals();
|
||||
|
||||
const result = computed(() => ({
|
||||
columns: ["id", "name"],
|
||||
rows: [[1, "Ada"] as CellValue[]],
|
||||
}));
|
||||
const rowStatusFilter = ref<"all" | "changed" | "edited" | "new" | "deleted">("all");
|
||||
const emitted: unknown[][] = [];
|
||||
let editor: ReturnType<typeof useDataGridEditor>;
|
||||
|
||||
editor = useDataGridEditor({
|
||||
result,
|
||||
editable: computed(() => true),
|
||||
databaseType: computed(() => "postgres"),
|
||||
connectionId: computed(() => "conn-1"),
|
||||
database: computed(() => "main"),
|
||||
tableMeta: computed(() => ({
|
||||
tableName: "people",
|
||||
columns: [column("id", true), column("name")],
|
||||
primaryKeys: ["id"],
|
||||
})),
|
||||
onExecuteSql: computed(() => undefined),
|
||||
customSave: computed(() => async () => {}),
|
||||
sql: computed(() => "SELECT id, name FROM people"),
|
||||
searchText: ref(""),
|
||||
whereFilterInput: ref(""),
|
||||
orderByInput: ref(""),
|
||||
rowStatusFilter,
|
||||
pageSize: ref(50),
|
||||
currentPage: ref(1),
|
||||
getRowItem: (rowId) => {
|
||||
if (rowId !== 0) return undefined;
|
||||
return {
|
||||
id: 0,
|
||||
sourceIndex: 0,
|
||||
data: result.value.rows[0],
|
||||
isNew: false,
|
||||
isDeleted: false,
|
||||
isDirtyCol: [false, false],
|
||||
status: "clean",
|
||||
};
|
||||
},
|
||||
emit: (...args) => {
|
||||
emitted.push(args);
|
||||
},
|
||||
});
|
||||
|
||||
editor.applyCellValue(0, 1, "Ada Lovelace");
|
||||
await editor.saveChanges();
|
||||
|
||||
assert.deepEqual(emitted, []);
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import test from "node:test";
|
||||
import { dataGridHeaderContentWidth, scrollbarGutterWidth } from "../../src/lib/dataGridScrollGutter.ts";
|
||||
|
||||
test("calculates scrollbar gutter from element dimensions", () => {
|
||||
assert.equal(scrollbarGutterWidth({ offsetWidth: 1000, clientWidth: 985 }), 15);
|
||||
assert.equal(scrollbarGutterWidth({ offsetWidth: 1000, clientWidth: 1000 }), 0);
|
||||
});
|
||||
|
||||
test("extends header content width by the body scrollbar gutter", () => {
|
||||
assert.equal(dataGridHeaderContentWidth("var(--total-w)", 15), "calc(var(--total-w) + 15px)");
|
||||
assert.equal(dataGridHeaderContentWidth("var(--total-w)", 0), "var(--total-w)");
|
||||
});
|
||||
@@ -0,0 +1,497 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import test from "node:test";
|
||||
import {
|
||||
buildDataGridRollbackStatements,
|
||||
buildDataGridSaveStatements,
|
||||
dataGridSaveExecutionSchema,
|
||||
normalizeDataGridSaveError,
|
||||
formatGridSqlLiteral,
|
||||
validateDataGridSave,
|
||||
} from "../../src/lib/dataGridSql.ts";
|
||||
import { DBX_NEO4J_ELEMENT_ID_COLUMN } from "../../src/lib/tableEditing.ts";
|
||||
|
||||
test("builds SQL Server grid save statements with schema and bracket quoting", () => {
|
||||
const statements = buildDataGridSaveStatements({
|
||||
databaseType: "sqlserver",
|
||||
tableMeta: {
|
||||
schema: "game",
|
||||
tableName: "player states",
|
||||
primaryKeys: ["role id"],
|
||||
},
|
||||
columns: ["role id", "state", "updated at"],
|
||||
rows: [[42, "old", "2026-05-03"]],
|
||||
dirtyRows: [
|
||||
[
|
||||
0,
|
||||
[
|
||||
[1, "ready"],
|
||||
[2, "2026-05-04"],
|
||||
],
|
||||
],
|
||||
],
|
||||
deletedRows: [0],
|
||||
newRows: [[43, "new", "2026-05-05"]],
|
||||
});
|
||||
|
||||
assert.deepEqual(statements, [
|
||||
"UPDATE [game].[player states] SET [state] = N'ready', [updated at] = N'2026-05-04' WHERE [role id] = 42;",
|
||||
"DELETE FROM [game].[player states] WHERE [role id] = 42;",
|
||||
"INSERT INTO [game].[player states] ([role id], [state], [updated at]) VALUES (43, N'new', N'2026-05-05');",
|
||||
]);
|
||||
});
|
||||
|
||||
test("builds Access grid save statements with backtick identifiers", () => {
|
||||
const statements = buildDataGridSaveStatements({
|
||||
databaseType: "access",
|
||||
tableMeta: {
|
||||
tableName: "Order Details",
|
||||
primaryKeys: ["Order ID"],
|
||||
},
|
||||
columns: ["Order ID", "Product Name", "Active"],
|
||||
rows: [[42, "Old", true]],
|
||||
dirtyRows: [[0, [[1, "Ready"]]]],
|
||||
deletedRows: [0],
|
||||
newRows: [[43, "New", false]],
|
||||
});
|
||||
|
||||
assert.deepEqual(statements, [
|
||||
"UPDATE `Order Details` SET `Product Name` = 'Ready' WHERE `Order ID` = 42;",
|
||||
"DELETE FROM `Order Details` WHERE `Order ID` = 42;",
|
||||
"INSERT INTO `Order Details` (`Order ID`, `Product Name`, `Active`) VALUES (43, 'New', FALSE);",
|
||||
]);
|
||||
});
|
||||
|
||||
test("builds Access grid save statements with row predicates when primary keys are unavailable", () => {
|
||||
const statements = buildDataGridSaveStatements({
|
||||
databaseType: "access",
|
||||
tableMeta: {
|
||||
tableName: "orders",
|
||||
primaryKeys: [],
|
||||
},
|
||||
columns: ["id", "quantity", "status", "shipped_at"],
|
||||
rows: [[1, 3, "pending", null]],
|
||||
dirtyRows: [[0, [[1, 4]]]],
|
||||
deletedRows: [0],
|
||||
newRows: [],
|
||||
});
|
||||
|
||||
assert.deepEqual(statements, [
|
||||
"UPDATE `orders` SET `quantity` = 4 WHERE `id` = 1 AND `quantity` = 3 AND `status` = 'pending' AND `shipped_at` IS NULL;",
|
||||
"DELETE FROM `orders` WHERE `id` = 1 AND `quantity` = 3 AND `status` = 'pending' AND `shipped_at` IS NULL;",
|
||||
]);
|
||||
});
|
||||
|
||||
test("builds grid save statements through source columns for aliased query results", () => {
|
||||
const statements = buildDataGridSaveStatements({
|
||||
databaseType: "postgres",
|
||||
tableMeta: {
|
||||
schema: "public",
|
||||
tableName: "ihli_data",
|
||||
primaryKeys: ["iso3", "year"],
|
||||
},
|
||||
columns: ["country_code", "report_year", "country_name", "ihli_rank"],
|
||||
sourceColumns: ["iso3", "year", "country_name", "rank"],
|
||||
rows: [["LUX", 2007, "Luxembourg", 1]],
|
||||
dirtyRows: [[0, [[2, "Luxembourg City"]]]],
|
||||
deletedRows: [0],
|
||||
newRows: [["USA", 2008, "United States", 2]],
|
||||
});
|
||||
|
||||
assert.deepEqual(statements, [
|
||||
`UPDATE "public"."ihli_data" SET "country_name" = 'Luxembourg City' WHERE "iso3" = 'LUX' AND "year" = 2007;`,
|
||||
`DELETE FROM "public"."ihli_data" WHERE "iso3" = 'LUX' AND "year" = 2007;`,
|
||||
`INSERT INTO "public"."ihli_data" ("iso3", "year", "country_name", "rank") VALUES ('USA', 2008, 'United States', 2);`,
|
||||
]);
|
||||
});
|
||||
|
||||
test("skips expression-only result columns when saving aliased query results", () => {
|
||||
const statements = buildDataGridSaveStatements({
|
||||
databaseType: "postgres",
|
||||
tableMeta: {
|
||||
schema: "public",
|
||||
tableName: "ihli_data",
|
||||
primaryKeys: ["iso3", "year"],
|
||||
},
|
||||
columns: ["iso3", "year", "country_name", "score"],
|
||||
sourceColumns: ["iso3", "year", "country_name", undefined],
|
||||
rows: [["LUX", 2007, "Luxembourg", 50242.1]],
|
||||
dirtyRows: [
|
||||
[
|
||||
0,
|
||||
[
|
||||
[2, "Luxembourg City"],
|
||||
[3, 999],
|
||||
],
|
||||
],
|
||||
],
|
||||
deletedRows: [],
|
||||
newRows: [["USA", 2008, "United States", 43000]],
|
||||
});
|
||||
|
||||
assert.deepEqual(statements, [
|
||||
`UPDATE "public"."ihli_data" SET "country_name" = 'Luxembourg City' WHERE "iso3" = 'LUX' AND "year" = 2007;`,
|
||||
`INSERT INTO "public"."ihli_data" ("iso3", "year", "country_name") VALUES ('USA', 2008, 'United States');`,
|
||||
]);
|
||||
});
|
||||
|
||||
test("builds Hive grid save statements with backtick identifiers", () => {
|
||||
const statements = buildDataGridSaveStatements({
|
||||
databaseType: "hive",
|
||||
tableMeta: {
|
||||
tableName: "department states",
|
||||
primaryKeys: ["dept id"],
|
||||
},
|
||||
columns: ["dept id", "display name"],
|
||||
rows: [[10, "Sales"]],
|
||||
dirtyRows: [[0, [[1, "Marketing"]]]],
|
||||
deletedRows: [0],
|
||||
newRows: [[20, "Engineering"]],
|
||||
});
|
||||
|
||||
assert.deepEqual(statements, [
|
||||
"UPDATE `department states` SET `display name` = 'Marketing' WHERE `dept id` = 10;",
|
||||
"DELETE FROM `department states` WHERE `dept id` = 10;",
|
||||
"INSERT INTO `department states` (`dept id`, `display name`) VALUES (20, 'Engineering');",
|
||||
]);
|
||||
});
|
||||
|
||||
test("builds Hive grid save statements without primary keys using row predicates", () => {
|
||||
const statements = buildDataGridSaveStatements({
|
||||
databaseType: "hive",
|
||||
tableMeta: {
|
||||
tableName: "departments",
|
||||
primaryKeys: [],
|
||||
},
|
||||
columns: ["id", "name", "location"],
|
||||
rows: [[10, "Sales", null]],
|
||||
dirtyRows: [[0, [[1, "Marketing"]]]],
|
||||
deletedRows: [0],
|
||||
newRows: [],
|
||||
});
|
||||
|
||||
assert.deepEqual(statements, [
|
||||
"UPDATE `departments` SET `name` = 'Marketing' WHERE `id` = 10 AND `name` = 'Sales' AND `location` IS NULL;",
|
||||
"DELETE FROM `departments` WHERE `id` = 10 AND `name` = 'Sales' AND `location` IS NULL;",
|
||||
]);
|
||||
});
|
||||
|
||||
test("builds TDengine append-only grid save statements", () => {
|
||||
const newTs = tdengineTimestampLiteral("2026-05-16 09:40:00.000");
|
||||
const statements = buildDataGridSaveStatements({
|
||||
databaseType: "tdengine",
|
||||
tableMeta: {
|
||||
schema: "test_db",
|
||||
tableName: "meters",
|
||||
primaryKeys: ["ts"],
|
||||
},
|
||||
columns: ["ts", "current", "voltage", "location", "groupid"],
|
||||
rows: [["2026-05-16 09:35:57.000", 10.3, 219, "Beijing", 1]],
|
||||
dirtyRows: [[0, [[1, 11.1]]]],
|
||||
deletedRows: [0],
|
||||
newRows: [["2026-05-16 09:40:00.000", 12.1, 220, "Shenzhen", 3]],
|
||||
});
|
||||
|
||||
assert.deepEqual(statements, [
|
||||
`INSERT INTO \`test_db\`.\`meters\` (\`ts\`, \`current\`, \`voltage\`, \`location\`, \`groupid\`) VALUES (${newTs}, 12.1, 220, 'Shenzhen', 3);`,
|
||||
]);
|
||||
});
|
||||
|
||||
test("builds TDengine supertable overwrites as full-row inserts by tbname and timestamp", () => {
|
||||
const existingTs = tdengineTimestampLiteral("2026-05-16 09:35:57.000");
|
||||
const newTs = tdengineTimestampLiteral("2026-05-16 09:40:00.000");
|
||||
const statements = buildDataGridSaveStatements({
|
||||
databaseType: "tdengine",
|
||||
tableMeta: {
|
||||
schema: "test_db",
|
||||
tableName: "meters",
|
||||
primaryKeys: ["tbname", "ts"],
|
||||
columns: [
|
||||
{ name: "ts", data_type: "TIMESTAMP", is_nullable: false, is_primary_key: true },
|
||||
{ name: "current", data_type: "FLOAT", is_nullable: true, is_primary_key: false },
|
||||
{ name: "voltage", data_type: "INT", is_nullable: true, is_primary_key: false },
|
||||
{ name: "location", data_type: "NCHAR", is_nullable: true, is_primary_key: false, extra: "TAG" },
|
||||
{ name: "groupid", data_type: "INT", is_nullable: true, is_primary_key: false, extra: "TAG" },
|
||||
],
|
||||
},
|
||||
columns: ["tbname", "ts", "current", "voltage", "location", "groupid"],
|
||||
rows: [["d1001", "2026-05-16 09:35:57.000", 10.3, 219, "Beijing", 1]],
|
||||
dirtyRows: [[0, [[2, 11.1]]]],
|
||||
deletedRows: [0],
|
||||
newRows: [["d1003", "2026-05-16 09:40:00.000", 12.1, 220, "Shenzhen", 3]],
|
||||
});
|
||||
|
||||
assert.deepEqual(statements, [
|
||||
`INSERT INTO \`test_db\`.\`meters\` (\`tbname\`, \`ts\`, \`current\`, \`voltage\`, \`location\`, \`groupid\`) VALUES ('d1001', ${existingTs}, 11.1, 219, 'Beijing', 1);`,
|
||||
`INSERT INTO \`test_db\`.\`meters\` (\`tbname\`, \`ts\`, \`current\`, \`voltage\`, \`location\`, \`groupid\`) VALUES ('d1003', ${newTs}, 12.1, 220, 'Shenzhen', 3);`,
|
||||
]);
|
||||
});
|
||||
|
||||
test("builds TDengine child table overwrites without pseudo or tag columns", () => {
|
||||
const existingTs = tdengineTimestampLiteral("2026-05-16 09:35:57.000");
|
||||
const newTs = tdengineTimestampLiteral("2026-05-16 09:40:00.000");
|
||||
const statements = buildDataGridSaveStatements({
|
||||
databaseType: "tdengine",
|
||||
tableMeta: {
|
||||
schema: "test_db",
|
||||
tableName: "d1001",
|
||||
primaryKeys: ["tbname", "ts"],
|
||||
columns: [
|
||||
{ name: "ts", data_type: "TIMESTAMP", is_nullable: false, is_primary_key: true },
|
||||
{ name: "current", data_type: "FLOAT", is_nullable: true, is_primary_key: false },
|
||||
{ name: "voltage", data_type: "INT", is_nullable: true, is_primary_key: false },
|
||||
{ name: "location", data_type: "NCHAR", is_nullable: true, is_primary_key: false, extra: "TAG" },
|
||||
{ name: "groupid", data_type: "INT", is_nullable: true, is_primary_key: false, extra: "TAG" },
|
||||
],
|
||||
},
|
||||
columns: ["tbname", "ts", "current", "voltage", "location", "groupid"],
|
||||
rows: [["d1001", "2026-05-16 09:35:57.000", 10.3, 219, "Beijing", 1]],
|
||||
dirtyRows: [[0, [[2, 11.1]]]],
|
||||
deletedRows: [],
|
||||
newRows: [["d1001", "2026-05-16 09:40:00.000", 12.1, 220, "Beijing", 1]],
|
||||
});
|
||||
|
||||
assert.deepEqual(statements, [
|
||||
`INSERT INTO \`test_db\`.\`d1001\` (\`ts\`, \`current\`, \`voltage\`) VALUES (${existingTs}, 11.1, 219);`,
|
||||
`INSERT INTO \`test_db\`.\`d1001\` (\`ts\`, \`current\`, \`voltage\`) VALUES (${newTs}, 12.1, 220);`,
|
||||
]);
|
||||
});
|
||||
|
||||
test("formats TDengine timestamp literals with the local timezone offset", () => {
|
||||
assert.equal(
|
||||
formatGridSqlLiteral("2026-05-16 09:35:57.975", "tdengine"),
|
||||
tdengineTimestampLiteral("2026-05-16 09:35:57.975"),
|
||||
);
|
||||
});
|
||||
|
||||
function tdengineTimestampLiteral(text: string): string {
|
||||
const [datePart, timePart] = text.split(" ");
|
||||
const [time, rawFraction = ""] = timePart.split(".");
|
||||
const fraction = `.${rawFraction.padEnd(3, "0").slice(0, 3)}`;
|
||||
const date = new Date(`${datePart}T${time}${fraction}`);
|
||||
const offsetMinutes = date.getTimezoneOffset();
|
||||
const sign = offsetMinutes <= 0 ? "+" : "-";
|
||||
const abs = Math.abs(offsetMinutes);
|
||||
const hours = String(Math.floor(abs / 60)).padStart(2, "0");
|
||||
const minutes = String(abs % 60).padStart(2, "0");
|
||||
return `'${datePart}T${time}${fraction}${sign}${hours}:${minutes}'`;
|
||||
}
|
||||
|
||||
test("builds Trino insert statements with schema-qualified identifiers", () => {
|
||||
const statements = buildDataGridSaveStatements({
|
||||
databaseType: "trino",
|
||||
tableMeta: {
|
||||
schema: "tiny",
|
||||
tableName: "nation",
|
||||
primaryKeys: [],
|
||||
},
|
||||
columns: ["nationkey", "name"],
|
||||
rows: [],
|
||||
dirtyRows: [],
|
||||
deletedRows: [],
|
||||
newRows: [[100, "Atlantis"]],
|
||||
});
|
||||
|
||||
assert.deepEqual(statements, ['INSERT INTO "tiny"."nation" ("nationkey", "name") VALUES (100, \'Atlantis\');']);
|
||||
});
|
||||
|
||||
test("builds Informix grid save statements without delimited identifiers", () => {
|
||||
const statements = buildDataGridSaveStatements({
|
||||
databaseType: "informix",
|
||||
tableMeta: {
|
||||
schema: "testdb",
|
||||
tableName: "dbx_grid_edit_probe",
|
||||
primaryKeys: ["id"],
|
||||
},
|
||||
columns: ["id", "name"],
|
||||
rows: [[1, "before"]],
|
||||
dirtyRows: [[0, [[1, "after"]]]],
|
||||
deletedRows: [0],
|
||||
newRows: [[2, "new"]],
|
||||
});
|
||||
|
||||
assert.deepEqual(statements, [
|
||||
"UPDATE dbx_grid_edit_probe SET name = 'after' WHERE id = 1;",
|
||||
"DELETE FROM dbx_grid_edit_probe WHERE id = 1;",
|
||||
"INSERT INTO dbx_grid_edit_probe (id, name) VALUES (2, 'new');",
|
||||
]);
|
||||
});
|
||||
|
||||
test("uses Oracle ROWID as a synthetic key without writing it as a normal column", () => {
|
||||
const statements = buildDataGridSaveStatements({
|
||||
databaseType: "oracle",
|
||||
tableMeta: {
|
||||
schema: "DBXTEST",
|
||||
tableName: "DBX_LOAD_TABLE_006",
|
||||
primaryKeys: ["__DBX_ROWID"],
|
||||
},
|
||||
columns: ["__DBX_ROWID", "ID", "CITY", "NOTE"],
|
||||
rows: [["AAATiBAABAAABrXAAA", 1, "上海", "old"]],
|
||||
dirtyRows: [[0, [[2, "北京"]]]],
|
||||
deletedRows: [0],
|
||||
newRows: [[null, 2, "广州", "new"]],
|
||||
});
|
||||
|
||||
assert.deepEqual(statements, [
|
||||
`UPDATE "DBXTEST"."DBX_LOAD_TABLE_006" SET "CITY" = '北京' WHERE ROWIDTOCHAR(ROWID) = 'AAATiBAABAAABrXAAA';`,
|
||||
`DELETE FROM "DBXTEST"."DBX_LOAD_TABLE_006" WHERE ROWIDTOCHAR(ROWID) = 'AAATiBAABAAABrXAAA';`,
|
||||
`INSERT INTO "DBXTEST"."DBX_LOAD_TABLE_006" ("ID", "CITY", "NOTE") VALUES (2, '广州', 'new');`,
|
||||
]);
|
||||
});
|
||||
|
||||
test("builds Neo4j grid save statements with elementId keys", () => {
|
||||
const statements = buildDataGridSaveStatements({
|
||||
databaseType: "neo4j",
|
||||
tableMeta: {
|
||||
tableName: "Employee",
|
||||
primaryKeys: [DBX_NEO4J_ELEMENT_ID_COLUMN],
|
||||
},
|
||||
columns: [DBX_NEO4J_ELEMENT_ID_COLUMN, "name", "active"],
|
||||
rows: [["4:abc:7", "Ada", true]],
|
||||
dirtyRows: [[0, [[1, "Grace"]]]],
|
||||
deletedRows: [0],
|
||||
newRows: [[null, "Linus", false]],
|
||||
});
|
||||
|
||||
assert.deepEqual(statements, [
|
||||
"MATCH (n:`Employee`) WHERE elementId(n) = '4:abc:7' SET n.`name` = 'Grace';",
|
||||
"MATCH (n:`Employee`) WHERE elementId(n) = '4:abc:7' DETACH DELETE n;",
|
||||
"CREATE (n:`Employee` {`name`: 'Linus', `active`: FALSE});",
|
||||
]);
|
||||
});
|
||||
|
||||
test("builds best-effort Neo4j rollback statements", () => {
|
||||
const statements = buildDataGridRollbackStatements({
|
||||
databaseType: "neo4j",
|
||||
tableMeta: {
|
||||
tableName: "Employee",
|
||||
primaryKeys: [DBX_NEO4J_ELEMENT_ID_COLUMN],
|
||||
},
|
||||
columns: [DBX_NEO4J_ELEMENT_ID_COLUMN, "name", "active"],
|
||||
rows: [["4:abc:7", "Ada", true]],
|
||||
dirtyRows: [[0, [[1, "Grace"]]]],
|
||||
deletedRows: [0],
|
||||
newRows: [[null, "Linus", false]],
|
||||
});
|
||||
|
||||
assert.deepEqual(statements, [
|
||||
"MATCH (n:`Employee`) WHERE n.`name` = 'Linus' AND n.`active` = FALSE DETACH DELETE n;",
|
||||
"CREATE (n:`Employee` {`name`: 'Ada', `active`: TRUE});",
|
||||
"MATCH (n:`Employee`) WHERE elementId(n) = '4:abc:7' SET n.`name` = 'Ada';",
|
||||
]);
|
||||
});
|
||||
|
||||
test("skips schema setup for Neo4j data grid saves", () => {
|
||||
assert.equal(
|
||||
dataGridSaveExecutionSchema("neo4j", { schema: "ignored", tableName: "Employee", primaryKeys: [] }),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
test("skips current_schema setup for Oracle data grid saves", () => {
|
||||
assert.equal(
|
||||
dataGridSaveExecutionSchema("oracle", { schema: "DBXTEST", tableName: "T", primaryKeys: [] }),
|
||||
undefined,
|
||||
);
|
||||
assert.equal(
|
||||
dataGridSaveExecutionSchema("postgres", { schema: "public", tableName: "T", primaryKeys: [] }),
|
||||
"public",
|
||||
);
|
||||
});
|
||||
|
||||
test("normalizes Hive ACID update and delete errors", () => {
|
||||
const error = normalizeDataGridSaveError(
|
||||
"hive",
|
||||
"Statement 1 failed: Agent RPC error (-1): Error while compiling statement: FAILED: SemanticException [Error 10294]: Attempt to do update or delete using transaction manager that does not support these operations.. Previous 0 statement(s) may have been committed.",
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
error,
|
||||
"Hive UPDATE/DELETE are not enabled for this table or server. Add rows with INSERT, or enable ACID transactional tables in Hive before editing/deleting existing rows.",
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects NULL writes to non-null table columns", () => {
|
||||
const error = validateDataGridSave({
|
||||
columns: ["ID", "CREATED_AT", "CITY"],
|
||||
columnInfo: [
|
||||
{ name: "ID", is_nullable: false, is_primary_key: true },
|
||||
{ name: "CREATED_AT", is_nullable: false, is_primary_key: false },
|
||||
{ name: "CITY", is_nullable: true, is_primary_key: false },
|
||||
],
|
||||
dirtyRows: [[0, [[1, null]]]],
|
||||
newRows: [[2, null, "上海"]],
|
||||
});
|
||||
|
||||
assert.equal(error, 'Column "CREATED_AT" does not allow NULL.');
|
||||
});
|
||||
|
||||
test("allows NULL for MySQL auto increment columns when inserting rows", () => {
|
||||
const error = validateDataGridSave({
|
||||
databaseType: "mysql",
|
||||
tableMeta: {
|
||||
tableName: "people",
|
||||
primaryKeys: ["id"],
|
||||
},
|
||||
columns: ["id", "name"],
|
||||
rows: [],
|
||||
columnInfo: [
|
||||
{ name: "id", is_nullable: false, column_default: null, is_primary_key: true, extra: "auto_increment" },
|
||||
{ name: "name", is_nullable: false, column_default: null, is_primary_key: false, extra: null },
|
||||
],
|
||||
dirtyRows: [],
|
||||
newRows: [[null, "Ada"]],
|
||||
});
|
||||
|
||||
assert.equal(error, undefined);
|
||||
});
|
||||
|
||||
test("rejects inserted rows that duplicate existing primary key values", () => {
|
||||
const error = validateDataGridSave({
|
||||
databaseType: "postgres",
|
||||
tableMeta: {
|
||||
tableName: "education_data",
|
||||
primaryKeys: ["country_code", "year"],
|
||||
},
|
||||
columns: ["country_code", "year", "value"],
|
||||
rows: [["ALB", 2021, 0.812]],
|
||||
columnInfo: [
|
||||
{ name: "country_code", is_nullable: false, column_default: null, is_primary_key: true, extra: null },
|
||||
{ name: "year", is_nullable: false, column_default: null, is_primary_key: true, extra: null },
|
||||
{ name: "value", is_nullable: true, column_default: null, is_primary_key: false, extra: null },
|
||||
],
|
||||
dirtyRows: [],
|
||||
newRows: [["ALB", 2021, 0.812]],
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
error,
|
||||
'New row duplicates the existing primary key (country_code = "ALB", year = 2021). Change the key before saving.',
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects inserted rows that duplicate another new row primary key", () => {
|
||||
const error = validateDataGridSave({
|
||||
databaseType: "postgres",
|
||||
tableMeta: {
|
||||
tableName: "education_data",
|
||||
primaryKeys: ["country_code", "year"],
|
||||
},
|
||||
columns: ["country_code", "year", "value"],
|
||||
rows: [],
|
||||
columnInfo: [
|
||||
{ name: "country_code", is_nullable: false, column_default: null, is_primary_key: true, extra: null },
|
||||
{ name: "year", is_nullable: false, column_default: null, is_primary_key: true, extra: null },
|
||||
{ name: "value", is_nullable: true, column_default: null, is_primary_key: false, extra: null },
|
||||
],
|
||||
dirtyRows: [],
|
||||
newRows: [
|
||||
["ALB", 2021, 0.812],
|
||||
["ALB", 2021, 0.913],
|
||||
],
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
error,
|
||||
'New row duplicates another new row\'s primary key (country_code = "ALB", year = 2021). Change the key before saving.',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import test from "node:test";
|
||||
import {
|
||||
buildTransposeRows,
|
||||
nextTransposeState,
|
||||
transposeAnchorRowIndex,
|
||||
transposeFieldWidth,
|
||||
transposeScrollLeftForRecord,
|
||||
visibleTransposeRecordWindow,
|
||||
} from "../../src/lib/dataGridTranspose.ts";
|
||||
|
||||
test("row number double click opens transpose for a row", () => {
|
||||
assert.deepEqual(nextTransposeState(false, null, 2), {
|
||||
showTranspose: true,
|
||||
transposeRowIndex: 2,
|
||||
});
|
||||
});
|
||||
|
||||
test("row number double click switches transpose to another row", () => {
|
||||
assert.deepEqual(nextTransposeState(true, 2, 4), {
|
||||
showTranspose: true,
|
||||
transposeRowIndex: 4,
|
||||
});
|
||||
});
|
||||
|
||||
test("row number double click closes transpose for the same row", () => {
|
||||
assert.deepEqual(nextTransposeState(true, 2, 2), {
|
||||
showTranspose: false,
|
||||
transposeRowIndex: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("builds one virtualizable transpose row per field with all record values", () => {
|
||||
const rows = buildTransposeRows({
|
||||
columns: ["id", "name", "notes"],
|
||||
records: [
|
||||
[1, "Ada", null],
|
||||
[2, "Grace", "compiler"],
|
||||
],
|
||||
typeByColumn: new Map([
|
||||
["id", "int"],
|
||||
["name", "varchar"],
|
||||
]),
|
||||
displayValue: (value) => (value === null ? "NULL" : String(value)),
|
||||
});
|
||||
|
||||
assert.deepEqual(rows, [
|
||||
{
|
||||
id: "0:id",
|
||||
column: "id",
|
||||
type: "int",
|
||||
values: [
|
||||
{ value: 1, display: "1", isNull: false },
|
||||
{ value: 2, display: "2", isNull: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "1:name",
|
||||
column: "name",
|
||||
type: "varchar",
|
||||
values: [
|
||||
{ value: "Ada", display: "Ada", isNull: false },
|
||||
{ value: "Grace", display: "Grace", isNull: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "2:notes",
|
||||
column: "notes",
|
||||
type: "",
|
||||
values: [
|
||||
{ value: null, display: "NULL", isNull: true },
|
||||
{ value: "compiler", display: "compiler", isNull: false },
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("calculates a horizontal record window with spacer widths", () => {
|
||||
assert.deepEqual(
|
||||
visibleTransposeRecordWindow({
|
||||
totalRecords: 100,
|
||||
scrollLeft: 680,
|
||||
viewportWidth: 720,
|
||||
pinnedWidth: 320,
|
||||
recordWidth: 160,
|
||||
overscan: 1,
|
||||
}),
|
||||
{
|
||||
start: 1,
|
||||
end: 7,
|
||||
beforeWidth: 160,
|
||||
afterWidth: 14880,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("uses the first selected row as the transpose anchor when context row is inside row selection", () => {
|
||||
assert.equal(
|
||||
transposeAnchorRowIndex({
|
||||
requestedRowIndex: 3,
|
||||
rowIds: [10, 11, 12, 13, 14, 15],
|
||||
selectedRowIds: new Set([12, 13, 14]),
|
||||
selectedRange: null,
|
||||
}),
|
||||
2,
|
||||
);
|
||||
});
|
||||
|
||||
test("uses the first selected cell range row as the transpose anchor when context row is inside range", () => {
|
||||
assert.equal(
|
||||
transposeAnchorRowIndex({
|
||||
requestedRowIndex: 5,
|
||||
rowIds: [10, 11, 12, 13, 14, 15],
|
||||
selectedRowIds: new Set(),
|
||||
selectedRange: { startRow: 2, endRow: 5, startCol: 0, endCol: 2 },
|
||||
}),
|
||||
2,
|
||||
);
|
||||
});
|
||||
|
||||
test("sizes the transpose field column from visible field names", () => {
|
||||
assert.equal(transposeFieldWidth(["id", "iso3", "year"]), 104);
|
||||
assert.equal(transposeFieldWidth(["country_name"]), 128);
|
||||
});
|
||||
|
||||
test("caps the transpose field column width for long field names", () => {
|
||||
assert.equal(transposeFieldWidth(["a_very_long_metric_column_name"]), 220);
|
||||
});
|
||||
|
||||
test("aligns the selected transpose record at the start of the scrollable records", () => {
|
||||
assert.equal(
|
||||
transposeScrollLeftForRecord({
|
||||
recordIndex: 55,
|
||||
totalRecords: 100,
|
||||
viewportWidth: 1200,
|
||||
pinnedWidth: 104,
|
||||
recordWidth: 168,
|
||||
}),
|
||||
9240,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {
|
||||
SCHEMA_AWARE_TYPES,
|
||||
TREE_SCHEMA_TYPES,
|
||||
getDatabaseCapability,
|
||||
supportsDatabaseCreation,
|
||||
supportsDatabaseSearch,
|
||||
supportsDriverManagement,
|
||||
supportsFieldLineage,
|
||||
supportsObjectBrowser,
|
||||
supportsObjectBrowserTreeNode,
|
||||
supportsSchemaDiagram,
|
||||
supportsSqlFileExecution,
|
||||
supportsTableImport,
|
||||
supportsTableTruncate,
|
||||
supportsTableStructureEditing,
|
||||
supportsTransfer,
|
||||
usesPostgresLikeStructureCopy,
|
||||
usesTreeSchemaMode,
|
||||
} from "../../src/lib/databaseCapabilities.ts";
|
||||
|
||||
test("treats Trino catalogs as schema tree roots", () => {
|
||||
assert.equal(TREE_SCHEMA_TYPES.has("trino"), true);
|
||||
});
|
||||
|
||||
test("treats TDengine databases as schema tree roots and agent driver databases", () => {
|
||||
assert.equal(TREE_SCHEMA_TYPES.has("tdengine"), true);
|
||||
assert.equal(SCHEMA_AWARE_TYPES.has("tdengine"), true);
|
||||
assert.equal(supportsDriverManagement("tdengine"), true);
|
||||
});
|
||||
|
||||
test("treats Access as a local single-database agent driver", () => {
|
||||
assert.equal(SCHEMA_AWARE_TYPES.has("access"), false);
|
||||
assert.equal(supportsDriverManagement("access"), true);
|
||||
assert.equal(supportsDatabaseSearch("access"), true);
|
||||
assert.equal(supportsTableImport("access"), true);
|
||||
});
|
||||
|
||||
test("describes schema tree mode through the capability helper", () => {
|
||||
assert.equal(usesTreeSchemaMode("trino"), true);
|
||||
assert.equal(usesTreeSchemaMode("h2"), true);
|
||||
assert.equal(usesTreeSchemaMode("mysql"), false);
|
||||
assert.equal(usesTreeSchemaMode(undefined), false);
|
||||
});
|
||||
|
||||
test("treats Trino tables as schema-qualified SQL targets", () => {
|
||||
assert.equal(SCHEMA_AWARE_TYPES.has("trino"), true);
|
||||
});
|
||||
|
||||
test("describes table editing capabilities for special database engines", () => {
|
||||
assert.deepEqual(getDatabaseCapability("hive").tableData, {
|
||||
insert: true,
|
||||
updateRequiresPrimaryKey: false,
|
||||
deleteRequiresPrimaryKey: false,
|
||||
requiresTransactionalTableForExistingRows: true,
|
||||
transaction: false,
|
||||
});
|
||||
|
||||
assert.deepEqual(getDatabaseCapability("trino").tableData, {
|
||||
insert: true,
|
||||
updateRequiresPrimaryKey: true,
|
||||
deleteRequiresPrimaryKey: true,
|
||||
requiresTransactionalTableForExistingRows: false,
|
||||
transaction: false,
|
||||
});
|
||||
|
||||
assert.equal(getDatabaseCapability("oracle").syntheticKey, "oracle-rowid");
|
||||
assert.equal(getDatabaseCapability("neo4j").syntheticKey, "neo4j-element-id");
|
||||
});
|
||||
|
||||
test("uses conservative table editing defaults for unknown or keyless relational engines", () => {
|
||||
assert.deepEqual(getDatabaseCapability("postgres").tableData, {
|
||||
insert: false,
|
||||
updateRequiresPrimaryKey: true,
|
||||
deleteRequiresPrimaryKey: true,
|
||||
requiresTransactionalTableForExistingRows: false,
|
||||
transaction: true,
|
||||
});
|
||||
assert.deepEqual(getDatabaseCapability(undefined).tableData, {
|
||||
insert: false,
|
||||
updateRequiresPrimaryKey: true,
|
||||
deleteRequiresPrimaryKey: true,
|
||||
requiresTransactionalTableForExistingRows: false,
|
||||
transaction: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("describes feature support through capability helpers", () => {
|
||||
assert.equal(supportsSqlFileExecution("mysql"), true);
|
||||
assert.equal(supportsSqlFileExecution("redis"), false);
|
||||
assert.equal(supportsSchemaDiagram("oracle"), true);
|
||||
assert.equal(supportsSchemaDiagram("trino"), false);
|
||||
assert.equal(supportsDatabaseSearch("neo4j"), true);
|
||||
assert.equal(supportsDatabaseSearch("redis"), false);
|
||||
assert.equal(supportsTableImport("duckdb"), true);
|
||||
assert.equal(supportsTableImport("hive"), false);
|
||||
assert.equal(supportsTableStructureEditing("postgres"), true);
|
||||
assert.equal(supportsTableStructureEditing("oracle"), false);
|
||||
assert.equal(supportsDatabaseCreation("clickhouse"), true);
|
||||
assert.equal(supportsDatabaseCreation("sqlite"), false);
|
||||
assert.equal(supportsFieldLineage("gaussdb"), true);
|
||||
assert.equal(supportsFieldLineage("trino"), false);
|
||||
assert.equal(supportsTransfer("duckdb"), true);
|
||||
assert.equal(supportsTransfer("hive"), false);
|
||||
assert.equal(supportsDriverManagement("oracle"), true);
|
||||
assert.equal(supportsDriverManagement("mysql"), false);
|
||||
assert.equal(usesPostgresLikeStructureCopy("gaussdb"), true);
|
||||
assert.equal(usesPostgresLikeStructureCopy("mysql"), false);
|
||||
assert.equal(supportsObjectBrowser("mysql"), true);
|
||||
assert.equal(supportsObjectBrowser("mongodb"), false);
|
||||
assert.equal(supportsTableTruncate("mysql"), true);
|
||||
assert.equal(supportsTableTruncate("duckdb"), false);
|
||||
});
|
||||
|
||||
test("schema-aware database nodes do not open an object browser tab", () => {
|
||||
assert.equal(supportsObjectBrowserTreeNode("postgres", "database"), false);
|
||||
assert.equal(supportsObjectBrowserTreeNode("postgres", "schema"), true);
|
||||
assert.equal(supportsObjectBrowserTreeNode("mysql", "database"), true);
|
||||
assert.equal(supportsObjectBrowserTreeNode("mongodb", "database"), false);
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import test from "node:test";
|
||||
import {
|
||||
DATABASE_EXPORT_INSERT_BATCH_SIZE,
|
||||
DATABASE_EXPORT_ROW_LIMIT,
|
||||
buildExportPageSql,
|
||||
buildDatabaseSqlExport,
|
||||
buildInsertStatements,
|
||||
formatSqlLiteral,
|
||||
} from "../../src/lib/databaseExport.ts";
|
||||
|
||||
test("formats SQL literals for exported INSERT statements", () => {
|
||||
assert.equal(formatSqlLiteral(null), "NULL");
|
||||
assert.equal(formatSqlLiteral(42), "42");
|
||||
assert.equal(formatSqlLiteral(true), "TRUE");
|
||||
assert.equal(formatSqlLiteral("O'Hara"), "'O''Hara'");
|
||||
});
|
||||
|
||||
test("builds batched INSERT statements for one exported table", () => {
|
||||
const statements = buildInsertStatements({
|
||||
qualifiedTableName: "`users`",
|
||||
columns: ["id", "name"],
|
||||
rows: [
|
||||
[1, "Ada"],
|
||||
[2, "O'Hara"],
|
||||
[3, "Linus"],
|
||||
],
|
||||
quoteIdentifier: (name) => `\`${name}\``,
|
||||
batchSize: 2,
|
||||
});
|
||||
|
||||
assert.deepEqual(statements, [
|
||||
"INSERT INTO `users` (`id`, `name`) VALUES (1, 'Ada'), (2, 'O''Hara');",
|
||||
"INSERT INTO `users` (`id`, `name`) VALUES (3, 'Linus');",
|
||||
]);
|
||||
});
|
||||
|
||||
test("builds capped export page queries", () => {
|
||||
assert.equal(
|
||||
buildExportPageSql({
|
||||
databaseType: "mysql",
|
||||
tableName: "users",
|
||||
limit: 500,
|
||||
offset: 1000,
|
||||
}),
|
||||
"SELECT * FROM `users` LIMIT 500 OFFSET 1000;",
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
buildExportPageSql({
|
||||
databaseType: "sqlserver",
|
||||
schema: "dbo",
|
||||
tableName: "accounts",
|
||||
limit: DATABASE_EXPORT_ROW_LIMIT,
|
||||
}),
|
||||
`SELECT * FROM [dbo].[accounts] ORDER BY (SELECT NULL) OFFSET 0 ROWS FETCH NEXT ${DATABASE_EXPORT_ROW_LIMIT} ROWS ONLY`,
|
||||
);
|
||||
});
|
||||
|
||||
test("builds a database SQL export with DDL before data", () => {
|
||||
const sql = buildDatabaseSqlExport({
|
||||
databaseName: "app",
|
||||
exportedAt: new Date("2026-05-02T00:00:00.000Z"),
|
||||
rowLimitPerTable: DATABASE_EXPORT_ROW_LIMIT,
|
||||
tables: [
|
||||
{
|
||||
displayName: "users",
|
||||
qualifiedTableName: "`users`",
|
||||
ddl: "CREATE TABLE `users` (`id` int);",
|
||||
columns: ["id"],
|
||||
rows: [[1]],
|
||||
truncated: true,
|
||||
},
|
||||
],
|
||||
quoteIdentifier: (name) => `\`${name}\``,
|
||||
insertBatchSize: DATABASE_EXPORT_INSERT_BATCH_SIZE,
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
sql,
|
||||
[
|
||||
"-- DBX database export",
|
||||
"-- Database: app",
|
||||
"-- Exported at: 2026-05-02T00:00:00.000Z",
|
||||
`-- Row limit per table: ${DATABASE_EXPORT_ROW_LIMIT}`,
|
||||
"",
|
||||
"-- Structure for users",
|
||||
"CREATE TABLE `users` (`id` int);",
|
||||
"",
|
||||
"-- Data for users",
|
||||
`-- Exported rows: 1 (truncated at ${DATABASE_EXPORT_ROW_LIMIT})`,
|
||||
"INSERT INTO `users` (`id`) VALUES (1);",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import test from "node:test";
|
||||
import { buildSelectedTablesPayload } from "../../src/lib/databaseExportSelection.ts";
|
||||
|
||||
test("omits selected table payload when every table is selected", () => {
|
||||
assert.equal(buildSelectedTablesPayload(["users", "orders"], ["users", "orders"]), undefined);
|
||||
});
|
||||
|
||||
test("builds selected table payload for a subset in table order", () => {
|
||||
assert.deepEqual(buildSelectedTablesPayload(["users", "orders", "logs"], ["logs", "users"]), ["users", "logs"]);
|
||||
});
|
||||
|
||||
test("builds an empty payload when no table is selected", () => {
|
||||
assert.deepEqual(buildSelectedTablesPayload(["users"], []), []);
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import test from "node:test";
|
||||
import { filterDatabaseOptions } from "../../src/lib/databaseOptionSearch.ts";
|
||||
|
||||
test("returns all database options when the search query is empty", () => {
|
||||
assert.deepEqual(filterDatabaseOptions(["app", "analytics"], ""), ["app", "analytics"]);
|
||||
assert.deepEqual(filterDatabaseOptions(["app", "analytics"], " "), ["app", "analytics"]);
|
||||
});
|
||||
|
||||
test("filters database options case-insensitively", () => {
|
||||
assert.deepEqual(filterDatabaseOptions(["app", "Analytics", "billing"], "ANA"), ["Analytics"]);
|
||||
});
|
||||
|
||||
test("matches redis-style display labels", () => {
|
||||
assert.deepEqual(
|
||||
filterDatabaseOptions(["0", "1", "12"], "db1", (database) => `db${database}`),
|
||||
["1", "12"],
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import type { ColumnInfo } from "../../src/types/database.ts";
|
||||
import {
|
||||
buildDatabaseSearchSql,
|
||||
buildSearchResultWhere,
|
||||
findMatchedSearchColumns,
|
||||
} from "../../src/lib/databaseSearch.ts";
|
||||
|
||||
function col(name: string, dataType: string, primary = false): ColumnInfo {
|
||||
return {
|
||||
name,
|
||||
data_type: dataType,
|
||||
is_nullable: true,
|
||||
column_default: null,
|
||||
is_primary_key: primary,
|
||||
extra: null,
|
||||
};
|
||||
}
|
||||
|
||||
test("builds a table search query over text columns", () => {
|
||||
const query = buildDatabaseSearchSql({
|
||||
databaseType: "mysql",
|
||||
tableName: "users",
|
||||
columns: [col("id", "bigint", true), col("email", "varchar"), col("avatar", "blob")],
|
||||
term: "alice@example.com",
|
||||
limit: 20,
|
||||
});
|
||||
|
||||
assert.ok(query);
|
||||
assert.deepEqual(query.searchableColumns, ["email"]);
|
||||
assert.equal(
|
||||
query.sql,
|
||||
"SELECT * FROM `users` WHERE (LOWER(CAST(`email` AS CHAR)) LIKE '%alice@example.com%' ESCAPE '~') LIMIT 20;",
|
||||
);
|
||||
});
|
||||
|
||||
test("adds exact numeric predicates only when the search term is numeric", () => {
|
||||
const query = buildDatabaseSearchSql({
|
||||
databaseType: "postgres",
|
||||
schema: "public",
|
||||
tableName: "orders",
|
||||
columns: [col("id", "integer", true), col("note", "text")],
|
||||
term: "42",
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
assert.ok(query);
|
||||
assert.deepEqual(query.searchableColumns, ["note", "id"]);
|
||||
assert.match(query.sql, /"id" = 42/);
|
||||
assert.match(query.sql, /FROM "public"\."orders"/);
|
||||
});
|
||||
|
||||
test("returns null when a table has no searchable columns", () => {
|
||||
const query = buildDatabaseSearchSql({
|
||||
databaseType: "sqlite",
|
||||
tableName: "files",
|
||||
columns: [col("payload", "blob")],
|
||||
term: "needle",
|
||||
});
|
||||
|
||||
assert.equal(query, null);
|
||||
});
|
||||
|
||||
test("finds matched columns from returned rows", () => {
|
||||
const matches = findMatchedSearchColumns(
|
||||
["id", "email", "note"],
|
||||
[42, "Alice@Example.com", "inactive"],
|
||||
[col("id", "integer", true), col("email", "varchar"), col("note", "text")],
|
||||
"alice",
|
||||
);
|
||||
|
||||
assert.deepEqual(matches, ["email"]);
|
||||
});
|
||||
|
||||
test("builds a stable where predicate for opening a result row", () => {
|
||||
const where = buildSearchResultWhere({
|
||||
databaseType: "sqlserver",
|
||||
columns: [col("id", "integer", true), col("email", "varchar")],
|
||||
resultColumns: ["id", "email"],
|
||||
row: [42, "alice@example.com"],
|
||||
});
|
||||
|
||||
assert.equal(where, "[id] = 42");
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { buildDatabaseTreeNodes } from "../../src/lib/databaseTree.ts";
|
||||
|
||||
test("设置默认库后侧边栏数据库树仍保留全部数据库", () => {
|
||||
const nodes = buildDatabaseTreeNodes("conn-1", [
|
||||
{ name: "campaign_data" },
|
||||
{ name: "cms" },
|
||||
{ name: "mk_campaign" },
|
||||
]);
|
||||
|
||||
assert.deepEqual(
|
||||
nodes.map((node) => node.database),
|
||||
["campaign_data", "cms", "mk_campaign"],
|
||||
);
|
||||
assert.equal(nodes.find((node) => node.database === "mk_campaign")?.id, "conn-1:mk_campaign");
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { isDefaultDatabase, resolveDefaultDatabase } from "../../src/lib/defaultDatabase.ts";
|
||||
|
||||
test("优先使用连接上已保存的默认数据库", () => {
|
||||
assert.equal(resolveDefaultDatabase({ database: "analytics" }, ["app", "analytics"]), "analytics");
|
||||
});
|
||||
|
||||
test("默认数据库为空时回退到首个可选数据库", () => {
|
||||
assert.equal(resolveDefaultDatabase({ database: undefined }, ["app", "analytics"]), "app");
|
||||
});
|
||||
|
||||
test("没有默认数据库且无候选项时返回空字符串", () => {
|
||||
assert.equal(resolveDefaultDatabase({ database: undefined }, []), "");
|
||||
});
|
||||
|
||||
test("判断当前数据库是否为默认数据库", () => {
|
||||
assert.equal(isDefaultDatabase({ database: "analytics" }, "analytics"), true);
|
||||
assert.equal(isDefaultDatabase({ database: "analytics" }, "app"), false);
|
||||
assert.equal(isDefaultDatabase(undefined, "analytics"), false);
|
||||
assert.equal(isDefaultDatabase({ database: "analytics" }, ""), false);
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import test from "node:test";
|
||||
import { buildEngineeringDiagram } from "../../src/lib/engineeringDiagram.ts";
|
||||
import {
|
||||
buildEngineeringDiagramSvg,
|
||||
buildTableDiagramSvg,
|
||||
diagramSvgFileName,
|
||||
} from "../../src/lib/diagramSvgExport.ts";
|
||||
import { buildDiagramRelationships, type DiagramTable } from "../../src/lib/erDiagram.ts";
|
||||
|
||||
const tables: DiagramTable[] = [
|
||||
{
|
||||
name: "users",
|
||||
columns: [
|
||||
{ name: "id", data_type: "bigint", is_nullable: false, column_default: null, is_primary_key: true, extra: null },
|
||||
{ name: "name & note", data_type: "varchar", is_nullable: true, column_default: null, is_primary_key: false, extra: null },
|
||||
],
|
||||
foreignKeys: [],
|
||||
},
|
||||
{
|
||||
name: "orders",
|
||||
columns: [
|
||||
{ name: "id", data_type: "bigint", is_nullable: false, column_default: null, is_primary_key: true, extra: null },
|
||||
{ name: "user_id", data_type: "bigint", is_nullable: false, column_default: null, is_primary_key: false, extra: null },
|
||||
],
|
||||
foreignKeys: [
|
||||
{ name: "orders_user_id_fk", column: "user_id", ref_table: "users", ref_column: "id" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
test("exports the table diagram as standalone SVG", () => {
|
||||
const relationships = buildDiagramRelationships(tables);
|
||||
const svg = buildTableDiagramSvg({
|
||||
tables,
|
||||
relationships,
|
||||
positions: {
|
||||
users: { x: 40, y: 40 },
|
||||
orders: { x: 360, y: 40 },
|
||||
},
|
||||
relationshipPaths: {
|
||||
[relationships[0].id]: "M 360 96 L 310 96",
|
||||
},
|
||||
canvas: { width: 720, height: 320 },
|
||||
cardWidth: 270,
|
||||
cardHeaderHeight: 44,
|
||||
columnRowHeight: 24,
|
||||
maxVisibleColumns: 9,
|
||||
moreColumnsLabel: (count) => `+ ${count} columns`,
|
||||
});
|
||||
|
||||
assert.match(svg, /^<svg /);
|
||||
assert.match(svg, /<path d="M 360 96 L 310 96"/);
|
||||
assert.match(svg, />users</);
|
||||
assert.match(svg, />orders</);
|
||||
assert.match(svg, />name & note</);
|
||||
assert.doesNotMatch(svg, /<foreignObject/);
|
||||
});
|
||||
|
||||
test("exports the engineering ER diagram with Chen-style shapes and cardinalities", () => {
|
||||
const relationships = buildDiagramRelationships(tables);
|
||||
const diagram = buildEngineeringDiagram(tables, relationships, {
|
||||
users: { x: 40, y: 40 },
|
||||
orders: { x: 360, y: 40 },
|
||||
});
|
||||
const svg = buildEngineeringDiagramSvg(diagram);
|
||||
|
||||
assert.match(svg, /^<svg /);
|
||||
assert.match(svg, /<ellipse /);
|
||||
assert.match(svg, /<polygon /);
|
||||
assert.match(svg, /<rect /);
|
||||
assert.match(svg, />N</);
|
||||
assert.match(svg, />1</);
|
||||
assert.match(svg, /text-decoration="underline"/);
|
||||
assert.doesNotMatch(svg, /<foreignObject/);
|
||||
});
|
||||
|
||||
test("builds safe SVG file names from the active diagram context", () => {
|
||||
assert.equal(
|
||||
diagramSvgFileName("prod/main", "billing db", "engineering"),
|
||||
"dbx-prod-main-billing-db-engineering-er.svg",
|
||||
);
|
||||
assert.equal(
|
||||
diagramSvgFileName("", "", "table"),
|
||||
"dbx-diagram-table-structure.svg",
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import test from "node:test";
|
||||
import {
|
||||
clampDiagramZoom,
|
||||
zoomFromGestureScale,
|
||||
zoomFromWheelDelta,
|
||||
} from "../../src/lib/diagramZoom.ts";
|
||||
|
||||
test("clamps diagram zoom to supported bounds", () => {
|
||||
assert.equal(clampDiagramZoom(0.2), 0.6);
|
||||
assert.equal(clampDiagramZoom(2), 1.5);
|
||||
assert.equal(clampDiagramZoom(1.234), 1.23);
|
||||
});
|
||||
|
||||
test("maps trackpad pinch wheel delta to smooth zoom changes", () => {
|
||||
assert.ok(zoomFromWheelDelta(1, -120) > 1);
|
||||
assert.ok(zoomFromWheelDelta(1, 120) < 1);
|
||||
});
|
||||
|
||||
test("maps WebKit gesture scale from the gesture start zoom", () => {
|
||||
assert.equal(zoomFromGestureScale(1, 1.25), 1.25);
|
||||
assert.equal(zoomFromGestureScale(1, 4), 1.5);
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { strict as assert } from "node:assert";
|
||||
import test from "node:test";
|
||||
import appPackage from "../../package.json" with { type: "json" };
|
||||
import downloadLinks from "../../docs/lib/downloadLinks.ts";
|
||||
|
||||
const { createInstallOptions } = downloadLinks;
|
||||
|
||||
test("docs install links are generated from the app package version", () => {
|
||||
const options = createInstallOptions("en", appPackage.version);
|
||||
const hrefs = options.map((option) => option.href);
|
||||
|
||||
assert.equal(hrefs.length, 5);
|
||||
assert.ok(hrefs.every((href) => href.includes(`/DBX_${appPackage.version}_`)));
|
||||
assert.equal(hrefs.some((href) => href.includes("0.5.9")), false);
|
||||
});
|
||||
|
||||
test("docs landing page reads latest release data instead of hard-coding the old latest update", () => {
|
||||
const source = readFileSync("docs/app/[lang]/page.tsx", "utf8");
|
||||
|
||||
assert.equal(source.includes("version: 'v0.5.4'"), false);
|
||||
assert.match(source, /LandingLatestUpdates/);
|
||||
assert.match(source, /fetchLatestReleaseInfo/);
|
||||
assert.match(source, /initialLatestRelease/);
|
||||
});
|
||||
|
||||
test("docs install widget reads the latest release version from latest.json at runtime", () => {
|
||||
const source = readFileSync("docs/components/landing/InstallTabs.tsx", "utf8");
|
||||
const latestRelease = readFileSync("docs/lib/latestRelease.ts", "utf8");
|
||||
|
||||
assert.equal(source.includes("fetchChangelog"), false);
|
||||
assert.match(source, /fetchLatestReleaseInfo/);
|
||||
assert.match(latestRelease, /latest\.json/);
|
||||
});
|
||||
|
||||
test("homepage update badge keeps latest.json version ahead of changelog tags", () => {
|
||||
const source = readFileSync("docs/components/landing/LandingLatestUpdates.tsx", "utf8");
|
||||
|
||||
assert.match(source, /Promise\.all\(\[fetchLatestReleaseInfo\(\), fetchChangelog\(lang\)\]\)/);
|
||||
assert.match(source, /releaseInfo \?\? initialLatestRelease/);
|
||||
});
|
||||
|
||||
test("docs changelog page loads releases in the browser from R2", () => {
|
||||
const source = readFileSync("docs/app/[lang]/changelog/page.tsx", "utf8");
|
||||
|
||||
assert.match(source, /initialData/);
|
||||
assert.match(source, /ChangelogRuntime/);
|
||||
});
|
||||
|
||||
test("legacy docs changelog pages do not keep stale release entries", () => {
|
||||
const en = readFileSync("docs/content/docs/changelog.mdx", "utf8");
|
||||
const cn = readFileSync("docs/content/docs/changelog.cn.mdx", "utf8");
|
||||
|
||||
assert.equal(en.includes("## v0.5.4"), false);
|
||||
assert.equal(cn.includes("## v0.5.4"), false);
|
||||
assert.match(en, /\/en\/changelog/);
|
||||
assert.match(cn, /\/cn\/changelog/);
|
||||
});
|
||||
|
||||
test("docs deploy does not rerun just to refresh R2 changelog data", () => {
|
||||
const source = readFileSync(".github/workflows/docs.yml", "utf8");
|
||||
|
||||
assert.equal(source.includes("workflow_run:"), false);
|
||||
assert.equal(source.includes("Sync Changelog to R2"), false);
|
||||
});
|
||||
|
||||
test("changelog sync configures R2 CORS for browser runtime reads", () => {
|
||||
const workflow = readFileSync(".github/workflows/sync-changelog.yml", "utf8");
|
||||
const cors = JSON.parse(readFileSync(".github/r2-cors.json", "utf8")) as {
|
||||
CORSRules: Array<{
|
||||
AllowedMethods: string[];
|
||||
AllowedOrigins: string[];
|
||||
}>;
|
||||
};
|
||||
const rule = cors.CORSRules[0];
|
||||
|
||||
assert.match(workflow, /put-bucket-cors/);
|
||||
assert.deepEqual(rule.AllowedOrigins, ["*"]);
|
||||
assert.ok(rule.AllowedMethods.includes("GET"));
|
||||
assert.ok(rule.AllowedMethods.includes("HEAD"));
|
||||
});
|
||||
|
||||
test("changelog sync also runs after release workflow publishes with GITHUB_TOKEN", () => {
|
||||
const workflow = readFileSync(".github/workflows/sync-changelog.yml", "utf8");
|
||||
|
||||
assert.match(workflow, /workflow_run:/);
|
||||
assert.match(workflow, /workflows: \['Release'\]/);
|
||||
assert.match(workflow, /github\.event\.workflow_run\.conclusion == 'success'/);
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import test from "node:test";
|
||||
import { buildEngineeringDiagram } from "../../src/lib/engineeringDiagram.ts";
|
||||
import type { DiagramRelationship, DiagramTable } from "../../src/lib/erDiagram.ts";
|
||||
|
||||
const tables: DiagramTable[] = [
|
||||
{
|
||||
name: "orders",
|
||||
columns: [
|
||||
{ name: "id", data_type: "bigint", is_nullable: false, column_default: null, is_primary_key: true, extra: null },
|
||||
{ name: "user_id", data_type: "bigint", is_nullable: false, column_default: null, is_primary_key: false, extra: null },
|
||||
{ name: "status", data_type: "varchar", is_nullable: false, column_default: null, is_primary_key: false, extra: null },
|
||||
],
|
||||
foreignKeys: [
|
||||
{ name: "orders_user_id_fk", column: "user_id", ref_table: "users", ref_column: "id" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "users",
|
||||
columns: [
|
||||
{ name: "id", data_type: "bigint", is_nullable: false, column_default: null, is_primary_key: true, extra: null },
|
||||
{ name: "name", data_type: "varchar", is_nullable: false, column_default: null, is_primary_key: false, extra: null },
|
||||
],
|
||||
foreignKeys: [],
|
||||
},
|
||||
];
|
||||
|
||||
const relationships: DiagramRelationship[] = [
|
||||
{
|
||||
id: "orders:orders_user_id_fk:user_id:users:id",
|
||||
name: "orders_user_id_fk",
|
||||
sourceTable: "orders",
|
||||
sourceColumn: "user_id",
|
||||
targetTable: "users",
|
||||
targetColumn: "id",
|
||||
},
|
||||
];
|
||||
|
||||
test("builds engineering ER nodes from tables, columns, and relationships", () => {
|
||||
const diagram = buildEngineeringDiagram(tables, relationships, {
|
||||
orders: { x: 300, y: 200 },
|
||||
users: { x: 40, y: 200 },
|
||||
});
|
||||
|
||||
assert.deepEqual(diagram.entities.map((entity) => entity.name), ["orders", "users"]);
|
||||
assert.equal(diagram.attributes.filter((attr) => attr.tableName === "orders").length, 3);
|
||||
assert.equal(diagram.relationships[0]?.sourceCardinality, "N");
|
||||
assert.equal(diagram.relationships[0]?.targetCardinality, "1");
|
||||
});
|
||||
|
||||
test("sizes the engineering canvas around attributes and relationship diamonds", () => {
|
||||
const diagram = buildEngineeringDiagram(tables, relationships, {
|
||||
orders: { x: 300, y: 200 },
|
||||
users: { x: 40, y: 200 },
|
||||
});
|
||||
|
||||
assert.ok(diagram.canvas.width > 500);
|
||||
assert.ok(diagram.canvas.height > 300);
|
||||
});
|
||||
|
||||
test("keeps dense attribute clouds from overlapping", () => {
|
||||
const denseTables: DiagramTable[] = [{
|
||||
name: "roles",
|
||||
columns: Array.from({ length: 36 }, (_, index) => ({
|
||||
name: `column_${index + 1}`,
|
||||
data_type: "varchar",
|
||||
is_nullable: true,
|
||||
column_default: null,
|
||||
is_primary_key: index === 0,
|
||||
extra: null,
|
||||
})),
|
||||
foreignKeys: [],
|
||||
}];
|
||||
|
||||
const diagram = buildEngineeringDiagram(denseTables, [], {
|
||||
roles: { x: 40, y: 40 },
|
||||
});
|
||||
const rects = [
|
||||
...diagram.entities,
|
||||
...diagram.attributes,
|
||||
];
|
||||
|
||||
for (let i = 0; i < rects.length; i++) {
|
||||
for (let j = i + 1; j < rects.length; j++) {
|
||||
const left = rects[i];
|
||||
const right = rects[j];
|
||||
const overlaps = left.x < right.x + right.width &&
|
||||
left.x + left.width > right.x &&
|
||||
left.y < right.y + right.height &&
|
||||
left.y + left.height > right.y;
|
||||
assert.equal(overlaps, false, `${left.id} overlaps ${right.id}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps adjacent entity centers reasonably close", () => {
|
||||
const diagram = buildEngineeringDiagram(tables, relationships, {
|
||||
users: { x: 40, y: 40 },
|
||||
orders: { x: 360, y: 40 },
|
||||
});
|
||||
const users = diagram.entities.find((entity) => entity.name === "users")!;
|
||||
const orders = diagram.entities.find((entity) => entity.name === "orders")!;
|
||||
const userCenter = users.x + users.width / 2;
|
||||
const orderCenter = orders.x + orders.width / 2;
|
||||
|
||||
assert.ok(Math.abs(orderCenter - userCenter) <= 560);
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import test from "node:test";
|
||||
import {
|
||||
buildDiagramRelationships,
|
||||
filterDiagramTables,
|
||||
layoutDiagramTables,
|
||||
} from "../../src/lib/erDiagram.ts";
|
||||
|
||||
test("builds relationships only between tables in the diagram", () => {
|
||||
const relationships = buildDiagramRelationships([
|
||||
{
|
||||
name: "orders",
|
||||
columns: [],
|
||||
foreignKeys: [
|
||||
{ name: "orders_user_id_fk", column: "user_id", ref_table: "users", ref_column: "id" },
|
||||
{ name: "orders_external_fk", column: "external_id", ref_table: "external_accounts", ref_column: "id" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "users",
|
||||
columns: [],
|
||||
foreignKeys: [],
|
||||
},
|
||||
]);
|
||||
|
||||
assert.deepEqual(relationships, [
|
||||
{
|
||||
id: "orders:orders_user_id_fk:user_id:users:id",
|
||||
name: "orders_user_id_fk",
|
||||
sourceTable: "orders",
|
||||
sourceColumn: "user_id",
|
||||
targetTable: "users",
|
||||
targetColumn: "id",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("filters diagram tables by table, column, and foreign key names", () => {
|
||||
const tables = [
|
||||
{
|
||||
name: "orders",
|
||||
columns: [{ name: "user_id", data_type: "int", is_nullable: false, column_default: null, is_primary_key: false, extra: null }],
|
||||
foreignKeys: [{ name: "orders_user_id_fk", column: "user_id", ref_table: "users", ref_column: "id" }],
|
||||
},
|
||||
{
|
||||
name: "audit_log",
|
||||
columns: [{ name: "payload", data_type: "json", is_nullable: true, column_default: null, is_primary_key: false, extra: null }],
|
||||
foreignKeys: [],
|
||||
},
|
||||
];
|
||||
|
||||
assert.deepEqual(filterDiagramTables(tables, "payload").map((table) => table.name), ["audit_log"]);
|
||||
assert.deepEqual(filterDiagramTables(tables, "orders_user").map((table) => table.name), ["orders"]);
|
||||
assert.deepEqual(filterDiagramTables(tables, "").map((table) => table.name), ["orders", "audit_log"]);
|
||||
});
|
||||
|
||||
test("lays out diagram tables in stable rows", () => {
|
||||
const positions = layoutDiagramTables(
|
||||
[
|
||||
{ name: "users", columns: [] },
|
||||
{ name: "orders", columns: [] },
|
||||
{ name: "line_items", columns: [] },
|
||||
],
|
||||
{ columnsPerRow: 2, cardWidth: 240, rowHeight: 180, gapX: 40, gapY: 30 },
|
||||
);
|
||||
|
||||
assert.deepEqual(positions, {
|
||||
users: { x: 40, y: 40 },
|
||||
orders: { x: 320, y: 40 },
|
||||
line_items: { x: 40, y: 250 },
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import test from "node:test";
|
||||
import {
|
||||
buildExplainSql,
|
||||
flattenExplainPlanNodes,
|
||||
parseExplainResult,
|
||||
supportsExplainPlan,
|
||||
} from "../../src/lib/explainPlan.ts";
|
||||
|
||||
test("builds PostgreSQL JSON explain SQL from a selected query", () => {
|
||||
const result = buildExplainSql("postgres", " select * from users where id = 1; ");
|
||||
|
||||
assert.deepEqual(result, {
|
||||
ok: true,
|
||||
sql: "EXPLAIN (FORMAT JSON) select * from users where id = 1",
|
||||
});
|
||||
});
|
||||
|
||||
test("builds MySQL JSON explain SQL and rejects unsafe statement kinds", () => {
|
||||
assert.deepEqual(buildExplainSql("mysql", "SELECT * FROM users;"), {
|
||||
ok: true,
|
||||
sql: "EXPLAIN FORMAT=JSON SELECT * FROM users",
|
||||
});
|
||||
|
||||
assert.equal(buildExplainSql("mysql", "delete from users").ok, false);
|
||||
});
|
||||
|
||||
test("reports explain support by database type", () => {
|
||||
assert.equal(supportsExplainPlan("postgres"), true);
|
||||
assert.equal(supportsExplainPlan("mysql"), true);
|
||||
assert.equal(supportsExplainPlan("sqlite"), false);
|
||||
});
|
||||
|
||||
test("parses PostgreSQL FORMAT JSON output into plan nodes", () => {
|
||||
const plan = parseExplainResult("postgres", {
|
||||
columns: ["QUERY PLAN"],
|
||||
rows: [[[
|
||||
{
|
||||
Plan: {
|
||||
"Node Type": "Nested Loop",
|
||||
"Startup Cost": 0.42,
|
||||
"Total Cost": 42.9,
|
||||
"Plan Rows": 12,
|
||||
Plans: [
|
||||
{
|
||||
"Node Type": "Index Scan",
|
||||
"Relation Name": "users",
|
||||
"Index Name": "users_pkey",
|
||||
"Startup Cost": 0.28,
|
||||
"Total Cost": 8.3,
|
||||
"Plan Rows": 1,
|
||||
},
|
||||
{
|
||||
"Node Type": "Seq Scan",
|
||||
"Relation Name": "orders",
|
||||
"Filter": "(user_id = users.id)",
|
||||
"Total Cost": 31.2,
|
||||
"Plan Rows": 20,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]]],
|
||||
affected_rows: 0,
|
||||
execution_time_ms: 3,
|
||||
});
|
||||
|
||||
assert.equal(plan.nodes[0].title, "Nested Loop");
|
||||
assert.equal(plan.nodes[0].cost, "0.42..42.9");
|
||||
assert.equal(plan.nodes[0].rows, "12");
|
||||
assert.equal(plan.nodes[0].children[0].relation, "users");
|
||||
assert.equal(plan.nodes[0].children[0].index, "users_pkey");
|
||||
assert.equal(flattenExplainPlanNodes(plan.nodes).map((node) => node.nodeType).join(","), "Nested Loop,Index Scan,Seq Scan");
|
||||
});
|
||||
|
||||
test("parses MySQL FORMAT=JSON output into plan nodes", () => {
|
||||
const plan = parseExplainResult("mysql", {
|
||||
columns: ["EXPLAIN"],
|
||||
rows: [[JSON.stringify({
|
||||
query_block: {
|
||||
select_id: 1,
|
||||
nested_loop: [
|
||||
{
|
||||
table: {
|
||||
table_name: "users",
|
||||
access_type: "ref",
|
||||
key: "idx_users_email",
|
||||
rows_examined_per_scan: 3,
|
||||
cost_info: { query_cost: "1.20" },
|
||||
attached_condition: "users.email is not null",
|
||||
},
|
||||
},
|
||||
{
|
||||
table: {
|
||||
table_name: "orders",
|
||||
access_type: "ALL",
|
||||
rows_examined_per_scan: 200,
|
||||
cost_info: { read_cost: "18.00" },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
})]],
|
||||
affected_rows: 0,
|
||||
execution_time_ms: 2,
|
||||
});
|
||||
|
||||
const flat = flattenExplainPlanNodes(plan.nodes);
|
||||
assert.equal(flat[0].nodeType, "query_block");
|
||||
assert.equal(flat[1].title, "ref on users");
|
||||
assert.equal(flat[1].index, "idx_users_email");
|
||||
assert.equal(flat[1].cost, "1.20");
|
||||
assert.equal(flat[2].rows, "200");
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import test from "node:test";
|
||||
import { analyzeFieldLineage, identifierInSql, summarizeLineageCounts } from "../../src/lib/fieldLineage.ts";
|
||||
|
||||
test("detects outgoing and incoming foreign key lineage as certain", () => {
|
||||
const result = analyzeFieldLineage({
|
||||
target: { schema: "public", table: "orders", column: "user_id" },
|
||||
tables: [
|
||||
{
|
||||
schema: "public",
|
||||
name: "orders",
|
||||
columns: ["id", "user_id"],
|
||||
foreignKeys: [{ name: "orders_user_id_fkey", column: "user_id", ref_table: "users", ref_column: "id" }],
|
||||
},
|
||||
{
|
||||
schema: "public",
|
||||
name: "order_events",
|
||||
columns: ["id", "order_user_id"],
|
||||
foreignKeys: [{ name: "events_user_fkey", column: "order_user_id", ref_table: "orders", ref_column: "user_id" }],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
assert.equal(result.items.length, 2);
|
||||
assert.deepEqual(
|
||||
result.items.map((item) => [item.kind, item.confidence, item.direction, item.table, item.column]),
|
||||
[
|
||||
["foreignKey", "certain", "outgoing", "users", "id"],
|
||||
["foreignKey", "certain", "incoming", "order_events", "order_user_id"],
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("finds view, query history, and same-name column references without scanning table data", () => {
|
||||
const result = analyzeFieldLineage({
|
||||
target: { schema: "public", table: "users", column: "email" },
|
||||
tables: [
|
||||
{ schema: "public", name: "users", columns: ["id", "email"], foreignKeys: [] },
|
||||
{ schema: "public", name: "newsletter_subscribers", columns: ["id", "email"], foreignKeys: [] },
|
||||
],
|
||||
views: [
|
||||
{
|
||||
schema: "public",
|
||||
name: "active_user_emails",
|
||||
ddl: "CREATE VIEW active_user_emails AS SELECT u.email FROM public.users u WHERE u.active = true",
|
||||
},
|
||||
],
|
||||
histories: [
|
||||
{
|
||||
id: "h1",
|
||||
sql: "select email from users where email like '%@example.com'",
|
||||
executed_at: "2026-05-02T00:00:00Z",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
result.items.map((item) => [item.kind, item.confidence, item.table, item.column]),
|
||||
[
|
||||
["viewReference", "likely", "active_user_emails", undefined],
|
||||
["historyReference", "likely", undefined, undefined],
|
||||
["sameName", "possible", "newsletter_subscribers", "email"],
|
||||
],
|
||||
);
|
||||
assert.deepEqual(summarizeLineageCounts(result.items), {
|
||||
certain: 0,
|
||||
likely: 2,
|
||||
possible: 1,
|
||||
});
|
||||
});
|
||||
|
||||
test("matches quoted and bare identifiers safely", () => {
|
||||
assert.equal(identifierInSql('select "User Email" from users', "User Email"), true);
|
||||
assert.equal(identifierInSql("select `email` from users", "email"), true);
|
||||
assert.equal(identifierInSql("select user_email from users", "email"), false);
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import test from "node:test";
|
||||
import { matchesRowStatusFilter, rowStatusFilterAfterAddingRow, type RowStatus } from "../../src/lib/gridRowStatus.ts";
|
||||
|
||||
const statuses: RowStatus[] = ["clean", "edited", "new", "deleted"];
|
||||
|
||||
test("status filter includes every row in all mode", () => {
|
||||
assert.deepEqual(
|
||||
statuses.filter((status) => matchesRowStatusFilter(status, "all")),
|
||||
["clean", "edited", "new", "deleted"],
|
||||
);
|
||||
});
|
||||
|
||||
test("status filter includes only changed rows in changed mode", () => {
|
||||
assert.deepEqual(
|
||||
statuses.filter((status) => matchesRowStatusFilter(status, "changed")),
|
||||
["edited", "new", "deleted"],
|
||||
);
|
||||
});
|
||||
|
||||
test("status filter can isolate a single changed status", () => {
|
||||
assert.deepEqual(
|
||||
statuses.filter((status) => matchesRowStatusFilter(status, "edited")),
|
||||
["edited"],
|
||||
);
|
||||
assert.deepEqual(
|
||||
statuses.filter((status) => matchesRowStatusFilter(status, "deleted")),
|
||||
["deleted"],
|
||||
);
|
||||
});
|
||||
|
||||
test("adding a row switches filters that would hide new rows back to all", () => {
|
||||
assert.equal(rowStatusFilterAfterAddingRow("deleted"), "all");
|
||||
assert.equal(rowStatusFilterAfterAddingRow("edited"), "all");
|
||||
assert.equal(rowStatusFilterAfterAddingRow("changed"), "changed");
|
||||
assert.equal(rowStatusFilterAfterAddingRow("new"), "new");
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import test from "node:test";
|
||||
import {
|
||||
extractSelection,
|
||||
formatSelectionAsCsv,
|
||||
formatSelectionAsJson,
|
||||
formatSelectionAsSqlInList,
|
||||
formatSelectionAsTsv,
|
||||
isCellInSelection,
|
||||
normalizeSelectionRange,
|
||||
} from "../../src/lib/gridSelection.ts";
|
||||
|
||||
test("normalizes a dragged cell range in either direction", () => {
|
||||
const range = normalizeSelectionRange(
|
||||
{ rowIndex: 4, colIndex: 3 },
|
||||
{ rowIndex: 1, colIndex: 0 },
|
||||
);
|
||||
|
||||
assert.deepEqual(range, {
|
||||
startRow: 1,
|
||||
endRow: 4,
|
||||
startCol: 0,
|
||||
endCol: 3,
|
||||
});
|
||||
assert.equal(isCellInSelection(2, 1, range), true);
|
||||
assert.equal(isCellInSelection(5, 1, range), false);
|
||||
});
|
||||
|
||||
test("extracts selection rows and columns from a rectangular range", () => {
|
||||
const selection = extractSelection(
|
||||
["id", "name", "active"],
|
||||
[
|
||||
[1, "Ada", true],
|
||||
[2, "Linus", false],
|
||||
[3, null, true],
|
||||
],
|
||||
{ startRow: 0, endRow: 1, startCol: 1, endCol: 2 },
|
||||
);
|
||||
|
||||
assert.deepEqual(selection.columns, ["name", "active"]);
|
||||
assert.deepEqual(selection.rows, [
|
||||
["Ada", true],
|
||||
["Linus", false],
|
||||
]);
|
||||
});
|
||||
|
||||
test("formats selected cells as TSV, CSV, JSON, and SQL values", () => {
|
||||
const selection = {
|
||||
columns: ["name", "note"],
|
||||
rows: [
|
||||
["Ada", "math"],
|
||||
["Bob", "quote \"here\""],
|
||||
["O'Hara", null],
|
||||
],
|
||||
};
|
||||
|
||||
assert.equal(formatSelectionAsTsv(selection), "Ada\tmath\nBob\tquote \"here\"\nO'Hara\tNULL");
|
||||
assert.equal(formatSelectionAsCsv(selection), "\"name\",\"note\"\n\"Ada\",\"math\"\n\"Bob\",\"quote \"\"here\"\"\"\n\"O'Hara\",\"NULL\"");
|
||||
assert.equal(
|
||||
formatSelectionAsJson(selection),
|
||||
JSON.stringify([
|
||||
{ name: "Ada", note: "math" },
|
||||
{ name: "Bob", note: "quote \"here\"" },
|
||||
{ name: "O'Hara", note: null },
|
||||
], null, 2),
|
||||
);
|
||||
assert.equal(formatSelectionAsSqlInList(selection), "('Ada', 'math', 'Bob', 'quote \"here\"', 'O''Hara', NULL)");
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import test from "node:test";
|
||||
import { shouldDeleteHistoryEntry, shouldClearHistory } from "../../src/lib/historyActions.ts";
|
||||
|
||||
test("requires confirmation before deleting history entries", () => {
|
||||
assert.equal(shouldDeleteHistoryEntry(() => false), false);
|
||||
assert.equal(shouldDeleteHistoryEntry(() => true), true);
|
||||
});
|
||||
|
||||
test("requires existing entries and confirmation before clearing history", () => {
|
||||
assert.equal(shouldClearHistory(0, () => true), false);
|
||||
assert.equal(shouldClearHistory(2, () => false), false);
|
||||
assert.equal(shouldClearHistory(2, () => true), true);
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import test from "node:test";
|
||||
import { resolveHistoryActivityKind } from "../../src/lib/historyActivityKind.ts";
|
||||
|
||||
test("legacy INSERT history entries are treated as data changes", () => {
|
||||
assert.equal(
|
||||
resolveHistoryActivityKind({
|
||||
activity_kind: "query",
|
||||
operation: "INSERT",
|
||||
sql: "INSERT INTO job_entries (id) VALUES (1)",
|
||||
}),
|
||||
"data_change",
|
||||
);
|
||||
});
|
||||
|
||||
test("legacy CREATE history entries are treated as schema changes", () => {
|
||||
assert.equal(
|
||||
resolveHistoryActivityKind({
|
||||
activity_kind: "query",
|
||||
operation: "CREATE",
|
||||
sql: "CREATE TABLE resume_contexts (id bigint)",
|
||||
}),
|
||||
"schema_change",
|
||||
);
|
||||
});
|
||||
|
||||
test("SELECT history entries stay queries", () => {
|
||||
assert.equal(
|
||||
resolveHistoryActivityKind({
|
||||
activity_kind: "query",
|
||||
operation: "SELECT",
|
||||
sql: "SELECT * FROM education_notes",
|
||||
}),
|
||||
"query",
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import test from "node:test";
|
||||
import {
|
||||
buildHistoryAiAnalysisPrompt,
|
||||
canRollbackHistoryEntry,
|
||||
type HistoryAiAnalysisEntry,
|
||||
} from "../../src/lib/historyAiAnalysis.ts";
|
||||
|
||||
const baseEntry: HistoryAiAnalysisEntry = {
|
||||
id: "h1",
|
||||
connection_name: "Local MySQL",
|
||||
database: "app",
|
||||
sql: "UPDATE users SET status = 'inactive' WHERE id = 42;",
|
||||
executed_at: "2026-05-15T07:30:00.000Z",
|
||||
execution_time_ms: 125,
|
||||
success: true,
|
||||
activity_kind: "data_change",
|
||||
operation: "UPDATE",
|
||||
target: "public.users",
|
||||
affected_rows: 1,
|
||||
rollback_sql: "UPDATE users SET status = 'active' WHERE id = 42;",
|
||||
};
|
||||
|
||||
test("buildHistoryAiAnalysisPrompt includes operation details and rollback SQL", () => {
|
||||
const prompt = buildHistoryAiAnalysisPrompt(baseEntry);
|
||||
|
||||
assert.match(prompt, /分析这条 DBX 历史记录/);
|
||||
assert.match(prompt, /Connection: Local MySQL/);
|
||||
assert.match(prompt, /Operation: UPDATE/);
|
||||
assert.match(prompt, /Affected rows: 1/);
|
||||
assert.match(prompt, /UPDATE users SET status = 'inactive'/);
|
||||
assert.match(prompt, /Rollback SQL/);
|
||||
assert.match(prompt, /UPDATE users SET status = 'active'/);
|
||||
});
|
||||
|
||||
test("buildHistoryAiAnalysisPrompt records when rollback SQL is unavailable", () => {
|
||||
const prompt = buildHistoryAiAnalysisPrompt({
|
||||
...baseEntry,
|
||||
rollback_sql: null,
|
||||
error: "permission denied",
|
||||
success: false,
|
||||
});
|
||||
|
||||
assert.match(prompt, /Status: failed/);
|
||||
assert.match(prompt, /Error: permission denied/);
|
||||
assert.match(prompt, /Rollback SQL:\n\(not available\)/);
|
||||
});
|
||||
|
||||
test("canRollbackHistoryEntry requires connection, database, and rollback SQL", () => {
|
||||
assert.equal(canRollbackHistoryEntry({ ...baseEntry, connection_id: "conn-1" }), true);
|
||||
assert.equal(canRollbackHistoryEntry({ ...baseEntry, connection_id: "" }), false);
|
||||
assert.equal(canRollbackHistoryEntry({ ...baseEntry, connection_id: "conn-1", database: "" }), false);
|
||||
assert.equal(canRollbackHistoryEntry({ ...baseEntry, connection_id: "conn-1", rollback_sql: "" }), false);
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import test from "node:test";
|
||||
import {
|
||||
HISTORY_ROW_HEIGHT,
|
||||
HISTORY_SCROLL_BUFFER,
|
||||
shouldVirtualizeHistory,
|
||||
} from "../../src/lib/historyVirtualList.ts";
|
||||
|
||||
test("history list virtualizes every non-empty result set", () => {
|
||||
assert.equal(shouldVirtualizeHistory(0), false);
|
||||
assert.equal(shouldVirtualizeHistory(1), true);
|
||||
assert.equal(shouldVirtualizeHistory(1000), true);
|
||||
});
|
||||
|
||||
test("history virtual list keeps enough buffer for smooth scrolling", () => {
|
||||
assert.equal(HISTORY_ROW_HEIGHT, 72);
|
||||
assert.ok(HISTORY_SCROLL_BUFFER >= HISTORY_ROW_HEIGHT * 6);
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import test from "node:test";
|
||||
import {
|
||||
eventToShortcut,
|
||||
isCancelSearchShortcut,
|
||||
isCloseTabShortcut,
|
||||
isExecuteSqlShortcut,
|
||||
isFocusSearchShortcut,
|
||||
isObjectSourceSaveShortcutTarget,
|
||||
isSaveShortcut,
|
||||
} from "../../src/lib/keyboardShortcuts.ts";
|
||||
import { shortcutToCodeMirrorKey } from "../../src/lib/shortcutRegistry.ts";
|
||||
|
||||
test("matches Cmd+Enter for SQL execution", () => {
|
||||
assert.equal(isExecuteSqlShortcut({ key: "Enter", metaKey: true }), true);
|
||||
});
|
||||
|
||||
test("matches custom shortcut settings for SQL execution", () => {
|
||||
assert.equal(
|
||||
isExecuteSqlShortcut({ key: "Enter", metaKey: true }, { executeSql: "Shift+Mod+Enter" } as any),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
isExecuteSqlShortcut({ key: "Enter", metaKey: true, shiftKey: true } as any, {
|
||||
executeSql: "Shift+Mod+Enter",
|
||||
} as any),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("records custom shortcuts from keydown events", () => {
|
||||
assert.equal(eventToShortcut({ key: "r", metaKey: true, shiftKey: true } as any), "Shift+Mod+R");
|
||||
assert.equal(eventToShortcut({ key: "F2" } as any), "F2");
|
||||
assert.equal(eventToShortcut({ key: "Control", ctrlKey: true } as any), null);
|
||||
});
|
||||
|
||||
test("converts custom shortcuts to CodeMirror key names", () => {
|
||||
assert.equal(shortcutToCodeMirrorKey("Shift+Mod+R"), "Shift-Mod-r");
|
||||
assert.equal(shortcutToCodeMirrorKey("Mod+/"), "Mod-/");
|
||||
});
|
||||
|
||||
test("matches Ctrl+Enter for SQL execution", () => {
|
||||
assert.equal(isExecuteSqlShortcut({ key: "Enter", ctrlKey: true }), true);
|
||||
});
|
||||
|
||||
test("ignores Enter without modifier", () => {
|
||||
assert.equal(isExecuteSqlShortcut({ key: "Enter" }), false);
|
||||
});
|
||||
|
||||
test("ignores composing input events", () => {
|
||||
assert.equal(isExecuteSqlShortcut({ key: "Enter", metaKey: true, isComposing: true }), false);
|
||||
});
|
||||
|
||||
test("matches Cmd+W for closing query tabs", () => {
|
||||
assert.equal(isCloseTabShortcut({ key: "w", metaKey: true }), true);
|
||||
});
|
||||
|
||||
test("ignores Ctrl+W for closing query tabs", () => {
|
||||
assert.equal(isCloseTabShortcut({ key: "w", ctrlKey: true }), false);
|
||||
});
|
||||
|
||||
test("matches Ctrl+F for focusing search", () => {
|
||||
assert.equal(isFocusSearchShortcut({ key: "f", ctrlKey: true }), true);
|
||||
});
|
||||
|
||||
test("matches Cmd+F for focusing search", () => {
|
||||
assert.equal(isFocusSearchShortcut({ key: "F", metaKey: true }), true);
|
||||
});
|
||||
|
||||
test("ignores focus search shortcut while composing", () => {
|
||||
assert.equal(isFocusSearchShortcut({ key: "f", ctrlKey: true, isComposing: true }), false);
|
||||
});
|
||||
|
||||
test("ignores Alt+F for focusing search", () => {
|
||||
assert.equal(isFocusSearchShortcut({ key: "f", altKey: true }), false);
|
||||
});
|
||||
|
||||
test("matches Cmd+S for saving", () => {
|
||||
assert.equal(isSaveShortcut({ key: "s", metaKey: true }), true);
|
||||
});
|
||||
|
||||
test("matches Ctrl+S for saving", () => {
|
||||
assert.equal(isSaveShortcut({ key: "S", ctrlKey: true }), true);
|
||||
});
|
||||
|
||||
test("ignores save shortcut while composing", () => {
|
||||
assert.equal(isSaveShortcut({ key: "s", metaKey: true, isComposing: true }), false);
|
||||
});
|
||||
|
||||
test("ignores Alt+S for saving", () => {
|
||||
assert.equal(isSaveShortcut({ key: "s", altKey: true }), false);
|
||||
});
|
||||
|
||||
test("detects object source editor targets for contextual save", () => {
|
||||
const target = {
|
||||
closest: (selector: string) =>
|
||||
selector === "[data-object-source-editor], [data-object-source-preview]" ? {} : null,
|
||||
};
|
||||
|
||||
assert.equal(isObjectSourceSaveShortcutTarget(target), true);
|
||||
});
|
||||
|
||||
test("ignores regular editor targets for contextual object source save", () => {
|
||||
const target = {
|
||||
closest: () => null,
|
||||
};
|
||||
|
||||
assert.equal(isObjectSourceSaveShortcutTarget(target), false);
|
||||
});
|
||||
|
||||
test("matches Escape for cancelling search", () => {
|
||||
assert.equal(isCancelSearchShortcut({ key: "Escape" }), true);
|
||||
});
|
||||
|
||||
test("ignores cancelling search while composing", () => {
|
||||
assert.equal(isCancelSearchShortcut({ key: "Escape", isComposing: true }), false);
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import test from "node:test";
|
||||
import { formatMarkdownTable } from "../../src/lib/markdownTable.ts";
|
||||
|
||||
test("escapes markdown table pipes and normalizes newlines", () => {
|
||||
const markdown = formatMarkdownTable({
|
||||
columns: ["id", "payload|kind"],
|
||||
rows: [
|
||||
[1, "a|b"],
|
||||
[2, "line one\nline two"],
|
||||
],
|
||||
});
|
||||
|
||||
assert.equal(markdown, [
|
||||
"| id | payload\\|kind |",
|
||||
"| --- | -------------------- |",
|
||||
"| 1 | a\\|b |",
|
||||
"| 2 | line one<br>line two |",
|
||||
"",
|
||||
].join("\n"));
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import test from "node:test";
|
||||
import { mongoDocumentsToQueryResult, parseMongoFindCommand } from "../../src/lib/mongoShellCommand.ts";
|
||||
|
||||
test("parseMongoFindCommand parses db collection find with an empty JSON filter", () => {
|
||||
assert.deepEqual(parseMongoFindCommand("db.users.find({})"), {
|
||||
collection: "users",
|
||||
filter: "{}",
|
||||
skip: 0,
|
||||
limit: 100,
|
||||
sort: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
test("parseMongoFindCommand parses getCollection find with chained sort skip and limit", () => {
|
||||
assert.deepEqual(
|
||||
parseMongoFindCommand(
|
||||
'db.getCollection("audit.logs").find({"level":"warn"}).sort({"createdAt":-1}).skip(20).limit(10)',
|
||||
),
|
||||
{
|
||||
collection: "audit.logs",
|
||||
filter: '{"level":"warn"}',
|
||||
skip: 20,
|
||||
limit: 10,
|
||||
sort: '{"createdAt":-1}',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("parseMongoFindCommand rejects unsupported mongo shell commands", () => {
|
||||
assert.equal(parseMongoFindCommand("db.users.insertOne({})"), null);
|
||||
});
|
||||
|
||||
test("mongoDocumentsToQueryResult turns mongo documents into grid rows", () => {
|
||||
const result = mongoDocumentsToQueryResult(
|
||||
[
|
||||
{ _id: "1", name: "Ada", profile: { role: "admin" } },
|
||||
{ _id: "2", active: true, name: "Lin" },
|
||||
],
|
||||
5,
|
||||
12,
|
||||
);
|
||||
|
||||
assert.deepEqual(result.columns, ["_id", "name", "profile", "active"]);
|
||||
assert.deepEqual(result.rows, [
|
||||
["1", "Ada", '{"role":"admin"}', null],
|
||||
["2", "Lin", null, true],
|
||||
]);
|
||||
assert.equal(result.affected_rows, 12);
|
||||
assert.equal(result.execution_time_ms, 5);
|
||||
assert.equal(result.truncated, true);
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import test from "node:test";
|
||||
import { buildRenameObjectSql, supportsObjectRename } from "../../src/lib/objectRenameSql.ts";
|
||||
|
||||
test("builds MySQL table and view rename statements", () => {
|
||||
assert.equal(
|
||||
buildRenameObjectSql({
|
||||
databaseType: "mysql",
|
||||
objectType: "TABLE",
|
||||
oldName: "users",
|
||||
newName: "app users",
|
||||
}),
|
||||
"RENAME TABLE `users` TO `app users`;",
|
||||
);
|
||||
assert.equal(
|
||||
buildRenameObjectSql({
|
||||
databaseType: "goldendb",
|
||||
objectType: "VIEW",
|
||||
oldName: "active_users",
|
||||
newName: "enabled_users",
|
||||
}),
|
||||
"RENAME TABLE `active_users` TO `enabled_users`;",
|
||||
);
|
||||
});
|
||||
|
||||
test("builds PostgreSQL table and view rename statements", () => {
|
||||
assert.equal(
|
||||
buildRenameObjectSql({
|
||||
databaseType: "postgres",
|
||||
objectType: "TABLE",
|
||||
schema: "public",
|
||||
oldName: "orders",
|
||||
newName: "archived orders",
|
||||
}),
|
||||
'ALTER TABLE "public"."orders" RENAME TO "archived orders";',
|
||||
);
|
||||
assert.equal(
|
||||
buildRenameObjectSql({
|
||||
databaseType: "postgres",
|
||||
objectType: "VIEW",
|
||||
schema: "public",
|
||||
oldName: "active_users",
|
||||
newName: "enabled_users",
|
||||
}),
|
||||
'ALTER VIEW "public"."active_users" RENAME TO "enabled_users";',
|
||||
);
|
||||
});
|
||||
|
||||
test("builds SQL Server rename statements for all object kinds", () => {
|
||||
assert.equal(
|
||||
buildRenameObjectSql({
|
||||
databaseType: "sqlserver",
|
||||
objectType: "FUNCTION",
|
||||
schema: "dbo",
|
||||
oldName: "fn_total",
|
||||
newName: "fn_order_total",
|
||||
}),
|
||||
"EXEC sp_rename N'dbo.fn_total', N'fn_order_total', N'OBJECT';",
|
||||
);
|
||||
assert.equal(supportsObjectRename("sqlserver", "PROCEDURE"), true);
|
||||
});
|
||||
|
||||
test("builds Oracle-family table and view rename statements", () => {
|
||||
assert.equal(
|
||||
buildRenameObjectSql({
|
||||
databaseType: "oracle",
|
||||
objectType: "TABLE",
|
||||
schema: "HR",
|
||||
oldName: "EMPLOYEES",
|
||||
newName: "STAFF",
|
||||
}),
|
||||
'ALTER TABLE "HR"."EMPLOYEES" RENAME TO "STAFF";',
|
||||
);
|
||||
assert.equal(
|
||||
buildRenameObjectSql({
|
||||
databaseType: "dameng",
|
||||
objectType: "VIEW",
|
||||
schema: "SYSDBA",
|
||||
oldName: "ACTIVE_USERS",
|
||||
newName: "ENABLED_USERS",
|
||||
}),
|
||||
'ALTER VIEW "SYSDBA"."ACTIVE_USERS" RENAME TO "ENABLED_USERS";',
|
||||
);
|
||||
});
|
||||
|
||||
test("reports unsupported routine rename cases", () => {
|
||||
assert.equal(supportsObjectRename("mysql", "PROCEDURE"), false);
|
||||
assert.equal(supportsObjectRename("postgres", "FUNCTION"), false);
|
||||
assert.throws(
|
||||
() =>
|
||||
buildRenameObjectSql({
|
||||
databaseType: "mysql",
|
||||
objectType: "PROCEDURE",
|
||||
oldName: "refresh_cache",
|
||||
newName: "refresh_cache_v2",
|
||||
}),
|
||||
/Renaming PROCEDURE is not supported/,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import test from "node:test";
|
||||
import { buildExecutableObjectSourceSql, objectSourceSaveExecutionMode } from "../../src/lib/objectSourceEditor.ts";
|
||||
|
||||
test("SQL Server edited source saves as ALTER", () => {
|
||||
const sql = buildExecutableObjectSourceSql({
|
||||
databaseType: "sqlserver",
|
||||
objectType: "PROCEDURE",
|
||||
schema: "dbo",
|
||||
name: "usp_demo",
|
||||
source: "CREATE PROCEDURE dbo.usp_demo AS SELECT 1;",
|
||||
});
|
||||
|
||||
assert.equal(sql, "ALTER PROCEDURE dbo.usp_demo AS SELECT 1;");
|
||||
});
|
||||
|
||||
test("SQL Server edited CREATE OR ALTER source saves as ALTER", () => {
|
||||
const sql = buildExecutableObjectSourceSql({
|
||||
databaseType: "sqlserver",
|
||||
objectType: "VIEW",
|
||||
schema: "dbo",
|
||||
name: "vw_demo",
|
||||
source: "CREATE OR ALTER VIEW dbo.vw_demo AS SELECT 1 AS id;",
|
||||
});
|
||||
|
||||
assert.equal(sql, "ALTER VIEW dbo.vw_demo AS SELECT 1 AS id;");
|
||||
});
|
||||
|
||||
test("SQL Server object source saves as a single batch", () => {
|
||||
assert.equal(objectSourceSaveExecutionMode("sqlserver"), "single");
|
||||
});
|
||||
|
||||
test("Kingbase object source saves as a single statement", () => {
|
||||
assert.equal(objectSourceSaveExecutionMode("kingbase"), "single");
|
||||
});
|
||||
|
||||
test("Postgres-family object source saves as a single statement", () => {
|
||||
assert.equal(objectSourceSaveExecutionMode("postgres"), "single");
|
||||
assert.equal(objectSourceSaveExecutionMode("gaussdb"), "single");
|
||||
});
|
||||
|
||||
test("MySQL object source saves as a single statement", () => {
|
||||
assert.equal(objectSourceSaveExecutionMode("mysql"), "single");
|
||||
});
|
||||
|
||||
test("Postgres view body opens as CREATE OR REPLACE VIEW", () => {
|
||||
const sql = buildExecutableObjectSourceSql({
|
||||
databaseType: "postgres",
|
||||
objectType: "VIEW",
|
||||
schema: "public",
|
||||
name: "active users",
|
||||
source: " SELECT id, name FROM users WHERE active ",
|
||||
});
|
||||
|
||||
assert.equal(sql, 'CREATE OR REPLACE VIEW "public"."active users" AS\nSELECT id, name FROM users WHERE active;');
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import test from "node:test";
|
||||
import { restoreOpenTabsState, serializeOpenTabs } from "../../src/lib/openTabsPersistence.ts";
|
||||
import type { QueryTab } from "../../src/types/database.ts";
|
||||
|
||||
function queryTab(overrides: Partial<QueryTab> = {}): QueryTab {
|
||||
return {
|
||||
id: "tab-1",
|
||||
title: "Query 1",
|
||||
connectionId: "conn-1",
|
||||
database: "app",
|
||||
schema: "public",
|
||||
sql: "select * from users",
|
||||
pinned: true,
|
||||
isExecuting: false,
|
||||
isCancelling: false,
|
||||
isExplaining: false,
|
||||
mode: "query",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test("serializes unsaved query tabs with editor context", () => {
|
||||
const saved = serializeOpenTabs([queryTab()]);
|
||||
|
||||
assert.deepEqual(saved, [
|
||||
{
|
||||
id: "tab-1",
|
||||
title: "Query 1",
|
||||
connectionId: "conn-1",
|
||||
database: "app",
|
||||
schema: "public",
|
||||
sql: "select * from users",
|
||||
savedSqlId: undefined,
|
||||
pinned: true,
|
||||
mode: "query",
|
||||
objectBrowser: undefined,
|
||||
objectSource: undefined,
|
||||
tableMeta: undefined,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("serializes object source query tabs with save context", () => {
|
||||
const saved = serializeOpenTabs([
|
||||
queryTab({
|
||||
title: "fn_add",
|
||||
objectSource: {
|
||||
schema: "public",
|
||||
name: "fn_add",
|
||||
objectType: "FUNCTION",
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
assert.deepEqual(saved[0]?.objectSource, {
|
||||
schema: "public",
|
||||
name: "fn_add",
|
||||
objectType: "FUNCTION",
|
||||
});
|
||||
});
|
||||
|
||||
test("restores unsaved query tabs and active tab after restart", () => {
|
||||
const raw = JSON.stringify([
|
||||
queryTab({ id: "tab-1", sql: "select 1" }),
|
||||
queryTab({ id: "tab-2", title: "Query 2", sql: "select 2" }),
|
||||
]);
|
||||
|
||||
const restored = restoreOpenTabsState(raw, "tab-2");
|
||||
|
||||
assert.deepEqual(
|
||||
restored.tabs.map((tab) => ({ id: tab.id, sql: tab.sql, isExecuting: tab.isExecuting })),
|
||||
[
|
||||
{ id: "tab-1", sql: "select 1", isExecuting: false },
|
||||
{ id: "tab-2", sql: "select 2", isExecuting: false },
|
||||
],
|
||||
);
|
||||
assert.equal(restored.activeTabId, "tab-2");
|
||||
});
|
||||
|
||||
test("restores object source save context", () => {
|
||||
const raw = JSON.stringify([
|
||||
queryTab({
|
||||
objectSource: {
|
||||
schema: "public",
|
||||
name: "fn_add",
|
||||
objectType: "FUNCTION",
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const restored = restoreOpenTabsState(raw, "tab-1");
|
||||
|
||||
assert.deepEqual(restored.tabs[0]?.objectSource, {
|
||||
schema: "public",
|
||||
name: "fn_add",
|
||||
objectType: "FUNCTION",
|
||||
});
|
||||
});
|
||||
|
||||
test("desktop restore keeps legacy query tabs without a mode", () => {
|
||||
const raw = JSON.stringify([
|
||||
{
|
||||
id: "legacy",
|
||||
title: "Query 1",
|
||||
connectionId: "conn-1",
|
||||
database: "app",
|
||||
sql: "select now()",
|
||||
},
|
||||
queryTab({ id: "data", title: "users", mode: "data" }),
|
||||
]);
|
||||
|
||||
const restored = restoreOpenTabsState(raw, "data", { queryOnly: true });
|
||||
|
||||
assert.deepEqual(
|
||||
restored.tabs.map((tab) => ({ id: tab.id, mode: tab.mode })),
|
||||
[{ id: "legacy", mode: "query" }],
|
||||
);
|
||||
assert.equal(restored.activeTabId, "legacy");
|
||||
});
|
||||
|
||||
test("ignores invalid persisted tab payloads", () => {
|
||||
const restored = restoreOpenTabsState(JSON.stringify([{ id: "missing-fields" }, queryTab()]), "missing-fields");
|
||||
|
||||
assert.deepEqual(
|
||||
restored.tabs.map((tab) => tab.id),
|
||||
["tab-1"],
|
||||
);
|
||||
assert.equal(restored.activeTabId, "tab-1");
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import test from "node:test";
|
||||
import { applyPinnedTreeNodeState, orderPinnedFirst } from "../../src/lib/pinnedItems.ts";
|
||||
import type { TreeNode } from "../../src/types/database.ts";
|
||||
|
||||
test("orders pinned items before unpinned items without changing relative order", () => {
|
||||
const items = [
|
||||
{ id: "a" },
|
||||
{ id: "b", pinned: true },
|
||||
{ id: "c" },
|
||||
{ id: "d", pinned: true },
|
||||
{ id: "e" },
|
||||
];
|
||||
|
||||
const ordered = orderPinnedFirst(items, (item) => !!item.pinned);
|
||||
|
||||
assert.deepEqual(ordered.map((item) => item.id), ["b", "d", "a", "c", "e"]);
|
||||
assert.deepEqual(items.map((item) => item.id), ["a", "b", "c", "d", "e"]);
|
||||
});
|
||||
|
||||
test("applies pinned state recursively to grouped tree nodes", () => {
|
||||
const nodes: TreeNode[] = [
|
||||
{
|
||||
id: "conn:db:__tables",
|
||||
label: "tree.tables",
|
||||
type: "group-tables",
|
||||
children: [
|
||||
{ id: "conn:db:orders", label: "orders", type: "table" },
|
||||
{ id: "conn:db:users", label: "users", type: "table" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const result = applyPinnedTreeNodeState(nodes, new Set(["conn:db:users"]));
|
||||
|
||||
assert.equal(result[0].children?.[0].id, "conn:db:users");
|
||||
assert.equal(result[0].children?.[0].pinned, true);
|
||||
assert.equal(result[0].children?.[1].id, "conn:db:orders");
|
||||
assert.equal(result[0].children?.[1].pinned, false);
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import test from "node:test";
|
||||
import { canCancelQueryExecution, queryExecutionLabelKey } from "../../src/lib/queryExecutionState.ts";
|
||||
|
||||
test("only allows cancelling an active execution with an execution id", () => {
|
||||
assert.equal(canCancelQueryExecution({ isExecuting: true, executionId: "exec-1" }), true);
|
||||
assert.equal(canCancelQueryExecution({ isExecuting: true, executionId: "exec-1", isCancelling: true }), false);
|
||||
assert.equal(canCancelQueryExecution({ isExecuting: true }), false);
|
||||
assert.equal(canCancelQueryExecution({ isExecuting: false, executionId: "exec-1" }), false);
|
||||
});
|
||||
|
||||
test("uses a stopping label while cancellation is in progress", () => {
|
||||
assert.equal(queryExecutionLabelKey({ isExecuting: true }), "common.loading");
|
||||
assert.equal(queryExecutionLabelKey({ isExecuting: true, isCancelling: true }), "common.stopping");
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {
|
||||
buildCountQuerySql,
|
||||
buildPaginatedQuerySql,
|
||||
buildQueryPaginationExecutionPlan,
|
||||
} from "../../src/lib/queryResultPagination.ts";
|
||||
|
||||
test("wraps a single select query with limit and offset", () => {
|
||||
const result = buildPaginatedQuerySql("SELECT id, name FROM users;", "postgres", 100, 200);
|
||||
|
||||
assert.deepEqual(result, {
|
||||
ok: true,
|
||||
sql: 'SELECT * FROM (SELECT id, name FROM users) "dbx_page" LIMIT 100 OFFSET 200;',
|
||||
});
|
||||
});
|
||||
|
||||
test("uses MySQL style quoting for paginated query alias", () => {
|
||||
const result = buildPaginatedQuerySql("SELECT id FROM users WHERE active = 1", "mysql", 50, 0);
|
||||
|
||||
assert.deepEqual(result, {
|
||||
ok: true,
|
||||
sql: "SELECT * FROM (SELECT id FROM users WHERE active = 1) `dbx_page` LIMIT 50;",
|
||||
});
|
||||
});
|
||||
|
||||
test("uses SQL Server offset fetch pagination", () => {
|
||||
const result = buildPaginatedQuerySql("SELECT id FROM users", "sqlserver", 100, 300);
|
||||
|
||||
assert.deepEqual(result, {
|
||||
ok: true,
|
||||
sql: "SELECT * FROM (SELECT id FROM users) [dbx_page] ORDER BY (SELECT NULL) OFFSET 300 ROWS FETCH NEXT 100 ROWS ONLY",
|
||||
});
|
||||
});
|
||||
|
||||
test("uses fetch first pagination for Oracle first page", () => {
|
||||
const result = buildPaginatedQuerySql("SELECT id FROM users", "oracle", 100, 0);
|
||||
|
||||
assert.deepEqual(result, {
|
||||
ok: true,
|
||||
sql: 'SELECT * FROM (SELECT id FROM users) "dbx_page" FETCH FIRST 100 ROWS ONLY',
|
||||
});
|
||||
});
|
||||
|
||||
test("supports CTE select queries", () => {
|
||||
const result = buildPaginatedQuerySql("WITH cte AS (SELECT 1 AS id) SELECT * FROM cte", "clickhouse", 100, 0);
|
||||
|
||||
assert.deepEqual(result, {
|
||||
ok: true,
|
||||
sql: 'SELECT * FROM (WITH cte AS (SELECT 1 AS id) SELECT * FROM cte) "dbx_page" LIMIT 100;',
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects multiple statements", () => {
|
||||
const result = buildPaginatedQuerySql("SELECT 1; SELECT 2;", "postgres", 100, 0);
|
||||
|
||||
assert.deepEqual(result, { ok: false, reason: "multi" });
|
||||
});
|
||||
|
||||
test("rejects non select statements", () => {
|
||||
const result = buildPaginatedQuerySql("UPDATE users SET name = 'A'", "postgres", 100, 0);
|
||||
|
||||
assert.deepEqual(result, { ok: false, reason: "not_select" });
|
||||
});
|
||||
|
||||
test("wraps a single select query for total row count", () => {
|
||||
const result = buildCountQuerySql("SELECT id, name FROM users;", "postgres");
|
||||
|
||||
assert.deepEqual(result, {
|
||||
ok: true,
|
||||
sql: 'SELECT COUNT(*) AS dbx_total_rows FROM (SELECT id, name FROM users) "dbx_count";',
|
||||
});
|
||||
});
|
||||
|
||||
test("uses MySQL style quoting for count query alias", () => {
|
||||
const result = buildCountQuerySql("WITH cte AS (SELECT 1 AS id) SELECT * FROM cte", "mysql");
|
||||
|
||||
assert.deepEqual(result, {
|
||||
ok: true,
|
||||
sql: "SELECT COUNT(*) AS dbx_total_rows FROM (WITH cte AS (SELECT 1 AS id) SELECT * FROM cte) `dbx_count`;",
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects count query for unsupported database types", () => {
|
||||
const result = buildCountQuerySql("SELECT * FROM nodes", "neo4j");
|
||||
|
||||
assert.deepEqual(result, { ok: false, reason: "unsupported" });
|
||||
});
|
||||
|
||||
test("rejects count query for multiple statements", () => {
|
||||
const result = buildCountQuerySql("SELECT 1; SELECT 2;", "postgres");
|
||||
|
||||
assert.deepEqual(result, { ok: false, reason: "multi" });
|
||||
});
|
||||
|
||||
test("uses an agent result session for the first jdbc page", () => {
|
||||
const plan = buildQueryPaginationExecutionPlan({
|
||||
sql: "SELECT * FROM events",
|
||||
queryBaseSql: "SELECT * FROM events",
|
||||
databaseType: "oracle",
|
||||
pagination: { limit: 500, offset: 0 },
|
||||
useAgentCursor: true,
|
||||
});
|
||||
|
||||
assert.equal(plan.sqlToExecute, "SELECT * FROM events");
|
||||
assert.equal(plan.pageLimit, 500);
|
||||
assert.equal(plan.pageOffset, 0);
|
||||
assert.equal(plan.pageSql, undefined);
|
||||
assert.equal(plan.useAgentResultSession, true);
|
||||
});
|
||||
|
||||
test("keeps using an agent result session for sequential jdbc pages", () => {
|
||||
const plan = buildQueryPaginationExecutionPlan({
|
||||
sql: "SELECT * FROM events",
|
||||
queryBaseSql: "SELECT * FROM events",
|
||||
databaseType: "oracle",
|
||||
pagination: { limit: 500, offset: 500, sessionId: "session-1" },
|
||||
useAgentCursor: true,
|
||||
});
|
||||
|
||||
assert.equal(plan.sqlToExecute, "SELECT * FROM events");
|
||||
assert.equal(plan.pageLimit, 500);
|
||||
assert.equal(plan.pageOffset, 500);
|
||||
assert.equal(plan.useAgentResultSession, true);
|
||||
});
|
||||
|
||||
test("uses SQL pagination instead of jdbc cursor for random agent page jumps", () => {
|
||||
const plan = buildQueryPaginationExecutionPlan({
|
||||
sql: "SELECT * FROM events",
|
||||
queryBaseSql: "SELECT * FROM events",
|
||||
databaseType: "oracle",
|
||||
pagination: { limit: 500, offset: 1500 },
|
||||
useAgentCursor: true,
|
||||
});
|
||||
|
||||
assert.equal(plan.sqlToExecute, 'SELECT * FROM (SELECT * FROM events) "dbx_page" OFFSET 1500 ROWS FETCH FIRST 500 ROWS ONLY');
|
||||
assert.equal(plan.pageSql, plan.sqlToExecute);
|
||||
assert.equal(plan.pageLimit, 500);
|
||||
assert.equal(plan.pageOffset, 1500);
|
||||
assert.equal(plan.useAgentResultSession, false);
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { buildSortedQuerySql } from "../../src/lib/queryResultSort.ts";
|
||||
|
||||
test("wraps a single select query with outer order by", () => {
|
||||
const result = buildSortedQuerySql("SELECT id, name FROM users;", "postgres", ["id", "name"], 1, "name", "asc");
|
||||
assert.deepEqual(result, {
|
||||
ok: true,
|
||||
sql: 'SELECT * FROM (SELECT id, name FROM users) t("id", "name") ORDER BY "name" ASC;',
|
||||
});
|
||||
});
|
||||
|
||||
test("preserves complex select body when wrapping sort sql", () => {
|
||||
const result = buildSortedQuerySql("SELECT id FROM users WHERE status = 'A'", "mysql", ["id"], 0, "id", "desc");
|
||||
assert.deepEqual(result, {
|
||||
ok: true,
|
||||
sql: "SELECT * FROM (SELECT id FROM users WHERE status = 'A') t(`id`) ORDER BY `id` DESC;",
|
||||
});
|
||||
});
|
||||
|
||||
test("assigns unique aliases for duplicate result column names", () => {
|
||||
const result = buildSortedQuerySql(
|
||||
"SELECT c.id, m.id FROM t_campaign c LEFT JOIN t_campaign_mdf m ON m.campaign_id = c.id",
|
||||
"mysql",
|
||||
["id", "id"],
|
||||
1,
|
||||
"id",
|
||||
"asc",
|
||||
);
|
||||
assert.deepEqual(result, {
|
||||
ok: true,
|
||||
sql: "SELECT * FROM (SELECT c.id, m.id FROM t_campaign c LEFT JOIN t_campaign_mdf m ON m.campaign_id = c.id) t(`id`, `id_2`) ORDER BY `id_2` ASC;",
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects multiple statements for result sorting", () => {
|
||||
const result = buildSortedQuerySql("SELECT 1; SELECT 2;", "postgres", ["id"], 0, "id", "asc");
|
||||
assert.deepEqual(result, { ok: false, reason: "multi" });
|
||||
});
|
||||
|
||||
test("rejects cte queries for result sorting", () => {
|
||||
const result = buildSortedQuerySql("WITH cte AS (SELECT 1) SELECT * FROM cte", "postgres", ["id"], 0, "id", "asc");
|
||||
assert.deepEqual(result, { ok: false, reason: "with" });
|
||||
});
|
||||
|
||||
test("rejects non select statements for result sorting", () => {
|
||||
const result = buildSortedQuerySql("UPDATE users SET name = 'A'", "postgres", ["name"], 0, "name", "asc");
|
||||
assert.deepEqual(result, { ok: false, reason: "not_select" });
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import test from "node:test";
|
||||
import { createPinia, setActivePinia } from "pinia";
|
||||
import { useQueryStore } from "../../src/stores/queryStore.ts";
|
||||
|
||||
test("setErrorResult stops loading and shows the error result", () => {
|
||||
setActivePinia(createPinia());
|
||||
const store = useQueryStore();
|
||||
const tabId = store.createTab("conn-1", "db", "users", "data");
|
||||
|
||||
store.setExecuting(tabId, true);
|
||||
store.setErrorResult(tabId, new Error("metadata failed"));
|
||||
|
||||
const tab = store.tabs.find((item) => item.id === tabId);
|
||||
assert.equal(tab?.isExecuting, false);
|
||||
assert.equal(tab?.isCancelling, false);
|
||||
assert.equal(tab?.executionId, undefined);
|
||||
assert.deepEqual(tab?.result?.columns, ["Error"]);
|
||||
assert.deepEqual(tab?.result?.rows, [["Error: metadata failed"]]);
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { classifyRedisCommandSafety, firstRedisCommandToken } from "../../src/lib/redisCommandSafety.ts";
|
||||
|
||||
test("firstRedisCommandToken reads the first command token case-insensitively", () => {
|
||||
assert.equal(firstRedisCommandToken(" get user:1"), "GET");
|
||||
assert.equal(firstRedisCommandToken('"set" user:1 Ada'), "SET");
|
||||
});
|
||||
|
||||
test("classifyRedisCommandSafety separates allowed confirmed and blocked commands", () => {
|
||||
assert.equal(classifyRedisCommandSafety("GET user:1"), "allowed");
|
||||
assert.equal(classifyRedisCommandSafety("set user:1 Ada"), "confirm");
|
||||
assert.equal(classifyRedisCommandSafety("FLUSHDB"), "confirm");
|
||||
assert.equal(classifyRedisCommandSafety("KEYS *"), "blocked");
|
||||
assert.equal(classifyRedisCommandSafety("flushall"), "blocked");
|
||||
assert.equal(classifyRedisCommandSafety("eval return 1 0"), "blocked");
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
buildRedisKeyTree,
|
||||
collectRedisGroupKeyRaws,
|
||||
collectExpandedGroupIds,
|
||||
flattenVisibleRedisKeyTree,
|
||||
type RedisKeyTreeNode,
|
||||
} from "../../src/lib/redisKeyTree.ts";
|
||||
import type { RedisKeyInfo } from "../../src/lib/api.ts";
|
||||
|
||||
function makeKey(key_display: string, key_raw: string, key_type = "string", ttl = -1): RedisKeyInfo {
|
||||
return { key_display, key_raw, key_type, ttl };
|
||||
}
|
||||
|
||||
function leafLabels(nodes: RedisKeyTreeNode[]): string[] {
|
||||
return nodes.filter((node) => node.kind === "leaf").map((node) => node.label);
|
||||
}
|
||||
|
||||
test("buildRedisKeyTree groups colon-delimited keys by segment", () => {
|
||||
const tree = buildRedisKeyTree(
|
||||
[makeKey("a:b:c", "k1"), makeKey("a:b:d", "k2"), makeKey("a:e", "k3"), makeKey("x", "k4")],
|
||||
0,
|
||||
);
|
||||
|
||||
assert.equal(tree.length, 2);
|
||||
assert.equal(tree[0]?.kind, "group");
|
||||
assert.equal(tree[1]?.kind, "leaf");
|
||||
assert.equal(tree[1]?.kind === "leaf" ? tree[1].fullKeyDisplay : "", "x");
|
||||
|
||||
const aGroup = tree[0];
|
||||
assert.equal(aGroup?.kind, "group");
|
||||
if (aGroup?.kind !== "group") return;
|
||||
|
||||
assert.deepEqual(aGroup.pathSegments, ["a"]);
|
||||
assert.deepEqual(leafLabels(aGroup.children), ["e"]);
|
||||
|
||||
const bGroup = aGroup.children.find((node) => node.kind === "group" && node.label === "b");
|
||||
assert.ok(bGroup);
|
||||
if (!bGroup || bGroup.kind !== "group") return;
|
||||
|
||||
assert.deepEqual(
|
||||
bGroup.children.map((node) => (node.kind === "leaf" ? node.fullKeyDisplay : node.label)),
|
||||
["a:b:c", "a:b:d"],
|
||||
);
|
||||
});
|
||||
|
||||
test("buildRedisKeyTree preserves binary prefix segments from display text", () => {
|
||||
const tree = buildRedisKeyTree([makeKey("\\xac\\xed\\x00\\x05t\\x00token:work:app", "k1")], 2);
|
||||
|
||||
assert.equal(tree.length, 1);
|
||||
const root = tree[0];
|
||||
assert.equal(root?.kind, "group");
|
||||
if (!root || root.kind !== "group") return;
|
||||
|
||||
assert.equal(root.label, "\\xac\\xed\\x00\\x05t\\x00token");
|
||||
const work = root.children[0];
|
||||
assert.equal(work?.kind, "group");
|
||||
if (!work || work.kind !== "group") return;
|
||||
|
||||
assert.equal(work.label, "work");
|
||||
const appLeaf = work.children[0];
|
||||
assert.equal(appLeaf?.kind, "leaf");
|
||||
if (!appLeaf || appLeaf.kind !== "leaf") return;
|
||||
assert.equal(appLeaf.label, "app");
|
||||
assert.equal(appLeaf.fullKeyDisplay, "\\xac\\xed\\x00\\x05t\\x00token:work:app");
|
||||
});
|
||||
|
||||
test("collectExpandedGroupIds and flattenVisibleRedisKeyTree expand all search paths", () => {
|
||||
const tree = buildRedisKeyTree([makeKey("user:profile:name", "k1"), makeKey("user:settings", "k2")], 0);
|
||||
const expanded = collectExpandedGroupIds(tree);
|
||||
const rows = flattenVisibleRedisKeyTree(tree, expanded);
|
||||
|
||||
assert.deepEqual(
|
||||
rows.map(({ node, depth }) => `${depth}:${node.kind}:${node.label}`),
|
||||
["0:group:user", "1:group:profile", "2:leaf:name", "1:leaf:settings"],
|
||||
);
|
||||
});
|
||||
|
||||
test("collectRedisGroupKeyRaws returns every leaf key under a group", () => {
|
||||
const tree = buildRedisKeyTree(
|
||||
[
|
||||
makeKey("user:profile:name", "k1"),
|
||||
makeKey("user:profile:email", "k2"),
|
||||
makeKey("user:settings", "k3"),
|
||||
makeKey("session:1", "k4"),
|
||||
],
|
||||
0,
|
||||
);
|
||||
|
||||
const userGroup = tree.find((node) => node.kind === "group" && node.label === "user");
|
||||
assert.ok(userGroup);
|
||||
if (!userGroup || userGroup.kind !== "group") return;
|
||||
|
||||
assert.deepEqual(collectRedisGroupKeyRaws(userGroup), ["k2", "k1", "k3"]);
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import test from "node:test";
|
||||
import {
|
||||
canEditRedisMemberDetail,
|
||||
clampRedisMemberDetailSheetWidth,
|
||||
formatRedisMemberDetail,
|
||||
getRedisMemberSelectionKey,
|
||||
highlightRedisJsonDetail,
|
||||
} from "../../src/lib/redisValuePresentation.ts";
|
||||
|
||||
test("formats JSON object strings for Redis member details", () => {
|
||||
const detail = formatRedisMemberDetail('{"id":1,"name":"Ada","tags":["dbx","redis"]}');
|
||||
|
||||
assert.equal(detail.format, "json");
|
||||
assert.equal(detail.text, '{\n "id": 1,\n "name": "Ada",\n "tags": [\n "dbx",\n "redis"\n ]\n}');
|
||||
});
|
||||
|
||||
test("keeps plain Redis member strings unchanged", () => {
|
||||
const detail = formatRedisMemberDetail("plain long member value");
|
||||
|
||||
assert.equal(detail.format, "text");
|
||||
assert.equal(detail.text, "plain long member value");
|
||||
});
|
||||
|
||||
test("formats non-string Redis member values as JSON", () => {
|
||||
const detail = formatRedisMemberDetail({ field: "name", value: '{"nested":true}' });
|
||||
|
||||
assert.equal(detail.format, "json");
|
||||
assert.equal(detail.text, '{\n "field": "name",\n "value": "{\\"nested\\":true}"\n}');
|
||||
});
|
||||
|
||||
test("builds stable Redis member selection keys from title and formatted value", () => {
|
||||
const key = getRedisMemberSelectionKey("#2", '{"id":240,"kind":"json"}');
|
||||
|
||||
assert.equal(key, '#2\n{\n "id": 240,\n "kind": "json"\n}');
|
||||
});
|
||||
|
||||
test("highlights formatted Redis JSON detail safely", () => {
|
||||
const html = highlightRedisJsonDetail('{"id":240,"name":"<script>","active":true,"meta":null}');
|
||||
|
||||
assert.match(html, /<span class="json-key">"id":<\/span>/);
|
||||
assert.match(html, /<span class="json-number">240<\/span>/);
|
||||
assert.match(html, /<span class="json-string">"<script>"<\/span>/);
|
||||
assert.match(html, /<span class="json-boolean">true<\/span>/);
|
||||
assert.match(html, /<span class="json-null">null<\/span>/);
|
||||
assert.doesNotMatch(html, /<script>/);
|
||||
});
|
||||
|
||||
test("allows editing Redis collection members except stream fields", () => {
|
||||
assert.equal(canEditRedisMemberDetail("list"), true);
|
||||
assert.equal(canEditRedisMemberDetail("hash"), true);
|
||||
assert.equal(canEditRedisMemberDetail("set"), true);
|
||||
assert.equal(canEditRedisMemberDetail("zset"), true);
|
||||
assert.equal(canEditRedisMemberDetail("stream"), false);
|
||||
});
|
||||
|
||||
test("clamps Redis member detail sheet width to viewport and usable bounds", () => {
|
||||
assert.equal(clampRedisMemberDetailSheetWidth(200, 1200), 360);
|
||||
assert.equal(clampRedisMemberDetailSheetWidth(640, 1200), 640);
|
||||
assert.equal(clampRedisMemberDetailSheetWidth(1200, 1400), 900);
|
||||
assert.equal(clampRedisMemberDetailSheetWidth(900, 500), 468);
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { strict as assert } from "node:assert";
|
||||
import test from "node:test";
|
||||
|
||||
test("settings about panel uses the app version prop instead of a hard-coded version", () => {
|
||||
const source = readFileSync("src/components/editor/EditorSettingsDialog.vue", "utf8");
|
||||
|
||||
assert.equal(source.includes("v0.5.0"), false);
|
||||
assert.match(source, /appVersion/);
|
||||
});
|
||||
|
||||
test("release workflow does not publish the default Tauri release body", () => {
|
||||
const source = readFileSync(".github/workflows/release.yml", "utf8");
|
||||
|
||||
assert.equal(source.includes("See the assets below to download and install."), false);
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import test from "node:test";
|
||||
import { diffForeignKeys, diffIndexes, generateSyncSql, type TableDiff } from "../../src/lib/schemaDiff.ts";
|
||||
import type { ForeignKeyInfo, IndexInfo } from "../../src/types/database.ts";
|
||||
|
||||
function index(overrides: Partial<IndexInfo>): IndexInfo {
|
||||
return {
|
||||
name: "idx_users_email",
|
||||
columns: ["email"],
|
||||
is_unique: false,
|
||||
is_primary: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function foreignKey(overrides: Partial<ForeignKeyInfo>): ForeignKeyInfo {
|
||||
return {
|
||||
name: "orders_user_id_fk",
|
||||
column: "user_id",
|
||||
ref_table: "users",
|
||||
ref_column: "id",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test("detects modified indexes, not only added or removed indexes", () => {
|
||||
const diffs = diffIndexes(
|
||||
[index({ name: "idx_orders_status", columns: ["status", "created_at"], is_unique: false })],
|
||||
[index({ name: "idx_orders_status", columns: ["status"], is_unique: true })],
|
||||
);
|
||||
|
||||
assert.equal(diffs.length, 1);
|
||||
assert.equal(diffs[0].type, "modified");
|
||||
assert.deepEqual(diffs[0].changes, ["unique: YES → NO", "columns: status → status, created_at"]);
|
||||
});
|
||||
|
||||
test("detects foreign key additions, removals, and target changes", () => {
|
||||
const diffs = diffForeignKeys(
|
||||
[
|
||||
foreignKey({ name: "orders_user_id_fk" }),
|
||||
foreignKey({ name: "orders_account_id_fk", column: "account_id", ref_table: "accounts" }),
|
||||
],
|
||||
[
|
||||
foreignKey({ name: "orders_user_id_fk", ref_table: "members" }),
|
||||
foreignKey({ name: "orders_region_id_fk", column: "region_id", ref_table: "regions" }),
|
||||
],
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
diffs.map((diff) => [diff.type, diff.name]),
|
||||
[
|
||||
["modified", "orders_user_id_fk"],
|
||||
["added", "orders_account_id_fk"],
|
||||
["removed", "orders_region_id_fk"],
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("generates sync SQL for index and foreign key changes", () => {
|
||||
const diffs: TableDiff[] = [
|
||||
{
|
||||
type: "modified",
|
||||
name: "orders",
|
||||
indexes: [
|
||||
{
|
||||
type: "modified",
|
||||
name: "idx_orders_status",
|
||||
source: index({ name: "idx_orders_status", columns: ["status", "created_at"], is_unique: true }),
|
||||
},
|
||||
],
|
||||
foreignKeys: [
|
||||
{
|
||||
type: "modified",
|
||||
name: "orders_user_id_fk",
|
||||
source: foreignKey({ name: "orders_user_id_fk", ref_table: "users" }),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
assert.equal(
|
||||
generateSyncSql(diffs, "postgres"),
|
||||
[
|
||||
'ALTER TABLE "orders" DROP CONSTRAINT "orders_user_id_fk";',
|
||||
'DROP INDEX IF EXISTS "idx_orders_status";',
|
||||
'CREATE UNIQUE INDEX "idx_orders_status" ON "orders" ("status", "created_at");',
|
||||
'ALTER TABLE "orders" ADD CONSTRAINT "orders_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "users" ("id");',
|
||||
].join("\n"),
|
||||
);
|
||||
});
|
||||
|
||||
test("qualifies generated schema sync SQL with target schema", () => {
|
||||
const diffs: TableDiff[] = [
|
||||
{
|
||||
type: "modified",
|
||||
name: "orders",
|
||||
columns: [
|
||||
{
|
||||
type: "added",
|
||||
name: "status",
|
||||
source: {
|
||||
name: "status",
|
||||
data_type: "text",
|
||||
is_nullable: true,
|
||||
column_default: null,
|
||||
is_primary_key: false,
|
||||
extra: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
indexes: [
|
||||
{ type: "added", name: "idx_orders_status", source: index({ name: "idx_orders_status", columns: ["status"] }) },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
assert.equal(
|
||||
generateSyncSql(diffs, "postgres", "sales"),
|
||||
[
|
||||
"-- Alter table: orders",
|
||||
'ALTER TABLE "sales"."orders" ADD COLUMN "status" text;',
|
||||
"",
|
||||
'CREATE INDEX "idx_orders_status" ON "sales"."orders" ("status");',
|
||||
].join("\n"),
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { hasSchemaOptionsCacheEntry } from "../../src/composables/useSchemaOptions.ts";
|
||||
|
||||
test("treats an empty schema option list as a loaded cache entry", () => {
|
||||
const options = {
|
||||
"conn:db": [],
|
||||
};
|
||||
|
||||
assert.equal(hasSchemaOptionsCacheEntry(options, "conn:db"), true);
|
||||
assert.equal(hasSchemaOptionsCacheEntry(options, "conn:other"), false);
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {
|
||||
SCHEMA_TREE_CACHE_TTL_MS,
|
||||
decodeSchemaTreeCache,
|
||||
encodeSchemaTreeCache,
|
||||
} from "../../src/lib/schemaTreeCache.ts";
|
||||
|
||||
const children = [{ id: "conn:db", label: "db", type: "database" }];
|
||||
const now = Date.parse("2026-05-17T10:00:00.000Z");
|
||||
|
||||
test("wraps tree children with a cache timestamp", () => {
|
||||
assert.deepEqual(encodeSchemaTreeCache(children, now), {
|
||||
version: 1,
|
||||
cachedAt: "2026-05-17T10:00:00.000Z",
|
||||
children,
|
||||
});
|
||||
});
|
||||
|
||||
test("treats recent schema tree cache envelopes as fresh", () => {
|
||||
const payload = encodeSchemaTreeCache(children, now);
|
||||
|
||||
assert.deepEqual(decodeSchemaTreeCache(payload, now + SCHEMA_TREE_CACHE_TTL_MS - 1), {
|
||||
children,
|
||||
isStale: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("treats expired schema tree cache envelopes as stale", () => {
|
||||
const payload = encodeSchemaTreeCache(children, now);
|
||||
|
||||
assert.deepEqual(decodeSchemaTreeCache(payload, now + SCHEMA_TREE_CACHE_TTL_MS + 1), {
|
||||
children,
|
||||
isStale: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps legacy array cache readable but stale", () => {
|
||||
assert.deepEqual(decodeSchemaTreeCache(children, now), {
|
||||
children,
|
||||
isStale: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects invalid schema tree cache payloads", () => {
|
||||
assert.equal(decodeSchemaTreeCache({ version: 1, cachedAt: "bad", children: "nope" }, now), null);
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { strict as assert } from "node:assert";
|
||||
import test from "node:test";
|
||||
|
||||
const source = readFileSync("src/components/editor/EditorSettingsDialog.vue", "utf8");
|
||||
|
||||
test("settings dialog uses a side category navigation", () => {
|
||||
assert.match(source, /settingsCategoryNav/);
|
||||
assert.match(source, /settingsCategoryButton/);
|
||||
});
|
||||
|
||||
test("Redis scan size lives in its own settings category", () => {
|
||||
const redisTab = source.indexOf('value: "redis"');
|
||||
const redisContent = source.search(/activeSettingsTab === ['"]redis['"]/);
|
||||
const redisScanSetting = source.indexOf('t("settings.redisScanPageSize")');
|
||||
const editorContent = source.search(/activeSettingsTab === ['"]editor['"]/);
|
||||
|
||||
assert.ok(redisTab > -1);
|
||||
assert.ok(redisContent > -1);
|
||||
assert.ok(redisScanSetting > redisContent);
|
||||
assert.ok(redisScanSetting > editorContent);
|
||||
});
|
||||
|
||||
test("settings action footer stays at the bottom of the content pane", () => {
|
||||
assert.match(source, /class="[^"]*min-h-full[^"]*flex-col[^"]*"/);
|
||||
assert.match(source, /<DialogFooter class="[^"]*mt-auto[^"]*"/);
|
||||
});
|
||||
|
||||
test("settings dialog has a shortcuts category", () => {
|
||||
assert.match(source, /value: "shortcuts"/);
|
||||
assert.match(source, /activeSettingsTab === ['"]shortcuts['"]/);
|
||||
assert.match(source, /SHORTCUT_DEFINITIONS/);
|
||||
});
|
||||
|
||||
test("shortcut settings capture custom keydown input instead of fixed select options", () => {
|
||||
assert.match(source, /onShortcutKeydown/);
|
||||
assert.match(source, /@keydown="\(event: KeyboardEvent\) => onShortcutKeydown/);
|
||||
assert.doesNotMatch(source, /definition\.options/);
|
||||
});
|
||||
|
||||
test("settings dialog exposes sidebar activation in navigation settings", () => {
|
||||
assert.match(source, /value: "navigation"/);
|
||||
assert.match(source, /activeSettingsTab === ['"]navigation['"]/);
|
||||
assert.match(source, /settings\.sidebarActivation/);
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
AI_PROVIDER_PRESETS,
|
||||
DEFAULT_EDITOR_SETTINGS,
|
||||
normalizeAiConfig,
|
||||
normalizeEditorSettings,
|
||||
} from "../../src/stores/settingsStore.ts";
|
||||
|
||||
test("defaults Redis scan page size to 1000 keys", () => {
|
||||
assert.equal(DEFAULT_EDITOR_SETTINGS.redisScanPageSize, 1000);
|
||||
assert.equal(normalizeEditorSettings({}).redisScanPageSize, 1000);
|
||||
});
|
||||
|
||||
test("keeps a saved Redis scan page size", () => {
|
||||
assert.equal(normalizeEditorSettings({ redisScanPageSize: 5000 }).redisScanPageSize, 5000);
|
||||
});
|
||||
|
||||
test("defaults shortcut settings", () => {
|
||||
const settings = normalizeEditorSettings({});
|
||||
|
||||
assert.equal(settings.shortcuts.executeSql, "Mod+Enter");
|
||||
assert.equal(settings.shortcuts.saveSql, "Mod+S");
|
||||
assert.equal(settings.shortcuts.focusSearch, "Mod+F");
|
||||
});
|
||||
|
||||
test("keeps saved shortcut overrides", () => {
|
||||
const settings = normalizeEditorSettings({ shortcuts: { executeSql: "Shift+Mod+Enter" } as any });
|
||||
|
||||
assert.equal(settings.shortcuts.executeSql, "Shift+Mod+Enter");
|
||||
assert.equal(settings.shortcuts.saveSql, "Mod+S");
|
||||
});
|
||||
|
||||
test("defaults sidebar activation to single click", () => {
|
||||
assert.equal(DEFAULT_EDITOR_SETTINGS.sidebarActivation, "single");
|
||||
assert.equal(normalizeEditorSettings({}).sidebarActivation, "single");
|
||||
});
|
||||
|
||||
test("keeps saved sidebar activation", () => {
|
||||
assert.equal(normalizeEditorSettings({ sidebarActivation: "double" } as any).sidebarActivation, "double");
|
||||
assert.equal(normalizeEditorSettings({ sidebarActivation: "invalid" } as any).sidebarActivation, "single");
|
||||
});
|
||||
|
||||
test("defaults column formatters to an empty record", () => {
|
||||
assert.deepEqual(DEFAULT_EDITOR_SETTINGS.columnFormatters, {});
|
||||
assert.deepEqual(normalizeEditorSettings({}).columnFormatters, {});
|
||||
});
|
||||
|
||||
test("keeps only valid saved column formatter configs", () => {
|
||||
const settings = normalizeEditorSettings({
|
||||
columnFormatters: {
|
||||
"conn::db::public::users::created_at": { kind: "datetime", unit: "auto" },
|
||||
"conn::db::public::users::bad_date": { kind: "datetime", unit: "bogus" },
|
||||
"conn::db::public::users::name": { kind: "mask", prefix: 2, suffix: 2 },
|
||||
"conn::db::public::users::payload": { kind: "json-path", path: "$.user.name" },
|
||||
"conn::db::public::users::invalid_json": { kind: "json-path", path: "user.name" },
|
||||
"conn::db::public::users::status": { kind: "custom-ref", formatterId: "fmt_1" },
|
||||
},
|
||||
customColumnFormatters: {
|
||||
fmt_1: { id: "fmt_1", name: "Status label", template: "status:${value}" },
|
||||
fmt_empty_name: { id: "fmt_empty_name", name: "", template: "x:${value}" },
|
||||
fmt_empty_template: { id: "fmt_empty_template", name: "Broken", template: "" },
|
||||
},
|
||||
} as any);
|
||||
|
||||
assert.deepEqual(settings.columnFormatters, {
|
||||
"conn::db::public::users::created_at": { kind: "datetime", unit: "auto" },
|
||||
"conn::db::public::users::name": { kind: "mask", prefix: 2, suffix: 2 },
|
||||
"conn::db::public::users::payload": { kind: "json-path", path: "$.user.name" },
|
||||
"conn::db::public::users::status": { kind: "custom-ref", formatterId: "fmt_1" },
|
||||
});
|
||||
assert.deepEqual(settings.customColumnFormatters, {
|
||||
fmt_1: { id: "fmt_1", name: "Status label", template: "status:${value}" },
|
||||
});
|
||||
});
|
||||
|
||||
test("AI provider presets include common hosted and local providers", () => {
|
||||
assert.equal(AI_PROVIDER_PRESETS.gemini.endpoint, "https://generativelanguage.googleapis.com");
|
||||
assert.equal(AI_PROVIDER_PRESETS.gemini.model, "gemini-1.5-pro");
|
||||
assert.equal(AI_PROVIDER_PRESETS.deepseek.endpoint, "https://api.deepseek.com/v1");
|
||||
assert.equal(AI_PROVIDER_PRESETS.deepseek.model, "deepseek-v4-flash");
|
||||
assert.equal(AI_PROVIDER_PRESETS.qwen.endpoint, "https://dashscope.aliyuncs.com/compatible-mode/v1");
|
||||
assert.equal(AI_PROVIDER_PRESETS.ollama.endpoint, "http://localhost:11434/v1");
|
||||
assert.equal(AI_PROVIDER_PRESETS.ollama.requiresApiKey, false);
|
||||
assert.equal(AI_PROVIDER_PRESETS.openai.iconSlug, "openai");
|
||||
assert.equal(AI_PROVIDER_PRESETS.deepseek.iconSlug, "deepseek");
|
||||
});
|
||||
|
||||
test("normalizes legacy AI config and fills provider defaults", () => {
|
||||
const legacy = normalizeAiConfig({
|
||||
provider: "openai",
|
||||
apiKey: "key",
|
||||
endpoint: "https://api.openai.com/v1/chat/completions",
|
||||
model: "gpt-4o",
|
||||
} as any);
|
||||
|
||||
assert.equal(legacy.apiStyle, "completions");
|
||||
assert.equal(legacy.provider, "openai");
|
||||
assert.equal(legacy.apiKey, "key");
|
||||
|
||||
const ollama = normalizeAiConfig({ provider: "ollama" } as any);
|
||||
assert.equal(ollama.endpoint, "http://localhost:11434/v1");
|
||||
assert.equal(ollama.model, "llama3.1");
|
||||
assert.equal(ollama.apiKey, "");
|
||||
});
|
||||
@@ -0,0 +1,278 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
reconcileLayout,
|
||||
buildTreeNodesFromLayout,
|
||||
createGroup,
|
||||
deleteGroup,
|
||||
renameGroup,
|
||||
moveConnectionToGroup,
|
||||
reorderEntry,
|
||||
toggleGroupCollapsed,
|
||||
appendConnectionToLayout,
|
||||
removeConnectionFromSidebarLayout,
|
||||
emptyLayout,
|
||||
} from "../../src/lib/sidebarLayout.ts";
|
||||
import type { ConnectionConfig, SidebarLayout } from "../../src/types/database.ts";
|
||||
|
||||
function conn(id: string, name?: string): ConnectionConfig {
|
||||
return {
|
||||
id,
|
||||
name: name || id,
|
||||
db_type: "postgres",
|
||||
host: "localhost",
|
||||
port: 5432,
|
||||
username: "user",
|
||||
password: "",
|
||||
};
|
||||
}
|
||||
|
||||
// --- reconcileLayout ---
|
||||
|
||||
test("reconcileLayout returns all connections ungrouped when layout is null", () => {
|
||||
const result = reconcileLayout(["a", "b", "c"], null);
|
||||
assert.deepEqual(result.groups, []);
|
||||
assert.deepEqual(result.order, [
|
||||
{ type: "connection", id: "a" },
|
||||
{ type: "connection", id: "b" },
|
||||
{ type: "connection", id: "c" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("reconcileLayout appends new connections not in layout", () => {
|
||||
const layout: SidebarLayout = {
|
||||
groups: [],
|
||||
order: [{ type: "connection", id: "a" }],
|
||||
};
|
||||
const result = reconcileLayout(["a", "b"], layout);
|
||||
assert.equal(result.order.length, 2);
|
||||
assert.deepEqual(result.order[1], { type: "connection", id: "b" });
|
||||
});
|
||||
|
||||
test("reconcileLayout removes stale connections from layout", () => {
|
||||
const layout: SidebarLayout = {
|
||||
groups: [{ id: "g1", name: "Group", collapsed: false }],
|
||||
order: [
|
||||
{ type: "group", id: "g1", connectionIds: ["a", "removed"] },
|
||||
{ type: "connection", id: "b" },
|
||||
],
|
||||
};
|
||||
const result = reconcileLayout(["a", "b"], layout);
|
||||
const groupEntry = result.order.find((e) => e.type === "group");
|
||||
assert.ok(groupEntry && groupEntry.type === "group");
|
||||
assert.deepEqual(groupEntry.connectionIds, ["a"]);
|
||||
assert.equal(result.order.length, 2);
|
||||
});
|
||||
|
||||
test("reconcileLayout removes groups with no order entry", () => {
|
||||
const layout: SidebarLayout = {
|
||||
groups: [
|
||||
{ id: "g1", name: "Used", collapsed: false },
|
||||
{ id: "g2", name: "Orphan", collapsed: false },
|
||||
],
|
||||
order: [{ type: "group", id: "g1", connectionIds: ["a"] }],
|
||||
};
|
||||
const result = reconcileLayout(["a"], layout);
|
||||
assert.equal(result.groups.length, 1);
|
||||
assert.equal(result.groups[0].id, "g1");
|
||||
});
|
||||
|
||||
// --- buildTreeNodesFromLayout ---
|
||||
|
||||
test("buildTreeNodesFromLayout creates group nodes with connection children", () => {
|
||||
const layout: SidebarLayout = {
|
||||
groups: [{ id: "g1", name: "Production", collapsed: false }],
|
||||
order: [
|
||||
{ type: "group", id: "g1", connectionIds: ["a", "b"] },
|
||||
{ type: "connection", id: "c" },
|
||||
],
|
||||
};
|
||||
const connections = [conn("a", "Server A"), conn("b", "Server B"), conn("c", "Server C")];
|
||||
const nodes = buildTreeNodesFromLayout(layout, connections, new Set());
|
||||
|
||||
assert.equal(nodes.length, 2);
|
||||
assert.equal(nodes[0].type, "connection-group");
|
||||
assert.equal(nodes[0].label, "Production");
|
||||
assert.equal(nodes[0].children?.length, 2);
|
||||
assert.equal(nodes[0].isExpanded, true);
|
||||
assert.equal(nodes[1].type, "connection");
|
||||
assert.equal(nodes[1].id, "c");
|
||||
});
|
||||
|
||||
test("buildTreeNodesFromLayout respects collapsed groups", () => {
|
||||
const layout: SidebarLayout = {
|
||||
groups: [{ id: "g1", name: "G", collapsed: true }],
|
||||
order: [{ type: "group", id: "g1", connectionIds: ["a"] }],
|
||||
};
|
||||
const nodes = buildTreeNodesFromLayout(layout, [conn("a")], new Set());
|
||||
assert.equal(nodes[0].isExpanded, false);
|
||||
});
|
||||
|
||||
test("buildTreeNodesFromLayout applies pinning within groups", () => {
|
||||
const layout: SidebarLayout = {
|
||||
groups: [{ id: "g1", name: "G", collapsed: false }],
|
||||
order: [{ type: "group", id: "g1", connectionIds: ["a", "b"] }],
|
||||
};
|
||||
const nodes = buildTreeNodesFromLayout(layout, [conn("a"), conn("b")], new Set(["b"]));
|
||||
const children = nodes[0].children!;
|
||||
assert.equal(children[0].id, "b");
|
||||
assert.equal(children[1].id, "a");
|
||||
});
|
||||
|
||||
// --- createGroup ---
|
||||
|
||||
test("createGroup adds a new empty group", () => {
|
||||
const layout = emptyLayout();
|
||||
const result = createGroup(layout, "Dev");
|
||||
assert.equal(result.layout.groups.length, 1);
|
||||
assert.equal(result.layout.groups[0].name, "Dev");
|
||||
assert.equal(result.layout.order.length, 1);
|
||||
assert.ok(result.layout.order[0].type === "group");
|
||||
});
|
||||
|
||||
// --- renameGroup ---
|
||||
|
||||
test("renameGroup updates group name", () => {
|
||||
const { layout, groupId } = createGroup(emptyLayout(), "Old");
|
||||
const result = renameGroup(layout, groupId, "New");
|
||||
assert.equal(result.groups[0].name, "New");
|
||||
});
|
||||
|
||||
// --- deleteGroup ---
|
||||
|
||||
test("deleteGroup moves connections to ungrouped", () => {
|
||||
let layout = emptyLayout();
|
||||
layout = appendConnectionToLayout(layout, "a");
|
||||
const { layout: withGroup, groupId } = createGroup(layout, "G");
|
||||
const moved = moveConnectionToGroup(withGroup, "a", groupId);
|
||||
const result = deleteGroup(moved, groupId);
|
||||
|
||||
assert.equal(result.groups.length, 0);
|
||||
assert.deepEqual(result.order, [{ type: "connection", id: "a" }]);
|
||||
});
|
||||
|
||||
// --- toggleGroupCollapsed ---
|
||||
|
||||
test("toggleGroupCollapsed flips collapsed state", () => {
|
||||
const { layout, groupId } = createGroup(emptyLayout(), "G");
|
||||
assert.equal(layout.groups[0].collapsed, false);
|
||||
const toggled = toggleGroupCollapsed(layout, groupId);
|
||||
assert.equal(toggled.groups[0].collapsed, true);
|
||||
});
|
||||
|
||||
// --- moveConnectionToGroup ---
|
||||
|
||||
test("moveConnectionToGroup moves connection into a group", () => {
|
||||
let layout: SidebarLayout = {
|
||||
groups: [{ id: "g1", name: "G", collapsed: false }],
|
||||
order: [
|
||||
{ type: "group", id: "g1", connectionIds: [] },
|
||||
{ type: "connection", id: "a" },
|
||||
],
|
||||
};
|
||||
const result = moveConnectionToGroup(layout, "a", "g1");
|
||||
const groupEntry = result.order.find((e) => e.type === "group" && e.id === "g1");
|
||||
assert.ok(groupEntry && groupEntry.type === "group");
|
||||
assert.deepEqual(groupEntry.connectionIds, ["a"]);
|
||||
assert.equal(result.order.length, 1);
|
||||
});
|
||||
|
||||
test("moveConnectionToGroup moves connection out of a group", () => {
|
||||
let layout: SidebarLayout = {
|
||||
groups: [{ id: "g1", name: "G", collapsed: false }],
|
||||
order: [{ type: "group", id: "g1", connectionIds: ["a"] }],
|
||||
};
|
||||
const result = moveConnectionToGroup(layout, "a", null);
|
||||
assert.equal(result.order.length, 2);
|
||||
assert.deepEqual(result.order[1], { type: "connection", id: "a" });
|
||||
});
|
||||
|
||||
// --- reorderEntry ---
|
||||
|
||||
test("reorderEntry moves connection before another", () => {
|
||||
const layout: SidebarLayout = {
|
||||
groups: [],
|
||||
order: [
|
||||
{ type: "connection", id: "a" },
|
||||
{ type: "connection", id: "b" },
|
||||
{ type: "connection", id: "c" },
|
||||
],
|
||||
};
|
||||
const result = reorderEntry(layout, "c", "a", "before");
|
||||
assert.deepEqual(result.order.map((e) => e.id), ["c", "a", "b"]);
|
||||
});
|
||||
|
||||
test("reorderEntry moves connection after another", () => {
|
||||
const layout: SidebarLayout = {
|
||||
groups: [],
|
||||
order: [
|
||||
{ type: "connection", id: "a" },
|
||||
{ type: "connection", id: "b" },
|
||||
{ type: "connection", id: "c" },
|
||||
],
|
||||
};
|
||||
const result = reorderEntry(layout, "a", "b", "after");
|
||||
assert.deepEqual(result.order.map((e) => e.id), ["b", "a", "c"]);
|
||||
});
|
||||
|
||||
test("reorderEntry moves connection inside a group", () => {
|
||||
const layout: SidebarLayout = {
|
||||
groups: [{ id: "g1", name: "G", collapsed: false }],
|
||||
order: [
|
||||
{ type: "group", id: "g1", connectionIds: [] },
|
||||
{ type: "connection", id: "a" },
|
||||
],
|
||||
};
|
||||
const result = reorderEntry(layout, "a", "g1", "inside");
|
||||
assert.equal(result.order.length, 1);
|
||||
const groupEntry = result.order[0];
|
||||
assert.ok(groupEntry.type === "group");
|
||||
assert.deepEqual(groupEntry.connectionIds, ["a"]);
|
||||
});
|
||||
|
||||
test("reorderEntry is a no-op when dragging to same position", () => {
|
||||
const layout: SidebarLayout = {
|
||||
groups: [],
|
||||
order: [{ type: "connection", id: "a" }],
|
||||
};
|
||||
const result = reorderEntry(layout, "a", "a", "before");
|
||||
assert.deepEqual(result, layout);
|
||||
});
|
||||
|
||||
// --- appendConnectionToLayout / removeConnectionFromSidebarLayout ---
|
||||
|
||||
test("appendConnectionToLayout adds to the end", () => {
|
||||
const layout = appendConnectionToLayout(emptyLayout(), "x");
|
||||
assert.equal(layout.order.length, 1);
|
||||
assert.deepEqual(layout.order[0], { type: "connection", id: "x" });
|
||||
});
|
||||
|
||||
test("appendConnectionToLayout adds to target group and expands it", () => {
|
||||
const layout: SidebarLayout = {
|
||||
groups: [{ id: "g1", name: "G", collapsed: true }],
|
||||
order: [{ type: "group", id: "g1", connectionIds: ["a"] }],
|
||||
};
|
||||
const result = appendConnectionToLayout(layout, "b", "g1");
|
||||
const groupEntry = result.order[0];
|
||||
|
||||
assert.equal(result.groups[0].collapsed, false);
|
||||
assert.ok(groupEntry.type === "group");
|
||||
assert.deepEqual(groupEntry.connectionIds, ["a", "b"]);
|
||||
});
|
||||
|
||||
test("removeConnectionFromSidebarLayout removes from ungrouped", () => {
|
||||
let layout = appendConnectionToLayout(emptyLayout(), "x");
|
||||
layout = removeConnectionFromSidebarLayout(layout, "x");
|
||||
assert.equal(layout.order.length, 0);
|
||||
});
|
||||
|
||||
test("removeConnectionFromSidebarLayout removes from inside a group", () => {
|
||||
const layout: SidebarLayout = {
|
||||
groups: [{ id: "g1", name: "G", collapsed: false }],
|
||||
order: [{ type: "group", id: "g1", connectionIds: ["a", "b"] }],
|
||||
};
|
||||
const result = removeConnectionFromSidebarLayout(layout, "a");
|
||||
const groupEntry = result.order[0];
|
||||
assert.ok(groupEntry.type === "group");
|
||||
assert.deepEqual(groupEntry.connectionIds, ["b"]);
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import { test } from "node:test";
|
||||
import { matchSidebarLabel } from "../../src/lib/sidebarSearch.ts";
|
||||
|
||||
test("matches exact and prefix labels first", () => {
|
||||
assert.equal(matchSidebarLabel("orders", "orders")?.kind, "exact");
|
||||
assert.equal(matchSidebarLabel("orders_archive", "ord")?.kind, "prefix");
|
||||
});
|
||||
|
||||
test("matches word prefixes in underscored and dotted identifiers", () => {
|
||||
assert.equal(matchSidebarLabel("user_orders", "ord")?.kind, "word-prefix");
|
||||
assert.equal(matchSidebarLabel("sales.customer_profile", "cust")?.kind, "word-prefix");
|
||||
});
|
||||
|
||||
test("matches DataGrip-style abbreviations by identifier word boundaries", () => {
|
||||
assert.equal(matchSidebarLabel("additional_country", "ac")?.kind, "abbreviation");
|
||||
assert.equal(matchSidebarLabel("sales.customer_profile", "scp")?.kind, "abbreviation");
|
||||
});
|
||||
|
||||
test("keeps one-character fuzzy matches disabled", () => {
|
||||
assert.equal(matchSidebarLabel("orders", "r")?.kind, "substring");
|
||||
assert.equal(matchSidebarLabel("orders", "x"), null);
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import test from "node:test";
|
||||
import { filterSidebarTree } from "../../src/lib/sidebarSearchTree.ts";
|
||||
import type { TreeNode } from "../../src/types/database.ts";
|
||||
|
||||
test("preserves loaded table children when the table itself matches search", () => {
|
||||
const nodes: TreeNode[] = [
|
||||
{
|
||||
id: "conn:db",
|
||||
label: "app",
|
||||
type: "database",
|
||||
connectionId: "conn",
|
||||
database: "app",
|
||||
isExpanded: true,
|
||||
children: [
|
||||
{
|
||||
id: "conn:db:orders",
|
||||
label: "orders",
|
||||
type: "table",
|
||||
connectionId: "conn",
|
||||
database: "app",
|
||||
isExpanded: true,
|
||||
children: [
|
||||
{
|
||||
id: "conn:db:orders:__columns",
|
||||
label: "tree.columns",
|
||||
type: "group-columns",
|
||||
connectionId: "conn",
|
||||
database: "app",
|
||||
tableName: "orders",
|
||||
isExpanded: true,
|
||||
children: [
|
||||
{
|
||||
id: "conn:db:orders:__columns:id",
|
||||
label: "id",
|
||||
type: "column",
|
||||
connectionId: "conn",
|
||||
database: "app",
|
||||
tableName: "orders",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const filtered = filterSidebarTree(nodes, "orders", new Set());
|
||||
|
||||
const table = filtered[0]?.children?.[0];
|
||||
assert.equal(table?.label, "orders");
|
||||
assert.equal(table?.children?.[0]?.label, "tree.columns");
|
||||
assert.equal(table?.children?.[0]?.children?.[0]?.label, "id");
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import test from "node:test";
|
||||
import { canTreeNodeExpand, canTreeNodeShowExpander, treeItemPaddingLeft } from "../../src/lib/sidebarTreeItemLayout.ts";
|
||||
|
||||
test("treeItemPaddingLeft converts tree depth to sidebar indentation", () => {
|
||||
assert.equal(treeItemPaddingLeft(0), "8px");
|
||||
assert.equal(treeItemPaddingLeft(1), "24px");
|
||||
assert.equal(treeItemPaddingLeft(2), "40px");
|
||||
});
|
||||
|
||||
test("canTreeNodeExpand only returns true for expandable sidebar node types", () => {
|
||||
assert.equal(canTreeNodeExpand("connection"), true);
|
||||
assert.equal(canTreeNodeExpand("database"), true);
|
||||
assert.equal(canTreeNodeExpand("schema"), true);
|
||||
assert.equal(canTreeNodeExpand("table"), true);
|
||||
assert.equal(canTreeNodeExpand("view"), true);
|
||||
assert.equal(canTreeNodeExpand("saved-sql-root"), true);
|
||||
assert.equal(canTreeNodeExpand("saved-sql-folder"), true);
|
||||
|
||||
assert.equal(canTreeNodeExpand("saved-sql-file"), false);
|
||||
assert.equal(canTreeNodeExpand("object-browser"), false);
|
||||
assert.equal(canTreeNodeExpand("column"), false);
|
||||
assert.equal(canTreeNodeExpand("index"), false);
|
||||
assert.equal(canTreeNodeExpand("redis-db"), false);
|
||||
assert.equal(canTreeNodeExpand("mongo-collection"), false);
|
||||
});
|
||||
|
||||
test("canTreeNodeShowExpander hides empty saved SQL containers", () => {
|
||||
assert.equal(canTreeNodeShowExpander({ type: "saved-sql-root", childCount: 0 }), false);
|
||||
assert.equal(canTreeNodeShowExpander({ type: "saved-sql-folder", childCount: 0 }), false);
|
||||
assert.equal(canTreeNodeShowExpander({ type: "saved-sql-root", childCount: 1 }), true);
|
||||
assert.equal(canTreeNodeShowExpander({ type: "saved-sql-folder", childCount: 1 }), true);
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import test from "node:test";
|
||||
import {
|
||||
allEditableColumnsWriteable,
|
||||
allPrimaryKeysPresent,
|
||||
analyzeEditableQuery,
|
||||
analyzeEditableQueryEditability,
|
||||
queryEditabilityMessageKey,
|
||||
} from "../../src/lib/sqlAnalysis.ts";
|
||||
|
||||
test("recognizes a simple single-table SELECT as editable", () => {
|
||||
const result = analyzeEditableQueryEditability("select id, name from public.users where active = true order by id");
|
||||
|
||||
assert.equal(result.editable, true);
|
||||
assert.deepEqual(result.analysis, {
|
||||
schema: "public",
|
||||
tableName: "users",
|
||||
tableAlias: undefined,
|
||||
selectStar: false,
|
||||
columns: [
|
||||
{ sourceName: "id", resultName: "id", expression: "id" },
|
||||
{ sourceName: "name", resultName: "name", expression: "name" },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("recognizes quoted table names and table aliases", () => {
|
||||
const result = analyzeEditableQueryEditability('SELECT u."id", u."full name" FROM "app schema"."user table" AS u');
|
||||
|
||||
assert.equal(result.editable, true);
|
||||
assert.deepEqual(result.analysis, {
|
||||
schema: "app schema",
|
||||
tableName: "user table",
|
||||
tableAlias: "u",
|
||||
selectStar: false,
|
||||
columns: [
|
||||
{ sourceName: "id", resultName: "id", expression: 'u."id"' },
|
||||
{ sourceName: "full name", resultName: "full name", expression: 'u."full name"' },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps the legacy analyzer API for editable SELECT queries", () => {
|
||||
assert.deepEqual(analyzeEditableQuery("select * from users"), {
|
||||
schema: undefined,
|
||||
tableName: "users",
|
||||
tableAlias: undefined,
|
||||
selectStar: true,
|
||||
columns: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("reports why joined query results are not editable", () => {
|
||||
const result = analyzeEditableQueryEditability("select u.id, o.total from users u join orders o on o.user_id = u.id");
|
||||
|
||||
assert.deepEqual(result, {
|
||||
editable: false,
|
||||
reason: "complex-source",
|
||||
});
|
||||
assert.equal(queryEditabilityMessageKey(result.reason), "grid.queryEditUnsupportedComplexSource");
|
||||
});
|
||||
|
||||
test("reports computed result columns as unsafe to edit", () => {
|
||||
const result = analyzeEditableQueryEditability("select id, count(*) as total from users group by id");
|
||||
|
||||
assert.deepEqual(result, {
|
||||
editable: false,
|
||||
reason: "aggregation",
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps single-table expression columns while mapping writable source columns", () => {
|
||||
const result = analyzeEditableQueryEditability(
|
||||
"select iso3, year, country_name, ihli / gdp_pc as score from ihli_data",
|
||||
);
|
||||
|
||||
assert.equal(result.editable, true);
|
||||
assert.deepEqual(result.analysis.columns, [
|
||||
{ sourceName: "iso3", resultName: "iso3", expression: "iso3" },
|
||||
{ sourceName: "year", resultName: "year", expression: "year" },
|
||||
{ sourceName: "country_name", resultName: "country_name", expression: "country_name" },
|
||||
{ sourceName: undefined, resultName: "score", expression: "ihli / gdp_pc" },
|
||||
]);
|
||||
assert.equal(
|
||||
allPrimaryKeysPresent(["iso3", "year"], ["iso3", "year", "country_name", "score"], result.analysis),
|
||||
true,
|
||||
);
|
||||
assert.equal(allEditableColumnsWriteable(result.analysis, ["iso3", "year", "country_name", "score"]), true);
|
||||
});
|
||||
|
||||
test("accepts aliased primary key source columns for row identity", () => {
|
||||
const analysis = analyzeEditableQuery("select id as user_id, name from users");
|
||||
|
||||
assert.ok(analysis);
|
||||
assert.equal(allPrimaryKeysPresent(["id"], ["user_id", "name"], analysis), true);
|
||||
assert.equal(allEditableColumnsWriteable(analysis, ["user_id", "name"]), true);
|
||||
assert.equal(
|
||||
allPrimaryKeysPresent(["id"], ["id", "name"], analyzeEditableQuery("select id, name from users")!),
|
||||
true,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,329 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import test from "node:test";
|
||||
import {
|
||||
buildSqlCompletionItems,
|
||||
getSqlFunctionSignatureHelp,
|
||||
shouldAutoOpenSqlCompletion,
|
||||
type SqlCompletionColumn,
|
||||
type SqlCompletionTable,
|
||||
} from "../../src/lib/sqlCompletion.ts";
|
||||
|
||||
const tables: SqlCompletionTable[] = [
|
||||
{ name: "users", schema: "public", type: "table" },
|
||||
{ name: "user_profiles", schema: "public", type: "table" },
|
||||
{ name: "orders", schema: "public", type: "table" },
|
||||
];
|
||||
|
||||
const columnsByTable = new Map<string, SqlCompletionColumn[]>([
|
||||
[
|
||||
"public.users",
|
||||
[
|
||||
{ name: "id", table: "users", schema: "public", dataType: "bigint" },
|
||||
{ name: "name", table: "users", schema: "public", dataType: "varchar" },
|
||||
{ name: "email", table: "users", schema: "public", dataType: "varchar" },
|
||||
],
|
||||
],
|
||||
[
|
||||
"public.orders",
|
||||
[
|
||||
{ name: "id", table: "orders", schema: "public", dataType: "bigint" },
|
||||
{ name: "user_id", table: "orders", schema: "public", dataType: "bigint" },
|
||||
{ name: "status", table: "orders", schema: "public", dataType: "varchar" },
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
test("suggests SQL keywords for generic keyword input", () => {
|
||||
const items = buildSqlCompletionItems("sel", 3, {
|
||||
tables,
|
||||
columnsByTable,
|
||||
});
|
||||
|
||||
assert.equal(items[0]?.label, "SELECT");
|
||||
assert.equal(items[0]?.type, "keyword");
|
||||
});
|
||||
|
||||
test("suggests matching table names after FROM", () => {
|
||||
const sql = "select * from us";
|
||||
const items = buildSqlCompletionItems(sql, sql.length, {
|
||||
tables,
|
||||
columnsByTable,
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
items.slice(0, 2).map((item) => item.label),
|
||||
["users", "user_profiles"],
|
||||
);
|
||||
});
|
||||
|
||||
test("ranks prefix matches above substring matches for table names", () => {
|
||||
const sql = "select * from user";
|
||||
const items = buildSqlCompletionItems(sql, sql.length, {
|
||||
tables,
|
||||
columnsByTable,
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
items.filter((item) => item.type === "table").map((item) => item.label),
|
||||
["users", "user_profiles"],
|
||||
);
|
||||
});
|
||||
|
||||
test("suggests columns for an explicit alias qualifier", () => {
|
||||
const sql = "select u. from public.users u";
|
||||
const cursor = "select u.".length;
|
||||
const items = buildSqlCompletionItems(sql, cursor, {
|
||||
tables,
|
||||
columnsByTable,
|
||||
});
|
||||
|
||||
const columnItems = items.filter((item) => item.type === "column");
|
||||
assert.deepEqual(
|
||||
columnItems.map((item) => item.label),
|
||||
["id", "name", "email"],
|
||||
);
|
||||
});
|
||||
|
||||
test("suggests columns from referenced tables in select list", () => {
|
||||
const sql = "select na from public.users u join public.orders o on u.id = o.user_id";
|
||||
const cursor = "select na".length;
|
||||
const items = buildSqlCompletionItems(sql, cursor, {
|
||||
tables,
|
||||
columnsByTable,
|
||||
});
|
||||
|
||||
assert.equal(items[0]?.label, "name");
|
||||
assert.equal(items[0]?.type, "column");
|
||||
});
|
||||
|
||||
test("suggests tables after LEFT JOIN", () => {
|
||||
const sql = "select * from users left join us";
|
||||
const items = buildSqlCompletionItems(sql, sql.length, {
|
||||
tables,
|
||||
columnsByTable,
|
||||
});
|
||||
|
||||
assert.ok(items.some((item) => item.label === "users" && item.type === "table"));
|
||||
assert.ok(items.some((item) => item.label === "user_profiles" && item.type === "table"));
|
||||
});
|
||||
|
||||
test("suggests tables after comma in FROM clause", () => {
|
||||
const sql = "select * from users, or";
|
||||
const items = buildSqlCompletionItems(sql, sql.length, {
|
||||
tables,
|
||||
columnsByTable,
|
||||
});
|
||||
|
||||
assert.ok(items.some((item) => item.label === "orders" && item.type === "table"));
|
||||
});
|
||||
|
||||
test("suggests keywords when typing without context", () => {
|
||||
const sql = "us";
|
||||
const items = buildSqlCompletionItems(sql, sql.length, {
|
||||
tables,
|
||||
columnsByTable,
|
||||
});
|
||||
|
||||
assert.ok(items.some((item) => item.type === "keyword" && item.label === "USING"));
|
||||
});
|
||||
|
||||
test("always includes keywords alongside table suggestions", () => {
|
||||
const sql = "select * from us";
|
||||
const items = buildSqlCompletionItems(sql, sql.length, {
|
||||
tables,
|
||||
columnsByTable,
|
||||
});
|
||||
|
||||
assert.ok(items.some((item) => item.type === "table"));
|
||||
assert.ok(items.some((item) => item.type === "keyword" && item.label === "USING"));
|
||||
});
|
||||
|
||||
test("suggests SQL Server IF keyword for conditional DDL", () => {
|
||||
const sql = "DROP TABLE I";
|
||||
const items = buildSqlCompletionItems(sql, sql.length, {
|
||||
tables,
|
||||
columnsByTable,
|
||||
});
|
||||
|
||||
assert.ok(items.some((item) => item.type === "keyword" && item.label === "IF"));
|
||||
});
|
||||
|
||||
test("suggests SQL Server IIF and CHOOSE scalar functions", () => {
|
||||
const iifItems = buildSqlCompletionItems("SELECT II", "SELECT II".length, {
|
||||
tables,
|
||||
columnsByTable,
|
||||
});
|
||||
const chooseItems = buildSqlCompletionItems("SELECT CHO", "SELECT CHO".length, {
|
||||
tables,
|
||||
columnsByTable,
|
||||
});
|
||||
|
||||
assert.ok(iifItems.some((item) => item.type === "keyword" && item.label === "IIF"));
|
||||
assert.ok(chooseItems.some((item) => item.type === "keyword" && item.label === "CHOOSE"));
|
||||
});
|
||||
|
||||
test("suggests SQL Server data types in CREATE TABLE column definitions", () => {
|
||||
const sql = "CREATE TABLE dbo.jobs (id ";
|
||||
const items = buildSqlCompletionItems(sql, sql.length, {
|
||||
tables,
|
||||
columnsByTable,
|
||||
});
|
||||
|
||||
assert.ok(items.some((item) => item.type === "keyword" && item.label === "INT"));
|
||||
assert.ok(items.some((item) => item.type === "keyword" && item.label === "BIGINT"));
|
||||
assert.ok(items.some((item) => item.type === "keyword" && item.label === "NVARCHAR"));
|
||||
});
|
||||
|
||||
test("does not auto-open completion after structural punctuation", () => {
|
||||
for (const sql of ["select count(*)", "select * from users;", "select * from users,"]) {
|
||||
assert.equal(shouldAutoOpenSqlCompletion(sql, sql.length), false, sql);
|
||||
}
|
||||
});
|
||||
|
||||
test("auto-opens completion after word characters and explicit dot qualifiers", () => {
|
||||
for (const sql of ["sel", "select * from us", "select u."]) {
|
||||
assert.equal(shouldAutoOpenSqlCompletion(sql, sql.length), true, sql);
|
||||
}
|
||||
});
|
||||
|
||||
test("auto-opens completion after ON whitespace for join conditions", () => {
|
||||
const sql = "select * from public.users u join public.orders o on ";
|
||||
|
||||
assert.equal(shouldAutoOpenSqlCompletion(sql, sql.length), true);
|
||||
});
|
||||
|
||||
test("limits table suggestions for large schemas after filtering by prefix", () => {
|
||||
const largeTables: SqlCompletionTable[] = Array.from({ length: 500 }, (_, index) => ({
|
||||
name: `erp_invoice_${String(index).padStart(4, "0")}`,
|
||||
schema: "dbo",
|
||||
type: "table",
|
||||
}));
|
||||
|
||||
const sql = "select * from erp_invoice_";
|
||||
const items = buildSqlCompletionItems(sql, sql.length, {
|
||||
tables: largeTables,
|
||||
columnsByTable,
|
||||
});
|
||||
|
||||
const tableItems = items.filter((item) => item.type === "table");
|
||||
assert.equal(tableItems.length, 200);
|
||||
assert.equal(tableItems[0]?.label, "erp_invoice_0000");
|
||||
assert.equal(tableItems.at(-1)?.label, "erp_invoice_0199");
|
||||
});
|
||||
|
||||
test("suggests SQL snippets for common abbreviations", () => {
|
||||
const items = buildSqlCompletionItems("sel", 3, {
|
||||
tables,
|
||||
columnsByTable,
|
||||
});
|
||||
|
||||
const snippet = items.find((item) => item.type === "snippet" && item.label === "select *");
|
||||
assert.ok(snippet);
|
||||
assert.equal(snippet.apply, "SELECT *\nFROM ${table}\nLIMIT 100;");
|
||||
});
|
||||
|
||||
test("suggests DATE_FORMAT as parameter snippet", () => {
|
||||
const sql = "select date_";
|
||||
const items = buildSqlCompletionItems(sql, sql.length, {
|
||||
tables,
|
||||
columnsByTable,
|
||||
});
|
||||
|
||||
const snippet = items.find((item) => item.type === "snippet" && item.label === "DATE_FORMAT");
|
||||
assert.ok(snippet);
|
||||
assert.equal(snippet.detail, "function");
|
||||
assert.equal(snippet.apply, "DATE_FORMAT(${date}, ${format})");
|
||||
});
|
||||
|
||||
test("matches alias qualifier case-insensitively", () => {
|
||||
const sql = "select O. from public.orders o";
|
||||
const cursor = "select O.".length;
|
||||
const items = buildSqlCompletionItems(sql, cursor, {
|
||||
tables,
|
||||
columnsByTable,
|
||||
});
|
||||
|
||||
const columnItems = items.filter((item) => item.type === "column");
|
||||
assert.deepEqual(
|
||||
columnItems.map((item) => item.label),
|
||||
["id", "user_id", "status"],
|
||||
);
|
||||
});
|
||||
|
||||
test("suggests referenced columns after ORDER BY", () => {
|
||||
const sql = "select name from public.users u order by na";
|
||||
const items = buildSqlCompletionItems(sql, sql.length, {
|
||||
tables,
|
||||
columnsByTable,
|
||||
});
|
||||
|
||||
assert.equal(items[0]?.label, "name");
|
||||
assert.equal(items[0]?.type, "column");
|
||||
});
|
||||
|
||||
test("prioritizes select aliases in ORDER BY completion", () => {
|
||||
const sql = "select u.name as display_name, count(*) order_count from public.users u order by ";
|
||||
const items = buildSqlCompletionItems(sql, sql.length, {
|
||||
tables,
|
||||
columnsByTable,
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
items.slice(0, 2).map((item) => [item.label, item.detail]),
|
||||
[
|
||||
["display_name", "SELECT alias"],
|
||||
["order_count", "SELECT alias"],
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("prioritizes select aliases in GROUP BY completion", () => {
|
||||
const sql = "select u.name as display_name from public.users u group by ";
|
||||
const items = buildSqlCompletionItems(sql, sql.length, {
|
||||
tables,
|
||||
columnsByTable,
|
||||
});
|
||||
|
||||
assert.equal(items[0]?.label, "display_name");
|
||||
assert.equal(items[0]?.detail, "SELECT alias");
|
||||
});
|
||||
|
||||
test("suggests likely join condition snippets after ON", () => {
|
||||
const sql = "select * from public.users u join public.orders o on ";
|
||||
const items = buildSqlCompletionItems(sql, sql.length, {
|
||||
tables,
|
||||
columnsByTable,
|
||||
});
|
||||
|
||||
const joinCondition = items.find((item) => item.type === "snippet" && item.label === "u.id = o.user_id");
|
||||
assert.ok(joinCondition);
|
||||
assert.equal(joinCondition.apply, "u.id = o.user_id");
|
||||
});
|
||||
|
||||
test("suggests likely join condition snippets when joined table owns the id column", () => {
|
||||
const sql = "select * from public.orders o join public.users u on ";
|
||||
const items = buildSqlCompletionItems(sql, sql.length, {
|
||||
tables,
|
||||
columnsByTable,
|
||||
});
|
||||
|
||||
const joinCondition = items.find((item) => item.type === "snippet" && item.label === "o.user_id = u.id");
|
||||
assert.ok(joinCondition);
|
||||
assert.equal(joinCondition.apply, "o.user_id = u.id");
|
||||
});
|
||||
|
||||
test("returns function signature help inside function arguments", () => {
|
||||
const sql = "select date_format(created_at, ";
|
||||
const signature = getSqlFunctionSignatureHelp(sql, sql.length);
|
||||
|
||||
assert.deepEqual(signature, {
|
||||
name: "DATE_FORMAT",
|
||||
signature: "DATE_FORMAT(date, format)",
|
||||
activeParameter: 1,
|
||||
parameters: ["date", "format"],
|
||||
});
|
||||
});
|
||||
|
||||
test("returns null signature help outside function calls", () => {
|
||||
assert.equal(getSqlFunctionSignatureHelp("select created_at from users", "select created_at".length), null);
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import test from "node:test";
|
||||
import { parseSqlErrorLocation } from "../../src/lib/sqlDiagnostics.ts";
|
||||
|
||||
test("parses generic line and column error positions", () => {
|
||||
assert.deepEqual(parseSqlErrorLocation("syntax error at line 3 column 12"), {
|
||||
line: 2,
|
||||
column: 11,
|
||||
});
|
||||
});
|
||||
|
||||
test("parses colon separated line and column error positions", () => {
|
||||
assert.deepEqual(parseSqlErrorLocation("ERROR 1064 (42000): near 'from' at line 2, column 7"), {
|
||||
line: 1,
|
||||
column: 6,
|
||||
});
|
||||
});
|
||||
|
||||
test("parses PostgreSQL position offsets", () => {
|
||||
assert.deepEqual(parseSqlErrorLocation('ERROR: syntax error at or near "from"\nLINE 4: select from users\n ^'), {
|
||||
line: 3,
|
||||
column: 8,
|
||||
});
|
||||
});
|
||||
|
||||
test("returns null when error has no editor position", () => {
|
||||
assert.equal(parseSqlErrorLocation("permission denied for table users"), null);
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import test from "node:test";
|
||||
import { resolveExecutableSql } from "../../src/lib/sqlExecutionTarget.ts";
|
||||
|
||||
test("uses selected SQL when the editor has a non-empty selection", () => {
|
||||
const sql = "SELECT * FROM users;\nSELECT * FROM orders;";
|
||||
|
||||
const result = resolveExecutableSql(sql, "SELECT * FROM orders;");
|
||||
|
||||
assert.equal(result, "SELECT * FROM orders;");
|
||||
});
|
||||
|
||||
test("falls back to full SQL when the selection is only whitespace", () => {
|
||||
const sql = "SELECT * FROM users;";
|
||||
|
||||
const result = resolveExecutableSql(sql, " \n\t");
|
||||
|
||||
assert.equal(result, sql);
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import test from "node:test";
|
||||
import { externalSqlFilePaths, isSqlFilePath, sqlFileTitleFromPath } from "../../src/lib/sqlFileOpen.ts";
|
||||
|
||||
test("detects SQL file paths case-insensitively", () => {
|
||||
assert.equal(isSqlFilePath("/tmp/report.sql"), true);
|
||||
assert.equal(isSqlFilePath("C:\\Users\\me\\Query.SQL"), true);
|
||||
assert.equal(isSqlFilePath("/tmp/report.txt"), false);
|
||||
});
|
||||
|
||||
test("derives a tab title from Unix or Windows SQL paths", () => {
|
||||
assert.equal(sqlFileTitleFromPath("/tmp/report.sql"), "report.sql");
|
||||
assert.equal(sqlFileTitleFromPath("C:\\Users\\me\\Query.SQL"), "Query.SQL");
|
||||
});
|
||||
|
||||
test("filters external open payloads down to SQL files", () => {
|
||||
assert.deepEqual(externalSqlFilePaths(["/tmp/a.sql", "/tmp/readme.md", "/tmp/b.SQL"]), ["/tmp/a.sql", "/tmp/b.SQL"]);
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import test from "node:test";
|
||||
import { formatSqlText } from "../../src/lib/sqlFormatter.ts";
|
||||
|
||||
test("formats SQL with uppercase keywords and readable line breaks", async () => {
|
||||
const formatted = await formatSqlText("select id, name from users where active = 1 order by name", "postgres");
|
||||
|
||||
assert.match(formatted, /^SELECT\b/);
|
||||
assert.match(formatted, /\nFROM\b/);
|
||||
assert.match(formatted, /\nWHERE\b/);
|
||||
assert.match(formatted, /\nORDER BY\b/);
|
||||
});
|
||||
|
||||
test("leaves blank SQL unchanged", async () => {
|
||||
assert.equal(await formatSqlText(" \n\t", "mysql"), " \n\t");
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { sqlMetadataRefreshScope, sqlMetadataRefreshTarget } from "../../src/lib/sqlMetadataRefresh.ts";
|
||||
|
||||
test("database DDL refreshes the connection database list", () => {
|
||||
assert.equal(sqlMetadataRefreshScope("CREATE DATABASE app;"), "connection");
|
||||
assert.deepEqual(sqlMetadataRefreshTarget("DROP DATABASE app;"), { scope: "connection" });
|
||||
});
|
||||
|
||||
test("schema DDL refreshes the selected database tree", () => {
|
||||
assert.equal(sqlMetadataRefreshScope("drop schema public cascade;"), "database");
|
||||
assert.deepEqual(sqlMetadataRefreshTarget("CREATE SCHEMA analytics;"), { scope: "database" });
|
||||
});
|
||||
|
||||
test("object DDL refreshes the selected database tree", () => {
|
||||
assert.equal(sqlMetadataRefreshScope("CREATE TABLE users (id int);"), "database");
|
||||
assert.equal(sqlMetadataRefreshScope("CREATE TEMP TABLE scratch (id int);"), "database");
|
||||
assert.equal(sqlMetadataRefreshScope("CREATE MATERIALIZED VIEW daily_users AS SELECT 1;"), "database");
|
||||
assert.equal(sqlMetadataRefreshScope("ALTER TABLE users ADD COLUMN name text;"), "database");
|
||||
assert.equal(sqlMetadataRefreshScope("DROP VIEW active_users;"), "database");
|
||||
assert.equal(
|
||||
sqlMetadataRefreshScope("CREATE OR REPLACE FUNCTION f() RETURNS int AS $$ SELECT 1 $$ LANGUAGE SQL;"),
|
||||
"database",
|
||||
);
|
||||
});
|
||||
|
||||
test("qualified object DDL refreshes the matching schema node", () => {
|
||||
assert.deepEqual(sqlMetadataRefreshTarget("CREATE TABLE public.users (id int);"), {
|
||||
scope: "database",
|
||||
schema: "public",
|
||||
});
|
||||
assert.deepEqual(sqlMetadataRefreshTarget('ALTER TABLE "Sales".orders ADD COLUMN name text;'), {
|
||||
scope: "database",
|
||||
schema: "Sales",
|
||||
});
|
||||
assert.deepEqual(sqlMetadataRefreshTarget("DROP VIEW `reporting`.active_users;"), {
|
||||
scope: "database",
|
||||
schema: "reporting",
|
||||
});
|
||||
assert.deepEqual(sqlMetadataRefreshTarget("CREATE INDEX idx_users_email ON public.users (email);"), {
|
||||
scope: "database",
|
||||
schema: "public",
|
||||
});
|
||||
});
|
||||
|
||||
test("unqualified object DDL can use the active schema as the refresh target", () => {
|
||||
assert.deepEqual(sqlMetadataRefreshTarget("ALTER TABLE users ADD COLUMN name text;", "public"), {
|
||||
scope: "database",
|
||||
schema: "public",
|
||||
});
|
||||
assert.deepEqual(sqlMetadataRefreshTarget("CREATE SCHEMA analytics;", "public"), { scope: "database" });
|
||||
});
|
||||
|
||||
test("multiple object DDL schemas fall back to refreshing the database tree", () => {
|
||||
assert.deepEqual(
|
||||
sqlMetadataRefreshTarget("CREATE TABLE public.users (id int); CREATE TABLE audit.events (id int);"),
|
||||
{ scope: "database" },
|
||||
);
|
||||
});
|
||||
|
||||
test("data-only SQL does not refresh metadata trees", () => {
|
||||
assert.equal(sqlMetadataRefreshScope("SELECT * FROM users;"), "none");
|
||||
assert.equal(sqlMetadataRefreshScope("INSERT INTO users VALUES (1);"), "none");
|
||||
assert.equal(sqlMetadataRefreshScope("TRUNCATE TABLE users;"), "none");
|
||||
assert.deepEqual(sqlMetadataRefreshTarget("SELECT * FROM users;"), { scope: "none" });
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { buildSqlServerDatabaseTreeNodes } from "../../src/lib/sqlServerTree.ts";
|
||||
import type { ObjectInfo } from "../../src/types/database.ts";
|
||||
|
||||
function obj(name: string, objectType = "TABLE"): ObjectInfo {
|
||||
return {
|
||||
name,
|
||||
object_type: objectType,
|
||||
};
|
||||
}
|
||||
|
||||
test("SQL Server database tree groups dbo objects and keeps non-default schemas", () => {
|
||||
const nodes = buildSqlServerDatabaseTreeNodes("conn", "app", ["dbo", "sales"], [
|
||||
obj("customers"),
|
||||
obj("customer_view", "VIEW"),
|
||||
obj("get_total", "FUNCTION"),
|
||||
]);
|
||||
|
||||
const topLevel = nodes.map((n) => ({ id: n.id, label: n.label, type: n.type }));
|
||||
assert.deepEqual(topLevel, [
|
||||
{ id: "conn:app:__tables", label: "tree.tables", type: "group-tables" },
|
||||
{ id: "conn:app:__views", label: "tree.views", type: "group-views" },
|
||||
{ id: "conn:app:__functions", label: "tree.functions", type: "group-functions" },
|
||||
{ id: "conn:app:sales", label: "sales", type: "schema" },
|
||||
]);
|
||||
|
||||
const tableGroup = nodes.find((n) => n.type === "group-tables");
|
||||
assert.deepEqual(
|
||||
tableGroup?.children?.map((c) => c.label),
|
||||
["customers"],
|
||||
);
|
||||
|
||||
const functionGroup = nodes.find((n) => n.type === "group-functions");
|
||||
assert.deepEqual(
|
||||
functionGroup?.children?.map((c) => c.label),
|
||||
["get_total"],
|
||||
);
|
||||
});
|
||||
|
||||
test("SQL Server database tree shows only schemas when dbo has no objects", () => {
|
||||
const nodes = buildSqlServerDatabaseTreeNodes("conn", "app", ["dbo", "archive"], []);
|
||||
|
||||
assert.deepEqual(
|
||||
nodes.map((node) => ({ id: node.id, label: node.label, type: node.type })),
|
||||
[{ id: "conn:app:archive", label: "archive", type: "schema" }],
|
||||
);
|
||||
});
|
||||
|
||||
test("SQL Server database tree groups procedures alongside tables", () => {
|
||||
const nodes = buildSqlServerDatabaseTreeNodes("conn", "app", ["dbo"], [
|
||||
obj("orders"),
|
||||
obj("sp_refresh", "PROCEDURE"),
|
||||
]);
|
||||
|
||||
assert.deepEqual(
|
||||
nodes.map((n) => ({ label: n.label, type: n.type })),
|
||||
[
|
||||
{ label: "tree.tables", type: "group-tables" },
|
||||
{ label: "tree.procedures", type: "group-procedures" },
|
||||
],
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { findStatementAtCursor } from "../../src/lib/sqlStatementSplit.ts";
|
||||
|
||||
test("finds single statement", () => {
|
||||
assert.equal(findStatementAtCursor("SELECT 1", 3), "SELECT 1");
|
||||
});
|
||||
|
||||
test("finds first statement before semicolon", () => {
|
||||
const sql = "SELECT 1; SELECT 2";
|
||||
assert.equal(findStatementAtCursor(sql, 3), "SELECT 1");
|
||||
});
|
||||
|
||||
test("finds second statement after semicolon", () => {
|
||||
const sql = "SELECT 1; SELECT 2";
|
||||
assert.equal(findStatementAtCursor(sql, 12), "SELECT 2");
|
||||
});
|
||||
|
||||
test("cursor at semicolon belongs to first statement", () => {
|
||||
const sql = "SELECT 1; SELECT 2";
|
||||
assert.equal(findStatementAtCursor(sql, 9), "SELECT 1");
|
||||
});
|
||||
|
||||
test("cursor at end belongs to last statement", () => {
|
||||
const sql = "SELECT 1; SELECT 2";
|
||||
assert.equal(findStatementAtCursor(sql, 18), "SELECT 2");
|
||||
});
|
||||
|
||||
test("skips semicolons inside single-quoted strings", () => {
|
||||
const sql = "SELECT ';'; SELECT 2";
|
||||
assert.equal(findStatementAtCursor(sql, 15), "SELECT 2");
|
||||
assert.equal(findStatementAtCursor(sql, 3), "SELECT ';'");
|
||||
});
|
||||
|
||||
test("skips semicolons inside double-quoted identifiers", () => {
|
||||
const sql = 'SELECT ";"; SELECT 2';
|
||||
assert.equal(findStatementAtCursor(sql, 3), 'SELECT ";"');
|
||||
});
|
||||
|
||||
test("skips semicolons inside line comments", () => {
|
||||
const sql = "SELECT 1 -- ; comment\n; SELECT 2";
|
||||
assert.equal(findStatementAtCursor(sql, 3), "SELECT 1 -- ; comment");
|
||||
assert.equal(findStatementAtCursor(sql, 25), "SELECT 2");
|
||||
});
|
||||
|
||||
test("skips semicolons inside block comments", () => {
|
||||
const sql = "SELECT 1 /* ; */ ; SELECT 2";
|
||||
assert.equal(findStatementAtCursor(sql, 3), "SELECT 1 /* ; */");
|
||||
assert.equal(findStatementAtCursor(sql, 22), "SELECT 2");
|
||||
});
|
||||
|
||||
test("handles multiple statements", () => {
|
||||
const sql = "INSERT INTO t VALUES (1); UPDATE t SET x = 2; DELETE FROM t";
|
||||
assert.equal(findStatementAtCursor(sql, 5), "INSERT INTO t VALUES (1)");
|
||||
assert.equal(findStatementAtCursor(sql, 30), "UPDATE t SET x = 2");
|
||||
assert.equal(findStatementAtCursor(sql, 50), "DELETE FROM t");
|
||||
});
|
||||
|
||||
test("handles trailing semicolon", () => {
|
||||
const sql = "SELECT 1;";
|
||||
assert.equal(findStatementAtCursor(sql, 3), "SELECT 1");
|
||||
});
|
||||
|
||||
test("handles backtick-quoted identifiers", () => {
|
||||
const sql = "SELECT `a;b`; SELECT 2";
|
||||
assert.equal(findStatementAtCursor(sql, 3), "SELECT `a;b`");
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import test from "node:test";
|
||||
import { closeAllTabsState, closeOtherTabsState } from "../../src/lib/tabCloseActions.ts";
|
||||
|
||||
test("close other tabs keeps the target tab and makes it active", () => {
|
||||
const tabs = [{ id: "a" }, { id: "b" }, { id: "c" }];
|
||||
|
||||
const result = closeOtherTabsState(tabs, "a", "b");
|
||||
|
||||
assert.deepEqual(result.tabs.map((tab) => tab.id), ["b"]);
|
||||
assert.equal(result.activeTabId, "b");
|
||||
assert.deepEqual(tabs.map((tab) => tab.id), ["a", "b", "c"]);
|
||||
});
|
||||
|
||||
test("close other tabs is a no-op when the target tab is missing", () => {
|
||||
const tabs = [{ id: "a" }, { id: "b" }];
|
||||
|
||||
const result = closeOtherTabsState(tabs, "a", "missing");
|
||||
|
||||
assert.deepEqual(result.tabs.map((tab) => tab.id), ["a", "b"]);
|
||||
assert.equal(result.activeTabId, "a");
|
||||
});
|
||||
|
||||
test("close all tabs clears every tab and active tab", () => {
|
||||
const result = closeAllTabsState([{ id: "a" }, { id: "b" }], "a");
|
||||
|
||||
assert.deepEqual(result.tabs, []);
|
||||
assert.equal(result.activeTabId, null);
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import test from "node:test";
|
||||
import { fetchTableDataForExport, TABLE_DATA_EXPORT_PAGE_SIZE } from "../../src/lib/tableDataExport.ts";
|
||||
import type { QueryResult } from "../../src/types/database.ts";
|
||||
|
||||
function result(rows: QueryResult["rows"]): QueryResult {
|
||||
return {
|
||||
columns: ["id"],
|
||||
rows,
|
||||
affected_rows: 0,
|
||||
execution_time_ms: 1,
|
||||
truncated: false,
|
||||
has_more: false,
|
||||
};
|
||||
}
|
||||
|
||||
test("fetchTableDataForExport pages past the 10000 row export boundary", async () => {
|
||||
const sqls: string[] = [];
|
||||
const pages = [
|
||||
result(Array.from({ length: TABLE_DATA_EXPORT_PAGE_SIZE }, (_, index) => [index + 1])),
|
||||
result([[10_001], [10_002]]),
|
||||
];
|
||||
|
||||
const exported = await fetchTableDataForExport({
|
||||
databaseType: "mysql",
|
||||
tableName: "users",
|
||||
executePage: async (sql) => {
|
||||
sqls.push(sql);
|
||||
return pages.shift() ?? result([]);
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(exported.rows.length, TABLE_DATA_EXPORT_PAGE_SIZE + 2);
|
||||
assert.deepEqual(exported.rows.at(-1), [10_002]);
|
||||
assert.deepEqual(sqls, [
|
||||
"SELECT * FROM `users` LIMIT 10000;",
|
||||
"SELECT * FROM `users` LIMIT 10000 OFFSET 10000;",
|
||||
]);
|
||||
assert.equal(exported.truncated, false);
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import test from "node:test";
|
||||
import {
|
||||
DBX_NEO4J_ELEMENT_ID_COLUMN,
|
||||
DBX_ROWID_COLUMN,
|
||||
DBX_TDENGINE_TBNAME_COLUMN,
|
||||
canEditExistingTableRows,
|
||||
editablePrimaryKeys,
|
||||
hiveTablePropertiesIndicateTransactional,
|
||||
isHiddenGridColumn,
|
||||
isTdengineExistingRowReadonlyColumn,
|
||||
isTableDataEditable,
|
||||
supportsDataGridTransaction,
|
||||
usesSyntheticRowIdKey,
|
||||
} from "../../src/lib/tableEditing.ts";
|
||||
import type { ColumnInfo } from "../../src/types/database.ts";
|
||||
|
||||
function column(name: string, isPrimaryKey = false): ColumnInfo {
|
||||
return {
|
||||
name,
|
||||
data_type: "VARCHAR2",
|
||||
is_nullable: true,
|
||||
column_default: null,
|
||||
is_primary_key: isPrimaryKey,
|
||||
extra: null,
|
||||
};
|
||||
}
|
||||
|
||||
test("uses ROWID as Oracle editable key when a table has no primary key", () => {
|
||||
assert.deepEqual(editablePrimaryKeys("oracle", [column("ID"), column("CITY")]), [DBX_ROWID_COLUMN]);
|
||||
});
|
||||
|
||||
test("keeps declared primary keys ahead of Oracle ROWID fallback", () => {
|
||||
assert.deepEqual(editablePrimaryKeys("oracle", [column("ID", true), column("CITY")]), ["ID"]);
|
||||
});
|
||||
|
||||
test("does not synthesize ROWID for non-Oracle keyless tables", () => {
|
||||
assert.deepEqual(editablePrimaryKeys("mysql", [column("ID"), column("CITY")]), []);
|
||||
});
|
||||
|
||||
test("uses tbname and timestamp as TDengine editable keys", () => {
|
||||
assert.deepEqual(editablePrimaryKeys("tdengine", [column("ts", true), column("current")]), [
|
||||
DBX_TDENGINE_TBNAME_COLUMN,
|
||||
"ts",
|
||||
]);
|
||||
});
|
||||
|
||||
test("allows Hive table data editing even without declared primary keys", () => {
|
||||
assert.equal(isTableDataEditable("access", []), true);
|
||||
assert.equal(isTableDataEditable("hive", []), true);
|
||||
assert.equal(isTableDataEditable("trino", []), true);
|
||||
assert.equal(isTableDataEditable("informix", []), true);
|
||||
assert.equal(isTableDataEditable("tdengine", []), true);
|
||||
assert.equal(isTableDataEditable("mysql", []), false);
|
||||
assert.equal(isTableDataEditable("postgres", ["id"]), true);
|
||||
});
|
||||
|
||||
test("does not use transactional grid saves for Hive", () => {
|
||||
assert.equal(supportsDataGridTransaction("hive"), false);
|
||||
assert.equal(supportsDataGridTransaction("trino"), false);
|
||||
assert.equal(supportsDataGridTransaction("postgres"), true);
|
||||
});
|
||||
|
||||
test("allows existing row edits according to database-specific key requirements", () => {
|
||||
assert.equal(canEditExistingTableRows("access", undefined, []), true);
|
||||
assert.equal(canEditExistingTableRows("access", undefined, ["ID"]), true);
|
||||
assert.equal(canEditExistingTableRows("hive", true), true);
|
||||
assert.equal(canEditExistingTableRows("hive", false), false);
|
||||
assert.equal(canEditExistingTableRows("hive", undefined), false);
|
||||
assert.equal(canEditExistingTableRows("trino", undefined, []), false);
|
||||
assert.equal(canEditExistingTableRows("trino", undefined, ["id"]), true);
|
||||
assert.equal(canEditExistingTableRows("informix", undefined, []), false);
|
||||
assert.equal(canEditExistingTableRows("informix", undefined, ["id"]), true);
|
||||
assert.equal(canEditExistingTableRows("tdengine", undefined, ["ts"]), false);
|
||||
assert.equal(canEditExistingTableRows("tdengine", undefined, [DBX_TDENGINE_TBNAME_COLUMN, "ts"]), true);
|
||||
assert.equal(canEditExistingTableRows("postgres", undefined), true);
|
||||
});
|
||||
|
||||
test("detects transactional Hive table properties", () => {
|
||||
assert.equal(
|
||||
hiveTablePropertiesIndicateTransactional({
|
||||
columns: ["prpt_name", "prpt_value"],
|
||||
rows: [["transactional", "true"]],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
hiveTablePropertiesIndicateTransactional({
|
||||
columns: ["prpt_name", "prpt_value"],
|
||||
rows: [["Table testdb.departments does not have property: transactional", null]],
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("uses elementId as Neo4j editable key when labels have no primary key", () => {
|
||||
assert.deepEqual(editablePrimaryKeys("neo4j", [column("name"), column("role")]), [DBX_NEO4J_ELEMENT_ID_COLUMN]);
|
||||
});
|
||||
|
||||
test("keeps TDengine existing row identity and tag columns read-only", () => {
|
||||
assert.equal(isTdengineExistingRowReadonlyColumn("tdengine", DBX_TDENGINE_TBNAME_COLUMN, [column("ts", true)]), true);
|
||||
assert.equal(isTdengineExistingRowReadonlyColumn("tdengine", "ts", [column("ts", true)]), true);
|
||||
assert.equal(isTdengineExistingRowReadonlyColumn("tdengine", "location", [column("location")]), false);
|
||||
assert.equal(isTdengineExistingRowReadonlyColumn("mysql", "ts", [column("ts", true)]), false);
|
||||
assert.equal(
|
||||
isTdengineExistingRowReadonlyColumn("tdengine", "location", [
|
||||
{ ...column("location"), extra: "TAG", comment: "TAG" },
|
||||
]),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("detects the synthetic Oracle ROWID key case", () => {
|
||||
assert.equal(usesSyntheticRowIdKey("oracle", [DBX_ROWID_COLUMN]), true);
|
||||
assert.equal(usesSyntheticRowIdKey("oracle", [DBX_ROWID_COLUMN.toLowerCase()]), true);
|
||||
assert.equal(usesSyntheticRowIdKey("postgres", [DBX_ROWID_COLUMN]), false);
|
||||
assert.equal(usesSyntheticRowIdKey("oracle", ["ID"]), false);
|
||||
assert.equal(usesSyntheticRowIdKey("neo4j", [DBX_NEO4J_ELEMENT_ID_COLUMN]), true);
|
||||
});
|
||||
|
||||
test("hides only the synthetic Oracle ROWID grid column", () => {
|
||||
assert.equal(isHiddenGridColumn("oracle", DBX_ROWID_COLUMN, [DBX_ROWID_COLUMN]), true);
|
||||
assert.equal(isHiddenGridColumn("oracle", "ROWID", [DBX_ROWID_COLUMN]), false);
|
||||
assert.equal(isHiddenGridColumn("mysql", DBX_ROWID_COLUMN, [DBX_ROWID_COLUMN]), false);
|
||||
assert.equal(isHiddenGridColumn("neo4j", DBX_NEO4J_ELEMENT_ID_COLUMN, [DBX_NEO4J_ELEMENT_ID_COLUMN]), true);
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import test from "node:test";
|
||||
import {
|
||||
autoMapImportColumns,
|
||||
normalizeImportColumnName,
|
||||
} from "../../src/lib/tableImport.ts";
|
||||
|
||||
test("normalizes import column names for matching", () => {
|
||||
assert.equal(normalizeImportColumnName(" User ID "), "user id");
|
||||
assert.equal(normalizeImportColumnName("user_id"), "user id");
|
||||
assert.equal(normalizeImportColumnName("USER-ID"), "user id");
|
||||
});
|
||||
|
||||
test("auto maps source columns to matching target columns and skips unknown columns", () => {
|
||||
const mapping = autoMapImportColumns(
|
||||
["id", "Name", "created-at", "ignored"],
|
||||
["user_id", "name", "created_at"],
|
||||
);
|
||||
|
||||
assert.deepEqual(mapping, {
|
||||
id: "",
|
||||
Name: "name",
|
||||
"created-at": "created_at",
|
||||
ignored: "",
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import test from "node:test";
|
||||
import { DBX_ROWID_COLUMN } from "../../src/lib/tableEditing.ts";
|
||||
import { buildTableSelectSql } from "../../src/lib/tableSelectSql.ts";
|
||||
|
||||
test("builds a MySQL table WHERE query from search input", () => {
|
||||
const sql = buildTableSelectSql({
|
||||
databaseType: "mysql",
|
||||
tableName: "users",
|
||||
primaryKeys: ["id"],
|
||||
whereInput: "where status = 'active'",
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
assert.equal(sql, "SELECT * FROM `users` WHERE (status = 'active') ORDER BY `id` ASC LIMIT 100;");
|
||||
});
|
||||
|
||||
test("builds a schema-qualified PostgreSQL table WHERE query", () => {
|
||||
const sql = buildTableSelectSql({
|
||||
databaseType: "postgres",
|
||||
schema: "public",
|
||||
tableName: "orders",
|
||||
whereInput: "WHERE amount > 10",
|
||||
limit: 50,
|
||||
offset: 100,
|
||||
});
|
||||
|
||||
assert.equal(sql, 'SELECT * FROM "public"."orders" WHERE (amount > 10) LIMIT 50 OFFSET 100;');
|
||||
});
|
||||
|
||||
test("builds schema-qualified Trino table data queries", () => {
|
||||
const sql = buildTableSelectSql({
|
||||
databaseType: "trino",
|
||||
schema: "tiny",
|
||||
tableName: "nation",
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
assert.equal(sql, 'SELECT * FROM "tiny"."nation" LIMIT 100;');
|
||||
});
|
||||
|
||||
test("builds Hive table data queries with backtick identifiers", () => {
|
||||
const sql = buildTableSelectSql({
|
||||
databaseType: "hive",
|
||||
tableName: "departments",
|
||||
primaryKeys: ["dept id"],
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
assert.equal(sql, "SELECT * FROM `departments` ORDER BY `dept id` ASC LIMIT 100;");
|
||||
});
|
||||
|
||||
test("builds TDengine table data queries with backtick identifiers", () => {
|
||||
const sql = buildTableSelectSql({
|
||||
databaseType: "tdengine",
|
||||
schema: "test_db",
|
||||
tableName: "meters",
|
||||
columns: ["ts", "current", "voltage", "location", "groupid"],
|
||||
primaryKeys: ["ts"],
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
sql,
|
||||
"SELECT tbname, `ts` AS `ts`, `current` AS `current`, `voltage` AS `voltage`, `location` AS `location`, `groupid` AS `groupid` FROM `test_db`.`meters` ORDER BY `ts` ASC LIMIT 100;",
|
||||
);
|
||||
});
|
||||
|
||||
test("builds Informix table data queries without database-qualified delimited identifiers", () => {
|
||||
const sql = buildTableSelectSql({
|
||||
databaseType: "informix",
|
||||
schema: "testdb",
|
||||
tableName: "dbx_grid_edit_probe",
|
||||
primaryKeys: ["id"],
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
assert.equal(sql, "SELECT * FROM dbx_grid_edit_probe ORDER BY id ASC LIMIT 100;");
|
||||
});
|
||||
|
||||
test("builds Access table data queries with backtick identifiers", () => {
|
||||
const sql = buildTableSelectSql({
|
||||
databaseType: "access",
|
||||
tableName: "Order Details",
|
||||
primaryKeys: ["Order ID"],
|
||||
limit: 100,
|
||||
offset: 200,
|
||||
});
|
||||
|
||||
assert.equal(sql, "SELECT * FROM `Order Details` ORDER BY `Order ID` ASC LIMIT 100 OFFSET 200;");
|
||||
});
|
||||
|
||||
test("expands Hive table data queries into aliased table columns", () => {
|
||||
const sql = buildTableSelectSql({
|
||||
databaseType: "hive",
|
||||
tableName: "departments",
|
||||
columns: ["id", "name"],
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
assert.equal(sql, "SELECT `id` AS `id`, `name` AS `name` FROM `departments` LIMIT 100;");
|
||||
});
|
||||
|
||||
test("builds SQL Server first page query with schema-aware brackets", () => {
|
||||
const sql = buildTableSelectSql({
|
||||
databaseType: "sqlserver",
|
||||
schema: "dbo",
|
||||
tableName: "accounts",
|
||||
whereInput: "where id = 1",
|
||||
limit: 25,
|
||||
primaryKeys: ["id"],
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
sql,
|
||||
"SELECT * FROM [dbo].[accounts] WHERE (id = 1) ORDER BY [id] ASC OFFSET 0 ROWS FETCH NEXT 25 ROWS ONLY",
|
||||
);
|
||||
});
|
||||
|
||||
test("builds SQL Server later pages with OFFSET and FETCH", () => {
|
||||
const sql = buildTableSelectSql({
|
||||
databaseType: "sqlserver",
|
||||
schema: "sales",
|
||||
tableName: "orders",
|
||||
primaryKeys: ["order_id"],
|
||||
limit: 50,
|
||||
offset: 100,
|
||||
});
|
||||
|
||||
assert.equal(sql, "SELECT * FROM [sales].[orders] ORDER BY [order_id] ASC OFFSET 100 ROWS FETCH NEXT 50 ROWS ONLY");
|
||||
});
|
||||
|
||||
test("builds SQL Server pages with fallback order columns when there is no primary key", () => {
|
||||
const sql = buildTableSelectSql({
|
||||
databaseType: "sqlserver",
|
||||
schema: "dbo",
|
||||
tableName: "logs",
|
||||
fallbackOrderColumns: ["created_at"],
|
||||
limit: 50,
|
||||
offset: 50,
|
||||
});
|
||||
|
||||
assert.equal(sql, "SELECT * FROM [dbo].[logs] ORDER BY [created_at] ASC OFFSET 50 ROWS FETCH NEXT 50 ROWS ONLY");
|
||||
});
|
||||
|
||||
test("builds Oracle table data queries with ROWID for keyless editing", () => {
|
||||
const sql = buildTableSelectSql({
|
||||
databaseType: "oracle",
|
||||
schema: "DBXTEST",
|
||||
tableName: "DBX_LOAD_TABLE_006",
|
||||
primaryKeys: [DBX_ROWID_COLUMN],
|
||||
includeRowId: true,
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
sql,
|
||||
`SELECT ROWIDTOCHAR(t.ROWID) AS "__DBX_ROWID", t.* FROM "DBXTEST"."DBX_LOAD_TABLE_006" t ORDER BY t.ROWID ASC FETCH FIRST 100 ROWS ONLY`,
|
||||
);
|
||||
});
|
||||
|
||||
test("builds Neo4j table data queries as Cypher label matches", () => {
|
||||
const sql = buildTableSelectSql({
|
||||
databaseType: "neo4j",
|
||||
tableName: "Employee",
|
||||
primaryKeys: ["id"],
|
||||
limit: 100,
|
||||
offset: 200,
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
sql,
|
||||
"MATCH (n:`Employee`) RETURN elementId(n) AS `__DBX_ELEMENT_ID`, n ORDER BY n.`id` ASC SKIP 200 LIMIT 100;",
|
||||
);
|
||||
});
|
||||
|
||||
test("expands Neo4j table data queries into node property columns", () => {
|
||||
const sql = buildTableSelectSql({
|
||||
databaseType: "neo4j",
|
||||
tableName: "Employee",
|
||||
columns: ["id", "first name", "role"],
|
||||
primaryKeys: ["id"],
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
sql,
|
||||
"MATCH (n:`Employee`) RETURN elementId(n) AS `__DBX_ELEMENT_ID`, n.`id` AS `id`, n.`first name` AS `first name`, n.`role` AS `role` ORDER BY n.`id` ASC LIMIT 100;",
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { strict as assert } from "node:assert";
|
||||
import test from "node:test";
|
||||
|
||||
const source = readFileSync("src/components/structure/TableStructureEditorDialog.vue", "utf8");
|
||||
|
||||
test("column comments can be expanded into a multiline editor", () => {
|
||||
assert.match(source, /PopoverContent/);
|
||||
assert.match(source, /v-model="column\.comment"/);
|
||||
assert.match(source, /<textarea[\s\S]*v-model="column\.comment"/);
|
||||
assert.match(source, /t\("structureEditor\.editComment"\)/);
|
||||
});
|
||||
|
||||
test("structure editor keeps columns when optional metadata fails", () => {
|
||||
assert.match(source, /const nextColumns = await api\.getColumns/);
|
||||
assert.match(source, /api\s*\n\s*\.listIndexes[\s\S]*\.catch\(\(\) => \[\]\)/);
|
||||
assert.match(source, /api\s*\n\s*\.listForeignKeys[\s\S]*\.catch\(\(\) => \[\]\)/);
|
||||
assert.match(source, /api\s*\n\s*\.listTriggers[\s\S]*\.catch\(\(\) => \[\]\)/);
|
||||
});
|
||||
@@ -0,0 +1,456 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {
|
||||
buildTableStructureChangeSql,
|
||||
type EditableStructureColumn,
|
||||
type EditableStructureIndex,
|
||||
} from "../../src/lib/tableStructureEditorSql.ts";
|
||||
|
||||
function column(overrides: Partial<EditableStructureColumn>): EditableStructureColumn {
|
||||
return {
|
||||
id: overrides.id ?? "col",
|
||||
name: overrides.name ?? "name",
|
||||
dataType: overrides.dataType ?? "varchar(255)",
|
||||
isNullable: overrides.isNullable ?? true,
|
||||
defaultValue: overrides.defaultValue ?? "",
|
||||
comment: overrides.comment ?? "",
|
||||
isPrimaryKey: overrides.isPrimaryKey ?? false,
|
||||
original: overrides.original,
|
||||
markedForDrop: overrides.markedForDrop ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
function index(overrides: Partial<EditableStructureIndex>): EditableStructureIndex {
|
||||
return {
|
||||
id: overrides.id ?? "idx",
|
||||
name: overrides.name ?? "idx_name",
|
||||
columns: overrides.columns ?? ["name"],
|
||||
isUnique: overrides.isUnique ?? false,
|
||||
isPrimary: overrides.isPrimary ?? false,
|
||||
filter: overrides.filter ?? "",
|
||||
indexType: overrides.indexType ?? "",
|
||||
includedColumns: overrides.includedColumns ?? [],
|
||||
comment: overrides.comment ?? "",
|
||||
original: overrides.original,
|
||||
markedForDrop: overrides.markedForDrop ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
test("builds MySQL column and index change statements", () => {
|
||||
const result = buildTableStructureChangeSql({
|
||||
databaseType: "mysql",
|
||||
tableName: "users",
|
||||
columns: [
|
||||
column({
|
||||
id: "name",
|
||||
name: "display_name",
|
||||
dataType: "varchar(120)",
|
||||
isNullable: false,
|
||||
defaultValue: "'guest'",
|
||||
comment: "Shown name",
|
||||
original: {
|
||||
name: "name",
|
||||
data_type: "varchar(80)",
|
||||
is_nullable: true,
|
||||
column_default: null,
|
||||
is_primary_key: false,
|
||||
extra: null,
|
||||
comment: "",
|
||||
},
|
||||
}),
|
||||
column({ id: "email", name: "email", dataType: "varchar(255)", isNullable: false }),
|
||||
column({
|
||||
id: "legacy",
|
||||
name: "legacy",
|
||||
markedForDrop: true,
|
||||
original: {
|
||||
name: "legacy",
|
||||
data_type: "text",
|
||||
is_nullable: true,
|
||||
column_default: null,
|
||||
is_primary_key: false,
|
||||
extra: null,
|
||||
},
|
||||
}),
|
||||
],
|
||||
indexes: [
|
||||
index({
|
||||
id: "old",
|
||||
name: "idx_old",
|
||||
markedForDrop: true,
|
||||
original: { name: "idx_old", columns: ["name"], is_unique: false, is_primary: false },
|
||||
}),
|
||||
index({ id: "email_idx", name: "uniq_users_email", columns: ["email"], isUnique: true }),
|
||||
],
|
||||
});
|
||||
|
||||
assert.deepEqual(result.warnings, []);
|
||||
assert.deepEqual(result.statements, [
|
||||
"ALTER TABLE `users` CHANGE COLUMN `name` `display_name` varchar(120) NOT NULL DEFAULT 'guest' COMMENT 'Shown name';",
|
||||
"ALTER TABLE `users` ADD COLUMN `email` varchar(255) NOT NULL;",
|
||||
"ALTER TABLE `users` DROP COLUMN `legacy`;",
|
||||
"DROP INDEX `idx_old` ON `users`;",
|
||||
"CREATE UNIQUE INDEX `uniq_users_email` ON `users` (`email`);",
|
||||
]);
|
||||
});
|
||||
|
||||
test("builds PostgreSQL rename, type, default, comment, and index statements", () => {
|
||||
const result = buildTableStructureChangeSql({
|
||||
databaseType: "postgres",
|
||||
schema: "public",
|
||||
tableName: "accounts",
|
||||
columns: [
|
||||
column({
|
||||
id: "status",
|
||||
name: "account_status",
|
||||
dataType: "text",
|
||||
isNullable: false,
|
||||
defaultValue: "'active'",
|
||||
comment: "Current status",
|
||||
original: {
|
||||
name: "status",
|
||||
data_type: "varchar",
|
||||
is_nullable: true,
|
||||
column_default: null,
|
||||
is_primary_key: false,
|
||||
extra: null,
|
||||
comment: "",
|
||||
},
|
||||
}),
|
||||
],
|
||||
indexes: [
|
||||
index({ id: "new", name: "idx_accounts_status", columns: ["account_status"] }),
|
||||
],
|
||||
});
|
||||
|
||||
assert.deepEqual(result.warnings, []);
|
||||
assert.deepEqual(result.statements, [
|
||||
"ALTER TABLE \"public\".\"accounts\" RENAME COLUMN \"status\" TO \"account_status\";",
|
||||
"ALTER TABLE \"public\".\"accounts\" ALTER COLUMN \"account_status\" TYPE text;",
|
||||
"ALTER TABLE \"public\".\"accounts\" ALTER COLUMN \"account_status\" SET NOT NULL;",
|
||||
"ALTER TABLE \"public\".\"accounts\" ALTER COLUMN \"account_status\" SET DEFAULT 'active';",
|
||||
"COMMENT ON COLUMN \"public\".\"accounts\".\"account_status\" IS 'Current status';",
|
||||
"CREATE INDEX \"idx_accounts_status\" ON \"public\".\"accounts\" (\"account_status\");",
|
||||
]);
|
||||
});
|
||||
|
||||
test("warns when SQLite cannot safely alter existing column attributes", () => {
|
||||
const result = buildTableStructureChangeSql({
|
||||
databaseType: "sqlite",
|
||||
tableName: "notes",
|
||||
columns: [
|
||||
column({
|
||||
id: "title",
|
||||
name: "title",
|
||||
dataType: "text",
|
||||
isNullable: false,
|
||||
original: {
|
||||
name: "title",
|
||||
data_type: "varchar(100)",
|
||||
is_nullable: true,
|
||||
column_default: null,
|
||||
is_primary_key: false,
|
||||
extra: null,
|
||||
},
|
||||
}),
|
||||
column({ id: "body", name: "body", dataType: "text", isNullable: true }),
|
||||
],
|
||||
indexes: [],
|
||||
});
|
||||
|
||||
assert.deepEqual(result.statements, [
|
||||
"ALTER TABLE \"notes\" ADD COLUMN \"body\" text;",
|
||||
]);
|
||||
assert.deepEqual(result.warnings, [
|
||||
"SQLite cannot safely alter existing column \"title\" without rebuilding the table.",
|
||||
]);
|
||||
});
|
||||
|
||||
test("quotes SQL Server table, column, and index names with brackets", () => {
|
||||
const result = buildTableStructureChangeSql({
|
||||
databaseType: "sqlserver",
|
||||
schema: "dbo",
|
||||
tableName: "users",
|
||||
columns: [
|
||||
column({ id: "email", name: "email", dataType: "nvarchar(255)", isNullable: false }),
|
||||
],
|
||||
indexes: [
|
||||
index({ id: "idx", name: "idx_users_email", columns: ["email"] }),
|
||||
],
|
||||
});
|
||||
|
||||
assert.deepEqual(result.warnings, []);
|
||||
assert.deepEqual(result.statements, [
|
||||
"ALTER TABLE [dbo].[users] ADD [email] nvarchar(255) NOT NULL;",
|
||||
"CREATE INDEX [idx_users_email] ON [dbo].[users] ([email]);",
|
||||
]);
|
||||
});
|
||||
|
||||
test("PostgreSQL index with INCLUDE clause", () => {
|
||||
const result = buildTableStructureChangeSql({
|
||||
databaseType: "postgres",
|
||||
schema: "public",
|
||||
tableName: "orders",
|
||||
columns: [],
|
||||
indexes: [
|
||||
index({
|
||||
id: "new",
|
||||
name: "idx_orders_status",
|
||||
columns: ["status"],
|
||||
includedColumns: ["total", "created_at"],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
assert.deepEqual(result.warnings, []);
|
||||
assert.deepEqual(result.statements, [
|
||||
'CREATE INDEX "idx_orders_status" ON "public"."orders" ("status") INCLUDE ("total", "created_at");',
|
||||
]);
|
||||
});
|
||||
|
||||
test("PostgreSQL index with USING clause (index type)", () => {
|
||||
const result = buildTableStructureChangeSql({
|
||||
databaseType: "postgres",
|
||||
schema: "public",
|
||||
tableName: "docs",
|
||||
columns: [],
|
||||
indexes: [
|
||||
index({
|
||||
id: "new",
|
||||
name: "idx_docs_body",
|
||||
columns: ["body"],
|
||||
indexType: "GIN",
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
assert.deepEqual(result.warnings, []);
|
||||
assert.deepEqual(result.statements, [
|
||||
'CREATE INDEX "idx_docs_body" ON "public"."docs" USING GIN ("body");',
|
||||
]);
|
||||
});
|
||||
|
||||
test("PostgreSQL index with WHERE filter", () => {
|
||||
const result = buildTableStructureChangeSql({
|
||||
databaseType: "postgres",
|
||||
schema: "public",
|
||||
tableName: "users",
|
||||
columns: [],
|
||||
indexes: [
|
||||
index({
|
||||
id: "new",
|
||||
name: "idx_users_active",
|
||||
columns: ["email"],
|
||||
filter: "deleted_at IS NULL",
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
assert.deepEqual(result.warnings, []);
|
||||
assert.deepEqual(result.statements, [
|
||||
'CREATE INDEX "idx_users_active" ON "public"."users" ("email") WHERE deleted_at IS NULL;',
|
||||
]);
|
||||
});
|
||||
|
||||
test("PostgreSQL index with COMMENT", () => {
|
||||
const result = buildTableStructureChangeSql({
|
||||
databaseType: "postgres",
|
||||
schema: "public",
|
||||
tableName: "users",
|
||||
columns: [],
|
||||
indexes: [
|
||||
index({
|
||||
id: "new",
|
||||
name: "idx_users_email",
|
||||
columns: ["email"],
|
||||
comment: "Fast lookup by email",
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
assert.deepEqual(result.warnings, []);
|
||||
assert.deepEqual(result.statements, [
|
||||
'CREATE INDEX "idx_users_email" ON "public"."users" ("email");',
|
||||
"COMMENT ON INDEX \"idx_users_email\" IS 'Fast lookup by email';",
|
||||
]);
|
||||
});
|
||||
|
||||
test("PostgreSQL index with single quote in comment is escaped", () => {
|
||||
const result = buildTableStructureChangeSql({
|
||||
databaseType: "postgres",
|
||||
schema: "public",
|
||||
tableName: "users",
|
||||
columns: [],
|
||||
indexes: [
|
||||
index({
|
||||
id: "new",
|
||||
name: "idx_users_email",
|
||||
columns: ["email"],
|
||||
comment: "User's primary email",
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
assert.deepEqual(result.warnings, []);
|
||||
assert.deepEqual(result.statements, [
|
||||
'CREATE INDEX "idx_users_email" ON "public"."users" ("email");',
|
||||
"COMMENT ON INDEX \"idx_users_email\" IS 'User''s primary email';",
|
||||
]);
|
||||
});
|
||||
|
||||
test("PostgreSQL index with all options combined (unique + type + include + filter + comment)", () => {
|
||||
const result = buildTableStructureChangeSql({
|
||||
databaseType: "postgres",
|
||||
schema: "public",
|
||||
tableName: "orders",
|
||||
columns: [],
|
||||
indexes: [
|
||||
index({
|
||||
id: "new",
|
||||
name: "idx_orders_covering",
|
||||
columns: ["user_id", "status"],
|
||||
isUnique: true,
|
||||
indexType: "BTREE",
|
||||
includedColumns: ["total"],
|
||||
filter: "status = 'active'",
|
||||
comment: "Covering index for active orders",
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
assert.deepEqual(result.warnings, []);
|
||||
assert.deepEqual(result.statements, [
|
||||
'CREATE UNIQUE INDEX "idx_orders_covering" ON "public"."orders" USING BTREE ("user_id", "status") INCLUDE ("total") WHERE status = \'active\';',
|
||||
"COMMENT ON INDEX \"idx_orders_covering\" IS 'Covering index for active orders';",
|
||||
]);
|
||||
});
|
||||
|
||||
test("SQL Server index with type prefix", () => {
|
||||
const result = buildTableStructureChangeSql({
|
||||
databaseType: "sqlserver",
|
||||
schema: "dbo",
|
||||
tableName: "logs",
|
||||
columns: [],
|
||||
indexes: [
|
||||
index({
|
||||
id: "new",
|
||||
name: "idx_logs_message",
|
||||
columns: ["message"],
|
||||
indexType: "CLUSTERED",
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
assert.deepEqual(result.warnings, []);
|
||||
assert.deepEqual(result.statements, [
|
||||
"CREATE CLUSTERED INDEX [idx_logs_message] ON [dbo].[logs] ([message]);",
|
||||
]);
|
||||
});
|
||||
|
||||
test("SQL Server index with INCLUDE clause", () => {
|
||||
const result = buildTableStructureChangeSql({
|
||||
databaseType: "sqlserver",
|
||||
schema: "dbo",
|
||||
tableName: "orders",
|
||||
columns: [],
|
||||
indexes: [
|
||||
index({
|
||||
id: "new",
|
||||
name: "idx_orders_status",
|
||||
columns: ["status"],
|
||||
includedColumns: ["total", "created_at"],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
assert.deepEqual(result.warnings, []);
|
||||
assert.deepEqual(result.statements, [
|
||||
"CREATE INDEX [idx_orders_status] ON [dbo].[orders] ([status]) INCLUDE ([total], [created_at]);",
|
||||
]);
|
||||
});
|
||||
|
||||
test("SQL Server index with type + include combined", () => {
|
||||
const result = buildTableStructureChangeSql({
|
||||
databaseType: "sqlserver",
|
||||
schema: "dbo",
|
||||
tableName: "orders",
|
||||
columns: [],
|
||||
indexes: [
|
||||
index({
|
||||
id: "new",
|
||||
name: "idx_orders_covering",
|
||||
columns: ["user_id"],
|
||||
indexType: "NONCLUSTERED",
|
||||
includedColumns: ["total", "status"],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
assert.deepEqual(result.warnings, []);
|
||||
assert.deepEqual(result.statements, [
|
||||
"CREATE NONCLUSTERED INDEX [idx_orders_covering] ON [dbo].[orders] ([user_id]) INCLUDE ([total], [status]);",
|
||||
]);
|
||||
});
|
||||
|
||||
test("MySQL index omits USING, type prefix, INCLUDE, and WHERE (unsupported)", () => {
|
||||
const result = buildTableStructureChangeSql({
|
||||
databaseType: "mysql",
|
||||
tableName: "orders",
|
||||
columns: [],
|
||||
indexes: [
|
||||
index({
|
||||
id: "new",
|
||||
name: "idx_orders_status",
|
||||
columns: ["status"],
|
||||
indexType: "BTREE",
|
||||
includedColumns: ["total"],
|
||||
filter: "deleted = 0",
|
||||
comment: "Some comment",
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
assert.deepEqual(result.warnings, []);
|
||||
assert.deepEqual(result.statements, [
|
||||
"CREATE INDEX `idx_orders_status` ON `orders` (`status`);",
|
||||
]);
|
||||
});
|
||||
|
||||
test("SQLite index with filter (partial index)", () => {
|
||||
const result = buildTableStructureChangeSql({
|
||||
databaseType: "sqlite",
|
||||
tableName: "users",
|
||||
columns: [],
|
||||
indexes: [
|
||||
index({
|
||||
id: "new",
|
||||
name: "idx_users_active",
|
||||
columns: ["email"],
|
||||
filter: "deleted_at IS NULL",
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
assert.deepEqual(result.warnings, []);
|
||||
assert.deepEqual(result.statements, [
|
||||
'CREATE INDEX "idx_users_active" ON "users" ("email") WHERE deleted_at IS NULL;',
|
||||
]);
|
||||
});
|
||||
|
||||
test("index with empty name and columns produces warnings and no statements", () => {
|
||||
const result = buildTableStructureChangeSql({
|
||||
databaseType: "postgres",
|
||||
schema: "public",
|
||||
tableName: "users",
|
||||
columns: [],
|
||||
indexes: [
|
||||
index({ id: "empty", name: "", columns: [] }),
|
||||
],
|
||||
});
|
||||
|
||||
assert.deepEqual(result.warnings, [
|
||||
'Index name cannot be empty.',
|
||||
'Index "(new)" needs at least one column.',
|
||||
]);
|
||||
assert.deepEqual(result.statements, []);
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {
|
||||
createColumnDrafts,
|
||||
createIndexDrafts,
|
||||
toColumnNames,
|
||||
} from "../../src/lib/tableStructureEditorState.ts";
|
||||
import type { ColumnInfo, IndexInfo } from "../../src/types/database.ts";
|
||||
|
||||
const columns: ColumnInfo[] = [
|
||||
{
|
||||
name: "id",
|
||||
data_type: "bigint",
|
||||
is_nullable: false,
|
||||
column_default: null,
|
||||
is_primary_key: true,
|
||||
extra: "auto_increment",
|
||||
comment: "identifier",
|
||||
},
|
||||
{
|
||||
name: "name",
|
||||
data_type: "varchar(120)",
|
||||
is_nullable: true,
|
||||
column_default: "'guest'",
|
||||
is_primary_key: false,
|
||||
extra: null,
|
||||
comment: null,
|
||||
},
|
||||
];
|
||||
|
||||
const indexes: IndexInfo[] = [
|
||||
{ name: "PRIMARY", columns: ["id"], is_unique: true, is_primary: true },
|
||||
{ name: "idx_name", columns: ["name"], is_unique: false, is_primary: false },
|
||||
];
|
||||
|
||||
test("creates editable column drafts from column metadata", () => {
|
||||
const drafts = createColumnDrafts(columns);
|
||||
|
||||
assert.deepEqual(drafts.map((draft) => ({
|
||||
id: draft.id,
|
||||
name: draft.name,
|
||||
dataType: draft.dataType,
|
||||
isNullable: draft.isNullable,
|
||||
defaultValue: draft.defaultValue,
|
||||
comment: draft.comment,
|
||||
isPrimaryKey: draft.isPrimaryKey,
|
||||
markedForDrop: draft.markedForDrop,
|
||||
originalName: draft.original?.name,
|
||||
})), [
|
||||
{
|
||||
id: "existing:id",
|
||||
name: "id",
|
||||
dataType: "bigint",
|
||||
isNullable: false,
|
||||
defaultValue: "",
|
||||
comment: "identifier",
|
||||
isPrimaryKey: true,
|
||||
markedForDrop: false,
|
||||
originalName: "id",
|
||||
},
|
||||
{
|
||||
id: "existing:name",
|
||||
name: "name",
|
||||
dataType: "varchar(120)",
|
||||
isNullable: true,
|
||||
defaultValue: "'guest'",
|
||||
comment: "",
|
||||
isPrimaryKey: false,
|
||||
markedForDrop: false,
|
||||
originalName: "name",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("creates editable index drafts and splits pasted column lists", () => {
|
||||
const drafts = createIndexDrafts(indexes);
|
||||
|
||||
assert.deepEqual(drafts.map((draft) => ({
|
||||
id: draft.id,
|
||||
name: draft.name,
|
||||
columns: draft.columns,
|
||||
isUnique: draft.isUnique,
|
||||
isPrimary: draft.isPrimary,
|
||||
originalName: draft.original?.name,
|
||||
})), [
|
||||
{
|
||||
id: "existing:PRIMARY",
|
||||
name: "PRIMARY",
|
||||
columns: ["id"],
|
||||
isUnique: true,
|
||||
isPrimary: true,
|
||||
originalName: "PRIMARY",
|
||||
},
|
||||
{
|
||||
id: "existing:idx_name",
|
||||
name: "idx_name",
|
||||
columns: ["name"],
|
||||
isUnique: false,
|
||||
isPrimary: false,
|
||||
originalName: "idx_name",
|
||||
},
|
||||
]);
|
||||
assert.equal(toColumnNames(["id", "name"]), "id, name");
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { buildTableTreeNodes, expandCachedObjectBrowserNodes, objectGroupRefreshParentId } from "../../src/lib/tableTree.ts";
|
||||
import type { TableInfo } from "../../src/types/database.ts";
|
||||
|
||||
function table(name: string, tableType: "TABLE" | "VIEW" = "TABLE"): TableInfo {
|
||||
return { name, table_type: tableType };
|
||||
}
|
||||
|
||||
test("keeps every table as a sidebar node instead of truncating to object browser", () => {
|
||||
const tables: TableInfo[] = Array.from({ length: 16 }, (_, index) => table(`table_${index + 1}`));
|
||||
|
||||
const nodes = buildTableTreeNodes({
|
||||
nodeId: "conn:db",
|
||||
connectionId: "conn",
|
||||
database: "db",
|
||||
tables,
|
||||
});
|
||||
|
||||
assert.equal(nodes.length, 16);
|
||||
assert.equal(nodes.at(-1)?.label, "table_16");
|
||||
assert.equal(
|
||||
nodes.some((node) => node.type === "object-browser"),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("preserves table and view node types", () => {
|
||||
const nodes = buildTableTreeNodes({
|
||||
nodeId: "conn:db:public",
|
||||
connectionId: "conn",
|
||||
database: "db",
|
||||
schema: "public",
|
||||
tables: [table("users"), table("user_view", "VIEW")],
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
nodes.map((node) => [node.label, node.type, node.schema]),
|
||||
[
|
||||
["users", "table", "public"],
|
||||
["user_view", "view", "public"],
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("expands cached object-browser nodes back into regular table nodes", () => {
|
||||
const nodes = expandCachedObjectBrowserNodes([
|
||||
{
|
||||
id: "conn:db:table_1",
|
||||
label: "table_1",
|
||||
type: "table",
|
||||
connectionId: "conn",
|
||||
database: "db",
|
||||
isExpanded: false,
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
id: "conn:db:__object_browser",
|
||||
label: "tree.objectBrowser",
|
||||
type: "object-browser",
|
||||
connectionId: "conn",
|
||||
database: "db",
|
||||
hiddenChildren: [
|
||||
{
|
||||
id: "conn:db:table_16",
|
||||
label: "table_16",
|
||||
type: "table",
|
||||
connectionId: "conn",
|
||||
database: "db",
|
||||
isExpanded: false,
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
assert.deepEqual(
|
||||
nodes.map((node) => [node.label, node.type]),
|
||||
[
|
||||
["table_1", "table"],
|
||||
["table_16", "table"],
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("resolves grouped object refreshes to the parent schema node", () => {
|
||||
assert.equal(
|
||||
objectGroupRefreshParentId({
|
||||
id: "conn:db:public:__tables",
|
||||
label: "tree.tables",
|
||||
type: "group-tables",
|
||||
connectionId: "conn",
|
||||
database: "db",
|
||||
schema: "public",
|
||||
}),
|
||||
"conn:db:public",
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import test from "node:test";
|
||||
import { isTauriRuntime } from "../../src/lib/tauriRuntime.ts";
|
||||
|
||||
test("detects plain browser-like globals as non-Tauri runtime", () => {
|
||||
assert.equal(isTauriRuntime({}), false);
|
||||
});
|
||||
|
||||
test("detects Tauri globals", () => {
|
||||
assert.equal(isTauriRuntime({ __TAURI_INTERNALS__: {} }), true);
|
||||
assert.equal(isTauriRuntime({ __TAURI__: {} }), true);
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import test from "node:test";
|
||||
import { nextTransferTerminalState } from "../../src/lib/transferProgressState.ts";
|
||||
|
||||
test("marks transfer as failed when a table progress event reports error", () => {
|
||||
const state = nextTransferTerminalState(
|
||||
{ done: false, cancelled: false, error: false },
|
||||
{ status: "error" },
|
||||
);
|
||||
|
||||
assert.deepEqual(state, { done: false, cancelled: false, error: true });
|
||||
});
|
||||
|
||||
test("keeps terminal flags for done and cancelled progress events", () => {
|
||||
assert.deepEqual(
|
||||
nextTransferTerminalState({ done: false, cancelled: false, error: false }, { status: "done" }),
|
||||
{ done: true, cancelled: false, error: false },
|
||||
);
|
||||
assert.deepEqual(
|
||||
nextTransferTerminalState({ done: false, cancelled: false, error: false }, { status: "cancelled" }),
|
||||
{ done: false, cancelled: true, error: false },
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { sidebarSelectionCopyAction, treeNodeRowAction, treeNodeRowDoubleClickAction } from "../../src/lib/treeNodeClick.ts";
|
||||
|
||||
test("table and view rows open data without toggling structure groups", () => {
|
||||
assert.equal(treeNodeRowAction("table", true), "open-data");
|
||||
assert.equal(treeNodeRowAction("view", true), "open-data");
|
||||
});
|
||||
|
||||
test("double click navigation mode selects rows on single click", () => {
|
||||
assert.equal(treeNodeRowAction("table", true, "double"), "none");
|
||||
assert.equal(treeNodeRowAction("view", true, "double"), "none");
|
||||
assert.equal(treeNodeRowAction("procedure", false, "double"), "none");
|
||||
assert.equal(treeNodeRowAction("saved-sql-file", false, "double"), "none");
|
||||
});
|
||||
|
||||
test("double click navigation mode opens actionable rows on double click", () => {
|
||||
assert.equal(treeNodeRowDoubleClickAction("table", true, "double"), "open-data");
|
||||
assert.equal(treeNodeRowDoubleClickAction("view", true, "double"), "open-data");
|
||||
assert.equal(treeNodeRowDoubleClickAction("procedure", false, "double"), "open-source");
|
||||
assert.equal(treeNodeRowDoubleClickAction("saved-sql-file", false, "double"), "open-saved-sql");
|
||||
});
|
||||
|
||||
test("double click navigation mode toggles expandable rows on double click", () => {
|
||||
assert.equal(treeNodeRowDoubleClickAction("connection", false, "double", true), "toggle");
|
||||
assert.equal(treeNodeRowDoubleClickAction("group-columns", false, "double", true), "toggle");
|
||||
assert.equal(treeNodeRowDoubleClickAction("redis-db", false, "double", false), "toggle");
|
||||
});
|
||||
|
||||
test("expandable non-table rows still toggle from row clicks", () => {
|
||||
assert.equal(treeNodeRowAction("connection", true), "toggle");
|
||||
assert.equal(treeNodeRowAction("database", true), "toggle");
|
||||
assert.equal(treeNodeRowAction("schema", true), "toggle");
|
||||
assert.equal(treeNodeRowAction("group-columns", true), "toggle");
|
||||
});
|
||||
|
||||
test("leaf data browser nodes keep their open behavior through toggle handler", () => {
|
||||
assert.equal(treeNodeRowAction("redis-db", false), "toggle");
|
||||
assert.equal(treeNodeRowAction("mongo-collection", false), "toggle");
|
||||
});
|
||||
|
||||
test("plain metadata leaf rows do nothing on row clicks", () => {
|
||||
assert.equal(treeNodeRowAction("column", false), "none");
|
||||
assert.equal(treeNodeRowAction("index", false), "none");
|
||||
});
|
||||
|
||||
test("database and schema rows open object browser only on double click", () => {
|
||||
assert.equal(treeNodeRowAction("database", true), "toggle");
|
||||
assert.equal(treeNodeRowAction("schema", true), "toggle");
|
||||
assert.equal(treeNodeRowDoubleClickAction("database", true), "open-object-browser");
|
||||
assert.equal(treeNodeRowDoubleClickAction("schema", true), "open-object-browser");
|
||||
assert.equal(treeNodeRowAction("database", true, "double"), "none");
|
||||
assert.equal(treeNodeRowAction("schema", true, "double"), "none");
|
||||
});
|
||||
|
||||
test("double click navigation mode opens object browser for database and schema rows", () => {
|
||||
assert.equal(treeNodeRowDoubleClickAction("database", true, "double", false), "open-object-browser");
|
||||
assert.equal(treeNodeRowDoubleClickAction("schema", true, "double", false), "open-object-browser");
|
||||
});
|
||||
|
||||
test("double click navigation mode opens object browser and expands expandable database and schema rows", () => {
|
||||
assert.equal(treeNodeRowDoubleClickAction("database", true, "double", true), "open-object-browser-and-expand");
|
||||
assert.equal(treeNodeRowDoubleClickAction("schema", true, "double", true), "open-object-browser-and-expand");
|
||||
});
|
||||
|
||||
test("double click does not open object browser for non-browsable rows", () => {
|
||||
assert.equal(treeNodeRowDoubleClickAction("database", false), "none");
|
||||
assert.equal(treeNodeRowDoubleClickAction("table", true), "none");
|
||||
assert.equal(treeNodeRowDoubleClickAction("column", true), "none");
|
||||
});
|
||||
|
||||
test("double click navigation mode copies the selected sidebar row name", () => {
|
||||
assert.equal(sidebarSelectionCopyAction({ key: "c", metaKey: true }, "double"), "copy-name");
|
||||
assert.equal(sidebarSelectionCopyAction({ key: "C", ctrlKey: true }, "double"), "copy-name");
|
||||
});
|
||||
|
||||
test("single click navigation mode leaves copy shortcuts alone", () => {
|
||||
assert.equal(sidebarSelectionCopyAction({ key: "c", metaKey: true }, "single"), "none");
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { findDatabaseTreeNode } from "../../src/lib/treeRefreshTarget.ts";
|
||||
import type { TreeNode } from "../../src/types/database.ts";
|
||||
|
||||
test("finds database refresh targets inside grouped sidebar trees", () => {
|
||||
const target: TreeNode = {
|
||||
id: "conn-1:app",
|
||||
label: "app",
|
||||
type: "database",
|
||||
connectionId: "conn-1",
|
||||
database: "app",
|
||||
};
|
||||
const nodes: TreeNode[] = [
|
||||
{
|
||||
id: "group-1",
|
||||
label: "Production",
|
||||
type: "connection-group",
|
||||
children: [
|
||||
{
|
||||
id: "conn-1",
|
||||
label: "mysql",
|
||||
type: "connection",
|
||||
connectionId: "conn-1",
|
||||
children: [target],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
assert.equal(findDatabaseTreeNode(nodes, "conn-1", "app"), target);
|
||||
});
|
||||
|
||||
test("returns null when the target database node is not loaded", () => {
|
||||
assert.equal(findDatabaseTreeNode([], "conn-1", "app"), null);
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import test from "node:test";
|
||||
import {
|
||||
SIDEBAR_TREE_ROW_HEIGHT,
|
||||
SIDEBAR_TREE_PRERENDER_COUNT,
|
||||
SIDEBAR_TREE_SCROLL_BUFFER,
|
||||
flattenTree,
|
||||
shouldVirtualizeFlatTree,
|
||||
} from "../../src/composables/useFlatTree.ts";
|
||||
import type { TreeNode } from "../../src/types/database.ts";
|
||||
|
||||
test("flattenTree preserves depth and node type for virtualized sidebar rows", () => {
|
||||
const nodes: TreeNode[] = [
|
||||
{
|
||||
id: "conn",
|
||||
label: "Connection",
|
||||
type: "connection",
|
||||
isExpanded: true,
|
||||
children: [
|
||||
{ id: "conn:file", label: "Query.sql", type: "saved-sql-file" },
|
||||
{
|
||||
id: "conn:db",
|
||||
label: "db",
|
||||
type: "database",
|
||||
isExpanded: true,
|
||||
children: [{ id: "conn:db:table", label: "table", type: "table" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const flat = flattenTree(nodes);
|
||||
|
||||
assert.deepEqual(
|
||||
flat.map((item) => ({ id: item.id, depth: item.depth, type: item.type })),
|
||||
[
|
||||
{ id: "conn", depth: 0, type: "connection" },
|
||||
{ id: "conn:file", depth: 1, type: "saved-sql-file" },
|
||||
{ id: "conn:db", depth: 1, type: "database" },
|
||||
{ id: "conn:db:table", depth: 2, type: "table" },
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("shouldVirtualizeFlatTree virtualizes every non-empty sidebar tree", () => {
|
||||
assert.equal(shouldVirtualizeFlatTree(0), false);
|
||||
assert.equal(shouldVirtualizeFlatTree(1), true);
|
||||
assert.equal(shouldVirtualizeFlatTree(100), true);
|
||||
});
|
||||
|
||||
test("sidebar virtual tree keeps enough buffered rows for fast scrolling", () => {
|
||||
assert.equal(SIDEBAR_TREE_ROW_HEIGHT, 28);
|
||||
assert.equal(SIDEBAR_TREE_SCROLL_BUFFER, 600);
|
||||
assert.ok(SIDEBAR_TREE_SCROLL_BUFFER >= SIDEBAR_TREE_ROW_HEIGHT * 20);
|
||||
});
|
||||
|
||||
test("sidebar virtual tree prerenders enough rows for the first frame", () => {
|
||||
assert.ok(SIDEBAR_TREE_PRERENDER_COUNT >= 40);
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import test from "node:test";
|
||||
import { isDangerousSql, stripSqlComments } from "../../src/composables/useSqlExecution.ts";
|
||||
|
||||
test("stripSqlComments removes block comments", () => {
|
||||
assert.equal(stripSqlComments("SELECT /* drop */ 1").includes("drop"), false);
|
||||
});
|
||||
|
||||
test("stripSqlComments removes line comments", () => {
|
||||
assert.equal(stripSqlComments("SELECT 1 -- DROP TABLE").includes("DROP"), false);
|
||||
});
|
||||
|
||||
test("stripSqlComments removes MySQL hash comments", () => {
|
||||
assert.equal(stripSqlComments("SELECT 1 # DELETE").includes("DELETE"), false);
|
||||
});
|
||||
|
||||
test("SELECT with REPLACE function is not dangerous", () => {
|
||||
assert.equal(isDangerousSql("SELECT REPLACE(col, ';', ',') FROM t"), false);
|
||||
});
|
||||
|
||||
test("SELECT with TRUNCATE function is not dangerous", () => {
|
||||
assert.equal(isDangerousSql("SELECT TRUNCATE(1.234, 2)"), false);
|
||||
});
|
||||
|
||||
test("REPLACE INTO is dangerous", () => {
|
||||
assert.equal(isDangerousSql("REPLACE INTO t VALUES (1, 2)"), true);
|
||||
});
|
||||
|
||||
test("DELETE FROM is dangerous", () => {
|
||||
assert.equal(isDangerousSql("DELETE FROM t WHERE id = 1"), true);
|
||||
});
|
||||
|
||||
test("DROP TABLE is dangerous", () => {
|
||||
assert.equal(isDangerousSql("DROP TABLE t"), true);
|
||||
});
|
||||
|
||||
test("UPDATE is dangerous", () => {
|
||||
assert.equal(isDangerousSql("UPDATE t SET col = 1"), true);
|
||||
});
|
||||
|
||||
test("ALTER TABLE is dangerous", () => {
|
||||
assert.equal(isDangerousSql("ALTER TABLE t ADD COLUMN c INT"), true);
|
||||
});
|
||||
|
||||
test("TRUNCATE TABLE is dangerous", () => {
|
||||
assert.equal(isDangerousSql("TRUNCATE TABLE t"), true);
|
||||
});
|
||||
|
||||
test("multi-statement with danger in second statement is dangerous", () => {
|
||||
assert.equal(isDangerousSql("SELECT 1; DROP TABLE t"), true);
|
||||
});
|
||||
|
||||
test("danger keyword inside comment is not dangerous", () => {
|
||||
assert.equal(isDangerousSql("SELECT 1 /* DROP TABLE t */"), false);
|
||||
});
|
||||
|
||||
test("leading whitespace before danger keyword is dangerous", () => {
|
||||
assert.equal(isDangerousSql(" DELETE FROM t"), true);
|
||||
});
|
||||
|
||||
test("complex SELECT with joins and functions is not dangerous", () => {
|
||||
const sql = `select mkey, replace(xxxx,';',',') as xxxx_content,
|
||||
SUBSTRING_INDEX(SUBSTRING_INDEX(pmid,'/',3),'/',-1) as xxx
|
||||
from m_xxx left join (
|
||||
select xxx from m_alarm where alarm_time >= SUBDATE(now(), interval 3 minute)
|
||||
) as xxxx on m_alarm.mid = m_alarm_tmp.mid`;
|
||||
assert.equal(isDangerousSql(sql), false);
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import test from "node:test";
|
||||
import {
|
||||
filterVisibleDatabaseNames,
|
||||
normalizeVisibleDatabaseSelection,
|
||||
visibleDatabaseFilterIsEnabled,
|
||||
} from "../../src/lib/visibleDatabases.ts";
|
||||
|
||||
test("undefined visible database filter keeps every database", () => {
|
||||
assert.deepEqual(filterVisibleDatabaseNames(["app", "analytics"], undefined), ["app", "analytics"]);
|
||||
assert.equal(visibleDatabaseFilterIsEnabled(undefined), false);
|
||||
});
|
||||
|
||||
test("configured visible database filter keeps selected databases in source order", () => {
|
||||
assert.deepEqual(filterVisibleDatabaseNames(["app", "analytics", "billing"], ["billing", "app"]), [
|
||||
"app",
|
||||
"billing",
|
||||
]);
|
||||
assert.equal(visibleDatabaseFilterIsEnabled(["billing", "app"]), true);
|
||||
});
|
||||
|
||||
test("empty configured visible database filter hides every database", () => {
|
||||
assert.deepEqual(filterVisibleDatabaseNames(["app", "analytics"], []), []);
|
||||
assert.equal(visibleDatabaseFilterIsEnabled([]), true);
|
||||
});
|
||||
|
||||
test("normalizes selected database names against fresh database names", () => {
|
||||
assert.deepEqual(normalizeVisibleDatabaseSelection(["billing", "missing", "app", "app"], ["app", "billing"]), [
|
||||
"billing",
|
||||
"app",
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import test from "node:test";
|
||||
import { buildXlsxWorkbook } from "../../src/lib/xlsxExport.ts";
|
||||
|
||||
test("builds an xlsx workbook zip with worksheet data", () => {
|
||||
const workbook = buildXlsxWorkbook({
|
||||
sheetName: "Users",
|
||||
columns: ["id", "name", "active"],
|
||||
rows: [
|
||||
[1, "Ada & Bob", true],
|
||||
[2, null, false],
|
||||
],
|
||||
});
|
||||
const text = new TextDecoder().decode(workbook);
|
||||
|
||||
assert.equal(workbook[0], 0x50);
|
||||
assert.equal(workbook[1], 0x4b);
|
||||
assert.match(text, /\[Content_Types\]\.xml/);
|
||||
assert.match(text, /xl\/worksheets\/sheet1\.xml/);
|
||||
assert.match(text, /name="Users"/);
|
||||
assert.match(text, /<c r="A2"><v>1<\/v><\/c>/);
|
||||
assert.match(text, /Ada & Bob/);
|
||||
assert.match(text, /<c r="C2" t="b"><v>1<\/v><\/c>/);
|
||||
});
|
||||
|
||||
test("sanitizes invalid sheet names", () => {
|
||||
const workbook = buildXlsxWorkbook({
|
||||
sheetName: "bad/name:with*chars?and-a-very-long-tail",
|
||||
columns: ["value"],
|
||||
rows: [["ok"]],
|
||||
});
|
||||
const text = new TextDecoder().decode(workbook);
|
||||
|
||||
assert.match(text, /name="bad name with chars and-a-very-"/);
|
||||
});
|
||||
Reference in New Issue
Block a user