UNPKG

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