UNPKG

zs-core-db

Version:

Servicios generales de acceso a bases de datos

475 lines (398 loc) 13.9 kB
// @ts-check const uuid = require("uuid"); const { log, error, raise } = require("../utils/Logging"); const oracle = require("./oracle"); const postgres = require("./postgres"); const DBModel = require("./DBModel"); const DBValidator = require("./DBValidator"); const DRIVERS = { oracle, postgres }; const CONNECTIONS = { __vendor: "", __database: "", }; const getConnection = (vendor, database) => { const _vendor = vendor || CONNECTIONS.__vendor || DBModel.DEFAULTS.driver; const _database = database || CONNECTIONS.__database || DBModel.DEFAULTS.database; CONNECTIONS[_vendor] = CONNECTIONS[_vendor] ?? {}; const connection = CONNECTIONS[_vendor][_database]; const missingConnection = !_vendor && !_database && !connection; missingConnection && raise(`------>No hay conexión para ${_vendor}.${_database}`); return connection; }; const createId = () => uuid.v4(); /** * @param {string} url * @returns */ const parseURL = (url) => { !url && raise("Se requiere url para conectar a una base de datos"); const vendor = url.split("://")[0]; !vendor && raise(`Falta tipo de base de datos al inicio de url: ${url}`); const user = url.split("://")[1].split(":")[0]; !user && raise(`Falta USER en url: ${url}`); const password = url.split("://")[1].split(":")[1].split("@")[0]; !password && raise(`Falta PASSWORD en url: ${url}`); const host = url.split("@")[1].split(":")[0]; !host && raise(`Falta HOST en url: ${url}`); const port = url.split("@")[1].split(":")[1]; !port && raise(`Falta PORT en url: ${url}`); const hasLastSlash = url.split("://")[1].lastIndexOf("/") >= 0; const database = String((hasLastSlash && url.split("/").reverse()[0]) || ""); !database && raise(`Falta base de datos al final de url: ${url}`); const driver = DRIVERS[vendor]; !driver && raise(`No existe conector implementado para ${vendor}`); return { vendor, database, driver, user }; }; const toUrl = (connectParams) => (typeof connectParams === "string" && connectParams) || (typeof connectParams === "object" && `${connectParams.dialect ?? "postgres"}://${connectParams.user}:${ connectParams.pass }@${connectParams.host}:${connectParams.port}/${connectParams.name}`) || ""; /** * @param {string|object} connectParams */ const reconnect = async (connectParams) => { try { const url = toUrl(connectParams); console.log("CONNECT-PARAMS", connectParams); console.log("CONNECT-PARAMS---URL", url); const { vendor, database, driver, user } = parseURL(url); const connection = getConnection(vendor, database); const connectionId = `${user}@${url.split("@")[1]}`; try { await connection?.check(url); } catch (checkError) { try { log(`Reconectando ${connectionId}`); await driver.connect(url); log(`Conexión ${connectionId} [OK]`); } catch (connectError) { const ms = 3000; error(`Reconectando ${connectionId}: ${connectError?.message}`); error(`Se reintentará conexión en ${ms / 1000} segundos`); setTimeout(reconnect, ms, url); } } } catch (urlError) { raise("Imposible reconectar a base de datos"); } }; /** * @param {string|object} connectParams */ const connect = async (connectParams) => { const url = toUrl(connectParams); const { vendor, database, driver } = parseURL(url); const initConnection = async (url = "") => { const user = url.split("://")[1].split(":")[0]; const connectionId = `${user}@${url.split("@")[1]}`; log(`Conectando a base de datos: ${connectionId}`); const connection = await driver.connect(url); CONNECTIONS[vendor][database] = { ...connection, url }; log(`Conexión exitosa: ${connectionId} [OK]`); use(database, vendor); DBModel.DEFAULTS.driver = vendor; DBModel.DEFAULTS.database = database; Object.values(DBModel.getDeclaredModels()).forEach((model) => declare(model) ); return connection; }; try { const connection = getConnection(vendor, database) ?? (await initConnection(url)); } catch (connectError) { await getConnection(vendor, database)?.close(); CONNECTIONS[vendor][database] = undefined; const ms = 3000; error(`Conectando a ${url}: ${connectError?.message}`); error(`Se reintentará conexión en ${ms / 1000} segundos`); setTimeout(connect, ms, url); } }; /** * @param {string} vendor * @param {string} database * @returns */ const use = async (database, vendor) => { const _vendor = vendor || CONNECTIONS.__vendor || DBModel.DEFAULTS.driver; const _database = database || CONNECTIONS.__database || DBModel.DEFAULTS.database; const connection = getConnection(_vendor, _database); await reconnect(connection?.url); connection?.use(_database); }; /** * Field * @typedef {Object} Field * @property {string} [column] * @property {string} type * @property {boolean} [required] * @property {any} [default] Valor por defecto (si estuviera vacio) */ /** * Key * @typedef {Object} Key * @property {string} [type] * @property {Array<string>} fields */ /** * Relation * @typedef {Object} Relation * @property {RelationTarget} detail Definición de detalle de la relacion * @property {RelationTarget} related Definición de referencia de la rlaeción */ /** * RelationTarget * @typedef {Object} RelationTarget * @property {DeclaredObject} model Nombre de la entidad de detalle * @property {string} id Campo a traves del cual se obtiene el detalle * @property {string} param Campo a traves del cual se obtiene el detalle */ /** * ModelSchema * @typedef {Object} ModelSchema * @property {string} __filename Ruta de archivo (__filename) * @property {string} [driver] Proveedor de datos (oracle, postgres, etc) * @property {string} [database] Base de datos * @property {string} table Nombre de tabla * @property {Object.<string, Field>} fields Campos de la tabla * @property {Object.<string, Key>} keys Indices de la tabla * @property {Object.<string, Relation>} [relations] Tiene muchos */ /** * @typedef {Object} DeclaredObject * @property {string} table * @property {string} entity * @property {Object.<string, Field>} fields Campos de la tabla * @property {Object.<string, Key>} keys Indices de la tabla * @property {Function} createId * @property {Function} find * @property {Function} findOne * @property {Function} save * @property {Function} create * @property {Function} update * @property {Function} remove * @property {Object.<string, Relation>} [relations] Relaciones */ /** * @param {ModelSchema} model * @returns {DeclaredObject} */ const declare = (model) => { DBValidator.checkRequiredModel(model); const prepared = DBModel.prepare(model); const declared = { entity: prepared.entity, table: prepared.table, fields: prepared.fields, keys: prepared.keys, details: model.details, references: model.references, relations: model.relations, createId: () => createId(), find: (query = {}) => find(prepared, query), findOne: (query = {}) => findOne(prepared, query), save: (object = {}) => save(prepared, object), create: (data = {}) => create(prepared, data), update: (data = {}, where = {}) => update(prepared, data, where), remove: (where = {}) => remove(prepared, where), }; return declared; }; const toSQLWhere = (model, query = {}) => { const invalid = Object.keys(query ?? {}) .filter((key) => key !== "_id") .filter((key) => !model.fields[key]); invalid[0] && raise(`Propiedades de consulta inválidas: ${invalid}`); const props = Object.entries(query); return ( props[0] && `where ${props .map((prop) => `${model.fields[prop[0]].column} = :${prop[0]}`) .join(" and ")}` ); }; const find = async (model = {}, queryParams) => { await use(model.database, model.driver); const columns = Object.keys(model.fields).map( (fieldName) => `${model.fields[fieldName].column} as "${fieldName}"` ); const sql = `SELECT ${columns} FROM ${model.table} ${toSQLWhere( model, queryParams )}`; const bind = (!Object.keys(queryParams).includes("_id") && queryParams) || (({ _id, ...rest }) => ({ row_id: _id, ...rest }))(queryParams); return await query({ sql, bind }); }; const findOne = async (model, query) => (await find(model, query))[0]; // /** // * @param {DBModel.TranspiledSchema} model // * @param {Object} data // * @returns // */ const create = async (model, data) => { use(model.database, model.driver); DBValidator.checkRequiredModel(model); DBValidator.checkRequiredData(model, data); DBValidator.checkUndeclaredFields(model, data); DBValidator.checkRequiredInsertFields(model, data); const sequenceField = Object.values(model.keys).find( (key) => key.type === "PK" && key.columns.length === 1 && model.fields[key.fields[0]].type === "SEQUENCE" )?.fields[0]; const columns = Object.keys(model.fields) .filter((key) => key !== sequenceField) .map((key) => model.fields[key].column) .join(", "); const fields = Object.keys(model.fields) .filter((key) => key !== sequenceField) .map((field) => `:${field}`) .join(", "); const sql = `INSERT INTO ${model.table} (${columns}) VALUES (${fields})`; const uuidField = Object.values(model.keys).find( (key) => key.type === "PK" && key.columns.length === 1 && model.fields[key.fields[0]].type === "UUID" )?.fields[0]; const uuidValue = (uuidField && !data[uuidField] && createId()) || (uuidField && data[uuidField]); const values = {}; Object.keys(model.fields) .filter((key) => key !== sequenceField) .forEach((key) => { const field = model.fields[key]; const value = data[key]; const isDate = field.type.startsWith("DATE"); const isAudit = field.type.startsWith("AUDIT"); const isString = field.type.startsWith("STR") || field.type === "CLOB"; const isNumber = field.type.startsWith("NUM"); const isBoolean = field.type.startsWith("BOOL"); const dateValue = (isDate && value && ((typeof value === "string" && !value.includes("T") && new Date(value + "T00:00:00")) || new Date(value))) || undefined; const auditValue = (isAudit && new Date()) || undefined; const numberValue = (isNumber && +value) || undefined; const stringValue = (isString && value) || undefined; const booleanValue = isBoolean && value; const defaultValue = field.default; values[key] = dateValue || auditValue || stringValue || numberValue || booleanValue || defaultValue || uuidField === key && uuidValue || null; }); const conn = getConnection(); const created = await conn.execute(sql, values); await conn.commit(); return created; }; // /** // * @param {DBModel.TranspiledSchema} model // * @param {Object} data // * @param {Object} where // * @returns // */ const update = async (model, data, where) => { delete data._where; //PQZ-TODO OJO con esto... no me convence porque no es deterministico use(model.database, model.driver); DBValidator.checkRequiredModel(model); DBValidator.checkRequiredData(model, data); DBValidator.checkUpdateCondition(model, where); DBValidator.checkUndeclaredFields(model, data); DBValidator.checkRequiredUpdateFields(model, data); const setting = Object.entries(data) .map((entry) => `${model.fields[entry[0]].column} = :${entry[0]}`) .join(", "); const condition = Object.entries(where) .map((entry) => `${model.fields[entry[0]].column} = :w_${entry[0]}`) .join(" and "); const sql = `UPDATE ${model.table} SET ${setting} WHERE ${condition}`; const values = {}; Object.keys(data).forEach( (key) => (values[key] = { date: new Date(data[key]) }[model.fields[key].type.split("(")[0]] ?? data[key] ?? model.fields[key]?.default) ); Object.keys(where).forEach( (key) => (values[`w_${key}`] = { date: new Date(where[key]) }[model.fields[key].type.split("(")[0]] ?? where[key] ?? model.fields[key]?.default) ); const conn = getConnection(); const updated = await conn.execute(sql, values); await conn.commit(); return updated; }; // /** // * @param {DBModel.TranspiledSchema} model // * @param {object} where // * @returns // */ const remove = async (model, where) => { use(model.database, model.driver); DBValidator.checkRequiredModel(model); DBValidator.checkDeleteCondition(model, where); const condition = Object.entries(where) .map((entry) => `${model.fields[entry[0]].column} = :${entry[0]}`) .join(" and "); const sql = `DELETE FROM ${model.table} WHERE ${condition}`; const values = {}; Object.keys(where).forEach( (key) => (values[key] = { date: new Date(where[key]) }[model.fields[key].type.split("(")[0]] ?? where[key] ?? model.fields[key]?.default) ); const conn = getConnection(); const deleted = await conn.execute(sql, values); await conn.commit(); return deleted; }; const query = async ({ sql = "", bind = {}, sort = {}, initial = 1, limit = 0, }) => { !sql.trim()[0] && raise("Falta especificar la sentencia SQL"); !sql.trimStart().toLowerCase().startsWith("select ") && !sql.trimStart().toLowerCase().startsWith("select\n") && raise("Las consultas SQL deben comenzar con 'SELECT'"); initial < 1 && raise("Registro inicial debe ser mayor a cero"); limit < 0 && raise("Límite debe ser mayor o igual a cero"); const conn = getConnection(); return conn.query({ sql, bind, sort, initial, limit, }); }; module.exports = { connect, use, declare, query };