feat(spanner): support insert returning (#11460)

* Update SpannerDriver.ts

* Update package.json

* Update package.json

* Update package.json

* Update package-lock.json

* Update package.json

* #11453

* Revert "Update package.json"

This reverts commit 20f24de10cda62ad0c9a368b14290fbb4f355b32.

* Revert "Update package.json"

This reverts commit bcf6678e95b57570ea526935bb7490c9b11a16da.

* Update package.json

* Revert "Update package-lock.json"

This reverts commit a003e5659336b38b8cade5f1605c17f7c3e59673.

* #11460

* #11460

* FIX/Spanner Numeric type value string

* #11460 Test functional spanner

* test: update returning tests

* refactor: simplify condition

* style: fix lint/format

* test: fix returning test for spanner

---------

Co-authored-by: Lucian Mocanu <alumni@users.noreply.github.com>
This commit is contained in:
Denes Antonio de Souza 2025-05-13 17:45:53 -03:00 committed by GitHub
parent e9eaf79604
commit 144634d4c0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 221 additions and 260 deletions

View File

@ -134,5 +134,16 @@
"maxRetries": 3
},
"logging": false
},
{
"skip": false,
"name": "spanner",
"type": "spanner",
"host": "localhost",
"projectId": "test-project",
"instanceId": "test-instance",
"databaseId": "test-db",
"port": 9010,
"logging": false
}
]

View File

