prisma-driver-adapter-implementation

--- Référence requise pour le travail d'adaptateur de pilote Prisma v7. À utiliser lors de l'implémentation ou de la modification d'adaptateurs, de l'ajout de pilotes de base de données ou de la modification des interfaces SqlDriverAdapter/Transaction. Contient les détails critiques du contrat non déductibles des exemples de code — y compris le protocole du cycle de vie des transactions, les exigences de mappage des erreurs et la liste de vérification. Les implémentations existantes ne remplacent pas cette compétence.

npx skills add https://github.com/prisma/skills --skill prisma-driver-adapter-implementation

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: true lors de l'ouverture de la base de données pour obtenir bigint pour les grands entiers
  • Seul le niveau d'isolement SERIALIZABLE est 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 int8 peuvent être retournées sous forme de string (déjà stringifiées par le pilote)
  • Les colonnes numeric sont 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 BIGINT comme 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é :

  • [ ] SqlMigrationAwareDriverAdapterFactory implémenté avec connect() et connectToShadowDb()
  • [ ] SqlDriverAdapter implémente queryRaw, executeRaw, executeScript, startTransaction, dispose
  • [ ] Transaction implémente queryRaw, executeRaw, commit, rollback avec options: { usePhantomQuery: false }
  • [ ] commit() et rollback() sont des crochets de cycle de vie uniquement (pas de SQL émis)
  • [ ] startTransaction émet BEGIN (profondeur 1) ou SAVEPOINT 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 DriverAdapterError avec un MappedError kind 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