zs-core-db
Version:
Servicios generales de acceso a bases de datos
475 lines (398 loc) • 13.9 kB
JavaScript
// @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 };