diff --git a/README.md b/README.md index 238ea6d..8211f0a 100644 --- a/README.md +++ b/README.md @@ -78,10 +78,10 @@ await db.syncDB(useSchema(DBSchema, { logger: false })) await db.syncDB(useMigrator(new FileMigrationProvider('./migrations'), {/* options */})) ``` -sync options type: +Schema sync options type: ```ts -export type SyncOptions = { +export type SchemaSyncOptions = { /** * Whether to enable debug logger */ @@ -245,21 +245,21 @@ const softDeleteTable = defineTable({ const softDeleteSchema = { testSoftDelete: softDeleteTable, } -const { executor, whereExists, whereDeleted } = createSoftDeleteExecutor() -const db = new SqliteBuilder>({ +const db = new SoftDeleteSqliteBuilder>({ dialect: new SqliteDialect({ database: new Database(':memory:'), }), - // use soft delete executor - executor, }) await db.deleteFrom('testSoftDelete').where('id', '=', 1).execute() // update "testSoftDelete" set "isDeleted" = 1 where "id" = 1 -// If you are using original kysely instance: -await db.kysely.selectFrom('testSoftDelete').selectAll().$call(whereExists).execute() +// if you are using original kysely instance: +await db.kysely.selectFrom('testSoftDelete').selectAll().$call(db.whereExists).execute() + +// fix `DeleteResult` runtime type +db.toDeleteResult(await db.deleteFrom('testSoftDelete').where('id', '=', 1).execute()) ``` ### Page Query diff --git a/src/builder.ts b/src/builder/base.ts similarity index 53% rename from src/builder.ts rename to src/builder/base.ts index 2c923d1..ab6a932 100644 --- a/src/builder.ts +++ b/src/builder/base.ts @@ -1,34 +1,26 @@ import type { Promisable } from '@subframe7536/type-utils' import type { CompiledQuery, - DeleteQueryBuilder, - DeleteResult, Dialect, + JoinType, KyselyPlugin, QueryResult, RawBuilder, Transaction, } from 'kysely' -import type { - ExtractTableAlias, - From, - FromTables, - TableReference, -} from 'kysely/dist/cjs/parser/table-parser' import { Kysely } from 'kysely' import { BaseSerializePlugin } from 'kysely-plugin-serialize' -import { baseExecutor, type Executor, type JoinFnName } from './executor' -import { createKyselyLogger, type LoggerOptions } from './logger' -import { checkIntegrity as runCheckIntegrity } from './pragma' -import { savePoint } from './savepoint' -import { defaultDeserializer, defaultSerializer } from './serialize' +import { createKyselyLogger, type LoggerOptions } from '../logger' +import { checkIntegrity as runCheckIntegrity } from '../pragma' +import { savePoint } from '../savepoint' +import { defaultDeserializer, defaultSerializer } from '../serialize' import { type DBLogger, IntegrityError, type SchemaUpdater, type StatusResult, -} from './types' -import { executeSQL } from './utils' +} from '../types' +import { executeSQL } from '../utils' export type SqliteBuilderOptions = { /** @@ -52,22 +44,6 @@ export type SqliteBuilderOptions = { * DB logger */ logger?: DBLogger - /** - * Custom executor - * @example - * import { SqliteBuilder, createSoftDeleteExecutor } from 'kysely-sqlite-builder' - * - * const { executor, whereExists } = createSoftDeleteExecutor() - * - * const db = new SqliteBuilder({ - * dialect: new SqliteDialect({ - * database: new Database(':memory:'), - * }), - * // use soft delete executor - * executor, - * }) - */ - executor?: Executor } interface TransactionOptions { @@ -82,12 +58,18 @@ interface TransactionOptions { onRollback?: (err: unknown) => Promisable } -export class SqliteBuilder> { +type CamelCase = S extends `${infer First}${infer Rest}` + ? First extends Uppercase + ? `${Lowercase}${Rest}` + : S + : S +export type JoinFnName = CamelCase + +export class BaseSqliteBuilder> { public trxCount = 0 private ky: Kysely private trx?: Transaction private log?: DBLogger - private e: Executor /** * Current kysely / transaction instance @@ -96,136 +78,12 @@ export class SqliteBuilder> { return this.trx || this.ky } - public insertInto: Kysely['insertInto'] = tb => this.e.insertInto(this.kysely, tb) - public replaceInto: Kysely['replaceInto'] = tb => this.e.replaceInto(this.kysely, tb) - public selectFrom: Kysely['selectFrom'] = (tb: any) => this.e.selectFrom(this.kysely, tb) - public updateTable: Kysely['updateTable'] = (tb: any) => this.e.updateTable(this.kysely, tb) - /** - * Creates a delete query. - * - * See the {@link DeleteQueryBuilder.where} method for examples on how to specify - * a where clause for the delete operation. - * - * The return value of the query is an instance of {@link DeleteResult}. - * - * ### Examples - * - * - * - * Delete a single row: - * - * ```ts - * const result = await db - * .deleteFrom('person') - * .where('person.id', '=', '1') - * .executeTakeFirst() - * - * console.log(result.numDeletedRows) - * ``` - * - * The generated SQL (PostgreSQL): - * - * ```sql - * delete from "person" where "person"."id" = $1 - * ``` - */ - public deleteFrom: { - (from: TR): Omit< - DeleteQueryBuilder, DeleteResult>, - JoinFnName - > - >(table: TR): Omit< - DeleteQueryBuilder, FromTables, DeleteResult>, - JoinFnName - > - } = (tb: any) => this.e.deleteFrom(this.kysely, tb) as any - - /** - * SQLite builder. All methods will run in current transaction - * @param options options - * @example - * ### Definition - * - * ```ts - * import { FileMigrationProvider, SqliteDialect, createSoftDeleteExecutor } from 'kysely' - * import { SqliteBuilder } from 'kysely-sqlite-builder' - * import { useMigrator } from 'kysely-sqlite-builder/migrator' - * import Database from 'better-sqlite3' - * import type { InferDatabase } from 'kysely-sqlite-builder/schema' - * import { DataType, column, defineTable } from 'kysely-sqlite-builder/schema' - * - * const testTable = defineTable({ - * columns: { - * id: column.increments(), - * person: column.object({ defaultTo: { name: 'test' } }), - * gender: column.boolean({ notNull: true }), - * // or just object - * manual: { type: DataType.boolean }, - * array: column.object().$cast(), - * literal: column.string().$cast<'l1' | 'l2'>(), - * score: column.float(), - * birth: column.date(), - * buffer: column.blob(), - * }, - * primary: 'id', // optional - * index: ['person', ['id', 'gender']], - * timeTrigger: { create: true, update: true }, - * }) - * - * const DBSchema = { - * test: testTable, - * } - * - * // create soft delete executor - * const { executor, whereExists } = createSoftDeleteExecutor() - * - * const db = new SqliteBuilder>({ - * dialect: new SqliteDialect({ - * database: new Database(':memory:'), - * }), - * logger: console, - * onQuery: true, - * executor, // use soft delete executor - * }) - * - * // update table using schema - * await db.syncDB(useSchema(DBSchema, { logger: false })) - * - * // update table using migrator - * await db.syncDB(useMigrator(new FileMigrationProvider('./migrations'), { options})) - * - * // usage: insertInto / selectFrom / updateTable / deleteFrom - * await db.insertInto('test').values({ person: { name: 'test' }, gender: true }).execute() - * - * db.transaction(async (trx) => { - * // auto load transaction - * await db.insertInto('test').values({ gender: true }).execute() - * // or - * await trx.insertInto('test').values({ person: { name: 'test' }, gender: true }).execute() - * db.transaction(async () => { - * // nest transaction, use savepoint - * await db.selectFrom('test').where('gender', '=', true).execute() - * }) - * }) - * - * // use origin instance: Kysely or Transaction - * await db.kysely.insertInto('test').values({ gender: false }).execute() - * - * // run raw sql - * await db.execute(sql`PRAGMA user_version = ${2}`) - * await db.execute('PRAGMA user_version = ?', [2]) - * - * // destroy - * await db.destroy() - * ``` - */ public constructor(options: SqliteBuilderOptions) { const { dialect, logger, onQuery, plugins = [], - executor = baseExecutor, } = options this.log = logger plugins.push(new BaseSerializePlugin({ @@ -245,7 +103,6 @@ export class SqliteBuilder> { } this.ky = new Kysely({ dialect, log, plugins }) - this.e = executor } /** diff --git a/src/builder/index.ts b/src/builder/index.ts new file mode 100644 index 0000000..9df7280 --- /dev/null +++ b/src/builder/index.ts @@ -0,0 +1,3 @@ +export * from './base' +export * from './normal' +export * from './softDelete' diff --git a/src/builder/normal.ts b/src/builder/normal.ts new file mode 100644 index 0000000..551f813 --- /dev/null +++ b/src/builder/normal.ts @@ -0,0 +1,125 @@ +import type { DeleteQueryBuilder, DeleteResult, Kysely } from 'kysely' +import type { ExtractTableAlias, From, FromTables, TableReference } from 'kysely/dist/cjs/parser/table-parser' +import { BaseSqliteBuilder, type JoinFnName } from './base' + +/** + * SQLite builder. All methods will run in current transaction + * @param options options + * @example + * ### Definition + * + * ```ts + * import { FileMigrationProvider, SqliteDialect, createSoftDeleteExecutor } from 'kysely' + * import { SqliteBuilder } from 'kysely-sqlite-builder' + * import { useMigrator } from 'kysely-sqlite-builder/migrator' + * import Database from 'better-sqlite3' + * import type { InferDatabase } from 'kysely-sqlite-builder/schema' + * import { DataType, column, defineTable } from 'kysely-sqlite-builder/schema' + * + * const testTable = defineTable({ + * columns: { + * id: column.increments(), + * person: column.object({ defaultTo: { name: 'test' } }), + * gender: column.boolean({ notNull: true }), + * // or just object + * manual: { type: DataType.boolean }, + * array: column.object().$cast(), + * literal: column.string().$cast<'l1' | 'l2'>(), + * score: column.float(), + * birth: column.date(), + * buffer: column.blob(), + * }, + * primary: 'id', // optional + * index: ['person', ['id', 'gender']], + * timeTrigger: { create: true, update: true }, + * }) + * + * const DBSchema = { + * test: testTable, + * } + * + * const db = new SqliteBuilder>({ + * dialect: new SqliteDialect({ + * database: new Database(':memory:'), + * }), + * logger: console, + * onQuery: true, + * executor, // use soft delete executor + * }) + * + * // update table using schema + * await db.syncDB(useSchema(DBSchema, { logger: false })) + * + * // update table using migrator + * await db.syncDB(useMigrator(new FileMigrationProvider('./migrations'), { options})) + * + * // usage: insertInto / selectFrom / updateTable / deleteFrom + * await db.insertInto('test').values({ person: { name: 'test' }, gender: true }).execute() + * + * db.transaction(async (trx) => { + * // auto load transaction + * await db.insertInto('test').values({ gender: true }).execute() + * // or + * await trx.insertInto('test').values({ person: { name: 'test' }, gender: true }).execute() + * db.transaction(async () => { + * // nest transaction, use savepoint + * await db.selectFrom('test').where('gender', '=', true).execute() + * }) + * }) + * + * // use origin instance: Kysely or Transaction + * await db.kysely.insertInto('test').values({ gender: false }).execute() + * + * // run raw sql + * await db.execute(sql`PRAGMA user_version = ${2}`) + * await db.execute('PRAGMA user_version = ?', [2]) + * + * // destroy + * await db.destroy() + * ``` + */ +export class SqliteBuilder> extends BaseSqliteBuilder { + public insertInto: Kysely['insertInto'] = tb => this.kysely.insertInto(tb) + public replaceInto: Kysely['replaceInto'] = tb => this.kysely.replaceInto(tb) + public selectFrom: Kysely['selectFrom'] = (tb: any) => this.kysely.selectFrom(tb) as any + public updateTable: Kysely['updateTable'] = (tb: any) => this.kysely.updateTable(tb) as any + /** + * Creates a delete query. + * + * See the {@link DeleteQueryBuilder.where} method for examples on how to specify + * a where clause for the delete operation. + * + * The return value of the query is an instance of {@link DeleteResult}. + * + * ### Examples + * + * + * + * Delete a single row: + * + * ```ts + * const result = await db + * .deleteFrom('person') + * .where('person.id', '=', '1') + * .executeTakeFirst() + * + * console.log(result.numDeletedRows) + * ``` + * + * The generated SQL (SQLite): + * + * ```sql + * delete from "person" where "person"."id" = $1 + * ``` + */ + public deleteFrom: { + (from: TR): Omit< + DeleteQueryBuilder, DeleteResult>, + JoinFnName + > + >(table: TR): Omit< + DeleteQueryBuilder, FromTables, DeleteResult>, + JoinFnName + > + } = (tb: any) => this.kysely.deleteFrom(tb) as any +} diff --git a/src/builder/softDelete.ts b/src/builder/softDelete.ts new file mode 100644 index 0000000..b8b94f6 --- /dev/null +++ b/src/builder/softDelete.ts @@ -0,0 +1,92 @@ +import type { ExtractTableAlias, From, FromTables, TableReference } from 'kysely/dist/cjs/parser/table-parser' +import { type DeleteQueryBuilder, DeleteResult, type Kysely, type UpdateResult, type WhereInterface } from 'kysely' +import { BaseSqliteBuilder, type JoinFnName, type SqliteBuilderOptions } from './base' + +interface SoftDeleteSqliteBuilderOptions extends SqliteBuilderOptions { + /** + * Delete column name + * @default 'isDeleted' + */ + deleteColumnName?: string +} + +/** + * {@link SqliteBuilder} with soft delete + */ +export class SoftDeleteSqliteBuilder> extends BaseSqliteBuilder { + private col: string + /** + * Filters rows that are not soft deleted + */ + public whereExists: (qb: T) => T + /** + * Filters rows that are soft deleted + */ + public whereDeleted: (qb: T) => T + constructor(options: SoftDeleteSqliteBuilderOptions) { + super(options) + const delCol = options.deleteColumnName || 'isDeleted' + this.col = delCol + this.whereExists = (qb: T) => (qb as WhereInterface).where(delCol, '=', 0) as T + this.whereDeleted = (qb: T) => (qb as WhereInterface).where(delCol, '=', 1) as T + } + + public insertInto: Kysely['insertInto'] = tb => this.kysely.insertInto(tb) + public replaceInto: Kysely['replaceInto'] = tb => this.kysely.replaceInto(tb) + public selectFrom: Kysely['selectFrom'] = (tb: any) => this.kysely.selectFrom(tb).where(this.col, '=', 0 as any) as any + public updateTable: Kysely['updateTable'] = (tb: any) => this.kysely.updateTable(tb).where(this.col, '=', 0 as any) as any + /** + * Creates a soft delete query. + * + * See the {@link DeleteQueryBuilder.where} method for examples on how to specify + * a where clause for the delete operation. + * + * The return value of the query is an instance of {@link DeleteResult}. + * + * ### Examples + * + * + * + * Delete a single row: + * + * ```ts + * const result = await db + * .deleteFrom('person') + * .where('person.id', '=', '1') + * .executeTakeFirst() + * + * console.log(result.numDeletedRows) + * ``` + * + * The generated SQL (SQLite): + * + * ```sql + * update "person" set "isDeleted" = 1 where "person"."id" = $1 + * ``` + */ + public deleteFrom: { + (from: TR): Omit< + DeleteQueryBuilder, DeleteResult>, + JoinFnName + > + >(table: TR): Omit< + DeleteQueryBuilder, FromTables, DeleteResult>, + JoinFnName + > + } = (tb: any) => this.kysely.updateTable(tb).set(this.col, 1 as any) as any + + /** + * Fix `DeleteResult` runtime type + * @param result original `DeleteResult` + * @example + * db.toDeleteResult( + * await db + * .deleteFrom('testSoftDelete') + * .where('id', '=', 1) + * .execute() + * ) + */ + public toDeleteResult(result: DeleteResult[]): DeleteResult[] { + return result.map(r => new DeleteResult((r as unknown as UpdateResult).numUpdatedRows)) + } +} diff --git a/src/executor.ts b/src/executor.ts deleted file mode 100644 index 8974e8b..0000000 --- a/src/executor.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { - type DeleteQueryBuilder, - DeleteResult, - type InsertQueryBuilder, - type InsertResult, - type JoinType, - type Kysely, - type SelectQueryBuilder, - type UpdateQueryBuilder, - type UpdateResult, - type WhereInterface, -} from 'kysely' - -type CamelCase = S extends `${infer First}${infer Rest}` - ? First extends Uppercase - ? `${Lowercase}${Rest}` - : S - : S -export type JoinFnName = CamelCase - -/** - * Basic executor - */ -export interface Executor { - selectFrom: (db: Kysely, tb: any) => SelectQueryBuilder - insertInto: (db: Kysely, tb: any) => InsertQueryBuilder - replaceInto: (db: Kysely, tb: any) => InsertQueryBuilder - updateTable: (db: Kysely, tb: any) => UpdateQueryBuilder - deleteFrom: (db: Kysely, tb: any) => DeleteQueryBuilder -} -export const baseExecutor: Executor = { - selectFrom: (db, tb) => db.selectFrom(tb), - insertInto: (db, tb) => db.insertInto(tb), - replaceInto: (db, tb) => db.replaceInto(tb), - updateTable: (db, tb) => db.updateTable(tb), - deleteFrom: (db, tb) => db.deleteFrom(tb), -} - -type CreateSoftDeleteExecutorReturn = { - /** - * SQLite builder executor - * @example - * const { executor, whereExists } = createSoftDeleteExecutor() - * - * const db = new SqliteBuilder>({ - * dialect: new SqliteDialect({ - * database: new Database(':memory:'), - * }), - * // use soft delete executor - * executor, - * }) - */ - executor: Executor - /** - * Filter query builder with `where('isDeleted', '=', 0)` - * @example - * const { executor, whereExists } = createSoftDeleteExecutor() - * db.selectFrom('test').selectAll().$call(whereExists) - */ - whereExists: (qb: T) => T - /** - * Filter query builder with `where('isDeleted', '=', 1)` - * @example - * const { executor, whereDeleted } = createSoftDeleteExecutor() - * db.selectFrom('test').selectAll().$call(whereDeleted) - */ - whereDeleted: (qb: T) => T -} - -/** - * Create soft delete executor function, `1` is deleted, `0` is default value - * - * Return type of `deleteFrom` is `UpdateResult` insteadof `DeleteResult`, - * to fix it, wrap the result with {@link toDeleteResult} - * @param deleteColumnName delete column name, default is `'isDeleted'` - */ -export function createSoftDeleteExecutor(deleteColumnName = 'isDeleted'): CreateSoftDeleteExecutorReturn { - return { - executor: { - selectFrom: (db: Kysely, table: any) => db.selectFrom(table).where(deleteColumnName, '=', 0), - insertInto: (db: Kysely, table: any) => db.insertInto(table), - replaceInto: (db: Kysely, table: any) => db.replaceInto(table), - updateTable: (db: Kysely, table: any) => db.updateTable(table).where(deleteColumnName, '=', 0), - deleteFrom: (db: Kysely, table: any) => db.updateTable(table).set(deleteColumnName, 1) as any, - }, - whereExists: (qb: T) => (qb as WhereInterface).where(deleteColumnName, '=', 0) as T, - whereDeleted: (qb: T) => (qb as WhereInterface).where(deleteColumnName, '=', 1) as T, - } -} - -/** - * Convert `UpdateResult` to `DeleteResult`, usefult for soft delete - * @param result the result of `deleteFrom` in `createSoftDeleteExecutor` - */ -export function toDeleteResult(result: DeleteResult): DeleteResult { - return new DeleteResult((result as unknown as UpdateResult).numUpdatedRows) -} diff --git a/src/index.ts b/src/index.ts index aa80f07..05d0dbc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,4 @@ export * from './builder' -export * from './executor' export * from './logger' export * from './page-query' export * from './pragma' diff --git a/tests/builder.test.ts b/tests/builder.test.ts index 389342f..e4b3647 100644 --- a/tests/builder.test.ts +++ b/tests/builder.test.ts @@ -1,9 +1,9 @@ import type { InferDatabase } from '../src/schema' import { describe, expect, it } from 'bun:test' -import { pageQuery, precompile } from '../src' +import { pageQuery, precompile, SoftDeleteSqliteBuilder } from '../src' import { getOrSetDBVersion } from '../src/pragma' import { column, defineTable, useSchema } from '../src/schema' -import { baseTables, getDatabaseBuilder } from './utils' +import { baseTables, createDialect, getDatabaseBuilder } from './utils' describe('test builder', async () => { it('should insert', async () => { @@ -101,7 +101,11 @@ describe('test builder', async () => { testSoftDelete: softDeleteTable, } - const db = getDatabaseBuilder>() + const db = new SoftDeleteSqliteBuilder>({ + dialect: createDialect(), + logger: console, + onQuery: false, + }) await db.syncDB(useSchema(softDeleteSchema, { log: false })) const insertResult = await db @@ -111,12 +115,22 @@ describe('test builder', async () => { .executeTakeFirst() expect(insertResult?.isDeleted).toBe(0) - await db.deleteFrom('testSoftDelete').where('id', '=', 1).execute() + const deleteResult = await db.deleteFrom('testSoftDelete').where('id', '=', 1).execute() + expect(deleteResult[0].numDeletedRows).toBeUndefined() + const fixedDeleteResult = db.toDeleteResult(deleteResult) + expect(fixedDeleteResult[0].numDeletedRows).toBe(1n) + const selectResult = await db .selectFrom('testSoftDelete') .selectAll() .executeTakeFirst() expect(selectResult).toBeUndefined() + const selectResult1 = await db.kysely + .selectFrom('testSoftDelete') + .selectAll() + .$call(db.whereExists) + .executeTakeFirst() + expect(selectResult1).toBeUndefined() const updateResult = await db .updateTable('testSoftDelete')