mirror of
https://github.com/johndoe6345789/postgres.git
synced 2026-04-24 13:55:00 +00:00
feat: Add constraint management API and configuration
- Add constraint-management feature to features.json - Create /api/admin/constraints endpoint (GET, POST, DELETE) - Support UNIQUE and CHECK constraints - Add getConstraintTypes() utility function - Add integration tests for constraints API - Add unit tests for constraint types - Follow CODE_STYLE.md guidelines Co-authored-by: johndoe6345789 <224850594+johndoe6345789@users.noreply.github.com>
This commit is contained in:
236
src/app/api/admin/constraints/route.ts
Normal file
236
src/app/api/admin/constraints/route.ts
Normal file
@@ -0,0 +1,236 @@
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db } from '@/utils/db';
|
||||
import { getSession } from '@/utils/session';
|
||||
|
||||
// Validate identifier format (prevent SQL injection)
|
||||
function isValidIdentifier(name: string): boolean {
|
||||
return /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name);
|
||||
}
|
||||
|
||||
// Validate table exists
|
||||
async function validateTable(tableName: string): Promise<boolean> {
|
||||
const result = await db.execute(sql`
|
||||
SELECT table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = ${tableName}
|
||||
`);
|
||||
return result.rows.length > 0;
|
||||
}
|
||||
|
||||
// LIST CONSTRAINTS
|
||||
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 },
|
||||
);
|
||||
}
|
||||
|
||||
// Validate identifier
|
||||
if (!isValidIdentifier(tableName)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid table name format' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Validate table exists
|
||||
if (!(await validateTable(tableName))) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Table not found' },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
// Get all constraints for the table
|
||||
const constraints = await db.execute(sql`
|
||||
SELECT
|
||||
tc.constraint_name,
|
||||
tc.constraint_type,
|
||||
kcu.column_name,
|
||||
cc.check_clause
|
||||
FROM information_schema.table_constraints tc
|
||||
LEFT JOIN information_schema.key_column_usage kcu
|
||||
ON tc.constraint_name = kcu.constraint_name
|
||||
AND tc.table_schema = kcu.table_schema
|
||||
AND tc.table_name = kcu.table_name
|
||||
LEFT JOIN information_schema.check_constraints cc
|
||||
ON tc.constraint_name = cc.constraint_name
|
||||
WHERE tc.table_schema = 'public'
|
||||
AND tc.table_name = ${tableName}
|
||||
AND tc.constraint_type IN ('UNIQUE', 'CHECK')
|
||||
ORDER BY tc.constraint_name
|
||||
`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
constraints: constraints.rows,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('List constraints error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: error.message || 'Failed to list constraints' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ADD CONSTRAINT
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Unauthorized' },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
const { tableName, constraintName, constraintType, columnName, checkExpression } = await request.json();
|
||||
|
||||
if (!tableName || !constraintName || !constraintType) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Table name, constraint name, and constraint type are required' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Validate identifiers
|
||||
if (!isValidIdentifier(tableName) || !isValidIdentifier(constraintName)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid table or constraint name format' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Validate column name if provided
|
||||
if (columnName && !isValidIdentifier(columnName)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid column name format' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Validate table exists
|
||||
if (!(await validateTable(tableName))) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Table not found' },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
let alterQuery = '';
|
||||
|
||||
if (constraintType === 'UNIQUE') {
|
||||
if (!columnName) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Column name is required for UNIQUE constraint' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
alterQuery = `ALTER TABLE "${tableName}" ADD CONSTRAINT "${constraintName}" UNIQUE ("${columnName}")`;
|
||||
} else if (constraintType === 'CHECK') {
|
||||
if (!checkExpression) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Check expression is required for CHECK constraint' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
// Basic validation for check expression - prevent obvious SQL injection attempts
|
||||
if (checkExpression.includes(';') || checkExpression.toLowerCase().includes('drop ')) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid check expression' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
alterQuery = `ALTER TABLE "${tableName}" ADD CONSTRAINT "${constraintName}" CHECK (${checkExpression})`;
|
||||
} else {
|
||||
return NextResponse.json(
|
||||
{ error: 'Unsupported constraint type. Supported types: UNIQUE, CHECK' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
await db.execute(sql.raw(alterQuery));
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `Constraint '${constraintName}' added successfully`,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Add constraint error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: error.message || 'Failed to add constraint' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// DROP CONSTRAINT
|
||||
export async function DELETE(request: Request) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Unauthorized' },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
const { tableName, constraintName } = await request.json();
|
||||
|
||||
if (!tableName || !constraintName) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Table name and constraint name are required' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Validate identifiers
|
||||
if (!isValidIdentifier(tableName) || !isValidIdentifier(constraintName)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid table or constraint name format' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Validate table exists
|
||||
if (!(await validateTable(tableName))) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Table not found' },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
const alterQuery = `ALTER TABLE "${tableName}" DROP CONSTRAINT "${constraintName}"`;
|
||||
await db.execute(sql.raw(alterQuery));
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `Constraint '${constraintName}' dropped successfully`,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Drop constraint error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: error.message || 'Failed to drop constraint' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -85,6 +85,39 @@
|
||||
"icon": "Code",
|
||||
"actions": ["execute"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "constraint-management",
|
||||
"name": "Constraint Management",
|
||||
"description": "Add and manage table constraints (UNIQUE, CHECK)",
|
||||
"enabled": true,
|
||||
"priority": "high",
|
||||
"endpoints": [
|
||||
{
|
||||
"path": "/api/admin/constraints",
|
||||
"methods": ["GET", "POST", "DELETE"],
|
||||
"description": "Manage table constraints"
|
||||
}
|
||||
],
|
||||
"ui": {
|
||||
"showInNav": true,
|
||||
"icon": "Rule",
|
||||
"actions": ["list", "add", "delete"]
|
||||
}
|
||||
}
|
||||
],
|
||||
"constraintTypes": [
|
||||
{
|
||||
"name": "UNIQUE",
|
||||
"description": "Ensure column values are unique",
|
||||
"requiresColumn": true,
|
||||
"requiresExpression": false
|
||||
},
|
||||
{
|
||||
"name": "CHECK",
|
||||
"description": "Validate data using a boolean expression",
|
||||
"requiresColumn": false,
|
||||
"requiresExpression": true
|
||||
}
|
||||
],
|
||||
"dataTypes": [
|
||||
@@ -159,6 +192,12 @@
|
||||
"label": "Table Manager",
|
||||
"icon": "TableChart",
|
||||
"featureId": "table-management"
|
||||
},
|
||||
{
|
||||
"id": "constraints",
|
||||
"label": "Constraints",
|
||||
"icon": "Rule",
|
||||
"featureId": "constraint-management"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
getConstraintTypes,
|
||||
getDataTypes,
|
||||
getEnabledFeaturesByPriority,
|
||||
getFeatureById,
|
||||
@@ -257,6 +258,47 @@ describe('FeatureConfig', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('getConstraintTypes', () => {
|
||||
it('should return array of constraint types', () => {
|
||||
const constraintTypes = getConstraintTypes();
|
||||
|
||||
expect(Array.isArray(constraintTypes)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return constraint types with required properties', () => {
|
||||
const constraintTypes = getConstraintTypes();
|
||||
|
||||
constraintTypes.forEach(constraintType => {
|
||||
expect(constraintType).toHaveProperty('name');
|
||||
expect(constraintType).toHaveProperty('description');
|
||||
expect(constraintType).toHaveProperty('requiresColumn');
|
||||
expect(constraintType).toHaveProperty('requiresExpression');
|
||||
expect(typeof constraintType.name).toBe('string');
|
||||
expect(typeof constraintType.description).toBe('string');
|
||||
expect(typeof constraintType.requiresColumn).toBe('boolean');
|
||||
expect(typeof constraintType.requiresExpression).toBe('boolean');
|
||||
});
|
||||
});
|
||||
|
||||
it('should include UNIQUE constraint type', () => {
|
||||
const constraintTypes = getConstraintTypes();
|
||||
const uniqueConstraint = constraintTypes.find(ct => ct.name === 'UNIQUE');
|
||||
|
||||
expect(uniqueConstraint).toBeDefined();
|
||||
expect(uniqueConstraint?.requiresColumn).toBe(true);
|
||||
expect(uniqueConstraint?.requiresExpression).toBe(false);
|
||||
});
|
||||
|
||||
it('should include CHECK constraint type', () => {
|
||||
const constraintTypes = getConstraintTypes();
|
||||
const checkConstraint = constraintTypes.find(ct => ct.name === 'CHECK');
|
||||
|
||||
expect(checkConstraint).toBeDefined();
|
||||
expect(checkConstraint?.requiresColumn).toBe(false);
|
||||
expect(checkConstraint?.requiresExpression).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Feature endpoints', () => {
|
||||
it('should have valid endpoint structure for database-crud', () => {
|
||||
const feature = getFeatureById('database-crud');
|
||||
|
||||
@@ -33,6 +33,13 @@ export type NavItem = {
|
||||
featureId: string;
|
||||
};
|
||||
|
||||
export type ConstraintType = {
|
||||
name: string;
|
||||
description: string;
|
||||
requiresColumn: boolean;
|
||||
requiresExpression: boolean;
|
||||
};
|
||||
|
||||
export function getFeatures(): Feature[] {
|
||||
return featuresConfig.features.filter(f => f.enabled);
|
||||
}
|
||||
@@ -45,6 +52,10 @@ export function getDataTypes(): DataType[] {
|
||||
return featuresConfig.dataTypes;
|
||||
}
|
||||
|
||||
export function getConstraintTypes(): ConstraintType[] {
|
||||
return (featuresConfig as any).constraintTypes || [];
|
||||
}
|
||||
|
||||
export function getNavItems(): NavItem[] {
|
||||
return featuresConfig.navItems.filter(item => {
|
||||
const feature = getFeatureById(item.featureId);
|
||||
|
||||
144
tests/integration/ConstraintManager.spec.ts
Normal file
144
tests/integration/ConstraintManager.spec.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
test.describe('Constraint Manager', () => {
|
||||
test.describe('List Constraints API', () => {
|
||||
test('should reject list constraints without authentication', async ({ page }) => {
|
||||
const response = await page.request.get('/api/admin/constraints?tableName=test_table');
|
||||
|
||||
expect(response.status()).toBe(401);
|
||||
});
|
||||
|
||||
test('should reject list constraints without table name', async ({ page }) => {
|
||||
const response = await page.request.get('/api/admin/constraints');
|
||||
|
||||
expect([400, 401]).toContain(response.status());
|
||||
});
|
||||
|
||||
test('should reject list constraints with invalid table name', async ({ page }) => {
|
||||
const response = await page.request.get('/api/admin/constraints?tableName=invalid-table!@#');
|
||||
|
||||
expect([400, 401]).toContain(response.status());
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Add Constraint API', () => {
|
||||
test('should reject add constraint without authentication', async ({ page }) => {
|
||||
const response = await page.request.post('/api/admin/constraints', {
|
||||
data: {
|
||||
tableName: 'test_table',
|
||||
constraintName: 'unique_email',
|
||||
constraintType: 'UNIQUE',
|
||||
columnName: 'email',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status()).toBe(401);
|
||||
});
|
||||
|
||||
test('should reject add constraint without required fields', async ({ page }) => {
|
||||
const response = await page.request.post('/api/admin/constraints', {
|
||||
data: {
|
||||
tableName: 'test_table',
|
||||
},
|
||||
});
|
||||
|
||||
expect([400, 401]).toContain(response.status());
|
||||
});
|
||||
|
||||
test('should reject add constraint with invalid table name', async ({ page }) => {
|
||||
const response = await page.request.post('/api/admin/constraints', {
|
||||
data: {
|
||||
tableName: 'invalid-table!@#',
|
||||
constraintName: 'test_constraint',
|
||||
constraintType: 'UNIQUE',
|
||||
columnName: 'email',
|
||||
},
|
||||
});
|
||||
|
||||
expect([400, 401]).toContain(response.status());
|
||||
});
|
||||
|
||||
test('should reject UNIQUE constraint without column name', async ({ page }) => {
|
||||
const response = await page.request.post('/api/admin/constraints', {
|
||||
data: {
|
||||
tableName: 'test_table',
|
||||
constraintName: 'test_unique',
|
||||
constraintType: 'UNIQUE',
|
||||
},
|
||||
});
|
||||
|
||||
expect([400, 401]).toContain(response.status());
|
||||
});
|
||||
|
||||
test('should reject CHECK constraint without expression', async ({ page }) => {
|
||||
const response = await page.request.post('/api/admin/constraints', {
|
||||
data: {
|
||||
tableName: 'test_table',
|
||||
constraintName: 'test_check',
|
||||
constraintType: 'CHECK',
|
||||
},
|
||||
});
|
||||
|
||||
expect([400, 401]).toContain(response.status());
|
||||
});
|
||||
|
||||
test('should reject CHECK constraint with dangerous expression', async ({ page }) => {
|
||||
const response = await page.request.post('/api/admin/constraints', {
|
||||
data: {
|
||||
tableName: 'test_table',
|
||||
constraintName: 'test_check',
|
||||
constraintType: 'CHECK',
|
||||
checkExpression: 'age > 0; DROP TABLE test',
|
||||
},
|
||||
});
|
||||
|
||||
expect([400, 401]).toContain(response.status());
|
||||
});
|
||||
|
||||
test('should reject unsupported constraint type', async ({ page }) => {
|
||||
const response = await page.request.post('/api/admin/constraints', {
|
||||
data: {
|
||||
tableName: 'test_table',
|
||||
constraintName: 'test_fk',
|
||||
constraintType: 'FOREIGN_KEY',
|
||||
},
|
||||
});
|
||||
|
||||
expect([400, 401]).toContain(response.status());
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Drop Constraint API', () => {
|
||||
test('should reject drop constraint without authentication', async ({ page }) => {
|
||||
const response = await page.request.delete('/api/admin/constraints', {
|
||||
data: {
|
||||
tableName: 'test_table',
|
||||
constraintName: 'unique_email',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status()).toBe(401);
|
||||
});
|
||||
|
||||
test('should reject drop constraint without required fields', async ({ page }) => {
|
||||
const response = await page.request.delete('/api/admin/constraints', {
|
||||
data: {
|
||||
tableName: 'test_table',
|
||||
},
|
||||
});
|
||||
|
||||
expect([400, 401]).toContain(response.status());
|
||||
});
|
||||
|
||||
test('should reject drop constraint with invalid identifiers', async ({ page }) => {
|
||||
const response = await page.request.delete('/api/admin/constraints', {
|
||||
data: {
|
||||
tableName: 'invalid!@#',
|
||||
constraintName: 'invalid!@#',
|
||||
},
|
||||
});
|
||||
|
||||
expect([400, 401]).toContain(response.status());
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user