From 8f598f60a13202cbe01e379be5c36d65d08f09b8 Mon Sep 17 00:00:00 2001 From: Alberto Schiabel Date: Thu, 3 Aug 2023 10:23:12 +0200 Subject: [PATCH] feat: JS connectors integration (#4097) * Renames and restructure * Use proxy in `JsQueryable::is_healthy` * Change neon and planetscale drivers to use a promise for the version The promise executes aynchrounsly on constructor, but it's awaited whenever the version is required, particularly to provide consistent health information. Otherwise isHealthy would be returning false, while the promise hasn't resolved yet. * feat: add child parent relation migration * feat(js-connectors): add support for transactions in PlanetScale, without touching Rust * chore: apply renames as in #4085 * chore: use switch case syntax * chore: add hard error if we detect intertwine transaction * feat(js-connectors): adopt Rust's "performIO" pattern to simplify isHealthy() and version() * feat(js-connectors): implement "is_healthy()" * feat(js-connectors): add data conversions for neon * feat(js-connector): add common ConnectorConfig for PlanetScale and Neon * feat(js-connector): update type mapping in neon, use common ConnectorConfig * feat(js-connector): use common ConnectorConfig * test(js-connector): split + centralise tests for PlanetScale and Neon, enable "@withFlavor" test decorator * chore: update README.md * chore: fix typo * chore: fix typo * chore: restore PlanetScale transactions * fix: address review comments * feat: simplify neon/planetscale data-type conversions --------- Co-authored-by: Miguel Fernandez --- .../js-connectors/smoke-test-js/README.md | 31 +- .../migrations/type_test/migration.sql | 51 ++ .../schema.prisma | 27 +- .../migrations/type_test/migration.sql | 35 + .../schema.prisma | 26 +- .../smoke-test-js/prisma/schema.prisma | 64 -- .../smoke-test-js/src/connector/neon.ts | 155 ++++ .../src/connector/planetscale.ts | 292 ++++++ .../src/{driver => connector}/util.ts | 10 + .../smoke-test-js/src/driver/mock.ts | 83 -- .../smoke-test-js/src/driver/neon.ts | 121 --- .../smoke-test-js/src/driver/planetscale.ts | 185 ---- .../src/engines/types/Library.ts | 3 +- .../js-connectors/smoke-test-js/src/neon.ts | 79 +- .../smoke-test-js/src/planetscale.ts | 82 +- .../js-connectors/smoke-test-js/src/test.ts | 263 ++++++ .../js-connectors/smoke-test-js/src/util.ts | 6 +- query-engine/js-connectors/src/proxy.rs | 857 ++++++++++++------ query-engine/js-connectors/src/queryable.rs | 12 +- 19 files changed, 1485 insertions(+), 897 deletions(-) create mode 100644 query-engine/js-connectors/smoke-test-js/prisma/mysql-planetscale/migrations/type_test/migration.sql rename query-engine/js-connectors/smoke-test-js/prisma/{mysql => mysql-planetscale}/schema.prisma (89%) create mode 100644 query-engine/js-connectors/smoke-test-js/prisma/postgres-neon/migrations/type_test/migration.sql rename query-engine/js-connectors/smoke-test-js/prisma/{postgresql => postgres-neon}/schema.prisma (85%) delete mode 100644 query-engine/js-connectors/smoke-test-js/prisma/schema.prisma create mode 100644 query-engine/js-connectors/smoke-test-js/src/connector/neon.ts create mode 100644 query-engine/js-connectors/smoke-test-js/src/connector/planetscale.ts rename query-engine/js-connectors/smoke-test-js/src/{driver => connector}/util.ts (81%) delete mode 100644 query-engine/js-connectors/smoke-test-js/src/driver/mock.ts delete mode 100644 query-engine/js-connectors/smoke-test-js/src/driver/neon.ts delete mode 100644 query-engine/js-connectors/smoke-test-js/src/driver/planetscale.ts create mode 100644 query-engine/js-connectors/smoke-test-js/src/test.ts diff --git a/query-engine/js-connectors/smoke-test-js/README.md b/query-engine/js-connectors/smoke-test-js/README.md index 29872d7e1a8..3e1ba956fbe 100644 --- a/query-engine/js-connectors/smoke-test-js/README.md +++ b/query-engine/js-connectors/smoke-test-js/README.md @@ -1,7 +1,9 @@ # @prisma/smoke-test-js This is a playground for testing the `libquery` client with the experimental Node.js drivers. -It contains a subset of `@prisma/client`, plus a handy [`index.ts`](./src/index.ts) file with a `main` function. +It contains a subset of `@prisma/client`, plus some handy executable smoke tests: +- [`./src/planetscale.ts`](./src/planetscale.ts) +- [`./src/neon.ts`](./src/neon.ts) ## How to setup @@ -10,7 +12,7 @@ We assume Node.js `v18.16.1`+ is installed. - Create a `.envrc` starting from `.envrc.example`, and fill in the missing values following the given template - Install Node.js dependencies via ```bash - npm i + pnpm i ``` ### PlanetScale @@ -20,9 +22,10 @@ We assume Node.js `v18.16.1`+ is installed. - Create a new `shadow` database branch. Repeat the steps above (selecting the `shadow` branch instead of `main`), and paste the generated URL in the `JS_PLANETSCALE_SHADOW_DATABASE_URL` environment variable in `.envrc`. In the current directory: -- Run `cp prisma/mysql/schema.prisma prisma/` to use the MySQL schema. Change the provider to `mysql`. -- Run `prisma migrate reset` to populate the remote PlanetScale database with the "smoke test" data. -- Change the `provider` name in [./prisma/schema.prisma](./prisma/schema.prisma) from `mysql` to `@prisma/planetscale`. +- Set the provider in [./prisma/mysql-planetscale/schema.prisma](./prisma/mysql-planetscale/schema.prisma) to `mysql`. +- Run `npx prisma db push --schema ./prisma/mysql-planetscale/schema.prisma` +- Run `npx prisma migrate deploy --schema ./prisma/mysql-planetscale/schema.prisma` +- Set the provider in [./prisma/mysql-planetscale/schema.prisma](./prisma/mysql-planetscale/schema.prisma) to `@prisma/planetscale`. Note: you used to be able to run these Prisma commands without changing the provider name, but [#4074](https://github.com/prisma/prisma-engines/pull/4074) changed that (see https://github.com/prisma/prisma-engines/pull/4074#issuecomment-1649942475). @@ -32,28 +35,30 @@ Note: you used to be able to run these Prisma commands without changing the prov - Paste the connection string to `JS_NEON_DATABASE_URL`. Create a shadow branch and repeat the step above, paste the connection string to `JS_NEON_SHADOW_DATABASE_URL`. In the current directory: -- Run `cp prisma/postgresql/schema.prisma prisma/` to use the PostgreSQL schema. Change the provider to `postgresql`. -- Run `prisma migrate reset` to populate the remote PlanetScale database with the "smoke test" data. -- Change the `provider` name in [./prisma/schema.prisma](./prisma/schema.prisma) from `postgresql` to `@prisma/neon`. +- Set the provider in [./prisma/postgres-neon/schema.prisma](./prisma/postgres-neon/schema.prisma) to `postgres`. +- Run `npx prisma db push --schema ./prisma/postgres-neon/schema.prisma` +- Run `npx prisma migrate deploy --schema ./prisma/postgres-neon/schema.prisma` +- Set the provider in [./prisma/postgres-neon/schema.prisma](./prisma/postgres-neon/schema.prisma) to `@prisma/neon`. ## How to use In the current directory: - Run `cargo build -p query-engine-node-api` to compile the `libquery` Query Engine -- Run `npm run planetscale` to run smoke tests against the PlanetScale database +- Run `pnpm planetscale` to run smoke tests against the PlanetScale database +- Run `pnpm neon` to run smoke tests against the PlanetScale database ## How to test -There is no automatic test. However, [./src/index.ts](./src/index.ts) includes a pipeline you can use to interactively experiment with the new Query Engine. +There is no automatic test. However, [./src/planetscale.ts](./src/planetscale.ts) includes a pipeline you can use to interactively experiment with the new Query Engine. -In particular, the pipeline steps are currently the following: +In particular, the pipeline steps are currently the following (in the case of PlanetScale): -- Define `db`, a class instance wrapper around the `@planetscale/database` JS driver for PlanetScale +- Define `db`, a class instance wrapper around the `@planetscale/database` serverless driver for PlanetScale - Define `nodejsFnCtx`, an object exposing (a)sync "Queryable" functions that can be safely passed to Rust, so that it can interact with `db`'s class methods - Load the *debug* version of `libquery`, i.e., the compilation artifact of the `query-engine-node-api` crate - Define `engine` via the `QueryEngine` constructor exposed by Rust - Initialize the connector via `engine.connect()` -- Run a Prisma `findMany` query via the JSON protocol, according to the Prisma schema in [./prisma/schema.prisma](./prisma/schema.prisma), storing the result in `resultSet` +- Run a Prisma `findMany` query via the JSON protocol, according to the Prisma schema in [./prisma/mysql-planetscale/schema.prisma](./prisma/mysql-planetscale/schema.prisma), storing the result in `resultSet` - Release the connector via `engine.disconnect()` - Attempt a reconnection (useful to catch possible panics in the implementation) - Close the database connection via `nodejsFnCtx` diff --git a/query-engine/js-connectors/smoke-test-js/prisma/mysql-planetscale/migrations/type_test/migration.sql b/query-engine/js-connectors/smoke-test-js/prisma/mysql-planetscale/migrations/type_test/migration.sql new file mode 100644 index 00000000000..95c0433bbc5 --- /dev/null +++ b/query-engine/js-connectors/smoke-test-js/prisma/mysql-planetscale/migrations/type_test/migration.sql @@ -0,0 +1,51 @@ +INSERT INTO type_test ( + tinyint_column, + smallint_column, + mediumint_column, + int_column, + bigint_column, + float_column, + double_column, + decimal_column, + boolean_column, + bit_column, + char_column, + varchar_column, + text_column, + date_column, + time_column, + year_column, + datetime_column, + timestamp_column, + json_column, + enum_column, + binary_column, + varbinary_column, + blob_column, + set_column +) VALUES ( + 127, -- tinyint + 32767, -- smallint + 8388607, -- mediumint + 2147483647, -- int + 9223372036854775807, -- bigint + 3.402823466, -- float + 1.7976931348623157, -- double + 99999999.99, -- decimal + TRUE, -- boolean + 1, -- bit + 'c', -- char + 'Sample varchar', -- varchar + 'This is a long text...', -- text + '2023-07-24', -- date + '23:59:59', -- time + 2023, -- year + '2023-07-24 23:59:59', -- datetime + '2023-07-24 23:59:59', -- timestamp + '{"key": "value"}', -- json + 'value3', -- enum + 0x4D7953514C, -- binary + 0x48656C6C6F20, -- varbinary + _binary 'binary', -- blob + 'option1,option3' -- set +); diff --git a/query-engine/js-connectors/smoke-test-js/prisma/mysql/schema.prisma b/query-engine/js-connectors/smoke-test-js/prisma/mysql-planetscale/schema.prisma similarity index 89% rename from query-engine/js-connectors/smoke-test-js/prisma/mysql/schema.prisma rename to query-engine/js-connectors/smoke-test-js/prisma/mysql-planetscale/schema.prisma index 3782fdf3c94..ec09e885d4a 100644 --- a/query-engine/js-connectors/smoke-test-js/prisma/mysql/schema.prisma +++ b/query-engine/js-connectors/smoke-test-js/prisma/mysql-planetscale/schema.prisma @@ -1,20 +1,14 @@ generator client { provider = "prisma-client-js" - // previewFeatures = ["jsConnectors"] } datasource db { provider = "@prisma/planetscale" + // provider = "mysql" url = env("JS_PLANETSCALE_DATABASE_URL") shadowDatabaseUrl = env("JS_PLANETSCALE_SHADOW_DATABASE_URL") } -model some_users { - id Int @id @default(autoincrement()) - firstname String @db.VarChar(32) - lastname String @db.VarChar(32) -} - model type_test { id Int @id @default(autoincrement()) tinyint_column Int @db.TinyInt @@ -78,3 +72,22 @@ enum type_test_enum_column_null { value2 value3 } + +model Child { + c String @unique + c_1 String + c_2 String + parentId String? @unique + non_unique String? + id String @id + @@unique([c_1, c_2]) +} + +model Parent { + p String @unique + p_1 String + p_2 String + non_unique String? + id String @id + @@unique([p_1, p_2]) +} diff --git a/query-engine/js-connectors/smoke-test-js/prisma/postgres-neon/migrations/type_test/migration.sql b/query-engine/js-connectors/smoke-test-js/prisma/postgres-neon/migrations/type_test/migration.sql new file mode 100644 index 00000000000..70f38a6a6ad --- /dev/null +++ b/query-engine/js-connectors/smoke-test-js/prisma/postgres-neon/migrations/type_test/migration.sql @@ -0,0 +1,35 @@ +INSERT INTO type_test ( + smallint_column, + int_column, + bigint_column, + float_column, + double_column, + decimal_column, + boolean_column, + char_column, + varchar_column, + text_column, + date_column, + time_column, + datetime_column, + timestamp_column, + json_column, + enum_column +) VALUES ( + 32767, -- smallint + 2147483647, -- int + 9223372036854775807, -- bigint + 3.402823466, -- float + 1.7976931348623157, -- double + 99999999.99, -- decimal + TRUE, -- boolean + 'c', -- char + 'Sample varchar', -- varchar + 'This is a long text...', -- text + '2023-07-24', -- date + '23:59:59', -- time + '2023-07-24 23:59:59', -- datetime + '2023-07-24 23:59:59', -- timestamp + '{"key": "value"}', -- json + 'value3' -- enum +); diff --git a/query-engine/js-connectors/smoke-test-js/prisma/postgresql/schema.prisma b/query-engine/js-connectors/smoke-test-js/prisma/postgres-neon/schema.prisma similarity index 85% rename from query-engine/js-connectors/smoke-test-js/prisma/postgresql/schema.prisma rename to query-engine/js-connectors/smoke-test-js/prisma/postgres-neon/schema.prisma index 445671fe0b2..95a5d72eb70 100644 --- a/query-engine/js-connectors/smoke-test-js/prisma/postgresql/schema.prisma +++ b/query-engine/js-connectors/smoke-test-js/prisma/postgres-neon/schema.prisma @@ -5,16 +5,11 @@ generator client { datasource db { provider = "@prisma/neon" + // provider = "postgres" url = env("JS_NEON_DATABASE_URL") shadowDatabaseUrl = env("JS_NEON_SHADOW_DATABASE_URL") } -model some_users { - id Int @id @default(autoincrement()) - firstname String @db.VarChar(32) - lastname String @db.VarChar(32) -} - model type_test { id Int @id @default(autoincrement()) smallint_column Int @db.SmallInt @@ -62,3 +57,22 @@ enum type_test_enum_column_null { value2 value3 } + +model Child { + c String @unique + c_1 String + c_2 String + parentId String? @unique + non_unique String? + id String @id + @@unique([c_1, c_2]) +} + +model Parent { + p String @unique + p_1 String + p_2 String + non_unique String? + id String @id + @@unique([p_1, p_2]) +} diff --git a/query-engine/js-connectors/smoke-test-js/prisma/schema.prisma b/query-engine/js-connectors/smoke-test-js/prisma/schema.prisma deleted file mode 100644 index 445671fe0b2..00000000000 --- a/query-engine/js-connectors/smoke-test-js/prisma/schema.prisma +++ /dev/null @@ -1,64 +0,0 @@ -generator client { - provider = "prisma-client-js" - // previewFeatures = ["jsConnectors"] -} - -datasource db { - provider = "@prisma/neon" - url = env("JS_NEON_DATABASE_URL") - shadowDatabaseUrl = env("JS_NEON_SHADOW_DATABASE_URL") -} - -model some_users { - id Int @id @default(autoincrement()) - firstname String @db.VarChar(32) - lastname String @db.VarChar(32) -} - -model type_test { - id Int @id @default(autoincrement()) - smallint_column Int @db.SmallInt - smallint_column_null Int? @db.SmallInt - int_column Int - int_column_null Int? - bigint_column BigInt - bigint_column_null BigInt? - float_column Float @db.Real - float_column_null Float? @db.Real - double_column Float - double_column_null Float? - decimal_column Decimal @db.Decimal(10, 2) - decimal_column_null Decimal? @db.Decimal(10, 2) - boolean_column Boolean - boolean_column_null Boolean? - char_column String @db.Char(10) - char_column_null String? @db.Char(10) - varchar_column String @db.VarChar(255) - varchar_column_null String? @db.VarChar(255) - text_column String @db.Text - text_column_null String? @db.Text - date_column DateTime @db.Date - date_column_null DateTime? @db.Date - time_column DateTime @db.Time(0) - time_column_null DateTime? @db.Time(0) - datetime_column DateTime - datetime_column_null DateTime? - timestamp_column DateTime @db.Timestamp(0) - timestamp_column_null DateTime? @db.Timestamp(0) - json_column Json - json_column_null Json? - enum_column type_test_enum_column - enum_column_null type_test_enum_column_null? -} - -enum type_test_enum_column { - value1 - value2 - value3 -} - -enum type_test_enum_column_null { - value1 - value2 - value3 -} diff --git a/query-engine/js-connectors/smoke-test-js/src/connector/neon.ts b/query-engine/js-connectors/smoke-test-js/src/connector/neon.ts new file mode 100644 index 00000000000..ca12de96c1b --- /dev/null +++ b/query-engine/js-connectors/smoke-test-js/src/connector/neon.ts @@ -0,0 +1,155 @@ +import { Pool, neonConfig, types } from '@neondatabase/serverless' +import type { NeonConfig } from '@neondatabase/serverless' +import ws from 'ws' +import type { Closeable, Connector, ResultSet, Query } from '../engines/types/Library.js' +import { ColumnType } from '../engines/types/Library.js' +import { ConnectorConfig } from './util.js' + +neonConfig.webSocketConstructor = ws + +const NeonColumnType = types.builtins + +/** + * This is a simplification of quaint's value inference logic. Take a look at quaint's conversion.rs + * module to see how other attributes of the field packet such as the field length are used to infer + * the correct quaint::Value variant. + */ +function fieldToColumnType(fieldTypeId: number): ColumnType { + switch (fieldTypeId) { + case NeonColumnType['INT2']: + case NeonColumnType['INT4']: + return ColumnType.Int32 + case NeonColumnType['INT8']: + return ColumnType.Int64 + case NeonColumnType['FLOAT4']: + return ColumnType.Float + case NeonColumnType['FLOAT8']: + return ColumnType.Double + case NeonColumnType['BOOL']: + return ColumnType.Boolean + case NeonColumnType['DATE']: + return ColumnType.Date + case NeonColumnType['TIME']: + return ColumnType.Time + case NeonColumnType['TIMESTAMP']: + return ColumnType.DateTime + case NeonColumnType['NUMERIC']: + return ColumnType.Numeric + case NeonColumnType['BPCHAR']: + return ColumnType.Char + case NeonColumnType['TEXT']: + case NeonColumnType['VARCHAR']: + return ColumnType.Text + case NeonColumnType['JSONB']: + return ColumnType.Json + default: + if (fieldTypeId >= 10000) { + // Postgres Custom Types + return ColumnType.Enum + } + throw new Error(`Unsupported column type: ${fieldTypeId}`) + } +} + +// return string instead of JavaScript Date object +types.setTypeParser(NeonColumnType.DATE, date => date); +types.setTypeParser(NeonColumnType.TIME, date => date); +types.setTypeParser(NeonColumnType.TIMESTAMP, date => date); + +export type PrismaNeonConfig = ConnectorConfig & Partial> + +class PrismaNeon implements Connector, Closeable { + readonly flavor = 'postgres' + + private pool: Pool + private isRunning: boolean = true + private _isHealthy: boolean = true + private _version: string | undefined = undefined + + constructor(config: PrismaNeonConfig) { + const { url: connectionString, ...rest } = config + this.pool = new Pool({ connectionString, ...rest }) + } + + async close(): Promise { + if (this.isRunning) { + await this.pool.end() + this.isRunning = false + } + } + + /** + * Returns false, if connection is considered to not be in a working state. + */ + isHealthy(): boolean { + return this.isRunning && this._isHealthy + } + + /** + * Execute a query given as SQL, interpolating the given parameters. + */ + async queryRaw(query: Query): Promise { + const { fields, rows: results } = await this.performIO(query) + + const columns = fields.map(field => field.name) + const resultSet: ResultSet = { + columnNames: columns, + columnTypes: fields.map(field => fieldToColumnType(field.dataTypeID)), + rows: results.map(result => columns.map(column => result[column])), + } + + return resultSet + } + + /** + * Execute a query given as SQL, interpolating the given parameters and + * returning the number of affected rows. + * Note: Queryable expects a u64, but napi.rs only supports u32. + */ + async executeRaw(query: Query): Promise { + const { rowCount: rowsAffected } = await this.performIO(query) + return rowsAffected + } + + /** + * Return the version of the underlying database, queried directly from the + * source. This corresponds to the `version()` function on PostgreSQL for + * example. The version string is returned directly without any form of + * parsing or normalization. + */ + async version(): Promise { + if (this._version) { + return Promise.resolve(this._version) + } + + const { rows } = await this.performIO({ sql: 'SELECT VERSION()', args: [] }) + this._version = rows[0]['version'] as string + return this._version + } + + /** + * Run a query against the database, returning the result set. + * Should the query fail due to a connection error, the connection is + * marked as unhealthy. + */ + private async performIO(query: Query) { + const { sql, args: values } = query + + try { + return await this.pool.query(sql, values) + } catch (e) { + const error = e as Error & { code: string } + + if (['ENOTFOUND', 'EAI_AGAIN'].includes(error.code)) { + this._isHealthy = false + } + + throw e + } + } +} + +export const createNeonConnector = (config: PrismaNeonConfig): Connector & Closeable => { + const db = new PrismaNeon(config) + return db +} diff --git a/query-engine/js-connectors/smoke-test-js/src/connector/planetscale.ts b/query-engine/js-connectors/smoke-test-js/src/connector/planetscale.ts new file mode 100644 index 00000000000..452096fc43f --- /dev/null +++ b/query-engine/js-connectors/smoke-test-js/src/connector/planetscale.ts @@ -0,0 +1,292 @@ +import * as planetScale from '@planetscale/database' +import type { Config as PlanetScaleConfig } from '@planetscale/database' +import { EventEmitter } from 'node:events' +import { setImmediate } from 'node:timers/promises' +import type { Closeable, Connector, ResultSet, Query } from '../engines/types/Library.js' +import { ColumnType } from '../engines/types/Library.js' +import type { ConnectorConfig } from './util.js' + +// See: https://github.com/planetscale/vitess-types/blob/06235e372d2050b4c0fff49972df8111e696c564/src/vitess/query/v16/query.proto#L108-L218 +type PlanetScaleColumnType + = 'NULL_TYPE' // unsupported + | 'INT8' + | 'UINT8' + | 'INT16' + | 'UINT16' + | 'INT24' + | 'UINT24' + | 'INT32' + | 'UINT32' + | 'INT64' + | 'UINT64' + | 'FLOAT32' + | 'FLOAT64' + | 'TIMESTAMP' + | 'DATE' + | 'TIME' + | 'DATETIME' + | 'YEAR' + | 'DECIMAL' + | 'TEXT' + | 'BLOB' + | 'VARCHAR' + | 'VARBINARY' + | 'CHAR' + | 'BINARY' + | 'BIT' + | 'ENUM' + | 'SET' // unsupported + | 'TUPLE' // unsupported + | 'GEOMETRY' + | 'JSON' + | 'EXPRESSION' // unsupported + | 'HEXNUM' + | 'HEXVAL' + | 'BITNUM' + +/** + * This is a simplification of quaint's value inference logic. Take a look at quaint's conversion.rs + * module to see how other attributes of the field packet such as the field length are used to infer + * the correct quaint::Value variant. + */ +function fieldToColumnType(field: PlanetScaleColumnType): ColumnType { + switch (field) { + case 'INT8': + case 'UINT8': + case 'INT16': + case 'UINT16': + case 'INT24': + case 'UINT24': + case 'INT32': + case 'UINT32': + case 'YEAR': + return ColumnType.Int32 + case 'INT64': + case 'UINT64': + return ColumnType.Int64 + case 'FLOAT32': + return ColumnType.Float + case 'FLOAT64': + return ColumnType.Double + case 'TIMESTAMP': + case 'DATETIME': + return ColumnType.DateTime + case 'DATE': + return ColumnType.Date + case 'TIME': + return ColumnType.Time + case 'DECIMAL': + return ColumnType.Numeric + case 'CHAR': + return ColumnType.Char + case 'TEXT': + case 'VARCHAR': + return ColumnType.Text + case 'ENUM': + return ColumnType.Enum + case 'JSON': + return ColumnType.Json + case 'BLOB': + case 'BINARY': + case 'VARBINARY': + case 'BIT': + case 'BITNUM': + case 'HEXNUM': + case 'HEXVAL': + case 'GEOMETRY': + return ColumnType.Bytes + default: + throw new Error(`Unsupported column type: ${field}`) + } +} + +export type PrismaPlanetScaleConfig = ConnectorConfig & Partial + +type TransactionCapableDriver + = { + /** + * Indicates a transaction is in progress in this connector's instance. + */ + inTransaction: true + + /** + * The standard PlanetScale client. + */ + client: planetScale.Transaction + } + | { + /** + * Indicates that no transactions are in progress in this connector's instance. + */ + inTransaction: false + + /** + * The PlanetScale client, scoped in transaction mode. + */ + client: planetScale.Connection + } + +const TRANSACTION_BEGIN = 'BEGIN' +const TRANSACTION_COMMIT = 'COMMIT' +const TRANSACTION_ROLLBACK = 'ROLLBACK' + +class PrismaPlanetScale implements Connector, Closeable { + readonly flavor = 'mysql' + + private driver: TransactionCapableDriver + private isRunning: boolean = true + private _isHealthy: boolean = true + private _version: string | undefined = undefined + private txEmitter = new EventEmitter() + + constructor(config: PrismaPlanetScaleConfig) { + const client = planetScale.connect(config) + + // initialize the driver as a non-transactional client + this.driver = { + client, + inTransaction: false, + } + } + + async close(): Promise { + if (this.isRunning) { + this.isRunning = false + } + } + + /** + * Returns false, if connection is considered to not be in a working state. + */ + isHealthy(): boolean { + return this.isRunning && this._isHealthy + } + + /** + * Execute a query given as SQL, interpolating the given parameters. + */ + async queryRaw(query: Query): Promise { + const tag = '[js::query_raw]' + console.log(tag, query) + + const { fields, rows: results } = await this.performIO(query) + + const columns = fields.map(field => field.name) + const resultSet: ResultSet = { + columnNames: columns, + columnTypes: fields.map(field => fieldToColumnType(field.type as PlanetScaleColumnType)), + rows: results.map(result => columns.map(column => result[column])), + } + + return resultSet + } + + /** + * Execute a query given as SQL, interpolating the given parameters and + * returning the number of affected rows. + * Note: Queryable expects a u64, but napi.rs only supports u32. + */ + async executeRaw(query: Query): Promise { + const tag = '[js::execute_raw]' + console.log(tag, query) + + const connection = this.driver.client + const { sql } = query + + switch (sql) { + case TRANSACTION_BEGIN: { + // check if a transaction is already in progress + if (this.driver.inTransaction) { + throw new Error('A transaction is already in progress') + } + + (this.driver.client as planetScale.Connection).transaction(async (tx) => { + // tx holds the scope for executing queries in transaction mode + this.driver.client = tx + + // signal the transaction began + this.driver.inTransaction = true + console.log('[js] transaction began') + + await new Promise((resolve, reject) => { + this.txEmitter.once(TRANSACTION_COMMIT, () => { + this.driver.inTransaction = false + console.log('[js] transaction ended successfully') + this.driver.client = connection + resolve(undefined) + }) + + this.txEmitter.once(TRANSACTION_ROLLBACK, () => { + this.driver.inTransaction = false + console.log('[js] transaction ended with error') + this.driver.client = connection + reject('ROLLBACK') + }) + }) + }) + + // ensure that this.driver.client is set to `planetScale.Transaction` + await setImmediate(0, { + // we do not require the event loop to remain active + ref: false, + }) + + return Promise.resolve(-1) + } + case TRANSACTION_COMMIT: { + this.txEmitter.emit(sql) + return Promise.resolve(-1) + } + case TRANSACTION_ROLLBACK: { + this.txEmitter.emit(sql) + return Promise.resolve(-2) + } + default: { + const { rowsAffected } = await this.performIO(query) + return rowsAffected + } + } + } + + /** + * Return the version of the underlying database, queried directly from the + * source. This corresponds to the `version()` function on PostgreSQL for + * example. The version string is returned directly without any form of + * parsing or normalization. + */ + async version(): Promise { + if (this._version) { + return Promise.resolve(this._version) + } + + const { rows } = await this.performIO({ sql: 'SELECT @@version', args: [] }) + this._version = rows[0]['@@version'] as string + return this._version + } + + /** + * Run a query against the database, returning the result set. + * Should the query fail due to a connection error, the connection is + * marked as unhealthy. + */ + private async performIO(query: Query) { + const { sql, args: values } = query + + try { + return await this.driver.client.execute(sql, values) + } catch (e) { + const error = e as Error & { code: string } + + if (['ENOTFOUND', 'EAI_AGAIN'].includes(error.code)) { + this._isHealthy = false + } + + throw e + } + } +} + +export const createPlanetScaleConnector = (config: PrismaPlanetScaleConfig): Connector & Closeable => { + const db = new PrismaPlanetScale(config) + return db +} diff --git a/query-engine/js-connectors/smoke-test-js/src/driver/util.ts b/query-engine/js-connectors/smoke-test-js/src/connector/util.ts similarity index 81% rename from query-engine/js-connectors/smoke-test-js/src/driver/util.ts rename to query-engine/js-connectors/smoke-test-js/src/connector/util.ts index 4c89ac88afe..8da0a673716 100644 --- a/query-engine/js-connectors/smoke-test-js/src/driver/util.ts +++ b/query-engine/js-connectors/smoke-test-js/src/connector/util.ts @@ -12,3 +12,13 @@ export const binder = (queryable: Connector & Closeable): Connector & Closeable close: queryable.close.bind(queryable), flavor: queryable.flavor, }) + +export type ConnectorConfig + = { + host: string, + username: string, + password: string, + url: never + } | { + url: string, + } diff --git a/query-engine/js-connectors/smoke-test-js/src/driver/mock.ts b/query-engine/js-connectors/smoke-test-js/src/driver/mock.ts deleted file mode 100644 index ee56179a2a7..00000000000 --- a/query-engine/js-connectors/smoke-test-js/src/driver/mock.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { setTimeout } from 'node:timers/promises' - -import { Closeable, ColumnType, Query, Connector, ResultSet } from '../engines/types/Library.js' - -class MockSQL implements Connector, Closeable { - private maybeVersion?: string - private isRunning: boolean = true - - constructor(connectionString: string) { - // lazily retrieve the version and store it into `maybeVersion` - setTimeout(50) - .then(() => { - this.maybeVersion = 'x.y.z' - }) - } - - async close(): Promise { - console.log('[nodejs] calling close() on connection pool') - if (this.isRunning) { - this.isRunning = false - await setTimeout(150) - console.log('[nodejs] closed connection pool') - } - } - - /** - * Returns false, if connection is considered to not be in a working state. - */ - isHealthy(): boolean { - const result = this.maybeVersion !== undefined - && this.isRunning - console.log(`[nodejs] isHealthy: ${result}`) - return result - } - - /** - * Execute a query given as SQL, interpolating the given parameters. - */ - async queryRaw(params: Query): Promise { - console.log('[nodejs] calling queryRaw', params) - await setTimeout(100) - - const resultSet: ResultSet = { - columnNames: ['id', 'firstname', 'company_id'], - columnTypes: [ColumnType.Int64, ColumnType.Text, ColumnType.Int64], - rows: [ - [1, 'Alberto', 1], - [2, 'Tom', 1], - ], - } - console.log('[nodejs] resultSet', resultSet) - - return resultSet - } - - /** - * Execute a query given as SQL, interpolating the given parameters and - * returning the number of affected rows. - * Note: Queryable expects a u64, but napi.rs only supports u32. - */ - async executeRaw(params: Query): Promise { - console.log('[nodejs] calling executeRaw', params) - await setTimeout(100) - - const affectedRows = 32 - return affectedRows - } - - /** - * Return the version of the underlying database, queried directly from the - * source. This corresponds to the `version()` function on PostgreSQL for - * example. The version string is returned directly without any form of - * parsing or normalization. - */ - version(): Promise { - return Promise.resolve(this.maybeVersion) - } -} - -export const createMockConnector = (connectionString: string): Connector & Closeable => { - const db = new MockSQL(connectionString) - return db -} diff --git a/query-engine/js-connectors/smoke-test-js/src/driver/neon.ts b/query-engine/js-connectors/smoke-test-js/src/driver/neon.ts deleted file mode 100644 index c69a4434625..00000000000 --- a/query-engine/js-connectors/smoke-test-js/src/driver/neon.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { Pool, PoolConfig, neonConfig } from '@neondatabase/serverless' -import type { Closeable, Connector, ResultSet, Query } from '../engines/types/Library.js' -import { ColumnType } from '../engines/types/Library.js' - -import ws from 'ws'; -neonConfig.webSocketConstructor = ws; - -/** - * This is a simplification of quaint's value inference logic. Take a look at quaint's conversion.rs - * module to see how other attributes of the field packet such as the field length are used to infer - * the correct quaint::Value variant. - */ -function fieldToColumnType(fieldTypeId: number): ColumnType { - switch (fieldTypeId) { - case 16: // BOOL - case 21: // INT2 - case 23: // INT4 - return ColumnType.Int32 - case 20: // INT8 - case 1700: // numeric - return ColumnType.Int64 - case 700: // FLOAT4 - return ColumnType.Float - case 701: // FLOAT8 - return ColumnType.Double - case 25: // TEXT - case 1043: // VARCHAR - return ColumnType.Text - case 1042: // BPCHAR - return ColumnType.Char - case 1082: // DATE - return ColumnType.Date - case 1083: // TIME - return ColumnType.Time - case 1114: // TIMESTAMP - return ColumnType.DateTime - case 3802: // JSONB - return ColumnType.Json - default: - if (fieldTypeId >= 10000) { - // Postgres Custom Types - return ColumnType.Enum - } - throw new Error(`Unsupported column type: ${fieldTypeId}`) - } -} - -type NeonConfig = PoolConfig; - -class PrismaNeon implements Connector, Closeable { - private pool: Pool - private maybeVersion?: string - private isRunning: boolean = true - flavor = "postgres" - - constructor(config: NeonConfig) { - this.pool = new Pool(config) - // lazily retrieve the version and store it into `maybeVersion` - this.pool.query('SELECT VERSION()').then((results) => { - this.maybeVersion = results.rows[0]['version'] - }) - } - - async close(): Promise { - if (this.isRunning) { - await this.pool.end() - this.isRunning = false - } - } - - /** - * Returns false, if connection is considered to not be in a working state. - */ - isHealthy(): boolean { - const result = this.maybeVersion !== undefined - && this.isRunning - return result - } - - /** - * Execute a query given as SQL, interpolating the given parameters. - */ - async queryRaw(query: Query): Promise { - const { sql, args: values } = query - console.log(sql, values) - const { fields, rows: results } = await this.pool.query(sql, values) - const columns = fields.map(field => field.name) - const resultSet: ResultSet = { - columnNames: columns, - columnTypes: fields.map(field => fieldToColumnType(field.dataTypeID)), - rows: results.map(result => columns.map(column => result[column])), - } - return resultSet - } - - /** - * Execute a query given as SQL, interpolating the given parameters and - * returning the number of affected rows. - * Note: Queryable expects a u64, but napi.rs only supports u32. - */ - async executeRaw(query: Query): Promise { - const { sql, args: values } = query - const { rowCount } = await this.pool.query(sql, values) - return rowCount - } - - /** - * Return the version of the underlying database, queried directly from the - * source. This corresponds to the `version()` function on PostgreSQL for - * example. The version string is returned directly without any form of - * parsing or normalization. - */ - version(): Promise { - return Promise.resolve(this.maybeVersion) - } -} - -export const createNeonConnector = (config: NeonConfig): Connector & Closeable => { - const db = new PrismaNeon(config) - return db -} diff --git a/query-engine/js-connectors/smoke-test-js/src/driver/planetscale.ts b/query-engine/js-connectors/smoke-test-js/src/driver/planetscale.ts deleted file mode 100644 index caa28519c8f..00000000000 --- a/query-engine/js-connectors/smoke-test-js/src/driver/planetscale.ts +++ /dev/null @@ -1,185 +0,0 @@ -import * as planetScale from '@planetscale/database' -import type { Closeable, Connector, ResultSet, Query } from '../engines/types/Library.js' -import { ColumnType } from '../engines/types/Library.js' - -// See: https://github.com/planetscale/vitess-types/blob/06235e372d2050b4c0fff49972df8111e696c564/src/vitess/query/v16/query.proto#L108-L218 -type PlanetScaleColumnType - = 'NULL_TYPE' // unsupported - | 'INT8' - | 'UINT8' - | 'INT16' - | 'UINT16' - | 'INT24' - | 'UINT24' - | 'INT32' - | 'UINT32' - | 'INT64' - | 'UINT64' - | 'FLOAT32' - | 'FLOAT64' - | 'TIMESTAMP' - | 'DATE' - | 'TIME' - | 'DATETIME' - | 'YEAR' - | 'DECIMAL' - | 'TEXT' - | 'BLOB' - | 'VARCHAR' - | 'VARBINARY' - | 'CHAR' - | 'BINARY' - | 'BIT' - | 'ENUM' - | 'SET' // unsupported - | 'TUPLE' // unsupported - | 'GEOMETRY' - | 'JSON' - | 'EXPRESSION' // unsupported - | 'HEXNUM' - | 'HEXVAL' - | 'BITNUM' - -/** - * This is a simplification of quaint's value inference logic. Take a look at quaint's conversion.rs - * module to see how other attributes of the field packet such as the field length are used to infer - * the correct quaint::Value variant. - */ -function fieldToColumnType(field: PlanetScaleColumnType): ColumnType { - switch (field) { - case 'INT8': - case 'UINT8': - case 'INT16': - case 'UINT16': - case 'INT24': - case 'UINT24': - case 'INT32': - case 'UINT32': - case 'YEAR': - return ColumnType.Int32 - case 'INT64': - case 'UINT64': - return ColumnType.Int64 - case 'FLOAT32': - return ColumnType.Float - case 'FLOAT64': - return ColumnType.Double - case 'TIMESTAMP': - case 'DATETIME': - return ColumnType.DateTime - case 'DATE': - return ColumnType.Date - case 'TIME': - return ColumnType.Time - case 'DECIMAL': - return ColumnType.Numeric - case 'CHAR': - return ColumnType.Char - case 'TEXT': - case 'VARCHAR': - return ColumnType.Text - case 'ENUM': - return ColumnType.Enum - case 'JSON': - return ColumnType.Json - case 'BLOB': - case 'BINARY': - case 'VARBINARY': - case 'BIT': - case 'BITNUM': - case 'HEXNUM': - case 'HEXVAL': - case 'GEOMETRY': - return ColumnType.Bytes - default: - throw new Error(`Unsupported column type: ${field}`) - } -} - -type PlanetScaleConfig = - & { - fetch?: planetScale.Config['fetch'], - } - & ( - { - host: string, - username: string, - password: string, - } | { - url: string, - } - ) - -class PrismaPlanetScale implements Connector, Closeable { - private client: planetScale.Connection - private maybeVersion?: string - private isRunning: boolean = true - flavor = "mysql" - - constructor(config: PlanetScaleConfig) { - this.client = planetScale.connect(config) - - // lazily retrieve the version and store it into `maybeVersion` - this.client.execute('SELECT @@version, @@GLOBAL.version').then((results) => { - this.maybeVersion = results.rows[0]['@@version'] - }) - } - - async close(): Promise { - if (this.isRunning) { - this.isRunning = false - } - } - - /** - * Returns false, if connection is considered to not be in a working state. - */ - isHealthy(): boolean { - const result = this.maybeVersion !== undefined - && this.isRunning - return result - } - - /** - * Execute a query given as SQL, interpolating the given parameters. - */ - async queryRaw(query: Query): Promise { - const { sql, args: values } = query - const { fields, rows: results } = await this.client.execute(sql, values, { as: 'object' }) - - const columns = fields.map(field => field.name) - const resultSet: ResultSet = { - columnNames: columns, - columnTypes: fields.map(field => fieldToColumnType(field.type as PlanetScaleColumnType)), - rows: results.map(result => columns.map(column => result[column])), - } - - return resultSet - } - - /** - * Execute a query given as SQL, interpolating the given parameters and - * returning the number of affected rows. - * Note: Queryable expects a u64, but napi.rs only supports u32. - */ - async executeRaw(query: Query): Promise { - const { sql, args: values } = query - const { rowsAffected } = await this.client.execute(sql, values) - return rowsAffected - } - - /** - * Return the version of the underlying database, queried directly from the - * source. This corresponds to the `version()` function on PostgreSQL for - * example. The version string is returned directly without any form of - * parsing or normalization. - */ - version(): Promise { - return Promise.resolve(this.maybeVersion) - } -} - -export const createPlanetScaleConnector = (config: PlanetScaleConfig): Connector & Closeable => { - const db = new PrismaPlanetScale(config) - return db -} diff --git a/query-engine/js-connectors/smoke-test-js/src/engines/types/Library.ts b/query-engine/js-connectors/smoke-test-js/src/engines/types/Library.ts index c2e2d486c27..ff9902bf4e1 100644 --- a/query-engine/js-connectors/smoke-test-js/src/engines/types/Library.ts +++ b/query-engine/js-connectors/smoke-test-js/src/engines/types/Library.ts @@ -49,11 +49,12 @@ export const enum ColumnType { } export type Connector = { + readonly flavor: 'mysql' | 'postgres', + queryRaw: (params: Query) => Promise executeRaw: (params: Query) => Promise version: () => Promise isHealthy: () => boolean - flavor: string, } export type Closeable = { diff --git a/query-engine/js-connectors/smoke-test-js/src/neon.ts b/query-engine/js-connectors/smoke-test-js/src/neon.ts index 83013deaff3..80da0357b17 100644 --- a/query-engine/js-connectors/smoke-test-js/src/neon.ts +++ b/query-engine/js-connectors/smoke-test-js/src/neon.ts @@ -1,84 +1,17 @@ +import { createNeonConnector } from './connector/neon.js' +import { smokeTest } from './test.js' -import { setImmediate, setTimeout } from 'node:timers/promises' - -import { binder } from './driver/util.js' -import { createNeonConnector } from './driver/neon.js' -import { initQueryEngine } from './util.js' - -async function main() { +async function neon() { const connectionString = `${process.env.JS_NEON_DATABASE_URL as string}` - /* Use `db` if you want to test the actual Neon database */ const db = createNeonConnector({ - connectionString, + url: connectionString, }) - // `binder` is required to preserve the `this` context to the group of functions passed to libquery. - const driver = binder(db) - - // wait for the database pool to be initialized - await setImmediate(0) - - const engine = initQueryEngine(driver) - - console.log('[nodejs] connecting...') - await engine.connect('trace') - console.log('[nodejs] connected') - - console.log('[nodejs] isHealthy', await driver.isHealthy()) - - // Smoke test for Neon that ensures we're able to decode every common data type. - const resultSet = await engine.query(`{ - "action": "findMany", - "modelName": "type_test", - "query": { - "selection": { - "smallint_column": true, - "int_column": true, - "bigint_column": true, - "float_column": true, - "double_column": true, - "decimal_column": true, - "boolean_column": true, - "char_column": true, - "varchar_column": true, - "text_column": true, - "date_column": true, - "time_column": true, - "datetime_column": true, - "timestamp_column": true, - "json_column": true, - "enum_column": true - } - } - }`, 'trace', undefined) - - console.log('[nodejs] findMany resultSet', JSON.stringify(JSON.parse(resultSet), null, 2)) - - // Note: calling `engine.disconnect` won't actually close the database connection. - console.log('[nodejs] disconnecting...') - await engine.disconnect('trace') - console.log('[nodejs] disconnected') - - console.log('[nodejs] re-connecting...') - await engine.connect('trace') - console.log('[nodejs] re-connecting') - - await setTimeout(0) - - console.log('[nodejs] re-disconnecting...') - await engine.disconnect('trace') - console.log('[nodejs] re-disconnected') - - // Close the database connection. This is required to prevent the process from hanging. - console.log('[nodejs] closing database connection...') - await driver.close() - console.log('[nodejs] closed database connection') - - process.exit(0) + await smokeTest(db, '../prisma/postgres-neon/schema.prisma') } -main().catch((e) => { +neon().catch((e) => { console.error(e) process.exit(1) }) diff --git a/query-engine/js-connectors/smoke-test-js/src/planetscale.ts b/query-engine/js-connectors/smoke-test-js/src/planetscale.ts index 429c22d4a8c..cfbcaede9ba 100644 --- a/query-engine/js-connectors/smoke-test-js/src/planetscale.ts +++ b/query-engine/js-connectors/smoke-test-js/src/planetscale.ts @@ -1,89 +1,17 @@ +import { createPlanetScaleConnector } from './connector/planetscale.js' +import { smokeTest } from './test.js' -import { setImmediate, setTimeout } from 'node:timers/promises' - -import { binder } from './driver/util.js' -import { createPlanetScaleConnector } from './driver/planetscale.js' -import { initQueryEngine } from './util.js' - -async function main() { +async function planetscale() { const connectionString = `${process.env.JS_PLANETSCALE_DATABASE_URL as string}` - /* Use `db` if you want to test the actual PlanetScale database */ const db = createPlanetScaleConnector({ url: connectionString, }) - // `binder` is required to preserve the `this` context to the group of functions passed to libquery. - const driver = binder(db) - - // wait for the database pool to be initialized - await setImmediate(0) - - const engine = initQueryEngine(driver) - - console.log('[nodejs] connecting...') - await engine.connect('trace') - console.log('[nodejs] connected') - - console.log('[nodejs] isHealthy', await driver.isHealthy()) - - // Smoke test for PlanetScale that ensures we're able to decode every common data type. - const resultSet = await engine.query(`{ - "action": "findMany", - "modelName": "type_test", - "query": { - "selection": { - "tinyint_column": true, - "smallint_column": true, - "mediumint_column": true, - "int_column": true, - "bigint_column": true, - "float_column": true, - "double_column": true, - "decimal_column": true, - "boolean_column": true, - "char_column": true, - "varchar_column": true, - "text_column": true, - "date_column": true, - "time_column": true, - "datetime_column": true, - "timestamp_column": true, - "json_column": true, - "enum_column": true, - "binary_column": true, - "varbinary_column": true, - "blob_column": true - } - } - }`, 'trace', undefined) - - console.log('[nodejs] findMany resultSet', JSON.stringify(JSON.parse(resultSet), null, 2)) - - // Note: calling `engine.disconnect` won't actually close the database connection. - console.log('[nodejs] disconnecting...') - await engine.disconnect('trace') - console.log('[nodejs] disconnected') - - console.log('[nodejs] re-connecting...') - await engine.connect('trace') - console.log('[nodejs] re-connecting') - - await setTimeout(0) - - console.log('[nodejs] re-disconnecting...') - await engine.disconnect('trace') - console.log('[nodejs] re-disconnected') - - // Close the database connection. This is required to prevent the process from hanging. - console.log('[nodejs] closing database connection...') - await driver.close() - console.log('[nodejs] closed database connection') - - process.exit(0) + await smokeTest(db, '../prisma/mysql-planetscale/schema.prisma') } -main().catch((e) => { +planetscale().catch((e) => { console.error(e) process.exit(1) }) diff --git a/query-engine/js-connectors/smoke-test-js/src/test.ts b/query-engine/js-connectors/smoke-test-js/src/test.ts new file mode 100644 index 00000000000..1a8b56e3fa1 --- /dev/null +++ b/query-engine/js-connectors/smoke-test-js/src/test.ts @@ -0,0 +1,263 @@ +import { setImmediate, setTimeout } from 'node:timers/promises' +import type { QueryEngineInstance, Connector, Closeable } from './engines/types/Library.js' +import { binder } from './connector/util.js' +import { initQueryEngine } from './util.js' + +type Flavor = Connector['flavor'] + +export async function smokeTest(db: Connector & Closeable, prismaSchemaRelativePath: string) { + // `binder` is required to preserve the `this` context to the group of functions passed to libquery. + const conn = binder(db) + + // wait for the database pool to be initialized + await setImmediate(0) + + const engine = initQueryEngine(conn, prismaSchemaRelativePath) + + console.log('[nodejs] connecting...') + await engine.connect('trace') + console.log('[nodejs] connected') + + // console.log('[nodejs] isHealthy', await conn.isHealthy()) + + const test = new SmokeTest(engine, db.flavor) + + await test.testFindManyTypeTest() + await test.testCreateAndDeleteChildParent() + + // Note: calling `engine.disconnect` won't actually close the database connection. + console.log('[nodejs] disconnecting...') + await engine.disconnect('trace') + console.log('[nodejs] disconnected') + + console.log('[nodejs] re-connecting...') + await engine.connect('trace') + console.log('[nodejs] re-connecting') + + await setTimeout(0) + + console.log('[nodejs] re-disconnecting...') + await engine.disconnect('trace') + console.log('[nodejs] re-disconnected') + + // Close the database connection. This is required to prevent the process from hanging. + console.log('[nodejs] closing database connection...') + await conn.close() + console.log('[nodejs] closed database connection') + + process.exit(0) +} + +class SmokeTest { + constructor(private readonly engine: QueryEngineInstance, readonly flavor: Connector['flavor']) {} + + async testFindManyTypeTest() { + await this.testFindManyTypeTestMySQL() + await this.testFindManyTypeTestPostgres() + } + + @withFlavor({ only: ['mysql'] }) + private async testFindManyTypeTestMySQL() { + const resultSet = await this.engine.query(` + { + "action": "findMany", + "modelName": "type_test", + "query": { + "selection": { + "tinyint_column": true, + "smallint_column": true, + "mediumint_column": true, + "int_column": true, + "bigint_column": true, + "float_column": true, + "double_column": true, + "decimal_column": true, + "boolean_column": true, + "char_column": true, + "varchar_column": true, + "text_column": true, + "date_column": true, + "time_column": true, + "datetime_column": true, + "timestamp_column": true, + "json_column": true, + "enum_column": true, + "binary_column": true, + "varbinary_column": true, + "blob_column": true + } + } + } + `, 'trace', undefined) + console.log('[nodejs] findMany resultSet', JSON.stringify(JSON.parse(resultSet), null, 2)) + + return resultSet + } + + @withFlavor({ only: ['postgres'] }) + private async testFindManyTypeTestPostgres() { + const resultSet = await this.engine.query(` + { + "action": "findMany", + "modelName": "type_test", + "query": { + "selection": { + "smallint_column": true, + "int_column": true, + "bigint_column": true, + "float_column": true, + "double_column": true, + "decimal_column": true, + "boolean_column": true, + "char_column": true, + "varchar_column": true, + "text_column": true, + "date_column": true, + "time_column": true, + "datetime_column": true, + "timestamp_column": true, + "json_column": true, + "enum_column": true + } + } + } + `, 'trace', undefined) + console.log('[nodejs] findMany resultSet', JSON.stringify(JSON.parse(resultSet), null, 2)) + + return resultSet + } + + @withFlavor({ exclude: ['postgres'] }) + async testCreateAndDeleteChildParent() { + /* Delete all child and parent records */ + + // Queries: [ + // 'SELECT `cf-users`.`Child`.`id` FROM `cf-users`.`Child` WHERE 1=1', + // 'SELECT `cf-users`.`Child`.`id` FROM `cf-users`.`Child` WHERE 1=1', + // 'DELETE FROM `cf-users`.`Child` WHERE (`cf-users`.`Child`.`id` IN (?) AND 1=1)' + // ] + await this.engine.query(` + { + "modelName": "Child", + "action": "deleteMany", + "query": { + "arguments": { + "where": {} + }, + "selection": { + "count": true + } + } + } + `, 'trace', undefined) + + // Queries: [ + // 'SELECT `cf-users`.`Parent`.`id` FROM `cf-users`.`Parent` WHERE 1=1', + // 'SELECT `cf-users`.`Parent`.`id` FROM `cf-users`.`Parent` WHERE 1=1', + // 'DELETE FROM `cf-users`.`Parent` WHERE (`cf-users`.`Parent`.`id` IN (?) AND 1=1)' + // ] + await this.engine.query(` + { + "modelName": "Parent", + "action": "deleteMany", + "query": { + "arguments": { + "where": {} + }, + "selection": { + "count": true + } + } + } + `, 'trace', undefined) + + /* Create a parent with some new children, within a transaction */ + + // Queries: [ + // 'INSERT INTO `cf-users`.`Parent` (`p`,`p_1`,`p_2`,`id`) VALUES (?,?,?,?)', + // 'INSERT INTO `cf-users`.`Child` (`c`,`c_1`,`c_2`,`parentId`,`id`) VALUES (?,?,?,?,?)', + // 'SELECT `cf-users`.`Parent`.`id`, `cf-users`.`Parent`.`p` FROM `cf-users`.`Parent` WHERE `cf-users`.`Parent`.`id` = ? LIMIT ? OFFSET ?', + // 'SELECT `cf-users`.`Child`.`id`, `cf-users`.`Child`.`c`, `cf-users`.`Child`.`parentId` FROM `cf-users`.`Child` WHERE `cf-users`.`Child`.`parentId` IN (?)' + // ] + await this.engine.query(` + { + "modelName": "Parent", + "action": "createOne", + "query": { + "arguments": { + "data": { + "p": "p1", + "p_1": "1", + "p_2": "2", + "childOpt": { + "create": { + "c": "c1", + "c_1": "foo", + "c_2": "bar" + } + } + } + }, + "selection": { + "p": true, + "childOpt": { + "arguments": null, + "selection": { + "c": true + } + } + } + } + } + `, 'trace', undefined) + + /* Delete the parent */ + + // Queries: [ + // 'SELECT `cf-users`.`Parent`.`id` FROM `cf-users`.`Parent` WHERE `cf-users`.`Parent`.`p` = ?', + // 'SELECT `cf-users`.`Child`.`id`, `cf-users`.`Child`.`parentId` FROM `cf-users`.`Child` WHERE (1=1 AND `cf-users`.`Child`.`parentId` IN (?))', + // 'UPDATE `cf-users`.`Child` SET `parentId` = ? WHERE (`cf-users`.`Child`.`id` IN (?) AND 1=1)', + // 'SELECT `cf-users`.`Parent`.`id` FROM `cf-users`.`Parent` WHERE `cf-users`.`Parent`.`p` = ?', + // 'DELETE FROM `cf-users`.`Parent` WHERE (`cf-users`.`Parent`.`id` IN (?) AND `cf-users`.`Parent`.`p` = ?)' + // ] + const resultDeleteMany = await this.engine.query(` + { + "modelName": "Parent", + "action": "deleteMany", + "query": { + "arguments": { + "where": { + "p": "p1" + } + }, + "selection": { + "count": true + } + } + } + `, 'trace', undefined) + console.log('[js] resultDeleteMany', JSON.stringify(JSON.parse(resultDeleteMany), null, 2)) + } +} + +type WithFlavorInput + = { only: Array, exclude?: never } + | { exclude: Array, only?: never } + +function withFlavor({ only, exclude }: WithFlavorInput) { + return function decorator(originalMethod: () => any, _ctx: ClassMethodDecoratorContext unknown>) { + return function replacement(this: SmokeTest) { + if ((exclude || []).includes(this.flavor)) { + console.log(`[nodejs::exclude] Skipping test "${originalMethod.name}" with flavor: ${this.flavor}`) + return + } + + if ((only || []).length > 0 && !(only || []).includes(this.flavor)) { + console.log(`[nodejs::only] Skipping test "${originalMethod.name}" with flavor: ${this.flavor}`) + return + } + + return originalMethod.call(this) + } + } +} diff --git a/query-engine/js-connectors/smoke-test-js/src/util.ts b/query-engine/js-connectors/smoke-test-js/src/util.ts index 4e02237469b..3d5735962a0 100644 --- a/query-engine/js-connectors/smoke-test-js/src/util.ts +++ b/query-engine/js-connectors/smoke-test-js/src/util.ts @@ -4,13 +4,15 @@ import fs from 'node:fs' import { Connector, Library, QueryEngineInstance } from './engines/types/Library.js' -export function initQueryEngine(driver: Connector): QueryEngineInstance { +export function initQueryEngine(driver: Connector, prismaSchemaRelativePath: string): QueryEngineInstance { // I assume nobody will run this on Windows ¯\_(ツ)_/¯ const libExt = os.platform() === 'darwin' ? 'dylib' : 'so' const dirname = path.dirname(new URL(import.meta.url).pathname) const libQueryEnginePath = path.join(dirname, `../../../../target/debug/libquery_engine.${libExt}`) - const schemaPath = path.join(dirname, `../prisma/schema.prisma`) + const schemaPath = path.join(dirname, prismaSchemaRelativePath) + + console.log('[js] read Prisma schema from', schemaPath) const libqueryEngine = { exports: {} as unknown as Library } // @ts-ignore diff --git a/query-engine/js-connectors/src/proxy.rs b/query-engine/js-connectors/src/proxy.rs index 6a0b40880ab..386614988d3 100644 --- a/query-engine/js-connectors/src/proxy.rs +++ b/query-engine/js-connectors/src/proxy.rs @@ -1,11 +1,14 @@ use core::panic; +use std::sync::{Arc, Condvar, Mutex}; use napi::bindgen_prelude::{FromNapiValue, Promise as JsPromise, ToNapiValue}; use napi::threadsafe_function::{ErrorStrategy, ThreadsafeFunction}; use napi::{JsObject, JsString}; use napi_derive::napi; +use psl::JsConnectorFlavor; use quaint::connector::ResultSet as QuaintResultSet; use quaint::Value as QuaintValue; +use std::str::FromStr; // TODO(jkomyno): import these 3rd-party crates from the `quaint-core` crate. use bigdecimal::BigDecimal; @@ -33,7 +36,10 @@ pub struct Proxy { close: ThreadsafeFunction<(), ErrorStrategy::Fatal>, /// Return true iff the underlying database connection is healthy. - #[allow(dead_code)] + /// Note: we already attempted turning `is_healthy` into just a `JsFunction` + /// (which would result in a simpler `call` API), but any call to it panics, + /// and `unsafe impl Send/Sync` for `Proxy` become necessary. + /// Moreover, `JsFunction` is not `Clone`. is_healthy: ThreadsafeFunction<(), ErrorStrategy::Fatal>, /// Return the flavor for this driver. @@ -42,13 +48,13 @@ pub struct Proxy { } /// Reify creates a Rust proxy to access the JS driver passed in as a parameter. -pub fn reify(js_driver: JsObject) -> napi::Result { - let query_raw = js_driver.get_named_property("queryRaw")?; - let execute_raw = js_driver.get_named_property("executeRaw")?; - let version = js_driver.get_named_property("version")?; - let close = js_driver.get_named_property("close")?; - let is_healthy = js_driver.get_named_property("isHealthy")?; - let flavor: JsString = js_driver.get_named_property("flavor")?; +pub fn reify(js_connector: JsObject) -> napi::Result { + let query_raw = js_connector.get_named_property("queryRaw")?; + let execute_raw = js_connector.get_named_property("executeRaw")?; + let version = js_connector.get_named_property("version")?; + let close: ThreadsafeFunction<(), ErrorStrategy::Fatal> = js_connector.get_named_property("close")?; + let is_healthy = js_connector.get_named_property("isHealthy")?; + let flavor: JsString = js_connector.get_named_property("flavor")?; let driver = Proxy { query_raw, @@ -172,6 +178,42 @@ pub struct Query { } fn js_planetscale_value_to_quaint(json_value: serde_json::Value, column_type: ColumnType) -> QuaintValue<'static> { + match column_type { + ColumnType::Boolean => match json_value { + serde_json::Value::Number(b) => QuaintValue::Boolean(b.as_u64().or(None).map(|b| b != 0)), + serde_json::Value::Null => QuaintValue::Boolean(None), + mismatch => panic!("Expected a number, found {:?}", mismatch), + }, + ColumnType::Char => match json_value { + serde_json::Value::String(s) if s.len() == 1 => QuaintValue::Char(s.chars().next()), + serde_json::Value::Null => QuaintValue::Char(None), + mismatch => panic!("Expected a string, found {:?}", mismatch), + }, + _ => js_base_value_to_quaint(json_value, column_type), + } +} + +fn js_neon_value_to_quaint(json_value: serde_json::Value, column_type: ColumnType) -> QuaintValue<'static> { + match column_type { + ColumnType::Boolean => match json_value { + serde_json::Value::Bool(b) => QuaintValue::boolean(b), + serde_json::Value::Null => QuaintValue::Boolean(None), + mismatch => panic!("Expected a boolean, found {:?}", mismatch), + }, + ColumnType::Char => match json_value { + serde_json::Value::String(s) => QuaintValue::Char(s.chars().next()), + serde_json::Value::Null => QuaintValue::Char(None), + mismatch => panic!("Expected a string, found {:?}", mismatch), + }, + _ => js_base_value_to_quaint(json_value, column_type), + } +} + +/// Handle data-type conversion from a JSON value to a Quaint value. +/// This is used for most data types, except those that require connector-specific handling, e.g., `ColumnType::Boolean`. +/// In the future, after https://github.com/prisma/team-orm/issues/257, every connector-specific handling should be moved +/// out of Rust and into TypeScript. +fn js_base_value_to_quaint(json_value: serde_json::Value, column_type: ColumnType) -> QuaintValue<'static> { // Note for the future: it may be worth revisiting how much bloat so many panics with different static // strings add to the compiled artefact, and in case we should come up with a restricted set of panic // messages, or even find a way of removing them altogether. @@ -193,8 +235,9 @@ fn js_planetscale_value_to_quaint(json_value: serde_json::Value, column_type: Co mismatch => panic!("Expected a string, found {:?}", mismatch), }, ColumnType::Float => match json_value { - // n.as_f32() is not implemented, so we need to downcast from f64 instead - serde_json::Value::Number(n) => QuaintValue::float(n.as_f64().expect("number must be a f32") as f32), + // n.as_f32() is not implemented, so we need to downcast from f64 instead. + // We assume that the JSON value is a valid f32 number, but we check for overflows anyway. + serde_json::Value::Number(n) => QuaintValue::float(f64_to_f32(n.as_f64().expect("number must be a f64"))), serde_json::Value::Null => QuaintValue::Float(None), mismatch => panic!("Expected a f32 number, found {:?}", mismatch), }, @@ -205,36 +248,19 @@ fn js_planetscale_value_to_quaint(json_value: serde_json::Value, column_type: Co }, ColumnType::Numeric => match json_value { serde_json::Value::String(s) => { - // Turn this into a BigInt value with an additional "scale" variable indicating the scale of 10. - // E.g., if s = "1234.99", s_as_bigint = 123499, s_scale = 2. - let (s_as_bigint, s_scale) = if let Some(dot) = s.find('.') { - let scale = s.len() - dot - 1; - let s = s.replace('.', ""); - ( - num_bigint::BigInt::parse_bytes(s.as_bytes(), 10) - .expect("string-encoded number must be a numeric"), - scale as i64, - ) - } else { - ( - num_bigint::BigInt::parse_bytes(s.as_bytes(), 10) - .expect("string-encoded number must be a numeric"), - 0, - ) - }; - let decimal = BigDecimal::new(s_as_bigint, s_scale); + let decimal = BigDecimal::from_str(&s).expect("invalid numeric value"); QuaintValue::numeric(decimal) } serde_json::Value::Null => QuaintValue::Numeric(None), mismatch => panic!("Expected a string-encoded number, found {:?}", mismatch), }, ColumnType::Boolean => match json_value { - serde_json::Value::Number(b) => QuaintValue::Boolean(b.as_u64().or(None).map(|b| b != 0)), + serde_json::Value::Bool(b) => QuaintValue::boolean(b), serde_json::Value::Null => QuaintValue::Boolean(None), - mismatch => panic!("Expected a number, found {:?}", mismatch), + mismatch => panic!("Expected a boolean, found {:?}", mismatch), }, ColumnType::Char => match json_value { - serde_json::Value::String(s) if s.len() == 1 => QuaintValue::Char(s.chars().next()), + serde_json::Value::String(s) => QuaintValue::Char(s.chars().next()), serde_json::Value::Null => QuaintValue::Char(None), mismatch => panic!("Expected a string, found {:?}", mismatch), }, @@ -289,8 +315,11 @@ fn js_planetscale_value_to_quaint(json_value: serde_json::Value, column_type: Co } } -impl From for QuaintResultSet { - fn from(mut js_result_set: JSResultSet) -> Self { +pub struct FlavoredJSResultSet(pub (JsConnectorFlavor, JSResultSet)); + +impl From for QuaintResultSet { + fn from(pair: FlavoredJSResultSet) -> Self { + let (flavor, mut js_result_set) = pair.0; // TODO: extract, todo: error rather than panic? let to_quaint_row = move |row: &mut Vec| -> Vec> { let mut res = Vec::with_capacity(row.len()); @@ -298,7 +327,13 @@ impl From for QuaintResultSet { for i in 0..row.len() { let column_type = js_result_set.column_types[i]; let json_value = row.remove(0); - let quaint_value = js_planetscale_value_to_quaint(json_value, column_type); + + // Note: here, we could consider using conditional compile-time variables to avoid the match. + let quaint_value = match flavor { + JsConnectorFlavor::MySQL => js_planetscale_value_to_quaint(json_value, column_type), + JsConnectorFlavor::Postgres => js_neon_value_to_quaint(json_value, column_type), + }; + res.push(quaint_value); } @@ -335,274 +370,578 @@ impl Proxy { } pub fn is_healthy(&self) -> napi::Result { - // TODO: call `is_healthy` in a blocking fashion, returning its result as a boolean. - unimplemented!(); + let result_arc = Arc::new((Mutex::new(None), Condvar::new())); + let result_arc_clone: Arc<(Mutex>, Condvar)> = result_arc.clone(); + + let set_value_callback = move |value: bool| { + let (lock, cvar) = &*result_arc_clone; + let mut result_guard = lock.lock().unwrap(); + *result_guard = Some(value); + cvar.notify_one(); + + Ok(()) + }; + + // Should anyone find a less mind-boggling way to retrieve the result of a synchronous JS + // function, please do so. + self.is_healthy.call_with_return_value( + (), + napi::threadsafe_function::ThreadsafeFunctionCallMode::Blocking, + set_value_callback, + ); + + // wait for `set_value_callback` to be called and to set the result + let (lock, cvar) = &*result_arc; + let mut result_guard = lock.lock().unwrap(); + while result_guard.is_none() { + result_guard = cvar.wait(result_guard).unwrap(); + } + + Ok(result_guard.unwrap_or_default()) } } +/// Coerce a `f64` to a `f32`, asserting that the conversion is lossless. +/// Note that, when overflow occurs during conversion, the result is `infinity`. +fn f64_to_f32(x: f64) -> f32 { + let y = x as f32; + + assert_eq!(x.is_finite(), y.is_finite(), "f32 overflow during conversion"); + + y +} + #[cfg(test)] mod proxy_test { - use num_bigint::BigInt; - use serde_json::json; + mod planetscale { + use num_bigint::BigInt; + use serde_json::json; - use super::*; + use super::super::*; - #[track_caller] - fn test_null(quaint_none: QuaintValue, column_type: ColumnType) { - let json_value = serde_json::Value::Null; - let quaint_value = super::js_planetscale_value_to_quaint(json_value, column_type); - assert_eq!(quaint_value, quaint_none); - } + #[track_caller] + fn test_null(quaint_none: QuaintValue, column_type: ColumnType) { + let json_value = serde_json::Value::Null; + let quaint_value = js_planetscale_value_to_quaint(json_value, column_type); + assert_eq!(quaint_value, quaint_none); + } - #[test] - fn js_planetscale_value_int32_to_quaint() { - let column_type = ColumnType::Int32; - - // null - test_null(QuaintValue::Int32(None), column_type); - - // 0 - let n: i32 = 0; - let json_value = serde_json::Value::Number(serde_json::Number::from(n)); - let quaint_value = super::js_planetscale_value_to_quaint(json_value, column_type); - assert_eq!(quaint_value, QuaintValue::Int32(Some(n))); - - // max - let n: i32 = i32::MAX; - let json_value = serde_json::Value::Number(serde_json::Number::from(n)); - let quaint_value = super::js_planetscale_value_to_quaint(json_value, column_type); - assert_eq!(quaint_value, QuaintValue::Int32(Some(n))); - - // min - let n: i32 = i32::MIN; - let json_value = serde_json::Value::Number(serde_json::Number::from(n)); - let quaint_value = super::js_planetscale_value_to_quaint(json_value, column_type); - assert_eq!(quaint_value, QuaintValue::Int32(Some(n))); - } + #[test] + fn js_planetscale_value_int32_to_quaint() { + let column_type = ColumnType::Int32; + + // null + test_null(QuaintValue::Int32(None), column_type); + + // 0 + let n: i32 = 0; + let json_value = serde_json::Value::Number(serde_json::Number::from(n)); + let quaint_value = js_planetscale_value_to_quaint(json_value, column_type); + assert_eq!(quaint_value, QuaintValue::Int32(Some(n))); + + // max + let n: i32 = i32::MAX; + let json_value = serde_json::Value::Number(serde_json::Number::from(n)); + let quaint_value = js_planetscale_value_to_quaint(json_value, column_type); + assert_eq!(quaint_value, QuaintValue::Int32(Some(n))); + + // min + let n: i32 = i32::MIN; + let json_value = serde_json::Value::Number(serde_json::Number::from(n)); + let quaint_value = js_planetscale_value_to_quaint(json_value, column_type); + assert_eq!(quaint_value, QuaintValue::Int32(Some(n))); + } - #[test] - fn js_planetscale_value_int64_to_quaint() { - let column_type = ColumnType::Int64; - - // null - test_null(QuaintValue::Int64(None), column_type); - - // 0 - let n: i64 = 0; - let json_value = serde_json::Value::String(n.to_string()); - let quaint_value = super::js_planetscale_value_to_quaint(json_value, column_type); - assert_eq!(quaint_value, QuaintValue::Int64(Some(n))); - - // max - let n: i64 = i64::MAX; - let json_value = serde_json::Value::String(n.to_string()); - let quaint_value = super::js_planetscale_value_to_quaint(json_value, column_type); - assert_eq!(quaint_value, QuaintValue::Int64(Some(n))); - - // min - let n: i64 = i64::MIN; - let json_value = serde_json::Value::String(n.to_string()); - let quaint_value = super::js_planetscale_value_to_quaint(json_value, column_type); - assert_eq!(quaint_value, QuaintValue::Int64(Some(n))); - } + #[test] + fn js_planetscale_value_int64_to_quaint() { + let column_type = ColumnType::Int64; + + // null + test_null(QuaintValue::Int64(None), column_type); + + // 0 + let n: i64 = 0; + let json_value = serde_json::Value::String(n.to_string()); + let quaint_value = js_planetscale_value_to_quaint(json_value, column_type); + assert_eq!(quaint_value, QuaintValue::Int64(Some(n))); + + // max + let n: i64 = i64::MAX; + let json_value = serde_json::Value::String(n.to_string()); + let quaint_value = js_planetscale_value_to_quaint(json_value, column_type); + assert_eq!(quaint_value, QuaintValue::Int64(Some(n))); + + // min + let n: i64 = i64::MIN; + let json_value = serde_json::Value::String(n.to_string()); + let quaint_value = js_planetscale_value_to_quaint(json_value, column_type); + assert_eq!(quaint_value, QuaintValue::Int64(Some(n))); + } - #[test] - fn js_planetscale_value_float_to_quaint() { - let column_type = ColumnType::Float; - - // null - test_null(QuaintValue::Float(None), column_type); - - // 0 - let n: f32 = 0.0; - let json_value = serde_json::Value::Number(serde_json::Number::from_f64(n.into()).unwrap()); - let quaint_value = super::js_planetscale_value_to_quaint(json_value, column_type); - assert_eq!(quaint_value, QuaintValue::Float(Some(n))); - - // max - let n: f32 = f32::MAX; - let json_value = serde_json::Value::Number(serde_json::Number::from_f64(n.into()).unwrap()); - let quaint_value = super::js_planetscale_value_to_quaint(json_value, column_type); - assert_eq!(quaint_value, QuaintValue::Float(Some(n))); - - // min - let n: f32 = f32::MIN; - let json_value = serde_json::Value::Number(serde_json::Number::from_f64(n.into()).unwrap()); - let quaint_value = super::js_planetscale_value_to_quaint(json_value, column_type); - assert_eq!(quaint_value, QuaintValue::Float(Some(n))); - } + #[test] + fn js_planetscale_value_float_to_quaint() { + let column_type = ColumnType::Float; + + // null + test_null(QuaintValue::Float(None), column_type); + + // 0 + let n: f32 = 0.0; + let json_value = serde_json::Value::Number(serde_json::Number::from_f64(n.into()).unwrap()); + let quaint_value = js_planetscale_value_to_quaint(json_value, column_type); + assert_eq!(quaint_value, QuaintValue::Float(Some(n))); + + // max + let n: f32 = f32::MAX; + let json_value = serde_json::Value::Number(serde_json::Number::from_f64(n.into()).unwrap()); + let quaint_value = js_planetscale_value_to_quaint(json_value, column_type); + assert_eq!(quaint_value, QuaintValue::Float(Some(n))); + + // min + let n: f32 = f32::MIN; + let json_value = serde_json::Value::Number(serde_json::Number::from_f64(n.into()).unwrap()); + let quaint_value = js_planetscale_value_to_quaint(json_value, column_type); + assert_eq!(quaint_value, QuaintValue::Float(Some(n))); + } - #[test] - fn js_planetscale_value_double_to_quaint() { - let column_type = ColumnType::Double; - - // null - test_null(QuaintValue::Double(None), column_type); - - // 0 - let n: f64 = 0.0; - let json_value = serde_json::Value::Number(serde_json::Number::from_f64(n).unwrap()); - let quaint_value = super::js_planetscale_value_to_quaint(json_value, column_type); - assert_eq!(quaint_value, QuaintValue::Double(Some(n))); - - // max - let n: f64 = f64::MAX; - let json_value = serde_json::Value::Number(serde_json::Number::from_f64(n).unwrap()); - let quaint_value = super::js_planetscale_value_to_quaint(json_value, column_type); - assert_eq!(quaint_value, QuaintValue::Double(Some(n))); - - // min - let n: f64 = f64::MIN; - let json_value = serde_json::Value::Number(serde_json::Number::from_f64(n).unwrap()); - let quaint_value = super::js_planetscale_value_to_quaint(json_value, column_type); - assert_eq!(quaint_value, QuaintValue::Double(Some(n))); - } + #[test] + fn js_planetscale_value_double_to_quaint() { + let column_type = ColumnType::Double; + + // null + test_null(QuaintValue::Double(None), column_type); + + // 0 + let n: f64 = 0.0; + let json_value = serde_json::Value::Number(serde_json::Number::from_f64(n).unwrap()); + let quaint_value = js_planetscale_value_to_quaint(json_value, column_type); + assert_eq!(quaint_value, QuaintValue::Double(Some(n))); + + // max + let n: f64 = f64::MAX; + let json_value = serde_json::Value::Number(serde_json::Number::from_f64(n).unwrap()); + let quaint_value = js_planetscale_value_to_quaint(json_value, column_type); + assert_eq!(quaint_value, QuaintValue::Double(Some(n))); + + // min + let n: f64 = f64::MIN; + let json_value = serde_json::Value::Number(serde_json::Number::from_f64(n).unwrap()); + let quaint_value = js_planetscale_value_to_quaint(json_value, column_type); + assert_eq!(quaint_value, QuaintValue::Double(Some(n))); + } - #[test] - fn js_planetscale_value_numeric_to_quaint() { - let column_type = ColumnType::Numeric; + #[test] + fn js_planetscale_value_numeric_to_quaint() { + let column_type = ColumnType::Numeric; - // null - test_null(QuaintValue::Numeric(None), column_type); + // null + test_null(QuaintValue::Numeric(None), column_type); - let n_as_string = "1234.99"; - let decimal = BigDecimal::new(BigInt::parse_bytes(b"123499", 10).unwrap(), 2); + let n_as_string = "1234.99"; + let decimal = BigDecimal::new(BigInt::parse_bytes(b"123499", 10).unwrap(), 2); - let json_value = serde_json::Value::String(n_as_string.into()); - let quaint_value = super::js_planetscale_value_to_quaint(json_value, column_type); - assert_eq!(quaint_value, QuaintValue::Numeric(Some(decimal))); + let json_value = serde_json::Value::String(n_as_string.into()); + let quaint_value = js_planetscale_value_to_quaint(json_value, column_type); + assert_eq!(quaint_value, QuaintValue::Numeric(Some(decimal))); - let n_as_string = "1234.999999"; - let decimal = BigDecimal::new(BigInt::parse_bytes(b"1234999999", 10).unwrap(), 6); + let n_as_string = "1234.999999"; + let decimal = BigDecimal::new(BigInt::parse_bytes(b"1234999999", 10).unwrap(), 6); - let json_value = serde_json::Value::String(n_as_string.into()); - let quaint_value = super::js_planetscale_value_to_quaint(json_value, column_type); - assert_eq!(quaint_value, QuaintValue::Numeric(Some(decimal))); - } + let json_value = serde_json::Value::String(n_as_string.into()); + let quaint_value = js_planetscale_value_to_quaint(json_value, column_type); + assert_eq!(quaint_value, QuaintValue::Numeric(Some(decimal))); + } - #[test] - fn js_planetscale_value_boolean_to_quaint() { - let column_type = ColumnType::Boolean; + #[test] + fn js_planetscale_value_boolean_to_quaint() { + let column_type = ColumnType::Boolean; - // null - test_null(QuaintValue::Boolean(None), column_type); + // null + test_null(QuaintValue::Boolean(None), column_type); - // true - let bool_as_n = 1; - let json_value = serde_json::Value::Number(serde_json::Number::from(bool_as_n)); - let quaint_value = super::js_planetscale_value_to_quaint(json_value, column_type); - assert_eq!(quaint_value, QuaintValue::Boolean(Some(true))); + // true + let bool_as_n = 1; + let json_value = serde_json::Value::Number(serde_json::Number::from(bool_as_n)); + let quaint_value = js_planetscale_value_to_quaint(json_value, column_type); + assert_eq!(quaint_value, QuaintValue::Boolean(Some(true))); - // false - let bool_as_n = 0; - let json_value = serde_json::Value::Number(serde_json::Number::from(bool_as_n)); - let quaint_value = super::js_planetscale_value_to_quaint(json_value, column_type); - assert_eq!(quaint_value, QuaintValue::Boolean(Some(false))); - } + // false + let bool_as_n = 0; + let json_value = serde_json::Value::Number(serde_json::Number::from(bool_as_n)); + let quaint_value = js_planetscale_value_to_quaint(json_value, column_type); + assert_eq!(quaint_value, QuaintValue::Boolean(Some(false))); + } - #[test] - fn js_planetscale_value_char_to_quaint() { - let column_type = ColumnType::Char; + #[test] + fn js_planetscale_value_char_to_quaint() { + let column_type = ColumnType::Char; - // null - test_null(QuaintValue::Char(None), column_type); + // null + test_null(QuaintValue::Char(None), column_type); - let c = 'c'; - let json_value = serde_json::Value::String(c.to_string()); - let quaint_value = super::js_planetscale_value_to_quaint(json_value, column_type); - assert_eq!(quaint_value, QuaintValue::Char(Some(c))); - } + let c = 'c'; + let json_value = serde_json::Value::String(c.to_string()); + let quaint_value = js_planetscale_value_to_quaint(json_value, column_type); + assert_eq!(quaint_value, QuaintValue::Char(Some(c))); + } - #[test] - fn js_planetscale_value_text_to_quaint() { - let column_type = ColumnType::Text; + #[test] + fn js_planetscale_value_text_to_quaint() { + let column_type = ColumnType::Text; - // null - test_null(QuaintValue::Text(None), column_type); + // null + test_null(QuaintValue::Text(None), column_type); - let s = "some text"; - let json_value = serde_json::Value::String(s.to_string()); - let quaint_value = super::js_planetscale_value_to_quaint(json_value, column_type); - assert_eq!(quaint_value, QuaintValue::Text(Some(s.into()))); - } + let s = "some text"; + let json_value = serde_json::Value::String(s.to_string()); + let quaint_value = js_planetscale_value_to_quaint(json_value, column_type); + assert_eq!(quaint_value, QuaintValue::Text(Some(s.into()))); + } - #[test] - fn js_planetscale_value_date_to_quaint() { - let column_type = ColumnType::Date; + #[test] + fn js_planetscale_value_date_to_quaint() { + let column_type = ColumnType::Date; - // null - test_null(QuaintValue::Date(None), column_type); + // null + test_null(QuaintValue::Date(None), column_type); - let s = "2023-01-01"; - let json_value = serde_json::Value::String(s.to_string()); - let quaint_value = super::js_planetscale_value_to_quaint(json_value, column_type); + let s = "2023-01-01"; + let json_value = serde_json::Value::String(s.to_string()); + let quaint_value = js_planetscale_value_to_quaint(json_value, column_type); - let date = NaiveDate::from_ymd(2023, 01, 01); - assert_eq!(quaint_value, QuaintValue::Date(Some(date))); - } + let date = NaiveDate::from_ymd(2023, 01, 01); + assert_eq!(quaint_value, QuaintValue::Date(Some(date))); + } - #[test] - fn js_planetscale_value_time_to_quaint() { - let column_type = ColumnType::Time; + #[test] + fn js_planetscale_value_time_to_quaint() { + let column_type = ColumnType::Time; - // null - test_null(QuaintValue::Time(None), column_type); + // null + test_null(QuaintValue::Time(None), column_type); - let s = "23:59:59"; - let json_value = serde_json::Value::String(s.to_string()); - let quaint_value = super::js_planetscale_value_to_quaint(json_value, column_type); + let s = "23:59:59"; + let json_value = serde_json::Value::String(s.to_string()); + let quaint_value = js_planetscale_value_to_quaint(json_value, column_type); - let time: NaiveTime = NaiveTime::from_hms(23, 59, 59); - assert_eq!(quaint_value, QuaintValue::Time(Some(time))); - } + let time: NaiveTime = NaiveTime::from_hms(23, 59, 59); + assert_eq!(quaint_value, QuaintValue::Time(Some(time))); + } - #[test] - fn js_planetscale_value_datetime_to_quaint() { - let column_type = ColumnType::DateTime; + #[test] + fn js_planetscale_value_datetime_to_quaint() { + let column_type = ColumnType::DateTime; - // null - test_null(QuaintValue::DateTime(None), column_type); + // null + test_null(QuaintValue::DateTime(None), column_type); - let s = "2023-01-01 23:59:59"; - let json_value = serde_json::Value::String(s.to_string()); - let quaint_value = super::js_planetscale_value_to_quaint(json_value, column_type); + let s = "2023-01-01 23:59:59"; + let json_value = serde_json::Value::String(s.to_string()); + let quaint_value = js_planetscale_value_to_quaint(json_value, column_type); - let datetime = NaiveDate::from_ymd(2023, 01, 01).and_hms(23, 59, 59); - let datetime = DateTime::from_utc(datetime, Utc); - assert_eq!(quaint_value, QuaintValue::DateTime(Some(datetime))); - } + let datetime = NaiveDate::from_ymd(2023, 01, 01).and_hms(23, 59, 59); + let datetime = DateTime::from_utc(datetime, Utc); + assert_eq!(quaint_value, QuaintValue::DateTime(Some(datetime))); + } + + #[test] + fn js_planetscale_value_json_to_quaint() { + let column_type = ColumnType::Json; + + // null + test_null(QuaintValue::Json(None), column_type); + + let json = json!({ + "key": "value", + "nested": [ + true, + false, + 1, + null + ] + }); + let json_value = serde_json::Value::from(json.clone()); + let quaint_value = js_planetscale_value_to_quaint(json_value, column_type); + assert_eq!(quaint_value, QuaintValue::Json(Some(json.clone()))); + } - #[test] - fn js_planetscale_value_json_to_quaint() { - let column_type = ColumnType::Json; - - // null - test_null(QuaintValue::Json(None), column_type); - - let json = json!({ - "key": "value", - "nested": [ - true, - false, - 1, - null - ] - }); - let json_value = serde_json::Value::from(json.clone()); - let quaint_value = super::js_planetscale_value_to_quaint(json_value, column_type); - assert_eq!(quaint_value, QuaintValue::Json(Some(json.clone()))); + #[test] + fn js_planetscale_value_enum_to_quaint() { + let column_type = ColumnType::Enum; + + // null + test_null(QuaintValue::Enum(None), column_type); + + let s = "some enum variant"; + let json_value = serde_json::Value::String(s.to_string()); + let quaint_value = js_planetscale_value_to_quaint(json_value, column_type); + assert_eq!(quaint_value, QuaintValue::Enum(Some(s.into()))); + } } - #[test] - fn js_planetscale_value_enum_to_quaint() { - let column_type = ColumnType::Enum; + mod neon { + use num_bigint::BigInt; + use serde_json::json; + + use super::super::*; + + #[track_caller] + fn test_null(quaint_none: QuaintValue, column_type: ColumnType) { + let json_value = serde_json::Value::Null; + let quaint_value = js_neon_value_to_quaint(json_value, column_type); + assert_eq!(quaint_value, quaint_none); + } + + #[test] + fn js_neon_value_int32_to_quaint() { + let column_type = ColumnType::Int32; + + // null + test_null(QuaintValue::Int32(None), column_type); + + // 0 + let n: i32 = 0; + let json_value = serde_json::Value::Number(serde_json::Number::from(n)); + let quaint_value = js_neon_value_to_quaint(json_value, column_type); + assert_eq!(quaint_value, QuaintValue::Int32(Some(n))); + + // max + let n: i32 = i32::MAX; + let json_value = serde_json::Value::Number(serde_json::Number::from(n)); + let quaint_value = js_neon_value_to_quaint(json_value, column_type); + assert_eq!(quaint_value, QuaintValue::Int32(Some(n))); + + // min + let n: i32 = i32::MIN; + let json_value = serde_json::Value::Number(serde_json::Number::from(n)); + let quaint_value = js_neon_value_to_quaint(json_value, column_type); + assert_eq!(quaint_value, QuaintValue::Int32(Some(n))); + } + + #[test] + fn js_neon_value_int64_to_quaint() { + let column_type = ColumnType::Int64; + + // null + test_null(QuaintValue::Int64(None), column_type); + + // 0 + let n: i64 = 0; + let json_value = serde_json::Value::String(n.to_string()); + let quaint_value = js_neon_value_to_quaint(json_value, column_type); + assert_eq!(quaint_value, QuaintValue::Int64(Some(n))); + + // max + let n: i64 = i64::MAX; + let json_value = serde_json::Value::String(n.to_string()); + let quaint_value = js_neon_value_to_quaint(json_value, column_type); + assert_eq!(quaint_value, QuaintValue::Int64(Some(n))); + + // min + let n: i64 = i64::MIN; + let json_value = serde_json::Value::String(n.to_string()); + let quaint_value = js_neon_value_to_quaint(json_value, column_type); + assert_eq!(quaint_value, QuaintValue::Int64(Some(n))); + } + + #[test] + fn js_neon_value_float_to_quaint() { + let column_type = ColumnType::Float; + + // null + test_null(QuaintValue::Float(None), column_type); + + // 0 + let n: f32 = 0.0; + let json_value = serde_json::Value::Number(serde_json::Number::from_f64(n.into()).unwrap()); + let quaint_value = js_neon_value_to_quaint(json_value, column_type); + assert_eq!(quaint_value, QuaintValue::Float(Some(n))); + + // max + let n: f32 = f32::MAX; + let json_value = serde_json::Value::Number(serde_json::Number::from_f64(n.into()).unwrap()); + let quaint_value = js_neon_value_to_quaint(json_value, column_type); + assert_eq!(quaint_value, QuaintValue::Float(Some(n))); + + // min + let n: f32 = f32::MIN; + let json_value = serde_json::Value::Number(serde_json::Number::from_f64(n.into()).unwrap()); + let quaint_value = js_neon_value_to_quaint(json_value, column_type); + assert_eq!(quaint_value, QuaintValue::Float(Some(n))); + } + + #[test] + fn js_neon_value_double_to_quaint() { + let column_type = ColumnType::Double; + + // null + test_null(QuaintValue::Double(None), column_type); + + // 0 + let n: f64 = 0.0; + let json_value = serde_json::Value::Number(serde_json::Number::from_f64(n).unwrap()); + let quaint_value = js_neon_value_to_quaint(json_value, column_type); + assert_eq!(quaint_value, QuaintValue::Double(Some(n))); + + // max + let n: f64 = f64::MAX; + let json_value = serde_json::Value::Number(serde_json::Number::from_f64(n).unwrap()); + let quaint_value = js_neon_value_to_quaint(json_value, column_type); + assert_eq!(quaint_value, QuaintValue::Double(Some(n))); + + // min + let n: f64 = f64::MIN; + let json_value = serde_json::Value::Number(serde_json::Number::from_f64(n).unwrap()); + let quaint_value = js_neon_value_to_quaint(json_value, column_type); + assert_eq!(quaint_value, QuaintValue::Double(Some(n))); + } + + #[test] + fn js_neon_value_numeric_to_quaint() { + let column_type = ColumnType::Numeric; + + // null + test_null(QuaintValue::Numeric(None), column_type); + + let n_as_string = "1234.99"; + let decimal = BigDecimal::new(BigInt::parse_bytes(b"123499", 10).unwrap(), 2); + + let json_value = serde_json::Value::String(n_as_string.into()); + let quaint_value = js_neon_value_to_quaint(json_value, column_type); + assert_eq!(quaint_value, QuaintValue::Numeric(Some(decimal))); + + let n_as_string = "1234.999999"; + let decimal = BigDecimal::new(BigInt::parse_bytes(b"1234999999", 10).unwrap(), 6); + + let json_value = serde_json::Value::String(n_as_string.into()); + let quaint_value = js_neon_value_to_quaint(json_value, column_type); + assert_eq!(quaint_value, QuaintValue::Numeric(Some(decimal))); + } + + #[test] + fn js_neon_value_boolean_to_quaint() { + let column_type = ColumnType::Boolean; + + // null + test_null(QuaintValue::Boolean(None), column_type); - // null - test_null(QuaintValue::Enum(None), column_type); + // true + let bool_val = true; + let json_value = serde_json::Value::Bool(bool_val); + let quaint_value = js_neon_value_to_quaint(json_value, column_type); + assert_eq!(quaint_value, QuaintValue::Boolean(Some(bool_val))); - let s = "some enum variant"; - let json_value = serde_json::Value::String(s.to_string()); - let quaint_value = super::js_planetscale_value_to_quaint(json_value, column_type); - assert_eq!(quaint_value, QuaintValue::Enum(Some(s.into()))); + // false + let bool_val = false; + let json_value = serde_json::Value::Bool(bool_val); + let quaint_value = js_neon_value_to_quaint(json_value, column_type); + assert_eq!(quaint_value, QuaintValue::Boolean(Some(bool_val))); + } + + #[test] + fn js_neon_value_char_to_quaint() { + let column_type = ColumnType::Char; + + // null + test_null(QuaintValue::Char(None), column_type); + + let c = 'c'; + let json_value = serde_json::Value::String(c.to_string()); + let quaint_value = js_neon_value_to_quaint(json_value, column_type); + assert_eq!(quaint_value, QuaintValue::Char(Some(c))); + } + + #[test] + fn js_neon_value_text_to_quaint() { + let column_type = ColumnType::Text; + + // null + test_null(QuaintValue::Text(None), column_type); + + let s = "some text"; + let json_value = serde_json::Value::String(s.to_string()); + let quaint_value = js_neon_value_to_quaint(json_value, column_type); + assert_eq!(quaint_value, QuaintValue::Text(Some(s.into()))); + } + + #[test] + fn js_neon_value_date_to_quaint() { + let column_type = ColumnType::Date; + + // null + test_null(QuaintValue::Date(None), column_type); + + let s = "2023-01-01"; + let json_value = serde_json::Value::String(s.to_string()); + let quaint_value = js_neon_value_to_quaint(json_value, column_type); + + let date = NaiveDate::from_ymd(2023, 01, 01); + assert_eq!(quaint_value, QuaintValue::Date(Some(date))); + } + + #[test] + fn js_neon_value_time_to_quaint() { + let column_type = ColumnType::Time; + + // null + test_null(QuaintValue::Time(None), column_type); + + let s = "23:59:59"; + let json_value = serde_json::Value::String(s.to_string()); + let quaint_value = js_neon_value_to_quaint(json_value, column_type); + + let time: NaiveTime = NaiveTime::from_hms(23, 59, 59); + assert_eq!(quaint_value, QuaintValue::Time(Some(time))); + } + + #[test] + fn js_neon_value_datetime_to_quaint() { + let column_type = ColumnType::DateTime; + + // null + test_null(QuaintValue::DateTime(None), column_type); + + let s = "2023-01-01 23:59:59"; + let json_value = serde_json::Value::String(s.to_string()); + let quaint_value = js_neon_value_to_quaint(json_value, column_type); + + let datetime = NaiveDate::from_ymd(2023, 01, 01).and_hms(23, 59, 59); + let datetime = DateTime::from_utc(datetime, Utc); + assert_eq!(quaint_value, QuaintValue::DateTime(Some(datetime))); + } + + #[test] + fn js_neon_value_json_to_quaint() { + let column_type = ColumnType::Json; + + // null + test_null(QuaintValue::Json(None), column_type); + + let json = json!({ + "key": "value", + "nested": [ + true, + false, + 1, + null + ] + }); + let json_value = serde_json::Value::from(json.clone()); + let quaint_value = js_neon_value_to_quaint(json_value, column_type); + assert_eq!(quaint_value, QuaintValue::Json(Some(json.clone()))); + } + + #[test] + fn js_neon_value_enum_to_quaint() { + let column_type = ColumnType::Enum; + + // null + test_null(QuaintValue::Enum(None), column_type); + + let s = "some enum variant"; + let json_value = serde_json::Value::String(s.to_string()); + let quaint_value = js_neon_value_to_quaint(json_value, column_type); + assert_eq!(quaint_value, QuaintValue::Enum(Some(s.into()))); + } } } diff --git a/query-engine/js-connectors/src/queryable.rs b/query-engine/js-connectors/src/queryable.rs index 21941694897..2b9eda8695a 100644 --- a/query-engine/js-connectors/src/queryable.rs +++ b/query-engine/js-connectors/src/queryable.rs @@ -1,4 +1,4 @@ -use crate::proxy::{self, JSResultSet, Proxy, Query}; +use crate::proxy::{self, FlavoredJSResultSet, JSResultSet, Proxy, Query}; use async_trait::async_trait; use napi::JsObject; use psl::JsConnectorFlavor; @@ -121,8 +121,7 @@ impl QuaintQueryable for JsQueryable { /// Returns false, if connection is considered to not be in a working state. fn is_healthy(&self) -> bool { - // TODO: use self.driver.is_healthy() - true + self.proxy.is_healthy().unwrap_or(false) } /// Sets the transaction isolation level to given value. @@ -144,8 +143,9 @@ impl JsQueryable { Query { sql, args } } - async fn transform_result_set(result_set: JSResultSet) -> quaint::Result { - Ok(ResultSet::from(result_set)) + async fn transform_result_set(flavor: JsConnectorFlavor, result_set: JSResultSet) -> quaint::Result { + let flavored_js_result_set = FlavoredJSResultSet((flavor, result_set)); + Ok(ResultSet::from(flavored_js_result_set)) } async fn do_query_raw(&self, sql: &str, params: &[Value<'_>]) -> quaint::Result { @@ -159,7 +159,7 @@ impl JsQueryable { let len = result_set.len(); let deserialization_span = info_span!("js:query:result", user_facing = true, "length" = %len); - Self::transform_result_set(result_set) + Self::transform_result_set(self.flavor, result_set) .instrument(deserialization_span) .await }