Repository: prisma/prisma-engines
Pull request #5351: fix: fix raw query test serialization issue
Author: login: jacek-prisma
Created: 2025-04-24T12:14:36Z | Merged: 2025-04-24T15:47:56Z

[ORM-872](https://linear.app/prisma-company/issue/ORM-872/investigate-and-fix-broken-raw-query-outputs)


Diff:
diff --git a/libs/driver-adapters/executor/src/qc-test-worker/worker-query.ts b/libs/driver-adapters/executor/src/qc-test-worker/worker-query.ts
index c2d926d6f7e3..fa02e273f406 100644
--- a/libs/driver-adapters/executor/src/qc-test-worker/worker-query.ts
+++ b/libs/driver-adapters/executor/src/qc-test-worker/worker-query.ts
@@ -1,12 +1,20 @@
 import * as util from 'node:util'
-import { SqlQueryable, IsolationLevel } from '@prisma/driver-adapter-utils'
+import {
+  SqlQueryable,
+  IsolationLevel,
+  ColumnType,
+  ColumnTypeEnum,
+  SqlResultSet,
+} from '@prisma/driver-adapter-utils'
 import { JsonProtocolQuery, QueryParams } from '../types/jsonRpc'
 import type { State } from './worker'
-import { debug } from '../utils'
+import { assertNever, debug } from '../utils'
 import {
   noopTracingHelper,
+  QueryEvent,
   QueryInterpreter,
   type QueryInterpreterTransactionManager,
+  QueryPlanNode,
   type TransactionManager,
   UserFacingError,
 } from '@prisma/client-engine-runtime'
@@ -101,7 +109,7 @@ class QueryPipeline {
       }
     }
 
-    const queryPlan = JSON.parse(queryPlanString)
+    const queryPlan = JSON.parse(queryPlanString) as QueryPlanNode
 
     debug('🟢 Query plan: ', util.inspect(queryPlan, false, null, true))
 
@@ -111,14 +119,22 @@ class QueryPipeline {
         : { enabled: false }
     ) satisfies QueryInterpreterTransactionManager
 
-    const interpreter = new QueryInterpreter({
+    const interpreterOpts = {
       transactionManager: qiTransactionManager,
       placeholderValues: {},
-      onQuery: (event) => {
+      onQuery: (event: QueryEvent) => {
         this.logs.push(JSON.stringify(event))
       },
       tracingHelper: noopTracingHelper,
-    })
+    }
+
+    // workaround needed due to raw SQL tests being written against unserialized results
+    const interpreter = isPlainRawQuery(queryPlan)
+      ? new QueryInterpreter({
+          ...interpreterOpts,
+          serializer: serializeRawQueryResult,
+        })
+      : QueryInterpreter.forSql(interpreterOpts)
 
     return interpreter.run(queryPlan, queryable)
   }
@@ -194,3 +210,98 @@ function getOperationResultInQeFormat(result: unknown) {
     return result
   }
 }
