UNPKG

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