diff --git a/frontend/src/lib/components/apps/components/display/dbtable/dbFeatures.ts b/frontend/src/lib/components/apps/components/display/dbtable/dbFeatures.ts index bcc33f027c..9d7ca012a0 100644 --- a/frontend/src/lib/components/apps/components/display/dbtable/dbFeatures.ts +++ b/frontend/src/lib/components/apps/components/display/dbtable/dbFeatures.ts @@ -9,6 +9,7 @@ export type DbFeatures = { primaryKeys?: boolean defaultValues?: boolean schemas?: boolean + indexes?: boolean } export function getDbFeatures(dbInput: DbInput): Required { @@ -17,7 +18,10 @@ export function getDbFeatures(dbInput: DbInput): Required { primaryKeys: true, defaultValues: true, enforcedForeignKeys: true, - schemas: dbInput.type !== 'ducklake' && dbSupportsSchemas(dbInput.resourceType) + schemas: dbInput.type !== 'ducklake' && dbSupportsSchemas(dbInput.resourceType), + // Index management is currently PostgreSQL-only (backend markers reject + // other dialects). Structured as a flag so it can be widened later. + indexes: dbInput.type !== 'ducklake' && dbInput.resourceType === 'postgresql' } if (dbInput.type == 'ducklake') diff --git a/frontend/src/lib/components/apps/components/display/dbtable/queries/indexes.ts b/frontend/src/lib/components/apps/components/display/dbtable/queries/indexes.ts new file mode 100644 index 0000000000..98670e5fe1 --- /dev/null +++ b/frontend/src/lib/components/apps/components/display/dbtable/queries/indexes.ts @@ -0,0 +1,99 @@ +// Index management types and transforms. +// +// Indexes are managed imperatively (create / drop happen immediately), so unlike +// foreign keys we do not round-trip a structured column model back from the DB — +// the listing shows the canonical definition string returned by the database. +// Currently PostgreSQL-only; the backend markers reject other dialects. + +/** A single key of a to-be-created index. */ +export type IndexColumnInput = { + value: string + /** When true, `value` is an arbitrary SQL expression (e.g. `lower(email)`) + * rather than a plain column name. */ + isExpression?: boolean +} + +/** User-provided values for creating an index. */ +export type CreateIndexInput = { + name?: string + columns: IndexColumnInput[] + unique?: boolean + /** Index access method: btree (default) / hash / gin / gist / brin / spgist. */ + method?: string + /** Partial-index predicate (raw SQL, placed after WHERE). */ + where?: string + /** Covering (INCLUDE) columns. */ + include?: string[] + /** Build the index without locking the table against writes. */ + concurrent?: boolean +} + +/** Raw row shape returned by the LIST_INDEXES marker (PostgreSQL). */ +export type RawIndex = { + index_name: string + index_def: string + is_unique: boolean | string | number + is_primary: boolean | string | number + is_valid: boolean | string | number + method: string + size_bytes: number | string | null + backs_constraint: boolean | string | number +} + +/** Display model for an existing index. */ +export type DbIndex = { + name: string + definition: string + isUnique: boolean + isPrimary: boolean + isValid: boolean + method: string + sizeBytes: number + /** Backs a PK/unique constraint — cannot be dropped directly (read-only). */ + backsConstraint: boolean +} + +// Postgres booleans can arrive as true/false, 't'/'f', or 1/0 depending on how +// the driver serializes the result — coerce all of them. +function coerceBool(v: boolean | string | number | null | undefined): boolean { + if (typeof v === 'boolean') return v + if (typeof v === 'number') return v !== 0 + if (typeof v === 'string') return v === 't' || v.toLowerCase() === 'true' || v === '1' + return false +} + +function coerceNumber(v: number | string | null | undefined): number { + if (typeof v === 'number') return v + if (typeof v === 'string') { + const n = Number(v) + return Number.isFinite(n) ? n : 0 + } + return 0 +} + +export function transformIndexes(raw: RawIndex[]): DbIndex[] { + if (!raw || !Array.isArray(raw)) return [] + return raw.map((r) => ({ + name: r.index_name, + definition: r.index_def, + isUnique: coerceBool(r.is_unique), + isPrimary: coerceBool(r.is_primary), + isValid: coerceBool(r.is_valid), + method: r.method, + sizeBytes: coerceNumber(r.size_bytes), + backsConstraint: coerceBool(r.backs_constraint) + })) +} + +/** Human-readable byte size for display (e.g. "12 kB"). */ +export function formatIndexSize(bytes: number): string { + if (!bytes || bytes <= 0) return '0 B' + const units = ['B', 'kB', 'MB', 'GB', 'TB'] + let i = 0 + let n = bytes + while (n >= 1024 && i < units.length - 1) { + n /= 1024 + i++ + } + return `${i === 0 ? n : n.toFixed(1)} ${units[i]}` +} diff --git a/frontend/src/lib/components/dbOps.ts b/frontend/src/lib/components/dbOps.ts index 8a528fd8e3..fca84a83f0 100644 --- a/frontend/src/lib/components/dbOps.ts +++ b/frontend/src/lib/components/dbOps.ts @@ -18,6 +18,12 @@ import { transformSnowflakeForeignKeys, type RawForeignKey } from './apps/components/display/dbtable/queries/relationalKeys' +import { + transformIndexes, + type CreateIndexInput, + type DbIndex, + type RawIndex +} from './apps/components/display/dbtable/queries/indexes' export type IDbTableOps = { dbType: DbType @@ -282,6 +288,88 @@ export function dbSchemaOpsWithPreviewScripts({ } } +export type IDbIndexOps = { + listIndexes: (params: { tableKey: string; schema?: string }) => Promise + createIndex: (params: { + tableKey: string + schema?: string + values: CreateIndexInput + }) => Promise + previewCreateIndexSql: (params: { + tableKey: string + schema?: string + values: CreateIndexInput + }) => Promise + dropIndex: (params: { name: string; schema?: string; concurrent?: boolean }) => Promise +} + +export function dbIndexOpsWithPreviewScripts({ + workspace, + input +}: { + workspace: string + input: DbInput +}): IDbIndexOps { + const dbType = getDbType(input) + const dbArg = getDatabaseArg(input) + const language = getLanguageByResourceType(dbType) + + function makeMarker(op: string, payload: Record): string { + return `-- WM_INTERNAL_DB_${op} ${JSON.stringify(payload)}` + } + + function createIndexPayload({ + tableKey, + schema, + values + }: { + tableKey: string + schema?: string + values: CreateIndexInput + }): Record { + return { + name: values.name?.trim() || undefined, + table: tableKey, + schema, + columns: values.columns, + unique: values.unique ?? false, + method: values.method, + where: values.where?.trim() || undefined, + include: values.include ?? [], + concurrent: values.concurrent ?? false + } + } + + return { + listIndexes: async ({ tableKey, schema }) => { + const content = makeMarker('LIST_INDEXES', { table: tableKey, schema }) + const result = (await runScriptAndPollResult({ + workspace, + requestBody: { args: { ...dbArg }, language, content } + })) as RawIndex[] + return transformIndexes(result) + }, + createIndex: async ({ tableKey, schema, values }) => { + const content = makeMarker('CREATE_INDEX', createIndexPayload({ tableKey, schema, values })) + await runScriptAndPollResult({ + workspace, + requestBody: { args: { ...dbArg }, language, content } + }) + }, + previewCreateIndexSql: async ({ tableKey, schema, values }) => { + const content = makeMarker('CREATE_INDEX', createIndexPayload({ tableKey, schema, values })) + return expandMarker(workspace, language, content) + }, + dropIndex: async ({ name, schema, concurrent }) => { + const content = makeMarker('DROP_INDEX', { name, schema, concurrent: concurrent ?? false }) + await runScriptAndPollResult({ + workspace, + requestBody: { args: { ...dbArg }, language, content } + }) + } + } +} + export async function getDucklakeSchema({ workspace, ducklake