diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..209e3ef --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +20 diff --git a/CLAUDE.md b/CLAUDE.md index afae6e8..1f84f34 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,18 +5,20 @@ **data-api-client** is a lightweight wrapper for the Amazon Aurora Serverless Data API that simplifies database interactions by abstracting away field value type annotations. It acts as a "DocumentClient" equivalent for the RDS Data API. - **Package Name**: data-api-client -- **Current Version**: 2.2.0 +- **Current Version**: 2.3.0 - **Author**: Jeremy Daly - **License**: MIT - **Repository**: https://github.com/jeremydaly/data-api-client ## Project Status -Version 2.0 beta supports: +Version 2.x (stable) supports: - New RDS Data API for Aurora Serverless v2 and Aurora provisioned database instances -- Amazon Aurora PostgreSQL-Compatible Edition enhancements +- Amazon Aurora PostgreSQL-Compatible Edition enhancements (default engine is now `'pg'`) - AWS SDK v3 (migrated from v2) - Full TypeScript implementation with type definitions +- Drop-in driver compat layers (`pg`, `mysql2`) plus a Knex adapter (`compat/knex`), with Drizzle, Kysely, and Knex support +- Automatic retries for Aurora Serverless v2 scale-to-zero wake-ups ## Architecture @@ -25,14 +27,21 @@ The library is written in TypeScript and compiled to CommonJS JavaScript for bac **Modular Source Structure**: - `src/index.ts` - Entry point (exports `init` from client.ts) -- `src/client.ts` - Client initialization and configuration (~155 lines) -- `src/types.ts` - TypeScript type definitions and interfaces (~171 lines) -- `src/params.ts` - Parameter parsing, normalization, and processing (~183 lines) -- `src/query.ts` - Query execution logic (~95 lines) -- `src/results.ts` - Result formatting and record processing, including array value parsing (~165 lines) -- `src/transaction.ts` - Transaction management (~87 lines) -- `src/utils.ts` - Utility functions for SQL parsing, type detection, and date handling (~154 lines) -- Compiled output in `dist/`: `index.js`, `index.d.ts`, `types.js`, `types.d.ts`, etc. +- `src/client.ts` - Client initialization and configuration +- `src/types.ts` - TypeScript type definitions and interfaces +- `src/params.ts` - Parameter parsing, normalization, and processing +- `src/query.ts` - Query execution logic +- `src/results.ts` - Result formatting and record processing, including array value parsing +- `src/transaction.ts` - Transaction management +- `src/utils.ts` - Utility functions for SQL parsing, type detection, and date handling +- `src/pg-escape.ts` - Internal PostgreSQL identifier/string escaping (no external dependency) +- `src/retry.ts` - Automatic retry logic for scale-to-zero cluster wake-ups (see Retry Behavior) +- `src/compat/` - Drop-in driver compatibility layers (see Driver Compatibility Layers) + - `compat/pg.ts` - node-postgres (`pg`) compatible client/pool + - `compat/mysql2.ts` - mysql2 compatible connection/pool + - `compat/errors.ts` - Maps Data API errors to pg/mysql2 error shapes + - `compat/index.ts` - Compat layer exports +- Compiled output in `dist/`: `index.js`, `index.d.ts`, `types.js`, `types.d.ts`, plus `dist/compat/*` **Key architectural decisions**: 1. **TypeScript with build step** - Written in TypeScript, compiled to JavaScript @@ -110,6 +119,70 @@ Supported types: - Handles nested/multidimensional arrays recursively - Array parameters must use workarounds (see Known Limitations) +## Driver Compatibility Layers (`src/compat/`) + +In addition to the native `dataApiClient(...)` interface, the library ships drop-in +adapters that mimic the popular Node database drivers so ORMs and query builders can +run on the Data API unchanged. These are published as subpath exports (see Package +Entry Points) and are exercised by the ORM integration tests. + +- **`data-api-client/compat/pg`** — `createPgClient` / `createPgPool` expose a + node-postgres-shaped client (`query()` returning `{ rows, rowCount, command, fields }`, + `EventEmitter`, `connect`/`end`). Targets Drizzle and Kysely Postgres dialects. +- **`data-api-client/compat/mysql2`** — `createMySQLConnection` / `createMySQLPool` + expose a mysql2-shaped connection (`query()` returning `[rows, fields]`, + `PoolConnection.release()`). Set `namedPlaceholders: true` in config to enable + `:name` placeholder syntax mysql2 callers expect. +- **`data-api-client/compat/knex`** — `createKnexMySQLClient` / `createKnexPgClient` + return custom Knex `client` classes (subclasses of Knex's mysql2/pg dialects with + `_driver()` overridden) so Knex runs over the Data API. `knex` is an optional peer + dependency, lazy-`require`d. See ORM support status for the transaction caveat. +- **`data-api-client/compat/errors`** — `mapToPostgresError` / `mapToMySQLError` + translate Data API exceptions into pg/mysql2-shaped error objects (code, etc.) so + ORM error handling behaves as expected. + +**ORM support status**: Drizzle and Kysely work because they accept an injected +driver/dialect and call its `query()` methods, so a look-alike pool/client suffices. +**Knex is supported via `compat/knex`** using a different mechanism: Knex *constructs* +its own driver rather than accepting an injected pool, so the helpers subclass Knex's +mysql2/pg dialect and override the single `_driver()` method to hand Knex a Data +API-backed connection. Both engines are covered by real integration tests +(`integration-tests/knex-{mysql,pg}.int.test.ts`); `test:int:orm:knex` runs both +(with `:pg`/`:mysql` variants). + +**Transactions (all SQL-driven callers)**: ORMs/Knex drive transactions by issuing +literal `BEGIN`/`COMMIT`/`ROLLBACK` SQL on the connection. Both compat layers intercept +these via `src/compat/transaction-sql.ts` (`classifyTransactionControl`) and map them to +the Data API lifecycle (`beginTransaction`/`commitTransaction`/`rollbackTransaction`), +threading `transactionId` onto subsequent queries on that connection. This works because +the caller runs a whole transaction on one acquired connection, and `transactionId` is a +per-connection-instance closure. Knex `db.transaction()` commits and rolls back correctly. + +**Limitation — nested transactions**: SQL `SAVEPOINT`/`RELEASE`/`ROLLBACK TO SAVEPOINT` +have no Data API primitive, so the classifier throws a clear error for them. Top-level +transactions work; nested ones (e.g. `trx.transaction(...)`) are rejected. Covered by +`nested transactions (savepoints) are rejected` tests in both Knex suites. + +**mysql2 callback note**: the mysql2 compat `query()` accepts the +`query(config, undefined, callback)` form (params undefined, callback as 3rd arg) that +Knex uses for bindings-less transaction statements — see the callback-parsing in +`createMySQLConnection`. Missing this caused Knex transactions to hang. + +When adding features, keep the native client and compat layers in sync — a behavior +change in `query.ts`/`results.ts` usually needs matching compat tests. + +## Retry Behavior (`src/retry.ts`) + +`withRetry()` wraps every command send (`query()` and the raw `executeStatement` etc. +methods) to survive Aurora Serverless v2 **scale-to-zero wake-ups**. Enabled by default. + +- **`DatabaseResumingException`** (cluster waking) → retried with wake-up-tuned delays + (`0, 2, 5, 10, 15, 20, 25, 30, 35, 40s`), capped by `retryOptions.maxRetries` (default **9**) +- **Transient connection errors** (e.g. "Communications link failure", `StatementTimeoutException`) + → 3 quick retries with exponential backoff (`0, 2, 4s`) +- **`retryOptions.retryableErrors`** → custom error codes/names to also retry with wake-up delays +- Disable with `retryOptions: { enabled: false }` + ## Configuration Options ```javascript @@ -120,8 +193,9 @@ Supported types: // Optional database: string, // Default database name - engine: 'mysql'|'pg', // Database engine (default: 'mysql') + engine: 'mysql'|'pg', // Database engine (default: 'pg') hydrateColumnNames: boolean, // Return objects vs arrays (default: true) + namedPlaceholders: boolean, // Enable :name placeholders for mysql2 compat layer (default: false) options: object, // Passed to RDSDataClient constructor client: RDSDataClient, // Custom RDSDataClient instance (for X-Ray, etc.) @@ -130,6 +204,12 @@ Supported types: treatAsLocalDate: boolean // Use local time (default: false) }, + retryOptions: { // Scale-to-zero wake-up retries (see Retry Behavior) + enabled: boolean, // default: true + maxRetries: number, // default: 9 + retryableErrors: string[] // extra error codes/names to retry (default: []) + }, + // Deprecated (set in options instead) region: string, sslEnabled: boolean, // Note: TLS is always enabled in SDK v3, use options.endpoint for local dev @@ -141,19 +221,22 @@ Supported types: - **Framework**: Vitest (migrated from Jest) - **Test structure**: - - Unit tests: `src/*.test.ts` (4 test files colocated with source) - - Integration tests: `integration-tests/*.test.ts` (4 test files for MySQL and PostgreSQL) + - Unit tests: `src/*.test.ts` (colocated with source) + - Integration tests: `integration-tests/*.int.test.ts`, grouped into three suites: + - **core**: `mysql.int.test.ts`, `postgres.int.test.ts` (native client against each engine) + - **compat**: `pg-compat.int.test.ts`, `mysql2-compat.int.test.ts` (driver compat layers) + - **orm**: `drizzle-{pg,mysql}`, `kysely-{pg,mysql}`, `knex-{mysql,pg}` (all working, including transactions; nested transactions rejected — see Driver Compatibility Layers) + - See `integration-tests/INTEGRATION_TESTING.md` for the Aurora Serverless v2 CloudFormation setup (`infra/`) - **Config**: `vitest.config.mjs` (ES module format, requires `.mjs` extension) -- **IMPORTANT**: Before running integration tests, run `source .env.local` to load AWS credentials and cluster ARNs +- **IMPORTANT**: Integration tests read AWS credentials/ARNs from `process.env`; nothing auto-loads `.env.local` (vitest config does not). You must `source .env.local` **in the same command** as the test run, because shell env vars do not persist across separate commands — e.g. `source .env.local && npm run test:int:orm:knex`. Sourcing in one step and running tests in another (or a fresh terminal / new `!` command) will not work. - **Sample data**: `fixtures/sample-*-response.json` files (imported via `#fixtures/*` alias) -- **Run tests**: - - `npm test` - Build + run unit tests - - `npm run test:unit` - Build + run unit tests - - `npm run test:integration` - Build + run all integration tests (requires `.env.local`) - - `npm run test:integration:mysql` - Build + run MySQL integration tests (requires `.env.local`) - - `npm run test:integration:postgres` - Build + run PostgreSQL integration tests (requires `.env.local`) +- **Run tests** (script names match the suite grouping above): + - `npm test` / `npm run test:unit` - Build + run unit tests + - `npm run test:int:core` - Native client integration tests (both engines; `:mysql` / `:pg` variants) + - `npm run test:int:compat` - Driver compat-layer integration tests (`:pg` / `:mysql` variants) + - `npm run test:int:orm:kysely` / `:drizzle` / `:knex` - ORM integration tests (engine variants available) - `npm run test-ci` - Build + lint + run unit tests (for CI) - - For manual integration test runs: `source .env.local && npx vitest run integration-tests/` + - For manual runs: `source .env.local && npx vitest run integration-tests/` - **Global test functions**: Enabled via `globals: true` in config (no need to import describe/test/expect) - **Test helpers**: Integration tests use `setup.ts` for database setup - **Integration test credentials**: Stored in `.env.local` (not in git): @@ -196,20 +279,22 @@ Supported types: - **Note**: PostgreSQL escaping is handled by internal `src/pg-escape.ts` module (no external dependency) ### Development -- **@aws-sdk/client-rds-data** (^3.712.0) - AWS SDK v3 RDS Data API client (also peer dep) +- **@aws-sdk/client-rds-data** (^3.1048.0) - AWS SDK v3 RDS Data API client (also peer dep) - **typescript** (^5.9.3) - TypeScript compiler - **@types/node** (^24.6.2) - Node.js type definitions - **@types/sqlstring** (^2.3.2) - sqlstring type definitions +- **@types/pg** (^8.15.5) - pg type definitions (for compat layer) - **@typescript-eslint/parser** (^8.45.0) - TypeScript ESLint parser - **@typescript-eslint/eslint-plugin** (^8.45.0) - TypeScript ESLint rules - **eslint** (^8.12.0) + plugins - Linting -- **vitest** (^3.2.4) - Testing framework -- **@vitest/ui** (^3.2.4) - Vitest UI +- **vitest** (^4.1.8) - Testing framework +- **@vitest/ui** (^4.1.8) - Vitest UI - **prettier** (^2.6.2) - Code formatting - **tsx** (^4.20.6) - TypeScript execution engine +- **pg**, **drizzle-orm**, **kysely**, **knex** - ORM/driver targets used only by compat integration tests ### Peer Dependencies -- **@aws-sdk/client-rds-data** (^3.0.0) - Optional peer dependency (available in Lambda runtime or installed by user) +- **@aws-sdk/client-rds-data** (^3.1048.0) - Optional peer dependency (available in Lambda runtime or installed by user) ## Common Patterns @@ -359,16 +444,23 @@ Publishing to npm uses OIDC Trusted Publishers (no long-lived npm tokens). Confi │ ├── results.ts # Result formatting and record processing │ ├── transaction.ts # Transaction management │ ├── utils.ts # Utility functions +│ ├── retry.ts # Scale-to-zero wake-up retry logic │ ├── pg-escape.ts # Internal PostgreSQL escaping utilities (no external deps) -│ ├── params.test.ts # Parameter processing tests -│ ├── query.test.ts # Query execution tests -│ ├── results.test.ts # Result formatting tests -│ ├── pg-escape.test.ts # PostgreSQL escape function tests -│ └── utils.test.ts # Utility function tests +│ ├── compat/ # Drop-in driver compatibility layers +│ │ ├── index.ts # Compat exports +│ │ ├── pg.ts # node-postgres (pg) compatible client/pool +│ │ ├── mysql2.ts # mysql2 compatible connection/pool +│ │ └── errors.ts # Data API → pg/mysql2 error mapping +│ └── *.test.ts # Colocated unit tests (params, query, results, utils, retry, pg-escape) ├── integration-tests/ │ ├── setup.ts # Integration test setup -│ ├── mysql.int.test.ts # MySQL integration tests -│ └── postgres.int.test.ts # PostgreSQL integration tests (comprehensive) +│ ├── INTEGRATION_TESTING.md # Aurora Serverless v2 setup guide +│ ├── mysql.int.test.ts # MySQL native client tests +│ ├── postgres.int.test.ts # PostgreSQL native client tests (comprehensive) +│ ├── pg-compat.int.test.ts # pg compat layer tests +│ ├── mysql2-compat.int.test.ts # mysql2 compat layer tests +│ └── {drizzle,kysely,knex}-*.int.test.ts # ORM integration tests +├── infra/ # CloudFormation for integration-test Aurora clusters ├── dist/ # Compiled output (gitignored, distributed in npm) │ ├── index.js # Compiled main file │ ├── index.d.ts # Type definitions @@ -380,7 +472,7 @@ Publishing to npm uses OIDC Trusted Publishers (no long-lived npm tokens). Confi ├── tsconfig.json # TypeScript configuration ├── tsconfig.eslint.json # TypeScript ESLint configuration ├── vitest.config.mjs # Vitest configuration (ES module, requires .mjs) -├── package.json # NPM configuration (main: dist/index.js, types: dist/index.d.ts) +├── package.json # NPM config (main: dist/index.js; subpath exports: ./compat, ./compat/pg, ./compat/mysql2, ./types) ├── package-lock.json # Dependency lock file ├── README.md # User documentation ├── CLAUDE.md # AI development context (this file) @@ -506,7 +598,9 @@ npm run build # Compile TypeScript to JavaScript npm run build:watch # Compile TypeScript in watch mode npm test # Build + run unit tests npm run test:unit # Build + run unit tests -npm run test:integration # Build + run all integration tests +npm run test:int:core # Build + run native client integration tests (requires .env.local) +npm run test:int:compat # Build + run driver compat-layer integration tests +npm run test:int:orm:kysely # Build + run Kysely ORM integration tests (also :drizzle, :knex) npm run test-ci # Build + lint + tests (for CI) npm run lint # Run ESLint on TypeScript source vitest # Run tests in watch mode (interactive) diff --git a/README.md b/README.md index 3dcba52..3268247 100644 --- a/README.md +++ b/README.md @@ -3,52 +3,42 @@ [![npm](https://img.shields.io/npm/v/data-api-client.svg)](https://www.npmjs.com/package/data-api-client) [![npm](https://img.shields.io/npm/l/data-api-client.svg)](https://www.npmjs.com/package/data-api-client) -> **Note:** Version 2.1.0 introduces mysql2 and pg compatibility layers with full ORM support! We welcome your feedback and bug reports. Please [open an issue](https://github.com/jeremydaly/data-api-client/issues) if you encounter any problems or have suggestions for improvement. -> > **Using v1.x?** See [README_v1.md](README_v1.md) for v1.x documentation. -The **Data API Client** is a lightweight wrapper that simplifies working with the Amazon Aurora Serverless Data API by abstracting away the notion of field values. This abstraction annotates native JavaScript types supplied as input parameters, as well as converts annotated response data to native JavaScript types. It's basically a [DocumentClient](https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/DynamoDB/DocumentClient.html) for the Data API. It also dramatically simplifies **transactions**, provides **automatic retry logic** for scale-to-zero clusters, and includes **compatibility layers** for mysql2 and pg with full **ORM support**. +The **Data API Client** is a lightweight wrapper that makes working with the Amazon Aurora Serverless Data API incredibly easy. The Data API makes you annotate every field value with its type, both going in and coming back, which gets old fast. This library handles that for you, mapping native JavaScript types to the Data API's format and back automatically. It's basically a [DocumentClient](https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/DynamoDB/DocumentClient.html) for the Data API, with clean **transactions** and **automatic retry logic** for scale-to-zero clusters built in. It also gives you drop-in **compatibility layers** for mysql2 and pg, so you can plug the Data API into your favorite ORMs and query builders. Point **Drizzle**, **Kysely**, or **Knex** at it and keep writing the queries you already know. -**Version 2.1** adds mysql2 and pg compatibility layers, automatic retry logic for cluster wake-ups, and verified support for Drizzle ORM and Kysely query builder. +For more information about the Aurora Serverless Data API, you can review the [official documentation](https://docs.aws.amazon.com/AmazonRDS/latest/AuroraUserGuide/data-api.html) or read [Aurora Serverless Data API: An (updated) First Look](https://www.jeremydaly.com/aurora-serverless-data-api-a-first-look/) for some more insights on performance. -**Version 2.0** introduced support for the new [RDS Data API for Aurora Serverless v2 and Aurora provisioned database instances](https://aws.amazon.com/about-aws/whats-new/2024/09/amazon-aurora-mysql-rds-data-api/), enhanced [Amazon Aurora PostgreSQL-Compatible Edition](https://aws.amazon.com/about-aws/whats-new/2023/12/amazon-aurora-postgresql-rds-data-api/) support, migration to AWS SDK v3, full TypeScript implementation, and comprehensive PostgreSQL data type coverage including **automatic array handling**. +## What's New in v2.3 -For more information about the Aurora Serverless Data API, you can review the [official documentation](https://docs.aws.amazon.com/AmazonRDS/latest/AuroraUserGuide/data-api.html) or read [Aurora Serverless Data API: An (updated) First Look](https://www.jeremydaly.com/aurora-serverless-data-api-a-first-look/) for some more insights on performance. +- **Knex support**: the `data-api-client/compat/knex` helpers let Knex run on the Data API for both MySQL and PostgreSQL. + - `createKnexMySQLClient()` and `createKnexPgClient()` return a custom Knex `client` wired to the Data API + - **Transactions** work: `db.transaction()` commits on success and rolls back when the callback throws. Nested transactions are rejected, since the Data API has no `SAVEPOINT` primitive. + - The common query-builder surface is covered by integration tests for both engines: selects, the `where` family, joins, aggregates, ordering/pagination, unions, subqueries, CTEs, `insert`/`update`/`del`, `returning`, `increment`/`decrement`, and `onConflict().merge()` upserts + - `knex` is an optional peer dependency +- Fixes two latent compatibility-layer bugs that also affected non-Knex callers: pg parameter binding via the config-object callback form, and mysql2 callback parsing + +See the [Knex section](#knex-query-builder) for usage. + +## Changelog + +### v2.2 + +- Dependency updates, including a raised `@aws-sdk/client-rds-data` peer dependency floor + +### v2.1 + +- **mysql2** and **pg** compatibility layers: drop-in replacements for those drivers, with connection pools, transactions, and both Promise and callback APIs +- **Automatic retry logic** for Aurora Serverless scale-to-zero wake-ups (`DatabaseResumingException` and transient connection errors), configurable and enabled by default +- Verified ORM support for **Drizzle** and **Kysely** -## What's New in v2.1 - -- **Automatic Retry Logic**: Built-in retry handling for Aurora Serverless scale-to-zero wake-ups - - Smart detection of `DatabaseResumingException` with optimized retry delays - - Automatic handling of connection errors with exponential backoff - - Configurable and enabled by default -- **mysql2 Compatibility Layer**: Drop-in replacement for the `mysql2/promise` library - - Full support for connection pools and transactions - - Works seamlessly with ORMs like Drizzle and Kysely -- **pg Compatibility Layer**: Drop-in replacement for the `pg` (node-postgres) library - - Promise-based and callback-based APIs - - Compatible with ORMs and query builders -- **ORM Support**: Tested and verified with popular ORMs: - - **Drizzle ORM**: Full support for both MySQL and PostgreSQL - - **Kysely**: Query builder support for both engines - -## What's New in v2.0 - -- **AWS SDK v3**: Migrated from AWS SDK v2 to v3 for smaller bundle sizes and better tree-shaking -- **TypeScript**: Full TypeScript implementation with comprehensive type definitions -- **PostgreSQL Array Support**: Automatic conversion of PostgreSQL arrays to native JavaScript arrays in query results -- **Comprehensive Data Type Coverage**: Extensive support for PostgreSQL data types including: - - All numeric types (SMALLINT, INT, BIGINT, DECIMAL, NUMERIC, REAL, DOUBLE PRECISION) - - String types (CHAR, VARCHAR, TEXT) - - Boolean, Date/Time types (DATE, TIME, TIMESTAMP, TIMESTAMPTZ) - - Binary data (BYTEA) - - JSON and JSONB with nested structures - - UUID with type casting support - - Network types (INET, CIDR) - - Range types (INT4RANGE, NUMRANGE, TSTZRANGE) - - Arrays of all supported types -- **Modern Build System**: TypeScript compilation with ES6+ output -- **Enhanced Type Casting**: Improved support for PostgreSQL type casting with inline (`::type`) and parameter-based casting -- **Better Error Handling**: More informative error messages and validation +### v2.0 + +- Migrated to **AWS SDK v3** for smaller bundles and better tree-shaking +- Full **TypeScript** implementation with comprehensive type definitions +- **PostgreSQL array** results automatically converted to native JavaScript arrays +- Comprehensive PostgreSQL data type coverage: numeric, string, boolean, date/time, `BYTEA`, JSON/JSONB, UUID, network (INET/CIDR), range, and arrays of all supported types +- Inline (`::type`) and parameter-based type casting, with more informative error messages ## Simple Examples @@ -197,7 +187,7 @@ The AWS Data API offers a [built-in JSON format option](https://docs.aws.amazon. **No Additional Limitations:** AWS's JSON support requires unique column names and has a 10MB response limit. The Data API Client works with any column configuration and imposes no additional size restrictions. -In summary, AWS's JSON support is a basic convenience feature, while the **Data API Client** provides true type intelligence, format flexibility, and seamless handling of complex PostgreSQL features that the native Data API doesn't support well. +AWS's JSON support is a basic convenience feature. The **Data API Client** provides true type intelligence, format flexibility, and seamless handling of complex PostgreSQL features that the native Data API doesn't support well. ## Installation and Setup @@ -855,10 +845,78 @@ const db = new Kysely({ const users = await db.selectFrom('users').selectAll().where('id', '=', 123).execute() ``` +#### Knex Query Builder + +Knex doesn't accept an injected pool like Drizzle and Kysely. It constructs its own +driver internally, so the `data-api-client/compat/knex` helpers return a custom Knex +`client` class wired to the Data API, which you pass as `client`: + +**MySQL with Knex:** + +```typescript +import knex from 'knex' +import { createKnexMySQLClient } from 'data-api-client/compat/knex' + +const db = knex({ + client: createKnexMySQLClient({ + resourceArn: 'arn:...', + secretArn: 'arn:...', + database: 'myDatabase' + }), + connection: {} +}) + +// Use Knex normally +const users = await db('users').where('id', 123).select('*') +``` + +**PostgreSQL with Knex:** + +```typescript +import knex from 'knex' +import { createKnexPgClient } from 'data-api-client/compat/knex' + +const db = knex({ + client: createKnexPgClient({ + resourceArn: 'arn:...', + secretArn: 'arn:...', + database: 'myDatabase' + }), + connection: {} +}) + +// Use Knex normally +const id = await db('users').insert({ name: 'Alice' }).returning('id') +``` + +> **Note:** `knex` is an optional peer dependency. Install it alongside `data-api-client` +> to use these helpers. + +Knex transactions work. The compat layer intercepts the `BEGIN`/`COMMIT`/`ROLLBACK` +SQL that Knex issues and maps it to the Data API transaction lifecycle: + +```typescript +await db.transaction(async (trx) => { + const [userId] = await trx('users').insert({ name: 'Alice' }).returning('id') + await trx('posts').insert({ user_id: userId, title: 'Hello' }) +}) // commits on success, rolls back if the callback throws +``` + +> **Nested transactions are not supported.** They require SQL `SAVEPOINT`s, which the RDS +> Data API has no primitive for, so a nested `trx.transaction(...)` throws. A single +> top-level transaction works as shown above. + +The common query-builder syntax is covered by integration tests for both engines: +selects/`distinct`/`pluck`/`first`, the `where` family (`whereIn`, `whereNull`, +`whereBetween`, `whereExists`, `whereRaw`, and so on), joins, `groupBy`/`having`/aggregates, +`orderBy`/`limit`/`offset`, unions, subqueries, CTEs (`with`), `insert`/`update`/`del`, +`returning` (PostgreSQL), `increment`/`decrement`, and `onConflict().merge()` upserts. +Streaming via `.stream()` is not supported, since the Data API has no cursor API. + **Benefits of Compatibility Layers:** - **Zero code changes** when migrating from mysql2 or pg -- **Full ORM support** (Drizzle, Kysely) +- **Full ORM support** (Drizzle, Kysely, Knex) - **Automatic retry logic** for cluster wake-ups - **Connection pooling simulation** (getConnection, release) - **Both Promise and callback APIs** supported @@ -919,7 +977,7 @@ await data.query('INSERT INTO products (tags) VALUES (ARRAY[:tag1, :tag2, :tag3] }) ``` -Despite these input limitations, **all array results are automatically converted to native JavaScript arrays**, making it easy to work with PostgreSQL array data in your application. `NULL` elements inside arrays round-trip correctly — e.g. `'{1,NULL,3}'::int[]` deserializes to `[1, null, 3]`. +Despite these input limitations, **all array results are automatically converted to native JavaScript arrays**, making it easy to work with PostgreSQL array data in your application. `NULL` elements inside arrays round-trip correctly. For example, `'{1,NULL,3}'::int[]` deserializes to `[1, null, 3]`. ## PostgreSQL Data Type Support diff --git a/integration-tests/knex-mysql-querybuilder.int.test.ts b/integration-tests/knex-mysql-querybuilder.int.test.ts new file mode 100644 index 0000000..6dcf0e5 --- /dev/null +++ b/integration-tests/knex-mysql-querybuilder.int.test.ts @@ -0,0 +1,249 @@ +/** + * Knex query-builder coverage over the Data API (MySQL). + * + * Mirrors knex-pg-querybuilder for the MySQL-supported subset of common + * query-builder syntax from https://knexjs.org/guide/query-builder.html. + * MySQL has no RETURNING (inserts yield insertId) and uses ON DUPLICATE KEY + * UPDATE for upserts. + */ +import { describe, test, expect, beforeAll, afterAll } from 'vitest' +import knexLib, { type Knex } from 'knex' +import { createKnexMySQLClient } from '../src/compat/knex' +import { loadConfig } from './setup' + +const USERS = 'qb_mysql_users' +const POSTS = 'qb_mysql_posts' + +describe('Knex query-builder coverage (MySQL)', () => { + let db: Knex + + beforeAll(async () => { + const cfg = loadConfig('mysql') + db = knexLib({ + client: createKnexMySQLClient({ + resourceArn: cfg.resourceArn, + secretArn: cfg.secretArn, + database: cfg.database, + options: { region: cfg.region } + }) as never, + connection: {}, + pool: { min: 0, max: 1 } + }) + + await db.schema.dropTableIfExists(POSTS) + await db.schema.dropTableIfExists(USERS) + await db.schema.createTable(USERS, (t) => { + t.increments('id').primary() + t.string('name').notNullable() + t.string('email').unique().notNullable() + t.integer('age') + t.string('dept') + t.boolean('active').defaultTo(true) + }) + await db.schema.createTable(POSTS, (t) => { + t.increments('id').primary() + t.integer('user_id') + t.string('title') + t.integer('votes').defaultTo(0) + }) + + await db(USERS).insert([ + { name: 'Alice', email: 'alice@x.com', age: 30, dept: 'eng' }, + { name: 'Bob', email: 'bob@x.com', age: 25, dept: 'eng' }, + { name: 'Carol', email: 'carol@x.com', age: 35, dept: 'sales' }, + { name: 'Dave', email: 'dave@x.com', age: null, dept: 'sales' } + ]) + await db(POSTS).insert([ + { user_id: 1, title: 'A1', votes: 5 }, + { user_id: 1, title: 'A2', votes: 3 }, + { user_id: 2, title: 'B1', votes: 10 } + ]) + }, 90000) + + afterAll(async () => { + if (db) { + await db.schema.dropTableIfExists(POSTS) + await db.schema.dropTableIfExists(USERS) + await db.destroy() + } + }) + + // ---- Selection & projection ---- + test('select specific columns + alias', async () => { + const rows = await db(USERS).select('name', 'age as years').orderBy('id').limit(1) + expect(rows[0]).toEqual({ name: 'Alice', years: 30 }) + }) + + test('distinct', async () => { + const rows = await db(USERS).distinct('dept').orderBy('dept') + expect(rows.map((r) => r.dept)).toEqual(['eng', 'sales']) + }) + + test('pluck', async () => { + const names = await db(USERS).orderBy('id').pluck('name') + expect(names).toEqual(['Alice', 'Bob', 'Carol', 'Dave']) + }) + + test('first', async () => { + const row = await db(USERS).where({ name: 'Bob' }).first() + expect(row?.email).toBe('bob@x.com') + }) + + // ---- Where variants ---- + test('where / andWhere / orWhere', async () => { + const rows = await db(USERS).where('dept', 'eng').andWhere('age', '>', 26).orWhere('name', 'Carol') + expect(rows.map((r) => r.name).sort()).toEqual(['Alice', 'Carol']) + }) + + test('whereNot', async () => { + const rows = await db(USERS).whereNot('dept', 'eng') + expect(rows.every((r) => r.dept === 'sales')).toBe(true) + }) + + test('whereIn / whereNotIn', async () => { + const inRows = await db(USERS).whereIn('name', ['Alice', 'Bob']).pluck('name') + expect(inRows.sort()).toEqual(['Alice', 'Bob']) + const notIn = await db(USERS).whereNotIn('name', ['Alice', 'Bob', 'Carol']).pluck('name') + expect(notIn).toEqual(['Dave']) + }) + + test('whereNull / whereNotNull', async () => { + expect(await db(USERS).whereNull('age').pluck('name')).toEqual(['Dave']) + expect(await db(USERS).whereNotNull('age').pluck('name')).toHaveLength(3) + }) + + test('whereBetween', async () => { + const rows = await db(USERS).whereBetween('age', [26, 34]).pluck('name') + expect(rows).toEqual(['Alice']) + }) + + test('LIKE via where(col, "like", val)', async () => { + // NOTE: Knex's dedicated whereLike() appends `COLLATE utf8_bin`, which MySQL + // rejects on utf8mb4 columns (a Knex/MySQL quirk, not a compat-layer issue — + // it affects real mysql2 too). The portable LIKE form works: + const rows = await db(USERS).where('email', 'like', '%@x.com').pluck('name') + expect(rows).toHaveLength(4) + }) + + test('whereRaw', async () => { + const rows = await db(USERS).whereRaw('age > ?', [28]).pluck('name') + expect(rows.sort()).toEqual(['Alice', 'Carol']) + }) + + test('whereExists (subquery)', async () => { + const rows = await db(USERS) + .whereExists(function () { + this.select('*').from(POSTS).whereRaw(`${POSTS}.user_id = ${USERS}.id`) + }) + .pluck('name') + expect(rows.sort()).toEqual(['Alice', 'Bob']) + }) + + // ---- Joins ---- + test('innerJoin', async () => { + const rows = await db(USERS) + .innerJoin(POSTS, `${USERS}.id`, `${POSTS}.user_id`) + .select(`${USERS}.name`, `${POSTS}.title`) + .orderBy(`${POSTS}.title`) + expect(rows).toHaveLength(3) + expect(rows[0]).toEqual({ name: 'Alice', title: 'A1' }) + }) + + test('leftJoin + count + groupBy', async () => { + const rows = await db(USERS) + .leftJoin(POSTS, `${USERS}.id`, `${POSTS}.user_id`) + .select(`${USERS}.name`) + .count(`${POSTS}.id as posts`) + .groupBy(`${USERS}.name`) + const carol = rows.find((r) => r.name === 'Carol') + expect(Number(carol.posts)).toBe(0) + }) + + // ---- Grouping & aggregation ---- + test('groupBy + having + count', async () => { + const rows = await db(USERS).select('dept').count('id as c').groupBy('dept').having(db.raw('count(id)'), '>', 1) + expect(rows.every((r) => Number(r.c) > 1)).toBe(true) + }) + + test('aggregates min/max/sum/avg', async () => { + const [agg] = await db(USERS).min('age as mn').max('age as mx').sum('age as sm').avg('age as av') + expect(Number(agg.mn)).toBe(25) + expect(Number(agg.mx)).toBe(35) + expect(Number(agg.sm)).toBe(90) + expect(Math.round(Number(agg.av))).toBe(30) + }) + + // ---- Ordering & pagination ---- + test('orderBy + limit + offset', async () => { + const rows = await db(USERS).whereNotNull('age').orderBy('age', 'desc').limit(2).offset(1).pluck('name') + expect(rows).toEqual(['Alice', 'Bob']) + }) + + // ---- Set operations ---- + test('union', async () => { + const rows = await db(USERS) + .select('name') + .where('dept', 'eng') + .union(function () { + this.select('name').from(USERS).where('dept', 'sales') + }) + expect(rows).toHaveLength(4) + }) + + // ---- Modification ---- + test('insert returns insertId', async () => { + const [id] = await db(USERS).insert({ name: 'Eve', email: 'eve@x.com', age: 28, dept: 'eng' }) + expect(id).toBeGreaterThan(0) + await db(USERS).where({ name: 'Eve' }).del() + }) + + test('batch insert', async () => { + await db(USERS).insert([ + { name: 'F1', email: 'f1@x.com', dept: 'eng' }, + { name: 'F2', email: 'f2@x.com', dept: 'eng' } + ]) + expect(await db(USERS).whereIn('name', ['F1', 'F2']).pluck('name')).toHaveLength(2) + await db(USERS).whereIn('name', ['F1', 'F2']).del() + }) + + test('update returns affected count', async () => { + const affected = await db(USERS).where({ name: 'Bob' }).update({ age: 26 }) + expect(affected).toBe(1) + await db(USERS).where({ name: 'Bob' }).update({ age: 25 }) + }) + + test('del returns affected count', async () => { + await db(USERS).insert({ name: 'Tmp', email: 'tmp@x.com', dept: 'eng' }) + const deleted = await db(USERS).where({ name: 'Tmp' }).del() + expect(deleted).toBe(1) + }) + + test('increment / decrement', async () => { + await db(POSTS).where({ title: 'A1' }).increment('votes', 2) + expect((await db(POSTS).where({ title: 'A1' }).first()).votes).toBe(7) + await db(POSTS).where({ title: 'A1' }).decrement('votes', 2) + expect((await db(POSTS).where({ title: 'A1' }).first()).votes).toBe(5) + }) + + test('onConflict merge (ON DUPLICATE KEY UPDATE)', async () => { + await db(USERS) + .insert({ name: 'Alice Updated', email: 'alice@x.com', age: 31, dept: 'eng' }) + .onConflict('email') + .merge() + expect((await db(USERS).where({ email: 'alice@x.com' }).first()).name).toBe('Alice Updated') + await db(USERS).where({ email: 'alice@x.com' }).update({ name: 'Alice', age: 30 }) + }) + + // ---- Advanced ---- + test('raw query', async () => { + const [rows] = await db.raw('SELECT count(*) AS n FROM ??', [USERS]) + expect(Number(rows[0].n)).toBe(4) + }) + + test('subquery in where', async () => { + const rows = await db(USERS) + .whereIn('id', db(POSTS).distinct('user_id')) + .pluck('name') + expect(rows.sort()).toEqual(['Alice', 'Bob']) + }) +}) diff --git a/integration-tests/knex-mysql.int.test.ts b/integration-tests/knex-mysql.int.test.ts index 99f8d20..c0d0670 100644 --- a/integration-tests/knex-mysql.int.test.ts +++ b/integration-tests/knex-mysql.int.test.ts @@ -1,519 +1,130 @@ /** - * Knex with MySQL2 Compatibility Integration Tests + * Knex with MySQL via the Data API compatibility layer * - * NOTE: These tests are skipped because Knex requires a different integration approach. - * When you pass `connection: pool`, Knex tries to use it as a config object to create - * a real MySQL2 connection, rather than using the pool's query methods directly. - * - * Knex works differently from Kysely - it expects to manage its own connection pool - * and doesn't support drop-in pool replacement. To use Knex with Data API, you would - * need to create a custom Knex dialect, which is beyond the scope of this compatibility layer. - * - * For a working ORM solution with the MySQL compatibility layer, consider using raw - * mysql2 connection methods directly (see mysql2-compat.int.test.ts). + * Uses createKnexMySQLClient (src/compat/knex.ts), which subclasses Knex's + * mysql2 dialect and overrides _driver() so Knex drives a Data API-backed + * connection. Exercises the query builder end-to-end against a live cluster. */ - import { describe, test, expect, beforeAll, afterAll } from 'vitest' -import { RDSDataClient } from '@aws-sdk/client-rds-data' -import { createMySQLPool } from '../src/compat/mysql2' -import { loadConfig, type IntegrationTestConfig } from './setup' -import knex, { Knex } from 'knex' +import knexLib, { type Knex } from 'knex' +import { createKnexMySQLClient } from '../src/compat/knex' +import { loadConfig } from './setup' -describe.skip('Knex with MySQL2 Compat', () => { - let config: IntegrationTestConfig - let rdsClient: RDSDataClient - let pool: ReturnType +const TABLE = 'knex_users' + +describe('Knex with MySQL via Data API compat', () => { let db: Knex beforeAll(async () => { - config = loadConfig('mysql') - rdsClient = new RDSDataClient({ region: config.region }) - - // await waitForCluster(rdsClient, config) // No longer needed - automatic retry logic - - pool = createMySQLPool(config) - - db = knex({ - client: 'mysql2', - connection: pool as any - }) - - // Create test table using raw SQL - await pool.query(` - CREATE TABLE IF NOT EXISTS knex_users ( - id INT AUTO_INCREMENT PRIMARY KEY, - name VARCHAR(255) NOT NULL, - email VARCHAR(255) NOT NULL, - age INT, - active BOOLEAN DEFAULT 1, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP - ) - `) - - // Clear any existing data - await pool.query('DELETE FROM knex_users') - }, 60000) - - afterAll(async () => { - await pool.query('DROP TABLE IF EXISTS knex_users') - await db.destroy() - await pool.end() - rdsClient.destroy() - }, 60000) - - test('should insert records with Knex', async () => { - const result = await db('knex_users').insert({ - name: 'Alice', - email: 'alice@example.com', - age: 30, - active: true - }) - - expect(result[0]).toBeGreaterThan(0) // insertId - }) - - test('should select records with Knex', async () => { - // Insert test data - await db('knex_users').insert({ - name: 'Bob', - email: 'bob@example.com', - age: 25, - active: true - }) - - const result = await db('knex_users').where({ name: 'Bob' }).select('*') - - expect(result).toHaveLength(1) - expect(result[0].name).toBe('Bob') - expect(result[0].email).toBe('bob@example.com') - expect(result[0].age).toBe(25) - }) - - test('should select specific columns with Knex', async () => { - const result = await db('knex_users').where({ name: 'Bob' }).select('name', 'email') - - expect(result).toHaveLength(1) - expect(result[0].name).toBe('Bob') - expect(result[0].email).toBe('bob@example.com') - expect(result[0]).not.toHaveProperty('age') - }) - - test('should update records with Knex', async () => { - // Insert test data - await db('knex_users').insert({ - name: 'Charlie', - email: 'charlie@example.com', - age: 35, - active: true + const cfg = loadConfig('mysql') + db = knexLib({ + client: createKnexMySQLClient({ + resourceArn: cfg.resourceArn, + secretArn: cfg.secretArn, + database: cfg.database, + options: { region: cfg.region } + }) as never, + connection: {}, + // The Data API is connectionless; a single logical connection keeps the + // suite deterministic. + pool: { min: 0, max: 1 } }) - // Update the record - const updateResult = await db('knex_users').where({ name: 'Charlie' }).update({ age: 36 }) - - expect(updateResult).toBe(1) // affectedRows - - // Verify update - const result = await db('knex_users').where({ name: 'Charlie' }).first() - - expect(result.age).toBe(36) - }) - - test('should delete records with Knex', async () => { - // Insert test data - await db('knex_users').insert({ - name: 'David', - email: 'david@example.com', - age: 40, - active: true + await db.schema.dropTableIfExists(TABLE) + await db.schema.createTable(TABLE, (t) => { + t.increments('id').primary() + t.string('name').notNullable() + t.string('email').notNullable() + t.integer('age') + t.boolean('active').defaultTo(true) }) + }, 90000) - // Delete the record - const deleteResult = await db('knex_users').where({ name: 'David' }).delete() - - expect(deleteResult).toBe(1) // affectedRows - - // Verify deletion - const result = await db('knex_users').where({ name: 'David' }) - - expect(result).toHaveLength(0) - }) - - test('should handle complex WHERE clauses with Knex', async () => { - // Insert test data - await db('knex_users').insert([ - { name: 'Eve', email: 'eve@example.com', age: 28, active: true }, - { name: 'Frank', email: 'frank@example.com', age: 32, active: false }, - { name: 'Grace', email: 'grace@example.com', age: 29, active: true } - ]) - - // Query with multiple conditions - const result = await db('knex_users').where({ active: true }).andWhere('age', '>=', 28).select('*') - - expect(result.length).toBeGreaterThanOrEqual(2) - expect(result.every((u) => u.active === true || u.active === 1)).toBe(true) - }) - - test('should handle OR conditions with Knex', async () => { - const result = await db('knex_users').where({ name: 'Eve' }).orWhere({ name: 'Grace' }).select('*').orderBy('name') - - expect(result.length).toBeGreaterThanOrEqual(2) - expect(result.map((r) => r.name).sort()).toEqual(expect.arrayContaining(['Eve', 'Grace'])) - }) - - test('should handle ORDER BY with Knex', async () => { - const result = await db('knex_users').select('*').orderBy('age', 'asc') - - expect(result.length).toBeGreaterThan(0) - // Verify ascending order - for (let i = 1; i < result.length; i++) { - if (result[i].age !== null && result[i - 1].age !== null) { - expect(result[i].age).toBeGreaterThanOrEqual(result[i - 1].age) - } + afterAll(async () => { + if (db) { + await db.schema.dropTableIfExists(TABLE) + await db.destroy() } }) - test('should handle LIMIT and OFFSET with Knex', async () => { - const result = await db('knex_users').select('*').limit(2).offset(1) - - expect(result.length).toBeLessThanOrEqual(2) + test('insert returns generated id', async () => { + const ids = await db(TABLE).insert({ name: 'Alice', email: 'alice@example.com', age: 30 }) + expect(ids[0]).toBeGreaterThan(0) }) - test('should handle NULL values with Knex', async () => { - await db('knex_users').insert({ - name: 'NullAge', - email: 'nullage@example.com', - age: null, - active: true - }) - - const result = await db('knex_users').where({ name: 'NullAge' }).first() - - expect(result.age).toBeNull() + test('select all rows', async () => { + await db(TABLE).insert({ name: 'Bob', email: 'bob@example.com', age: 25 }) + const rows = await db(TABLE).select('*').orderBy('id') + expect(rows.length).toBeGreaterThanOrEqual(2) + expect(rows[0]).toHaveProperty('name') }) - test('should handle whereNull with Knex', async () => { - const result = await db('knex_users').whereNull('age').select('*') - - expect(result.length).toBeGreaterThanOrEqual(1) - expect(result.every((r) => r.age === null)).toBe(true) + test('where + first', async () => { + const row = await db(TABLE).where({ email: 'alice@example.com' }).first() + expect(row?.name).toBe('Alice') + expect(row?.age).toBe(30) }) - test('should handle whereNotNull with Knex', async () => { - const result = await db('knex_users').whereNotNull('age').select('*') - - expect(result.length).toBeGreaterThanOrEqual(1) - expect(result.every((r) => r.age !== null)).toBe(true) + test('parameterized where with bindings', async () => { + const rows = await db(TABLE).where('age', '>', 26).select('name') + const names = rows.map((r) => r.name) + expect(names).toContain('Alice') + expect(names).not.toContain('Bob') }) - test('should handle COUNT aggregate with Knex', async () => { - const result = await db('knex_users').count('id as count').first() - - expect(result?.count).toBeGreaterThan(0) + test('update returns affected row count', async () => { + const affected = await db(TABLE).where({ name: 'Bob' }).update({ age: 26 }) + expect(affected).toBe(1) + const bob = await db(TABLE).where({ name: 'Bob' }).first() + expect(bob?.age).toBe(26) }) - test('should handle AVG aggregate with Knex', async () => { - const result = await db('knex_users').avg('age as avg_age').first() - - expect(result?.avg_age).toBeDefined() + test('count aggregate', async () => { + const result = await db(TABLE).count<{ c: number }[]>({ c: '*' }) + expect(Number(result[0].c)).toBeGreaterThanOrEqual(2) }) - test('should handle MIN/MAX aggregates with Knex', async () => { - const result = await db('knex_users').min('age as min_age').max('age as max_age').first() - - expect(result?.min_age).toBeDefined() - expect(result?.max_age).toBeDefined() - if (result?.min_age !== null && result?.max_age !== null) { - expect(result.max_age).toBeGreaterThanOrEqual(result.min_age) - } + test('orderBy + limit', async () => { + const rows = await db(TABLE).select('name').orderBy('age', 'desc').limit(1) + expect(rows).toHaveLength(1) }) - test('should handle whereIn with Knex', async () => { - const result = await db('knex_users').whereIn('name', ['Alice', 'Bob', 'Charlie']).select('*') - - expect(result.length).toBeGreaterThanOrEqual(1) - expect(result.every((r) => ['Alice', 'Bob', 'Charlie'].includes(r.name))).toBe(true) + test('delete returns affected row count', async () => { + const deleted = await db(TABLE).where({ name: 'Bob' }).del() + expect(deleted).toBe(1) + const remaining = await db(TABLE).select('*') + expect(remaining.every((r) => r.name !== 'Bob')).toBe(true) }) - test('should handle whereBetween with Knex', async () => { - const result = await db('knex_users').whereBetween('age', [25, 35]).select('*') - - expect(result.length).toBeGreaterThanOrEqual(1) - expect(result.every((r) => r.age === null || (r.age >= 25 && r.age <= 35))).toBe(true) - }) - - test('should handle raw SQL with Knex', async () => { - const result = await db.raw('SELECT * FROM knex_users WHERE age > ?', [25]) - - expect(result[0].length).toBeGreaterThan(0) - expect(result[0].every((r: any) => r.age === null || r.age > 25)).toBe(true) - }) - - test('should handle multiple inserts and return insert IDs', async () => { - const result = await db('knex_users').insert([ - { name: 'Henry', email: 'henry@example.com', age: 45 }, - { name: 'Iris', email: 'iris@example.com', age: 33 } - ]) - - // First insert ID is returned - expect(result[0]).toBeGreaterThan(0) - }) - - test('should handle distinct with Knex', async () => { - // Insert duplicate ages - await db('knex_users').insert([ - { name: 'Jack', email: 'jack@example.com', age: 50 }, - { name: 'Kate', email: 'kate@example.com', age: 50 } - ]) - - const result = await db('knex_users').distinct('age').whereNotNull('age').orderBy('age') - - const ages = result.map((r) => r.age) - const uniqueAges = [...new Set(ages)] - - expect(ages).toEqual(uniqueAges) - }) -}) - -describe.skip('Knex Transactions with MySQL2 Compat', () => { - let config: IntegrationTestConfig - let rdsClient: RDSDataClient - let pool: ReturnType - let db: Knex - - beforeAll(async () => { - config = loadConfig('mysql') - rdsClient = new RDSDataClient({ region: config.region }) - - // await waitForCluster(rdsClient, config) // No longer needed - automatic retry logic - - pool = createMySQLPool(config) - - db = knex({ - client: 'mysql2', - connection: pool as any - }) - - // Create test table - await pool.query(` - CREATE TABLE IF NOT EXISTS knex_tx_test ( - id INT AUTO_INCREMENT PRIMARY KEY, - name VARCHAR(255) NOT NULL, - email VARCHAR(255) NOT NULL - ) - `) - - // Clear any existing data - await pool.query('DELETE FROM knex_tx_test') - }, 60000) - - afterAll(async () => { - await pool.query('DROP TABLE IF EXISTS knex_tx_test') - await db.destroy() - await pool.end() - rdsClient.destroy() - }, 60000) - - test('should commit transaction with Knex', async () => { + // Transactions: Knex issues literal BEGIN/COMMIT/ROLLBACK SQL, which the + // compat layer intercepts and maps to the Data API transaction lifecycle. + test('transaction commits', async () => { await db.transaction(async (trx) => { - await trx('knex_tx_test').insert({ - name: 'TxUser1', - email: 'txuser1@example.com' - }) - - await trx('knex_tx_test').insert({ - name: 'TxUser2', - email: 'txuser2@example.com' - }) + await trx(TABLE).insert({ name: 'Txn Commit', email: 'txc@example.com', age: 40 }) }) - - // Verify both records were inserted - const result = await db('knex_tx_test').select('*') - - expect(result.length).toBeGreaterThanOrEqual(2) - expect(result.some((r) => r.name === 'TxUser1')).toBe(true) - expect(result.some((r) => r.name === 'TxUser2')).toBe(true) + const row = await db(TABLE).where({ email: 'txc@example.com' }).first() + expect(row?.name).toBe('Txn Commit') }) - test('should rollback transaction on error with Knex', async () => { - const initialCount = await db('knex_tx_test').count('id as count').first() - - try { - await db.transaction(async (trx) => { - await trx('knex_tx_test').insert({ - name: 'TxRollback', - email: 'rollback@example.com' - }) - - // Force error to trigger rollback - throw new Error('Intentional rollback') + test('transaction rolls back on error', async () => { + await expect( + db.transaction(async (trx) => { + await trx(TABLE).insert({ name: 'Txn Rollback', email: 'txr@example.com', age: 41 }) + throw new Error('force rollback') }) - } catch (err) { - // Expected error - } - - // Verify record was NOT inserted - const result = await db('knex_tx_test').where({ name: 'TxRollback' }).select('*') - - expect(result).toHaveLength(0) - - // Verify count hasn't changed - const finalCount = await db('knex_tx_test').count('id as count').first() - - expect(finalCount?.count).toBe(initialCount?.count) + ).rejects.toThrow('force rollback') + const row = await db(TABLE).where({ email: 'txr@example.com' }).first() + expect(row).toBeUndefined() }) - test('should handle multiple operations in transaction', async () => { - await db.transaction(async (trx) => { - // Insert - await trx('knex_tx_test').insert({ - name: 'MultiOp1', - email: 'multiop1@example.com' + // Nested transactions need SAVEPOINTs, which the Data API does not support. + test('nested transactions (savepoints) are rejected', async () => { + await expect( + db.transaction(async (trx) => { + await trx.transaction(async () => { + /* inner uses SAVEPOINT — unsupported */ + }) }) - - // Update - await trx('knex_tx_test').where({ name: 'MultiOp1' }).update({ email: 'updated@example.com' }) - - // Select to verify - const result = await trx('knex_tx_test').where({ name: 'MultiOp1' }).first() - - expect(result.email).toBe('updated@example.com') - }) - - // Verify changes were committed - const result = await db('knex_tx_test').where({ name: 'MultiOp1' }).first() - - expect(result.email).toBe('updated@example.com') - }) -}) - -describe.skip('Knex Query Builder Features with MySQL2 Compat', () => { - let config: IntegrationTestConfig - let rdsClient: RDSDataClient - let pool: ReturnType - let db: Knex - - beforeAll(async () => { - config = loadConfig('mysql') - rdsClient = new RDSDataClient({ region: config.region }) - - // await waitForCluster(rdsClient, config) // No longer needed - automatic retry logic - - pool = createMySQLPool(config) - - db = knex({ - client: 'mysql2', - connection: pool as any - }) - - // Create test tables - await pool.query(` - CREATE TABLE IF NOT EXISTS knex_products ( - id INT AUTO_INCREMENT PRIMARY KEY, - name VARCHAR(255) NOT NULL, - price DECIMAL(10, 2) NOT NULL, - category VARCHAR(100) - ) - `) - - await pool.query(` - CREATE TABLE IF NOT EXISTS knex_orders ( - id INT AUTO_INCREMENT PRIMARY KEY, - product_id INT NOT NULL, - quantity INT NOT NULL, - FOREIGN KEY (product_id) REFERENCES knex_products(id) - ) - `) - - // Clear existing data - await pool.query('DELETE FROM knex_orders') - await pool.query('DELETE FROM knex_products') - - // Insert test data - const productIds = await db('knex_products').insert([ - { name: 'Laptop', price: 999.99, category: 'Electronics' }, - { name: 'Mouse', price: 29.99, category: 'Electronics' }, - { name: 'Desk', price: 299.99, category: 'Furniture' }, - { name: 'Chair', price: 199.99, category: 'Furniture' } - ]) - - // Insert orders using first product ID - await db('knex_orders').insert([ - { product_id: productIds[0], quantity: 2 }, - { product_id: productIds[0], quantity: 1 } - ]) - }, 60000) - - afterAll(async () => { - await pool.query('DROP TABLE IF EXISTS knex_orders') - await pool.query('DROP TABLE IF EXISTS knex_products') - await db.destroy() - await pool.end() - rdsClient.destroy() - }, 60000) - - test('should handle JOIN with Knex', async () => { - const result = await db('knex_orders') - .join('knex_products', 'knex_orders.product_id', 'knex_products.id') - .select('knex_orders.*', 'knex_products.name as product_name', 'knex_products.price') - - expect(result.length).toBeGreaterThan(0) - expect(result[0]).toHaveProperty('product_name') - expect(result[0]).toHaveProperty('price') - }) - - test('should handle LEFT JOIN with Knex', async () => { - const result = await db('knex_products') - .leftJoin('knex_orders', 'knex_products.id', 'knex_orders.product_id') - .select('knex_products.name', 'knex_orders.quantity') - - expect(result.length).toBeGreaterThanOrEqual(4) // All products - }) - - test('should handle GROUP BY with Knex', async () => { - const result = await db('knex_products') - .select('category') - .count('id as count') - .groupBy('category') - .orderBy('category') - - expect(result.length).toBe(2) // Electronics and Furniture - expect(result.every((r) => r.count >= 2)).toBe(true) - }) - - test('should handle HAVING with Knex', async () => { - const result = await db('knex_products') - .select('category') - .count('id as count') - .groupBy('category') - .having('count', '>=', 2) - - expect(result.length).toBe(2) - }) - - test('should handle subqueries with Knex', async () => { - const avgPrice = db('knex_products').avg('price as avg_price') - - const result = await db('knex_products').select('*').where('price', '>', avgPrice) - - expect(result.length).toBeGreaterThan(0) - }) - - test('should handle whereLike with Knex', async () => { - const result = await db('knex_products').where('name', 'like', '%top%').select('*') - - expect(result.length).toBeGreaterThanOrEqual(1) - expect(result.some((r) => r.name.toLowerCase().includes('top'))).toBe(true) - }) - - test('should handle orderBy multiple columns with Knex', async () => { - const result = await db('knex_products').select('*').orderBy('category').orderBy('price', 'desc') - - expect(result.length).toBeGreaterThan(0) - // Verify ordering - for (let i = 1; i < result.length; i++) { - if (result[i].category === result[i - 1].category) { - expect(parseFloat(result[i].price)).toBeLessThanOrEqual(parseFloat(result[i - 1].price)) - } - } + ).rejects.toThrow(/Nested transactions/) }) }) diff --git a/integration-tests/knex-pg-querybuilder.int.test.ts b/integration-tests/knex-pg-querybuilder.int.test.ts new file mode 100644 index 0000000..bd5f2a7 --- /dev/null +++ b/integration-tests/knex-pg-querybuilder.int.test.ts @@ -0,0 +1,259 @@ +/** + * Knex query-builder coverage over the Data API (PostgreSQL). + * + * Exercises the common query-builder syntax from + * https://knexjs.org/guide/query-builder.html to verify the compat layer + * executes whatever SQL Knex generates. Knex owns SQL generation; these tests + * confirm bindings, result shapes, and transaction/returning behavior. + */ +import { describe, test, expect, beforeAll, afterAll } from 'vitest' +import knexLib, { type Knex } from 'knex' +import { createKnexPgClient } from '../src/compat/knex' +import { loadConfig } from './setup' + +const USERS = 'qb_pg_users' +const POSTS = 'qb_pg_posts' + +describe('Knex query-builder coverage (PostgreSQL)', () => { + let db: Knex + + beforeAll(async () => { + const cfg = loadConfig('pg') + db = knexLib({ + client: createKnexPgClient({ + resourceArn: cfg.resourceArn, + secretArn: cfg.secretArn, + database: cfg.database, + options: { region: cfg.region } + }) as never, + connection: {}, + pool: { min: 0, max: 1 } + }) + + await db.schema.dropTableIfExists(POSTS) + await db.schema.dropTableIfExists(USERS) + await db.schema.createTable(USERS, (t) => { + t.increments('id').primary() + t.string('name').notNullable() + t.string('email').unique().notNullable() + t.integer('age') + t.string('dept') + t.boolean('active').defaultTo(true) + }) + await db.schema.createTable(POSTS, (t) => { + t.increments('id').primary() + t.integer('user_id').references('id').inTable(USERS) + t.string('title') + t.integer('votes').defaultTo(0) + }) + + await db(USERS).insert([ + { name: 'Alice', email: 'alice@x.com', age: 30, dept: 'eng' }, + { name: 'Bob', email: 'bob@x.com', age: 25, dept: 'eng' }, + { name: 'Carol', email: 'carol@x.com', age: 35, dept: 'sales' }, + { name: 'Dave', email: 'dave@x.com', age: null, dept: 'sales' } + ]) + await db(POSTS).insert([ + { user_id: 1, title: 'A1', votes: 5 }, + { user_id: 1, title: 'A2', votes: 3 }, + { user_id: 2, title: 'B1', votes: 10 } + ]) + }, 90000) + + afterAll(async () => { + if (db) { + await db.schema.dropTableIfExists(POSTS) + await db.schema.dropTableIfExists(USERS) + await db.destroy() + } + }) + + // ---- Selection & projection ---- + test('select specific columns + alias', async () => { + const rows = await db(USERS).select('name', 'age as years').orderBy('id').limit(1) + expect(rows[0]).toEqual({ name: 'Alice', years: 30 }) + }) + + test('distinct', async () => { + const rows = await db(USERS).distinct('dept').orderBy('dept') + expect(rows.map((r) => r.dept)).toEqual(['eng', 'sales']) + }) + + test('pluck', async () => { + const names = await db(USERS).orderBy('id').pluck('name') + expect(names).toEqual(['Alice', 'Bob', 'Carol', 'Dave']) + }) + + test('first', async () => { + const row = await db(USERS).where({ name: 'Bob' }).first() + expect(row?.email).toBe('bob@x.com') + }) + + // ---- Where variants ---- + test('where / andWhere / orWhere', async () => { + const rows = await db(USERS).where('dept', 'eng').andWhere('age', '>', 26).orWhere('name', 'Carol') + const names = rows.map((r) => r.name).sort() + expect(names).toEqual(['Alice', 'Carol']) + }) + + test('whereNot', async () => { + const rows = await db(USERS).whereNot('dept', 'eng') + expect(rows.every((r) => r.dept === 'sales')).toBe(true) + }) + + test('whereIn / whereNotIn', async () => { + const inRows = await db(USERS).whereIn('name', ['Alice', 'Bob']).pluck('name') + expect(inRows.sort()).toEqual(['Alice', 'Bob']) + const notIn = await db(USERS).whereNotIn('name', ['Alice', 'Bob', 'Carol']).pluck('name') + expect(notIn).toEqual(['Dave']) + }) + + test('whereNull / whereNotNull', async () => { + const nulls = await db(USERS).whereNull('age').pluck('name') + expect(nulls).toEqual(['Dave']) + const notNulls = await db(USERS).whereNotNull('age').pluck('name') + expect(notNulls).toHaveLength(3) + }) + + test('whereBetween', async () => { + const rows = await db(USERS).whereBetween('age', [26, 34]).pluck('name') + expect(rows).toEqual(['Alice']) + }) + + test('whereLike / whereILike', async () => { + const rows = await db(USERS).whereILike('email', '%@x.com').pluck('name') + expect(rows).toHaveLength(4) + }) + + test('whereRaw', async () => { + const rows = await db(USERS).whereRaw('age > ?', [28]).pluck('name') + expect(rows.sort()).toEqual(['Carol'].concat(['Alice']).sort()) + }) + + test('whereExists (subquery)', async () => { + const rows = await db(USERS) + .whereExists(function () { + this.select('*').from(POSTS).whereRaw(`${POSTS}.user_id = ${USERS}.id`) + }) + .pluck('name') + expect(rows.sort()).toEqual(['Alice', 'Bob']) + }) + + // ---- Joins ---- + test('innerJoin', async () => { + const rows = await db(USERS) + .innerJoin(POSTS, `${USERS}.id`, `${POSTS}.user_id`) + .select(`${USERS}.name`, `${POSTS}.title`) + .orderBy(`${POSTS}.title`) + expect(rows).toHaveLength(3) + expect(rows[0]).toEqual({ name: 'Alice', title: 'A1' }) + }) + + test('leftJoin', async () => { + const rows = await db(USERS) + .leftJoin(POSTS, `${USERS}.id`, `${POSTS}.user_id`) + .select(`${USERS}.name`) + .count(`${POSTS}.id as posts`) + .groupBy(`${USERS}.name`) + const carol = rows.find((r) => r.name === 'Carol') + expect(Number(carol.posts)).toBe(0) + }) + + // ---- Grouping & aggregation ---- + test('groupBy + having + count', async () => { + const rows = await db(USERS).select('dept').count('id as c').groupBy('dept').having(db.raw('count(id)'), '>', 1) + expect(rows.every((r) => Number(r.c) > 1)).toBe(true) + }) + + test('aggregates min/max/sum/avg', async () => { + const [agg] = await db(USERS).min('age as mn').max('age as mx').sum('age as sm').avg('age as av') + expect(Number(agg.mn)).toBe(25) + expect(Number(agg.mx)).toBe(35) + expect(Number(agg.sm)).toBe(90) + expect(Math.round(Number(agg.av))).toBe(30) + }) + + // ---- Ordering & pagination ---- + test('orderBy + limit + offset', async () => { + // whereNotNull avoids Postgres NULLS-FIRST ambiguity on DESC ordering + const rows = await db(USERS).whereNotNull('age').orderBy('age', 'desc').limit(2).offset(1).pluck('name') + expect(rows).toEqual(['Alice', 'Bob']) + }) + + // ---- Set operations ---- + test('union', async () => { + const rows = await db(USERS) + .select('name') + .where('dept', 'eng') + .union(function () { + this.select('name').from(USERS).where('dept', 'sales') + }) + expect(rows).toHaveLength(4) + }) + + // ---- Modification ---- + test('insert + returning', async () => { + const [row] = await db(USERS).insert({ name: 'Eve', email: 'eve@x.com', age: 28, dept: 'eng' }).returning(['id', 'name']) + expect(row.id).toBeGreaterThan(0) + expect(row.name).toBe('Eve') + await db(USERS).where({ name: 'Eve' }).del() + }) + + test('batch insert', async () => { + const ids = await db(USERS) + .insert([ + { name: 'F1', email: 'f1@x.com', dept: 'eng' }, + { name: 'F2', email: 'f2@x.com', dept: 'eng' } + ]) + .returning('id') + expect(ids).toHaveLength(2) + await db(USERS).whereIn('name', ['F1', 'F2']).del() + }) + + test('update + returning', async () => { + const [row] = await db(USERS).where({ name: 'Bob' }).update({ age: 26 }).returning(['name', 'age']) + expect(row).toEqual({ name: 'Bob', age: 26 }) + await db(USERS).where({ name: 'Bob' }).update({ age: 25 }) + }) + + test('increment / decrement', async () => { + await db(POSTS).where({ title: 'A1' }).increment('votes', 2) + let p = await db(POSTS).where({ title: 'A1' }).first() + expect(p.votes).toBe(7) + await db(POSTS).where({ title: 'A1' }).decrement('votes', 2) + p = await db(POSTS).where({ title: 'A1' }).first() + expect(p.votes).toBe(5) + }) + + test('onConflict merge (upsert)', async () => { + await db(USERS) + .insert({ name: 'Alice Updated', email: 'alice@x.com', age: 31, dept: 'eng' }) + .onConflict('email') + .merge() + const a = await db(USERS).where({ email: 'alice@x.com' }).first() + expect(a.name).toBe('Alice Updated') + await db(USERS).where({ email: 'alice@x.com' }).update({ name: 'Alice', age: 30 }) + }) + + // ---- Advanced ---- + test('raw query', async () => { + const result = await db.raw('SELECT count(*)::int AS n FROM ??', [USERS]) + expect(result.rows[0].n).toBe(4) + }) + + test('with (CTE)', async () => { + const rows = await db + .with('eng', (qb) => qb.from(USERS).where('dept', 'eng').select('name')) + .select('*') + .from('eng') + .orderBy('name') + expect(rows.map((r) => r.name)).toEqual(['Alice', 'Bob']) + }) + + test('subquery in where', async () => { + const rows = await db(USERS) + .whereIn('id', db(POSTS).distinct('user_id')) + .pluck('name') + expect(rows.sort()).toEqual(['Alice', 'Bob']) + }) +}) diff --git a/integration-tests/knex-pg.int.test.ts b/integration-tests/knex-pg.int.test.ts new file mode 100644 index 0000000..ef66f7f --- /dev/null +++ b/integration-tests/knex-pg.int.test.ts @@ -0,0 +1,128 @@ +/** + * Knex with PostgreSQL via the Data API compatibility layer + * + * Uses createKnexPgClient (src/compat/knex.ts), which subclasses Knex's pg + * dialect and overrides _driver() so Knex drives a Data API-backed pg client. + * The pg dialect is stricter than mysql2 — it constructs `new driver.Client()`, + * expects connect() to return a Promise, and runs a `select version();` check + * on first acquire — so this exercises more of the connection contract. + */ +import { describe, test, expect, beforeAll, afterAll } from 'vitest' +import knexLib, { type Knex } from 'knex' +import { createKnexPgClient } from '../src/compat/knex' +import { loadConfig } from './setup' + +const TABLE = 'knex_pg_users' + +describe('Knex with PostgreSQL via Data API compat', () => { + let db: Knex + + beforeAll(async () => { + const cfg = loadConfig('pg') + db = knexLib({ + client: createKnexPgClient({ + resourceArn: cfg.resourceArn, + secretArn: cfg.secretArn, + database: cfg.database, + options: { region: cfg.region } + }) as never, + connection: {}, + pool: { min: 0, max: 1 } + }) + + await db.schema.dropTableIfExists(TABLE) + await db.schema.createTable(TABLE, (t) => { + t.increments('id').primary() + t.string('name').notNullable() + t.string('email').notNullable() + t.integer('age') + t.boolean('active').defaultTo(true) + }) + }, 90000) + + afterAll(async () => { + if (db) { + await db.schema.dropTableIfExists(TABLE) + await db.destroy() + } + }) + + test('insert with returning id', async () => { + const rows = await db(TABLE).insert({ name: 'Alice', email: 'alice@example.com', age: 30 }).returning('id') + expect(rows[0].id).toBeGreaterThan(0) + }) + + test('select all rows', async () => { + await db(TABLE).insert({ name: 'Bob', email: 'bob@example.com', age: 25 }) + const rows = await db(TABLE).select('*').orderBy('id') + expect(rows.length).toBeGreaterThanOrEqual(2) + expect(rows[0]).toHaveProperty('name') + }) + + test('where + first', async () => { + const row = await db(TABLE).where({ email: 'alice@example.com' }).first() + expect(row?.name).toBe('Alice') + expect(row?.age).toBe(30) + }) + + test('parameterized where with $-bindings', async () => { + const rows = await db(TABLE).where('age', '>', 26).select('name') + const names = rows.map((r) => r.name) + expect(names).toContain('Alice') + expect(names).not.toContain('Bob') + }) + + test('update with returning', async () => { + const rows = await db(TABLE).where({ name: 'Bob' }).update({ age: 26 }).returning(['id', 'age']) + expect(rows[0].age).toBe(26) + }) + + test('count aggregate', async () => { + const result = await db(TABLE).count<{ c: number }[]>({ c: '*' }) + expect(Number(result[0].c)).toBeGreaterThanOrEqual(2) + }) + + test('orderBy + limit', async () => { + const rows = await db(TABLE).select('name').orderBy('age', 'desc').limit(1) + expect(rows).toHaveLength(1) + }) + + test('delete returns affected row count', async () => { + const deleted = await db(TABLE).where({ name: 'Bob' }).del() + expect(deleted).toBe(1) + const remaining = await db(TABLE).select('*') + expect(remaining.every((r) => r.name !== 'Bob')).toBe(true) + }) + + // Transactions: Knex issues literal BEGIN/COMMIT/ROLLBACK SQL, which the + // compat layer intercepts and maps to the Data API transaction lifecycle. + test('transaction commits', async () => { + await db.transaction(async (trx) => { + await trx(TABLE).insert({ name: 'Txn Commit', email: 'txc@example.com', age: 40 }) + }) + const row = await db(TABLE).where({ email: 'txc@example.com' }).first() + expect(row?.name).toBe('Txn Commit') + }) + + test('transaction rolls back on error', async () => { + await expect( + db.transaction(async (trx) => { + await trx(TABLE).insert({ name: 'Txn Rollback', email: 'txr@example.com', age: 41 }) + throw new Error('force rollback') + }) + ).rejects.toThrow('force rollback') + const row = await db(TABLE).where({ email: 'txr@example.com' }).first() + expect(row).toBeUndefined() + }) + + // Nested transactions need SAVEPOINTs, which the Data API does not support. + test('nested transactions (savepoints) are rejected', async () => { + await expect( + db.transaction(async (trx) => { + await trx.transaction(async () => { + /* inner uses SAVEPOINT — unsupported */ + }) + }) + ).rejects.toThrow(/Nested transactions/) + }) +}) diff --git a/package-lock.json b/package-lock.json index bab6ea6..adb7799 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "data-api-client", - "version": "2.2.0", + "version": "2.3.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "data-api-client", - "version": "2.2.0", + "version": "2.3.0", "license": "MIT", "dependencies": { "sqlstring": "^2.3.2" @@ -30,12 +30,19 @@ "typescript": "^5.9.3", "vitest": "^4.1.8" }, + "engines": { + "node": ">=20" + }, "peerDependencies": { - "@aws-sdk/client-rds-data": "^3.1048.0" + "@aws-sdk/client-rds-data": "^3.1048.0", + "knex": "^3.0.0" }, "peerDependenciesMeta": { "@aws-sdk/client-rds-data": { "optional": true + }, + "knex": { + "optional": true } } }, @@ -1601,10 +1608,11 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" } @@ -1806,10 +1814,11 @@ } }, "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "dev": true, + "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -1869,10 +1878,11 @@ "dev": true }, "node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -1936,10 +1946,11 @@ "dev": true }, "node_modules/cross-spawn": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "dev": true, + "license": "MIT", "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", @@ -6123,9 +6134,9 @@ }, "dependencies": { "brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", "dev": true, "requires": { "balanced-match": "^1.0.0" @@ -6270,9 +6281,9 @@ "requires": {} }, "ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "dev": true, "requires": { "fast-deep-equal": "^3.1.1", @@ -6320,9 +6331,9 @@ "dev": true }, "brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", "dev": true, "requires": { "balanced-match": "^1.0.0", @@ -6375,9 +6386,9 @@ "dev": true }, "cross-spawn": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "dev": true, "requires": { "path-key": "^3.1.0", diff --git a/package.json b/package.json index 463900f..cc295a3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "data-api-client", - "version": "2.2.0", + "version": "2.3.0", "description": "A lightweight wrapper that simplifies working with the Amazon Aurora Serverless Data API", "main": "dist/index.js", "types": "dist/index.d.ts", @@ -20,6 +20,11 @@ "import": "./dist/compat/mysql2.js", "require": "./dist/compat/mysql2.js" }, + "./compat/knex": { + "types": "./dist/compat/knex.d.ts", + "import": "./dist/compat/knex.js", + "require": "./dist/compat/knex.js" + }, "./compat": { "types": "./dist/compat/index.d.ts", "import": "./dist/compat/index.js", @@ -49,7 +54,9 @@ "test:int:orm:drizzle": "npm run build && vitest run integration-tests/drizzle-pg.int.test.ts && vitest run integration-tests/drizzle-mysql.int.test.ts", "test:int:orm:drizzle:pg": "npm run build && vitest run integration-tests/drizzle-pg.int.test.ts", "test:int:orm:drizzle:mysql": "npm run build && vitest run integration-tests/drizzle-mysql.int.test.ts", - "test:int:orm:knex": "npm run build && vitest run integration-tests/knex-mysql.int.test.ts", + "test:int:orm:knex": "npm run build && vitest run integration-tests/knex-mysql.int.test.ts integration-tests/knex-pg.int.test.ts integration-tests/knex-mysql-querybuilder.int.test.ts integration-tests/knex-pg-querybuilder.int.test.ts", + "test:int:orm:knex:pg": "npm run build && vitest run integration-tests/knex-pg.int.test.ts integration-tests/knex-pg-querybuilder.int.test.ts", + "test:int:orm:knex:mysql": "npm run build && vitest run integration-tests/knex-mysql.int.test.ts integration-tests/knex-mysql-querybuilder.int.test.ts", "test-ci": "npm run build && eslint src && vitest run src/", "lint": "eslint src", "prepublishOnly": "npm run build" @@ -96,14 +103,21 @@ "sqlstring": "^2.3.2" }, "peerDependencies": { - "@aws-sdk/client-rds-data": "^3.1048.0" + "@aws-sdk/client-rds-data": "^3.1048.0", + "knex": "^3.0.0" }, "peerDependenciesMeta": { "@aws-sdk/client-rds-data": { "optional": true + }, + "knex": { + "optional": true } }, "files": [ "dist" - ] + ], + "engines": { + "node": ">=20" + } } diff --git a/src/compat/index.ts b/src/compat/index.ts index 5e0e75e..76ef356 100644 --- a/src/compat/index.ts +++ b/src/compat/index.ts @@ -6,6 +6,7 @@ * Provides compatibility interfaces for popular database clients: * - pg (node-postgres) for PostgreSQL * - mysql2 for MySQL + * - knex (custom client classes for MySQL and PostgreSQL) */ export { createPgClient, createPgPool } from './pg' @@ -14,5 +15,7 @@ export type { PgCompatClient, PgCompatPool, PgQueryResult } from './pg' export { createMySQLConnection, createMySQLPool } from './mysql2' export type { Connection, Pool, PoolConnection, MySQL2QueryResult } from './mysql2' +export { createKnexMySQLClient, createKnexPgClient } from './knex' + export { mapToPostgresError, mapToMySQLError } from './errors' export type { PostgresError, MySQLError } from './errors' diff --git a/src/compat/knex.ts b/src/compat/knex.ts new file mode 100644 index 0000000..e97aa89 --- /dev/null +++ b/src/compat/knex.ts @@ -0,0 +1,89 @@ +'use strict' + +/** + * Knex compatibility layer + * + * Unlike Kysely and Drizzle, Knex does not accept an injected pool/driver — it + * constructs its own driver internally via `Client._driver()` (which normally + * does `require('mysql2')` / `require('pg')`) and then builds connections from + * it. The supported Knex extension point is passing a custom `client` (a + * `Client` subclass), so we subclass the built-in dialect and override the + * single `_driver()` method to hand Knex a Data API-backed connection instead + * of a real database socket. Knex still owns all SQL generation, query + * building, and result shaping. + * + * Usage: + * import knex from 'knex' + * import { createKnexMySQLClient, createKnexPgClient } from 'data-api-client/compat/knex' + * + * const mysql = knex({ client: createKnexMySQLClient(dataApiConfig), connection: {} }) + * const pg = knex({ client: createKnexPgClient(dataApiConfig), connection: {} }) + * + * Requires `knex` to be installed (an optional peer dependency). + * + * LIMITATION — transactions: Knex transactions issue literal + * BEGIN/COMMIT/ROLLBACK SQL through the raw connection, which the RDS Data API + * does not honor as real transactions (it requires a threaded transactionId). + * Use `client.transaction()` on the native data-api-client for transactional + * work; Knex's `db.transaction()` is not supported. + */ + +import { createMySQLConnection } from './mysql2' +import { createPgClient } from './pg' +import type { DataAPIClientConfig } from '../types' + +/** + * Build a Knex `client` class wired to the Data API via the mysql2 compat layer. + * Returns a `Client` subclass suitable for `knex({ client: })`. + */ +export function createKnexMySQLClient(config: DataAPIClientConfig): unknown { + // Lazy require so importing this module does not make `knex` a hard dependency + // of data-api-client — only callers who actually use Knex need it installed. + // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires + const Client_MySQL2 = require('knex/lib/dialects/mysql2') + + return class DataApiKnexMySQLClient extends Client_MySQL2 { + // Knex calls `driver.createConnection(connectionSettings)` inside + // acquireRawConnection(). We ignore the settings and hand back a Data + // API-backed connection. + _driver() { + return { + createConnection: () => { + const conn = createMySQLConnection(config) as unknown as Record + // The mysql2 dialect's validateConnection() reads + // `connection.stream.destroyed`; our Data API connection is always + // "live", so expose a stable stream stub that never reports destroyed. + if (!conn.stream) { + conn.stream = { destroyed: false } + } + return conn + } + } + } + } +} + +/** + * Build a Knex `client` class wired to the Data API via the pg compat layer. + * Returns a `Client` subclass suitable for `knex({ client: })`. + */ +export function createKnexPgClient(config: DataAPIClientConfig): unknown { + // Lazy require so importing this module does not make `knex` a hard dependency. + // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires + const Client_PG = require('knex/lib/dialects/postgres') + + return class DataApiKnexPgClient extends Client_PG { + // The pg dialect does `new driver.Client(connectionSettings)` inside + // _acquireOnlyConnection(). A constructor function that returns an object + // makes `new` yield that object, so we hand back our Data API-backed + // pg-compatible client. Knex then calls connect()/query()/end() on it and + // runs its `select version();` check, all of which the compat client serves. + _driver() { + return { + Client: function DataApiPgClient() { + return createPgClient(config) + } + } + } + } +} diff --git a/src/compat/mysql2.ts b/src/compat/mysql2.ts index 432d18d..fddd0a9 100644 --- a/src/compat/mysql2.ts +++ b/src/compat/mysql2.ts @@ -13,6 +13,7 @@ import SqlString from 'sqlstring' import { init } from '../client' import type { DataAPIClientConfig, DataAPIClient, QueryResult as DataAPIQueryResult } from '../types' import { mapToMySQLError } from './errors' +import { classifyTransactionControl, NESTED_TRANSACTION_MESSAGE } from './transaction-sql' // Define our own compatible types instead of using mysql2's types // This allows us to properly type the Promise-based interface @@ -288,6 +289,12 @@ export function createMySQLConnection(config: DataAPIClientConfig): Connection { sqlOrOptions: string | { sql: string; values?: any[]; rowsAsArray?: boolean; namedPlaceholders?: boolean }, params?: any[] | Record ): Promise<[R[] | MySQL2QueryResult, any]> { + // Intercept transaction-control statements (BEGIN/COMMIT/ROLLBACK/...) before + // any formatting and map them to the Data API transaction lifecycle. + const rawSql = typeof sqlOrOptions === 'string' ? sqlOrOptions : sqlOrOptions.sql + const txControl = await runTransactionControl(rawSql) + if (txControl) return txControl + let sql: string let values: any[] | Record = [] let rowsAsArray = false @@ -342,6 +349,44 @@ export function createMySQLConnection(config: DataAPIClientConfig): Connection { return convertToMySQL2Result(result, sql) } + // Intercept transaction-control statements (BEGIN/COMMIT/ROLLBACK/...) issued + // as raw SQL (e.g. by Knex) and map them to the Data API transaction + // lifecycle, threading transactionId onto subsequent queries on this + // connection. Runs before executeQuery's normal path. + async function runTransactionControl(sql: string): Promise<[R[], any] | null> { + const txn = classifyTransactionControl(sql) + if (!txn) return null + const empty: [R[], any] = [[] as R[], undefined] + switch (txn.kind) { + case 'begin': { + const txResult = await core.beginTransaction() + transactionId = txResult.transactionId + return empty + } + case 'commit': { + if (transactionId) { + await core.commitTransaction({ transactionId } as any) + transactionId = undefined + } + return empty + } + case 'rollback': { + if (transactionId) { + await core.rollbackTransaction({ transactionId } as any) + transactionId = undefined + } + return empty + } + case 'setTransaction': + // Isolation-level prefix; the Data API uses default isolation. No-op. + return empty + case 'savepoint': + case 'release': + case 'rollbackTo': + throw new Error(NESTED_TRANSACTION_MESSAGE) + } + } + const connection = Object.assign(eventEmitter, { connect(callback?: (err: Error | null) => void): any { // No-op for Data API (no connection needed) @@ -383,9 +428,14 @@ export function createMySQLConnection(config: DataAPIClientConfig): Connection { // Drizzle calls query({ sql }, params) - params come as second arg if (typeof paramsOrCallback === 'function') { cb = paramsOrCallback as ((err: Error | null, results: R[] | MySQL2QueryResult, fields: any) => void) - } else if (paramsOrCallback !== undefined) { - params = paramsOrCallback - if (callback !== undefined) { + } else { + // params may be undefined while the callback is supplied as the 3rd + // arg — e.g. Knex issues `query({ sql: 'BEGIN;' }, undefined, cb)` for + // transaction-control statements that have no bindings. + if (paramsOrCallback !== undefined && paramsOrCallback !== null) { + params = paramsOrCallback + } + if (typeof callback === 'function') { cb = callback } } @@ -393,9 +443,14 @@ export function createMySQLConnection(config: DataAPIClientConfig): Connection { // query(sql, params?, callback?) if (typeof paramsOrCallback === 'function') { cb = paramsOrCallback as ((err: Error | null, results: R[] | MySQL2QueryResult, fields: any) => void) - } else if (paramsOrCallback !== undefined) { - params = paramsOrCallback - if (callback !== undefined) { + } else { + // params may be undefined while the callback is supplied as the 3rd + // arg — e.g. Knex issues `query({ sql: 'BEGIN;' }, undefined, cb)` for + // transaction-control statements that have no bindings. + if (paramsOrCallback !== undefined && paramsOrCallback !== null) { + params = paramsOrCallback + } + if (typeof callback === 'function') { cb = callback } } @@ -653,9 +708,14 @@ export function createMySQLPool(config: DataAPIClientConfig): Pool { // Drizzle calls query({ sql }, params) - params come as second arg if (typeof paramsOrCallback === 'function') { cb = paramsOrCallback as ((err: Error | null, results: R[] | MySQL2QueryResult, fields: any) => void) - } else if (paramsOrCallback !== undefined) { - params = paramsOrCallback - if (callback !== undefined) { + } else { + // params may be undefined while the callback is supplied as the 3rd + // arg — e.g. Knex issues `query({ sql: 'BEGIN;' }, undefined, cb)` for + // transaction-control statements that have no bindings. + if (paramsOrCallback !== undefined && paramsOrCallback !== null) { + params = paramsOrCallback + } + if (typeof callback === 'function') { cb = callback } } @@ -663,9 +723,14 @@ export function createMySQLPool(config: DataAPIClientConfig): Pool { // query(sql, params?, callback?) if (typeof paramsOrCallback === 'function') { cb = paramsOrCallback as ((err: Error | null, results: R[] | MySQL2QueryResult, fields: any) => void) - } else if (paramsOrCallback !== undefined) { - params = paramsOrCallback - if (callback !== undefined) { + } else { + // params may be undefined while the callback is supplied as the 3rd + // arg — e.g. Knex issues `query({ sql: 'BEGIN;' }, undefined, cb)` for + // transaction-control statements that have no bindings. + if (paramsOrCallback !== undefined && paramsOrCallback !== null) { + params = paramsOrCallback + } + if (typeof callback === 'function') { cb = callback } } diff --git a/src/compat/pg.ts b/src/compat/pg.ts index 3b091f5..cc5bb8c 100644 --- a/src/compat/pg.ts +++ b/src/compat/pg.ts @@ -13,6 +13,7 @@ import * as pgEscape from '../pg-escape' import { init } from '../client' import type { DataAPIClientConfig, DataAPIClient, QueryResult as DataAPIQueryResult } from '../types' import { mapToPostgresError, type PostgresError } from './errors' +import { classifyTransactionControl, NESTED_TRANSACTION_MESSAGE } from './transaction-sql' // Pg-compatible types export interface PgQueryResult { @@ -150,7 +151,6 @@ function convertPgPlaceholders(sql: string, params: any[] = []): { sql: string; * Infer SQL command type from query */ function inferCommand(sql: string): string { - const match = sql.trim().split(/\s+/)[0]?.toUpperCase() const knownCommands = [ 'SELECT', 'INSERT', @@ -163,7 +163,24 @@ function inferCommand(sql: string): string { 'GRANT', 'REVOKE' ] - return knownCommands.includes(match) ? match : 'QUERY' + const first = sql.trim().split(/\s+/)[0]?.toUpperCase() + if (first && knownCommands.includes(first)) return first + + // CTEs start with WITH [RECURSIVE]; the effective command is the primary + // statement's verb (e.g. `WITH x AS (...) SELECT ...` is a SELECT). Find the + // first SELECT/INSERT/UPDATE/DELETE keyword at parenthesis depth 0 — the CTE + // bodies are nested in parens, so their verbs are skipped. + if (first === 'WITH') { + let depth = 0 + const re = /\(|\)|\b(SELECT|INSERT|UPDATE|DELETE)\b/gi + let m: RegExpExecArray | null + while ((m = re.exec(sql)) !== null) { + if (m[0] === '(') depth++ + else if (m[0] === ')') depth-- + else if (depth === 0) return m[1].toUpperCase() + } + } + return 'QUERY' } /** @@ -263,46 +280,38 @@ export function createPgClient(config: DataAPIClientConfig): PgCompatClient { // Similarly, sqlOrConfig.types is ignored as the Data API handles type parsing. } - // Check for transaction control commands - const upperSql = sql.trim().toUpperCase() - - // BEGIN transaction - if (upperSql === 'BEGIN' || upperSql.startsWith('BEGIN ')) { - const txResult = await core.beginTransaction() - transactionId = txResult.transactionId - return { - rows: [] as R[], - rowCount: 0, - command: 'BEGIN', - fields: [] - } - } - - // COMMIT transaction - if (upperSql === 'COMMIT') { - if (transactionId) { - await core.commitTransaction({ transactionId } as any) - transactionId = undefined - } - return { - rows: [] as R[], - rowCount: 0, - command: 'COMMIT', - fields: [] - } - } - - // ROLLBACK transaction - if (upperSql === 'ROLLBACK') { - if (transactionId) { - await core.rollbackTransaction({ transactionId } as any) - transactionId = undefined - } - return { - rows: [] as R[], - rowCount: 0, - command: 'ROLLBACK', - fields: [] + // Intercept transaction-control statements (BEGIN/COMMIT/ROLLBACK/...) and + // map them to the Data API transaction lifecycle, threading transactionId. + const txn = classifyTransactionControl(sql) + if (txn) { + const empty = (command: string): PgQueryResult => ({ rows: [] as R[], rowCount: 0, command, fields: [] }) + switch (txn.kind) { + case 'begin': { + const txResult = await core.beginTransaction() + transactionId = txResult.transactionId + return empty('BEGIN') + } + case 'commit': { + if (transactionId) { + await core.commitTransaction({ transactionId } as any) + transactionId = undefined + } + return empty('COMMIT') + } + case 'rollback': { + if (transactionId) { + await core.rollbackTransaction({ transactionId } as any) + transactionId = undefined + } + return empty('ROLLBACK') + } + case 'setTransaction': + // Isolation-level prefix; the Data API uses default isolation. No-op. + return empty('SET') + case 'savepoint': + case 'release': + case 'rollbackTo': + throw new Error(NESTED_TRANSACTION_MESSAGE) } } @@ -397,8 +406,11 @@ export function createPgClient(config: DataAPIClientConfig): PgCompatClient { throw new Error('Query streams are not supported by RDS Data API') } - // Determine if callback style or promise style - let params: any[] = [] + // Determine if callback style or promise style. + // Leave params undefined unless an explicit values array is supplied, so + // that values embedded in a config object (e.g. query({ text, values }, cb) + // as Knex calls it) are not clobbered by an empty default in executeQuery. + let params: any[] | undefined let cb: ((err: Error | null, result: PgQueryResult) => void) | undefined if (typeof sqlOrConfig === 'object' && 'text' in sqlOrConfig) { @@ -566,8 +578,11 @@ export function createPgPool(config: DataAPIClientConfig): PgCompatPool { throw new Error('Query streams are not supported by RDS Data API') } - // Determine if callback style or promise style - let params: any[] = [] + // Determine if callback style or promise style. + // Leave params undefined unless an explicit values array is supplied, so + // that values embedded in a config object (e.g. query({ text, values }, cb) + // as Knex calls it) are not clobbered by an empty default in executeQuery. + let params: any[] | undefined let cb: ((err: Error | null, result: PgQueryResult) => void) | undefined if (typeof sqlOrConfig === 'object' && 'text' in sqlOrConfig) { diff --git a/src/compat/transaction-sql.ts b/src/compat/transaction-sql.ts new file mode 100644 index 0000000..8a793a8 --- /dev/null +++ b/src/compat/transaction-sql.ts @@ -0,0 +1,66 @@ +'use strict' + +/** + * Transaction-control SQL classifier (shared by the pg and mysql2 compat layers). + * + * ORMs/query builders like Knex drive transactions by issuing literal SQL + * (`BEGIN;`, `COMMIT;`, `ROLLBACK`, `SAVEPOINT s1;`, ...) through the raw + * connection, rather than calling driver methods. The RDS Data API instead + * needs `beginTransaction`/`commitTransaction`/`rollbackTransaction` commands + * with a threaded `transactionId`. The compat connection intercepts these + * statements (see classifyTransactionControl) and maps them to the Data API + * transaction lifecycle, threading the transactionId onto subsequent queries. + */ + +export type TransactionControl = + | { kind: 'begin' } + | { kind: 'commit' } + | { kind: 'rollback' } + | { kind: 'setTransaction' } // isolation-level prefix (e.g. SET TRANSACTION ...); no-op + | { kind: 'savepoint'; name: string } // nested transaction — unsupported by the Data API + | { kind: 'release'; name: string } // RELEASE SAVEPOINT — unsupported + | { kind: 'rollbackTo'; name: string } // ROLLBACK TO SAVEPOINT — unsupported + +// Normalize a statement for keyword matching: drop a trailing semicolon, +// collapse internal whitespace, and uppercase. +const normalize = (sql: string): string => + sql + .trim() + .replace(/;\s*$/, '') + .replace(/\s+/g, ' ') + .toUpperCase() + +/** + * Classify a SQL statement as a transaction-control command, or return null if + * it is an ordinary query that should be executed normally. + */ +export const classifyTransactionControl = (sql: string): TransactionControl | null => { + const n = normalize(sql) + + // Begin: BEGIN, BEGIN;, BEGIN TRANSACTION [mode], START TRANSACTION [mode] + if (n === 'BEGIN' || n.startsWith('BEGIN ') || n.startsWith('BEGIN TRANSACTION') || n.startsWith('START TRANSACTION')) { + return { kind: 'begin' } + } + if (n === 'COMMIT' || n === 'COMMIT WORK') return { kind: 'commit' } + if (n === 'ROLLBACK' || n === 'ROLLBACK WORK') return { kind: 'rollback' } + + // Savepoint family (nested transactions) — captured so the connection can + // raise a clear "unsupported" error rather than send invalid SQL. + const savepoint = n.match(/^SAVEPOINT (.+)$/) + if (savepoint) return { kind: 'savepoint', name: savepoint[1] } + const release = n.match(/^RELEASE SAVEPOINT (.+)$/) + if (release) return { kind: 'release', name: release[1] } + const rollbackTo = n.match(/^ROLLBACK TO SAVEPOINT (.+)$/) + if (rollbackTo) return { kind: 'rollbackTo', name: rollbackTo[1] } + + // Isolation-level prefix emitted before BEGIN by some dialects; the Data API + // begins with default isolation, so this is a no-op. + if (n.startsWith('SET TRANSACTION')) return { kind: 'setTransaction' } + + return null +} + +/** Error thrown when a nested-transaction (savepoint) operation is attempted. */ +export const NESTED_TRANSACTION_MESSAGE = + 'Nested transactions (SAVEPOINT) are not supported over the RDS Data API. ' + + 'Use a single top-level transaction.'