From 7aabaf58f1b13205a7aa2a0a5d6e314706f5b444 Mon Sep 17 00:00:00 2001 From: Bare7a Date: Thu, 6 Aug 2026 12:24:34 +0300 Subject: [PATCH 1/5] feat(XS-53): Object DDL and a deeper schema tree --- README.md | 27 +- e2e/pages/schema-page.ts | 62 +++ e2e/specs/sidebar/object-ddl.spec.ts | 108 +++++ frontend/bindings/xensql/internal/app/app.ts | 69 ++- .../xensql/internal/database/index.ts | 8 +- .../xensql/internal/database/models.ts | 254 +++++++++- .../sidebar/SchemaObjectGroupNode.tsx | 103 ++++ frontend/src/features/sidebar/SchemaPanel.tsx | 92 +++- .../src/features/sidebar/SchemaTableRow.tsx | 58 ++- .../src/features/sidebar/SchemaTreeNode.tsx | 45 +- .../features/sidebar/hooks/useSchemaTree.ts | 84 +++- .../sidebar/lib/schemaObjects.test.ts | 165 +++++++ .../src/features/sidebar/lib/schemaObjects.ts | 109 +++++ frontend/src/i18n/locales/bg.json | 37 +- frontend/src/i18n/locales/de.json | 37 +- frontend/src/i18n/locales/en.json | 37 +- frontend/src/shared/lib/api.ts | 18 + frontend/src/shared/lib/normalize.test.ts | 49 ++ frontend/src/shared/lib/normalize.ts | 53 ++ frontend/src/styles/tree.css | 57 +++ frontend/src/types/index.ts | 62 +++ internal/app/app_schema.go | 70 +++ internal/app/e2e_object_ddl_test.go | 309 ++++++++++++ internal/database/ddl.go | 155 ++++++ internal/database/ddl_test.go | 253 ++++++++++ internal/database/driver.go | 8 + internal/database/mysql/schema.go | 297 ++++++++++++ internal/database/pool_test.go | 11 + internal/database/postgres/schema.go | 454 ++++++++++++++++++ internal/database/postgres/schema_test.go | 77 +++ internal/database/sqlite/schema.go | 297 ++++++++++++ internal/database/sqlite/schema_test.go | 293 +++++++++++ internal/database/types.go | 85 ++++ 33 files changed, 3787 insertions(+), 56 deletions(-) create mode 100644 e2e/specs/sidebar/object-ddl.spec.ts create mode 100644 frontend/src/features/sidebar/SchemaObjectGroupNode.tsx create mode 100644 frontend/src/features/sidebar/lib/schemaObjects.test.ts create mode 100644 frontend/src/features/sidebar/lib/schemaObjects.ts create mode 100644 internal/app/app_schema.go create mode 100644 internal/app/e2e_object_ddl_test.go create mode 100644 internal/database/ddl.go create mode 100644 internal/database/ddl_test.go create mode 100644 internal/database/mysql/schema.go create mode 100644 internal/database/postgres/schema.go create mode 100644 internal/database/postgres/schema_test.go create mode 100644 internal/database/sqlite/schema.go create mode 100644 internal/database/sqlite/schema_test.go diff --git a/README.md b/README.md index 25bec1e..313e72c 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 diff --git a/e2e/pages/schema-page.ts b/e2e/pages/schema-page.ts index d2f53c4..fa873c5 100644 --- a/e2e/pages/schema-page.ts +++ b/e2e/pages/schema-page.ts @@ -1,5 +1,8 @@ import { expect, type Locator, type Page } from '@playwright/test'; +/** Mirrors the frontend's SchemaObjectGroup. */ +type SchemaObjectGroup = 'indexes' | 'constraints' | 'triggers'; + /** The sidebar schema browser: refresh, expand schemas/tables and inspect columns. */ export class SchemaPage { readonly page: Page; @@ -89,4 +92,63 @@ export class SchemaPage { async insertColumn(column: string): Promise { await this.columnRow(column).click(); } + + // ── Object groups (indexes / constraints / triggers / functions) ─────────── + /** A table's group header row; the table must already be expanded. */ + groupRow(group: SchemaObjectGroup): Locator { + return this.page.getByTestId(`schema-group-${group}`); + } + + /** Present only once the group has been expanded. */ + 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}"]`); + } + + /** Expand a table and one of its groups, waiting for the lazily-loaded rows. */ + async expandGroup(table: string, group: SchemaObjectGroup): Promise { + await this.expandColumns(table); + const header = this.groupRow(group).first(); + await header.waitFor({ state: 'visible' }); + await header.click(); + // An empty group renders a placeholder instead of rows. + await expect(this.groupRows(group).first().or(this.page.locator('.tree-children .text-muted').first())).toBeVisible( + { timeout: 30_000 }, + ); + } + + /** Expand the schema-level Functions group. */ + async expandRoutines(): Promise { + const header = this.page.getByTestId('schema-group-routines').first(); + await header.waitFor({ state: 'visible' }); + await header.click(); + } + + // ── DDL ─────────────────────────────────────────────────────────────────── + /** Table context menu β†’ "Copy DDL". */ + async copyTableDDL(table: string): Promise { + await this.openTableMenu(table); + await this.page.getByRole('menuitem', { name: 'Copy DDL', exact: true }).click(); + } + + /** Table context menu β†’ "Open DDL in new tab". */ + async openTableDDLInTab(table: string): Promise { + await this.openTableMenu(table); + await this.page.getByRole('menuitem', { name: 'Open DDL in new tab', exact: true }).click(); + } + + /** Object-row context menu β†’ "Copy DDL". */ + async copyObjectDDL(group: SchemaObjectGroup, name: string): Promise { + 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(); + } + + /** Reads the clipboard; the caller must have granted clipboard permissions. */ + async clipboardText(): Promise { + return this.page.evaluate(() => navigator.clipboard.readText()); + } } diff --git a/e2e/specs/sidebar/object-ddl.spec.ts b/e2e/specs/sidebar/object-ddl.spec.ts new file mode 100644 index 0000000..dd5b6af --- /dev/null +++ b/e2e/specs/sidebar/object-ddl.spec.ts @@ -0,0 +1,108 @@ +import { POSTGRES } from '@support/databases'; +import { expect, test } from '@support/fixtures'; + +// Clipboard reads need explicit permission; granted once for the whole file. +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'); + await expect(schema.objectRow('constraints', `${child}_pkey`)).toContainText('PRIMARY KEY'); + 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'); + // Proves the composed Postgres statement carries more than column names. + 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(); + }); +}); diff --git a/frontend/bindings/xensql/internal/app/app.ts b/frontend/bindings/xensql/internal/app/app.ts index 7a683e5..b64fdfb 100644 --- a/frontend/bindings/xensql/internal/app/app.ts +++ b/frontend/bindings/xensql/internal/app/app.ts @@ -153,6 +153,13 @@ export function GetEditorSession(): $CancellablePromise }); } +/** + * GetObjectDDL reads the catalog only, so it stays available on read-only connections. + */ +export function GetObjectDDL(connectionID: string, ref: database$0.ObjectRef): $CancellablePromise { + return $Call.ByID(3474183456, connectionID, ref); +} + export function GetPathDefaults(): $CancellablePromise<$models.PathDefaults> { return $Call.ByID(230484794).then(($result: any) => { return $$createType7($result); @@ -207,27 +214,51 @@ export function ListConnections(): $CancellablePromise { + return $Call.ByID(459667143, connectionID, schema, table).then(($result: any) => { + return $$createType17($result); + }); +} + export function ListFolders(): $CancellablePromise { return $Call.ByID(1373072582).then(($result: any) => { - return $$createType17($result); + return $$createType19($result); + }); +} + +export function ListIndexes(connectionID: string, schema: string, table: string): $CancellablePromise { + return $Call.ByID(1696593423, connectionID, schema, table).then(($result: any) => { + return $$createType21($result); + }); +} + +export function ListRoutines(connectionID: string, schema: string): $CancellablePromise { + return $Call.ByID(1660715140, connectionID, schema).then(($result: any) => { + return $$createType23($result); }); } export function ListSavedQueries(connectionID: string): $CancellablePromise { return $Call.ByID(2254370512, connectionID).then(($result: any) => { - return $$createType19($result); + return $$createType25($result); }); } export function ListSchemas(connectionID: string): $CancellablePromise { return $Call.ByID(2969331507, connectionID).then(($result: any) => { - return $$createType21($result); + return $$createType27($result); }); } export function ListTables(connectionID: string, schema: string): $CancellablePromise { 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 { + return $Call.ByID(1037368812, connectionID, schema, table).then(($result: any) => { + return $$createType31($result); }); } @@ -237,7 +268,7 @@ export function ListTables(connectionID: string, schema: string): $CancellablePr */ export function LoadSchemaData(connectionID: string): $CancellablePromise { return $Call.ByID(4233994986, connectionID).then(($result: any) => { - return $$createType24($result); + return $$createType32($result); }); } @@ -284,13 +315,13 @@ export function SaveEditorSession(session: storage$0.EditorSession): $Cancellabl export function SaveFolder(f: storage$0.ConnectionFolder): $CancellablePromise { return $Call.ByID(1026390748, f).then(($result: any) => { - return $$createType16($result); + return $$createType18($result); }); } export function SaveSavedQuery(q: database$0.SavedQuery): $CancellablePromise { return $Call.ByID(1936361457, q).then(($result: any) => { - return $$createType18($result); + return $$createType24($result); }); } @@ -326,7 +357,7 @@ export function SetWindowStateFlush(flush: any): $CancellablePromise { export function SettingsStore(): $CancellablePromise { return $Call.ByID(2329735545).then(($result: any) => { - return $$createType26($result); + return $$createType34($result); }); } @@ -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); diff --git a/frontend/bindings/xensql/internal/database/index.ts b/frontend/bindings/xensql/internal/database/index.ts index 27f0055..c4d58c1 100644 --- a/frontend/bindings/xensql/internal/database/index.ts +++ b/frontend/bindings/xensql/internal/database/index.ts @@ -5,12 +5,17 @@ export { ColumnInfo, ConnectionConfig, ConnectionStatus, + ConstraintInfo, DriverType, HistoryEntry, + IndexInfo, + ObjectKind, + ObjectRef, PlanField, PlanNode, QueryPlan, QueryResult, + RoutineInfo, RowDelete, RowUpdate, SSHAuthMethod, @@ -20,5 +25,6 @@ export { SchemaInfo, SchemaTables, TableDataRequest, - TableInfo + TableInfo, + TriggerInfo } from "./models.js"; diff --git a/frontend/bindings/xensql/internal/database/models.ts b/frontend/bindings/xensql/internal/database/models.ts index ed6dec8..01edea6 100644 --- a/frontend/bindings/xensql/internal/database/models.ts +++ b/frontend/bindings/xensql/internal/database/models.ts @@ -144,6 +144,62 @@ export class ConnectionStatus { } } +export class ConstraintInfo { + "name": string; + "schema": string; + "table": string; + + /** + * Type is one of PRIMARY KEY, FOREIGN KEY, UNIQUE, CHECK. + */ + "type": string; + "columns": string[]; + "refTable"?: string; + "refColumns"?: string[]; + + /** + * Definition is the engine's own rendering of the body, when it exposes one. + */ + "definition"?: string; + + /** Creates a new ConstraintInfo instance. */ + constructor($$source: Partial = {}) { + if (!("name" in $$source)) { + this["name"] = ""; + } + if (!("schema" in $$source)) { + this["schema"] = ""; + } + if (!("table" in $$source)) { + this["table"] = ""; + } + if (!("type" in $$source)) { + this["type"] = ""; + } + if (!("columns" in $$source)) { + this["columns"] = []; + } + + Object.assign(this, $$source); + } + + /** + * Creates a new ConstraintInfo instance from a string or object. + */ + static createFrom($$source: any = {}): ConstraintInfo { + const $$createField4_0 = $$createType1; + const $$createField6_0 = $$createType1; + let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; + if ("columns" in $$parsedSource) { + $$parsedSource["columns"] = $$createField4_0($$parsedSource["columns"]); + } + if ("refColumns" in $$parsedSource) { + $$parsedSource["refColumns"] = $$createField6_0($$parsedSource["refColumns"]); + } + return new ConstraintInfo($$parsedSource as Partial); + } +} + export enum DriverType { /** * The Go zero value for the underlying type of the enum. @@ -201,6 +257,106 @@ export class HistoryEntry { } } +export class IndexInfo { + "name": string; + "schema": string; + "table": string; + "columns": string[]; + + /** + * IsPrimary marks the index backing a primary key, which has no standalone DDL. + */ + "isPrimary": boolean; + "isUnique": boolean; + "method"?: string; + + /** Creates a new IndexInfo instance. */ + constructor($$source: Partial = {}) { + if (!("name" in $$source)) { + this["name"] = ""; + } + if (!("schema" in $$source)) { + this["schema"] = ""; + } + if (!("table" in $$source)) { + this["table"] = ""; + } + if (!("columns" in $$source)) { + this["columns"] = []; + } + if (!("isPrimary" in $$source)) { + this["isPrimary"] = false; + } + if (!("isUnique" in $$source)) { + this["isUnique"] = false; + } + + Object.assign(this, $$source); + } + + /** + * Creates a new IndexInfo instance from a string or object. + */ + static createFrom($$source: any = {}): IndexInfo { + const $$createField3_0 = $$createType1; + let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; + if ("columns" in $$parsedSource) { + $$parsedSource["columns"] = $$createField3_0($$parsedSource["columns"]); + } + return new IndexInfo($$parsedSource as Partial); + } +} + +export enum ObjectKind { + /** + * The Go zero value for the underlying type of the enum. + */ + $zero = "", + + ObjectTable = "table", + ObjectView = "view", + ObjectMatView = "materialized view", + ObjectIndex = "index", + ObjectConstraint = "constraint", + ObjectTrigger = "trigger", + ObjectFunction = "function", + ObjectProcedure = "procedure", +}; + +/** + * ObjectRef names the parent relation in Table for index / constraint / trigger kinds only. + */ +export class ObjectRef { + "schema": string; + "name": string; + "kind": ObjectKind; + "table"?: string; + "args"?: string; + + /** Creates a new ObjectRef instance. */ + constructor($$source: Partial = {}) { + if (!("schema" in $$source)) { + this["schema"] = ""; + } + if (!("name" in $$source)) { + this["name"] = ""; + } + if (!("kind" in $$source)) { + this["kind"] = ObjectKind.$zero; + } + + Object.assign(this, $$source); + } + + /** + * Creates a new ObjectRef instance from a string or object. + */ + static createFrom($$source: any = {}): ObjectRef { + let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; + return new ObjectRef($$parsedSource as Partial); + } +} + export class PlanField { "key": string; "value": string; @@ -262,8 +418,8 @@ export class PlanNode { * Creates a new PlanNode instance from a string or object. */ static createFrom($$source: any = {}): PlanNode { - const $$createField12_0 = $$createType2; - const $$createField13_0 = $$createType4; + const $$createField12_0 = $$createType3; + const $$createField13_0 = $$createType5; let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; if ("fields" in $$parsedSource) { $$parsedSource["fields"] = $$createField12_0($$parsedSource["fields"]); @@ -319,8 +475,8 @@ export class QueryPlan { * Creates a new QueryPlan instance from a string or object. */ static createFrom($$source: any = {}): QueryPlan { - const $$createField4_0 = $$createType4; - const $$createField9_0 = $$createType5; + const $$createField4_0 = $$createType5; + const $$createField9_0 = $$createType1; let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; if ("nodes" in $$parsedSource) { $$parsedSource["nodes"] = $$createField4_0($$parsedSource["nodes"]); @@ -372,10 +528,10 @@ export class QueryResult { * Creates a new QueryResult instance from a string or object. */ static createFrom($$source: any = {}): QueryResult { - const $$createField0_0 = $$createType5; - const $$createField1_0 = $$createType5; + const $$createField0_0 = $$createType1; + const $$createField1_0 = $$createType1; const $$createField2_0 = $$createType7; - const $$createField7_0 = $$createType5; + const $$createField7_0 = $$createType1; let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; if ("columns" in $$parsedSource) { $$parsedSource["columns"] = $$createField0_0($$parsedSource["columns"]); @@ -393,6 +549,45 @@ export class QueryResult { } } +export class RoutineInfo { + "name": string; + "schema": string; + + /** + * Kind is ObjectFunction or ObjectProcedure. + */ + "kind": ObjectKind; + "returnType"?: string; + + /** + * Args is the argument list without parentheses; it disambiguates overloads. + */ + "args"?: string; + + /** Creates a new RoutineInfo instance. */ + constructor($$source: Partial = {}) { + if (!("name" in $$source)) { + this["name"] = ""; + } + if (!("schema" in $$source)) { + this["schema"] = ""; + } + if (!("kind" in $$source)) { + this["kind"] = ObjectKind.$zero; + } + + Object.assign(this, $$source); + } + + /** + * Creates a new RoutineInfo instance from a string or object. + */ + static createFrom($$source: any = {}): RoutineInfo { + let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; + return new RoutineInfo($$parsedSource as Partial); + } +} + export class RowDelete { "schema": string; "table": string; @@ -707,13 +902,48 @@ export class TableInfo { } } +export class TriggerInfo { + "name": string; + "schema": string; + "table": string; + + /** + * Timing is BEFORE / AFTER / INSTEAD OF; Events is the comma-joined event list. + */ + "timing"?: string; + "events"?: string; + + /** Creates a new TriggerInfo instance. */ + constructor($$source: Partial = {}) { + if (!("name" in $$source)) { + this["name"] = ""; + } + if (!("schema" in $$source)) { + this["schema"] = ""; + } + if (!("table" in $$source)) { + this["table"] = ""; + } + + Object.assign(this, $$source); + } + + /** + * Creates a new TriggerInfo instance from a string or object. + */ + static createFrom($$source: any = {}): TriggerInfo { + let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; + return new TriggerInfo($$parsedSource as Partial); + } +} + // Private type creation functions const $$createType0 = SSHConfig.createFrom; -const $$createType1 = PlanField.createFrom; -const $$createType2 = $Create.Array($$createType1); -const $$createType3 = PlanNode.createFrom; -const $$createType4 = $Create.Array($$createType3); -const $$createType5 = $Create.Array($Create.Any); +const $$createType1 = $Create.Array($Create.Any); +const $$createType2 = PlanField.createFrom; +const $$createType3 = $Create.Array($$createType2); +const $$createType4 = PlanNode.createFrom; +const $$createType5 = $Create.Array($$createType4); const $$createType6 = $Create.Array($Create.Any); const $$createType7 = $Create.Array($$createType6); const $$createType8 = $Create.Map($Create.Any, $Create.Any); diff --git a/frontend/src/features/sidebar/SchemaObjectGroupNode.tsx b/frontend/src/features/sidebar/SchemaObjectGroupNode.tsx new file mode 100644 index 0000000..a80c0bf --- /dev/null +++ b/frontend/src/features/sidebar/SchemaObjectGroupNode.tsx @@ -0,0 +1,103 @@ +import { ChevronDown, ChevronRight, Loader2 } from 'lucide-react'; +import { memo } from 'react'; +import { useTranslation } from 'react-i18next'; +import type { ObjectBadge, SchemaObjectRow } from '@/features/sidebar/lib/schemaObjects'; +import { rowActivateKeyDown } from '@/shared/hooks/useListKeyboardNav'; +import { cx } from '@/shared/lib/cx'; + +// PK/FK reuse the colours the column rows already have. +const BADGE_CLASS: Record = { + pk: 'tree-column-pk', + fk: 'tree-column-fk', + unique: 'tree-object-badge--unique', + check: 'tree-object-badge--check', + index: 'tree-object-badge--index', + function: 'tree-object-badge--routine', + procedure: 'tree-object-badge--routine', +}; + +interface Props { + label: string; + icon: React.ReactNode; + rowIcon: React.ReactNode; + open: boolean; + loading: boolean; + /** Undefined until first expanded. */ + rows: SchemaObjectRow[] | undefined; + emptyLabel: string; + testId: string; + onToggle: () => void; + onRowContextMenu: (e: React.MouseEvent, row: SchemaObjectRow) => void; +} + +export const SchemaObjectGroupNode = memo(function SchemaObjectGroupNode({ + label, + icon, + rowIcon, + open, + loading, + rows, + emptyLabel, + testId, + onToggle, + onRowContextMenu, +}: Props) { + const { t } = useTranslation(); + + return ( +
+
+ {open ? : } + {icon} + {label} + {rows && {rows.length}} +
+ + {open && ( +
+ {loading && ( +
+ {t('sidebar.loadingObjects')} +
+ )} + {!loading && rows?.length === 0 && ( +
{emptyLabel}
+ )} + {!loading && + rows?.map((row) => ( +
{}} + onKeyDown={rowActivateKeyDown} + onContextMenu={(e) => onRowContextMenu(e, row)} + > + {rowIcon} + {row.label} + {row.badge && ( + + {t(`sidebar.badge.${row.badge}`)} + + )} + {row.detail && {row.detail}} +
+ ))} +
+ )} +
+ ); +}); diff --git a/frontend/src/features/sidebar/SchemaPanel.tsx b/frontend/src/features/sidebar/SchemaPanel.tsx index b8780e3..bc0ebed 100644 --- a/frontend/src/features/sidebar/SchemaPanel.tsx +++ b/frontend/src/features/sidebar/SchemaPanel.tsx @@ -3,6 +3,7 @@ import { useCallback, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { buildQualifiedTable } from '@/features/editor/lib/sqlQuoting'; import { tableKey, tableMatchesSearch, useSchemaTree } from '@/features/sidebar/hooks/useSchemaTree'; +import type { SchemaObjectRow } from '@/features/sidebar/lib/schemaObjects'; import { SchemaTreeNode } from '@/features/sidebar/SchemaTreeNode'; import { SidebarFilterBar } from '@/features/sidebar/SidebarFilterBar'; import { ContextMenu } from '@/shared/components/ContextMenu'; @@ -10,7 +11,7 @@ import { useContextMenu } from '@/shared/hooks/useContextMenu'; import { useDebouncedValue } from '@/shared/hooks/useDebouncedValue'; import { useListKeyboardNav } from '@/shared/hooks/useListKeyboardNav'; import { api } from '@/shared/lib/api'; -import { appToast } from '@/shared/lib/appToast'; +import { appToast, toastError } from '@/shared/lib/appToast'; import { cx } from '@/shared/lib/cx'; import { insertSqlIntoEditor } from '@/shared/lib/insertSql'; import { formatError } from '@/shared/lib/normalize'; @@ -21,7 +22,7 @@ import { useSchemas, useStoreActions, } from '@/store/selectors'; -import type { TableInfo } from '@/types'; +import type { ObjectKind, ObjectRef, SchemaObjectGroup, TableInfo } from '@/types'; interface SchemaPanelProps { onOpenQuery: (connId: string, sql?: string, options?: { forceNew?: boolean; title?: string }) => void; @@ -62,6 +63,11 @@ export function SchemaPanel({ onOpenQuery, onBrowseTable, onOpenConnectionTab }: loadSchema, loadTables, toggleTableColumns, + expandedGroups, + loadingGroups, + objectRows, + toggleObjectGroup, + toggleRoutines, } = useSchemaTree({ connId, connConnected, schemaList, schemaSearch }); const copyText = useCallback( @@ -76,8 +82,55 @@ export function SchemaPanel({ onOpenQuery, onBrowseTable, onOpenConnectionTab }: [t], ); + // Null when the lookup fails, with the reason already surfaced; callers just stop. + const fetchDDL = useCallback( + async (ref: ObjectRef): Promise => { + if (!connId) return null; + try { + return await api.getObjectDDL(connId, ref); + } catch (err) { + toastError(err, t('errors.ddlFailed')); + return null; + } + }, + [connId, t], + ); + + const copyDDL = useCallback( + async (ref: ObjectRef) => { + const ddl = await fetchDDL(ref); + if (ddl == null) return; + try { + await api.copyToClipboard(ddl); + appToast.success(t('toast.copiedDDL')); + } catch { + /* clipboard unavailable */ + } + }, + [fetchDDL, t], + ); + + // An ordinary SQL tab, so highlighting, search and editing come free. + const openDDLInTab = useCallback( + async (ref: ObjectRef) => { + if (!connId) return; + const ddl = await fetchDDL(ref); + if (ddl == null) return; + onOpenQuery(connId, ddl, { forceNew: true, title: t('sidebar.ddlTabTitle', { name: ref.name }) }); + }, + [connId, fetchDDL, onOpenQuery, t], + ); + + const ddlMenuItems = useCallback( + (ref: ObjectRef) => [ + { label: t('sidebar.copyDDL'), action: () => void copyDDL(ref) }, + { label: t('sidebar.openDDLInTab'), action: () => void openDDLInTab(ref) }, + ], + [copyDDL, openDDLInTab, t], + ); + const openTableMenu = useCallback( - (e: React.MouseEvent, schemaName: string, table: string) => { + (e: React.MouseEvent, schemaName: string, table: string, kind: ObjectKind) => { if (!connId) return; const cid = connId; const qualified = buildQualifiedTable(schemaDriver, schemaName, table); @@ -100,12 +153,14 @@ export function SchemaPanel({ onOpenQuery, onBrowseTable, onOpenConnectionTab }: }), }, { label: '', action: () => {}, separator: true }, + ...ddlMenuItems({ schema: schemaName, name: table, kind }), + { label: '', action: () => {}, separator: true }, { label: t('sidebar.insertName'), action: () => insertSqlIntoEditor(qualified) }, { label: t('sidebar.copyName'), action: () => void copyText(table) }, { label: t('sidebar.copyQualifiedName'), action: () => void copyText(qualified) }, ]); }, - [connId, schemaDriver, onBrowseTable, onOpenQuery, copyText, openMenu, t], + [connId, schemaDriver, onBrowseTable, onOpenQuery, copyText, ddlMenuItems, openMenu, t], ); const openColumnMenu = useCallback( @@ -118,6 +173,17 @@ export function SchemaPanel({ onOpenQuery, onBrowseTable, onOpenConnectionTab }: [copyText, openMenu, t], ); + const openObjectMenu = useCallback( + (e: React.MouseEvent, row: SchemaObjectRow) => { + openMenu(e, [ + ...ddlMenuItems(row.ref), + { label: '', action: () => {}, separator: true }, + { label: t('sidebar.copyName'), action: () => void copyText(row.ref.name) }, + ]); + }, + [copyText, ddlMenuItems, openMenu, t], + ); + // Stable callbacks so memo(SchemaTableRow) holds across tree re-renders. const handleToggleTable = useCallback( (schemaName: string, table: string) => { @@ -132,6 +198,18 @@ export function SchemaPanel({ onOpenQuery, onBrowseTable, onOpenConnectionTab }: [connId, onBrowseTable], ); const handleColumnClick = useCallback((colName: string) => insertSqlIntoEditor(colName), []); + const handleToggleGroup = useCallback( + (schemaName: string, table: string, group: SchemaObjectGroup) => { + if (connId) void toggleObjectGroup(connId, schemaName, table, group); + }, + [connId, toggleObjectGroup], + ); + const handleToggleRoutines = useCallback( + (schemaName: string) => { + if (connId) void toggleRoutines(connId, schemaName); + }, + [connId, toggleRoutines], + ); const visibleTablesByKey = useMemo | null>(() => { if (!schemaSearch || !connId) return null; @@ -269,6 +347,9 @@ export function SchemaPanel({ onOpenQuery, onBrowseTable, onOpenConnectionTab }: expandedTables={expandedTables} tableColumns={tableColumns} loadingColumns={loadingColumns} + expandedGroups={expandedGroups} + loadingGroups={loadingGroups} + objectRows={objectRows} onToggleSchema={() => { if (!isOpen) void loadTables(connId, sch.name); else setExpandedSchemas((e) => ({ ...e, [key]: false })); @@ -278,6 +359,9 @@ export function SchemaPanel({ onOpenQuery, onBrowseTable, onOpenConnectionTab }: onBrowse={handleBrowseTableRow} onColumnClick={handleColumnClick} onColumnContextMenu={openColumnMenu} + onToggleGroup={handleToggleGroup} + onToggleRoutines={handleToggleRoutines} + onObjectContextMenu={openObjectMenu} /> ); })} diff --git a/frontend/src/features/sidebar/SchemaTableRow.tsx b/frontend/src/features/sidebar/SchemaTableRow.tsx index 0b6b598..abdfc82 100644 --- a/frontend/src/features/sidebar/SchemaTableRow.tsx +++ b/frontend/src/features/sidebar/SchemaTableRow.tsx @@ -1,39 +1,61 @@ -import { ChevronDown, ChevronRight, Columns3, Eye, Loader2, Table2 } from 'lucide-react'; +import { ChevronDown, ChevronRight, Columns3, Eye, Hash, KeyRound, Loader2, Table2, View, Zap } from 'lucide-react'; import { memo, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { columnMatchesSearch } from '@/features/sidebar/hooks/useSchemaTree'; +import { groupKey, isViewKind, type SchemaObjectRow, TABLE_GROUPS } from '@/features/sidebar/lib/schemaObjects'; +import { SchemaObjectGroupNode } from '@/features/sidebar/SchemaObjectGroupNode'; import { rowActivateKeyDown } from '@/shared/hooks/useListKeyboardNav'; import { cx } from '@/shared/lib/cx'; -import type { ColumnInfo, TableInfo } from '@/types'; +import type { ColumnInfo, ObjectKind, SchemaObjectGroup, TableInfo } from '@/types'; + +const GROUP_ICON: Record = { + indexes: , + constraints: , + triggers: , +}; interface SchemaTableRowProps { + connId: string; schemaName: string; table: TableInfo; tableOpen: boolean; cols: ColumnInfo[]; colsLoading: boolean; schemaSearch: string; + // Passed whole and indexed here: they change only on an explicit group toggle, so the + // search pre-warm path that drives most re-renders still benefits from the memo. + expandedGroups: Record; + loadingGroups: Record; + objectRows: Record; // Stable, identity-parameterized callbacks so memo holds across tree re-renders. onToggleTable: (schemaName: string, table: string) => void; - onTableContextMenu: (e: React.MouseEvent, schemaName: string, table: string) => void; + onTableContextMenu: (e: React.MouseEvent, schemaName: string, table: string, kind: ObjectKind) => void; onBrowse: (schemaName: string, table: string) => void; onColumnClick: (colName: string) => void; onColumnContextMenu: (e: React.MouseEvent, colName: string) => void; + onToggleGroup: (schemaName: string, table: string, group: SchemaObjectGroup) => void; + onObjectContextMenu: (e: React.MouseEvent, row: SchemaObjectRow) => void; } // Memoized so toggling/loading one table re-renders only that row, not the whole schema. export const SchemaTableRow = memo(function SchemaTableRow({ + connId, schemaName, table, tableOpen, cols, colsLoading, schemaSearch, + expandedGroups, + loadingGroups, + objectRows, onToggleTable, onTableContextMenu, onBrowse, onColumnClick, onColumnContextMenu, + onToggleGroup, + onObjectContextMenu, }: SchemaTableRowProps) { const { t } = useTranslation(); @@ -51,6 +73,8 @@ export const SchemaTableRow = memo(function SchemaTableRow({ }, [cols, schemaSearch, table.name]); const isTableExpanded = tableOpen || (!!schemaSearch && !tableNameMatches && (columnMatches || colsLoading)); + const kind = (table.type || 'table') as ObjectKind; + const isView = isViewKind(kind); return (
@@ -61,14 +85,16 @@ export const SchemaTableRow = memo(function SchemaTableRow({ data-nav-item data-testid="schema-table" data-table={table.name} + data-object-kind={kind} data-tooltip={t('tooltip.schemaTableRow')} onClick={() => onToggleTable(schemaName, table.name)} onKeyDown={rowActivateKeyDown} - onContextMenu={(e) => onTableContextMenu(e, schemaName, table.name)} + onContextMenu={(e) => onTableContextMenu(e, schemaName, table.name, kind)} > {isTableExpanded ? : } - + {isView ? : } {table.name} + {isView && {t('sidebar.badge.view')}}
))} + + {/* Collapsed until asked for, so the table-and-columns path costs no extra + round-trips. Hidden during a search, which is a column hunt. */} + {!schemaSearch && + TABLE_GROUPS.map((group) => { + const key = groupKey(connId, schemaName, table.name, group); + return ( + onToggleGroup(schemaName, table.name, group)} + onRowContextMenu={onObjectContextMenu} + /> + ); + })} )} diff --git a/frontend/src/features/sidebar/SchemaTreeNode.tsx b/frontend/src/features/sidebar/SchemaTreeNode.tsx index 98368c0..60989a4 100644 --- a/frontend/src/features/sidebar/SchemaTreeNode.tsx +++ b/frontend/src/features/sidebar/SchemaTreeNode.tsx @@ -1,13 +1,17 @@ -import { ChevronDown, ChevronRight, FolderOpen, Loader2 } from 'lucide-react'; +import { ChevronDown, ChevronRight, FolderOpen, FunctionSquare, Loader2 } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { tableKey } from '@/features/sidebar/hooks/useSchemaTree'; +import { routinesKey, type SchemaObjectRow } from '@/features/sidebar/lib/schemaObjects'; +import { SchemaObjectGroupNode } from '@/features/sidebar/SchemaObjectGroupNode'; import { SchemaTableRow } from '@/features/sidebar/SchemaTableRow'; import { rowActivateKeyDown } from '@/shared/hooks/useListKeyboardNav'; -import type { ColumnInfo, SchemaInfo, TableInfo } from '@/types'; +import type { ColumnInfo, ObjectKind, SchemaInfo, SchemaObjectGroup, TableInfo } from '@/types'; // Stable ref so tables without loaded columns keep equal props (a fresh [] would defeat memo). const EMPTY_COLS: ColumnInfo[] = []; +const ROUTINE_ICON = ; + interface SchemaTreeNodeProps { connId: string; sch: SchemaInfo; @@ -19,12 +23,18 @@ interface SchemaTreeNodeProps { expandedTables: Record; tableColumns: Record; loadingColumns: Record; + expandedGroups: Record; + loadingGroups: Record; + objectRows: Record; onToggleSchema: () => void; onToggleTable: (schemaName: string, table: string) => void; - onTableContextMenu: (e: React.MouseEvent, schemaName: string, table: string) => void; + onTableContextMenu: (e: React.MouseEvent, schemaName: string, table: string, kind: ObjectKind) => void; onBrowse: (schemaName: string, table: string) => void; onColumnClick: (colName: string) => void; onColumnContextMenu: (e: React.MouseEvent, colName: string) => void; + onToggleGroup: (schemaName: string, table: string, group: SchemaObjectGroup) => void; + onToggleRoutines: (schemaName: string) => void; + onObjectContextMenu: (e: React.MouseEvent, row: SchemaObjectRow) => void; } export function SchemaTreeNode({ @@ -38,14 +48,21 @@ export function SchemaTreeNode({ expandedTables, tableColumns, loadingColumns, + expandedGroups, + loadingGroups, + objectRows, onToggleSchema, onToggleTable, onTableContextMenu, onBrowse, onColumnClick, onColumnContextMenu, + onToggleGroup, + onToggleRoutines, + onObjectContextMenu, }: SchemaTreeNodeProps) { const { t } = useTranslation(); + const routinesCacheKey = routinesKey(connId, sch.name); return (
@@ -86,20 +103,42 @@ export function SchemaTreeNode({ return ( ); })} + + {/* Hidden during a search, which is a table/column hunt. */} + {!tablesLoading && !schemaSearch && ( + onToggleRoutines(sch.name)} + onRowContextMenu={onObjectContextMenu} + /> + )}
)} diff --git a/frontend/src/features/sidebar/hooks/useSchemaTree.ts b/frontend/src/features/sidebar/hooks/useSchemaTree.ts index 7c5867d..c46c71c 100644 --- a/frontend/src/features/sidebar/hooks/useSchemaTree.ts +++ b/frontend/src/features/sidebar/hooks/useSchemaTree.ts @@ -1,11 +1,20 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { + constraintRows, + groupKey, + indexRows, + routineRows, + routinesKey, + type SchemaObjectRow, + triggerRows, +} from '@/features/sidebar/lib/schemaObjects'; import { api } from '@/shared/lib/api'; import { invalidateColumnCache } from '@/shared/lib/columnCache'; import { formatError } from '@/shared/lib/normalize'; import { readStoredJson, STORAGE_KEYS, writeStoredJson } from '@/shared/lib/storageKeys'; import { useSchemas, useStoreActions, useTablesMap } from '@/store/selectors'; -import type { ColumnInfo, SchemaInfo } from '@/types'; +import type { ColumnInfo, SchemaInfo, SchemaObjectGroup } from '@/types'; export const tableKey = (connectionId: string, schema: string, table: string) => `${connectionId}:${schema}:${table}`; @@ -44,6 +53,10 @@ export function useSchemaTree({ connId, connConnected, schemaList, schemaSearch const [schemaError, setSchemaError] = useState(''); const [loadingColumns, setLoadingColumns] = useState>({}); const [tableColumns, setTableColumns] = useState>({}); + // Not persisted: cheap to refetch, and a stale index list is worse than a round-trip. + const [expandedGroups, setExpandedGroups] = useState>({}); + const [loadingGroups, setLoadingGroups] = useState>({}); + const [objectRows, setObjectRows] = useState>({}); // "Fetched" tracked apart from "has rows": an empty schema ([] tables) is loaded and must not be // re-fetched, else the effects below loop on every `tables` change. @@ -62,10 +75,12 @@ export function useSchemaTree({ connId, connConnected, schemaList, schemaSearch } setLoadingSchema(true); setSchemaError(''); - // Full (re)load: forget fetch markers and the editor's column cache. + // Full (re)load: forget fetch markers, cached groups and the editor's column cache. loadedTablesRef.current.clear(); loadedColumnsRef.current.clear(); setTableColumns({}); + setObjectRows({}); + setExpandedGroups({}); invalidateColumnCache(connectionId); try { @@ -156,6 +171,66 @@ export function useSchemaTree({ connId, connConnected, schemaList, schemaSearch [expandedTables, fetchTableColumns], ); + // Collapsed by default and fetched on first expand. + const toggleObjectGroup = useCallback( + async (connectionId: string, schema: string, table: string, group: SchemaObjectGroup) => { + const key = groupKey(connectionId, schema, table, group); + if (expandedGroups[key]) { + setExpandedGroups((prev) => ({ ...prev, [key]: false })); + return; + } + setExpandedGroups((prev) => ({ ...prev, [key]: true })); + if (objectRows[key] || loadingGroups[key]) return; + + setLoadingGroups((prev) => ({ ...prev, [key]: true })); + try { + await api.connect(connectionId); + setConnected(connectionId, true); + let rows: SchemaObjectRow[]; + if (group === 'indexes') { + rows = indexRows(await api.listIndexes(connectionId, schema, table)); + } else if (group === 'constraints') { + rows = constraintRows(await api.listConstraints(connectionId, schema, table)); + } else { + rows = triggerRows(await api.listTriggers(connectionId, schema, table)); + } + setObjectRows((prev) => ({ ...prev, [key]: rows })); + } catch (err) { + setSchemaError(formatError(err)); + setExpandedGroups((prev) => ({ ...prev, [key]: false })); + } finally { + setLoadingGroups((prev) => ({ ...prev, [key]: false })); + } + }, + [expandedGroups, objectRows, loadingGroups, setConnected], + ); + + const toggleRoutines = useCallback( + async (connectionId: string, schema: string) => { + const key = routinesKey(connectionId, schema); + if (expandedGroups[key]) { + setExpandedGroups((prev) => ({ ...prev, [key]: false })); + return; + } + setExpandedGroups((prev) => ({ ...prev, [key]: true })); + if (objectRows[key] || loadingGroups[key]) return; + + setLoadingGroups((prev) => ({ ...prev, [key]: true })); + try { + await api.connect(connectionId); + setConnected(connectionId, true); + const rows = routineRows(await api.listRoutines(connectionId, schema)); + setObjectRows((prev) => ({ ...prev, [key]: rows })); + } catch (err) { + setSchemaError(formatError(err)); + setExpandedGroups((prev) => ({ ...prev, [key]: false })); + } finally { + setLoadingGroups((prev) => ({ ...prev, [key]: false })); + } + }, + [expandedGroups, objectRows, loadingGroups, setConnected], + ); + // Drop a stale error on connection switch (sync, so it can't wipe loadSchema's later async error). useEffect(() => { setSchemaError(''); @@ -241,5 +316,10 @@ export function useSchemaTree({ connId, connConnected, schemaList, schemaSearch loadSchema, loadTables, toggleTableColumns, + expandedGroups, + loadingGroups, + objectRows, + toggleObjectGroup, + toggleRoutines, }; } diff --git a/frontend/src/features/sidebar/lib/schemaObjects.test.ts b/frontend/src/features/sidebar/lib/schemaObjects.test.ts new file mode 100644 index 0000000..d63ba73 --- /dev/null +++ b/frontend/src/features/sidebar/lib/schemaObjects.test.ts @@ -0,0 +1,165 @@ +import { describe, expect, it } from 'vitest'; +import { + constraintRows, + groupKey, + indexRows, + isViewKind, + routineRows, + routinesKey, + triggerRows, +} from '@/features/sidebar/lib/schemaObjects'; +import type { ConstraintInfo, IndexInfo, RoutineInfo, TriggerInfo } from '@/types'; + +const index = (over: Partial = {}): IndexInfo => ({ + name: 'idx', + schema: 'public', + table: 'users', + columns: ['email'], + isPrimary: false, + isUnique: false, + ...over, +}); + +const constraint = (over: Partial = {}): ConstraintInfo => ({ + name: 'c', + schema: 'public', + table: 'users', + type: 'PRIMARY KEY', + columns: ['id'], + ...over, +}); + +describe('indexRows', () => { + it('renders columns and carries a DDL ref', () => { + const [row] = indexRows([index({ name: 'users_email_idx', columns: ['email', 'org_id'] })]); + expect(row.label).toBe('users_email_idx'); + expect(row.detail).toBe('(email, org_id)'); + expect(row.ref).toEqual({ schema: 'public', name: 'users_email_idx', kind: 'index', table: 'users' }); + }); + + it('badges primary before unique', () => { + expect(indexRows([index({ isPrimary: true, isUnique: true })])[0].badge).toBe('pk'); + expect(indexRows([index({ isUnique: true })])[0].badge).toBe('unique'); + expect(indexRows([index()])[0].badge).toBe('index'); + }); + + it('leaves an expression-only index without a column list', () => { + expect(indexRows([index({ columns: [] })])[0].detail).toBe(''); + }); +}); + +describe('constraintRows', () => { + it('points a foreign key at its target', () => { + const [row] = constraintRows([ + constraint({ + name: 'users_org_fk', + type: 'FOREIGN KEY', + columns: ['org_id'], + refTable: 'orgs', + refColumns: ['id'], + }), + ]); + expect(row.detail).toBe('(org_id) β†’ orgs(id)'); + expect(row.badge).toBe('fk'); + expect(row.ref.kind).toBe('constraint'); + }); + + it('falls back to the local columns when the FK target is unknown', () => { + const [row] = constraintRows([constraint({ type: 'FOREIGN KEY', columns: ['org_id'] })]); + expect(row.detail).toBe('(org_id)'); + }); + + it('shows a check body instead of columns', () => { + const [row] = constraintRows([constraint({ type: 'CHECK', columns: [], definition: 'CHECK ((age > 0))' })]); + expect(row.detail).toBe('CHECK ((age > 0))'); + expect(row.badge).toBe('check'); + }); + + it('renders primary and unique key columns', () => { + expect(constraintRows([constraint({ columns: ['a', 'b'] })])[0].detail).toBe('(a, b)'); + expect(constraintRows([constraint({ type: 'UNIQUE' })])[0].badge).toBe('unique'); + }); + + it('gives an unrecognised constraint type no badge rather than a wrong one', () => { + expect(constraintRows([constraint({ type: 'EXCLUDE' })])[0].badge).toBeUndefined(); + }); + + it('labels and keys SQLite’s unnamed inline constraints apart', () => { + const rows = constraintRows([ + constraint({ name: '', type: 'PRIMARY KEY', columns: ['id'] }), + constraint({ name: '', type: 'FOREIGN KEY', columns: ['org_id'], refTable: 'orgs' }), + ]); + expect(rows.map((r) => r.label)).toEqual(['PRIMARY KEY', 'FOREIGN KEY']); + expect(new Set(rows.map((r) => r.key)).size).toBe(2); + expect(rows[0].ref.name).toBe(''); + }); +}); + +describe('triggerRows', () => { + it('joins timing and events', () => { + const trigger: TriggerInfo = { + name: 'users_audit', + schema: 'public', + table: 'users', + timing: 'AFTER', + events: 'INSERT, UPDATE', + }; + const [row] = triggerRows([trigger]); + expect(row.detail).toBe('AFTER INSERT, UPDATE'); + expect(row.ref).toEqual({ schema: 'public', name: 'users_audit', kind: 'trigger', table: 'users' }); + }); + + it('omits a missing timing without leaving a stray space', () => { + const [row] = triggerRows([{ name: 't', schema: 'main', table: 'users', events: 'UPDATE' }]); + expect(row.detail).toBe('UPDATE'); + }); +}); + +describe('routineRows', () => { + const routine = (over: Partial = {}): RoutineInfo => ({ + name: 'add', + schema: 'public', + kind: 'function', + ...over, + }); + + it('shows the signature and return type', () => { + const [row] = routineRows([routine({ args: 'a integer, b integer', returnType: 'integer' })]); + expect(row.label).toBe('add(a integer, b integer)'); + expect(row.detail).toBe('β†’ integer'); + expect(row.badge).toBe('function'); + }); + + it('keys overloads apart by signature so both rows render', () => { + const rows = routineRows([routine({ args: 'a integer' }), routine({ args: 'a text' })]); + expect(rows.map((r) => r.key)).toEqual(['add(a integer)', 'add(a text)']); + expect(rows[0].ref).toEqual({ schema: 'public', name: 'add', kind: 'function', args: 'a integer' }); + }); + + it('renders an argument-less procedure', () => { + const [row] = routineRows([routine({ name: 'cleanup', kind: 'procedure' })]); + expect(row.label).toBe('cleanup()'); + expect(row.badge).toBe('procedure'); + expect(row.detail).toBe(''); + }); +}); + +describe('cache keys', () => { + it('separates groups of the same table', () => { + expect(groupKey('c1', 'public', 'users', 'indexes')).toBe('c1:public:users:indexes'); + expect(groupKey('c1', 'public', 'users', 'triggers')).not.toBe(groupKey('c1', 'public', 'users', 'indexes')); + }); + + it('scopes routines to a schema', () => { + expect(routinesKey('c1', 'public')).toBe('c1:public:routines'); + }); +}); + +describe('isViewKind', () => { + it('treats plain and materialized views alike', () => { + expect(isViewKind('view')).toBe(true); + expect(isViewKind('materialized view')).toBe(true); + expect(isViewKind('table')).toBe(false); + expect(isViewKind('index')).toBe(false); + }); +}); diff --git a/frontend/src/features/sidebar/lib/schemaObjects.ts b/frontend/src/features/sidebar/lib/schemaObjects.ts new file mode 100644 index 0000000..32ecb40 --- /dev/null +++ b/frontend/src/features/sidebar/lib/schemaObjects.ts @@ -0,0 +1,109 @@ +import type { + ConstraintInfo, + IndexInfo, + ObjectKind, + ObjectRef, + RoutineInfo, + SchemaObjectGroup, + TriggerInfo, +} from '@/types'; + +export type ObjectBadge = 'pk' | 'fk' | 'unique' | 'check' | 'index' | 'function' | 'procedure'; + +// The four backend shapes flattened so one component renders them all. +export interface SchemaObjectRow { + /** Unique within its group; `name` alone isn't, since SQLite reports inline keys unnamed. */ + key: string; + /** The object's own name, as the DDL lookup expects it. */ + name: string; + label: string; + detail: string; + badge?: ObjectBadge; + ref: ObjectRef; +} + +const cols = (list: string[] | undefined) => (list?.length ? `(${list.join(', ')})` : ''); + +export function indexRows(indexes: IndexInfo[]): SchemaObjectRow[] { + return indexes.map((idx) => ({ + key: idx.name, + name: idx.name, + label: idx.name, + detail: cols(idx.columns), + badge: idx.isPrimary ? 'pk' : idx.isUnique ? 'unique' : 'index', + ref: { schema: idx.schema, name: idx.name, kind: 'index', table: idx.table }, + })); +} + +// An unrecognised type (EXCLUDE) gets no badge rather than a wrong one. +function constraintBadge(type: string): ObjectBadge | undefined { + switch (type.toUpperCase()) { + case 'PRIMARY KEY': + return 'pk'; + case 'FOREIGN KEY': + return 'fk'; + case 'UNIQUE': + return 'unique'; + case 'CHECK': + return 'check'; + default: + return undefined; + } +} + +function constraintDetail(c: ConstraintInfo): string { + if (c.type.toUpperCase() === 'FOREIGN KEY') { + const target = c.refTable ? `${c.refTable}${cols(c.refColumns)}` : ''; + return target ? `${cols(c.columns)} β†’ ${target}` : cols(c.columns); + } + // A CHECK owns no key columns, so its body is all there is to show. + if (c.type.toUpperCase() === 'CHECK') { + return c.definition ?? ''; + } + return cols(c.columns); +} + +export function constraintRows(constraints: ConstraintInfo[]): SchemaObjectRow[] { + return constraints.map((c) => ({ + // Two unnamed constraints of the same type can't cover the same columns, so this is unique. + key: c.name || `${c.type}:${c.columns.join(',')}`, + name: c.name, + // SQLite's inline keys arrive unnamed; the type beats an empty row. + label: c.name || c.type, + detail: constraintDetail(c), + badge: constraintBadge(c.type), + ref: { schema: c.schema, name: c.name, kind: 'constraint', table: c.table }, + })); +} + +export function triggerRows(triggers: TriggerInfo[]): SchemaObjectRow[] { + return triggers.map((tr) => ({ + key: tr.name, + name: tr.name, + label: tr.name, + detail: [tr.timing, tr.events].filter(Boolean).join(' '), + ref: { schema: tr.schema, name: tr.name, kind: 'trigger', table: tr.table }, + })); +} + +export function routineRows(routines: RoutineInfo[]): SchemaObjectRow[] { + return routines.map((r) => ({ + key: r.args ? `${r.name}(${r.args})` : r.name, + name: r.name, + label: `${r.name}(${r.args ?? ''})`, + detail: r.returnType ? `β†’ ${r.returnType}` : '', + badge: r.kind === 'procedure' ? 'procedure' : 'function', + ref: { schema: r.schema, name: r.name, kind: r.kind, args: r.args }, + })); +} + +export const groupKey = (connectionId: string, schema: string, table: string, group: SchemaObjectGroup) => + `${connectionId}:${schema}:${table}:${group}`; + +export const routinesKey = (connectionId: string, schema: string) => `${connectionId}:${schema}:routines`; + +export const TABLE_GROUPS: SchemaObjectGroup[] = ['indexes', 'constraints', 'triggers']; + +export function isViewKind(kind: ObjectKind): boolean { + return kind === 'view' || kind === 'materialized view'; +} diff --git a/frontend/src/i18n/locales/bg.json b/frontend/src/i18n/locales/bg.json index 8981d23..6049f9e 100644 --- a/frontend/src/i18n/locales/bg.json +++ b/frontend/src/i18n/locales/bg.json @@ -97,7 +97,8 @@ "jumpToError": "Към Π³Ρ€Π΅ΡˆΠΊΠ°Ρ‚Π°", "detailLabel": "Π”Π΅Ρ‚Π°ΠΉΠ»", "hintLabel": "Подсказка", - "unknown": "НСизвСстна Π³Ρ€Π΅ΡˆΠΊΠ°" + "unknown": "НСизвСстна Π³Ρ€Π΅ΡˆΠΊΠ°", + "ddlFailed": "DDL Π½Π΅ ΠΌΠΎΠΆΠ° Π΄Π° бъдС ΠΏΡ€ΠΎΡ‡Π΅Ρ‚Π΅Π½" }, "toast": { "dismiss": "Π—Π°Ρ‚Π²ΠΎΡ€ΠΈ", @@ -109,7 +110,8 @@ "exportCopied": "Π•ΠΊΡΠΏΠΎΡ€Ρ‚ΡŠΡ‚ Π΅ ΠΊΠΎΠΏΠΈΡ€Π°Π½ Π² ΠΊΠ»ΠΈΠΏΠ±ΠΎΡ€Π΄Π°", "connectionSuccess": "Π’Ρ€ΡŠΠ·ΠΊΠ°Ρ‚Π° Π΅ ΡƒΡΠΏΠ΅ΡˆΠ½Π°!", "savedQuery": "Заявката Π΅ Π·Π°ΠΏΠ°Π·Π΅Π½Π°", - "exportStopped": "ЕкспортиранСто Π΅ спряно - {{fileName}} ΡΡŠΠ΄ΡŠΡ€ΠΆΠ° само ΠΏΡŠΡ€Π²ΠΈΡ‚Π΅ {{count}} Ρ€Π΅Π΄(Π°)" + "exportStopped": "ЕкспортиранСто Π΅ спряно - {{fileName}} ΡΡŠΠ΄ΡŠΡ€ΠΆΠ° само ΠΏΡŠΡ€Π²ΠΈΡ‚Π΅ {{count}} Ρ€Π΅Π΄(Π°)", + "copiedDDL": "DDL Π΅ ΠΊΠΎΠΏΠΈΡ€Π°Π½ Π² ΠΊΠ»ΠΈΠΏΠ±ΠΎΡ€Π΄Π°" }, "tooltip": { "runStatement": "Изпълни Ρ‚Π°Π·ΠΈ заявка", @@ -162,7 +164,8 @@ "prettify": "Π€ΠΎΡ€ΠΌΠ°Ρ‚ΠΈΡ€Π°ΠΉ JSON, XML ΠΈΠ»ΠΈ HTML", "minify": "ΠšΠΎΠΌΠΏΡ€Π΅ΡΠΈΡ€Π°ΠΉ JSON, XML ΠΈΠ»ΠΈ HTML", "schemaTableRow": "Клик Π·Π° Ρ€Π°Π·Π³ΡŠΠ²Π°Π½Π΅ Β· ДСсСн Π±ΡƒΡ‚ΠΎΠ½ Π·Π° дСйствия", - "schemaColumnRow": "Клик Π·Π° вмъкванС Β· ДСсСн Π±ΡƒΡ‚ΠΎΠ½ Π·Π° дСйствия" + "schemaColumnRow": "Клик Π·Π° вмъкванС Β· ДСсСн Π±ΡƒΡ‚ΠΎΠ½ Π·Π° дСйствия", + "schemaObjectRow": "ДСсСн Π±ΡƒΡ‚ΠΎΠ½ Π·Π° DDL" }, "editor": { "fontSize": "Π¨Ρ€ΠΈΡ„Ρ‚", @@ -381,7 +384,33 @@ "copySql": "ΠšΠΎΠΏΠΈΡ€Π°ΠΉ SQL", "pin": "Π—Π°ΠΊΠ°Ρ‡ΠΈ", "unpin": "ΠžΡ‚ΠΊΠ°Ρ‡ΠΈ", - "connectedLabel": "Π‘Π²ΡŠΡ€Π·Π°Π½" + "connectedLabel": "Π‘Π²ΡŠΡ€Π·Π°Π½", + "loadingObjects": "Π—Π°Ρ€Π΅ΠΆΠ΄Π°Π½Π΅", + "copyDDL": "ΠšΠΎΠΏΠΈΡ€Π°ΠΉ DDL", + "openDDLInTab": "ΠžΡ‚Π²ΠΎΡ€ΠΈ DDL Π² Π½ΠΎΠ² Ρ‚Π°Π±", + "ddlTabTitle": "DDL: {{name}}", + "group": { + "indexes": "ИндСкси", + "constraints": "ΠžΠ³Ρ€Π°Π½ΠΈΡ‡Π΅Π½ΠΈΡ", + "triggers": "Π’Ρ€ΠΈΠ³Π΅Ρ€ΠΈ", + "routines": "Π€ΡƒΠ½ΠΊΡ†ΠΈΠΈ" + }, + "empty": { + "indexes": "Няма индСкси", + "constraints": "Няма ограничСния", + "triggers": "Няма Ρ‚Ρ€ΠΈΠ³Π΅Ρ€ΠΈ", + "routines": "Няма Ρ„ΡƒΠ½ΠΊΡ†ΠΈΠΈ" + }, + "badge": { + "pk": "PK", + "fk": "FK", + "unique": "UQ", + "check": "CK", + "index": "IDX", + "function": "FN", + "procedure": "ПРОЦ", + "view": "VIEW" + } }, "errorBoundary": { "schema": "Π“Ρ€Π΅ΡˆΠΊΠ° Π² ΠΏΡ€Π΅Π³Π»Π΅Π΄Π° Π½Π° схСмата", diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 246c7f9..bae1d2d 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -97,7 +97,8 @@ "jumpToError": "Zur Fehlerstelle springen", "detailLabel": "Detail", "hintLabel": "Hinweis", - "unknown": "Unbekannter Fehler" + "unknown": "Unbekannter Fehler", + "ddlFailed": "DDL konnte nicht gelesen werden" }, "toast": { "dismiss": "Schließen", @@ -109,7 +110,8 @@ "exportCopied": "Export in Zwischenablage kopiert", "connectionSuccess": "Verbindung erfolgreich!", "savedQuery": "Abfrage gespeichert", - "exportStopped": "Export abgebrochen - {{fileName}} enthΓ€lt nur die ersten {{count}} Zeile(n)" + "exportStopped": "Export abgebrochen - {{fileName}} enthΓ€lt nur die ersten {{count}} Zeile(n)", + "copiedDDL": "DDL in die Zwischenablage kopiert" }, "tooltip": { "runStatement": "Diese Anweisung ausfΓΌhren", @@ -162,7 +164,8 @@ "prettify": "JSON, XML oder HTML formatieren", "minify": "JSON, XML oder HTML komprimieren", "schemaTableRow": "Klick zum Aufklappen Β· Rechtsklick fΓΌr Aktionen", - "schemaColumnRow": "Klick zum EinfΓΌgen Β· Rechtsklick fΓΌr Aktionen" + "schemaColumnRow": "Klick zum EinfΓΌgen Β· Rechtsklick fΓΌr Aktionen", + "schemaObjectRow": "Rechtsklick fΓΌr DDL" }, "editor": { "fontSize": "Schriftgrâße", @@ -381,7 +384,33 @@ "copySql": "SQL kopieren", "pin": "Anheften", "unpin": "LΓΆsen", - "connectedLabel": "Verbunden" + "connectedLabel": "Verbunden", + "loadingObjects": "LΓ€dt", + "copyDDL": "DDL kopieren", + "openDDLInTab": "DDL in neuem Tab ΓΆffnen", + "ddlTabTitle": "DDL: {{name}}", + "group": { + "indexes": "Indizes", + "constraints": "Constraints", + "triggers": "Trigger", + "routines": "Funktionen" + }, + "empty": { + "indexes": "Keine Indizes", + "constraints": "Keine Constraints", + "triggers": "Keine Trigger", + "routines": "Keine Funktionen" + }, + "badge": { + "pk": "PK", + "fk": "FK", + "unique": "UQ", + "check": "CK", + "index": "IDX", + "function": "FN", + "procedure": "PROZ", + "view": "VIEW" + } }, "errorBoundary": { "schema": "Fehler im Schema-Browser", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index c5d75bb..0d27e02 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -97,7 +97,8 @@ "jumpToError": "Jump to error", "detailLabel": "Detail", "hintLabel": "Hint", - "unknown": "Unknown error" + "unknown": "Unknown error", + "ddlFailed": "Could not read DDL" }, "toast": { "dismiss": "Dismiss", @@ -109,7 +110,8 @@ "exportCopied": "Export copied to clipboard", "connectionSuccess": "Connection successful!", "savedQuery": "Query saved", - "exportStopped": "Export stopped - {{fileName}} holds only the first {{count}} row(s)" + "exportStopped": "Export stopped - {{fileName}} holds only the first {{count}} row(s)", + "copiedDDL": "DDL copied to clipboard" }, "tooltip": { "runStatement": "Run this statement", @@ -162,7 +164,8 @@ "prettify": "Pretty-print JSON, XML, or HTML", "minify": "Compact JSON, XML, or HTML", "schemaTableRow": "Click to expand Β· Right-click for actions", - "schemaColumnRow": "Click to insert Β· Right-click for actions" + "schemaColumnRow": "Click to insert Β· Right-click for actions", + "schemaObjectRow": "Right-click for DDL" }, "editor": { "fontSize": "Font size", @@ -381,7 +384,33 @@ "copySql": "Copy SQL", "pin": "Pin", "unpin": "Unpin", - "connectedLabel": "Connected" + "connectedLabel": "Connected", + "loadingObjects": "Loading", + "copyDDL": "Copy DDL", + "openDDLInTab": "Open DDL in new tab", + "ddlTabTitle": "DDL: {{name}}", + "group": { + "indexes": "Indexes", + "constraints": "Constraints", + "triggers": "Triggers", + "routines": "Functions" + }, + "empty": { + "indexes": "No indexes", + "constraints": "No constraints", + "triggers": "No triggers", + "routines": "No functions" + }, + "badge": { + "pk": "PK", + "fk": "FK", + "unique": "UQ", + "check": "CK", + "index": "IDX", + "function": "FN", + "procedure": "PROC", + "view": "VIEW" + } }, "errorBoundary": { "schema": "Schema browser error", diff --git a/frontend/src/shared/lib/api.ts b/frontend/src/shared/lib/api.ts index 40156ca..0d253bf 100644 --- a/frontend/src/shared/lib/api.ts +++ b/frontend/src/shared/lib/api.ts @@ -20,6 +20,7 @@ import { GetAppInfo, GetConnectionStatus, GetEditorSession, + GetObjectDDL, GetPathDefaults, GetPendingFile, GetQueryHistory, @@ -27,10 +28,14 @@ import { IsConnected, ListColumns, ListConnections, + ListConstraints, ListFolders, + ListIndexes, + ListRoutines, ListSavedQueries, ListSchemas, ListTables, + ListTriggers, LoadSchemaData, PickExportSavePath, PickKnownHostsFile, @@ -50,11 +55,15 @@ import { import { writeClipboardText } from '@/shared/lib/clipboard'; import { normalizeColumns, + normalizeConstraints, normalizeHistory, + normalizeIndexes, + normalizeRoutines, normalizeSavedQueries, normalizeSchemaBundle, normalizeSchemas, normalizeTables, + normalizeTriggers, toArray, } from '@/shared/lib/normalize'; import type { @@ -62,6 +71,7 @@ import type { ConnectionFolder, EditorTab, HistoryEntry, + ObjectRef, QueryPlan, QueryResult, SavedQuery, @@ -93,6 +103,14 @@ export const api = { listTables: async (connId: string, schema: string) => normalizeTables(await ListTables(connId, schema)), listColumns: async (connId: string, schema: string, table: string) => normalizeColumns(await ListColumns(connId, schema, table)), + listIndexes: async (connId: string, schema: string, table: string) => + normalizeIndexes(await ListIndexes(connId, schema, table)), + listConstraints: async (connId: string, schema: string, table: string) => + normalizeConstraints(await ListConstraints(connId, schema, table)), + listTriggers: async (connId: string, schema: string, table: string) => + normalizeTriggers(await ListTriggers(connId, schema, table)), + listRoutines: async (connId: string, schema: string) => normalizeRoutines(await ListRoutines(connId, schema)), + getObjectDDL: (connId: string, ref: ObjectRef): Promise => cast(GetObjectDDL(connId, ref as never)), executeQueryStream: (connId: string, tabId: string, sql: string): Promise => cast(ExecuteQueryStream(connId, tabId, sql)), explainQuery: (connId: string, tabId: string, sql: string, analyze: boolean): Promise => diff --git a/frontend/src/shared/lib/normalize.test.ts b/frontend/src/shared/lib/normalize.test.ts index 6d2ff4c..b71d16b 100644 --- a/frontend/src/shared/lib/normalize.test.ts +++ b/frontend/src/shared/lib/normalize.test.ts @@ -3,12 +3,16 @@ import { formatError, normalizeColumns, normalizeConnectionStatus, + normalizeConstraints, normalizeHistory, + normalizeIndexes, normalizeQueryResult, + normalizeRoutines, normalizeSavedQueries, normalizeSchemaBundle, normalizeSchemas, normalizeTables, + normalizeTriggers, toArray, uniquifyColumns, } from '@/shared/lib/normalize'; @@ -174,6 +178,51 @@ describe('normalizeQueryResult disambiguates duplicate columns', () => { }); }); +// Go omits empty slices over the Wails boundary, so list fields must survive arriving undefined. +describe('schema object normalizers', () => { + it('normalizeIndexes fills missing fields', () => { + const [idx] = normalizeIndexes([{ name: 'i', schema: 'public', table: 'users', isUnique: true }]); + expect(idx).toEqual({ + name: 'i', + schema: 'public', + table: 'users', + columns: [], + isPrimary: false, + isUnique: true, + method: undefined, + }); + }); + + it('normalizeConstraints keeps optional FK fields undefined when absent', () => { + const [pk] = normalizeConstraints([{ name: 'pk', type: 'PRIMARY KEY', columns: ['id'] }]); + expect(pk.columns).toEqual(['id']); + expect(pk.refTable).toBeUndefined(); + expect(pk.refColumns).toBeUndefined(); + + const [fk] = normalizeConstraints([ + { name: 'fk', type: 'FOREIGN KEY', columns: ['org_id'], refTable: 'orgs', refColumns: ['id'] }, + ]); + expect(fk.refColumns).toEqual(['id']); + }); + + it('normalizeTriggers tolerates a missing timing', () => { + const [tr] = normalizeTriggers([{ name: 't', schema: 'main', table: 'users' }]); + expect(tr).toEqual({ name: 't', schema: 'main', table: 'users', timing: undefined, events: undefined }); + }); + + it('normalizeRoutines defaults an unlabelled routine to a function', () => { + const rows = normalizeRoutines([{ name: 'a' }, { name: 'b', kind: 'procedure' }, { name: 'c', kind: 'junk' }]); + expect(rows.map((r) => r.kind)).toEqual(['function', 'procedure', 'function']); + }); + + it('returns an empty array for null and non-array input', () => { + expect(normalizeIndexes(null)).toEqual([]); + expect(normalizeConstraints(undefined)).toEqual([]); + expect(normalizeTriggers('nope')).toEqual([]); + expect(normalizeRoutines(42)).toEqual([]); + }); +}); + describe('formatError', () => { it('handles strings, Errors and objects with .message', () => { expect(formatError('boom')).toBe('boom'); diff --git a/frontend/src/shared/lib/normalize.ts b/frontend/src/shared/lib/normalize.ts index 187135e..01631eb 100644 --- a/frontend/src/shared/lib/normalize.ts +++ b/frontend/src/shared/lib/normalize.ts @@ -2,12 +2,16 @@ import { t } from '@/i18n'; import type { ColumnInfo, ConnectionStatus, + ConstraintInfo, HistoryEntry, + IndexInfo, QueryResult, + RoutineInfo, SavedQuery, SchemaBundle, SchemaInfo, TableInfo, + TriggerInfo, } from '@/types'; export function toArray(data: unknown): T[] { @@ -51,6 +55,55 @@ export function normalizeColumns(data: unknown): ColumnInfo[] { })); } +function toStringArray(data: unknown): string[] { + return toArray(data).map((v) => String(v ?? '')); +} + +export function normalizeIndexes(data: unknown): IndexInfo[] { + return toArray>(data).map((i) => ({ + name: String(i?.name ?? ''), + schema: String(i?.schema ?? ''), + table: String(i?.table ?? ''), + columns: toStringArray(i?.columns), + isPrimary: Boolean(i?.isPrimary), + isUnique: Boolean(i?.isUnique), + method: i?.method != null ? String(i.method) : undefined, + })); +} + +export function normalizeConstraints(data: unknown): ConstraintInfo[] { + return toArray>(data).map((c) => ({ + name: String(c?.name ?? ''), + schema: String(c?.schema ?? ''), + table: String(c?.table ?? ''), + type: String(c?.type ?? ''), + columns: toStringArray(c?.columns), + refTable: c?.refTable != null ? String(c.refTable) : undefined, + refColumns: c?.refColumns != null ? toStringArray(c.refColumns) : undefined, + definition: c?.definition != null ? String(c.definition) : undefined, + })); +} + +export function normalizeTriggers(data: unknown): TriggerInfo[] { + return toArray>(data).map((tr) => ({ + name: String(tr?.name ?? ''), + schema: String(tr?.schema ?? ''), + table: String(tr?.table ?? ''), + timing: tr?.timing != null ? String(tr.timing) : undefined, + events: tr?.events != null ? String(tr.events) : undefined, + })); +} + +export function normalizeRoutines(data: unknown): RoutineInfo[] { + return toArray>(data).map((r) => ({ + name: String(r?.name ?? ''), + schema: String(r?.schema ?? ''), + kind: r?.kind === 'procedure' ? 'procedure' : 'function', + returnType: r?.returnType != null ? String(r.returnType) : undefined, + args: r?.args != null ? String(r.args) : undefined, + })); +} + // SQL can repeat column names (SELECT a.id, b.id); the grid/JSON viewer/export key by name, so // disambiguate duplicates (id, id_2, …). Cell data is positional, so only the name array changes. export function uniquifyColumns(columns: string[]): string[] { diff --git a/frontend/src/styles/tree.css b/frontend/src/styles/tree.css index 0515c6e..f800f13 100644 --- a/frontend/src/styles/tree.css +++ b/frontend/src/styles/tree.css @@ -145,3 +145,60 @@ .tree-column--clickable { cursor: pointer; } + +/* Object groups sit a step below their relations: same row mechanics, quieter type. */ +.tree-item--group { + font-size: var(--text-sm); + color: var(--text-muted); +} + +.tree-icon--view { + color: var(--accent); +} + +.tree-object { + cursor: default; +} + +/* Sized from its own text, so the name holds its width against the detail. */ +.tree-object .tree-column-name { + flex: 1 1 auto; +} + +/* Yields space ~100x faster than the name, which stays readable. */ +.tree-object-detail { + flex: 0 100 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: var(--text-2xs); + color: var(--text-muted); + font-family: var(--font-mono); +} + +.tree-object-badge { + flex-shrink: 0; + font-size: var(--text-3xs); + font-weight: 600; + letter-spacing: 0.02em; + padding: 0 var(--space-4); + border-radius: var(--radius-sm); + color: var(--text-muted); + background: color-mix(in srgb, var(--text-muted) 12%, transparent); +} + +.tree-object-badge--unique { + color: var(--success); + background: color-mix(in srgb, var(--success) 12%, transparent); +} + +.tree-object-badge--check { + color: var(--warning); + background: color-mix(in srgb, var(--warning) 12%, transparent); +} + +.tree-object-badge--view { + color: var(--accent); + background: color-mix(in srgb, var(--accent) 12%, transparent); +} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index c1ff55b..792db2c 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -63,6 +63,68 @@ export interface SchemaInfo { name: string; } +// See database.ObjectKind. +export type ObjectKind = + | 'table' + | 'view' + | 'materialized view' + | 'index' + | 'constraint' + | 'trigger' + | 'function' + | 'procedure'; + +export interface IndexInfo { + name: string; + schema: string; + table: string; + columns: string[]; + /** The index backing a primary key, which has no standalone DDL. */ + isPrimary: boolean; + isUnique: boolean; + method?: string; +} + +export interface ConstraintInfo { + name: string; + schema: string; + table: string; + /** One of PRIMARY KEY, FOREIGN KEY, UNIQUE, CHECK. */ + type: string; + columns: string[]; + refTable?: string; + refColumns?: string[]; + definition?: string; +} + +export interface TriggerInfo { + name: string; + schema: string; + table: string; + timing?: string; + events?: string; +} + +export interface RoutineInfo { + name: string; + schema: string; + kind: ObjectKind; + returnType?: string; + /** Argument list without parentheses; disambiguates overloads. */ + args?: string; +} + +// See database.ObjectRef. `table` names the parent relation for index/constraint/trigger kinds. +export interface ObjectRef { + schema: string; + name: string; + kind: ObjectKind; + table?: string; + args?: string; +} + +export type SchemaObjectGroup = 'indexes' | 'constraints' | 'triggers'; + export interface SchemaTables { schema: string; tables: TableInfo[]; diff --git a/internal/app/app_schema.go b/internal/app/app_schema.go new file mode 100644 index 0000000..013734a --- /dev/null +++ b/internal/app/app_schema.go @@ -0,0 +1,70 @@ +package app + +import ( + "context" + "fmt" + "time" + + "xensql/internal/database" +) + +// schemaTimeout keeps a saturated pool from wedging the sidebar, which fires these lazily. +const schemaTimeout = 15 * time.Second + +func (a *App) schemaContext() (context.Context, context.CancelFunc) { + return context.WithTimeout(a.ctx, schemaTimeout) +} + +func (a *App) ListIndexes(connectionID, schema, table string) ([]database.IndexInfo, error) { + s, err := a.sessionFor(connectionID) + if err != nil { + return nil, err + } + ctx, cancel := a.schemaContext() + defer cancel() + return s.ListIndexes(ctx, schema, table) +} + +func (a *App) ListConstraints(connectionID, schema, table string) ([]database.ConstraintInfo, error) { + s, err := a.sessionFor(connectionID) + if err != nil { + return nil, err + } + ctx, cancel := a.schemaContext() + defer cancel() + return s.ListConstraints(ctx, schema, table) +} + +func (a *App) ListTriggers(connectionID, schema, table string) ([]database.TriggerInfo, error) { + s, err := a.sessionFor(connectionID) + if err != nil { + return nil, err + } + ctx, cancel := a.schemaContext() + defer cancel() + return s.ListTriggers(ctx, schema, table) +} + +func (a *App) ListRoutines(connectionID, schema string) ([]database.RoutineInfo, error) { + s, err := a.sessionFor(connectionID) + if err != nil { + return nil, err + } + ctx, cancel := a.schemaContext() + defer cancel() + return s.ListRoutines(ctx, schema) +} + +// GetObjectDDL reads the catalog only, so it stays available on read-only connections. +func (a *App) GetObjectDDL(connectionID string, ref database.ObjectRef) (string, error) { + if ref.Name == "" { + return "", fmt.Errorf("object name is required") + } + s, err := a.sessionFor(connectionID) + if err != nil { + return "", err + } + ctx, cancel := a.schemaContext() + defer cancel() + return s.ObjectDDL(ctx, ref) +} diff --git a/internal/app/e2e_object_ddl_test.go b/internal/app/e2e_object_ddl_test.go new file mode 100644 index 0000000..656614a --- /dev/null +++ b/internal/app/e2e_object_ddl_test.go @@ -0,0 +1,309 @@ +//go:build e2e + +package app + +import ( + "fmt" + "strings" + "testing" + + "xensql/internal/database" +) + +// createFunctionSQL returns a trivial function; MySQL needs DETERMINISTIC under binary logging. +func createFunctionSQL(e engine, name string) string { + if e.driver == database.DriverPostgres { + return fmt.Sprintf(`CREATE FUNCTION %s(a int) RETURNS int LANGUAGE sql AS $$ SELECT a + 1 $$`, name) + } + return fmt.Sprintf(`CREATE FUNCTION %s(a INT) RETURNS INT DETERMINISTIC RETURN a + 1`, name) +} + +// createTriggerSQL returns AFTER UPDATE statements; Postgres needs a trigger function first. +func createTriggerSQL(e engine, trigger, table, helperFn string) []string { + target := qualified(e, table) + if e.driver == database.DriverPostgres { + return []string{ + fmt.Sprintf(`CREATE FUNCTION %s() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RETURN NEW; END $$`, helperFn), + fmt.Sprintf(`CREATE TRIGGER %s AFTER UPDATE ON %s FOR EACH ROW EXECUTE FUNCTION %s()`, trigger, target, helperFn), + } + } + return []string{ + fmt.Sprintf(`CREATE TRIGGER %s AFTER UPDATE ON %s FOR EACH ROW SET @xensql_e2e = 1`, trigger, target), + } +} + +func dropQuietly(t *testing.T, a *App, connID, stmt string) { + t.Helper() + t.Cleanup(func() { _, _ = a.ExecuteQuery(connID, stmt) }) +} + +func indexNamed(indexes []database.IndexInfo, name string) (database.IndexInfo, bool) { + for _, idx := range indexes { + if idx.Name == name { + return idx, true + } + } + return database.IndexInfo{}, false +} + +func constraintOfType(constraints []database.ConstraintInfo, ctype string) (database.ConstraintInfo, bool) { + for _, c := range constraints { + if c.Type == ctype { + return c, true + } + } + return database.ConstraintInfo{}, false +} + +// TestE2EObjectDDL covers the catalog listings and GetObjectDDL per object class. The table DDL +// is verified by round-trip: drop it, replay the generated statements, check the shape returns. +func TestE2EObjectDDL(t *testing.T) { + forEachEngine(t, func(t *testing.T, a *App, e engine, connID string) { + parent := uniqueTable("ddl_orgs") + child := uniqueTable("ddl_users") + indexName := child + "_nickname_idx" + + createTempTable(t, a, e, connID, e.autoPKTable(parent), parent) + + notNullText := "VARCHAR(255)" + if e.driver == database.DriverPostgres { + notNullText = "TEXT" + } + childDDL := fmt.Sprintf( + `CREATE TABLE %s (%s, email %s NOT NULL UNIQUE, nickname %s, org_id INT, `+ + `CONSTRAINT %s_org_fk FOREIGN KEY (org_id) REFERENCES %s(id))`, + qualified(e, child), pkColumn(e), notNullText, notNullText, child, qualified(e, parent)) + createTempTable(t, a, e, connID, childDDL, child) + mustExec(t, a, connID, fmt.Sprintf("CREATE INDEX %s ON %s (nickname)", indexName, qualified(e, child))) + + t.Run("ListIndexes", func(t *testing.T) { + indexes, err := a.ListIndexes(connID, e.browseSchema, child) + if err != nil { + t.Fatalf("ListIndexes: %v", err) + } + idx, ok := indexNamed(indexes, indexName) + if !ok { + t.Fatalf("index %q missing from %+v", indexName, indexes) + } + if idx.IsUnique || idx.IsPrimary { + t.Errorf("%q should be a plain index, got %+v", indexName, idx) + } + if len(idx.Columns) != 1 || idx.Columns[0] != "nickname" { + t.Errorf("%q columns = %v, want [nickname]", indexName, idx.Columns) + } + var hasPrimary bool + for _, i := range indexes { + if i.IsPrimary { + hasPrimary = true + } + } + if !hasPrimary { + t.Errorf("expected a primary-key index among %+v", indexes) + } + }) + + t.Run("ListConstraints", func(t *testing.T) { + constraints, err := a.ListConstraints(connID, e.browseSchema, child) + if err != nil { + t.Fatalf("ListConstraints: %v", err) + } + pk, ok := constraintOfType(constraints, "PRIMARY KEY") + if !ok { + t.Fatalf("no PRIMARY KEY among %+v", constraints) + } + if len(pk.Columns) != 1 || pk.Columns[0] != "id" { + t.Errorf("PK columns = %v, want [id]", pk.Columns) + } + if _, ok := constraintOfType(constraints, "UNIQUE"); !ok { + t.Errorf("no UNIQUE among %+v", constraints) + } + fk, ok := constraintOfType(constraints, "FOREIGN KEY") + if !ok { + t.Fatalf("no FOREIGN KEY among %+v", constraints) + } + if fk.RefTable != parent { + t.Errorf("FK ref table = %q, want %q", fk.RefTable, parent) + } + if len(fk.Columns) != 1 || fk.Columns[0] != "org_id" { + t.Errorf("FK columns = %v, want [org_id]", fk.Columns) + } + }) + + t.Run("ListTriggers", func(t *testing.T) { + trigger := uniqueTable("ddl_trg") + helperFn := uniqueTable("ddl_trgfn") + for _, stmt := range createTriggerSQL(e, trigger, child, helperFn) { + mustExec(t, a, connID, stmt) + } + if e.driver == database.DriverPostgres { + dropQuietly(t, a, connID, "DROP FUNCTION IF EXISTS "+helperFn+"() CASCADE") + } + + triggers, err := a.ListTriggers(connID, e.browseSchema, child) + if err != nil { + t.Fatalf("ListTriggers: %v", err) + } + var found *database.TriggerInfo + for i := range triggers { + if triggers[i].Name == trigger { + found = &triggers[i] + } + } + if found == nil { + t.Fatalf("trigger %q missing from %+v", trigger, triggers) + } + if found.Timing != "AFTER" { + t.Errorf("trigger timing = %q, want AFTER", found.Timing) + } + if !strings.Contains(found.Events, "UPDATE") { + t.Errorf("trigger events = %q, want to contain UPDATE", found.Events) + } + + ddl, err := a.GetObjectDDL(connID, database.ObjectRef{ + Kind: database.ObjectTrigger, Schema: e.browseSchema, Name: trigger, Table: child, + }) + if err != nil { + t.Fatalf("trigger DDL: %v", err) + } + if !strings.Contains(strings.ToUpper(ddl), "CREATE TRIGGER") { + t.Errorf("trigger DDL should be a CREATE TRIGGER:\n%s", ddl) + } + }) + + t.Run("ListRoutines", func(t *testing.T) { + fn := uniqueTable("ddl_fn") + mustExec(t, a, connID, createFunctionSQL(e, fn)) + drop := "DROP FUNCTION IF EXISTS " + fn + if e.driver == database.DriverPostgres { + drop += "(int)" + } + dropQuietly(t, a, connID, drop) + + routines, err := a.ListRoutines(connID, e.browseSchema) + if err != nil { + t.Fatalf("ListRoutines: %v", err) + } + var found *database.RoutineInfo + for i := range routines { + if routines[i].Name == fn { + found = &routines[i] + } + } + if found == nil { + t.Fatalf("function %q missing from %d routines", fn, len(routines)) + } + if found.Kind != database.ObjectFunction { + t.Errorf("routine kind = %q, want function", found.Kind) + } + + ddl, err := a.GetObjectDDL(connID, database.ObjectRef{ + Kind: database.ObjectFunction, Schema: e.browseSchema, Name: fn, Args: found.Args, + }) + if err != nil { + t.Fatalf("function DDL: %v", err) + } + if !strings.Contains(strings.ToUpper(ddl), "FUNCTION") { + t.Errorf("function DDL should mention FUNCTION:\n%s", ddl) + } + }) + + t.Run("IndexDDL", func(t *testing.T) { + ddl, err := a.GetObjectDDL(connID, database.ObjectRef{ + Kind: database.ObjectIndex, Schema: e.browseSchema, Name: indexName, Table: child, + }) + if err != nil { + t.Fatalf("index DDL: %v", err) + } + if !strings.Contains(strings.ToUpper(ddl), "CREATE INDEX") { + t.Errorf("index DDL should be a CREATE INDEX:\n%s", ddl) + } + if !strings.Contains(ddl, "nickname") { + t.Errorf("index DDL should name its column:\n%s", ddl) + } + }) + + t.Run("ConstraintDDL", func(t *testing.T) { + ddl, err := a.GetObjectDDL(connID, database.ObjectRef{ + Kind: database.ObjectConstraint, Schema: e.browseSchema, + Name: child + "_org_fk", Table: child, + }) + if err != nil { + t.Fatalf("constraint DDL: %v", err) + } + upper := strings.ToUpper(ddl) + if !strings.Contains(upper, "ALTER TABLE") || !strings.Contains(upper, "FOREIGN KEY") { + t.Errorf("constraint DDL should be an ALTER TABLE ... FOREIGN KEY:\n%s", ddl) + } + }) + + t.Run("ViewDDL", func(t *testing.T) { + view := uniqueTable("ddl_view") + mustExec(t, a, connID, fmt.Sprintf("CREATE VIEW %s AS SELECT id, email FROM %s", + qualified(e, view), qualified(e, child))) + dropQuietly(t, a, connID, "DROP VIEW IF EXISTS "+qualified(e, view)) + + ddl, err := a.GetObjectDDL(connID, database.ObjectRef{ + Kind: database.ObjectView, Schema: e.browseSchema, Name: view, + }) + if err != nil { + t.Fatalf("view DDL: %v", err) + } + if !strings.Contains(strings.ToUpper(ddl), "VIEW") { + t.Errorf("view DDL should mention VIEW:\n%s", ddl) + } + }) + + // Rebuilds the table from the generated DDL alone, so a malformed clause fails here. + t.Run("TableDDLRoundTrips", func(t *testing.T) { + ddl, err := a.GetObjectDDL(connID, database.ObjectRef{ + Kind: database.ObjectTable, Schema: e.browseSchema, Name: child, + }) + if err != nil { + t.Fatalf("table DDL: %v", err) + } + if !strings.Contains(strings.ToUpper(ddl), "CREATE TABLE") { + t.Fatalf("table DDL should be a CREATE TABLE:\n%s", ddl) + } + + before, err := a.ListColumns(connID, e.browseSchema, child) + if err != nil { + t.Fatalf("ListColumns before: %v", err) + } + + mustExec(t, a, connID, "DROP TABLE "+qualified(e, child)) + for _, stmt := range database.SplitStatements(e.driver, ddl) { + if _, err := a.ExecuteQuery(connID, stmt); err != nil { + t.Fatalf("replaying generated DDL failed on %q: %v\nfull DDL:\n%s", stmt, err, ddl) + } + } + + after, err := a.ListColumns(connID, e.browseSchema, child) + if err != nil { + t.Fatalf("ListColumns after: %v", err) + } + if len(before) != len(after) { + t.Fatalf("column count changed: %d before, %d after\nDDL:\n%s", len(before), len(after), ddl) + } + for i := range before { + if before[i].Name != after[i].Name { + t.Errorf("column %d: %q before, %q after", i, before[i].Name, after[i].Name) + } + if before[i].IsPrimary != after[i].IsPrimary { + t.Errorf("column %q primary flag changed: %v -> %v", + before[i].Name, before[i].IsPrimary, after[i].IsPrimary) + } + if before[i].IsNullable != after[i].IsNullable { + t.Errorf("column %q nullable flag changed: %v -> %v", + before[i].Name, before[i].IsNullable, after[i].IsNullable) + } + } + indexes, err := a.ListIndexes(connID, e.browseSchema, child) + if err != nil { + t.Fatalf("ListIndexes after: %v", err) + } + if _, ok := indexNamed(indexes, indexName); !ok { + t.Errorf("index %q was lost in the round trip; DDL:\n%s", indexName, ddl) + } + }) + }) +} diff --git a/internal/database/ddl.go b/internal/database/ddl.go new file mode 100644 index 0000000..e0afac8 --- /dev/null +++ b/internal/database/ddl.go @@ -0,0 +1,155 @@ +package database + +import ( + "fmt" + "strings" +) + +const ddlIndent = " " + +type DDLColumn struct { + Name string + Type string + NotNull bool + Default string + Collation string + // Identity is "ALWAYS" or "BY DEFAULT" on an identity column. + Identity string + // Generated is the expression of a stored generated column, which cannot also have a Default. + Generated string +} + +func RenderColumn(driver DriverType, col DDLColumn) string { + var b strings.Builder + b.WriteString(QuoteIdent(driver, col.Name)) + if col.Type != "" { + b.WriteString(" " + col.Type) + } + if col.Collation != "" { + b.WriteString(" COLLATE " + QuoteIdent(driver, col.Collation)) + } + switch { + case col.Generated != "": + b.WriteString(" GENERATED ALWAYS AS (" + col.Generated + ") STORED") + case col.Identity != "": + b.WriteString(" GENERATED " + col.Identity + " AS IDENTITY") + case col.Default != "": + b.WriteString(" DEFAULT " + col.Default) + } + if col.NotNull { + b.WriteString(" NOT NULL") + } + return b.String() +} + +// ComposeCreateTable takes already-rendered constraint clauses, e.g. `CONSTRAINT "pk" PRIMARY KEY ("id")`. +func ComposeCreateTable(driver DriverType, schema, table string, cols []DDLColumn, tableConstraints []string) string { + lines := make([]string, 0, len(cols)+len(tableConstraints)) + for _, col := range cols { + lines = append(lines, ddlIndent+RenderColumn(driver, col)) + } + for _, c := range tableConstraints { + lines = append(lines, ddlIndent+c) + } + head := "CREATE TABLE " + BuildQualifiedTable(driver, schema, table) + if len(lines) == 0 { + return head + " ();" + } + return head + " (\n" + strings.Join(lines, ",\n") + "\n);" +} + +// RenderConstraint prefers the engine's own Definition; empty when neither form is usable. +func RenderConstraint(driver DriverType, c ConstraintInfo) string { + body := c.Definition + if body == "" { + body = synthesizeConstraintBody(driver, c) + } + if body == "" { + return "" + } + if c.Name == "" { + return body + } + return "CONSTRAINT " + QuoteIdent(driver, c.Name) + " " + body +} + +func synthesizeConstraintBody(driver DriverType, c ConstraintInfo) string { + cols := QuoteIdentList(driver, c.Columns) + switch strings.ToUpper(c.Type) { + case "PRIMARY KEY": + if cols == "" { + return "" + } + return "PRIMARY KEY (" + cols + ")" + case "UNIQUE": + if cols == "" { + return "" + } + return "UNIQUE (" + cols + ")" + case "FOREIGN KEY": + if cols == "" || c.RefTable == "" { + return "" + } + ref := QuoteIdent(driver, c.RefTable) + if len(c.RefColumns) > 0 { + ref += " (" + QuoteIdentList(driver, c.RefColumns) + ")" + } + return "FOREIGN KEY (" + cols + ") REFERENCES " + ref + } + return "" +} + +// RenderCreateIndex is empty for a primary-key index, which has no standalone form. +func RenderCreateIndex(driver DriverType, idx IndexInfo) string { + if idx.IsPrimary || len(idx.Columns) == 0 { + return "" + } + unique := "" + if idx.IsUnique { + unique = "UNIQUE " + } + using := "" + // USING is Postgres-only syntax, though MySQL reports a method too. + if idx.Method != "" && driver == DriverPostgres { + using = " USING " + idx.Method + } + return fmt.Sprintf("CREATE %sINDEX %s ON %s%s (%s);", + unique, + QuoteIdent(driver, idx.Name), + BuildQualifiedTable(driver, idx.Schema, idx.Table), + using, + QuoteIdentList(driver, idx.Columns)) +} + +func QuoteIdentList(driver DriverType, idents []string) string { + if len(idents) == 0 { + return "" + } + quoted := make([]string, len(idents)) + for i, id := range idents { + quoted[i] = QuoteIdent(driver, id) + } + return strings.Join(quoted, ", ") +} + +func JoinDDL(blocks ...string) string { + kept := make([]string, 0, len(blocks)) + for _, b := range blocks { + if trimmed := strings.TrimRight(b, " \t\n"); trimmed != "" { + kept = append(kept, trimmed) + } + } + return strings.Join(kept, "\n\n") +} + +func TerminateStatement(sqlText string) string { + trimmed := strings.TrimRight(sqlText, " \t\r\n") + if trimmed == "" || strings.HasSuffix(trimmed, ";") { + return trimmed + } + return trimmed + ";" +} + +func ErrUnsupportedDDL(driver DriverType, kind ObjectKind) error { + return fmt.Errorf("%s does not expose DDL for %s objects", driver, kind) +} diff --git a/internal/database/ddl_test.go b/internal/database/ddl_test.go new file mode 100644 index 0000000..f61251d --- /dev/null +++ b/internal/database/ddl_test.go @@ -0,0 +1,253 @@ +package database + +import ( + "strings" + "testing" +) + +func TestRenderColumn(t *testing.T) { + tests := []struct { + name string + col DDLColumn + want string + }{ + { + name: "plain nullable", + col: DDLColumn{Name: "email", Type: "text"}, + want: `"email" text`, + }, + { + name: "not null with default", + col: DDLColumn{Name: "created_at", Type: "timestamptz", NotNull: true, Default: "now()"}, + want: `"created_at" timestamptz DEFAULT now() NOT NULL`, + }, + { + name: "identity outranks default", + col: DDLColumn{Name: "id", Type: "bigint", NotNull: true, Identity: "BY DEFAULT", Default: "ignored"}, + want: `"id" bigint GENERATED BY DEFAULT AS IDENTITY NOT NULL`, + }, + { + name: "generated column carries no default", + col: DDLColumn{Name: "total", Type: "numeric", Generated: "qty * price", Default: "ignored"}, + want: `"total" numeric GENERATED ALWAYS AS (qty * price) STORED`, + }, + { + name: "explicit collation", + col: DDLColumn{Name: "name", Type: "text", Collation: "C"}, + want: `"name" text COLLATE "C"`, + }, + { + name: "embedded quote is escaped", + col: DDLColumn{Name: `we"ird`, Type: "text"}, + want: `"we""ird" text`, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := RenderColumn(DriverPostgres, tc.col); got != tc.want { + t.Errorf("RenderColumn()\n got %s\nwant %s", got, tc.want) + } + }) + } +} + +func TestComposeCreateTable(t *testing.T) { + got := ComposeCreateTable(DriverPostgres, "public", "users", + []DDLColumn{ + {Name: "id", Type: "bigint", NotNull: true, Identity: "BY DEFAULT"}, + {Name: "email", Type: "text", NotNull: true}, + }, + []string{`CONSTRAINT "users_pkey" PRIMARY KEY ("id")`}) + + want := `CREATE TABLE "public"."users" ( + "id" bigint GENERATED BY DEFAULT AS IDENTITY NOT NULL, + "email" text NOT NULL, + CONSTRAINT "users_pkey" PRIMARY KEY ("id") +);` + if got != want { + t.Errorf("ComposeCreateTable()\n got %s\nwant %s", got, want) + } +} + +func TestComposeCreateTableMySQLQuoting(t *testing.T) { + got := ComposeCreateTable(DriverMySQL, "shop", "orders", + []DDLColumn{{Name: "id", Type: "int", NotNull: true}}, nil) + if !strings.Contains(got, "`shop`.`orders`") || !strings.Contains(got, "`id` int NOT NULL") { + t.Errorf("MySQL DDL should use backticks, got %s", got) + } +} + +func TestComposeCreateTableEmpty(t *testing.T) { + got := ComposeCreateTable(DriverPostgres, "public", "empty", nil, nil) + if got != `CREATE TABLE "public"."empty" ();` { + t.Errorf("empty table DDL = %s", got) + } +} + +func TestRenderConstraint(t *testing.T) { + tests := []struct { + name string + in ConstraintInfo + want string + }{ + { + name: "engine definition wins over synthesis", + in: ConstraintInfo{Name: "ck_age", Type: "CHECK", Definition: "CHECK ((age > 0))"}, + want: `CONSTRAINT "ck_age" CHECK ((age > 0))`, + }, + { + name: "synthesized primary key", + in: ConstraintInfo{Name: "users_pkey", Type: "PRIMARY KEY", Columns: []string{"id"}}, + want: `CONSTRAINT "users_pkey" PRIMARY KEY ("id")`, + }, + { + name: "synthesized composite unique", + in: ConstraintInfo{Name: "u_ab", Type: "UNIQUE", Columns: []string{"a", "b"}}, + want: `CONSTRAINT "u_ab" UNIQUE ("a", "b")`, + }, + { + name: "synthesized foreign key", + in: ConstraintInfo{ + Name: "fk_org", Type: "FOREIGN KEY", Columns: []string{"org_id"}, + RefTable: "orgs", RefColumns: []string{"id"}, + }, + want: `CONSTRAINT "fk_org" FOREIGN KEY ("org_id") REFERENCES "orgs" ("id")`, + }, + { + name: "unnamed constraint drops the CONSTRAINT prefix", + in: ConstraintInfo{Type: "PRIMARY KEY", Columns: []string{"id"}}, + want: `PRIMARY KEY ("id")`, + }, + { + name: "check without definition is skipped", + in: ConstraintInfo{Name: "ck", Type: "CHECK"}, + want: "", + }, + { + name: "primary key without columns is skipped", + in: ConstraintInfo{Name: "pk", Type: "PRIMARY KEY"}, + want: "", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := RenderConstraint(DriverPostgres, tc.in); got != tc.want { + t.Errorf("RenderConstraint()\n got %q\nwant %q", got, tc.want) + } + }) + } +} + +func TestRenderCreateIndex(t *testing.T) { + tests := []struct { + name string + driver DriverType + in IndexInfo + want string + }{ + { + name: "unique index with method", + driver: DriverPostgres, + in: IndexInfo{ + Name: "users_email_idx", Schema: "public", Table: "users", + Columns: []string{"email"}, IsUnique: true, Method: "btree", + }, + want: `CREATE UNIQUE INDEX "users_email_idx" ON "public"."users" USING btree ("email");`, + }, + { + name: "mysql drops the method", + driver: DriverMySQL, + in: IndexInfo{ + Name: "idx_a", Schema: "shop", Table: "orders", + Columns: []string{"a", "b"}, Method: "btree", + }, + want: "CREATE INDEX `idx_a` ON `shop`.`orders` (`a`, `b`);", + }, + { + name: "primary key index has no standalone form", + driver: DriverPostgres, + in: IndexInfo{Name: "users_pkey", Table: "users", Columns: []string{"id"}, IsPrimary: true}, + want: "", + }, + { + name: "expression-only index has no columns to render", + driver: DriverPostgres, + in: IndexInfo{Name: "idx_expr", Table: "users"}, + want: "", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := RenderCreateIndex(tc.driver, tc.in); got != tc.want { + t.Errorf("RenderCreateIndex()\n got %q\nwant %q", got, tc.want) + } + }) + } +} + +func TestJoinDDL(t *testing.T) { + got := JoinDDL("CREATE TABLE a ();", "", " \n", "CREATE INDEX i ON a (x);") + want := "CREATE TABLE a ();\n\nCREATE INDEX i ON a (x);" + if got != want { + t.Errorf("JoinDDL() = %q, want %q", got, want) + } + if got := JoinDDL("", ""); got != "" { + t.Errorf("JoinDDL of nothing = %q, want empty", got) + } +} + +func TestTerminateStatement(t *testing.T) { + tests := []struct{ in, want string }{ + {"CREATE TABLE t (a int)", "CREATE TABLE t (a int);"}, + {"CREATE TABLE t (a int);", "CREATE TABLE t (a int);"}, + {"CREATE TABLE t (a int);\n\n", "CREATE TABLE t (a int);"}, + {"", ""}, + {" \n ", ""}, + } + for _, tc := range tests { + if got := TerminateStatement(tc.in); got != tc.want { + t.Errorf("TerminateStatement(%q) = %q, want %q", tc.in, got, tc.want) + } + } +} + +func TestQuoteIdentList(t *testing.T) { + if got := QuoteIdentList(DriverPostgres, []string{"a", "b"}); got != `"a", "b"` { + t.Errorf("QuoteIdentList() = %q", got) + } + if got := QuoteIdentList(DriverPostgres, nil); got != "" { + t.Errorf("QuoteIdentList(nil) = %q, want empty", got) + } +} + +func TestRelationKind(t *testing.T) { + tests := []struct { + in string + want ObjectKind + }{ + {"table", ObjectTable}, + {"view", ObjectView}, + {"VIEW", ObjectView}, + {"materialized view", ObjectMatView}, + {"partitioned table", ObjectTable}, + {"", ObjectTable}, + } + for _, tc := range tests { + if got := RelationKind(tc.in); got != tc.want { + t.Errorf("RelationKind(%q) = %q, want %q", tc.in, got, tc.want) + } + } +} + +func TestObjectKindIsRelation(t *testing.T) { + for _, k := range []ObjectKind{ObjectTable, ObjectView, ObjectMatView} { + if !k.IsRelation() { + t.Errorf("%q should be a relation", k) + } + } + for _, k := range []ObjectKind{ObjectIndex, ObjectConstraint, ObjectTrigger, ObjectFunction, ObjectProcedure} { + if k.IsRelation() { + t.Errorf("%q should not be a relation", k) + } + } +} diff --git a/internal/database/driver.go b/internal/database/driver.go index 50dfdc9..1b2d5cc 100644 --- a/internal/database/driver.go +++ b/internal/database/driver.go @@ -27,6 +27,14 @@ type Session interface { ListSchemas(ctx context.Context) ([]SchemaInfo, error) ListTables(ctx context.Context, schema string) ([]TableInfo, error) ListColumns(ctx context.Context, schema, table string) ([]ColumnInfo, error) + ListIndexes(ctx context.Context, schema, table string) ([]IndexInfo, error) + ListConstraints(ctx context.Context, schema, table string) ([]ConstraintInfo, error) + ListTriggers(ctx context.Context, schema, table string) ([]TriggerInfo, error) + // ListRoutines returns the schema's functions and procedures; empty where there are none. + ListRoutines(ctx context.Context, schema string) ([]RoutineInfo, error) + // ObjectDDL renders the object's CREATE statement, verbatim where the engine stores it and + // composed from the catalog otherwise. + ObjectDDL(ctx context.Context, ref ObjectRef) (string, error) QueryTable(ctx context.Context, req TableDataRequest) (*QueryResult, error) QueryTableStream(ctx context.Context, req TableDataRequest, opts StreamOpts) (*QueryResult, error) UpdateRow(ctx context.Context, upd RowUpdate) error diff --git a/internal/database/mysql/schema.go b/internal/database/mysql/schema.go new file mode 100644 index 0000000..2201503 --- /dev/null +++ b/internal/database/mysql/schema.go @@ -0,0 +1,297 @@ +package mysql + +import ( + "context" + "database/sql" + "fmt" + "strings" + + "xensql/internal/database" +) + +func (s *Session) ListIndexes(ctx context.Context, schema, table string) ([]database.IndexInfo, error) { + schema = s.SchemaOr(schema) + rows, err := s.DB.QueryContext(ctx, ` + SELECT INDEX_NAME, NON_UNIQUE, INDEX_TYPE, COLUMN_NAME + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? + ORDER BY INDEX_NAME, SEQ_IN_INDEX`, schema, table) + if err != nil { + return nil, err + } + defer rows.Close() + var order []string + grouped := map[string]*database.IndexInfo{} + for rows.Next() { + var name, indexType string + var nonUnique int + // NULL for a functional index part (MySQL 8.0.13+), which has an expression, not a column. + var column sql.NullString + if err := rows.Scan(&name, &nonUnique, &indexType, &column); err != nil { + return nil, err + } + idx, seen := grouped[name] + if !seen { + idx = &database.IndexInfo{ + Name: name, + Schema: schema, + Table: table, + IsPrimary: name == "PRIMARY", + IsUnique: nonUnique == 0, + Method: strings.ToLower(indexType), + } + grouped[name] = idx + order = append(order, name) + } + if column.Valid { + idx.Columns = append(idx.Columns, column.String) + } + } + if err := rows.Err(); err != nil { + return nil, err + } + out := make([]database.IndexInfo, 0, len(order)) + for _, name := range order { + out = append(out, *grouped[name]) + } + return out, nil +} + +func (s *Session) ListConstraints(ctx context.Context, schema, table string) ([]database.ConstraintInfo, error) { + schema = s.SchemaOr(schema) + rows, err := s.DB.QueryContext(ctx, ` + SELECT tc.CONSTRAINT_NAME, tc.CONSTRAINT_TYPE, + kcu.COLUMN_NAME, kcu.REFERENCED_TABLE_NAME, kcu.REFERENCED_COLUMN_NAME + FROM information_schema.TABLE_CONSTRAINTS tc + LEFT JOIN information_schema.KEY_COLUMN_USAGE kcu + ON kcu.CONSTRAINT_SCHEMA = tc.CONSTRAINT_SCHEMA + AND kcu.CONSTRAINT_NAME = tc.CONSTRAINT_NAME + AND kcu.TABLE_NAME = tc.TABLE_NAME + WHERE tc.TABLE_SCHEMA = ? AND tc.TABLE_NAME = ? + ORDER BY tc.CONSTRAINT_TYPE, tc.CONSTRAINT_NAME, kcu.ORDINAL_POSITION`, schema, table) + if err != nil { + return nil, err + } + defer rows.Close() + var order []string + grouped := map[string]*database.ConstraintInfo{} + for rows.Next() { + var name, ctype string + // All NULL for a CHECK constraint, which owns no key columns. + var column, refTable, refColumn sql.NullString + if err := rows.Scan(&name, &ctype, &column, &refTable, &refColumn); err != nil { + return nil, err + } + c, seen := grouped[name] + if !seen { + c = &database.ConstraintInfo{ + Name: name, + Schema: schema, + Table: table, + Type: strings.ToUpper(ctype), + RefTable: refTable.String, + } + grouped[name] = c + order = append(order, name) + } + if column.Valid { + c.Columns = append(c.Columns, column.String) + } + if refColumn.Valid { + c.RefColumns = append(c.RefColumns, refColumn.String) + } + } + if err := rows.Err(); err != nil { + return nil, err + } + clauses := s.checkClauses(ctx, schema) + out := make([]database.ConstraintInfo, 0, len(order)) + for _, name := range order { + c := *grouped[name] + if c.Type == "CHECK" { + if clause, ok := clauses[name]; ok { + c.Definition = "CHECK " + clause + } + } + out = append(out, c) + } + return out, nil +} + +// checkClauses maps constraint name to CHECK body. The table only exists on MySQL 8.0.16+ and +// MariaDB 10.2.22+ with differing columns, so a failure degrades to no clauses. +func (s *Session) checkClauses(ctx context.Context, schema string) map[string]string { + rows, err := s.DB.QueryContext(ctx, ` + SELECT CONSTRAINT_NAME, CHECK_CLAUSE + FROM information_schema.CHECK_CONSTRAINTS + WHERE CONSTRAINT_SCHEMA = ?`, schema) + if err != nil { + return nil + } + defer rows.Close() + out := map[string]string{} + for rows.Next() { + var name, clause string + if err := rows.Scan(&name, &clause); err != nil { + return out + } + out[name] = clause + } + return out +} + +func (s *Session) ListTriggers(ctx context.Context, schema, table string) ([]database.TriggerInfo, error) { + schema = s.SchemaOr(schema) + rows, err := s.DB.QueryContext(ctx, ` + SELECT TRIGGER_NAME, ACTION_TIMING, EVENT_MANIPULATION + FROM information_schema.TRIGGERS + WHERE TRIGGER_SCHEMA = ? AND EVENT_OBJECT_TABLE = ? + ORDER BY TRIGGER_NAME`, schema, table) + if err != nil { + return nil, err + } + defer rows.Close() + var out []database.TriggerInfo + for rows.Next() { + var name, timing, event string + if err := rows.Scan(&name, &timing, &event); err != nil { + return nil, err + } + out = append(out, database.TriggerInfo{ + Name: name, Schema: schema, Table: table, + Timing: strings.ToUpper(timing), Events: strings.ToUpper(event), + }) + } + return out, rows.Err() +} + +// ListRoutines leaves Args empty: MySQL forbids overloading. +func (s *Session) ListRoutines(ctx context.Context, schema string) ([]database.RoutineInfo, error) { + schema = s.SchemaOr(schema) + rows, err := s.DB.QueryContext(ctx, ` + SELECT ROUTINE_NAME, ROUTINE_TYPE, COALESCE(DTD_IDENTIFIER, '') + FROM information_schema.ROUTINES + WHERE ROUTINE_SCHEMA = ? + ORDER BY ROUTINE_TYPE, ROUTINE_NAME`, schema) + if err != nil { + return nil, err + } + defer rows.Close() + var out []database.RoutineInfo + for rows.Next() { + var name, routineType, returnType string + if err := rows.Scan(&name, &routineType, &returnType); err != nil { + return nil, err + } + kind := database.ObjectFunction + if strings.EqualFold(routineType, "PROCEDURE") { + kind = database.ObjectProcedure + returnType = "" + } + out = append(out, database.RoutineInfo{ + Name: name, Schema: schema, Kind: kind, ReturnType: returnType, + }) + } + return out, rows.Err() +} + +func (s *Session) ObjectDDL(ctx context.Context, ref database.ObjectRef) (string, error) { + schema := s.SchemaOr(ref.Schema) + qualified := database.BuildQualifiedTable(database.DriverMySQL, schema, ref.Name) + switch ref.Kind { + case database.ObjectTable: + return s.showCreate(ctx, "SHOW CREATE TABLE "+qualified) + case database.ObjectView: + return s.showCreate(ctx, "SHOW CREATE VIEW "+qualified) + case database.ObjectTrigger: + return s.showCreate(ctx, "SHOW CREATE TRIGGER "+qualified) + case database.ObjectFunction: + return s.showCreate(ctx, "SHOW CREATE FUNCTION "+qualified) + case database.ObjectProcedure: + return s.showCreate(ctx, "SHOW CREATE PROCEDURE "+qualified) + case database.ObjectIndex: + return s.indexDDL(ctx, schema, ref) + case database.ObjectConstraint: + return s.constraintDDL(ctx, schema, ref) + } + return "", database.ErrUnsupportedDDL(database.DriverMySQL, ref.Kind) +} + +// indexDDL synthesizes CREATE INDEX; MySQL has no SHOW CREATE INDEX. +func (s *Session) indexDDL(ctx context.Context, schema string, ref database.ObjectRef) (string, error) { + indexes, err := s.ListIndexes(ctx, schema, ref.Table) + if err != nil { + return "", err + } + for _, idx := range indexes { + if idx.Name != ref.Name { + continue + } + if idx.IsPrimary { + return fmt.Sprintf("ALTER TABLE %s ADD PRIMARY KEY (%s);", + database.BuildQualifiedTable(database.DriverMySQL, schema, ref.Table), + database.QuoteIdentList(database.DriverMySQL, idx.Columns)), nil + } + if synth := database.RenderCreateIndex(database.DriverMySQL, idx); synth != "" { + return synth, nil + } + } + return "", fmt.Errorf("index %s not found on %s", ref.Name, ref.Table) +} + +func (s *Session) constraintDDL(ctx context.Context, schema string, ref database.ObjectRef) (string, error) { + constraints, err := s.ListConstraints(ctx, schema, ref.Table) + if err != nil { + return "", err + } + for _, c := range constraints { + if c.Name != ref.Name { + continue + } + clause := database.RenderConstraint(database.DriverMySQL, c) + if clause == "" { + break + } + return fmt.Sprintf("ALTER TABLE %s\n ADD %s;", + database.BuildQualifiedTable(database.DriverMySQL, schema, ref.Table), clause), nil + } + return "", fmt.Errorf("constraint %s not found on %s", ref.Name, ref.Table) +} + +// showCreate locates the definition column by name; the result shape differs per object type. +func (s *Session) showCreate(ctx context.Context, stmt string) (string, error) { + rows, err := s.DB.QueryContext(ctx, stmt) + if err != nil { + return "", err + } + defer rows.Close() + cols, err := rows.Columns() + if err != nil { + return "", err + } + target := -1 + for i, c := range cols { + if strings.HasPrefix(strings.ToLower(c), "create") { + target = i + break + } + } + if target < 0 { + return "", fmt.Errorf("no definition column in %s", stmt) + } + if !rows.Next() { + if err := rows.Err(); err != nil { + return "", err + } + return "", fmt.Errorf("no definition returned by %s", stmt) + } + values := make([]sql.NullString, len(cols)) + dest := make([]any, len(cols)) + for i := range values { + dest[i] = &values[i] + } + if err := rows.Scan(dest...); err != nil { + return "", err + } + return database.TerminateStatement(values[target].String), nil +} diff --git a/internal/database/pool_test.go b/internal/database/pool_test.go index 35c64b1..d68f466 100644 --- a/internal/database/pool_test.go +++ b/internal/database/pool_test.go @@ -27,6 +27,17 @@ func (f *fakeSession) ListTables(context.Context, string) ([]TableInfo, error) { func (f *fakeSession) ListColumns(context.Context, string, string) ([]ColumnInfo, error) { return nil, nil } +func (f *fakeSession) ListIndexes(context.Context, string, string) ([]IndexInfo, error) { + return nil, nil +} +func (f *fakeSession) ListConstraints(context.Context, string, string) ([]ConstraintInfo, error) { + return nil, nil +} +func (f *fakeSession) ListTriggers(context.Context, string, string) ([]TriggerInfo, error) { + return nil, nil +} +func (f *fakeSession) ListRoutines(context.Context, string) ([]RoutineInfo, error) { return nil, nil } +func (f *fakeSession) ObjectDDL(context.Context, ObjectRef) (string, error) { return "", nil } func (f *fakeSession) QueryTable(context.Context, TableDataRequest) (*QueryResult, error) { return &QueryResult{}, nil } diff --git a/internal/database/postgres/schema.go b/internal/database/postgres/schema.go new file mode 100644 index 0000000..bc83779 --- /dev/null +++ b/internal/database/postgres/schema.go @@ -0,0 +1,454 @@ +package postgres + +import ( + "context" + "database/sql" + "fmt" + "strings" + + "xensql/internal/database" +) + +func (s *Session) ListIndexes(ctx context.Context, schema, table string) ([]database.IndexInfo, error) { + schema = s.SchemaOr(schema) + // One row per index column rather than an aggregate, so nothing has to scan a Postgres array. + rows, err := s.DB.QueryContext(ctx, ` + SELECT i.relname, ix.indisunique, ix.indisprimary, am.amname, a.attname + FROM pg_catalog.pg_index ix + JOIN pg_catalog.pg_class i ON i.oid = ix.indexrelid + JOIN pg_catalog.pg_class t ON t.oid = ix.indrelid + JOIN pg_catalog.pg_namespace n ON n.oid = t.relnamespace + JOIN pg_catalog.pg_am am ON am.oid = i.relam + LEFT JOIN LATERAL unnest(ix.indkey) WITH ORDINALITY AS k(attnum, ord) ON true + LEFT JOIN pg_catalog.pg_attribute a ON a.attrelid = t.oid AND a.attnum = k.attnum + WHERE n.nspname = $1 AND t.relname = $2 + ORDER BY i.relname, k.ord`, schema, table) + if err != nil { + return nil, err + } + defer rows.Close() + var order []string + grouped := map[string]*database.IndexInfo{} + for rows.Next() { + var name, method string + var unique, primary bool + // NULL for an expression part, whose indkey entry is 0 and matches no attribute. + var column sql.NullString + if err := rows.Scan(&name, &unique, &primary, &method, &column); err != nil { + return nil, err + } + idx, seen := grouped[name] + if !seen { + idx = &database.IndexInfo{ + Name: name, Schema: schema, Table: table, + IsPrimary: primary, IsUnique: unique, Method: method, + } + grouped[name] = idx + order = append(order, name) + } + if column.Valid { + idx.Columns = append(idx.Columns, column.String) + } + } + if err := rows.Err(); err != nil { + return nil, err + } + out := make([]database.IndexInfo, 0, len(order)) + for _, name := range order { + out = append(out, *grouped[name]) + } + return out, nil +} + +func constraintTypeName(contype string) string { + switch contype { + case "p": + return "PRIMARY KEY" + case "f": + return "FOREIGN KEY" + case "u": + return "UNIQUE" + case "c": + return "CHECK" + case "x": + return "EXCLUDE" + } + return strings.ToUpper(contype) +} + +func (s *Session) ListConstraints(ctx context.Context, schema, table string) ([]database.ConstraintInfo, error) { + schema = s.SchemaOr(schema) + rows, err := s.DB.QueryContext(ctx, ` + SELECT con.conname, con.contype, + pg_catalog.pg_get_constraintdef(con.oid, true), + COALESCE(reft.relname, ''), + a.attname, refa.attname + FROM pg_catalog.pg_constraint con + JOIN pg_catalog.pg_class t ON t.oid = con.conrelid + JOIN pg_catalog.pg_namespace n ON n.oid = t.relnamespace + LEFT JOIN pg_catalog.pg_class reft ON reft.oid = con.confrelid + LEFT JOIN LATERAL unnest(con.conkey) WITH ORDINALITY AS k(attnum, ord) ON true + LEFT JOIN pg_catalog.pg_attribute a ON a.attrelid = con.conrelid AND a.attnum = k.attnum + LEFT JOIN pg_catalog.pg_attribute refa ON refa.attrelid = con.confrelid + AND refa.attnum = con.confkey[k.ord] + WHERE n.nspname = $1 AND t.relname = $2 + ORDER BY con.contype, con.conname, k.ord`, schema, table) + if err != nil { + return nil, err + } + defer rows.Close() + var order []string + grouped := map[string]*database.ConstraintInfo{} + for rows.Next() { + var name, contype, def, refTable string + // NULL on a CHECK constraint, which references no key column. + var column, refColumn sql.NullString + if err := rows.Scan(&name, &contype, &def, &refTable, &column, &refColumn); err != nil { + return nil, err + } + c, seen := grouped[name] + if !seen { + c = &database.ConstraintInfo{ + Name: name, Schema: schema, Table: table, + Type: constraintTypeName(contype), Definition: def, RefTable: refTable, + } + grouped[name] = c + order = append(order, name) + } + if column.Valid { + c.Columns = append(c.Columns, column.String) + } + if refColumn.Valid { + c.RefColumns = append(c.RefColumns, refColumn.String) + } + } + if err := rows.Err(); err != nil { + return nil, err + } + out := make([]database.ConstraintInfo, 0, len(order)) + for _, name := range order { + out = append(out, *grouped[name]) + } + return out, nil +} + +// pg_trigger.tgtype bit flags; see the TRIGGER_TYPE_* macros in the Postgres source. +const ( + trigTypeBefore = 1 << 1 + trigTypeInsert = 1 << 2 + trigTypeDelete = 1 << 3 + trigTypeUpdate = 1 << 4 + trigTypeTruncate = 1 << 5 + trigTypeInstead = 1 << 6 +) + +func decodeTriggerType(tgtype int) (timing, events string) { + switch { + case tgtype&trigTypeInstead != 0: + timing = "INSTEAD OF" + case tgtype&trigTypeBefore != 0: + timing = "BEFORE" + default: + timing = "AFTER" + } + var parts []string + for _, e := range []struct { + bit int + name string + }{ + {trigTypeInsert, "INSERT"}, + {trigTypeUpdate, "UPDATE"}, + {trigTypeDelete, "DELETE"}, + {trigTypeTruncate, "TRUNCATE"}, + } { + if tgtype&e.bit != 0 { + parts = append(parts, e.name) + } + } + return timing, strings.Join(parts, ", ") +} + +func (s *Session) ListTriggers(ctx context.Context, schema, table string) ([]database.TriggerInfo, error) { + schema = s.SchemaOr(schema) + // tgisinternal hides the triggers Postgres creates to enforce foreign keys. + rows, err := s.DB.QueryContext(ctx, ` + SELECT tg.tgname, tg.tgtype + FROM pg_catalog.pg_trigger tg + JOIN pg_catalog.pg_class t ON t.oid = tg.tgrelid + JOIN pg_catalog.pg_namespace n ON n.oid = t.relnamespace + WHERE n.nspname = $1 AND t.relname = $2 AND NOT tg.tgisinternal + ORDER BY tg.tgname`, schema, table) + if err != nil { + return nil, err + } + defer rows.Close() + var out []database.TriggerInfo + for rows.Next() { + var name string + var tgtype int + if err := rows.Scan(&name, &tgtype); err != nil { + return nil, err + } + timing, events := decodeTriggerType(tgtype) + out = append(out, database.TriggerInfo{ + Name: name, Schema: schema, Table: table, Timing: timing, Events: events, + }) + } + return out, rows.Err() +} + +func (s *Session) ListRoutines(ctx context.Context, schema string) ([]database.RoutineInfo, error) { + schema = s.SchemaOr(schema) + rows, err := s.DB.QueryContext(ctx, ` + SELECT p.proname, + p.prokind, + COALESCE(pg_catalog.pg_get_function_result(p.oid), ''), + COALESCE(pg_catalog.pg_get_function_identity_arguments(p.oid), '') + FROM pg_catalog.pg_proc p + JOIN pg_catalog.pg_namespace n ON n.oid = p.pronamespace + WHERE n.nspname = $1 AND p.prokind IN ('f', 'p') + ORDER BY p.proname, 4`, schema) + if err != nil { + return nil, err + } + defer rows.Close() + var out []database.RoutineInfo + for rows.Next() { + var name, prokind, result, args string + if err := rows.Scan(&name, &prokind, &result, &args); err != nil { + return nil, err + } + kind := database.ObjectFunction + if prokind == "p" { + kind = database.ObjectProcedure + result = "" + } + out = append(out, database.RoutineInfo{ + Name: name, Schema: schema, Kind: kind, ReturnType: result, Args: args, + }) + } + return out, rows.Err() +} + +func (s *Session) ObjectDDL(ctx context.Context, ref database.ObjectRef) (string, error) { + schema := s.SchemaOr(ref.Schema) + switch ref.Kind { + case database.ObjectTable: + return s.tableDDL(ctx, schema, ref.Name) + case database.ObjectView, database.ObjectMatView: + return s.viewDDL(ctx, schema, ref) + case database.ObjectIndex: + return s.scalarDDL(ctx, ` + SELECT pg_catalog.pg_get_indexdef(i.oid) + FROM pg_catalog.pg_class i + JOIN pg_catalog.pg_namespace n ON n.oid = i.relnamespace + WHERE n.nspname = $1 AND i.relname = $2 AND i.relkind IN ('i', 'I')`, + "index", ref.Name, schema, ref.Name) + case database.ObjectTrigger: + return s.scalarDDL(ctx, ` + SELECT pg_catalog.pg_get_triggerdef(tg.oid, true) + FROM pg_catalog.pg_trigger tg + JOIN pg_catalog.pg_class t ON t.oid = tg.tgrelid + JOIN pg_catalog.pg_namespace n ON n.oid = t.relnamespace + WHERE n.nspname = $1 AND t.relname = $2 AND tg.tgname = $3`, + "trigger", ref.Name, schema, ref.Table, ref.Name) + case database.ObjectConstraint: + return s.constraintDDL(ctx, schema, ref) + case database.ObjectFunction, database.ObjectProcedure: + return s.routineDDL(ctx, schema, ref) + } + return "", database.ErrUnsupportedDDL(database.DriverPostgres, ref.Kind) +} + +func (s *Session) viewDDL(ctx context.Context, schema string, ref database.ObjectRef) (string, error) { + def, err := s.scalarDDL(ctx, ` + SELECT pg_catalog.pg_get_viewdef(c.oid, true) + FROM pg_catalog.pg_class c + JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = $1 AND c.relname = $2 AND c.relkind IN ('v', 'm')`, + "view", ref.Name, schema, ref.Name) + if err != nil { + return "", err + } + head := "CREATE OR REPLACE VIEW " + if ref.Kind == database.ObjectMatView { + // A materialized view has no OR REPLACE form. + head = "CREATE MATERIALIZED VIEW " + } + return head + database.BuildQualifiedTable(database.DriverPostgres, schema, ref.Name) + " AS\n" + def, nil +} + +func (s *Session) constraintDDL(ctx context.Context, schema string, ref database.ObjectRef) (string, error) { + def, err := s.scalarDDL(ctx, ` + SELECT pg_catalog.pg_get_constraintdef(con.oid, true) + FROM pg_catalog.pg_constraint con + JOIN pg_catalog.pg_class t ON t.oid = con.conrelid + JOIN pg_catalog.pg_namespace n ON n.oid = t.relnamespace + WHERE n.nspname = $1 AND t.relname = $2 AND con.conname = $3`, + "constraint", ref.Name, schema, ref.Table, ref.Name) + if err != nil { + return "", err + } + return fmt.Sprintf("ALTER TABLE ONLY %s\n ADD CONSTRAINT %s %s;", + database.BuildQualifiedTable(database.DriverPostgres, schema, ref.Table), + database.QuoteIdent(database.DriverPostgres, ref.Name), + strings.TrimSuffix(def, ";")), nil +} + +// routineDDL matches on the identity argument list, resolving overloads. +func (s *Session) routineDDL(ctx context.Context, schema string, ref database.ObjectRef) (string, error) { + return s.scalarDDL(ctx, ` + SELECT pg_catalog.pg_get_functiondef(p.oid) + FROM pg_catalog.pg_proc p + JOIN pg_catalog.pg_namespace n ON n.oid = p.pronamespace + WHERE n.nspname = $1 AND p.proname = $2 + AND ($3 = '' OR pg_catalog.pg_get_function_identity_arguments(p.oid) = $3) + ORDER BY p.oid + LIMIT 1`, + string(ref.Kind), ref.Name, schema, ref.Name, ref.Args) +} + +// tableDDL composes CREATE TABLE from the catalog; Postgres has no SHOW CREATE TABLE. +func (s *Session) tableDDL(ctx context.Context, schema, table string) (string, error) { + cols, err := s.ddlColumns(ctx, schema, table) + if err != nil { + return "", err + } + if len(cols) == 0 { + return "", fmt.Errorf("table %s not found", table) + } + constraints, err := s.ListConstraints(ctx, schema, table) + if err != nil { + return "", err + } + clauses := make([]string, 0, len(constraints)) + constrained := make(map[string]bool, len(constraints)) + for _, c := range constraints { + constrained[c.Name] = true + if clause := database.RenderConstraint(database.DriverPostgres, c); clause != "" { + clauses = append(clauses, clause) + } + } + + blocks := []string{database.ComposeCreateTable(database.DriverPostgres, schema, table, cols, clauses)} + + indexes, err := s.ListIndexes(ctx, schema, table) + if err != nil { + return "", err + } + for _, idx := range indexes { + // A constraint's backing index shares its name and is already covered by the clause above. + if constrained[idx.Name] { + continue + } + if synth := database.RenderCreateIndex(database.DriverPostgres, idx); synth != "" { + blocks = append(blocks, synth) + } + } + + comments, err := s.tableComments(ctx, schema, table) + if err != nil { + return "", err + } + return database.JoinDDL(append(blocks, comments)...), nil +} + +func (s *Session) ddlColumns(ctx context.Context, schema, table string) ([]database.DDLColumn, error) { + // The collation join drops the type's own default, so only an explicit override emits COLLATE. + rows, err := s.DB.QueryContext(ctx, ` + SELECT a.attname, + pg_catalog.format_type(a.atttypid, a.atttypmod), + a.attnotnull, + COALESCE(pg_catalog.pg_get_expr(ad.adbin, ad.adrelid), ''), + a.attidentity, + a.attgenerated, + COALESCE(co.collname, '') + FROM pg_catalog.pg_attribute a + JOIN pg_catalog.pg_class c ON c.oid = a.attrelid + JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace + JOIN pg_catalog.pg_type ty ON ty.oid = a.atttypid + LEFT JOIN pg_catalog.pg_attrdef ad ON ad.adrelid = a.attrelid AND ad.adnum = a.attnum + LEFT JOIN pg_catalog.pg_collation co ON co.oid = a.attcollation + AND a.attcollation <> ty.typcollation + WHERE n.nspname = $1 AND c.relname = $2 + AND a.attnum > 0 AND NOT a.attisdropped + ORDER BY a.attnum`, schema, table) + if err != nil { + return nil, err + } + defer rows.Close() + var cols []database.DDLColumn + for rows.Next() { + var name, dtype, defaultExpr, identity, generated, collation string + var notNull bool + if err := rows.Scan(&name, &dtype, ¬Null, &defaultExpr, &identity, &generated, &collation); err != nil { + return nil, err + } + col := database.DDLColumn{ + Name: name, Type: dtype, NotNull: notNull, Collation: collation, + } + switch { + case generated == "s": + // A stored generated column keeps its expression in pg_attrdef, not as a DEFAULT. + col.Generated = defaultExpr + case identity == "a": + col.Identity = "ALWAYS" + case identity == "d": + col.Identity = "BY DEFAULT" + default: + col.Default = defaultExpr + } + cols = append(cols, col) + } + return cols, rows.Err() +} + +func (s *Session) tableComments(ctx context.Context, schema, table string) (string, error) { + qualified := database.BuildQualifiedTable(database.DriverPostgres, schema, table) + rows, err := s.DB.QueryContext(ctx, ` + SELECT COALESCE(a.attname, ''), d.description + FROM pg_catalog.pg_class c + JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace + JOIN pg_catalog.pg_description d ON d.objoid = c.oid + LEFT JOIN pg_catalog.pg_attribute a ON a.attrelid = c.oid AND a.attnum = d.objsubid + AND NOT a.attisdropped + WHERE n.nspname = $1 AND c.relname = $2 + AND (d.objsubid = 0 OR a.attname IS NOT NULL) + ORDER BY d.objsubid`, schema, table) + if err != nil { + return "", err + } + defer rows.Close() + var lines []string + for rows.Next() { + var column, description string + if err := rows.Scan(&column, &description); err != nil { + return "", err + } + target := "TABLE " + qualified + if column != "" { + target = "COLUMN " + qualified + "." + database.QuoteIdent(database.DriverPostgres, column) + } + lines = append(lines, fmt.Sprintf("COMMENT ON %s IS %s;", target, quoteLiteral(description))) + } + if err := rows.Err(); err != nil { + return "", err + } + return strings.Join(lines, "\n"), nil +} + +func quoteLiteral(s string) string { + return "'" + strings.ReplaceAll(s, "'", "''") + "'" +} + +func (s *Session) scalarDDL(ctx context.Context, query, kind, name string, args ...any) (string, error) { + var def string + err := s.DB.QueryRowContext(ctx, query, args...).Scan(&def) + if err == sql.ErrNoRows { + return "", fmt.Errorf("%s %s not found", kind, name) + } + if err != nil { + return "", err + } + return database.TerminateStatement(def), nil +} diff --git a/internal/database/postgres/schema_test.go b/internal/database/postgres/schema_test.go new file mode 100644 index 0000000..42eae8c --- /dev/null +++ b/internal/database/postgres/schema_test.go @@ -0,0 +1,77 @@ +package postgres + +import "testing" + +func TestConstraintTypeName(t *testing.T) { + tests := []struct{ in, want string }{ + {"p", "PRIMARY KEY"}, + {"f", "FOREIGN KEY"}, + {"u", "UNIQUE"}, + {"c", "CHECK"}, + {"x", "EXCLUDE"}, + {"t", "T"}, + } + for _, tc := range tests { + if got := constraintTypeName(tc.in); got != tc.want { + t.Errorf("constraintTypeName(%q) = %q, want %q", tc.in, got, tc.want) + } + } +} + +func TestDecodeTriggerType(t *testing.T) { + tests := []struct { + name string + tgtype int + wantTiming string + wantEvents string + }{ + { + name: "before insert row", tgtype: 1 | trigTypeBefore | trigTypeInsert, + wantTiming: "BEFORE", wantEvents: "INSERT", + }, + { + name: "after update", tgtype: trigTypeUpdate, + wantTiming: "AFTER", wantEvents: "UPDATE", + }, + { + name: "instead of outranks before", tgtype: trigTypeInstead | trigTypeBefore | trigTypeDelete, + wantTiming: "INSTEAD OF", wantEvents: "DELETE", + }, + { + name: "multiple events", + tgtype: trigTypeBefore | trigTypeDelete | trigTypeInsert | trigTypeUpdate, + wantTiming: "BEFORE", wantEvents: "INSERT, UPDATE, DELETE", + }, + { + name: "truncate", tgtype: trigTypeTruncate, + wantTiming: "AFTER", wantEvents: "TRUNCATE", + }, + { + name: "no event bits", tgtype: trigTypeBefore, + wantTiming: "BEFORE", wantEvents: "", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + timing, events := decodeTriggerType(tc.tgtype) + if timing != tc.wantTiming || events != tc.wantEvents { + t.Errorf("decodeTriggerType(%d) = %q/%q, want %q/%q", + tc.tgtype, timing, events, tc.wantTiming, tc.wantEvents) + } + }) + } +} + +func TestQuoteLiteral(t *testing.T) { + tests := []struct{ in, want string }{ + {"plain", "'plain'"}, + {"it's", "'it''s'"}, + {"", "''"}, + {"'; DROP TABLE users; --", "'''; DROP TABLE users; --'"}, + } + for _, tc := range tests { + if got := quoteLiteral(tc.in); got != tc.want { + t.Errorf("quoteLiteral(%q) = %q, want %q", tc.in, got, tc.want) + } + } +} diff --git a/internal/database/sqlite/schema.go b/internal/database/sqlite/schema.go new file mode 100644 index 0000000..ce1b780 --- /dev/null +++ b/internal/database/sqlite/schema.go @@ -0,0 +1,297 @@ +package sqlite + +import ( + "context" + "database/sql" + "fmt" + "regexp" + "strings" + + "xensql/internal/database" +) + +// SQLite has no routines. +func (s *Session) ListRoutines(ctx context.Context, schema string) ([]database.RoutineInfo, error) { + return nil, nil +} + +func (s *Session) ListIndexes(ctx context.Context, schema, table string) ([]database.IndexInfo, error) { + entries, err := s.indexList(ctx, table) + if err != nil { + return nil, err + } + indexes := make([]database.IndexInfo, 0, len(entries)) + for _, e := range entries { + cols, err := s.indexColumns(ctx, e.name) + if err != nil { + return nil, err + } + indexes = append(indexes, database.IndexInfo{ + Name: e.name, + Schema: "main", + Table: table, + Columns: cols, + IsPrimary: e.origin == "pk", + IsUnique: e.unique, + }) + } + return indexes, nil +} + +type sqliteIndexEntry struct { + name string + unique bool + // origin is "c" (CREATE INDEX), "u" (UNIQUE constraint) or "pk" (PRIMARY KEY). + origin string +} + +func (s *Session) indexList(ctx context.Context, table string) ([]sqliteIndexEntry, error) { + rows, err := s.DB.QueryContext(ctx, + fmt.Sprintf("PRAGMA index_list(%s)", database.QuoteIdent(database.DriverSQLite, table))) + if err != nil { + return nil, err + } + defer rows.Close() + var out []sqliteIndexEntry + for rows.Next() { + var seq int + var name, origin string + var unique, partial int + if err := rows.Scan(&seq, &name, &unique, &origin, &partial); err != nil { + return nil, err + } + out = append(out, sqliteIndexEntry{name: name, unique: unique == 1, origin: origin}) + } + return out, rows.Err() +} + +// indexColumns skips expression parts, whose name PRAGMA index_info reports as NULL. +func (s *Session) indexColumns(ctx context.Context, index string) ([]string, error) { + rows, err := s.DB.QueryContext(ctx, + fmt.Sprintf("PRAGMA index_info(%s)", database.QuoteIdent(database.DriverSQLite, index))) + if err != nil { + return nil, err + } + defer rows.Close() + var cols []string + for rows.Next() { + var seqno, cid int + var name sql.NullString + if err := rows.Scan(&seqno, &cid, &name); err != nil { + return nil, err + } + if name.Valid { + cols = append(cols, name.String) + } + } + return cols, rows.Err() +} + +// ListConstraints reads the pragmas; CHECK has none, so it appears only in the table's own DDL. +func (s *Session) ListConstraints(ctx context.Context, schema, table string) ([]database.ConstraintInfo, error) { + cols, err := s.ListColumns(ctx, schema, table) + if err != nil { + return nil, err + } + out := make([]database.ConstraintInfo, 0, 4) + if pks := database.PrimaryKeys(cols); len(pks) > 0 { + out = append(out, database.ConstraintInfo{ + Schema: "main", Table: table, Type: "PRIMARY KEY", Columns: pks, + }) + } + + uniques, err := s.indexList(ctx, table) + if err != nil { + return nil, err + } + for _, e := range uniques { + if e.origin != "u" { + continue + } + ucols, err := s.indexColumns(ctx, e.name) + if err != nil { + return nil, err + } + out = append(out, database.ConstraintInfo{ + Name: e.name, Schema: "main", Table: table, Type: "UNIQUE", Columns: ucols, + }) + } + + fks, err := s.foreignKeyConstraints(ctx, table) + if err != nil { + return nil, err + } + return append(out, fks...), nil +} + +// foreignKeyConstraints groups rows by id, so a composite key is one constraint. +func (s *Session) foreignKeyConstraints(ctx context.Context, table string) ([]database.ConstraintInfo, error) { + rows, err := s.DB.QueryContext(ctx, + fmt.Sprintf("PRAGMA foreign_key_list(%s)", database.QuoteIdent(database.DriverSQLite, table))) + if err != nil { + return nil, err + } + defer rows.Close() + var order []int + grouped := map[int]*database.ConstraintInfo{} + for rows.Next() { + var id, seq int + var refTable, from string + var to sql.NullString + var onUpdate, onDelete, matchType string + if err := rows.Scan(&id, &seq, &refTable, &from, &to, &onUpdate, &onDelete, &matchType); err != nil { + return nil, err + } + c, seen := grouped[id] + if !seen { + c = &database.ConstraintInfo{ + Schema: "main", Table: table, Type: "FOREIGN KEY", RefTable: refTable, + } + grouped[id] = c + order = append(order, id) + } + c.Columns = append(c.Columns, from) + if to.Valid { + c.RefColumns = append(c.RefColumns, to.String) + } + } + if err := rows.Err(); err != nil { + return nil, err + } + out := make([]database.ConstraintInfo, 0, len(order)) + for _, id := range order { + out = append(out, *grouped[id]) + } + return out, nil +} + +func (s *Session) ListTriggers(ctx context.Context, schema, table string) ([]database.TriggerInfo, error) { + rows, err := s.DB.QueryContext(ctx, ` + SELECT name, COALESCE(sql, '') FROM sqlite_master + WHERE type = 'trigger' AND tbl_name = ? + ORDER BY name`, table) + if err != nil { + return nil, err + } + defer rows.Close() + var out []database.TriggerInfo + for rows.Next() { + var name, ddl string + if err := rows.Scan(&name, &ddl); err != nil { + return nil, err + } + timing, events := parseTriggerHead(ddl) + out = append(out, database.TriggerInfo{ + Name: name, Schema: "main", Table: table, Timing: timing, Events: events, + }) + } + return out, rows.Err() +} + +// sqliteTriggerHead captures the text between the trigger name and ON, where the keywords live. +var sqliteTriggerHead = regexp.MustCompile(`(?is)\bCREATE\s+(?:TEMP(?:ORARY)?\s+)?TRIGGER\s+(?:IF\s+NOT\s+EXISTS\s+)?(.*?)\s+ON\s`) + +// parseTriggerHead reads timing and event from a stored CREATE TRIGGER; SQLite defaults to BEFORE. +func parseTriggerHead(ddl string) (timing, events string) { + m := sqliteTriggerHead.FindStringSubmatch(ddl) + if m == nil { + return "", "" + } + head := strings.ToUpper(strings.Join(strings.Fields(m[1]), " ")) + switch { + case strings.Contains(head, "INSTEAD OF"): + timing = "INSTEAD OF" + case strings.Contains(head, "AFTER"): + timing = "AFTER" + default: + timing = "BEFORE" + } + for _, ev := range []string{"INSERT", "UPDATE", "DELETE"} { + if strings.Contains(head, ev) { + events = ev + break + } + } + return timing, events +} + +func (s *Session) ObjectDDL(ctx context.Context, ref database.ObjectRef) (string, error) { + switch ref.Kind { + case database.ObjectTable, database.ObjectView: + return s.relationDDL(ctx, ref) + case database.ObjectTrigger: + return s.masterDDL(ctx, "trigger", ref.Name) + case database.ObjectIndex: + return s.indexDDL(ctx, ref) + case database.ObjectConstraint: + return s.relationDDL(ctx, database.ObjectRef{Kind: database.ObjectTable, Name: ref.Table}) + } + return "", database.ErrUnsupportedDDL(database.DriverSQLite, ref.Kind) +} + +// relationDDL appends the table's standalone indexes, which SQLite stores as separate statements. +func (s *Session) relationDDL(ctx context.Context, ref database.ObjectRef) (string, error) { + base, err := s.masterDDL(ctx, string(ref.Kind), ref.Name) + if err != nil { + return "", err + } + if ref.Kind != database.ObjectTable { + return base, nil + } + rows, err := s.DB.QueryContext(ctx, ` + SELECT sql FROM sqlite_master + WHERE type = 'index' AND tbl_name = ? AND sql IS NOT NULL + ORDER BY name`, ref.Name) + if err != nil { + return "", err + } + defer rows.Close() + blocks := []string{base} + for rows.Next() { + var ddl string + if err := rows.Scan(&ddl); err != nil { + return "", err + } + blocks = append(blocks, database.TerminateStatement(ddl)) + } + if err := rows.Err(); err != nil { + return "", err + } + return database.JoinDDL(blocks...), nil +} + +// indexDDL synthesizes a statement for implicit indexes, which sqlite_master stores with NULL sql. +func (s *Session) indexDDL(ctx context.Context, ref database.ObjectRef) (string, error) { + ddl, err := s.masterDDL(ctx, "index", ref.Name) + if err == nil && ddl != "" { + return ddl, nil + } + indexes, listErr := s.ListIndexes(ctx, ref.Schema, ref.Table) + if listErr != nil { + return "", listErr + } + for _, idx := range indexes { + if idx.Name != ref.Name { + continue + } + if synth := database.RenderCreateIndex(database.DriverSQLite, idx); synth != "" { + return synth, nil + } + return "", fmt.Errorf("index %s is created implicitly by its constraint", ref.Name) + } + return "", err +} + +// masterDDL reads an object's original statement text, empty when sql is NULL. +func (s *Session) masterDDL(ctx context.Context, objType, name string) (string, error) { + var ddl sql.NullString + err := s.DB.QueryRowContext(ctx, + `SELECT sql FROM sqlite_master WHERE type = ? AND name = ?`, objType, name).Scan(&ddl) + if err == sql.ErrNoRows { + return "", fmt.Errorf("%s %s not found", objType, name) + } + if err != nil { + return "", err + } + return database.TerminateStatement(ddl.String), nil +} diff --git a/internal/database/sqlite/schema_test.go b/internal/database/sqlite/schema_test.go new file mode 100644 index 0000000..dab49d4 --- /dev/null +++ b/internal/database/sqlite/schema_test.go @@ -0,0 +1,293 @@ +package sqlite + +import ( + "context" + "strings" + "testing" + + "xensql/internal/database" +) + +func seedSchemaObjects(t *testing.T, s database.Session) { + t.Helper() + ctx := context.Background() + stmts := []string{ + `CREATE TABLE orgs (id INTEGER PRIMARY KEY, name TEXT NOT NULL)`, + `CREATE TABLE users ( + id INTEGER PRIMARY KEY, + email TEXT NOT NULL UNIQUE, + org_id INTEGER, + nickname TEXT, + FOREIGN KEY (org_id) REFERENCES orgs(id) + )`, + `CREATE INDEX users_nickname_idx ON users (nickname)`, + `CREATE TRIGGER users_touch AFTER UPDATE ON users BEGIN SELECT 1; END`, + } + for _, stmt := range stmts { + if _, err := s.Execute(ctx, stmt); err != nil { + t.Fatalf("seed %q: %v", stmt, err) + } + } +} + +func TestListIndexes(t *testing.T) { + s := newTestSession(t) + seedSchemaObjects(t, s) + + indexes, err := s.ListIndexes(context.Background(), "main", "users") + if err != nil { + t.Fatalf("ListIndexes: %v", err) + } + byName := map[string]database.IndexInfo{} + for _, idx := range indexes { + byName[idx.Name] = idx + } + + explicit, ok := byName["users_nickname_idx"] + if !ok { + t.Fatalf("missing explicit index, got %+v", indexes) + } + if explicit.IsUnique || explicit.IsPrimary { + t.Errorf("users_nickname_idx should be a plain index, got %+v", explicit) + } + if len(explicit.Columns) != 1 || explicit.Columns[0] != "nickname" { + t.Errorf("users_nickname_idx columns = %v, want [nickname]", explicit.Columns) + } + + var foundUnique bool + for _, idx := range indexes { + if idx.IsUnique && len(idx.Columns) == 1 && idx.Columns[0] == "email" { + foundUnique = true + } + } + if !foundUnique { + t.Errorf("expected a unique index over email, got %+v", indexes) + } +} + +func TestListConstraints(t *testing.T) { + s := newTestSession(t) + seedSchemaObjects(t, s) + + constraints, err := s.ListConstraints(context.Background(), "main", "users") + if err != nil { + t.Fatalf("ListConstraints: %v", err) + } + byType := map[string]database.ConstraintInfo{} + for _, c := range constraints { + byType[c.Type] = c + } + + pk, ok := byType["PRIMARY KEY"] + if !ok || len(pk.Columns) != 1 || pk.Columns[0] != "id" { + t.Errorf("PRIMARY KEY constraint = %+v, want columns [id]", pk) + } + if _, ok := byType["UNIQUE"]; !ok { + t.Errorf("expected a UNIQUE constraint, got %+v", constraints) + } + fk, ok := byType["FOREIGN KEY"] + if !ok { + t.Fatalf("expected a FOREIGN KEY constraint, got %+v", constraints) + } + if fk.RefTable != "orgs" { + t.Errorf("FK ref table = %q, want orgs", fk.RefTable) + } + if len(fk.Columns) != 1 || fk.Columns[0] != "org_id" { + t.Errorf("FK columns = %v, want [org_id]", fk.Columns) + } + if len(fk.RefColumns) != 1 || fk.RefColumns[0] != "id" { + t.Errorf("FK ref columns = %v, want [id]", fk.RefColumns) + } +} + +// A composite key spans several PRAGMA rows sharing one id and must collapse to one constraint. +func TestListConstraintsGroupsCompositeForeignKey(t *testing.T) { + s := newTestSession(t) + ctx := context.Background() + for _, stmt := range []string{ + `CREATE TABLE parent (a INTEGER, b INTEGER, PRIMARY KEY (a, b))`, + `CREATE TABLE child (x INTEGER, y INTEGER, FOREIGN KEY (x, y) REFERENCES parent(a, b))`, + } { + if _, err := s.Execute(ctx, stmt); err != nil { + t.Fatalf("seed: %v", err) + } + } + constraints, err := s.ListConstraints(ctx, "main", "child") + if err != nil { + t.Fatalf("ListConstraints: %v", err) + } + var fks []database.ConstraintInfo + for _, c := range constraints { + if c.Type == "FOREIGN KEY" { + fks = append(fks, c) + } + } + if len(fks) != 1 { + t.Fatalf("composite FK should be one constraint, got %d: %+v", len(fks), fks) + } + if strings.Join(fks[0].Columns, ",") != "x,y" { + t.Errorf("FK columns = %v, want [x y]", fks[0].Columns) + } + if strings.Join(fks[0].RefColumns, ",") != "a,b" { + t.Errorf("FK ref columns = %v, want [a b]", fks[0].RefColumns) + } +} + +func TestListTriggers(t *testing.T) { + s := newTestSession(t) + seedSchemaObjects(t, s) + + triggers, err := s.ListTriggers(context.Background(), "main", "users") + if err != nil { + t.Fatalf("ListTriggers: %v", err) + } + if len(triggers) != 1 { + t.Fatalf("expected 1 trigger, got %+v", triggers) + } + if triggers[0].Name != "users_touch" { + t.Errorf("trigger name = %q", triggers[0].Name) + } + if triggers[0].Timing != "AFTER" || triggers[0].Events != "UPDATE" { + t.Errorf("trigger timing/events = %q/%q, want AFTER/UPDATE", triggers[0].Timing, triggers[0].Events) + } +} + +func TestListRoutinesIsEmpty(t *testing.T) { + s := newTestSession(t) + routines, err := s.ListRoutines(context.Background(), "main") + if err != nil { + t.Fatalf("ListRoutines: %v", err) + } + if len(routines) != 0 { + t.Errorf("SQLite has no routines, got %+v", routines) + } +} + +func TestObjectDDL(t *testing.T) { + s := newTestSession(t) + seedSchemaObjects(t, s) + ctx := context.Background() + + t.Run("table includes its standalone indexes", func(t *testing.T) { + ddl, err := s.ObjectDDL(ctx, database.ObjectRef{Kind: database.ObjectTable, Schema: "main", Name: "users"}) + if err != nil { + t.Fatalf("ObjectDDL: %v", err) + } + if !strings.Contains(ddl, "CREATE TABLE users") { + t.Errorf("missing CREATE TABLE in:\n%s", ddl) + } + if !strings.Contains(ddl, "CREATE INDEX users_nickname_idx") { + t.Errorf("missing the table's index in:\n%s", ddl) + } + if !strings.HasSuffix(ddl, ";") { + t.Errorf("DDL should be terminated:\n%s", ddl) + } + }) + + t.Run("trigger", func(t *testing.T) { + ddl, err := s.ObjectDDL(ctx, database.ObjectRef{ + Kind: database.ObjectTrigger, Schema: "main", Name: "users_touch", Table: "users", + }) + if err != nil { + t.Fatalf("ObjectDDL: %v", err) + } + if !strings.Contains(ddl, "CREATE TRIGGER users_touch") { + t.Errorf("unexpected trigger DDL:\n%s", ddl) + } + }) + + t.Run("explicit index", func(t *testing.T) { + ddl, err := s.ObjectDDL(ctx, database.ObjectRef{ + Kind: database.ObjectIndex, Schema: "main", Name: "users_nickname_idx", Table: "users", + }) + if err != nil { + t.Fatalf("ObjectDDL: %v", err) + } + if !strings.Contains(ddl, "CREATE INDEX users_nickname_idx") { + t.Errorf("unexpected index DDL:\n%s", ddl) + } + }) + + t.Run("constraint falls back to the owning table", func(t *testing.T) { + ddl, err := s.ObjectDDL(ctx, database.ObjectRef{ + Kind: database.ObjectConstraint, Schema: "main", Name: "pk", Table: "users", + }) + if err != nil { + t.Fatalf("ObjectDDL: %v", err) + } + if !strings.Contains(ddl, "CREATE TABLE users") { + t.Errorf("constraint DDL should show the table:\n%s", ddl) + } + }) + + t.Run("routines are unsupported", func(t *testing.T) { + if _, err := s.ObjectDDL(ctx, database.ObjectRef{ + Kind: database.ObjectFunction, Schema: "main", Name: "whatever", + }); err == nil { + t.Error("expected an unsupported-kind error") + } + }) + + t.Run("missing object reports not found", func(t *testing.T) { + if _, err := s.ObjectDDL(ctx, database.ObjectRef{ + Kind: database.ObjectTable, Schema: "main", Name: "nope", + }); err == nil { + t.Error("expected a not-found error") + } + }) +} + +func TestParseTriggerHead(t *testing.T) { + tests := []struct { + name string + ddl string + wantTiming string + wantEvents string + }{ + { + name: "explicit after update", + ddl: "CREATE TRIGGER t AFTER UPDATE ON users BEGIN SELECT 1; END", + wantTiming: "AFTER", + wantEvents: "UPDATE", + }, + { + name: "implicit timing defaults to before", + ddl: "CREATE TRIGGER t DELETE ON users BEGIN SELECT 1; END", + wantTiming: "BEFORE", + wantEvents: "DELETE", + }, + { + name: "instead of on a view", + ddl: "CREATE TRIGGER t INSTEAD OF INSERT ON v BEGIN SELECT 1; END", + wantTiming: "INSTEAD OF", + wantEvents: "INSERT", + }, + { + name: "temporary trigger with if not exists", + ddl: "CREATE TEMPORARY TRIGGER IF NOT EXISTS t BEFORE INSERT ON users BEGIN SELECT 1; END", + wantTiming: "BEFORE", + wantEvents: "INSERT", + }, + { + name: "update of named columns", + ddl: "CREATE TRIGGER t AFTER UPDATE OF email, nickname ON users BEGIN SELECT 1; END", + wantTiming: "AFTER", + wantEvents: "UPDATE", + }, + { + name: "unparseable statement", + ddl: "", + wantTiming: "", + wantEvents: "", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + timing, events := parseTriggerHead(tc.ddl) + if timing != tc.wantTiming || events != tc.wantEvents { + t.Errorf("parseTriggerHead(%q) = %q/%q, want %q/%q", + tc.ddl, timing, events, tc.wantTiming, tc.wantEvents) + } + }) + } +} diff --git a/internal/database/types.go b/internal/database/types.go index 5cc1a12..2708677 100644 --- a/internal/database/types.go +++ b/internal/database/types.go @@ -1,5 +1,7 @@ package database +import "strings" + type DriverType string const ( @@ -75,6 +77,89 @@ type TableInfo struct { Type string `json:"type"` } +type ObjectKind string + +const ( + ObjectTable ObjectKind = "table" + ObjectView ObjectKind = "view" + ObjectMatView ObjectKind = "materialized view" + ObjectIndex ObjectKind = "index" + ObjectConstraint ObjectKind = "constraint" + ObjectTrigger ObjectKind = "trigger" + ObjectFunction ObjectKind = "function" + ObjectProcedure ObjectKind = "procedure" +) + +func (k ObjectKind) IsRelation() bool { + switch k { + case ObjectTable, ObjectView, ObjectMatView: + return true + } + return false +} + +func RelationKind(tableType string) ObjectKind { + switch ObjectKind(strings.ToLower(tableType)) { + case ObjectView: + return ObjectView + case ObjectMatView: + return ObjectMatView + } + return ObjectTable +} + +type IndexInfo struct { + Name string `json:"name"` + Schema string `json:"schema"` + Table string `json:"table"` + Columns []string `json:"columns"` + // IsPrimary marks the index backing a primary key, which has no standalone DDL. + IsPrimary bool `json:"isPrimary"` + IsUnique bool `json:"isUnique"` + Method string `json:"method,omitempty"` +} + +type ConstraintInfo struct { + Name string `json:"name"` + Schema string `json:"schema"` + Table string `json:"table"` + // Type is one of PRIMARY KEY, FOREIGN KEY, UNIQUE, CHECK. + Type string `json:"type"` + Columns []string `json:"columns"` + RefTable string `json:"refTable,omitempty"` + RefColumns []string `json:"refColumns,omitempty"` + // Definition is the engine's own rendering of the body, when it exposes one. + Definition string `json:"definition,omitempty"` +} + +type TriggerInfo struct { + Name string `json:"name"` + Schema string `json:"schema"` + Table string `json:"table"` + // Timing is BEFORE / AFTER / INSTEAD OF; Events is the comma-joined event list. + Timing string `json:"timing,omitempty"` + Events string `json:"events,omitempty"` +} + +type RoutineInfo struct { + Name string `json:"name"` + Schema string `json:"schema"` + // Kind is ObjectFunction or ObjectProcedure. + Kind ObjectKind `json:"kind"` + ReturnType string `json:"returnType,omitempty"` + // Args is the argument list without parentheses; it disambiguates overloads. + Args string `json:"args,omitempty"` +} + +// ObjectRef names the parent relation in Table for index / constraint / trigger kinds only. +type ObjectRef struct { + Schema string `json:"schema"` + Name string `json:"name"` + Kind ObjectKind `json:"kind"` + Table string `json:"table,omitempty"` + Args string `json:"args,omitempty"` +} + type SchemaInfo struct { Name string `json:"name"` } From 96475e65c2689247c84130d834c94225cf4d1a3e Mon Sep 17 00:00:00 2001 From: Bare7a Date: Thu, 6 Aug 2026 13:37:13 +0300 Subject: [PATCH 2/5] Fixed Tests --- e2e/pages/schema-page.ts | 12 --- e2e/specs/sidebar/object-ddl.spec.ts | 2 - .../xensql/internal/database/models.ts | 16 +--- frontend/src/features/editor/EditorTabBar.tsx | 6 +- .../features/editor/lib/tabKindIcon.test.ts | 72 -------------- .../src/features/editor/lib/tabKindIcon.ts | 31 ------ .../src/features/layout/QuickSearchDialog.tsx | 17 ++-- frontend/src/features/sidebar/SchemaPanel.tsx | 2 - .../src/features/sidebar/SchemaTableRow.tsx | 36 ++++--- .../src/features/sidebar/SchemaTreeNode.tsx | 10 +- .../sidebar/lib/schemaObjects.test.ts | 10 -- .../src/features/sidebar/lib/schemaObjects.ts | 24 +---- frontend/src/shared/lib/normalize.test.ts | 1 - frontend/src/shared/lib/objectIcon.test.ts | 96 +++++++++++++++++++ frontend/src/shared/lib/objectIcon.ts | 57 +++++++++++ frontend/src/styles/tree.css | 5 +- frontend/src/types/index.ts | 5 +- internal/app/app_schema.go | 2 +- internal/app/e2e_object_ddl_test.go | 14 +-- internal/database/ddl.go | 3 +- internal/database/driver.go | 4 +- internal/database/mysql/schema.go | 45 +++++---- internal/database/mysql/schema_test.go | 48 ++++++++++ internal/database/postgres/schema.go | 34 +++++-- internal/database/postgres/schema_test.go | 16 ++++ internal/database/sqlite/schema.go | 11 +-- internal/database/sqlite/schema_test.go | 1 - internal/database/types.go | 27 +++--- 28 files changed, 345 insertions(+), 262 deletions(-) delete mode 100644 frontend/src/features/editor/lib/tabKindIcon.test.ts delete mode 100644 frontend/src/features/editor/lib/tabKindIcon.ts create mode 100644 frontend/src/shared/lib/objectIcon.test.ts create mode 100644 frontend/src/shared/lib/objectIcon.ts create mode 100644 internal/database/mysql/schema_test.go diff --git a/e2e/pages/schema-page.ts b/e2e/pages/schema-page.ts index fa873c5..394c147 100644 --- a/e2e/pages/schema-page.ts +++ b/e2e/pages/schema-page.ts @@ -1,6 +1,5 @@ import { expect, type Locator, type Page } from '@playwright/test'; -/** Mirrors the frontend's SchemaObjectGroup. */ type SchemaObjectGroup = 'indexes' | 'constraints' | 'triggers'; /** The sidebar schema browser: refresh, expand schemas/tables and inspect columns. */ @@ -93,13 +92,10 @@ export class SchemaPage { await this.columnRow(column).click(); } - // ── Object groups (indexes / constraints / triggers / functions) ─────────── - /** A table's group header row; the table must already be expanded. */ groupRow(group: SchemaObjectGroup): Locator { return this.page.getByTestId(`schema-group-${group}`); } - /** Present only once the group has been expanded. */ groupRows(group: SchemaObjectGroup): Locator { return this.page.getByTestId(`schema-group-${group}-row`); } @@ -108,46 +104,38 @@ export class SchemaPage { return this.page.locator(`[data-testid="schema-group-${group}-row"][data-object="${name}"]`); } - /** Expand a table and one of its groups, waiting for the lazily-loaded rows. */ async expandGroup(table: string, group: SchemaObjectGroup): Promise { await this.expandColumns(table); const header = this.groupRow(group).first(); await header.waitFor({ state: 'visible' }); await header.click(); - // An empty group renders a placeholder instead of rows. await expect(this.groupRows(group).first().or(this.page.locator('.tree-children .text-muted').first())).toBeVisible( { timeout: 30_000 }, ); } - /** Expand the schema-level Functions group. */ async expandRoutines(): Promise { const header = this.page.getByTestId('schema-group-routines').first(); await header.waitFor({ state: 'visible' }); await header.click(); } - // ── DDL ─────────────────────────────────────────────────────────────────── - /** Table context menu β†’ "Copy DDL". */ async copyTableDDL(table: string): Promise { await this.openTableMenu(table); await this.page.getByRole('menuitem', { name: 'Copy DDL', exact: true }).click(); } - /** Table context menu β†’ "Open DDL in new tab". */ async openTableDDLInTab(table: string): Promise { await this.openTableMenu(table); await this.page.getByRole('menuitem', { name: 'Open DDL in new tab', exact: true }).click(); } - /** Object-row context menu β†’ "Copy DDL". */ async copyObjectDDL(group: SchemaObjectGroup, name: string): Promise { 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(); } - /** Reads the clipboard; the caller must have granted clipboard permissions. */ async clipboardText(): Promise { return this.page.evaluate(() => navigator.clipboard.readText()); } diff --git a/e2e/specs/sidebar/object-ddl.spec.ts b/e2e/specs/sidebar/object-ddl.spec.ts index dd5b6af..53a5e80 100644 --- a/e2e/specs/sidebar/object-ddl.spec.ts +++ b/e2e/specs/sidebar/object-ddl.spec.ts @@ -1,7 +1,6 @@ import { POSTGRES } from '@support/databases'; import { expect, test } from '@support/fixtures'; -// Clipboard reads need explicit permission; granted once for the whole file. test.use({ permissions: ['clipboard-read', 'clipboard-write'] }); test.describe('Object DDL and the deeper schema tree', () => { @@ -56,7 +55,6 @@ test.describe('Object DDL and the deeper schema tree', () => { expect(ddl).toContain(`CREATE TABLE`); expect(ddl).toContain(table); expect(ddl).toContain('email'); - // Proves the composed Postgres statement carries more than column names. expect(ddl).toContain('NOT NULL'); expect(ddl).toContain('PRIMARY KEY'); }).toPass({ timeout: 15_000 }); diff --git a/frontend/bindings/xensql/internal/database/models.ts b/frontend/bindings/xensql/internal/database/models.ts index 01edea6..5433f2f 100644 --- a/frontend/bindings/xensql/internal/database/models.ts +++ b/frontend/bindings/xensql/internal/database/models.ts @@ -158,7 +158,7 @@ export class ConstraintInfo { "refColumns"?: string[]; /** - * Definition is the engine's own rendering of the body, when it exposes one. + * Definition is the engine's own rendering, where it exposes one. */ "definition"?: string; @@ -262,10 +262,6 @@ export class IndexInfo { "schema": string; "table": string; "columns": string[]; - - /** - * IsPrimary marks the index backing a primary key, which has no standalone DDL. - */ "isPrimary": boolean; "isUnique": boolean; "method"?: string; @@ -324,7 +320,7 @@ export enum ObjectKind { }; /** - * ObjectRef names the parent relation in Table for index / constraint / trigger kinds only. + * Table is set for index / constraint / trigger kinds only. */ export class ObjectRef { "schema": string; @@ -552,16 +548,8 @@ export class QueryResult { export class RoutineInfo { "name": string; "schema": string; - - /** - * Kind is ObjectFunction or ObjectProcedure. - */ "kind": ObjectKind; "returnType"?: string; - - /** - * Args is the argument list without parentheses; it disambiguates overloads. - */ "args"?: string; /** Creates a new RoutineInfo instance. */ diff --git a/frontend/src/features/editor/EditorTabBar.tsx b/frontend/src/features/editor/EditorTabBar.tsx index 878c30c..d33e55a 100644 --- a/frontend/src/features/editor/EditorTabBar.tsx +++ b/frontend/src/features/editor/EditorTabBar.tsx @@ -2,8 +2,9 @@ import { Lock, Plus, X } from 'lucide-react'; import { forwardRef, memo } from 'react'; import { useTranslation } from 'react-i18next'; import { isSavedQueryTabDirty } from '@/features/editor/lib/savedQueryTab'; -import { iconForEditorTab } from '@/features/editor/lib/tabKindIcon'; import { cx } from '@/shared/lib/cx'; +import { iconForEditorTab, relationKindOf } from '@/shared/lib/objectIcon'; +import { useTablesMap } from '@/store/selectors'; import type { ConnectionConfig, EditorTab } from '@/types'; interface EditorTabBarProps { @@ -45,6 +46,7 @@ export const EditorTabBar = memo( ref, ) { const { t } = useTranslation(); + const tables = useTablesMap(); return (
@@ -53,7 +55,7 @@ export const EditorTabBar = memo( const tabReadOnly = !!conn?.readOnly; const tabDirty = isSavedQueryTabDirty(tab); const isActive = tab.id === activeTabId; - const TabIcon = iconForEditorTab(tab); + const TabIcon = iconForEditorTab(tab, relationKindOf(tables, tab)); const tabKindTooltip = tab.tableView ? t('tooltip.tableViewTab') : tab.savedQueryId diff --git a/frontend/src/features/editor/lib/tabKindIcon.test.ts b/frontend/src/features/editor/lib/tabKindIcon.test.ts deleted file mode 100644 index d4eca50..0000000 --- a/frontend/src/features/editor/lib/tabKindIcon.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { Bookmark, Database, File, Table2 } from 'lucide-react'; -import { describe, expect, it } from 'vitest'; -import { iconForEditorTab, iconForQuickSearchKind, tabKindOf } from '@/features/editor/lib/tabKindIcon'; -import type { EditorTab } from '@/types'; - -function tab(overrides: Partial = {}): EditorTab { - return { - id: 'tab-1', - connectionId: 'conn-1', - title: 'Query 1', - sql: 'SELECT 1', - color: '#abc', - ...overrides, - }; -} - -describe('tabKindOf', () => { - it('returns sql for a plain editor tab', () => { - expect(tabKindOf(tab())).toBe('sql'); - }); - - it('returns table when tableView is set', () => { - expect(tabKindOf(tab({ tableView: { schema: 'public', table: 'users' } }))).toBe('table'); - }); - - it('returns saved when savedQueryId is set', () => { - expect(tabKindOf(tab({ savedQueryId: 'sq-1' }))).toBe('saved'); - }); - - it('prefers table over saved when both are present', () => { - expect( - tabKindOf( - tab({ - savedQueryId: 'sq-1', - tableView: { schema: 'public', table: 'users' }, - }), - ), - ).toBe('table'); - }); -}); - -describe('iconForEditorTab', () => { - it('maps sql tabs to File', () => { - expect(iconForEditorTab(tab())).toBe(File); - }); - - it('maps table view tabs to Table2', () => { - expect(iconForEditorTab(tab({ tableView: { schema: 'public', table: 'users' } }))).toBe(Table2); - }); - - it('maps saved query tabs to Bookmark', () => { - expect(iconForEditorTab(tab({ savedQueryId: 'sq-1' }))).toBe(Bookmark); - }); -}); - -describe('iconForQuickSearchKind', () => { - it('maps sql to File', () => { - expect(iconForQuickSearchKind('sql')).toBe(File); - }); - - it('maps table to Table2', () => { - expect(iconForQuickSearchKind('table')).toBe(Table2); - }); - - it('maps saved to Bookmark', () => { - expect(iconForQuickSearchKind('saved')).toBe(Bookmark); - }); - - it('maps conn to Database', () => { - expect(iconForQuickSearchKind('conn')).toBe(Database); - }); -}); diff --git a/frontend/src/features/editor/lib/tabKindIcon.ts b/frontend/src/features/editor/lib/tabKindIcon.ts deleted file mode 100644 index 7fc7947..0000000 --- a/frontend/src/features/editor/lib/tabKindIcon.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { Bookmark, Database, File, type LucideIcon, Table2 } from 'lucide-react'; -import type { EditorTab } from '@/types'; - -export type TabKind = 'sql' | 'table' | 'saved'; - -export type QuickSearchKind = TabKind | 'conn'; - -const TAB_KIND_ICON: Record = { - sql: File, - table: Table2, - saved: Bookmark, -}; - -const QUICK_SEARCH_KIND_ICON: Record = { - ...TAB_KIND_ICON, - conn: Database, -}; - -export function tabKindOf(tab: EditorTab): TabKind { - if (tab.tableView) return 'table'; - if (tab.savedQueryId) return 'saved'; - return 'sql'; -} - -export function iconForEditorTab(tab: EditorTab): LucideIcon { - return TAB_KIND_ICON[tabKindOf(tab)]; -} - -export function iconForQuickSearchKind(kind: QuickSearchKind): LucideIcon { - return QUICK_SEARCH_KIND_ICON[kind]; -} diff --git a/frontend/src/features/layout/QuickSearchDialog.tsx b/frontend/src/features/layout/QuickSearchDialog.tsx index ed7ab97..81a9c9d 100644 --- a/frontend/src/features/layout/QuickSearchDialog.tsx +++ b/frontend/src/features/layout/QuickSearchDialog.tsx @@ -1,11 +1,11 @@ import { type ReactNode, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { isSavedQueryOpenInTabs } from '@/features/editor/lib/savedQueryTab'; -import { iconForEditorTab, iconForQuickSearchKind } from '@/features/editor/lib/tabKindIcon'; import { isTableViewOpenInTabs } from '@/features/table-view/lib/tableViewTab'; import { useDebouncedValue } from '@/shared/hooks/useDebouncedValue'; import { rankCandidate } from '@/shared/lib/fuzzyMatch'; -import type { ConnectionConfig, EditorTab, SavedQuery, TableInfo } from '@/types'; +import { iconFor, iconForEditorTab, relationKindOf } from '@/shared/lib/objectIcon'; +import type { ConnectionConfig, EditorTab, ObjectKind, SavedQuery, TableInfo } from '@/types'; type QuickItem = { score: number; ranges: [number, number][] } & ( | { type: 'tab'; key: string; label: string; detail?: string; color: string; tab: EditorTab } @@ -18,6 +18,7 @@ type QuickItem = { score: number; ranges: [number, number][] } & ( connectionId: string; schema: string; table: string; + kind: ObjectKind; } | { type: 'saved'; key: string; label: string; detail?: string; color: string; saved: SavedQuery } | { @@ -56,9 +57,12 @@ const KIND_KEY: Record = { conn: 'quickSearch.kindConnection', }; -function iconForItem(item: QuickItem) { - if (item.type === 'tab') return iconForEditorTab(item.tab); - return iconForQuickSearchKind(item.type); +const QUICK_ITEM_ICON = { saved: 'savedQuery', conn: 'connection' } as const; + +function iconForItem(item: QuickItem, tables: Record) { + if (item.type === 'tab') return iconForEditorTab(item.tab, relationKindOf(tables, item.tab)); + if (item.type === 'table') return iconFor(item.kind); + return iconFor(QUICK_ITEM_ICON[item.type]); } function highlightLabel(text: string, ranges: [number, number][]): ReactNode { @@ -159,6 +163,7 @@ function QuickSearchContent({ connectionId, schema, table: tbl.name, + kind: (tbl.type || 'table') as ObjectKind, score: r.score, ranges: r.ranges, }); @@ -244,7 +249,7 @@ function QuickSearchContent({
{t('quickSearch.noResults')}
) : ( items.map((item, idx) => { - const Icon = iconForItem(item); + const Icon = iconForItem(item, tables); const kind = t(KIND_KEY[item.type]); return (
))} - {/* Collapsed until asked for, so the table-and-columns path costs no extra - round-trips. Hidden during a search, which is a column hunt. */} + {/* Collapsed until asked for; hidden during a search, which is a column hunt. */} {!schemaSearch && TABLE_GROUPS.map((group) => { const key = groupKey(connId, schemaName, table.name, group); @@ -166,8 +174,8 @@ export const SchemaTableRow = memo(function SchemaTableRow({ ; +const SchemaIcon = iconFor('schema'); +const RoutineIcon = iconFor('function'); +const ROUTINE_ICON = ; interface SchemaTreeNodeProps { connId: string; @@ -77,7 +80,7 @@ export function SchemaTreeNode({ onKeyDown={rowActivateKeyDown} > {schemaExpanded ? : } - + {sch.name} {allTables.length > 0 && ( @@ -124,7 +127,6 @@ export function SchemaTreeNode({ ); })} - {/* Hidden during a search, which is a table/column hunt. */} {!tablesLoading && !schemaSearch && ( { expect(routinesKey('c1', 'public')).toBe('c1:public:routines'); }); }); - -describe('isViewKind', () => { - it('treats plain and materialized views alike', () => { - expect(isViewKind('view')).toBe(true); - expect(isViewKind('materialized view')).toBe(true); - expect(isViewKind('table')).toBe(false); - expect(isViewKind('index')).toBe(false); - }); -}); diff --git a/frontend/src/features/sidebar/lib/schemaObjects.ts b/frontend/src/features/sidebar/lib/schemaObjects.ts index 32ecb40..79534f2 100644 --- a/frontend/src/features/sidebar/lib/schemaObjects.ts +++ b/frontend/src/features/sidebar/lib/schemaObjects.ts @@ -1,20 +1,10 @@ -import type { - ConstraintInfo, - IndexInfo, - ObjectKind, - ObjectRef, - RoutineInfo, - SchemaObjectGroup, - TriggerInfo, -} from '@/types'; +import type { ConstraintInfo, IndexInfo, ObjectRef, RoutineInfo, SchemaObjectGroup, TriggerInfo } from '@/types'; export type ObjectBadge = 'pk' | 'fk' | 'unique' | 'check' | 'index' | 'function' | 'procedure'; -// The four backend shapes flattened so one component renders them all. export interface SchemaObjectRow { - /** Unique within its group; `name` alone isn't, since SQLite reports inline keys unnamed. */ + /** Unique within its group; `name` alone is not. */ key: string; - /** The object's own name, as the DDL lookup expects it. */ name: string; label: string; detail: string; @@ -35,7 +25,6 @@ export function indexRows(indexes: IndexInfo[]): SchemaObjectRow[] { })); } -// An unrecognised type (EXCLUDE) gets no badge rather than a wrong one. function constraintBadge(type: string): ObjectBadge | undefined { switch (type.toUpperCase()) { case 'PRIMARY KEY': @@ -56,7 +45,6 @@ function constraintDetail(c: ConstraintInfo): string { const target = c.refTable ? `${c.refTable}${cols(c.refColumns)}` : ''; return target ? `${cols(c.columns)} β†’ ${target}` : cols(c.columns); } - // A CHECK owns no key columns, so its body is all there is to show. if (c.type.toUpperCase() === 'CHECK') { return c.definition ?? ''; } @@ -65,10 +53,10 @@ function constraintDetail(c: ConstraintInfo): string { export function constraintRows(constraints: ConstraintInfo[]): SchemaObjectRow[] { return constraints.map((c) => ({ - // Two unnamed constraints of the same type can't cover the same columns, so this is unique. + // Unique: two unnamed constraints of one type cannot cover the same columns. key: c.name || `${c.type}:${c.columns.join(',')}`, name: c.name, - // SQLite's inline keys arrive unnamed; the type beats an empty row. + // SQLite's inline keys arrive unnamed. label: c.name || c.type, detail: constraintDetail(c), badge: constraintBadge(c.type), @@ -103,7 +91,3 @@ export const groupKey = (connectionId: string, schema: string, table: string, gr export const routinesKey = (connectionId: string, schema: string) => `${connectionId}:${schema}:routines`; export const TABLE_GROUPS: SchemaObjectGroup[] = ['indexes', 'constraints', 'triggers']; - -export function isViewKind(kind: ObjectKind): boolean { - return kind === 'view' || kind === 'materialized view'; -} diff --git a/frontend/src/shared/lib/normalize.test.ts b/frontend/src/shared/lib/normalize.test.ts index b71d16b..0158709 100644 --- a/frontend/src/shared/lib/normalize.test.ts +++ b/frontend/src/shared/lib/normalize.test.ts @@ -178,7 +178,6 @@ describe('normalizeQueryResult disambiguates duplicate columns', () => { }); }); -// Go omits empty slices over the Wails boundary, so list fields must survive arriving undefined. describe('schema object normalizers', () => { it('normalizeIndexes fills missing fields', () => { const [idx] = normalizeIndexes([{ name: 'i', schema: 'public', table: 'users', isUnique: true }]); diff --git a/frontend/src/shared/lib/objectIcon.test.ts b/frontend/src/shared/lib/objectIcon.test.ts new file mode 100644 index 0000000..49f734c --- /dev/null +++ b/frontend/src/shared/lib/objectIcon.test.ts @@ -0,0 +1,96 @@ +import { Bookmark, Database, File, FunctionSquare, Hash, KeyRound, Table2, View, Zap } from 'lucide-react'; +import { describe, expect, it } from 'vitest'; +import { iconFor, iconForEditorTab, isViewKind, relationKindOf } from '@/shared/lib/objectIcon'; +import type { EditorTab, TableInfo } from '@/types'; + +const tab = (over: Partial = {}): EditorTab => ({ + id: 'tab-1', + connectionId: 'conn-1', + title: 'Query 1', + sql: 'SELECT 1', + color: '#abc', + ...over, +}); + +const table = (name: string, type: string): TableInfo => ({ schema: 'public', name, type }); + +describe('iconFor', () => { + it('maps every object kind', () => { + expect(iconFor('table')).toBe(Table2); + expect(iconFor('view')).toBe(View); + expect(iconFor('materialized view')).toBe(View); + expect(iconFor('index')).toBe(Hash); + expect(iconFor('constraint')).toBe(KeyRound); + expect(iconFor('trigger')).toBe(Zap); + expect(iconFor('function')).toBe(FunctionSquare); + expect(iconFor('procedure')).toBe(FunctionSquare); + }); + + it('maps the app’s own concepts', () => { + expect(iconFor('connection')).toBe(Database); + expect(iconFor('query')).toBe(File); + expect(iconFor('savedQuery')).toBe(Bookmark); + }); + + it('falls back to a table for anything unrecognised', () => { + expect(iconFor(undefined)).toBe(Table2); + expect(iconFor(null)).toBe(Table2); + expect(iconFor('nonsense')).toBe(Table2); + }); +}); + +describe('relationKindOf', () => { + const tables = { + 'conn-1:public': [table('users', 'table'), table('active_users', 'view')], + }; + + it('reads the kind of the relation a tab is browsing', () => { + expect(relationKindOf(tables, tab({ tableView: { schema: 'public', table: 'active_users' } }))).toBe('view'); + expect(relationKindOf(tables, tab({ tableView: { schema: 'public', table: 'users' } }))).toBe('table'); + }); + + it('is undefined for a tab that browses nothing', () => { + expect(relationKindOf(tables, tab())).toBeUndefined(); + }); + + it('is undefined when that schema is not loaded yet', () => { + expect(relationKindOf({}, tab({ tableView: { schema: 'public', table: 'users' } }))).toBeUndefined(); + expect( + relationKindOf(tables, tab({ connectionId: 'conn-2', tableView: { schema: 'public', table: 'users' } })), + ).toBeUndefined(); + }); +}); + +describe('iconForEditorTab', () => { + it('gives a view-browsing tab the view icon', () => { + expect(iconForEditorTab(tab({ tableView: { schema: 'public', table: 'v' } }), 'view')).toBe(View); + }); + + it('gives a table-browsing tab the table icon', () => { + expect(iconForEditorTab(tab({ tableView: { schema: 'public', table: 't' } }), 'table')).toBe(Table2); + }); + + it('falls back to a table when the schema has not loaded', () => { + expect(iconForEditorTab(tab({ tableView: { schema: 'public', table: 't' } }))).toBe(Table2); + }); + + it('maps saved query and plain sql tabs', () => { + expect(iconForEditorTab(tab({ savedQueryId: 'sq-1' }))).toBe(Bookmark); + expect(iconForEditorTab(tab())).toBe(File); + }); + + it('prefers the browsed relation over a saved query id', () => { + expect(iconForEditorTab(tab({ savedQueryId: 'sq-1', tableView: { schema: 'public', table: 'v' } }), 'view')).toBe( + View, + ); + }); +}); + +describe('isViewKind', () => { + it('treats plain and materialized views alike', () => { + expect(isViewKind('view')).toBe(true); + expect(isViewKind('materialized view')).toBe(true); + expect(isViewKind('table')).toBe(false); + expect(isViewKind(undefined)).toBe(false); + }); +}); diff --git a/frontend/src/shared/lib/objectIcon.ts b/frontend/src/shared/lib/objectIcon.ts new file mode 100644 index 0000000..d637050 --- /dev/null +++ b/frontend/src/shared/lib/objectIcon.ts @@ -0,0 +1,57 @@ +import { + Bookmark, + Columns3, + Database, + File, + FolderOpen, + FunctionSquare, + Hash, + KeyRound, + type LucideIcon, + Table2, + View, + Zap, +} from 'lucide-react'; +import type { EditorTab, ObjectKind, TableInfo } from '@/types'; + +export type IconKind = ObjectKind | 'schema' | 'column' | 'connection' | 'query' | 'savedQuery'; + +// The single source of truth for object icons: the tree, Quick Search and the editor tabs all +// resolve through it. +const ICONS: Record = { + table: Table2, + view: View, + 'materialized view': View, + index: Hash, + constraint: KeyRound, + trigger: Zap, + function: FunctionSquare, + procedure: FunctionSquare, + schema: FolderOpen, + column: Columns3, + connection: Database, + query: File, + savedQuery: Bookmark, +}; + +export function iconFor(kind: IconKind | string | undefined | null): LucideIcon { + return (kind && ICONS[kind as IconKind]) || Table2; +} + +export function isViewKind(kind: ObjectKind | string | undefined): boolean { + return kind === 'view' || kind === 'materialized view'; +} + +// Resolved from the loaded schema rather than the tab, which persists only schema and table. +// Undefined until those tables load; callers fall back to a table. +export function relationKindOf(tables: Record, tab: EditorTab): ObjectKind | undefined { + if (!tab.tableView) return undefined; + const list = tables[`${tab.connectionId}:${tab.tableView.schema}`]; + return list?.find((t) => t.name === tab.tableView?.table)?.type as ObjectKind | undefined; +} + +export function iconForEditorTab(tab: EditorTab, relationKind?: ObjectKind): LucideIcon { + if (tab.tableView) return iconFor(relationKind ?? 'table'); + if (tab.savedQueryId) return iconFor('savedQuery'); + return iconFor('query'); +} diff --git a/frontend/src/styles/tree.css b/frontend/src/styles/tree.css index f800f13..36a5184 100644 --- a/frontend/src/styles/tree.css +++ b/frontend/src/styles/tree.css @@ -146,7 +146,6 @@ cursor: pointer; } -/* Object groups sit a step below their relations: same row mechanics, quieter type. */ .tree-item--group { font-size: var(--text-sm); color: var(--text-muted); @@ -160,12 +159,12 @@ cursor: default; } -/* Sized from its own text, so the name holds its width against the detail. */ +/* Sized from its own text so the name holds width against the detail. */ .tree-object .tree-column-name { flex: 1 1 auto; } -/* Yields space ~100x faster than the name, which stays readable. */ +/* Yields space ~100x faster than the name. */ .tree-object-detail { flex: 0 100 auto; min-width: 0; diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 792db2c..708442c 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -63,7 +63,6 @@ export interface SchemaInfo { name: string; } -// See database.ObjectKind. export type ObjectKind = | 'table' | 'view' @@ -79,7 +78,6 @@ export interface IndexInfo { schema: string; table: string; columns: string[]; - /** The index backing a primary key, which has no standalone DDL. */ isPrimary: boolean; isUnique: boolean; method?: string; @@ -110,11 +108,10 @@ export interface RoutineInfo { schema: string; kind: ObjectKind; returnType?: string; - /** Argument list without parentheses; disambiguates overloads. */ args?: string; } -// See database.ObjectRef. `table` names the parent relation for index/constraint/trigger kinds. +// `table` is set for index / constraint / trigger kinds only. export interface ObjectRef { schema: string; name: string; diff --git a/internal/app/app_schema.go b/internal/app/app_schema.go index 013734a..a9a7a1c 100644 --- a/internal/app/app_schema.go +++ b/internal/app/app_schema.go @@ -8,7 +8,7 @@ import ( "xensql/internal/database" ) -// schemaTimeout keeps a saturated pool from wedging the sidebar, which fires these lazily. +// The tree fires these lazily; a saturated pool must not wedge the sidebar. const schemaTimeout = 15 * time.Second func (a *App) schemaContext() (context.Context, context.CancelFunc) { diff --git a/internal/app/e2e_object_ddl_test.go b/internal/app/e2e_object_ddl_test.go index 656614a..1e23321 100644 --- a/internal/app/e2e_object_ddl_test.go +++ b/internal/app/e2e_object_ddl_test.go @@ -10,7 +10,6 @@ import ( "xensql/internal/database" ) -// createFunctionSQL returns a trivial function; MySQL needs DETERMINISTIC under binary logging. func createFunctionSQL(e engine, name string) string { if e.driver == database.DriverPostgres { return fmt.Sprintf(`CREATE FUNCTION %s(a int) RETURNS int LANGUAGE sql AS $$ SELECT a + 1 $$`, name) @@ -18,7 +17,6 @@ func createFunctionSQL(e engine, name string) string { return fmt.Sprintf(`CREATE FUNCTION %s(a INT) RETURNS INT DETERMINISTIC RETURN a + 1`, name) } -// createTriggerSQL returns AFTER UPDATE statements; Postgres needs a trigger function first. func createTriggerSQL(e engine, trigger, table, helperFn string) []string { target := qualified(e, table) if e.driver == database.DriverPostgres { @@ -55,8 +53,8 @@ func constraintOfType(constraints []database.ConstraintInfo, ctype string) (data return database.ConstraintInfo{}, false } -// TestE2EObjectDDL covers the catalog listings and GetObjectDDL per object class. The table DDL -// is verified by round-trip: drop it, replay the generated statements, check the shape returns. +// The table DDL is verified by round-trip: drop it, replay the generated statements, check the +// shape returns. func TestE2EObjectDDL(t *testing.T) { forEachEngine(t, func(t *testing.T, a *App, e engine, connID string) { parent := uniqueTable("ddl_orgs") @@ -165,8 +163,11 @@ func TestE2EObjectDDL(t *testing.T) { if err != nil { t.Fatalf("trigger DDL: %v", err) } - if !strings.Contains(strings.ToUpper(ddl), "CREATE TRIGGER") { - t.Errorf("trigger DDL should be a CREATE TRIGGER:\n%s", ddl) + // MySQL puts DEFINER=... between the two words. + upper := strings.ToUpper(ddl) + if !strings.HasPrefix(upper, "CREATE") || !strings.Contains(upper, "TRIGGER") || + !strings.Contains(ddl, trigger) { + t.Errorf("trigger DDL should create %q:\n%s", trigger, ddl) } }) @@ -253,7 +254,6 @@ func TestE2EObjectDDL(t *testing.T) { } }) - // Rebuilds the table from the generated DDL alone, so a malformed clause fails here. t.Run("TableDDLRoundTrips", func(t *testing.T) { ddl, err := a.GetObjectDDL(connID, database.ObjectRef{ Kind: database.ObjectTable, Schema: e.browseSchema, Name: child, diff --git a/internal/database/ddl.go b/internal/database/ddl.go index e0afac8..221abef 100644 --- a/internal/database/ddl.go +++ b/internal/database/ddl.go @@ -15,7 +15,7 @@ type DDLColumn struct { Collation string // Identity is "ALWAYS" or "BY DEFAULT" on an identity column. Identity string - // Generated is the expression of a stored generated column, which cannot also have a Default. + // Generated excludes Default; a generated column cannot have one. Generated string } @@ -42,7 +42,6 @@ func RenderColumn(driver DriverType, col DDLColumn) string { return b.String() } -// ComposeCreateTable takes already-rendered constraint clauses, e.g. `CONSTRAINT "pk" PRIMARY KEY ("id")`. func ComposeCreateTable(driver DriverType, schema, table string, cols []DDLColumn, tableConstraints []string) string { lines := make([]string, 0, len(cols)+len(tableConstraints)) for _, col := range cols { diff --git a/internal/database/driver.go b/internal/database/driver.go index 1b2d5cc..b01d37f 100644 --- a/internal/database/driver.go +++ b/internal/database/driver.go @@ -30,10 +30,8 @@ type Session interface { ListIndexes(ctx context.Context, schema, table string) ([]IndexInfo, error) ListConstraints(ctx context.Context, schema, table string) ([]ConstraintInfo, error) ListTriggers(ctx context.Context, schema, table string) ([]TriggerInfo, error) - // ListRoutines returns the schema's functions and procedures; empty where there are none. ListRoutines(ctx context.Context, schema string) ([]RoutineInfo, error) - // ObjectDDL renders the object's CREATE statement, verbatim where the engine stores it and - // composed from the catalog otherwise. + // Verbatim where the engine stores the statement, composed from the catalog otherwise. ObjectDDL(ctx context.Context, ref ObjectRef) (string, error) QueryTable(ctx context.Context, req TableDataRequest) (*QueryResult, error) QueryTableStream(ctx context.Context, req TableDataRequest, opts StreamOpts) (*QueryResult, error) diff --git a/internal/database/mysql/schema.go b/internal/database/mysql/schema.go index 2201503..b0ca87a 100644 --- a/internal/database/mysql/schema.go +++ b/internal/database/mysql/schema.go @@ -25,7 +25,6 @@ func (s *Session) ListIndexes(ctx context.Context, schema, table string) ([]data for rows.Next() { var name, indexType string var nonUnique int - // NULL for a functional index part (MySQL 8.0.13+), which has an expression, not a column. var column sql.NullString if err := rows.Scan(&name, &nonUnique, &indexType, &column); err != nil { return nil, err @@ -77,7 +76,6 @@ func (s *Session) ListConstraints(ctx context.Context, schema, table string) ([] grouped := map[string]*database.ConstraintInfo{} for rows.Next() { var name, ctype string - // All NULL for a CHECK constraint, which owns no key columns. var column, refTable, refColumn sql.NullString if err := rows.Scan(&name, &ctype, &column, &refTable, &refColumn); err != nil { return nil, err @@ -118,8 +116,7 @@ func (s *Session) ListConstraints(ctx context.Context, schema, table string) ([] return out, nil } -// checkClauses maps constraint name to CHECK body. The table only exists on MySQL 8.0.16+ and -// MariaDB 10.2.22+ with differing columns, so a failure degrades to no clauses. +// The table only exists on MySQL 8.0.16+ / MariaDB 10.2.22+, so a failure degrades to no clauses. func (s *Session) checkClauses(ctx context.Context, schema string) map[string]string { rows, err := s.DB.QueryContext(ctx, ` SELECT CONSTRAINT_NAME, CHECK_CLAUSE @@ -200,15 +197,16 @@ func (s *Session) ObjectDDL(ctx context.Context, ref database.ObjectRef) (string qualified := database.BuildQualifiedTable(database.DriverMySQL, schema, ref.Name) switch ref.Kind { case database.ObjectTable: - return s.showCreate(ctx, "SHOW CREATE TABLE "+qualified) + return s.showCreate(ctx, "SHOW CREATE TABLE "+qualified, "Create Table") case database.ObjectView: - return s.showCreate(ctx, "SHOW CREATE VIEW "+qualified) + return s.showCreate(ctx, "SHOW CREATE VIEW "+qualified, "Create View") case database.ObjectTrigger: - return s.showCreate(ctx, "SHOW CREATE TRIGGER "+qualified) + // Not a "Create *" column, and the row also carries a "Created" timestamp. + return s.showCreate(ctx, "SHOW CREATE TRIGGER "+qualified, "SQL Original Statement") case database.ObjectFunction: - return s.showCreate(ctx, "SHOW CREATE FUNCTION "+qualified) + return s.showCreate(ctx, "SHOW CREATE FUNCTION "+qualified, "Create Function") case database.ObjectProcedure: - return s.showCreate(ctx, "SHOW CREATE PROCEDURE "+qualified) + return s.showCreate(ctx, "SHOW CREATE PROCEDURE "+qualified, "Create Procedure") case database.ObjectIndex: return s.indexDDL(ctx, schema, ref) case database.ObjectConstraint: @@ -217,6 +215,22 @@ func (s *Session) ObjectDDL(ctx context.Context, ref database.ObjectRef) (string return "", database.ErrUnsupportedDDL(database.DriverMySQL, ref.Kind) } +// The fallback skips "Created", which is a timestamp, not DDL. +func columnIndex(cols []string, defColumn string) int { + for i, c := range cols { + if strings.EqualFold(c, defColumn) { + return i + } + } + for i, c := range cols { + lc := strings.ToLower(c) + if strings.HasPrefix(lc, "create") && lc != "created" { + return i + } + } + return -1 +} + // indexDDL synthesizes CREATE INDEX; MySQL has no SHOW CREATE INDEX. func (s *Session) indexDDL(ctx context.Context, schema string, ref database.ObjectRef) (string, error) { indexes, err := s.ListIndexes(ctx, schema, ref.Table) @@ -258,8 +272,7 @@ func (s *Session) constraintDDL(ctx context.Context, schema string, ref database return "", fmt.Errorf("constraint %s not found on %s", ref.Name, ref.Table) } -// showCreate locates the definition column by name; the result shape differs per object type. -func (s *Session) showCreate(ctx context.Context, stmt string) (string, error) { +func (s *Session) showCreate(ctx context.Context, stmt, defColumn string) (string, error) { rows, err := s.DB.QueryContext(ctx, stmt) if err != nil { return "", err @@ -269,15 +282,9 @@ func (s *Session) showCreate(ctx context.Context, stmt string) (string, error) { if err != nil { return "", err } - target := -1 - for i, c := range cols { - if strings.HasPrefix(strings.ToLower(c), "create") { - target = i - break - } - } + target := columnIndex(cols, defColumn) if target < 0 { - return "", fmt.Errorf("no definition column in %s", stmt) + return "", fmt.Errorf("no %q column in %s", defColumn, stmt) } if !rows.Next() { if err := rows.Err(); err != nil { diff --git a/internal/database/mysql/schema_test.go b/internal/database/mysql/schema_test.go new file mode 100644 index 0000000..095355e --- /dev/null +++ b/internal/database/mysql/schema_test.go @@ -0,0 +1,48 @@ +package mysql + +import "testing" + +func TestColumnIndex(t *testing.T) { + table := []string{"Table", "Create Table"} + view := []string{"View", "Create View", "character_set_client", "collation_connection"} + trigger := []string{ + "Trigger", "sql_mode", "SQL Original Statement", + "character_set_client", "collation_connection", "Database Collation", "Created", + } + routine := []string{ + "Procedure", "sql_mode", "Create Procedure", + "character_set_client", "collation_connection", "Database Collation", + } + + tests := []struct { + name string + cols []string + want int + def string + }{ + {"table", table, 1, "Create Table"}, + {"view", view, 1, "Create View"}, + // Picking the "Created" column returned a date instead of the statement. + {"trigger", trigger, 2, "SQL Original Statement"}, + {"procedure", routine, 2, "Create Procedure"}, + {"name match is case-insensitive", table, 1, "CREATE TABLE"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := columnIndex(tc.cols, tc.def); got != tc.want { + t.Errorf("columnIndex(%v, %q) = %d, want %d", tc.cols, tc.def, got, tc.want) + } + }) + } +} + +func TestColumnIndexFallbackSkipsCreatedTimestamp(t *testing.T) { + cols := []string{"Trigger", "sql_mode", "Create Trigger", "Created"} + if got := columnIndex(cols, "Renamed Column"); got != 2 { + t.Errorf("fallback picked %d, want 2 (the Create* column, not Created)", got) + } + + if got := columnIndex([]string{"Trigger", "Created"}, "Nope"); got != -1 { + t.Errorf("columnIndex = %d, want -1", got) + } +} diff --git a/internal/database/postgres/schema.go b/internal/database/postgres/schema.go index bc83779..3ffa059 100644 --- a/internal/database/postgres/schema.go +++ b/internal/database/postgres/schema.go @@ -11,7 +11,6 @@ import ( func (s *Session) ListIndexes(ctx context.Context, schema, table string) ([]database.IndexInfo, error) { schema = s.SchemaOr(schema) - // One row per index column rather than an aggregate, so nothing has to scan a Postgres array. rows, err := s.DB.QueryContext(ctx, ` SELECT i.relname, ix.indisunique, ix.indisprimary, am.amname, a.attname FROM pg_catalog.pg_index ix @@ -32,7 +31,6 @@ func (s *Session) ListIndexes(ctx context.Context, schema, table string) ([]data for rows.Next() { var name, method string var unique, primary bool - // NULL for an expression part, whose indkey entry is 0 and matches no attribute. var column sql.NullString if err := rows.Scan(&name, &unique, &primary, &method, &column); err != nil { return nil, err @@ -101,7 +99,6 @@ func (s *Session) ListConstraints(ctx context.Context, schema, table string) ([] grouped := map[string]*database.ConstraintInfo{} for rows.Next() { var name, contype, def, refTable string - // NULL on a CHECK constraint, which references no key column. var column, refColumn sql.NullString if err := rows.Scan(&name, &contype, &def, &refTable, &column, &refColumn); err != nil { return nil, err @@ -295,7 +292,6 @@ func (s *Session) constraintDDL(ctx context.Context, schema string, ref database strings.TrimSuffix(def, ";")), nil } -// routineDDL matches on the identity argument list, resolving overloads. func (s *Session) routineDDL(ctx context.Context, schema string, ref database.ObjectRef) (string, error) { return s.scalarDDL(ctx, ` SELECT pg_catalog.pg_get_functiondef(p.oid) @@ -337,7 +333,7 @@ func (s *Session) tableDDL(ctx context.Context, schema, table string) (string, e return "", err } for _, idx := range indexes { - // A constraint's backing index shares its name and is already covered by the clause above. + // Shares the constraint name; already covered by the clause above. if constrained[idx.Name] { continue } @@ -353,8 +349,20 @@ func (s *Session) tableDDL(ctx context.Context, schema, table string) (string, e return database.JoinDDL(append(blocks, comments)...), nil } +func serialTypeFor(dtype string) string { + switch dtype { + case "smallint": + return "smallserial" + case "integer": + return "serial" + case "bigint": + return "bigserial" + } + return "" +} + func (s *Session) ddlColumns(ctx context.Context, schema, table string) ([]database.DDLColumn, error) { - // The collation join drops the type's own default, so only an explicit override emits COLLATE. + // Only an explicit override emits COLLATE. rows, err := s.DB.QueryContext(ctx, ` SELECT a.attname, pg_catalog.format_type(a.atttypid, a.atttypmod), @@ -362,7 +370,10 @@ func (s *Session) ddlColumns(ctx context.Context, schema, table string) ([]datab COALESCE(pg_catalog.pg_get_expr(ad.adbin, ad.adrelid), ''), a.attidentity, a.attgenerated, - COALESCE(co.collname, '') + COALESCE(co.collname, ''), + pg_catalog.pg_get_serial_sequence( + pg_catalog.quote_ident(n.nspname) || '.' || pg_catalog.quote_ident(c.relname), + a.attname) IS NOT NULL FROM pg_catalog.pg_attribute a JOIN pg_catalog.pg_class c ON c.oid = a.attrelid JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace @@ -380,8 +391,8 @@ func (s *Session) ddlColumns(ctx context.Context, schema, table string) ([]datab var cols []database.DDLColumn for rows.Next() { var name, dtype, defaultExpr, identity, generated, collation string - var notNull bool - if err := rows.Scan(&name, &dtype, ¬Null, &defaultExpr, &identity, &generated, &collation); err != nil { + var notNull, isSerial bool + if err := rows.Scan(&name, &dtype, ¬Null, &defaultExpr, &identity, &generated, &collation, &isSerial); err != nil { return nil, err } col := database.DDLColumn{ @@ -389,12 +400,15 @@ func (s *Session) ddlColumns(ctx context.Context, schema, table string) ([]datab } switch { case generated == "s": - // A stored generated column keeps its expression in pg_attrdef, not as a DEFAULT. + // Its expression lives in pg_attrdef, not as a DEFAULT. col.Generated = defaultExpr case identity == "a": col.Identity = "ALWAYS" case identity == "d": col.Identity = "BY DEFAULT" + case isSerial && serialTypeFor(dtype) != "": + // The sequence is dropped with the table, so a nextval() default would not replay. + col.Type = serialTypeFor(dtype) default: col.Default = defaultExpr } diff --git a/internal/database/postgres/schema_test.go b/internal/database/postgres/schema_test.go index 42eae8c..0b6e2ba 100644 --- a/internal/database/postgres/schema_test.go +++ b/internal/database/postgres/schema_test.go @@ -62,6 +62,22 @@ func TestDecodeTriggerType(t *testing.T) { } } +func TestSerialTypeFor(t *testing.T) { + tests := []struct{ in, want string }{ + {"smallint", "smallserial"}, + {"integer", "serial"}, + {"bigint", "bigserial"}, + {"text", ""}, + {"numeric", ""}, + {"", ""}, + } + for _, tc := range tests { + if got := serialTypeFor(tc.in); got != tc.want { + t.Errorf("serialTypeFor(%q) = %q, want %q", tc.in, got, tc.want) + } + } +} + func TestQuoteLiteral(t *testing.T) { tests := []struct{ in, want string }{ {"plain", "'plain'"}, diff --git a/internal/database/sqlite/schema.go b/internal/database/sqlite/schema.go index ce1b780..dc961d5 100644 --- a/internal/database/sqlite/schema.go +++ b/internal/database/sqlite/schema.go @@ -65,7 +65,6 @@ func (s *Session) indexList(ctx context.Context, table string) ([]sqliteIndexEnt return out, rows.Err() } -// indexColumns skips expression parts, whose name PRAGMA index_info reports as NULL. func (s *Session) indexColumns(ctx context.Context, index string) ([]string, error) { rows, err := s.DB.QueryContext(ctx, fmt.Sprintf("PRAGMA index_info(%s)", database.QuoteIdent(database.DriverSQLite, index))) @@ -87,7 +86,7 @@ func (s *Session) indexColumns(ctx context.Context, index string) ([]string, err return cols, rows.Err() } -// ListConstraints reads the pragmas; CHECK has none, so it appears only in the table's own DDL. +// CHECK has no pragma, so it appears only in the table's own DDL. func (s *Session) ListConstraints(ctx context.Context, schema, table string) ([]database.ConstraintInfo, error) { cols, err := s.ListColumns(ctx, schema, table) if err != nil { @@ -124,7 +123,6 @@ func (s *Session) ListConstraints(ctx context.Context, schema, table string) ([] return append(out, fks...), nil } -// foreignKeyConstraints groups rows by id, so a composite key is one constraint. func (s *Session) foreignKeyConstraints(ctx context.Context, table string) ([]database.ConstraintInfo, error) { rows, err := s.DB.QueryContext(ctx, fmt.Sprintf("PRAGMA foreign_key_list(%s)", database.QuoteIdent(database.DriverSQLite, table))) @@ -188,7 +186,6 @@ func (s *Session) ListTriggers(ctx context.Context, schema, table string) ([]dat return out, rows.Err() } -// sqliteTriggerHead captures the text between the trigger name and ON, where the keywords live. var sqliteTriggerHead = regexp.MustCompile(`(?is)\bCREATE\s+(?:TEMP(?:ORARY)?\s+)?TRIGGER\s+(?:IF\s+NOT\s+EXISTS\s+)?(.*?)\s+ON\s`) // parseTriggerHead reads timing and event from a stored CREATE TRIGGER; SQLite defaults to BEFORE. @@ -229,7 +226,7 @@ func (s *Session) ObjectDDL(ctx context.Context, ref database.ObjectRef) (string return "", database.ErrUnsupportedDDL(database.DriverSQLite, ref.Kind) } -// relationDDL appends the table's standalone indexes, which SQLite stores as separate statements. +// SQLite stores standalone indexes as separate statements. func (s *Session) relationDDL(ctx context.Context, ref database.ObjectRef) (string, error) { base, err := s.masterDDL(ctx, string(ref.Kind), ref.Name) if err != nil { @@ -260,7 +257,7 @@ func (s *Session) relationDDL(ctx context.Context, ref database.ObjectRef) (stri return database.JoinDDL(blocks...), nil } -// indexDDL synthesizes a statement for implicit indexes, which sqlite_master stores with NULL sql. +// Implicit indexes are stored with a NULL sql, so they are synthesized. func (s *Session) indexDDL(ctx context.Context, ref database.ObjectRef) (string, error) { ddl, err := s.masterDDL(ctx, "index", ref.Name) if err == nil && ddl != "" { @@ -282,7 +279,7 @@ func (s *Session) indexDDL(ctx context.Context, ref database.ObjectRef) (string, return "", err } -// masterDDL reads an object's original statement text, empty when sql is NULL. +// Empty, not an error, when sql is NULL. func (s *Session) masterDDL(ctx context.Context, objType, name string) (string, error) { var ddl sql.NullString err := s.DB.QueryRowContext(ctx, diff --git a/internal/database/sqlite/schema_test.go b/internal/database/sqlite/schema_test.go index dab49d4..2c5f1db 100644 --- a/internal/database/sqlite/schema_test.go +++ b/internal/database/sqlite/schema_test.go @@ -100,7 +100,6 @@ func TestListConstraints(t *testing.T) { } } -// A composite key spans several PRAGMA rows sharing one id and must collapse to one constraint. func TestListConstraintsGroupsCompositeForeignKey(t *testing.T) { s := newTestSession(t) ctx := context.Background() diff --git a/internal/database/types.go b/internal/database/types.go index 2708677..3cf881a 100644 --- a/internal/database/types.go +++ b/internal/database/types.go @@ -109,14 +109,13 @@ func RelationKind(tableType string) ObjectKind { } type IndexInfo struct { - Name string `json:"name"` - Schema string `json:"schema"` - Table string `json:"table"` - Columns []string `json:"columns"` - // IsPrimary marks the index backing a primary key, which has no standalone DDL. - IsPrimary bool `json:"isPrimary"` - IsUnique bool `json:"isUnique"` - Method string `json:"method,omitempty"` + Name string `json:"name"` + Schema string `json:"schema"` + Table string `json:"table"` + Columns []string `json:"columns"` + IsPrimary bool `json:"isPrimary"` + IsUnique bool `json:"isUnique"` + Method string `json:"method,omitempty"` } type ConstraintInfo struct { @@ -128,7 +127,7 @@ type ConstraintInfo struct { Columns []string `json:"columns"` RefTable string `json:"refTable,omitempty"` RefColumns []string `json:"refColumns,omitempty"` - // Definition is the engine's own rendering of the body, when it exposes one. + // Definition is the engine's own rendering, where it exposes one. Definition string `json:"definition,omitempty"` } @@ -142,16 +141,14 @@ type TriggerInfo struct { } type RoutineInfo struct { - Name string `json:"name"` - Schema string `json:"schema"` - // Kind is ObjectFunction or ObjectProcedure. + Name string `json:"name"` + Schema string `json:"schema"` Kind ObjectKind `json:"kind"` ReturnType string `json:"returnType,omitempty"` - // Args is the argument list without parentheses; it disambiguates overloads. - Args string `json:"args,omitempty"` + Args string `json:"args,omitempty"` } -// ObjectRef names the parent relation in Table for index / constraint / trigger kinds only. +// Table is set for index / constraint / trigger kinds only. type ObjectRef struct { Schema string `json:"schema"` Name string `json:"name"` From e6b8bb6801838a19e3a7bb4d3dac946823faabdd Mon Sep 17 00:00:00 2001 From: Bare7a Date: Thu, 6 Aug 2026 14:52:34 +0300 Subject: [PATCH 3/5] Updated E2E schema-page.ts file --- e2e/pages/schema-page.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/e2e/pages/schema-page.ts b/e2e/pages/schema-page.ts index 394c147..f82a878 100644 --- a/e2e/pages/schema-page.ts +++ b/e2e/pages/schema-page.ts @@ -104,10 +104,14 @@ export class SchemaPage { return this.page.locator(`[data-testid="schema-group-${group}-row"][data-object="${name}"]`); } - async expandGroup(table: string, group: SchemaObjectGroup): Promise { - await this.expandColumns(table); +async expandGroup(table: string, group: SchemaObjectGroup): Promise { const header = this.groupRow(group).first(); - await header.waitFor({ state: 'visible' }); + + 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 }, From 2857c154cfbfd0d15ef0a3b79574db37875fda6d Mon Sep 17 00:00:00 2001 From: Bare7a Date: Thu, 6 Aug 2026 15:00:20 +0300 Subject: [PATCH 4/5] Updated E2E tests --- e2e/specs/sidebar/object-ddl.spec.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/e2e/specs/sidebar/object-ddl.spec.ts b/e2e/specs/sidebar/object-ddl.spec.ts index 53a5e80..34b5095 100644 --- a/e2e/specs/sidebar/object-ddl.spec.ts +++ b/e2e/specs/sidebar/object-ddl.spec.ts @@ -19,7 +19,12 @@ test.describe('Object DDL and the deeper schema tree', () => { await expect(schema.objectRow('indexes', `${child}_pkey`)).toContainText('PK'); await schema.expandGroup(child, 'constraints'); + + const pk = schema.objectRow('constraints', `${child}_pkey`); + await expect(pk).toContainText('PK'); + await expect(pk).toContainText('(id)'); await expect(schema.objectRow('constraints', `${child}_pkey`)).toContainText('PRIMARY KEY'); + const fk = schema.groupRows('constraints').filter({ hasText: 'FK' }).first(); await expect(fk).toContainText(parent); From dbea33a1af0dd8248e0b5fc65c7f8238c15b448a Mon Sep 17 00:00:00 2001 From: Bare7a Date: Thu, 6 Aug 2026 15:20:41 +0300 Subject: [PATCH 5/5] More E2E test fixes --- e2e/pages/schema-page.ts | 5 ++--- e2e/specs/sidebar/object-ddl.spec.ts | 14 ++++++-------- 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/e2e/pages/schema-page.ts b/e2e/pages/schema-page.ts index f82a878..ee1c2fa 100644 --- a/e2e/pages/schema-page.ts +++ b/e2e/pages/schema-page.ts @@ -104,14 +104,13 @@ export class SchemaPage { return this.page.locator(`[data-testid="schema-group-${group}-row"][data-object="${name}"]`); } -async expandGroup(table: string, group: SchemaObjectGroup): Promise { + async expandGroup(table: string, group: SchemaObjectGroup): Promise { 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 }, diff --git a/e2e/specs/sidebar/object-ddl.spec.ts b/e2e/specs/sidebar/object-ddl.spec.ts index 34b5095..0e45879 100644 --- a/e2e/specs/sidebar/object-ddl.spec.ts +++ b/e2e/specs/sidebar/object-ddl.spec.ts @@ -4,7 +4,7 @@ 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 }) => { + 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', { @@ -19,12 +19,10 @@ test.describe('Object DDL and the deeper schema tree', () => { 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)'); - await expect(schema.objectRow('constraints', `${child}_pkey`)).toContainText('PRIMARY KEY'); - const fk = schema.groupRows('constraints').filter({ hasText: 'FK' }).first(); await expect(fk).toContainText(parent); @@ -47,7 +45,7 @@ test.describe('Object DDL and the deeper schema tree', () => { await expect(schema.tableRow(table)).toHaveAttribute('data-object-kind', 'table'); }); - test('copies a table’s DDL to the clipboard', async ({ connections, schema, seed }) => { + 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)', @@ -65,7 +63,7 @@ test.describe('Object DDL and the deeper schema tree', () => { }).toPass({ timeout: 15_000 }); }); - test('opens a table’s DDL in a new editor tab', async ({ connections, editor, schema, seed, tabs }) => { + 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(); @@ -76,7 +74,7 @@ test.describe('Object DDL and the deeper schema tree', () => { await expect(editor.active.locator('.view-lines')).toContainText(table); }); - test('copies an index’s own DDL', async ({ connections, editor, schema, seed, app }) => { + 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`; @@ -93,7 +91,7 @@ test.describe('Object DDL and the deeper schema tree', () => { }).toPass({ timeout: 15_000 }); }); - test('lists schema functions', async ({ connections, editor, schema, app }) => { + 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 $$;`);