mirror of
https://github.com/chartdb/chartdb.git
synced 2026-02-09 21:19:45 -06:00
feat: add GIN index and array type support for PostgreSQL/CockroachDB (#1048)
* feat: add GIN index and array type support for PostgreSQL/CockroachDB * fix * fix --------- Co-authored-by: Guy Ben-Aharon <baguy3@gmail.com>
This commit is contained in:
@@ -59,6 +59,7 @@ export interface SelectBoxProps {
|
||||
commandOnMouseDown?: (e: React.MouseEvent) => void;
|
||||
commandOnClick?: (e: React.MouseEvent) => void;
|
||||
onSearchChange?: (search: string) => void;
|
||||
modal?: boolean;
|
||||
}
|
||||
|
||||
export const SelectBox = React.forwardRef<HTMLInputElement, SelectBoxProps>(
|
||||
@@ -89,6 +90,7 @@ export const SelectBox = React.forwardRef<HTMLInputElement, SelectBoxProps>(
|
||||
commandOnMouseDown,
|
||||
commandOnClick,
|
||||
onSearchChange,
|
||||
modal = true,
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
@@ -312,7 +314,7 @@ export const SelectBox = React.forwardRef<HTMLInputElement, SelectBoxProps>(
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover open={isOpen} onOpenChange={onOpenChange} modal={true}>
|
||||
<Popover open={isOpen} onOpenChange={onOpenChange} modal={modal}>
|
||||
<PopoverTrigger asChild tabIndex={0} onKeyDown={handleKeyDown}>
|
||||
<div
|
||||
className={cn(
|
||||
|
||||
@@ -56,6 +56,7 @@ export interface SQLIndex {
|
||||
name: string;
|
||||
columns: string[];
|
||||
unique: boolean;
|
||||
type?: string; // Index type (btree, hash, gin, gist, etc.)
|
||||
}
|
||||
|
||||
export interface SQLForeignKey {
|
||||
@@ -614,6 +615,9 @@ export const typeAffinity: Record<string, Record<string, string>> = {
|
||||
},
|
||||
};
|
||||
|
||||
// CockroachDB uses PostgreSQL-compatible types - reference dynamically
|
||||
typeAffinity[DatabaseType.COCKROACHDB] = typeAffinity[DatabaseType.POSTGRESQL];
|
||||
|
||||
// Convert SQLParserResult to ChartDB Diagram structure
|
||||
export function convertToChartDBDiagram(
|
||||
parserResult: SQLParserResult,
|
||||
@@ -636,9 +640,18 @@ export function convertToChartDBDiagram(
|
||||
// Use special case handling for specific database types to ensure correct mapping
|
||||
let mappedType: DataType;
|
||||
|
||||
// Detect and handle array types (e.g., int[], text[], varchar[])
|
||||
const isArrayType = column.type.endsWith('[]');
|
||||
const baseColumnType = isArrayType
|
||||
? column.type.slice(0, -2)
|
||||
: column.type;
|
||||
|
||||
// Create a modified column object with the base type for mapping
|
||||
const columnForMapping = { ...column, type: baseColumnType };
|
||||
|
||||
// SQLite-specific handling for numeric types
|
||||
if (sourceDatabaseType === DatabaseType.SQLITE) {
|
||||
const normalizedType = column.type.toLowerCase();
|
||||
const normalizedType = columnForMapping.type.toLowerCase();
|
||||
|
||||
if (normalizedType === 'integer' || normalizedType === 'int') {
|
||||
// Ensure integer types are preserved
|
||||
@@ -658,7 +671,7 @@ export function convertToChartDBDiagram(
|
||||
} else {
|
||||
// Use the standard mapping for other types
|
||||
mappedType = mapSQLTypeToGenericType(
|
||||
column.type,
|
||||
columnForMapping.type,
|
||||
sourceDatabaseType
|
||||
);
|
||||
}
|
||||
@@ -668,7 +681,7 @@ export function convertToChartDBDiagram(
|
||||
sourceDatabaseType === DatabaseType.MYSQL ||
|
||||
sourceDatabaseType === DatabaseType.MARIADB
|
||||
) {
|
||||
const normalizedType = column.type
|
||||
const normalizedType = columnForMapping.type
|
||||
.toLowerCase()
|
||||
.replace(/\(\d+\)/, '')
|
||||
.trim();
|
||||
@@ -690,17 +703,18 @@ export function convertToChartDBDiagram(
|
||||
} else {
|
||||
// Use the standard mapping for other types
|
||||
mappedType = mapSQLTypeToGenericType(
|
||||
column.type,
|
||||
columnForMapping.type,
|
||||
sourceDatabaseType
|
||||
);
|
||||
}
|
||||
}
|
||||
// Handle PostgreSQL integer type specifically
|
||||
// Handle PostgreSQL/CockroachDB integer type specifically
|
||||
else if (
|
||||
sourceDatabaseType === DatabaseType.POSTGRESQL &&
|
||||
(column.type.toLowerCase() === 'integer' ||
|
||||
column.type.toLowerCase() === 'int' ||
|
||||
column.type.toLowerCase() === 'int4')
|
||||
(sourceDatabaseType === DatabaseType.POSTGRESQL ||
|
||||
sourceDatabaseType === DatabaseType.COCKROACHDB) &&
|
||||
(columnForMapping.type.toLowerCase() === 'integer' ||
|
||||
columnForMapping.type.toLowerCase() === 'int' ||
|
||||
columnForMapping.type.toLowerCase() === 'int4')
|
||||
) {
|
||||
// Ensure integer types are preserved
|
||||
mappedType = { id: 'integer', name: 'integer' };
|
||||
@@ -708,21 +722,25 @@ export function convertToChartDBDiagram(
|
||||
supportsCustomTypes(sourceDatabaseType) &&
|
||||
parserResult.enums &&
|
||||
parserResult.enums.some(
|
||||
(e) => e.name.toLowerCase() === column.type.toLowerCase()
|
||||
(e) =>
|
||||
e.name.toLowerCase() ===
|
||||
columnForMapping.type.toLowerCase()
|
||||
)
|
||||
) {
|
||||
// If the column type matches a custom enum type, preserve it
|
||||
mappedType = {
|
||||
id: column.type.toLowerCase(),
|
||||
name: column.type,
|
||||
id: columnForMapping.type.toLowerCase(),
|
||||
name: columnForMapping.type,
|
||||
};
|
||||
}
|
||||
// Handle PostgreSQL-specific types (not in genericDataTypes)
|
||||
// Handle PostgreSQL/CockroachDB-specific types (not in genericDataTypes)
|
||||
else if (
|
||||
sourceDatabaseType === DatabaseType.POSTGRESQL &&
|
||||
targetDatabaseType === DatabaseType.POSTGRESQL
|
||||
(sourceDatabaseType === DatabaseType.POSTGRESQL ||
|
||||
sourceDatabaseType === DatabaseType.COCKROACHDB) &&
|
||||
(targetDatabaseType === DatabaseType.POSTGRESQL ||
|
||||
targetDatabaseType === DatabaseType.COCKROACHDB)
|
||||
) {
|
||||
const normalizedType = column.type.toLowerCase();
|
||||
const normalizedType = columnForMapping.type.toLowerCase();
|
||||
|
||||
// Preserve PostgreSQL-specific types that don't exist in genericDataTypes
|
||||
// Serial types are PostgreSQL-specific syntax (not true data types)
|
||||
@@ -738,7 +756,7 @@ export function convertToChartDBDiagram(
|
||||
} else {
|
||||
// Use the standard mapping for other types
|
||||
mappedType = mapSQLTypeToGenericType(
|
||||
column.type,
|
||||
columnForMapping.type,
|
||||
sourceDatabaseType
|
||||
);
|
||||
}
|
||||
@@ -748,7 +766,7 @@ export function convertToChartDBDiagram(
|
||||
sourceDatabaseType === DatabaseType.SQL_SERVER &&
|
||||
targetDatabaseType === DatabaseType.SQL_SERVER
|
||||
) {
|
||||
const normalizedType = column.type.toLowerCase();
|
||||
const normalizedType = columnForMapping.type.toLowerCase();
|
||||
|
||||
// Preserve SQL Server specific types when target is also SQL Server
|
||||
if (
|
||||
@@ -770,14 +788,14 @@ export function convertToChartDBDiagram(
|
||||
} else {
|
||||
// Use the standard mapping for other types
|
||||
mappedType = mapSQLTypeToGenericType(
|
||||
column.type,
|
||||
columnForMapping.type,
|
||||
sourceDatabaseType
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Use the standard mapping for other types
|
||||
mappedType = mapSQLTypeToGenericType(
|
||||
column.type,
|
||||
columnForMapping.type,
|
||||
sourceDatabaseType
|
||||
);
|
||||
}
|
||||
@@ -803,6 +821,7 @@ export function convertToChartDBDiagram(
|
||||
default: column.default || '',
|
||||
createdAt: Date.now(),
|
||||
increment: column.increment,
|
||||
isArray: isArrayType || undefined,
|
||||
};
|
||||
|
||||
// Add type arguments if present
|
||||
@@ -899,13 +918,20 @@ export function convertToChartDBDiagram(
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
const index: DBIndex = {
|
||||
id: generateId(),
|
||||
name: sqlIndex.name,
|
||||
fieldIds,
|
||||
unique: sqlIndex.unique,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
|
||||
// Add type if specified (for GIN, HASH, etc.)
|
||||
if (sqlIndex.type) {
|
||||
index.type = sqlIndex.type as DBIndex['type'];
|
||||
}
|
||||
|
||||
return index;
|
||||
})
|
||||
.filter((idx): idx is DBIndex => idx !== null);
|
||||
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { fromPostgres } from '../postgresql';
|
||||
import { convertToChartDBDiagram } from '../../../common';
|
||||
import { DatabaseType } from '@/lib/domain/database-type';
|
||||
|
||||
describe('Array Type Conversion', () => {
|
||||
it('should correctly parse and convert array types with isArray flag', async () => {
|
||||
const sql = `
|
||||
CREATE TABLE test_arrays (
|
||||
id bigint NOT NULL PRIMARY KEY,
|
||||
int_array int[],
|
||||
text_array text[],
|
||||
varchar_array varchar(255)[],
|
||||
jsonb_data jsonb,
|
||||
regular_int int
|
||||
);
|
||||
|
||||
CREATE INDEX idx_int_array ON test_arrays USING GIN (int_array);
|
||||
`;
|
||||
|
||||
// Parse SQL
|
||||
const parserResult = await fromPostgres(sql);
|
||||
|
||||
// Verify parser correctly captures array notation in type string
|
||||
const intArrayCol = parserResult.tables[0].columns.find(
|
||||
(c) => c.name === 'int_array'
|
||||
);
|
||||
// The parser normalizes int to integer, but should preserve []
|
||||
expect(intArrayCol?.type).toMatch(/\[\]$/);
|
||||
|
||||
const textArrayCol = parserResult.tables[0].columns.find(
|
||||
(c) => c.name === 'text_array'
|
||||
);
|
||||
expect(textArrayCol?.type).toBe('text[]');
|
||||
|
||||
const varcharArrayCol = parserResult.tables[0].columns.find(
|
||||
(c) => c.name === 'varchar_array'
|
||||
);
|
||||
expect(varcharArrayCol?.type).toBe('varchar(255)[]');
|
||||
|
||||
// Convert to diagram
|
||||
const diagram = convertToChartDBDiagram(
|
||||
parserResult,
|
||||
DatabaseType.POSTGRESQL,
|
||||
DatabaseType.POSTGRESQL
|
||||
);
|
||||
|
||||
const table = diagram.tables?.find((t) => t.name === 'test_arrays');
|
||||
expect(table).toBeDefined();
|
||||
|
||||
// Check int[] field - should have isArray=true and base type (PostgreSQL uses 'int' as the canonical form)
|
||||
const intArrayField = table!.fields.find((f) => f.name === 'int_array');
|
||||
expect(intArrayField).toBeDefined();
|
||||
expect(intArrayField!.isArray).toBe(true);
|
||||
expect(intArrayField!.type.id).toBe('int');
|
||||
|
||||
// Check text[] field - should have isArray=true and type=text
|
||||
const textArrayField = table!.fields.find(
|
||||
(f) => f.name === 'text_array'
|
||||
);
|
||||
expect(textArrayField).toBeDefined();
|
||||
expect(textArrayField!.isArray).toBe(true);
|
||||
expect(textArrayField!.type.id).toBe('text');
|
||||
|
||||
// Check varchar[] field - should have isArray=true and type=varchar
|
||||
const varcharArrayField = table!.fields.find(
|
||||
(f) => f.name === 'varchar_array'
|
||||
);
|
||||
expect(varcharArrayField).toBeDefined();
|
||||
expect(varcharArrayField!.isArray).toBe(true);
|
||||
expect(varcharArrayField!.type.id).toBe('varchar');
|
||||
|
||||
// Check regular jsonb field - should NOT have isArray
|
||||
const jsonbField = table!.fields.find((f) => f.name === 'jsonb_data');
|
||||
expect(jsonbField).toBeDefined();
|
||||
expect(jsonbField!.isArray).toBeUndefined();
|
||||
expect(jsonbField!.type.id).toBe('jsonb');
|
||||
|
||||
// Check regular int field - should NOT have isArray (PostgreSQL uses 'int' as the canonical form)
|
||||
const regularIntField = table!.fields.find(
|
||||
(f) => f.name === 'regular_int'
|
||||
);
|
||||
expect(regularIntField).toBeDefined();
|
||||
expect(regularIntField!.isArray).toBeUndefined();
|
||||
expect(regularIntField!.type.id).toBe('int');
|
||||
});
|
||||
|
||||
it('should handle multi-dimensional arrays', async () => {
|
||||
const sql = `
|
||||
CREATE TABLE matrix_data (
|
||||
id serial PRIMARY KEY,
|
||||
matrix int[][]
|
||||
);
|
||||
`;
|
||||
|
||||
const parserResult = await fromPostgres(sql);
|
||||
|
||||
// Check parser captures the type with array notation
|
||||
const matrixCol = parserResult.tables[0].columns.find(
|
||||
(c) => c.name === 'matrix'
|
||||
);
|
||||
// Multi-dimensional arrays should still have array notation
|
||||
expect(matrixCol?.type).toMatch(/\[\]/);
|
||||
});
|
||||
|
||||
it('should correctly parse GIN index type from USING clause', async () => {
|
||||
const sql = `
|
||||
CREATE TABLE test_gin_index (
|
||||
id bigint NOT NULL PRIMARY KEY,
|
||||
tags text[]
|
||||
);
|
||||
|
||||
CREATE INDEX idx_tags ON test_gin_index USING GIN (tags);
|
||||
CREATE INDEX idx_tags_btree ON test_gin_index USING BTREE (id);
|
||||
CREATE INDEX idx_tags_hash ON test_gin_index USING HASH (id);
|
||||
`;
|
||||
|
||||
// Parse SQL
|
||||
const parserResult = await fromPostgres(sql);
|
||||
|
||||
// Check that the parser captures the index type
|
||||
const table = parserResult.tables[0];
|
||||
expect(table.indexes).toHaveLength(3);
|
||||
|
||||
const ginIndex = table.indexes.find((idx) => idx.name === 'idx_tags');
|
||||
expect(ginIndex).toBeDefined();
|
||||
expect(ginIndex!.type).toBe('gin');
|
||||
|
||||
const btreeIndex = table.indexes.find(
|
||||
(idx) => idx.name === 'idx_tags_btree'
|
||||
);
|
||||
expect(btreeIndex).toBeDefined();
|
||||
expect(btreeIndex!.type).toBe('btree');
|
||||
|
||||
const hashIndex = table.indexes.find(
|
||||
(idx) => idx.name === 'idx_tags_hash'
|
||||
);
|
||||
expect(hashIndex).toBeDefined();
|
||||
expect(hashIndex!.type).toBe('hash');
|
||||
|
||||
// Convert to diagram and verify index types are preserved
|
||||
const diagram = convertToChartDBDiagram(
|
||||
parserResult,
|
||||
DatabaseType.POSTGRESQL,
|
||||
DatabaseType.POSTGRESQL
|
||||
);
|
||||
|
||||
const diagramTable = diagram.tables?.find(
|
||||
(t) => t.name === 'test_gin_index'
|
||||
);
|
||||
expect(diagramTable).toBeDefined();
|
||||
|
||||
const diagramGinIndex = diagramTable!.indexes.find(
|
||||
(idx) => idx.name === 'idx_tags'
|
||||
);
|
||||
expect(diagramGinIndex).toBeDefined();
|
||||
expect(diagramGinIndex!.type).toBe('gin');
|
||||
|
||||
const diagramBtreeIndex = diagramTable!.indexes.find(
|
||||
(idx) => idx.name === 'idx_tags_btree'
|
||||
);
|
||||
expect(diagramBtreeIndex).toBeDefined();
|
||||
expect(diagramBtreeIndex!.type).toBe('btree');
|
||||
|
||||
const diagramHashIndex = diagramTable!.indexes.find(
|
||||
(idx) => idx.name === 'idx_tags_hash'
|
||||
);
|
||||
expect(diagramHashIndex).toBeDefined();
|
||||
expect(diagramHashIndex!.type).toBe('hash');
|
||||
});
|
||||
});
|
||||
@@ -1096,6 +1096,14 @@ export async function fromPostgres(
|
||||
| number
|
||||
| undefined;
|
||||
|
||||
// Check if this is an array type (node-sql-parser stores this separately)
|
||||
const arrayInfo = definition?.array as
|
||||
| { dimension?: number }
|
||||
| undefined;
|
||||
const isArrayType =
|
||||
arrayInfo?.dimension !== undefined ||
|
||||
rawDataType.endsWith('[]');
|
||||
|
||||
// Normalize the type (pass length to handle parser quirks like INT with length=8)
|
||||
let finalDataType = normalizePostgreSQLType(
|
||||
rawDataType,
|
||||
@@ -1163,6 +1171,12 @@ export async function fromPostgres(
|
||||
}
|
||||
}
|
||||
|
||||
// Add array suffix if this is an array type
|
||||
// (only if not already present from rawDataType)
|
||||
if (isArrayType && !finalDataType.endsWith('[]')) {
|
||||
finalDataType = `${finalDataType}[]`;
|
||||
}
|
||||
|
||||
if (columnName) {
|
||||
const isPrimaryKey =
|
||||
columnDef.primary_key === 'primary key' ||
|
||||
@@ -2172,12 +2186,29 @@ export async function fromPostgres(
|
||||
createIndexStmt.index_name ||
|
||||
`idx_${tableName}_${columns.join('_')}`;
|
||||
|
||||
// Extract index type from USING clause (e.g., USING GIN, USING HASH)
|
||||
// The parser may store this in different properties
|
||||
let indexType: string | undefined;
|
||||
const indexUsing = createIndexStmt.index_using;
|
||||
if (typeof indexUsing === 'string') {
|
||||
indexType = indexUsing.toLowerCase();
|
||||
} else if (
|
||||
indexUsing &&
|
||||
typeof indexUsing === 'object' &&
|
||||
'type' in indexUsing
|
||||
) {
|
||||
indexType = String(
|
||||
(indexUsing as { type: unknown }).type
|
||||
).toLowerCase();
|
||||
}
|
||||
|
||||
table.indexes.push({
|
||||
name: indexName,
|
||||
columns,
|
||||
unique:
|
||||
createIndexStmt.index_type === 'unique' ||
|
||||
createIndexStmt.unique === true,
|
||||
type: indexType,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,7 +197,9 @@ export async function sqlImportToDiagram({
|
||||
// Select the appropriate parser based on database type
|
||||
switch (sourceDatabaseType) {
|
||||
case DatabaseType.POSTGRESQL:
|
||||
case DatabaseType.COCKROACHDB:
|
||||
// Check if the SQL is from pg_dump and use the appropriate parser
|
||||
// CockroachDB uses PostgreSQL-compatible syntax
|
||||
if (isPgDumpFormat(sqlContent)) {
|
||||
parserResult = await fromPostgresDump(sqlContent);
|
||||
} else {
|
||||
@@ -278,7 +280,8 @@ export async function parseSQLError({
|
||||
// Validate SQL based on the database type
|
||||
switch (sourceDatabaseType) {
|
||||
case DatabaseType.POSTGRESQL:
|
||||
// PostgreSQL validation - check format and use appropriate parser
|
||||
case DatabaseType.COCKROACHDB:
|
||||
// PostgreSQL/CockroachDB validation - check format and use appropriate parser
|
||||
if (isPgDumpFormat(sqlContent)) {
|
||||
await fromPostgresDump(sqlContent);
|
||||
} else {
|
||||
|
||||
@@ -30,6 +30,8 @@ export function validateSQL(
|
||||
): ValidationResult {
|
||||
switch (databaseType) {
|
||||
case DatabaseType.POSTGRESQL:
|
||||
case DatabaseType.COCKROACHDB:
|
||||
// CockroachDB uses PostgreSQL-compatible syntax
|
||||
return validatePostgreSQLDialect(sql);
|
||||
|
||||
case DatabaseType.MYSQL:
|
||||
|
||||
@@ -2,6 +2,7 @@ import { z } from 'zod';
|
||||
import { generateId } from '../utils';
|
||||
import { DatabaseType } from './database-type';
|
||||
import type { DBTable } from './db-table';
|
||||
import type { DBField } from './db-field';
|
||||
|
||||
export const INDEX_TYPES = [
|
||||
'btree',
|
||||
@@ -41,16 +42,66 @@ export const dbIndexSchema: z.ZodType<DBIndex> = z.object({
|
||||
isPrimaryKey: z.boolean().or(z.null()).optional(),
|
||||
});
|
||||
|
||||
export const databaseIndexTypes: { [key in DatabaseType]?: IndexType[] } = {
|
||||
[DatabaseType.POSTGRESQL]: ['btree', 'hash'],
|
||||
export const databaseIndexTypes: Record<DatabaseType, IndexType[] | undefined> =
|
||||
{
|
||||
[DatabaseType.POSTGRESQL]: ['btree', 'hash', 'gin'],
|
||||
[DatabaseType.COCKROACHDB]: ['btree', 'hash', 'gin'],
|
||||
[DatabaseType.MYSQL]: undefined,
|
||||
[DatabaseType.MARIADB]: undefined,
|
||||
[DatabaseType.SQL_SERVER]: undefined,
|
||||
[DatabaseType.SQLITE]: undefined,
|
||||
[DatabaseType.CLICKHOUSE]: undefined,
|
||||
[DatabaseType.ORACLE]: undefined,
|
||||
[DatabaseType.GENERIC]: undefined,
|
||||
};
|
||||
|
||||
export const defaultIndexTypeForDatabase: Record<
|
||||
DatabaseType,
|
||||
IndexType | undefined
|
||||
> = {
|
||||
[DatabaseType.POSTGRESQL]: 'btree',
|
||||
[DatabaseType.COCKROACHDB]: 'btree',
|
||||
[DatabaseType.MYSQL]: undefined,
|
||||
[DatabaseType.MARIADB]: undefined,
|
||||
[DatabaseType.SQL_SERVER]: undefined,
|
||||
[DatabaseType.SQLITE]: undefined,
|
||||
[DatabaseType.CLICKHOUSE]: undefined,
|
||||
[DatabaseType.ORACLE]: undefined,
|
||||
[DatabaseType.GENERIC]: undefined,
|
||||
};
|
||||
|
||||
export const defaultIndexTypeForDatabase: {
|
||||
[key in DatabaseType]?: IndexType;
|
||||
} = {
|
||||
[DatabaseType.POSTGRESQL]: 'btree',
|
||||
// Data types that support GIN indexes in PostgreSQL/CockroachDB
|
||||
const GIN_SUPPORTED_TYPES = ['jsonb', 'json', 'tsvector', 'hstore'] as const;
|
||||
|
||||
export const supportsGinIndex = (field: DBField): boolean => {
|
||||
if (field.isArray) return true;
|
||||
const typeLower = field.type.id.toLowerCase();
|
||||
return GIN_SUPPORTED_TYPES.includes(
|
||||
typeLower as (typeof GIN_SUPPORTED_TYPES)[number]
|
||||
);
|
||||
};
|
||||
|
||||
export const canFieldsUseGinIndex = (fields: DBField[]): boolean => {
|
||||
return fields.length > 0 && fields.every(supportsGinIndex);
|
||||
};
|
||||
|
||||
export interface IndexTypeConfig {
|
||||
label: string;
|
||||
value: IndexType;
|
||||
disabledTooltip?: string;
|
||||
}
|
||||
|
||||
export const INDEX_TYPE_CONFIGS: IndexTypeConfig[] = [
|
||||
{ label: 'B-tree (default)', value: 'btree' },
|
||||
{ label: 'Hash', value: 'hash' },
|
||||
{
|
||||
label: 'GIN',
|
||||
value: 'gin',
|
||||
disabledTooltip:
|
||||
'GIN indexes require array, jsonb, json, tsvector, or hstore types',
|
||||
},
|
||||
];
|
||||
|
||||
export const getTablePrimaryKeyIndex = ({
|
||||
table,
|
||||
}: {
|
||||
|
||||
@@ -142,6 +142,7 @@ export const TableField: React.FC<TableFieldProps> = ({
|
||||
'side_panel.tables_section.table.no_types_found'
|
||||
)}
|
||||
readonly={readonly}
|
||||
modal={false}
|
||||
/>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import React from 'react';
|
||||
import { Check, ChevronsUpDown } from 'lucide-react';
|
||||
import { Button } from '@/components/button/button';
|
||||
import type { IndexType, IndexTypeConfig } from '@/lib/domain/db-index';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/popover/popover';
|
||||
import { Label } from '@/components/label/label';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/tooltip/tooltip';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface IndexTypeSelectorProps {
|
||||
options: Array<IndexTypeConfig & { disabled: boolean }>;
|
||||
value: IndexType;
|
||||
label: string;
|
||||
onChange: (value: IndexType) => void;
|
||||
readonly?: boolean;
|
||||
t: (key: string) => string;
|
||||
}
|
||||
|
||||
export const IndexTypeSelector: React.FC<IndexTypeSelectorProps> = ({
|
||||
options,
|
||||
value,
|
||||
label,
|
||||
onChange,
|
||||
readonly,
|
||||
t,
|
||||
}) => (
|
||||
<div className="mt-2 flex flex-col gap-2">
|
||||
<Label htmlFor="indexType" className="text-subtitle">
|
||||
{t('side_panel.tables_section.table.index_actions.index_type')}
|
||||
</Label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-between font-normal"
|
||||
disabled={readonly}
|
||||
>
|
||||
{label}
|
||||
<ChevronsUpDown className="ml-2 size-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[180px] p-1">
|
||||
<div className="flex flex-col">
|
||||
{options.map((option) => (
|
||||
<IndexTypeOption
|
||||
key={option.value}
|
||||
option={option}
|
||||
isSelected={value === option.value}
|
||||
onSelect={() => {
|
||||
if (!option.disabled) {
|
||||
onChange(option.value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
|
||||
interface IndexTypeOptionProps {
|
||||
option: IndexTypeConfig & { disabled: boolean };
|
||||
isSelected: boolean;
|
||||
onSelect: () => void;
|
||||
}
|
||||
|
||||
const IndexTypeOption: React.FC<IndexTypeOptionProps> = ({
|
||||
option,
|
||||
isSelected,
|
||||
onSelect,
|
||||
}) => {
|
||||
const content = (
|
||||
<div
|
||||
className={cn(
|
||||
'flex cursor-pointer items-center rounded-sm px-2 py-1.5 text-sm',
|
||||
option.disabled
|
||||
? 'cursor-not-allowed opacity-50'
|
||||
: 'hover:bg-accent',
|
||||
isSelected && 'bg-accent'
|
||||
)}
|
||||
onClick={onSelect}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
'mr-2 h-4 w-4',
|
||||
isSelected ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
{option.label}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (option.disabled && option.disabledTooltip) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{content}</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
{option.disabledTooltip}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return content;
|
||||
};
|
||||
@@ -2,7 +2,9 @@ import React, { useCallback, useMemo } from 'react';
|
||||
import { Ellipsis, Trash2, KeyRound } from 'lucide-react';
|
||||
import { Button } from '@/components/button/button';
|
||||
import {
|
||||
canFieldsUseGinIndex,
|
||||
databaseIndexTypes,
|
||||
INDEX_TYPE_CONFIGS,
|
||||
type DBIndex,
|
||||
type IndexType,
|
||||
} from '@/lib/domain/db-index';
|
||||
@@ -25,6 +27,7 @@ import {
|
||||
TooltipTrigger,
|
||||
} from '@/components/tooltip/tooltip';
|
||||
import { useChartDB } from '@/hooks/use-chartdb';
|
||||
import { IndexTypeSelector } from './index-type-selector';
|
||||
|
||||
export interface TableIndexProps {
|
||||
index: DBIndex;
|
||||
@@ -33,11 +36,6 @@ export interface TableIndexProps {
|
||||
fields: DBField[];
|
||||
}
|
||||
|
||||
const allIndexTypeOptions: { label: string; value: IndexType }[] = [
|
||||
{ label: 'B-tree (default)', value: 'btree' },
|
||||
{ label: 'Hash', value: 'hash' },
|
||||
];
|
||||
|
||||
export const TableIndex: React.FC<TableIndexProps> = ({
|
||||
fields,
|
||||
index,
|
||||
@@ -46,10 +44,39 @@ export const TableIndex: React.FC<TableIndexProps> = ({
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { databaseType, readonly } = useChartDB();
|
||||
const fieldOptions = fields.map((field) => ({
|
||||
label: field.name,
|
||||
value: field.id,
|
||||
}));
|
||||
|
||||
const fieldOptions = useMemo(
|
||||
() =>
|
||||
fields.map((field) => ({
|
||||
label: field.name,
|
||||
value: field.id,
|
||||
})),
|
||||
[fields]
|
||||
);
|
||||
|
||||
const selectedFields = useMemo(
|
||||
() => fields.filter((f) => index.fieldIds.includes(f.id)),
|
||||
[fields, index.fieldIds]
|
||||
);
|
||||
|
||||
const canUseGin = useMemo(
|
||||
() => canFieldsUseGinIndex(selectedFields),
|
||||
[selectedFields]
|
||||
);
|
||||
|
||||
const availableIndexTypes = databaseIndexTypes[databaseType];
|
||||
|
||||
const indexTypeOptions = useMemo(() => {
|
||||
if (!availableIndexTypes) return [];
|
||||
|
||||
return INDEX_TYPE_CONFIGS.filter((config) =>
|
||||
availableIndexTypes.includes(config.value)
|
||||
).map((config) => ({
|
||||
...config,
|
||||
disabled: config.value === 'gin' && !canUseGin,
|
||||
}));
|
||||
}, [availableIndexTypes, canUseGin]);
|
||||
|
||||
const updateIndexFields = useCallback(
|
||||
(fieldIds: string | string[]) => {
|
||||
const ids = Array.isArray(fieldIds) ? fieldIds : [fieldIds];
|
||||
@@ -57,39 +84,43 @@ export const TableIndex: React.FC<TableIndexProps> = ({
|
||||
// For hash indexes, only keep the last selected field
|
||||
if (index.type === 'hash' && ids.length > 0) {
|
||||
updateIndex({ fieldIds: [ids[ids.length - 1]] });
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if GIN is still valid with new field selection
|
||||
const newSelectedFields = fields.filter((f) => ids.includes(f.id));
|
||||
const ginStillValid = canFieldsUseGinIndex(newSelectedFields);
|
||||
|
||||
if (index.type === 'gin' && !ginStillValid) {
|
||||
// Reset to btree if GIN is no longer valid
|
||||
updateIndex({ fieldIds: ids, type: 'btree' });
|
||||
} else {
|
||||
updateIndex({ fieldIds: ids });
|
||||
}
|
||||
},
|
||||
[index.type, updateIndex]
|
||||
);
|
||||
|
||||
const indexTypeOptions = useMemo(
|
||||
() =>
|
||||
allIndexTypeOptions.filter((option) =>
|
||||
databaseIndexTypes[databaseType]?.includes(option.value)
|
||||
),
|
||||
[databaseType]
|
||||
[index.type, updateIndex, fields]
|
||||
);
|
||||
|
||||
const updateIndexType = useCallback(
|
||||
(value: string | string[]) => {
|
||||
{
|
||||
const newType = value as IndexType;
|
||||
// If switching to hash and multiple fields are selected, keep only the first
|
||||
if (newType === 'hash' && index.fieldIds.length > 1) {
|
||||
updateIndex({
|
||||
type: newType,
|
||||
fieldIds: [index.fieldIds[0]],
|
||||
});
|
||||
} else {
|
||||
updateIndex({ type: newType });
|
||||
}
|
||||
(newType: IndexType) => {
|
||||
// If switching to hash and multiple fields are selected, keep only the first
|
||||
if (newType === 'hash' && index.fieldIds.length > 1) {
|
||||
updateIndex({
|
||||
type: newType,
|
||||
fieldIds: [index.fieldIds[0]],
|
||||
});
|
||||
} else {
|
||||
updateIndex({ type: newType });
|
||||
}
|
||||
},
|
||||
[updateIndex, index.fieldIds]
|
||||
);
|
||||
|
||||
const currentIndexType = index.type || 'btree';
|
||||
const currentTypeLabel =
|
||||
indexTypeOptions.find((opt) => opt.value === currentIndexType)?.label ||
|
||||
'Select type';
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-row justify-between gap-2 p-1">
|
||||
<SelectBox
|
||||
@@ -108,6 +139,7 @@ export const TableIndex: React.FC<TableIndexProps> = ({
|
||||
keepOrder
|
||||
disabled={index.isPrimaryKey ?? false}
|
||||
readonly={readonly}
|
||||
modal={false}
|
||||
/>
|
||||
<div className="flex shrink-0 gap-1">
|
||||
{index.isPrimaryKey ? (
|
||||
@@ -203,25 +235,17 @@ export const TableIndex: React.FC<TableIndexProps> = ({
|
||||
disabled={readonly}
|
||||
/>
|
||||
</div>
|
||||
{indexTypeOptions.length > 0 ? (
|
||||
<div className="mt-2 flex flex-col gap-2">
|
||||
<Label
|
||||
htmlFor="indexType"
|
||||
className="text-subtitle"
|
||||
>
|
||||
{t(
|
||||
'side_panel.tables_section.table.index_actions.index_type'
|
||||
)}
|
||||
</Label>
|
||||
<SelectBox
|
||||
options={indexTypeOptions}
|
||||
value={index.type || 'btree'}
|
||||
onChange={updateIndexType}
|
||||
readonly={readonly}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{!readonly ? (
|
||||
{indexTypeOptions.length > 0 && (
|
||||
<IndexTypeSelector
|
||||
options={indexTypeOptions}
|
||||
value={currentIndexType}
|
||||
label={currentTypeLabel}
|
||||
onChange={updateIndexType}
|
||||
readonly={readonly}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
{!readonly && (
|
||||
<>
|
||||
<Separator orientation="horizontal" />
|
||||
<Button
|
||||
@@ -235,7 +259,7 @@ export const TableIndex: React.FC<TableIndexProps> = ({
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
Reference in New Issue
Block a user