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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 25 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,8 @@ Work with **SQLite**, **PostgreSQL** and **MySQL / MariaDB** in a single fast de
- Reach databases behind a bastion over an SSH tunnel
- Stream results - rows arrive as the driver yields them
- Run multi-statement scripts and get a result tab per output
- Explore schemas instantly
- Explore schemas instantly - down to indexes, constraints and triggers
- Copy any object's DDL in one click
- Save and reuse queries
- Export anything in one click
- Auto-update with one click
Expand Down Expand Up @@ -214,12 +215,34 @@ gives you `Result 1 Β· Plan 1` as switchable tabs, each keeping its own state.

## πŸ—ƒοΈ Schema Explorer

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

Columns stay directly under their table, so nothing moved. Indexes, constraints and triggers sit
below them as collapsed groups and are fetched only when you open one - expanding a table costs
exactly what it did before.

Each group row carries what you actually want at a glance: an index's columns and whether it's
unique, a foreign key's target (`(org_id) β†’ orgs(id)`), a check's expression, a trigger's timing
and events.

### πŸ“‹ Copy DDL

Right-click any object - table, view, index, constraint, trigger, function - for **Copy DDL** and
**Open DDL in new tab**. The second opens an ordinary SQL tab, so the statement arrives with
syntax highlighting, search and editing, ready to run or tweak.

- **SQLite** and **MySQL / MariaDB** hand back the engine's own text (`sqlite_master`,
`SHOW CREATE …`), so what you copy is what the server stored
- **PostgreSQL** has no `SHOW CREATE TABLE`, so the statement is composed from the catalog:
columns with their types, defaults, identity and generated expressions, collations, every table
constraint, the indexes no constraint already implies, and `COMMENT ON` for anything documented
- A table's DDL includes its standalone indexes, so pasting it elsewhere rebuilds the table whole

---

## πŸ“Š Results Grid
Expand Down
53 changes: 53 additions & 0 deletions e2e/pages/schema-page.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { expect, type Locator, type Page } from '@playwright/test';

type SchemaObjectGroup = 'indexes' | 'constraints' | 'triggers';

