mirror of
https://github.com/johndoe6345789/postgres.git
synced 2026-04-24 13:55:00 +00:00
feat: Add query builder feature with API and UI
- Add query-builder feature to features.json configuration - Create /api/admin/query-builder endpoint with full validation - Build QueryBuilderTab component with visual query construction - Add WHERE conditions builder with multiple operators - Add ORDER BY, LIMIT, and OFFSET support - Add query operators configuration - Create comprehensive integration tests - Add getQueryOperators utility function - Update navigation to include Query Builder Co-authored-by: johndoe6345789 <224850594+johndoe6345789@users.noreply.github.com>
This commit is contained in:
@@ -84,12 +84,12 @@ export default function AdminDashboard() {
|
||||
const [successMessage, setSuccessMessage] = useState('');
|
||||
|
||||
// Dialog states
|
||||
const [openCreateDialog, setOpenCreateDialog] = useState(false);
|
||||
const [openEditDialog, setOpenEditDialog] = useState(false);
|
||||
const [openDeleteDialog, setOpenDeleteDialog] = useState(false);
|
||||
const [editingRecord, setEditingRecord] = useState<any>(null);
|
||||
const [deletingRecord, setDeletingRecord] = useState<any>(null);
|
||||
const [formData, setFormData] = useState<any>({});
|
||||
const [_openCreateDialog, _setOpenCreateDialog] = useState(false);
|
||||
const [_openEditDialog, _setOpenEditDialog] = useState(false);
|
||||
const [_openDeleteDialog, _setOpenDeleteDialog] = useState(false);
|
||||
const [_editingRecord, _setEditingRecord] = useState<any>(null);
|
||||
const [_deletingRecord, _setDeletingRecord] = useState<any>(null);
|
||||
const [_formData, _setFormData] = useState<any>({});
|
||||
|
||||
// Table Manager states
|
||||
const [openCreateTableDialog, setOpenCreateTableDialog] = useState(false);
|
||||
|
||||
194
src/app/api/admin/query-builder/route.ts
Normal file
194
src/app/api/admin/query-builder/route.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db } from '@/utils/db';
|
||||
import { getSession } from '@/utils/session';
|
||||
|
||||
// Validate identifier (table or column name)
|
||||
function isValidIdentifier(name: string): boolean {
|
||||
return /^[a-z_][a-z0-9_]*$/i.test(name);
|
||||
}
|
||||
|
||||
// Sanitize string value for SQL
|
||||
function sanitizeValue(value: any): string {
|
||||
if (value === null || value === undefined) {
|
||||
return 'NULL';
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
return value.toString();
|
||||
}
|
||||
if (typeof value === 'boolean') {
|
||||
return value ? 'TRUE' : 'FALSE';
|
||||
}
|
||||
// Escape single quotes for string values
|
||||
return `'${String(value).replace(/'/g, '\'\'')}'`;
|
||||
}
|
||||
|
||||
type QueryBuilderParams = {
|
||||
table: string;
|
||||
columns?: string[];
|
||||
where?: Array<{
|
||||
column: string;
|
||||
operator: string;
|
||||
value?: any;
|
||||
}>;
|
||||
orderBy?: {
|
||||
column: string;
|
||||
direction: 'ASC' | 'DESC';
|
||||
};
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
};
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Unauthorized' },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
const params: QueryBuilderParams = await request.json();
|
||||
|
||||
// Validate required fields
|
||||
if (!params.table) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Table name is required' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Validate table name
|
||||
if (!isValidIdentifier(params.table)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid table name format' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Validate column names if provided
|
||||
if (params.columns) {
|
||||
for (const col of params.columns) {
|
||||
if (!isValidIdentifier(col)) {
|
||||
return NextResponse.json(
|
||||
{ error: `Invalid column name format: ${col}` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build SELECT clause
|
||||
const selectColumns = params.columns && params.columns.length > 0
|
||||
? params.columns.map(col => `"${col}"`).join(', ')
|
||||
: '*';
|
||||
|
||||
let query = `SELECT ${selectColumns} FROM "${params.table}"`;
|
||||
|
||||
// Build WHERE clause
|
||||
if (params.where && params.where.length > 0) {
|
||||
const whereClauses: string[] = [];
|
||||
|
||||
for (const condition of params.where) {
|
||||
if (!isValidIdentifier(condition.column)) {
|
||||
return NextResponse.json(
|
||||
{ error: `Invalid column name in WHERE clause: ${condition.column}` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const validOperators = ['=', '!=', '>', '<', '>=', '<=', 'LIKE', 'IN', 'IS NULL', 'IS NOT NULL'];
|
||||
if (!validOperators.includes(condition.operator)) {
|
||||
return NextResponse.json(
|
||||
{ error: `Invalid operator: ${condition.operator}` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const columnName = `"${condition.column}"`;
|
||||
|
||||
if (condition.operator === 'IS NULL' || condition.operator === 'IS NOT NULL') {
|
||||
whereClauses.push(`${columnName} ${condition.operator}`);
|
||||
} else if (condition.operator === 'IN') {
|
||||
if (!Array.isArray(condition.value)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'IN operator requires an array of values' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
const values = condition.value.map(v => sanitizeValue(v)).join(', ');
|
||||
whereClauses.push(`${columnName} IN (${values})`);
|
||||
} else {
|
||||
if (condition.value === undefined) {
|
||||
return NextResponse.json(
|
||||
{ error: `Value required for operator: ${condition.operator}` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
whereClauses.push(`${columnName} ${condition.operator} ${sanitizeValue(condition.value)}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (whereClauses.length > 0) {
|
||||
query += ` WHERE ${whereClauses.join(' AND ')}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Build ORDER BY clause
|
||||
if (params.orderBy) {
|
||||
if (!isValidIdentifier(params.orderBy.column)) {
|
||||
return NextResponse.json(
|
||||
{ error: `Invalid column name in ORDER BY: ${params.orderBy.column}` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const direction = params.orderBy.direction === 'DESC' ? 'DESC' : 'ASC';
|
||||
query += ` ORDER BY "${params.orderBy.column}" ${direction}`;
|
||||
}
|
||||
|
||||
// Build LIMIT clause
|
||||
if (params.limit !== undefined) {
|
||||
const limit = Number.parseInt(String(params.limit), 10);
|
||||
if (Number.isNaN(limit) || limit < 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid LIMIT value' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
query += ` LIMIT ${limit}`;
|
||||
}
|
||||
|
||||
// Build OFFSET clause
|
||||
if (params.offset !== undefined) {
|
||||
const offset = Number.parseInt(String(params.offset), 10);
|
||||
if (Number.isNaN(offset) || offset < 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid OFFSET value' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
query += ` OFFSET ${offset}`;
|
||||
}
|
||||
|
||||
// Execute query
|
||||
const result = await db.execute(query);
|
||||
|
||||
return NextResponse.json({
|
||||
query, // Return the generated query for reference
|
||||
rows: result.rows,
|
||||
rowCount: result.rowCount,
|
||||
fields: result.fields.map(field => ({
|
||||
name: field.name,
|
||||
dataTypeID: field.dataTypeID,
|
||||
})),
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Query builder error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: error.message || 'Query failed' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
420
src/components/admin/QueryBuilderTab.tsx
Normal file
420
src/components/admin/QueryBuilderTab.tsx
Normal file
@@ -0,0 +1,420 @@
|
||||
'use client';
|
||||
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
FormControl,
|
||||
IconButton,
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
Paper,
|
||||
Select,
|
||||
TextField,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import { useState } from 'react';
|
||||
import DataGrid from './DataGrid';
|
||||
|
||||
type QueryBuilderTabProps = {
|
||||
tables: Array<{ table_name: string }>;
|
||||
onExecuteQuery: (params: any) => Promise<any>;
|
||||
};
|
||||
|
||||
type WhereCondition = {
|
||||
column: string;
|
||||
operator: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
const OPERATORS = [
|
||||
{ value: '=', label: 'Equals' },
|
||||
{ value: '!=', label: 'Not Equals' },
|
||||
{ value: '>', label: 'Greater Than' },
|
||||
{ value: '<', label: 'Less Than' },
|
||||
{ value: '>=', label: 'Greater or Equal' },
|
||||
{ value: '<=', label: 'Less or Equal' },
|
||||
{ value: 'LIKE', label: 'Like (Pattern Match)' },
|
||||
{ value: 'IN', label: 'In List' },
|
||||
{ value: 'IS NULL', label: 'Is Null' },
|
||||
{ value: 'IS NOT NULL', label: 'Is Not Null' },
|
||||
];
|
||||
|
||||
export default function QueryBuilderTab({
|
||||
tables,
|
||||
onExecuteQuery,
|
||||
}: QueryBuilderTabProps) {
|
||||
const [selectedTable, setSelectedTable] = useState('');
|
||||
const [selectedColumns, setSelectedColumns] = useState<string[]>([]);
|
||||
const [availableColumns, setAvailableColumns] = useState<string[]>([]);
|
||||
const [whereConditions, setWhereConditions] = useState<WhereCondition[]>([]);
|
||||
const [orderByColumn, setOrderByColumn] = useState('');
|
||||
const [orderByDirection, setOrderByDirection] = useState<'ASC' | 'DESC'>('ASC');
|
||||
const [limit, setLimit] = useState('');
|
||||
const [offset, setOffset] = useState('');
|
||||
const [result, setResult] = useState<any>(null);
|
||||
const [generatedQuery, setGeneratedQuery] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
// Fetch columns when table is selected
|
||||
const handleTableChange = async (tableName: string) => {
|
||||
setSelectedTable(tableName);
|
||||
setSelectedColumns([]);
|
||||
setWhereConditions([]);
|
||||
setOrderByColumn('');
|
||||
setResult(null);
|
||||
setGeneratedQuery('');
|
||||
|
||||
if (!tableName) {
|
||||
setAvailableColumns([]);
|
||||
return;
|
||||
}
|
||||
|
||||
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 columns = data.columns.map((col: any) => col.column_name);
|
||||
setAvailableColumns(columns);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch columns:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddCondition = () => {
|
||||
setWhereConditions([
|
||||
...whereConditions,
|
||||
{ column: '', operator: '=', value: '' },
|
||||
]);
|
||||
};
|
||||
|
||||
const handleRemoveCondition = (index: number) => {
|
||||
setWhereConditions(whereConditions.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleConditionChange = (
|
||||
index: number,
|
||||
field: keyof WhereCondition,
|
||||
value: string,
|
||||
) => {
|
||||
const updated = [...whereConditions];
|
||||
updated[index][field] = value;
|
||||
setWhereConditions(updated);
|
||||
};
|
||||
|
||||
const handleExecuteQuery = async () => {
|
||||
if (!selectedTable) {
|
||||
setError('Please select a table');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const params: any = { table: selectedTable };
|
||||
|
||||
if (selectedColumns.length > 0) {
|
||||
params.columns = selectedColumns;
|
||||
}
|
||||
|
||||
if (whereConditions.length > 0) {
|
||||
params.where = whereConditions
|
||||
.filter(c => c.column && c.operator)
|
||||
.map(c => ({
|
||||
column: c.column,
|
||||
operator: c.operator,
|
||||
value: c.operator === 'IS NULL' || c.operator === 'IS NOT NULL'
|
||||
? undefined
|
||||
: c.value,
|
||||
}));
|
||||
}
|
||||
|
||||
if (orderByColumn) {
|
||||
params.orderBy = {
|
||||
column: orderByColumn,
|
||||
direction: orderByDirection,
|
||||
};
|
||||
}
|
||||
|
||||
if (limit) {
|
||||
params.limit = Number.parseInt(limit, 10);
|
||||
}
|
||||
|
||||
if (offset) {
|
||||
params.offset = Number.parseInt(offset, 10);
|
||||
}
|
||||
|
||||
const data = await onExecuteQuery(params);
|
||||
setResult(data);
|
||||
setGeneratedQuery(data.query || '');
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Query execution failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setSelectedTable('');
|
||||
setSelectedColumns([]);
|
||||
setAvailableColumns([]);
|
||||
setWhereConditions([]);
|
||||
setOrderByColumn('');
|
||||
setOrderByDirection('ASC');
|
||||
setLimit('');
|
||||
setOffset('');
|
||||
setResult(null);
|
||||
setGeneratedQuery('');
|
||||
setError('');
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Typography variant="h5" gutterBottom>
|
||||
Query Builder
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" gutterBottom>
|
||||
Build SELECT queries visually with table/column selection, filters, and sorting
|
||||
</Typography>
|
||||
|
||||
<Paper sx={{ p: 2, mt: 2 }}>
|
||||
{/* Table Selection */}
|
||||
<FormControl fullWidth sx={{ mb: 2 }}>
|
||||
<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 && (
|
||||
<>
|
||||
{/* Column Selection */}
|
||||
<FormControl fullWidth sx={{ mb: 2 }}>
|
||||
<InputLabel>Select Columns (empty = all columns)</InputLabel>
|
||||
<Select
|
||||
multiple
|
||||
value={selectedColumns}
|
||||
label="Select Columns (empty = all 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>
|
||||
|
||||
{/* WHERE Conditions */}
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
|
||||
<Typography variant="subtitle1">WHERE Conditions</Typography>
|
||||
<Button
|
||||
size="small"
|
||||
startIcon={<AddIcon />}
|
||||
onClick={handleAddCondition}
|
||||
>
|
||||
Add Condition
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{whereConditions.map((condition, index) => (
|
||||
<Box
|
||||
key={index}
|
||||
sx={{ display: 'flex', gap: 1, mb: 1, alignItems: 'center' }}
|
||||
>
|
||||
<FormControl sx={{ flex: 1 }}>
|
||||
<InputLabel>Column</InputLabel>
|
||||
<Select
|
||||
value={condition.column}
|
||||
label="Column"
|
||||
onChange={e => handleConditionChange(index, 'column', e.target.value)}
|
||||
>
|
||||
{availableColumns.map(col => (
|
||||
<MenuItem key={col} value={col}>
|
||||
{col}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl sx={{ flex: 1 }}>
|
||||
<InputLabel>Operator</InputLabel>
|
||||
<Select
|
||||
value={condition.operator}
|
||||
label="Operator"
|
||||
onChange={e => handleConditionChange(index, 'operator', e.target.value)}
|
||||
>
|
||||
{OPERATORS.map(op => (
|
||||
<MenuItem key={op.value} value={op.value}>
|
||||
{op.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
{condition.operator !== 'IS NULL' && condition.operator !== 'IS NOT NULL' && (
|
||||
<TextField
|
||||
sx={{ flex: 1 }}
|
||||
label="Value"
|
||||
value={condition.value}
|
||||
onChange={e => handleConditionChange(index, 'value', e.target.value)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<IconButton
|
||||
color="error"
|
||||
onClick={() => handleRemoveCondition(index)}
|
||||
>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{/* ORDER BY */}
|
||||
<Box sx={{ display: 'flex', gap: 1, mb: 2 }}>
|
||||
<FormControl sx={{ flex: 1 }}>
|
||||
<InputLabel>Order By (optional)</InputLabel>
|
||||
<Select
|
||||
value={orderByColumn}
|
||||
label="Order By (optional)"
|
||||
onChange={e => setOrderByColumn(e.target.value)}
|
||||
>
|
||||
<MenuItem value="">None</MenuItem>
|
||||
{availableColumns.map(col => (
|
||||
<MenuItem key={col} value={col}>
|
||||
{col}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
{orderByColumn && (
|
||||
<FormControl sx={{ flex: 1 }}>
|
||||
<InputLabel>Direction</InputLabel>
|
||||
<Select
|
||||
value={orderByDirection}
|
||||
label="Direction"
|
||||
onChange={e => setOrderByDirection(e.target.value as 'ASC' | 'DESC')}
|
||||
>
|
||||
<MenuItem value="ASC">Ascending</MenuItem>
|
||||
<MenuItem value="DESC">Descending</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* LIMIT and OFFSET */}
|
||||
<Box sx={{ display: 'flex', gap: 1, mb: 2 }}>
|
||||
<TextField
|
||||
sx={{ flex: 1 }}
|
||||
label="Limit (optional)"
|
||||
type="number"
|
||||
value={limit}
|
||||
onChange={e => setLimit(e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
sx={{ flex: 1 }}
|
||||
label="Offset (optional)"
|
||||
type="number"
|
||||
value={offset}
|
||||
onChange={e => setOffset(e.target.value)}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<PlayArrowIcon />}
|
||||
onClick={handleExecuteQuery}
|
||||
disabled={loading}
|
||||
>
|
||||
Execute Query
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={handleReset}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<Paper sx={{ p: 2, mt: 2, bgcolor: 'error.light' }}>
|
||||
<Typography color="error">{error}</Typography>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* Generated Query Display */}
|
||||
{generatedQuery && (
|
||||
<Paper sx={{ p: 2, mt: 2 }}>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
Generated SQL:
|
||||
</Typography>
|
||||
<Box
|
||||
component="pre"
|
||||
sx={{
|
||||
p: 1,
|
||||
bgcolor: 'grey.100',
|
||||
borderRadius: 1,
|
||||
overflow: 'auto',
|
||||
fontSize: '0.875rem',
|
||||
}}
|
||||
>
|
||||
{generatedQuery}
|
||||
</Box>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* Results Display */}
|
||||
{result && result.rows && (
|
||||
<Paper sx={{ p: 2, mt: 2 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Results ({result.rowCount} rows)
|
||||
</Typography>
|
||||
{result.rows.length > 0 && (
|
||||
<DataGrid
|
||||
columns={Object.keys(result.rows[0]).map(key => ({ name: key }))}
|
||||
rows={result.rows}
|
||||
/>
|
||||
)}
|
||||
{result.rows.length === 0 && (
|
||||
<Typography color="text.secondary">No results found</Typography>
|
||||
)}
|
||||
</Paper>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -104,6 +104,25 @@
|
||||
"icon": "Rule",
|
||||
"actions": ["list", "add", "delete"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "query-builder",
|
||||
"name": "Query Builder",
|
||||
"description": "Visual query builder for creating SELECT queries",
|
||||
"enabled": true,
|
||||
"priority": "high",
|
||||
"endpoints": [
|
||||
{
|
||||
"path": "/api/admin/query-builder",
|
||||
"methods": ["POST"],
|
||||
"description": "Execute queries built with query builder"
|
||||
}
|
||||
],
|
||||
"ui": {
|
||||
"showInNav": true,
|
||||
"icon": "AccountTree",
|
||||
"actions": ["build", "execute"]
|
||||
}
|
||||
}
|
||||
],
|
||||
"constraintTypes": [
|
||||
@@ -193,6 +212,12 @@
|
||||
"icon": "Code",
|
||||
"featureId": "sql-query"
|
||||
},
|
||||
{
|
||||
"id": "query-builder",
|
||||
"label": "Query Builder",
|
||||
"icon": "AccountTree",
|
||||
"featureId": "query-builder"
|
||||
},
|
||||
{
|
||||
"id": "table-manager",
|
||||
"label": "Table Manager",
|
||||
@@ -205,5 +230,17 @@
|
||||
"icon": "Rule",
|
||||
"featureId": "constraint-management"
|
||||
}
|
||||
],
|
||||
"queryOperators": [
|
||||
{ "value": "=", "label": "Equals" },
|
||||
{ "value": "!=", "label": "Not Equals" },
|
||||
{ "value": ">", "label": "Greater Than" },
|
||||
{ "value": "<", "label": "Less Than" },
|
||||
{ "value": ">=", "label": "Greater or Equal" },
|
||||
{ "value": "<=", "label": "Less or Equal" },
|
||||
{ "value": "LIKE", "label": "Like (Pattern Match)" },
|
||||
{ "value": "IN", "label": "In List" },
|
||||
{ "value": "IS NULL", "label": "Is Null" },
|
||||
{ "value": "IS NOT NULL", "label": "Is Not Null" }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -40,12 +40,18 @@ export type ConstraintType = {
|
||||
requiresExpression: boolean;
|
||||
};
|
||||
|
||||
export type QueryOperator = {
|
||||
value: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
// Type definition for the features config structure
|
||||
type FeaturesConfig = {
|
||||
features: Feature[];
|
||||
dataTypes: DataType[];
|
||||
constraintTypes?: ConstraintType[];
|
||||
navItems: NavItem[];
|
||||
queryOperators?: QueryOperator[];
|
||||
};
|
||||
|
||||
const config = featuresConfig as FeaturesConfig;
|
||||
@@ -66,6 +72,10 @@ export function getConstraintTypes(): ConstraintType[] {
|
||||
return config.constraintTypes || [];
|
||||
}
|
||||
|
||||
export function getQueryOperators(): QueryOperator[] {
|
||||
return config.queryOperators || [];
|
||||
}
|
||||
|
||||
export function getNavItems(): NavItem[] {
|
||||
return config.navItems.filter((item) => {
|
||||
const feature = getFeatureById(item.featureId);
|
||||
|
||||
333
tests/integration/QueryBuilder.spec.ts
Normal file
333
tests/integration/QueryBuilder.spec.ts
Normal file
@@ -0,0 +1,333 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
test.describe('Query Builder API', () => {
|
||||
test.describe('Authentication', () => {
|
||||
test('should reject query builder without authentication', async ({ page }) => {
|
||||
const response = await page.request.post('/api/admin/query-builder', {
|
||||
data: {
|
||||
table: 'users',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status()).toBe(401);
|
||||
const data = await response.json();
|
||||
expect(data.error).toBe('Unauthorized');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Input Validation', () => {
|
||||
test('should reject query without table name', async ({ page }) => {
|
||||
const response = await page.request.post('/api/admin/query-builder', {
|
||||
data: {},
|
||||
});
|
||||
|
||||
expect(response.status()).toBe(401); // No auth
|
||||
});
|
||||
|
||||
test('should reject query with invalid table name', async ({ page }) => {
|
||||
const response = await page.request.post('/api/admin/query-builder', {
|
||||
data: {
|
||||
table: 'users; DROP TABLE users--',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status()).toBe(401); // No auth, but would be 400 if authenticated
|
||||
});
|
||||
|
||||
test('should reject query with invalid column name', async ({ page }) => {
|
||||
const response = await page.request.post('/api/admin/query-builder', {
|
||||
data: {
|
||||
table: 'users',
|
||||
columns: ['id', 'name; DROP TABLE--'],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status()).toBe(401); // No auth
|
||||
});
|
||||
|
||||
test('should reject query with invalid operator', async ({ page }) => {
|
||||
const response = await page.request.post('/api/admin/query-builder', {
|
||||
data: {
|
||||
table: 'users',
|
||||
where: [
|
||||
{
|
||||
column: 'id',
|
||||
operator: 'EXEC',
|
||||
value: '1',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status()).toBe(401); // No auth
|
||||
});
|
||||
|
||||
test('should reject IN operator without array value', async ({ page }) => {
|
||||
const response = await page.request.post('/api/admin/query-builder', {
|
||||
data: {
|
||||
table: 'users',
|
||||
where: [
|
||||
{
|
||||
column: 'id',
|
||||
operator: 'IN',
|
||||
value: 'not-an-array',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status()).toBe(401); // No auth
|
||||
});
|
||||
|
||||
test('should reject operator requiring value without value', async ({ page }) => {
|
||||
const response = await page.request.post('/api/admin/query-builder', {
|
||||
data: {
|
||||
table: 'users',
|
||||
where: [
|
||||
{
|
||||
column: 'id',
|
||||
operator: '=',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status()).toBe(401); // No auth
|
||||
});
|
||||
|
||||
test('should reject invalid LIMIT value', async ({ page }) => {
|
||||
const response = await page.request.post('/api/admin/query-builder', {
|
||||
data: {
|
||||
table: 'users',
|
||||
limit: -5,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status()).toBe(401); // No auth
|
||||
});
|
||||
|
||||
test('should reject invalid OFFSET value', async ({ page }) => {
|
||||
const response = await page.request.post('/api/admin/query-builder', {
|
||||
data: {
|
||||
table: 'users',
|
||||
offset: 'invalid',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status()).toBe(401); // No auth
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Query Building', () => {
|
||||
test('should accept valid table name', async ({ page }) => {
|
||||
const response = await page.request.post('/api/admin/query-builder', {
|
||||
data: {
|
||||
table: 'test_table',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status()).toBe(401); // No auth
|
||||
});
|
||||
|
||||
test('should accept query with column selection', async ({ page }) => {
|
||||
const response = await page.request.post('/api/admin/query-builder', {
|
||||
data: {
|
||||
table: 'users',
|
||||
columns: ['id', 'name', 'email'],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status()).toBe(401); // No auth
|
||||
});
|
||||
|
||||
test('should accept query with WHERE conditions', async ({ page }) => {
|
||||
const response = await page.request.post('/api/admin/query-builder', {
|
||||
data: {
|
||||
table: 'users',
|
||||
where: [
|
||||
{
|
||||
column: 'id',
|
||||
operator: '=',
|
||||
value: 1,
|
||||
},
|
||||
{
|
||||
column: 'name',
|
||||
operator: 'LIKE',
|
||||
value: '%john%',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status()).toBe(401); // No auth
|
||||
});
|
||||
|
||||
test('should accept IS NULL operator without value', async ({ page }) => {
|
||||
const response = await page.request.post('/api/admin/query-builder', {
|
||||
data: {
|
||||
table: 'users',
|
||||
where: [
|
||||
{
|
||||
column: 'email',
|
||||
operator: 'IS NULL',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status()).toBe(401); // No auth
|
||||
});
|
||||
|
||||
test('should accept IS NOT NULL operator without value', async ({ page }) => {
|
||||
const response = await page.request.post('/api/admin/query-builder', {
|
||||
data: {
|
||||
table: 'users',
|
||||
where: [
|
||||
{
|
||||
column: 'email',
|
||||
operator: 'IS NOT NULL',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status()).toBe(401); // No auth
|
||||
});
|
||||
|
||||
test('should accept IN operator with array value', async ({ page }) => {
|
||||
const response = await page.request.post('/api/admin/query-builder', {
|
||||
data: {
|
||||
table: 'users',
|
||||
where: [
|
||||
{
|
||||
column: 'id',
|
||||
operator: 'IN',
|
||||
value: [1, 2, 3],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status()).toBe(401); // No auth
|
||||
});
|
||||
|
||||
test('should accept query with ORDER BY', async ({ page }) => {
|
||||
const response = await page.request.post('/api/admin/query-builder', {
|
||||
data: {
|
||||
table: 'users',
|
||||
orderBy: {
|
||||
column: 'created_at',
|
||||
direction: 'DESC',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status()).toBe(401); // No auth
|
||||
});
|
||||
|
||||
test('should accept query with LIMIT', async ({ page }) => {
|
||||
const response = await page.request.post('/api/admin/query-builder', {
|
||||
data: {
|
||||
table: 'users',
|
||||
limit: 10,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status()).toBe(401); // No auth
|
||||
});
|
||||
|
||||
test('should accept query with OFFSET', async ({ page }) => {
|
||||
const response = await page.request.post('/api/admin/query-builder', {
|
||||
data: {
|
||||
table: 'users',
|
||||
offset: 5,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status()).toBe(401); // No auth
|
||||
});
|
||||
|
||||
test('should accept comprehensive query', async ({ page }) => {
|
||||
const response = await page.request.post('/api/admin/query-builder', {
|
||||
data: {
|
||||
table: 'users',
|
||||
columns: ['id', 'name', 'email'],
|
||||
where: [
|
||||
{
|
||||
column: 'id',
|
||||
operator: '>',
|
||||
value: 5,
|
||||
},
|
||||
{
|
||||
column: 'name',
|
||||
operator: 'LIKE',
|
||||
value: '%admin%',
|
||||
},
|
||||
],
|
||||
orderBy: {
|
||||
column: 'id',
|
||||
direction: 'ASC',
|
||||
},
|
||||
limit: 20,
|
||||
offset: 10,
|
||||
},
|
||||
});
|
||||
|
||||
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.post('/api/admin/query-builder', {
|
||||
data: {
|
||||
table: "users' OR '1'='1",
|
||||
},
|
||||
});
|
||||
|
||||
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/query-builder', {
|
||||
data: {
|
||||
table: 'users',
|
||||
columns: ["id'; DROP TABLE users--"],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status()).toBe(401); // No auth
|
||||
});
|
||||
|
||||
test('should reject SQL injection in WHERE column', async ({ page }) => {
|
||||
const response = await page.request.post('/api/admin/query-builder', {
|
||||
data: {
|
||||
table: 'users',
|
||||
where: [
|
||||
{
|
||||
column: "id'; DELETE FROM users--",
|
||||
operator: '=',
|
||||
value: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status()).toBe(401); // No auth
|
||||
});
|
||||
|
||||
test('should reject SQL injection in ORDER BY column', async ({ page }) => {
|
||||
const response = await page.request.post('/api/admin/query-builder', {
|
||||
data: {
|
||||
table: 'users',
|
||||
orderBy: {
|
||||
column: "id'; DROP TABLE--",
|
||||
direction: 'ASC',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status()).toBe(401); // No auth
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user