Drizzle connector with drizzle model features for plain objects (without relations).

This commit is contained in:
Madeorsk 2024-10-05 18:40:44 +02:00
parent 214cb7f1c1
commit ff793f31f4
Signed by: Madeorsk
GPG key ID: 677E51CA765BB79F
13 changed files with 471 additions and 16 deletions

5
.env.test Normal file
View file

@ -0,0 +1,5 @@
POSTGRES_HOST=localhost
POSTGRES_PORT=5432
POSTGRES_USERNAME="sharkitek"
POSTGRES_PASSWORD="sharkitek"
POSTGRES_DATABASE="sharkitek"

View file

@ -1,10 +0,0 @@
/** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */
export default {
preset: "ts-jest",
testEnvironment: "node",
roots: [
"./tests",
],
};

View file

@ -1,6 +1,6 @@
{ {
"name": "@sharkitek/drizzle", "name": "@sharkitek/drizzle",
"version": "1.0.0", "version": "3.0.0-beta",
"description": "Drizzle connector for Sharkitek models.", "description": "Drizzle connector for Sharkitek models.",
"repository": "https://code.zeptotech.net/Sharkitek/Drizzle", "repository": "https://code.zeptotech.net/Sharkitek/Drizzle",
"author": { "author": {
@ -10,7 +10,7 @@
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
"build": "tsc && vite build", "build": "tsc && vite build",
"test": "jest" "test": "vitest"
}, },
"type": "module", "type": "module",
"source": "src/index.ts", "source": "src/index.ts",
@ -23,17 +23,19 @@
"access": "public" "access": "public"
}, },
"devDependencies": { "devDependencies": {
"@types/jest": "^29.5.13", "@sharkitek/core": "^3.3.0",
"@types/node": "^22.7.4", "@types/node": "^22.7.4",
"dotenv": "^16.4.5",
"drizzle-orm": "^0.33.0", "drizzle-orm": "^0.33.0",
"jest": "^29.7.0", "postgres": "^3.4.4",
"ts-jest": "^29.2.5",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"typescript": "^5.6.2", "typescript": "^5.6.2",
"vite": "^5.4.8", "vite": "^5.4.8",
"vite-plugin-dts": "^4.2.3" "vite-plugin-dts": "^4.2.3",
"vitest": "^2.1.2"
}, },
"peerDependencies": { "peerDependencies": {
"@sharkitek/core": "^3.0.0",
"drizzle-orm": "^0.33.0" "drizzle-orm": "^0.33.0"
}, },
"packageManager": "yarn@4.5.0" "packageManager": "yarn@4.5.0"

151
src/Drizzle.ts Normal file
View file

