Database Schema Viewer
blockdevopsVisual database schema inspector and SQL query previewer with cluster health header, table navigation sidebar with row counts, columns metadata table (data types, nullability, defaults, PK/FK indicators), foreign keys diagram, B-Tree indexes, and syntax-styled DDL preview with copy actions.
Installation
$pnpm dlx uipkge-ng@latest add database-schema-viewer$npx uipkge-ng@latest add database-schema-viewer$yarn dlx uipkge-ng@latest add database-schema-viewer$bunx uipkge-ng@latest add database-schema-viewerManifest:
https://uipkge.dev/r/angular/database-schema-viewer.jsonInstalls to:src/app/components/blocks/database-schema-viewer/Variants
Loading interactive previews…
Loading interactive previews…
Schema
Type aliases exported from this item's source. Use these to shape the data you pass in.
SchemaColumnDefinitioninterface SchemaColumnDefinition {
name: string
type: string
nullable: boolean
defaultValue: string | null
isPrimaryKey?: boolean
isForeignKey?: boolean
foreignKeyRef?: string
description?: string
}SchemaForeignKeyDefinitioninterface SchemaForeignKeyDefinition {
name: string
column: string
foreignTable: string
foreignColumn: string
onDelete: 'CASCADE' | 'RESTRICT' | 'SET NULL' | 'NO ACTION'
onUpdate: 'CASCADE' | 'RESTRICT' | 'SET NULL' | 'NO ACTION'
}SchemaIndexDefinitioninterface SchemaIndexDefinition {
name: string
type: 'B-Tree' | 'GIN' | 'GiST' | 'Hash'
columns: string[]
size: string
isUnique?: boolean
isPrimary?: boolean
}SchemaTableinterface SchemaTable {
name: string
schema: string
rowCount: number
rowCountFormatted: string
size: string
primaryKey: string
lastAnalyzed: string
description: string
columns: SchemaColumnDefinition[]
foreignKeys: SchemaForeignKeyDefinition[]
indexes: SchemaIndexDefinition[]
ddl: string
}Files installed (2)
src/app/components/blocks/database-schema-viewer/database-schema-viewer.component.ts45 kB
// Visual database schema inspector: cluster health header with search + Export DDL, // table navigation sidebar with row counts, column metadata table (types, nullability, // defaults, PK/FK indicators), foreign-key relations, B-Tree indexes, and a DDL preview // with copy actions. Port of the React block 1:1. import { ChangeDetectionStrategy, Component, Input, computed, signal } from '@angular/core' import { ArrowRight, Check, Clock, Code2, Copy, Database, Download, HardDrive, Key, Layers, Link2, LucideAngularModule, Search, Table2, } from 'lucide-angular' import { cn } from '@/lib/utils' import { UiBadgeComponent } from '@/ui/badge/badge.component' import { UiButtonComponent } from '@/ui/button/button.component' import { UiCardComponent } from '@/ui/card/card.component' import { UiInputComponent } from '@/ui/input/input.component' import { UiSeparatorComponent } from '@/ui/separator/separator.component' import { UiTableBodyComponent, UiTableCellComponent, UiTableComponent, UiTableHeadComponent, UiTableHeaderComponent, UiTableRowComponent, } from '@/ui/table/table.component' import { UiTabsComponent, UiTabsContentComponent, UiTabsListComponent, UiTabsTriggerComponent, } from '@/ui/tabs/tabs.component' export interface SchemaColumnDefinition { name: string type: string nullable: boolean defaultValue: string | null isPrimaryKey?: boolean isForeignKey?: boolean foreignKeyRef?: string description?: string } export interface SchemaForeignKeyDefinition { name: string column: string foreignTable: string foreignColumn: string onDelete: 'CASCADE' | 'RESTRICT' | 'SET NULL' | 'NO ACTION' onUpdate: 'CASCADE' | 'RESTRICT' | 'SET NULL' | 'NO ACTION' } export interface SchemaIndexDefinition { name: string type: 'B-Tree' | 'GIN' | 'GiST' | 'Hash' columns: string[] size: string isUnique?: boolean isPrimary?: boolean } export interface SchemaTable { name: string schema: string rowCount: number rowCountFormatted: string size: string primaryKey: string lastAnalyzed: string description: string columns: SchemaColumnDefinition[] foreignKeys: SchemaForeignKeyDefinition[] indexes: SchemaIndexDefinition[] ddl: string } const schemaTables: SchemaTable[] = [ { name: 'users', schema: 'public', rowCount: 42850, rowCountFormatted: '42,850 rows', size: '18.4 MB', primaryKey: 'id (UUID)', lastAnalyzed: '12 mins ago', description: 'User accounts, authentication identities, role assignments, and profile metadata.', columns: [ { name: 'id', type: 'uuid', nullable: false, defaultValue: 'gen_random_uuid()', isPrimaryKey: true, description: 'Unique identifier for the user', }, { name: 'email', type: 'varchar(255)', nullable: false, defaultValue: null, description: 'Verified primary contact email', }, { name: 'encrypted_password', type: 'varchar(255)', nullable: false, defaultValue: null, description: 'Bcrypt hashed password digest', }, { name: 'full_name', type: 'varchar(100)', nullable: true, defaultValue: null, description: 'Display name or legal identity', }, { name: 'role', type: 'varchar(32)', nullable: false, defaultValue: "'member'", description: 'System authorization role', }, { name: 'avatar_url', type: 'text', nullable: true, defaultValue: null, description: 'Remote profile avatar image URL', }, { name: 'is_verified', type: 'boolean', nullable: false, defaultValue: 'false', description: 'Email verification status flag', }, { name: 'created_at', type: 'timestamptz', nullable: false, defaultValue: 'CURRENT_TIMESTAMP', description: 'Account creation timestamp', }, { name: 'updated_at', type: 'timestamptz', nullable: false, defaultValue: 'CURRENT_TIMESTAMP', description: 'Last record modification timestamp', }, ], foreignKeys: [], indexes: [ { name: 'users_pkey', type: 'B-Tree', columns: ['id'], size: '1.2 MB', isPrimary: true, isUnique: true }, { name: 'idx_users_email', type: 'B-Tree', columns: ['email'], size: '940 KB', isUnique: true }, { name: 'idx_users_role_created', type: 'B-Tree', columns: ['role', 'created_at DESC'], size: '1.8 MB' }, ], ddl: `CREATE TABLE public.users ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), email varchar(255) NOT NULL UNIQUE, encrypted_password varchar(255) NOT NULL, full_name varchar(100), role varchar(32) NOT NULL DEFAULT 'member', avatar_url text, is_verified boolean NOT NULL DEFAULT false, created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT chk_users_role CHECK (role IN ('admin', 'member', 'viewer')) );`, }, { name: 'orders', schema: 'public', rowCount: 148290, rowCountFormatted: '148,290 rows', size: '64.2 MB', primaryKey: 'id (UUID)', lastAnalyzed: '2 mins ago', description: 'Customer transactional purchase orders, fulfillment status, and payment totals.', columns: [ { name: 'id', type: 'uuid', nullable: false, defaultValue: 'gen_random_uuid()', isPrimaryKey: true, description: 'Unique order identifier', }, { name: 'order_number', type: 'varchar(48)', nullable: false, defaultValue: null, description: 'Human-readable sequential invoice reference', }, { name: 'user_id', type: 'uuid', nullable: false, defaultValue: null, isForeignKey: true, foreignKeyRef: 'users.id', description: 'Purchaser account reference', }, { name: 'status', type: 'varchar(32)', nullable: false, defaultValue: "'pending'", description: 'Order lifecycle stage', }, { name: 'total_amount', type: 'numeric(10,2)', nullable: false, defaultValue: '0.00', description: 'Grand total charged in base currency', }, { name: 'currency', type: 'varchar(3)', nullable: false, defaultValue: "'USD'", description: 'ISO 4217 three-letter currency code', }, { name: 'billing_address', type: 'jsonb', nullable: true, defaultValue: "'{}'::jsonb", description: 'Structured billing snapshot', }, { name: 'shipping_address', type: 'jsonb', nullable: true, defaultValue: "'{}'::jsonb", description: 'Structured destination address', }, { name: 'shipped_at', type: 'timestamptz', nullable: true, defaultValue: null, description: 'Carrier pickup confirmation date', }, { name: 'created_at', type: 'timestamptz', nullable: false, defaultValue: 'CURRENT_TIMESTAMP', description: 'Checkout completion timestamp', }, { name: 'updated_at', type: 'timestamptz', nullable: false, defaultValue: 'CURRENT_TIMESTAMP', description: 'Order state mutation timestamp', }, ], foreignKeys: [ { name: 'fk_orders_user_id', column: 'user_id', foreignTable: 'users', foreignColumn: 'id', onDelete: 'CASCADE', onUpdate: 'CASCADE', }, ], indexes: [ { name: 'orders_pkey', type: 'B-Tree', columns: ['id'], size: '4.8 MB', isPrimary: true, isUnique: true }, { name: 'idx_orders_user_id', type: 'B-Tree', columns: ['user_id'], size: '3.4 MB' }, { name: 'idx_orders_status_created', type: 'B-Tree', columns: ['status', 'created_at DESC'], size: '5.1 MB' }, { name: 'idx_orders_number_unique', type: 'B-Tree', columns: ['order_number'], size: '3.2 MB', isUnique: true }, ], ddl: `CREATE TABLE public.orders ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), order_number varchar(48) NOT NULL UNIQUE, user_id uuid NOT NULL REFERENCES public.users(id) ON DELETE CASCADE ON UPDATE CASCADE, status varchar(32) NOT NULL DEFAULT 'pending', total_amount numeric(10,2) NOT NULL DEFAULT 0.00, currency varchar(3) NOT NULL DEFAULT 'USD', billing_address jsonb DEFAULT '{}'::jsonb, shipping_address jsonb DEFAULT '{}'::jsonb, shipped_at timestamptz, created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP );`, }, { name: 'order_items', schema: 'public', rowCount: 384120, rowCountFormatted: '384,120 rows', size: '112.5 MB', primaryKey: 'id (UUID)', lastAnalyzed: '8 mins ago', description: 'Granular line items and unit pricing linked to purchase orders.', columns: [ { name: 'id', type: 'uuid', nullable: false, defaultValue: 'gen_random_uuid()', isPrimaryKey: true, description: 'Line item primary key', }, { name: 'order_id', type: 'uuid', nullable: false, defaultValue: null, isForeignKey: true, foreignKeyRef: 'orders.id', description: 'Parent order reference', }, { name: 'product_id', type: 'uuid', nullable: false, defaultValue: null, isForeignKey: true, foreignKeyRef: 'products.id', description: 'Catalog item reference', }, { name: 'quantity', type: 'integer', nullable: false, defaultValue: '1', description: 'Units purchased' }, { name: 'unit_price', type: 'numeric(10,2)', nullable: false, defaultValue: '0.00', description: 'Unit price captured at purchase', }, { name: 'discount_amount', type: 'numeric(10,2)', nullable: false, defaultValue: '0.00', description: 'Item level discount applied', }, { name: 'created_at', type: 'timestamptz', nullable: false, defaultValue: 'CURRENT_TIMESTAMP', description: 'Record insertion timestamp', }, ], foreignKeys: [ { name: 'fk_order_items_order_id', column: 'order_id', foreignTable: 'orders', foreignColumn: 'id', onDelete: 'CASCADE', onUpdate: 'CASCADE', }, { name: 'fk_order_items_product_id', column: 'product_id', foreignTable: 'products', foreignColumn: 'id', onDelete: 'RESTRICT', onUpdate: 'CASCADE', }, ], indexes: [ { name: 'order_items_pkey', type: 'B-Tree', columns: ['id'], size: '12.1 MB', isPrimary: true, isUnique: true }, { name: 'idx_order_items_order_id', type: 'B-Tree', columns: ['order_id'], size: '8.9 MB' }, { name: 'idx_order_items_product_id', type: 'B-Tree', columns: ['product_id'], size: '8.4 MB' }, ], ddl: `CREATE TABLE public.order_items ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), order_id uuid NOT NULL REFERENCES public.orders(id) ON DELETE CASCADE ON UPDATE CASCADE, product_id uuid NOT NULL REFERENCES public.products(id) ON DELETE RESTRICT ON UPDATE CASCADE, quantity integer NOT NULL DEFAULT 1, unit_price numeric(10,2) NOT NULL DEFAULT 0.00, discount_amount numeric(10,2) NOT NULL DEFAULT 0.00, created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP );`, }, { name: 'products', schema: 'public', rowCount: 12400, rowCountFormatted: '12,400 rows', size: '9.8 MB', primaryKey: 'id (UUID)', lastAnalyzed: '45 mins ago', description: 'Catalog products, SKU identifiers, real-time inventory counts, and price tiers.', columns: [ { name: 'id', type: 'uuid', nullable: false, defaultValue: 'gen_random_uuid()', isPrimaryKey: true, description: 'Product primary key', }, { name: 'sku', type: 'varchar(64)', nullable: false, defaultValue: null, description: 'Stock keeping unit barcode index', }, { name: 'name', type: 'varchar(255)', nullable: false, defaultValue: null, description: 'Public product title' }, { name: 'description', type: 'text', nullable: true, defaultValue: null, description: 'Detailed marketing specifications', }, { name: 'price', type: 'numeric(10,2)', nullable: false, defaultValue: '0.00', description: 'Base retail price' }, { name: 'stock_quantity', type: 'integer', nullable: false, defaultValue: '0', description: 'Available warehouse stock', }, { name: 'is_active', type: 'boolean', nullable: false, defaultValue: 'true', description: 'Visibility in storefront', }, { name: 'metadata', type: 'jsonb', nullable: true, defaultValue: "'{}'::jsonb", description: 'Custom attributes and tags', }, { name: 'created_at', type: 'timestamptz', nullable: false, defaultValue: 'CURRENT_TIMESTAMP', description: 'Product onboarding timestamp', }, { name: 'updated_at', type: 'timestamptz', nullable: false, defaultValue: 'CURRENT_TIMESTAMP', description: 'Last catalog revision', }, ], foreignKeys: [], indexes: [ { name: 'products_pkey', type: 'B-Tree', columns: ['id'], size: '380 KB', isPrimary: true, isUnique: true }, { name: 'idx_products_sku', type: 'B-Tree', columns: ['sku'], size: '290 KB', isUnique: true }, { name: 'idx_products_is_active', type: 'B-Tree', columns: ['is_active'], size: '180 KB' }, ], ddl: `CREATE TABLE public.products ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), sku varchar(64) NOT NULL UNIQUE, name varchar(255) NOT NULL, description text, price numeric(10,2) NOT NULL DEFAULT 0.00, stock_quantity integer NOT NULL DEFAULT 0, is_active boolean NOT NULL DEFAULT true, metadata jsonb DEFAULT '{}'::jsonb, created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP );`, }, { name: 'subscriptions', schema: 'public', rowCount: 19850, rowCountFormatted: '19,850 rows', size: '14.1 MB', primaryKey: 'id (UUID)', lastAnalyzed: '18 mins ago', description: 'Recurring billing contracts, subscription plans, and renewal period bounds.', columns: [ { name: 'id', type: 'uuid', nullable: false, defaultValue: 'gen_random_uuid()', isPrimaryKey: true, description: 'Subscription contract ID', }, { name: 'user_id', type: 'uuid', nullable: false, defaultValue: null, isForeignKey: true, foreignKeyRef: 'users.id', description: 'Subscriber user ID', }, { name: 'plan_tier', type: 'varchar(32)', nullable: false, defaultValue: "'starter'", description: 'SaaS plan designation', }, { name: 'status', type: 'varchar(32)', nullable: false, defaultValue: "'active'", description: 'Lifecycle status', }, { name: 'current_period_start', type: 'timestamptz', nullable: false, defaultValue: 'CURRENT_TIMESTAMP', description: 'Current billing window start', }, { name: 'current_period_end', type: 'timestamptz', nullable: false, defaultValue: null, description: 'Next scheduled billing date', }, { name: 'cancel_at_period_end', type: 'boolean', nullable: false, defaultValue: 'false', description: 'Cancellation intent at renewal', }, { name: 'created_at', type: 'timestamptz', nullable: false, defaultValue: 'CURRENT_TIMESTAMP', description: 'Initial signup timestamp', }, ], foreignKeys: [ { name: 'fk_subscriptions_user_id', column: 'user_id', foreignTable: 'users', foreignColumn: 'id', onDelete: 'CASCADE', onUpdate: 'CASCADE', }, ], indexes: [ { name: 'subscriptions_pkey', type: 'B-Tree', columns: ['id'], size: '620 KB', isPrimary: true, isUnique: true }, { name: 'idx_subscriptions_user_id', type: 'B-Tree', columns: ['user_id'], size: '540 KB' }, { name: 'idx_subscriptions_status', type: 'B-Tree', columns: ['status'], size: '410 KB' }, ], ddl: `CREATE TABLE public.subscriptions ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), user_id uuid NOT NULL REFERENCES public.users(id) ON DELETE CASCADE ON UPDATE CASCADE, plan_tier varchar(32) NOT NULL DEFAULT 'starter', status varchar(32) NOT NULL DEFAULT 'active', current_period_start timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, current_period_end timestamptz NOT NULL, cancel_at_period_end boolean NOT NULL DEFAULT false, created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP );`, }, { name: 'audit_logs', schema: 'public', rowCount: 920400, rowCountFormatted: '920,400 rows', size: '201.0 MB', primaryKey: 'id (UUID)', lastAnalyzed: '1 min ago', description: 'Immutable security event stream, compliance audit records, and state change captures.', columns: [ { name: 'id', type: 'uuid', nullable: false, defaultValue: 'gen_random_uuid()', isPrimaryKey: true, description: 'Event ledger entry identifier', }, { name: 'actor_id', type: 'uuid', nullable: true, defaultValue: null, isForeignKey: true, foreignKeyRef: 'users.id', description: 'Initiating user account reference', }, { name: 'action', type: 'varchar(64)', nullable: false, defaultValue: null, description: 'Standardized action identifier', }, { name: 'entity_type', type: 'varchar(64)', nullable: false, defaultValue: null, description: 'Target entity namespace', }, { name: 'entity_id', type: 'varchar(128)', nullable: false, defaultValue: null, description: 'Target entity unique identifier', }, { name: 'ip_address', type: 'inet', nullable: true, defaultValue: null, description: 'Client network IP' }, { name: 'payload', type: 'jsonb', nullable: true, defaultValue: "'{}'::jsonb", description: 'Full before/after state diff', }, { name: 'created_at', type: 'timestamptz', nullable: false, defaultValue: 'CURRENT_TIMESTAMP', description: 'Exact audit record timestamp', }, ], foreignKeys: [ { name: 'fk_audit_logs_actor_id', column: 'actor_id', foreignTable: 'users', foreignColumn: 'id', onDelete: 'SET NULL', onUpdate: 'CASCADE', }, ], indexes: [ { name: 'audit_logs_pkey', type: 'B-Tree', columns: ['id'], size: '28.4 MB', isPrimary: true, isUnique: true }, { name: 'idx_audit_logs_actor_id', type: 'B-Tree', columns: ['actor_id'], size: '19.2 MB' }, { name: 'idx_audit_logs_action_created', type: 'B-Tree', columns: ['action', 'created_at DESC'], size: '32.6 MB', }, { name: 'idx_audit_logs_entity', type: 'B-Tree', columns: ['entity_type', 'entity_id'], size: '24.1 MB' }, ], ddl: `CREATE TABLE public.audit_logs ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), actor_id uuid REFERENCES public.users(id) ON DELETE SET NULL ON UPDATE CASCADE, action varchar(64) NOT NULL, entity_type varchar(64) NOT NULL, entity_id varchar(128) NOT NULL, ip_address inet, payload jsonb DEFAULT '{}'::jsonb, created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP );`, }, ] export function formatSchemaRowCount(count: number): string { if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M` if (count >= 1_000) return `${(count / 1_000).toFixed(1)}k` return String(count) } @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'ui-database-schema-viewer, [ui-database-schema-viewer]', standalone: true, // React renders the root <div> itself: the host stays out of layout and `class` goes to the root. host: { '[attr.class]': '"contents"' }, imports: [ LucideAngularModule, UiBadgeComponent, UiButtonComponent, UiCardComponent, UiInputComponent, UiSeparatorComponent, UiTableBodyComponent, UiTableCellComponent, UiTableComponent, UiTableHeadComponent, UiTableHeaderComponent, UiTableRowComponent, UiTabsComponent, UiTabsContentComponent, UiTabsListComponent, UiTabsTriggerComponent, ], template: ` <div data-slot="database-schema-viewer" [class]="rootClass"> <!-- Top Database Header --> <header class="border-border bg-card/60 border-b px-4 py-3.5 sm:px-6"> <div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between"> <div class="flex flex-wrap items-center gap-3"> <div class="bg-primary/10 text-primary flex size-9 items-center justify-center rounded-lg"> <lucide-icon [img]="Database" class="size-4.5" /> </div> <div class="flex flex-col"> <div class="flex flex-wrap items-center gap-2"> <span class="font-mono text-sm font-semibold tracking-tight">production-cluster-pg16</span> <span class="bg-success flex size-2 rounded-full" title="Connected & Synced"></span> <span ui-badge variant="secondary" class="font-mono text-xs font-normal">PostgreSQL 16.2</span> </div> <span class="text-muted-foreground text-xs">18 tables · 420 MB</span> </div> </div> <div class="flex flex-wrap items-center gap-2"> <div class="relative w-full sm:w-60"> <lucide-icon [img]="Search" class="text-muted-foreground absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2" /> <ui-input [value]="searchQuery()" (valueChange)="searchQuery.set($event)" placeholder="Search tables or columns..." class="h-8 pl-8 text-xs" /> </div> <button ui-button aria-label="Download attachment" variant="outline" size="sm" class="h-8 shrink-0 gap-1.5 text-xs" (click)="exportAllDDL()" > @if (exportedAll()) { <lucide-icon [img]="Check" class="text-success size-3.5" /> } @else { <lucide-icon [img]="Download" class="size-3.5" /> } {{ exportedAll() ? 'DDL Copied!' : 'Export DDL' }} </button> </div> </div> </header> <!-- Two-Column Inspector Body --> <div class="grid grid-cols-1 md:grid-cols-[240px_1fr] lg:grid-cols-[260px_1fr]"> <!-- Left Sidebar: Tables List --> <aside class="border-border bg-muted/20 border-r p-3 sm:p-4"> <div class="mb-2.5 flex items-center justify-between gap-x-2 px-1"> <span class="text-muted-foreground text-xs font-semibold tracking-wider uppercase">Database Tables</span> <span ui-badge variant="secondary" class="h-5 px-1.5 font-mono text-xs">{{ filteredTables().length }}</span> </div> <div class="space-y-1"> @for (table of filteredTables(); track table.name) { <button type="button" [class]="tableButtonClass(table.name)" (click)="selectedTableName.set(table.name)"> <div class="flex min-w-0 items-center gap-2"> <lucide-icon [img]="Table2" class="size-3.5 shrink-0 opacity-70" /> <span class="truncate font-mono">{{ table.name }}</span> </div> <span class="bg-muted text-muted-foreground ml-2 shrink-0 rounded-full px-1.5 py-0.5 font-mono text-xs"> {{ formatCount(table.rowCount) }} </span> </button> } @if (filteredTables().length === 0) { <div class="text-muted-foreground py-6 text-center text-xs">No matching tables found</div> } </div> <ui-separator class="my-4" /> <div class="border-border/70 bg-card space-y-1.5 rounded-lg border p-3 text-xs"> <div class="text-muted-foreground flex items-center justify-between gap-x-2"> <span>Encoding:</span> <span class="text-foreground font-mono">UTF8</span> </div> <div class="text-muted-foreground flex items-center justify-between gap-x-2"> <span>Collation:</span> <span class="text-foreground font-mono">en_US.utf8</span> </div> <div class="text-muted-foreground flex items-center justify-between gap-x-2"> <span>Default Schema:</span> <span class="text-foreground font-mono">public</span> </div> </div> </aside> <!-- Right Main Panel: Table Detail Inspector --> <main class="bg-card/30 min-w-0 space-y-5 p-4 sm:p-6"> <!-- Active Table Header & Stats --> <div class="border-border space-y-3 border-b pb-2"> <div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between"> <div class="space-y-1"> <div class="flex flex-wrap items-center gap-2"> <span class="text-muted-foreground font-mono text-sm">public.</span> <h2 class="text-foreground font-mono text-xl font-bold tracking-tight break-all"> {{ activeTable().name }} </h2> <span ui-badge variant="outline" class="font-mono text-xs font-normal">BASE TABLE</span> </div> <p class="text-muted-foreground text-xs">{{ activeTable().description }}</p> </div> <button ui-button variant="outline" size="sm" class="h-8 shrink-0 gap-1.5 self-start text-xs sm:self-auto" (click)="copyDDL(activeTable().ddl)" > @if (copiedDDL()) { <lucide-icon [img]="Check" class="text-success size-3.5" /> } @else { <lucide-icon [img]="Copy" class="size-3.5" /> } {{ copiedDDL() ? 'Copied DDL' : 'Copy Table DDL' }} </button> </div> <!-- Metadata Stat Badges --> <div class="flex flex-wrap items-center gap-2 pt-1"> <div class="border-border bg-card text-muted-foreground inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs" > <lucide-icon [img]="Table2" class="text-foreground/70 size-3.5" /> <span>Rows:</span> <span class="text-foreground font-mono font-medium">{{ activeTable().rowCountFormatted }}</span> </div> <div class="border-border bg-card text-muted-foreground inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs" > <lucide-icon [img]="Key" class="text-warning size-3.5" /> <span>Primary Key:</span> <span class="text-foreground font-mono font-medium">{{ activeTable().primaryKey }}</span> </div> <div class="border-border bg-card text-muted-foreground inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs" > <lucide-icon [img]="HardDrive" class="text-foreground/70 size-3.5" /> <span>Table Size:</span> <span class="text-foreground font-mono font-medium">{{ activeTable().size }}</span> </div> <div class="border-border bg-card text-muted-foreground inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs" > <lucide-icon [img]="Clock" class="text-foreground/70 size-3.5" /> <span>Last Analyzed:</span> <span class="text-foreground font-medium">{{ activeTable().lastAnalyzed }}</span> </div> </div> </div> <!-- View Tabs Navigation --> <ui-tabs [value]="activeTab()" (valueChange)="activeTab.set($event)" class="w-full"> <ui-tabs-list class="grid h-9 w-full grid-cols-2 p-1 md:grid-cols-4"> <button ui-tabs-trigger value="columns" class="gap-1.5 text-xs"> <lucide-icon [img]="Table2" class="size-3.5" /> <span>Columns</span> <span class="bg-muted py-0.2 rounded-full px-1.5 font-mono text-xs"> {{ activeTable().columns.length }} </span> </button> <button ui-tabs-trigger value="foreign-keys" class="gap-1.5 text-xs"> <lucide-icon [img]="Link2" class="size-3.5" /> <span>Foreign Keys</span> <span class="bg-muted py-0.2 rounded-full px-1.5 font-mono text-xs"> {{ activeTable().foreignKeys.length }} </span> </button> <button ui-tabs-trigger value="indexes" class="gap-1.5 text-xs"> <lucide-icon [img]="Layers" class="size-3.5" /> <span>Indexes</span> <span class="bg-muted py-0.2 rounded-full px-1.5 font-mono text-xs"> {{ activeTable().indexes.length }} </span> </button> <button ui-tabs-trigger value="ddl" class="gap-1.5 text-xs"> <lucide-icon [img]="Code2" class="size-3.5" /> <span>SQL DDL Preview</span> </button> </ui-tabs-list> <!-- Tab 1: Columns List --> <ui-tabs-content value="columns" class="mt-4"> <div ui-card class="border-border overflow-hidden border shadow-none"> <div class="overflow-x-auto"> <table ui-table> <thead ui-table-header> <tr ui-table-row class="bg-muted/40 hover:bg-muted/40"> <th ui-table-head class="text-xs font-semibold">Column Name</th> <th ui-table-head class="text-xs font-semibold">Data Type</th> <th ui-table-head class="text-xs font-semibold">Nullable</th> <th ui-table-head class="text-xs font-semibold">Default Value</th> <th ui-table-head class="text-xs font-semibold">Constraints</th> <th ui-table-head class="text-xs font-semibold">Description</th> </tr> </thead> <tbody ui-table-body> @for (col of activeTable().columns; track col.name) { <tr ui-table-row class="text-xs"> <td ui-table-cell class="font-mono font-medium"> <div class="flex items-center gap-1.5"> @if (col.isPrimaryKey) { <lucide-icon [img]="Key" class="text-warning size-3.5 shrink-0" /> } @else if (col.isForeignKey) { <lucide-icon [img]="Link2" class="text-info size-3.5 shrink-0" /> } <span [class]="col.isPrimaryKey ? 'text-warning font-semibold' : ''">{{ col.name }}</span> </div> </td> <td ui-table-cell> <span ui-badge variant="secondary" class="font-mono text-xs font-normal">{{ col.type }}</span> </td> <td ui-table-cell> @if (!col.nullable) { <span ui-badge variant="outline" class="text-muted-foreground text-xs">NOT NULL</span> } @else { <span class="text-muted-foreground">NULL</span> } </td> <td ui-table-cell class="text-muted-foreground font-mono">{{ col.defaultValue ?? '—' }}</td> <td ui-table-cell> <div class="flex flex-wrap items-center gap-1"> @if (col.isPrimaryKey) { <span ui-badge class="border-warning/30 bg-warning/15 text-warning text-xs">PK</span> } @if (col.isForeignKey) { <span ui-badge variant="secondary" class="gap-1 font-mono text-xs"> FK → {{ col.foreignKeyRef }} </span> } @if (!col.isPrimaryKey && !col.isForeignKey) { <span class="text-muted-foreground">—</span> } </div> </td> <td ui-table-cell class="text-muted-foreground max-w-[200px] truncate"> {{ col.description ?? '—' }} </td> </tr> } </tbody> </table> </div> </div> </ui-tabs-content> <!-- Tab 2: Foreign Keys & Relations --> <ui-tabs-content value="foreign-keys" class="mt-4 space-y-3"> @if (activeTable().foreignKeys.length > 0) { <div class="space-y-3"> @for (fk of activeTable().foreignKeys; track fk.name) { <div ui-card class="border-border space-y-3 border p-4 shadow-none"> <div class="border-border flex flex-wrap items-center justify-between gap-2 border-b pb-2"> <div class="flex flex-wrap items-center gap-2"> <lucide-icon [img]="Link2" class="text-info size-4" /> <span class="text-foreground font-mono text-xs font-semibold">{{ fk.name }}</span> </div> <span ui-badge variant="secondary" class="font-mono text-xs">Many-to-One (N:1)</span> </div> <div class="bg-muted/40 border-border/60 flex flex-col justify-between gap-3 rounded-lg border p-3 sm:flex-row sm:items-center" > <div class="flex flex-wrap items-center gap-2"> <span class="text-muted-foreground text-xs">Source:</span> <span ui-badge variant="outline" class="font-mono text-xs"> {{ activeTable().name }}.{{ fk.column }} </span> </div> <lucide-icon [img]="ArrowRight" class="text-muted-foreground hidden size-4 sm:block" /> <div class="flex flex-wrap items-center gap-2"> <span class="text-muted-foreground text-xs">References:</span> <span ui-badge variant="default" class="font-mono text-xs"> {{ fk.foreignTable }}.{{ fk.foreignColumn }} </span> </div> </div> <div class="grid grid-cols-1 gap-2 pt-1 text-xs sm:grid-cols-2"> <div class="border-border/50 bg-card flex items-center justify-between gap-x-2 rounded-md border px-2.5 py-1.5" > <span class="text-muted-foreground">On Delete Action:</span> <span ui-badge variant="secondary" class="font-mono text-xs">{{ fk.onDelete }}</span> </div> <div class="border-border/50 bg-card flex items-center justify-between gap-x-2 rounded-md border px-2.5 py-1.5" > <span class="text-muted-foreground">On Update Action:</span> <span ui-badge variant="secondary" class="font-mono text-xs">{{ fk.onUpdate }}</span> </div> </div> </div> } </div> } @else { <div ui-card class="border-border border border-dashed p-8 text-center"> <div class="flex flex-col items-center justify-center space-y-2"> <div class="bg-muted text-muted-foreground flex size-10 items-center justify-center rounded-full"> <lucide-icon [img]="Link2" class="size-5" /> </div> <h3 class="text-sm font-semibold">No Outbound Foreign Keys</h3> <p class="text-muted-foreground max-w-sm text-xs"> Table <span class="font-mono font-medium">{{ activeTable().name }}</span> has no outbound relations. It acts as an independent entity or is referenced by downstream child tables. </p> </div> </div> } </ui-tabs-content> <!-- Tab 3: Indexes & Constraints --> <ui-tabs-content value="indexes" class="mt-4"> <div ui-card class="border-border overflow-hidden border shadow-none"> <div class="overflow-x-auto"> <table ui-table> <thead ui-table-header> <tr ui-table-row class="bg-muted/40 hover:bg-muted/40"> <th ui-table-head class="text-xs font-semibold">Index Name</th> <th ui-table-head class="text-xs font-semibold">Method</th> <th ui-table-head class="text-xs font-semibold">Indexed Columns</th> <th ui-table-head class="text-xs font-semibold">Index Size</th> <th ui-table-head class="text-xs font-semibold">Constraint Type</th> </tr> </thead> <tbody ui-table-body> @for (idx of activeTable().indexes; track idx.name) { <tr ui-table-row class="text-xs"> <td ui-table-cell class="font-mono font-medium"> <div class="flex items-center gap-1.5"> <lucide-icon [img]="Layers" class="text-muted-foreground size-3.5 shrink-0" /> <span>{{ idx.name }}</span> </div> </td> <td ui-table-cell> <span ui-badge variant="secondary" class="font-mono text-xs">{{ idx.type }}</span> </td> <td ui-table-cell class="text-foreground font-mono">{{ idx.columns.join(', ') }}</td> <td ui-table-cell class="text-muted-foreground font-mono">{{ idx.size }}</td> <td ui-table-cell> @if (idx.isPrimary) { <span ui-badge class="border-warning/30 bg-warning/15 text-warning text-xs"> PRIMARY KEY </span> } @else if (idx.isUnique) { <span ui-badge variant="secondary" class="text-xs">UNIQUE</span> } @else { <span ui-badge variant="outline" class="text-muted-foreground text-xs">INDEX</span> } </td> </tr> } </tbody> </table> </div> </div> </ui-tabs-content> <!-- Tab 4: SQL DDL Preview --> <ui-tabs-content value="ddl" class="mt-4"> <div class="border-border bg-muted/40 overflow-hidden rounded-lg border"> <div class="border-border bg-card flex items-center justify-between gap-x-2 border-b px-4 py-2.5"> <div class="flex flex-wrap items-center gap-2"> <lucide-icon [img]="Code2" class="text-primary size-4" /> <span class="text-foreground font-mono text-xs font-medium">{{ activeTable().name }}.sql</span> <span ui-badge variant="secondary" class="font-mono text-xs">PostgreSQL DDL</span> </div> <button ui-button variant="ghost" size="sm" class="h-7 gap-1 text-xs" (click)="copyDDL(activeTable().ddl)" > @if (copiedDDL()) { <lucide-icon [img]="Check" class="text-success size-3" /> } @else { <lucide-icon [img]="Copy" class="size-3" /> } {{ copiedDDL() ? 'Copied' : 'Copy' }} </button> </div> <div class="overflow-x-auto bg-neutral-950 p-4 font-mono text-xs leading-relaxed text-neutral-100 dark:bg-neutral-950" > <pre class="whitespace-pre"><code>{{ activeTable().ddl }}</code></pre> </div> </div> </ui-tabs-content> </ui-tabs> </main> </div> </div> `, }) export class UiDatabaseSchemaViewerComponent { protected readonly ArrowRight = ArrowRight protected readonly Check = Check protected readonly Clock = Clock protected readonly Code2 = Code2 protected readonly Copy = Copy protected readonly Database = Database protected readonly Download = Download protected readonly HardDrive = HardDrive protected readonly Key = Key protected readonly Layers = Layers protected readonly Link2 = Link2 protected readonly Search = Search protected readonly Table2 = Table2 @Input() set initialTable(v: string) { if (v) this.selectedTableName.set(v) } @Input('class') className?: string readonly searchQuery = signal('') readonly selectedTableName = signal('orders') readonly activeTab = signal('columns') readonly copiedDDL = signal(false) readonly exportedAll = signal(false) private copyTimer: ReturnType<typeof setTimeout> | undefined private exportTimer: ReturnType<typeof setTimeout> | undefined readonly filteredTables = computed(() => { const query = this.searchQuery().trim().toLowerCase() if (!query) return schemaTables return schemaTables.filter( (table) => table.name.toLowerCase().includes(query) || table.description.toLowerCase().includes(query) || table.columns.some((col) => col.name.toLowerCase().includes(query)), ) }) readonly activeTable = computed<SchemaTable>(() => { return schemaTables.find((t) => t.name === this.selectedTableName()) ?? schemaTables[0]! }) get rootClass(): string { return cn( 'bg-background text-foreground border-border w-full overflow-hidden rounded-xl border shadow-xs', this.className, ) } formatCount(count: number): string { return formatSchemaRowCount(count) } tableButtonClass(name: string): string { return cn( 'flex w-full items-center justify-between rounded-lg px-2.5 py-2 text-left text-xs transition-colors', this.selectedTableName() === name ? 'bg-primary/10 text-primary border-primary/20 border font-medium shadow-xs' : 'text-muted-foreground hover:bg-muted/70 hover:text-foreground border border-transparent', ) } copyDDL(text: string): void { try { void navigator.clipboard?.writeText(text) } catch { // Clipboard unavailable in some contexts; the flag still flips for feedback. } this.copiedDDL.set(true) clearTimeout(this.copyTimer) this.copyTimer = setTimeout(() => this.copiedDDL.set(false), 2000) } exportAllDDL(): void { const allSql = schemaTables.map((t) => `-- Table: ${t.name}\n${t.ddl}`).join('\n\n') try { void navigator.clipboard?.writeText(allSql) } catch { // Clipboard unavailable in some contexts; the flag still flips for feedback. } this.exportedAll.set(true) clearTimeout(this.exportTimer) this.exportTimer = setTimeout(() => this.exportedAll.set(false), 2000) } }src/app/components/blocks/database-schema-viewer/index.ts0.2 kB
export { UiDatabaseSchemaViewerComponent, formatSchemaRowCount, type SchemaColumnDefinition, type SchemaForeignKeyDefinition, type SchemaIndexDefinition, type SchemaTable, } from './database-schema-viewer.component'
Raw manifest:https://uipkge.dev/r/angular/database-schema-viewer.json