432 lines
14 KiB
TypeScript
432 lines
14 KiB
TypeScript
import { inspect } from 'node:util';
|
|
import { MongoClient, MongoClientOptions, Db, Document, WithId, ObjectId, Filter, IndexSpecification, CreateIndexesOptions, FindOptions } from 'mongodb';
|
|
import { IServer, ILogger, LoggerClientOptions } from './interfaces/index.js';
|
|
|
|
type Credentials = {
|
|
URI?: string,
|
|
user?: string,
|
|
password?: string,
|
|
host?: string,
|
|
port?: number,
|
|
database: string,
|
|
authDb?: string
|
|
}
|
|
|
|
type MongoQuery = {
|
|
_id?: unknown,
|
|
[key: string]: unknown
|
|
}
|
|
|
|
export type MongoOptions = {
|
|
credentials: Credentials,
|
|
loggerOptions?: LoggerClientOptions,
|
|
client?: MongoClientOptions,
|
|
load?: boolean
|
|
}
|
|
|
|
type StringIndexable = {[key: string]: boolean | string | number | Document | object}
|
|
|
|
const objIsSubset = (superObj: StringIndexable, subObj: StringIndexable): boolean =>
|
|
{
|
|
return Object.keys(subObj).every(ele =>
|
|
{
|
|
if (typeof subObj[ele] === 'object' && typeof superObj[ele] === 'object')
|
|
{
|
|
return objIsSubset(superObj[ele] as StringIndexable, subObj[ele] as StringIndexable);
|
|
}
|
|
return subObj[ele] === superObj[ele];
|
|
});
|
|
};
|
|
|
|
/**
|
|
* A dedicated class to locally wrap the mongodb API wrapper
|
|
*
|
|
* @class MongoDB
|
|
*/
|
|
class MongoDB
|
|
{
|
|
|
|
#_database: string;
|
|
#config: MongoOptions;
|
|
#logger: ILogger;
|
|
#URI: string;
|
|
|
|
#db: Db | null;
|
|
#_client: MongoClient;
|
|
|
|
constructor (server: IServer, config: MongoOptions)
|
|
{
|
|
|
|
if (!server)
|
|
throw new Error('Missing reference to server!');
|
|
if (!config)
|
|
throw new Error('No config options provided!');
|
|
|
|
const { user, password, host, port, database, URI, authDb } = config.credentials;
|
|
if ((!host?.length || !port || !database?.length) && !URI)
|
|
throw new Error('Must provide host, port, and database OR URI parameters!');
|
|
|
|
this.#config = config;
|
|
this.#db = null; // DB connection
|
|
this.#_database = database; // Which database to connect to
|
|
|
|
this.#logger = server.createLogger(this, config.loggerOptions);
|
|
|
|
if (URI)
|
|
{
|
|
this.#URI = URI;
|
|
}
|
|
else
|
|
{
|
|
let AUTH_DB = authDb;
|
|
const auth = user ? `${user}:${password}@` : '';
|
|
if (!AUTH_DB && auth)
|
|
{
|
|
this.#logger.warn('No explicit auth db provided with MONGO_AUTH_DB, will attempt to use MONGO_DB for auth source');
|
|
AUTH_DB = authDb;
|
|
}
|
|
else if (!auth)
|
|
{
|
|
this.#logger.warn('No auth provided, proceeding without');
|
|
}
|
|
|
|
this.#URI = `mongodb://${auth}${host}:${port}/${AUTH_DB || ''}?readPreference=secondaryPreferred`;
|
|
}
|
|
|
|
this.#_client = new MongoClient(this.#URI, this.#config.client);
|
|
|
|
// TODO figure out reconnecting to DB when connection fails
|
|
this.#_client.on('error', (error) => this.#logger.error(`MongoDB error:\n${error.stack}`))
|
|
.on('timeout', () => this.#logger.warn('MongoDB timed out'))
|
|
.on('close', () => this.#logger.info('MongoDB client disconnected'))
|
|
.on('open', () => this.#logger.info('MongoDB client connected'));
|
|
|
|
}
|
|
|
|
get database ()
|
|
{
|
|
return this.#_database;
|
|
}
|
|
|
|
get client ()
|
|
{
|
|
return this.#_client;
|
|
}
|
|
|
|
/**
|
|
* Initialises the connection to Mongo
|
|
*
|
|
* @memberof MongoDB
|
|
*/
|
|
async init ()
|
|
{
|
|
|
|
if (!this.#config.load)
|
|
return this.#logger.info('Not loading MongoDB');
|
|
|
|
if (this.#db)
|
|
throw new Error('Database already connected');
|
|
|
|
this.#logger.status(`Initializing database connection to ${this.#_client.options.hosts}`);
|
|
|
|
await this.#_client.connect();
|
|
this.#logger.debug('Connected, selecting DB');
|
|
this.#db = await this.#_client.db(this.#_database);
|
|
|
|
this.#logger.status('MongoDB ready');
|
|
|
|
return this;
|
|
|
|
}
|
|
|
|
async close ()
|
|
{
|
|
if (!this.#db)
|
|
return;
|
|
|
|
this.#logger.status('Closing database connection');
|
|
await this.#_client.close();
|
|
this.#db = null;
|
|
this.#logger.status('Database closed');
|
|
}
|
|
|
|
get mongoClient ()
|
|
{
|
|
return this.#_client;
|
|
}
|
|
|
|
/**
|
|
* Find and return the first match
|
|
*
|
|
* @param {String} db The collection in which the data is to be updated
|
|
* @param {Object} query The filter that is used to find the data
|
|
* @returns {Array} An array containing the corresponding objects for the query
|
|
* @memberof Database
|
|
*/
|
|
async find<T extends Document> (db: string, query: MongoQuery, options?: FindOptions<T>): Promise<WithId<T>[]>
|
|
{
|
|
|
|
if (!this.#db)
|
|
throw new Error('MongoDB not connected');
|
|
|
|
if (typeof db !== 'string')
|
|
throw new TypeError('Expecting collection name for the first argument');
|
|
|
|
if (typeof query._id === 'string')
|
|
query._id = new ObjectId(query._id);
|
|
|
|
this.#logger.debug(`Incoming find query for ${db} with parameters ${inspect(query)}`);
|
|
|
|
const cursor = this.#db.collection<T>(db).find(query as Filter<T>, options);
|
|
return cursor.toArray();
|
|
|
|
}
|
|
|
|
/**
|
|
* Find and return the first match
|
|
*
|
|
* @param {String} db The collection in which the data is to be updated
|
|
* @param {Object} query The filter that is used to find the data
|
|
* @returns {Object} An object containing the queried data
|
|
* @memberof Database
|
|
*/
|
|
async findOne<T extends Document> (db: string, query: MongoQuery, options: FindOptions<T> = {}): Promise<WithId<T> | null>
|
|
{
|
|
|
|
if (!this.#db)
|
|
throw new Error('MongoDB not connected');
|
|
if (typeof db !== 'string')
|
|
throw new TypeError('Expecting collection name for the first argument');
|
|
|
|
if (typeof query._id === 'string' && query._id.length === 12)
|
|
query._id = new ObjectId(query._id);
|
|
|
|
this.#logger.debug(`Incoming findOne query for ${db} with parameters ${inspect(query)}`);
|
|
const result = await this.#db.collection<T>(db).findOne(query as Filter<T>, options);
|
|
return result;
|
|
|
|
}
|
|
|
|
/**
|
|
* Update any and all filter matches.
|
|
*
|
|
* @param {String} db The collection in which the data is to be updated
|
|
* @param {Object} filter The filter that is used to find the data
|
|
* @param {Object} data The updated data
|
|
* @returns {WriteResult} Object containing the followint counts: Matched, Upserted, Modified
|
|
* @memberof Database
|
|
*/
|
|
async updateMany<T extends Document> (db: string, filter: MongoQuery, data: T, upsert = false)
|
|
{
|
|
|
|
if (!this.#db)
|
|
throw new Error('MongoDB not connected');
|
|
if (typeof db !== 'string')
|
|
throw new TypeError('Expecting collection name for the first argument');
|
|
if (!filter)
|
|
throw new Error('Cannot run update many without a filter, if you mean to update every single document, pass an empty object');
|
|
if (typeof filter._id === 'string')
|
|
filter._id = new ObjectId(filter._id);
|
|
|
|
this.#logger.debug(`Incoming update query for '${db}' with parameters\n${inspect(filter)}\nand data\n${inspect(data)}`);
|
|
const result = await this.#db.collection<T>(db).updateMany(filter as Filter<T>, { $set: data }, { upsert });
|
|
return result;
|
|
|
|
}
|
|
|
|
/**
|
|
* Update the first filter match.
|
|
*
|
|
* @param {String} db The collection in which the data is to be updated
|
|
* @param {Object} filter The filter that is used to find the data
|
|
* @param {Object} data The updated data
|
|
* @returns {WriteResult} Object containing the followint counts: Matched, Upserted, Modified
|
|
* @memberof Database
|
|
*/
|
|
async updateOne (db: string, filter: MongoQuery, data: Document, upsert = false)
|
|
{
|
|
|
|
if (!this.#db)
|
|
throw new Error('MongoDB not connected');
|
|
if (typeof db !== 'string')
|
|
throw new TypeError('Expecting collection name for the first argument');
|
|
if (typeof filter._id === 'string')
|
|
filter._id = new ObjectId(filter._id);
|
|
|
|
this.#logger.debug(`Incoming updateOne query for ${db} with parameters ${inspect(filter)}`);
|
|
const result = await this.#db.collection(db).updateOne(filter as Filter<Document>, { $set: data }, { upsert });
|
|
return result;
|
|
|
|
}
|
|
|
|
/**
|
|
* Insert document.
|
|
*
|
|
* @param {String} db The collection in which the data is to be updated
|
|
* @param {Object} filter The filter that is used to find the data
|
|
* @param {Object} data The updated data
|
|
* @returns {WriteResult} Object containing the followint counts: Matched, Upserted, Modified
|
|
* @memberof Database
|
|
*/
|
|
async insertOne (db: string, data: Document)
|
|
{
|
|
|
|
if (!this.#db)
|
|
throw new Error('MongoDB not connected');
|
|
if (typeof db !== 'string')
|
|
throw new TypeError('Expecting collection name for the first argument');
|
|
if (typeof data._id === 'string')
|
|
data._id = new ObjectId(data._id);
|
|
|
|
this.#logger.debug(`Incoming insertOne query for ${db} with parameters ${inspect(data)}`);
|
|
const result = await this.#db.collection(db).insertOne(data);
|
|
return result;
|
|
|
|
}
|
|
|
|
async deleteOne (db: string, filter: Document)
|
|
{
|
|
|
|
if (!this.#db)
|
|
throw new Error('MongoDB not connected');
|
|
if (typeof db !== 'string')
|
|
throw new TypeError('Expecting collection name for the first argument');
|
|
if (typeof filter._id === 'string')
|
|
filter._id = new ObjectId(filter._id);
|
|
|
|
this.#logger.debug(`Incoming deleteOne query for ${db} with parameters ${inspect(filter)}`);
|
|
const result = await this.#db.collection(db).deleteOne(filter);
|
|
return result;
|
|
|
|
}
|
|
|
|
/**
|
|
* Push data to an array
|
|
*
|
|
* @param {string} db The collection to query
|
|
* @param {object} filter The filter to find the document to update
|
|
* @param {object} data The data to be pushed
|
|
* @param {boolean} [upsert=false]
|
|
* @returns
|
|
* @memberof Database
|
|
*/
|
|
async push (db: string, filter: Document, data: object, upsert = false)
|
|
{
|
|
|
|
if (!this.#db)
|
|
throw new Error('MongoDB not connected');
|
|
if (typeof db !== 'string')
|
|
throw new TypeError('Expecting collection name for the first argument');
|
|
if (typeof filter._id === 'string')
|
|
filter._id = new ObjectId(filter._id);
|
|
|
|
this.#logger.debug(`Incoming push query for ${db}, with upsert ${upsert} and with parameters ${inspect(filter)} and data ${inspect(data)}`);
|
|
const result = await this.#db.collection(db).updateOne(filter, { $push: data }, { upsert });
|
|
return result;
|
|
|
|
}
|
|
|
|
/**
|
|
* Find a random element from a database
|
|
*
|
|
* @param {string} db The collection to query
|
|
* @param {object} [filter={}] The filtering object to narrow down the sample pool
|
|
* @param {number} [amount=1] Amount of items to return
|
|
* @returns {object}
|
|
* @memberof Database
|
|
*/
|
|
random<T extends Document> (db: string, filter: Document = {}, amount = 1)
|
|
{
|
|
|
|
if (!this.#db)
|
|
throw new Error('MongoDB not connected');
|
|
if (typeof db !== 'string')
|
|
throw new TypeError('Expecting collection name for the first argument');
|
|
if (typeof filter._id === 'string')
|
|
filter._id = new ObjectId(filter._id);
|
|
|
|
this.#logger.debug(`Incoming random query for ${db} with parameters ${inspect(filter)} and amount ${amount}`);
|
|
|
|
if (amount > 100)
|
|
amount = 100;
|
|
|
|
const cursor = this.#db.collection(db).aggregate<T>([{ $match: filter }, { $sample: { size: amount } }]);
|
|
return cursor.toArray();
|
|
|
|
}
|
|
|
|
stats (options = {})
|
|
{
|
|
if (!this.#db)
|
|
throw new Error('MongoDB not connected');
|
|
const result = this.#db.stats(options);
|
|
return result;
|
|
}
|
|
|
|
collection<T extends Document> (coll: string)
|
|
{
|
|
if (!this.#db)
|
|
throw new Error('MongoDB not connected');
|
|
return this.#db.collection<T>(coll);
|
|
}
|
|
|
|
count (coll: string, query: Document)
|
|
{
|
|
if (!this.#db)
|
|
throw new Error('MongoDB not connected');
|
|
return this.#db.collection(coll).countDocuments(query);
|
|
}
|
|
|
|
async ensureIndex (collection: string, index: IndexSpecification, options?: CreateIndexesOptions & StringIndexable): Promise<void>
|
|
{
|
|
if (!this.#db)
|
|
return Promise.reject(new Error('MongoDB not connected'));
|
|
if (!(index instanceof Array))
|
|
index = [ index ];
|
|
|
|
const indexes = await this.#db.collection(collection).indexes();
|
|
const existing = indexes.find(idx => idx.name.startsWith(index));
|
|
if (existing && this.#indexesEqual(existing, options))
|
|
return;
|
|
|
|
if (existing)
|
|
await this.#db.collection(collection).dropIndex(existing.name);
|
|
|
|
await this.#db.collection(collection).createIndex(index, options);
|
|
}
|
|
|
|
async ensureIndices (collection: string, indices: IndexSpecification[], options?: CreateIndexesOptions & StringIndexable): Promise<void>
|
|
{
|
|
if (!this.#db)
|
|
return Promise.reject(new Error('MongoDB not connected'));
|
|
for (const index of indices)
|
|
await this.ensureIndex(collection, index, options);
|
|
}
|
|
|
|
#indexesEqual (existing: Document, options?: CreateIndexesOptions & StringIndexable)
|
|
{
|
|
// 3 keys on the existing means that only the name was given
|
|
if (!options && Object.keys(existing).length === 3)
|
|
return true;
|
|
else if (!options)
|
|
return false;
|
|
|
|
const keys = Object.keys(options);
|
|
for (const key of keys)
|
|
{
|
|
if (typeof options[key] !== typeof existing[key])
|
|
return false;
|
|
|
|
if (typeof options[key] === 'object' && !objIsSubset(existing, options))
|
|
return false;
|
|
else if (options[key] !== existing[key])
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
export { MongoDB }; |