diff --git a/src/dialect/mysql/mysql-driver.ts b/src/dialect/mysql/mysql-driver.ts index 2ddfac56f..dda0bb4be 100644 --- a/src/dialect/mysql/mysql-driver.ts +++ b/src/dialect/mysql/mysql-driver.ts @@ -121,6 +121,11 @@ class MysqlConnection implements DatabaseConnection { if (isOkPacket(result)) { const { insertId, affectedRows } = result + const numAffectedRows = + affectedRows !== undefined && affectedRows !== null + ? BigInt(affectedRows) + : undefined + return { insertId: insertId !== undefined && @@ -128,10 +133,9 @@ class MysqlConnection implements DatabaseConnection { insertId.toString() !== '0' ? BigInt(insertId) : undefined, - numUpdatedOrDeletedRows: - affectedRows !== undefined && insertId !== null - ? BigInt(affectedRows) - : undefined, + // TODO: remove. + numUpdatedOrDeletedRows: numAffectedRows, + numAffectedRows, rows: [], } } else if (Array.isArray(result)) { diff --git a/src/dialect/postgres/postgres-driver.ts b/src/dialect/postgres/postgres-driver.ts index a3cc3ab98..ae070d1f6 100644 --- a/src/dialect/postgres/postgres-driver.ts +++ b/src/dialect/postgres/postgres-driver.ts @@ -106,9 +106,17 @@ class PostgresConnection implements DatabaseConnection { ...compiledQuery.parameters, ]) - if (result.command === 'UPDATE' || result.command === 'DELETE') { + if ( + result.command === 'INSERT' || + result.command === 'UPDATE' || + result.command === 'DELETE' + ) { + const numAffectedRows = BigInt(result.rowCount) + return { - numUpdatedOrDeletedRows: BigInt(result.rowCount), + // TODO: remove. + numUpdatedOrDeletedRows: numAffectedRows, + numAffectedRows, rows: result.rows ?? [], } } diff --git a/src/dialect/sqlite/sqlite-driver.ts b/src/dialect/sqlite/sqlite-driver.ts index 9db4092e9..510848a1c 100644 --- a/src/dialect/sqlite/sqlite-driver.ts +++ b/src/dialect/sqlite/sqlite-driver.ts @@ -76,11 +76,13 @@ class SqliteConnection implements DatabaseConnection { } else { const { changes, lastInsertRowid } = stmt.run(parameters) + const numAffectedRows = + changes !== undefined && changes !== null ? BigInt(changes) : undefined + return Promise.resolve({ - numUpdatedOrDeletedRows: - changes !== undefined && changes !== null - ? BigInt(changes) - : undefined, + // TODO: remove. + numUpdatedOrDeletedRows: numAffectedRows, + numAffectedRows, insertId: lastInsertRowid !== undefined && lastInsertRowid !== null ? BigInt(lastInsertRowid) diff --git a/src/driver/database-connection.ts b/src/driver/database-connection.ts index b7cad17d7..17a1c6646 100644 --- a/src/driver/database-connection.ts +++ b/src/driver/database-connection.ts @@ -15,11 +15,17 @@ export interface DatabaseConnection { export interface QueryResult { /** - * This is defined for update and delete queries and contains - * the number of rows the query updated/deleted. + * @deprecated use {@link QueryResult.numAffectedRows} instead. */ + // TODO: remove. readonly numUpdatedOrDeletedRows?: bigint + /** + * This is defined for insert, update and delete queries and contains + * the number of rows the query inserted/updated/deleted. + */ + readonly numAffectedRows?: bigint + /** * This is defined for insert queries on dialects that return * the auto incrementing primary key from an insert. diff --git a/src/index.ts b/src/index.ts index 9d00dd06f..c45d6fdbe 100644 --- a/src/index.ts +++ b/src/index.ts @@ -190,6 +190,7 @@ export { UnknownRow, } from './util/type-utils.js' export * from './util/infer-result.js' +export { logOnce } from './util/log-once.js' export { SelectExpression, diff --git a/src/query-builder/delete-query-builder.ts b/src/query-builder/delete-query-builder.ts index 59a427d79..6f6b9e93e 100644 --- a/src/query-builder/delete-query-builder.ts +++ b/src/query-builder/delete-query-builder.ts @@ -608,9 +608,14 @@ export class DeleteQueryBuilder if (this.#props.executor.adapter.supportsReturning && query.returning) { return result.rows - } else { - return [new DeleteResult(result.numUpdatedOrDeletedRows!) as unknown as O] } + + return [ + new DeleteResult( + // TODO: remove numUpdatedOrDeletedRows. + (result.numAffectedRows ?? result.numUpdatedOrDeletedRows)! + ) as any, + ] } /** diff --git a/src/query-builder/insert-query-builder.ts b/src/query-builder/insert-query-builder.ts index 41aa6b6bc..92c4756cb 100644 --- a/src/query-builder/insert-query-builder.ts +++ b/src/query-builder/insert-query-builder.ts @@ -652,9 +652,15 @@ export class InsertQueryBuilder if (this.#props.executor.adapter.supportsReturning && query.returning) { return result.rows - } else { - return [new InsertResult(result.insertId) as unknown as O] } + + return [ + new InsertResult( + result.insertId, + // TODO: remove numUpdatedOrDeletedRows. + result.numAffectedRows ?? result.numUpdatedOrDeletedRows + ) as any, + ] } /** diff --git a/src/query-builder/insert-result.ts b/src/query-builder/insert-result.ts index e03d6c9cd..63fd31801 100644 --- a/src/query-builder/insert-result.ts +++ b/src/query-builder/insert-result.ts @@ -7,6 +7,9 @@ * need to use {@link ReturningInterface.returning} or {@link ReturningInterface.returningAll} * to get out the inserted id. * + * {@link numInsertedOrUpdatedRows} holds the number of (actually) inserted rows. + * On MySQL, updated rows are counted twice when using `on duplicate key update`. + * * ### Examples * * ```ts @@ -20,9 +23,14 @@ */ export class InsertResult { readonly #insertId: bigint | undefined + readonly #numInsertedOrUpdatedRows: bigint | undefined - constructor(insertId: bigint | undefined) { + constructor( + insertId: bigint | undefined, + numInsertedOrUpdatedRows: bigint | undefined + ) { this.#insertId = insertId + this.#numInsertedOrUpdatedRows = numInsertedOrUpdatedRows } /** @@ -31,4 +39,11 @@ export class InsertResult { get insertId(): bigint | undefined { return this.#insertId } + + /** + * Affected rows count. + */ + get numInsertedOrUpdatedRows(): bigint | undefined { + return this.#numInsertedOrUpdatedRows + } } diff --git a/src/query-builder/update-query-builder.ts b/src/query-builder/update-query-builder.ts index 751f1131d..17909c182 100644 --- a/src/query-builder/update-query-builder.ts +++ b/src/query-builder/update-query-builder.ts @@ -704,9 +704,14 @@ export class UpdateQueryBuilder if (this.#props.executor.adapter.supportsReturning && query.returning) { return result.rows - } else { - return [new UpdateResult(result.numUpdatedOrDeletedRows!) as unknown as O] } + + return [ + new UpdateResult( + // TODO: remove numUpdatedOrDeletedRows. + (result.numAffectedRows ?? result.numUpdatedOrDeletedRows)! + ) as any, + ] } /** diff --git a/src/query-executor/query-executor-base.ts b/src/query-executor/query-executor-base.ts index 2fd76642a..e51fed3a9 100644 --- a/src/query-executor/query-executor-base.ts +++ b/src/query-executor/query-executor-base.ts @@ -11,6 +11,7 @@ import { QueryId } from '../util/query-id.js' import { DialectAdapter } from '../dialect/dialect-adapter.js' import { QueryExecutor } from './query-executor.js' import { Deferred } from '../util/deferred.js' +import { logOnce } from '../util/log-once.js' const NO_PLUGINS: ReadonlyArray = freeze([]) @@ -65,7 +66,13 @@ export abstract class QueryExecutorBase implements QueryExecutor { ): Promise> { return await this.provideConnection(async (connection) => { const result = await connection.executeQuery(compiledQuery) - return this.#transformResult(result, queryId) + + const transformedResult = await this.#transformResult(result, queryId) + + // TODO: remove. + warnOfOutdatedDriverOrPlugins(result, transformedResult) + + return transformedResult as any }) } @@ -118,3 +125,24 @@ export abstract class QueryExecutorBase implements QueryExecutor { return result } } + +// TODO: remove. +function warnOfOutdatedDriverOrPlugins( + result: QueryResult, + transformedResult: QueryResult +): void { + const { numAffectedRows } = result + + if ( + (numAffectedRows === undefined && + result.numUpdatedOrDeletedRows === undefined) || + (numAffectedRows !== undefined && + transformedResult.numAffectedRows !== undefined) + ) { + return + } + + logOnce( + 'kysely:warning: outdated driver/plugin detected! QueryResult.numUpdatedOrDeletedRows is deprecated and will be removed in a future release.' + ) +} diff --git a/src/util/log-once.ts b/src/util/log-once.ts new file mode 100644 index 000000000..9c85ef3f2 --- /dev/null +++ b/src/util/log-once.ts @@ -0,0 +1,14 @@ +const LOGGED_MESSAGES: Set = new Set() + +/** + * Use for system-level logging, such as deprecation messages. + * Logs a message and ensures it won't be logged again. + */ +export function logOnce(message: string): void { + if (LOGGED_MESSAGES.has(message)) { + return + } + + LOGGED_MESSAGES.add(message) + console.log(message) +} diff --git a/test/node/src/delete.test.ts b/test/node/src/delete.test.ts index 1b7b53fb0..3e59293e0 100644 --- a/test/node/src/delete.test.ts +++ b/test/node/src/delete.test.ts @@ -104,7 +104,10 @@ for (const dialect of BUILT_IN_DIALECTS) { sqlite: NOT_SUPPORTED, }) - await query.execute() + const result = await query.executeTakeFirst() + + expect(result).to.be.instanceOf(DeleteResult) + expect(result.numDeletedRows).to.equal(2n) }) } diff --git a/test/node/src/insert.test.ts b/test/node/src/insert.test.ts index 474949a45..90e3db62a 100644 --- a/test/node/src/insert.test.ts +++ b/test/node/src/insert.test.ts @@ -58,9 +58,10 @@ for (const dialect of BUILT_IN_DIALECTS) { const result = await query.executeTakeFirst() expect(result).to.be.instanceOf(InsertResult) + expect(result.numInsertedOrUpdatedRows).to.equal(1n) if (dialect === 'postgres') { - expect(result.insertId).to.equal(undefined) + expect(result.insertId).to.be.undefined } else { expect(result.insertId).to.be.a('bigint') } @@ -100,6 +101,7 @@ for (const dialect of BUILT_IN_DIALECTS) { const result = await query.executeTakeFirst() expect(result).to.be.instanceOf(InsertResult) + expect(result.numInsertedOrUpdatedRows).to.equal(1n) expect(await getNewestPerson(ctx.db)).to.eql({ first_name: 'Hammo', @@ -130,7 +132,15 @@ for (const dialect of BUILT_IN_DIALECTS) { }, }) - await query.execute() + const result = await query.executeTakeFirst() + expect(result).to.be.instanceOf(InsertResult) + + const { pet_count } = await ctx.db + .selectFrom('pet') + .select(sql`count(*)`.as('pet_count')) + .executeTakeFirstOrThrow() + + expect(result.numInsertedOrUpdatedRows).to.equal(BigInt(pet_count)) const persons = await ctx.db .selectFrom('person') @@ -177,8 +187,13 @@ for (const dialect of BUILT_IN_DIALECTS) { sqlite: NOT_SUPPORTED, }) - const res = await query.execute() - expect(res).to.have.length(2) + const result = await query.execute() + + expect(result).to.have.length(2) + expect(result).to.deep.equal([ + { first_name: '1', gender: 'foo' }, + { first_name: '2', gender: 'bar' }, + ]) }) } @@ -205,7 +220,16 @@ for (const dialect of BUILT_IN_DIALECTS) { }, }) - await query.execute() + const result = await query.executeTakeFirst() + + expect(result).to.be.instanceOf(InsertResult) + expect(result.numInsertedOrUpdatedRows).to.equal(1n) + + if (dialect === 'postgres') { + expect(result.insertId).to.be.undefined + } else { + expect(result.insertId).to.be.a('bigint') + } }) if (dialect === 'mysql') { @@ -234,7 +258,8 @@ for (const dialect of BUILT_IN_DIALECTS) { const result = await query.executeTakeFirst() expect(result).to.be.instanceOf(InsertResult) - expect(result.insertId).to.equal(undefined) + expect(result.insertId).to.be.undefined + expect(result.numInsertedOrUpdatedRows).to.equal(0n) }) } @@ -272,13 +297,15 @@ for (const dialect of BUILT_IN_DIALECTS) { }) const result = await query.executeTakeFirst() + expect(result).to.be.instanceOf(InsertResult) + expect(result.numInsertedOrUpdatedRows).to.equal(0n) if (dialect === 'sqlite') { // SQLite seems to return the last inserted id even if nothing got inserted. expect(result.insertId! > 0n).to.be.equal(true) } else { - expect(result.insertId).to.equal(undefined) + expect(result.insertId).to.be.undefined } }) } @@ -310,8 +337,10 @@ for (const dialect of BUILT_IN_DIALECTS) { }) const result = await query.executeTakeFirst() + expect(result).to.be.instanceOf(InsertResult) - expect(result.insertId).to.equal(undefined) + expect(result.insertId).to.be.undefined + expect(result.numInsertedOrUpdatedRows).to.equal(0n) }) } @@ -342,7 +371,11 @@ for (const dialect of BUILT_IN_DIALECTS) { sqlite: NOT_SUPPORTED, }) - await query.execute() + const result = await query.executeTakeFirst() + + expect(result).to.be.instanceOf(InsertResult) + expect(result.insertId).to.equal(BigInt(id)) + expect(result.numInsertedOrUpdatedRows).to.equal(2n) const updatedPet = await ctx.db .selectFrom('pet') @@ -394,7 +427,16 @@ for (const dialect of BUILT_IN_DIALECTS) { }, }) - await query.execute() + const result = await query.executeTakeFirst() + + expect(result).to.be.instanceOf(InsertResult) + expect(result.numInsertedOrUpdatedRows).to.equal(1n) + + if (dialect === 'postgres') { + expect(result.insertId).to.be.undefined + } else { + expect(result.insertId).to.be.a('bigint') + } const updatedPet = await ctx.db .selectFrom('pet') @@ -485,12 +527,71 @@ for (const dialect of BUILT_IN_DIALECTS) { sqlite: NOT_SUPPORTED, }) - await query.execute() + const result = await query.execute() + + expect(result).to.have.length(1) + expect(result[0]).to.containSubset({ + species: 'hamster', + name: 'Catto', + }) }) } + it('should insert multiple rows', async () => { + const query = ctx.db.insertInto('person').values([ + { + first_name: 'Foo', + last_name: 'Bar', + gender: 'other', + }, + { + first_name: 'Baz', + last_name: 'Spam', + gender: 'other', + }, + ]) + + testSql(query, dialect, { + postgres: { + sql: 'insert into "person" ("first_name", "last_name", "gender") values ($1, $2, $3), ($4, $5, $6)', + parameters: ['Foo', 'Bar', 'other', 'Baz', 'Spam', 'other'], + }, + mysql: { + sql: 'insert into `person` (`first_name`, `last_name`, `gender`) values (?, ?, ?), (?, ?, ?)', + parameters: ['Foo', 'Bar', 'other', 'Baz', 'Spam', 'other'], + }, + sqlite: { + sql: 'insert into "person" ("first_name", "last_name", "gender") values (?, ?, ?), (?, ?, ?)', + parameters: ['Foo', 'Bar', 'other', 'Baz', 'Spam', 'other'], + }, + }) + + const result = await query.executeTakeFirst() + + expect(result).to.be.instanceOf(InsertResult) + expect(result.numInsertedOrUpdatedRows).to.equal(2n) + + if (dialect === 'postgres') { + expect(result.insertId).to.be.undefined + } else { + expect(result.insertId).to.be.a('bigint') + } + + const inserted = await ctx.db + .selectFrom('person') + .selectAll() + .orderBy('id', 'desc') + .limit(2) + .execute() + + expect(inserted).to.containSubset([ + { first_name: 'Foo', last_name: 'Bar', gender: 'other' }, + { first_name: 'Baz', last_name: 'Spam', gender: 'other' }, + ]) + }) + if (dialect === 'postgres' || dialect === 'sqlite') { - it('should insert multiple rows', async () => { + it('should insert multiple rows while falling back to default values in partial rows', async () => { const query = ctx.db .insertInto('person') .values([ @@ -522,10 +623,15 @@ for (const dialect of BUILT_IN_DIALECTS) { }) const result = await query.execute() + expect(result).to.have.length(2) + expect(result).to.containSubset([ + { first_name: 'Foo', last_name: null, gender: 'other' }, + { first_name: 'Baz', last_name: 'Spam', gender: 'other' }, + ]) }) - it('should return data using `returning`', async () => { + it('should insert a row and return data using `returning`', async () => { const result = await ctx.db .insertInto('person') .values({ @@ -552,7 +658,7 @@ for (const dialect of BUILT_IN_DIALECTS) { last_name: 'Barson', }) - it('conditional returning statement should add optional fields', async () => { + it('should insert a row, returning some fields of inserted row and conditionally returning additional fields', async () => { const condition = true const query = ctx.db @@ -566,11 +672,12 @@ for (const dialect of BUILT_IN_DIALECTS) { .if(condition, (qb) => qb.returning('last_name')) const result = await query.executeTakeFirstOrThrow() + expect(result.last_name).to.equal('Barson') }) }) - it('should return data using `returningAll`', async () => { + it('should insert a row and return data using `returningAll`', async () => { const result = await ctx.db .insertInto('person') .values({ diff --git a/test/node/src/log-once.test.ts b/test/node/src/log-once.test.ts new file mode 100644 index 000000000..e2d7c61c7 --- /dev/null +++ b/test/node/src/log-once.test.ts @@ -0,0 +1,27 @@ +import { expect } from 'chai' +import { createSandbox, SinonSpy } from 'sinon' +import { logOnce } from '../../..' + +describe('logOnce', () => { + let logSpy: SinonSpy + const sandbox = createSandbox() + + before(() => { + logSpy = sandbox.spy(console, 'log') + }) + + it('should log each message once.', () => { + const message = 'Kysely is awesome!' + const message2 = 'Type-safety is everything!' + + logOnce(message) + logOnce(message) + logOnce(message2) + logOnce(message2) + logOnce(message) + + expect(logSpy.calledTwice).to.be.true + expect(logSpy.getCall(0).args[0]).to.equal(message) + expect(logSpy.getCall(1).args[0]).to.equal(message2) + }) +}) diff --git a/test/node/src/update.test.ts b/test/node/src/update.test.ts index 1dba94f7a..5531aaaaa 100644 --- a/test/node/src/update.test.ts +++ b/test/node/src/update.test.ts @@ -113,7 +113,7 @@ for (const dialect of BUILT_IN_DIALECTS) { expect(person.last_name).to.equal('Catto') }) - it('should update update using a raw expression', async () => { + it('should update one row using a raw expression', async () => { const query = ctx.db .updateTable('person') .set({ @@ -136,10 +136,21 @@ for (const dialect of BUILT_IN_DIALECTS) { }, }) - await query.execute() + const result = await query.executeTakeFirst() + + expect(result).to.be.instanceOf(UpdateResult) + expect(result.numUpdatedRows).to.equal(1n) + + const jennifer = await ctx.db + .selectFrom('person') + .where('first_name', '=', 'Jennifer') + .select('last_name') + .executeTakeFirstOrThrow() + + expect(jennifer.last_name).to.equal('Jennifer') }) - it('undefined values should be ignored', async () => { + it('should update one row while ignoring undefined values', async () => { const query = ctx.db .updateTable('person') .set({ id: undefined, first_name: 'Foo', last_name: 'Barson' }) @@ -160,11 +171,25 @@ for (const dialect of BUILT_IN_DIALECTS) { }, }) - await query.execute() + const result = await query.executeTakeFirst() + + expect(result).to.be.instanceOf(UpdateResult) + expect(result.numUpdatedRows).to.equal(1n) + + const female = await ctx.db + .selectFrom('person') + .where('gender', '=', 'female') + .select(['first_name', 'last_name']) + .executeTakeFirstOrThrow() + + expect(female).to.deep.equal({ + first_name: 'Foo', + last_name: 'Barson', + }) }) if (dialect === 'postgres' || dialect === 'sqlite') { - it('should return updated rows when `returning` is used', async () => { + it('should update some rows and return updated rows when `returning` is used', async () => { const query = ctx.db .updateTable('person') .set({ last_name: 'Barson' }) @@ -196,7 +221,7 @@ for (const dialect of BUILT_IN_DIALECTS) { ]) }) - it('conditional returning statement should add optional fields', async () => { + it('should update all rows, returning some fields of updated rows, and conditionally returning additional fields', async () => { const condition = true const query = ctx.db @@ -206,10 +231,11 @@ for (const dialect of BUILT_IN_DIALECTS) { .if(condition, (qb) => qb.returning('last_name')) const result = await query.executeTakeFirstOrThrow() + expect(result.last_name).to.equal('Barson') }) - it('should join a table when `from` is called', async () => { + it('should update some rows and join a table when `from` is called', async () => { const query = ctx.db .updateTable('person') .from('pet') @@ -233,6 +259,7 @@ for (const dialect of BUILT_IN_DIALECTS) { }) const result = await query.execute() + expect(result[0].first_name).to.equal('Doggo') }) }