Skip to content

Commit

Permalink
feat(operators): Add $regexp operator (sequelize#7855)
Browse files Browse the repository at this point in the history
Adds $regexp operator for MySQL and Postgres and its negated and case-insensitive versions.
  • Loading branch information
Scott Rocha authored and felixfbecker committed Jul 9, 2017
1 parent 0528f39 commit 18e04a2
Show file tree
Hide file tree
Showing 9 changed files with 257 additions and 3 deletions.
4 changes: 4 additions & 0 deletions docs/querying.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,10 @@ $like: '%hat', // LIKE '%hat'
$notLike: '%hat' // NOT LIKE '%hat'
$iLike: '%hat' // ILIKE '%hat' (case insensitive) (PG only)
$notILike: '%hat' // NOT ILIKE '%hat' (PG only)
$regexp: '^[h|a|t]' // REGEXP/~ '^[h|a|t]' (MySQL/PG only)
$notRegexp: '^[h|a|t]' // NOT REGEXP/!~ '^[h|a|t]' (MySQL/PG only)
$iRegexp: '^[h|a|t]' // ~* '^[h|a|t]' (PG only)
$notIRegexp: '^[h|a|t]' // !~* '^[h|a|t]' (PG only)
$like: { $any: ['cat', 'hat']}
// LIKE ANY ARRAY['cat', 'hat'] - also works for iLike and notLike
$overlap: [1, 2] // && [1, 2] (PG array overlap operator)
Expand Down
19 changes: 19 additions & 0 deletions lib/dialects/abstract/query-generator.js
Original file line number Diff line number Diff line change
Expand Up @@ -1977,6 +1977,10 @@ const QueryGenerator = {
$notLike: 'NOT LIKE',
$iLike: 'ILIKE',
$notILike: 'NOT ILIKE',
$regexp: '~',
$notRegexp: '!~',
$iRegexp: '~*',
$notIRegexp: '!~*',
$between: 'BETWEEN',
$notBetween: 'NOT BETWEEN',
$overlap: '&&',
Expand Down Expand Up @@ -2282,7 +2286,20 @@ const QueryGenerator = {
comparator = 'IS NOT';
}

if (comparator.indexOf('~') !== -1) {
escapeValue = false;
}

if (this._dialect.name === 'mysql') {
if (comparator === '~') {
comparator = 'REGEXP';
} else if (comparator === '!~') {
comparator = 'NOT REGEXP';
}
}

escapeOptions.acceptStrings = comparator.indexOf('LIKE') !== -1;
escapeOptions.acceptRegExp = comparator.indexOf('~') !== -1 || comparator.indexOf('REGEXP') !== -1;

if (escapeValue) {
value = this.escape(value, field, escapeOptions);
Expand All @@ -2291,6 +2308,8 @@ const QueryGenerator = {
if (escapeOptions.acceptStrings && comparator.indexOf('ANY') > comparator.indexOf('LIKE')) {
value = '(' + value + ')';
}
} else if (escapeOptions.acceptRegExp) {
value = '\'' + value + '\'';
}
}

Expand Down
3 changes: 2 additions & 1 deletion lib/dialects/mysql/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,8 @@ MysqlDialect.prototype.supports = _.merge(_.cloneDeep(AbstractDialect.prototype.
updateOnDuplicate: true,
indexViaAlter: true,
NUMERIC: true,
GEOMETRY: true
GEOMETRY: true,
REGEXP: true
});

ConnectionManager.prototype.defaultVersion = '5.6.0';
Expand Down
1 change: 1 addition & 0 deletions lib/dialects/postgres/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ PostgresDialect.prototype.supports = _.merge(_.cloneDeep(AbstractDialect.prototy
ARRAY: true,
RANGE: true,
GEOMETRY: true,
REGEXP: true,
GEOGRAPHY: true,
JSON: true,
JSONB: true,
Expand Down
2 changes: 1 addition & 1 deletion lib/model.js
Original file line number Diff line number Diff line change
Expand Up @@ -1406,7 +1406,7 @@ class Model {
* ```sql
* WHERE attr1 > 50 AND attr2 <= 45 AND attr3 IN (1,2,3) AND attr4 != 5
* ```
* Possible options are: `$ne, $in, $not, $notIn, $gte, $gt, $lte, $lt, $like, $ilike/$iLike, $notLike, $notILike, '..'/$between, '!..'/$notBetween, '&&'/$overlap, '@>'/$contains, '<@'/$contained`
* Possible options are: `$ne, $in, $not, $notIn, $gte, $gt, $lte, $lt, $like, $ilike/$iLike, $notLike, $notILike, $regexp, $notRegexp, '..'/$between, '!..'/$notBetween, '&&'/$overlap, '@>'/$contains, '<@'/$contained`
*
* __Queries using OR__
* ```js
Expand Down
128 changes: 128 additions & 0 deletions test/integration/model/attributes/operators.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
'use strict';

const chai = require('chai'),
Sequelize = require('../../../../index'),
Promise = Sequelize.Promise,
expect = chai.expect,
Support = require(__dirname + '/../../support'),
DataTypes = require(__dirname + '/../../../../lib/data-types'),
dialect = Support.getTestDialect();

describe(Support.getTestDialectTeaser('Model'), () => {
describe('attributes', () => {
describe('operators', () => {
describe('REGEXP', () => {
beforeEach(function() {
const queryInterface = this.sequelize.getQueryInterface();

this.User = this.sequelize.define('user', {
id: {
type: DataTypes.INTEGER,
allowNull: false,
primaryKey: true,
autoIncrement: true,
field: 'userId'
},
name: {
type: DataTypes.STRING,
field: 'full_name'
}
}, {
tableName: 'users',
timestamps: false
});

return Promise.all([
queryInterface.createTable('users', {
userId: {
type: DataTypes.INTEGER,
allowNull: false,
primaryKey: true,
autoIncrement: true
},
full_name: {
type: DataTypes.STRING
}
})
]);
});

if (dialect === 'mysql' || dialect === 'postgres') {
it('should work with a regexp where', function() {
const self = this;

return this.User.create({
name: 'Foobar'
}).then(() => {
return self.User.find({
where: {
name: {
$regexp: '^Foo'
}
}
});
}).then(user => {
expect(user).to.be.ok;
});
});

it('should work with a not regexp where', function() {
const self = this;

return this.User.create({
name: 'Foobar'
}).then(() => {
return self.User.find({
where: {
name: {
$notRegexp: '^Foo'
}
}
});
}).then(user => {
expect(user).to.not.be.ok;
});
});

if (dialect === 'postgres') {
it('should work with a case-insensitive regexp where', function() {
const self = this;

return this.User.create({
name: 'Foobar'
}).then(() => {
return self.User.find({
where: {
name: {
$iRegexp: '^foo'
}
}
});
}).then(user => {
expect(user).to.be.ok;
});
});

it('should work with a case-insensitive not regexp where', function() {
const self = this;

return this.User.create({
name: 'Foobar'
}).then(() => {
return self.User.find({
where: {
name: {
$notIRegexp: '^foo'
}
}
});
}).then(user => {
expect(user).to.not.be.ok;
});
});
}
}
});
});
});
});
10 changes: 10 additions & 0 deletions test/unit/dialects/mysql/query-generator.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -381,6 +381,16 @@ if (dialect === 'mysql') {
arguments: ['myTable', {where: {field: {not: 3}}}],
expectation: 'SELECT * FROM `myTable` WHERE `myTable`.`field` != 3;',
context: QueryGenerator
}, {
title: 'Regular Expression in where clause',
arguments: ['myTable', {where: {field: {$regexp: '^[h|a|t]'}}}],
expectation: "SELECT * FROM `myTable` WHERE `myTable`.`field` REGEXP '^[h|a|t]';",
context: QueryGenerator
}, {
title: 'Regular Expression negation in where clause',
arguments: ['myTable', {where: {field: {$notRegexp: '^[h|a|t]'}}}],
expectation: "SELECT * FROM `myTable` WHERE `myTable`.`field` NOT REGEXP '^[h|a|t]';",
context: QueryGenerator
}
],

Expand Down
21 changes: 20 additions & 1 deletion test/unit/dialects/postgres/query-generator.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -502,6 +502,26 @@ if (dialect.match(/^postgres/)) {
arguments: ['myTable', {where: {field: {not: 3}}}],
expectation: 'SELECT * FROM myTable WHERE myTable.field != 3;',
context: {options: {quoteIdentifiers: false}}
}, {
title: 'Regular Expression in where clause',
arguments: ['myTable', {where: {field: {$regexp: '^[h|a|t]'}}}],
expectation: "SELECT * FROM \"myTable\" WHERE \"myTable\".\"field\" ~ '^[h|a|t]';",
context: QueryGenerator
}, {
title: 'Regular Expression negation in where clause',
arguments: ['myTable', {where: {field: {$notRegexp: '^[h|a|t]'}}}],
expectation: "SELECT * FROM \"myTable\" WHERE \"myTable\".\"field\" !~ '^[h|a|t]';",
context: QueryGenerator
}, {
title: 'Case-insensitive Regular Expression in where clause',
arguments: ['myTable', {where: {field: {$iRegexp: '^[h|a|t]'}}}],
expectation: "SELECT * FROM \"myTable\" WHERE \"myTable\".\"field\" ~* '^[h|a|t]';",
context: QueryGenerator
}, {
title: 'Case-insensitive Regular Expression negation in where clause',
arguments: ['myTable', {where: {field: {$notIRegexp: '^[h|a|t]'}}}],
expectation: "SELECT * FROM \"myTable\" WHERE \"myTable\".\"field\" !~* '^[h|a|t]';",
context: QueryGenerator
}
],

Expand Down Expand Up @@ -615,7 +635,6 @@ if (dialect.match(/^postgres/)) {
expectation: "INSERT INTO mySchema.myTable (name) VALUES ('foo'';DROP TABLE mySchema.myTable;');",
context: {options: {quoteIdentifiers: false}}
}

],

bulkInsertQuery: [
Expand Down
72 changes: 72 additions & 0 deletions test/unit/sql/where.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -908,6 +908,78 @@ suite(Support.getTestDialectTeaser('SQL'), () => {
});
}

if (current.dialect.supports.REGEXP) {
suite('$regexp', () => {
testsql('username', {
$regexp: '^sw.*r$'
}, {
mysql: "`username` REGEXP '^sw.*r$'",
postgres: '"username" ~ \'^sw.*r$\''
});
});

suite('$regexp', () => {
testsql('newline', {
$regexp: '^new\nline$'
}, {
mysql: "`newline` REGEXP '^new\nline$'",
postgres: '"newline" ~ \'^new\nline$\''
});
});

suite('$notRegexp', () => {
testsql('username', {
$notRegexp: '^sw.*r$'
}, {
mysql: "`username` NOT REGEXP '^sw.*r$'",
postgres: '"username" !~ \'^sw.*r$\''
});
});

suite('$notRegexp', () => {
testsql('newline', {
$notRegexp: '^new\nline$'
}, {
mysql: "`newline` NOT REGEXP '^new\nline$'",
postgres: '"newline" !~ \'^new\nline$\''
});
});

if (current.dialect.name === 'postgres') {
suite('$iRegexp', () => {
testsql('username', {
$iRegexp: '^sw.*r$'
}, {
postgres: '"username" ~* \'^sw.*r$\''
});
});

suite('$iRegexp', () => {
testsql('newline', {
$iRegexp: '^new\nline$'
}, {
postgres: '"newline" ~* \'^new\nline$\''
});
});

suite('$notIRegexp', () => {
testsql('username', {
$notIRegexp: '^sw.*r$'
}, {
postgres: '"username" !~* \'^sw.*r$\''
});
});

suite('$notIRegexp', () => {
testsql('newline', {
$notIRegexp: '^new\nline$'
}, {
postgres: '"newline" !~* \'^new\nline$\''
});
});
}
}

suite('fn', () => {
test('{name: this.sequelize.fn(\'LOWER\', \'DERP\')}', function() {
expectsql(sql.whereQuery({name: this.sequelize.fn('LOWER', 'DERP')}), {
Expand Down

0 comments on commit 18e04a2

Please sign in to comment.