@ -1,4 +1,4 @@
import { Driver, ReturningType } from "../Driver"
import { Driver } from "../Driver"
import { DriverPackageNotInstalledError } from "../../error/DriverPackageNotInstalledError"
import { SpannerQueryRunner } from "./SpannerQueryRunner"
import { ObjectLiteral } from "../../common/ObjectLiteral"
@ -178,16 +178,6 @@ export class SpannerDriver implements Driver {
enabled: true,
}
/**
* Supported returning types
*/
private readonly _isReturningSqlSupported: Record<ReturningType, boolean> =
{
delete: false,
insert: false,
update: false,
}
// -------------------------------------------------------------------------
// Constructor
// -------------------------------------------------------------------------
@ -352,7 +342,7 @@ export class SpannerDriver implements Driver {
target: EntityMetadata | Table | View | TableForeignKey | string,
): { database?: string; schema?: string; tableName: string } {
const driverDatabase = this.database
const driverSchema = undefined
const driverSchema: any = undefined
if (target instanceof Table || target instanceof View) {
const parsed = this.parseTableName(target.name)
@ -412,7 +402,7 @@ export class SpannerDriver implements Driver {
if (columnMetadata.type === "numeric") {
const lib = this.options.driver || PlatformTools.load("spanner")
return lib.Spanner.numeric(value)
return lib.Spanner.numeric(value.toString())
} else if (columnMetadata.type === "date") {
return DateUtils.mixedDateToDateString(value)
} else if (columnMetadata.type === "json") {
@ -707,15 +697,15 @@ export class SpannerDriver implements Driver {
/**
* Returns true if driver supports RETURNING / OUTPUT statement.
*/
isReturningSqlSupported(returningType: ReturningType): boolean {
return this._isReturningSqlSupported[returningType]
isReturningSqlSupported(): boolean {
return true
}
/**
* Returns true if driver supports uuid values generation on its own.
*/
isUUIDGenerationSupported(): boolean {
return false
return true
}
/**

View File

@ -7,7 +7,7 @@ import { TypeORMError } from "./TypeORMError"
export class ReturningStatementNotSupportedError extends TypeORMError {
constructor() {
super(
`OUTPUT or RETURNING clause only supported by Microsoft SQL Server or PostgreSQL or MariaDB databases.`,
`OUTPUT or RETURNING clause only supported by PostgreSQL, MariaDB, Microsoft SqlServer or Google Spanner.`,
)
}
}

View File

@ -289,6 +289,9 @@ export class DeleteQueryBuilder<Entity extends ObjectLiteral>
if (this.connection.driver.options.type === "mssql") {
return `DELETE FROM ${tableName} OUTPUT ${returningExpression}${whereExpression}`
}
if (this.connection.driver.options.type === "spanner") {
return `DELETE FROM ${tableName}${whereExpression} THEN RETURN ${returningExpression}`
}
return `DELETE FROM ${tableName}${whereExpression} RETURNING ${returningExpression}`
}
}

View File

@ -650,6 +650,13 @@ export class InsertQueryBuilder<
query += ` RETURNING ${returningExpression}`
}
if (
returningExpression &&
this.connection.driver.options.type === "spanner"
) {
query += ` THEN RETURN ${returningExpression}`
}
// Inserting a specific value for an auto-increment primary key in mssql requires enabling IDENTITY_INSERT
// IDENTITY_INSERT can only be enabled for tables where there is an IDENTITY column and only if there is a value to be inserted (i.e. supplying DEFAULT is prohibited if IDENTITY_INSERT is enabled)
if (
@ -865,6 +872,13 @@ export class InsertQueryBuilder<
this.connection.driver.normalizeDefault(
column,
)
} else if (
this.connection.driver.options.type ===
"spanner" &&
column.isGenerated &&
column.generationStrategy === "uuid"
) {
expression += "GENERATE_UUID()" // Produces a random universally unique identifier (UUID) as a STRING value.
} else {
expression += "NULL" // otherwise simply use NULL and pray if column is nullable
}

View File

@ -153,22 +153,31 @@ export class ReturningResultsEntityUpdator {
const generatedMaps = entities.map((entity, entityIndex) => {
if (
this.queryRunner.connection.driver.options.type === "oracle" &&
Array.isArray(insertResult.raw) &&
this.expressionMap.extraReturningColumns.length > 0
) {
insertResult.raw = insertResult.raw.reduce(
(newRaw, rawItem, rawItemIndex) => {
newRaw[
this.expressionMap.extraReturningColumns[
rawItemIndex
].databaseName
] = rawItem[0]
return newRaw
},
{} as ObjectLiteral,
)
if (
this.queryRunner.connection.driver.options.type === "oracle"
) {
insertResult.raw = insertResult.raw.reduce(
(newRaw, rawItem, rawItemIndex) => {
newRaw[
this.expressionMap.extraReturningColumns[
rawItemIndex
].databaseName
] = rawItem[0]
return newRaw
},
{} as ObjectLiteral,
)
} else if (
this.queryRunner.connection.driver.options.type ===
"spanner"
) {
insertResult.raw = insertResult.raw[0]
}
}
// get all values generated by a database for us
const result = Array.isArray(insertResult.raw)
? insertResult.raw[entityIndex]

View File

@ -697,6 +697,14 @@ export class UpdateQueryBuilder<Entity extends ObjectLiteral>
", ",
)} OUTPUT ${returningExpression}${whereExpression}`
}
if (this.connection.driver.options.type === "spanner") {
return `UPDATE ${this.getTableName(
this.getMainTableName(),
)} SET ${updateColumnAndValues.join(
", ",
)}${whereExpression} THEN RETURN ${returningExpression}`
}
return `UPDATE ${this.getTableName(
this.getMainTableName(),
)} SET ${updateColumnAndValues.join(

View File

@ -0,0 +1,10 @@
import { Entity, PrimaryGeneratedColumn, Column } from "../../../../../src"
@Entity()
export class User {
@PrimaryGeneratedColumn()
id: number
@Column()
name: string
}

View File

@ -0,0 +1,147 @@
import { expect } from "chai"
import "reflect-metadata"
import { DataSource } from "../../../../src/data-source/DataSource"
import {
closeTestingConnections,
createTestingConnections,
reloadTestingDatabases,
} from "../../../utils/test-utils"
import { User } from "./entity/User"
describe("query builder > insert/update/delete returning", () => {
let connections: DataSource[]
before(
async () =>
(connections = await createTestingConnections({
entities: [__dirname + "/entity/*{.js,.ts}"],
enabledDrivers: ["mssql", "postgres", "spanner"],
})),
)
beforeEach(() => reloadTestingDatabases(connections))
after(() => closeTestingConnections(connections))
it("should create and perform an INSERT statement, including RETURNING or OUTPUT clause", () =>
Promise.all(
connections.map(async (connection) => {
const user = new User()
user.name = "Tim Merrison"
const qb = connection
.createQueryBuilder()
.insert()
.into(User)
.values(user)
.returning(
connection.driver.options.type === "mssql"
? "inserted.*"
: "*",
)
const sql = qb.getSql()
if (connection.driver.options.type === "mssql") {
expect(sql).to.equal(
`INSERT INTO "user"("name") OUTPUT inserted.* VALUES (@0)`,
)
} else if (connection.driver.options.type === "postgres") {
expect(sql).to.equal(
`INSERT INTO "user"("name") VALUES ($1) RETURNING *`,
)
} else if (connection.driver.options.type === "spanner") {
expect(sql).to.equal(
"INSERT INTO `user`(`id`, `name`) VALUES (NULL, @param0) THEN RETURN *",
)
}
const returning = await qb.execute()
expect(returning.raw).to.deep.equal([
{ id: 1, name: user.name },
])
}),
))
it("should create and perform an UPDATE statement, including RETURNING or OUTPUT clause", () =>
Promise.all(
connections.map(async (connection) => {
const user = new User()
user.name = "Tim Merrison"
await connection.manager.save(user)
const qb = connection
.createQueryBuilder()
.update(User)
.set({ name: "Joe Bloggs" })
.where("name = :name", { name: user.name })
.returning(
connection.driver.options.type === "mssql"
? "inserted.*"
: "*",
)
const sql = qb.getSql()
if (connection.driver.options.type === "mssql") {
expect(sql).to.equal(
`UPDATE "user" SET "name" = @0 OUTPUT inserted.* WHERE "name" = @1`,
)
} else if (connection.driver.options.type === "postgres") {
expect(sql).to.equal(
`UPDATE "user" SET "name" = $1 WHERE "name" = $2 RETURNING *`,
)
} else if (connection.driver.options.type === "spanner") {
expect(sql).to.equal(
"UPDATE `user` SET `name` = @param0 WHERE `name` = @param1 THEN RETURN *",
)
}
const returning = await qb.execute()
expect(returning.raw).to.deep.equal([
{ id: 1, name: "Joe Bloggs" },
])
}),
))
it("should create and perform a DELETE statement, including RETURNING or OUTPUT clause", () =>
Promise.all(
connections.map(async (connection) => {
const user = new User()
user.name = "Tim Merrison"
await connection.manager.save(user)
const qb = connection
.createQueryBuilder()
.delete()
.from(User)
.where("name = :name", { name: user.name })
.returning(
connection.driver.options.type === "mssql"
? "deleted.*"
: "*",
)
const sql = qb.getSql()
if (connection.driver.options.type === "mssql") {
expect(sql).to.equal(
`DELETE FROM "user" OUTPUT deleted.* WHERE "name" = @0`,
)
} else if (connection.driver.options.type === "postgres") {
expect(sql).to.equal(
`DELETE FROM "user" WHERE "name" = $1 RETURNING *`,
)
} else if (connection.driver.options.type === "spanner") {
expect(sql).to.equal(
"DELETE FROM `user` WHERE `name` = @param0 THEN RETURN *",
)
}
const returning = await qb.execute()
expect(returning.raw).to.deep.equal([
{ id: 1, name: user.name },
])
}),
))
})

View File

@ -1,12 +0,0 @@
import { Entity } from "../../../../src/decorator/entity/Entity"
import { PrimaryGeneratedColumn } from "../../../../src/decorator/columns/PrimaryGeneratedColumn"
import { Column } from "../../../../src/decorator/columns/Column"
@Entity()
export class User {
@PrimaryGeneratedColumn()
id: number
@Column()
name: string
}

View File

@ -1,219 +0,0 @@
import "reflect-metadata"
import {
closeTestingConnections,
createTestingConnections,
reloadTestingDatabases,
} from "../../utils/test-utils"
import { DataSource } from "../../../src/data-source/DataSource"
import { User } from "./entity/User"
import { expect } from "chai"
import { ReturningStatementNotSupportedError } from "../../../src/error/ReturningStatementNotSupportedError"
describe("github issues > #660 Specifying a RETURNING or OUTPUT clause with QueryBuilder", () => {
let connections: DataSource[]
before(
async () =>
(connections = await createTestingConnections({
entities: [__dirname + "/entity/*{.js,.ts}"],
})),
)
beforeEach(() => reloadTestingDatabases(connections))
after(() => closeTestingConnections(connections))
it("should create an INSERT statement, including RETURNING or OUTPUT clause (PostgreSQL and MSSQL only)", () =>
Promise.all(
connections.map(async (connection) => {
const user = new User()
user.name = "Tim Merrison"
let sql: string = ""
try {
sql = connection
.createQueryBuilder()
.insert()
.into(User)
.values(user)
.returning(
connection.driver.options.type === "postgres"
? "*"
: "inserted.*",
)
.disableEscaping()
.getSql()
} catch (err) {
expect(err.message).to.eql(
new ReturningStatementNotSupportedError().message,
)
}
if (connection.driver.options.type === "mssql") {
expect(sql).to.equal(
"INSERT INTO user(name) OUTPUT inserted.* VALUES (@0)",
)
} else if (connection.driver.options.type === "postgres") {
expect(sql).to.equal(
"INSERT INTO user(name) VALUES ($1) RETURNING *",
)
}
}),
))
it("should perform insert with RETURNING or OUTPUT clause (PostgreSQL and MSSQL only)", () =>
Promise.all(
connections.map(async (connection) => {
const user = new User()
user.name = "Tim Merrison"
if (
connection.driver.options.type === "mssql" ||
connection.driver.options.type === "postgres"
) {
const returning = await connection
.createQueryBuilder()
.insert()
.into(User)
.values(user)
.returning(
connection.driver.options.type === "postgres"
? "*"
: "inserted.*",
)
.execute()
returning.raw.should.be.eql([{ id: 1, name: user.name }])
}
}),
))
it("should create an UPDATE statement, including RETURNING or OUTPUT clause (PostgreSQL and MSSQL only)", () =>
Promise.all(
connections.map(async (connection) => {
const user = new User()
user.name = "Tim Merrison"
try {
const sql = connection
.createQueryBuilder()
.update(User)
.set({ name: "Joe Bloggs" })
.where("name = :name", { name: user.name })
.returning(
connection.driver.options.type === "postgres"
? "*"
: "inserted.*",
)
.disableEscaping()
.getSql()
if (connection.driver.options.type === "mssql") {
expect(sql).to.equal(
"UPDATE user SET name = @0 OUTPUT inserted.* WHERE name = @1",
)
} else if (connection.driver.options.type === "postgres") {
expect(sql).to.equal(
"UPDATE user SET name = $1 WHERE name = $2 RETURNING *",
)
}
} catch (err) {
expect(err.message).to.eql(
new ReturningStatementNotSupportedError().message,
)
}
}),
))
it("should perform update with RETURNING or OUTPUT clause (PostgreSQL and MSSQL only)", () =>
Promise.all(
connections.map(async (connection) => {
const user = new User()
user.name = "Tim Merrison"
await connection.manager.save(user)
if (
connection.driver.options.type === "mssql" ||
connection.driver.options.type === "postgres"
) {
const returning = await connection
.createQueryBuilder()
.update(User)
.set({ name: "Joe Bloggs" })
.where("name = :name", { name: user.name })
.returning(
connection.driver.options.type === "postgres"
? "*"
: "inserted.*",
)
.execute()
returning.raw.should.be.eql([{ id: 1, name: "Joe Bloggs" }])
}
}),
))
it("should create a DELETE statement, including RETURNING or OUTPUT clause (PostgreSQL and MSSQL only)", () =>
Promise.all(
connections.map(async (connection) => {
try {
const user = new User()
user.name = "Tim Merrison"
const sql = connection
.createQueryBuilder()
.delete()
.from(User)
.where("name = :name", { name: user.name })
.returning(
connection.driver.options.type === "postgres"
? "*"
: "deleted.*",
)
.disableEscaping()
.getSql()
if (connection.driver.options.type === "mssql") {
expect(sql).to.equal(
"DELETE FROM user OUTPUT deleted.* WHERE name = @0",
)
} else if (connection.driver.options.type === "postgres") {
expect(sql).to.equal(
"DELETE FROM user WHERE name = $1 RETURNING *",
)
}
} catch (err) {
expect(err.message).to.eql(
new ReturningStatementNotSupportedError().message,
)
}
}),
))
it("should perform delete with RETURNING or OUTPUT clause (PostgreSQL and MSSQL only)", () =>
Promise.all(
connections.map(async (connection) => {
const user = new User()
user.name = "Tim Merrison"
await connection.manager.save(user)
if (
connection.driver.options.type === "mssql" ||
connection.driver.options.type === "postgres"
) {
const returning = await connection
.createQueryBuilder()
.delete()
.from(User)
.where("name = :name", { name: user.name })
.returning(
connection.driver.options.type === "postgres"
? "*"
: "deleted.*",
)
.execute()
returning.raw.should.be.eql([{ id: 1, name: user.name }])
}
}),
))
})