/** The sidebar schema browser: refresh, expand schemas/tables and inspect columns. */
export class SchemaPage {
readonly page: Page;
Expand Down Expand Up @@ -89,4 +91,55 @@ export class SchemaPage {
async insertColumn(column: string): Promise<void> {
await this.columnRow(column).click();
}

groupRow(group: SchemaObjectGroup): Locator {
return this.page.getByTestId(`schema-group-${group}`);
}

groupRows(group: SchemaObjectGroup): Locator {
return this.page.getByTestId(`schema-group-${group}-row`);
}

objectRow(group: SchemaObjectGroup, name: string): Locator {
return this.page.locator(`[data-testid="schema-group-${group}-row"][data-object="${name}"]`);
}

async expandGroup(table: string, group: SchemaObjectGroup): Promise<void> {
const header = this.groupRow(group).first();
// expandColumns toggles, so expanding a second group would collapse the table again.
if (!(await header.isVisible().catch(() => false))) {
await this.expandColumns(table);
await header.waitFor({ state: 'visible' });
}
await header.click();
await expect(this.groupRows(group).first().or(this.page.locator('.tree-children .text-muted').first())).toBeVisible(
{ timeout: 30_000 },
);
}

async expandRoutines(): Promise<void> {
const header = this.page.getByTestId('schema-group-routines').first();
await header.waitFor({ state: 'visible' });
await header.click();
}

async copyTableDDL(table: string): Promise<void> {
await this.openTableMenu(table);
await this.page.getByRole('menuitem', { name: 'Copy DDL', exact: true }).click();
}

async openTableDDLInTab(table: string): Promise<void> {
await this.openTableMenu(table);
await this.page.getByRole('menuitem', { name: 'Open DDL in new tab', exact: true }).click();
}

async copyObjectDDL(group: SchemaObjectGroup, name: string): Promise<void> {
await this.objectRow(group, name).click({ button: 'right' });
await this.page.locator('.context-menu').waitFor({ state: 'visible' });
await this.page.getByRole('menuitem', { name: 'Copy DDL', exact: true }).click();
}

async clipboardText(): Promise<string> {
return this.page.evaluate(() => navigator.clipboard.readText());
}
}
109 changes: 109 additions & 0 deletions e2e/specs/sidebar/object-ddl.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { POSTGRES } from '@support/databases';
import { expect, test } from '@support/fixtures';

test.use({ permissions: ['clipboard-read', 'clipboard-write'] });

test.describe('Object DDL and the deeper schema tree', () => {
test("lists a table's indexes, constraints and triggers", async ({ connections, editor, schema, seed, app }) => {
await connections.createAndConnect(POSTGRES);
const parent = await seed.table('e2e_ddl_parent');
const child = await seed.table('e2e_ddl_child', {
columns: `(id INTEGER PRIMARY KEY, email VARCHAR(50) NOT NULL UNIQUE, parent_id INTEGER REFERENCES ${parent}(id))`,
});
await editor.run(`CREATE INDEX ${child}_email_idx ON ${child} (email);`);
await app.expectStatementApplied();
await schema.refresh();

await schema.expandGroup(child, 'indexes');
await expect(schema.objectRow('indexes', `${child}_email_idx`)).toBeVisible();
await expect(schema.objectRow('indexes', `${child}_pkey`)).toContainText('PK');

await schema.expandGroup(child, 'constraints');
// Postgres names its constraints, so the row shows the name plus a PK badge and its columns.
const pk = schema.objectRow('constraints', `${child}_pkey`);
await expect(pk).toContainText('PK');
await expect(pk).toContainText('(id)');
const fk = schema.groupRows('constraints').filter({ hasText: 'FK' }).first();
await expect(fk).toContainText(parent);

await schema.expandGroup(child, 'triggers');
await expect(schema.groupRow('triggers').first()).toBeVisible();
await expect(schema.groupRows('triggers')).toHaveCount(0);
});

test('marks a view apart from a table', async ({ connections, editor, schema, seed, app }) => {
await connections.createAndConnect(POSTGRES);
const table = await seed.table('e2e_ddl_v', { insert: `(id, name) VALUES (1, 'Alice')` });
const view = `${table}_view`;
await editor.run(`CREATE VIEW ${view} AS SELECT id, name FROM ${table};`);
await app.expectStatementApplied();
await schema.refresh();

const viewRow = await schema.revealTable(view);
await expect(viewRow).toHaveAttribute('data-object-kind', 'view');
await expect(viewRow).toContainText('VIEW');
await expect(schema.tableRow(table)).toHaveAttribute('data-object-kind', 'table');
});

test("copies a table's DDL to the clipboard", async ({ connections, schema, seed }) => {
await connections.createAndConnect(POSTGRES);
const table = await seed.table('e2e_ddl_copy', {
columns: '(id INTEGER PRIMARY KEY, email VARCHAR(50) NOT NULL)',
});
await schema.refresh();

await schema.copyTableDDL(table);
await expect(async () => {
const ddl = await schema.clipboardText();
expect(ddl).toContain(`CREATE TABLE`);
expect(ddl).toContain(table);
expect(ddl).toContain('email');
expect(ddl).toContain('NOT NULL');
expect(ddl).toContain('PRIMARY KEY');
}).toPass({ timeout: 15_000 });
});

test("opens a table's DDL in a new editor tab", async ({ connections, editor, schema, seed, tabs }) => {
await connections.createAndConnect(POSTGRES);
const table = await seed.table('e2e_ddl_tab');
await schema.refresh();

await schema.openTableDDLInTab(table);
await expect(tabs.activeTitle).toContainText(`DDL: ${table}`);
await expect(editor.active.locator('.view-lines')).toContainText('CREATE TABLE');
await expect(editor.active.locator('.view-lines')).toContainText(table);
});

test("copies an index's own DDL", async ({ connections, editor, schema, seed, app }) => {
await connections.createAndConnect(POSTGRES);
const table = await seed.table('e2e_ddl_idx');
const index = `${table}_name_idx`;
await editor.run(`CREATE INDEX ${index} ON ${table} (name);`);
await app.expectStatementApplied();
await schema.refresh();

await schema.expandGroup(table, 'indexes');
await schema.copyObjectDDL('indexes', index);
await expect(async () => {
const ddl = await schema.clipboardText();
expect(ddl).toContain('CREATE INDEX');
expect(ddl).toContain(index);
}).toPass({ timeout: 15_000 });
});

test("lists schema functions", async ({ connections, editor, schema, app }) => {
await connections.createAndConnect(POSTGRES);
const fn = `e2e_ddl_fn_${Date.now().toString(36)}`;
await editor.run(`CREATE FUNCTION ${fn}(a int) RETURNS int LANGUAGE sql AS $$ SELECT a + 1 $$;`);
await app.expectStatementApplied();
await schema.refresh();

await schema.expandRoutines();
await expect(schema.page.getByTestId('schema-group-routines-row').filter({ hasText: fn })).toBeVisible({
timeout: 30_000,
});

await editor.run(`DROP FUNCTION ${fn}(int);`);
await app.expectStatementApplied();
});
});
69 changes: 54 additions & 15 deletions frontend/bindings/xensql/internal/app/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,13 @@ export function GetEditorSession(): $CancellablePromise<storage$0.EditorSession>
});
}

/**
* GetObjectDDL reads the catalog only, so it stays available on read-only connections.
*/
export function GetObjectDDL(connectionID: string, ref: database$0.ObjectRef): $CancellablePromise<string> {
return $Call.ByID(3474183456, connectionID, ref);
}

export function GetPathDefaults(): $CancellablePromise<$models.PathDefaults> {
return $Call.ByID(230484794).then(($result: any) => {
return $$createType7($result);
Expand Down Expand Up @@ -207,27 +214,51 @@ export function ListConnections(): $CancellablePromise<database$0.ConnectionConf
});
}

