fix: JSON parsing for mysql2 client library (#8319) (#11659)

This commit is contained in:
David Höck 2025-09-19 10:34:17 +02:00 committed by GitHub
parent a49f612289
commit 974ead202d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 277 additions and 1 deletions

View File

@ -57,6 +57,11 @@ export class MysqlDriver implements Driver {
*/
poolCluster: any
/**
* The actual connector package that was loaded ("mysql" or "mysql2").
*/
private loadedConnectorPackage: "mysql" | "mysql2" | undefined
// -------------------------------------------------------------------------
// Public Implemented Properties
// -------------------------------------------------------------------------
@ -584,6 +589,14 @@ export class MysqlDriver implements Driver {
}
}
/**
* Checks if the driver is using mysql2 package.
*/
protected isUsingMysql2(): boolean {
// Check which package was actually loaded during initialization
return this.loadedConnectorPackage === "mysql2"
}
/**
* Prepares given value to a value to be persisted, based on its column type and metadata.
*/
@ -655,7 +668,28 @@ export class MysqlDriver implements Driver {
} else if (columnMetadata.type === "date") {
value = DateUtils.mixedDateToDateString(value)
} else if (columnMetadata.type === "json") {
value = typeof value === "string" ? JSON.parse(value) : value
// mysql2 returns JSON values already parsed, but may still be a string
// if the JSON value itself is a string (e.g., "\"hello\"")
// mysql (classic) always returns JSON as strings that need parsing
if (this.isUsingMysql2()) {
// With mysql2, only parse if it's a valid JSON string representation
// but not if it's already an object or a JSON primitive
if (typeof value === "string") {
try {
// Try to parse it - if it fails, it's already a parsed string value
const parsed = JSON.parse(value)
value = parsed
} catch {
// It's a string that's not valid JSON, which means mysql2
// already parsed it and it's just a string value
// Keep value as is
}
}
// If it's not a string, mysql2 has already parsed it correctly
} else {
// Classic mysql always returns JSON as strings
value = typeof value === "string" ? JSON.parse(value) : value
}
} else if (columnMetadata.type === "time") {
value = DateUtils.mixedTimeToString(value)
} else if (columnMetadata.type === "simple-array") {
@ -1144,6 +1178,15 @@ export class MysqlDriver implements Driver {
* Loads all driver dependencies.
*/
protected loadDependencies(): void {
// Warn if driver is provided directly but connectorPackage is not specified
if (this.options.driver && !this.options.connectorPackage) {
console.warn(
"Warning: MySQL driver instance provided directly without specifying connectorPackage. " +
"This may lead to unexpected JSON parsing behavior differences between mysql and mysql2. " +
"Consider explicitly setting connectorPackage: 'mysql' or 'mysql2' in your configuration.",
)
}
const connectorPackage = this.options.connectorPackage ?? "mysql"
const fallbackConnectorPackage =
connectorPackage === "mysql"
@ -1166,9 +1209,27 @@ export class MysqlDriver implements Driver {
`'${connectorPackage}' was found but it is empty. Falling back to '${fallbackConnectorPackage}'.`,
)
}
// Successfully loaded the requested package
// If driver was provided directly, try to detect which package it is
if (this.options.driver && !this.options.connectorPackage) {
// Try to detect if it's mysql2 based on unique properties
if (
this.mysql.version ||
(this.mysql.Connection &&
this.mysql.Connection.prototype.execute)
) {
this.loadedConnectorPackage = "mysql2"
} else {
this.loadedConnectorPackage = "mysql"
}
} else {
this.loadedConnectorPackage = connectorPackage
}
} catch (e) {
try {
this.mysql = PlatformTools.load(fallbackConnectorPackage) // try to load second supported package
// Successfully loaded the fallback package
this.loadedConnectorPackage = fallbackConnectorPackage
} catch (e) {
throw new DriverPackageNotInstalledError(
"Mysql",

View File

@ -0,0 +1,28 @@
import { Entity, PrimaryGeneratedColumn, Column } from "../../../../../src"
@Entity()
export class JsonEntity {
@PrimaryGeneratedColumn()
id: number
@Column({ type: "json", nullable: true })
jsonObject: any
@Column({ type: "json", nullable: true })
jsonArray: any[]
@Column({ type: "json", nullable: true })
jsonString: string
@Column({ type: "json", nullable: true })
jsonNumber: number
@Column({ type: "json", nullable: true })
jsonBoolean: boolean
@Column({ type: "json", nullable: true })
jsonNull: null
@Column({ type: "json", nullable: true })
complexJson: any
}

View File

@ -0,0 +1,187 @@
import { expect } from "chai"
import { DataSource } from "../../../../src"
import "../../../utils/test-setup"
import {
closeTestingConnections,
createTestingConnections,
reloadTestingDatabases,
} from "../../../utils/test-utils"
import { JsonEntity } from "./entity/JsonEntity"
describe("mysql json parsing", () => {
let connections: DataSource[]
before(
async () =>
(connections = await createTestingConnections({
entities: [JsonEntity],
enabledDrivers: ["mysql", "mariadb"],
})),
)
beforeEach(() => reloadTestingDatabases(connections))
after(() => closeTestingConnections(connections))
it("should correctly parse JSON objects", () =>
Promise.all(
connections.map(async (connection) => {
const repo = connection.getRepository(JsonEntity)
const entity = new JsonEntity()
entity.jsonObject = { foo: "bar", nested: { value: 123 } }
const saved = await repo.save(entity)
const loaded = await repo.findOneBy({ id: saved.id })
expect(loaded).to.be.not.undefined
expect(loaded!.jsonObject).to.deep.equal({
foo: "bar",
nested: { value: 123 },
})
}),
))
it("should correctly parse JSON arrays", () =>
Promise.all(
connections.map(async (connection) => {
const repo = connection.getRepository(JsonEntity)
const entity = new JsonEntity()
entity.jsonArray = [1, "two", { three: 3 }, null, true]
const saved = await repo.save(entity)
const loaded = await repo.findOneBy({ id: saved.id })
expect(loaded).to.be.not.undefined
expect(loaded!.jsonArray).to.deep.equal([
1,
"two",
{ three: 3 },
null,
true,
])
}),
))
it("should correctly handle JSON string primitives", () =>
Promise.all(
connections.map(async (connection) => {
const repo = connection.getRepository(JsonEntity)
const entity = new JsonEntity()
entity.jsonString = "hello world"
const saved = await repo.save(entity)
const loaded = await repo.findOneBy({ id: saved.id })
expect(loaded).to.be.not.undefined
expect(loaded!.jsonString).to.be.a("string")
expect(loaded!.jsonString).to.equal("hello world")
}),
))
it("should correctly handle JSON number primitives", () =>
Promise.all(
connections.map(async (connection) => {
const repo = connection.getRepository(JsonEntity)
const entity = new JsonEntity()
entity.jsonNumber = 42.5
const saved = await repo.save(entity)
const loaded = await repo.findOneBy({ id: saved.id })
expect(loaded).to.be.not.undefined
expect(loaded!.jsonNumber).to.be.a("number")
expect(loaded!.jsonNumber).to.equal(42.5)
}),
))
it("should correctly handle JSON boolean primitives", () =>
Promise.all(
connections.map(async (connection) => {
const repo = connection.getRepository(JsonEntity)
const entity = new JsonEntity()
entity.jsonBoolean = true
const saved = await repo.save(entity)
const loaded = await repo.findOneBy({ id: saved.id })
expect(loaded).to.be.not.undefined
expect(loaded!.jsonBoolean).to.be.a("boolean")
expect(loaded!.jsonBoolean).to.equal(true)
}),
))
it("should correctly handle JSON null", () =>
Promise.all(
connections.map(async (connection) => {
const repo = connection.getRepository(JsonEntity)
const entity = new JsonEntity()
entity.jsonNull = null
const saved = await repo.save(entity)
const loaded = await repo.findOneBy({ id: saved.id })
expect(loaded).to.be.not.undefined
expect(loaded!.jsonNull).to.be.null
}),
))
it("should handle complex nested JSON structures", () =>
Promise.all(
connections.map(async (connection) => {
const repo = connection.getRepository(JsonEntity)
const entity = new JsonEntity()
entity.complexJson = {
users: [
{ id: 1, name: "Alice", active: true },
{ id: 2, name: "Bob", active: false },
],
settings: {
theme: "dark",
notifications: {
email: true,
push: false,
},
},
metadata: null,
count: 42,
}
const saved = await repo.save(entity)
const loaded = await repo.findOneBy({ id: saved.id })
expect(loaded).to.be.not.undefined
expect(loaded!.complexJson).to.deep.equal(entity.complexJson)
}),
))
it("should handle edge case of JSON strings containing quotes", () =>
Promise.all(
connections.map(async (connection) => {
const repo = connection.getRepository(JsonEntity)
const entity = new JsonEntity()
entity.jsonString = 'string with "quotes" inside'
const saved = await repo.save(entity)
const loaded = await repo.findOneBy({ id: saved.id })
expect(loaded).to.be.not.undefined
expect(loaded!.jsonString).to.be.a("string")
expect(loaded!.jsonString).to.equal(
'string with "quotes" inside',
)
}),
))
it("should handle edge case of empty strings", () =>
Promise.all(
connections.map(async (connection) => {
const repo = connection.getRepository(JsonEntity)
const entity = new JsonEntity()
entity.jsonString = ""
const saved = await repo.save(entity)
const loaded = await repo.findOneBy({ id: saved.id })
expect(loaded).to.be.not.undefined
expect(loaded!.jsonString).to.be.a("string")
expect(loaded!.jsonString).to.equal("")
}),
))
})