Prisma 7 Driver Adapter Implementation Guide
This skill provides everything needed to implement a Prisma ORM v7 driver adapter for any database.
Architecture Overview
┌─────────────────────────────────────────────────────────────────┐
│ PrismaClient │
│ (requires adapter factory) │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ SqlMigrationAwareDriverAdapterFactory │
│ ┌─────────────────────┐ ┌─────────────────────────────┐ │
│ │ connect() │ │ connectToShadowDb() │ │
│ │ → SqlDriverAdapter │ │ → SqlDriverAdapter │ │
│ └─────────────────────┘ └─────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ SqlDriverAdapter │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────────────┐ │
│ │ queryRaw() │ │ executeRaw() │ │ startTransaction() │ │
│ │ → ResultSet │ │ → number │ │ → Transaction │ │
│ └──────────────┘ └──────────────┘ └──────────────────────────┘ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────────────┐ │
│ │executeScript │ │ dispose() │ │ getConnectionInfo() │ │
│ └──────────────┘ └──────────────┘ └──────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Transaction │
│ Extends SqlQueryable + commit() + rollback() + options │
│ (lifecycle hooks only — Prisma sends SQL via executeRaw) │
└─────────────────────────────────────────────────────────────────┘
Required Interfaces
Import from @prisma/driver-adapter-utils:
import type {
ColumnType,
IsolationLevel,
SqlDriverAdapter,
SqlMigrationAwareDriverAdapterFactory,
SqlQuery,
SqlQueryable,
SqlResultSet,
Transaction,
TransactionOptions,
ArgType,
ConnectionInfo,
MappedError,
} from "@prisma/driver-adapter-utils";
import {
ColumnTypeEnum,
DriverAdapterError,
} from "@prisma/driver-adapter-utils";
Interface Definitions
SqlQuery (input to queryRaw/executeRaw)
type SqlQuery = {
sql: string; // Parameterized SQL with placeholders
args: Array<unknown>; // Bound parameter values
argTypes: Array<ArgType>; // Type hints for each argument
};
type ArgType = {
scalarType: ArgScalarType; // 'string' | 'int' | 'bigint' | 'float' | 'decimal' | 'boolean' | 'enum' | 'uuid' | 'json' | 'datetime' | 'bytes' | 'unknown'
dbType?: string;
arity: "scalar" | "list";
};
SqlResultSet (output from queryRaw)
interface SqlResultSet {
columnNames: Array<string>; // Column names in order
columnTypes: Array<ColumnType>; // Column types matching columnNames
rows: Array<Array<unknown>>; // Row data as arrays
lastInsertId?: string; // For INSERT without RETURNING
}
ColumnTypeEnum values
const ColumnTypeEnum = {
Int32: 0,
Int64: 1,
Float: 2,
Double: 3,
Numeric: 4,
Boolean: 5,
Character: 6,
Text: 7,
Date: 8,
Time: 9,
DateTime: 10,
Json: 11,
Enum: 12,
Bytes: 13,
Set: 14,
Uuid: 15,
Int32Array: 64,
Int64Array: 65,
FloatArray: 66,
DoubleArray: 67,
NumericArray: 68,
BooleanArray: 69,
CharacterArray: 70,
TextArray: 71,
DateArray: 72,
TimeArray: 73,
DateTimeArray: 74,
JsonArray: 75,
EnumArray: 76,
BytesArray: 77,
UuidArray: 78,
UnknownNumber: 128,
} as const;
SqlDriverAdapter
interface SqlDriverAdapter extends SqlQueryable {
executeScript(script: string): Promise<void>;
startTransaction(isolationLevel?: IsolationLevel): Promise<Transaction>;
getConnectionInfo?(): ConnectionInfo;
dispose(): Promise<void>;
}
Transaction
interface Transaction extends SqlQueryable {
readonly options: TransactionOptions;
commit(): Promise<void>;
rollback(): Promise<void>;
}
type TransactionOptions = { usePhantomQuery: boolean };
SqlMigrationAwareDriverAdapterFactory
interface SqlMigrationAwareDriverAdapterFactory {
readonly provider: "mysql" | "postgres" | "sqlite" | "sqlserver";
readonly adapterName: string;
connect(): Promise<SqlDriverAdapter>;
connectToShadowDb(): Promise<SqlDriverAdapter>;
}
Implementation Steps
Step 1: Create the Queryable base class
class MyQueryable<TClient> implements SqlQueryable {
readonly provider = "postgres" as const; // or 'sqlite' | 'mysql' | 'sqlserver'
readonly adapterName = "@my-org/adapter-mydb" as const;
constructor(protected readonly client: TClient) {}
async queryRaw(query: SqlQuery): Promise<SqlResultSet> {
try {
const args = query.args.map((arg, i) =>
mapArg(arg, query.argTypes[i] ?? { scalarType: "unknown", arity: "scalar" })
);
// Execute query with your driver
const result = await this.client.query(query.sql, args);
// Extract column metadata
const columnNames = /* get from result */;
const columnTypes = /* map to ColumnTypeEnum */;
// Map rows to ResultValue arrays
const rows = result.map(row => mapRow(row, columnTypes));
return { columnNames, columnTypes, rows };
} catch (e) {
this.onError(e);
}
}
async executeRaw(query: SqlQuery): Promise<number> {
try {
const args = query.args.map((arg, i) =>
mapArg(arg, query.argTypes[i] ?? { scalarType: "unknown", arity: "scalar" })
);
const result = await this.client.query(query.sql, args);
return result.affectedRows ?? 0;
} catch (e) {
this.onError(e);
}
}
protected onError(error: unknown): never {
throw new DriverAdapterError(convertDriverError(error));
}
}
Step 2: Create the Transaction class
Critical: commit() and rollback() are lifecycle hooks only. They must NOT issue SQL. Prisma sends COMMIT/ROLLBACK via executeRaw on the transaction object.
class MyTransaction extends MyQueryable<TClient> implements Transaction {
readonly options: TransactionOptions;
readonly #release: () => void;
constructor(
client: TClient,
options: TransactionOptions,
release: () => void,
) {
super(client);
this.options = options;
this.#release = release;
}
commit(): Promise<void> {
// DO NOT issue COMMIT SQL here — Prisma does it via executeRaw
this.#release(); // Release connection/resources
return Promise.resolve();
}
rollback(): Promise<void> {
// DO NOT issue ROLLBACK SQL here — Prisma does it via executeRaw
this.#release();
return Promise.resolve();
}
}
Step 3: Create the Adapter class
class MyAdapter extends MyQueryable<TClient> implements SqlDriverAdapter {
#transactionDepth = 0;
constructor(client: TClient) {
super(client);
}
async executeScript(script: string): Promise<void> {
// For SQLite: split on ';' and run each statement
// For Postgres: use multi-statement execution
try {
// Implementation depends on driver capabilities
} catch (e) {
this.onError(e);
}
}
async startTransaction(
isolationLevel?: IsolationLevel,
): Promise<Transaction> {
// Validate isolation level for your database
const validLevels = new Set<IsolationLevel>([
"READ UNCOMMITTED",
"READ COMMITTED",
"REPEATABLE READ",
"SERIALIZABLE",
]);
if (isolationLevel !== undefined && !validLevels.has(isolationLevel)) {
throw new DriverAdapterError({
kind: "InvalidIsolationLevel",
level: isolationLevel,
});
}
const options: TransactionOptions = { usePhantomQuery: false };
this.#transactionDepth += 1;
const depth = this.#transactionDepth;
try {
if (depth === 1) {
// Issue BEGIN (with isolation level if specified)
const beginSql = isolationLevel
? `BEGIN ISOLATION LEVEL ${isolationLevel}`
: "BEGIN";
await this.client.query(beginSql);
} else {
// Nested: use savepoints
await this.client.query(`SAVEPOINT sp_${depth}`);
}
} catch (e) {
this.#transactionDepth -= 1;
this.onError(e);
}
const release = () => {
this.#transactionDepth -= 1;
};
return new MyTransaction(this.client, options, release);
}
getConnectionInfo(): ConnectionInfo {
return { supportsRelationJoins: true };
}
async dispose(): Promise<void> {
await this.client.close();
}
}
Step 4: Create the Factory class
export type MyAdapterConfig = {
url: string;
};
export type MyAdapterOptions = {
shadowDatabaseUrl?: string;
};
export class MyAdapterFactory implements SqlMigrationAwareDriverAdapterFactory {
readonly provider = "postgres" as const;
readonly adapterName = "@my-org/adapter-mydb" as const;
constructor(
private readonly config: MyAdapterConfig,
private readonly options?: MyAdapterOptions,
) {}
connect(): Promise<SqlDriverAdapter> {
return Promise.resolve(new MyAdapter(openConnection(this.config.url)));
}
connectToShadowDb(): Promise<SqlDriverAdapter> {
const url = this.options?.shadowDatabaseUrl ?? this.config.url;
return Promise.resolve(new MyAdapter(openConnection(url)));
}
}
Conversion Helpers
Argument Mapping (input)
Convert Prisma argument values to driver-native types:
function mapArg(arg: unknown, argType: ArgType): unknown {
if (arg === null || arg === undefined) return null;
// String → number for int columns
if (typeof arg === "string" && argType.scalarType === "int")
return Number.parseInt(arg, 10);
// String → number for float columns
if (typeof arg === "string" && argType.scalarType === "float")
return Number.parseFloat(arg);
// String → BigInt for bigint columns
if (typeof arg === "string" && argType.scalarType === "bigint")
return BigInt(arg);
// Base64 string → Buffer for bytes columns
if (typeof arg === "string" && argType.scalarType === "bytes")
return Buffer.from(arg, "base64");
// Boolean → 0/1 for SQLite
if (typeof arg === "boolean" && /* SQLite */)
return arg ? 1 : 0;
return arg;
}
Row Mapping (output)
Convert driver result values to Prisma-expected types:
function mapRow(row: unknown[], columnTypes: ColumnType[]): ResultValue[] {
const result: ResultValue[] = [];
for (let i = 0; i < row.length; i++) {
const value = row[i] ?? null;
const colType = columnTypes[i];
if (value === null) {
result.push(null);
continue;
}
// bigint → string for Int64 (JSON-safe)
if (typeof value === "bigint") {
result.push(value.toString());
continue;
}
// Date → ISO 8601 string for DateTime
if (value instanceof Date) {
result.push(value.toISOString());
continue;
}
// JSON objects → stringified
if (colType === ColumnTypeEnum.Json && typeof value === "object") {
result.push(JSON.stringify(value));
continue;
}
result.push(value as ResultValue);
}
return result;
}
Column Type Inference
When the driver doesn't provide type metadata, infer from JS values:
function inferColumnType(value: NonNullable<unknown>): ColumnType {
if (typeof value === "boolean") return ColumnTypeEnum.Boolean;
if (typeof value === "bigint") return ColumnTypeEnum.Int64;
if (value instanceof Uint8Array) return ColumnTypeEnum.Bytes;
if (value instanceof Date) return ColumnTypeEnum.DateTime;
if (Array.isArray(value)) return ColumnTypeEnum.Text; // fallback
if (typeof value === "object") return ColumnTypeEnum.Json;
if (typeof value === "number") return ColumnTypeEnum.UnknownNumber;
return ColumnTypeEnum.Text;
}
Error Handling
Map driver errors to MappedError for Prisma to handle correctly:
function convertDriverError(error: unknown): MappedError {
if (error instanceof Error) {
// Database-specific error mapping
const dbError = error as Error & { code?: string; errno?: number };
// PostgreSQL example
if (dbError.code === "23505") {
return { kind: "UniqueConstraintViolation" };
}
if (dbError.code === "23502") {
return { kind: "NullConstraintViolation" };
}
if (dbError.code === "23503") {
return { kind: "ForeignKeyConstraintViolation" };
}
if (dbError.code === "42P01") {
return { kind: "TableDoesNotExist" };
}
// SQLite example
if (error.name === "SQLiteError") {
return {
kind: "sqlite",
extendedCode: dbError.errno ?? 1,
message: error.message,
};
}
// PostgreSQL raw error
if (dbError.code) {
return {
kind: "postgres",
code: dbError.code,
severity: "ERROR",
message: error.message,
detail: undefined,
column: undefined,
hint: undefined,
};
}
}
return { kind: "GenericJs", id: 0 };
}
Database-Specific Notes
SQLite
- Set
safeIntegers: truewhen opening the database to getbigintfor large integers - Only
SERIALIZABLEisolation level is valid executeScript: split on;and run each statement individually- Boolean values: store as 0/1, return as boolean
PostgreSQL
- All standard isolation levels are valid
- For connection pooling (PgBouncer), use
prepare: false - Transactions require a dedicated connection (
reserve()pattern) executeScript: use multi-statement execution (.simple()in some drivers)int8columns may return as string (already stringified by driver)numericcolumns return as string to preserve precision
MySQL/MariaDB
- Supports
READ UNCOMMITTED,READ COMMITTED,REPEATABLE READ,SERIALIZABLE - Use
?placeholders for parameters - Handle
BIGINTas string for large values
Testing Strategy
Unit Tests (no PrismaClient)
Test the adapter directly with the raw database driver:
describe("queryRaw", () => {
test("returns column names and types", async () => {
const adapter = new MyAdapter(createTestConnection());
const result = await adapter.queryRaw({
sql: "SELECT id, name FROM users",
args: [],
argTypes: [],
});
expect(result.columnNames).toEqual(["id", "name"]);
expect(result.columnTypes[0]).toBe(ColumnTypeEnum.Int32);
});
});
describe("startTransaction", () => {
test("commit persists changes", async () => {
const adapter = new MyAdapter(createTestConnection());
const tx = await adapter.startTransaction();
await tx.executeRaw({
sql: "INSERT INTO users (name) VALUES (?)",
args: ["Alice"],
argTypes: [],
});
// Prisma sends COMMIT via executeRaw
await tx.executeRaw({ sql: "COMMIT", args: [], argTypes: [] });
await tx.commit(); // lifecycle hook only
// Verify data persisted
});
});
E2E Tests (with PrismaClient)
Test the full integration:
describe("E2E", () => {
let prisma: PrismaClient;
beforeEach(async () => {
const factory = new MyAdapterFactory({ url: TEST_DB_URL });
prisma = new PrismaClient({ adapter: factory });
});
test("CRUD operations", async () => {
const user = await prisma.user.create({ data: { name: "Alice" } });
expect(user.id).toBeGreaterThan(0);
const found = await prisma.user.findUnique({ where: { id: user.id } });
expect(found?.name).toBe("Alice");
});
test("transactions roll back on error", async () => {
await expect(
prisma.$transaction(async (tx) => {
await tx.user.create({ data: { name: "Bob" } });
throw new Error("Rollback!");
}),
).rejects.toThrow();
expect(await prisma.user.count()).toBe(0);
});
});
Usage Example
import { PrismaClient } from "./generated/prisma/client";
import { MyAdapterFactory } from "@my-org/adapter-mydb";
const factory = new MyAdapterFactory({
url: process.env.DATABASE_URL!,
});
const prisma = new PrismaClient({ adapter: factory });
// Use prisma normally
const users = await prisma.user.findMany();
Checklist
Before considering the adapter complete:
-
SqlMigrationAwareDriverAdapterFactoryimplemented withconnect()andconnectToShadowDb() -
SqlDriverAdapterimplementsqueryRaw,executeRaw,executeScript,startTransaction,dispose -
TransactionimplementsqueryRaw,executeRaw,commit,rollbackwithoptions: { usePhantomQuery: false } -
commit()androllback()are lifecycle hooks only (no SQL issued) -
startTransactionissuesBEGIN(depth 1) orSAVEPOINT sp_N(nested) - Argument mapping handles: string→int, string→bigint, string→float, base64→bytes
- Row mapping handles: bigint→string, Date→ISO string, JSON→string
- Column types correctly mapped to
ColumnTypeEnum - Errors wrapped in
DriverAdapterErrorwith properMappedErrorkind - Isolation level validation for the target database
- Unit tests pass for queryRaw, executeRaw, executeScript, transactions
- E2E tests pass with real PrismaClient