v0.7.6: PostgreSQL object management, keychain toggle, tabbed Objects workspace (#12)
* [P1-T1] Change::Ddl Rust variant + execute_change arm * [P1-T2] Frontend ddl change type + objectCrud capability * [P1-T3] use_keychain data model + migration v8 * [P2-T1] object_crud skeleton + validators + build_ddl dispatch * [P2-T2] Sequence builders * [P2-T3] Enum builders (no value removal) * [P2-T4] View / matview / extension builders * [P2-T5] Index + constraint builders * [P2-T6] Function / procedure + trigger builders * [P2-T7] build_object_ddl + get_available_extensions commands + wrappers * [P3-T1] conditional keychain + session passwords * [P3-T2] keychain-off password prompt on connect * [P3-T3] default-ON keychain opt-out + tooltip + modal conditional * [P3-T4] ddl queue card + after-commit refetch * [P4-T1] ObjectCrudDialog shell * [P4-T2] SequenceForm * [P4-T3] EnumForm with no-removal note * [P4-T4] ExtensionForm with available-extensions picker * [P4-T5] ViewForm (view + materialized view) * [P5-T1] IndexForm with column picker * [P5-T2] ConstraintForm (check/unique/pk/fk) + ColumnPicker * [P5-T3] FunctionForm (function + procedure) * [P5-T4] TriggerForm with trigger-function picker * [P5-T5] ObjectContextMenu + Explorer/TableOverflowMenu CRUD wiring * [P6-T1] object tab type + openObjectTab dedup * [P6-T2] extract ObjectDetail for object tabs * [P6-T3] Objects view two-pane sidebar + workspace * [P6-T4] object-tab content + per-type tab icons * [P7-T1] Version bump 0.7.5 -> 0.7.6 * [P7-T2] docs sync README/ROADMAP/AGENTS for v0.7.6 * [P7-T3] chore: Cargo.lock version sync 0.7.5 -> 0.7.6 * fix(ui): object tab icon stacks above name (preflight svg block) * fix(ui): optically center object tab icon with name * fix(ui): object tab icon matches query/table icon handling * [UI-POLISH-1] objectForm tab type + openFormTab store action * [UI-POLISH-2] ObjectFormTab + KindForm with Visual/SQL toggle * [UI-POLISH-3] route create/edit through form tabs; remove modal * docs: create/edit now open as form tabs (AGENTS sync) * [FB-1] follow app styling patterns + schema dropdown in forms * [FB-2] Monaco editor for function body + view definition * [FB-3] form tabs styled like viewers + in-cell editing * [FB-5] focus outline scoped to input area (label excluded) * [FB-6] no amber focus outline on Monaco body/definition rows * [FB-7] header dedupe + schema default + full edit prefill * [FB-8] SQL view in read-only Monaco editor * docs: roadmap — table create/edit + relationships (next) * docs: roadmap — Admin follow-up is 0.7.7 (next after 0.7.6)
This commit is contained in:
@@ -1,11 +1,11 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import tauriConf from "../../src-tauri/tauri.conf.json";
|
||||
|
||||
describe("tauri bundle config (v0.7.5)", () => {
|
||||
describe("tauri bundle config (v0.7.6)", () => {
|
||||
it("declares bundled pg_tools resources", () => {
|
||||
expect(tauriConf.bundle.resources).toContain("resources/pg_tools/*");
|
||||
});
|
||||
it("version is 0.7.5", () => {
|
||||
expect(tauriConf.version).toBe("0.7.5");
|
||||
it("version is 0.7.6", () => {
|
||||
expect(tauriConf.version).toBe("0.7.6");
|
||||
});
|
||||
});
|
||||
@@ -61,4 +61,20 @@ describe("buildChangeSql", () => {
|
||||
expect(buildChangeSql({ id: "1", type: "empty_table", schema: "public", table: "t" } as any)).toBe('DELETE FROM "public"."t"');
|
||||
expect(buildChangeSql({ id: "1", type: "drop_table", schema: "public", table: "t" } as any)).toBe('DROP TABLE "public"."t"');
|
||||
});
|
||||
});
|
||||
|
||||
const ddlItem: QueueItem = {
|
||||
id: "ch-1", type: "ddl",
|
||||
sql: "CREATE TYPE public.role AS ENUM ('admin')",
|
||||
status: "pending", createdAt: 0,
|
||||
};
|
||||
describe("ddl change", () => {
|
||||
it("builds a ddl payload with id + type + sql", () => {
|
||||
expect(buildChangePayload(ddlItem)).toEqual({
|
||||
id: "ch-1", type: "ddl", sql: "CREATE TYPE public.role AS ENUM ('admin')",
|
||||
});
|
||||
});
|
||||
it("preview SQL is the raw sql", () => {
|
||||
expect(buildChangeSql(ddlItem)).toBe("CREATE TYPE public.role AS ENUM ('admin')");
|
||||
});
|
||||
});
|
||||
@@ -57,6 +57,8 @@ export function buildChangeSql(item: QueueItem): string {
|
||||
}
|
||||
case "empty_table":
|
||||
return `DELETE FROM ${t}`;
|
||||
case "ddl":
|
||||
return item.sql ?? "";
|
||||
case "drop_table":
|
||||
return `DROP TABLE ${t}`;
|
||||
default:
|
||||
@@ -84,6 +86,8 @@ export function buildChangePayload(item: QueueItem): ChangePayload {
|
||||
return { id: item.id, type: "drop_table", schema, table };
|
||||
case "empty_table":
|
||||
return { id: item.id, type: "empty_table", schema, table };
|
||||
case "ddl":
|
||||
return { id: item.id, type: "ddl", sql: item.sql };
|
||||
default:
|
||||
return { id: item.id, type: item.type, sql: item.sql };
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ describe("dbCapabilities", () => {
|
||||
const c = DB_CAPABILITIES.postgresql;
|
||||
expect(c).toEqual<DbCapabilities>({
|
||||
explorer: true, queries: true, objects: true, visualizer: true,
|
||||
tools: true, editing: true, import: true, ddl: true,
|
||||
tools: true, editing: true, import: true, ddl: true, objectCrud: true,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -37,7 +37,7 @@ describe("dbCapabilities", () => {
|
||||
it("gives Redis nothing (connection+test only)", () => {
|
||||
expect(DB_CAPABILITIES.redis).toEqual<DbCapabilities>({
|
||||
explorer: false, queries: false, objects: false, visualizer: false,
|
||||
tools: false, editing: false, import: false, ddl: false,
|
||||
tools: false, editing: false, import: false, ddl: false, objectCrud: false,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -57,4 +57,16 @@ describe("dbCapabilities", () => {
|
||||
expect(getCapabilities("sqlite").objects).toBe(false);
|
||||
expect(getCapabilities("redis").objects).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("objectCrud capability", () => {
|
||||
it("is true only for postgresql", () => {
|
||||
expect(DB_CAPABILITIES.postgresql.objectCrud).toBe(true);
|
||||
expect(DB_CAPABILITIES.mysql.objectCrud).toBe(false);
|
||||
expect(DB_CAPABILITIES.sqlite.objectCrud).toBe(false);
|
||||
expect(DB_CAPABILITIES.redis.objectCrud).toBe(false);
|
||||
});
|
||||
it("unknown types get objectCrud false", () => {
|
||||
expect(getCapabilities("oracle").objectCrud).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -17,15 +17,17 @@ export interface DbCapabilities {
|
||||
import: boolean;
|
||||
/** Copy table schema (DDL). */
|
||||
ddl: boolean;
|
||||
/** Create / edit / drop PostgreSQL objects via the changes queue. */
|
||||
objectCrud: boolean;
|
||||
}
|
||||
|
||||
const ALL_FALSE: DbCapabilities = {
|
||||
explorer: false, queries: false, objects: false, visualizer: false,
|
||||
tools: false, editing: false, import: false, ddl: false,
|
||||
tools: false, editing: false, import: false, ddl: false, objectCrud: false,
|
||||
};
|
||||
|
||||
export const DB_CAPABILITIES: Record<DbType, DbCapabilities> = {
|
||||
postgresql: { ...ALL_FALSE, explorer: true, queries: true, objects: true, visualizer: true, tools: true, editing: true, import: true, ddl: true },
|
||||
postgresql: { ...ALL_FALSE, explorer: true, queries: true, objects: true, visualizer: true, tools: true, editing: true, import: true, ddl: true, objectCrud: true },
|
||||
mysql: { ...ALL_FALSE, explorer: true, queries: true, editing: true, import: true, ddl: true },
|
||||
sqlite: { ...ALL_FALSE, explorer: true, queries: true, visualizer: true, editing: true, import: true, ddl: true },
|
||||
redis: { ...ALL_FALSE },
|
||||
|
||||
@@ -4,7 +4,7 @@ import { describe, it, expect } from "vitest";
|
||||
import agents from "../../AGENTS.md?raw";
|
||||
import readme from "../../README.md?raw";
|
||||
|
||||
describe("v0.7.5 docs coverage", () => {
|
||||
describe("v0.7.6 docs coverage", () => {
|
||||
it("AGENTS.md marks inline cell editing complete", () => {
|
||||
expect(agents).toContain("Inline cell editing");
|
||||
expect(agents).toMatch(/Inline cell editing \| ✅/);
|
||||
@@ -24,8 +24,8 @@ describe("v0.7.5 docs coverage", () => {
|
||||
expect(agents).toMatch(/Connection status indicator on cards \| ✅/);
|
||||
expect(agents).toMatch(/Move-to-folder bulk action \| ✅/);
|
||||
});
|
||||
it("README declares v0.7.5", () => {
|
||||
expect(readme).toContain("0.7.5");
|
||||
it("README declares v0.7.6", () => {
|
||||
expect(readme).toContain("0.7.6");
|
||||
});
|
||||
it("AGENTS.md marks schema CRUD complete", () => {
|
||||
expect(agents).toMatch(/Schema CRUD \| ✅/);
|
||||
@@ -50,4 +50,18 @@ describe("v0.7.5 docs coverage", () => {
|
||||
expect(readme).toMatch(/Changes queue \(stage → commit\)\s*\|[^|]*❌[^|]*\|[^|]*\|[^|]*\|\s*\*\*✅ Queue → Commit All\*\*/);
|
||||
expect(readme).not.toMatch(/Inline cell editing.*Upcoming/);
|
||||
});
|
||||
it("AGENTS.md marks object management CRUD complete", () => {
|
||||
expect(agents).toMatch(/Object management CRUD \| ✅/);
|
||||
});
|
||||
it("AGENTS.md marks the keychain toggle complete", () => {
|
||||
expect(agents).toMatch(/Enable keychain toggle \| ✅/);
|
||||
});
|
||||
it("AGENTS.md marks the Objects view tabbed workspace complete", () => {
|
||||
expect(agents).toMatch(/Objects view tabbed workspace \| ✅/);
|
||||
});
|
||||
it("README links to v0.7.6 assets in both download tables", () => {
|
||||
expect(readme).toContain("releases/download/v0.7.6/");
|
||||
expect(readme).toContain("Gridline_0.7.6_aarch64.dmg");
|
||||
expect(readme).toContain("Gridline-0.7.6-1.x86_64.rpm");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { saveConnectionPassword, deleteConnectionPassword } from "./commands";
|
||||
|
||||
// Persist (or purge) the DB password according to the keychain toggle.
|
||||
// use_keychain defaults to true (opt-out): ON = OS keychain; OFF = purge + session-only.
|
||||
// Shared by connectionStore.createConnection and EditConnectionModal.
|
||||
export async function persistDbPassword(
|
||||
connectionId: string,
|
||||
useKeychain: boolean | undefined,
|
||||
password: string | null | undefined,
|
||||
): Promise<void> {
|
||||
const useKc = useKeychain ?? true;
|
||||
if (useKc && password) {
|
||||
await saveConnectionPassword(connectionId, password);
|
||||
} else if (!useKc) {
|
||||
try { await deleteConnectionPassword(connectionId); } catch { /* purge; ignore missing */ }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,463 @@
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
|
||||
vi.mock("@tauri-apps/api/core", () => ({
|
||||
invoke: vi.fn(),
|
||||
}));
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import {
|
||||
buildObjectDdl,
|
||||
dropCrudParams,
|
||||
getAvailableExtensions,
|
||||
initialCrudParams,
|
||||
} from "./objectCrud";
|
||||
|
||||
describe("buildObjectDdl", () => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it("invokes build_object_ddl with connectionId, kind, params", async () => {
|
||||
vi.mocked(invoke).mockResolvedValueOnce([
|
||||
"CREATE TYPE public.role AS ENUM ('admin')",
|
||||
]);
|
||||
const sql = await buildObjectDdl("c1", "enum", {
|
||||
schema: "public",
|
||||
name: "role",
|
||||
action: { op: "create", labels: ["admin"] },
|
||||
});
|
||||
expect(invoke).toHaveBeenCalledWith("build_object_ddl", {
|
||||
connectionId: "c1",
|
||||
kind: "enum",
|
||||
params: {
|
||||
schema: "public",
|
||||
name: "role",
|
||||
action: { op: "create", labels: ["admin"] },
|
||||
},
|
||||
});
|
||||
expect(sql).toEqual(["CREATE TYPE public.role AS ENUM ('admin')"]);
|
||||
});
|
||||
|
||||
it("passes through drop params for sequences", async () => {
|
||||
vi.mocked(invoke).mockResolvedValueOnce(['DROP SEQUENCE "public"."s"']);
|
||||
const sql = await buildObjectDdl("c1", "sequence", {
|
||||
schema: "public",
|
||||
name: "s",
|
||||
action: { op: "drop" },
|
||||
});
|
||||
expect(invoke).toHaveBeenCalledWith("build_object_ddl", {
|
||||
connectionId: "c1",
|
||||
kind: "sequence",
|
||||
params: { schema: "public", name: "s", action: { op: "drop" } },
|
||||
});
|
||||
expect(sql).toEqual(['DROP SEQUENCE "public"."s"']);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAvailableExtensions", () => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it("invokes get_available_extensions with connectionId", async () => {
|
||||
vi.mocked(invoke).mockResolvedValueOnce([
|
||||
{ name: "pgcrypto", version: "1.3", comment: null },
|
||||
]);
|
||||
const exts = await getAvailableExtensions("c1");
|
||||
expect(invoke).toHaveBeenCalledWith("get_available_extensions", {
|
||||
connectionId: "c1",
|
||||
});
|
||||
expect(exts).toEqual([{ name: "pgcrypto", version: "1.3", comment: null }]);
|
||||
});
|
||||
|
||||
it("returns empty array when no extensions available", async () => {
|
||||
vi.mocked(invoke).mockResolvedValueOnce([]);
|
||||
const exts = await getAvailableExtensions("c2");
|
||||
expect(exts).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("initialCrudParams", () => {
|
||||
it("create shape starts empty with sensible defaults", () => {
|
||||
const p = initialCrudParams(
|
||||
"sequence",
|
||||
{ schema: "public", name: "" },
|
||||
"create",
|
||||
);
|
||||
expect(p).toEqual({
|
||||
schema: "public",
|
||||
name: "",
|
||||
action: {
|
||||
op: "create",
|
||||
increment: "1",
|
||||
min_value: "1",
|
||||
max_value: "9223372036854775807",
|
||||
start: "1",
|
||||
cycle: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("edit prefills the name from the item", () => {
|
||||
const p = initialCrudParams(
|
||||
"sequence",
|
||||
{ schema: "public", name: "s" },
|
||||
"edit",
|
||||
);
|
||||
expect(p).toMatchObject({
|
||||
schema: "public",
|
||||
name: "s",
|
||||
action: { op: "create", increment: "1" },
|
||||
});
|
||||
});
|
||||
|
||||
it("enum edit carries labels through", () => {
|
||||
const p = initialCrudParams(
|
||||
"enum",
|
||||
{ schema: "public", name: "role", labels: ["admin"] },
|
||||
"edit",
|
||||
);
|
||||
expect(p.action).toMatchObject({ op: "create", labels: ["admin"] });
|
||||
});
|
||||
|
||||
it("index create keeps the owning table and an empty column list", () => {
|
||||
const p = initialCrudParams(
|
||||
"index",
|
||||
{ schema: "public", table: "users", name: "" },
|
||||
"create",
|
||||
);
|
||||
expect(p).toEqual({
|
||||
schema: "public",
|
||||
table: "users",
|
||||
name: "",
|
||||
action: { op: "create", unique: false, method: "", columns: [], predicate: null },
|
||||
});
|
||||
});
|
||||
|
||||
it("constraint create starts as a CHECK on the owning table", () => {
|
||||
const p = initialCrudParams(
|
||||
"constraint",
|
||||
{ schema: "public", table: "users", name: "" },
|
||||
"create",
|
||||
);
|
||||
expect(p).toEqual({
|
||||
schema: "public",
|
||||
table: "users",
|
||||
name: "",
|
||||
action: { op: "check", expression: "" },
|
||||
});
|
||||
});
|
||||
|
||||
it("procedure edit prefills is_procedure from kind", () => {
|
||||
const p = initialCrudParams(
|
||||
"procedure",
|
||||
{ schema: "public", name: "do_thing" },
|
||||
"edit",
|
||||
);
|
||||
expect(p).toMatchObject({
|
||||
schema: "public",
|
||||
name: "do_thing",
|
||||
is_procedure: true,
|
||||
action: { op: "create_or_replace", args: [] },
|
||||
});
|
||||
});
|
||||
|
||||
it("view edit prefills the definition and marks non-materialized", () => {
|
||||
const p = initialCrudParams(
|
||||
"view",
|
||||
{ schema: "public", name: "v", definition: "SELECT 1" },
|
||||
"edit",
|
||||
);
|
||||
expect(p).toMatchObject({
|
||||
schema: "public",
|
||||
name: "v",
|
||||
materialized: false,
|
||||
action: { op: "create", definition: "SELECT 1" },
|
||||
});
|
||||
});
|
||||
|
||||
it("function edit prefills body, zipped args, return_type, and language", () => {
|
||||
const p = initialCrudParams(
|
||||
"function",
|
||||
{
|
||||
schema: "public",
|
||||
name: "add_one",
|
||||
return_type: "integer",
|
||||
argument_types: ["integer"],
|
||||
argument_names: ["x"],
|
||||
argument_modes: ["IN"],
|
||||
language: "plpgsql",
|
||||
source: "BEGIN RETURN x + 1; END",
|
||||
kind: "f",
|
||||
},
|
||||
"edit",
|
||||
);
|
||||
expect(p).toMatchObject({
|
||||
schema: "public",
|
||||
name: "add_one",
|
||||
is_procedure: false,
|
||||
action: {
|
||||
op: "create_or_replace",
|
||||
args: [{ mode: "IN", name: "x", type: "integer" }],
|
||||
return_type: "integer",
|
||||
language: "plpgsql",
|
||||
body: "BEGIN RETURN x + 1; END",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("procedure edit prefills source and zips multiple args", () => {
|
||||
const p = initialCrudParams(
|
||||
"procedure",
|
||||
{
|
||||
schema: "public",
|
||||
name: "do_thing",
|
||||
return_type: "void",
|
||||
argument_types: ["int", "text"],
|
||||
argument_names: ["a", "b"],
|
||||
argument_modes: ["IN", "OUT"],
|
||||
language: "plpgsql",
|
||||
source: "BEGIN PERFORM a; END",
|
||||
kind: "p",
|
||||
},
|
||||
"edit",
|
||||
);
|
||||
expect(p).toMatchObject({
|
||||
is_procedure: true,
|
||||
action: {
|
||||
op: "create_or_replace",
|
||||
args: [
|
||||
{ mode: "IN", name: "a", type: "int" },
|
||||
{ mode: "OUT", name: "b", type: "text" },
|
||||
],
|
||||
return_type: "void",
|
||||
body: "BEGIN PERFORM a; END",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("function create keeps empty args/body and the default language", () => {
|
||||
const p = initialCrudParams(
|
||||
"function",
|
||||
{ schema: "public", name: "add_one" },
|
||||
"create",
|
||||
);
|
||||
expect(p.action).toMatchObject({
|
||||
op: "create_or_replace",
|
||||
args: [],
|
||||
return_type: null,
|
||||
language: "plpgsql",
|
||||
body: "",
|
||||
});
|
||||
});
|
||||
|
||||
it("sequence edit prefills increment/min/max/start/cycle from the item", () => {
|
||||
const p = initialCrudParams(
|
||||
"sequence",
|
||||
{
|
||||
schema: "public",
|
||||
name: "s",
|
||||
start_value: "5",
|
||||
min_value: "1",
|
||||
max_value: "999",
|
||||
increment: "2",
|
||||
cycle: true,
|
||||
},
|
||||
"edit",
|
||||
);
|
||||
expect(p.action).toMatchObject({
|
||||
op: "create",
|
||||
increment: "2",
|
||||
min_value: "1",
|
||||
max_value: "999",
|
||||
start: "5",
|
||||
cycle: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("trigger edit prefills table, timing, events, and orientation", () => {
|
||||
const p = initialCrudParams(
|
||||
"trigger",
|
||||
{
|
||||
schema: "public",
|
||||
name: "trg",
|
||||
table_name: "users",
|
||||
event_manipulation: "INSERT",
|
||||
action_timing: "AFTER",
|
||||
action_orientation: "row",
|
||||
},
|
||||
"edit",
|
||||
);
|
||||
expect(p.action).toMatchObject({
|
||||
op: "create",
|
||||
table: "users",
|
||||
timing: "AFTER",
|
||||
events: ["INSERT"],
|
||||
orientation: "ROW",
|
||||
});
|
||||
});
|
||||
|
||||
it("trigger edit splits OR-joined event_manipulation into separate events", () => {
|
||||
const p = initialCrudParams(
|
||||
"trigger",
|
||||
{
|
||||
schema: "public",
|
||||
name: "trg",
|
||||
table_name: "orders",
|
||||
event_manipulation: "INSERT OR UPDATE",
|
||||
action_timing: "BEFORE",
|
||||
action_orientation: "STATEMENT",
|
||||
},
|
||||
"edit",
|
||||
);
|
||||
expect(p.action).toMatchObject({
|
||||
events: ["INSERT", "UPDATE"],
|
||||
orientation: "STATEMENT",
|
||||
});
|
||||
});
|
||||
|
||||
it("index edit prefills name, unique, method, and columns", () => {
|
||||
const p = initialCrudParams(
|
||||
"index",
|
||||
{
|
||||
schema: "public",
|
||||
table: "users",
|
||||
name: "idx_users_email",
|
||||
is_unique: true,
|
||||
method: "btree",
|
||||
columns: ["email"],
|
||||
},
|
||||
"edit",
|
||||
);
|
||||
expect(p).toMatchObject({
|
||||
schema: "public",
|
||||
table: "users",
|
||||
name: "idx_users_email",
|
||||
action: {
|
||||
op: "create",
|
||||
unique: true,
|
||||
method: "btree",
|
||||
columns: ["email"],
|
||||
predicate: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("constraint edit maps CHECK contype to a check action with the definition", () => {
|
||||
const p = initialCrudParams(
|
||||
"constraint",
|
||||
{
|
||||
schema: "public",
|
||||
table: "users",
|
||||
name: "chk_age",
|
||||
contype: "CHECK",
|
||||
definition: "CHECK (age > 0)",
|
||||
columns: ["age"],
|
||||
},
|
||||
"edit",
|
||||
);
|
||||
expect(p).toMatchObject({
|
||||
schema: "public",
|
||||
table: "users",
|
||||
name: "chk_age",
|
||||
action: { op: "check", expression: "CHECK (age > 0)" },
|
||||
});
|
||||
});
|
||||
|
||||
it("constraint edit maps UNIQUE contype to a unique action with columns", () => {
|
||||
const p = initialCrudParams(
|
||||
"constraint",
|
||||
{
|
||||
schema: "public",
|
||||
table: "users",
|
||||
name: "uniq_email",
|
||||
contype: "UNIQUE",
|
||||
definition: "UNIQUE (email)",
|
||||
columns: ["email"],
|
||||
},
|
||||
"edit",
|
||||
);
|
||||
expect(p).toMatchObject({
|
||||
name: "uniq_email",
|
||||
action: { op: "unique", columns: ["email"] },
|
||||
});
|
||||
});
|
||||
|
||||
it("extension edit prefills the version from the item", () => {
|
||||
const p = initialCrudParams(
|
||||
"extension",
|
||||
{ schema: "public", name: "pgcrypto", version: "1.3" },
|
||||
"edit",
|
||||
);
|
||||
expect(p).toMatchObject({
|
||||
name: "pgcrypto",
|
||||
action: { op: "create", version: "1.3" },
|
||||
});
|
||||
});
|
||||
|
||||
it("view edit marks materialized when the item is a materialized view", () => {
|
||||
const p = initialCrudParams(
|
||||
"view",
|
||||
{
|
||||
schema: "public",
|
||||
name: "mv",
|
||||
table_type: "MATERIALIZED VIEW",
|
||||
definition: "SELECT 1",
|
||||
},
|
||||
"edit",
|
||||
);
|
||||
expect(p).toMatchObject({
|
||||
name: "mv",
|
||||
materialized: true,
|
||||
action: { op: "create", definition: "SELECT 1" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("dropCrudParams", () => {
|
||||
it("plain kinds build a base drop action", () => {
|
||||
expect(
|
||||
dropCrudParams("sequence", { schema: "public", name: "s" }),
|
||||
).toEqual({ schema: "public", name: "s", action: { op: "drop" } });
|
||||
});
|
||||
|
||||
it("index and constraint carry the owning table", () => {
|
||||
expect(
|
||||
dropCrudParams("index", { schema: "public", name: "i", table: "users" }),
|
||||
).toEqual({
|
||||
schema: "public",
|
||||
name: "i",
|
||||
table: "users",
|
||||
action: { op: "drop" },
|
||||
});
|
||||
});
|
||||
|
||||
it("trigger uses table_name as a fallback for the table", () => {
|
||||
expect(
|
||||
dropCrudParams("trigger", {
|
||||
schema: "public",
|
||||
name: "trg",
|
||||
table_name: "users",
|
||||
}),
|
||||
).toEqual({
|
||||
schema: "public",
|
||||
name: "trg",
|
||||
action: { op: "drop", table: "users" },
|
||||
});
|
||||
});
|
||||
|
||||
it("view marks materialized via table_type", () => {
|
||||
expect(
|
||||
dropCrudParams("view", { schema: "public", name: "m", table_type: "m" }),
|
||||
).toMatchObject({ materialized: true, action: { op: "drop" } });
|
||||
});
|
||||
|
||||
it("function carries argument types and is_procedure false", () => {
|
||||
expect(
|
||||
dropCrudParams("function", {
|
||||
schema: "public",
|
||||
name: "f",
|
||||
argument_types: ["int"],
|
||||
}),
|
||||
).toMatchObject({
|
||||
is_procedure: false,
|
||||
action: { op: "drop", arg_types: ["int"] },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,261 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
// NOTE on argument key naming: Tauri v2 converts Rust snake_case command
|
||||
// parameter names to camelCase on the IPC boundary (connection_id ->
|
||||
// connectionId). Single-word params (kind, params) are unchanged.
|
||||
|
||||
/// The object kinds supported by the Rust build_ddl dispatcher.
|
||||
export type ObjectKind =
|
||||
| "sequence"
|
||||
| "enum"
|
||||
| "view"
|
||||
| "extension"
|
||||
| "index"
|
||||
| "constraint"
|
||||
| "function"
|
||||
| "procedure"
|
||||
| "trigger";
|
||||
|
||||
/// Opaque payload for a build: `{ schema, name, action: { op, ... } }`.
|
||||
/// The concrete shape is validated server-side by each kind's params struct.
|
||||
export type DdlParams = Record<string, unknown>;
|
||||
|
||||
/// A browsable object row — the per-kind detail fields the CRUD helpers read.
|
||||
/// All fields beyond `schema`/`name` are optional per object kind.
|
||||
/// (The extra fields mirror the real per-kind shapes in `src/lib/types.ts`
|
||||
/// and are consumed by `initialCrudParams` on edit.)
|
||||
export interface CrudItem {
|
||||
schema: string;
|
||||
name: string;
|
||||
table?: string;
|
||||
table_name?: string;
|
||||
table_type?: string;
|
||||
materialized?: boolean;
|
||||
labels?: string[];
|
||||
definition?: string;
|
||||
argument_types?: string[];
|
||||
// function / procedure (FunctionInfo)
|
||||
return_type?: string | null;
|
||||
argument_names?: string[];
|
||||
argument_modes?: string[];
|
||||
language?: string;
|
||||
source?: string | null;
|
||||
kind?: string;
|
||||
// sequence (SequenceInfo)
|
||||
start_value?: string;
|
||||
min_value?: string;
|
||||
max_value?: string;
|
||||
increment?: string;
|
||||
cycle?: boolean;
|
||||
// trigger (TriggerInfo)
|
||||
event_manipulation?: string;
|
||||
action_timing?: string;
|
||||
action_orientation?: string;
|
||||
enabled?: string;
|
||||
// extension (ExtensionInfo)
|
||||
version?: string | null;
|
||||
// index (IndexInfo)
|
||||
is_unique?: boolean;
|
||||
method?: string;
|
||||
columns?: string[];
|
||||
// constraint (ConstraintInfo)
|
||||
contype?: "CHECK" | "UNIQUE" | "EXCLUSION";
|
||||
}
|
||||
|
||||
/**
|
||||
* Initial dialog params for a create/edit operation.
|
||||
* Create starts empty (schema prefilled); edit prefills from the item.
|
||||
*/
|
||||
export function initialCrudParams(
|
||||
kind: ObjectKind,
|
||||
item: CrudItem,
|
||||
mode: "create" | "edit",
|
||||
): DdlParams {
|
||||
const schema = item.schema;
|
||||
switch (kind) {
|
||||
case "sequence":
|
||||
return {
|
||||
schema,
|
||||
name: mode === "edit" ? item.name : "",
|
||||
action: {
|
||||
op: "create",
|
||||
increment: mode === "edit" ? (item.increment ?? "1") : "1",
|
||||
min_value: mode === "edit" ? (item.min_value ?? "1") : "1",
|
||||
max_value:
|
||||
mode === "edit"
|
||||
? (item.max_value ?? "9223372036854775807")
|
||||
: "9223372036854775807",
|
||||
start: mode === "edit" ? (item.start_value ?? "1") : "1",
|
||||
cycle: mode === "edit" ? (item.cycle ?? false) : false,
|
||||
},
|
||||
};
|
||||
case "enum":
|
||||
return {
|
||||
schema,
|
||||
name: mode === "edit" ? item.name : "",
|
||||
action: {
|
||||
op: "create",
|
||||
labels: mode === "edit" ? (item.labels ?? []) : [],
|
||||
},
|
||||
};
|
||||
case "extension":
|
||||
return {
|
||||
schema,
|
||||
name: mode === "edit" ? item.name : "",
|
||||
action: {
|
||||
op: "create",
|
||||
version: mode === "edit" ? (item.version ?? null) : null,
|
||||
},
|
||||
};
|
||||
case "view":
|
||||
return {
|
||||
schema,
|
||||
name: mode === "edit" ? item.name : "",
|
||||
materialized:
|
||||
mode === "edit"
|
||||
? item.table_type === "MATERIALIZED VIEW" || item.materialized === true
|
||||
: false,
|
||||
action: {
|
||||
op: "create",
|
||||
definition: mode === "edit" ? (item.definition ?? "") : "",
|
||||
},
|
||||
};
|
||||
case "index":
|
||||
return {
|
||||
schema,
|
||||
table: item.table ?? "",
|
||||
name: mode === "edit" ? item.name : "",
|
||||
action: {
|
||||
op: "create",
|
||||
unique: mode === "edit" ? (item.is_unique ?? false) : false,
|
||||
method: mode === "edit" ? (item.method ?? "") : "",
|
||||
columns: mode === "edit" ? (item.columns ?? []) : [],
|
||||
predicate: null,
|
||||
},
|
||||
};
|
||||
case "constraint":
|
||||
return {
|
||||
schema,
|
||||
table: item.table ?? "",
|
||||
name: mode === "edit" ? item.name : "",
|
||||
action:
|
||||
mode === "edit"
|
||||
? item.contype === "UNIQUE"
|
||||
? { op: "unique", columns: item.columns ?? [] }
|
||||
: { op: "check", expression: item.definition ?? "" }
|
||||
: { op: "check", expression: "" },
|
||||
};
|
||||
case "function":
|
||||
case "procedure":
|
||||
return {
|
||||
schema,
|
||||
name: mode === "edit" ? item.name : "",
|
||||
is_procedure: kind === "procedure",
|
||||
action: {
|
||||
op: "create_or_replace",
|
||||
args:
|
||||
mode === "edit"
|
||||
? (item.argument_names ?? []).map((n, i) => ({
|
||||
mode: item.argument_modes?.[i] ?? "in",
|
||||
name: n,
|
||||
type: item.argument_types?.[i] ?? "",
|
||||
}))
|
||||
: [],
|
||||
return_type: mode === "edit" ? (item.return_type ?? null) : null,
|
||||
language: mode === "edit" ? (item.language ?? "plpgsql") : "plpgsql",
|
||||
body: mode === "edit" ? (item.source ?? "") : "",
|
||||
volatility: null,
|
||||
strict: false,
|
||||
},
|
||||
};
|
||||
case "trigger":
|
||||
return {
|
||||
schema,
|
||||
name: mode === "edit" ? item.name : "",
|
||||
action: {
|
||||
op: "create",
|
||||
table: item.table_name ?? "",
|
||||
timing:
|
||||
mode === "edit" ? (item.action_timing ?? "BEFORE") : "BEFORE",
|
||||
events:
|
||||
mode === "edit"
|
||||
? (item.event_manipulation ?? "INSERT")
|
||||
.split(/[,|]|\s+OR\s+/i)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
: ["INSERT"],
|
||||
orientation:
|
||||
mode === "edit"
|
||||
? (item.action_orientation ?? "ROW").toUpperCase()
|
||||
: "ROW",
|
||||
function_schema: schema,
|
||||
function_name: "",
|
||||
function_args: [],
|
||||
when: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal params that drop the object, carrying any per-kind context
|
||||
* (owning table, argument types, materialized flag, …).
|
||||
*/
|
||||
export function dropCrudParams(kind: ObjectKind, item: CrudItem): DdlParams {
|
||||
const base = { schema: item.schema, name: item.name };
|
||||
if (kind === "trigger") {
|
||||
return {
|
||||
...base,
|
||||
action: { op: "drop", table: item.table ?? item.table_name ?? "" },
|
||||
};
|
||||
}
|
||||
if (kind === "index" || kind === "constraint") {
|
||||
return {
|
||||
...base,
|
||||
schema: item.schema,
|
||||
table: item.table ?? "",
|
||||
name: item.name,
|
||||
action: { op: "drop" },
|
||||
};
|
||||
}
|
||||
if (kind === "view") {
|
||||
return {
|
||||
...base,
|
||||
materialized: item.table_type === "m" || item.materialized === true,
|
||||
action: { op: "drop" },
|
||||
};
|
||||
}
|
||||
if (kind === "function" || kind === "procedure") {
|
||||
return {
|
||||
...base,
|
||||
is_procedure: kind === "procedure",
|
||||
action: { op: "drop", arg_types: item.argument_types ?? [] },
|
||||
};
|
||||
}
|
||||
return { ...base, action: { op: "drop" } };
|
||||
}
|
||||
|
||||
/// An installable extension from pg_available_extensions.
|
||||
export interface AvailableExtension {
|
||||
name: string;
|
||||
version: string;
|
||||
comment: string | null;
|
||||
}
|
||||
|
||||
/** Build one or more SQL statements for an object CRUD operation. */
|
||||
export async function buildObjectDdl(
|
||||
connectionId: string,
|
||||
kind: ObjectKind,
|
||||
params: DdlParams,
|
||||
): Promise<string[]> {
|
||||
return invoke<string[]>("build_object_ddl", { connectionId, kind, params });
|
||||
}
|
||||
|
||||
/** List extensions available for install on the current server. */
|
||||
export async function getAvailableExtensions(
|
||||
connectionId: string,
|
||||
): Promise<AvailableExtension[]> {
|
||||
return invoke<AvailableExtension[]>("get_available_extensions", {
|
||||
connectionId,
|
||||
});
|
||||
}
|
||||
+4
-1
@@ -46,6 +46,8 @@ export interface Connection {
|
||||
environment?: string | null;
|
||||
// Favorite flag (v0.5.0 — pinned connection)
|
||||
favorite: boolean;
|
||||
// Whether to save the password to the OS keychain (opt-out, default ON)
|
||||
use_keychain?: boolean;
|
||||
}
|
||||
|
||||
export type NewConnectionMode = "simple" | "detailed";
|
||||
@@ -199,7 +201,8 @@ export type ChangeItemType =
|
||||
| "create_index"
|
||||
| "drop_index"
|
||||
| "bulk_insert"
|
||||
| "empty_table";
|
||||
| "empty_table"
|
||||
| "ddl";
|
||||
|
||||
export interface ChangeItem {
|
||||
type: ChangeItemType;
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, it, expect } from "vitest";
|
||||
import pkg from "../../package.json";
|
||||
|
||||
describe("version", () => {
|
||||
it("declares v0.7.5 across the app shell", () => {
|
||||
expect(pkg.version).toBe("0.7.5");
|
||||
it("declares v0.7.6 across the app shell", () => {
|
||||
expect(pkg.version).toBe("0.7.6");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user