export function ListConstraints(connectionID: string, schema: string, table: string): $CancellablePromise<database$0.ConstraintInfo[]> {
return $Call.ByID(459667143, connectionID, schema, table).then(($result: any) => {
return $$createType17($result);
});
}

export function ListFolders(): $CancellablePromise<storage$0.ConnectionFolder[]> {
return $Call.ByID(1373072582).then(($result: any) => {
return $$createType17($result);
return $$createType19($result);
});
}

export function ListIndexes(connectionID: string, schema: string, table: string): $CancellablePromise<database$0.IndexInfo[]> {
return $Call.ByID(1696593423, connectionID, schema, table).then(($result: any) => {
return $$createType21($result);
});
}

export function ListRoutines(connectionID: string, schema: string): $CancellablePromise<database$0.RoutineInfo[]> {
return $Call.ByID(1660715140, connectionID, schema).then(($result: any) => {
return $$createType23($result);
});
}

export function ListSavedQueries(connectionID: string): $CancellablePromise<database$0.SavedQuery[]> {
return $Call.ByID(2254370512, connectionID).then(($result: any) => {
return $$createType19($result);
return $$createType25($result);
});
}

export function ListSchemas(connectionID: string): $CancellablePromise<database$0.SchemaInfo[]> {
return $Call.ByID(2969331507, connectionID).then(($result: any) => {
return $$createType21($result);
return $$createType27($result);
});
}

export function ListTables(connectionID: string, schema: string): $CancellablePromise<database$0.TableInfo[]> {
return $Call.ByID(773846824, connectionID, schema).then(($result: any) => {
return $$createType23($result);
return $$createType29($result);
});
}

export function ListTriggers(connectionID: string, schema: string, table: string): $CancellablePromise<database$0.TriggerInfo[]> {
return $Call.ByID(1037368812, connectionID, schema, table).then(($result: any) => {
return $$createType31($result);
});
}

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

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

export function SaveFolder(f: storage$0.ConnectionFolder): $CancellablePromise<storage$0.ConnectionFolder> {
return $Call.ByID(1026390748, f).then(($result: any) => {
return $$createType16($result);
return $$createType18($result);
});
}

export function SaveSavedQuery(q: database$0.SavedQuery): $CancellablePromise<database$0.SavedQuery> {
return $Call.ByID(1936361457, q).then(($result: any) => {
return $$createType18($result);
return $$createType24($result);
});
}

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

export function SettingsStore(): $CancellablePromise<storage$0.SettingsStore | null> {
return $Call.ByID(2329735545).then(($result: any) => {
return $$createType26($result);
return $$createType34($result);
});
}

Expand Down Expand Up @@ -362,14 +393,22 @@ const $$createType12 = database$0.ColumnInfo.createFrom;
const $$createType13 = $Create.Array($$createType12);
const $$createType14 = database$0.ConnectionConfig.createFrom;
const $$createType15 = $Create.Array($$createType14);
const $$createType16 = storage$0.ConnectionFolder.createFrom;
const $$createType16 = database$0.ConstraintInfo.createFrom;
const $$createType17 = $Create.Array($$createType16);
const $$createType18 = database$0.SavedQuery.createFrom;
const $$createType18 = storage$0.ConnectionFolder.createFrom;
const $$createType19 = $Create.Array($$createType18);
const $$createType20 = database$0.SchemaInfo.createFrom;
const $$createType20 = database$0.IndexInfo.createFrom;
const $$createType21 = $Create.Array($$createType20);
const $$createType22 = database$0.TableInfo.createFrom;
const $$createType22 = database$0.RoutineInfo.createFrom;
const $$createType23 = $Create.Array($$createType22);
const $$createType24 = database$0.SchemaBundle.createFrom;
const $$createType25 = storage$0.SettingsStore.createFrom;
const $$createType26 = $Create.Nullable($$createType25);
const $$createType24 = database$0.SavedQuery.createFrom;
const $$createType25 = $Create.Array($$createType24);
const $$createType26 = database$0.SchemaInfo.createFrom;
const $$createType27 = $Create.Array($$createType26);
const $$createType28 = database$0.TableInfo.createFrom;
const $$createType29 = $Create.Array($$createType28);
const $$createType30 = database$0.TriggerInfo.createFrom;
const $$createType31 = $Create.Array($$createType30);
const $$createType32 = database$0.SchemaBundle.createFrom;
const $$createType33 = storage$0.SettingsStore.createFrom;
const $$createType34 = $Create.Nullable($$createType33);
8 changes: 7 additions & 1 deletion frontend/bindings/xensql/internal/database/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,17 @@ export {
ColumnInfo,
ConnectionConfig,
ConnectionStatus,
ConstraintInfo,
DriverType,
HistoryEntry,
IndexInfo,
ObjectKind,
ObjectRef,
PlanField,
PlanNode,
QueryPlan,
QueryResult,
RoutineInfo,
RowDelete,
RowUpdate,
SSHAuthMethod,
Expand All @@ -20,5 +25,6 @@ export {
SchemaInfo,
SchemaTables,
TableDataRequest,
TableInfo
TableInfo,
TriggerInfo
} from "./models.js";
Loading
Loading