diff --git a/.env.test b/.env.test new file mode 100644 index 0000000..1f7a5cd --- /dev/null +++ b/.env.test @@ -0,0 +1,5 @@ +POSTGRES_HOST=localhost +POSTGRES_PORT=5432 +POSTGRES_USERNAME="sharkitek" +POSTGRES_PASSWORD="sharkitek" +POSTGRES_DATABASE="sharkitek" diff --git a/jest.config.js b/jest.config.js deleted file mode 100644 index 77a9c90..0000000 --- a/jest.config.js +++ /dev/null @@ -1,10 +0,0 @@ -/** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */ - -export default { - preset: "ts-jest", - testEnvironment: "node", - - roots: [ - "./tests", - ], -}; diff --git a/package.json b/package.json index ced3d11..422a371 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@sharkitek/drizzle", - "version": "1.0.0", + "version": "3.0.0-beta", "description": "Drizzle connector for Sharkitek models.", "repository": "https://code.zeptotech.net/Sharkitek/Drizzle", "author": { @@ -10,7 +10,7 @@ "license": "MIT", "scripts": { "build": "tsc && vite build", - "test": "jest" + "test": "vitest" }, "type": "module", "source": "src/index.ts", @@ -23,17 +23,19 @@ "access": "public" }, "devDependencies": { - "@types/jest": "^29.5.13", + "@sharkitek/core": "^3.3.0", "@types/node": "^22.7.4", + "dotenv": "^16.4.5", "drizzle-orm": "^0.33.0", - "jest": "^29.7.0", - "ts-jest": "^29.2.5", + "postgres": "^3.4.4", "ts-node": "^10.9.2", "typescript": "^5.6.2", "vite": "^5.4.8", - "vite-plugin-dts": "^4.2.3" + "vite-plugin-dts": "^4.2.3", + "vitest": "^2.1.2" }, "peerDependencies": { + "@sharkitek/core": "^3.0.0", "drizzle-orm": "^0.33.0" }, "packageManager": "yarn@4.5.0" diff --git a/src/Drizzle.ts b/src/Drizzle.ts new file mode 100644 index 0000000..7add89f --- /dev/null +++ b/src/Drizzle.ts @@ -0,0 +1,151 @@ +import {IdentifierType, Model, ModelShape, SerializedModel} from "@sharkitek/core"; +import {eq, getTableColumns, Table, TableConfig} from "drizzle-orm"; +import {PgDatabase} from "drizzle-orm/pg-core"; +import {PgQueryResultHKT} from "drizzle-orm/pg-core/session"; +import type {ExtractTablesWithRelations, TablesRelationalConfig} from "drizzle-orm/relations"; +import {ModelQuery} from "./Query"; + +/** + * Serialized values converters for database. + */ +export const serializedToDatabaseTypes: Record any> = { + "date": (serializedValue: string) => new Date(serializedValue), +}; + +/** + * Sharkitek model extension for Drizzle. + */ +export interface DrizzleExtension< + ModelType extends Model>, + TableType extends Table, TC extends TableConfig, + TQueryResult extends PgQueryResultHKT, TFullSchema extends Record, + Scopes extends object, + Shape extends ModelShape, Identifier extends keyof Shape = any, TSchema extends TablesRelationalConfig = ExtractTablesWithRelations +> +{ + /** + * Drizzle model manager. + */ + drizzle(): DrizzleModel; +} + +/** + * Drizzle model manager. + */ +export class DrizzleModel< + ModelType extends Model>, + TableType extends Table, TC extends TableConfig, + TQueryResult extends PgQueryResultHKT, TFullSchema extends Record, + Scopes extends object, + Shape extends ModelShape, Identifier extends keyof Shape = any, TSchema extends TablesRelationalConfig = ExtractTablesWithRelations +> +{ + constructor(protected model: ModelType, + protected database: PgDatabase, + protected table: TableType, + protected scopes: Scopes & ThisType>) + {} + + /** + * Get Drizzle database. + */ + getDatabase(): PgDatabase + { + return this.database; + } + + /** + * Get model Drizzle table. + */ + getTable(): TableType + { + return this.table; + } + + /** + * Get scopes. + */ + getScopes(): Scopes & ThisType> + { + return this.scopes; + } + + /** + * Save the model in database. + */ + async save(): Promise + { + // Get serialized model update. + const serializedModel = this.model.patch(); + + // Get table columns of the model. + const tableColumns = getTableColumns(this.table); + + // Create model data for the database. + const databaseModelData = Object.fromEntries( + Object.entries(serializedModel).map( + // Only keep table column values and convert them into database types, if it is required. + ([key, value]) => [key, tableColumns?.[key] ? (serializedToDatabaseTypes?.[tableColumns?.[key].dataType]?.(value) ?? value) : undefined] + ) + ) as Partial>; + + // Initialize query result variable. + let result: any; + + if (this.model.isNew()) + { // Insert the new model in database and get the inserted row data. + result = (await (this.database.insert(this.table)).values(databaseModelData as any).returning() as any)?.[0]; + } + else + { // Update model data in database, and get the updated row data. + result = (await (this.database.update(this.table) + .set(databaseModelData) + .where(eq((this.table as any)?.[this.model.getIdentifierName()], this.model.getIdentifier()))) + .returning() as any)?.[0]; + } + + // Update model from inserted or updated row data. + this.model.deserialize(result); + + return true; + } + + async refresh(): Promise + { + // Update model from up-to-date row data. + this.model.deserialize( + (( // Retrieve model data from database. + await this.database.select().from(this.table) + .where(eq((this.table as any)?.[this.model.getIdentifierName()], this.model.getIdentifier())) + ) as any)[0] + ); + } +} + +/** + * Drizzle extension initializer. + * @param database Drizzle database. + * @param table Drizzle database table. + * @param scopes + */ +export function drizzle< + ModelType extends Model>, + TableType extends Table, TC extends TableConfig, + TQueryResult extends PgQueryResultHKT, TFullSchema extends Record, + Scopes extends object, + Shape extends ModelShape, Identifier extends keyof Shape = any, TSchema extends TablesRelationalConfig = ExtractTablesWithRelations, +>( + database: PgDatabase, + table: TableType, + scopes: Scopes & ThisType> +): DrizzleExtension & ThisType +{ + // Return initialized drizzle extension. + return { + drizzle(): DrizzleModel + { + // Initialize drizzle model manager instance. + return new DrizzleModel(this, database, table, scopes); + } + }; +} diff --git a/src/Query.ts b/src/Query.ts new file mode 100644 index 0000000..a49681c --- /dev/null +++ b/src/Query.ts @@ -0,0 +1,109 @@ +import {AnyModel, IdentifierType, Model, ModelClass, ModelShape, SerializedModel} from "@sharkitek/core"; +import {eq, getTableName, Table, TableConfig} from "drizzle-orm"; +import {PgDatabase} from "drizzle-orm/pg-core"; +import {PgQueryResultHKT} from "drizzle-orm/pg-core/session"; +import type {DBQueryConfig, ExtractTablesWithRelations, TablesRelationalConfig} from "drizzle-orm/relations"; +import type {KnownKeysOnly} from "drizzle-orm/utils"; +import {DrizzleExtension} from "./Drizzle"; +import {inArray} from "drizzle-orm/sql/expressions/conditions"; + +/** + * Model query manager. + */ +export class ModelQuery< + ModelType extends Model>, + TableType extends Table, TC extends TableConfig, + TQueryResult extends PgQueryResultHKT, TFullSchema extends Record, + Shape extends ModelShape, Identifier extends keyof Shape = any, TSchema extends TablesRelationalConfig = ExtractTablesWithRelations +> +{ + constructor(protected modelClass: ModelClass, protected modelInstance: ModelType, protected database: PgDatabase, protected table: TableType) + {} + + /** + * Parse the given model data to a model. + * @param rawModel Raw model. + * @protected + */ + protected parseModel(rawModel: SerializedModel|null): ModelType|null + { + // The raw model is null or undefined, return NULL. + if (!rawModel) return null; + // Parse the given raw model to a model. + return (new this.modelClass()).deserialize(rawModel) as ModelType; + } + + /** + * Find models matching the given configuration. + * @param config Request configuration. + */ + async get>(config?: KnownKeysOnly>): Promise + { + // Parse retrieved raw models. + return ( + // Find many models from the given configuration. + await ((this.database.query as any)?.[getTableName(this.table)]?.findMany(config) as Promise[]>) + ).map(rawModel => this.parseModel(rawModel)).filter((model) => !!model); // Only keep non-null models. + } + + /** + * Find the first model matching the given configuration. + * @param config Request configuration. + */ + async first, "limit">>(config?: KnownKeysOnly, "limit">>): Promise + { + // Parse retrieved raw model. + return this.parseModel( + // Find a model from the given configuration. + await ((this.database.query as any)?.[getTableName(this.table)]?.findFirst(config) as Promise>) + ); + } + + /** + * Find a model from its identifier. + * @param identifier Model identifier. + */ + async find(identifier: IdentifierType): Promise + { + // Find the first model which match the given identifier. + return this.first({ + where: eq((this.table as any)?.[this.modelInstance.getIdentifierName()], identifier), + }); + } + + /** + * Find many models from their identifier. + * @param identifiers Model identifiers. + */ + async findMany(...identifiers: (IdentifierType|IdentifierType[])[]): Promise + { + // Find many models which match the given identifiers. + return this.get({ + where: inArray((this.table as any)?.[this.modelInstance.getIdentifierName()], identifiers.flat()), + }); + } +} + +/** + * Model query builder. + * @param modelClass Model class. + */ +export function query< + ModelType extends Model> & DrizzleExtension, + TableType extends Table, TC extends TableConfig, + TQueryResult extends PgQueryResultHKT, TFullSchema extends Record, + Scopes extends object, + Shape extends ModelShape, Identifier extends keyof Shape = any, TSchema extends TablesRelationalConfig = ExtractTablesWithRelations +>(modelClass: ModelClass): ModelQuery & Scopes +{ + // Get model drizzle instance. + const model = new modelClass(); + const modelDrizzle = model.drizzle(); + // Create a model query with the model database and table. + return Object.assign( + new ModelQuery( + modelClass, model, modelDrizzle.getDatabase(), modelDrizzle.getTable(), + ), + modelDrizzle.getScopes(), + ); +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..154b308 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,3 @@ + +export * from "./Drizzle"; +export * from "./Query"; diff --git a/tests/Drizzle.test.ts b/tests/Drizzle.test.ts new file mode 100644 index 0000000..17b6930 --- /dev/null +++ b/tests/Drizzle.test.ts @@ -0,0 +1,64 @@ +import {test, expect, beforeAll} from "vitest"; +import {query} from "../src"; +import {Invoice} from "./Invoice"; +import {setupDefaultInvoices} from "./database"; + +beforeAll(async () => { + await setupDefaultInvoices(); +}); + +test("retrieve existing models from database", async () => { + const invoice = await query(Invoice).find(1); + const invoices = await query(Invoice).findMany(1, 2); + + expect(invoice).not.toBeNull(); + expect(invoices).toHaveLength(2); + + expect(invoice?.amount).toStrictEqual(5450.12); + + expect(invoices.reduce((total, invoice) => (total + invoice.amount), 0)).toStrictEqual(5450.12 + 1122.54); + + expect(invoices[0].date.getFullYear()).toStrictEqual(1997); +}) + +test("alter existing model in database", async () => { + // Get an invoice to change its date. + const invoice = await query(Invoice).find(1) as Invoice; + invoice.date.setDate(15); + await invoice.drizzle().save(); + + // Get the same invoice again, to check that the date has been successfully changed. + const updatedInvoice = await query(Invoice).find(1); + expect(updatedInvoice?.date?.getDate()).toStrictEqual(15); +}); + +test("refresh a model from database", async () => { + // Get an invoice to change its date. + const invoice = await query(Invoice).find(1) as Invoice; + invoice.date.setDate(30); + // Refresh from database: it should revert the changes. + await invoice.drizzle().refresh(); + + expect(invoice?.date?.getDate()).toStrictEqual(15); +}); + +test("insert a new model in database", async () => { + const now = new Date(); + + const invoice = new Invoice(); + invoice.date = now; + invoice.amount = 12.34; + invoice.clientName = "client name"; + invoice.clientAddress = "any address string"; + + await invoice.drizzle().save(); + + expect(invoice.serialize()).toStrictEqual({ + id: 3, + date: now.toISOString(), + amount: "12.34", + clientName: "client name", + clientAddress: "any address string", + }); + expect(invoice.serialize()).toStrictEqual((await query(Invoice).find(invoice.id))?.serialize()); +}); diff --git a/tests/Invoice.ts b/tests/Invoice.ts new file mode 100644 index 0000000..5df710d --- /dev/null +++ b/tests/Invoice.ts @@ -0,0 +1,19 @@ +import {s} from "@sharkitek/core"; +import {drizzle} from "../src"; +import {database} from "./database"; +import {invoices} from "./schema/invoices"; + +/** + * Invoice model class example with Sharkitek and its Drizzle connector. + * @see https://code.zeptotech.net/Sharkitek/Core + */ +export class Invoice extends s.model({ + id: s.property.numeric(), + date: s.property.date(), + amount: s.property.decimal(), + clientName: s.property.string(), + clientAddress: s.property.string(), +}, "id").extends(drizzle(database, invoices, { +})) +{ +} diff --git a/tests/database.ts b/tests/database.ts new file mode 100644 index 0000000..899ed7b --- /dev/null +++ b/tests/database.ts @@ -0,0 +1,56 @@ +import dotenv from "dotenv"; +import postgres from "postgres"; +import {drizzle} from "drizzle-orm/postgres-js"; +import {sql} from "drizzle-orm"; +import * as InvoicesSchema from "./schema/invoices"; + +// Load configuration from environment variables. +dotenv.config({ + path: ".env.test", +}); + +const queryClient = postgres(`postgres://${process.env.POSTGRES_USERNAME}:${process.env.POSTGRES_PASSWORD}@${process.env.POSTGRES_HOST}:${process.env.POSTGRES_PORT}/${process.env.POSTGRES_DATABASE}`); +export const database = drizzle(queryClient, { + schema: { + ...InvoicesSchema, + }, +}); + +/** + * Initialize database schema for tests. + */ +export async function initializeDatabase(): Promise +{ + await database.execute(sql` +DROP TABLE IF EXISTS invoices; +CREATE TABLE invoices( + id SERIAL PRIMARY KEY, + date TIMESTAMP WITH TIME ZONE, + amount NUMERIC(12, 2), + client_name VARCHAR, + client_address TEXT +); +`); +} + +/** + * Add default invoices for tests. + */ +export async function setupDefaultInvoices(): Promise +{ + await database.execute(sql`TRUNCATE invoices RESTART IDENTITY;`); + await database.insert(InvoicesSchema.invoices).values([ + { + date: new Date("1997-10-09"), + amount: "5450.12", + clientName: "test name", + clientAddress: "test test test", + }, + { + date: new Date(), + amount: "1122.54", + clientName: "another name", + clientAddress: "foo bar baz", + }, + ]); +} diff --git a/tests/schema/invoices.ts b/tests/schema/invoices.ts new file mode 100644 index 0000000..047103f --- /dev/null +++ b/tests/schema/invoices.ts @@ -0,0 +1,13 @@ +import {numeric, pgTable, serial, text, timestamp, varchar} from "drizzle-orm/pg-core"; + +/** + * Invoices example table with Drizzle. + * @see https://orm.drizzle.team/docs/sql-schema-declaration + */ +export const invoices = pgTable("invoices", { + id: serial("id").primaryKey(), + date: timestamp("date", { withTimezone: true }).notNull(), + amount: numeric("amount", { precision: 12, scale: 2 }).notNull(), + clientName: varchar("client_name").notNull(), + clientAddress: text("client_address"), +}); diff --git a/tests/setup.ts b/tests/setup.ts new file mode 100644 index 0000000..376a8d7 --- /dev/null +++ b/tests/setup.ts @@ -0,0 +1,3 @@ +import {initializeDatabase} from "./database"; + +initializeDatabase(); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..69babfe --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,32 @@ +{ + "ts-node": { + "compilerOptions": { + "module": "ESNext", + "types": ["node"], + } + }, + + "compilerOptions": { + "outDir": "./lib/", + "incremental": true, + "sourceMap": true, + "skipLibCheck": true, + "strictNullChecks": true, + "noImplicitAny": true, + "noImplicitThis": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "resolveJsonModule": true, + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "declaration": true, + "declarationMap": true, + "module": "ES6", + "target": "ES6", + "moduleResolution": "Bundler", + "lib": [ + "ESNext", + "DOM" + ] + } +} diff --git a/vite.config.ts b/vite.config.ts index 7d8271f..f114236 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -14,6 +14,14 @@ export default defineConfig(({ mode }: ConfigEnv): UserConfig => { formats: ["es"], fileName: "index", }, + rollupOptions: { + external: ["@sharkitek/core", "drizzle-orm"], + }, + }, + + test: { + root: "./tests", + setupFiles: ["./tests/setup.ts"], }, plugins: [