Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
e71b882
fix(client): preserve BigInt precision in relationJoins queries
polaz Jan 8, 2026
09e2f8f
Merge branch 'main' into fix/bigint-precision-relation-joins
polaz Jan 14, 2026
10c0579
perf(client): add fast-path check to safeJsonParse
polaz Jan 16, 2026
65dbafa
fix(client): prevent regex from matching inside JSON strings
polaz Jan 16, 2026
2860041
fix(client): handle top-level large integer JSON values
polaz Jan 16, 2026
1cfaff0
fix(client): add type assertion in test for TypeScript compatibility
polaz Jan 16, 2026
f589730
test(client-engine-runtime): remove redundant precision loss demonstr…
polaz Jan 20, 2026
1793950
Merge branch 'main' into fix/bigint-precision-relation-joins
polaz Jan 20, 2026
021b1df
Merge branch 'main' into fix/bigint-precision-relation-joins
polaz Jan 21, 2026
8459517
fix(client): remove safeJsonParse workaround, add regression test
polaz Jan 21, 2026
1078097
Merge branch 'main' into fix/bigint-precision-relation-joins
polaz Jan 21, 2026
10a62f9
test(client): drop obsolete utils tests and run bigint test broadly
polaz Jan 28, 2026
c3503c2
Merge branch 'main' into fix/bigint-precision-relation-joins
polaz Jan 29, 2026
8cbf355
Merge branch 'main' into fix/bigint-precision-relation-joins
polaz Jan 29, 2026
9daf4fd
Merge branch 'main' into fix/bigint-precision-relation-joins
aqrln Feb 2, 2026
7c0601a
Merge branch 'main' into fix/bigint-precision-relation-joins
polaz Feb 12, 2026
d824fa7
Merge branch 'main' into fix/bigint-precision-relation-joins
polaz Feb 25, 2026
faa78d5
fix: remove superfluous `as Value` type cast
polaz Feb 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
fix(client): preserve BigInt precision in relationJoins queries
- Add `safeJsonParse()` function that pre-processes JSON strings to quote
  large integers (16+ digits) before parsing, preventing precision loss
- Update `data-mapper.ts`, `in-memory-processing.ts`, and `serialize-sql.ts`
  to use `safeJsonParse()` instead of `JSON.parse()` when parsing JSON
  strings that may contain BigInt values
- Add comprehensive test suite for `safeJsonParse()` in `utils.test.ts`

When using driver adapters with `relationJoins` preview feature, PostgreSQL
returns related data as JSON-aggregated columns via lateral joins. Standard
`JSON.parse()` converts integers exceeding `Number.MAX_SAFE_INTEGER` to
JavaScript Number, silently corrupting the values. For example,
`312590077454712834` becomes `312590077454712800`.

This fix ensures BigInt foreign key values in nested relations are preserved
by converting large integers to strings during JSON parsing. The existing
data mapper then converts these strings to the correct BigInt type.
  • Loading branch information
polaz committed Jan 10, 2026
commit e71b882845f49028598e65f10a2124aca53d1936
4 changes: 2 additions & 2 deletions packages/client-engine-runtime/src/interpreter/data-mapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Decimal } from '@prisma/client-runtime-utils'

import { FieldScalarType, FieldType, ResultNode } from '../query-plan'
import { UserFacingError } from '../user-facing-error'
import { assertNever, safeJsonStringify } from '../utils'
import { assertNever, safeJsonParse, safeJsonStringify } from '../utils'
import { PrismaObject, Value } from './scope'

