Guide de mise en œuvre de l'adaptateur de pilote Prisma 7
Cette compétence fournit tout ce qui est nécessaire pour mettre en œuvre un adaptateur de pilote Prisma ORM v7 pour toute base de données.
Aperçu de l'architecture
┌─────────────────────────────────────────────────────────────────┐
│ 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) │
└─────────────────────────────────────────────────────────────────┘
Interfaces requises
Importez depuis @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";
Définitions des interfaces
SqlQuery (entrée pour queryRaw/executeRaw)
type SqlQuery = {
sql: string; // SQL paramétré avec des espaces réservés
args: Array<unknown>; // Valeurs de paramètres liés
argTypes: Array<ArgType>; // Indices de type pour chaque argument
};
type ArgType = {
scalarType: ArgScalarType; // 'string' | 'int' | 'bigint' | 'float' | 'decimal' | 'boolean' | 'enum' | 'uuid' | 'json' | 'datetime' | 'bytes' | 'unknown'
dbType?: string;
arity: "scalar" | "list";
};
SqlResultSet (sortie de queryRaw)
interface SqlResultSet {
columnNames: Array<string>; // Noms de colonnes dans l'ordre
columnTypes: Array<ColumnType>; // Types de colonnes correspondant aux columnNames
rows: Array<Array<unknown>>; // Données de lignes sous forme de tableaux
lastInsertId?: string; // Pour INSERT sans RETURNING
}
Valeurs de ColumnTypeEnum
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>;
}
Étapes de mise en œuvre
Étape 1 : Créer la classe de base Queryable
class MyQueryable<TClient> implements SqlQueryable {
readonly provider = "postgres" as const; // ou '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" })
);
// Exécuter la requête avec votre pilote
const result = await this.client.query(query.sql, args);
// Extraire les métadonnées de colonne
const columnNames = /* obtenir du résultat */;
const columnTypes = /* mapper vers ColumnTypeEnum */;
// Mapper les lignes vers des tableaux ResultValue
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));
}
}
Étape 2 : Créer la classe Transaction
Critique : commit() et rollback() sont des crochets de cycle de vie uniquement. Ils ne doivent PAS émettre de SQL. Prisma envoie COMMIT/ROLLBACK via executeRaw sur l'objet transaction.
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> {
// NE PAS émettre de SQL COMMIT ici — Prisma le fait via executeRaw
this.#release(); // Libérer la connexion/les ressources
return Promise.resolve();
}
rollback(): Promise<void> {
// NE PAS émettre de SQL ROLLBACK ici — Prisma le fait via executeRaw
this.#release();
return Promise.resolve();
}
}
Étape 3 : Créer la classe Adapter
class MyAdapter extends MyQueryable<TClient> implements SqlDriverAdapter {
#transactionDepth = 0;
constructor(client: TClient) {
super(client);
}
async executeScript(script: string): Promise<void> {
// Pour SQLite : diviser par ';' et exécuter chaque instruction
// Pour Postgres : utiliser l'exécution multi-instructions
try {
// La mise en œuvre dépend des capacités du pilote
} catch (e) {
this.onError(e);
}
}
async startTransaction(
isolationLevel?: IsolationLevel,
): Promise<Transaction> {
// Valider le niveau d'isolement pour votre base de données
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) {
// Émettre BEGIN (avec niveau d'isolement si spécifié)
const beginSql = isolationLevel
? `BEGIN ISOLATION LEVEL ${isolationLevel}`
: "BEGIN";
await this.client.query(beginSql);
} else {
// Imbriquées : utiliser des points de sauvegarde
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();
}
}
Étape 4 : Créer la classe Factory
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)));
}
}
Assistants de conversion
Mappage d'arguments (entrée)
Convertir les valeurs d'argument Prisma en types natifs du pilote :
function mapArg(arg: unknown, argType: ArgType): unknown {
if (arg === null || arg === undefined) return null;
// String → nombre pour colonnes int
if (typeof arg === "string" && argType.scalarType === "int")
return Number.parseInt(arg, 10);
// String → nombre pour colonnes float
if (typeof arg === "string" && argType.scalarType === "float")
return Number.parseFloat(arg);
// String → BigInt pour colonnes bigint
if (typeof arg === "string" && argType.scalarType === "bigint")
return BigInt(arg);
// String en base64 → Buffer pour colonnes bytes
if (typeof arg === "string" && argType.scalarType === "bytes")
return Buffer.from(arg, "base64");
// Boolean → 0/1 pour SQLite
if (typeof arg === "boolean" && /* SQLite */)
return arg ? 1 : 0;
return arg;
}
Mappage de lignes (sortie)
Convertir les valeurs de résultat du pilote en types attendus par Prisma :
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 pour Int64 (compatible JSON)
if (typeof value === "bigint") {
result.push(value.toString());
continue;
}
// Date → string ISO 8601 pour DateTime
if (value instanceof Date) {
result.push(value.toISOString());
continue;
}
// Objets JSON → stringifiés
if (colType === ColumnTypeEnum.Json && typeof value === "object") {
result.push(JSON.stringify(value));
continue;
}
result.push(value as ResultValue);
}
return result;
}
Inférence du type de colonne
Lorsque le pilote ne fournit pas de métadonnées de type, déduire à partir des valeurs JS :
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;
}
Gestion des erreurs
Mapper les erreurs du pilote vers MappedError pour que Prisma les traite correctement :
function convertDriverError(error: unknown): MappedError {
if (error instanceof Error) {
// Mappage d'erreur spécifique à la base de données
const dbError = error as Error & { code?: string; errno?: number };
// Exemple PostgreSQL
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" };
}
// Exemple SQLite
if (error.name === "SQLiteError") {
return {
kind: "sqlite",
extendedCode: dbError.errno ?? 1,
message: error.message,
};
}
// Erreur PostgreSQL brute
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 };
}
Notes spécifiques aux bases de données
SQLite
- Définir
safeIntegers: truelors de l'ouverture de la base de données pour obtenirbigintpour les grands entiers - Seul le niveau d'isolement
SERIALIZABLEest valide executeScript: diviser par;et exécuter chaque instruction individuellement- Valeurs booléennes : stocker comme 0/1, retourner comme booléen
PostgreSQL
- Tous les niveaux d'isolement standard sont valides
- Pour le groupement de connexions (PgBouncer), utiliser
prepare: false - Les transactions nécessitent une connexion dédiée (motif
reserve()) executeScript: utiliser l'exécution multi-instructions (.simple()dans certains pilotes)- Les colonnes
int8peuvent être retournées sous forme de string (déjà stringifiées par le pilote) - Les colonnes
numericsont retournées sous forme de string pour préserver la précision
MySQL/MariaDB
- Supporte
READ UNCOMMITTED,READ COMMITTED,REPEATABLE READ,SERIALIZABLE - Utiliser les espaces réservés
?pour les paramètres - Gérer
BIGINTcomme string pour les grandes valeurs
Stratégie de test
Tests unitaires (pas de PrismaClient)
Tester l'adaptateur directement avec le pilote de base de données brut :
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 envoie COMMIT via executeRaw
await tx.executeRaw({ sql: "COMMIT", args: [], argTypes: [] });
await tx.commit(); // crochet de cycle de vie uniquement
// Vérifier que les données sont persistées
});
});
Tests E2E (avec PrismaClient)
Tester l'intégration complète :
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);
});
});
Exemple d'utilisation
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 });
// Utiliser prisma normalement
const users = await prisma.user.findMany();
Liste de contrôle
Avant de considérer l'adaptateur comme terminé :
- [ ]
SqlMigrationAwareDriverAdapterFactoryimplémenté avecconnect()etconnectToShadowDb() - [ ]
SqlDriverAdapterimplémentequeryRaw,executeRaw,executeScript,startTransaction,dispose - [ ]
TransactionimplémentequeryRaw,executeRaw,commit,rollbackavecoptions: { usePhantomQuery: false } - [ ]
commit()etrollback()sont des crochets de cycle de vie uniquement (pas de SQL émis) - [ ]
startTransactionémetBEGIN(profondeur 1) ouSAVEPOINT sp_N(imbriquée) - [ ] Le mappage d'arguments gère : string→int, string→bigint, string→float, base64→bytes
- [ ] Le mappage de lignes gère : bigint→string, Date→string ISO, JSON→string
- [ ] Les types de colonnes sont correctement mappés vers
ColumnTypeEnum - [ ] Les erreurs sont enveloppées dans
DriverAdapterErroravec unMappedErrorkind approprié - [ ] Validation du niveau d'isolement pour la base de données cible
- [ ] Les tests unitaires réussissent pour queryRaw, executeRaw, executeScript, transactions
- [ ] Les tests E2E réussissent avec un vrai PrismaClient