@aradox/multi-orm
Version:
Type-safe ORM with multi-datasource support, row-level security, and Prisma-like API for PostgreSQL, SQL Server, and HTTP APIs
407 lines • 15.5 kB
JavaScript
"use strict";
/**
* Schema Validation Layer
*
* Validates DSL schema structure and conventions BEFORE IR generation.
* Catches errors like:
* - Invalid naming conventions (PascalCase models, camelCase fields)
* - Missing bidirectional relations
* - Circular dependencies
* - Missing required attributes (@datasource, @id)
* - Invalid relation configurations
*
* These validations happen at parse time and produce errors with source locations.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.validateSchema = validateSchema;
exports.formatSchemaValidationErrors = formatSchemaValidationErrors;
/**
* Main validation function - validates entire schema
*/
function validateSchema(context) {
const errors = [];
const warnings = [];
// 1. Naming conventions (returns both errors and warnings)
const namingResults = validateNamingConventions(context);
for (const result of namingResults) {
if (result.severity === 'error') {
errors.push(result);
}
else {
warnings.push(result);
}
}
// 2. Required attributes
errors.push(...validateRequiredAttributes(context));
// 2.5 Enum definitions
warnings.push(...validateEnums(context));
// 3. Bidirectional relations
errors.push(...validateBidirectionalRelations(context));
// 4. Circular dependencies
warnings.push(...detectCircularDependencies(context));
// 5. Datasource references
errors.push(...validateDatasourceReferences(context));
// 6. Relation strategies
errors.push(...validateRelationStrategies(context));
return {
valid: errors.length === 0,
errors,
warnings
};
}
/**
* Validate enum definitions (naming + value formatting)
*/
function validateEnums(context) {
const results = [];
if (!context.enums)
return results;
for (const [name, enm] of context.enums) {
// Enum name should be PascalCase
if (!isPascalCase(name)) {
results.push({
message: `Enum name '${name}' should be PascalCase`,
location: {
file: context.filePath,
line: enm.line,
column: 6,
length: name.length
},
suggestion: `Rename to '${toPascalCase(name)}'`,
code: 'W201',
severity: 'warning'
});
}
// Enum values should be valid identifiers (recommend ALL_CAPS)
for (const val of enm.values) {
if (!/^[A-Z][A-Z0-9_]*$/.test(val)) {
results.push({
message: `Enum value '${val}' in enum '${name}' should be ALL_CAPS (alphanumeric + underscores)`,
location: {
file: context.filePath,
line: enm.line,
column: 2,
length: val.length
},
suggestion: `Use ALL_CAPS like 'MY_VALUE' or wrap with @map if mapping is needed`,
code: 'W202',
severity: 'warning'
});
}
}
}
return results;
}
/**
* RULE: Models must be PascalCase, fields should be camelCase (warning)
* Returns tuple: [errors, warnings]
*/
function validateNamingConventions(context) {
const results = [];
for (const [modelName, model] of context.models) {
// Check model name is PascalCase
if (!isPascalCase(modelName)) {
results.push({
message: `Model name '${modelName}' must be PascalCase`,
location: {
file: context.filePath,
line: model.line,
column: 6, // After "model "
length: modelName.length
},
suggestion: `Rename to '${toPascalCase(modelName)}'`,
code: 'E101',
severity: 'error'
});
}
// Check field names are camelCase (WARNING only - skip if @map is present)
for (const field of model.fields) {
// Skip validation if @map is present (allows any API field name with physical mapping)
if (field.map) {
continue;
}
// Warn if field name is not camelCase or PascalCase
if (!isCamelCase(field.name) && !isPascalCase(field.name)) {
results.push({
message: `Field '${field.name}' in model '${modelName}' should be camelCase or PascalCase`,
location: {
file: context.filePath,
line: field.line,
column: 2,
length: field.name.length
},
suggestion: `Use camelCase ('${toCamelCase(field.name)}') or add @map("${field.name}") to map to database column`,
code: 'W102',
severity: 'warning'
});
}
}
}
return results;
}
/**
* RULE: All models must have @datasource attribute
* RULE: All models must have at least one @id field
*/
function validateRequiredAttributes(context) {
const errors = [];
for (const [modelName, model] of context.models) {
// Check @datasource attribute
if (!model.datasource) {
errors.push({
message: `Model '${modelName}' is missing @datasource attribute`,
location: {
file: context.filePath,
line: model.line,
column: 0,
length: 5 + modelName.length // "model <name>"
},
suggestion: 'Add @datasource(datasourceName) after model name',
code: 'E103',
severity: 'error'
});
}
// Check at least one @id field
const idFields = model.fields.filter(f => f.isId);
if (idFields.length === 0) {
errors.push({
message: `Model '${modelName}' must have at least one @id field`,
location: {
file: context.filePath,
line: model.line,
column: 0,
length: 5 + modelName.length
},
suggestion: 'Add @id attribute to a unique identifier field',
code: 'E104',
severity: 'error'
});
}
}
return errors;
}
/**
* RULE: Relations must be bidirectional (both sides must declare @relation)
* EXCEPTION: Lookup-strategy relations are read-only joins and don't require inverse relations
*/
function validateBidirectionalRelations(context) {
const errors = [];
for (const [modelName, model] of context.models) {
for (const field of model.fields) {
if (!field.relation)
continue;
// Lookup-strategy relations are read-only joins and don't require bidirectional setup
if (field.relation.strategy === 'lookup') {
continue;
}
const targetModel = context.models.get(field.relation.model);
if (!targetModel) {
// This will be caught by validateDatasourceReferences
continue;
}
// Find the inverse relation
const inverseRelation = targetModel.fields.find(f => f.relation && f.relation.model === modelName);
if (!inverseRelation) {
errors.push({
message: `Relation '${field.name}' in model '${modelName}' has no inverse relation in model '${field.relation.model}'`,
location: {
file: context.filePath,
line: field.line,
column: 2,
length: field.name.length
},
suggestion: `Add a relation field in model '${field.relation.model}' pointing back to '${modelName}', or use strategy:"lookup" for one-way relations`,
code: 'E105',
severity: 'error'
});
}
}
}
return errors;
}
/**
* RULE: Detect circular dependencies (A → B → A without proper foreign keys)
* This is a warning, not an error, as circular relations are valid with proper setup
*/
function detectCircularDependencies(context) {
const warnings = [];
const visited = new Set();
const recursionStack = new Set();
const paths = new Map();
function dfs(modelName, path) {
visited.add(modelName);
recursionStack.add(modelName);
paths.set(modelName, [...path, modelName]);
const model = context.models.get(modelName);
if (!model)
return false;
for (const field of model.fields) {
if (!field.relation)
continue;
const targetModel = field.relation.model;
if (!visited.has(targetModel)) {
if (dfs(targetModel, [...path, modelName])) {
return true;
}
}
else if (recursionStack.has(targetModel)) {
// Circular dependency detected
const cyclePath = [...path, modelName, targetModel];
warnings.push({
message: `Circular dependency detected: ${cyclePath.join(' → ')}`,
location: {
file: context.filePath,
line: field.line,
column: 2,
length: field.name.length
},
suggestion: 'Ensure foreign key constraints are properly configured to avoid infinite loops',
code: 'W101',
severity: 'warning'
});
return true;
}
}
recursionStack.delete(modelName);
return false;
}
for (const [modelName] of context.models) {
if (!visited.has(modelName)) {
dfs(modelName, []);
}
}
return warnings;
}
/**
* RULE: All @datasource references must exist
* RULE: All relation target models must exist
*/
function validateDatasourceReferences(context) {
const errors = [];
for (const [modelName, model] of context.models) {
// Check datasource exists
if (model.datasource && !context.datasources.has(model.datasource)) {
errors.push({
message: `Model '${modelName}' references unknown datasource '${model.datasource}'`,
location: {
file: context.filePath,
line: model.line,
column: 0,
length: 5 + modelName.length
},
suggestion: `Define datasource '${model.datasource}' or use an existing datasource: ${Array.from(context.datasources.keys()).join(', ')}`,
code: 'E106',
severity: 'error'
});
}
// Check relation target models exist
for (const field of model.fields) {
if (!field.relation)
continue;
if (!context.models.has(field.relation.model)) {
errors.push({
message: `Field '${field.name}' in model '${modelName}' references unknown model '${field.relation.model}'`,
location: {
file: context.filePath,
line: field.line,
column: 2,
length: field.name.length
},
suggestion: `Define model '${field.relation.model}' or fix the relation type`,
code: 'E107',
severity: 'error'
});
}
}
}
return errors;
}
/**
* RULE: All relations must have a strategy (R-005)
*/
function validateRelationStrategies(context) {
const errors = [];
for (const [modelName, model] of context.models) {
for (const field of model.fields) {
if (!field.relation)
continue;
if (!field.relation.strategy) {
errors.push({
message: `Relation '${field.name}' in model '${modelName}' is missing strategy attribute`,
location: {
file: context.filePath,
line: field.line,
column: 2,
length: field.name.length
},
suggestion: 'Add strategy:"lookup" or strategy:"pushdown" to @relation attribute',
code: 'E108',
severity: 'error'
});
}
else if (!['lookup', 'pushdown'].includes(field.relation.strategy)) {
errors.push({
message: `Invalid relation strategy '${field.relation.strategy}' for field '${field.name}' in model '${modelName}'`,
location: {
file: context.filePath,
line: field.line,
column: 2,
length: field.name.length
},
suggestion: 'Use strategy:"lookup" or strategy:"pushdown"',
code: 'E109',
severity: 'error'
});
}
}
}
return errors;
}
// ============================================================================
// Helper functions
// ============================================================================
function isPascalCase(str) {
// PascalCase: starts with uppercase, contains only letters/digits
return /^[A-Z][a-zA-Z0-9]*$/.test(str);
}
function isCamelCase(str) {
// camelCase: starts with lowercase, contains only letters/digits
// Allow single capital letters for acronyms (e.g., userId, postId)
return /^[a-z][a-zA-Z0-9]*$/.test(str);
}
function toPascalCase(str) {
return str.charAt(0).toUpperCase() + str.slice(1);
}
function toCamelCase(str) {
return str.charAt(0).toLowerCase() + str.slice(1);
}
/**
* Format validation errors for display
*/
function formatSchemaValidationErrors(result) {
const lines = [];
if (result.errors.length > 0) {
lines.push('❌ Schema Validation Errors:');
for (const error of result.errors) {
lines.push(` ${error.location.file}:${error.location.line}:${error.location.column} - ${error.message}`);
if (error.suggestion) {
lines.push(` 💡 Suggestion: ${error.suggestion}`);
}
lines.push(` Code: ${error.code}`);
lines.push('');
}
}
if (result.warnings.length > 0) {
lines.push('⚠️ Schema Validation Warnings:');
for (const warning of result.warnings) {
lines.push(` ${warning.location.file}:${warning.location.line}:${warning.location.column} - ${warning.message}`);
if (warning.suggestion) {
lines.push(` 💡 Suggestion: ${warning.suggestion}`);
}
lines.push(` Code: ${warning.code}`);
lines.push('');
}
}
return lines.join('\n');
}
//# sourceMappingURL=schema-validator.js.map