export class DataMapperError extends UserFacingError {
Expand Down Expand Up @@ -68,7 +68,7 @@ function mapArrayOrObject(
if (typeof data === 'string') {
let decodedData: Value
try {
decodedData = JSON.parse(data)
decodedData = safeJsonParse(data) as Value
} catch (error) {
throw new DataMapperError(`Expected an array or object, got a string that is not valid JSON`, {
cause: error,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import { InMemoryOps, Pagination } from '../query-plan'
import { doKeysMatch } from '../utils'
import { doKeysMatch, safeJsonParse } from '../utils'

export function processRecords(value: unknown, ops: InMemoryOps): unknown {
if (value == null) {
return value
}

if (typeof value === 'string') {
return processRecords(JSON.parse(value), ops)
return processRecords(safeJsonParse(value), ops)
}

if (Array.isArray(value)) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { type ColumnType, ColumnTypeEnum, type SqlResultSet } from '@prisma/driver-adapter-utils'

import { assertNever } from '../utils'
import { assertNever, safeJsonParse } from '../utils'

export function serializeSql(resultSet: SqlResultSet): Record<string, unknown>[] {
return resultSet.rows.map((row) =>
Expand Down Expand Up @@ -62,7 +62,7 @@ function serializeRawValue(value: unknown, type: ColumnType): unknown {
case ColumnTypeEnum.Json:
switch (typeof value) {
case 'string':
return JSON.parse(value)
return safeJsonParse(value)
default:
throw new Error(`Cannot serialize value of type ${typeof value} as Json`)
}
Expand Down
110 changes: 110 additions & 0 deletions packages/client-engine-runtime/src/utils.test.ts
Comment thread
polaz marked this conversation as resolved.
Outdated
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { safeJsonParse, safeJsonStringify } from './utils'

describe('safeJsonParse', () => {
test('parses regular JSON normally', () => {
expect(safeJsonParse('{"name":"Alice","age":30}')).toEqual({ name: 'Alice', age: 30 })
})

test('parses arrays', () => {
expect(safeJsonParse('[1,2,3]')).toEqual([1, 2, 3])
})

test('parses nested objects', () => {
expect(safeJsonParse('{"user":{"id":123,"name":"Bob"}}')).toEqual({
user: { id: 123, name: 'Bob' },
})
})

test('preserves 15-digit integers as numbers (safe)', () => {
// 15-digit integers are always safe
expect(safeJsonParse('{"id":123456789012345}')).toEqual({ id: 123456789012345 })
})

test('converts 16+ digit integers to strings (may exceed MAX_SAFE_INTEGER)', () => {
// 16-digit integers may exceed MAX_SAFE_INTEGER, so convert to string
// This includes MAX_SAFE_INTEGER (9007199254740991) which has 16 digits
const result = safeJsonParse('{"id":9007199254740991}')
expect(result).toEqual({ id: '9007199254740991' })
})
Comment thread
polaz marked this conversation as resolved.
Outdated

test('converts large integers (18 digits) to strings to preserve precision', () => {
// 312590077454712834 is a typical BigInt ID that exceeds MAX_SAFE_INTEGER
// Without safeJsonParse, JSON.parse would lose precision
const result = safeJsonParse('{"userId":312590077454712834}')
expect(result).toEqual({ userId: '312590077454712834' })
// The string value preserves exact precision
expect((result as { userId: string }).userId).toBe('312590077454712834')
})

test('handles multiple large integers in the same object', () => {
const result = safeJsonParse('{"userId":312590077454712834,"fileId":912590077454712834}')
expect(result).toEqual({
userId: '312590077454712834',
fileId: '912590077454712834',
})
})

test('handles large integers in arrays', () => {
const result = safeJsonParse('[312590077454712834,412590077454712834]')
expect(result).toEqual(['312590077454712834', '412590077454712834'])
})

test('handles negative large integers', () => {
const result = safeJsonParse('{"id":-312590077454712834}')
expect(result).toEqual({ id: '-312590077454712834' })
})

test('handles large integers in nested structures', () => {
const result = safeJsonParse('{"user":{"id":312590077454712834,"files":[{"fileId":412590077454712834}]}}')
expect(result).toEqual({
user: {
id: '312590077454712834',
files: [{ fileId: '412590077454712834' }],
},
})
})

test('handles mixed arrays with small and large integers', () => {
const result = safeJsonParse('[1, 312590077454712834, 3]')
expect(result).toEqual([1, '312590077454712834', 3])
})

test('demonstrates the precision loss problem it solves', () => {
const originalId = '312590077454712834'
const json = `{"userId":${originalId}}`

// Standard JSON.parse would lose precision
const unsafeParsed = JSON.parse(json)
expect(unsafeParsed.userId).not.toBe(312590077454712834n) // Not BigInt, it's a Number
// The exact corruption value varies by JS engine, just verify it's corrupted
expect(unsafeParsed.userId.toString()).not.toBe('312590077454712834')

// safeJsonParse preserves the value as string
const safeParsed = safeJsonParse(json)
expect((safeParsed as { userId: string }).userId).toBe('312590077454712834') // Exact value preserved
})
})
Comment thread
polaz marked this conversation as resolved.
Outdated

describe('safeJsonStringify', () => {
test('serializes BigInt values as strings', () => {
const obj = { id: BigInt('312590077454712834') }
expect(safeJsonStringify(obj)).toBe('{"id":"312590077454712834"}')
})

test('serializes Uint8Array as base64', () => {
const obj = { data: new Uint8Array([1, 2, 3, 4]) }
expect(safeJsonStringify(obj)).toBe('{"data":"AQIDBA=="}')
})

test('handles nested BigInt values', () => {
const obj = {
user: {
id: BigInt('312590077454712834'),
files: [{ fileId: BigInt('412590077454712834') }],
},
}
const result = JSON.parse(safeJsonStringify(obj))
Comment thread
polaz marked this conversation as resolved.
Outdated
expect(result.user.id).toBe('312590077454712834')
expect(result.user.files[0].fileId).toBe('412590077454712834')
})
})
41 changes: 41 additions & 0 deletions packages/client-engine-runtime/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,3 +126,44 @@ export function safeJsonStringify(obj: unknown): string {
return val
})
}

/**
* Regular expression to match JSON integer values that may lose precision when
* parsed as JavaScript Number. Matches integers with more than 15 significant digits.
Comment thread
polaz marked this conversation as resolved.
Outdated
*
* The pattern handles integers in both contexts:
* - Object values: `"key": 12345678901234567,`
* - Array values: `[12345678901234567,` or `, 12345678901234567,`
*
* Uses lookahead (?=...) to avoid consuming the suffix character, allowing
* consecutive matches in arrays like `[123..., 456...]`.
*
* Capturing groups:
* - Group 1: The prefix (`:`, `[`, or `,` with optional whitespace)
* - Group 2: The integer value (16+ digits, optionally negative)
* - Lookahead asserts suffix (`,`, `}`, or `]`) without consuming it
*/
const LARGE_INT_PATTERN = /(:\s*|[,\[]\s*)(-?\d{16,})(?=\s*[,}\]])/g
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

/**
* `JSON.parse` wrapper that preserves precision for large integer values.
*
* JavaScript's Number type can only safely represent integers up to
* Number.MAX_SAFE_INTEGER (2^53 - 1 = 9007199254740991). Larger integers
* lose precision when parsed as Number.
*
* This function pre-processes the JSON string to convert large integers to
* strings before parsing, preserving their precision. The data mapper will
* later convert these string values to BigInt as needed.
*
* This is particularly important for relationJoins queries where BigInt foreign
* key values are embedded in JSON-aggregated relation data from PostgreSQL.
*/
export function safeJsonParse(json: string): unknown {
// Replace large integers with quoted strings to preserve precision.
// Numbers with 16+ digits may exceed MAX_SAFE_INTEGER and lose precision.
const safeJson = json.replace(LARGE_INT_PATTERN, (_match, prefix, num) => {
return `${prefix}"${num}"`
})
return JSON.parse(safeJson)
}
Loading