+
+function isPlainRawQuery(plan: QueryPlanNode): boolean {
+  switch (plan.type) {
+    case 'query':
+      return plan.args.type === 'rawSql'
+    case 'seq':
+      return plan.args.length === 1 && isPlainRawQuery(plan.args[0])
+    default:
+      return false
+  }
+}
+
+function serializeRawQueryResult(
+  resultSet: SqlResultSet,
+): Record<string, unknown> {
+  return {
+    columns: resultSet.columnNames,
+    types: resultSet.columnTypes.map((type) => serializeColumnType(type)),
+    rows: resultSet.rows,
+  }
+}
+
+// maps JS column types to their Rust equivalents in order to satisfy assertions in tests
+function serializeColumnType(columnType: ColumnType): string {
+  switch (columnType) {
+    case ColumnTypeEnum.Int32:
+      return 'int'
+    case ColumnTypeEnum.Int64:
+      return 'bigint'
+    case ColumnTypeEnum.Float:
+      return 'float'
+    case ColumnTypeEnum.Double:
+      return 'double'
+    case ColumnTypeEnum.Text:
+      return 'string'
+    case ColumnTypeEnum.Enum:
+      return 'enum'
+    case ColumnTypeEnum.Bytes:
+      return 'bytes'
+    case ColumnTypeEnum.Boolean:
+      return 'bool'
+    case ColumnTypeEnum.Character:
+      return 'char'
+    case ColumnTypeEnum.Numeric:
+      return 'decimal'
+    case ColumnTypeEnum.Json:
+      return 'json'
+    case ColumnTypeEnum.Uuid:
+      return 'uuid'
+    case ColumnTypeEnum.DateTime:
+      return 'datetime'
+    case ColumnTypeEnum.Date:
+      return 'date'
+    case ColumnTypeEnum.Time:
+      return 'time'
+    case ColumnTypeEnum.Int32Array:
+      return 'int-array'
+    case ColumnTypeEnum.Int64Array:
+      return 'bigint-array'
+    case ColumnTypeEnum.FloatArray:
+      return 'float-array'
+    case ColumnTypeEnum.DoubleArray:
+      return 'double-array'
+    case ColumnTypeEnum.TextArray:
+      return 'string-array'
+    case ColumnTypeEnum.EnumArray:
+      return 'string-array'
+    case ColumnTypeEnum.BytesArray:
+      return 'bytes-array'
+    case ColumnTypeEnum.BooleanArray:
+      return 'bool-array'
+    case ColumnTypeEnum.CharacterArray:
+      return 'char-array'
+    case ColumnTypeEnum.NumericArray:
+      return 'decimal-array'
+    case ColumnTypeEnum.JsonArray:
+      return 'json-array'
+    case ColumnTypeEnum.UuidArray:
+      return 'uuid-array'
+    case ColumnTypeEnum.DateTimeArray:
+      return 'datetime-array'
+    case ColumnTypeEnum.DateArray:
+      return 'date-array'
+    case ColumnTypeEnum.TimeArray:
+      return 'time-array'
+    case ColumnTypeEnum.UnknownNumber:
+      return 'unknown'
+    case ColumnTypeEnum.Set:
+      /// The following PlanetScale type IDs are mapped into Set:
+      /// - SET (SET) -> e.g. `"foo,bar"` (String-encoded, comma-separated)
+      return 'string'
+    default:
+      assertNever(columnType, `Unexpected column type: ${columnType}`)
+  }
+}
diff --git a/query-compiler/query-engine-tests-todo/libsql/fail b/query-compiler/query-engine-tests-todo/libsql/fail
index 5805e5e44477..26d7db797d92 100644
--- a/query-compiler/query-engine-tests-todo/libsql/fail
+++ b/query-compiler/query-engine-tests-todo/libsql/fail
@@ -70,7 +70,6 @@ new::regressions::prisma_14696::prisma_14696::create_does_not_panic
 new::regressions::prisma_15204::conversion_error::convert_to_bigint_sqlite_js
 new::regressions::prisma_15204::conversion_error::convert_to_int_sqlite_js
 new::regressions::prisma_21182::regression::query_with_normalized_dependencies
-new::regressions::prisma_21369::prisma_21369::select_null_works
 new::regressions::prisma_22971::prisma_22971::test_22971
 new::regressions::prisma_5952::regression::decimal_find_different_uniques
 new::regressions::prisma_5952::regression::decimal_find_different_uniques_unquoted
diff --git a/query-compiler/query-engine-tests-todo/pg/fail b/query-compiler/query-engine-tests-todo/pg/fail
index 449866f7bdcc..278e43be0bc0 100644
--- a/query-compiler/query-engine-tests-todo/pg/fail
+++ b/query-compiler/query-engine-tests-todo/pg/fail
@@ -77,7 +77,6 @@ new::regressions::prisma_13097::prisma_13097::group_by_enum_array
 new::regressions::prisma_13158::prisma_1358::insert_mixed_int_float_array_in_execute_raw
 new::regressions::prisma_14696::prisma_14696::create_does_not_panic
 new::regressions::prisma_21182::regression::query_with_normalized_dependencies
-new::regressions::prisma_21369::prisma_21369::select_null_works
 new::regressions::prisma_21901::prisma_21901::test
 new::regressions::prisma_22971::prisma_22971::test_22971
 new::regressions::prisma_5952::regression::decimal_find_different_uniques
@@ -372,11 +371,8 @@ raw::sql::casts::casts::query_date_casts
 raw::sql::errors::raw_errors::list_param_for_scalar_column_should_not_panic_pg_js
 raw::sql::input_coercion::input_coercion::decimal_input_correctly_coerced
 raw::sql::input_coercion::input_coercion::scalar_input_correctly_coerced
