From 8217ff5a3230a5022ce3a18ddc2dd46b91e1275d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lenon?= Date: Tue, 23 Aug 2022 15:47:37 -0300 Subject: [PATCH] feat(core): transactions, models, criterias and relations --- package-lock.json | 13 +- package.json | 1 + src/Drivers/PostgresDriver.js | 167 ++++-- .../NotImplementedDefinitionException.js | 28 + .../NotImplementedSchemaException.js | 28 + src/Facades/Database.js | 15 + src/Factories/ConnectionFactory.js | 1 + src/Factories/DriverFactory.js | 6 +- src/Factories/ModelFactory.js | 132 +++++ src/Models/Column.js | 143 ++++++ src/Models/Model.js | 289 +++++++++++ src/Models/Relation.js | 151 ++++++ src/Utils/Criteria.js | 220 ++++++++ src/Utils/ModelQueryBuilder.js | 478 ++++++++++++++++++ src/index.d.ts | 63 ++- src/index.js | 93 +++- tests/Stubs/configs/database.js | 7 +- tests/Stubs/models/Product.js | 66 +++ tests/Stubs/models/User.js | 78 +++ tests/Unit/PostgresDriverTest.js | 82 ++- tests/Unit/UserModelTest.js | 216 ++++++++ 21 files changed, 2153 insertions(+), 124 deletions(-) create mode 100644 src/Exceptions/NotImplementedDefinitionException.js create mode 100644 src/Exceptions/NotImplementedSchemaException.js create mode 100644 src/Facades/Database.js create mode 100644 src/Factories/ModelFactory.js create mode 100644 src/Models/Column.js create mode 100644 src/Models/Model.js create mode 100644 src/Models/Relation.js create mode 100644 src/Utils/Criteria.js create mode 100644 src/Utils/ModelQueryBuilder.js create mode 100644 tests/Stubs/models/Product.js create mode 100644 tests/Stubs/models/User.js create mode 100644 tests/Unit/UserModelTest.js diff --git a/package-lock.json b/package-lock.json index 9f1b48b..b50c3f2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "MIT", "dependencies": { "@athenna/artisan": "1.3.6", + "@faker-js/faker": "^7.4.0", "@secjs/utils": "1.9.7", "pg": "8.7.3", "typeorm": "0.3.7", @@ -482,9 +483,9 @@ } }, "node_modules/@faker-js/faker": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-7.3.0.tgz", - "integrity": "sha512-1W0PZezq2rxlAssoWemi9gFRD8IQxvf0FPL5Km3TOmGHFG7ib0TbFBJ0yC7D/1NsxunjNTK6WjUXV8ao/mKZ5w==", + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-7.4.0.tgz", + "integrity": "sha512-xDd3Tvkt2jgkx1LkuwwxpNBy/Oe+LkZBTwkgEFTiWpVSZgQ5sc/LenbHKRHbFl0dq/KFeeq/szyyPtpJRKY0fg==", "engines": { "node": ">=14.0.0", "npm": ">=6.0.0" @@ -9319,9 +9320,9 @@ } }, "@faker-js/faker": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-7.3.0.tgz", - "integrity": "sha512-1W0PZezq2rxlAssoWemi9gFRD8IQxvf0FPL5Km3TOmGHFG7ib0TbFBJ0yC7D/1NsxunjNTK6WjUXV8ao/mKZ5w==" + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-7.4.0.tgz", + "integrity": "sha512-xDd3Tvkt2jgkx1LkuwwxpNBy/Oe+LkZBTwkgEFTiWpVSZgQ5sc/LenbHKRHbFl0dq/KFeeq/szyyPtpJRKY0fg==" }, "@humanwhocodes/config-array": { "version": "0.9.5", diff --git a/package.json b/package.json index e521da1..33ded0d 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ }, "dependencies": { "@athenna/artisan": "1.3.6", + "@faker-js/faker": "7.4.0", "@secjs/utils": "1.9.7", "pg": "8.7.3", "typeorm": "0.3.7", diff --git a/src/Drivers/PostgresDriver.js b/src/Drivers/PostgresDriver.js index 013b9ff..90acf7f 100644 --- a/src/Drivers/PostgresDriver.js +++ b/src/Drivers/PostgresDriver.js @@ -1,5 +1,14 @@ +/** + * @athenna/database + * + * (c) João Lenon + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + import { Table } from 'typeorm' -import { Is } from '@secjs/utils' +import { Exec, Is } from '@secjs/utils' import { Transaction } from '#src/index' import { DriverFactory } from '#src/Factories/DriverFactory' @@ -15,6 +24,13 @@ export class PostgresDriver { */ #isConnected = false + /** + * The TypeORM data source. + * + * @type {import('typeorm').DataSource|null} + */ + #dataSource = null + /** * The query runner responsible to handle database operations. * @@ -64,6 +80,13 @@ export class PostgresDriver { */ #select = [] + /** + * The add select queries done to this instance. + * + * @type {string[]} + */ + #addSelect = [] + /** * The skip value done to this instance. * @@ -90,14 +113,26 @@ export class PostgresDriver { * * @param {string|any} connection * @param {any} configs - * @param {import('typeorm').QueryRunner} [client] + * @param {import('typeorm').DataSource} [dataSource] * @return {Database} */ - constructor(connection, configs = {}, client = null) { + constructor(connection, configs = {}, dataSource = null) { this.#configs = configs this.#connection = connection - this.#client = client + if (dataSource) { + this.#dataSource = dataSource + this.#client = this.#dataSource.createQueryRunner() + } + } + + /** + * Return the TypeORM data source. + * + * @return {import('typeorm').DataSource|null} + */ + getDataSource() { + return this.#dataSource } /** @@ -112,14 +147,14 @@ export class PostgresDriver { return } - this.#client = ( - await DriverFactory.createConnectionByDriver( - 'postgres', - this.#connection, - this.#configs, - saveOnDriver, - ) - ).createQueryRunner() + this.#dataSource = await DriverFactory.createConnectionByDriver( + 'postgres', + this.#connection, + this.#configs, + saveOnDriver, + ) + + this.#client = this.#dataSource.createQueryRunner() this.#isConnected = true } @@ -153,16 +188,9 @@ export class PostgresDriver { } /** @type {import('typeorm').SelectQueryBuilder} */ - let query = null - - if (Is.String(this.#table)) { - query = this.#client.manager.createQueryBuilder().from(this.#table) - } else { - query = this.#client.manager.createQueryBuilder( - this.#table, - this.#table.name, - ) - } + const query = this.#client.manager + .getRepository(this.#table) + .createQueryBuilder(this.#table) if (!fullQuery) { return query @@ -170,6 +198,7 @@ export class PostgresDriver { this.#setRelationsOnQuery(query) this.#setSelectOnQuery(query) + this.#setAddSelectOnQuery(query) this.#setWhereOnQuery(query) this.#setOrderByOnQuery(query) @@ -190,15 +219,9 @@ export class PostgresDriver { * @return {Promise} */ async startTransaction() { - const client = await this.#client.connection.createQueryRunner() - - await client.startTransaction() + await this.#client.startTransaction() - const driver = new PostgresDriver(this.#connection, this.#configs, client) - - driver.buildTable(this.#table) - - return new Transaction(driver) + return new Transaction(this) } /** @@ -209,6 +232,8 @@ export class PostgresDriver { async commitTransaction() { await this.#client.commitTransaction() await this.#client.release() + + this.#client = this.#dataSource.createQueryRunner() } /** @@ -219,6 +244,8 @@ export class PostgresDriver { async rollbackTransaction() { await this.#client.rollbackTransaction() await this.#client.release() + + this.#client = this.#dataSource.createQueryRunner() } /** @@ -516,9 +543,7 @@ export class PostgresDriver { * @return {Promise} */ async find() { - const data = await this.query(true).take(1).execute() - - return data[0] + return this.query(true).getOne() } /** @@ -527,7 +552,24 @@ export class PostgresDriver { * @return {Promise} */ async findMany() { - return this.query(true).execute() + return this.query(true).getMany() + } + + /** + * Find many values in database and return as paginated response. + * + * @param [page] {boolean} + * @param [limit] {boolean} + * @param [resourceUrl] {string} + * @return {Promise} + */ + async paginate(page = 0, limit = 10, resourceUrl = '/') { + const [data, count] = await this.query(true) + .take(page) + .limit(limit) + .getManyAndCount() + + return Exec.pagination(data, count, { page, limit, resourceUrl }) } /** @@ -593,6 +635,10 @@ export class PostgresDriver { .returning(select) .execute() + if (result.raw.length === 1) { + return result.raw[0] + } + return result.raw } @@ -601,21 +647,19 @@ export class PostgresDriver { * * @return {Promise} */ - async delete(soft = false) { + async delete() { if (!this.#where.size) { throw new EmptyWhereException('delete') } const select = this.#select.length ? [...this.#select] : '*' - if (soft) { - const result = await this.update({ deletedAt: new Date() }) + const result = await this.query(true).delete().returning(select).execute() - return result.raw + if (result.raw && result.raw.length === 1) { + return result.raw[0] } - const result = await this.query(true).delete().returning(select).execute() - return result.raw } @@ -638,11 +682,29 @@ export class PostgresDriver { * @return {PostgresDriver} */ buildSelect(...columns) { + if (columns.find(column => column === '*')) { + this.#select = [] + + return this + } + columns.forEach(column => this.#select.push(`${this.#table}.${column}`)) return this } + /** + * Set the columns that should be selected on query. + * + * @param columns {string} + * @return {PostgresDriver} + */ + buildAddSelect(...columns) { + columns.forEach(column => this.#addSelect.push(`${this.#table}.${column}`)) + + return this + } + /** * Set a where statement in your query. * @@ -893,6 +955,27 @@ export class PostgresDriver { this.#select = [] } + /** + * Set add select options in query if exists. + * + * @param query {import('typeorm').SelectQueryBuilder} + */ + #setAddSelectOnQuery(query) { + if (query.returning) { + query.returning(this.#addSelect.length ? this.#addSelect : '*') + + return + } + + if (!this.#addSelect.length) { + return + } + + this.#addSelect.forEach(select => query.addSelect(select)) + + this.#addSelect = [] + } + /** * Set all where options in query if exists. * @@ -907,12 +990,12 @@ export class PostgresDriver { for (const [key, value] of iterator) { if (!value) { - query.where(key) + query.andWhere(key) continue } - query.where(key, value) + query.andWhere(key, value) } this.#where = new Map() diff --git a/src/Exceptions/NotImplementedDefinitionException.js b/src/Exceptions/NotImplementedDefinitionException.js new file mode 100644 index 0000000..0f92562 --- /dev/null +++ b/src/Exceptions/NotImplementedDefinitionException.js @@ -0,0 +1,28 @@ +/** + * @athenna/database + * + * (c) João Lenon + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { Exception } from '@secjs/utils' + +export class NotImplementedDefinitionException extends Exception { + /** + * Creates a new instance of NotImplementedDefinitionException. + * + * @return {NotImplementedDefinitionException} + */ + constructor(modelName) { + const content = `You have not implemented the "static definition()" method inside your ${modelName} model.` + + super( + content, + 500, + 'E_NOT_IMPLEMENTED_DEFINITION_ERROR', + `Open you ${modelName} model and write your "static definition()" method using the "faker" method.`, + ) + } +} diff --git a/src/Exceptions/NotImplementedSchemaException.js b/src/Exceptions/NotImplementedSchemaException.js new file mode 100644 index 0000000..a770f25 --- /dev/null +++ b/src/Exceptions/NotImplementedSchemaException.js @@ -0,0 +1,28 @@ +/** + * @athenna/database + * + * (c) João Lenon + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { Exception } from '@secjs/utils' + +export class NotImplementedSchemaException extends Exception { + /** + * Creates a new instance of NotImplementedSchemaException. + * + * @return {NotImplementedSchemaException} + */ + constructor(modelName) { + const content = `You have not implemented the "static schema()" method inside your ${modelName} model.` + + super( + content, + 500, + 'E_NOT_IMPLEMENTED_SCHEMA_ERROR', + `Open you ${modelName} model and write your "static schema()" method using the "Column" class from @athenna/database package.`, + ) + } +} diff --git a/src/Facades/Database.js b/src/Facades/Database.js new file mode 100644 index 0000000..2473803 --- /dev/null +++ b/src/Facades/Database.js @@ -0,0 +1,15 @@ +/** + * @athenna/database + * + * (c) João Lenon + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { Facade } from '@athenna/ioc' + +/** + * @type {Facade & import('#src/index').Database} + */ +export const Database = Facade.createFor('Athenna/Core/Database') diff --git a/src/Factories/ConnectionFactory.js b/src/Factories/ConnectionFactory.js index 4c6c780..edba9e8 100644 --- a/src/Factories/ConnectionFactory.js +++ b/src/Factories/ConnectionFactory.js @@ -123,6 +123,7 @@ export class ConnectionFactory { typeormOptions.host = configs.host typeormOptions.port = configs.port typeormOptions.debug = configs.debug + typeormOptions.logging = configs.logging typeormOptions.username = configs.user typeormOptions.password = configs.password typeormOptions.database = configs.database diff --git a/src/Factories/DriverFactory.js b/src/Factories/DriverFactory.js index a953a96..29b61fa 100644 --- a/src/Factories/DriverFactory.js +++ b/src/Factories/DriverFactory.js @@ -65,7 +65,7 @@ export class DriverFactory { const { Driver, clientConnection } = this.#drivers.get(conConfig.driver) if (clientConnection) { - return new Driver(clientConnection, runtimeConfig) + return new Driver(conName, runtimeConfig, clientConnection) } this.#drivers.set(conConfig.driver, { @@ -148,6 +148,10 @@ export class DriverFactory { conName = driverObject.lastConName } + if (conName === 'default') { + conName = Config.get('database.default') + } + const client = await ConnectionFactory[driverName](conName, configs) if (!saveOnDriver) { diff --git a/src/Factories/ModelFactory.js b/src/Factories/ModelFactory.js new file mode 100644 index 0000000..16a0bcf --- /dev/null +++ b/src/Factories/ModelFactory.js @@ -0,0 +1,132 @@ +export class ModelFactory { + /** + * The number of models to be created. + * + * @type {number} + */ + #count = 1 + + /** + * The model that we are going to use to generate + * data. + * + * @type {any} + */ + #Model = null + + /** + * Set the returning key that this factory will return. + * + * @type {string|null} + */ + #returning = null + + /** + * Creates a new instance of ModelFactory. + * + * @param Model {any} + * @param returning {string} + * @return {ModelFactory} + */ + constructor(Model, returning = '*') { + this.#Model = Model + this.#returning = returning + } + + /** + * Set the number of models to be created + * + * @param number + * @return {ModelFactory} + */ + count(number) { + this.#count = number + + return this + } + + /** + * Make models without creating it on database. + * + * @param override {any} + * @param asArrayOnOne {boolean} + */ + async make(override = {}, asArrayOnOne = true) { + const promises = [] + + for (let i = 1; i <= this.#count; i++) { + promises.push(this.#getDefinition(override, 'make')) + } + + let data = await Promise.all(promises) + + if (this.#returning !== '*') { + data = data.map(d => d[this.#returning]) + } + + if (asArrayOnOne && data.length === 1) { + return data[0] + } + + return data + } + + /** + * Create models creating it on database. + * + * @param override {any} + * @param asArrayOnOne {boolean} + */ + async create(override = {}, asArrayOnOne = true) { + const promises = [] + + for (let i = 1; i <= this.#count; i++) { + promises.push(this.#getDefinition(override, 'create')) + } + + let data = await this.#Model.createMany(await Promise.all(promises)) + + if (this.#returning !== '*') { + data = data.map(d => d[this.#returning]) + } + + if (asArrayOnOne && data.length === 1) { + return data[0] + } + + return data + } + + /** + * Execute the definition method and return data. + * + * @param override {any} + * @param method {string} + * @return {Promise} + */ + async #getDefinition(override, method) { + const data = await this.#Model.definition() + + const promises = Object.keys(data).reduce((promises, key) => { + // Do not execute sub factory if the value already exists in values object + if (override && override[key]) return promises + + if (data[key] instanceof ModelFactory) { + promises.push( + Promise.resolve(data[key][method]()).then( + result => (data[key] = result), + ), + ) + } + + return promises + }, []) + + await Promise.all(promises) + + return { + ...data, + ...override, + } + } +} diff --git a/src/Models/Column.js b/src/Models/Column.js new file mode 100644 index 0000000..4d7ed7e --- /dev/null +++ b/src/Models/Column.js @@ -0,0 +1,143 @@ +import { Json } from '@secjs/utils' + +export class Column { + static #column = { + isColumn: true, + } + + /** + * Create an auto incremented integer primary key. Usefully for id's. + * + * This method is an alias for: + * @example Column.type('int').isGenerated().isPrimary().get() + * + * @return {any} + */ + static autoIncrementedIntPk() { + return this.type('int').isGenerated().isPrimary().get() + } + + /** + * Create a "createdAt" column. + * + * This method is an alias for: + * @example Column.type('timestamp').default('now()').get() + * + * @return {any} + */ + static createdAt() { + return this.type('timestamp').default('now()').get() + } + + /** + * Create a "updatedAt" column. + * + * This method is an alias for: + * @example Column.type('timestamp').default('now()').get() + * + * @return {any} + */ + static updatedAt() { + return this.type('timestamp').default('now()').get() + } + + /** + * Create a "deletedAt" column. + * + * This method is an alias for: + * @example Column.type('timestamp').default(null).isNullable().get() + * + * @return {any} + */ + static deletedAt() { + return this.type('timestamp').default(null).isNullable().get() + } + + /** + * Set the type of your column. + * + * @return {Column} + */ + static type(type) { + this.#column.type = type + + return this + } + + /** + * Set the default value of your column. + * + * @return {Column} + */ + static default(value) { + this.#column.default = value + + return this + } + + /** + * Set if this column should be hidded. + */ + static isHidden() { + this.#column.select = false + + return this + } + + /** + * Set if your column is auto generated. + * + * @return {Column} + */ + static isGenerated() { + this.#column.generated = true + + return this + } + + /** + * Set if your column is primary. + * + * @return {Column} + */ + static isPrimary() { + this.#column.primary = true + + return this + } + + /** + * Set if your column is unique. + * + * @return {Column} + */ + static isUnique() { + this.#column.unique = true + + return this + } + + /** + * Set if your column is nullable. + * + * @return {Column} + */ + static isNullable() { + this.#column.nullable = true + + return this + } + + /** + * Get the clean object built. + * + * @return {any} + */ + static get() { + const jsonColumn = Json.copy(this.#column) + + this.#column = { isColumn: true } + + return jsonColumn + } +} diff --git a/src/Models/Model.js b/src/Models/Model.js new file mode 100644 index 0000000..52cc170 --- /dev/null +++ b/src/Models/Model.js @@ -0,0 +1,289 @@ +/** + * @athenna/database + * + * (c) João Lenon + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { String } from '@secjs/utils' +import { EntitySchema } from 'typeorm' +import { faker } from '@faker-js/faker' + +import { Database } from '#src/index' +import { Criteria } from '#src/Utils/Criteria' +import { ModelFactory } from '#src/Factories/ModelFactory' +import { ModelQueryBuilder } from '#src/Utils/ModelQueryBuilder' +import { EmptyWhereException } from '#src/Exceptions/EmptyWhereException' +import { NotImplementedSchemaException } from '#src/Exceptions/NotImplementedSchemaException' +import { NotImplementedDefinitionException } from '#src/Exceptions/NotImplementedDefinitionException' + +export class Model { + /** + * Set the db connection that this model instance will work with. + * + * @return {string} + */ + static get connection() { + return 'default' + } + + /** + * Set the table name of this model instance. + * + * @return {string} + */ + static get table() { + return String.pluralize(this.name.toLowerCase()) + } + + /** + * Set the primary key of your model. + * + * @return {string} + */ + static get primaryKey() { + return 'id' + } + + /** + * The attributes that could be persisted in database. + * + * @return {string[]} + */ + static get persistOnly() { + return ['*'] + } + + /** + * Return a boolean specifying if Model will use soft delete. + * + * @return {boolean} + */ + static get isSoftDelete() { + return true + } + + /** + * Return the DELETED_AT column name in database. + * + * @return {string} + */ + static get DELETED_AT() { + return 'deletedAt' + } + + /** + * Return the criterias set to this model. + * + * @return {any} + */ + static get criterias() { + return { + deletedAt: Criteria.whereNull(this.DELETED_AT).get(), + } + } + + /** + * The faker instance to create fake data. + * + * @type {Faker} + */ + static faker = faker + + /** + * The default schema for model instances. + * + * @return {any} + */ + static schema() { + throw new NotImplementedSchemaException(this.name) + } + + /** + * The definition method used by factories. + * + * @return {any} + */ + static async definition() { + throw new NotImplementedDefinitionException(this.name) + } + + /** + * Create the factory object to generate data. + * + * @return {ModelFactory} + */ + static factory(returning = '*') { + return new ModelFactory(this, returning) + } + + /** + * The TypeORM entity schema instance. + * + * @return {EntitySchema} + */ + static getSchema() { + const schema = this.schema() + + const columns = {} + const relations = {} + + Object.keys(schema).forEach(key => { + const value = schema[key] + + if (value.isColumn) { + delete value.isColumn + + columns[key] = value + } else { + delete value.isRelation + + relations[key] = value + } + }) + + return new EntitySchema({ + name: this.table, + tableName: this.table, + columns, + relations, + }) + } + + /** + * Create a new model query builder. + * + * @param [withCriterias] {boolean} + * @return {ModelQueryBuilder} + */ + static query(withCriterias = true) { + return new ModelQueryBuilder(this, new Database(), withCriterias) + } + + /** + * Get one data in DB and return as a subclass instance. + * + * @param {any} [where] + * @return {Promise>} + */ + static async find(where = {}) { + const query = this.query() + + if (Object.keys(where).length) { + query.where(where) + } + + return query.where(where).find() + } + + /** + * Get many data in DB and return as an array of subclass instance. + * + * @param {any} [where] + * @return {Promise[]>} + */ + static async findMany(where = {}) { + const query = this.query() + + if (Object.keys(where).length) { + query.where(where) + } + + return query.where(where).findMany() + } + + /** + * Find many models in database and return as paginated response. + * + * @param [page] {boolean} + * @param [limit] {boolean} + * @param [resourceUrl] {string} + * @return {Promise<{ + * data: InstanceType[], + * meta: { + * totalItems: number, + * itemsPerPage: number, + * totalPages: number, + * currentPage: number, + * itemCount: number, + * }, + * links: { + * next: string, + * previous: string, + * last: string, + * first: string + * } + * }>} + */ + static async paginate(page = 0, limit = 10, resourceUrl = '/') { + return this.query().paginate(page, limit, resourceUrl) + } + + /** + * Create a new model in DB and return as a subclass instance. + * + * @param {any} data + * @return {Promise>} + */ + static async create(data = {}) { + return this.query().create(data) + } + + /** + * Create many models in DB and return as subclass instances. + * + * @param {any[]} data + * @return {Promise[]>} + */ + static async createMany(data = []) { + return this.query().createMany(data) + } + + /** + * Update a model in DB and return as a subclass instance. + * + * @param {any} where + * @param {any} [data] + * @return {Promise|InstanceType[]>} + */ + static async update(where, data = {}) { + if (!Object.keys(where).length) { + throw new EmptyWhereException('update') + } + + return this.query().where(where).update(data) + } + + /** + * Delete a model in DB and return as a subclass instance or void. + * + * @param {any} where + * @param {boolean} force + * @return {Promise|void>} + */ + static async delete(where, force = false) { + if (!Object.keys(where).length) { + throw new EmptyWhereException('delete') + } + + return this.query().where(where).delete(force) + } + + /** + * Return a Json object from the actual subclass instance. + * + * @return {any|any[]} + */ + toJSON() { + const json = {} + + Object.keys(this).forEach(key => (json[key] = this[key])) + + return json + } + + // TODO + // async save() +} diff --git a/src/Models/Relation.js b/src/Models/Relation.js new file mode 100644 index 0000000..91c570b --- /dev/null +++ b/src/Models/Relation.js @@ -0,0 +1,151 @@ +import { Json } from '@secjs/utils' + +export class Relation { + static #relation = { + isRelation: true, + } + + /** + * Create a oneToOne relation schema. + * + * This method is an alias for: + * @example Relation.target(model).type('one-to-one').inverseSide(inverseSide).get() + * + * @param inverseSide {string} + * @param model {any} + * @param cascade {boolean} + * @return {any} + */ + static oneToOne(inverseSide, model, cascade = false) { + this.#relation.cascade = cascade + + return this.target(model).type('one-to-one').inverseSide(inverseSide).get() + } + + /** + * Create a oneToMany relation schema. + * + * This method is an alias for: + * @example Relation.target(model).type('one-to-many').inverseSide(inverseSide).get() + * + * @param inverseSide {string} + * @param model {any} + * @param cascade {boolean} + * @return {any} + */ + static oneToMany(inverseSide, model, cascade = false) { + this.#relation.cascade = cascade + + return this.target(model).type('one-to-many').inverseSide(inverseSide).get() + } + + /** + * Create a manyToOne relation schema. + * + * This method is an alias for: + * @example Relation.target(model).type('many-to-one').inverseSide(inverseSide).get() + * + * @param inverseSide {string} + * @param model {any} + * @param cascade {boolean} + * @return {any} + */ + static manyToOne(inverseSide, model, cascade = false) { + this.#relation.cascade = cascade + + return this.target(model).type('many-to-one').inverseSide(inverseSide).get() + } + + /** + * Create a manyToMany relation schema. + * + * This method is an alias for: + * @example Relation.target(model).type('many-tomany').inverseSide(inverseSide).get() + * + * @param inverseSide {string} + * @param model {any} + * @param cascade {boolean} + * @return {any} + */ + static manyToMany(inverseSide, model, cascade = false) { + this.#relation.cascade = cascade + + return this.target(model) + .type('many-to-many') + .inverseSide(inverseSide) + .get() + } + + /** + * Set the target model that your relation is pointing. + * + * @param model {any} + * @return {Relation} + */ + static target(model) { + this.#relation.target = model.table + + return this + } + + /** + * Set the relation type. + * + * @param type {"one-to-one","one-to-many","many-to-one","many-to-many"} + * @return {Relation} + */ + static type(type) { + this.#relation.type = type + + return this + } + + /** + * Set the inverse side of your model schema. + * + * @param name + * @return {Relation} + */ + static inverseSide(name) { + this.#relation.inverseSide = name + + return this + } + + /** + * Set the column that the relation should join. + * + * @param column + */ + static joinColumn(column) { + if (!this.#relation.joinColumn) { + this.#relation.joinColumn = {} + } + + this.#relation.joinColumn.name = column + } + + /** + * Set if relation should be cascaded on delete/update. + * + * @return {Relation} + */ + static cascade() { + this.#relation.cascade = true + + return this + } + + /** + * Get the clean object built. + * + * @return {any} + */ + static get() { + const jsonColumn = Json.copy(this.#relation) + + this.#relation = { isRelation: true } + + return jsonColumn + } +} diff --git a/src/Utils/Criteria.js b/src/Utils/Criteria.js new file mode 100644 index 0000000..853a721 --- /dev/null +++ b/src/Utils/Criteria.js @@ -0,0 +1,220 @@ +import { Json } from '@secjs/utils' + +export class Criteria { + static #criteria = new Map() + + /** + * Set the table that this query will be executed. + * + * @param tableName {string|any} + * @return {Criteria} + */ + static table(tableName) { + this.#criteria.set('table', [tableName]) + + return this + } + + /** + * Set the columns that should be selected on query. + * + * @param columns {string} + * @return {Criteria} + */ + static select(...columns) { + this.#criteria.set('select', [columns]) + + return this + } + + /** + * Set a include statement in your query. + * + * @param relation {string|any} + * @param [operation] {string} + * @return {Criteria} + */ + static includes(relation, operation) { + this.#criteria.set('includes', [relation, operation]) + + return this + } + + /** + * Set a where statement in your query. + * + * @param statement {string|Record} + * @param [value] {any} + * @return {Criteria} + */ + static where(statement, value) { + this.#criteria.set('where', [statement, value]) + + return this + } + + /** + * Set a where like statement in your query. + * + * @param statement {string|Record} + * @param [value] {any} + * @return {Criteria} + */ + static whereLike(statement, value) { + this.#criteria.set('whereLike', [statement, value]) + + return this + } + + /** + * Set a where ILike statement in your query. + * + * @param statement {string|Record} + * @param [value] {any} + * @return {Criteria} + */ + static whereILike(statement, value) { + this.#criteria.set('whereILike', [statement, value]) + + return this + } + + /** + * Set a where not statement in your query. + * + * @param statement {string|Record} + * @param [value] {any} + * @return {Criteria} + */ + static whereNot(statement, value) { + this.#criteria.set('whereNot', [statement, value]) + + return this + } + + /** + * Set a where in statement in your query. + * + * @param columnName {string} + * @param values {any[]} + * @return {Criteria} + */ + static whereIn(columnName, values) { + this.#criteria.set('whereIn', [columnName, values]) + + return this + } + + /** + * Set a where not in statement in your query. + * + * @param columnName {string} + * @param values {any[]} + * @return {Criteria} + */ + static whereNotIn(columnName, values) { + this.#criteria.set('whereNotIn', [columnName, values]) + + return this + } + + /** + * Set a where null statement in your query. + * + * @param columnName {string} + * @return {Criteria} + */ + static whereNull(columnName) { + this.#criteria.set('whereNull', [columnName]) + + return this + } + + /** + * Set a where not null statement in your query. + * + * @param columnName {string} + * @return {Criteria} + */ + static whereNotNull(columnName) { + this.#criteria.set('whereNotNull', [columnName]) + + return this + } + + /** + * Set a where between statement in your query. + * + * @param columnName {string} + * @param values {[any, any]} + * @return {Criteria} + */ + static whereBetween(columnName, values) { + this.#criteria.set('whereBetween', [columnName, values]) + + return this + } + + /** + * Set a where not between statement in your query. + * + * @param columnName {string} + * @param values {[any, any]} + * @return {Criteria} + */ + static whereNotBetween(columnName, values) { + this.#criteria.set('whereNotBetween', [columnName, values]) + + return this + } + + /** + * Set a order by statement in your query. + * + * @param columnName {string} + * @param [direction] {'asc'|'desc'|'ASC'|'DESC'} + * @return {Criteria} + */ + static orderBy(columnName, direction = 'ASC') { + this.#criteria.set('orderBy', [columnName, direction]) + + return this + } + + /** + * Set the skip number in your query. + * + * @param number {number} + * @return {Criteria} + */ + static skip(number) { + this.#criteria.set('skip', [number]) + + return this + } + + /** + * Set the limit number in your query. + * + * @param number {number} + * @return {Criteria} + */ + static limit(number) { + this.#criteria.set('limit', [number]) + + return this + } + + /** + * Get the criteria map. + * + * @return {Map} + */ + static get() { + const map = Json.copy(this.#criteria) + + this.#criteria.clear() + + return map + } +} diff --git a/src/Utils/ModelQueryBuilder.js b/src/Utils/ModelQueryBuilder.js new file mode 100644 index 0000000..e960eef --- /dev/null +++ b/src/Utils/ModelQueryBuilder.js @@ -0,0 +1,478 @@ +/** + * @athenna/database + * + * (c) João Lenon + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { Is, Json } from '@secjs/utils' +import { Model } from '#src/Models/Model' + +export class ModelQueryBuilder { + /** + * The model that is using this instance. + * + * @type {any} + */ + #Model + + /** + * The database instance used to handle database operations. + * + * @type {import('#src/index').Database} + */ + #DB + + /** + * Set if this instance of query builder will use criterias or not. + * + * @type {boolean} + */ + #withCriterias + + /** + * All the criterias that should be removed by query builder. + * + * @type {string[]} + */ + #removedCriterias = [] + + /** + * Creates a new instance of ModelQueryBuilder. + * + * @param model + * @param DB + * @return {ModelQueryBuilder} + */ + constructor(model, DB, withCriterias) { + DB.connection(model.connection).buildTable(model.table) + + this.#DB = DB + this.#Model = model + this.#withCriterias = withCriterias + } + + /** + * Find one data in database. + * + * @return {Promise} + */ + async find() { + this.#setCriterias() + + return this.#generateModels(await this.#DB.find()) + } + + /** + * Find many data in database. + * + * @return {Promise} + */ + async findMany() { + this.#setCriterias() + + return this.#generateModels(await this.#DB.findMany()) + } + + /** + * Find many models in database and return as paginated response. + * + * @param [page] {boolean} + * @param [limit] {boolean} + * @param [resourceUrl] {string} + * @return {Promise<{ + * data: InstanceType[], + * meta: { + * totalItems: number, + * itemsPerPage: number, + * totalPages: number, + * currentPage: number, + * itemCount: number, + * }, + * links: { + * next: string, + * previous: string, + * last: string, + * first: string + * } + * }>} + */ + async paginate(page = 0, limit = 10, resourceUrl = '/') { + this.#setCriterias() + + const { data, meta, links } = await this.#DB.paginate( + page, + limit, + resourceUrl, + ) + + return { data: this.#generateModels(data), meta, links } + } + + /** + * Count the number of models in database. + * + * @return {Promise} + */ + async count() { + this.#setCriterias() + + return this.#DB.count() + } + + /** + * Create one model in database. + * + * @param data {any} + * @return {Promise} + */ + async create(data) { + return this.#generateModels(await this.#DB.create(this.#fillable(data))) + } + + /** + * Create many models in database. + * + * @param data {any} + * @return {Promise} + */ + async createMany(data) { + return this.#generateModels(await this.#DB.createMany(this.#fillable(data))) + } + + /** + * Update one or more models in database. + * + * @param data {any} + * @return {Promise} + */ + async update(data) { + return this.#generateModels(await this.#DB.update(this.#fillable(data))) + } + + /** + * Delete one or more models in database. + * + * @param [force] {boolean} + * @return {Promise} + */ + async delete(force = false) { + if (this.#Model.isSoftDelete && !force) { + return this.#generateModels( + await this.#DB.update({ [this.#Model.DELETED_AT]: new Date() }), + ) + } + + return this.#generateModels(await this.#DB.delete()) + } + + /** + * Remove the criteria from query builder by name. + * + * @param name + * @return {ModelQueryBuilder} + */ + removeCriteria(name) { + this.#removedCriterias.push(name) + + return this + } + + /** + * List the criterias from query builder. + * + * @param withRemoved {boolean} + * @return {any} + */ + listCriterias(withRemoved = false) { + if (withRemoved) { + return this.#Model.criterias + } + + const criterias = Json.copy(this.#Model.criterias) + + Object.keys(criterias).forEach(key => { + if (this.#removedCriterias.includes(key)) { + delete criterias[key] + } + }) + + return criterias + } + + /** + * Set the columns that should be selected on query. + * + * @param columns {string} + * @return {ModelQueryBuilder} + */ + select(...columns) { + this.#DB.buildSelect(...columns) + + return this + } + + /** + * Set the columns that should be selected on query. + * + * @param columns {string} + * @return {ModelQueryBuilder} + */ + addSelect(...columns) { + this.#DB.buildAddSelect(...columns) + + return this + } + + /** + * Set how many models should be skipped in your query. + * + * @param number {number} + * @return {ModelQueryBuilder} + */ + skip(number) { + this.#DB.buildSkip(number) + + return this + } + + /** + * Set the limit of models in your query. + * + * @param number {number} + * @return {ModelQueryBuilder} + */ + limit(number) { + this.#DB.buildLimit(number) + + return this + } + + /** + * Set the order in your query. + * + * @param [columnName] {string} + * @param [direction] {'asc'|'desc'|'ASC'|'DESC'} + * @return {ModelQueryBuilder} + */ + orderBy(columnName = this.#Model.primaryKey, direction = 'ASC') { + this.#DB.buildOrderBy(columnName, direction) + + return this + } + + /** + * Include some relation in your query. + * + * @param [relationName] {string|any} + * @param [operation] {string} + * @return {ModelQueryBuilder} + */ + includes(relationName) { + this.#DB.buildIncludes(relationName) + + return this + } + + /** + * Set a where statement in your query. + * + * @param statement {string|Record} + * @param [value] {any} + * @return {ModelQueryBuilder} + */ + where(statement, value) { + this.#DB.buildWhere(statement, value) + + return this + } + + /** + * Set a where like statement in your query. + * + * @param statement {string|Record} + * @param [value] {any} + * @return {ModelQueryBuilder} + */ + whereLike(statement, value) { + this.#DB.buildWhereLike(statement, value) + + return this + } + + /** + * Set a where ILike statement in your query. + * + * @param statement {string|Record} + * @param [value] {any} + * @return {ModelQueryBuilder} + */ + whereILike(statement, value) { + this.#DB.buildWhereILike(statement, value) + + return this + } + + /** + * Set a where not statement in your query. + * + * @param statement {string|Record} + * @param [value] {any} + * @return {ModelQueryBuilder} + */ + whereNot(statement, value) { + this.#DB.buildWhereNot(statement, value) + + return this + } + + /** + * Set a where in statement in your query. + * + * @param columnName {string} + * @param values {any[]} + * @return {ModelQueryBuilder} + */ + whereIn(columnName, values) { + this.#DB.buildWhereIn(columnName, values) + + return this + } + + /** + * Set a where not in statement in your query. + * + * @param columnName {string} + * @param values {any[]} + * @return {ModelQueryBuilder} + */ + whereNotIn(columnName, values) { + this.#DB.buildWhereNotIn(columnName, values) + + return this + } + + /** + * Set a where between statement in your query. + * + * @param columnName {string} + * @param values {[any, any]} + * @return {ModelQueryBuilder} + */ + whereBetween(columnName, values) { + this.#DB.buildWhereBetween(columnName, values) + + return this + } + + /** + * Set a where not between statement in your query. + * + * @param columnName {string} + * @param values {[any, any]} + * @return {ModelQueryBuilder} + */ + whereNotBetween(columnName, values) { + this.#DB.buildWhereNotBetween(columnName, values) + + return this + } + + /** + * Set a where null statement in your query. + * + * @param columnName {string} + * @return {ModelQueryBuilder} + */ + whereNull(columnName) { + this.#DB.buildWhereNull(columnName) + + return this + } + + /** + * Set a where not null statement in your query. + * + * @param columnName {string} + * @return {ModelQueryBuilder} + */ + whereNotNull(columnName) { + this.#DB.buildWhereNotNull(columnName) + + return this + } + + /** + * Generate model instances from data. + * + * @param {any|any[]} data + */ + #generateModels(data) { + if (!data) { + return null + } + + if (!Is.Array(data)) { + const model = new Model() + + Object.keys(data).forEach(key => (model[key] = data[key])) + + return model + } + + return data.map(d => this.#generateModels(d)) + } + + /** + * Get data only from persist only. + * + * @param data {any} + * @return {any|any[]} + */ + #fillable(data) { + if (!data) { + return null + } + + if (this.#Model.persistOnly[0] === '*') { + return data + } + + if (!Is.Array(data)) { + Object.keys(data).forEach(key => { + if (!this.#Model.persistOnly.includes(key)) { + delete data[key] + } + }) + + return data + } + + return data.map(d => this.#fillable(d)) + } + + /** + * Set the criterias on query builder. + * + * @return {void} + */ + #setCriterias() { + if (!this.#withCriterias) { + return + } + + Object.keys(this.#Model.criterias).forEach(k => { + if (this.#removedCriterias.includes(k)) { + return + } + + const criteria = this.#Model.criterias[k] + + for (const [key, value] of criteria.entries()) { + this[key](...value) + } + }) + } +} diff --git a/src/index.d.ts b/src/index.d.ts index 0dcc59b..bb4d27c 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -19,10 +19,10 @@ export class Database { /** * Change the database connection. * - * @param {string} connections + * @param {string} connection * @return {Database} */ - connection(...connections: string[]): Database + connection(connection: string): Database /** * Connect to database. @@ -40,6 +40,13 @@ export class Database { */ close(): Promise + /** + * Return the TypeORM data source. + * + * @return {import('typeorm').DataSource|null} + */ + getDataSource(): import('typeorm').DataSource + /** * Creates a new instance of query builder. * @@ -251,6 +258,16 @@ export class Database { */ findMany(): Promise + /** + * Find many values in database and return as paginated response. + * + * @param [page] {boolean} + * @param [limit] {boolean} + * @param [resourceUrl] {string} + * @return {} + */ + paginate(page?: number, limit?: number, resourceUrl?: string): Promise + /** * Create a value in database. * @@ -278,9 +295,9 @@ export class Database { /** * Delete one value in database. * - * @return {Promise} + * @return {Promise} */ - delete(soft?: boolean): Promise + delete(): Promise /** * Set the table that this query will be executed. @@ -298,6 +315,14 @@ export class Database { */ buildSelect(...columns: string[]): Database + /** + * Set the columns that should be selected on query. + * + * @param columns {string} + * @return {Database} + */ + buildAddSelect(...columns: string[]): Database + /** * Set a where statement in your query. * @@ -634,6 +659,16 @@ export class Transaction { */ findMany(): Promise + /** + * Find many values in database and return as paginated response. + * + * @param [page] {boolean} + * @param [limit] {boolean} + * @param [resourceUrl] {string} + * @return {} + */ + paginate(page?: number, limit?: number, resourceUrl?: string): Promise + /** * Create a value in database. * @@ -661,25 +696,33 @@ export class Transaction { /** * Delete one value in database. * - * @return {Promise} + * @return {Promise} */ - delete(soft?: boolean): Promise + delete(): Promise /** * Set the table that this query will be executed. * * @param tableName {string|any} - * @return {Database} + * @return {Transaction} */ - buildTable(tableName: string | any): Database + buildTable(tableName: string | any): Transaction /** * Set the columns that should be selected on query. * * @param columns {string} - * @return {Database} + * @return {Transaction} */ - buildSelect(...columns: string[]): Database + buildSelect(...columns: string[]): Transaction + + /** + * Set the columns that should be selected on query. + * + * @param columns {string} + * @return {Transaction} + */ + buildAddSelect(...columns: string[]): Transaction /** * Set a where statement in your query. diff --git a/src/index.js b/src/index.js index ccce4c0..4119382 100644 --- a/src/index.js +++ b/src/index.js @@ -53,8 +53,6 @@ export class Database { * @return {Database} */ connection(connection) { - this.close() - this.#driver = DriverFactory.fabricate(connection, this.#configs) return this @@ -80,6 +78,15 @@ export class Database { return this.#driver.close() } + /** + * Return the TypeORM data source. + * + * @return {import('typeorm').DataSource|null} + */ + getDataSource() { + return this.#driver.getDataSource() + } + /** * Creates a new instance of query builder. * @@ -337,6 +344,18 @@ export class Database { return this.#driver.findMany() } + /** + * Find many values in database and return as paginated response. + * + * @param [page] {boolean} + * @param [limit] {boolean} + * @param [resourceUrl] {string} + * @return {Promise} + */ + async paginate(page = 0, limit = 10, resourceUrl = '/') { + return this.#driver.paginate(page, limit, resourceUrl) + } + /** * Create a value in database. * @@ -361,7 +380,7 @@ export class Database { * Update a value in database. * * @param {any} data - * @return {Promise} + * @return {Promise} */ async update(data) { return this.#driver.update(data) @@ -370,10 +389,10 @@ export class Database { /** * Delete one value in database. * - * @return {Promise} + * @return {Promise} */ - async delete(soft = false) { - return this.#driver.delete(soft) + async delete() { + return this.#driver.delete() } /** @@ -395,20 +414,19 @@ export class Database { * @return {Database} */ buildSelect(...columns) { - this.#driver.buildSelect(columns) + this.#driver.buildSelect(...columns) return this } /** - * Set a where statement in your query. + * Set the columns that should be selected on query. * - * @param statement {string|Record} - * @param [value] {any} + * @param columns {string} * @return {Database} */ - buildWhere(statement, value) { - this.#driver.buildWhere(statement, value) + buildAddSelect(...columns) { + this.#driver.buildAddSelect(...columns) return this } @@ -420,12 +438,25 @@ export class Database { * @param [operation] {string} * @return {Database} */ - buildIncludes(relation, operation = 'leftJoinAndSelect') { + buildIncludes(relation, operation) { this.#driver.buildIncludes(relation, operation) return this } + /** + * Set a where statement in your query. + * + * @param statement {string|Record} + * @param [value] {any} + * @return {Database} + */ + buildWhere(statement, value) { + this.#driver.buildWhere(statement, value) + + return this + } + /** * Set a where like statement in your query. * @@ -863,6 +894,18 @@ export class Transaction { return this.#driver.findMany() } + /** + * Find many values in database and return as paginated response. + * + * @param [page] {boolean} + * @param [limit] {boolean} + * @param [resourceUrl] {string} + * @return {Promise} + */ + async paginate(page = 0, limit = 10, resourceUrl = '/') { + return this.#driver.paginate(page, limit, resourceUrl) + } + /** * Create a value in database. * @@ -927,14 +970,13 @@ export class Transaction { } /** - * Set a where statement in your query. + * Set the columns that should be selected on query. * - * @param statement {string|Record} - * @param [value] {any} + * @param columns {string} * @return {Transaction} */ - buildWhere(statement, value) { - this.#driver.buildWhere(statement, value) + buildAddSelect(...columns) { + this.#driver.buildAddSelect(...columns) return this } @@ -946,12 +988,25 @@ export class Transaction { * @param [operation] {string} * @return {Transaction} */ - buildIncludes(relation, operation = 'leftJoinAndSelect') { + buildIncludes(relation, operation) { this.#driver.buildIncludes(relation, operation) return this } + /** + * Set a where statement in your query. + * + * @param statement {string|Record} + * @param [value] {any} + * @return {Transaction} + */ + buildWhere(statement, value) { + this.#driver.buildWhere(statement, value) + + return this + } + /** * Set a where like statement in your query. * diff --git a/tests/Stubs/configs/database.js b/tests/Stubs/configs/database.js index d20b5d9..54c3c5b 100644 --- a/tests/Stubs/configs/database.js +++ b/tests/Stubs/configs/database.js @@ -1,3 +1,6 @@ +import { User } from '#tests/Stubs/models/User' +import { Product } from '#tests/Stubs/models/Product' + export default { /* |-------------------------------------------------------------------------- @@ -44,8 +47,8 @@ export default { database: 'postgres', user: 'postgres', password: '12345', - debug: true, - entities: [], + logging: ['error', 'warn'], + entities: [User.getSchema(), Product.getSchema()], migrations: [], synchronize: false, }, diff --git a/tests/Stubs/models/Product.js b/tests/Stubs/models/Product.js new file mode 100644 index 0000000..498d0ac --- /dev/null +++ b/tests/Stubs/models/Product.js @@ -0,0 +1,66 @@ +import { Model } from '#src/Models/Model' +import { Column } from '#src/Models/Column' +import { Relation } from '#src/Models/Relation' +import { User } from '#tests/Stubs/models/User' + +export class Product extends Model { + /** + * Set the table name of this model instance. + * + * @return {string} + */ + static get table() { + return 'products' + } + + /** + * Set the primary key of your model. + * + * @return {string} + */ + static get primaryKey() { + return 'id' + } + + /** + * The attributes that could be persisted in database. + * + * @return {string[]} + */ + static get persistOnly() { + return ['id', 'name', 'userId'] + } + + /** + * The default schema for model instances. + * + * @return {any} + */ + static schema() { + return { + id: Column.autoIncrementedIntPk(), + name: Column.type('varchar').get(), + userId: Column.type('int').get(), + user: Relation.manyToOne('products', User), + createdAt: Column.createdAt(), + updatedAt: Column.updatedAt(), + deletedAt: Column.deletedAt(), + } + } + + /** + * The definition method used by factories. + * + * @return {any} + */ + static async definition() { + return { + id: this.faker.datatype.number(), + name: this.faker.name.fullName(), + userId: User.factory('id'), + createdAt: this.faker.datatype.datetime(), + updatedAt: this.faker.datatype.datetime(), + deletedAt: null, + } + } +} diff --git a/tests/Stubs/models/User.js b/tests/Stubs/models/User.js new file mode 100644 index 0000000..34b6489 --- /dev/null +++ b/tests/Stubs/models/User.js @@ -0,0 +1,78 @@ +import { Model } from '#src/Models/Model' +import { Column } from '#src/Models/Column' +import { Relation } from '#src/Models/Relation' +import { Product } from '#tests/Stubs/models/Product' +import { Criteria } from '#src/Utils/Criteria' + +export class User extends Model { + /** + * Set the table name of this model instance. + * + * @return {string} + */ + static get table() { + return 'users' + } + + /** + * Set the primary key of your model. + * + * @return {string} + */ + static get primaryKey() { + return 'id' + } + + /** + * The attributes that could be persisted in database. + * + * @return {string[]} + */ + static get persistOnly() { + return ['id', 'name', 'email'] + } + + /** + * Return the criterias set to this model. + * + * @return {any} + */ + static get criterias() { + return { + deletedAt: Criteria.whereNull(this.DELETED_AT).get(), + } + } + + /** + * The default schema for model instances. + * + * @return {any} + */ + static schema() { + return { + id: Column.autoIncrementedIntPk(), + name: Column.type('varchar').get(), + email: Column.type('varchar').isHidden().isUnique().get(), + products: Relation.oneToMany('user', Product, true), + createdAt: Column.createdAt(), + updatedAt: Column.updatedAt(), + deletedAt: Column.deletedAt(), + } + } + + /** + * The definition method used by factories. + * + * @return {any} + */ + static async definition() { + return { + id: this.faker.datatype.number(), + name: this.faker.name.fullName(), + email: this.faker.internet.email(), + createdAt: this.faker.datatype.datetime(), + updatedAt: this.faker.datatype.datetime(), + deletedAt: null, + } + } +} diff --git a/tests/Unit/PostgresDriverTest.js b/tests/Unit/PostgresDriverTest.js index 9d36a99..273402c 100644 --- a/tests/Unit/PostgresDriverTest.js +++ b/tests/Unit/PostgresDriverTest.js @@ -11,7 +11,7 @@ import { test } from '@japa/runner' import { Path, Folder, Config } from '@secjs/utils' import { Database } from '#src/index' -import { DriverFactory } from '#src/Factories/DriverFactory' +import { User } from '#tests/Stubs/models/User' test.group('PostgresDriverTest', group => { /** @type {Database} */ @@ -19,32 +19,21 @@ test.group('PostgresDriverTest', group => { group.setup(async () => { await new Folder(Path.stubs('configs')).copy(Path.config()) - await new Config().safeLoad(Path.config('database.js')) - - database = new Database() - }) - - group.teardown(async () => { - await database.dropTable('users') - await database.close() - - await Folder.safeRemove(Path.config()) }) - test('should be able to connect to database', async ({ assert }) => { - await database.connection('postgres').connect() + group.each.setup(async () => { + database = new Database() - assert.deepEqual(['postgres'], DriverFactory.availableDrivers(true)) - }) + await database.connect() - test('should be able to create and drop tables', async () => { const options = { columns: [ { name: 'id', type: 'int', isPrimary: true, + isGenerated: true, generationStrategy: 'increment', }, { @@ -76,14 +65,19 @@ test.group('PostgresDriverTest', group => { } await database.createTable('users', options) - await database.dropTable('users') - - /** - * Create again to continue testing. - */ - await database.createTable('users', options) database.buildTable('users') + + await User.factory().count(10).create() + }) + + group.each.teardown(async () => { + await database.dropTable('users') + await database.close() + }) + + group.teardown(async () => { + await Folder.safeRemove(Path.config()) }) test('should be able to list tables and databases', async ({ assert }) => { @@ -91,12 +85,11 @@ test.group('PostgresDriverTest', group => { const databases = await database.getDatabases() assert.deepEqual(databases, ['postgres']) - assert.deepEqual(tables[0], 'pg_catalog.pg_statistic') + assert.isTrue(tables.includes('users')) }) test('should be able to create user and users', async ({ assert }) => { const user = await database.create({ - id: 1, name: 'João Lenon', email: 'lenonSec7@gmail.com', }) @@ -106,20 +99,17 @@ test.group('PostgresDriverTest', group => { assert.isNull(user.deletedAt) const users = await database.createMany([ - { id: 2, name: 'Victor Tesoura', email: 'txsoura@gmail.com' }, - { id: 3, name: 'Henry Bernardo', email: 'hbplay@gmail.com' }, + { name: 'Victor Tesoura', email: 'txsoura@gmail.com' }, + { name: 'Henry Bernardo', email: 'hbplay@gmail.com' }, ]) assert.lengthOf(users, 2) - assert.deepEqual(users[0].id, 2) - assert.deepEqual(users[1].id, 3) }) test('should be able to find user and users', async ({ assert }) => { const user = await database.buildWhere('id', 1).find() assert.deepEqual(user.id, 1) - assert.deepEqual(user.name, 'João Lenon') const users = await database.buildWhereIn('id', [1, 2]).buildOrderBy('id', 'DESC').findMany() @@ -128,8 +118,24 @@ test.group('PostgresDriverTest', group => { assert.deepEqual(users[1].id, 1) }) + test('should be able to get paginate users', async ({ assert }) => { + const { data, meta, links } = await database.buildWhereIn('id', [1, 2]).buildOrderBy('id', 'DESC').paginate() + + assert.lengthOf(data, 2) + assert.deepEqual(meta.itemCount, 2) + assert.deepEqual(meta.totalItems, 2) + assert.deepEqual(meta.totalPages, 1) + assert.deepEqual(meta.currentPage, 0) + assert.deepEqual(meta.itemsPerPage, 10) + + assert.deepEqual(links.first, '/?limit=10') + assert.deepEqual(links.previous, '/?page=0&limit=10') + assert.deepEqual(links.next, '/?page=1&limit=10') + assert.deepEqual(links.last, '/?page=1&limit=10') + }) + test('should be able to update user and users', async ({ assert }) => { - const [user] = await database.buildWhere('id', 1).update({ name: 'João Lenon Updated' }) + const user = await database.buildWhere('id', 1).update({ name: 'João Lenon Updated' }) assert.deepEqual(user.id, 1) assert.deepEqual(user.name, 'João Lenon Updated') @@ -143,24 +149,12 @@ test.group('PostgresDriverTest', group => { assert.deepEqual(users[1].name, 'João Lenon Updated') }) - test('should be able to delete/softDelete user and users', async ({ assert }) => { + test('should be able to delete user and users', async ({ assert }) => { await database.buildWhere('id', 3).delete() const notFoundUser = await database.buildWhere('id', 3).find() - assert.isUndefined(notFoundUser) - - await database.buildWhereIn('id', [1, 2]).delete(true) - - const users = await database.buildWhereIn('id', [1, 2]).findMany() - - assert.lengthOf(users, 2) - - assert.deepEqual(users[0].id, 1) - assert.isDefined(users[0].deletedAt) - - assert.deepEqual(users[1].id, 2) - assert.isDefined(users[1].deletedAt) + assert.isNull(notFoundUser) }) test('should be able to start/commit/rollback transactions', async ({ assert }) => { diff --git a/tests/Unit/UserModelTest.js b/tests/Unit/UserModelTest.js new file mode 100644 index 0000000..7dc8bda --- /dev/null +++ b/tests/Unit/UserModelTest.js @@ -0,0 +1,216 @@ +/** + * @athenna/database + * + * (c) João Lenon + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { test } from '@japa/runner' +import { Path, Folder, Config } from '@secjs/utils' + +import { Database } from '#src/index' +import { User } from '#tests/Stubs/models/User' +import { Product } from '#tests/Stubs/models/Product' + +test.group('UserModelTest', group => { + /** @type {Database} */ + let database = null + + group.setup(async () => { + await new Folder(Path.stubs('configs')).copy(Path.config()) + await new Config().safeLoad(Path.config('database.js')) + }) + + group.each.setup(async () => { + database = new Database() + + await database.connect() + + await database.createTable('users', { + columns: [ + { + name: 'id', + type: 'int', + isPrimary: true, + isGenerated: true, + generationStrategy: 'increment', + }, + { + name: 'name', + type: 'varchar', + }, + { + name: 'email', + isUnique: true, + type: 'varchar', + }, + { + name: 'createdAt', + type: 'timestamp', + default: 'now()', + }, + { + name: 'updatedAt', + type: 'timestamp', + default: 'now()', + }, + { + name: 'deletedAt', + type: 'timestamp', + isNullable: true, + default: null, + }, + ], + }) + await database.createTable('products', { + columns: [ + { + name: 'id', + type: 'int', + isPrimary: true, + isGenerated: true, + generationStrategy: 'increment', + }, + { + name: 'name', + type: 'varchar', + }, + { + name: 'userId', + type: 'int', + }, + { + name: 'createdAt', + type: 'timestamp', + default: 'now()', + }, + { + name: 'updatedAt', + type: 'timestamp', + default: 'now()', + }, + { + name: 'deletedAt', + type: 'timestamp', + isNullable: true, + default: null, + }, + ], + }) + + database.buildTable('users') + + await User.factory().count(10).create() + }) + + group.each.teardown(async () => { + await database.dropTable('users') + await database.dropTable('products') + await database.close() + }) + + group.teardown(async () => { + await Folder.safeRemove(Path.config()) + }) + + test('should be able to create user and users', async ({ assert }) => { + const user = await User.create({ + name: 'João Lenon', + email: 'lenonSec7@gmail.com', + }) + + assert.isDefined(user.createdAt) + assert.isDefined(user.updatedAt) + assert.isNull(user.deletedAt) + + const users = await User.createMany([ + { name: 'Victor Tesoura', email: 'txsoura@gmail.com' }, + { name: 'Henry Bernardo', email: 'hbplay@gmail.com' }, + ]) + + assert.lengthOf(users, 2) + }) + + test('should be able to find user and users', async ({ assert }) => { + const user = await User.find({ id: 1 }) + + assert.isUndefined(user.email) + assert.deepEqual(user.id, 1) + + const users = await User.query().addSelect('email').whereIn('id', [1, 2]).orderBy('id', 'DESC').findMany() + + assert.lengthOf(users, 2) + assert.isDefined(users[0].email) + assert.isDefined(users[1].email) + assert.deepEqual(users[0].id, 2) + assert.deepEqual(users[1].id, 1) + }) + + test('should be able to get paginate users', async ({ assert }) => { + const { data, meta, links } = await User.query().whereIn('id', [1, 2]).orderBy('id', 'DESC').paginate() + + assert.lengthOf(data, 2) + assert.deepEqual(meta.itemCount, 2) + assert.deepEqual(meta.totalItems, 2) + assert.deepEqual(meta.totalPages, 1) + assert.deepEqual(meta.currentPage, 0) + assert.deepEqual(meta.itemsPerPage, 10) + + assert.deepEqual(links.first, '/?limit=10') + assert.deepEqual(links.previous, '/?page=0&limit=10') + assert.deepEqual(links.next, '/?page=1&limit=10') + assert.deepEqual(links.last, '/?page=1&limit=10') + }) + + test('should be able to update user and users', async ({ assert }) => { + const { createdAt } = await User.find({ id: 1 }) + + const user = await User.update({ id: 1 }, { name: 'João Lenon Updated', createdAt: new Date() }) + + assert.deepEqual(user.createdAt, createdAt) + assert.deepEqual(user.id, 1) + assert.deepEqual(user.name, 'João Lenon Updated') + + const usersDates = await User.query().whereIn('id', [1, 2]).findMany() + const users = await User.query().whereIn('id', [1, 2]).update({ name: 'João Lenon Updated', createdAt: new Date() }) + + assert.deepEqual(users[0].createdAt, usersDates[0].createdAt) + assert.deepEqual(users[1].createdAt, usersDates[1].createdAt) + assert.lengthOf(users, 2) + assert.deepEqual(users[0].id, 1) + assert.deepEqual(users[0].name, 'João Lenon Updated') + assert.deepEqual(users[1].id, 2) + assert.deepEqual(users[1].name, 'João Lenon Updated') + }) + + test('should be able to delete/softDelete user and users', async ({ assert }) => { + await User.delete({ id: 3 }, true) + + const notFoundUser = await User.find({ id: 3 }) + + assert.isNull(notFoundUser) + + await User.query().whereIn('id', [1, 2]).delete() + + const users = await User.query().removeCriteria('deletedAt').whereIn('id', [1, 2]).findMany() + + assert.lengthOf(users, 2) + + assert.deepEqual(users[0].id, 1) + assert.isDefined(users[0].deletedAt) + + assert.deepEqual(users[1].id, 2) + assert.isDefined(users[1].deletedAt) + }) + + test('should be able to add products to user', async ({ assert }) => { + await Product.create({ name: 'iPhone X', userId: 1 }) + + const user = await User.query().where({ id: 1 }).includes('products').find() + + assert.deepEqual(user.products[0].id, 1) + assert.deepEqual(user.products[0].name, 'iPhone X') + }) +})