Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
36 changes: 36 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion bin/orm-mysql.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
});

Expand Down
2 changes: 1 addition & 1 deletion commands/skills.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.`);
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@axiosleo/orm-mysql",
"version": "0.15.1",
"version": "0.15.2",
"description": "MySQL ORM tool",
"keywords": [
"mysql",
Expand Down
61 changes: 48 additions & 13 deletions src/builder.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -353,32 +367,48 @@ 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} (?)`;
}

_buildConditionContain(condition, isNot = false) {
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);
Expand All @@ -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);
Expand Down
109 changes: 107 additions & 2 deletions tests/builder.tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
16 changes: 8 additions & 8 deletions tests/query.tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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', () => {
Expand Down
Loading
Loading