From 25743b8ecd5490eb923a1b8bece48cde6b1dba1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9A=AE=E5=86=AC=E6=8B=BE=E6=9F=92?= Date: Thu, 3 Apr 2025 17:26:50 +0800 Subject: [PATCH 1/2] feat(bentocache): Adding Drizzle ORM Adapters and Related Tests --- packages/bentocache/package.json | 8 +- .../src/drivers/database/adapters/drizzle.ts | 765 ++++++++++++++++++ .../src/types/options/drivers_options.ts | 17 + .../tests/drivers/drizzle/helpers.ts | 8 + .../tests/drivers/drizzle/mysql.spec.ts | 33 + .../tests/drivers/drizzle/postgres.spec.ts | 26 + .../tests/drivers/drizzle/sqlite.spec.ts | 27 + 7 files changed, 883 insertions(+), 1 deletion(-) create mode 100644 packages/bentocache/src/drivers/database/adapters/drizzle.ts create mode 100644 packages/bentocache/tests/drivers/drizzle/helpers.ts create mode 100644 packages/bentocache/tests/drivers/drizzle/mysql.spec.ts create mode 100644 packages/bentocache/tests/drivers/drizzle/postgres.spec.ts create mode 100644 packages/bentocache/tests/drivers/drizzle/sqlite.spec.ts diff --git a/packages/bentocache/package.json b/packages/bentocache/package.json index e47bc86..7d93b20 100644 --- a/packages/bentocache/package.json +++ b/packages/bentocache/package.json @@ -24,6 +24,7 @@ "./drivers/knex": "./build/src/drivers/database/adapters/knex.js", "./drivers/kysely": "./build/src/drivers/database/adapters/kysely.js", "./drivers/orchid": "./build/src/drivers/database/adapters/orchid.js", + "./drivers/drizzle": "./build/src/drivers/database/adapters/drizzle.js", "./types": "./build/src/types/main.js", "./plugins/*": "./build/plugins/*.js", "./test_suite": "./build/src/test_suite.js" @@ -49,6 +50,7 @@ }, "peerDependencies": { "@aws-sdk/client-dynamodb": "^3.438.0", + "drizzle-orm": "^0.41.0", "ioredis": "^5.3.2", "knex": "^3.0.1", "kysely": "^0.27.3", @@ -69,6 +71,9 @@ }, "orchid-orm": { "optional": true + }, + "drizzle-orm": { + "optional": true } }, "dependencies": { @@ -85,6 +90,7 @@ "@types/pg": "^8.11.11", "better-sqlite3": "^11.8.1", "dayjs": "^1.11.13", + "drizzle-orm": "^0.41.0", "emittery": "^1.1.0", "ioredis": "^5.5.0", "knex": "^3.1.0", @@ -127,4 +133,4 @@ "web": true } } -} +} \ No newline at end of file diff --git a/packages/bentocache/src/drivers/database/adapters/drizzle.ts b/packages/bentocache/src/drivers/database/adapters/drizzle.ts new file mode 100644 index 0000000..717b0ba --- /dev/null +++ b/packages/bentocache/src/drivers/database/adapters/drizzle.ts @@ -0,0 +1,765 @@ +import type { MySql2Database } from 'drizzle-orm/mysql2' +import type { NodePgDatabase } from 'drizzle-orm/node-postgres' +import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3' +import { sqliteTable, text as sqliteText, integer } from 'drizzle-orm/sqlite-core' +import { eq, inArray, and, lt, or, isNull, sql as rawSql, gt, like } from 'drizzle-orm' +import { pgTable, text as pgText, timestamp as pgTimestamp } from 'drizzle-orm/pg-core' +import { mysqlTable, text as mysqlText, timestamp as mysqlTimestamp } from 'drizzle-orm/mysql-core' + +import { DatabaseDriver } from '../database.js' +import type { CreateDriverResult, DatabaseAdapter } from '../../../types/main.js' + +export type DrizzleConnection = + | NodePgDatabase> + | MySql2Database + | BetterSQLite3Database + +export interface DrizzleConfig { + connection: DrizzleConnection + tableName?: string + dialect?: 'pg' | 'mysql' | 'sqlite' + prefix?: string +} + +export function drizzleDriver(options: DrizzleConfig): CreateDriverResult { + return { + options, + factory: (config: DrizzleConfig) => { + const adapter = new DrizzleAdapter(config) + adapter.createTableIfNotExists() + return new DatabaseDriver(adapter, config) + }, + } +} + +export class DrizzleAdapter implements DatabaseAdapter { + #connection: DrizzleConnection + #tableName: string + #dialect: 'pg' | 'mysql' | 'sqlite' + #table: any + #sqliteClient: any | null = null + + // Function references initialized once based on database type + #getImpl!: (key: string) => Promise<{ value: string; expiresAt: number | null } | undefined> + #setImpl!: (row: { key: string; value: string; expiresAt: Date | null }) => Promise + #deleteImpl!: (key: string) => Promise + #deleteManyImpl!: (keys: string[]) => Promise + #clearImpl!: (prefix: string) => Promise + #pruneExpiredEntriesImpl!: () => Promise + #createTableImpl!: () => Promise + + constructor(config: DrizzleConfig) { + this.#connection = config.connection + this.#tableName = config.tableName || '__cache' + this.#dialect = config.dialect || this.#detectDialect() + this.#table = this.#createTable() + + // Extract native SQLite client if in SQLite mode + if (this.#dialect === 'sqlite') { + this.#sqliteClient = this.#extractSqliteNativeClient() + } + + // Initialize implementations based on database type + this.#initImplementations() + } + + /** + * Initialize method implementations based on database dialect + */ + #initImplementations(): void { + // Initialize get implementation + if (this.#dialect === 'sqlite' && this.#sqliteClient) { + this.#getImpl = this.#getSqliteNative + } else if (this.#dialect === 'sqlite') { + this.#getImpl = this.#getSqlite + } else if (this.#dialect === 'mysql') { + this.#getImpl = this.#getMysql + } else { + this.#getImpl = this.#getDefault + } + + // Initialize set implementation + if (this.#dialect === 'sqlite' && this.#sqliteClient) { + this.#setImpl = this.#setSqliteNative + } else if (this.#dialect === 'sqlite') { + this.#setImpl = this.#setSqlite + } else if (this.#dialect === 'mysql') { + this.#setImpl = this.#setMysql + } else { + this.#setImpl = this.#setDefault + } + + // Initialize delete implementation + if (this.#dialect === 'mysql') { + this.#deleteImpl = this.#deleteMysql + } else { + this.#deleteImpl = this.#deleteDefault + } + + // Initialize deleteMany implementation + if (this.#dialect === 'mysql') { + this.#deleteManyImpl = this.#deleteManyMysql + } else { + this.#deleteManyImpl = this.#deleteManyDefault + } + + // Initialize clear implementation + if (this.#dialect === 'sqlite' && this.#sqliteClient) { + this.#clearImpl = this.#clearSqliteNative + } else { + this.#clearImpl = this.#clearDefault + } + + // Initialize pruneExpiredEntries implementation + if (this.#dialect === 'sqlite' && this.#sqliteClient) { + this.#pruneExpiredEntriesImpl = this.#pruneExpiredEntriesSqliteNative + } else if (this.#dialect === 'sqlite') { + this.#pruneExpiredEntriesImpl = this.#pruneExpiredEntriesSqlite + } else { + this.#pruneExpiredEntriesImpl = this.#pruneExpiredEntriesDefault + } + + // Initialize createTable implementation + if (this.#dialect === 'pg') { + this.#createTableImpl = this.#createTablePg + } else if (this.#dialect === 'mysql') { + this.#createTableImpl = this.#createTableMysql + } else if (this.#dialect === 'sqlite' && this.#sqliteClient) { + this.#createTableImpl = this.#createTableSqliteNative + } else { + this.#createTableImpl = this.#createTableSqlite + } + } + + /** + * Set the table name for the cache + */ + setTableName(tableName: string): void { + this.#tableName = tableName + this.#table = this.#createTable() + } + + // Public methods, directly calling their specific implementations + + /** + * Get a value from the cache by key + */ + async get(key: string): Promise<{ value: string; expiresAt: number | null } | undefined> { + return this.#getImpl(key) + } + + /** + * Delete a value from the cache by key + */ + async delete(key: string): Promise { + return this.#deleteImpl(key) + } + + /** + * Delete multiple values from the cache by keys + */ + async deleteMany(keys: string[]): Promise { + return this.#deleteManyImpl(keys) + } + + /** + * Disconnect from the database (not required for Drizzle) + */ + async disconnect(): Promise { + // No explicit disconnect needed for Drizzle + } + + /** + * Create the cache table if it doesn't exist + */ + async createTableIfNotExists(): Promise { + return this.#createTableImpl() + } + + /** + * Remove expired entries from the cache + */ + async pruneExpiredEntries(): Promise { + return this.#pruneExpiredEntriesImpl() + } + + /** + * Clear all entries with the given prefix + */ + async clear(prefix: string): Promise { + return this.#clearImpl(prefix) + } + + /** + * Set a value in the cache + */ + async set(row: { key: string; value: string; expiresAt: Date | null }): Promise { + return this.#setImpl(row) + } + + // Implementation methods for different database dialects + + // --- Get implementations --- + + /** + * Get implementation using native SQLite client + */ + async #getSqliteNative( + key: string, + ): Promise<{ value: string; expiresAt: number | null } | undefined> { + const currentTimestamp = new Date().getTime() + const stmt = this.#sqliteClient.prepare(` + SELECT key, value, expires_at + FROM ${this.#tableName} + WHERE key = ? AND (expires_at IS NULL OR expires_at > ?) + LIMIT 1 + `) + const row = stmt.get(key, currentTimestamp) + if (!row) return undefined + + return { + value: row.value, + expiresAt: row.expires_at, + } + } + + /** + * Get implementation for SQLite using Drizzle ORM + */ + async #getSqlite(key: string): Promise<{ value: string; expiresAt: number | null } | undefined> { + const now = new Date().getTime() + const result = await this.db + .select() + .from(this.#table) + .where( + and( + eq(this.#table.key, key), + or(isNull(this.#table.expiresAt), gt(this.#table.expiresAt, now)), + ), + ) + .limit(1) + + if (!result.length) return undefined + + // Ensure SQLite expiresAt is properly converted to a number + let expiresAt = result[0].expiresAt + if (expiresAt !== null && typeof expiresAt !== 'number') { + // Try to convert string to number + expiresAt = Number(expiresAt) + if (Number.isNaN(expiresAt)) { + expiresAt = null + } + } + + return { + value: result[0].value, + expiresAt, + } + } + + /** + * Get implementation for MySQL using raw SQL + */ + async #getMysql(key: string): Promise<{ value: string; expiresAt: number | null } | undefined> { + // Use UNIX_TIMESTAMP to convert dates on the server side + const result = await this.db.execute(rawSql` + SELECT + \`key\`, + \`value\`, + \`expires_at\`, + UNIX_TIMESTAMP(\`expires_at\`) * 1000 as expires_timestamp + FROM ${rawSql.identifier(this.#tableName)} + WHERE \`key\` = ${key} + AND (\`expires_at\` IS NULL OR \`expires_at\` > NOW()) + LIMIT 1 + `) + + // Process MySQL result + const rows = Array.isArray(result) && result.length === 2 ? result[0] : result + + if (!rows || !rows.length) return undefined + + const row = rows[0] + return { + value: row.value, + // Use server-side timestamp instead of local conversion + expiresAt: row.expires_timestamp !== null ? Number(row.expires_timestamp) : null, + } + } + + /** + * Default get implementation (PostgreSQL) + */ + async #getDefault(key: string): Promise<{ value: string; expiresAt: number | null } | undefined> { + const now = new Date() + const result = await this.db + .select() + .from(this.#table) + .where( + and( + eq(this.#table.key, key), + or(isNull(this.#table.expiresAt), gt(this.#table.expiresAt, now)), + ), + ) + .limit(1) + + if (!result.length) return undefined + + // Ensure consistent expiresAt format across all databases + let expiresAt = result[0].expiresAt + if (expiresAt !== null) { + if (typeof expiresAt === 'number') { + // Already a number, no conversion needed + } else if (expiresAt instanceof Date) { + expiresAt = expiresAt.getTime() + } else if (typeof expiresAt === 'string') { + // Try to convert string to number or date + const numericValue = Number(expiresAt) + if (!Number.isNaN(numericValue)) { + expiresAt = numericValue + } else { + const dateValue = new Date(expiresAt) + if (!Number.isNaN(dateValue.getTime())) { + expiresAt = dateValue.getTime() + } else { + expiresAt = null + } + } + } else { + // Unsupported type, set to null + expiresAt = null + } + } + + return { + value: result[0].value, + expiresAt, + } + } + + // --- Set implementations --- + + /** + * Set implementation using native SQLite client + */ + async #setSqliteNative(row: { + key: string + value: string + expiresAt: Date | null + }): Promise { + const expiresAtValue = row.expiresAt ? row.expiresAt.getTime() : null + const stmt = this.#sqliteClient.prepare(` + INSERT OR REPLACE INTO ${this.#tableName} (key, value, expires_at) + VALUES (?, ?, ?) + `) + stmt.run(row.key, row.value, expiresAtValue) + } + + /** + * Set implementation for SQLite using Drizzle ORM + */ + async #setSqlite(row: { key: string; value: string; expiresAt: Date | null }): Promise { + const expiresAtValue = row.expiresAt ? row.expiresAt.getTime() : null + + // First try using onConflictDoUpdate method + if (typeof this.db.insert === 'function' && typeof this.db.onConflictDoUpdate === 'function') { + await this.db + .insert(this.#table) + .values({ + key: row.key, + value: row.value, + expiresAt: expiresAtValue, + }) + .onConflictDoUpdate({ + target: this.#table.key, + set: { + value: row.value, + expiresAt: expiresAtValue, + }, + }) + return + } + + // Check if record exists + const existing = await this.db + .select() + .from(this.#table) + .where(eq(this.#table.key, row.key)) + .limit(1) + + if (existing && existing.length > 0) { + // Update + await this.db + .update(this.#table) + .set({ + value: row.value, + expiresAt: expiresAtValue, + }) + .where(eq(this.#table.key, row.key)) + } else { + // Insert + await this.db.insert(this.#table).values({ + key: row.key, + value: row.value, + expiresAt: expiresAtValue, + }) + } + } + + /** + * Set implementation for MySQL using raw SQL + */ + async #setMysql(row: { key: string; value: string; expiresAt: Date | null }): Promise { + if (row.expiresAt === null) { + await this.db.execute(rawSql` + REPLACE INTO ${rawSql.identifier(this.#tableName)} ( + \`key\`, \`value\`, \`expires_at\` + ) VALUES ( + ${row.key}, ${row.value}, NULL + ) + `) + } else { + // Use FROM_UNIXTIME to ensure correct timestamp handling + const timestamp = Math.floor(row.expiresAt.getTime() / 1000) + + await this.db.execute(rawSql` + REPLACE INTO ${rawSql.identifier(this.#tableName)} ( + \`key\`, \`value\`, \`expires_at\` + ) VALUES ( + ${row.key}, ${row.value}, FROM_UNIXTIME(${timestamp}) + ) + `) + } + } + + /** + * Default set implementation (PostgreSQL) + */ + async #setDefault(row: { key: string; value: string; expiresAt: Date | null }): Promise { + await this.db + .insert(this.#table) + .values({ + key: row.key, + value: row.value, + expiresAt: row.expiresAt, + }) + .onConflictDoUpdate({ + target: this.#table.key, + set: { + value: row.value, + expiresAt: row.expiresAt, + }, + }) + } + + // --- Delete implementations --- + + /** + * Delete implementation for MySQL with explicit existence check + */ + async #deleteMysql(key: string): Promise { + // First check if record exists + const existsResult = await this.db.execute(rawSql` + SELECT COUNT(*) as count + FROM ${rawSql.identifier(this.#tableName)} + WHERE \`key\` = ${key} + LIMIT 1 + `) + + // Process MySQL result + const rows = + Array.isArray(existsResult) && existsResult.length === 2 ? existsResult[0] : existsResult + + const exists = rows && rows.length > 0 && rows[0].count > 0 + + // If exists, then delete + if (exists) { + await this.db.delete(this.#table).where(eq(this.#table.key, key)) + return true + } + + return false + } + + /** + * Default delete implementation with returning support + */ + async #deleteDefault(key: string): Promise { + // Some SQLite drivers may not support returning + if (this.#dialect === 'sqlite') { + // First check if record exists + const existingRecord = await this.db + .select() + .from(this.#table) + .where(eq(this.#table.key, key)) + .limit(1) + + // Execute delete operation + await this.db.delete(this.#table).where(eq(this.#table.key, key)) + + // Return whether record was deleted + return existingRecord.length > 0 + } + + // PostgreSQL and other databases supporting returning + const result = await this.db.delete(this.#table).where(eq(this.#table.key, key)).returning() + return result.length > 0 + } + + // --- DeleteMany implementations --- + + /** + * DeleteMany implementation for MySQL + */ + async #deleteManyMysql(keys: string[]): Promise { + // Simply return 0 for empty arrays - this matches other drivers + if (keys.length === 0) return 0 + + const result = await this.db.delete(this.#table).where(inArray(this.#table.key, keys)) + + // Return number of affected rows, matching Kysely's implementation + return typeof result.affectedRows === 'number' ? result.affectedRows : 0 + } + + /** + * Default deleteMany implementation + */ + async #deleteManyDefault(keys: string[]): Promise { + // Simply return 0 for empty arrays - this matches other drivers + if (keys.length === 0) return 0 + + // For PostgreSQL and other databases supporting returning + if (this.#dialect !== 'sqlite') { + const result = await this.db + .delete(this.#table) + .where(inArray(this.#table.key, keys)) + .returning() + + // Return actual count rather than keys.length + return result.length + } + + // For SQLite, we need to do a simpler version + // SQLite may not support returning in some configurations + const result = await this.db.delete(this.#table).where(inArray(this.#table.key, keys)) + + // Return 0 if no result is available + return result && typeof result.length === 'number' ? result.length : 0 + } + + // --- Clear implementations --- + + /** + * Clear implementation using native SQLite client + */ + async #clearSqliteNative(prefix: string): Promise { + const stmt = this.#sqliteClient.prepare(` + DELETE FROM ${this.#tableName} + WHERE key LIKE ? + `) + stmt.run(`${prefix}%`) + } + + /** + * Default clear implementation using like operator + */ + async #clearDefault(prefix: string): Promise { + await this.db.delete(this.#table).where(like(this.#table.key, `${prefix}%`)) + } + + // --- PruneExpiredEntries implementations --- + + /** + * PruneExpiredEntries implementation using native SQLite client + */ + async #pruneExpiredEntriesSqliteNative(): Promise { + const now = Date.now() + const stmt = this.#sqliteClient.prepare(` + DELETE FROM ${this.#tableName} + WHERE expires_at IS NOT NULL AND expires_at < ? + `) + stmt.run(now) + } + + /** + * PruneExpiredEntries implementation for SQLite using Drizzle ORM + */ + async #pruneExpiredEntriesSqlite(): Promise { + const now = Date.now() + await this.db + .delete(this.#table) + .where(and(gt(this.#table.expiresAt, 0), lt(this.#table.expiresAt, now))) + } + + /** + * Default pruneExpiredEntries implementation + */ + async #pruneExpiredEntriesDefault(): Promise { + const now = new Date() + await this.db.delete(this.#table).where(lt(this.#table.expiresAt, now)) + } + + // --- CreateTable implementations --- + + /** + * Create table implementation for PostgreSQL + */ + async #createTablePg(): Promise { + await this.db.execute(rawSql` + CREATE TABLE IF NOT EXISTS ${rawSql.identifier(this.#tableName)} ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + expires_at TIMESTAMP WITH TIME ZONE + ) + `) + } + + /** + * Create table implementation for MySQL + */ + async #createTableMysql(): Promise { + await this.db.execute(rawSql` + CREATE TABLE IF NOT EXISTS ${rawSql.identifier(this.#tableName)} ( + \`key\` VARCHAR(255) PRIMARY KEY, + \`value\` TEXT NOT NULL, + \`expires_at\` TIMESTAMP NULL DEFAULT NULL + ) + `) + } + + /** + * Create table implementation using native SQLite client + */ + async #createTableSqliteNative(): Promise { + this.#sqliteClient.exec(` + CREATE TABLE IF NOT EXISTS ${this.#tableName} ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + expires_at INTEGER + ) + `) + } + + /** + * Create table implementation for SQLite using Drizzle ORM + */ + async #createTableSqlite(): Promise { + // Try different methods until finding a working one + + // Try using SQL execution + if (typeof this.db.execute === 'function') { + await this.db.execute(rawSql` + CREATE TABLE IF NOT EXISTS ${rawSql.identifier(this.#tableName)} ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + expires_at INTEGER + ) + `) + return + } + + // Try using run method (supported by some SQLite clients) + if (typeof this.db.run === 'function') { + await this.db.run(` + CREATE TABLE IF NOT EXISTS ${this.#tableName} ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + expires_at INTEGER + ) + `) + return + } + + // Try using schema API + if (this.db.schema) { + const hasTable = await this.db.schema.hasTable?.(this.#tableName) + if (!hasTable) { + await this.db.schema + .createTable(this.#tableName) + .ifNotExists() + .addColumn('key', 'text', (col: any) => col.primaryKey()) + .addColumn('value', 'text', (col: any) => col.notNull()) + .addColumn('expires_at', 'integer') + .execute() + } + return + } + + // Finally try using existing table + await this.db.select().from(this.#table).limit(1) + } + + /** + * Get type-safe database connection + */ + private get db(): any { + return this.#connection + } + + /** + * Detect database dialect from connection object + */ + #detectDialect(): 'pg' | 'mysql' | 'sqlite' { + const connConstructor = this.#connection.constructor.name.toLowerCase() + + if (connConstructor.includes('pg') || connConstructor.includes('postgres')) { + return 'pg' + } else if (connConstructor.includes('mysql')) { + return 'mysql' + } else if (connConstructor.includes('sqlite')) { + return 'sqlite' + } + + return 'pg' + } + + /** + * Create table schema based on database dialect + */ + #createTable() { + switch (this.#dialect) { + case 'pg': + return pgTable(this.#tableName, { + key: pgText('key').primaryKey(), + value: pgText('value').notNull(), + expiresAt: pgTimestamp('expires_at', { withTimezone: true }), + }) + case 'mysql': + return mysqlTable(this.#tableName, { + key: mysqlText('key').primaryKey(), + value: mysqlText('value').notNull(), + expiresAt: mysqlTimestamp('expires_at'), + }) + case 'sqlite': + // Ensure correct column types, use integer for timestamps in SQLite + return sqliteTable(this.#tableName, { + key: sqliteText('key').primaryKey(), + value: sqliteText('value').notNull(), + expiresAt: integer('expires_at'), // Use plain number + }) + } + } + + /** + * Extract native SQLite client from drizzle-wrapped connection + */ + #extractSqliteNativeClient(): any { + const conn = this.#connection + + // Try common paths to find SQLite client + const possiblePaths = [ + (conn as any)?.driver?.database, + (conn as any)?.client, + (conn as any)?.db, + (conn as any)?.connection, + ] + + for (const client of possiblePaths) { + if (client && typeof client.exec === 'function') { + return client + } + } + + return null + } +} diff --git a/packages/bentocache/src/types/options/drivers_options.ts b/packages/bentocache/src/types/options/drivers_options.ts index e8d5877..1705bad 100644 --- a/packages/bentocache/src/types/options/drivers_options.ts +++ b/packages/bentocache/src/types/options/drivers_options.ts @@ -1,6 +1,9 @@ import type { Knex } from 'knex' import type { Kysely } from 'kysely' +import type { MySql2Database } from 'drizzle-orm/mysql2' +import type { NodePgDatabase } from 'drizzle-orm/node-postgres' import type { DynamoDBClientConfig } from '@aws-sdk/client-dynamodb' +import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3' import type { Redis as IoRedis, RedisOptions as IoRedisOptions } from 'ioredis' import type { DbResult, DefaultColumnTypes, DefaultSchemaConfig } from 'orchid-orm' @@ -176,3 +179,17 @@ export interface OrchidConfig extends DatabaseConfig { */ connection: DbResult> } + +/** + * Configuration accepted by the Drizzle ORM adapter + */ +export interface DrizzleConfig extends DatabaseConfig { + /** + * The Drizzle ORM instance + */ + dialect: 'pg' | 'mysql' | 'sqlite' + connection: + | NodePgDatabase> + | MySql2Database + | BetterSQLite3Database +} diff --git a/packages/bentocache/tests/drivers/drizzle/helpers.ts b/packages/bentocache/tests/drivers/drizzle/helpers.ts new file mode 100644 index 0000000..7809333 --- /dev/null +++ b/packages/bentocache/tests/drivers/drizzle/helpers.ts @@ -0,0 +1,8 @@ +import type { DrizzleConfig } from '../../../src/types/main.js' +import { DatabaseDriver } from '../../../src/drivers/database/database.js' +import { DrizzleAdapter } from '../../../src/drivers/database/adapters/drizzle.js' + +export function createDrizzleStore(options: DrizzleConfig) { + const adapter = new DrizzleAdapter(options) + return new DatabaseDriver(adapter, options) +} diff --git a/packages/bentocache/tests/drivers/drizzle/mysql.spec.ts b/packages/bentocache/tests/drivers/drizzle/mysql.spec.ts new file mode 100644 index 0000000..bad173b --- /dev/null +++ b/packages/bentocache/tests/drivers/drizzle/mysql.spec.ts @@ -0,0 +1,33 @@ +import mysql from 'mysql2/promise' +import { test } from '@japa/runner' +import { drizzle } from 'drizzle-orm/mysql2' + +import { createDrizzleStore } from './helpers.js' +import { registerCacheDriverTestSuite } from '../../../src/test_suite.js' + +test.group('Drizzle | MySQL driver', (group) => { + // 只有在连接成功时才注册测试套件 + registerCacheDriverTestSuite({ + test, + group, + supportsMilliseconds: true, + createDriver: (options) => { + const db = drizzle({ + client: mysql.createPool({ + host: 'localhost', + port: 3306, + database: 'test', + user: 'root', + password: 'root', + }), + }) + + return createDrizzleStore({ + connection: db, + dialect: 'mysql', + prefix: 'japa', + ...options, + }) + }, + }) +}) diff --git a/packages/bentocache/tests/drivers/drizzle/postgres.spec.ts b/packages/bentocache/tests/drivers/drizzle/postgres.spec.ts new file mode 100644 index 0000000..352a34e --- /dev/null +++ b/packages/bentocache/tests/drivers/drizzle/postgres.spec.ts @@ -0,0 +1,26 @@ +import pg from 'pg' +import { test } from '@japa/runner' +import { drizzle } from 'drizzle-orm/node-postgres' + +import { createDrizzleStore } from './helpers.js' +import { registerCacheDriverTestSuite } from '../../../src/test_suite.js' + +test.group('Drizzle | Postgres driver', (group) => { + registerCacheDriverTestSuite({ + test, + group, + supportsMilliseconds: false, + createDriver: (options) => { + const pool = new pg.Pool({ + host: 'localhost', + port: 5432, + database: 'postgres', + user: 'mudong17', + password: '12345678', + }) + const db = drizzle({ client: pool }) + + return createDrizzleStore({ connection: db, dialect: 'pg', prefix: 'japa', ...options }) + }, + }) +}) diff --git a/packages/bentocache/tests/drivers/drizzle/sqlite.spec.ts b/packages/bentocache/tests/drivers/drizzle/sqlite.spec.ts new file mode 100644 index 0000000..af4fc48 --- /dev/null +++ b/packages/bentocache/tests/drivers/drizzle/sqlite.spec.ts @@ -0,0 +1,27 @@ +import { test } from '@japa/runner' +import Database from 'better-sqlite3' +import { drizzle } from 'drizzle-orm/better-sqlite3' + +import { createDrizzleStore } from './helpers.js' +import { registerCacheDriverTestSuite } from '../../../src/test_suite.js' + +test.group('Drizzle | SQLite driver', (group) => { + registerCacheDriverTestSuite({ + test, + group, + supportsMilliseconds: false, + createDriver: (options) => { + const db = drizzle({ + client: new Database('./cache.db'), + }) + + return createDrizzleStore({ + connection: db, + dialect: 'sqlite', + prefix: 'japa', + tableName: 'bentocache', + ...options, + }) + }, + }) +}) From ea4e98223603d43f0cdab758f7f45bfa7cf01df3 Mon Sep 17 00:00:00 2001 From: Julien Ripouteau Date: Sun, 20 Apr 2025 18:01:09 +0200 Subject: [PATCH 2/2] fix: add disconnect + some config fixes --- packages/bentocache/package.json | 2 +- .../src/drivers/database/adapters/drizzle.ts | 9 +- .../tests/drivers/drizzle/mysql.spec.ts | 10 +- .../tests/drivers/drizzle/postgres.spec.ts | 4 +- .../tests/drivers/drizzle/sqlite.spec.ts | 4 +- pnpm-lock.yaml | 104 ++++++++++++++++++ 6 files changed, 117 insertions(+), 16 deletions(-) diff --git a/packages/bentocache/package.json b/packages/bentocache/package.json index 7d93b20..0b3fea7 100644 --- a/packages/bentocache/package.json +++ b/packages/bentocache/package.json @@ -133,4 +133,4 @@ "web": true } } -} \ No newline at end of file +} diff --git a/packages/bentocache/src/drivers/database/adapters/drizzle.ts b/packages/bentocache/src/drivers/database/adapters/drizzle.ts index 717b0ba..c0f79e7 100644 --- a/packages/bentocache/src/drivers/database/adapters/drizzle.ts +++ b/packages/bentocache/src/drivers/database/adapters/drizzle.ts @@ -163,10 +163,15 @@ export class DrizzleAdapter implements DatabaseAdapter { } /** - * Disconnect from the database (not required for Drizzle) + * Disconnect from the database */ async disconnect(): Promise { - // No explicit disconnect needed for Drizzle + if ('$client' in this.#connection) { + const client = this.#connection.$client as any + if (typeof client.end === 'function') { + await client.end() + } + } } /** diff --git a/packages/bentocache/tests/drivers/drizzle/mysql.spec.ts b/packages/bentocache/tests/drivers/drizzle/mysql.spec.ts index bad173b..9586ac7 100644 --- a/packages/bentocache/tests/drivers/drizzle/mysql.spec.ts +++ b/packages/bentocache/tests/drivers/drizzle/mysql.spec.ts @@ -6,7 +6,6 @@ import { createDrizzleStore } from './helpers.js' import { registerCacheDriverTestSuite } from '../../../src/test_suite.js' test.group('Drizzle | MySQL driver', (group) => { - // 只有在连接成功时才注册测试套件 registerCacheDriverTestSuite({ test, group, @@ -16,18 +15,13 @@ test.group('Drizzle | MySQL driver', (group) => { client: mysql.createPool({ host: 'localhost', port: 3306, - database: 'test', + database: 'mysql', user: 'root', password: 'root', }), }) - return createDrizzleStore({ - connection: db, - dialect: 'mysql', - prefix: 'japa', - ...options, - }) + return createDrizzleStore({ connection: db, dialect: 'mysql', prefix: 'japa', ...options }) }, }) }) diff --git a/packages/bentocache/tests/drivers/drizzle/postgres.spec.ts b/packages/bentocache/tests/drivers/drizzle/postgres.spec.ts index 352a34e..e3df6ba 100644 --- a/packages/bentocache/tests/drivers/drizzle/postgres.spec.ts +++ b/packages/bentocache/tests/drivers/drizzle/postgres.spec.ts @@ -15,8 +15,8 @@ test.group('Drizzle | Postgres driver', (group) => { host: 'localhost', port: 5432, database: 'postgres', - user: 'mudong17', - password: '12345678', + user: 'postgres', + password: 'postgres', }) const db = drizzle({ client: pool }) diff --git a/packages/bentocache/tests/drivers/drizzle/sqlite.spec.ts b/packages/bentocache/tests/drivers/drizzle/sqlite.spec.ts index af4fc48..ec3ad00 100644 --- a/packages/bentocache/tests/drivers/drizzle/sqlite.spec.ts +++ b/packages/bentocache/tests/drivers/drizzle/sqlite.spec.ts @@ -11,9 +11,7 @@ test.group('Drizzle | SQLite driver', (group) => { group, supportsMilliseconds: false, createDriver: (options) => { - const db = drizzle({ - client: new Database('./cache.db'), - }) + const db = drizzle({ client: new Database('./cache.db') }) return createDrizzleStore({ connection: db, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b74d8fb..e2b1748 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -214,6 +214,9 @@ importers: dayjs: specifier: ^1.11.13 version: 1.11.13 + drizzle-orm: + specifier: ^0.41.0 + version: 0.41.0(@opentelemetry/api@1.7.0)(@types/better-sqlite3@7.6.12)(@types/pg@8.11.11)(better-sqlite3@11.8.1)(knex@3.1.0(better-sqlite3@11.8.1)(mysql2@3.12.0)(pg@8.13.3)(sqlite3@5.1.7))(kysely@0.27.5)(mysql2@3.12.0)(pg@8.13.3)(sqlite3@5.1.7) emittery: specifier: ^1.1.0 version: 1.1.0 @@ -3602,6 +3605,95 @@ packages: resolution: {integrity: sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==} engines: {node: '>=12'} + drizzle-orm@0.41.0: + resolution: {integrity: sha512-7A4ZxhHk9gdlXmTdPj/lREtP+3u8KvZ4yEN6MYVxBzZGex5Wtdc+CWSbu7btgF6TB0N+MNPrvW7RKBbxJchs/Q==} + peerDependencies: + '@aws-sdk/client-rds-data': '>=3' + '@cloudflare/workers-types': '>=4' + '@electric-sql/pglite': '>=0.2.0' + '@libsql/client': '>=0.10.0' + '@libsql/client-wasm': '>=0.10.0' + '@neondatabase/serverless': '>=0.10.0' + '@op-engineering/op-sqlite': '>=2' + '@opentelemetry/api': ^1.4.1 + '@planetscale/database': '>=1' + '@prisma/client': '*' + '@tidbcloud/serverless': '*' + '@types/better-sqlite3': '*' + '@types/pg': '*' + '@types/sql.js': '*' + '@vercel/postgres': '>=0.8.0' + '@xata.io/client': '*' + better-sqlite3: '>=7' + bun-types: '*' + expo-sqlite: '>=14.0.0' + gel: '>=2' + knex: '*' + kysely: '*' + mysql2: '>=2' + pg: '>=8' + postgres: '>=3' + prisma: '*' + sql.js: '>=1' + sqlite3: '>=5' + peerDependenciesMeta: + '@aws-sdk/client-rds-data': + optional: true + '@cloudflare/workers-types': + optional: true + '@electric-sql/pglite': + optional: true + '@libsql/client': + optional: true + '@libsql/client-wasm': + optional: true + '@neondatabase/serverless': + optional: true + '@op-engineering/op-sqlite': + optional: true + '@opentelemetry/api': + optional: true + '@planetscale/database': + optional: true + '@prisma/client': + optional: true + '@tidbcloud/serverless': + optional: true + '@types/better-sqlite3': + optional: true + '@types/pg': + optional: true + '@types/sql.js': + optional: true + '@vercel/postgres': + optional: true + '@xata.io/client': + optional: true + better-sqlite3: + optional: true + bun-types: + optional: true + expo-sqlite: + optional: true + gel: + optional: true + knex: + optional: true + kysely: + optional: true + mysql2: + optional: true + pg: + optional: true + postgres: + optional: true + prisma: + optional: true + sql.js: + optional: true + sqlite3: + optional: true + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -11137,6 +11229,18 @@ snapshots: dotenv@16.4.7: {} + drizzle-orm@0.41.0(@opentelemetry/api@1.7.0)(@types/better-sqlite3@7.6.12)(@types/pg@8.11.11)(better-sqlite3@11.8.1)(knex@3.1.0(better-sqlite3@11.8.1)(mysql2@3.12.0)(pg@8.13.3)(sqlite3@5.1.7))(kysely@0.27.5)(mysql2@3.12.0)(pg@8.13.3)(sqlite3@5.1.7): + optionalDependencies: + '@opentelemetry/api': 1.7.0 + '@types/better-sqlite3': 7.6.12 + '@types/pg': 8.11.11 + better-sqlite3: 11.8.1 + knex: 3.1.0(better-sqlite3@11.8.1)(mysql2@3.12.0)(pg@8.13.3)(sqlite3@5.1.7) + kysely: 0.27.5 + mysql2: 3.12.0 + pg: 8.13.3 + sqlite3: 5.1.7 + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2