From 7c7944b1b674f94e0277a7cebb08e415acc5a741 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Douglas=20Gad=C3=AAlha?= Date: Fri, 18 Nov 2022 15:26:51 -0300 Subject: [PATCH] feat: initial version based on knex-repository api is 1:1 compatible --- .github/workflows/release.yml | 24 +++ .github/workflows/release_dev.yml | 25 +++ .github/workflows/spec.yml | 38 ++++ README.md | 1 + package.json | 61 ++++++ spec/BaseRepository.test.ts | 159 +++++++++++++++ src/BaseRepository.ts | 310 ++++++++++++++++++++++++++++++ src/index.ts | 1 + 8 files changed, 619 insertions(+) create mode 100644 .github/workflows/release.yml create mode 100644 .github/workflows/release_dev.yml create mode 100644 .github/workflows/spec.yml create mode 100644 README.md create mode 100644 package.json create mode 100644 spec/BaseRepository.test.ts create mode 100644 src/BaseRepository.ts create mode 100644 src/index.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..688da06 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,24 @@ +name: release +on: + release: + types: [published] +jobs: + publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-node@v3 + with: + registry-url: https://registry.npmjs.org + node-version: 16 + - run: npm install + - run: | + npm install -g json && json -I -f package.json -e ' + this.version = "${{ github.ref }}".replace("refs/tags/", ""); + this.main = "dist/src/index.js"; + this.types = "dist/src/index.d.ts"; + ' + - run: npm run build + - run: npm publish + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_AUTH_TOKEN }} diff --git a/.github/workflows/release_dev.yml b/.github/workflows/release_dev.yml new file mode 100644 index 0000000..674daac --- /dev/null +++ b/.github/workflows/release_dev.yml @@ -0,0 +1,25 @@ +name: release_dev +on: + push: + branches: + - master +jobs: + publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-node@v3 + with: + registry-url: https://registry.npmjs.org + node-version: 16 + - run: npm install + - run: | + npm install -g json && json -I -f package.json -e ' + this.version = "0.0.0-dev.'$(date -u +'%Y%m%d%H%M%S')'"; + this.main = "dist/src/index.js"; + this.types = "dist/src/index.d.ts"; + ' + - run: npm run build + - run: npm publish --tag dev + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_AUTH_TOKEN }} diff --git a/.github/workflows/spec.yml b/.github/workflows/spec.yml new file mode 100644 index 0000000..0df02f5 --- /dev/null +++ b/.github/workflows/spec.yml @@ -0,0 +1,38 @@ +name: "spec" +on: [push, pull_request] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + node: + - 14 + - 16 + - 18 + postgres: + - 12-alpine + - 13-alpine + - 14-alpine + - 15-alpine + services: + postgres: + image: postgres:${{ matrix.postgres }} + env: + POSTGRES_HOST_AUTH_METHOD: trust + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-node@v3 + with: + node-version: ${{ matrix.node }} + - run: npm install + - run: npx tsc -noEmit + - run: npm run eslint:check + - run: npm test diff --git a/README.md b/README.md new file mode 100644 index 0000000..e976d7e --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +# `@cubos/kysely-repository` diff --git a/package.json b/package.json new file mode 100644 index 0000000..7efd7df --- /dev/null +++ b/package.json @@ -0,0 +1,61 @@ +{ + "name": "@cubos/kysely-repository", + "version": "0.0.0", + "description": "A set of repository classes to make it easier to interact with your database with kysely", + "main": "src/index.ts", + "scripts": { + "test": "jest", + "eslint:fix": "eslint --fix '{src,spec}/**/*.ts'", + "eslint:check": "eslint '{src,spec}/**/*.ts'", + "build": "tsc", + "postgres:start": "docker run -d -p 5432:5432 --name postgres -e POSTGRES_HOST_AUTH_METHOD=trust postgres:15-alpine" + }, + "keywords": [], + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/cubos/node-kysely-repository.git" + }, + "bugs": { + "url": "https://github.com/cubos/node-kysely-repository/issues" + }, + "publishConfig": { + "access": "public" + }, + "homepage": "https://github.com/cubos/node-kysely-repository#readme", + "dependencies": { + "kysely": "^0.22.0" + }, + "devDependencies": { + "@cubos/eslint-config": "^2.0.664038", + "@types/jest": "^29.2.2", + "@types/lodash": "^4.14.188", + "@types/node": "^18.11.9", + "@types/pg": "^8.6.5", + "jest": "^29.2.2", + "jest-extended": "^3.1.0", + "pg": "^8.8.0", + "ts-jest": "^29.0.3", + "typescript": "^4.8.4" + }, + "jest": { + "preset": "ts-jest", + "modulePaths": [ + "/src/" + ], + "testEnvironment": "node", + "testMatch": [ + "**/spec/**/*.ts" + ], + "verbose": true, + "testTimeout": 60000, + "collectCoverage": true, + "coverageReporters": [ + "text", + "lcov" + ], + "setupFilesAfterEnv": [ + "jest-extended" + ] + } +} diff --git a/spec/BaseRepository.test.ts b/spec/BaseRepository.test.ts new file mode 100644 index 0000000..50be9f4 --- /dev/null +++ b/spec/BaseRepository.test.ts @@ -0,0 +1,159 @@ +import { randomBytes } from "crypto"; + +import { Kysely, PostgresDialect, sql } from "kysely"; +import type { PoolConfig } from "pg"; +import { Pool } from "pg"; + +import { BaseRepository } from "../src"; +import type { BaseModel } from "../src/BaseRepository"; + +const poolConfig: PoolConfig = { + database: "postgres", + host: process.env.DB_HOST ?? "localhost", + password: process.env.DB_PASSWORD ?? "postgres", + user: process.env.DB_USER ?? "postgres", +}; + +type Database = Record; + +function randomTableName() { + return `test${randomBytes(8).toString("hex")}`; +} + +describe("BaseRepository", () => { + const database = `test_${randomBytes(8).toString("hex")}`; + const masterConn = new Kysely({ dialect: new PostgresDialect({ pool: new Pool(poolConfig) }) }); + const conn = new Kysely({ dialect: new PostgresDialect({ pool: new Pool({ ...poolConfig, database }) }) }); + + beforeAll(async () => { + await sql`CREATE DATABASE ${sql.raw(database)}`.execute(masterConn); + }); + + afterAll(async () => { + await conn.destroy(); + await sql`DROP DATABASE ${sql.raw(database)}`.execute(masterConn); + await masterConn.destroy(); + }); + + it("inserts, finds, updates and deletes objects", async () => { + const tableName = randomTableName(); + + await BaseRepository.createTable(conn, tableName, table => { + return table.addColumn("name", "text"); + }); + + interface Model extends BaseModel { + name: string; + } + + type TestDb = Record; + + const repo = new BaseRepository(conn as Kysely, tableName); + + expect(await repo.findAll()).toHaveLength(0); + + const inserted = await repo.insert({ name: "foo" }); + + expect(inserted.name).toBe("foo"); + expect(inserted.createdAt).toBeInstanceOf(Date); + expect(inserted.updatedAt).toBeInstanceOf(Date); + expect(inserted.updatedAt.getTime()).toBe(inserted.updatedAt.getTime()); + expect(inserted.id).toBeDefined(); + expect(await repo.findAll()).toEqual([inserted]); + + const updated = await repo.update({ id: inserted.id, name: "bar" }); + + expect(updated.name).toBe("bar"); + expect(updated.id).toBe(inserted.id); + expect(updated.createdAt.getTime()).toBe(inserted.createdAt.getTime()); + expect(updated.updatedAt.getTime()).toBeGreaterThan(inserted.updatedAt.getTime()); + expect(await repo.findAll()).toEqual([updated]); + expect(await repo.findBy({ name: "bar" })).toEqual([updated]); + expect(await repo.findOneBy({ name: "bar" })).toEqual(updated); + expect(await repo.get(inserted.id)).toEqual(updated); + expect(await repo.findBy({ name: "foo" })).toEqual([]); + expect(await repo.count()).toBe(1); + + const deleted = await repo.delete(updated.id); + + expect(updated).toEqual(deleted); + expect(await repo.findAll()).toEqual([]); + expect(await repo.count()).toBe(0); + + await expect(repo.update({ id: inserted.id, name: "baz" })).rejects.toThrowError("no result"); + await expect(repo.delete(updated.id)).rejects.toThrowError("no result"); + }); + + it("allows altering tables", async () => { + const tableName = randomTableName(); + + await BaseRepository.createTable(conn, tableName, table => { + return table.addColumn("name", "text"); + }); + + interface Model1 extends BaseModel { + name: string; + } + + type TestDb1 = Record; + + await new BaseRepository(conn as Kysely, tableName).insert({ name: "foo" }); + + await BaseRepository.alterTable(conn, tableName, table => { + return table.addColumn("age", "integer").defaultTo(20); + }); + + interface Model2 extends BaseModel { + name: string; + age: number; + } + + type TestDb2 = Record; + + const [row] = await new BaseRepository(conn as Kysely, tableName).findAll(); + + expect(row.name).toBe("foo"); + expect(row.age).toBe(20); + + await BaseRepository.dropTable(conn, tableName); + }); + + it("inserts and deletes many", async () => { + const tableName = randomTableName(); + + await BaseRepository.createTable(conn, tableName, table => { + return table.addColumn("value", "integer"); + }); + + interface Model extends BaseModel { + value: number; + } + + type TestDb = Record; + + const repo = new BaseRepository(conn as Kysely, tableName); + + const objectsToInsert = new Array(10).fill(0).map((_, index) => ({ value: index })); + + const insertedObjects = await repo.insertAll(objectsToInsert); + + expect(insertedObjects.map(x => x.value)).toEqual(objectsToInsert.map(x => x.value)); + expect(new Set(insertedObjects.map(x => x.id)).size).toEqual(objectsToInsert.length); + expect(new Set(insertedObjects.map(x => x.createdAt.getTime())).size).toEqual(1); + expect(new Set(insertedObjects.map(x => x.updatedAt.getTime())).size).toEqual(1); + + const deletedObjects = await repo.deleteBy(item => item.where("value", ">=", 5)); + + expect(deletedObjects).toEqual(insertedObjects.slice(5)); + expect(await repo.findAll()).toEqual(expect.arrayContaining(insertedObjects.slice(0, 5))); + + const moreDeletedObjects = await repo.deleteBy({ value: 0 }); + + expect(moreDeletedObjects).toEqual([insertedObjects[0]]); + expect(await repo.findAll()).toEqual(expect.arrayContaining(insertedObjects.slice(1, 5))); + + await repo.truncate(); + + expect(await repo.findAll()).toEqual([]); + }); +}); diff --git a/src/BaseRepository.ts b/src/BaseRepository.ts new file mode 100644 index 0000000..91da01b --- /dev/null +++ b/src/BaseRepository.ts @@ -0,0 +1,310 @@ +import { randomUUID } from "crypto"; + +import type { AlterTableBuilder, AlterTableExecutor, CreateTableBuilder, InsertObject, Kysely, SelectQueryBuilder, Transaction } from "kysely"; +import { sql } from "kysely"; + +export interface BaseModel { + id: string; + createdAt: Date; + updatedAt: Date; +} + +type Insert = Omit & { id?: T["id"] }; +type Filter = Partial>; +type Update = Filter & { id: T["id"] }; + +export class BaseRepository, TableName extends keyof Database & string> { + constructor(private readonly db: Kysely>, private readonly tableName: TableName) {} + + static async createTable( + db: Kysely, + tableName: string, + tableBuilder: (table: CreateTableBuilder) => CreateTableBuilder, + idColumnType: "text" | "uuid" = "uuid", + ) { + let op = db.schema + .createTable(tableName) + .addColumn("id", idColumnType, col => col.primaryKey()) + .addColumn("createdAt", "timestamptz", col => col.notNull().defaultTo(sql`NOW()`)) + .addColumn("updatedAt", "timestamptz", col => col.notNull().defaultTo(sql`NOW()`)); + + op = tableBuilder(op); + + await op.execute(); + } + + static async dropTable(db: Kysely, tableName: string) { + await db.schema.dropTable(tableName).execute(); + } + + static async alterTable( + db: Kysely, + tableName: string, + tableBuilder: (tableBuilder: AlterTableBuilder) => Omit, + ) { + const op = db.schema.alterTable(tableName); + const res = tableBuilder(op); + + await res.execute(); + } + + withTransaction(db: Transaction>) { + return new BaseRepository(db, this.tableName); + } + + private select() { + return this.db.selectFrom(this.tableName); + } + + /** + * Insere um objeto e retorna a instância criada + * + * @param item objeto a ser inserido + * @returns instância do objeto criado + */ + async insert(item: Insert) { + const now = new Date(); + + const [result] = await this.db + .insertInto(this.tableName) + .values({ + id: randomUUID(), + ...item, + createdAt: now, + updatedAt: now, + } as InsertObject, TableName>) + .returningAll() + .execute(); + + return result; + } + + /** + * Insere múltiplos objetos e retorna as instâncias criadas + * + * @param items objetos a serem inseridos + * @returns instância dos objetos criados + */ + async insertAll(items: Array>) { + const now = new Date(); + + return this.db + .insertInto(this.tableName) + .values( + items.map(item => ({ + id: randomUUID(), + ...item, + createdAt: now, + updatedAt: now, + })), + ) + .returningAll() + .execute(); + } + + /** + * Obtém a última versão de um objeto através de parâmetro(s) do mesmo + * + * @param condition condição de busca através de parâmetros ou com um callback do knex + * @returns instância do objeto ou undefined se não encontrado + */ + async findOneBy( + condition: + | Filter + | ((qb: SelectQueryBuilder) => SelectQueryBuilder) = {}, + ) { + let op = this.select().selectAll(); + + if (typeof condition === "function") { + op = condition(op as any) as any; + } else { + for (const [key, value] of Object.entries(condition)) { + op = op.where(key as any, "=", value); + } + } + + return op.executeTakeFirst(); + } + + /** + * Obtém uma sequência de objetos de acordo com um limite e pagina de busca + * + * @param page página na qual deseja-se realizar a busca + * @param pageSize limite de itens retornados pela busca + * @param condition parâmetros dos objetos a serem utilizadas na condição de busca + * @param queryBuilder callback síncrono possibilitando adicionar mais parâmetros na condição de busca + * @returns objeto contendo o resultado e configurações da pesquisa + */ + async findAllPaginated( + page = 1, + pageSize = 10, + condition: + | Filter + | ((qb: SelectQueryBuilder) => SelectQueryBuilder) = {}, + ) { + const rowCount = await this.count(condition); + let op = this.select().selectAll(); + + if (typeof condition === "function") { + op = condition(op as any) as any; + } else { + for (const [key, value] of Object.entries(condition)) { + op = op.where(key as any, "=", value); + } + } + + const result = await op + .limit(pageSize) + .offset((page - 1) * pageSize) + .execute(); + + return { + data: result, + page, + pageCount: Math.ceil(rowCount / pageSize), + pageSize, + rowCount, + }; + } + + /** + * Obtém a contagem de objetos através de parâmetro(s) do mesmo + * + * @param condition condição de busca através de parâmetros ou com um callback do knex + * @returns contagem de objetos + */ + async count( + condition: + | Filter + | ((qb: SelectQueryBuilder) => SelectQueryBuilder) = {}, + ) { + let op = this.select(); + + if (typeof condition === "function") { + op = condition(op as any) as any; + } else { + for (const [key, value] of Object.entries(condition)) { + op = op.where(key as any, "=", value); + } + } + + op = op.select(sql`COUNT(*) AS count` as any); + + const { count } = (await op.executeTakeFirst()) as { count: string | undefined }; + + return parseInt((count ?? "0").toString(), 10); + } + + /** + * Obtém a última versão de todos os objetos + * + * @returns array com a instância dos objetos + */ + async findAll() { + return this.select().selectAll().execute(); + } + + /** + * Obtém a última versão de alguns objetos através de parâmetro(s) dos mesmos + * + * @param condition condição de busca através de parâmetros ou com um callback do knex + * @returns array com a instância dos objetos encontrados + */ + async findBy( + condition: + | Filter + | ((qb: SelectQueryBuilder) => SelectQueryBuilder), + ) { + let op = this.select().selectAll(); + + if (typeof condition === "function") { + op = condition(op as any) as any; + } else { + for (const [key, value] of Object.entries(condition)) { + op = op.where(key as any, "=", value); + } + } + + return op.execute(); + } + + /** + * Obtém a última versão de um objeto através do identificador + * + * @param id identificador do objeto + * @returns instância do objeto ou undefined se não encontrado + */ + async get(id: Database[TableName]["id"]) { + return this.select() + .selectAll() + .where("id", "=", id as any) + .executeTakeFirst(); + } + + /** + * Atualiza uma instância de um objeto + * + * @param item objeto a ser atualizado + * @returns objeto atualizado + * @throws NoResultError + */ + async update(item: Update) { + const updatedItem = await this.db + .updateTable(this.tableName) + .where("id", "=", item.id as any) + .set({ + ...item, + updatedAt: new Date(), + } as any) + .returningAll() + .executeTakeFirstOrThrow(); + + return updatedItem; + } + + /** + * Exclui a instância de um objeto através do identificador + * + * @param id identificador do objeto + * @returns objeto excluído + * @throws NoResultError + */ + async delete(id: Database[TableName]["id"]) { + return this.db + .deleteFrom(this.tableName) + .where("id", "=", id as any) + .returningAll() + .executeTakeFirstOrThrow(); + } + + /** + * Exclui múltiplas instâncias de um objeto através de uma condição + * + * @param condition condição de busca através de parâmetros ou com um callback do knex + * @returns objetos excluídos + */ + async deleteBy( + condition: + | Filter + | ((qb: SelectQueryBuilder) => SelectQueryBuilder), + ) { + let op = this.db.deleteFrom(this.tableName); + + if (typeof condition === "function") { + op = condition(op as any) as any; + } else { + for (const [key, value] of Object.entries(condition)) { + op = op.where(key as any, "=", value); + } + } + + return op.returningAll().execute(); + } + + /** + * Exclui todos os objetos da tabela. + */ + async truncate() { + await this.db.deleteFrom(this.tableName).execute(); + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..9378f0d --- /dev/null +++ b/src/index.ts @@ -0,0 +1 @@ +export { BaseRepository, BaseModel } from "./BaseRepository";