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:
copilot-swe-agent[bot]
2026-01-08 03:42:13 +00:00
parent a91f6d95fd
commit 5fb035e29c
5 changed files with 472 additions and 0 deletions

View 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 },
);
}
}

View File

@@ -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"
}
]
}

View File

@@ -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');

View File

@@ -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);