@ -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<string, (serializedValue: any) => any> = {
"date": (serializedValue: string) => new Date(serializedValue),
};
/**
* Sharkitek model extension for Drizzle.
*/
export interface DrizzleExtension<
ModelType extends Model<Shape, IdentifierType<Shape, Identifier>>,
TableType extends Table<TC>, TC extends TableConfig,
TQueryResult extends PgQueryResultHKT, TFullSchema extends Record<string, unknown>,
Scopes extends object,
Shape extends ModelShape, Identifier extends keyof Shape = any, TSchema extends TablesRelationalConfig = ExtractTablesWithRelations<TFullSchema>
>
{
/**
* Drizzle model manager.
*/
drizzle(): DrizzleModel<ModelType, TableType, TC, TQueryResult, TFullSchema, Scopes, Shape, Identifier, TSchema>;
}
/**
* Drizzle model manager.
*/
export class DrizzleModel<
ModelType extends Model<Shape, IdentifierType<Shape, Identifier>>,
TableType extends Table<TC>, TC extends TableConfig,
TQueryResult extends PgQueryResultHKT, TFullSchema extends Record<string, unknown>,
Scopes extends object,
Shape extends ModelShape, Identifier extends keyof Shape = any, TSchema extends TablesRelationalConfig = ExtractTablesWithRelations<TFullSchema>
>
{
constructor(protected model: ModelType,
protected database: PgDatabase<TQueryResult, TFullSchema, TSchema>,
protected table: TableType,
protected scopes: Scopes & ThisType<ModelQuery<ModelType, TableType, TC, TQueryResult, TFullSchema, Shape, Identifier, TSchema>>)
{}
/**
* Get Drizzle database.
*/
getDatabase(): PgDatabase<TQueryResult, TFullSchema, TSchema>
{
return this.database;
}
/**
* Get model Drizzle table.
*/
getTable(): TableType
{
return this.table;
}
/**
* Get scopes.
*/
getScopes(): Scopes & ThisType<ModelQuery<ModelType, TableType, TC, TQueryResult, TFullSchema, Shape, Identifier, TSchema>>
{
return this.scopes;
}
/**
* Save the model in database.
*/
async save(): Promise<boolean>
{
// 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<SerializedModel<Shape>>;
// 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<void>
{
// 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<Shape, IdentifierType<Shape, Identifier>>,
TableType extends Table<TC>, TC extends TableConfig,
TQueryResult extends PgQueryResultHKT, TFullSchema extends Record<string, unknown>,
Scopes extends object,
Shape extends ModelShape, Identifier extends keyof Shape = any, TSchema extends TablesRelationalConfig = ExtractTablesWithRelations<TFullSchema>,
>(
database: PgDatabase<TQueryResult, TFullSchema, TSchema>,
table: TableType,
scopes: Scopes & ThisType<ModelQuery<ModelType, TableType, TC, TQueryResult, TFullSchema, Shape, Identifier, TSchema>>
): DrizzleExtension<ModelType, TableType, TC, TQueryResult, TFullSchema, Scopes, Shape, Identifier, TSchema> & ThisType<ModelType>
{
// Return initialized drizzle extension.
return {
drizzle(): DrizzleModel<ModelType, TableType, TC, TQueryResult, TFullSchema, Scopes, Shape, Identifier, TSchema>
{
// Initialize drizzle model manager instance.
return new DrizzleModel<ModelType, TableType, TC, TQueryResult, TFullSchema, Scopes, Shape, Identifier, TSchema>(this, database, table, scopes);
}
};
}

109
src/Query.ts Normal file
View file

@ -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<Shape, IdentifierType<Shape, Identifier>>,
TableType extends Table<TC>, TC extends TableConfig,
TQueryResult extends PgQueryResultHKT, TFullSchema extends Record<string, unknown>,
Shape extends ModelShape, Identifier extends keyof Shape = any, TSchema extends TablesRelationalConfig = ExtractTablesWithRelations<TFullSchema>
>
{
constructor(protected modelClass: ModelClass<ModelType, Shape, Identifier>, protected modelInstance: ModelType, protected database: PgDatabase<TQueryResult, TFullSchema, TSchema>, protected table: TableType)
{}
/**
* Parse the given model data to a model.
* @param rawModel Raw model.
* @protected
*/
protected parseModel(rawModel: SerializedModel<Shape>|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<TConfig extends DBQueryConfig<"many", true, TSchema, TSchema[keyof TSchema]>>(config?: KnownKeysOnly<TConfig, DBQueryConfig<"many", true, TSchema, TSchema[keyof TSchema]>>): Promise<ModelType[]>
{
// 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<SerializedModel<Shape>[]>)
).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<TSelection extends Omit<DBQueryConfig<"many", true, TSchema, TSchema[keyof TSchema]>, "limit">>(config?: KnownKeysOnly<TSelection, Omit<DBQueryConfig<"many", true, TSchema, TSchema[keyof TSchema]>, "limit">>): Promise<ModelType|null>
{
// 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<SerializedModel<Shape>>)
);
}
/**
* Find a model from its identifier.
* @param identifier Model identifier.
*/
async find(identifier: IdentifierType<Shape, Identifier>): Promise<ModelType|null>
{
// 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<Shape, Identifier>|IdentifierType<Shape, Identifier>[])[]): Promise<ModelType[]>
{
// 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<Shape, IdentifierType<Shape, Identifier>> & DrizzleExtension<AnyModel, TableType, TC, TQueryResult, TFullSchema, Scopes, any, any, TSchema>,
TableType extends Table<TC>, TC extends TableConfig,
TQueryResult extends PgQueryResultHKT, TFullSchema extends Record<string, unknown>,
Scopes extends object,
Shape extends ModelShape, Identifier extends keyof Shape = any, TSchema extends TablesRelationalConfig = ExtractTablesWithRelations<TFullSchema>
>(modelClass: ModelClass<ModelType, Shape, Identifier>): ModelQuery<ModelType, TableType, TC, TQueryResult, TFullSchema, Shape, Identifier, TSchema> & 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<ModelType, TableType, TC, TQueryResult, TFullSchema, Shape, Identifier, TSchema>(
modelClass, model, modelDrizzle.getDatabase(), modelDrizzle.getTable(),
),
modelDrizzle.getScopes(),
);
}

3
src/index.ts Normal file
View file

@ -0,0 +1,3 @@
export * from "./Drizzle";
export * from "./Query";

64
tests/Drizzle.test.ts Normal file
View file

@ -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());
});

19
tests/Invoice.ts Normal file
View file

@ -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, {
}))
{
}

56
tests/database.ts Normal file
View file

@ -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<void>
{
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<void>
{
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",
},
]);
}

13
tests/schema/invoices.ts Normal file
View file

@ -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"),
});

3
tests/setup.ts Normal file
View file

@ -0,0 +1,3 @@
import {initializeDatabase} from "./database";
initializeDatabase();

32
tsconfig.json Normal file
View file

@ -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"
]
}
}

View file

@ -14,6 +14,14 @@ export default defineConfig(({ mode }: ConfigEnv): UserConfig => {
formats: ["es"], formats: ["es"],
fileName: "index", fileName: "index",
}, },
rollupOptions: {
external: ["@sharkitek/core", "drizzle-orm"],
},
},
test: {
root: "./tests",
setupFiles: ["./tests/setup.ts"],
}, },
plugins: [ plugins: [