diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 08ea404d1f87..f961c20df6fa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -171,7 +171,7 @@ jobs: index: [1, 2, 3, 4] env: TEST_TIMEOUT_MULTIPLIER: 2 - MYSQL_URL: mysql://root:root@127.0.0.1:3306/mysql + MYSQL_URL: mysql://root:root@127.0.0.1:3306/mysql?allowPublicKeyRetrieval=true services: postgres: image: postgres:15 @@ -240,7 +240,7 @@ jobs: # container, because GitHub Actions service containers can't override # the postgres CMD to enable shared_preload_libraries=pg_stat_statements). WRANGLER_HYPERDRIVE_LOCAL_CONNECTION_STRING_HYPERDRIVE: postgres://postgres:postgres@127.0.0.1:5433/prisma_8_cloudflare_worker - MYSQL_URL: mysql://root:root@127.0.0.1:3306/mysql + MYSQL_URL: mysql://root:root@127.0.0.1:3306/mysql?allowPublicKeyRetrieval=true services: postgres: image: postgres:15 diff --git a/docs/reference/error-reference.md b/docs/reference/error-reference.md index e6ea563ddac1..eafb86c9aacf 100644 --- a/docs/reference/error-reference.md +++ b/docs/reference/error-reference.md @@ -1176,11 +1176,11 @@ A lane terminal (SQL DSL `.build()`, ORM collection terminal) received an annota ### RUNTIME.ARGUMENT_INVALID -A built-in Postgres query operation or full-text helper received an argument it cannot use, `postgres()` or `postgresServerless()` received a `cursor` option it cannot use, or `createInMemoryCacheStore` received a size or lifetime it cannot use. Five cases: (1) the `language` of `fullTextMatches`, `fullTextRank` and `fullTextHeadline`, of the `tsquery` parsers (`websearchToTsquery`, `toTsquery`, `plaintoTsquery`, `phrasetoTsquery`) and of the `tsquery` template tag is written into the SQL as an inline literal rather than a bound parameter, so it is checked against the text-search configurations a stock PostgreSQL server ships with and anything else is refused, while the query is being built; (2) a literal part of a `tsquery` template has an invalid JavaScript escape, such as `\u`, so JavaScript gives the tag no text for that part and the tag refuses it rather than drop it, also while the query is being built; (3) the `cursor` option has a key other than `batchSize` (including the driver's own `disabled` setting) or a `batchSize` that is not a positive integer, raised at the factory call; leave `cursor` unset to read without a cursor. (4) `createInMemoryCacheStore` from the cache middleware received a `maxEntries` that is not a positive integer, or a `ttlMs` that is not a positive number (`Infinity` means never expire), raised at the factory call; (5) the document given to `fullTextMatches` or `fullTextRank` is not a full-text index, a column or a `fullTextDocument` (such as weight groups passed as a bare array, or an index of another type), is a full-text index given together with a `language` option, which the index states itself, or is a full-text index whose options are not a full-text definition, or the weight groups given to `fullTextDocument` are not one to four non-empty groups, raised while the query is being built. Payload: `helper` (the operation, or `postgres` / `postgresServerless` for the factory option, `createInMemoryCacheStore` for the cache store), `argument`, `received`, plus `extension: 'postgres'` for the factory option. The MySQL runtime driver raises it when MySQL refuses a value for a placeholder it reads as an integer, such as a `LIMIT` or `OFFSET` value that is not an integer (MySQL error 1210); the MySQL error is attached as `cause`, with `sql` in the payload. +A built-in Postgres query operation or full-text helper received an argument it cannot use, `postgres()` or `postgresServerless()` received a `cursor` option it cannot use, `mysql()` received a `stream` option that is not a boolean, or `createInMemoryCacheStore` received a size or lifetime it cannot use. Five cases: (1) the `language` of `fullTextMatches`, `fullTextRank` and `fullTextHeadline`, of the `tsquery` parsers (`websearchToTsquery`, `toTsquery`, `plaintoTsquery`, `phrasetoTsquery`) and of the `tsquery` template tag is written into the SQL as an inline literal rather than a bound parameter, so it is checked against the text-search configurations a stock PostgreSQL server ships with and anything else is refused, while the query is being built; (2) a literal part of a `tsquery` template has an invalid JavaScript escape, such as `\u`, so JavaScript gives the tag no text for that part and the tag refuses it rather than drop it, also while the query is being built; (3) the `cursor` option has a key other than `batchSize` (including the driver's own `disabled` setting) or a `batchSize` that is not a positive integer, raised at the factory call; leave `cursor` unset to read without a cursor. `mysql()` checks its `stream` option the same way at the factory call. (4) `createInMemoryCacheStore` from the cache middleware received a `maxEntries` that is not a positive integer, or a `ttlMs` that is not a positive number (`Infinity` means never expire), raised at the factory call; (5) the document given to `fullTextMatches` or `fullTextRank` is not a full-text index, a column or a `fullTextDocument` (such as weight groups passed as a bare array, or an index of another type), is a full-text index given together with a `language` option, which the index states itself, or is a full-text index whose options are not a full-text definition, or the weight groups given to `fullTextDocument` are not one to four non-empty groups, raised while the query is being built. Payload: `helper` (the operation, or `postgres` / `postgresServerless` / `mysql` for the factory option, `createInMemoryCacheStore` for the cache store), `argument`, `received`, plus `extension: 'postgres'` or `extension: 'mysql'` for the factory option. ### RUNTIME.AST_INVALID -A lowered SQL AST is structurally invalid: a subquery projecting other than one column, an INSERT with zero rows, a missing column value, an empty onConflict column list or do-update-set, an UPDATE with no SET assignments, an INSERT target table absent from contract storage, or an AST node constructed with invalid arguments (empty FunctionSource column aliases, a CaseExpr with no branches, a raw query declaring a `__proto__` result column, a name that cannot survive as a column, so alias it in SQL and declare the alias). Raised by the Postgres and SQLite SQL renderers and by AST node construction in relational-core. The MySQL drivers raise it for a parameter value MySQL has no parameter type for, such as an object, an array or a non-finite number, with `target: 'mysql'`, `index` and `valueType`. The MySQL adapter and drivers raise it for SQL text containing an executable `/*!` comment, whose content MySQL runs as SQL: the adapter when it reads raw SQL to find the operands of `LIMIT` and `OFFSET`, the drivers when they find a statement's placeholders. Payload: `node`, `table`, `column`; construction sites carry node-specific fields. +A lowered SQL AST is structurally invalid: a subquery projecting other than one column, an INSERT with zero rows, a missing column value, an empty onConflict column list or do-update-set, an UPDATE with no SET assignments, an INSERT target table absent from contract storage, or an AST node constructed with invalid arguments (empty FunctionSource column aliases, a CaseExpr with no branches, a raw query declaring a `__proto__` result column, a name that cannot survive as a column, so alias it in SQL and declare the alias). Raised by the Postgres and SQLite SQL renderers and by AST node construction in relational-core. The MySQL drivers raise it for SQL text containing an executable `/*!` comment, whose content MySQL runs as SQL, when they find a statement's placeholders. Payload: `node`, `table`, `column`; construction sites carry node-specific fields. ### RUNTIME.AST_UNSUPPORTED @@ -1188,7 +1188,7 @@ The authored SQL AST uses a feature this target cannot render, e.g. DEFAULT as a ### RUNTIME.BINDING_INVALID -A client (`postgres()`, `sqlite()`, `mongo()`) received a connection binding whose shape is wrong for the target: malformed connection string, unsupported binding kind, or missing required fields. Raised at `connect(...)` / client construction. The MySQL drivers raise it for a URL parameter they do not read (they read `ssl`, `connectTimeout`, `flags`, `connectionLimit`, `poolTimeout` and `statementCacheSize`, which must be at most 1,000), for a `charset` parameter, because they always connect as utf8mb4, or for a pool setting that is not a positive integer. A serverless Postgres client raises it from `connect({ url })` for an empty URL, a string that is not a URL, or a scheme other than `postgres://` or `postgresql://`, before any `pg.Client` exists. Payload: `received`, `reason`. +A client (`postgres()`, `sqlite()`, `mongo()`) received a connection binding whose shape is wrong for the target: malformed connection string, unsupported binding kind, or missing required fields. Raised at `connect(...)` / client construction. `mysql()` raises it, as `postgres()` does, for an empty URL, a string that is not a URL, or a scheme other than `mysql://`. The MySQL drivers read `ssl`, `sslmode`, `sslcert`, `sslkey`, `sslrootcert` and `allowPublicKeyRetrieval`, refuse `charset`, and ignore any other URL parameter, as `pg` does. They raise it for a parameter value of the wrong form (an `ssl` other than `true`, `1`, `false` or `0`, an unknown `sslmode`, an `allowPublicKeyRetrieval` other than `true` or `false`), for an `sslcert`, `sslkey` or `sslrootcert` file they cannot read, for `sslcert` without `sslkey`, because in a Prisma 7 MySQL URL `sslcert` named the server's CA file, which is now `sslrootcert`, or for a `charset` parameter, because they always connect as utf8mb4. The message for a wrong value names the parameter and the form its value must take. On MySQL, `received` is the names of the parameters at fault: no value from the URL is copied into the error, because the URL carries the password. A serverless Postgres client raises it from `connect({ url })` for an empty URL, a string that is not a URL, or a scheme other than `postgres://` or `postgresql://`, before any `pg.Client` exists. Payload: `received`, `reason`. ### RUNTIME.BINDING_MISSING @@ -1285,6 +1285,8 @@ The SQL integer codecs also check the JS type of the value they are given, and r The exact integer codecs make one exception, and only for a stored value. `toDataTypeValue` on `pg/int8@1`, `pg/unboundedint@1`, and `sqlite/bigint@1` also accepts a `number`, because a schema language writes no `bigint` literal. The number must be an integer within the safe range, and a value that is not raises ` number literal must be an integer within the safe integer range, got `: a `number` past that range was already rounded before the codec saw it, so its digits no longer name the value that was written. The PSL interpreter therefore does not hand these codecs a rounded number: it reads a number `@default` from its source text, gives the codec the plain number when that reads, and the decimal text of the literal when it does not, so `BigInt @default(0)` still arrives as the JSON number `0` while `BigInt @default(9007199254739999999)` arrives as its digits. `toWire`, the path a query parameter travels, takes no such number; it requires the `bigint`. +The MySQL drivers raise this code for a parameter value they cannot send, before the statement runs, with `meta.target: 'mysql'`, `meta.paramIndex` (counted from 0) and `meta.received`: a `bigint` outside the signed 64-bit range (`received: 'bigint'`; the `mariadb` package crashes the process on one below -2^63 and sends one at or above 2^63 as a DECIMAL), a `Date` (the package would encode it in the process's local time zone; the MySQL codecs send dates as text), an object, an array, `NaN` or an infinity. A whole number outside the signed 64-bit range is sent as a double, not refused. The refused value is not in the message. + ### RUNTIME.EXECUTION_RESULT_MISSING A statistics execution completed without returning statement statistics. This indicates a runtime or middleware implementation violated the execution contract instead of returning `{ affectedRows }`. Payload: none. @@ -1427,15 +1429,15 @@ Calling `connect(binding)` on a driver, or `connect()` on a client (`postgres()` ### DRIVER.CONNECTION_FAILED -A control-plane driver could not establish a database connection (`driver.create(url)` in the SQLite, Postgres, MySQL, and Mongo control drivers), or `connect({ url })` on a serverless Postgres client (`@prisma/orm-postgres/serverless`) could not open its `pg.Client`'s database connection: the database refused the connection, rejected the credentials, or did not answer within the 20 second connect timeout. The `why` carries the underlying driver message and the original error is attached as `cause` (for the serverless client, the driver's connection error, which wraps pg's own); connection URLs in meta are redacted. The MySQL runtime driver also raises it when a statement waits longer than the URL's `poolTimeout` (ten seconds by default) for a free pooled connection, with `poolTimeoutMs`. Payload: `path` (SQLite); `sqlState` plus redacted URL fields (Postgres control driver); redacted URL fields (Mongo, MySQL); `extension: 'postgres'` plus redacted URL fields (serverless `connect`). +A control-plane driver could not establish a database connection (`driver.create(url)` in the SQLite, Postgres, MySQL, and Mongo control drivers), or `connect({ url })` on a serverless Postgres client (`@prisma/orm-postgres/serverless`) could not open its `pg.Client`'s database connection: the database refused the connection, rejected the credentials, or did not answer within the 20 second connect timeout. The `why` carries the underlying driver message and the original error is attached as `cause` (for the serverless client, the driver's connection error, which wraps pg's own); connection URLs in meta are redacted. The MySQL control driver raises it when signing in needs the server's public key: the account uses `caching_sha2_password` or `sha256_password`, the server asks for the full password exchange, and the connection has no TLS, so the `mariadb` package refuses to send the password (its error 45044). This can start after a server restart, when the server no longer has the account cached. The `fix` names the two ways out: `allowPublicKeyRetrieval=true` in the URL, or TLS through `sslmode` or `ssl=true`. The MySQL control driver also raises it when MySQL refuses a session setting the stack passes, which it sets right after connecting. The MySQL runtime driver reports these failures as the Postgres driver reports its own, not with this code: a refused sign-in as the `SqlQueryError` MySQL raised; a missing public key, failing session setup, a refused connection and an unknown or unreachable host as a `SqlConnectionError` that is not transient, failing the statements waiting at that moment at once rather than after `poolOptions.connectionTimeoutMillis`; a later statement waits for the `mariadb` pool's next attempt and fails when that attempt fails: the package retries a refused connection or an unknown host every 500 ms within one attempt, and pauses 200 ms longer after each failed attempt, up to 10 seconds; and a lost connection or a pool that gives no connection in time as a transient `SqlConnectionError`. Payload: `path` (SQLite); `sqlState` plus redacted URL fields (Postgres control driver); redacted URL fields (Mongo, MySQL); `extension: 'postgres'` plus redacted URL fields (serverless `connect`). ### DRIVER.NOT_CONNECTED -Using a driver, a client, or the CLI control client before `connect(...)` has been called, or after it was closed. It surfaces from runtime `query` / `execute`, a prepared statement's `query(target, params, options?)`, `acquireConnection`, or `explain`, including lazily when iterating a query result. A closed Postgres client raises it as "Postgres client is closed" from every member, synchronously; it also closes its runtime, so a runtime captured with `runtime()` before `close()` refuses new work at once with "Runtime is closed" (a caller's pool or `pg.Client` stays open); and a closed serverless connection raises it as "Postgres connection is closed" from `runtime()`; the connection's ORM queries, `transaction(...)` and prepared statements follow the runtime's rule below. The SQL runtime raises it with the message "Runtime is closed" for a query, execute, prepared statement or `connection()` that starts once the runtime refuses new work; its `fix` names the missing `await`, and the rejection is immediate. When refusal begins depends on the runtime's `closeRefusal` policy. With `'at-once'`, which a Postgres client built from `{ url }` uses, it begins when `close()` is called, and work already in flight completes. With `'when-idle'`, which a serverless connection uses, it begins once `close()` has been called and the runtime has then been idle for one turn of the event loop (a `setTimeout(0)`), so a lazy result returned without `await` from an `await using` scope and awaited after the scope ended is refused, while work that keeps the runtime busy from the close onward, including an ORM write's reload and a helper's chain of queries through the ORM, completes, because `close()` waits for it, and is never refused because of the close; if it fails for its own reason, it rejects like any unawaited promise. Work that starts after the runtime was idle for a turn is refused; whether a query sent after waiting on something other than the database counts as idle depends on timing, so do not rely on it; an unawaited promise that fails then, or fails for its own reason, rejects like any unawaited promise, and Node reports it as unhandled. The Postgres driver's own messages are "Postgres driver is closed. Call connect(binding) to reconnect." after `close()`, "Postgres connection lost or closed. Create a new client to reconnect." from a query on a `pg.Client` whose connection was lost, whoever created it, since a pg client cannot reconnect, and "Postgres connection lost or closed. Call connect(binding) to reconnect." from the runtime on a later use, once that connection was destroyed. Binding a client the driver already gave up on fails with "This pg client lost its connection or was given up by an earlier driver. Create a new client to reconnect." The MySQL control driver raises it as "MySQL connection lost or closed" for every query after the server closed its connection or the connection failed, with the connection's error as `cause` and its message as `why`. The MySQL runtime driver raises it with the same message when the connection under a streaming query is lost, because mysql2 tells the query nothing; the connection is evicted. +Using a driver, a client, or the CLI control client before `connect(...)` has been called, or after it was closed. It surfaces from runtime `query` / `execute`, a prepared statement's `query(target, params, options?)`, `acquireConnection`, or `explain`, including lazily when iterating a query result. A closed Postgres client raises it as "Postgres client is closed" from every member, synchronously; it also closes its runtime, so a runtime captured with `runtime()` before `close()` refuses new work at once with "Runtime is closed" (a caller's pool or `pg.Client` stays open); and a closed serverless connection raises it as "Postgres connection is closed" from `runtime()`; the connection's ORM queries, `transaction(...)` and prepared statements follow the runtime's rule below. The SQL runtime raises it with the message "Runtime is closed" for a query, execute, prepared statement or `connection()` that starts once the runtime refuses new work; its `fix` names the missing `await`, and the rejection is immediate. When refusal begins depends on the runtime's `closeRefusal` policy. With `'at-once'`, which a Postgres client built from `{ url }` uses, it begins when `close()` is called, and work already in flight completes. With `'when-idle'`, which a serverless connection uses, it begins once `close()` has been called and the runtime has then been idle for one turn of the event loop (a `setTimeout(0)`), so a lazy result returned without `await` from an `await using` scope and awaited after the scope ended is refused, while work that keeps the runtime busy from the close onward, including an ORM write's reload and a helper's chain of queries through the ORM, completes, because `close()` waits for it, and is never refused because of the close; if it fails for its own reason, it rejects like any unawaited promise. Work that starts after the runtime was idle for a turn is refused; whether a query sent after waiting on something other than the database counts as idle depends on timing, so do not rely on it; an unawaited promise that fails then, or fails for its own reason, rejects like any unawaited promise, and Node reports it as unhandled. The Postgres driver's own messages are "Postgres driver is closed. Call connect(binding) to reconnect." after `close()`, "Postgres connection lost or closed. Create a new client to reconnect." from a query on a `pg.Client` whose connection was lost, whoever created it, since a pg client cannot reconnect, and "Postgres connection lost or closed. Call connect(binding) to reconnect." from the runtime on a later use, once that connection was destroyed. Binding a client the driver already gave up on fails with "This pg client lost its connection or was given up by an earlier driver. Create a new client to reconnect." The MySQL drivers raise it only for use before `connect` or after `close`. A connection lost under a statement is a `SqlConnectionError` with `transient: true`, as on Postgres, and the runtime driver destroys that connection. ### DRIVER.PREPARE_FAILED -A prepared statement failed again after the PostgreSQL driver discarded a stale handle and retried with a fresh handle. The normalized PostgreSQL error is attached as `cause`. Payload: `handle`. The MySQL drivers raise it for a statement MySQL cannot run as a prepared statement (MySQL error 1295, `ER_UNSUPPORTED_PS`), such as `LOCK TABLES`: they send every statement with values as a prepared statement. They also raise it when the server has reached its limit on open prepared statements (error 1461, `max_prepared_stmt_count`, 16,382 by default), with a `fix` that names `statementCacheSize` and `connectionLimit`. The MySQL error is attached as `cause`. Payload: `sql`. +A prepared statement failed again after the PostgreSQL driver discarded a stale handle and retried with a fresh handle. The normalized PostgreSQL error is attached as `cause`. Payload: `handle`. ## MIGRATION diff --git a/examples/prisma-8-demo-mysql/README.md b/examples/prisma-8-demo-mysql/README.md index a9b6f254d803..44a5a9d21b8e 100644 --- a/examples/prisma-8-demo-mysql/README.md +++ b/examples/prisma-8-demo-mysql/README.md @@ -47,12 +47,20 @@ The repository's `docker-compose.yaml` starts a server on port 3307; create the that database, never MySQL's own `mysql` system database, or the CLI creates the example's tables inside it. +The URL sets `allowPublicKeyRetrieval=true` because the local server has no +TLS: MySQL 8.4 signs `root` in with `caching_sha2_password`, which then needs +the server's public key to send the password. Without it, connecting fails +once the server no longer has the account cached, such as after a restart. +Against a server reached over a network, use TLS (`sslmode=verify-full`, or +`ssl=true`) instead, since fetching the key without TLS lets a machine in between read the +password. + ```bash docker compose up -d mysql docker compose exec mysql mysql -uroot -proot -e 'CREATE DATABASE prisma_demo' pnpm install pnpm emit # generates src/prisma/contract.json + contract.d.ts -export MYSQL_URL=mysql://root:root@127.0.0.1:3307/prisma_demo +export MYSQL_URL='mysql://root:root@127.0.0.1:3307/prisma_demo?allowPublicKeyRetrieval=true' pnpm db:migrate # applies migrations/ to the database pnpm db:verify # checks the database matches the contract pnpm db:sign # records the contract the database now satisfies diff --git a/examples/prisma-8-demo-mysql/package.json b/examples/prisma-8-demo-mysql/package.json index 12e35ccdf551..ad39ee1df87e 100644 --- a/examples/prisma-8-demo-mysql/package.json +++ b/examples/prisma-8-demo-mysql/package.json @@ -24,7 +24,6 @@ "@repo/prisma-bin": "workspace:8.0.0-rc.21", "@repo/tsconfig": "workspace:8.0.0-rc.21", "@types/node": "catalog:", - "mysql2": "3.15.3", "typescript": "catalog:", "vitest": "catalog:" }, diff --git a/examples/prisma-8-demo-mysql/test/database.ts b/examples/prisma-8-demo-mysql/test/database.ts index 3450f9c805f7..0c7345d53169 100644 --- a/examples/prisma-8-demo-mysql/test/database.ts +++ b/examples/prisma-8-demo-mysql/test/database.ts @@ -1,5 +1,5 @@ import { randomBytes } from 'node:crypto'; -import { createConnection } from 'mysql2/promise'; +import mysqlControlDriver from '@prisma/orm-mysql/driver/control'; export interface TestDatabase { readonly url: string; @@ -23,11 +23,12 @@ export async function createTestDatabase(serverUrl: string): Promise { - const connection = await createConnection(serverUrl); + const driver = await mysqlControlDriver.create(serverUrl); try { - await connection.query(statement); + await driver.query(statement); } finally { - await connection.end(); + await driver.close(); } } diff --git a/packages/3-targets/mysql/1-target/README.md b/packages/3-targets/mysql/1-target/README.md index cd39504a2084..d2cb2b38875a 100644 --- a/packages/3-targets/mysql/1-target/README.md +++ b/packages/3-targets/mysql/1-target/README.md @@ -17,7 +17,7 @@ Declares what MySQL is to the SQL family: its data types, codecs and PSL scalars - **Descriptor metadata**: `targetId: 'mysql'`, `supportsNamespaces: false` and the unbound default namespace ([ADR 223](../../../../docs/architecture%20docs/adrs/ADR%20223%20-%20Target-owned%20default%20namespace.md)). Namespace blocks are refused through that flag, with the display name and reason the descriptor supplies. - **Data types** (`src/core/data-types.ts`, [ADR 254](../../../../docs/architecture%20docs/adrs/ADR%20254%20-%20Data%20types%20and%20casts.md)): `mysql/int`, `mysql/bigint`, `mysql/varchar`, `mysql/char`, `mysql/text`, `mysql/datetime` and `mysql/enum`. PSL `String` is `varchar(191)`, so a `@unique` column can be indexed. `mysql/enum` is a native `enum('A','B')` column type whose name is the text MySQL's catalog prints, so `db verify` compares the members and their order. - **Enum columns** (`src/core/mysql-database.ts`): the namespace factory writes each enum column's members, from the value set the column names, into its type parameters in declared order. No CHECK constraint is derived. -- **Codecs**: one per data type, plus MySQL adaptations of the family's `sql/char@1`, `sql/varchar@1` and `sql/raw@1`. Each `fromWire` reads what the driver hands over: the text MySQL sends for text, bigint, decimal, date and JSON columns, and a number for an `int`. `projectJson` writes a column into the JSON an include reads, in the form its `fromWire` reads from a row: `int`, the text types, `enum` and `datetime` pass through; `bigint` renders as `CAST(x AS CHAR)`, because JavaScript's `JSON.parse` would round a number past 2^53. A raw column is refused. The bigint codec sends a JavaScript bigint, which mysql2 sends as its decimal text; the adapter writes an `int` or `bigint` param as `CAST(?N AS SIGNED)`, so MySQL reads it as an integer. The codec refuses a value outside the signed 64-bit range. +- **Codecs**: one per data type, plus MySQL adaptations of the family's `sql/char@1`, `sql/varchar@1` and `sql/raw@1`. Each `fromWire` reads what the driver hands over: the text MySQL sends, for every column; an include's JSON carries an `int` as a number. `projectJson` writes a column into the JSON an include reads, in the form its `fromWire` reads from a row: `int`, the text types, `enum` and `datetime` pass through; `bigint` renders as `CAST(x AS CHAR)`, because JavaScript's `JSON.parse` would round a number past 2^53. A raw column is refused. As on Postgres, `toWire` returns what the database reads for each data type: the `int` codec a number and the `bigint` codec decimal text, as the Postgres `int8` codec does. MySQL reads a text parameter beside a `bigint` column exactly; where nothing gives a parameter a type, as in `?1 + ?2` in raw SQL, the author writes the cast, as on Postgres. The bigint codec refuses a value outside the signed 64-bit range. - **Control tables**: `_prisma_marker` and `_prisma_ledger` live beside the application's tables, because MySQL has no schema inside a database. `src/contract-free/control-bootstrap.ts` declares their DDL. - **Control target descriptor**: contract serializer, `contractToSchema`, `diffSchema`, the planner and the runner. - **Planner** (`src/core/migrations/planner.ts`, `issue-planner.ts`): creates and drops tables, columns, indexes, unique constraints and foreign keys, with backtick-quoted DDL. Foreign keys are added after every table exists and dropped first. Dropping a foreign key also drops the index InnoDB made for it, when no other index served the key and the destination keeps no index of that name; dropping an index a foreign key of the destination uses, with no other index to take over, is refused, because MySQL refuses it. A change to a column's type, nullability or default, a primary key, a CHECK constraint, a required column with no default, a name longer than 64 characters, an autoincrement column that does not lead the primary key or a unique constraint, and every statement such as a rename are refused with a conflict that says they are not yet supported on MySQL. `migration.ts` is written as calls on `MysqlMigration` (`src/core/migrations/mysql-migration.ts`). diff --git a/packages/3-targets/mysql/1-target/src/core/codecs.ts b/packages/3-targets/mysql/1-target/src/core/codecs.ts index 48ae9ccf3a26..68282c671aa0 100644 --- a/packages/3-targets/mysql/1-target/src/core/codecs.ts +++ b/packages/3-targets/mysql/1-target/src/core/codecs.ts @@ -1,5 +1,5 @@ /** - * The MySQL target's codecs. The driver hands every column to a codec as the text MySQL sends for it (ADR 155), so each `fromWire` reads that text; `toWire` writes the value MySQL reads for a parameter. + * The MySQL target's codecs. The driver hands every column to a codec as text, or bytes for a binary column (ADR 155), so each `fromWire` reads that text; `toWire` writes the value MySQL reads for a parameter. */ import type { JsonValue } from '@internal/contract/types'; @@ -127,7 +127,7 @@ export class MysqlTextDescriptor extends MysqlCodecDescriptor { export const mysqlTextDescriptor = new MysqlTextDescriptor(); -/** A MySQL `int` as a JavaScript number. MySQL sends it as decimal text. */ +/** A MySQL `int` as a JavaScript number. A row carries it as decimal text, and an include's JSON as a number. */ export class MysqlIntCodec extends CodecImpl< typeof MYSQL_INT_CODEC_ID, readonly ['equality', 'order', 'numeric'], @@ -173,14 +173,14 @@ export class MysqlIntDescriptor extends MysqlCodecDescriptor { export const mysqlIntDescriptor = new MysqlIntDescriptor(); -/** A MySQL `bigint` as a JavaScript bigint. The driver reads it as its decimal text and sends a bigint as a 64-bit integer, which MySQL compares exactly. */ +/** A MySQL `bigint` as a JavaScript bigint. A row, an include's JSON and a parameter carry it as decimal text, as the Postgres `int8` codec does; MySQL reads a text parameter beside a `bigint` column exactly. */ export class MysqlBigintCodec extends CodecImpl< typeof MYSQL_BIGINT_CODEC_ID, readonly ['equality', 'order', 'numeric'], - string | bigint, + string, bigint > { - async toWire(value: bigint, _ctx: CodecCallContext): Promise { + async toWire(value: bigint, _ctx: CodecCallContext): Promise { if (typeof value !== 'bigint' || value < BIGINT_MIN || value > BIGINT_MAX) { return encodeFailed( MYSQL_BIGINT_CODEC_ID, @@ -188,10 +188,9 @@ export class MysqlBigintCodec extends CodecImpl< value, ); } - return value; + return value.toString(); } - async fromWire(wire: string | bigint, _ctx: CodecCallContext): Promise { - if (typeof wire === 'bigint') return wire; + async fromWire(wire: string, _ctx: CodecCallContext): Promise { if (!DECIMAL_INTEGER.test(wire)) { return decodeFailed(MYSQL_BIGINT_CODEC_ID, 'the decimal text of an integer', wire); } diff --git a/packages/3-targets/mysql/1-target/src/core/descriptor-meta-runtime.ts b/packages/3-targets/mysql/1-target/src/core/descriptor-meta-runtime.ts index 0d3527a44426..604a2a119fc5 100644 --- a/packages/3-targets/mysql/1-target/src/core/descriptor-meta-runtime.ts +++ b/packages/3-targets/mysql/1-target/src/core/descriptor-meta-runtime.ts @@ -9,9 +9,10 @@ const mysqlTargetDescriptorMetaRuntimeBase = { version: '0.0.1', capabilities: {}, dataTypes: mysqlDataTypes, + sessionSettings: { time_zone: '+00:00' }, } as const; -/** The runtime-safe part of the MySQL target's metadata: it leaves out `authoring`, which only contract construction reads. */ +/** The runtime-safe part of the MySQL target's metadata: it leaves out `authoring`, which only contract construction reads. Every session runs in UTC, because the datetime codec reads and writes UTC. */ export const mysqlTargetDescriptorMetaRuntime: typeof mysqlTargetDescriptorMetaRuntimeBase & { readonly __codecTypes?: CodecTypes; } = mysqlTargetDescriptorMetaRuntimeBase; diff --git a/packages/3-targets/mysql/1-target/test/codecs.test.ts b/packages/3-targets/mysql/1-target/test/codecs.test.ts index 54b3a4e0ba15..7a5f2f45fbe0 100644 --- a/packages/3-targets/mysql/1-target/test/codecs.test.ts +++ b/packages/3-targets/mysql/1-target/test/codecs.test.ts @@ -41,10 +41,11 @@ describe('MySQL codecs read the text MySQL sends', () => { expect(codec.fromDataTypeValue(value(mysqlInt, 3))).toBe(3); }); - it('reads a bigint from its decimal text without losing digits', async () => { + it('reads a bigint from its decimal text and writes it as decimal text, without losing digits', async () => { const codec = mysqlBigintDescriptor.factory()(instance); expect(await codec.fromWire('9007199254740993', ctx)).toBe(9007199254740993n); - expect(await codec.toWire(9007199254740993n, ctx)).toBe(9007199254740993n); + expect(await codec.toWire(9007199254740993n, ctx)).toBe('9007199254740993'); + await expect(codec.toWire(Object(1n), ctx)).rejects.toThrow(/must be a bigint/); await expect(codec.fromWire('x', ctx)).rejects.toThrow(/decimal text/); expect(codec.toDataTypeValue(12n)).toEqual(value(mysqlBigint, '12')); expect(codec.fromDataTypeValue(value(mysqlBigint, '12'))).toBe(12n); @@ -129,7 +130,7 @@ describe('the MySQL bigint codec', () => { expect([ await codec.toWire(2n ** 63n - 1n, ctx), await codec.toWire(-(2n ** 63n), ctx), - ]).toEqual([2n ** 63n - 1n, -(2n ** 63n)]); + ]).toEqual(['9223372036854775807', '-9223372036854775808']); for (const value of [2n ** 63n, -(2n ** 63n) - 1n, 2n ** 64n - 1n]) { await expect(codec.toWire(value, ctx)).rejects.toMatchObject({ code: 'RUNTIME.ENCODE_FAILED', diff --git a/packages/3-targets/mysql/1-target/test/descriptor.test.ts b/packages/3-targets/mysql/1-target/test/descriptor.test.ts index dcd6ec69c990..e6b2c3a2f9f9 100644 --- a/packages/3-targets/mysql/1-target/test/descriptor.test.ts +++ b/packages/3-targets/mysql/1-target/test/descriptor.test.ts @@ -7,6 +7,7 @@ import { MysqlCreateTable } from '../src/core/ddl/nodes'; import { mysqlTargetDescriptorMeta } from '../src/core/descriptor-meta'; import mysqlControlTarget from '../src/core/migrations/control-target'; import { escapeLiteral, quoteIdentifier, quoteLiteral } from '../src/core/sql-utils'; +import mysqlRuntimeTarget from '../src/exports/runtime'; describe('the MySQL target descriptor', () => { it('reports no namespaces and the unbound default namespace', () => { @@ -23,6 +24,12 @@ describe('the MySQL target descriptor', () => { }); }); + it('declares, on the control and runtime target, that every session runs in UTC, which its datetime codec reads, as Postgres declares DateStyle', () => { + expect([mysqlTargetDescriptorMeta.sessionSettings, mysqlRuntimeTarget.sessionSettings]).toEqual( + [{ time_zone: '+00:00' }, { time_zone: '+00:00' }], + ); + }); + it('maps String to varchar(191) and DateTime to datetime(3)', () => { const types = mysqlTargetDescriptorMeta.authoring.type; expect([types.String.output, types.DateTime.output, types.Int.output]).toEqual([ diff --git a/packages/3-targets/mysql/2-adapter/README.md b/packages/3-targets/mysql/2-adapter/README.md index 7d71abb70549..d81180cde8e2 100644 --- a/packages/3-targets/mysql/2-adapter/README.md +++ b/packages/3-targets/mysql/2-adapter/README.md @@ -19,7 +19,7 @@ Lowers the queries the ORM and the SQL builder build into MySQL SQL, and reads a - **Marker and ledger reads and writes**, **`withTransaction`** (`START TRANSACTION`), and the `CREATE TABLE IF NOT EXISTS` statements for the control tables. `initMarker` writes with `INSERT … ON DUPLICATE KEY UPDATE`. MySQL has no `RETURNING`, so `updateMarker` reads and locks the row with `FOR UPDATE` inside the caller's transaction and writes it only when it still holds the expected hash. - **`holdMarkerLock`** takes the marker lock, a `GET_LOCK` MySQL holds for the session, around the work it guards and releases it with `RELEASE_LOCK` when that work ends; `db sign` creates the marker table and writes the markers inside it. `lockMarker` takes no lock, because the lock is already held around the transaction. The migration runner takes the same lock through the same helper. - **Query lowering** (`src/core/sql-renderer.ts`), used by the runtime adapter: - - Backtick quoting. Each parameter is written as `?N`, N being its position in `collectOrderedParamRefs`, the order the runtime encodes parameters in; a parameter written twice is bound once. `src/core/placeholders.ts` then checks that every placeholder names a parameter and every parameter has one, and raises `RUNTIME.PARAM_PLACEHOLDER_MISMATCH` otherwise, as for a `?` written into raw SQL text. The driver sends the values apart from the SQL, as a prepared statement's parameters. mysql2 sends an integer value as text, so an `int` or `bigint` param, decided from its codec's data type, is written `CAST(?N AS SIGNED)`, which keeps `?1 < ?2`, `GREATEST`, `JSON_ARRAY(?1)` and arithmetic past 2^53 integer; `LIMIT` and `OFFSET`, which take only a bare placeholder, read it as an integer themselves. A literal the AST carries is written inline only when it is a number, a bigint, a boolean or NULL, which only code writes, such as the ORM's `1`; a text, date or JSON literal is refused with `RUNTIME.AST_UNSUPPORTED`, because only a caller could supply it and a caller's value belongs in a param. `JSON_OBJECT` keys, which are contract field and relation names, are written through the target's `escapeLiteral`, which doubles quotes and backslashes. + - Backtick quoting. Each parameter is written as `?N`, N being its position in `collectOrderedParamRefs`, the order the runtime encodes parameters in; a parameter written twice is bound once. `src/core/placeholders.ts` then checks that every placeholder names a parameter and every parameter has one, and raises `RUNTIME.PARAM_PLACEHOLDER_MISMATCH` otherwise, as for a `?` written into raw SQL text. The driver sends the values apart from the SQL, as a prepared statement's parameters. Every parameter, an integer one included, is a bare `?N`, as the Postgres adapter writes `$N` for every data type whose type the server infers from where the parameter stands, the integer ones among them. MySQL infers it the same way: a parameter beside a `bigint` column is read as an integer, even when the `bigint` codec sends decimal text. Where nothing gives a parameter a type, as in `?1 + ?2` in raw SQL, the author writes the cast, as on Postgres. `LIMIT ?1` takes only a bare placeholder, so the adapter could not cast there in any case. A literal the AST carries is written inline only when it is a number, a bigint, a boolean or NULL, which only code writes, such as the ORM's `1`; a text, date or JSON literal is refused with `RUNTIME.AST_UNSUPPORTED`, because only a caller could supply it and a caller's value belongs in a param. `JSON_OBJECT` keys, which are contract field and relation names, are written through the target's `escapeLiteral`, which doubles quotes and backslashes. - `<=>` for IS NOT DISTINCT FROM, a leading `x IS NULL` sort key for NULLS FIRST and NULLS LAST, the largest row count MySQL takes for an `OFFSET` with no `LIMIT`. - A to-one include and an unordered array use `JSON_ARRAYAGG`. A to-many include with an order uses `GROUP_CONCAT(… ORDER BY …)` cast to JSON, because `JSON_ARRAYAGG` takes no `ORDER BY`; the statement raises `group_concat_max_len` itself with a `SET_VAR` hint, so it does not depend on the driver's session. An include with no rows is `JSON_ARRAY()`. Text longer than `max_allowed_packet` makes MySQL's `CONCAT` return NULL with only a warning, so a NULL array with rows fails the statement instead of reading as empty. MySQL reports that failure as `Invalid JSON text in argument 1 to function cast_as_json`, which names neither the include nor `max_allowed_packet`: the driver cannot tell it from any other invalid JSON cast, so it passes the error on unchanged. This size limit applies only once an include has an order. - Refused with `RUNTIME.AST_UNSUPPORTED`: FULL and LATERAL joins, function sources, row locks, DISTINCT ON, non-finite numbers, and writes. @@ -28,7 +28,7 @@ Lowers the queries the ORM and the SQL builder build into MySQL SQL, and reads a ## Driver contract -The SQL this adapter lowers is not runnable as written: each parameter is a numbered `?N`, which MySQL does not read. A driver used with this adapter must rewrite each `?N` to MySQL's positional `?`, order the values to match, and send them as a server-side prepared statement's parameters, never inside the SQL. `@internal/driver-mysql` does this. The adapter writes an `int` or `bigint` parameter as `CAST(?N AS SIGNED)` only because mysql2 sends a JavaScript `BigInt` as text, and a comparison with no column to give that text a type, such as `?1 < ?2`, would compare it as text. A driver that sends integers as integers would not need the cast. +The SQL this adapter lowers is not runnable as written: each parameter is a numbered `?N`, which MySQL does not read. A driver used with this adapter must rewrite each `?N` to MySQL's positional `?`, order the values to match, and send them as a server-side prepared statement's parameters, never inside the SQL. `@internal/driver-mysql` does this. The adapter writes no casts on parameters. The binary protocol needs a type for each value, so the driver must send a JavaScript integer as an integer type: `LIMIT ?1` refuses one sent as a double, which is how the `mariadb` package sends any number past 2^31. The driver's rules for the type each value is sent as are in [`parameter-values.ts`](../3-driver/src/core/parameter-values.ts). ## Exports diff --git a/packages/3-targets/mysql/2-adapter/src/core/sql-renderer.ts b/packages/3-targets/mysql/2-adapter/src/core/sql-renderer.ts index 3478dac3cf1d..a84b301359e6 100644 --- a/packages/3-targets/mysql/2-adapter/src/core/sql-renderer.ts +++ b/packages/3-targets/mysql/2-adapter/src/core/sql-renderer.ts @@ -36,15 +36,8 @@ import type { } from '@internal/sql-relational-core/ast'; import { collectOrderedParamRefs } from '@internal/sql-relational-core/ast'; import type { MysqlCodecDescriptorRegistry } from '@internal/target-mysql/codec-descriptor'; -import { mysqlBigint, mysqlInt } from '@internal/target-mysql/data-types'; import { mysqlError, mysqlQueryNotYetSupported } from '@internal/target-mysql/errors'; -import { - escapeLiteral, - type MysqlPlaceholderToken, - type MysqlSqlToken, - quoteIdentifier, - tokenizeMysqlSql, -} from '@internal/target-mysql/sql-utils'; +import { escapeLiteral, quoteIdentifier } from '@internal/target-mysql/sql-utils'; import { assertNever, InternalError } from '@internal/utils/internal-error'; import { structuredError } from '@internal/utils/structured-error'; import { assertPlaceholdersMatchParams } from './placeholders'; @@ -300,61 +293,9 @@ function assertNoDigitAfterParam(parts: RawExpr['parts'] | RawQueryAst['parts']) }); } -function isParamPart(part: RawExpr['parts'][number]): part is AnyParamRef { - return ( - typeof part !== 'string' && (part.kind === 'param-ref' || part.kind === 'prepared-param-ref') - ); -} - -const isKeyword = (token: MysqlSqlToken | undefined, keyword: string): boolean => - token?.kind === 'word' && token.text.toUpperCase() === keyword; - -const isIntegerLiteral = (token: MysqlSqlToken | undefined): boolean => - token?.kind === 'word' && [...token.text].every((char) => char >= '0' && char <= '9'); - -/** - * The raw parts that are the operand of `LIMIT` or `OFFSET`, which MySQL takes only as a bare placeholder: a param whose previous token, past whitespace and comments, is `LIMIT` or `OFFSET`, or is the comma of `LIMIT a, b` after an integer or a param that is itself an operand. The raw text is read with the target's lexer, so a keyword inside a quoted string or a comment does not count. - */ -function limitOperandParts(parts: RawExpr['parts'] | RawQueryAst['parts']): ReadonlySet { - const sql = parts - .map((part, index) => - typeof part === 'string' ? part : isParamPart(part) ? `?${index + 1}` : ' 0 ', - ) - .join(''); - const tokens = tokenizeMysqlSql(sql).filter( - (token) => token.kind !== 'whitespace' && token.kind !== 'comment', - ); - const partOf = (token: MysqlPlaceholderToken) => (token.number ?? 0) - 1; - const operands = new Set(); - tokens.forEach((token, position) => { - if (token.kind !== 'placeholder') return; - const [third, second, first] = [ - tokens[position - 3], - tokens[position - 2], - tokens[position - 1], - ]; - const afterKeyword = isKeyword(first, 'LIMIT') || isKeyword(first, 'OFFSET'); - const afterLimitComma = - first?.kind === 'symbol' && - first.text === ',' && - ((isIntegerLiteral(second) && isKeyword(third, 'LIMIT')) || - (second?.kind === 'placeholder' && operands.has(partOf(second)))); - if (afterKeyword || afterLimitComma) operands.add(partOf(token)); - }); - return operands; -} - function renderParts(parts: RawExpr['parts'] | RawQueryAst['parts'], ctx: RenderContext): string { assertNoDigitAfterParam(parts); - const limitOperands = limitOperandParts(parts); - return parts - .map((part, index) => { - if (typeof part === 'string') return part; - return isParamPart(part) && limitOperands.has(index) - ? placeholder(part, ctx) - : renderExpr(part, ctx); - }) - .join(''); + return parts.map((part) => (typeof part === 'string' ? part : renderExpr(part, ctx))).join(''); } function renderColumn(ref: ColumnRef): string { @@ -517,14 +458,6 @@ function descriptorOf(codec: CodecRef, ctx: RenderContext, use: string) { return descriptor; } -/** - * The `CAST` target of a param by its codec's data type. mysql2 sends a bigint as text, so where no column gives the param a type MySQL reads an integer as a string: `?1 < ?2` with 10 and 9 compares text, and `JSON_ARRAY(?1)` holds `"5"`. Casting keeps it an integer everywhere except `LIMIT` and `OFFSET`, which take only a bare placeholder and read it as an integer. - */ -const PARAMETER_CASTS: ReadonlyMap = new Map([ - [mysqlInt.id, 'SIGNED'], - [mysqlBigint.id, 'SIGNED'], -]); - function placeholder(ref: AnyParamRef, ctx: RenderContext): string { const number = ctx.placeholderNumbers.get(ref); if (number === undefined) { @@ -533,13 +466,10 @@ function placeholder(ref: AnyParamRef, ctx: RenderContext): string { return `?${number}`; } +/** A param as its numbered placeholder, refusing one whose codec the MySQL registry lacks. */ function renderParam(ref: AnyParamRef, ctx: RenderContext): string { - const castType = - ref.codec === undefined - ? undefined - : PARAMETER_CASTS.get(descriptorOf(ref.codec, ctx, 'a parameter').dataType); - const written = placeholder(ref, ctx); - return castType === undefined ? written : `CAST(${written} AS ${castType})`; + if (ref.codec !== undefined) descriptorOf(ref.codec, ctx, 'a parameter'); + return placeholder(ref, ctx); } function projectThroughCodec( diff --git a/packages/3-targets/mysql/2-adapter/test/control-adapter.live.test.ts b/packages/3-targets/mysql/2-adapter/test/control-adapter.live.test.ts index 467416b543a0..c4a1941246dd 100644 --- a/packages/3-targets/mysql/2-adapter/test/control-adapter.live.test.ts +++ b/packages/3-targets/mysql/2-adapter/test/control-adapter.live.test.ts @@ -1,5 +1,7 @@ import mysqlDriver, { type MysqlControlDriver } from '@internal/driver-mysql/control'; import { acquireMarkerLock, releaseMarkerLock } from '@internal/target-mysql/marker-lock'; +import mysqlTarget from '@internal/target-mysql/runtime'; +import { ifDefined } from '@internal/utils/defined'; import { timeouts } from '@repo/test-utils'; import { withMysqlDatabase } from '@repo/test-utils/mysql-database'; import { describe, expect, it } from 'vitest'; @@ -64,10 +66,12 @@ describe('MysqlControlAdapter against a MySQL server', { }); }); - it('stamps control-table rows with the current time in UTC', async (test) => { + it('stamps control-table rows with the current time in UTC, in a session with the settings the target declares', async (test) => { await withMysqlDatabase(test, async ({ url }) => { const adapter = createAdapter(); - const driver = await mysqlDriver.create(url); + const driver = await mysqlDriver.create(url, { + ...ifDefined('sessionSettings', mysqlTarget.sessionSettings), + }); try { for (const node of adapter.bootstrapControlTableQueries()) { await driver.query((await adapter.lowerToExecuteRequest(node)).sql); diff --git a/packages/3-targets/mysql/2-adapter/test/sql-renderer.test.ts b/packages/3-targets/mysql/2-adapter/test/sql-renderer.test.ts index 455d28150398..fe62dcdf7322 100644 --- a/packages/3-targets/mysql/2-adapter/test/sql-renderer.test.ts +++ b/packages/3-targets/mysql/2-adapter/test/sql-renderer.test.ts @@ -73,7 +73,7 @@ describe('MySQL lowering of a select', () => { ).toEqual([{ kind: 'bind', name: 'email' }]); }); - it('casts an integer param to SIGNED, except in LIMIT and OFFSET, so MySQL reads it as an integer where no column gives it a type', () => { + it('writes an integer param as a bare placeholder, which the driver sends as a BIGINT', () => { const big = ColumnRef.of('t', 'big'); const bigParam = (value: bigint) => ParamRef.of(value, { codec: { codecId: 'mysql/bigint@1' } }); @@ -94,10 +94,10 @@ describe('MySQL lowering of a select', () => { ), ), ]).toEqual([ - 'SELECT `user`.`id` AS `id` FROM `user` WHERE `t`.`big` = CAST(?1 AS SIGNED)', - 'SELECT `user`.`id` AS `id` FROM `user` WHERE `t`.`big` IN (CAST(?1 AS SIGNED))', - 'SELECT `user`.`id` AS `id` FROM `user` WHERE `t`.`big` = CAST(?1 AS SIGNED)', - 'SELECT `user`.`id` AS `id` FROM `user` WHERE CAST(?1 AS SIGNED) < CAST(?2 AS SIGNED)', + 'SELECT `user`.`id` AS `id` FROM `user` WHERE `t`.`big` = ?1', + 'SELECT `user`.`id` AS `id` FROM `user` WHERE `t`.`big` IN (?1)', + 'SELECT `user`.`id` AS `id` FROM `user` WHERE `t`.`big` = ?1', + 'SELECT `user`.`id` AS `id` FROM `user` WHERE ?1 < ?2', 'SELECT `user`.`id` AS `id` FROM `user` LIMIT ?1 OFFSET ?2', 'SELECT `user`.`id` AS `id` FROM `user` WHERE `user`.`email` = ?1', ]); @@ -399,34 +399,18 @@ describe('MySQL placeholder check', () => { ); }); - it('writes a raw integer value as a bare placeholder where it is the operand of LIMIT or OFFSET, and casts it elsewhere', () => { + it('writes a raw integer value as a bare placeholder wherever it appears', () => { const int = (value: number) => ParamRef.of(value, { codec: { codecId: 'mysql/int@1' } }); const raw = (...parts: (string | ReturnType)[]) => sqlOf(RawQueryAst.affectedCount(parts)); expect([ - raw('SELECT id FROM t LIMIT ', int(2)), raw('SELECT id FROM t LIMIT ', int(2), ' OFFSET ', int(1)), - raw('SELECT id FROM t limit ', int(1), ', ', int(2)), - raw('SELECT id FROM t LIMIT /* page */ ', int(2), ' -- x\n OFFSET\n', int(1)), - raw('SELECT id FROM t LIMIT 5, ', int(2)), - raw('SELECT id FROM t LIMIT ', int(1), ', /* count */ ', int(2)), - raw('SELECT ', int(10), ' < ', int(9), ' FROM t WHERE x_limit = ', int(3)), - raw('SELECT * FROM (SELECT id FROM t LIMIT ', int(2), ') AS d'), - raw("SELECT 'LIMIT' = ", int(1), ', `limit` = ', int(2), ' FROM t'), - raw('SELECT id /* LIMIT */ , ', int(1), ' FROM t -- OFFSET\n WHERE id = ', int(2)), - raw('SELECT id FROM t LIMIT 5.0, ', int(2)), + raw('SELECT ', int(10), ' < ', int(9), ' FROM t'), + raw("SELECT 'why?' = ", int(1), ' FROM t'), ]).toEqual([ - 'SELECT id FROM t LIMIT ?1', 'SELECT id FROM t LIMIT ?1 OFFSET ?2', - 'SELECT id FROM t limit ?1, ?2', - 'SELECT id FROM t LIMIT /* page */ ?1 -- x\n OFFSET\n?2', - 'SELECT id FROM t LIMIT 5, ?1', - 'SELECT id FROM t LIMIT ?1, /* count */ ?2', - 'SELECT CAST(?1 AS SIGNED) < CAST(?2 AS SIGNED) FROM t WHERE x_limit = CAST(?3 AS SIGNED)', - 'SELECT * FROM (SELECT id FROM t LIMIT ?1) AS d', - "SELECT 'LIMIT' = CAST(?1 AS SIGNED), `limit` = CAST(?2 AS SIGNED) FROM t", - 'SELECT id /* LIMIT */ , CAST(?1 AS SIGNED) FROM t -- OFFSET\n WHERE id = CAST(?2 AS SIGNED)', - 'SELECT id FROM t LIMIT 5.0, CAST(?1 AS SIGNED)', + 'SELECT ?1 < ?2 FROM t', + "SELECT 'why?' = ?1 FROM t", ]); }); diff --git a/packages/3-targets/mysql/3-driver/README.md b/packages/3-targets/mysql/3-driver/README.md index 560483c043cf..c19d6cfd04dd 100644 --- a/packages/3-targets/mysql/3-driver/README.md +++ b/packages/3-targets/mysql/3-driver/README.md @@ -1,6 +1,6 @@ # @internal/driver-mysql -MySQL runtime and control drivers for Prisma 8, built on mysql2. +MySQL runtime and control drivers for Prisma 8, built on the `mariadb` package (3.5.4 or later). ## Package Classification @@ -10,18 +10,24 @@ MySQL runtime and control drivers for Prisma 8, built on mysql2. ## Purpose -Connects the runtime and the control plane to a MySQL server and hands codecs the text the server sends ([ADR 155](../../../../docs/architecture%20docs/adrs/ADR%20155%20-%20Driver%20Codec%20Boundary%20and%20Lowering%20Responsibilities.md)). +Connects the runtime and the control plane to a MySQL server and hands codecs every row value as text, bytes or null ([ADR 155](../../../../docs/architecture%20docs/adrs/ADR%20155%20-%20Driver%20Codec%20Boundary%20and%20Lowering%20Responsibilities.md)). ## Responsibilities -- **Prepared statements**: a statement with values runs as a server-side prepared statement, mysql2's `execute()`, so a value never travels inside the SQL. `src/core/positional-params.ts` writes the adapter's numbered `?N` as MySQL's positional `?` and orders the values to match, a value repeated as often as its `?N` appears; a bare `?` takes the values in order. It reads the SQL with the target's lexer, so a `?` inside a quoted string, a backtick identifier or a comment stays text, and an executable `/*!` comment is refused. A statement whose placeholders and values do not match raises `RUNTIME.PARAM_PLACEHOLDER_MISMATCH`; a safe integer is sent as a JavaScript bigint, because mysql2 sends a number as a double, which MySQL refuses where it needs an integer, as in `LIMIT`. mysql2 sends a bigint as its decimal text: MySQL gives that text the type of a column it is compared with, and the adapter casts an integer param everywhere else (see the adapter README). A statement MySQL cannot prepare, such as `LOCK TABLES`, fails with `DRIVER.PREPARE_FAILED`. Each connection keeps at most `statementCacheSize` prepared statements, 100 by default and at most 1,000, and closes the ones it drops: MySQL caps open statements server-wide (16,382 by default), and reaching that cap fails with `DRIVER.PREPARE_FAILED`. A `LIMIT` or `OFFSET` value that is not an integer fails with `RUNTIME.ARGUMENT_INVALID`. SQL the driver or the control plane writes with no values, such as the session setup and `START TRANSACTION`, which MySQL does not prepare, runs through the text protocol, `query()`. `multipleStatements` stays off. -- **Connection URL** (`src/core/connection-options.ts`): reads `ssl`, `connectTimeout` and `flags` as connection options and `connectionLimit`, `poolTimeout` (milliseconds, ten seconds by default) and `statementCacheSize` as pool settings, and refuses any other parameter with `RUNTIME.BINDING_INVALID`. Every connection uses utf8mb4 and a `charset` parameter is refused: the driver reads each statement as utf8mb4 to find its placeholders, and under a narrower charset MySQL can read a character of raw SQL as a quote or a backslash, so the two would disagree about where a placeholder is. -- **Session setup** (`src/core/session-setup.ts`): every connection clears `NO_BACKSLASH_ESCAPES` and `ANSI_QUOTES`, so MySQL reads quoted text as the driver's lexer does when it finds placeholders, and runs in UTC and in utf8mb4. The setup runs once per connection, before its first statement. A raw `SET` stays on the connection for its lifetime, as on Postgres: the connection goes back to the pool with the setting, and later statements on it see it. -- **Rows**: a prepared statement's rows come over the binary protocol, read with a bigint and a decimal as their exact text, a date and time as the text MySQL prints, JSON as UTF-8 text and binary strings as Buffers; an `int` reads as a number, a FLOAT as its widened double, a TIME without trailing fraction zeros and a YEAR as a number. Text-protocol rows go through `src/core/server-text.ts`, a `typeCast` that returns every column as text. -- **Errors**: a query error MySQL reports becomes a `SqlQueryError` carrying its SQLSTATE; a failed connection is `DRIVER.CONNECTION_FAILED` with the URL redacted. -- **Runtime driver** (`./runtime`): a mysql2 pool whose statements all run as prepared statements. Each physical connection runs the session setup once, before its first statement, and goes back to the pool after each statement; a lost or broken connection is destroyed. Rows stream through the connection; a stream whose connection is lost ends with `DRIVER.NOT_CONNECTED`, and a lost connection or a fatal error evicts it. When the caller stops reading a stream early, a connection the pool lent for that statement is evicted, and a connection the caller holds reads and drops the rest so its session survives. A transaction runs on one connection. A statement that waits longer than `poolTimeout` for a connection fails with `DRIVER.CONNECTION_FAILED`. `close()` waits for every connection and stream it has handed out. +Each responsibility follows the Postgres driver unless it says why MySQL differs. + +- **Statements** (`src/core/runtime/mysql-driver.ts`): a statement with values runs as a server-side prepared statement (`prepare`, `execute`, `close`), so a value never travels inside the SQL. A statement without values runs over the text protocol, as `pg` runs one, so a raw `START TRANSACTION` or `LOCK TABLES`, which MySQL does not prepare, runs too; nothing is written into it, and `multipleStatements` is off. `src/core/positional-params.ts` writes the adapter's numbered `?N` as MySQL's positional `?` and orders the values to match, a value repeated as often as its `?N` appears; a bare `?` takes the values in order. It reads the SQL with the target's lexer, so a `?` inside a quoted string, a backtick identifier or a comment stays text, and an executable `/*!` comment is refused. A statement whose placeholders and values do not match, by the lexer or by the count MySQL reports when it prepares the statement, raises `RUNTIME.PARAM_PLACEHOLDER_MISMATCH`. Every option that rewrites SQL on the client stays off. +- **No statement reuse**: each prepared statement is closed after it runs (`prepareCacheLength: 0`), and the request's `preparedStatementHandle` is ignored, which ADR 210 allows. Reusing a statement per handle, as the Postgres driver does, would keep one statement open per handle on every connection with no limit, against MySQL's server-wide cap on open statements (`max_prepared_stmt_count`, 16,382 by default, error 1461), and since MySQL 8.0.22 a reused statement keeps the parameter types of its first run, so a value sent later as another type is converted. +- **Values** (`src/core/parameter-values.ts`): the binary protocol gives each value a type, which `pg` never has to choose. An integer number within the signed 64-bit range is sent as a BIGINT, because the package sends a number as INT only below 2^31 - 1 and otherwise as DOUBLE, which MySQL refuses in `LIMIT`. Any other finite number is sent as the DOUBLE it is. A `bigint` must fit a signed BIGINT: below -2^63 the package throws inside its socket write, which crashes the process. A `Uint8Array` is sent as a Buffer over the same bytes, because the package sends any other `Uint8Array` as JSON text. A `Date` is refused: the package encodes it in the process's local time zone, and the MySQL codecs send dates as text. A refused value raises `RUNTIME.ENCODE_FAILED` with `paramIndex` and `received`, and the value itself is left out. The package's `logParam` is off, so it appends no values to error messages; MySQL's own message can still quote a value, such as `Duplicate entry '…'`, as Postgres's `Key (email)=(…)` does. +- **Rows** (`src/core/text-or-bytes.ts`): both drivers return every row value in the form [ADR 155](../../../../docs/architecture%20docs/adrs/ADR%20155%20-%20Driver%20Codec%20Boundary%20and%20Lowering%20Responsibilities.md) sets: text, a `Uint8Array` or `null`. One `typeCast`, `readTextOrBytes`, reads a binary-collation string or blob, BIT, GEOMETRY and any type the package has no name for, such as MySQL 9's VECTOR, as bytes, and every other column as text. The text is the same whichever protocol carried the row: MySQL writes it over the text protocol, the package writes numbers, dates and times from binary under a prepared statement, and the driver makes the two agree. Each takes the form MySQL writes over the text protocol, as Postgres returns every value as the server writes it. YEAR and a ZEROFILL integer are padded to the column's width and a TIME has the column's fraction digits. A FLOAT and a DOUBLE are read as the number the server sent and written as MySQL writes them (`src/core/float-text.ts`): a FLOAT with six significant digits, ties to even, and a DOUBLE with the fewest digits that read back as the same double, both in fixed notation unless that needs more than 14 zeros after the point or more than 15 digits before it with none after it. So `0.1` reads `0.1`, `1e-7` reads `0.0000001`, `1e20` reads `1e20`, and negative zero reads `-0`, from either protocol. A column declared with decimals, such as `FLOAT(12,6)` or `DOUBLE(10,2)`, reads with exactly those decimals in fixed notation, as MySQL writes it: the fewest digits that read back as the same double, a FLOAT's value read as a double, padded with zeros when they fit, otherwise the exact value rounded to them, ties to even. So a `FLOAT(12,6)` holding 1234.5677 reads `1234.567749`, a `DOUBLE(10,2)` holding 1.5 reads `1.50`, and its negative zero, which a computed column can hold, reads `-0.00`. DECIMAL, JSON, ENUM and SET (`a,b`) arrive as MySQL's text. +- **Connection URL** (`src/core/connection-options.ts`): reads the TLS parameters as `pg-connection-string` reads them for Postgres: `ssl` (`true`, `1`, `false`, `0`), `sslmode` (`disable`; `no-verify` accepts any certificate; every other mode verifies the certificate and host name), and `sslcert`, `sslkey` and `sslrootcert`, each the path of a PEM file. It also reads `allowPublicKeyRetrieval` (off by default; MySQL 8.4's `caching_sha2_password` needs it, or TLS, over plain TCP). It ignores any other parameter, as `pg` does: the package never sees the URL, so a parameter such as `multipleStatements` has no effect. Two parameters are refused with `RUNTIME.BINDING_INVALID`, because the driver cannot do what they ask. `sslcert` without `sslkey` is refused, because in a Prisma 7 MySQL URL `sslcert` named the CA file that checks the server, which is now `sslrootcert`; the message says so. A `charset` is refused, because every connection uses utf8mb4: the driver reads each statement as utf8mb4 to find its placeholders, and MySQL must read it the same way. No error copies the URL or a parameter's value. The package never follows a server's redirect to another host (`permitRedirect: false`), as `pg` never does. +- **Pool** (`MysqlDriverCreateOptions.poolOptions`): sized and timed as the Postgres facade sizes `pg.Pool`. The pool opens a connection only when one is needed, closes one idle for `idleTimeoutMillis` (30 seconds by default), and waits `connectionTimeoutMillis` (20 seconds) both to open a connection and for a pooled one to come free; `max` (10 by default) sets its size. +- **Session**: the package runs the session setup once on each physical connection (`initSql`). It clears `NO_BACKSLASH_ESCAPES` and `ANSI_QUOTES`, so MySQL reads quoted text as the driver's lexer does, sets utf8mb4, none of which holds a value. The session settings the target declares and the stack passes to `create` (the UTC `time_zone`) are set with one prepared `SET SESSION` (`src/core/session-settings.ts`), each value a parameter, the first time the runtime driver takes each physical connection from the pool, as the Postgres driver's `SessionSettingsGuard` sets them with `set_config($1, $2)`; the control driver sets them the same way after it connects. A value of decimal integer text is sent as a number, because MySQL refuses text for an integer variable. A setting MySQL refuses fails the statement at once with the `SqlQueryError` MySQL raised, and the connection is destroyed. There is no reset on release: a raw `SET` stays on the connection for its lifetime, as on Postgres. The pool rolls back a transaction left open on a connection it gets back. +- **Errors** (`src/core/normalize-error.ts`): as on Postgres, a lost connection (the package marks it fatal) and a pool that gives no connection in time are a `SqlConnectionError` with `transient: true`, and the lost connection is destroyed. A sign-in MySQL refuses (1045, 1698, 1524) is the `SqlQueryError` MySQL raised, as Postgres reports 28P01. A sign-in that needs the server's public key (the package's error 45044: the account uses `caching_sha2_password` or `sha256_password` and the connection has no TLS) is a `SqlConnectionError` with `transient: false` whose message names `allowPublicKeyRetrieval=true` and TLS, and session setup that fails (45030) is one too. A Node socket or DNS error is a `SqlConnectionError` classified by its code with the Postgres driver's table: a refused connection and an unknown or unreachable host are not transient; a reset, a timeout, a broken pipe and a failed DNS lookup are. The pool keeps retrying a connection until `connectionTimeoutMillis` ends, pausing longer after each failure, up to 10 seconds, and reports each failure to its `logger.error` as it happens, together with every failed statement. The driver reacts only to an error the package marks fatal that no retry can get past: a refused sign-in, a missing public key, failing session setup, a refused connection or an unknown or unreachable host; a statement can fail with a sign-in's error number, such as 1524, but not fatally. It fails the statements waiting for a connection at once, as `pg.Pool` rejects a pending connect whose connection fails, and keeps no failure, as `pg.Pool` keeps none: a later statement waits for the pool's next attempt. This is where the package differs from `pg.Pool`, which tries each connect at once. Its retries and pauses are fixed: within one attempt it retries a refused connection or an unknown host every 500 ms, and after each failed attempt it pauses 200 ms longer, up to 10 seconds. So while the server stays unreachable each later statement waits: measured, about 200 ms more for each statement after a refused sign-in, and after a refused connection at least 500 ms, growing with the pauses up to about 10 seconds. The package has no way to remove a request from its queue, so a statement the driver fails leaves its request there until the pool serves it or `connectionTimeoutMillis` ends. The driver returns a connection the pool hands to such a request straight to the pool: after 500 statements failed against a refused port, 500 requests stayed queued, and once the server accepted connections the next statement ran after 589 ms, behind them. The pool makes no attempt while no statement waits, so once the cause is fixed the next statement runs, after any pause still under way. A pool timeout caused by one of these failures reports it. Any other error MySQL reports is a `SqlQueryError` with its SQLSTATE and no `detail`, since MySQL sends none. Two such errors concern prepared statements: error 1295, a statement MySQL will not prepare, can come only from such a statement sent with values, since one without values runs as text; and error 1461, the server-wide cap on open prepared statements, can come only from other clients of the server holding statements open, since this driver closes each one after it runs. MySQL reports every constraint violation under SQLSTATE 23000, so the driver maps a duplicate key (1062, 1586) to `23505`, a foreign key violation (1216, 1217, 1451, 1452) to `23503`, a NULL in a NOT NULL column (1048) to `23502` and a CHECK violation (3819) to `23514`, as Postgres sends them and as the SQLite driver maps its own. `DRIVER.NOT_CONNECTED` means the driver was used before `connect` or after `close`; a failed control connection is `DRIVER.CONNECTION_FAILED` with the URL redacted. +- **Runtime driver** (`./runtime`): a `mariadb` pool. A query reads its whole result and returns its connection before the first row, as the Postgres facade does without `cursor`, so a statement awaited inside the loop over a query in a transaction runs. `stream: true` streams rows as they arrive instead. Statements on a held connection or transaction reach the server in the order they are sent, under a copy of the Postgres driver's per-client lock: a statement holds its client's lock until it settles, and a stream for its whole life. Without it a commit could run first, because the package queues a prepared statement's execution only after MySQL answers the prepare. A statement sent while a stream on the connection is open therefore waits for the stream to end, and one awaited inside the stream's own loop never finishes, as on Postgres with a cursor. When the caller stops a pooled stream early, the driver drops the rest if the whole result has arrived and returns the connection; otherwise it destroys the connection, so a large result is not downloaded only to be thrown away, and the package opens one short-lived extra connection to send `KILL`, because MySQL cannot cancel a statement on its own connection. A held connection reads and drops the rest, so its session survives. Releasing a held connection while a statement on it is still running, such as a stream the caller dropped, destroys it: the package's release sends `ROLLBACK` behind that statement and waits for it, which would never finish, where `pg`'s release does not wait. Transactions use the package's `beginTransaction()`, `commit()` and `rollback()`. `close()` waits for every connection and stream it has handed out, then ends the pool. +- **Control driver** (`./control`): one connection, running statements as the runtime driver does, a user's `rawSql` migration step included, and starting its session with the settings the stack passes. ## Exports -- `./runtime`: the runtime driver descriptor +- `./runtime`: the runtime driver descriptor, and the `MysqlBinding`, `MysqlDriverCreateOptions` and `MysqlPoolOptions` types - `./control`: the control driver descriptor and `MysqlControlDriver` diff --git a/packages/3-targets/mysql/3-driver/package.json b/packages/3-targets/mysql/3-driver/package.json index 4a43d6517323..e7f6739636c4 100644 --- a/packages/3-targets/mysql/3-driver/package.json +++ b/packages/3-targets/mysql/3-driver/package.json @@ -22,7 +22,8 @@ "@internal/sql-relational-core": "workspace:8.0.0-rc.21", "@internal/target-mysql": "workspace:8.0.0-rc.21", "@internal/utils": "workspace:8.0.0-rc.21", - "mysql2": "3.15.3" + "arktype": "^2.2.2", + "mariadb": "3.5.4" }, "devDependencies": { "@repo/test-utils": "workspace:8.0.0-rc.21", diff --git a/packages/3-targets/mysql/3-driver/src/core/connection-options.ts b/packages/3-targets/mysql/3-driver/src/core/connection-options.ts index 76762182b597..7158e34977bd 100644 --- a/packages/3-targets/mysql/3-driver/src/core/connection-options.ts +++ b/packages/3-targets/mysql/3-driver/src/core/connection-options.ts @@ -1,141 +1,180 @@ -import { blindCast } from '@internal/utils/casts'; +import { readFileSync } from 'node:fs'; import { ifDefined } from '@internal/utils/defined'; import { structuredError } from '@internal/utils/structured-error'; -import type { ConnectionOptions } from 'mysql2/promise'; +import { type } from 'arktype'; +import type { ConnectionConfig, PoolConfig } from 'mariadb'; +import { SESSION_SETUP } from './session-setup'; +import { readTextOrBytes } from './text-or-bytes'; + +/** The pool settings `mysql()` takes, named and defaulted as the Postgres facade's `poolOptions` and `pg.Pool`. */ +export interface MysqlPoolOptions { + /** How long to wait for a new connection to open, and for a pooled one to come free, in milliseconds. 20 seconds by default. */ + readonly connectionTimeoutMillis?: number; + /** How long a pooled connection may stay idle before the pool closes it, in milliseconds. 30 seconds by default; 0 keeps idle connections open. */ + readonly idleTimeoutMillis?: number; + /** The most connections the pool opens. 10 by default. */ + readonly max?: number; +} -const MULTI_STATEMENTS_FLAG = 'MULTI_STATEMENTS'; +const DEFAULT_CONNECT_TIMEOUT_MILLIS = 20_000; +const DEFAULT_IDLE_TIMEOUT_MILLIS = 30_000; -/** The URL parameters mysql2 reads as connection options. Any other mysql2 option could change how a statement is sent, such as `namedPlaceholders`, so none other is passed on. */ -const CONNECTION_PARAMETERS = new Set(['ssl', 'connectTimeout', 'flags']); +/** + * The URL parameters the driver reads. The TLS parameters are those `pg-connection-string` reads for the Postgres driver: `ssl` turns TLS on or off; `sslmode`, `sslcert`, `sslkey` and `sslrootcert` turn it on, the last three naming PEM files. + */ +const URL_PARAMETERS = { + 'ssl?': type("'true' | '1' | 'false' | '0'").pipe((text) => text === 'true' || text === '1'), + 'sslmode?': "'disable' | 'prefer' | 'require' | 'verify-ca' | 'verify-full' | 'no-verify'", + 'sslcert?': 'string > 0', + 'sslkey?': 'string > 0', + 'sslrootcert?': 'string > 0', + 'allowPublicKeyRetrieval?': type("'true' | 'false'").pipe((text) => text === 'true'), +} as const; -/** The URL parameters the runtime driver's pool reads. */ -const POOL_PARAMETERS = new Set(['connectionLimit', 'poolTimeout', 'statementCacheSize']); +const urlParameters = type(URL_PARAMETERS); -/** How mysql2 reads the binary protocol's rows: a bigint and a decimal as their exact text, a date and time as the text MySQL prints, JSON as its UTF-8 text, binary strings as Buffers, and every other string as text. */ -const BINARY_ROW_OPTIONS = { - supportBigNumbers: true, - bigNumberStrings: true, - dateStrings: true, - jsonStrings: true, -} as const; +type ParameterName = keyof typeof URL_PARAMETERS extends `${infer Name}?` ? Name : never; -const DEFAULT_POOL_TIMEOUT_MS = 10_000; +/** The form each URL parameter's value must take, for the refusal of a value. */ +const PARAMETER_FORMS: Readonly> = { + ssl: 'true, 1, false or 0', + sslmode: 'disable, prefer, require, verify-ca, verify-full or no-verify', + sslcert: 'the path of a PEM file', + sslkey: 'the path of a PEM file', + sslrootcert: 'the path of a PEM file', + allowPublicKeyRetrieval: 'true or false', +}; -/** Prepared statements each connection keeps open, as the Prisma engines' MySQL connector does. MySQL caps open statements server-wide (16,382 by default), and mysql2's own default of 16,000 per connection would let two connections reach it. */ -const DEFAULT_STATEMENT_CACHE_SIZE = 100; +function isParameterName(name: string): name is ParameterName { + return Object.hasOwn(PARAMETER_FORMS, name); +} -/** The largest statement cache a URL may ask for: at mysql2's default of 10 connections, 1,000 each keeps one process at 10,000 open statements, under MySQL's server-wide default of 16,382. */ -const MAX_STATEMENT_CACHE_SIZE = 1000; +type UrlParameters = typeof urlParameters.infer; -/** The connection's character set. The renderer writes literal text into the SQL, which mysql2 encodes in this charset; a narrower one, such as latin1 or sjis, can turn a character of it into a quote or a backslash after escaping. */ -const CHARSET = 'UTF8MB4_UNICODE_CI'; +/** + * Options the URL cannot change. Values travel only as prepared-statement parameters, so every option that would rewrite SQL on the client stays off, and `logParam` keeps values out of error messages. `permitRedirect` stays off, so a server cannot send the client, with its credentials, to another host, which `pg` never does. Each statement is prepared, run and closed (`prepareCacheLength: 0`). `typeCast` reads every column as text or bytes, so no column reaches a codec as a value the package parsed. `foundRows` makes an UPDATE count the rows it matched, as Postgres's row count does. The connection is utf8mb4: the driver reads each statement as utf8mb4 to find its placeholders, and MySQL must read it the same way. + */ +const FIXED_OPTIONS: ConnectionConfig & { readonly permitRedirect: false } = { + collation: 'UTF8MB4_UNICODE_CI', + multipleStatements: false, + namedPlaceholders: false, + permitSetMultiParamEntries: false, + permitLocalInfile: false, + logParam: false, + permitRedirect: false, + prepareCacheLength: 0, + foundRows: true, + typeCast: readTextOrBytes, +}; + +/** Refuses the URL. `parameters` names the parameters at fault; no value from the URL goes into the error, because a URL carries the password. */ +function invalidBinding(reason: string, parameters: readonly string[]): Error { + return structuredError('RUNTIME.BINDING_INVALID', `MySQL connection URL: ${reason}`, { + meta: { received: parameters.join(', '), reason }, + }); +} -function parameterValue(text: string): unknown { +/** Parses `url` as the Postgres facade does, refusing it without the parser's error, which carries the whole URL. */ +function parseUrl(url: string): URL { try { - return JSON.parse(text); + return new URL(url); } catch { - return text; + throw invalidBinding('the URL cannot be parsed', []); } } -function invalidBinding(reason: string, received: string): Error { - return structuredError('RUNTIME.BINDING_INVALID', `MySQL connection URL: ${reason}`, { - meta: { received, reason }, - }); -} - -function urlParameters(parsed: URL): Map { - const parameters = new Map(parsed.searchParams); - for (const name of parameters.keys()) { - if (name === 'charset') { - throw invalidBinding( - 'the driver always uses utf8mb4, because a narrower charset can turn a character of a value into a quote after escaping; remove the charset parameter', - name, - ); - } - if (!CONNECTION_PARAMETERS.has(name) && !POOL_PARAMETERS.has(name)) { - throw invalidBinding( - `the parameter ${name} is not one the MySQL driver reads; it reads ${[...CONNECTION_PARAMETERS, ...POOL_PARAMETERS].join(', ')}`, - name, - ); - } +function readUrl(url: string): { readonly parsed: URL; readonly parameters: UrlParameters } { + const parsed = parseUrl(url); + if (parsed.searchParams.has('charset')) { + throw invalidBinding( + 'the driver always uses utf8mb4, because it reads each statement as utf8mb4 to find its placeholders; remove the charset parameter', + ['charset'], + ); } - return parameters; + const names = [...new Set(parsed.searchParams.keys())].filter(isParameterName); + const parameters = urlParameters( + Object.fromEntries([...parsed.searchParams].filter(([name]) => isParameterName(name))), + ); + if (parameters instanceof type.errors) { + const invalid = names.filter((name) => parameters.some((error) => error.path[0] === name)); + throw invalidBinding( + invalid.map((name) => `${name} must be ${PARAMETER_FORMS[name]}`).join('; '), + invalid, + ); + } + if (parameters.sslcert !== undefined && parameters.sslkey === undefined) { + throw invalidBinding( + "sslcert names the client's certificate and needs sslkey; to check the server's certificate against a CA file, as sslcert did in Prisma 7, use sslrootcert", + ['sslcert'], + ); + } + return { parsed, parameters }; } -function flagsWithoutMultipleStatements(flags: unknown): string[] | undefined { - if (flags === undefined) return undefined; - const listed = Array.isArray(flags) ? flags.map(String) : String(flags).split(/\s*,+\s*/); - return listed - .map((flag) => flag.toUpperCase()) - .filter((flag) => flag.length > 0 && flag !== MULTI_STATEMENTS_FLAG); +function readPem(parameter: 'sslcert' | 'sslkey' | 'sslrootcert', path: string): string { + try { + return readFileSync(path, 'utf8'); + } catch { + throw invalidBinding(`the file ${parameter} names cannot be read`, [parameter]); + } } /** - * The mysql2 options for a connection URL, always utf8mb4, with multiple statements kept off whatever the flags say. Refuses a URL parameter the driver does not read, and a charset. + * The TLS settings the URL gives, read as `pg-connection-string` reads them for the Postgres driver: `ssl=true` turns TLS on and `ssl=false` off; `sslcert`, `sslkey`, `sslrootcert` or `sslmode` turn it on, with the certificate, key and CA read from the files they name. `sslmode=disable` turns it off; `no-verify` accepts any server certificate; every other mode verifies the certificate and the host name, as `verify-full` does. */ -export function mysqlConnectionOptions(url: string, connectTimeoutMs?: number): ConnectionOptions { - const parsed = new URL(url); - const parameters = urlParameters(parsed); - const { flags, ...options } = Object.fromEntries( - [...parameters] - .filter(([name]) => CONNECTION_PARAMETERS.has(name)) - .map(([name, value]) => [name, parameterValue(value)]), - ); +function tlsOptions(parameters: UrlParameters): ConnectionConfig['ssl'] { + const { sslcert, sslkey, sslrootcert, sslmode } = parameters; + if ([sslcert, sslkey, sslrootcert, sslmode].every((value) => value === undefined)) { + return parameters.ssl; + } + if (sslmode === 'disable') return false; + return { + ...ifDefined('cert', sslcert === undefined ? undefined : readPem('sslcert', sslcert)), + ...ifDefined('key', sslkey === undefined ? undefined : readPem('sslkey', sslkey)), + ...ifDefined('ca', sslrootcert === undefined ? undefined : readPem('sslrootcert', sslrootcert)), + ...(sslmode === 'no-verify' ? { rejectUnauthorized: false } : {}), + }; +} + +function connectionOptions( + parsed: URL, + parameters: UrlParameters, + connectTimeoutMs: number | undefined, +): ConnectionConfig { return { - ...blindCast< - ConnectionOptions, - 'each allowed URL parameter is the mysql2 option of the same name, JSON-parsed when it parses' - >(options), - host: decodeURIComponent(parsed.hostname), + host: decodeURIComponent(parsed.hostname).replace(/^\[(.*)\]$/, '$1'), ...ifDefined('port', parsed.port === '' ? undefined : Number(parsed.port)), user: decodeURIComponent(parsed.username), password: decodeURIComponent(parsed.password), database: decodeURIComponent(parsed.pathname.slice(1)), - ...ifDefined('flags', flagsWithoutMultipleStatements(flags)), - multipleStatements: false, - ...BINARY_ROW_OPTIONS, - charset: CHARSET, - timezone: 'Z', - ...ifDefined('connectTimeout', connectTimeoutMs), + ...ifDefined('ssl', tlsOptions(parameters)), + allowPublicKeyRetrieval: parameters.allowPublicKeyRetrieval ?? false, + connectTimeout: connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MILLIS, + initSql: [...SESSION_SETUP], + ...FIXED_OPTIONS, }; } -export interface MysqlPoolSettings { - readonly connectionLimit: number | undefined; - /** How long a statement waits for a free pooled connection before it fails. */ - readonly poolTimeoutMs: number; - /** How many prepared statements each connection keeps open; it closes the least recently used one beyond that. */ - readonly statementCacheSize: number; +/** The `mariadb` options for one connection to the server a `mysql://` URL names. Ignores a URL parameter the driver does not read, as `pg` does, and refuses a charset. */ +export function mysqlConnectionOptions( + url: string, + settings?: { readonly connectTimeoutMs?: number | undefined }, +): ConnectionConfig { + const { parsed, parameters } = readUrl(url); + return connectionOptions(parsed, parameters, settings?.connectTimeoutMs); } -function positiveInteger(name: string, text: string | undefined): number | undefined { - if (text === undefined) return undefined; - const value = Number(text); - if (!Number.isSafeInteger(value) || value <= 0) { - throw invalidBinding(`${name} must be a positive integer`, `${name}=${text}`); - } - return value; -} - -function statementCacheSize(text: string | undefined): number { - const size = positiveInteger('statementCacheSize', text) ?? DEFAULT_STATEMENT_CACHE_SIZE; - if (size > MAX_STATEMENT_CACHE_SIZE) { - throw invalidBinding( - `statementCacheSize must be at most ${MAX_STATEMENT_CACHE_SIZE}, so a process's connections stay under MySQL's server-wide limit on open prepared statements`, - `statementCacheSize=${text}`, - ); - } - return size; -} - -/** The pool settings a connection URL gives: `connectionLimit`; `poolTimeout` in milliseconds, ten seconds unless the URL says otherwise; and `statementCacheSize`, 100 unless it says otherwise. */ -export function mysqlPoolSettings(url: string): MysqlPoolSettings { - const parameters = urlParameters(new URL(url)); +/** + * The `mariadb` pool options for a `mysql://` URL, sized and timed as the Postgres driver's `pg.Pool`: it opens a connection only when one is needed (`minimumIdle: 0`), closes one idle for `idleTimeoutMillis`, and waits `connectionTimeoutMillis` both to open a connection and for a pooled one to come free. + */ +export function mysqlPoolOptions(url: string, poolOptions?: MysqlPoolOptions): PoolConfig { + const { parsed, parameters } = readUrl(url); + const timeoutMs = poolOptions?.connectionTimeoutMillis ?? DEFAULT_CONNECT_TIMEOUT_MILLIS; return { - connectionLimit: positiveInteger('connectionLimit', parameters.get('connectionLimit')), - poolTimeoutMs: - positiveInteger('poolTimeout', parameters.get('poolTimeout')) ?? DEFAULT_POOL_TIMEOUT_MS, - statementCacheSize: statementCacheSize(parameters.get('statementCacheSize')), + ...connectionOptions(parsed, parameters, timeoutMs), + acquireTimeout: timeoutMs, + minimumIdle: 0, + idleTimeout: Math.ceil((poolOptions?.idleTimeoutMillis ?? DEFAULT_IDLE_TIMEOUT_MILLIS) / 1000), + ...ifDefined('connectionLimit', poolOptions?.max), }; } diff --git a/packages/3-targets/mysql/3-driver/src/core/float-text.ts b/packages/3-targets/mysql/3-driver/src/core/float-text.ts new file mode 100644 index 000000000000..203764b03470 --- /dev/null +++ b/packages/3-targets/mysql/3-driver/src/core/float-text.ts @@ -0,0 +1,126 @@ +const SIGNIFICANT_DIGITS = 6; +const MAX_FIXED_DIGITS = 15; + +/** The exact decimal digits of `significand` × 2^`binaryExponent`, and where the decimal point falls in them. */ +function exactDigits( + significand: bigint, + binaryExponent: number, +): { readonly digits: string; readonly pointAt: number } { + if (binaryExponent >= 0) { + const digits = (significand << BigInt(binaryExponent)).toString(); + return { digits, pointAt: digits.length }; + } + const digits = (significand * 5n ** BigInt(-binaryExponent)).toString(); + return { digits, pointAt: digits.length + binaryExponent }; +} + +/** The exact decimal digits of a finite, non-zero 32-bit float's magnitude, and where the decimal point falls in them. */ +function exactFloatDigits(bits: number): { readonly digits: string; readonly pointAt: number } { + const biasedExponent = (bits >>> 23) & 0xff; + const fraction = BigInt(bits & 0x7fffff); + return biasedExponent === 0 + ? exactDigits(fraction, -149) + : exactDigits(fraction | (1n << 23n), biasedExponent - 150); +} + +/** The exact decimal digits of a finite, non-zero double's magnitude, and where the decimal point falls in them. */ +function exactDoubleDigits(value: number): { readonly digits: string; readonly pointAt: number } { + const view = new DataView(new ArrayBuffer(8)); + view.setFloat64(0, value); + const bits = view.getBigUint64(0); + const biasedExponent = Number((bits >> 52n) & 0x7ffn); + const fraction = bits & ((1n << 52n) - 1n); + return biasedExponent === 0 + ? exactDigits(fraction, -1074) + : exactDigits(fraction | (1n << 52n), biasedExponent - 1075); +} + +/** `digits`, with the decimal point at `pointAt`, rounded to their first `kept` digits, ties to even, and where the decimal point then falls. Rounding to no digits at all gives zero. */ +function roundedTo( + digits: string, + pointAt: number, + kept: number, +): { digits: string; pointAt: number } { + if (digits.length <= kept) return { digits, pointAt }; + if (kept < 0) return { digits: '0', pointAt: 1 }; + const head = digits.slice(0, kept); + const first = digits[kept] ?? '0'; + const tie = first === '5' && !/[1-9]/.test(digits.slice(kept + 1)); + const roundsUp = first > '5' || (first === '5' && (!tie || Number(head.at(-1) ?? '0') % 2 === 1)); + if (!roundsUp) return head === '' ? { digits: '0', pointAt: 1 } : { digits: head, pointAt }; + const raised = (BigInt(`0${head}`) + 1n).toString(); + return { digits: raised, pointAt: raised.length > kept ? pointAt + 1 : pointAt }; +} + +function withoutTrailingZeros(digits: string): string { + return digits.replace(/0+$/, ''); +} + +/** The fewest significant digits that read back as the double `magnitude`, which JavaScript also prints, and where the decimal point falls in them. */ +function shortestDigits(magnitude: number): { readonly digits: string; readonly pointAt: number } { + const [mantissa = '', exponent = ''] = magnitude.toExponential().split('e'); + return { digits: mantissa.replace('.', ''), pointAt: Number(exponent) + 1 }; +} + +function fixed(digits: string, pointAt: number): string { + if (pointAt <= 0) return `0.${'0'.repeat(-pointAt)}${digits}`; + if (pointAt < digits.length) return `${digits.slice(0, pointAt)}.${digits.slice(pointAt)}`; + return digits.padEnd(pointAt, '0'); +} + +function scientific(digits: string, pointAt: number): string { + const fraction = digits.slice(1); + return `${digits[0]}${fraction === '' ? '' : `.${fraction}`}e${pointAt - 1}`; +} + +/** Significant `digits` with the decimal point at `pointAt` in MySQL's notation: fixed unless that needs more than 14 zeros after the point, or more than 15 digits before it with none after it. */ +function mysqlNotation(negative: boolean, digits: string, pointAt: number): string { + const isFixed = + pointAt > -MAX_FIXED_DIGITS && (pointAt <= MAX_FIXED_DIGITS || digits.length > pointAt); + return `${negative ? '-' : ''}${isFixed ? fixed(digits, pointAt) : scientific(digits, pointAt)}`; +} + +/** + * A FLOAT's text as MySQL writes it over the text protocol: six significant digits, rounded half to even, without trailing zeros, in MySQL's notation. Under a prepared statement MySQL sends the 32-bit float in binary, and this gives it the same text. + */ +export function mysqlFloatText(value: number): string { + const view = new DataView(new ArrayBuffer(4)); + view.setFloat32(0, value); + const bits = view.getUint32(0); + const negative = bits >>> 31 === 1; + if ((bits & 0x7fffffff) === 0) return mysqlNotation(negative, '0', 1); + const exact = exactFloatDigits(bits); + const { digits, pointAt } = roundedTo(exact.digits, exact.pointAt, SIGNIFICANT_DIGITS); + return mysqlNotation(negative, withoutTrailingZeros(digits), pointAt); +} + +/** + * A DOUBLE's text as MySQL writes it over the text protocol: the fewest significant digits that read back as the same double, which JavaScript also prints, in MySQL's notation. Under a prepared statement MySQL sends the double in binary, and this gives it the same text. + */ +export function mysqlDoubleText(value: number): string { + const negative = value < 0 || Object.is(value, -0); + const { digits, pointAt } = shortestDigits(Math.abs(value)); + return mysqlNotation(negative, digits, pointAt); +} + +function digitsToDecimals( + magnitude: number, + decimals: number, +): { digits: string; pointAt: number } { + const shortest = shortestDigits(magnitude); + if (shortest.digits.length - shortest.pointAt <= decimals) return shortest; + const exact = exactDoubleDigits(magnitude); + return roundedTo(exact.digits, exact.pointAt, exact.pointAt + decimals); +} + +/** + * A FLOAT or DOUBLE column declared with `decimals`, such as `DOUBLE(10,2)`, as MySQL writes it over the text protocol, a FLOAT's value read as a double: the fewest significant digits that read back as the same double, padded with zeros to `decimals` digits after the point when they fit, and otherwise the exact value rounded to `decimals` digits after the point, ties to even, in fixed notation. Negative zero, which a computed column such as `-price` can hold, keeps its sign. + */ +export function mysqlFixedDecimalsText(value: number, decimals: number): string { + const { digits, pointAt } = digitsToDecimals(Math.abs(value), decimals); + const whole = pointAt > 0 ? digits.slice(0, pointAt).padEnd(pointAt, '0') : '0'; + const fraction = `${'0'.repeat(Math.max(-pointAt, 0))}${digits.slice(Math.max(pointAt, 0))}` + .padEnd(decimals, '0') + .slice(0, decimals); + return `${value < 0 || Object.is(value, -0) ? '-' : ''}${whole}${decimals === 0 ? '' : `.${fraction}`}`; +} diff --git a/packages/3-targets/mysql/3-driver/src/core/migrations/control-driver.ts b/packages/3-targets/mysql/3-driver/src/core/migrations/control-driver.ts index 74287a6c7726..1a9de91f8460 100644 --- a/packages/3-targets/mysql/3-driver/src/core/migrations/control-driver.ts +++ b/packages/3-targets/mysql/3-driver/src/core/migrations/control-driver.ts @@ -6,17 +6,16 @@ import type { import type { SqlControlDriverInstance } from '@internal/sql-contract/types'; import { blindCast } from '@internal/utils/casts'; import { redactDatabaseUrl } from '@internal/utils/redact-db-url'; -import { type Connection, type ConnectionOptions, createConnection } from 'mysql2/promise'; -import { mysqlConnectionOptions, mysqlPoolSettings } from '../connection-options'; +import { type Connection, createConnection } from 'mariadb'; +import { mysqlConnectionOptions } from '../connection-options'; import { mysqlDriverDescriptorMeta } from '../descriptor-meta'; -import { connectionLost, isFatal, normalizeMysqlError, statementError } from '../normalize-error'; +import { connectionFix, connectionLost, isFatal, normalizeMysqlError } from '../normalize-error'; +import { assertPlaceholderCount } from '../placeholder-count'; import { positionalParams } from '../positional-params'; -import { assertPreparedCount } from '../prepared-count'; -import { serverText } from '../server-text'; -import { SESSION_SETUP } from '../session-setup'; +import { applySessionSettings, sessionSettingsStatement } from '../session-settings'; /** - * A MySQL connection for the control plane. A statement with values runs as a server-side prepared statement, `execute()`, so a value never travels inside the SQL; SQL with no values, which the control plane writes itself, runs through the text protocol, `query()`, and reads every column as the text MySQL sends. `multipleStatements` stays off whatever the URL says. + * A MySQL connection for the control plane. It runs statements as the runtime driver does: one with values as a server-side prepared statement, so a value never travels inside the SQL, and one without values, a user's `rawSql` migration step included, over the text protocol, so `START TRANSACTION` and `LOCK TABLES`, which MySQL does not prepare, run too. Either way every column reads in one form, text or bytes. */ export class MysqlControlDriver implements SqlControlDriverInstance<'mysql'> { readonly familyId = 'sql' as const; @@ -30,9 +29,7 @@ export class MysqlControlDriver implements SqlControlDriverInstance<'mysql'> { }); } - /** - * Runs `sql` and returns its rows. A statement without values runs over the text protocol and reads every column as the text MySQL sends. A statement with values runs as a prepared statement over the binary protocol, so an integer reads as a number and a date as the text MySQL prints: the same query returns rows of a different shape depending on whether it has values. - */ + /** Runs `sql` and returns its rows, every column as text and a binary column as bytes. */ async query>( sql: string, params?: readonly unknown[], @@ -40,29 +37,34 @@ export class MysqlControlDriver implements SqlControlDriverInstance<'mysql'> { if (this.#lost !== undefined) throw connectionLost(this.#lost); try { const statement = positionalParams(sql, params ?? []); - const [result] = + const result: unknown = statement.values.length === 0 - ? await this.connection.query({ sql: statement.sql, typeCast: serverText }) + ? await this.connection.query(statement.sql) : await this.#executeChecked(statement.sql, statement.values); return { rows: Array.isArray(result) - ? blindCast( - result, + ? blindCast( + [...result], ) : [], }; } catch (error) { - if (isFatal(error)) { + if (error instanceof Error && isFatal(error)) { this.#lost = error; throw connectionLost(error); } - throw statementError(error, sql); + throw normalizeMysqlError(error); } } - async #executeChecked(sql: string, values: unknown[]) { - assertPreparedCount(await this.connection.prepare(sql), values.length); - return this.connection.execute({ sql, values }); + async #executeChecked(sql: string, values: unknown[]): Promise { + const statement = await this.connection.prepare(sql); + try { + assertPlaceholderCount(statement, values.length); + return await statement.execute(values); + } finally { + statement.close(); + } } async databaseName(): Promise { @@ -76,31 +78,29 @@ export class MysqlControlDriver implements SqlControlDriverInstance<'mysql'> { } } -async function connect(connectionOptions: ConnectionOptions): Promise { - const connection = await createConnection(connectionOptions); - try { - for (const statement of SESSION_SETUP) await connection.query(statement); - return connection; - } catch (error) { - await connection.end().catch(() => {}); - throw error; - } -} - const mysqlDriverDescriptor: ControlDriverDescriptor<'sql', 'mysql', MysqlControlDriver> = { ...mysqlDriverDescriptorMeta, async create(url: string, options?: ControlDriverConnectOptions): Promise { - const connectionOptions = { - ...mysqlConnectionOptions(url, options?.connectTimeoutMs), - maxPreparedStatements: mysqlPoolSettings(url).statementCacheSize, - }; + const connectionOptions = mysqlConnectionOptions(url, { + connectTimeoutMs: options?.connectTimeoutMs, + }); + const settings = sessionSettingsStatement(options?.sessionSettings ?? {}); try { - return new MysqlControlDriver(await connect(connectionOptions)); + const connection = await createConnection(connectionOptions); + if (settings !== undefined) { + await applySessionSettings(connection, settings).catch(async (error: unknown) => { + await connection.end().catch(() => {}); + throw error; + }); + } + return new MysqlControlDriver(connection); } catch (error) { const normalized = normalizeMysqlError(error); throw errorRuntime('DRIVER.CONNECTION_FAILED', 'Database connection failed', { why: normalized instanceof Error ? normalized.message : String(normalized), - fix: 'Verify the database URL, ensure the database is reachable, and confirm credentials/permissions', + fix: + connectionFix(error) ?? + 'Verify the database URL, ensure the database is reachable, and confirm credentials/permissions', meta: { ...redactDatabaseUrl(url) }, cause: error, }); diff --git a/packages/3-targets/mysql/3-driver/src/core/normalize-error.ts b/packages/3-targets/mysql/3-driver/src/core/normalize-error.ts index 85e919c43872..f4a69d23174f 100644 --- a/packages/3-targets/mysql/3-driver/src/core/normalize-error.ts +++ b/packages/3-targets/mysql/3-driver/src/core/normalize-error.ts @@ -1,82 +1,135 @@ -import { errorRuntime } from '@internal/errors/execution'; -import { SqlQueryError } from '@internal/sql-errors'; +import { SqlConnectionError, SqlQueryError } from '@internal/sql-errors'; import { ifDefined } from '@internal/utils/defined'; -interface Mysql2Error extends Error { - readonly sqlState?: string; - readonly sqlMessage?: string; +/** The fields of the `mariadb` package's `SqlError` the driver reads. */ +interface MariadbError extends Error { + readonly errno: number; + readonly sqlState?: string | null; + readonly sqlMessage?: string | null; + readonly fatal?: boolean; } -function isMysql2Error(error: unknown): error is Mysql2Error { - return error instanceof Error && 'sqlState' in error && typeof error.sqlState === 'string'; +function isMariadbError(error: unknown): error is MariadbError { + return error instanceof Error && 'errno' in error && typeof error.errno === 'number'; } -/** A query error MySQL reported, as the family's `SqlQueryError` with its SQLSTATE; any other error unchanged. */ -export function normalizeMysqlError(error: unknown): unknown { - if (!isMysql2Error(error)) return error; - return new SqlQueryError(error.sqlMessage ?? error.message, { - cause: error, - ...ifDefined('sqlState', error.sqlState), - }); +/** Whether `error` left its connection unusable: the package marks a lost socket, and a server error of SQLSTATE class 08, as fatal. */ +export function isFatal(error: unknown): boolean { + return error instanceof Error && 'fatal' in error && error.fatal === true; } -/** Whether mysql2 reported `error` as fatal, which leaves its connection unusable. */ -export function isFatal(error: unknown): error is Error { - return error instanceof Error && 'fatal' in error && error.fatal === true; +function serverMessage(error: Error): string { + return isMariadbError(error) ? (error.sqlMessage ?? error.message) : error.message; } -/** The error a statement ends with when its connection is lost or closed under it. */ -export function connectionLost(cause?: unknown): Error { - return errorRuntime('DRIVER.NOT_CONNECTED', 'MySQL connection lost or closed', { - why: cause instanceof Error ? cause.message : 'The connection ended.', - fix: 'Retry the work; the driver opens a new connection.', - ...(cause === undefined ? {} : { cause }), +/** The error a statement ends with when its connection is lost or closed under it: a `SqlConnectionError` a retry on a new connection can get past, as on Postgres. */ +export function connectionLost(cause: Error): SqlConnectionError { + return new SqlConnectionError(serverMessage(cause), { + cause, + transient: true, + ...ifDefined('sqlState', isMariadbError(cause) ? (cause.sqlState ?? undefined) : undefined), }); } -const ER_WRONG_ARGUMENTS = 1210; -const ER_UNSUPPORTED_PS = 1295; -const ER_MAX_PREPARED_STMT_COUNT_REACHED = 1461; +/** Node's socket and DNS error codes, and whether a retry can succeed after each, as the Postgres driver classifies them. */ +const NODE_CONNECTION_CODES: ReadonlyMap = new Map([ + ['ECONNRESET', true], + ['ETIMEDOUT', true], + ['EPIPE', true], + ['EAI_AGAIN', true], + ['ECONNREFUSED', false], + ['ENOTFOUND', false], + ['EHOSTUNREACH', false], +]); + +/** Whether a retry can succeed after `error`, for a Node socket or DNS error; undefined for any other. */ +function nodeConnectionTransience(error: unknown): boolean | undefined { + return error instanceof Error && 'code' in error && typeof error.code === 'string' + ? NODE_CONNECTION_CODES.get(error.code) + : undefined; +} + +const ER_GET_CONNECTION_TIMEOUT = 45028; +const ER_INITIAL_SQL_ERROR = 45030; +const ER_CANNOT_RETRIEVE_RSA_KEY = 45044; + +/** The errors MySQL refuses a sign-in with: access denied, access denied for a socket-authenticated account, and an authentication plugin the server has not loaded. Another attempt with the same URL fails the same way. */ +const SIGN_IN_REFUSED: ReadonlySet = new Set([1045, 1698, 1524]); + +/** Whether `error` is one another attempt to connect with the same settings cannot get past: a refused connection, an unknown or unreachable host, a refused sign-in, a sign-in that needs the server's public key, or session setup MySQL refuses. */ +export function connectingCannotSucceed(error: unknown): boolean { + if (nodeConnectionTransience(error) === false) return true; + return ( + isMariadbError(error) && + (SIGN_IN_REFUSED.has(error.errno) || + error.errno === ER_CANNOT_RETRIEVE_RSA_KEY || + error.errno === ER_INITIAL_SQL_ERROR) + ); +} + +const UNIQUE_VIOLATION = '23505'; +const FOREIGN_KEY_VIOLATION = '23503'; +const NOT_NULL_VIOLATION = '23502'; +const CHECK_VIOLATION = '23514'; + +/** The SQLSTATE Postgres sends for each constraint MySQL reports under a shared SQLSTATE, as the SQLite driver maps its own codes, so a consumer such as `isUniqueConstraintViolation` reads them alike. */ +const CONSTRAINT_SQLSTATES: ReadonlyMap = new Map([ + [1062, UNIQUE_VIOLATION], + [1586, UNIQUE_VIOLATION], + [1216, FOREIGN_KEY_VIOLATION], + [1217, FOREIGN_KEY_VIOLATION], + [1451, FOREIGN_KEY_VIOLATION], + [1452, FOREIGN_KEY_VIOLATION], + [1048, NOT_NULL_VIOLATION], + [3819, CHECK_VIOLATION], +]); + +const PUBLIC_KEY_FIX = + 'Add allowPublicKeyRetrieval=true to the URL, or connect over TLS with sslmode or ssl=true in the URL. Fetching the key over plain TCP lets a machine between the client and the server pose as the server and read the password; TLS does not.'; + +/** The fix for a failed connection when the package's error names its cause, such as a sign-in that needs the server's public key. */ +export function connectionFix(error: unknown): string | undefined { + return isMariadbError(error) && error.errno === ER_CANNOT_RETRIEVE_RSA_KEY + ? PUBLIC_KEY_FIX + : undefined; +} + +function publicKeyNeeded(error: MariadbError): SqlConnectionError { + return new SqlConnectionError( + `MySQL sign-in needs the server's public key: the account signs in with caching_sha2_password or sha256_password over a connection without TLS. ${PUBLIC_KEY_FIX}`, + { cause: error, transient: false }, + ); +} -function errnoOf(error: unknown): unknown { - return error instanceof Error && 'errno' in error ? error.errno : undefined; +function poolTimedOut(error: MariadbError): unknown { + const cause = error.cause; + if (connectingCannotSucceed(cause)) return normalizeMysqlError(cause); + return new SqlConnectionError( + cause instanceof Error + ? `Timed out waiting for a pooled MySQL connection; the pool could not open one: ${serverMessage(cause)}` + : 'Timed out waiting for a pooled MySQL connection; every connection in the pool was in use.', + { cause: error, transient: true }, + ); } /** - * A statement's error, by MySQL's error number: `DRIVER.PREPARE_FAILED` for a statement MySQL cannot prepare or for the server's limit on open prepared statements, `RUNTIME.ARGUMENT_INVALID` for a value MySQL refused for a placeholder, such as a `LIMIT` that is not an integer, and otherwise the driver's usual normalization. + * An error from the `mariadb` package as the driver reports it, in the family's types as the Postgres driver reports its own: a Node socket or DNS error as a `SqlConnectionError`, transient as the Postgres driver's table says; a lost connection and a pool that gave no connection in time as a transient `SqlConnectionError`; a sign-in that needs the server's public key, and session setup that fails, as a `SqlConnectionError` that is not transient; a sign-in MySQL refuses, as Postgres's 28P01, and an error MySQL reported about a statement as `SqlQueryError` with its SQLSTATE, a constraint violation with the SQLSTATE Postgres sends; and any other error unchanged. A pool that timed out because it could not connect in a way no retry can get past reports that error. */ -export function statementError(error: unknown, sql: string): unknown { - switch (errnoOf(error)) { - case ER_UNSUPPORTED_PS: - return errorRuntime( - 'DRIVER.PREPARE_FAILED', - 'MySQL cannot run this statement as a prepared statement', - { - why: 'The runtime driver sends every statement as a server-side prepared statement, so values never travel inside the SQL, and MySQL refuses to prepare a few statements, such as LOCK TABLES.', - fix: 'Run the statement outside the Prisma runtime.', - meta: { sql }, - cause: error, - }, - ); - case ER_MAX_PREPARED_STMT_COUNT_REACHED: - return errorRuntime( - 'DRIVER.PREPARE_FAILED', - 'MySQL has reached its limit on open prepared statements', - { - why: 'Every connection keeps up to statementCacheSize prepared statements open, and the server caps them across all connections (max_prepared_stmt_count, 16,382 by default).', - fix: 'Lower statementCacheSize or connectionLimit in the URL, run fewer processes against the server, or raise max_prepared_stmt_count on the server.', - meta: { sql }, - cause: error, - }, - ); - case ER_WRONG_ARGUMENTS: - return errorRuntime('RUNTIME.ARGUMENT_INVALID', 'MySQL refused a value for a placeholder', { - why: 'A placeholder MySQL reads as an integer, such as a LIMIT or OFFSET, received a value that is not one.', - fix: 'Pass an integer.', - meta: { sql }, - cause: error, - }); - default: - return normalizeMysqlError(error); +export function normalizeMysqlError(error: unknown): unknown { + const transient = nodeConnectionTransience(error); + if (transient !== undefined && error instanceof Error) { + return new SqlConnectionError(error.message, { cause: error, transient }); + } + if (!isMariadbError(error)) return error; + if (error.errno === ER_CANNOT_RETRIEVE_RSA_KEY) return publicKeyNeeded(error); + if (error.errno === ER_INITIAL_SQL_ERROR) { + return new SqlConnectionError(serverMessage(error), { cause: error, transient: false }); } + if (isFatal(error) && !SIGN_IN_REFUSED.has(error.errno)) return connectionLost(error); + if (error.errno === ER_GET_CONNECTION_TIMEOUT) return poolTimedOut(error); + if (typeof error.sqlState !== 'string') return error; + return new SqlQueryError(error.sqlMessage ?? error.message, { + cause: error, + sqlState: CONSTRAINT_SQLSTATES.get(error.errno) ?? error.sqlState, + }); } diff --git a/packages/3-targets/mysql/3-driver/src/core/parameter-values.ts b/packages/3-targets/mysql/3-driver/src/core/parameter-values.ts new file mode 100644 index 000000000000..e2cc76ec1bd6 --- /dev/null +++ b/packages/3-targets/mysql/3-driver/src/core/parameter-values.ts @@ -0,0 +1,58 @@ +import { mysqlError } from '@internal/target-mysql/errors'; + +function unsendable(message: string, paramIndex: number, received: string): Error { + return mysqlError('RUNTIME.ENCODE_FAILED', message, { + meta: { target: 'mysql', paramIndex, received }, + }); +} + +const BIGINT_MIN = -(2n ** 63n); +const BIGINT_MAX = 2n ** 63n - 1n; + +function withinBigint(value: bigint): boolean { + return value >= BIGINT_MIN && value <= BIGINT_MAX; +} + +function receivedKind(value: unknown): string { + if (value instanceof Date) return 'Date'; + if (Array.isArray(value)) return 'array'; + return typeof value; +} + +/** + * The value the `mariadb` package sends for a parameter. An integer number within the signed 64-bit range goes as a BIGINT, because the package sends a number as INT only below 2^31 - 1 and otherwise as DOUBLE, which MySQL refuses in `LIMIT`; any other finite number goes as the DOUBLE it is. A bigint must fit a signed BIGINT: the package crashes the process on one below -2^63, and sends one at or above 2^63 as DECIMAL text, so the parameter's type would depend on its value. A `Uint8Array` goes as a Buffer over the same bytes, because the package sends any other `Uint8Array` as JSON text. A `Date` is refused: the package encodes it in the process's local time zone, and the MySQL codecs send dates as text. + */ +export function bindValue(value: unknown, index: number): unknown { + if (value === null || value === undefined) return null; + if (typeof value === 'number') { + if (!Number.isFinite(value)) { + throw unsendable( + `Parameter ${index + 1} is ${value}, which MySQL cannot store`, + index, + String(value), + ); + } + if (!Number.isInteger(value)) return value; + const integer = BigInt(value); + return withinBigint(integer) ? integer : value; + } + if (typeof value === 'bigint') { + if (withinBigint(value)) return value; + throw unsendable( + `Parameter ${index + 1} is an integer outside MySQL's signed 64-bit BIGINT`, + index, + 'bigint', + ); + } + if (Buffer.isBuffer(value)) return value; + if (value instanceof Uint8Array) { + return Buffer.from(value.buffer, value.byteOffset, value.byteLength); + } + if (typeof value === 'string' || typeof value === 'boolean') return value; + const received = receivedKind(value); + throw unsendable( + `Parameter ${index + 1} is of type ${received}; a MySQL parameter must be text, a number, a bigint, a boolean, bytes or null, and a Date is sent as text by its codec`, + index, + received, + ); +} diff --git a/packages/3-targets/mysql/3-driver/src/core/prepared-count.ts b/packages/3-targets/mysql/3-driver/src/core/placeholder-count.ts similarity index 56% rename from packages/3-targets/mysql/3-driver/src/core/prepared-count.ts rename to packages/3-targets/mysql/3-driver/src/core/placeholder-count.ts index c89b45bf6e6f..dbe73c5dbb14 100644 --- a/packages/3-targets/mysql/3-driver/src/core/prepared-count.ts +++ b/packages/3-targets/mysql/3-driver/src/core/placeholder-count.ts @@ -1,26 +1,16 @@ import { structuredError } from '@internal/utils/structured-error'; -function hasParameters(value: object): value is { readonly parameters: readonly unknown[] } { - return 'parameters' in value && Array.isArray(value.parameters); -} - -/** The number of placeholders MySQL reported when it prepared a statement. mysql2 carries them as `parameters` on the statement, which the promise API wraps as `statement`; its typings leave both out. */ +/** The number of placeholders MySQL reported when it prepared a statement. The `mariadb` package keeps it as `parameterCount` on the prepared statement; its typings leave it out. */ function preparedParameterCount(prepared: object): number { - if (hasParameters(prepared)) return prepared.parameters.length; - if ( - 'statement' in prepared && - typeof prepared.statement === 'object' && - prepared.statement !== null - ) { - return hasParameters(prepared.statement) ? prepared.statement.parameters.length : 0; - } - return 0; + return 'parameterCount' in prepared && typeof prepared.parameterCount === 'number' + ? prepared.parameterCount + : 0; } /** - * Refuses a prepared statement for which MySQL reads a different number of placeholders than there are values. The driver's lexer and MySQL agree on the driver's session settings; a session a statement changed, such as one that turned on `NO_BACKSLASH_ESCAPES`, can make them disagree, and mysql2 never compares the count itself: too few values fail with a malformed packet, too many bind garbage. + * Refuses a prepared statement for which MySQL reads a different number of placeholders than there are values. The driver's lexer and MySQL agree on the driver's session settings; a session a statement changed, such as one that turned on `NO_BACKSLASH_ESCAPES`, can make them disagree. The package refuses too few values, but binds too many to the placeholders it has, in order, so a value would land in the wrong place. */ -export function assertPreparedCount(prepared: object, values: number): void { +export function assertPlaceholderCount(prepared: object, values: number): void { const count = preparedParameterCount(prepared); if (count === values) return; throw structuredError( diff --git a/packages/3-targets/mysql/3-driver/src/core/positional-params.ts b/packages/3-targets/mysql/3-driver/src/core/positional-params.ts index 20e24034aab5..f615a486cc1f 100644 --- a/packages/3-targets/mysql/3-driver/src/core/positional-params.ts +++ b/packages/3-targets/mysql/3-driver/src/core/positional-params.ts @@ -1,5 +1,6 @@ import { type MysqlPlaceholderToken, tokenizeMysqlSql } from '@internal/target-mysql/sql-utils'; import { structuredError } from '@internal/utils/structured-error'; +import { bindValue } from './parameter-values'; function placeholderMismatch(placeholders: readonly string[], values: number): Error { return structuredError( @@ -30,34 +31,6 @@ export function valueIndexes( return indexes; } -function unsupportedValue(message: string, meta: Record): Error { - return structuredError('RUNTIME.AST_INVALID', message, { meta: { target: 'mysql', ...meta } }); -} - -/** The value mysql2 sends for a parameter: a safe integer as a BigInt, which MySQL reads as an integer where it needs one, as in `LIMIT`, where mysql2 would send a number as a double; bytes as a Buffer. */ -function bindValue(value: unknown, index: number): unknown { - if (value === null || value === undefined) return null; - if (typeof value === 'number') { - if (!Number.isFinite(value)) { - throw unsupportedValue(`MySQL has no parameter value for ${value}`, { index }); - } - return Number.isSafeInteger(value) ? BigInt(value) : value; - } - if (value instanceof Uint8Array) return Buffer.from(value); - if ( - typeof value === 'string' || - typeof value === 'bigint' || - typeof value === 'boolean' || - value instanceof Date - ) { - return value; - } - throw unsupportedValue( - 'A MySQL parameter must be text, a number, a bigint, a boolean, a Date, bytes or null', - { index, valueType: Array.isArray(value) ? 'array' : typeof value }, - ); -} - /** * The statement with each placeholder written as MySQL's positional `?`, and its values in that order. The adapter writes numbered `?N` placeholders, a value written as often as its `?N` appears; the control plane writes bare `?`s, which take the values in order. `tokenizeMysqlSql` reads the statement as MySQL's lexer does, so a `?` inside a quoted string, a backtick identifier or a comment stays text. Refuses a statement that mixes the two forms, or whose placeholders and values do not match. */ diff --git a/packages/3-targets/mysql/3-driver/src/core/runtime/mysql-driver.ts b/packages/3-targets/mysql/3-driver/src/core/runtime/mysql-driver.ts index 098baa960978..eb465488ddaf 100644 --- a/packages/3-targets/mysql/3-driver/src/core/runtime/mysql-driver.ts +++ b/packages/3-targets/mysql/3-driver/src/core/runtime/mysql-driver.ts @@ -1,4 +1,8 @@ +import { EventEmitter } from 'node:events'; +import type { Readable } from 'node:stream'; +import { finished } from 'node:stream/promises'; import { errorRuntime } from '@internal/errors/execution'; +import type { SessionSettings } from '@internal/framework-components/components'; import type { RuntimeDriverInstance } from '@internal/framework-components/execution'; import type { SqlConnection, @@ -10,296 +14,397 @@ import type { SqlTransaction, } from '@internal/sql-relational-core/ast'; import { blindCast } from '@internal/utils/casts'; -import { ifDefined } from '@internal/utils/defined'; -import { createPool, type Pool, type PoolConnection, type ResultSetHeader } from 'mysql2'; -import { mysqlConnectionOptions, mysqlPoolSettings } from '../connection-options'; -import { connectionLost, isFatal, normalizeMysqlError, statementError } from '../normalize-error'; +import { createPool, type Pool, type PoolConnection } from 'mariadb'; +import { type MysqlPoolOptions, mysqlPoolOptions } from '../connection-options'; +import { connectingCannotSucceed, isFatal, normalizeMysqlError } from '../normalize-error'; +import { assertPlaceholderCount } from '../placeholder-count'; import { positionalParams } from '../positional-params'; -import { assertPreparedCount } from '../prepared-count'; -import { SESSION_SETUP } from '../session-setup'; +import { SessionSettingsGuard } from '../session-settings'; /** Where the runtime driver connects: a `mysql://` URL. */ export type MysqlBinding = { readonly kind: 'url'; readonly url: string }; export type MysqlRuntimeDriver = RuntimeDriverInstance<'sql', 'mysql'> & SqlDriver; +export interface MysqlDriverCreateOptions { + /** Stream each query's rows as they arrive, instead of reading the whole result before the first row, which is the default, as on Postgres without a cursor. */ + readonly stream?: boolean | undefined; + readonly poolOptions?: MysqlPoolOptions | undefined; + /** The session settings the stack's target declares, which every connection starts with. */ + readonly sessionSettings?: SessionSettings | undefined; +} + function notConnected(message: string) { return errorRuntime('DRIVER.NOT_CONNECTED', message, { fix: 'Call connect(binding) before running queries, and do not use the driver after close().', }); } -/** Prepares `sql`, which mysql2 caches for the `execute()` that follows, and checks MySQL's placeholder count against the values. */ -function prepareChecked( - connection: PoolConnection, - sql: string, - values: readonly unknown[], -): Promise { - return new Promise((resolve, reject) => { - connection.prepare(sql, (error, statement) => { - if (error) return reject(statementError(error, sql)); - try { - assertPreparedCount(statement, values.length); - resolve(); - } catch (mismatch) { - reject(mismatch); - } - }); - }); -} +class AsyncMutex { + #queue = Promise.resolve(); -/** Runs a statement as a server-side prepared statement, its values sent apart from the SQL. */ -async function run(connection: PoolConnection, request: SqlExecuteRequest): Promise { - const { sql, values } = positionalParams(request.sql, request.params ?? []); - await prepareChecked(connection, sql, values); - return new Promise((resolve, reject) => { - connection.execute({ sql, values }, (error, result) => { - if (error) return reject(statementError(error, sql)); - resolve(result); + async lock(): Promise<() => void> { + const previous = this.#queue; + let releaseLock: (() => void) | undefined; + this.#queue = new Promise((resolve) => { + releaseLock = resolve; }); - }); -} - -/** Runs SQL the driver writes itself, with no values, through the text protocol: the session setup and `START TRANSACTION`, which MySQL does not prepare. */ -function runConstant(connection: PoolConnection, sql: string): Promise { - return new Promise((resolve, reject) => { - connection.query(sql, (error) => (error ? reject(normalizeMysqlError(error)) : resolve())); - }); + await previous; + return () => { + releaseLock?.(); + releaseLock = undefined; + }; + } } -function isResultSetHeader(result: unknown): result is ResultSetHeader { - return typeof result === 'object' && result !== null && 'affectedRows' in result; -} +/** + * One statement at a time on each client, in the order sent, as the Postgres driver's per-client lock runs them. The lock is held until the statement settles, or for a stream's whole life. The package queues a prepared statement's execution only after MySQL answers the prepare, so without the lock a commit sent meanwhile would run first. + */ +const clientQueryLocks = new WeakMap(); -async function setUpSession(connection: PoolConnection): Promise { - for (const sql of SESSION_SETUP) await runConstant(connection, sql); +function acquireClientQueryLock(client: object): Promise<() => void> { + let mutex = clientQueryLocks.get(client); + if (mutex === undefined) { + mutex = new AsyncMutex(); + clientQueryLocks.set(client, mutex); + } + return mutex.lock(); } -/** Counts what the driver has handed out, a connection or a stream, so `close()` can wait until every one is back. */ -class Leases { - #count = 0; - #idle: (() => void)[] = []; - - take(): () => void { - this.#count += 1; - let returned = false; - return () => { - if (returned) return; - returned = true; - this.#count -= 1; - if (this.#count === 0) for (const resolve of this.#idle.splice(0)) resolve(); - }; +/** + * Runs `request` on `client` and returns its result. A statement with values runs as a server-side prepared statement, its `?N` placeholders written as `?`, after checking MySQL's placeholder count against the values. A statement without values runs over the text protocol, as `pg` runs one: nothing is written into it, so a raw `START TRANSACTION` or `LOCK TABLES`, which MySQL does not prepare, runs too. Throws the package's error as it is. + */ +async function run(client: PoolConnection, request: SqlExecuteRequest): Promise { + const { sql, values } = positionalParams(request.sql, request.params ?? []); + if (values.length === 0) return client.query(sql); + const statement = await client.prepare(sql); + try { + assertPlaceholderCount(statement, values.length); + return await statement.execute(values); + } finally { + statement.close(); } +} - idle(): Promise { - return this.#count === 0 - ? Promise.resolve() - : new Promise((resolve) => this.#idle.push(resolve)); +/** Starts streaming the rows of `request` on `client`, prepared when it has values and over the text protocol when it has none, as {@link run} runs it. `close` closes the prepared statement, if any. Throws the package's error as it is. */ +async function startStream( + client: PoolConnection, + request: SqlExecuteRequest, +): Promise<{ readonly rows: Readable; readonly close: () => void }> { + const { sql, values } = positionalParams(request.sql, request.params ?? []); + if (values.length === 0) return { rows: client.queryStream(sql), close: () => {} }; + const statement = await client.prepare(sql); + try { + assertPlaceholderCount(statement, values.length); + return { rows: statement.executeStream(values), close: () => statement.close() }; + } catch (error) { + statement.close(); + throw error; } } -/** A pooled connection lent to one caller. `done` gives it back to the pool, or destroys it when it may be broken. */ -interface Lent { - readonly connection: PoolConnection; - /** What to do when the caller stops reading a stream early: evict a connection the pool lent for this one statement, or read and drop the rest of the result on a connection the caller holds, whose session must survive. */ - readonly onEarlyExit: 'evict' | 'drain'; - done(evict: boolean): void; +function affectedRows(result: unknown): number { + return typeof result === 'object' && + result !== null && + 'affectedRows' in result && + typeof result.affectedRows === 'number' + ? result.affectedRows + : 0; } -/** What a stream reports back to whoever lent its connection. */ -interface StreamOutcome { - evict: boolean; +/** What a statement reports back to whoever released its client: whether the client may be broken and must be destroyed. */ +interface Outcome { + destroy: boolean; } -interface StreamOptions { - readonly onEarlyExit: Lent['onEarlyExit']; - readonly outcome: StreamOutcome; +/** The error to report for `error`, after noting in `outcome` whether it left the client unusable. */ +function settle(error: unknown, outcome: Outcome): unknown { + if (isFatal(error)) outcome.destroy = true; + return normalizeMysqlError(error); } /** - * Streams the rows of `request` on `connection`, run as a prepared statement. When the caller stops reading early, the connection is evicted, or on a held connection it reads and drops the rest of the result so its session stays usable. mysql2 tells a streaming query nothing when its connection is lost, so the stream listens to the connection and ends with `DRIVER.NOT_CONNECTED`. A lost connection, or a fatal error, sets `outcome.evict`. + * Whether the package has received the end of the result, so that any rows the caller has not read are already in the stream's buffer. The package pushes the end of the stream when MySQL's last packet arrives; Node records that in `_readableState.ended`, and its public API reports the end only after the caller has read every row. */ -async function* streamRows( - connection: PoolConnection, - request: SqlExecuteRequest, - { onEarlyExit, outcome }: StreamOptions, -): AsyncGenerator { - const { sql, values } = positionalParams(request.sql, request.params ?? []); - await prepareChecked(connection, sql, values); - const command = connection.execute({ sql, values }); - let settle: () => void = () => {}; - const finished = new Promise((resolve) => { - settle = resolve; - command.once('end', () => resolve()); - }); - const stream = command.stream(); - const onLost = (cause?: unknown) => { - outcome.evict = true; - stream.destroy(connectionLost(cause)); - settle(); - }; - connection.once('end', onLost); - connection.once('error', onLost); - let completed = false; - let failed = false; - try { - for await (const row of stream) { - yield blindCast( - row, - ); - } - completed = true; - } catch (error) { - failed = true; - if (isFatal(error)) outcome.evict = true; - throw statementError(error, sql); - } finally { - const stoppedEarly = !completed && !failed && !outcome.evict; - if (stoppedEarly && onEarlyExit === 'evict') { - outcome.evict = true; - stream.destroy(); - settle(); - } else if (stoppedEarly) { - connection.resume(); - } - if (!failed) await finished; - connection.off('end', onLost); - connection.off('error', onLost); - } +function resultReceived(rows: Readable): boolean { + const state: unknown = Reflect.get(rows, '_readableState'); + return typeof state === 'object' && state !== null && 'ended' in state && state.ended === true; } +async function dropRest(rows: Readable & { close?: () => void }): Promise { + rows.close?.(); + rows.resume(); + await finished(rows); +} + +/** The package can report an error on a stream after the caller stopped reading it, such as the one for a connection destroyed mid-result; the generator has already settled by then. */ +function ignoreAfterStop(): void {} + abstract class MysqlQueryable implements SqlQueryable { - protected abstract lend(): Promise; + protected abstract acquireClient(): Promise; + protected abstract releaseClient(client: PoolConnection, outcome: Outcome): Promise; + /** Whether the caller holds the client, so its session must survive a stream the caller stops early. */ + protected abstract readonly holdsClient: boolean; + + constructor(protected readonly streams: boolean) {} async *query>(request: SqlExecuteRequest): AsyncIterable { - const lent = await this.lend(); - const outcome: StreamOutcome = { evict: false }; - try { - yield* streamRows(lent.connection, request, { - onEarlyExit: lent.onEarlyExit, - outcome, - }); - } finally { - lent.done(outcome.evict); + if (this.streams) { + yield* this.#streamRows(request); + return; + } + const result = await this.#runReleasing(request); + for (const row of Array.isArray(result) ? result : []) { + yield blindCast< + Row, + 'the package returns the rows the statement produces, shaped by the query' + >(row); } } async execute(request: SqlExecuteRequest): Promise { - const lent = await this.lend(); - let evict = false; + return { affectedRows: affectedRows(await this.#runReleasing(request)) }; + } + + async #runReleasing(request: SqlExecuteRequest): Promise { + const client = await this.acquireClient(); + const outcome: Outcome = { destroy: false }; try { - const result = await run(lent.connection, request); - return { affectedRows: isResultSetHeader(result) ? result.affectedRows : 0 }; + const releaseLock = await acquireClientQueryLock(client); + try { + return await run(client, request); + } finally { + releaseLock(); + } } catch (error) { - evict = isFatal(error instanceof Error && error.cause !== undefined ? error.cause : error); - throw error; + throw settle(error, outcome); } finally { - lent.done(evict); + await this.releaseClient(client, outcome); } } + + /** + * Streams the rows of `request` as they arrive; the package pauses the socket while the caller is busy. A caller that stops reading early is handled by {@link MysqlQueryable.#stopEarly}. A fatal error, such as a lost connection, destroys the client. + */ + async *#streamRows(request: SqlExecuteRequest): AsyncGenerator { + const client = await this.acquireClient(); + const outcome: Outcome = { destroy: false }; + const releaseLock = await acquireClientQueryLock(client); + try { + const stream = await startStream(client, request).catch((error: unknown) => { + throw settle(error, outcome); + }); + stream.rows.on('error', ignoreAfterStop); + let settled = false; + try { + for await (const row of stream.rows.iterator({ destroyOnReturn: false })) { + yield blindCast< + Row, + 'the package streams the rows the statement produces, shaped by the query' + >(row); + } + settled = true; + } catch (error) { + settled = true; + throw settle(error, outcome); + } finally { + if (!settled) await this.#stopEarly(stream.rows, outcome); + if (outcome.destroy) stream.rows.destroy(); + else stream.close(); + } + } finally { + releaseLock(); + await this.releaseClient(client, outcome); + } + } + + /** + * Ends a stream the caller stopped reading early. Once the whole result has arrived, or on a held client whose session must survive, the rest is read and dropped and the client stays usable. A pooled client whose result is still arriving is destroyed instead, so a large result is not downloaded only to be thrown away; MySQL does not say how many rows remain. Destroying it mid-result makes the package open one short-lived extra connection to send `KILL`, because MySQL cannot cancel a statement on its own connection. + */ + async #stopEarly(rows: Readable, outcome: Outcome): Promise { + if (!this.holdsClient && !resultReceived(rows)) { + outcome.destroy = true; + return; + } + await dropRest(rows).catch((error: unknown) => { + if (isFatal(error)) outcome.destroy = true; + }); + } } -function lendHeld(connection: PoolConnection): Lent { - return { connection, onEarlyExit: 'drain', done: () => {} }; +/** The state of a held connection, shared by the connection and its transactions as the Postgres driver shares its transaction state. */ +interface HeldState { + /** How many statements are running or waiting for the client's lock; a stream the caller dropped without finishing it stays running. */ + running: number; } -/** A connection held by one caller, whose statements all run on the same session. */ -class MysqlConnectionImpl extends MysqlQueryable implements SqlConnection { - readonly #connection: PoolConnection; - readonly #giveBack: () => void; +/** + * A queryable on a connection the caller holds, whose statements all run on the same session, in the order they are sent, under the client's lock. A statement sent while a stream on the connection is open waits for that stream to end; one awaited inside the stream's own loop never finishes, as on Postgres with a cursor. + */ +abstract class MysqlHeldQueryable extends MysqlQueryable { + protected readonly holdsClient = true; + + constructor( + protected readonly client: PoolConnection, + protected readonly held: HeldState, + streams: boolean, + ) { + super(streams); + } + + protected async acquireClient(): Promise { + this.held.running += 1; + return this.client; + } + + protected async releaseClient(): Promise { + this.held.running -= 1; + } - constructor(connection: PoolConnection, giveBack: () => void) { - super(); - this.#connection = connection; - this.#giveBack = giveBack; + /** Runs one of the package's transaction calls under the client's lock, taken as a statement takes it, so it keeps its place among the statements sent. */ + protected async underLock(call: () => Promise): Promise { + const client = await this.acquireClient(); + try { + const releaseLock = await acquireClientQueryLock(client); + try { + await call(); + } finally { + releaseLock(); + } + } catch (error) { + throw normalizeMysqlError(error); + } finally { + await this.releaseClient(); + } } +} + +class MysqlConnectionImpl extends MysqlHeldQueryable implements SqlConnection { + #onRelease: (() => void) | undefined; - protected async lend(): Promise { - return lendHeld(this.#connection); + constructor(client: PoolConnection, streams: boolean, onRelease: () => void) { + super(client, { running: 0 }, streams); + this.#onRelease = onRelease; + } + + #released(): void { + const onRelease = this.#onRelease; + this.#onRelease = undefined; + onRelease?.(); } async beginTransaction(): Promise { - await runConstant(this.#connection, 'START TRANSACTION'); - return new MysqlTransactionImpl(this.#connection); + await this.underLock(() => this.client.beginTransaction()); + return new MysqlTransactionImpl(this.client, this.held, this.streams); } + /** + * Returns the connection to the pool, as the Postgres driver's `release()` does. A connection with a statement still running, such as a stream the caller dropped, is destroyed instead: the package's release sends `ROLLBACK` behind that statement and waits for it, which would never finish, where `pg`'s release does not wait on the server. + */ async release(): Promise { - this.#connection.release(); - this.#giveBack(); + if (this.held.running > 0) return this.destroy(); + try { + await this.client.release(); + } finally { + this.#released(); + } } async destroy(_reason?: unknown): Promise { - this.#connection.destroy(); - this.#giveBack(); + this.client.destroy(); + this.#released(); } } -class MysqlTransactionImpl extends MysqlQueryable implements SqlTransaction { - readonly #connection: PoolConnection; +class MysqlTransactionImpl extends MysqlHeldQueryable implements SqlTransaction { + async commit(): Promise { + await this.underLock(() => this.client.commit()); + } - constructor(connection: PoolConnection) { - super(); - this.#connection = connection; + async rollback(): Promise { + await this.underLock(() => this.client.rollback()); } +} - protected async lend(): Promise { - return lendHeld(this.#connection); +/** How many clients the driver has handed out and not had back, a held connection or one lent to a single statement, so `close()` can wait until every one is back. */ +class ClientsInUse { + #count = 0; + #idle: (() => void)[] = []; + + acquired(): void { + this.#count += 1; } - async commit(): Promise { - await this.execute({ sql: 'COMMIT' }); + released(): void { + this.#count -= 1; + if (this.#count === 0) for (const resolve of this.#idle.splice(0)) resolve(); } - async rollback(): Promise { - await this.execute({ sql: 'ROLLBACK' }); + none(): Promise { + return this.#count === 0 + ? Promise.resolve() + : new Promise((resolve) => this.#idle.push(resolve)); } } -/** A connection from `pool`, or `DRIVER.CONNECTION_FAILED` once `timeoutMs` passes without one; a connection that arrives later goes back to the pool. */ -function waitForConnection(pool: Pool, timeoutMs: number): Promise { - return new Promise((resolve, reject) => { - let waiting = true; - const timer = setTimeout(() => { - waiting = false; - reject( - errorRuntime( - 'DRIVER.CONNECTION_FAILED', - 'Timed out waiting for a pooled MySQL connection', - { - why: `No connection was free within ${timeoutMs} ms; every connection in the pool was in use.`, - fix: 'Release held connections sooner, or raise connectionLimit or poolTimeout in the URL.', - meta: { poolTimeoutMs: timeoutMs }, - }, - ), - ); - }, timeoutMs); - pool.getConnection((error, acquired) => { - clearTimeout(timer); - if (!waiting) { - if (!error) acquired.release(); - return; - } - if (error) reject(normalizeMysqlError(error)); - else resolve(acquired); +/** The pool reports a connection it failed to open on its `'error'` event, and again as the cause of a `getConnection` that times out; the driver reports it there. */ +function ignoreConnectionFailure(): void {} + +/** + * The statements waiting for a connection from the pool. The pool keeps retrying a connection it cannot open, with a growing pause between attempts, and fails a waiting `getConnection` only when `connectionTimeoutMillis` ends; the package reports each failure to its `logger.error` as it happens, together with every failed statement. `pg.Pool` instead rejects a pending connect whose connection fails, at once. So the driver fails the waiting statements at once with a failure no retry can get past. It keeps no failure: a later statement waits for the pool's next attempt. + */ +class WaitingForConnection { + readonly #fail = new Set<(error: unknown) => void>(); + + async race(getting: Promise): Promise { + let fail: (error: unknown) => void = () => {}; + const failed = new Promise((_, reject) => { + fail = reject; }); - }); + this.#fail.add(fail); + try { + return await Promise.race([getting, failed]); + } finally { + this.#fail.delete(fail); + } + } + + /** Reacts to an error the package reports: only a failure to connect, which the package marks fatal, that no retry can get past. A statement can fail with the same error number, such as 1524 for a missing authentication plugin, but not fatally. */ + reported(error: unknown): void { + if (!isFatal(error) || !connectingCannotSucceed(error)) return; + for (const fail of this.#fail) fail(error); + this.#fail.clear(); + } +} + +/** Gives a connection the pool hands over after its statement stopped waiting back to the pool. */ +function releaseLate(getting: Promise): void { + getting.then((late) => late.release(), ignoreConnectionFailure).catch(ignoreConnectionFailure); } type DriverState = | { readonly kind: 'unbound' } - | { readonly kind: 'connected'; readonly pool: Pool; readonly poolTimeoutMs: number } + | { readonly kind: 'connected'; readonly pool: Pool; readonly waiting: WaitingForConnection } | { readonly kind: 'closed' }; /** - * The MySQL runtime driver: a mysql2 pool that runs every statement as a server-side prepared statement, so a value never travels inside the SQL. Each connection keeps a bounded cache of prepared statements (`statementCacheSize`) and closes the ones it drops. Each physical connection runs the session setup once, before its first statement, and goes back to the pool after each statement; a broken connection is destroyed. `close()` waits for every connection and stream it has handed out before it ends the pool. + * The MySQL runtime driver: a `mariadb` pool. A statement with values runs as a server-side prepared statement, so a value never travels inside the SQL. A query reads its whole result before the first row unless `stream` is set. The pool runs the session setup once on each physical connection, and the driver then sets the target's session settings on it with a prepared statement; a connection goes back to the pool after each statement as it is; a broken connection is destroyed. `close()` waits for every connection and stream it has handed out before it ends the pool. */ export class MysqlDriver extends MysqlQueryable implements MysqlRuntimeDriver { readonly familyId = 'sql' as const; readonly targetId = 'mysql' as const; + protected readonly holdsClient = false; #state: DriverState = { kind: 'unbound' }; - readonly #leases = new Leases(); - readonly #setUp = new WeakSet(); + readonly #inUse = new ClientsInUse(); + readonly #options: MysqlDriverCreateOptions; + readonly #sessionSettings: SessionSettingsGuard; + + constructor(options: MysqlDriverCreateOptions = {}) { + super(options.stream ?? false); + this.#options = options; + this.#sessionSettings = new SessionSettingsGuard(options.sessionSettings ?? {}); + } get state(): SqlDriverState { return this.#state.kind; @@ -311,67 +416,61 @@ export class MysqlDriver extends MysqlQueryable implements MysqlRuntimeDriver { fix: 'Call close() before connecting again.', }); } - const { connectionLimit, poolTimeoutMs, statementCacheSize } = mysqlPoolSettings(binding.url); + const waiting = new WaitingForConnection(); const pool = createPool({ - ...mysqlConnectionOptions(binding.url), - maxPreparedStatements: statementCacheSize, - ...ifDefined('connectionLimit', connectionLimit), + ...mysqlPoolOptions(binding.url, this.#options.poolOptions), + logger: { error: (error) => waiting.reported(error) }, }); - this.#state = { kind: 'connected', pool, poolTimeoutMs }; + if (pool instanceof EventEmitter) pool.on('error', ignoreConnectionFailure); + this.#state = { kind: 'connected', pool, waiting }; } async acquireConnection(): Promise { - const giveBack = this.#leases.take(); - try { - return new MysqlConnectionImpl(await this.#acquire(), giveBack); - } catch (error) { - giveBack(); - throw error; - } - } - - protected async lend(): Promise { - const giveBack = this.#leases.take(); - try { - const connection = await this.#acquire(); - return { - connection, - onEarlyExit: 'evict', - done: (evict) => { - if (evict) connection.destroy(); - else connection.release(); - giveBack(); - }, - }; - } catch (error) { - giveBack(); - throw error; - } + const client = await this.acquireClient(); + return new MysqlConnectionImpl(client, this.streams, () => this.#inUse.released()); } async close(): Promise { if (this.#state.kind !== 'connected') return; const { pool } = this.#state; this.#state = { kind: 'closed' }; - await this.#leases.idle(); - await new Promise((resolve) => pool.end(() => resolve())); + await this.#inUse.none(); + await pool.end(); } - async #acquire(): Promise { + protected async acquireClient(): Promise { if (this.#state.kind !== 'connected') { throw notConnected( this.#state.kind === 'closed' ? 'MySQL driver is closed' : 'MySQL driver not connected', ); } - const connection = await waitForConnection(this.#state.pool, this.#state.poolTimeoutMs); - if (this.#setUp.has(connection)) return connection; + const { pool, waiting } = this.#state; + this.#inUse.acquired(); + const getting = pool.getConnection(); + let client: PoolConnection; try { - await setUpSession(connection); + client = await waiting.race(getting); } catch (error) { - connection.destroy(); + releaseLate(getting); + this.#inUse.released(); throw normalizeMysqlError(error); } - this.#setUp.add(connection); - return connection; + try { + await this.#sessionSettings.ensure(client); + } catch (error) { + client.destroy(); + this.#inUse.released(); + throw normalizeMysqlError(error); + } + return client; + } + + protected async releaseClient(client: PoolConnection, { destroy }: Outcome): Promise { + try { + if (destroy) client.destroy(); + else await client.release(); + } finally { + this.#inUse.released(); + } } } diff --git a/packages/3-targets/mysql/3-driver/src/core/runtime/runtime-driver.ts b/packages/3-targets/mysql/3-driver/src/core/runtime/runtime-driver.ts index ea755f59979e..823195b0a4b2 100644 --- a/packages/3-targets/mysql/3-driver/src/core/runtime/runtime-driver.ts +++ b/packages/3-targets/mysql/3-driver/src/core/runtime/runtime-driver.ts @@ -1,16 +1,25 @@ +import type { SessionSettings } from '@internal/framework-components/components'; import type { RuntimeDriverDescriptor } from '@internal/framework-components/execution'; +import { ifDefined } from '@internal/utils/defined'; import { mysqlDriverDescriptorMeta } from '../descriptor-meta'; -import { MysqlDriver, type MysqlRuntimeDriver } from './mysql-driver'; +import { + MysqlDriver, + type MysqlDriverCreateOptions, + type MysqlRuntimeDriver, +} from './mysql-driver'; const mysqlRuntimeDriverDescriptor: RuntimeDriverDescriptor< 'sql', 'mysql', - void, + MysqlDriverCreateOptions, MysqlRuntimeDriver > = { ...mysqlDriverDescriptorMeta, - create(): MysqlRuntimeDriver { - return new MysqlDriver(); + create( + options?: MysqlDriverCreateOptions, + sessionSettings?: SessionSettings, + ): MysqlRuntimeDriver { + return new MysqlDriver({ ...options, ...ifDefined('sessionSettings', sessionSettings) }); }, }; diff --git a/packages/3-targets/mysql/3-driver/src/core/server-text.ts b/packages/3-targets/mysql/3-driver/src/core/server-text.ts deleted file mode 100644 index ed21497757b6..000000000000 --- a/packages/3-targets/mysql/3-driver/src/core/server-text.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { TypeCast } from 'mysql2'; - -/** The column types whose value may be bytes: a binary column of one of them reads as a Buffer, any other as text in the column's character set. */ -const BYTES_OR_TEXT = new Set([ - 'TINY_BLOB', - 'MEDIUM_BLOB', - 'LONG_BLOB', - 'BLOB', - 'STRING', - 'VAR_STRING', -]); - -/** The column types MySQL sends as bytes whatever their character set. */ -const BYTES = new Set(['BIT', 'GEOMETRY']); - -/** - * Hands codecs the text MySQL sends for each column (ADR 155). A JSON column is read as UTF-8, because MySQL reports its character set as binary; a binary string or blob column as its bytes; every other column, numbers and dates included, as text, never as a JavaScript number or `Date`. - */ -export const serverText: TypeCast = (field, next) => { - if (field.type === 'JSON') return field.string('utf8'); - if (BYTES.has(field.type)) return field.buffer(); - if (BYTES_OR_TEXT.has(field.type)) return next(); - return field.string('utf8'); -}; diff --git a/packages/3-targets/mysql/3-driver/src/core/session-settings.ts b/packages/3-targets/mysql/3-driver/src/core/session-settings.ts new file mode 100644 index 000000000000..397582a37a03 --- /dev/null +++ b/packages/3-targets/mysql/3-driver/src/core/session-settings.ts @@ -0,0 +1,61 @@ +import type { SessionSettings } from '@internal/framework-components/components'; +import { quoteIdentifier } from '@internal/target-mysql/sql-utils'; +import { assertDefined } from '@internal/utils/assertions'; +import type { Connection } from 'mariadb'; + +const DECIMAL_INTEGER = /^-?\d+$/; + +/** A setting's value as MySQL takes it: decimal integer text as a whole number, because MySQL refuses text for an integer variable, and any other text as it is. */ +function settingValue(value: string): string | bigint { + return DECIMAL_INTEGER.test(value) ? BigInt(value) : value; +} + +export interface SessionSettingsStatement { + readonly sql: string; + readonly values: readonly (string | bigint)[]; +} + +/** The one statement that sets `sessionSettings`: each name a quoted identifier, each value a parameter. Undefined when there is nothing to set. */ +export function sessionSettingsStatement( + sessionSettings: SessionSettings, +): SessionSettingsStatement | undefined { + const entries = Object.entries(sessionSettings); + if (entries.length === 0) return undefined; + return { + sql: `SET SESSION ${entries.map(([name]) => `${quoteIdentifier(name)} = ?`).join(', ')}`, + values: entries.map(([, value]) => settingValue(value)), + }; +} + +/** Sets the session settings on `connection` with a prepared statement, so no value travels inside SQL text. Throws the package's error as it is. */ +export async function applySessionSettings( + connection: Connection, + statement: SessionSettingsStatement, +): Promise { + const prepared = await connection.prepare(statement.sql); + try { + await prepared.execute([...statement.values]); + } finally { + prepared.close(); + } +} + +/** + * Ensures the session settings a target declares on each physical connection the runtime driver gets from its pool, once, as the Postgres driver's `SessionSettingsGuard` does for a pool it did not start the sessions of. The package hands out a new wrapper for each `getConnection`, so a connection is known by its `info`, which belongs to the physical connection. + */ +export class SessionSettingsGuard { + readonly #statement: SessionSettingsStatement | undefined; + readonly #ensured = new WeakSet(); + + constructor(sessionSettings: SessionSettings) { + this.#statement = sessionSettingsStatement(sessionSettings); + } + + async ensure(connection: Connection): Promise { + const key = connection.info; + assertDefined(key, 'the package gives every connection its info when it creates it'); + if (this.#statement === undefined || this.#ensured.has(key)) return; + await applySessionSettings(connection, this.#statement); + this.#ensured.add(key); + } +} diff --git a/packages/3-targets/mysql/3-driver/src/core/session-setup.ts b/packages/3-targets/mysql/3-driver/src/core/session-setup.ts index ab0d5555a49f..52fbec680dbf 100644 --- a/packages/3-targets/mysql/3-driver/src/core/session-setup.ts +++ b/packages/3-targets/mysql/3-driver/src/core/session-setup.ts @@ -1,8 +1,7 @@ /** - * The statements every new connection runs once, before any other. The lexer that finds placeholders reads a backslash as an escape and `"…"` as a string, so the session clears NO_BACKSLASH_ESCAPES and ANSI_QUOTES and MySQL reads quoted text the same way. The session runs in UTC, so `CURRENT_TIMESTAMP` writes the UTC time the codecs read, and in utf8mb4. + * The statements every new connection runs once, before any other. The lexer that finds placeholders reads a backslash as an escape and `"…"` as a string, so the session clears NO_BACKSLASH_ESCAPES and ANSI_QUOTES and MySQL reads quoted text the same way; and it reads each statement as utf8mb4. These hold no value, so they run as text through the package's `initSql`; the target's session settings, which do, are set afterwards with a prepared statement. */ export const SESSION_SETUP = [ "SET SESSION sql_mode = TRIM(BOTH ',' FROM REPLACE(REPLACE(CONCAT(',', @@SESSION.sql_mode, ','), ',NO_BACKSLASH_ESCAPES,', ','), ',ANSI_QUOTES,', ','))", - "SET SESSION time_zone = '+00:00'", 'SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci', ] as const; diff --git a/packages/3-targets/mysql/3-driver/src/core/text-or-bytes.ts b/packages/3-targets/mysql/3-driver/src/core/text-or-bytes.ts new file mode 100644 index 000000000000..5028df1c02d2 --- /dev/null +++ b/packages/3-targets/mysql/3-driver/src/core/text-or-bytes.ts @@ -0,0 +1,94 @@ +import type { FieldInfo, TypeCastFunction } from 'mariadb'; +import { mysqlDoubleText, mysqlFixedDecimalsText, mysqlFloatText } from './float-text'; + +/** MySQL's `binary` collation, which marks a string or blob column that holds bytes. */ +const BINARY_COLLATION = 63; + +/** The decimals MySQL reports for a FLOAT or DOUBLE column declared without them. */ +const NOT_FIXED_DECIMALS = 31; + +/** The column flag MySQL sets on a `ZEROFILL` number and on `YEAR`, whose text it pads with zeros to the column's width. */ +const ZEROFILL = 1 << 6; + +/** The column types whose value is text, or bytes when the column's collation is binary. */ +const CHARACTER_OR_BLOB_TYPES: ReadonlySet = new Set([ + 'VARCHAR', + 'VAR_STRING', + 'STRING', + 'TINY_BLOB', + 'MEDIUM_BLOB', + 'LONG_BLOB', + 'BLOB', +]); + +/** The column types besides FLOAT and DOUBLE whose value MySQL sends as text. A type outside this list, such as BIT, GEOMETRY or one the package has no name for, is read as bytes. */ +const TEXT_TYPES: ReadonlySet = new Set([ + ...CHARACTER_OR_BLOB_TYPES, + 'DECIMAL', + 'NEWDECIMAL', + 'TINY', + 'SHORT', + 'INT', + 'INT24', + 'BIGINT', + 'YEAR', + 'DATE', + 'NEWDATE', + 'TIME', + 'TIME2', + 'DATETIME', + 'DATETIME2', + 'TIMESTAMP', + 'TIMESTAMP2', + 'JSON', + 'ENUM', + 'SET', + 'NULL', +]); + +/** The most fraction digits a MySQL time can have; a computed column of unknown precision reports more. */ +const MAX_FRACTION_DIGITS = 6; + +function withFractionDigits(time: string, digits: number): string { + if (digits > MAX_FRACTION_DIGITS) return time; + const [whole = '', fraction = ''] = time.split('.'); + return digits === 0 ? whole : `${whole}.${fraction.padEnd(digits, '0').slice(0, digits)}`; +} + +/** + * The column's text in one form whichever protocol carried it. Over the text protocol MySQL writes it; under a prepared statement MySQL sends numbers, dates and times in binary and the package writes them. They differ for three kinds of column besides FLOAT and DOUBLE, so each gets MySQL's form: YEAR and a ZEROFILL integer padded to the column's width, and a TIME with the column's fraction digits. + */ +function sameTextEitherProtocol(field: FieldInfo, text: string): string { + switch (field.type) { + case 'TIME': + return withFractionDigits(text, field.scale); + default: + return (field.flags & ZEROFILL) === 0 ? text : text.padStart(field.columnLength, '0'); + } +} + +/** + * A FLOAT or DOUBLE as MySQL writes it over the text protocol, with the column's declared decimals if it has them. The package reads the number the server sent, whichever the protocol; the text it writes for a prepared statement's binary number differs from MySQL's in digits, notation and the sign of zero. + */ +function floatingText(field: FieldInfo): string | null { + const value = field.float(); + if (value === null) return null; + if (field.scale < NOT_FIXED_DECIMALS) return mysqlFixedDecimalsText(value, field.scale); + return field.type === 'FLOAT' ? mysqlFloatText(value) : mysqlDoubleText(value); +} + +/** + * Reads each column as the row value ADR 155 sets at the driver boundary: a binary string or blob, BIT, GEOMETRY and a column of a type the package does not know as its bytes, every other column as text, and NULL as `null`. Bytes are a plain `Uint8Array`, not the package's `Buffer`. + */ +export const readTextOrBytes: TypeCastFunction = (field) => { + if (field.type === 'FLOAT' || field.type === 'DOUBLE') return floatingText(field); + if ( + !TEXT_TYPES.has(field.type) || + (CHARACTER_OR_BLOB_TYPES.has(field.type) && field.collation.index === BINARY_COLLATION) + ) { + const bytes = field.buffer(); + return bytes === null ? null : new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength); + } + const text = field.string(); + return text === null ? null : sameTextEitherProtocol(field, text); +}; diff --git a/packages/3-targets/mysql/3-driver/src/exports/runtime.ts b/packages/3-targets/mysql/3-driver/src/exports/runtime.ts index d2b385d4950c..b8c2220a487b 100644 --- a/packages/3-targets/mysql/3-driver/src/exports/runtime.ts +++ b/packages/3-targets/mysql/3-driver/src/exports/runtime.ts @@ -1,2 +1,7 @@ -export type { MysqlBinding, MysqlRuntimeDriver } from '../core/runtime/mysql-driver'; +export type { MysqlPoolOptions } from '../core/connection-options'; +export type { + MysqlBinding, + MysqlDriverCreateOptions, + MysqlRuntimeDriver, +} from '../core/runtime/mysql-driver'; export { default } from '../core/runtime/runtime-driver'; diff --git a/packages/3-targets/mysql/3-driver/test/connection-options.test.ts b/packages/3-targets/mysql/3-driver/test/connection-options.test.ts index 2558caa3f899..a1b935864277 100644 --- a/packages/3-targets/mysql/3-driver/test/connection-options.test.ts +++ b/packages/3-targets/mysql/3-driver/test/connection-options.test.ts @@ -1,38 +1,120 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { inspect } from 'node:util'; import { describe, expect, it } from 'vitest'; -import { mysqlConnectionOptions, mysqlPoolSettings } from '../src/core/connection-options'; +import { mysqlConnectionOptions, mysqlPoolOptions } from '../src/core/connection-options'; +import { SESSION_SETUP } from '../src/core/session-setup'; +import { readTextOrBytes } from '../src/core/text-or-bytes'; describe('mysqlConnectionOptions', () => { - it('reads the server, credentials, database and parameters from the URL', () => { - expect( - mysqlConnectionOptions('mysql://app:p%40ss@db.example:3307/shop?ssl=true'), - ).toMatchObject({ + it('reads the server, credentials and database from the URL', () => { + expect(mysqlConnectionOptions('mysql://app:p%40ss@db.example:3307/shop')).toMatchObject({ host: 'db.example', port: 3307, user: 'app', password: 'p@ss', database: 'shop', - ssl: true, - charset: 'UTF8MB4_UNICODE_CI', }); }); - it('leaves the port to mysql2 when the URL names none', () => { - expect(mysqlConnectionOptions('mysql://root@localhost/app')).not.toHaveProperty('port'); + it('reads TLS settings as pg-connection-string does for Postgres: ssl, sslmode, and sslcert, sslkey and sslrootcert as PEM files', () => { + const dir = mkdtempSync(join(tmpdir(), 'mysql-tls-')); + const file = (name: string) => { + const path = join(dir, name); + writeFileSync(path, `${name} PEM`); + return encodeURIComponent(path); + }; + const ssl = (query: string) => + mysqlConnectionOptions(`mysql://root@localhost/app?${query}`).ssl; + try { + expect({ + none: ssl('allowPublicKeyRetrieval=false'), + on: ssl('ssl=true'), + one: ssl('ssl=1'), + off: ssl('ssl=0'), + verifyFull: ssl('sslmode=verify-full'), + require: ssl('sslmode=require'), + noVerify: ssl('sslmode=no-verify'), + disable: ssl(`sslmode=disable&sslrootcert=${file('ca')}`), + files: ssl(`sslcert=${file('cert')}&sslkey=${file('key')}&sslrootcert=${file('ca')}`), + }).toEqual({ + none: undefined, + on: true, + one: true, + off: false, + verifyFull: {}, + require: {}, + noVerify: { rejectUnauthorized: false }, + disable: false, + files: { cert: 'cert PEM', key: 'key PEM', ca: 'ca PEM' }, + }); + expect(() => ssl('sslkey=/no/such/file.pem')).toThrow( + expect.objectContaining({ + code: 'RUNTIME.BINDING_INVALID', + message: expect.stringContaining('the file sslkey names cannot be read'), + meta: { received: 'sslkey', reason: expect.any(String) }, + }), + ); + } finally { + rmSync(dir, { recursive: true }); + } }); - it('keeps multiple statements off whatever the flags say', () => { - expect( - mysqlConnectionOptions('mysql://root@localhost/app?flags=MULTI_STATEMENTS,-FOUND_ROWS'), - ).toMatchObject({ multipleStatements: false, flags: ['-FOUND_ROWS'] }); - expect( - mysqlConnectionOptions('mysql://root@localhost/app?flags=["multi_statements"]'), - ).toMatchObject({ multipleStatements: false, flags: [] }); + it('reads an IPv6 host without its brackets', () => { + expect(mysqlConnectionOptions('mysql://root@[::1]:3306/app')).toMatchObject({ host: '::1' }); }); - it('always connects as utf8mb4, and refuses a charset in the URL', () => { + it('leaves the port to the package when the URL names none', () => { + expect(mysqlConnectionOptions('mysql://root@localhost/app')).not.toHaveProperty('port'); + }); + + it('sets up each connection with the session statements alone, in utf8mb4, never logs values, never caches statements and counts matched rows', () => { + expect(mysqlConnectionOptions('mysql://root@localhost/app').initSql).toEqual([ + ...SESSION_SETUP, + ]); expect(mysqlConnectionOptions('mysql://root@localhost/app')).toMatchObject({ - charset: 'UTF8MB4_UNICODE_CI', + collation: 'UTF8MB4_UNICODE_CI', + prepareCacheLength: 0, + foundRows: true, + logParam: false, + permitRedirect: false, + multipleStatements: false, + namedPlaceholders: false, + permitSetMultiParamEntries: false, + permitLocalInfile: false, }); + }); + + it('reads every column as text or bytes, on the pool and on a single connection', () => { + expect({ + connection: mysqlConnectionOptions('mysql://root@localhost/app').typeCast, + pool: mysqlPoolOptions('mysql://root@localhost/app').typeCast, + }).toEqual({ connection: readTextOrBytes, pool: readTextOrBytes }); + }); + + it('fetches the server public key only when allowPublicKeyRetrieval=true', () => { + expect({ + default: mysqlConnectionOptions('mysql://root@localhost/app').allowPublicKeyRetrieval, + on: mysqlConnectionOptions('mysql://root@localhost/app?allowPublicKeyRetrieval=true') + .allowPublicKeyRetrieval, + off: mysqlConnectionOptions('mysql://root@localhost/app?allowPublicKeyRetrieval=false') + .allowPublicKeyRetrieval, + }).toEqual({ default: false, on: true, off: false }); + expect(() => + mysqlConnectionOptions('mysql://root@localhost/app?allowPublicKeyRetrieval=yes'), + ).toThrow(expect.objectContaining({ code: 'RUNTIME.BINDING_INVALID' })); + }); + + it('refuses TLS settings it does not know', () => { + for (const query of ['ssl={}', 'ssl=maybe', 'sslmode=allow', 'sslcert=']) { + expect(() => mysqlConnectionOptions(`mysql://root@localhost/app?${query}`)).toThrow( + expect.objectContaining({ code: 'RUNTIME.BINDING_INVALID' }), + ); + } + }); + + it('always connects as utf8mb4, and refuses a charset in the URL', () => { for (const charset of ['latin1', 'utf8mb4']) { expect(() => mysqlConnectionOptions(`mysql://root@localhost/app?charset=${charset}`)).toThrow( expect.objectContaining({ @@ -43,59 +125,112 @@ describe('mysqlConnectionOptions', () => { } }); - it('refuses a URL parameter the driver does not understand', () => { + it('ignores a URL parameter it does not read, as pg ignores one, so the URL cannot turn on a package option', () => { + const plain = mysqlPoolOptions('mysql://root@localhost/app?sslmode=require'); for (const parameter of [ - 'namedPlaceholders=true', 'multipleStatements=true', + 'namedPlaceholders=true', + 'permitSetMultiParamEntries=true', + 'permitLocalInfile=true', + 'permitRedirect=true', 'typeCast=false', + 'prepareCacheLength=10', + 'connection_limit=2', + 'sslaccept=accept_invalid_certs', ]) { - expect(() => mysqlConnectionOptions(`mysql://root@localhost/app?${parameter}`)).toThrow( - expect.objectContaining({ code: 'RUNTIME.BINDING_INVALID' }), + expect(mysqlPoolOptions(`mysql://root@localhost/app?sslmode=require&${parameter}`)).toEqual( + plain, ); } }); - it('passes the connect timeout it is given, and leaves out the pool settings', () => { - const options = mysqlConnectionOptions( - 'mysql://root@localhost/app?connectionLimit=2&poolTimeout=100', - 500, + it("refuses sslcert without sslkey, because a Prisma 7 MySQL URL's sslcert named the server's CA, which is now sslrootcert", () => { + expect(() => mysqlConnectionOptions('mysql://root@localhost/app?sslcert=/etc/ca.pem')).toThrow( + expect.objectContaining({ + code: 'RUNTIME.BINDING_INVALID', + message: expect.stringContaining( + "sslcert names the client's certificate and needs sslkey; to check the server's certificate against a CA file, as sslcert did in Prisma 7, use sslrootcert", + ), + meta: { received: 'sslcert', reason: expect.any(String) }, + }), ); - expect({ - connectTimeout: options.connectTimeout, - poolSettings: 'connectionLimit' in options || 'poolTimeout' in options, - }).toEqual({ connectTimeout: 500, poolSettings: false }); - }); -}); - -describe('mysqlPoolSettings', () => { - it('reads the pool size and how long to wait for a connection, waiting ten seconds by default', () => { - expect({ - given: mysqlPoolSettings( - 'mysql://root@localhost/app?connectionLimit=5&poolTimeout=2000&statementCacheSize=20', - ), - defaults: mysqlPoolSettings('mysql://root@localhost/app?ssl=true'), - }).toEqual({ - given: { connectionLimit: 5, poolTimeoutMs: 2000, statementCacheSize: 20 }, - defaults: { connectionLimit: undefined, poolTimeoutMs: 10_000, statementCacheSize: 100 }, - }); }); - it('accepts a statement cache of up to 1000', () => { - expect(mysqlPoolSettings('mysql://root@localhost/app?statementCacheSize=1000')).toMatchObject({ - statementCacheSize: 1000, - }); + it('names a parameter whose value it refuses, and the form the value must take', () => { + expect(() => + mysqlPoolOptions('mysql://root@localhost/app?sslmode=soon&allowPublicKeyRetrieval=true'), + ).toThrow( + expect.objectContaining({ + code: 'RUNTIME.BINDING_INVALID', + message: expect.stringContaining( + 'sslmode must be disable, prefer, require, verify-ca, verify-full or no-verify', + ), + meta: { received: 'sslmode', reason: expect.any(String) }, + }), + ); }); - it('refuses a pool setting that is not a positive integer, and a statement cache above 1000', () => { - for (const parameter of [ - 'connectionLimit=0', - 'poolTimeout=soon', - 'statementCacheSize=-1', - 'statementCacheSize=1001', + it('copies no value from the URL into the error, not the password, even from a URL it cannot parse', () => { + const secret = 'hunter2-secret'; + for (const url of [ + `mysql://app:${secret}@db.example/app?sslmode=${secret}`, + `mysql://app:${secret}@db.example/app?sslcert=/${secret}.pem`, + `mysql://app:${secret}@db.example/app?sslkey=/${secret}.pem`, + `mysql://app:${secret}@db.example/app?charset=${secret}`, + `mysql://app:${secret}@[db.example/app`, ]) { - expect(() => mysqlPoolSettings(`mysql://root@localhost/app?${parameter}`)).toThrow( - expect.objectContaining({ code: 'RUNTIME.BINDING_INVALID' }), - ); + const error = (() => { + try { + mysqlPoolOptions(url); + } catch (thrown) { + return thrown; + } + throw new Error(`accepted ${url}`); + })(); + expect({ + code: Object(error).code, + leaked: inspect(error, { depth: 10 }).includes(secret), + }).toEqual({ code: 'RUNTIME.BINDING_INVALID', leaked: false }); } }); + + it('waits 20 seconds to connect, as the Postgres facade does, unless the caller says otherwise, and leaves out the pool settings', () => { + const given = mysqlConnectionOptions('mysql://root@localhost/app', { connectTimeoutMs: 500 }); + expect({ + default: mysqlConnectionOptions('mysql://root@localhost/app').connectTimeout, + caller: given.connectTimeout, + poolSettings: 'connectionLimit' in given || 'acquireTimeout' in given, + }).toEqual({ default: 20_000, caller: 500, poolSettings: false }); + }); +}); + +describe('mysqlPoolOptions', () => { + it("sizes and times the pool as the Postgres driver's pg.Pool: no connection opened before one is needed, idle ones closed after 30 seconds, 20 seconds to wait for one", () => { + expect({ + defaults: mysqlPoolOptions('mysql://root@localhost/app?ssl=true'), + given: mysqlPoolOptions('mysql://root@localhost/app', { + connectionTimeoutMillis: 2000, + idleTimeoutMillis: 1500, + max: 5, + }), + }).toMatchObject({ + defaults: { + minimumIdle: 0, + idleTimeout: 30, + acquireTimeout: 20_000, + connectTimeout: 20_000, + prepareCacheLength: 0, + ssl: true, + }, + given: { + minimumIdle: 0, + idleTimeout: 2, + acquireTimeout: 2000, + connectTimeout: 2000, + connectionLimit: 5, + initSql: [...SESSION_SETUP], + }, + }); + expect(mysqlPoolOptions('mysql://root@localhost/app')).not.toHaveProperty('connectionLimit'); + }); }); diff --git a/packages/3-targets/mysql/3-driver/test/control-driver.live.test.ts b/packages/3-targets/mysql/3-driver/test/control-driver.live.test.ts index 49edffef1ace..3314298c233e 100644 --- a/packages/3-targets/mysql/3-driver/test/control-driver.live.test.ts +++ b/packages/3-targets/mysql/3-driver/test/control-driver.live.test.ts @@ -1,18 +1,18 @@ import { timeouts } from '@repo/test-utils'; import { withMysqlDatabase } from '@repo/test-utils/mysql-database'; -import { createConnection } from 'mysql2/promise'; import { describe, expect, it } from 'vitest'; import type { MysqlControlDriver } from '../src/core/migrations/control-driver'; import mysqlDriver from '../src/core/migrations/control-driver'; +import mysqlRuntimeDriver from '../src/core/runtime/runtime-driver'; async function connectWhileServerDefaultIncludes( url: string, sqlMode: string, ): Promise { - const admin = await createConnection(url); + const admin = await mysqlDriver.create(url); try { - const [rows] = await admin.query('SELECT @@GLOBAL.sql_mode AS mode'); - const [{ mode }] = rows as [{ mode: string }]; + const { rows } = await admin.query<{ mode: string }>('SELECT @@GLOBAL.sql_mode AS mode'); + const mode = rows[0]?.mode ?? ''; await admin.query(`SET GLOBAL sql_mode = '${[mode, sqlMode].filter(Boolean).join(',')}'`); let driver: MysqlControlDriver | undefined; try { @@ -25,43 +25,70 @@ async function connectWhileServerDefaultIncludes( }); } } finally { - await admin.end(); + await admin.close(); } } describe('MysqlControlDriver against a MySQL server', { timeout: timeouts.databaseOperation, }, () => { - it('hands every column over as the text MySQL sends', async (test) => { + it('hands every column over as text, a binary column as bytes, in one form with values or without', async (test) => { await withMysqlDatabase(test, async ({ url, name }) => { const driver = await mysqlDriver.create(url); try { await driver.query( - "CREATE TABLE t (i INT, b BIGINT, d DECIMAL(10,2), dt DATETIME(3), j JSON, bin VARBINARY(4), txt TEXT, e ENUM('A','B'))", + "CREATE TABLE t (i INT, b BIGINT, d DECIMAL(10,2), dt DATETIME(3), j JSON, bin VARBINARY(4), txt TEXT, e ENUM('A','B'), y YEAR, z TINYINT(3) ZEROFILL, tm TIME(2), tm0 TIME(3), db DOUBLE, f FLOAT)", ); - await driver.query('INSERT INTO t VALUES (?, ?, ?, ?, ?, ?, ?, ?)', [ - 42, - '9007199254740993', - '1.50', - '2024-01-02 03:04:05.678', - '{"a": [1, 2]}', - Buffer.from([0, 1, 2, 255]), - 'héllo', - 'B', - ]); - const { rows } = await driver.query('SELECT * FROM t'); - expect(rows).toEqual([ - { - i: '42', - b: '9007199254740993', - d: '1.50', - dt: '2024-01-02 03:04:05.678', - j: '{"a": [1, 2]}', - bin: Buffer.from([0, 1, 2, 255]), - txt: 'héllo', - e: 'B', - }, - ]); + await driver.query( + 'INSERT INTO t VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, 5, ?, ?, 1e21, 3.4e38)', + [ + 42, + '9007199254740993', + '1.50', + '2024-01-02 03:04:05.678', + '{"a": [1, 2]}', + new Uint8Array([0, 1, 2, 255]), + 'héllo', + 'B', + '25:00:00.5', + '-01:02:03', + ], + ); + const row = { + i: '42', + b: '9007199254740993', + d: '1.50', + dt: '2024-01-02 03:04:05.678', + j: '{"a": [1, 2]}', + bin: new Uint8Array([0, 1, 2, 255]), + txt: 'héllo', + e: 'B', + y: '0000', + z: '005', + tm: '25:00:00.50', + tm0: '-01:02:03.000', + db: '1e21', + f: '3.4e38', + }; + const runtime = mysqlRuntimeDriver.create(); + await runtime.connect({ kind: 'url', url }); + try { + const read = async (rows: AsyncIterable) => { + const out: unknown[] = []; + for await (const r of rows) out.push(r); + return out; + }; + expect({ + text: (await driver.query('SELECT * FROM t')).rows, + prepared: (await driver.query('SELECT * FROM t WHERE i = ?', [42])).rows, + runtimeText: await read(runtime.query({ sql: 'SELECT * FROM t' })), + runtimePrepared: await read( + runtime.query({ sql: 'SELECT * FROM t WHERE i = ?1', params: [42] }), + ), + }).toEqual({ text: [row], prepared: [row], runtimeText: [row], runtimePrepared: [row] }); + } finally { + await runtime.close(); + } expect(await driver.databaseName()).toBe(name); } finally { await driver.close(); @@ -90,21 +117,25 @@ describe('MysqlControlDriver against a MySQL server', { }); }); - it('refuses a URL that asks for multiple statements', async (test) => { + it('ignores a URL that asks for multiple statements, so two statements in one query are still refused', async (test) => { await withMysqlDatabase(test, async ({ url }) => { const withMultipleStatements = new URL(url); withMultipleStatements.searchParams.set('multipleStatements', 'true'); - await expect(mysqlDriver.create(withMultipleStatements.toString())).rejects.toMatchObject({ - code: 'RUNTIME.BINDING_INVALID', - }); + const driver = await mysqlDriver.create(withMultipleStatements.toString()); + try { + await expect(driver.query('SELECT 1; SELECT 2')).rejects.toMatchObject({ + kind: 'sql_query', + sqlState: '42000', + }); + } finally { + await driver.close(); + } }); }); - it('refuses two statements in one query even when the URL flags multiple statements', async (test) => { + it('refuses two statements in one query', async (test) => { await withMysqlDatabase(test, async ({ url }) => { - const withMultipleStatements = new URL(url); - withMultipleStatements.searchParams.set('flags', 'MULTI_STATEMENTS'); - const driver = await mysqlDriver.create(withMultipleStatements.toString()); + const driver = await mysqlDriver.create(url); try { await expect(driver.query('SELECT 1; SELECT 2')).rejects.toMatchObject({ kind: 'sql_query', @@ -121,7 +152,7 @@ describe('MysqlControlDriver against a MySQL server', { const driver = await mysqlDriver.create(url); try { await expect(driver.query('SELECT ? AS v', [{ id: 1 }])).rejects.toMatchObject({ - code: 'RUNTIME.AST_INVALID', + code: 'RUNTIME.ENCODE_FAILED', }); } finally { await driver.close(); @@ -217,32 +248,48 @@ describe('MysqlControlDriver against a MySQL server', { }); }); - it('runs its session in UTC', async (test) => { + it('starts its session with the session settings the stack passes, set with parameters, the target UTC time zone and an integer setting among them', async (test) => { await withMysqlDatabase(test, async ({ url }) => { - const driver = await mysqlDriver.create(url); + const driver = await mysqlDriver.create(url, { + sessionSettings: { time_zone: '+00:00', sql_select_limit: '7' }, + }); try { - expect(await driver.query('SELECT @@SESSION.time_zone AS tz')).toEqual({ - rows: [{ tz: '+00:00' }], - }); + expect( + await driver.query('SELECT @@SESSION.time_zone AS tz, @@SESSION.sql_select_limit AS sl'), + ).toEqual({ rows: [{ tz: '+00:00', sl: '7' }] }); } finally { await driver.close(); } }); }); - it('reports a connection the server closed as DRIVER.NOT_CONNECTED instead of crashing', async (test) => { + it('reports a session setting MySQL refuses as DRIVER.CONNECTION_FAILED with its reason', async (test) => { + await withMysqlDatabase(test, async ({ url }) => { + await expect( + mysqlDriver.create(url, { + sessionSettings: { time_zone: "+00:00' , sql_mode = 'ANSI_QUOTES" }, + }), + ).rejects.toMatchObject({ + code: 'DRIVER.CONNECTION_FAILED', + why: expect.stringContaining("time zone: '+00:00' , sql_mode = 'ANSI_QUOTES'"), + }); + }); + }); + + it('reports a connection the server closed as a SqlConnectionError instead of crashing', async (test) => { await withMysqlDatabase(test, async ({ url }) => { const driver = await mysqlDriver.create(url); - const admin = await createConnection(url); + const admin = await mysqlDriver.create(url); try { const { rows } = await driver.query<{ id: string }>('SELECT CONNECTION_ID() AS id'); await admin.query(`KILL ${Number(rows[0]?.id)}`); await new Promise((resolve) => setTimeout(resolve, 200)); await expect(driver.query('SELECT 1')).rejects.toMatchObject({ - code: 'DRIVER.NOT_CONNECTED', + kind: 'sql_connection', + transient: true, }); } finally { - await admin.end(); + await admin.close(); await driver.close(); } }); @@ -277,4 +324,76 @@ describe('MysqlControlDriver against a MySQL server', { expect(JSON.stringify(meta)).toContain('no_such_database_for_prisma'); }); }); + + it('signs in with a password over plain TCP only when the URL allows fetching the server key, and otherwise names the two ways to allow it', async (test) => { + await withMysqlDatabase(test, async ({ url, name }) => { + const admin = await mysqlDriver.create(url); + const user = `${name.slice(-12)}_u`; + try { + await admin.query( + `CREATE USER '${user}'@'%' IDENTIFIED WITH caching_sha2_password BY 'pw-1'`, + ); + await admin.query(`GRANT ALL ON \`${name}\`.* TO '${user}'@'%'`); + const asUser = new URL(url); + asUser.username = user; + asUser.password = 'pw-1'; + asUser.searchParams.delete('allowPublicKeyRetrieval'); + const refused = await mysqlDriver.create(asUser.toString()).then( + async (driver) => { + await driver.close(); + return undefined; + }, + (error: unknown) => error, + ); + const pooled = mysqlRuntimeDriver.create({ + poolOptions: { connectionTimeoutMillis: 1000 }, + }); + await pooled.connect({ kind: 'url', url: asUser.toString() }); + const pooledRefused = await pooled.execute({ sql: 'SELECT 1' }).then( + () => undefined, + (error: unknown) => error, + ); + await pooled.close(); + asUser.searchParams.set('allowPublicKeyRetrieval', 'true'); + const allowed = await mysqlDriver.create(asUser.toString()); + try { + const waysOut = expect.stringMatching(/allowPublicKeyRetrieval=true.*TLS/); + expect({ + refused, + pooledRefused, + user: (await allowed.query('SELECT CURRENT_USER() AS u')).rows, + }).toEqual({ + refused: expect.objectContaining({ code: 'DRIVER.CONNECTION_FAILED', fix: waysOut }), + pooledRefused: expect.objectContaining({ + kind: 'sql_connection', + transient: false, + message: waysOut, + }), + user: [{ u: `${user}@%` }], + }); + } finally { + await allowed.close(); + } + } finally { + await admin.query(`DROP USER IF EXISTS '${user}'@'%'`).catch(() => undefined); + await admin.close(); + } + }); + }); + + it('connects over TLS with the settings in the URL', async (test) => { + await withMysqlDatabase(test, async ({ url }) => { + const tls = new URL(url); + tls.searchParams.set('sslmode', 'no-verify'); + const driver = await mysqlDriver.create(tls.toString()); + try { + const { rows } = await driver.query<{ Value: string }>( + "SHOW SESSION STATUS LIKE 'Ssl_version'", + ); + expect(rows[0]?.Value).toMatch(/^TLS/); + } finally { + await driver.close(); + } + }); + }); }); diff --git a/packages/3-targets/mysql/3-driver/test/control-driver.test.ts b/packages/3-targets/mysql/3-driver/test/control-driver.test.ts index dd99f3e093b8..97ee2c4f08a9 100644 --- a/packages/3-targets/mysql/3-driver/test/control-driver.test.ts +++ b/packages/3-targets/mysql/3-driver/test/control-driver.test.ts @@ -1,14 +1,14 @@ import { EventEmitter } from 'node:events'; -import type { Connection } from 'mysql2/promise'; +import type { Connection } from 'mariadb'; import { describe, expect, it } from 'vitest'; import { MysqlControlDriver } from '../src/core/migrations/control-driver'; class FakeConnection extends EventEmitter { queries = 0; - async query(): Promise<[unknown[]]> { + async query(): Promise { this.queries += 1; - return [[]]; + return []; } } @@ -19,8 +19,9 @@ describe('MysqlControlDriver', () => { expect(() => connection.emit('error', new Error('Connection lost'))).not.toThrow(); await expect(driver.query('SELECT 1')).rejects.toMatchObject({ - code: 'DRIVER.NOT_CONNECTED', - why: 'Connection lost', + kind: 'sql_connection', + transient: true, + message: 'Connection lost', }); expect(connection.queries).toBe(0); }); diff --git a/packages/3-targets/mysql/3-driver/test/normalize-error.test.ts b/packages/3-targets/mysql/3-driver/test/normalize-error.test.ts new file mode 100644 index 000000000000..3196e394868b --- /dev/null +++ b/packages/3-targets/mysql/3-driver/test/normalize-error.test.ts @@ -0,0 +1,287 @@ +import { isUniqueConstraintViolation } from '@internal/sql-errors'; +import { describe, expect, it } from 'vitest'; +import { normalizeMysqlError } from '../src/core/normalize-error'; + +function sqlError(fields: { + errno: number; + sqlState: string | null; + fatal: boolean; + sqlMessage: string; +}) { + return Object.assign(new Error(`(conn:1, no: ${fields.errno}) ${fields.sqlMessage}`), { + name: 'SqlError', + code: 'ER_X', + sql: null, + text: fields.sqlMessage, + ...fields, + }); +} + +describe('normalizeMysqlError', () => { + it('turns an error MySQL reports into a SqlQueryError with its SQLSTATE and bare message', () => { + const error = sqlError({ + errno: 1146, + sqlState: '42S02', + fatal: false, + sqlMessage: "Table 'x' doesn't exist", + }); + expect(normalizeMysqlError(error)).toEqual( + expect.objectContaining({ + kind: 'sql_query', + sqlState: '42S02', + message: "Table 'x' doesn't exist", + cause: error, + }), + ); + }); + + it('reports a lost connection as a transient SqlConnectionError, as the Postgres driver does, not as a statement error', () => { + for (const errno of [45009, 45013]) { + const error = sqlError({ + errno, + sqlState: '08S01', + fatal: true, + sqlMessage: 'socket has unexpectedly been closed', + }); + expect(normalizeMysqlError(error)).toEqual( + expect.objectContaining({ + kind: 'sql_connection', + transient: true, + sqlState: '08S01', + message: 'socket has unexpectedly been closed', + cause: error, + }), + ); + } + }); + + it('reports a pool that gave no connection in time as a transient SqlConnectionError, with the reason the pool could not connect', () => { + const cause = sqlError({ + errno: 45012, + sqlState: '08S01', + fatal: true, + sqlMessage: 'Connection timeout: failed to create socket after 1001ms', + }); + const error = Object.assign( + sqlError({ + errno: 45028, + sqlState: 'HY000', + fatal: false, + sqlMessage: 'pool timeout: failed to retrieve a connection from pool after 200ms', + }), + { cause }, + ); + expect(normalizeMysqlError(error)).toEqual( + expect.objectContaining({ + kind: 'sql_connection', + transient: true, + message: expect.stringContaining('failed to create socket'), + cause: error, + }), + ); + }); + + it('reports a socket error by its Node code with the Postgres driver table, a refused connection and an unknown or unreachable host as not transient, directly and as the reason a pool gave no connection', () => { + const socketError = (code: string, errno: number | undefined) => + Object.assign(errno === undefined ? new AggregateError([]) : new Error(`connect ${code}`), { + code, + errno, + fatal: true, + sqlState: 'HY000', + }); + const timedOut = (cause: Error) => + Object.assign( + sqlError({ + errno: 45028, + sqlState: 'HY000', + fatal: false, + sqlMessage: 'pool failed to retrieve a connection from pool', + }), + { cause }, + ); + const cases = [ + socketError('ECONNREFUSED', -61), + socketError('ECONNREFUSED', undefined), + socketError('ENOTFOUND', -3008), + socketError('EHOSTUNREACH', -65), + socketError('ECONNRESET', -54), + socketError('ETIMEDOUT', -60), + socketError('EPIPE', -32), + socketError('EAI_AGAIN', -3001), + ]; + expect( + cases.map((error) => { + const direct = Object(normalizeMysqlError(error)); + const pooled = Object(normalizeMysqlError(timedOut(error))); + return { + direct: { + kind: direct.kind, + transient: direct.transient, + sqlState: direct.sqlState, + message: direct.message, + isCause: direct.cause === error, + }, + pooled: { + kind: pooled.kind, + transient: pooled.transient, + isCause: pooled.cause === error, + }, + }; + }), + ).toEqual( + cases.map((error) => { + const transient = !['ECONNREFUSED', 'ENOTFOUND', 'EHOSTUNREACH'].includes(error.code); + return { + direct: { + kind: 'sql_connection', + transient, + sqlState: undefined, + message: error.message, + isCause: true, + }, + pooled: { kind: 'sql_connection', transient, isCause: !transient }, + }; + }), + ); + }); + + it('reports a sign-in MySQL refuses as the SqlQueryError MySQL raised, as the Postgres driver reports 28P01, directly and as the reason a pool gave no connection', () => { + for (const [errno, sqlState] of [ + [1045, '28000'], + [1698, '28000'], + [1524, 'HY000'], + ] as const) { + const refused = sqlError({ errno, sqlState, fatal: true, sqlMessage: 'Access denied' }); + const timedOut = Object.assign( + sqlError({ + errno: 45028, + sqlState: 'HY000', + fatal: false, + sqlMessage: 'pool timeout: failed to retrieve a connection from pool after 200ms', + }), + { cause: refused }, + ); + const expected = expect.objectContaining({ + kind: 'sql_query', + sqlState, + message: 'Access denied', + cause: refused, + }); + expect({ + direct: normalizeMysqlError(refused), + pooled: normalizeMysqlError(timedOut), + }).toEqual({ direct: expected, pooled: expected }); + } + }); + + it('reports session setup that fails on a new connection as a SqlConnectionError that will not pass on retry', () => { + const error = sqlError({ + errno: 45030, + sqlState: '08S01', + fatal: true, + sqlMessage: 'Error executing initial sql command: Unknown system variable', + }); + expect(normalizeMysqlError(error)).toEqual( + expect.objectContaining({ + kind: 'sql_connection', + transient: false, + message: expect.stringContaining('Unknown system variable'), + cause: error, + }), + ); + }); + + it('leaves any other error as it is', () => { + const error = new Error('socket hang up'); + expect(normalizeMysqlError(error)).toBe(error); + }); +}); + +describe('normalizeMysqlError for errors MySQL reports about a statement', () => { + it('reports every server error as a SqlQueryError with no detail of the driver own, as MySQL sends none', () => { + const cases = [ + { + errno: 1295, + sqlState: 'HY000', + sqlMessage: 'This command is not supported in the prepared statement protocol yet', + }, + { + errno: 1461, + sqlState: '42000', + sqlMessage: "Can't create more than max_prepared_stmt_count statements", + }, + { errno: 1210, sqlState: 'HY000', sqlMessage: 'Incorrect arguments to LIMIT' }, + ]; + for (const fields of cases) { + const error = sqlError({ ...fields, fatal: false }); + const normalized = normalizeMysqlError(error); + expect({ normalized, hasDetail: Object(normalized).detail !== undefined }).toEqual({ + normalized: expect.objectContaining({ + kind: 'sql_query', + sqlState: fields.sqlState, + message: fields.sqlMessage, + cause: error, + }), + hasDetail: false, + }); + } + }); + + it('reports constraint violations with the SQLSTATEs Postgres sends and the SQLite driver maps to, so a duplicate key is a unique violation', () => { + const cases = [ + [1062, '23000', '23505'], + [1586, '23000', '23505'], + [1451, '23000', '23503'], + [1452, '23000', '23503'], + [1216, '23000', '23503'], + [1217, '23000', '23503'], + [1048, '23000', '23502'], + [3819, 'HY000', '23514'], + [1022, '23000', '23000'], + ] as const; + expect( + cases.map(([errno, sqlState]) => { + const normalized = normalizeMysqlError( + sqlError({ errno, sqlState, fatal: false, sqlMessage: 'violated' }), + ); + return { + sqlState: Object(normalized).sqlState, + unique: isUniqueConstraintViolation(normalized), + }; + }), + ).toEqual(cases.map(([, , sqlState]) => ({ sqlState, unique: sqlState === '23505' }))); + }); + + it('reports a sign-in that needs the server public key as a SqlConnectionError that will not pass on retry, naming allowPublicKeyRetrieval and TLS', () => { + const error = sqlError({ + errno: 45044, + sqlState: '08S01', + fatal: true, + sqlMessage: 'RSA public key is not available client side.', + }); + const message = expect.stringMatching(/allowPublicKeyRetrieval=true.*TLS/); + const timedOut = Object.assign( + sqlError({ + errno: 45028, + sqlState: 'HY000', + fatal: false, + sqlMessage: 'pool timeout: failed to retrieve a connection from pool after 200ms', + }), + { cause: error }, + ); + expect({ direct: normalizeMysqlError(error), pooled: normalizeMysqlError(timedOut) }).toEqual({ + direct: expect.objectContaining({ + kind: 'sql_connection', + transient: false, + message, + cause: error, + }), + pooled: expect.objectContaining({ + kind: 'sql_connection', + transient: false, + message, + cause: error, + }), + }); + }); +}); diff --git a/packages/3-targets/mysql/3-driver/test/positional-params.test.ts b/packages/3-targets/mysql/3-driver/test/positional-params.test.ts index 172846d42fab..486c685a6d8f 100644 --- a/packages/3-targets/mysql/3-driver/test/positional-params.test.ts +++ b/packages/3-targets/mysql/3-driver/test/positional-params.test.ts @@ -1,3 +1,4 @@ +import { inspect } from 'node:util'; import { describe, expect, it } from 'vitest'; import { positionalParams } from '../src/core/positional-params'; @@ -16,16 +17,71 @@ describe('positionalParams', () => { }); }); - it('sends an integer number as a BigInt, which MySQL reads as an integer where LIMIT needs one', () => { - expect(positionalParams('SELECT ?1, ?2, ?3', [2, 1.5, 2n]).values).toEqual([2n, 1.5, 2n]); + it('sends an integer number within the signed 64-bit range as a bigint, so MySQL reads it as BIGINT, and any other number as a double', () => { + expect( + positionalParams('SELECT ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9', [ + 2, + 2147483647, + 2 ** 60, + -(2 ** 63), + 1.5, + 2n, + 1e20, + -1e20, + 2 ** 63, + ]).values, + ).toEqual([2n, 2147483647n, 2n ** 60n, -(2n ** 63n), 1.5, 2n, 1e20, -1e20, 2 ** 63]); }); - it('sends bytes as a Buffer', () => { - expect(positionalParams('SELECT ?1', [new Uint8Array([1, 255])]).values).toEqual([ - Buffer.from([1, 255]), + it('refuses a bigint outside the signed 64-bit range before it reaches the package, leaving the value out of the error', () => { + for (const value of [-(2n ** 63n) - 1n, 2n ** 63n]) { + const error = (() => { + try { + positionalParams('SELECT ?1', [value]); + } catch (thrown) { + return thrown; + } + throw new Error('accepted'); + })(); + expect({ + error, + leaked: inspect(error, { depth: 10 }).includes(String(value).replace('-', '')), + }).toEqual({ + error: expect.objectContaining({ + code: 'RUNTIME.ENCODE_FAILED', + meta: { target: 'mysql', paramIndex: 0, received: 'bigint' }, + }), + leaked: false, + }); + } + expect(positionalParams('SELECT ?1, ?2', [-(2n ** 63n), 2n ** 63n - 1n]).values).toEqual([ + -(2n ** 63n), + 2n ** 63n - 1n, ]); }); + it('sends bytes as a Buffer over the same memory, and a Buffer as it is', () => { + const bytes = new Uint8Array([0, 1, 255, 7]).subarray(1, 3); + const buffer = Buffer.from([9]); + const [sent, kept] = positionalParams('SELECT ?1, ?2', [bytes, buffer]).values; + expect({ + sent, + isBuffer: Buffer.isBuffer(sent), + sameMemory: Buffer.isBuffer(sent) && sent.buffer === bytes.buffer, + kept: kept === buffer, + }).toEqual({ sent: Buffer.from([1, 255]), isBuffer: true, sameMemory: true, kept: true }); + }); + + it('refuses a Date, which the package would encode in the local time zone', () => { + expect(() => positionalParams('SELECT ?1, ?2', ['a', new Date(0)])).toThrow( + expect.objectContaining({ + code: 'RUNTIME.ENCODE_FAILED', + message: expect.stringContaining('Date'), + meta: { target: 'mysql', paramIndex: 1, received: 'Date' }, + }), + ); + }); + it('reads the statement as MySQL does: -- before a control character opens a comment, and /*! is refused', () => { expect(positionalParams("SELECT 1 --\x01 '\n, ?1", ['a'])).toEqual({ sql: "SELECT 1 --\x01 '\n, ?", @@ -50,9 +106,17 @@ describe('positionalParams', () => { }); it('refuses a value MySQL has no parameter type for', () => { - for (const value of [{ a: 1 }, [1, 2], Number.NaN]) { + for (const [value, received] of [ + [{ a: 1 }, 'object'], + [[1, 2], 'array'], + [Number.NaN, 'NaN'], + [Number.POSITIVE_INFINITY, 'Infinity'], + ] as const) { expect(() => positionalParams('SELECT ?1', [value])).toThrow( - expect.objectContaining({ code: 'RUNTIME.AST_INVALID' }), + expect.objectContaining({ + code: 'RUNTIME.ENCODE_FAILED', + meta: { target: 'mysql', paramIndex: 0, received }, + }), ); } }); diff --git a/packages/3-targets/mysql/3-driver/test/runtime-driver.live.test.ts b/packages/3-targets/mysql/3-driver/test/runtime-driver.live.test.ts index 8b1628ec88ba..54d375ea81aa 100644 --- a/packages/3-targets/mysql/3-driver/test/runtime-driver.live.test.ts +++ b/packages/3-targets/mysql/3-driver/test/runtime-driver.live.test.ts @@ -1,3 +1,5 @@ +import { connect, createServer } from 'node:net'; +import { inspect } from 'node:util'; import { timeouts } from '@repo/test-utils'; import { withMysqlDatabase } from '@repo/test-utils/mysql-database'; import { describe, expect, it } from 'vitest'; @@ -10,11 +12,14 @@ async function collect(rows: AsyncIterable): Promise { return out; } +type DriverOptions = Parameters[0]; + async function withDriver( url: string, fn: (driver: ReturnType) => Promise, + options?: DriverOptions, ): Promise { - const driver = mysqlRuntimeDriver.create(); + const driver = mysqlRuntimeDriver.create(options); await driver.connect({ kind: 'url', url }); try { return await fn(driver); @@ -30,11 +35,67 @@ async function drain(rows: AsyncIterator): Promise { while (!(await rows.next()).done) {} } +const tenSlowRows = + 'WITH RECURSIVE s(n) AS (SELECT 1 UNION ALL SELECT n + 1 FROM s WHERE n < 10) SELECT n, SLEEP(0.05) AS z FROM s'; + const numbers = 'SELECT n FROM (SELECT 1 AS n UNION ALL SELECT 2 UNION ALL SELECT 3) AS t ORDER BY n'; +const streaming = { stream: true } as const; +const single = { stream: true, poolOptions: { max: 1 } } as const; + +const twoHundredNumbers = + 'WITH RECURSIVE s(n) AS (SELECT 1 UNION ALL SELECT n + 1 FROM s WHERE n < 200) SELECT n FROM s'; + +/** + * Runs `fn` with a URL for a new user of its own on the test database, and a function that counts the connections that user has opened on the server. A user of its own keeps the count apart from other tests sharing the server. + */ +async function withCountedUser( + url: string, + name: string, + fn: (userUrl: URL, opened: () => Promise) => Promise, +): Promise { + const admin = await mysqlControlDriver.create(url); + const user = `${name.slice(-12)}_c`; + try { + await admin.query(`CREATE USER '${user}'@'%' IDENTIFIED BY 'pw-counted'`); + await admin.query(`GRANT ALL ON \`${name}\`.* TO '${user}'@'%'`); + const userUrl = new URL(url); + userUrl.username = user; + userUrl.password = 'pw-counted'; + userUrl.searchParams.set('allowPublicKeyRetrieval', 'true'); + const opened = async () => { + const { rows } = await admin.query<{ n: string }>( + 'SELECT COALESCE(SUM(TOTAL_CONNECTIONS), 0) AS n FROM performance_schema.accounts WHERE USER = ?', + [user], + ); + return Number(rows[0]?.n); + }; + return await fn(userUrl, opened); + } finally { + await admin.query(`DROP USER IF EXISTS '${user}'@'%'`).catch(() => undefined); + await admin.close(); + } +} + +async function freePort(): Promise { + const server = createServer(); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + await new Promise((resolve) => server.close(resolve)); + if (address === null || typeof address === 'string') throw new Error('no port'); + return address.port; +} + +async function connectionId(driver: ReturnType) { + const [row] = await collect( + driver.query<{ id: bigint }>({ sql: 'SELECT CONNECTION_ID() AS id' }), + ); + return row?.id; +} + describe('MySQL runtime driver', { timeout: timeouts.databaseOperation }, () => { - it('streams a bigint, a datetime and JSON as text, and a value bound apart from the SQL', async (test) => { + it('streams an integer, a datetime and JSON as text, and a value bound apart from the SQL', async (test) => { await withMysqlDatabase(test, ({ url }) => withDriver(url, async (driver) => { expect( @@ -146,87 +207,256 @@ describe('MySQL runtime driver', { timeout: timeouts.databaseOperation }, () => ); }); - it('keeps no more prepared statements open than the cache size on each connection', async (test) => { - await withMysqlDatabase(test, async ({ url, name }) => { - const cached = new URL(url); - cached.searchParams.set('statementCacheSize', '5'); - cached.searchParams.set('connectionLimit', '2'); - const admin = await mysqlControlDriver.create(url); - try { - await withDriver(cached.toString(), async (driver) => { - const sizes = Array.from({ length: 300 }, (_, i) => i + 1); - for (let start = 0; start < sizes.length; start += 10) { - await Promise.all( - sizes.slice(start, start + 10).map((size) => { - const list = Array.from({ length: size }, (_, i) => `?${i + 1}`).join(', '); - return collect( - driver.query({ - sql: `SELECT 1 AS n FROM DUAL WHERE 1 IN (${list})`, - params: Array.from({ length: size }, (_, i) => i + 1), - }), - ); - }), - ); - } - const { rows } = await admin.query<{ open: string }>( - 'SELECT COUNT(*) AS open FROM performance_schema.prepared_statements_instances p JOIN performance_schema.threads t ON p.OWNER_THREAD_ID = t.THREAD_ID WHERE t.PROCESSLIST_DB = ? AND t.PROCESSLIST_ID <> CONNECTION_ID()', - [name], + async function openStatements(url: string, name: string): Promise { + const admin = await mysqlControlDriver.create(url); + try { + const { rows } = await admin.query<{ open: string }>( + 'SELECT COUNT(*) AS open FROM performance_schema.prepared_statements_instances p JOIN performance_schema.threads t ON p.OWNER_THREAD_ID = t.THREAD_ID WHERE t.PROCESSLIST_DB = ? AND t.PROCESSLIST_ID <> CONNECTION_ID()', + [name], + ); + return Number(rows[0]?.open); + } finally { + await admin.close(); + } + } + + async function runManyStatements(driver: ReturnType) { + const sizes = Array.from({ length: 60 }, (_, i) => i + 1); + for (let start = 0; start < sizes.length; start += 10) { + await Promise.all( + sizes.slice(start, start + 10).map((size) => { + const list = Array.from({ length: size }, (_, i) => `?${i + 1}`).join(', '); + return collect( + driver.query({ + sql: `SELECT 1 AS n FROM DUAL WHERE 1 IN (${list})`, + params: Array.from({ length: size }, (_, i) => i + 1), + }), ); - expect(Number(rows[0]?.open)).toBeLessThanOrEqual(10); - }); - } finally { - await admin.close(); - } + }), + ); + } + } + + it('closes every prepared statement after it runs, so a process never holds statements open against the server-wide limit', async (test) => { + await withMysqlDatabase(test, async ({ url, name }) => { + const open = await withDriver( + url, + async (driver) => { + await runManyStatements(driver); + return openStatements(url, name); + }, + { poolOptions: { max: 2 } }, + ); + expect(open).toBe(0); }); }); - it('refuses a statement MySQL cannot prepare with DRIVER.PREPARE_FAILED', async (test) => { + it('sends integers as BIGINT, so a comparison with no column compares numbers and LIMIT takes the largest INT', async (test) => { await withMysqlDatabase(test, ({ url }) => withDriver(url, async (driver) => { - await driver.execute({ sql: 'CREATE TABLE t (id int)' }); - await expect(driver.execute({ sql: 'LOCK TABLES t READ' })).rejects.toMatchObject({ - code: 'DRIVER.PREPARE_FAILED', + expect({ + compared: await collect( + driver.query({ + sql: 'SELECT ?1 < ?2 AS lt, ?3 < ?4 AS text_lt', + params: [10, 9, '10', '9'], + }), + ), + limited: await collect( + driver.query({ sql: 'SELECT 1 AS n FROM DUAL LIMIT ?1', params: [2147483647] }), + ), + offset: await collect( + driver.query({ + sql: 'SELECT 1 AS n FROM DUAL LIMIT ?1 OFFSET ?2', + params: [2 ** 53, 0], + }), + ), + }).toEqual({ + compared: [{ lt: '0', text_lt: '1' }], + limited: [{ n: '1' }], + offset: [{ n: '1' }], }); }), ); }); - it('reads each column type from the binary protocol in the form its codec takes', async (test) => { + it('round-trips the ends of the signed 64-bit range and bytes, sends a whole number past it as a double, and refuses a bigint past it or a Date without crashing', async (test) => { + await withMysqlDatabase(test, ({ url }) => + withDriver(url, async (driver) => { + await driver.execute({ sql: 'CREATE TABLE t (b BIGINT, v VARBINARY(4), d DOUBLE)' }); + await driver.execute({ + sql: 'INSERT INTO t VALUES (?1, ?2, ?3), (?4, NULL, ?5)', + params: [-(2n ** 63n), new Uint8Array([0, 1, 255]), 1e19, 2n ** 63n - 1n, 1e20], + }); + for (const value of [-(2n ** 63n) - 1n, 2n ** 64n, new Date(0)]) { + await expect( + driver.execute({ sql: 'INSERT INTO t (b) VALUES (?1)', params: [value] }), + ).rejects.toMatchObject({ code: 'RUNTIME.ENCODE_FAILED' }); + } + expect({ + rows: await collect(driver.query({ sql: 'SELECT b, v, d FROM t ORDER BY b' })), + below: await collect( + driver.query({ sql: 'SELECT COUNT(*) AS n FROM t WHERE d < ?1', params: [1e20] }), + ), + }).toEqual({ + rows: [ + { + b: '-9223372036854775808', + v: new Uint8Array([0, 1, 255]), + d: '1e19', + }, + { b: '9223372036854775807', v: null, d: '1e20' }, + ], + below: [{ n: '1' }], + }); + }), + ); + }); + + it('runs a raw statement without values over the text protocol, as pg does, so START TRANSACTION and LOCK TABLES, which MySQL does not prepare, run', async (test) => { + await withMysqlDatabase(test, ({ url }) => + withDriver(url, async (driver) => { + await driver.execute({ sql: 'CREATE TABLE t (id int)' }); + const connection = await driver.acquireConnection(); + try { + await connection.execute({ sql: 'LOCK TABLES t WRITE' }); + await connection.execute({ sql: 'START TRANSACTION' }); + await connection.execute({ sql: 'INSERT INTO t VALUES (?1)', params: [1] }); + await connection.execute({ sql: 'COMMIT' }); + await connection.execute({ sql: 'UNLOCK TABLES' }); + } finally { + await connection.release(); + } + expect(await collect(driver.query({ sql: 'SELECT id FROM t' }))).toEqual([{ id: '1' }]); + }), + ); + }); + + it('returns every column type as text, binary columns as bytes and NULL as null, on a pooled and a held connection, over the text protocol and prepared', async (test) => { await withMysqlDatabase(test, ({ url }) => withDriver(url, async (driver) => { await driver.execute({ - sql: "CREATE TABLE t (i INT, b BIGINT, ub BIGINT UNSIGNED, d DECIMAL(65,30), dt DATETIME(3), dd DATE, tm TIME(3), j JSON, vc VARCHAR(10), c CHAR(3), tx TEXT, e ENUM('A','B'), vb VARBINARY(4), f FLOAT, y YEAR)", + sql: "CREATE TABLE t (i INT, ti TINYINT(1), b BIGINT, ub BIGINT UNSIGNED, d DECIMAL(65,30), f FLOAT, db DOUBLE, y YEAR, dt DATETIME(3), dd DATE, tm TIME(3), j JSON, vc VARCHAR(10), c CHAR(3), tx TEXT, e ENUM('A','B'), st SET('a','b'), vb VARBINARY(4), bl BLOB, bt BIT(1), n INT)", }); await driver.execute({ - sql: "INSERT INTO t VALUES (?1, ?2, ?3, ?4, ?5, '2026-10-23', '01:02:03.450', '{\"a\": 1}', ?6, 'ab', 'tx', 'B', X'00ff', 0.1, 2026)", + sql: "INSERT INTO t VALUES (?1, 1, ?2, ?3, ?4, 0.1, 0.1, 2026, ?5, '2026-10-23', '01:02:03.450', '{\"a\": 1}', ?6, 'ab', 'tx', 'B', 'a,b', X'00ff', X'01', b'1', NULL)", params: [ - -7, - 9223372036854775807n, + '-7', + '9223372036854775807', '18446744073709551615', '1.000000000000000000000000000001', '2026-10-23 01:02:03.456', "ħ\\'", ], }); - expect(await collect(driver.query({ sql: 'SELECT * FROM t' }))).toEqual([ + const row = { + i: '-7', + ti: '1', + b: '9223372036854775807', + ub: '18446744073709551615', + d: '1.000000000000000000000000000001', + f: '0.1', + db: '0.1', + y: '2026', + dt: '2026-10-23 01:02:03.456', + dd: '2026-10-23', + tm: '01:02:03.450', + j: '{"a": 1}', + vc: "ħ\\'", + c: 'ab', + tx: 'tx', + e: 'B', + st: 'a,b', + vb: new Uint8Array([0, 255]), + bl: new Uint8Array([1]), + bt: new Uint8Array([1]), + n: null, + }; + const prepared = { sql: 'SELECT * FROM t WHERE ?1 = ?1', params: [1] }; + const connection = await driver.acquireConnection(); + try { + expect({ + pooled: await collect(driver.query({ sql: 'SELECT * FROM t' })), + held: await collect(connection.query({ sql: 'SELECT * FROM t' })), + pooledPrepared: await collect(driver.query(prepared)), + heldPrepared: await collect(connection.query(prepared)), + }).toEqual({ pooled: [row], held: [row], pooledPrepared: [row], heldPrepared: [row] }); + } finally { + await connection.release(); + } + }), + ); + }); + + it('reads a FLOAT and a DOUBLE as MySQL writes them over the text protocol, negative zero included, whether the row came over the text protocol or prepared', async (test) => { + await withMysqlDatabase(test, ({ url }) => + withDriver(url, async (driver) => { + await driver.execute({ sql: 'CREATE TABLE t (id INT, f FLOAT, d DOUBLE)' }); + await driver.execute({ + sql: 'INSERT INTO t VALUES (1, -0e0, -0e0), (2, 0.1, 1e20), (3, 3.4e38, 1e-7), (4, 1e-16, 1e15), (5, 2.5, 4233813289058719.5)', + }); + const rows = [ + { f: '-0', d: '-0' }, + { f: '0.1', d: '1e20' }, + { f: '3.4e38', d: '0.0000001' }, + { f: '1e-16', d: '1e15' }, + { f: '2.5', d: '4233813289058719.5' }, + ]; + expect({ + text: await collect(driver.query({ sql: 'SELECT f, d FROM t ORDER BY id' })), + prepared: await collect( + driver.query({ sql: 'SELECT f, d FROM t WHERE ?1 = ?1 ORDER BY id', params: [1] }), + ), + }).toEqual({ text: rows, prepared: rows }); + }), + ); + }); + + it('reads a FLOAT and a DOUBLE declared with decimals with exactly those decimals, as MySQL writes them over the text protocol, whether the row came over the text protocol or prepared', async (test) => { + await withMysqlDatabase(test, ({ url }) => + withDriver(url, async (driver) => { + await driver.execute({ + sql: 'CREATE TABLE t (id INT, f FLOAT(12,6), d DOUBLE(10,2), w DOUBLE(40,30), z FLOAT(10,1))', + }); + await driver.execute({ + sql: 'INSERT INTO t VALUES (1, 1234.5677e0, 1.5e0, 0.1e0, 5e0), (2, 0.1e0, 100e0, 1e-25, -7e0), (3, -0.5e0, -12345678.9e0, 1e0/3e0, 0e0), (4, 98765.4321e0, 0e0, 123456789.123456789e0, 2097152.3e0)', + }); + const rows = [ + { f: '1234.567749', d: '1.50', w: '0.100000000000000000000000000000', z: '5.0' }, + { f: '0.100000', d: '100.00', w: '0.000000000000000000000000100000', z: '-7.0' }, + { f: '-0.500000', d: '-12345678.90', w: '0.333333333333333300000000000000', z: '0.0' }, { - i: -7, - b: '9223372036854775807', - ub: '18446744073709551615', - d: '1.000000000000000000000000000001', - dt: '2026-10-23 01:02:03.456', - dd: '2026-10-23', - tm: '01:02:03.45', - j: '{"a": 1}', - vc: "ħ\\'", - c: 'ab', - tx: 'tx', - e: 'B', - vb: Buffer.from([0, 255]), - f: 0.10000000149011612, - y: 2026, + f: '98765.429688', + d: '0.00', + w: '123456789.123456790000000000000000000000', + z: '2097152.2', }, - ]); + ]; + expect({ + text: await collect(driver.query({ sql: 'SELECT f, d, w, z FROM t ORDER BY id' })), + prepared: await collect( + driver.query({ + sql: 'SELECT f, d, w, z FROM t WHERE ?1 = ?1 ORDER BY id', + params: [1], + }), + ), + }).toEqual({ text: rows, prepared: rows }); + }), + ); + }); + + it('reads negative zero in a computed column declared with decimals with its sign, as MySQL writes it, whether the row came over the text protocol or prepared', async (test) => { + await withMysqlDatabase(test, ({ url }) => + withDriver(url, async (driver) => { + await driver.execute({ + sql: 'CREATE TABLE t (f FLOAT(10,2), d DOUBLE(10,2), w DOUBLE(255,0))', + }); + await driver.execute({ sql: 'INSERT INTO t VALUES (-1.5e0, 0e0, 0e0)' }); + const rows = [{ negated: '-0.00', times: '-0.00', whole: '-0' }]; + const select = 'SELECT -d AS negated, f * 0 AS times, -w AS whole FROM t'; + expect({ + text: await collect(driver.query({ sql: select })), + prepared: await collect(driver.query({ sql: `${select} WHERE ?1 = ?1`, params: [1] })), + }).toEqual({ text: rows, prepared: rows }); }), ); }); @@ -239,15 +469,14 @@ describe('MySQL runtime driver', { timeout: timeouts.databaseOperation }, () => await connection.execute({ sql: "SET SESSION sql_mode = 'NO_BACKSLASH_ESCAPES'", }); - await expect( - collect(connection.query({ sql: "SELECT 'x\\' AS a, ? AS b" })), - ).rejects.toMatchObject({ + const request = { sql: "SELECT ?1 AS v, 'x\\' AS a, ? AS b", params: [1] }; + await expect(collect(connection.query(request))).rejects.toMatchObject({ + code: 'RUNTIME.PARAM_PLACEHOLDER_MISMATCH', + meta: expect.objectContaining({ prepared: 2, params: 1 }), + }); + await expect(connection.execute(request)).rejects.toMatchObject({ code: 'RUNTIME.PARAM_PLACEHOLDER_MISMATCH', - meta: expect.objectContaining({ prepared: 1, params: 0 }), }); - await expect( - connection.execute({ sql: "SELECT 'x\\' AS a, ? AS b" }), - ).rejects.toMatchObject({ code: 'RUNTIME.PARAM_PLACEHOLDER_MISMATCH' }); } finally { await connection.destroy(); } @@ -257,24 +486,26 @@ describe('MySQL runtime driver', { timeout: timeouts.databaseOperation }, () => it('keeps a SET on its connection after the connection goes back to the pool, as Postgres does', async (test) => { await withMysqlDatabase(test, async ({ url }) => { - const single = new URL(url); - single.searchParams.set('connectionLimit', '1'); - await withDriver(single.toString(), async (driver) => { - const zone = () => - collect( - driver.query>({ sql: 'SELECT @@SESSION.time_zone AS tz' }), - ); - const before = await zone(); - await driver.execute({ sql: "SET time_zone = '+05:00'" }); - expect({ before, after: await zone() }).toEqual({ - before: [{ tz: '+00:00' }], - after: [{ tz: '+05:00' }], - }); - }); + await withDriver( + url, + async (driver) => { + const zone = () => + collect( + driver.query>({ sql: 'SELECT @@SESSION.time_zone AS tz' }), + ); + const before = await zone(); + await driver.execute({ sql: "SET time_zone = '+05:00'" }); + expect({ before, after: await zone() }).toEqual({ + before: [{ tz: '+00:00' }], + after: [{ tz: '+05:00' }], + }); + }, + { poolOptions: { max: 1 }, sessionSettings: { time_zone: '+00:00' } }, + ); }); }); - it('refuses a LIMIT value that is not an integer with RUNTIME.ARGUMENT_INVALID', async (test) => { + it('reports a LIMIT value that is not an integer as the SqlQueryError MySQL raises', async (test) => { await withMysqlDatabase(test, ({ url }) => withDriver(url, async (driver) => { await expect( @@ -284,70 +515,464 @@ describe('MySQL runtime driver', { timeout: timeouts.databaseOperation }, () => params: [1.5], }), ), - ).rejects.toMatchObject({ code: 'RUNTIME.ARGUMENT_INVALID' }); + ).rejects.toMatchObject({ kind: 'sql_query', sqlState: 'HY000' }); }), ); }); - it('sets up every connection: no backslash escapes mode and UTC', async (test) => { + it('sets up every connection: no backslash escapes mode, and the session settings it is created with, an integer setting among them', async (test) => { await withMysqlDatabase(test, ({ url }) => - withDriver(url, async (driver) => { - const [row] = await collect( - driver.query>({ - sql: "SELECT @@SESSION.time_zone AS tz, FIND_IN_SET('NO_BACKSLASH_ESCAPES', @@SESSION.sql_mode) AS nbe", - }), + withDriver( + url, + async (driver) => { + const read = () => + collect( + driver.query>({ + sql: "SELECT @@SESSION.time_zone AS tz, @@SESSION.sql_select_limit AS sl, FIND_IN_SET('NO_BACKSLASH_ESCAPES', @@SESSION.sql_mode) AS nbe", + }), + ); + const held = await driver.acquireConnection(); + try { + const [onHeld] = await collect( + held.query>({ sql: 'SELECT @@SESSION.time_zone AS tz' }), + ); + expect({ pooled: await read(), held: onHeld }).toEqual({ + pooled: [{ tz: '+03:00', sl: '7', nbe: '0' }], + held: { tz: '+03:00' }, + }); + } finally { + await held.release(); + } + }, + { sessionSettings: { time_zone: '+03:00', sql_select_limit: '7' } }, + ), + ); + }); + + it('sends a session setting as a parameter, so a value with a quote stays one value, and reports a setting MySQL refuses at once, as a SqlQueryError', async (test) => { + await withMysqlDatabase(test, ({ url }) => + withDriver( + url, + async (driver) => { + const started = Date.now(); + const error = await driver + .execute({ sql: 'SELECT 1' }) + .catch((thrown: unknown) => thrown); + expect({ + error: Object(error), + fast: Date.now() - started < 2000, + }).toMatchObject({ + error: { + kind: 'sql_query', + sqlState: 'HY000', + message: expect.stringContaining("time zone: '+00:00' , sql_mode = 'ANSI_QUOTES'"), + }, + fast: true, + }); + }, + { sessionSettings: { time_zone: "+00:00' , sql_mode = 'ANSI_QUOTES" } }, + ), + ); + }); + + it('leaves a connection usable after its caller stops reading a stream early', async (test) => { + await withMysqlDatabase(test, ({ url }) => + withDriver( + url, + async (driver) => { + const connection = await driver.acquireConnection(); + try { + for await (const row of connection.query({ sql: numbers })) { + expect(row).toEqual({ n: '1' }); + break; + } + expect(await collect(connection.query({ sql: 'SELECT 7 AS n' }))).toEqual([{ n: '7' }]); + } finally { + await connection.release(); + } + }, + streaming, + ), + ); + }); + + it('stops a pooled stream the caller breaks out of without reading the rest of the result', async (test) => { + await withMysqlDatabase(test, ({ url }) => + withDriver( + url, + async (driver) => { + const started = Date.now(); + for await (const _row of driver.query({ sql: slowRows })) break; + const elapsedMs = Date.now() - started; + expect({ + quick: elapsedMs < 3000, + next: await collect(driver.query({ sql: 'SELECT 1 AS n' })), + }).toEqual({ quick: true, next: [{ n: '1' }] }); + }, + streaming, + ), + ); + }); + + it('returns a pooled connection whose stream the caller stops early to the pool when the whole result has arrived, opening no connection', async (test) => { + await withMysqlDatabase(test, ({ url, name }) => + withCountedUser(url, name, async (userUrl, opened) => { + await withDriver( + userUrl.toString(), + async (driver) => { + const first = await connectionId(driver); + const before = await opened(); + for (let round = 0; round < 10; round += 1) { + for (const sql of [numbers, twoHundredNumbers]) { + for await (const _row of driver.query({ sql })) break; + } + } + expect({ + opened: (await opened()) - before, + same: (await connectionId(driver)) === first, + }).toEqual({ + opened: 0, + same: true, + }); + }, + single, ); - expect(row).toEqual({ tz: '+00:00', nbe: '0' }); }), ); }); - it('leaves a connection usable after its caller stops reading a stream early', async (test) => { + it('destroys a pooled connection whose stream the caller stops early while a large rest of the result is still to come', async (test) => { + await withMysqlDatabase(test, ({ url, name }) => + withCountedUser(url, name, async (userUrl, opened) => { + await withDriver( + userUrl.toString(), + async (driver) => { + const first = await connectionId(driver); + const before = await opened(); + for await (const _row of driver.query({ sql: slowRows })) break; + const next = await connectionId(driver); + expect({ opened: (await opened()) - before > 0, replaced: next !== first }).toEqual({ + opened: true, + replaced: true, + }); + }, + single, + ); + }), + ); + }); + + it('runs a statement awaited inside the loop over a query on the same transaction, because a query reads its whole result first, as Postgres does without a cursor', async (test) => { await withMysqlDatabase(test, ({ url }) => withDriver(url, async (driver) => { + await driver.execute({ sql: 'CREATE TABLE t (n INT)' }); const connection = await driver.acquireConnection(); - try { - for await (const row of connection.query({ sql: numbers })) { - expect(row).toEqual({ n: '1' }); - break; + const transaction = await connection.beginTransaction(); + const inserted: unknown[] = []; + for await (const row of transaction.query<{ n: string }>({ sql: numbers })) { + inserted.push( + await transaction.execute({ sql: 'INSERT INTO t VALUES (?1)', params: [row.n] }), + ); + } + await transaction.commit(); + await connection.release(); + expect({ + inserted, + after: await collect(driver.query({ sql: 'SELECT n FROM t ORDER BY n' })), + }).toEqual({ + inserted: [{ affectedRows: 1 }, { affectedRows: 1 }, { affectedRows: 1 }], + after: [{ n: '1' }, { n: '2' }, { n: '3' }], + }); + }), + ); + }); + + it('runs a statement sent on a transaction while a stream on it is still arriving after the stream ends, and a commit sent after it after it, as the Postgres driver does with a cursor', async (test) => { + await withMysqlDatabase(test, ({ url }) => + withDriver( + url, + async (driver) => { + await driver.execute({ sql: 'CREATE TABLE t (n INT)' }); + const connection = await driver.acquireConnection(); + const transaction = await connection.beginTransaction(); + let lastRowAt = 0; + let insertedAt = 0; + let inserted: Promise | undefined; + let committed: Promise | undefined; + for await (const _row of transaction.query({ sql: tenSlowRows })) { + lastRowAt = performance.now(); + inserted ??= transaction + .execute({ sql: 'INSERT INTO t VALUES (?1)', params: [1] }) + .then((stats) => { + insertedAt = performance.now(); + return stats; + }); + committed ??= transaction.commit(); } - expect(await collect(connection.query({ sql: 'SELECT 7 AS n' }))).toEqual([{ n: '7' }]); + const stats = await inserted; + await committed; + await connection.release(); + expect({ + insertedAfterLastRow: insertedAt >= lastRowAt, + stats, + after: await collect(driver.query({ sql: 'SELECT n FROM t' })), + }).toEqual({ + insertedAfterLastRow: true, + stats: { affectedRows: 1 }, + after: [{ n: '1' }], + }); + }, + streaming, + ), + ); + }); + + it('runs statements on a transaction in the order they are sent, so a prepared statement sent before a rollback is rolled back', async (test) => { + await withMysqlDatabase(test, ({ url }) => + withDriver(url, async (driver) => { + await driver.execute({ sql: 'CREATE TABLE t (n INT)' }); + const connection = await driver.acquireConnection(); + try { + const transaction = await connection.beginTransaction(); + await Promise.all([ + transaction.execute({ sql: 'INSERT INTO t VALUES (?1)', params: [1] }), + transaction.rollback(), + ]); } finally { await connection.release(); } + expect(await collect(driver.query({ sql: 'SELECT n FROM t' }))).toEqual([]); }), ); }); - it('stops a pooled stream the caller breaks out of without reading the rest of the result', async (test) => { - await withMysqlDatabase(test, ({ url }) => - withDriver(url, async (driver) => { - const started = Date.now(); - for await (const _row of driver.query({ sql: slowRows })) break; - const elapsedMs = Date.now() - started; + it('destroys a held connection released while a stream on it is still open, so the pool and close() go on', async (test) => { + await withMysqlDatabase(test, async ({ url }) => { + const driver = mysqlRuntimeDriver.create(single); + await driver.connect({ kind: 'url', url }); + const connection = await driver.acquireConnection(); + const transaction = await connection.beginTransaction(); + const rows = transaction.query<{ cid: string }>({ sql: slowRows })[Symbol.asyncIterator](); + const first = await rows.next(); + await connection.release(); + const [after] = await collect( + driver.query<{ cid: string }>({ sql: 'SELECT CONNECTION_ID() AS cid' }), + ); + await driver.close(); + expect({ + newConnection: after?.cid !== (first.done ? undefined : first.value.cid), + state: driver.state, + }).toEqual({ newConnection: true, state: 'closed' }); + }); + }); + + it('fails a statement at once when MySQL refuses the sign-in or the account needs the server public key, and neither is transient, as on Postgres', async (test) => { + await withMysqlDatabase(test, async ({ url, name }) => { + const admin = await mysqlControlDriver.create(url); + const user = `${name.slice(-12)}_s`; + try { + await admin.query( + `CREATE USER '${user}'@'%' IDENTIFIED WITH caching_sha2_password BY 'pw-sign-in'`, + ); + const failure = async (password: string, allowPublicKeyRetrieval: boolean) => { + const asUser = new URL(url); + asUser.username = user; + asUser.password = password; + asUser.searchParams.set('allowPublicKeyRetrieval', String(allowPublicKeyRetrieval)); + const started = Date.now(); + const error = await withDriver( + asUser.toString(), + (driver) => driver.execute({ sql: 'SELECT 1' }), + { poolOptions: { connectionTimeoutMillis: 10_000 } }, + ).catch((thrown: unknown) => thrown); + return { error: Object(error), fast: Date.now() - started < 3000 }; + }; expect({ - quick: elapsedMs < 3000, - next: await collect(driver.query({ sql: 'SELECT 1 AS n' })), - }).toEqual({ quick: true, next: [{ n: '1' }] }); - }), + publicKey: await failure('pw-sign-in', false), + password: await failure('wrong', true), + }).toMatchObject({ + publicKey: { + error: { kind: 'sql_connection', transient: false }, + fast: true, + }, + password: { + error: { kind: 'sql_query', sqlState: '28000' }, + fast: true, + }, + }); + } finally { + await admin.query(`DROP USER IF EXISTS '${user}'@'%'`).catch(() => undefined); + await admin.close(); + } + }); + }); + + it('runs a statement waiting for a pooled connection once one is free, when a statement on another connection fails with an error MySQL also refuses a sign-in with', async (test) => { + await withMysqlDatabase(test, ({ url, name }) => + withDriver( + url, + async (driver) => { + const user = `${name.slice(-12)}_p`; + const held = await driver.acquireConnection(); + try { + const sleeping = driver.execute({ sql: 'SELECT SLEEP(1)' }); + await new Promise((resolve) => setTimeout(resolve, 200)); + const waiting = collect(driver.query({ sql: 'SELECT 1 AS n' })); + await new Promise((resolve) => setTimeout(resolve, 200)); + const failed = await held + .execute({ sql: `CREATE USER '${user}'@'%' IDENTIFIED WITH no_such_plugin` }) + .catch((error: unknown) => error); + expect({ failed, waiting: await waiting, slept: await sleeping }).toEqual({ + failed: expect.objectContaining({ kind: 'sql_query', sqlState: 'HY000' }), + waiting: [{ n: '1' }], + slept: { affectedRows: 0 }, + }); + } finally { + await held.execute({ sql: `DROP USER IF EXISTS '${user}'@'%'` }); + await held.release(); + } + }, + { poolOptions: { max: 2 } }, + ), ); }); - it('gives up waiting for a pooled connection after the pool timeout', async (test) => { + it('fails a statement at once as not transient when the server refuses the connection, as on Postgres', async (test) => { await withMysqlDatabase(test, async ({ url }) => { - const limited = new URL(url); - limited.searchParams.set('connectionLimit', '1'); - limited.searchParams.set('poolTimeout', '200'); - await withDriver(limited.toString(), async (driver) => { - const held = await driver.acquireConnection(); - const waiting = collect(driver.query({ sql: 'SELECT 1 AS n' })); - await expect(waiting).rejects.toMatchObject({ code: 'DRIVER.CONNECTION_FAILED' }); - await held.release(); - expect(await collect(driver.query({ sql: 'SELECT 2 AS n' }))).toEqual([{ n: '2' }]); + const closedPort = new URL(url); + closedPort.hostname = '127.0.0.1'; + closedPort.port = '1'; + const started = Date.now(); + const error = await withDriver( + closedPort.toString(), + (driver) => driver.execute({ sql: 'SELECT 1' }), + { poolOptions: { connectionTimeoutMillis: 10_000 } }, + ).catch((thrown: unknown) => thrown); + expect({ error, fast: Date.now() - started < 3000 }).toEqual({ + error: expect.objectContaining({ kind: 'sql_connection', transient: false }), + fast: true, }); }); }); + it('runs a statement once the server accepts connections again, after an earlier statement failed because it refused them, as pg.Pool tries each connect afresh', async (test) => { + await withMysqlDatabase(test, async ({ url }) => { + const server = new URL(url); + const port = await freePort(); + const refusing = new URL(url); + refusing.hostname = '127.0.0.1'; + refusing.port = String(port); + const proxy = createServer((client) => { + const upstream = connect(Number(server.port), server.hostname); + client.pipe(upstream).pipe(client); + client.on('error', () => upstream.destroy()); + upstream.on('error', () => client.destroy()); + }); + try { + const outcomes = await withDriver( + refusing.toString(), + async (driver) => { + const refused = await driver + .execute({ sql: 'SELECT 1' }) + .catch((error: unknown) => error); + await new Promise((resolve) => setTimeout(resolve, 700)); + await new Promise((resolve) => proxy.listen(port, '127.0.0.1', resolve)); + const afterReturn = await collect(driver.query({ sql: 'SELECT 2 AS n' })); + return { refused, afterReturn }; + }, + { poolOptions: { connectionTimeoutMillis: 500 } }, + ); + expect(outcomes).toEqual({ + refused: expect.objectContaining({ kind: 'sql_connection', transient: false }), + afterReturn: [{ n: '2' }], + }); + } finally { + await new Promise((resolve) => proxy.close(resolve)); + } + }); + }); + + it('returns each connection the pool hands to a statement that already failed straight to the pool, so the pool serves every such request and then runs the next statement on its one connection', async (test) => { + await withMysqlDatabase(test, ({ url, name }) => + withCountedUser(url, name, async (userUrl, opened) => { + const port = await freePort(); + const refusing = new URL(userUrl); + refusing.hostname = '127.0.0.1'; + refusing.port = String(port); + const proxy = createServer((client) => { + const upstream = connect(Number(userUrl.port), userUrl.hostname); + client.pipe(upstream).pipe(client); + client.on('error', () => upstream.destroy()); + upstream.on('error', () => client.destroy()); + }); + try { + const before = await opened(); + const outcomes = await withDriver( + refusing.toString(), + async (driver) => { + const refused = await Promise.all( + Array.from({ length: 5 }, (_, n) => + driver.execute({ sql: `SELECT ${n}` }).catch((error: unknown) => error), + ), + ); + await new Promise((resolve) => proxy.listen(port, '127.0.0.1', resolve)); + const started = Date.now(); + const afterReturn = await collect(driver.query({ sql: 'SELECT 2 AS n' })); + const waited = Date.now() - started; + const next = await collect(driver.query({ sql: 'SELECT 3 AS n' })); + return { refused, afterReturn, next, beforeTimeout: waited < 2000 }; + }, + { poolOptions: { max: 1, connectionTimeoutMillis: 2500 } }, + ); + expect({ ...outcomes, opened: (await opened()) - before }).toEqual({ + refused: Array.from({ length: 5 }, () => + expect.objectContaining({ kind: 'sql_connection', transient: false }), + ), + afterReturn: [{ n: '2' }], + next: [{ n: '3' }], + beforeTimeout: true, + opened: 1, + }); + } finally { + await new Promise((resolve) => proxy.close(resolve)); + } + }), + ); + }); + + it('gives up waiting for a pooled connection after connectionTimeoutMillis', async (test) => { + await withMysqlDatabase(test, ({ url }) => + withDriver( + url, + async (driver) => { + const held = await driver.acquireConnection(); + const waiting = collect(driver.query({ sql: 'SELECT 1 AS n' })); + await expect(waiting).rejects.toMatchObject({ kind: 'sql_connection', transient: true }); + await held.release(); + expect(await collect(driver.query({ sql: 'SELECT 2 AS n' }))).toEqual([{ n: '2' }]); + }, + { poolOptions: { max: 1, connectionTimeoutMillis: 200 } }, + ), + ); + }); + + it('opens no connection before the first statement needs one, as pg.Pool does', async (test) => { + await withMysqlDatabase(test, ({ url, name }) => + withCountedUser(url, name, async (userUrl, opened) => { + const before = await opened(); + await withDriver(userUrl.toString(), async (driver) => { + await new Promise((resolve) => setTimeout(resolve, 300)); + const atConnect = (await opened()) - before; + await collect(driver.query({ sql: 'SELECT 1 AS n' })); + expect({ atConnect, afterOne: (await opened()) - before }).toEqual({ + atConnect: 0, + afterOne: 1, + }); + }); + }), + ); + }); + it('commits and rolls back a transaction on one connection, and reports affected rows', async (test) => { await withMysqlDatabase(test, ({ url }) => withDriver(url, async (driver) => { @@ -369,7 +994,7 @@ describe('MySQL runtime driver', { timeout: timeouts.databaseOperation }, () => expect({ stats, rows: await collect(driver.query({ sql: 'SELECT id FROM t ORDER BY id' })), - }).toEqual({ stats: { affectedRows: 2 }, rows: [{ id: 3 }, { id: 4 }] }); + }).toEqual({ stats: { affectedRows: 2 }, rows: [{ id: '3' }, { id: '4' }] }); } finally { await connection.release(); } @@ -379,7 +1004,7 @@ describe('MySQL runtime driver', { timeout: timeouts.databaseOperation }, () => it('waits in close() for a stream in flight to finish', async (test) => { await withMysqlDatabase(test, async ({ url }) => { - const driver = mysqlRuntimeDriver.create(); + const driver = mysqlRuntimeDriver.create(streaming); await driver.connect({ kind: 'url', url }); const iterator = driver.query({ sql: numbers })[Symbol.asyncIterator](); const first = await iterator.next(); @@ -423,16 +1048,19 @@ describe('MySQL runtime driver', { timeout: timeouts.databaseOperation }, () => }); }); - it('ends a stream whose connection is lost with DRIVER.NOT_CONNECTED, and close() still finishes', async (test) => { + it('ends a stream whose connection is lost with a transient SqlConnectionError, and close() still finishes', async (test) => { await withMysqlDatabase(test, async ({ url }) => { const admin = await mysqlControlDriver.create(url); - const driver = mysqlRuntimeDriver.create(); + const driver = mysqlRuntimeDriver.create(streaming); await driver.connect({ kind: 'url', url }); try { const rows = driver.query<{ cid: string }>({ sql: slowRows })[Symbol.asyncIterator](); const first = await rows.next(); await admin.query(`KILL ${first.done ? '' : first.value.cid}`); - await expect(drain(rows)).rejects.toMatchObject({ code: 'DRIVER.NOT_CONNECTED' }); + await expect(drain(rows)).rejects.toMatchObject({ + kind: 'sql_connection', + transient: true, + }); await driver.close(); expect(driver.state).toBe('closed'); } finally { @@ -444,7 +1072,7 @@ describe('MySQL runtime driver', { timeout: timeouts.databaseOperation }, () => it('stops reading a held connection that is lost after the caller broke out of its stream', async (test) => { await withMysqlDatabase(test, async ({ url }) => { const admin = await mysqlControlDriver.create(url); - const driver = mysqlRuntimeDriver.create(); + const driver = mysqlRuntimeDriver.create(streaming); await driver.connect({ kind: 'url', url }); try { const connection = await driver.acquireConnection(); @@ -463,6 +1091,30 @@ describe('MySQL runtime driver', { timeout: timeouts.databaseOperation }, () => }); }); + it('leaves the values of a statement MySQL refuses when it runs out of its error, from a stream, from a whole result and from execute', async (test) => { + await withMysqlDatabase(test, async ({ url }) => { + const value = 'distinctive-value-5d1c9e'; + const request = { sql: 'INSERT INTO t VALUES (?1)', params: [value] }; + const errors = async (driver: ReturnType) => [ + await collect(driver.query(request)).catch((error: unknown) => error), + await driver.execute(request).catch((error: unknown) => error), + ]; + await withDriver(url, (driver) => + driver.execute({ sql: "CREATE TABLE t (s VARCHAR(40), CHECK (s = 'allowed'))" }), + ); + const all = [ + ...(await withDriver(url, errors, streaming)), + ...(await withDriver(url, errors)), + ]; + expect( + all.map((error) => ({ + sqlState: Object(error).sqlState, + leaked: inspect(error, { depth: 10 }).includes(value), + })), + ).toEqual(Array.from({ length: 4 }, () => ({ sqlState: '23514', leaked: false }))); + }); + }); + it('reports a query error with its SQLSTATE', async (test) => { await withMysqlDatabase(test, ({ url }) => withDriver(url, async (driver) => { diff --git a/packages/3-targets/mysql/3-driver/test/runtime-driver.test.ts b/packages/3-targets/mysql/3-driver/test/runtime-driver.test.ts index 1a238383ce0a..fcef7690b484 100644 --- a/packages/3-targets/mysql/3-driver/test/runtime-driver.test.ts +++ b/packages/3-targets/mysql/3-driver/test/runtime-driver.test.ts @@ -1,149 +1,639 @@ import { EventEmitter } from 'node:events'; import { Readable } from 'node:stream'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import mysqlRuntimeDriver from '../src/core/runtime/runtime-driver'; import { SESSION_SETUP } from '../src/core/session-setup'; -class FakeConnection extends EventEmitter { - readonly release = vi.fn(); - readonly destroy = vi.fn(); - readonly end = vi.fn(); - prepareError: unknown; - streamFails = true; +/** How a fake result arrives: row by row as the caller reads (`rows`), all at once with its end before the caller reads (`received`), or as an error on the stream (`fails`) or thrown by `executeStream` itself (`throws`). `hidesState` hides Node's internal `_readableState` from the driver. */ +type StreamBehaviour = ( + | { kind: 'rows'; rows: unknown[] } + | { kind: 'received'; rows: unknown[] } + | { kind: 'fails'; error: Error } + | { kind: 'throws'; error: Error } +) & { hidesState?: boolean }; - readonly constantSql: unknown[] = []; +/** `stream` as the driver sees it when Node keeps no `_readableState`; the stream's own methods still run on the real stream. */ +function withoutReadableState(stream: Readable): Readable { + return new Proxy(stream, { + get(target, property) { + if (property === '_readableState') return undefined; + const value: unknown = Reflect.get(target, property, target); + return typeof value === 'function' ? value.bind(target) : value; + }, + }); +} - query(sql: unknown, callback: (error: unknown, result: unknown) => void) { - this.constantSql.push(sql); - queueMicrotask(() => callback(null, { affectedRows: 0 })); - return new EventEmitter(); - } +function mariadbError(fatal: boolean): Error { + return Object.assign( + new Error(fatal ? 'socket has unexpectedly been closed' : "Table 't' doesn't exist"), + { + errno: fatal ? 45009 : 1146, + sqlState: fatal ? '08S01' : '42S02', + sqlMessage: fatal ? 'socket has unexpectedly been closed' : "Table 't' doesn't exist", + fatal, + }, + ); +} - prepare(_sql: unknown, callback: (error: unknown, statement: unknown) => void) { - queueMicrotask(() => callback(this.prepareError ?? null, { parameters: [] })); - } +class FakeStatement { + parameterCount = 0; + executeError: Error | undefined; + executeResult: unknown = { affectedRows: 3 }; + readonly execute = vi.fn(async (_values?: unknown[]) => { + if (this.executeError !== undefined) throw this.executeError; + return this.executeResult; + }); + readonly close = vi.fn(); + readonly streamClosed = vi.fn(); + constructor(private readonly behaviour: StreamBehaviour) {} - execute(_options: unknown, callback?: (error: unknown, result: unknown) => void) { - if (callback) { - queueMicrotask(() => callback(null, { affectedRows: 0 })); - return new EventEmitter(); + executeStream(_values?: unknown[]): Readable { + const behaviour = this.behaviour; + if (behaviour.kind === 'throws') throw behaviour.error; + const pending = behaviour.kind === 'rows' ? [...behaviour.rows] : []; + const stream = new Readable({ + objectMode: true, + read() { + if (behaviour.kind === 'fails') { + queueMicrotask(() => this.emit('error', behaviour.error)); + return; + } + if (behaviour.kind === 'rows') this.push(pending.length > 0 ? pending.shift() : null); + }, + }); + if (behaviour.kind === 'received') { + for (const row of behaviour.rows) stream.push(row); + stream.push(null); } - const command = new EventEmitter(); - const fails = this.streamFails; - return Object.assign(command, { - stream: () => - new Readable({ - objectMode: true, - read() { - if (!fails) { - this.push(null); - queueMicrotask(() => command.emit('end')); - return; - } - this.destroy( - Object.assign(new Error('Server shutdown in progress'), { - fatal: true, - sqlState: '08S01', - }), - ); - }, - }), + const closable = Object.assign(stream, { + close: () => { + this.streamClosed(); + pending.length = 0; + }, }); + return behaviour.hidesState ? withoutReadableState(closable) : closable; } } -const connection = new FakeConnection(); +class FakeConnection extends EventEmitter { + readonly info = { threadId: 1 }; + readonly release = vi.fn(async () => {}); + readonly destroy = vi.fn(); + readonly beginTransaction = vi.fn(async () => {}); + readonly commit = vi.fn(async () => {}); + readonly rollback = vi.fn(async () => {}); + textRows: unknown[] = [{ n: 1 }, { n: 2 }, { n: 3 }]; + readonly query = vi.fn(async (_sql: string) => this.textRows); + readonly queryStream = vi.fn((_sql: string) => new FakeStatement(this.behaviour).executeStream()); + readonly prepared: { sql: string; statement: FakeStatement }[] = []; + behaviour: StreamBehaviour = { kind: 'rows', rows: [{ n: 1 }, { n: 2 }, { n: 3 }] }; + parameterCount = 1; + prepareError: Error | undefined; + executeError: Error | undefined; + executeResult: unknown = { affectedRows: 3 }; + + async prepare(sql: string): Promise { + if (this.prepareError !== undefined) throw this.prepareError; + const statement = new FakeStatement(this.behaviour); + statement.parameterCount = this.parameterCount; + statement.executeError = this.executeError; + statement.executeResult = this.executeResult; + this.prepared.push({ sql, statement }); + return statement; + } +} -vi.mock('mysql2', async (importOriginal) => ({ - ...(await importOriginal()), - createPool: () => ({ - getConnection: (callback: (error: unknown, conn: FakeConnection) => void) => - callback(null, connection), - end: (callback: () => void) => callback(), - }), +class FakePool extends EventEmitter { + readonly end = vi.fn(async () => {}); + /** When set, `getConnection` waits until the test hands the connection over with `handOver`. */ + waits = false; + readonly #waiting: ((connection: FakeConnection) => void)[] = []; + constructor( + readonly options: { readonly logger?: { readonly error?: (error: Error) => void } }, + private readonly connection: FakeConnection, + ) { + super(); + } + /** A new wrapper over the one connection, as the package hands out for each `getConnection`. */ + #wrapper(): FakeConnection { + return Object.create(this.connection); + } + async getConnection(): Promise { + if (!this.waits) return this.#wrapper(); + return new Promise((resolve) => this.#waiting.push(resolve)); + } + handOver(): void { + for (const resolve of this.#waiting.splice(0)) resolve(this.#wrapper()); + } + logError(error: Error): void { + this.options.logger?.error?.(error); + } +} + +const state = vi.hoisted(() => ({ pools: [] as unknown[] })); +let connection = new FakeConnection(); + +vi.mock('mariadb', () => ({ + createPool: (options: ConstructorParameters[0]) => { + const pool = new FakePool(options, connection); + state.pools.push(pool); + return pool; + }, })); -describe('MySQL runtime driver with a fatal stream error', () => { - it('destroys the connection instead of returning it to the pool', async () => { - const driver = mysqlRuntimeDriver.create(); +async function connected(options?: Parameters[0]) { + const driver = mysqlRuntimeDriver.create(options); + await driver.connect({ kind: 'url', url: 'mysql://root@localhost/db' }); + return driver; +} + +const streaming = { stream: true } as const; + +/** A query with a value, so it runs as a prepared statement. */ +const valued = { sql: 'SELECT n FROM t WHERE n > ?1', params: [0] }; + +function firstStatement(): FakeStatement { + const statement = connection.prepared[0]?.statement; + if (statement === undefined) throw new Error('no statement was prepared'); + return statement; +} + +function lastPool(): FakePool { + const pool = state.pools.at(-1); + if (!(pool instanceof FakePool)) throw new Error('no pool'); + return pool; +} + +async function collect(rows: AsyncIterable): Promise { + const out: Row[] = []; + for await (const row of rows) out.push(row); + return out; +} + +beforeEach(() => { + connection = new FakeConnection(); +}); + +describe('MySQL runtime driver and its pool', () => { + it('creates the pool with the session setup, the statement cache off and the pool options, and no value in its SQL text', async () => { + const driver = mysqlRuntimeDriver.create( + { poolOptions: { connectionTimeoutMillis: 250 } }, + { time_zone: '+00:00' }, + ); await driver.connect({ kind: 'url', url: 'mysql://root@localhost/db' }); - const rows = driver.query({ sql: 'SELECT 1' })[Symbol.asyncIterator](); - await expect(rows.next()).rejects.toMatchObject({ sqlState: '08S01' }); + expect(lastPool().options).toMatchObject({ + initSql: [...SESSION_SETUP], + prepareCacheLength: 0, + acquireTimeout: 250, + minimumIdle: 0, + }); + }); + + it('sets the session settings with one prepared SET the first time it gets each connection, as the Postgres driver sets them with parameters, an integer setting as a number', async () => { + const driver = mysqlRuntimeDriver.create({}, { time_zone: '+00:00', sql_select_limit: '5' }); + await driver.connect({ kind: 'url', url: 'mysql://root@localhost/db' }); + await driver.execute({ sql: 'SELECT 1' }); + await driver.execute({ sql: 'SELECT 2' }); + const connectionHeld = await driver.acquireConnection(); + await connectionHeld.release(); + expect({ + prepared: connection.prepared.map(({ sql, statement }) => ({ + sql, + values: statement.execute.mock.calls, + closed: statement.close.mock.calls.length, + })), + text: connection.query.mock.calls.map(([sql]) => sql), + }).toEqual({ + prepared: [ + { + sql: 'SET SESSION `time_zone` = ?, `sql_select_limit` = ?', + values: [[['+00:00', 5n]]], + closed: 1, + }, + ], + text: ['SELECT 1', 'SELECT 2'], + }); + }); + + it('destroys a connection whose session settings MySQL refuses, and reports the error at once as the SqlQueryError MySQL raised', async () => { + connection.executeError = Object.assign(new Error("Unknown or incorrect time zone: 'x'"), { + errno: 1298, + sqlState: 'HY000', + sqlMessage: "Unknown or incorrect time zone: 'x'", + fatal: false, + }); + const driver = mysqlRuntimeDriver.create({}, { time_zone: 'x' }); + await driver.connect({ kind: 'url', url: 'mysql://root@localhost/db' }); + await expect(driver.execute({ sql: 'SELECT 1' })).rejects.toMatchObject({ + kind: 'sql_query', + sqlState: 'HY000', + }); expect({ destroyed: connection.destroy.mock.calls.length, released: connection.release.mock.calls.length, - }).toEqual({ destroyed: 1, released: 0 }); + ran: connection.query.mock.calls.length, + }).toEqual({ destroyed: 1, released: 0, ran: 0 }); + await driver.close(); }); -}); -describe('MySQL runtime driver at the server limit on prepared statements', () => { - it('names statementCacheSize and connectionLimit when MySQL refuses to prepare another statement', async () => { - connection.prepareError = Object.assign( - new Error("Can't create more than max_prepared_stmt_count statements"), - { - errno: 1461, - sqlState: '42000', - }, + it('fails the statements waiting for a connection at once when the package reports a sign-in that cannot succeed, as pg.Pool rejects a connect, and releases a connection handed over later', async () => { + const driver = await connected(); + const pool = lastPool(); + pool.waits = true; + const waiting = [driver.execute({ sql: 'SELECT 1' }), driver.execute({ sql: 'SELECT 2' })]; + await new Promise((resolve) => setImmediate(resolve)); + pool.logError( + Object.assign(new Error("Access denied for user 'app'"), { + errno: 1045, + sqlState: '28000', + sqlMessage: "Access denied for user 'app'", + fatal: true, + }), ); - try { - const driver = mysqlRuntimeDriver.create(); - await driver.connect({ kind: 'url', url: 'mysql://root@localhost/db' }); - await expect(driver.execute({ sql: 'SELECT 1' })).rejects.toMatchObject({ - code: 'DRIVER.PREPARE_FAILED', - fix: expect.stringContaining('statementCacheSize'), - }); - } finally { - connection.prepareError = undefined; + const refused = expect.objectContaining({ kind: 'sql_query', sqlState: '28000' }); + expect(await Promise.allSettled(waiting)).toEqual([ + { status: 'rejected', reason: refused }, + { status: 'rejected', reason: refused }, + ]); + pool.handOver(); + await new Promise((resolve) => setImmediate(resolve)); + expect({ + released: connection.release.mock.calls.length, + ran: connection.query.mock.calls.length, + }).toEqual({ released: 2, ran: 0 }); + await driver.close(); + }); + + it('fails the statements waiting for a connection at once, as not transient, when the package reports a refused connection or an unknown or unreachable host, as pg.Pool does', async () => { + const outcomes = []; + for (const code of ['ECONNREFUSED', 'ENOTFOUND', 'EHOSTUNREACH']) { + const driver = await connected(); + const pool = lastPool(); + pool.waits = true; + const waiting = driver.execute({ sql: 'SELECT 1' }); + await new Promise((resolve) => setImmediate(resolve)); + pool.logError( + Object.assign(new Error(`connect ${code}`), { + code, + errno: -1, + fatal: true, + sqlState: 'HY000', + }), + ); + outcomes.push(await waiting.catch((error: unknown) => error)); + await driver.close(); } + expect(outcomes).toEqual([ + expect.objectContaining({ kind: 'sql_connection', transient: false }), + expect.objectContaining({ kind: 'sql_connection', transient: false }), + expect.objectContaining({ kind: 'sql_connection', transient: false }), + ]); + }); + + it('leaves the statements waiting for a connection waiting when the package reports the error of a statement, even one MySQL also refuses a sign-in with, or a lost connection', async () => { + const driver = await connected(); + const pool = lastPool(); + pool.waits = true; + const waiting = driver.execute({ sql: 'SELECT 1' }); + await new Promise((resolve) => setImmediate(resolve)); + pool.logError( + Object.assign(new Error("Plugin 'no_such_plugin' is not loaded"), { + errno: 1524, + sqlState: 'HY000', + sqlMessage: "Plugin 'no_such_plugin' is not loaded", + fatal: false, + }), + ); + pool.logError(mariadbError(true)); + pool.logError( + Object.assign(new Error('read ECONNRESET'), { + code: 'ECONNRESET', + errno: -54, + fatal: true, + sqlState: 'HY000', + }), + ); + pool.handOver(); + expect(await waiting).toEqual({ affectedRows: 0 }); + await driver.close(); }); -}); -describe('MySQL runtime driver and the pool', () => { - beforeEach(() => { - connection.streamFails = false; + it('sends a statement that arrives after a connection failure to the pool, which runs it once the pool opens a connection, as pg.Pool tries each connect afresh', async () => { + const driver = await connected(); + const pool = lastPool(); + pool.waits = true; + const first = driver.execute({ sql: 'SELECT 1' }); + await new Promise((resolve) => setImmediate(resolve)); + pool.logError( + Object.assign(new Error('connect ECONNREFUSED'), { + code: 'ECONNREFUSED', + errno: -61, + fatal: true, + sqlState: 'HY000', + }), + ); + const failed = await first.catch((error: unknown) => error); + const later = driver.execute({ sql: 'SELECT 2' }); + await new Promise((resolve) => setImmediate(resolve)); + pool.handOver(); + expect({ failed, later: await later }).toEqual({ + failed: expect.objectContaining({ kind: 'sql_connection', transient: false }), + later: { affectedRows: 0 }, + }); + await driver.close(); }); - afterEach(() => { - connection.streamFails = true; + + it('listens to the pool, so a connection it fails to open does not crash the process', async () => { + await connected(); + expect(() => lastPool().emit('error', new Error('connect ECONNREFUSED'))).not.toThrow(); }); - async function connected() { - const driver = mysqlRuntimeDriver.create(); - await driver.connect({ kind: 'url', url: 'mysql://root@localhost/db' }); - await driver.execute({ sql: 'SELECT 0' }); - const setups = () => connection.constantSql.filter((sql) => sql === SESSION_SETUP[0]).length; - const start = { - setups: setups(), + it('runs a statement with values prepared and one without over the text protocol, as pg does, returns the connection after each, and closes each prepared statement', async () => { + const driver = await connected(); + await driver.execute({ sql: 'SELECT ?1', params: [1] }); + await driver.execute({ sql: 'START TRANSACTION' }); + await collect(driver.query(valued)); + await collect(driver.query({ sql: 'SELECT n FROM t' })); + expect({ + released: connection.release.mock.calls.length, + destroyed: connection.destroy.mock.calls.length, + prepared: connection.prepared.map(({ sql, statement }) => [ + sql, + statement.close.mock.calls.length, + ]), + text: connection.query.mock.calls.map(([sql]) => sql), + }).toEqual({ + released: 4, + destroyed: 0, + prepared: [ + ['SELECT ?', 1], + ['SELECT n FROM t WHERE n > ?', 1], + ], + text: ['START TRANSACTION', 'SELECT n FROM t'], + }); + }); + + it('reads the whole result and returns the connection before the first row unless it streams, as Postgres does without a cursor', async () => { + connection.executeResult = [{ n: 1 }, { n: 2 }]; + const driver = await connected(); + const rows = driver.query(valued)[Symbol.asyncIterator](); + const first = await rows.next(); + const releasedAtFirstRow = connection.release.mock.calls.length; + expect({ + first, + releasedAtFirstRow, + rest: await collect({ [Symbol.asyncIterator]: () => rows }), + streamed: connection.prepared.map(({ statement }) => statement.execute.mock.calls.length), + }).toEqual({ + first: { done: false, value: { n: 1 } }, + releasedAtFirstRow: 1, + rest: [{ n: 2 }], + streamed: [1], + }); + }); + + it('streams the rows of a statement without values over the text protocol when it streams', async () => { + const driver = await connected(streaming); + expect({ + rows: await collect(driver.query({ sql: 'SELECT n FROM t' })), + streamed: connection.queryStream.mock.calls.map(([sql]) => sql), + released: connection.release.mock.calls.length, + }).toEqual({ + rows: [{ n: 1 }, { n: 2 }, { n: 3 }], + streamed: ['SELECT n FROM t'], + released: 1, + }); + }); + + it('reports the rows a statement changed', async () => { + const driver = await connected(); + expect(await driver.execute({ sql: 'DELETE FROM t WHERE n > ?1', params: [0] })).toEqual({ + affectedRows: 3, + }); + }); + + it('refuses a statement whose placeholders MySQL counts differently from the values, without running it', async () => { + connection.parameterCount = 2; + const driver = await connected(); + await expect(driver.execute({ sql: 'SELECT ?1', params: [1] })).rejects.toMatchObject({ + code: 'RUNTIME.PARAM_PLACEHOLDER_MISMATCH', + meta: { prepared: 2, params: 1 }, + }); + const statement = firstStatement(); + expect({ + executed: statement.execute.mock.calls.length, + closed: statement.close.mock.calls.length, + released: connection.release.mock.calls.length, + }).toEqual({ executed: 0, closed: 1, released: 1 }); + }); + + it('destroys a pooled connection whose stream the caller stops early while the rest of the result is still arriving', async () => { + const driver = await connected(streaming); + for await (const _row of driver.query(valued)) break; + const statement = firstStatement(); + expect({ + destroyed: connection.destroy.mock.calls.length, released: connection.release.mock.calls.length, + drained: statement.streamClosed.mock.calls.length, + }).toEqual({ destroyed: 1, released: 0, drained: 0 }); + }); + + it('drops the rest of a pooled stream the caller stops early once the whole result has arrived, and returns the connection to the pool', async () => { + connection.behaviour = { kind: 'received', rows: [{ n: 1 }, { n: 2 }, { n: 3 }] }; + const driver = await connected(streaming); + for await (const _row of driver.query(valued)) break; + const statement = firstStatement(); + expect({ destroyed: connection.destroy.mock.calls.length, - closed: connection.end.mock.calls.length, + released: connection.release.mock.calls.length, + drained: statement.streamClosed.mock.calls.length, + closed: statement.close.mock.calls.length, + }).toEqual({ destroyed: 0, released: 1, drained: 1, closed: 1 }); + }); + + it('destroys a pooled connection whose stream the caller stops early when Node keeps no record of whether the result has arrived', async () => { + connection.behaviour = { + kind: 'received', + rows: [{ n: 1 }, { n: 2 }, { n: 3 }], + hidesState: true, + }; + const driver = await connected(streaming); + for await (const _row of driver.query(valued)) break; + expect({ + destroyed: connection.destroy.mock.calls.length, + released: connection.release.mock.calls.length, + drained: firstStatement().streamClosed.mock.calls.length, + }).toEqual({ destroyed: 1, released: 0, drained: 0 }); + }); + + it('drops the rest of a stream on a held connection the caller stops early when Node keeps no record of whether the result has arrived, and keeps the connection', async () => { + connection.behaviour = { + kind: 'received', + rows: [{ n: 1 }, { n: 2 }, { n: 3 }], + hidesState: true, }; - const since = () => ({ - setups: setups() - start.setups, - released: connection.release.mock.calls.length - start.released, - destroyed: connection.destroy.mock.calls.length - start.destroyed, - closed: connection.end.mock.calls.length - start.closed, + const driver = await connected(streaming); + const held = await driver.acquireConnection(); + for await (const _row of held.query(valued)) break; + await held.release(); + expect({ + destroyed: connection.destroy.mock.calls.length, + released: connection.release.mock.calls.length, + drained: firstStatement().streamClosed.mock.calls.length, + }).toEqual({ destroyed: 0, released: 1, drained: 1 }); + }); + + it('closes the prepared statement and reports the error when executeStream throws, and returns the connection to the pool', async () => { + connection.behaviour = { kind: 'throws', error: mariadbError(false) }; + const driver = await connected(streaming); + await expect(collect(driver.query(valued))).rejects.toMatchObject({ + kind: 'sql_query', + sqlState: '42S02', }); - return { driver, since }; - } + expect({ + closed: firstStatement().close.mock.calls.length, + released: connection.release.mock.calls.length, + destroyed: connection.destroy.mock.calls.length, + }).toEqual({ closed: 1, released: 1, destroyed: 0 }); + }); - it('returns a pooled connection to the pool after every statement, a SET included, without setting it up again', async () => { - const { driver, since } = await connected(); - await driver.execute({ sql: 'SELECT 1' }); - await driver.execute({ sql: "SET time_zone = '+05:00'" }); - for await (const _row of driver.query({ sql: 'SET autocommit = 0' })) { + it('returns the connection to the pool after a statement error, from a stream, from execute and from prepare', async () => { + const outcomes = []; + for (const fail of ['stream', 'execute', 'prepare'] as const) { + connection = new FakeConnection(); + if (fail === 'stream') connection.behaviour = { kind: 'fails', error: mariadbError(false) }; + if (fail === 'execute') connection.executeError = mariadbError(false); + if (fail === 'prepare') connection.prepareError = mariadbError(false); + const driver = await connected(streaming); + const run = + fail === 'stream' + ? collect(driver.query(valued)) + : driver.execute({ sql: 'DELETE FROM t WHERE n > ?1', params: [0] }); + await expect(run).rejects.toMatchObject({ kind: 'sql_query', sqlState: '42S02' }); + outcomes.push({ + fail, + released: connection.release.mock.calls.length, + destroyed: connection.destroy.mock.calls.length, + closed: connection.prepared.map(({ statement }) => statement.close.mock.calls.length), + }); + } + expect(outcomes).toEqual([ + { fail: 'stream', released: 1, destroyed: 0, closed: [1] }, + { fail: 'execute', released: 1, destroyed: 0, closed: [1] }, + { fail: 'prepare', released: 1, destroyed: 0, closed: [] }, + ]); + }); + + it('destroys the connection when it is lost during prepare, from query as from execute', async () => { + const outcomes = []; + for (const call of ['query', 'execute'] as const) { + connection = new FakeConnection(); + connection.prepareError = mariadbError(true); + const driver = await connected(); + const run = + call === 'query' + ? collect(driver.query(valued)) + : driver.execute({ sql: 'DELETE FROM t WHERE n > ?1', params: [0] }); + await expect(run).rejects.toMatchObject({ kind: 'sql_connection', transient: true }); + outcomes.push({ + call, + released: connection.release.mock.calls.length, + destroyed: connection.destroy.mock.calls.length, + }); } - expect(since()).toEqual({ setups: 0, released: 3, destroyed: 0, closed: 0 }); + expect(outcomes).toEqual([ + { call: 'query', released: 0, destroyed: 1 }, + { call: 'execute', released: 0, destroyed: 1 }, + ]); + }); + + it('drops the rest of a stream on a held connection the caller stops early, and keeps the connection', async () => { + const driver = await connected(streaming); + const held = await driver.acquireConnection(); + for await (const _row of held.query(valued)) break; + expect(await collect(held.query(valued))).toEqual([{ n: 1 }, { n: 2 }, { n: 3 }]); + await held.release(); + const first = firstStatement(); + expect({ + destroyed: connection.destroy.mock.calls.length, + released: connection.release.mock.calls.length, + drained: first.streamClosed.mock.calls.length, + closed: first.close.mock.calls.length, + }).toEqual({ destroyed: 0, released: 1, drained: 1, closed: 1 }); + }); + + it('destroys the connection of a stream that loses it, and reports a SqlConnectionError', async () => { + connection.behaviour = { + kind: 'fails', + error: Object.assign(new Error('socket has unexpectedly been closed'), { + errno: 45009, + sqlState: '08S01', + sqlMessage: 'socket has unexpectedly been closed', + fatal: true, + }), + }; + const driver = await connected(streaming); + await expect(collect(driver.query(valued))).rejects.toMatchObject({ + kind: 'sql_connection', + }); + expect({ + destroyed: connection.destroy.mock.calls.length, + released: connection.release.mock.calls.length, + }).toEqual({ destroyed: 1, released: 0 }); }); - it('returns a held connection to the pool on release, after a transaction that ran a SET', async () => { - const { driver, since } = await connected(); + it('runs a transaction with the package transaction calls on the held connection, and returns it to the pool on release', async () => { + const driver = await connected(); + const held = await driver.acquireConnection(); + const committed = await held.beginTransaction(); + await committed.execute({ sql: 'SET foreign_key_checks = 0' }); + await committed.commit(); + const rolledBack = await held.beginTransaction(); + await rolledBack.rollback(); + await held.release(); + expect({ + begun: connection.beginTransaction.mock.calls.length, + committed: connection.commit.mock.calls.length, + rolledBack: connection.rollback.mock.calls.length, + text: connection.query.mock.calls.map(([sql]) => sql), + released: connection.release.mock.calls.length, + destroyed: connection.destroy.mock.calls.length, + }).toEqual({ + begun: 2, + committed: 1, + rolledBack: 1, + text: ['SET foreign_key_checks = 0'], + released: 1, + destroyed: 0, + }); + }); + + it('destroys a held connection released while a stream on it is still open, instead of waiting for the stream, so close() finishes', async () => { + const driver = await connected(streaming); const held = await driver.acquireConnection(); - await held.execute({ sql: 'SELECT 1' }); const transaction = await held.beginTransaction(); - await transaction.execute({ sql: 'SET foreign_key_checks = 0' }); - await transaction.execute({ sql: 'SELECT 2' }); - await transaction.commit(); + const rows = transaction.query(valued)[Symbol.asyncIterator](); + await rows.next(); await held.release(); - expect(since()).toEqual({ setups: 0, released: 1, destroyed: 0, closed: 0 }); + await driver.close(); + expect({ + destroyed: connection.destroy.mock.calls.length, + released: connection.release.mock.calls.length, + ended: lastPool().end.mock.calls.length, + }).toEqual({ destroyed: 1, released: 0, ended: 1 }); + }); + + it('ends the pool on close, after the connections it lent come back', async () => { + const driver = await connected(); + const held = await driver.acquireConnection(); + const closing = driver.close(); + await Promise.resolve(); + const endedBeforeRelease = lastPool().end.mock.calls.length; + await held.release(); + await closing; + expect({ endedBeforeRelease, ended: lastPool().end.mock.calls.length }).toEqual({ + endedBeforeRelease: 0, + ended: 1, + }); }); }); diff --git a/packages/3-targets/mysql/3-driver/test/server-text.test.ts b/packages/3-targets/mysql/3-driver/test/server-text.test.ts deleted file mode 100644 index 25f511e649c3..000000000000 --- a/packages/3-targets/mysql/3-driver/test/server-text.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -import type { TypeCast } from 'mysql2'; -import { describe, expect, it } from 'vitest'; -import { normalizeMysqlError } from '../src/core/normalize-error'; -import { serverText } from '../src/core/server-text'; - -type Field = Parameters unknown>>[0]; - -function field(type: string) { - const calls: string[] = []; - const value = { - type, - string: (encoding?: string) => { - calls.push(`string:${encoding}`); - return 'text'; - }, - buffer: () => { - calls.push('buffer'); - return Buffer.from('bytes'); - }, - } as unknown as Field; - return { value, calls }; -} - -const cast = serverText as Extract unknown>; - -describe('serverText', () => { - it.each([ - ['LONG', 'string:utf8'], - ['LONGLONG', 'string:utf8'], - ['NEWDECIMAL', 'string:utf8'], - ['DATETIME', 'string:utf8'], - ['TIMESTAMP', 'string:utf8'], - ['JSON', 'string:utf8'], - ['BIT', 'buffer'], - ['GEOMETRY', 'buffer'], - ])('reads a %s column with %s', (type, call) => { - const { value, calls } = field(type); - cast(value, () => 'next'); - expect(calls).toEqual([call]); - }); - - it.each(['BLOB', 'VAR_STRING', 'STRING', 'LONG_BLOB'])( - 'leaves a %s column to mysql2, which reads a binary one as bytes and any other as text', - (type) => { - const { value, calls } = field(type); - expect(cast(value, () => 'next')).toBe('next'); - expect(calls).toEqual([]); - }, - ); -}); - -describe('normalizeMysqlError', () => { - it('turns an error MySQL reports into a SqlQueryError with its SQLSTATE', () => { - const error = Object.assign(new Error('raw'), { - sqlState: '42S02', - sqlMessage: "Table 'x' doesn't exist", - }); - expect(normalizeMysqlError(error)).toEqual( - expect.objectContaining({ - kind: 'sql_query', - sqlState: '42S02', - message: "Table 'x' doesn't exist", - }), - ); - }); - - it('leaves any other error as it is', () => { - const error = new Error('socket hang up'); - expect(normalizeMysqlError(error)).toBe(error); - }); -}); diff --git a/packages/3-targets/mysql/3-driver/test/session-settings.test.ts b/packages/3-targets/mysql/3-driver/test/session-settings.test.ts new file mode 100644 index 000000000000..24f084f6cefb --- /dev/null +++ b/packages/3-targets/mysql/3-driver/test/session-settings.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest'; +import { sessionSettingsStatement } from '../src/core/session-settings'; + +describe('sessionSettingsStatement', () => { + it('sets every setting in one statement, each name quoted and each value a parameter', () => { + expect({ + none: sessionSettingsStatement({}), + settings: sessionSettingsStatement({ time_zone: '+00:00', 'odd`name': "it's" }), + }).toEqual({ + none: undefined, + settings: { sql: 'SET SESSION `time_zone` = ?, `odd``name` = ?', values: ['+00:00', "it's"] }, + }); + }); + + it('sends a value of decimal integer text as a number, because MySQL refuses text for an integer variable', () => { + expect( + sessionSettingsStatement({ a: '5', b: '-12', c: '05x', d: '1.5', e: '' })?.values, + ).toEqual([5n, -12n, '05x', '1.5', '']); + }); +}); diff --git a/packages/3-targets/mysql/3-driver/test/text-or-bytes.test.ts b/packages/3-targets/mysql/3-driver/test/text-or-bytes.test.ts new file mode 100644 index 000000000000..47f7349b6453 --- /dev/null +++ b/packages/3-targets/mysql/3-driver/test/text-or-bytes.test.ts @@ -0,0 +1,205 @@ +import type { FieldInfo } from 'mariadb'; +import { describe, expect, it } from 'vitest'; +import { readTextOrBytes } from '../src/core/text-or-bytes'; + +const BINARY = 63; +const UTF8MB4 = 224; +const ZEROFILL = 1 << 6; +const NOT_FIXED = 31; + +interface FieldShape { + readonly type: string | undefined; + readonly collation?: number; + readonly text?: string | null; + readonly number?: number | null; + readonly flags?: number; + readonly columnLength?: number; + readonly scale?: number; +} + +function field({ + type, + collation = BINARY, + text = 'text', + number = 1, + flags = 0, + columnLength = 0, + scale = 0, +}: FieldShape) { + const calls: string[] = []; + const value = { + type, + collation: { index: collation }, + flags, + columnLength, + scale, + string: () => { + calls.push('string'); + return text; + }, + buffer: () => { + calls.push('buffer'); + return text === null ? null : Buffer.from('bytes'); + }, + float: () => { + calls.push('float'); + return number; + }, + } as unknown as FieldInfo; + return { value, calls }; +} + +const read = (shape: FieldShape) => readTextOrBytes(field(shape).value, () => 'next'); + +describe('readTextOrBytes', () => { + it.each([ + ['INT', BINARY, 'string'], + ['TINY', BINARY, 'string'], + ['YEAR', BINARY, 'string'], + ['FLOAT', BINARY, 'float'], + ['DOUBLE', BINARY, 'float'], + ['DATE', BINARY, 'string'], + ['TIME', BINARY, 'string'], + ['BIGINT', BINARY, 'string'], + ['NEWDECIMAL', BINARY, 'string'], + ['DATETIME', BINARY, 'string'], + ['TIMESTAMP', BINARY, 'string'], + ['JSON', BINARY, 'string'], + ['ENUM', UTF8MB4, 'string'], + ['SET', UTF8MB4, 'string'], + ['VAR_STRING', UTF8MB4, 'string'], + ['BLOB', UTF8MB4, 'string'], + ['VAR_STRING', BINARY, 'buffer'], + ['STRING', BINARY, 'buffer'], + ['LONG_BLOB', BINARY, 'buffer'], + ['BIT', BINARY, 'buffer'], + ['GEOMETRY', BINARY, 'buffer'], + ])('reads a %s column in collation %i with %s()', (type, collation, call) => { + const { value, calls } = field({ type, collation }); + readTextOrBytes(value, () => 'next'); + expect(calls).toEqual([call]); + }); + + it('reads a column of a type the package has no name for, such as MySQL 9 VECTOR, as bytes and never as text', () => { + const { value, calls } = field({ type: undefined, collation: BINARY }); + expect({ read: readTextOrBytes(value, () => 'next'), calls }).toEqual({ + read: new Uint8Array(Buffer.from('bytes')), + calls: ['buffer'], + }); + }); + + it('returns bytes as a plain Uint8Array over the same memory, and NULL as null', () => { + const bytes = read({ type: 'BLOB', collation: BINARY }); + expect({ + plain: bytes instanceof Uint8Array && !Buffer.isBuffer(bytes), + bytes, + nullBytes: read({ type: 'BIT', text: null }), + nullText: read({ type: 'TIME', text: null }), + nullNumber: read({ type: 'DOUBLE', number: null }), + }).toEqual({ + plain: true, + bytes: new Uint8Array(Buffer.from('bytes')), + nullBytes: null, + nullText: null, + nullNumber: null, + }); + }); + + it('reads the same text for a column whether MySQL sent it as text or the package wrote it from binary', () => { + const pairs = [ + { type: 'YEAR', columnLength: 4, flags: ZEROFILL, texts: ['0000', '0'] }, + { type: 'TINY', columnLength: 3, flags: ZEROFILL, texts: ['005', '5'] }, + { type: 'TIME', scale: 2, texts: ['25:00:00.50', '25:00:00.500000'] }, + { type: 'TIME', scale: 3, texts: ['-01:02:03.000', '-01:02:03'] }, + { type: 'TIME', scale: 0, texts: ['01:02:03', '01:02:03'] }, + ]; + expect( + pairs.map(({ texts, ...shape }) => texts.map((text) => read({ ...shape, text }))), + ).toEqual([ + ['0000', '0000'], + ['005', '005'], + ['25:00:00.50', '25:00:00.50'], + ['-01:02:03.000', '-01:02:03.000'], + ['01:02:03', '01:02:03'], + ]); + }); + + it('reads a FLOAT as MySQL writes it over the text protocol: six significant digits, ties to even, and an exponent outside 1e-15 to 1e15', () => { + const floats: readonly (readonly [number, string])[] = [ + [0.10000000149011612, '0.1'], + [123456792, '123457000'], + [8057845, '8057840'], + [-123456.5, '-123456'], + [3.3999999521443642e38, '3.4e38'], + [999999986991104, '1e15'], + [125148409626624, '125148000000000'], + [1.0000000036274937e-15, '0.000000000000001'], + [9.99999050715428e-16, '9.99999e-16'], + [5.000000018137469e-16, '5e-16'], + [1.401298464324817e-45, '1.4013e-45'], + [1.1754943508222875e-38, '1.17549e-38'], + [-2.5, '-2.5'], + [0, '0'], + [-0, '-0'], + ]; + expect(floats.map(([number]) => read({ type: 'FLOAT', scale: NOT_FIXED, number }))).toEqual( + floats.map(([, text]) => text), + ); + }); + + it('reads a DOUBLE as MySQL writes it over the text protocol: the shortest digits that read back as the same double, in fixed notation from 1e-15 and up to 1e15 or while digits follow the point', () => { + const doubles: readonly (readonly [number, string])[] = [ + [0.1, '0.1'], + [0.30000000000000004, '0.30000000000000004'], + [1e-7, '0.0000001'], + [1e-15, '0.000000000000001'], + [1.2345678901234567e-14, '0.000000000000012345678901234567'], + [1e-16, '1e-16'], + [123456789012345.6, '123456789012345.6'], + [999999999999999, '999999999999999'], + [1e15, '1e15'], + [9007199254740992, '9.007199254740992e15'], + [4233813289058719.5, '4233813289058719.5'], + [-1234567890123456.8, '-1234567890123456.8'], + [1.2345678901234568e17, '1.2345678901234568e17'], + [1e20, '1e20'], + [1e21, '1e21'], + [Number.MAX_VALUE, '1.7976931348623157e308'], + [5e-324, '5e-324'], + [-2.5, '-2.5'], + [0, '0'], + [-0, '-0'], + ]; + expect(doubles.map(([number]) => read({ type: 'DOUBLE', scale: NOT_FIXED, number }))).toEqual( + doubles.map(([, text]) => text), + ); + }); + + it('reads a FLOAT or DOUBLE declared with decimals as MySQL writes it over the text protocol: the shortest digits, rounded to the declared decimals ties to even or padded with zeros, in fixed notation', () => { + const declared: readonly (readonly [string, number, number, string])[] = [ + ['FLOAT', 6, 1234.5677490234375, '1234.567749'], + ['FLOAT', 6, 1234.567749, '1234.567749'], + ['FLOAT', 6, 98765.4296875, '98765.429688'], + ['FLOAT', 6, 0.10000000149011612, '0.100000'], + ['FLOAT', 20, 0.10000000149011612, '0.10000000149011612000'], + ['FLOAT', 1, 2097152.25, '2097152.2'], + ['FLOAT', 1, 2097152.75, '2097152.8'], + ['FLOAT', 1, -7, '-7.0'], + ['DOUBLE', 2, 1.5, '1.50'], + ['DOUBLE', 2, 100, '100.00'], + ['DOUBLE', 2, -12345678.9, '-12345678.90'], + ['DOUBLE', 2, 1e20, '100000000000000000000.00'], + ['DOUBLE', 2, 0, '0.00'], + ['DOUBLE', 2, -0, '-0.00'], + ['FLOAT', 1, -0, '-0.0'], + ['DOUBLE', 30, 1e-25, '0.000000000000000000000000100000'], + ['DOUBLE', 30, 0.3333333333333333, '0.333333333333333300000000000000'], + ['DOUBLE', 30, 123456789.12345679, '123456789.123456790000000000000000000000'], + ['DOUBLE', 0, 5, '5'], + ['DOUBLE', 0, -0, '-0'], + ]; + expect(declared.map(([type, scale, number]) => read({ type, scale, number }))).toEqual( + declared.map(([, , , text]) => text), + ); + }); +}); diff --git a/packages/3-targets/mysql/9-facade/README.md b/packages/3-targets/mysql/9-facade/README.md index 48e0a98cbdd7..9db90e25f9f1 100644 --- a/packages/3-targets/mysql/9-facade/README.md +++ b/packages/3-targets/mysql/9-facade/README.md @@ -18,10 +18,10 @@ Wires the MySQL target, adapter and driver together for a project's `prisma.conf - **`defineContract`** (`./contract-builder`): the TypeScript contract builder bound to the MySQL packs. - **`createMysqlControlClient`** (`./control`): a control client for `migration plan`, `db migrate`, `db verify`, `db sign` and introspection. - **`./migration`**: what a MySQL `migration.ts` imports: `Migration`, `MigrationCLI` and the column and constraint helpers. -- **`mysql({ contract, url })`** (`./runtime`): a lazy MySQL client with `orm`, `sql`, `raw`, `query`, `execute`, `transaction` and `close`, as `sqlite(...)` and `postgres(...)` have. It connects on its first query. +- **`mysql({ contract, url })`** (`./runtime`): a lazy MySQL client with `orm`, `sql`, `raw`, `query`, `execute`, `transaction` and `close`, as `sqlite(...)` and `postgres(...)` have. It connects on its first query. As in `postgres(...)`, the URL must use `mysql://` (`RUNTIME.BINDING_INVALID` otherwise), a query reads its whole result before the first row unless `stream: true` is set, and `poolOptions` takes `connectionTimeoutMillis` (20 seconds by default) and `idleTimeoutMillis` (30 seconds), plus `max` (10), the pool size: a Postgres user sizes a pool by passing a `pg.Pool`, but the MySQL driver builds its own, because it depends on capability flags set when each connection signs in. `stream` must be a boolean (`RUNTIME.ARGUMENT_INVALID` otherwise, checked when `mysql()` is called, as `postgres()` checks `cursor`); it is not a cursor with a batch size, because MySQL has no server-side cursor for a query. With `stream: true`, a statement awaited inside the loop over a stream on the same transaction never finishes, as on Postgres with `cursor`. - **`contractFromJson`** (`./static`): validates an emitted `contract.json` once; `mysqlStatic` gives the query surfaces without a driver. - Published as `@prisma/orm-mysql`. ## Testing -`test/fixtures/` holds the project's example contract in PSL with its emitted `generated/` files, which `pnpm fixtures:check` keeps stable. The live tests need `MYSQL_URL` and are skipped without it. `docker compose up mysql` starts MySQL 8.4 for them: `MYSQL_URL=mysql://root:root@127.0.0.1:3307/mysql pnpm test`. +`test/fixtures/` holds the project's example contract in PSL with its emitted `generated/` files, which `pnpm fixtures:check` keeps stable. The live tests need `MYSQL_URL` and are skipped without it. `docker compose up mysql` starts MySQL 8.4 for them: `MYSQL_URL='mysql://root:root@127.0.0.1:3307/mysql?allowPublicKeyRetrieval=true' pnpm test`. The server has no TLS, so the URL lets the driver fetch its public key to sign in with `caching_sha2_password`. diff --git a/packages/3-targets/mysql/9-facade/package.json b/packages/3-targets/mysql/9-facade/package.json index 071e9750f960..4948b7e774b2 100644 --- a/packages/3-targets/mysql/9-facade/package.json +++ b/packages/3-targets/mysql/9-facade/package.json @@ -33,6 +33,7 @@ "@internal/sql-runtime": "workspace:8.0.0-rc.21", "@internal/target-mysql": "workspace:8.0.0-rc.21", "@internal/utils": "workspace:8.0.0-rc.21", + "arktype": "catalog:", "pathe": "^2.0.3" }, "devDependencies": { diff --git a/packages/3-targets/mysql/9-facade/src/errors.ts b/packages/3-targets/mysql/9-facade/src/errors.ts index 7843ee002f3f..b415b3e86584 100644 --- a/packages/3-targets/mysql/9-facade/src/errors.ts +++ b/packages/3-targets/mysql/9-facade/src/errors.ts @@ -2,6 +2,7 @@ import type { StructuredError, StructuredErrorOptions } from '@internal/utils/st import { structuredError } from '@internal/utils/structured-error'; export type MysqlClientErrorCode = + | 'RUNTIME.ARGUMENT_INVALID' | 'RUNTIME.BINDING_MISSING' | 'DRIVER.NOT_CONNECTED' | 'DRIVER.ALREADY_CONNECTED'; diff --git a/packages/3-targets/mysql/9-facade/src/exports/runtime.ts b/packages/3-targets/mysql/9-facade/src/exports/runtime.ts index 7432852a0e5c..bdc0fe55eea6 100644 --- a/packages/3-targets/mysql/9-facade/src/exports/runtime.ts +++ b/packages/3-targets/mysql/9-facade/src/exports/runtime.ts @@ -1,3 +1,4 @@ +export type { MysqlPoolOptions } from '@internal/driver-mysql/runtime'; export type { MysqlBindingInput } from '../runtime/binding'; export type { MysqlClient, diff --git a/packages/3-targets/mysql/9-facade/src/runtime/binding.ts b/packages/3-targets/mysql/9-facade/src/runtime/binding.ts index 7697b0f5ca02..f218af4433d0 100644 --- a/packages/3-targets/mysql/9-facade/src/runtime/binding.ts +++ b/packages/3-targets/mysql/9-facade/src/runtime/binding.ts @@ -1,13 +1,41 @@ import type { MysqlBinding } from '@internal/driver-mysql/runtime'; +import { structuredError } from '@internal/utils/structured-error'; export type MysqlBindingInput = { readonly url: string }; +function invalidUrl(message: string, meta: Record): Error { + return structuredError('RUNTIME.BINDING_INVALID', message, { + meta: { extension: 'mysql', ...meta }, + }); +} + +/** Checks `url` as the Postgres facade checks its own: a non-empty URL that parses, with the `mysql://` scheme. No part of the URL goes into the error. */ +export function validateMysqlUrl(url: string): string { + const trimmed = url.trim(); + if (trimmed.length === 0) { + throw invalidUrl('MySQL URL must be a non-empty string', { reason: 'empty url' }); + } + let parsed: URL; + try { + parsed = new URL(trimmed); + } catch { + throw invalidUrl('MySQL URL must be a valid URL', { reason: 'unparseable url' }); + } + if (parsed.protocol !== 'mysql:') { + throw invalidUrl('MySQL URL must use mysql://', { + reason: 'wrong scheme', + received: parsed.protocol, + }); + } + return trimmed; +} + export function resolveMysqlBinding(input: MysqlBindingInput): MysqlBinding { - return { kind: 'url', url: input.url }; + return { kind: 'url', url: validateMysqlUrl(input.url) }; } export function resolveOptionalMysqlBinding(options: { readonly url?: string; }): MysqlBinding | undefined { - return options.url === undefined ? undefined : { kind: 'url', url: options.url }; + return options.url === undefined ? undefined : resolveMysqlBinding({ url: options.url }); } diff --git a/packages/3-targets/mysql/9-facade/src/runtime/mysql-options.ts b/packages/3-targets/mysql/9-facade/src/runtime/mysql-options.ts new file mode 100644 index 000000000000..a615b2779e91 --- /dev/null +++ b/packages/3-targets/mysql/9-facade/src/runtime/mysql-options.ts @@ -0,0 +1,17 @@ +import { type } from 'arktype'; +import { mysqlClientError } from '../errors'; + +const streamOptionSchema = type('boolean | undefined'); + +/** Checks `stream` when the factory is called, as the Postgres facade checks `cursor`. */ +export function validateStreamOption(stream: boolean | undefined): boolean { + const result = streamOptionSchema(stream); + if (result instanceof type.errors) { + throw mysqlClientError('RUNTIME.ARGUMENT_INVALID', 'Invalid stream option', { + why: result.summary, + fix: 'Pass stream: true to stream rows as they arrive, or leave stream unset to read each whole result first.', + meta: { extension: 'mysql', helper: 'mysql', argument: 'stream', received: stream }, + }); + } + return result ?? false; +} diff --git a/packages/3-targets/mysql/9-facade/src/runtime/mysql.ts b/packages/3-targets/mysql/9-facade/src/runtime/mysql.ts index af58efd7d067..3b6c8ab1855b 100644 --- a/packages/3-targets/mysql/9-facade/src/runtime/mysql.ts +++ b/packages/3-targets/mysql/9-facade/src/runtime/mysql.ts @@ -1,6 +1,6 @@ import mysqlAdapter from '@internal/adapter-mysql/runtime'; import type { Contract } from '@internal/contract/types'; -import type { MysqlBinding } from '@internal/driver-mysql/runtime'; +import type { MysqlBinding, MysqlPoolOptions } from '@internal/driver-mysql/runtime'; import mysqlDriver from '@internal/driver-mysql/runtime'; import { UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir'; import { createRawLane, sql as sqlBuilder } from '@internal/sql-builder/runtime'; @@ -51,6 +51,7 @@ import { mysqlClientError } from '../errors'; import { validateMysqlContract } from '../static/contract-from-json'; import { buildMysqlStaticContext, type MysqlStaticContext } from '../static/mysql-static'; import { resolveMysqlBinding, resolveOptionalMysqlBinding } from './binding'; +import { validateStreamOption } from './mysql-options'; import { MysqlRuntimeImpl } from './mysql-runtime'; export type MysqlTargetId = 'mysql'; @@ -188,6 +189,11 @@ export interface MysqlOptionsBase { readonly verifyMarker?: VerifyMarkerOption; readonly log?: Log; readonly mode?: RuntimeMode; + /** + * Stream each query's rows as they arrive. Unset reads the whole result before the first row, as the Postgres facade does without `cursor`. It is a boolean, not Postgres's `cursor: { batchSize }`, because MySQL has no server-side cursor for a query: the server sends the whole result and the driver reads it from the socket as the caller asks for rows. A value that is not a boolean fails the factory call. While a stream on a connection is open, a statement sent on the same connection waits for it, so one awaited inside the loop over a stream in a transaction never finishes. + */ + readonly stream?: boolean; + readonly poolOptions?: MysqlPoolOptions; } export type MysqlOptions< @@ -220,6 +226,7 @@ export default function mysql< TContract extends Contract, TCollections extends CollectionClasses = NoCollectionClasses, >(options: MysqlOptions): MysqlClient { + const stream = validateStreamOption(options.stream); const contract = validateMysqlContract(options.contract); let binding = resolveOptionalMysqlBinding(options); @@ -289,7 +296,10 @@ export default function mysql< throw new InternalError('Driver descriptor missing from execution stack'); } - const driver = driverDescriptor.create(); + const driver = driverDescriptor.create({ + stream, + ...ifDefined('poolOptions', options.poolOptions), + }); ownedDispose = () => driver.close(); runtimeDriver = driver; if (binding !== undefined) { diff --git a/packages/3-targets/mysql/9-facade/src/static/mysql-static.ts b/packages/3-targets/mysql/9-facade/src/static/mysql-static.ts index fa69414fe411..1a5cbb6ca3b9 100644 --- a/packages/3-targets/mysql/9-facade/src/static/mysql-static.ts +++ b/packages/3-targets/mysql/9-facade/src/static/mysql-static.ts @@ -59,7 +59,7 @@ export function buildMysqlStaticContext>( /** * The query surfaces of a MySQL client without a driver: `sql`, `raw`, `enums` and `toSql`. Use - * it where loading the mysql2 driver is not wanted. + * it where loading the MySQL driver is not wanted. * * @example * ```ts diff --git a/packages/3-targets/mysql/9-facade/test/runtime/no-database.test.ts b/packages/3-targets/mysql/9-facade/test/runtime/no-database.test.ts index 7aef25a65e58..c9fb6bb15a94 100644 --- a/packages/3-targets/mysql/9-facade/test/runtime/no-database.test.ts +++ b/packages/3-targets/mysql/9-facade/test/runtime/no-database.test.ts @@ -1,4 +1,5 @@ import mysqlDriver from '@internal/driver-mysql/runtime'; +import { blindCast } from '@internal/utils/casts'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import mysql from '../../src/exports/runtime'; import mysqlStatic, { contractFromJson } from '../../src/exports/static'; @@ -47,6 +48,56 @@ describe('a MySQL client with no database', () => { }); }); + it('refuses a URL that is empty, cannot be parsed or names another scheme, as the Postgres facade does', async () => { + const refusals = ['', 'not a url', 'postgres://root@localhost/app'].map((url) => { + try { + mysql({ contract, url }); + return undefined; + } catch (error) { + return Object(error).code; + } + }); + await expect( + mysql({ contract }).connect({ url: 'postgres://root@localhost/app' }), + ).rejects.toMatchObject({ code: 'RUNTIME.BINDING_INVALID' }); + expect(refusals).toEqual(Array.from({ length: 3 }, () => 'RUNTIME.BINDING_INVALID')); + }); + + it('creates its driver reading whole results unless stream is set, with the pool options it is given', () => { + mysql({ contract }).runtime(); + mysql({ + contract, + stream: true, + poolOptions: { max: 3, connectionTimeoutMillis: 1000, idleTimeoutMillis: 2000 }, + }).runtime(); + expect(driverCreate.mock.calls.map((call: unknown[]) => call[0])).toEqual([ + { stream: false }, + { + stream: true, + poolOptions: { max: 3, connectionTimeoutMillis: 1000, idleTimeoutMillis: 2000 }, + }, + ]); + }); + + it('checks the stream option when the factory is called, as the Postgres facade checks cursor', () => { + for (const received of ['yes', 1, { batchSize: 10 }]) { + expect(() => + mysql({ + contract, + stream: blindCast(received), + }), + ).toThrow( + expect.objectContaining({ + code: 'RUNTIME.ARGUMENT_INVALID', + message: 'Invalid stream option', + fix: expect.stringContaining('leave stream unset'), + meta: { extension: 'mysql', helper: 'mysql', argument: 'stream', received }, + }), + ); + } + expect(() => mysql({ contract, stream: false })).not.toThrow(); + }); + it('refuses to connect without a URL', async () => { await expect(mysql({ contract }).connect()).rejects.toMatchObject({ code: 'RUNTIME.BINDING_MISSING', diff --git a/packages/3-targets/mysql/9-facade/test/runtime/raw-lane.live.test.ts b/packages/3-targets/mysql/9-facade/test/runtime/raw-lane.live.test.ts index b57619e0b8cc..05dc8fba14e4 100644 --- a/packages/3-targets/mysql/9-facade/test/runtime/raw-lane.live.test.ts +++ b/packages/3-targets/mysql/9-facade/test/runtime/raw-lane.live.test.ts @@ -9,12 +9,12 @@ import { exampleDatabase, withSeededDatabase } from './live-database'; const contract = contractFromJson(contractJson); describe('MySQL raw SQL with integer values', { timeout: timeouts.databaseOperation }, () => { - it('compares, combines and builds JSON from integer values as integers, not text', async (test) => { + it('compares and builds JSON from int values as integers, and adds bigint values, sent as decimal text, as integers once one is cast, as on Postgres', async (test) => { await withSeededDatabase(test, exampleDatabase, async (url) => { await using db = mysql({ contract, url, verifyMarker: false }); const [row] = await db.query( db.raw - .sql`SELECT ${10} < ${9} AS lt, GREATEST(${10}, ${9}) AS greatest, CAST(JSON_ARRAY(${5}) AS CHAR) AS arr, ${9007199254740993n} + ${1n} AS sum` + .sql`SELECT ${10} < ${9} AS lt, GREATEST(${10}, ${9}) AS greatest, CAST(JSON_ARRAY(${5}) AS CHAR) AS arr, CAST(${9007199254740993n} AS SIGNED) + ${1n} AS sum` .returnsRow({ lt: 'mysql/int@1', greatest: 'mysql/int@1', @@ -70,4 +70,26 @@ describe('MySQL raw SQL with integer values', { timeout: timeouts.databaseOperat }); }); }); + + it('runs a query awaited inside the loop over a large result in a transaction, because a query reads its whole result first, as on Postgres', async (test) => { + await withSeededDatabase(test, exampleDatabase, async (url) => { + await using db = mysql({ contract, url, verifyMarker: false }); + const seen = await db.transaction(async (tx) => { + let total = 0; + for await (const row of tx.query( + tx.raw + .sql`WITH RECURSIVE s(n) AS (SELECT 1 UNION ALL SELECT n + 1 FROM s WHERE n < ${500}) SELECT n, REPEAT('x', 4000) AS pad FROM s` + .returnsRow({ n: 'mysql/int@1', pad: 'mysql/text@1' }) + .build(), + )) { + const [echo] = await tx.query( + tx.raw.sql`SELECT ${row.n} AS m`.returnsRow({ m: 'mysql/int@1' }).build(), + ); + total += echo?.m ?? 0; + } + return total; + }); + expect(seen).toBe((500 * 501) / 2); + }); + }); }); diff --git a/packages/9-public/@prisma/orm-mysql/package.json b/packages/9-public/@prisma/orm-mysql/package.json index 73136a1bc388..476de3910c73 100644 --- a/packages/9-public/@prisma/orm-mysql/package.json +++ b/packages/9-public/@prisma/orm-mysql/package.json @@ -20,6 +20,7 @@ "@prisma/orm-framework": "workspace:8.0.0-rc.21", "@prisma/orm-target-mysql": "workspace:8.0.0-rc.21", "@prisma/orm-toolchain": "workspace:8.0.0-rc.21", + "arktype": "catalog:", "pathe": "^2.0.3" }, "devDependencies": { diff --git a/packages/9-public/@prisma/orm-target-mysql/package.json b/packages/9-public/@prisma/orm-target-mysql/package.json index e1cd3fd60cda..261b5a02b025 100644 --- a/packages/9-public/@prisma/orm-target-mysql/package.json +++ b/packages/9-public/@prisma/orm-target-mysql/package.json @@ -19,7 +19,7 @@ "@prisma/orm-toolchain": "workspace:8.0.0-rc.21", "@standard-schema/spec": "1.1.0", "arktype": "^2.2.2", - "mysql2": "3.15.3" + "mariadb": "3.5.4" }, "devDependencies": { "@internal/adapter-mysql": "workspace:8.0.0-rc.21", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 42e3f88117fb..6b661a1aa9db 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -665,9 +665,6 @@ importers: '@types/node': specifier: 'catalog:' version: 26.1.2 - mysql2: - specifier: 3.15.3 - version: 3.15.3 typescript: specifier: 'catalog:' version: 5.9.3 @@ -4098,9 +4095,12 @@ importers: '@internal/utils': specifier: workspace:8.0.0-rc.21 version: link:../../../1-framework/0-foundation/utils - mysql2: - specifier: 3.15.3 - version: 3.15.3 + arktype: + specifier: ^2.2.2 + version: 2.2.7 + mariadb: + specifier: 3.5.4 + version: 3.5.4 devDependencies: '@repo/test-utils': specifier: workspace:8.0.0-rc.21 @@ -4171,6 +4171,9 @@ importers: '@internal/utils': specifier: workspace:8.0.0-rc.21 version: link:../../../1-framework/0-foundation/utils + arktype: + specifier: 'catalog:' + version: 2.2.7 pathe: specifier: ^2.0.3 version: 2.0.3 @@ -5464,6 +5467,9 @@ importers: '@prisma/orm-toolchain': specifier: workspace:8.0.0-rc.21 version: link:../orm-toolchain + arktype: + specifier: 'catalog:' + version: 2.2.7 pathe: specifier: ^2.0.3 version: 2.0.3 @@ -5636,9 +5642,9 @@ importers: arktype: specifier: ^2.2.2 version: 2.2.7 - mysql2: - specifier: 3.15.3 - version: 3.15.3 + mariadb: + specifier: 3.5.4 + version: 3.5.4 devDependencies: '@internal/adapter-mysql': specifier: workspace:8.0.0-rc.21 @@ -6438,9 +6444,9 @@ importers: '@types/pg': specifier: 'catalog:' version: 8.20.4 - mysql2: - specifier: 3.15.3 - version: 3.15.3 + mariadb: + specifier: 3.5.4 + version: 3.5.4 pg: specifier: 'catalog:' version: 8.22.0 @@ -10983,6 +10989,10 @@ packages: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} engines: {node: '>=10'} + mariadb@3.5.4: + resolution: {integrity: sha512-3m0vdfgRkjle5/9hHl4R7rlVvnDA7QfuFSNkdIAQNROWKOS0J3b9IQK9HaZSCGEVKiDUbFXFiINfC+cmR5P/aA==} + engines: {node: '>= 20.0.0'} + marked@14.0.0: resolution: {integrity: sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==} engines: {node: '>= 18'} @@ -16484,6 +16494,14 @@ snapshots: dependencies: semver: 7.8.5 + mariadb@3.5.4: + dependencies: + '@types/geojson': 7946.0.16 + '@types/node': 26.1.2 + denque: 2.1.0 + iconv-lite: 0.7.3 + lru-cache: 11.5.2 + marked@14.0.0: {} math-intrinsics@1.1.0: {} diff --git a/projects/mysql-poc/coupling.md b/projects/mysql-poc/coupling.md index 3cb435c9e990..d5b8843eabde 100644 --- a/projects/mysql-poc/coupling.md +++ b/projects/mysql-poc/coupling.md @@ -33,8 +33,8 @@ Each place where shared code assumes Postgres or SQLite in a way MySQL or MariaD | 23 | `sql-contract-emitter/src/index.ts` `namespaceSerializedKind` | Every namespace of kind schema is emitted as `"postgres-schema"` or `"postgres-unbound-schema"` | The target supplies the literal | Unknown: changes emitted types | Open | | 24 | `sql-contract/src/index-naming.ts` | Authored SQL text is compared against Postgres's reprinted form | Compare against the target's reprint | No | Open | | 25 | `sql-contract-ts/src/target-authoring-hooks.ts` `ColumnTypeQualifier` (`qualifyColumnType`) | A native enum type is named by the column (Postgres `typeName`), so the hook gets only the codec id and its type parameters | MySQL's `enum('A','B')` lists the members; the hook cannot see the column's enum members. Slice 2a writes them from the column's value set in the target's namespace factory (`target-mysql/src/core/mysql-database.ts`) instead | No | Open | -| 26 | `sql-contract/src/sql-data-type.ts` `unquotedSqlBaseName`, `sqlBaseName`; `family-sql/src/core/migrations/contract-to-schema-ir.ts` `schemaTypeText` | A type that claims a kind is a named enum with a string `typeName` parameter; a type named by its catalog text has no base name | MySQL's enum is a column type named by its members, so slice 2a declares it `nameIsCatalogText`; the runtime's parameter cast (`sqlBaseName`) refuses such a type, which slice 3 meets | Unknown | Open; slice 3 casts MySQL integer parameters to `SIGNED` in the adapter, decided from the parameter's codec, except as `LIMIT`/`OFFSET` operands | +| 26 | `sql-contract/src/sql-data-type.ts` `unquotedSqlBaseName`, `sqlBaseName`; `family-sql/src/core/migrations/contract-to-schema-ir.ts` `schemaTypeText` | A type that claims a kind is a named enum with a string `typeName` parameter; a type named by its catalog text has no base name | MySQL's enum is a column type named by its members, so slice 2a declares it `nameIsCatalogText`; the runtime's parameter cast (`sqlBaseName`) refuses such a type, which slice 3 meets | Unknown | Open; slice 3 does not meet it: the MySQL adapter writes no parameter casts, as the Postgres adapter writes none for a data type the server infers | | 27 | `scripts/regen-example-migrations.mjs` | Its SQL path is wired to the Postgres target pack | Read the target from each example's config | No: repository tooling | Open | -| 28 | `sql-relational-core` driver request (`SqlExecuteRequest`), `sql-runtime` prepared statements | A driver never needs to know whether a statement may change session state | The MySQL driver must close a pooled connection whose statement may have changed session state the driver relies on | Yes: a required field on the driver request | Fixed in slice 3 (TML-3695): `containsRawSql` on every driver request; the runtime computes it from the query's AST where it builds the request, and code that sends its own statement (an extension's `SET`) sets it. Postgres and SQLite ignore it | -| 29 | `sql-orm-client/src/grouped-collection.ts` `having`; `framework-components` codec descriptor | A `having` comparand can be written into the SQL as a literal | Every value is a bound parameter, so a value is never read as SQL | Yes: a fractional or out-of-range comparand against an integer aggregate now fails, and codec descriptors gain an optional `integerRepresentation` | Fixed in slice 3 (TML-3695) on every target: the comparand is a parameter encoded with the aggregate's codec; the codec's declared `integerRepresentation` decides integer and bigint handling for both the type and the runtime | +| 28 | `sql-relational-core` driver request (`SqlExecuteRequest`) | A driver never needs to know whether a statement may change session state | None: a raw `SET` stays on its pooled connection, as on Postgres | No | Closed in slice 3 (TML-3695): a flag on the driver request and closing connections after raw SQL were tried and reverted (opening connections is expensive, and the flag was a shared change for one target). The MySQL driver sets its session once per connection and never resets it | +| 29 | `sql-orm-client/src/grouped-collection.ts` `having` | A `having` comparand can be written into the SQL as a literal | Renderers that write literals into SQL need them to be safe | Unknown | Open: slice 3 tried making the comparand a parameter on every target, with a new codec descriptor field, and reverted it. The MySQL renderer writes number, boolean and NULL literals inline and refuses the rest (row 30) | | 30 | `sql-relational-core/src/ast` `LiteralExpr` | A literal records no origin, so a renderer cannot tell a code constant from a caller's value | Renderers that write literals into SQL need to know which are safe | Unknown | Open: the MySQL renderer refuses text, date, array and object literals; numbers, booleans and NULL are written inline | diff --git a/projects/mysql-poc/deferred.md b/projects/mysql-poc/deferred.md index 56601f95d635..7c34b671fd0b 100644 --- a/projects/mysql-poc/deferred.md +++ b/projects/mysql-poc/deferred.md @@ -42,9 +42,3 @@ Including `migrations/**` in the MySQL example's `tsconfig.json` fails with TS27 From the system-design review of slice 3 (2026-10-23). The MySQL facade's client (about 440 lines) and the MySQL SQL renderer are copied from SQLite's. The renderer copy is justified for now, because the dialect differences are throughout it. The client copy has already drifted once: `main` gained `delegatingRuntime` while the MySQL copy kept its own, and slice 3 had to adopt it. The family-level builder should come first. Two follow-ups: a family-level builder for a target's `mysql(...)`/`sqlite(...)`/`postgres(...)` client, so each facade supplies only its driver and options; and a shared renderer core that targets extend for their dialect, so a fix like collecting parameters in written order lands once. - -## A codec's integer representation is not checked against its input type - -From the post-fix system-design review of slice 3 (2026-10-23). - -A codec descriptor declares `integerRepresentation: 'number' | 'bigint'`, and the ORM's `having` reads it for both the comparand's type and the runtime check. Nothing at compile time ties it to the codec's TypeScript input type, so a third-party codec can declare `'bigint'` while taking a `number`. Each target's tests check its own codecs. A compile-time check needs a change to the generics of the framework's codec descriptor base class, which is outside this project. diff --git a/projects/mysql-poc/design-notes.md b/projects/mysql-poc/design-notes.md index eeb75c476dbd..0be8941c6a71 100644 --- a/projects/mysql-poc/design-notes.md +++ b/projects/mysql-poc/design-notes.md @@ -44,7 +44,7 @@ The reasoning behind each decision in [`spec.md`](./spec.md), the facts it rests - **Defaults.** MariaDB quotes literal defaults in `COLUMN_DEFAULT`, writes `current_timestamp()`, and has no `DEFAULT_GENERATED`. - **Types.** MariaDB reports integer display widths (`int(11)`) and has native `UUID`, `INET4` and `INET6`. - **Syntax.** MariaDB rejects MySQL's `AS new` row alias in upserts and `FOR SHARE`; it has `RETURNING` on `INSERT` and `DELETE` and MySQL has none. -- **Shared:** wire protocol, drivers (`mysql2` serves both), `VALUES()` upserts, CTEs, window functions, identifier rules, implicit commit on DDL. That much sharing suits subclassing. +- **Shared:** wire protocol, drivers (the `mariadb` package serves both), `VALUES()` upserts, CTEs, window functions, identifier rules, implicit commit on DDL. That much sharing suits subclassing. Hibernate makes the same split (`MariaDBDialect extends MySQLDialect`). TypeORM and Sequelize distinguish the two as well. @@ -92,74 +92,88 @@ These were decided by the orchestrator from the slice 2 research, under Will's i - **A to-many include keeps its order with `GROUP_CONCAT(… ORDER BY …)`.** The MySQL manual says the element order of `JSON_ARRAYAGG` is undefined, and that the optimizer ignores a derived table's `ORDER BY` when the outer query aggregates, so neither keeps related rows in order. `GROUP_CONCAT` honours its own `ORDER BY`. The adapter builds the JSON array text with it and casts the result to JSON. A statement that uses `GROUP_CONCAT` carries a `SET_VAR(group_concat_max_len = …)` optimizer hint, so the SQL does not depend on a session setting. An empty array is written only when the include matched no rows; if the array text is NULL for any other reason (for example a result larger than `max_allowed_packet`, where MySQL returns NULL with only a warning), the cast fails the statement instead of returning an empty list. It needs no shared change. Rejected for now: carrying a sort key in each JSON object and sorting in the runtime's decoder, which needs a shared change and a new capability; it is the fallback if live tests show the chosen form breaks. - **Coupling rows 10, 11 and 26 wait.** Capability checks already refuse `distinctOn` and the Postgres-only lock strengths on MySQL, so neither blocks reads. Row 26 does not block reads. (The integer parameter casts this bullet once described are removed by section 8: the driver sends integers as BIGINT.) -- **Parameters are numbered.** The MySQL renderer writes `?N`, where N is the parameter's place in the runtime's parameter list. SQLite had the same parameter-order bug and got the same fix on `main`. Section 8 has the open question on how the driver maps them. +- **Parameters are numbered.** The MySQL renderer writes `?N`, where N is the parameter's place in the runtime's parameter list. SQLite had the same parameter-order bug and got the same fix on `main`. The driver rewrites them to `?` and orders the values to match (section 8). -## 8. The MySQL driver (2026-10-27, proposed; replaces the mysql2 design of 2026-10-23) +## 8. The MySQL driver (2026-10-27, approved 2026-10-28, revised 2026-10-28; replaces the mysql2 design of 2026-10-23) This section describes how the MySQL runtime driver and control driver talk to the server. It replaces an earlier design built on the `mysql2` package. Facts come from the `mariadb` package's source (3.4.5 and 3.5.4), its issue trackers, Prisma 7's adapter, and probes against a MySQL 9.6 server; the research notes are `wip/research-mysql-protocol.md` and `wip/research-mariadb-driver.md` in the slice's worktree. ### The package: `mariadb`, for MySQL and MariaDB -Both drivers use the `mariadb` npm package, version 3.5.4 or later, for MySQL and for MariaDB. Prisma 7's `@prisma/adapter-mariadb` uses it for both. Prisma chose it in June 2025 after a `mysql2` adapter was built and abandoned: `mysql2` held about 40 MB more for every test and never released it (Linear TML-761, closed 2025-06-20; TML-1129: "it's just too unreliable … we're pivoting to the `mariadb` driver for both MySQL and MariaDB"). 3.5.4 is the floor because earlier versions corrupt rows when a type conversion asks for a column's text under prepared statements (CONJS-363), and hang without pipelining (CONJS-361). +Both drivers use the `mariadb` npm package, version 3.5.4 or later (pinned at 3.5.4), for MySQL and for MariaDB. Prisma 7's `@prisma/adapter-mariadb` uses it for both. Prisma chose it in June 2025 after a `mysql2` adapter was built and abandoned: `mysql2` held about 40 MB more for every test and never released it (Linear TML-761, closed 2025-06-20; TML-1129: "it's just too unreliable … we're pivoting to the `mariadb` driver for both MySQL and MariaDB"). 3.5.4 is the floor because earlier versions corrupt rows when a type conversion asks for a column's text under prepared statements (CONJS-363), and hang without pipelining (CONJS-361). -### Values never travel inside SQL text +### Revised 2026-10-28: copy the Postgres driver -Every statement that carries values runs as a server-side prepared statement (`execute`): the SQL goes to the server with `?` placeholders, the server parses it, and the values follow separately. The driver never writes a value into SQL, and never uses the package's client-side escaping. This is the decision Will made on 2026-10-23 after three reviews found injections in client-side escaping; changing the package does not change it. +The second review measured this section against Will's rule of 2026-10-28: the MySQL target uses the Postgres target's patterns, and differs only where MySQL forces it. The subsections below describe the driver after that revision. Each difference from Postgres says what forces it. -A statement with no values that Prisma itself writes may run over the text protocol (`query`), because there is nothing in it to inject. That is needed for the statements MySQL refuses to prepare (error 1295, confirmed by probe): `START TRANSACTION`, `BEGIN`, `SAVEPOINT`, `ROLLBACK TO SAVEPOINT`, `RELEASE SAVEPOINT`, `LOCK TABLES` and `UNLOCK TABLES`. Transactions use the package's `beginTransaction()`, `commit()` and `rollback()`. Raw SQL a user writes always runs prepared, even with no values; a raw `START TRANSACTION` therefore fails with MySQL's own error. +### Statements, and values never inside SQL text -### Placeholders: open question for Will +Every statement that carries values runs as a server-side prepared statement: the SQL goes to the server with `?` placeholders, the server parses it, and the values follow separately. The driver never writes a value into SQL and never uses the package's client-side escaping. This is the decision Will made on 2026-10-23 after three reviews found injections in client-side escaping. -The shared runtime encodes a statement's values after the adapter renders it, and always in its own order: each parameter once, in the order it first appears in the query's tree (`collectOrderedParamRefs`). Postgres writes `$1`, SQLite writes `?1`, so a placeholder names its value by number and the order does not matter. MySQL has only `?`, which takes values in the order the placeholders appear in the SQL text, with a parameter used twice needing its value twice. That order can differ from the runtime's (across joins and derived tables; the bug fixed for SQLite on `main` in prisma/orm#30853). So something must map placeholders to values after encoding. Two ways: +A statement without values runs over the text protocol on both drivers, as `pg` runs one on Postgres (it prepares only when there are values). Nothing is written into such a statement, and multiple statements are off, so there is nothing to inject. This lets raw `START TRANSACTION`, `LOCK TABLES` and the other statements MySQL refuses to prepare (error 1295) run, on the runtime as in migrations. -- **(a) Today's way, MySQL only.** The renderer writes `?1`, `?2`; the driver reads the SQL with a MySQL lexer, rewrites each `?N` to `?` and orders the values to match. No shared change. The lexer must skip strings, comments and quoted identifiers, and how MySQL reads those depends on `sql_mode` (`ANSI_QUOTES`, `NO_BACKSLASH_ESCAPES`). Prisma's own SQL contains no strings, so only raw SQL that a user writes under a changed `sql_mode` can be misread. The worst case is a wrong value bound or a count mismatch, never a value read as SQL, because values still travel separately. -- **(b) A SQL family change.** The lowered statement says which runtime parameter each placeholder takes, in written order, and the runtime or driver orders the values from that. Nothing reads SQL text. It changes the adapter-to-driver contract in the SQL family, which needs Will's decision. +### Placeholders: numbered by the renderer, rewritten by the driver (Will, 2026-10-28) -Recommended: (a) for this project, because it is contained in MySQL's packages and its failure is bounded; (b) recorded as a coupling row for the family to consider when it next changes the driver contract. +The renderer handles parameters exactly as the Postgres and SQLite renderers do. It numbers each parameter node once, in the order the runtime encodes values (`collectOrderedParamRefs`), and writes that number wherever the node appears. Postgres writes `$N` and SQLite writes `?N`, which the server reads directly. MySQL's `?` cannot carry a number and takes values by position, so the MySQL renderer writes `?N` and the driver turns each `?N` into `?`, listing the values in placeholder order (a parameter written twice is sent twice). The driver reads the SQL with a MySQL lexer that skips strings, comments and quoted identifiers, so a `?1` inside a string in raw SQL is left alone. Nothing changes in the SQL family. -### The prepared-statement cache: off by default +Rejected: a SQL family change in which the lowered statement lists, in written order, which parameter each placeholder takes. It adds a field to the shared adapter-to-driver contract for one target. -The package keeps a cache of prepared statements per connection (`prepareCacheLength`, default 256) and closes a statement on the server when the cache evicts it. MySQL limits open prepared statements across all connections to 16,382 by default (`max_prepared_stmt_count`). Prisma 7 left the cache on in 7.5.0 and users hit the limit within two weeks (prisma/orm#29361, #29364); 7.x then set it to 0 (#29392). A second problem only appears with the cache on: since MySQL 8.0.22 a cached statement keeps the parameter types of its first run, so a value sent later as a different type is converted, and the probe stored `'9007199254740993'` as `9007199254740992` after a first run with a fraction. With the cache off, each statement is prepared, run and closed, and the probe stored the exact value. +### No statement reuse -So the cache is off by default (`prepareCacheLength: 0`), and a `statementCacheSize` URL parameter turns it on, documented with both risks. The cost of off is one extra round trip per statement on MySQL (prepare, then execute, then close); MariaDB sends prepare and execute together. The Prisma engines used a cache of 100 per connection; that is the alternative if the cost proves too high. +Each prepared statement is closed after it runs (`prepareCacheLength: 0`). Postgres reuses a statement only through the user's prepared-statement handle (ADR 210), keeping it open on the connection for the handle's life; ADR 210 lets a driver ignore the handle, and the MySQL driver does, for two reasons MySQL forces. MySQL caps open prepared statements across all connections of the server (`max_prepared_stmt_count`, 16,382 by default), and a statement kept per handle on every pooled connection has no limit of its own; Prisma 7 left the package's cache on in 7.5.0 and users hit the cap within two weeks (prisma/orm#29361, #29364), and 7.x then set it to 0 (#29392). And since MySQL 8.0.22 a reused statement keeps the parameter types of its first run, so a value sent later as another type is converted: a probe stored `'9007199254740993'` as `9007199254740992` after a first run with a fraction. The `statementCacheSize` URL parameter of the first draft, a cache keyed on SQL text, which ADR 210 rejected, is gone. The cost is one extra round trip per statement on MySQL (prepare, execute, close); MariaDB sends prepare and execute together. ### Sending values -The package's encoding has traps the driver must guard against (all from its source, confirmed by probe): +Codecs return what the database reads for each data type, as the Postgres codecs do: the `int` codec a number, the `bigint` codec decimal text (as `pg/int8@1`), a boolean as a boolean. The adapter writes every parameter as a bare `?N`, as the Postgres adapter writes `$N` for every data type whose type the server infers from where the parameter stands, the integers among them. MySQL infers the same way (8.0.22 and later): a decimal text parameter beside a `bigint` column is compared and stored exactly. Where nothing gives a parameter a type, as in `?1 + ?2` in raw SQL, the author writes the cast, as on Postgres. `LIMIT ?` takes only a bare placeholder, so a cast could not go there anyway. -- A JavaScript number is sent as INT only between −2^31 and 2^31 − 2, and otherwise as DOUBLE, so `LIMIT ?` with 2147483647 fails. The driver sends every integer number as a `bigint` (BIGINT), which also keeps one parameter's wire type the same on every run. With integers sent as BIGINT, a comparison with no column (`? < ?`) compares integers, so the renderer no longer needs `CAST(? AS SIGNED)`, and the raw-SQL `LIMIT` detection goes with it. -- A `bigint` below −2^63 makes the package throw inside its socket callback, which crashes the process. The driver refuses a `bigint` outside the signed 64-bit range before sending. -- A `Date` is encoded in the process's local time zone whatever the `timezone` option says. The MySQL codecs send dates and times as text, never as `Date`. +The binary protocol gives each value a wire type, which `pg` never has to choose, so the driver's `parameter-values.ts` has rules for the JavaScript values the package mis-sends (all from its source, confirmed by probe): + +- A number is sent as INT only between −2^31 and 2^31 − 2, and otherwise as DOUBLE, so `LIMIT ?` with 2147483647 fails. The driver sends an integer number as a BIGINT, so an integer's wire type does not change with its size, and any other number as the DOUBLE it is. +- A `bigint` below −2^63 makes the package throw inside its socket callback, which crashes the process. The driver refuses a `bigint` outside the signed 64-bit range before sending, without the value in the error. +- A `Date` is encoded in the process's local time zone whatever the `timezone` option says. The MySQL codecs send dates as text; the driver refuses a `Date`. - A `Uint8Array` that is not a Node `Buffer` is sent as JSON text. The driver wraps it as a `Buffer`. ### Reading values -Options: `bigIntAsNumber: false` and `decimalAsNumber: false` (the defaults: BIGINT as an exact `bigint`, DECIMAL as exact digit text), `dateStrings: true` (DATE and DATETIME as the server's text, padded to the column's precision), `jsonStrings: true` with `autoJsonMap: false` (JSON as the server's text, so large numbers survive), `bitOneIsBoolean: false`. TIME is always text, YEAR a number, SET an array, binary columns a `Buffer`. FLOAT reads as the widened double (`0.1` as `0.10000000149011612`); the codec handles it, as before. These match what Prisma 7 sets, except where the codecs need otherwise. +Both drivers return every row value in the form ADR 155 sets: text, a `Uint8Array` for bytes, or `null`. The Postgres driver gives `pg` a type parser that returns the server's text; the MySQL drivers give the `mariadb` package one `typeCast` function, `readTextOrBytes`, which reads a binary-collation string or blob, BIT, GEOMETRY and any type the package has no name for (MySQL 9's VECTOR) as bytes, and every other column as text. No codec sees a `bigint`, a `number`, a `Date` or a `Buffer`. + +Over the text protocol MySQL writes the text; under a prepared statement MySQL sends numbers, dates and times in binary and the package writes them (3.5.4, `binary-decoder.js`). For five kinds of column the two differ, and since both drivers use both protocols, `readTextOrBytes` gives each the form MySQL writes over the text protocol, as Postgres returns every value as the server writes it: YEAR and a ZEROFILL integer padded to the column's width, a TIME with the column's fraction digits, a FLOAT and a DOUBLE. For a FLOAT or a DOUBLE the driver reads the number the server sent, whichever the protocol, because the package's text for a binary number differs from MySQL's in digits, notation and the sign of zero (it writes negative zero as `0`). MySQL writes a FLOAT with six significant digits, rounded half to even, and a DOUBLE with the fewest digits that read back as the same double, which are the digits JavaScript prints. Both use one notation: fixed, unless that needs more than 14 zeros after the point, or more than 15 digits before it with none after it (`0.1`, `0.000000000000001`, `1e-16`, `4233813289058719.5`, `1e15`, `1e20`); negative zero is `-0`. A column declared with decimals, such as `FLOAT(12,6)` or `DOUBLE(10,2)`, reports them as the column's scale on both protocols (31 means none). MySQL writes its value with exactly those decimals in fixed notation, reading a FLOAT's value as a double: the fewest digits that read back as the same double, padded with zeros when they fit, and otherwise the exact value rounded to them, half to even. So a `FLOAT(12,6)` holding 1234.5677 reads `1234.567749` and a `DOUBLE(10,2)` holding 1.5 reads `1.50`. Negative zero, which a computed column such as `-price` can hold, reads `-0.00` as MySQL writes it. MariaDB reports 39 for no declared decimals, so the MariaDB target must check that value. A probe of 54,992 FLOAT and DOUBLE values on MySQL 9.6, random bit patterns, subnormals, ties, both zeros and the notation boundaries among them, found the driver's text equal to MySQL's from both protocols. A second probe (`wip/f02/probe-declared.mjs` in the slice's worktree) did the same for 56,070 values in 14 FLOAT and DOUBLE columns declared with 0 to 30 decimals. DECIMAL, JSON, ENUM and SET arrive as MySQL's own text. The live tests read every column type on a pooled and a held connection, over the text protocol and prepared, and the five kinds through both drivers with and without values. ### Connections and the session -The pool runs the session setup once per physical connection through `initSql`: `sql_mode` without `NO_BACKSLASH_ESCAPES` and `ANSI_QUOTES`, `time_zone = '+00:00'`, utf8mb4. A connection goes back to the pool after each statement, as it is. There is no reset on release: the package's `resetAfterUse` is ignored on MySQL (it sends `COM_RESET_CONNECTION` only to MariaDB), the reset would close every prepared statement on the connection, and no other driver or ORM resets by default (Drizzle, Kysely, node-postgres, Rails; `mysql2` added an opt-in in 2026). A raw `SET` a user runs stays on that connection for its lifetime, as on Postgres; the README says so. +The target declares the session settings its codecs need, as the Postgres target declares `DateStyle`: `sessionSettings: { time_zone: '+00:00' }`, because the datetime codec reads and writes UTC. The stack passes them to both drivers. The package runs the driver's own session setup once per physical connection through `initSql`: `sql_mode` without `NO_BACKSLASH_ESCAPES` and `ANSI_QUOTES`, and utf8mb4, which the driver's lexer needs. These hold no value. The target's settings do, so they never go into SQL text: the runtime driver sets them with one prepared `SET SESSION` the first time it takes each physical connection from the pool, as the Postgres driver's `SessionSettingsGuard` sets them with `set_config($1, $2)` on a pool whose sessions it did not start, and the control driver sets them the same way after it connects. Postgres can also put them in a connection's startup options; MySQL has no such thing. A value of decimal integer text is sent as a number, because MySQL refuses text for an integer variable even in a prepared `SET`, where Postgres's `set_config` takes text for every setting. A setting MySQL refuses fails the statement at once with MySQL's error, and the connection is destroyed. + +A connection goes back to the pool after each statement, as it is. There is no reset on release: the package's `resetAfterUse` is ignored on MySQL (it sends `COM_RESET_CONNECTION` only to MariaDB), and no other driver or ORM resets by default (Drizzle, Kysely, node-postgres, Rails; `mysql2` added an opt-in in 2026). A raw `SET` a user runs stays on that connection for its lifetime, as on Postgres. + +The pool is sized and timed as the Postgres facade sizes `pg.Pool`, through `poolOptions` on `mysql()`: it opens a connection only when one is needed (`minimumIdle: 0`; the package's default fills the pool at once), closes one idle for `idleTimeoutMillis` (30 seconds), and waits `connectionTimeoutMillis` (20 seconds) both to open a connection and for a pooled one to come free. `max` sets the size (10). The Postgres facade has no `max`: a Postgres user who needs another size passes a `pg.Pool` they built. The MySQL driver cannot take a pool the caller built, because it depends on behaviour fixed when each connection signs in, which it can neither set nor check on such a pool: `foundRows` (whether an UPDATE's affected rows count matched rows, as Postgres counts them), `multipleStatements` and `permitLocalInfile` are capability flags of the sign-in. So the driver builds its pool, and `max`, named as `pg.Pool`'s own, is how its size is set. The facade refuses a URL whose scheme is not `mysql://`, as the Postgres facade refuses one that is not `postgres://`. + +TLS settings are the URL parameters `pg-connection-string` reads for Postgres: `ssl`, `sslmode`, and `sslcert`, `sslkey` and `sslrootcert` as PEM file paths. The driver ignores any other parameter, as `pg` does; the package never sees the URL, so a parameter cannot turn on one of its options. Two are refused, because the driver cannot do what they ask: `charset`, because every connection must be utf8mb4 for the lexer, where `pg` honours `client_encoding`; and `sslcert` without `sslkey`, because in a Prisma 7 MySQL URL `sslcert` named the CA file that checks the server, which `pg` calls `sslrootcert`, and the message says so. The package never follows a server's redirect to another host (`permitRedirect: false`), which it does by default over verified TLS when MariaDB 11.3 or later asks; `pg` never follows one. + +Other pool facts the driver relies on: the pool's `'error'` event needs a listener; a lost connection fails the running statement with `fatal: true`, which the driver reports as a transient `SqlConnectionError`, as Postgres does; the pool keeps retrying a connection it cannot open until its timeout, pausing 200 ms longer after each failure, up to 10 seconds, but reports each failed connection to its `logger.error` at once. `logger.error` also receives every failed statement, so the driver reacts only to an error the package marks fatal that no retry gets past: a refused connection, an unknown or unreachable host, a refused sign-in, a missing public key, or failing session setup. A statement can fail with a sign-in's error number, such as 1524 for a missing authentication plugin, but never fatally. On such a failure the driver fails the statements waiting for a connection at once, as `pg.Pool` rejects a pending connect whose connection fails. It keeps no failure, as `pg.Pool` keeps none: a later statement asks the pool for a connection and fails only when the pool's next attempt fails. Here the package differs from `pg.Pool`, which makes a new attempt for each connect at once. The package's retries and pauses are fixed, and the driver does not work around them, so while the server stays unreachable each later statement waits for the pool's next attempt and fails when it fails. Within one attempt, which lasts `connectionTimeoutMillis` less 100 ms, the package retries a refused connection or an unknown host every 500 ms; a refused sign-in ends the attempt at once. After each failed attempt it pauses 200 ms longer than after the one before, up to 10 seconds. Measured on MySQL 9.6 with statements sent one after another: after a refused sign-in, 6 ms for the first statement, then 202, 403, 604 and so on up to 1,403 ms for the eighth; after a refused connection, about 500 ms each with the default 20 seconds, and from 0 to 1,002 ms with `connectionTimeoutMillis: 1000`, once the pauses between attempts apply. The package has no way to remove a request from its queue, so a statement the driver fails leaves its request there until the pool serves it or `connectionTimeoutMillis` ends. The driver returns a connection the pool hands to such a request straight to the pool: after 500 statements failed against a refused port, 500 requests stayed queued, and once the server accepted connections the next statement ran after 589 ms, behind them. The pool makes no attempt while no statement waits. So once the cause is fixed, the next statement makes the attempt and runs, after any pause still under way ends. A failure a retry can get past, such as a reset connection or a connect timeout, still waits for the pool's retries until `connectionTimeoutMillis`, because the package reports it in the same way as a statement that lost its connection. A refused sign-in is the `SqlQueryError` MySQL raised, as Postgres reports 28P01; a missing public key and failing session setup are a `SqlConnectionError` that is not transient; a Node socket or DNS error is a `SqlConnectionError`, transient as the Postgres driver's table says (a refused connection and an unknown or unreachable host are not); `pool.end()` waits up to 10 seconds for borrowed connections, then destroys them, so `close()` waits for streams and held connections itself first. + +### Reading whole results, and streams -Other pool facts the driver relies on: the pool's `'error'` event needs a listener; a lost connection fails the running statement with error 45009 and `fatal: true`, which the driver uses to tell a lost connection from a statement error; `pool.end()` waits up to 10 seconds for borrowed connections, then destroys them, so `close()` waits for streams and held connections itself first. +A query reads its whole result and returns its connection before the first row, as the Postgres facade does by default (`cursor: { disabled: true }`), so a statement awaited inside the loop over a query in a transaction runs. `stream: true` on `mysql()` streams rows as they arrive, through `executeStream` (or `queryStream` without values), which pauses the socket while the consumer is busy; it is the counterpart of Postgres's `cursor` option. It is a boolean, not `cursor: { batchSize }`, because MySQL has no server-side cursor for a query: the server sends the whole result and the driver reads it from the socket as the caller asks for rows, so there is no batch to size. `mysql()` checks it with arktype when it is called, as `postgres()` checks `cursor`. -### Streams +Statements on a held connection reach the server in the order they are sent, under a copy of the Postgres driver's per-client lock: a statement holds its client's lock until it settles, and a stream for its whole life. Without it, a rollback sent while a prepared statement waited for MySQL's answer to its prepare ran first (confirmed by probe), because the package queues the execution only after that answer. While a stream is open, a statement sent on its connection waits for the stream to end, and one awaited inside the stream's own loop never finishes, as on Postgres with a cursor. -A query's rows stream through `prepare(sql)` and `statement.executeStream(values)`, which pauses the socket while the consumer is busy. Stopping early discards the remaining rows by reading them; MySQL cannot cancel a running statement on the same connection. A pooled stream that the caller stops early destroys its connection instead, so a large result is not downloaded only to be thrown away. +MySQL cannot cancel a running statement on the same connection. When the caller stops a pooled stream early and the whole result has already arrived, the driver drops the buffered rows and returns the connection to the pool. When rows are still arriving, it destroys the connection, so a large result is not downloaded only to be thrown away; the package then opens one short connection to send `KILL`. On a held connection the driver reads and drops the rest. Releasing a held connection with a statement still running destroys it: the package's release sends `ROLLBACK` behind the running statement and waits for it, where `pg`'s release does not wait on the server. ### Authentication -MySQL 8.4's default `caching_sha2_password` fails over plain TCP unless TLS is used or the client may fetch the server's key (`allowPublicKeyRetrieval`). The URL accepts TLS settings and an `allowPublicKeyRetrieval` parameter, off by default. +MySQL 8.4's default `caching_sha2_password` fails over plain TCP unless TLS is used or the client may fetch the server's key (`allowPublicKeyRetrieval`). The URL accepts an `allowPublicKeyRetrieval` parameter, off by default, and the error for the failure names both ways out. It arrives at once and is not transient. ### What this removes from the slice -`mysql2` and its typings; the `CAST(? AS SIGNED)` integer casts and the raw-SQL `LIMIT`/`OFFSET` detection; the driver's statement cache code and its bound checks (the package's option replaces them). If (a) is chosen for placeholders, the lexer stays in the driver only. +`mysql2` and its typings; the `CAST(? AS SIGNED)` integer casts and the raw-SQL `LIMIT`/`OFFSET` detection; the driver's statement cache code and its bound checks. The placeholder lexer stays, in the driver only. ### Rejected - **`mysql2`.** Prisma abandoned it in 2025 for memory growth; it sends a `BigInt` as text, which forced the integer casts; it needs 3.24.0 (August 2026) for `LIMIT ?` with numbers. -- **The text protocol**, as Drizzle and Kysely use. Neither project has stated why it chose it. It writes values into SQL on the client, which is what this design exists to avoid. -- **A cache on by default.** The server-wide limit and the parameter-type problem above. -- **Reset on release.** Ignored on MySQL by this package, and it would empty the statement cache. +- **The text protocol for statements with values**, as Drizzle and Kysely use. It writes values into SQL on the client, which is what this design exists to avoid. +- **A statement cache**, keyed on SQL text or on the prepared-statement handle: the server-wide limit and the parameter-type problem above. +- **Reset on release.** Ignored on MySQL by this package. +- **Streaming by default.** It made a statement awaited inside the loop over a query hang where Postgres runs it. ## Settled by testing in the proof of concept diff --git a/projects/mysql-poc/handover.md b/projects/mysql-poc/handover.md index 70adaa07d2f0..ea9bb22c648b 100644 --- a/projects/mysql-poc/handover.md +++ b/projects/mysql-poc/handover.md @@ -40,10 +40,7 @@ Linear project: "MySQL proof of concept" (team Terminal). **Key decisions taken in the slice (all in design notes sections 7 and 8):** - A to-many include keeps its order with `GROUP_CONCAT(… ORDER BY …)` cast to JSON, with a `SET_VAR(group_concat_max_len = …)` optimizer hint on the statement; an overflow fails the statement rather than returning an empty list. -- **The runtime driver uses server-side prepared statements** (mysql2 `execute()`), approved by Will after three reviews found SQL injection through client-side escaping. The renderer writes `?N`; the driver rewrites to positional `?`; a bounded statement cache (`statementCacheSize`, default 100, max 1000). Evidence: Prisma 7's MariaDB adapter and the Prisma engines both use prepared statements; Drizzle and Kysely use the text protocol. -- Integer parameters are written `CAST(?N AS SIGNED)` (mysql2 sends `BigInt` as text), except as `LIMIT`/`OFFSET` operands. -- No caller value is written into SQL as a literal: the ORM's `having` comparand is now a bound parameter on every target; `fns.and`/`fns.or` accept only `true`/`false`; the MySQL renderer refuses text, date, array and object literals. -- Raw SQL and the session: the runtime tells the driver when a statement contains raw SQL; a pooled MySQL connection that ran raw SQL is discarded. +- **Superseded after this handover (2026-10-28):** slice 3 merged on the `mysql2` driver with every shared change reverted (the raw-SQL request flag, the codec integer field, `having` comparands as parameters, stricter `fns.and`/`fns.or`). The drivers were then rebuilt on the `mariadb` package in a follow-up PR; design notes section 8 is the current design. The MySQL renderer still refuses text, date, array and object literals. **State at handover:** the final `/drive-code-review` ran. Its artifacts are gitignored, so a fresh worktree does not have them: copy the folder `/Users/will/Projects/prisma/orm/.claude/worktrees/mysql-orm-poc-47bed5/projects/mysql-poc/slices/mysql-reads/reviews/` into the same path in the new worktree (`system-design-review-final.md` and `code-review-final.md` are the latest; earlier rounds sit beside them). No SQL injection found; 11 of 12 acceptance criteria pass. The implementer was part-way through fixing the findings when the session stopped. Item 1 is done (`f31890f719`). Item 2 is half done in `18232b02ab` ("wip(mysql): containsRawSql computed from the AST — incomplete"): the AST query, the runtime computing the flag, the adapters cleaned up and the required request field are in, and the repo typecheck passes; still missing are setting `true` in tests that send `SET`, tests that the flag reaches the driver on every path, and the test runs (the commit body lists them). Items 3–7 are not started. The fix list given to the implementer: diff --git a/projects/mysql-poc/slices/mysql-reads/spec.md b/projects/mysql-poc/slices/mysql-reads/spec.md index 0aee9e325de8..161aea7ae829 100644 --- a/projects/mysql-poc/slices/mysql-reads/spec.md +++ b/projects/mysql-poc/slices/mysql-reads/spec.md @@ -26,7 +26,7 @@ Each call lowers to one MySQL statement. Related rows come back in the order the - **Lowering in the MySQL adapter.** A renderer for every AST node the ORM and SQL builder emit for reads, modelled on SQLite's: backtick quoting, `?` placeholders, `LIMIT`/`OFFSET` (a large `LIMIT` when only `OFFSET` is given), `<=>` for IS NOT DISTINCT FROM, NULLS FIRST/LAST emulated with an `x IS NULL` sort key, casts with MySQL type names, `JSON_OBJECT` for to-one includes. FULL JOIN, function sources, lock clauses and anything else MySQL can't express are refused with `RUNTIME.AST_UNSUPPORTED`, through capabilities where the ORM checks them. - **To-many includes keep their order with `GROUP_CONCAT`.** `JSON_ARRAYAGG` has no `ORDER BY`, and MySQL ignores a derived table's `ORDER BY` when the outer query aggregates. The adapter renders a to-many include as `CAST(CONCAT('[', GROUP_CONCAT( ORDER BY … SEPARATOR ','), ']') AS JSON)`, and `COALESCE(…, JSON_ARRAY())` when there are no rows (never the string `'[]'`, which would turn nested includes into strings). The driver raises the session's `group_concat_max_len` on every connection, so an overflow fails the JSON cast, never truncating silently. If a live test shows MySQL breaks this order, stop and report: the fallback needs a shared change. - **JSON projection per codec.** Each codec's `projectJson` writes what the driver would hand over for a row: `int`, `varchar`, `text` and `enum` pass through; `bigint` and `datetime` render as text (`CAST(x AS CHAR)`), so precision and the fraction digits match the row form. -- **Runtime driver.** A mysql2 pool and the text-protocol `query()`, reusing 2a's server-text `typeCast` and session setup (backslash escaping off, UTC, multiple statements off, and now `group_concat_max_len`), run once per connection. Rows stream through the connection; transactions use one connection; `close()` waits for streams in flight, as the Postgres driver does. +- **Runtime driver.** (As first planned; replaced, see "Changed during the slice".) A mysql2 pool and the text-protocol `query()`, reusing 2a's server-text `typeCast` and session setup (backslash escaping off, UTC, multiple statements off, and now `group_concat_max_len`), run once per connection. Rows stream through the connection; transactions use one connection; `close()` waits for streams in flight, as the Postgres driver does. - **Marker read for the runtime.** The runtime's marker check reads the marker through the same control tables 2b writes. ## Coherence rationale @@ -58,20 +58,15 @@ One reviewer reads this as "MySQL can read": a runtime plane and a renderer, wit The At a glance calls return the expected rows against MySQL 8.4 in live tests, including a nested include and an ordered to-many include; golden tests cover every rendered node kind; and build, typecheck, lint, `lint:deps` and the changed packages' tests pass. -**Added 2026-10-23, after the reviews:** the runtime driver runs statements as server-side prepared statements with a bounded cache (design notes section 8), replacing client-side escaping. +**Added 2026-10-23, after the reviews:** the runtime driver runs statements as server-side prepared statements, with the prepared-statement cache off (design notes section 8), replacing client-side escaping. ## Changed during the slice -The reviews changed four parts of the chosen design above. Design notes sections 7 and 8 give the reasons. +The reviews changed three parts of the chosen design above. Design notes sections 7 and 8 give the reasons. -- **Placeholders are numbered.** The renderer writes `?N`, N being the parameter's place in the runtime's list, not `?`. +- **Placeholders are numbered.** The renderer writes `?N`, N being the parameter's place in the runtime's list, as the Postgres and SQLite renderers do. The driver rewrites each `?N` to `?` and sends the values in placeholder order. - **The length limit is in the statement.** A statement that uses `GROUP_CONCAT` carries a `SET_VAR(group_concat_max_len = …)` optimizer hint; the driver no longer sets it for the session. -- **Values travel apart from the SQL.** The runtime driver runs every statement as a server-side prepared statement, with a bounded cache, not the text protocol. Integer parameters are written `CAST(?N AS SIGNED)`, except as `LIMIT` or `OFFSET` operands. -- **Raw SQL never leaves a setting on a shared connection.** A pooled connection that ran raw SQL is closed, not returned to the pool. +- **The drivers use the `mariadb` package, and values travel apart from the SQL.** Both drivers run on `mariadb`, as Prisma 7 does. Every statement with values runs as a server-side prepared statement; one without values runs over the text protocol, as `pg` runs it. No statement is reused. The renderer writes no parameter casts, as the Postgres renderer writes none for integers; the bigint codec sends decimal text, as Postgres's does. +- **The driver copies the Postgres driver (second review, 2026-10-28).** A query reads its whole result unless `stream: true` is set; the target declares the UTC time zone as a session setting; connection failures are `SqlConnectionError` and constraint violations take Postgres's SQLSTATEs; pool and TLS settings are named as on Postgres. -The slice also made four shared changes, each with an upgrade note in `upgrade-instructions/pending/mysql-orm-reads/`: - -1. Every SQL driver request carries `containsRawSql`, computed by the runtime from the query's AST. -2. An ORM `having` comparand is a bound parameter on every target, so a fractional or out-of-range comparand against an integer aggregate now fails. -3. Codec descriptors can declare `integerRepresentation`, which the `having` comparand's type and runtime check both read. -4. `fns.and` and `fns.or` accept only `true` and `false` as raw operands. +The slice makes no change outside the MySQL packages. Changes to shared packages tried during review (a raw-SQL flag on driver requests, a codec field for integer handling, `having` comparands as parameters, and stricter `fns.and`/`fns.or` operands) were reverted. diff --git a/test/utils/package.json b/test/utils/package.json index 026e5d8c350f..3597bcb9157e 100644 --- a/test/utils/package.json +++ b/test/utils/package.json @@ -25,7 +25,7 @@ "tsdown": "catalog:", "typescript": "catalog:", "vitest": "catalog:", - "mysql2": "3.15.3" + "mariadb": "3.5.4" }, "exports": { ".": { diff --git a/test/utils/src/mysql-database.ts b/test/utils/src/mysql-database.ts index 3178353bfb2b..bfa078a12718 100644 --- a/test/utils/src/mysql-database.ts +++ b/test/utils/src/mysql-database.ts @@ -1,5 +1,7 @@ import { randomBytes } from 'node:crypto'; -import { createConnection } from 'mysql2/promise'; +import { readFileSync } from 'node:fs'; +import { ifDefined } from '@internal/utils/defined'; +import { type ConnectionConfig, createConnection } from 'mariadb'; /** A MySQL database created for one test. */ export interface MysqlTestDatabase { @@ -20,6 +22,45 @@ export function mysqlServerUrl(): string | undefined { return url === undefined || url.length === 0 ? undefined : url; } +/** + * The TLS settings of the URL, read as the MySQL driver reads them, which is as `pg-connection-string` reads them for Postgres: `ssl` turns TLS on or off; `sslmode`, `sslcert`, `sslkey` or `sslrootcert` turn it on, the last three naming PEM files; `sslmode=disable` turns it off and `no-verify` accepts any certificate. + */ +function tlsOptions(url: URL): ConnectionConfig['ssl'] { + const parameter = (name: string) => url.searchParams.get(name) ?? undefined; + const sslmode = parameter('sslmode'); + const files = { + cert: parameter('sslcert'), + key: parameter('sslkey'), + ca: parameter('sslrootcert'), + }; + if (sslmode === undefined && Object.values(files).every((path) => path === undefined)) { + const ssl = parameter('ssl'); + return ssl === undefined ? undefined : ssl === 'true' || ssl === '1'; + } + if (sslmode === 'disable') return false; + return { + ...ifDefined('cert', files.cert === undefined ? undefined : readFileSync(files.cert, 'utf8')), + ...ifDefined('key', files.key === undefined ? undefined : readFileSync(files.key, 'utf8')), + ...ifDefined('ca', files.ca === undefined ? undefined : readFileSync(files.ca, 'utf8')), + ...(sslmode === 'no-verify' ? { rejectUnauthorized: false } : {}), + }; +} + +/** + * Connection options for the server a `mysql://` URL names, with its TLS and `allowPublicKeyRetrieval` parameters; the `mariadb` package reads only `mariadb://` URLs. The MySQL control driver reads the URL the same way, but this package cannot depend on it: the driver's tests depend on this package. + */ +function serverOptions(serverUrl: string): ConnectionConfig { + const url = new URL(serverUrl); + return { + host: url.hostname.replace(/^\[(.*)\]$/, '$1'), + ...(url.port === '' ? {} : { port: Number(url.port) }), + user: decodeURIComponent(url.username), + password: decodeURIComponent(url.password), + ...ifDefined('ssl', tlsOptions(url)), + allowPublicKeyRetrieval: url.searchParams.get('allowPublicKeyRetrieval') === 'true', + }; +} + function uniqueDatabaseName(): string { return `prisma_test_${Date.now().toString(36)}_${randomBytes(4).toString('hex')}`; } @@ -39,7 +80,7 @@ export async function withMysqlDatabase( const name = uniqueDatabaseName(); const url = new URL(serverUrl); url.pathname = `/${name}`; - const admin = await createConnection({ uri: serverUrl }); + const admin = await createConnection(serverOptions(serverUrl)); try { await admin.query(`CREATE DATABASE \`${name}\``); let testFailed = false; diff --git a/test/utils/test/mysql-database.test.ts b/test/utils/test/mysql-database.test.ts new file mode 100644 index 000000000000..6737479938b3 --- /dev/null +++ b/test/utils/test/mysql-database.test.ts @@ -0,0 +1,61 @@ +import { createConnection } from 'mariadb'; +import { describe, expect, it } from 'vitest'; +import { mysqlServerUrl, withMysqlDatabase } from '../src/mysql-database'; +import { timeouts } from '../src/timeouts'; + +async function asAdmin( + serverUrl: string, + fn: (run: (sql: string) => Promise) => Promise, +): Promise { + const url = new URL(serverUrl); + const connection = await createConnection({ + host: url.hostname, + ...(url.port === '' ? {} : { port: Number(url.port) }), + user: decodeURIComponent(url.username), + password: decodeURIComponent(url.password), + allowPublicKeyRetrieval: url.searchParams.get('allowPublicKeyRetrieval') === 'true', + }); + try { + return await fn((sql) => connection.query(sql)); + } finally { + await connection.end(); + } +} + +describe('withMysqlDatabase', () => { + it( + 'reaches a server that requires TLS when MYSQL_URL carries the TLS parameters the MySQL driver reads', + async (test) => { + const serverUrl = mysqlServerUrl(); + if (serverUrl === undefined) { + test.skip('MYSQL_URL is not set'); + return; + } + const user = `tls_${Date.now().toString(36)}`; + await asAdmin(serverUrl, async (run) => { + await run(`CREATE USER '${user}'@'%' IDENTIFIED BY 'pw-tls' REQUIRE SSL`); + await run(`GRANT CREATE, DROP ON *.* TO '${user}'@'%'`); + const tlsUrl = new URL(serverUrl); + tlsUrl.username = user; + tlsUrl.password = 'pw-tls'; + tlsUrl.searchParams.delete('allowPublicKeyRetrieval'); + tlsUrl.searchParams.set('sslmode', 'no-verify'); + process.env['MYSQL_URL'] = tlsUrl.toString(); + try { + const created = await withMysqlDatabase(test, async ({ name, url }) => ({ + name, + sslmode: new URL(url).searchParams.get('sslmode'), + })); + expect(created).toEqual({ + name: expect.stringMatching(/^prisma_test_/), + sslmode: 'no-verify', + }); + } finally { + process.env['MYSQL_URL'] = serverUrl; + await run(`DROP USER IF EXISTS '${user}'@'%'`); + } + }); + }, + timeouts.databaseOperation, + ); +}); diff --git a/test/utils/tsdown.config.ts b/test/utils/tsdown.config.ts index 766717f9a897..4ce1f1c6452f 100644 --- a/test/utils/tsdown.config.ts +++ b/test/utils/tsdown.config.ts @@ -13,7 +13,7 @@ export default defineConfig({ timeouts: 'src/timeouts.ts', 'typed-expectations': 'src/typed-expectations.ts', }, - external: ['@internal/contract', '@prisma/dev', 'mysql2', 'pg', 'vitest', /^node:/], + external: ['@internal/contract', '@prisma/dev', 'mariadb', 'pg', 'vitest', /^node:/], outDir: 'dist/exports', // Keep manual exports to preserve root "." mapping with this custom outDir layout. exports: { enabled: false }, diff --git a/upgrade-instructions/pending/mysql-mariadb-driver/app/instructions.md b/upgrade-instructions/pending/mysql-mariadb-driver/app/instructions.md new file mode 100644 index 000000000000..8e1cc0ebe668 --- /dev/null +++ b/upgrade-instructions/pending/mysql-mariadb-driver/app/instructions.md @@ -0,0 +1,3 @@ +--- +changes: [] +--- diff --git a/upgrade-instructions/pending/mysql-mariadb-driver/extension/instructions.md b/upgrade-instructions/pending/mysql-mariadb-driver/extension/instructions.md new file mode 100644 index 000000000000..8e1cc0ebe668 --- /dev/null +++ b/upgrade-instructions/pending/mysql-mariadb-driver/extension/instructions.md @@ -0,0 +1,3 @@ +--- +changes: [] +---