dynamic-orm
Version:
A flexible and powerful dynamic ORM for SQL databases with Redis caching support and many-to-many relationship handling
1,056 lines • 50.8 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.DynamicModel = void 0;
const uuid_1 = require("uuid");
// Default logger that uses console
const defaultLogger = {
error: (message, ...args) => console.error(message, ...args),
warn: (message, ...args) => console.warn(message, ...args),
info: (message, ...args) => console.info(message, ...args),
debug: (message, ...args) => console.debug(message, ...args)
};
/**
* Enhanced Dynamic Model for database operations
* Provides a flexible and powerful abstraction over database tables
*/
class DynamicModel {
/**
* Create a new Dynamic Model instance
* @param table - Database table name
* @param options - Configuration options
* @param db - Database adapter
* @param cache - Cache adapter
*/
constructor(table, options = {}, db, cache) {
this.table = table;
this.useCache = options.useCache || false;
this.cacheTTL = options.cacheTTL || 3600; // Default: 1 hour
this.primaryKey = options.primaryKey || 'id';
this.defaultLimit = options.defaultLimit || 100;
this.maxLimit = options.maxLimit || 1000;
this.searchableFields = options.searchableFields || [];
this.db = db;
this.cache = cache;
this.logger = options.logger || defaultLogger;
}
/**
* Find records with filtering, pagination, sorting and field selection
*/
async findAll(options = {}) {
// For backward compatibility with getAll
if (typeof this.getAll === 'undefined') {
this.getAll = this.findAll;
}
// Handle both new options object format and old filters direct parameter
const isLegacyCall = !options || typeof options !== 'object' || !Object.keys(options).some(k => ['filters', 'sort', 'fields', 'pagination', 'search', 'relations'].includes(k));
const filters = isLegacyCall ? options : (options.filters || {});
const sort = isLegacyCall ? null : options.sort;
const fields = isLegacyCall ? null : options.fields;
const pagination = isLegacyCall ? null : options.pagination;
const search = isLegacyCall ? null : options.search;
const relations = isLegacyCall ? null : options.relations;
// Build cache key
const cacheKey = this._buildCacheKey('findAll', { filters, sort, fields, pagination, search, relations });
// Try to get from cache
let cached = null;
if (this.useCache) {
try {
cached = await this.cache.get(cacheKey);
if (cached) {
return JSON.parse(cached);
}
}
catch (cacheError) {
// Log cache error but continue without cache
this.logger.error(`[${this.table}] Cache error in findAll:`, cacheError);
// Continue execution without using cache
}
}
try {
// Prepare query components
let params = [];
let conditions = [];
let whereClause = '';
let joins = [];
let groupBy = '';
let tableAliases = {};
let mainTableAlias = 't1';
// Add table alias for main table
tableAliases[this.table] = mainTableAlias;
// Process relations if provided
if (relations && Array.isArray(relations) && relations.length > 0) {
let joinIndex = 2;
for (const relation of relations) {
if (!relation.table)
continue;
const as = relation.as || relation.table;
// Handle many-to-many relationship (using a junction table)
if (relation.through && relation.throughLocalKey && relation.throughForeignKey) {
// For many-to-many relationships, we need two joins: one to the junction table and one to the related table
const junctionAlias = `t${joinIndex++}`;
const relAlias = `t${joinIndex}`;
// Join to the junction table first
joins.push(`LEFT JOIN ${relation.through} ${junctionAlias} ON ${mainTableAlias}.${this.primaryKey} = ${junctionAlias}.${relation.throughLocalKey}`);
// Then join from junction table to the related table
joins.push(`LEFT JOIN ${relation.table} ${relAlias} ON ${junctionAlias}.${relation.throughForeignKey} = ${relAlias}.${relation.foreignKey}`);
// Save table alias for later use
tableAliases[relation.table] = relAlias;
tableAliases[relation.through] = junctionAlias;
// Add relation filters if provided
if (relation.filters && typeof relation.filters === 'object') {
for (const [key, value] of Object.entries(relation.filters)) {
// Apply filters to the related table, not the junction table
// Handle different types of filter values for relation
if (value === null) {
conditions.push(`${relAlias}.${key} IS NULL`);
}
else if (Array.isArray(value)) {
if (value.length === 0) {
conditions.push('FALSE');
}
else {
const placeholders = value.map(() => '?').join(', ');
conditions.push(`${relAlias}.${key} IN (${placeholders})`);
params.push(...value);
}
}
else if (typeof value === 'object') {
for (const [op, val] of Object.entries(value)) {
let sqlOp;
switch (op) {
case 'gt':
sqlOp = '>';
break;
case 'lt':
sqlOp = '<';
break;
case 'gte':
sqlOp = '>=';
break;
case 'lte':
sqlOp = '<=';
break;
case 'ne':
sqlOp = '!=';
break;
case 'like':
sqlOp = 'LIKE';
break;
case 'ilike':
sqlOp = 'ILIKE';
break;
default: sqlOp = '=';
}
if (val === null && op === 'ne') {
conditions.push(`${relAlias}.${key} IS NOT NULL`);
}
else if (val === null) {
conditions.push(`${relAlias}.${key} IS NULL`);
}
else {
conditions.push(`${relAlias}.${key} ${sqlOp} ?`);
params.push(val);
}
}
}
else {
conditions.push(`${relAlias}.${key} = ?`);
params.push(value);
}
}
}
}
else {
// Handle regular one-to-many or one-to-one relationship
if (!relation.foreignKey)
continue;
const joinType = (relation.type || 'left').toUpperCase();
const relAlias = `t${joinIndex}`;
const localKey = relation.localKey || this.primaryKey;
// Save table alias for later use
tableAliases[relation.table] = relAlias;
// Build join clause - swap the order of fields in the ON clause
joins.push(`${joinType} JOIN ${relation.table} ${relAlias} ON ${mainTableAlias}.${localKey} = ${relAlias}.${relation.foreignKey}`);
// Add relation filters if provided
if (relation.filters && typeof relation.filters === 'object') {
for (const [key, value] of Object.entries(relation.filters)) {
// Handle different types of filter values for relation
if (value === null) {
conditions.push(`${relAlias}.${key} IS NULL`);
}
else if (Array.isArray(value)) {
if (value.length === 0) {
conditions.push('FALSE');
}
else {
const placeholders = value.map(() => '?').join(', ');
conditions.push(`${relAlias}.${key} IN (${placeholders})`);
params.push(...value);
}
}
else if (typeof value === 'object') {
for (const [op, val] of Object.entries(value)) {
let sqlOp;
switch (op) {
case 'gt':
sqlOp = '>';
break;
case 'lt':
sqlOp = '<';
break;
case 'gte':
sqlOp = '>=';
break;
case 'lte':
sqlOp = '<=';
break;
case 'ne':
sqlOp = '!=';
break;
case 'like':
sqlOp = 'LIKE';
break;
case 'ilike':
sqlOp = 'ILIKE';
break;
default: sqlOp = '=';
}
if (val === null && op === 'ne') {
conditions.push(`${relAlias}.${key} IS NOT NULL`);
}
else if (val === null) {
conditions.push(`${relAlias}.${key} IS NULL`);
}
else {
conditions.push(`${relAlias}.${key} ${sqlOp} ?`);
params.push(val);
}
}
}
else {
conditions.push(`${relAlias}.${key} = ?`);
params.push(value);
}
}
}
}
joinIndex++;
}
// Need GROUP BY when using joins to avoid duplicates
if (joins.length > 0) {
groupBy = `GROUP BY ${mainTableAlias}.${this.primaryKey}`;
}
}
// Handle search if provided
if (search && this.searchableFields.length > 0) {
const searchConditions = this.searchableFields.map(field => {
const dotIndex = field.indexOf('.');
// Handle fields with table names
if (dotIndex > -1) {
const tableName = field.substring(0, dotIndex);
const columnName = field.substring(dotIndex + 1);
const tableAlias = tableAliases[tableName] || mainTableAlias;
params.push(`%${search}%`);
return `${tableAlias}.${columnName} LIKE ?`;
}
else {
params.push(`%${search}%`);
return `${mainTableAlias}.${field} LIKE ?`;
}
});
if (searchConditions.length > 0) {
conditions.push(`(${searchConditions.join(' OR ')})`);
}
}
// Process main table filters
if (filters && Object.keys(filters).length > 0) {
for (const [key, value] of Object.entries(filters)) {
// Check if filter key contains table reference (tableName.columnName)
const dotIndex = key.indexOf('.');
let tableAlias = mainTableAlias;
let columnName = key;
if (dotIndex > -1) {
const tableName = key.substring(0, dotIndex);
columnName = key.substring(dotIndex + 1);
tableAlias = tableAliases[tableName] || mainTableAlias;
}
// Handle different types of filter values
if (value === null) {
conditions.push(`${tableAlias}.${columnName} IS NULL`);
}
else if (Array.isArray(value)) {
if (value.length === 0) {
conditions.push('FALSE');
}
else {
const placeholders = value.map(() => '?').join(', ');
conditions.push(`${tableAlias}.${columnName} IN (${placeholders})`);
params.push(...value);
}
}
else if (typeof value === 'object') {
for (const [op, val] of Object.entries(value)) {
let sqlOp;
switch (op) {
case 'gt':
sqlOp = '>';
break;
case 'lt':
sqlOp = '<';
break;
case 'gte':
sqlOp = '>=';
break;
case 'lte':
sqlOp = '<=';
break;
case 'ne':
sqlOp = '!=';
break;
case 'like':
sqlOp = 'LIKE';
break;
case 'ilike':
sqlOp = 'ILIKE';
break;
default: sqlOp = '=';
}
if (val === null && op === 'ne') {
conditions.push(`${tableAlias}.${columnName} IS NOT NULL`);
}
else if (val === null) {
conditions.push(`${tableAlias}.${columnName} IS NULL`);
}
else {
conditions.push(`${tableAlias}.${columnName} ${sqlOp} ?`);
params.push(val);
}
}
}
else {
conditions.push(`${tableAlias}.${columnName} = ?`);
params.push(value);
}
}
}
if (conditions.length > 0) {
whereClause = `WHERE ${conditions.join(' AND ')}`;
}
// Build SELECT clause
let selectFields = '';
if (relations && Array.isArray(relations) && relations.length > 0) {
// Handle selections from multiple tables
const selectParts = [];
// Main table fields
if (fields) {
if (typeof fields === 'string') {
selectParts.push(`${mainTableAlias}.${fields}`);
}
else if (Array.isArray(fields)) {
selectParts.push(fields.map(f => `${mainTableAlias}.${f}`).join(', '));
}
}
else {
selectParts.push(`${mainTableAlias}.*`);
}
// Related table fields with JSON aggregation
for (const relation of relations) {
if (!relation.table)
continue;
const relAlias = tableAliases[relation.table];
const as = relation.as || relation.table;
// Include specific relation fields or all
const relationFields = relation.select || '*';
let relFieldsStr = '';
if (relationFields === '*') {
relFieldsStr = `${relAlias}.*`;
}
else if (Array.isArray(relationFields)) {
relFieldsStr = relationFields.map(f => `${relAlias}.${f} AS "${as}.${f}"`).join(', ');
}
else if (typeof relationFields === 'string') {
relFieldsStr = `${relAlias}.${relationFields} AS "${as}.${relationFields}"`;
}
if (relFieldsStr) {
selectParts.push(relFieldsStr);
}
}
selectFields = selectParts.join(', ');
}
else {
// Simple table select
selectFields = this._buildSelectClause(fields);
// Add table alias if we have it
if (mainTableAlias) {
// If selectFields contains specific field names
if (selectFields !== '*') {
// Add table alias to each field
selectFields = selectFields.split(', ')
.map(field => `${mainTableAlias}.${field.trim()}`)
.join(', ');
}
else {
selectFields = `${mainTableAlias}.*`;
}
}
}
// Build ORDER BY clause with table alias
let orderByClause = '';
if (sort) {
let sortFields = [];
if (typeof sort === 'string') {
const direction = sort.startsWith('-') ? 'DESC' : 'ASC';
const field = sort.startsWith('-') ? sort.substring(1) : sort;
const dotIndex = field.indexOf('.');
let tableAlias = mainTableAlias;
let columnName = field;
if (dotIndex > -1) {
const tableName = field.substring(0, dotIndex);
columnName = field.substring(dotIndex + 1);
tableAlias = tableAliases[tableName] || mainTableAlias;
}
sortFields.push(`${tableAlias}.${columnName} ${direction}`);
}
else if (Array.isArray(sort)) {
sortFields = sort.map(field => {
const direction = field.startsWith('-') ? 'DESC' : 'ASC';
const fieldName = field.startsWith('-') ? field.substring(1) : field;
const dotIndex = fieldName.indexOf('.');
let tableAlias = mainTableAlias;
let columnName = fieldName;
if (dotIndex > -1) {
const tableName = fieldName.substring(0, dotIndex);
columnName = fieldName.substring(dotIndex + 1);
tableAlias = tableAliases[tableName] || mainTableAlias;
}
return `${tableAlias}.${columnName} ${direction}`;
});
}
else if (typeof sort === 'object') {
sortFields = Object.entries(sort).map(([field, direction]) => {
const dotIndex = field.indexOf('.');
let tableAlias = mainTableAlias;
let columnName = field;
if (dotIndex > -1) {
const tableName = field.substring(0, dotIndex);
columnName = field.substring(dotIndex + 1);
tableAlias = tableAliases[tableName] || mainTableAlias;
}
return `${tableAlias}.${columnName} ${(direction || '').toString().toLowerCase() === 'desc' ? 'DESC' : 'ASC'}`;
});
}
if (sortFields.length > 0) {
orderByClause = `ORDER BY ${sortFields.join(', ')}`;
}
}
// Build pagination clauses
let limitClause = '';
let offsetClause = '';
let page = 1;
let limit = 0;
let offset = 0;
if (pagination) {
page = Math.max(1, parseInt(pagination.page) || 1);
limit = pagination.limit ?
Math.min(this.maxLimit, Math.max(1, parseInt(pagination.limit))) :
this.defaultLimit;
offset = (page - 1) * limit;
limitClause = `LIMIT ${limit}`;
offsetClause = `OFFSET ${offset}`;
}
// Build FROM clause with joins
const fromClause = joins.length > 0
? `FROM ${this.table} ${mainTableAlias} ${joins.join(' ')}`
: `FROM ${this.table} ${mainTableAlias}`;
// Execute count query for pagination
let total = 0;
if (pagination || isLegacyCall) {
// We need to use a subquery for accurate counts with JOINs
let countQuery;
if (joins.length > 0) {
countQuery = `
SELECT COUNT(DISTINCT ${mainTableAlias}.${this.primaryKey}) as total
${fromClause}
${whereClause}
`;
}
else {
countQuery = `
SELECT COUNT(*) as total
${fromClause}
${whereClause}
`;
}
const [countResult] = await this.db.prepare(countQuery, params);
total = parseInt(countResult.total);
}
// Execute data query
const dataQuery = `
SELECT ${selectFields}
${fromClause}
${whereClause}
${groupBy}
${orderByClause}
${limitClause}
${offsetClause}
`;
const rawData = await this.db.prepare(dataQuery, params);
// Process results to nest relation data
let data = rawData;
if (relations && Array.isArray(relations) && relations.length > 0) {
// Group related data into nested objects
data = await this._processRelatedData(rawData, relations);
}
// Build result
const result = {
data,
pagination: {
total,
page,
limit: limit || total,
pages: limit ? Math.ceil(total / limit) : 1,
hasNext: limit ? (offset + limit < total) : false
}
};
// Cache the result
if (this.useCache) {
try {
await this.cache.set(cacheKey, JSON.stringify(result), 'EX', this.cacheTTL);
}
catch (cacheError) {
// Just log cache error but continue
this.logger.error(`[${this.table}] Cache error during set in findAll:`, cacheError);
}
}
return result;
}
catch (error) {
this.logger.error(`[${this.table}] findAll error:`, error);
throw error;
}
}
// Alias getAll to findAll for backward compatibility
async getAll(options = {}) {
return this.findAll(options);
}
/**
* Process query results to organize related data
* @private
*/
async _processRelatedData(records, relations) {
if (!records || !records.length)
return [];
const result = [];
const primaryKeyField = this.primaryKey;
const processedIds = new Set();
for (const row of records) {
const id = row[primaryKeyField];
// Skip if we already processed this main record
if (processedIds.has(id))
continue;
processedIds.add(id);
// Create the main record (exclude relation fields)
const mainRecord = { ...row };
// Process each relation
for (const relation of relations) {
const as = relation.as || relation.table;
// Handle many-to-many relationship
if (relation.through) {
// Always treat many-to-many relationships as "many" type
// Special case for when only one field is selected
const isSingleField = relation.select && Array.isArray(relation.select) && relation.select.length === 1;
const singleFieldName = isSingleField ? relation.select[0] : null;
// We need to collect all rows for this record to get all related data
const relatedRows = records.filter(r => r[primaryKeyField] === id);
// Set to track processed related entities to avoid duplicates
const processedRelatedIds = new Set();
const hasAnyRelatedData = relatedRows.some(row => {
// Check if there's any related data
if (relation.select && Array.isArray(relation.select)) {
for (const field of relation.select) {
const relFieldKey = `${as}.${field}`;
if (relFieldKey in row && row[relFieldKey] !== null) {
return true;
}
}
}
return false;
});
// Initialize the array only if we actually found related data
if (hasAnyRelatedData) {
// Initialize with empty array
mainRecord[as] = [];
for (const relatedRow of relatedRows) {
if (relation.select && Array.isArray(relation.select)) {
const relObject = {};
let hasRelatedData = false;
let relatedEntityId = null;
for (const field of relation.select) {
const relFieldKey = `${as}.${field}`;
// If the related field exists and has a value
if (relFieldKey in relatedRow && relatedRow[relFieldKey] !== null) {
relObject[field] = relatedRow[relFieldKey];
// Mark that we found related data
hasRelatedData = true;
// If this is the FK field from the related table
if (field === relation.foreignKey) {
relatedEntityId = relatedRow[relFieldKey];
}
// Remove the dotted field from the main record
delete mainRecord[relFieldKey];
}
}
// Only add if we found related data and haven't processed this related entity yet
if (hasRelatedData && relatedEntityId && !processedRelatedIds.has(relatedEntityId)) {
processedRelatedIds.add(relatedEntityId);
if (isSingleField) {
// For single field selection, add the value directly to the array
mainRecord[as].push(relObject[singleFieldName]);
}
else {
// For multiple fields, add the object to the array
mainRecord[as].push(relObject);
}
}
}
}
}
else {
// If no related data was found, don't include the field at all
// This ensures the property won't appear in the output when there's no data
// Do nothing - don't set the property at all
}
}
else {
// Handle one-to-one or one-to-many relationship
// Initialize relation container
mainRecord[as] = relation.type === 'many' ? [] : {};
// Extract relation fields from the row
if (relation.select && Array.isArray(relation.select)) {
const relObject = relation.type === 'many' ? [] : {};
let hasRelatedData = false;
for (const field of relation.select) {
const relFieldKey = `${as}.${field}`;
// If the relation field exists in the result
if (relFieldKey in row) {
// Add the field to the relation object
if (relation.type === 'many') {
// Handle many relation case
if (!Array.isArray(relObject) || !relObject[0]) {
relObject[0] = {};
}
relObject[0][field] = row[relFieldKey];
}
else {
// Handle single relation case
relObject[field] = row[relFieldKey];
}
// Mark that we found related data
hasRelatedData = true;
// Remove the dotted field from the main record
delete mainRecord[relFieldKey];
}
}
// Only set if we actually found related data
if (hasRelatedData) {
if (relation.type === 'many') {
mainRecord[as] = relObject;
}
else {
// If relation has only a single field, extract the value directly
if (relation.select.length === 1) {
const singleField = relation.select[0];
mainRecord[as] = relObject[singleField];
}
else {
mainRecord[as] = relObject;
}
}
}
else {
// If no relation data was found, set to null for single relations
// or empty array for many relations
mainRecord[as] = relation.type === 'many' ? [] : null;
}
}
else {
// If no relation fields were selected, set to null for single relations
// or empty array for many relations
mainRecord[as] = relation.type === 'many' ? [] : null;
}
}
}
result.push(mainRecord);
}
return result;
}
/**
* Find a record by its primary key
*/
async findById(id, fields) {
if (!id)
return null;
const cacheKey = this._buildCacheKey('findById', { id, fields });
// Try to get from cache
let cached = null;
if (this.useCache) {
try {
cached = await this.cache.get(cacheKey);
if (cached) {
return JSON.parse(cached);
}
}
catch (cacheError) {
// Log cache error but continue without cache
this.logger.error(`[${this.table}] Cache error in findById:`, cacheError);
// Continue execution without using cache
}
}
try {
const selectClause = this._buildSelectClause(fields);
const query = `SELECT ${selectClause} FROM ${this.table} WHERE ${this.primaryKey} = ?`;
const results = await this.db.prepare(query, [id]);
const record = results.length > 0 ? results[0] : null;
// Cache result
if (this.useCache && record) {
try {
await this.cache.set(cacheKey, JSON.stringify(record), 'EX', this.cacheTTL);
}
catch (cacheError) {
// Just log cache error but continue
this.logger.error(`[${this.table}] Cache error during set in findById:`, cacheError);
}
}
return record;
}
catch (error) {
this.logger.error(`[${this.table}] findById error:`, error);
throw error;
}
}
/**
* Find a record by a specific field value
*/
async findByField(field, value, fields) {
let cached = null;
const cacheKey = this._buildCacheKey('findByField', { field, value, fields });
if (this.useCache) {
try {
cached = await this.cache.get(cacheKey);
if (cached) {
return JSON.parse(cached);
}
}
catch (error) {
this.logger.error(`[${this.table}] Cache error when getting in findByField:`, error);
}
}
try {
const selectClause = this._buildSelectClause(fields);
const query = `SELECT ${selectClause} FROM ${this.table} WHERE ${field} = ? LIMIT 1`;
const results = await this.db.prepare(query, [value]);
const record = results.length > 0 ? results[0] : null;
// Cache result
if (this.useCache && record) {
try {
await this.cache.set(cacheKey, JSON.stringify(record), 'EX', this.cacheTTL);
}
catch (error) {
this.logger.error(`[${this.table}] Cache error when setting in findByField:`, error);
}
}
return record;
}
catch (error) {
this.logger.error(`[${this.table}] findByField error:`, error);
throw error;
}
}
/**
* Create a new record
*/
async create(data, returnRecord = true) {
if (!data || typeof data !== 'object' || Object.keys(data).length === 0) {
throw new Error('Data must be a non-empty object');
}
// Add UUID if no ID is provided and primaryKey is 'id'
if (this.primaryKey === 'id' && !data.id) {
data.id = (0, uuid_1.v4)();
}
try {
const keys = Object.keys(data).join(", ");
const values = Object.values(data);
const placeholders = values.map(() => "?").join(", ");
let query = `INSERT INTO ${this.table} (${keys}) VALUES (${placeholders})`;
if (returnRecord) {
query += ` RETURNING *`;
}
const result = await this.db.prepare(query, values);
// Store the return value before trying to invalidate cache
const returnValue = returnRecord ? result[0] : result;
// Invalidate cache
if (this.useCache) {
await this.invalidateTableCache();
}
return returnValue;
}
catch (error) {
this.logger.error(`[${this.table}] create error:`, error);
throw error;
}
}
/**
* Update a record by ID
*/
async update(id, data, returnRecord = true) {
if (!id) {
throw new Error('ID is required');
}
if (!data || typeof data !== 'object' || Object.keys(data).length === 0) {
throw new Error('Update data must be a non-empty object');
}
try {
const updates = Object.keys(data).map((key) => `${key} = ?`).join(", ");
let query = `UPDATE ${this.table} SET ${updates} WHERE ${this.primaryKey} = ?`;
if (returnRecord) {
query += ` RETURNING *`;
}
const result = await this.db.prepare(query, [...Object.values(data), id]);
// Invalidate cache
if (this.useCache) {
try {
await Promise.all([
this.cache.del(this._buildCacheKey('findById', { id })),
this.invalidateTableCache()
]);
}
catch (cacheError) {
// Just log cache error but continue
this.logger.error(`[${this.table}] Cache error during invalidation in update:`, cacheError);
}
}
return returnRecord ? (result.length > 0 ? result[0] : null) : result;
}
catch (error) {
this.logger.error(`[${this.table}] update error:`, error);
throw error;
}
}
/**
* Delete a record by ID
*/
async delete(id, returnRecord = false) {
if (!id) {
throw new Error('ID is required');
}
try {
let deletedRecord = null;
// If we need to return the record, get it first
if (returnRecord) {
deletedRecord = await this.findById(id);
if (!deletedRecord)
return null;
}
const query = `DELETE FROM ${this.table} WHERE ${this.primaryKey} = ?`;
const result = await this.db.prepare(query, [id]);
// Invalidate cache
if (this.useCache) {
try {
await Promise.all([
this.cache.del(this._buildCacheKey('findById', { id })),
this.invalidateTableCache()
]);
}
catch (cacheError) {
// Just log cache error but continue
this.logger.error(`[${this.table}] Cache error during invalidation in delete:`, cacheError);
}
}
return returnRecord ? deletedRecord : result;
}
catch (error) {
this.logger.error(`[${this.table}] delete error:`, error);
throw error;
}
}
/**
* Count records matching filters
*/
async count(filters = {}) {
const cacheKey = this._buildCacheKey('count', { filters });
// Try to get from cache
let cached = null;
if (this.useCache) {
try {
cached = await this.cache.get(cacheKey);
if (cached) {
return parseInt(cached);
}
}
catch (cacheError) {
// Log cache error but continue without cache
this.logger.error(`[${this.table}] Cache error in count:`, cacheError);
// Continue execution without using cache
}
}
try {
// Build WHERE clause
let params = [];
let conditions = [];
for (const [key, value] of Object.entries(filters)) {
if (value === null) {
conditions.push(`${key} IS NULL`);
}
else if (Array.isArray(value)) {
if (value.length === 0) {
conditions.push('FALSE');
}
else {
const placeholders = value.map(() => '?').join(', ');
conditions.push(`${key} IN (${placeholders})`);
params.push(...value);
}
}
else if (typeof value === 'object') {
for (const [op, val] of Object.entries(value)) {
let sqlOp;
switch (op) {
case 'gt':
sqlOp = '>';
break;
case 'lt':
sqlOp = '<';
break;
case 'gte':
sqlOp = '>=';
break;
case 'lte':
sqlOp = '<=';
break;
case 'ne':
sqlOp = '!=';
break;
case 'like':
sqlOp = 'LIKE';
break;
default: sqlOp = '=';
}
conditions.push(`${key} ${sqlOp} ?`);
params.push(val);
}
}
else {
conditions.push(`${key} = ?`);
params.push(value);
}
}
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
const query = `SELECT COUNT(*) as count FROM ${this.table} ${whereClause}`;
const [result] = await this.db.prepare(query, params);
const count = parseInt(result.count);
// Cache result
if (this.useCache) {
try {
await this.cache.set(cacheKey, count.toString(), 'EX', this.cacheTTL);
}
catch (cacheError) {
// Just log cache error but continue
this.logger.error(`[${this.table}] Cache error during set in count:`, cacheError);
}
}
return count;
}
catch (error) {
this.logger.error(`[${this.table}] count error:`, error);
throw error;
}
}
/**
* Execute multiple operations in a transaction
*/
async withTransaction(callback) {
return this.db.transaction(async (conn) => {
// Create a transaction-specific model
const txModel = {
table: this.table,
primaryKey: this.primaryKey,
// Transaction-specific methods
executeQuery: async (sql, params = []) => {
return await conn.prepare(sql, params);
},
create: async (data, returnRecord = true) => {
const keys = Object.keys(data).join(', ');
const values = Object.values(data);
const placeholders = values.map(() => '?').join(', ');
let query = `INSERT INTO ${this.table} (${keys}) VALUES (${placeholders})`;
if (returnRecord) {
query += ` RETURNING *`;
}
const result = await conn.prepare(query, values);
return returnRecord ? result[0] : result;
},
update: async (id, data, returnRecord = true) => {
const updates = Object.keys(data).map(key => `${key} = ?`).join(', ');
let query = `UPDATE ${this.table} SET ${updates} WHERE ${this.primaryKey} = ?`;
if (returnRecord) {
query += ` RETURNING *`;
}
const result = await conn.prepare(query, [...Object.values(data), id]);
return returnRecord ? (result.length > 0 ? result[0] : null) : result;
},
delete: async (id, returnRecord = false) => {
if (returnRecord) {
const query = `SELECT * FROM ${this.table} WHERE ${this.primaryKey} = ?`;
const records = await conn.prepare(query, [id]);
const record = records.length > 0 ? records[0] : null;
if (!record)
return null;
await conn.prepare(`DELETE FROM ${this.table} WHERE ${this.primaryKey} = ?`, [id]);
return record;
}
else {
const query = `DELETE FROM ${this.table} WHERE ${this.primaryKey} = ?`;
return await conn.prepare(query, [id]);
}
},
findById: async (id, fields) => {
const selectFields = this._buildSelectClause(fields);
const query = `SELECT ${selectFields} FROM ${this.table} WHERE ${this.primaryKey} = ?`;
const records = await conn.prepare(query, [id]);
return records.length > 0 ? records[0] : null;
}
};
// Execute the callback with our transaction model
const result = await callback(txModel);
// Invalidate cache after successful transaction
if (this.useCache) {
await this.invalidateTableCache();
}
return result;
});
}
/**
* Execute a custom query
*/
async executeQuery(sql, params = []) {
try {
return await this.db.prepare(sql, params);
}
catch (error) {
this.logger.error(`[${this.table}] executeQuery error:`, error);
throw error;
}
}
/**
* Invalidate all cache for this table
*/
async invalidateTableCache() {
if (!this.useCache)
return;
try {
const keys = await this.cache.keys(`${this.table}:*`);
if (keys.length > 0) {
await this.cache.del(keys);
}
}
catch