Skip to content

Commit e505585

Browse files
authored
feat(XS-53): Object DDL and a deeper schema tree (#65)
The schema tree now continues past columns into indexes, constraints and triggers per table, with functions and procedures closing each schema and views marked apart from tables. Right-clicking any object offers Copy DDL and Open DDL in new tab - SQLite and MySQL/MariaDB return the engine's own text, while PostgreSQL's CREATE TABLE is composed from the catalog because it has no SHOW CREATE TABLE.
1 parent 932062e commit e505585

40 files changed

Lines changed: 3989 additions & 169 deletions

README.md

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,8 @@ Work with **SQLite**, **PostgreSQL** and **MySQL / MariaDB** in a single fast de
6767
- Reach databases behind a bastion over an SSH tunnel
6868
- Stream results - rows arrive as the driver yields them
6969
- Run multi-statement scripts and get a result tab per output
70-
- Explore schemas instantly
70+
- Explore schemas instantly - down to indexes, constraints and triggers
71+
- Copy any object's DDL in one click
7172
- Save and reuse queries
7273
- Export anything in one click
7374
- Auto-update with one click
@@ -214,12 +215,34 @@ gives you `Result 1 · Plan 1` as switchable tabs, each keeping its own state.
214215

215216
## 🗃️ Schema Explorer
216217

217-
- Tree view: schemas → tables → columns
218+
- Tree view: schemas → tables / views → columns, then **indexes**, **constraints** and **triggers**
219+
- Views are called out with their own icon and badge; **functions and procedures** close each schema
218220
- Search tables and columns instantly
219221
- **Double-click** a table → `SELECT` in a new tab
220222
- **Ctrl+double-click** → browse table data in the grid (editable when primary keys exist)
221223
- Refresh schema on demand
222224

225+
Columns stay directly under their table, so nothing moved. Indexes, constraints and triggers sit
226+
below them as collapsed groups and are fetched only when you open one - expanding a table costs
227+
exactly what it did before.
228+
229+
Each group row carries what you actually want at a glance: an index's columns and whether it's
230+
unique, a foreign key's target (`(org_id) → orgs(id)`), a check's expression, a trigger's timing
231+
and events.
232+
233+
### 📋 Copy DDL
234+
235+
Right-click any object - table, view, index, constraint, trigger, function - for **Copy DDL** and
236+
**Open DDL in new tab**. The second opens an ordinary SQL tab, so the statement arrives with
237+
syntax highlighting, search and editing, ready to run or tweak.
238+
239+
- **SQLite** and **MySQL / MariaDB** hand back the engine's own text (`sqlite_master`,
240+
`SHOW CREATE …`), so what you copy is what the server stored
241+
- **PostgreSQL** has no `SHOW CREATE TABLE`, so the statement is composed from the catalog:
242+
columns with their types, defaults, identity and generated expressions, collations, every table
243+
constraint, the indexes no constraint already implies, and `COMMENT ON` for anything documented
244+
- A table's DDL includes its standalone indexes, so pasting it elsewhere rebuilds the table whole
245+
223246
---
224247

225248
## 📊 Results Grid

e2e/pages/schema-page.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { expect, type Locator, type Page } from '@playwright/test';
22