-raw::sql::scalar_list::scalar_list::empty_scalar_lists
 raw::sql::scalar_list::scalar_list::null_native_type_lists
-raw::sql::scalar_list::scalar_list::null_only_scalar_lists
 raw::sql::scalar_list::scalar_list::null_scalar_lists
-raw::sql::scalar_list::scalar_list::prisma_11339
 raw::sql::typed_output::typed_output::all_scalars_pg
 writes::data_types::bigint::bigint::using_bigint_field
 writes::data_types::bytes::bytes::byte_id_coercion


Reviews:
user: login: viktor-ferenczi
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-24T15:35:11Z
user: login: jacek-prisma
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-24T15:40:37Z
user: login: jacek-prisma
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-24T15:40:59Z
user: login: jacek-prisma
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-24T15:41:15Z
user: login: jacek-prisma
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-24T15:42:18Z

Review comments:
diff_hunk: @@ -111,14 +119,22 @@ class QueryPipeline {
         : { enabled: false }
     ) satisfies QueryInterpreterTransactionManager
 
-    const interpreter = new QueryInterpreter({
+    const interpreterOpts = {
       transactionManager: qiTransactionManager,
       placeholderValues: {},
-      onQuery: (event) => {
+      onQuery: (event: QueryEvent) => {
         this.logs.push(JSON.stringify(event))
       },
       tracingHelper: noopTracingHelper,
-    })
+    }
+
+    // workaround needed due to raw SQL tests being written against unserialized results
+    const interpreter = isPlainRawQuery(queryPlan)
+      ? new QueryInterpreter({
path: libs/driver-adapters/executor/src/qc-test-worker/worker-query.ts
user: login: viktor-ferenczi
body: Minor: It may be nicer to introduce a `QueryInterpreter.forRawSql` constructor for symmetry.
created_at: 2025-04-24T15:29:20Z
author_association: CONTRIBUTOR
diff_hunk: @@ -194,3 +210,98 @@ function getOperationResultInQeFormat(result: unknown) {
     return result
   }
 }
+
+function isPlainRawQuery(plan: QueryPlanNode): boolean {
+  switch (plan.type) {
+    case 'query':
+      return plan.args.type === 'rawSql'
+    case 'seq':
+      return plan.args.length === 1 && isPlainRawQuery(plan.args[0])
+    default:
+      return false
+  }
+}
+
+function serializeRawQueryResult(
path: libs/driver-adapters/executor/src/qc-test-worker/worker-query.ts
user: login: viktor-ferenczi
body: I think the low level serialization should be separated into another file for clarity, maybe together with the Query Interpreter. It would be cleaner especially if the constructor mentioned in my comment above is added.
created_at: 2025-04-24T15:31:25Z
author_association: CONTRIBUTOR
diff_hunk: @@ -194,3 +210,98 @@ function getOperationResultInQeFormat(result: unknown) {
     return result
   }
 }
+
+function isPlainRawQuery(plan: QueryPlanNode): boolean {
+  switch (plan.type) {
+    case 'query':
+      return plan.args.type === 'rawSql'
+    case 'seq':
+      return plan.args.length === 1 && isPlainRawQuery(plan.args[0])
path: libs/driver-adapters/executor/src/qc-test-worker/worker-query.ts
user: login: viktor-ferenczi
body: So we expect only single item sequences of raw queries?
created_at: 2025-04-24T15:31:51Z
author_association: CONTRIBUTOR
diff_hunk: @@ -194,3 +210,98 @@ function getOperationResultInQeFormat(result: unknown) {
     return result
   }
 }
+
+function isPlainRawQuery(plan: QueryPlanNode): boolean {
+  switch (plan.type) {
+    case 'query':
+      return plan.args.type === 'rawSql'
+    case 'seq':
+      return plan.args.length === 1 && isPlainRawQuery(plan.args[0])
+    default:
+      return false
+  }
+}
+
+function serializeRawQueryResult(
+  resultSet: SqlResultSet,
+): Record<string, unknown> {
+  return {
+    columns: resultSet.columnNames,
+    types: resultSet.columnTypes.map((type) => serializeColumnType(type)),
+    rows: resultSet.rows,
+  }
+}
+
+// maps JS column types to their Rust equivalents in order to satisfy assertions in tests
+function serializeColumnType(columnType: ColumnType): string {
+  switch (columnType) {
+    case ColumnTypeEnum.Int32:
+      return 'int'
+    case ColumnTypeEnum.Int64:
+      return 'bigint'
+    case ColumnTypeEnum.Float:
+      return 'float'
+    case ColumnTypeEnum.Double:
+      return 'double'
+    case ColumnTypeEnum.Text:
+      return 'string'
+    case ColumnTypeEnum.Enum:
+      return 'enum'
+    case ColumnTypeEnum.Bytes:
+      return 'bytes'
+    case ColumnTypeEnum.Boolean:
+      return 'bool'
+    case ColumnTypeEnum.Character:
+      return 'char'
+    case ColumnTypeEnum.Numeric:
+      return 'decimal'
+    case ColumnTypeEnum.Json:
+      return 'json'
+    case ColumnTypeEnum.Uuid:
+      return 'uuid'
+    case ColumnTypeEnum.DateTime:
+      return 'datetime'
+    case ColumnTypeEnum.Date:
+      return 'date'
+    case ColumnTypeEnum.Time:
+      return 'time'
+    case ColumnTypeEnum.Int32Array:
+      return 'int-array'
+    case ColumnTypeEnum.Int64Array:
+      return 'bigint-array'
+    case ColumnTypeEnum.FloatArray:
+      return 'float-array'
+    case ColumnTypeEnum.DoubleArray:
+      return 'double-array'
+    case ColumnTypeEnum.TextArray:
+      return 'string-array'
+    case ColumnTypeEnum.EnumArray:
+      return 'string-array'
path: libs/driver-adapters/executor/src/qc-test-worker/worker-query.ts
user: login: viktor-ferenczi
body: Just to be sure: We don't have 'enum-array'?

I don't see one defined in `impl std::fmt::Display for ColumnType`, so that's consistent. 
created_at: 2025-04-24T15:32:52Z
author_association: CONTRIBUTOR
diff_hunk: @@ -111,14 +119,22 @@ class QueryPipeline {
         : { enabled: false }
     ) satisfies QueryInterpreterTransactionManager
 
-    const interpreter = new QueryInterpreter({
+    const interpreterOpts = {
       transactionManager: qiTransactionManager,
       placeholderValues: {},
-      onQuery: (event) => {
+      onQuery: (event: QueryEvent) => {
         this.logs.push(JSON.stringify(event))
       },
       tracingHelper: noopTracingHelper,
-    })
+    }
+
+    // workaround needed due to raw SQL tests being written against unserialized results
+    const interpreter = isPlainRawQuery(queryPlan)
+      ? new QueryInterpreter({
path: libs/driver-adapters/executor/src/qc-test-worker/worker-query.ts
user: login: jacek-prisma
body: this is only needed for query engine tests, I'd rather not change prisma/prisma for it
created_at: 2025-04-24T15:40:36Z
author_association: CONTRIBUTOR
diff_hunk: @@ -194,3 +210,98 @@ function getOperationResultInQeFormat(result: unknown) {
     return result
   }
 }
+
+function isPlainRawQuery(plan: QueryPlanNode): boolean {
+  switch (plan.type) {
+    case 'query':
+      return plan.args.type === 'rawSql'
+    case 'seq':
+      return plan.args.length === 1 && isPlainRawQuery(plan.args[0])
path: libs/driver-adapters/executor/src/qc-test-worker/worker-query.ts
user: login: jacek-prisma
body: yep, that's what I see in the tests
created_at: 2025-04-24T15:40:59Z
author_association: CONTRIBUTOR
diff_hunk: @@ -194,3 +210,98 @@ function getOperationResultInQeFormat(result: unknown) {
     return result
   }
 }
+
+function isPlainRawQuery(plan: QueryPlanNode): boolean {
+  switch (plan.type) {
+    case 'query':
+      return plan.args.type === 'rawSql'
+    case 'seq':
+      return plan.args.length === 1 && isPlainRawQuery(plan.args[0])
+    default:
+      return false
+  }
+}
+
+function serializeRawQueryResult(
path: libs/driver-adapters/executor/src/qc-test-worker/worker-query.ts
user: login: jacek-prisma
body: I don't mind separating it into its own file, note that this is just a test utility though
created_at: 2025-04-24T15:41:15Z
author_association: CONTRIBUTOR
diff_hunk: @@ -194,3 +210,98 @@ function getOperationResultInQeFormat(result: unknown) {
     return result
   }
 }
+
+function isPlainRawQuery(plan: QueryPlanNode): boolean {
+  switch (plan.type) {
+    case 'query':
+      return plan.args.type === 'rawSql'
+    case 'seq':
+      return plan.args.length === 1 && isPlainRawQuery(plan.args[0])
+    default:
+      return false
+  }
+}
+
+function serializeRawQueryResult(
+  resultSet: SqlResultSet,
+): Record<string, unknown> {
+  return {
+    columns: resultSet.columnNames,
+    types: resultSet.columnTypes.map((type) => serializeColumnType(type)),
+    rows: resultSet.rows,
+  }
+}
+
+// maps JS column types to their Rust equivalents in order to satisfy assertions in tests
+function serializeColumnType(columnType: ColumnType): string {
+  switch (columnType) {
+    case ColumnTypeEnum.Int32:
+      return 'int'
+    case ColumnTypeEnum.Int64:
+      return 'bigint'
+    case ColumnTypeEnum.Float:
+      return 'float'
+    case ColumnTypeEnum.Double:
+      return 'double'
+    case ColumnTypeEnum.Text:
+      return 'string'
+    case ColumnTypeEnum.Enum:
+      return 'enum'
+    case ColumnTypeEnum.Bytes:
+      return 'bytes'
+    case ColumnTypeEnum.Boolean:
+      return 'bool'
+    case ColumnTypeEnum.Character:
+      return 'char'
+    case ColumnTypeEnum.Numeric:
+      return 'decimal'
+    case ColumnTypeEnum.Json:
+      return 'json'
+    case ColumnTypeEnum.Uuid:
+      return 'uuid'
+    case ColumnTypeEnum.DateTime:
+      return 'datetime'
+    case ColumnTypeEnum.Date:
+      return 'date'
+    case ColumnTypeEnum.Time:
+      return 'time'
+    case ColumnTypeEnum.Int32Array:
+      return 'int-array'
+    case ColumnTypeEnum.Int64Array:
+      return 'bigint-array'
+    case ColumnTypeEnum.FloatArray:
+      return 'float-array'
+    case ColumnTypeEnum.DoubleArray:
+      return 'double-array'
+    case ColumnTypeEnum.TextArray:
+      return 'string-array'
+    case ColumnTypeEnum.EnumArray:
+      return 'string-array'
path: libs/driver-adapters/executor/src/qc-test-worker/worker-query.ts
user: login: jacek-prisma
body: there's an additional mapping here, enum array is mapped to string array https://github.com/prisma/prisma-engines/blob/2a4af470dcf83a3f6d9d5e739e23380449936435/libs/driver-adapters/src/types.rs#L227
created_at: 2025-04-24T15:42:18Z
author_association: CONTRIBUTOR

PR comments:
user: login: codspeed-hq[bot]
created_at: 2025-04-24T12:21:34Z
body: <!-- __CODSPEED_PERFORMANCE_REPORT_COMMENT__ -->
<!-- __CODSPEED_INSTRUMENTATION_PERFORMANCE_REPORT_COMMENT__ -->

## [CodSpeed Performance Report](https://codspeed.io/prisma/prisma-engines/branches/fix%2Ffix-qc-raw-query-tests)

### Merging #5351 will **not alter performance**

<sub>Comparing <code>fix/fix-qc-raw-query-tests</code> (72dac42) with <code>main</code> (2a4af47)</sub>



### Summary

`✅ 11` untouched benchmarks  




author_association: NONE
user: login: github-actions[bot]
created_at: 2025-04-24T12:24:10Z
body: <!-- wasm-size -->
### WASM Query Engine file Size

|Engine            | This PR                                      | Base branch                                      | Diff
|------------------|----------------------------------------------|--------------------------------------------------|-----------------------------------------------
| Postgres         | 2.185MiB    |  2.185MiB    | 0.000B
| Postgres (gzip)  | 875.240KiB |  875.240KiB | 0.000B
| Mysql            | 2.150MiB         |  2.150MiB          | 0.000B
| Mysql (gzip)     | 860.136KiB      |  860.136KiB       | 0.000B
| Sqlite           | 2.059MiB        |  2.059MiB         | 0.000B
| Sqlite (gzip)    | 824.130KiB     |  824.132KiB     | -2.000B
author_association: CONTRIBUTOR