supa-seed
Version:
A constraint-aware, framework-agnostic database seeding framework with deep PostgreSQL business logic discovery and MakerKit integration support
510 lines • 20.8 kB
JavaScript
"use strict";
/**
* Relationship Analyzer
* Analyzes foreign key relationships and builds dependency graphs for seeding order
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.RelationshipAnalyzer = void 0;
const logger_1 = require("../../core/utils/logger");
const dependency_graph_1 = require("../../schema/dependency-graph");
class RelationshipAnalyzer {
constructor(client, options = {}) {
this.analysisCache = new Map();
this.client = client;
this.options = {
schemas: ['public'],
includeTables: [],
excludeTables: [],
detectJunctionTables: true,
analyzeTenantScoping: true,
includeOptionalRelationships: true,
detectSelfReferences: true,
enableCaching: true,
cacheTimeout: 30,
maxConcurrentQueries: 10,
queryTimeout: 30000,
generateRecommendations: true,
includeMetadata: true,
verboseLogging: false,
...options
};
}
/**
* Analyze relationships and build dependency graph
*/
async analyzeRelationships() {
const startTime = Date.now();
logger_1.Logger.info('🔗 Starting relationship analysis...');
try {
// Check cache
const cacheKey = this.generateCacheKey();
if (this.options.enableCaching && this.analysisCache.has(cacheKey)) {
logger_1.Logger.debug('Using cached relationship analysis');
const cached = this.analysisCache.get(cacheKey);
cached.analysisMetadata.cacheHit = true;
return cached;
}
// Get all tables in specified schemas
const tables = await this.getTables();
// Get foreign key relationships
const foreignKeys = await this.getForeignKeyRelationships();
// Get column information for metadata
const columns = await this.getColumnInformation();
// Build dependency graph
const dependencyGraph = await this.buildDependencyGraph(tables, foreignKeys, columns);
// Calculate seeding order
const seedingOrder = dependencyGraph.calculateSeedingOrderWithPhases({
respectCircularDependencies: true,
prioritizeJunctionTables: false,
groupByTenant: this.options.analyzeTenantScoping
});
// Convert to table dependencies
const tableDependencies = this.convertToTableDependencies(foreignKeys);
// Generate analysis metadata
const analysisMetadata = this.generateAnalysisMetadata(tables, foreignKeys, dependencyGraph, startTime, false);
const builtGraph = dependencyGraph.build();
const result = {
success: true,
dependencyGraph: builtGraph,
foreignKeyRelationships: foreignKeys,
tableDependencies,
analysisMetadata,
seedingOrder,
recommendations: this.generateRecommendations(dependencyGraph),
warnings: builtGraph.metadata.warnings,
errors: [],
executionTime: Date.now() - startTime
};
// Cache result
if (this.options.enableCaching) {
this.analysisCache.set(cacheKey, result);
}
logger_1.Logger.success(`✅ Relationship analysis completed in ${result.executionTime}ms`);
logger_1.Logger.info(`📊 Found ${foreignKeys.length} relationships across ${tables.length} tables`);
logger_1.Logger.info(`🔄 Seeding order: ${seedingOrder.seedingOrder.join(' → ')}`);
return result;
}
catch (error) {
logger_1.Logger.error('Relationship analysis failed:', error);
return {
success: false,
dependencyGraph: this.createEmptyGraph(),
foreignKeyRelationships: [],
tableDependencies: [],
analysisMetadata: {
tablesAnalyzed: 0,
relationshipsFound: 0,
junctionTablesDetected: [],
tenantScopedTables: [],
circularDependencies: 0,
maxDependencyDepth: 0,
analysisTimestamp: new Date().toISOString(),
confidence: 0,
cacheHit: false
},
seedingOrder: {
success: false,
seedingOrder: [],
phases: [],
circularDependenciesResolved: [],
warnings: [],
errors: [error.message],
metadata: {
totalPhases: 0,
estimatedSeedingTime: 0,
complexity: 'unknown',
recommendations: []
}
},
recommendations: [],
warnings: [],
errors: [error.message],
executionTime: Date.now() - startTime
};
}
}
/**
* Get all tables from specified schemas
*/
async getTables() {
logger_1.Logger.debug('Querying database tables...');
const schemaFilter = this.options.schemas.join(',');
const { data: tables, error } = await this.client
.from('information_schema.tables')
.select('table_name, table_schema, table_type')
.in('table_schema', this.options.schemas)
.eq('table_type', 'BASE TABLE');
if (error) {
throw new Error(`Failed to query tables: ${error.message}`);
}
let filteredTables = (tables || []);
// Apply include/exclude filters
if (this.options.includeTables.length > 0) {
filteredTables = filteredTables.filter(t => this.options.includeTables.includes(t.table_name));
}
if (this.options.excludeTables.length > 0) {
filteredTables = filteredTables.filter(t => !this.options.excludeTables.includes(t.table_name));
}
logger_1.Logger.debug(`Found ${filteredTables.length} tables to analyze`);
return filteredTables;
}
/**
* Get foreign key relationships
*/
async getForeignKeyRelationships() {
logger_1.Logger.debug('Querying foreign key relationships...');
const { data: foreignKeys, error } = await this.client.rpc('get_foreign_keys_detailed', {
schema_names: this.options.schemas
});
if (error) {
logger_1.Logger.debug('Detailed foreign key query failed, using basic query');
return await this.getForeignKeysBasic();
}
return this.transformForeignKeys((foreignKeys || []));
}
/**
* Fallback method to get foreign keys using basic information_schema queries
*/
async getForeignKeysBasic() {
const query = `
SELECT
tc.constraint_name,
tc.table_name,
kcu.column_name,
ccu.table_name AS foreign_table_name,
ccu.column_name AS foreign_column_name,
rc.update_rule,
rc.delete_rule,
tc.is_deferrable,
tc.initially_deferred,
tc.table_schema,
ccu.table_schema AS foreign_table_schema,
col.is_nullable
FROM information_schema.table_constraints tc
JOIN information_schema.key_column_usage kcu
ON tc.constraint_name = kcu.constraint_name
AND tc.table_schema = kcu.table_schema
JOIN information_schema.constraint_column_usage ccu
ON ccu.constraint_name = tc.constraint_name
AND ccu.table_schema = tc.table_schema
JOIN information_schema.referential_constraints rc
ON tc.constraint_name = rc.constraint_name
AND tc.table_schema = rc.constraint_schema
JOIN information_schema.columns col
ON kcu.table_name = col.table_name
AND kcu.column_name = col.column_name
AND kcu.table_schema = col.table_schema
WHERE tc.constraint_type = 'FOREIGN KEY'
AND tc.table_schema = ANY($1)
`;
try {
const { data, error } = await this.client.rpc('exec_sql', {
query,
params: [this.options.schemas]
});
if (error) {
logger_1.Logger.warn('Basic foreign key query failed, using minimal fallback');
return [];
}
return this.transformForeignKeys((data || []));
}
catch (error) {
logger_1.Logger.warn('Foreign key query failed:', error);
return [];
}
}
/**
* Transform raw foreign key data to our format
*/
transformForeignKeys(rawData) {
return rawData.map(fk => ({
constraintName: fk.constraint_name || 'unknown',
fromTable: fk.table_name || 'unknown',
fromColumn: fk.column_name || 'unknown',
toTable: fk.foreign_table_name || 'unknown',
toColumn: fk.foreign_column_name || 'unknown',
onDelete: this.normalizeReferentialAction(fk.delete_rule || 'NO ACTION'),
onUpdate: this.normalizeReferentialAction(fk.update_rule || 'NO ACTION'),
isNullable: fk.is_nullable === 'YES' || fk.is_nullable === true,
isDeferrable: fk.is_deferrable === 'YES' || fk.is_deferrable === true,
schema: fk.table_schema || 'public'
}));
}
/**
* Get column information for metadata analysis
*/
async getColumnInformation() {
logger_1.Logger.debug('Querying column information...');
const { data: columns, error } = await this.client
.from('information_schema.columns')
.select('table_name, column_name, is_nullable, data_type')
.in('table_schema', this.options.schemas);
if (error) {
logger_1.Logger.warn('Failed to get column information:', error);
return [];
}
// Add primary key information
const columnsWithPK = await Promise.all((columns || []).map(async (col) => ({
...col,
is_primary_key: await this.isColumnPrimaryKey(col.table_name, col.column_name)
})));
return columnsWithPK;
}
/**
* Check if a column is part of primary key
*/
async isColumnPrimaryKey(tableName, columnName) {
try {
const { data, error } = await this.client
.from('information_schema.key_column_usage')
.select('constraint_name')
.eq('table_name', tableName)
.eq('column_name', columnName)
.in('table_schema', this.options.schemas);
if (error || !data)
return false;
// Check if any constraint is a primary key
for (const kcu of data) {
const { data: constraint } = await this.client
.from('information_schema.table_constraints')
.select('constraint_type')
.eq('constraint_name', kcu.constraint_name)
.eq('constraint_type', 'PRIMARY KEY')
.single();
if (constraint)
return true;
}
return false;
}
catch {
return false;
}
}
/**
* Build dependency graph from relationships
*/
async buildDependencyGraph(tables, foreignKeys, columns) {
logger_1.Logger.debug('Building dependency graph...');
const builder = new dependency_graph_1.DependencyGraphBuilder();
// Add all tables as nodes
for (const table of tables) {
const metadata = await this.generateTableMetadata(table, columns, foreignKeys);
builder.addNode(table.table_name, table.table_schema, metadata);
}
// Add relationships as edges
for (const fk of foreignKeys) {
// Only add if both tables exist in our table list
const fromExists = tables.some(t => t.table_name === fk.fromTable);
const toExists = tables.some(t => t.table_name === fk.toTable);
if (fromExists && toExists) {
builder.addEdge(fk.fromTable, fk.toTable, fk);
}
}
return builder;
}
/**
* Generate metadata for a table
*/
async generateTableMetadata(table, columns, foreignKeys) {
const tableColumns = columns.filter(c => c.table_name === table.table_name);
const tableForeignKeys = foreignKeys.filter(fk => fk.fromTable === table.table_name);
const primaryKeyColumns = tableColumns
.filter(c => c.is_primary_key)
.map(c => c.column_name);
return {
isJunctionTable: this.isJunctionTable(table.table_name, tableColumns, tableForeignKeys),
isTenantScoped: this.isTenantScoped(tableColumns),
hasTimestamps: this.hasTimestampColumns(tableColumns),
primaryKeyColumns,
foreignKeyCount: tableForeignKeys.length,
estimatedSize: this.estimateTableSize(tableColumns),
seedingComplexity: this.estimateSeedingComplexity(tableColumns, tableForeignKeys)
};
}
/**
* Check if table is a junction table
*/
isJunctionTable(tableName, columns, foreignKeys) {
// Junction table heuristics:
// 1. Has 2 or more foreign keys
// 2. Primary key consists only of foreign key columns
// 3. Few or no additional columns
if (foreignKeys.length < 2)
return false;
const primaryKeyColumns = columns.filter(c => c.is_primary_key).map(c => c.column_name);
const foreignKeyColumns = foreignKeys.map(fk => fk.fromColumn);
// Check if all PK columns are FK columns
const allPKColumnsAreFKs = primaryKeyColumns.every(pk => foreignKeyColumns.includes(pk));
// Check if table has minimal additional columns
const nonFKColumns = columns.filter(c => !foreignKeyColumns.includes(c.column_name) &&
!['created_at', 'updated_at', 'id'].includes(c.column_name));
return allPKColumnsAreFKs && nonFKColumns.length <= 2;
}
/**
* Check if table is tenant-scoped
*/
isTenantScoped(columns) {
const tenantColumns = ['account_id', 'tenant_id', 'organization_id', 'team_id'];
return columns.some(c => tenantColumns.includes(c.column_name));
}
/**
* Check if table has timestamp columns
*/
hasTimestampColumns(columns) {
const timestampColumns = ['created_at', 'updated_at', 'timestamp'];
return columns.some(c => timestampColumns.includes(c.column_name));
}
/**
* Estimate table size category
*/
estimateTableSize(columns) {
// Simple heuristic based on column count
if (columns.length <= 5)
return 'small';
if (columns.length <= 15)
return 'medium';
return 'large';
}
/**
* Estimate seeding complexity
*/
estimateSeedingComplexity(columns, foreignKeys) {
let complexity = 0;
// Add complexity for foreign keys
complexity += foreignKeys.length * 2;
// Add complexity for non-nullable columns
complexity += columns.filter(c => c.is_nullable === 'NO').length;
// Add complexity for complex data types
const complexTypes = ['json', 'jsonb', 'array', 'geometry'];
complexity += columns.filter(c => complexTypes.some(type => c.data_type.includes(type))).length * 3;
if (complexity <= 5)
return 'simple';
if (complexity <= 15)
return 'moderate';
return 'complex';
}
/**
* Convert foreign keys to table dependencies
*/
convertToTableDependencies(foreignKeys) {
return foreignKeys.map(fk => ({
fromTable: fk.fromTable,
toTable: fk.toTable,
relationship: fk.isNullable ? 'optional' : 'required',
foreignKey: fk,
constraint: fk.constraintName
}));
}
/**
* Generate analysis metadata
*/
generateAnalysisMetadata(tables, foreignKeys, dependencyGraph, startTime, cacheHit) {
const graph = dependencyGraph.build();
return {
tablesAnalyzed: tables.length,
relationshipsFound: foreignKeys.length,
junctionTablesDetected: graph.nodes
.filter(n => n.metadata.isJunctionTable)
.map(n => n.table),
tenantScopedTables: graph.nodes
.filter(n => n.metadata.isTenantScoped)
.map(n => n.table),
circularDependencies: graph.cycles.length,
maxDependencyDepth: graph.metadata.maxDepth,
analysisTimestamp: new Date().toISOString(),
confidence: graph.metadata.confidence,
cacheHit
};
}
/**
* Generate recommendations based on analysis
*/
generateRecommendations(dependencyGraph) {
const graph = dependencyGraph.build();
const recommendations = [];
if (graph.cycles.length > 0) {
recommendations.push(`Found ${graph.cycles.length} circular dependencies - consider using nullable foreign keys`);
}
const junctionTables = graph.nodes.filter(n => n.metadata.isJunctionTable);
if (junctionTables.length > 0) {
recommendations.push(`Detected ${junctionTables.length} junction tables - ensure proper many-to-many handling`);
}
if (graph.metadata.maxDepth > 5) {
recommendations.push('Deep dependency chain detected - consider flattening schema design');
}
const tenantTables = graph.nodes.filter(n => n.metadata.isTenantScoped);
if (tenantTables.length > 0) {
recommendations.push(`${tenantTables.length} tenant-scoped tables detected - enable multi-tenant mode`);
}
return recommendations;
}
/**
* Normalize referential action strings
*/
normalizeReferentialAction(action) {
const normalized = action.toUpperCase().trim();
switch (normalized) {
case 'CASCADE': return 'CASCADE';
case 'SET NULL': return 'SET NULL';
case 'RESTRICT': return 'RESTRICT';
case 'SET DEFAULT': return 'SET DEFAULT';
default: return 'NO ACTION';
}
}
/**
* Create empty dependency graph for error cases
*/
createEmptyGraph() {
const builder = new dependency_graph_1.DependencyGraphBuilder();
return builder.build();
}
/**
* Generate cache key for results
*/
generateCacheKey() {
const keyData = {
schemas: this.options.schemas.sort(),
includes: this.options.includeTables.sort(),
excludes: this.options.excludeTables.sort(),
options: {
junctions: this.options.detectJunctionTables,
tenant: this.options.analyzeTenantScoping,
optional: this.options.includeOptionalRelationships
}
};
return `relationship_analysis_${Buffer.from(JSON.stringify(keyData)).toString('base64')}`;
}
/**
* Clear analysis cache
*/
clearCache() {
this.analysisCache.clear();
logger_1.Logger.debug('Relationship analysis cache cleared');
}
/**
* Get dependency graph for specific tables
*/
async getDependencyGraphForTables(tableNames) {
const analysis = await this.analyzeRelationships();
if (!analysis.success) {
throw new Error('Failed to analyze relationships');
}
// Filter graph to only include specified tables
const filteredBuilder = new dependency_graph_1.DependencyGraphBuilder();
// Add nodes for specified tables
for (const node of analysis.dependencyGraph.nodes) {
if (tableNames.includes(node.table)) {
filteredBuilder.addNode(node.table, node.schema, node.metadata);
}
}
// Add edges between specified tables
for (const fk of analysis.foreignKeyRelationships) {
if (tableNames.includes(fk.fromTable) && tableNames.includes(fk.toTable)) {
filteredBuilder.addEdge(fk.fromTable, fk.toTable, fk);
}
}
return filteredBuilder.build();
}
}
exports.RelationshipAnalyzer = RelationshipAnalyzer;
//# sourceMappingURL=relationship-analyzer.js.map