3+
type SchemaObjectGroup = 'indexes' | 'constraints' | 'triggers';
4+
35
/** The sidebar schema browser: refresh, expand schemas/tables and inspect columns. */
46
export class SchemaPage {
57
readonly page: Page;
@@ -89,4 +91,55 @@ export class SchemaPage {
8991
async insertColumn(column: string): Promise<void> {
9092
await this.columnRow(column).click();
9193
}
94+
95+
groupRow(group: SchemaObjectGroup): Locator {
96+
return this.page.getByTestId(`schema-group-${group}`);
97+
}
98+
99+
groupRows(group: SchemaObjectGroup): Locator {
100+
return this.page.getByTestId(`schema-group-${group}-row`);
101+
}
102+
103+
objectRow(group: SchemaObjectGroup, name: string): Locator {
104+
return this.page.locator(`[data-testid="schema-group-${group}-row"][data-object="${name}"]`);
105+
}
106+
107+
async expandGroup(table: string, group: SchemaObjectGroup): Promise<void> {
108+
const header = this.groupRow(group).first();
109+
// expandColumns toggles, so expanding a second group would collapse the table again.
110+
if (!(await header.isVisible().catch(() => false))) {
111+
await this.expandColumns(table);
112+
await header.waitFor({ state: 'visible' });
113+
}
114+
await header.click();
115+
await expect(this.groupRows(group).first().or(this.page.locator('.tree-children .text-muted').first())).toBeVisible(
116+
{ timeout: 30_000 },
117+
);
118+
}
119+
120+
async expandRoutines(): Promise<void> {
121+
const header = this.page.getByTestId('schema-group-routines').first();
122+
await header.waitFor({ state: 'visible' });
123+
await header.click();
124+
}
125+
126+
async copyTableDDL(table: string): Promise<void> {
127+
await this.openTableMenu(table);
128+
await this.page.getByRole('menuitem', { name: 'Copy DDL', exact: true }).click();
129+
}
130+
131+
async openTableDDLInTab(table: string): Promise<void> {
132+
await this.openTableMenu(table);
133+
await this.page.getByRole('menuitem', { name: 'Open DDL in new tab', exact: true }).click();
134+
}
135+
136+
async copyObjectDDL(group: SchemaObjectGroup, name: string): Promise<void> {
137+
await this.objectRow(group, name).click({ button: 'right' });
138+
await this.page.locator('.context-menu').waitFor({ state: 'visible' });
139+
await this.page.getByRole('menuitem', { name: 'Copy DDL', exact: true }).click();
140+
}
141+
142+
async clipboardText(): Promise<string> {
143+
return this.page.evaluate(() => navigator.clipboard.readText());
144+
}
92145
}
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import { POSTGRES } from '@support/databases';
2+
import { expect, test } from '@support/fixtures';
3+
4+
test.use({ permissions: ['clipboard-read', 'clipboard-write'] });
5+
6+
test.describe('Object DDL and the deeper schema tree', () => {
7+
test("lists a table's indexes, constraints and triggers", async ({ connections, editor, schema, seed, app }) => {
8+
await connections.createAndConnect(POSTGRES);
9+
const parent = await seed.table('e2e_ddl_parent');
10+
const child = await seed.table('e2e_ddl_child', {
11+
columns: `(id INTEGER PRIMARY KEY, email VARCHAR(50) NOT NULL UNIQUE, parent_id INTEGER REFERENCES ${parent}(id))`,
12+
});
13+
await editor.run(`CREATE INDEX ${child}_email_idx ON ${child} (email);`);
14+
await app.expectStatementApplied();
15+
await schema.refresh();
16+
17+
await schema.expandGroup(child, 'indexes');
18+
await expect(schema.objectRow('indexes', `${child}_email_idx`)).toBeVisible();
19+
await expect(schema.objectRow('indexes', `${child}_pkey`)).toContainText('PK');
20+
21+
await schema.expandGroup(child, 'constraints');
22+
// Postgres names its constraints, so the row shows the name plus a PK badge and its columns.
23+
const pk = schema.objectRow('constraints', `${child}_pkey`);
24+
await expect(pk).toContainText('PK');
25+
await expect(pk).toContainText('(id)');
26+
const fk = schema.groupRows('constraints').filter({ hasText: 'FK' }).first();
27+
await expect(fk).toContainText(parent);
28+
29+
await schema.expandGroup(child, 'triggers');
30+
await expect(schema.groupRow('triggers').first()).toBeVisible();
31+
await expect(schema.groupRows('triggers')).toHaveCount(0);
32+
});
33+
34+
test('marks a view apart from a table', async ({ connections, editor, schema, seed, app }) => {
35+
await connections.createAndConnect(POSTGRES);
36+
const table = await seed.table('e2e_ddl_v', { insert: `(id, name) VALUES (1, 'Alice')` });
37+
const view = `${table}_view`;
38+
await editor.run(`CREATE VIEW ${view} AS SELECT id, name FROM ${table};`);
39+
await app.expectStatementApplied();
40+
await schema.refresh();
41+
42+
const viewRow = await schema.revealTable(view);
43+
await expect(viewRow).toHaveAttribute('data-object-kind', 'view');
44+
await expect(viewRow).toContainText('VIEW');
45+
await expect(schema.tableRow(table)).toHaveAttribute('data-object-kind', 'table');
46+
});
47+
48+
test("copies a table's DDL to the clipboard", async ({ connections, schema, seed }) => {
49+
await connections.createAndConnect(POSTGRES);
50+
const table = await seed.table('e2e_ddl_copy', {
51+
columns: '(id INTEGER PRIMARY KEY, email VARCHAR(50) NOT NULL)',
52+
});
53+
await schema.refresh();
54+
55+
await schema.copyTableDDL(table);
56+
await expect(async () => {
57+
const ddl = await schema.clipboardText();
58+
expect(ddl).toContain(`CREATE TABLE`);
59+
expect(ddl).toContain(table);
60+
expect(ddl).toContain('email');
61+
expect(ddl).toContain('NOT NULL');
62+
expect(ddl).toContain('PRIMARY KEY');
63+
}).toPass({ timeout: 15_000 });
64+
});
65+
66+
test("opens a table's DDL in a new editor tab", async ({ connections, editor, schema, seed, tabs }) => {
67+
await connections.createAndConnect(POSTGRES);
68+
const table = await seed.table('e2e_ddl_tab');
69+
await schema.refresh();
70+
71+
await schema.openTableDDLInTab(table);
72+
await expect(tabs.activeTitle).toContainText(`DDL: ${table}`);
73+
await expect(editor.active.locator('.view-lines')).toContainText('CREATE TABLE');
74+
await expect(editor.active.locator('.view-lines')).toContainText(table);
75+
});
76+
77+
test("copies an index's own DDL", async ({ connections, editor, schema, seed, app }) => {
78+
await connections.createAndConnect(POSTGRES);
79+
const table = await seed.table('e2e_ddl_idx');
80+
const index = `${table}_name_idx`;
81+
await editor.run(`CREATE INDEX ${index} ON ${table} (name);`);
82+
await app.expectStatementApplied();
83+
await schema.refresh();
84+
85+
await schema.expandGroup(table, 'indexes');
86+
await schema.copyObjectDDL('indexes', index);
87+
await expect(async () => {
88+
const ddl = await schema.clipboardText();
89+
expect(ddl).toContain('CREATE INDEX');
90+
expect(ddl).toContain(index);
91+
}).toPass({ timeout: 15_000 });
92+
});
93+
94+
test("lists schema functions", async ({ connections, editor, schema, app }) => {
95+
await connections.createAndConnect(POSTGRES);
96+
const fn = `e2e_ddl_fn_${Date.now().toString(36)}`;
97+
await editor.run(`CREATE FUNCTION ${fn}(a int) RETURNS int LANGUAGE sql AS $$ SELECT a + 1 $$;`);
98+
await app.expectStatementApplied();
99+
await schema.refresh();
100+
101+
await schema.expandRoutines();
102+
await expect(schema.page.getByTestId('schema-group-routines-row').filter({ hasText: fn })).toBeVisible({
103+
timeout: 30_000,
104+
});
105+
106+
await editor.run(`DROP FUNCTION ${fn}(int);`);
107+
await app.expectStatementApplied();
108+
});
109+
});

frontend/bindings/xensql/internal/app/app.ts

Lines changed: 54 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,13 @@ export function GetEditorSession(): $CancellablePromise<storage$0.EditorSession>
153153
});
154154
}
155155

