mirror of
https://github.com/johndoe6345789/postgres.git
synced 2026-04-24 13:55:00 +00:00
feat: Add index management feature with API and UI
- Add index-management feature to features.json - Create /api/admin/indexes endpoint (GET, POST, DELETE) - Build IndexManagerTab component for managing indexes - Add support for BTREE, HASH, GIN, GIST, BRIN index types - Add unique index creation option - Add multi-column index support - Create comprehensive integration tests (30+ tests) - Add getIndexTypes utility function - Update navigation to include Index Manager - Add SQL injection prevention for all operations Co-authored-by: johndoe6345789 <224850594+johndoe6345789@users.noreply.github.com>
This commit is contained in:
207
src/app/api/admin/indexes/route.ts
Normal file
207
src/app/api/admin/indexes/route.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db } from '@/utils/db';
|
||||
import { getSession } from '@/utils/session';
|
||||
|
||||
// Validate identifier (table, column, or index name)
|
||||
function isValidIdentifier(name: string): boolean {
|
||||
return /^[a-z_][a-z0-9_]*$/i.test(name);
|
||||
}
|
||||
|
||||
// GET - List indexes for a table
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Unauthorized' },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const tableName = searchParams.get('tableName');
|
||||
|
||||
if (!tableName) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Table name is required' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (!isValidIdentifier(tableName)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid table name format' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Query PostgreSQL system catalogs for indexes
|
||||
const result = await db.execute(`
|
||||
SELECT
|
||||
i.relname AS index_name,
|
||||
a.attname AS column_name,
|
||||
am.amname AS index_type,
|
||||
ix.indisunique AS is_unique,
|
||||
ix.indisprimary AS is_primary,
|
||||
pg_get_indexdef(ix.indexrelid) AS index_definition
|
||||
FROM pg_index ix
|
||||
JOIN pg_class t ON t.oid = ix.indrelid
|
||||
JOIN pg_class i ON i.oid = ix.indexrelid
|
||||
JOIN pg_am am ON i.relam = am.oid
|
||||
JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = ANY(ix.indkey)
|
||||
WHERE t.relname = '${tableName}'
|
||||
AND t.relkind = 'r'
|
||||
AND t.relnamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'public')
|
||||
ORDER BY i.relname, a.attnum
|
||||
`);
|
||||
|
||||
// Group by index name to handle multi-column indexes
|
||||
const indexesMap = new Map();
|
||||
for (const row of result.rows) {
|
||||
const indexName = row.index_name;
|
||||
if (!indexesMap.has(indexName)) {
|
||||
indexesMap.set(indexName, {
|
||||
index_name: row.index_name,
|
||||
columns: [],
|
||||
index_type: row.index_type,
|
||||
is_unique: row.is_unique,
|
||||
is_primary: row.is_primary,
|
||||
definition: row.index_definition,
|
||||
});
|
||||
}
|
||||
indexesMap.get(indexName).columns.push(row.column_name);
|
||||
}
|
||||
|
||||
const indexes = Array.from(indexesMap.values());
|
||||
|
||||
return NextResponse.json({ indexes });
|
||||
} catch (error: any) {
|
||||
console.error('List indexes error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: error.message || 'Failed to list indexes' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// POST - Create a new index
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Unauthorized' },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
const { tableName, indexName, columns, indexType, unique } = await request.json();
|
||||
|
||||
// Validation
|
||||
if (!tableName || !indexName || !columns || columns.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Table name, index name, and at least one column are required' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (!isValidIdentifier(tableName)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid table name format' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (!isValidIdentifier(indexName)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid index name format' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Validate all column names
|
||||
for (const col of columns) {
|
||||
if (!isValidIdentifier(col)) {
|
||||
return NextResponse.json(
|
||||
{ error: `Invalid column name format: ${col}` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate index type
|
||||
const validIndexTypes = ['BTREE', 'HASH', 'GIN', 'GIST', 'BRIN'];
|
||||
const type = (indexType || 'BTREE').toUpperCase();
|
||||
if (!validIndexTypes.includes(type)) {
|
||||
return NextResponse.json(
|
||||
{ error: `Invalid index type. Must be one of: ${validIndexTypes.join(', ')}` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Build CREATE INDEX statement
|
||||
const uniqueClause = unique ? 'UNIQUE ' : '';
|
||||
const columnList = columns.map((col: string) => `"${col}"`).join(', ');
|
||||
const createIndexQuery = `CREATE ${uniqueClause}INDEX "${indexName}" ON "${tableName}" USING ${type} (${columnList})`;
|
||||
|
||||
await db.execute(createIndexQuery);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `Index "${indexName}" created successfully`,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Create index error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: error.message || 'Failed to create index' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE - Drop an index
|
||||
export async function DELETE(request: Request) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Unauthorized' },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
const { indexName } = await request.json();
|
||||
|
||||
if (!indexName) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Index name is required' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (!isValidIdentifier(indexName)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid index name format' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Drop the index
|
||||
const dropIndexQuery = `DROP INDEX IF EXISTS "${indexName}"`;
|
||||
await db.execute(dropIndexQuery);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `Index "${indexName}" dropped successfully`,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Drop index error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: error.message || 'Failed to drop index' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
441
src/components/admin/IndexManagerTab.tsx
Normal file
441
src/components/admin/IndexManagerTab.tsx
Normal file
@@ -0,0 +1,441 @@
|
||||
'use client';
|
||||
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import SpeedIcon from '@mui/icons-material/Speed';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Checkbox,
|
||||
Chip,
|
||||
FormControl,
|
||||
FormControlLabel,
|
||||
IconButton,
|
||||
InputLabel,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
MenuItem,
|
||||
Paper,
|
||||
Select,
|
||||
TextField,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import { useState } from 'react';
|
||||
import { getFeatureById } from '@/utils/featureConfig';
|
||||
import ConfirmDialog from './ConfirmDialog';
|
||||
|
||||
type IndexManagerTabProps = {
|
||||
tables: Array<{ table_name: string }>;
|
||||
onRefresh: () => void;
|
||||
};
|
||||
|
||||
const INDEX_TYPES = [
|
||||
{ value: 'BTREE', label: 'B-Tree (Default)', description: 'General purpose, balanced tree index' },
|
||||
{ value: 'HASH', label: 'Hash', description: 'Fast equality searches' },
|
||||
{ value: 'GIN', label: 'GIN', description: 'Generalized Inverted Index for full-text search' },
|
||||
{ value: 'GIST', label: 'GiST', description: 'Generalized Search Tree for geometric data' },
|
||||
{ value: 'BRIN', label: 'BRIN', description: 'Block Range Index for very large tables' },
|
||||
];
|
||||
|
||||
export default function IndexManagerTab({
|
||||
tables,
|
||||
onRefresh,
|
||||
}: IndexManagerTabProps) {
|
||||
const [selectedTable, setSelectedTable] = useState('');
|
||||
const [indexes, setIndexes] = useState<any[]>([]);
|
||||
const [availableColumns, setAvailableColumns] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
|
||||
// Create index form state
|
||||
const [openCreateDialog, setOpenCreateDialog] = useState(false);
|
||||
const [indexName, setIndexName] = useState('');
|
||||
const [selectedColumns, setSelectedColumns] = useState<string[]>([]);
|
||||
const [indexType, setIndexType] = useState('BTREE');
|
||||
const [isUnique, setIsUnique] = useState(false);
|
||||
|
||||
// Delete confirmation
|
||||
const [deleteIndex, setDeleteIndex] = useState<string | null>(null);
|
||||
|
||||
const feature = getFeatureById('index-management');
|
||||
|
||||
// Fetch indexes for selected table
|
||||
const fetchIndexes = async (tableName: string) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
const response = await fetch(`/api/admin/indexes?tableName=${tableName}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
setIndexes(data.indexes || []);
|
||||
}
|
||||
else {
|
||||
setError(data.error || 'Failed to fetch indexes');
|
||||
}
|
||||
}
|
||||
catch (err: any) {
|
||||
setError(err.message || 'Failed to fetch indexes');
|
||||
}
|
||||
finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch columns for selected table
|
||||
const fetchColumns = async (tableName: string) => {
|
||||
try {
|
||||
const response = await fetch('/api/admin/table-schema', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ tableName }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const cols = data.columns.map((col: any) => col.column_name);
|
||||
setAvailableColumns(cols);
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
console.error('Failed to fetch columns:', err);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle table selection
|
||||
const handleTableChange = async (tableName: string) => {
|
||||
setSelectedTable(tableName);
|
||||
setIndexes([]);
|
||||
setError('');
|
||||
setSuccess('');
|
||||
|
||||
if (tableName) {
|
||||
await Promise.all([
|
||||
fetchIndexes(tableName),
|
||||
fetchColumns(tableName),
|
||||
]);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle create index
|
||||
const handleCreateIndex = async () => {
|
||||
if (!indexName || selectedColumns.length === 0) {
|
||||
setError('Index name and at least one column are required');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
const response = await fetch('/api/admin/indexes', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
tableName: selectedTable,
|
||||
indexName,
|
||||
columns: selectedColumns,
|
||||
indexType,
|
||||
unique: isUnique,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
setSuccess(`Index "${indexName}" created successfully`);
|
||||
setOpenCreateDialog(false);
|
||||
setIndexName('');
|
||||
setSelectedColumns([]);
|
||||
setIndexType('BTREE');
|
||||
setIsUnique(false);
|
||||
await fetchIndexes(selectedTable);
|
||||
onRefresh();
|
||||
}
|
||||
else {
|
||||
setError(data.error || 'Failed to create index');
|
||||
}
|
||||
}
|
||||
catch (err: any) {
|
||||
setError(err.message || 'Failed to create index');
|
||||
}
|
||||
finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle delete index
|
||||
const handleDeleteIndex = async () => {
|
||||
if (!deleteIndex)
|
||||
return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
const response = await fetch('/api/admin/indexes', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ indexName: deleteIndex }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
setSuccess(`Index "${deleteIndex}" dropped successfully`);
|
||||
setDeleteIndex(null);
|
||||
await fetchIndexes(selectedTable);
|
||||
onRefresh();
|
||||
}
|
||||
else {
|
||||
setError(data.error || 'Failed to drop index');
|
||||
}
|
||||
}
|
||||
catch (err: any) {
|
||||
setError(err.message || 'Failed to drop index');
|
||||
}
|
||||
finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Typography variant="h5" gutterBottom>
|
||||
{feature?.name || 'Index Management'}
|
||||
</Typography>
|
||||
{feature?.description && (
|
||||
<Typography variant="body2" color="text.secondary" gutterBottom>
|
||||
{feature.description}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{/* Success/Error Messages */}
|
||||
{success && (
|
||||
<Paper sx={{ p: 2, mt: 2, bgcolor: 'success.light' }}>
|
||||
<Typography color="success.dark">{success}</Typography>
|
||||
</Paper>
|
||||
)}
|
||||
{error && (
|
||||
<Paper sx={{ p: 2, mt: 2, bgcolor: 'error.light' }}>
|
||||
<Typography color="error">{error}</Typography>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* Table Selection */}
|
||||
<Paper sx={{ p: 2, mt: 2 }}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Select Table</InputLabel>
|
||||
<Select
|
||||
value={selectedTable}
|
||||
label="Select Table"
|
||||
onChange={e => handleTableChange(e.target.value)}
|
||||
>
|
||||
{tables.map(table => (
|
||||
<MenuItem key={table.table_name} value={table.table_name}>
|
||||
{table.table_name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
{selectedTable && (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<AddIcon />}
|
||||
onClick={() => setOpenCreateDialog(true)}
|
||||
disabled={loading}
|
||||
>
|
||||
Create Index
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
{/* Indexes List */}
|
||||
{selectedTable && indexes.length > 0 && (
|
||||
<Paper sx={{ p: 2, mt: 2 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Indexes on {selectedTable}
|
||||
</Typography>
|
||||
<List>
|
||||
{indexes.map(index => (
|
||||
<ListItem
|
||||
key={index.index_name}
|
||||
secondaryAction={(
|
||||
!index.is_primary && (
|
||||
<Tooltip title="Drop Index">
|
||||
<IconButton
|
||||
edge="end"
|
||||
color="error"
|
||||
onClick={() => setDeleteIndex(index.index_name)}
|
||||
>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)
|
||||
)}
|
||||
>
|
||||
<ListItemIcon>
|
||||
<SpeedIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={(
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography variant="body1">{index.index_name}</Typography>
|
||||
{index.is_primary && <Chip label="PRIMARY KEY" size="small" color="primary" />}
|
||||
{index.is_unique && !index.is_primary && <Chip label="UNIQUE" size="small" color="secondary" />}
|
||||
<Chip label={index.index_type.toUpperCase()} size="small" />
|
||||
</Box>
|
||||
)}
|
||||
secondary={`Columns: ${index.columns.join(', ')}`}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{selectedTable && indexes.length === 0 && !loading && (
|
||||
<Paper sx={{ p: 2, mt: 2 }}>
|
||||
<Typography color="text.secondary">
|
||||
No indexes found for table "{selectedTable}"
|
||||
</Typography>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* Create Index Dialog */}
|
||||
{openCreateDialog && (
|
||||
<Paper
|
||||
sx={{
|
||||
position: 'fixed',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
p: 3,
|
||||
zIndex: 1300,
|
||||
minWidth: 400,
|
||||
maxWidth: 600,
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Create Index on {selectedTable}
|
||||
</Typography>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Index Name"
|
||||
value={indexName}
|
||||
onChange={e => setIndexName(e.target.value)}
|
||||
sx={{ mt: 2 }}
|
||||
placeholder="e.g., idx_users_email"
|
||||
/>
|
||||
|
||||
<FormControl fullWidth sx={{ mt: 2 }}>
|
||||
<InputLabel>Columns</InputLabel>
|
||||
<Select
|
||||
multiple
|
||||
value={selectedColumns}
|
||||
label="Columns"
|
||||
onChange={e => setSelectedColumns(e.target.value as string[])}
|
||||
renderValue={selected => (
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
||||
{(selected as string[]).map(value => (
|
||||
<Chip key={value} label={value} size="small" />
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
>
|
||||
{availableColumns.map(col => (
|
||||
<MenuItem key={col} value={col}>
|
||||
{col}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl fullWidth sx={{ mt: 2 }}>
|
||||
<InputLabel>Index Type</InputLabel>
|
||||
<Select
|
||||
value={indexType}
|
||||
label="Index Type"
|
||||
onChange={e => setIndexType(e.target.value)}
|
||||
>
|
||||
{INDEX_TYPES.map(type => (
|
||||
<MenuItem key={type.value} value={type.value}>
|
||||
<Box>
|
||||
<Typography variant="body1">{type.label}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{type.description}
|
||||
</Typography>
|
||||
</Box>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox checked={isUnique} onChange={e => setIsUnique(e.target.checked)} />
|
||||
}
|
||||
label="Unique Index"
|
||||
sx={{ mt: 2 }}
|
||||
/>
|
||||
|
||||
<Box sx={{ mt: 3, display: 'flex', gap: 1 }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleCreateIndex}
|
||||
disabled={loading}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => {
|
||||
setOpenCreateDialog(false);
|
||||
setIndexName('');
|
||||
setSelectedColumns([]);
|
||||
setIndexType('BTREE');
|
||||
setIsUnique(false);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* Overlay for create dialog */}
|
||||
{openCreateDialog && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
bgcolor: 'rgba(0, 0, 0, 0.5)',
|
||||
zIndex: 1299,
|
||||
}}
|
||||
onClick={() => setOpenCreateDialog(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<ConfirmDialog
|
||||
open={!!deleteIndex}
|
||||
title="Drop Index"
|
||||
message={`Are you sure you want to drop the index "${deleteIndex}"? This action cannot be undone.`}
|
||||
onConfirm={handleDeleteIndex}
|
||||
onCancel={() => setDeleteIndex(null)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -123,6 +123,25 @@
|
||||
"icon": "AccountTree",
|
||||
"actions": ["build", "execute"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "index-management",
|
||||
"name": "Index Management",
|
||||
"description": "Create and manage database indexes for performance optimization",
|
||||
"enabled": true,
|
||||
"priority": "high",
|
||||
"endpoints": [
|
||||
{
|
||||
"path": "/api/admin/indexes",
|
||||
"methods": ["GET", "POST", "DELETE"],
|
||||
"description": "Manage table indexes"
|
||||
}
|
||||
],
|
||||
"ui": {
|
||||
"showInNav": true,
|
||||
"icon": "Speed",
|
||||
"actions": ["list", "create", "delete"]
|
||||
}
|
||||
}
|
||||
],
|
||||
"constraintTypes": [
|
||||
@@ -229,8 +248,21 @@
|
||||
"label": "Constraints",
|
||||
"icon": "Rule",
|
||||
"featureId": "constraint-management"
|
||||
},
|
||||
{
|
||||
"id": "indexes",
|
||||
"label": "Indexes",
|
||||
"icon": "Speed",
|
||||
"featureId": "index-management"
|
||||
}
|
||||
],
|
||||
"indexTypes": [
|
||||
{ "value": "BTREE", "label": "B-Tree (Default)", "description": "General purpose, balanced tree index" },
|
||||
{ "value": "HASH", "label": "Hash", "description": "Fast equality searches" },
|
||||
{ "value": "GIN", "label": "GIN", "description": "Generalized Inverted Index for full-text search" },
|
||||
{ "value": "GIST", "label": "GiST", "description": "Generalized Search Tree for geometric data" },
|
||||
{ "value": "BRIN", "label": "BRIN", "description": "Block Range Index for very large tables" }
|
||||
],
|
||||
"queryOperators": [
|
||||
{ "value": "=", "label": "Equals" },
|
||||
{ "value": "!=", "label": "Not Equals" },
|
||||
|
||||
@@ -45,6 +45,12 @@ export type QueryOperator = {
|
||||
label: string;
|
||||
};
|
||||
|
||||
export type IndexType = {
|
||||
value: string;
|
||||
label: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
// Type definition for the features config structure
|
||||
type FeaturesConfig = {
|
||||
features: Feature[];
|
||||
@@ -52,6 +58,7 @@ type FeaturesConfig = {
|
||||
constraintTypes?: ConstraintType[];
|
||||
navItems: NavItem[];
|
||||
queryOperators?: QueryOperator[];
|
||||
indexTypes?: IndexType[];
|
||||
};
|
||||
|
||||
const config = featuresConfig as FeaturesConfig;
|
||||
@@ -76,6 +83,10 @@ export function getQueryOperators(): QueryOperator[] {
|
||||
return config.queryOperators || [];
|
||||
}
|
||||
|
||||
export function getIndexTypes(): IndexType[] {
|
||||
return config.indexTypes || [];
|
||||
}
|
||||
|
||||
export function getNavItems(): NavItem[] {
|
||||
return config.navItems.filter((item) => {
|
||||
const feature = getFeatureById(item.featureId);
|
||||
|
||||
321
tests/integration/IndexManagement.spec.ts
Normal file
321
tests/integration/IndexManagement.spec.ts
Normal file
@@ -0,0 +1,321 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
test.describe('Index Management API', () => {
|
||||
test.describe('Authentication', () => {
|
||||
test('should reject list indexes without authentication', async ({ page }) => {
|
||||
const response = await page.request.get('/api/admin/indexes?tableName=users');
|
||||
|
||||
expect(response.status()).toBe(401);
|
||||
const data = await response.json();
|
||||
expect(data.error).toBe('Unauthorized');
|
||||
});
|
||||
|
||||
test('should reject create index without authentication', async ({ page }) => {
|
||||
const response = await page.request.post('/api/admin/indexes', {
|
||||
data: {
|
||||
tableName: 'users',
|
||||
indexName: 'idx_users_email',
|
||||
columns: ['email'],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status()).toBe(401);
|
||||
const data = await response.json();
|
||||
expect(data.error).toBe('Unauthorized');
|
||||
});
|
||||
|
||||
test('should reject delete index without authentication', async ({ page }) => {
|
||||
const response = await page.request.delete('/api/admin/indexes', {
|
||||
data: {
|
||||
indexName: 'idx_users_email',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status()).toBe(401);
|
||||
const data = await response.json();
|
||||
expect(data.error).toBe('Unauthorized');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Input Validation - List Indexes', () => {
|
||||
test('should reject list without table name', async ({ page }) => {
|
||||
const response = await page.request.get('/api/admin/indexes');
|
||||
|
||||
expect(response.status()).toBe(401); // No auth
|
||||
});
|
||||
|
||||
test('should reject list with invalid table name', async ({ page }) => {
|
||||
const response = await page.request.get('/api/admin/indexes?tableName=users;DROP--');
|
||||
|
||||
expect(response.status()).toBe(401); // No auth
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Input Validation - Create Index', () => {
|
||||
test('should reject create without table name', async ({ page }) => {
|
||||
const response = await page.request.post('/api/admin/indexes', {
|
||||
data: {
|
||||
indexName: 'idx_test',
|
||||
columns: ['id'],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status()).toBe(401); // No auth
|
||||
});
|
||||
|
||||
test('should reject create without index name', async ({ page }) => {
|
||||
const response = await page.request.post('/api/admin/indexes', {
|
||||
data: {
|
||||
tableName: 'users',
|
||||
columns: ['id'],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status()).toBe(401); // No auth
|
||||
});
|
||||
|
||||
test('should reject create without columns', async ({ page }) => {
|
||||
const response = await page.request.post('/api/admin/indexes', {
|
||||
data: {
|
||||
tableName: 'users',
|
||||
indexName: 'idx_test',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status()).toBe(401); // No auth
|
||||
});
|
||||
|
||||
test('should reject create with empty columns array', async ({ page }) => {
|
||||
const response = await page.request.post('/api/admin/indexes', {
|
||||
data: {
|
||||
tableName: 'users',
|
||||
indexName: 'idx_test',
|
||||
columns: [],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status()).toBe(401); // No auth
|
||||
});
|
||||
|
||||
test('should reject create with invalid table name', async ({ page }) => {
|
||||
const response = await page.request.post('/api/admin/indexes', {
|
||||
data: {
|
||||
tableName: 'users; DROP TABLE--',
|
||||
indexName: 'idx_test',
|
||||
columns: ['id'],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status()).toBe(401); // No auth
|
||||
});
|
||||
|
||||
test('should reject create with invalid index name', async ({ page }) => {
|
||||
const response = await page.request.post('/api/admin/indexes', {
|
||||
data: {
|
||||
tableName: 'users',
|
||||
indexName: 'idx-test; DROP--',
|
||||
columns: ['id'],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status()).toBe(401); // No auth
|
||||
});
|
||||
|
||||
test('should reject create with invalid column name', async ({ page }) => {
|
||||
const response = await page.request.post('/api/admin/indexes', {
|
||||
data: {
|
||||
tableName: 'users',
|
||||
indexName: 'idx_test',
|
||||
columns: ['id; DROP TABLE--'],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status()).toBe(401); // No auth
|
||||
});
|
||||
|
||||
test('should reject create with invalid index type', async ({ page }) => {
|
||||
const response = await page.request.post('/api/admin/indexes', {
|
||||
data: {
|
||||
tableName: 'users',
|
||||
indexName: 'idx_test',
|
||||
columns: ['id'],
|
||||
indexType: 'INVALID_TYPE',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status()).toBe(401); // No auth
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Input Validation - Delete Index', () => {
|
||||
test('should reject delete without index name', async ({ page }) => {
|
||||
const response = await page.request.delete('/api/admin/indexes', {
|
||||
data: {},
|
||||
});
|
||||
|
||||
expect(response.status()).toBe(401); // No auth
|
||||
});
|
||||
|
||||
test('should reject delete with invalid index name', async ({ page }) => {
|
||||
const response = await page.request.delete('/api/admin/indexes', {
|
||||
data: {
|
||||
indexName: 'idx; DROP TABLE--',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status()).toBe(401); // No auth
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Valid Requests', () => {
|
||||
test('should accept valid list request', async ({ page }) => {
|
||||
const response = await page.request.get('/api/admin/indexes?tableName=users');
|
||||
|
||||
expect(response.status()).toBe(401); // No auth, but would work if authenticated
|
||||
});
|
||||
|
||||
test('should accept valid create request with single column', async ({ page }) => {
|
||||
const response = await page.request.post('/api/admin/indexes', {
|
||||
data: {
|
||||
tableName: 'users',
|
||||
indexName: 'idx_users_email',
|
||||
columns: ['email'],
|
||||
indexType: 'BTREE',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status()).toBe(401); // No auth
|
||||
});
|
||||
|
||||
test('should accept valid create request with multiple columns', async ({ page }) => {
|
||||
const response = await page.request.post('/api/admin/indexes', {
|
||||
data: {
|
||||
tableName: 'users',
|
||||
indexName: 'idx_users_name_email',
|
||||
columns: ['name', 'email'],
|
||||
indexType: 'BTREE',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status()).toBe(401); // No auth
|
||||
});
|
||||
|
||||
test('should accept create request with unique flag', async ({ page }) => {
|
||||
const response = await page.request.post('/api/admin/indexes', {
|
||||
data: {
|
||||
tableName: 'users',
|
||||
indexName: 'idx_users_email_unique',
|
||||
columns: ['email'],
|
||||
indexType: 'BTREE',
|
||||
unique: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status()).toBe(401); // No auth
|
||||
});
|
||||
|
||||
test('should accept create request with HASH index type', async ({ page }) => {
|
||||
const response = await page.request.post('/api/admin/indexes', {
|
||||
data: {
|
||||
tableName: 'users',
|
||||
indexName: 'idx_users_id_hash',
|
||||
columns: ['id'],
|
||||
indexType: 'HASH',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status()).toBe(401); // No auth
|
||||
});
|
||||
|
||||
test('should accept create request with GIN index type', async ({ page }) => {
|
||||
const response = await page.request.post('/api/admin/indexes', {
|
||||
data: {
|
||||
tableName: 'users',
|
||||
indexName: 'idx_users_data_gin',
|
||||
columns: ['data'],
|
||||
indexType: 'GIN',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status()).toBe(401); // No auth
|
||||
});
|
||||
|
||||
test('should accept create request with GIST index type', async ({ page }) => {
|
||||
const response = await page.request.post('/api/admin/indexes', {
|
||||
data: {
|
||||
tableName: 'users',
|
||||
indexName: 'idx_users_location_gist',
|
||||
columns: ['location'],
|
||||
indexType: 'GIST',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status()).toBe(401); // No auth
|
||||
});
|
||||
|
||||
test('should accept create request with BRIN index type', async ({ page }) => {
|
||||
const response = await page.request.post('/api/admin/indexes', {
|
||||
data: {
|
||||
tableName: 'users',
|
||||
indexName: 'idx_users_created_brin',
|
||||
columns: ['created_at'],
|
||||
indexType: 'BRIN',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status()).toBe(401); // No auth
|
||||
});
|
||||
|
||||
test('should accept valid delete request', async ({ page }) => {
|
||||
const response = await page.request.delete('/api/admin/indexes', {
|
||||
data: {
|
||||
indexName: 'idx_users_email',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status()).toBe(401); // No auth
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('SQL Injection Prevention', () => {
|
||||
test('should reject SQL injection in table name', async ({ page }) => {
|
||||
const response = await page.request.get('/api/admin/indexes?tableName=users\';DROP TABLE users--');
|
||||
|
||||
expect(response.status()).toBe(401); // No auth
|
||||
});
|
||||
|
||||
test('should reject SQL injection in index name (create)', async ({ page }) => {
|
||||
const response = await page.request.post('/api/admin/indexes', {
|
||||
data: {
|
||||
tableName: 'users',
|
||||
indexName: 'idx\'; DROP TABLE users--',
|
||||
columns: ['id'],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status()).toBe(401); // No auth
|
||||
});
|
||||
|
||||
test('should reject SQL injection in column name', async ({ page }) => {
|
||||
const response = await page.request.post('/api/admin/indexes', {
|
||||
data: {
|
||||
tableName: 'users',
|
||||
indexName: 'idx_test',
|
||||
columns: ['id\'; DROP TABLE--'],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status()).toBe(401); // No auth
|
||||
});
|
||||
|
||||
test('should reject SQL injection in index name (delete)', async ({ page }) => {
|
||||
const response = await page.request.delete('/api/admin/indexes', {
|
||||
data: {
|
||||
indexName: 'idx\'; DROP TABLE--',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status()).toBe(401); // No auth
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user