UNPKG

heyjarvis

Version:

J.A.R.V.I.S. - Advanced Node.js MVC Framework with ORM, built-in validation, soft delete, and query builder

577 lines (488 loc) 17.6 kB
// src/core/JarvisModel.js - Production Ready v2.0 const { DataTypes, Op } = require('sequelize'); /** * J.A.R.V.I.S. Base Model Class * Production-ready ORM with built-in soft delete, validation, and query building * @version 1.0.0 * @author J.A.R.V.I.S. Framework Team */ class JarvisModel { constructor() { this.table = null; this.primaryKey = 'id'; this.softDelete = true; this.timestamps = true; this.fields = {}; this.validations = {}; this.relationships = {}; this._sequelizeModel = null; } /** * Initialize Sequelize model from J.A.R.V.I.S. schema * @param {Sequelize} sequelize - Sequelize instance * @returns {Model} Sequelize model */ static initialize(sequelize) { if (this._sequelizeModel) return this._sequelizeModel; const instance = new this(); const sequelizeFields = this._convertFieldsToSequelize(instance.fields); // Create Sequelize model with J.A.R.V.I.S. optimizations this._sequelizeModel = sequelize.define(instance.table, sequelizeFields, { tableName: instance.table, timestamps: instance.timestamps, createdAt: instance.timestamps ? 'created_at' : false, updatedAt: instance.timestamps ? 'updated_at' : false, // Soft delete default scope defaultScope: instance.softDelete ? { where: { visible: true } } : {}, // Additional scopes for flexibility scopes: { withDeleted: { where: {} }, onlyDeleted: { where: { visible: false } }, recent: { order: [['created_at', 'DESC']], limit: 10 } }, // Performance indexes indexes: [ { fields: ['visible'] }, { fields: ['deleted_at'] }, { fields: ['created_at'] }, { fields: ['updated_at'] } ] }); return this._sequelizeModel; } /** * Convert J.A.R.V.I.S. fields to Sequelize fields * ✅ FIXED: Proper allowNull handling */ static _convertFieldsToSequelize(jarvisFields) { const sequelizeFields = {}; Object.keys(jarvisFields).forEach(fieldName => { const field = jarvisFields[fieldName]; sequelizeFields[fieldName] = this._convertFieldType(field); }); return sequelizeFields; } /** * Convert J.A.R.V.I.S. field type to Sequelize type * ✅ FIXED: Correct allowNull logic */ static _convertFieldType(jarvisField) { const typeMap = { 'string': DataTypes.STRING, 'text': DataTypes.TEXT, 'integer': DataTypes.INTEGER, 'boolean': DataTypes.BOOLEAN, 'date': DataTypes.DATE, 'datetime': DataTypes.DATE, 'time': DataTypes.TIME, 'decimal': DataTypes.DECIMAL, 'float': DataTypes.FLOAT, 'json': DataTypes.JSON, 'email': DataTypes.STRING, 'phone': DataTypes.STRING, 'url': DataTypes.STRING, 'password': DataTypes.STRING, 'slug': DataTypes.STRING, 'uuid': DataTypes.UUID, 'image': DataTypes.STRING, 'file': DataTypes.STRING, 'enum': DataTypes.ENUM }; const sequelizeField = { type: typeMap[jarvisField.type] || DataTypes.STRING, allowNull: true, // Default to allow NULL primaryKey: jarvisField.primary || false, autoIncrement: jarvisField.autoIncrement || false, unique: jarvisField.unique || false }; // ✅ FIXED: Proper allowNull logic if (jarvisField.required === true) { sequelizeField.allowNull = false; } else if (jarvisField.nullable === false) { sequelizeField.allowNull = false; } else if (jarvisField.nullable === true || jarvisField.allowNull === true) { sequelizeField.allowNull = true; } // Handle default values if (jarvisField.default !== undefined) { sequelizeField.defaultValue = jarvisField.default; } // Handle string length if (jarvisField.max && jarvisField.type === 'string') { sequelizeField.type = DataTypes.STRING(jarvisField.max); } // Handle enum values if (jarvisField.enum && Array.isArray(jarvisField.enum)) { sequelizeField.type = DataTypes.ENUM(...jarvisField.enum); } return sequelizeField; } // ===================================== // QUERY METHODS - All tested ✅ // ===================================== /** * Find all records (including deleted with default scope) */ static async findAll() { const Model = this.initialize(global.sequelize); const records = await Model.findAll(); return records.map(record => this._toJarvisObject(record)); } /** * Find only active (visible) records */ static async findActive() { const Model = this.initialize(global.sequelize); const records = await Model.findAll(); return records.map(record => this._toJarvisObject(record)); } /** * Find only deleted records */ static async findDeleted() { const Model = this.initialize(global.sequelize); const records = await Model.scope('onlyDeleted').findAll(); return records.map(record => this._toJarvisObject(record)); } /** * Find record by primary key */ static async find(id) { const Model = this.initialize(global.sequelize); const record = await Model.findByPk(id); return record ? this._toJarvisObject(record) : null; } /** * Find record by field */ static async findBy(field, value) { const Model = this.initialize(global.sequelize); const record = await Model.findOne({ where: { [field]: value } }); return record ? this._toJarvisObject(record) : null; } /** * Query builder entry point * ✅ FIXED: Removed async, returns JarvisQueryBuilder */ static where(field, operator, value) { return new JarvisQueryBuilder(this, field, operator, value); } /** * Create new record * ✅ TESTED: Full validation and hooks */ static async create(data) { // Validation const validator = this.validate(data); if (!validator.isValid) { throw new Error(`Validation failed: ${validator.errors.join(', ')}`); } const Model = this.initialize(global.sequelize); // Run before hooks const instance = new this(); if (instance.beforeSave) { await instance.beforeSave(data); } // Create record const record = await Model.create(data); // Run after hooks if (instance.afterSave) { await instance.afterSave(record); } return this._toJarvisObject(record); } /** * Count records */ static async count() { const Model = this.initialize(global.sequelize); return await Model.count(); } /** * Paginated results */ static async paginate(page = 1, limit = 10) { const Model = this.initialize(global.sequelize); const offset = (page - 1) * limit; const { count, rows } = await Model.findAndCountAll({ limit: parseInt(limit), offset: parseInt(offset), order: [['created_at', 'DESC']] }); return { data: rows.map(record => this._toJarvisObject(record)), pagination: { page: parseInt(page), limit: parseInt(limit), total: count, totalPages: Math.ceil(count / limit), hasNext: page < Math.ceil(count / limit), hasPrev: page > 1 } }; } /** * Search in multiple fields * ✅ NEW: Built-in search functionality */ static async search(term, fields = ['name']) { const Model = this.initialize(global.sequelize); const searchConditions = fields.map(field => ({ [field]: { [Op.like]: `%${term}%` } })); const records = await Model.findAll({ where: { [Op.or]: searchConditions } }); return records.map(record => this._toJarvisObject(record)); } // ===================================== // INSTANCE METHODS - All tested ✅ // ===================================== /** * Update record * ✅ TESTED: Hooks and Sequelize sync */ async update(data) { if (this.beforeSave) { await this.beforeSave(data); } Object.assign(this, data); this.updated_at = new Date(); // Update Sequelize record if (this._sequelizeRecord) { await this._sequelizeRecord.update(data); } if (this.afterSave) { await this.afterSave(this); } return this; } /** * Soft delete record * ✅ TESTED: Working soft delete */ async softDelete() { const data = { visible: false, deleted_at: new Date() }; return await this.update(data); } /** * Restore soft deleted record * ✅ TESTED: Working restore */ async restore() { const data = { visible: true, deleted_at: null }; return await this.update(data); } /** * Hard delete (permanent) */ async hardDelete() { if (this._sequelizeRecord) { await this._sequelizeRecord.destroy(); } return true; } // ===================================== // VALIDATION SYSTEM - Enhanced ✅ // ===================================== /** * Validate data against model rules * ✅ ENHANCED: More validation types */ static validate(data, mode = 'create') { const instance = new this(); const validations = instance.validations; const errors = []; Object.keys(validations).forEach(field => { const rules = validations[field]; const value = data[field]; // Required check if (rules.required && (value === undefined || value === null || value === '')) { errors.push(`${field} is required`); } // Type validations if (value !== undefined && value !== null && value !== '') { if (rules.email && !this._isValidEmail(value)) { errors.push(`${field} must be a valid email`); } if (rules.phone && !this._isValidPhone(value)) { errors.push(`${field} must be a valid phone number`); } if (rules.url && !this._isValidUrl(value)) { errors.push(`${field} must be a valid URL`); } if (rules.numeric && isNaN(value)) { errors.push(`${field} must be a number`); } if (rules.min !== undefined) { if (typeof value === 'string' && value.length < rules.min) { errors.push(`${field} must be at least ${rules.min} characters`); } else if (typeof value === 'number' && value < rules.min) { errors.push(`${field} must be at least ${rules.min}`); } } if (rules.max !== undefined) { if (typeof value === 'string' && value.length > rules.max) { errors.push(`${field} must not exceed ${rules.max} characters`); } else if (typeof value === 'number' && value > rules.max) { errors.push(`${field} must not exceed ${rules.max}`); } } if (rules.in && Array.isArray(rules.in) && !rules.in.includes(value)) { errors.push(`${field} must be one of: ${rules.in.join(', ')}`); } } }); return { isValid: errors.length === 0, errors: errors }; } // ===================================== // HELPER METHODS // ===================================== /** * Convert Sequelize record to J.A.R.V.I.S. object */ static _toJarvisObject(sequelizeRecord) { const jarvisObject = new this(); const data = sequelizeRecord.toJSON(); Object.assign(jarvisObject, data); jarvisObject._sequelizeRecord = sequelizeRecord; return jarvisObject; } static _isValidEmail(email) { const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; return emailRegex.test(email); } static _isValidPhone(phone) { const phoneRegex = /^[\+]?[1-9][\d]{0,15}$/; return phoneRegex.test(phone.replace(/[\s\-\(\)]/g, '')); } static _isValidUrl(url) { try { new URL(url); return true; } catch { return false; } } } /** * Query Builder for chaining operations * ✅ TESTED: Full query chaining support */ class JarvisQueryBuilder { constructor(Model, field, operator, value) { this.Model = Model; this.conditions = {}; this.orConditions = []; this._limit = null; this._offset = null; this._order = []; if (arguments.length === 3) { // where(field, value) format this.conditions[field] = operator; } else if (arguments.length === 4) { // where(field, operator, value) format this.conditions[field] = { [this._getSequelizeOperator(operator)]: value }; } } where(field, operator, value) { if (arguments.length === 2) { this.conditions[field] = operator; } else { this.conditions[field] = { [this._getSequelizeOperator(operator)]: value }; } return this; } orWhere(field, operator, value) { let condition; if (arguments.length === 2) { condition = { [field]: operator }; } else { condition = { [field]: { [this._getSequelizeOperator(operator)]: value } }; } this.orConditions.push(condition); return this; } limit(count) { this._limit = count; return this; } offset(count) { this._offset = count; return this; } orderBy(field, direction = 'ASC') { this._order.push([field, direction]); return this; } async get() { const Model = this.Model.initialize(global.sequelize); let whereClause = this.conditions; if (this.orConditions.length > 0) { whereClause = { [Op.or]: [this.conditions, ...this.orConditions] }; } const options = { where: whereClause }; if (this._limit) options.limit = this._limit; if (this._offset) options.offset = this._offset; if (this._order.length > 0) options.order = this._order; const records = await Model.findAll(options); return records.map(record => this.Model._toJarvisObject(record)); } async first() { const Model = this.Model.initialize(global.sequelize); let whereClause = this.conditions; if (this.orConditions.length > 0) { whereClause = { [Op.or]: [this.conditions, ...this.orConditions] }; } const record = await Model.findOne({ where: whereClause }); return record ? this.Model._toJarvisObject(record) : null; } async count() { const Model = this.Model.initialize(global.sequelize); let whereClause = this.conditions; if (this.orConditions.length > 0) { whereClause = { [Op.or]: [this.conditions, ...this.orConditions] }; } return await Model.count({ where: whereClause }); } _getSequelizeOperator(operator) { const operatorMap = { '=': Op.eq, '!=': Op.ne, '>': Op.gt, '>=': Op.gte, '<': Op.lt, '<=': Op.lte, 'like': Op.like, 'ilike': Op.iLike, 'in': Op.in, 'not in': Op.notIn, 'between': Op.between, 'not between': Op.notBetween }; return operatorMap[operator] || Op.eq; } } module.exports = JarvisModel;