"use strict" const mysql = require("mysql"); const Select = require("./lib/Select"); const Insert = require("./lib/Insert"); const Delete = require("./lib/Delete"); const Update = require("./lib/Update"); const { CreateTable, Structure, AlterTable } = require("./lib/Tables"); const { throwTypeError } = require("./lib/Errors"); /** * @typedef {Object} InstanceOptions * @property {String} [charset] - Charset to use * @property {String} [defaultDatabase] - The default database * @property {Boolean} [multipleStatements] - Whether multiple statements should be allowed in a single query * @property {Boolean} [insecureAuth] - Whether insecure authentication methods should be allowed * @property {String} [customIdentifier] - Sets a custom identifier for this instance * @property {Boolean} [isDefault] - Whether this instance is returned by default via 'getInstance' * @property {object} [ssl] - Whether to use ssl */ /** * Exported as instance */ class awSQL { #instances = {}; // Holds instances. Key is identifier/name. #default; // Holds the identifier of the default instance (the one used if identifier is omitted) /** * Creates a new instance (database connection) * @param {String} hostname - Hostname where the database is located * @param {String} username - Username to login with * @param {String} password * @param {InstanceOptions} [options] * @returns {Instance} */ createInstance(hostname="localhost", username, password, options = { charset:"utf8mb4", defaultDatabase: false, multipleStatements: false, insecureAuth: false, customIdentifier: false, isDefault: false, ssl: false, }){ if (!password) throw new Error(`Can't create instance: No password given`); if (!username) throw new Error(`Can't create instance: No username given`); const identifier = options.customIdentifier||`${username}@${hostname}`; // Set identifier to given identifier or [username]@[hostname] // If an instance with that identifier exists, throw error if (this.#instances[identifier]) throw new Error(`Can't create new instance with identifier "${identifier}": An instance with the same name already exists`); const instance = new Instance(hostname, username, password, options.charset, options.defaultDatabase, options.multipleStatements, options.insecureAuth, options.ssl); this.#instances[identifier] = instance; // Store instance if (options.createAsDefault) this.#default = identifier; // If this instance was created with default option set it as the default instance return instance; } /** * Returns an instance matching the given identifier * * Returns default (or first) instance if no identifier is given * @param {String} [identifier] - Identifier of the instance to get * @returns {Instance} */ getInstance(identifier) { if (Object.keys(this.#instances).length===0) return undefined; // If no instance is found at all return -> Safety return // If no identifier is set return default or first instance if (!identifier) { return this.#default? this.#instances[this.#default] // If default exists get that : this.#instances[Object.keys(this.#instances)[0]]; // Otherwise return first instance } return this.#instances[identifier]; // If identifier given return that instance } /** * Returns a list of the identifiers of all instances * @returns {Array} */ listInstances(){ return Object.keys(this.#instances); } /** * Deletes an instance (and closes any open connection) * @param {String} identifier - Identifier of the instance to delete * @returns {true} */ deleteInstance(identifier){ if (!identifier) throw new Error("Can't delete Instance: No identifier set"); if (typeof identifier !== "string") throwTypeError("string", identifier); if (!this.#instances[identifier]) throw new Error(`Can't delete Instance '${identifier}': No Instance`); this.#instances[identifier].destroy(); // Memory: Close connection if (this.#default === identifier) this.#default = undefined; // If this instance was default, clear it from default delete this.#instances[identifier]; return true; } } /** * An awSQL-Instance */ class Instance { #user; #password; #host; #insecureAuth; #multipleStatements; #charset; #connection; #ssl; #selectedDatabase; constructor(hostname="localhost", username, password, charset="utf8mb4", defaultDatabase=false, multipleStatements=false, insecureAuth=false, ssl=false){ this.#host = hostname; this.#user = username; this.#password = password; this.#charset = charset; this.#multipleStatements = multipleStatements; this.#insecureAuth = insecureAuth; this.#selectedDatabase = defaultDatabase||username; this.#ssl = ssl; } /** * Connects the instance * @returns {undefined} */ connect(){ return new Promise((resolve, reject) => { const connection = mysql.createConnection({ // Create a new mysql-connection user: this.#user, password: this.#password, host: this.#host, insecureAuth: this.#insecureAuth, multipleStatements: this.#multipleStatements, charset: this.#charset, ssl: this.#ssl }); this.#connection = connection; // Store the connection connection.connect((err) =>{ if (err) throw err; resolve(`Connected to ${this.#host} with user ${this.#user}`); }); connection.on("error", (err) => { if (!err.fatal){ return; } this.destroy(); // Memory: Destroy if connection errored this.connect(); // And try to reconnect }) }) } /** * Destroys the connection * @returns {true} */ destroy(){ if (this.#connection) this.#connection.end(); this.#connection = undefined; return true; } /** * Performs a raw query * @param {String} queryString The sql query string to perform. * @param {Array} values - An array holding all replacable ?-values from left to right. * @returns {Any} - The individual result of your query */ queryRaw(queryString, values=[]){ if (!queryString) throw new Error(`queryString must not be empty`); if (typeof queryString !== "string") throwTypeError("string", queryString); if (!Array.isArray(values)) throwTypeError("array", values); return new Promise((resolve) => { if (!this.#connection) throw new Error("Querying failed: No connection"); this.#connection.query(queryString, values, (err, result) => { if (err) throw err; resolve(result); }) }) } /** * Returns a list of database names the user has access to * @param {Boolean} excludeSchema - Whether to exclude the default database 'information_schema' * @returns {Array} */ async getDatabases (excludeSchema=false){ if (typeof excludeSchema !== "boolean") throwTypeError("boolean", excludeSchema); let dbs = await this.queryRaw("SHOW DATABASES;"); if (excludeSchema) dbs = dbs.filter((db)=>db.Database!=="information_schema") return dbs.map(db => db.Database); } /** * Selects a default database for future queries * @param {String} name - Name of the database * @returns {this} */ selectDatabase(name){ if (!name) throw new Error("name must not be empty"); if (typeof name !== "string") throwTypeError("string", name); this.#selectedDatabase = name; return this; } /** * Returns a list of tables for the selected database * - 'multipleStatements' must be active for this to work * @param {String} [database] - Database to select. Can be empty as long as a default database was set with 'selectDatabase' * @returns {Array} */ async getTables(database){ if (!this.#multipleStatements) throw new Error("getTables: multipleStatements must be set to 'true' in instance options"); if (!this.#selectedDatabase && !database) throw new Error("getTables: No database selected"); const tables = (await this.queryRaw(`USE ${database||this.#selectedDatabase}; SHOW TABLES;`))[1]; return tables.map(table => table[`Tables_in_${database||this.#selectedDatabase}`]); } /** * Prepares a new select query * @param {String} from - Name of the table * @param {...String} [columns] - Name of columns to select. Leave empty to select all * @returns {Select} */ select(from, ...columns){ if (!from) throw new Error("Can't prepare select: No 'from' given"); if (typeof from !== "string") throwTypeError("string", from); return new Select(this, this.#selectedDatabase, from, columns); } /** * Prepares a new query to insert data * @param {String} into - Name of the table to insert into * @returns {Insert} */ insert(into){ if (!into) throw new Error("Can't prepare insert: No 'into' given"); if (typeof into !== "string") throwTypeError("string", into); return new Insert(this, this.#selectedDatabase, into); } /** * Prepares a new delete query * @param {String} from - Name of the table to delete from * @returns {Delete} */ delete(from){ if (!from) throw new Error("Can't prepare delete: No 'from' given"); if (typeof from !== "string") throwTypeError("string", from); return new Delete(this, this.#selectedDatabase, from); } /** * Prepares a new update query * @param {String} table - Name of the table to update data in * @returns {Update} */ update(table){ if (!table) throw new Error("Can't prepare update: No 'table' given"); if (typeof table !== "string") throwTypeError("string", table); return new Update(this, this.#selectedDatabase, table); } /** * Drops a whole database * - Requires admin privileges * @param {String} database - Name of the database to drop * @returns {OkPacket} */ async dropDatabase (database){ if (!database) throw new Error(`Can't drop database: No database given`); if (typeof database !== "string") throwTypeError("string", database); return await this.queryRaw(`DROP DATABASE ${database};`); } /** * Drops a whole table * @param {String} table - Name of the table to drop * @returns {OkPacket} */ async dropTable(table){ if (!table) throw new Error("Can't drop table: No table set"); if (typeof table !== "string") throwTypeError("string", table); if (!this.#selectedDatabase) throw new Error(`Can't drop table '${table}': Database not set`); return await this.queryRaw(`DROP TABLE ${this.#selectedDatabase}.${table}`); } /** * Creates a new database * - Requires admin privileges * @param {String} name - Name of the database to create * @returns {OkPacket} */ async createDatabase(name){ if (!name) throw new Error(`Can't create database: No name given`); if (typeof name !== "string") throwTypeError("string", name); return await this.queryRaw(`CREATE DATABASE ${name};`); } /** * Prepares to create a new table * @param {String} name - Name of the table to create * @returns {CreateTable} */ createTable(name){ if (!name) throw new Error("Can't create table: No name given"); if (typeof name !== "string") throwTypeError("string", name); return new CreateTable(this, this.#selectedDatabase, name); } /** * Alters a table and updates to the new given structure. * * @param {String} name * @returns {AlterTable} */ alterTable(name){ if (!name) throw new Error("Can't alter table: No name given"); if (typeof name !== "string") throwTypeError("string", name); return new AlterTable(this, this.#selectedDatabase, name); } /** * Prepares to create a new table-structure * @returns {Structure} */ createStructure(){ return new Structure(); } /** * Returns the structure object of a table * @param {String} table - Name of table to get structure of * @param {String} [database] - Name of the underlying database * @returns {Structure} */ async getStructure(table, database){ if (!table) throw new Error("Can't get structure: table not given"); if (typeof table !== "string") throwTypeError("string", table); if (!this.#selectedDatabase && !database) throw new Error(`Can't get structure of table ${table}: Database not selected`); return new Structure(await this.queryRaw(`DESCRIBE ${database||this.#selectedDatabase}.${table};`)); } /** * Checks the structure of a table * @param {String} table - Name of the table * @param {Structure} desiredStructure - Structure to check against * @param {String} [database] - Name of the database. If omitted, uses default database * @returns {CheckResult} */ async checkStructure(table, desiredStructure, database){ if (!table) throw new Error("Can't check structure: table not given"); if (typeof table !== "string") throwTypeError("string", table); if (typeof desiredStructure !== "object") throwTypeError("object", desiredStructure); if (!this.#selectedDatabase && !database) throw new Error(`Can't get structure of table ${table}: Database not selected`); const dbStruc = (await this.getStructure(table, database||this.#selectedDatabase)).get(); // Get current structure -> Array const result = { errors: [], passed: [] } for (let col of dbStruc){ // Check if current table has a field that is not defined if (!desiredStructure.find(dsStruc => dsStruc.Field === col.Field)){ result.errors.push(`${col.Field}: Existing, but not defined`); } } for (let col of desiredStructure){ const dbCol = dbStruc.find((dbCol) => col.Field === dbCol.Field); // Check if the current table has the field if (!dbCol) { result.errors.push(`${col.Field}: Missing completely`); continue; } let breakOut = false; for (let key in col){ if (col[key] !== dbCol[key]){ // If the current key has a different value result.errors.push(`${dbCol.Field}.${key}: Required ${col[key]}, got ${dbCol[key]}`); breakOut=true; } } if (!breakOut){ // If no errors happened result.passed.push(`${col.Field}`); } } return result; } /** * Returns total amount of rows of a table * @param {String} table - Table name * @returns {Number} */ async total(table){ if (!table) throw new Error("Can't get structure: table not given"); if (typeof table !== "string") throwTypeError("string", table); if (!this.#selectedDatabase) throw new Error("Can't get total: No default table set"); return await new Select(this, this.#selectedDatabase, table).count(true).execute(); } /** * Returns if connection is established * @returns {Boolean} */ isConnected(){ if (this.#connection) return true; return false; } } /** * @typedef {Object} CheckResult * @property {Array} errors - String representation of errors found * @property {Array} passed - String representation of passed checks */ /** * @typedef {Object} OkPacket * @property {Number} fieldCount - Defaults to 0 on non-select queries * @property {Number} affectedRows - The number of rows affected by the query * @property {Number} insertId - The ID of the last inserted row if the table has an auto-increment column; otherwise always 0 * @property {Number} serverStatus - A status flag representing the current state of the mysql server * @property {Number} warningCount - The number of warnings generated during query execution. * @property {String} message - An optional message providin additional information about the query result (normally empty) * @property {Boolean} protocol41 - Whether mysql protocol 4.1 or later is used * @property {Number} changedRows - The number of rows actually changed by the query */ const awSQLInstance = new awSQL(); module.exports = {awSQL: awSQLInstance, Structure}; /** * @exports awSQLInstance */