@athenna/database
Version:
The Athenna database handler for SQL/NoSQL.
543 lines (542 loc) • 16.9 kB
JavaScript
/**
* @athenna/database
*
* (c) João Lenon <lenon@athenna.io>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
import { Is, Json, String, Options, Collection } from '@athenna/common';
import { Database } from '#src/facades/Database';
import { faker } from '@faker-js/faker';
import { ModelSchema } from '#src/models/schemas/ModelSchema';
import { ORIGINAL_SYMBOL } from '#src/constants/OriginalSymbol';
import { ModelFactory } from '#src/models/factories/ModelFactory';
import { ModelGenerator } from '#src/models/factories/ModelGenerator';
import { ModelQueryBuilder } from '#src/models/builders/ModelQueryBuilder';
export class BaseModel {
/**
* Set if the `attributes` method should be called or not.
*/
static get isToSetAttributes() {
return Config.get(`database.connections.${this.connection()}.validations.isToSetAttributes`, true);
}
/**
* Set if the option annotation `isUnique`
* should be verified or not.
*/
static get isToValidateUnique() {
return Config.get(`database.connections.${this.connection()}.validations.isToValidateUnique`, true);
}
/**
* Set if the option annotation `isNullable`
* should be verified or not.
*/
static get isToValidateNullable() {
return Config.get(`database.connections.${this.connection()}.validations.isToValidateNullable`, true);
}
/**
* The faker instance to create fake data in
* definition instance.
*/
static get faker() {
return faker;
}
/**
* Set the connection name that model will use
* to access database.
*/
static connection() {
return Config.get('database.default');
}
/**
* Set if model should automatically be sync with
* database when running DatabaseProvider.
*
* @default true
*/
static sync() {
const connection = this.connection();
const driver = Config.get(`database.connections.${connection}.driver`);
if (driver === 'mongo') {
return true;
}
return false;
}
/**
* Set the table name of this model instance.
*/
static table() {
return String.pluralize(String.toSnakeCase(this.name).toLowerCase());
}
/**
* Set the default values that should be set when creating or
* updating the model.
*/
static attributes() {
return {};
}
/**
* Set the definition data that will be used when fabricating
* instances of your model using factories.
*/
static async definition() {
return {};
}
/**
* Create a new ModelSchema instance from your model.
*/
static schema() {
return new ModelSchema(this);
}
/**
* Create a new ModelFactory instance from your model.
*/
static factory() {
return new ModelFactory(this);
}
/**
* Enable/disable setting the default attributes properties
* when creating/updating models.
*/
static setAttributes(value) {
Config.set(`database.connections.${this.connection()}.validations.isToSetAttributes`, value);
return this;
}
/**
* Enable/disable the `isUnique` property validation of
* models columns.
*/
static uniqueValidation(value) {
Config.set(`database.connections.${this.connection()}.validations.isToValidateUnique`, value);
return this;
}
/**
* Enable/disable the `isNullable` property validation of
* models columns.
*/
static nullableValidation(value) {
Config.set(`database.connections.${this.connection()}.validations.isToValidateNullable`, value);
return this;
}
/**
* Create a query builder for the model.
*/
static query() {
const driver = Database.connection(this.connection()).driver;
return new ModelQueryBuilder(this, driver)
.setAttributes(this.isToSetAttributes)
.uniqueValidation(this.isToValidateUnique)
.nullableValidation(this.isToValidateNullable);
}
/**
* Remove all data inside model table
* and restart the identity of the table.
*/
static async truncate() {
await Database.connection(this.connection()).truncate(this.table());
}
static async pluck(key, where) {
const query = this.query();
if (where) {
query.where(where);
}
return query.pluck(key);
}
static async pluckMany(key, where) {
const query = this.query();
if (where) {
query.where(where);
}
return query.pluckMany(key);
}
/**
* Find a value in database.
*/
static async find(where) {
const query = this.query();
if (where) {
query.where(where);
}
return query.find();
}
/**
* Find a value in database.
*/
static async exists(where) {
const query = this.query();
if (where) {
query.where(where);
}
return query.exists();
}
/**
* Find a value in database or throw exception if undefined.
*/
static async findOrFail(where) {
const query = this.query();
if (where) {
query.where(where);
}
return query.findOrFail();
}
/**
* Find a value in database or create a new one if it doesn't exist.
*/
static async findOrCreate(where, data) {
const query = this.query();
if (where) {
query.where(where);
}
return query.findOrCreate(data);
}
/**
* Return a single data or, if no results are found,
* execute the given closure.
*/
static async findOr(where, closure) {
const query = this.query();
if (where) {
query.where(where);
}
return query.findOr(closure);
}
/**
* Find many values in database.
*/
static async findMany(where) {
const query = this.query();
if (where) {
query.where(where);
}
return query.findMany();
}
/**
* Find many values in database and return paginated.
*/
static async paginate(options, where) {
const query = this.query();
if (where) {
query.where(where);
}
return query.paginate(options);
}
/**
* Find many values in database and return
* as a collection instance.
*/
static async collection(where) {
const query = this.query();
if (where) {
query.where(where);
}
return query.collection();
}
/**
* Create a value in database.
*/
static async create(data = {}, cleanPersist = true) {
return this.query().create(data, cleanPersist);
}
/**
* Create many values in database.
*/
static async createMany(data, cleanPersist = true) {
return this.query().createMany(data, cleanPersist);
}
/**
* Create or update a value in database.
*/
static async createOrUpdate(where, data, cleanPersist = true) {
const query = this.query();
if (where) {
query.where(where);
}
return query.createOrUpdate(data, cleanPersist);
}
/**
* Update a value in database.
*/
static async update(where, data, cleanPersist = true) {
const query = this.query();
if (where) {
query.where(where);
}
return query.update(data, cleanPersist);
}
/**
* Restore a soft deleted value from database.
*/
static async restore(where, data) {
const query = this.query();
if (where) {
query.where(where);
}
return query.restore(data);
}
/**
* Delete or soft delete a value in database.
*/
static async delete(where, force = false) {
const query = this.query();
if (where) {
query.where(where);
}
return query.delete(force);
}
/**
* Set the original model values by deep copying
* the model state.
*/
setOriginal() {
this[ORIGINAL_SYMBOL] = {};
const copied = Json.copy(this);
Object.keys(copied).forEach(key => {
const value = this[key];
if (Is.Array(value) && value[0] && ORIGINAL_SYMBOL in value[0]) {
return;
}
if (Is.Object(value) && value && ORIGINAL_SYMBOL in value) {
return;
}
this[ORIGINAL_SYMBOL][key] = copied[key];
});
return this;
}
/**
* Return a Json object from the actual subclass instance.
*/
toJSON(options) {
options = Options.create(options, {
withHidden: false
});
const _Model = this.constructor;
const json = {};
const relations = _Model.schema().getRelationProperties();
/**
* Execute the toJSON of relations.
*/
Object.keys(this).forEach(key => {
if (relations.includes(key)) {
if (Is.Array(this[key])) {
json[key] = this[key].map(d => (d.toJSON ? d.toJSON() : d));
return;
}
json[key] = this[key]?.toJSON ? this[key].toJSON() : this[key];
return;
}
if (!options.withHidden &&
_Model.schema().getColumnByProperty(key)?.isHidden) {
return;
}
json[key] = this[key];
});
return json;
}
/**
* Eager load a model relation from model instance.
*/
async load(relation, closure) {
const Model = this.constructor;
const schema = Model.schema();
const generator = new ModelGenerator(Model, schema);
await generator.includeRelation(this, schema.includeRelation(relation, closure));
if (relation.includes('.')) {
return Json.get(this, relation);
}
return this[relation];
}
/**
* Eager load a model relation without mutating
* the current model instance.
*/
async loadOnly(relation, closure) {
const Model = this.constructor;
const schema = Model.schema();
const generator = new ModelGenerator(Model, schema);
const copy = new Model();
const relations = schema.getRelationProperties();
Object.keys(this).forEach(key => {
if (relations.includes(key)) {
return;
}
copy[key] = Json.copy(this[key]);
});
await generator.includeRelation(copy, schema.includeRelation(relation, closure));
if (relation.includes('.')) {
return Json.get(copy, relation);
}
return copy[relation];
}
/**
* Validate if model is persisted in database
* or if it's a fresh instance.
*/
isPersisted() {
return !!this[ORIGINAL_SYMBOL];
}
/**
* Get values only that are different from
* the original symbol to avoid updating
* data that was not changed.
*/
dirty() {
if (!this.isPersisted()) {
return this;
}
const dirty = {};
Object.keys(this).forEach(key => {
const orig = this[ORIGINAL_SYMBOL][key];
const curr = this[key];
if (Json.isEqual(orig, curr)) {
return;
}
if (Is.Object(curr) || Is.Array(curr)) {
dirty[key] = Json.copy(curr);
return;
}
dirty[key] = Json.diff(orig, curr);
});
return dirty;
}
/**
* Validate if model has been changed from
* it initial state when it was retrieved from
* database.
*/
isDirty() {
return Object.keys(this.dirty()).length > 0;
}
/**
* Save the changes done in the model in database.
*/
async save(cleanPersist = true) {
const Model = this.constructor;
const schema = Model.schema();
const primaryKey = schema.getMainPrimaryKeyProperty();
const date = new Date();
const createdAt = schema.getCreatedAtColumn();
const updatedAt = schema.getUpdatedAtColumn();
const deletedAt = schema.getDeletedAtColumn();
const attributes = Model.isToSetAttributes ? Model.attributes() : {};
Object.keys(attributes).forEach(key => {
if (this[key]) {
return;
}
this[key] = attributes[key];
});
if (createdAt && this[createdAt.property] === undefined) {
this[createdAt.property] = date;
}
if (updatedAt && this[updatedAt.property] === undefined) {
this[updatedAt.property] = date;
}
if (deletedAt && this[deletedAt.property] === undefined) {
this[deletedAt.property] = null;
}
const data = this.dirty();
if (!this.isPersisted()) {
const created = await Model.create(data, cleanPersist);
Object.keys(created).forEach(key => (this[key] = created[key]));
return this.setOriginal();
}
/**
* Means data is not dirty because there are any
* value that is different from original symbol.
*/
if (!Object.keys(data).length) {
return this;
}
const where = { [primaryKey]: this[primaryKey] };
const updated = await Model.update(where, data, cleanPersist);
Object.keys(updated).forEach(key => (this[key] = updated[key]));
return this.setOriginal();
}
/**
* Create a new instance of the model from retrieving
* again the data from database. The existing
* model instance WILL NOT BE affected.
*/
async fresh() {
const Model = this.constructor;
const primaryKey = Model.schema().getMainPrimaryKeyProperty();
return Model.query()
.where(primaryKey, this[primaryKey])
.withTrashed()
.find();
}
/**
* Refresh the model instance data retrieving
* model data using the main primary key. The
* existing model instance WILL BE affected.
*/
async refresh() {
const Model = this.constructor;
const schema = Model.schema();
const relations = schema.getRelationProperties();
const primaryKey = schema.getMainPrimaryKeyProperty();
const query = Model.query()
.where(primaryKey, this[primaryKey])
.withTrashed();
Object.keys(this).forEach(key => {
if (!relations.includes(key)) {
return;
}
query.with(key);
});
const data = await query.find();
Object.keys(data).forEach(key => (this[key] = data[key]));
}
/**
* Verify if model is soft deleted.
*/
isTrashed() {
const Model = this.constructor;
const deletedAt = Model.schema().getDeletedAtColumn();
return !!this[deletedAt.property];
}
/**
* Delete or soft delete your model from database.
*/
async delete(force = false) {
const Model = this.constructor;
const primaryKey = Model.schema().getMainPrimaryKeyProperty();
await Model.query().where(primaryKey, this[primaryKey]).delete(force);
}
/**
* Restore a soft deleted model from database.
*/
async restore() {
const Model = this.constructor;
const schema = Model.schema();
const primaryKey = schema.getMainPrimaryKeyProperty();
const date = new Date();
const createdAt = schema.getCreatedAtColumn();
const updatedAt = schema.getUpdatedAtColumn();
const deletedAt = schema.getDeletedAtColumn();
const attributes = Model.isToSetAttributes ? Model.attributes() : {};
Object.keys(attributes).forEach(key => {
if (this[key]) {
return;
}
this[key] = attributes[key];
});
if (createdAt && this[createdAt.property] === undefined) {
this[createdAt.property] = date;
}
if (updatedAt && this[updatedAt.property] === undefined) {
this[updatedAt.property] = date;
}
/**
* Forcing the deleted at column to be null to restore the model.
*/
if (deletedAt) {
this[deletedAt.property] = null;
}
const data = this.dirty();
const where = { [primaryKey]: this[primaryKey] };
const restored = await Model.restore(where, data);
Object.keys(restored).forEach(key => (this[key] = restored[key]));
return this.setOriginal();
}
}