Menu

Change language

GitHub repo

Database Schema Viewer

blockdevops

Visual 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

$npx uipkge-ng@latest add database-schema-viewer
Manifest:https://uipkge.dev/r/angular/database-schema-viewer.jsonInstalls to:src/app/components/blocks/database-schema-viewer/

Variants

Loading interactive previews…

Schema

Type aliases exported from this item's source. Use these to shape the data you pass in.

SchemaColumnDefinition
interface SchemaColumnDefinition {
  name: string
  type: string
  nullable: boolean
  defaultValue: string | null
  isPrimaryKey?: boolean
  isForeignKey?: boolean
  foreignKeyRef?: string
  description?: string
}
SchemaForeignKeyDefinition
interface SchemaForeignKeyDefinition {
  name: string
  column: string
  foreignTable: string
  foreignColumn: string
  onDelete: 'CASCADE' | 'RESTRICT' | 'SET NULL' | 'NO ACTION'
  onUpdate: 'CASCADE' | 'RESTRICT' | 'SET NULL' | 'NO ACTION'
}
SchemaIndexDefinition
interface SchemaIndexDefinition {
  name: string
  type: 'B-Tree' | 'GIN' | 'GiST' | 'Hash'
  columns: string[]
  size: string
  isUnique?: boolean
  isPrimary?: boolean
}
SchemaTable
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
}

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 &rarr; {{ 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

Raw manifest:https://uipkge.dev/r/angular/database-schema-viewer.json