artmapper
Version:
Spring Boot clone for Node.js with TypeScript/JavaScript - JPA-like ORM, Lombok decorators, dependency injection, and MySQL support
309 lines • 13.4 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.QueryBuilder = exports.EntityManager = void 0;
require("reflect-metadata");
const entity_1 = require("../decorators/entity");
const RelationshipManager_1 = require("./RelationshipManager");
class EntityManager {
constructor(pool) {
this.pool = pool;
this.relationshipManager = new RelationshipManager_1.RelationshipManager(pool);
}
/**
* Persist an entity to the database
* @param entity The entity to persist
* @param fieldsToUpdate Optional array of field names to update (for partial updates)
*/
async persist(entity, fieldsToUpdate) {
const entityClass = entity.constructor;
const entityMetadata = Reflect.getMetadata(entity_1.ENTITY_METADATA_KEY, entityClass);
const tableMetadata = Reflect.getMetadata(entity_1.TABLE_METADATA_KEY, entityClass);
const columnMetadata = Reflect.getMetadata(entity_1.COLUMN_METADATA_KEY, entityClass) || {};
const idProperty = Reflect.getMetadata(entity_1.ID_METADATA_KEY, entityClass);
const generatedValue = Reflect.getMetadata(entity_1.GENERATED_VALUE_METADATA_KEY, entityClass);
if (!entityMetadata) {
throw new Error(`Class ${entityClass.name} is not an entity`);
}
const tableName = tableMetadata?.name || entityClass.name.toLowerCase();
const columns = Object.keys(columnMetadata);
// Check if entity has an ID (for update vs insert)
const idValue = idProperty ? entity[idProperty] : null;
const idColumn = idProperty ? columnMetadata[idProperty]?.name || idProperty : null;
// If entity has an ID value, it's an UPDATE (regardless of AUTO_INCREMENT strategy)
if (idValue !== null && idValue !== undefined) {
// Update existing entity
// If fieldsToUpdate is specified, only update those fields
// Otherwise, update all fields that are not undefined
const updateColumns = columns.filter(col => {
if (col === idProperty)
return false;
// If fieldsToUpdate is specified, only include those fields
if (fieldsToUpdate && fieldsToUpdate.length > 0) {
return fieldsToUpdate.includes(col);
}
// Otherwise, include all fields that are not undefined
const value = entity[col];
return value !== undefined;
});
if (updateColumns.length === 0) {
// No fields to update, return entity as-is
return entity;
}
const setClause = updateColumns
.map(col => {
const colName = columnMetadata[col].name || col;
return `\`${colName}\` = ?`;
})
.join(', ');
const updateValues = updateColumns.map(col => {
const value = entity[col];
return value === undefined ? null : value;
});
const sql = `UPDATE \`${tableName}\` SET ${setClause} WHERE \`${idColumn}\` = ?`;
await this.pool.execute(sql, [...updateValues, idValue]);
return entity;
}
else {
// Insert new entity
// Exclude ID column if it's AUTO_INCREMENT
const isAutoIncrement = generatedValue?.strategy === 'AUTO' && idProperty;
// Only include columns that are actually set (not undefined)
// Let the database handle default values - don't include undefined fields
const insertColumns = columns.filter(col => {
if (isAutoIncrement && col === idProperty)
return false;
const value = entity[col];
// Only include if value is explicitly set (not undefined)
return value !== undefined;
});
if (insertColumns.length === 0) {
throw new Error(`Cannot insert entity: no columns to insert`);
}
const columnNames = insertColumns.map(col => columnMetadata[col].name || col);
const placeholders = insertColumns.map(() => '?').join(', ');
const insertValues = insertColumns.map(col => {
const value = entity[col];
// Convert undefined to null for MySQL (shouldn't happen since we filtered, but safety check)
return value === undefined ? null : value;
});
const sql = `INSERT INTO ${tableName} (${columnNames.join(', ')}) VALUES (${placeholders})`;
const [result] = await this.pool.execute(sql, insertValues);
// Set the generated ID back to the entity
if (isAutoIncrement && result.insertId) {
entity[idProperty] = result.insertId;
}
return entity;
}
}
/**
* Find an entity by ID
*/
async find(entityClass, id) {
const entityMetadata = Reflect.getMetadata(entity_1.ENTITY_METADATA_KEY, entityClass);
const tableMetadata = Reflect.getMetadata(entity_1.TABLE_METADATA_KEY, entityClass);
const columnMetadata = Reflect.getMetadata(entity_1.COLUMN_METADATA_KEY, entityClass) || {};
const idProperty = Reflect.getMetadata(entity_1.ID_METADATA_KEY, entityClass);
if (!entityMetadata) {
throw new Error(`Class ${entityClass.name} is not an entity`);
}
const tableName = tableMetadata?.name || entityClass.name.toLowerCase();
const idColumn = idProperty ? columnMetadata[idProperty]?.name || idProperty : 'id';
const sql = `SELECT * FROM ${tableName} WHERE ${idColumn} = ? LIMIT 1`;
const [rows] = await this.pool.execute(sql, [id]);
if (rows.length === 0) {
return null;
}
return this.mapRowToEntity(entityClass, rows[0], columnMetadata);
}
/**
* Find all entities
*/
async findAll(entityClass) {
const entityMetadata = Reflect.getMetadata(entity_1.ENTITY_METADATA_KEY, entityClass);
const tableMetadata = Reflect.getMetadata(entity_1.TABLE_METADATA_KEY, entityClass);
const columnMetadata = Reflect.getMetadata(entity_1.COLUMN_METADATA_KEY, entityClass) || {};
if (!entityMetadata) {
throw new Error(`Class ${entityClass.name} is not an entity`);
}
const tableName = tableMetadata?.name || entityClass.name.toLowerCase();
const sql = `SELECT * FROM ${tableName}`;
const [rows] = await this.pool.execute(sql);
const entities = rows.map(row => this.mapRowToEntity(entityClass, row, columnMetadata));
// Load EAGER relationships for all entities
for (const entity of entities) {
await this.loadRelationships(entity);
}
return entities;
}
/**
* Remove an entity from the database
*/
async remove(entity) {
const entityClass = entity.constructor;
const entityMetadata = Reflect.getMetadata(entity_1.ENTITY_METADATA_KEY, entityClass);
const tableMetadata = Reflect.getMetadata(entity_1.TABLE_METADATA_KEY, entityClass);
const columnMetadata = Reflect.getMetadata(entity_1.COLUMN_METADATA_KEY, entityClass) || {};
const idProperty = Reflect.getMetadata(entity_1.ID_METADATA_KEY, entityClass);
if (!entityMetadata) {
throw new Error(`Class ${entityClass.name} is not an entity`);
}
if (!idProperty) {
throw new Error(`Entity ${entityClass.name} does not have an ID property`);
}
const tableName = tableMetadata?.name || entityClass.name.toLowerCase();
const idColumn = columnMetadata[idProperty]?.name || idProperty;
const idValue = entity[idProperty];
if (!idValue) {
throw new Error(`Entity ${entityClass.name} does not have an ID value`);
}
const sql = `DELETE FROM ${tableName} WHERE ${idColumn} = ?`;
await this.pool.execute(sql, [idValue]);
}
/**
* Execute a custom query
*/
async query(sql, params) {
const [rows] = await this.pool.execute(sql, params || []);
return rows;
}
/**
* Execute a custom query and return single result
*/
async queryOne(sql, params) {
const results = await this.query(sql, params);
return results.length > 0 ? results[0] : null;
}
/**
* Create a query builder
*/
createQueryBuilder(entityClass) {
return new QueryBuilder(this.pool, entityClass);
}
/**
* Map database row to entity instance
*/
mapRowToEntity(entityClass, row, columnMetadata) {
const entity = new entityClass();
const reverseColumnMap = {};
// Create reverse mapping: column name -> property name
Object.keys(columnMetadata).forEach(prop => {
const colName = columnMetadata[prop].name || prop;
reverseColumnMap[colName] = prop;
});
// Map row data to entity properties
Object.keys(row).forEach(colName => {
const propName = reverseColumnMap[colName] || colName;
entity[propName] = row[colName];
});
return entity;
}
/**
* Load relationships for an entity (EAGER loading)
*/
async loadRelationships(entity) {
const entityClass = entity.constructor;
// Load ManyToOne relationships (EAGER by default)
const manyToOneRelations = Reflect.getMetadata(entity_1.MANY_TO_ONE_METADATA_KEY, entityClass) || {};
for (const [propertyKey, relationMeta] of Object.entries(manyToOneRelations)) {
const meta = relationMeta;
if (meta.fetch === 'EAGER' && meta.targetEntity) {
const targetEntityClass = meta.targetEntity();
const related = await this.relationshipManager.loadManyToOne(entity, propertyKey, meta, targetEntityClass);
entity[propertyKey] = related;
}
}
// Load OneToOne relationships (EAGER by default)
const oneToOneRelations = Reflect.getMetadata(entity_1.ONE_TO_ONE_METADATA_KEY, entityClass) || {};
for (const [propertyKey, relationMeta] of Object.entries(oneToOneRelations)) {
const meta = relationMeta;
if (meta.fetch === 'EAGER' && meta.targetEntity) {
const targetEntityClass = meta.targetEntity();
const related = await this.relationshipManager.loadOneToOne(entity, propertyKey, meta, targetEntityClass);
entity[propertyKey] = related;
}
}
return entity;
}
/**
* Get relationship manager for manual relationship loading
*/
getRelationshipManager() {
return this.relationshipManager;
}
}
exports.EntityManager = EntityManager;
/**
* Query Builder for type-safe queries
*/
class QueryBuilder {
constructor(pool, entityClass) {
this.pool = pool;
this.entityClass = entityClass;
this.selectClause = '*';
this.fromClause = '';
this.whereClauses = [];
this.whereParams = [];
this.orderByClause = '';
this.limitClause = '';
this.joinClauses = [];
const tableMetadata = Reflect.getMetadata(entity_1.TABLE_METADATA_KEY, entityClass);
this.fromClause = tableMetadata?.name || entityClass.name.toLowerCase();
}
select(fields) {
this.selectClause = fields;
return this;
}
where(condition, ...params) {
this.whereClauses.push(condition);
// Convert undefined to null for MySQL compatibility
const normalizedParams = params.map(param => param === undefined ? null : param);
this.whereParams.push(...normalizedParams);
return this;
}
andWhere(condition, ...params) {
return this.where(condition, ...params);
}
orderBy(field, direction = 'ASC') {
this.orderByClause = `ORDER BY ${field} ${direction}`;
return this;
}
limit(count, offset) {
this.limitClause = offset !== undefined ? `LIMIT ${offset}, ${count}` : `LIMIT ${count}`;
return this;
}
join(table, condition) {
this.joinClauses.push(`JOIN ${table} ON ${condition}`);
return this;
}
leftJoin(table, condition) {
this.joinClauses.push(`LEFT JOIN ${table} ON ${condition}`);
return this;
}
async getMany() {
const sql = this.buildQuery();
const [rows] = await this.pool.execute(sql, this.whereParams);
return rows;
}
async getOne() {
const results = await this.getMany();
return results.length > 0 ? results[0] : null;
}
buildQuery() {
let sql = `SELECT ${this.selectClause} FROM ${this.fromClause}`;
if (this.joinClauses.length > 0) {
sql += ' ' + this.joinClauses.join(' ');
}
if (this.whereClauses.length > 0) {
sql += ' WHERE ' + this.whereClauses.join(' AND ');
}
if (this.orderByClause) {
sql += ' ' + this.orderByClause;
}
if (this.limitClause) {
sql += ' ' + this.limitClause;
}
return sql;
}
}
exports.QueryBuilder = QueryBuilder;
//# sourceMappingURL=EntityManager.js.map