156+
/**
157+
* GetObjectDDL reads the catalog only, so it stays available on read-only connections.
158+
*/
159+
export function GetObjectDDL(connectionID: string, ref: database$0.ObjectRef): $CancellablePromise<string> {
160+
return $Call.ByID(3474183456, connectionID, ref);
161+
}
162+
156163
export function GetPathDefaults(): $CancellablePromise<$models.PathDefaults> {
157164
return $Call.ByID(230484794).then(($result: any) => {
158165
return $$createType7($result);
@@ -207,27 +214,51 @@ export function ListConnections(): $CancellablePromise<database$0.ConnectionConf
207214
});
208215
}
209216

217+
export function ListConstraints(connectionID: string, schema: string, table: string): $CancellablePromise<database$0.ConstraintInfo[]> {
218+
return $Call.ByID(459667143, connectionID, schema, table).then(($result: any) => {
219+
return $$createType17($result);
220+
});
221+
}
222+
210223
export function ListFolders(): $CancellablePromise<storage$0.ConnectionFolder[]> {
211224
return $Call.ByID(1373072582).then(($result: any) => {
212-
return $$createType17($result);
225+
return $$createType19($result);
226+
});
227+
}
228+
229+
export function ListIndexes(connectionID: string, schema: string, table: string): $CancellablePromise<database$0.IndexInfo[]> {
230+
return $Call.ByID(1696593423, connectionID, schema, table).then(($result: any) => {
231+
return $$createType21($result);
232+
});
233+
}
234+
235+
export function ListRoutines(connectionID: string, schema: string): $CancellablePromise<database$0.RoutineInfo[]> {
236+
return $Call.ByID(1660715140, connectionID, schema).then(($result: any) => {
237+
return $$createType23($result);
213238
});
214239
}
215240

216241
export function ListSavedQueries(connectionID: string): $CancellablePromise<database$0.SavedQuery[]> {
217242
return $Call.ByID(2254370512, connectionID).then(($result: any) => {
218-
return $$createType19($result);
243+
return $$createType25($result);
219244
});
220245
}
221246

222247
export function ListSchemas(connectionID: string): $CancellablePromise<database$0.SchemaInfo[]> {
223248
return $Call.ByID(2969331507, connectionID).then(($result: any) => {
224-
return $$createType21($result);
249+
return $$createType27($result);
225250
});
226251
}
227252

