From bf2a48233ee7821a4a12bc1684f7e3af496373dd Mon Sep 17 00:00:00 2001 From: axiosleo Date: Fri, 12 Jun 2026 14:36:02 +0800 Subject: [PATCH 1/3] feat(builder): add support for array placeholders in JSON conditions to enhance query flexibility --- src/builder.js | 61 +++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 48 insertions(+), 13 deletions(-) diff --git a/src/builder.js b/src/builder.js index 5bb0bb9..2eed10d 100644 --- a/src/builder.js +++ b/src/builder.js @@ -334,6 +334,20 @@ class Builder { return null; } + /** + * Bind each array element as a single scalar value and return the matching + * placeholder list (e.g. "?,?,?"). Binding the whole array as one value only + * works with conn.query (client-side interpolation); conn.execute (prepared + * statement, used on the transaction path) does not expand arrays and would + * silently match nothing. + * @param {Array} values + * @returns {string} + */ + _buildArrayPlaceholders(values) { + values.forEach((item) => this.values.push(item)); + return values.map(() => '?').join(','); + } + _buildConditionBetween(condition, isNot = false) { if (!Array.isArray(condition.value) || condition.value.length !== 2) { throw new Error('Value must be an array with two elements for "BETWEEN" condition'); @@ -353,22 +367,33 @@ class Builder { } _buildConditionIn(condition, isNot = false) { - if (Array.isArray(condition.value) && !condition.value.length) { + // "1,2,3" is split into an array, then goes through the same expansion path as arrays + let v = is.string(condition.value) + ? condition.value.split(',').map(s => s.trim()).filter(s => s.length) + : condition.value; + if (Array.isArray(v) && !v.length) { throw new Error('Value must not be empty for "IN" condition'); - } else if (!Array.isArray(condition.value) && !(condition.value instanceof Query)) { + } else if (!Array.isArray(v) && !(v instanceof Query)) { throw new Error('Value must be an array or sub-query for "IN" condition'); } if (condition.key.indexOf('->') !== -1) { let keys = condition.key.split('->'); let k = `${this._buildFieldKey(keys[0])}`; - let res = this._buildConditionValues(condition.value); - let sql = res ? `JSON_CONTAINS(JSON_ARRAY(${res}), JSON_EXTRACT(${k}, '${keys[1]}'))` : - `JSON_CONTAINS(JSON_ARRAY(?), JSON_EXTRACT(${k}, '${keys[1]}'))`; + let sql; + if (Array.isArray(v)) { + sql = `JSON_CONTAINS(JSON_ARRAY(${this._buildArrayPlaceholders(v)}), JSON_EXTRACT(${k}, '${keys[1]}'))`; + } else { + let res = this._buildConditionValues(v); + sql = res ? `JSON_CONTAINS(JSON_ARRAY(${res}), JSON_EXTRACT(${k}, '${keys[1]}'))` : + `JSON_CONTAINS(JSON_ARRAY(?), JSON_EXTRACT(${k}, '${keys[1]}'))`; + } return isNot ? `${sql}=0` : sql; } - let v = is.string(condition.value) ? condition.value.split(',').map(v => v.trim()) : condition.value; - let res = this._buildConditionValues(v); const opt = isNot ? 'NOT IN' : 'IN'; + if (Array.isArray(v)) { + return `${this._buildFieldKey(condition.key)} ${opt} (${this._buildArrayPlaceholders(v)})`; + } + let res = this._buildConditionValues(v); return res ? `${this._buildFieldKey(condition.key)} ${opt} (${res})` : `${this._buildFieldKey(condition.key)} ${opt} (?)`; } @@ -376,9 +401,14 @@ class Builder { if (condition.key.indexOf('->') !== -1) { let keys = condition.key.split('->'); let k = `${this._buildFieldKey(keys[0])}`; - let res = this._buildConditionValues(condition.value); - let sql = res ? `JSON_CONTAINS(${k}, JSON_ARRAY(${res}), '${keys[1]}')` : - `JSON_CONTAINS(${k}, JSON_ARRAY(?), '${keys[1]}')`; + let sql; + if (Array.isArray(condition.value)) { + sql = `JSON_CONTAINS(${k}, JSON_ARRAY(${this._buildArrayPlaceholders(condition.value)}), '${keys[1]}')`; + } else { + let res = this._buildConditionValues(condition.value); + sql = res ? `JSON_CONTAINS(${k}, JSON_ARRAY(${res}), '${keys[1]}')` : + `JSON_CONTAINS(${k}, JSON_ARRAY(?), '${keys[1]}')`; + } return isNot ? `${sql}=0` : sql; } let res = this._buildConditionValues(condition.value); @@ -390,9 +420,14 @@ class Builder { if (condition.key.indexOf('->') !== -1) { let keys = condition.key.split('->'); let k = `${this._buildFieldKey(keys[0])}`; - let res = this._buildConditionValues(condition.value); - let sql = res ? `JSON_OVERLAPS(JSON_EXTRACT(${k}, '${keys[1]}'), JSON_ARRAY(${res}))` : - `JSON_OVERLAPS(JSON_EXTRACT(${k}, '${keys[1]}'), JSON_ARRAY(?))`; + let sql; + if (Array.isArray(condition.value)) { + sql = `JSON_OVERLAPS(JSON_EXTRACT(${k}, '${keys[1]}'), JSON_ARRAY(${this._buildArrayPlaceholders(condition.value)}))`; + } else { + let res = this._buildConditionValues(condition.value); + sql = res ? `JSON_OVERLAPS(JSON_EXTRACT(${k}, '${keys[1]}'), JSON_ARRAY(${res}))` : + `JSON_OVERLAPS(JSON_EXTRACT(${k}, '${keys[1]}'), JSON_ARRAY(?))`; + } return isNot ? `${sql}=0` : sql; } let res = this._buildConditionValues(condition.value); From 9700647f07c8d36b36c4fdf3d4eb2eda25a20e64 Mon Sep 17 00:00:00 2001 From: axiosleo Date: Fri, 12 Jun 2026 14:36:49 +0800 Subject: [PATCH 2/3] fix(tests): enhance regression tests for IN/NOT IN conditions in transactions, ensuring correct placeholder expansion and value binding --- tests/builder.tests.js | 109 +++++++++++++++- tests/query.tests.js | 16 +-- tests/transaction-wherein.tests.js | 193 +++++++++++++++++++++++++++++ tests/transaction.ft.js | 92 +++++++++++++- 4 files changed, 399 insertions(+), 11 deletions(-) create mode 100644 tests/transaction-wherein.tests.js diff --git a/tests/builder.tests.js b/tests/builder.tests.js index 505d423..7a28ce3 100644 --- a/tests/builder.tests.js +++ b/tests/builder.tests.js @@ -429,9 +429,114 @@ describe('builder test case', () => { tables: [{ table: 'table1' }], operator: 'select' }; - expect((new Builder(options)).sql).to.be.equal( - 'SELECT * FROM `table1` WHERE `id` IN (?)' + const builder = new Builder(options); + expect(builder.sql).to.be.equal( + 'SELECT * FROM `table1` WHERE `id` IN (?,?,?)' ); + expect(builder.values).to.deep.equal([1, 2, 3]); + }); + + describe('IN condition placeholder expansion (regression for transaction/execute path)', () => { + const buildOptions = (conditions) => ({ + sql: '', + values: [], + conditions, + tables: [{ table: 'table1' }], + operator: 'select' + }); + + it('should expand array value into one placeholder per element', () => { + const builder = new Builder(buildOptions([{ key: 'id', opt: 'in', value: [1, 2] }])); + expect(builder.sql).to.be.equal('SELECT * FROM `table1` WHERE `id` IN (?,?)'); + expect(builder.values).to.deep.equal([1, 2]); + builder.values.forEach((v) => expect(v).to.not.be.an('array')); + }); + + it('should expand single element array', () => { + const builder = new Builder(buildOptions([{ key: 'id', opt: 'in', value: [42] }])); + expect(builder.sql).to.be.equal('SELECT * FROM `table1` WHERE `id` IN (?)'); + expect(builder.values).to.deep.equal([42]); + }); + + it('should expand NOT IN the same way', () => { + const builder = new Builder(buildOptions([{ key: 'id', opt: 'not in', value: [1, 2, 3] }])); + expect(builder.sql).to.be.equal('SELECT * FROM `table1` WHERE `id` NOT IN (?,?,?)'); + expect(builder.values).to.deep.equal([1, 2, 3]); + }); + + it('should split comma separated string and bind each element', () => { + const builder = new Builder(buildOptions([{ key: 'name', opt: 'in', value: 'a, b' }])); + expect(builder.sql).to.be.equal('SELECT * FROM `table1` WHERE `name` IN (?,?)'); + expect(builder.values).to.deep.equal(['a', 'b']); + }); + + it('should throw for empty array', () => { + expect(() => { + new Builder(buildOptions([{ key: 'id', opt: 'in', value: [] }])); + }).to.throw('Value must not be empty for "IN" condition'); + }); + + it('should throw for empty string', () => { + expect(() => { + new Builder(buildOptions([{ key: 'id', opt: 'in', value: '' }])); + }).to.throw('Value must not be empty for "IN" condition'); + }); + + it('should throw for non-array, non-string, non-query value', () => { + expect(() => { + new Builder(buildOptions([{ key: 'id', opt: 'in', value: 1 }])); + }).to.throw('Value must be an array or sub-query for "IN" condition'); + }); + + it('should keep sub-query behavior unchanged', () => { + const subQuery = new Query('select'); + subQuery.table('table2').attr('id').where('status', 1); + const builder = new Builder(buildOptions([{ key: 'id', opt: 'in', value: subQuery }])); + expect(builder.sql).to.be.equal( + 'SELECT * FROM `table1` WHERE `id` IN (SELECT `id` FROM `table2` WHERE `status` = ?)' + ); + expect(builder.values).to.deep.equal([1]); + }); + + it('should expand array inside JSON_ARRAY for JSON path IN condition', () => { + const builder = new Builder(buildOptions([{ key: 'data->$.id', opt: 'in', value: [1, 2] }])); + expect(builder.sql).to.be.equal( + 'SELECT * FROM `table1` WHERE JSON_CONTAINS(JSON_ARRAY(?,?), JSON_EXTRACT(`data`, \'$.id\'))' + ); + expect(builder.values).to.deep.equal([1, 2]); + }); + + it('should expand array inside JSON_ARRAY for JSON path NOT IN condition', () => { + const builder = new Builder(buildOptions([{ key: 'data->$.id', opt: 'not in', value: [1, 2] }])); + expect(builder.sql).to.be.equal( + 'SELECT * FROM `table1` WHERE JSON_CONTAINS(JSON_ARRAY(?,?), JSON_EXTRACT(`data`, \'$.id\'))=0' + ); + expect(builder.values).to.deep.equal([1, 2]); + }); + + it('should expand array inside JSON_ARRAY for JSON path CONTAIN condition', () => { + const builder = new Builder(buildOptions([{ key: 'tags->$', opt: 'contain', value: ['a', 'b'] }])); + expect(builder.sql).to.be.equal( + 'SELECT * FROM `table1` WHERE JSON_CONTAINS(`tags`, JSON_ARRAY(?,?), \'$\')' + ); + expect(builder.values).to.deep.equal(['a', 'b']); + }); + + it('should expand array inside JSON_ARRAY for JSON path OVERLAPS condition', () => { + const builder = new Builder(buildOptions([{ key: 'data->$.tags', opt: 'overlaps', value: ['a', 'b', 'c'] }])); + expect(builder.sql).to.be.equal( + 'SELECT * FROM `table1` WHERE JSON_OVERLAPS(JSON_EXTRACT(`data`, \'$.tags\'), JSON_ARRAY(?,?,?))' + ); + expect(builder.values).to.deep.equal(['a', 'b', 'c']); + }); + + it('should keep scalar value behavior for JSON path CONTAIN condition', () => { + const builder = new Builder(buildOptions([{ key: 'tags->$', opt: 'contain', value: 'a' }])); + expect(builder.sql).to.be.equal( + 'SELECT * FROM `table1` WHERE JSON_CONTAINS(`tags`, JSON_ARRAY(?), \'$\')' + ); + expect(builder.values).to.deep.equal(['a']); + }); }); it('test condition with BETWEEN operator', () => { diff --git a/tests/query.tests.js b/tests/query.tests.js index 285d92f..c3868f4 100644 --- a/tests/query.tests.js +++ b/tests/query.tests.js @@ -96,15 +96,15 @@ describe('query test case', () => { query = handler.table('users', 'u'); query.where('u.meta->$.id', 'in', [1, 2, 3]); let res = query.buildSql('select'); - expect(res.sql).to.be.equal('SELECT * FROM `users` AS `u` WHERE JSON_CONTAINS(JSON_ARRAY(?), JSON_EXTRACT(`u`.`meta`, \'$.id\'))'); - expect(JSON.stringify(res.values)).to.be.equal('[[1,2,3]]'); + expect(res.sql).to.be.equal('SELECT * FROM `users` AS `u` WHERE JSON_CONTAINS(JSON_ARRAY(?,?,?), JSON_EXTRACT(`u`.`meta`, \'$.id\'))'); + expect(JSON.stringify(res.values)).to.be.equal('[1,2,3]'); // opt=not in query = handler.table('users', 'u'); query.where('u.meta->$.id', 'not in', [1, 2, 3]); res = query.buildSql('select'); - expect(res.sql).to.be.equal('SELECT * FROM `users` AS `u` WHERE JSON_CONTAINS(JSON_ARRAY(?), JSON_EXTRACT(`u`.`meta`, \'$.id\'))=0'); - expect(JSON.stringify(res.values)).to.be.equal('[[1,2,3]]'); + expect(res.sql).to.be.equal('SELECT * FROM `users` AS `u` WHERE JSON_CONTAINS(JSON_ARRAY(?,?,?), JSON_EXTRACT(`u`.`meta`, \'$.id\'))=0'); + expect(JSON.stringify(res.values)).to.be.equal('[1,2,3]'); // opt=contain query = handler.table('users', 'u'); @@ -126,15 +126,15 @@ describe('query test case', () => { let query = handler.table('users', 'u'); query.where('u.meta', 'in', [1, 2, 3]); let res = query.buildSql('select'); - expect(res.sql).to.be.equal('SELECT * FROM `users` AS `u` WHERE `u`.`meta` IN (?)'); - expect(JSON.stringify(res.values)).to.be.equal('[[1,2,3]]'); + expect(res.sql).to.be.equal('SELECT * FROM `users` AS `u` WHERE `u`.`meta` IN (?,?,?)'); + expect(JSON.stringify(res.values)).to.be.equal('[1,2,3]'); // opt=not in query = handler.table('users', 'u'); query.where('u.meta', 'not in', [1, 2, 3]); res = query.buildSql('select'); - expect(res.sql).to.be.equal('SELECT * FROM `users` AS `u` WHERE `u`.`meta` NOT IN (?)'); - expect(JSON.stringify(res.values)).to.be.equal('[[1,2,3]]'); + expect(res.sql).to.be.equal('SELECT * FROM `users` AS `u` WHERE `u`.`meta` NOT IN (?,?,?)'); + expect(JSON.stringify(res.values)).to.be.equal('[1,2,3]'); }); it('contain condition', () => { diff --git a/tests/transaction-wherein.tests.js b/tests/transaction-wherein.tests.js new file mode 100644 index 0000000..6186c59 --- /dev/null +++ b/tests/transaction-wherein.tests.js @@ -0,0 +1,193 @@ +'use strict'; + +/** + * Regression tests for whereIn / whereNotIn / where(field, 'IN', array) + * on the transaction path. + * + * Background: TransactionOperator sets options.transaction = true, which makes + * core._query() use conn.execute() (server-side prepared statement) instead of + * conn.query() (client-side interpolation). Prepared statements bind exactly one + * scalar per placeholder and never expand arrays, so `IN (?)` bound with [1, 2] + * silently matched zero rows before the fix. The builder must therefore emit one + * placeholder per element with flat scalar values. + */ + +let expect = null; +const { QueryOperator } = require('../src/operator'); +const { TransactionHandler } = require('../src/transaction'); + +const FIXTURE_ROWS = [ + { id: 1, name: 'alice' }, + { id: 2, name: 'bob' }, + { id: 3, name: 'carol' } +]; + +/** + * Mock of a mysql2/promise connection whose execute() mimics real prepared + * statement behavior: it never expands array parameters. If a bound value is + * an array (the old buggy binding), it silently returns an empty result set, + * exactly like the observed production behavior. + */ +function createMockConn(captured) { + const evaluate = (sql, values) => { + const placeholders = (sql.match(/\?/g) || []).length; + if (placeholders !== values.length || values.some((v) => Array.isArray(v))) { + return []; + } + const m = sql.match(/WHERE `(\w+)` (NOT IN|IN) \((\?(,\?)*)\)/); + if (!m) { + return []; + } + const field = m[1]; + const isNot = m[2] === 'NOT IN'; + const bound = values.slice(values.length - m[3].split(',').length); + return FIXTURE_ROWS.filter((row) => isNot ? !bound.includes(row[field]) : bound.includes(row[field])); + }; + return { + beginTransaction: async () => { }, + commit: async () => { }, + rollback: async () => { }, + execute: async (sql, values = []) => { + captured.push({ sql, values }); + if (sql.startsWith('SET TRANSACTION')) { + return [[]]; + } + if (sql.startsWith('UPDATE') || sql.startsWith('DELETE')) { + return [{ affectedRows: evaluate(sql, values).length }]; + } + return [evaluate(sql, values)]; + } + }; +} + +describe('whereIn on transaction path (regression)', () => { + before(async function () { + const chai = await import('chai'); + expect = chai.expect; + }); + + describe('builder output via notExec()', () => { + const conn = { query: () => { }, execute: () => { } }; + + it('whereIn with array should expand placeholders and bind scalars', async () => { + const builder = await new QueryOperator(conn) + .table('users').whereIn('id', [1, 2]).notExec().select(); + expect(builder.sql).to.be.equal('SELECT * FROM `users` WHERE `id` IN (?,?)'); + expect(builder.values).to.deep.equal([1, 2]); + }); + + it('whereNotIn with array should expand placeholders', async () => { + const builder = await new QueryOperator(conn) + .table('users').whereNotIn('id', [1, 2]).notExec().select(); + expect(builder.sql).to.be.equal('SELECT * FROM `users` WHERE `id` NOT IN (?,?)'); + expect(builder.values).to.deep.equal([1, 2]); + }); + + it('where(field, "IN", array) should behave the same as whereIn', async () => { + const builder = await new QueryOperator(conn) + .table('users').where('id', 'IN', [1, 2]).notExec().select(); + expect(builder.sql).to.be.equal('SELECT * FROM `users` WHERE `id` IN (?,?)'); + expect(builder.values).to.deep.equal([1, 2]); + }); + + it('whereIn with comma separated string should split into scalars', async () => { + const builder = await new QueryOperator(conn) + .table('users').whereIn('name', 'alice, bob').notExec().select(); + expect(builder.sql).to.be.equal('SELECT * FROM `users` WHERE `name` IN (?,?)'); + expect(builder.values).to.deep.equal(['alice', 'bob']); + }); + + it('whereIn with single element array should produce one placeholder', async () => { + const builder = await new QueryOperator(conn) + .table('users').whereIn('id', [2]).notExec().select(); + expect(builder.sql).to.be.equal('SELECT * FROM `users` WHERE `id` IN (?)'); + expect(builder.values).to.deep.equal([2]); + }); + + it('whereIn with empty array should still throw', async () => { + try { + await new QueryOperator(conn).table('users').whereIn('id', []).notExec().select(); + expect.fail('Should have thrown an error'); + } catch (err) { + expect(err.message).to.be.equal('Value must not be empty for "IN" condition'); + } + }); + }); + + describe('TransactionHandler with mocked prepared-statement execute', () => { + /** + * @returns {Promise<{tx: TransactionHandler, captured: Array}>} + */ + const beginTx = async () => { + const captured = []; + const tx = new TransactionHandler(createMockConn(captured)); + await tx.begin(); + return { tx, captured }; + }; + + it('whereIn SELECT should return matching rows', async () => { + const { tx, captured } = await beginTx(); + const rows = await tx.table('users').whereIn('id', [1, 2]).select(); + expect(rows).to.have.lengthOf(2); + expect(rows.map((r) => r.id)).to.deep.equal([1, 2]); + const last = captured[captured.length - 1]; + expect(last.sql).to.be.equal('SELECT * FROM `users` WHERE `id` IN (?,?)'); + expect(last.values).to.deep.equal([1, 2]); + last.values.forEach((v) => expect(v).to.not.be.an('array')); + await tx.rollback(); + }); + + it('whereNotIn SELECT should return non-matching rows', async () => { + const { tx } = await beginTx(); + const rows = await tx.table('users').whereNotIn('id', [1, 2]).select(); + expect(rows).to.have.lengthOf(1); + expect(rows[0].id).to.be.equal(3); + await tx.rollback(); + }); + + it('where(field, "IN", array) SELECT should return matching rows', async () => { + const { tx } = await beginTx(); + const rows = await tx.table('users').where('id', 'IN', [2, 3]).select(); + expect(rows).to.have.lengthOf(2); + await tx.rollback(); + }); + + it('whereIn with comma separated string should return matching rows', async () => { + const { tx, captured } = await beginTx(); + const rows = await tx.table('users').whereIn('name', 'alice,bob').select(); + expect(rows).to.have.lengthOf(2); + const last = captured[captured.length - 1]; + expect(last.values).to.deep.equal(['alice', 'bob']); + await tx.rollback(); + }); + + it('whereIn with single element array should return one row', async () => { + const { tx } = await beginTx(); + const rows = await tx.table('users').whereIn('id', [2]).select(); + expect(rows).to.have.lengthOf(1); + expect(rows[0].name).to.be.equal('bob'); + await tx.rollback(); + }); + + it('whereIn UPDATE should affect matching rows', async () => { + const { tx, captured } = await beginTx(); + const res = await tx.table('users').whereIn('id', [1, 2]).update({ name: 'updated' }); + expect(res.affectedRows).to.be.equal(2); + const last = captured[captured.length - 1]; + expect(last.sql).to.be.equal('UPDATE `users` SET `name` = ? WHERE `id` IN (?,?)'); + expect(last.values).to.deep.equal(['updated', 1, 2]); + await tx.commit(); + }); + + it('transaction path and non-transaction builder should produce identical sql/values', async () => { + const { tx, captured } = await beginTx(); + await tx.table('users').whereIn('id', [1, 2]).select(); + const txCall = captured[captured.length - 1]; + const builder = await new QueryOperator({ query: () => { } }) + .table('users').whereIn('id', [1, 2]).notExec().select(); + expect(txCall.sql).to.be.equal(builder.sql); + expect(txCall.values).to.deep.equal(builder.values); + await tx.rollback(); + }); + }); +}); diff --git a/tests/transaction.ft.js b/tests/transaction.ft.js index 84b231b..71ebbee 100644 --- a/tests/transaction.ft.js +++ b/tests/transaction.ft.js @@ -418,6 +418,94 @@ async function test5_transfer() { } } +// 测试场景 6: 事务内 whereIn 条件(0.15.2 回归测试) +async function test6_whereInTransaction() { + section('测试场景 6: 事务内 whereIn 条件(回归测试)'); + + const pool = mysql.createPool(config); + const queryHandler = new QueryHandler(pool); + + try { + info('插入 3 个测试用户...'); + const ts = Date.now(); + const ids = []; + for (let i = 1; i <= 3; i++) { + const res = await queryHandler.table('users').insert({ + name: `WhereIn User ${i}`, + email: `wherein_${ts}_${i}@example.com`, + balance: 100.00 * i + }); + ids.push(res.insertId); + } + success(`用户插入成功,IDs: ${ids.join(', ')}`); + + info('非事务 whereIn 查询(基准)...'); + const baseline = await queryHandler.table('users').whereIn('id', ids).select(); + if (baseline.length !== 3) { + throw new Error(`非事务 whereIn 返回 ${baseline.length} 行,预期 3 行`); + } + success(`非事务 whereIn 返回 ${baseline.length} 行`); + + info('开始事务...'); + const tx = await queryHandler.beginTransaction({ level: 'RC' }); + try { + info('事务内 whereIn SELECT...'); + const rows = await tx.table('users').whereIn('id', ids).select(); + if (rows.length !== 3) { + throw new Error(`事务内 whereIn 返回 ${rows.length} 行,预期 3 行(修复前会静默返回 0 行)`); + } + success(`事务内 whereIn 返回 ${rows.length} 行(与非事务一致)`); + + info('事务内 whereNotIn SELECT...'); + const notRows = await tx.table('users') + .whereIn('id', ids).whereNotIn('id', [ids[0]]).select(); + if (notRows.length !== 2) { + throw new Error(`事务内 whereNotIn 返回 ${notRows.length} 行,预期 2 行`); + } + success(`事务内 whereNotIn 返回 ${notRows.length} 行`); + + info('事务内 where(field, "IN", array) SELECT...'); + const optRows = await tx.table('users').where('id', 'IN', [ids[0], ids[1]]).select(); + if (optRows.length !== 2) { + throw new Error(`事务内 where IN 返回 ${optRows.length} 行,预期 2 行`); + } + success(`事务内 where(field, "IN", array) 返回 ${optRows.length} 行`); + + info('事务内基于 whereIn 的 UPDATE...'); + const updateRes = await tx.table('users') + .whereIn('id', [ids[0], ids[1]]) + .update({ balance: 999.99 }); + if (updateRes.affectedRows !== 2) { + throw new Error(`事务内 whereIn UPDATE 影响 ${updateRes.affectedRows} 行,预期 2 行(修复前会影响 0 行)`); + } + success(`事务内 whereIn UPDATE 影响 ${updateRes.affectedRows} 行`); + + info('回滚事务...'); + await tx.rollback(); + success('事务已回滚'); + } catch (err) { + await tx.rollback(); + throw err; + } + + info('验证回滚后数据未变...'); + const after = await queryHandler.table('users').whereIn('id', ids).select(); + const changed = after.filter((u) => parseFloat(u.balance) === 999.99); + if (changed.length === 0) { + success('✓ 回滚验证成功(UPDATE 未生效)'); + } else { + error('✗ 回滚验证失败'); + } + + success('✓ 测试场景 6 完成\n'); + } catch (err) { + error(`测试失败: ${err.message}`); + throw err; + } finally { + await pool.end(); + } +} + // 主测试函数 async function runAllTests() { console.log('\n'); @@ -431,7 +519,8 @@ async function runAllTests() { { name: '测试场景 2: 并发事务不阻塞', fn: test2_concurrentTransactions }, { name: '测试场景 3: 事务回滚', fn: test3_rollback }, { name: '测试场景 4: 库存扣减场景(行锁)', fn: test4_stockDeduction }, - { name: '测试场景 5: 转账场景(多表事务)', fn: test5_transfer } + { name: '测试场景 5: 转账场景(多表事务)', fn: test5_transfer }, + { name: '测试场景 6: 事务内 whereIn 条件(回归测试)', fn: test6_whereInTransaction } ]; let passed = 0; @@ -492,6 +581,7 @@ module.exports = { test3_rollback, test4_stockDeduction, test5_transfer, + test6_whereInTransaction, runAllTests }; From bc4170036f86d2cfa9a0915137d1ade2c96bbfbc Mon Sep 17 00:00:00 2001 From: axiosleo Date: Fri, 12 Jun 2026 14:38:18 +0800 Subject: [PATCH 3/3] Bump 0.15.2 --- CHANGELOG.md | 36 ++++++++++++++++++++++++++++++++++++ bin/orm-mysql.js | 2 +- commands/skills.js | 2 +- package.json | 2 +- 4 files changed, 39 insertions(+), 3 deletions(-) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..40a2fa3 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,36 @@ +# Changelog + +## 0.15.2 (2026-06-12) + +### Fixed + +- **Data correctness: `IN` conditions silently matched zero rows on the transaction path.** + + In 0.15.0 / 0.15.1, `whereIn()` / `whereNotIn()` / `where(field, 'IN', array)` executed + through `TransactionHandler` silently returned an empty result set (SELECT) or affected + 0 rows (UPDATE / DELETE), without any error. The same query worked fine through + `QueryHandler`. + + Root cause: the SQL builder bound the whole array as a single value, producing + `WHERE \`id\` IN (?)` with values `[[1, 2]]`. The non-transaction path uses + `conn.query()` (client-side interpolation), which happens to expand array parameters + into `IN (1, 2)`. The transaction path uses `conn.execute()` (server-side prepared + statement), which never expands arrays, so the condition matched nothing. + + Fix: `Builder` now emits one placeholder per array element (`IN (?,?)`) and binds flat + scalar values, so `query` and `execute` behave identically. The transaction path keeps + using prepared statements (`conn.execute`). + +- The same array-as-single-bind pattern was fixed in the JSON branches: `key->'$.path'` + with `IN` / `NOT IN`, `CONTAIN` / `NOT CONTAIN` and `OVERLAPS` / `NOT OVERLAPS` now + expand arrays into `JSON_ARRAY(?,?,...)` with scalar bindings. + +- Comma-separated string values for `IN` (e.g. `whereIn('id', '1,2,3')`, documented in + `index.d.ts`) previously threw `Value must be an array or sub-query for "IN" condition`; + they are now split, trimmed and expanded like arrays. + +### Unchanged behavior + +- Empty arrays (and now empty strings) for `IN` still throw + `Value must not be empty for "IN" condition`. +- `Query` sub-queries for `IN` still render the sub-select SQL. diff --git a/bin/orm-mysql.js b/bin/orm-mysql.js index 4587563..26bcaa4 100755 --- a/bin/orm-mysql.js +++ b/bin/orm-mysql.js @@ -9,7 +9,7 @@ const app = new App({ name: 'MySQL ORM CLI', desc: 'migrate, model, seed, etc.', bin: 'orm-mysql', - version: '0.15.1', + version: '0.15.2', commands_dir: path.join(__dirname, '../commands'), }); diff --git a/commands/skills.js b/commands/skills.js index c0f8a54..e477fdc 100644 --- a/commands/skills.js +++ b/commands/skills.js @@ -118,7 +118,7 @@ class SkillsCommand extends Command { printer.info(`Found ${PKG_NAME}@${source.version} in node_modules`); } else if (source.outdated) { printer.warning(`${PKG_NAME}@${source.localVersion} is installed locally but does not include skills files.`); - printer.warning('Skills files are available since v0.15.1. Please update:'); + printer.warning('Skills files are available since v0.15.2. Please update:'); printer.warning(` npm install ${PKG_NAME}@latest`); printer.println(); printer.info(`Using skills from npx ${PKG_NAME}@${source.version} instead.`); diff --git a/package.json b/package.json index 349a3f2..cda5fcc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@axiosleo/orm-mysql", - "version": "0.15.1", + "version": "0.15.2", "description": "MySQL ORM tool", "keywords": [ "mysql",