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
JavaScript
// 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;