228253
export function ListTables(connectionID: string, schema: string): $CancellablePromise<database$0.TableInfo[]> {
229254
return $Call.ByID(773846824, connectionID, schema).then(($result: any) => {
230-
return $$createType23($result);
255+
return $$createType29($result);
256+
});
257+
}
258+
259+
export function ListTriggers(connectionID: string, schema: string, table: string): $CancellablePromise<database$0.TriggerInfo[]> {
260+
return $Call.ByID(1037368812, connectionID, schema, table).then(($result: any) => {
261+
return $$createType31($result);
231262
});
232263
}
233264

@@ -237,7 +268,7 @@ export function ListTables(connectionID: string, schema: string): $CancellablePr
237268
*/
238269
export function LoadSchemaData(connectionID: string): $CancellablePromise<database$0.SchemaBundle> {
239270
return $Call.ByID(4233994986, connectionID).then(($result: any) => {
240-
return $$createType24($result);
271+
return $$createType32($result);
241272
});
242273
}
243274

@@ -284,13 +315,13 @@ export function SaveEditorSession(session: storage$0.EditorSession): $Cancellabl
284315

285316
export function SaveFolder(f: storage$0.ConnectionFolder): $CancellablePromise<storage$0.ConnectionFolder> {
286317
return $Call.ByID(1026390748, f).then(($result: any) => {
287-
return $$createType16($result);
318+
return $$createType18($result);
288319
});
289320
}
290321

291322
export function SaveSavedQuery(q: database$0.SavedQuery): $CancellablePromise<database$0.SavedQuery> {
292323
return $Call.ByID(1936361457, q).then(($result: any) => {
293-
return $$createType18($result);
324+
return $$createType24($result);
294325
});
295326
}
296327

@@ -326,7 +357,7 @@ export function SetWindowStateFlush(flush: any): $CancellablePromise<void> {
326357

327358
export function SettingsStore(): $CancellablePromise<storage$0.SettingsStore | null> {
328359
return $Call.ByID(2329735545).then(($result: any) => {
329-
return $$createType26($result);
360+
return $$createType34($result);
330361
});
331362
}
332363

@@ -362,14 +393,22 @@ const $$createType12 = database$0.ColumnInfo.createFrom;
362393
const $$createType13 = $Create.Array($$createType12);
363394
const $$createType14 = database$0.ConnectionConfig.createFrom;
364395
const $$createType15 = $Create.Array($$createType14);
365-
const $$createType16 = storage$0.ConnectionFolder.createFrom;
396+
const $$createType16 = database$0.ConstraintInfo.createFrom;
366397
const $$createType17 = $Create.Array($$createType16);
367-
const $$createType18 = database$0.SavedQuery.createFrom;
398+
const $$createType18 = storage$0.ConnectionFolder.createFrom;
368399
const $$createType19 = $Create.Array($$createType18);
369-
const $$createType20 = database$0.SchemaInfo.createFrom;
400+
const $$createType20 = database$0.IndexInfo.createFrom;
370401
const $$createType21 = $Create.Array($$createType20);
371-
const $$createType22 = database$0.TableInfo.createFrom;
402+
const $$createType22 = database$0.RoutineInfo.createFrom;
372403
const $$createType23 = $Create.Array($$createType22);
373-
const $$createType24 = database$0.SchemaBundle.createFrom;
374-
const $$createType25 = storage$0.SettingsStore.createFrom;
375-
const $$createType26 = $Create.Nullable($$createType25);
404+
const $$createType24 = database$0.SavedQuery.createFrom;
405+
const $$createType25 = $Create.Array($$createType24);
406+
const $$createType26 = database$0.SchemaInfo.createFrom;
407+
const $$createType27 = $Create.Array($$createType26);
408+
const $$createType28 = database$0.TableInfo.createFrom;
409+
const $$createType29 = $Create.Array($$createType28);
410+
const $$createType30 = database$0.TriggerInfo.createFrom;
411+
const $$createType31 = $Create.Array($$createType30);
412+
const $$createType32 = database$0.SchemaBundle.createFrom;
413+
const $$createType33 = storage$0.SettingsStore.createFrom;
414+
const $$createType34 = $Create.Nullable($$createType33);

frontend/bindings/xensql/internal/database/index.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,17 @@ export {
55
ColumnInfo,
66
ConnectionConfig,
77
ConnectionStatus,
8+
ConstraintInfo,
89
DriverType,
910
HistoryEntry,
11+
IndexInfo,
12+
ObjectKind,
13+
ObjectRef,
1014
PlanField,
1115
PlanNode,
1216
QueryPlan,
1317
QueryResult,
18+
RoutineInfo,
1419
RowDelete,
1520
RowUpdate,
1621
SSHAuthMethod,
@@ -20,5 +25,6 @@ export {
2025
SchemaInfo,
2126
SchemaTables,
2227
TableDataRequest,
23-
TableInfo
28+
TableInfo,
29+
TriggerInfo
2430
} from "./models.js";

0 commit comments

Comments
 (0)